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,92 @@
|
||||
/**
|
||||
* Tests fuer den Abbruch einer offenen Zahlung. Kernpunkt ist die
|
||||
* Reihenfolge: erst bei Stripe nachsehen, dann zurueckbuchen — sonst liesse
|
||||
* sich eine bereits bezahlte Bestellung stornieren und doppelt verkaufen.
|
||||
*/
|
||||
import { describe, test, expect, beforeEach, vi } from "vitest";
|
||||
|
||||
const sessionsRetrieve = vi.fn();
|
||||
const sessionsExpire = vi.fn();
|
||||
vi.mock("@/lib/stripe", () => ({
|
||||
getStripeClient: () => ({
|
||||
checkout: { sessions: { retrieve: sessionsRetrieve, expire: sessionsExpire } },
|
||||
}),
|
||||
}));
|
||||
|
||||
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 releaseUnpaidOrder = vi.fn();
|
||||
vi.mock("@/lib/order-release", () => ({
|
||||
releaseUnpaidOrder: (...args: unknown[]) => releaseUnpaidOrder(...args),
|
||||
}));
|
||||
|
||||
const db = { order: { findUnique: vi.fn() } };
|
||||
vi.mock("@/lib/prisma", () => ({ prisma: db }));
|
||||
|
||||
const { releaseCheckout } = await import("./checkout-release");
|
||||
|
||||
const TOKEN = "b".repeat(64);
|
||||
const ORDER = {
|
||||
id: "order_1",
|
||||
orderNumber: "WH-2026-AABBCCDD",
|
||||
status: "NEU",
|
||||
stripeCheckoutSessionId: "cs_test_1",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
rateLimit.mockReturnValue(true);
|
||||
db.order.findUnique.mockResolvedValue(ORDER);
|
||||
sessionsRetrieve.mockResolvedValue({ status: "open" });
|
||||
sessionsExpire.mockResolvedValue({});
|
||||
releaseUnpaidOrder.mockResolvedValue({ orderNumber: ORDER.orderNumber });
|
||||
vi.spyOn(console, "info").mockImplementation(() => {});
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
describe("releaseCheckout", () => {
|
||||
test("schliesst eine offene Sitzung erst und gibt danach den Bestand frei", async () => {
|
||||
await releaseCheckout(TOKEN);
|
||||
|
||||
expect(sessionsExpire).toHaveBeenCalledWith("cs_test_1");
|
||||
expect(releaseUnpaidOrder).toHaveBeenCalledWith(ORDER.id, "delete");
|
||||
expect(sessionsExpire.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
releaseUnpaidOrder.mock.invocationCallOrder[0]
|
||||
);
|
||||
});
|
||||
|
||||
test("laesst eine bei Stripe bereits bezahlte Bestellung unangetastet", async () => {
|
||||
sessionsRetrieve.mockResolvedValue({ status: "complete" });
|
||||
await releaseCheckout(TOKEN);
|
||||
expect(sessionsExpire).not.toHaveBeenCalled();
|
||||
expect(releaseUnpaidOrder).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("gibt nichts frei, wenn das Schliessen bei Stripe scheitert", async () => {
|
||||
sessionsExpire.mockRejectedValue(new Error("stripe down"));
|
||||
await expect(releaseCheckout(TOKEN)).resolves.toBeUndefined();
|
||||
expect(releaseUnpaidOrder).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("ignoriert Tokens im falschen Format, ohne die Datenbank zu fragen", async () => {
|
||||
await releaseCheckout("WH-2026-AABBCCDD");
|
||||
expect(db.order.findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("tut nichts bei einer nicht mehr offenen Bestellung", async () => {
|
||||
db.order.findUnique.mockResolvedValue({ ...ORDER, status: "BEZAHLT" });
|
||||
await releaseCheckout(TOKEN);
|
||||
expect(sessionsRetrieve).not.toHaveBeenCalled();
|
||||
expect(releaseUnpaidOrder).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("bremst viele Abbrueche derselben Adresse", async () => {
|
||||
rateLimit.mockReturnValue(false);
|
||||
await releaseCheckout(TOKEN);
|
||||
expect(db.order.findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user