fix: review-loop B — adversarial findings (sweep auto-refund, admin clamp, 2FA real challenge, opaque refresh tokens, gated client IP, GBP pence)

Loop B aggressive adversarial round (3 attack agents) + fix + secondary + verification:
- CRITICAL: sweep replay auto-refunds provably-created-later duplicate charges (gated on parseable CreatedAt); 22h legitimate-retry window == 22h sweep cutoff (no dead zone)
- HIGH: admin Take Payment clamps to remaining obligation (cash/giftcard/saved-card/terminal); no unintended tip from overflow; campaign credit against remaining
- HIGH: /api/services/eligible-for/{id} requires auth + owner-or-admin (DOB/age + patch-test health-data leak closed)
- HIGH: opaque refresh-token rotation (login/refresh return {token, jti, refreshToken}; refresh REQUIRES opaque token; single-use rotation; logout revokes; access token rejected at refresh)
- HIGH: saved-card charges require a REAL 2FA verification code (B6/B10) — backend gate on all 8 charge paths + shared TwoFactorCodeInput frontend component on all 7 surfaces; 2FA gate is no longer setup-flag-only
- MEDIUM: ungated CF-Connecting-IP in reserve/admin_reserve gated via exported mw.ClientIP; 2FA limiter keyed on userID alone (no header-rotation bypass); ChangePassword actually revokes JTI + refresh tokens; 2FA setup mint cooldown + persistent failed-attempt counter; campaign redemption race surfaces campaign_fully_redeemed
- Terminal saved-card VAT applied (was under-collected); age-guard reconcile failures notify; isWeakJWTSecret entropy gate; gift-card redeem per-card counter + per-user limiter; webhook signature key startup validation
- NEW internal/twofa package (single source of truth breaking the payments<->user import cycle); consolidation of duplicate 2FA hash/verify
- Frontend: refresh-token storage + rotation, TwoFactorCodeInput component, amountPaidPence in admin modal, B5/B6/B10 contract wiring; 70 frontend tests
- Tests: loop_b_fixes_test.go, internal/twofa tests, updated auth/services/profile/twofa/mw tests

All 26 backend packages pass (incl. internal/twofa); frontend 70/70 + build clean; env-docs 41/41.
This commit is contained in:
2026-08-22 00:34:50 +01:00
parent faceb9809c
commit fe88f2084d
55 changed files with 4180 additions and 971 deletions
@@ -886,6 +886,216 @@ func TestWebhook_PaymentUpdated_IdempotentReplay(t *testing.T) {
}
}
// =============================================================================
// B1-support — orphaned sweep-minted duplicate charge (payment.updated with no
// local row that resolves to a pending origin row)
// =============================================================================
// createWebhookTestPendingOrigin inserts a pending payments row that carries an
// idempotency key but NO square_payment_id — exactly the population the keyed
// stale-pending sweep replays — and returns its local id.
func createWebhookTestPendingOrigin(t *testing.T, idempotencyKey string) string {
t.Helper()
var id string
err := db.Conn.QueryRow(context.Background(), `
INSERT INTO payments (payment_type, payment_method, status, amount, idempotency_key, created_at, updated_at)
VALUES ('full', 'online_square', 'pending', 10.00, $1, NOW(), NOW())
RETURNING id
`, idempotencyKey).Scan(&id)
if err != nil {
t.Fatalf("failed to create pending origin payment: %v", err)
}
return id
}
func countOrphanReplayNotifications(t *testing.T, originID string) int {
t.Helper()
var n int
if err := db.Conn.QueryRow(context.Background(),
`SELECT COUNT(*) FROM admin_notifications WHERE id = $1 AND reason = 'critical_payment_log'`,
orphanReplayChargeNotificationID(originID)).Scan(&n); err != nil {
t.Fatalf("failed to count orphan-replay notifications: %v", err)
}
return n
}
// TestWebhook_PaymentUpdated_OrphanedReplay_MarksOriginFailed locks the
// B1-support webhook half: a COMPLETED payment.updated for a charge whose
// square_payment_id matches no local row — the sweep-minted duplicate — finds
// its pending origin row by the replayed idempotency key, marks it failed (not
// rescued), and raises exactly one deduped admin notification.
func TestWebhook_PaymentUpdated_OrphanedReplay_MarksOriginFailed(t *testing.T) {
const (
orphanSquareID = "sqp_orphan_c2"
idemKey = "b1-orphan-key-001"
)
originID := createWebhookTestPendingOrigin(t, idemKey)
event := SquareWebhookEvent{
Type: "payment.updated",
EventID: "evt_orphan_c2_1",
CreatedAt: "2025-01-01T00:00:00Z",
Data: json.RawMessage(`{
"type": "payment",
"id": "` + orphanSquareID + `",
"object": {
"payment": {
"id": "` + orphanSquareID + `",
"status": "COMPLETED",
"idempotency_key": "` + idemKey + `",
"amount_money": {"amount": 1000, "currency": "GBP"}
}
}
}`),
}
w := deliverWebhook(t, event)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
// The origin row must be settled to 'failed' — NOT rescued to 'completed'
// (which would hide the duplicate behind the original charge).
if got := getPaymentStatus(t, originID); got != "failed" {
t.Errorf("expected origin pending payment 'failed', got %q", got)
}
if n := countOrphanReplayNotifications(t, originID); n != 1 {
t.Errorf("expected exactly 1 orphan-replay notification, got %d", n)
}
// Re-delivery under a FRESH event_id (bypassing the handler's event_id
// dedup) must not add a second notification — the deterministic per-origin
// id dedups (and, by extension, the sweep's auto-refund cannot be
// double-triggered by this path).
event.EventID = "evt_orphan_c2_2"
w2 := deliverWebhook(t, event)
if w2.Code != http.StatusOK {
t.Fatalf("expected 200 on re-delivery, got %d: %s", w2.Code, w2.Body.String())
}
if n := countOrphanReplayNotifications(t, originID); n != 1 {
t.Errorf("expected notification count to stay 1 after re-delivery, got %d", n)
}
}
// TestWebhook_PaymentUpdated_OrphanedReplay_NoOrigin_Noop verifies the orphan
// detection is a no-op when no pending origin row matches: the event is
// acknowledged 200 without touching any row or raising a notification.
func TestWebhook_PaymentUpdated_OrphanedReplay_NoOrigin_Noop(t *testing.T) {
const orphanSquareID = "sqp_orphan_noorigin"
before := countCriticalNotifications(t)
event := SquareWebhookEvent{
Type: "payment.updated",
EventID: "evt_orphan_noorigin_1",
CreatedAt: "2025-01-01T00:00:00Z",
Data: json.RawMessage(`{
"type": "payment",
"id": "` + orphanSquareID + `",
"object": {
"payment": {
"id": "` + orphanSquareID + `",
"status": "COMPLETED",
"idempotency_key": "b1-orphan-key-never-used",
"amount_money": {"amount": 1000, "currency": "GBP"}
}
}
}`),
}
w := deliverWebhook(t, event)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
if n := countCriticalNotifications(t) - before; n != 0 {
t.Errorf("expected no new critical notification with no origin match, got %d", n)
}
if got := countWebhookEvents(t, event.EventID); got != 1 {
t.Errorf("expected 1 dedup row, got %d", got)
}
}
// TestWebhook_PaymentUpdated_OrphanedReplay_ReferenceFallback locks the
// reference_id fallback of the origin lookup: a COMPLETED orphan event whose
// payload carries no idempotency key still finds its pending origin row via the
// replayed reference_id + matching amount.
func TestWebhook_PaymentUpdated_OrphanedReplay_ReferenceFallback(t *testing.T) {
const (
orphanSquareID = "sqp_orphan_refc2"
refID = "b16bad0000aa"
)
var originID string
if err := db.Conn.QueryRow(context.Background(), `
INSERT INTO payments (payment_type, payment_method, status, amount, gift_card_id, created_at, updated_at)
VALUES ('full', 'online_square', 'pending', 10.00, $1, NOW(), NOW())
RETURNING id
`, refID).Scan(&originID); err != nil {
t.Fatalf("failed to create reference-origin payment: %v", err)
}
event := SquareWebhookEvent{
Type: "payment.updated",
EventID: "evt_orphan_refc2_1",
CreatedAt: "2025-01-01T00:00:00Z",
Data: json.RawMessage(`{
"type": "payment",
"id": "` + orphanSquareID + `",
"object": {
"payment": {
"id": "` + orphanSquareID + `",
"status": "COMPLETED",
"reference_id": "` + refID + `",
"amount_money": {"amount": 1000, "currency": "GBP"}
}
}
}`),
}
w := deliverWebhook(t, event)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
if got := getPaymentStatus(t, originID); got != "failed" {
t.Errorf("expected reference-matched origin payment 'failed', got %q", got)
}
if n := countOrphanReplayNotifications(t, originID); n != 1 {
t.Errorf("expected exactly 1 orphan-replay notification, got %d", n)
}
}
// TestWebhook_PaymentUpdated_SettledRow_NoOrphanDetection verifies the orphan
// detection NEVER fires for a charge that already has a local row in a settled
// status (a plain payment.updated replay of a known charge): the row is left
// untouched and no notification is raised.
func TestWebhook_PaymentUpdated_SettledRow_NoOrphanDetection(t *testing.T) {
const squarePaymentID = "sqp_settled_replay"
payID := createWebhookTestPayment(t, squarePaymentID, "completed")
before := countCriticalNotifications(t)
event := SquareWebhookEvent{
Type: "payment.updated",
EventID: "evt_settled_replay_1",
CreatedAt: "2025-01-01T00:00:00Z",
Data: json.RawMessage(`{
"type": "payment",
"id": "` + squarePaymentID + `",
"object": {
"payment": {
"id": "` + squarePaymentID + `",
"status": "COMPLETED",
"idempotency_key": "b1-settled-key",
"amount_money": {"amount": 1000, "currency": "GBP"}
}
}
}`),
}
w := deliverWebhook(t, event)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
if got := getPaymentStatus(t, payID); got != "completed" {
t.Errorf("expected settled payment to stay 'completed', got %q", got)
}
if n := countCriticalNotifications(t) - before; n != 0 {
t.Errorf("expected no new critical notification for a known settled row, got %d", n)
}
}
// =============================================================================
// State mutation — refund.updated
// =============================================================================