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:
2026-08-22 00:34:50 +01:00
parent b46927336b
commit 9a182db932
27 changed files with 1279 additions and 300 deletions
+97 -18
View File
@@ -45,6 +45,23 @@ import (
// DeleteAccountHandler Coverage Tests
// =============================================================================
// deleteAccountRequest builds the DELETE /api/user/account request the handler
// now requires (finding 3): the current password in the body, plus a 2FA code
// when one is supplied (enforced environments + 2FA-enabled users only).
func deleteAccountRequest(t *testing.T, ctx context.Context, userID, password, code string) *http.Request {
t.Helper()
body := map[string]string{"current_password": password}
if code != "" {
body["verification_code"] = code
}
b, err := json.Marshal(body)
require.NoError(t, err)
req := httptest.NewRequest(http.MethodDelete, "/api/user/account", bytes.NewReader(b))
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(context.WithValue(ctx, mw.UserIDKey, userID))
return req
}
// TestDeleteAccount_Unauthorized verifies that deleting an account without
// setting user ID in context returns 401 Unauthorized.
func TestDeleteAccount_Unauthorized(t *testing.T) {
@@ -92,8 +109,7 @@ func TestDeleteAccount_WithBooking(t *testing.T) {
t.Fatalf("failed to create booking: %v", err)
}
req := httptest.NewRequest(http.MethodDelete, "/api/user/account", nil)
req = req.WithContext(context.WithValue(ctx, mw.UserIDKey, userID))
req := deleteAccountRequest(t, ctx, userID, "testpassword123", "")
rr := httptest.NewRecorder()
DeleteAccountHandler(rr, req)
@@ -135,8 +151,7 @@ func TestDeleteAccount_GuestWithBooking(t *testing.T) {
t.Fatalf("failed to create booking: %v", err)
}
req := httptest.NewRequest(http.MethodDelete, "/api/user/account", nil)
req = req.WithContext(context.WithValue(ctx, mw.UserIDKey, userID))
req := deleteAccountRequest(t, ctx, userID, "testpassword123", "")
rr := httptest.NewRecorder()
DeleteAccountHandler(rr, req)
@@ -155,6 +170,77 @@ func TestDeleteAccount_GuestWithBooking(t *testing.T) {
}
}
// TestDeleteAccount_WrongPassword_Rejected verifies the finding-3 password
// re-verification: deleting an account with the WRONG current password returns
// 401 and the account survives.
func TestDeleteAccount_WrongPassword_Rejected(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
req := deleteAccountRequest(t, ctx, userID, "not-the-password", "")
rr := httptest.NewRecorder()
DeleteAccountHandler(rr, req)
require.Equal(t, http.StatusUnauthorized, rr.Code, rr.Body.String())
var firstName string
require.NoError(t, tx.QueryRow(ctx, `SELECT n_first_name FROM users WHERE id = $1`, userID).Scan(&firstName))
require.Equal(t, "Test", firstName, "the account must not be touched by a failed re-verification")
}
// TestDeleteAccount_MissingPasswordBody_Rejected verifies that a DELETE with no
// password body (the pre-finding-3 client contract) is rejected as malformed.
func TestDeleteAccount_MissingPasswordBody_Rejected(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
req := httptest.NewRequest(http.MethodDelete, "/api/user/account", nil)
req = req.WithContext(context.WithValue(ctx, mw.UserIDKey, userID))
rr := httptest.NewRecorder()
DeleteAccountHandler(rr, req)
require.Equal(t, http.StatusBadRequest, rr.Code, rr.Body.String())
}
// TestDeleteAccount_Enforced2FA_RequiresCode verifies the finding-3 2FA
// re-verification: in an enforced environment, a 2FA-enabled user must present
// a correct one-time code (and their password) — a wrong code is rejected with
// 400 and the account survives; the correct code deletes it.
func TestDeleteAccount_Enforced2FA_RequiresCode(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
_, err = tx.Exec(ctx, `UPDATE users SET two_factor_enabled = true, two_factor_method = 'email' WHERE id = $1`, userID)
require.NoError(t, err)
// Enforced by default in tests (SQUARE_ENVIRONMENT unset) + 2FA enabled →
// the handler demands a code. A wrong code must 400.
seedPendingTwoFA(t, ctx, tx, userID, "424242")
req := deleteAccountRequest(t, ctx, userID, "testpassword123", "000000")
rr := httptest.NewRecorder()
DeleteAccountHandler(rr, req)
require.Equal(t, http.StatusBadRequest, rr.Code, "wrong 2FA code must be rejected before deletion")
require.Contains(t, rr.Body.String(), "incorrect verification code")
var firstName string
require.NoError(t, tx.QueryRow(ctx, `SELECT n_first_name FROM users WHERE id = $1`, userID).Scan(&firstName))
require.Equal(t, "Test", firstName, "the account must survive a wrong 2FA code")
// A missing code must also be rejected.
req = deleteAccountRequest(t, ctx, userID, "testpassword123", "")
rr = httptest.NewRecorder()
DeleteAccountHandler(rr, req)
require.Equal(t, http.StatusBadRequest, rr.Code, "a missing 2FA code must be rejected in an enforced environment")
// The correct code (password + code) deletes the account.
req = deleteAccountRequest(t, ctx, userID, "testpassword123", "424242")
rr = httptest.NewRecorder()
DeleteAccountHandler(rr, req)
require.Equal(t, http.StatusNoContent, rr.Code, rr.Body.String())
}
// =============================================================================
// ChangePasswordHandler Coverage Tests
// =============================================================================
@@ -566,8 +652,7 @@ func TestDeleteAccount_WithProfilePicture(t *testing.T) {
require.NoError(t, err)
handler := http.HandlerFunc(DeleteAccountHandler)
req := httptest.NewRequest("DELETE", "/api/user/account", nil)
req = req.WithContext(context.WithValue(ctx, mw.UserIDKey, userID))
req := deleteAccountRequest(t, ctx, userID, "testpassword123", "")
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
@@ -594,8 +679,7 @@ func TestDeleteAccount_WithSquareClient(t *testing.T) {
require.NoError(t, err)
handler := http.HandlerFunc(DeleteAccountHandler)
req := httptest.NewRequest("DELETE", "/api/user/account", nil)
req = req.WithContext(context.WithValue(ctx, mw.UserIDKey, userID))
req := deleteAccountRequest(t, ctx, userID, "testpassword123", "")
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
@@ -689,8 +773,7 @@ func TestDeleteAccount_LogsRedactCardTokens(t *testing.T) {
require.NoError(t, err)
handler := http.HandlerFunc(DeleteAccountHandler)
req := httptest.NewRequest(http.MethodDelete, "/api/user/account", nil)
req = req.WithContext(context.WithValue(ctx, mw.UserIDKey, userID))
req := deleteAccountRequest(t, ctx, userID, "testpassword123", "")
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
require.Equal(t, http.StatusNoContent, w.Code)
@@ -734,8 +817,7 @@ func TestDeleteAccount_DeletesSquareCustomerOnce(t *testing.T) {
}
handler := http.HandlerFunc(DeleteAccountHandler)
req := httptest.NewRequest(http.MethodDelete, "/api/user/account", nil)
req = req.WithContext(context.WithValue(ctx, mw.UserIDKey, userID))
req := deleteAccountRequest(t, ctx, userID, "testpassword123", "")
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
require.Equal(t, http.StatusNoContent, w.Code)
@@ -813,8 +895,7 @@ func TestDeleteAccount_SquareCleanupNotDispatchedOnTxFailure(t *testing.T) {
reqCtx := db.ContextWithTx(context.Background(), &failingTx{Tx: pgxTx, failExec: true})
handler := http.HandlerFunc(DeleteAccountHandler)
req := httptest.NewRequest(http.MethodDelete, "/api/user/account", nil)
req = req.WithContext(context.WithValue(reqCtx, mw.UserIDKey, guestID))
req := deleteAccountRequest(t, reqCtx, guestID, "testpassword123", "")
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
@@ -875,8 +956,7 @@ func TestDeleteAccount_SkipsSharedSquareCustomer(t *testing.T) {
})
handler := http.HandlerFunc(DeleteAccountHandler)
req := httptest.NewRequest(http.MethodDelete, "/api/user/account", nil)
req = req.WithContext(context.WithValue(context.Background(), mw.UserIDKey, userA))
req := deleteAccountRequest(t, context.Background(), userA, "testpassword123", "")
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
require.Equal(t, http.StatusNoContent, w.Code)
@@ -938,8 +1018,7 @@ func TestDeleteAccount_InvalidatesSquareCustomerCache(t *testing.T) {
require.NotEmpty(t, originalID, "a Square customer must be provisioned and cached for the save-card user")
handler := http.HandlerFunc(DeleteAccountHandler)
req := httptest.NewRequest(http.MethodDelete, "/api/user/account", nil)
req = req.WithContext(context.WithValue(ctx, mw.UserIDKey, userID))
req := deleteAccountRequest(t, ctx, userID, "testpassword123", "")
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
require.Equal(t, http.StatusNoContent, w.Code)