Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
12 changes: 12 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
39 changes: 39 additions & 0 deletions scripts/e2e-suite.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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]}")/.."

Expand All @@ -27,13 +32,17 @@ 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
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
Expand All @@ -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" \
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading