- 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)
170 lines
6.2 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|
|
}
|