@fuzdev/fuz_app 0.20.0 → 0.21.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_registry.js +1 -1
- package/dist/actions/action_spec.d.ts +1 -1
- package/dist/actions/action_spec.js +1 -1
- package/dist/actions/broadcast_api.d.ts +2 -3
- package/dist/actions/broadcast_api.d.ts.map +1 -1
- package/dist/actions/broadcast_api.js +2 -3
- package/dist/actions/register_action_ws.js +3 -3
- package/dist/testing/ws_round_trip.d.ts +161 -0
- package/dist/testing/ws_round_trip.d.ts.map +1 -0
- package/dist/testing/ws_round_trip.js +334 -0
- package/package.json +1 -1
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// `request_response_specs`, `remote_notification_specs`, `local_call_specs`,
|
|
8
8
|
// `frontend_methods`, `backend_methods`, and `methods` are used by consumers (codegen).
|
|
9
9
|
// The rest are pre-built for future use. Revisit which getters to keep when the action
|
|
10
|
-
// system matures
|
|
10
|
+
// system matures. Also consider lazy memoization (`??=` or derived).
|
|
11
11
|
/**
|
|
12
12
|
* Utility class to manage and query action specifications.
|
|
13
13
|
* Provides helper methods to get actions by various criteria.
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* `action_bridge.ts` derive `RouteSpec` and `EventSpec` from them.
|
|
7
7
|
*
|
|
8
8
|
* TODO @action-system-review The action system (action_spec, action_registry,
|
|
9
|
-
* action_codegen, action_bridge) will evolve significantly
|
|
9
|
+
* action_codegen, action_bridge) will evolve significantly as RPC patterns settle.
|
|
10
10
|
* Current state: bridge is stable, registry and codegen are partially stub API.
|
|
11
11
|
* Search for `@action-system-review` across the actions/ and routes/ modules.
|
|
12
12
|
*
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* `action_bridge.ts` derive `RouteSpec` and `EventSpec` from them.
|
|
7
7
|
*
|
|
8
8
|
* TODO @action-system-review The action system (action_spec, action_registry,
|
|
9
|
-
* action_codegen, action_bridge) will evolve significantly
|
|
9
|
+
* action_codegen, action_bridge) will evolve significantly as RPC patterns settle.
|
|
10
10
|
* Current state: bridge is stable, registry and codegen are partially stub API.
|
|
11
11
|
* Search for `@action-system-review` across the actions/ and routes/ modules.
|
|
12
12
|
*
|
|
@@ -12,9 +12,8 @@
|
|
|
12
12
|
* streaming (`completion_progress`, `tx_apply` events) stays on
|
|
13
13
|
* `ctx.notify` inside a handler — it's socket-scoped, not broadcast.
|
|
14
14
|
*
|
|
15
|
-
* Extracted from zzz's `backend_actions_api.ts`
|
|
16
|
-
*
|
|
17
|
-
* undying.
|
|
15
|
+
* Extracted from zzz's `backend_actions_api.ts` to stop the pattern from
|
|
16
|
+
* drifting across zzz, tx, and undying.
|
|
18
17
|
*
|
|
19
18
|
* @module
|
|
20
19
|
*/
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"broadcast_api.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/actions/broadcast_api.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"broadcast_api.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/actions/broadcast_api.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,OAAO,EAAS,KAAK,MAAM,IAAI,UAAU,EAAC,MAAM,yBAAyB,CAAC;AAG1E,OAAO,KAAK,EAAC,UAAU,EAAC,MAAM,kBAAkB,CAAC;AACjD,OAAO,KAAK,EAAC,eAAe,EAAC,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAEN,KAAK,kBAAkB,EACvB,MAAM,4BAA4B,CAAC;AAEpC;;;;;;;;GAQG;AACH,MAAM,MAAM,eAAe,GAAG,CAC7B,UAAU,EAAE,kBAAkB,EAC9B,MAAM,EAAE,MAAM,EACd,KAAK,EAAE,OAAO,KACV,OAAO,CAAC;AAEb,0CAA0C;AAC1C,MAAM,WAAW,yBAAyB;IACzC,8DAA8D;IAC9D,IAAI,EAAE,UAAU,CAAC;IACjB;;;;;OAKG;IACH,KAAK,EAAE,aAAa,CAAC,eAAe,CAAC,CAAC;IACtC,gFAAgF;IAChF,GAAG,CAAC,EAAE,UAAU,GAAG,IAAI,CAAC;IACxB;;;;;;;;;OASG;IACH,cAAc,CAAC,EAAE,eAAe,CAAC;CACjC;AAED;;;;GAIG;AACH,MAAM,MAAM,YAAY,GAAG,MAAM,CAAC,MAAM,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC;AAE3E;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AACH,eAAO,MAAM,oBAAoB,GAAI,IAAI,GAAG,YAAY,EACvD,SAAS,yBAAyB,KAChC,IAoDF,CAAC"}
|
|
@@ -12,9 +12,8 @@
|
|
|
12
12
|
* streaming (`completion_progress`, `tx_apply` events) stays on
|
|
13
13
|
* `ctx.notify` inside a handler — it's socket-scoped, not broadcast.
|
|
14
14
|
*
|
|
15
|
-
* Extracted from zzz's `backend_actions_api.ts`
|
|
16
|
-
*
|
|
17
|
-
* undying.
|
|
15
|
+
* Extracted from zzz's `backend_actions_api.ts` to stop the pattern from
|
|
16
|
+
* drifting across zzz, tx, and undying.
|
|
18
17
|
*
|
|
19
18
|
* @module
|
|
20
19
|
*/
|
|
@@ -143,9 +143,9 @@ export const register_action_ws = (options) => {
|
|
|
143
143
|
await wait(artificial_delay);
|
|
144
144
|
}
|
|
145
145
|
// Socket-scoped notification — routes to originator only, not
|
|
146
|
-
// broadcast. Future work
|
|
147
|
-
//
|
|
148
|
-
//
|
|
146
|
+
// broadcast. Future work: other audiences — account-scoped,
|
|
147
|
+
// ACL-filtered, broadcast — likely via a transport-level
|
|
148
|
+
// policy hook.
|
|
149
149
|
const notify = (notify_method, notify_params) => {
|
|
150
150
|
try {
|
|
151
151
|
const notification = create_jsonrpc_notification(notify_method, to_jsonrpc_params(notify_params));
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-process test helpers for WebSocket JSON-RPC round-trips.
|
|
3
|
+
*
|
|
4
|
+
* Drives `register_action_ws` without an HTTP server. Consumers supply
|
|
5
|
+
* specs + handlers; the harness constructs real `WSContext` instances
|
|
6
|
+
* backed by test-owned `send`/`close` pairs, fakes the authenticated
|
|
7
|
+
* Hono context (`request_context`, credential type, session id, api
|
|
8
|
+
* token id), and exposes a `connect()` factory returning a
|
|
9
|
+
* `MockWsClient` per connection.
|
|
10
|
+
*
|
|
11
|
+
* Two layers are exported:
|
|
12
|
+
*
|
|
13
|
+
* - **Primitives** (`create_fake_ws`, `create_fake_hono_context`,
|
|
14
|
+
* `create_stub_upgrade`, `MinimalActionEnvironment`) — used by
|
|
15
|
+
* fuz_app's own dispatcher tests and by consumers wiring tight
|
|
16
|
+
* one-off tests.
|
|
17
|
+
* - **Harness** (`create_ws_test_harness`, `MockWsClient`,
|
|
18
|
+
* `keeper_identity`) — the high-level driver. Give it specs +
|
|
19
|
+
* handlers, get back `{transport, connect()}`. Use this unless you
|
|
20
|
+
* need bare primitives.
|
|
21
|
+
*
|
|
22
|
+
* Hono's wire upgrade is skipped — the Node test runtime has no
|
|
23
|
+
* `@hono/node-ws` adapter — but the full dispatch path is exercised
|
|
24
|
+
* (per-action auth, input validation, `ctx.notify` back to the
|
|
25
|
+
* originating socket, broadcast via `BackendWebsocketTransport`, and
|
|
26
|
+
* close-on-revoke).
|
|
27
|
+
*
|
|
28
|
+
* @module
|
|
29
|
+
*/
|
|
30
|
+
import type { Context } from 'hono';
|
|
31
|
+
import { WSContext, type UpgradeWebSocket, type WSEvents } from 'hono/ws';
|
|
32
|
+
import { Logger } from '@fuzdev/fuz_util/log.js';
|
|
33
|
+
import type { ActionSpecUnion } from '../actions/action_spec.js';
|
|
34
|
+
import type { ActionEventEnvironment } from '../actions/action_event_types.js';
|
|
35
|
+
import { type BaseHandlerContext, type RegisterActionWsOptions, type WsActionHandler } from '../actions/register_action_ws.js';
|
|
36
|
+
import { BackendWebsocketTransport } from '../actions/transports_ws_backend.js';
|
|
37
|
+
import { type RequestContext } from '../auth/request_context.js';
|
|
38
|
+
import { type CredentialType } from '../hono_context.js';
|
|
39
|
+
import { type Uuid } from '../uuid.js';
|
|
40
|
+
/**
|
|
41
|
+
* A `WSContext` paired with capture arrays. Use `sends` to assert on
|
|
42
|
+
* outgoing frames; use `closes` to assert on revocation / close.
|
|
43
|
+
*/
|
|
44
|
+
export interface FakeWs {
|
|
45
|
+
ws: WSContext;
|
|
46
|
+
sends: Array<string>;
|
|
47
|
+
closes: Array<{
|
|
48
|
+
code?: number;
|
|
49
|
+
reason?: string;
|
|
50
|
+
}>;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Build a real `WSContext` backed by in-memory `send`/`close` capture.
|
|
54
|
+
* Parsing of outgoing frames is left to the caller — `sends` holds the
|
|
55
|
+
* raw strings as the dispatcher wrote them.
|
|
56
|
+
*/
|
|
57
|
+
export declare const create_fake_ws: () => FakeWs;
|
|
58
|
+
/** Options for `create_fake_hono_context`. */
|
|
59
|
+
export interface FakeHonoContextOptions {
|
|
60
|
+
credential_type: CredentialType;
|
|
61
|
+
/** A single role to grant via `create_test_request_context`. */
|
|
62
|
+
role?: string;
|
|
63
|
+
auth_session_id?: string | null;
|
|
64
|
+
api_token_id?: string | null;
|
|
65
|
+
/**
|
|
66
|
+
* Override the `RequestContext` outright (for multi-role or custom
|
|
67
|
+
* account/actor fixtures). Takes precedence over `role`.
|
|
68
|
+
*/
|
|
69
|
+
request_context?: RequestContext;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Build a fake Hono `Context` exposing the auth keys the dispatcher
|
|
73
|
+
* reads via `c.get(...)`. Only `.get()` is populated — no other Hono
|
|
74
|
+
* context surface is simulated.
|
|
75
|
+
*/
|
|
76
|
+
export declare const create_fake_hono_context: (opts: FakeHonoContextOptions) => Context;
|
|
77
|
+
/** The return of `create_stub_upgrade` — fake `upgradeWebSocket` + factory capture. */
|
|
78
|
+
export interface StubUpgrade {
|
|
79
|
+
upgradeWebSocket: UpgradeWebSocket;
|
|
80
|
+
get_create_events: () => (c: Context) => WSEvents | Promise<WSEvents>;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Build a fake `upgradeWebSocket` that captures the `createEvents`
|
|
84
|
+
* callback. The returned middleware is inert — tests drive
|
|
85
|
+
* `createEvents` directly.
|
|
86
|
+
*/
|
|
87
|
+
export declare const create_stub_upgrade: () => StubUpgrade;
|
|
88
|
+
/**
|
|
89
|
+
* Minimal `ActionEventEnvironment` for tests that instantiate an
|
|
90
|
+
* `ActionPeer` without pulling in the full runtime. Pre-loads a
|
|
91
|
+
* spec map from the supplied list.
|
|
92
|
+
*/
|
|
93
|
+
export declare class MinimalActionEnvironment implements ActionEventEnvironment {
|
|
94
|
+
#private;
|
|
95
|
+
executor: 'frontend' | 'backend';
|
|
96
|
+
constructor(specs: ReadonlyArray<ActionSpecUnion>);
|
|
97
|
+
lookup_action_handler(): undefined;
|
|
98
|
+
lookup_action_spec(method: string): ActionSpecUnion | undefined;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Hono types `WSEvents.onMessage` as `() => void | Promise<void>`.
|
|
102
|
+
* Awaits only the Promise branch so tests observe full dispatch
|
|
103
|
+
* (auth, validation, handler, send).
|
|
104
|
+
*/
|
|
105
|
+
export declare const dispatch_ws_message: (on_message: NonNullable<WSEvents["onMessage"]>, event: MessageEvent, ws: WSContext) => Promise<void>;
|
|
106
|
+
/** Auth identity for a mock connection. */
|
|
107
|
+
export interface WsConnectIdentity {
|
|
108
|
+
/** Account id for the connection. Defaults to a fresh uuid per call. */
|
|
109
|
+
account_id?: Uuid;
|
|
110
|
+
/** Credential type. Defaults to `'session'`. Keeper actions require `'daemon_token'`. */
|
|
111
|
+
credential_type?: CredentialType;
|
|
112
|
+
/** Session id (any string). Defaults to a fresh uuid. Hashed by the dispatcher. */
|
|
113
|
+
session_id?: string;
|
|
114
|
+
/** Api token id; set for bearer connections, null otherwise. */
|
|
115
|
+
api_token_id?: string | null;
|
|
116
|
+
/** Roles to grant via active permits. Pass `[ROLE_KEEPER]` for keeper actions. */
|
|
117
|
+
roles?: Array<string>;
|
|
118
|
+
}
|
|
119
|
+
/** A mock WS client: send requests, inspect/await incoming messages. */
|
|
120
|
+
export interface MockWsClient {
|
|
121
|
+
/** Send a JSON-RPC message (request or notification) to the server. */
|
|
122
|
+
send: (message: unknown) => Promise<void>;
|
|
123
|
+
/** Close the connection, firing `onClose`. */
|
|
124
|
+
close: (code?: number, reason?: string) => void;
|
|
125
|
+
/** Every message the server has sent, in arrival order. */
|
|
126
|
+
readonly messages: ReadonlyArray<unknown>;
|
|
127
|
+
/**
|
|
128
|
+
* Wait until a message satisfies `predicate`. Matches are checked
|
|
129
|
+
* against already-received messages first, then new arrivals until
|
|
130
|
+
* the timeout (defaults to 1000ms).
|
|
131
|
+
*/
|
|
132
|
+
wait_for: <T = unknown>(predicate: (msg: unknown) => boolean, timeout_ms?: number) => Promise<T>;
|
|
133
|
+
}
|
|
134
|
+
/** Options for `create_ws_test_harness`. */
|
|
135
|
+
export interface CreateWsTestHarnessOptions<TCtx extends BaseHandlerContext> {
|
|
136
|
+
specs: ReadonlyArray<ActionSpecUnion>;
|
|
137
|
+
handlers: Record<string, WsActionHandler<TCtx>>;
|
|
138
|
+
extend_context?: RegisterActionWsOptions<TCtx>['extend_context'];
|
|
139
|
+
/** Pass a pre-created transport to share with a broadcast API. */
|
|
140
|
+
transport?: BackendWebsocketTransport;
|
|
141
|
+
/** Optional logger. Defaults to a silent `[ws-test]` logger. */
|
|
142
|
+
log?: Logger;
|
|
143
|
+
}
|
|
144
|
+
/** A harness instance — transport handle + connection factory. */
|
|
145
|
+
export interface WsTestHarness {
|
|
146
|
+
transport: BackendWebsocketTransport;
|
|
147
|
+
connect: (identity?: WsConnectIdentity) => MockWsClient;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Create a WebSocket test harness for the given specs + handlers.
|
|
151
|
+
*
|
|
152
|
+
* Registers against a throwaway Hono app with a fake
|
|
153
|
+
* `upgradeWebSocket`; the captured events factory is invoked per
|
|
154
|
+
* `connect()` with a synthesized Hono context carrying the requested
|
|
155
|
+
* auth identity. Returned clients drive the real
|
|
156
|
+
* `onOpen`/`onMessage`/`onClose` path against a real `WSContext`.
|
|
157
|
+
*/
|
|
158
|
+
export declare const create_ws_test_harness: <TCtx extends BaseHandlerContext>(options: CreateWsTestHarnessOptions<TCtx>) => WsTestHarness;
|
|
159
|
+
/** Convenience: default identity for keeper-authenticated connections. */
|
|
160
|
+
export declare const keeper_identity: () => WsConnectIdentity;
|
|
161
|
+
//# sourceMappingURL=ws_round_trip.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ws_round_trip.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/ws_round_trip.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-process test helpers for WebSocket JSON-RPC round-trips.
|
|
3
|
+
*
|
|
4
|
+
* Drives `register_action_ws` without an HTTP server. Consumers supply
|
|
5
|
+
* specs + handlers; the harness constructs real `WSContext` instances
|
|
6
|
+
* backed by test-owned `send`/`close` pairs, fakes the authenticated
|
|
7
|
+
* Hono context (`request_context`, credential type, session id, api
|
|
8
|
+
* token id), and exposes a `connect()` factory returning a
|
|
9
|
+
* `MockWsClient` per connection.
|
|
10
|
+
*
|
|
11
|
+
* Two layers are exported:
|
|
12
|
+
*
|
|
13
|
+
* - **Primitives** (`create_fake_ws`, `create_fake_hono_context`,
|
|
14
|
+
* `create_stub_upgrade`, `MinimalActionEnvironment`) — used by
|
|
15
|
+
* fuz_app's own dispatcher tests and by consumers wiring tight
|
|
16
|
+
* one-off tests.
|
|
17
|
+
* - **Harness** (`create_ws_test_harness`, `MockWsClient`,
|
|
18
|
+
* `keeper_identity`) — the high-level driver. Give it specs +
|
|
19
|
+
* handlers, get back `{transport, connect()}`. Use this unless you
|
|
20
|
+
* need bare primitives.
|
|
21
|
+
*
|
|
22
|
+
* Hono's wire upgrade is skipped — the Node test runtime has no
|
|
23
|
+
* `@hono/node-ws` adapter — but the full dispatch path is exercised
|
|
24
|
+
* (per-action auth, input validation, `ctx.notify` back to the
|
|
25
|
+
* originating socket, broadcast via `BackendWebsocketTransport`, and
|
|
26
|
+
* close-on-revoke).
|
|
27
|
+
*
|
|
28
|
+
* @module
|
|
29
|
+
*/
|
|
30
|
+
import { WSContext, createWSMessageEvent, } from 'hono/ws';
|
|
31
|
+
import { Logger } from '@fuzdev/fuz_util/log.js';
|
|
32
|
+
import { register_action_ws, } from '../actions/register_action_ws.js';
|
|
33
|
+
import { BackendWebsocketTransport } from '../actions/transports_ws_backend.js';
|
|
34
|
+
import { REQUEST_CONTEXT_KEY } from '../auth/request_context.js';
|
|
35
|
+
import { ROLE_KEEPER } from '../auth/role_schema.js';
|
|
36
|
+
import { AUTH_API_TOKEN_ID_KEY, CREDENTIAL_TYPE_KEY } from '../hono_context.js';
|
|
37
|
+
import { create_uuid } from '../uuid.js';
|
|
38
|
+
/**
|
|
39
|
+
* Build a real `WSContext` backed by in-memory `send`/`close` capture.
|
|
40
|
+
* Parsing of outgoing frames is left to the caller — `sends` holds the
|
|
41
|
+
* raw strings as the dispatcher wrote them.
|
|
42
|
+
*/
|
|
43
|
+
export const create_fake_ws = () => {
|
|
44
|
+
const sends = [];
|
|
45
|
+
const closes = [];
|
|
46
|
+
const init = {
|
|
47
|
+
send: (data) => {
|
|
48
|
+
sends.push(typeof data === 'string' ? data : '<binary>');
|
|
49
|
+
},
|
|
50
|
+
close: (code, reason) => {
|
|
51
|
+
closes.push({ code, reason });
|
|
52
|
+
},
|
|
53
|
+
readyState: 1,
|
|
54
|
+
};
|
|
55
|
+
return { ws: new WSContext(init), sends, closes };
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* Build a fake Hono `Context` exposing the auth keys the dispatcher
|
|
59
|
+
* reads via `c.get(...)`. Only `.get()` is populated — no other Hono
|
|
60
|
+
* context surface is simulated.
|
|
61
|
+
*/
|
|
62
|
+
export const create_fake_hono_context = (opts) => {
|
|
63
|
+
const request_context = opts.request_context ?? build_simple_request_context(opts.role);
|
|
64
|
+
const vars = {
|
|
65
|
+
[REQUEST_CONTEXT_KEY]: request_context,
|
|
66
|
+
[CREDENTIAL_TYPE_KEY]: opts.credential_type,
|
|
67
|
+
auth_session_id: opts.auth_session_id ?? (opts.credential_type === 'session' ? 's1' : null),
|
|
68
|
+
[AUTH_API_TOKEN_ID_KEY]: opts.api_token_id ?? null,
|
|
69
|
+
};
|
|
70
|
+
return {
|
|
71
|
+
get: (key) => vars[key],
|
|
72
|
+
};
|
|
73
|
+
};
|
|
74
|
+
/**
|
|
75
|
+
* Build a fake `upgradeWebSocket` that captures the `createEvents`
|
|
76
|
+
* callback. The returned middleware is inert — tests drive
|
|
77
|
+
* `createEvents` directly.
|
|
78
|
+
*/
|
|
79
|
+
export const create_stub_upgrade = () => {
|
|
80
|
+
let captured = null;
|
|
81
|
+
const upgradeWebSocket = ((createEvents) => {
|
|
82
|
+
captured = createEvents;
|
|
83
|
+
return async (_c, next) => next();
|
|
84
|
+
});
|
|
85
|
+
return {
|
|
86
|
+
upgradeWebSocket,
|
|
87
|
+
get_create_events: () => {
|
|
88
|
+
if (!captured)
|
|
89
|
+
throw new Error('upgradeWebSocket was not called');
|
|
90
|
+
return captured;
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
};
|
|
94
|
+
/**
|
|
95
|
+
* Minimal `ActionEventEnvironment` for tests that instantiate an
|
|
96
|
+
* `ActionPeer` without pulling in the full runtime. Pre-loads a
|
|
97
|
+
* spec map from the supplied list.
|
|
98
|
+
*/
|
|
99
|
+
export class MinimalActionEnvironment {
|
|
100
|
+
executor = 'backend';
|
|
101
|
+
#specs = new Map();
|
|
102
|
+
constructor(specs) {
|
|
103
|
+
for (const spec of specs)
|
|
104
|
+
this.#specs.set(spec.method, spec);
|
|
105
|
+
}
|
|
106
|
+
lookup_action_handler() {
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
lookup_action_spec(method) {
|
|
110
|
+
return this.#specs.get(method);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Hono types `WSEvents.onMessage` as `() => void | Promise<void>`.
|
|
115
|
+
* Awaits only the Promise branch so tests observe full dispatch
|
|
116
|
+
* (auth, validation, handler, send).
|
|
117
|
+
*/
|
|
118
|
+
export const dispatch_ws_message = async (on_message, event, ws) => {
|
|
119
|
+
// eslint-disable-next-line @typescript-eslint/no-confusing-void-expression
|
|
120
|
+
const result = on_message(event, ws);
|
|
121
|
+
if (result instanceof Promise)
|
|
122
|
+
await result;
|
|
123
|
+
};
|
|
124
|
+
const DEFAULT_TIMEOUT_MS = 1000;
|
|
125
|
+
/**
|
|
126
|
+
* Build a `RequestContext` with a fresh UUID account/actor and permits
|
|
127
|
+
* for the supplied roles. Used by the high-level harness so callers can
|
|
128
|
+
* pass `roles: [ROLE_KEEPER, 'admin']`.
|
|
129
|
+
*/
|
|
130
|
+
const build_multi_role_request_context = (account_id, roles) => {
|
|
131
|
+
const actor_id = create_uuid();
|
|
132
|
+
const now = new Date().toISOString();
|
|
133
|
+
return {
|
|
134
|
+
account: {
|
|
135
|
+
id: account_id,
|
|
136
|
+
username: 'ws-test',
|
|
137
|
+
email: null,
|
|
138
|
+
email_verified: false,
|
|
139
|
+
password_hash: '',
|
|
140
|
+
created_at: now,
|
|
141
|
+
created_by: null,
|
|
142
|
+
updated_at: now,
|
|
143
|
+
updated_by: null,
|
|
144
|
+
},
|
|
145
|
+
actor: {
|
|
146
|
+
id: actor_id,
|
|
147
|
+
account_id,
|
|
148
|
+
name: 'ws-test',
|
|
149
|
+
created_at: now,
|
|
150
|
+
updated_at: null,
|
|
151
|
+
updated_by: null,
|
|
152
|
+
},
|
|
153
|
+
permits: roles.map((role) => ({
|
|
154
|
+
id: create_uuid(),
|
|
155
|
+
actor_id,
|
|
156
|
+
role,
|
|
157
|
+
created_at: now,
|
|
158
|
+
expires_at: null,
|
|
159
|
+
revoked_at: null,
|
|
160
|
+
revoked_by: null,
|
|
161
|
+
granted_by: null,
|
|
162
|
+
})),
|
|
163
|
+
};
|
|
164
|
+
};
|
|
165
|
+
/**
|
|
166
|
+
* Stub `RequestContext` for single-role or public fakes. Hardcoded
|
|
167
|
+
* ids (`acc_1` / `act_1`) mirror `create_test_request_context` in
|
|
168
|
+
* `auth_apps.ts`.
|
|
169
|
+
*/
|
|
170
|
+
const build_simple_request_context = (role) => {
|
|
171
|
+
const now = new Date().toISOString();
|
|
172
|
+
return {
|
|
173
|
+
account: {
|
|
174
|
+
id: 'acc_1',
|
|
175
|
+
username: 'testuser',
|
|
176
|
+
password_hash: 'hash',
|
|
177
|
+
created_at: now,
|
|
178
|
+
updated_at: now,
|
|
179
|
+
created_by: null,
|
|
180
|
+
updated_by: null,
|
|
181
|
+
email: null,
|
|
182
|
+
email_verified: false,
|
|
183
|
+
},
|
|
184
|
+
actor: {
|
|
185
|
+
id: 'act_1',
|
|
186
|
+
account_id: 'acc_1',
|
|
187
|
+
name: 'testuser',
|
|
188
|
+
created_at: now,
|
|
189
|
+
updated_at: null,
|
|
190
|
+
updated_by: null,
|
|
191
|
+
},
|
|
192
|
+
permits: role
|
|
193
|
+
? [
|
|
194
|
+
{
|
|
195
|
+
id: 'perm_1',
|
|
196
|
+
actor_id: 'act_1',
|
|
197
|
+
role,
|
|
198
|
+
created_at: now,
|
|
199
|
+
expires_at: null,
|
|
200
|
+
revoked_at: null,
|
|
201
|
+
revoked_by: null,
|
|
202
|
+
granted_by: null,
|
|
203
|
+
},
|
|
204
|
+
]
|
|
205
|
+
: [],
|
|
206
|
+
};
|
|
207
|
+
};
|
|
208
|
+
/**
|
|
209
|
+
* Create a WebSocket test harness for the given specs + handlers.
|
|
210
|
+
*
|
|
211
|
+
* Registers against a throwaway Hono app with a fake
|
|
212
|
+
* `upgradeWebSocket`; the captured events factory is invoked per
|
|
213
|
+
* `connect()` with a synthesized Hono context carrying the requested
|
|
214
|
+
* auth identity. Returned clients drive the real
|
|
215
|
+
* `onOpen`/`onMessage`/`onClose` path against a real `WSContext`.
|
|
216
|
+
*/
|
|
217
|
+
export const create_ws_test_harness = (options) => {
|
|
218
|
+
const { specs, handlers, extend_context = (base) => base, transport = new BackendWebsocketTransport(), log = new Logger('[ws-test]', { level: 'off' }), } = options;
|
|
219
|
+
const stub = create_stub_upgrade();
|
|
220
|
+
// Minimal Hono stub — `register_action_ws` only needs `.get(path, handler)`.
|
|
221
|
+
const stub_app = { get: () => stub_app };
|
|
222
|
+
register_action_ws({
|
|
223
|
+
path: '/test/ws',
|
|
224
|
+
app: stub_app,
|
|
225
|
+
upgradeWebSocket: stub.upgradeWebSocket,
|
|
226
|
+
specs,
|
|
227
|
+
handlers,
|
|
228
|
+
extend_context,
|
|
229
|
+
transport,
|
|
230
|
+
log,
|
|
231
|
+
});
|
|
232
|
+
const events_factory = stub.get_create_events();
|
|
233
|
+
const connect = (identity = {}) => {
|
|
234
|
+
const account_id = identity.account_id ?? create_uuid();
|
|
235
|
+
const credential_type = identity.credential_type ?? 'session';
|
|
236
|
+
const session_id = identity.session_id ?? create_uuid();
|
|
237
|
+
const api_token_id = identity.api_token_id ?? null;
|
|
238
|
+
const roles = identity.roles ?? [];
|
|
239
|
+
const ctx_store = new Map([
|
|
240
|
+
[REQUEST_CONTEXT_KEY, build_multi_role_request_context(account_id, roles)],
|
|
241
|
+
[CREDENTIAL_TYPE_KEY, credential_type],
|
|
242
|
+
['auth_session_id', session_id],
|
|
243
|
+
[AUTH_API_TOKEN_ID_KEY, api_token_id],
|
|
244
|
+
]);
|
|
245
|
+
const fake_c = {
|
|
246
|
+
get: (key) => ctx_store.get(key),
|
|
247
|
+
};
|
|
248
|
+
const received = [];
|
|
249
|
+
const waiters = [];
|
|
250
|
+
let is_closed = false;
|
|
251
|
+
// Real WSContext backed by test-owned send/close. Parsing is done
|
|
252
|
+
// on receive so tests can assert against structured messages.
|
|
253
|
+
const ws = new WSContext({
|
|
254
|
+
readyState: 1,
|
|
255
|
+
send: (data) => {
|
|
256
|
+
if (is_closed)
|
|
257
|
+
return;
|
|
258
|
+
const parsed = typeof data === 'string' ? JSON.parse(data) : data;
|
|
259
|
+
received.push(parsed);
|
|
260
|
+
for (let i = waiters.length - 1; i >= 0; i--) {
|
|
261
|
+
const waiter = waiters[i];
|
|
262
|
+
if (waiter.predicate(parsed)) {
|
|
263
|
+
waiter.resolve(parsed);
|
|
264
|
+
waiters.splice(i, 1);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
},
|
|
268
|
+
close: (code, reason) => {
|
|
269
|
+
if (is_closed)
|
|
270
|
+
return;
|
|
271
|
+
is_closed = true;
|
|
272
|
+
const close_event = new Event('close');
|
|
273
|
+
Object.defineProperties(close_event, {
|
|
274
|
+
code: { value: code ?? 1000, writable: false },
|
|
275
|
+
reason: { value: reason ?? '', writable: false },
|
|
276
|
+
wasClean: { value: true, writable: false },
|
|
277
|
+
});
|
|
278
|
+
void Promise.resolve(events).then((e) => e.onClose?.(close_event, ws));
|
|
279
|
+
},
|
|
280
|
+
});
|
|
281
|
+
// Resolve the (possibly async) events factory synchronously via
|
|
282
|
+
// a microtask chain. Tests always await `connect().send(...)`
|
|
283
|
+
// which sequences after.
|
|
284
|
+
let events = events_factory(fake_c);
|
|
285
|
+
const events_ready = Promise.resolve(events).then((resolved) => {
|
|
286
|
+
events = resolved;
|
|
287
|
+
resolved.onOpen?.(new Event('open'), ws);
|
|
288
|
+
return resolved;
|
|
289
|
+
});
|
|
290
|
+
return {
|
|
291
|
+
get messages() {
|
|
292
|
+
return received;
|
|
293
|
+
},
|
|
294
|
+
async send(message) {
|
|
295
|
+
const resolved = await events_ready;
|
|
296
|
+
if (is_closed)
|
|
297
|
+
throw new Error('send after close');
|
|
298
|
+
const message_event = createWSMessageEvent(JSON.stringify(message));
|
|
299
|
+
// `onMessage` is typed as returning void by Hono, but
|
|
300
|
+
// `register_action_ws` implements it as async — cast so
|
|
301
|
+
// tests await the full dispatch (auth, validation,
|
|
302
|
+
// handler, send).
|
|
303
|
+
await resolved.onMessage?.(message_event, ws);
|
|
304
|
+
},
|
|
305
|
+
close(code, reason) {
|
|
306
|
+
if (is_closed)
|
|
307
|
+
return;
|
|
308
|
+
ws.close(code, reason);
|
|
309
|
+
},
|
|
310
|
+
wait_for(predicate, timeout_ms = DEFAULT_TIMEOUT_MS) {
|
|
311
|
+
for (const msg of received) {
|
|
312
|
+
if (predicate(msg))
|
|
313
|
+
return Promise.resolve(msg);
|
|
314
|
+
}
|
|
315
|
+
return new Promise((resolve, reject) => {
|
|
316
|
+
const timer = setTimeout(() => reject(new Error(`wait_for timed out after ${timeout_ms}ms`)), timeout_ms);
|
|
317
|
+
waiters.push({
|
|
318
|
+
predicate,
|
|
319
|
+
resolve: (msg) => {
|
|
320
|
+
clearTimeout(timer);
|
|
321
|
+
resolve(msg);
|
|
322
|
+
},
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
},
|
|
326
|
+
};
|
|
327
|
+
};
|
|
328
|
+
return { transport, connect };
|
|
329
|
+
};
|
|
330
|
+
/** Convenience: default identity for keeper-authenticated connections. */
|
|
331
|
+
export const keeper_identity = () => ({
|
|
332
|
+
credential_type: 'daemon_token',
|
|
333
|
+
roles: [ROLE_KEEPER],
|
|
334
|
+
});
|