@fuzdev/fuz_app 0.75.0 → 0.77.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/auth/CLAUDE.md +4 -0
- package/dist/auth/account_routes.d.ts.map +1 -1
- package/dist/auth/account_routes.js +19 -14
- package/dist/db/CLAUDE.md +4 -3
- package/dist/db/cell_queries.d.ts +0 -23
- package/dist/db/cell_queries.d.ts.map +1 -1
- package/dist/db/cell_queries.js +0 -30
- package/dist/server/serve_fact_route.d.ts +84 -33
- package/dist/server/serve_fact_route.d.ts.map +1 -1
- package/dist/server/serve_fact_route.js +242 -141
- package/dist/testing/CLAUDE.md +34 -1
- package/dist/testing/cross_backend/default_spine_surface.d.ts +26 -1
- package/dist/testing/cross_backend/default_spine_surface.d.ts.map +1 -1
- package/dist/testing/cross_backend/default_spine_surface.js +6 -4
- package/dist/testing/cross_backend/role_grant_offer_notification_ws.d.ts +24 -0
- package/dist/testing/cross_backend/role_grant_offer_notification_ws.d.ts.map +1 -0
- package/dist/testing/cross_backend/role_grant_offer_notification_ws.js +256 -0
- package/dist/testing/integration.d.ts.map +1 -1
- package/dist/testing/integration.js +78 -0
- package/package.json +5 -5
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import '../assert_dev_env.js';
|
|
2
|
+
/**
|
|
3
|
+
* Cross-process **role-grant-offer lifecycle WS notification** suite — the
|
|
4
|
+
* machinery proof for the consentful-role-grants notification fan-out across
|
|
5
|
+
* any spine backend. Covers all seven server-initiated notifications:
|
|
6
|
+
*
|
|
7
|
+
* - `role_grant_offer_received` → recipient (offer created)
|
|
8
|
+
* - `role_grant_offer_accepted` → grantor (recipient accepts)
|
|
9
|
+
* - `role_grant_offer_declined` → grantor (recipient declines)
|
|
10
|
+
* - `role_grant_offer_retracted` → recipient (grantor retracts)
|
|
11
|
+
* - `role_grant_revoke` (flat, omits `revoked_by`) → revokee (active grant revoked)
|
|
12
|
+
* - `role_grant_offer_supersede` → each superseded sibling's grantor, fired on
|
|
13
|
+
* BOTH the accept-cascade (`reason: 'sibling_accepted'`) and the
|
|
14
|
+
* revoke-cascade (`reason: 'role_grant_revoked'`)
|
|
15
|
+
*
|
|
16
|
+
* These exercise only spine primitives (accounts, role-grants, offers, WS
|
|
17
|
+
* notifications) — zero consumer domain — so the suite lives here and runs
|
|
18
|
+
* against any backend that wires the standard RPC actions' `notification_sender`
|
|
19
|
+
* and mounts a registered WS socket: fuz_app's own spine self-tests
|
|
20
|
+
* (`testing_spine_server` + the Rust `testing_spine_stub`) and downstream
|
|
21
|
+
* twin-impl consumers (the fuz_forge Deno/Hono + Rust `fuz_forge_server`
|
|
22
|
+
* backends) alike.
|
|
23
|
+
*
|
|
24
|
+
* Each case is a *targeted* server-initiated notification (vs the broadcast in
|
|
25
|
+
* a `repo_updated`-style suite), so it opens the affected counterparty's socket,
|
|
26
|
+
* drives the lifecycle RPC over HTTP, then asserts the frame lands on that
|
|
27
|
+
* socket and strict-parses against the canonical wire schema — the guard
|
|
28
|
+
* against serialization drift (field / null / datetime / the flat revoke shape /
|
|
29
|
+
* the supersede `reason` + `cause_id`).
|
|
30
|
+
*
|
|
31
|
+
* Sends are queued on the post-commit drain (handler-emit, not audit-derived),
|
|
32
|
+
* so a frame may land a beat after the RPC resolves — `WsClient.wait_for` polls
|
|
33
|
+
* already-received messages then waits, absorbing the fan-out latency without a
|
|
34
|
+
* sleep, and its method+predicate filter ignores unrelated frames (e.g. the
|
|
35
|
+
* `received` push the recipient also gets). `ROLE_ADMIN` is the only
|
|
36
|
+
* admin-grantable role; accounts that already hold it can still be offered it
|
|
37
|
+
* again (a fresh pending row — the prior accept is terminal, no already-granted
|
|
38
|
+
* guard), and accept stays idempotent on the role_grant while still superseding
|
|
39
|
+
* pending siblings. Gated on `capabilities.ws`.
|
|
40
|
+
*
|
|
41
|
+
* Cross-process only: `create_ws_transport` needs a real bound socket, so wire
|
|
42
|
+
* it from a `*.cross.test.ts` file, never an in-process setup. Authed cookies
|
|
43
|
+
* come from the per-account session minted by `fixture.create_account` /
|
|
44
|
+
* `fixture.create_session_headers`.
|
|
45
|
+
*
|
|
46
|
+
* @module
|
|
47
|
+
*/
|
|
48
|
+
import { assert, describe } from 'vitest';
|
|
49
|
+
import { rpc_call } from '../rpc_helpers.js';
|
|
50
|
+
import { create_ws_transport } from '../transports/ws_transport.js';
|
|
51
|
+
import { is_notification_with } from '../transports/ws_client.js';
|
|
52
|
+
import { ROLE_ADMIN } from '../../auth/role_schema.js';
|
|
53
|
+
import { ROLE_GRANT_OFFER_RECEIVED_NOTIFICATION_METHOD, ROLE_GRANT_OFFER_ACCEPTED_NOTIFICATION_METHOD, ROLE_GRANT_OFFER_DECLINED_NOTIFICATION_METHOD, ROLE_GRANT_OFFER_RETRACTED_NOTIFICATION_METHOD, ROLE_GRANT_OFFER_SUPERSEDE_NOTIFICATION_METHOD, ROLE_GRANT_REVOKE_NOTIFICATION_METHOD, RoleGrantOfferReceivedParams, RoleGrantOfferAcceptedParams, RoleGrantOfferDeclinedParams, RoleGrantOfferRetractedParams, RoleGrantOfferSupersedeParams, RoleGrantRevokeParams, } from '../../auth/role_grant_offer_notifications.js';
|
|
54
|
+
import { test_if } from './capabilities.js';
|
|
55
|
+
/** JSON-RPC endpoint path — matches the spine's `/api/rpc` (and the forge's). */
|
|
56
|
+
const RPC_PATH = '/api/rpc';
|
|
57
|
+
/**
|
|
58
|
+
* Register the role-grant-offer WS notification suite — seven cases over a real
|
|
59
|
+
* upgrade, one per server-initiated notification (received / accepted /
|
|
60
|
+
* declined / retracted / revoke + supersede on both the accept and revoke
|
|
61
|
+
* cascades). Each opens the affected counterparty's socket, drives the
|
|
62
|
+
* lifecycle RPC, and strict-parses the delivered frame against its canonical
|
|
63
|
+
* params schema. Gated on `capabilities.ws`.
|
|
64
|
+
*/
|
|
65
|
+
export const describe_role_grant_offer_notification_ws_tests = (options) => {
|
|
66
|
+
const { setup_test, capabilities, base_url, ws_path } = options;
|
|
67
|
+
// -- shared helpers -------------------------------------------------------
|
|
68
|
+
/** Open a WS transport for a single session cookie (`<name>=<value>`). */
|
|
69
|
+
const open_ws = (cookie) => {
|
|
70
|
+
assert.ok(cookie, 'expected a session cookie for the WS upgrade');
|
|
71
|
+
return create_ws_transport({ base_url, ws_path, cookies: [cookie], origin: base_url });
|
|
72
|
+
};
|
|
73
|
+
/** Drive a JSON-RPC call over the fixture's HTTP transport. */
|
|
74
|
+
const rpc = (fixture, method, params, headers) => rpc_call({ app: fixture.transport, path: RPC_PATH, method, params, headers });
|
|
75
|
+
/**
|
|
76
|
+
* Create a fresh pending `ROLE_ADMIN` offer from `grantor_headers` (defaults
|
|
77
|
+
* to the keeper) to `to_account_id`, returning the created offer.
|
|
78
|
+
*/
|
|
79
|
+
const create_pending_offer = async (fixture, to_account_id, grantor_headers = fixture.create_session_headers()) => {
|
|
80
|
+
const res = await rpc(fixture, 'role_grant_offer_create', { to_account_id, role: ROLE_ADMIN }, grantor_headers);
|
|
81
|
+
if (!res.ok)
|
|
82
|
+
assert.fail(`offer create failed: ${JSON.stringify(res)}`);
|
|
83
|
+
return res.result.offer;
|
|
84
|
+
};
|
|
85
|
+
/**
|
|
86
|
+
* Materialize an active `ROLE_ADMIN` role_grant on `recipient` via a fresh
|
|
87
|
+
* keeper offer + recipient accept (idempotent on a grant the account already
|
|
88
|
+
* holds), returning its `role_grant_id` — the handle `role_grant_revoke`
|
|
89
|
+
* keys on.
|
|
90
|
+
*/
|
|
91
|
+
const create_active_role_grant = async (fixture, recipient) => {
|
|
92
|
+
const offer = await create_pending_offer(fixture, recipient.account.id);
|
|
93
|
+
const accepted = await rpc(fixture, 'role_grant_offer_accept', { offer_id: offer.id }, recipient.create_session_headers());
|
|
94
|
+
if (!accepted.ok)
|
|
95
|
+
assert.fail(`accept failed: ${JSON.stringify(accepted)}`);
|
|
96
|
+
return accepted.result.role_grant_id;
|
|
97
|
+
};
|
|
98
|
+
// -- tests ----------------------------------------------------------------
|
|
99
|
+
describe('role_grant_offer WS notifications (cross-process)', () => {
|
|
100
|
+
test_if(capabilities.ws, 'an offer create delivers role_grant_offer_received to the recipient WS', async () => {
|
|
101
|
+
const fixture = await setup_test();
|
|
102
|
+
// Seed a second admin account — admin so it can open the
|
|
103
|
+
// ROLE_ADMIN-gated WS (forge); harmless on the auth-only spine.
|
|
104
|
+
// `create_account` rides the real offer/accept handlers (that
|
|
105
|
+
// earlier notification lands before the socket opens, so it's not
|
|
106
|
+
// the one we observe).
|
|
107
|
+
const recipient = await fixture.create_account({ roles: [ROLE_ADMIN] });
|
|
108
|
+
const recipient_ws = await open_ws(recipient.create_session_headers().cookie);
|
|
109
|
+
try {
|
|
110
|
+
// The keeper (grantor, holds ROLE_ADMIN) offers the recipient a
|
|
111
|
+
// role over RPC — fresh pending offer, emits the notification
|
|
112
|
+
// post-commit.
|
|
113
|
+
const created = await rpc(fixture, 'role_grant_offer_create', { to_account_id: recipient.account.id, role: ROLE_ADMIN }, fixture.create_session_headers());
|
|
114
|
+
assert.isTrue(created.ok, `offer create failed: ${JSON.stringify(created)}`);
|
|
115
|
+
const frame = await recipient_ws.wait_for(is_notification_with(ROLE_GRANT_OFFER_RECEIVED_NOTIFICATION_METHOD, (p) => p.offer.to_account_id === recipient.account.id), 5000);
|
|
116
|
+
// Params strict-parse against the canonical wire schema — guards
|
|
117
|
+
// the serialization against field/null/datetime drift.
|
|
118
|
+
const params = RoleGrantOfferReceivedParams.parse(frame.params);
|
|
119
|
+
assert.strictEqual(params.offer.to_account_id, recipient.account.id);
|
|
120
|
+
assert.strictEqual(params.offer.role, ROLE_ADMIN);
|
|
121
|
+
}
|
|
122
|
+
finally {
|
|
123
|
+
await recipient_ws.close();
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
test_if(capabilities.ws, 'accept delivers role_grant_offer_accepted to the grantor WS', async () => {
|
|
127
|
+
const fixture = await setup_test();
|
|
128
|
+
const recipient = await fixture.create_account({ roles: [ROLE_ADMIN] });
|
|
129
|
+
// Grantor = keeper; open its socket BEFORE the recipient accepts.
|
|
130
|
+
const grantor = await open_ws(fixture.create_session_headers().cookie);
|
|
131
|
+
try {
|
|
132
|
+
const offer = await create_pending_offer(fixture, recipient.account.id);
|
|
133
|
+
const accepted = await rpc(fixture, 'role_grant_offer_accept', { offer_id: offer.id }, recipient.create_session_headers());
|
|
134
|
+
assert.isTrue(accepted.ok, `accept failed: ${JSON.stringify(accepted)}`);
|
|
135
|
+
const frame = await grantor.wait_for(is_notification_with(ROLE_GRANT_OFFER_ACCEPTED_NOTIFICATION_METHOD, (p) => p.offer.id === offer.id), 5000);
|
|
136
|
+
const params = RoleGrantOfferAcceptedParams.parse(frame.params);
|
|
137
|
+
assert.strictEqual(params.offer.id, offer.id);
|
|
138
|
+
assert.isNotNull(params.offer.accepted_at);
|
|
139
|
+
}
|
|
140
|
+
finally {
|
|
141
|
+
await grantor.close();
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
test_if(capabilities.ws, 'decline delivers role_grant_offer_declined to the grantor WS', async () => {
|
|
145
|
+
const fixture = await setup_test();
|
|
146
|
+
const recipient = await fixture.create_account({ roles: [ROLE_ADMIN] });
|
|
147
|
+
const grantor = await open_ws(fixture.create_session_headers().cookie);
|
|
148
|
+
try {
|
|
149
|
+
const offer = await create_pending_offer(fixture, recipient.account.id);
|
|
150
|
+
const declined = await rpc(fixture, 'role_grant_offer_decline', { offer_id: offer.id, reason: 'no thanks' }, recipient.create_session_headers());
|
|
151
|
+
assert.isTrue(declined.ok, `decline failed: ${JSON.stringify(declined)}`);
|
|
152
|
+
const frame = await grantor.wait_for(is_notification_with(ROLE_GRANT_OFFER_DECLINED_NOTIFICATION_METHOD, (p) => p.offer.id === offer.id), 5000);
|
|
153
|
+
const params = RoleGrantOfferDeclinedParams.parse(frame.params);
|
|
154
|
+
assert.strictEqual(params.offer.id, offer.id);
|
|
155
|
+
// Decline reason rides inside the offer row, not a sibling field.
|
|
156
|
+
assert.strictEqual(params.offer.decline_reason, 'no thanks');
|
|
157
|
+
}
|
|
158
|
+
finally {
|
|
159
|
+
await grantor.close();
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
test_if(capabilities.ws, 'retract delivers role_grant_offer_retracted to the recipient WS', async () => {
|
|
163
|
+
const fixture = await setup_test();
|
|
164
|
+
const recipient = await fixture.create_account({ roles: [ROLE_ADMIN] });
|
|
165
|
+
// Recipient learns their pending offer was pulled — open its socket.
|
|
166
|
+
const recipient_ws = await open_ws(recipient.create_session_headers().cookie);
|
|
167
|
+
try {
|
|
168
|
+
const offer = await create_pending_offer(fixture, recipient.account.id);
|
|
169
|
+
const retracted = await rpc(fixture, 'role_grant_offer_retract', { offer_id: offer.id }, fixture.create_session_headers());
|
|
170
|
+
assert.isTrue(retracted.ok, `retract failed: ${JSON.stringify(retracted)}`);
|
|
171
|
+
const frame = await recipient_ws.wait_for(is_notification_with(ROLE_GRANT_OFFER_RETRACTED_NOTIFICATION_METHOD, (p) => p.offer.id === offer.id), 5000);
|
|
172
|
+
const params = RoleGrantOfferRetractedParams.parse(frame.params);
|
|
173
|
+
assert.strictEqual(params.offer.id, offer.id);
|
|
174
|
+
assert.strictEqual(params.offer.to_account_id, recipient.account.id);
|
|
175
|
+
assert.isNotNull(params.offer.retracted_at);
|
|
176
|
+
}
|
|
177
|
+
finally {
|
|
178
|
+
await recipient_ws.close();
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
test_if(capabilities.ws, 'revoke delivers a flat role_grant_revoke to the revokee WS', async () => {
|
|
182
|
+
const fixture = await setup_test();
|
|
183
|
+
const revokee = await fixture.create_account({ roles: [ROLE_ADMIN] });
|
|
184
|
+
const role_grant_id = await create_active_role_grant(fixture, revokee);
|
|
185
|
+
const revokee_ws = await open_ws(revokee.create_session_headers().cookie);
|
|
186
|
+
try {
|
|
187
|
+
const revoked = await rpc(fixture, 'role_grant_revoke', { actor_id: revokee.actor.id, role_grant_id, reason: 'cleanup' }, fixture.create_session_headers());
|
|
188
|
+
assert.isTrue(revoked.ok, `revoke failed: ${JSON.stringify(revoked)}`);
|
|
189
|
+
const frame = await revokee_ws.wait_for(is_notification_with(ROLE_GRANT_REVOKE_NOTIFICATION_METHOD, (p) => p.role_grant_id === role_grant_id), 5000);
|
|
190
|
+
// Flat params — guards the no-`offer`-wrapper, `revoked_by`-omitted
|
|
191
|
+
// shape against drift.
|
|
192
|
+
const params = RoleGrantRevokeParams.parse(frame.params);
|
|
193
|
+
assert.strictEqual(params.role_grant_id, role_grant_id);
|
|
194
|
+
assert.strictEqual(params.role, ROLE_ADMIN);
|
|
195
|
+
assert.strictEqual(params.reason, 'cleanup');
|
|
196
|
+
assert.notProperty(frame.params, 'revoked_by');
|
|
197
|
+
}
|
|
198
|
+
finally {
|
|
199
|
+
await revokee_ws.close();
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
test_if(capabilities.ws, 'accept of a sibling delivers role_grant_offer_supersede to the other grantor WS', async () => {
|
|
203
|
+
const fixture = await setup_test();
|
|
204
|
+
// Three identities: keeper (grantor 1, socket), a second admin grantor
|
|
205
|
+
// (grantor 2), and the recipient.
|
|
206
|
+
const grantor2 = await fixture.create_account({ roles: [ROLE_ADMIN] });
|
|
207
|
+
const recipient = await fixture.create_account({ roles: [ROLE_ADMIN] });
|
|
208
|
+
// Two coexisting pending offers for the same (recipient, role) — keyed
|
|
209
|
+
// per grantor, so they don't upsert each other.
|
|
210
|
+
const keeper_offer = await create_pending_offer(fixture, recipient.account.id);
|
|
211
|
+
const grantor2_offer = await create_pending_offer(fixture, recipient.account.id, grantor2.create_session_headers());
|
|
212
|
+
// Open grantor 1 (keeper) — its offer is the one that gets superseded
|
|
213
|
+
// when the recipient accepts grantor 2's offer.
|
|
214
|
+
const grantor1_ws = await open_ws(fixture.create_session_headers().cookie);
|
|
215
|
+
try {
|
|
216
|
+
const accepted = await rpc(fixture, 'role_grant_offer_accept', { offer_id: grantor2_offer.id }, recipient.create_session_headers());
|
|
217
|
+
assert.isTrue(accepted.ok, `accept failed: ${JSON.stringify(accepted)}`);
|
|
218
|
+
const frame = await grantor1_ws.wait_for(is_notification_with(ROLE_GRANT_OFFER_SUPERSEDE_NOTIFICATION_METHOD, (p) => p.offer.id === keeper_offer.id), 5000);
|
|
219
|
+
const params = RoleGrantOfferSupersedeParams.parse(frame.params);
|
|
220
|
+
assert.strictEqual(params.offer.id, keeper_offer.id);
|
|
221
|
+
assert.strictEqual(params.reason, 'sibling_accepted');
|
|
222
|
+
// cause_id points at the accepted sibling (grantor 2's offer).
|
|
223
|
+
assert.strictEqual(params.cause_id, grantor2_offer.id);
|
|
224
|
+
assert.isNotNull(params.offer.superseded_at);
|
|
225
|
+
}
|
|
226
|
+
finally {
|
|
227
|
+
await grantor1_ws.close();
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
test_if(capabilities.ws, 'revoke supersedes a pending sibling offer and notifies its grantor WS', async () => {
|
|
231
|
+
const fixture = await setup_test();
|
|
232
|
+
const grantor = await fixture.create_account({ roles: [ROLE_ADMIN] });
|
|
233
|
+
const revokee = await fixture.create_account({ roles: [ROLE_ADMIN] });
|
|
234
|
+
const role_grant_id = await create_active_role_grant(fixture, revokee);
|
|
235
|
+
// A *pending* sibling offer for the same (account, role) — this is what
|
|
236
|
+
// the revoke cascade supersedes.
|
|
237
|
+
const sibling_offer = await create_pending_offer(fixture, revokee.account.id, grantor.create_session_headers());
|
|
238
|
+
// Watch the sibling grantor's socket for the supersede push.
|
|
239
|
+
const grantor_ws = await open_ws(grantor.create_session_headers().cookie);
|
|
240
|
+
try {
|
|
241
|
+
const revoked = await rpc(fixture, 'role_grant_revoke', { actor_id: revokee.actor.id, role_grant_id }, fixture.create_session_headers());
|
|
242
|
+
assert.isTrue(revoked.ok, `revoke failed: ${JSON.stringify(revoked)}`);
|
|
243
|
+
const frame = await grantor_ws.wait_for(is_notification_with(ROLE_GRANT_OFFER_SUPERSEDE_NOTIFICATION_METHOD, (p) => p.offer.id === sibling_offer.id), 5000);
|
|
244
|
+
const params = RoleGrantOfferSupersedeParams.parse(frame.params);
|
|
245
|
+
assert.strictEqual(params.offer.id, sibling_offer.id);
|
|
246
|
+
assert.strictEqual(params.reason, 'role_grant_revoked');
|
|
247
|
+
// cause_id points at the revoked role_grant.
|
|
248
|
+
assert.strictEqual(params.cause_id, role_grant_id);
|
|
249
|
+
assert.isNotNull(params.offer.superseded_at);
|
|
250
|
+
}
|
|
251
|
+
finally {
|
|
252
|
+
await grantor_ws.close();
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"integration.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/integration.ts"],"names":[],"mappings":"AAAA,OAAO,qBAAqB,CAAC;AAsB7B,OAAO,KAAK,EAAC,cAAc,EAAC,MAAM,2BAA2B,CAAC;AAM9D,OAAO,EAKN,KAAK,uBAAuB,EAC5B,MAAM,kBAAkB,CAAC;
|
|
1
|
+
{"version":3,"file":"integration.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/integration.ts"],"names":[],"mappings":"AAAA,OAAO,qBAAqB,CAAC;AAsB7B,OAAO,KAAK,EAAC,cAAc,EAAC,MAAM,2BAA2B,CAAC;AAM9D,OAAO,EAKN,KAAK,uBAAuB,EAC5B,MAAM,kBAAkB,CAAC;AAkB1B,OAAO,KAAK,EAAC,cAAc,EAAC,MAAM,oBAAoB,CAAC;AACvD,OAAO,EAAC,KAAK,mBAAmB,EAAC,MAAM,iCAAiC,CAAC;AACzE,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,0BAA0B,CAAC;AAGxD;;GAEG;AACH,MAAM,WAAW,8BAA8B;IAC9C;;;;OAIG;IACH,UAAU,EAAE,SAAS,CAAC;IACtB;;;;;;;;;OASG;IACH,cAAc,EAAE,cAAc,CAAC;IAC/B,kEAAkE;IAClE,YAAY,EAAE,mBAAmB,CAAC;IAClC;;;;OAIG;IACH,eAAe,EAAE,cAAc,CAAC,MAAM,CAAC,CAAC;IACxC;;;;;;;;;;;;OAYG;IACH,aAAa,EAAE,uBAAuB,CAAC;IACvC;;;;;;;OAOG;IACH,kBAAkB,CAAC,EAAE,MAAM,CAAC;CAC5B;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AACH,eAAO,MAAM,mCAAmC,GAC/C,SAAS,8BAA8B,KACrC,IAw6CF,CAAC"}
|
|
@@ -23,6 +23,7 @@ import { ErrorCoverageCollector, assert_error_coverage, DEFAULT_INTEGRATION_ERRO
|
|
|
23
23
|
import { is_public_auth } from '../http/auth_shape.js';
|
|
24
24
|
import { account_verify_action_spec, account_session_list_action_spec, account_session_revoke_action_spec, account_session_revoke_all_action_spec, account_token_create_action_spec, account_token_list_action_spec, account_token_revoke_action_spec, } from '../auth/account_action_specs.js';
|
|
25
25
|
import { invite_create_action_spec } from '../auth/admin_action_specs.js';
|
|
26
|
+
import { LoginOutput, AccountStatusOutput } from '../auth/account_routes.js';
|
|
26
27
|
import {} from './cross_backend/capabilities.js';
|
|
27
28
|
import { DEFAULT_TEST_PASSWORD } from './app_server.js';
|
|
28
29
|
/**
|
|
@@ -91,6 +92,14 @@ export const describe_standard_integration_tests = (options) => {
|
|
|
91
92
|
});
|
|
92
93
|
assert_error_coverage(error_collector, auth_routes.length > 0 ? auth_routes : route_specs, {
|
|
93
94
|
min_coverage: options.error_coverage_min ?? DEFAULT_INTEGRATION_ERROR_COVERAGE,
|
|
95
|
+
// Authorization denials (403) on these scoped auth routes — the
|
|
96
|
+
// credential-channel gate on /logout + /password, the invite gate on
|
|
97
|
+
// /signup — are exercised by the conformance + attack-surface suites,
|
|
98
|
+
// not this lifecycle suite. Drop 403 from this collector's denominator
|
|
99
|
+
// (same spirit as the [401, 403, 429] ignore in the attack-surface
|
|
100
|
+
// tightness defaults); otherwise #10 adding /logout's 403 to the spine
|
|
101
|
+
// surface tips the ratio under threshold here though the gate is tested.
|
|
102
|
+
ignore_statuses: [403],
|
|
94
103
|
});
|
|
95
104
|
});
|
|
96
105
|
// --- 1. Login/logout lifecycle ---
|
|
@@ -269,6 +278,75 @@ export const describe_standard_integration_tests = (options) => {
|
|
|
269
278
|
assert.deepStrictEqual(wrong_pw_keys, no_user_keys, 'Response keys must be identical to prevent account enumeration');
|
|
270
279
|
assert.strictEqual(wrong_pw_body.error, no_user_body.error, 'Error codes must be identical');
|
|
271
280
|
});
|
|
281
|
+
// Wire-shape gate: the successful `POST /login` body must strict-parse
|
|
282
|
+
// against `LoginOutput` (`{ok: true}`). `.strictObject` rejects any
|
|
283
|
+
// extra field, so a backend leaking `username` / `account_id` (the
|
|
284
|
+
// Rust spine's old shape) fails here on either impl.
|
|
285
|
+
test('successful login body strict-parses against LoginOutput', async () => {
|
|
286
|
+
const fixture = await options.setup_test();
|
|
287
|
+
const login_route = find_auth_route(route_specs, '/login', 'POST');
|
|
288
|
+
assert.ok(login_route, 'Expected POST /login route — ensure create_route_specs includes account routes');
|
|
289
|
+
const res = await fixture.transport(login_route.path, {
|
|
290
|
+
method: 'POST',
|
|
291
|
+
headers: {
|
|
292
|
+
host: 'localhost',
|
|
293
|
+
origin: 'http://localhost:5173',
|
|
294
|
+
'content-type': 'application/json',
|
|
295
|
+
},
|
|
296
|
+
body: JSON.stringify({
|
|
297
|
+
username: fixture.account.username,
|
|
298
|
+
password: DEFAULT_TEST_PASSWORD,
|
|
299
|
+
}),
|
|
300
|
+
});
|
|
301
|
+
assert.strictEqual(res.status, 200);
|
|
302
|
+
const body = await res.json();
|
|
303
|
+
// Throws on any extra or missing field — drift on either backend fails.
|
|
304
|
+
LoginOutput.parse(body);
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
// --- 1b. Account status body (strict schema) ---
|
|
308
|
+
describe('account status response body', () => {
|
|
309
|
+
// Wire-shape gate: the authenticated `GET /api/account/status` body
|
|
310
|
+
// must strict-parse against `AccountStatusOutput` — the full shape
|
|
311
|
+
// `{account: SessionAccountJson, actor: ActorSummaryJson | null,
|
|
312
|
+
// role_grants: RoleGrantSummaryJson[]}`. `.strictObject` rejects any
|
|
313
|
+
// extra or missing field on `account` / `actor` / each role_grant, so
|
|
314
|
+
// a backend returning the old narrow shape (Rust's `{account:{id,
|
|
315
|
+
// username}, role_grants:[{role}]}`) fails here on either impl. The
|
|
316
|
+
// fixture keeper is single-actor, so `actor` must be non-null and
|
|
317
|
+
// `role_grants` populated (keeper holds keeper + admin globally).
|
|
318
|
+
//
|
|
319
|
+
// `/status` is mounted at `create_app_server` time (it needs the
|
|
320
|
+
// `bootstrap_available` runtime state), not by `create_account_route_specs`
|
|
321
|
+
// and not listed in the declared surface, so we can't gate on `route_specs`.
|
|
322
|
+
// A backend that mounts only the account-route factory (e.g. a minimal
|
|
323
|
+
// in-process route set) doesn't serve it — probe at runtime and skip on
|
|
324
|
+
// 404. The full spine surfaces (in-process + cross-process) serve it, so
|
|
325
|
+
// the gate runs there. `find_auth_route` can't be used: `/status` isn't a
|
|
326
|
+
// `RestAuthRouteSuffix`.
|
|
327
|
+
test('authenticated status body strict-parses against AccountStatusOutput', async (ctx) => {
|
|
328
|
+
const fixture = await options.setup_test();
|
|
329
|
+
const login_route = find_auth_route(route_specs, '/login', 'POST');
|
|
330
|
+
assert.ok(login_route, 'Expected POST /login route — ensure create_route_specs includes account routes');
|
|
331
|
+
// `/status` is the sibling of `/login` under the same account prefix.
|
|
332
|
+
const status_path = login_route.path.replace(/\/login$/, '/status');
|
|
333
|
+
const res = await fixture.transport(status_path, {
|
|
334
|
+
method: 'GET',
|
|
335
|
+
headers: fixture.create_session_headers({ host: 'localhost' }),
|
|
336
|
+
});
|
|
337
|
+
if (res.status === 404) {
|
|
338
|
+
// Backend doesn't mount /status (minimal route set) — nothing to gate.
|
|
339
|
+
ctx.skip();
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
assert.strictEqual(res.status, 200);
|
|
343
|
+
const body = await res.json();
|
|
344
|
+
// Throws on any extra/missing field across account/actor/role_grants.
|
|
345
|
+
const parsed = AccountStatusOutput.parse(body);
|
|
346
|
+
// Single-actor keeper: actor resolved, role_grants populated.
|
|
347
|
+
assert.ok(parsed.actor, 'single-actor keeper must resolve a non-null actor');
|
|
348
|
+
assert.ok(parsed.role_grants.length > 0, 'single-actor keeper must have populated role_grants');
|
|
349
|
+
});
|
|
272
350
|
});
|
|
273
351
|
// --- 2. Cookie attributes ---
|
|
274
352
|
describe('cookie attributes', () => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fuzdev/fuz_app",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.77.0",
|
|
4
4
|
"description": "fullstack app library",
|
|
5
5
|
"glyph": "🗝",
|
|
6
6
|
"logo": "logo.svg",
|
|
@@ -63,10 +63,10 @@
|
|
|
63
63
|
},
|
|
64
64
|
"devDependencies": {
|
|
65
65
|
"@electric-sql/pglite": "^0.4.5",
|
|
66
|
-
"@fuzdev/blake3_wasm": "^0.1.
|
|
66
|
+
"@fuzdev/blake3_wasm": "^0.1.1",
|
|
67
67
|
"@fuzdev/fuz_code": "^0.45.1",
|
|
68
|
-
"@fuzdev/fuz_css": "^0.
|
|
69
|
-
"@fuzdev/fuz_ui": "^0.
|
|
68
|
+
"@fuzdev/fuz_css": "^0.61.1",
|
|
69
|
+
"@fuzdev/fuz_ui": "^0.197.0",
|
|
70
70
|
"@fuzdev/fuz_util": "^0.63.0",
|
|
71
71
|
"@fuzdev/gro": "^0.200.0",
|
|
72
72
|
"@hono/node-server": "^1.19.14",
|
|
@@ -94,7 +94,7 @@
|
|
|
94
94
|
"prettier-plugin-svelte": "^3.5.1",
|
|
95
95
|
"svelte": "^5.56.0",
|
|
96
96
|
"svelte-check": "^4.4.5",
|
|
97
|
-
"svelte-docinfo": "^0.2.
|
|
97
|
+
"svelte-docinfo": "^0.2.1",
|
|
98
98
|
"svelte2tsx": "^0.7.52",
|
|
99
99
|
"tslib": "^2.8.1",
|
|
100
100
|
"typescript": "^5.9.3",
|