diff --git a/workspace-server/internal/handlers/admin_schedules_health_integration_test.go b/workspace-server/internal/handlers/admin_schedules_health_integration_test.go new file mode 100644 index 000000000..3aaab1597 --- /dev/null +++ b/workspace-server/internal/handlers/admin_schedules_health_integration_test.go @@ -0,0 +1,201 @@ +//go:build integration +// +build integration + +// admin_schedules_health_integration_test.go — REAL Postgres integration +// tests for GET /admin/schedules/health (handlers/admin_schedules_health.go). +// +// Mirrors pending_uploads_integration_test.go / +// delegation_ledger_integration_test.go. Unit tests in +// admin_schedules_health_test.go pin the SQL shape + classification +// function; these tests pin the OBSERVABLE row state end-to-end: +// - admin view joins workspace_schedules with non-removed workspaces +// - status classifies as "never_run" / "ok" / "stale" against real +// last_run_at values + real cron intervals +// - removed workspaces are excluded from the join +// +// Run with: +// +// docker run --rm -d --name pg-integration \ +// -e POSTGRES_PASSWORD=test -e POSTGRES_DB=molecule \ +// -p 55432:5432 postgres:15-alpine +// sleep 4 +// psql ... < workspace-server/migrations/001_workspaces.sql +// psql ... < workspace-server/migrations/015_workspace_schedules.sql +// cd workspace-server +// INTEGRATION_DB_URL="postgres://postgres:test@localhost:55432/molecule?sslmode=disable" \ +// go test -tags=integration ./internal/handlers/ -run Integration_AdminSchedulesHealth -v + +package handlers + +import ( + "context" + "database/sql" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + "github.com/gin-gonic/gin" + _ "github.com/lib/pq" + + "git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/db" +) + +func integrationDB_AdminSchedulesHealth(t *testing.T) *sql.DB { + t.Helper() + url := os.Getenv("INTEGRATION_DB_URL") + if url == "" { + t.Skip("INTEGRATION_DB_URL not set; skipping (local devs: see file header)") + } + conn, err := sql.Open("postgres", url) + if err != nil { + t.Fatalf("open: %v", err) + } + if err := conn.Ping(); err != nil { + t.Fatalf("ping: %v", err) + } + if _, err := conn.ExecContext(context.Background(), + `DELETE FROM workspace_schedules WHERE workspace_id IN (SELECT id FROM workspaces WHERE name LIKE 'integ-ash-%')`); err != nil { + t.Fatalf("cleanup schedules: %v", err) + } + if _, err := conn.ExecContext(context.Background(), + `DELETE FROM workspaces WHERE name LIKE 'integ-ash-%'`); err != nil { + t.Fatalf("cleanup workspaces: %v", err) + } + prev := db.DB + db.DB = conn + t.Cleanup(func() { + conn.ExecContext(context.Background(), `DELETE FROM workspace_schedules WHERE workspace_id IN (SELECT id FROM workspaces WHERE name LIKE 'integ-ash-%')`) + conn.ExecContext(context.Background(), `DELETE FROM workspaces WHERE name LIKE 'integ-ash-%'`) + db.DB = prev + conn.Close() + }) + return conn +} + +func seedWorkspace_AdminSchedulesHealth(t *testing.T, conn *sql.DB, id string, status string) { + t.Helper() + if _, err := conn.ExecContext(context.Background(), + `INSERT INTO workspaces (id, name, status) VALUES ($1, $2, $3)`, + id, "integ-ash-"+id, status); err != nil { + t.Fatalf("seed workspace: %v", err) + } +} + +// seedSchedule_AdminSchedulesHealth inserts a workspace_schedules row +// directly (bypassing the handler) so the test can pin last_run_at to +// any value, including backdated for the "stale" classification case. +func seedSchedule_AdminSchedulesHealth(t *testing.T, conn *sql.DB, workspaceID, name, cronExpr, tz string, lastRunAt *time.Time) { + t.Helper() + var lastRunArg interface{} = lastRunAt + // next_run_at = now() so the row is "in-window" for the scheduler. + if _, err := conn.ExecContext(context.Background(), + `INSERT INTO workspace_schedules + (workspace_id, name, cron_expr, timezone, prompt, enabled, last_run_at, next_run_at, run_count, last_status) + VALUES ($1, $2, $3, $4, 'test prompt', true, $5, now(), 1, 'ok')`, + workspaceID, name, cronExpr, tz, lastRunArg); err != nil { + t.Fatalf("seed schedule: %v", err) + } +} + +// TestIntegration_AdminSchedulesHealth_ClassifiesRows pins the three +// classification branches against real DB rows: never_run (last_run_at +// IS NULL), ok (recent last_run_at), stale (last_run_at well past 2× +// cron interval). Also asserts the join excludes removed workspaces. +func TestIntegration_AdminSchedulesHealth_ClassifiesRows(t *testing.T) { + conn := integrationDB_AdminSchedulesHealth(t) + handler := NewAdminSchedulesHealthHandler() + + // Two visible workspaces + one removed (must NOT appear in results). + // IDs are derived from the human-readable name via integUUID so the + // schema (UUID-typed id column) is satisfied while failure logs still + // print a recognizable name. + wsOK := integUUID("integ-ash-ws-ok") + wsStale := integUUID("integ-ash-ws-stale") + wsRemoved := integUUID("integ-ash-ws-removed") + seedWorkspace_AdminSchedulesHealth(t, conn, wsOK, "online") + seedWorkspace_AdminSchedulesHealth(t, conn, wsStale, "online") + seedWorkspace_AdminSchedulesHealth(t, conn, wsRemoved, "removed") + + // --- never_run: last_run_at IS NULL --- + // (Don't pass lastRunAt; seedSchedule inserts NULL by default if + // we pass a nil pointer. Already handled by lastRunArg interface{}.) + seedSchedule_AdminSchedulesHealth(t, conn, wsOK, "never_run_schedule", "0 * * * *", "UTC", nil) + + // --- ok: last_run_at within 2× cron interval (every-15-min → threshold ~30min) --- + okLast := time.Now().Add(-2 * time.Minute) // 2 min ago, well within 30 min + seedSchedule_AdminSchedulesHealth(t, conn, wsOK, "ok_schedule", "*/15 * * * *", "UTC", &okLast) + + // --- stale: last_run_at way past 2× cron interval (every-15-min, ran 1h ago) --- + staleLast := time.Now().Add(-1 * time.Hour) // 1h ago, well past 30 min + seedSchedule_AdminSchedulesHealth(t, conn, wsStale, "stale_schedule", "*/15 * * * *", "UTC", &staleLast) + + // --- removed workspace's schedule must NOT appear --- + // Add a schedule to the removed workspace to prove it's filtered out. + seedSchedule_AdminSchedulesHealth(t, conn, wsRemoved, "removed_schedule", "0 * * * *", "UTC", nil) + + // --- Call the handler --- + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("GET", "/admin/schedules/health", nil) + handler.Health(c) + if w.Code != http.StatusOK { + t.Fatalf("Health: status want 200, got %d: %s", w.Code, w.Body.String()) + } + var got []adminScheduleHealth + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { + t.Fatalf("Health: parse: %v", err) + } + + // Index by schedule name for assertions. + byName := map[string]adminScheduleHealth{} + for _, e := range got { + byName[e.ScheduleName] = e + } + + // --- Assert: never_run classification --- + if e, ok := byName["never_run_schedule"]; !ok { + t.Errorf("never_run_schedule missing from response (got %d entries: %+v)", len(got), byName) + } else if e.Status != "never_run" { + t.Errorf("never_run_schedule: status want never_run, got %q", e.Status) + } + + // --- Assert: ok classification --- + if e, ok := byName["ok_schedule"]; !ok { + t.Errorf("ok_schedule missing from response") + } else if e.Status != "ok" { + t.Errorf("ok_schedule: status want ok, got %q (last_run_at=%v threshold=%ds)", + e.Status, e.LastRunAt, e.StaleThresholdSeconds) + } + + // --- Assert: stale classification --- + if e, ok := byName["stale_schedule"]; !ok { + t.Errorf("stale_schedule missing from response") + } else if e.Status != "stale" { + t.Errorf("stale_schedule: status want stale, got %q (last_run_at=%v threshold=%ds)", + e.Status, e.LastRunAt, e.StaleThresholdSeconds) + } + + // --- Assert: removed workspace is filtered out --- + if _, ok := byName["removed_schedule"]; ok { + t.Errorf("removed_schedule should be filtered out (workspace status=removed)") + } + + // --- Assert: stale threshold is 2× cron interval. */15 fires every + // 15 minutes (900s) so the threshold is 2× 900s = 1800s. (The previous + // version of this assertion expected ~3600 — the original test author + // mis-read `*/15` as "every 30 minutes", which is two off-by-one errors + // stacked. Handler computes the threshold from the next-two-fires gap + // in budget_periods.go, which is correct.) --- + if e, ok := byName["ok_schedule"]; ok { + // Allow ±10s slack for runtime compute jitter (scheduler.ComputeNextRun + // is a pure function so it's deterministic, but the "now" snapshot is + // taken at test time, not at seed time, so the precise gap can shift + // by a couple of seconds if the test runs across a 15-min boundary). + if e.StaleThresholdSeconds < 1790 || e.StaleThresholdSeconds > 1810 { + t.Errorf("ok_schedule: stale_threshold_seconds want ~1800 (2× 15min), got %d", e.StaleThresholdSeconds) + } + } +} diff --git a/workspace-server/internal/handlers/budget_integration_test.go b/workspace-server/internal/handlers/budget_integration_test.go new file mode 100644 index 000000000..713d4f328 --- /dev/null +++ b/workspace-server/internal/handlers/budget_integration_test.go @@ -0,0 +1,357 @@ +//go:build integration +// +build integration + +// budget_integration_test.go — REAL Postgres integration tests for +// /workspaces/:id/budget (handlers/budget.go). +// +// Mirrors pending_uploads_integration_test.go / +// delegation_ledger_integration_test.go. Unit tests in budget_test.go +// pin the SQL shape (sqlmock); these tests pin the OBSERVABLE row state +// against real Postgres, including: +// - GET returns budget_limit / monthly_spend / budget_remaining with +// the exact null-vs-int math the production handler computes +// - PATCH sets, clears, and rejects bad inputs (negative / missing / +// non-numeric) against real DB rows +// - existence check uses status != 'removed' (removed ws → 404) +// - updated_at advances on PATCH +// - PATCH re-reads + returns the same shape as GET +// +// Run with: +// +// docker run --rm -d --name pg-integration \ +// -e POSTGRES_PASSWORD=test -e POSTGRES_DB=molecule \ +// -p 55432:5432 postgres:15-alpine +// sleep 4 +// psql ... < workspace-server/migrations/001_workspaces.sql +// psql ... < workspace-server/migrations/027_workspace_budget.up.sql +// psql ... < workspace-server/migrations/20260529000000_workspace_multiperiod_budget.up.sql +// cd workspace-server +// INTEGRATION_DB_URL="postgres://postgres:test@localhost:55432/molecule?sslmode=disable" \ +// go test -tags=integration ./internal/handlers/ -run Integration_Budget -v + +package handlers + +import ( + "bytes" + "context" + "database/sql" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + "github.com/gin-gonic/gin" + _ "github.com/lib/pq" + "git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/db" +) + +// integrationDB_Budget opens the integration PG connection, wipes our +// test rows, and hot-swaps the package-level db.DB. NOT SAFE for +// t.Parallel() — the global db.DB is shared. +func integrationDB_Budget(t *testing.T) *sql.DB { + t.Helper() + url := os.Getenv("INTEGRATION_DB_URL") + if url == "" { + t.Skip("INTEGRATION_DB_URL not set; skipping (local devs: see file header)") + } + conn, err := sql.Open("postgres", url) + if err != nil { + t.Fatalf("open: %v", err) + } + if err := conn.Ping(); err != nil { + t.Fatalf("ping: %v", err) + } + for _, stmt := range []string{ + // Wipe ledger rows first (workspace_id is TEXT, no FK, but + // grouping the cleanup with workspaces makes intent clear). + // Cast `id::text` because workspace_spend_events.workspace_id + // is TEXT while workspaces.id is UUID — without the cast, + // Postgres rejects the IN comparison with `operator does not + // exist: text = uuid`. The other test files don't hit this + // because their join tables also store workspace_id as TEXT. + `DELETE FROM workspace_spend_events WHERE workspace_id IN (SELECT id::text FROM workspaces WHERE name LIKE 'integ-bud-%')`, + `DELETE FROM workspaces WHERE name LIKE 'integ-bud-%'`, + } { + if _, err := conn.ExecContext(context.Background(), stmt); err != nil { + t.Fatalf("cleanup %q: %v", stmt, err) + } + } + prev := db.DB + db.DB = conn + t.Cleanup(func() { + conn.ExecContext(context.Background(), `DELETE FROM workspace_spend_events WHERE workspace_id IN (SELECT id::text FROM workspaces WHERE name LIKE 'integ-bud-%')`) + conn.ExecContext(context.Background(), `DELETE FROM workspaces WHERE name LIKE 'integ-bud-%'`) + db.DB = prev + conn.Close() + }) + return conn +} + +// seedWorkspace_Budget inserts a workspaces row with the per-period +// budget_limits JSONB (the SSOT since migration 20260529000000) and, +// if monthlySpend > 0, a corresponding workspace_spend_events ledger +// row so the handler's rolling-window SUM picks it up. The legacy +// budget_limit / monthly_spend BIGINT columns are no longer the SSOT — +// the handler reads the JSONB + the ledger. The status is hardcoded +// to 'online' (a valid workspace_status enum value — see migration 043). +// The removed-status case uses a separate helper. +func seedWorkspace_Budget(t *testing.T, conn *sql.DB, id string, budgetLimit *int64, monthlySpend int64) { + t.Helper() + // Render the JSONB shape the handler expects: {"monthly":N} when a + // limit is configured, {} otherwise. Absent keys = no limit (the + // default) so we only mention periods that have a configured ceiling. + limits := map[string]int64{} + if budgetLimit != nil { + limits["monthly"] = *budgetLimit + } + limitsJSON, err := json.Marshal(limits) + if err != nil { + t.Fatalf("seed: marshal limits: %v", err) + } + if _, err := conn.ExecContext(context.Background(), + `INSERT INTO workspaces (id, name, status, budget_limits) + VALUES ($1, $2, 'online', $3::jsonb)`, + id, "integ-bud-"+id, string(limitsJSON)); err != nil { + t.Fatalf("seed: %v", err) + } + // Record the monthly spend as a single ledger event with the full + // delta. spendByPeriod sums delta_cents over the rolling window — + // a single recent row (default occurred_at=now()) lands inside all + // four windows (1h/24h/7d/30d), so the monthly figure the test + // expects shows up regardless of which window the assertion targets. + if monthlySpend > 0 { + if _, err := conn.ExecContext(context.Background(), + `INSERT INTO workspace_spend_events (workspace_id, delta_cents) VALUES ($1, $2)`, + id, monthlySpend); err != nil { + t.Fatalf("seed spend: %v", err) + } + } +} + +// doPatch_Budget fires PATCH /workspaces/:id/budget with the given JSON body. +func doPatch_Budget(t *testing.T, h *BudgetHandler, workspaceID, body string) *httptest.ResponseRecorder { + t.Helper() + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "id", Value: workspaceID}} + c.Request = httptest.NewRequest("PATCH", "/workspaces/"+workspaceID+"/budget", bytes.NewReader([]byte(body))) + c.Request.Header.Set("Content-Type", "application/json") + h.PatchBudget(c) + return w +} + +// doGet_Budget fires GET /workspaces/:id/budget. +func doGet_Budget(t *testing.T, h *BudgetHandler, workspaceID string) *httptest.ResponseRecorder { + t.Helper() + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "id", Value: workspaceID}} + c.Request = httptest.NewRequest("GET", "/workspaces/"+workspaceID+"/budget", nil) + h.GetBudget(c) + return w +} + +// TestIntegration_Budget_GetPatchPersistsAndValidates pins the GET / PATCH +// surface against real Postgres: null math, set/clear, validation, existence +// check, updated_at advancement, and round-trip persistence (the +// "PersistsAndValidates" suffix matches the watch-fail-first name PM-cited +// in the #2151 CHUNK 2 dispatch). +func TestIntegration_Budget_GetPatchPersistsAndValidates(t *testing.T) { + conn := integrationDB_Budget(t) + handler := NewBudgetHandler() + + wsA := integUUID("integ-bud-ws-a") + wsB := integUUID("integ-bud-ws-b") + wsAOver := integUUID("integ-bud-ws-a-over") + wsRemoved := integUUID("integ-bud-ws-removed") + wsGhost := integUUID("integ-bud-ws-ghost") + + // Case A: no budget set (budget_limit NULL) + // Case B: under budget (limit 10000, spend 2500 → remaining 7500) + // Case C: over budget (limit 1000, spend 1500 → remaining -500, per + // the comment in budget.go: "Can be negative") + seedWorkspace_Budget(t, conn, wsA, nil, 0) + seedWorkspace_Budget(t, conn, wsB, int64Ptr(10000), 2500) + overLim := int64(1000) + seedWorkspace_Budget(t, conn, wsAOver, &overLim, 1500) + // removed-workspace case (status='removed' so the handler's + // `WHERE status != 'removed'` existence check rejects it with 404). + if _, err := conn.ExecContext(context.Background(), + `INSERT INTO workspaces (id, name, status, budget_limits) + VALUES ($1, 'integ-bud-removed', 'removed', '{}'::jsonb)`, wsRemoved); err != nil { + t.Fatalf("seed removed: %v", err) + } + + // --- Case 1: GET — no budget set → budget_limit=nil, budget_remaining=nil, monthly_spend=0 --- + w := doGet_Budget(t, handler, wsA) + if w.Code != http.StatusOK { + t.Fatalf("GET no-budget: status want 200, got %d: %s", w.Code, w.Body.String()) + } + var r1 struct { + BudgetLimit *int64 `json:"budget_limit"` + MonthlySpend int64 `json:"monthly_spend"` + BudgetRemaining *int64 `json:"budget_remaining"` + } + if err := json.Unmarshal(w.Body.Bytes(), &r1); err != nil { + t.Fatalf("GET no-budget: parse: %v", err) + } + if r1.BudgetLimit != nil { + t.Errorf("GET no-budget: budget_limit want nil, got %d", *r1.BudgetLimit) + } + if r1.BudgetRemaining != nil { + t.Errorf("GET no-budget: budget_remaining want nil, got %d", *r1.BudgetRemaining) + } + if r1.MonthlySpend != 0 { + t.Errorf("GET no-budget: monthly_spend want 0, got %d", r1.MonthlySpend) + } + + // --- Case 2: GET — under budget → remaining = limit - spend (positive) --- + w = doGet_Budget(t, handler, wsB) + if w.Code != http.StatusOK { + t.Fatalf("GET under: status want 200, got %d: %s", w.Code, w.Body.String()) + } + var r2 struct { + BudgetLimit *int64 `json:"budget_limit"` + MonthlySpend int64 `json:"monthly_spend"` + BudgetRemaining *int64 `json:"budget_remaining"` + } + json.Unmarshal(w.Body.Bytes(), &r2) + if r2.BudgetLimit == nil || *r2.BudgetLimit != 10000 { + t.Errorf("GET under: budget_limit want 10000, got %v", r2.BudgetLimit) + } + if r2.MonthlySpend != 2500 { + t.Errorf("GET under: monthly_spend want 2500, got %d", r2.MonthlySpend) + } + if r2.BudgetRemaining == nil || *r2.BudgetRemaining != 7500 { + t.Errorf("GET under: budget_remaining want 7500, got %v", r2.BudgetRemaining) + } + + // --- Case 3: GET — over budget → remaining is NEGATIVE (per budget.go doc) --- + w = doGet_Budget(t, handler, wsAOver) + if w.Code != http.StatusOK { + t.Fatalf("GET over: status want 200, got %d: %s", w.Code, w.Body.String()) + } + var r3 struct { + BudgetLimit *int64 `json:"budget_limit"` + MonthlySpend int64 `json:"monthly_spend"` + BudgetRemaining *int64 `json:"budget_remaining"` + } + json.Unmarshal(w.Body.Bytes(), &r3) + if r3.BudgetRemaining == nil || *r3.BudgetRemaining != -500 { + t.Errorf("GET over: budget_remaining want -500, got %v", r3.BudgetRemaining) + } + + // --- Case 4: GET — removed workspace → 404 (existence check) --- + w = doGet_Budget(t, handler, wsRemoved) + if w.Code != http.StatusNotFound { + t.Errorf("GET removed: status want 404, got %d: %s", w.Code, w.Body.String()) + } + + // --- Case 5: GET — unknown workspace → 404 --- + w = doGet_Budget(t, handler, wsGhost) + if w.Code != http.StatusNotFound { + t.Errorf("GET ghost: status want 404, got %d: %s", w.Code, w.Body.String()) + } + + // --- Case 6: PATCH — set budget_limit on wsA from NULL → 5000, persist + re-read --- + before := time.Now().UTC().Add(-2 * time.Second) + w = doPatch_Budget(t, handler, wsA, `{"budget_limit": 5000}`) + if w.Code != http.StatusOK { + t.Fatalf("PATCH set: status want 200, got %d: %s", w.Code, w.Body.String()) + } + var p1 struct { + BudgetLimit *int64 `json:"budget_limit"` + MonthlySpend int64 `json:"monthly_spend"` + BudgetRemaining *int64 `json:"budget_remaining"` + } + json.Unmarshal(w.Body.Bytes(), &p1) + if p1.BudgetLimit == nil || *p1.BudgetLimit != 5000 { + t.Errorf("PATCH set: budget_limit want 5000, got %v", p1.BudgetLimit) + } + // re-read: GET should now return limit=5000, spend=0, remaining=5000 + w = doGet_Budget(t, handler, wsA) + json.Unmarshal(w.Body.Bytes(), &p1) + if p1.BudgetLimit == nil || *p1.BudgetLimit != 5000 { + t.Errorf("PATCH set re-read: budget_limit want 5000, got %v", p1.BudgetLimit) + } + if p1.MonthlySpend != 0 { + t.Errorf("PATCH set re-read: monthly_spend want 0, got %d", p1.MonthlySpend) + } + if p1.BudgetRemaining == nil || *p1.BudgetRemaining != 5000 { + t.Errorf("PATCH set re-read: budget_remaining want 5000, got %v", p1.BudgetRemaining) + } + // updated_at advanced + var updatedAt time.Time + if err := conn.QueryRowContext(context.Background(), + `SELECT updated_at FROM workspaces WHERE id = $1`, wsA).Scan(&updatedAt); err != nil { + t.Fatalf("updated_at: %v", err) + } + if !updatedAt.After(before) { + t.Errorf("PATCH set: updated_at want > %v, got %v", before, updatedAt) + } + + // --- Case 7: PATCH — clear budget_limit (explicit null) → 200, GET returns nil --- + w = doPatch_Budget(t, handler, wsA, `{"budget_limit": null}`) + if w.Code != http.StatusOK { + t.Fatalf("PATCH clear: status want 200, got %d: %s", w.Code, w.Body.String()) + } + w = doGet_Budget(t, handler, wsA) + json.Unmarshal(w.Body.Bytes(), &p1) + if p1.BudgetLimit != nil { + t.Errorf("PATCH clear re-read: budget_limit want nil, got %d", *p1.BudgetLimit) + } + + // --- Case 8: PATCH — negative budget_limit → 400 --- + w = doPatch_Budget(t, handler, wsA, `{"budget_limit": -1}`) + if w.Code != http.StatusBadRequest { + t.Errorf("PATCH negative: status want 400, got %d: %s", w.Code, w.Body.String()) + } + + // --- Case 9: PATCH — missing budget_limit field → 400 --- + w = doPatch_Budget(t, handler, wsA, `{}`) + if w.Code != http.StatusBadRequest { + t.Errorf("PATCH missing: status want 400, got %d: %s", w.Code, w.Body.String()) + } + + // --- Case 10: PATCH — non-numeric budget_limit → 400 --- + w = doPatch_Budget(t, handler, wsA, `{"budget_limit": "abc"}`) + if w.Code != http.StatusBadRequest { + t.Errorf("PATCH non-numeric: status want 400, got %d: %s", w.Code, w.Body.String()) + } + + // --- Case 11: PATCH — unknown workspace → 404 --- + w = doPatch_Budget(t, handler, wsGhost, `{"budget_limit": 1000}`) + if w.Code != http.StatusNotFound { + t.Errorf("PATCH ghost: status want 404, got %d: %s", w.Code, w.Body.String()) + } + + // --- Case 12: PATCH — removed workspace → 404 (existence check) --- + w = doPatch_Budget(t, handler, wsRemoved, `{"budget_limit": 1000}`) + if w.Code != http.StatusNotFound { + t.Errorf("PATCH removed: status want 404, got %d: %s", w.Code, w.Body.String()) + } + + // --- Case 13: PATCH — set then update again, PATCH response shape matches GET --- + w = doPatch_Budget(t, handler, wsB, `{"budget_limit": 8000}`) + if w.Code != http.StatusOK { + t.Fatalf("PATCH update: status want 200, got %d: %s", w.Code, w.Body.String()) + } + w = doGet_Budget(t, handler, wsB) + json.Unmarshal(w.Body.Bytes(), &p1) + if p1.BudgetLimit == nil || *p1.BudgetLimit != 8000 { + t.Errorf("PATCH update re-read: budget_limit want 8000, got %v", p1.BudgetLimit) + } + // monthly_spend unchanged at 2500 + if p1.MonthlySpend != 2500 { + t.Errorf("PATCH update re-read: monthly_spend want 2500, got %d", p1.MonthlySpend) + } + // remaining = 8000 - 2500 = 5500 + if p1.BudgetRemaining == nil || *p1.BudgetRemaining != 5500 { + t.Errorf("PATCH update re-read: budget_remaining want 5500, got %v", p1.BudgetRemaining) + } +} + +// int64Ptr returns &i — small helper so call sites stay readable. +func int64Ptr(i int64) *int64 { return &i } diff --git a/workspace-server/internal/handlers/integration_test_helpers_test.go b/workspace-server/internal/handlers/integration_test_helpers_test.go new file mode 100644 index 000000000..219357d06 --- /dev/null +++ b/workspace-server/internal/handlers/integration_test_helpers_test.go @@ -0,0 +1,37 @@ +//go:build integration +// +build integration + +// integration_test_helpers_test.go — shared helpers for the +// `//go:build integration` test files. +// +// The handlers package uses github.com/google/uuid in production code +// (workspaces.id, workspace_schedules.workspace_id, activity_logs.workspace_id, +// and workspace_auth_tokens.workspace_id are all UUID columns — see +// migrations 001_workspaces.sql, 015_workspace_schedules.sql, +// 009_activity_logs.sql, 020_workspace_auth_tokens.up.sql). Real +// Postgres rejects non-UUID-shaped strings on insert. +// +// The integration tests in this package want human-readable fixture +// names so failures print obviously ("integ-sch-ws-a", not a random +// UUID). integUUID is a tiny helper that maps any string to a +// stable UUID via SHA-1 in the URL namespace — same input → same +// UUID, different inputs → different UUIDs. The test can keep its +// readable names but every place that needs a UUID-shaped value +// passes through this helper. +// +// Cleanup is driven off `workspaces.name` (a TEXT column we set to +// the test marker) rather than `workspaces.id` (a UUID column) so +// we don't have to keep a running list of generated UUIDs in sync +// between the test body and the cleanup helper. + +package handlers + +import "github.com/google/uuid" + +// integUUID returns a deterministic UUID derived from s. The URL +// namespace keeps the input space disjoint from production UUIDs +// (which use the random v4 generator) and from the OID namespace +// (which uuid.NewSHA1 would default to). +func integUUID(s string) string { + return uuid.NewSHA1(uuid.NameSpaceURL, []byte(s)).String() +} diff --git a/workspace-server/internal/handlers/schedules_integration_test.go b/workspace-server/internal/handlers/schedules_integration_test.go new file mode 100644 index 000000000..aec946318 --- /dev/null +++ b/workspace-server/internal/handlers/schedules_integration_test.go @@ -0,0 +1,377 @@ +//go:build integration +// +build integration + +// schedules_integration_test.go — REAL Postgres integration tests for +// the /workspaces/:id/schedules surface (handlers/schedules.go). +// +// Mirrors pending_uploads_integration_test.go / +// delegation_ledger_integration_test.go. Unit tests in schedules_test.go +// pin the SQL shape (sqlmock); these tests pin the OBSERVABLE row state +// against real Postgres, including: +// - Create / List / Update / Delete round-trip +// - Update recomputes next_run_at when cron_expr or timezone changes +// - Update / Delete with wrong-workspace ID → 404 (IDOR protection, issue #113) +// - RunNow returns the stored prompt verbatim (no A2A fire) +// - History reads activity_logs filtered by request_body->>'schedule_id' +// - Health (self-call) returns only health fields (no prompt, no cron_expr) +// +// Run with: +// +// docker run --rm -d --name pg-integration \ +// -e POSTGRES_PASSWORD=test -e POSTGRES_DB=molecule \ +// -p 55432:5432 postgres:15-alpine +// sleep 4 +// psql ... < workspace-server/migrations/001_workspaces.sql +// psql ... < workspace-server/migrations/009_activity_logs.sql +// psql ... < workspace-server/migrations/015_workspace_schedules.sql +// cd workspace-server +// INTEGRATION_DB_URL="postgres://postgres:test@localhost:55432/molecule?sslmode=disable" \ +// go test -tags=integration ./internal/handlers/ -run Integration_Schedules -v + +package handlers + +import ( + "bytes" + "context" + "database/sql" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + "github.com/gin-gonic/gin" + _ "github.com/lib/pq" + "git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/db" +) + +func integrationDB_Schedules(t *testing.T) *sql.DB { + t.Helper() + url := os.Getenv("INTEGRATION_DB_URL") + if url == "" { + t.Skip("INTEGRATION_DB_URL not set; skipping (local devs: see file header)") + } + conn, err := sql.Open("postgres", url) + if err != nil { + t.Fatalf("open: %v", err) + } + if err := conn.Ping(); err != nil { + t.Fatalf("ping: %v", err) + } + // Wipe in FK order: activity_logs first (references workspaces), then + // workspace_schedules (references workspaces), then workspaces. + for _, stmt := range []string{ + `DELETE FROM activity_logs WHERE workspace_id IN (SELECT id FROM workspaces WHERE name LIKE 'integ-sch-%')`, + `DELETE FROM workspace_schedules WHERE workspace_id IN (SELECT id FROM workspaces WHERE name LIKE 'integ-sch-%')`, + `DELETE FROM workspaces WHERE name LIKE 'integ-sch-%'`, + } { + if _, err := conn.ExecContext(context.Background(), stmt); err != nil { + t.Fatalf("cleanup %q: %v", stmt, err) + } + } + prev := db.DB + db.DB = conn + t.Cleanup(func() { + conn.ExecContext(context.Background(), `DELETE FROM activity_logs WHERE workspace_id IN (SELECT id FROM workspaces WHERE name LIKE 'integ-sch-%')`) + conn.ExecContext(context.Background(), `DELETE FROM workspace_schedules WHERE workspace_id IN (SELECT id FROM workspaces WHERE name LIKE 'integ-sch-%')`) + conn.ExecContext(context.Background(), `DELETE FROM workspaces WHERE name LIKE 'integ-sch-%'`) + db.DB = prev + conn.Close() + }) + return conn +} + +func seedWorkspace_Schedules(t *testing.T, conn *sql.DB, id string) { + t.Helper() + if _, err := conn.ExecContext(context.Background(), + `INSERT INTO workspaces (id, name, status) VALUES ($1, $2, 'online')`, + id, "integ-sch-"+id); err != nil { + t.Fatalf("seed workspace: %v", err) + } +} + +// seedActivityLog_Schedules inserts a cron_run row directly so the +// History endpoint can find it via request_body->>'schedule_id'. +func seedActivityLog_Schedules(t *testing.T, conn *sql.DB, workspaceID, scheduleID string, status string, when time.Time) { + t.Helper() + if _, err := conn.ExecContext(context.Background(), + `INSERT INTO activity_logs (workspace_id, activity_type, request_body, status, duration_ms, created_at) + VALUES ($1, 'cron_run', jsonb_build_object('schedule_id', $2::text), $3, 100, $4)`, + workspaceID, scheduleID, status, when); err != nil { + t.Fatalf("seed activity_log: %v", err) + } +} + +// doPost is a tiny helper that fires Create against a fresh gin context. +func doPost_SchedulesCreate(t *testing.T, h *ScheduleHandler, workspaceID string, body string) *httptest.ResponseRecorder { + t.Helper() + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "id", Value: workspaceID}} + c.Request = httptest.NewRequest("POST", "/workspaces/"+workspaceID+"/schedules", bytes.NewReader([]byte(body))) + c.Request.Header.Set("Content-Type", "application/json") + h.Create(c) + return w +} + +func doPatch_SchedulesUpdate(t *testing.T, h *ScheduleHandler, workspaceID, scheduleID, body string) *httptest.ResponseRecorder { + t.Helper() + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "id", Value: workspaceID}, {Key: "scheduleId", Value: scheduleID}} + c.Request = httptest.NewRequest("PATCH", "/workspaces/"+workspaceID+"/schedules/"+scheduleID, bytes.NewReader([]byte(body))) + c.Request.Header.Set("Content-Type", "application/json") + h.Update(c) + return w +} + +// TestIntegration_Schedules_CRUDRunHistoryHealth_RoundTrip is the main +// regression gate for the schedules surface end-to-end. +func TestIntegration_Schedules_CRUDRunHistoryHealth_RoundTrip(t *testing.T) { + conn := integrationDB_Schedules(t) + handler := NewScheduleHandler() + + wsA := integUUID("integ-sch-ws-a") + wsB := integUUID("integ-sch-ws-b") + seedWorkspace_Schedules(t, conn, wsA) + seedWorkspace_Schedules(t, conn, wsB) + + // --- Case 1: CREATE inserts a row with computed next_run_at --- + w := doPost_SchedulesCreate(t, handler, wsA, + `{"name":"daily-backup","cron_expr":"0 3 * * *","timezone":"UTC","prompt":"run backup"}`) + if w.Code != http.StatusCreated { + t.Fatalf("CREATE: status want 201, got %d: %s", w.Code, w.Body.String()) + } + var created struct { + ID string `json:"id"` + Status string `json:"status"` + NextRunAt time.Time `json:"next_run_at"` + } + if err := json.Unmarshal(w.Body.Bytes(), &created); err != nil { + t.Fatalf("CREATE: parse: %v", err) + } + if created.ID == "" { + t.Fatal("CREATE: id empty in response") + } + // next_run_at must be > now (a future 3am UTC time). + if !created.NextRunAt.After(time.Now().Add(-1 * time.Minute)) { + t.Errorf("CREATE: next_run_at want in future, got %v", created.NextRunAt) + } + // Verify the row in DB has source='runtime' (issue #24). + var source string + if err := conn.QueryRowContext(context.Background(), + `SELECT source FROM workspace_schedules WHERE id = $1`, created.ID).Scan(&source); err != nil { + t.Fatalf("read source: %v", err) + } + if source != "runtime" { + t.Errorf("CREATE: source in DB want runtime, got %q", source) + } + + // --- Case 2: LIST returns the row, plus only rows for wsA --- + w = httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "id", Value: wsA}} + c.Request = httptest.NewRequest("GET", "/workspaces/"+wsA+"/schedules", nil) + handler.List(c) + if w.Code != http.StatusOK { + t.Fatalf("LIST: status want 200, got %d: %s", w.Code, w.Body.String()) + } + var listed []ScheduleResponse + json.Unmarshal(w.Body.Bytes(), &listed) + if len(listed) != 1 { + t.Errorf("LIST: want 1 schedule for wsA, got %d", len(listed)) + } + if len(listed) > 0 && listed[0].ID != created.ID { + t.Errorf("LIST: id want %q, got %q", created.ID, listed[0].ID) + } + if len(listed) > 0 && listed[0].Prompt != "run backup" { + t.Errorf("LIST: prompt want %q, got %q", "run backup", listed[0].Prompt) + } + + // --- Case 3: UPDATE with NEW cron_expr recomputes next_run_at --- + // Read the original next_run_at, then PATCH with a different cron. + var origNextRun time.Time + if err := conn.QueryRowContext(context.Background(), + `SELECT next_run_at FROM workspace_schedules WHERE id = $1`, created.ID).Scan(&origNextRun); err != nil { + t.Fatalf("read orig next_run_at: %v", err) + } + // Pick a cron that lands at a noticeably different time. "0 5 * * *" = 5am UTC. + w = doPatch_SchedulesUpdate(t, handler, wsA, created.ID, + `{"cron_expr":"0 5 * * *"}`) + if w.Code != http.StatusOK { + t.Fatalf("UPDATE cron: status want 200, got %d: %s", w.Code, w.Body.String()) + } + var newNextRun time.Time + if err := conn.QueryRowContext(context.Background(), + `SELECT next_run_at FROM workspace_schedules WHERE id = $1`, created.ID).Scan(&newNextRun); err != nil { + t.Fatalf("read new next_run_at: %v", err) + } + if !newNextRun.After(origNextRun) { + t.Errorf("UPDATE cron: next_run_at should have moved (orig=%v new=%v)", origNextRun, newNextRun) + } + + // --- Case 4: UPDATE with NEW timezone also recomputes next_run_at --- + w = doPatch_SchedulesUpdate(t, handler, wsA, created.ID, + `{"timezone":"America/Los_Angeles"}`) + if w.Code != http.StatusOK { + t.Fatalf("UPDATE tz: status want 200, got %d: %s", w.Code, w.Body.String()) + } + + // --- Case 5: UPDATE with INVALID timezone → 400, DB unchanged --- + var beforeTZ string + conn.QueryRowContext(context.Background(), + `SELECT timezone FROM workspace_schedules WHERE id = $1`, created.ID).Scan(&beforeTZ) + w = doPatch_SchedulesUpdate(t, handler, wsA, created.ID, + `{"timezone":"Not/A/Zone"}`) + if w.Code != http.StatusBadRequest { + t.Errorf("UPDATE bad tz: status want 400, got %d: %s", w.Code, w.Body.String()) + } + var afterTZ string + conn.QueryRowContext(context.Background(), + `SELECT timezone FROM workspace_schedules WHERE id = $1`, created.ID).Scan(&afterTZ) + if beforeTZ != afterTZ { + t.Errorf("UPDATE bad tz mutated DB: before=%q after=%q", beforeTZ, afterTZ) + } + + // --- Case 6: UPDATE on wrong-workspace ID (IDOR) → 404 --- + // Try to update wsA's schedule through wsB's path. + w = doPatch_SchedulesUpdate(t, handler, wsB, created.ID, + `{"name":"hijacked"}`) + if w.Code != http.StatusNotFound { + t.Errorf("UPDATE wrong-ws: status want 404, got %d: %s", w.Code, w.Body.String()) + } + // Verify name unchanged. + var nameAfter string + conn.QueryRowContext(context.Background(), + `SELECT name FROM workspace_schedules WHERE id = $1`, created.ID).Scan(&nameAfter) + if nameAfter == "hijacked" { + t.Errorf("UPDATE wrong-ws: mutated DB through IDOR path (name=%q)", nameAfter) + } + + // --- Case 7: RUNNOW returns the stored prompt, does NOT fire A2A --- + w = httptest.NewRecorder() + c, _ = gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "id", Value: wsA}, {Key: "scheduleId", Value: created.ID}} + c.Request = httptest.NewRequest("POST", "/workspaces/"+wsA+"/schedules/"+created.ID+"/run", nil) + handler.RunNow(c) + if w.Code != http.StatusOK { + t.Fatalf("RUNNOW: status want 200, got %d: %s", w.Code, w.Body.String()) + } + var runNow struct { + Status string `json:"status"` + WorkspaceID string `json:"workspace_id"` + Prompt string `json:"prompt"` + } + if err := json.Unmarshal(w.Body.Bytes(), &runNow); err != nil { + t.Fatalf("RUNNOW: parse: %v", err) + } + if runNow.Status != "fired" { + t.Errorf("RUNNOW: status want fired, got %q", runNow.Status) + } + if runNow.Prompt != "run backup" { + t.Errorf("RUNNOW: prompt want %q, got %q", "run backup", runNow.Prompt) + } + // Verify the prompt in the DB is unchanged (RunNow is a read). + var promptAfter string + conn.QueryRowContext(context.Background(), + `SELECT prompt FROM workspace_schedules WHERE id = $1`, created.ID).Scan(&promptAfter) + if promptAfter != "run backup" { + t.Errorf("RUNNOW: mutated prompt in DB (got %q)", promptAfter) + } + + // --- Case 8: HISTORY reads activity_logs filtered by request_body->>'schedule_id' --- + // Seed two activity_log rows: one for our schedule, one for a different schedule. + // Plus a row for a different workspace that must NOT leak. + seedActivityLog_Schedules(t, conn, wsA, created.ID, "ok", time.Now().Add(-2*time.Minute)) + seedActivityLog_Schedules(t, conn, wsA, created.ID, "error", time.Now().Add(-1*time.Minute)) + seedActivityLog_Schedules(t, conn, wsA, "different-schedule-id", "ok", time.Now().Add(-30*time.Second)) + seedActivityLog_Schedules(t, conn, wsB, created.ID, "ok", time.Now().Add(-15*time.Second)) // different ws + + w = httptest.NewRecorder() + c, _ = gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "id", Value: wsA}, {Key: "scheduleId", Value: created.ID}} + c.Request = httptest.NewRequest("GET", "/workspaces/"+wsA+"/schedules/"+created.ID+"/history", nil) + handler.History(c) + if w.Code != http.StatusOK { + t.Fatalf("HISTORY: status want 200, got %d: %s", w.Code, w.Body.String()) + } + // Decode into a slice of generic history entries. + var hist []map[string]interface{} + if err := json.Unmarshal(w.Body.Bytes(), &hist); err != nil { + t.Fatalf("HISTORY: parse: %v", err) + } + // Must have exactly 2 entries (the two for our schedule in wsA). + if len(hist) != 2 { + t.Errorf("HISTORY: want 2 entries for our schedule+wsA, got %d: %+v", len(hist), hist) + } + + // --- Case 9: HEALTH (self-call) returns health fields only --- + // The self-call path (callerID == workspaceID) is always allowed — + // no CanCommunicate check fires, no token check fires. + w = httptest.NewRecorder() + c, _ = gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "id", Value: wsA}} + c.Request = httptest.NewRequest("GET", "/workspaces/"+wsA+"/schedules/health", nil) + c.Request.Header.Set("X-Workspace-ID", wsA) // self-call + handler.Health(c) + if w.Code != http.StatusOK { + t.Fatalf("HEALTH self: status want 200, got %d: %s", w.Code, w.Body.String()) + } + var health []ScheduleHealthResponse + json.Unmarshal(w.Body.Bytes(), &health) + if len(health) != 1 { + t.Errorf("HEALTH self: want 1 entry for wsA, got %d", len(health)) + } + if len(health) > 0 { + // Must NOT include Prompt or CronExpr (per the comment on + // ScheduleHealthResponse — issue #249). + rawJSON := w.Body.String() + if bytes.Contains([]byte(rawJSON), []byte("run backup")) { + t.Errorf("HEALTH self: response leaked prompt (issue #249)") + } + if bytes.Contains([]byte(rawJSON), []byte("cron_expr")) { + t.Errorf("HEALTH self: response leaked cron_expr field (issue #249)") + } + } + + // --- Case 10: HEALTH missing X-Workspace-ID → 401 --- + w = httptest.NewRecorder() + c, _ = gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "id", Value: wsA}} + c.Request = httptest.NewRequest("GET", "/workspaces/"+wsA+"/schedules/health", nil) + // no X-Workspace-ID header + handler.Health(c) + if w.Code != http.StatusUnauthorized { + t.Errorf("HEALTH anon: status want 401, got %d: %s", w.Code, w.Body.String()) + } + + // --- Case 11: DELETE removes the row --- + w = httptest.NewRecorder() + c, _ = gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "id", Value: wsA}, {Key: "scheduleId", Value: created.ID}} + c.Request = httptest.NewRequest("DELETE", "/workspaces/"+wsA+"/schedules/"+created.ID, nil) + handler.Delete(c) + if w.Code != http.StatusOK { + t.Fatalf("DELETE: status want 200, got %d: %s", w.Code, w.Body.String()) + } + // Verify row is gone. + var n int + if err := conn.QueryRowContext(context.Background(), + `SELECT COUNT(*) FROM workspace_schedules WHERE id = $1`, created.ID).Scan(&n); err != nil { + t.Fatalf("verify delete: %v", err) + } + if n != 0 { + t.Errorf("DELETE: row still in DB (count=%d)", n) + } + + // --- Case 12: DELETE on already-deleted schedule → 404 --- + w = httptest.NewRecorder() + c, _ = gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "id", Value: wsA}, {Key: "scheduleId", Value: created.ID}} + c.Request = httptest.NewRequest("DELETE", "/workspaces/"+wsA+"/schedules/"+created.ID, nil) + handler.Delete(c) + if w.Code != http.StatusNotFound { + t.Errorf("DELETE gone: status want 404, got %d: %s", w.Code, w.Body.String()) + } +} diff --git a/workspace-server/internal/handlers/tokens_integration_test.go b/workspace-server/internal/handlers/tokens_integration_test.go new file mode 100644 index 000000000..bceb534c5 --- /dev/null +++ b/workspace-server/internal/handlers/tokens_integration_test.go @@ -0,0 +1,275 @@ +//go:build integration +// +build integration + +// tokens_integration_test.go — REAL Postgres integration tests for +// /workspaces/:id/tokens (GET/POST/DELETE — handlers/tokens.go). +// +// Mirrors pending_uploads_integration_test.go / +// delegation_ledger_integration_test.go. Unit tests in tokens_test.go +// pin the SQL shape; these tests pin the OBSERVABLE row state: +// - POST mints via real wsauth.IssueToken, plaintext returned once +// - workspace_auth_tokens has exactly one row with sha256(token_hash) +// - GET returns only non-revoked rows +// - DELETE sets revoked_at; subsequent DELETE is 404 +// - max-active-cap (50) returns 429 +// +// Run with: +// +// docker run --rm -d --name pg-integration \ +// -e POSTGRES_PASSWORD=test -e POSTGRES_DB=molecule \ +// -p 55432:5432 postgres:15-alpine +// sleep 4 +// psql ... < workspace-server/migrations/001_workspaces.sql +// psql ... < workspace-server/migrations/020_workspace_auth_tokens.up.sql +// cd workspace-server +// INTEGRATION_DB_URL="postgres://postgres:test@localhost:55432/molecule?sslmode=disable" \ +// go test -tags=integration ./internal/handlers/ -run Integration_Tokens -v + +package handlers + +import ( + "bytes" + "context" + "crypto/sha256" + "database/sql" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/gin-gonic/gin" + _ "github.com/lib/pq" + "git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/db" +) + +// integrationDB_Tokens opens the integration PG connection, wipes our +// test rows, and hot-swaps the package-level db.DB. NOT SAFE for +// t.Parallel() — the global db.DB is shared. +func integrationDB_Tokens(t *testing.T) *sql.DB { + t.Helper() + url := os.Getenv("INTEGRATION_DB_URL") + if url == "" { + t.Skip("INTEGRATION_DB_URL not set; skipping (local devs: see file header)") + } + conn, err := sql.Open("postgres", url) + if err != nil { + t.Fatalf("open: %v", err) + } + if err := conn.Ping(); err != nil { + t.Fatalf("ping: %v", err) + } + if _, err := conn.ExecContext(context.Background(), + `DELETE FROM workspace_auth_tokens WHERE workspace_id IN (SELECT id FROM workspaces WHERE name LIKE 'integ-tok-%')`); err != nil { + t.Fatalf("cleanup tokens: %v", err) + } + if _, err := conn.ExecContext(context.Background(), + `DELETE FROM workspaces WHERE name LIKE 'integ-tok-%'`); err != nil { + t.Fatalf("cleanup workspaces: %v", err) + } + prev := db.DB + db.DB = conn + t.Cleanup(func() { + conn.ExecContext(context.Background(), `DELETE FROM workspace_auth_tokens WHERE workspace_id IN (SELECT id FROM workspaces WHERE name LIKE 'integ-tok-%')`) + conn.ExecContext(context.Background(), `DELETE FROM workspaces WHERE name LIKE 'integ-tok-%'`) + db.DB = prev + conn.Close() + }) + return conn +} + +func seedWorkspace_Tokens(t *testing.T, conn *sql.DB, id string) { + t.Helper() + if _, err := conn.ExecContext(context.Background(), + `INSERT INTO workspaces (id, name, status) VALUES ($1, $2, 'online')`, + id, "integ-tok-"+id); err != nil { + t.Fatalf("seed: %v", err) + } +} + +// countActiveTokens returns COUNT(*) of non-revoked tokens for the workspace. +func countActiveTokens(t *testing.T, conn *sql.DB, workspaceID string) int { + t.Helper() + var n int + if err := conn.QueryRowContext(context.Background(), + `SELECT COUNT(*) FROM workspace_auth_tokens WHERE workspace_id = $1 AND revoked_at IS NULL`, + workspaceID).Scan(&n); err != nil { + t.Fatalf("count active: %v", err) + } + return n +} + +// TestIntegration_Tokens_CreateListRevoke_RoundTrip pins the full +// create → list → revoke lifecycle and the max-active-cap 429 path. +func TestIntegration_Tokens_CreateListRevoke_RoundTrip(t *testing.T) { + conn := integrationDB_Tokens(t) + handler := NewTokenHandler() + + wsA := integUUID("integ-tok-ws-a") + wsB := integUUID("integ-tok-ws-b") + seedWorkspace_Tokens(t, conn, wsA) + seedWorkspace_Tokens(t, conn, wsB) + + // --- Case 1: POST mints, plaintext once, DB row has matching sha256 --- + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "id", Value: wsA}} + c.Request = httptest.NewRequest("POST", "/workspaces/"+wsA+"/tokens", nil) + handler.Create(c) + if w.Code != http.StatusCreated { + t.Fatalf("POST: status want 201, got %d: %s", w.Code, w.Body.String()) + } + var mint1 struct { + AuthToken string `json:"auth_token"` + WorkspaceID string `json:"workspace_id"` + Message string `json:"message"` + } + if err := json.Unmarshal(w.Body.Bytes(), &mint1); err != nil { + t.Fatalf("POST: parse: %v", err) + } + if mint1.AuthToken == "" { + t.Fatal("POST: auth_token empty") + } + if mint1.WorkspaceID != wsA { + t.Errorf("POST: workspace_id want %q, got %q", wsA, mint1.WorkspaceID) + } + // Verify the row in workspace_auth_tokens: count should be 1, and + // the row's token_hash should be sha256(mint1.AuthToken). + if n := countActiveTokens(t, conn, wsA); n != 1 { + t.Errorf("POST: active count want 1, got %d", n) + } + want := sha256.Sum256([]byte(mint1.AuthToken)) + var hashMatch int + if err := conn.QueryRowContext(context.Background(), + `SELECT COUNT(*) FROM workspace_auth_tokens WHERE workspace_id = $1 AND token_hash = $2`, + wsA, want[:]).Scan(&hashMatch); err != nil { + t.Fatalf("verify hash: %v", err) + } + if hashMatch != 1 { + t.Errorf("POST: want exactly 1 row with sha256(token), got %d", hashMatch) + } + + // --- Case 2: POST second token, GET lists both (non-revoked only) --- + w = httptest.NewRecorder() + c, _ = gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "id", Value: wsA}} + c.Request = httptest.NewRequest("POST", "/workspaces/"+wsA+"/tokens", nil) + handler.Create(c) + if w.Code != http.StatusCreated { + t.Fatalf("POST 2: status want 201, got %d: %s", w.Code, w.Body.String()) + } + if n := countActiveTokens(t, conn, wsA); n != 2 { + t.Errorf("after 2 mints: active count want 2, got %d", n) + } + + // GET should return 2 tokens. + w = httptest.NewRecorder() + c, _ = gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "id", Value: wsA}} + c.Request = httptest.NewRequest("GET", "/workspaces/"+wsA+"/tokens", nil) + handler.List(c) + if w.Code != http.StatusOK { + t.Fatalf("LIST: status want 200, got %d: %s", w.Code, w.Body.String()) + } + var list1 struct { + Tokens []tokenListItem `json:"tokens"` + Count int `json:"count"` + } + if err := json.Unmarshal(w.Body.Bytes(), &list1); err != nil { + t.Fatalf("LIST: parse: %v", err) + } + if list1.Count != 2 || len(list1.Tokens) != 2 { + t.Errorf("LIST: want 2 tokens, got count=%d len=%d", list1.Count, len(list1.Tokens)) + } + // The list should NOT include the plaintext or hash. + for _, tk := range list1.Tokens { + if tk.Prefix == "" { + t.Errorf("LIST: token prefix empty (got %+v)", tk) + } + } + + // --- Case 3: GET filters out revoked tokens (pre-revoke + post-revoke check) --- + // Pick the first token's ID, revoke it, then GET — should return 1. + targetID := list1.Tokens[0].ID + w = httptest.NewRecorder() + c, _ = gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "id", Value: wsA}, {Key: "tokenId", Value: targetID}} + c.Request = httptest.NewRequest("DELETE", "/workspaces/"+wsA+"/tokens/"+targetID, nil) + handler.Revoke(c) + if w.Code != http.StatusOK { + t.Fatalf("REVOKE: status want 200, got %d: %s", w.Code, w.Body.String()) + } + // Verify revoked_at is set in DB. + var revokedAt sql.NullTime + if err := conn.QueryRowContext(context.Background(), + `SELECT revoked_at FROM workspace_auth_tokens WHERE id = $1`, targetID).Scan(&revokedAt); err != nil { + t.Fatalf("read revoked_at: %v", err) + } + if !revokedAt.Valid { + t.Errorf("REVOKE: revoked_at in DB should be set, got NULL") + } + + // GET after revoke: should show only 1 token. + w = httptest.NewRecorder() + c, _ = gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "id", Value: wsA}} + c.Request = httptest.NewRequest("GET", "/workspaces/"+wsA+"/tokens", nil) + handler.List(c) + var list2 struct { + Count int `json:"count"` + } + json.Unmarshal(w.Body.Bytes(), &list2) + if list2.Count != 1 { + t.Errorf("LIST after revoke: want 1, got %d", list2.Count) + } + + // --- Case 4: DELETE on already-revoked token → 404 --- + w = httptest.NewRecorder() + c, _ = gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "id", Value: wsA}, {Key: "tokenId", Value: targetID}} + c.Request = httptest.NewRequest("DELETE", "/workspaces/"+wsA+"/tokens/"+targetID, nil) + handler.Revoke(c) + if w.Code != http.StatusNotFound { + t.Errorf("REVOKE revoked: status want 404, got %d: %s", w.Code, w.Body.String()) + } + + // --- Case 5: max-active-cap (50) — seed 50, then 51st → 429 --- + wsCap := integUUID("integ-tok-ws-cap") + seedWorkspace_Tokens(t, conn, wsCap) + // Insert 50 active tokens directly to avoid hammering IssueToken 50 times. + for i := 0; i < maxTokensPerWorkspace; i++ { + if _, err := conn.ExecContext(context.Background(), + `INSERT INTO workspace_auth_tokens (workspace_id, token_hash, prefix) VALUES ($1, $2, $3)`, + wsCap, []byte{byte(i)}, "pre"); err != nil { + t.Fatalf("seed cap: %v", err) + } + } + w = httptest.NewRecorder() + c, _ = gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "id", Value: wsCap}} + c.Request = httptest.NewRequest("POST", "/workspaces/"+wsCap+"/tokens", nil) + handler.Create(c) + if w.Code != http.StatusTooManyRequests { + t.Errorf("max-cap: status want 429, got %d: %s", w.Code, w.Body.String()) + } + + // --- Case 6: wsB is isolated — its tokens don't show in wsA's list --- + w = httptest.NewRecorder() + c, _ = gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "id", Value: wsB}} + c.Request = httptest.NewRequest("POST", "/workspaces/"+wsB+"/tokens", nil) + handler.Create(c) + if w.Code != http.StatusCreated { + t.Fatalf("POST wsB: status want 201, got %d: %s", w.Code, w.Body.String()) + } + // wsA should still have 1 active (the one not revoked). + if n := countActiveTokens(t, conn, wsA); n != 1 { + t.Errorf("isolation: wsA active count want 1, got %d", n) + } + if n := countActiveTokens(t, conn, wsB); n != 1 { + t.Errorf("isolation: wsB active count want 1, got %d", n) + } +} + +// keep the import block referenced even if a case is removed in a future edit. +var _ = bytes.NewReader