Files
scan-receipts/tests/e2e/webhook_verification.test.ts
Timo 84b9987c49 Add full application: receipt scanning, auth, billing, and account deletion
Brings the working codebase (Next.js app, auth system, Stripe billing,
Docker/deploy config, tests, docs) into version control on top of the
placeholder initial commit, and adds account self-deletion (Danger Zone
in Settings, password + typed-email confirmation, cascading DB cleanup,
Stripe cancellation) per GDPR right-to-erasure.

Excludes local build caches, node_modules, and internal agent scratch
files; .gitignore hardened to keep those out going forward.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 20:59:04 +02:00

178 lines
8.1 KiB
TypeScript

/**
* Webhook Verification Suite
*
* Pure decision logic for the Stripe webhook policy: event-type allowlist,
* payment-status gating, the amount cross-check against the REAL shared price
* catalog, plan validation and expiry derivation. Signature verification
* itself requires live Stripe secrets (HMAC of the raw body), so it is only
* ever exercised end-to-end by the running app — everything that decides
* whether a license may be granted is covered here, no database needed.
*/
import { describe, test, expect } from "./runner";
import {
ALLOWED_EVENT_TYPES,
isAllowedEventType,
isPaymentConfirmed,
sessionModeMatchesPlan,
subscriptionExpiresAt,
verifyPaidAmount,
} from "../../src/lib/billing/webhookPolicy";
import { getPlanConfig, isPlanId, resolvePlan } from "../../src/lib/billing/pricing";
describe("WebhookVerification — event type allowlist", () => {
test("all handled event types are allowed (including the fixed invoice.payment_succeeded)", () => {
expect(isAllowedEventType("checkout.session.completed")).toBe(true);
expect(isAllowedEventType("checkout.session.expired")).toBe(true);
expect(isAllowedEventType("invoice.payment_failed")).toBe(true);
expect(isAllowedEventType("invoice.payment_succeeded")).toBe(true);
expect(isAllowedEventType("customer.subscription.updated")).toBe(true);
expect(isAllowedEventType("customer.subscription.deleted")).toBe(true);
});
test("unhandled and unknown event types are rejected", () => {
expect(isAllowedEventType("charge.succeeded")).toBe(false);
expect(isAllowedEventType("payment_intent.succeeded")).toBe(false);
expect(isAllowedEventType("customer.created")).toBe(false);
expect(isAllowedEventType("invoice.created")).toBe(false);
expect(isAllowedEventType("")).toBe(false);
});
test("the allowlist is not accidentally empty", () => {
expect(ALLOWED_EVENT_TYPES.size).toBeGreaterThan(0);
// Every allowlisted type is a real Stripe event name pattern, never a wildcard.
expect(ALLOWED_EVENT_TYPES.has("*")).toBe(false);
});
});
describe("WebhookVerification — payment confirmation gating", () => {
test("payment-mode session is confirmed only when payment_status is paid", () => {
expect(isPaymentConfirmed({ mode: "payment", payment_status: "paid" }, null)).toBe(true);
expect(isPaymentConfirmed({ mode: "payment", payment_status: "unpaid" }, null)).toBe(false);
expect(isPaymentConfirmed({ mode: "payment", payment_status: "no_payment_required" }, null)).toBe(
false
);
expect(isPaymentConfirmed({ mode: "payment", payment_status: null }, null)).toBe(false);
});
test("subscription is confirmed while active or trialing", () => {
expect(isPaymentConfirmed({ mode: "subscription" }, { status: "active" })).toBe(true);
expect(isPaymentConfirmed({ mode: "subscription" }, { status: "trialing" })).toBe(true);
});
test("subscription is rejected when canceled, unpaid, past_due or missing", () => {
expect(isPaymentConfirmed({ mode: "subscription" }, { status: "canceled" })).toBe(false);
expect(isPaymentConfirmed({ mode: "subscription" }, { status: "unpaid" })).toBe(false);
expect(isPaymentConfirmed({ mode: "subscription" }, { status: "past_due" })).toBe(false);
expect(isPaymentConfirmed({ mode: "subscription" }, { status: "incomplete" })).toBe(false);
expect(isPaymentConfirmed({ mode: "subscription" }, null)).toBe(false);
expect(isPaymentConfirmed({ mode: "subscription" }, undefined)).toBe(false);
});
test("unknown or missing session modes are never confirmed", () => {
expect(isPaymentConfirmed({ mode: null, payment_status: "paid" }, null)).toBe(false);
expect(isPaymentConfirmed({ mode: "setup", payment_status: "paid" }, null)).toBe(false);
expect(isPaymentConfirmed({}, null)).toBe(false);
});
});
describe("WebhookVerification — amount cross-check against the real catalog", () => {
test("lifetime amount matching the catalog exactly is accepted", () => {
const session = {
mode: "payment",
payment_status: "paid",
amount_total: getPlanConfig("lifetime").unitAmountMinor,
};
expect(verifyPaidAmount(session, "lifetime")).toBe(true);
expect(verifyPaidAmount(session, getPlanConfig("lifetime").id)).toBe(true);
// The catalog really is the source: 5999 minor units for lifetime.
expect(getPlanConfig("lifetime").unitAmountMinor).toBe(5999);
});
test("one cent less than the catalog amount is rejected", () => {
expect(
verifyPaidAmount(
{ mode: "payment", payment_status: "paid", amount_total: getPlanConfig("lifetime").unitAmountMinor - 1 },
"lifetime"
)
).toBe(false);
});
test("a missing amount_total is rejected for one-time payments", () => {
expect(
verifyPaidAmount({ mode: "payment", payment_status: "paid", amount_total: null }, "lifetime")
).toBe(false);
});
test("subscription sessions are not amount-checked (trial amount may be 0)", () => {
expect(verifyPaidAmount({ mode: "subscription", amount_total: 0 }, "weekly")).toBe(true);
expect(verifyPaidAmount({ mode: "subscription", amount_total: 0 }, "annual")).toBe(true);
expect(verifyPaidAmount({ mode: "subscription", amount_total: 1 }, "weekly")).toBe(true);
});
});
describe("WebhookVerification — plan validation", () => {
test("known plan ids validate", () => {
expect(isPlanId("weekly")).toBe(true);
expect(isPlanId("annual")).toBe(true);
expect(isPlanId("lifetime")).toBe(true);
});
test("unknown and missing plans are rejected by isPlanId", () => {
expect(isPlanId("enterprise")).toBe(false);
expect(isPlanId("free")).toBe(false);
expect(isPlanId("")).toBe(false);
expect(isPlanId(null)).toBe(false);
expect(isPlanId(undefined)).toBe(false);
});
test("resolvePlan falls back to annual for unknown or absent values", () => {
expect(resolvePlan("lifetime")).toBe("lifetime");
expect(resolvePlan("weekly")).toBe("weekly");
expect(resolvePlan("annual")).toBe("annual");
expect(resolvePlan("bogus")).toBe("annual");
expect(resolvePlan(null)).toBe("annual");
expect(resolvePlan(undefined)).toBe("annual");
expect(resolvePlan("")).toBe("annual");
});
test("the session mode must match the plan's billing mode", () => {
expect(sessionModeMatchesPlan("payment", "lifetime")).toBe(true);
expect(sessionModeMatchesPlan("subscription", "weekly")).toBe(true);
expect(sessionModeMatchesPlan("subscription", "annual")).toBe(true);
expect(sessionModeMatchesPlan("subscription", "lifetime")).toBe(false);
expect(sessionModeMatchesPlan("payment", "annual")).toBe(false);
});
});
describe("WebhookVerification — expiry derivation from the subscription", () => {
test("expiresAt comes from current_period_end, never Date.now()", () => {
const periodEnd = 1_700_000_000; // fixed point in the past — must not shift
const expires = subscriptionExpiresAt({ status: "trialing", current_period_end: periodEnd });
expect(expires?.getTime()).toBe(periodEnd * 1000);
// A fixed period end must yield a fixed expiry regardless of when the test runs.
expect(Date.now()).toBeGreaterThan(periodEnd * 1000);
});
test("missing period end or missing subscription yields no expiry", () => {
expect(subscriptionExpiresAt({ status: "active", current_period_end: null })).toBeNull();
expect(subscriptionExpiresAt({ status: "active" })).toBeNull();
expect(subscriptionExpiresAt(null)).toBeNull();
expect(subscriptionExpiresAt(undefined)).toBeNull();
});
});
describe("WebhookVerification — catalog contract used by the webhook", () => {
test("lifetime is a one-time payment; weekly and annual are subscriptions", () => {
expect(getPlanConfig("lifetime").mode).toBe("payment");
expect(getPlanConfig("weekly").mode).toBe("subscription");
expect(getPlanConfig("annual").mode).toBe("subscription");
});
test("catalog amounts match the pricing guide (EUR minor units)", () => {
expect(getPlanConfig("lifetime").unitAmountMinor).toBe(5999);
expect(getPlanConfig("weekly").unitAmountMinor).toBe(499);
expect(getPlanConfig("annual").unitAmountMinor).toBe(3999);
});
});