Skip to content

Commit a37fc14

Browse files
test(claim): cover token-precedence, takeover side-effects, session validity
Strengthen integration coverage of the anonymous->registered claim funnel with three behaviours the existing onboarding suites assert only partially: - token-vs-jwt precedence: when BOTH the canonical `token` and the deprecated `jwt` alias are present with conflicting values, `token` must win (ClaimRequest.claimToken). Existing tests only prove the jwt-only fallback, not the precedence. - account-takeover guard side-effects: assert the P0-1 refusal leaves the anonymous resource unattached (team_id IS NULL), grafts nothing onto the victim's team, and mints NO session_token. Existing tests assert only the 409 + JWT-not-consumed invariant. - happy-path session validity: decode the minted session JWT under the server secret and assert its tid/uid match the just-created team/user — existing tests only check session_token is non-empty. Reuses existing handlers_test harness (onboardingResidualApp, mintOnboardingJWT, decodeErrCode, testhelpers.*); adds no new harness. Error codes / response fields referenced via named constants. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 7a2eb87 commit a37fc14

1 file changed

Lines changed: 242 additions & 0 deletions

File tree

Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
package handlers_test
2+
3+
// claim_funnel_integration_test.go — integration coverage for the
4+
// anonymous→registered claim funnel, targeting the behaviours the
5+
// existing onboarding suites assert at most partially:
6+
//
7+
// 1. token-vs-jwt precedence — when BOTH the canonical `token` and the
8+
// deprecated `jwt` alias are present, `token` MUST win
9+
// (ClaimRequest.claimToken, onboarding.go:218). The existing
10+
// TestClaim_JWTLegacyAlias_AcceptedFallback only proves the
11+
// `jwt`-only fallback; nothing pins the precedence when both fields
12+
// carry conflicting values.
13+
//
14+
// 2. account-takeover guard side-effects — the existing
15+
// TestClaim_AccountTakeoverGuard / TestResidualClaim_AccountExists_409
16+
// assert the 409 + JWT-not-consumed invariant, but NOT that the
17+
// refusal leaves the anonymous resources unattached (team_id still
18+
// NULL) and mints NO session token. Those are the security-load-
19+
// bearing assertions of P0-1 (no resource graft, no session for an
20+
// unproven email) — a regression that re-attached the resources or
21+
// leaked a session would slip past the existing tests.
22+
//
23+
// 3. happy-path session token validity — the existing happy-path tests
24+
// assert session_token is non-empty but never decode it. This test
25+
// verifies the minted session JWT decodes under the server secret
26+
// and carries the just-created team_id (tid) + user_id (uid), proving
27+
// the funnel hands the caller a usable, correctly-scoped session.
28+
//
29+
// All helpers (onboardingResidualApp, mintOnboardingJWT, decodeErrCode,
30+
// testhelpers.*) are defined in the existing handlers_test files and
31+
// reused here — this file adds no new harness.
32+
//
33+
// These are integration tests requiring a real Postgres (TEST_DATABASE_URL).
34+
35+
import (
36+
"context"
37+
"net/http"
38+
"testing"
39+
40+
"github.com/golang-jwt/jwt/v4"
41+
"github.com/google/uuid"
42+
"github.com/stretchr/testify/assert"
43+
"github.com/stretchr/testify/require"
44+
45+
"instant.dev/internal/models"
46+
"instant.dev/internal/testhelpers"
47+
)
48+
49+
// claimAccountExistsErrCode is the wire `error` code POST /claim returns
50+
// when the supplied email already belongs to a registered account
51+
// (onboarding.go errCodeAccountExists). Named here so the assertions read
52+
// against a constant, not a scattered string literal.
53+
const claimAccountExistsErrCode = "account_exists"
54+
55+
// claimSessionTokenField is the response field carrying the minted session
56+
// JWT on a successful claim (onboarding.go Claim resp map).
57+
const claimSessionTokenField = "session_token"
58+
59+
// claimTeamIDField / claimUserIDField are the success-response identity
60+
// fields echoed back to the caller.
61+
const (
62+
claimTeamIDField = "team_id"
63+
claimUserIDField = "user_id"
64+
)
65+
66+
// sessionClaimTeamID / sessionClaimUserID are the JSON keys on the minted
67+
// session JWT (handlers.sessionClaims: `tid` / `uid`). Kept as named
68+
// constants so the decode assertions don't hardcode the wire field names
69+
// inline.
70+
const (
71+
sessionClaimTeamID = "tid"
72+
sessionClaimUserID = "uid"
73+
)
74+
75+
// TestClaimFunnel_TokenWinsOverJWTAlias asserts the claimToken precedence:
76+
// when a request carries BOTH `token` (a valid provisioned onboarding JWT)
77+
// and `jwt` (garbage), the claim succeeds — proving the handler read the
78+
// canonical `token` field and ignored the deprecated `jwt` alias. If the
79+
// precedence ever flipped (jwt winning), the garbage alias would drive an
80+
// invalid_token 400 and this test would go RED.
81+
func TestClaimFunnel_TokenWinsOverJWTAlias(t *testing.T) {
82+
db, cleanDB := testhelpers.SetupTestDB(t)
83+
defer cleanDB()
84+
rdb, cleanRedis := testhelpers.SetupTestRedis(t)
85+
defer cleanRedis()
86+
87+
app, cleanApp := testhelpers.NewTestApp(t, db, rdb)
88+
defer cleanApp()
89+
90+
fp := testhelpers.UniqueFingerprint(t)
91+
res := testhelpers.MustProvisionCacheFull(t, app, fp)
92+
require.NotEmpty(t, res.JWT, "provision response must include an onboarding JWT")
93+
defer db.Exec(`DELETE FROM resources WHERE token = $1`, res.Token)
94+
95+
// Both fields present: canonical `token` is the real JWT, deprecated
96+
// `jwt` is garbage. token must win → 201.
97+
body := map[string]any{
98+
"token": res.JWT,
99+
"jwt": "garbage-alias-that-must-be-ignored",
100+
"email": testhelpers.UniqueEmail(t),
101+
"team_name": "token-wins-" + uuid.NewString()[:8],
102+
}
103+
resp := testhelpers.PostJSON(t, app, "/claim", body)
104+
defer resp.Body.Close()
105+
require.Equal(t, http.StatusCreated, resp.StatusCode,
106+
"token must win over jwt when both present — a 400 means the garbage jwt alias was read instead")
107+
defer db.Exec(`DELETE FROM teams WHERE id = (SELECT team_id FROM resources WHERE token = $1)`, res.Token)
108+
109+
// The resource must have been claimed by the token path.
110+
var teamIDNull bool
111+
require.NoError(t, db.QueryRow(
112+
`SELECT team_id IS NULL FROM resources WHERE token = $1`, res.Token,
113+
).Scan(&teamIDNull))
114+
assert.False(t, teamIDNull,
115+
"resource must be attached — proves the canonical token drove the claim")
116+
}
117+
118+
// TestClaimFunnel_AccountTakeover_NoResourceAttach_NoSession extends the
119+
// P0-1 takeover guard coverage with the two side-effect invariants the
120+
// existing tests omit:
121+
//
122+
// - the anonymous resource the JWT pointed at must STAY unattached
123+
// (team_id IS NULL) — the refused claim must not graft it onto the
124+
// victim's (or any) team;
125+
// - the 409 response must carry NO session_token — a refused claim must
126+
// never leak a session for an email the caller didn't prove they own.
127+
func TestClaimFunnel_AccountTakeover_NoResourceAttach_NoSession(t *testing.T) {
128+
db, cleanDB := testhelpers.SetupTestDB(t)
129+
defer cleanDB()
130+
rdb, cleanRedis := testhelpers.SetupTestRedis(t)
131+
defer cleanRedis()
132+
133+
app, cleanApp := testhelpers.NewTestApp(t, db, rdb)
134+
defer cleanApp()
135+
ctx := context.Background()
136+
137+
// Provision a real anonymous resource so the JWT references something
138+
// claimable — this is what the guard must refuse to graft.
139+
fp := testhelpers.UniqueFingerprint(t)
140+
res := testhelpers.MustProvisionCacheFull(t, app, fp)
141+
require.NotEmpty(t, res.JWT, "provision response must include an onboarding JWT")
142+
defer db.Exec(`DELETE FROM resources WHERE token = $1`, res.Token)
143+
144+
// Seed a pre-existing registered account for the email the attacker
145+
// will claim with.
146+
victimEmail := testhelpers.UniqueEmail(t)
147+
victimTeam := testhelpers.MustCreateTeamDB(t, db, "hobby")
148+
_, err := models.CreateUser(ctx, db, uuid.MustParse(victimTeam), victimEmail, "", "", "owner")
149+
require.NoError(t, err)
150+
defer db.Exec(`DELETE FROM teams WHERE id = $1::uuid`, victimTeam)
151+
152+
// Claim the provisioned resource's JWT but with the victim's email.
153+
resp := testhelpers.PostJSON(t, app, "/claim", map[string]any{
154+
"token": res.JWT,
155+
"email": victimEmail,
156+
})
157+
defer resp.Body.Close()
158+
159+
require.Equal(t, http.StatusConflict, resp.StatusCode,
160+
"claiming with an email that already has an account must be refused (P0-1)")
161+
assert.Equal(t, claimAccountExistsErrCode, decodeErrCode(t, resp),
162+
"refusal must use the account_exists error code")
163+
164+
var got map[string]any
165+
testhelpers.DecodeJSON(t, resp, &got)
166+
_, hasSession := got[claimSessionTokenField]
167+
assert.False(t, hasSession,
168+
"a refused claim must NOT mint a session_token for an unproven email (P0-1)")
169+
170+
// The anonymous resource must remain unattached — the guard must not
171+
// have grafted it onto the victim's team (or any team).
172+
var teamIDNull bool
173+
require.NoError(t, db.QueryRow(
174+
`SELECT team_id IS NULL FROM resources WHERE token = $1`, res.Token,
175+
).Scan(&teamIDNull))
176+
assert.True(t, teamIDNull,
177+
"a refused claim must leave the anonymous resource unattached (team_id IS NULL)")
178+
179+
// Belt-and-braces: the victim's team must own no resources it didn't
180+
// already have (the provisioned resource must not have been grafted in).
181+
var grafted int
182+
require.NoError(t, db.QueryRow(
183+
`SELECT count(*) FROM resources WHERE team_id = $1::uuid`, victimTeam,
184+
).Scan(&grafted))
185+
assert.Equal(t, 0, grafted,
186+
"refused claim must not graft the anonymous resource onto the victim's team")
187+
}
188+
189+
// TestClaimFunnel_HappyPath_SessionTokenDecodesToCreatedTeam asserts the
190+
// minted session JWT is real and correctly scoped: it decodes under the
191+
// server secret AND carries the team_id (tid) + user_id (uid) the claim
192+
// response echoes. This proves the funnel hands the caller a usable,
193+
// correctly-scoped session — not just a non-empty string.
194+
func TestClaimFunnel_HappyPath_SessionTokenDecodesToCreatedTeam(t *testing.T) {
195+
db, cleanDB := testhelpers.SetupTestDB(t)
196+
defer cleanDB()
197+
rdb, cleanRedis := testhelpers.SetupTestRedis(t)
198+
defer cleanRedis()
199+
200+
app, cleanApp := testhelpers.NewTestApp(t, db, rdb)
201+
defer cleanApp()
202+
203+
fp := testhelpers.UniqueFingerprint(t)
204+
res := testhelpers.MustProvisionCacheFull(t, app, fp)
205+
require.NotEmpty(t, res.JWT, "provision response must include an onboarding JWT")
206+
defer db.Exec(`DELETE FROM resources WHERE token = $1`, res.Token)
207+
208+
resp := testhelpers.PostJSON(t, app, "/claim", map[string]any{
209+
"token": res.JWT,
210+
"email": testhelpers.UniqueEmail(t),
211+
"team_name": "session-decode-" + uuid.NewString()[:8],
212+
})
213+
defer resp.Body.Close()
214+
require.Equal(t, http.StatusCreated, resp.StatusCode)
215+
216+
var got map[string]any
217+
testhelpers.DecodeJSON(t, resp, &got)
218+
defer db.Exec(`DELETE FROM teams WHERE id = (SELECT team_id FROM resources WHERE token = $1)`, res.Token)
219+
220+
respTeamID, _ := got[claimTeamIDField].(string)
221+
respUserID, _ := got[claimUserIDField].(string)
222+
require.NotEmpty(t, respTeamID, "claim response must carry team_id")
223+
require.NotEmpty(t, respUserID, "claim response must carry user_id")
224+
225+
sessionToken, _ := got[claimSessionTokenField].(string)
226+
require.NotEmpty(t, sessionToken, "happy-path claim must mint a session_token")
227+
228+
// The session JWT must decode under the server (test) secret —
229+
// sessionClaims is unexported, so parse into MapClaims and read the
230+
// `tid` / `uid` wire fields directly.
231+
claims := jwt.MapClaims{}
232+
parsed, err := jwt.ParseWithClaims(sessionToken, claims, func(*jwt.Token) (interface{}, error) {
233+
return []byte(testhelpers.TestJWTSecret), nil
234+
})
235+
require.NoError(t, err, "minted session token must verify under the server secret")
236+
require.True(t, parsed.Valid, "minted session token must be valid")
237+
238+
assert.Equal(t, respTeamID, claims[sessionClaimTeamID],
239+
"session token tid must equal the just-created team_id")
240+
assert.Equal(t, respUserID, claims[sessionClaimUserID],
241+
"session token uid must equal the just-created user_id")
242+
}

0 commit comments

Comments
 (0)