Skip to content

Commit 764da11

Browse files
mastermanas805Manas Srivastavaclaude
authored
fix(onboarding,brevo): /start always 302 + Brevo 401 carries correct agent_action (API-5/6) (#172)
* fix(onboarding,brevo): /start always 302 + Brevo 401 carries correct agent_action (API-5/6) == API-5 (P2) — GET /start ALWAYS 302s == Per CLAUDE.md 'Live API surface': '/start (302 → dashboard /claim?t=jwt)'. Previously, an invalid/missing/expired token surfaced the raw JSON envelope '{"ok":false,"error":"invalid_token"}' with HTTP 400. /start URLs land in agents' terminal logs ('upgrade: https://api.instanode.dev/start?t=...'); when a user copy-pastes a stale one into a browser, they see naked JSON instead of a friendly recovery flow. Fix: pass the token through verbatim and let the dashboard's ClaimPage handle every validation case (expired / unrecognised / already-claimed / empty). The JTI lookup now happens once at /claim time where it's actually load-bearing — the platform side no longer wastes a DB lookup on every drive-by /start hit. landing_viewed metric still increments so the funnel pivot stays measurable. Edge cases: - Missing ?t — 302 to /claim with no t= query, dashboard renders empty state. - Garbage token — 302 to /claim?t=garbage, dashboard renders 'invalid token'. - Unknown JTI — 302 to /claim?t=<jwt>, dashboard does the lookup. - Happy path — unchanged (302 to /claim?t=<jwt>). == API-6 (P2) — Brevo 401 agent_action targets operator, not user == POST /webhooks/brevo/<bogus-secret> returned 401 with the canonical 'unauthorized' error class — and the canonical agent_action for that class ('Tell the user their INSTANODE_TOKEN is missing or invalid. Have them log in at https://instanode.dev/login...'). The Brevo webhook is unrelated to user auth: an agent reading the error envelope would tell the user to log in for a new INSTANODE_TOKEN, which is uselessly wrong. The actual incident is an OPERATOR-side Brevo dashboard config drift. Fix: introduce a new error class 'brevo_secret_mismatch' with operator- targeted agent_action that tells the caller to verify the Brevo dashboard webhook URL contains the configured BREVO_WEBHOOK_SECRET. HTTP status stays 401 — only the error CODE + agent_action copy change. Existing operator alerting that pivots off metrics.BrevoWebhookEventsTotal{result="unauthorized"} is unaffected. == Multi-surface checklist (memory rule 22) == | Surface | Touched | Why | |---|---|---| | api/internal/handlers/openapi.go | Yes | /start contract changed from {302,400} to {302} only; spec updated to reflect always-302 + 't' parameter now optional | | api/internal/handlers/helpers.go | Yes | New 'brevo_secret_mismatch' entry in codeToAgentAction map | | content/llms.txt | No | /start was already documented as 302 — the dashboard-renders-error behaviour is the only new contract, and it's not a customer/agent-facing change (every agent that follows the 302 already lands at the dashboard regardless) | | METRICS-CATALOG.md | No | No new metrics | | dashboard | No | ClaimPage already handles the 'no/expired token' UI state | == Test coverage == - TestResidualStartLanding_MissingToken_RedirectsToClaim — no t= → 302 - TestResidualStartLanding_GarbageToken_StillRedirects — invalid JWT → 302 - TestResidualStartLanding_UnknownJTI_StillRedirects — valid sig + unknown JTI → 302 - TestResidualStartLanding_HappyPath_Redirects — unchanged - TestBrevoTxWebhook_SecretMismatch_AgentActionMentionsBrevo — pins API-6: error code is brevo_secret_mismatch, agent_action mentions Brevo, does NOT carry INSTANODE_TOKEN or the user-login recovery script == Four-pass deploy ritual (memory rule 23) == - [x] Local build + vet + new tests green - [ ] CI green (this PR) - [ ] Auto-deploy via deploy.yml on merge - [ ] Live verify: curl https://api.instanode.dev/healthz | jq .commit_id == merge SHA == QA evidence == - /tmp/qa-session/API/start_bogus.txt + start_bogus_headers.txt — pre-fix 400 JSON - /tmp/qa-session/API/brevo_bogus.json — pre-fix INSTANODE_TOKEN agent_action Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test+helpers: align /start tests with always-302 + reword brevo agent_action to U3 contract Follow-up to the round-1 build-and-test failures: 1. brevo_secret_mismatch agent_action — reworded to comply with U3 contract enforced by TestAgentActionContract: must open with 'Tell the user', contain a full https://instanode.dev/ URL, and stay under 280 chars. New copy: Tell the user this is a Brevo-webhook config mismatch, not their auth. Operators must verify the Brevo dashboard webhook URL matches the configured BREVO_WEBHOOK_SECRET — see https://instanode.dev/docs/email. Still routes operators to the correct fix (Brevo dashboard webhook URL, not the user-login flow), but in the standard agent-action voice. 210 chars. 2. Existing /start tests aligned with the API-5 always-302 contract: - TestStartLanding_AlreadyClaimedRedirectsToDashboardWithFlag — now asserts 302 to /claim?t=, no longer asserts the already_claimed=true flag (dashboard handles the already-claimed UI now). - TestStartLanding_MissingTokenReturns400 — now asserts 302 with no t= query. - TestStartLanding_UnknownJTI_400 — now asserts 302 to /claim?t=. - TestOnboarding_GetStart_ExpiredJWT_Returns400LinkExpired — 302 to /claim. - TestOnboarding_GetStart_TamperedJWT_Returns400InvalidLink — 302 to /claim. - TestStartLanding_NoToken_Returns400 — 302 to /claim (no t=). - TestStartLanding_TamperedJWT_Returns400 — 302 to /claim?t=. - TestStartLanding_AlreadyClaimed_Returns302 — 302 to /claim?t= (no flag). - TestOnboarding_JWTWithFutureIssuedAt_Returns400 — 302 to /claim?t=. Test function NAMES kept verbatim for grep stability; ASSERTIONS updated to the new always-bounce contract per CLAUDE.md 'Live API surface' line. 3. TestBrevoTxWebhook_SecretMismatch_AgentActionMentionsBrevo — tightened the negative assertion to match the rephrased operator copy ('log in at https://instanode.dev/login to mint a new one' is the load-bearing piece we must NOT carry). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Manas Srivastava <noreply@instanode.dev> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 7868dce commit 764da11

8 files changed

Lines changed: 169 additions & 104 deletions

File tree

internal/handlers/brevo_webhook.go

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -356,10 +356,18 @@ func (h *BrevoTransactionalWebhookHandler) Receive(c *fiber.Ctx) error {
356356
// B13-F7 / B18 wave-3: hydrate the canonical ErrorResponse envelope
357357
// (ok/error/message/request_id/retry_after_seconds/agent_action) so
358358
// schema validators on the wire see the same 4xx shape every other
359-
// handler emits. respondError reads the canonical agent_action from
360-
// codeToAgentAction["unauthorized"], so we get a consistent UX
361-
// surface without a per-call override.
362-
return respondError(c, fiber.StatusUnauthorized, "unauthorized",
359+
// handler emits.
360+
//
361+
// API-6 (QA 2026-05-29): use the Brevo-specific error code
362+
// `brevo_secret_mismatch` instead of the generic `unauthorized` so
363+
// the canonical agent_action correctly tells the OPERATOR to fix
364+
// their Brevo dashboard config, instead of telling a USER to log
365+
// in for a new INSTANODE_TOKEN (this webhook is unrelated to user
366+
// auth). HTTP status stays 401 — only the error CODE + agent_action
367+
// copy change, so existing operator alerting that pivots off
368+
// `metrics.BrevoWebhookEventsTotal{result="unauthorized"}` is
369+
// unaffected.
370+
return respondError(c, fiber.StatusUnauthorized, "brevo_secret_mismatch",
363371
"Brevo webhook URL secret did not match the configured value.")
364372
}
365373

internal/handlers/brevo_webhook_test.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,10 @@ package handlers_test
3030
import (
3131
"bytes"
3232
"errors"
33+
"io"
3334
"net/http"
3435
"net/http/httptest"
36+
"strings"
3537
"testing"
3638

3739
sqlmock "github.com/DATA-DOG/go-sqlmock"
@@ -171,6 +173,47 @@ func TestBrevoTxWebhook_SecretMismatch_Returns401(t *testing.T) {
171173
}
172174
}
173175

176+
// TestBrevoTxWebhook_SecretMismatch_AgentActionMentionsBrevo pins API-6 (QA
177+
// 2026-05-29): the 401 envelope must carry an OPERATOR-targeted agent_action
178+
// telling whoever called this endpoint to verify their Brevo webhook URL,
179+
// NOT the generic "tell the user to log in for a new INSTANODE_TOKEN" copy
180+
// that ships on the canonical `unauthorized` error class. The Brevo webhook
181+
// is unrelated to user auth.
182+
func TestBrevoTxWebhook_SecretMismatch_AgentActionMentionsBrevo(t *testing.T) {
183+
db, _, _ := sqlmock.New()
184+
defer db.Close()
185+
h := handlers.NewBrevoTransactionalWebhookHandler(db, &config.Config{BrevoWebhookSecret: testBrevoTxSecret})
186+
app := brevoTxApp(t, h)
187+
188+
resp := postBrevoTx(t, app, "bogus-secret", `{"event":"delivered","message-id":"x"}`)
189+
if resp.StatusCode != http.StatusUnauthorized {
190+
t.Fatalf("status = %d; want 401", resp.StatusCode)
191+
}
192+
193+
raw, err := io.ReadAll(resp.Body)
194+
if err != nil {
195+
t.Fatalf("read body: %v", err)
196+
}
197+
body := string(raw)
198+
199+
// Error CODE must be the Brevo-specific one — not the generic
200+
// "unauthorized" that ships with the user-login agent_action.
201+
if !strings.Contains(body, `"error":"brevo_secret_mismatch"`) {
202+
t.Errorf("body must carry error=brevo_secret_mismatch; got %s", body)
203+
}
204+
// Agent action must mention Brevo / BREVO_WEBHOOK_SECRET — NOT
205+
// "INSTANODE_TOKEN" or the generic login-recovery script.
206+
if !strings.Contains(strings.ToLower(body), "brevo") {
207+
t.Errorf("agent_action must mention Brevo; got %s", body)
208+
}
209+
if strings.Contains(body, "INSTANODE_TOKEN") {
210+
t.Errorf("agent_action must NOT mention INSTANODE_TOKEN (unrelated to this webhook); got %s", body)
211+
}
212+
if strings.Contains(body, "log in at https://instanode.dev/login to mint a new one") {
213+
t.Errorf("agent_action must NOT carry the user-login recovery script; got %s", body)
214+
}
215+
}
216+
174217
// ── 4. Closed-by-default: empty configured secret OR empty URL param
175218

176219
func TestBrevoTxWebhook_EmptyConfiguredSecret_Returns401(t *testing.T) {

internal/handlers/helpers.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,16 @@ var codeToAgentAction = map[string]errorCodeMeta{
145145
"unauthorized": {
146146
AgentAction: "Tell the user their INSTANODE_TOKEN is missing or invalid. Have them log in at https://instanode.dev/login to mint a new one — takes 30 seconds.",
147147
},
148+
// brevo_secret_mismatch is a Brevo webhook URL-path-token mismatch — NOT a
149+
// user-auth failure. The generic "unauthorized" agent_action ("log in to mint
150+
// a new INSTANODE_TOKEN") sent an unrelated recovery script that was
151+
// uselessly wrong for the actual incident (operator must verify their Brevo
152+
// dashboard webhook URL contains the configured BREVO_WEBHOOK_SECRET).
153+
// API-6 (QA 2026-05-29): give this error its own copy. Follows the U3
154+
// contract — "Tell the user" opening, https://instanode.dev/ URL, < 280 chars.
155+
"brevo_secret_mismatch": {
156+
AgentAction: "Tell the user this is a Brevo-webhook config mismatch, not their auth. Operators must verify the Brevo dashboard webhook URL matches the configured BREVO_WEBHOOK_SECRET — see https://instanode.dev/docs/email.",
157+
},
148158
"auth_required": {
149159
AgentAction: "Tell the user this action requires an authenticated session. Have them log in or sign up at https://instanode.dev/login — both flows mint a token.",
150160
},

internal/handlers/onboarding.go

Lines changed: 27 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -37,42 +37,38 @@ func NewOnboardingHandler(db *sql.DB, cfg *config.Config, emailClient *email.Cli
3737
return &OnboardingHandler{db: db, cfg: cfg, email: emailClient}
3838
}
3939

40-
// StartLanding handles GET /start?t={jwt}
41-
// Validates the JWT and redirects to the dashboard ClaimPage.
40+
// StartLanding handles GET /start?t={jwt}.
41+
//
42+
// API-5 (QA 2026-05-29): per CLAUDE.md "Live API surface", /start must ALWAYS
43+
// 302 to the dashboard `/claim?t=jwt` — the dashboard is the user-facing
44+
// landing page that renders any token error (expired / unrecognised /
45+
// already-claimed) in a friendly UI. Previously, an invalid token surfaced
46+
// the raw `{"ok":false,"error":"invalid_token"}` JSON envelope with HTTP 400,
47+
// which is what an upgrade link printed in an agent's terminal log lands on
48+
// when copy-pasted into a browser — the user sees naked JSON, not a recovery
49+
// flow.
50+
//
51+
// The new contract: pass the token through verbatim and let the dashboard's
52+
// ClaimPage handle validation. Bonus: the platform side avoids a DB lookup on
53+
// every drive-by /start hit (the JTI lookup now happens once, at /claim time,
54+
// where it's actually load-bearing).
55+
//
56+
// Edge cases:
57+
// - Missing `t` query: 302 to /claim (no token) — the dashboard's ClaimPage
58+
// renders its "no token" empty state.
59+
// - Token shape is preserved (url.QueryEscape on the raw value); no
60+
// validation, no decoding — invalidity is the dashboard's concern.
61+
//
62+
// The landing-viewed metric still increments so the funnel pivot of
63+
// "agents that surface /start in their tool output" stays measurable.
4264
func (h *OnboardingHandler) StartLanding(c *fiber.Ctx) error {
43-
ctx := c.UserContext()
44-
requestID := middleware.GetRequestID(c)
4565
jwtStr := c.Query("t")
46-
if jwtStr == "" {
47-
return respondError(c, fiber.StatusBadRequest, "missing_token", "Upgrade token is required")
48-
}
49-
50-
claims, err := crypto.VerifyOnboardingJWT([]byte(h.cfg.JWTSecret), jwtStr)
51-
if err != nil {
52-
slog.Warn("onboarding.start.invalid_jwt",
53-
"error", err,
54-
"request_id", requestID,
55-
)
56-
return respondError(c, fiber.StatusBadRequest, "invalid_token", "Upgrade token is invalid or expired")
57-
}
58-
59-
// Verify JTI exists and hasn't been converted.
60-
ev, err := models.GetOnboardingByJTI(ctx, h.db, claims.ID)
61-
if err != nil {
62-
var notFound *models.ErrOnboardingNotFound
63-
if errors.As(err, &notFound) {
64-
return respondError(c, fiber.StatusBadRequest, "invalid_token", "Upgrade token not recognized")
65-
}
66-
slog.Error("onboarding.start.db_error", "error", err, "request_id", requestID)
67-
return respondError(c, fiber.StatusServiceUnavailable, "lookup_failed", "Failed to verify upgrade token")
68-
}
69-
70-
if ev.ConvertedAt.Valid {
71-
return c.Redirect(h.cfg.DashboardBaseURL+"/claim?already_claimed=true", fiber.StatusFound)
72-
}
7366

7467
metrics.ConversionFunnel.WithLabelValues("landing_viewed").Inc()
7568

69+
if jwtStr == "" {
70+
return c.Redirect(h.cfg.DashboardBaseURL+"/claim", fiber.StatusFound)
71+
}
7672
return c.Redirect(h.cfg.DashboardBaseURL+"/claim?t="+url.QueryEscape(jwtStr), fiber.StatusFound)
7773
}
7874

internal/handlers/onboarding_coverage_test.go

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -455,6 +455,13 @@ func TestClaim_HappyPath_FreshTeamAndUser(t *testing.T) {
455455
assert.NotEmpty(t, got["session_token"])
456456
}
457457

458+
// API-5 (QA 2026-05-29): /start ALWAYS 302s to the dashboard /claim, regardless
459+
// of token validity. The dashboard renders any error UI (expired / unrecognised
460+
// / already-claimed). Pre-fix, these tests asserted 400 JSON; the new contract
461+
// is "always-bounce", so they assert 302 to /claim instead. The
462+
// `already_claimed` flag no longer surfaces in the redirect URL because we no
463+
// longer look up the JTI at /start time — the dashboard does it.
464+
458465
func TestStartLanding_AlreadyClaimedRedirectsToDashboardWithFlag(t *testing.T) {
459466
db, clean := testhelpers.SetupTestDB(t)
460467
defer clean()
@@ -489,10 +496,13 @@ func TestStartLanding_AlreadyClaimedRedirectsToDashboardWithFlag(t *testing.T) {
489496
defer resp.Body.Close()
490497
assert.Equal(t, http.StatusFound, resp.StatusCode)
491498
loc := resp.Header.Get("Location")
492-
assert.Contains(t, loc, "already_claimed=true")
499+
// Always-302 contract: the dashboard handles already-claimed in its UI;
500+
// the platform side just forwards the token verbatim.
501+
assert.Contains(t, loc, "/claim?t=")
493502
}
494503

495504
func TestStartLanding_MissingTokenReturns400(t *testing.T) {
505+
// API-5: kept name for grep; new contract is 302 to /claim with no t= query.
496506
db, clean := testhelpers.SetupTestDB(t)
497507
defer clean()
498508
rdb, cleanRedis := testhelpers.SetupTestRedis(t)
@@ -504,13 +514,15 @@ func TestStartLanding_MissingTokenReturns400(t *testing.T) {
504514
resp, err := app.Test(req, 5000)
505515
require.NoError(t, err)
506516
defer resp.Body.Close()
507-
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
508-
var body map[string]any
509-
testhelpers.DecodeJSON(t, resp, &body)
510-
assert.Equal(t, "missing_token", body["error"])
517+
assert.Equal(t, http.StatusFound, resp.StatusCode)
518+
loc := resp.Header.Get("Location")
519+
assert.Contains(t, loc, "/claim")
520+
assert.NotContains(t, loc, "/claim?t=", "missing token must redirect without t= query")
511521
}
512522

513523
func TestStartLanding_UnknownJTI_400(t *testing.T) {
524+
// API-5: kept name for grep; new contract is 302 to /claim?t=<jwt> — the
525+
// dashboard does the JTI lookup and renders any error UI.
514526
db, clean := testhelpers.SetupTestDB(t)
515527
defer clean()
516528
rdb, cleanRedis := testhelpers.SetupTestRedis(t)
@@ -527,7 +539,8 @@ func TestStartLanding_UnknownJTI_400(t *testing.T) {
527539
resp, err := app.Test(req, 5000)
528540
require.NoError(t, err)
529541
defer resp.Body.Close()
530-
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
542+
assert.Equal(t, http.StatusFound, resp.StatusCode)
543+
assert.Contains(t, resp.Header.Get("Location"), "/claim?t=")
531544
}
532545

533546
// ===== Email validation helpers =====

internal/handlers/onboarding_residual_test.go

Lines changed: 26 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -95,61 +95,52 @@ func doGet(t *testing.T, app *fiber.App, path string) *http.Response {
9595
}
9696

9797
// ── StartLanding ─────────────────────────────────────────────────────────────
98-
99-
func TestResidualStartLanding_MissingToken_400(t *testing.T) {
98+
//
99+
// API-5 (QA 2026-05-29): /start now ALWAYS 302s to the dashboard /claim
100+
// regardless of token validity — the dashboard ClaimPage renders any token
101+
// error (expired / unrecognised / already-claimed) in a friendly UI. The
102+
// platform side no longer validates the JWT at /start; that's the dashboard's
103+
// job. Per CLAUDE.md "Live API surface" line.
104+
105+
// TestResidualStartLanding_MissingToken_RedirectsToClaim — no token → 302 to
106+
// /claim (no t=) so the dashboard renders its empty / login state.
107+
func TestResidualStartLanding_MissingToken_RedirectsToClaim(t *testing.T) {
100108
db, clean := testhelpers.SetupTestDB(t)
101109
defer clean()
102110
app := onboardingResidualApp(t, db)
103111
resp := doGet(t, app, "/start")
104-
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
112+
assert.Equal(t, http.StatusFound, resp.StatusCode)
113+
assert.Contains(t, resp.Header.Get("Location"), "/claim")
114+
// No t= query when missing.
115+
assert.NotContains(t, resp.Header.Get("Location"), "/claim?t=")
105116
}
106117

107-
func TestResidualStartLanding_InvalidJWT_400(t *testing.T) {
118+
// TestResidualStartLanding_GarbageToken_StillRedirects — invalid/garbage tokens
119+
// must NOT 400 the user with raw JSON. The dashboard renders the error.
120+
func TestResidualStartLanding_GarbageToken_StillRedirects(t *testing.T) {
108121
db, clean := testhelpers.SetupTestDB(t)
109122
defer clean()
110123
app := onboardingResidualApp(t, db)
111124
resp := doGet(t, app, "/start?t=garbage")
112-
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
125+
assert.Equal(t, http.StatusFound, resp.StatusCode)
126+
assert.Contains(t, resp.Header.Get("Location"), "/claim?t=garbage")
113127
}
114128

115-
func TestResidualStartLanding_UnknownJTI_400(t *testing.T) {
129+
// TestResidualStartLanding_UnknownJTI_StillRedirects — a syntactically valid
130+
// JWT for an unknown JTI must also 302 — the dashboard does the JTI lookup
131+
// and renders the "expired/unrecognised" message.
132+
func TestResidualStartLanding_UnknownJTI_StillRedirects(t *testing.T) {
116133
db, clean := testhelpers.SetupTestDB(t)
117134
defer clean()
118135
app := onboardingResidualApp(t, db)
119136
signed := mintOnboardingJWT(t, uuid.NewString(), "fp-start-unknown", nil)
120137
resp := doGet(t, app, "/start?t="+signed)
121-
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
122-
}
123-
124-
// TestStartLanding_DBError_503 drives the db_error arm (66-67) via a brokenDB:
125-
// JWT verifies in-process, then GetOnboardingByJTI errors with a non-notfound
126-
// error → 503 lookup_failed.
127-
func TestResidualStartLanding_DBError_503(t *testing.T) {
128-
app := onboardingResidualApp(t, brokenDB(t))
129-
signed := mintOnboardingJWT(t, uuid.NewString(), "fp-start-broken", nil)
130-
resp := doGet(t, app, "/start?t="+signed)
131-
assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
132-
}
133-
134-
// TestStartLanding_AlreadyClaimed_Redirects drives the converted-redirect arm
135-
// (70-72): a converted onboarding row → 302 to the dashboard with the flag.
136-
func TestResidualStartLanding_AlreadyClaimed_Redirects(t *testing.T) {
137-
db, clean := testhelpers.SetupTestDB(t)
138-
defer clean()
139-
app := onboardingResidualApp(t, db)
140-
jti := uuid.NewString()
141-
_, err := db.ExecContext(context.Background(), `
142-
INSERT INTO onboarding_events (jti, fingerprint, converted_at, team_id)
143-
VALUES ($1, $2, now(), NULL)
144-
`, jti, "fp-start-claimed")
145-
require.NoError(t, err)
146-
signed := mintOnboardingJWT(t, jti, "fp-start-claimed", nil)
147-
resp := doGet(t, app, "/start?t="+signed)
148138
assert.Equal(t, http.StatusFound, resp.StatusCode)
149-
assert.Contains(t, resp.Header.Get("Location"), "already_claimed=true")
139+
assert.Contains(t, resp.Header.Get("Location"), "/claim?t=")
150140
}
151141

152-
// TestStartLanding_HappyPath_Redirects drives the success redirect (74-76).
142+
// TestResidualStartLanding_HappyPath_Redirects — happy path is still a 302
143+
// with t= query intact. Same shape as the always-302 contract.
153144
func TestResidualStartLanding_HappyPath_Redirects(t *testing.T) {
154145
db, clean := testhelpers.SetupTestDB(t)
155146
defer clean()

0 commit comments

Comments
 (0)