@fuzdev/fuz_app 0.125.0 → 0.127.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 +56 -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/CLAUDE.md +3 -3
- package/dist/http/surface.d.ts +12 -9
- package/dist/http/surface.d.ts.map +1 -1
- package/dist/rate_limiter.d.ts +43 -3
- package/dist/rate_limiter.d.ts.map +1 -1
- package/dist/rate_limiter.js +27 -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 +128 -55
- package/dist/server/app_server.d.ts.map +1 -1
- package/dist/server/app_server.js +110 -61
- 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 +83 -25
- 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 +20 -20
- 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 +12 -12
- package/dist/testing/cross_backend/default_backend_configs.d.ts.map +1 -1
- package/dist/testing/cross_backend/default_backend_configs.js +11 -10
- 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 +16 -1
- 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 +5 -4
- package/dist/testing/cross_backend/spine_surface_constants.d.ts +43 -3
- package/dist/testing/cross_backend/spine_surface_constants.d.ts.map +1 -1
- package/dist/testing/cross_backend/spine_surface_constants.js +43 -3
- package/dist/testing/cross_backend/testing_backdoor.d.ts +15 -1
- package/dist/testing/cross_backend/testing_backdoor.d.ts.map +1 -1
- package/dist/testing/cross_backend/testing_backdoor.js +51 -6
- 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 +72 -41
- package/dist/testing/cross_backend/testing_server_core.d.ts.map +1 -1
- package/dist/testing/cross_backend/testing_server_core.js +22 -14
- 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 -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 +10 -8
- 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/middleware.d.ts.map +1 -1
- package/dist/testing/middleware.js +4 -3
- package/dist/testing/stubs.d.ts +17 -4
- package/dist/testing/stubs.d.ts.map +1 -1
- package/dist/testing/stubs.js +46 -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 +43 -37
- package/dist/testing/ws_round_trip.d.ts.map +1 -1
- package/dist/testing/ws_round_trip.js +28 -46
- 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
|
+
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"middleware.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/middleware.ts"],"names":[],"mappings":"AAAA,OAAO,qBAAqB,CAAC;AAE7B;;;;;;;;GAQG;AAEH,OAAO,EAAE,EAAE,EAA0B,MAAM,QAAQ,CAAC;AACpD,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAC5B,OAAO,KAAK,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;
|
|
1
|
+
{"version":3,"file":"middleware.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/middleware.ts"],"names":[],"mappings":"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"}
|
|
@@ -12,6 +12,7 @@ import { vi, test, assert, describe } from 'vitest';
|
|
|
12
12
|
import { Hono } from 'hono';
|
|
13
13
|
import { Logger } from '@fuzdev/fuz_util/log.ts';
|
|
14
14
|
import { create_bearer_auth_middleware } from '../auth/bearer_auth.js';
|
|
15
|
+
import { AUTH_MIDDLEWARE_PATH } from '../auth/middleware.js';
|
|
15
16
|
import { query_validate_api_token } from '../auth/api_token_queries.js';
|
|
16
17
|
import { query_account_by_id, query_actor_by_id, query_active_actors_by_account } from '../auth/account_queries.js';
|
|
17
18
|
import { query_role_grant_find_active_for_actor } from '../auth/role_grant_queries.js';
|
|
@@ -117,7 +118,7 @@ export const create_bearer_auth_test_app = (tc) => {
|
|
|
117
118
|
c.set('client_ip', TEST_CLIENT_IP);
|
|
118
119
|
await next();
|
|
119
120
|
});
|
|
120
|
-
app.use(
|
|
121
|
+
app.use(AUTH_MIDDLEWARE_PATH, bearer_middleware);
|
|
121
122
|
// route handler echoes the account-grain identity the middleware writes
|
|
122
123
|
// (bearer auth sets `ACCOUNT_ID_KEY` + `CREDENTIAL_TYPE_KEY` +
|
|
123
124
|
// `AUTH_API_TOKEN_ID_KEY`; it never builds the full request context —
|
|
@@ -230,8 +231,8 @@ export const create_test_middleware_stack_app = (options) => {
|
|
|
230
231
|
const bearer_mw = create_bearer_auth_middleware(STUB_DEPS, new Logger('test', { level: 'off' }));
|
|
231
232
|
const app = new Hono();
|
|
232
233
|
app.use('*', proxy_mw);
|
|
233
|
-
app.use(
|
|
234
|
-
app.use(
|
|
234
|
+
app.use(AUTH_MIDDLEWARE_PATH, origin_mw);
|
|
235
|
+
app.use(AUTH_MIDDLEWARE_PATH, bearer_mw);
|
|
235
236
|
// echo route for assertions — exposes the account-grain identity bearer
|
|
236
237
|
// auth writes (`ACCOUNT_ID_KEY`); the full request context is the
|
|
237
238
|
// dispatcher's authorization phase concern, not middleware.
|
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,18 @@ 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 — without it, such a spec throws, since the surface records
|
|
136
|
+
* the exact patterns the upgrade gate matches.
|
|
137
|
+
*/
|
|
138
|
+
allowed_origins?: ReadonlyArray<RegExp>;
|
|
131
139
|
/** Transform middleware array (e.g., zap's `extend_middleware_for_zap_binary`). */
|
|
132
140
|
transform_middleware?: (specs: Array<MiddlewareSpec>) => Array<MiddlewareSpec>;
|
|
133
141
|
/**
|
|
@@ -152,12 +160,17 @@ export interface CreateTestAppSurfaceSpecOptions {
|
|
|
152
160
|
* file.
|
|
153
161
|
*
|
|
154
162
|
* Mirrors `create_app_server`'s route assembly: consumer routes +
|
|
155
|
-
* factory-managed bootstrap routes + surface generation
|
|
163
|
+
* factory-managed bootstrap routes + surface generation — including its
|
|
164
|
+
* refusal of an `rpc_endpoints` or `ws_endpoints` path outside the auth
|
|
165
|
+
* middleware's scope (`assert_endpoint_in_auth_scope`). If
|
|
156
166
|
* `create_app_server` changes how it wires routes, update this helper
|
|
157
167
|
* to stay in sync (single source of truth for all consumers).
|
|
158
168
|
*
|
|
159
169
|
* @param options - surface spec options
|
|
160
170
|
* @returns the surface spec for the standard suites
|
|
171
|
+
* @throws Error when an endpoint path is outside `AUTH_MIDDLEWARE_PATH`, or a
|
|
172
|
+
* `ws_endpoints` spec declares no `allowed_origins` and no `allowed_origins`
|
|
173
|
+
* option is given
|
|
161
174
|
*/
|
|
162
175
|
export declare const create_test_app_surface_spec: (options: CreateTestAppSurfaceSpecOptions) => AppSurfaceSpec;
|
|
163
176
|
//# 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
|
});
|
|
@@ -211,6 +213,21 @@ export const create_stub_app_server_context = (session_options) => {
|
|
|
211
213
|
audit_sse: null
|
|
212
214
|
};
|
|
213
215
|
};
|
|
216
|
+
/**
|
|
217
|
+
* Narrow a WS endpoint spec that carries its own `allowed_origins` — the only
|
|
218
|
+
* way it resolves when no server list is given to default from.
|
|
219
|
+
*
|
|
220
|
+
* @throws Error when the spec declares no `allowed_origins`
|
|
221
|
+
*/
|
|
222
|
+
const to_self_resolved_ws_endpoint = (endpoint) => {
|
|
223
|
+
const { allowed_origins } = endpoint;
|
|
224
|
+
if (!allowed_origins) {
|
|
225
|
+
throw new Error(`create_test_app_surface_spec: ws endpoint ${endpoint.path} has no allowed_origins — ` +
|
|
226
|
+
"pass the server's list as create_test_app_surface_spec's allowed_origins option, " +
|
|
227
|
+
'or set allowed_origins on the spec');
|
|
228
|
+
}
|
|
229
|
+
return { ...endpoint, allowed_origins };
|
|
230
|
+
};
|
|
214
231
|
/**
|
|
215
232
|
* Create an `AppSurfaceSpec` for the standard testing suites.
|
|
216
233
|
*
|
|
@@ -222,12 +239,17 @@ export const create_stub_app_server_context = (session_options) => {
|
|
|
222
239
|
* file.
|
|
223
240
|
*
|
|
224
241
|
* Mirrors `create_app_server`'s route assembly: consumer routes +
|
|
225
|
-
* factory-managed bootstrap routes + surface generation
|
|
242
|
+
* factory-managed bootstrap routes + surface generation — including its
|
|
243
|
+
* refusal of an `rpc_endpoints` or `ws_endpoints` path outside the auth
|
|
244
|
+
* middleware's scope (`assert_endpoint_in_auth_scope`). If
|
|
226
245
|
* `create_app_server` changes how it wires routes, update this helper
|
|
227
246
|
* to stay in sync (single source of truth for all consumers).
|
|
228
247
|
*
|
|
229
248
|
* @param options - surface spec options
|
|
230
249
|
* @returns the surface spec for the standard suites
|
|
250
|
+
* @throws Error when an endpoint path is outside `AUTH_MIDDLEWARE_PATH`, or a
|
|
251
|
+
* `ws_endpoints` spec declares no `allowed_origins` and no `allowed_origins`
|
|
252
|
+
* option is given
|
|
231
253
|
*/
|
|
232
254
|
export const create_test_app_surface_spec = (options) => {
|
|
233
255
|
const ctx = create_stub_app_server_context(options.session_options);
|
|
@@ -237,14 +259,25 @@ export const create_test_app_surface_spec = (options) => {
|
|
|
237
259
|
const resolved_rpc_endpoints = typeof options.rpc_endpoints === 'function'
|
|
238
260
|
? options.rpc_endpoints(ctx)
|
|
239
261
|
: options.rpc_endpoints;
|
|
240
|
-
const rpc_route_specs = resolved_rpc_endpoints?.flatMap((endpoint) =>
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
262
|
+
const rpc_route_specs = resolved_rpc_endpoints?.flatMap((endpoint) => {
|
|
263
|
+
assert_endpoint_in_auth_scope('rpc_endpoints', endpoint.path);
|
|
264
|
+
return create_rpc_endpoint({
|
|
265
|
+
path: endpoint.path,
|
|
266
|
+
actions: endpoint.actions,
|
|
267
|
+
log: ctx.deps.log,
|
|
268
|
+
action_ip_rate_limiter: ctx.action_ip_rate_limiter,
|
|
269
|
+
action_account_rate_limiter: ctx.action_account_rate_limiter
|
|
270
|
+
});
|
|
271
|
+
}) ?? [];
|
|
245
272
|
// Resolve ws endpoints (mirrors create_app_server). Surface-only —
|
|
246
|
-
// no `register_ws_endpoint` call here, so no `
|
|
247
|
-
const
|
|
273
|
+
// no `register_ws_endpoint` call here, so no `create_upgrade_websocket` needed.
|
|
274
|
+
const declared_ws_endpoints = typeof options.ws_endpoints === 'function' ? options.ws_endpoints(ctx) : options.ws_endpoints;
|
|
275
|
+
const resolved_ws_endpoints = options.allowed_origins
|
|
276
|
+
? resolve_ws_endpoints(declared_ws_endpoints, options.allowed_origins)
|
|
277
|
+
: declared_ws_endpoints?.map(to_self_resolved_ws_endpoint);
|
|
278
|
+
for (const endpoint of resolved_ws_endpoints ?? []) {
|
|
279
|
+
assert_endpoint_in_auth_scope('ws_endpoints', endpoint.path);
|
|
280
|
+
}
|
|
248
281
|
// Bootstrap routes mirror `create_app_server`: mounted for `surface_only`
|
|
249
282
|
// and `live` modes; omitted for `disabled` / undefined. Surface generation
|
|
250
283
|
// 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, {
|
|
@@ -9,28 +9,27 @@ import './assert_dev_env.js';
|
|
|
9
9
|
* token id), and exposes a `connect()` factory returning a `WsClient`
|
|
10
10
|
* per connection.
|
|
11
11
|
*
|
|
12
|
-
*
|
|
12
|
+
* Two layers are exported:
|
|
13
13
|
*
|
|
14
14
|
* - **Primitives** (`create_fake_ws`, `create_fake_hono_context`,
|
|
15
15
|
* `create_stub_upgrade`, `MinimalActionEnvironment`,
|
|
16
16
|
* `dispatch_ws_message`) — used by fuz_app's own dispatcher tests
|
|
17
17
|
* and by consumers wiring tight one-off tests.
|
|
18
|
-
* - **Harness** (`create_ws_test_harness
|
|
19
|
-
*
|
|
18
|
+
* - **Harness** (`create_ws_test_harness`) — the high-level driver.
|
|
19
|
+
* Give it specs + handlers, get back
|
|
20
20
|
* `{transport, connect()}`. `connect()` is async and resolves after
|
|
21
21
|
* `on_socket_open` completes, so broadcasts sent immediately after
|
|
22
22
|
* `await harness.connect()` reach the client. Returns a `WsClient`
|
|
23
23
|
* (shared interface — see `transports/ws_client.ts`); the same
|
|
24
24
|
* interface is implemented by `transports/ws_transport.ts` for
|
|
25
25
|
* cross-process tests.
|
|
26
|
-
* - **Broadcast wiring** — `build_broadcast_api` for wiring a typed
|
|
27
|
-
* broadcast API against the harness's transport. Wire-frame types
|
|
28
|
-
* + predicates (`is_notification`, `is_response_for`,
|
|
29
|
-
* `JsonrpcNotificationFrame`, ...) live in `transports/ws_client.ts`
|
|
30
|
-
* so both in-process and cross-process drivers reference one source.
|
|
31
26
|
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
27
|
+
* Wire-frame types + predicates (`is_notification`, `is_response_for`,
|
|
28
|
+
* `JsonrpcNotificationFrame`, ...) live in `transports/ws_client.ts` so
|
|
29
|
+
* both in-process and cross-process drivers reference one source.
|
|
30
|
+
*
|
|
31
|
+
* Hono's wire upgrade is skipped — there is no server, so no runtime
|
|
32
|
+
* adapter — but the full dispatch path is exercised
|
|
34
33
|
* (per-action auth, input validation, `ctx.notify` back to the
|
|
35
34
|
* originating socket, broadcast via `BackendWebsocketTransport`, and
|
|
36
35
|
* close-on-revoke).
|
|
@@ -87,15 +86,45 @@ export interface FakeHonoContextOptions {
|
|
|
87
86
|
* Only `.get()` is populated — no other Hono context surface is simulated.
|
|
88
87
|
*/
|
|
89
88
|
export declare const create_fake_hono_context: (opts: FakeHonoContextOptions) => Context;
|
|
90
|
-
/**
|
|
89
|
+
/** One upgrade that reached a `create_stub_upgrade` route. */
|
|
90
|
+
export interface StubUpgradeRecord {
|
|
91
|
+
/**
|
|
92
|
+
* The real Hono `Context` the request carried through the app's
|
|
93
|
+
* middleware chain — session, request context, client IP, and every
|
|
94
|
+
* other value the middleware set.
|
|
95
|
+
*/
|
|
96
|
+
c: Context;
|
|
97
|
+
/** The events factory of the endpoint the upgrade reached. */
|
|
98
|
+
create_events: (c: Context) => WSEvents | Promise<WSEvents>;
|
|
99
|
+
}
|
|
100
|
+
/** The return of `create_stub_upgrade` — fake `upgradeWebSocket` + factory and upgrade capture. */
|
|
91
101
|
export interface StubUpgrade {
|
|
92
102
|
upgradeWebSocket: UpgradeWebSocket;
|
|
103
|
+
/**
|
|
104
|
+
* The events factory of the most recently mounted endpoint, for tests
|
|
105
|
+
* that build events from a hand-made context. Throws when no endpoint
|
|
106
|
+
* was mounted.
|
|
107
|
+
*/
|
|
93
108
|
get_create_events: () => (c: Context) => WSEvents | Promise<WSEvents>;
|
|
109
|
+
/**
|
|
110
|
+
* Every upgrade request that reached a stub route, in arrival order —
|
|
111
|
+
* what `app.request(path, {headers: {upgrade: 'websocket', ...}})`
|
|
112
|
+
* leaves behind once the app's middleware passed it. Build the socket's
|
|
113
|
+
* events with `record.create_events(record.c)`.
|
|
114
|
+
*/
|
|
115
|
+
upgrades: ReadonlyArray<StubUpgradeRecord>;
|
|
94
116
|
}
|
|
95
117
|
/**
|
|
96
|
-
* Build a fake `upgradeWebSocket` that
|
|
97
|
-
*
|
|
98
|
-
*
|
|
118
|
+
* Build a fake `upgradeWebSocket` that stands in for a runtime adapter's.
|
|
119
|
+
*
|
|
120
|
+
* Like the real adapters, the middleware it returns passes a request
|
|
121
|
+
* without an `Upgrade: websocket` header to `next()`. An upgrade request
|
|
122
|
+
* is recorded in `upgrades` — its real `Context` and the endpoint's events
|
|
123
|
+
* factory — and answered `200` with no body (a `Response` cannot carry the
|
|
124
|
+
* `101` an adapter answers). No socket is opened: tests drive the recorded
|
|
125
|
+
* factory, so an upgrade sent through `app.request` exercises the app's
|
|
126
|
+
* whole middleware chain — origin, session or bearer, client IP — before
|
|
127
|
+
* the dispatcher sees it.
|
|
99
128
|
*/
|
|
100
129
|
export declare const create_stub_upgrade: () => StubUpgrade;
|
|
101
130
|
/**
|
|
@@ -193,27 +222,4 @@ export interface WsTestHarness {
|
|
|
193
222
|
* `onOpen`/`onMessage`/`onClose` path against a real `WSContext`.
|
|
194
223
|
*/
|
|
195
224
|
export declare const create_ws_test_harness: (options: CreateWsTestHarnessOptions) => WsTestHarness;
|
|
196
|
-
/** Convenience: default identity for keeper-authenticated connections. */
|
|
197
|
-
export declare const keeper_identity: () => WsConnectIdentity;
|
|
198
|
-
/**
|
|
199
|
-
* Wire a typed broadcast API against the harness's transport, matching
|
|
200
|
-
* how a consumer's real backend composes the stack. Returns the typed
|
|
201
|
-
* API so tests can call `.zap_run_created(...)` / `.workspace_changed(...)`
|
|
202
|
-
* etc. directly.
|
|
203
|
-
*
|
|
204
|
-
* ```ts
|
|
205
|
-
* const harness = create_ws_test_harness({actions});
|
|
206
|
-
* const broadcast = build_broadcast_api<MyBackendActionsApi>({
|
|
207
|
-
* harness,
|
|
208
|
-
* specs: my_broadcast_action_specs,
|
|
209
|
-
* });
|
|
210
|
-
* const client = await harness.connect(keeper_identity());
|
|
211
|
-
* await broadcast.zap_run_created({run_id: '...', ...});
|
|
212
|
-
* await client.wait_for(is_notification('zap_run_created'));
|
|
213
|
-
* ```
|
|
214
|
-
*/
|
|
215
|
-
export declare const build_broadcast_api: <TApi extends object>(options: {
|
|
216
|
-
harness: WsTestHarness;
|
|
217
|
-
specs: ReadonlyArray<ActionSpecUnion>;
|
|
218
|
-
}) => TApi;
|
|
219
225
|
//# sourceMappingURL=ws_round_trip.d.ts.map
|