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.
207 lines
8.7 KiB
Go
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")
|
|
}
|