Skip to content

Commit 4dfd318

Browse files
test(matrix): W4 deploy-approval block integration suite (move exempt→mapped) (#254)
Covers the public email-link approval landing GET /approve/:token (USER-FLOW-INVENTORY §W4), which was in routeCoverageExemptions with no mapped test. New DB-backed handler-integration suite in package handlers_test drives the route through the production wiring (approveBlockApp mirrors router.go's app.Get("/approve/:token", NewPromoteApprovalHandler(db, rdb).Approve)) against a real migrated Postgres + miniredis, asserting the real contract (read from promote_approval.go): - valid pending token → 302 to dashboard ?approved=1 + row PERSISTED 'approved' (source-of-truth state change) - single-use: second click on an approved token → 410 already-used - already-resolved (approved/rejected/executed) → 410, no state change - past-expiry pending token → 410 + row flipped to 'expired' - unknown token → 404 invalid (no probing oracle) - per-IP/sec rate-limit burst → 429 The route is public — the 32-byte token IS the credential, no auth chain (matches router.go wiring above the /api/v1 RequireAuth group), so the suite tests the token paths, not a session JWT. Done-bar guard: GET /approve/:token moves routeCoverageExemptions → routeTestMap (TestApproveBlock_ValidPendingToken). Both done-bar guards stay green (route mapped, test name resolves via AST scan, no stale rows). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent d680ed2 commit 4dfd318

3 files changed

Lines changed: 423 additions & 2 deletions

File tree

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
package handlers_test
2+
3+
// approve_block_helpers_test.go — shared helpers for the W4 deploy-approval
4+
// block integration suite (approve_block_routes_test.go). These cover the
5+
// public email-link approval landing — GET /approve/:token — from
6+
// USER-FLOW-INVENTORY-AND-TEST-MATRIX.md §W4, which prior to this suite sat in
7+
// internal/router/route_donebar_guard_test.go's routeCoverageExemptions with a
8+
// "TODO: matrix W4 deploy-approval flow" pointer and NO mapped test.
9+
//
10+
// Mirrors the established W3 vault-block / team-block convention
11+
// (vault_block_helpers_test.go): a thin SetupTestDB wrapper + a loud
12+
// skip-when-no-DB guard + DB-backed seed helpers. Helpers here are prefixed
13+
// approveBlock* so they do NOT collide with — and do NOT redefine — the
14+
// existing miniRedis / doJSON / decodeBody helpers (which this suite reuses
15+
// verbatim) or the white-box daApp / daSeedApproval helpers in
16+
// promote_approval_deployasync_test.go (those live in package handlers; this
17+
// suite is package handlers_test).
18+
//
19+
// Why a dedicated app builder rather than NewTestAppWithServices: that shared
20+
// test app does NOT register the approve route. approveBlockApp registers the
21+
// route through the SAME wiring internal/router/router.go installs —
22+
// handlers.NewPromoteApprovalHandler(db, rdb) + app.Get("/approve/:token",
23+
// h.Approve) — against the real migrated test DB and a real (miniredis) Redis,
24+
// so the suite exercises the public token-IS-the-credential contract end-to-end:
25+
// the per-IP rate limit, the four token branches (invalid / expired /
26+
// already-used / valid), the persisted status flip, and the 302→dashboard
27+
// redirect. The token is the only credential — there is NO RequireAuth on this
28+
// route by design (the email URL must work for an anonymous click), so no JWT is
29+
// minted here.
30+
31+
import (
32+
"context"
33+
"database/sql"
34+
"net/http"
35+
"net/http/httptest"
36+
"os"
37+
"testing"
38+
"time"
39+
40+
"github.com/gofiber/fiber/v2"
41+
"github.com/google/uuid"
42+
"github.com/redis/go-redis/v9"
43+
"github.com/stretchr/testify/require"
44+
45+
"instant.dev/internal/handlers"
46+
"instant.dev/internal/models"
47+
"instant.dev/internal/testhelpers"
48+
)
49+
50+
// approveBlockSkipNoDB skips a W4 deploy-approval test when no test Postgres is
51+
// configured. The approve block is a real-backend integration surface — these
52+
// tests assert on actual rows in promote_approvals (status flip from 'pending'
53+
// to 'approved'/'expired') and on the rendered HTML / redirect contract, so a
54+
// missing DB is a loud skip, never a false green. Mirrors vaultBlockSkipNoDB.
55+
func approveBlockSkipNoDB(t *testing.T) {
56+
t.Helper()
57+
if os.Getenv("TEST_DATABASE_URL") == "" {
58+
t.Skip("W4 deploy-approval integration: TEST_DATABASE_URL not set")
59+
}
60+
}
61+
62+
// approveBlockDB opens a fresh migrated test DB and returns it with its cleanup.
63+
// Thin wrapper over testhelpers.SetupTestDB so every W4 approve-block test reads
64+
// the same way.
65+
func approveBlockDB(t *testing.T) (*sql.DB, func()) {
66+
t.Helper()
67+
return testhelpers.SetupTestDB(t)
68+
}
69+
70+
// approveBlockApp builds a Fiber app that registers the public approve route
71+
// through the SAME wiring production uses (internal/router/router.go line ~599):
72+
// handlers.NewPromoteApprovalHandler(db, rdb) then
73+
// app.Get("/approve/:token", h.Approve). There is NO auth middleware — the route
74+
// is public by design (the token IS the credential), matching router.go where
75+
// this route is wired ABOVE the /api/v1 RequireAuth group. rdb drives the
76+
// per-IP rate limit (pass a real miniredis client to exercise it; pass nil to
77+
// exercise the rate-limit-fails-open posture).
78+
func approveBlockApp(t *testing.T, db *sql.DB, rdb *redis.Client) *fiber.App {
79+
t.Helper()
80+
// ProxyHeader mirrors router.New so c.IP() reads X-Forwarded-For — the
81+
// per-IP rate-limit key the handler builds.
82+
app := fiber.New(fiber.Config{ProxyHeader: "X-Forwarded-For"})
83+
h := handlers.NewPromoteApprovalHandler(db, rdb)
84+
app.Get("/approve/:token", h.Approve)
85+
return app
86+
}
87+
88+
// approveBlockSeedTeam inserts a 'pro'-tier team and returns its id, registering
89+
// cleanup of its promote_approvals + the team row. promote_approvals.team_id has
90+
// a NOT NULL FK to teams, so every seeded approval needs a real team.
91+
func approveBlockSeedTeam(t *testing.T, db *sql.DB) uuid.UUID {
92+
t.Helper()
93+
var id uuid.UUID
94+
require.NoError(t,
95+
db.QueryRow(`INSERT INTO teams (plan_tier) VALUES ('pro') RETURNING id`).Scan(&id),
96+
"seed approve-block team")
97+
t.Cleanup(func() {
98+
db.Exec(`DELETE FROM promote_approvals WHERE team_id = $1`, id)
99+
db.Exec(`DELETE FROM audit_log WHERE team_id = $1`, id)
100+
db.Exec(`DELETE FROM teams WHERE id = $1`, id)
101+
})
102+
return id
103+
}
104+
105+
// approveBlockSeedApproval inserts a promote_approvals row for teamID at the
106+
// given status, with expiry `expiresIn` from now. A NEGATIVE expiresIn forces an
107+
// already-expired row (CreatePromoteApproval coerces a non-positive TTL to the
108+
// 24h default, so we set expires_at directly in that case — same trick as the
109+
// white-box daSeedApproval). Returns the row (carrying the plaintext token to
110+
// click). Built on the real models.CreatePromoteApproval so the row shape
111+
// matches what the production CreatePromoteApprovalAndEmit writes.
112+
func approveBlockSeedApproval(t *testing.T, db *sql.DB, teamID uuid.UUID, status string, expiresIn time.Duration) *models.PromoteApproval {
113+
t.Helper()
114+
tok, err := models.GeneratePromoteApprovalToken()
115+
require.NoError(t, err, "gen approve token")
116+
117+
ttl := expiresIn
118+
if ttl <= 0 {
119+
// CreatePromoteApproval would coerce this to the default; pass a small
120+
// positive TTL, then force expires_at into the past below.
121+
ttl = time.Hour
122+
}
123+
row, err := models.CreatePromoteApproval(context.Background(), db, models.CreatePromoteApprovalParams{
124+
Token: tok,
125+
TeamID: teamID,
126+
RequestedByEmail: "approver-" + uuid.NewString()[:8] + "@example.com",
127+
PromoteKind: models.PromoteApprovalKindStack,
128+
PromotePayload: []byte(`{"from":"staging","to":"production"}`),
129+
FromEnv: "staging",
130+
ToEnv: "production",
131+
TTL: ttl,
132+
})
133+
require.NoError(t, err, "create approve-block approval")
134+
135+
if status != models.PromoteApprovalStatusPending {
136+
_, err = db.Exec(`UPDATE promote_approvals SET status=$1 WHERE id=$2`, status, row.ID)
137+
require.NoError(t, err, "set approval status")
138+
row.Status = status
139+
}
140+
if expiresIn < 0 {
141+
_, err = db.Exec(`UPDATE promote_approvals SET expires_at = now() - interval '1 hour' WHERE id=$1`, row.ID)
142+
require.NoError(t, err, "force-expire approval")
143+
row.ExpiresAt = time.Now().UTC().Add(-time.Hour)
144+
}
145+
return row
146+
}
147+
148+
// approveBlockGet issues a GET /approve/:token through the test app and returns
149+
// the *http.Response WITHOUT following the redirect (fiber's app.Test does not
150+
// auto-follow), so the caller can assert on the 302 + Location header. The
151+
// X-Forwarded-For header pins the per-IP rate-limit bucket so each test gets a
152+
// fresh budget regardless of test ordering. Body is left open for the caller to
153+
// read + close.
154+
func approveBlockGet(t *testing.T, app *fiber.App, token, clientIP string) *http.Response {
155+
t.Helper()
156+
req := httptest.NewRequest(http.MethodGet, "/approve/"+token, nil)
157+
if clientIP != "" {
158+
req.Header.Set("X-Forwarded-For", clientIP)
159+
}
160+
resp, err := app.Test(req, 5000)
161+
require.NoError(t, err, "GET /approve/%s", token)
162+
return resp
163+
}
164+
165+
// approveBlockStatusOf reads the persisted status of a promote_approvals row by
166+
// id — the source-of-truth assertion that the handler's state change actually
167+
// landed in Postgres (not merely that it returned a 302).
168+
func approveBlockStatusOf(t *testing.T, db *sql.DB, id uuid.UUID) string {
169+
t.Helper()
170+
var status string
171+
require.NoError(t,
172+
db.QueryRow(`SELECT status FROM promote_approvals WHERE id=$1`, id).Scan(&status),
173+
"read approval status")
174+
return status
175+
}

0 commit comments

Comments
 (0)