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