Files
Crussell/backend/handlers/payments/charge_helpers_test.go
T
popertots 8dcfe52ac8 fix: till saved-card SCA/consent + ownership invariant (F1/F5), money-F4 top-up expiry gate, 2FA-enforced 402 tests
- till.go: the saved-card till charge carries the C6 consent fields and enforces
  them on the (unreachable) 2FA fallback path; the card ownership SELECT became
  owner-agnostic with the owner read at the gate (F1 — no charge surface can
  act on a card it does not own); a till sale's gift-card creation/top-up now
  runs under the SAME per-admin daily-cap advisory lock as the admin API
  surfaces (F5) so two concurrent distinct sales cannot overshoot the £5,000
  day ceiling; money-F4: an expired gift card can never be topped up (expiry
  gate mirrors RedeemGiftCard's DB-clock comparison) — the top-up would
  otherwise resurrect a card the nightly cleanup already forfeited.
- charge_helpers_test.go: TestResolveChargeSource_SCATokenizeResult_UsesTokenAsSource
  pins the SCA tokenize-result wire contract (token as source, card row for the
  customer).
- errors_test.go: token-less saved-card charges are refused 402
  verification_required under 2FA enforcement (create-payment, gift-card buy +
  save-card), even for a user with 2FA enabled — the homegrown gate can never
  substitute for SCA.
2026-08-22 00:34:50 +01:00

235 lines
10 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")
}
// TestResolveChargeSource_SCATokenizeResult_UsesTokenAsSource pins the SCA
// wire contract at the source-resolution level: a call carrying BOTH a
// new-card token (the SCA tokenize-result) and a saved card id must return the
// TOKEN as the charge source — never the stored ccof id — while still deriving
// the Square customer from the saved card row.
func TestResolveChargeSource_SCATokenizeResult_UsesTokenAsSource(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)
}()
cardID, err := fixtures.CreateTestPaymentMethod(db.Conn, userID, "ccof:sca-tokenize-unit", "VISA", "4242")
require.NoError(t, err)
token := "cnon:sca-tokenize-unit"
sourceID, savedCardID, sqCustID, ok := resolveChargeSource(ctx, httptest.NewRecorder(), NewPaymentService(), userID, &token, &cardID, false, "")
require.True(t, ok, "the tokenize-result source must resolve")
require.Equal(t, token, sourceID, "the tokenize-result token must be the charge source")
require.NotEqual(t, "ccof:sca-tokenize-unit", sourceID, "the stored ccof id must NOT be the source")
require.NotNil(t, savedCardID, "the saved-card row id must be returned")
require.Equal(t, cardID, *savedCardID, "the saved-card row id must match the input card")
require.NotEmpty(t, sqCustID, "customer_id must derive from the saved card row")
}