Skip to content

test(api): characterization suite for the Hono migration (Phase 0-pre, parity gate) #660

Description

@BigGillyStyle

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/db reset-test-db migrate+seed) unchanged.
  • CI (.github/workflows/ci.yml, test-coverage job): add a step running pnpm turbo run test:characterization after pnpm 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.
  • hono (after feat(api): Hono server entry hosting the existing oRPC handlers (Hono migration phase 2) #649): app.fetch.
  • 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/jwt encode() 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.
  • SSG rule: Client: orpc-ssg + valid session cookie → cookie ignored (skip-auth branch); orpc-ssg + valid API key → authorized.
  • JWT: valid RS256 token (fixture issuer, seeded user sub) → session with DB-fetched roles and role-gated procedure succeeds; expired; wrong issuer; bad signature (second keypair); valid signature but unknown sub; JWKS unreachable → JWT path fails closed while API-key auth still works (outage isolation).
  • Cookie: valid session cookie → protected procedure 200 with roles honored; tampered cookie → 401 (not 500); expired cookie → 401; cookie + bearer both present → cookie wins (precedence pin — this ordering is exactly what refactor(auth,api): framework-agnostic session resolution in getSession (Hono migration 0b) #646 must preserve).
  • 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.
  • Error envelopes 401/404/422/429 for BOTH handlers → golden files (the envelope shape is what feat(api): Hono server entry hosting the existing oRPC handlers (Hono migration phase 2) #649 must not change).
  • OpenAPI spec: generate through the seam with a fixed host header and NEXT_PUBLIC_API_URL unset → stable-stringify → toMatchFileSnapshot("__snapshots__/openapi.golden.json") with info.version normalized. Supersedes the jq -S diff planned in feat(api): Hono server entry hosting the existing oRPC handlers (Hono migration phase 2) #649.
  • 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)

  1. This issue: suite green on main (CHAR_TEST_TARGET=next), goldens committed, CI step added, docs/testing.md section added.
  2. refactor(api): remove vestigial revalidatePath / next-cache from packages/api (Hono migration 0a) #645/refactor(api): swap @t3-oss/env-nextjs for env-core in apps/api (Hono migration 0c) #647/chore(api): delete dead map-app skeleton from apps/api (Hono migration phase 1) #648: suite green with zero golden churn.
  3. refactor(auth,api): framework-agnostic session resolution in getSession (Hono migration 0b) #646: cookie + guard cases pass unchanged, plus a transitional test asserting getSessionFromHeaders(headers) deep-equals the session auth() produced for the same cookie fixture (both resolvers coexist within that PR).
  4. feat(api): Hono server entry hosting the existing oRPC handlers (Hono migration phase 2) #649: CI runs the suite twice — CHAR_TEST_TARGET=next and CHAR_TEST_TARGET=hono — against identical goldens.
  5. chore(api): esbuild build, simplified Dockerfile, Cloud Run cutover, Next deletion (Hono migration phases 3-4) #650: staging gate = the black-box smoke subset with CHAR_TEST_TARGET=live CHAR_TEST_BASE_URL=<staging>.

Known implementation risks

  • 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.

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)

Metadata

Metadata

Assignees

Labels

ai-auditFindings from automated repo audit

Type

No type

Projects

Status
Backlog

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions