Skip to content

Commit 821976d

Browse files
Merge branch 'master' into dependabot/github_actions/actions-f61a940cf5
2 parents 3287289 + 9a59b2c commit 821976d

10 files changed

Lines changed: 503 additions & 15 deletions
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+
}

internal/handlers/onboarding.go

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,37 @@ const (
233233
errCodeAccountExists = "account_exists"
234234
)
235235

236+
// attachClaimedResourceToTeam links a single anonymous resource to the claiming
237+
// team and elevates it anonymous->free. It is the shared write path for BOTH
238+
// claim-time resource grabs (the JWT-listed loop and the fingerprint-discovered
239+
// loop) so they behave identically and observably.
240+
//
241+
// Pre-fix both call sites used `_, _ = h.db.ExecContext(...)`, swallowing any
242+
// error: a failed UPDATE left the resource with team_id IS NULL AFTER a
243+
// successful claim — an orphaned-after-claim resource with NO log and NO metric,
244+
// invisible to operators (the user "claimed" but their resource never attached).
245+
// The claim itself is deliberately NOT failed over one attach hiccup (the
246+
// team+user already exist; the next claim/reconcile can re-grab the still-NULL
247+
// row), but the failure is now logged at WARN so it is visible in NR Logs. The
248+
// error is returned for the caller's awareness and for tests; callers may ignore
249+
// it. The `WHERE team_id IS NULL` guard keeps this idempotent (0 rows when the
250+
// resource was already attached — not an error).
251+
func attachClaimedResourceToTeam(ctx context.Context, db *sql.DB, teamID, resourceID uuid.UUID, requestID string) error {
252+
if _, err := db.ExecContext(ctx, `
253+
UPDATE resources SET team_id = $1, tier = 'free'
254+
WHERE id = $2 AND team_id IS NULL
255+
`, teamID, resourceID); err != nil {
256+
slog.Warn("onboarding.claim.resource_attach_failed",
257+
"error", err,
258+
"resource_id", resourceID,
259+
"team_id", teamID,
260+
"request_id", requestID,
261+
"note", "resource left orphaned (team_id NULL) after a successful claim — reaper/re-claim follow-up")
262+
return err
263+
}
264+
return nil
265+
}
266+
236267
// Claim handles POST /claim — converts an anonymous session to a registered team.
237268
func (h *OnboardingHandler) Claim(c *fiber.Ctx) error {
238269
ctx, span := otel.Tracer("instant.dev/handlers").Start(c.UserContext(), "onboarding.claim")
@@ -455,10 +486,7 @@ func (h *OnboardingHandler) Claim(c *fiber.Ctx) error {
455486
// the Razorpay subscription.charged webhook clears it (via
456487
// ElevateResourceTiersByTeam). If the user never pays, the reaper
457488
// deletes the resource at expires_at — same fate as an anonymous one.
458-
_, _ = h.db.ExecContext(ctx, `
459-
UPDATE resources SET team_id = $1, tier = 'free'
460-
WHERE id = $2 AND team_id IS NULL
461-
`, team.ID, resource.ID)
489+
_ = attachClaimedResourceToTeam(ctx, h.db, team.ID, resource.ID, requestID)
462490
}
463491

464492
// Also claim any additional fingerprint resources not yet in the JWT.
@@ -472,10 +500,7 @@ func (h *OnboardingHandler) Claim(c *fiber.Ctx) error {
472500
if claimedIDs[r.ID] || r.TeamID.Valid {
473501
continue
474502
}
475-
_, _ = h.db.ExecContext(ctx, `
476-
UPDATE resources SET team_id = $1, tier = 'free'
477-
WHERE id = $2 AND team_id IS NULL
478-
`, team.ID, r.ID)
503+
_ = attachClaimedResourceToTeam(ctx, h.db, team.ID, r.ID, requestID)
479504
}
480505
}
481506

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
package handlers
2+
3+
// onboarding_attach_resource_test.go — unit tests for attachClaimedResourceToTeam,
4+
// the shared claim-time resource-attach helper. Pre-fix both call sites used
5+
// `_, _ = h.db.ExecContext(...)`, swallowing the error and silently orphaning a
6+
// resource after a successful claim. These tests pin that the error is now
7+
// surfaced (and the happy / already-attached paths are unaffected). Uses sqlmock
8+
// so the error path is covered without a live Postgres (no docker needed).
9+
10+
import (
11+
"context"
12+
"errors"
13+
"testing"
14+
15+
sqlmock "github.com/DATA-DOG/go-sqlmock"
16+
"github.com/google/uuid"
17+
)
18+
19+
func TestAttachClaimedResourceToTeam(t *testing.T) {
20+
teamID, resID := uuid.New(), uuid.New()
21+
22+
t.Run("success", func(t *testing.T) {
23+
db, mock, err := sqlmock.New()
24+
if err != nil {
25+
t.Fatalf("sqlmock.New: %v", err)
26+
}
27+
defer func() { _ = db.Close() }()
28+
mock.ExpectExec(`UPDATE resources SET team_id`).
29+
WillReturnResult(sqlmock.NewResult(0, 1))
30+
if err := attachClaimedResourceToTeam(context.Background(), db, teamID, resID, "req-1"); err != nil {
31+
t.Fatalf("unexpected error: %v", err)
32+
}
33+
if err := mock.ExpectationsWereMet(); err != nil {
34+
t.Errorf("unmet expectations: %v", err)
35+
}
36+
})
37+
38+
t.Run("error is surfaced, not swallowed", func(t *testing.T) {
39+
db, mock, err := sqlmock.New()
40+
if err != nil {
41+
t.Fatalf("sqlmock.New: %v", err)
42+
}
43+
defer func() { _ = db.Close() }()
44+
mock.ExpectExec(`UPDATE resources SET team_id`).
45+
WillReturnError(errors.New("deadlock"))
46+
if err := attachClaimedResourceToTeam(context.Background(), db, teamID, resID, "req-1"); err == nil {
47+
t.Fatal("expected the attach error to be RETURNED (pre-fix it was silently swallowed, orphaning the resource)")
48+
}
49+
if err := mock.ExpectationsWereMet(); err != nil {
50+
t.Errorf("unmet expectations: %v", err)
51+
}
52+
})
53+
54+
t.Run("already attached is a no-op (0 rows, no error)", func(t *testing.T) {
55+
db, mock, err := sqlmock.New()
56+
if err != nil {
57+
t.Fatalf("sqlmock.New: %v", err)
58+
}
59+
defer func() { _ = db.Close() }()
60+
// WHERE team_id IS NULL guard → 0 rows when already attached; not an error.
61+
mock.ExpectExec(`UPDATE resources SET team_id`).
62+
WillReturnResult(sqlmock.NewResult(0, 0))
63+
if err := attachClaimedResourceToTeam(context.Background(), db, teamID, resID, "req-1"); err != nil {
64+
t.Fatalf("0-rows (already attached) must not error: %v", err)
65+
}
66+
if err := mock.ExpectationsWereMet(); err != nil {
67+
t.Errorf("unmet expectations: %v", err)
68+
}
69+
})
70+
}

internal/models/deployment_event.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,15 @@ const (
6161
// (10-minute deadline in runDeploy / waitForJobComplete).
6262
FailureReasonDeadlineExceeded = "DeadlineExceeded"
6363

64+
// FailureReasonStartFailed means k8s created the app's pod but the
65+
// container could not start — the runtime "CreateContainerError",
66+
// "CreateContainerConfigError", or "RunContainerError" waiting reasons.
67+
// The modal cause is a built image with no CMD/ENTRYPOINT ("no command
68+
// specified") or an invalid container configuration. Distinct from
69+
// ImagePullBackOff (image unreachable) and CrashLoopBackOff (image runs
70+
// then exits non-zero): here the container is never successfully created.
71+
FailureReasonStartFailed = "StartFailed"
72+
6473
// FailureReasonError covers transient k8s API errors and generic
6574
// "ReplicaFailure" conditions that don't map to a more specific reason.
6675
FailureReasonError = "Error"

0 commit comments

Comments
 (0)