Files
Wabenhain/src/lib/checkout-release.test.ts
T
Nick SzaunigandClaude Opus 5.5 4ee5bafe20 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]>
2026-09-25 08:01:18 +02:00

93 lines
3.3 KiB
TypeScript

/**
* 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();
});
});