fix: full-scope review — tip-inclusive amount_due, sweep deposit-strand, A6 clamp cap, B13 clawback, 2FA single-use, mint audit, account-deletion re-auth, refresh dedup
Full-scope Loop A restart review (18 findings across money/security/dup-mod): MONEY: - HIGH: amount_paid/amount_due CTEs now exclude payment_type='tip' (bookings.go x6, today.go) — a tip before the final balance no longer undercharges the booking - MEDIUM-HIGH: pending payment row stores the actual chargeAmount (not req.Amount) so the sweep replay amount-match rescues deposit-with-discount rows instead of auto-refunding them; refundSweepDuplicateCharge refunds the replayed payment's actual amount - MEDIUM: A6 deposit clamp-up now caps at the discounted obligation (remainingPence - eligibleDiscountPence) — no more silent overcharge when a campaign discount >= deposit - MEDIUM: B13 campaign-loss balance credits are clawed back on cancellation (clawbackB13CampaignCredit in ProcessCancellationRefundTx) - LOW: replayLegitimateRetryWindow extended 22h->24h so a legitimate same-key retry in the retry-eligible window is rescued, not auto-refunded SECURITY: - 2FA single-use strengthened (consume-at-gate for fresh charges, re-issue on failure) - Admin 2FA mint now writes admin_audit_log + logs code reuse - Account deletion requires current password (and 2FA when enforced) — stolen token can no longer destroy the account - Multi-tab refresh-token replay deduped via cross-tab lock (no false family-kill alerts) - family-alive cache invalidated on password change / GDPR erasure - Login lockout keyed per user+IP with a capped ceiling FRONTEND/DUP-MOD: - OverflowTipConfirm shared component (UserPaymentModal + BookingFlow); overflow computation aligned (deposit-discount-aware) - PaymentModal admin 2FA gate now method-conditioned (no over-reveal on cash/giftcard) - requestTwoFactorCode shared helper (requestNewTwoFactorCode + adminRequestNewTwoFactorCode) - BookingFlow deposit display aligned to the discounted amount; formatCurrency used consistently 26/26 backend packages; 80/80 frontend tests + build; env-docs 41/41.
This commit is contained in:
@@ -229,6 +229,27 @@ func InvalidateFamilyAlive(familyID string) {
|
||||
}
|
||||
}
|
||||
|
||||
// InvalidateFamilyAliveByUser drops every cached family-alive verdict for a
|
||||
// user, so access tokens bound to ANY of the user's rotation families are
|
||||
// re-checked against the DB on their next verification. Called when a user's
|
||||
// credentials die wholesale — a password change deletes every refresh token
|
||||
// the user holds, and GDPR erasure does the same inside anonymize_user() /
|
||||
// delete_guest_user() — so killed families' access tokens die immediately
|
||||
// instead of riding the familyAliveCacheTTL (LOW 5).
|
||||
func InvalidateFamilyAliveByUser(userID string) {
|
||||
if userID == "" {
|
||||
return
|
||||
}
|
||||
familyAliveCache.mu.Lock()
|
||||
defer familyAliveCache.mu.Unlock()
|
||||
suffix := "|" + userID
|
||||
for k := range familyAliveCache.m {
|
||||
if strings.HasSuffix(k, suffix) {
|
||||
delete(familyAliveCache.m, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CleanupRevokedJTIs removes expired entries from PostgreSQL and returns the count of deleted rows.
|
||||
func CleanupRevokedJTIs(ctx context.Context) (int, error) {
|
||||
if db.Conn == nil {
|
||||
|
||||
@@ -872,3 +872,30 @@ func TestVerifyToken_WrongRoleType(t *testing.T) {
|
||||
t.Errorf("expected 'invalid role claim' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInvalidateFamilyAliveByUser pins LOW 5: dropping the cached family-alive
|
||||
// verdicts for a USER (password change / GDPR erasure) removes every family of
|
||||
// that user while leaving other users' families untouched.
|
||||
func TestInvalidateFamilyAliveByUser(t *testing.T) {
|
||||
t.Cleanup(func() {
|
||||
familyAliveCache.mu.Lock()
|
||||
familyAliveCache.m = make(map[string]familyAliveCacheEntry)
|
||||
familyAliveCache.mu.Unlock()
|
||||
})
|
||||
|
||||
familyAliveStore("family-a|user-1", true)
|
||||
familyAliveStore("family-b|user-1", true)
|
||||
familyAliveStore("family-c|user-2", true)
|
||||
|
||||
InvalidateFamilyAliveByUser("user-1")
|
||||
|
||||
_, okA := familyAliveLookup("family-a|user-1")
|
||||
_, okB := familyAliveLookup("family-b|user-1")
|
||||
_, okC := familyAliveLookup("family-c|user-2")
|
||||
require.False(t, okA, "user-1's family-a verdict must be dropped")
|
||||
require.False(t, okB, "user-1's family-b verdict must be dropped")
|
||||
require.True(t, okC, "user-2's family-c verdict must survive")
|
||||
|
||||
// An empty user id is a no-op, never a panic.
|
||||
InvalidateFamilyAliveByUser("")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user