fix: restart-loop-A findings — pending sweep refunds, tip carve on discounts, TOCTOU redemption, single-use 2FA code + mint endpoint, refresh-token family revocation, admin 2FA code UX

Restart of Loop A (fresh review -> fix -> verify) findings from commit 5e967fa:
- B1: sweep auto-refund treats Square PENDING refunds as NON-terminal (row stays pending, no gift-card clawback, refunds row inserted for payments AND till_sales, re-polls the deterministic sweepdup- key); Square-less pre-pass exempts square_refund_id IS NOT NULL rows
- M4: terminal tip carve accounts for pending campaign discounts (headroom = total - pending - paid) so explicit tips aren't absorbed as service revenue; no-tip case stays a single record
- max_redemptions TOCTOU closed with atomic conditional UPDATE ... RETURNING; exhausted-at-apply surfaces campaign_fully_redeemed
- 2FA: verification code is single-use on the saved-card gate (VerifyForUser consume=true, interactive flows unaffected); new POST /api/user/2fa/code mints a fresh code for enabled users (RequireAuth + RequireNonGuest + mint cooldown + per-user limiter)
- Refresh tokens: family_id + used_at columns; reuse of an already-rotated token revokes the ENTIRE family and inserts a refresh_token_reuse admin alert; rotation mints descendants in the same family
- Frontend: 2FA code input + Request-a-new-code on all saved-card surfaces; admin modal keys code input to customer 2FA + 403 self-heal; tip-display note for pending discounts; 76 frontend tests
- Verified: all 26 backend packages pass, frontend build+tests green, env-docs 41/41
This commit is contained in:
2026-08-22 00:34:50 +01:00
parent fe88f2084d
commit 4d5d2cd381
28 changed files with 2047 additions and 341 deletions
+89 -5
View File
@@ -424,7 +424,7 @@ func TestVerifyRefreshToken_Success(t *testing.T) {
}
// First verify should succeed
retUserID, retRole, err := VerifyRefreshToken(ctx, token)
retUserID, retRole, _, err := VerifyRefreshToken(ctx, token)
if err != nil {
t.Fatalf("VerifyRefreshToken() failed: %v", err)
}
@@ -436,7 +436,7 @@ func TestVerifyRefreshToken_Success(t *testing.T) {
}
// Second verify with same token must fail (rotation — token consumed)
_, _, err = VerifyRefreshToken(ctx, token)
_, _, _, err = VerifyRefreshToken(ctx, token)
if err == nil {
t.Fatal("expected error for consumed token, got nil")
}
@@ -461,13 +461,13 @@ func TestVerifyRefreshToken_Rotation(t *testing.T) {
}
// First call should succeed
_, _, err = VerifyRefreshToken(ctx, token)
_, _, _, err = VerifyRefreshToken(ctx, token)
if err != nil {
t.Fatalf("first verification should succeed, got: %v", err)
}
// Second call with the same token must fail
_, _, err = VerifyRefreshToken(ctx, token)
_, _, _, err = VerifyRefreshToken(ctx, token)
if err == nil {
t.Fatal("expected error for rotated token, got nil")
}
@@ -476,12 +476,96 @@ func TestVerifyRefreshToken_Rotation(t *testing.T) {
}
}
// TestVerifyRefreshToken_ReuseRevokesFamilyAndAlerts verifies the reuse
// detection: generate a token → rotate it once (minting a descendant in the
// SAME family via GenerateRefreshTokenInFamily) → present the ORIGINAL token
// again. The replay must (i) delete the ENTIRE rotation family (the descendant
// included) from refresh_tokens and (ii) insert an admin_notifications row with
// reason 'refresh_token_reuse' for the user.
func TestVerifyRefreshToken_ReuseRevokesFamilyAndAlerts(t *testing.T) {
ctx, tx := testtx.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
// 1. Generate a refresh token (new family) and rotate it once.
original, err := GenerateRefreshToken(ctx, userID, "verified_email")
if err != nil {
t.Fatalf("GenerateRefreshToken() failed: %v", err)
}
_, _, familyID, err := VerifyRefreshToken(ctx, original)
if err != nil {
t.Fatalf("first verification should succeed, got: %v", err)
}
if familyID == "" {
t.Fatal("expected non-empty family_id from rotation")
}
// 2. Mint the descendant in the SAME family (as RefreshTokenHandler does).
descendant, err := GenerateRefreshTokenInFamily(ctx, userID, "verified_email", familyID)
if err != nil {
t.Fatalf("GenerateRefreshTokenInFamily() failed: %v", err)
}
var famCount int
if err := tx.QueryRow(ctx,
`SELECT COUNT(*) FROM refresh_tokens WHERE family_id = $1`, familyID).Scan(&famCount); err != nil {
t.Fatalf("failed to count family rows: %v", err)
}
if famCount != 2 {
t.Fatalf("expected 2 refresh tokens in family, got %d", famCount)
}
// 3. Replay the ORIGINAL token — theft.
_, _, _, err = VerifyRefreshToken(ctx, original)
if err == nil {
t.Fatal("expected error for replayed token, got nil")
}
if !strings.Contains(err.Error(), "invalid or expired") {
t.Errorf("expected 'invalid or expired' error, got: %v", err)
}
// (i) The entire family is revoked: the used original AND the descendant.
var famAfter int
if err := tx.QueryRow(ctx,
`SELECT COUNT(*) FROM refresh_tokens WHERE family_id = $1`, familyID).Scan(&famAfter); err != nil {
t.Fatalf("failed to count family rows after replay: %v", err)
}
if famAfter != 0 {
t.Errorf("expected 0 refresh tokens in family after reuse (descendant killed), got %d", famAfter)
}
var descHashCount int
if err := tx.QueryRow(ctx,
`SELECT COUNT(*) FROM refresh_tokens WHERE token_hash = encode(sha256($1::bytea), 'hex')`,
descendant).Scan(&descHashCount); err != nil {
t.Fatalf("failed to check descendant: %v", err)
}
if descHashCount != 0 {
t.Errorf("expected descendant to be deleted, got %d rows", descHashCount)
}
// (ii) An admin alert with reason 'refresh_token_reuse' exists for the user.
var alertCount int
if err := tx.QueryRow(ctx,
`SELECT COUNT(*) FROM admin_notifications WHERE reason = 'refresh_token_reuse' AND user_id = $1`,
userID).Scan(&alertCount); err != nil {
t.Fatalf("failed to query admin_notifications: %v", err)
}
if alertCount != 1 {
t.Errorf("expected 1 'refresh_token_reuse' alert, got %d", alertCount)
}
}
// TestVerifyRefreshToken_InvalidToken calls VerifyRefreshToken with a fake
// token string and expects it to fail with "invalid or expired".
func TestVerifyRefreshToken_InvalidToken(t *testing.T) {
ctx, _ := testtx.SetupTestTx(t)
_, _, err := VerifyRefreshToken(ctx, "this-is-a-completely-fake-token-string")
_, _, _, err := VerifyRefreshToken(ctx, "this-is-a-completely-fake-token-string")
if err == nil {
t.Fatal("expected error for invalid token, got nil")
}