@fuzdev/fuz_app 0.12.0 → 0.13.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/action_codegen.d.ts.map +1 -1
- package/dist/auth/account_routes.d.ts +30 -0
- package/dist/auth/account_routes.d.ts.map +1 -1
- package/dist/auth/account_routes.js +44 -9
- package/dist/auth/admin_routes.d.ts.map +1 -1
- package/dist/auth/admin_routes.js +33 -2
- package/dist/auth/audit_log_routes.d.ts +2 -1
- package/dist/auth/audit_log_routes.d.ts.map +1 -1
- package/dist/auth/audit_log_routes.js +11 -2
- package/dist/auth/audit_log_schema.d.ts +1 -1
- package/dist/auth/audit_log_schema.d.ts.map +1 -1
- package/dist/auth/audit_log_schema.js +3 -1
- package/dist/auth/permit_queries.d.ts +19 -0
- package/dist/auth/permit_queries.d.ts.map +1 -1
- package/dist/auth/permit_queries.js +21 -0
- package/dist/auth/request_context.d.ts +10 -0
- package/dist/auth/request_context.d.ts.map +1 -1
- package/dist/auth/request_context.js +14 -0
- package/dist/hono_context.d.ts +7 -0
- package/dist/hono_context.d.ts.map +1 -1
- package/dist/realtime/sse_auth_guard.d.ts +23 -3
- package/dist/realtime/sse_auth_guard.d.ts.map +1 -1
- package/dist/realtime/sse_auth_guard.js +38 -2
- package/dist/realtime/subscriber_registry.d.ts +62 -17
- package/dist/realtime/subscriber_registry.d.ts.map +1 -1
- package/dist/realtime/subscriber_registry.js +64 -21
- package/dist/server/validate_nginx.d.ts.map +1 -1
- package/dist/server/validate_nginx.js +61 -7
- package/dist/testing/admin_integration.d.ts.map +1 -1
- package/dist/testing/admin_integration.js +8 -8
- package/dist/testing/app_server.d.ts +9 -0
- package/dist/testing/app_server.d.ts.map +1 -1
- package/dist/testing/app_server.js +4 -3
- package/dist/testing/data_exposure.d.ts.map +1 -1
- package/dist/testing/data_exposure.js +1 -20
- package/dist/testing/error_coverage.d.ts +93 -27
- package/dist/testing/error_coverage.d.ts.map +1 -1
- package/dist/testing/error_coverage.js +160 -67
- package/dist/testing/integration.d.ts.map +1 -1
- package/dist/testing/integration.js +6 -6
- package/dist/testing/integration_helpers.d.ts +17 -0
- package/dist/testing/integration_helpers.d.ts.map +1 -1
- package/dist/testing/integration_helpers.js +31 -0
- package/dist/testing/round_trip.d.ts.map +1 -1
- package/dist/testing/round_trip.js +41 -55
- package/dist/testing/sse_round_trip.d.ts +64 -0
- package/dist/testing/sse_round_trip.d.ts.map +1 -0
- package/dist/testing/sse_round_trip.js +241 -0
- 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('
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
headers
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
+
});
|