@fuzdev/fuz_app 0.125.0 → 0.126.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 +57 -20
- package/dist/actions/action_rpc.d.ts +14 -11
- package/dist/actions/action_rpc.d.ts.map +1 -1
- package/dist/actions/action_rpc.js +5 -4
- package/dist/actions/connection_closer.d.ts +13 -3
- package/dist/actions/connection_closer.d.ts.map +1 -1
- package/dist/actions/connection_closer.js +7 -5
- package/dist/actions/register_action_ws.d.ts +18 -13
- package/dist/actions/register_action_ws.d.ts.map +1 -1
- package/dist/actions/register_action_ws.js +8 -6
- package/dist/actions/register_ws_endpoint.d.ts +6 -3
- package/dist/actions/register_ws_endpoint.d.ts.map +1 -1
- package/dist/actions/register_ws_endpoint.js +6 -3
- package/dist/actions/transports.d.ts +9 -0
- package/dist/actions/transports.d.ts.map +1 -1
- package/dist/actions/transports.js +9 -0
- package/dist/actions/transports_ws_backend.d.ts +15 -0
- package/dist/actions/transports_ws_backend.d.ts.map +1 -1
- package/dist/actions/transports_ws_backend.js +25 -5
- package/dist/actions/ws_endpoint_spec.d.ts +24 -4
- package/dist/actions/ws_endpoint_spec.d.ts.map +1 -1
- package/dist/actions/ws_endpoint_spec.js +15 -1
- package/dist/auth/middleware.d.ts +29 -1
- package/dist/auth/middleware.d.ts.map +1 -1
- package/dist/auth/middleware.js +35 -1
- package/dist/http/surface.d.ts +3 -1
- package/dist/http/surface.d.ts.map +1 -1
- package/dist/http/surface.js +15 -1
- package/dist/rate_limiter.d.ts +25 -3
- package/dist/rate_limiter.d.ts.map +1 -1
- package/dist/rate_limiter.js +4 -3
- package/dist/realtime/sse_auth_guard.d.ts +2 -1
- package/dist/realtime/sse_auth_guard.d.ts.map +1 -1
- package/dist/realtime/sse_auth_guard.js +4 -2
- package/dist/realtime/subscriber_registry.d.ts +12 -0
- package/dist/realtime/subscriber_registry.d.ts.map +1 -1
- package/dist/realtime/subscriber_registry.js +18 -2
- package/dist/server/app_server.d.ts +127 -55
- package/dist/server/app_server.d.ts.map +1 -1
- package/dist/server/app_server.js +107 -60
- package/dist/server/app_server_context.d.ts +17 -9
- package/dist/server/app_server_context.d.ts.map +1 -1
- package/dist/testing/CLAUDE.md +75 -17
- package/dist/testing/app_server.d.ts +6 -3
- package/dist/testing/app_server.d.ts.map +1 -1
- package/dist/testing/app_server.js +11 -12
- package/dist/testing/connection_closer_helpers.d.ts +10 -4
- package/dist/testing/connection_closer_helpers.d.ts.map +1 -1
- package/dist/testing/connection_closer_helpers.js +4 -0
- package/dist/testing/cross_backend/capabilities.d.ts +3 -3
- package/dist/testing/cross_backend/default_backend_configs.d.ts +31 -0
- package/dist/testing/cross_backend/default_backend_configs.d.ts.map +1 -1
- package/dist/testing/cross_backend/default_backend_configs.js +31 -0
- package/dist/testing/cross_backend/full_spine_mount.d.ts +35 -11
- package/dist/testing/cross_backend/full_spine_mount.d.ts.map +1 -1
- package/dist/testing/cross_backend/full_spine_mount.js +26 -9
- package/dist/testing/cross_backend/rust_spine_stub_backend_config.d.ts +15 -0
- 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 +3 -2
- package/dist/testing/cross_backend/testing_backdoor.d.ts.map +1 -1
- package/dist/testing/cross_backend/testing_backdoor.js +2 -1
- package/dist/testing/cross_backend/testing_reset_actions.d.ts +6 -3
- package/dist/testing/cross_backend/testing_reset_actions.d.ts.map +1 -1
- package/dist/testing/cross_backend/testing_reset_actions.js +6 -3
- package/dist/testing/cross_backend/testing_server_bun.d.ts.map +1 -1
- package/dist/testing/cross_backend/testing_server_bun.js +5 -1
- package/dist/testing/cross_backend/testing_server_core.d.ts +68 -37
- package/dist/testing/cross_backend/testing_server_core.d.ts.map +1 -1
- package/dist/testing/cross_backend/testing_server_core.js +20 -12
- package/dist/testing/cross_backend/testing_server_deno.js +1 -1
- package/dist/testing/cross_backend/testing_server_node.d.ts.map +1 -1
- package/dist/testing/cross_backend/testing_server_node.js +20 -7
- package/dist/testing/cross_backend/ts_spine_backend_config.d.ts +15 -0
- 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 -5
- package/dist/testing/cross_backend/ws_action_rate_limit.d.ts +26 -0
- package/dist/testing/cross_backend/ws_action_rate_limit.d.ts.map +1 -0
- package/dist/testing/cross_backend/ws_action_rate_limit.js +261 -0
- package/dist/testing/stubs.d.ts +14 -4
- package/dist/testing/stubs.d.ts.map +1 -1
- package/dist/testing/stubs.js +31 -13
- package/dist/testing/transports/ws_transport.d.ts +7 -0
- package/dist/testing/transports/ws_transport.d.ts.map +1 -1
- package/dist/testing/transports/ws_transport.js +1 -1
- package/dist/testing/ws_round_trip.d.ts +36 -6
- package/dist/testing/ws_round_trip.d.ts.map +1 -1
- package/dist/testing/ws_round_trip.js +28 -8
- package/package.json +2 -2
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import '../assert_dev_env.js';
|
|
2
|
+
/**
|
|
3
|
+
* Cross-backend parity suite for **action rate limiting over a real
|
|
4
|
+
* WebSocket**, and its budget shared with HTTP RPC.
|
|
5
|
+
*
|
|
6
|
+
* An action spec's `rate_limit` class (`'ip'` / `'account'` / `'both'`) is
|
|
7
|
+
* charged by the dispatcher on both transports, and a server holds one
|
|
8
|
+
* limiter per axis, so an action's budget is per caller, not per transport.
|
|
9
|
+
* The in-process tests pin that through an assembled app; this suite pins it
|
|
10
|
+
* over the wire, against backends spawned with small shared limiters (the
|
|
11
|
+
* action rate-limit toggles — `ACTION_RATE_LIMIT_ENABLED_ENV` and
|
|
12
|
+
* `ACTION_RATE_LIMIT_MAX_ATTEMPTS_ENV` — see `global_setup_login_security.ts`):
|
|
13
|
+
*
|
|
14
|
+
* - **account axis over WS** — `account_token_create` (`rate_limit:
|
|
15
|
+
* 'account'` on both spines) with malformed params: the first `max_attempts`
|
|
16
|
+
* calls each answer `invalid_params` (the throttle runs before params
|
|
17
|
+
* validation, so a malformed call still charges), and the next answers
|
|
18
|
+
* `rate_limited` with a `retry_after`.
|
|
19
|
+
* - **one account bucket across transports** — calls split between HTTP RPC and
|
|
20
|
+
* WS exhaust one budget: the call past the cap is refused on WS and on HTTP
|
|
21
|
+
* (`429`).
|
|
22
|
+
* - **IP axis, keyed by the resolved client IP** — `peer/ping` (`rate_limit:
|
|
23
|
+
* 'ip'`, public) with malformed params, from one `X-Forwarded-For` address
|
|
24
|
+
* over anonymous HTTP and over a WS whose upgrade carried the same header:
|
|
25
|
+
* the call past the cap is refused on both, while a second address keeps its
|
|
26
|
+
* own budget on both.
|
|
27
|
+
* - **account axis at the dispatchers** — `cell_create` (`rate_limit:
|
|
28
|
+
* 'account'`, on both spines' RPC and WS mounts) with malformed params: the
|
|
29
|
+
* call past the cap answers `429` over HTTP and `rate_limited` over WS, calls
|
|
30
|
+
* split between the two transports exhaust one budget, and a budget split
|
|
31
|
+
* between `cell_create` and `account_token_create` is one bucket.
|
|
32
|
+
*
|
|
33
|
+
* **Charge sites.** The TS spine charges every classed action in its
|
|
34
|
+
* dispatcher. The Rust stub has two sites holding the same limiters: the auth
|
|
35
|
+
* families (`account_token_create`) charge inside their handlers, and the
|
|
36
|
+
* dispatcher states charge every other classed spec. So the
|
|
37
|
+
* `account_token_create` cases pin the Rust in-handler site, `peer/ping` pins
|
|
38
|
+
* the Rust WS dispatcher's IP axis, and `cell_create` pins the account axis of
|
|
39
|
+
* both Rust dispatchers, RPC and WS.
|
|
40
|
+
*
|
|
41
|
+
* **Isolation.** Limiter state is in-memory and `_testing_reset` wipes only
|
|
42
|
+
* the database, so every case reads a bucket no other case touched: the
|
|
43
|
+
* account cases each run on the fresh keeper the reset seeds (a new account
|
|
44
|
+
* id), and the IP case uses addresses no other case sends. Login limiters are
|
|
45
|
+
* also on in this project; nothing here logs in.
|
|
46
|
+
*
|
|
47
|
+
* Cross-process only: the composition under test is the spawned binary's
|
|
48
|
+
* (`create_app_server` threading one limiter pair to both mounts on TS; the
|
|
49
|
+
* stub's dispatcher states plus its auth-family builders on Rust).
|
|
50
|
+
*
|
|
51
|
+
* `$lib`-free by contract (relative specifiers only), like the sibling
|
|
52
|
+
* cross-backend suites.
|
|
53
|
+
*
|
|
54
|
+
* @module
|
|
55
|
+
*/
|
|
56
|
+
import { assert, describe, test } from 'vitest';
|
|
57
|
+
import { create_jsonrpc_request } from '../../http/jsonrpc_helpers.js';
|
|
58
|
+
import { create_ws_transport } from '../transports/ws_transport.js';
|
|
59
|
+
import { is_response_for } from '../transports/ws_client.js';
|
|
60
|
+
// Wire literals are deliberate independent copies of the producers'
|
|
61
|
+
// constants (`JSONRPC_ERROR_CODES` on TS, the `JsonrpcError` taxonomy on
|
|
62
|
+
// Rust) — parity by test, so a renamed or renumbered code fails here.
|
|
63
|
+
/** JSON-RPC `invalid_params`. */
|
|
64
|
+
const CODE_INVALID_PARAMS = -32602;
|
|
65
|
+
/** JSON-RPC `rate_limited` — the spines' server-side throttle code. */
|
|
66
|
+
const CODE_RATE_LIMITED = -32006;
|
|
67
|
+
/** An `account`-classed action on both spines (session-only, side-effecting). */
|
|
68
|
+
const ACCOUNT_LIMITED_METHOD = 'account_token_create';
|
|
69
|
+
/** Unknown key — refused by both spines' strict params decode, after the throttle. */
|
|
70
|
+
const ACCOUNT_LIMITED_BAD_PARAMS = { not_a_param: true };
|
|
71
|
+
/**
|
|
72
|
+
* An `account`-classed action the Rust stub charges at its dispatchers (not
|
|
73
|
+
* in-handler, unlike `ACCOUNT_LIMITED_METHOD`), mounted on both spines' RPC
|
|
74
|
+
* and WS. Actor-grain, so the keeper's actor resolves before the throttle; the same
|
|
75
|
+
* unknown-key params are refused by both spines' strict decode, after it.
|
|
76
|
+
*/
|
|
77
|
+
const DISPATCHER_ACCOUNT_LIMITED_METHOD = 'cell_create';
|
|
78
|
+
/** The `ip`-classed public protocol action on both spines. */
|
|
79
|
+
const IP_LIMITED_METHOD = 'peer/ping';
|
|
80
|
+
/** Wrong-typed `nonce` — refused by both spines' params decode, after the throttle. */
|
|
81
|
+
const IP_LIMITED_BAD_PARAMS = { nonce: 'not_a_number' };
|
|
82
|
+
/**
|
|
83
|
+
* RFC 5737 TEST-NET-2 (`198.51.100.0/24`) documentation addresses, spoofed
|
|
84
|
+
* via `X-Forwarded-For` (both spines trust the loopback peer in this project).
|
|
85
|
+
* Distinct from `login_security.ts`'s TEST-NET-3 addresses, though the login
|
|
86
|
+
* and action limiters keep separate buckets anyway.
|
|
87
|
+
*/
|
|
88
|
+
const XFF_IP_EXHAUSTED = '198.51.100.1';
|
|
89
|
+
const XFF_IP_FRESH = '198.51.100.2';
|
|
90
|
+
/** Generous per-response wait — the cases assert the server's answer, not a timeout. */
|
|
91
|
+
const RESPONSE_TIMEOUT_MS = 5000;
|
|
92
|
+
const outcome_from_frame = (status, frame) => ({
|
|
93
|
+
status,
|
|
94
|
+
code: frame?.error?.code ?? null,
|
|
95
|
+
retry_after: frame?.error?.data?.retry_after
|
|
96
|
+
});
|
|
97
|
+
/**
|
|
98
|
+
* Register the action rate-limit suite: the account axis over WS, one account
|
|
99
|
+
* bucket across HTTP RPC and WS, the per-IP axis keyed by `X-Forwarded-For`
|
|
100
|
+
* across both transports, and the account axis at the RPC and WS dispatchers.
|
|
101
|
+
*/
|
|
102
|
+
export const describe_ws_action_rate_limit_cross_tests = (options) => {
|
|
103
|
+
const { setup_test, base_url, ws_path, max_attempts } = options;
|
|
104
|
+
const rpc_path = options.rpc_path ?? '/api/rpc';
|
|
105
|
+
assert(Number.isInteger(max_attempts) && max_attempts >= 2, 'ws_action_rate_limit needs max_attempts >= 2 to split a budget across transports');
|
|
106
|
+
let next_id = 1;
|
|
107
|
+
/** POST one JSON-RPC call on a fresh (cookie-jar-free) transport. */
|
|
108
|
+
const call_http = async (fixture, method, params, headers) => {
|
|
109
|
+
const res = await fixture.fresh_transport()(rpc_path, {
|
|
110
|
+
method: 'POST',
|
|
111
|
+
headers: { 'content-type': 'application/json', ...headers },
|
|
112
|
+
body: JSON.stringify(create_jsonrpc_request(method, params, next_id++))
|
|
113
|
+
});
|
|
114
|
+
const body = (await res.json().catch(() => undefined));
|
|
115
|
+
return outcome_from_frame(res.status, body);
|
|
116
|
+
};
|
|
117
|
+
/** Send one JSON-RPC call over an open socket and read its response. */
|
|
118
|
+
const call_ws = async (ws, method, params) => {
|
|
119
|
+
const id = next_id++;
|
|
120
|
+
await ws.send(create_jsonrpc_request(method, params, id));
|
|
121
|
+
const frame = await ws.wait_for(is_response_for(id), RESPONSE_TIMEOUT_MS);
|
|
122
|
+
return outcome_from_frame(null, frame);
|
|
123
|
+
};
|
|
124
|
+
/** Open a socket on the keeper's session, optionally behind a forwarded IP. */
|
|
125
|
+
const open_ws = (fixture, forwarded_for) => {
|
|
126
|
+
const cookie = fixture.create_session_headers().cookie;
|
|
127
|
+
assert.ok(cookie, 'expected a session cookie for the WS upgrade');
|
|
128
|
+
return create_ws_transport({
|
|
129
|
+
base_url,
|
|
130
|
+
ws_path,
|
|
131
|
+
cookies: [cookie],
|
|
132
|
+
origin: base_url,
|
|
133
|
+
headers: forwarded_for ? { 'x-forwarded-for': forwarded_for } : undefined
|
|
134
|
+
});
|
|
135
|
+
};
|
|
136
|
+
const assert_charged_not_limited = (outcome, label) => {
|
|
137
|
+
assert.strictEqual(outcome.code, CODE_INVALID_PARAMS, `${label}: expected invalid_params (charged, under the cap), got ${JSON.stringify(outcome)}`);
|
|
138
|
+
if (outcome.status !== null)
|
|
139
|
+
assert.strictEqual(outcome.status, 400, `${label}: HTTP status`);
|
|
140
|
+
};
|
|
141
|
+
const assert_rate_limited = (outcome, label) => {
|
|
142
|
+
assert.strictEqual(outcome.code, CODE_RATE_LIMITED, `${label}: expected rate_limited, got ${JSON.stringify(outcome)}`);
|
|
143
|
+
assert.ok(typeof outcome.retry_after === 'number' && outcome.retry_after > 0, `${label}: rate_limited carries a positive data.retry_after, got ${String(outcome.retry_after)}`);
|
|
144
|
+
if (outcome.status !== null)
|
|
145
|
+
assert.strictEqual(outcome.status, 429, `${label}: HTTP status`);
|
|
146
|
+
};
|
|
147
|
+
describe('action rate limiting (cross-process, real socket)', () => {
|
|
148
|
+
test(`an account-limited action is refused on the WS call past the cap`, async () => {
|
|
149
|
+
const fixture = await setup_test();
|
|
150
|
+
const ws = await open_ws(fixture);
|
|
151
|
+
try {
|
|
152
|
+
for (let i = 1; i <= max_attempts; i++) {
|
|
153
|
+
assert_charged_not_limited(await call_ws(ws, ACCOUNT_LIMITED_METHOD, ACCOUNT_LIMITED_BAD_PARAMS), `WS call ${i}`);
|
|
154
|
+
}
|
|
155
|
+
assert_rate_limited(await call_ws(ws, ACCOUNT_LIMITED_METHOD, ACCOUNT_LIMITED_BAD_PARAMS), `WS call ${max_attempts + 1}`);
|
|
156
|
+
}
|
|
157
|
+
finally {
|
|
158
|
+
await ws.close();
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
test('HTTP RPC and WS calls draw one account budget', async () => {
|
|
162
|
+
const fixture = await setup_test();
|
|
163
|
+
const session = fixture.create_session_headers();
|
|
164
|
+
const http_calls = Math.floor(max_attempts / 2);
|
|
165
|
+
for (let i = 1; i <= http_calls; i++) {
|
|
166
|
+
assert_charged_not_limited(await call_http(fixture, ACCOUNT_LIMITED_METHOD, ACCOUNT_LIMITED_BAD_PARAMS, session), `HTTP call ${i}`);
|
|
167
|
+
}
|
|
168
|
+
const ws = await open_ws(fixture);
|
|
169
|
+
try {
|
|
170
|
+
for (let i = http_calls + 1; i <= max_attempts; i++) {
|
|
171
|
+
assert_charged_not_limited(await call_ws(ws, ACCOUNT_LIMITED_METHOD, ACCOUNT_LIMITED_BAD_PARAMS), `WS call ${i}`);
|
|
172
|
+
}
|
|
173
|
+
assert_rate_limited(await call_ws(ws, ACCOUNT_LIMITED_METHOD, ACCOUNT_LIMITED_BAD_PARAMS), 'WS call past the shared cap');
|
|
174
|
+
}
|
|
175
|
+
finally {
|
|
176
|
+
await ws.close();
|
|
177
|
+
}
|
|
178
|
+
assert_rate_limited(await call_http(fixture, ACCOUNT_LIMITED_METHOD, ACCOUNT_LIMITED_BAD_PARAMS, session), 'HTTP call past the shared cap');
|
|
179
|
+
});
|
|
180
|
+
test('an account-limited action is refused at the RPC dispatcher past the cap', async () => {
|
|
181
|
+
const fixture = await setup_test();
|
|
182
|
+
const session = fixture.create_session_headers();
|
|
183
|
+
for (let i = 1; i <= max_attempts; i++) {
|
|
184
|
+
assert_charged_not_limited(await call_http(fixture, DISPATCHER_ACCOUNT_LIMITED_METHOD, ACCOUNT_LIMITED_BAD_PARAMS, session), `HTTP call ${i}`);
|
|
185
|
+
}
|
|
186
|
+
assert_rate_limited(await call_http(fixture, DISPATCHER_ACCOUNT_LIMITED_METHOD, ACCOUNT_LIMITED_BAD_PARAMS, session), `HTTP call ${max_attempts + 1}`);
|
|
187
|
+
});
|
|
188
|
+
test('an account-limited action is refused at the WS dispatcher past the cap', async () => {
|
|
189
|
+
const fixture = await setup_test();
|
|
190
|
+
const ws = await open_ws(fixture);
|
|
191
|
+
try {
|
|
192
|
+
for (let i = 1; i <= max_attempts; i++) {
|
|
193
|
+
assert_charged_not_limited(await call_ws(ws, DISPATCHER_ACCOUNT_LIMITED_METHOD, ACCOUNT_LIMITED_BAD_PARAMS), `WS call ${i}`);
|
|
194
|
+
}
|
|
195
|
+
assert_rate_limited(await call_ws(ws, DISPATCHER_ACCOUNT_LIMITED_METHOD, ACCOUNT_LIMITED_BAD_PARAMS), `WS call ${max_attempts + 1}`);
|
|
196
|
+
}
|
|
197
|
+
finally {
|
|
198
|
+
await ws.close();
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
test('the dispatchers draw one account budget across HTTP RPC and WS', async () => {
|
|
202
|
+
const fixture = await setup_test();
|
|
203
|
+
const session = fixture.create_session_headers();
|
|
204
|
+
const http_calls = Math.floor(max_attempts / 2);
|
|
205
|
+
for (let i = 1; i <= http_calls; i++) {
|
|
206
|
+
assert_charged_not_limited(await call_http(fixture, DISPATCHER_ACCOUNT_LIMITED_METHOD, ACCOUNT_LIMITED_BAD_PARAMS, session), `HTTP call ${i}`);
|
|
207
|
+
}
|
|
208
|
+
const ws = await open_ws(fixture);
|
|
209
|
+
try {
|
|
210
|
+
for (let i = http_calls + 1; i <= max_attempts; i++) {
|
|
211
|
+
assert_charged_not_limited(await call_ws(ws, DISPATCHER_ACCOUNT_LIMITED_METHOD, ACCOUNT_LIMITED_BAD_PARAMS), `WS call ${i}`);
|
|
212
|
+
}
|
|
213
|
+
assert_rate_limited(await call_ws(ws, DISPATCHER_ACCOUNT_LIMITED_METHOD, ACCOUNT_LIMITED_BAD_PARAMS), 'WS call past the shared cap');
|
|
214
|
+
}
|
|
215
|
+
finally {
|
|
216
|
+
await ws.close();
|
|
217
|
+
}
|
|
218
|
+
assert_rate_limited(await call_http(fixture, DISPATCHER_ACCOUNT_LIMITED_METHOD, ACCOUNT_LIMITED_BAD_PARAMS, session), 'HTTP call past the shared cap');
|
|
219
|
+
});
|
|
220
|
+
test('two account-limited actions draw one account budget', async () => {
|
|
221
|
+
const fixture = await setup_test();
|
|
222
|
+
const session = fixture.create_session_headers();
|
|
223
|
+
const first_calls = Math.floor(max_attempts / 2);
|
|
224
|
+
for (let i = 1; i <= max_attempts; i++) {
|
|
225
|
+
const method = i <= first_calls ? DISPATCHER_ACCOUNT_LIMITED_METHOD : ACCOUNT_LIMITED_METHOD;
|
|
226
|
+
assert_charged_not_limited(await call_http(fixture, method, ACCOUNT_LIMITED_BAD_PARAMS, session), `HTTP ${method} call ${i}`);
|
|
227
|
+
}
|
|
228
|
+
for (const method of [DISPATCHER_ACCOUNT_LIMITED_METHOD, ACCOUNT_LIMITED_METHOD]) {
|
|
229
|
+
assert_rate_limited(await call_http(fixture, method, ACCOUNT_LIMITED_BAD_PARAMS, session), `HTTP ${method} call past the shared cap`);
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
test('an IP-limited action is keyed by the forwarded client IP on both transports', async () => {
|
|
233
|
+
// the addresses are fixed per process and their bucket outlives
|
|
234
|
+
// `_testing_reset`, so a vitest retry of this case would start
|
|
235
|
+
// exhausted and fail
|
|
236
|
+
const fixture = await setup_test();
|
|
237
|
+
const exhausted = { 'x-forwarded-for': XFF_IP_EXHAUSTED };
|
|
238
|
+
// anonymous HTTP — `peer/ping` is public, so only the IP axis applies
|
|
239
|
+
for (let i = 1; i < max_attempts; i++) {
|
|
240
|
+
assert_charged_not_limited(await call_http(fixture, IP_LIMITED_METHOD, IP_LIMITED_BAD_PARAMS, exhausted), `HTTP call ${i} from ${XFF_IP_EXHAUSTED}`);
|
|
241
|
+
}
|
|
242
|
+
const ws_exhausted = await open_ws(fixture, XFF_IP_EXHAUSTED);
|
|
243
|
+
const ws_fresh = await open_ws(fixture, XFF_IP_FRESH);
|
|
244
|
+
try {
|
|
245
|
+
assert_charged_not_limited(await call_ws(ws_exhausted, IP_LIMITED_METHOD, IP_LIMITED_BAD_PARAMS), `WS call ${max_attempts} from ${XFF_IP_EXHAUSTED}`);
|
|
246
|
+
assert_rate_limited(await call_ws(ws_exhausted, IP_LIMITED_METHOD, IP_LIMITED_BAD_PARAMS), `WS call past the cap from ${XFF_IP_EXHAUSTED}`);
|
|
247
|
+
assert_rate_limited(await call_http(fixture, IP_LIMITED_METHOD, IP_LIMITED_BAD_PARAMS, exhausted), `HTTP call past the cap from ${XFF_IP_EXHAUSTED}`);
|
|
248
|
+
// a second address has its own bucket, on both transports, even on
|
|
249
|
+
// the same account's session
|
|
250
|
+
assert_charged_not_limited(await call_ws(ws_fresh, IP_LIMITED_METHOD, IP_LIMITED_BAD_PARAMS), `WS call from ${XFF_IP_FRESH}`);
|
|
251
|
+
assert_charged_not_limited(await call_http(fixture, IP_LIMITED_METHOD, IP_LIMITED_BAD_PARAMS, {
|
|
252
|
+
'x-forwarded-for': XFF_IP_FRESH
|
|
253
|
+
}), `HTTP call from ${XFF_IP_FRESH}`);
|
|
254
|
+
}
|
|
255
|
+
finally {
|
|
256
|
+
await ws_exhausted.close();
|
|
257
|
+
await ws_fresh.close();
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
};
|
package/dist/testing/stubs.d.ts
CHANGED
|
@@ -9,7 +9,7 @@ import type { AppServerContext } from '../server/app_server_context.js';
|
|
|
9
9
|
import { Db } from '../db/db.js';
|
|
10
10
|
import { type RouteSpec } from '../http/route_spec.js';
|
|
11
11
|
import { type AppSurfaceSpec, type RpcEndpointSpec } from '../http/surface.js';
|
|
12
|
-
import type
|
|
12
|
+
import { type WsEndpointSpec } from '../actions/ws_endpoint_spec.js';
|
|
13
13
|
import type { EventSpec } from '../realtime/sse.js';
|
|
14
14
|
import { type AuditLogSse } from '../realtime/sse_auth_guard.js';
|
|
15
15
|
/**
|
|
@@ -124,10 +124,17 @@ export interface CreateTestAppSurfaceSpecOptions {
|
|
|
124
124
|
* to both entry points so the attack surface tests see the same WS
|
|
125
125
|
* endpoints production auto-mounts. The factory runs once against
|
|
126
126
|
* the stub `AppServerContext` this helper already builds. No
|
|
127
|
-
* `
|
|
128
|
-
* only, never mounts.
|
|
127
|
+
* `create_upgrade_websocket` needed — this helper produces an
|
|
128
|
+
* `AppSurfaceSpec` only, never mounts.
|
|
129
129
|
*/
|
|
130
130
|
ws_endpoints?: ReadonlyArray<WsEndpointSpec> | ((ctx: AppServerContext) => ReadonlyArray<WsEndpointSpec>);
|
|
131
|
+
/**
|
|
132
|
+
* The server's origin allowlist — `AppServerOptions.allowed_origins`.
|
|
133
|
+
* Read only as the default for a `ws_endpoints` spec that declares no
|
|
134
|
+
* `allowed_origins`, as `create_app_server` does; required when any spec
|
|
135
|
+
* omits its own, since surface generation throws on an unresolved one.
|
|
136
|
+
*/
|
|
137
|
+
allowed_origins?: ReadonlyArray<RegExp>;
|
|
131
138
|
/** Transform middleware array (e.g., zap's `extend_middleware_for_zap_binary`). */
|
|
132
139
|
transform_middleware?: (specs: Array<MiddlewareSpec>) => Array<MiddlewareSpec>;
|
|
133
140
|
/**
|
|
@@ -152,12 +159,15 @@ export interface CreateTestAppSurfaceSpecOptions {
|
|
|
152
159
|
* file.
|
|
153
160
|
*
|
|
154
161
|
* Mirrors `create_app_server`'s route assembly: consumer routes +
|
|
155
|
-
* factory-managed bootstrap routes + surface generation
|
|
162
|
+
* factory-managed bootstrap routes + surface generation — including its
|
|
163
|
+
* refusal of an `rpc_endpoints` or `ws_endpoints` path outside the auth
|
|
164
|
+
* middleware's scope (`assert_endpoint_in_auth_scope`). If
|
|
156
165
|
* `create_app_server` changes how it wires routes, update this helper
|
|
157
166
|
* to stay in sync (single source of truth for all consumers).
|
|
158
167
|
*
|
|
159
168
|
* @param options - surface spec options
|
|
160
169
|
* @returns the surface spec for the standard suites
|
|
170
|
+
* @throws Error when an endpoint path is outside `AUTH_MIDDLEWARE_PATH`
|
|
161
171
|
*/
|
|
162
172
|
export declare const create_test_app_surface_spec: (options: CreateTestAppSurfaceSpecOptions) => AppSurfaceSpec;
|
|
163
173
|
//# sourceMappingURL=stubs.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"stubs.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/stubs.ts"],"names":[],"mappings":"AAAA,OAAO,qBAAqB,CAAC;AAa7B,OAAO,KAAK,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAE7B,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,2BAA2B,CAAC;AAChE,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,4BAA4B,CAAC;AAEjE,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,iBAAiB,CAAC;AAC/C,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,0BAA0B,CAAC;AAE7D,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,yBAAyB,CAAC;AACtE,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,iCAAiC,CAAC;AACxE,OAAO,EAAE,EAAE,EAAE,MAAM,aAAa,CAAC;AACjC,OAAO,EAAsB,KAAK,SAAS,EAAE,MAAM,uBAAuB,CAAC;AAG3E,OAAO,EAEN,KAAK,cAAc,EACnB,KAAK,eAAe,EACpB,MAAM,oBAAoB,CAAC;AAC5B,OAAO,KAAK,
|
|
1
|
+
{"version":3,"file":"stubs.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/stubs.ts"],"names":[],"mappings":"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"}
|
package/dist/testing/stubs.js
CHANGED
|
@@ -15,9 +15,11 @@ import { prefix_route_specs } from '../http/route_spec.js';
|
|
|
15
15
|
import { bootstrap_route_shape } from '../auth/bootstrap_route_schema.js';
|
|
16
16
|
import { create_rpc_endpoint } from '../actions/action_rpc.js';
|
|
17
17
|
import { create_app_surface_spec } from '../http/surface.js';
|
|
18
|
+
import { resolve_ws_endpoints } from '../actions/ws_endpoint_spec.js';
|
|
18
19
|
import { AUDIT_LOG_SSE_MAX_PER_SCOPE } from '../realtime/sse_auth_guard.js';
|
|
19
20
|
import { SubscriberRegistry } from '../realtime/subscriber_registry.js';
|
|
20
21
|
import { BaseServerEnv } from '../server/env.js';
|
|
22
|
+
import { AUTH_MIDDLEWARE_PATH, assert_endpoint_in_auth_scope } from '../auth/middleware.js';
|
|
21
23
|
/**
|
|
22
24
|
* Create a Proxy that throws descriptive errors on any property access or method call.
|
|
23
25
|
*
|
|
@@ -160,12 +162,12 @@ export const create_stub_app_deps = () => ({
|
|
|
160
162
|
/** Create the API middleware stub array matching `create_auth_middleware_specs` output. */
|
|
161
163
|
export const create_stub_api_middleware = (options) => {
|
|
162
164
|
const specs = [
|
|
163
|
-
{ name: 'origin', path:
|
|
164
|
-
{ name: 'session', path:
|
|
165
|
-
{ name: 'request_context', path:
|
|
165
|
+
{ name: 'origin', path: AUTH_MIDDLEWARE_PATH, handler: stub_mw, errors: { 403: ApiError } },
|
|
166
|
+
{ name: 'session', path: AUTH_MIDDLEWARE_PATH, handler: stub_mw },
|
|
167
|
+
{ name: 'request_context', path: AUTH_MIDDLEWARE_PATH, handler: stub_mw },
|
|
166
168
|
{
|
|
167
169
|
name: 'bearer_auth',
|
|
168
|
-
path:
|
|
170
|
+
path: AUTH_MIDDLEWARE_PATH,
|
|
169
171
|
handler: stub_mw,
|
|
170
172
|
errors: { 401: ApiError, 403: ApiError, 429: RateLimitError }
|
|
171
173
|
}
|
|
@@ -173,7 +175,7 @@ export const create_stub_api_middleware = (options) => {
|
|
|
173
175
|
if (options?.include_daemon_token) {
|
|
174
176
|
specs.push({
|
|
175
177
|
name: 'daemon_token',
|
|
176
|
-
path:
|
|
178
|
+
path: AUTH_MIDDLEWARE_PATH,
|
|
177
179
|
handler: stub_mw,
|
|
178
180
|
errors: { 401: ApiError, 500: ApiError, 503: ApiError }
|
|
179
181
|
});
|
|
@@ -222,12 +224,15 @@ export const create_stub_app_server_context = (session_options) => {
|
|
|
222
224
|
* file.
|
|
223
225
|
*
|
|
224
226
|
* Mirrors `create_app_server`'s route assembly: consumer routes +
|
|
225
|
-
* factory-managed bootstrap routes + surface generation
|
|
227
|
+
* factory-managed bootstrap routes + surface generation — including its
|
|
228
|
+
* refusal of an `rpc_endpoints` or `ws_endpoints` path outside the auth
|
|
229
|
+
* middleware's scope (`assert_endpoint_in_auth_scope`). If
|
|
226
230
|
* `create_app_server` changes how it wires routes, update this helper
|
|
227
231
|
* to stay in sync (single source of truth for all consumers).
|
|
228
232
|
*
|
|
229
233
|
* @param options - surface spec options
|
|
230
234
|
* @returns the surface spec for the standard suites
|
|
235
|
+
* @throws Error when an endpoint path is outside `AUTH_MIDDLEWARE_PATH`
|
|
231
236
|
*/
|
|
232
237
|
export const create_test_app_surface_spec = (options) => {
|
|
233
238
|
const ctx = create_stub_app_server_context(options.session_options);
|
|
@@ -237,14 +242,27 @@ export const create_test_app_surface_spec = (options) => {
|
|
|
237
242
|
const resolved_rpc_endpoints = typeof options.rpc_endpoints === 'function'
|
|
238
243
|
? options.rpc_endpoints(ctx)
|
|
239
244
|
: options.rpc_endpoints;
|
|
240
|
-
const rpc_route_specs = resolved_rpc_endpoints?.flatMap((endpoint) =>
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
+
const rpc_route_specs = resolved_rpc_endpoints?.flatMap((endpoint) => {
|
|
246
|
+
assert_endpoint_in_auth_scope('rpc_endpoints', endpoint.path);
|
|
247
|
+
return create_rpc_endpoint({
|
|
248
|
+
path: endpoint.path,
|
|
249
|
+
actions: endpoint.actions,
|
|
250
|
+
log: ctx.deps.log,
|
|
251
|
+
action_ip_rate_limiter: ctx.action_ip_rate_limiter,
|
|
252
|
+
action_account_rate_limiter: ctx.action_account_rate_limiter
|
|
253
|
+
});
|
|
254
|
+
}) ?? [];
|
|
245
255
|
// Resolve ws endpoints (mirrors create_app_server). Surface-only —
|
|
246
|
-
// no `register_ws_endpoint` call here, so no `
|
|
247
|
-
const
|
|
256
|
+
// no `register_ws_endpoint` call here, so no `create_upgrade_websocket` needed.
|
|
257
|
+
const declared_ws_endpoints = typeof options.ws_endpoints === 'function' ? options.ws_endpoints(ctx) : options.ws_endpoints;
|
|
258
|
+
// without the server's list, an origin-less spec reaches surface
|
|
259
|
+
// generation unresolved and throws there
|
|
260
|
+
const resolved_ws_endpoints = options.allowed_origins
|
|
261
|
+
? resolve_ws_endpoints(declared_ws_endpoints, options.allowed_origins)
|
|
262
|
+
: declared_ws_endpoints;
|
|
263
|
+
for (const endpoint of resolved_ws_endpoints ?? []) {
|
|
264
|
+
assert_endpoint_in_auth_scope('ws_endpoints', endpoint.path);
|
|
265
|
+
}
|
|
248
266
|
// Bootstrap routes mirror `create_app_server`: mounted for `surface_only`
|
|
249
267
|
// and `live` modes; omitted for `disabled` / undefined. Surface generation
|
|
250
268
|
// uses an `available: false` placeholder regardless of mode — the handler
|
|
@@ -34,6 +34,13 @@ export interface WsTransportOptions {
|
|
|
34
34
|
* behavior).
|
|
35
35
|
*/
|
|
36
36
|
readonly on_request?: WsRequestResponder;
|
|
37
|
+
/**
|
|
38
|
+
* Extra headers for the upgrade request — e.g. an `X-Forwarded-For` that a
|
|
39
|
+
* backend trusting the loopback proxy resolves as the connection's client
|
|
40
|
+
* IP, which keys the per-IP action rate limiter for every message on the
|
|
41
|
+
* socket. `cookies` and `origin` set their own headers.
|
|
42
|
+
*/
|
|
43
|
+
readonly headers?: Readonly<Record<string, string>>;
|
|
37
44
|
}
|
|
38
45
|
/**
|
|
39
46
|
* Build a real-upgrade WS client pinned to `options.base_url` + `ws_path`.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ws_transport.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/transports/ws_transport.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAuB9B,OAAO,EAMN,KAAK,QAAQ,EACb,KAAK,kBAAkB,EAEvB,MAAM,gBAAgB,CAAC;AAIxB,sDAAsD;AACtD,MAAM,WAAW,kBAAkB;IAClC,gHAAgH;IAChH,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,8DAA8D;IAC9D,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB;;;;;OAKG;IACH,QAAQ,CAAC,OAAO,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IACxC;;;;;OAKG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB;;;OAGG;IACH,QAAQ,CAAC,kBAAkB,CAAC,EAAE,MAAM,CAAC;IACrC;;;;;;;OAOG;IACH,QAAQ,CAAC,UAAU,CAAC,EAAE,kBAAkB,CAAC;
|
|
1
|
+
{"version":3,"file":"ws_transport.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/transports/ws_transport.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAuB9B,OAAO,EAMN,KAAK,QAAQ,EACb,KAAK,kBAAkB,EAEvB,MAAM,gBAAgB,CAAC;AAIxB,sDAAsD;AACtD,MAAM,WAAW,kBAAkB;IAClC,gHAAgH;IAChH,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,8DAA8D;IAC9D,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB;;;;;OAKG;IACH,QAAQ,CAAC,OAAO,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IACxC;;;;;OAKG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB;;;OAGG;IACH,QAAQ,CAAC,kBAAkB,CAAC,EAAE,MAAM,CAAC;IACrC;;;;;;;OAOG;IACH,QAAQ,CAAC,UAAU,CAAC,EAAE,kBAAkB,CAAC;IACzC;;;;;OAKG;IACH,QAAQ,CAAC,OAAO,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;CACpD;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,eAAO,MAAM,mBAAmB,GAAU,SAAS,kBAAkB,KAAG,OAAO,CAAC,QAAQ,CAwJvF,CAAC;AAEF,mFAAmF;AACnF,eAAO,MAAM,qBAAqB,aAAa,CAAC;AAEhD;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,4BAA4B,GACxC,SAAS,kBAAkB,KACzB,OAAO,CAAC,QAAQ,CASlB,CAAC"}
|
|
@@ -45,7 +45,7 @@ export const create_ws_transport = async (options) => {
|
|
|
45
45
|
const { base_url, ws_path, cookies, origin, default_timeout_ms, on_request } = options;
|
|
46
46
|
const default_timeout = default_timeout_ms ?? WS_CLIENT_DEFAULT_TIMEOUT_MS;
|
|
47
47
|
const ws_url = `${base_url.replace(/^http/i, 'ws')}${ws_path}`;
|
|
48
|
-
const headers = {};
|
|
48
|
+
const headers = { ...options.headers };
|
|
49
49
|
if (cookies.length > 0)
|
|
50
50
|
headers.Cookie = cookies.join('; ');
|
|
51
51
|
const socket = new WebSocket(ws_url, {
|
|
@@ -29,8 +29,8 @@ import './assert_dev_env.js';
|
|
|
29
29
|
* `JsonrpcNotificationFrame`, ...) live in `transports/ws_client.ts`
|
|
30
30
|
* so both in-process and cross-process drivers reference one source.
|
|
31
31
|
*
|
|
32
|
-
* Hono's wire upgrade is skipped —
|
|
33
|
-
*
|
|
32
|
+
* Hono's wire upgrade is skipped — there is no server, so no runtime
|
|
33
|
+
* adapter — but the full dispatch path is exercised
|
|
34
34
|
* (per-action auth, input validation, `ctx.notify` back to the
|
|
35
35
|
* originating socket, broadcast via `BackendWebsocketTransport`, and
|
|
36
36
|
* close-on-revoke).
|
|
@@ -87,15 +87,45 @@ export interface FakeHonoContextOptions {
|
|
|
87
87
|
* Only `.get()` is populated — no other Hono context surface is simulated.
|
|
88
88
|
*/
|
|
89
89
|
export declare const create_fake_hono_context: (opts: FakeHonoContextOptions) => Context;
|
|
90
|
-
/**
|
|
90
|
+
/** One upgrade that reached a `create_stub_upgrade` route. */
|
|
91
|
+
export interface StubUpgradeRecord {
|
|
92
|
+
/**
|
|
93
|
+
* The real Hono `Context` the request carried through the app's
|
|
94
|
+
* middleware chain — session, request context, client IP, and every
|
|
95
|
+
* other value the middleware set.
|
|
96
|
+
*/
|
|
97
|
+
c: Context;
|
|
98
|
+
/** The events factory of the endpoint the upgrade reached. */
|
|
99
|
+
create_events: (c: Context) => WSEvents | Promise<WSEvents>;
|
|
100
|
+
}
|
|
101
|
+
/** The return of `create_stub_upgrade` — fake `upgradeWebSocket` + factory and upgrade capture. */
|
|
91
102
|
export interface StubUpgrade {
|
|
92
103
|
upgradeWebSocket: UpgradeWebSocket;
|
|
104
|
+
/**
|
|
105
|
+
* The events factory of the most recently mounted endpoint, for tests
|
|
106
|
+
* that build events from a hand-made context. Throws when no endpoint
|
|
107
|
+
* was mounted.
|
|
108
|
+
*/
|
|
93
109
|
get_create_events: () => (c: Context) => WSEvents | Promise<WSEvents>;
|
|
110
|
+
/**
|
|
111
|
+
* Every upgrade request that reached a stub route, in arrival order —
|
|
112
|
+
* what `app.request(path, {headers: {upgrade: 'websocket', ...}})`
|
|
113
|
+
* leaves behind once the app's middleware passed it. Build the socket's
|
|
114
|
+
* events with `record.create_events(record.c)`.
|
|
115
|
+
*/
|
|
116
|
+
upgrades: ReadonlyArray<StubUpgradeRecord>;
|
|
94
117
|
}
|
|
95
118
|
/**
|
|
96
|
-
* Build a fake `upgradeWebSocket` that
|
|
97
|
-
*
|
|
98
|
-
*
|
|
119
|
+
* Build a fake `upgradeWebSocket` that stands in for a runtime adapter's.
|
|
120
|
+
*
|
|
121
|
+
* Like the real adapters, the middleware it returns passes a request
|
|
122
|
+
* without an `Upgrade: websocket` header to `next()`. An upgrade request
|
|
123
|
+
* is recorded in `upgrades` — its real `Context` and the endpoint's events
|
|
124
|
+
* factory — and answered `200` with no body (a `Response` cannot carry the
|
|
125
|
+
* `101` an adapter answers). No socket is opened: tests drive the recorded
|
|
126
|
+
* factory, so an upgrade sent through `app.request` exercises the app's
|
|
127
|
+
* whole middleware chain — origin, session or bearer, client IP — before
|
|
128
|
+
* the dispatcher sees it.
|
|
99
129
|
*/
|
|
100
130
|
export declare const create_stub_upgrade: () => StubUpgrade;
|
|
101
131
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ws_round_trip.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/ws_round_trip.ts"],"names":[],"mappings":"AAAA,OAAO,qBAAqB,CAAC;AAE7B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqCG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAQ,MAAM,MAAM,CAAC;AAC1C,OAAO,EACN,SAAS,EAET,KAAK,gBAAgB,EAErB,KAAK,QAAQ,EACb,MAAM,SAAS,CAAC;AACjB,OAAO,EAAE,MAAM,EAAE,MAAM,yBAAyB,CAAC;AACjD,OAAO,EAAe,KAAK,IAAI,EAAE,MAAM,wBAAwB,CAAC;AAEhE,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,2BAA2B,CAAC;AACjE,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,4BAA4B,CAAC;AAEzD,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,kCAAkC,CAAC;AAE/E,OAAO,EAAsB,KAAK,uBAAuB,EAAE,MAAM,kCAAkC,CAAC;AAEpG,OAAO,EAAE,yBAAyB,EAAE,MAAM,qCAAqC,CAAC;AAChF,OAAO,EAGN,KAAK,cAAc,EACnB,MAAM,4BAA4B,CAAC;AAGpC,OAAO,EAKN,KAAK,cAAc,EACnB,MAAM,oBAAoB,CAAC;AAG5B,OAAO,EAMN,KAAK,QAAQ,EACb,KAAK,kBAAkB,EAEvB,MAAM,2BAA2B,CAAC;AAMnC;;;GAGG;AACH,MAAM,WAAW,MAAM;IACtB,EAAE,EAAE,SAAS,CAAC;IACd,KAAK,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;IACrB,MAAM,EAAE,KAAK,CAAC;QAAE,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CAClD;AAED;;;;GAIG;AACH,eAAO,MAAM,cAAc,QAAO,MAajC,CAAC;AAEF,8CAA8C;AAC9C,MAAM,WAAW,sBAAsB;IACtC,eAAe,EAAE,cAAc,CAAC;IAChC,gEAAgE;IAChE,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,eAAe,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC,YAAY,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B;;;OAGG;IACH,eAAe,CAAC,EAAE,cAAc,CAAC;CACjC;AAED;;;;;GAKG;AACH,eAAO,MAAM,wBAAwB,GAAI,MAAM,sBAAsB,KAAG,OAmBvE,CAAC;AAEF,
|
|
1
|
+
{"version":3,"file":"ws_round_trip.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/ws_round_trip.ts"],"names":[],"mappings":"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"}
|
|
@@ -58,15 +58,30 @@ export const create_fake_hono_context = (opts) => {
|
|
|
58
58
|
};
|
|
59
59
|
};
|
|
60
60
|
/**
|
|
61
|
-
* Build a fake `upgradeWebSocket` that
|
|
62
|
-
*
|
|
63
|
-
*
|
|
61
|
+
* Build a fake `upgradeWebSocket` that stands in for a runtime adapter's.
|
|
62
|
+
*
|
|
63
|
+
* Like the real adapters, the middleware it returns passes a request
|
|
64
|
+
* without an `Upgrade: websocket` header to `next()`. An upgrade request
|
|
65
|
+
* is recorded in `upgrades` — its real `Context` and the endpoint's events
|
|
66
|
+
* factory — and answered `200` with no body (a `Response` cannot carry the
|
|
67
|
+
* `101` an adapter answers). No socket is opened: tests drive the recorded
|
|
68
|
+
* factory, so an upgrade sent through `app.request` exercises the app's
|
|
69
|
+
* whole middleware chain — origin, session or bearer, client IP — before
|
|
70
|
+
* the dispatcher sees it.
|
|
64
71
|
*/
|
|
65
72
|
export const create_stub_upgrade = () => {
|
|
66
73
|
let captured = null;
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
74
|
+
const upgrades = [];
|
|
75
|
+
const upgradeWebSocket = ((create_events) => {
|
|
76
|
+
captured = create_events;
|
|
77
|
+
return async (c, next) => {
|
|
78
|
+
if (c.req.header('upgrade')?.toLowerCase() !== 'websocket') {
|
|
79
|
+
await next();
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
upgrades.push({ c, create_events });
|
|
83
|
+
return new Response(null, { status: 200 });
|
|
84
|
+
};
|
|
70
85
|
});
|
|
71
86
|
return {
|
|
72
87
|
upgradeWebSocket,
|
|
@@ -74,7 +89,8 @@ export const create_stub_upgrade = () => {
|
|
|
74
89
|
if (!captured)
|
|
75
90
|
throw new Error('upgradeWebSocket was not called');
|
|
76
91
|
return captured;
|
|
77
|
-
}
|
|
92
|
+
},
|
|
93
|
+
upgrades
|
|
78
94
|
};
|
|
79
95
|
};
|
|
80
96
|
/**
|
|
@@ -198,7 +214,11 @@ export const create_ws_test_harness = (options) => {
|
|
|
198
214
|
heartbeat,
|
|
199
215
|
log,
|
|
200
216
|
on_socket_open,
|
|
201
|
-
on_socket_close
|
|
217
|
+
on_socket_close,
|
|
218
|
+
// the harness drives per-message dispatch, not throttling — the
|
|
219
|
+
// assembled-app tests cover the limiters `create_app_server` threads
|
|
220
|
+
action_ip_rate_limiter: null,
|
|
221
|
+
action_account_rate_limiter: null
|
|
202
222
|
});
|
|
203
223
|
const events_factory = stub.get_create_events();
|
|
204
224
|
const connect = async (identity = {}) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fuzdev/fuz_app",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.126.0",
|
|
4
4
|
"description": "fullstack app library",
|
|
5
5
|
"glyph": "🗝",
|
|
6
6
|
"logo": "logo.svg",
|
|
@@ -87,7 +87,7 @@
|
|
|
87
87
|
"@electric-sql/pglite": "^0.4.5",
|
|
88
88
|
"@fuzdev/blake3-wasm": "^0.2.0",
|
|
89
89
|
"@fuzdev/fuz_code": "^0.50.0",
|
|
90
|
-
"@fuzdev/fuz_css": "^0.65.
|
|
90
|
+
"@fuzdev/fuz_css": "^0.65.3",
|
|
91
91
|
"@fuzdev/fuz_ui": "^0.210.2",
|
|
92
92
|
"@fuzdev/fuz_util": "^0.71.0",
|
|
93
93
|
"@fuzdev/gro": "^0.213.0",
|