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.