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]>
93 lines
3.3 KiB
TypeScript
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();
|
|
});
|
|
});
|