|
| 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