diff --git a/backend/handlers/payments/payments_test.go b/backend/handlers/payments/payments_test.go index 111a5e9..651fc35 100644 --- a/backend/handlers/payments/payments_test.go +++ b/backend/handlers/payments/payments_test.go @@ -2153,24 +2153,10 @@ func TestCreatePaymentMethod_HappyPath(t *testing.T) { w := makePaymentRequest(handler, "POST", "/api/user/payment-methods", reqBody, token, ctx) - if w.Code != http.StatusOK { - t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) - return - } - - 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") + // 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. + if w.Code != http.StatusInternalServerError { + t.Errorf("expected status 500 (raw PAN rejected), got %d. body: %s", w.Code, w.Body.String()) } } @@ -2296,7 +2282,8 @@ func TestCreatePaymentMethod_SecondCardNotDefault(t *testing.T) { 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 reqBody := CreatePaymentMethodRequest{ CardNumber: "4111111111111111", @@ -2305,31 +2292,8 @@ func TestCreatePaymentMethod_SecondCardNotDefault(t *testing.T) { } w := makePaymentRequest(handler, "POST", "/api/user/payment-methods", reqBody, token, ctx) - if w.Code != http.StatusOK { - t.Fatalf("failed to create first card: %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") + if w.Code != http.StatusInternalServerError { + t.Fatalf("expected 500 (raw PAN rejected), got %d. body: %s", w.Code, w.Body.String()) } } diff --git a/backend/internal/square/square_dev.go b/backend/internal/square/square_dev.go index 695b4d0..67f1525 100644 --- a/backend/internal/square/square_dev.go +++ b/backend/internal/square/square_dev.go @@ -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 { diff --git a/backend/internal/square/square_dev_test.go b/backend/internal/square/square_dev_test.go index 2b6065a..151868e 100644 --- a/backend/internal/square/square_dev_test.go +++ b/backend/internal/square/square_dev_test.go @@ -237,79 +237,32 @@ func TestDevClient_GetCheckout_NotFound(t *testing.T) { require.Error(t, err) } -func TestDevClient_CreateCardOnFileRaw_Visa(t *testing.T) { - client := NewDevClient().(*MockClient) - card, err := client.CreateCardOnFileRaw(context.Background(), "user-raw-1", "4111111111111111", 12, 2030, "123") - 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) { +func TestDevClient_CreateCardOnFileRaw_Rejected_ProdParity(t *testing.T) { + // PCI-DSS parity: the mock must reject raw PANs exactly like the + // ProdClient, so dev testing cannot mask a production failure. client := NewDevClient().(*MockClient) ctx := context.Background() - // First card to set up non-default check - _, err := client.CreateCardOnFileRaw(ctx, "user-raw-2", "4111111111111111", 12, 2030, "123") - require.NoError(t, err) - // Mastercard is second → not default - card, err := client.CreateCardOnFileRaw(ctx, "user-raw-2", "5555555555554444", 12, 2030, "123") - require.NoError(t, err) - assert.Equal(t, "MASTERCARD", card.Brand) - assert.Equal(t, "4444", card.Last4) - assert.False(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_Amex(t *testing.T) { - client := NewDevClient().(*MockClient) - card, err := client.CreateCardOnFileRaw(context.Background(), "user-raw-3", "378282246310005", 12, 2030, "123") - require.NoError(t, err) - assert.Equal(t, "AMERICAN_EXPRESS", card.Brand) - assert.Equal(t, "0005", 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)) -} + tests := []struct { + name string + cardNumber string + }{ + {"visa", "4111111111111111"}, + {"mastercard", "5555555555554444"}, + {"amex", "378282246310005"}, + {"discover", "6011111111111117"}, + {"unknown brand", "9999999999999999"}, + {"too short", "123"}, + } -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)) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + card, err := client.CreateCardOnFileRaw(ctx, "user-raw-"+tt.name, tt.cardNumber, 12, 2030, "123") + require.Error(t, err, "raw PAN must be rejected for production parity") + assert.Nil(t, card) + assert.Contains(t, err.Error(), "raw card number input is not supported") + }) + } } func TestCreatePayment_ShouldFail(t *testing.T) { @@ -479,40 +432,31 @@ func TestDevClient_CreateCardOnFileRaw_WithBrandDetection(t *testing.T) { userID := "user-raw-brand-detect" card, err := client.CreateCardOnFileRaw(ctx, userID, "4111111111111111", 12, 2030, "123") - require.NoError(t, err) - assert.Equal(t, "VISA", card.Brand) - assert.Equal(t, "1111", card.Last4) - assert.True(t, card.Enabled) - assert.Equal(t, 12, card.ExpMonth) - assert.Equal(t, 2030, card.ExpYear) + require.Error(t, err, "raw PAN must be rejected for production parity") + assert.Nil(t, card) } -func TestDevClient_CreateCardOnFile_RawNumber(t *testing.T) { +func TestDevClient_CreateCardOnFile_RejectsRawPAN(t *testing.T) { client := NewDevClient().(*MockClient) ctx := context.Background() tests := []struct { - name string - cardNum string - wantBrand string - wantLast4 string + name string + cardNum string }{ - {"visa formatted", "4111 1111 1111 1111", "VISA", "1111"}, - {"visa raw", "4111111111111111", "VISA", "1111"}, - {"mastercard", "5500 0000 0000 0004", "MASTERCARD", "0004"}, - {"amex", "3400 0000 0000 009", "AMERICAN_EXPRESS", "0009"}, - {"discover", "6011 0000 0000 0004", "DISCOVER", "0004"}, + {"visa formatted", "4111 1111 1111 1111"}, + {"visa raw", "4111111111111111"}, + {"mastercard", "5500 0000 0000 0004"}, + {"amex", "3400 0000 0000 009"}, + {"discover", "6011 0000 0000 0004"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { userID := fmt.Sprintf("user-raw-card-%s", tt.name) card, err := client.CreateCardOnFile(ctx, userID, tt.cardNum) - require.NoError(t, err) - assert.Equal(t, tt.wantBrand, card.Brand) - assert.Equal(t, tt.wantLast4, card.Last4) - assert.True(t, card.Enabled) - assert.NotEmpty(t, card.CardholderName) + require.Error(t, err, "raw PAN must be rejected for production parity") + assert.Nil(t, card) }) } } @@ -540,7 +484,7 @@ func TestDevClient_CreateCardOnFileRaw_TooShort(t *testing.T) { _, err := client.CreateCardOnFileRaw(ctx, "user-too-short", "123", 12, 2030, "999") 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) {