Files
Crussell/backend/handlers/payments/charge_helpers_test.go
T
popertots 6d82535780 fix: adversarial review round — replay-rescue double-charge, discount credit, 2FA/per-IP limits, snapshot encryption, refund reconciliation, VAT, frontend parity, tests+docs
Addresses the adversarial fresh-eyes audit (findings A1-A20) plus review-round fixes:
- CRITICAL A1: replay-by-key rescue cross-checks replayed CreatedAt; ccof blind-fail leaves pending with CRITICAL + notification instead of clawing back
- A2/A3/A4: till idempotency key restored to unconditional hash; tip rejected in CreateBookingPayment; campaign discount now reduces the charged amount (deposit credit)
- A5: admin notifications on blind-fail, manual-refund re-arm, cap-stranded charge-group, webhook FAILED/REJECTED refunds
- A6/A10: BuyGiftCard idempotency user-scoped; gift-card slot scan advances past failed rows
- A7/A14/A15: 2FA user+IP limiter, SNAPSHOT_ENC_KEY startup validation, accurate pepper/log-delivery docs
- A8/A9: snapshot encryption on all write+reuse sites; MPV->SPV effective voucher type (single VAT point)
- A11/A12/A13/A16: amount-aware refund reconciliation; completed-booking refund re-validation; till retry dedup; PaymentWasRefunded on SquareClient interface
- A17/A18/A19/A20: CI runs npm test; confirm_overflow_tip frontend dialog; unknown-event admin notification; mock token redaction
- M7 ConfirmOverflowTip, M9 snapshot encryption, C1 discount ordering regression test
- Frontend vitest framework (41 tests), backend coverage for fixed functions, docs corrected (2,269 tests, SUPPORT_EMAIL tokens, resolution status)

All 25 backend packages pass; frontend 41/41; build + env-docs green.
2026-08-22 00:34:50 +01:00

207 lines
8.7 KiB
Go

//go:build test && dev
package payments
import (
"bytes"
"context"
"encoding/base64"
"errors"
"net/http/httptest"
"strings"
"sync"
"testing"
"crussell/db"
"crussell/internal/square"
"crussell/testutils/fixtures"
"github.com/stretchr/testify/require"
)
// orphanCardClient wraps the dev Square client to record every DeleteCardOnFile
// call. Used to assert the save-card orphan cleanup in resolveChargeSource
// WITHOUT needing to reach the real Square API. failDelete simulates a Square
// disable failure so tests can verify the charge is not failed by cleanup.
type orphanCardClient struct {
square.SquareClient
mu sync.Mutex
deleted []string
failDelete bool
}
func (c *orphanCardClient) DeleteCardOnFile(ctx context.Context, cardID string) error {
c.mu.Lock()
c.deleted = append(c.deleted, cardID)
c.mu.Unlock()
if c.failDelete {
return errors.New("square: network error disabling card at Square")
}
return nil
}
func (c *orphanCardClient) deletedIDs() []string {
c.mu.Lock()
defer c.mu.Unlock()
return append([]string(nil), c.deleted...)
}
// TestResolveChargeSource_SaveCard_HappyPath guards the save-card branch: the
// Square card is created, persisted locally via SaveCardForUser, and NO
// DeleteCardOnFile cleanup is triggered (a saved card must never be deleted).
func TestResolveChargeSource_SaveCard_HappyPath(t *testing.T) {
ctx := context.Background()
userID, err := fixtures.CreateTestUser(db.Conn)
require.NoError(t, err)
defer func() {
InvalidateSquareCustomerCache(userID)
_, _ = db.Conn.Exec(ctx, `DELETE FROM user_saved_cards WHERE user_id = $1`, userID)
_, _ = db.Conn.Exec(ctx, `DELETE FROM users WHERE id = $1`, userID)
}()
origClient := SquareClient
rec := &orphanCardClient{SquareClient: square.NewDevClient()}
SquareClient = rec
defer func() { SquareClient = origClient }()
token := "cnon:test-save-happy"
sourceID, savedCardID, sqCustID, ok := resolveChargeSource(ctx, httptest.NewRecorder(), NewPaymentService(), userID, &token, nil, true, "")
require.True(t, ok, "save-card source resolution must succeed on the happy path")
require.True(t, strings.HasPrefix(sourceID, "ccof:"), "source must be the created card-on-file, got %q", sourceID)
require.NotNil(t, savedCardID, "a successful SaveCardForUser must return the local row id")
require.NotEmpty(t, sqCustID, "the provisioned Square customer id must be returned")
var rows int
require.NoError(t, db.Conn.QueryRow(ctx, `SELECT COUNT(*) FROM user_saved_cards WHERE user_id = $1 AND square_card_id = $2`, userID, sourceID).Scan(&rows))
require.Equal(t, 1, rows, "the card must be persisted as a user_saved_cards row")
require.Empty(t, rec.deletedIDs(), "a successfully saved card must never be disabled at Square")
}
// TestResolveChargeSource_SaveCard_OrphanCleanedUp verifies the orphan-card
// fix: when CreateCardOnFile succeeds but the local DB save fails, the
// just-created Square card is disabled (DeleteCardOnFile) so no card-on-file
// is left at Square without a DB row. The charge must still resolve ok=true.
func TestResolveChargeSource_SaveCard_OrphanCleanedUp(t *testing.T) {
ctx := context.Background()
// A non-existent user: EnsureSquareCustomer is bypassed via the cache, and
// SaveCardForUser's INSERT fails on the users(id) FK — a clean injection of
// the DB-save failure without touching other test state.
userID := "c_orphan_00"
squareCustomerCache.Store(userID, "cus_orphan")
defer InvalidateSquareCustomerCache(userID)
origClient := SquareClient
rec := &orphanCardClient{SquareClient: square.NewDevClient()}
SquareClient = rec
defer func() { SquareClient = origClient }()
token := "cnon:test-orphan-cleanup"
sourceID, savedCardID, sqCustID, ok := resolveChargeSource(ctx, httptest.NewRecorder(), NewPaymentService(), userID, &token, nil, true, "")
require.True(t, ok, "a local save failure must NOT fail the charge")
require.Nil(t, savedCardID, "no local saved-card row must exist after the failed save")
require.Equal(t, "cus_orphan", sqCustID)
require.True(t, strings.HasPrefix(sourceID, "ccof:"), "source must still be the created card-on-file, got %q", sourceID)
deletes := rec.deletedIDs()
require.Len(t, deletes, 1, "the just-created Square card must be disabled exactly once")
require.Equal(t, sourceID, deletes[0], "the disabled card must be the one this call just created")
var rows int
require.NoError(t, db.Conn.QueryRow(ctx, `SELECT COUNT(*) FROM user_saved_cards WHERE user_id = $1`, userID).Scan(&rows))
require.Zero(t, rows, "no orphaned saved-card row may exist")
}
// TestResolveChargeSource_SaveCard_CleanupFailureStillCharges verifies the
// best-effort contract: when the DB save fails AND the Square disable also
// fails, the charge must still resolve ok=true (the orphan is only logged for
// manual cleanup, never allowed to fail the request).
func TestResolveChargeSource_SaveCard_CleanupFailureStillCharges(t *testing.T) {
ctx := context.Background()
userID := "c_orphan_01"
squareCustomerCache.Store(userID, "cus_orphan")
defer InvalidateSquareCustomerCache(userID)
origClient := SquareClient
rec := &orphanCardClient{SquareClient: square.NewDevClient(), failDelete: true}
SquareClient = rec
defer func() { SquareClient = origClient }()
token := "cnon:test-orphan-cleanup-fail"
sourceID, savedCardID, _, ok := resolveChargeSource(ctx, httptest.NewRecorder(), NewPaymentService(), userID, &token, nil, true, "")
require.True(t, ok, "a failed Square disable must never fail the charge")
require.Nil(t, savedCardID)
require.True(t, strings.HasPrefix(sourceID, "ccof:"), "source must be the created card-on-file, got %q", sourceID)
require.Equal(t, []string{sourceID}, rec.deletedIDs(), "the disable must be attempted even when it will fail")
}
// snapshotEncKeyForTest returns a deterministic base64-encoded 32-byte
// AES-256 key so encryption tests do not depend on a real env secret.
func snapshotEncKeyForTest() string {
key := make([]byte, 32)
for i := range key {
key[i] = byte(i)
}
return base64.StdEncoding.EncodeToString(key)
}
// TestEncryptDecryptSnapshot_RoundTrip pins the M9 lossless constraint: in a
// non-mock environment with SNAPSHOT_ENC_KEY set, encryptSnapshot must not
// store plaintext and decryptSnapshot must recover the ORIGINAL bytes exactly
// — Square's identical-body idempotency replay depends on byte-for-byte
// fidelity. It also covers the dev/mock path (plaintext passthrough) and the
// legacy/unmarked plaintext path through decryptSnapshot.
func TestEncryptDecryptSnapshot_RoundTrip(t *testing.T) {
t.Setenv("SQUARE_ENVIRONMENT", "production")
t.Setenv("SNAPSHOT_ENC_KEY", snapshotEncKeyForTest())
body := []byte(`{"source_id":"cnon:test-nonce","buyer_email_address":"buyer@example.com","idempotency_key":"test-key"}`)
enc, err := encryptSnapshot(body)
require.NoError(t, err)
require.False(t, bytes.Equal(enc, body), "production-mode snapshots must not be stored in plaintext")
require.True(t, bytes.HasPrefix(enc, []byte(snapshotEncMarker)), "encrypted snapshot must carry the enc:v1: marker")
dec, err := decryptSnapshot(enc)
require.NoError(t, err)
require.True(t, bytes.Equal(dec, body), "decrypt must recover the byte-identical original snapshot (Square idempotent replay depends on it)")
// Plaintext / legacy / dev-mock values pass through decrypt unchanged.
decPlain, err := decryptSnapshot(body)
require.NoError(t, err)
require.True(t, bytes.Equal(decPlain, body), "unmarked snapshot values must pass through unchanged")
}
// TestEncryptSnapshot_DevMockStoresPlaintext pins the M9 gate: in dev/mock
// environments the snapshot stays plaintext (no key required), so the mock
// test suite keeps working unchanged.
func TestEncryptSnapshot_DevMockStoresPlaintext(t *testing.T) {
t.Setenv("SQUARE_ENVIRONMENT", "mock")
t.Setenv("SNAPSHOT_ENC_KEY", "")
body := []byte(`{"source_id":"cnon:test-nonce"}`)
enc, err := encryptSnapshot(body)
require.NoError(t, err)
require.True(t, bytes.Equal(enc, body), "mock-mode snapshots must stay plaintext")
}
// TestEncryptDecryptSnapshot_WrongKeyFails pins the auth failure path: a
// snapshot encrypted with one key must not decrypt (silently or otherwise)
// with a different key — GCM authentication must reject it.
func TestEncryptDecryptSnapshot_WrongKeyFails(t *testing.T) {
t.Setenv("SQUARE_ENVIRONMENT", "production")
t.Setenv("SNAPSHOT_ENC_KEY", snapshotEncKeyForTest())
enc, err := encryptSnapshot([]byte(`{"source_id":"cnon:test-nonce"}`))
require.NoError(t, err)
// A different valid 32-byte key must fail GCM authentication.
other := make([]byte, 32)
for i := range other {
other[i] = 0xFF
}
t.Setenv("SNAPSHOT_ENC_KEY", base64.StdEncoding.EncodeToString(other))
_, err = decryptSnapshot(enc)
require.Error(t, err, "a snapshot encrypted with a different key must not decrypt")
}