@fuzdev/fuz_app 0.145.0 → 0.146.1
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/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 +5 -0
- package/dist/server/fact_security_headers.d.ts.map +1 -1
- package/dist/server/fact_security_headers.js +5 -0
- package/dist/server/serve_fact_route.d.ts +12 -7
- package/dist/server/serve_fact_route.d.ts.map +1 -1
- package/dist/server/serve_fact_route.js +30 -23
- package/dist/server/x_accel.d.ts +37 -9
- package/dist/server/x_accel.d.ts.map +1 -1
- package/dist/server/x_accel.js +136 -19
- 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/dist/ui/Datatable.svelte +3 -3
- package/package.json +1 -1
|
@@ -14,7 +14,11 @@ import '../assert_dev_env.js';
|
|
|
14
14
|
* though the identical bytes are world-readable via B's cell (one deduped
|
|
15
15
|
* `fact` row; authz lives on the `(cell, hash)` edge, never unioned);
|
|
16
16
|
* - **404-mask** — a missing cell and a viewable cell that doesn't reference
|
|
17
|
-
* the hash both 404 (never 403, never "exists elsewhere")
|
|
17
|
+
* the hash both 404 (never 403, never "exists elsewhere"), and every masked
|
|
18
|
+
* 404 is bodyless, so its shape names no branch;
|
|
19
|
+
* - **hardening headers** — a served fact carries `X-Content-Type-Options:
|
|
20
|
+
* nosniff`, the `default-src 'none'; sandbox` CSP, and `Cache-Control:
|
|
21
|
+
* private, max-age=300`, whatever its content type;
|
|
18
22
|
* - **bare-hash admin-only** — `GET /api/facts/:hash` is admin (keeper) only:
|
|
19
23
|
* non-admin → 403, anonymous → 401;
|
|
20
24
|
* - **multi-actor fallthrough** — a multi-actor caller resolves to a null
|
|
@@ -22,7 +26,10 @@ import '../assert_dev_env.js';
|
|
|
22
26
|
* read its own *private* fact there (admitted only by public cells). Opt-in
|
|
23
27
|
* (needs the multi-actor setup); every spine resolves the acting actor at the
|
|
24
28
|
* dispatcher's authorization phase from account-grain credentials, so the
|
|
25
|
-
* multi-actor account is drivable on TS and Rust alike
|
|
29
|
+
* multi-actor account is drivable on TS and Rust alike;
|
|
30
|
+
* - **narrowed-token downgrade** — a scope-narrowed API token on the
|
|
31
|
+
* cell-scoped route is anonymous, not refused: its owner's private fact 404s
|
|
32
|
+
* exactly as for an anonymous caller, while a public cell still serves.
|
|
26
33
|
*
|
|
27
34
|
* Facts are seeded **embedded** via `_testing_put_fact` (the cross-process
|
|
28
35
|
* driver has no DB handle); the referencing cell via the `cell_create` RPC
|
|
@@ -40,8 +47,10 @@ import { describe, assert } from 'vitest';
|
|
|
40
47
|
import { z } from 'zod';
|
|
41
48
|
import { FactHashSchema } from '../../primitive_schemas.js';
|
|
42
49
|
import { CellCreateOutput } from '../../auth/cell_action_specs.js';
|
|
50
|
+
import { FACT_CACHE_CONTROL, FACT_CONTENT_SECURITY_POLICY, FACT_X_CONTENT_TYPE_OPTIONS } from '../../server/fact_security_headers.js';
|
|
43
51
|
import { test_if } from './capabilities.js';
|
|
44
52
|
import { cross_rpc_call, expect_output } from './cell_cross_helpers.js';
|
|
53
|
+
import { mint_narrowed_api_token } from './narrowed_api_token.js';
|
|
45
54
|
import { SPINE_RPC_PATH } from './spine_surface_constants.js';
|
|
46
55
|
/** A blake3 hash that is a valid form but references no seeded fact. */
|
|
47
56
|
const UNRELATED_HASH = `blake3:${'0'.repeat(64)}`;
|
|
@@ -70,15 +79,37 @@ export const cross_put_fact = async (fixture, rpc_path, input) => expect_output(
|
|
|
70
79
|
// credential when an `Origin` / `Referer` is present (browser
|
|
71
80
|
// context), and cross-process `fixture.transport` auto-adds `Origin`.
|
|
72
81
|
fixture.fresh_transport({ origin: null }), rpc_path, '_testing_put_fact', input, fixture.create_daemon_token_headers()), TestingPutFactOutput).hash;
|
|
73
|
-
/** GET a fact over a cross-process `FetchTransport`; returns `{status, content_type, text}`. */
|
|
82
|
+
/** GET a fact over a cross-process `FetchTransport`; returns `{status, content_type, text, headers}`. */
|
|
74
83
|
const fact_get = async (transport, path, headers) => {
|
|
75
84
|
const res = await transport(path, { method: 'GET', headers });
|
|
76
85
|
return {
|
|
77
86
|
status: res.status,
|
|
78
87
|
content_type: res.headers.get('content-type'),
|
|
79
|
-
text: await res.text()
|
|
88
|
+
text: await res.text(),
|
|
89
|
+
headers: res.headers
|
|
80
90
|
};
|
|
81
91
|
};
|
|
92
|
+
/**
|
|
93
|
+
* Assert a served fact carries the hardening headers — `nosniff`, the
|
|
94
|
+
* sandboxing CSP — and the private cache policy. A spine that dropped either
|
|
95
|
+
* hardening header would serve a user-typed `content_type` (or a sniffable
|
|
96
|
+
* octet-stream) as executable HTML.
|
|
97
|
+
*/
|
|
98
|
+
const assert_fact_headers = (headers, label) => {
|
|
99
|
+
assert.strictEqual(headers.get('x-content-type-options'), FACT_X_CONTENT_TYPE_OPTIONS, `${label}: X-Content-Type-Options`);
|
|
100
|
+
assert.strictEqual(headers.get('content-security-policy'), FACT_CONTENT_SECURITY_POLICY, `${label}: Content-Security-Policy`);
|
|
101
|
+
assert.strictEqual(headers.get('cache-control'), FACT_CACHE_CONTROL, `${label}: Cache-Control`);
|
|
102
|
+
};
|
|
103
|
+
/**
|
|
104
|
+
* Assert a 404 is the masked one: no body, so the response says nothing about
|
|
105
|
+
* which check refused it.
|
|
106
|
+
*/
|
|
107
|
+
const assert_masked_404 = (got, label) => {
|
|
108
|
+
assert.strictEqual(got.status, 404, `${label} did not 404`);
|
|
109
|
+
assert.strictEqual(got.text, '', `${label}: a masked 404 has no body`);
|
|
110
|
+
// TODO: the content type isn't pinned — the Node TS spine answers the
|
|
111
|
+
// bodyless 404 with `text/plain; charset=UTF-8`, Deno, Bun and Rust with none
|
|
112
|
+
};
|
|
82
113
|
export const describe_fact_serving_cross_tests = (options) => {
|
|
83
114
|
const { setup_test, setup_test_multi_actor, capabilities } = options;
|
|
84
115
|
const rpc_path = options.rpc_path ?? SPINE_RPC_PATH;
|
|
@@ -105,6 +136,7 @@ export const describe_fact_serving_cross_tests = (options) => {
|
|
|
105
136
|
assert.strictEqual(got.text, 'public-fact-bytes');
|
|
106
137
|
// The served `Content-Type` echoes the seeded value on both backends.
|
|
107
138
|
assert.strictEqual(got.content_type, 'text/plain', 'served content-type drifted');
|
|
139
|
+
assert_fact_headers(got.headers, 'cell-scoped text/plain fact');
|
|
108
140
|
});
|
|
109
141
|
test_if(capabilities.fact_serving, 'cross-owner dedup does not leak: A’s private reference stays 404 while B publishes the same bytes', async () => {
|
|
110
142
|
const fixture = await setup_test();
|
|
@@ -129,12 +161,13 @@ export const describe_fact_serving_cross_tests = (options) => {
|
|
|
129
161
|
assert.strictEqual(via_b.status, 200, `B's public cell should serve the fact: ${via_b.text}`);
|
|
130
162
|
// Seeded without a content_type → both backends fall back to octet-stream.
|
|
131
163
|
assert.strictEqual(via_b.content_type, 'application/octet-stream', 'no-content-type fact should serve as octet-stream');
|
|
164
|
+
assert_fact_headers(via_b.headers, 'octet-stream fact');
|
|
132
165
|
// A's PRIVATE reference must NOT leak — to anon or to B — even though
|
|
133
166
|
// the identical bytes are world-readable via B's public cell.
|
|
134
167
|
const a_via_anon = await fact_get(anon, cell_fact_path(a_cell, hash), {});
|
|
135
|
-
|
|
168
|
+
assert_masked_404(a_via_anon, "A's private reference read by anon");
|
|
136
169
|
const a_via_b = await fact_get(t, cell_fact_path(a_cell, hash), b.create_session_headers());
|
|
137
|
-
|
|
170
|
+
assert_masked_404(a_via_b, "A's private reference read by B");
|
|
138
171
|
// A can read its own private cell's fact.
|
|
139
172
|
const a_via_a = await fact_get(t, cell_fact_path(a_cell, hash), a.create_session_headers());
|
|
140
173
|
assert.strictEqual(a_via_a.status, 200, 'A could not read its own private fact');
|
|
@@ -146,29 +179,33 @@ export const describe_fact_serving_cross_tests = (options) => {
|
|
|
146
179
|
const admin_headers = fixture.create_session_headers();
|
|
147
180
|
// Missing cell → 404.
|
|
148
181
|
const missing = await fact_get(t, cell_fact_path(NIL_UUID, hash), admin_headers);
|
|
149
|
-
|
|
182
|
+
assert_masked_404(missing, 'missing cell');
|
|
150
183
|
// A viewable (keeper-owned, public) cell that references a DIFFERENT
|
|
151
184
|
// hash → 404 (missing cell→fact edge), even though the caller can view
|
|
152
185
|
// the cell. `path` is admin-only and the keeper is admin.
|
|
153
186
|
const unrelated_cell = await create_cell_with_ref(t, admin_headers, UNRELATED_HASH, 'public');
|
|
154
187
|
const no_edge = await fact_get(t, cell_fact_path(unrelated_cell, hash), admin_headers);
|
|
155
|
-
|
|
188
|
+
assert_masked_404(no_edge, 'cell without the edge');
|
|
156
189
|
// Edge present but no `fact` row: the same cell DOES reference
|
|
157
190
|
// `UNRELATED_HASH`, which was never seeded. Cell + edge + view all
|
|
158
191
|
// pass, so this exercises the distinct serve-time "metadata missing"
|
|
159
192
|
// 404 branch — masked identically to the authz 404s above.
|
|
160
193
|
const edge_no_bytes = await fact_get(t, cell_fact_path(unrelated_cell, UNRELATED_HASH), admin_headers);
|
|
161
|
-
|
|
194
|
+
assert_masked_404(edge_no_bytes, 'edge to an absent fact');
|
|
162
195
|
});
|
|
163
196
|
test_if(capabilities.fact_serving, 'bare-hash route is admin-only', async () => {
|
|
164
197
|
const fixture = await setup_test();
|
|
165
198
|
const non_admin = await fixture.create_account({ username: 'fact_non_admin' });
|
|
166
|
-
|
|
199
|
+
// stored as `text/html` — the type a dropped hardening header would let
|
|
200
|
+
// execute in the admin's session
|
|
201
|
+
const hash = await put_fact(fixture, 'bare-hash-bytes', 'text/html');
|
|
167
202
|
const t = fixture.fresh_transport();
|
|
168
|
-
// keeper holds ROLE_ADMIN → 200 + the actual bytes.
|
|
203
|
+
// keeper holds ROLE_ADMIN → 200 + the actual bytes, hardened.
|
|
169
204
|
const as_admin = await fact_get(t, `/api/facts/${hash}`, fixture.create_session_headers());
|
|
170
205
|
assert.strictEqual(as_admin.status, 200, `admin bare-hash read failed: ${as_admin.text}`);
|
|
171
206
|
assert.strictEqual(as_admin.text, 'bare-hash-bytes', 'bare-hash route served wrong bytes');
|
|
207
|
+
assert.strictEqual(as_admin.content_type, 'text/html', 'served content-type drifted');
|
|
208
|
+
assert_fact_headers(as_admin.headers, 'bare-hash text/html fact');
|
|
172
209
|
// non-admin → 403.
|
|
173
210
|
const as_non_admin = await fact_get(t, `/api/facts/${hash}`, non_admin.create_session_headers());
|
|
174
211
|
assert.strictEqual(as_non_admin.status, 403, 'non-admin reached the bare-hash route');
|
|
@@ -177,6 +214,42 @@ export const describe_fact_serving_cross_tests = (options) => {
|
|
|
177
214
|
const as_anon = await fact_get(anon, `/api/facts/${hash}`, {});
|
|
178
215
|
assert.strictEqual(as_anon.status, 401, 'anon reached the bare-hash route');
|
|
179
216
|
});
|
|
217
|
+
// A narrowed token reaches no non-RPC surface; on this anonymous-admitting
|
|
218
|
+
// route it is downgraded to anonymous rather than refused (a 403 would
|
|
219
|
+
// enforce nothing — the holder drops the header). So the owner's narrowed
|
|
220
|
+
// token reads exactly what an anonymous caller reads: its private fact
|
|
221
|
+
// 404s, a public cell still serves. The owner's full-scope bearer reading
|
|
222
|
+
// the private fact pins that the 404 is the scope, not the bearer.
|
|
223
|
+
test_if(capabilities.fact_serving, 'narrowed api token is treated as anonymous on the cell-scoped route', async () => {
|
|
224
|
+
const fixture = await setup_test();
|
|
225
|
+
const owner = await fixture.create_account({ username: 'fact_narrowed_owner' });
|
|
226
|
+
const hash = await cross_put_fact(fixture, rpc_path, {
|
|
227
|
+
content: 'narrowed-token-bytes',
|
|
228
|
+
holder: owner.actor.id
|
|
229
|
+
});
|
|
230
|
+
const t = fixture.fresh_transport();
|
|
231
|
+
const session = owner.create_session_headers();
|
|
232
|
+
const private_cell = await create_cell_with_ref(t, session, hash, 'private');
|
|
233
|
+
const public_cell = await create_cell_with_ref(t, session, hash, 'public');
|
|
234
|
+
const token = await mint_narrowed_api_token({
|
|
235
|
+
app: fixture.transport,
|
|
236
|
+
rpc_path,
|
|
237
|
+
session_headers: session,
|
|
238
|
+
methods: ['cell_get']
|
|
239
|
+
});
|
|
240
|
+
// no `Origin` — a bearer in a browser context is discarded
|
|
241
|
+
const bare = fixture.fresh_transport({ origin: null });
|
|
242
|
+
const narrowed = { authorization: `Bearer ${token}` };
|
|
243
|
+
const full = await fact_get(bare, cell_fact_path(private_cell, hash), owner.create_bearer_headers());
|
|
244
|
+
assert.strictEqual(full.status, 200, `full-scope bearer read failed: ${full.text}`);
|
|
245
|
+
const anon_private = await fact_get(bare, cell_fact_path(private_cell, hash), {});
|
|
246
|
+
const narrowed_private = await fact_get(bare, cell_fact_path(private_cell, hash), narrowed);
|
|
247
|
+
assert_masked_404(anon_private, 'anon read of a private fact');
|
|
248
|
+
assert_masked_404(narrowed_private, 'narrowed-token read of a private fact');
|
|
249
|
+
const narrowed_public = await fact_get(bare, cell_fact_path(public_cell, hash), narrowed);
|
|
250
|
+
assert.strictEqual(narrowed_public.status, 200, 'narrowed token refused a public cell');
|
|
251
|
+
assert.strictEqual(narrowed_public.text, 'narrowed-token-bytes');
|
|
252
|
+
});
|
|
180
253
|
// The cell-scoped route is pure-public — no `acting?` slot — so the handler
|
|
181
254
|
// can't disambiguate a multi-actor account and resolves it to a null
|
|
182
255
|
// (anonymous) context. This pins that fallthrough: a multi-actor caller is
|
|
@@ -201,7 +274,7 @@ export const describe_fact_serving_cross_tests = (options) => {
|
|
|
201
274
|
// Owns a PRIVATE cell, yet reading its own fact back resolves to a null
|
|
202
275
|
// (anonymous) context → 404. The owner path never runs.
|
|
203
276
|
const own_private = await fact_get(t, cell_fact_path(private_cell, hash), keeper);
|
|
204
|
-
|
|
277
|
+
assert_masked_404(own_private, "multi-actor owner's private read");
|
|
205
278
|
// A PUBLIC cell still admits the anonymous-treated caller → 200, proving
|
|
206
279
|
// the 404 above is the multi-actor fallthrough, not a blanket block.
|
|
207
280
|
const via_public = await fact_get(t, cell_fact_path(public_cell, hash), keeper);
|
|
@@ -8,6 +8,17 @@ export interface IdentityParityCrossTestOptions {
|
|
|
8
8
|
readonly login_path?: string;
|
|
9
9
|
/** REST signup route path. Default `/api/account/signup` (the spine convention). */
|
|
10
10
|
readonly signup_path?: string;
|
|
11
|
+
/**
|
|
12
|
+
* REST bootstrap route path. Default `/api/account/bootstrap` (the spine
|
|
13
|
+
* convention); `null` skips the bootstrap case, for a surface that mounts no
|
|
14
|
+
* bootstrap route.
|
|
15
|
+
*/
|
|
16
|
+
readonly bootstrap_path?: string | null;
|
|
17
|
+
/**
|
|
18
|
+
* REST password-change route path, for the malformed-body case. Default
|
|
19
|
+
* `/api/account/password` (the spine convention); `null` skips that case.
|
|
20
|
+
*/
|
|
21
|
+
readonly password_path?: string | null;
|
|
11
22
|
}
|
|
12
23
|
export declare const describe_identity_parity_cross_tests: (options: IdentityParityCrossTestOptions) => void;
|
|
13
24
|
//# sourceMappingURL=identity_parity.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"identity_parity.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/identity_parity.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;
|
|
1
|
+
{"version":3,"file":"identity_parity.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/identity_parity.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAoH9B,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAmB5C,uDAAuD;AACvD,MAAM,WAAW,8BAA8B;IAC9C,+DAA+D;IAC/D,QAAQ,CAAC,UAAU,EAAE,SAAS,CAAC;IAC/B,kFAAkF;IAClF,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;IAC7B,oFAAoF;IACpF,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B;;;;OAIG;IACH,QAAQ,CAAC,cAAc,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACxC;;;OAGG;IACH,QAAQ,CAAC,aAAa,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CACvC;AA8JD,eAAO,MAAM,oCAAoC,GAChD,SAAS,8BAA8B,KACrC,IAieF,CAAC"}
|
|
@@ -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
|
+
};
|