@fuzdev/fuz_app 0.144.0 → 0.146.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 +7 -6
- package/dist/auth/CLAUDE.md +5 -2
- package/dist/auth/account_action_specs.d.ts.map +1 -1
- package/dist/auth/account_action_specs.js +2 -1
- package/dist/auth/account_route_schema.d.ts.map +1 -1
- package/dist/auth/account_route_schema.js +9 -2
- package/dist/auth/account_routes.d.ts +8 -0
- package/dist/auth/account_routes.d.ts.map +1 -1
- package/dist/auth/account_routes.js +12 -3
- package/dist/auth/request_context.d.ts +23 -0
- package/dist/auth/request_context.d.ts.map +1 -1
- package/dist/auth/request_context.js +32 -1
- package/dist/auth/token_scope.d.ts +22 -14
- package/dist/auth/token_scope.d.ts.map +1 -1
- package/dist/auth/token_scope.js +23 -14
- package/dist/db/CLAUDE.md +7 -3
- package/dist/db/fact_ddl.d.ts +1 -0
- package/dist/db/fact_ddl.d.ts.map +1 -1
- package/dist/db/fact_ddl.js +1 -0
- package/dist/db/fact_holder_queries.d.ts +8 -4
- package/dist/db/fact_holder_queries.d.ts.map +1 -1
- package/dist/db/fact_holder_queries.js +9 -5
- package/dist/db/fact_queries.d.ts +10 -7
- package/dist/db/fact_queries.d.ts.map +1 -1
- package/dist/db/fact_queries.js +31 -10
- package/dist/http/CLAUDE.md +2 -2
- package/dist/http/auth_shape.d.ts.map +1 -1
- package/dist/http/auth_shape.js +11 -6
- package/dist/http/proxy.d.ts +22 -21
- package/dist/http/proxy.d.ts.map +1 -1
- package/dist/http/proxy.js +28 -33
- package/dist/http/route_spec.d.ts +8 -0
- package/dist/http/route_spec.d.ts.map +1 -1
- package/dist/server/env.d.ts.map +1 -1
- package/dist/server/env.js +1 -1
- package/dist/server/fact_security_headers.d.ts +59 -0
- package/dist/server/fact_security_headers.d.ts.map +1 -0
- package/dist/server/fact_security_headers.js +58 -0
- package/dist/server/serve_fact_route.d.ts +21 -10
- package/dist/server/serve_fact_route.d.ts.map +1 -1
- package/dist/server/serve_fact_route.js +41 -50
- package/dist/server/x_accel.d.ts +135 -31
- package/dist/server/x_accel.d.ts.map +1 -1
- package/dist/server/x_accel.js +652 -88
- package/dist/testing/CLAUDE.md +73 -22
- package/dist/testing/cross_backend/capabilities.d.ts +0 -18
- package/dist/testing/cross_backend/capabilities.d.ts.map +1 -1
- package/dist/testing/cross_backend/capabilities.js +1 -4
- package/dist/testing/cross_backend/default_backend_configs.d.ts.map +1 -1
- package/dist/testing/cross_backend/default_backend_configs.js +0 -7
- package/dist/testing/cross_backend/fact_serving.d.ts.map +1 -1
- package/dist/testing/cross_backend/fact_serving.js +85 -12
- package/dist/testing/cross_backend/identity_parity.d.ts +11 -0
- package/dist/testing/cross_backend/identity_parity.d.ts.map +1 -1
- package/dist/testing/cross_backend/identity_parity.js +124 -9
- package/dist/testing/cross_backend/login_security.d.ts.map +1 -1
- package/dist/testing/cross_backend/login_security.js +28 -0
- package/dist/testing/cross_backend/revocation_drivers.d.ts +70 -0
- package/dist/testing/cross_backend/revocation_drivers.d.ts.map +1 -0
- package/dist/testing/cross_backend/revocation_drivers.js +104 -0
- package/dist/testing/cross_backend/sse_round_trip.d.ts +6 -1
- package/dist/testing/cross_backend/sse_round_trip.d.ts.map +1 -1
- package/dist/testing/cross_backend/sse_round_trip.js +52 -16
- package/dist/testing/cross_backend/token_scope_surface.d.ts.map +1 -1
- package/dist/testing/cross_backend/token_scope_surface.js +33 -0
- package/dist/testing/cross_backend/ws_round_trip.d.ts +13 -1
- package/dist/testing/cross_backend/ws_round_trip.d.ts.map +1 -1
- package/dist/testing/cross_backend/ws_round_trip.js +155 -39
- package/package.json +3 -3
|
@@ -25,7 +25,9 @@ import '../assert_dev_env.js';
|
|
|
25
25
|
* all-uppercase submission (proves the *lookup* folds case, not merely that
|
|
26
26
|
* the stored form was lowercased).
|
|
27
27
|
* - **whitespace-trim login** — the same shape logs in with surrounding
|
|
28
|
-
* whitespace (proves `.trim()` on the lookup path).
|
|
28
|
+
* whitespace (proves `.trim()` on the lookup path). The trimmed set is JS
|
|
29
|
+
* `String.prototype.trim`'s on every spine: U+FEFF padding is trimmed (→
|
|
30
|
+
* 200, and alone → 400 empty), U+0085 is not (→ 401 lookup miss).
|
|
29
31
|
* - **no Unicode case-fold collision (negative)** — a Turkish-`İ` (U+0130)
|
|
30
32
|
* variant of an existing ASCII username must NOT match. Both JS
|
|
31
33
|
* `.toLowerCase()` and Rust `str::to_lowercase()` map `İ` → `i` + U+0307
|
|
@@ -67,6 +69,10 @@ import '../assert_dev_env.js';
|
|
|
67
69
|
* with mid-string `_`/`-` accepted siblings as the don't-over-reject control.
|
|
68
70
|
* Signup is also strict-object on both spines (TS `z.strictObject`, Rust
|
|
69
71
|
* `#[serde(deny_unknown_fields)]`), so an unknown signup body key → 400.
|
|
72
|
+
* Nothing trims a creation username: a padded one is refused 400 at signup and
|
|
73
|
+
* at bootstrap, never trimmed into a legal name. Bootstrap validates its body
|
|
74
|
+
* before its window check, so the padded bootstrap answers 400 even after
|
|
75
|
+
* bootstrap completed.
|
|
70
76
|
*
|
|
71
77
|
* **Email format (creation):** the optional signup `email` is validated to a
|
|
72
78
|
* loose `local@domain.tld` shape on both spines — TS `Email`
|
|
@@ -78,6 +84,14 @@ import '../assert_dev_env.js';
|
|
|
78
84
|
* share. This closed a real divergence: the Rust spine previously
|
|
79
85
|
* length-checked the email only, accepting any non-empty string TS rejected.
|
|
80
86
|
*
|
|
87
|
+
* **Malformed JSON body:** a body typed `application/json` that doesn't parse
|
|
88
|
+
* (`{`) or parses to anything but an object (`[]`) is 400 `{error:
|
|
89
|
+
* "invalid_json_body"}`, with no other field, on login, signup, bootstrap
|
|
90
|
+
* (`bootstrap_path`, parsed ahead of the window check), and the password change
|
|
91
|
+
* (`password_path`, default `/api/account/password`, `null` to skip) — parsed
|
|
92
|
+
* only after its credential and session-only gates, so the case sends the
|
|
93
|
+
* keeper's session, and an anonymous unparseable body is still 401.
|
|
94
|
+
*
|
|
81
95
|
* Both surfaces are flat REST on every spine, so this is an imperative suite
|
|
82
96
|
* (not a `conformance_table` row) and ungated. Runs both legs via the shared
|
|
83
97
|
* `{setup_test}` protocol: the in-process leg
|
|
@@ -88,8 +102,9 @@ import '../assert_dev_env.js';
|
|
|
88
102
|
* @module
|
|
89
103
|
*/
|
|
90
104
|
import { describe, test, assert } from 'vitest';
|
|
91
|
-
import { ERROR_INVALID_CREDENTIALS, ERROR_INVALID_REQUEST_BODY, ERROR_NO_MATCHING_INVITE } from '../../http/error_schemas.js';
|
|
105
|
+
import { ERROR_ALREADY_BOOTSTRAPPED, ERROR_AUTHENTICATION_REQUIRED, ERROR_INVALID_CREDENTIALS, ERROR_INVALID_JSON_BODY, ERROR_INVALID_REQUEST_BODY, ERROR_NO_MATCHING_INVITE } from '../../http/error_schemas.js';
|
|
92
106
|
import { DEFAULT_TEST_PASSWORD } from '../test_credentials.js';
|
|
107
|
+
import { test_if } from './capabilities.js';
|
|
93
108
|
/**
|
|
94
109
|
* The flat-REST `{error}` reason each terminal login/signup status carries on
|
|
95
110
|
* every spine — the assertion target that turns a status-only check into a
|
|
@@ -245,18 +260,29 @@ export const describe_identity_parity_cross_tests = (options) => {
|
|
|
245
260
|
const { setup_test } = options;
|
|
246
261
|
const login_path = options.login_path ?? '/api/account/login';
|
|
247
262
|
const signup_path = options.signup_path ?? '/api/account/signup';
|
|
263
|
+
const bootstrap_path = options.bootstrap_path === undefined ? '/api/account/bootstrap' : options.bootstrap_path;
|
|
264
|
+
const password_path = options.password_path === undefined ? '/api/account/password' : options.password_path;
|
|
248
265
|
/**
|
|
249
|
-
* POST
|
|
250
|
-
*
|
|
251
|
-
*
|
|
266
|
+
* POST a raw body typed `application/json` on a fresh (cookie-jar-free)
|
|
267
|
+
* transport, with the transport's allowed `Origin`; return the status and
|
|
268
|
+
* the body text. Always reads the body so the cross-process socket releases.
|
|
252
269
|
*/
|
|
253
|
-
const
|
|
270
|
+
const post_raw = async (fixture, path, body, headers) => {
|
|
254
271
|
const res = await fixture.fresh_transport()(path, {
|
|
255
272
|
method: 'POST',
|
|
256
|
-
headers: { 'content-type': 'application/json' },
|
|
257
|
-
body
|
|
273
|
+
headers: { 'content-type': 'application/json', ...headers },
|
|
274
|
+
body
|
|
258
275
|
});
|
|
259
|
-
|
|
276
|
+
return { status: res.status, text: await res.text().catch(() => '') };
|
|
277
|
+
};
|
|
278
|
+
/**
|
|
279
|
+
* POST JSON on a fresh (cookie-jar-free) transport; return the status and the
|
|
280
|
+
* parsed flat-REST `{error}` reason (`undefined` for success / non-JSON
|
|
281
|
+
* bodies).
|
|
282
|
+
*/
|
|
283
|
+
const post_json = async (fixture, path, body) => {
|
|
284
|
+
const res = await post_raw(fixture, path, JSON.stringify(body));
|
|
285
|
+
const text = res.text;
|
|
260
286
|
let error;
|
|
261
287
|
try {
|
|
262
288
|
const parsed = text ? JSON.parse(text) : undefined;
|
|
@@ -311,6 +337,23 @@ export const describe_identity_parity_cross_tests = (options) => {
|
|
|
311
337
|
await fixture.create_account({ username: 'Canon_Trim_User' });
|
|
312
338
|
assert_attempt(await login(fixture, ' canon_trim_user '), 200, 'whitespace-trim login');
|
|
313
339
|
});
|
|
340
|
+
// JS `trim` and Rust `str::trim` disagree on two code points: U+FEFF is JS
|
|
341
|
+
// whitespace but not Unicode `White_Space`, U+0085 the reverse. Every
|
|
342
|
+
// spine trims the JS set (TS is the oracle).
|
|
343
|
+
test('U+FEFF padding is trimmed on lookup → 200', async () => {
|
|
344
|
+
const fixture = await setup_test();
|
|
345
|
+
await fixture.create_account({ username: 'canon_bom_user' });
|
|
346
|
+
assert_attempt(await login(fixture, '\ufeffcanon_bom_user\ufeff'), 200, 'U+FEFF-padded login');
|
|
347
|
+
});
|
|
348
|
+
test('U+FEFF alone is empty after the trim → 400', async () => {
|
|
349
|
+
const fixture = await setup_test();
|
|
350
|
+
assert_attempt(await login(fixture, '\ufeff'), 400, 'U+FEFF-only login');
|
|
351
|
+
});
|
|
352
|
+
test('U+0085 padding is not trimmed → 401 lookup miss', async () => {
|
|
353
|
+
const fixture = await setup_test();
|
|
354
|
+
await fixture.create_account({ username: 'canon_nel_user' });
|
|
355
|
+
assert_attempt(await login(fixture, '\u0085canon_nel_user\u0085'), 401, 'U+0085-padded login');
|
|
356
|
+
});
|
|
314
357
|
test('no Unicode case-fold collision — Turkish İ variant of an ASCII username → 401', async () => {
|
|
315
358
|
const fixture = await setup_test();
|
|
316
359
|
// `canon_admin_user` carries an ASCII `i`; the probe replaces it with İ
|
|
@@ -463,5 +506,77 @@ export const describe_identity_parity_cross_tests = (options) => {
|
|
|
463
506
|
unexpected_field: 'x'
|
|
464
507
|
}), 400, 'signup unknown body key');
|
|
465
508
|
});
|
|
509
|
+
// `Username` validates the raw value — nothing trims a creation username,
|
|
510
|
+
// so padding is refused, never repaired into a legal name (which would
|
|
511
|
+
// reach the invite gate → 403, or create the account under open signup).
|
|
512
|
+
for (const [username, label] of [
|
|
513
|
+
[' padded_signup_user ', 'space padding'],
|
|
514
|
+
['padded_signup_user\t', 'trailing tab'],
|
|
515
|
+
['\u0085padded_signup_user', 'leading U+0085']
|
|
516
|
+
]) {
|
|
517
|
+
test(`padded username (${label}) → 400, not trimmed`, async () => {
|
|
518
|
+
const fixture = await setup_test();
|
|
519
|
+
assert_attempt(await signup(fixture, username), 400, `signup ${label}`);
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
});
|
|
523
|
+
describe('bootstrap input validation parity', () => {
|
|
524
|
+
// The body is validated before the bootstrap window is consulted on every
|
|
525
|
+
// spine (the TS route schema runs ahead of the handler), so a padded
|
|
526
|
+
// username answers 400 `invalid_request_body` even after bootstrap
|
|
527
|
+
// completed — not the closed window's refusal. The unpadded control, the
|
|
528
|
+
// same body otherwise, reaches the closed window, so the 400 can only be
|
|
529
|
+
// the username's.
|
|
530
|
+
test_if(bootstrap_path !== null, 'padded username → 400, not trimmed, ahead of the window check', async () => {
|
|
531
|
+
const fixture = await setup_test();
|
|
532
|
+
const body = {
|
|
533
|
+
token: 'identity-parity-not-the-token',
|
|
534
|
+
username: ' padded_keeper ',
|
|
535
|
+
password: DEFAULT_TEST_PASSWORD
|
|
536
|
+
};
|
|
537
|
+
assert_attempt(await post_json(fixture, bootstrap_path, body), 400, 'bootstrap padded username');
|
|
538
|
+
const control = await post_json(fixture, bootstrap_path, {
|
|
539
|
+
...body,
|
|
540
|
+
username: 'padded_keeper'
|
|
541
|
+
});
|
|
542
|
+
assert.deepStrictEqual(control, { status: 403, error: ERROR_ALREADY_BOOTSTRAPPED }, 'bootstrap unpadded control');
|
|
543
|
+
});
|
|
544
|
+
});
|
|
545
|
+
describe('malformed JSON body parity', () => {
|
|
546
|
+
// A body typed `application/json` that doesn't parse, or parses to
|
|
547
|
+
// anything but an object, is 400 `invalid_json_body` on every spine —
|
|
548
|
+
// never the schema's `invalid_request_body`, and with no other field.
|
|
549
|
+
// Bootstrap parses ahead of its window check (so the 400 holds after
|
|
550
|
+
// bootstrap completed); the password change parses only after its
|
|
551
|
+
// credential and session-only gates, so it needs a session, and an
|
|
552
|
+
// anonymous caller still hears 401.
|
|
553
|
+
const MALFORMED_BODIES = [
|
|
554
|
+
['{', 'unparseable'],
|
|
555
|
+
['[]', 'a non-object array']
|
|
556
|
+
];
|
|
557
|
+
const assert_invalid_json_body = (got, label) => {
|
|
558
|
+
assert.strictEqual(got.status, 400, `${label} — status: ${got.text}`);
|
|
559
|
+
assert.deepStrictEqual(JSON.parse(got.text), { error: ERROR_INVALID_JSON_BODY }, `${label} — body`);
|
|
560
|
+
};
|
|
561
|
+
const routes = [
|
|
562
|
+
{ name: 'login', path: login_path, session: false },
|
|
563
|
+
{ name: 'signup', path: signup_path, session: false },
|
|
564
|
+
{ name: 'bootstrap', path: bootstrap_path, session: false },
|
|
565
|
+
{ name: 'password', path: password_path, session: true }
|
|
566
|
+
];
|
|
567
|
+
for (const { name, path, session } of routes) {
|
|
568
|
+
for (const [body, label] of MALFORMED_BODIES) {
|
|
569
|
+
test_if(path !== null, `${name}: ${label} body → 400 invalid_json_body`, async () => {
|
|
570
|
+
const fixture = await setup_test();
|
|
571
|
+
assert_invalid_json_body(await post_raw(fixture, path, body, session ? fixture.create_session_headers() : undefined), `${name} ${label} body`);
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
test_if(password_path !== null, 'password: an unparseable body without a session → 401, the gates ahead of the parse', async () => {
|
|
576
|
+
const fixture = await setup_test();
|
|
577
|
+
const got = await post_raw(fixture, password_path, '{');
|
|
578
|
+
assert.strictEqual(got.status, 401, `anonymous unparseable password body: ${got.text}`);
|
|
579
|
+
assert.strictEqual(JSON.parse(got.text).error, ERROR_AUTHENTICATION_REQUIRED);
|
|
580
|
+
});
|
|
466
581
|
});
|
|
467
582
|
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"login_security.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/login_security.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;
|
|
1
|
+
{"version":3,"file":"login_security.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/login_security.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAsF9B,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAK5C,mDAAmD;AACnD,MAAM,WAAW,6BAA6B;IAC7C,2EAA2E;IAC3E,QAAQ,CAAC,UAAU,EAAE,SAAS,CAAC;IAC/B,kFAAkF;IAClF,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;IAC7B,wEAAwE;IACxE,QAAQ,CAAC,aAAa,CAAC,EAAE,MAAM,CAAC;IAChC;;;;;;OAMG;IACH,QAAQ,CAAC,uBAAuB,CAAC,EAAE,OAAO,CAAC;IAC3C;;;;OAIG;IACH,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CAClC;AAmDD,eAAO,MAAM,mCAAmC,GAC/C,SAAS,6BAA6B,KACpC,IA8XF,CAAC"}
|
|
@@ -44,6 +44,12 @@ import '../assert_dev_env.js';
|
|
|
44
44
|
* after exhausting one forwarded IP, a raw request carrying a spoofed line
|
|
45
45
|
* first and that IP last still `429`s. Raw socket only (`fetch` merges
|
|
46
46
|
* repeated headers into one line), so it runs when `base_url` is given.
|
|
47
|
+
* - **a malformed hop stops the walk** — a trusted proxy that appends a
|
|
48
|
+
* non-bare-IP token (`ip:port` here) leaves the client's own entry to its
|
|
49
|
+
* left. After exhausting one forwarded IP, a chain with that IP left of an
|
|
50
|
+
* `ip:port` entry answers `401`, not `429`: the walk stopped at the malformed
|
|
51
|
+
* entry and keyed the request on the connection IP (its audit row says so),
|
|
52
|
+
* never on the client-written entry past it.
|
|
47
53
|
* - **a success does not refund the per-IP budget** — the distributed-spray
|
|
48
54
|
* backstop. Failed guesses against *distinct* usernames from one forwarded
|
|
49
55
|
* IP, interleaved with the attacker's own successful logins, must still 429
|
|
@@ -90,6 +96,9 @@ const XFF_SPRAY = '203.0.113.4';
|
|
|
90
96
|
const XFF_BURST = '203.0.113.5';
|
|
91
97
|
const XFF_CORRECT_BURST = '203.0.113.7';
|
|
92
98
|
const XFF_REPEATED_LINES = '203.0.113.6';
|
|
99
|
+
const XFF_MALFORMED_HOP = '203.0.113.8';
|
|
100
|
+
/** What a trusted hop that appends `ip:port` (an Azure App Gateway, IIS ARR) writes. */
|
|
101
|
+
const XFF_PORT_SUFFIXED_HOP = '198.51.100.7:5555';
|
|
93
102
|
/** RFC 5737 TEST-NET-2 — the address a client writes into its own first line. */
|
|
94
103
|
const XFF_SPOOFED_LINE = '198.51.100.77';
|
|
95
104
|
/**
|
|
@@ -193,6 +202,25 @@ export const describe_login_security_cross_tests = (options) => {
|
|
|
193
202
|
const fresh = await attempt(fixture, XFF_SEGREGATION_FRESH, 'probe_xff');
|
|
194
203
|
assert.strictEqual(fresh.status, 401, "a fresh forwarded IP must not inherit another IP's limit");
|
|
195
204
|
});
|
|
205
|
+
test('a malformed hop stops the walk: the request keys on the connection IP', async () => {
|
|
206
|
+
const fixture = await setup_test();
|
|
207
|
+
for (let i = 0; i < ip_limit; i++) {
|
|
208
|
+
await attempt(fixture, XFF_MALFORMED_HOP, 'probe_malformed_hop');
|
|
209
|
+
}
|
|
210
|
+
const exhausted = await attempt(fixture, XFF_MALFORMED_HOP, 'probe_malformed_hop');
|
|
211
|
+
assert.strictEqual(exhausted.status, 429, 'the exhausted forwarded IP must be rate-limited');
|
|
212
|
+
// The client-written entry sits left of the hop's malformed one. A walk
|
|
213
|
+
// that skipped the malformed entry would key on it again → 429; one
|
|
214
|
+
// that stops keys on the (loopback) connection IP → 401.
|
|
215
|
+
const stopped = await attempt(fixture, `${XFF_MALFORMED_HOP}, ${XFF_PORT_SUFFIXED_HOP}`, 'probe_malformed_hop');
|
|
216
|
+
assert.strictEqual(stopped.status, 401, 'the walk must stop at the malformed hop, not reach the entry left of it');
|
|
217
|
+
const drained = await cross_rpc_call(fixture.fresh_transport({ origin: null }), SPINE_RPC_PATH, '_testing_drain_effects', undefined, fixture.create_daemon_token_headers());
|
|
218
|
+
assert.ok(drained.ok, `_testing_drain_effects failed: ${JSON.stringify(drained.error)}`);
|
|
219
|
+
const listed = expect_output(await cross_rpc_call(fixture.transport, SPINE_RPC_PATH, 'audit_log_list', { event_type: 'login', outcome: 'failure' }, fixture.create_session_headers()), AuditLogListOutput);
|
|
220
|
+
const ips = listed.events.map((e) => e.ip);
|
|
221
|
+
assert.ok(ips.some((ip) => ip === '127.0.0.1' || ip === '::1'), `the stopped walk must key on the loopback connection IP: ${JSON.stringify(ips)}`);
|
|
222
|
+
assert.ok(!ips.includes(XFF_PORT_SUFFIXED_HOP), `a malformed entry is never a client IP: ${JSON.stringify(ips)}`);
|
|
223
|
+
});
|
|
196
224
|
test('IPv6 clients share a bucket per /64, and the audit row keeps the full address', async () => {
|
|
197
225
|
const fixture = await setup_test();
|
|
198
226
|
for (let i = 0; i < ip_limit; i++) {
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import '../assert_dev_env.js';
|
|
2
|
+
import type { BackendCapabilities } from './capabilities.js';
|
|
3
|
+
import type { TestAccountFixture, TestFixture } from './setup.js';
|
|
4
|
+
/**
|
|
5
|
+
* POST a JSON body to a REST route on `account`'s session, asserting `200`.
|
|
6
|
+
*
|
|
7
|
+
* @param fixture - the per-test fixture whose fresh transport carries the request
|
|
8
|
+
* @param account - the account whose session cookie authenticates it
|
|
9
|
+
* @param path - the REST route path
|
|
10
|
+
* @param body - the JSON request body
|
|
11
|
+
*/
|
|
12
|
+
export declare const cross_post_as: (fixture: TestFixture, account: TestAccountFixture, path: string, body: unknown) => Promise<void>;
|
|
13
|
+
/**
|
|
14
|
+
* Revoke every API token of `account_id` over the keeper's admin session
|
|
15
|
+
* (`admin_token_revoke_all`), asserting success.
|
|
16
|
+
*
|
|
17
|
+
* @param fixture - the per-test fixture; its keeper holds the admin role
|
|
18
|
+
* @param rpc_path - the RPC endpoint path
|
|
19
|
+
* @param account_id - the account whose tokens to revoke
|
|
20
|
+
*/
|
|
21
|
+
export declare const cross_admin_token_revoke_all: (fixture: TestFixture, rpc_path: string, account_id: string) => Promise<void>;
|
|
22
|
+
/**
|
|
23
|
+
* Purge `account_id` over the keeper's daemon token (`account_purge`),
|
|
24
|
+
* asserting success. The request carries no `Origin`, as a daemon-token
|
|
25
|
+
* caller's wouldn't.
|
|
26
|
+
*
|
|
27
|
+
* @param fixture - the per-test fixture
|
|
28
|
+
* @param rpc_path - the RPC endpoint path
|
|
29
|
+
* @param account_id - the account to purge
|
|
30
|
+
*/
|
|
31
|
+
export declare const cross_account_purge: (fixture: TestFixture, rpc_path: string, account_id: string) => Promise<void>;
|
|
32
|
+
/** One account-wide revocation, as a row both transport suites iterate. */
|
|
33
|
+
export interface AccountRevocation {
|
|
34
|
+
/** Names the revocation in a case name and a failure message. */
|
|
35
|
+
readonly label: string;
|
|
36
|
+
/**
|
|
37
|
+
* Distinguishes the target account's username — unique in the table, and
|
|
38
|
+
* short enough that a suite's prefix keeps the username within `Username`.
|
|
39
|
+
*/
|
|
40
|
+
readonly slug: string;
|
|
41
|
+
/** Whether the backend serves the revocation (beyond `rpc_path`, which every row needs). */
|
|
42
|
+
readonly gate: boolean;
|
|
43
|
+
/**
|
|
44
|
+
* Revoke `target`'s credentials and assert the revocation succeeded.
|
|
45
|
+
* `target` must have been created with `DEFAULT_TEST_PASSWORD`, which the
|
|
46
|
+
* password-change row presents.
|
|
47
|
+
*/
|
|
48
|
+
readonly revoke: (fixture: TestFixture, target: TestAccountFixture) => Promise<void>;
|
|
49
|
+
}
|
|
50
|
+
/** Configuration for `create_account_revocations`. */
|
|
51
|
+
export interface AccountRevocationsOptions {
|
|
52
|
+
/** Backend capability flags; `account_purge` gates on `account_lifecycle`. */
|
|
53
|
+
readonly capabilities: BackendCapabilities;
|
|
54
|
+
/** RPC endpoint path the admin and keeper revocations are sent to. */
|
|
55
|
+
readonly rpc_path: string;
|
|
56
|
+
/** REST logout route path. */
|
|
57
|
+
readonly logout_path: string;
|
|
58
|
+
/** REST password-change route path. */
|
|
59
|
+
readonly password_path: string;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* The account-wide revocations that close every connection of the target
|
|
63
|
+
* account: `admin_token_revoke_all`, `POST /logout`, `POST /password`, and
|
|
64
|
+
* `account_purge` (gated on `capabilities.account_lifecycle`).
|
|
65
|
+
*
|
|
66
|
+
* @param options - the backend's capabilities and route paths
|
|
67
|
+
* @returns the revocation rows, in a stable order
|
|
68
|
+
*/
|
|
69
|
+
export declare const create_account_revocations: (options: AccountRevocationsOptions) => ReadonlyArray<AccountRevocation>;
|
|
70
|
+
//# sourceMappingURL=revocation_drivers.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"revocation_drivers.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/revocation_drivers.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AA4B9B,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,mBAAmB,CAAC;AAE7D,OAAO,KAAK,EAAE,kBAAkB,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAElE;;;;;;;GAOG;AACH,eAAO,MAAM,aAAa,GACzB,SAAS,WAAW,EACpB,SAAS,kBAAkB,EAC3B,MAAM,MAAM,EACZ,MAAM,OAAO,KACX,OAAO,CAAC,IAAI,CAOd,CAAC;AAEF;;;;;;;GAOG;AACH,eAAO,MAAM,4BAA4B,GACxC,SAAS,WAAW,EACpB,UAAU,MAAM,EAChB,YAAY,MAAM,KAChB,OAAO,CAAC,IAAI,CASd,CAAC;AAEF;;;;;;;;GAQG;AACH,eAAO,MAAM,mBAAmB,GAC/B,SAAS,WAAW,EACpB,UAAU,MAAM,EAChB,YAAY,MAAM,KAChB,OAAO,CAAC,IAAI,CASd,CAAC;AAEF,2EAA2E;AAC3E,MAAM,WAAW,iBAAiB;IACjC,iEAAiE;IACjE,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB;;;OAGG;IACH,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,4FAA4F;IAC5F,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAC;IACvB;;;;OAIG;IACH,QAAQ,CAAC,MAAM,EAAE,CAAC,OAAO,EAAE,WAAW,EAAE,MAAM,EAAE,kBAAkB,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CACrF;AAED,sDAAsD;AACtD,MAAM,WAAW,yBAAyB;IACzC,8EAA8E;IAC9E,QAAQ,CAAC,YAAY,EAAE,mBAAmB,CAAC;IAC3C,sEAAsE;IACtE,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,8BAA8B;IAC9B,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,uCAAuC;IACvC,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;CAC/B;AAED;;;;;;;GAOG;AACH,eAAO,MAAM,0BAA0B,GACtC,SAAS,yBAAyB,KAChC,aAAa,CAAC,iBAAiB,CAiCjC,CAAC"}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import '../assert_dev_env.js';
|
|
2
|
+
/**
|
|
3
|
+
* The account-wide revocations the cross-process WS and SSE round-trip suites
|
|
4
|
+
* both close a live connection on — one table, so a revocation added here is
|
|
5
|
+
* covered on both transports.
|
|
6
|
+
*
|
|
7
|
+
* Each row drives one revocation of a target account's credentials and asserts
|
|
8
|
+
* it succeeded; the suite opens the target's connections first and asserts
|
|
9
|
+
* their close after. `describe_cross_process_ws_tests` and
|
|
10
|
+
* `describe_cross_process_sse_tests` iterate `create_account_revocations`. The
|
|
11
|
+
* revocations whose close is narrower than the account (one session, one
|
|
12
|
+
* token, a cap eviction) or that a suite drives differently (session
|
|
13
|
+
* revoke-all, account delete) stay in their suites.
|
|
14
|
+
*
|
|
15
|
+
* The drivers are exported on their own for a consumer suite that closes a
|
|
16
|
+
* transport of its own on the same revocations.
|
|
17
|
+
*
|
|
18
|
+
* @module
|
|
19
|
+
*/
|
|
20
|
+
import { assert } from 'vitest';
|
|
21
|
+
import { account_purge_action_spec, admin_token_revoke_all_action_spec } from '../../auth/admin_action_specs.js';
|
|
22
|
+
import { DEFAULT_TEST_PASSWORD } from '../test_credentials.js';
|
|
23
|
+
import { cross_rpc_call } from './cell_cross_helpers.js';
|
|
24
|
+
/**
|
|
25
|
+
* POST a JSON body to a REST route on `account`'s session, asserting `200`.
|
|
26
|
+
*
|
|
27
|
+
* @param fixture - the per-test fixture whose fresh transport carries the request
|
|
28
|
+
* @param account - the account whose session cookie authenticates it
|
|
29
|
+
* @param path - the REST route path
|
|
30
|
+
* @param body - the JSON request body
|
|
31
|
+
*/
|
|
32
|
+
export const cross_post_as = async (fixture, account, path, body) => {
|
|
33
|
+
const res = await fixture.fresh_transport()(path, {
|
|
34
|
+
method: 'POST',
|
|
35
|
+
headers: { 'content-type': 'application/json', ...account.create_session_headers() },
|
|
36
|
+
body: JSON.stringify(body)
|
|
37
|
+
});
|
|
38
|
+
assert.strictEqual(res.status, 200, `POST ${path}: ${await res.text()}`);
|
|
39
|
+
};
|
|
40
|
+
/**
|
|
41
|
+
* Revoke every API token of `account_id` over the keeper's admin session
|
|
42
|
+
* (`admin_token_revoke_all`), asserting success.
|
|
43
|
+
*
|
|
44
|
+
* @param fixture - the per-test fixture; its keeper holds the admin role
|
|
45
|
+
* @param rpc_path - the RPC endpoint path
|
|
46
|
+
* @param account_id - the account whose tokens to revoke
|
|
47
|
+
*/
|
|
48
|
+
export const cross_admin_token_revoke_all = async (fixture, rpc_path, account_id) => {
|
|
49
|
+
const res = await cross_rpc_call(fixture.transport, rpc_path, admin_token_revoke_all_action_spec.method, { account_id }, fixture.create_session_headers());
|
|
50
|
+
assert.ok(res.ok, `admin_token_revoke_all failed: ${JSON.stringify(res.error)}`);
|
|
51
|
+
};
|
|
52
|
+
/**
|
|
53
|
+
* Purge `account_id` over the keeper's daemon token (`account_purge`),
|
|
54
|
+
* asserting success. The request carries no `Origin`, as a daemon-token
|
|
55
|
+
* caller's wouldn't.
|
|
56
|
+
*
|
|
57
|
+
* @param fixture - the per-test fixture
|
|
58
|
+
* @param rpc_path - the RPC endpoint path
|
|
59
|
+
* @param account_id - the account to purge
|
|
60
|
+
*/
|
|
61
|
+
export const cross_account_purge = async (fixture, rpc_path, account_id) => {
|
|
62
|
+
const res = await cross_rpc_call(fixture.fresh_transport({ origin: null }), rpc_path, account_purge_action_spec.method, { account_id, confirm: true }, fixture.create_daemon_token_headers());
|
|
63
|
+
assert.ok(res.ok, `account_purge failed: ${JSON.stringify(res.error)}`);
|
|
64
|
+
};
|
|
65
|
+
/**
|
|
66
|
+
* The account-wide revocations that close every connection of the target
|
|
67
|
+
* account: `admin_token_revoke_all`, `POST /logout`, `POST /password`, and
|
|
68
|
+
* `account_purge` (gated on `capabilities.account_lifecycle`).
|
|
69
|
+
*
|
|
70
|
+
* @param options - the backend's capabilities and route paths
|
|
71
|
+
* @returns the revocation rows, in a stable order
|
|
72
|
+
*/
|
|
73
|
+
export const create_account_revocations = (options) => {
|
|
74
|
+
const { capabilities, rpc_path, logout_path, password_path } = options;
|
|
75
|
+
return [
|
|
76
|
+
{
|
|
77
|
+
label: 'admin_token_revoke_all',
|
|
78
|
+
slug: 'token_revoke_all',
|
|
79
|
+
gate: true,
|
|
80
|
+
revoke: (fixture, target) => cross_admin_token_revoke_all(fixture, rpc_path, target.account.id)
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
label: 'POST /logout',
|
|
84
|
+
slug: 'logout',
|
|
85
|
+
gate: true,
|
|
86
|
+
revoke: (fixture, target) => cross_post_as(fixture, target, logout_path, {})
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
label: 'POST /password',
|
|
90
|
+
slug: 'password',
|
|
91
|
+
gate: true,
|
|
92
|
+
revoke: (fixture, target) => cross_post_as(fixture, target, password_path, {
|
|
93
|
+
current_password: DEFAULT_TEST_PASSWORD,
|
|
94
|
+
new_password: 'revoked-new-password-456'
|
|
95
|
+
})
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
label: 'account_purge',
|
|
99
|
+
slug: 'purge',
|
|
100
|
+
gate: capabilities.account_lifecycle,
|
|
101
|
+
revoke: (fixture, target) => cross_account_purge(fixture, rpc_path, target.account.id)
|
|
102
|
+
}
|
|
103
|
+
];
|
|
104
|
+
};
|
|
@@ -34,12 +34,17 @@ export interface CrossProcessSseTestOptions {
|
|
|
34
34
|
* skips the case.
|
|
35
35
|
*/
|
|
36
36
|
readonly max_per_scope?: number | null;
|
|
37
|
+
/** REST logout route path, for the logout close case. Default `/api/account/logout`. */
|
|
38
|
+
readonly logout_path?: string;
|
|
39
|
+
/** REST password-change route path, for its close case. Default `/api/account/password`. */
|
|
40
|
+
readonly password_path?: string;
|
|
37
41
|
}
|
|
38
42
|
/**
|
|
39
43
|
* Register the cross-process SSE round-trip suite. Its cases run over a
|
|
40
44
|
* real streaming `fetch`: connected-comment, audit data frame, account-wide
|
|
41
45
|
* close-on-revoke, session-scoped close-on-revoke, close on account delete,
|
|
42
|
-
*
|
|
46
|
+
* close on admin token revoke-all, logout, password change and purge, and the
|
|
47
|
+
* per-session cap.
|
|
43
48
|
*/
|
|
44
49
|
export declare const describe_cross_process_sse_tests: (options: CrossProcessSseTestOptions) => void;
|
|
45
50
|
//# sourceMappingURL=sse_round_trip.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sse_round_trip.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/sse_round_trip.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;
|
|
1
|
+
{"version":3,"file":"sse_round_trip.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/sse_round_trip.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAkF9B,OAAO,EAAE,KAAK,mBAAmB,EAAW,MAAM,mBAAmB,CAAC;AAEtE,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAK5C,4DAA4D;AAC5D,MAAM,WAAW,0BAA0B;IAC1C;;;;;;OAMG;IACH,QAAQ,CAAC,UAAU,EAAE,SAAS,CAAC;IAC/B,wEAAwE;IACxE,QAAQ,CAAC,YAAY,EAAE,mBAAmB,CAAC;IAC3C,2EAA2E;IAC3E,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,6EAA6E;IAC7E,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B;;;;;;OAMG;IACH,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B,6DAA6D;IAC7D,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB;;;;;OAKG;IACH,QAAQ,CAAC,aAAa,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACvC,wFAAwF;IACxF,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B,4FAA4F;IAC5F,QAAQ,CAAC,aAAa,CAAC,EAAE,MAAM,CAAC;CAChC;AA0BD;;;;;;GAMG;AACH,eAAO,MAAM,gCAAgC,GAAI,SAAS,0BAA0B,KAAG,IAyStF,CAAC"}
|
|
@@ -35,18 +35,21 @@ import '../assert_dev_env.js';
|
|
|
35
35
|
* `capabilities.account_lifecycle`) — the keeper soft-deletes a second
|
|
36
36
|
* admin whose stream is open; the delete revokes its credentials and
|
|
37
37
|
* closes its stream.
|
|
38
|
-
* 6. **
|
|
38
|
+
* 6. **close on the other account-wide revocations** (gated on `rpc_path`;
|
|
39
|
+
* purge also on `capabilities.account_lifecycle`) — `admin_token_revoke_all`,
|
|
40
|
+
* `POST /logout`, `POST /password`, and `account_purge` of a second admin
|
|
41
|
+
* each close its stream — the rows `create_account_revocations` shares with
|
|
42
|
+
* the WS suite.
|
|
43
|
+
* 7. **per-session cap** — one stream past `max_per_scope` on one session
|
|
39
44
|
* closes the oldest (evict-oldest, no final frame) and leaves every other
|
|
40
45
|
* stream open. Needs no RPC: the cap runs inside the subscribe itself.
|
|
41
46
|
*
|
|
42
|
-
* The close-on-revoke matrix is layered: cases 3–
|
|
43
|
-
* and session-scoped paths cross-process;
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
* role-matched) are covered by the spine's `fuz_realtime` SSE-registry unit
|
|
47
|
-
* tests and the in-process guard self-test
|
|
48
|
-
* `token_revoke_all`-with-zero-tokens case (which may emit no audit row) stays
|
|
49
|
-
* out to keep the spawned-backend suite non-flaky.
|
|
47
|
+
* The close-on-revoke matrix is layered: cases 3–6 exercise the account-wide
|
|
48
|
+
* and session-scoped paths cross-process; `token_revoke` (token-scoped — the
|
|
49
|
+
* stock stream admits sessions only, so its bearer close is covered on the
|
|
50
|
+
* WebSocket by `describe_cross_process_ws_tests`) and `role_grant_revoke`
|
|
51
|
+
* (role-matched) are covered by the spine's `fuz_realtime` SSE-registry unit
|
|
52
|
+
* tests and the in-process guard self-test.
|
|
50
53
|
*
|
|
51
54
|
* Gated on `capabilities.sse` — backends without an end-to-end SSE stream
|
|
52
55
|
* skip (the cases still surface as `.skip` in the report). Cross-process
|
|
@@ -63,7 +66,9 @@ import { AUDIT_LOG_SSE_MAX_PER_SCOPE, audit_log_event_specs } from '../../realti
|
|
|
63
66
|
import { SSE_CONNECTED_COMMENT } from '../../realtime/sse_constants.js';
|
|
64
67
|
import { create_sse_transport } from '../transports/sse_transport.js';
|
|
65
68
|
import { create_rpc_post_init } from '../rpc_helpers.js';
|
|
69
|
+
import { DEFAULT_TEST_PASSWORD } from '../test_credentials.js';
|
|
66
70
|
import { test_if } from './capabilities.js';
|
|
71
|
+
import { create_account_revocations } from './revocation_drivers.js';
|
|
67
72
|
/** Default audit-log SSE stream path — the standard fuz_app `/api/admin/audit/stream`. */
|
|
68
73
|
const DEFAULT_SSE_PATH = '/api/admin/audit/stream';
|
|
69
74
|
/**
|
|
@@ -85,10 +90,11 @@ const assert_audit_data_frame = (frame) => {
|
|
|
85
90
|
* Register the cross-process SSE round-trip suite. Its cases run over a
|
|
86
91
|
* real streaming `fetch`: connected-comment, audit data frame, account-wide
|
|
87
92
|
* close-on-revoke, session-scoped close-on-revoke, close on account delete,
|
|
88
|
-
*
|
|
93
|
+
* close on admin token revoke-all, logout, password change and purge, and the
|
|
94
|
+
* per-session cap.
|
|
89
95
|
*/
|
|
90
96
|
export const describe_cross_process_sse_tests = (options) => {
|
|
91
|
-
const { setup_test, capabilities, base_url, rpc_path, origin } = options;
|
|
97
|
+
const { setup_test, capabilities, base_url, rpc_path, origin, logout_path = '/api/account/logout', password_path = '/api/account/password' } = options;
|
|
92
98
|
const sse_path = options.sse_path ?? DEFAULT_SSE_PATH;
|
|
93
99
|
const max_per_scope = options.max_per_scope === undefined ? AUDIT_LOG_SSE_MAX_PER_SCOPE : options.max_per_scope;
|
|
94
100
|
describe('cross-process sse', () => {
|
|
@@ -166,11 +172,8 @@ export const describe_cross_process_sse_tests = (options) => {
|
|
|
166
172
|
// TS registry's `close_by_identity(session_hash)`). The keeper holds
|
|
167
173
|
// exactly one session, so revoking it by its blake3 hash drops the stream
|
|
168
174
|
// opened under it. This is the close-on-revoke path the account-wide case
|
|
169
|
-
// above doesn't exercise;
|
|
170
|
-
//
|
|
171
|
-
// share the close the `session_revoke_all` and account-delete cases cover,
|
|
172
|
-
// and `token_revoke`'s token-scoped and `role_grant_revoke`'s role-matched
|
|
173
|
-
// closes are covered by `fuz_realtime`'s SSE registry unit tests.
|
|
175
|
+
// above doesn't exercise; `role_grant_revoke`'s role-matched close is
|
|
176
|
+
// covered by `fuz_realtime`'s SSE registry unit tests.
|
|
174
177
|
test_if(capabilities.sse && rpc_path !== undefined, 'stream closes on a single session_revoke of the subscriber session', async () => {
|
|
175
178
|
const fixture = await setup_test();
|
|
176
179
|
const sse = await create_sse_transport({
|
|
@@ -223,6 +226,39 @@ export const describe_cross_process_sse_tests = (options) => {
|
|
|
223
226
|
await sse.close();
|
|
224
227
|
}
|
|
225
228
|
});
|
|
229
|
+
// The account-wide revocations past session revoke-all and delete
|
|
230
|
+
// (`revocation_drivers.ts`, shared with the WS suite) each close the
|
|
231
|
+
// target admin's stream. Without `rpc_path` the rows still register, as
|
|
232
|
+
// skipped cases, and never run — so the empty path is never sent.
|
|
233
|
+
const revocations = create_account_revocations({
|
|
234
|
+
capabilities,
|
|
235
|
+
rpc_path: rpc_path ?? '',
|
|
236
|
+
logout_path,
|
|
237
|
+
password_path
|
|
238
|
+
});
|
|
239
|
+
for (const { label, slug, gate, revoke } of revocations) {
|
|
240
|
+
test_if(capabilities.sse && gate && rpc_path !== undefined, `stream closes on ${label} of the subscriber account`, async () => {
|
|
241
|
+
const fixture = await setup_test();
|
|
242
|
+
const target = await fixture.create_account({
|
|
243
|
+
username: `sse_revoke_${slug}`,
|
|
244
|
+
password_value: DEFAULT_TEST_PASSWORD,
|
|
245
|
+
roles: [ROLE_ADMIN]
|
|
246
|
+
});
|
|
247
|
+
const cookie = target.create_session_headers().cookie;
|
|
248
|
+
assert.ok(cookie, 'expected a session cookie for the target account');
|
|
249
|
+
const sse = await create_sse_transport({ base_url, sse_path, cookies: [cookie], origin });
|
|
250
|
+
try {
|
|
251
|
+
const first = await sse.read_frame();
|
|
252
|
+
assert.strictEqual(first + '\n\n', SSE_CONNECTED_COMMENT, 'first frame must be the connected comment');
|
|
253
|
+
await revoke(fixture, target);
|
|
254
|
+
const closed = await sse.wait_for_close(2000);
|
|
255
|
+
assert.ok(closed, `stream did not close within 2s after ${label}`);
|
|
256
|
+
}
|
|
257
|
+
finally {
|
|
258
|
+
await sse.close();
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
}
|
|
226
262
|
// One stream past the cap on one session → the oldest closes, the newest
|
|
227
263
|
// stays open. Opened in sequence, each awaited to its connect comment, so
|
|
228
264
|
// the server registered them in order and "oldest" is stream 0 on both
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"token_scope_surface.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/token_scope_surface.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;
|
|
1
|
+
{"version":3,"file":"token_scope_surface.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/token_scope_surface.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAgF9B,OAAO,KAAK,EAAE,uCAAuC,EAAE,MAAM,YAAY,CAAC;AAqB1E,wDAAwD;AACxD,MAAM,WAAW,iCAAkC,SAAQ,uCAAuC;IACjG,6DAA6D;IAC7D,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;CAC3B;AA6DD,eAAO,MAAM,wCAAwC,GACpD,SAAS,iCAAiC,KACxC,IA+JF,CAAC"}
|
|
@@ -35,6 +35,11 @@ import '../assert_dev_env.js';
|
|
|
35
35
|
* `surface:audit_stream`'s own capability string is consequently unreachable
|
|
36
36
|
* on the default mount and has no cross-impl pin — each spine's census holds
|
|
37
37
|
* its declaration, the way the WS upgrade's is held.
|
|
38
|
+
* - **The account status read answers a narrowed token as anonymous** —
|
|
39
|
+
* `GET /api/account/status` answers anonymous callers, so rule 3 there is the
|
|
40
|
+
* anonymous answer (`401` plus the bootstrap flag), byte for byte, never the
|
|
41
|
+
* account's email, its verification, or its role grants. A full-scope bearer
|
|
42
|
+
* reading the account is the control.
|
|
38
43
|
* - **Scope outranks role** — a narrowed token whose account *also* lacks the
|
|
39
44
|
* gating role must hear `token_scope_required`, not
|
|
40
45
|
* `insufficient_permissions`. This is the observable the TS↔Rust ordering
|
|
@@ -70,6 +75,8 @@ import { mint_narrowed_api_token } from './narrowed_api_token.js';
|
|
|
70
75
|
import { SPINE_RPC_PATH } from './spine_surface_constants.js';
|
|
71
76
|
/** Default audit-log SSE path — the `audit_stream` surface. */
|
|
72
77
|
const DEFAULT_SSE_PATH = '/api/admin/audit/stream';
|
|
78
|
+
/** The account status read, which answers a narrowed token as anonymous. */
|
|
79
|
+
const STATUS_PATH = '/api/account/status';
|
|
73
80
|
/**
|
|
74
81
|
* A well-formed hash that no fact has. The scope gate is a pre-authorization
|
|
75
82
|
* guard, so it fires before the handler ever looks the hash up — but *params*
|
|
@@ -172,6 +179,32 @@ export const describe_token_scope_surface_cross_tests = (options) => {
|
|
|
172
179
|
const body = (await res.json().catch(() => undefined));
|
|
173
180
|
assert.notStrictEqual(body?.error, ERROR_TOKEN_SCOPE_REQUIRED, 'a full-authority bearer must not be refused on scope grounds');
|
|
174
181
|
});
|
|
182
|
+
/**
|
|
183
|
+
* The status read answers anonymous callers, so a narrowed token is
|
|
184
|
+
* answered as one — byte for byte, status and body — rather than refused.
|
|
185
|
+
* The full-scope bearer of the same account reads the account, so the
|
|
186
|
+
* 401 is the scope, not the bearer.
|
|
187
|
+
*/
|
|
188
|
+
test_if(capabilities.account_status, 'a narrowed token reads the account status as the anonymous caller', async () => {
|
|
189
|
+
const fixture = await setup_test();
|
|
190
|
+
const token = await mint_narrowed_api_token({
|
|
191
|
+
app: fixture.transport,
|
|
192
|
+
rpc_path,
|
|
193
|
+
session_headers: fixture.create_session_headers(),
|
|
194
|
+
methods: [ADMITTED_METHOD]
|
|
195
|
+
});
|
|
196
|
+
const anonymous = await fixture.fresh_transport({ origin: null })(STATUS_PATH, {});
|
|
197
|
+
const narrowed = await fixture.fresh_transport({ origin: null })(STATUS_PATH, {
|
|
198
|
+
headers: { authorization: `Bearer ${token}` }
|
|
199
|
+
});
|
|
200
|
+
assert.strictEqual(anonymous.status, 401, 'anonymous status must be 401');
|
|
201
|
+
assert.strictEqual(narrowed.status, 401, 'a narrowed token must read status as anonymous');
|
|
202
|
+
assert.strictEqual(await narrowed.text(), await anonymous.text(), 'the narrowed answer must be the anonymous one, byte for byte');
|
|
203
|
+
const full = await fixture.fresh_transport({ origin: null })(STATUS_PATH, {
|
|
204
|
+
headers: fixture.create_bearer_headers()
|
|
205
|
+
});
|
|
206
|
+
assert.strictEqual(full.status, 200, 'a full-scope bearer must read the account');
|
|
207
|
+
});
|
|
175
208
|
/**
|
|
176
209
|
* **Scope outranks role**, and this is the case that had no cross-impl
|
|
177
210
|
* pin while the two spines disagreed about it.
|