Files
Crussell/backend/handlers/payments/idempotency_helpers_test.go
T
popertotsandSisyphus 9a12a2d886 fix: round-3 — tip gate asymmetry, webhook VAT align + 503 notifications, cash-tip campaign overcharge, lockout DoS, erasure durability, S3 retry cap, env parsing, per-user rate limiters, consume dead code, frontend 2FA remnants
- tip gate: CreateTipPayment saved-card 2FA gate now has scaTokenizedSavedCard skip matching every other charge surface (booking, terminal, gift-card); isSCATokenizeResultShape escape added to tip SAVE gate
- webhook: align UPDATE clears VAT fields before re-apply (matches sweep rescue); 503 unknown-event tracking with 24h timeout notification via square_webhook_events table
- cash-tip: cashChargeBasePence no longer restores campaign or subtracts loyalty — overcharge and tip shortfall fixed; 2FA dead code remnants removed from gift-card buy flow; TwoFactorCodeInput help text deconfused; refund pre-fill unit mismatch fixed (pounds vs pence); SCA buyer names split from full_name; passwordless delete UI accepts empty password
- lockout: successful current-password clears shared failed_attempts/locked_until (victim can recover from login lockout via password change); passwordless delete condition changed to require 2FA only in enforced env
- erasure: stale-guest batch erasure persists Square card/customer targets to durable outbox before NULLing them (crash-safe); S3 deletion retry capped at 10 attempts with admin notification; S3_PROFILE_PICS_BUCKET startup check added
- env parsing: IsExplicitDevOrMockEnv and Square HTTP client base-URL switch now normalize (ToLower+TrimSpace) for consistency
- auth: change-password/delete-account get per-user rate limiters (10/min); consume param dead code suppressed with TODO
- frontend: 2FA/SCA dead code removed from gift-card buy flow, TwoFactorCodeInput help text fixed, refund pre-fill unit mismatch fixed, buyer names populated from full_name

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
2026-08-22 00:34:51 +01:00

170 lines
6.2 KiB
Go

//go:build test && dev
package payments
import (
"context"
"testing"
"crussell/internal/square"
)
// TestDeriveRefundIdempotencyKey_Deterministic locks the M1 property: the
// server-side refund key derived from (payment_id, amount pence, refund type)
// is DETERMINISTIC — a retry of the same logical refund re-derives the SAME
// key, so Square's idempotency dedup returns the original refund instead of
// minting a second one.
func TestDeriveRefundIdempotencyKey_Deterministic(t *testing.T) {
key1 := deriveRefundIdempotencyKey("pay1234567890", 5000, "manual")
key2 := deriveRefundIdempotencyKey("pay1234567890", 5000, "manual")
if key1 != key2 {
t.Errorf("expected the derived refund key to be deterministic, got %q vs %q", key1, key2)
}
}
// TestDeriveRefundIdempotencyKey_DistinguishesInputs locks the M1 discriminator
// fields: the key MUST change when the payment, the amount, or the refund type
// changes (a distinct partial refund of the same amount must never collide with
// a cancellation refund of the same size).
func TestDeriveRefundIdempotencyKey_DistinguishesInputs(t *testing.T) {
base := deriveRefundIdempotencyKey("pay1234567890", 5000, "manual")
otherPayment := deriveRefundIdempotencyKey("pay9999999999", 5000, "manual")
if otherPayment == base {
t.Errorf("expected a different payment id to produce a different refund key")
}
otherAmount := deriveRefundIdempotencyKey("pay1234567890", 5001, "manual")
if otherAmount == base {
t.Errorf("expected a different amount to produce a different refund key")
}
otherType := deriveRefundIdempotencyKey("pay1234567890", 5000, "cancellation")
if otherType == base {
t.Errorf("expected a different refund type to produce a different refund key")
}
}
// TestDeriveRefundIdempotencyKey_RespectsSquareLimit locks the
// truncateIdempotencyKey semantics: every derived key stays within Square's
// 45-char /v2/refunds idempotency-key limit, even for an over-length candidate
// (a long payment id / large amount), and the truncation stays deterministic.
func TestDeriveRefundIdempotencyKey_RespectsSquareLimit(t *testing.T) {
cases := []struct {
paymentID string
amount int64
refundType string
}{
{"pay1234567890", 5000, "manual"},
{"pay1234567890", 5000, "cancellation"},
{"a-very-long-payment-id-that-exceeds-the-45-char-limit-when-combined", 999999999, "manual"},
{"pay1234567890", 999999999, "a-refund-type-that-is-itself-quite-long"},
}
for _, c := range cases {
key := deriveRefundIdempotencyKey(c.paymentID, c.amount, c.refundType)
if len(key) > maxIdempotencyKeyLength {
t.Errorf("derived refund key %q (%d chars) exceeds Square's %d-char limit", key, len(key), maxIdempotencyKeyLength)
}
if key != deriveRefundIdempotencyKey(c.paymentID, c.amount, c.refundType) {
t.Errorf("derived refund key %q is not deterministic across calls", key)
}
}
}
// TestDeriveRefundIdempotencyKey_RetryAfterSweepResolution_NoSecondRefund locks
// the M1 end-to-end dedup: a refund issued with the deterministic server-side
// key, resolved by a sweep pass, and then RETRIED maps to the SAME Square refund
// (the mock's refundByKey dedup returns the original) — a second Square refund
// is never minted. This is the mechanism the refund issuance uses so a
// no-client-key retry after sweep resolution dedups onto the first refund.
func TestDeriveRefundIdempotencyKey_RetryAfterSweepResolution_NoSecondRefund(t *testing.T) {
mock := square.NewDevClient().(*square.MockClient)
payment, err := mock.CreatePayment(context.Background(), square.CreatePaymentReq{
Amount: 5000,
Currency: "GBP",
SourceID: "cnon:test-card",
IdempotencyKey: "seed-refund-dedup-payment",
})
if err != nil {
t.Fatalf("failed to seed the payment at the mock: %v", err)
}
paymentID := "payrefund000001"
amountPence := int64(5000)
// The no-client-key refund issues with the deterministic server-side key.
key := deriveRefundIdempotencyKey(paymentID, amountPence, "manual")
first, err := mock.RefundPayment(context.Background(), square.RefundPaymentReq{
PaymentID: payment.SquarePayID,
Amount: amountPence,
IdempotencyKey: key,
Reason: "test",
})
if err != nil {
t.Fatalf("first refund failed: %v", err)
}
// After the sweep resolves the first attempt, the client retries the SAME
// logical refund. The retry re-derives the SAME deterministic key, so the
// mock's Square-style dedup returns the ORIGINAL refund — RefundKeyCount
// stays 1 and no second Square refund is minted.
retry, retryErr := mock.RefundPayment(context.Background(), square.RefundPaymentReq{
PaymentID: payment.SquarePayID,
Amount: amountPence,
IdempotencyKey: deriveRefundIdempotencyKey(paymentID, amountPence, "manual"),
Reason: "test",
})
if retryErr != nil {
t.Fatalf("retry after sweep resolution failed: %v", retryErr)
}
if first.ID != retry.ID {
t.Errorf("expected the retry to dedup onto the original refund %s, got a different refund %s", first.ID, retry.ID)
}
if got := mock.RefundKeyCount(); got != 1 {
t.Errorf("expected exactly ONE Square refund after the retry, got %d distinct refund keys", got)
}
}
// TestIsExplicitDevOrMockEnv_NormalizedPins the FIX 5 normalization: the env
// value is lowercased and trimmed before comparison, so "Mock", " MOCK ",
// "Production " (space), and "PROD" all map correctly. Empty/unknown stays
// fail-closed (false).
func TestIsExplicitDevOrMockEnv_Normalized(t *testing.T) {
cases := []struct {
env string
want bool
}{
// Exact matches (unchanged behavior)
{"mock", true},
{"dev", true},
{"development", true},
{"test", true},
// Case normalization
{"Mock", true},
{"MOCK", true},
{"Dev", true},
{"DEVELOPMENT", true},
// Trailing/leading whitespace
{" mock ", true},
{" mock ", true},
{"mock ", true},
{"", false},
{"production", false},
{"PROD", false},
{"Production ", false},
{" PRODUCTION ", false},
{"sandbox", false},
{"staging", false},
{"unknown", false},
}
for _, tc := range cases {
t.Run(tc.env, func(t *testing.T) {
t.Setenv("SQUARE_ENVIRONMENT", tc.env)
got := IsExplicitDevOrMockEnv()
if got != tc.want {
t.Errorf("IsExplicitDevOrMockEnv(%q) = %v, want %v", tc.env, got, tc.want)
}
})
}
}