feat(#2151): real-infra coverage for schedules + budget + tokens handlers (chunk 2/7) #2171

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