fix: round-3 — tip gate asymmetry, webhook VAT align + 503 notifications, cash-tip campaign overcharge, lockout DoS, erasure durability, S3 retry cap, env parsing, per-user rate limiters, consume dead code, frontend 2FA remnants

- tip gate: CreateTipPayment saved-card 2FA gate now has scaTokenizedSavedCard skip matching every other charge surface (booking, terminal, gift-card); isSCATokenizeResultShape escape added to tip SAVE gate
- webhook: align UPDATE clears VAT fields before re-apply (matches sweep rescue); 503 unknown-event tracking with 24h timeout notification via square_webhook_events table
- cash-tip: cashChargeBasePence no longer restores campaign or subtracts loyalty — overcharge and tip shortfall fixed; 2FA dead code remnants removed from gift-card buy flow; TwoFactorCodeInput help text deconfused; refund pre-fill unit mismatch fixed (pounds vs pence); SCA buyer names split from full_name; passwordless delete UI accepts empty password
- lockout: successful current-password clears shared failed_attempts/locked_until (victim can recover from login lockout via password change); passwordless delete condition changed to require 2FA only in enforced env
- erasure: stale-guest batch erasure persists Square card/customer targets to durable outbox before NULLing them (crash-safe); S3 deletion retry capped at 10 attempts with admin notification; S3_PROFILE_PICS_BUCKET startup check added
- env parsing: IsExplicitDevOrMockEnv and Square HTTP client base-URL switch now normalize (ToLower+TrimSpace) for consistency
- auth: change-password/delete-account get per-user rate limiters (10/min); consume param dead code suppressed with TODO
- frontend: 2FA/SCA dead code removed from gift-card buy flow, TwoFactorCodeInput help text fixed, refund pre-fill unit mismatch fixed, buyer names populated from full_name

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
This commit is contained in:
2026-08-22 00:34:51 +01:00
co-authored by Sisyphus
parent fba00a10ad
commit 9a12a2d886
27 changed files with 1206 additions and 226 deletions
@@ -404,10 +404,84 @@ func TestBuyGiftCard_ForeignIdempotencyKey_NotReused(t *testing.T) {
}
// =============================================================================
// H4 — a COMPLETED provisional terminal checkout must be recorded, not just
// released
// Fix 1 — tip 2FA gate asymmetry: saved-card tip with SCA tokenize-result
// must skip the gate in an enforced deployment
// =============================================================================
// TestCreateTipPayment_EnforcedSavedCard_SCATokenizeResult_Succeeds locks the
// Fix 1 gate skip: a saved-card tip carrying an SCA tokenize-result token
// (new_card_token alongside card_id) must skip the 2FA gate and complete,
// matching the CreateBookingPayment scaTokenizedSavedCard pattern. Without the
// fix, the tip path gates on card_id alone and refuses 402
// verification_required because the legacy verification_token field is empty.
func TestCreateTipPayment_EnforcedSavedCard_SCATokenizeResult_Succeeds(t *testing.T) {
helperEnvEnforce2FAStaging(t)
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupTestDataPast(t, ctx, tx)
userToken := jwt.GenerateUserToken(userID)
// A completed payment is required before a tip can be added.
_, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "online_square", "full", "completed")
require.NoError(t, err)
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:mock_tip_sca_ok", "VISA", "4242")
require.NoError(t, err)
origClient := SquareClient
mc := square.NewDevClient().(*square.MockClient)
mc.SimulateSavedCardVerificationRequired = true
SquareClient = mc
defer func() { SquareClient = origClient }()
scaToken := "cnon:sca-4242_500_ok"
req := CreateTipPaymentRequest{
Amount: 500,
CardID: &cardID,
NewCardToken: &scaToken,
IdempotencyKey: "enforced-tip-scatokenized",
}
w := makePaymentRequest(withNonGuest(CreateTipPayment), "POST", "/api/bookings/"+bookingID+"/tip", req, userToken, ctx)
require.Equal(t, http.StatusOK, w.Code, "an SCA tokenize-result tip must skip the enforced gate and complete, body: %s", w.Body.String())
var payCount int
require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND status = 'completed' AND payment_type = 'tip'`, bookingID).Scan(&payCount))
require.Equal(t, 1, payCount, "the SCA-tokenized tip must record exactly one completed tip payment")
}
// TestCreateTipPayment_EnforcedSavedCard_SCATokenizeResult_SaveCard_Succeeds
// locks the Fix 1 SAVE gate skip: a save-card tip carrying an SCA tokenize-result
// token must skip the SAVE gate and persist the card, matching the
// CreateBookingPayment isSCATokenizeResultShape pattern.
func TestCreateTipPayment_EnforcedSavedCard_SCATokenizeResult_SaveCard_Succeeds(t *testing.T) {
helperEnvEnforce2FAStaging(t)
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupTestDataPast(t, ctx, tx)
userToken := jwt.GenerateUserToken(userID)
_, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "online_square", "full", "completed")
require.NoError(t, err)
// A cnon:sca-... token with save_card=true and no card_id is the
// NEW-card SCA tokenize-result save shape — isSCATokenizeResultShape
// must recognise it and skip the SAVE gate. Use the regular mock
// (no SimulateSavedCardVerificationRequired) so the ccof charge from
// CreateCardOnFile succeeds.
scaToken := "cnon:sca-round9-save-tip"
req := CreateTipPaymentRequest{
Amount: 500,
NewCardToken: &scaToken,
SaveCard: true,
IdempotencyKey: "enforced-tip-scasave",
}
w := makePaymentRequest(withNonGuest(CreateTipPayment), "POST", "/api/bookings/"+bookingID+"/tip", req, userToken, ctx)
require.Equal(t, http.StatusOK, w.Code, "an SCA tokenize-result tip with save_card=true must skip the enforced SAVE gate and complete, body: %s", w.Body.String())
var payCount int
require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND status = 'completed' AND payment_type = 'tip'`, bookingID).Scan(&payCount))
require.Equal(t, 1, payCount, "the SCA-tokenized save-card tip must record exactly one completed tip payment")
}
// TestActiveTerminalCheckoutID_ProvisionalCompleted_RecordsPayment locks the
// H4 fix: when activeTerminalCheckoutID discovers a provisional (tmp-)
// checkout COMPLETED at Square, it must RECORD the payment (mirroring the