@fuzdev/fuz_app 0.141.0 → 0.142.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 +10 -5
- package/dist/actions/connection_closer.d.ts +19 -2
- package/dist/actions/connection_closer.d.ts.map +1 -1
- package/dist/actions/connection_closer.js +25 -2
- package/dist/actions/perform_action.d.ts +10 -0
- package/dist/actions/perform_action.d.ts.map +1 -1
- package/dist/actions/perform_action.js +23 -9
- package/dist/actions/register_ws_endpoint.d.ts.map +1 -1
- package/dist/actions/register_ws_endpoint.js +5 -2
- package/dist/auth/CLAUDE.md +33 -13
- package/dist/auth/account_actions.d.ts.map +1 -1
- package/dist/auth/account_actions.js +12 -1
- package/dist/auth/account_route_schema.d.ts.map +1 -1
- package/dist/auth/account_route_schema.js +8 -0
- package/dist/auth/account_routes.d.ts +7 -2
- package/dist/auth/account_routes.d.ts.map +1 -1
- package/dist/auth/account_routes.js +240 -187
- package/dist/auth/admin_action_specs.d.ts +6 -1
- package/dist/auth/admin_action_specs.d.ts.map +1 -1
- package/dist/auth/admin_action_specs.js +6 -1
- package/dist/auth/admin_actions.d.ts.map +1 -1
- package/dist/auth/admin_actions.js +14 -2
- package/dist/auth/bootstrap_routes.d.ts.map +1 -1
- package/dist/auth/bootstrap_routes.js +85 -76
- package/dist/auth/cell_action_specs.d.ts +7 -3
- package/dist/auth/cell_action_specs.d.ts.map +1 -1
- package/dist/auth/cell_action_specs.js +9 -5
- package/dist/auth/cell_actions.d.ts +3 -3
- package/dist/auth/cell_actions.d.ts.map +1 -1
- package/dist/auth/cell_actions.js +67 -38
- package/dist/auth/cell_authorize.d.ts +69 -8
- package/dist/auth/cell_authorize.d.ts.map +1 -1
- package/dist/auth/cell_authorize.js +89 -9
- package/dist/auth/cell_field_action_specs.d.ts +5 -4
- package/dist/auth/cell_field_action_specs.d.ts.map +1 -1
- package/dist/auth/cell_field_action_specs.js +7 -7
- package/dist/auth/cell_field_actions.d.ts +7 -5
- package/dist/auth/cell_field_actions.d.ts.map +1 -1
- package/dist/auth/cell_field_actions.js +24 -22
- package/dist/auth/cell_grant_action_specs.d.ts +6 -0
- package/dist/auth/cell_grant_action_specs.d.ts.map +1 -1
- package/dist/auth/cell_grant_action_specs.js +6 -0
- package/dist/auth/cell_grant_actions.d.ts +3 -1
- package/dist/auth/cell_grant_actions.d.ts.map +1 -1
- package/dist/auth/cell_grant_actions.js +16 -2
- package/dist/auth/cell_item_action_specs.d.ts +5 -4
- package/dist/auth/cell_item_action_specs.d.ts.map +1 -1
- package/dist/auth/cell_item_action_specs.js +7 -7
- package/dist/auth/cell_item_actions.d.ts +6 -5
- package/dist/auth/cell_item_actions.d.ts.map +1 -1
- package/dist/auth/cell_item_actions.js +23 -22
- package/dist/auth/cell_relation_list_limit.d.ts +20 -0
- package/dist/auth/cell_relation_list_limit.d.ts.map +1 -0
- package/dist/auth/cell_relation_list_limit.js +27 -0
- package/dist/auth/cell_relation_visibility.d.ts +4 -3
- package/dist/auth/cell_relation_visibility.d.ts.map +1 -1
- package/dist/auth/cell_relation_visibility.js +6 -5
- package/dist/auth/cell_steward.d.ts +45 -0
- package/dist/auth/cell_steward.d.ts.map +1 -0
- package/dist/auth/cell_steward.js +53 -0
- package/dist/auth/request_context.d.ts +4 -0
- package/dist/auth/request_context.d.ts.map +1 -1
- package/dist/auth/request_context.js +6 -1
- package/dist/auth/role_grant_offer_action_specs.d.ts +5 -0
- package/dist/auth/role_grant_offer_action_specs.d.ts.map +1 -1
- package/dist/auth/role_grant_offer_action_specs.js +6 -1
- package/dist/auth/role_grant_offer_actions.d.ts.map +1 -1
- package/dist/auth/role_grant_offer_actions.js +23 -16
- package/dist/auth/role_grant_offer_queries.d.ts +8 -6
- package/dist/auth/role_grant_offer_queries.d.ts.map +1 -1
- package/dist/auth/role_grant_offer_queries.js +16 -8
- package/dist/auth/signup_routes.d.ts +4 -3
- package/dist/auth/signup_routes.d.ts.map +1 -1
- package/dist/auth/signup_routes.js +157 -134
- package/dist/auth/token_scope.d.ts +7 -1
- package/dist/auth/token_scope.d.ts.map +1 -1
- package/dist/auth/token_scope.js +8 -2
- package/dist/db/CLAUDE.md +32 -19
- package/dist/db/cell_ddl.d.ts +1 -2
- package/dist/db/cell_ddl.d.ts.map +1 -1
- package/dist/db/cell_ddl.js +1 -2
- package/dist/db/cell_field_queries.d.ts +20 -18
- package/dist/db/cell_field_queries.d.ts.map +1 -1
- package/dist/db/cell_field_queries.js +28 -22
- package/dist/db/cell_item_queries.d.ts +20 -14
- package/dist/db/cell_item_queries.d.ts.map +1 -1
- package/dist/db/cell_item_queries.js +28 -18
- package/dist/db/cell_moderation.d.ts +67 -0
- package/dist/db/cell_moderation.d.ts.map +1 -0
- package/dist/db/cell_moderation.js +66 -0
- package/dist/db/cell_queries.d.ts +44 -14
- package/dist/db/cell_queries.d.ts.map +1 -1
- package/dist/db/cell_queries.js +68 -45
- package/dist/db/cell_view_sql.d.ts +102 -0
- package/dist/db/cell_view_sql.d.ts.map +1 -0
- package/dist/db/cell_view_sql.js +112 -0
- package/dist/db/db_pg.d.ts +1 -1
- package/dist/db/db_pg.js +1 -1
- package/dist/http/auth_shape.d.ts +5 -2
- package/dist/http/auth_shape.d.ts.map +1 -1
- package/dist/http/auth_shape.js +5 -2
- package/dist/http/ip_canonical.d.ts +30 -3
- package/dist/http/ip_canonical.d.ts.map +1 -1
- package/dist/http/ip_canonical.js +50 -3
- package/dist/primitive_schemas.d.ts +10 -1
- package/dist/primitive_schemas.d.ts.map +1 -1
- package/dist/primitive_schemas.js +11 -2
- package/dist/rate_limiter.d.ts +179 -12
- package/dist/rate_limiter.d.ts.map +1 -1
- package/dist/rate_limiter.js +352 -42
- package/dist/server/serve_fact_route.d.ts +8 -4
- package/dist/server/serve_fact_route.d.ts.map +1 -1
- package/dist/server/serve_fact_route.js +15 -6
- package/dist/testing/CLAUDE.md +10 -3
- package/dist/testing/cross_backend/account_lifecycle.d.ts.map +1 -1
- package/dist/testing/cross_backend/account_lifecycle.js +37 -2
- package/dist/testing/cross_backend/cell_gated_create.d.ts +38 -0
- package/dist/testing/cross_backend/cell_gated_create.d.ts.map +1 -1
- package/dist/testing/cross_backend/cell_gated_create.js +401 -5
- package/dist/testing/cross_backend/cell_relations.d.ts.map +1 -1
- package/dist/testing/cross_backend/cell_relations.js +145 -2
- package/dist/testing/cross_backend/expected_schema.json +0 -1
- package/dist/testing/cross_backend/login_security.d.ts +16 -0
- package/dist/testing/cross_backend/login_security.d.ts.map +1 -1
- package/dist/testing/cross_backend/login_security.js +154 -0
- package/dist/testing/cross_backend/query_shape.d.ts.map +1 -1
- package/dist/testing/cross_backend/query_shape.js +24 -2
- package/dist/testing/cross_backend/role_grant_participation.d.ts.map +1 -1
- package/dist/testing/cross_backend/role_grant_participation.js +64 -0
- package/dist/testing/cross_backend/spine_surface_constants.d.ts +1 -1
- package/dist/testing/cross_backend/spine_surface_constants.js +1 -1
- package/dist/testing/cross_backend/ws_action_rate_limit.d.ts.map +1 -1
- package/dist/testing/cross_backend/ws_action_rate_limit.js +21 -1
- package/dist/testing/schema_introspect.js +1 -1
- package/dist/testing/schema_parity.d.ts +4 -3
- package/dist/testing/schema_parity.d.ts.map +1 -1
- package/dist/testing/schema_parity.js +4 -3
- package/dist/testing/schema_ready_fixture.d.ts +2 -4
- package/dist/testing/schema_ready_fixture.d.ts.map +1 -1
- package/dist/testing/schema_ready_fixture.js +2 -4
- package/dist/ui/auth_state.svelte.d.ts +10 -0
- package/dist/ui/auth_state.svelte.d.ts.map +1 -1
- package/dist/ui/auth_state.svelte.js +22 -4
- package/package.json +1 -1
- package/dist/db/cell_history_ddl.d.ts +0 -38
- package/dist/db/cell_history_ddl.d.ts.map +0 -1
- package/dist/db/cell_history_ddl.js +0 -61
|
@@ -16,11 +16,23 @@ import '../assert_dev_env.js';
|
|
|
16
16
|
* - **grant lifecycle** — owner grants an actor-shaped editor; the grantee
|
|
17
17
|
* gains edit (was 404 before the grant); `cell_grant_list` is manage-tier
|
|
18
18
|
* (owner sees it, the editor gets the IDOR 404); revoke drops the edit path.
|
|
19
|
+
* - **no owner principal** — a grant naming the cell's owner is refused
|
|
20
|
+
* `cell_grant_principal_is_owner`, with the owner's actor id sent lowercase
|
|
21
|
+
* or uppercase.
|
|
19
22
|
* - **`cell_visibility_manage_only`** — the editor-grant holder can edit
|
|
20
23
|
* content but flipping `visibility` is 403 (the case the 5-verb CRUD cut
|
|
21
24
|
* couldn't reach without a grant principal).
|
|
22
25
|
* - **fields** — `cell_field_set` UPSERT, forward + reverse `cell_field_list`,
|
|
23
26
|
* idempotent `cell_field_delete`.
|
|
27
|
+
* - **relation-list caps** — past the default page of inbound edges, a
|
|
28
|
+
* reverse `cell_item_list` / `cell_field_list` with no `limit` answers the
|
|
29
|
+
* default page, an explicit `limit` caps it in either direction, and the
|
|
30
|
+
* forward list defaults the same — the same counts on both spines. A page is
|
|
31
|
+
* visible edges: a page's worth of private ends older than one public end
|
|
32
|
+
* still leaves the public one on an anonymous page, in all four lists. An
|
|
33
|
+
* out-of-range or mistyped `limit` (`0`, `-1`, `1.5`, past the max, `null`,
|
|
34
|
+
* a string) is `invalid_params` in every direction, and the reverse lists
|
|
35
|
+
* read oldest edge first.
|
|
24
36
|
* - **items** — `cell_item_insert` at fractional-index positions, forward
|
|
25
37
|
* (lex-ordered) + reverse `cell_item_list`, `cell_item_move`, idempotent
|
|
26
38
|
* `cell_item_delete`.
|
|
@@ -55,13 +67,15 @@ import '../assert_dev_env.js';
|
|
|
55
67
|
* @module
|
|
56
68
|
*/
|
|
57
69
|
import { describe, assert } from 'vitest';
|
|
58
|
-
import { fractional_index_between } from '@fuzdev/fuz_util/fractional_index.ts';
|
|
70
|
+
import { fractional_index_between, fractional_indices_between } from '@fuzdev/fuz_util/fractional_index.ts';
|
|
59
71
|
import { CellCreateOutput, CellUpdateOutput, CellCloneOutput, CellGetOutput, CellListOutput } from '../../auth/cell_action_specs.js';
|
|
60
72
|
import { AuditLogListOutput } from '../../auth/admin_action_specs.js';
|
|
61
|
-
import { CellGrantCreateOutput, CellGrantListOutput, CellGrantRevokeOutput } from '../../auth/cell_grant_action_specs.js';
|
|
73
|
+
import { ERROR_CELL_GRANT_PRINCIPAL_IS_OWNER, CellGrantCreateOutput, CellGrantListOutput, CellGrantRevokeOutput } from '../../auth/cell_grant_action_specs.js';
|
|
62
74
|
import { CellFieldDeleteOutput, CellFieldListOutput, CellFieldSetOutput } from '../../auth/cell_field_action_specs.js';
|
|
63
75
|
import { CellItemDeleteOutput, CellItemInsertOutput, CellItemListOutput, CellItemMoveOutput } from '../../auth/cell_item_action_specs.js';
|
|
64
76
|
import { CellAuditListOutput } from '../../auth/cell_audit_action_specs.js';
|
|
77
|
+
import { JSONRPC_INVALID_PARAMS } from '../../http/jsonrpc.js';
|
|
78
|
+
import { CELL_RELATION_LIST_LIMIT_DEFAULT, CELL_RELATION_LIST_LIMIT_MAX } from '../../auth/cell_relation_list_limit.js';
|
|
65
79
|
import { test_if } from './capabilities.js';
|
|
66
80
|
import { cross_rpc_call, error_reason, expect_output } from './cell_cross_helpers.js';
|
|
67
81
|
import { SPINE_RPC_PATH } from './spine_surface_constants.js';
|
|
@@ -134,6 +148,20 @@ export const describe_cell_relations_cross_tests = (options) => {
|
|
|
134
148
|
assert.ok(!post.ok, 'revoked editor still edited');
|
|
135
149
|
assert.strictEqual(error_reason(post), 'cell_not_found');
|
|
136
150
|
});
|
|
151
|
+
test_if(capabilities.cell_relations, 'a grant naming the owner is refused cell_grant_principal_is_owner, whatever the id case', async () => {
|
|
152
|
+
const fixture = await setup_test();
|
|
153
|
+
const owner = await fixture.create_account({ username: 'cell_grant_self_owner' });
|
|
154
|
+
const t = fixture.fresh_transport();
|
|
155
|
+
const owner_h = owner.create_session_headers();
|
|
156
|
+
const cell = expect_output(await cross_rpc_call(t, rpc_path, 'cell_create', { kind: 'note', data: {} }, owner_h), CellCreateOutput).cell;
|
|
157
|
+
// both spines canonicalize the id before comparing it with
|
|
158
|
+
// `created_by`, so the uppercased owner id is the owner too
|
|
159
|
+
for (const actor_id of [owner.actor.id, owner.actor.id.toUpperCase()]) {
|
|
160
|
+
const res = await cross_rpc_call(t, rpc_path, 'cell_grant_create', { cell_id: cell.id, level: 'viewer', principal: { kind: 'actor', actor_id } }, owner_h);
|
|
161
|
+
assert.ok(!res.ok, `a grant to the owner must be refused (${actor_id})`);
|
|
162
|
+
assert.strictEqual(error_reason(res), ERROR_CELL_GRANT_PRINCIPAL_IS_OWNER, actor_id);
|
|
163
|
+
}
|
|
164
|
+
});
|
|
137
165
|
test_if(capabilities.cell_relations, "cell_list shared_with: 'me' lists grant-admitted cells, excludes owned, enriches cell_grants", async () => {
|
|
138
166
|
const fixture = await setup_test();
|
|
139
167
|
const owner = await fixture.create_account({ username: 'cell_shared_owner' });
|
|
@@ -219,6 +247,121 @@ export const describe_cell_relations_cross_tests = (options) => {
|
|
|
219
247
|
const del2 = expect_output(await cross_rpc_call(t, rpc_path, 'cell_field_delete', { source_id: source.id, name: 'cover' }, h), CellFieldDeleteOutput);
|
|
220
248
|
assert.strictEqual(del2.deleted, false);
|
|
221
249
|
});
|
|
250
|
+
test_if(capabilities.cell_relations, 'relation lists default to a capped page and honor limit, forward and reverse', async () => {
|
|
251
|
+
const fixture = await setup_test();
|
|
252
|
+
const owner = await fixture.create_account({ username: 'cell_list_cap_owner' });
|
|
253
|
+
const t = fixture.fresh_transport();
|
|
254
|
+
const h = owner.create_session_headers();
|
|
255
|
+
// one past the default page, so an omitted limit shows the cap
|
|
256
|
+
const inbound = CELL_RELATION_LIST_LIMIT_DEFAULT + 1;
|
|
257
|
+
const make = async () => expect_output(await cross_rpc_call(t, rpc_path, 'cell_create', { kind: 'note', data: {} }, h), CellCreateOutput).cell.id;
|
|
258
|
+
const hub = await make();
|
|
259
|
+
const parent = await make();
|
|
260
|
+
const source = await make();
|
|
261
|
+
const positions = fractional_indices_between(null, null, inbound);
|
|
262
|
+
for (let i = 0; i < inbound; i++) {
|
|
263
|
+
expect_output(await cross_rpc_call(t, rpc_path, 'cell_item_insert', { parent_id: parent, child_id: hub, position: positions[i] }, h), CellItemInsertOutput);
|
|
264
|
+
expect_output(await cross_rpc_call(t, rpc_path, 'cell_field_set', { source_id: source, name: `link_${i}`, target_id: hub }, h), CellFieldSetOutput);
|
|
265
|
+
}
|
|
266
|
+
const items = async (params) => expect_output(await cross_rpc_call(t, rpc_path, 'cell_item_list', params, h), CellItemListOutput).items.length;
|
|
267
|
+
const fields = async (params) => expect_output(await cross_rpc_call(t, rpc_path, 'cell_field_list', params, h), CellFieldListOutput).fields.length;
|
|
268
|
+
for (const [label, count] of [
|
|
269
|
+
['reverse items, no limit', await items({ child_id: hub })],
|
|
270
|
+
['reverse fields, no limit', await fields({ target_id: hub })],
|
|
271
|
+
['forward items, no limit', await items({ parent_id: parent })],
|
|
272
|
+
['forward fields, no limit', await fields({ source_id: source })]
|
|
273
|
+
]) {
|
|
274
|
+
assert.strictEqual(count, CELL_RELATION_LIST_LIMIT_DEFAULT, label);
|
|
275
|
+
}
|
|
276
|
+
assert.strictEqual(await items({ child_id: hub, limit: 3 }), 3, 'reverse items, limit 3');
|
|
277
|
+
assert.strictEqual(await fields({ target_id: hub, limit: 3 }), 3, 'reverse fields, limit 3');
|
|
278
|
+
assert.strictEqual(await items({ child_id: hub, limit: inbound }), inbound, 'reverse items, limit past the default');
|
|
279
|
+
assert.strictEqual(await fields({ target_id: hub, limit: inbound }), inbound, 'reverse fields, limit past the default');
|
|
280
|
+
});
|
|
281
|
+
test_if(capabilities.cell_relations, 'a relation page is visible edges: hidden ends never crowd out a visible one', async () => {
|
|
282
|
+
const fixture = await setup_test();
|
|
283
|
+
const owner = await fixture.create_account({ username: 'cell_list_pad_owner' });
|
|
284
|
+
const t = fixture.fresh_transport();
|
|
285
|
+
const h = owner.create_session_headers();
|
|
286
|
+
const anon = fixture.fresh_transport({ origin: null });
|
|
287
|
+
const make = async (visibility) => expect_output(await cross_rpc_call(t, rpc_path, 'cell_create', { kind: 'note', data: {}, visibility }, h), CellCreateOutput).cell.id;
|
|
288
|
+
const call = async (method, params) => cross_rpc_call(t, rpc_path, method, params, h);
|
|
289
|
+
// A full default page of private edges, older (and, forward, named
|
|
290
|
+
// earlier) than one public edge, in all four lists.
|
|
291
|
+
const target = await make('public');
|
|
292
|
+
const child = await make('public');
|
|
293
|
+
const source = await make('public');
|
|
294
|
+
const parent = await make('public');
|
|
295
|
+
const padding = CELL_RELATION_LIST_LIMIT_DEFAULT + 1;
|
|
296
|
+
const positions = fractional_indices_between(null, null, padding + 1);
|
|
297
|
+
for (let i = 0; i <= padding; i++) {
|
|
298
|
+
const end = await make(i < padding ? 'private' : 'public');
|
|
299
|
+
const name = `link_${String(i).padStart(3, '0')}`;
|
|
300
|
+
expect_output(await call('cell_field_set', { source_id: end, name: 'link', target_id: target }), CellFieldSetOutput);
|
|
301
|
+
expect_output(await call('cell_item_insert', { parent_id: end, child_id: child, position: 'a0' }), CellItemInsertOutput);
|
|
302
|
+
expect_output(await call('cell_field_set', { source_id: source, name, target_id: end }), CellFieldSetOutput);
|
|
303
|
+
expect_output(await call('cell_item_insert', {
|
|
304
|
+
parent_id: parent,
|
|
305
|
+
child_id: end,
|
|
306
|
+
position: positions[i]
|
|
307
|
+
}), CellItemInsertOutput);
|
|
308
|
+
}
|
|
309
|
+
const anon_fields = async (params) => expect_output(await cross_rpc_call(anon, rpc_path, 'cell_field_list', params, {}), CellFieldListOutput).fields;
|
|
310
|
+
const anon_items = async (params) => expect_output(await cross_rpc_call(anon, rpc_path, 'cell_item_list', params, {}), CellItemListOutput).items;
|
|
311
|
+
assert.strictEqual((await anon_fields({ target_id: target })).length, 1, 'reverse fields: the public source, past a page of private ones');
|
|
312
|
+
assert.strictEqual((await anon_items({ child_id: child })).length, 1, 'reverse items: the public parent, past a page of private ones');
|
|
313
|
+
assert.strictEqual((await anon_fields({ source_id: source })).length, 1, 'forward fields: the public target, past a page of private ones');
|
|
314
|
+
assert.strictEqual((await anon_items({ parent_id: parent })).length, 1, 'forward items: the public child, past a page of private ones');
|
|
315
|
+
// the owner views every end: a full default page
|
|
316
|
+
const owned = expect_output(await call('cell_field_list', { target_id: target }), CellFieldListOutput).fields;
|
|
317
|
+
assert.strictEqual(owned.length, CELL_RELATION_LIST_LIMIT_DEFAULT, "the owner's page");
|
|
318
|
+
});
|
|
319
|
+
test_if(capabilities.cell_relations, 'relation-list limit: out-of-range and mistyped values refused alike, forward and reverse', async () => {
|
|
320
|
+
const fixture = await setup_test();
|
|
321
|
+
const owner = await fixture.create_account({ username: 'cell_list_limit_owner' });
|
|
322
|
+
const t = fixture.fresh_transport();
|
|
323
|
+
const h = owner.create_session_headers();
|
|
324
|
+
const cell = expect_output(await cross_rpc_call(t, rpc_path, 'cell_create', { kind: 'note', data: {} }, h), CellCreateOutput).cell.id;
|
|
325
|
+
const directions = [
|
|
326
|
+
['cell_field_list', { source_id: cell }],
|
|
327
|
+
['cell_field_list', { target_id: cell }],
|
|
328
|
+
['cell_item_list', { parent_id: cell }],
|
|
329
|
+
['cell_item_list', { child_id: cell }]
|
|
330
|
+
];
|
|
331
|
+
for (const [method, base] of directions) {
|
|
332
|
+
for (const limit of [0, -1, 1.5, CELL_RELATION_LIST_LIMIT_MAX + 1, null, '5']) {
|
|
333
|
+
const res = await cross_rpc_call(t, rpc_path, method, { ...base, limit }, h);
|
|
334
|
+
const label = `${method} ${JSON.stringify(base)} limit ${JSON.stringify(limit)}`;
|
|
335
|
+
assert.ok(!res.ok, `${label} must be refused`);
|
|
336
|
+
assert.strictEqual(res.error?.code, JSONRPC_INVALID_PARAMS, label);
|
|
337
|
+
assert.strictEqual(error_reason(res), undefined, label);
|
|
338
|
+
}
|
|
339
|
+
for (const limit of [1, CELL_RELATION_LIST_LIMIT_MAX]) {
|
|
340
|
+
const res = await cross_rpc_call(t, rpc_path, method, { ...base, limit }, h);
|
|
341
|
+
assert.ok(res.ok, `${method} ${JSON.stringify(base)} limit ${limit}`);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
});
|
|
345
|
+
test_if(capabilities.cell_relations, 'reverse relation lists read oldest edge first, so a page of one is the oldest', async () => {
|
|
346
|
+
const fixture = await setup_test();
|
|
347
|
+
const owner = await fixture.create_account({ username: 'cell_list_order_owner' });
|
|
348
|
+
const t = fixture.fresh_transport();
|
|
349
|
+
const h = owner.create_session_headers();
|
|
350
|
+
const make = async () => expect_output(await cross_rpc_call(t, rpc_path, 'cell_create', { kind: 'note', data: {} }, h), CellCreateOutput).cell.id;
|
|
351
|
+
const hub = await make();
|
|
352
|
+
// each edge its own call, so each has its own `created_at`, in order
|
|
353
|
+
const ends = [await make(), await make(), await make()];
|
|
354
|
+
for (const end of ends) {
|
|
355
|
+
expect_output(await cross_rpc_call(t, rpc_path, 'cell_item_insert', { parent_id: end, child_id: hub, position: 'a0' }, h), CellItemInsertOutput);
|
|
356
|
+
expect_output(await cross_rpc_call(t, rpc_path, 'cell_field_set', { source_id: end, name: 'link', target_id: hub }, h), CellFieldSetOutput);
|
|
357
|
+
}
|
|
358
|
+
const parents = async (limit) => expect_output(await cross_rpc_call(t, rpc_path, 'cell_item_list', { child_id: hub, limit }, h), CellItemListOutput).items.map((i) => i.parent_id);
|
|
359
|
+
const sources = async (limit) => expect_output(await cross_rpc_call(t, rpc_path, 'cell_field_list', { target_id: hub, limit }, h), CellFieldListOutput).fields.map((f) => f.source_id);
|
|
360
|
+
assert.deepStrictEqual(await parents(), ends, 'reverse items, oldest first');
|
|
361
|
+
assert.deepStrictEqual(await parents(1), [ends[0]], 'reverse items, a page of one');
|
|
362
|
+
assert.deepStrictEqual(await sources(), ends, 'reverse fields, oldest first');
|
|
363
|
+
assert.deepStrictEqual(await sources(1), [ends[0]], 'reverse fields, a page of one');
|
|
364
|
+
});
|
|
222
365
|
test_if(capabilities.cell_relations, 'item insert → ordered forward + reverse list → move → idempotent delete', async () => {
|
|
223
366
|
const fixture = await setup_test();
|
|
224
367
|
const owner = await fixture.create_account({ username: 'cell_item_owner' });
|
|
@@ -76,7 +76,6 @@
|
|
|
76
76
|
"role",
|
|
77
77
|
"scope_id"
|
|
78
78
|
],
|
|
79
|
-
"cell_history": ["action_id", "cell_id", "created_at", "fact_hash", "id"],
|
|
80
79
|
"cell_item": ["child_id", "created_at", "parent_id", "position"],
|
|
81
80
|
"fact": ["bytes", "content_type", "created_at", "external_url", "hash", "size"],
|
|
82
81
|
"fact_ref": ["source_hash", "target_hash"],
|
|
@@ -6,6 +6,22 @@ export interface LoginSecurityCrossTestOptions {
|
|
|
6
6
|
readonly setup_test: SetupTest;
|
|
7
7
|
/** REST login route path. Default `/api/account/login` (the spine convention). */
|
|
8
8
|
readonly login_path?: string;
|
|
9
|
+
/** REST password-change route path. Default `/api/account/password`. */
|
|
10
|
+
readonly password_path?: string;
|
|
11
|
+
/**
|
|
12
|
+
* Whether the backend serves concurrent logins — each its own database
|
|
13
|
+
* transaction — at once, for the burst-of-correct-logins case. Default
|
|
14
|
+
* `false`: a backend on one in-process connection (pglet-wasm, PGlite)
|
|
15
|
+
* refuses a second top-level transaction while one runs, so concurrent
|
|
16
|
+
* successful logins answer `500` there whatever the limiter does.
|
|
17
|
+
*/
|
|
18
|
+
readonly concurrent_transactions?: boolean;
|
|
19
|
+
/**
|
|
20
|
+
* The backend's base URL, for the repeated-`X-Forwarded-For` case, which
|
|
21
|
+
* needs a raw socket to send two header lines. `null` (the default) skips
|
|
22
|
+
* that case.
|
|
23
|
+
*/
|
|
24
|
+
readonly base_url?: string | null;
|
|
9
25
|
}
|
|
10
26
|
export declare const describe_login_security_cross_tests: (options: LoginSecurityCrossTestOptions) => void;
|
|
11
27
|
//# sourceMappingURL=login_security.d.ts.map
|
|
@@ -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;AAgF9B,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;AAgDD,eAAO,MAAM,mCAAmC,GAC/C,SAAS,6BAA6B,KACpC,IA4UF,CAAC"}
|
|
@@ -24,6 +24,26 @@ import '../assert_dev_env.js';
|
|
|
24
24
|
* not `429`). A backend that ignored XFF and keyed on the (loopback) TCP
|
|
25
25
|
* peer would 429 the fresh-IP request too — so the `401` proves the limiter
|
|
26
26
|
* keys on the resolved `X-Forwarded-For` client IP.
|
|
27
|
+
* - **IPv6 clients share a bucket per `/64`** — after one forwarded IPv6
|
|
28
|
+
* address exhausts its bucket, another address in the same `/64` is `429`
|
|
29
|
+
* and one in the next `/64` is not; the login audit rows keep each full
|
|
30
|
+
* address, never the bucket key. A password change, which shares the
|
|
31
|
+
* per-IP login limiter, from an address in an exhausted `/64` is `429` too.
|
|
32
|
+
* - **a concurrent burst gets at most the cap past the limiter** — `2 ×
|
|
33
|
+
* ip_limit` wrong logins sent at once from one fresh forwarded IP answer at
|
|
34
|
+
* most `ip_limit` `401`s and `429` for the rest. A limiter checked before
|
|
35
|
+
* the lookup and recorded after the verify lets the whole burst through; each
|
|
36
|
+
* attempt is reserved instead, counted in flight from the check on.
|
|
37
|
+
* - **a burst of correct logins past the cap waits, then succeeds** — attempts
|
|
38
|
+
* count while in flight whatever their outcome, so `2 × ip_limit` correct
|
|
39
|
+
* logins at once from one address meet the cap; the ones past it wait for a
|
|
40
|
+
* slot (at most the cap of them) and all answer `200`. Runs on a backend
|
|
41
|
+
* that serves concurrent transactions (`concurrent_transactions`).
|
|
42
|
+
* - **every `X-Forwarded-For` line is read** — a client that sends its own
|
|
43
|
+
* line ahead of the one the trusted proxy appended can't choose its bucket:
|
|
44
|
+
* after exhausting one forwarded IP, a raw request carrying a spoofed line
|
|
45
|
+
* first and that IP last still `429`s. Raw socket only (`fetch` merges
|
|
46
|
+
* repeated headers into one line), so it runs when `base_url` is given.
|
|
27
47
|
* - **a success does not refund the per-IP budget** — the distributed-spray
|
|
28
48
|
* backstop. Failed guesses against *distinct* usernames from one forwarded
|
|
29
49
|
* IP, interleaved with the attacker's own successful logins, must still 429
|
|
@@ -50,7 +70,11 @@ import '../assert_dev_env.js';
|
|
|
50
70
|
*/
|
|
51
71
|
import { describe, test, assert } from 'vitest';
|
|
52
72
|
import { ERROR_RATE_LIMIT_EXCEEDED } from '../../http/error_schemas.js';
|
|
73
|
+
import { raw_http_exchange, raw_http_parse_response } from '../transports/raw_http.js';
|
|
53
74
|
import { default_login_account_rate_limit, default_login_ip_rate_limit } from '../../rate_limiter.js';
|
|
75
|
+
import { AuditLogListOutput } from '../../auth/admin_action_specs.js';
|
|
76
|
+
import { cross_rpc_call, expect_output } from './cell_cross_helpers.js';
|
|
77
|
+
import { SPINE_RPC_PATH } from './spine_surface_constants.js';
|
|
54
78
|
/**
|
|
55
79
|
* RFC 5737 TEST-NET-3 (`203.0.113.0/24`) documentation addresses — guaranteed
|
|
56
80
|
* non-routable, so they never collide with a real client IP. One per bucket the
|
|
@@ -63,11 +87,35 @@ const XFF_IP_LIMIT = '203.0.113.1';
|
|
|
63
87
|
const XFF_SEGREGATION_EXHAUST = '203.0.113.2';
|
|
64
88
|
const XFF_SEGREGATION_FRESH = '203.0.113.3';
|
|
65
89
|
const XFF_SPRAY = '203.0.113.4';
|
|
90
|
+
const XFF_BURST = '203.0.113.5';
|
|
91
|
+
const XFF_CORRECT_BURST = '203.0.113.7';
|
|
92
|
+
const XFF_REPEATED_LINES = '203.0.113.6';
|
|
93
|
+
/** RFC 5737 TEST-NET-2 — the address a client writes into its own first line. */
|
|
94
|
+
const XFF_SPOOFED_LINE = '198.51.100.77';
|
|
95
|
+
/**
|
|
96
|
+
* RFC 3849 documentation (`2001:db8::/32`) addresses: two in one `/64` — the
|
|
97
|
+
* second differs past bit 64 only — and one in the next `/64`.
|
|
98
|
+
*/
|
|
99
|
+
const XFF_V6_EXHAUST = '2001:db8:e3:1::1';
|
|
100
|
+
const XFF_V6_SAME_64 = '2001:db8:e3:1:8000::2';
|
|
101
|
+
const XFF_V6_OTHER_64 = '2001:db8:e3:2::1';
|
|
102
|
+
/**
|
|
103
|
+
* Two addresses in one more `/64`: login exhausts the first, and a password
|
|
104
|
+
* change — which reserves on the same per-IP login limiter — comes from the
|
|
105
|
+
* second.
|
|
106
|
+
*/
|
|
107
|
+
const XFF_V6_PASSWORD_EXHAUST = '2001:db8:e3:5::1';
|
|
108
|
+
const XFF_V6_PASSWORD_SAME_64 = '2001:db8:e3:5:8000::2';
|
|
109
|
+
/** How long a raw exchange waits for the login's answer. */
|
|
110
|
+
const RAW_READ_TIMEOUT_MS = 5000;
|
|
66
111
|
/** A non-routable, non-existent identifier — login resolves to the not-found path (records the bucket, returns 401). */
|
|
67
112
|
const PROBE_PASSWORD = 'login_security_wrong_password';
|
|
68
113
|
export const describe_login_security_cross_tests = (options) => {
|
|
69
114
|
const { setup_test } = options;
|
|
70
115
|
const login_path = options.login_path ?? '/api/account/login';
|
|
116
|
+
const password_path = options.password_path ?? '/api/account/password';
|
|
117
|
+
const concurrent_transactions = options.concurrent_transactions ?? false;
|
|
118
|
+
const base_url = options.base_url ?? null;
|
|
71
119
|
/** Per-IP cap shared by both impls (TS `default_login_ip_rate_limit`, Rust `DEFAULT_LOGIN_IP_RATE_LIMIT`). */
|
|
72
120
|
const ip_limit = default_login_ip_rate_limit.max_attempts;
|
|
73
121
|
// The XFF-segregation case enables both limiters (one flag drives both), so
|
|
@@ -145,6 +193,112 @@ export const describe_login_security_cross_tests = (options) => {
|
|
|
145
193
|
const fresh = await attempt(fixture, XFF_SEGREGATION_FRESH, 'probe_xff');
|
|
146
194
|
assert.strictEqual(fresh.status, 401, "a fresh forwarded IP must not inherit another IP's limit");
|
|
147
195
|
});
|
|
196
|
+
test('IPv6 clients share a bucket per /64, and the audit row keeps the full address', async () => {
|
|
197
|
+
const fixture = await setup_test();
|
|
198
|
+
for (let i = 0; i < ip_limit; i++) {
|
|
199
|
+
const outcome = await attempt(fixture, XFF_V6_EXHAUST, 'probe_v6');
|
|
200
|
+
assert.strictEqual(outcome.status, 401, `attempt ${i + 1} from ${XFF_V6_EXHAUST}`);
|
|
201
|
+
}
|
|
202
|
+
// another address in the same /64 draws the same budget
|
|
203
|
+
const same = await attempt(fixture, XFF_V6_SAME_64, 'probe_v6');
|
|
204
|
+
assert.strictEqual(same.status, 429, 'an address in the same /64 must be rate-limited');
|
|
205
|
+
// the next /64 has its own
|
|
206
|
+
const other = await attempt(fixture, XFF_V6_OTHER_64, 'probe_v6');
|
|
207
|
+
assert.strictEqual(other.status, 401, 'the next /64 must keep its own budget');
|
|
208
|
+
// The limiter keys the /64; the audit trail keeps the address. Drain
|
|
209
|
+
// the fire-and-forget audit writes, then read this test's login rows
|
|
210
|
+
// (`_testing_reset` wiped the log at setup).
|
|
211
|
+
const drained = await cross_rpc_call(fixture.fresh_transport({ origin: null }), SPINE_RPC_PATH, '_testing_drain_effects', undefined, fixture.create_daemon_token_headers());
|
|
212
|
+
assert.ok(drained.ok, `_testing_drain_effects failed: ${JSON.stringify(drained.error)}`);
|
|
213
|
+
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);
|
|
214
|
+
const ips = listed.events.map((e) => e.ip);
|
|
215
|
+
assert.ok(ips.includes(XFF_V6_EXHAUST), `audit ips: ${JSON.stringify(ips)}`);
|
|
216
|
+
assert.ok(ips.includes(XFF_V6_OTHER_64), `audit ips: ${JSON.stringify(ips)}`);
|
|
217
|
+
assert.ok(ips.every((ip) => ip === null || !ip.includes('/')), `no audit row may carry a rate-limit key: ${JSON.stringify(ips)}`);
|
|
218
|
+
});
|
|
219
|
+
test('a password change from the /64 an exhausted IPv6 login shares is rate-limited', async () => {
|
|
220
|
+
const fixture = await setup_test();
|
|
221
|
+
for (let i = 0; i < ip_limit; i++) {
|
|
222
|
+
const outcome = await attempt(fixture, XFF_V6_PASSWORD_EXHAUST, 'probe_v6_password');
|
|
223
|
+
assert.strictEqual(outcome.status, 401, `attempt ${i + 1} from ${XFF_V6_PASSWORD_EXHAUST}`);
|
|
224
|
+
}
|
|
225
|
+
// The keeper's session, from another address in the same /64: the
|
|
226
|
+
// password route reserves on the per-IP login limiter before it
|
|
227
|
+
// reads the credential or the body, so the /64's spent budget
|
|
228
|
+
// answers. Keyed on the full address instead, the wrong
|
|
229
|
+
// `current_password` would answer `401`.
|
|
230
|
+
const res = await fixture.fresh_transport()(password_path, {
|
|
231
|
+
method: 'POST',
|
|
232
|
+
headers: fixture.create_session_headers({
|
|
233
|
+
'content-type': 'application/json',
|
|
234
|
+
'x-forwarded-for': XFF_V6_PASSWORD_SAME_64
|
|
235
|
+
}),
|
|
236
|
+
body: JSON.stringify({
|
|
237
|
+
current_password: PROBE_PASSWORD,
|
|
238
|
+
new_password: 'login_security_new_password'
|
|
239
|
+
})
|
|
240
|
+
});
|
|
241
|
+
const body = (await res.json().catch(() => undefined));
|
|
242
|
+
assert.strictEqual(res.status, 429, `password change answered ${JSON.stringify(body)}`);
|
|
243
|
+
assert.strictEqual(body?.error, ERROR_RATE_LIMIT_EXCEEDED);
|
|
244
|
+
});
|
|
245
|
+
test(`a concurrent burst of ${ip_limit * 2} wrong logins gets at most ${ip_limit} past the per-IP limit`, async () => {
|
|
246
|
+
const fixture = await setup_test();
|
|
247
|
+
// All sent at once, so every request reaches the limiter before any
|
|
248
|
+
// has been answered. Its own username keeps the per-account bucket
|
|
249
|
+
// (cap above the IP cap, asserted above) out of the count.
|
|
250
|
+
const outcomes = await Promise.all(Array.from({ length: ip_limit * 2 }, () => attempt(fixture, XFF_BURST, 'probe_burst')));
|
|
251
|
+
const statuses = outcomes.map((o) => o.status);
|
|
252
|
+
const refused = statuses.filter((status) => status === 401).length;
|
|
253
|
+
const limited = statuses.filter((status) => status === 429).length;
|
|
254
|
+
// Exactly the cap: every admitted attempt fails and records, and the
|
|
255
|
+
// ones past it — waiting for a slot — are refused as those failures
|
|
256
|
+
// record, so the count is the same however the burst interleaves.
|
|
257
|
+
assert.strictEqual(refused, ip_limit, `exactly ${ip_limit} may reach the verify (${statuses})`);
|
|
258
|
+
assert.strictEqual(refused + limited, ip_limit * 2, `the rest must be 429 (${statuses})`);
|
|
259
|
+
// And the window is spent: the burst can't have left budget behind.
|
|
260
|
+
const after = await attempt(fixture, XFF_BURST, 'probe_burst');
|
|
261
|
+
assert.strictEqual(after.status, 429, 'the burst must leave the per-IP bucket full');
|
|
262
|
+
});
|
|
263
|
+
test.skipIf(!concurrent_transactions)(`a concurrent burst of ${ip_limit * 2} correct logins all succeed past the per-IP cap`, async () => {
|
|
264
|
+
const fixture = await setup_test();
|
|
265
|
+
const account = await fixture.create_account({
|
|
266
|
+
username: 'burst_correct',
|
|
267
|
+
password_value: 'burst_correct_password'
|
|
268
|
+
});
|
|
269
|
+
const statuses = await Promise.all(Array.from({ length: ip_limit * 2 }, () => succeed(fixture, XFF_CORRECT_BURST, account.account.username, 'burst_correct_password')));
|
|
270
|
+
assert.deepStrictEqual(statuses, Array(ip_limit * 2).fill(200), 'the logins past the cap wait for a slot rather than being refused');
|
|
271
|
+
});
|
|
272
|
+
test.skipIf(base_url === null)('every X-Forwarded-For line is read (a spoofed first line picks no bucket)', async () => {
|
|
273
|
+
assert.ok(base_url !== null);
|
|
274
|
+
const fixture = await setup_test();
|
|
275
|
+
for (let i = 0; i < ip_limit; i++) {
|
|
276
|
+
await attempt(fixture, XFF_REPEATED_LINES, 'probe_xff_lines');
|
|
277
|
+
}
|
|
278
|
+
const exhausted = await attempt(fixture, XFF_REPEATED_LINES, 'probe_xff_lines');
|
|
279
|
+
assert.strictEqual(exhausted.status, 429, 'the forwarded IP must be exhausted first');
|
|
280
|
+
// The client's own line first, the address the proxy appended last.
|
|
281
|
+
// A backend that read only the first line would key this on the
|
|
282
|
+
// fresh spoofed address and answer 401.
|
|
283
|
+
const url = new URL(base_url);
|
|
284
|
+
const body = JSON.stringify({ username: 'probe_xff_lines', password: PROBE_PASSWORD });
|
|
285
|
+
const request = `POST ${login_path} HTTP/1.1\r\n` +
|
|
286
|
+
`host: ${url.host}\r\n` +
|
|
287
|
+
`content-type: application/json\r\n` +
|
|
288
|
+
`x-forwarded-for: ${XFF_SPOOFED_LINE}\r\n` +
|
|
289
|
+
`x-forwarded-for: ${XFF_REPEATED_LINES}\r\n` +
|
|
290
|
+
`content-length: ${body.length}\r\n` +
|
|
291
|
+
`connection: close\r\n` +
|
|
292
|
+
`\r\n` +
|
|
293
|
+
body;
|
|
294
|
+
const { received } = await raw_http_exchange(base_url, request, {
|
|
295
|
+
read_timeout_ms: RAW_READ_TIMEOUT_MS,
|
|
296
|
+
until: 'response'
|
|
297
|
+
});
|
|
298
|
+
const response = raw_http_parse_response(received);
|
|
299
|
+
assert.ok(response, `no whole response; received: ${received.slice(0, 200)}`);
|
|
300
|
+
assert.strictEqual(response.status, 429, `the request must key on the proxy-appended line (body: ${response.body})`);
|
|
301
|
+
});
|
|
148
302
|
test('a success does not refund the per-IP budget (distributed-spray backstop)', async () => {
|
|
149
303
|
const fixture = await setup_test();
|
|
150
304
|
// The attacker's own account. Minting rides the setup path from
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"query_shape.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/query_shape.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;
|
|
1
|
+
{"version":3,"file":"query_shape.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/query_shape.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAwC9B,OAAO,KAAK,EAAE,uCAAuC,EAAE,MAAM,YAAY,CAAC;AAG1E;;;;;GAKG;AACH,MAAM,MAAM,0BAA0B,GAAG,uCAAuC,CAAC;AAQjF,eAAO,MAAM,gCAAgC,GAAI,SAAS,0BAA0B,KAAG,IAkJtF,CAAC"}
|
|
@@ -12,8 +12,9 @@ import '../assert_dev_env.js';
|
|
|
12
12
|
* pinned here on both backends:
|
|
13
13
|
*
|
|
14
14
|
* - **`GET /api/account/status`** — NO query schema: a duplicated `acting`
|
|
15
|
-
* reads first-wins (the persona resolves),
|
|
16
|
-
*
|
|
15
|
+
* reads first-wins (the persona resolves), a malformed `acting` answers
|
|
16
|
+
* account-only (never a default-actor resolve), unknown keys are ignored,
|
|
17
|
+
* and an anonymous caller with junk query hears 401, never a query 400.
|
|
17
18
|
* - **the JSON-RPC GET endpoint** — `method` / `id` / `params` read
|
|
18
19
|
* first-wins; a duplicated `method` must dispatch the first one on every
|
|
19
20
|
* backend (a last-wins reader dispatches a different method per backend on
|
|
@@ -55,6 +56,27 @@ export const describe_query_shape_cross_tests = (options) => {
|
|
|
55
56
|
const body = (await res.json());
|
|
56
57
|
assert.strictEqual(body.actor?.id, fixture.actor.id, 'first acting occurrence must win');
|
|
57
58
|
});
|
|
59
|
+
test_if(capabilities.account_status, 'status: a malformed acting is a missing persona, not a default-actor resolve', async () => {
|
|
60
|
+
const fixture = await setup_test();
|
|
61
|
+
// The keeper has one actor: an omitted selector resolves it, while a
|
|
62
|
+
// malformed one answers account-only.
|
|
63
|
+
const omitted = await fixture.transport(STATUS_PATH, {
|
|
64
|
+
method: 'GET',
|
|
65
|
+
headers: fixture.create_session_headers()
|
|
66
|
+
});
|
|
67
|
+
assert.strictEqual(omitted.status, 200);
|
|
68
|
+
const omitted_body = (await omitted.json());
|
|
69
|
+
assert.strictEqual(omitted_body.actor?.id, fixture.actor.id);
|
|
70
|
+
for (const raw of ['not-a-uuid', '', `"${fixture.actor.id}"`]) {
|
|
71
|
+
const res = await fixture.transport(`${STATUS_PATH}?acting=${encodeURIComponent(raw)}`, {
|
|
72
|
+
method: 'GET',
|
|
73
|
+
headers: fixture.create_session_headers()
|
|
74
|
+
});
|
|
75
|
+
assert.strictEqual(res.status, 200, raw);
|
|
76
|
+
const body = (await res.json());
|
|
77
|
+
assert.strictEqual(body.actor, null, raw);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
58
80
|
test_if(capabilities.account_status, 'status: anonymous with duplicated/unknown query keys → 401, never a query 400', async () => {
|
|
59
81
|
const fixture = await setup_test();
|
|
60
82
|
const anon = fixture.fresh_transport();
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"role_grant_participation.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/role_grant_participation.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;
|
|
1
|
+
{"version":3,"file":"role_grant_participation.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/role_grant_participation.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAqD9B,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAE5C,0EAA0E;AAC1E,MAAM,WAAW,sCAAsC;IACtD,+DAA+D;IAC/D,QAAQ,CAAC,UAAU,EAAE,SAAS,CAAC;IAC/B,6CAA6C;IAC7C,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED,eAAO,MAAM,6CAA6C,GACzD,SAAS,sCAAsC,KAC7C,IAuQF,CAAC"}
|
|
@@ -15,6 +15,15 @@ import '../assert_dev_env.js';
|
|
|
15
15
|
* re-assigning the active grant is idempotent (same id).
|
|
16
16
|
* - **consent flow** — `role_grant_offer_create` of `participant` (admin-only)
|
|
17
17
|
* → the recipient `role_grant_offer_accept`s → a role_grant lands.
|
|
18
|
+
* - **offer replay** — re-accepting an accepted offer whose grant still
|
|
19
|
+
* stands answers `200` with the same `role_grant_id` and writes no audit
|
|
20
|
+
* row; once the grant is revoked, the replay is refused `invalid_request`
|
|
21
|
+
* with reason `role_grant_offer_terminal` (never the revoked grant's id).
|
|
22
|
+
* - **no self-offer, in either case** — an admin offering to its own
|
|
23
|
+
* account is refused `invalid_params` with reason
|
|
24
|
+
* `role_grant_offer_self_target`, whether the id is sent lowercase or
|
|
25
|
+
* uppercase: both spines canonicalize the id before comparing it with the
|
|
26
|
+
* grantor's account.
|
|
18
27
|
* - **scoped assign** — `role_grant_assign` of `participant` with a paired
|
|
19
28
|
* `scope_kind` + `scope_id` lands a scoped grant carrying both, read back
|
|
20
29
|
* through `admin_account_list`, distinct from the global grant.
|
|
@@ -32,6 +41,9 @@ import '../assert_dev_env.js';
|
|
|
32
41
|
*/
|
|
33
42
|
import { describe, test, assert } from 'vitest';
|
|
34
43
|
import { rpc_call } from '../rpc_helpers.js';
|
|
44
|
+
import { AuditLogListOutput } from '../../auth/admin_action_specs.js';
|
|
45
|
+
import { ERROR_ROLE_GRANT_OFFER_SELF_TARGET, ERROR_ROLE_GRANT_OFFER_TERMINAL } from '../../auth/role_grant_offer_action_specs.js';
|
|
46
|
+
import { JSONRPC_INVALID_PARAMS, JSONRPC_INVALID_REQUEST } from '../../http/jsonrpc.js';
|
|
35
47
|
import { SPINE_PARTICIPANT_ROLE, SPINE_RPC_PATH } from './spine_surface_constants.js';
|
|
36
48
|
export const describe_role_grant_participation_cross_tests = (options) => {
|
|
37
49
|
const { setup_test } = options;
|
|
@@ -100,5 +112,57 @@ export const describe_role_grant_participation_cross_tests = (options) => {
|
|
|
100
112
|
assert.ok(accepted.ok, `accept must succeed: ${JSON.stringify(accepted)}`);
|
|
101
113
|
assert.ok(typeof accepted.result.role_grant_id === 'string', 'accept lands a role_grant for the app-role');
|
|
102
114
|
});
|
|
115
|
+
/** Offer `participant` to `recipient` as the keeper and accept it; returns the ids. */
|
|
116
|
+
const offer_and_accept = async (fixture, recipient) => {
|
|
117
|
+
const created = await rpc(fixture.transport, 'role_grant_offer_create', { to_account_id: recipient.account.id, role: SPINE_PARTICIPANT_ROLE }, fixture.create_session_headers());
|
|
118
|
+
assert.ok(created.ok, `offer create must succeed: ${JSON.stringify(created)}`);
|
|
119
|
+
const offer_id = created.result.offer.id;
|
|
120
|
+
const accepted = await rpc(fixture.fresh_transport(), 'role_grant_offer_accept', { offer_id }, recipient.create_session_headers());
|
|
121
|
+
assert.ok(accepted.ok, `accept must succeed: ${JSON.stringify(accepted)}`);
|
|
122
|
+
const { role_grant_id } = accepted.result;
|
|
123
|
+
return { offer_id, role_grant_id };
|
|
124
|
+
};
|
|
125
|
+
/** How many `event_type` audit rows the keeper can list. */
|
|
126
|
+
const count_audit_rows = async (fixture, event_type) => {
|
|
127
|
+
const listed = await rpc(fixture.transport, 'audit_log_list', { event_type }, fixture.create_session_headers());
|
|
128
|
+
assert.ok(listed.ok, `audit_log_list must succeed: ${JSON.stringify(listed)}`);
|
|
129
|
+
return AuditLogListOutput.parse(listed.result).events.length;
|
|
130
|
+
};
|
|
131
|
+
test('re-accepting an accepted offer answers the same grant and writes no audit row', async () => {
|
|
132
|
+
const fixture = await setup_test();
|
|
133
|
+
const recipient = await fixture.create_account({ username: 'participation_replayer' });
|
|
134
|
+
const { offer_id, role_grant_id } = await offer_and_accept(fixture, recipient);
|
|
135
|
+
const accept_rows = await count_audit_rows(fixture, 'role_grant_offer_accept');
|
|
136
|
+
const create_rows = await count_audit_rows(fixture, 'role_grant_create');
|
|
137
|
+
assert.ok(accept_rows >= 1, 'the accept itself is audited');
|
|
138
|
+
const replayed = await rpc(fixture.fresh_transport(), 'role_grant_offer_accept', { offer_id }, recipient.create_session_headers());
|
|
139
|
+
assert.ok(replayed.ok, `a replay of a held grant must succeed: ${JSON.stringify(replayed)}`);
|
|
140
|
+
assert.strictEqual(replayed.result.role_grant_id, role_grant_id, 'a replay answers the grant the accept minted');
|
|
141
|
+
assert.strictEqual(await count_audit_rows(fixture, 'role_grant_offer_accept'), accept_rows, 'a replay writes no role_grant_offer_accept row');
|
|
142
|
+
assert.strictEqual(await count_audit_rows(fixture, 'role_grant_create'), create_rows, 'a replay writes no role_grant_create row');
|
|
143
|
+
});
|
|
144
|
+
test('re-accepting an offer whose grant was revoked is refused as terminal', async () => {
|
|
145
|
+
const fixture = await setup_test();
|
|
146
|
+
const recipient = await fixture.create_account({ username: 'participation_revoked' });
|
|
147
|
+
const { offer_id, role_grant_id } = await offer_and_accept(fixture, recipient);
|
|
148
|
+
const revoked = await rpc(fixture.transport, 'role_grant_revoke', { actor_id: recipient.actor.id, role_grant_id }, fixture.create_session_headers());
|
|
149
|
+
assert.ok(revoked.ok, `revoke must succeed: ${JSON.stringify(revoked)}`);
|
|
150
|
+
const replayed = await rpc(fixture.fresh_transport(), 'role_grant_offer_accept', { offer_id }, recipient.create_session_headers());
|
|
151
|
+
assert.ok(!replayed.ok, `a replay after revoke must be refused: ${JSON.stringify(replayed)}`);
|
|
152
|
+
assert.strictEqual(replayed.status, 400);
|
|
153
|
+
assert.strictEqual(replayed.error.code, JSONRPC_INVALID_REQUEST);
|
|
154
|
+
assert.deepStrictEqual(replayed.error.data, { reason: ERROR_ROLE_GRANT_OFFER_TERMINAL });
|
|
155
|
+
});
|
|
156
|
+
test('an admin offering to its own account is refused, whatever the id case', async () => {
|
|
157
|
+
const fixture = await setup_test();
|
|
158
|
+
const own_id = fixture.account.id;
|
|
159
|
+
for (const to_account_id of [own_id, own_id.toUpperCase()]) {
|
|
160
|
+
const res = await rpc(fixture.transport, 'role_grant_offer_create', { to_account_id, role: SPINE_PARTICIPANT_ROLE }, fixture.create_session_headers());
|
|
161
|
+
assert.ok(!res.ok, `a self-offer must be refused (${to_account_id}): ${JSON.stringify(res)}`);
|
|
162
|
+
assert.strictEqual(res.status, 400, to_account_id);
|
|
163
|
+
assert.strictEqual(res.error.code, JSONRPC_INVALID_PARAMS, to_account_id);
|
|
164
|
+
assert.deepStrictEqual(res.error.data, { reason: ERROR_ROLE_GRANT_OFFER_SELF_TARGET }, to_account_id);
|
|
165
|
+
}
|
|
166
|
+
});
|
|
103
167
|
});
|
|
104
168
|
};
|
|
@@ -72,7 +72,7 @@ export declare const SPINE_ROLE_GATED_WS_PATH = "/api/gated/ws";
|
|
|
72
72
|
export declare const SPINE_ROLE_GATED_WS_ROLES: ReadonlyArray<string>;
|
|
73
73
|
/**
|
|
74
74
|
* Committed expected-schema fixture for the spine `/ready` deploy gate — the
|
|
75
|
-
* column map a fresh full spine bootstrap (auth + cell +
|
|
75
|
+
* column map a fresh full spine bootstrap (auth + cell + fact)
|
|
76
76
|
* produces. Resolved relative to this module so the spawned TS binary (which
|
|
77
77
|
* imports this source under its loader) reads it off disk via `node:fs`.
|
|
78
78
|
* Regenerated + guarded by `src/test/cross_backend/spine_expected_schema.db.test.ts`.
|
|
@@ -75,7 +75,7 @@ export const SPINE_ROLE_GATED_WS_ROLES = Object.freeze([
|
|
|
75
75
|
]);
|
|
76
76
|
/**
|
|
77
77
|
* Committed expected-schema fixture for the spine `/ready` deploy gate — the
|
|
78
|
-
* column map a fresh full spine bootstrap (auth + cell +
|
|
78
|
+
* column map a fresh full spine bootstrap (auth + cell + fact)
|
|
79
79
|
* produces. Resolved relative to this module so the spawned TS binary (which
|
|
80
80
|
* imports this source under its loader) reads it off disk via `node:fs`.
|
|
81
81
|
* Regenerated + guarded by `src/test/cross_backend/spine_expected_schema.db.test.ts`.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ws_action_rate_limit.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/ws_action_rate_limit.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;
|
|
1
|
+
{"version":3,"file":"ws_action_rate_limit.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/ws_action_rate_limit.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAkE9B,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAgD5C,+DAA+D;AAC/D,MAAM,WAAW,iCAAiC;IACjD,2EAA2E;IAC3E,QAAQ,CAAC,UAAU,EAAE,SAAS,CAAC;IAC/B,2EAA2E;IAC3E,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,gDAAgD;IAChD,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,yEAAyE;IACzE,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B;;;;OAIG;IACH,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;CAC9B;AAkBD;;;;GAIG;AACH,eAAO,MAAM,yCAAyC,GACrD,SAAS,iCAAiC,KACxC,IAwSF,CAAC"}
|
|
@@ -23,7 +23,9 @@ import '../assert_dev_env.js';
|
|
|
23
23
|
* 'ip'`, public) with malformed params, from one `X-Forwarded-For` address
|
|
24
24
|
* over anonymous HTTP and over a WS whose upgrade carried the same header:
|
|
25
25
|
* the call past the cap is refused on both, while a second address keeps its
|
|
26
|
-
* own budget on both.
|
|
26
|
+
* own budget on both. Over HTTP, IPv6 addresses bucket by their `/64`:
|
|
27
|
+
* another address in the same `/64` is refused once one has spent the
|
|
28
|
+
* budget, while one in the next `/64` keeps its own.
|
|
27
29
|
* - **account axis at the dispatchers** — `cell_create` (`rate_limit:
|
|
28
30
|
* 'account'`, on both spines' RPC and WS mounts) with malformed params: the
|
|
29
31
|
* call past the cap answers `429` over HTTP and `rate_limited` over WS, calls
|
|
@@ -84,6 +86,13 @@ const IP_LIMITED_BAD_PARAMS = { nonce: 'not_a_number' };
|
|
|
84
86
|
*/
|
|
85
87
|
const XFF_IP_EXHAUSTED = '198.51.100.1';
|
|
86
88
|
const XFF_IP_FRESH = '198.51.100.2';
|
|
89
|
+
/**
|
|
90
|
+
* RFC 3849 documentation addresses: two in one `/64` — the second differs past
|
|
91
|
+
* bit 64 only — and one in the next `/64`. Distinct from `login_security.ts`'s.
|
|
92
|
+
*/
|
|
93
|
+
const XFF_V6_EXHAUSTED = '2001:db8:e3a:1::1';
|
|
94
|
+
const XFF_V6_SAME_64 = '2001:db8:e3a:1:8000::2';
|
|
95
|
+
const XFF_V6_OTHER_64 = '2001:db8:e3a:2::1';
|
|
87
96
|
/** Generous per-response wait — the cases assert the server's answer, not a timeout. */
|
|
88
97
|
const RESPONSE_TIMEOUT_MS = 5000;
|
|
89
98
|
const outcome_from_frame = (status, frame) => ({
|
|
@@ -254,5 +263,16 @@ export const describe_ws_action_rate_limit_cross_tests = (options) => {
|
|
|
254
263
|
await ws_fresh.close();
|
|
255
264
|
}
|
|
256
265
|
});
|
|
266
|
+
test('an IP-limited action buckets IPv6 clients by /64', async () => {
|
|
267
|
+
const fixture = await setup_test();
|
|
268
|
+
const call = (forwarded_for) => call_http(fixture, IP_LIMITED_METHOD, IP_LIMITED_BAD_PARAMS, {
|
|
269
|
+
'x-forwarded-for': forwarded_for
|
|
270
|
+
});
|
|
271
|
+
for (let i = 1; i <= max_attempts; i++) {
|
|
272
|
+
assert_charged_not_limited(await call(XFF_V6_EXHAUSTED), `call ${i} from ${XFF_V6_EXHAUSTED}`);
|
|
273
|
+
}
|
|
274
|
+
assert_rate_limited(await call(XFF_V6_SAME_64), `a call from ${XFF_V6_SAME_64}, same /64`);
|
|
275
|
+
assert_charged_not_limited(await call(XFF_V6_OTHER_64), `a call from ${XFF_V6_OTHER_64}, the next /64`);
|
|
276
|
+
});
|
|
257
277
|
});
|
|
258
278
|
};
|
|
@@ -105,7 +105,7 @@ export const SchemaSnapshot = z.object({
|
|
|
105
105
|
* to the schema snapshot — is a gated fact, not a latent interop break.
|
|
106
106
|
*/
|
|
107
107
|
export const MigrationTrackerEntry = z.object({
|
|
108
|
-
/** Migration namespace (e.g. `
|
|
108
|
+
/** Migration namespace (e.g. `fuz_auth`, `fuz_cell`). */
|
|
109
109
|
namespace: z.string(),
|
|
110
110
|
/** Migration name within the namespace (e.g. `full_cell_schema`). */
|
|
111
111
|
name: z.string(),
|