- 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
182 lines
6.9 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|
|
}
|