Save products you love by clicking the heart icon.
Eine gründliche Evaluierung von SOGo 6, der Next.js/Flask-Groupware-Suite, betrieben mit Stalwart Mailserver und OpenLDAP via Docker Compose. Behandelt Architektur, Feature-Implementierung, SSO, MFA und >1.800 bestandene Tests.
A practical guide to scaling Nextcloud, OpenDesk, and similar self-hosted cloud platforms through four growth tiers, with concrete configurations and architecture patterns.
Stripe makes payments easy to integrate. Testing those payments reliably is another matter entirely. A single missed edge case — an unpaid session recorded as a purchase, a webhook replayed twice, or a download URL controlled by the client — can cost you real money and real customer trust.
This guide covers the complete Stripe testing paradigm, distilled from building and hardening a production checkout system with digital downloads, physical orders, and subscription billing.
Stripe operates in two modes, toggled in the Dashboard:
Key insight: Your server code doesn't change between modes. The only difference is which API keys you use. This means your test infrastructure should mirror production exactly — same webhook handlers, same idempotency logic, same validation.
Test card numbers (like 4242424242424242) work in two scenarios:
sk_test_*) — always work, whether through API or Checkoutsk_live_*) — accepted by the API (session creation succeeds), but rejected by Stripe Checkout's hosted payment page at the point of paymentThis means you can create checkout sessions programmatically with live keys and test cards, but the customer-facing payment step will fail. For end-to-end testing, always use test keys.
Not all test cards are equal. Stripe provides a rich set of cards to simulate specific scenarios:
| Card Number | Scenario | Expected Result |
|---|---|---|
4242424242424242 | Standard Visa | Payment succeeds |
4000056655665556 | Visa debit | Payment succeeds |
5555555555554444 | Mastercard | Payment succeeds |
4000000000000002 | Generic decline | Payment declined |
4000000000009995 | Insufficient funds | Decline with insufficient_funds |
| Card Number | Scenario | Authentication |
|---|---|---|
4000002500003155 | 3DS2 required | Challenge with any code |
4000002760002762 | 3DS2 required | Frictionless auto-approve |
4000000000003220 | 3DS required, fails | Challenge always fails |
Pro tip: Test 3DS cards with payment_method_options[card][request_three_d_secure] = 'any' to ensure your flow handles authentication challenges.
| Card Number | Scenario | Why It Matters |
|---|---|---|
4000000000000069 | Expired card | Your error handling |
4000000000000127 | Incorrect CVC | Validation feedback |
4000000000000101 | Processing error | Retry logic |
4242424242424241 | Wrong checksum | Card validation |
4000020000000003 | Charge later (auth) | Async payments |
A typical Stripe checkout integration has four stages:
One of the most common bugs: the checkout.session.completed webhook fires for all checkout sessions — including unpaid ones. SEPA payments arrive with payment_status: "unpaid" initially. Subscription checkouts fire this event too.
// ❌ WRONG — records purchases for unpaid sessions
case "checkout.session.completed": {
const session = event.data.object;
// Missing: no check on payment_status or mode!
pushPurchase({ session_id: session.id, ... });
}
// ✅ CORRECT — only record completed payments
case "checkout.session.completed": {
const session = event.data.object;
if (session.mode !== "payment" || session.payment_status !== "paid") {
logger.info(`Skipping non-payment: ${session.id}`);
break;
}
pushPurchase({ session_id: session.id, ... });
}
A sophisticated attack: an attacker buys the cheapest product but overrides downloadUrl in the checkout request to point at an expensive product's file. The flow:
The fix: resolve download paths server-side from content frontmatter, ignoring client metadata entirely.
// Server-side resolver — single source of truth
export async function resolveProductDownloadUrl(slug: string) {
const content = await getContent("products", slug);
return content?.frontmatter?.download_url || null;
}
// In webhook: resolve from content, not from client metadata
const downloadUrl = await resolveProductDownloadUrl(item.slug);
const token = signDownloadToken({ product_slug: slug, download_url: downloadUrl });
Mock Stripe's webhook construction and test your handler logic in isolation:
// tests/unit/api/stripe/webhook.test.ts
vi.mock("@/lib/stripe", () => ({
constructWebhookEvent: mockConstructEvent,
}));
it("records purchase for paid checkout", async () => {
mockConstructEvent.mockReturnValue({
type: "checkout.session.completed",
data: {
object: {
mode: "payment",
payment_status: "paid",
id: "cs_test_123",
metadata: { productName: "Test", productSlug: "test" },
customer_details: { email: "buyer@test.com" },
},
},
});
const res = await POST(mockRequest("{}", "sig_abc"));
expect(res._status).toBe(200);
expect(mockPurchases).toHaveLength(1);
expect(mockSendMail).toHaveBeenCalled();
});
Coverage targets for webhook tests:
| Test Case | Why |
|---|---|
| Standard single-product purchase | Happy path |
| Multi-item cart purchase | Array handling, UNIQUE constraint |
| Subscription checkout (mode=subscription) | Should be skipped |
| Unpaid session (payment_status=unpaid) | Should be skipped |
| Duplicate webhook delivery | Idempotency |
| Missing metadata fields | Graceful degradation |
| Corrupt items JSON | Error handling |
| Physical/print orders | Shipping notification email |
| Email send failure | Error doesn't crash handler |
Install the Stripe CLI and forward real webhook events to your local server:
# Install (macOS)
brew install stripe/stripe-cli/stripe
# Login (gets your test-mode API key)
stripe login
# Forward webhooks to localhost:3000
stripe listen --forward-to localhost:3000/api/stripe/webhook
# This prints your webhook signing secret (whsec_...)
# Add it to your .env.local:
# STRIPE_WEBHOOK_SECRET=whsec_xxx
Then trigger a test event:
# Trigger a checkout completion event
stripe trigger checkout.session.completed
# Trigger a payment failure
stripe trigger payment_intent.payment_failed
# Trigger a subscription event
stripe trigger customer.subscription.updated
Test the entire flow: checkout → webhook → download, including state management across calls:
it("checkout → webhook → download flow succeeds", async () => {
// 1. Create checkout
const checkout = await createCheckout({ priceId: "price_test", ... });
expect(checkout._data.checkoutURL).toBeDefined();
// 2. Fire webhook (simulates Stripe calling your server)
mockConstructEvent.mockReturnValue({
type: "checkout.session.completed",
data: { object: { mode: "payment", payment_status: "paid", ... } },
});
const webhook = await webhookHandler(mockRequest({}, "sig"));
expect(webhook._status).toBe(200);
// 3. Download with signed token
const token = signDownloadToken({ session_id: "cs_123", ... });
mockRetrieve.mockResolvedValue({ payment_status: "paid" });
const download = await downloadHandler(mockDownloadRequest(token));
expect(download._status).toBe(200);
});
| Stage | Tests | Speed | Stripe Interaction |
|---|---|---|---|
| CI (PR) | Unit tests with mocks | ~20s | None (mocked) |
| Staging | Stripe CLI forwarding | ~2m | Real test-mode API |
| Production deploy | Smoke tests | ~10s | API-only, no payments |
| Manual | Full E2E with test card | ~5m | Full test-mode flow |
# .env.local (development)
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_test_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
# .env.production (production)
STRIPE_SECRET_KEY=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_live_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_...
Never commit real keys. Use .env.local (gitignored) for development and a secrets manager for production.
Always verify webhook signatures. Never trust raw webhook payloads.
import crypto from "crypto";
function constructWebhookEvent(body: string, signature: string) {
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;
if (!webhookSecret) throw new Error("Not configured");
const elements = signature.split(",");
const timestamp = elements.find((e) => e.startsWith("t="))?.slice(2);
const sig = elements.find((e) => e.startsWith("v1="))?.slice(3);
if (!timestamp || !sig) throw new Error("Invalid signature format");
// Reject events older than 5 minutes (replay protection)
const age = Math.floor(Date.now() / 1000) - parseInt(timestamp);
if (age > 300) throw new Error("Stale webhook");
const signedPayload = `${timestamp}.${body}`;
const expected = crypto.createHmac("sha256", webhookSecret).update(signedPayload).digest("hex");
if (sig !== expected) throw new Error("Signature mismatch");
return JSON.parse(body);
}
Webhooks can be delivered multiple times. Always check for duplicates:
// Before processing
const existing = loadPurchases();
if (existing.some((p) => p.session_id === session.id)) {
logger.info("Duplicate webhook — skipping");
break;
}
Rate-limit all Stripe-facing endpoints:
// Create checkout — external API call
const isRateLimited = createRateLimiter(60_000, 10); // 10 per minute per IP
Don't use Math.random() for OTPs. Use the Web Crypto API:
export function generateOTP(): string {
const buf = new Uint32Array(1);
crypto.getRandomValues(buf);
return String(100000 + (buf[0] % 900000));
}
Download tokens must be:
Stripe metadata values are limited to 500 characters. A cart with 20+ items can exceed this:
// ❌ Unbounded
metadata.items = JSON.stringify(items);
// ✅ Guard size
const itemsJson = JSON.stringify(items.slice(0, 20));
metadata.items = itemsJson.length > 490 ? itemsJson.slice(0, 487) + '..."' : itemsJson;
checkout.session.completed fires for BOTH payment and subscription checkouts. Your handler must check session.mode:
if (session.mode !== "payment") break; // subscriptions use customer.subscription.* events
customer.subscription.updated fires on any subscription change, including cancellation. Don't blindly clear canceled_at:
// ❌ Wrong — wipes cancellation date
canceled_at: undefined;
// ✅ Correct — preserve unless reactivated
canceled_at: sub.status === "active"
? undefined
: sub.canceled_at
? new Date(sub.canceled_at * 1000).toISOString()
: undefined;
If your rate limiter runs after request.json(), an attacker can exhaust your server's resources before being throttled:
// ❌ Wrong — body parsed before rate check
const body = await request.json();
if (isRateLimited(ip)) return 429;
// ✅ Correct — rate check before parsing
if (isRateLimited(ip)) return 429;
const body = await request.json();
In private browsing or when storage is blocked, localStorage.getItem() throws:
// ❌ Crashes in private browsing
const [email] = useState(() => localStorage.getItem(KEY));
// ✅ Safe
const [email] = useState(() => {
try {
return localStorage.getItem(KEY) || "";
} catch {
return "";
}
});
Run through this checklist before every release:
4242 succeeds end-to-end (Stripe CLI mode)4000000000000002 handled gracefullypayment_status: unpaid) is skippedmode: subscription) is skippedTesting Stripe integrations isn't optional — it's a security requirement. A single vulnerability in payment handling can lead to inventory theft, customer data exposure, or revenue loss.
The pattern is always the same: never trust client input, verify everything server-side, and test edge cases that Stripe's happy path documentation doesn't cover.
The investment in comprehensive testing pays for itself the first time a webhook is replayed, a card is declined, or a customer disputes a charge. Build the testing infrastructure once, run it on every commit, and ship payments with confidence.