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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user