diff --git a/.env.example b/.env.example index bfc709b..015a6d5 100644 --- a/.env.example +++ b/.env.example @@ -123,6 +123,17 @@ MOLLIE_WEBHOOK_URL= # without a test_ prefix. MOLLIE_API_KEY_TEST= +# Test-mode Stripe platform key, read by scripts/e2e-suite.sh for the unmocked MINT +# E2E (tests/e2e/connect-mint.spec.ts), which creates REAL Stripe connected accounts +# and deletes every one of them afterwards. Never read by the app, which reads +# STRIPE_API_KEY. The suite hard-refuses anything without an sk_test_ prefix, and +# falls back to STRIPE_SECRET_KEY only when that is itself an sk_test_ key. Unset -> +# the mint spec skips with a stated reason; it never passes quietly. +# +# Needs `Connect -> write`, like the control plane's own key. NEVER a live key here: +# a live connected account is a real business's KYC record, not a fixture. +STRIPE_API_KEY_TEST= + # --- Sofra's own registration details, printed on every invoice (B0/B4) --- # Owner inputs (plan §8.1). Until ALL of these are set, NO invoice is issued — # deliberately: a placeholder KVK number on a real invoice is worse than no diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e48fbf6..a0081d8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -580,6 +580,18 @@ jobs: ""|test_*) ;; *) echo "MOLLIE_API_KEY_TEST is not a test_ key — refusing" >&2; exit 1 ;; esac + # Same refusal, one provider over, and the stake is higher: the mint spec + # CREATES Stripe connected accounts. A test-mode account is deletable and the + # spec deletes every one it makes; a live account is a real business's KYC + # record. So a non-sk_test_ value fails the job rather than reaching a runner. + - name: Refuse a non-test Stripe key + env: + STRIPE_API_KEY: ${{ secrets.STRIPE_API_KEY_TEST }} + run: | + case "$STRIPE_API_KEY" in + ""|sk_test_*) ;; + *) echo "STRIPE_API_KEY_TEST is not an sk_test_ key — refusing" >&2; exit 1 ;; + esac - name: Run smoke # Billing E2E runs against the REAL Mollie API when a test_ key is # available. Optional on purpose: with no secret the billing spec SKIPS @@ -591,6 +603,12 @@ jobs: # `pull_request_target`. env: MOLLIE_API_KEY: ${{ secrets.MOLLIE_API_KEY_TEST }} + # The mint E2E (tests/e2e/connect-mint.spec.ts) runs against the REAL Stripe + # API when an sk_test_ key is available, and deletes every account it creates + # — asserted, not best-effort. Optional the same way the Mollie key is: with + # no secret the spec SKIPS with a stated reason. Until the secret exists, the + # chain is proven by a local run of scripts/e2e-suite.sh and nothing more. + STRIPE_API_KEY: ${{ secrets.STRIPE_API_KEY_TEST }} # Mollie validates webhook reachability at payment creation and 422s a # localhost URL, so real payments cannot be created from a runner without # this. The spec POSTs the real payment id to the local handler itself; diff --git a/CLAUDE.md b/CLAUDE.md index 480989e..085572c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -67,7 +67,7 @@ Output before writing code: (1) which `require*()` guard covers each new surface - **CI** (`.github/workflows/ci.yml`, on every PR to `develop`/`main` + on push to `main`; a `develop` push run was dropped 2026-08-16 as a bit-identical duplicate of the release PR's run; `cache-warm.yml` runs on `push: develop` in its place and does nothing but populate ci.yml's `.next`/Playwright cache keys, because Actions caches are ref-scoped and a PR reads only its own scope + its base branch's): typecheck · eslint · next build · prisma migrations-apply + drift check · **vitest unit + coverage floor** · **i18n parity (6 locales)** · **file-length** · **playwright login smoke** (sharded 2 ways since 2026-08-17 — `--shard=i/2` on separate runners, so the whole suite still runs on every PR; the per-shard checks are suffixed and the REQUIRED context is the aggregate `playwright (login smoke)` job, green iff both shards pass) · gitleaks · TruffleHog · Trivy fs · npm audit · OSV · semgrep; weekly `security-audit.yml`. SonarCloud autoscan (no CI job — don't add one). - **Review gate ACTIVE in this repo** (since 2026-07-07): Stop + PreToolUse + PostToolUse (file-length checker) hooks (`.claude/settings.json`) + git pre-push → workspace `scripts/review-gate/` with the `sofra.md` overlay. No `--no-verify`, no bypasses. -- **E2E, unmocked** — `npm run test:e2e:full` (`scripts/e2e-suite.sh`) stands up a throwaway Postgres, migrates, seeds, builds and runs the whole Playwright suite: the O2 self-serve funnel (`tests/e2e/self-serve-signup.spec.ts`, 12 tests) and **a real Mollie first payment on the `test_` key** (`tests/e2e/billing-mollie.spec.ts`). Three things to know before touching it: (1) the suite refuses to start on a `live_` key and the billing spec **skips with a stated reason** when no test key is present — it never silently passes; (2) it uses its own registry fixture, because since O2 an unreadable registry makes the signup fail **closed**, so an absent one is not a neutral condition; (3) each test gets a unique `x-forwarded-for` (`tests/e2e/helpers/fixtures.ts`) because the intake allows only 5 POSTs/IP/15min — the header, not a loosened limit, is how the suite avoids rate-limiting itself, and the worker index must be part of it or parallel workers collide. Mollie cannot call localhost (it validates reachability and 422s), so `MOLLIE_WEBHOOK_URL` points at an inert sink and the spec POSTs the real `tr_` id to the local handler — fetch-and-verify still runs against the real API; only the delivery hop is stood in for. +- **E2E, unmocked** — `npm run test:e2e:full` (`scripts/e2e-suite.sh`) stands up a throwaway Postgres, migrates, seeds, builds and runs the whole Playwright suite: the O2 self-serve funnel (`tests/e2e/self-serve-signup.spec.ts`, 12 tests), **a real Mollie first payment on the `test_` key** (`tests/e2e/billing-mollie.spec.ts`) and **a real Stripe Connect mint on an `sk_test_` key** (`tests/e2e/connect-mint.spec.ts` — `mintForProposal` → `createExpressAccount` → `recordConnectAccount` → the fields a registry entry carries, the chain that had never run in one piece anywhere because staging has no `PROVISION_GITHUB_TOKEN`; it creates REAL connected accounts and **deletes every one**, asserted, in an `afterAll` that inventories the database AND Stripe rather than what a test remembered to report). Three things to know before touching it: (1) the suite refuses to start on a `live_`/non-`sk_test_` key and the billing and mint specs **skip with a stated reason** when no test key is present — they never silently pass; (2) it uses its own registry fixture, because since O2 an unreadable registry makes the signup fail **closed**, so an absent one is not a neutral condition; (3) each test gets a unique `x-forwarded-for` (`tests/e2e/helpers/fixtures.ts`) because the intake allows only 5 POSTs/IP/15min — the header, not a loosened limit, is how the suite avoids rate-limiting itself, and the worker index must be part of it or parallel workers collide. Mollie cannot call localhost (it validates reachability and 422s), so `MOLLIE_WEBHOOK_URL` points at an inert sink and the spec POSTs the real `tr_` id to the local handler — fetch-and-verify still runs against the real API; only the delivery hop is stood in for. - **`/api/health`** (`app/api/health/route.ts`) — public, unauthenticated, **dependency-free**: `{status, service, version, builtAt}`, where `version` is the commit the image was baked from (Dockerfile `ARG BUILD_SHA` ← `build-image.yml`, which then asserts the baked value matches `github.sha`). It exists so a deployed environment can be told apart from a months-old one; everything else about a stale image looks healthy. `status: "ok"` means *this process serves HTTP* and **not** that the DB is up — liveness and readiness are separate on purpose, because pinging Postgres from a public unauthenticated route is a DoS lever. Do not add fields: `tests/e2e/health.spec.ts` pins the payload to those four keys precisely so a DB status or a Mollie mode cannot be added quietly. The Docker HEALTHCHECK deliberately still probes `/en` — nothing declares `depends_on: service_healthy`, so the probe's only reader is a human, and a rendered page proves strictly more. - **`indexing-monitor.yml`** (daily) — guards that production stays crawlable (robots.txt **and** no de-indexing header; the two live in different places and can disagree) and that every non-canonical copy stays hidden. `robots.txt` is **baked**, not runtime — `app/robots.ts` keys off `NEXT_PUBLIC_SITE_URL`, a *build* arg — so a wrong posture takes a rebuild, not a box `.env` edit. - **E2E against DEPLOYED staging** — `npm run test:e2e:staging` (`tests/e2e/staging-live.spec.ts`) runs against `https://staging.sofrapiwas.com`. Disjoint from the local suite by design: it covers only what cannot exist until something is deployed — the box `.env` reaching the container with the *right* values, the founder-run `:migrate-staging` one-off, Caddy + TLS, and that the deployed bake is the staging one. **Read-only by construction** (no account, no payment, no row), so there is nothing to restore. Needs `STAGING_ADMIN='{email: …, password: …}'` in the gitignored `.env` — **the quotes are load-bearing**, because `scripts/e2e-suite.sh` sources that same file with `set -a && . ./.env` and bash reads an unquoted brace value as an assignment plus a command, killing the whole local suite under `set -euo pipefail`. Two things it deliberately does NOT prove, so don't read them into a green run: that the deployed image is *current* (no health/version endpoint — a months-old `:staging` bake passes), and that the Mollie key is `test_` rather than `live_` (`mollieConfigured()` reports only that some key is set). diff --git a/prisma/schema.prisma b/prisma/schema.prisma index da18e48..1a86da5 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -4,6 +4,18 @@ generator client { provider = "prisma-client" output = "../lib/generated/prisma" + // The module system this package actually is. package.json declares no `type`, + // so every .ts in this repo is CommonJS — but the generator was emitting an + // ESM-only client (`globalThis["__dirname"] = path.dirname(fileURLToPath( + // import.meta.url))` at the top of client.ts), which only a bundler can load. + // Next bundles, so the app never noticed; anything that does NOT bundle could + // not import `lib/db.ts` at all, which is why the mint chain had no way to be + // exercised outside a browser request (tests/e2e/connect-mint.spec.ts). Saying + // `cjs` makes the generated code match the package it is generated into. + // Nothing is committed — lib/generated/ is gitignored and rebuilt by + // `prisma generate` on every build, so this changes an OUTPUT FORMAT, not an + // artifact anyone reviews. + moduleFormat = "cjs" } datasource db { diff --git a/scripts/e2e-suite.sh b/scripts/e2e-suite.sh index 6973abc..775672f 100755 --- a/scripts/e2e-suite.sh +++ b/scripts/e2e-suite.sh @@ -11,6 +11,11 @@ # Billing (CLAUDE.md §9): this refuses to run against a `live_` key. It reads # MOLLIE_API_KEY_TEST from .env; without it the billing specs SKIP loudly rather # than pass quietly, and the rest of the suite still runs. +# +# Payments (same rule, sharper): the mint spec CREATES Stripe connected accounts, so +# it runs on an `sk_test_` key only — STRIPE_API_KEY_TEST, or a STRIPE_SECRET_KEY that +# is already a test key. Without one it SKIPS with a reason. Every account it creates +# is deleted by the spec itself, and the deletion is asserted. set -euo pipefail cd "$(dirname "${BASH_SOURCE[0]}")/.." @@ -27,6 +32,7 @@ trap cleanup EXIT # without touching Mollie at all (and how the skip path itself gets tested). # Without this, sourcing .env would silently put the key back. PRESET_MOLLIE="${MOLLIE_API_KEY_TEST-__unset__}" +PRESET_STRIPE="${STRIPE_API_KEY_TEST-__unset__}" if [[ -f .env ]]; then # shellcheck disable=SC1091 set -a && . ./.env && set +a @@ -34,6 +40,9 @@ fi if [[ "$PRESET_MOLLIE" != "__unset__" ]]; then MOLLIE_API_KEY_TEST="$PRESET_MOLLIE" fi +if [[ "$PRESET_STRIPE" != "__unset__" ]]; then + STRIPE_API_KEY_TEST="$PRESET_STRIPE" +fi MOLLIE_KEY="${MOLLIE_API_KEY_TEST:-}" if [[ -n "$MOLLIE_KEY" && "$MOLLIE_KEY" != test_* ]]; then echo "refusing to run: MOLLIE_API_KEY_TEST is not a test_ key" >&2 @@ -43,6 +52,32 @@ if [[ -z "$MOLLIE_KEY" ]]; then echo "note: no MOLLIE_API_KEY_TEST — the billing specs will skip" >&2 fi +# ── secrets: test Stripe key only, never the live one ─────────────────────── +# Same shape as Mollie above, and for a sharper reason: tests/e2e/connect-mint.spec.ts +# CREATES Stripe connected accounts, which are real objects with a KYC surface. A +# test-mode one can be deleted afterwards (the spec asserts it); a live one belongs to +# a real business. So an explicitly-named *_TEST value that is not `sk_test_` kills the +# run rather than being ignored — an operator who set it meant to exercise this. +# +# The fallback to STRIPE_SECRET_KEY is deliberate and is FILTERED, not refused: that is +# the name a developer's .env already uses, and without the fallback the mint spec would +# skip on every machine that has a perfectly good test key sitting right there — a gap +# that closes itself quietly, which is the failure this spec exists to prevent. A live +# value under that name is simply not used (the suite is not the place to fail someone's +# whole run over a variable they did not aim at us), and the spec then skips WITH A +# REASON. +STRIPE_KEY="${STRIPE_API_KEY_TEST:-}" +if [[ -n "$STRIPE_KEY" && "$STRIPE_KEY" != sk_test_* ]]; then + echo "refusing to run: STRIPE_API_KEY_TEST is not an sk_test_ key" >&2 + exit 1 +fi +if [[ -z "$STRIPE_KEY" && "${STRIPE_SECRET_KEY:-}" == sk_test_* ]]; then + STRIPE_KEY="$STRIPE_SECRET_KEY" +fi +if [[ -z "$STRIPE_KEY" ]]; then + echo "note: no sk_test_ Stripe key — the connect-mint spec will skip" >&2 +fi + echo "→ throwaway postgres on :$DB_PORT" cleanup docker run -d --rm --name "$CONTAINER" \ @@ -121,6 +156,10 @@ export SOFRA_INVOICE_SERIES="E2E" # fails on this value — and that failure is the proof it passed. export PROVISION_GITHUB_TOKEN="ghp_e2e_placeholder_never_valid_0000000000" export MOLLIE_API_KEY="$MOLLIE_KEY" +# The platform Stripe key the mint spec (and the app) read. Only ever an sk_test_ +# value by the time it gets here — see the refusal above. Empty is a valid state: +# tests/e2e/connect-mint.spec.ts then skips with a stated reason instead of passing. +export STRIPE_API_KEY="$STRIPE_KEY" # A developer's real Resend key in .env/.env.local would otherwise fire a live # API call per signup — sending test addresses to a third party and making the # run depend on their uptime. Blanking it here wins (process env beats .env diff --git a/tests/e2e/connect-mint.spec.ts b/tests/e2e/connect-mint.spec.ts new file mode 100644 index 0000000..ec316e9 --- /dev/null +++ b/tests/e2e/connect-mint.spec.ts @@ -0,0 +1,359 @@ +import { expect, test } from "./helpers/fixtures"; +import { mintForProposal } from "@/lib/provisioning-mint"; +import { RUN_ID, uniq } from "./helpers/flows"; +import { + findConnectAccounts, + findConnectAccountsForRun, + forgetConnectAccount, +} from "./helpers/db"; + +// The MINT CHAIN, run in one piece for the first time (ADR-011 amendment, E1–E4). +// +// `mintForProposal -> createExpressAccount -> recordConnectAccount -> the fields a +// registry entry carries` was unit-tested, mutation-tested, and every Stripe call in +// it had been probed BY HAND — but the chain itself had never executed end to end in +// any environment (BACKLOG: "The mint path has NEVER run end to end anywhere"). +// `sofra-staging` cannot rehearse it: `provisioningConfigured()` is +// `Boolean(process.env.PROVISION_GITHUB_TOKEN)` and that variable is absent there, so +// `openProvisioningPr` refuses before it reaches the mint. Without this file the first +// ever run of the hand-offs would be a real restaurant's account. +// +// So this exercises everything BELOW `openProvisioningPr` — the GitHub half is not +// here, deliberately: it writes to the deploy repo, which is not a throwaway. +// +// Nothing is mocked (CLAUDE.md §7). Real `POST /v1/accounts` against the REAL Stripe +// API on an `sk_test_` key, the suite's throwaway Postgres, and the suite's own running +// server for the locale claim. Every account created here is a real Stripe object and +// is DELETED in `afterAll`, whatever the tests did. +// +// TEST MODE ONLY, and the reason is not squeamishness. A test-mode account can be +// deleted; Stripe refuses to delete a LIVE account "that has access to the standard +// dashboard and [for which] Stripe is responsible for negative account balances". +// Express happens to have neither property (measured on the live platform 2026-09-05, +// `deleted: true` then GET -> 403), so the refusal would not actually fire — but a live +// connected account is a real KYC/compliance object attached to a real business, and a +// suite that creates one on every run has no business existing. Hence the guard below, +// `scripts/e2e-suite.sh`'s hard refusal of anything but `sk_test_`, and CI's. + +const stripeKey = process.env.STRIPE_API_KEY ?? ""; + +/** + * Why this file may not run, or null when it may. + * + * Applied from INSIDE each test body rather than at describe scope, for the reason + * `billing-mollie.spec.ts` gives: a describe-level `test.skip(cond, reason)` reads to a + * static analyser as a permanently-disabled test, which is the opposite of what it is — + * a runtime decision that reports itself. Playwright marks the run SKIPPED either way, + * never passed. + */ +const skipReason = (() => { + if (!stripeKey) { + return "STRIPE_API_KEY is not set — export STRIPE_API_KEY_TEST and run scripts/e2e-suite.sh"; + } + if (!stripeKey.startsWith("sk_test_")) { + return "STRIPE_API_KEY is not an sk_test_ key — refusing to mint connected accounts (CLAUDE.md §9)"; + } + return null; +})(); + +/** The slice of Stripe's Account object this file reads back. */ +type StripeAccount = { + id: string; + type?: string; + country?: string; + email?: string; + business_profile?: { name?: string; url?: string; mcc?: string }; + capabilities?: Record; + metadata?: Record; + deleted?: boolean; +}; + +/** + * Stripe, called DIRECTLY rather than through `lib/stripe.ts`. + * + * Two reasons, and both are about not letting the test share the code under test. + * `stripeGet` throws on a non-2xx, and the status IS the assertion here (a deleted + * account answers 403). And `DELETE /v1/accounts` is a capability the application must + * never have: nothing in this app may destroy a restaurant's payment account, so it + * stays in the test process where the only accounts it can reach are test-mode ones. + */ +async function stripeApi( + method: "GET" | "DELETE", + path: string, +): Promise<{ status: number; body: T }> { + const res = await fetch(`https://api.stripe.com${path}`, { + method, + headers: { Authorization: `Bearer ${stripeKey}` }, + }); + return { status: res.status, body: (await res.json()) as T }; +} + +/** + * The account ids Stripe itself holds for a tenant slug, read from + * `metadata[sofra_tenant]` — the tag `expressAccountForm` sets on every mint. + * + * This is the only oracle that can see the failure that matters: a SECOND live account + * for one restaurant. The database cannot, because `tenantSlug` is unique there — a + * duplicate mint would show up as one row and one orphan at Stripe. + * + * `limit=100` with no cursor is enough: the list is newest-first, and anything this run + * created is at the front of it. + */ +async function stripeAccountsFor(slug: string): Promise { + const { status, body } = await stripeApi<{ data?: StripeAccount[] }>( + "GET", + "/v1/accounts?limit=100", + ); + expect(status, "listing the platform's connected accounts").toBe(200); + return (body.data ?? []) + .filter((a) => a.metadata?.sofra_tenant === slug) + .map((a) => a.id); +} + +/** + * The tenant minted by the first test, kept so the later tests can use the SAME listing + * call as a positive control. "No account was created for this slug" is an empty + * result, and an empty result from an instrument that can no longer see anything reads + * identically — so every such claim below is paired with a slug that must be found. + */ +let control: { slug: string; account: string } | null = null; + +/** The modules a tenant that bought card payments carries. `online-payments` is the + * one that pairs with an account (ACCOUNT_PAIRED_MODULE_IDS); `core` rides along + * because every tenant has it, so the mint has to find its trigger in a list rather + * than in the only element. */ +const PAID_MODULES = ["core", "online-payments"]; + +/** The input two tests share, minus the parts each varies. */ +function proposal(slug: string, over: { currency: string; modules: string[] }) { + return { + slug, + name: "Chez Mint", + adminEmail: `${slug}@example.test`, + url: `https://${slug}.sofrapiwas.com`, + ...over, + }; +} + +// SERIAL, and it is a real dependency rather than tidiness: the second and third tests +// use the first test's account as the positive control for the Stripe listing. If the +// mint fails there is no control, and Playwright skipping the rest is the correct +// outcome — better than two tests that "pass" on an instrument nothing proved awake. +test.describe.configure({ mode: "serial" }); + +test.describe("minting a tenant's Stripe Connect account", () => { + // A real account creation, a read-back, a replay and a listing — all network, none of + // it under our control. The config's 30s default is not a budget for that. + test.setTimeout(120_000); + + // NOT best-effort, unlike the Mollie spec's teardown. There a failed cleanup leaves a + // test subscription charging into a dead sink; here it leaves a live-shaped connected + // account, on a platform, with a KYC surface and an onboarding link that anyone + // holding the token can open. So the deletion is ASSERTED, and asserted for every + // account before the first failure is reported — a loop that threw on account one + // would leak accounts two and three. + // + // The inventory is taken from the DATABASE and from STRIPE, not from what a test + // remembered to hand over: the account that most needs deleting is the one created by + // a test that then failed on the next line. The Stripe half also covers the one case + // the database cannot — a mint that succeeded and whose row write did not. + test.afterAll(async () => { + if (skipReason !== null) return; + + const rows = await findConnectAccountsForRun(RUN_ID); + const listed = await stripeApi<{ data?: StripeAccount[] }>("GET", "/v1/accounts?limit=100"); + expect(listed.status, "cleanup could not list the platform's accounts").toBe(200); + const tagged = (listed.body.data ?? []) + .filter((a) => (a.metadata?.sofra_tenant ?? "").endsWith(RUN_ID)) + .map((a) => a.id); + const ids = [...new Set([...rows.map((r) => r.stripeAccountId), ...tagged])]; + + const outcomes: Array<{ id: string; deleted: boolean | null; afterGet: number | null; error?: string }> = []; + for (const id of ids) { + try { + const del = await stripeApi<{ deleted?: boolean }>("DELETE", `/v1/accounts/${id}`); + const after = await stripeApi("GET", `/v1/accounts/${id}`); + outcomes.push({ id, deleted: del.body.deleted ?? null, afterGet: after.status }); + } catch (e) { + outcomes.push({ id, deleted: null, afterGet: null, error: String(e) }); + } + } + + console.log(`e2e cleanup: ${outcomes.length} connected account(s) — ${JSON.stringify(outcomes)}`); + for (const o of outcomes) { + expect(o.deleted, `deleting ${o.id} did not answer deleted:true (${o.error ?? "no error"})`).toBe(true); + // The deletion, confirmed by something other than the deletion's own reply. + expect(o.afterGet, `${o.id} is still readable after being deleted`).toBe(403); + } + }); + + test("a CHF tenant that bought online-payments is minted, recorded, and replayable", async ({ + request, + }) => { + test.skip(skipReason !== null, skipReason ?? ""); + + const slug = uniq.slug("mint"); + const input = proposal(slug, { currency: "CHF", modules: PAID_MODULES }); + + // ── the mint ────────────────────────────────────────────────────────── + const first = await mintForProposal(input); + expect(first.note ?? null, "the mint must not have been refused").toBeNull(); + expect(first.stripeAccount ?? "", "the account id the registry entry would carry").toMatch( + /^acct_[A-Za-z0-9]+$/, + ); + const account = first.stripeAccount!; + control = { slug, account }; + + // ── the link that goes into the registry entry ──────────────────────── + // Absolute (the URL constructor throws otherwise) and UNPREFIXED. The prefix + // matters because this value is copied into a box `.env` and then shown to + // everyone at the restaurant: baking `/fr/` in would pick a language, once and + // permanently, for a Swiss tenant whose staff read French and whose owner reads + // German. Asserting the first segment is `onboarding` refuses ANY prefix, not just + // the six in i18n/routing.ts. + const link = new URL(first.paymentsLinkUrl ?? ""); + expect(link.pathname.split("/")[1], "the stored URL must carry no locale prefix").toBe( + "onboarding", + ); + expect(link.pathname).toMatch(/^\/onboarding\/payments\/[A-Za-z0-9_-]{20,}$/); + expect(link.origin, "minted against this environment's own base").toBe( + new URL(process.env.NEXTAUTH_URL ?? "").origin, + ); + + // ── and the unprefixed URL really does serve two readers ────────────── + // The claim above is about a string; this is the behaviour. Redirects are NOT + // followed: the middleware's own Location header is the answer, and following it + // would mint a real Stripe Account Link for a page nobody is looking at. + const fr = await request.get(link.toString(), { + maxRedirects: 0, + headers: { "accept-language": "fr" }, + }); + const de = await request.get(link.toString(), { + maxRedirects: 0, + headers: { "accept-language": "de" }, + }); + expect(fr.headers().location, "a French reader is sent to the French page").toContain( + "/fr/onboarding/payments/", + ); + expect(de.headers().location, "a German reader is sent to the German page").toContain( + "/de/onboarding/payments/", + ); + expect(fr.headers().location, "one URL, two answers").not.toBe(de.headers().location); + + // ── the row that survives a crash before the registry PR ────────────── + const rows = await findConnectAccounts(slug); + expect(rows, "exactly one StripeConnectAccount row").toHaveLength(1); + expect(rows[0].stripeAccountId).toBe(account); + expect(rows[0].onboardingToken, "a row with no token is unreachable by its own page").not.toBeNull(); + expect(link.pathname.endsWith(rows[0].onboardingToken ?? "\u0000"), "the URL addresses THIS row").toBe(true); + expect(rows[0].country, "derived from CHF, and immutable at Stripe afterwards").toBe("CH"); + // The convention spelled out rather than imported: a changed key is a new live + // account, so the value is the assertion. + expect(rows[0].idempotencyKey).toBe(`${slug}-connect-express-v1`); + + // ── what Stripe actually holds ──────────────────────────────────────── + // The hand-off nobody had ever observed: that the payload this chain composes + // arrives as the account the registry entry then names. + const got = await stripeApi("GET", `/v1/accounts/${account}`); + expect(got.status).toBe(200); + expect(got.body.type, "Express — the type is fixed at creation").toBe("express"); + expect(got.body.country).toBe("CH"); + expect(got.body.metadata?.sofra_tenant, "the tag that traces an account to a tenant").toBe(slug); + expect(got.body.business_profile?.url).toBe(input.url); + expect(got.body.business_profile?.mcc, "eating places").toBe("5812"); + // Requested TOGETHER in the create call: omitting one fails quietly, and + // card_payments is refused without transfers. + for (const capability of ["card_payments", "transfers", "twint_payments"]) { + expect( + Object.keys(got.body.capabilities ?? {}), + `${capability} must have been requested at creation — it cannot be added by update`, + ).toContain(capability); + } + + // ── IDEMPOTENCY 1: the ordinary re-run reads our own row ────────────── + const second = await mintForProposal(input); + expect(second.stripeAccount, "a second proposal must not mint a second account").toBe(account); + expect(second.paymentsLinkUrl, "and must not re-issue the link already recorded").toBe( + first.paymentsLinkUrl, + ); + expect(await findConnectAccounts(slug), "still one row").toHaveLength(1); + + // ── IDEMPOTENCY 2: the crash this whole table exists for ────────────── + // The window is real: the account exists at Stripe the moment `POST /v1/accounts` + // returns, and the row is written after it. Deleting the row reproduces exactly + // that state, and it is the only way to make the next attempt go to STRIPE again — + // which is what proves the recovery is the idempotency key rather than the row. + expect(await forgetConnectAccount(slug), "the crash, arranged").toBe(1); + const replay = await mintForProposal(input); + expect(replay.stripeAccount, "a replay after a crash must RECOVER the account, not mint a twin").toBe( + account, + ); + expect(await findConnectAccounts(slug), "and re-record it once").toHaveLength(1); + // The link is NOT the same one: the token is minted with the row, so the recovered + // row carries a new one. Harmless precisely because this window closes before the + // registry PR is composed — nothing has published the first token yet. It would not + // be harmless later, which is why nothing else deletes this row. + expect(replay.paymentsLinkUrl).not.toBe(first.paymentsLinkUrl); + + // The claim the database cannot make: Stripe holds ONE account for this tenant. + expect(await stripeAccountsFor(slug), "one tenant, one live account").toEqual([account]); + }); + + test("a tenant that did not buy online-payments mints nothing", async () => { + test.skip(skipReason !== null, skipReason ?? ""); + expect(control, "needs the first test's account as a positive control").not.toBeNull(); + + const slug = uniq.slug("cash"); + const result = await mintForProposal(proposal(slug, { currency: "CHF", modules: ["core"] })); + + // `{}` and not a note: nothing was attempted, so there is nothing to report to a + // founder. A note here would put "no Stripe account" in the PR body of every + // cash-only restaurant we ever provision. + expect(result, "no account, no link, and nothing to explain").toEqual({}); + expect(await findConnectAccounts(slug)).toHaveLength(0); + expect(await stripeAccountsFor(slug), "and no live account at Stripe").toEqual([]); + // ...proven to be a real answer rather than a blind instrument. + expect(await stripeAccountsFor(control!.slug), "positive control").toEqual([control!.account]); + }); + + test("EUR refuses BEFORE Stripe is called, and the refusal says why", async () => { + test.skip(skipReason !== null, skipReason ?? ""); + expect(control, "needs the first test's account as a positive control").not.toBeNull(); + + const eurSlug = uniq.slug("eur"); + const chfSlug = uniq.slug("ctl"); + const key = process.env.STRIPE_API_KEY; + + // A DELIBERATELY BROKEN key, for the length of these two calls. It is what makes + // "no network call" observable rather than asserted from reading the source: a call + // that reached Stripe would come back 401 and say so in the note. The CHF case + // below is the control that proves the broken key really does surface — without it, + // "the note is not a 401" would also be true of a key that still worked. + let eur: Awaited>; + let chf: Awaited>; + try { + process.env.STRIPE_API_KEY = "sk_test_deliberately_invalid_key_for_this_control"; + eur = await mintForProposal(proposal(eurSlug, { currency: "EUR", modules: PAID_MODULES })); + chf = await mintForProposal(proposal(chfSlug, { currency: "CHF", modules: PAID_MODULES })); + } finally { + process.env.STRIPE_API_KEY = key; + } + + // The control first: with this key, anything that REACHES Stripe is refused. + expect(chf.stripeAccount ?? null, "the control must not have minted anything").toBeNull(); + expect(chf.note ?? "", "the broken key is visible when the call is made").toMatch( + /401|invalid api key/i, + ); + + // So EUR, answering with a country refusal instead, never got that far. + expect(eur.stripeAccount ?? null).toBeNull(); + expect(eur.note ?? "", "seven countries share EUR, and Stripe fixes the country forever").toMatch( + /EUR does not name one country/, + ); + expect(eur.note ?? "").not.toMatch(/401|invalid api key/i); + + expect(await findConnectAccounts(eurSlug), "no row for a refused currency").toHaveLength(0); + expect(await stripeAccountsFor(eurSlug), "and no account at Stripe").toEqual([]); + expect(await stripeAccountsFor(control!.slug), "positive control").toEqual([control!.account]); + }); +}); diff --git a/tests/e2e/helpers/db.ts b/tests/e2e/helpers/db.ts index cbe87f2..3b8dfa2 100644 --- a/tests/e2e/helpers/db.ts +++ b/tests/e2e/helpers/db.ts @@ -748,3 +748,57 @@ export async function arrangeBillingIdentityFor( [userId, opts.legalName, opts.tradeName ?? null, opts.addressLine1], ); } + + +/** One `StripeConnectAccount` row — the sibling table that holds a MINTED + * connected account between the Stripe call and the registry PR (E1). */ +export type ConnectAccountRow = { + tenantSlug: string; + stripeAccountId: string; + idempotencyKey: string; + country: string; + onboardingToken: string | null; +}; + +/** Rows for one slug. Read directly rather than through + * `findConnectAccountForSlug`, so the spec's oracle is not the same code path + * the mint used to write it — a reader that filtered the row out would + * otherwise make a missing row and a hidden row indistinguishable. */ +export async function findConnectAccounts(tenantSlug: string): Promise { + return await query( + `SELECT "tenantSlug", "stripeAccountId", "idempotencyKey", country, "onboardingToken" + FROM "StripeConnectAccount" WHERE "tenantSlug" = $1`, + [tenantSlug], + ); +} + +/** Every row this run left behind, whatever the slug — the cleanup's own + * inventory. Keyed on the run's namespace (`uniq.slug` ends every slug with + * RUN_ID) rather than on what a test remembered to report, because the account + * that most needs deleting is the one created by a test that then failed. A + * suffix match rather than `e2e-%`, so a run can never delete another run's + * accounts if this is ever pointed at a database it does not own. */ +export async function findConnectAccountsForRun(runId: string): Promise { + return await query( + `SELECT "tenantSlug", "stripeAccountId", "idempotencyKey", country, "onboardingToken" + FROM "StripeConnectAccount" WHERE "tenantSlug" LIKE '%' || $1 ORDER BY "tenantSlug"`, + [runId], + ); +} + +/** + * Drop the row for a slug and report how many went — the CRASH, arranged. + * + * The window this simulates is the one `StripeConnectAccount` exists for: the + * account is live at Stripe and the process died before the row was written. + * Deleting the row afterwards puts the system in exactly that state, which is + * the only way to make the next mint go to Stripe again and prove the + * idempotency key (not the row) is what recovers the account. + */ +export async function forgetConnectAccount(tenantSlug: string): Promise { + const rows = await query<{ tenantSlug: string }>( + `DELETE FROM "StripeConnectAccount" WHERE "tenantSlug" = $1 RETURNING "tenantSlug"`, + [tenantSlug], + ); + return rows.length; +}