You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Part of the apps/api → Hono migration epic #644. Phase 0-pre: blocks #646 (and gates every later phase). Ships on Next.js today; the suite itself is the migration's parity proof.
Why
A coverage assessment found the migration's blast radius is exactly the least-tested code in the repo:
packages/api router logic is well covered (20 integration test files, unmocked router via createRouterClient, real Postgres) — that harness is framework-agnostic and survives the migration untouched.
But auth resolution has zero end-to-end tests: packages/api/src/__tests__/setup.ts mocks auth() to return a finished session, which early-returns past everything in getSession (packages/api/src/shared.ts:172-295) — the RS256 JWT-via-JWKS path, the DB API-key lookup, the Client-header rule, cookie decoding, and the dev-mock branch are never exercised. No RS256 test keypair or JWKS fixture exists anywhere; apps/auth's token signing (src/lib/jwt.ts) is also untested.
Nothing anywhere fires an HTTP-level request through handleRequest with an unmocked router; apps/api's enforced ~90% coverage measures only the Next wiring with the router mocked out.
Since #646 rewrites the untested auth resolution and #649 re-hosts the untested wire layer, this characterization suite must land first.
Design
Location & wiring
New apps/api/characterization/ directory with its own apps/api/vitest.characterization.config.ts: node environment, fileParallelism: false, env: { NODE_ENV: "test" }, tsconfig paths (so ~/lib/logging resolves), no coverage thresholds (leave the existing jsdom config and its thresholds untouched).
New turbo task test:characterization: dependsOn: ["^topo", "reset-test-db", "^reset-test-db"], cache: false. Reuses the existing test-DB pipeline (TEST_DATABASE_URL, packages/dbreset-test-db migrate+seed) unchanged.
CI (.github/workflows/ci.yml, test-coverage job): add a step running pnpm turbo run test:characterizationafterpnpm test (sequential — must not mutate the shared f3_test DB concurrently with packages/api tests). No new CI env vars needed (AUTH_SECRET, SUPER_ADMIN_API_KEY, TEST_DATABASE_URL already present; NEXT_PUBLIC_AUTH_URL is set at runtime by global-setup, see below).
Transport seam (characterization/transport.ts)
All tests are written against type Invoke = (req: Request) => Promise<Response>, selected by CHAR_TEST_TARGET:
next (default, pre-migration): import GET from src/app/[[...rest]]/route.ts (all method exports are the same handleRequest), wrapped in the next/headers shim below.
live: real fetch against CHAR_TEST_BASE_URL (local next start / node server.js, or staging). Only the read-only smoke subset runs in this mode (describe.runIf(target.kind !== "live") gates anything needing DB fixtures or in-memory rate-limit state).
RPC-protocol tests use a real oRPC RPCLink with a custom fetch bound to the seam (same pattern as apps/map/src/orpc/client.ts:16) — never hand-rolled RPC wire frames.
Cookie enabler (verified against next-auth 5.0.0-beta.31 internals): the no-arg auth() is just await headers() (from next/headers) → a plain Request carrying the cookie header → @auth/core's Auth(). So the suite mocks only next/headers with an AsyncLocalStorage-backed headers() that the next transport populates from the incoming request — the REAL @auth/core decode, cookie names, and session callback from packages/auth/src/config.ts all execute unmocked, in-process. (createActionURL also reads x-forwarded-proto from those headers — pass the full request headers through.) Fallback if a next-auth bump changes those internals: demote cookie cases to the live target only; the tests themselves don't change.
Fixtures
Shared helpers: extract the vitest-free helpers from packages/api/src/__tests__/test-utils.ts (uniqueId, cleanup.*, role/org helpers, session factories) into a new @acme/api/testing export (packages/api/src/testing/index.ts); test-utils.ts re-exports them so the 20 existing test files need zero changes.
JWT/JWKS: vitest globalSetup generates an RS256 keypair per run (jose.generateKeyPair — nothing committed), starts a tiny node:http server on port 0 serving /.well-known/jwks.json (kid f3-auth-1, matching apps/auth/src/lib/jwt.ts), and sets process.env.NEXT_PUBLIC_AUTH_URL to it. This works because packages/api/src/shared.ts:57-63 reads that var straight from process.env at module import; globalSetup runs before workers fork. The JWKS-unreachable case gets its own test file that sets a dead-port URL before importing the transport (forks pool = fresh module registry per file).
Cookies: @auth/core/jwtencode() with AUTH_SECRET and the cookie name/salt from packages/auth/src/config.ts — decode happens through the real server path, so encode→decode parity is inherent in every cookie test.
API keys: real apiKeys + rolesXApiKeysXOrg rows via the fixtures module (which returns created ids so snapshot scrubbing is exact).
Auth matrix (~30 cases — the point of this issue)
API key: valid key + Client header with roles → authorized; valid key with no role rows → roles: [] → editor procedure 401; revoked (revokedAt); expired (expiresAt vs DB_NOW); unknown key (protected 401 but public procedure still 200); Authorization/authorization casing and Bearer/bearer prefix equivalence.
Client-header rule: bearer present + no Client header → 401 with the exact documented message (snapshot for both RPC and OpenAPI envelopes).
Super-admin: x-api-key = SUPER_ADMIN_API_KEY authorizes revalidate; wrong key 401; session without nation-admin → the exact "not authorized to revalidate" message.
Role guards through REAL resolution (not injected sessions): representative protected/editor/admin/nationAdmin/revalidateAuth procedures × {no auth, user-role key, editor key, admin key, nation-admin JWT} → exact status + message snapshots (characterize actual behavior, e.g. code returns UNAUTHORIZED where docs say 403 — pin what IS).
Dev-mock branch: own file, isDevelopment mocked true (NODE_ENV stays test so DB routing is untouched) → mock admin session returned.
Rate limiting: unique x-forwarded-for per case; request N+1 → 429 with the retry message shape; different IP unaffected (per-IP isolation + first-IP-of-chain extraction). Cheap in-process against ping; excluded from live.
Wire matrix (~15 cases)
Client-header dispatch: same procedure via RPCLink (Client: orpc → /v1) vs REST GET (OpenAPI handler) — different body shapes, golden both; repeat for f3-me and orpc-ssg header values; /v1 path WITHOUT the header falls to OpenAPI → 404 (dispatch is header-based, not path-based).
/ → /docs redirect including x-forwarded-proto/x-forwarded-host derivation; exact 404 body ("Not found"); CORS preflight (OPTIONS + Origin → origin echo, access-control-allow-credentials: true, allow-headers/methods, max-age 600 — this catches Hono OPTIONS wiring) and CORS headers on actual responses.
Serialization edges: Date-returning and nullable-field responses via both handlers.
Golden-file rules
Normalizer produces { status, selected headers, scrubbed body } (fixture ids and timestamps replaced by path-rule, not regex-over-blob); one snapshot file per case via toMatchFileSnapshot so PR diffs are reviewable. Goldens are frozen for the duration of Phases 0a–4: any diff = behavior change = migration bug or explicit sign-off in the PR.
Also in scope
Minimal apps/auth/src/lib/jwt.test.ts (~4 tests): signAccessToken output verifies against the app's own getJWKS() (RS256, kid f3-auth-1, numeric-string sub, issuer = NEXT_PUBLIC_AUTH_URL) using a generated fixture PEM in AUTH_JWT_PRIVATE_KEY. Pins the token producer to the same contract the characterization suite pins the consumer to.
Out of scope
Router business logic (covered by packages/api), next-auth login flows (OTP/email — untouched by the migration), Scalar docs HTML, distributed rate limiting, coverage thresholds for this suite.
Parity workflow (how later phases use this)
This issue: suite green on main (CHAR_TEST_TARGET=next), goldens committed, CI step added, docs/testing.md section added.
The next/headers shim is coupled to next-auth beta internals (verified against beta.31); if a bump breaks it, move cookie cases to the live leg — tests unchanged.
characterization/setup.ts must not transitively import packages/api/src/shared.ts before env pinning (JWKS is built at import time).
The rate limiter is a module singleton per worker — rate-limit tests must use unique IPs throughout.
Acceptance criteria
Suite passes on main locally (pnpm --filter f3-api test:characterization with docker Postgres) and in CI.
Auth matrix and wire matrix cases above all present; golden files committed and reviewable.
Existing packages/api tests untouched and green (helpers extraction is re-export-compatible).
docs/testing.md documents the suite, the CHAR_TEST_TARGET seam, and the golden-freeze rule.
Part of the apps/api → Hono migration epic #644. Phase 0-pre: blocks #646 (and gates every later phase). Ships on Next.js today; the suite itself is the migration's parity proof.
Why
A coverage assessment found the migration's blast radius is exactly the least-tested code in the repo:
packages/apirouter logic is well covered (20 integration test files, unmocked router viacreateRouterClient, real Postgres) — that harness is framework-agnostic and survives the migration untouched.packages/api/src/__tests__/setup.tsmocksauth()to return a finished session, which early-returns past everything ingetSession(packages/api/src/shared.ts:172-295) — the RS256 JWT-via-JWKS path, the DB API-key lookup, theClient-header rule, cookie decoding, and the dev-mock branch are never exercised. No RS256 test keypair or JWKS fixture exists anywhere;apps/auth's token signing (src/lib/jwt.ts) is also untested.handleRequestwith an unmocked router; apps/api's enforced ~90% coverage measures only the Next wiring with the router mocked out.Since #646 rewrites the untested auth resolution and #649 re-hosts the untested wire layer, this characterization suite must land first.
Design
Location & wiring
apps/api/characterization/directory with its ownapps/api/vitest.characterization.config.ts: node environment,fileParallelism: false,env: { NODE_ENV: "test" }, tsconfig paths (so~/lib/loggingresolves), no coverage thresholds (leave the existing jsdom config and its thresholds untouched).test:characterization:dependsOn: ["^topo", "reset-test-db", "^reset-test-db"],cache: false. Reuses the existing test-DB pipeline (TEST_DATABASE_URL,packages/dbreset-test-dbmigrate+seed) unchanged..github/workflows/ci.yml,test-coveragejob): add a step runningpnpm turbo run test:characterizationafterpnpm test(sequential — must not mutate the sharedf3_testDB concurrently with packages/api tests). No new CI env vars needed (AUTH_SECRET,SUPER_ADMIN_API_KEY,TEST_DATABASE_URLalready present;NEXT_PUBLIC_AUTH_URLis set at runtime by global-setup, see below).Transport seam (
characterization/transport.ts)All tests are written against
type Invoke = (req: Request) => Promise<Response>, selected byCHAR_TEST_TARGET:next(default, pre-migration): importGETfromsrc/app/[[...rest]]/route.ts(all method exports are the samehandleRequest), wrapped in thenext/headersshim below.hono(after feat(api): Hono server entry hosting the existing oRPC handlers (Hono migration phase 2) #649):app.fetch.live: realfetchagainstCHAR_TEST_BASE_URL(localnext start/node server.js, or staging). Only the read-only smoke subset runs in this mode (describe.runIf(target.kind !== "live")gates anything needing DB fixtures or in-memory rate-limit state).RPC-protocol tests use a real oRPC
RPCLinkwith a customfetchbound to the seam (same pattern asapps/map/src/orpc/client.ts:16) — never hand-rolled RPC wire frames.Cookie enabler (verified against next-auth 5.0.0-beta.31 internals): the no-arg
auth()is justawait headers()(fromnext/headers) → a plainRequestcarrying the cookie header →@auth/core'sAuth(). So the suite mocks onlynext/headerswith an AsyncLocalStorage-backedheaders()that thenexttransport populates from the incoming request — the REAL@auth/coredecode, cookie names, and session callback frompackages/auth/src/config.tsall execute unmocked, in-process. (createActionURLalso readsx-forwarded-protofrom those headers — pass the full request headers through.) Fallback if a next-auth bump changes those internals: demote cookie cases to thelivetarget only; the tests themselves don't change.Fixtures
packages/api/src/__tests__/test-utils.ts(uniqueId,cleanup.*, role/org helpers, session factories) into a new@acme/api/testingexport (packages/api/src/testing/index.ts);test-utils.tsre-exports them so the 20 existing test files need zero changes.globalSetupgenerates an RS256 keypair per run (jose.generateKeyPair— nothing committed), starts a tinynode:httpserver on port 0 serving/.well-known/jwks.json(kidf3-auth-1, matchingapps/auth/src/lib/jwt.ts), and setsprocess.env.NEXT_PUBLIC_AUTH_URLto it. This works becausepackages/api/src/shared.ts:57-63reads that var straight fromprocess.envat module import; globalSetup runs before workers fork. The JWKS-unreachable case gets its own test file that sets a dead-port URL before importing the transport (forks pool = fresh module registry per file).@auth/core/jwtencode()withAUTH_SECRETand the cookie name/salt frompackages/auth/src/config.ts— decode happens through the real server path, so encode→decode parity is inherent in every cookie test.apiKeys+rolesXApiKeysXOrgrows via the fixtures module (which returns created ids so snapshot scrubbing is exact).Auth matrix (~30 cases — the point of this issue)
Clientheader with roles → authorized; valid key with no role rows →roles: []→ editor procedure 401; revoked (revokedAt); expired (expiresAtvsDB_NOW); unknown key (protected 401 but public procedure still 200);Authorization/authorizationcasing andBearer/bearerprefix equivalence.Clientheader → 401 with the exact documented message (snapshot for both RPC and OpenAPI envelopes).x-api-key=SUPER_ADMIN_API_KEYauthorizes revalidate; wrong key 401; session without nation-admin → the exact "not authorized to revalidate" message.Client: orpc-ssg+ valid session cookie → cookie ignored (skip-auth branch); orpc-ssg + valid API key → authorized.sub; JWKS unreachable → JWT path fails closed while API-key auth still works (outage isolation).isDevelopmentmocked true (NODE_ENV staystestso DB routing is untouched) → mock admin session returned.x-forwarded-forper case; request N+1 → 429 with the retry message shape; different IP unaffected (per-IP isolation + first-IP-of-chain extraction). Cheap in-process againstping; excluded fromlive.Wire matrix (~15 cases)
RPCLink(Client: orpc→/v1) vs RESTGET(OpenAPI handler) — different body shapes, golden both; repeat forf3-meandorpc-ssgheader values;/v1path WITHOUT the header falls to OpenAPI → 404 (dispatch is header-based, not path-based)./→/docsredirect includingx-forwarded-proto/x-forwarded-hostderivation; exact 404 body ("Not found"); CORS preflight (OPTIONS+ Origin → origin echo,access-control-allow-credentials: true, allow-headers/methods, max-age 600 — this catches Hono OPTIONS wiring) and CORS headers on actual responses.hostheader andNEXT_PUBLIC_API_URLunset → stable-stringify →toMatchFileSnapshot("__snapshots__/openapi.golden.json")withinfo.versionnormalized. Supersedes thejq -Sdiff planned in feat(api): Hono server entry hosting the existing oRPC handlers (Hono migration phase 2) #649.Golden-file rules
Normalizer produces
{ status, selected headers, scrubbed body }(fixture ids and timestamps replaced by path-rule, not regex-over-blob); one snapshot file per case viatoMatchFileSnapshotso PR diffs are reviewable. Goldens are frozen for the duration of Phases 0a–4: any diff = behavior change = migration bug or explicit sign-off in the PR.Also in scope
Minimal
apps/auth/src/lib/jwt.test.ts(~4 tests):signAccessTokenoutput verifies against the app's owngetJWKS()(RS256, kidf3-auth-1, numeric-stringsub, issuer =NEXT_PUBLIC_AUTH_URL) using a generated fixture PEM inAUTH_JWT_PRIVATE_KEY. Pins the token producer to the same contract the characterization suite pins the consumer to.Out of scope
Router business logic (covered by packages/api), next-auth login flows (OTP/email — untouched by the migration), Scalar docs HTML, distributed rate limiting, coverage thresholds for this suite.
Parity workflow (how later phases use this)
main(CHAR_TEST_TARGET=next), goldens committed, CI step added,docs/testing.mdsection added.getSessionFromHeaders(headers)deep-equals the sessionauth()produced for the same cookie fixture (both resolvers coexist within that PR).CHAR_TEST_TARGET=nextandCHAR_TEST_TARGET=hono— against identical goldens.CHAR_TEST_TARGET=live CHAR_TEST_BASE_URL=<staging>.Known implementation risks
next/headersshim is coupled to next-auth beta internals (verified against beta.31); if a bump breaks it, move cookie cases to theliveleg — tests unchanged.characterization/setup.tsmust not transitively importpackages/api/src/shared.tsbefore env pinning (JWKS is built at import time).Acceptance criteria
mainlocally (pnpm --filter f3-api test:characterizationwith docker Postgres) and in CI.packages/apitests untouched and green (helpers extraction is re-export-compatible).docs/testing.mddocuments the suite, theCHAR_TEST_TARGETseam, and the golden-freeze rule.Effort: ~14 test files / ~70–80 tests; roughly 5–6.5 focused days, splittable into S/M PRs (seam+fixtures → auth matrix → wire matrix+CI → apps/auth jwt test).
Filed by Claude Code - Fable 5 (API framework architectural assessment)