fix: SCA review round + gitea pipeline green — GDPR audit scrub, backend test gaps, frontend SCA/Square-API, docs parity
7 review agents (pipeline run, self-review, codebase-context, frontend-placement, backend testing-gaps, Square-API, docs-parity) audited the SCA-primary work. ALL findings fixed, including every pre-existing red CI job: GDPR (HIGH): - anonymize_user() now scrubs admin_audit_log.target_user_id (mirrors delete_guest_user) so 2fa_fallback_charge rows (customer id + card_last4 PII) no longer survive registered-user account deletion; gdpr test added BACKEND TEST GAPS (all 10): - delivery-unavailable 503 branch: prod-tag predicate test + dev-variant marker - twoFactorFallbackEnabled alias/case/default matrix tests + exported wrapper - insertTwoFAFallbackAudit details-JSON shape + audit-row assertions for all 6 gate sites (booking/tip/gift-card/payment-method/terminal/till, both actors) - CreateTerminalPayment.VerificationToken: passthrough, too-long 400, 2FA-skip, token-less fallback + SCA-required (new terminal_sca_test.go) - isVerificationRequiredError at all 5 charge sites (402 + code:verification_required) - customer_initiated handler-level assertions (MIT false admin / CIT true customer) - Mock: ApprovePendingVerification, ChallengeResult auto/deny, _deny token suffix, parseVerifyToken unit tests FRONTEND SCA + Square-API (CRITICAL): - tokenizeSavedCardWithVerification reads result.token (the verified token) not result.verificationResult (deprecated verifyBuyer shape — saved-card SCA could never succeed in production before); parseTokenizeVerificationResult pure fn extracted + pinned in square.test.ts; 'verified' with no token proceeds tokenless - HIGH: saved-card idempotency key regenerated after a definitive 402 (fresh token under the same key = IDEMPOTENCY_KEY_REUSED dead-loop); kept on 503/cancelled - challenge-cancelled copy no longer promises a 2FA fallback the UI doesn't show; 'waiting for approval in your banking app' state on CIT surfaces - sca-unavailable demotion resets per attempt; card selection disabled mid-challenge; genuine saved-card declines no longer relabeled 'requires verification'; modal-close guard during processing; retry affordance standardized PIPELINE (every red job now green): - prod-tag build break fixed (shared square stub + test_helpers_test.go, prod-safe) - govulncheck: x/image 0.45.0 bumped (x/text resolved); go mod tidy clean - race: TestDeleteAccount_InvalidatesSquareCustomerCache made deterministic - DAV_ADMIN_PASSWORD placeholder in .env.example (compose config passes) - frontend: prettier 28 files, eslint, a11y 38 errors, knip (currentZIndex), deps in-range, audit vulns (nanoid/postcss) — all fixed; 67 vitest cases DOCS PARITY (6 DRIFTs + 5 GAPs): payments doc Ch4/Ch14/Appendix A, Technical Manual 2FA + counter-reset + payment sections, README test counts + SNAPSHOT_ENC_KEY, Feature Catalog, .env.example REQUIRE_2FA — SCA-primary/2FA-backup posture verified against code everywhere Verified: 26/26 dev + 24/24 prod packages, both vet tags, golangci-lint/staticcheck/ gosec 0 on both tags, gitleaks clean, 2,464 backend + 67 frontend tests.
This commit is contained in:
@@ -180,12 +180,82 @@ export function shouldFallbackTo2FA(scaOutcome: string): boolean {
|
||||
return scaOutcome === 'sca-unavailable';
|
||||
}
|
||||
|
||||
/** Outcome of a saved-card SCA challenge, used by the payment surfaces to
|
||||
* decide whether to retry with the fresh verification token, surface a
|
||||
* retryable failure, or fall back to the 2FA gate. */
|
||||
export type SavedCardVerificationOutcome =
|
||||
'verified' | 'challenge-cancelled' | 'sca-unavailable' | 'sca-failed';
|
||||
|
||||
/** Result of tokenizeSavedCardWithVerification. */
|
||||
export interface SavedCardVerificationResult {
|
||||
verificationToken: string | null;
|
||||
outcome: SavedCardVerificationOutcome;
|
||||
}
|
||||
|
||||
/** Square Web Payments `card.tokenize()` result shape. Per the CURRENT SDK
|
||||
* (Square.js /v1/), tokenize returns `{ status, token, details?, errors }` —
|
||||
* the SCA-verified token for both the new-card and the card-on-file
|
||||
* (`card.tokenize(verificationDetails, cardId)`) flows comes back in the SAME
|
||||
* `token` field. There is NO nested `verificationResult` — that only exists on
|
||||
* the deprecated `payments.verifyBuyer()` flow, which Square is retiring (see
|
||||
* developer.squareup.com/docs/web-payments/take-card-payment → "Migrate from
|
||||
* Payments.verifyBuyer()"). */
|
||||
export interface SquareTokenizeResult {
|
||||
status: string;
|
||||
token?: string;
|
||||
errors?: Array<{ message?: string; code?: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a Square `card.tokenize()` result to the saved-card SCA outcome.
|
||||
*
|
||||
* - `status === 'OK'` means buyer verification either completed or was NOT
|
||||
* required by the issuer — the charge may proceed. The verification-aware
|
||||
* token (when present) is the `token` field; a tokenless OK means no SCA was
|
||||
* demanded, so the charge proceeds token-less (the backend 2FA gate / Square
|
||||
* risk rules are the fallback), never a dead-end.
|
||||
* - `VERIFICATION_CHALLENGE` / cancel-coded errors mean the challenge was
|
||||
* shown but not completed — the buyer can retry, so this is retryable.
|
||||
* - `CARD_DECLINED_VERIFICATION_REQUIRED` means no challenge could run — SCA
|
||||
* is unavailable and the surface falls back to the 2FA gate.
|
||||
* - anything else is a hard SCA failure.
|
||||
*/
|
||||
export function parseTokenizeVerificationResult(
|
||||
result: SquareTokenizeResult
|
||||
): SavedCardVerificationResult {
|
||||
if (result.status === 'OK') {
|
||||
return { verificationToken: result.token ?? null, outcome: 'verified' };
|
||||
}
|
||||
const codes = (result.errors ?? []).map((e) => e.code ?? '').filter(Boolean);
|
||||
const errorText =
|
||||
codes.join(' ') + ' ' + (result.errors ?? []).map((e) => e.message ?? '').join(' ');
|
||||
if (result.status === 'VERIFICATION_CHALLENGE' || /cancel/i.test(errorText)) {
|
||||
return { verificationToken: null, outcome: 'challenge-cancelled' };
|
||||
}
|
||||
if (codes.includes('CARD_DECLINED_VERIFICATION_REQUIRED')) {
|
||||
return { verificationToken: null, outcome: 'sca-unavailable' };
|
||||
}
|
||||
return { verificationToken: null, outcome: 'sca-failed' };
|
||||
}
|
||||
|
||||
/** User-facing guidance for a saved-card charge whose issuer requires Strong
|
||||
* Customer Authentication: the buyer must approve the payment in their banking
|
||||
* app (the client-side tokenizeSavedCardWithVerification challenge does this). */
|
||||
export const VERIFICATION_REQUIRED_MESSAGE =
|
||||
'Your card issuer requires verification. Approve this payment in your banking app.';
|
||||
|
||||
/** User-facing message for a saved-card SCA challenge that was cancelled or did
|
||||
* not complete. Retryable via SCA — deliberately does NOT promise the 2FA code
|
||||
* input, which the customer surfaces only surface on 'sca-unavailable'. */
|
||||
export const CARD_VERIFICATION_RETRY_MESSAGE =
|
||||
"Card verification was cancelled or didn't complete. Please try again.";
|
||||
|
||||
/** User-facing guidance appended to VERIFICATION_REQUIRED_MESSAGE when the
|
||||
* issuer's SCA challenge genuinely cannot run — the 2FA code input is the
|
||||
* only available authorisation and is surfaced as the fallback gate. */
|
||||
export const SCA_UNAVAILABLE_2FA_FALLBACK_MESSAGE =
|
||||
"In-app approval isn't available for this card — enter the verification code instead.";
|
||||
|
||||
/** User-facing guidance for a saved-card charge the issuer requires
|
||||
* verification to complete. Retrying the same saved card is pointless — the
|
||||
* buyer must pay with a new card or re-add their card. */
|
||||
@@ -264,7 +334,9 @@ export async function requestNewTwoFactorCode(): Promise<TwoFactorCodeRequestRes
|
||||
* the CUSTOMER's userID, so the code is delivered to the customer and can
|
||||
* satisfy the card-owner gate — the admin's session never receives or
|
||||
* authenticates the customer's card. */
|
||||
export async function adminRequestNewTwoFactorCode(userID: string): Promise<TwoFactorCodeRequestResult> {
|
||||
export async function adminRequestNewTwoFactorCode(
|
||||
userID: string
|
||||
): Promise<TwoFactorCodeRequestResult> {
|
||||
return requestTwoFactorCode(`/api/admin/users/${encodeURIComponent(userID)}/2fa/code`);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user