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