fix: review round — B1 clock-skew tolerance + re-poll escalation, refresh-token access-token revocation, shared 2FA composable, per-package-DB test alignment

Three fresh reviews (money/security/dup-mod) cross-validated findings:
- MEDIUM: B1 'new charge' discrimination adds a lower-bound tolerance (replayRescueLowerBoundSkew) so a retained-key replay of the ORIGINAL charge (DB clock ahead of Square) is never auto-refunded; ambiguous margins leave PENDING + CRITICAL
- MEDIUM: B1 re-poll escalates after stalePendingB1RefundAge (48h) — FAILED/REJECTED refunds go terminal (fail parent, claw back till-sale funding, CRITICAL notification); no more unbounded re-polling / stranded parents without webhooks
- DRIFT-REAL: processManualPaymentGroup now checks PENDING/FAILED/REJECTED on the synchronous refund response (mirrors processChargeGroup/manual handler) — no more premature 'completed'
- HIGH: refresh-token family kill now also invalidates the attacker's freshly-minted ACCESS token — access tokens carry a family_id claim and VerifyToken rejects tokens whose family was deleted (GenerateTokenForFamily + family-alive check); 30s grace window for concurrent two-tab refresh (no false theft alert)
- LOW: 2FA mint endpoint returns remaining_seconds; in-memory 2FA counters documented; 90-day refresh expiry single-sourced (RefreshTokenLifetime + make_interval)
- Dup/mod: NEW shared useTwoFactorCodeForSavedCard Svelte composable replaces 6 surface copies of the 2FA gate logic (Request-a-new-code added to BookingFlow + TillPurchases); account page adopts generateUUID
- Test architecture: removed t.Parallel() from 8 global-SquareClient-swapping tests per Testing Architecture doc line 89 (B1 flaky-test lesson) — fixes within-package race
- SQL alias pence rename (total_cents/paid_cents -> total_pence/paid_pence)

26/26 backend packages; 72/72 frontend tests + build; env-docs 41/41.
This commit is contained in:
2026-08-22 00:34:50 +01:00
parent a8bf24ee23
commit a6a4683b74
24 changed files with 1338 additions and 384 deletions
+151 -4
View File
@@ -378,7 +378,7 @@ func TestGenerateRefreshToken_Success(t *testing.T) {
t.Fatalf("failed to create test user: %v", err)
}
token, err := GenerateRefreshToken(ctx, userID, "verified_email")
token, _, err := GenerateRefreshToken(ctx, userID, "verified_email")
if err != nil {
t.Fatalf("GenerateRefreshToken() failed: %v", err)
}
@@ -418,7 +418,7 @@ func TestVerifyRefreshToken_Success(t *testing.T) {
t.Fatalf("failed to create test user: %v", err)
}
token, err := GenerateRefreshToken(ctx, userID, "verified_email")
token, _, err := GenerateRefreshToken(ctx, userID, "verified_email")
if err != nil {
t.Fatalf("GenerateRefreshToken() failed: %v", err)
}
@@ -455,7 +455,7 @@ func TestVerifyRefreshToken_Rotation(t *testing.T) {
t.Fatalf("failed to create test user: %v", err)
}
token, err := GenerateRefreshToken(ctx, userID, "verified_email")
token, _, err := GenerateRefreshToken(ctx, userID, "verified_email")
if err != nil {
t.Fatalf("GenerateRefreshToken() failed: %v", err)
}
@@ -491,7 +491,7 @@ func TestVerifyRefreshToken_ReuseRevokesFamilyAndAlerts(t *testing.T) {
}
// 1. Generate a refresh token (new family) and rotate it once.
original, err := GenerateRefreshToken(ctx, userID, "verified_email")
original, _, err := GenerateRefreshToken(ctx, userID, "verified_email")
if err != nil {
t.Fatalf("GenerateRefreshToken() failed: %v", err)
}
@@ -519,6 +519,18 @@ func TestVerifyRefreshToken_ReuseRevokesFamilyAndAlerts(t *testing.T) {
t.Fatalf("expected 2 refresh tokens in family, got %d", famCount)
}
// MEDIUM 4 grace window: backdate the original's used_at past the 30s
// reuse grace so the replay below is genuine theft. WITHOUT this, a replay
// moments after rotation is a benign two-tab concurrent refresh and the
// family must NOT be killed.
if _, err := tx.Exec(ctx, `
UPDATE refresh_tokens
SET used_at = NOW() - make_interval(secs => 60)
WHERE token_hash = encode(sha256($1::bytea), 'hex')
`, original); err != nil {
t.Fatalf("failed to backdate used_at for reuse test: %v", err)
}
// 3. Replay the ORIGINAL token — theft.
_, _, _, err = VerifyRefreshToken(ctx, original)
if err == nil {
@@ -560,6 +572,141 @@ func TestVerifyRefreshToken_ReuseRevokesFamilyAndAlerts(t *testing.T) {
}
}
// TestVerifyRefreshToken_ReplayWithinGrace_IsBenign verifies the MEDIUM 4
// hardening: a used-token replay WITHIN the 30s grace window (two tabs sharing
// one localStorage refresh token both refreshing on load) is a benign
// concurrent refresh — the generic error is returned, but the rotation family
// survives and no refresh_token_reuse alert is raised.
func TestVerifyRefreshToken_ReplayWithinGrace_IsBenign(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)
}
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")
}
// Mint the descendant in the same family (as RefreshTokenHandler does).
if _, err := GenerateRefreshTokenInFamily(ctx, userID, "verified_email", familyID); err != nil {
t.Fatalf("GenerateRefreshTokenInFamily() failed: %v", err)
}
// Replay the original IMMEDIATELY — inside the grace window → benign.
_, _, _, err = VerifyRefreshToken(ctx, original)
if err == nil {
t.Fatal("expected error for within-grace replay, got nil")
}
if !strings.Contains(err.Error(), "invalid or expired") {
t.Errorf("expected 'invalid or expired' error, got: %v", err)
}
// The family survives: used original + descendant both still present.
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.Errorf("expected 2 refresh tokens in family after within-grace replay, got %d", famCount)
}
// No theft alert.
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 != 0 {
t.Errorf("expected 0 'refresh_token_reuse' alerts for a within-grace replay, got %d", alertCount)
}
}
// TestAccessTokenKilledWithRotationFamily verifies the HIGH 1 fix: an access
// token minted at rotation is bound (family_id claim) to the rotation family,
// so when reuse detection DELETEs the family the access token — which the
// attacker was handed at rotation — stops verifying immediately instead of
// staying valid for its 1-hour TTL.
func TestAccessTokenKilledWithRotationFamily(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)
}
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)
}
// The rotation response: a descendant refresh token in the same family and
// an access token minted in that SAME family (as RefreshTokenHandler does).
if _, err := GenerateRefreshTokenInFamily(ctx, userID, "verified_email", familyID); err != nil {
t.Fatalf("GenerateRefreshTokenInFamily() failed: %v", err)
}
attackerToken, attackerJTI, err := GenerateTokenForFamily(userID, "verified_email", familyID)
if err != nil {
t.Fatalf("GenerateTokenForFamily() failed: %v", err)
}
if attackerJTI == "" {
t.Fatal("expected non-empty JTI")
}
// While its family is alive the access token verifies (JTI not revoked).
if _, _, _, err := VerifyToken(attackerToken, ctx); err != nil {
t.Fatalf("access token must verify while its rotation family is alive: %v", err)
}
// Backdate used_at past the grace window, then replay the original — theft
// detected, whole family DELETEd.
if _, err := tx.Exec(ctx, `
UPDATE refresh_tokens
SET used_at = NOW() - make_interval(secs => 60)
WHERE token_hash = encode(sha256($1::bytea), 'hex')
`, original); err != nil {
t.Fatalf("failed to backdate used_at: %v", err)
}
_, _, _, err = VerifyRefreshToken(ctx, original)
if err == nil {
t.Fatal("expected theft detection on replayed token, got nil")
}
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 != 0 {
t.Fatalf("expected 0 refresh tokens in family after reuse, got %d", famCount)
}
// The attacker's access token is now dead even though its JTI was never
// revoked — the family-alive check rejects it (HIGH 1).
if _, _, _, err := VerifyToken(attackerToken, ctx); err == nil {
t.Fatal("access token must be rejected once its rotation family is killed")
} else if !strings.Contains(err.Error(), "token revoked") {
t.Fatalf("expected 'token revoked' error, got: %v", err)
}
}
// TestVerifyRefreshToken_InvalidToken calls VerifyRefreshToken with a fake
// token string and expects it to fail with "invalid or expired".
func TestVerifyRefreshToken_InvalidToken(t *testing.T) {