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:
@@ -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) {
|
||||
log.Printf("[SQUARE-MOCK] CreateCardOnFile: user=%s", userID)
|
||||
|
||||
// Detect card info from the input token.
|
||||
// Raw card numbers (digit-only, possibly with spaces) are parsed directly.
|
||||
// Nonce-like tokens (cnon:xxx etc.) use detectCardInfo for mapped values.
|
||||
cleanDigits := strings.ReplaceAll(cardToken, " ", "")
|
||||
cardBrand := "VISA"
|
||||
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
|
||||
// Match the real Square API: source_id must be a token (cnon:xxx nonce or
|
||||
// ccof:xxx card ID). Raw PANs are rejected exactly as Square would, so the
|
||||
// mock behaves identically to production.
|
||||
if !isTokenLike(cardToken) {
|
||||
return nil, fmt.Errorf("invalid source_id: %q — use a card nonce (cnon:xxx) or card ID (ccof:xxx)", cardToken)
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
@@ -357,13 +336,14 @@ func (m *MockClient) CreateCardOnFile(ctx context.Context, userID, cardToken str
|
||||
|
||||
now := clock.Now().UTC()
|
||||
cardID := fmt.Sprintf("mock_card_%d", now.UnixNano())
|
||||
brand, last4 := detectCardInfo(cardToken)
|
||||
card := &CardOnFile{
|
||||
ID: cardID,
|
||||
CardID: fmt.Sprintf("ccof_mock_%d", now.UnixNano()),
|
||||
Brand: cardBrand,
|
||||
Last4: cardLast4,
|
||||
ExpMonth: cardExpMonth,
|
||||
ExpYear: cardExpYear,
|
||||
Brand: brand,
|
||||
Last4: last4,
|
||||
ExpMonth: 12,
|
||||
ExpYear: 2030,
|
||||
Fingerprint: fmt.Sprintf("sqfp_mock_%d", now.UnixNano()),
|
||||
CardholderName: "John Doe",
|
||||
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) {
|
||||
log.Printf("[SQUARE-MOCK] CreateCardOnFileRaw: user=%s", userID)
|
||||
|
||||
if len(cardNumber) < 4 {
|
||||
return nil, fmt.Errorf("invalid card number: too short")
|
||||
}
|
||||
|
||||
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
|
||||
// PCI-DSS parity with production: raw card numbers are never accepted.
|
||||
// The mock must behave identically to the ProdClient so dev testing does
|
||||
// not mask a production failure.
|
||||
return nil, fmt.Errorf("square: raw card number input is not supported in production — use CreateCardOnFile with a card nonce")
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// isAllDigits returns true if every rune in s is an ASCII digit.
|
||||
func isAllDigits(s string) bool {
|
||||
for _, r := range s {
|
||||
if r < '0' || r > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return len(s) > 0
|
||||
// isTokenLike returns true for Square source_id tokens: cnon:xxx nonces and
|
||||
// ccof:xxx card IDs. Raw PANs (all digits) are NOT token-like and are rejected.
|
||||
func isTokenLike(s string) bool {
|
||||
return strings.HasPrefix(s, "cnon:") || strings.HasPrefix(s, "ccof:")
|
||||
}
|
||||
|
||||
func realBaseURL(env string) string {
|
||||
|
||||
Reference in New Issue
Block a user