//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") }