@fuzdev/fuz_app 0.133.0 → 0.135.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 +91 -9
- package/dist/actions/register_action_ws.d.ts +23 -3
- package/dist/actions/register_action_ws.d.ts.map +1 -1
- package/dist/actions/register_action_ws.js +57 -37
- package/dist/actions/transports.d.ts +23 -0
- package/dist/actions/transports.d.ts.map +1 -1
- package/dist/actions/transports.js +23 -0
- package/dist/actions/transports_ws_backend.d.ts +132 -9
- package/dist/actions/transports_ws_backend.d.ts.map +1 -1
- package/dist/actions/transports_ws_backend.js +236 -33
- package/dist/actions/ws_endpoint_spec.d.ts +9 -0
- package/dist/actions/ws_endpoint_spec.d.ts.map +1 -1
- package/dist/actions/ws_outbound_writer.d.ts +206 -0
- package/dist/actions/ws_outbound_writer.d.ts.map +1 -0
- package/dist/actions/ws_outbound_writer.js +431 -0
- package/dist/auth/CLAUDE.md +2 -2
- package/dist/auth/audit_log_routes.d.ts.map +1 -1
- package/dist/auth/audit_log_routes.js +3 -1
- package/dist/auth/cell_actions.js +1 -1
- package/dist/auth/role_schema.d.ts +14 -3
- package/dist/auth/role_schema.d.ts.map +1 -1
- package/dist/auth/role_schema.js +14 -2
- package/dist/db/CLAUDE.md +10 -7
- package/dist/db/create_db.d.ts +8 -6
- package/dist/db/create_db.d.ts.map +1 -1
- package/dist/db/create_db.js +11 -10
- package/dist/db/db.d.ts +9 -7
- package/dist/db/db.d.ts.map +1 -1
- package/dist/db/db.js +5 -4
- package/dist/db/db_pglet.d.ts +43 -0
- package/dist/db/db_pglet.d.ts.map +1 -0
- package/dist/db/db_pglet.js +55 -0
- package/dist/db/migrate.d.ts +1 -1
- package/dist/db/migrate.js +2 -2
- package/dist/http/CLAUDE.md +2 -1
- package/dist/http/body_limit.d.ts +61 -0
- package/dist/http/body_limit.d.ts.map +1 -0
- package/dist/http/body_limit.js +87 -0
- package/dist/http/db_routes.js +2 -2
- package/dist/http/error_schemas.d.ts +2 -0
- package/dist/http/error_schemas.d.ts.map +1 -1
- package/dist/http/error_schemas.js +2 -0
- package/dist/realtime/outbound_budget.d.ts +91 -0
- package/dist/realtime/outbound_budget.d.ts.map +1 -0
- package/dist/realtime/outbound_budget.js +108 -0
- package/dist/realtime/sse.d.ts +36 -3
- package/dist/realtime/sse.d.ts.map +1 -1
- package/dist/realtime/sse.js +58 -6
- package/dist/realtime/sse_constants.d.ts +12 -0
- package/dist/realtime/sse_constants.d.ts.map +1 -1
- package/dist/realtime/sse_constants.js +12 -0
- package/dist/server/app_server.d.ts.map +1 -1
- package/dist/server/app_server.js +3 -6
- package/dist/testing/CLAUDE.md +87 -28
- package/dist/testing/admin_integration.js +1 -1
- package/dist/testing/app_server.d.ts +5 -5
- package/dist/testing/app_server.d.ts.map +1 -1
- package/dist/testing/app_server.js +21 -20
- package/dist/testing/audit_completeness.d.ts +1 -1
- package/dist/testing/audit_completeness.js +1 -1
- package/dist/testing/audit_drift_guard.d.ts +1 -1
- package/dist/testing/audit_drift_guard.js +1 -1
- package/dist/testing/cross_backend/body_size_smuggling.d.ts +9 -9
- package/dist/testing/cross_backend/body_size_smuggling.d.ts.map +1 -1
- package/dist/testing/cross_backend/body_size_smuggling.js +110 -60
- package/dist/testing/cross_backend/capabilities.d.ts +18 -15
- package/dist/testing/cross_backend/capabilities.d.ts.map +1 -1
- package/dist/testing/cross_backend/capabilities.js +1 -0
- package/dist/testing/cross_backend/default_backend_configs.d.ts +5 -5
- package/dist/testing/cross_backend/default_backend_configs.d.ts.map +1 -1
- package/dist/testing/cross_backend/default_backend_configs.js +6 -6
- package/dist/testing/cross_backend/in_process_setup.d.ts +2 -2
- package/dist/testing/cross_backend/in_process_setup.js +3 -3
- package/dist/testing/cross_backend/role_grant_role_grammar.d.ts +6 -0
- package/dist/testing/cross_backend/role_grant_role_grammar.d.ts.map +1 -0
- package/dist/testing/cross_backend/role_grant_role_grammar.js +117 -0
- package/dist/testing/cross_backend/rust_spine_stub_backend_config.d.ts +2 -3
- package/dist/testing/cross_backend/rust_spine_stub_backend_config.d.ts.map +1 -1
- package/dist/testing/cross_backend/rust_spine_stub_backend_config.js +2 -3
- package/dist/testing/cross_backend/test_cell_gated_create_authorize.js +1 -1
- package/dist/testing/cross_backend/testing_backdoor.d.ts.map +1 -1
- package/dist/testing/cross_backend/testing_backdoor.js +8 -4
- package/dist/testing/cross_backend/testing_reset_actions.d.ts +84 -4
- package/dist/testing/cross_backend/testing_reset_actions.d.ts.map +1 -1
- package/dist/testing/cross_backend/testing_reset_actions.js +102 -3
- package/dist/testing/cross_backend/ts_spine_backend_config.d.ts +2 -2
- package/dist/testing/cross_backend/ts_spine_backend_config.d.ts.map +1 -1
- package/dist/testing/cross_backend/ts_spine_backend_config.js +9 -7
- package/dist/testing/cross_backend/ws_outbound_overflow.d.ts +29 -0
- package/dist/testing/cross_backend/ws_outbound_overflow.d.ts.map +1 -0
- package/dist/testing/cross_backend/ws_outbound_overflow.js +211 -0
- package/dist/testing/db.d.ts +49 -60
- package/dist/testing/db.d.ts.map +1 -1
- package/dist/testing/db.js +104 -101
- package/dist/testing/integration.js +1 -1
- package/dist/testing/rate_limiting.d.ts +2 -1
- package/dist/testing/rate_limiting.d.ts.map +1 -1
- package/dist/testing/rate_limiting.js +2 -2
- package/dist/testing/schema_introspect.d.ts +10 -2
- package/dist/testing/schema_introspect.d.ts.map +1 -1
- package/dist/testing/schema_introspect.js +15 -7
- package/dist/testing/sse_round_trip.d.ts +1 -1
- package/dist/testing/sse_round_trip.d.ts.map +1 -1
- package/dist/testing/sse_round_trip.js +2 -2
- package/dist/testing/stubs.js +1 -1
- package/dist/testing/transports/ws_transport.d.ts +4 -2
- package/dist/testing/transports/ws_transport.d.ts.map +1 -1
- package/dist/testing/transports/ws_transport.js +2 -1
- package/dist/testing/ws_round_trip.d.ts +32 -2
- package/dist/testing/ws_round_trip.d.ts.map +1 -1
- package/dist/testing/ws_round_trip.js +26 -4
- package/package.json +6 -1
|
@@ -4,10 +4,11 @@ import '../assert_dev_env.js';
|
|
|
4
4
|
* actions.
|
|
5
5
|
*
|
|
6
6
|
* `_testing_reset` / `_testing_mint_session` / `_testing_put_fact` /
|
|
7
|
-
* `
|
|
8
|
-
* `_testing_action_manifest` are privileged
|
|
9
|
-
* wire never exposes —
|
|
10
|
-
* row, raw fact insert)
|
|
7
|
+
* `_testing_flood_notifications` / `_testing_schema_snapshot` /
|
|
8
|
+
* `_testing_migration_tracker` / `_testing_action_manifest` are privileged
|
|
9
|
+
* test-binary actions the production wire never exposes — direct DB writes
|
|
10
|
+
* (full auth wipe, forged session row, raw fact insert), a flood of the
|
|
11
|
+
* caller's own connection, and introspection reads (the live schema, the
|
|
11
12
|
* `schema_version` migration tracker, and the live RPC registry — the highest
|
|
12
13
|
* info-leak of the set were the gate to break). Their only structural fence
|
|
13
14
|
* is the **daemon-token** credential gate on
|
|
@@ -78,6 +79,9 @@ const backdoor_methods = [
|
|
|
78
79
|
{ method: '_testing_reset', params: {} },
|
|
79
80
|
{ method: '_testing_mint_session', params: { account_id: NIL_UUID, expires_in_seconds: -60 } },
|
|
80
81
|
{ method: '_testing_put_fact', params: { content: 'backdoor-probe' } },
|
|
82
|
+
// An empty burst — valid input, so it clears the 400 phase; a refused
|
|
83
|
+
// caller never reaches the handler that would queue it.
|
|
84
|
+
{ method: '_testing_flood_notifications', params: { count: 0, size: 0 } },
|
|
81
85
|
// The schema-dump read — `exclude_tables` is optional, so `{}` is valid
|
|
82
86
|
// and clears the 400 phase like the writes above.
|
|
83
87
|
{ method: '_testing_schema_snapshot', params: {} },
|
|
@@ -8,10 +8,12 @@ import '../assert_dev_env.js';
|
|
|
8
8
|
* expired-by-construction server-side session for the expiry conformance
|
|
9
9
|
* cases), **`_testing_put_fact`** (seed an embedded fact for the fact-serving
|
|
10
10
|
* suite), **`_testing_schema_snapshot`** (introspect the live schema for
|
|
11
|
-
* cross-impl parity diffing against a Rust backend's `fuz_db` snapshot),
|
|
11
|
+
* cross-impl parity diffing against a Rust backend's `fuz_db` snapshot),
|
|
12
12
|
* **`_testing_migration_tracker`** (dump the `schema_version` tracker rows for
|
|
13
13
|
* cross-impl migration-identity parity — the provenance half the snapshot gate
|
|
14
|
-
* excludes by design)
|
|
14
|
+
* excludes by design), and **`_testing_flood_notifications`** (queue a burst
|
|
15
|
+
* of notifications onto the caller's own WebSocket connection, to push its
|
|
16
|
+
* outbound queue past its byte budget).
|
|
15
17
|
*
|
|
16
18
|
* A further daemon-token action, **`_testing_action_manifest`** (dump the live
|
|
17
19
|
* RPC registry for cross-impl method-set + auth-shape parity), lives here too
|
|
@@ -291,6 +293,84 @@ export declare const testing_put_fact_action_spec: {
|
|
|
291
293
|
readonly async: true;
|
|
292
294
|
readonly description: string;
|
|
293
295
|
};
|
|
296
|
+
/** The wire method of each notification `_testing_flood_notifications` queues. */
|
|
297
|
+
export declare const TESTING_FLOOD_NOTIFICATION_METHOD = "_testing_flood";
|
|
298
|
+
/**
|
|
299
|
+
* The most notifications one `_testing_flood_notifications` call queues.
|
|
300
|
+
* Empty ones at this count are charged more than
|
|
301
|
+
* `DEFAULT_WS_OUTBOUND_QUEUE_BYTES` between them — each frame's envelope and
|
|
302
|
+
* its overhead — so a burst of tiny frames can overflow a connection on a
|
|
303
|
+
* default transport too. Same value as the Rust spine's.
|
|
304
|
+
*/
|
|
305
|
+
export declare const TESTING_FLOOD_MAX_COUNT = 65536;
|
|
306
|
+
/** The largest `payload` one `_testing_flood_notifications` call builds, in bytes. */
|
|
307
|
+
export declare const TESTING_FLOOD_MAX_SIZE: number;
|
|
308
|
+
/**
|
|
309
|
+
* The most payload bytes one `_testing_flood_notifications` call builds across
|
|
310
|
+
* all its notifications — the bound on `count` × `size`. The handler
|
|
311
|
+
* serializes each notification without yielding, so the product is how long
|
|
312
|
+
* one call holds the event loop.
|
|
313
|
+
*/
|
|
314
|
+
export declare const TESTING_FLOOD_MAX_TOTAL_BYTES: number;
|
|
315
|
+
/** The `data.reason` of the refusal `_testing_flood_notifications` gives a call over HTTP. */
|
|
316
|
+
export declare const ERROR_TESTING_FLOOD_NO_CONNECTION = "no_connection";
|
|
317
|
+
/**
|
|
318
|
+
* `_testing_flood_notifications` — queue a burst of notifications onto the
|
|
319
|
+
* **caller's own WebSocket connection**, all in one synchronous run, so a suite
|
|
320
|
+
* driving the server from outside can push a connection's outbound queue past
|
|
321
|
+
* its byte budget deterministically — without a second producer, and whether
|
|
322
|
+
* or not its client is reading: the connection's write pump gets no turn while
|
|
323
|
+
* the handler runs, so the burst waits at once. Past the budget the server
|
|
324
|
+
* closes the socket with `WS_CLOSE_OUTBOUND_OVERFLOW`.
|
|
325
|
+
*
|
|
326
|
+
* Each notification is
|
|
327
|
+
* `{"jsonrpc":"2.0","method":"_testing_flood","params":{"index":<i>,"payload":"<size × 'x'>"}}`
|
|
328
|
+
* with `index` counting from 0, so every frame is a little longer than `size`
|
|
329
|
+
* bytes and a client can tell which frames reached it.
|
|
330
|
+
*
|
|
331
|
+
* The result is `{queued: count}` — the number asked for, not the number that
|
|
332
|
+
* fit: when the burst overflows the queue the connection is closed and this
|
|
333
|
+
* response is never written. A burst that fits arrives in order, ahead of the
|
|
334
|
+
* response.
|
|
335
|
+
*
|
|
336
|
+
* A call past `TESTING_FLOOD_MAX_COUNT`, `TESTING_FLOOD_MAX_SIZE`, or
|
|
337
|
+
* `TESTING_FLOOD_MAX_TOTAL_BYTES` (`count` × `size`) is refused with
|
|
338
|
+
* `invalid_params` and queues nothing. Over HTTP there is no connection to
|
|
339
|
+
* queue onto, so the call is refused with `invalid_params`, reason
|
|
340
|
+
* `ERROR_TESTING_FLOOD_NO_CONNECTION`, rather than answering as though it had
|
|
341
|
+
* flooded something.
|
|
342
|
+
*
|
|
343
|
+
* `auth` gates on the daemon-token credential, matching `_testing_reset`. The
|
|
344
|
+
* Rust mirror is `fuz_testing::create_testing_flood_notifications_action_spec`.
|
|
345
|
+
*/
|
|
346
|
+
export declare const testing_flood_notifications_action_spec: {
|
|
347
|
+
readonly method: "_testing_flood_notifications";
|
|
348
|
+
readonly kind: "request_response";
|
|
349
|
+
readonly initiator: "frontend";
|
|
350
|
+
readonly auth: {
|
|
351
|
+
readonly account: "required";
|
|
352
|
+
readonly actor: "none";
|
|
353
|
+
readonly credential_types: readonly ["daemon_token"];
|
|
354
|
+
};
|
|
355
|
+
readonly side_effects: false;
|
|
356
|
+
readonly input: z.ZodObject<{
|
|
357
|
+
count: z.ZodNumber;
|
|
358
|
+
size: z.ZodNumber;
|
|
359
|
+
}, z.core.$strict>;
|
|
360
|
+
readonly output: z.ZodObject<{
|
|
361
|
+
queued: z.ZodNumber;
|
|
362
|
+
}, z.core.$strict>;
|
|
363
|
+
readonly async: true;
|
|
364
|
+
readonly error_reasons: readonly ["no_connection"];
|
|
365
|
+
readonly description: string;
|
|
366
|
+
};
|
|
367
|
+
/**
|
|
368
|
+
* Build the standalone `_testing_flood_notifications` action (see the spec
|
|
369
|
+
* doc). The spine binary bundles it via `create_testing_actions`; mount it
|
|
370
|
+
* directly on an in-process WS endpoint whose suite drives an outbound
|
|
371
|
+
* overflow.
|
|
372
|
+
*/
|
|
373
|
+
export declare const create_testing_flood_notifications_action: () => RpcAction;
|
|
294
374
|
/**
|
|
295
375
|
* `_testing_schema_snapshot` — introspect the live database into a normalized
|
|
296
376
|
* `SchemaSnapshot` for cross-impl parity diffing. The cross-backend harness
|
|
@@ -482,8 +562,8 @@ export interface CreateTestingActionsOptions {
|
|
|
482
562
|
* Consumer-supplied callback invoked after the auth-table reset, passed
|
|
483
563
|
* the same transactional `Db` the auth wipes ran on. DB-domain consumers
|
|
484
564
|
* (e.g. fuz_forge truncating its cell / fact / file tables) MUST use this
|
|
485
|
-
* `db` rather than a separately-pooled connection — under
|
|
486
|
-
* connection a second connection deadlocks against this still-open
|
|
565
|
+
* `db` rather than a separately-pooled connection — under the in-memory
|
|
566
|
+
* drivers' single connection (pglet, PGlite) a second connection deadlocks against this still-open
|
|
487
567
|
* transaction. `testing_zzz_server` clears in-memory workspace registry +
|
|
488
568
|
* terminals + scoped-FS scratch (ignores `db`); `testing_spine_stub` has
|
|
489
569
|
* no domain layer and omits the option. Runs inside the same RPC dispatch
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"testing_reset_actions.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/testing_reset_actions.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAE9B
|
|
1
|
+
{"version":3,"file":"testing_reset_actions.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/testing_reset_actions.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAE9B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqEG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAKxB,OAAO,EAAc,KAAK,SAAS,EAAE,MAAM,6BAA6B,CAAC;AAOzE,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,oBAAoB,CAAC;AAClD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,6BAA6B,CAAC;AAChE,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,8BAA8B,CAAC;AACnE,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,4BAA4B,CAAC;AACnE,OAAO,KAAK,EAAE,EAAE,EAAE,MAAM,gBAAgB,CAAC;AAoCzC;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,eAAO,MAAM,yBAAyB;;;;;;;;;;;;;;;;;QAiBpC;;;;;;;;WAQG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAWyC,CAAC;AAE/C;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,eAAO,MAAM,iCAAiC;;;;;;;;;;;;;;;;CAWA,CAAC;AAE/C;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AACH,eAAO,MAAM,gCAAgC;;;;;;;;;;;;;;;;;;;CAwBC,CAAC;AAE/C;;;;;;;;GAQG;AACH,eAAO,MAAM,mCAAmC,GAAI,OAAO,YAAY,KAAG,SAIvE,CAAC;AAEJ;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,4BAA4B;;;;;;;;;;;;;;;;;;;CAeK,CAAC;AAE/C,kFAAkF;AAClF,eAAO,MAAM,iCAAiC,mBAAmB,CAAC;AAElE;;;;;;GAMG;AACH,eAAO,MAAM,uBAAuB,QAAS,CAAC;AAE9C,sFAAsF;AACtF,eAAO,MAAM,sBAAsB,QAAmB,CAAC;AAEvD;;;;;GAKG;AACH,eAAO,MAAM,6BAA6B,QAAmB,CAAC;AAE9D,8FAA8F;AAC9F,eAAO,MAAM,iCAAiC,kBAAkB,CAAC;AAEjE;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,eAAO,MAAM,uCAAuC;;;;;;;;;;;;;;;;;;;;CAwBN,CAAC;AAE/C;;;;;GAKG;AACH,eAAO,MAAM,yCAAyC,QAAO,SAmB1D,CAAC;AAEJ;;;;;;;;;GASG;AACH,eAAO,MAAM,mCAAmC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAWF,CAAC;AAE/C;;;;GAIG;AACH,eAAO,MAAM,qCAAqC,QAAO,SAGvD,CAAC;AAEH;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,qCAAqC;;;;;;;;;;;;;;;;;;;;CAYJ,CAAC;AAE/C;;;;GAIG;AACH,eAAO,MAAM,uCAAuC,QAAO,SAGzD,CAAC;AAEH;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,eAAO,MAAM,mCAAmC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAaF,CAAC;AAkB/C;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,qCAAqC,GACjD,SAAS,aAAa,CAAC,SAAS,CAAC,KAC/B,SAMF,CAAC;AAEF,4CAA4C;AAC5C,MAAM,WAAW,2BAA2B;IAC3C;;;;;OAKG;IACH,QAAQ,CAAC,eAAe,EAAE,cAAc,CAAC,MAAM,CAAC,CAAC;IACjD;;;;;;OAMG;IACH,QAAQ,CAAC,kBAAkB,EAAE,gBAAgB,CAAC;IAC9C;;;;;;;;;;;OAWG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,CAAC,EAAE,EAAE,EAAE,KAAK,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;CACxD;AAED;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,sBAAsB,GAClC,MAAM,OAAO,EACb,SAAS,2BAA2B,KAClC,KAAK,CAAC,SAAS,CA6IjB,CAAC"}
|
|
@@ -8,10 +8,12 @@ import '../assert_dev_env.js';
|
|
|
8
8
|
* expired-by-construction server-side session for the expiry conformance
|
|
9
9
|
* cases), **`_testing_put_fact`** (seed an embedded fact for the fact-serving
|
|
10
10
|
* suite), **`_testing_schema_snapshot`** (introspect the live schema for
|
|
11
|
-
* cross-impl parity diffing against a Rust backend's `fuz_db` snapshot),
|
|
11
|
+
* cross-impl parity diffing against a Rust backend's `fuz_db` snapshot),
|
|
12
12
|
* **`_testing_migration_tracker`** (dump the `schema_version` tracker rows for
|
|
13
13
|
* cross-impl migration-identity parity — the provenance half the snapshot gate
|
|
14
|
-
* excludes by design)
|
|
14
|
+
* excludes by design), and **`_testing_flood_notifications`** (queue a burst
|
|
15
|
+
* of notifications onto the caller's own WebSocket connection, to push its
|
|
16
|
+
* outbound queue past its byte budget).
|
|
15
17
|
*
|
|
16
18
|
* A further daemon-token action, **`_testing_action_manifest`** (dump the live
|
|
17
19
|
* RPC registry for cross-impl method-set + auth-shape parity), lives here too
|
|
@@ -72,6 +74,7 @@ import { Uuid } from '@fuzdev/fuz_util/id.ts';
|
|
|
72
74
|
import { fact_hash_bytes } from '@fuzdev/fuz_util/fact_hash.ts';
|
|
73
75
|
import { FactHashSchema } from '@fuzdev/fuz_util/hash_schemas.ts';
|
|
74
76
|
import { rpc_action } from '../../actions/action_rpc.js';
|
|
77
|
+
import { jsonrpc_errors } from '../../http/jsonrpc_errors.js';
|
|
75
78
|
import { emit_after_commit } from '../../http/pending_effects.js';
|
|
76
79
|
import { protocol_action_specs } from '../../actions/protocol.js';
|
|
77
80
|
import { query_put_fact } from '../../db/fact_queries.js';
|
|
@@ -291,6 +294,101 @@ export const testing_put_fact_action_spec = {
|
|
|
291
294
|
description: 'Test-binary only — seed an embedded fact (blake3 of the UTF-8 content) and return its hash, ' +
|
|
292
295
|
'so the cross-process fact-serving suite can store bytes without a DB handle.'
|
|
293
296
|
};
|
|
297
|
+
/** The wire method of each notification `_testing_flood_notifications` queues. */
|
|
298
|
+
export const TESTING_FLOOD_NOTIFICATION_METHOD = '_testing_flood';
|
|
299
|
+
/**
|
|
300
|
+
* The most notifications one `_testing_flood_notifications` call queues.
|
|
301
|
+
* Empty ones at this count are charged more than
|
|
302
|
+
* `DEFAULT_WS_OUTBOUND_QUEUE_BYTES` between them — each frame's envelope and
|
|
303
|
+
* its overhead — so a burst of tiny frames can overflow a connection on a
|
|
304
|
+
* default transport too. Same value as the Rust spine's.
|
|
305
|
+
*/
|
|
306
|
+
export const TESTING_FLOOD_MAX_COUNT = 65_536;
|
|
307
|
+
/** The largest `payload` one `_testing_flood_notifications` call builds, in bytes. */
|
|
308
|
+
export const TESTING_FLOOD_MAX_SIZE = 16 * 1024 * 1024;
|
|
309
|
+
/**
|
|
310
|
+
* The most payload bytes one `_testing_flood_notifications` call builds across
|
|
311
|
+
* all its notifications — the bound on `count` × `size`. The handler
|
|
312
|
+
* serializes each notification without yielding, so the product is how long
|
|
313
|
+
* one call holds the event loop.
|
|
314
|
+
*/
|
|
315
|
+
export const TESTING_FLOOD_MAX_TOTAL_BYTES = 64 * 1024 * 1024;
|
|
316
|
+
/** The `data.reason` of the refusal `_testing_flood_notifications` gives a call over HTTP. */
|
|
317
|
+
export const ERROR_TESTING_FLOOD_NO_CONNECTION = 'no_connection';
|
|
318
|
+
/**
|
|
319
|
+
* `_testing_flood_notifications` — queue a burst of notifications onto the
|
|
320
|
+
* **caller's own WebSocket connection**, all in one synchronous run, so a suite
|
|
321
|
+
* driving the server from outside can push a connection's outbound queue past
|
|
322
|
+
* its byte budget deterministically — without a second producer, and whether
|
|
323
|
+
* or not its client is reading: the connection's write pump gets no turn while
|
|
324
|
+
* the handler runs, so the burst waits at once. Past the budget the server
|
|
325
|
+
* closes the socket with `WS_CLOSE_OUTBOUND_OVERFLOW`.
|
|
326
|
+
*
|
|
327
|
+
* Each notification is
|
|
328
|
+
* `{"jsonrpc":"2.0","method":"_testing_flood","params":{"index":<i>,"payload":"<size × 'x'>"}}`
|
|
329
|
+
* with `index` counting from 0, so every frame is a little longer than `size`
|
|
330
|
+
* bytes and a client can tell which frames reached it.
|
|
331
|
+
*
|
|
332
|
+
* The result is `{queued: count}` — the number asked for, not the number that
|
|
333
|
+
* fit: when the burst overflows the queue the connection is closed and this
|
|
334
|
+
* response is never written. A burst that fits arrives in order, ahead of the
|
|
335
|
+
* response.
|
|
336
|
+
*
|
|
337
|
+
* A call past `TESTING_FLOOD_MAX_COUNT`, `TESTING_FLOOD_MAX_SIZE`, or
|
|
338
|
+
* `TESTING_FLOOD_MAX_TOTAL_BYTES` (`count` × `size`) is refused with
|
|
339
|
+
* `invalid_params` and queues nothing. Over HTTP there is no connection to
|
|
340
|
+
* queue onto, so the call is refused with `invalid_params`, reason
|
|
341
|
+
* `ERROR_TESTING_FLOOD_NO_CONNECTION`, rather than answering as though it had
|
|
342
|
+
* flooded something.
|
|
343
|
+
*
|
|
344
|
+
* `auth` gates on the daemon-token credential, matching `_testing_reset`. The
|
|
345
|
+
* Rust mirror is `fuz_testing::create_testing_flood_notifications_action_spec`.
|
|
346
|
+
*/
|
|
347
|
+
export const testing_flood_notifications_action_spec = {
|
|
348
|
+
method: '_testing_flood_notifications',
|
|
349
|
+
kind: 'request_response',
|
|
350
|
+
initiator: 'frontend',
|
|
351
|
+
auth: TESTING_ACTION_AUTH,
|
|
352
|
+
side_effects: false,
|
|
353
|
+
input: z.strictObject({
|
|
354
|
+
count: z.number().int().min(0).max(TESTING_FLOOD_MAX_COUNT).meta({
|
|
355
|
+
description: 'How many notifications to queue.'
|
|
356
|
+
}),
|
|
357
|
+
size: z.number().int().min(0).max(TESTING_FLOOD_MAX_SIZE).meta({
|
|
358
|
+
description: "The byte length of each notification's `payload` string."
|
|
359
|
+
})
|
|
360
|
+
}),
|
|
361
|
+
output: z.strictObject({
|
|
362
|
+
queued: z.number().int().meta({
|
|
363
|
+
description: 'The number of notifications asked for — not the number that fit.'
|
|
364
|
+
})
|
|
365
|
+
}),
|
|
366
|
+
async: true,
|
|
367
|
+
error_reasons: [ERROR_TESTING_FLOOD_NO_CONNECTION],
|
|
368
|
+
description: "Test-binary only — queue a burst of notifications onto the caller's own WebSocket " +
|
|
369
|
+
'connection in one synchronous run, to push its outbound queue past its byte budget.'
|
|
370
|
+
};
|
|
371
|
+
/**
|
|
372
|
+
* Build the standalone `_testing_flood_notifications` action (see the spec
|
|
373
|
+
* doc). The spine binary bundles it via `create_testing_actions`; mount it
|
|
374
|
+
* directly on an in-process WS endpoint whose suite drives an outbound
|
|
375
|
+
* overflow.
|
|
376
|
+
*/
|
|
377
|
+
export const create_testing_flood_notifications_action = () =>
|
|
378
|
+
// not `async`: the burst must be queued in one synchronous run
|
|
379
|
+
rpc_action(testing_flood_notifications_action_spec, ({ count, size }, ctx) => {
|
|
380
|
+
if (count * size > TESTING_FLOOD_MAX_TOTAL_BYTES) {
|
|
381
|
+
throw jsonrpc_errors.invalid_params(`count * size must be at most ${TESTING_FLOOD_MAX_TOTAL_BYTES}`);
|
|
382
|
+
}
|
|
383
|
+
if (ctx.connection_id === undefined) {
|
|
384
|
+
throw jsonrpc_errors.invalid_params('_testing_flood_notifications needs a WebSocket connection', { reason: ERROR_TESTING_FLOOD_NO_CONNECTION });
|
|
385
|
+
}
|
|
386
|
+
const payload = 'x'.repeat(size);
|
|
387
|
+
for (let index = 0; index < count; index++) {
|
|
388
|
+
ctx.notify(TESTING_FLOOD_NOTIFICATION_METHOD, { index, payload });
|
|
389
|
+
}
|
|
390
|
+
return { queued: count };
|
|
391
|
+
});
|
|
294
392
|
/**
|
|
295
393
|
* `_testing_schema_snapshot` — introspect the live database into a normalized
|
|
296
394
|
* `SchemaSnapshot` for cross-impl parity diffing. The cross-backend harness
|
|
@@ -527,7 +625,7 @@ export const create_testing_actions = (deps, options) => {
|
|
|
527
625
|
// fuz_forge cell/fact/file truncation, or no-op for spine_stub).
|
|
528
626
|
// Pass the transactional `ctx.db` so DB-domain truncation runs
|
|
529
627
|
// on the same connection — a separate pool connection deadlocks
|
|
530
|
-
// against this open transaction under
|
|
628
|
+
// against this open transaction under the in-memory drivers.
|
|
531
629
|
if (reset_state)
|
|
532
630
|
await reset_state(ctx.db);
|
|
533
631
|
return { ...keeper, extra_accounts: extras, extra_actors };
|
|
@@ -555,6 +653,7 @@ export const create_testing_actions = (deps, options) => {
|
|
|
555
653
|
return { hash };
|
|
556
654
|
}),
|
|
557
655
|
create_testing_drain_effects_action(deps.audit),
|
|
656
|
+
create_testing_flood_notifications_action(),
|
|
558
657
|
create_testing_schema_snapshot_action(),
|
|
559
658
|
create_testing_migration_tracker_action()
|
|
560
659
|
];
|
|
@@ -7,7 +7,7 @@ import '../assert_dev_env.js';
|
|
|
7
7
|
* these spawn fuz_app's own TS impl over real HTTP with no domain layer, so
|
|
8
8
|
* the `cross_backend_ts_node` / `cross_backend_ts_deno` / `cross_backend_ts_bun`
|
|
9
9
|
* self-test projects verify fuz_app's wire path in its own repo across all
|
|
10
|
-
* three JS runtimes. All run against in-memory
|
|
10
|
+
* three JS runtimes. All run against in-memory pglet (`memory://`) — no
|
|
11
11
|
* external Postgres, unlike the Rust path.
|
|
12
12
|
*
|
|
13
13
|
* The binary writes its daemon token to `{FUZ_TESTING_TS_SPINE_DIR}/run/daemon_token`;
|
|
@@ -32,7 +32,7 @@ export declare const TS_SPINE_BUN_ENTRY = "src/test/cross_backend/testing_spine_
|
|
|
32
32
|
export interface TsSpineBackendConfigOptions {
|
|
33
33
|
/** Listening port. Defaults per runtime (`1178` Node, `1179` Deno, `1180` Bun). */
|
|
34
34
|
readonly port?: number;
|
|
35
|
-
/** Database URL. Default `'memory://'` (in-memory
|
|
35
|
+
/** Database URL. Default `'memory://'` (in-memory pglet). */
|
|
36
36
|
readonly database_url?: string;
|
|
37
37
|
/**
|
|
38
38
|
* Enable the per-IP + per-account login rate limiters on the spawned binary
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ts_spine_backend_config.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/ts_spine_backend_config.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAE9B;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;
|
|
1
|
+
{"version":3,"file":"ts_spine_backend_config.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/ts_spine_backend_config.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAE9B;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAwDzD,6FAA6F;AAC7F,eAAO,MAAM,0BAA0B,OAAO,CAAC;AAE/C,iDAAiD;AACjD,eAAO,MAAM,0BAA0B,OAAO,CAAC;AAE/C,gDAAgD;AAChD,eAAO,MAAM,yBAAyB,OAAO,CAAC;AAE9C,yDAAyD;AACzD,eAAO,MAAM,mBAAmB,wDAAwD,CAAC;AAEzF,yDAAyD;AACzD,eAAO,MAAM,mBAAmB,wDAAwD,CAAC;AAEzF,wDAAwD;AACxD,eAAO,MAAM,kBAAkB,uDAAuD,CAAC;AAEvF,MAAM,WAAW,2BAA2B;IAC3C,mFAAmF;IACnF,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IACvB,6DAA6D;IAC7D,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAC/B;;;;;;;;;OASG;IACH,QAAQ,CAAC,uBAAuB,CAAC,EAAE,OAAO,CAAC;IAC3C;;;;;;OAMG;IACH,QAAQ,CAAC,wBAAwB,CAAC,EAAE,OAAO,CAAC;IAC5C;;;;;OAKG;IACH,QAAQ,CAAC,8BAA8B,CAAC,EAAE,MAAM,CAAC;CACjD;AAWD;;;GAGG;AACH,eAAO,MAAM,4BAA4B,GACxC,UAAS,2BAAgC,KACvC,aAoBF,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,2BAA2B,GACvC,UAAS,2BAAgC,KACvC,aAoBF,CAAC;AAEF;;;;;;;;;;GAUG;AACH,eAAO,MAAM,4BAA4B,GACxC,UAAS,2BAAgC,KACvC,aAiCF,CAAC"}
|
|
@@ -23,13 +23,15 @@ const ts_spine_capabilities = Object.freeze({
|
|
|
23
23
|
/**
|
|
24
24
|
* Capabilities for the **Bun** spine binary — `ts_spine_capabilities` with
|
|
25
25
|
* `oversized_reject_closes_connection: false` and
|
|
26
|
-
* `ws_handshake_pipelining: false`.
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* response carries `Connection: close`,
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
26
|
+
* `ws_handshake_pipelining: false`.
|
|
27
|
+
*
|
|
28
|
+
* `Bun.serve` does not close the connection after an oversized-body `413`:
|
|
29
|
+
* the response carries `Connection: close`, and Bun keeps the socket open,
|
|
30
|
+
* skips the declared body, and serves the request that follows it (observed
|
|
31
|
+
* on Bun 1.3.11). The next request is framed on `Content-Length`, so nothing
|
|
32
|
+
* is desynchronized, but the smuggling suite's "the pipelined request is never
|
|
33
|
+
* served" assertion does not hold; the flag puts Bun on the suite's no-desync
|
|
34
|
+
* arm, which requires exactly the 413 and the reply to the pipelined request.
|
|
33
35
|
* See `docs/security.md` §"Body Size Limiting".
|
|
34
36
|
*
|
|
35
37
|
* `Bun.serve` also answers `400` to an upgrade request that carries WebSocket
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import '../assert_dev_env.js';
|
|
2
|
+
import { type BackendCapabilities } from './capabilities.js';
|
|
3
|
+
import type { SetupTest } from './setup.js';
|
|
4
|
+
/** Options for the outbound-overflow parity suite. */
|
|
5
|
+
export interface WsOutboundOverflowCrossTestOptions {
|
|
6
|
+
/** Per-test fixture producer (`default_cross_process_setup(handle)`). */
|
|
7
|
+
readonly setup_test: SetupTest;
|
|
8
|
+
/** Backend capability flags; every WebSocket case gates on `capabilities.ws`. */
|
|
9
|
+
readonly capabilities: BackendCapabilities;
|
|
10
|
+
/** Base URL the backend is reachable at (e.g. `http://localhost:1178`). */
|
|
11
|
+
readonly base_url: string;
|
|
12
|
+
/** WebSocket endpoint path on the backend (e.g. `/api/ws`). */
|
|
13
|
+
readonly ws_path: string;
|
|
14
|
+
/** Origin for the session socket's upgrade. Defaults to `base_url`. */
|
|
15
|
+
readonly origin?: string;
|
|
16
|
+
/**
|
|
17
|
+
* The backend's RPC endpoint path, for the over-HTTP refusal. Omitted, that
|
|
18
|
+
* case is skipped.
|
|
19
|
+
*/
|
|
20
|
+
readonly rpc_path?: string;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Register the outbound-overflow parity suite over a real upgrade: a burst
|
|
24
|
+
* within the budget arrives whole, one past it closes the socket with
|
|
25
|
+
* `WS_CLOSE_OUTBOUND_OVERFLOW` and no response, and the account's other
|
|
26
|
+
* connection is unaffected.
|
|
27
|
+
*/
|
|
28
|
+
export declare const describe_ws_outbound_overflow_cross_tests: (options: WsOutboundOverflowCrossTestOptions) => void;
|
|
29
|
+
//# sourceMappingURL=ws_outbound_overflow.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ws_outbound_overflow.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/ws_outbound_overflow.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAiE9B,OAAO,EAAE,KAAK,mBAAmB,EAAW,MAAM,mBAAmB,CAAC;AAEtE,OAAO,KAAK,EAAE,SAAS,EAAe,MAAM,YAAY,CAAC;AAiCzD,sDAAsD;AACtD,MAAM,WAAW,kCAAkC;IAClD,yEAAyE;IACzE,QAAQ,CAAC,UAAU,EAAE,SAAS,CAAC;IAC/B,iFAAiF;IACjF,QAAQ,CAAC,YAAY,EAAE,mBAAmB,CAAC;IAC3C,2EAA2E;IAC3E,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,+DAA+D;IAC/D,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,uEAAuE;IACvE,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB;;;OAGG;IACH,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED;;;;;GAKG;AACH,eAAO,MAAM,yCAAyC,GACrD,SAAS,kCAAkC,KACzC,IA0LF,CAAC"}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import '../assert_dev_env.js';
|
|
2
|
+
/**
|
|
3
|
+
* Cross-backend parity suite for the **per-connection outbound bound** over a
|
|
4
|
+
* real upgrade.
|
|
5
|
+
*
|
|
6
|
+
* Both spines hold what waits to be written to one WebSocket to a byte budget
|
|
7
|
+
* (`BackendWebsocketTransport`'s `outbound_queue_bytes` on the TS side, the
|
|
8
|
+
* Rust `fuz_realtime` outbound queue on the other; both default to 4 MiB, each
|
|
9
|
+
* waiting frame charged its UTF-8 length plus 64 bytes). A frame that does not
|
|
10
|
+
* fit closes the socket with `WS_CLOSE_OUTBOUND_OVERFLOW` and the reason
|
|
11
|
+
* `outbound queue overflow`; the credential stays good, so a client reconnects
|
|
12
|
+
* under its ordinary backoff. The code and the reason are therefore wire
|
|
13
|
+
* contract, and this suite is the pin that runs on both.
|
|
14
|
+
*
|
|
15
|
+
* It drives the backend's `_testing_flood_notifications` action, which queues
|
|
16
|
+
* a burst of notifications onto the caller's own connection in one synchronous
|
|
17
|
+
* run — so the burst waits at once whether or not the client is reading, and
|
|
18
|
+
* the overflow is deterministic. The cases:
|
|
19
|
+
*
|
|
20
|
+
* - **within the budget** — a small burst arrives whole and in order, ahead of
|
|
21
|
+
* its response, and the socket stays open.
|
|
22
|
+
* - **large, within the budget** — a burst of a few mebibytes, most of the
|
|
23
|
+
* default budget, arrives the same way: every frame whole, in order, the
|
|
24
|
+
* response last. Megabyte frames can outrun a socket's buffer, and where
|
|
25
|
+
* they do — the Deno TS spine, as observed; Node and Bun write the burst
|
|
26
|
+
* through — the frames behind the first wait on the backend's write pump.
|
|
27
|
+
* - **overflow** — a burst far past the budget closes the socket with the code
|
|
28
|
+
* and the exact reason; the response of the flooding request never arrives;
|
|
29
|
+
* any flood frames that did arrive form a contiguous prefix of the burst.
|
|
30
|
+
* A second connection on the same account keeps dispatching throughout.
|
|
31
|
+
* - **refusal** — a burst past the action's own size limit is refused with
|
|
32
|
+
* `invalid_params`, and the socket stays open.
|
|
33
|
+
* - **no connection** — over HTTP RPC the action refuses with `invalid_params`
|
|
34
|
+
* and reason `no_connection`, rather than answering as though it flooded.
|
|
35
|
+
*
|
|
36
|
+
* What the suite does **not** assert, because it is each implementation's own:
|
|
37
|
+
* how many frames accepted before the overflow reach the client (the Rust
|
|
38
|
+
* spine discards them all; the TS spine may deliver the first), and the exact
|
|
39
|
+
* boundary a burst of tiny frames overflows at.
|
|
40
|
+
*
|
|
41
|
+
* The wire literals are deliberate independent copies of the producers'
|
|
42
|
+
* constants — parity by test, so a value changed on one side fails here
|
|
43
|
+
* instead of moving both at once.
|
|
44
|
+
*
|
|
45
|
+
* The flood action is gated on the daemon token, so the flooding socket
|
|
46
|
+
* upgrades with it and no `Origin` (both spines discard a daemon token
|
|
47
|
+
* presented in a browser context). Gated on `capabilities.ws`; cross-process
|
|
48
|
+
* only (`create_ws_transport` needs a real bound socket), and only for a
|
|
49
|
+
* backend that mounts `_testing_flood_notifications` on its WS endpoint.
|
|
50
|
+
*
|
|
51
|
+
* @module
|
|
52
|
+
*/
|
|
53
|
+
import { assert, describe } from 'vitest';
|
|
54
|
+
import { create_jsonrpc_request } from '../../http/jsonrpc_helpers.js';
|
|
55
|
+
import { create_admitted_ws_transport } from '../transports/ws_transport.js';
|
|
56
|
+
import { is_notification, is_response_for } from '../transports/ws_client.js';
|
|
57
|
+
import { test_if } from './capabilities.js';
|
|
58
|
+
import { cross_rpc_call } from './cell_cross_helpers.js';
|
|
59
|
+
/** The close code both spines send on an outbound overflow. */
|
|
60
|
+
const WS_CLOSE_OUTBOUND_OVERFLOW = 4005;
|
|
61
|
+
/** The close reason both spines send with it. */
|
|
62
|
+
const OUTBOUND_OVERFLOW_REASON = 'outbound queue overflow';
|
|
63
|
+
const FLOOD_METHOD = '_testing_flood_notifications';
|
|
64
|
+
const FLOOD_NOTIFICATION_METHOD = '_testing_flood';
|
|
65
|
+
const HEARTBEAT_METHOD = 'heartbeat';
|
|
66
|
+
const INVALID_PARAMS = -32602;
|
|
67
|
+
const REASON_NO_CONNECTION = 'no_connection';
|
|
68
|
+
const MIB = 1024 * 1024;
|
|
69
|
+
/** A burst within any budget. */
|
|
70
|
+
const FITS = { count: 3, size: 8 };
|
|
71
|
+
/**
|
|
72
|
+
* A burst of 3 MiB in megabyte frames: charged a little over 3 MiB with its
|
|
73
|
+
* envelopes and overhead, inside the 4 MiB default budget.
|
|
74
|
+
*/
|
|
75
|
+
const LARGE_FITS = { count: 3, size: MIB };
|
|
76
|
+
/** A burst of 32 MiB — eight times the default budget. */
|
|
77
|
+
const OVERFLOWS = { count: 32, size: MIB };
|
|
78
|
+
/** One mebibyte past the action's `count` × `size` limit. */
|
|
79
|
+
const REFUSED = { count: 65, size: MIB };
|
|
80
|
+
/** How long the test waits for the megabyte burst and its response. */
|
|
81
|
+
const LARGE_BURST_TIMEOUT_MS = 15_000;
|
|
82
|
+
/** How long the test waits for the overflow close. */
|
|
83
|
+
const CLOSE_TIMEOUT_MS = 15_000;
|
|
84
|
+
/**
|
|
85
|
+
* Register the outbound-overflow parity suite over a real upgrade: a burst
|
|
86
|
+
* within the budget arrives whole, one past it closes the socket with
|
|
87
|
+
* `WS_CLOSE_OUTBOUND_OVERFLOW` and no response, and the account's other
|
|
88
|
+
* connection is unaffected.
|
|
89
|
+
*/
|
|
90
|
+
export const describe_ws_outbound_overflow_cross_tests = (options) => {
|
|
91
|
+
const { setup_test, capabilities, base_url, ws_path, origin, rpc_path } = options;
|
|
92
|
+
/** A daemon-token socket the backend has admitted — the one that floods itself. */
|
|
93
|
+
const open_flooder = (fixture) => create_admitted_ws_transport({
|
|
94
|
+
base_url,
|
|
95
|
+
ws_path,
|
|
96
|
+
cookies: [],
|
|
97
|
+
origin: null,
|
|
98
|
+
headers: fixture.create_daemon_token_headers()
|
|
99
|
+
});
|
|
100
|
+
/** The `index` of every flood notification `client` received, in arrival order. */
|
|
101
|
+
const flood_indices = (client) => client.messages
|
|
102
|
+
.filter(is_notification(FLOOD_NOTIFICATION_METHOD))
|
|
103
|
+
.map((msg) => msg.params.index);
|
|
104
|
+
const assert_open_and_answering = async (client, id, message) => {
|
|
105
|
+
assert.deepStrictEqual(await client.request(id, HEARTBEAT_METHOD, {}), {}, message);
|
|
106
|
+
assert.strictEqual(client.close_code, null, message);
|
|
107
|
+
};
|
|
108
|
+
describe('websocket outbound overflow parity', { timeout: 30_000 }, () => {
|
|
109
|
+
test_if(capabilities.ws, 'a burst within the budget arrives in order, ahead of its response', async () => {
|
|
110
|
+
const fixture = await setup_test();
|
|
111
|
+
const client = await open_flooder(fixture);
|
|
112
|
+
try {
|
|
113
|
+
const result = await client.request('fits', FLOOD_METHOD, FITS);
|
|
114
|
+
assert.deepStrictEqual(result, { queued: FITS.count });
|
|
115
|
+
assert.deepStrictEqual(flood_indices(client), [0, 1, 2]);
|
|
116
|
+
const frames = client.messages.filter(is_notification(FLOOD_NOTIFICATION_METHOD));
|
|
117
|
+
for (const frame of frames) {
|
|
118
|
+
assert.strictEqual(frame.params.payload, 'x'.repeat(FITS.size));
|
|
119
|
+
}
|
|
120
|
+
// the whole burst is ahead of the response
|
|
121
|
+
const response_at = client.messages.findIndex(is_response_for('fits'));
|
|
122
|
+
const last_flood_at = client.messages.lastIndexOf(frames.at(-1));
|
|
123
|
+
assert.ok(last_flood_at < response_at, 'the burst precedes its response');
|
|
124
|
+
await assert_open_and_answering(client, 'after_fits', 'the socket stays open');
|
|
125
|
+
}
|
|
126
|
+
finally {
|
|
127
|
+
await client.close().catch(() => { });
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
test_if(capabilities.ws, 'a multi-megabyte burst within the budget arrives whole and in order, the response last', async () => {
|
|
131
|
+
const fixture = await setup_test();
|
|
132
|
+
const client = await open_flooder(fixture);
|
|
133
|
+
try {
|
|
134
|
+
const result = await client.request('large_fits', FLOOD_METHOD, LARGE_FITS, LARGE_BURST_TIMEOUT_MS);
|
|
135
|
+
assert.deepStrictEqual(result, { queued: LARGE_FITS.count });
|
|
136
|
+
assert.deepStrictEqual(flood_indices(client), [0, 1, 2]);
|
|
137
|
+
const frames = client.messages.filter(is_notification(FLOOD_NOTIFICATION_METHOD));
|
|
138
|
+
const payload = 'x'.repeat(LARGE_FITS.size);
|
|
139
|
+
for (const frame of frames) {
|
|
140
|
+
// compared by length first: a mismatch would otherwise print a mebibyte
|
|
141
|
+
assert.strictEqual(frame.params.payload.length, LARGE_FITS.size);
|
|
142
|
+
assert.ok(frame.params.payload === payload, 'the payload arrived intact');
|
|
143
|
+
}
|
|
144
|
+
// nothing follows the response: the whole burst is ahead of it
|
|
145
|
+
const response_at = client.messages.findIndex(is_response_for('large_fits'));
|
|
146
|
+
const last_flood_at = client.messages.lastIndexOf(frames.at(-1));
|
|
147
|
+
assert.ok(last_flood_at < response_at, 'the burst precedes its response');
|
|
148
|
+
assert.strictEqual(client.close_code, null, 'a burst within the budget closes nothing');
|
|
149
|
+
await assert_open_and_answering(client, 'after_large_fits', 'the socket stays open');
|
|
150
|
+
}
|
|
151
|
+
finally {
|
|
152
|
+
await client.close().catch(() => { });
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
test_if(capabilities.ws, 'a burst past the budget closes the socket with WS_CLOSE_OUTBOUND_OVERFLOW and no response', async () => {
|
|
156
|
+
const fixture = await setup_test();
|
|
157
|
+
// a second connection on the same account (the keeper's session)
|
|
158
|
+
const bystander = await create_admitted_ws_transport({
|
|
159
|
+
base_url,
|
|
160
|
+
ws_path,
|
|
161
|
+
cookies: fixture.transport.cookies(),
|
|
162
|
+
origin
|
|
163
|
+
});
|
|
164
|
+
const client = await open_flooder(fixture);
|
|
165
|
+
try {
|
|
166
|
+
await client.send(create_jsonrpc_request(FLOOD_METHOD, OVERFLOWS, 'overflows'));
|
|
167
|
+
const closed = await client.wait_for_close(CLOSE_TIMEOUT_MS);
|
|
168
|
+
assert.ok(closed, 'the flooded socket did not close');
|
|
169
|
+
assert.strictEqual(client.close_code, WS_CLOSE_OUTBOUND_OVERFLOW);
|
|
170
|
+
assert.strictEqual(client.close_reason, OUTBOUND_OVERFLOW_REASON);
|
|
171
|
+
// the response of the request whose handler overflowed is not sent
|
|
172
|
+
assert.strictEqual(client.messages.some(is_response_for('overflows')), false, 'no response may follow an overflow');
|
|
173
|
+
// frames accepted before the overflow may or may not arrive; any
|
|
174
|
+
// that do are a contiguous prefix of the burst, and never all of it
|
|
175
|
+
const indices = flood_indices(client);
|
|
176
|
+
assert.ok(indices.length < OVERFLOWS.count, `${indices.length} flood frames arrived`);
|
|
177
|
+
assert.deepStrictEqual(indices, indices.map((_, i) => i), 'the flood frames that arrived are a contiguous prefix from index 0');
|
|
178
|
+
// the account's other connection kept working through it
|
|
179
|
+
await assert_open_and_answering(bystander, 'bystander', "another connection on the account is untouched by the first one's overflow");
|
|
180
|
+
// not a revocation: the daemon token still upgrades
|
|
181
|
+
const again = await open_flooder(fixture);
|
|
182
|
+
await again.close();
|
|
183
|
+
}
|
|
184
|
+
finally {
|
|
185
|
+
await Promise.all([client.close().catch(() => { }), bystander.close().catch(() => { })]);
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
test_if(capabilities.ws, 'a burst past the flood action size limit is refused, and the socket stays open', async () => {
|
|
189
|
+
const fixture = await setup_test();
|
|
190
|
+
const client = await open_flooder(fixture);
|
|
191
|
+
try {
|
|
192
|
+
await client.send(create_jsonrpc_request(FLOOD_METHOD, REFUSED, 'refused'));
|
|
193
|
+
const frame = await client.wait_for(is_response_for('refused'));
|
|
194
|
+
assert.ok(frame.error, 'expected invalid_params but the call succeeded');
|
|
195
|
+
assert.strictEqual(frame.error.code, INVALID_PARAMS);
|
|
196
|
+
assert.deepStrictEqual(flood_indices(client), [], 'a refused burst queues nothing');
|
|
197
|
+
await assert_open_and_answering(client, 'after_refused', 'the socket stays open');
|
|
198
|
+
}
|
|
199
|
+
finally {
|
|
200
|
+
await client.close().catch(() => { });
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
test_if(rpc_path !== undefined, 'over HTTP the flood action refuses with invalid_params, reason no_connection', async () => {
|
|
204
|
+
const fixture = await setup_test();
|
|
205
|
+
const res = await cross_rpc_call(fixture.fresh_transport({ origin: null }), rpc_path, FLOOD_METHOD, FITS, fixture.create_daemon_token_headers());
|
|
206
|
+
assert.strictEqual(res.ok, false, 'expected a refusal over HTTP');
|
|
207
|
+
assert.strictEqual(res.error?.code, INVALID_PARAMS);
|
|
208
|
+
assert.strictEqual(res.error?.data?.reason, REASON_NO_CONNECTION);
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
};
|