feat: Stripe Embedded Checkout statt Weiterleitung zu checkout.stripe.com
Bezahlt wird jetzt direkt auf /checkout: die Checkout-Session laeuft mit ui_mode "embedded_page", das Zahlungsformular ist ein Stripe-iframe (PCI SAQ A bleibt). PayPal, Karte, SEPA und Klarna kommen weiterhin dynamisch aus dem Dashboard. - Zahlungsschritt ersetzt das Formular; "Zurueck zu deinen Angaben" gibt Sitzung und Bestand sofort frei (POST /api/checkout/cancel) und behaelt alle Eingaben, Turnstile wird neu geladen - Rueckkehrseite prueft serverseitig bei Stripe und gleicht die Sitzung mit der eigenen Bestellung ab; bei abgebrochener PayPal-Zahlung wird dieselbe Sitzung erneut eingebettet - Erneuter Versuch und Abbruch sind per HttpOnly-Cookie an den bestellenden Browser gebunden, die session_id in der URL allein reicht nicht - Alte /checkout/cancel?ref= Links aendern per GET nichts mehr - CSP um die Stripe-Hosts fuer Embedded Checkout und 3-D-Secure erweitert - STRIPE_PUBLISHABLE_KEY zur Laufzeit gelesen (kein NEXT_PUBLIC_), mit Preflight-Pruefung auf pk_live und Schutz gegen versehentliche sk_/rk_ Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
6ac7cc5d1f
commit
4ee5bafe20
28 files changed
+1272
-179
No files matched your search
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Tests fuer die Auswertung der Rueckkehr von Stripe. Die Adresszeile ist frei
|
||||
* waehlbar — "bezahlt" darf deshalb nur herauskommen, wenn Stripe es sagt und
|
||||
* die Sitzung zu einer eigenen Bestellung gehoert.
|
||||
*/
|
||||
import { describe, test, expect, beforeEach, vi } from "vitest";
|
||||
|
||||
const sessionsRetrieve = vi.fn();
|
||||
vi.mock("@/lib/stripe", () => ({
|
||||
getStripeClient: () => ({ checkout: { sessions: { retrieve: sessionsRetrieve } } }),
|
||||
}));
|
||||
|
||||
const rateLimit = vi.fn<(key: string, limit: number, windowMs: number) => boolean>(() => true);
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
rateLimit: (key: string, limit: number, windowMs: number) => rateLimit(key, limit, windowMs),
|
||||
getClientIp: vi.fn(async () => "203.0.113.7"),
|
||||
}));
|
||||
|
||||
const db = { order: { findFirst: vi.fn() } };
|
||||
vi.mock("@/lib/prisma", () => ({ prisma: db }));
|
||||
|
||||
const { resolveCheckoutReturn } = await import("./checkout-return");
|
||||
|
||||
const SESSION_ID = "cs_test_a1B2c3";
|
||||
const ORDER = {
|
||||
id: "order_1",
|
||||
orderNumber: "WH-2026-AABBCCDD",
|
||||
status: "NEU",
|
||||
cancelToken: "a".repeat(64),
|
||||
};
|
||||
|
||||
function stripeSession(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: SESSION_ID,
|
||||
status: "complete",
|
||||
payment_status: "paid",
|
||||
client_secret: "cs_test_a1B2c3_secret_x",
|
||||
metadata: { orderId: ORDER.id },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
rateLimit.mockReturnValue(true);
|
||||
db.order.findFirst.mockResolvedValue(ORDER);
|
||||
sessionsRetrieve.mockResolvedValue(stripeSession());
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
describe("resolveCheckoutReturn", () => {
|
||||
test("meldet eine bezahlte Sitzung mit der Bestellnummer aus der Datenbank", async () => {
|
||||
await expect(resolveCheckoutReturn(SESSION_ID)).resolves.toEqual({
|
||||
kind: "paid",
|
||||
orderNumber: ORDER.orderNumber,
|
||||
});
|
||||
});
|
||||
|
||||
test("meldet eine abgeschlossene, noch nicht eingegangene Zahlung als in Bearbeitung", async () => {
|
||||
sessionsRetrieve.mockResolvedValue(stripeSession({ payment_status: "unpaid" }));
|
||||
await expect(resolveCheckoutReturn(SESSION_ID)).resolves.toEqual({
|
||||
kind: "processing",
|
||||
orderNumber: ORDER.orderNumber,
|
||||
});
|
||||
});
|
||||
|
||||
test("bietet nach abgebrochener PayPal-Zahlung dem bestellenden Browser einen erneuten Versuch an", async () => {
|
||||
sessionsRetrieve.mockResolvedValue(stripeSession({ status: "open", payment_status: "unpaid" }));
|
||||
await expect(resolveCheckoutReturn(SESSION_ID, ORDER.cancelToken)).resolves.toEqual({
|
||||
kind: "open",
|
||||
clientSecret: "cs_test_a1B2c3_secret_x",
|
||||
cancelToken: ORDER.cancelToken,
|
||||
});
|
||||
});
|
||||
|
||||
test("gibt Zahlungsformular und Abbruch-Token nicht an einen fremden Browser mit derselben URL", async () => {
|
||||
// Die session_id steht in Verlauf, Proxy-Logs und Screenshots. Allein
|
||||
// darf sie keine fremde Bestellung oeffnen oder abbrechen.
|
||||
sessionsRetrieve.mockResolvedValue(stripeSession({ status: "open", payment_status: "unpaid" }));
|
||||
await expect(resolveCheckoutReturn(SESSION_ID)).resolves.toEqual({ kind: "notCompleted" });
|
||||
await expect(resolveCheckoutReturn(SESSION_ID, "f".repeat(64))).resolves.toEqual({
|
||||
kind: "notCompleted",
|
||||
});
|
||||
});
|
||||
|
||||
test("fragt bei einer fremd geformten ID weder Datenbank noch Stripe", async () => {
|
||||
await expect(resolveCheckoutReturn('cs_test_1"; DROP')).resolves.toEqual({ kind: "notCompleted" });
|
||||
await expect(resolveCheckoutReturn("pi_123")).resolves.toEqual({ kind: "notCompleted" });
|
||||
expect(db.order.findFirst).not.toHaveBeenCalled();
|
||||
expect(sessionsRetrieve).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("fragt Stripe gar nicht erst, wenn es keine passende Bestellung gibt", async () => {
|
||||
db.order.findFirst.mockResolvedValue(null);
|
||||
await expect(resolveCheckoutReturn(SESSION_ID)).resolves.toEqual({ kind: "notCompleted" });
|
||||
expect(sessionsRetrieve).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("glaubt keiner Sitzung, die laut Metadaten zu einer anderen Bestellung gehoert", async () => {
|
||||
sessionsRetrieve.mockResolvedValue(stripeSession({ metadata: { orderId: "order_other" } }));
|
||||
await expect(resolveCheckoutReturn(SESSION_ID)).resolves.toEqual({ kind: "notCompleted" });
|
||||
});
|
||||
|
||||
test("bietet eine abgelaufene Sitzung nicht zum Bezahlen an", async () => {
|
||||
sessionsRetrieve.mockResolvedValue(stripeSession({ status: "expired", payment_status: "unpaid" }));
|
||||
await expect(resolveCheckoutReturn(SESSION_ID)).resolves.toEqual({ kind: "notCompleted" });
|
||||
});
|
||||
|
||||
test("bremst viele Abfragen derselben Adresse, bevor Stripe gefragt wird", async () => {
|
||||
rateLimit.mockReturnValue(false);
|
||||
await expect(resolveCheckoutReturn(SESSION_ID)).resolves.toEqual({ kind: "notCompleted" });
|
||||
expect(sessionsRetrieve).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("vertraut bei einem Stripe-Ausfall dem vom Webhook bereits verbuchten Status", async () => {
|
||||
db.order.findFirst.mockResolvedValue({ ...ORDER, status: "BEZAHLT" });
|
||||
sessionsRetrieve.mockRejectedValue(new Error("ECONNRESET"));
|
||||
await expect(resolveCheckoutReturn(SESSION_ID)).resolves.toEqual({
|
||||
kind: "paid",
|
||||
orderNumber: ORDER.orderNumber,
|
||||
});
|
||||
});
|
||||
|
||||
test("behauptet bei einem Stripe-Ausfall und offener Bestellung keine Zahlung", async () => {
|
||||
sessionsRetrieve.mockRejectedValue(new Error("ECONNRESET"));
|
||||
await expect(resolveCheckoutReturn(SESSION_ID)).resolves.toEqual({ kind: "notCompleted" });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user