Match dev mock to production: reject raw PAN card creation (PCI-DSS parity)
Previously the dev MockClient was more permissive than production: - MockClient.CreateCardOnFileRaw processed raw PANs and stored mock cards, while ProdClient and devProdClient both block raw PANs. A dev testing the raw-card flow saw it succeed, masking a production failure. - MockClient.CreateCardOnFile accepted raw PANs as source_id via an isAllDigits branch. Real Square only accepts cnon:xxx/ccof:xxx tokens. Now the mock behaves identically to production: - CreateCardOnFileRaw returns the same PCI error as ProdClient - CreateCardOnFile validates source_id is token-like (cnon:/ccof:) and rejects raw PANs - Removed dead isAllDigits helper Tests updated to assert the parity behavior: - TestDevClient_CreateCardOnFileRaw_Rejected_ProdParity (table-driven, replaces 5 brand-specific raw-PAN tests) - TestDevClient_CreateCardOnFile_RejectsRawPAN (replaces RawNumber) - TestCreatePaymentMethod_HappyPath / SecondCardNotDefault now expect 500 instead of 200, documenting the prod block
This commit is contained in:
@@ -2153,24 +2153,10 @@ func TestCreatePaymentMethod_HappyPath(t *testing.T) {
|
|||||||
|
|
||||||
w := makePaymentRequest(handler, "POST", "/api/user/payment-methods", reqBody, token, ctx)
|
w := makePaymentRequest(handler, "POST", "/api/user/payment-methods", reqBody, token, ctx)
|
||||||
|
|
||||||
if w.Code != http.StatusOK {
|
// PCI-DSS parity: raw PAN card creation is blocked in production, so the
|
||||||
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
|
// mock must reject it too — otherwise dev testing masks a prod failure.
|
||||||
return
|
if w.Code != http.StatusInternalServerError {
|
||||||
}
|
t.Errorf("expected status 500 (raw PAN rejected), got %d. body: %s", w.Code, w.Body.String())
|
||||||
|
|
||||||
var card SavedCard
|
|
||||||
if err := json.Unmarshal(w.Body.Bytes(), &card); err != nil {
|
|
||||||
t.Fatalf("failed to parse response: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if card.Brand != "VISA" {
|
|
||||||
t.Errorf("expected brand VISA, got %s", card.Brand)
|
|
||||||
}
|
|
||||||
if card.Last4 != "1111" {
|
|
||||||
t.Errorf("expected last4 1111, got %s", card.Last4)
|
|
||||||
}
|
|
||||||
if !card.IsDefault {
|
|
||||||
t.Error("expected first card to be default")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2296,7 +2282,8 @@ func TestCreatePaymentMethod_SecondCardNotDefault(t *testing.T) {
|
|||||||
|
|
||||||
token := jwt.GenerateUserToken(userID)
|
token := jwt.GenerateUserToken(userID)
|
||||||
|
|
||||||
// Create first card
|
// PCI-DSS parity: raw PAN card creation is blocked in production, so the
|
||||||
|
// mock must reject it too — otherwise dev testing masks a prod failure.
|
||||||
handler := CreatePaymentMethod
|
handler := CreatePaymentMethod
|
||||||
reqBody := CreatePaymentMethodRequest{
|
reqBody := CreatePaymentMethodRequest{
|
||||||
CardNumber: "4111111111111111",
|
CardNumber: "4111111111111111",
|
||||||
@@ -2305,31 +2292,8 @@ func TestCreatePaymentMethod_SecondCardNotDefault(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
w := makePaymentRequest(handler, "POST", "/api/user/payment-methods", reqBody, token, ctx)
|
w := makePaymentRequest(handler, "POST", "/api/user/payment-methods", reqBody, token, ctx)
|
||||||
if w.Code != http.StatusOK {
|
if w.Code != http.StatusInternalServerError {
|
||||||
t.Fatalf("failed to create first card: %d. body: %s", w.Code, w.Body.String())
|
t.Fatalf("expected 500 (raw PAN rejected), got %d. body: %s", w.Code, w.Body.String())
|
||||||
}
|
|
||||||
|
|
||||||
reqBody2 := CreatePaymentMethodRequest{
|
|
||||||
CardNumber: "5500000000000004",
|
|
||||||
Expiry: "06/30",
|
|
||||||
CVC: "456",
|
|
||||||
}
|
|
||||||
|
|
||||||
w = makePaymentRequest(handler, "POST", "/api/user/payment-methods", reqBody2, token, ctx)
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Fatalf("failed to create second card: %d. body: %s", w.Code, w.Body.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
var card SavedCard
|
|
||||||
if err := json.Unmarshal(w.Body.Bytes(), &card); err != nil {
|
|
||||||
t.Fatalf("failed to parse response: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if card.Brand != "MASTERCARD" {
|
|
||||||
t.Errorf("expected brand MASTERCARD, got %s", card.Brand)
|
|
||||||
}
|
|
||||||
if card.IsDefault {
|
|
||||||
t.Error("expected second card to NOT be default")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -320,32 +320,11 @@ func (m *MockClient) RefundPayment(ctx context.Context, req RefundPaymentReq) (*
|
|||||||
func (m *MockClient) CreateCardOnFile(ctx context.Context, userID, cardToken string) (*CardOnFile, error) {
|
func (m *MockClient) CreateCardOnFile(ctx context.Context, userID, cardToken string) (*CardOnFile, error) {
|
||||||
log.Printf("[SQUARE-MOCK] CreateCardOnFile: user=%s", userID)
|
log.Printf("[SQUARE-MOCK] CreateCardOnFile: user=%s", userID)
|
||||||
|
|
||||||
// Detect card info from the input token.
|
// Match the real Square API: source_id must be a token (cnon:xxx nonce or
|
||||||
// Raw card numbers (digit-only, possibly with spaces) are parsed directly.
|
// ccof:xxx card ID). Raw PANs are rejected exactly as Square would, so the
|
||||||
// Nonce-like tokens (cnon:xxx etc.) use detectCardInfo for mapped values.
|
// mock behaves identically to production.
|
||||||
cleanDigits := strings.ReplaceAll(cardToken, " ", "")
|
if !isTokenLike(cardToken) {
|
||||||
cardBrand := "VISA"
|
return nil, fmt.Errorf("invalid source_id: %q — use a card nonce (cnon:xxx) or card ID (ccof:xxx)", cardToken)
|
||||||
cardLast4 := "4242"
|
|
||||||
cardExpMonth := 12
|
|
||||||
cardExpYear := 2030
|
|
||||||
|
|
||||||
if isAllDigits(cleanDigits) && len(cleanDigits) >= 13 {
|
|
||||||
cardLast4 = cleanDigits[len(cleanDigits)-4:]
|
|
||||||
firstDigit := string(cleanDigits[0])
|
|
||||||
switch firstDigit {
|
|
||||||
case "4":
|
|
||||||
cardBrand = "VISA"
|
|
||||||
case "5":
|
|
||||||
cardBrand = "MASTERCARD"
|
|
||||||
case "3":
|
|
||||||
cardBrand = "AMERICAN_EXPRESS"
|
|
||||||
case "6":
|
|
||||||
cardBrand = "DISCOVER"
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
brand, last4 := detectCardInfo(cardToken)
|
|
||||||
cardBrand = brand
|
|
||||||
cardLast4 = last4
|
|
||||||
}
|
}
|
||||||
|
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
@@ -357,13 +336,14 @@ func (m *MockClient) CreateCardOnFile(ctx context.Context, userID, cardToken str
|
|||||||
|
|
||||||
now := clock.Now().UTC()
|
now := clock.Now().UTC()
|
||||||
cardID := fmt.Sprintf("mock_card_%d", now.UnixNano())
|
cardID := fmt.Sprintf("mock_card_%d", now.UnixNano())
|
||||||
|
brand, last4 := detectCardInfo(cardToken)
|
||||||
card := &CardOnFile{
|
card := &CardOnFile{
|
||||||
ID: cardID,
|
ID: cardID,
|
||||||
CardID: fmt.Sprintf("ccof_mock_%d", now.UnixNano()),
|
CardID: fmt.Sprintf("ccof_mock_%d", now.UnixNano()),
|
||||||
Brand: cardBrand,
|
Brand: brand,
|
||||||
Last4: cardLast4,
|
Last4: last4,
|
||||||
ExpMonth: cardExpMonth,
|
ExpMonth: 12,
|
||||||
ExpYear: cardExpYear,
|
ExpYear: 2030,
|
||||||
Fingerprint: fmt.Sprintf("sqfp_mock_%d", now.UnixNano()),
|
Fingerprint: fmt.Sprintf("sqfp_mock_%d", now.UnixNano()),
|
||||||
CardholderName: "John Doe",
|
CardholderName: "John Doe",
|
||||||
CustomerID: userID,
|
CustomerID: userID,
|
||||||
@@ -378,46 +358,10 @@ func (m *MockClient) CreateCardOnFile(ctx context.Context, userID, cardToken str
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockClient) CreateCardOnFileRaw(ctx context.Context, userID, cardNumber string, expMonth, expYear int, cvc string) (*CardOnFile, error) {
|
func (m *MockClient) CreateCardOnFileRaw(ctx context.Context, userID, cardNumber string, expMonth, expYear int, cvc string) (*CardOnFile, error) {
|
||||||
log.Printf("[SQUARE-MOCK] CreateCardOnFileRaw: user=%s", userID)
|
// PCI-DSS parity with production: raw card numbers are never accepted.
|
||||||
|
// The mock must behave identically to the ProdClient so dev testing does
|
||||||
if len(cardNumber) < 4 {
|
// not mask a production failure.
|
||||||
return nil, fmt.Errorf("invalid card number: too short")
|
return nil, fmt.Errorf("square: raw card number input is not supported in production — use CreateCardOnFile with a card nonce")
|
||||||
}
|
|
||||||
|
|
||||||
m.mu.Lock()
|
|
||||||
defer m.mu.Unlock()
|
|
||||||
|
|
||||||
if m.cards[userID] == nil {
|
|
||||||
m.cards[userID] = make(map[string]*CardOnFile)
|
|
||||||
}
|
|
||||||
|
|
||||||
now := clock.Now().UTC()
|
|
||||||
cardID := fmt.Sprintf("mock_card_%d", now.UnixNano())
|
|
||||||
last4 := cardNumber[len(cardNumber)-4:]
|
|
||||||
brands := map[string]string{"4": "VISA", "5": "MASTERCARD", "3": "AMERICAN_EXPRESS", "6": "DISCOVER"}
|
|
||||||
brand := brands[string(cardNumber[0])]
|
|
||||||
if brand == "" {
|
|
||||||
brand = "UNKNOWN"
|
|
||||||
}
|
|
||||||
|
|
||||||
card := &CardOnFile{
|
|
||||||
ID: cardID,
|
|
||||||
CardID: fmt.Sprintf("ccof_mock_%d", now.UnixNano()),
|
|
||||||
Brand: brand,
|
|
||||||
Last4: last4,
|
|
||||||
ExpMonth: expMonth,
|
|
||||||
ExpYear: expYear,
|
|
||||||
Fingerprint: fmt.Sprintf("sqfp_mock_%d", now.UnixNano()),
|
|
||||||
CardholderName: "John Doe",
|
|
||||||
CustomerID: userID,
|
|
||||||
Enabled: true,
|
|
||||||
IsDefault: len(m.cards[userID]) == 0,
|
|
||||||
Version: 1,
|
|
||||||
CreatedAt: now.Format(time.RFC3339),
|
|
||||||
}
|
|
||||||
m.cards[userID][cardID] = card
|
|
||||||
log.Printf("[SQUARE-MOCK] Card created: id=%s, brand=%s, last4=%s", cardID, card.Brand, card.Last4)
|
|
||||||
return card, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockClient) GetCardsOnFile(ctx context.Context, userID string) ([]CardOnFile, error) {
|
func (m *MockClient) GetCardsOnFile(ctx context.Context, userID string) ([]CardOnFile, error) {
|
||||||
@@ -454,14 +398,10 @@ func (m *MockClient) DeleteCardOnFile(ctx context.Context, cardID string) error
|
|||||||
return fmt.Errorf("card not found: %s", cardID)
|
return fmt.Errorf("card not found: %s", cardID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// isAllDigits returns true if every rune in s is an ASCII digit.
|
// isTokenLike returns true for Square source_id tokens: cnon:xxx nonces and
|
||||||
func isAllDigits(s string) bool {
|
// ccof:xxx card IDs. Raw PANs (all digits) are NOT token-like and are rejected.
|
||||||
for _, r := range s {
|
func isTokenLike(s string) bool {
|
||||||
if r < '0' || r > '9' {
|
return strings.HasPrefix(s, "cnon:") || strings.HasPrefix(s, "ccof:")
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return len(s) > 0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func realBaseURL(env string) string {
|
func realBaseURL(env string) string {
|
||||||
|
|||||||
@@ -237,79 +237,32 @@ func TestDevClient_GetCheckout_NotFound(t *testing.T) {
|
|||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDevClient_CreateCardOnFileRaw_Visa(t *testing.T) {
|
func TestDevClient_CreateCardOnFileRaw_Rejected_ProdParity(t *testing.T) {
|
||||||
client := NewDevClient().(*MockClient)
|
// PCI-DSS parity: the mock must reject raw PANs exactly like the
|
||||||
card, err := client.CreateCardOnFileRaw(context.Background(), "user-raw-1", "4111111111111111", 12, 2030, "123")
|
// ProdClient, so dev testing cannot mask a production failure.
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, "VISA", card.Brand)
|
|
||||||
assert.Equal(t, "1111", card.Last4)
|
|
||||||
assert.True(t, card.IsDefault)
|
|
||||||
assert.True(t, card.Enabled)
|
|
||||||
assert.Equal(t, 12, card.ExpMonth)
|
|
||||||
assert.Equal(t, 2030, card.ExpYear)
|
|
||||||
assert.NotEmpty(t, card.CreatedAt)
|
|
||||||
assert.Greater(t, card.Version, int64(0))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDevClient_CreateCardOnFileRaw_Mastercard(t *testing.T) {
|
|
||||||
client := NewDevClient().(*MockClient)
|
client := NewDevClient().(*MockClient)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
// First card to set up non-default check
|
|
||||||
_, err := client.CreateCardOnFileRaw(ctx, "user-raw-2", "4111111111111111", 12, 2030, "123")
|
tests := []struct {
|
||||||
require.NoError(t, err)
|
name string
|
||||||
// Mastercard is second → not default
|
cardNumber string
|
||||||
card, err := client.CreateCardOnFileRaw(ctx, "user-raw-2", "5555555555554444", 12, 2030, "123")
|
}{
|
||||||
require.NoError(t, err)
|
{"visa", "4111111111111111"},
|
||||||
assert.Equal(t, "MASTERCARD", card.Brand)
|
{"mastercard", "5555555555554444"},
|
||||||
assert.Equal(t, "4444", card.Last4)
|
{"amex", "378282246310005"},
|
||||||
assert.False(t, card.IsDefault)
|
{"discover", "6011111111111117"},
|
||||||
assert.True(t, card.Enabled)
|
{"unknown brand", "9999999999999999"},
|
||||||
assert.Equal(t, 12, card.ExpMonth)
|
{"too short", "123"},
|
||||||
assert.Equal(t, 2030, card.ExpYear)
|
|
||||||
assert.NotEmpty(t, card.CreatedAt)
|
|
||||||
assert.Greater(t, card.Version, int64(0))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDevClient_CreateCardOnFileRaw_Amex(t *testing.T) {
|
for _, tt := range tests {
|
||||||
client := NewDevClient().(*MockClient)
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
card, err := client.CreateCardOnFileRaw(context.Background(), "user-raw-3", "378282246310005", 12, 2030, "123")
|
card, err := client.CreateCardOnFileRaw(ctx, "user-raw-"+tt.name, tt.cardNumber, 12, 2030, "123")
|
||||||
require.NoError(t, err)
|
require.Error(t, err, "raw PAN must be rejected for production parity")
|
||||||
assert.Equal(t, "AMERICAN_EXPRESS", card.Brand)
|
assert.Nil(t, card)
|
||||||
assert.Equal(t, "0005", card.Last4)
|
assert.Contains(t, err.Error(), "raw card number input is not supported")
|
||||||
assert.True(t, card.IsDefault)
|
})
|
||||||
assert.True(t, card.Enabled)
|
|
||||||
assert.Equal(t, 12, card.ExpMonth)
|
|
||||||
assert.Equal(t, 2030, card.ExpYear)
|
|
||||||
assert.NotEmpty(t, card.CreatedAt)
|
|
||||||
assert.Greater(t, card.Version, int64(0))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDevClient_CreateCardOnFileRaw_Discover(t *testing.T) {
|
|
||||||
client := NewDevClient().(*MockClient)
|
|
||||||
card, err := client.CreateCardOnFileRaw(context.Background(), "user-raw-4", "6011111111111117", 12, 2030, "123")
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, "DISCOVER", card.Brand)
|
|
||||||
assert.Equal(t, "1117", card.Last4)
|
|
||||||
assert.True(t, card.IsDefault)
|
|
||||||
assert.True(t, card.Enabled)
|
|
||||||
assert.Equal(t, 12, card.ExpMonth)
|
|
||||||
assert.Equal(t, 2030, card.ExpYear)
|
|
||||||
assert.NotEmpty(t, card.CreatedAt)
|
|
||||||
assert.Greater(t, card.Version, int64(0))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDevClient_CreateCardOnFileRaw_UnknownBrand(t *testing.T) {
|
|
||||||
client := NewDevClient().(*MockClient)
|
|
||||||
card, err := client.CreateCardOnFileRaw(context.Background(), "user-raw-5", "9999999999999999", 12, 2030, "123")
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, "UNKNOWN", card.Brand)
|
|
||||||
assert.Equal(t, "9999", card.Last4)
|
|
||||||
assert.True(t, card.IsDefault)
|
|
||||||
assert.True(t, card.Enabled)
|
|
||||||
assert.Equal(t, 12, card.ExpMonth)
|
|
||||||
assert.Equal(t, 2030, card.ExpYear)
|
|
||||||
assert.NotEmpty(t, card.CreatedAt)
|
|
||||||
assert.Greater(t, card.Version, int64(0))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCreatePayment_ShouldFail(t *testing.T) {
|
func TestCreatePayment_ShouldFail(t *testing.T) {
|
||||||
@@ -479,40 +432,31 @@ func TestDevClient_CreateCardOnFileRaw_WithBrandDetection(t *testing.T) {
|
|||||||
userID := "user-raw-brand-detect"
|
userID := "user-raw-brand-detect"
|
||||||
|
|
||||||
card, err := client.CreateCardOnFileRaw(ctx, userID, "4111111111111111", 12, 2030, "123")
|
card, err := client.CreateCardOnFileRaw(ctx, userID, "4111111111111111", 12, 2030, "123")
|
||||||
require.NoError(t, err)
|
require.Error(t, err, "raw PAN must be rejected for production parity")
|
||||||
assert.Equal(t, "VISA", card.Brand)
|
assert.Nil(t, card)
|
||||||
assert.Equal(t, "1111", card.Last4)
|
|
||||||
assert.True(t, card.Enabled)
|
|
||||||
assert.Equal(t, 12, card.ExpMonth)
|
|
||||||
assert.Equal(t, 2030, card.ExpYear)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDevClient_CreateCardOnFile_RawNumber(t *testing.T) {
|
func TestDevClient_CreateCardOnFile_RejectsRawPAN(t *testing.T) {
|
||||||
client := NewDevClient().(*MockClient)
|
client := NewDevClient().(*MockClient)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
cardNum string
|
cardNum string
|
||||||
wantBrand string
|
|
||||||
wantLast4 string
|
|
||||||
}{
|
}{
|
||||||
{"visa formatted", "4111 1111 1111 1111", "VISA", "1111"},
|
{"visa formatted", "4111 1111 1111 1111"},
|
||||||
{"visa raw", "4111111111111111", "VISA", "1111"},
|
{"visa raw", "4111111111111111"},
|
||||||
{"mastercard", "5500 0000 0000 0004", "MASTERCARD", "0004"},
|
{"mastercard", "5500 0000 0000 0004"},
|
||||||
{"amex", "3400 0000 0000 009", "AMERICAN_EXPRESS", "0009"},
|
{"amex", "3400 0000 0000 009"},
|
||||||
{"discover", "6011 0000 0000 0004", "DISCOVER", "0004"},
|
{"discover", "6011 0000 0000 0004"},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
userID := fmt.Sprintf("user-raw-card-%s", tt.name)
|
userID := fmt.Sprintf("user-raw-card-%s", tt.name)
|
||||||
card, err := client.CreateCardOnFile(ctx, userID, tt.cardNum)
|
card, err := client.CreateCardOnFile(ctx, userID, tt.cardNum)
|
||||||
require.NoError(t, err)
|
require.Error(t, err, "raw PAN must be rejected for production parity")
|
||||||
assert.Equal(t, tt.wantBrand, card.Brand)
|
assert.Nil(t, card)
|
||||||
assert.Equal(t, tt.wantLast4, card.Last4)
|
|
||||||
assert.True(t, card.Enabled)
|
|
||||||
assert.NotEmpty(t, card.CardholderName)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -540,7 +484,7 @@ func TestDevClient_CreateCardOnFileRaw_TooShort(t *testing.T) {
|
|||||||
|
|
||||||
_, err := client.CreateCardOnFileRaw(ctx, "user-too-short", "123", 12, 2030, "999")
|
_, err := client.CreateCardOnFileRaw(ctx, "user-too-short", "123", 12, 2030, "999")
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
assert.Contains(t, err.Error(), "too short")
|
assert.Contains(t, err.Error(), "raw card number input is not supported")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDevClient_GetCardsOnFile_Empty(t *testing.T) {
|
func TestDevClient_GetCardsOnFile_Empty(t *testing.T) {
|
||||||
|
|||||||
Reference in New Issue
Block a user