@fuzdev/fuz_app 0.134.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 +93 -11
- 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 +133 -10
- package/dist/actions/transports_ws_backend.d.ts.map +1 -1
- package/dist/actions/transports_ws_backend.js +237 -34
- 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 +10 -4
- 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/audit_log_routes.d.ts.map +1 -1
- package/dist/auth/audit_log_routes.js +3 -1
- 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/cell_actions.js +1 -1
- 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/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/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/db/migrate.d.ts +1 -1
- package/dist/db/migrate.js +2 -2
- package/dist/env/load.d.ts +1 -1
- package/dist/env/load.js +1 -1
- package/dist/http/CLAUDE.md +2 -1
- package/dist/http/body_limit.d.ts +74 -0
- package/dist/http/body_limit.d.ts.map +1 -0
- package/dist/http/body_limit.js +100 -0
- package/dist/http/db_routes.d.ts.map +1 -1
- package/dist/http/db_routes.js +5 -3
- 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 +96 -19
- package/dist/testing/admin_integration.js +1 -1
- 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 +19 -6
- package/dist/testing/audit_drift_guard.d.ts.map +1 -1
- package/dist/testing/audit_drift_guard.js +25 -9
- 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 +103 -108
- package/dist/testing/cross_backend/capabilities.d.ts +3 -21
- package/dist/testing/cross_backend/capabilities.d.ts.map +1 -1
- package/dist/testing/cross_backend/capabilities.js +1 -3
- 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/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/testing_backdoor.d.ts.map +1 -1
- package/dist/testing/cross_backend/testing_backdoor.js +10 -4
- package/dist/testing/cross_backend/testing_reset_actions.d.ts +87 -2
- package/dist/testing/cross_backend/testing_reset_actions.d.ts.map +1 -1
- package/dist/testing/cross_backend/testing_reset_actions.js +106 -2
- 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 +8 -13
- 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 +276 -0
- package/dist/testing/integration.js +1 -1
- package/dist/testing/schema_introspect.d.ts +30 -2
- package/dist/testing/schema_introspect.d.ts.map +1 -1
- package/dist/testing/schema_introspect.js +38 -7
- 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/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 +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
|
@@ -0,0 +1,276 @@
|
|
|
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
|
+
* - **the action's limits** — over HTTP RPC a call at each of the action's
|
|
36
|
+
* limits (`count`, `size`, `count` × `size`) is refused for having no
|
|
37
|
+
* connection, and a call one past each is refused `invalid_params` with no
|
|
38
|
+
* reason. Both spines check the limits before they look for the connection
|
|
39
|
+
* and before they build a payload, so the boundaries are read off the
|
|
40
|
+
* refusal and no burst is ever allocated — which pins the two spines'
|
|
41
|
+
* limits equal.
|
|
42
|
+
*
|
|
43
|
+
* What the suite does **not** assert, because it is each implementation's own:
|
|
44
|
+
* how many frames accepted before the overflow reach the client (the Rust
|
|
45
|
+
* spine discards them all; the TS spine may deliver the first), and the exact
|
|
46
|
+
* boundary a burst of tiny frames overflows at.
|
|
47
|
+
*
|
|
48
|
+
* The wire literals are deliberate independent copies of the producers'
|
|
49
|
+
* constants — parity by test, so a value changed on one side fails here
|
|
50
|
+
* instead of moving both at once.
|
|
51
|
+
*
|
|
52
|
+
* The flood action is gated on the daemon token, so the flooding socket
|
|
53
|
+
* upgrades with it and no `Origin` (both spines discard a daemon token
|
|
54
|
+
* presented in a browser context). Gated on `capabilities.ws`; cross-process
|
|
55
|
+
* only (`create_ws_transport` needs a real bound socket), and only for a
|
|
56
|
+
* backend that mounts `_testing_flood_notifications` on its WS endpoint.
|
|
57
|
+
*
|
|
58
|
+
* @module
|
|
59
|
+
*/
|
|
60
|
+
import { assert, describe } from 'vitest';
|
|
61
|
+
import { create_jsonrpc_request } from '../../http/jsonrpc_helpers.js';
|
|
62
|
+
import { create_admitted_ws_transport } from '../transports/ws_transport.js';
|
|
63
|
+
import { is_notification, is_response_for } from '../transports/ws_client.js';
|
|
64
|
+
import { test_if } from './capabilities.js';
|
|
65
|
+
import { cross_rpc_call } from './cell_cross_helpers.js';
|
|
66
|
+
/** The close code both spines send on an outbound overflow. */
|
|
67
|
+
const WS_CLOSE_OUTBOUND_OVERFLOW = 4005;
|
|
68
|
+
/** The close reason both spines send with it. */
|
|
69
|
+
const OUTBOUND_OVERFLOW_REASON = 'outbound queue overflow';
|
|
70
|
+
const FLOOD_METHOD = '_testing_flood_notifications';
|
|
71
|
+
const FLOOD_NOTIFICATION_METHOD = '_testing_flood';
|
|
72
|
+
const HEARTBEAT_METHOD = 'heartbeat';
|
|
73
|
+
const INVALID_PARAMS = -32602;
|
|
74
|
+
const REASON_NO_CONNECTION = 'no_connection';
|
|
75
|
+
const MIB = 1024 * 1024;
|
|
76
|
+
/** A burst within any budget. */
|
|
77
|
+
const FITS = { count: 3, size: 8 };
|
|
78
|
+
/**
|
|
79
|
+
* A burst of 3 MiB in megabyte frames: charged a little over 3 MiB with its
|
|
80
|
+
* envelopes and overhead, inside the 4 MiB default budget.
|
|
81
|
+
*/
|
|
82
|
+
const LARGE_FITS = { count: 3, size: MIB };
|
|
83
|
+
/** A burst of 32 MiB — eight times the default budget. */
|
|
84
|
+
const OVERFLOWS = { count: 32, size: MIB };
|
|
85
|
+
/** One mebibyte past the action's `count` × `size` limit. */
|
|
86
|
+
const REFUSED = { count: 65, size: MIB };
|
|
87
|
+
/** The most notifications one flood call may ask for. */
|
|
88
|
+
const FLOOD_MAX_COUNT = 65_536;
|
|
89
|
+
/** The largest payload one flood call may ask for. */
|
|
90
|
+
const FLOOD_MAX_SIZE = 16 * MIB;
|
|
91
|
+
/** The most payload bytes (`count` × `size`) one flood call may ask for. */
|
|
92
|
+
const FLOOD_MAX_TOTAL_BYTES = 64 * MIB;
|
|
93
|
+
/**
|
|
94
|
+
* A `count` and `size`, each within its own limit, whose product is exactly
|
|
95
|
+
* one byte past `FLOOD_MAX_TOTAL_BYTES` (the factors of 2^26 + 1).
|
|
96
|
+
*/
|
|
97
|
+
const FLOOD_ONE_BYTE_PAST_TOTAL = { count: 265, size: 253_241 };
|
|
98
|
+
/**
|
|
99
|
+
* Calls at and one past each of the flood action's limits. Each "past" call
|
|
100
|
+
* holds the other axis at zero, or both axes within their own limit, so one
|
|
101
|
+
* limit alone decides it.
|
|
102
|
+
*/
|
|
103
|
+
const FLOOD_LIMIT_CASES = [
|
|
104
|
+
{ name: 'count at its limit', params: { count: FLOOD_MAX_COUNT, size: 1 }, within: true },
|
|
105
|
+
{ name: 'count past its limit', params: { count: FLOOD_MAX_COUNT + 1, size: 0 }, within: false },
|
|
106
|
+
{ name: 'size at its limit', params: { count: 1, size: FLOOD_MAX_SIZE }, within: true },
|
|
107
|
+
{ name: 'size past its limit', params: { count: 0, size: FLOOD_MAX_SIZE + 1 }, within: false },
|
|
108
|
+
{
|
|
109
|
+
name: 'count × size at its limit, both axes at theirs or under',
|
|
110
|
+
params: { count: FLOOD_MAX_TOTAL_BYTES / FLOOD_MAX_SIZE, size: FLOOD_MAX_SIZE },
|
|
111
|
+
within: true
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
name: 'count × size at its limit, count at its own',
|
|
115
|
+
params: { count: FLOOD_MAX_COUNT, size: FLOOD_MAX_TOTAL_BYTES / FLOOD_MAX_COUNT },
|
|
116
|
+
within: true
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
name: 'count × size past its limit, each axis within its own',
|
|
120
|
+
params: { count: FLOOD_MAX_COUNT, size: FLOOD_MAX_TOTAL_BYTES / FLOOD_MAX_COUNT + 1 },
|
|
121
|
+
within: false
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
// the smallest step past the product limit: the row above overshoots it by
|
|
125
|
+
// `FLOOD_MAX_COUNT` bytes, so alone it admits a limit that many bytes loose
|
|
126
|
+
name: 'count × size one byte past its limit',
|
|
127
|
+
params: FLOOD_ONE_BYTE_PAST_TOTAL,
|
|
128
|
+
within: false
|
|
129
|
+
}
|
|
130
|
+
];
|
|
131
|
+
/** How long the test waits for the megabyte burst and its response. */
|
|
132
|
+
const LARGE_BURST_TIMEOUT_MS = 15_000;
|
|
133
|
+
/** How long the test waits for the overflow close. */
|
|
134
|
+
const CLOSE_TIMEOUT_MS = 15_000;
|
|
135
|
+
/**
|
|
136
|
+
* Register the outbound-overflow parity suite over a real upgrade: a burst
|
|
137
|
+
* within the budget arrives whole, one past it closes the socket with
|
|
138
|
+
* `WS_CLOSE_OUTBOUND_OVERFLOW` and no response, and the account's other
|
|
139
|
+
* connection is unaffected.
|
|
140
|
+
*/
|
|
141
|
+
export const describe_ws_outbound_overflow_cross_tests = (options) => {
|
|
142
|
+
const { setup_test, capabilities, base_url, ws_path, origin, rpc_path } = options;
|
|
143
|
+
/** A daemon-token socket the backend has admitted — the one that floods itself. */
|
|
144
|
+
const open_flooder = (fixture) => create_admitted_ws_transport({
|
|
145
|
+
base_url,
|
|
146
|
+
ws_path,
|
|
147
|
+
cookies: [],
|
|
148
|
+
origin: null,
|
|
149
|
+
headers: fixture.create_daemon_token_headers()
|
|
150
|
+
});
|
|
151
|
+
/** The `index` of every flood notification `client` received, in arrival order. */
|
|
152
|
+
const flood_indices = (client) => client.messages
|
|
153
|
+
.filter(is_notification(FLOOD_NOTIFICATION_METHOD))
|
|
154
|
+
.map((msg) => msg.params.index);
|
|
155
|
+
const assert_open_and_answering = async (client, id, message) => {
|
|
156
|
+
assert.deepStrictEqual(await client.request(id, HEARTBEAT_METHOD, {}), {}, message);
|
|
157
|
+
assert.strictEqual(client.close_code, null, message);
|
|
158
|
+
};
|
|
159
|
+
describe('websocket outbound overflow parity', { timeout: 30_000 }, () => {
|
|
160
|
+
test_if(capabilities.ws, 'a burst within the budget arrives in order, ahead of its response', async () => {
|
|
161
|
+
const fixture = await setup_test();
|
|
162
|
+
const client = await open_flooder(fixture);
|
|
163
|
+
try {
|
|
164
|
+
const result = await client.request('fits', FLOOD_METHOD, FITS);
|
|
165
|
+
assert.deepStrictEqual(result, { queued: FITS.count });
|
|
166
|
+
assert.deepStrictEqual(flood_indices(client), [0, 1, 2]);
|
|
167
|
+
const frames = client.messages.filter(is_notification(FLOOD_NOTIFICATION_METHOD));
|
|
168
|
+
for (const frame of frames) {
|
|
169
|
+
assert.strictEqual(frame.params.payload, 'x'.repeat(FITS.size));
|
|
170
|
+
}
|
|
171
|
+
// the whole burst is ahead of the response
|
|
172
|
+
const response_at = client.messages.findIndex(is_response_for('fits'));
|
|
173
|
+
const last_flood_at = client.messages.lastIndexOf(frames.at(-1));
|
|
174
|
+
assert.ok(last_flood_at < response_at, 'the burst precedes its response');
|
|
175
|
+
await assert_open_and_answering(client, 'after_fits', 'the socket stays open');
|
|
176
|
+
}
|
|
177
|
+
finally {
|
|
178
|
+
await client.close().catch(() => { });
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
test_if(capabilities.ws, 'a multi-megabyte burst within the budget arrives whole and in order, the response last', async () => {
|
|
182
|
+
const fixture = await setup_test();
|
|
183
|
+
const client = await open_flooder(fixture);
|
|
184
|
+
try {
|
|
185
|
+
const result = await client.request('large_fits', FLOOD_METHOD, LARGE_FITS, LARGE_BURST_TIMEOUT_MS);
|
|
186
|
+
assert.deepStrictEqual(result, { queued: LARGE_FITS.count });
|
|
187
|
+
assert.deepStrictEqual(flood_indices(client), [0, 1, 2]);
|
|
188
|
+
const frames = client.messages.filter(is_notification(FLOOD_NOTIFICATION_METHOD));
|
|
189
|
+
const payload = 'x'.repeat(LARGE_FITS.size);
|
|
190
|
+
for (const frame of frames) {
|
|
191
|
+
// compared by length first: a mismatch would otherwise print a mebibyte
|
|
192
|
+
assert.strictEqual(frame.params.payload.length, LARGE_FITS.size);
|
|
193
|
+
assert.ok(frame.params.payload === payload, 'the payload arrived intact');
|
|
194
|
+
}
|
|
195
|
+
// nothing follows the response: the whole burst is ahead of it
|
|
196
|
+
const response_at = client.messages.findIndex(is_response_for('large_fits'));
|
|
197
|
+
const last_flood_at = client.messages.lastIndexOf(frames.at(-1));
|
|
198
|
+
assert.ok(last_flood_at < response_at, 'the burst precedes its response');
|
|
199
|
+
assert.strictEqual(client.close_code, null, 'a burst within the budget closes nothing');
|
|
200
|
+
await assert_open_and_answering(client, 'after_large_fits', 'the socket stays open');
|
|
201
|
+
}
|
|
202
|
+
finally {
|
|
203
|
+
await client.close().catch(() => { });
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
test_if(capabilities.ws, 'a burst past the budget closes the socket with WS_CLOSE_OUTBOUND_OVERFLOW and no response', async () => {
|
|
207
|
+
const fixture = await setup_test();
|
|
208
|
+
// a second connection on the same account (the keeper's session)
|
|
209
|
+
const bystander = await create_admitted_ws_transport({
|
|
210
|
+
base_url,
|
|
211
|
+
ws_path,
|
|
212
|
+
cookies: fixture.transport.cookies(),
|
|
213
|
+
origin
|
|
214
|
+
});
|
|
215
|
+
const client = await open_flooder(fixture);
|
|
216
|
+
try {
|
|
217
|
+
await client.send(create_jsonrpc_request(FLOOD_METHOD, OVERFLOWS, 'overflows'));
|
|
218
|
+
const closed = await client.wait_for_close(CLOSE_TIMEOUT_MS);
|
|
219
|
+
assert.ok(closed, 'the flooded socket did not close');
|
|
220
|
+
assert.strictEqual(client.close_code, WS_CLOSE_OUTBOUND_OVERFLOW);
|
|
221
|
+
assert.strictEqual(client.close_reason, OUTBOUND_OVERFLOW_REASON);
|
|
222
|
+
// the response of the request whose handler overflowed is not sent
|
|
223
|
+
assert.strictEqual(client.messages.some(is_response_for('overflows')), false, 'no response may follow an overflow');
|
|
224
|
+
// frames accepted before the overflow may or may not arrive; any
|
|
225
|
+
// that do are a contiguous prefix of the burst, and never all of it
|
|
226
|
+
const indices = flood_indices(client);
|
|
227
|
+
assert.ok(indices.length < OVERFLOWS.count, `${indices.length} flood frames arrived`);
|
|
228
|
+
assert.deepStrictEqual(indices, indices.map((_, i) => i), 'the flood frames that arrived are a contiguous prefix from index 0');
|
|
229
|
+
// the account's other connection kept working through it
|
|
230
|
+
await assert_open_and_answering(bystander, 'bystander', "another connection on the account is untouched by the first one's overflow");
|
|
231
|
+
// not a revocation: the daemon token still upgrades
|
|
232
|
+
const again = await open_flooder(fixture);
|
|
233
|
+
await again.close();
|
|
234
|
+
}
|
|
235
|
+
finally {
|
|
236
|
+
await Promise.all([client.close().catch(() => { }), bystander.close().catch(() => { })]);
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
test_if(capabilities.ws, 'a burst past the flood action size limit is refused, and the socket stays open', async () => {
|
|
240
|
+
const fixture = await setup_test();
|
|
241
|
+
const client = await open_flooder(fixture);
|
|
242
|
+
try {
|
|
243
|
+
await client.send(create_jsonrpc_request(FLOOD_METHOD, REFUSED, 'refused'));
|
|
244
|
+
const frame = await client.wait_for(is_response_for('refused'));
|
|
245
|
+
assert.ok(frame.error, 'expected invalid_params but the call succeeded');
|
|
246
|
+
assert.strictEqual(frame.error.code, INVALID_PARAMS);
|
|
247
|
+
assert.deepStrictEqual(flood_indices(client), [], 'a refused burst queues nothing');
|
|
248
|
+
await assert_open_and_answering(client, 'after_refused', 'the socket stays open');
|
|
249
|
+
}
|
|
250
|
+
finally {
|
|
251
|
+
await client.close().catch(() => { });
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
test_if(rpc_path !== undefined, 'over HTTP the flood action refuses with invalid_params, reason no_connection', async () => {
|
|
255
|
+
const fixture = await setup_test();
|
|
256
|
+
const res = await cross_rpc_call(fixture.fresh_transport({ origin: null }), rpc_path, FLOOD_METHOD, FITS, fixture.create_daemon_token_headers());
|
|
257
|
+
assert.strictEqual(res.ok, false, 'expected a refusal over HTTP');
|
|
258
|
+
assert.strictEqual(res.error?.code, INVALID_PARAMS);
|
|
259
|
+
assert.strictEqual(res.error?.data?.reason, REASON_NO_CONNECTION);
|
|
260
|
+
});
|
|
261
|
+
test_if(rpc_path !== undefined, 'over HTTP a flood call at each limit is refused for no connection, one past it for its params', async () => {
|
|
262
|
+
const fixture = await setup_test();
|
|
263
|
+
const transport = fixture.fresh_transport({ origin: null });
|
|
264
|
+
// the hand-factored case is only "one byte past" for this limit
|
|
265
|
+
assert.strictEqual(FLOOD_ONE_BYTE_PAST_TOTAL.count * FLOOD_ONE_BYTE_PAST_TOTAL.size, FLOOD_MAX_TOTAL_BYTES + 1);
|
|
266
|
+
for (const { name, params, within } of FLOOD_LIMIT_CASES) {
|
|
267
|
+
const res = await cross_rpc_call(transport, rpc_path, FLOOD_METHOD, params, fixture.create_daemon_token_headers());
|
|
268
|
+
assert.strictEqual(res.ok, false, `${name}: expected a refusal over HTTP`);
|
|
269
|
+
assert.strictEqual(res.error?.code, INVALID_PARAMS, name);
|
|
270
|
+
// within the limits the call gets as far as the connection check;
|
|
271
|
+
// past one it is refused before that, with no reason
|
|
272
|
+
assert.strictEqual(res.error?.data?.reason, within ? REASON_NO_CONNECTION : undefined, `${name}: ${JSON.stringify(params)}`);
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
};
|
|
@@ -4,7 +4,7 @@ import './assert_dev_env.js';
|
|
|
4
4
|
*
|
|
5
5
|
* `describe_standard_integration_tests` creates a composable test suite that
|
|
6
6
|
* exercises the full middleware stack (origin, session, bearer_auth, request_context)
|
|
7
|
-
* against a real
|
|
7
|
+
* against a real database. Consumers call it with their route factory and
|
|
8
8
|
* session config — all auth route tests come for free.
|
|
9
9
|
*
|
|
10
10
|
* Tests use `stub_password_deps` (deterministic hashing, no Argon2 overhead).
|
|
@@ -14,10 +14,22 @@ import './assert_dev_env.js';
|
|
|
14
14
|
* so a label set / ordering drift (e.g. `cell_visibility`) is a gated fact,
|
|
15
15
|
* not invisible
|
|
16
16
|
*
|
|
17
|
-
* Designed for `pg_catalog` introspection —
|
|
18
|
-
*
|
|
17
|
+
* Designed for `pg_catalog` introspection — it runs on PostgreSQL, PGlite and
|
|
18
|
+
* pglet. The snapshot is fully deterministic: every collection sorts by
|
|
19
19
|
* a stable key and excludes time-varying fields like `applied_at`.
|
|
20
20
|
*
|
|
21
|
+
* Index and constraint definitions are compared as the engine renders them,
|
|
22
|
+
* so both sides of a parity comparison need engines that render alike. pglet
|
|
23
|
+
* leaves an index's modifiers out of `pg_indexes.indexdef` — per-key sort
|
|
24
|
+
* direction, `NULLS` placement, operator classes, `NULLS NOT DISTINCT` — and
|
|
25
|
+
* that misleads a comparison in both directions: a pglet snapshot diffed
|
|
26
|
+
* against a PostgreSQL one reports every index that uses a modifier, with
|
|
27
|
+
* identical DDL on both sides (a false difference), and two pglet snapshots
|
|
28
|
+
* agree when the two schemas differ only in a modifier (a false agreement).
|
|
29
|
+
* So `query_schema_snapshot` throws on pglet when the snapshot holds an
|
|
30
|
+
* index, unless the caller passes `accept_pglet_index_definitions`. Take both
|
|
31
|
+
* snapshots of a parity comparison on PostgreSQL.
|
|
32
|
+
*
|
|
21
33
|
* Paired with `testing/schema_parity.ts` for comparison + assertion helpers.
|
|
22
34
|
*
|
|
23
35
|
* @module
|
|
@@ -162,6 +174,17 @@ export interface QuerySchemaSnapshotOptions {
|
|
|
162
174
|
* consumer's domain schema.
|
|
163
175
|
*/
|
|
164
176
|
readonly exclude_tables?: ReadonlyArray<string>;
|
|
177
|
+
/**
|
|
178
|
+
* Take the snapshot on pglet even though it holds indexes. Default `false`:
|
|
179
|
+
* pglet's `pg_indexes.indexdef` omits index modifiers (sort direction,
|
|
180
|
+
* `NULLS` placement, operator classes, `NULLS NOT DISTINCT`), so the
|
|
181
|
+
* snapshot's index definitions cannot be compared with another engine's,
|
|
182
|
+
* and do not tell two pglet schemas apart when they differ only in a
|
|
183
|
+
* modifier. Pass `true` only where no index definition is compared —
|
|
184
|
+
* reading tables, columns, constraints, sequences or enums off the
|
|
185
|
+
* snapshot. No effect on PostgreSQL or PGlite.
|
|
186
|
+
*/
|
|
187
|
+
readonly accept_pglet_index_definitions?: boolean;
|
|
165
188
|
}
|
|
166
189
|
/**
|
|
167
190
|
* Introspect a live database into a deterministic `SchemaSnapshot`.
|
|
@@ -172,6 +195,11 @@ export interface QuerySchemaSnapshotOptions {
|
|
|
172
195
|
* The `schema_version` migration tracker never appears in the `tables`
|
|
173
196
|
* field — it's framework bookkeeping created by the migration runner,
|
|
174
197
|
* identical across consumers, and would only add noise.
|
|
198
|
+
*
|
|
199
|
+
* @param db - the database to introspect
|
|
200
|
+
* @param options - schema, table excludes, and the pglet opt-in
|
|
201
|
+
* @returns the snapshot, every collection sorted
|
|
202
|
+
* @throws Error on pglet when the snapshot holds an index and `accept_pglet_index_definitions` is not set — its index definitions omit modifiers, so a comparison built on them is wrong in both directions
|
|
175
203
|
*/
|
|
176
204
|
export declare const query_schema_snapshot: (db: Db, options?: QuerySchemaSnapshotOptions) => Promise<SchemaSnapshot>;
|
|
177
205
|
//# sourceMappingURL=schema_introspect.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"schema_introspect.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/schema_introspect.ts"],"names":[],"mappings":"AAAA,OAAO,qBAAqB,CAAC;AAE7B
|
|
1
|
+
{"version":3,"file":"schema_introspect.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/schema_introspect.ts"],"names":[],"mappings":"AAAA,OAAO,qBAAqB,CAAC;AAE7B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,OAAO,KAAK,EAAE,EAAE,EAAE,MAAM,aAAa,CAAC;AAEtC;;;;;GAKG;AACH,eAAO,MAAM,cAAc;;;;;;iBAWzB,CAAC;AACH,MAAM,MAAM,cAAc,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,cAAc,CAAC,CAAC;AAE5D,qCAAqC;AACrC,eAAO,MAAM,aAAa;;;;;;;;;;;;;;;;;iBAOxB,CAAC;AACH,MAAM,MAAM,aAAa,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,aAAa,CAAC,CAAC;AAE1D,qFAAqF;AACrF,eAAO,MAAM,gBAAgB;;iBAE3B,CAAC;AACH,MAAM,MAAM,gBAAgB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,gBAAgB,CAAC,CAAC;AAEhE;;;;;GAKG;AACH,eAAO,MAAM,gBAAgB;;iBAG3B,CAAC;AACH,MAAM,MAAM,gBAAgB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,gBAAgB,CAAC,CAAC;AAEhE;;;;;;;;GAQG;AACH,eAAO,MAAM,cAAc;;;;;;;;;;;;;;;;;;;;;;;;;iBAOzB,CAAC;AACH,MAAM,MAAM,cAAc,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,cAAc,CAAC,CAAC;AAE5D;;;;;;;;;;GAUG;AACH,eAAO,MAAM,qBAAqB;;;;iBAOhC,CAAC;AACH,MAAM,MAAM,qBAAqB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,qBAAqB,CAAC,CAAC;AAE1E;;;;GAIG;AACH,eAAO,MAAM,gBAAgB;;;;;;iBAE3B,CAAC;AACH,MAAM,MAAM,gBAAgB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,gBAAgB,CAAC,CAAC;AAEhE;;;;;;;GAOG;AACH,eAAO,MAAM,uBAAuB,GAAU,IAAI,EAAE,KAAG,OAAO,CAAC,gBAAgB,CAa9E,CAAC;AAEF,kDAAkD;AAClD,MAAM,WAAW,0BAA0B;IAC1C;;;OAGG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB;;;;;OAKG;IACH,QAAQ,CAAC,cAAc,CAAC,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IAChD;;;;;;;;;OASG;IACH,QAAQ,CAAC,8BAA8B,CAAC,EAAE,OAAO,CAAC;CAClD;AAiED;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,qBAAqB,GACjC,IAAI,EAAE,EACN,UAAS,0BAA+B,KACtC,OAAO,CAAC,cAAc,CA0IxB,CAAC"}
|
|
@@ -14,10 +14,22 @@ import './assert_dev_env.js';
|
|
|
14
14
|
* so a label set / ordering drift (e.g. `cell_visibility`) is a gated fact,
|
|
15
15
|
* not invisible
|
|
16
16
|
*
|
|
17
|
-
* Designed for `pg_catalog` introspection —
|
|
18
|
-
*
|
|
17
|
+
* Designed for `pg_catalog` introspection — it runs on PostgreSQL, PGlite and
|
|
18
|
+
* pglet. The snapshot is fully deterministic: every collection sorts by
|
|
19
19
|
* a stable key and excludes time-varying fields like `applied_at`.
|
|
20
20
|
*
|
|
21
|
+
* Index and constraint definitions are compared as the engine renders them,
|
|
22
|
+
* so both sides of a parity comparison need engines that render alike. pglet
|
|
23
|
+
* leaves an index's modifiers out of `pg_indexes.indexdef` — per-key sort
|
|
24
|
+
* direction, `NULLS` placement, operator classes, `NULLS NOT DISTINCT` — and
|
|
25
|
+
* that misleads a comparison in both directions: a pglet snapshot diffed
|
|
26
|
+
* against a PostgreSQL one reports every index that uses a modifier, with
|
|
27
|
+
* identical DDL on both sides (a false difference), and two pglet snapshots
|
|
28
|
+
* agree when the two schemas differ only in a modifier (a false agreement).
|
|
29
|
+
* So `query_schema_snapshot` throws on pglet when the snapshot holds an
|
|
30
|
+
* index, unless the caller passes `accept_pglet_index_definitions`. Take both
|
|
31
|
+
* snapshots of a parity comparison on PostgreSQL.
|
|
32
|
+
*
|
|
21
33
|
* Paired with `testing/schema_parity.ts` for comparison + assertion helpers.
|
|
22
34
|
*
|
|
23
35
|
* @module
|
|
@@ -128,6 +140,8 @@ export const query_migration_tracker = async (db) => {
|
|
|
128
140
|
}))
|
|
129
141
|
};
|
|
130
142
|
};
|
|
143
|
+
/** The marker pglet's `version()` carries. */
|
|
144
|
+
const PGLET_VERSION_MARKER = '(pglet)';
|
|
131
145
|
const sort_keys = (record) => {
|
|
132
146
|
const sorted = {};
|
|
133
147
|
for (const key of Object.keys(record).sort()) {
|
|
@@ -162,6 +176,11 @@ const contype_to_kind = (contype) => {
|
|
|
162
176
|
* The `schema_version` migration tracker never appears in the `tables`
|
|
163
177
|
* field — it's framework bookkeeping created by the migration runner,
|
|
164
178
|
* identical across consumers, and would only add noise.
|
|
179
|
+
*
|
|
180
|
+
* @param db - the database to introspect
|
|
181
|
+
* @param options - schema, table excludes, and the pglet opt-in
|
|
182
|
+
* @returns the snapshot, every collection sorted
|
|
183
|
+
* @throws Error on pglet when the snapshot holds an index and `accept_pglet_index_definitions` is not set — its index definitions omit modifiers, so a comparison built on them is wrong in both directions
|
|
165
184
|
*/
|
|
166
185
|
export const query_schema_snapshot = async (db, options = {}) => {
|
|
167
186
|
const schema = options.schema ?? 'public';
|
|
@@ -187,11 +206,11 @@ export const query_schema_snapshot = async (db, options = {}) => {
|
|
|
187
206
|
WHERE schemaname = $1
|
|
188
207
|
ORDER BY tablename ASC, indexname ASC`, [schema]);
|
|
189
208
|
// Constraints — pg_get_constraintdef produces a canonical text rendering.
|
|
190
|
-
// Skip NOT NULL constraints (`contype = 'n'`):
|
|
191
|
-
//
|
|
192
|
-
//
|
|
193
|
-
//
|
|
194
|
-
//
|
|
209
|
+
// Skip NOT NULL constraints (`contype = 'n'`): a named `pg_constraint` row
|
|
210
|
+
// per NOT NULL column is something an engine may or may not catalog
|
|
211
|
+
// (PostgreSQL 18, PGlite 0.5 and pglet 0.1 each do), and nullability is
|
|
212
|
+
// already captured per-column by `is_nullable` — including the rows would
|
|
213
|
+
// report an engine-cataloging difference as drift between two backends.
|
|
195
214
|
const constraint_rows = await db.query(`SELECT c.conrelid::regclass::text AS table_name,
|
|
196
215
|
c.conname,
|
|
197
216
|
c.contype::text,
|
|
@@ -252,6 +271,18 @@ export const query_schema_snapshot = async (db, options = {}) => {
|
|
|
252
271
|
for (const row of enum_rows) {
|
|
253
272
|
(enums[row.enum_name] ??= { labels: [] }).labels.push(row.label);
|
|
254
273
|
}
|
|
274
|
+
if (!options.accept_pglet_index_definitions &&
|
|
275
|
+
Object.values(tables).some((table) => table.indexes.length > 0)) {
|
|
276
|
+
const [version_row] = await db.query(`SELECT version() AS version`);
|
|
277
|
+
if (version_row?.version.includes(PGLET_VERSION_MARKER)) {
|
|
278
|
+
throw new Error('query_schema_snapshot: this database is pglet, whose pg_indexes.indexdef omits index ' +
|
|
279
|
+
'modifiers (sort direction, NULLS placement, operator classes, NULLS NOT DISTINCT), so ' +
|
|
280
|
+
"the snapshot's index definitions report false differences against PostgreSQL and false " +
|
|
281
|
+
'agreement between two pglet schemas. Take the snapshot on PostgreSQL (a TS backend ' +
|
|
282
|
+
'config defaults to memory://, which is pglet), or pass ' +
|
|
283
|
+
'{accept_pglet_index_definitions: true} when no index definition is compared.');
|
|
284
|
+
}
|
|
285
|
+
}
|
|
255
286
|
return {
|
|
256
287
|
tables: sort_keys(tables),
|
|
257
288
|
sequences: sort_keys(sequences),
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import '../assert_dev_env.js';
|
|
2
|
+
/** What a raw exchange read back, and how it ended. */
|
|
3
|
+
export interface RawHttpExchange {
|
|
4
|
+
/** Every byte the server sent, `latin1`-decoded. */
|
|
5
|
+
readonly received: string;
|
|
6
|
+
/**
|
|
7
|
+
* `true` when the connection ended before the exchange stopped reading —
|
|
8
|
+
* the server closed it, or it never opened (a refused or unresolvable
|
|
9
|
+
* address reads as closed, with nothing received).
|
|
10
|
+
*/
|
|
11
|
+
readonly closed_by_server: boolean;
|
|
12
|
+
}
|
|
13
|
+
/** Options for `raw_http_exchange`. */
|
|
14
|
+
export interface RawHttpExchangeOptions {
|
|
15
|
+
/** How long to read before giving up on the server. */
|
|
16
|
+
readonly read_timeout_ms: number;
|
|
17
|
+
/**
|
|
18
|
+
* When the exchange stops reading. `'close'` reads until the server closes
|
|
19
|
+
* the connection; `'response'` stops as soon as one whole response has
|
|
20
|
+
* arrived (`raw_http_parse_response`), for a server that answers and then
|
|
21
|
+
* keeps the connection open. Either way the read timeout bounds it.
|
|
22
|
+
*
|
|
23
|
+
* `'response'` stops at the first response the bytes frame, so it is for a
|
|
24
|
+
* final response carrying `Content-Length` or chunked framing: a `1xx`
|
|
25
|
+
* interim response ends the read, and so does the head of a response
|
|
26
|
+
* delimited by the connection closing, before its body.
|
|
27
|
+
*
|
|
28
|
+
* @default 'close'
|
|
29
|
+
*/
|
|
30
|
+
readonly until?: 'close' | 'response';
|
|
31
|
+
}
|
|
32
|
+
/** One parsed HTTP/1.1 response. */
|
|
33
|
+
export interface RawHttpResponse {
|
|
34
|
+
readonly status: number;
|
|
35
|
+
/** Response headers, names lowercased; a repeated name keeps its last value. */
|
|
36
|
+
readonly headers: Readonly<Record<string, string>>;
|
|
37
|
+
/** The response body, with any chunked framing removed. */
|
|
38
|
+
readonly body: string;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Open a raw TCP socket to `base_url`, write `request_bytes` once, and collect
|
|
42
|
+
* what the server sends back until it closes the connection, a whole response
|
|
43
|
+
* has arrived (`until: 'response'`), or `read_timeout_ms` elapses.
|
|
44
|
+
*
|
|
45
|
+
* Write errors are swallowed: a server that refuses a request and closes
|
|
46
|
+
* mid-upload surfaces as `EPIPE` / `ECONNRESET` on the unfinished write, and
|
|
47
|
+
* what a caller asserts on is what was read back. A write error also ends the
|
|
48
|
+
* socket, so bytes the server sent just before it can go unread — a caller
|
|
49
|
+
* that needs to read a refusal reliably sends nothing the server will leave
|
|
50
|
+
* unread.
|
|
51
|
+
*
|
|
52
|
+
* @param base_url - the server's base URL; only its host and port are used
|
|
53
|
+
* @param request_bytes - the bytes to send, `latin1`-encoded
|
|
54
|
+
*/
|
|
55
|
+
export declare const raw_http_exchange: (base_url: string, request_bytes: string, options: RawHttpExchangeOptions) => Promise<RawHttpExchange>;
|
|
56
|
+
/**
|
|
57
|
+
* Count HTTP response status lines in a raw byte stream. Deliberately
|
|
58
|
+
* **unanchored** (no `^`/`m`): a second pipelined response is concatenated
|
|
59
|
+
* straight after the first response's body, which carries no trailing newline,
|
|
60
|
+
* so a line-anchored match would miss it — and missing a smuggled second
|
|
61
|
+
* response is a silent false negative. It counts exactly the status lines as
|
|
62
|
+
* long as no response header or body contains the literal `HTTP/1.x NNN`.
|
|
63
|
+
*/
|
|
64
|
+
export declare const raw_http_count_responses: (raw: string) => number;
|
|
65
|
+
/** The status code of the first HTTP response in a raw byte stream, or `null` if none. */
|
|
66
|
+
export declare const raw_http_first_status: (raw: string) => number | null;
|
|
67
|
+
/**
|
|
68
|
+
* Parse the first response in a raw byte stream, once all of it has arrived.
|
|
69
|
+
*
|
|
70
|
+
* The body is delimited by `Transfer-Encoding: chunked` or `Content-Length`.
|
|
71
|
+
* A response carrying neither is delimited by the connection closing, which
|
|
72
|
+
* the bytes alone don't show, so its body is whatever followed the head.
|
|
73
|
+
*
|
|
74
|
+
* @param raw - the bytes read so far, `latin1`-decoded
|
|
75
|
+
* @returns the response, or `null` when the head or the framed body is incomplete
|
|
76
|
+
*/
|
|
77
|
+
export declare const raw_http_parse_response: (raw: string) => RawHttpResponse | null;
|
|
78
|
+
//# sourceMappingURL=raw_http.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"raw_http.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/transports/raw_http.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAuB9B,uDAAuD;AACvD,MAAM,WAAW,eAAe;IAC/B,oDAAoD;IACpD,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B;;;;OAIG;IACH,QAAQ,CAAC,gBAAgB,EAAE,OAAO,CAAC;CACnC;AAED,uCAAuC;AACvC,MAAM,WAAW,sBAAsB;IACtC,uDAAuD;IACvD,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAC;IACjC;;;;;;;;;;;;OAYG;IACH,QAAQ,CAAC,KAAK,CAAC,EAAE,OAAO,GAAG,UAAU,CAAC;CACtC;AAED,oCAAoC;AACpC,MAAM,WAAW,eAAe;IAC/B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,gFAAgF;IAChF,QAAQ,CAAC,OAAO,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IACnD,2DAA2D;IAC3D,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;CACtB;AAED;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,iBAAiB,GAC7B,UAAU,MAAM,EAChB,eAAe,MAAM,EACrB,SAAS,sBAAsB,KAC7B,OAAO,CAAC,eAAe,CAwBvB,CAAC;AAEJ;;;;;;;GAOG;AACH,eAAO,MAAM,wBAAwB,GAAI,KAAK,MAAM,KAAG,MACN,CAAC;AAElD,0FAA0F;AAC1F,eAAO,MAAM,qBAAqB,GAAI,KAAK,MAAM,KAAG,MAAM,GAAG,IAG5D,CAAC;AAyBF;;;;;;;;;GASG;AACH,eAAO,MAAM,uBAAuB,GAAI,KAAK,MAAM,KAAG,eAAe,GAAG,IAyBvE,CAAC"}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import '../assert_dev_env.js';
|
|
2
|
+
/**
|
|
3
|
+
* Raw-socket HTTP/1.1 exchanges, for tests that need to control exactly which
|
|
4
|
+
* bytes reach the server.
|
|
5
|
+
*
|
|
6
|
+
* `fetch` frames a request itself and sends all of it, so some server
|
|
7
|
+
* behaviors can't be reached through it: what a server does with two requests
|
|
8
|
+
* in one write, with duplicate or malformed headers, with a request head whose
|
|
9
|
+
* body never follows, or with a chunked body that is never terminated. This
|
|
10
|
+
* writes caller-built bytes over `node:net` and hands back what the server
|
|
11
|
+
* sent, plus just enough parsing to read a status or one whole response.
|
|
12
|
+
*
|
|
13
|
+
* Everything is `latin1`-decoded, so a string's length is its byte length.
|
|
14
|
+
*
|
|
15
|
+
* Plain TCP only: an `https:` URL is not spoken to over TLS, and the host must
|
|
16
|
+
* be a name or an IPv4 address — a bracketed IPv6 literal does not connect.
|
|
17
|
+
*
|
|
18
|
+
* @module
|
|
19
|
+
*/
|
|
20
|
+
import { connect } from 'node:net';
|
|
21
|
+
/**
|
|
22
|
+
* Open a raw TCP socket to `base_url`, write `request_bytes` once, and collect
|
|
23
|
+
* what the server sends back until it closes the connection, a whole response
|
|
24
|
+
* has arrived (`until: 'response'`), or `read_timeout_ms` elapses.
|
|
25
|
+
*
|
|
26
|
+
* Write errors are swallowed: a server that refuses a request and closes
|
|
27
|
+
* mid-upload surfaces as `EPIPE` / `ECONNRESET` on the unfinished write, and
|
|
28
|
+
* what a caller asserts on is what was read back. A write error also ends the
|
|
29
|
+
* socket, so bytes the server sent just before it can go unread — a caller
|
|
30
|
+
* that needs to read a refusal reliably sends nothing the server will leave
|
|
31
|
+
* unread.
|
|
32
|
+
*
|
|
33
|
+
* @param base_url - the server's base URL; only its host and port are used
|
|
34
|
+
* @param request_bytes - the bytes to send, `latin1`-encoded
|
|
35
|
+
*/
|
|
36
|
+
export const raw_http_exchange = (base_url, request_bytes, options) => new Promise((resolve) => {
|
|
37
|
+
const { read_timeout_ms, until = 'close' } = options;
|
|
38
|
+
const url = new URL(base_url);
|
|
39
|
+
const port = Number(url.port) || (url.protocol === 'https:' ? 443 : 80);
|
|
40
|
+
const socket = connect({ host: url.hostname, port });
|
|
41
|
+
let received = '';
|
|
42
|
+
let settled = false;
|
|
43
|
+
const finish = (closed_by_server) => {
|
|
44
|
+
if (settled)
|
|
45
|
+
return;
|
|
46
|
+
settled = true;
|
|
47
|
+
clearTimeout(timer);
|
|
48
|
+
socket.destroy();
|
|
49
|
+
resolve({ received, closed_by_server });
|
|
50
|
+
};
|
|
51
|
+
const timer = setTimeout(() => finish(false), read_timeout_ms);
|
|
52
|
+
socket.setEncoding('latin1');
|
|
53
|
+
socket.on('connect', () => socket.write(request_bytes, 'latin1'));
|
|
54
|
+
socket.on('data', (chunk) => {
|
|
55
|
+
received += chunk;
|
|
56
|
+
if (until === 'response' && raw_http_parse_response(received))
|
|
57
|
+
finish(false);
|
|
58
|
+
});
|
|
59
|
+
socket.on('error', () => { }); // EPIPE/ECONNRESET on a mid-write close is expected
|
|
60
|
+
socket.on('close', () => finish(true));
|
|
61
|
+
});
|
|
62
|
+
/**
|
|
63
|
+
* Count HTTP response status lines in a raw byte stream. Deliberately
|
|
64
|
+
* **unanchored** (no `^`/`m`): a second pipelined response is concatenated
|
|
65
|
+
* straight after the first response's body, which carries no trailing newline,
|
|
66
|
+
* so a line-anchored match would miss it — and missing a smuggled second
|
|
67
|
+
* response is a silent false negative. It counts exactly the status lines as
|
|
68
|
+
* long as no response header or body contains the literal `HTTP/1.x NNN`.
|
|
69
|
+
*/
|
|
70
|
+
export const raw_http_count_responses = (raw) => (raw.match(/HTTP\/1\.[01] \d{3}/g) ?? []).length;
|
|
71
|
+
/** The status code of the first HTTP response in a raw byte stream, or `null` if none. */
|
|
72
|
+
export const raw_http_first_status = (raw) => {
|
|
73
|
+
const match = /HTTP\/1\.[01] (\d{3})/.exec(raw);
|
|
74
|
+
return match ? Number(match[1]) : null;
|
|
75
|
+
};
|
|
76
|
+
/**
|
|
77
|
+
* Remove chunked framing from a body.
|
|
78
|
+
*
|
|
79
|
+
* @returns the decoded body, or `null` when the terminating chunk hasn't arrived
|
|
80
|
+
*/
|
|
81
|
+
const decode_chunked = (raw) => {
|
|
82
|
+
let body = '';
|
|
83
|
+
let offset = 0;
|
|
84
|
+
for (;;) {
|
|
85
|
+
const line_end = raw.indexOf('\r\n', offset);
|
|
86
|
+
if (line_end === -1)
|
|
87
|
+
return null;
|
|
88
|
+
// a chunk-size line may carry `;extension`s after the hex size
|
|
89
|
+
const size = Number.parseInt(raw.slice(offset, line_end), 16);
|
|
90
|
+
if (!Number.isFinite(size))
|
|
91
|
+
return null;
|
|
92
|
+
const data_start = line_end + 2;
|
|
93
|
+
// the last chunk is followed by optional trailers and an empty line
|
|
94
|
+
if (size === 0)
|
|
95
|
+
return raw.includes('\r\n\r\n', line_end) ? body : null;
|
|
96
|
+
if (raw.length < data_start + size + 2)
|
|
97
|
+
return null;
|
|
98
|
+
body += raw.slice(data_start, data_start + size);
|
|
99
|
+
offset = data_start + size + 2;
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
/**
|
|
103
|
+
* Parse the first response in a raw byte stream, once all of it has arrived.
|
|
104
|
+
*
|
|
105
|
+
* The body is delimited by `Transfer-Encoding: chunked` or `Content-Length`.
|
|
106
|
+
* A response carrying neither is delimited by the connection closing, which
|
|
107
|
+
* the bytes alone don't show, so its body is whatever followed the head.
|
|
108
|
+
*
|
|
109
|
+
* @param raw - the bytes read so far, `latin1`-decoded
|
|
110
|
+
* @returns the response, or `null` when the head or the framed body is incomplete
|
|
111
|
+
*/
|
|
112
|
+
export const raw_http_parse_response = (raw) => {
|
|
113
|
+
const head_end = raw.indexOf('\r\n\r\n');
|
|
114
|
+
if (head_end === -1)
|
|
115
|
+
return null;
|
|
116
|
+
const [status_line, ...header_lines] = raw.slice(0, head_end).split('\r\n');
|
|
117
|
+
const status_match = /^HTTP\/1\.[01] (\d{3})/.exec(status_line);
|
|
118
|
+
if (!status_match)
|
|
119
|
+
return null;
|
|
120
|
+
const headers = {};
|
|
121
|
+
for (const line of header_lines) {
|
|
122
|
+
const colon = line.indexOf(':');
|
|
123
|
+
if (colon <= 0)
|
|
124
|
+
continue;
|
|
125
|
+
headers[line.slice(0, colon).trim().toLowerCase()] = line.slice(colon + 1).trim();
|
|
126
|
+
}
|
|
127
|
+
const status = Number(status_match[1]);
|
|
128
|
+
const rest = raw.slice(head_end + 4);
|
|
129
|
+
if (headers['transfer-encoding']?.toLowerCase().includes('chunked')) {
|
|
130
|
+
const body = decode_chunked(rest);
|
|
131
|
+
return body === null ? null : { status, headers, body };
|
|
132
|
+
}
|
|
133
|
+
const content_length = headers['content-length'];
|
|
134
|
+
if (content_length !== undefined) {
|
|
135
|
+
const length = Number(content_length);
|
|
136
|
+
if (!Number.isSafeInteger(length) || length < 0 || rest.length < length)
|
|
137
|
+
return null;
|
|
138
|
+
return { status, headers, body: rest.slice(0, length) };
|
|
139
|
+
}
|
|
140
|
+
return { status, headers, body: rest };
|
|
141
|
+
};
|