@fuzdev/fuz_app 0.139.0 → 0.141.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/actions/CLAUDE.md +62 -17
- package/dist/actions/action_rpc.d.ts +5 -1
- package/dist/actions/action_rpc.d.ts.map +1 -1
- package/dist/actions/action_rpc.js +9 -1
- package/dist/actions/connection_closer.d.ts +25 -2
- package/dist/actions/connection_closer.d.ts.map +1 -1
- package/dist/actions/connection_closer.js +8 -1
- package/dist/actions/register_action_ws.d.ts +42 -6
- package/dist/actions/register_action_ws.d.ts.map +1 -1
- package/dist/actions/register_action_ws.js +87 -19
- package/dist/actions/register_ws_endpoint.d.ts +15 -6
- package/dist/actions/register_ws_endpoint.d.ts.map +1 -1
- package/dist/actions/register_ws_endpoint.js +5 -2
- package/dist/actions/socket.svelte.d.ts +19 -4
- package/dist/actions/socket.svelte.d.ts.map +1 -1
- package/dist/actions/socket.svelte.js +31 -4
- package/dist/actions/transports.d.ts +30 -1
- package/dist/actions/transports.d.ts.map +1 -1
- package/dist/actions/transports.js +30 -1
- package/dist/actions/transports_ws_auth_guard.d.ts +13 -6
- package/dist/actions/transports_ws_auth_guard.d.ts.map +1 -1
- package/dist/actions/transports_ws_auth_guard.js +24 -10
- package/dist/actions/transports_ws_backend.d.ts +51 -5
- package/dist/actions/transports_ws_backend.d.ts.map +1 -1
- package/dist/actions/transports_ws_backend.js +62 -9
- package/dist/actions/ws_endpoint_spec.d.ts +11 -10
- package/dist/actions/ws_endpoint_spec.d.ts.map +1 -1
- package/dist/auth/CLAUDE.md +11 -9
- package/dist/auth/account_route_schema.d.ts +1 -0
- package/dist/auth/account_route_schema.d.ts.map +1 -1
- package/dist/auth/account_route_schema.js +4 -0
- package/dist/auth/api_token.d.ts +20 -0
- package/dist/auth/api_token.d.ts.map +1 -1
- package/dist/auth/api_token.js +21 -1
- package/dist/auth/api_token_queries.d.ts +22 -4
- package/dist/auth/api_token_queries.d.ts.map +1 -1
- package/dist/auth/api_token_queries.js +11 -6
- package/dist/auth/audit_log_routes.d.ts +10 -0
- package/dist/auth/audit_log_routes.d.ts.map +1 -1
- package/dist/auth/audit_log_routes.js +20 -0
- package/dist/auth/audit_log_schema.d.ts +30 -5
- package/dist/auth/audit_log_schema.d.ts.map +1 -1
- package/dist/auth/audit_log_schema.js +30 -0
- package/dist/auth/bearer_auth.d.ts +4 -2
- package/dist/auth/bearer_auth.d.ts.map +1 -1
- package/dist/auth/bearer_auth.js +19 -8
- package/dist/auth/bootstrap_route_schema.d.ts +1 -0
- package/dist/auth/bootstrap_route_schema.d.ts.map +1 -1
- package/dist/auth/bootstrap_route_schema.js +1 -0
- package/dist/auth/cleanup.d.ts +9 -9
- package/dist/auth/cleanup.js +9 -9
- package/dist/auth/credential_deadline.d.ts +95 -0
- package/dist/auth/credential_deadline.d.ts.map +1 -0
- package/dist/auth/credential_deadline.js +139 -0
- package/dist/auth/daemon_token_middleware.d.ts.map +1 -1
- package/dist/auth/daemon_token_middleware.js +4 -1
- package/dist/auth/middleware.d.ts +90 -12
- package/dist/auth/middleware.d.ts.map +1 -1
- package/dist/auth/middleware.js +115 -16
- package/dist/auth/request_context.d.ts +4 -2
- package/dist/auth/request_context.d.ts.map +1 -1
- package/dist/auth/request_context.js +14 -3
- package/dist/auth/resolved_auth.d.ts +13 -2
- package/dist/auth/resolved_auth.d.ts.map +1 -1
- package/dist/auth/resolved_auth.js +5 -4
- package/dist/auth/session_cookie.d.ts +4 -0
- package/dist/auth/session_cookie.d.ts.map +1 -1
- package/dist/auth/session_cookie.js +3 -2
- package/dist/auth/session_middleware.d.ts +1 -1
- package/dist/auth/session_middleware.d.ts.map +1 -1
- package/dist/auth/session_middleware.js +4 -1
- package/dist/auth/session_queries.d.ts +19 -2
- package/dist/auth/session_queries.d.ts.map +1 -1
- package/dist/auth/session_queries.js +8 -2
- package/dist/auth/signup_route_schema.d.ts.map +1 -1
- package/dist/auth/signup_route_schema.js +1 -0
- package/dist/hono_context.d.ts +23 -0
- package/dist/hono_context.d.ts.map +1 -1
- package/dist/hono_context.js +19 -0
- package/dist/http/CLAUDE.md +41 -15
- package/dist/http/middleware_spec.d.ts +16 -0
- package/dist/http/middleware_spec.d.ts.map +1 -1
- package/dist/http/origin.d.ts +113 -18
- package/dist/http/origin.d.ts.map +1 -1
- package/dist/http/origin.js +247 -127
- package/dist/http/route_spec.d.ts +21 -0
- package/dist/http/route_spec.d.ts.map +1 -1
- package/dist/http/schema_helpers.d.ts +22 -0
- package/dist/http/schema_helpers.d.ts.map +1 -1
- package/dist/http/schema_helpers.js +24 -0
- package/dist/http/surface.d.ts +6 -1
- package/dist/http/surface.d.ts.map +1 -1
- package/dist/http/surface.js +12 -5
- package/dist/realtime/sse_auth_guard.d.ts +22 -6
- package/dist/realtime/sse_auth_guard.d.ts.map +1 -1
- package/dist/realtime/sse_auth_guard.js +31 -12
- package/dist/realtime/subscriber_registry.d.ts +10 -1
- package/dist/realtime/subscriber_registry.d.ts.map +1 -1
- package/dist/realtime/subscriber_registry.js +10 -1
- package/dist/server/app_server.d.ts +20 -7
- package/dist/server/app_server.d.ts.map +1 -1
- package/dist/server/app_server.js +8 -3
- package/dist/server/env.d.ts +2 -1
- package/dist/server/env.d.ts.map +1 -1
- package/dist/server/env.js +7 -17
- package/dist/server/serve_fact_route.d.ts.map +1 -1
- package/dist/server/serve_fact_route.js +1 -0
- package/dist/testing/CLAUDE.md +154 -22
- package/dist/testing/adversarial_headers.d.ts.map +1 -1
- package/dist/testing/adversarial_headers.js +13 -6
- package/dist/testing/connection_closer_helpers.d.ts +6 -1
- package/dist/testing/connection_closer_helpers.d.ts.map +1 -1
- package/dist/testing/connection_closer_helpers.js +4 -0
- package/dist/testing/cross_backend/body_size.d.ts.map +1 -1
- package/dist/testing/cross_backend/body_size.js +14 -65
- package/dist/testing/cross_backend/capabilities.d.ts +26 -2
- package/dist/testing/cross_backend/capabilities.d.ts.map +1 -1
- package/dist/testing/cross_backend/capabilities.js +9 -3
- package/dist/testing/cross_backend/conformance_case.d.ts +11 -3
- package/dist/testing/cross_backend/conformance_case.d.ts.map +1 -1
- package/dist/testing/cross_backend/conformance_case.js +6 -0
- package/dist/testing/cross_backend/conformance_table.d.ts.map +1 -1
- package/dist/testing/cross_backend/conformance_table.js +20 -19
- package/dist/testing/cross_backend/credential_deadline.d.ts +25 -0
- package/dist/testing/cross_backend/credential_deadline.d.ts.map +1 -0
- package/dist/testing/cross_backend/credential_deadline.js +185 -0
- package/dist/testing/cross_backend/default_backend_configs.d.ts.map +1 -1
- package/dist/testing/cross_backend/default_backend_configs.js +13 -2
- package/dist/testing/cross_backend/full_spine_mount.d.ts +3 -0
- package/dist/testing/cross_backend/full_spine_mount.d.ts.map +1 -1
- package/dist/testing/cross_backend/full_spine_mount.js +7 -1
- package/dist/testing/cross_backend/narrowed_api_token.d.ts +23 -0
- package/dist/testing/cross_backend/narrowed_api_token.d.ts.map +1 -0
- package/dist/testing/cross_backend/narrowed_api_token.js +40 -0
- package/dist/testing/cross_backend/origin.d.ts.map +1 -1
- package/dist/testing/cross_backend/origin.js +6 -26
- package/dist/testing/cross_backend/origin_conformance.d.ts +152 -0
- package/dist/testing/cross_backend/origin_conformance.d.ts.map +1 -0
- package/dist/testing/cross_backend/origin_conformance.js +136 -0
- package/dist/testing/cross_backend/origin_conformance.json +1679 -0
- package/dist/testing/cross_backend/origin_scope.d.ts +16 -0
- package/dist/testing/cross_backend/origin_scope.d.ts.map +1 -0
- package/dist/testing/cross_backend/origin_scope.js +140 -0
- package/dist/testing/cross_backend/role_grant_cross_helpers.d.ts +35 -0
- package/dist/testing/cross_backend/role_grant_cross_helpers.d.ts.map +1 -0
- package/dist/testing/cross_backend/role_grant_cross_helpers.js +64 -0
- package/dist/testing/cross_backend/role_grant_offer_notification_ws.d.ts +8 -0
- package/dist/testing/cross_backend/role_grant_offer_notification_ws.d.ts.map +1 -1
- package/dist/testing/cross_backend/role_grant_offer_notification_ws.js +44 -33
- package/dist/testing/cross_backend/rust_spine_stub_backend_config.d.ts.map +1 -1
- package/dist/testing/cross_backend/rust_spine_stub_backend_config.js +6 -4
- package/dist/testing/cross_backend/spine_surface_constants.d.ts +19 -0
- package/dist/testing/cross_backend/spine_surface_constants.d.ts.map +1 -1
- package/dist/testing/cross_backend/spine_surface_constants.js +22 -0
- package/dist/testing/cross_backend/testing_backdoor.d.ts.map +1 -1
- package/dist/testing/cross_backend/testing_backdoor.js +20 -7
- package/dist/testing/cross_backend/testing_reset_actions.d.ts +180 -5
- package/dist/testing/cross_backend/testing_reset_actions.d.ts.map +1 -1
- package/dist/testing/cross_backend/testing_reset_actions.js +154 -5
- package/dist/testing/cross_backend/token_scope_surface.d.ts.map +1 -1
- package/dist/testing/cross_backend/token_scope_surface.js +25 -21
- package/dist/testing/cross_backend/transport_composition.d.ts +60 -0
- package/dist/testing/cross_backend/transport_composition.d.ts.map +1 -0
- package/dist/testing/cross_backend/transport_composition.js +364 -0
- package/dist/testing/cross_backend/ts_spine_backend_config.d.ts.map +1 -1
- package/dist/testing/cross_backend/ts_spine_backend_config.js +21 -6
- package/dist/testing/cross_backend/ws_role_gated.d.ts +38 -0
- package/dist/testing/cross_backend/ws_role_gated.d.ts.map +1 -0
- package/dist/testing/cross_backend/ws_role_gated.js +142 -0
- package/dist/testing/entities.d.ts +23 -0
- package/dist/testing/entities.d.ts.map +1 -1
- package/dist/testing/entities.js +37 -0
- package/dist/testing/integration.d.ts.map +1 -1
- package/dist/testing/integration.js +3 -1
- package/dist/testing/middleware.d.ts +10 -0
- package/dist/testing/middleware.d.ts.map +1 -1
- package/dist/testing/middleware.js +31 -7
- package/dist/testing/stubs.d.ts +16 -3
- package/dist/testing/stubs.d.ts.map +1 -1
- package/dist/testing/stubs.js +20 -7
- package/dist/testing/surface_invariants.js +2 -2
- package/package.json +12 -12
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
import '../assert_dev_env.js';
|
|
2
|
+
/**
|
|
3
|
+
* Cross-backend front-door contract: what every transport answers before its
|
|
4
|
+
* request reaches per-action dispatch.
|
|
5
|
+
*
|
|
6
|
+
* Each spine composes its front door differently. The TS spine mounts one
|
|
7
|
+
* stack at `/api/*` — body limit, then origin, then session and bearer — and
|
|
8
|
+
* each surface adds its own gates behind it. The Rust spine stacks global
|
|
9
|
+
* layers (body limit, then origin) and has each handler resolve its credential
|
|
10
|
+
* and run its own gates. Two compositions can agree on every per-surface suite
|
|
11
|
+
* and still disagree on which refusal answers when a request trips several, so
|
|
12
|
+
* this suite pins the answer per transport as a table:
|
|
13
|
+
*
|
|
14
|
+
* | surface | foreign `Origin` | unreadable `Origin` | duplicated `Origin` | over cap + foreign `Origin` | no credential | narrowed token |
|
|
15
|
+
* | --- | --- | --- | --- | --- | --- | --- |
|
|
16
|
+
* | REST `POST` (`/logout`) | 403 `forbidden_origin` | 403 `forbidden_origin` | 403 `forbidden_origin` | 413 `payload_too_large` | 401 `authentication_required` | 403 `credential_type_required` |
|
|
17
|
+
* | REST `POST` (`/password`) | — | — | — | — | 401 `authentication_required` | — |
|
|
18
|
+
* | RPC `POST` | 403 `forbidden_origin` | 403 `forbidden_origin` | 403 `forbidden_origin` | 413 `payload_too_large` | 401 `authentication_required` | 403 `token_scope_required` (`rpc:<method>`) |
|
|
19
|
+
* | RPC `GET` | 403 `forbidden_origin` | 403 `forbidden_origin` | 403 `forbidden_origin` | — | 401 `authentication_required` | 403 `token_scope_required` (`rpc:<method>`) |
|
|
20
|
+
* | WS upgrade | 403 `forbidden_origin` | 403 `forbidden_origin` | 403 `forbidden_origin` | — | 401 `authentication_required` | 403 `token_scope_required` (`surface:ws_upgrade`) |
|
|
21
|
+
* | SSE (audit stream) | 403 `forbidden_origin` | 403 `forbidden_origin` | 403 `forbidden_origin` | — | 401 `authentication_required` | 403 `credential_type_required` |
|
|
22
|
+
*
|
|
23
|
+
* - **foreign `Origin`** carries the keeper's session cookie, so only the
|
|
24
|
+
* origin check can refuse it — a front door missing that check answers the
|
|
25
|
+
* surface's success instead.
|
|
26
|
+
* - **unreadable `Origin`** is the allowed origin with a byte past ASCII
|
|
27
|
+
* (`0xE9`), with the keeper's session — a check that reads such a value as
|
|
28
|
+
* absent lets it through.
|
|
29
|
+
* - **duplicated `Origin`** sends the allowed origin on two header lines, with
|
|
30
|
+
* the keeper's session — refused whether a backend sees the two lines (the
|
|
31
|
+
* Rust spine) or one value joined from them (the TS runtimes), so a check that
|
|
32
|
+
* reads only the first lets it through.
|
|
33
|
+
* - **over cap + foreign `Origin`** declares a `Content-Length` one byte over
|
|
34
|
+
* the shared 1 MiB cap and carries no credential: the cap answers on the
|
|
35
|
+
* header, ahead of both the origin check and auth. Only the surfaces that
|
|
36
|
+
* take a body have the column.
|
|
37
|
+
* - **no credential** sends an allowed `Origin` and nothing else.
|
|
38
|
+
* - **narrowed token** sends a bearer minted with a method-scoped scope, and no
|
|
39
|
+
* `Origin` (a bearer in a browser context is discarded, not refused). The RPC
|
|
40
|
+
* probe calls a method the scope doesn't name. A narrowed token reaches no
|
|
41
|
+
* non-RPC surface, but on a session-only surface the credential gate answers
|
|
42
|
+
* first — the REST probe is session-only `/logout`, and the audit stream is
|
|
43
|
+
* session-only on both spines.
|
|
44
|
+
*
|
|
45
|
+
* Each case asserts the status and the refusal body: a flat `{error}` (plus
|
|
46
|
+
* its named fields) or, past the RPC front door, the JSON-RPC error envelope
|
|
47
|
+
* with the reason on `error.data.reason`. A WebSocket refusal's body is
|
|
48
|
+
* required where the backend declares `capabilities.ws_refusal_body`; where it
|
|
49
|
+
* doesn't (Node's `@hono/node-ws` drops a refused upgrade's body), an empty
|
|
50
|
+
* body passes on its status and a body that is sent is still asserted.
|
|
51
|
+
*
|
|
52
|
+
* **Transport.** Given a `base_url`, every probe is written to the socket
|
|
53
|
+
* (`raw_http_exchange`): an upgrade has to be, and an over-cap request sends
|
|
54
|
+
* its head alone, as `body_size.ts` explains — the 413 must arrive without a
|
|
55
|
+
* body byte. With `base_url: null` the probes go through the fixture's
|
|
56
|
+
* in-process transport, whole bodies included, and the WebSocket rows skip
|
|
57
|
+
* (there is no socket to upgrade).
|
|
58
|
+
*
|
|
59
|
+
* Wire it for any backend that serves the spine's account routes, RPC
|
|
60
|
+
* endpoint, WebSocket endpoint and audit stream; the WS and SSE rows gate on
|
|
61
|
+
* `capabilities.ws` and `capabilities.sse`.
|
|
62
|
+
*
|
|
63
|
+
* @module
|
|
64
|
+
*/
|
|
65
|
+
import { assert, describe } from 'vitest';
|
|
66
|
+
import { randomBytes } from 'node:crypto';
|
|
67
|
+
import { account_verify_action_spec } from '../../auth/account_action_specs.js';
|
|
68
|
+
import { ERROR_AUTHENTICATION_REQUIRED, ERROR_CREDENTIAL_TYPE_REQUIRED, ERROR_FORBIDDEN_ORIGIN, ERROR_PAYLOAD_TOO_LARGE, ERROR_TOKEN_SCOPE_REQUIRED } from '../../http/error_schemas.js';
|
|
69
|
+
import { create_rpc_get_url } from '../rpc_helpers.js';
|
|
70
|
+
import { raw_http_exchange, raw_http_parse_response } from '../transports/raw_http.js';
|
|
71
|
+
import { test_if } from './capabilities.js';
|
|
72
|
+
import { mint_narrowed_api_token } from './narrowed_api_token.js';
|
|
73
|
+
import { SPINE_RPC_PATH, SPINE_SSE_PATH } from './spine_surface_constants.js';
|
|
74
|
+
/** The RPC method the narrowed token admits. */
|
|
75
|
+
const NARROWED_ADMITTED_METHOD = account_verify_action_spec.method;
|
|
76
|
+
/**
|
|
77
|
+
* The RPC method the narrowed-token probe calls — one its scope doesn't name.
|
|
78
|
+
* A read with no credential-type or role gate, so the scope gate is the only
|
|
79
|
+
* one that can refuse it, over `GET` as over `POST`.
|
|
80
|
+
*/
|
|
81
|
+
const NARROWED_REFUSED_METHOD = 'account_session_list';
|
|
82
|
+
const FORBIDDEN_ORIGIN = { status: 403, error: ERROR_FORBIDDEN_ORIGIN };
|
|
83
|
+
const PAYLOAD_TOO_LARGE = {
|
|
84
|
+
status: 413,
|
|
85
|
+
error: ERROR_PAYLOAD_TOO_LARGE
|
|
86
|
+
};
|
|
87
|
+
const SESSION_ONLY = {
|
|
88
|
+
status: 403,
|
|
89
|
+
error: ERROR_CREDENTIAL_TYPE_REQUIRED,
|
|
90
|
+
fields: { required_credential_types: ['session'] }
|
|
91
|
+
};
|
|
92
|
+
const RPC_UNAUTHENTICATED = {
|
|
93
|
+
status: 401,
|
|
94
|
+
error: ERROR_AUTHENTICATION_REQUIRED,
|
|
95
|
+
envelope: true
|
|
96
|
+
};
|
|
97
|
+
const RPC_SCOPE_REFUSED = {
|
|
98
|
+
status: 403,
|
|
99
|
+
error: ERROR_TOKEN_SCOPE_REQUIRED,
|
|
100
|
+
fields: { required_scope: `rpc:${NARROWED_REFUSED_METHOD}` },
|
|
101
|
+
envelope: true
|
|
102
|
+
};
|
|
103
|
+
const UNAUTHENTICATED = {
|
|
104
|
+
status: 401,
|
|
105
|
+
error: ERROR_AUTHENTICATION_REQUIRED
|
|
106
|
+
};
|
|
107
|
+
/** The front-door table — one case per cell of the module doc's matrix. */
|
|
108
|
+
export const transport_composition_cases = [
|
|
109
|
+
{ surface: 'rest_post', probe: 'foreign_origin', expect: FORBIDDEN_ORIGIN },
|
|
110
|
+
{ surface: 'rest_post', probe: 'unreadable_origin', expect: FORBIDDEN_ORIGIN },
|
|
111
|
+
{ surface: 'rest_post', probe: 'duplicated_origin', expect: FORBIDDEN_ORIGIN },
|
|
112
|
+
{ surface: 'rest_post', probe: 'over_cap_foreign_origin', expect: PAYLOAD_TOO_LARGE },
|
|
113
|
+
{ surface: 'rest_post', probe: 'no_credential', expect: UNAUTHENTICATED },
|
|
114
|
+
{ surface: 'rest_post', probe: 'narrowed_token', expect: SESSION_ONLY },
|
|
115
|
+
// the other session-only REST route that resolves its own credential on the
|
|
116
|
+
// Rust spine, so its 401 is pinned beside `/logout`'s
|
|
117
|
+
{ surface: 'rest_password', probe: 'no_credential', expect: UNAUTHENTICATED },
|
|
118
|
+
{ surface: 'rpc_post', probe: 'foreign_origin', expect: FORBIDDEN_ORIGIN },
|
|
119
|
+
{ surface: 'rpc_post', probe: 'unreadable_origin', expect: FORBIDDEN_ORIGIN },
|
|
120
|
+
{ surface: 'rpc_post', probe: 'duplicated_origin', expect: FORBIDDEN_ORIGIN },
|
|
121
|
+
{ surface: 'rpc_post', probe: 'over_cap_foreign_origin', expect: PAYLOAD_TOO_LARGE },
|
|
122
|
+
{ surface: 'rpc_post', probe: 'no_credential', expect: RPC_UNAUTHENTICATED },
|
|
123
|
+
{ surface: 'rpc_post', probe: 'narrowed_token', expect: RPC_SCOPE_REFUSED },
|
|
124
|
+
{ surface: 'rpc_get', probe: 'foreign_origin', expect: FORBIDDEN_ORIGIN },
|
|
125
|
+
{ surface: 'rpc_get', probe: 'unreadable_origin', expect: FORBIDDEN_ORIGIN },
|
|
126
|
+
{ surface: 'rpc_get', probe: 'duplicated_origin', expect: FORBIDDEN_ORIGIN },
|
|
127
|
+
{ surface: 'rpc_get', probe: 'no_credential', expect: RPC_UNAUTHENTICATED },
|
|
128
|
+
{ surface: 'rpc_get', probe: 'narrowed_token', expect: RPC_SCOPE_REFUSED },
|
|
129
|
+
{ surface: 'ws_upgrade', probe: 'foreign_origin', expect: FORBIDDEN_ORIGIN, gate: 'ws' },
|
|
130
|
+
{ surface: 'ws_upgrade', probe: 'unreadable_origin', expect: FORBIDDEN_ORIGIN, gate: 'ws' },
|
|
131
|
+
{ surface: 'ws_upgrade', probe: 'duplicated_origin', expect: FORBIDDEN_ORIGIN, gate: 'ws' },
|
|
132
|
+
{ surface: 'ws_upgrade', probe: 'no_credential', expect: UNAUTHENTICATED, gate: 'ws' },
|
|
133
|
+
{
|
|
134
|
+
surface: 'ws_upgrade',
|
|
135
|
+
probe: 'narrowed_token',
|
|
136
|
+
expect: {
|
|
137
|
+
status: 403,
|
|
138
|
+
error: ERROR_TOKEN_SCOPE_REQUIRED,
|
|
139
|
+
fields: { required_scope: 'surface:ws_upgrade' }
|
|
140
|
+
},
|
|
141
|
+
gate: 'ws'
|
|
142
|
+
},
|
|
143
|
+
{ surface: 'sse', probe: 'foreign_origin', expect: FORBIDDEN_ORIGIN, gate: 'sse' },
|
|
144
|
+
{ surface: 'sse', probe: 'unreadable_origin', expect: FORBIDDEN_ORIGIN, gate: 'sse' },
|
|
145
|
+
{ surface: 'sse', probe: 'duplicated_origin', expect: FORBIDDEN_ORIGIN, gate: 'sse' },
|
|
146
|
+
{ surface: 'sse', probe: 'no_credential', expect: UNAUTHENTICATED, gate: 'sse' },
|
|
147
|
+
{ surface: 'sse', probe: 'narrowed_token', expect: SESSION_ONLY, gate: 'sse' }
|
|
148
|
+
];
|
|
149
|
+
/** The shared 1 MiB default cap both spines ship, as in `body_size.ts`. */
|
|
150
|
+
const BODY_LIMIT_DEFAULT_BYTES = 1024 * 1024;
|
|
151
|
+
/** An `Origin` no backend's allowlist admits. */
|
|
152
|
+
const DISALLOWED_ORIGIN = 'http://evil.com';
|
|
153
|
+
/** How long a raw exchange waits for the refusal. */
|
|
154
|
+
const RAW_READ_TIMEOUT_MS = 2000;
|
|
155
|
+
const rpc_envelope = (method, id) => JSON.stringify({ jsonrpc: '2.0', method, id });
|
|
156
|
+
/** A JSON object body of exactly `bytes` ASCII bytes. */
|
|
157
|
+
const sized_json = (bytes) => {
|
|
158
|
+
const base = JSON.stringify({ pad: '' });
|
|
159
|
+
return JSON.stringify({ pad: 'x'.repeat(Math.max(0, bytes - base.length)) });
|
|
160
|
+
};
|
|
161
|
+
const to_header_lines = (headers) => Object.entries(headers)
|
|
162
|
+
.map(([name, value]) => `${name}: ${value}\r\n`)
|
|
163
|
+
.join('');
|
|
164
|
+
const parse_json = (text) => {
|
|
165
|
+
try {
|
|
166
|
+
return JSON.parse(text);
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
return undefined;
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
const case_name = (c) => `${c.surface} × ${c.probe} → ${c.expect.status} ${c.expect.error}`;
|
|
173
|
+
/**
|
|
174
|
+
* Read the refusal reason and fields out of a body, by the shape the case
|
|
175
|
+
* expects. An envelope expectation fails on a flat body and the other way
|
|
176
|
+
* round, so a spine answering from the wrong layer fails here.
|
|
177
|
+
*/
|
|
178
|
+
const assert_refusal_body = (c, body, text) => {
|
|
179
|
+
const label = case_name(c);
|
|
180
|
+
assert.ok(body !== null && typeof body === 'object', `${label}: expected a JSON body, got ${JSON.stringify(text.slice(0, 200))}`);
|
|
181
|
+
let refusal;
|
|
182
|
+
if (c.expect.envelope) {
|
|
183
|
+
const envelope = body;
|
|
184
|
+
assert.strictEqual(envelope.jsonrpc, '2.0', `${label}: expected a JSON-RPC envelope: ${text}`);
|
|
185
|
+
const data = envelope.error?.data;
|
|
186
|
+
assert.ok(data !== null && typeof data === 'object', `${label}: expected error.data on the envelope: ${text}`);
|
|
187
|
+
refusal = data;
|
|
188
|
+
assert.strictEqual(refusal.reason, c.expect.error, `${label}: error.data.reason`);
|
|
189
|
+
}
|
|
190
|
+
else {
|
|
191
|
+
refusal = body;
|
|
192
|
+
assert.ok(!('jsonrpc' in refusal), `${label}: expected a flat body, got an envelope: ${text}`);
|
|
193
|
+
assert.strictEqual(refusal.error, c.expect.error, `${label}: body.error`);
|
|
194
|
+
}
|
|
195
|
+
for (const [key, expected] of Object.entries(c.expect.fields ?? {})) {
|
|
196
|
+
assert.deepStrictEqual(refusal[key], expected, `${label}: ${key}`);
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
/**
|
|
200
|
+
* Register the front-door table's cases — one test per cell, each on a fresh
|
|
201
|
+
* fixture.
|
|
202
|
+
*
|
|
203
|
+
* This is the front-door contract: a backend that wires it answers each
|
|
204
|
+
* transport's probes as the table says — when one request trips several
|
|
205
|
+
* refusals, the one the table names answers.
|
|
206
|
+
*/
|
|
207
|
+
export const describe_transport_composition_cross_tests = (options) => {
|
|
208
|
+
const { setup_test, capabilities, base_url } = options;
|
|
209
|
+
const rpc_path = options.rpc_path ?? SPINE_RPC_PATH;
|
|
210
|
+
const rest_path = options.rest_path ?? '/api/account/logout';
|
|
211
|
+
const password_path = options.password_path ?? '/api/account/password';
|
|
212
|
+
const ws_path = options.ws_path ?? '/api/ws';
|
|
213
|
+
const sse_path = options.sse_path ?? SPINE_SSE_PATH;
|
|
214
|
+
const allowed_origin = options.origin ?? (base_url === null ? 'http://localhost:5173' : new URL(base_url).origin);
|
|
215
|
+
/** The request line, headers and body a case's probe sends. */
|
|
216
|
+
const build_probe = async (c, fixture) => {
|
|
217
|
+
const over_cap = c.probe === 'over_cap_foreign_origin';
|
|
218
|
+
let credential;
|
|
219
|
+
let repeated_headers;
|
|
220
|
+
switch (c.probe) {
|
|
221
|
+
case 'foreign_origin':
|
|
222
|
+
credential = { ...fixture.create_session_headers(), origin: DISALLOWED_ORIGIN };
|
|
223
|
+
break;
|
|
224
|
+
case 'unreadable_origin':
|
|
225
|
+
// latin1 on the socket — the byte 0xE9
|
|
226
|
+
credential = { ...fixture.create_session_headers(), origin: `${allowed_origin}\u00e9` };
|
|
227
|
+
break;
|
|
228
|
+
case 'duplicated_origin':
|
|
229
|
+
credential = { ...fixture.create_session_headers(), origin: allowed_origin };
|
|
230
|
+
repeated_headers = [['origin', allowed_origin]];
|
|
231
|
+
break;
|
|
232
|
+
case 'over_cap_foreign_origin':
|
|
233
|
+
// no credential either: the cap answers ahead of the origin check and auth
|
|
234
|
+
credential = { origin: DISALLOWED_ORIGIN };
|
|
235
|
+
break;
|
|
236
|
+
case 'no_credential':
|
|
237
|
+
credential = { origin: allowed_origin };
|
|
238
|
+
break;
|
|
239
|
+
case 'narrowed_token': {
|
|
240
|
+
const token = await mint_narrowed_api_token({
|
|
241
|
+
app: fixture.transport,
|
|
242
|
+
rpc_path,
|
|
243
|
+
session_headers: fixture.create_session_headers(),
|
|
244
|
+
methods: [NARROWED_ADMITTED_METHOD]
|
|
245
|
+
});
|
|
246
|
+
// no `Origin` — a bearer in a browser context is discarded
|
|
247
|
+
credential = { authorization: `Bearer ${token}` };
|
|
248
|
+
break;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
const rpc_method = c.probe === 'narrowed_token' ? NARROWED_REFUSED_METHOD : account_verify_action_spec.method;
|
|
252
|
+
const id = `${c.surface}-${c.probe}`;
|
|
253
|
+
switch (c.surface) {
|
|
254
|
+
case 'rest_post':
|
|
255
|
+
case 'rest_password':
|
|
256
|
+
return {
|
|
257
|
+
method: 'POST',
|
|
258
|
+
path: c.surface === 'rest_post' ? rest_path : password_path,
|
|
259
|
+
headers: { ...credential, 'content-type': 'application/json' },
|
|
260
|
+
body: over_cap ? sized_json(BODY_LIMIT_DEFAULT_BYTES + 1) : '{}',
|
|
261
|
+
over_cap,
|
|
262
|
+
repeated_headers
|
|
263
|
+
};
|
|
264
|
+
case 'rpc_post': {
|
|
265
|
+
return {
|
|
266
|
+
method: 'POST',
|
|
267
|
+
path: rpc_path,
|
|
268
|
+
headers: { ...credential, 'content-type': 'application/json' },
|
|
269
|
+
body: over_cap ? sized_json(BODY_LIMIT_DEFAULT_BYTES + 1) : rpc_envelope(rpc_method, id),
|
|
270
|
+
over_cap,
|
|
271
|
+
repeated_headers
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
case 'rpc_get':
|
|
275
|
+
return {
|
|
276
|
+
method: 'GET',
|
|
277
|
+
path: create_rpc_get_url(rpc_path, rpc_method, undefined, id),
|
|
278
|
+
headers: credential,
|
|
279
|
+
body: null,
|
|
280
|
+
over_cap,
|
|
281
|
+
repeated_headers
|
|
282
|
+
};
|
|
283
|
+
case 'ws_upgrade':
|
|
284
|
+
return {
|
|
285
|
+
method: 'GET',
|
|
286
|
+
path: ws_path,
|
|
287
|
+
headers: {
|
|
288
|
+
...credential,
|
|
289
|
+
upgrade: 'websocket',
|
|
290
|
+
connection: 'Upgrade',
|
|
291
|
+
'sec-websocket-key': randomBytes(16).toString('base64'),
|
|
292
|
+
'sec-websocket-version': '13'
|
|
293
|
+
},
|
|
294
|
+
body: null,
|
|
295
|
+
over_cap,
|
|
296
|
+
repeated_headers
|
|
297
|
+
};
|
|
298
|
+
case 'sse':
|
|
299
|
+
return {
|
|
300
|
+
method: 'GET',
|
|
301
|
+
path: sse_path,
|
|
302
|
+
headers: { ...credential, accept: 'text/event-stream' },
|
|
303
|
+
body: null,
|
|
304
|
+
over_cap,
|
|
305
|
+
repeated_headers
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
};
|
|
309
|
+
/** Put a probe on the socket, or through the in-process transport. */
|
|
310
|
+
const send_probe = async (fixture, probe) => {
|
|
311
|
+
if (base_url === null) {
|
|
312
|
+
const headers = new Headers(probe.headers);
|
|
313
|
+
// in-process there is no wire: a repeated header joins, as the runtimes join it
|
|
314
|
+
for (const [name, value] of probe.repeated_headers ?? [])
|
|
315
|
+
headers.append(name, value);
|
|
316
|
+
const res = await fixture.fresh_transport()(probe.path, {
|
|
317
|
+
method: probe.method,
|
|
318
|
+
headers,
|
|
319
|
+
...(probe.body === null ? {} : { body: probe.body })
|
|
320
|
+
});
|
|
321
|
+
return { status: res.status, text: await res.text() };
|
|
322
|
+
}
|
|
323
|
+
const url = new URL(base_url);
|
|
324
|
+
const body = probe.over_cap ? '' : (probe.body ?? '');
|
|
325
|
+
const length_header = probe.over_cap
|
|
326
|
+
? { 'content-length': String(BODY_LIMIT_DEFAULT_BYTES + 1) }
|
|
327
|
+
: probe.body === null
|
|
328
|
+
? {}
|
|
329
|
+
: { 'content-length': String(body.length) };
|
|
330
|
+
const request = `${probe.method} ${probe.path} HTTP/1.1\r\n` +
|
|
331
|
+
to_header_lines({ host: url.host, ...probe.headers, ...length_header }) +
|
|
332
|
+
(probe.repeated_headers ?? []).map(([name, value]) => `${name}: ${value}\r\n`).join('') +
|
|
333
|
+
`\r\n` +
|
|
334
|
+
body;
|
|
335
|
+
const { received, closed_by_server } = await raw_http_exchange(base_url, request, {
|
|
336
|
+
read_timeout_ms: RAW_READ_TIMEOUT_MS,
|
|
337
|
+
until: 'response'
|
|
338
|
+
});
|
|
339
|
+
const response = raw_http_parse_response(received);
|
|
340
|
+
assert.ok(response, `${probe.method} ${probe.path}: expected a whole response; ` +
|
|
341
|
+
(closed_by_server
|
|
342
|
+
? 'the server closed the connection first'
|
|
343
|
+
: `none arrived within ${RAW_READ_TIMEOUT_MS}ms`) +
|
|
344
|
+
`. Received: ${received ? received.slice(0, 200) : 'nothing'}`);
|
|
345
|
+
return { status: response.status, text: response.body };
|
|
346
|
+
};
|
|
347
|
+
describe('transport composition (front door)', () => {
|
|
348
|
+
for (const c of transport_composition_cases) {
|
|
349
|
+
const gated = c.gate === undefined || capabilities[c.gate];
|
|
350
|
+
// an upgrade needs a socket; in-process there is none
|
|
351
|
+
const reachable = c.surface !== 'ws_upgrade' || base_url !== null;
|
|
352
|
+
test_if(gated && reachable, case_name(c), async () => {
|
|
353
|
+
const fixture = await setup_test();
|
|
354
|
+
const res = await send_probe(fixture, await build_probe(c, fixture));
|
|
355
|
+
assert.strictEqual(res.status, c.expect.status, `${case_name(c)}: status (body: ${res.text.slice(0, 200)})`);
|
|
356
|
+
// a backend that declares no WS refusal body (Node's `@hono/node-ws`)
|
|
357
|
+
// may answer with an empty one; a body it does send is still asserted
|
|
358
|
+
if (c.surface === 'ws_upgrade' && !capabilities.ws_refusal_body && res.text === '')
|
|
359
|
+
return;
|
|
360
|
+
assert_refusal_body(c, parse_json(res.text), res.text);
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
});
|
|
364
|
+
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ts_spine_backend_config.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/ts_spine_backend_config.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAE9B;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;
|
|
1
|
+
{"version":3,"file":"ts_spine_backend_config.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/ts_spine_backend_config.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAE9B;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAiEzD,6FAA6F;AAC7F,eAAO,MAAM,0BAA0B,OAAO,CAAC;AAE/C,iDAAiD;AACjD,eAAO,MAAM,0BAA0B,OAAO,CAAC;AAE/C,gDAAgD;AAChD,eAAO,MAAM,yBAAyB,OAAO,CAAC;AAE9C,yDAAyD;AACzD,eAAO,MAAM,mBAAmB,wDAAwD,CAAC;AAEzF,yDAAyD;AACzD,eAAO,MAAM,mBAAmB,wDAAwD,CAAC;AAEzF,wDAAwD;AACxD,eAAO,MAAM,kBAAkB,uDAAuD,CAAC;AAEvF,MAAM,WAAW,2BAA2B;IAC3C,mFAAmF;IACnF,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IACvB,6DAA6D;IAC7D,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAC/B;;;;;;;;;OASG;IACH,QAAQ,CAAC,uBAAuB,CAAC,EAAE,OAAO,CAAC;IAC3C;;;;;;OAMG;IACH,QAAQ,CAAC,wBAAwB,CAAC,EAAE,OAAO,CAAC;IAC5C;;;;;OAKG;IACH,QAAQ,CAAC,8BAA8B,CAAC,EAAE,MAAM,CAAC;CACjD;AAWD;;;GAGG;AACH,eAAO,MAAM,4BAA4B,GACxC,UAAS,2BAAgC,KACvC,aAoBF,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,2BAA2B,GACvC,UAAS,2BAAgC,KACvC,aAoBF,CAAC;AAEF;;;;;;;;;;GAUG;AACH,eAAO,MAAM,4BAA4B,GACxC,UAAS,2BAAgC,KACvC,aAiCF,CAAC"}
|
|
@@ -9,8 +9,11 @@ import { LOGIN_RATE_LIMIT_ENABLED_ENV, SPINE_SSE_PATH, TS_SPINE_DIR_ENV } from '
|
|
|
9
9
|
* WS transport's `request_connection` path drives server→client `peer/ping`),
|
|
10
10
|
* `ws_account_actions` (the binary mounts its full action set on `/api/ws`,
|
|
11
11
|
* the account actions among them),
|
|
12
|
-
*
|
|
13
|
-
* `test_cell_gated_create_authorize` policy)
|
|
12
|
+
* `cell_gated_create` (the binary's `full_spine_mount` mounts the
|
|
13
|
+
* `test_cell_gated_create_authorize` policy), and `ws_role_gated` (the binary
|
|
14
|
+
* mounts the role-gated WS endpoint at `SPINE_ROLE_GATED_WS_PATH`). The Node
|
|
15
|
+
* binary's preset; `ws_refusal_body` stays `false`, as `@hono/node-ws` drops a
|
|
16
|
+
* refused upgrade's body.
|
|
14
17
|
*/
|
|
15
18
|
const ts_spine_capabilities = Object.freeze({
|
|
16
19
|
...ts_default_capabilities,
|
|
@@ -18,11 +21,22 @@ const ts_spine_capabilities = Object.freeze({
|
|
|
18
21
|
ready: true,
|
|
19
22
|
peer_request: true,
|
|
20
23
|
ws_account_actions: true,
|
|
21
|
-
cell_gated_create: true
|
|
24
|
+
cell_gated_create: true,
|
|
25
|
+
ws_role_gated: true
|
|
26
|
+
});
|
|
27
|
+
/**
|
|
28
|
+
* Capabilities for the **Deno** spine binary — `ts_spine_capabilities` with
|
|
29
|
+
* `ws_refusal_body: true` (`Deno.upgradeWebSocket` leaves a refused upgrade's
|
|
30
|
+
* response to Hono, body included).
|
|
31
|
+
*/
|
|
32
|
+
const ts_spine_deno_capabilities = Object.freeze({
|
|
33
|
+
...ts_spine_capabilities,
|
|
34
|
+
ws_refusal_body: true
|
|
22
35
|
});
|
|
23
36
|
/**
|
|
24
37
|
* Capabilities for the **Bun** spine binary — `ts_spine_capabilities` with
|
|
25
|
-
* `ws_handshake_pipelining: false
|
|
38
|
+
* `ws_handshake_pipelining: false` and `ws_refusal_body: true` (`Bun.serve`
|
|
39
|
+
* sends a refused upgrade's response as built).
|
|
26
40
|
*
|
|
27
41
|
* `Bun.serve` answers `400` to an upgrade request that carries WebSocket
|
|
28
42
|
* frames in the same TCP write when the handler upgrades after an await that
|
|
@@ -34,7 +48,8 @@ const ts_spine_capabilities = Object.freeze({
|
|
|
34
48
|
*/
|
|
35
49
|
const ts_spine_bun_capabilities = Object.freeze({
|
|
36
50
|
...ts_spine_capabilities,
|
|
37
|
-
ws_handshake_pipelining: false
|
|
51
|
+
ws_handshake_pipelining: false,
|
|
52
|
+
ws_refusal_body: true
|
|
38
53
|
});
|
|
39
54
|
/** Default port for the Node TS spine binary — slots beside the Rust `spine_stub` (1177). */
|
|
40
55
|
export const TS_SPINE_NODE_DEFAULT_PORT = 1178;
|
|
@@ -148,7 +163,7 @@ export const ts_spine_deno_backend_config = (options = {}) => {
|
|
|
148
163
|
...login_rate_limit_env(enable_login_rate_limit),
|
|
149
164
|
...action_rate_limit_env(options)
|
|
150
165
|
},
|
|
151
|
-
capabilities:
|
|
166
|
+
capabilities: ts_spine_deno_capabilities
|
|
152
167
|
}),
|
|
153
168
|
sse_path: SPINE_SSE_PATH
|
|
154
169
|
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import '../assert_dev_env.js';
|
|
2
|
+
import { type BackendCapabilities } from './capabilities.js';
|
|
3
|
+
import type { SetupTest } from './setup.js';
|
|
4
|
+
/** Configuration for `describe_ws_role_gated_cross_tests`. */
|
|
5
|
+
export interface WsRoleGatedCrossTestOptions {
|
|
6
|
+
/** Per-test fixture producer (`default_cross_process_setup(handle)`). */
|
|
7
|
+
readonly setup_test: SetupTest;
|
|
8
|
+
/** Backend capability flags; every case gates on `ws` and `ws_role_gated`. */
|
|
9
|
+
readonly capabilities: BackendCapabilities;
|
|
10
|
+
/** Base URL the backend is reachable at. */
|
|
11
|
+
readonly base_url: string;
|
|
12
|
+
/** The ungated WebSocket endpoint path (e.g. `/api/ws`). */
|
|
13
|
+
readonly ws_path: string;
|
|
14
|
+
/** The role-gated WebSocket endpoint path. */
|
|
15
|
+
readonly gated_ws_path: string;
|
|
16
|
+
/**
|
|
17
|
+
* The roles `gated_ws_path` requires: `ROLE_ADMIN` and `scoped_role`
|
|
18
|
+
* among them.
|
|
19
|
+
*/
|
|
20
|
+
readonly gated_roles: ReadonlyArray<string>;
|
|
21
|
+
/** A role in `gated_roles` an admin can grant both globally and scoped. */
|
|
22
|
+
readonly scoped_role: string;
|
|
23
|
+
/** JSON-RPC endpoint path. Default `SPINE_RPC_PATH`. */
|
|
24
|
+
readonly rpc_path?: string;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Register the role-gated WebSocket suite: a revoked global grant of a gating
|
|
28
|
+
* role closes the account's gated socket with `WS_CLOSE_PERMISSION_REVOKED`,
|
|
29
|
+
* its ungated socket stays open and still receives the account's pushes, and
|
|
30
|
+
* a re-upgrade is refused at the gate; a scoped revoke of a gating role
|
|
31
|
+
* closes nothing.
|
|
32
|
+
*
|
|
33
|
+
* That the closed registration is sent nothing more is beyond what a client
|
|
34
|
+
* can observe once it has read the close frame, so the suite does not claim
|
|
35
|
+
* it: each spine's own transport tests pin it.
|
|
36
|
+
*/
|
|
37
|
+
export declare const describe_ws_role_gated_cross_tests: (options: WsRoleGatedCrossTestOptions) => void;
|
|
38
|
+
//# sourceMappingURL=ws_role_gated.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ws_role_gated.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/ws_role_gated.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAwC9B,OAAO,EAAE,KAAK,mBAAmB,EAAW,MAAM,mBAAmB,CAAC;AAKtE,OAAO,KAAK,EAAE,SAAS,EAAmC,MAAM,YAAY,CAAC;AAM7E,8DAA8D;AAC9D,MAAM,WAAW,2BAA2B;IAC3C,yEAAyE;IACzE,QAAQ,CAAC,UAAU,EAAE,SAAS,CAAC;IAC/B,8EAA8E;IAC9E,QAAQ,CAAC,YAAY,EAAE,mBAAmB,CAAC;IAC3C,4CAA4C;IAC5C,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,4DAA4D;IAC5D,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,8CAA8C;IAC9C,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;IAC/B;;;OAGG;IACH,QAAQ,CAAC,WAAW,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IAC5C,2EAA2E;IAC3E,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,wDAAwD;IACxD,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED;;;;;;;;;;GAUG;AACH,eAAO,MAAM,kCAAkC,GAAI,SAAS,2BAA2B,KAAG,IA2IzF,CAAC"}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import '../assert_dev_env.js';
|
|
2
|
+
/**
|
|
3
|
+
* Cross-process **role-gated WebSocket** suite — a socket admitted by an
|
|
4
|
+
* endpoint's role gate (`register_ws_endpoint`'s `required_roles`) loses it
|
|
5
|
+
* with the role.
|
|
6
|
+
*
|
|
7
|
+
* A role gate decides who may hold a socket, and so who receives the
|
|
8
|
+
* endpoint's server pushes, which reach every admitted connection with no
|
|
9
|
+
* per-recipient filter. So both spines record the gating roles on each
|
|
10
|
+
* registration and close the account's gated sockets when a global grant of a
|
|
11
|
+
* listed role is revoked — with `WS_CLOSE_PERMISSION_REVOKED`, not the
|
|
12
|
+
* session-revoked code, since the credential is still good — and send nothing
|
|
13
|
+
* after the close. The ungated sockets of the same account, on the same
|
|
14
|
+
* transport, stay open: the gate is per endpoint, not per account. A scoped
|
|
15
|
+
* grant never admits at a role gate, so revoking one closes nothing.
|
|
16
|
+
*
|
|
17
|
+
* Runs against a backend that mounts, beside its ungated `ws_path`, a second
|
|
18
|
+
* endpoint at `gated_ws_path` gated on `gated_roles` — which must include
|
|
19
|
+
* `ROLE_ADMIN` (the revoked global grant) and a role that can be granted both
|
|
20
|
+
* globally and scoped (the scoped revoke). fuz_app's spine binaries mount
|
|
21
|
+
* `SPINE_ROLE_GATED_WS_PATH` gated on `SPINE_ROLE_GATED_WS_ROLES`. Gated on
|
|
22
|
+
* `capabilities.ws` and `capabilities.ws_role_gated`; cross-process only.
|
|
23
|
+
*
|
|
24
|
+
* @module
|
|
25
|
+
*/
|
|
26
|
+
import { assert, describe } from 'vitest';
|
|
27
|
+
import { assert_rejects } from '@fuzdev/fuz_util/testing.ts';
|
|
28
|
+
import { heartbeat_action_spec } from '../../actions/heartbeat.js';
|
|
29
|
+
import { WS_CLOSE_PERMISSION_REVOKED, WS_CLOSE_PERMISSION_REVOKED_REASON } from '../../actions/transports.js';
|
|
30
|
+
import { ROLE_ADMIN } from '../../auth/role_schema.js';
|
|
31
|
+
import { ROLE_GRANT_OFFER_RECEIVED_NOTIFICATION_METHOD } from '../../auth/role_grant_offer_notifications.js';
|
|
32
|
+
import { rpc_call } from '../rpc_helpers.js';
|
|
33
|
+
import { is_notification_with } from '../transports/ws_client.js';
|
|
34
|
+
import { create_admitted_ws_transport, create_ws_transport } from '../transports/ws_transport.js';
|
|
35
|
+
import { test_if } from './capabilities.js';
|
|
36
|
+
import { create_active_role_grant, create_pending_role_grant_offer } from './role_grant_cross_helpers.js';
|
|
37
|
+
import { SPINE_RPC_PATH } from './spine_surface_constants.js';
|
|
38
|
+
/** How long a socket that must stay open is watched for a close it must not get. */
|
|
39
|
+
const STAYS_OPEN_MS = 1500;
|
|
40
|
+
/**
|
|
41
|
+
* Register the role-gated WebSocket suite: a revoked global grant of a gating
|
|
42
|
+
* role closes the account's gated socket with `WS_CLOSE_PERMISSION_REVOKED`,
|
|
43
|
+
* its ungated socket stays open and still receives the account's pushes, and
|
|
44
|
+
* a re-upgrade is refused at the gate; a scoped revoke of a gating role
|
|
45
|
+
* closes nothing.
|
|
46
|
+
*
|
|
47
|
+
* That the closed registration is sent nothing more is beyond what a client
|
|
48
|
+
* can observe once it has read the close frame, so the suite does not claim
|
|
49
|
+
* it: each spine's own transport tests pin it.
|
|
50
|
+
*/
|
|
51
|
+
export const describe_ws_role_gated_cross_tests = (options) => {
|
|
52
|
+
const { setup_test, capabilities, base_url, ws_path, gated_ws_path, gated_roles, scoped_role, rpc_path = SPINE_RPC_PATH } = options;
|
|
53
|
+
const enabled = capabilities.ws && capabilities.ws_role_gated;
|
|
54
|
+
if (enabled && (!gated_roles.includes(ROLE_ADMIN) || !gated_roles.includes(scoped_role))) {
|
|
55
|
+
throw new Error(`describe_ws_role_gated_cross_tests: gated_roles must include ${ROLE_ADMIN} and ${scoped_role}`);
|
|
56
|
+
}
|
|
57
|
+
const rpc = (fixture, method, params, headers = fixture.create_session_headers()) => rpc_call({ app: fixture.transport, path: rpc_path, method, params, headers });
|
|
58
|
+
const cookie_of = (account) => {
|
|
59
|
+
const { cookie } = account.create_session_headers();
|
|
60
|
+
assert.ok(cookie, 'expected a session cookie for the WS upgrade');
|
|
61
|
+
return cookie;
|
|
62
|
+
};
|
|
63
|
+
const open = (path, account) => create_admitted_ws_transport({
|
|
64
|
+
base_url,
|
|
65
|
+
ws_path: path,
|
|
66
|
+
cookies: [cookie_of(account)],
|
|
67
|
+
origin: base_url
|
|
68
|
+
});
|
|
69
|
+
describe('role-gated websocket (cross-process)', () => {
|
|
70
|
+
test_if(enabled, 'revoking a global grant of a gating role closes the gated socket with WS_CLOSE_PERMISSION_REVOKED', async () => {
|
|
71
|
+
const fixture = await setup_test();
|
|
72
|
+
const target = await fixture.create_account({ roles: [ROLE_ADMIN] });
|
|
73
|
+
const role_grant_id = await create_active_role_grant(fixture, target, { rpc_path });
|
|
74
|
+
const gated = await open(gated_ws_path, target);
|
|
75
|
+
const ungated = await open(ws_path, target);
|
|
76
|
+
try {
|
|
77
|
+
const revoked = await rpc(fixture, 'role_grant_revoke', {
|
|
78
|
+
actor_id: target.actor.id,
|
|
79
|
+
role_grant_id
|
|
80
|
+
});
|
|
81
|
+
assert.isTrue(revoked.ok, `revoke failed: ${JSON.stringify(revoked)}`);
|
|
82
|
+
assert.ok(await gated.wait_for_close(5000), 'the gated socket did not close');
|
|
83
|
+
assert.strictEqual(gated.close_code, WS_CLOSE_PERMISSION_REVOKED);
|
|
84
|
+
assert.strictEqual(gated.close_reason, WS_CLOSE_PERMISSION_REVOKED_REASON);
|
|
85
|
+
// a push to the account still reaches its ungated socket, and the
|
|
86
|
+
// closed client records nothing more
|
|
87
|
+
const received_before = gated.messages.length;
|
|
88
|
+
await create_pending_role_grant_offer(fixture, target.account.id, fixture.create_session_headers(), { rpc_path });
|
|
89
|
+
await ungated.wait_for(is_notification_with(ROLE_GRANT_OFFER_RECEIVED_NOTIFICATION_METHOD, (p) => p.offer.to_account_id === target.account.id), 5000);
|
|
90
|
+
assert.strictEqual(gated.messages.length, received_before);
|
|
91
|
+
// the account's ungated socket on the same backend stays open
|
|
92
|
+
assert.strictEqual(ungated.close_code, null);
|
|
93
|
+
assert.deepStrictEqual(await ungated.request(2, heartbeat_action_spec.method, {}), {});
|
|
94
|
+
// and the gate refuses the account a new gated socket
|
|
95
|
+
await assert_rejects(() => create_ws_transport({
|
|
96
|
+
base_url,
|
|
97
|
+
ws_path: gated_ws_path,
|
|
98
|
+
cookies: [cookie_of(target)],
|
|
99
|
+
origin: base_url
|
|
100
|
+
}), /403/);
|
|
101
|
+
}
|
|
102
|
+
finally {
|
|
103
|
+
await gated.close();
|
|
104
|
+
await ungated.close();
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
test_if(enabled, 'a scoped revoke of a gating role closes nothing', async () => {
|
|
108
|
+
const fixture = await setup_test();
|
|
109
|
+
// a global grant of `scoped_role` admits the socket — which proves the
|
|
110
|
+
// endpoint lists it, so the scoped revoke below is one of a gating role
|
|
111
|
+
const target = await fixture.create_account();
|
|
112
|
+
const assign = async (scope) => {
|
|
113
|
+
const assigned = await rpc(fixture, 'role_grant_assign', {
|
|
114
|
+
to_account_id: target.account.id,
|
|
115
|
+
role: scoped_role,
|
|
116
|
+
...scope
|
|
117
|
+
});
|
|
118
|
+
if (!assigned.ok)
|
|
119
|
+
assert.fail(`assign failed: ${JSON.stringify(assigned)}`);
|
|
120
|
+
return assigned.result.role_grant_id;
|
|
121
|
+
};
|
|
122
|
+
await assign({});
|
|
123
|
+
const role_grant_id = await assign({
|
|
124
|
+
scope_kind: 'space',
|
|
125
|
+
scope_id: '33333333-3333-4333-8333-333333333333'
|
|
126
|
+
});
|
|
127
|
+
const gated = await open(gated_ws_path, target);
|
|
128
|
+
try {
|
|
129
|
+
const revoked = await rpc(fixture, 'role_grant_revoke', {
|
|
130
|
+
actor_id: target.actor.id,
|
|
131
|
+
role_grant_id
|
|
132
|
+
});
|
|
133
|
+
assert.isTrue(revoked.ok, `revoke failed: ${JSON.stringify(revoked)}`);
|
|
134
|
+
assert.isFalse(await gated.wait_for_close(STAYS_OPEN_MS), `closed with ${gated.close_code} on a scoped revoke`);
|
|
135
|
+
assert.deepStrictEqual(await gated.request(2, heartbeat_action_spec.method, {}), {});
|
|
136
|
+
}
|
|
137
|
+
finally {
|
|
138
|
+
await gated.close();
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
};
|
|
@@ -2,6 +2,7 @@ import './assert_dev_env.js';
|
|
|
2
2
|
import type { Account, Actor, RoleGrant } from '../auth/account_schema.js';
|
|
3
3
|
import type { AuditLogEvent } from '../auth/audit_log_schema.js';
|
|
4
4
|
import type { RequestActorContext } from '../auth/request_context.js';
|
|
5
|
+
import type { ValidatedApiToken } from '../auth/api_token_queries.js';
|
|
5
6
|
/** Override type for `create_test_account` — id-like fields accept plain `string`. */
|
|
6
7
|
export type TestAccountOverrides = Partial<Omit<Account, 'id' | 'created_by' | 'updated_by'>> & {
|
|
7
8
|
id?: string;
|
|
@@ -45,4 +46,26 @@ export type TestAuditEventOverrides = Partial<Omit<AuditLogEvent, 'id' | 'actor_
|
|
|
45
46
|
};
|
|
46
47
|
/** Create a test `AuditLogEvent` with sensible defaults. */
|
|
47
48
|
export declare const create_test_audit_event: (overrides?: TestAuditEventOverrides) => AuditLogEvent;
|
|
49
|
+
/**
|
|
50
|
+
* Create a well-formed raw API token for tests that need a bearer value to
|
|
51
|
+
* pass the `is_api_token_shaped` precheck and reach token validation. The
|
|
52
|
+
* body is `label` padded with `A` to the generated length, so distinct labels
|
|
53
|
+
* give distinct tokens and a token in a failure message reads back to its
|
|
54
|
+
* test. Not a real credential — it authenticates only where a test mocks the
|
|
55
|
+
* lookup.
|
|
56
|
+
*
|
|
57
|
+
* @param label - base64url characters (`A-Z a-z 0-9 - _`) that start the token body
|
|
58
|
+
* @returns `API_TOKEN_PREFIX` plus an `API_TOKEN_BODY_LENGTH`-char body
|
|
59
|
+
* @throws Error when `label` is longer than the body or not base64url
|
|
60
|
+
*/
|
|
61
|
+
export declare const create_test_raw_api_token: (label?: string) => string;
|
|
62
|
+
/** Override type for `create_test_validated_api_token` — `account_id` accepts plain `string`. */
|
|
63
|
+
export type TestValidatedApiTokenOverrides = Partial<Omit<ValidatedApiToken, 'account_id'>> & {
|
|
64
|
+
account_id?: string;
|
|
65
|
+
};
|
|
66
|
+
/**
|
|
67
|
+
* Create a test `ValidatedApiToken` — what `query_validate_api_token` returns
|
|
68
|
+
* for a live token — with a `full` scope by default.
|
|
69
|
+
*/
|
|
70
|
+
export declare const create_test_validated_api_token: (overrides?: TestValidatedApiTokenOverrides) => ValidatedApiToken;
|
|
48
71
|
//# sourceMappingURL=entities.d.ts.map
|