testing
126 modules
testing/admin_integration.ts
Standard admin integration test suite for fuz_app admin routes.
describe_standard_admin_integration_tests creates a composable test suite that exercises admin account listing, role_grant grant/revoke (via the RPC surface — see
role_grant_offer_create/role_grant_revoke), session/token management, and audit log routes against a real database.Consumers call it with their route factory, session config, role schema, and RPC endpoint specs — all admin route tests come for free.
Scope: admin *semantics* — cross-admin isolation, role_grant grant/revoke flow, session/token revoke-all, audit writes. Output-schema conformance for admin methods is not the concern of this suite; it lives in:
- describe_rpc_round_trip_tests — every RPC method (admin methods
included) is hit with a spec-generated valid body and the 2xx result
is validated against
spec.output. - describe_round_trip_validation — every REST route is hit and
validated against its declared
output/ error schemas (SSE routes skipped viaContent-Type: text/event-stream). - describe_sse_route_tests — SSE frames validated against their declared EventSpec.
- describe_rpc_round_trip_tests — every RPC method (admin methods
included) is hit with a spec-generated valid body and the 2xx result
is validated against
testing/adversarial_404.ts
Adversarial 404 testing for routes with params and declared 404 error schemas.
Creates stub handlers that return 404 with the declared error code, fires requests with valid-format-but-nonexistent params (nil UUIDs), and validates response bodies against the declared 404 Zod schemas.
No DB needed — tests schema conformance of 404 responses, not real handlers.
testing/adversarial_headers.ts
Adversarial header attack test suite.
Provides standard header injection test cases and a convenience wrapper for exercising middleware stacks with adversarial headers.
testing/adversarial_input.ts
Adversarial input validation testing for route specs.
Walks Zod schemas directly to generate payloads that must fail validation. Fires requests against a test app and asserts 400 responses before handlers are reached.
Tests are focused: one representative wrong-type value per field, one format violation per constrained field, one null and one missing test per required field, plus whole-body structural attacks (non-object body, extra unknown keys).
testing/app_server.ts
Bootstrapped app server factory for integration tests.
Creates a keeper account, API token, and signed session cookie on a database. By default uses a cached in-memory pglet (one shared WASM instance per loaded copy of testing/db.ts — once per run under the
dbproject'sisolate: false+fileParallelism: false); passdbto use an existing database (any driver) instead.Also provides create_test_app — a combined helper that creates both a TestAppServer and a fully assembled Hono app with middleware and routes.
testing/assert_dev_env.ts
testing/assertions.ts
Assertion helpers for auth attack surface testing.
Plain functions called inside explicit
test()blocks to verify surface snapshots, public routes, and middleware stacks.testing/attack_surface.ts
Adversarial auth enforcement test runners and the standard attack surface suite.
The combinatorial test runner (describe_adversarial_auth) generates test suites for routes x auth levels. The standard suite (describe_standard_attack_surface_tests) composes all attack surface test groups into a single call.
Stubs, app factories, and assertion helpers live in focused submodules:
test_auth_stubs— stub factories and pre-built dep bundlestest_auth_apps— auth-level test app factoriestest_auth_assertions— snapshot, public route, and middleware assertions
testing/audit_completeness.ts
Composable audit log completeness test suite.
Verifies that every auth mutation route produces the expected audit log event. Uses the real middleware stack and database, then reads back through the
audit_log_listRPC — the production observation path the admin UI consumes. This is intentional end-to-end coverage: emit → persist → query → wire response, all in one round-trip.The trade is a deliberate transport coupling: a regression in audit_log_list_action_spec's auth or response shape can surface here as a secondary failure. describe_rpc_round_trip_tests covers that RPC directly, so primary breakages localize there first. For *unit-level* "did the handler emit?" assertions without the persistence path, use create_recording_audit_emitter from testing/audit_drift_guard.ts — that captures emits before they hit DB or transport.
Bootstrap is excluded because it requires filesystem token state that create_test_app does not provide. Bootstrap audit logging is tested separately in
bootstrap_account.db.test.ts.testing/audit_drift_guard.ts
testing/auth_apps.ts
Auth test app factories for adversarial testing.
Creates Hono test apps at each auth level (public, authenticated, keeper, per-role) for use in adversarial auth enforcement and input validation tests.
testing/bootstrap_success.ts
Bootstrap success-path suite for consumer projects.
Exercises
POST /bootstrapagainst an empty DB (no pre-keeper, lock unflipped) through the real bootstrap_account flow. Asserts on observable state — account exists,bootstrap_lock.bootstrappedis true, audit row emitted, response body shape — rather thanon_bootstrapcallback invocation, so the suite stays cross-impl friendly when cross-process testing wires it against a spawned Rust backend.Folded into describe_standard_tests with a
bootstrap.mode === 'live'silent-skip gate; consumers wiring live bootstrap pick up success-path coverage by default.testing/connection_closer_helpers.ts
testing/cross_backend/account_lifecycle.ts
Cross-backend parity suite for the account-lifecycle admin verbs:
account_delete(soft),account_undelete(reactivation), andaccount_purge(keeper hard-delete), plus the keeper guard.Like the cell suites, these verbs can't ride the generic describe_rpc_round_trip_tests: they're stateful and destructive (a generic round-trip would tombstone the bootstrapped keeper). They live-mount on every spine's RPC path but stay off the declared surface, so this dedicated suite is their cross-impl validator. Every success
resultis parsed against the verb's declared Zod output schema, so a TS↔Rust envelope drift fails the assertion.testing/cross_backend/action_manifest_parity.ts
Cross-impl action-manifest parity — structural diff + assertion over two ActionManifests captured via the
_testing_action_manifestRPC action.The action-surface twin of
schema_parity.ts: two live impls (the TS fuz_app spine and the Rusttesting_spine_stub) are each other's parity reference. After both bootstrap, dump each one's live RPC method set, diff, fail loudly on drift. The diff entries name the specific divergence (a method only one impl mounts, a per-method auth-axis, side-effect, or rate-limit-class mismatch) so the error message points at the source.const manifest_a = await capture_action_manifest(ts_handle); const manifest_b = await capture_action_manifest(rust_handle); assert_action_manifests_equal(manifest_a, manifest_b, {a: 'ts', b: 'rust'});Non-coverage: the manifest captures `{method, side_effects, account, actor, roles, credential_types, rate_limit}` — the wire-relevant auth shape. It does not capture input/output schemas (the declared-surface wire shape is gated by
rpc_round_trip) nor the protocol actionsheartbeat/cancel(excluded by construction — seeaction_manifest.ts).testing/cross_backend/action_manifest.ts
Cross-impl RPC action-manifest introspection — a normalized, JSON-serializable dump of a backend's live RPC method set, one entry per method carrying its auth shape, side-effect flag, and rate-limit class.
The sibling of
schema_introspect.ts's SchemaSnapshot: where that captures the live *database* shape for the schema-parity gate, this captures the live *RPC registry* shape for the action-manifest parity gate. Both are dumped over a daemon-token_testing_*introspection action (_testing_action_manifesthere,_testing_schema_snapshotthere) and diffed across the TS spine and the Rusttesting_spine_stubso a method-set or per-method auth-shape divergence fails loud. This complements the in-repospine_method_coveragegate: that proves *mounted ⟹ covered*; this proves *TS-mount-set ≡ Rust-mount-set* (method set + auth shape).Scope — domain + testing surface, not wire protocol. Protocol actions (
heartbeat/cancel/peer/ping) are excluded: on the TS spine create_testing_action_manifest_action filters them via the protocol_action_specs method set; the Rust stub dropsPROTOCOL_ACTION_SPECS. The two impls organize protocol actions differently (peer/pingis on the TS spine's WS and HTTP-RPC endpoints — it must answerpeer_no_transportover HTTP — while heartbeat/cancel stay WS-only; the Rust stub compiles one shared registry serving both transports), so including them would be a spurious cross-impl diff. This matches the scope of the in-repospine_method_coveragegate (also over build_full_spine_rpc_actions).Paired with
action_manifest_parity.tsfor the diff + assertion helpers.testing/cross_backend/actor_lookup.ts
Cross-backend parity suite for
actor_lookup.actor_lookupis an opt-in batched id → label resolver ({ids} → {actors: [{id, username, display_name?}]}), not folded into the standard bundle. It's live-mounted on the spine RPC path but kept off the declared surface (create_spine_surface_spec) — like cells / ws / sse — so the standard cross suite's generic round-trip never drives it; this dedicated suite is its validator. Three cases over raw transport calls:- anonymous → 401 — the account-grain auth gate refuses an unauthenticated caller before the handler runs.
- keeper resolves own actor → 200 — the populated round trip: the
returned row carries the keeper's
id+username, and noaccount_id/email/ timestamp / role field (the wire shape's deliberate info-leak posture). This is the assertion that exercises the Rust row→JSON mapping against the TS canonical shape. - empty
ids→ 400 — themin(1)input bound is enforced on both spines (TS Zod, Rustparse_ids).
Runs both legs via the shared
{setup_test}protocol: the in-process leg (auth/actor_lookup_parity.db.test.ts, plaingro test) and the cross-process leg (cross_backend/actor_lookup.cross.test.ts, the TS spine binaries + Rusttesting_spine_stubover real HTTP).actor_lookupis mounted on every spine, so the suite is ungated.testing/cross_backend/actor_search.ts
Cross-backend parity suite for
actor_search.actor_searchis an opt-in case-insensitive prefix search overactor.name(`{query, scope_ids?, limit?} → {actors: [{id, username, display_name?}]}`), not folded into the standard bundle. Likeactor_lookup/ cells, it's live-mounted on the spine RPC path but kept off the declared surface, so this dedicated suite is its validator. The security property under test is the empty-scope_idsadmin gate:- anonymous → 401 — the account-grain auth gate refuses an unauthenticated caller before the handler runs.
- non-admin + no
scope_ids→ 400actor_search_scope_required— an unbounded global search is admin-only; a non-admin must scope the query. This is the core security assertion, exercised against each impl's real auth resolution. - non-admin +
scope_ids→ 200 — passing a scope bypasses the admin requirement (results are filtered to actors holding active role_grants on those scopes); an unheld scope simply yields an empty result, not a rejection — proving the gate keys onscope_idspresence, not identity. - admin + no
scope_ids→ 200 — the admin path reaches the unbounded search.
Cites
security.md§Authorization (theactor_searchscope gate).Runs both legs via the shared
{setup_test}protocol: in-process (auth/actor_search_parity.db.test.ts) + cross-process (cross_backend/actor_search.cross.test.ts, TS spine binaries + Rusttesting_spine_stub). Mounted on every spine, so the suite is ungated.testing/cross_backend/app_settings.ts
Cross-backend effect suite for the
open_signupapp setting.The declarative conformance table pins the admin gate on
app_settings_get/app_settings_update(401 / 403 / 200). This suite pins the behavioral effect of the toggle end to end: an admin flipsopen_signupviaapp_settings_update, and a subsequent anonymousPOST /signupobserves the new value.- toggle on → anonymous signup without an invite succeeds (200) — with
open_signup: true, the invite gate is skipped. - toggle off → anonymous signup is refused (403
no_matching_invite) — flipping it back restores the invite requirement, proving the gate keys on the live value rather than a one-time read.
The signup handler reads the toggle fresh from the database on every request, so the admin's write is visible to the next signup. This suite runs in a single process, so it validates the read-through *mechanism* — not multi-process consistency (which the fresh-read shape provides by construction but no single-binary test can observe).
Cites
security.md§Signup. Runs both legs via the shared{setup_test}protocol: in-process (auth/app_settings_parity.db.test.ts) + cross-process (cross_backend/app_settings.cross.test.ts, TS spine binaries + Rusttesting_spine_stub). Mounted on every spine, so the suite is ungated.- toggle on → anonymous signup without an invite succeeds (200) — with
testing/cross_backend/backend_config.ts
Cross-process backend configuration.
BackendConfig describes a spawnable test binary — argv, mount paths, env vars, bootstrap credentials, daemon-token discovery path, declared capabilities. Consumer projects ship per-backend factories (
deno_backend_config(),rust_backend_config(),rust_spine_stub_backend_config()) that produce this shape; spawn_backend consumes it.fuz_app ships
rust_spine_stub_backend_config()as a convenience preset (operational dep ontesting_spine_stub— path-based discovery, nopackage.jsoncoupling to the stub's source package). Otherwise backend-specific knowledge (binary paths, port choices, env vars) is a consumer concern; fuz_app's testing library knows nothing about Deno, Cargo, or any specific runtime beyond that preset.testing/cross_backend/bench/bench_report.ts
Reporting adapters over a CrossImplBenchResult — all built on fuz_util's formatters + Welch comparison. Markdown for human eyeballs, a per-scenario TS-vs-reference significance verdict, and a self-describing JSON artifact.
testing/cross_backend/bench/run_cross_impl_bench.ts
Drive identical wire scenarios across several spawned backends and time each round trip, so a TS impl and a Rust impl can be compared apples-to-apples (both cross-process over real HTTP). The reusable cross-impl measurement primitive.
fuz_util's benchmark library is the engine —
Benchmarkruns each scenario as a task andBenchmarkResult.statscarries the percentiles; this module is the thin scenario→task→tagged-result adapter. Reporting (markdown, TS-vs-Rust verdict, JSON artifact) lives in testing/cross_backend/bench/bench_report.ts.testing/cross_backend/bench/scenario.ts
Context handed to a
BenchScenario.run. Carries a ready, pre-authed transport (the bootstrapped keeper's, by default) plus the resolved RPC path and the backend's declared capabilities. A scenario fires one round trip (or a small fixed *idempotent* sequence) against it — no per-call_testing_reset, which is the correctness-test model and would dominate the timing.testing/cross_backend/body_size_smuggling.ts
Cross-backend request-smuggling probe for the body-size limit's connection handling — the security sibling of
body_size.ts.When the server caps the request body it answers
413on theContent-Lengthheader. The strong (defense-in-depth) posture is to close the connection *without reading the oversized body*: HTTP/1.1 forbids reusing a keep-alive connection whose request body wasn't consumed, because unread body bytes would be parsed as the start of the next request — a classic request-smuggling vector. This suite probes the boundary by pipelining: it opens a raw TCP socket and sends, in one write, an oversizedPOSTimmediately followed by a secondGET. Every backend must close after the reject, so the pipelined GET goes unanswered and at most one response comes back.<= 1rather than "exactly the 413" because the impls close differently at the TCP level (a graceful close delivers the 413 first; hyper's RST can drop the in-flight 413 before the client reads it), so demanding a cleanly-read 413 would be flaky.- Node closes at once with the body unread — create_app_server's body
limit (http/body_limit.ts) refuses on the header and answers
Connection: close, which is what makes@hono/node-serverclose. - Bun closes at once too, honouring that header. Bun 1.4.3 is the tested floor: an older Bun kept the connection after the 413 and served the next request (correctly framed), which fails this suite.
- Deno closes once the whole declared body has arrived, which this probe's single write delivers; a client that withholds the body holds a Deno connection open, and no request behind the body is served either way.
- hyper (the Rust spine) closes at once.
The oversized body is rejected *with* a 413 on every backend — pinned reliably by describe_body_size_cross_tests, which sends the over-cap request's head and no body, so no upload races the close; this test owns only the connection-handling half. A positive control (two pipelined requests →
>= 2responses) proves a second response *would* be seen if a trailing request were processed — without it<= 1would be vacuous on a server that never reuses connections — and that the counter isn't undercounting.Content-LengthbesideTransfer-Encoding. The second probe sends a request carrying both headers — the shape a request-smuggling attack hands a server and a proxy that disagree on which one frames the body — with aGETpipelined behind it. What the backends share is the outcome, not the status: the request is refused with400(the Node, Deno and Bun parsers) or answered as its chunked body reads (hyper, which drops theContent-Length), and either way the server closes the connection and the pipelined request is never served. The status of the one response is implementation-defined and not asserted.Raw-socket by necessity (the FetchTransport can't pipeline two requests on one connection), so — unlike
body_size.ts— this is cross-process only (no in-process leg; there is no socket in-process — the in-process pin is that the reject never reads the request body, inserver/create_app_server.db.test.ts).Cited property:
docs/security.md§"Body Size Limiting" (connection handling on oversized reject).- Node closes at once with the body unread — create_app_server's body
limit (http/body_limit.ts) refuses on the header and answers
testing/cross_backend/body_size.ts
Cross-backend parity suite for the request body-size limit.
create_app_server (TS) and the Rust spine both cap the request body at a 1 MiB default (DEFAULT_MAX_BODY_SIZE /
fuz_http'sDEFAULT_BODY_LIMIT_BYTES) and reject oversized payloads with413and the canonical flat REST body{error: 'payload_too_large'}. A declaredContent-Lengthover the cap is refused on the header, *before* auth, origin, or dispatch run (middleware step 4). Each impl unit-tests this in isolation; this suite pins the cross-impl agreement on the status + body shape, and on the exact>cap boundary (not an off-by-one divergence). The cases:- over-limit POST (cap + 1 byte) → 413
payload_too_large, refused before any handler runs (the limit fires ahead of origin verification + the dispatcher, so an over-cap body is rejected regardless of how well-formed it is). Exactly one byte over — both impls reject on a strict>, so this is the tight upper boundary, and staying just over keeps it clear of any larger framework-default limit that would answer with a different body. - at-limit POST (exactly the cap) → not 413 — one byte under the rejection threshold passes the size gate and reaches the dispatcher (the downstream status is irrelevant; only "not size-rejected" is asserted). The boundary sibling of the case above.
- under-limit POST (small) → 200 — a small, well-formed authenticated
account_verifyenvelope sails through to a successful handler response, the positive control that the route works for normal traffic. - The cap answering a declared length ahead of the origin check and auth —
over-cap with a disallowed
Originand no credential → 413 — is pinned per transport by the front-door table (transport_composition.ts), beside the under-cap 403 that shows the origin check still runs. - over-limit chunked POST (no
Content-Length), authenticated → 413 — a body with no declared length is refused by the bytes that arrive.
Implementation-defined, not pinned here. Without a declared length the two spines measure at different points: the TS spine reads the body up to the cap before any later middleware runs, so its 413 comes ahead of the origin check and auth; the Rust spine counts as the handler reads, so a refusal made on the headers (a disallowed
Origin) answers first and a handler that never reads the body answers its own status. Only the authenticated, allowed-origin chunked case — where nothing else refuses — has one answer on every backend. A repeatedContent-Lengthwith equal values is also the implementation's: the TS spine answers 400invalid_content_lengthwhere its runtime's parser hands the repeat over, and hyper folds it into one value and accepts.Over a real socket the over-cap cases send no byte the server won't read. A server that caps the body answers 413 and may close the connection with the rest of the upload unread — correct HTTP, since an unread request body can't share a keep-alive socket. A client still uploading then has its write fail (
EPIPE) and can lose the 413 the server did send, so a whole over-cap body sent throughfetchreads its answer only some of the time. Given abase_url, the suite speaks to the socket directly (raw_http_exchange) and holds back what the refusal doesn't need:- a declared over-cap length is sent as the request head alone —
Content-Lengthone over the cap and not one body byte. The 413 must arrive withinRAW_READ_TIMEOUT_MS, which also pins that the refusal is made on the header: a backend that reads the body before refusing, or has no cap, waits for bytes that never come and fails the case. - the chunked body is sent up to one byte past the cap and never terminated. The server has read every byte sent by the time it has counted past the cap, so nothing is left unread, and the 413 must arrive without the body's end.
With
base_url: nullthere is no socket and no such race (the in-processapp.request), so the same cases send whole bodies through the fixture's transport. The under-cap cases go through the transport either way.Like origin/payload rejection, this is middleware-level flat REST — not the JSON-RPC envelope the conformance-table runner expects — so it's an imperative suite, not a
conformance_tablerow. Runs both legs via the sharedsetup_testprotocol: the in-process leg (auth/body_size_parity.db.test.ts, plaingro test) and the cross-process leg (cross_backend/body_size.cross.test.ts, the TS spine binaries + Rusttesting_spine_stubover real HTTP). The body-size limit is on every spine, so the suite is ungated.Cited property:
docs/security.md§"Body Size Limiting".- over-limit POST (cap + 1 byte) → 413
testing/cross_backend/bootstrap_backend.ts
One-call spawn + bootstrap helper.
Composes
spawn_backend(config)andbootstrap({transport, config})so a consumer's vitestglobalSetupreduces to a single await:import {bootstrap_backend} from '@fuzdev/fuz_app/testing/cross_backend/bootstrap_backend.ts'; export default async function ({provide}) { const bootstrapped = await bootstrap_backend(deno_backend_config()); provide('backend_handle', bootstrapped); return async () => { await bootstrapped.teardown(); }; }If
bootstrap()throws — typically a bad token, port collision, or keeper-username mismatch — the spawned binary is torn down before the error propagates so vitest doesn't strand the port.testing/cross_backend/build_test_backend_paths.ts
Per-backend filesystem layout under
os.tmpdir()for cross-process tests.Isolation matters because vitest projects can run in parallel — a shared
rootwould mix daemon tokens across concurrently-running backends. Each backend gets its own subtree via theprefixarg (typically theBackendConfig.name).Consumers compose: take the generic paths from
build_test_backend_paths(name), add domain-specific dirs (e.g.zzz_dir,scoped_dir) under the returnedroot.testing/cross_backend/capabilities.ts
Capability vocabulary for cross-backend integration testing.
Backends declare which optional behaviors they support; suite bodies call
test_if(capabilities.X, ...)to skip cases the backend doesn't implement. Noif (config.name === 'rust')branches anywhere — name- checking is a code smell that says capability vocabulary is missing.In-process Hono via default_in_process_setup declares every capability its transport can exercise (see in_process_capabilities — the cross-process-only flags stay
false). Cross-process backends opt in per-flag on their BackendConfig.Where the per-backend declarations live (this file owns only the vocabulary + the in-process preset):
- in_process_capabilities — here; every flag
trueexcept the cross-process-only ones. - ts_default_capabilities / rust_default_capabilities — consumer-facing
family defaults, in
default_backend_configs.ts(full literals, so adding a capability is a compile error until each family declares it). ts_spine_capabilities/ts_spine_bun_capabilities— fuz_app's own TS spine presets, ints_spine_backend_config.ts(deltas off the family default; Bun flipsws_handshake_pipelining).rust_spine_stub_capabilities— fuz_app's Rust spine-stub preset, inrust_spine_stub_backend_config.ts(delta off the rust family default).
Gating flags vs shape notes. BackendCapabilities holds only flags a suite actually gates on (each has a
test_if(capabilities.X, ...)reader, or —ws_self_revocation_reply— forks one case's assertion). Wiring facts that gate nothing —bearer_auth/trusted_proxy/login_rate_limit— live in the parallel BackendShapeNotes record (in_process_shape_notes here, ts_default_shape_notes / rust_default_shape_notes indefault_backend_configs.ts) so the capability type never claims gating power it doesn't have.- in_process_capabilities — here; every flag
testing/cross_backend/cell_cross_helpers.ts
Shared call-site primitives for the cell cross-backend parity suites (testing/cross_backend/cell_crud.ts + testing/cross_backend/cell_relations.ts).
The cell verbs are stateful and authz-shaped, so both suites POST raw JSON-RPC envelopes (threading ids + auth headers across calls) and parse every success
resultagainst the verb's declared Zod output schema — the wire-shape parity gate. A TS↔Rust envelope drift, not just a payload field drift, fails the assertion.testing/cross_backend/cell_crud.ts
Dedicated stateful cell-CRUD parity suite for the cross-backend harness.
The generic describe_rpc_round_trip_tests can't cover cells: the verbs are stateful (update / delete / get-by-id need a real cell id threaded from a prior create) and
cell_get's input has a top-level.refine(). So cells stay off the standard declared surface (create_spine_surface_spec) — exactly like ws / sse — and this suite plus its sibling describe_cell_relations_cross_tests (grant / field / item / clone / audit) are the cell validators. This one gates oncapabilities.cell_crud; it runs against any backend that live-mounts the cell surface (the TS spine binary, the in-process Hono app, and the Rusttesting_spine_stub).Drives the full lifecycle (create → get → update → delete → list, threading the created id) plus the authz matrix the wire contract guarantees. Every success response is parsed against the verb's declared Zod output schema (CellCreateOutput / CellGetOutput / …), so a TS↔Rust envelope drift — not just a CellJson field drift — fails the suite:
- owner does full CRUD; responses match the output schemas exactly;
- anon sees
publiccells only —privateis 404 (existence not leaked); - a non-owner non-admin editing / reading / deleting another's private cell gets 404 (IDOR mask), never 403;
- admin reaches any cell;
- duplicate active
path→ 409 (cell_path_taken); pathwrite by a non-admin → 403 (cell_path_admin_only), on both create and update (even by the owner);cell_getwith neitheridnorpath→invalid_params;- null-auth
cell_listwithcreated_by→invalid_params; - a denied caller (non-editor update / non-admin path create) carrying a
kindinsidedatastill gets the authz error (404 / 403), never the kind error — authz runs before the input-shape checks, on both spines; - an empty-string
kindis rejected on create (cell_kind_empty) and is accepted as a list filter that matches nothing; - a
kindof exactly CELL_KIND_LENGTH_MAX UTF-16 code units is stored whole, and one unit past it isinvalid_paramson create and as a list filter.
The visibility-manage-tier 403 (
cell_visibility_manage_only) needs a non-owner editor, which only acell_grantcan produce, so it lives in describe_cell_relations_cross_tests alongside the grant verbs rather than here.testing/cross_backend/cell_gated_create.ts
Cross-backend parity suite for the parent-aware cell-creation authorizer (CellCreateAuthorize) — the directory model.
The authorizer adds no method, column, or wire shape, so the schema-snapshot and action-manifest parity gates are blind to a TS↔Rust authorizer divergence (the authorizer in the wrong phase, a different deny shape, the 404/403 split, the moderation outcome). This behavioral cross case is the only gate that catches one — proven *here in fuz_app*, against both reference spines.
Both spines mount the same directory-model policy (the TS spine binary via
create_test_cell_gated_create_authorize, the Rusttesting_spine_stubviaTestCellGatedCreateAuthorize): admin bypasses; a non-admin creating akind: 'space'root is denied (admin-only); a contribution under a space is gated by the space'sdata.policy[kind] = {min_role, moderation_required}, with the moderation outcome folded into the verdict; plain parentless creates stay open. The suite asserts all spines agree on:- root-create admin-only — a non-admin
spaceroot → 403cell_create_forbidden; admin → succeeds. - contribution gated by the root's policy — under a public space, an
unauthorized stranger → 403; a
participant→ succeeds. - moderation per
moderation_required— aparticipant'spost(moderation_required: true) is bornmoderation: 'pending'+ private; areact(moderation_required: false) is bornmoderation: 'approved'at the author's visibility. - 404 on a hidden parent vs 403 on a visible one — a contribution under a
private space the caller can't view → 404
cell_not_found(the parent is masked); under a public space → 403 (you see it, you can't contribute). - clone is no side door —
cell_cloneruns the authorizer for every cell it writes as a parentless create, so a non-admin clone of a viewablespaceroot → 403cell_create_forbidden; admin → succeeds.
Gated on
capabilities.cell_gated_create—trueonly on the reference spine binaries that mount the policy, so it skips for generic consumers and the in-process default app (the authorizer hook's in-process coverage is the standaloneauth/cell_create_authorize.db.test.ts).- root-create admin-only — a non-admin
testing/cross_backend/cell_grant_role.ts
Cross-backend parity suite for role-shaped
cell_grants.The cell CRUD / relations suites exercise only actor-shaped grant principals. This suite covers the role-shaped path and its closed-registry gate — the security-correctness property that the Rust spine previously lacked (it created inert grant rows for any role string). Both spines now validate the role against a closed registry at create.
- role grant admits a holder; excludes a non-holder — an owner grants
{role}on a private cell; an account holding that role cancell_getit (200), an account without it gets the IDOR-mask 404. - unknown role rejected at create (security-correctness) — granting a
role outside the registry is
invalid_params/cell_grant_unknown_role, not a silent inert row. - editor-level role grant admits edit — a holder of an
editor-level role grant cancell_updatethe cell's content.
The holder is seeded via
extra_accountsunder CELL_ROLE_HOLDER_USERNAME holding CELL_EDITOR_ROLE (the role has no grant path, so it can't be offered — the bootstrap-cradle seed is the only path). Both legs configure that seed and register CELL_EDITOR_ROLE in their role registry; the Rusttesting_spine_stubmirrors the same membership in itsknown_roles.Cites
security.md§Authorization (role-shaped cell-grant validation). Runs both legs via the shared{setup_test}protocol: in-process (auth/cell_grant_role_parity.db.test.ts) + cross-process (cross_backend/cell_grant_role.cross.test.ts). Gated oncapabilities.cell_relations(true on every spine, so it never skips).- role grant admits a holder; excludes a non-holder — an owner grants
testing/cross_backend/cell_relations.ts
Dedicated cell relation / ACL / audit parity suite for the cross-backend harness — the sibling of describe_cell_crud_cross_tests covering every cell verb beyond plain CRUD:
cell_grant_*,cell_field_*,cell_item_*,cell_clone, andcell_audit_list.Like the CRUD suite (and ws / sse), these verbs are live-mounted on the spine RPC path but stay off the standard declared surface, so the generic describe_rpc_round_trip_tests never drives them. Every success response is parsed against the verb's declared Zod output schema, so a TS↔Rust envelope drift fails the suite — not just a payload-field drift.
Coverage (gated on
capabilities.cell_relations):- grant lifecycle — owner grants an actor-shaped editor; the grantee
gains edit (was 404 before the grant);
cell_grant_listis manage-tier (owner sees it, the editor gets the IDOR 404); revoke drops the edit path. cell_visibility_manage_only— the editor-grant holder can edit content but flippingvisibilityis 403 (the case the 5-verb CRUD cut couldn't reach without a grant principal).- fields —
cell_field_setUPSERT, forward + reversecell_field_list, idempotentcell_field_delete. - items —
cell_item_insertat fractional-index positions, forward (lex-ordered) + reversecell_item_list,cell_item_move, idempotentcell_item_delete. - clone — shallow copies item / field *edges* (shared
child_id/target_id); deep clones each viewable child into a fresh cell at the same position. Both nullpathand stamp the caller as owner. shared_with: 'me'— the grant-drivencell_listbranch: a grantee lists exactly the cells that admit them viacell_grant, their own cells excluded, with thecell_grantsenrichment keyed by cell id. Plus the two guards — null-auth iscell_list_shared_with_requires_auth, and any value other than'me'isinvalid_paramson both spines. The cross twin of the in-processauth/cell_actions.list.db.test.ts§"shared_with: me".- audit —
cell_audit_listis manage-tier: the owner reads the cell's timeline; a viewer-grant holder who cancell_getthe cell still gets the IDOR 404 on the timeline (D14). - relation-read visibility (D8) — listing edges toward a cell the caller
can't view filters them out (no-existence-leak-via-edge): an anonymous
viewer of a public parent — and a viewer-grant holder of a private parent —
sees only independently-viewable children in the
cell_getbundle and the forwardcell_item_list/cell_field_list. The cross twin of the in-processauth/cell_relation_visibility.db.test.ts. - clone D8 — a cloner who can view a public parent but not a private child
silently drops that child: an admin (who *can* view it) reading the clone
still sees only the viewable edge/child, and the
cell_cloneaudit row records no skipped-child count — so the source's hidden-child count can't leak to the cloner. The cross twin ofauth/cell_actions.clone.db.test.ts.
Only actor-shaped grants are exercised — role-shaped principals need a closed role registry the Rust spine deliberately lacks, so role-grant parity is out of scope here (the TS impl covers it in-process).
- grant lifecycle — owner grants an actor-shaped editor; the grantee
gains edit (was 404 before the grant);
testing/cross_backend/conformance_case.ts
Declarative conformance-case schema for the cross-backend behavioral + security suite.
A conformance case is a single request → expected-response assertion, carried as data. The case references a
method(an RPC method name or a REST auth-route suffix); the runner (describe_conformance_table_tests) resolves theinput/outputZod schemas from the live action-spec registry / RouteSpec — the case never carries a schema. This is the opinionated behavioral/security layer on top of the spec-derived auto-enumeration (describe_rpc_round_trip_tests / describe_rpc_attack_surface_tests): the same case definition runs in-process (fast, everygro test) and cross-process (the conformance gate) against each impl's real auth resolution.The table is for single-request matrices (credential-type ceiling, privilege gates, IDOR masks, enumeration-equivalence, validation). Multi-step flows stay imperative in their own
describe_*suites, sharing assertion primitives — there is deliberately no declarative setup DSL.testing/cross_backend/conformance_table.ts
Table runner for the declarative cross-backend conformance suite.
describe_conformance_table_tests takes a list of ConformanceCase rows plus the standard
{setup_test, surface_source, capabilities}fixture protocol every Tier 1 suite uses — so one runner drives both transports: in-process via default_in_process_setup (fast, everygro test) and cross-process via default_cross_process_setup (the conformance gate, exercising each impl's real auth resolution over real HTTP). Same case definition, transport-parameterized.Each row references a
method; the runner resolves itsinput/outputschema from the live spec registry (RPC) or RouteSpec (the 6 REST auth routes) — the row never carries a schema. The principal the row runsasresolves to a TestFixture accessor viaresolve_principal— no inline credential minting.Every response also passes an always-on no-fingerprint invariant (assert_no_fingerprint_headers over FINGERPRINT_HEADERS —
Server/X-Powered-By/WWW-Authenticatemust stay absent on both spines), and a row may pin further header expectations viaexpect.headers. Headers are deliberately kept out of the equivalence-group{status, body}comparison.testing/cross_backend/cookie_attributes.ts
Cross-backend parity suite for session cookie attributes over real HTTP.
The session cookie's
Set-Cookieattributes are the load-bearing browser security boundary:HttpOnlykeeps the token out of JS (XSS can't read it),Securekeeps it off plaintext HTTP,SameSite=Strictis the primary CSRF defense (the Origin allowlist is only defense-in-depth), andPath=/scopes it to the whole app. A regression dropping any one of these is a real downgrade — and it's wire-observable, so both spines must emit the same hardened set. The cross-backend conformance table deliberately keepsSet-Cookie*out* of its byte-identity comparison (the signed value legitimately differs per request and per impl), so the attribute contract had no cross-backend pin: the TS spine's attributes were covered bysession_cookie.ts's own tests, but the Rust spine'ssign_session_cookie/ clear_session_cookie strings were asserted nowhere. This suite closes that gap on both impls by parsing the rawSet-Cookieand asserting the attributes directly. Three properties:- successful login sets a hardened cookie — `HttpOnly; Secure;
SameSite=Strict; Path=/
plus a positive integerMax-Age` (the session lifetime). The signed value is opaque here; only the attributes are pinned. - a failed login sets no session cookie — a denial mints no credential,
so there is no
Set-Cookiefor the session name at all. A spine that leaked a (signed-but-unauthenticated) cookie on the 401 would fail here. - logout clears the cookie with
Max-Age=0and the same hardened flags — the clear must not silently dropSecure/HttpOnly/SameSite=Strict(a cleared-but-unhardenedSet-Cookieis a downgrade window).
Both surfaces are flat REST (
POST /api/account/{login,logout}) on every spine, so this is an imperative suite (not aconformance_tablerow) — the sibling oforigin.ts/login_security.ts. Cross-process only: reading the rawSet-Cookieattributes needs the wire response (the in-process parse_session /session_cookie.rsunit tests cover each impl's cookie codec directly). Cited property:docs/security.md§"Session Security" (the "Cookie attributes" bullet —HttpOnly; Secure; SameSite=Strict; Path=/).- successful login sets a hardened cookie — `HttpOnly; Secure;
SameSite=Strict; Path=/
testing/cross_backend/create_cross_backend_global_setup.ts
Generic vitest
globalSetupfactory for cross-backend integration suites.Pairs with make_cross_backend_project: each cross-backend vitest project sets its own
test.name, and this factory derives the backend name from that project name (vitest 4 passes theTestProjectto globalSetup), picks the matching BackendConfig, spawns + bootstraps it via bootstrap_backend, andprovides a serializable handle that*.cross.test.tsfilesinjectand rebuild with reconstruct_bootstrapped_handle.A consumer's
global_setup.tscollapses to:import {create_cross_backend_global_setup} from '@fuzdev/fuz_app/testing/cross_backend/create_cross_backend_global_setup.ts'; import {deno_backend_config, rust_backend_config} from './my_backend_config.js'; import './cross_test_types.js'; // augments inject('backend_handle') export default create_cross_backend_global_setup({ configs: {deno: deno_backend_config, rust: rust_backend_config}, });vitest 4's
providehard-rejects non-serializable values, so the livechild/teardown/keeper_transportare stripped via serialize_bootstrapped_handle; the teardown closure stays in the globalSetup process and is returned for vitest to fire after the suite.testing/cross_backend/create_dual_spawn_global_setup.ts
Generic dual-spawn vitest
globalSetupfactory — spawns + bootstraps *two* backends at once andprovides both serialized handles, for any cross-impl gate that needs both alive together. (The per-backend create_cross_backend_global_setup derives one backend from the project name; this brings up a pair.)Its primary use is the schema- / action-manifest-parity gates (capture each backend over a
_testing_*introspection RPC and diff with assert_schema_snapshots_equal / assert_action_manifests_equal) — which is why the defaultprovide_keysareparity_handle_*. The login-security gate (global_setup_login_security.ts) reuses it with its own keys; any future two-backend gate can too.A consumer's dual-spawn
global_setup.tscollapses to:import {create_dual_spawn_global_setup} from '@fuzdev/fuz_app/testing/cross_backend/create_dual_spawn_global_setup.ts'; import {deno_backend_config, rust_backend_config} from './my_backend_config.js'; import './cross_test_types.js'; // augments the two provide keys export default create_dual_spawn_global_setup({ configs: {a: deno_backend_config, b: rust_backend_config}, });The
.cross.test.tsinjects both keys, rebuilds each with reconstruct_bootstrapped_handle, and asserts. Run this project in a latergroupOrderthan the single-backend projects (or with distinct ports) — it reuses both configs' ports, so it must not run concurrently with the per-backend projects.testing/cross_backend/credential_deadline.ts
Cross-process credential deadline suite — a long-lived connection closes when the credential it was opened with expires.
A WebSocket or an audit stream is authorized once, at open, and closed on revocation by identity — but expiry writes nothing a revocation listener could hear. So each spine carries the credential's deadline onto the connection (
ResolvedAuth.credential_deadline: the earlier of a session's cookie and row expiry, an API token's row expiry) and closes it there: a socket with WS_CLOSE_SESSION_REVOKED and WS_CLOSE_CREDENTIAL_EXPIRED_REASON, a stream by ending it as a revoked one ends.Each case pulls a fresh account's credential expiries in to
expiry_secondsfrom now with the daemon-token_testing_shorten_credential_expiryseam, before opening the connection (the deadline is read when the credential resolves), then checks the connection is open short of the deadline and closed at it:- WebSocket × session — 4001, "Credential expired".
- WebSocket × API token — the same.
- Audit stream × session — the stream ends.
- The seam only shortens: a second call asking for a later expiry moves nothing, and the socket still closes at the first deadline.
The fourth reachable combination, API token × audit stream, is not here: the stock audit stream admits sessions only on both spines. Each spine covers it below the wire — the Rust stream route on a widened credential gate, the TS deadline wait shared by both transports.
Gated on
capabilities.ws(cases 1, 2, 4) andcapabilities.sse(case 3); cross-process only — the seam is mounted on the spine test binaries.testing/cross_backend/credential_header_robustness.ts
Cross-backend credential-header robustness probe — the auth sibling of
body_size_smuggling.ts.The auth middleware reads a single credential per header (
Authorization,X-Daemon-Token). What a *duplicated*, *oversized*, or *control-char-injected* credential header does is framework-territory — Hono reads its headers via the WebHeadersAPI, axum viahttp::HeaderMap— and the two could resolve a duplicate differently (first-vs-last), cap header size differently, or parse a malformed value differently. None of that is exercised overfetch(the FetchTransport can't emit a duplicate or malformed header), so it had no cross-impl pin. This raw-socket suite sends hand-framed requests and asserts the security invariants that must hold regardless of how each framework resolves the ambiguity — so it pins a real property without blessing one resolution over the other:- a duplicated credential header never escalates — a request carrying a
valid keeper
X-Daemon-Token*and* a second, conflicting one must not perform a keeper operation. The target omits confirm, so the documented confirm guard (purge_not_confirmed) makes the honored-token branch a guaranteed non-2xx with zero side effect, and the discarded-token branch is a non-2xx auth/credential refusal — so whichever copy the framework picks, the outcome is non-2xx. The same shape with two garbageAuthorizationheaders pins the bearer path can't be escalated either. - a control-char-injected credential header can't smuggle or authenticate
— embedded
CRLF/ bareCRin the value never frames a second request (no smuggle) and never authenticates (the truncated / rejected value is no token). - no response desync — each crafted request yields exactly one HTTP response (a duplicate header that reframed the request would surface as a second status line, like the body-size smuggling probe).
- an oversized credential header can't wedge the server — after a request
with a 64 KiB
X-Daemon-Token(well past both frameworks' header caps), a subsequent normal request on a fresh connection still gets a response. The oversized request's own outcome (4xx / 431 / connection close) is don't-care; surviving it is the property.
Raw-socket by necessity (
fetchcan't emit duplicate or malformed headers), so — likebody_size_smuggling.ts— this is cross-process only and fixture-free: it needs only the base URL, the RPC path, and a valid daemon token (handle.daemon_token, kept current for the run by the same rotation the_testing_resetchannel relies on). Cited property:docs/security.md§"Credential Type Hierarchy" (a leaked / conflicting credential header can't escalate the credential ceiling) + §"API Token Security" (bearer handling).- a duplicated credential header never escalates — a request carrying a
valid keeper
testing/cross_backend/default_backend_configs.ts
Family-shared BackendConfig builders for cross-process test backends.
Two consumer-facing factories — make_default_ts_backend_config and make_default_rust_backend_config — own the common shape for the JS-runtime (Deno/Node on V8) and Rust families respectively. Per-backend factories in consumer projects compose a small declaration against one of these and add consumer-specific env vars via
extra_env.Defaults baked in by family:
- TS —
'memory://'(in-memory pglet), 30s startup window. Its shape notes (ts_default_shape_notes) recordtrusted_proxy: false/login_rate_limit: false— the TS canonical path leaves those limiters null in test mode. - Rust — caller-supplied real Postgres URL (an in-process WASM database isn't reachable
from
tokio-postgres), 120s startup window (cargo first-build cost), theFUZ_TESTING_RESET_DB_ON_STARTUP=trueself-wipe gate, and shape notes (rust_default_shape_notes) recordingtrusted_proxy: true+login_rate_limit: true.
Both builders default
port_env_varto'PORT'. Consumers whose binary reads a different name (e.g. zzz'sZZZ_PORT) override.Common across both families:
/api/rpc,/api/ws,/health,/api/account/bootstrap,cookie_name: 'fuz_session', the standard bootstrap block keyed offdefault_test_*constants. Builders callbuild_test_backend_paths(name)internally when the optionalpathsis omitted.- TS —
testing/cross_backend/default_secrets.ts
Default test secrets for cross-process backend bootstrap.
Every cross-backend consumer needs the same shape: a bootstrap token the harness writes to disk before spawn, a keeper username/password the harness POSTs to
/api/account/bootstrap, and cookie keys the binary uses to construct its Keyring. The literals here are dev-only and protected byassert_dev_env; the binaries themselves throw on production load.Each constant is exported individually so consumers can override one without re-deriving the rest. Builders in testing/cross_backend/default_backend_configs.ts thread these defaults into the
BackendConfig.bootstrapblock and theSECRET_FUZ_COOKIE_KEYSenv entry; callers compose thebootstrap_overridesknob when they need a non-default keeper.testing/cross_backend/default_spine_surface.ts
Canonical no-domain spine surface — the standard fuz_app auth/account/admin/audit surface with no consumer domain layer on top.
This is the single source of truth for "the standard spine surface", shared by:
- the Rust
testing_spine_stubcross-process self-tests (which build the AppSurfaceSpec via create_spine_surface_spec and drive the binary's wire shape), - the TS
testing_spine_servercross-process binary (which feeds create_spine_route_specs + spine_rpc_endpoints into a live create_app_server), and - the
cross_backend_ts_*self-test projects.
- the Rust
testing/cross_backend/expected_schema.json
testing/cross_backend/fact_serving.ts
Cross-backend fact-serving parity suite — the per-reference (cell-scoped) read model over real HTTP.
Re-proves the D1 fact-access cases (
docs/security.md§ Fact Access Control) against each backend's real auth resolution, twinning fuz_app's server/serve_fact_route.ts and the Rustfuz_fact_servingrouters:- cell-scoped admit — anon reads a fact through a viewable (public) referencing cell → 200 + bytes;
- cross-owner dedup does not leak — A's *private* reference to bytes that
B *also* publishes from a *public* cell stays 404 for everyone but A, even
though the identical bytes are world-readable via B's cell (one deduped
factrow; authz lives on the(cell, hash)edge, never unioned); - 404-mask — a missing cell and a viewable cell that doesn't reference the hash both 404 (never 403, never "exists elsewhere");
- bare-hash admin-only —
GET /api/facts/:hashis admin (keeper) only: non-admin → 403, anonymous → 401; - multi-actor fallthrough — a multi-actor caller resolves to a null
(anonymous) context on the (
acting-less) cell-scoped route, so it can't read its own *private* fact there (admitted only by public cells). Opt-in (needs the multi-actor setup); every spine resolves the acting actor at the dispatcher's authorization phase from account-grain credentials, so the multi-actor account is drivable on TS and Rust alike.
Facts are seeded embedded via
_testing_put_fact(the cross-process driver has no DB handle); the referencing cell via thecell_createRPC (extract_refslifts theblake3:hash indataintocell.refs). Gated oncapabilities.fact_serving; runs under everycross_backend_*project — the TS spine binary and the Rusttesting_spine_stubboth mount the serve routes + the seeder.testing/cross_backend/full_spine_mount.ts
The full live RPC mount for fuz_app's own spine test binary — the complete action set
testing_spine_server.tsexposes on a single RPC endpoint, in one place.Where
default_spine_surface.tsdefines the declared surface (create_spine_surface_spec / spine_rpc_endpoints — the create_standard_rpc_actions bundle the spec-derived suites auto-enumerate), this module defines its superset: the standard bundle plus the families the binary live-mounts but keeps off the declared surface —- the
_testing_*daemon-token backdoors (create_testing_actions), - the full cell verb set (CRUD + grant + field + item + audit),
- the opt-in
actor_lookup/actor_searchresolvers, - the
_testing_origin_checkprobe the origin conformance table drives, - the
_testing_shorten_credential_expiryseam the credential-deadline suite drives, - the
_testing_action_manifestbackdoor, appended last (it dumps the live method set for the cross-impl manifest-parity gate, so it must enumerate every method above it).
Single-sourcing the mount here lets the binary, the in-process parity setup, and the
spine_method_coveragereconciliation test all build the same list — so a method can never be mounted in one place and forgotten in another.The binary's WS endpoint serves the same list: build_full_spine_mount builds it once and hands the WS mount the protocol actions plus every RPC action, twinning the Rust
testing_spine_stub, which compiles one action registry and serves it on both transports. The reconciliation test enumerates build_full_spine_rpc_actions with stub deps and asserts the live method set equals the tagged coverage manifest; seesrc/test/cross_backend/spine_method_coverage.ts.- the
testing/cross_backend/identity_parity.ts
Cross-backend identity-primitive parity for fuz_app's own spine over real HTTP — the
primitive_schemastwins (Username, UsernameProvided, Email) and the login/signup input handling that enforces them, pinned facet-by-facet so a TS↔Rust divergence in any one surfaces as a failure: how usernames are canonicalized on the login lookup, the ASCII-only creation invariant that bounds what can ever be stored, and the email format rule applied at signup.UsernameProvided (login/lookup) canonicalizes the submitted username via
.trim().toLowerCase(), and the DB matches case-insensitively (LOWER(username) = LOWER($1)); Username (creation) lowercases at store and restricts to ASCII via^[a-zA-Z][0-9a-zA-Z_-]*[0-9a-zA-Z]$. The Rust spine must produce the same canonical form, the same case-insensitive lookup, and the same ASCII-only rejection — or a username that logs in on TS silently 401s on Rust, or a non-ASCII username storable on one backend reopens the homograph-collision surface. The spec-derived round-trip + conformance suites never vary username casing or charset, so this corner was unpinned.Canonicalization (login lookup):
- case-insensitive login — an account created
Mixed_Caselogs in via an all-uppercase submission (proves the *lookup* folds case, not merely that the stored form was lowercased). - whitespace-trim login — the same shape logs in with surrounding
whitespace (proves
.trim()on the lookup path). - no Unicode case-fold collision (negative) — a Turkish-
İ(U+0130) variant of an existing ASCII username must NOT match. Both JS.toLowerCase()and Ruststr::to_lowercase()mapİ→i+ U+0307 (combining dot above), never plaini, so the cased homograph stays a distinct, non-existent username → 401 on every backend.
Username-or-email login lookup: the login identifier resolves against username or email — TS query_account_by_username_or_email, Rust the converged
query_account_with_password_hashOR-lookup (this was the divergence the suite closed: the Rust spine previously matched username only). An account created with an email logs in via that email, case-insensitively (Email stores the original case; folding rides theLOWER(email) = LOWER($1)lookup), and username login keeps working when an email is present. A non-existent email → 401.Login input validation: malformed login input → 400
invalid_request_bodyon every spine — whitespace-only username (empty after trim), over-long username (> 255), empty password, and an unknown body key (strict object). TS runs the full LoginInput Zod schema; the Rust spine enforces the same shape inaccount_login(the convergence the suite closed: the Rust spine previously let three of these fall through to a lookup-miss 401, and TS let a whitespace-only identifier through to a 401). Asserted via the error reason, not just the status, so a same-status-wrong-body backend still fails.ASCII-only creation invariant: a non-ASCII username is rejected at signup input validation → 400 on every backend, so no Unicode username is ever stored — the precondition the login no-collision case relies on. (ASCII-only is the intended invariant for both spines, not an accident of the TS regex.)
Length + format creation parity: the full Username shape —
[USERNAME_LENGTH_MIN, USERNAME_LENGTH_MAX]=[3, 39]and the regex^[a-zA-Z][0-9a-zA-Z_-]*[0-9a-zA-Z]$— pinned across both backends. The TS Zod.min()/.max()/.regex()and the Rust hand-rolled byte-scan (is_valid_username_for_creation) are independent reimplementations of the same rule, so each length boundary is tested just-outside (→ 400) and just-inside (→ 403 no-matching-invite, the settle for a valid username on a spine withopen_signupat itsfalsedefault), and each format violation (leading non-letter, trailing punctuation, embedded disallowed char) → 400, with mid-string_/-accepted siblings as the don't-over-reject control. Signup is also strict-object on both spines (TSz.strictObject, Rust#[serde(deny_unknown_fields)]), so an unknown signup body key → 400.Email format (creation): the optional signup
emailis validated to a looselocal@domain.tldshape on both spines — TS Email (^[^\s@]+@[^\s@]+\.[^\s@]+$plus a 254-byte (RFC 5321 octet) length bound; whitespaceWhite_Space ∪ {U+FEFF}), Rust the hand-rolledis_valid_email. A malformed email → 400, a well-formed one → 403 no-matching-invite (the same accept/reject settle the username tables use). The accepted siblings include the single-char-TLDa@b.cthat Zod'sz.email()would reject — the deliberately-looser rule both spines now share. This closed a real divergence: the Rust spine previously length-checked the email only, accepting any non-empty string TS rejected.Both surfaces are flat REST on every spine, so this is an imperative suite (not a
conformance_tablerow) and ungated. Runs both legs via the shared{setup_test}protocol: the in-process leg (cross_backend/identity_parity.db.test.ts, plaingro test) and the cross-process leg (cross_backend/identity_parity.cross.test.ts, the TS spine binaries + Rusttesting_spine_stubover real HTTP).- case-insensitive login — an account created
testing/cross_backend/in_process_setup.ts
In-process fixture producers for the cross-backend suite protocol.
default_in_process_setup(options)wraps create_test_app into the SetupTest contract;default_in_process_suite_options(options)emits the full in-process suite bundle ({setup_test, surface_source, capabilities}plus factory-input pass-through). Both reach the in-process Hono app, so this module transitively importshono— it lives apart fromsetup.ts(the shared fixture protocol + the cross-process producer) so a Rust-only consumer driving a spawned backend can import the cross-process helpers without thehonopeer. The cross-process sibling (default_cross_process_setup) implements the same contract by spawning a binary and bootstrapping over real HTTP.testing/cross_backend/login_security.ts
Cross-backend parity suite for login rate limiting + trusted-proxy (X-Forwarded-For) resolution over real HTTP.
Login throttling and client-IP resolution are wired-but-never-crossed: the in-process describe_rate_limiting_tests covers the limiter, and the
fuz_http/ proxy middleware unit tests cover XFF resolution, but no case exercised either over a real socket on both impls — the limiter is nulled on every standard cross backend and the resolved client IP has no wire-observable downstream there. This dedicated suite spawns a backend with the login limiters enabled + the loopback proxy trusted (seeglobal_setup_login_security.ts), then pins two properties end-to-end:- per-IP login limit fires — the first default_login_ip_rate_limit
failed logins from one forwarded IP each return
401, and the next returns429with the canonical `{error: "rate_limit_exceeded", retry_after}body **and** aRetry-After: ceil(retry_after)` header. The 429 wire shape is the cross-impl contract — TS rate_limit_exceeded_response and Rustroute_response::rate_limit_exceededmust agree. - trusted-proxy / XFF resolution is honored — distinct
X-Forwarded-ForIPs get independent buckets: after exhausting one forwarded IP to429, a *different* forwarded IP is unaffected (401, not429). A backend that ignored XFF and keyed on the (loopback) TCP peer would 429 the fresh-IP request too — so the401proves the limiter keys on the resolvedX-Forwarded-Forclient IP. - a concurrent burst gets at most the cap past the limiter — `2 ×
ip_limit` wrong logins sent at once from one fresh forwarded IP answer at
most
ip_limit401s and429for the rest. A limiter checked before the lookup and recorded after the verify lets the whole burst through; each attempt is reserved instead, counted in flight from the check on. - a burst of correct logins past the cap waits, then succeeds — attempts
count while in flight whatever their outcome, so
2 × ip_limitcorrect logins at once from one address meet the cap; the ones past it wait for a slot (at most the cap of them) and all answer200. Runs on a backend that serves concurrent transactions (concurrent_transactions). - every
X-Forwarded-Forline is read — a client that sends its own line ahead of the one the trusted proxy appended can't choose its bucket: after exhausting one forwarded IP, a raw request carrying a spoofed line first and that IP last still429s. Raw socket only (fetchmerges repeated headers into one line), so it runs whenbase_urlis given. - a success does not refund the per-IP budget — the distributed-spray backstop. Failed guesses against *distinct* usernames from one forwarded IP, interleaved with the attacker's own successful logins, must still 429 within a bounded total-request budget. Both spines used to clear the IP bucket on success, which made that budget unbounded: one credential (or, under open signup, none) bought unlimited guesses against arbitrary victims from a single address. Only the account-grain bucket is forgiven now.
Determinism without a limiter reset. Limiter state is in-memory and the per-test
_testing_resetwipes only the DB, never the buckets — so each case uses its own forwarded IP and its own (non-existent) username, keeping every bucket independent across cases and across the two impls (separate processes). The login floor is zeroed on both spines, so the failed-login loop stays fast.Both surfaces are flat REST (
POST /api/account/login) on every spine, so this is an imperative suite (not aconformance_tablerow) — the sibling oforigin.ts/identity_parity.ts. Cross-process only: the limiter+proxy wiring is the point, and the in-process counterparts already exist (describe_rate_limiting_tests + the proxy middleware tests). Cited property:docs/security.md§"Rate Limiting" + §"Trusted Proxy / Client IP".- per-IP login limit fires — the first default_login_ip_rate_limit
failed logins from one forwarded IP each return
testing/cross_backend/make_cross_backend_project.ts
Generic vitest project factory for cross-backend integration suites.
One vitest project per spawned backend; each runs the consumer's shared
*.cross.test.tsfiles against its own bootstrapped binary. The paired create_cross_backend_global_setup (inglobal_setup.ts) reads the project'snameto pick which BackendConfig to spawn, so the project name is the single source of truth for backend selection.Consumers compose these into their
vite.config.ts:const cross_backend_projects = process.env.FUZ_TEST_CROSS_BACKEND ? [ make_cross_backend_project({name: 'cross_backend_ts_deno', global_setup: GLOBAL_SETUP}), make_cross_backend_project({name: 'cross_backend_rust', global_setup: GLOBAL_SETUP}), ] : [];where
GLOBAL_SETUP = './src/test/cross_backend/global_setup.ts'.This module is intentionally dependency-free and
assert_dev_env-free: it runs at vite config time (including production builds, where the consumer gates the projects behind an env flag), so it must not pull in the DEV-only test runtime.testing/cross_backend/method_coverage.ts
Reconcile a backend's live-mounted RPC method set against a tagged coverage manifest, so the off-declared-surface methods stay as drift-proof as the declared ones.
The spec-derived suites (describe_rpc_round_trip_tests / describe_rpc_attack_surface_tests) auto-enumerate the declared surface — add a method to a registry in create_spine_surface_spec and it is tested automatically. But a backend's live RPC endpoint also mounts methods kept *off* that surface (stateful cell verbs, opt-in resolvers,
_testing_*backdoors); those rely on hand-wired imperative suites and get no auto-enumeration. Nothing structurally guaranteed every live method was actually claimed by a suite — so a newly mounted-but-untested method could ship silently.assert_rpc_method_coverage closes that gap: it diffs the live method set against a MethodCoverageEntry manifest (both directions — a mounted-but-unclaimed method *and* a stale manifest row both fail loud) and checks each entry's tier is consistent with the declared surface + the backdoor prefix. The manifest becomes the forcing function — a new method can't reach the live mount without a manifest row naming the suite that covers it.
Pairs with
surface_invariants.tsassert_no_testing_methods (which guards the *reverse* — a backdoor must never leak *onto* the declared surface).testing/cross_backend/narrowed_api_token.ts
Mint a narrowed (method-scoped) api token through the production
account_token_createpath, for suites that probe what a narrowed token is refused.Minted rather than seeded: the token comes out of the same handler a real minter uses, on whichever account owns the session the caller passes — so a suite can put a narrowed token on an account that lacks a gating role, and the mint itself asserts that a narrowed scope is accepted at the mint boundary, not merely stored.
testing/cross_backend/origin_conformance.json
testing/cross_backend/origin_conformance.ts
The origin allowlist conformance table, and the cross-backend suite that runs it against each backend over the wire.
origin_conformance.json(beside this module) is the one statement of the allowlist grammar and matcher both spines implement. It holds two tables:- parse rows —
{raw, ok: true}or{raw, errors: [{index?, kind}]}: an allowlist value (asFUZ_ALLOWED_ORIGINSwould carry it) and whether it parses, or every refusal with its 1-based entry position (absent for a list-level refusal) and its AllowedOriginsErrorKind. - match rows —
{patterns, origin, allowed}: a valid allowlist, oneOriginvalue (nullfor none), and the request-level answer.
A row may carry a
notefor the reader. The TS unit test reads the table directly; describe_origin_conformance_cross_tests sends every row to a backend's_testing_origin_checkaction and compares the answer, so the Rust spine is held to the same rows without reading this file.- parse rows —
testing/cross_backend/origin_scope.ts
Cross-backend parity suite for the origin check's scope: every path, not only
/api.Both spines check the
Originof every request — the TS spine mounts the check on every path (ORIGIN_MIDDLEWARE_PATH), the Rust spine behind every route group offuz_http::compose_stack, the fallback included — and leave a path out only by a named carve-out with its reason. The front-door table (transport_composition.ts) pins the check on the/apitransports; this suite pins it off/api, on the route both spine test servers serve there,GET /ready:- foreign
Origin→ 403forbidden_origin— the check runs on a path outside/api. - absent
Origin→ passes — a probe or CLI client sends none, so the wider scope costs it nothing. - allowed
Origin→ passes — the control: the check admits its own origin rather than refusing every browser.
And on a path neither server routes, which reaches each spine's fallback:
- foreign
Origin→ 403forbidden_origin, absent → 404 — the fallback is behind the check, not attached after it. - an over-cap
POSTwith a declaredContent-Lengthand a foreignOrigin→ 413payload_too_large— the body cap comes ahead of the origin check on the fallback too (413-first holds for a declared length).
The
/readyrows are gated oncapabilities.ready, which every spine test server advertises; the unrouted rows run on every backend. Runs both legs via the shared{setup_test, capabilities}protocol: the in-process leg (cross_backend/origin_scope_parity.db.test.ts, plain `gro test) and the cross-process leg (cross_backend/origin_scope.cross.test.ts`, the TS spine binaries + Rusttesting_spine_stubover real HTTP).- foreign
testing/cross_backend/origin.ts
Cross-backend parity suite for Origin verification.
Origin checking is middleware that runs *before* the RPC dispatcher and refuses only an
Originit doesn't allow. The case here needs a transport that sends noOriginat all (fresh_transport({origin: null})), so this is an imperative suite rather than a conformance-table row. The case:- absent
Origin→ request passes — non-browser / direct-access clients (curl, CLI, server-to-server) carry noOriginand must not be blocked; token auth is the control for those callers.
A disallowed
Origin→ 403forbidden_origin, on every transport, is pinned by the front-door table (transport_composition.ts).Runs both legs via the shared
setup_testprotocol: the in-process leg (auth/origin_parity.db.test.ts, plaingro test) and the cross-process leg (cross_backend/origin.cross.test.ts, the TS spine binaries + Rusttesting_spine_stubover real HTTP). Origin middleware is on every spine, so the suite is ungated.- absent
testing/cross_backend/peer_ping_ws.ts
Cross-process server-initiated
peer/pingsuite — the machinery proof for ActionPeer (a backend initiating a JSON-RPC request to a connected client and awaiting its typed reply). The sibling of the one-way notification suite (role_grant_offer_notification_ws.ts), extended from server→client *notifications* to server→client *request/response*.peer/pingisinitiator: both: the client→server direction already exists asheartbeat; the new server→client direction is what this exercises. The observable trigger is the client invoking thepeer/pingaction over its own socket — the handler turns around and *initiates* apeer/pingrequest back to that socket, awaits the client's echo, validates it against PingResponse, and returns the validated shape. So one client RPC drives the whole round-trip, and every outcome (success /Timeout/ wrong-shape / client-error) surfaces as that RPC's wire response — directly assertable.The client side attaches an
on_requestresponder at construction (via create_ws_transport's seam) so the server-initiated request is answered as soon as it arrives. Security negatives use the rawWsClient.sendto inject unsolicited / cross-connection frames.Per-spec auth.
peer/pingisauth: public(a liveness echo is non-sensitive — see the design doc); the WS upgrade itself still authenticates, so the suite drives it over the keeper's session.Gated on
capabilities.peer_request, which a backend declares once it mountspeer/pingon a WS endpoint and the HTTP RPC endpoint — the TS spine binaries (BackendWebsocketTransport.request_connection) and the Rust spine both do. Cross-process only: create_ws_transport needs a real bound socket, so wire it from a*.cross.test.ts.testing/cross_backend/query_shape.ts
Cross-backend query-string shape parity suite.
The TS pipeline reads queries through Hono's
c.req.query()— a duplicated key resolves to its first occurrence — and only routes that declare az.strictObjectquery schema 400 unknown keys, in the pipeline's params → query → 401 → authz → 403 phase order. A derived-serde axum extractor diverges on every axis (a plain-text pre-handler "duplicate field" 400 observable anonymously, silently ignored unknown keys, query rejection ahead of the 401), so each spine surface that reads a query is pinned here on both backends:GET /api/account/status— NO query schema: a duplicatedactingreads first-wins (the persona resolves), unknown keys are ignored, and an anonymous caller with junk query hears 401, never a query 400.- the JSON-RPC GET endpoint —
method/id/paramsread first-wins; a duplicatedmethodmust dispatch the first one on every backend (a last-wins reader dispatches a different method per backend on the same request). GET /api/facts/:hash(gated oncapabilities.fact_serving) — strict query schema: unknown keys and a malformedactingare 400invalid_query_paramseven for an anonymous caller (query precedes the 401 guard), while a duplicatedactingtakes the first occurrence.
The per-surface in-process TS pins live beside each module's own tests (
account_status.test.ts,action_rpc.test.ts,serve_fact_route.db.test.ts); this suite is the cross-impl gate, so it is cross-process only — likelogin_security.tsandtesting_backdoor.ts.testing/cross_backend/ready.ts
Cross-backend parity suite for the
/readyschema-drift deploy gate.The
/readymechanism already ships on both twins (TS create_ready_route_spec / db/schema_ready.ts; Rustfuz_http::ready/fuz_db::schema_ready), each with its own drift →503unit tests. This suite is the missing automated cross-impl gate: an anonymousGET /readyreturns200 {ready: true}over real HTTP on both spine test servers, proving the success path is wire- identical and that both backends read the same committedexpected_schema.json(column-presence is engine-portable, so one fixture is the cross-impl contract)./readyis a plain public REST route — not an RPC method, not one of the six REST auth routes — and it's deliberately off the declared spine surface (create_spine_surface_spec), like ws/sse/cells/fact-serving. So it needs a bespoke imperative suite (à laorigin.cross.test.ts), gated oncapabilities.ready, rather than aconformance_tablerow or generic round-trip enumeration. The drift →503path stays per-impl unit tests.Runs both legs via the shared
{setup_test, capabilities}protocol: the in-process leg (cross_backend/ready_parity.db.test.ts, plaingro test) and the cross-process leg (cross_backend/ready.cross.test.ts, the TS spine binaries + Rusttesting_spine_stubover real HTTP).testing/cross_backend/role_grant_cross_helpers.ts
Role-grant fixtures for the cross-process suites — a pending offer, and an active grant materialized through the real offer + accept handlers — shared by the suites that need a grant to revoke (describe_role_grant_offer_notification_ws_tests, describe_ws_role_gated_cross_tests).
Both drive the standard role-grant-offer RPC actions over the fixture's HTTP transport, as the keeper unless a grantor is given.
testing/cross_backend/role_grant_offer_enumeration.ts
Cross-backend parity suite for the role_grant_offer_accept enumeration boundary — the deliberate 403-vs-404 split on the accept path.
role_grant_offer_acceptdistinguishes two denials, and the distinction is a conscious decision (not an accident to mask away):- a genuinely-nonexistent offer (or one on another account) → 404
role_grant_offer_not_found. The account-scoped IDOR guard refuses to confirm an offer id the caller's account doesn't own — so a cross-account prober learns nothing. - an offer that exists on the caller's OWN account but is targeted to a
different actor (a sibling persona) → 403
role_grant_offer_actor_mismatch. This reveals "an offer exists for a sibling actor" to a co-account caller — but there is no cross-account leak: every actor in the distinction belongs to the one account already authenticated, so the 403 discloses nothing a principal can't already see about its own account. Masking this to 404 would only obscure a legitimate "not yours to accept, pick the right persona" signal. So 403 is the chosen, defensible behavior — and both spines must agree on it (a future Rust or TS change that over-masked the sibling case to 404, or under-masked the cross-account case to 403, is the regression this suite catches).
The actor-mismatch arm only fires for an *actor-targeted* offer accepted by a sibling actor, so the suite needs a multi-actor recipient. The keeper is the only fixture account that can be seeded multi-actor (
extra_actors), and an account can't offer to itself — so the grantor is a separate admin account and the recipient is the keeper (actor A =fixture.actoris the offer target; actor B =fixture.extra_actors[0]is the rejected sibling).Multi-step (create → accept) and using the
actingselector, so this is an imperative suite (not aconformance_tablerow). The accept verb is on every spine's standard RPC surface, so the suite is ungated. Cross-process only: the sibling-actor / actor-targeted-offer setup is a wire flow. Cited property:docs/security.md§"Authorization" (the 404-over-403 mask is scoped to cross-principal leaks; an intra-account sibling-actor offer stays a 403role_grant_offer_actor_mismatch).- a genuinely-nonexistent offer (or one on another account) → 404
testing/cross_backend/role_grant_offer_notification_ws.ts
Cross-process role-grant-offer lifecycle WS notification suite — the machinery proof for the consentful-role-grants notification fan-out across any spine backend. Covers all seven server-initiated notifications:
role_grant_offer_received→ recipient (offer created)role_grant_offer_accepted→ grantor (recipient accepts)role_grant_offer_declined→ grantor (recipient declines)role_grant_offer_retracted→ recipient (grantor retracts)role_grant_revoke(flat, omitsrevoked_by) → revokee (active grant revoked)role_grant_offer_supersede→ each superseded sibling's grantor, fired on BOTH the accept-cascade (reason: 'sibling_accepted') and the revoke-cascade (reason: 'role_grant_revoked')
These exercise only spine primitives (accounts, role-grants, offers, WS notifications) — zero consumer domain — so the suite lives here and runs against any backend that wires the standard RPC actions'
notification_senderand mounts a registered WS socket: fuz_app's own spine self-tests (testing_spine_server+ the Rusttesting_spine_stub) and downstream twin-impl consumers (the fuz_forge Deno/Hono + Rustfuz_forge_serverbackends) alike.Each case is a *targeted* server-initiated notification (vs the broadcast in a
repo_updated-style suite), so it opens the affected counterparty's socket, drives the lifecycle RPC over HTTP, then asserts the frame lands on that socket and strict-parses against the canonical wire schema — the guard against serialization drift (field / null / datetime / the flat revoke shape / the supersedereason+cause_id).Sends are queued on the post-commit drain (handler-emit, not audit-derived), so a frame may land a beat after the RPC resolves —
WsClient.wait_forpolls already-received messages then waits, absorbing the fan-out latency without a sleep, and its method+predicate filter ignores unrelated frames (e.g. thereceivedpush the recipient also gets). ROLE_ADMIN is the only admin-grantable role; accounts that already hold it can still be offered it again (a fresh pending row — the prior accept is terminal, no already-granted guard), and accept stays idempotent on the role_grant while still superseding pending siblings. Gated oncapabilities.ws.The revoke case revokes the revokee's only ROLE_ADMIN grant. On an endpoint gated on that role (
ws_required_roles) the revokee's socket loses its access by the same revoke: it is closed with WS_CLOSE_PERMISSION_REVOKED, and the notification may or may not reach it first (nothing orders the two), so the case asserts the close there and the frame on an ungated endpoint.Cross-process only: create_admitted_ws_transport needs a real bound socket, so wire it from a
*.cross.test.tsfile, never an in-process setup. Authed cookies come from the per-account session minted byfixture.create_account/fixture.create_session_headers.testing/cross_backend/role_grant_participation.ts
Cross-backend parity suite for role-gated participation conferral — the success paths. The imperative escape hatch beneath the declarative
conformance_participation_cases.ts: the multi-step flows that need a real recipient account (so a static conformance row can't express them) — an admin assigns theparticipantapp-role, and an admin offers it through the consent flow and the recipient accepts.Proves the two conferral *write* paths agree on both spines (TS spine binary + Rust
testing_spine_stub):- immediate assign —
role_grant_assignof the admin-grantableparticipantrole lands a grant and returns{ok, role_grant_id}; re-assigning the active grant is idempotent (same id). - consent flow —
role_grant_offer_createofparticipant(admin-only) → the recipientrole_grant_offer_accepts → a role_grant lands. - offer replay — re-accepting an accepted offer whose grant still
stands answers
200with the samerole_grant_idand writes no audit row; once the grant is revoked, the replay is refusedinvalid_requestwith reasonrole_grant_offer_terminal(never the revoked grant's id). - scoped assign —
role_grant_assignofparticipantwith a pairedscope_kind+scope_idlands a scoped grant carrying both, read back throughadmin_account_list, distinct from the global grant.
The single-request gate/denial matrix (grantability refusal, admin-only conferral, dispatcher admin gate, auth) lives in the declarative table (
conformance_participation_cases.ts) — keep new single-request assertions there; this suite is only for flows the table cannot carry.The
participantrole is registered admin-grantable on both spines (spine_roles / the Rust stub'sRoleRegistry), so the suite is ungated; every spine mounts the standard RPC surface it drives.- immediate assign —
testing/cross_backend/role_grant_role_grammar.ts
Cross-backend parity suite for an off-grammar
roleon the grant verbs.role_grant_assignandrole_grant_offer_createdeclareroleas RoleName (lowercase letters and underscores, no leading or trailing underscore, at most 64 characters). A role string outside that grammar is refused at input validation — JSON-RPCinvalid_params, with nodata.reason— and writes no audit row: a role is caller-chosen text until it passes the grammar, and the grant verbs record the role they refuse in their failure rows.- refused at validation — each off-grammar role, on each verb, answers
invalid_paramswith no reason, so the refusal is the input schema's and not a grant-path or authorizer one. One of them is all lowercase letters and one character past the length cap: the cap is part of the grammar, so a failure row never carries an arbitrarily long caller-chosen string. - no audit row — after the refusals, no grant audit row carries an
off-grammar role. The control keeps that read from being vacuous: an
on-grammar role no registry holds is refused past validation with
role_not_web_grantable, the refusal a failure row records — as is one exactly at the length cap, which pins the boundary.
Exists because the action-manifest parity gate captures no param schemas while the Rust spine parses params by hand — a spine that let an off-grammar role reach its grant path would answer with a different error and write the caller's text into the audit log. Both verbs are on every spine's standard surface, so the suite is ungated. Cross-process only: it reads the audit log through
_testing_drain_effects, which the spine binaries mount.- refused at validation — each off-grammar role, on each verb, answers
testing/cross_backend/rust_spine_stub_backend_config.ts
Cross-process BackendConfig preset for the non-domain spine consumer,
testing_spine_stub— a Rust binary that mounts only the spine surface (auth / account / admin / audit / role-grant offers) with no domain layer. fuz_app drives it fromsrc/test/cross_backend/*.cross.test.tsto verify its TS spec against the Rust spine end-to-end with no domain implementation in the loop — drift becomes a fuz_app failure rather than a downstream consumer's failure with mixed signals.Binary discovery — env-supplied, never hardcoded. The binary lives in a sibling Rust workspace, not in fuz_app, so the preset never bakes a path in.
FUZ_TESTING_RUST_SPINE_STUB_BIN(or thebinary_pathoption) must point at a prebuilt binary; the preset throws a clear error when neither is set rather than guessing. Build once withcargo build -p testing_spine_stub --releaseand point the env var at the resultingtarget/release/testing_spine_stub; operators / CI cache the binary across runs for fast spawns.Operator setup — the target Postgres database must exist before the harness runs (the harness never issues
CREATE DATABASE, to avoid forcing aCREATEDBgrant on the test role):createdb fuz_app_test_rust_spine_stub 2>/dev/null || trueFUZ_TESTING_RUST_SPINE_STUB_DATABASE_URLpoints the stub at another server (e.g. a scratch cluster on a spare port).The binary self-wipes the auth-namespace schema on every boot (
FUZ_TESTING_RESET_DB_ON_STARTUP=true, set by the Rust-family builder), so no manualDROP TABLEbetween sessions is needed; per-test reset is the orthogonal_testing_resetRPC action default_cross_process_setup fires.testing/cross_backend/session_cap.ts
Cross-backend parity suite for the per-account concurrent-session cap over real HTTP.
Both spines cap concurrent sessions per account (the TS query_session_enforce_limit / DEFAULT_MAX_SESSIONS, the Rust
create_capped_session). Each spine's own tests pin its half in-process; nothing in them crosses the wire, so a spine that lost the cap would fail no shared test. This suite is the pin that runs on *both*. It pins:- the cap bounds concurrent sessions, by eviction not refusal — logging in
max_sessions + 1times succeeds every time (a login is never denied for being the one over the line), and the newest cookie resolves afterward. - the evicted session is the oldest — the first login's cookie no longer authenticates once the cap is exceeded. A spine with no cap authenticates it fine, which is exactly the defect this catches.
- an evicted session's socket is closed (when
wsis supplied) — the cap deletes the session row, and a WebSocket never re-reads its session, so the login that evicts it must close the socket it opened (WS_CLOSE_SESSION_REVOKED), once the eviction has committed. A spine that only deletes the row leaves the socket running on a dead session.
Why
max_sessions + 1logins and notmax_sessions. The starting session count isn't zero and isn't guaranteed equal across impls —create_accountseeds its own session on the Rust cradle. Overshooting by one guarantees at least one eviction whatever the baseline is, and the *oldest* login is the first to go either way. Asserting on the first and last sessions rather than on a row count keeps the case wire-observable (no DB channel) and baseline-independent.Both surfaces are flat REST (
POST /api/account/login,GET /api/account/status) on every spine, so this is an imperative suite (not aconformance_tablerow) — the sibling ofcookie_attributes.ts/origin.ts/login_security.ts. Cross-process only, and load-bearingly so: each login is held on its ownfresh_transportand identified by that transport's cookie jar, which in-process is a jar-less passthrough. That also keeps the suite free of a fourthSet-Cookieparser — reading the raw header iscookie_attributes.ts's job, and this case only needs "does this login still authenticate".What it does not pin: concurrent creators. Two simultaneous logins can each evict against a stale count under Read Committed and both commit above the cap — see query_session_enforce_limit's race note. That needs a barrier-based test against one backend, not a parity case.
- the cap bounds concurrent sessions, by eviction not refusal — logging in
testing/cross_backend/setup.ts
Per-test fixture protocol shared by in-process and cross-process transports.
Each standard suite body takes a required
setup_test: () => Promise<TestFixture>callback and invokes it once per test. The fixture carries everything a test needs to fire requests and assert on a single bootstrapped keeper account — transport, account / actor identity, three header builders, a multi-account mint factory, and (in-process only) the in-memory keyring + raw backend.The cross-process producer default_cross_process_setup lives here, alongside the spawn-a-backend transport plumbing — it implements the SetupTest contract by spawning a binary and bootstrapping over real HTTP. The in-process producers (default_in_process_setup / default_in_process_suite_options, which wrap create_test_app) live in the sibling
in_process_setup.tsso this module — and the cross-process consumers that import it — stay free of the in-process Hono app and its optionalhonopeer dependency.testing/cross_backend/simple_request.ts
Cross-backend parity suite for refusing CORS simple requests.
A
POSTwith noContent-Type, or atext/plain/ form / multipart one, is what a browser sends cross-origin without a preflight — carrying the session cookie when the page is same-site, which any otherlocalhostport is. Every spine's JSON routes requireapplication/jsonand refuse anything else with a flat415{error: 'unsupported_content_type'}before reading a credential or the body: the TS spine per route (apply_route_specs, everyPOSTthat isn'traw_body), the Rust spine in each JSON handler. The cases carry an allowedOriginand, where it matters, the keeper's session — the exact shape of a same-site page's forged request, which the origin allowlist alone admits when it's a wildcard — and a refused request must leave that session untouched.Runs both legs via the shared
setup_testprotocol: in-process (auth/simple_request_parity.db.test.ts) and cross-process (cross_backend/simple_request.cross.test.ts). Every spine has the gate, so the suite is ungated.testing/cross_backend/spawn_backend.ts
Spawn a test backend binary, wait for it to come up, and return a handle the test harness drives.
Lifecycle:
- Write the bootstrap token (
config.bootstrap.token) toconfig.bootstrap.token_pathso the binary picks it up at startup. child_process.spawn(...)the binary withdetached: true— creates a new process group so aSIGTERMto the negative PID tears down any descendants the binary spawned (PTYs, child workers). vitest worker death + Ctrl+C handlers also fire the group teardown so ports never strand.- Poll
{base_url}{health_path}until it returns 2xx orstartup_timeout_mselapses. - Read
config.bootstrap.daemon_token_pathto load the binary's deterministic daemon token; thread it onto BackendHandle so_testing_resetand other keeper-credential calls can authenticate.
Bootstrapping (
POST /api/account/bootstrap) is a separate concern — the caller composesbootstrap()from testing/transports/bootstrap.ts against a FetchTransport built aroundhandle.config.base_url. Splitting the two keeps spawn_backend consumer-agnostic — fuz_app knows nothing about specific binary contents.- Write the bootstrap token (
testing/cross_backend/spine_surface_constants.ts
Pure spine-surface constants — wire paths, roles, the fixture URL, and the env-var names the backend configs set for the spawned spine binaries. A hono-free leaf split out of
default_spine_surface.ts.Cross-process suite modules (which drive a separately-spawned backend binary over HTTP) need only the wire path / role / fixture-URL, not the in-process route handlers. Importing them from
default_spine_surface.tsused to drag its eageraccount_routes.ts/signup_routes.tsimports — and through themsession_middleware→hono/cookie— onto a backend-spawning consumer with nohonopeer installed (a Rust-only spine consumer). Keeping these constants on this handler-free leaf lets such a consumer import the path without the peer.default_spine_surface.tsimports the ones it uses from here.The env-var names live here so both sides of each contract share one constant: the backend configs that set them (
default_backend_configs.ts,ts_spine_backend_config.ts,rust_spine_stub_backend_config.ts) and the spawned TS spine binary that reads them, which imports this leaf rather than the config builders.testing/cross_backend/sse_round_trip.ts
Cross-process SSE round-trip suite — the cross-process counterpart to the in-process testing/sse_round_trip.ts harness.
Where the in-process harness reads a Hono
Response.bodydirectly, this suite opens a real streamingfetchagainst a spawned backend's audit-log SSE endpoint via create_sse_transport, threading the fresh-per-test keeper's session cookie. It is the only coverage of the spawned binary's live SSE path — the standard cross-process bundle (describe_standard_cross_process_tests) omits SSE by design, so consumers call this alongside it (paralleling describe_cross_process_ws_tests).Its cases follow the in-process SSE self-test against fuz_app's standard audit-log stream:
- connects — the stream opens and emits the
: connectedcomment. - data frame (gated on
rpc_path) — a minted secondary's sessions are revoked over the keeper's admin channel (admin_session_revoke_all), broadcasting asession_revoke_allaudit event as onedata:frame to the subscribed keeper without closing its stream (the event targets the secondary, not the subscriber). The secondary is minted *before* the stream opens socreate_account's own audit events (invite / signup / login / token) don't land on it. - close-on-revoke, account-wide (gated on
rpc_path) — the subscriber's *own* sessions are revoked (account_session_revoke_all), which closes every stream of the account once the revoke commits (the handler's close, repeated by the audit guard on thesession_revoke_allrow). Asserted viaSseTransport.wait_for_close. - close-on-revoke, session-scoped (gated on
rpc_path) — the subscriber's *own* single session is revoked (account_session_revoke), which closes only the stream registered under that session's hash (the distinct close cases 2–3 don't reach). - close on account delete (gated on
rpc_pathandcapabilities.account_lifecycle) — the keeper soft-deletes a second admin whose stream is open; the delete revokes its credentials and closes its stream. - per-session cap — one stream past
max_per_scopeon one session closes the oldest (evict-oldest, no final frame) and leaves every other stream open. Needs no RPC: the cap runs inside the subscribe itself.
The close-on-revoke matrix is layered: cases 3–5 exercise the account-wide and session-scoped paths cross-process; the remaining revocation events (
token_revoke_all/logout/password_change/account_purge, all account-wide;token_revoke, token-scoped; androle_grant_revoke, role-matched) are covered by the spine'sfuz_realtimeSSE-registry unit tests and the in-process guard self-test, so a cross-processtoken_revoke_all-with-zero-tokens case (which may emit no audit row) stays out to keep the spawned-backend suite non-flaky.Gated on
capabilities.sse— backends without an end-to-end SSE stream skip (the cases still surface as.skipin the report). Cross-process only: create_sse_transport needs a real bound socket, so wire it from a*.cross.test.tsfile, never an in-process setup.- connects — the stream opens and emits the
testing/cross_backend/standard.ts
Cross-process counterpart to describe_standard_tests.
Wires the cross-process-safe subset of the standard bundle — the five suites whose option shape is
{setup_test, surface_source, capabilities, ...}and whose bodies fire requests throughfixture.transportrather than touching the in-processBackend. Consumers wire one call against a spawned binary instead of repeating the five sibling calls per file.Suites included — always run:
- describe_standard_integration_tests
- describe_round_trip_validation
- describe_rpc_round_trip_tests
- describe_data_exposure_tests
Gated on
roles— included when the consumer supplies a RoleSchemaResult:Suites omitted — the three that don't survive a process boundary, documented here so per-consumer files don't have to repeat the bookkeeping:
- describe_rate_limiting_tests — builds a fresh TestApp per test to
inject tight per-test rate-limiter overrides. That path requires
in-process construction of
Backend+ rate limiter; the spawned binary has neither knob nor restart-per-test budget. - describe_audit_completeness_tests — reaches into FK-structural
introspection that only the in-process backend exposes. Wire-level
audit observability lives in the consumer's own audit
.cross.test.tsdrivingaudit_log_list/audit_log_role_grant_history. - describe_bootstrap_success_tests — bootstrap is one-shot per
backend lifecycle, and the consumer's
globalSetupalready consumed it before the suite file loads. Re-running would 409.
testing/cross_backend/test_cell_gated_create_authorize.ts
Test CellCreateAuthorize policy mounted on both reference spines — the TS spine binary's full mount (
full_spine_mount.ts) and the Rusttesting_spine_stub— so the cross-backendcell_gated_createsuite proves the parent-aware cell-creation authorizer agrees TS↔Rust. The twin of the RustTestCellGatedCreateAuthorize, directory model.A global admin bypasses everything (`{allow: true, moderation_required: false}
) — a scopedadmin` row is no admin here. For anyone else:- Root creation (
root_idnull):kind: 'space'is admin-only (denied); every other parentless kind stays open, so the plain-createcell_crud/cell_relationssuites are unaffected. - Contribution (
root_idset): the governing root'sdata.policy[kind] = {min_role?, moderation_required?}decides — a missing entry denies, a presentmin_rolethe actor lacks globally denies, and otherwise it admits with the entry'smoderation_requiredfolded into the verdict. The root'sdataarrives ininput.root_data(the handler read it in-tx), so the predicate is pure — no DB read of its own (which also dodges the single-connection in-memory deadlock a separate handle would hit — pglet, PGlite).
- Root creation (
testing/cross_backend/testing_backdoor.ts
Cross-backend negative-credential suite for the
_testing_*backdoor actions._testing_reset/_testing_mint_session/_testing_put_fact/_testing_flood_notifications/_testing_schema_snapshot/_testing_migration_tracker/_testing_action_manifest/_testing_origin_check/_testing_shorten_credential_expiryare privileged test-binary actions the production wire never exposes — direct DB writes (full auth wipe, forged session row, raw fact insert), a flood of the caller's own connection, introspection reads (the live schema, theschema_versionmigration tracker, and the live RPC registry — the highest info-leak of the set were the gate to break), a probe of the origin allowlist parser, and a rewrite of an account's credential expiries. Their only structural fence is the daemon-token credential gate on each spec'sauthaxis. A test binary live-mounts them on its RPC endpoint (and, on fuz_app's spine binary and the Rust stub, its WS endpoint) but keeps them off the declared surface — so the spec-derived describe_rpc_attack_surface_tests never enumerates them, and nothing else fires them with a non-daemon credential to prove the gate holds end-to-end. This suite does, against each impl's real auth resolution.For every backdoor method, three principals:
- anonymous (no credential) →
401(pre-authorization auth refuses an account-less caller before anything else). - session (the keeper's browser-context cookie) →
403credential_type_required— a session cookie, even one carrying the keeper role, tops out below the daemon-token channel. - bearer (the keeper's api-token, non-browser context) →
403credential_type_required— same ceiling; an api token cannot reach keeper operations.
Over WebSocket (when
wsis supplied), the same methods sent on a socket opened with the keeper's session cookie answer the sameforbiddenerror withcredential_type_required: the upgrade admits a session, but each message runs the same per-action gates as HTTP RPC, so a socket is no way around the daemon-token channel. Anonymous and bearer have no WS case — an anonymous upgrade is refused before any message, and the upgrade client threads cookies.Each method is sent with valid params so each case varies only the credential. (Validation runs after the gates — the order is 401 → authz → 403 → 429 → 400 — so the shapes are belt-and-suspenders here rather than load- bearing.) The handler never runs (the gate refuses first), so the writes never execute.
Complements the spec-level gate check (which pins that each spec *declares*
credential_types: ['daemon_token']) and the surface-absence invariant (assert_no_testing_methods) — this one pins the runtime 401/403 behavior on both impls. Cited property:security.md§Test Backdoor Actions (daemon-token-gated, off-surface, DEV-excluded).Cross-process only — the
_testing_*actions are mounted on the spawned binary, not the in-process app — like the ws/sse suites. Wire from a*.cross.test.ts. Requires the standard_testing_*actions mounted (the same precondition default_cross_process_setup already imposes for its per-test_testing_reset); the HTTP cases are ungated, since every cross backend mounts them, and the WS cases run whenwsis passed — for a backend that also serves them on its WS endpoint.- anonymous (no credential) →
testing/cross_backend/testing_reset_actions.ts
Test-binary RPC actions for cross-process integration tests.
The daemon-token-authed actions bundled by create_testing_actions:
_testing_reset(DB wipe + keeper re-seed),_testing_drain_effects(audit barrier),_testing_mint_session(forge an expired-by-construction server-side session for the expiry conformance cases),_testing_put_fact(seed an embedded fact for the fact-serving suite),_testing_schema_snapshot(introspect the live schema for cross-impl parity diffing against a Rust backend'sfuz_dbsnapshot),_testing_migration_tracker(dump theschema_versiontracker rows for cross-impl migration-identity parity — the provenance half the snapshot gate excludes by design), and_testing_flood_notifications(queue a burst of notifications onto the caller's own WebSocket connection, to push its outbound queue past its byte budget).Three further daemon-token actions live here but are not bundled by create_testing_actions, and are appended at the full-mount layer (build_full_spine_rpc_actions) instead:
_testing_action_manifest(dump the live RPC registry for cross-impl method-set + auth-shape parity — it must enumerate *every* mounted method, so it goes where the complete list exists),_testing_origin_check(answer the origin allowlist's parse and request check for the cross-impl origin conformance table), and_testing_shorten_credential_expiry(pull an account's credential expiries in to seconds from now, shorten-only, for the credential-deadline cases) — the last two kept out of the bundle so a consumer whose Rust binary doesn't mount their mirrors keeps a green manifest gate._testing_reset— full DB wipe + keeper re-seed + optional secondary-account seeding. The handler wipes every auth-namespace row (no keeper-preserve filter), flipsbootstrap_lockback to its post-bootstrap shape, seeds a fresh keeper account inline (reusing create_test_account_with_credentials so cross-process matches in-process write semantics), seeds any caller-requestedextra_accounts(also direct-inserted at this setup step), refreshes the daemon-token cache to point at the new keeper, and fires the consumer-supplied domain-state callback. The new keeper + secondary credentials return as the action output so the per-test fixture closes over them.In-process and cross-process keeper lifetimes match: both modes run against a freshly bootstrapped keeper per test, so mutation-cascade tests (password change, revoke-all, hardcoded-username signup uniqueness) and direct keeper-vs-admin probes work uniformly cross-process.
Keeper ≠ admin. The
keeperandadminroles are independent. Keeper authorizes daemon-token / bootstrap paths; admin authorizes the user-facing admin RPC surface._testing_resetseeds the keeper account with[ROLE_KEEPER, ROLE_ADMIN]by default — matching the production bootstrap_account flow — plus any roles passed viaextra_keeper_roles. Tests probing the keeper-vs-admin separation (a keeper-only account must 403 on admin RPCs) declare a secondary viaextra_accounts: [{username, roles: [ROLE_KEEPER]}]so the account is seeded at this same bootstrap-equivalent step.No free-form runtime bypass. There is no action for arbitrary direct role grants: a runtime bypass would let tests skip the production consent flow's side-effects (audit emit, WS fan-out) and silently mask bugs in those paths. The bypass that does exist —
extra_accounts— is framed as bootstrap-time seeding, the same shape bootstrap_account itself uses to grant the initialKEEPER+ADMINpair. Tests that want a role on a *post-bootstrap* account must route throughrole_grant_offer_create+role_grant_offer_accept(the production path); they observe the full event chain.Production safety: this module lives under
cross_backend/and starts withimport '../assert_dev_env.ts';— production bundles either tree-shake the module out or throw at startup. The Rust mirror (fuz_testingcrate) ships a parallel action; `cargo xtask check-releaseblocksfuz_testing` from entering production dep graphs.testing/cross_backend/testing_server_bun.ts
Bun runtime adapter for spawnable cross-process test server binaries.
Binds
Bun.serveand@hono/bun's module-levelupgradeWebSocket+websockethandler. The shared testing/cross_backend/testing_server_core.ts owns the rest. Third sibling to testing/cross_backend/testing_server_node.ts / testing/cross_backend/testing_server_deno.ts — together the three isolate the JS-runtime axis (Node V8 / Deno V8 / Bun JSC) on identical TS surfaces, and the Rust spine binary covers the cross-language axis.Needs the optional
@hono/bunpeer dep (the Bun counterpart to Node's@hono/node-server+@hono/node-ws);Bun.serveis built in. RuntimeDeps reuse create_node_runtime — Bun implements thenode:fs/node:processsurface RuntimeDeps +cli/daemontouch.Bun.serveis declared locally (mirroring testing/cross_backend/testing_server_deno.ts'sDenodeclaration) so this module typechecks under fuz_app's Node-based config without@types/bun. It is only ever *run* under Bun.testing/cross_backend/testing_server_core.ts
Runtime-agnostic core for spawnable cross-process test server binaries.
A test binary mounts a fuz_app-derived surface over a real HTTP socket so the
cross_backend/*suites (and the cross-impl bench) can drive it the same way they drive the Rust spine. This module owns the runtime-neutral orchestration — stale-daemon check, daemon-info write, serve, post-serve WS attach, graceful shutdown — and delegates the runtime-boundary primitives (HTTP serve, WS upgrade construction, signals, pid, exit) to a TestingServerAdapter. The shipped adapters are testing/cross_backend/testing_server_node.ts (@hono/node-server+@hono/node-ws), testing/cross_backend/testing_server_deno.ts (Deno.serve+@hono/deno), and testing/cross_backend/testing_server_bun.ts (Bun.serve+@hono/bun).The app itself — routes, RPC, DB,
_testing_reset, WS endpoints — is the caller'sStartTestingServerOptions.build_appseam, so this core stays domain-free. WebSocket endpoints mount through create_app_server'sws_endpoints:build_appasks its context to prepare the runtime's upgrade helper and passes the resulting factory ascreate_upgrade_websocket; the core attaches it to the server onceservereturns (Node'sinjectWebSocket). fuz_app's owntesting_spine_serverpasses a no-domain build; consumers (zzz, fuz_forge) pass their domain build.NEVER ships in a release. This module lives under
cross_backend/and opens withimport '../assert_dev_env.ts';, which throws on production-bundle load. The runtime adapters reach for the optional@hono/node-server/@hono/node-wspeer deps; only test binaries import them.testing/cross_backend/testing_server_deno.ts
Deno runtime adapter for spawnable cross-process test server binaries.
Binds
Deno.serveand@hono/deno's module-levelupgradeWebSocket(the optional@hono/denopeer dep). The shared testing/cross_backend/testing_server_core.ts owns the rest. Counterpart to testing/cross_backend/testing_server_node.ts — together they isolate the JS-runtime axis (Deno vs Node V8) on identical TS surfaces, and the Rust spine binary covers the cross-language axis.Denoglobals are declared locally (mirroring runtime/deno.ts) so this module typechecks under fuz_app's Node-based config without adeno.jsonor@types/deno. It is only ever *run* under Deno.testing/cross_backend/testing_server_node.ts
Node runtime adapter for spawnable cross-process test server binaries.
Binds
@hono/node-server'sserve()and@hono/node-ws's two-phasecreateNodeWebSocket({app})/injectWebSocket(server): the factoryprepare_websocketreturns builds the first half from the app create_app_server assembles, and the core runs the second onceserve()returns. The shared testing/cross_backend/testing_server_core.ts owns the rest. A test binary builds this adapter and hands it to start_testing_server alongside itsbuild_appseam.@hono/node-server+@hono/node-wsare optional peer deps (same posture asws) — only test binaries import them; production bundles never reach this module (theassert_dev_envguard throws on prod load).testing/cross_backend/token_lifetime.ts
Cross-backend parity suite for the API token lifetime axis of
account_token_create— the round-trip that keeps a one-sided landing impossible.Why this suite exists
The action-manifest parity gate captures
{method, side_effects, auth}and no param schemas, while the TS spine validates params against a ZodstrictObjectand the Rust spine hand-parses withparams.get(...). So a spine that silently ignored thelifetimefield would answer the same mint request with a 200 and an eternal token — same method, same status, nothing red anywhere. The forcing function has to observe the *stored* state: mint with a TTL, then readexpires_atback off the wire.What it pins
- A TTL mint stores a bounded expiry —
account_token_createwith{kind:'ttl', days}returns a non-nullexpires_at(create output *and*account_token_list), landing within a generous window aroundnow + days. A spine that dropped the field returnsnulland fails. - An eternal mint stores NULL — the control:
{kind:'eternal'}yieldsexpires_at: nullon both reads. - An omitted lifetime is refused —
lifetimeis required; a mint without it must 400invalid_paramson both spines. This is the direct pin on the silently-ignoring-unknown-shapes hazard. - Out-of-range days are refused —
days: 0400s identically, so the two hand-rolled bounds checks can't drift. - The
namecap — the same blind spot on another field of the same mint: a name of exactly API_TOKEN_NAME_LENGTH_MAX UTF-16 code units mints and reads back whole, and one unit past it isinvalid_params, with an astral-plane name at each side of the boundary so the two spines count the same unit.
- A TTL mint stores a bounded expiry —
testing/cross_backend/token_scope_surface.ts
Cross-backend parity suite for token scoping's non-RPC surface rule — *a narrowed api token is RPC-only*.
Why this suite exists
Every existing cross-impl token-scope assertion is the
rpc:<method>arm (thetoken_scope_casesbatch in the conformance security slate). Thesurface:<name>arm had none — and it is the load-bearing half of the design. Both spines implement it, at the same named surfaces, returning a denial body that is an explicit cross-impl contract (token_scope_denied_body ↔fuz_auth::token_scope_surface_denied_response), and nothing compared the two.That gap has the shape this control family keeps finding: the per-method arm was the one everybody looked at, so it was the one that got a gate. The surface arm is where a divergence could sit indefinitely — the schema gate can't see it (no column), the action-manifest gate can't see it (no method), and the spec-derived suites can't see it (these are REST routes, not the declared RPC surface).
What it pins
- The denial body, byte for byte — status,
error, and the exactrequired_scopecapability string, per surface. A spine that renamed a surface, dropped thesurface:prefix, or emitted the JSON-RPC envelope from a REST route fails here. - The audit stream's channel denial — that surface is gated to
credential_types: ['session']on both spines, so a bearer never reaches its scope gate and the observable iscredential_type_requiredinstead. The case is kept here rather than moved: it is still the bearer-vs-surface probe, and what it now pins is the coarser-gate-first ordering both spines run (a spine that checked scope first would answertoken_scope_required).surface:audit_stream's own capability string is consequently unreachable on the default mount and has no cross-impl pin — each spine's census holds its declaration, the way the WS upgrade's is held. - Scope outranks role — a narrowed token whose account *also* lacks the
gating role must hear
token_scope_required, notinsufficient_permissions. This is the observable the TS↔Rust ordering divergence produced before both spines converged on running the surface gate ahead of the role gate; it was explicitly *invisible* to the cross-backend suites, because every scoped-token probe used a token whose account happened to hold the role.
Two non-vacuity controls hold the negatives honest: the narrowed token must still reach the RPC method it *does* name (otherwise a spine that rejected the credential outright would pass every denial), and a full bearer must reach
fact_bare(otherwise a route broken for all bearers would pass too). The second has no audit-stream twin by construction — refusing every bearer is exactly what that route's channel gate is for.Why the WS upgrade isn't here
Rule 3's third probeable surface is the WebSocket upgrade, and it is deliberately omitted: create_ws_transport threads cookies, not bearer headers, and a refused upgrade surfaces as a thrown connection error whose body — the thing this suite exists to compare — isn't readable. The HTTP surfaces are where a denial body is observable, which is what the parity gate needs. The WS gate keeps its per-spine coverage (fuz_app's surface census pins the call site; the Rust census pins its own).
- The denial body, byte for byte — status,
testing/cross_backend/transport_composition.ts
Cross-backend front-door contract: what every transport answers before its request reaches per-action dispatch.
Each spine composes its front door differently. The TS spine mounts one stack at
/api/*— body limit, then origin, then session and bearer — and each surface adds its own gates behind it. The Rust spine stacks global layers (body limit, then origin) and has each handler resolve its credential and run its own gates. Two compositions can agree on every per-surface suite and still disagree on which refusal answers when a request trips several, so this suite pins the answer per transport as a table:surface foreign Originunreadable Originduplicated Originover cap + foreign Originno credential narrowed token REST POST(/logout)403 forbidden_origin403 forbidden_origin403 forbidden_origin413 payload_too_large401 authentication_required403 credential_type_requiredREST POST(/password)— — — — 401 authentication_required— RPC POST403 forbidden_origin403 forbidden_origin403 forbidden_origin413 payload_too_large401 authentication_required403 token_scope_required(rpc:<method>)RPC GET403 forbidden_origin403 forbidden_origin403 forbidden_origin— 401 authentication_required403 token_scope_required(rpc:<method>)WS upgrade 403 forbidden_origin403 forbidden_origin403 forbidden_origin— 401 authentication_required403 token_scope_required(surface:ws_upgrade)SSE (audit stream) 403 forbidden_origin403 forbidden_origin403 forbidden_origin— 401 authentication_required403 credential_type_required- foreign
Origincarries the keeper's session cookie, so only the origin check can refuse it — a front door missing that check answers the surface's success instead. - unreadable
Originis the allowed origin with a byte past ASCII (0xE9), with the keeper's session — a check that reads such a value as absent lets it through. - duplicated
Originsends the allowed origin on two header lines, with the keeper's session — refused whether a backend sees the two lines (the Rust spine) or one value joined from them (the TS runtimes), so a check that reads only the first lets it through. - over cap + foreign
Origindeclares aContent-Lengthone byte over the shared 1 MiB cap and carries no credential: the cap answers on the header, ahead of both the origin check and auth. Only the surfaces that take a body have the column. - no credential sends an allowed
Originand nothing else. - narrowed token sends a bearer minted with a method-scoped scope, and no
Origin(a bearer in a browser context is discarded, not refused). The RPC probe calls a method the scope doesn't name. A narrowed token reaches no non-RPC surface, but on a session-only surface the credential gate answers first — the REST probe is session-only/logout, and the audit stream is session-only on both spines.
Each case asserts the status and the refusal body: a flat
{error}(plus its named fields) or, past the RPC front door, the JSON-RPC error envelope with the reason onerror.data.reason. A WebSocket refusal's body is required where the backend declarescapabilities.ws_refusal_body; where it doesn't (Node's@hono/node-wsdrops a refused upgrade's body), an empty body passes on its status and a body that is sent is still asserted.Transport. Given a
base_url, every probe is written to the socket (raw_http_exchange): an upgrade has to be, and an over-cap request sends its head alone, asbody_size.tsexplains — the 413 must arrive without a body byte. Withbase_url: nullthe probes go through the fixture's in-process transport, whole bodies included, and the WebSocket rows skip (there is no socket to upgrade).Wire it for any backend that serves the spine's account routes, RPC endpoint, WebSocket endpoint and audit stream; the WS and SSE rows gate on
capabilities.wsandcapabilities.sse.- foreign
testing/cross_backend/ts_spine_backend_config.ts
Cross-process BackendConfig presets for fuz_app's domain-free TS spine test binary (
src/test/cross_backend/testing_spine_server_{node,deno,bun}.ts).The TS analog of rust_spine_stub_backend_config (which spawns the Rust spine): these spawn fuz_app's own TS impl over real HTTP with no domain layer, so the
cross_backend_ts_node/cross_backend_ts_deno/cross_backend_ts_bunself-test projects verify fuz_app's wire path in its own repo across all three JS runtimes. All run against in-memory pglet (memory://) — no external Postgres, unlike the Rust path.The binary writes its daemon token to
{FUZ_TESTING_TS_SPINE_DIR}/run/daemon_token; anchoring the dir topaths.rootmakes that equalpaths.daemon_token_path, which spawn_backend reads after the health probe.testing/cross_backend/wire_shapes.ts
Wire-shape assertions shared by the cross-backend suites.
A field can carry the right value on both backends and still be a parity defect if the two serialize it differently — a millisecond
2026-05-17T12:34:56.789Zand a second-precision2026-05-17T12:34:56Zboth parse as the same instant, so a Zodz.string()check and anassert.ok(value)pass on either. These helpers pin the bytes.testing/cross_backend/ws_action_rate_limit.ts
Cross-backend parity suite for action rate limiting over a real WebSocket, and its budget shared with HTTP RPC.
An action spec's
rate_limitclass ('ip'/'account'/'both') is charged by the dispatcher on both transports, and a server holds one limiter per axis, so an action's budget is per caller, not per transport. The in-process tests pin that through an assembled app; this suite pins it over the wire, against backends spawned with small shared limiters (the action rate-limit toggles — ACTION_RATE_LIMIT_ENABLED_ENV and ACTION_RATE_LIMIT_MAX_ATTEMPTS_ENV — seeglobal_setup_login_security.ts):- account axis over WS —
account_token_create(`rate_limit: 'account'on both spines) with malformed params: the firstmax_attempts` calls each answerinvalid_params(the throttle runs before params validation, so a malformed call still charges), and the next answersrate_limitedwith aretry_after. - one account bucket across transports — calls split between HTTP RPC and
WS exhaust one budget: the call past the cap is refused on WS and on HTTP
(
429). - IP axis, keyed by the resolved client IP —
peer/ping(`rate_limit: 'ip', public) with malformed params, from oneX-Forwarded-For` address over anonymous HTTP and over a WS whose upgrade carried the same header: the call past the cap is refused on both, while a second address keeps its own budget on both. - account axis at the dispatchers —
cell_create(`rate_limit: 'account'`, on both spines' RPC and WS mounts) with malformed params: the call past the cap answers429over HTTP andrate_limitedover WS, calls split between the two transports exhaust one budget, and a budget split betweencell_createandaccount_token_createis one bucket.
Charge sites. The TS spine charges every classed action in its dispatcher. The Rust stub has two sites holding the same limiters: the auth families (
account_token_create) charge inside their handlers, and the dispatcher states charge every other classed spec. So theaccount_token_createcases pin the Rust in-handler site,peer/pingpins the Rust WS dispatcher's IP axis, andcell_createpins the account axis of both Rust dispatchers, RPC and WS.Isolation. Limiter state is in-memory and
_testing_resetwipes only the database, so every case reads a bucket no other case touched: the account cases each run on the fresh keeper the reset seeds (a new account id), and the IP case uses addresses no other case sends. Login limiters are also on in this project; nothing here logs in.Cross-process only: the composition under test is the spawned binary's (create_app_server threading one limiter pair to both mounts on TS; the stub's dispatcher states plus its auth-family builders on Rust).
- account axis over WS —
testing/cross_backend/ws_connection_cap.ts
Cross-backend parity suite for the per-account WebSocket connection cap over a real upgrade.
Both spines bound an account's concurrent WebSocket connections (BackendWebsocketTransport's
max_connections_per_accounton the TS side, the Rustfuz_realtimeConnectionRegistryon the other; both default to DEFAULT_MAX_CONNECTIONS_PER_ACCOUNT) with the same policy: evict-oldest — a connection past the cap is always admitted, and the account's oldest is closed with WS_CLOSE_CONNECTION_LIMIT and the reasonconnection limit. A client reads that close as "superseded": closed until the user acts, not revoked and not reconnecting (FrontendWebsocketClient.superseded). The code, the reason, and which socket closes are therefore wire contract, and this suite is the pin that runs on both. Two cases:- the oldest is superseded, and it is not a revocation — on one session, opening one socket past the cap closes exactly the first with the connection-limit code and reason; every other socket, the newest included, stays open and still dispatches. The session then upgrades again, which a revoked session could not — and that upgrade supersedes the next-oldest in turn.
- a closed connection frees its slot — one socket held open while a cap's worth of others open and close in sequence is never evicted. A backend that leaks closed connections in its registry counts them toward the cap and closes the held socket, which is what this catches.
Sockets open in sequence, each answering a
heartbeatbefore the next opens (create_admitted_ws_transport), so the backend registered them in that order and "oldest" names the same socket on both spines (an upgrade answers its handshake before the connection is registered and admitted, so the handshake alone doesn't order them).Consumer-agnostic, like describe_cross_process_ws_tests: it drives only the
heartbeatprotocol action, present on every WS endpoint. Gated oncapabilities.ws; cross-process only (create_ws_transport needs a real bound socket). Each case opens more than a cap's worth of real sockets, which is why it's a separate call rather than more cases on the WS round-trip suite.testing/cross_backend/ws_inbound_dispatch.ts
Cross-process suite for the two refusals a WebSocket request can meet ahead of dispatch — the same on both spines, pinned over a real upgrade:
- a request whose id names a request still running on the socket is
answered
invalid_requestwithdata.reason: 'duplicate_request_id'and not dispatched; the running request keeps the id and answers as usual, and once it has answered the id is free again - past the per-socket in-flight ceiling (128 on both spines) a new request is
answered
queue_overflowwith its id and not dispatched; the socket stays open, the running requests still answer, and the duplicate check comes first
A request has to stay in flight for either to be observable, so the suite holds requests with
peer/ping: the server's handler pings the client back and waits for the echo, and the client's responder withholds it until the test releases it. Gated oncapabilities.peer_requestfor that reason.The wire literals are deliberate independent copies of the producers' constants (ERROR_DUPLICATE_REQUEST_ID, MAX_INBOUND_DISPATCHES_PER_CONNECTION, WS_INBOUND_DISPATCH_OVERFLOW_MESSAGE on TS; their Rust twins in
fuz_realtime) — parity by test, so a value renamed on one side fails here instead of moving both at once.Cross-process only: create_ws_transport needs a real bound socket, so wire it from a
*.cross.test.tsfile.- a request whose id names a request still running on the socket is
answered
testing/cross_backend/ws_outbound_overflow.ts
Cross-backend parity suite for the per-connection outbound bound over a real upgrade.
Both spines hold what waits to be written to one WebSocket to a byte budget (BackendWebsocketTransport's
outbound_queue_byteson the TS side, the Rustfuz_realtimeoutbound queue on the other; both default to 4 MiB, each waiting frame charged its UTF-8 length plus 64 bytes). A frame that does not fit closes the socket with WS_CLOSE_OUTBOUND_OVERFLOW and the reasonoutbound queue overflow; the credential stays good, so a client reconnects under its ordinary backoff. The code and the reason are therefore wire contract, and this suite is the pin that runs on both.It drives the backend's
_testing_flood_notificationsaction, which queues a burst of notifications onto the caller's own connection in one synchronous run — so the burst waits at once whether or not the client is reading, and the overflow is deterministic. The cases:- within the budget — a small burst arrives whole and in order, ahead of its response, and the socket stays open.
- large, within the budget — a burst of a few mebibytes, most of the default budget, arrives the same way: every frame whole, in order, the response last. Megabyte frames can outrun a socket's buffer, and where they do — the Deno TS spine, as observed; Node and Bun write the burst through — the frames behind the first wait on the backend's write pump.
- overflow — a burst far past the budget closes the socket with the code and the exact reason; the response of the flooding request never arrives; any flood frames that did arrive form a contiguous prefix of the burst. A second connection on the same account keeps dispatching throughout.
- refusal — a burst past the action's own size limit is refused with
invalid_params, and the socket stays open. - no connection — over HTTP RPC the action refuses with
invalid_paramsand reasonno_connection, rather than answering as though it flooded. - the action's limits — over HTTP RPC a call at each of the action's
limits (
count,size,count×size) is refused for having no connection, and a call one past each is refusedinvalid_paramswith no reason. Both spines check the limits before they look for the connection and before they build a payload, so the boundaries are read off the refusal and no burst is ever allocated — which pins the two spines' limits equal.
What the suite does not assert, because it is each implementation's own: how many frames accepted before the overflow reach the client (the Rust spine discards them all; the TS spine may deliver the first), and the exact boundary a burst of tiny frames overflows at.
The wire literals are deliberate independent copies of the producers' constants — parity by test, so a value changed on one side fails here instead of moving both at once.
The flood action is gated on the daemon token, so the flooding socket upgrades with it and no
Origin(both spines discard a daemon token presented in a browser context). Gated oncapabilities.ws; cross-process only (create_ws_transport needs a real bound socket), and only for a backend that mounts_testing_flood_notificationson its WS endpoint.testing/cross_backend/ws_role_gated.ts
Cross-process role-gated WebSocket suite — a socket admitted by an endpoint's role gate (register_ws_endpoint's
required_roles) loses it with the role.A role gate decides who may hold a socket, and so who receives the endpoint's server pushes, which reach every admitted connection with no per-recipient filter. So both spines record the gating roles on each registration and close the account's gated sockets when a global grant of a listed role is revoked — with WS_CLOSE_PERMISSION_REVOKED, not the session-revoked code, since the credential is still good — and send nothing after the close. The ungated sockets of the same account, on the same transport, stay open: the gate is per endpoint, not per account. A scoped grant never admits at a role gate, so revoking one closes nothing.
Runs against a backend that mounts, beside its ungated
ws_path, a second endpoint atgated_ws_pathgated ongated_roles— which must include ROLE_ADMIN (the revoked global grant) and a role that can be granted both globally and scoped (the scoped revoke). fuz_app's spine binaries mount SPINE_ROLE_GATED_WS_PATH gated on SPINE_ROLE_GATED_WS_ROLES. Gated oncapabilities.wsandcapabilities.ws_role_gated; cross-process only.testing/cross_backend/ws_round_trip.ts
Cross-process WebSocket round-trip suite — the cross-process counterpart to the in-process testing/ws_round_trip.ts harness.
Where the in-process harness drives register_action_ws against a fake Hono upgrade (no wire), this suite performs a real
WebSocketupgrade against a spawned backend via create_ws_transport (thewsnpm package), so the actual upgrade handshake + per-connection auth + JSON-RPC dispatch over the socket are exercised end-to-end. It is the only coverage of the spawned binary's live WS path — the standard cross-process bundle (describe_standard_cross_process_tests) omits WS by design, so consumers call this alongside it.Consumer-agnostic, apart from the cases
rpc_pathandcapabilities.ws_account_actionsgate. Every other case drives theheartbeatprotocol action, which assert_ws_endpoints_include_protocol_actions guarantees is present on every WS endpoint — so the suite needs no knowledge of a consumer's domain WS methods. It validates the transport, not the domain.Three cases mirror the upgrade stack register_ws_endpoint wires (origin check → require_auth → dispatch): an authenticated upgrade round-trips
heartbeat(the first case), and an anonymous upgrade and a disallowed-origin upgrade are refused (after the spine-parity cases). Per-connection auth is enforced at upgrade time (not per message), so the negative cases assert the upgrade itself rejects rather than a per-message error frame.Spine-parity cases follow the first. Both spines admit a connection only after re-reading its credential, and requests that arrive first wait for that rather than being dropped: requests sent the moment the socket opens are all answered, and — gated on
capabilities.ws_handshake_pipelining, with the ordering forced — so are frames written in the same TCP write as the upgrade request (driven through connect_raw_ws, since a client library sends nothing before itsopenevent).heartbeatanswers the parameterless shapes (absent,{}) and refuses a declared param withinvalid_params, and a message over the backend's cap (max_message_bytes, default DEFAULT_WS_MAX_MESSAGE_BYTES) closes the socket with WS_CLOSE_MESSAGE_TOO_BIG.The cases after the refused upgrades (gated on
rpc_path) cover server-initiated close. Per-message dispatch never re-checks credential validity, so an open socket ends only when a revocation closes it — which every revocation does, once its transaction commits. An authenticated socket is dropped when the account's sessions are revoked over the keeper's session channel (account_session_revoke_all), and when its account is deleted (account_delete, also gated oncapabilities.account_lifecycle). Omitrpc_pathto skip them (consumers without the standard account and admin actions on their RPC endpoint).The last cases are gated on
capabilities.ws_account_actions— the backend's WS endpoint mounts the self-service account actions: a socket that revokes its own sessions *over that socket* is closed with WS_CLOSE_SESSION_REVOKED, and — on a backend declaringcapabilities.ws_self_revocation_reply— reads the reply to its request first. The TS spine queues the close behind the commit and sends the reply ahead of it, so the caller is never left without an answer; the Rust spine does not answer yet, which that flag records. Under the same gate (andrpc_path), a socket mints an API token over WS and the token'stoken_createaudit row is read back after_testing_drain_effects— the barrier a WS mutation needs, since a WS reply can precede its audit write. The binary's create_testing_actions mount provides the drain.Gated on
capabilities.ws— backends without an end-to-end WS transport skip (the cases still surface as.skipin the report). Cross-process only: create_ws_transport needs a real bound socket, so wire it from a*.cross.test.tsfile, never an in-process setup.testing/cross_backend/xfail.ts
xfail_until — mark a deferred-by-design gap as an expected failure.
A thin wrapper over vitest's
test.failsthat bakes a tracking id + reason into the test label. Two properties make it the right tool for declared gaps (distinct from in-scope gaps, which fail loud as a normal redtest):- Visible — the case shows in the report as a distinct expected
failure, not a silent
.skip, so a deferred gap never disappears from view. - Self-cleaning —
test.failsturns red the moment the body stops throwing (i.e. the impl starts passing), forcing whoever closed the gap to delete the marker. A.skipwould rot silently; this can't.
Sibling to test_if in testing/cross_backend/capabilities.ts. No taxonomy — a one-line marker with a tracking id and a reason, nothing more.
- Visible — the case shows in the report as a distinct expected
failure, not a silent
testing/daemon_token_rotation.ts
Daemon-token producer — rotation + file persistence, test-harness only.
No production assembly mints daemon tokens: the credential's only remaining role is the cross-process test harness's keeper channel (
_testing_resetetc.), so the producer lives here behindassert_dev_envwhere it cannot reach a production bundle. The *consumer* half — validating a presentedX-Daemon-Token— stays in auth/daemon_token_middleware.ts, mirroring the Rust spine, whose producer is confined tofuz_testing.The token file is written atomically at mode
0600via write_file_atomic (unique temp name + exclusive create), so it never exists group/other-readable — the old optional-chmodpattern let a default umask land it at0644.testing/data_exposure.ts
Composable data exposure test suite.
Verifies that sensitive database fields never leak through HTTP responses:
- Schema-level: walks JSON Schema output/error schemas for blocklisted property names
- Runtime: fires real requests and checks response bodies against field blocklists
- Cross-privilege: verifies admin routes return 403 for non-admin users, and non-admin responses exclude admin-only fields
Cadence: per-describe
setup_test()call (see testing/round_trip.ts module docstring). The runtime body manages its own request ordering (auth-free → cross-privilege → 2xx) to avoid session-invalidation contamination between assertions, so per-test fixture re-creation isn't needed.testing/db_entities.ts
DB-backed entity factories for tests that need real
account+actorrows in the database.Companion to testing/entities.ts — that file ships in-memory factories (create_test_account, create_test_actor) for tests that mock the DB; this file ships factories that hit a real Db so query-level tests don't reimplement the same query_create_account_with_actor wrapper in every file.
For full-fledged test accounts that also need an API token + signed session cookie + role_grants, use bootstrap_test_keeper (keeper) or create_test_account_with_credentials (additional accounts) from testing/app_server.ts instead.
testing/db.ts
Test database fixtures for parameterized testing.
Provides factory builders for creating test databases with pglet-wasm (the in-memory default), PGlite (in-memory) and pg (PostgreSQL) drivers. Consumer projects provide their own schema initialization via run_migrations and compose factories into test suites.
@example
import {create_default_db_factory, create_pg_factory} from '@fuzdev/fuz_app/testing/db.ts'; import {run_migrations} from '@fuzdev/fuz_app/db/migrate.ts'; import {auth_migration_ns} from '@fuzdev/fuz_app/auth/migrations.ts'; const init_schema = async (db) => { await run_migrations(db, [auth_migration_ns]); }; const default_factory = create_default_db_factory(init_schema); const pg_factory = create_pg_factory(init_schema, process.env.TEST_DATABASE_URL); export const db_factories = [default_factory, pg_factory];testing/entities.ts
Shared test entity factories for Account, Actor, RoleGrant, AuditLogEvent, RequestContext, and API tokens.
Override types widen branded Uuid fields to
stringso tests can pass literal ids without per-call-site casts. The factory brands internally.Uses
create_test_*names to avoid collisions with realcreate_account_with_actorfrom auth/account_queries.ts.testing/error_coverage.ts
Error reachability coverage tracking.
Tracks which declared error statuses (and specific error codes) are actually exercised in tests. ErrorCoverageCollector records status codes (optionally with body
errorcodes) observed during test runs, then assert_error_coverage compares against declared error schemas to find uncovered error paths — reporting per-code when the declared schema is a literal or enum, per-status otherwise.testing/integration_helpers.ts
Integration test helpers — route lookup, response validation, and cookie utilities.
testing/integration.ts
Standard integration test suite for fuz_app auth routes.
describe_standard_integration_tests creates a composable test suite that exercises the full middleware stack (origin, session, bearer_auth, request_context) against a real database. Consumers call it with their route factory and session config — all auth route tests come for free.
Tests use stub_password_deps (deterministic hashing, no Argon2 overhead). Login handlers call
verify_password(submitted, stored_hash)which works because both hash and verify use the same stub logic.Rate limiters are disabled by default — tests make many login attempts and would trigger limits otherwise.
testing/middleware.ts
Table-driven middleware test helpers.
Provides mock builders for bearer auth middleware dependencies, a generic test runner that iterates case tables, and a reusable middleware stack factory for integration testing.
testing/mock_fs.ts
In-memory file system mock for tests that use dependency-injected
read_fileandwrite_filecallbacks. Avoids module-level mocking.testing/rate_limiting.ts
Rate limiting integration test suite.
Verifies that sensitive routes (login, bootstrap, token creation) enforce rate limits when rate limiters are enabled. Tests create a tight rate limiter (2 attempts / 1 minute) and fire requests until 429 is returned.
Consumers call describe_rate_limiting_tests with their route factory and session config — rate limit enforcement tests come for free.
Each test body constructs its own TestApp with a per-test rate limiter override in
app_options, so this suite reads its inputs directly from the options bag instead of going through the per-test fixture protocol — the single-fixture model can't carry three different rate-limiter configurations. Consumers passdefault_in_process_suite_options(...)anyway for shape uniformity with the other Tier 1 suites; the extra{setup_test, surface_source, capabilities}fields from the helper spread are ignored by TS and the suite body alike.testing/role_grant_helpers.ts
RPC-flow helpers for role_grant lifecycle in tests.
Sibling to testing/db_entities.ts's create_test_role_grant_direct — that one seeds a role_grant directly via query_create_role_grant (bypassing the consent flow) for tests that focus on revoke or isolation semantics. This file ships the RPC-driven complement: role_grant_offer_and_accept exercises the same
role_grant_offer_create+role_grant_offer_acceptspecs the admin UI consumes, so consumer tests pick up post-commit fan-out (audit, SSE broadcasts,_supersedenotifications) end-to-end.testing/round_trip.ts
Schema-driven round-trip validation test suite.
For every route spec in the supplied
surface_source, generates a valid request (auth, params, body) and validates the response against declared output or error schemas. DB-backed via the suite'ssetup_testfixture-producing callback.Cadence: per-describe
setup_test()call. Each test in the suite fires one HTTP request against a shared fixture — no test mutates state in a way that contaminates the next, so the per-test cost of re-bootstrapping isn't justified here. Suites with state-isolation requirements (integration, admin_integration, audit_completeness) callsetup_test()per test.testing/rpc_attack_surface.ts
Composable RPC attack surface test suite.
Three test groups for JSON-RPC 2.0 endpoints:
- Auth enforcement — per-method auth inside the dispatcher
- Adversarial envelopes — malformed JSON-RPC requests
- Adversarial params — schema-invalid params per method
Uses the same
{build, roles}config as describe_adversarial_auth and describe_adversarial_input. No DB needed — uses stub deps.testing/rpc_helpers.ts
JSON-RPC test helpers — request construction, response assertion, and one-shot call ergonomics.
Shared by testing/rpc_attack_surface.ts, testing/rpc_round_trip.ts, and consumer-facing admin/audit suites that exercise RPC methods directly.
testing/rpc_round_trip.ts
Schema-driven round-trip validation for RPC endpoints.
For every RPC method, generates valid params and fires JSON-RPC requests (POST for all methods, GET for reads), validating that responses are well-formed JSON-RPC. Successful responses are validated against the method's declared output schema. DB-backed via the suite's
setup_testfixture-producing callback.Cadence: per-describe
setup_test()call (see testing/round_trip.ts module docstring). RPC round-trip tests fire one JSON-RPC envelope per method-direction and don't mutate state in a way that contaminates the next case.testing/schema_generators.ts
Schema-driven value generation helpers for testing.
Walks Zod schemas to generate valid values for route params, request bodies, and URL paths. Used by adversarial input, adversarial 404, and round-trip validation test suites.
testing/schema_introspect.ts
PostgreSQL schema introspection — produces a normalized, JSON-serializable snapshot of a database's structure for cross-impl parity checks.
The snapshot covers:
- Tables with columns (data type, nullability, default, identity)
- Indexes with canonical Postgres-rendered definitions
- Constraints (CHECK, FOREIGN KEY, PRIMARY KEY, UNIQUE, EXCLUSION)
- Sequences with data type — distinguishes
int4(SERIAL) fromint8(BIGSERIAL) - Enum types (
CREATE TYPE ... AS ENUM) with labels in declared order — so a label set / ordering drift (e.g.cell_visibility) is a gated fact, not invisible
Designed for
pg_catalogintrospection — it runs on PostgreSQL, PGlite and pglet. The snapshot is fully deterministic: every collection sorts by a stable key and excludes time-varying fields likeapplied_at.Index and constraint definitions are compared as the engine renders them, so both sides of a parity comparison need engines that render alike. pglet leaves an index's modifiers out of
pg_indexes.indexdef— per-key sort direction,NULLSplacement, operator classes,NULLS NOT DISTINCT— and that misleads a comparison in both directions: a pglet snapshot diffed against a PostgreSQL one reports every index that uses a modifier, with identical DDL on both sides (a false difference), and two pglet snapshots agree when the two schemas differ only in a modifier (a false agreement). So query_schema_snapshot throws on pglet when the snapshot holds an index, unless the caller passesaccept_pglet_index_definitions. Take both snapshots of a parity comparison on PostgreSQL.Paired with testing/schema_parity.ts for comparison + assertion helpers.
testing/schema_parity.ts
Cross-impl schema parity — structural diff + assertion over two SchemaSnapshots captured via query_schema_snapshot.
Two live impls (TS fuz_app vs Rust spine) are each other's parity reference. After both bootstrap, snapshot each, diff, fail loudly on drift. The diff entries name the specific divergence (column type, missing index, constraint absent on one side) so the error message points at the source.
Consumer pattern (in zzz's integration runner or fuz_app's own cross-backend tests):
const snapshot_a = await query_schema_snapshot(db_after_deno_bootstrap); const snapshot_b = await query_schema_snapshot(db_after_rust_bootstrap); assert_schema_snapshots_equal(snapshot_a, snapshot_b, {a: 'deno', b: 'rust'});Non-coverage — drift the gate does not detect:
- regular triggers (
pg_trigger);CONSTRAINT TRIGGERis captured via pg_constraint, but standaloneCREATE TRIGGERis not - views, materialized views, functions, procedures
- table storage parameters (fillfactor, tablespace, autovacuum settings)
- column physical order — the snapshot keys columns by name, so two
impls with the same columns in different declaration order compare
equal (functional parity is preserved;
SELECT *ordering is not) COMMENT ON ...- the
schema_versionmigration tracker (always excluded — framework bookkeeping, not domain schema) - permissions /
GRANTs
None of these are used by the current fuz_app auth schema. Extend query_schema_snapshot + SchemaDiff if a consumer's schema reaches for them; omitting them today keeps the diff surface focused on what fuz_app actually emits.
- regular triggers (
testing/schema_ready_fixture.ts
Gen-time helper for the
/readyschema-drift probe's committed fixture.A consumer commits an
expected_schema.json(the column map a fresh full migration-chain bootstrap produces) and serves it through create_ready_route_spec. This helper keeps the regenerate-and-verify test that guards that fixture to ~10 lines: it introspects a freshly-bootstrapped DB, writes the fixture when an update flag is set, and reads it back so the caller can assert the committed copy equals a fresh bootstrap — the assertion that fails when the fixture drifts from the DDLs, so the runtime expectation can't silently fall behind.testing/sse_round_trip.ts
Schema-driven SSE route validation test suite.
Complements describe_round_trip_validation (which skips SSE routes). For each configured SSE route:
- Open the stream with matching auth.
- Assert the
: connectedcomment is emitted. - Fire
trigger()— expect onedata: {...}frame. - Validate the payload as
{method, params}against declared EventSpecs. - Fire
session_revoke_allfor the account and assert the stream closes (whenassert_closes_on_revoke !== false).
testing/standard.ts
Combined standard test suite helper.
Bundles every DB-backed suite carrying the standard option shape, each gated on its relevant config — silent-skip when the gate isn't met (same precedent as describe_standard_admin_integration_tests skipping when
rolesisn't provided). Consumers wire the standard surface in one call instead of seven; forgetting a suite no longer silently loses coverage.Attack surface suites stay separate — their option shape is
{build, snapshot_path, expected_public_routes, ...}rather than{setup_test, surface_source, capabilities}. A peer bundler lives for that side if/when needed.testing/stubs.ts
Stub factories for auth surface testing.
Provides throwing stubs (catch unexpected access), no-op stubs (allow access without side effects), and pre-built bundles for AppDeps.
testing/surface_invariants.ts
Surface invariant assertions for AppSurface data.
Two categories:
- Structural — validate internal consistency (error schema presence, descriptions, duplicates, middleware propagation, error schema validity, error code consistency). No options needed.
- Policy — enforce security policy (sensitive routes rate-limited, no public mutations, mutation method conventions). Configurable with sensible defaults.
Structural invariants catch schema/surface generation bugs. Policy invariants catch security misconfigurations. Both propagate automatically to consumers via assert_surface_invariants.
testing/test_credentials.ts
Shared test credential constants — deliberately hono-free.
DEFAULT_TEST_PASSWORD is the single source of truth for the password the in-process and cross-process test fixtures bootstrap and log in with. It lives in this leaf module (rather than
app_server.ts) so the cross-process setup helpers can import it without pulling in the in-process Hono test app —app_server.tstransitively reaches create_app_server (new Hono()), so any value import from it forces the optionalhonopeer onto Rust-only consumers whose cross-process suites never spawn the TS backend.Single-sourcing the constant keeps the in-process bootstrap, the cross-process
mint_accountsignup/login, and the integration suite's hardcoded login bodies from drifting — a divergence becomes a typecheck miss rather than a runtime password mismatch.testing/testing_rate_limiter.ts
Test-only RateLimiter subclass with bucket tracking + reset_all.
Production code never instantiates this; test binaries swap it in for production RateLimiter so
_testing_resetcan clear every bucket between cases. Mirrors theTestingArgon2idHasherpattern from the Rust spine — same call surface as the production class, plus test-only knobs (reset_all,tracked_keys).Constructor + every overridden method preserve production semantics by delegating to
super.*after tracking;reset_allwalks the tracked-keys set and callssuper.resetper entry. Tests that depend on burst behavior get identical results between production and test instantiations.Usage in a test binary:
import {TestingRateLimiter} from '@fuzdev/fuz_app/testing/testing_rate_limiter.ts'; const limiter = new TestingRateLimiter(default_login_ip_rate_limit); await create_app_server({backend, login_ip_rate_limiter: limiter, ...}); // Inside the `_testing_reset` handler's `reset_state`: limiter.reset_all();testing/transports/bootstrap.ts
Stateless cross-process bootstrap.
POSTs
{bootstrap_path}against the running test binary with the preconfigured token + username + password, parses the BootstrapOutput envelope, captures the sessionSet-Cookieonto the supplied FetchTransport (which carries it on every subsequent call), and returns the keeper credentials.Fires exactly once per backend lifetime — spawn_backend calls it inside vitest's
globalSetup. Per-test fixtures re-use the captured keeper credentials; fresh per-test accounts come fromfixture.create_account()(signup+login through production RPC), not re-bootstrap. The hybrid reset model in default_cross_process_setup depends on this — re-bootstrap would race the bootstrap lock and in-memory caches.testing/transports/fetch_transport.ts
Cookie-threading HTTP transport for cross-process tests.
Wraps the global
fetchagainst a base URL, carries cookies across requests in aMap-backed jar so the session cookie set on bootstrap is re-sent on every subsequent call. Satisfies the RpcTestTransport shapeHono.requestalready does — so every suite body that takestransport: RpcTestTransportworks against a cross-process binary unchanged.The
Originheader is threaded onto every request because the backend's allowlist ({ZZZ,ZAP,FUZ}_ALLOWED_ORIGINS) rejects mutations without anOrigin. Cross-process tests run withALLOWED_ORIGINS=http://localhost:*, so defaultingoriginto the configuredbase_urlis safe.The cookie jar is intentionally simple — it does not honour
Domain,Path,Expires, orSameSiteattributes. Cross-process tests always hit a single host:port, so name-keyed last-write-wins matches the behaviour real browsers exhibit against the same surface.testing/transports/raw_http.ts
Raw-socket HTTP/1.1 exchanges, for tests that need to control exactly which bytes reach the server.
fetchframes a request itself and sends all of it, so some server behaviors can't be reached through it: what a server does with two requests in one write, with duplicate or malformed headers, with a request head whose body never follows, or with a chunked body that is never terminated. This writes caller-built bytes overnode:netand hands back what the server sent, plus just enough parsing to read a status or one whole response.Everything is
latin1-decoded, so a string's length is its byte length.Plain TCP only: an
https:URL is not spoken to over TLS, and the host must be a name or an IPv4 address — a bracketed IPv6 literal does not connect.testing/transports/sse_frame_reader.ts
SSE frame reader over a
ReadableStreamDefaultReader<Uint8Array>.Transport-agnostic core shared by the in-process SSE route suite (testing/sse_round_trip.ts, reading a Hono
Response.body) and the cross-processtransports/sse_transport.ts(reading a streamingfetchbody):\n\n-delimited framing, a per-read timeout (so vitest can't hang on a stalled stream), andwait_for_closefor server-initiated close detection (the auth-guard revocation seam).testing/transports/sse_transport.ts
Cross-process Server-Sent Events transport.
Opens a real streaming
fetchagainst a spawned test binary's SSE endpoint, threading the session cookie captured by the sibling FetchTransport so the stream authenticates as the same account, then delegates frame parsing to the shared create_sse_frame_reader. Uses only built-in streamingfetch+TextDecoder— no extra dep.Mirrors how testing/transports/ws_transport.ts is the cross-process counterpart to the in-process WS harness; the in-process SSE route suite (testing/sse_round_trip.ts) shares the same create_sse_frame_reader over a Hono
Response.body.testing/transports/ws_client.ts
Shared WebSocket client surface for cross-backend tests.
WsClient is the interface every in-process or cross-process WS test driver implements —
send/request/close/messages/wait_for. Two impls today:- In-process — create_ws_test_harness in testing/ws_round_trip.ts. Drives register_action_ws against a fake Hono upgrade so the dispatcher's full path runs without the wire upgrade.
- Cross-process — create_ws_transport in testing/transports/ws_transport.ts.
Wraps the native
WebSocketupgrade against a real running binary, threading the session cookie captured by FetchTransport.
Wire-frame types + predicates also live here so both impls (and every shared assertion helper) reference one source.
testing/transports/ws_raw_client.ts
A raw-socket WebSocket client for tests that need to misbehave, or to control exactly which bytes reach the server when.
A client library answers a close frame itself and sends nothing until its
openevent, so two server behaviors can't be reached through one: what the server does with a client that withholds its close frame, and what it does with frames that arrive with the handshake, before the connection can have been admitted. This speaks just enough RFC 6455 overnode:netto do both — the upgrade request, masked client text frames, and unmasked server frames — and deliberately never replies to a close.Small frames only (payloads under 64 KiB, unfragmented), which is all the JSON-RPC traffic the suites send.
testing/transports/ws_transport.ts
Cross-process WebSocket transport.
Implements the shared WsClient interface (see testing/transports/ws_client.ts) over a real
WebSocketupgrade against a spawned test binary. The cookie captured by the sibling FetchTransport on bootstrap is threaded onto the upgrade request so the WS session authenticates as the same account.Uses the
wsnpm package — Node's nativeWebSocket(from undici) follows the WHATWG spec strictly and doesn't accept custom request headers on construction, so cookie-on-upgrade requires thewspackage'sheadersoption. fuz_app declareswsas an optional peerDependency — consumers wiring cross-process tests install it themselves (npm install --save-dev ws).testing/ws_round_trip.ts
In-process test helpers for WebSocket JSON-RPC round-trips.
Drives register_action_ws without an HTTP server. Consumers supply specs + handlers; the harness constructs real
WSContextinstances backed by test-ownedsend/closepairs, fakes the authenticated Hono context (request_context, credential type, session id, api token id), and exposes aconnect()factory returning a WsClient per connection.Two layers are exported:
- Primitives (create_fake_ws, create_fake_hono_context, create_stub_upgrade, MinimalActionEnvironment, dispatch_ws_message) — used by fuz_app's own dispatcher tests and by consumers wiring tight one-off tests. - Harness (create_ws_test_harness) — the high-level driver. Give it specs + handlers, get back
{transport, connect()}.connect()is async and resolves afteron_socket_opencompletes, so broadcasts sent immediately afterawait harness.connect()reach the client. Returns a WsClient (shared interface — seetransports/ws_client.ts); the same interface is implemented bytransports/ws_transport.tsfor cross-process tests.Wire-frame types + predicates (is_notification, is_response_for, JsonrpcNotificationFrame, ...) live in
transports/ws_client.tsso both in-process and cross-process drivers reference one source.Hono's wire upgrade is skipped — there is no server, so no runtime adapter — but the full dispatch path is exercised (per-action auth, input validation,
ctx.notifyback to the originating socket, broadcast via BackendWebsocketTransport, and close-on-revoke).