// cancel_test.go — covers POST /api/jobs/{id}/cancel. package http import ( "context" "encoding/json" stdhttp "net/http" "strings" "testing" "time" "github.com/coder/websocket" "github.com/oklog/ulid/v2" "gitea.dcglab.co.uk/steve/restic-manager/internal/api" "gitea.dcglab.co.uk/steve/restic-manager/internal/store" ) // TestCancelJobRunningHappyPath: a running job's cancel endpoint sends // a command.cancel envelope with the right job id, returns 202, and // writes a job.cancel audit row. func TestCancelJobRunningHappyPath(t *testing.T) { t.Parallel() srv, ts, st := rawTestServer(t) hostID, token := enrolHostForWS(t, srv, st, "cancel-host") c := agentDial(t, srv, ts, hostID, token) sendHello(t, c, "cancel-host") _ = drainUntil(t, c, api.MsgScheduleSet) // Seed a running job we can target. jobID := ulid.Make().String() now := time.Now().UTC() if err := st.CreateJob(context.Background(), store.Job{ ID: jobID, HostID: hostID, Kind: "backup", ActorKind: "user", CreatedAt: now, }); err != nil { t.Fatalf("create job: %v", err) } if err := st.MarkJobStarted(context.Background(), jobID, now); err != nil { t.Fatalf("mark started: %v", err) } cookie := loginAsAdmin(t, st) req, _ := stdhttp.NewRequest("POST", ts.URL+"/api/jobs/"+jobID+"/cancel", nil) req.AddCookie(cookie) res, err := stdhttp.DefaultClient.Do(req) if err != nil { t.Fatalf("do: %v", err) } defer res.Body.Close() if res.StatusCode != stdhttp.StatusAccepted { t.Fatalf("status: got %d, want 202", res.StatusCode) } // Read the dispatched command.cancel envelope. deadline := time.Now().Add(2 * time.Second) var got api.Envelope for time.Now().Before(deadline) { ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) mt, raw, rerr := c.Read(ctx) cancel() if rerr != nil { break } if mt != websocket.MessageText { continue } if !strings.Contains(string(raw), `"command.cancel"`) { continue } if err := json.Unmarshal(raw, &got); err != nil { t.Fatalf("unmarshal: %v", err) } break } if got.Type != api.MsgCommandCancel { t.Fatalf("never received command.cancel envelope") } var cp api.CommandCancelPayload if err := got.UnmarshalPayload(&cp); err != nil { t.Fatalf("unmarshal payload: %v", err) } if cp.JobID != jobID { t.Fatalf("payload job_id: got %q want %q", cp.JobID, jobID) } // Audit row exists. var n int if err := st.DB().QueryRow( `SELECT COUNT(*) FROM audit_log WHERE action = 'job.cancel' AND target_id = ?`, jobID).Scan(&n); err != nil { t.Fatalf("audit count: %v", err) } if n != 1 { t.Fatalf("audit rows: got %d, want 1", n) } } // TestCancelJobAlreadyTerminal: a job in succeeded/failed/canceled // state returns 409 and does NOT send a WS envelope. func TestCancelJobAlreadyTerminal(t *testing.T) { t.Parallel() srv, ts, st := rawTestServer(t) hostID, token := enrolHostForWS(t, srv, st, "term-host") c := agentDial(t, srv, ts, hostID, token) sendHello(t, c, "term-host") _ = drainUntil(t, c, api.MsgScheduleSet) jobID := ulid.Make().String() now := time.Now().UTC() if err := st.CreateJob(context.Background(), store.Job{ ID: jobID, HostID: hostID, Kind: "backup", ActorKind: "user", CreatedAt: now, }); err != nil { t.Fatalf("create job: %v", err) } if err := st.MarkJobFinished(context.Background(), jobID, "succeeded", 0, nil, "", now); err != nil { t.Fatalf("mark finished: %v", err) } cookie := loginAsAdmin(t, st) req, _ := stdhttp.NewRequest("POST", ts.URL+"/api/jobs/"+jobID+"/cancel", nil) req.AddCookie(cookie) res, err := stdhttp.DefaultClient.Do(req) if err != nil { t.Fatalf("do: %v", err) } defer res.Body.Close() if res.StatusCode != stdhttp.StatusConflict { t.Fatalf("status: got %d, want 409", res.StatusCode) } // Drain — no command.cancel should arrive. ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond) defer cancel() for { mt, raw, rerr := c.Read(ctx) if rerr != nil { break } if mt == websocket.MessageText && strings.Contains(string(raw), `"command.cancel"`) { t.Fatalf("unexpected command.cancel envelope for terminal job") } } } // TestCancelJobNotFound: 404 for a job id that doesn't exist. func TestCancelJobNotFound(t *testing.T) { t.Parallel() _, ts, st := rawTestServer(t) cookie := loginAsAdmin(t, st) req, _ := stdhttp.NewRequest("POST", ts.URL+"/api/jobs/"+ulid.Make().String()+"/cancel", nil) req.AddCookie(cookie) res, err := stdhttp.DefaultClient.Do(req) if err != nil { t.Fatalf("do: %v", err) } defer res.Body.Close() if res.StatusCode != stdhttp.StatusNotFound { t.Fatalf("status: got %d, want 404", res.StatusCode) } } // TestCancelJobHostOffline: a queued/running job whose host has no // active WS connection returns 503. func TestCancelJobHostOffline(t *testing.T) { t.Parallel() _, ts, st := rawTestServer(t) // Create a host but don't connect a WS for it. hostID := ulid.Make().String() if err := st.CreateHost(context.Background(), store.Host{ ID: hostID, Name: "offline-host", OS: "linux", Arch: "amd64", EnrolledAt: time.Now().UTC(), }, "deadbeef", ""); err != nil { t.Fatalf("create host: %v", err) } jobID := ulid.Make().String() now := time.Now().UTC() if err := st.CreateJob(context.Background(), store.Job{ ID: jobID, HostID: hostID, Kind: "backup", ActorKind: "user", CreatedAt: now, }); err != nil { t.Fatalf("create job: %v", err) } if err := st.MarkJobStarted(context.Background(), jobID, now); err != nil { t.Fatalf("mark started: %v", err) } cookie := loginAsAdmin(t, st) req, _ := stdhttp.NewRequest("POST", ts.URL+"/api/jobs/"+jobID+"/cancel", nil) req.AddCookie(cookie) res, err := stdhttp.DefaultClient.Do(req) if err != nil { t.Fatalf("do: %v", err) } defer res.Body.Close() if res.StatusCode != stdhttp.StatusServiceUnavailable { t.Fatalf("status: got %d, want 503", res.StatusCode) } }