diff --git a/internal/handlers/approve_block_helpers_test.go b/internal/handlers/approve_block_helpers_test.go new file mode 100644 index 0000000..e1a40bd --- /dev/null +++ b/internal/handlers/approve_block_helpers_test.go @@ -0,0 +1,175 @@ +package handlers_test + +// approve_block_helpers_test.go — shared helpers for the W4 deploy-approval +// block integration suite (approve_block_routes_test.go). These cover the +// public email-link approval landing — GET /approve/:token — from +// USER-FLOW-INVENTORY-AND-TEST-MATRIX.md §W4, which prior to this suite sat in +// internal/router/route_donebar_guard_test.go's routeCoverageExemptions with a +// "TODO: matrix W4 deploy-approval flow" pointer and NO mapped test. +// +// Mirrors the established W3 vault-block / team-block convention +// (vault_block_helpers_test.go): a thin SetupTestDB wrapper + a loud +// skip-when-no-DB guard + DB-backed seed helpers. Helpers here are prefixed +// approveBlock* so they do NOT collide with — and do NOT redefine — the +// existing miniRedis / doJSON / decodeBody helpers (which this suite reuses +// verbatim) or the white-box daApp / daSeedApproval helpers in +// promote_approval_deployasync_test.go (those live in package handlers; this +// suite is package handlers_test). +// +// Why a dedicated app builder rather than NewTestAppWithServices: that shared +// test app does NOT register the approve route. approveBlockApp registers the +// route through the SAME wiring internal/router/router.go installs — +// handlers.NewPromoteApprovalHandler(db, rdb) + app.Get("/approve/:token", +// h.Approve) — against the real migrated test DB and a real (miniredis) Redis, +// so the suite exercises the public token-IS-the-credential contract end-to-end: +// the per-IP rate limit, the four token branches (invalid / expired / +// already-used / valid), the persisted status flip, and the 302→dashboard +// redirect. The token is the only credential — there is NO RequireAuth on this +// route by design (the email URL must work for an anonymous click), so no JWT is +// minted here. + +import ( + "context" + "database/sql" + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/google/uuid" + "github.com/redis/go-redis/v9" + "github.com/stretchr/testify/require" + + "instant.dev/internal/handlers" + "instant.dev/internal/models" + "instant.dev/internal/testhelpers" +) + +// approveBlockSkipNoDB skips a W4 deploy-approval test when no test Postgres is +// configured. The approve block is a real-backend integration surface — these +// tests assert on actual rows in promote_approvals (status flip from 'pending' +// to 'approved'/'expired') and on the rendered HTML / redirect contract, so a +// missing DB is a loud skip, never a false green. Mirrors vaultBlockSkipNoDB. +func approveBlockSkipNoDB(t *testing.T) { + t.Helper() + if os.Getenv("TEST_DATABASE_URL") == "" { + t.Skip("W4 deploy-approval integration: TEST_DATABASE_URL not set") + } +} + +// approveBlockDB opens a fresh migrated test DB and returns it with its cleanup. +// Thin wrapper over testhelpers.SetupTestDB so every W4 approve-block test reads +// the same way. +func approveBlockDB(t *testing.T) (*sql.DB, func()) { + t.Helper() + return testhelpers.SetupTestDB(t) +} + +// approveBlockApp builds a Fiber app that registers the public approve route +// through the SAME wiring production uses (internal/router/router.go line ~599): +// handlers.NewPromoteApprovalHandler(db, rdb) then +// app.Get("/approve/:token", h.Approve). There is NO auth middleware — the route +// is public by design (the token IS the credential), matching router.go where +// this route is wired ABOVE the /api/v1 RequireAuth group. rdb drives the +// per-IP rate limit (pass a real miniredis client to exercise it; pass nil to +// exercise the rate-limit-fails-open posture). +func approveBlockApp(t *testing.T, db *sql.DB, rdb *redis.Client) *fiber.App { + t.Helper() + // ProxyHeader mirrors router.New so c.IP() reads X-Forwarded-For — the + // per-IP rate-limit key the handler builds. + app := fiber.New(fiber.Config{ProxyHeader: "X-Forwarded-For"}) + h := handlers.NewPromoteApprovalHandler(db, rdb) + app.Get("/approve/:token", h.Approve) + return app +} + +// approveBlockSeedTeam inserts a 'pro'-tier team and returns its id, registering +// cleanup of its promote_approvals + the team row. promote_approvals.team_id has +// a NOT NULL FK to teams, so every seeded approval needs a real team. +func approveBlockSeedTeam(t *testing.T, db *sql.DB) uuid.UUID { + t.Helper() + var id uuid.UUID + require.NoError(t, + db.QueryRow(`INSERT INTO teams (plan_tier) VALUES ('pro') RETURNING id`).Scan(&id), + "seed approve-block team") + t.Cleanup(func() { + db.Exec(`DELETE FROM promote_approvals WHERE team_id = $1`, id) + db.Exec(`DELETE FROM audit_log WHERE team_id = $1`, id) + db.Exec(`DELETE FROM teams WHERE id = $1`, id) + }) + return id +} + +// approveBlockSeedApproval inserts a promote_approvals row for teamID at the +// given status, with expiry `expiresIn` from now. A NEGATIVE expiresIn forces an +// already-expired row (CreatePromoteApproval coerces a non-positive TTL to the +// 24h default, so we set expires_at directly in that case — same trick as the +// white-box daSeedApproval). Returns the row (carrying the plaintext token to +// click). Built on the real models.CreatePromoteApproval so the row shape +// matches what the production CreatePromoteApprovalAndEmit writes. +func approveBlockSeedApproval(t *testing.T, db *sql.DB, teamID uuid.UUID, status string, expiresIn time.Duration) *models.PromoteApproval { + t.Helper() + tok, err := models.GeneratePromoteApprovalToken() + require.NoError(t, err, "gen approve token") + + ttl := expiresIn + if ttl <= 0 { + // CreatePromoteApproval would coerce this to the default; pass a small + // positive TTL, then force expires_at into the past below. + ttl = time.Hour + } + row, err := models.CreatePromoteApproval(context.Background(), db, models.CreatePromoteApprovalParams{ + Token: tok, + TeamID: teamID, + RequestedByEmail: "approver-" + uuid.NewString()[:8] + "@example.com", + PromoteKind: models.PromoteApprovalKindStack, + PromotePayload: []byte(`{"from":"staging","to":"production"}`), + FromEnv: "staging", + ToEnv: "production", + TTL: ttl, + }) + require.NoError(t, err, "create approve-block approval") + + if status != models.PromoteApprovalStatusPending { + _, err = db.Exec(`UPDATE promote_approvals SET status=$1 WHERE id=$2`, status, row.ID) + require.NoError(t, err, "set approval status") + row.Status = status + } + if expiresIn < 0 { + _, err = db.Exec(`UPDATE promote_approvals SET expires_at = now() - interval '1 hour' WHERE id=$1`, row.ID) + require.NoError(t, err, "force-expire approval") + row.ExpiresAt = time.Now().UTC().Add(-time.Hour) + } + return row +} + +// approveBlockGet issues a GET /approve/:token through the test app and returns +// the *http.Response WITHOUT following the redirect (fiber's app.Test does not +// auto-follow), so the caller can assert on the 302 + Location header. The +// X-Forwarded-For header pins the per-IP rate-limit bucket so each test gets a +// fresh budget regardless of test ordering. Body is left open for the caller to +// read + close. +func approveBlockGet(t *testing.T, app *fiber.App, token, clientIP string) *http.Response { + t.Helper() + req := httptest.NewRequest(http.MethodGet, "/approve/"+token, nil) + if clientIP != "" { + req.Header.Set("X-Forwarded-For", clientIP) + } + resp, err := app.Test(req, 5000) + require.NoError(t, err, "GET /approve/%s", token) + return resp +} + +// approveBlockStatusOf reads the persisted status of a promote_approvals row by +// id — the source-of-truth assertion that the handler's state change actually +// landed in Postgres (not merely that it returned a 302). +func approveBlockStatusOf(t *testing.T, db *sql.DB, id uuid.UUID) string { + t.Helper() + var status string + require.NoError(t, + db.QueryRow(`SELECT status FROM promote_approvals WHERE id=$1`, id).Scan(&status), + "read approval status") + return status +} diff --git a/internal/handlers/approve_block_routes_test.go b/internal/handlers/approve_block_routes_test.go new file mode 100644 index 0000000..d6396b1 --- /dev/null +++ b/internal/handlers/approve_block_routes_test.go @@ -0,0 +1,234 @@ +package handlers_test + +// approve_block_routes_test.go — W4 deploy-approval block integration suite. +// +// Covers the public email-link approval landing from +// USER-FLOW-INVENTORY-AND-TEST-MATRIX.md §W4: GET /approve/:token. This route +// was, in internal/router/route_donebar_guard_test.go, listed in +// routeCoverageExemptions ("approval-link landing — no integration test yet. +// TODO: matrix W4 deploy-approval flow.") with NO mapped test. This suite +// supplies the DB-backed integration coverage the done-bar guard's routeTestMap +// now points at, so the route moves exempt → mapped. +// +// THE REAL CONTRACT (read from internal/handlers/promote_approval.go Approve): +// the route is PUBLIC — the 32-byte random token IS the credential, so there is +// no Bearer/session (it must work for an anonymous click in an email client). +// The handler renders HTML (or redirects) across four token branches: +// +// 1. token doesn't exist → 404 "this link is invalid" HTML +// 2. token exists but past expiry → flips row to 'expired', 410 "expired" HTML +// 3. token exists, status≠pending → 410 "already used" HTML +// 4. token valid + pending → atomic approve, 302 → dashboard ?approved=1 +// +// plus a per-IP rate limit (promoteApprovalRateLimitPerSec/sec) that renders a +// 429 "slow down" HTML page. All non-success branches render the SAME shape of +// page (no branch is distinguishable to a probing attacker beyond the 302 vs +// 4xx the genuine user must see). +// +// Each test runs against a real migrated Postgres (testhelpers.SetupTestDB) +// through the PRODUCTION route wiring (approveBlockApp mirrors router.go's +// app.Get("/approve/:token", NewPromoteApprovalHandler(db, rdb).Approve)), +// asserting: +// - happy path: 302 + Location → dashboard/?approved=1 AND the row is +// PERSISTED as 'approved' in Postgres (source-of-truth state change), +// - single-use: a second click on an approved token → 410 "already used", +// - expired token: 410 + the row is flipped to 'expired' in Postgres, +// - invalid token: 404 (existence of a real token is unobservable), +// - rate limit: the (N+1)th click within one IP-second → 429. +// +// Skips loudly when TEST_DATABASE_URL is unset (approveBlockSkipNoDB). + +import ( + "io" + "net/http" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "instant.dev/internal/handlers" + "instant.dev/internal/models" +) + +// approveRateLimitBudget mirrors the unexported promoteApprovalRateLimitPerSec +// in promote_approval.go (per-IP requests per second, currently 10). This +// black-box (package handlers_test) suite can't reach the unexported constant, +// so it tracks the value here; if the prod budget changes, this burst count +// just needs to stay above it for the limiter to trip — the assertion below +// only requires that SOME request in the burst is rate-limited. +const approveRateLimitBudget = 10 + +// readApproveBody drains + closes the response body and returns it as a string. +func readApproveBody(t *testing.T, resp *http.Response) string { + t.Helper() + defer resp.Body.Close() + b, err := io.ReadAll(resp.Body) + require.NoError(t, err) + return string(b) +} + +// ───────────────────────────────────────────────────────────────────────── +// GET /approve/:token — PromoteApprovalHandler.Approve (happy path) +// ───────────────────────────────────────────────────────────────────────── + +func TestApproveBlock_ValidPendingToken(t *testing.T) { + approveBlockSkipNoDB(t) + db, cleanup := approveBlockDB(t) + defer cleanup() + + t.Run("valid pending token → 302 + dashboard redirect + row persisted as approved", func(t *testing.T) { + teamID := approveBlockSeedTeam(t, db) + row := approveBlockSeedApproval(t, db, teamID, models.PromoteApprovalStatusPending, time.Hour) + app := approveBlockApp(t, db, miniRedis(t)) + + resp := approveBlockGet(t, app, row.Token, "203.0.113.10") + body := readApproveBody(t, resp) + require.Equal(t, http.StatusFound, resp.StatusCode, "body=%s", body) + + // Redirect lands on the dashboard's per-approval detail with ?approved=1 + // (the toast trigger) — the canonical post-approval destination. + loc := resp.Header.Get("Location") + assert.Equal(t, handlers.PromoteApprovalDashboardURL+"/"+row.ID.String()+"?approved=1", loc, + "302 Location must point at the dashboard approval detail with the approved toast flag") + + // Source-of-truth state change: the row is PERSISTED as 'approved'. + assert.Equal(t, models.PromoteApprovalStatusApproved, approveBlockStatusOf(t, db, row.ID), + "a valid click must atomically flip the row from pending → approved in Postgres") + }) + + t.Run("single-use: a second click on the now-approved token → 410 already-used", func(t *testing.T) { + teamID := approveBlockSeedTeam(t, db) + row := approveBlockSeedApproval(t, db, teamID, models.PromoteApprovalStatusPending, time.Hour) + app := approveBlockApp(t, db, miniRedis(t)) + + // First click approves. + first := approveBlockGet(t, app, row.Token, "203.0.113.11") + _ = readApproveBody(t, first) + require.Equal(t, http.StatusFound, first.StatusCode) + + // Second click: the row is no longer pending → 410 "already used". + second := approveBlockGet(t, app, row.Token, "203.0.113.11") + body := readApproveBody(t, second) + assert.Equal(t, http.StatusGone, second.StatusCode, "single-use token must not approve twice") + assert.Contains(t, body, "already been used", + "a second click renders the 'already used' page, never a second 302") + // State is unchanged — still approved, not re-flipped. + assert.Equal(t, models.PromoteApprovalStatusApproved, approveBlockStatusOf(t, db, row.ID)) + }) +} + +// ───────────────────────────────────────────────────────────────────────── +// GET /approve/:token — already-used / rejected / executed tokens +// ───────────────────────────────────────────────────────────────────────── + +func TestApproveBlock_NonPendingToken(t *testing.T) { + approveBlockSkipNoDB(t) + db, cleanup := approveBlockDB(t) + defer cleanup() + + // Every already-resolved status renders the same "already used" page and + // must NOT re-approve. Drives branch 3 of the handler. + for _, status := range []string{ + models.PromoteApprovalStatusApproved, + models.PromoteApprovalStatusRejected, + models.PromoteApprovalStatusExecuted, + } { + status := status + t.Run(status+" token → 410 already-used, no state change", func(t *testing.T) { + teamID := approveBlockSeedTeam(t, db) + row := approveBlockSeedApproval(t, db, teamID, status, time.Hour) + app := approveBlockApp(t, db, miniRedis(t)) + + resp := approveBlockGet(t, app, row.Token, "203.0.113.20") + body := readApproveBody(t, resp) + assert.Equal(t, http.StatusGone, resp.StatusCode) + assert.Contains(t, body, "already been used") + assert.Equal(t, status, approveBlockStatusOf(t, db, row.ID), + "clicking a %s token must not change its status", status) + }) + } +} + +// ───────────────────────────────────────────────────────────────────────── +// GET /approve/:token — expired token (branch 2: flips row to 'expired') +// ───────────────────────────────────────────────────────────────────────── + +func TestApproveBlock_ExpiredToken(t *testing.T) { + approveBlockSkipNoDB(t) + db, cleanup := approveBlockDB(t) + defer cleanup() + + t.Run("pending-but-past-expiry token → 410 expired + row flipped to 'expired'", func(t *testing.T) { + teamID := approveBlockSeedTeam(t, db) + // Negative TTL forces an already-expired (but still 'pending') row. + row := approveBlockSeedApproval(t, db, teamID, models.PromoteApprovalStatusPending, -time.Hour) + app := approveBlockApp(t, db, miniRedis(t)) + + resp := approveBlockGet(t, app, row.Token, "203.0.113.30") + body := readApproveBody(t, resp) + require.Equal(t, http.StatusGone, resp.StatusCode, "body=%s", body) + assert.Contains(t, body, "expired", "expired token renders the 'expired' page") + + // The handler flips the row to 'expired' as a side effect (best-effort, + // but against a healthy DB it must land). + assert.Equal(t, models.PromoteApprovalStatusExpired, approveBlockStatusOf(t, db, row.ID), + "a click on a past-expiry pending row flips its status to 'expired'") + }) +} + +// ───────────────────────────────────────────────────────────────────────── +// GET /approve/:token — invalid token (branch 1: never-issued / typo'd) +// ───────────────────────────────────────────────────────────────────────── + +func TestApproveBlock_InvalidToken(t *testing.T) { + approveBlockSkipNoDB(t) + db, cleanup := approveBlockDB(t) + defer cleanup() + + t.Run("unknown token → 404 invalid (existence of a real token unobservable)", func(t *testing.T) { + app := approveBlockApp(t, db, miniRedis(t)) + // A well-formed-but-never-issued token. + bogus := "this-token-was-never-issued-" + uuid.NewString() + + resp := approveBlockGet(t, app, bogus, "203.0.113.40") + body := readApproveBody(t, resp) + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + assert.Contains(t, body, "invalid", + "an unknown token renders the same 'invalid' page a typo'd link would — no oracle for probing") + }) +} + +// ───────────────────────────────────────────────────────────────────────── +// GET /approve/:token — per-IP rate limit (defends the token space) +// ───────────────────────────────────────────────────────────────────────── + +func TestApproveBlock_RateLimit(t *testing.T) { + approveBlockSkipNoDB(t) + db, cleanup := approveBlockDB(t) + defer cleanup() + + t.Run("exceeding the per-IP/sec budget → 429 slow-down page", func(t *testing.T) { + app := approveBlockApp(t, db, miniRedis(t)) + const ip = "198.51.100.7" + // The budget is approveRateLimitBudget requests per IP-second. Hammer + // well past it with a bogus token (we only care about the limiter, which + // runs BEFORE the token lookup). At least one request in the burst must + // trip the limit and render the 429 page. + got429 := false + var lastBody string + for i := 0; i < approveRateLimitBudget+5; i++ { + resp := approveBlockGet(t, app, "rl-probe-token", ip) + lastBody = readApproveBody(t, resp) + if resp.StatusCode == http.StatusTooManyRequests { + got429 = true + assert.Contains(t, lastBody, "Too many requests", + "the 429 renders the rate-limit page") + break + } + } + require.True(t, got429, + "a burst beyond the per-IP/sec budget must yield a 429; last body=%s", lastBody) + }) +} diff --git a/internal/router/route_donebar_guard_test.go b/internal/router/route_donebar_guard_test.go index 0f09fec..7b1a953 100644 --- a/internal/router/route_donebar_guard_test.go +++ b/internal/router/route_donebar_guard_test.go @@ -248,6 +248,17 @@ var routeTestMap = map[string]string{ "POST /api/v1/team/invitations/:id/accept": "TestTeamBlock_AcceptInvitationByID", "DELETE /api/v1/teams/:team_id/invitations/:id": "TestTeamBlock_TeamsAliasRevokeInvitation", + // ── deploy-approval email link (W4) — DB-backed handler-integration suite + // (internal/handlers/approve_block_routes_test.go). The public GET + // /approve/:token landing (token IS the credential — no auth) is driven + // through the production route wiring (approveBlockApp mirrors + // router.go's app.Get("/approve/:token", NewPromoteApprovalHandler(db, + // rdb).Approve)) against a real Postgres: happy path (302 → dashboard + + // row persisted 'approved'), single-use (second click → 410), expired + // (410 + row flipped 'expired'), invalid token (404), and the per-IP + // rate limit (429). Moved here from routeCoverageExemptions. + "GET /approve/:token": "TestApproveBlock_ValidPendingToken", + // ── vault: per-team encrypted secret store (W3) — DB-backed handler- // integration suite (internal/handlers/vault_block_routes_test.go). Each // row points at the TestVaultBlock_* test that drives the route through the @@ -299,8 +310,9 @@ var routeCoverageExemptions = map[string]string{ "GET /.well-known/security.txt": "static RFC-9116 security.txt. TODO: matrix W7 content-surface smoke.", "GET /api/v1/incidents": "status-page incidents feed (read-only). TODO: matrix W7 status-surface smoke.", - // ── approve link (deploy/quota approval) — no e2e yet. - "GET /approve/:token": "approval-link landing — no integration test yet. TODO: matrix W4 deploy-approval flow.", + // ── approve link (deploy/quota approval) — MOVED to routeTestMap. Now + // covered by the W4 deploy-approval handler-integration suite + // (internal/handlers/approve_block_routes_test.go, TestApproveBlock_*). // ── auth: account-deletion confirm link — no e2e yet. "GET /auth/email/confirm-deletion": "magic-link account/team deletion confirm — no e2e yet. TODO: matrix W1 deletion-confirm flow.",