@fuzdev/fuz_app 0.135.0 → 0.136.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/actions/CLAUDE.md +2 -2
- package/dist/actions/transports_ws_backend.d.ts +1 -1
- package/dist/actions/transports_ws_backend.js +1 -1
- package/dist/auth/CLAUDE.md +8 -2
- package/dist/auth/account_action_specs.d.ts +7 -0
- package/dist/auth/account_action_specs.d.ts.map +1 -1
- package/dist/auth/account_action_specs.js +11 -1
- package/dist/auth/audit_emitter.d.ts +4 -0
- package/dist/auth/audit_emitter.d.ts.map +1 -1
- package/dist/auth/audit_emitter.js +18 -2
- package/dist/auth/cell_action_specs.d.ts +10 -0
- package/dist/auth/cell_action_specs.d.ts.map +1 -1
- package/dist/auth/cell_action_specs.js +17 -3
- package/dist/auth/role_grant_offer_action_specs.d.ts.map +1 -1
- package/dist/auth/role_grant_offer_action_specs.js +7 -2
- package/dist/auth/scope_kind_schema.d.ts +10 -0
- package/dist/auth/scope_kind_schema.d.ts.map +1 -1
- package/dist/auth/scope_kind_schema.js +11 -0
- package/dist/env/load.d.ts +1 -1
- package/dist/env/load.js +1 -1
- package/dist/http/CLAUDE.md +1 -1
- package/dist/http/body_limit.d.ts +27 -14
- package/dist/http/body_limit.d.ts.map +1 -1
- package/dist/http/body_limit.js +29 -16
- package/dist/http/db_routes.d.ts.map +1 -1
- package/dist/http/db_routes.js +5 -3
- package/dist/testing/CLAUDE.md +36 -19
- package/dist/testing/audit_drift_guard.d.ts +18 -5
- package/dist/testing/audit_drift_guard.d.ts.map +1 -1
- package/dist/testing/audit_drift_guard.js +24 -8
- package/dist/testing/cross_backend/body_size.d.ts +18 -5
- package/dist/testing/cross_backend/body_size.d.ts.map +1 -1
- package/dist/testing/cross_backend/body_size.js +233 -34
- package/dist/testing/cross_backend/body_size_smuggling.d.ts +0 -12
- package/dist/testing/cross_backend/body_size_smuggling.d.ts.map +1 -1
- package/dist/testing/cross_backend/body_size_smuggling.js +92 -147
- package/dist/testing/cross_backend/capabilities.d.ts +3 -24
- package/dist/testing/cross_backend/capabilities.d.ts.map +1 -1
- package/dist/testing/cross_backend/capabilities.js +1 -4
- package/dist/testing/cross_backend/cell_crud.d.ts.map +1 -1
- package/dist/testing/cross_backend/cell_crud.js +31 -2
- package/dist/testing/cross_backend/conformance_case.d.ts +3 -3
- package/dist/testing/cross_backend/credential_header_robustness.d.ts.map +1 -1
- package/dist/testing/cross_backend/credential_header_robustness.js +18 -54
- package/dist/testing/cross_backend/default_backend_configs.d.ts.map +1 -1
- package/dist/testing/cross_backend/default_backend_configs.js +2 -8
- package/dist/testing/cross_backend/testing_backdoor.d.ts.map +1 -1
- package/dist/testing/cross_backend/testing_backdoor.js +2 -0
- package/dist/testing/cross_backend/testing_reset_actions.d.ts +5 -0
- package/dist/testing/cross_backend/testing_reset_actions.d.ts.map +1 -1
- package/dist/testing/cross_backend/testing_reset_actions.js +5 -0
- package/dist/testing/cross_backend/token_lifetime.d.ts.map +1 -1
- package/dist/testing/cross_backend/token_lifetime.js +34 -1
- package/dist/testing/cross_backend/ts_spine_backend_config.d.ts.map +1 -1
- package/dist/testing/cross_backend/ts_spine_backend_config.js +7 -14
- package/dist/testing/cross_backend/ws_outbound_overflow.d.ts.map +1 -1
- package/dist/testing/cross_backend/ws_outbound_overflow.js +65 -0
- package/dist/testing/schema_introspect.d.ts +24 -4
- package/dist/testing/schema_introspect.d.ts.map +1 -1
- package/dist/testing/schema_introspect.js +27 -4
- package/dist/testing/transports/raw_http.d.ts +78 -0
- package/dist/testing/transports/raw_http.d.ts.map +1 -0
- package/dist/testing/transports/raw_http.js +141 -0
- package/package.json +1 -1
- package/dist/env/resolve.d.ts +0 -163
- package/dist/env/resolve.d.ts.map +0 -1
- package/dist/env/resolve.js +0 -267
package/dist/testing/CLAUDE.md
CHANGED
|
@@ -99,7 +99,8 @@ mismatch.
|
|
|
99
99
|
|
|
100
100
|
### `audit_drift_guard.ts` — audit-emission validation
|
|
101
101
|
|
|
102
|
-
- `install_audit_drift_guard()` — `beforeEach` resets + `afterEach` zero-checks `audit_metadata_validation_failures` + `audit_unknown_event_type_failures` counters from `auth/audit_log_queries.ts
|
|
102
|
+
- `install_audit_drift_guard()` — `beforeEach` resets + `afterEach` zero-checks the `audit_metadata_validation_failures` + `audit_unknown_event_type_failures` counters from `auth/audit_log_queries.ts` and the emitter's write-failure counter (`get_audit_write_failures` in `auth/audit_emitter.ts`). Call once at the top of any `describe_db` block firing audit emits — production validation and the emitter's write are fail-open, so without this any regression shipping a typo'd `event_type` or undeclared metadata field, or losing a row the database refused, is silent. A test that fails a write on purpose resets the counter (`reset_audit_write_failures`) once it has asserted it.
|
|
103
|
+
- `assert_no_audit_drift()` — the zero-check itself, for a test that manages the counters. Pair with `await_pending_effects: true` (the `create_test_app` default) so fire-and-forget audit writes complete by response time.
|
|
103
104
|
- `create_recording_audit_emitter(calls_ref?)` — no-op `AuditEmitter` pushing every `emit` and `emit_pool` call into `calls`. Pass `calls_ref` to write into a caller-owned array; omit to let the helper allocate. Returns `{emitter, calls}` — destructure `emitter` as the `audit` dep and read `calls` to assert. `add_listener` records (never fires) and returns a remover with the production semantics, so `listener_count` tracks what a server registered and released. Replaces per-file capturing emitters previously duplicated across `password_change.test.ts`, `audit_log.test.ts`, etc.
|
|
104
105
|
- `RecordingAuditEmitter` — `{emitter: AuditEmitter; calls: Array<AuditLogInput>}`.
|
|
105
106
|
|
|
@@ -223,7 +224,7 @@ test-only by construction.
|
|
|
223
224
|
|
|
224
225
|
### `schema_introspect.ts` — `query_schema_snapshot`
|
|
225
226
|
|
|
226
|
-
- `query_schema_snapshot(db, options?)` — introspects a live DB into a deterministic `SchemaSnapshot` via `pg_catalog` + `information_schema`. Captures tables, columns (with `udt_name` to distinguish int4/int8), indexes (`indexdef`), constraints (`pg_get_constraintdef`), sequences, and enum types (`pg_enum` labels in declared `enumsortorder`, so a `cell_visibility` label-set/order drift is gated). The `schema_version` migration tracker is always excluded — it's framework bookkeeping, not domain schema; twin impls keep it identical (same namespaces, names and partitioning), which `query_migration_tracker` + `assert_migration_trackers_equal` gate separately. Twinned by `fuz_db::query_schema_snapshot` (Rust); the `_testing_schema_snapshot` RPC's wire validator is the shared `SchemaSnapshot`, so the enum field must serialize on both sides.
|
|
227
|
+
- `query_schema_snapshot(db, options?)` — introspects a live DB into a deterministic `SchemaSnapshot` via `pg_catalog` + `information_schema`. Throws on pglet when the snapshot holds an index, unless `accept_pglet_index_definitions` is passed: pglet's `indexdef` omits index modifiers, so its index definitions read as false differences against PostgreSQL and false agreement between two pglet schemas. Captures tables, columns (with `udt_name` to distinguish int4/int8), indexes (`indexdef`), constraints (`pg_get_constraintdef`), sequences, and enum types (`pg_enum` labels in declared `enumsortorder`, so a `cell_visibility` label-set/order drift is gated). The `schema_version` migration tracker is always excluded — it's framework bookkeeping, not domain schema; twin impls keep it identical (same namespaces, names and partitioning), which `query_migration_tracker` + `assert_migration_trackers_equal` gate separately. Twinned by `fuz_db::query_schema_snapshot` (Rust); the `_testing_schema_snapshot` RPC's wire validator is the shared `SchemaSnapshot`, so the enum field must serialize on both sides.
|
|
227
228
|
- `SchemaSnapshot` — the Zod schema is canonical (co-located in `schema_introspect.ts`; the cross-impl `_testing_schema_snapshot` RPC action reuses it as its wire validator, and the type is `z.infer`'d from it). Fully JSON-serializable; every collection deterministically sorted on capture so structural equality is stable across runs.
|
|
228
229
|
- `query_migration_tracker(db)` / `MigrationTracker` — the migration-_identity_ twin of the snapshot: reads the `schema_version` tracker rows (`[{namespace, name, sequence}]`) the snapshot deliberately _excludes_. `MigrationTracker` is the `_testing_migration_tracker` RPC's wire validator; pairs with `assert_migration_trackers_equal` to gate that the two spines record byte-identical migration identity (the swap-freely invariant), catching a migration-name or namespace-partitioning drift the provenance-agnostic schema snapshot can't see.
|
|
229
230
|
|
|
@@ -927,7 +928,7 @@ source of truth for wire-shape conformance.
|
|
|
927
928
|
- `testing/cross_backend/capabilities.ts` — `BackendCapabilities` vocabulary
|
|
928
929
|
(the **gating** flags `ws` / `sse` / `cell_crud` / `cell_relations` /
|
|
929
930
|
`cell_gated_create` / `account_lifecycle` / `fact_serving` / `ready` /
|
|
930
|
-
`account_status` / `
|
|
931
|
+
`account_status` / `peer_request` /
|
|
931
932
|
`ws_handshake_pipelining` / `ws_account_actions` / `ws_self_revocation_reply` —
|
|
932
933
|
each has a `test_if`
|
|
933
934
|
reader (`ws_self_revocation_reply` forks one case's assertion instead); `peer_request` (server-initiated requests — the ActionPeer
|
|
@@ -971,18 +972,17 @@ source of truth for wire-shape conformance.
|
|
|
971
972
|
case — `GET /api/account/status` is bundled into `create_account_route_specs`,
|
|
972
973
|
so every spine serves it (`true`); the gate fails loud if a declaring backend
|
|
973
974
|
doesn't mount it (replacing the old runtime 404-sniff-skip).
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
framed, so the suite requires no desync there instead: exactly the 413 and
|
|
980
|
-
the reply to the pipelined request, which a backend that closes fails).
|
|
975
|
+
No flag covers the body-size smuggling probe: every backend closes the
|
|
976
|
+
connection after an oversized-body 413 (at once on Node, Bun and the Rust
|
|
977
|
+
spine, once the declared body has arrived on Deno), so the pipelined request
|
|
978
|
+
goes unanswered. Bun 1.4.3 is the tested floor — an older Bun kept the
|
|
979
|
+
connection and served the next request, correctly framed, and fails the probe.
|
|
981
980
|
`ws_handshake_pipelining` gates the WS round-trip suite's
|
|
982
981
|
frames-with-the-handshake case (`true` Node/Deno/Rust — frames written in the
|
|
983
|
-
same TCP write as the upgrade request reach the socket; `false` Bun,
|
|
984
|
-
|
|
985
|
-
|
|
982
|
+
same TCP write as the upgrade request reach the socket; `false` Bun, which
|
|
983
|
+
answers such a request `400` when the upgrade follows an asynchronous check,
|
|
984
|
+
as it does behind auth, and `false` in-process, where there is no socket to
|
|
985
|
+
write raw bytes to).
|
|
986
986
|
`ws_account_actions` (the WS endpoint mounts the account actions — `true` on
|
|
987
987
|
fuz_app's own spine presets, `false` in the family defaults) gates the WS
|
|
988
988
|
round-trip suite's self-revocation case and, with `rpc_path`, its
|
|
@@ -1345,7 +1345,7 @@ an audit row, an admin account listing, and an offer's `created_at` /
|
|
|
1345
1345
|
`expires_at`. `cross_backend/setup.ts` carries the four bases every
|
|
1346
1346
|
imperative cross suite aliases: `CrossSuiteOptions` (`setup_test` alone),
|
|
1347
1347
|
`CapabilityGatedCrossSuiteOptions` (+ `capabilities` — `ready`),
|
|
1348
|
-
`RpcPathCrossSuiteOptions` (+ `rpc_path?` — origin, body-size, actor
|
|
1348
|
+
`RpcPathCrossSuiteOptions` (+ `rpc_path?` — origin, body-size (which adds `base_url`), actor
|
|
1349
1349
|
lookup/search, app settings, testing backdoor), and the intersection
|
|
1350
1350
|
`RpcPathCapabilityGatedCrossSuiteOptions` (every `cell_*` suite, query-shape,
|
|
1351
1351
|
token-scope surface, fact-serving, account lifecycle). A suite takes the
|
|
@@ -1358,7 +1358,8 @@ the option type states whether the bundle is read.
|
|
|
1358
1358
|
edit/read/delete → 404 IDOR mask; admin reaches any; dup active `path` → 409;
|
|
1359
1359
|
`path` write by non-admin → 403 on create + update; `cell_get` with no
|
|
1360
1360
|
id/path → `invalid_params`; null-auth `cell_list` `created_by` →
|
|
1361
|
-
`invalid_params`
|
|
1361
|
+
`invalid_params`; a `kind` at `CELL_KIND_LENGTH_MAX` stored whole, one
|
|
1362
|
+
UTF-16 code unit past it → `invalid_params` on create and as a list filter).
|
|
1362
1363
|
- **`describe_cell_relations_cross_tests`** (gates on
|
|
1363
1364
|
`capabilities.cell_relations`) — the verbs beyond CRUD: grant lifecycle
|
|
1364
1365
|
(actor-shaped editor grant enables edit, manage-tier `cell_grant_list`,
|
|
@@ -1463,6 +1464,21 @@ are `src/test/auth/cell_crud_parity.db.test.ts`
|
|
|
1463
1464
|
write as the upgrade request, so before the connection can have been
|
|
1464
1465
|
admitted). Small unfragmented text frames only. Helpers `is_raw_ws_close`,
|
|
1465
1466
|
`raw_ws_close_code`, `RAW_WS_OPCODE_TEXT` / `_CLOSE`.
|
|
1467
|
+
- `testing/transports/raw_http.ts` — `raw_http_exchange(base_url, request_bytes, {read_timeout_ms, until?})`
|
|
1468
|
+
→ `RawHttpExchange` (`{received, closed_by_server}`): writes caller-built
|
|
1469
|
+
HTTP/1.1 bytes to a raw `node:net` socket once and reads what the server
|
|
1470
|
+
sends until it closes (`until: 'close'`, the default), until one whole
|
|
1471
|
+
response has arrived (`until: 'response'` — for a server that answers and
|
|
1472
|
+
keeps the connection), or until the read timeout. For what `fetch` can't
|
|
1473
|
+
send: two requests in one write, duplicate or malformed headers, a request
|
|
1474
|
+
head whose body never follows, an unterminated chunked body. Write errors are
|
|
1475
|
+
swallowed, and a write error ends the socket — so a caller that must read a
|
|
1476
|
+
refusal reliably sends nothing the server will leave unread. Helpers
|
|
1477
|
+
`raw_http_parse_response` (the first response, `null` until its head and
|
|
1478
|
+
framed body are whole), `raw_http_count_responses` (unanchored status-line
|
|
1479
|
+
count, so a response concatenated after a body is seen),
|
|
1480
|
+
`raw_http_first_status`. Shared by the body-size, body-size smuggling, and
|
|
1481
|
+
credential-header suites.
|
|
1466
1482
|
- `testing/transports/sse_frame_reader.ts` — `create_sse_frame_reader(reader, default_timeout_ms?)`,
|
|
1467
1483
|
the transport-agnostic SSE framing core over a
|
|
1468
1484
|
`ReadableStreamDefaultReader<Uint8Array>`: `\n\n` framing, per-read timeout,
|
|
@@ -1667,8 +1683,9 @@ reads the audit log behind `_testing_drain_effects`). fuz_app's own wiring is
|
|
|
1667
1683
|
imperative round-trip on `account_token_create`'s required `lifetime` union:
|
|
1668
1684
|
a `{kind:'ttl', days}` mint returns and stores a bounded `expires_at`
|
|
1669
1685
|
(create output + `account_token_list` read-back), `{kind:'eternal'}` stores
|
|
1670
|
-
NULL,
|
|
1671
|
-
spines
|
|
1686
|
+
NULL, an omitted or out-of-range lifetime 400s identically on both
|
|
1687
|
+
spines, and a `name` at `API_TOKEN_NAME_LENGTH_MAX` mints and reads back whole
|
|
1688
|
+
while one UTF-16 code unit past it is `invalid_params`. Exists because the action-manifest parity gate captures no param
|
|
1672
1689
|
schemas while the Rust spine hand-parses params — a spine that silently
|
|
1673
1690
|
dropped the field would answer the same mint with a 200 and an eternal
|
|
1674
1691
|
token; only the stored-state read-back catches it. Ungated (mint + list are
|
|
@@ -1705,8 +1722,8 @@ drift-guarded by `src/test/cross_backend/spine_expected_schema.db.test.ts`
|
|
|
1705
1722
|
|
|
1706
1723
|
`describe_testing_backdoor_cross_tests({setup_test, rpc_path?, ws?})` —
|
|
1707
1724
|
the negative-credential parity suite for the `_testing_*` backdoor actions.
|
|
1708
|
-
For each `_testing_*` action
|
|
1709
|
-
|
|
1725
|
+
For each `_testing_*` action (the privileged writes, the audit barrier, the
|
|
1726
|
+
connection flood, and the introspection reads) it fires three principals over real HTTP: **anonymous** →
|
|
1710
1727
|
401 `unauthenticated`, **session** → 403 `forbidden` `credential_type_required`,
|
|
1711
1728
|
**bearer** → 403 `forbidden` `credential_type_required` — and, when `ws`
|
|
1712
1729
|
(`{base_url, ws_path, origin?}`) is passed, sends each on a socket opened with
|
|
@@ -1,26 +1,39 @@
|
|
|
1
1
|
import './assert_dev_env.js';
|
|
2
|
-
import type
|
|
2
|
+
import { type AuditEmitter } from '../auth/audit_emitter.js';
|
|
3
3
|
import type { AuditLogInput } from '../auth/audit_log_schema.js';
|
|
4
4
|
/**
|
|
5
5
|
* Register per-test `beforeEach` + `afterEach` hooks that catch any audit
|
|
6
6
|
* emission with a metadata shape that fails its `audit_metadata_schemas`
|
|
7
|
-
* entry,
|
|
7
|
+
* entry, an `event_type` not present in the active `AuditLogConfig`, or a
|
|
8
|
+
* row the emitter failed to write.
|
|
8
9
|
*
|
|
9
10
|
* The production validation in `query_audit_log` is fail-open — it bumps
|
|
10
11
|
* process-wide counters and proceeds, so a regression that emits an
|
|
11
12
|
* undeclared metadata field or a typo'd event-type lands a row that
|
|
12
|
-
* passes downstream queries but breaks forensics.
|
|
13
|
-
*
|
|
13
|
+
* passes downstream queries but breaks forensics. The emitter's pool write
|
|
14
|
+
* is fail-open too: a refused `INSERT` is logged and counted, the request
|
|
15
|
+
* succeeds, and the row is gone. Tests that exercise audit emits should fail
|
|
16
|
+
* loudly when either happens.
|
|
14
17
|
*
|
|
15
18
|
* Call at the top of every `describe` / `describe_db` block that fires
|
|
16
19
|
* audit writes through `deps.audit.emit`. Resets counters before each
|
|
17
|
-
* test and asserts zero on completion.
|
|
20
|
+
* test and asserts zero on completion. A test that makes a write fail on
|
|
21
|
+
* purpose calls `reset_audit_write_failures` (`auth/audit_emitter.ts`) once
|
|
22
|
+
* it has asserted the count.
|
|
18
23
|
*
|
|
19
24
|
* Pair with `await_pending_effects: true` (the default for
|
|
20
25
|
* `create_test_app`) so fire-and-forget audit writes have completed by
|
|
21
26
|
* the time the after-each check observes counter state.
|
|
22
27
|
*/
|
|
23
28
|
export declare const install_audit_drift_guard: () => void;
|
|
29
|
+
/**
|
|
30
|
+
* Assert the process-wide audit counters are zero — the check
|
|
31
|
+
* `install_audit_drift_guard` runs after each test. Exported for a test that
|
|
32
|
+
* manages the counters itself.
|
|
33
|
+
*
|
|
34
|
+
* @throws AssertionError naming the first counter that is not zero
|
|
35
|
+
*/
|
|
36
|
+
export declare const assert_no_audit_drift: () => void;
|
|
24
37
|
/**
|
|
25
38
|
* Pair returned by `create_recording_audit_emitter` — the
|
|
26
39
|
* `AuditEmitter` to inject as `deps.audit`, plus the shared `calls`
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"audit_drift_guard.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/audit_drift_guard.ts"],"names":[],"mappings":"AAAA,OAAO,qBAAqB,CAAC;AAU7B,OAAO,KAAK,
|
|
1
|
+
{"version":3,"file":"audit_drift_guard.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/audit_drift_guard.ts"],"names":[],"mappings":"AAAA,OAAO,qBAAqB,CAAC;AAU7B,OAAO,EAGN,KAAK,YAAY,EACjB,MAAM,0BAA0B,CAAC;AAClC,OAAO,KAAK,EAAiB,aAAa,EAAE,MAAM,6BAA6B,CAAC;AAEhF;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,eAAO,MAAM,yBAAyB,QAAO,IAO5C,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,qBAAqB,QAAO,IAgBxC,CAAC;AAEF;;;;;;GAMG;AACH,MAAM,WAAW,qBAAqB;IACrC,OAAO,EAAE,YAAY,CAAC;IACtB,KAAK,EAAE,KAAK,CAAC,aAAa,CAAC,CAAC;CAC5B;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AACH,eAAO,MAAM,8BAA8B,GAC1C,YAAY,KAAK,CAAC,aAAa,CAAC,KAC9B,qBAqCF,CAAC"}
|
|
@@ -1,20 +1,26 @@
|
|
|
1
1
|
import './assert_dev_env.js';
|
|
2
2
|
import { assert, beforeEach, afterEach } from 'vitest';
|
|
3
3
|
import { get_audit_metadata_validation_failures, get_audit_unknown_event_type_failures, reset_audit_metadata_validation_failures, reset_audit_unknown_event_type_failures } from '../auth/audit_log_queries.js';
|
|
4
|
+
import { get_audit_write_failures, reset_audit_write_failures } from '../auth/audit_emitter.js';
|
|
4
5
|
/**
|
|
5
6
|
* Register per-test `beforeEach` + `afterEach` hooks that catch any audit
|
|
6
7
|
* emission with a metadata shape that fails its `audit_metadata_schemas`
|
|
7
|
-
* entry,
|
|
8
|
+
* entry, an `event_type` not present in the active `AuditLogConfig`, or a
|
|
9
|
+
* row the emitter failed to write.
|
|
8
10
|
*
|
|
9
11
|
* The production validation in `query_audit_log` is fail-open — it bumps
|
|
10
12
|
* process-wide counters and proceeds, so a regression that emits an
|
|
11
13
|
* undeclared metadata field or a typo'd event-type lands a row that
|
|
12
|
-
* passes downstream queries but breaks forensics.
|
|
13
|
-
*
|
|
14
|
+
* passes downstream queries but breaks forensics. The emitter's pool write
|
|
15
|
+
* is fail-open too: a refused `INSERT` is logged and counted, the request
|
|
16
|
+
* succeeds, and the row is gone. Tests that exercise audit emits should fail
|
|
17
|
+
* loudly when either happens.
|
|
14
18
|
*
|
|
15
19
|
* Call at the top of every `describe` / `describe_db` block that fires
|
|
16
20
|
* audit writes through `deps.audit.emit`. Resets counters before each
|
|
17
|
-
* test and asserts zero on completion.
|
|
21
|
+
* test and asserts zero on completion. A test that makes a write fail on
|
|
22
|
+
* purpose calls `reset_audit_write_failures` (`auth/audit_emitter.ts`) once
|
|
23
|
+
* it has asserted the count.
|
|
18
24
|
*
|
|
19
25
|
* Pair with `await_pending_effects: true` (the default for
|
|
20
26
|
* `create_test_app`) so fire-and-forget audit writes have completed by
|
|
@@ -24,11 +30,21 @@ export const install_audit_drift_guard = () => {
|
|
|
24
30
|
beforeEach(() => {
|
|
25
31
|
reset_audit_metadata_validation_failures();
|
|
26
32
|
reset_audit_unknown_event_type_failures();
|
|
33
|
+
reset_audit_write_failures();
|
|
27
34
|
});
|
|
28
|
-
afterEach(
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
35
|
+
afterEach(assert_no_audit_drift);
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* Assert the process-wide audit counters are zero — the check
|
|
39
|
+
* `install_audit_drift_guard` runs after each test. Exported for a test that
|
|
40
|
+
* manages the counters itself.
|
|
41
|
+
*
|
|
42
|
+
* @throws AssertionError naming the first counter that is not zero
|
|
43
|
+
*/
|
|
44
|
+
export const assert_no_audit_drift = () => {
|
|
45
|
+
assert.strictEqual(get_audit_metadata_validation_failures(), 0, 'audit metadata failed schema validation — see audit_log_schema.audit_metadata_schemas');
|
|
46
|
+
assert.strictEqual(get_audit_unknown_event_type_failures(), 0, 'audit emitted an unknown event_type — see AUDIT_EVENT_TYPES');
|
|
47
|
+
assert.strictEqual(get_audit_write_failures(), 0, 'an audit row failed to write and was lost — the emitter logged the database error');
|
|
32
48
|
};
|
|
33
49
|
/**
|
|
34
50
|
* Build a no-op `AuditEmitter` that records every `emit`, `emit_pool`, and
|
|
@@ -2,11 +2,24 @@ import '../assert_dev_env.js';
|
|
|
2
2
|
import type { RpcPathCrossSuiteOptions } from './setup.js';
|
|
3
3
|
/**
|
|
4
4
|
* Options for the body-size parity suite — the standard RPC-dispatched
|
|
5
|
-
* cross-suite shape (`setup_test` / `rpc_path`)
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* on the shape.
|
|
5
|
+
* cross-suite shape (`setup_test` / `rpc_path`) plus where the backend's
|
|
6
|
+
* socket is. The limit is on every spine, so no case is capability-gated and
|
|
7
|
+
* the flag bundle is not on the shape.
|
|
9
8
|
*/
|
|
10
|
-
export
|
|
9
|
+
export interface BodySizeCrossTestOptions extends RpcPathCrossSuiteOptions {
|
|
10
|
+
/**
|
|
11
|
+
* Base URL the spawned backend is reachable at (e.g. `http://localhost:1178`),
|
|
12
|
+
* or `null` for an in-process run, which has no socket. Given a URL, the
|
|
13
|
+
* over-cap cases write to the socket directly and send only the bytes the
|
|
14
|
+
* refusal needs; with `null` they send whole bodies through the fixture's
|
|
15
|
+
* transport.
|
|
16
|
+
*
|
|
17
|
+
* Pass the URL whenever the fixture's transport reaches a real socket.
|
|
18
|
+
* `null` there uploads each whole over-cap body through `fetch`, where the
|
|
19
|
+
* write races the close that follows the 413 and the case fails some of the
|
|
20
|
+
* time.
|
|
21
|
+
*/
|
|
22
|
+
readonly base_url: string | null;
|
|
23
|
+
}
|
|
11
24
|
export declare const describe_body_size_cross_tests: (options: BodySizeCrossTestOptions) => void;
|
|
12
25
|
//# sourceMappingURL=body_size.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"body_size.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/body_size.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;
|
|
1
|
+
{"version":3,"file":"body_size.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/body_size.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AA8F9B,OAAO,KAAK,EAAE,wBAAwB,EAAe,MAAM,YAAY,CAAC;AAGxE;;;;;GAKG;AACH,MAAM,WAAW,wBAAyB,SAAQ,wBAAwB;IACzE;;;;;;;;;;;OAWG;IACH,QAAQ,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;CACjC;AA2HD,eAAO,MAAM,8BAA8B,GAAI,SAAS,wBAAwB,KAAG,IAyLlF,CAAC"}
|
|
@@ -5,11 +5,12 @@ import '../assert_dev_env.js';
|
|
|
5
5
|
* `create_app_server` (TS) and the Rust spine both cap the request body at a
|
|
6
6
|
* 1 MiB default (`DEFAULT_MAX_BODY_SIZE` / `fuz_http`'s
|
|
7
7
|
* `DEFAULT_BODY_LIMIT_BYTES`) and reject oversized payloads with `413` and the
|
|
8
|
-
* canonical flat REST body `{error: 'payload_too_large'}
|
|
8
|
+
* canonical flat REST body `{error: 'payload_too_large'}`. A declared
|
|
9
|
+
* `Content-Length` over the cap is refused on the header, *before* auth,
|
|
9
10
|
* origin, or dispatch run (middleware step 4). Each impl unit-tests this in
|
|
10
|
-
* isolation
|
|
11
|
-
*
|
|
12
|
-
*
|
|
11
|
+
* isolation; this suite pins the cross-impl agreement on the status + body
|
|
12
|
+
* shape, on the exact `>` cap boundary (not an off-by-one divergence), and on
|
|
13
|
+
* the cap answering ahead of the origin check and of auth. The cases:
|
|
13
14
|
*
|
|
14
15
|
* - **over-limit POST (cap + 1 byte) → 413** `payload_too_large`, refused
|
|
15
16
|
* before any handler runs (the limit fires ahead of origin verification + the
|
|
@@ -24,17 +25,48 @@ import '../assert_dev_env.js';
|
|
|
24
25
|
* - **under-limit POST (small) → 200** — a small, well-formed authenticated
|
|
25
26
|
* `account_verify` envelope sails through to a successful handler response,
|
|
26
27
|
* the positive control that the route works for normal traffic.
|
|
28
|
+
* - **over-limit POST with a disallowed `Origin` → 413**, not the origin
|
|
29
|
+
* check's 403, and **over-limit POST with no credentials → 413**, not 401:
|
|
30
|
+
* the cap answers a declared length ahead of both. The control — the same
|
|
31
|
+
* disallowed `Origin` on an under-limit body → 403 `forbidden_origin` —
|
|
32
|
+
* shows the origin check still runs.
|
|
33
|
+
* - **over-limit chunked POST (no `Content-Length`), authenticated → 413** —
|
|
34
|
+
* a body with no declared length is refused by the bytes that arrive.
|
|
27
35
|
*
|
|
28
|
-
* **
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
36
|
+
* **Implementation-defined, not pinned here.** Without a declared length the
|
|
37
|
+
* two spines measure at different points: the TS spine reads the body up to
|
|
38
|
+
* the cap before any later middleware runs, so its 413 comes ahead of the
|
|
39
|
+
* origin check and auth; the Rust spine counts as the handler reads, so a
|
|
40
|
+
* refusal made on the headers (a disallowed `Origin`) answers first and a
|
|
41
|
+
* handler that never reads the body answers its own status. Only the
|
|
42
|
+
* authenticated, allowed-origin chunked case — where nothing else refuses —
|
|
43
|
+
* has one answer on every backend. A repeated `Content-Length` with equal
|
|
44
|
+
* values is also the implementation's: the TS spine answers 400
|
|
45
|
+
* `invalid_content_length` where its runtime's parser hands the repeat over,
|
|
46
|
+
* and hyper folds it into one value and accepts.
|
|
47
|
+
*
|
|
48
|
+
* **Over a real socket the over-cap cases send no byte the server won't
|
|
49
|
+
* read.** A server that caps the body answers 413 and may close the
|
|
50
|
+
* connection with the rest of the upload unread — correct HTTP, since an
|
|
51
|
+
* unread request body can't share a keep-alive socket. A client still
|
|
52
|
+
* uploading then has its write fail (`EPIPE`) and can lose the 413 the server
|
|
53
|
+
* did send, so a whole over-cap body sent through `fetch` reads its answer
|
|
54
|
+
* only some of the time. Given a `base_url`, the suite speaks to the socket
|
|
55
|
+
* directly (`raw_http_exchange`) and holds back what the refusal doesn't need:
|
|
56
|
+
*
|
|
57
|
+
* - a **declared** over-cap length is sent as the request head alone —
|
|
58
|
+
* `Content-Length` one over the cap and not one body byte. The 413 must
|
|
59
|
+
* arrive within `RAW_READ_TIMEOUT_MS`, which also pins that the refusal is
|
|
60
|
+
* made on the header: a backend that reads the body before refusing, or has
|
|
61
|
+
* no cap, waits for bytes that never come and fails the case.
|
|
62
|
+
* - the **chunked** body is sent up to one byte past the cap and never
|
|
63
|
+
* terminated. The server has read every byte sent by the time it has counted
|
|
64
|
+
* past the cap, so nothing is left unread, and the 413 must arrive without
|
|
65
|
+
* the body's end.
|
|
66
|
+
*
|
|
67
|
+
* With `base_url: null` there is no socket and no such race (the in-process
|
|
68
|
+
* `app.request`), so the same cases send whole bodies through the fixture's
|
|
69
|
+
* transport. The under-cap cases go through the transport either way.
|
|
38
70
|
*
|
|
39
71
|
* Like origin/payload rejection, this is middleware-level flat REST — not the
|
|
40
72
|
* JSON-RPC envelope the conformance-table runner expects — so it's an
|
|
@@ -51,7 +83,8 @@ import '../assert_dev_env.js';
|
|
|
51
83
|
*/
|
|
52
84
|
import { describe, test, assert } from 'vitest';
|
|
53
85
|
import { account_verify_action_spec } from '../../auth/account_action_specs.js';
|
|
54
|
-
import { ERROR_PAYLOAD_TOO_LARGE } from '../../http/error_schemas.js';
|
|
86
|
+
import { ERROR_FORBIDDEN_ORIGIN, ERROR_PAYLOAD_TOO_LARGE } from '../../http/error_schemas.js';
|
|
87
|
+
import { raw_http_exchange, raw_http_parse_response } from '../transports/raw_http.js';
|
|
55
88
|
import { SPINE_RPC_PATH } from './spine_surface_constants.js';
|
|
56
89
|
/**
|
|
57
90
|
* The shared 1 MiB default both impls ship (`DEFAULT_MAX_BODY_SIZE` in TS,
|
|
@@ -78,41 +111,159 @@ const sized_envelope = (id, target_bytes) => {
|
|
|
78
111
|
};
|
|
79
112
|
/** A small, well-formed nullary `account_verify` body — well under the cap. */
|
|
80
113
|
const small_envelope = (id) => JSON.stringify({ jsonrpc: '2.0', method: account_verify_action_spec.method, id });
|
|
114
|
+
/** An `Origin` no backend's allowlist admits. */
|
|
115
|
+
const DISALLOWED_ORIGIN = 'http://evil.com';
|
|
116
|
+
/** Chunk size of the streamed over-cap body. */
|
|
117
|
+
const STREAM_CHUNK_BYTES = 64 * 1024;
|
|
118
|
+
/**
|
|
119
|
+
* A request body of `total_bytes` delivered as a stream, so the request
|
|
120
|
+
* carries no `Content-Length` (`Transfer-Encoding: chunked` over a socket).
|
|
121
|
+
* Single-use.
|
|
122
|
+
*/
|
|
123
|
+
const streamed_body = (total_bytes) => {
|
|
124
|
+
let sent = 0;
|
|
125
|
+
return new ReadableStream({
|
|
126
|
+
pull: (controller) => {
|
|
127
|
+
if (sent >= total_bytes) {
|
|
128
|
+
controller.close();
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
const size = Math.min(STREAM_CHUNK_BYTES, total_bytes - sent);
|
|
132
|
+
sent += size;
|
|
133
|
+
controller.enqueue(new Uint8Array(size).fill(0x78));
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
};
|
|
81
137
|
/**
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
* one retry opens a fresh connection (and drops the dead socket from the
|
|
86
|
-
* pool). Keyed on "threw at all" rather than a specific error code so it holds
|
|
87
|
-
* across the node / deno / bun cross runtimes, which surface different errors.
|
|
138
|
+
* How long a raw over-cap exchange waits for the refusal. The server has every
|
|
139
|
+
* byte it will get when the wait starts, so a backend that refuses on what it
|
|
140
|
+
* has answers at once and one that waits for more runs the whole window.
|
|
88
141
|
*/
|
|
89
|
-
const
|
|
142
|
+
const RAW_READ_TIMEOUT_MS = 2000;
|
|
143
|
+
const to_sized_response = async (res) => ({
|
|
144
|
+
status: res.status,
|
|
145
|
+
body: await res.json().catch(() => undefined)
|
|
146
|
+
});
|
|
147
|
+
const parse_json = (text) => {
|
|
90
148
|
try {
|
|
91
|
-
return
|
|
149
|
+
return JSON.parse(text);
|
|
92
150
|
}
|
|
93
151
|
catch {
|
|
94
|
-
return
|
|
152
|
+
return undefined;
|
|
95
153
|
}
|
|
96
154
|
};
|
|
155
|
+
/** Render request headers as raw header lines. */
|
|
156
|
+
const to_header_lines = (headers) => Object.entries(headers)
|
|
157
|
+
.map(([name, value]) => `${name}: ${value}\r\n`)
|
|
158
|
+
.join('');
|
|
159
|
+
/**
|
|
160
|
+
* Write `request_bytes` to the backend's socket and read the one response it
|
|
161
|
+
* must answer with — without being sent anything more.
|
|
162
|
+
*
|
|
163
|
+
* @param withheld - what the request leaves unsent, for the failure message
|
|
164
|
+
*/
|
|
165
|
+
const exchange_withholding = async (base_url, request_bytes, withheld) => {
|
|
166
|
+
const { received, closed_by_server } = await raw_http_exchange(base_url, request_bytes, {
|
|
167
|
+
read_timeout_ms: RAW_READ_TIMEOUT_MS,
|
|
168
|
+
until: 'response'
|
|
169
|
+
});
|
|
170
|
+
const response = raw_http_parse_response(received);
|
|
171
|
+
assert.ok(response, `expected a whole response with ${withheld} withheld; ` +
|
|
172
|
+
(closed_by_server
|
|
173
|
+
? `the server closed the connection first`
|
|
174
|
+
: `none arrived within ${RAW_READ_TIMEOUT_MS}ms — the backend is waiting for bytes ` +
|
|
175
|
+
`it should refuse without`) +
|
|
176
|
+
`. Received: ${received ? received.slice(0, 120) : 'nothing'}`);
|
|
177
|
+
return { status: response.status, body: parse_json(response.body) };
|
|
178
|
+
};
|
|
179
|
+
/** Assert a response is the canonical over-cap refusal. */
|
|
180
|
+
const assert_payload_too_large = (res, message) => {
|
|
181
|
+
assert.strictEqual(res.status, 413, message);
|
|
182
|
+
assert.strictEqual(res.body?.error, ERROR_PAYLOAD_TOO_LARGE);
|
|
183
|
+
};
|
|
97
184
|
export const describe_body_size_cross_tests = (options) => {
|
|
98
|
-
const { setup_test } = options;
|
|
185
|
+
const { setup_test, base_url } = options;
|
|
99
186
|
const rpc_path = options.rpc_path ?? SPINE_RPC_PATH;
|
|
100
|
-
/** POST `body` to the RPC path as the keeper
|
|
101
|
-
const post = (fixture, body) =>
|
|
187
|
+
/** POST `body` to the RPC path as the keeper. */
|
|
188
|
+
const post = (fixture, body) => fixture.transport(rpc_path, {
|
|
102
189
|
method: 'POST',
|
|
103
190
|
headers: { ...fixture.create_session_headers(), 'content-type': 'application/json' },
|
|
104
191
|
body
|
|
105
192
|
});
|
|
193
|
+
/**
|
|
194
|
+
* POST with a declared `Content-Length` one byte over the cap. Over a
|
|
195
|
+
* socket only the head is sent; in-process, the whole body.
|
|
196
|
+
*
|
|
197
|
+
* `headers` carries the credential and any `Origin`, which defaults to the
|
|
198
|
+
* backend's own. `anonymous` picks the cookie-jar-free transport in-process;
|
|
199
|
+
* a raw head carries only what `headers` names either way.
|
|
200
|
+
*/
|
|
201
|
+
const post_over_limit_declared = async (fixture, request) => {
|
|
202
|
+
const { id, headers, anonymous } = request;
|
|
203
|
+
const content_length = BODY_LIMIT_DEFAULT_BYTES + 1;
|
|
204
|
+
if (base_url === null) {
|
|
205
|
+
const transport = anonymous ? fixture.fresh_transport() : fixture.transport;
|
|
206
|
+
return to_sized_response(await transport(rpc_path, {
|
|
207
|
+
method: 'POST',
|
|
208
|
+
headers: { ...headers, 'content-type': 'application/json' },
|
|
209
|
+
body: sized_envelope(id, content_length)
|
|
210
|
+
}));
|
|
211
|
+
}
|
|
212
|
+
const url = new URL(base_url);
|
|
213
|
+
return exchange_withholding(base_url, `POST ${rpc_path} HTTP/1.1\r\n` +
|
|
214
|
+
to_header_lines({
|
|
215
|
+
host: url.host,
|
|
216
|
+
origin: url.origin,
|
|
217
|
+
...headers,
|
|
218
|
+
'content-type': 'application/json',
|
|
219
|
+
'content-length': String(content_length)
|
|
220
|
+
}) +
|
|
221
|
+
`\r\n`, 'the declared body');
|
|
222
|
+
};
|
|
223
|
+
/**
|
|
224
|
+
* POST a chunked body past the cap as the keeper. Over a socket the body
|
|
225
|
+
* stops one byte past the cap, unterminated; in-process, a whole streamed
|
|
226
|
+
* body runs past it.
|
|
227
|
+
*/
|
|
228
|
+
const post_over_limit_chunked = async (fixture) => {
|
|
229
|
+
if (base_url === null) {
|
|
230
|
+
return to_sized_response(await fixture.transport(rpc_path, {
|
|
231
|
+
method: 'POST',
|
|
232
|
+
headers: { ...fixture.create_session_headers(), 'content-type': 'application/json' },
|
|
233
|
+
body: streamed_body(BODY_LIMIT_DEFAULT_BYTES + 1024),
|
|
234
|
+
// a streamed request body needs half-duplex `fetch`
|
|
235
|
+
duplex: 'half'
|
|
236
|
+
}));
|
|
237
|
+
}
|
|
238
|
+
const url = new URL(base_url);
|
|
239
|
+
// whole chunks up to exactly the cap, then a one-byte chunk's size line
|
|
240
|
+
// and its byte — and neither that chunk's closing CRLF nor the
|
|
241
|
+
// terminating chunk, so the last byte sent is the first one past the cap
|
|
242
|
+
const whole_chunk = `${STREAM_CHUNK_BYTES.toString(16)}\r\n${'x'.repeat(STREAM_CHUNK_BYTES)}\r\n`;
|
|
243
|
+
return exchange_withholding(base_url, `POST ${rpc_path} HTTP/1.1\r\n` +
|
|
244
|
+
to_header_lines({
|
|
245
|
+
host: url.host,
|
|
246
|
+
origin: url.origin,
|
|
247
|
+
...fixture.create_session_headers(),
|
|
248
|
+
'content-type': 'application/json',
|
|
249
|
+
'transfer-encoding': 'chunked'
|
|
250
|
+
}) +
|
|
251
|
+
`\r\n` +
|
|
252
|
+
whole_chunk.repeat(BODY_LIMIT_DEFAULT_BYTES / STREAM_CHUNK_BYTES) +
|
|
253
|
+
`1\r\nx`, `the chunked body's end`);
|
|
254
|
+
};
|
|
106
255
|
describe('body-size limit parity', () => {
|
|
107
256
|
test('over-limit POST (cap + 1) → 413 payload_too_large (refused before dispatch)', async () => {
|
|
108
257
|
const fixture = await setup_test();
|
|
109
|
-
// Keeper session cookie + the
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
const res = await
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
258
|
+
// Keeper session cookie + the default allowed Origin, so neither auth
|
|
259
|
+
// nor origin is the cause — only the size. One byte over the cap: both
|
|
260
|
+
// impls reject on a strict `>`.
|
|
261
|
+
const res = await post_over_limit_declared(fixture, {
|
|
262
|
+
id: 'over-limit',
|
|
263
|
+
headers: fixture.create_session_headers(),
|
|
264
|
+
anonymous: false
|
|
265
|
+
});
|
|
266
|
+
assert_payload_too_large(res, 'a body one byte over the cap must be rejected with 413');
|
|
116
267
|
});
|
|
117
268
|
test('at-limit POST (exactly the cap) → not size-rejected', async () => {
|
|
118
269
|
const fixture = await setup_test();
|
|
@@ -130,5 +281,53 @@ export const describe_body_size_cross_tests = (options) => {
|
|
|
130
281
|
const res = await post(fixture, small_envelope('under-limit'));
|
|
131
282
|
assert.strictEqual(res.status, 200, 'an under-limit authenticated request must pass');
|
|
132
283
|
});
|
|
284
|
+
test('over-limit POST with a disallowed Origin → 413, not 403', async () => {
|
|
285
|
+
const fixture = await setup_test();
|
|
286
|
+
// Keeper session + a rogue Origin: the origin check would answer 403,
|
|
287
|
+
// and the cap, which reads only the declared length, answers first.
|
|
288
|
+
const res = await post_over_limit_declared(fixture, {
|
|
289
|
+
id: 'over-limit-evil-origin',
|
|
290
|
+
headers: { ...fixture.create_session_headers(), origin: DISALLOWED_ORIGIN },
|
|
291
|
+
anonymous: false
|
|
292
|
+
});
|
|
293
|
+
assert_payload_too_large(res, 'a declared length over the cap must answer 413 ahead of the origin check');
|
|
294
|
+
});
|
|
295
|
+
test('under-limit POST with a disallowed Origin → 403 forbidden_origin', async () => {
|
|
296
|
+
const fixture = await setup_test();
|
|
297
|
+
// The control for the case above: the same request minus the size is
|
|
298
|
+
// refused by the origin check, so the 413 there is the cap answering
|
|
299
|
+
// first, not the origin check being absent.
|
|
300
|
+
const res = await fixture.transport(rpc_path, {
|
|
301
|
+
method: 'POST',
|
|
302
|
+
headers: {
|
|
303
|
+
...fixture.create_session_headers(),
|
|
304
|
+
origin: DISALLOWED_ORIGIN,
|
|
305
|
+
'content-type': 'application/json'
|
|
306
|
+
},
|
|
307
|
+
body: small_envelope('under-limit-evil-origin')
|
|
308
|
+
});
|
|
309
|
+
assert.strictEqual(res.status, 403, 'a disallowed Origin on an under-limit body is refused');
|
|
310
|
+
const body = (await res.json().catch(() => undefined));
|
|
311
|
+
assert.strictEqual(body?.error, ERROR_FORBIDDEN_ORIGIN);
|
|
312
|
+
});
|
|
313
|
+
test('over-limit POST with no credentials → 413, not 401', async () => {
|
|
314
|
+
const fixture = await setup_test();
|
|
315
|
+
// No cookie, no token: the dispatcher would answer 401, and the cap
|
|
316
|
+
// answers first.
|
|
317
|
+
const res = await post_over_limit_declared(fixture, {
|
|
318
|
+
id: 'over-limit-anonymous',
|
|
319
|
+
headers: {},
|
|
320
|
+
anonymous: true
|
|
321
|
+
});
|
|
322
|
+
assert_payload_too_large(res, 'a declared length over the cap must answer 413 ahead of auth');
|
|
323
|
+
});
|
|
324
|
+
test('over-limit chunked POST (no Content-Length), authenticated → 413', async () => {
|
|
325
|
+
const fixture = await setup_test();
|
|
326
|
+
// Keeper session + the default allowed Origin, so nothing but the size
|
|
327
|
+
// refuses. No declared length for the header check to read, so the
|
|
328
|
+
// refusal is by the bytes that arrive.
|
|
329
|
+
const res = await post_over_limit_chunked(fixture);
|
|
330
|
+
assert_payload_too_large(res, 'a chunked body past the cap must be rejected with 413 when nothing else refuses');
|
|
331
|
+
});
|
|
133
332
|
});
|
|
134
333
|
};
|
|
@@ -5,18 +5,6 @@ export interface BodySizeSmugglingCrossTestOptions {
|
|
|
5
5
|
readonly base_url: string;
|
|
6
6
|
/** RPC endpoint path to target. Default `/api/rpc`. */
|
|
7
7
|
readonly rpc_path?: string;
|
|
8
|
-
/**
|
|
9
|
-
* Whether the backend closes the connection after an oversized-body `413`
|
|
10
|
-
* (`capabilities.oversized_reject_closes_connection`). `true` (default)
|
|
11
|
-
* requires that the pipelined GET goes unanswered — at most one response.
|
|
12
|
-
* `false` (Bun) requires no desync instead: exactly two well-formed
|
|
13
|
-
* responses, the 413 and one matching what the backend answers that GET
|
|
14
|
-
* sent alone — so `rpc_path` must answer a bare `GET` with a stable status
|
|
15
|
-
* and body, and a backend that closes after the 413 fails. Default `true`
|
|
16
|
-
* so a consumer that leaves the flag out fails loud on a backend that
|
|
17
|
-
* keeps the connection.
|
|
18
|
-
*/
|
|
19
|
-
readonly closes_connection?: boolean;
|
|
20
8
|
}
|
|
21
9
|
export declare const describe_body_size_smuggling_cross_tests: (options: BodySizeSmugglingCrossTestOptions) => void;
|
|
22
10
|
//# sourceMappingURL=body_size_smuggling.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"body_size_smuggling.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/body_size_smuggling.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;
|
|
1
|
+
{"version":3,"file":"body_size_smuggling.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/body_size_smuggling.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAmE9B,4EAA4E;AAC5E,MAAM,WAAW,iCAAiC;IACjD,mFAAmF;IACnF,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,uDAAuD;IACvD,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;CAC3B;AAWD,eAAO,MAAM,wCAAwC,GACpD,SAAS,iCAAiC,KACxC,IA4GF,CAAC"}
|