@fuzdev/fuz_app 0.12.0 → 0.13.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.
Files changed (49) hide show
  1. package/dist/actions/action_codegen.d.ts.map +1 -1
  2. package/dist/auth/account_routes.d.ts +30 -0
  3. package/dist/auth/account_routes.d.ts.map +1 -1
  4. package/dist/auth/account_routes.js +44 -9
  5. package/dist/auth/admin_routes.d.ts.map +1 -1
  6. package/dist/auth/admin_routes.js +35 -2
  7. package/dist/auth/audit_log_routes.d.ts +2 -1
  8. package/dist/auth/audit_log_routes.d.ts.map +1 -1
  9. package/dist/auth/audit_log_routes.js +11 -2
  10. package/dist/auth/audit_log_schema.d.ts +1 -1
  11. package/dist/auth/audit_log_schema.d.ts.map +1 -1
  12. package/dist/auth/audit_log_schema.js +3 -1
  13. package/dist/auth/permit_queries.d.ts +19 -0
  14. package/dist/auth/permit_queries.d.ts.map +1 -1
  15. package/dist/auth/permit_queries.js +21 -0
  16. package/dist/auth/request_context.d.ts +10 -0
  17. package/dist/auth/request_context.d.ts.map +1 -1
  18. package/dist/auth/request_context.js +14 -0
  19. package/dist/hono_context.d.ts +7 -0
  20. package/dist/hono_context.d.ts.map +1 -1
  21. package/dist/realtime/sse_auth_guard.d.ts +23 -3
  22. package/dist/realtime/sse_auth_guard.d.ts.map +1 -1
  23. package/dist/realtime/sse_auth_guard.js +38 -2
  24. package/dist/realtime/subscriber_registry.d.ts +62 -17
  25. package/dist/realtime/subscriber_registry.d.ts.map +1 -1
  26. package/dist/realtime/subscriber_registry.js +64 -21
  27. package/dist/server/validate_nginx.d.ts.map +1 -1
  28. package/dist/server/validate_nginx.js +61 -7
  29. package/dist/testing/admin_integration.d.ts.map +1 -1
  30. package/dist/testing/admin_integration.js +8 -8
  31. package/dist/testing/app_server.d.ts +9 -0
  32. package/dist/testing/app_server.d.ts.map +1 -1
  33. package/dist/testing/app_server.js +4 -3
  34. package/dist/testing/data_exposure.d.ts.map +1 -1
  35. package/dist/testing/data_exposure.js +1 -20
  36. package/dist/testing/error_coverage.d.ts +93 -27
  37. package/dist/testing/error_coverage.d.ts.map +1 -1
  38. package/dist/testing/error_coverage.js +160 -67
  39. package/dist/testing/integration.d.ts.map +1 -1
  40. package/dist/testing/integration.js +6 -6
  41. package/dist/testing/integration_helpers.d.ts +17 -0
  42. package/dist/testing/integration_helpers.d.ts.map +1 -1
  43. package/dist/testing/integration_helpers.js +31 -0
  44. package/dist/testing/round_trip.d.ts.map +1 -1
  45. package/dist/testing/round_trip.js +41 -55
  46. package/dist/testing/sse_round_trip.d.ts +64 -0
  47. package/dist/testing/sse_round_trip.d.ts.map +1 -0
  48. package/dist/testing/sse_round_trip.js +241 -0
  49. package/package.json +1 -1
@@ -12,10 +12,11 @@ import { describe, test, beforeAll, afterAll } from 'vitest';
12
12
  import { ROLE_ADMIN } from '../auth/role_schema.js';
13
13
  import { create_test_app } from './app_server.js';
14
14
  import { create_pglite_factory } from './db.js';
15
- import { assert_response_matches_spec } from './integration_helpers.js';
15
+ import { assert_response_matches_spec, pick_auth_headers } from './integration_helpers.js';
16
16
  import { resolve_valid_path, generate_valid_body } from './schema_generators.js';
17
17
  import { run_migrations } from '../db/migrate.js';
18
18
  import { AUTH_MIGRATION_NS } from '../auth/migrations.js';
19
+ import { create_stub_app_server_context } from './stubs.js';
19
20
  /**
20
21
  * Run schema-driven round-trip validation tests.
21
22
  *
@@ -37,6 +38,12 @@ export const describe_round_trip_validation = (options) => {
37
38
  await run_migrations(db, [AUTH_MIGRATION_NS]);
38
39
  };
39
40
  const factories = options.db_factories ?? [create_pglite_factory(init_schema)];
41
+ // Pre-compute route specs at describe time so test.each can register one
42
+ // test per route. Mirrors create_test_app's route set (consumer routes
43
+ // only — bootstrap routes are not added unless `bootstrap` is configured
44
+ // on AppServerOptions, which create_test_app doesn't do).
45
+ const stub_ctx = create_stub_app_server_context(options.session_options);
46
+ const describe_time_specs = options.create_route_specs(stub_ctx);
40
47
  for (const factory of factories) {
41
48
  const describe_fn = factory.skip ? describe.skip : describe;
42
49
  describe_fn(`round-trip validation (${factory.name})`, () => {
@@ -66,62 +73,41 @@ export const describe_round_trip_validation = (options) => {
66
73
  await test_app.cleanup();
67
74
  await factory.close(db);
68
75
  });
69
- test('all routes produce schema-valid responses', async () => {
70
- for (const spec of test_app.route_specs) {
71
- const route_key = `${spec.method} ${spec.path}`;
72
- if (skip_set.has(route_key))
73
- continue;
74
- // Resolve URL with valid param values
75
- const url = resolve_valid_path(spec.path, spec.params);
76
- // Generate or override request body
77
- const override = options.input_overrides?.get(route_key);
78
- const body = override ?? generate_valid_body(spec.input);
79
- // Pick auth headers based on route auth requirement
80
- const headers = pick_auth_headers(spec, test_app, authed_account, admin_account);
81
- // Fire request
82
- const request_init = {
83
- method: spec.method,
84
- headers: {
85
- ...headers,
86
- ...(body ? { 'content-type': 'application/json' } : {}),
87
- },
88
- ...(body ? { body: JSON.stringify(body) } : {}),
89
- };
90
- const res = await test_app.app.request(url, request_init); // eslint-disable-line no-await-in-loop
91
- // Skip SSE responses — streaming bodies can't be parsed as JSON
92
- if (res.headers.get('Content-Type')?.includes('text/event-stream')) {
93
- await res.body?.cancel(); // eslint-disable-line no-await-in-loop
94
- continue;
95
- }
96
- // Validate response against declared schemas
97
- try {
98
- await assert_response_matches_spec(test_app.route_specs, spec.method, url, res); // eslint-disable-line no-await-in-loop
99
- }
100
- catch (e) {
101
- // Re-throw with route context for easier debugging
102
- throw new Error(`Round-trip validation failed for ${route_key} (status ${res.status}): ${e.message}`);
103
- }
76
+ test.each(describe_time_specs)('$method $path produces schema-valid response', async (spec) => {
77
+ const route_key = `${spec.method} ${spec.path}`;
78
+ if (skip_set.has(route_key))
79
+ return;
80
+ // Resolve URL with valid param values
81
+ const url = resolve_valid_path(spec.path, spec.params);
82
+ // Generate or override request body
83
+ const override = options.input_overrides?.get(route_key);
84
+ const body = override ?? generate_valid_body(spec.input);
85
+ // Pick auth headers based on route auth requirement
86
+ const headers = pick_auth_headers(spec, test_app, authed_account, admin_account);
87
+ // Fire request
88
+ const request_init = {
89
+ method: spec.method,
90
+ headers: {
91
+ ...headers,
92
+ ...(body ? { 'content-type': 'application/json' } : {}),
93
+ },
94
+ ...(body ? { body: JSON.stringify(body) } : {}),
95
+ };
96
+ const res = await test_app.app.request(url, request_init);
97
+ // Skip SSE responses streaming bodies can't be parsed as JSON
98
+ if (res.headers.get('Content-Type')?.includes('text/event-stream')) {
99
+ await res.body?.cancel();
100
+ return;
101
+ }
102
+ // Validate response against declared schemas
103
+ try {
104
+ await assert_response_matches_spec(test_app.route_specs, spec.method, url, res);
105
+ }
106
+ catch (e) {
107
+ // Re-throw with route context for easier debugging
108
+ throw new Error(`Round-trip validation failed for ${route_key} (status ${res.status}): ${e.message}`);
104
109
  }
105
110
  });
106
111
  });
107
112
  }
108
113
  };
109
- /**
110
- * Pick auth headers matching a route spec's auth requirement.
111
- */
112
- const pick_auth_headers = (spec, test_app, authed_account, admin_account) => {
113
- switch (spec.auth.type) {
114
- case 'none':
115
- return { host: 'localhost', origin: 'http://localhost:5173' };
116
- case 'authenticated':
117
- return authed_account.create_session_headers();
118
- case 'role':
119
- if (spec.auth.role === ROLE_ADMIN) {
120
- return admin_account.create_session_headers();
121
- }
122
- // Keeper role uses the bootstrapped account (which has keeper role)
123
- return test_app.create_session_headers();
124
- case 'keeper':
125
- return test_app.create_daemon_token_headers();
126
- }
127
- };
@@ -0,0 +1,64 @@
1
+ import './assert_dev_env.js';
2
+ import type { RouteSpec } from '../http/route_spec.js';
3
+ import type { AppServerContext, AppServerOptions } from '../server/app_server.js';
4
+ import type { SessionOptions } from '../auth/session_cookie.js';
5
+ import { type EventSpec } from '../realtime/sse.js';
6
+ import type { AuditLogEvent } from '../auth/audit_log_schema.js';
7
+ import { type TestApp, type TestAccount } from './app_server.js';
8
+ import { type DbFactory } from './db.js';
9
+ /** Config for a single SSE route under test. */
10
+ export interface SseRouteTestSpec {
11
+ /** Full HTTP path of the SSE endpoint (e.g., `'/api/tx/subscribe'`). */
12
+ path: string;
13
+ /**
14
+ * Fire an event matching one of the declared `event_specs` that should
15
+ * reach the open stream. Called after the `: connected` comment is observed.
16
+ * The triggered frame must be a JSON-serializable `{method, params}` payload.
17
+ */
18
+ trigger: (ctx: {
19
+ test_app: TestApp;
20
+ account: TestAccount;
21
+ }) => Promise<void>;
22
+ /**
23
+ * Event specs to validate the triggered payload against. When omitted,
24
+ * the payload is only asserted to be well-formed `{method, params}`.
25
+ */
26
+ event_specs?: Array<EventSpec>;
27
+ /**
28
+ * Whether to assert the stream closes after `session_revoke_all`.
29
+ * Default `true`. Set `false` for endpoints that don't wire a close-on-revoke
30
+ * guard (leaves a TODO to fix, rather than silently passing).
31
+ */
32
+ assert_closes_on_revoke?: boolean;
33
+ }
34
+ /** Options for `describe_sse_route_tests`. */
35
+ export interface SseRouteTestOptions {
36
+ /** Session config for cookie-based auth. */
37
+ session_options: SessionOptions<string>;
38
+ /** Route spec factory — same shape as production. */
39
+ create_route_specs: (ctx: AppServerContext) => Array<RouteSpec>;
40
+ /** Optional overrides for `AppServerOptions`. */
41
+ app_options?: Partial<Omit<AppServerOptions, 'backend' | 'session_options' | 'create_route_specs'>>;
42
+ /** Database factories to run tests against. Default: pglite only. */
43
+ db_factories?: Array<DbFactory>;
44
+ /**
45
+ * Backend audit event callback — threaded to `create_test_app_server`.
46
+ * Use to wire a close-on-revoke guard for consumer SSE registries
47
+ * (e.g., via `create_sse_auth_guard`) so `session_revoke_all` actually
48
+ * closes the tested streams.
49
+ */
50
+ on_audit_event?: (event: AuditLogEvent) => void;
51
+ /** SSE routes to exercise. */
52
+ routes: Array<SseRouteTestSpec>;
53
+ }
54
+ /**
55
+ * Run SSE route validation tests.
56
+ *
57
+ * For each route: opens an authenticated SSE connection, asserts the
58
+ * connected comment, fires the trigger, validates the resulting payload,
59
+ * then asserts close-on-revoke (unless opted out).
60
+ *
61
+ * @param options - SSE test configuration
62
+ */
63
+ export declare const describe_sse_route_tests: (options: SseRouteTestOptions) => void;
64
+ //# sourceMappingURL=sse_round_trip.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sse_round_trip.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/sse_round_trip.ts"],"names":[],"mappings":"AAAA,OAAO,qBAAqB,CAAC;AAmB7B,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,uBAAuB,CAAC;AACrD,OAAO,KAAK,EAAC,gBAAgB,EAAE,gBAAgB,EAAC,MAAM,yBAAyB,CAAC;AAChF,OAAO,KAAK,EAAC,cAAc,EAAC,MAAM,2BAA2B,CAAC;AAC9D,OAAO,EAAwB,KAAK,SAAS,EAAuB,MAAM,oBAAoB,CAAC;AAE/F,OAAO,KAAK,EAAC,aAAa,EAAC,MAAM,6BAA6B,CAAC;AAC/D,OAAO,EAAkB,KAAK,OAAO,EAAE,KAAK,WAAW,EAAC,MAAM,iBAAiB,CAAC;AAChF,OAAO,EAAwB,KAAK,SAAS,EAAC,MAAM,SAAS,CAAC;AAM9D,gDAAgD;AAChD,MAAM,WAAW,gBAAgB;IAChC,wEAAwE;IACxE,IAAI,EAAE,MAAM,CAAC;IACb;;;;OAIG;IACH,OAAO,EAAE,CAAC,GAAG,EAAE;QAAC,QAAQ,EAAE,OAAO,CAAC;QAAC,OAAO,EAAE,WAAW,CAAA;KAAC,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAC3E;;;OAGG;IACH,WAAW,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC,CAAC;IAC/B;;;;OAIG;IACH,uBAAuB,CAAC,EAAE,OAAO,CAAC;CAClC;AAED,8CAA8C;AAC9C,MAAM,WAAW,mBAAmB;IACnC,4CAA4C;IAC5C,eAAe,EAAE,cAAc,CAAC,MAAM,CAAC,CAAC;IACxC,qDAAqD;IACrD,kBAAkB,EAAE,CAAC,GAAG,EAAE,gBAAgB,KAAK,KAAK,CAAC,SAAS,CAAC,CAAC;IAChE,iDAAiD;IACjD,WAAW,CAAC,EAAE,OAAO,CACpB,IAAI,CAAC,gBAAgB,EAAE,SAAS,GAAG,iBAAiB,GAAG,oBAAoB,CAAC,CAC5E,CAAC;IACF,qEAAqE;IACrE,YAAY,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC,CAAC;IAChC;;;;;OAKG;IACH,cAAc,CAAC,EAAE,CAAC,KAAK,EAAE,aAAa,KAAK,IAAI,CAAC;IAChD,8BAA8B;IAC9B,MAAM,EAAE,KAAK,CAAC,gBAAgB,CAAC,CAAC;CAChC;AA0HD;;;;;;;;GAQG;AACH,eAAO,MAAM,wBAAwB,GAAI,SAAS,mBAAmB,KAAG,IA4GvE,CAAC"}
@@ -0,0 +1,241 @@
1
+ import './assert_dev_env.js';
2
+ /**
3
+ * Schema-driven SSE route validation test suite.
4
+ *
5
+ * Complements `describe_round_trip_validation` (which skips SSE routes).
6
+ * For each configured SSE route:
7
+ * 1. Open the stream with matching auth.
8
+ * 2. Assert the `: connected` comment is emitted.
9
+ * 3. Fire `trigger()` — expect one `data: {...}` frame.
10
+ * 4. Validate the payload as `{method, params}` against declared `EventSpec`s.
11
+ * 5. Fire `session_revoke_all` for the account and assert the stream closes
12
+ * (when `assert_closes_on_revoke !== false`).
13
+ *
14
+ * @module
15
+ */
16
+ import { describe, test, beforeAll, afterAll, assert } from 'vitest';
17
+ import { SSE_CONNECTED_COMMENT } from '../realtime/sse.js';
18
+ import { ROLE_ADMIN } from '../auth/role_schema.js';
19
+ import { create_test_app } from './app_server.js';
20
+ import { create_pglite_factory } from './db.js';
21
+ import { find_route_spec, pick_auth_headers } from './integration_helpers.js';
22
+ import { run_migrations } from '../db/migrate.js';
23
+ import { AUTH_MIGRATION_NS } from '../auth/migrations.js';
24
+ /**
25
+ * Read one complete SSE frame (up to `\n\n`) from a stream reader.
26
+ *
27
+ * Returns the frame without the trailing `\n\n`. Throws on premature close.
28
+ * Preserves any bytes past the terminator in the shared buffer for the next call.
29
+ */
30
+ const create_sse_frame_reader = (reader) => {
31
+ const decoder = new TextDecoder();
32
+ let buffer = '';
33
+ let closed = false;
34
+ const pump_once = async (timeout_ms) => {
35
+ // Race the read against a timeout — vitest will otherwise hang on a misbehaving stream.
36
+ const timeout = new Promise((resolve) => {
37
+ setTimeout(() => resolve({ done: true, value: undefined, timed_out: true }), timeout_ms);
38
+ });
39
+ const result = (await Promise.race([reader.read(), timeout]));
40
+ if ('timed_out' in result) {
41
+ throw new Error(`SSE read timed out after ${timeout_ms}ms`);
42
+ }
43
+ if (result.done) {
44
+ closed = true;
45
+ return false;
46
+ }
47
+ buffer += decoder.decode(result.value, { stream: true });
48
+ return true;
49
+ };
50
+ return {
51
+ read_frame: async (timeout_ms = 2000) => {
52
+ // SSE frames end with a blank line — the canonical terminator is `\n\n`.
53
+ while (true) {
54
+ const idx = buffer.indexOf('\n\n');
55
+ if (idx >= 0) {
56
+ const frame = buffer.slice(0, idx);
57
+ buffer = buffer.slice(idx + 2);
58
+ return frame;
59
+ }
60
+ const cont = await pump_once(timeout_ms); // eslint-disable-line no-await-in-loop
61
+ if (!cont)
62
+ throw new Error('SSE stream ended before a frame was received');
63
+ }
64
+ },
65
+ wait_for_close: async (timeout_ms) => {
66
+ // Drain until the server closes the stream (pump_once returns false) or timeout expires.
67
+ const deadline = Date.now() + timeout_ms;
68
+ for (;;) {
69
+ if (closed)
70
+ return true;
71
+ const remaining = deadline - Date.now();
72
+ if (remaining <= 0)
73
+ return false;
74
+ try {
75
+ // eslint-disable-next-line no-await-in-loop
76
+ await pump_once(Math.min(remaining, timeout_ms));
77
+ }
78
+ catch {
79
+ return false;
80
+ }
81
+ }
82
+ },
83
+ cancel: async () => {
84
+ try {
85
+ await reader.cancel();
86
+ }
87
+ catch {
88
+ // already closed
89
+ }
90
+ },
91
+ };
92
+ };
93
+ /**
94
+ * Validate a decoded SSE `data:` frame as a JSON-RPC-style `{method, params}` payload.
95
+ */
96
+ const parse_and_validate_sse_payload = (frame, event_specs, route_path) => {
97
+ const data_line = frame.split('\n').find((line) => line.startsWith('data: '));
98
+ assert.ok(data_line, `${route_path}: no 'data:' line in frame: ${JSON.stringify(frame)}`);
99
+ const json_str = data_line.slice('data: '.length);
100
+ let payload;
101
+ try {
102
+ payload = JSON.parse(json_str);
103
+ }
104
+ catch (e) {
105
+ throw new Error(`${route_path}: data frame not JSON: ${e.message} — ${json_str}`);
106
+ }
107
+ assert.ok(payload && typeof payload === 'object', `${route_path}: payload must be an object, got ${typeof payload}`);
108
+ const notification = payload;
109
+ assert.strictEqual(typeof notification.method, 'string', `${route_path}: payload.method must be a string`);
110
+ assert.ok('params' in notification, `${route_path}: payload missing 'params'`);
111
+ if (event_specs) {
112
+ const spec = event_specs.find((s) => s.method === notification.method);
113
+ assert.ok(spec, `${route_path}: no EventSpec declared for method '${notification.method}' (declared: ${event_specs.map((s) => s.method).join(', ')})`);
114
+ const result = spec.params.safeParse(notification.params);
115
+ if (!result.success) {
116
+ throw new Error(`${route_path}: params mismatch for method '${notification.method}': ${JSON.stringify(result.error.issues)}`);
117
+ }
118
+ }
119
+ return notification;
120
+ };
121
+ /**
122
+ * Run SSE route validation tests.
123
+ *
124
+ * For each route: opens an authenticated SSE connection, asserts the
125
+ * connected comment, fires the trigger, validates the resulting payload,
126
+ * then asserts close-on-revoke (unless opted out).
127
+ *
128
+ * @param options - SSE test configuration
129
+ */
130
+ export const describe_sse_route_tests = (options) => {
131
+ const init_schema = async (db) => {
132
+ await run_migrations(db, [AUTH_MIGRATION_NS]);
133
+ };
134
+ const factories = options.db_factories ?? [create_pglite_factory(init_schema)];
135
+ for (const factory of factories) {
136
+ const describe_fn = factory.skip ? describe.skip : describe;
137
+ describe_fn(`SSE validation (${factory.name})`, () => {
138
+ for (const route_config of options.routes) {
139
+ describe(`GET ${route_config.path}`, () => {
140
+ let test_app;
141
+ let authed_account;
142
+ let admin_account;
143
+ let account;
144
+ let db;
145
+ beforeAll(async () => {
146
+ db = await factory.create();
147
+ test_app = await create_test_app({
148
+ session_options: options.session_options,
149
+ create_route_specs: options.create_route_specs,
150
+ db,
151
+ app_options: options.app_options,
152
+ on_audit_event: options.on_audit_event,
153
+ });
154
+ authed_account = await test_app.create_account({
155
+ username: 'sse_authed',
156
+ roles: [],
157
+ });
158
+ admin_account = await test_app.create_account({
159
+ username: 'sse_admin',
160
+ roles: [ROLE_ADMIN],
161
+ });
162
+ });
163
+ afterAll(async () => {
164
+ await test_app.cleanup();
165
+ await factory.close(db);
166
+ });
167
+ test('opens, emits payload, closes on revoke', async () => {
168
+ const spec = find_route_spec(test_app.route_specs, 'GET', route_config.path);
169
+ assert.ok(spec, `no route spec for GET ${route_config.path}`);
170
+ account = pick_account_for_auth(spec, test_app, authed_account, admin_account);
171
+ const headers = pick_auth_headers(spec, test_app, authed_account, admin_account);
172
+ const res = await test_app.app.request(route_config.path, {
173
+ method: 'GET',
174
+ headers,
175
+ });
176
+ assert.strictEqual(res.status, 200, `expected 200 for ${route_config.path}, got ${res.status}`);
177
+ assert.ok(res.headers.get('Content-Type')?.includes('text/event-stream'), `${route_config.path}: Content-Type must be text/event-stream`);
178
+ assert.ok(res.body, `${route_config.path}: response has no body`);
179
+ const reader = res.body.getReader();
180
+ const sse = create_sse_frame_reader(reader);
181
+ try {
182
+ // 1. Connected comment — matches SSE_CONNECTED_COMMENT minus the trailing \n\n.
183
+ const first = await sse.read_frame();
184
+ assert.strictEqual(first + '\n\n', SSE_CONNECTED_COMMENT, `${route_config.path}: first frame must be the connected comment`);
185
+ // 2. Trigger → first data frame.
186
+ await route_config.trigger({ test_app, account });
187
+ const data_frame = await sse.read_frame();
188
+ parse_and_validate_sse_payload(data_frame, route_config.event_specs, route_config.path);
189
+ // 3. Close-on-revoke.
190
+ if (route_config.assert_closes_on_revoke !== false) {
191
+ const revoke_res = await test_app.app.request('/api/account/sessions/revoke-all', {
192
+ method: 'POST',
193
+ headers: account.create_session_headers(),
194
+ });
195
+ assert.ok(revoke_res.ok, `session_revoke_all returned ${revoke_res.status} — cannot assert stream closure`);
196
+ const closed = await sse.wait_for_close(2000);
197
+ assert.ok(closed, `${route_config.path}: stream did not close within 2s after session_revoke_all`);
198
+ }
199
+ }
200
+ finally {
201
+ await sse.cancel();
202
+ }
203
+ });
204
+ });
205
+ }
206
+ });
207
+ }
208
+ };
209
+ /**
210
+ * Pick the TestAccount that matches the route's auth type.
211
+ *
212
+ * Needed so the test can revoke the right account's sessions. Mirrors the
213
+ * fallthrough order of `pick_auth_headers` — routes with `role: admin` get
214
+ * the admin account; `authenticated` gets the authed account; stricter auth
215
+ * (keeper, other roles) uses the bootstrapped keeper account.
216
+ */
217
+ const pick_account_for_auth = (spec, test_app, authed_account, admin_account) => {
218
+ switch (spec.auth.type) {
219
+ case 'authenticated':
220
+ return authed_account;
221
+ case 'role':
222
+ if (spec.auth.role === ROLE_ADMIN)
223
+ return admin_account;
224
+ // keeper role — bootstrapped account is the keeper; model it as a TestAccount
225
+ return bootstrap_as_account(test_app);
226
+ case 'keeper':
227
+ case 'none':
228
+ return bootstrap_as_account(test_app);
229
+ }
230
+ };
231
+ /**
232
+ * Treat the bootstrapped `TestApp` account as a `TestAccount` for revocation.
233
+ */
234
+ const bootstrap_as_account = (test_app) => ({
235
+ account: test_app.backend.account,
236
+ actor: test_app.backend.actor,
237
+ session_cookie: test_app.backend.session_cookie,
238
+ api_token: test_app.backend.api_token,
239
+ create_session_headers: test_app.create_session_headers,
240
+ create_bearer_headers: test_app.create_bearer_headers,
241
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fuzdev/fuz_app",
3
- "version": "0.12.0",
3
+ "version": "0.13.1",
4
4
  "description": "fullstack app library",
5
5
  "glyph": "🗝",
6
6
  "logo": "logo.svg",