feat(#2151): real-infra coverage for schedules + budget + tokens handlers (chunk 2/7) #2171
@@ -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
|
||||
Reference in New Issue
Block a user