Payment Fraud and Edge Case Testing for E-Commerce Applications

Payment Fraud and Edge Case Testing for E-Commerce Applications

Payment fraud takes many forms — card testing attacks, negative amount injection, currency confusion, duplicate charge races, and webhook replay. Standard happy-path payment tests don't catch these scenarios. This guide covers testing adversarial and edge-case payment flows that expose real fraud and reliability vulnerabilities.

Card Testing Attack Prevention

Card testing is when fraudsters use your checkout to validate stolen card numbers — they attempt small charges to find valid cards before using them for larger fraud. Your system must detect and block this pattern.

// tests/fraud/card-testing.test.ts
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import request from 'supertest';
import app from '~/app';

describe('Card testing detection', () => {
  const userHeaders = { Authorization: 'Bearer user-token' };
  
  it('blocks rapid card attempts from the same IP', async () => {
    const ip = '192.168.1.100';
    const results: number[] = [];
    
    // Simulate 5 rapid payment attempts from same IP
    for (let i = 0; i < 5; i++) {
      const res = await request(app)
        .post('/api/payments/create-intent')
        .set({ ...userHeaders, 'X-Forwarded-For': ip })
        .send({ amount: 100, currency: 'usd' });
      
      results.push(res.status);
    }
    
    // After threshold (e.g., 3 attempts), subsequent ones should be rate-limited
    const blockedCount = results.filter((s) => s === 429).length;
    expect(blockedCount).toBeGreaterThan(0);
  });

  it('blocks repeated declined card attempts for the same user', async () => {
    const results: number[] = [];
    
    // 3 declines in a row
    for (let i = 0; i < 3; i++) {
      // Mock stripe to return a decline
      await request(app)
        .post('/api/payments/confirm')
        .set(userHeaders)
        .send({ 
          paymentIntentId: `pi_declined_${i}`,
          // Stripe test card that always declines
          paymentMethodId: 'pm_card_chargeDeclined',
        });
    }
    
    // 4th attempt should be blocked entirely
    const finalRes = await request(app)
      .post('/api/payments/create-intent')
      .set(userHeaders)
      .send({ amount: 100, currency: 'usd' });
    
    expect(finalRes.status).toBe(429);
    expect(finalRes.body.error).toMatch(/too many declined/i);
  });

  it('flags suspiciously small amounts (card testing pattern)', async () => {
    // Card testers often use $0.01 or $1.00 amounts
    const res = await request(app)
      .post('/api/payments/create-intent')
      .set(userHeaders)
      .send({ amount: 1, currency: 'usd' }); // $0.01
    
    // Should either block or flag for review
    // Your policy determines whether to block outright or add to fraud queue
    expect([200, 402, 403]).toContain(res.status);
    
    if (res.status === 200) {
      // If allowed, must be flagged for fraud review
      expect(res.body.fraud_review).toBe(true);
    }
  });
});

Negative Amount and Zero Amount Injection

Attackers attempt to create negative charges to get refunds or credits fraudulently:

// tests/fraud/amount-validation.test.ts
describe('Amount validation', () => {
  it('rejects negative amounts', async () => {
    const res = await request(app)
      .post('/api/payments/create-intent')
      .set(userHeaders)
      .send({ amount: -2999, currency: 'usd' });
    
    expect(res.status).toBe(400);
    expect(res.body.error).toMatch(/invalid amount/i);
  });

  it('rejects zero amounts', async () => {
    const res = await request(app)
      .post('/api/payments/create-intent')
      .set(userHeaders)
      .send({ amount: 0, currency: 'usd' });
    
    expect(res.status).toBe(400);
  });

  it('rejects amounts below minimum charge threshold', async () => {
    // Stripe minimum is $0.50 for USD
    const res = await request(app)
      .post('/api/payments/create-intent')
      .set(userHeaders)
      .send({ amount: 30, currency: 'usd' }); // $0.30
    
    expect(res.status).toBe(400);
    expect(res.body.error).toMatch(/minimum/i);
  });

  it('rejects amounts exceeding maximum', async () => {
    const res = await request(app)
      .post('/api/payments/create-intent')
      .set(userHeaders)
      .send({ amount: 99999999, currency: 'usd' }); // $999,999.99
    
    expect(res.status).toBe(400);
    expect(res.body.error).toMatch(/maximum/i);
  });

  it('ignores client-provided amount in favor of server-calculated amount', async () => {
    // Classic attack: modify the amount in the POST body
    // The server should calculate the amount from the cart, not trust the client
    const res = await request(app)
      .post('/api/checkout')
      .set(userHeaders)
      .send({ 
        cartId: 'cart_123',
        amount: 1, // Attacker sets $0.01 for a $299 product
      });
    
    // Server should use cart total, not client-provided amount
    expect(res.status).toBe(200);
    
    // Verify the payment intent was created with the CORRECT server-side amount
    const paymentIntent = res.body.paymentIntent;
    expect(paymentIntent.amount).toBe(29900); // $299.00 in cents
  });

  it('rejects non-integer amounts (floating point injection)', async () => {
    const res = await request(app)
      .post('/api/payments/create-intent')
      .set(userHeaders)
      .send({ amount: 29.99, currency: 'usd' }); // Should be 2999 (cents)
    
    // Either reject or correctly handle as cents
    if (res.status === 200) {
      // If accepted, verify it wasn't interpreted as dollars
      expect(res.body.amount).not.toBe(2999); // $29.99 != $0.30
    }
  });
});

Currency Edge Cases

Currency confusion can allow users to pay in a weaker currency for a product priced in a stronger one:

// tests/fraud/currency-validation.test.ts
describe('Currency validation', () => {
  it('rejects currencies not supported by the product', async () => {
    const res = await request(app)
      .post('/api/checkout')
      .set(userHeaders)
      .send({
        productId: 'prod_usd_only',
        currency: 'irr', // Iranian Rial — ~0.002 USD
      });
    
    expect(res.status).toBe(400);
    expect(res.body.error).toMatch(/currency not supported/i);
  });

  it('uses server-determined currency, not client-provided', async () => {
    // Attacker sends a weaker currency hoping the amount stays the same
    const res = await request(app)
      .post('/api/checkout')
      .set(userHeaders)
      .send({
        cartId: 'cart_usd_product',
        currency: 'jpy', // Yen — 100 JPY ≈ $0.67 USD
        amount: 2999, // This is $29.99 USD but only ¥2999 = ~$20
      });
    
    // Should use the product's configured currency (USD), not the client's suggestion
    if (res.status === 200) {
      expect(res.body.currency).toBe('usd');
      expect(res.body.amount).toBe(2999); // $29.99 in USD cents
    }
  });

  it('handles zero-decimal currencies correctly', async () => {
    // JPY, KRW, etc. don't use decimal places — 100 JPY is 100, not 10000
    const res = await request(app)
      .post('/api/payments/create-intent')
      .set(userHeaders)
      .send({ amount: 3000, currency: 'jpy' }); // ¥3000 (not ¥30.00)
    
    expect(res.status).toBe(200);
    // Stripe should receive amount: 3000 for JPY, not 300000
    expect(res.body.stripe_amount).toBe(3000);
  });
});

Duplicate Charge Race Conditions

Duplicate charges happen when users double-click submit or network retries fire while a payment is processing:

// tests/fraud/duplicate-charge.test.ts
import { describe, it, expect } from 'vitest';

describe('Duplicate charge prevention', () => {
  it('idempotency key prevents duplicate charges on retry', async () => {
    const idempotencyKey = `order_123_attempt_1`;
    
    // First request
    const res1 = await request(app)
      .post('/api/payments/create-intent')
      .set({ ...userHeaders, 'Idempotency-Key': idempotencyKey })
      .send({ amount: 2999, currency: 'usd', orderId: 'order_123' });
    
    expect(res1.status).toBe(200);
    const intentId1 = res1.body.id;
    
    // Second request with same idempotency key (simulates retry)
    const res2 = await request(app)
      .post('/api/payments/create-intent')
      .set({ ...userHeaders, 'Idempotency-Key': idempotencyKey })
      .send({ amount: 2999, currency: 'usd', orderId: 'order_123' });
    
    expect(res2.status).toBe(200);
    // Must return the SAME payment intent, not a new one
    expect(res2.body.id).toBe(intentId1);
  });

  it('blocks concurrent checkout attempts for the same cart', async () => {
    const cartId = 'cart_concurrent_test';
    
    // Fire two checkout requests simultaneously
    const [res1, res2] = await Promise.all([
      request(app)
        .post('/api/checkout')
        .set(userHeaders)
        .send({ cartId }),
      request(app)
        .post('/api/checkout')
        .set(userHeaders)
        .send({ cartId }),
    ]);
    
    const statuses = [res1.status, res2.status].sort();
    
    // One should succeed, one should fail with conflict
    expect(statuses).toContain(200);
    expect(statuses).toContain(409); // Conflict — checkout already in progress
  });

  it('prevents double-click charge via payment intent status check', async () => {
    const paymentIntentId = 'pi_already_succeeded';
    
    // Mock payment intent as already succeeded
    // Attempt to confirm it again
    const res = await request(app)
      .post('/api/payments/confirm')
      .set(userHeaders)
      .send({ paymentIntentId, paymentMethodId: 'pm_card_visa' });
    
    // Should not re-charge — return the existing success state
    expect([200, 400]).toContain(res.status);
    if (res.status === 200) {
      expect(res.body.status).toBe('succeeded');
      // Not charged again
    }
    if (res.status === 400) {
      expect(res.body.error).toMatch(/already succeeded/i);
    }
  });
});

Webhook Replay Attack Prevention

Stripe webhooks must have their signatures verified and timestamps checked to prevent replay attacks:

// tests/fraud/webhook-replay.test.ts
import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const secret = process.env.STRIPE_WEBHOOK_SECRET!;

describe('Webhook replay attack prevention', () => {
  it('rejects webhook with timestamp older than 5 minutes', async () => {
    const payload = JSON.stringify({
      type: 'payment_intent.succeeded',
      data: { object: { id: 'pi_123', metadata: { order_id: 'ord_123' } } },
    });

    // Generate signature with a timestamp from 10 minutes ago
    const oldTimestamp = Math.floor(Date.now() / 1000) - 600;
    const signature = stripe.webhooks.generateTestHeaderString({
      payload,
      secret,
      timestamp: oldTimestamp,
    });

    const res = await request(app)
      .post('/webhooks/stripe')
      .set('Stripe-Signature', signature)
      .set('Content-Type', 'application/json')
      .send(payload);

    // Stripe's constructEvent rejects events with timestamp > 5min old
    expect(res.status).toBe(400);
  });

  it('rejects replayed webhooks with duplicate event IDs', async () => {
    const eventId = 'evt_replay_test_unique_123';
    const payload = JSON.stringify({
      id: eventId,
      type: 'payment_intent.succeeded',
      data: { object: { id: 'pi_123', metadata: { order_id: 'ord_123' } } },
    });

    const timestamp = Math.floor(Date.now() / 1000);
    const signature = stripe.webhooks.generateTestHeaderString({
      payload,
      secret,
      timestamp,
    });

    const headers = {
      'Stripe-Signature': signature,
      'Content-Type': 'application/json',
    };

    // First delivery — should succeed
    const res1 = await request(app)
      .post('/webhooks/stripe')
      .set(headers)
      .send(payload);

    expect(res1.status).toBe(200);

    // Replay — same event ID — should be deduplicated
    const res2 = await request(app)
      .post('/webhooks/stripe')
      .set(headers)
      .send(payload);

    // Should return 200 (idempotent) but NOT process twice
    expect(res2.status).toBe(200);

    // Verify the order was only marked paid once
    const order = await getOrderById('ord_123');
    expect(order.paymentProcessedCount).toBe(1);
  });
});

Refund Fraud Prevention

// tests/fraud/refund.test.ts
describe('Refund fraud prevention', () => {
  it('prevents refund on orders belonging to other users', async () => {
    const victimToken = await getAuthToken({ userId: 'victim-user' });
    const attackerToken = await getAuthToken({ userId: 'attacker-user' });
    
    // Create order as victim
    const order = await createOrder({ userId: 'victim-user', amount: 5000 });
    
    // Attacker attempts to refund victim's order
    const res = await request(app)
      .post(`/api/orders/${order.id}/refund`)
      .set('Authorization', `Bearer ${attackerToken}`)
      .send({ amount: 5000 });
    
    expect(res.status).toBe(403);
  });

  it('prevents refund for amount exceeding original charge', async () => {
    const order = await createOrder({ userId: 'user-123', amount: 2999 });
    await markOrderPaid(order.id, 'pi_test');
    
    const res = await request(app)
      .post(`/api/orders/${order.id}/refund`)
      .set(userHeaders)
      .send({ amount: 5000 }); // More than the $29.99 charged
    
    expect(res.status).toBe(400);
    expect(res.body.error).toMatch(/exceeds original/i);
  });

  it('prevents double refund', async () => {
    const order = await createOrder({ userId: 'user-123', amount: 2999 });
    await markOrderPaid(order.id, 'pi_test');
    
    // First refund
    await request(app)
      .post(`/api/orders/${order.id}/refund`)
      .set(userHeaders)
      .send({ amount: 2999 });
    
    // Second refund attempt
    const res = await request(app)
      .post(`/api/orders/${order.id}/refund`)
      .set(userHeaders)
      .send({ amount: 2999 });
    
    expect(res.status).toBe(400);
    expect(res.body.error).toMatch(/already refunded/i);
  });
});

Summary

Payment fraud and edge case testing requires:

  • Card testing detection — rate limit rapid attempts by IP and user; flag suspiciously small amounts
  • Amount validation — reject negative, zero, and client-provided amounts; always calculate server-side
  • Currency validation — reject unsupported currencies and currency substitution attacks
  • Duplicate charge prevention — idempotency keys for retries, pessimistic locking for concurrent checkouts
  • Webhook replay protection — timestamp validation (Stripe's built-in) plus event ID deduplication in your DB
  • Refund fraud — ownership checks, amount ceiling enforcement, double-refund prevention

These aren't theoretical attacks — they're exploited regularly against real payment systems. Each scenario here represents a class of fraud observed in production. Automated tests are the only way to verify your defenses hold across deployments and code changes.

Read more