Files
Crussell/backend/handlers/payments/validators_test.go
T
popertots 1429eddd34 fix: payments hardening — SCA wire contract (saved-card ref + tokenize-result), terminal/till token routing, tip-cap overflow carve, completion campaign atomicity, orphan B1-evidence gate, gift-card gates/locks, admin backstops
- ValidateCardInfo accepts saved-card ref + new_card_token coexistence (matches resolveChargeSource); new_card_token added to terminal/till request structs so SCA tokens are never dropped
- maxOnlineTipPence (£250) enforced on the overflow-tip carve AND buildSplitRecords (both carve paths) — closes the £10k bypass
- completion-path campaign increments made atomic reserve-first (conditional UPDATE ... RETURNING) + schema backstops (chk_times_redeemed, partial unique index on milestone redemptions)
- webhook orphan detection gated on B1 evidence (b1_attempts / sweep-duplicate refund row) so a delayed legit completion is never marked failed
- gift-card: per-user £500/day cap lock held across read-modify-write, expired-card top-up gate, NaN/Inf float bounds, refund_failed ack filter, on_the_house excluded from balance, postChargeRecheck notification
- admin apply-redemption route + admin-or-owner, in-handler isAdminRequest on 4 gift-card handlers, tip lock key aligned
- 2FA fallback machinery removed (insertTwoFAFallbackAudit/reissue/consent), dead fields stripped from charge structs
- tests: prod-tag suite, mock SCA parity, tip-cap overflow, completion races, cards pagination, ValidateCardInfo tables
2026-08-22 00:34:50 +01:00

182 lines
6.9 KiB
Go

//go:build test && dev
package payments
import (
"strings"
"testing"
"github.com/stretchr/testify/require"
)
// TestValidateAmount covers the amount validator used by every payment
// handler: positive and within the £10,000 (1,000,000 pence) cap. Amounts are
// integer pence, so sub-penny "precision" is impossible by construction.
func TestValidateAmount(t *testing.T) {
t.Parallel()
valid := []int64{1, 500, 10000, 999999, 1000000}
for _, amount := range valid {
require.NoErrorf(t, ValidateAmount(amount), "expected %d to be a valid amount", amount)
}
invalid := []int64{0, -1, -500, 1000001}
for _, amount := range invalid {
require.Errorf(t, ValidateAmount(amount), "expected %d to be rejected", amount)
}
// The cap is exclusive: exactly £10,000 (1,000,000 pence) is allowed, one
// penny more is rejected.
if err := ValidateAmount(1000001); err == nil {
t.Error("expected amount above £10,000 cap to be rejected")
}
if err := ValidateAmount(1000000); err != nil {
t.Errorf("expected exactly £10,000 to be allowed, got %v", err)
}
}
// TestValidateCardInfo_SCACoexistence pins the SCA saved-card wire contract on
// the card-source validator: a saved-card reference (card_id or saved_card_id)
// riding along WITH new_card_token is now VALID — the tokenize-result token is
// the one-time charge source and the saved-card row supplies the customer
// (resolveChargeSource implements the coexistence). A card reference alone
// (legacy saved-card), a token alone (new-card), empty references, and neither
// present keep their prior semantics.
func TestValidateCardInfo_SCACoexistence(t *testing.T) {
t.Parallel()
cardID := "card-1"
savedCardID := "card-2"
token := "cnon:sca-tokenize"
empty := ""
// card_id + new_card_token — now valid (SCA tokenize-result source).
require.NoError(t, ValidateCardInfo(&cardID, nil, &token),
"card_id + new_card_token must be valid (SCA tokenize-result)")
// saved_card_id + new_card_token — valid (SCA tokenize-result).
require.NoError(t, ValidateCardInfo(nil, &savedCardID, &token),
"saved_card_id + new_card_token must be valid (SCA tokenize-result)")
// card_id only — valid legacy saved-card charge.
require.NoError(t, ValidateCardInfo(&cardID, nil, nil),
"card_id alone must remain valid")
// new_card_token only — valid new-card charge.
require.NoError(t, ValidateCardInfo(nil, nil, &token),
"a bare new_card_token must remain valid (new-card path)")
// Neither present — invalid.
require.Error(t, ValidateCardInfo(nil, nil, nil),
"neither a card reference nor new_card_token must be rejected")
// Empty strings are treated as absent — invalid when nothing is present.
require.Error(t, ValidateCardInfo(&empty, &empty, &empty),
"empty-string values must be rejected as absent")
require.Error(t, ValidateCardInfo(&empty, nil, &empty),
"an empty card_id with an empty token must be rejected")
// An empty card reference with a real token is still the valid new-card path.
require.NoError(t, ValidateCardInfo(&empty, nil, &token),
"an empty card_id with a real new_card_token is the new-card path and must be valid")
}
// TestValidateCardInfo_Table covers the full card-source shape space as a table:
// the three legal wire shapes (saved-card reference only, new-card token only,
// and both together as the SCA tokenize-result source) plus the both-absent and
// empty-string rejections. This is the drift guard for the SCA wire contract —
// every shape the frontend can send is enumerated here.
func TestValidateCardInfo_Table(t *testing.T) {
t.Parallel()
cardID := "card-1"
savedCardID := "saved-2"
token := "cnon:sca-tokenize-3"
empty := ""
tests := []struct {
name string
cardID *string
savedCard *string
token *string
wantValid bool
}{
{"saved card reference (card_id) only — legacy saved-card charge", &cardID, nil, nil, true},
{"saved card reference (saved_card_id) only", nil, &savedCardID, nil, true},
{"new card token only — new-card charge", nil, nil, &token, true},
{"saved-card reference + new card token — SCA tokenize-result source", &cardID, nil, &token, true},
{"saved_card_id + new card token — SCA tokenize-result source", nil, &savedCardID, &token, true},
{"neither present — invalid", nil, nil, nil, false},
{"empty card_id + empty saved_card_id + empty token — all absent", &empty, &empty, &empty, false},
{"empty card_id + empty token — absent card ref", &empty, nil, &empty, false},
{"empty saved_card_id + empty token — absent card ref", nil, &empty, &empty, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateCardInfo(tt.cardID, tt.savedCard, tt.token)
if tt.wantValid {
require.NoErrorf(t, err, "expected shape to be valid: %v", tt)
} else {
require.Errorf(t, err, "expected shape to be rejected: %v", tt)
}
})
}
}
// TestValidatePartialAmount_PenceSemantics pins the pence comparison: amounts
// are integer pence, so the boundary is exact — equal pence passes, one penny
// over fails, and non-positive amounts are rejected regardless of the balance.
func TestValidatePartialAmount_PenceSemantics(t *testing.T) {
t.Parallel()
tests := []struct {
name string
amountPence int64
remaining int64
wantValid bool
}{
{"partial equal to the remaining balance passes", 2500, 2500, true},
{"partial below the remaining balance passes", 1000, 2500, true},
{"one penny over the remaining balance fails", 2501, 2500, false},
{"large partial over the balance fails", 5000, 2500, false},
{"zero amount rejected even with balance", 0, 2500, false},
{"negative amount rejected", -100, 2500, false},
{"zero remaining rejects any positive partial", 1, 0, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidatePartialAmount(tt.amountPence, tt.remaining)
if tt.wantValid {
require.NoErrorf(t, err, "expected %d pence against %d remaining to be valid", tt.amountPence, tt.remaining)
} else {
require.Errorf(t, err, "expected %d pence against %d remaining to be rejected", tt.amountPence, tt.remaining)
}
})
}
}
// TestValidateVerificationToken_Bound pins the 512-char bound on Square's
// verification token: empty/nil is fine (token-less charges are valid input),
// anything at or under 512 chars passes, and 513+ is rejected.
func TestValidateVerificationToken_Bound(t *testing.T) {
t.Parallel()
ok := strings.Repeat("t", 512)
tooLong := strings.Repeat("t", 513)
tests := []struct {
name string
token *string
wantErr bool
}{
{"nil token passes (token-less is valid)", nil, false},
{"empty token passes", strPtr(""), false},
{"exactly 512 chars passes", &ok, false},
{"513 chars rejected", &tooLong, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateVerificationToken(tt.token)
if tt.wantErr {
require.Error(t, err)
} else {
require.NoError(t, err)
}
})
}
}