From 47ace026902fcf5cb316661bd64dd747ceeea923 Mon Sep 17 00:00:00 2001 From: "Molecule AI Dev Engineer B (MiniMax)" Date: Wed, 3 Jun 2026 13:47:21 +0000 Subject: [PATCH 01/12] feat(#2151): add chunk 2 real-infra handler integration tests --- ...admin_schedules_health_integration_test.go | 189 ++++++++++++++++++ 1 file changed, 189 insertions(+) create mode 100644 workspace-server/internal/handlers/admin_schedules_health_integration_test.go 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..7775c24ea --- /dev/null +++ b/workspace-server/internal/handlers/admin_schedules_health_integration_test.go @@ -0,0 +1,189 @@ +//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" + mdb "github.com/Molecule-AI/molecule-monorepo/platform/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 LIKE 'integ-ash-%'`); err != nil { + t.Fatalf("cleanup schedules: %v", err) + } + if _, err := conn.ExecContext(context.Background(), + `DELETE FROM workspaces WHERE id LIKE 'integ-ash-%'`); err != nil { + t.Fatalf("cleanup workspaces: %v", err) + } + prev := mdb.DB + mdb.DB = conn + t.Cleanup(func() { + conn.ExecContext(context.Background(), `DELETE FROM workspace_schedules WHERE workspace_id LIKE 'integ-ash-%'`) + conn.ExecContext(context.Background(), `DELETE FROM workspaces WHERE id LIKE 'integ-ash-%'`) + mdb.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). + wsOK := "integ-ash-ws-ok" + wsStale := "integ-ash-ws-stale" + wsRemoved := "integ-ash-ws-removed" + seedWorkspace_AdminSchedulesHealth(t, conn, wsOK, "running") + seedWorkspace_AdminSchedulesHealth(t, conn, wsStale, "running") + 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 (every-15-min = 1800s × 2 = 3600s) --- + if e, ok := byName["ok_schedule"]; ok { + // Allow ±5s slack for runtime compute jitter. + if e.StaleThresholdSeconds < 3590 || e.StaleThresholdSeconds > 3610 { + t.Errorf("ok_schedule: stale_threshold_seconds want ~3600 (2× 15min), got %d", e.StaleThresholdSeconds) + } + } +} -- 2.52.0 From ab13232dd74bcde163f526dd68c1e235e0de3055 Mon Sep 17 00:00:00 2001 From: "Molecule AI Dev Engineer B (MiniMax)" Date: Wed, 3 Jun 2026 13:47:21 +0000 Subject: [PATCH 02/12] feat(#2151): add admin_test_token_integration_test.go --- .../admin_test_token_integration_test.go | 204 ++++++++++++++++++ 1 file changed, 204 insertions(+) create mode 100644 workspace-server/internal/handlers/admin_test_token_integration_test.go diff --git a/workspace-server/internal/handlers/admin_test_token_integration_test.go b/workspace-server/internal/handlers/admin_test_token_integration_test.go new file mode 100644 index 000000000..942b126c7 --- /dev/null +++ b/workspace-server/internal/handlers/admin_test_token_integration_test.go @@ -0,0 +1,204 @@ +//go:build integration +// +build integration + +// admin_test_token_integration_test.go — REAL Postgres integration tests +// for GET /admin/workspaces/:id/test-token (handlers/admin_test_token.go). +// +// Mirrors the pending_uploads_integration_test.go / +// delegation_ledger_integration_test.go pattern (handlers-postgres-integration.yml). +// Unit tests in admin_test_token_test.go pin the route shape + TestTokensEnabled +// gating; these tests pin the OBSERVABLE behavior against real DB rows: +// - 404 in production-disabled mode (MOLECULE_ENV=production) +// - exact ADMIN_TOKEN match when set +// - 404 for unknown workspace +// - minted auth_token validates against the real workspace_auth_tokens table +// +// 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_AdminTestToken -v + +package handlers + +import ( + "context" + "crypto/sha256" + "database/sql" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/gin-gonic/gin" + _ "github.com/lib/pq" + mdb "github.com/Molecule-AI/molecule-monorepo/platform/internal/db" +) + +// integrationDB_AdminTestToken opens the integration PG connection, wipes +// the workspaces + workspace_auth_tokens tables for our test rows, and +// hot-swaps the package-level mdb.DB so the handler sees the same conn. +// NOT SAFE FOR t.Parallel() — each test must own the global. +func integrationDB_AdminTestToken(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 LIKE 'integ-adm-%'`); err != nil { + t.Fatalf("cleanup tokens: %v", err) + } + if _, err := conn.ExecContext(context.Background(), + `DELETE FROM workspaces WHERE id LIKE 'integ-adm-%'`); err != nil { + t.Fatalf("cleanup workspaces: %v", err) + } + prev := mdb.DB + mdb.DB = conn + t.Cleanup(func() { + conn.ExecContext(context.Background(), `DELETE FROM workspace_auth_tokens WHERE workspace_id LIKE 'integ-adm-%'`) + conn.ExecContext(context.Background(), `DELETE FROM workspaces WHERE id LIKE 'integ-adm-%'`) + mdb.DB = prev + conn.Close() + }) + return conn +} + +// seedWorkspace_AdminTestToken inserts a minimal workspaces row so the +// test-token handler can find it. +func seedWorkspace_AdminTestToken(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, 'running')`, + id, "integ-adm-"+id); err != nil { + t.Fatalf("seed workspace: %v", err) + } +} + +// TestIntegration_AdminTestToken_AuthGateAndMint pins the production +// gating, the ADMIN_TOKEN bearer match, the 404-on-unknown path, and +// that the minted auth_token lands in workspace_auth_tokens with a +// matching sha256(token_hash) row. +func TestIntegration_AdminTestToken_AuthGateAndMint(t *testing.T) { + conn := integrationDB_AdminTestToken(t) + handler := NewAdminTestTokenHandler() + + wsOK := "integ-adm-ws-ok" + wsGhost := "integ-adm-ws-ghost" + seedWorkspace_AdminTestToken(t, conn, wsOK) + + // --- Case 1: production-disabled (MOLECULE_ENV=production) → 404 --- + // The handler returns 404 (not 403) so attackers can't probe for the + // route's existence. t.Setenv restores on test exit. + t.Setenv("MOLECULE_ENV", "production") + t.Setenv("MOLECULE_ENABLE_TEST_TOKENS", "") + t.Setenv("ADMIN_TOKEN", "") + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "id", Value: wsOK}} + c.Request = httptest.NewRequest("GET", "/admin/workspaces/"+wsOK+"/test-token", nil) + handler.GetTestToken(c) + if w.Code != http.StatusNotFound { + t.Errorf("prod-disabled: status want 404, got %d: %s", w.Code, w.Body.String()) + } + + // Re-enable for the rest of the cases. + t.Setenv("MOLECULE_ENV", "dev") + t.Setenv("MOLECULE_ENABLE_TEST_TOKENS", "1") + + // --- Case 2: enabled, no ADMIN_TOKEN set, valid workspace → 200 with auth_token --- + t.Setenv("ADMIN_TOKEN", "") + w = httptest.NewRecorder() + c, _ = gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "id", Value: wsOK}} + c.Request = httptest.NewRequest("GET", "/admin/workspaces/"+wsOK+"/test-token", nil) + handler.GetTestToken(c) + if w.Code != http.StatusOK { + t.Fatalf("mint no-admin: status want 200, got %d: %s", w.Code, w.Body.String()) + } + var resp1 struct { + AuthToken string `json:"auth_token"` + WorkspaceID string `json:"workspace_id"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp1); err != nil { + t.Fatalf("mint no-admin: parse: %v", err) + } + if resp1.AuthToken == "" { + t.Errorf("mint no-admin: auth_token empty in response") + } + if resp1.WorkspaceID != wsOK { + t.Errorf("mint no-admin: workspace_id want %q, got %q", wsOK, resp1.WorkspaceID) + } + + // --- Case 3: enabled, ADMIN_TOKEN set, wrong bearer → 401 --- + t.Setenv("ADMIN_TOKEN", "real-admin-secret-xyz") + w = httptest.NewRecorder() + c, _ = gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "id", Value: wsOK}} + c.Request = httptest.NewRequest("GET", "/admin/workspaces/"+wsOK+"/test-token", nil) + c.Request.Header.Set("Authorization", "Bearer wrong-secret") + handler.GetTestToken(c) + if w.Code != http.StatusUnauthorized { + t.Errorf("admin wrong: status want 401, got %d: %s", w.Code, w.Body.String()) + } + + // --- Case 4: enabled, ADMIN_TOKEN set, correct bearer → 200 --- + w = httptest.NewRecorder() + c, _ = gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "id", Value: wsOK}} + c.Request = httptest.NewRequest("GET", "/admin/workspaces/"+wsOK+"/test-token", nil) + c.Request.Header.Set("Authorization", "Bearer real-admin-secret-xyz") + handler.GetTestToken(c) + if w.Code != http.StatusOK { + t.Fatalf("admin correct: status want 200, got %d: %s", w.Code, w.Body.String()) + } + var resp2 struct { + AuthToken string `json:"auth_token"` + WorkspaceID string `json:"workspace_id"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp2); err != nil { + t.Fatalf("admin correct: parse: %v", err) + } + if resp2.AuthToken == "" { + t.Errorf("admin correct: auth_token empty in response") + } + + // --- Case 5: enabled, unknown workspace → 404 --- + w = httptest.NewRecorder() + c, _ = gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "id", Value: wsGhost}} + c.Request = httptest.NewRequest("GET", "/admin/workspaces/"+wsGhost+"/test-token", nil) + handler.GetTestToken(c) + if w.Code != http.StatusNotFound { + t.Errorf("ghost ws: status want 404, got %d: %s", w.Code, w.Body.String()) + } + + // --- Case 6: mint validates against real DB --- + // Take the auth_token from Case 4 and verify there's a workspace_auth_tokens + // row whose token_hash = sha256(auth_token) for wsOK. + want := sha256.Sum256([]byte(resp2.AuthToken)) + var rowCount int + if err := conn.QueryRowContext(context.Background(), + `SELECT COUNT(*) FROM workspace_auth_tokens WHERE workspace_id = $1 AND token_hash = $2`, + wsOK, want[:], + ).Scan(&rowCount); err != nil { + t.Fatalf("verify hash: %v", err) + } + if rowCount != 1 { + t.Errorf("verify hash: want exactly 1 row matching sha256(token) for wsOK, got %d", rowCount) + } +} -- 2.52.0 From ee4004e198568c9da6c54f1eb6e69a9348d9c864 Mon Sep 17 00:00:00 2001 From: "Molecule AI Dev Engineer B (MiniMax)" Date: Wed, 3 Jun 2026 13:47:22 +0000 Subject: [PATCH 03/12] feat(#2151): add budget_integration_test.go --- .../handlers/budget_integration_test.go | 318 ++++++++++++++++++ 1 file changed, 318 insertions(+) create mode 100644 workspace-server/internal/handlers/budget_integration_test.go 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..0cbfd2831 --- /dev/null +++ b/workspace-server/internal/handlers/budget_integration_test.go @@ -0,0 +1,318 @@ +//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 +// 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" + mdb "github.com/Molecule-AI/molecule-monorepo/platform/internal/db" +) + +// integrationDB_Budget opens the integration PG connection, wipes our +// test rows, and hot-swaps the package-level mdb.DB. NOT SAFE for +// t.Parallel() — the global mdb.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) + } + if _, err := conn.ExecContext(context.Background(), + `DELETE FROM workspaces WHERE id LIKE 'integ-bud-%'`); err != nil { + t.Fatalf("cleanup: %v", err) + } + prev := mdb.DB + mdb.DB = conn + t.Cleanup(func() { + conn.ExecContext(context.Background(), `DELETE FROM workspaces WHERE id LIKE 'integ-bud-%'`) + mdb.DB = prev + conn.Close() + }) + return conn +} + +// seedWorkspace_Budget inserts a workspaces row with optional budget_limit +// (nil = NULL) and a fixed monthly_spend. The status is always 'running' — +// 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() + var lim interface{} = nil + if budgetLimit != nil { + lim = *budgetLimit + } + if _, err := conn.ExecContext(context.Background(), + `INSERT INTO workspaces (id, name, status, budget_limit, monthly_spend) + VALUES ($1, $2, 'running', $3, $4)`, + id, "integ-bud-"+id, lim, monthlySpend); err != nil { + t.Fatalf("seed: %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 := "integ-bud-ws-a" + wsB := "integ-bud-ws-b" + wsRemoved := "integ-bud-ws-removed" + wsGhost := "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, wsA+"over", &overLim, 1500) + // removed-workspace case + if _, err := conn.ExecContext(context.Background(), + `INSERT INTO workspaces (id, name, status, budget_limit, monthly_spend) + VALUES ($1, 'integ-bud-removed', 'removed', NULL, 0)`, 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, wsA+"over") + 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 } -- 2.52.0 From 36b0f16a374445f4fe8f6b2a4dca664bc5fdc743 Mon Sep 17 00:00:00 2001 From: "Molecule AI Dev Engineer B (MiniMax)" Date: Wed, 3 Jun 2026 13:47:23 +0000 Subject: [PATCH 04/12] feat(#2151): add schedules_integration_test.go --- .../handlers/schedules_integration_test.go | 377 ++++++++++++++++++ 1 file changed, 377 insertions(+) create mode 100644 workspace-server/internal/handlers/schedules_integration_test.go 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..bc05a2046 --- /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" + mdb "github.com/Molecule-AI/molecule-monorepo/platform/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 LIKE 'integ-sch-%'`, + `DELETE FROM workspace_schedules WHERE workspace_id LIKE 'integ-sch-%'`, + `DELETE FROM workspaces WHERE id LIKE 'integ-sch-%'`, + } { + if _, err := conn.ExecContext(context.Background(), stmt); err != nil { + t.Fatalf("cleanup %q: %v", stmt, err) + } + } + prev := mdb.DB + mdb.DB = conn + t.Cleanup(func() { + conn.ExecContext(context.Background(), `DELETE FROM activity_logs WHERE workspace_id LIKE 'integ-sch-%'`) + conn.ExecContext(context.Background(), `DELETE FROM workspace_schedules WHERE workspace_id LIKE 'integ-sch-%'`) + conn.ExecContext(context.Background(), `DELETE FROM workspaces WHERE id LIKE 'integ-sch-%'`) + mdb.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, 'running')`, + 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 := "integ-sch-ws-a" + wsB := "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()) + } +} -- 2.52.0 From 3756c4ac73111ee430e902e7f92c5553d9ab50e5 Mon Sep 17 00:00:00 2001 From: "Molecule AI Dev Engineer B (MiniMax)" Date: Wed, 3 Jun 2026 13:47:24 +0000 Subject: [PATCH 05/12] feat(#2151): add tokens_integration_test.go --- .../handlers/tokens_integration_test.go | 275 ++++++++++++++++++ 1 file changed, 275 insertions(+) create mode 100644 workspace-server/internal/handlers/tokens_integration_test.go 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..d7b2c6867 --- /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" + mdb "github.com/Molecule-AI/molecule-monorepo/platform/internal/db" +) + +// integrationDB_Tokens opens the integration PG connection, wipes our +// test rows, and hot-swaps the package-level mdb.DB. NOT SAFE for +// t.Parallel() — the global mdb.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 LIKE 'integ-tok-%'`); err != nil { + t.Fatalf("cleanup tokens: %v", err) + } + if _, err := conn.ExecContext(context.Background(), + `DELETE FROM workspaces WHERE id LIKE 'integ-tok-%'`); err != nil { + t.Fatalf("cleanup workspaces: %v", err) + } + prev := mdb.DB + mdb.DB = conn + t.Cleanup(func() { + conn.ExecContext(context.Background(), `DELETE FROM workspace_auth_tokens WHERE workspace_id LIKE 'integ-tok-%'`) + conn.ExecContext(context.Background(), `DELETE FROM workspaces WHERE id LIKE 'integ-tok-%'`) + mdb.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, 'running')`, + 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 := "integ-tok-ws-a" + wsB := "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 := "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 -- 2.52.0 From ccacb90c4d3022ec1d4cfe5810bd3a1740a79d7d Mon Sep 17 00:00:00 2001 From: "Molecule AI Dev Engineer A (Kimi)" Date: Wed, 3 Jun 2026 22:10:30 +0000 Subject: [PATCH 06/12] fix(handlers): correct db import in admin_schedules_health integration test The integration test imported github.com/Molecule-AI/molecule-monorepo/platform/internal/db which does not exist in the workspace-server module, causing [setup failed] on CI. Replace with the local git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/db package and use db.DB (not mdb.DB) for the global variable swap. Refs PR #2171. --- .../handlers/admin_schedules_health_integration_test.go | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/workspace-server/internal/handlers/admin_schedules_health_integration_test.go b/workspace-server/internal/handlers/admin_schedules_health_integration_test.go index 7775c24ea..9a130443a 100644 --- a/workspace-server/internal/handlers/admin_schedules_health_integration_test.go +++ b/workspace-server/internal/handlers/admin_schedules_health_integration_test.go @@ -39,7 +39,8 @@ import ( "github.com/gin-gonic/gin" _ "github.com/lib/pq" - mdb "github.com/Molecule-AI/molecule-monorepo/platform/internal/db" + + "git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/db" ) func integrationDB_AdminSchedulesHealth(t *testing.T) *sql.DB { @@ -63,12 +64,12 @@ func integrationDB_AdminSchedulesHealth(t *testing.T) *sql.DB { `DELETE FROM workspaces WHERE id LIKE 'integ-ash-%'`); err != nil { t.Fatalf("cleanup workspaces: %v", err) } - prev := mdb.DB - mdb.DB = conn + prev := db.DB + db.DB = conn t.Cleanup(func() { conn.ExecContext(context.Background(), `DELETE FROM workspace_schedules WHERE workspace_id LIKE 'integ-ash-%'`) conn.ExecContext(context.Background(), `DELETE FROM workspaces WHERE id LIKE 'integ-ash-%'`) - mdb.DB = prev + db.DB = prev conn.Close() }) return conn -- 2.52.0 From 3d710747b5d7da5adb29b9569b4b5648d2ee0d0e Mon Sep 17 00:00:00 2001 From: "Molecule AI Dev Engineer A (Kimi)" Date: Wed, 3 Jun 2026 22:14:24 +0000 Subject: [PATCH 07/12] fix(handlers): correct remaining db imports in integration tests (PR #2171) Four more integration test files had the same bad external import: admin_test_token_integration_test.go budget_integration_test.go schedules_integration_test.go tokens_integration_test.go All now use the local workspace-server/internal/db package. --- .../handlers/admin_test_token_integration_test.go | 10 +++++----- .../internal/handlers/budget_integration_test.go | 12 ++++++------ .../internal/handlers/schedules_integration_test.go | 8 ++++---- .../internal/handlers/tokens_integration_test.go | 12 ++++++------ 4 files changed, 21 insertions(+), 21 deletions(-) diff --git a/workspace-server/internal/handlers/admin_test_token_integration_test.go b/workspace-server/internal/handlers/admin_test_token_integration_test.go index 942b126c7..5d8aa598a 100644 --- a/workspace-server/internal/handlers/admin_test_token_integration_test.go +++ b/workspace-server/internal/handlers/admin_test_token_integration_test.go @@ -39,12 +39,12 @@ import ( "github.com/gin-gonic/gin" _ "github.com/lib/pq" - mdb "github.com/Molecule-AI/molecule-monorepo/platform/internal/db" + "git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/db" ) // integrationDB_AdminTestToken opens the integration PG connection, wipes // the workspaces + workspace_auth_tokens tables for our test rows, and -// hot-swaps the package-level mdb.DB so the handler sees the same conn. +// hot-swaps the package-level db.DB so the handler sees the same conn. // NOT SAFE FOR t.Parallel() — each test must own the global. func integrationDB_AdminTestToken(t *testing.T) *sql.DB { t.Helper() @@ -67,12 +67,12 @@ func integrationDB_AdminTestToken(t *testing.T) *sql.DB { `DELETE FROM workspaces WHERE id LIKE 'integ-adm-%'`); err != nil { t.Fatalf("cleanup workspaces: %v", err) } - prev := mdb.DB - mdb.DB = conn + prev := db.DB + db.DB = conn t.Cleanup(func() { conn.ExecContext(context.Background(), `DELETE FROM workspace_auth_tokens WHERE workspace_id LIKE 'integ-adm-%'`) conn.ExecContext(context.Background(), `DELETE FROM workspaces WHERE id LIKE 'integ-adm-%'`) - mdb.DB = prev + db.DB = prev conn.Close() }) return conn diff --git a/workspace-server/internal/handlers/budget_integration_test.go b/workspace-server/internal/handlers/budget_integration_test.go index 0cbfd2831..216fe4b02 100644 --- a/workspace-server/internal/handlers/budget_integration_test.go +++ b/workspace-server/internal/handlers/budget_integration_test.go @@ -43,12 +43,12 @@ import ( "github.com/gin-gonic/gin" _ "github.com/lib/pq" - mdb "github.com/Molecule-AI/molecule-monorepo/platform/internal/db" + "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 mdb.DB. NOT SAFE for -// t.Parallel() — the global mdb.DB is shared. +// 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") @@ -66,11 +66,11 @@ func integrationDB_Budget(t *testing.T) *sql.DB { `DELETE FROM workspaces WHERE id LIKE 'integ-bud-%'`); err != nil { t.Fatalf("cleanup: %v", err) } - prev := mdb.DB - mdb.DB = conn + prev := db.DB + db.DB = conn t.Cleanup(func() { conn.ExecContext(context.Background(), `DELETE FROM workspaces WHERE id LIKE 'integ-bud-%'`) - mdb.DB = prev + db.DB = prev conn.Close() }) return conn diff --git a/workspace-server/internal/handlers/schedules_integration_test.go b/workspace-server/internal/handlers/schedules_integration_test.go index bc05a2046..d77c83909 100644 --- a/workspace-server/internal/handlers/schedules_integration_test.go +++ b/workspace-server/internal/handlers/schedules_integration_test.go @@ -43,7 +43,7 @@ import ( "github.com/gin-gonic/gin" _ "github.com/lib/pq" - mdb "github.com/Molecule-AI/molecule-monorepo/platform/internal/db" + "git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/db" ) func integrationDB_Schedules(t *testing.T) *sql.DB { @@ -70,13 +70,13 @@ func integrationDB_Schedules(t *testing.T) *sql.DB { t.Fatalf("cleanup %q: %v", stmt, err) } } - prev := mdb.DB - mdb.DB = conn + prev := db.DB + db.DB = conn t.Cleanup(func() { conn.ExecContext(context.Background(), `DELETE FROM activity_logs WHERE workspace_id LIKE 'integ-sch-%'`) conn.ExecContext(context.Background(), `DELETE FROM workspace_schedules WHERE workspace_id LIKE 'integ-sch-%'`) conn.ExecContext(context.Background(), `DELETE FROM workspaces WHERE id LIKE 'integ-sch-%'`) - mdb.DB = prev + db.DB = prev conn.Close() }) return conn diff --git a/workspace-server/internal/handlers/tokens_integration_test.go b/workspace-server/internal/handlers/tokens_integration_test.go index d7b2c6867..50733b25e 100644 --- a/workspace-server/internal/handlers/tokens_integration_test.go +++ b/workspace-server/internal/handlers/tokens_integration_test.go @@ -40,12 +40,12 @@ import ( "github.com/gin-gonic/gin" _ "github.com/lib/pq" - mdb "github.com/Molecule-AI/molecule-monorepo/platform/internal/db" + "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 mdb.DB. NOT SAFE for -// t.Parallel() — the global mdb.DB is shared. +// 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") @@ -67,12 +67,12 @@ func integrationDB_Tokens(t *testing.T) *sql.DB { `DELETE FROM workspaces WHERE id LIKE 'integ-tok-%'`); err != nil { t.Fatalf("cleanup workspaces: %v", err) } - prev := mdb.DB - mdb.DB = conn + prev := db.DB + db.DB = conn t.Cleanup(func() { conn.ExecContext(context.Background(), `DELETE FROM workspace_auth_tokens WHERE workspace_id LIKE 'integ-tok-%'`) conn.ExecContext(context.Background(), `DELETE FROM workspaces WHERE id LIKE 'integ-tok-%'`) - mdb.DB = prev + db.DB = prev conn.Close() }) return conn -- 2.52.0 From e35467d6800b95b4efea51368ccd59973c95236f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Molecule=20AI=20=C2=B7=20fullstack-engineer?= Date: Wed, 3 Jun 2026 22:47:36 +0000 Subject: [PATCH 08/12] fix(integration-test): rename scheduleResponse/Health to exported ScheduleResponse/Health MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per core-devops REQUEST_CHANGES #8400 build-error diagnosis: the integration test referenced unexported type names that do not exist in workspace-server/internal/handlers/schedules.go on main. Production code defines the exported ScheduleResponse and ScheduleHealthResponse structs (per swaggo doc annotations). Renaming the test references to match fixes 2 of the 3 build errors: - internal/handlers/schedules_integration_test.go:180: undefined: scheduleResponse - internal/handlers/schedules_integration_test.go:321: undefined: scheduleHealthResponse The third error (NewAdminTestTokenHandler) is addressed in a separate commit that removes admin_test_token_integration_test.go from this PR — that test depends on production code (handlers/admin_test_token.go) that lives in PR #1460 (feat/handler-admin-test-token, targeting staging). Re-adding the integration test will happen after PR #1460 lands in main. This is a test-only rename. No production code change. No API change (exported names were always ScheduleResponse/ScheduleHealthResponse in the swaggo-generated doc comments). Addresses core-devops REQUEST_CHANGES #8400 (part 1 of 2). --- .../internal/handlers/schedules_integration_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/workspace-server/internal/handlers/schedules_integration_test.go b/workspace-server/internal/handlers/schedules_integration_test.go index d77c83909..846503bb5 100644 --- a/workspace-server/internal/handlers/schedules_integration_test.go +++ b/workspace-server/internal/handlers/schedules_integration_test.go @@ -177,7 +177,7 @@ func TestIntegration_Schedules_CRUDRunHistoryHealth_RoundTrip(t *testing.T) { if w.Code != http.StatusOK { t.Fatalf("LIST: status want 200, got %d: %s", w.Code, w.Body.String()) } - var listed []scheduleResponse + 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)) @@ -318,14 +318,14 @@ func TestIntegration_Schedules_CRUDRunHistoryHealth_RoundTrip(t *testing.T) { if w.Code != http.StatusOK { t.Fatalf("HEALTH self: status want 200, got %d: %s", w.Code, w.Body.String()) } - var health []scheduleHealthResponse + 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). + // ScheduleHealthResponse — issue #249). rawJSON := w.Body.String() if bytes.Contains([]byte(rawJSON), []byte("run backup")) { t.Errorf("HEALTH self: response leaked prompt (issue #249)") -- 2.52.0 From 1524f36f9eaca6d9d190bc3ffc6aef7b043adaa7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Molecule=20AI=20=C2=B7=20fullstack-engineer?= Date: Wed, 3 Jun 2026 22:47:37 +0000 Subject: [PATCH 09/12] fix(integration-test): remove admin_test_token_integration_test.go (depends on PR #1460 production code) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per core-devops REQUEST_CHANGES #8400 build-error diagnosis: this file references NewAdminTestTokenHandler (handlers/admin_test_token.go:24) which is not in main — it lives on branch feat/handler-admin-test-token (PR #1460, targeting staging). Re-adding this integration test should happen after PR #1460 lands in main, otherwise the file produces a build error on every CI run. This file was added as part of #2151 CHUNK 2 (real-infra coverage for schedules + budget + tokens handlers) but the 'tokens' coverage is better delivered via the integration test in PR #1460 itself rather than via a separate #2171 file. Alternative considered: stub out the test with `if NewAdminTestTokenHandler == nil { t.Skip }` but that hides the dependency rather than resolving it. A clean rebase after PR #1460 merges is the correct path forward. This is a test-removal. No production code change. No API change. Unit tests in admin_test_token_test.go (PR #1460) continue to cover the handler behavior at the route-shape + TestTokensEnabled gating level. Addresses core-devops REQUEST_CHANGES #8400 (part 2 of 2). --- .../admin_test_token_integration_test.go | 204 ------------------ 1 file changed, 204 deletions(-) delete mode 100644 workspace-server/internal/handlers/admin_test_token_integration_test.go diff --git a/workspace-server/internal/handlers/admin_test_token_integration_test.go b/workspace-server/internal/handlers/admin_test_token_integration_test.go deleted file mode 100644 index 5d8aa598a..000000000 --- a/workspace-server/internal/handlers/admin_test_token_integration_test.go +++ /dev/null @@ -1,204 +0,0 @@ -//go:build integration -// +build integration - -// admin_test_token_integration_test.go — REAL Postgres integration tests -// for GET /admin/workspaces/:id/test-token (handlers/admin_test_token.go). -// -// Mirrors the pending_uploads_integration_test.go / -// delegation_ledger_integration_test.go pattern (handlers-postgres-integration.yml). -// Unit tests in admin_test_token_test.go pin the route shape + TestTokensEnabled -// gating; these tests pin the OBSERVABLE behavior against real DB rows: -// - 404 in production-disabled mode (MOLECULE_ENV=production) -// - exact ADMIN_TOKEN match when set -// - 404 for unknown workspace -// - minted auth_token validates against the real workspace_auth_tokens table -// -// 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_AdminTestToken -v - -package handlers - -import ( - "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_AdminTestToken opens the integration PG connection, wipes -// the workspaces + workspace_auth_tokens tables for our test rows, and -// hot-swaps the package-level db.DB so the handler sees the same conn. -// NOT SAFE FOR t.Parallel() — each test must own the global. -func integrationDB_AdminTestToken(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 LIKE 'integ-adm-%'`); err != nil { - t.Fatalf("cleanup tokens: %v", err) - } - if _, err := conn.ExecContext(context.Background(), - `DELETE FROM workspaces WHERE id LIKE 'integ-adm-%'`); 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 LIKE 'integ-adm-%'`) - conn.ExecContext(context.Background(), `DELETE FROM workspaces WHERE id LIKE 'integ-adm-%'`) - db.DB = prev - conn.Close() - }) - return conn -} - -// seedWorkspace_AdminTestToken inserts a minimal workspaces row so the -// test-token handler can find it. -func seedWorkspace_AdminTestToken(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, 'running')`, - id, "integ-adm-"+id); err != nil { - t.Fatalf("seed workspace: %v", err) - } -} - -// TestIntegration_AdminTestToken_AuthGateAndMint pins the production -// gating, the ADMIN_TOKEN bearer match, the 404-on-unknown path, and -// that the minted auth_token lands in workspace_auth_tokens with a -// matching sha256(token_hash) row. -func TestIntegration_AdminTestToken_AuthGateAndMint(t *testing.T) { - conn := integrationDB_AdminTestToken(t) - handler := NewAdminTestTokenHandler() - - wsOK := "integ-adm-ws-ok" - wsGhost := "integ-adm-ws-ghost" - seedWorkspace_AdminTestToken(t, conn, wsOK) - - // --- Case 1: production-disabled (MOLECULE_ENV=production) → 404 --- - // The handler returns 404 (not 403) so attackers can't probe for the - // route's existence. t.Setenv restores on test exit. - t.Setenv("MOLECULE_ENV", "production") - t.Setenv("MOLECULE_ENABLE_TEST_TOKENS", "") - t.Setenv("ADMIN_TOKEN", "") - w := httptest.NewRecorder() - c, _ := gin.CreateTestContext(w) - c.Params = gin.Params{{Key: "id", Value: wsOK}} - c.Request = httptest.NewRequest("GET", "/admin/workspaces/"+wsOK+"/test-token", nil) - handler.GetTestToken(c) - if w.Code != http.StatusNotFound { - t.Errorf("prod-disabled: status want 404, got %d: %s", w.Code, w.Body.String()) - } - - // Re-enable for the rest of the cases. - t.Setenv("MOLECULE_ENV", "dev") - t.Setenv("MOLECULE_ENABLE_TEST_TOKENS", "1") - - // --- Case 2: enabled, no ADMIN_TOKEN set, valid workspace → 200 with auth_token --- - t.Setenv("ADMIN_TOKEN", "") - w = httptest.NewRecorder() - c, _ = gin.CreateTestContext(w) - c.Params = gin.Params{{Key: "id", Value: wsOK}} - c.Request = httptest.NewRequest("GET", "/admin/workspaces/"+wsOK+"/test-token", nil) - handler.GetTestToken(c) - if w.Code != http.StatusOK { - t.Fatalf("mint no-admin: status want 200, got %d: %s", w.Code, w.Body.String()) - } - var resp1 struct { - AuthToken string `json:"auth_token"` - WorkspaceID string `json:"workspace_id"` - } - if err := json.Unmarshal(w.Body.Bytes(), &resp1); err != nil { - t.Fatalf("mint no-admin: parse: %v", err) - } - if resp1.AuthToken == "" { - t.Errorf("mint no-admin: auth_token empty in response") - } - if resp1.WorkspaceID != wsOK { - t.Errorf("mint no-admin: workspace_id want %q, got %q", wsOK, resp1.WorkspaceID) - } - - // --- Case 3: enabled, ADMIN_TOKEN set, wrong bearer → 401 --- - t.Setenv("ADMIN_TOKEN", "real-admin-secret-xyz") - w = httptest.NewRecorder() - c, _ = gin.CreateTestContext(w) - c.Params = gin.Params{{Key: "id", Value: wsOK}} - c.Request = httptest.NewRequest("GET", "/admin/workspaces/"+wsOK+"/test-token", nil) - c.Request.Header.Set("Authorization", "Bearer wrong-secret") - handler.GetTestToken(c) - if w.Code != http.StatusUnauthorized { - t.Errorf("admin wrong: status want 401, got %d: %s", w.Code, w.Body.String()) - } - - // --- Case 4: enabled, ADMIN_TOKEN set, correct bearer → 200 --- - w = httptest.NewRecorder() - c, _ = gin.CreateTestContext(w) - c.Params = gin.Params{{Key: "id", Value: wsOK}} - c.Request = httptest.NewRequest("GET", "/admin/workspaces/"+wsOK+"/test-token", nil) - c.Request.Header.Set("Authorization", "Bearer real-admin-secret-xyz") - handler.GetTestToken(c) - if w.Code != http.StatusOK { - t.Fatalf("admin correct: status want 200, got %d: %s", w.Code, w.Body.String()) - } - var resp2 struct { - AuthToken string `json:"auth_token"` - WorkspaceID string `json:"workspace_id"` - } - if err := json.Unmarshal(w.Body.Bytes(), &resp2); err != nil { - t.Fatalf("admin correct: parse: %v", err) - } - if resp2.AuthToken == "" { - t.Errorf("admin correct: auth_token empty in response") - } - - // --- Case 5: enabled, unknown workspace → 404 --- - w = httptest.NewRecorder() - c, _ = gin.CreateTestContext(w) - c.Params = gin.Params{{Key: "id", Value: wsGhost}} - c.Request = httptest.NewRequest("GET", "/admin/workspaces/"+wsGhost+"/test-token", nil) - handler.GetTestToken(c) - if w.Code != http.StatusNotFound { - t.Errorf("ghost ws: status want 404, got %d: %s", w.Code, w.Body.String()) - } - - // --- Case 6: mint validates against real DB --- - // Take the auth_token from Case 4 and verify there's a workspace_auth_tokens - // row whose token_hash = sha256(auth_token) for wsOK. - want := sha256.Sum256([]byte(resp2.AuthToken)) - var rowCount int - if err := conn.QueryRowContext(context.Background(), - `SELECT COUNT(*) FROM workspace_auth_tokens WHERE workspace_id = $1 AND token_hash = $2`, - wsOK, want[:], - ).Scan(&rowCount); err != nil { - t.Fatalf("verify hash: %v", err) - } - if rowCount != 1 { - t.Errorf("verify hash: want exactly 1 row matching sha256(token) for wsOK, got %d", rowCount) - } -} -- 2.52.0 From f1ef6c405e612145b679f0b5dad2ba82cb6cd5b2 Mon Sep 17 00:00:00 2001 From: "Molecule AI Dev Engineer B (MiniMax)" Date: Wed, 3 Jun 2026 23:52:09 +0000 Subject: [PATCH 10/12] fix(integration-tests): UUID IDs + valid enum status for real-PG tests Follow-up to f410b8e1 which only addressed the LIKE-against-UUID-column shape error. The Handlers PG Integration re-run on f410b8e1 revealed two further issues that the surface fix masked: 1. `workspaces.id` is UUID-typed (001_workspaces.sql:2), but the test fixtures used non-UUID strings like "integ-ash-ws-ok" as the workspace id, producing pq: invalid input syntax for type uuid: "integ-ash-ws-ok" on every seed INSERT. 2. `workspaces.status` is a `workspace_status` ENUM (migration 043) with values provisioning/online/offline/degraded/failed/removed/ paused/hibernated/awaiting_agent/hibernating. The tests wrote 'running' everywhere, which the enum rejects: pq: invalid input value for enum workspace_status: "running" Both are intrinsic to PR #2171 (test files don't exist or are 1 line on main, per the CEO DECIDING TEST), so scope stays IN of #8400. Fix: - Add integration_test_helpers_test.go: a single integUUID(s) helper that maps a human-readable name to a deterministic UUID via SHA-1(uuid.NameSpaceURL, s). Same input always yields the same UUID, so the readable name in `wsX := integUUID("integ-ash-ws-ok")` is recoverable from any failure log line. - Wrap every test fixture ID (wsA, wsB, wsCap, wsRemoved, wsGhost, wsOK, wsStale, "ws-a-over" case) with integUUID. - Replace the hard-coded 'running' status with 'online' in the four seedWorkspace_* helpers (the only valid enum value that exercises the "workspace exists and is reachable" path). - Re-do the cleanup LIKE filter: instead of `WHERE id LIKE 'integ-%'` (workspaces.id is UUID, so still needs a cast there), filter on the TEXT `name` column which already carries the integ- prefix. Child tables (workspace_schedules, activity_logs, workspace_auth_tokens) join through the workspace_id FK against a `name LIKE` subquery on workspaces. The TEXT-vs-UUID mismatch that f410b8e1 was working around goes away entirely. Files: - integration_test_helpers_test.go (new, +36) - admin_schedules_health_integration_test.go (4 sites) - budget_integration_test.go (3 sites + 1 SQL literal) - schedules_integration_test.go (6 sites + 1 SQL literal) - tokens_integration_test.go (4 sites + 1 SQL literal) Co-Authored-By: Claude Opus 4.7 --- ...admin_schedules_health_integration_test.go | 21 ++++++----- .../handlers/budget_integration_test.go | 21 ++++++----- .../handlers/integration_test_helpers_test.go | 37 +++++++++++++++++++ .../handlers/schedules_integration_test.go | 18 ++++----- .../handlers/tokens_integration_test.go | 16 ++++---- 5 files changed, 77 insertions(+), 36 deletions(-) create mode 100644 workspace-server/internal/handlers/integration_test_helpers_test.go diff --git a/workspace-server/internal/handlers/admin_schedules_health_integration_test.go b/workspace-server/internal/handlers/admin_schedules_health_integration_test.go index 9a130443a..a2a659e4d 100644 --- a/workspace-server/internal/handlers/admin_schedules_health_integration_test.go +++ b/workspace-server/internal/handlers/admin_schedules_health_integration_test.go @@ -57,18 +57,18 @@ func integrationDB_AdminSchedulesHealth(t *testing.T) *sql.DB { t.Fatalf("ping: %v", err) } if _, err := conn.ExecContext(context.Background(), - `DELETE FROM workspace_schedules WHERE workspace_id LIKE 'integ-ash-%'`); err != nil { + `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 id LIKE 'integ-ash-%'`); err != nil { + `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 LIKE 'integ-ash-%'`) - conn.ExecContext(context.Background(), `DELETE FROM workspaces WHERE id LIKE 'integ-ash-%'`) + 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() }) @@ -109,11 +109,14 @@ func TestIntegration_AdminSchedulesHealth_ClassifiesRows(t *testing.T) { handler := NewAdminSchedulesHealthHandler() // Two visible workspaces + one removed (must NOT appear in results). - wsOK := "integ-ash-ws-ok" - wsStale := "integ-ash-ws-stale" - wsRemoved := "integ-ash-ws-removed" - seedWorkspace_AdminSchedulesHealth(t, conn, wsOK, "running") - seedWorkspace_AdminSchedulesHealth(t, conn, wsStale, "running") + // 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 --- diff --git a/workspace-server/internal/handlers/budget_integration_test.go b/workspace-server/internal/handlers/budget_integration_test.go index 216fe4b02..cf9f60db7 100644 --- a/workspace-server/internal/handlers/budget_integration_test.go +++ b/workspace-server/internal/handlers/budget_integration_test.go @@ -63,13 +63,13 @@ func integrationDB_Budget(t *testing.T) *sql.DB { t.Fatalf("ping: %v", err) } if _, err := conn.ExecContext(context.Background(), - `DELETE FROM workspaces WHERE id LIKE 'integ-bud-%'`); err != nil { + `DELETE FROM workspaces WHERE name LIKE 'integ-bud-%'`); err != nil { t.Fatalf("cleanup: %v", err) } prev := db.DB db.DB = conn t.Cleanup(func() { - conn.ExecContext(context.Background(), `DELETE FROM workspaces WHERE id LIKE 'integ-bud-%'`) + conn.ExecContext(context.Background(), `DELETE FROM workspaces WHERE name LIKE 'integ-bud-%'`) db.DB = prev conn.Close() }) @@ -77,8 +77,9 @@ func integrationDB_Budget(t *testing.T) *sql.DB { } // seedWorkspace_Budget inserts a workspaces row with optional budget_limit -// (nil = NULL) and a fixed monthly_spend. The status is always 'running' — -// the removed-status case uses a separate helper. +// (nil = NULL) and a fixed monthly_spend. 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() var lim interface{} = nil @@ -87,7 +88,7 @@ func seedWorkspace_Budget(t *testing.T, conn *sql.DB, id string, budgetLimit *in } if _, err := conn.ExecContext(context.Background(), `INSERT INTO workspaces (id, name, status, budget_limit, monthly_spend) - VALUES ($1, $2, 'running', $3, $4)`, + VALUES ($1, $2, 'online', $3, $4)`, id, "integ-bud-"+id, lim, monthlySpend); err != nil { t.Fatalf("seed: %v", err) } @@ -125,10 +126,10 @@ func TestIntegration_Budget_GetPatchPersistsAndValidates(t *testing.T) { conn := integrationDB_Budget(t) handler := NewBudgetHandler() - wsA := "integ-bud-ws-a" - wsB := "integ-bud-ws-b" - wsRemoved := "integ-bud-ws-removed" - wsGhost := "integ-bud-ws-ghost" + wsA := integUUID("integ-bud-ws-a") + wsB := integUUID("integ-bud-ws-b") + 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) @@ -137,7 +138,7 @@ func TestIntegration_Budget_GetPatchPersistsAndValidates(t *testing.T) { seedWorkspace_Budget(t, conn, wsA, nil, 0) seedWorkspace_Budget(t, conn, wsB, int64Ptr(10000), 2500) overLim := int64(1000) - seedWorkspace_Budget(t, conn, wsA+"over", &overLim, 1500) + seedWorkspace_Budget(t, conn, integUUID("integ-bud-ws-a-over"), &overLim, 1500) // removed-workspace case if _, err := conn.ExecContext(context.Background(), `INSERT INTO workspaces (id, name, status, budget_limit, monthly_spend) 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 index 846503bb5..aec946318 100644 --- a/workspace-server/internal/handlers/schedules_integration_test.go +++ b/workspace-server/internal/handlers/schedules_integration_test.go @@ -62,9 +62,9 @@ func integrationDB_Schedules(t *testing.T) *sql.DB { // 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 LIKE 'integ-sch-%'`, - `DELETE FROM workspace_schedules WHERE workspace_id LIKE 'integ-sch-%'`, - `DELETE FROM workspaces WHERE id LIKE 'integ-sch-%'`, + `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) @@ -73,9 +73,9 @@ func integrationDB_Schedules(t *testing.T) *sql.DB { prev := db.DB db.DB = conn t.Cleanup(func() { - conn.ExecContext(context.Background(), `DELETE FROM activity_logs WHERE workspace_id LIKE 'integ-sch-%'`) - conn.ExecContext(context.Background(), `DELETE FROM workspace_schedules WHERE workspace_id LIKE 'integ-sch-%'`) - conn.ExecContext(context.Background(), `DELETE FROM workspaces WHERE id LIKE 'integ-sch-%'`) + 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() }) @@ -85,7 +85,7 @@ func integrationDB_Schedules(t *testing.T) *sql.DB { 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, 'running')`, + `INSERT INTO workspaces (id, name, status) VALUES ($1, $2, 'online')`, id, "integ-sch-"+id); err != nil { t.Fatalf("seed workspace: %v", err) } @@ -132,8 +132,8 @@ func TestIntegration_Schedules_CRUDRunHistoryHealth_RoundTrip(t *testing.T) { conn := integrationDB_Schedules(t) handler := NewScheduleHandler() - wsA := "integ-sch-ws-a" - wsB := "integ-sch-ws-b" + wsA := integUUID("integ-sch-ws-a") + wsB := integUUID("integ-sch-ws-b") seedWorkspace_Schedules(t, conn, wsA) seedWorkspace_Schedules(t, conn, wsB) diff --git a/workspace-server/internal/handlers/tokens_integration_test.go b/workspace-server/internal/handlers/tokens_integration_test.go index 50733b25e..bceb534c5 100644 --- a/workspace-server/internal/handlers/tokens_integration_test.go +++ b/workspace-server/internal/handlers/tokens_integration_test.go @@ -60,18 +60,18 @@ func integrationDB_Tokens(t *testing.T) *sql.DB { t.Fatalf("ping: %v", err) } if _, err := conn.ExecContext(context.Background(), - `DELETE FROM workspace_auth_tokens WHERE workspace_id LIKE 'integ-tok-%'`); err != nil { + `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 id LIKE 'integ-tok-%'`); err != nil { + `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 LIKE 'integ-tok-%'`) - conn.ExecContext(context.Background(), `DELETE FROM workspaces WHERE id LIKE 'integ-tok-%'`) + 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() }) @@ -81,7 +81,7 @@ func integrationDB_Tokens(t *testing.T) *sql.DB { 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, 'running')`, + `INSERT INTO workspaces (id, name, status) VALUES ($1, $2, 'online')`, id, "integ-tok-"+id); err != nil { t.Fatalf("seed: %v", err) } @@ -105,8 +105,8 @@ func TestIntegration_Tokens_CreateListRevoke_RoundTrip(t *testing.T) { conn := integrationDB_Tokens(t) handler := NewTokenHandler() - wsA := "integ-tok-ws-a" - wsB := "integ-tok-ws-b" + wsA := integUUID("integ-tok-ws-a") + wsB := integUUID("integ-tok-ws-b") seedWorkspace_Tokens(t, conn, wsA) seedWorkspace_Tokens(t, conn, wsB) @@ -234,7 +234,7 @@ func TestIntegration_Tokens_CreateListRevoke_RoundTrip(t *testing.T) { } // --- Case 5: max-active-cap (50) — seed 50, then 51st → 429 --- - wsCap := "integ-tok-ws-cap" + 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++ { -- 2.52.0 From 2256e3222dccbee61efeccff86ffba5ef94e12a2 Mon Sep 17 00:00:00 2001 From: "Molecule AI Dev Engineer B (MiniMax)" Date: Thu, 4 Jun 2026 00:07:20 +0000 Subject: [PATCH 11/12] fix(integration-tests): test-side bugs from 2nd CI run vs f1ef6c40 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both files had real-DB-shape bugs that only surface when the tests run against actual Postgres (the sqlmock unit tests don't catch them): * admin_schedules_health: stale_threshold assertion expected ~3600 for "*/15 * * * *" (every 15 minutes). The handler computes the threshold as 2× (next-fire gap) = 2× 900s = 1800s. The test author misread "*/15" as "every 30 minutes" — two off-by-one errors stacked. Test now asserts 1800 with ±10s slack for runtime compute jitter. * budget: the seed wrote the legacy budget_limit / monthly_spend BIGINT columns, but the multi-period migration (20260529000000) moved the SSOT to workspaces.budget_limits JSONB and computes spend from workspace_spend_events.delta_cents via rolling-window SUM. The test predated that migration. Seed now writes the JSONB shape the handler reads and inserts a ledger event with the configured monthly_spend so spendByPeriod picks it up. Also fixed a latent bug: the over-budget case seeded via integUUID("integ-bud-ws-a-over") but GET'd via wsA+"over" (string concat) — those resolve to different strings, so the GET was 404. Test fixes only — no production code change. Co-Authored-By: Claude Opus 4.7 --- ...admin_schedules_health_integration_test.go | 16 +++-- .../handlers/budget_integration_test.go | 65 ++++++++++++++----- 2 files changed, 61 insertions(+), 20 deletions(-) diff --git a/workspace-server/internal/handlers/admin_schedules_health_integration_test.go b/workspace-server/internal/handlers/admin_schedules_health_integration_test.go index a2a659e4d..3aaab1597 100644 --- a/workspace-server/internal/handlers/admin_schedules_health_integration_test.go +++ b/workspace-server/internal/handlers/admin_schedules_health_integration_test.go @@ -183,11 +183,19 @@ func TestIntegration_AdminSchedulesHealth_ClassifiesRows(t *testing.T) { t.Errorf("removed_schedule should be filtered out (workspace status=removed)") } - // --- Assert: stale threshold is 2× cron interval (every-15-min = 1800s × 2 = 3600s) --- + // --- 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 ±5s slack for runtime compute jitter. - if e.StaleThresholdSeconds < 3590 || e.StaleThresholdSeconds > 3610 { - t.Errorf("ok_schedule: stale_threshold_seconds want ~3600 (2× 15min), got %d", e.StaleThresholdSeconds) + // 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 index cf9f60db7..8f9d29682 100644 --- a/workspace-server/internal/handlers/budget_integration_test.go +++ b/workspace-server/internal/handlers/budget_integration_test.go @@ -24,6 +24,7 @@ // 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 @@ -62,13 +63,20 @@ func integrationDB_Budget(t *testing.T) *sql.DB { if err := conn.Ping(); err != nil { t.Fatalf("ping: %v", err) } - if _, err := conn.ExecContext(context.Background(), - `DELETE FROM workspaces WHERE name LIKE 'integ-bud-%'`); err != nil { - t.Fatalf("cleanup: %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). + `DELETE FROM workspace_spend_events WHERE workspace_id IN (SELECT id 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 FROM workspaces WHERE name LIKE 'integ-bud-%')`) conn.ExecContext(context.Background(), `DELETE FROM workspaces WHERE name LIKE 'integ-bud-%'`) db.DB = prev conn.Close() @@ -76,22 +84,45 @@ func integrationDB_Budget(t *testing.T) *sql.DB { return conn } -// seedWorkspace_Budget inserts a workspaces row with optional budget_limit -// (nil = NULL) and a fixed monthly_spend. The status is hardcoded to -// 'online' (a valid workspace_status enum value — see migration 043). +// 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() - var lim interface{} = nil + // 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 { - lim = *budgetLimit + 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_limit, monthly_spend) - VALUES ($1, $2, 'online', $3, $4)`, - id, "integ-bud-"+id, lim, monthlySpend); err != nil { + `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. @@ -128,6 +159,7 @@ func TestIntegration_Budget_GetPatchPersistsAndValidates(t *testing.T) { 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") @@ -138,11 +170,12 @@ func TestIntegration_Budget_GetPatchPersistsAndValidates(t *testing.T) { seedWorkspace_Budget(t, conn, wsA, nil, 0) seedWorkspace_Budget(t, conn, wsB, int64Ptr(10000), 2500) overLim := int64(1000) - seedWorkspace_Budget(t, conn, integUUID("integ-bud-ws-a-over"), &overLim, 1500) - // removed-workspace case + 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_limit, monthly_spend) - VALUES ($1, 'integ-bud-removed', 'removed', NULL, 0)`, wsRemoved); err != nil { + `INSERT INTO workspaces (id, name, status, budget_limits) + VALUES ($1, 'integ-bud-removed', 'removed', '{}'::jsonb)`, wsRemoved); err != nil { t.Fatalf("seed removed: %v", err) } @@ -191,7 +224,7 @@ func TestIntegration_Budget_GetPatchPersistsAndValidates(t *testing.T) { } // --- Case 3: GET — over budget → remaining is NEGATIVE (per budget.go doc) --- - w = doGet_Budget(t, handler, wsA+"over") + 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()) } -- 2.52.0 From 116b2dbad2a1e139c5b1dfc2c78ed17edf155893 Mon Sep 17 00:00:00 2001 From: "Molecule AI Dev Engineer B (MiniMax)" Date: Thu, 4 Jun 2026 00:12:33 +0000 Subject: [PATCH 12/12] fix(integration-test): cast workspaces.id to text in spend_events cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new workspace_spend_events.workspace_id column is TEXT, but workspaces.id is UUID — Postgres can't compare across types, so the IN subquery in the budget-test cleanup failed with `operator does not exist: text = uuid` and the initial t.Fatal'd cleanup short-circuited the test before any assertions ran. The other test files (schedules, tokens, admin_schedules_health) don't hit this because activity_logs.workspace_id, workspace_schedules. workspace_id, and workspace_auth_tokens.workspace_id are all TEXT — the cast is only needed where the join table column is TEXT but the parent id is UUID. Test-only change. Co-Authored-By: Claude Opus 4.7 --- .../internal/handlers/budget_integration_test.go | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/workspace-server/internal/handlers/budget_integration_test.go b/workspace-server/internal/handlers/budget_integration_test.go index 8f9d29682..713d4f328 100644 --- a/workspace-server/internal/handlers/budget_integration_test.go +++ b/workspace-server/internal/handlers/budget_integration_test.go @@ -66,7 +66,12 @@ func integrationDB_Budget(t *testing.T) *sql.DB { for _, stmt := range []string{ // Wipe ledger rows first (workspace_id is TEXT, no FK, but // grouping the cleanup with workspaces makes intent clear). - `DELETE FROM workspace_spend_events WHERE workspace_id IN (SELECT id FROM workspaces WHERE name LIKE 'integ-bud-%')`, + // 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 { @@ -76,7 +81,7 @@ func integrationDB_Budget(t *testing.T) *sql.DB { prev := db.DB db.DB = conn t.Cleanup(func() { - conn.ExecContext(context.Background(), `DELETE FROM workspace_spend_events WHERE workspace_id IN (SELECT id FROM workspaces WHERE name LIKE 'integ-bud-%')`) + 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() -- 2.52.0