Files
kandev/apps/backend/internal/task/handlers/task_delete_authz_test.go
T

270 lines
9.3 KiB
Go

package handlers
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"sync"
"testing"
"github.com/gin-gonic/gin"
"github.com/kandev/kandev/internal/auth/authn"
"github.com/kandev/kandev/internal/task/models"
"github.com/kandev/kandev/internal/task/service"
ws "github.com/kandev/kandev/pkg/websocket"
)
// DELETE /api/v1/tasks/:id built its context with context.WithTimeout(
// context.Background(), …). context.Background() carries no identity, so the
// service's authorizeTaskID saw an identity-less internal caller and permitted
// deleting anyone's task. The sibling archive route uses c.Request.Context() and
// answers 404, which is the shape of the fix — but the request context is
// cancelled when the client disconnects, and this route detaches from that on
// purpose, so the fix is context.WithoutCancel: keep the values, drop the cancel.
//
// Symbols here are authz-prefixed so they cannot collide with the fixtures PR
// #2500 adds to this package while it is unmerged.
// authzDeleteRepo serves one task owned by user-b and records every delete the
// handler managed to issue.
type authzDeleteRepo struct {
mockRepository
mu sync.Mutex
// deleted records each delete alongside the liveness of the context it
// arrived with. A cancelled context means the caller handed us one that a
// client disconnect had already killed.
deleted []string
deleteErr []error
archived []string
}
func (r *authzDeleteRepo) GetTask(_ context.Context, id string) (*models.Task, error) {
return &models.Task{ID: id, WorkspaceID: "ws-b", Title: "Victim"}, nil
}
func (r *authzDeleteRepo) GetWorkspace(_ context.Context, id string) (*models.Workspace, error) {
return &models.Workspace{ID: id, Name: "B's", OwnerID: "user-b"}, nil
}
func (r *authzDeleteRepo) ArchiveTaskIfActive(_ context.Context, id, cascadeID string) (bool, error) {
r.mu.Lock()
defer r.mu.Unlock()
r.archived = append(r.archived, id+"/"+cascadeID)
return true, nil
}
func (r *authzDeleteRepo) ArchiveTaskIfActiveWithVacatedStep(
ctx context.Context,
id string,
cascadeID string,
) (string, bool, error) {
changed, err := r.ArchiveTaskIfActive(ctx, id, cascadeID)
return "", changed, err
}
func (r *authzDeleteRepo) archives() []string {
r.mu.Lock()
defer r.mu.Unlock()
return append([]string(nil), r.archived...)
}
func (r *authzDeleteRepo) DeleteTask(ctx context.Context, id string) error {
r.mu.Lock()
defer r.mu.Unlock()
r.deleted = append(r.deleted, id)
r.deleteErr = append(r.deleteErr, ctx.Err())
return nil
}
func (r *authzDeleteRepo) DeleteTaskWithVacatedStep(ctx context.Context, id string) (string, error) {
return "", r.DeleteTask(ctx, id)
}
func (r *authzDeleteRepo) ListChildren(context.Context, string) ([]*models.Task, error) {
return nil, nil
}
func (r *authzDeleteRepo) ListChildrenIncludingArchived(context.Context, string) ([]*models.Task, error) {
return nil, nil
}
func (r *authzDeleteRepo) CountToolCallMessagesBySession(context.Context, []string) (map[string]int, error) {
return nil, nil
}
func (r *authzDeleteRepo) deletes() []string {
r.mu.Lock()
defer r.mu.Unlock()
return append([]string(nil), r.deleted...)
}
// deliveredLive reports whether every recorded delete arrived on a context that
// was still alive. A real repository would abort on a cancelled one; the fake
// cannot, so the liveness is asserted directly.
func (r *authzDeleteRepo) deliveredLive() bool {
r.mu.Lock()
defer r.mu.Unlock()
for _, err := range r.deleteErr {
if err != nil {
return false
}
}
return len(r.deleteErr) > 0
}
func newAuthzTaskHandlers(t *testing.T, repo *authzDeleteRepo) *TaskHandlers {
t.Helper()
log := newTestLogger(t)
svc := service.NewService(service.Repos{
Workspaces: repo, Tasks: repo, TaskRepos: repo,
Workflows: repo, Messages: repo, Turns: repo,
Sessions: repo, GitSnapshots: repo, RepoEntities: repo,
Executors: repo, Environments: repo, TaskEnvironments: repo,
Reviews: repo,
}, nil, log, service.RepositoryDiscoveryConfig{})
return &TaskHandlers{service: svc, repo: repo, logger: log}
}
// authzDeleteRequest builds a DELETE for taskID carrying userID's identity.
func authzDeleteRequest(t *testing.T, userID, taskID string) (*gin.Context, *httptest.ResponseRecorder) {
t.Helper()
gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
req := httptest.NewRequest(http.MethodDelete, "/api/v1/tasks/"+taskID, nil)
req = req.WithContext(authn.WithIdentity(
req.Context(), authn.Identity{UserID: userID, Role: authn.RoleMember}))
c.Request = req
c.Params = gin.Params{{Key: "id", Value: taskID}}
return c, rec
}
func TestHTTPDeleteTaskDeniesForeignTask(t *testing.T) {
repo := &authzDeleteRepo{}
h := newAuthzTaskHandlers(t, repo)
c, rec := authzDeleteRequest(t, "user-a", "task-b")
h.httpDeleteTask(c)
if rec.Code != http.StatusNotFound {
t.Fatalf("foreign delete: status = %d body = %s, want 404", rec.Code, rec.Body.String())
}
if got := repo.deletes(); len(got) != 0 {
t.Fatalf("a denied delete reached the repository: %v", got)
}
// The owner must still be able to delete, otherwise a 404 for everyone
// would pass this test just as well.
ownerCtx, ownerRec := authzDeleteRequest(t, "user-b", "task-b")
h.httpDeleteTask(ownerCtx)
if ownerRec.Code != http.StatusOK {
t.Fatalf("owner delete: status = %d body = %s, want 200", ownerRec.Code, ownerRec.Body.String())
}
if got := repo.deletes(); len(got) != 1 || got[0] != "task-b" {
t.Fatalf("owner delete did not reach the repository: %v", got)
}
}
// TestHTTPDeleteTaskDeniesForeignTaskThroughCascade covers the shape backendapp
// actually ships: with a HandoffService wired the handler never calls
// Service.DeleteTask, so the guard that carries this route lives on the cascade.
//
// SetHandoffService is called exactly as production calls it — nothing here
// installs the access checker by hand — so removing that wiring fails this test.
func TestHTTPDeleteTaskDeniesForeignTaskThroughCascade(t *testing.T) {
repo := &authzDeleteRepo{}
h := newAuthzTaskHandlers(t, repo)
h.SetHandoffService(service.NewHandoffService(repo, nil, nil, nil, nil, nil))
c, rec := authzDeleteRequest(t, "user-a", "task-b")
h.httpDeleteTask(c)
if rec.Code != http.StatusNotFound {
t.Fatalf("foreign cascade delete: status = %d body = %s, want 404", rec.Code, rec.Body.String())
}
if got := repo.deletes(); len(got) != 0 {
t.Fatalf("a denied cascade delete reached the repository: %v", got)
}
ownerCtx, ownerRec := authzDeleteRequest(t, "user-b", "task-b")
h.httpDeleteTask(ownerCtx)
if ownerRec.Code != http.StatusOK {
t.Fatalf("owner cascade delete: status = %d body = %s, want 200", ownerRec.Code, ownerRec.Body.String())
}
if got := repo.deletes(); len(got) != 1 || got[0] != "task-b" {
t.Fatalf("owner cascade delete did not reach the repository: %v", got)
}
}
// TestWSArchiveTaskDeniesForeignTaskThroughCascade covers the third route the
// cascade substitution unscoped: wsArchiveTask also prefers HandoffService when
// one is wired, so it never reaches Service.ArchiveTask's guard either.
func TestWSArchiveTaskDeniesForeignTaskThroughCascade(t *testing.T) {
repo := &authzDeleteRepo{}
h := newAuthzTaskHandlers(t, repo)
h.SetHandoffService(service.NewHandoffService(repo, nil, nil, nil, nil, nil))
msg := &ws.Message{
ID: "msg-1",
Action: ws.ActionTaskArchive,
Payload: json.RawMessage(`{"id":"task-b"}`),
}
denied, err := h.wsArchiveTask(authzWSContext("user-a"), msg)
if err != nil {
t.Fatalf("wsArchiveTask: %v", err)
}
if denied.Type != ws.MessageTypeError {
t.Fatalf("foreign archive: type = %s payload = %s, want error", denied.Type, denied.Payload)
}
if got := repo.archives(); len(got) != 0 {
t.Fatalf("a denied archive reached the repository: %v", got)
}
owner, err := h.wsArchiveTask(authzWSContext("user-b"), msg)
if err != nil {
t.Fatalf("owner archive: %v", err)
}
if owner.Type == ws.MessageTypeError {
t.Fatalf("owner archive was refused: %s", owner.Payload)
}
if got := repo.archives(); len(got) != 1 {
t.Fatalf("owner archive did not reach the repository: %v", got)
}
}
// TestHTTPDeleteTaskSurvivesClientDisconnect is why the fix is
// context.WithoutCancel rather than a plain c.Request.Context() swap: a delete
// tears down worktrees, containers and subtree rows, and a browser that
// navigates away mid-request must not abort it half-done.
func TestHTTPDeleteTaskSurvivesClientDisconnect(t *testing.T) {
repo := &authzDeleteRepo{}
h := newAuthzTaskHandlers(t, repo)
c, rec := authzDeleteRequest(t, "user-b", "task-b")
gone, disconnect := context.WithCancel(c.Request.Context())
c.Request = c.Request.WithContext(gone)
disconnect()
h.httpDeleteTask(c)
if rec.Code != http.StatusOK {
t.Fatalf("disconnected delete: status = %d body = %s, want 200", rec.Code, rec.Body.String())
}
if got := repo.deletes(); len(got) != 1 || got[0] != "task-b" {
t.Fatalf("a client disconnect aborted the delete: %v", got)
}
// The status code alone proves nothing here — this fake repository ignores
// its context, so a cancelled one would still "succeed". Assert the context
// the delete was actually handed is live, which is what a real repository,
// worktree teardown or container removal depends on.
if !repo.deliveredLive() {
t.Fatal("the delete was handed a cancelled context; a client disconnect can abort it half-done")
}
}