@fuzdev/fuz_app 0.5.0 → 0.7.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.
Files changed (42) hide show
  1. package/dist/actions/action_bridge.d.ts +3 -3
  2. package/dist/actions/action_bridge.d.ts.map +1 -1
  3. package/dist/actions/action_bridge.js +4 -3
  4. package/dist/actions/action_rpc.d.ts +89 -0
  5. package/dist/actions/action_rpc.d.ts.map +1 -0
  6. package/dist/actions/action_rpc.js +248 -0
  7. package/dist/http/jsonrpc.d.ts +62 -0
  8. package/dist/http/jsonrpc.d.ts.map +1 -0
  9. package/dist/http/jsonrpc.js +49 -0
  10. package/dist/http/jsonrpc_errors.d.ts +132 -0
  11. package/dist/http/jsonrpc_errors.d.ts.map +1 -0
  12. package/dist/http/jsonrpc_errors.js +197 -0
  13. package/dist/http/route_spec.d.ts +2 -1
  14. package/dist/http/route_spec.d.ts.map +1 -1
  15. package/dist/http/route_spec.js +43 -7
  16. package/dist/http/surface.d.ts +25 -0
  17. package/dist/http/surface.d.ts.map +1 -1
  18. package/dist/http/surface.js +16 -1
  19. package/dist/server/app_server.d.ts +3 -1
  20. package/dist/server/app_server.d.ts.map +1 -1
  21. package/dist/server/app_server.js +2 -1
  22. package/dist/testing/adversarial_input.d.ts.map +1 -1
  23. package/dist/testing/adversarial_input.js +22 -7
  24. package/dist/testing/app_server.d.ts +2 -1
  25. package/dist/testing/app_server.d.ts.map +1 -1
  26. package/dist/testing/app_server.js +1 -0
  27. package/dist/testing/rpc_attack_surface.d.ts +23 -0
  28. package/dist/testing/rpc_attack_surface.d.ts.map +1 -0
  29. package/dist/testing/rpc_attack_surface.js +376 -0
  30. package/dist/testing/rpc_helpers.d.ts +44 -0
  31. package/dist/testing/rpc_helpers.d.ts.map +1 -0
  32. package/dist/testing/rpc_helpers.js +74 -0
  33. package/dist/testing/rpc_round_trip.d.ts +41 -0
  34. package/dist/testing/rpc_round_trip.d.ts.map +1 -0
  35. package/dist/testing/rpc_round_trip.js +163 -0
  36. package/dist/testing/stubs.d.ts +3 -1
  37. package/dist/testing/stubs.d.ts.map +1 -1
  38. package/dist/testing/stubs.js +2 -1
  39. package/dist/testing/surface_invariants.d.ts +4 -0
  40. package/dist/testing/surface_invariants.d.ts.map +1 -1
  41. package/dist/testing/surface_invariants.js +4 -0
  42. package/package.json +1 -1
@@ -0,0 +1,376 @@
1
+ import './assert_dev_env.js';
2
+ /**
3
+ * Composable RPC attack surface test suite.
4
+ *
5
+ * Three test groups for JSON-RPC 2.0 endpoints:
6
+ * 1. **Auth enforcement** — per-method auth inside the dispatcher
7
+ * 2. **Adversarial envelopes** — malformed JSON-RPC requests
8
+ * 3. **Adversarial params** — schema-invalid params per method
9
+ *
10
+ * Uses the same `{build, roles}` config as `describe_adversarial_auth`
11
+ * and `describe_adversarial_input`. No DB needed — uses stub deps.
12
+ *
13
+ * @module
14
+ */
15
+ import { test, assert, describe } from 'vitest';
16
+ import { JSONRPC_ERROR_CODES } from '../http/jsonrpc_errors.js';
17
+ import { create_auth_test_apps, select_auth_app } from './auth_apps.js';
18
+ import { generate_input_test_cases } from './adversarial_input.js';
19
+ import { ERROR_INVALID_JSON_BODY } from '../http/error_schemas.js';
20
+ import { create_rpc_post_init, create_rpc_get_url, assert_jsonrpc_error_response, } from './rpc_helpers.js';
21
+ // --- Helpers ---
22
+ /** Filter RPC methods that require any form of authentication. */
23
+ const filter_protected_rpc_methods = (endpoint) => endpoint.methods.filter((m) => m.auth.type !== 'none');
24
+ /** Filter RPC methods that require a specific role. */
25
+ const filter_role_rpc_methods = (endpoint) => endpoint.methods.filter((m) => m.auth.type === 'role');
26
+ /** Find the `RpcAction` source spec for a surface method. */
27
+ const find_rpc_action = (rpc_endpoint_specs, endpoint_path, method_name) => {
28
+ const ep = rpc_endpoint_specs.find((e) => e.path === endpoint_path);
29
+ return ep?.actions.find((a) => a.spec.method === method_name);
30
+ };
31
+ // --- Auth enforcement ---
32
+ /**
33
+ * Generate adversarial auth enforcement tests for RPC endpoints.
34
+ *
35
+ * For each endpoint, iterates methods with auth requirements and fires
36
+ * JSON-RPC envelopes with wrong/missing credentials. Auth errors are
37
+ * JSON-RPC format: `{jsonrpc, id, error: {code, message}}`.
38
+ *
39
+ * Describe blocks:
40
+ * - unauthenticated → error code -32001 — every protected method
41
+ * - wrong role → error code -32002 — every role method with non-matching roles
42
+ * - authenticated without role → -32002 — every role method, no-role context
43
+ * - keeper routes reject session credential → -32002
44
+ * - correct auth passes — every protected method, assert not 401/403
45
+ */
46
+ const describe_rpc_auth = (options) => {
47
+ const { build, roles } = options;
48
+ const { surface, route_specs } = build();
49
+ if (surface.rpc_endpoints.length === 0)
50
+ return;
51
+ const apps = create_auth_test_apps(route_specs, roles);
52
+ describe('RPC auth enforcement', () => {
53
+ for (const endpoint of surface.rpc_endpoints) {
54
+ const protected_methods = filter_protected_rpc_methods(endpoint);
55
+ if (protected_methods.length === 0)
56
+ continue;
57
+ const role_methods = filter_role_rpc_methods(endpoint);
58
+ describe(endpoint.path, () => {
59
+ describe('unauthenticated → JSON-RPC error', () => {
60
+ for (const method of protected_methods) {
61
+ test(`${method.name} (${format_auth(method.auth)})`, async () => {
62
+ const res = await apps.public.request(endpoint.path, create_rpc_post_init(method.name));
63
+ assert.strictEqual(res.status, 401, `${method.name} should return 401`);
64
+ const body = await res.json();
65
+ assert_jsonrpc_error_response(body, JSONRPC_ERROR_CODES.unauthenticated);
66
+ });
67
+ }
68
+ });
69
+ if (role_methods.length > 0) {
70
+ describe('wrong role → forbidden', () => {
71
+ for (const method of role_methods) {
72
+ const wrong_roles = roles.filter((r) => r !== method.auth.role);
73
+ for (const wrong_role of wrong_roles) {
74
+ test(`${method.name} (${wrong_role} instead of ${method.auth.role})`, async () => {
75
+ const app = apps.by_role.get(wrong_role);
76
+ if (!app)
77
+ throw new Error(`No test app for role '${wrong_role}'`);
78
+ const res = await app.request(endpoint.path, create_rpc_post_init(method.name));
79
+ assert.strictEqual(res.status, 403, `${method.name} should return 403`);
80
+ const body = await res.json();
81
+ assert_jsonrpc_error_response(body, JSONRPC_ERROR_CODES.forbidden);
82
+ });
83
+ }
84
+ }
85
+ });
86
+ describe('authenticated without role → forbidden', () => {
87
+ for (const method of role_methods) {
88
+ test(`${method.name} (${method.auth.role})`, async () => {
89
+ const res = await apps.authed.request(endpoint.path, create_rpc_post_init(method.name));
90
+ assert.strictEqual(res.status, 403, `${method.name} should return 403`);
91
+ const body = await res.json();
92
+ assert_jsonrpc_error_response(body, JSONRPC_ERROR_CODES.forbidden);
93
+ });
94
+ }
95
+ });
96
+ }
97
+ // NOTE: no "keeper rejects session credential" test for RPC — the RPC
98
+ // dispatcher's check_action_auth only checks role, not credential type.
99
+ // Credential type enforcement is a REST middleware concern (require_keeper).
100
+ describe('correct auth passes', () => {
101
+ for (const method of protected_methods) {
102
+ test(method.name, async () => {
103
+ const app = select_auth_app(apps, method.auth);
104
+ const res = await app.request(endpoint.path, create_rpc_post_init(method.name));
105
+ // handler may error (500, 404 from stub deps) — that's fine
106
+ assert.notStrictEqual(res.status, 401, 'should not be 401');
107
+ assert.notStrictEqual(res.status, 403, 'should not be 403');
108
+ });
109
+ }
110
+ });
111
+ // also test GET for read methods with auth
112
+ const protected_reads = protected_methods.filter((m) => !m.side_effects);
113
+ if (protected_reads.length > 0) {
114
+ describe('GET unauthenticated → JSON-RPC error', () => {
115
+ for (const method of protected_reads) {
116
+ test(`${method.name} (GET)`, async () => {
117
+ const url = create_rpc_get_url(endpoint.path, method.name);
118
+ const res = await apps.public.request(url);
119
+ assert.strictEqual(res.status, 401, `GET ${method.name} should return 401`);
120
+ const body = await res.json();
121
+ assert_jsonrpc_error_response(body, JSONRPC_ERROR_CODES.unauthenticated);
122
+ });
123
+ }
124
+ });
125
+ }
126
+ });
127
+ }
128
+ });
129
+ };
130
+ // --- Adversarial envelopes ---
131
+ /**
132
+ * Generate adversarial envelope tests for RPC endpoints.
133
+ *
134
+ * Fixed set of malformation cases that exercise the dispatcher's
135
+ * envelope parsing (step 1) and method lookup (step 2).
136
+ */
137
+ const describe_rpc_adversarial_envelopes = (options) => {
138
+ const { build, roles } = options;
139
+ const { surface, route_specs } = build();
140
+ if (surface.rpc_endpoints.length === 0)
141
+ return;
142
+ // public app for envelope errors (happen before auth checks)
143
+ const apps = create_auth_test_apps(route_specs, []);
144
+ // authed apps for the GET mutation test (needs correct auth to reach the side_effects check)
145
+ const authed_apps = create_auth_test_apps(route_specs, roles);
146
+ describe('RPC adversarial envelopes', () => {
147
+ for (const endpoint of surface.rpc_endpoints) {
148
+ // find a mutation method for GET-restriction testing
149
+ const mutation_method = endpoint.methods.find((m) => m.side_effects);
150
+ describe(endpoint.path, () => {
151
+ // --- POST envelope malformation ---
152
+ test('non-JSON body → parse_error', async () => {
153
+ const res = await apps.public.request(endpoint.path, {
154
+ method: 'POST',
155
+ headers: { 'Content-Type': 'application/json' },
156
+ body: 'not-json',
157
+ });
158
+ assert.strictEqual(res.status, 400);
159
+ const body = await res.json();
160
+ assert_jsonrpc_error_response(body, JSONRPC_ERROR_CODES.parse_error);
161
+ });
162
+ test('wrong jsonrpc version → invalid_request', async () => {
163
+ const res = await apps.public.request(endpoint.path, {
164
+ method: 'POST',
165
+ headers: { 'Content-Type': 'application/json' },
166
+ body: JSON.stringify({
167
+ jsonrpc: '1.0',
168
+ id: 'test',
169
+ method: endpoint.methods[0]?.name ?? 'any',
170
+ }),
171
+ });
172
+ assert.strictEqual(res.status, 400);
173
+ const body = await res.json();
174
+ assert_jsonrpc_error_response(body, JSONRPC_ERROR_CODES.invalid_request);
175
+ });
176
+ test('missing jsonrpc field → invalid_request', async () => {
177
+ const res = await apps.public.request(endpoint.path, {
178
+ method: 'POST',
179
+ headers: { 'Content-Type': 'application/json' },
180
+ body: JSON.stringify({
181
+ id: 'test',
182
+ method: endpoint.methods[0]?.name ?? 'any',
183
+ }),
184
+ });
185
+ assert.strictEqual(res.status, 400);
186
+ const body = await res.json();
187
+ assert_jsonrpc_error_response(body, JSONRPC_ERROR_CODES.invalid_request);
188
+ });
189
+ test('missing method field → invalid_request', async () => {
190
+ const res = await apps.public.request(endpoint.path, {
191
+ method: 'POST',
192
+ headers: { 'Content-Type': 'application/json' },
193
+ body: JSON.stringify({ jsonrpc: '2.0', id: 'test' }),
194
+ });
195
+ assert.strictEqual(res.status, 400);
196
+ const body = await res.json();
197
+ assert_jsonrpc_error_response(body, JSONRPC_ERROR_CODES.invalid_request);
198
+ });
199
+ test('missing id field → invalid_request', async () => {
200
+ const res = await apps.public.request(endpoint.path, {
201
+ method: 'POST',
202
+ headers: { 'Content-Type': 'application/json' },
203
+ body: JSON.stringify({
204
+ jsonrpc: '2.0',
205
+ method: endpoint.methods[0]?.name ?? 'any',
206
+ }),
207
+ });
208
+ assert.strictEqual(res.status, 400);
209
+ const body = await res.json();
210
+ assert_jsonrpc_error_response(body, JSONRPC_ERROR_CODES.invalid_request);
211
+ });
212
+ test('batch (array) body → invalid_request', async () => {
213
+ const res = await apps.public.request(endpoint.path, {
214
+ method: 'POST',
215
+ headers: { 'Content-Type': 'application/json' },
216
+ body: JSON.stringify([
217
+ { jsonrpc: '2.0', id: '1', method: endpoint.methods[0]?.name ?? 'any' },
218
+ ]),
219
+ });
220
+ assert.strictEqual(res.status, 400);
221
+ const body = await res.json();
222
+ assert_jsonrpc_error_response(body, JSONRPC_ERROR_CODES.invalid_request);
223
+ assert.strictEqual(body.id, null, 'batch has no extractable id');
224
+ });
225
+ test('unknown method name → method_not_found', async () => {
226
+ const res = await apps.public.request(endpoint.path, create_rpc_post_init('__nonexistent_method__'));
227
+ assert.strictEqual(res.status, 404);
228
+ const body = await res.json();
229
+ assert_jsonrpc_error_response(body, JSONRPC_ERROR_CODES.method_not_found);
230
+ });
231
+ // --- GET envelope malformation ---
232
+ test('GET missing method → invalid_request', async () => {
233
+ const res = await apps.public.request(`${endpoint.path}?id=test`);
234
+ assert.strictEqual(res.status, 400);
235
+ const body = await res.json();
236
+ assert_jsonrpc_error_response(body, JSONRPC_ERROR_CODES.invalid_request);
237
+ });
238
+ test('GET missing id → invalid_request', async () => {
239
+ const first_method = endpoint.methods[0]?.name ?? 'any';
240
+ const res = await apps.public.request(`${endpoint.path}?method=${first_method}`);
241
+ assert.strictEqual(res.status, 400);
242
+ const body = await res.json();
243
+ assert_jsonrpc_error_response(body, JSONRPC_ERROR_CODES.invalid_request);
244
+ });
245
+ test('GET invalid JSON params → invalid_params', async () => {
246
+ const read_method = endpoint.methods.find((m) => !m.side_effects);
247
+ // skip if no read methods exist
248
+ if (!read_method)
249
+ return;
250
+ const res = await apps.public.request(`${endpoint.path}?method=${read_method.name}&id=test&params=not-json`);
251
+ assert.strictEqual(res.status, 400);
252
+ const body = await res.json();
253
+ assert_jsonrpc_error_response(body, JSONRPC_ERROR_CODES.invalid_params);
254
+ });
255
+ test('GET non-object params → error', async () => {
256
+ const read_method = endpoint.methods.find((m) => !m.side_effects);
257
+ // skip if no read methods exist
258
+ if (!read_method)
259
+ return;
260
+ // valid JSON but not an object — hits dispatcher's params validation
261
+ const res = await apps.public.request(`${endpoint.path}?method=${read_method.name}&id=test&params=42`);
262
+ // should reject: either invalid_params (step 4) or auth error (step 3)
263
+ assert.ok(res.status >= 400, `expected error status for non-object params, got ${res.status}`);
264
+ const body = await res.json();
265
+ assert_jsonrpc_error_response(body);
266
+ });
267
+ if (mutation_method) {
268
+ test('GET mutation method → invalid_request (side effects)', async () => {
269
+ const url = create_rpc_get_url(endpoint.path, mutation_method.name);
270
+ // need correct auth to reach the side_effects check
271
+ const app = select_auth_app(authed_apps, mutation_method.auth);
272
+ const res = await app.request(url);
273
+ assert.strictEqual(res.status, 400);
274
+ const body = await res.json();
275
+ assert_jsonrpc_error_response(body, JSONRPC_ERROR_CODES.invalid_request);
276
+ });
277
+ }
278
+ });
279
+ }
280
+ });
281
+ };
282
+ // --- Adversarial params ---
283
+ /**
284
+ * Generate adversarial params validation tests for RPC endpoints.
285
+ *
286
+ * For each method with a non-null input schema, generates test cases
287
+ * from the schema (wrong types, missing fields, format violations)
288
+ * and wraps them in valid JSON-RPC envelopes. Reuses
289
+ * `generate_input_test_cases` from `adversarial_input.ts`.
290
+ */
291
+ const describe_rpc_adversarial_params = (options) => {
292
+ const { build, roles } = options;
293
+ const { surface, route_specs, rpc_endpoints: rpc_endpoint_specs } = build();
294
+ if (surface.rpc_endpoints.length === 0)
295
+ return;
296
+ const apps = create_auth_test_apps(route_specs, roles);
297
+ let total_cases = 0;
298
+ describe('RPC adversarial params', () => {
299
+ for (const endpoint of surface.rpc_endpoints) {
300
+ const methods_with_input = endpoint.methods.filter((m) => m.input_schema !== null);
301
+ if (methods_with_input.length === 0)
302
+ continue;
303
+ describe(endpoint.path, () => {
304
+ for (const method of methods_with_input) {
305
+ // look up the source RpcAction for the Zod schema
306
+ const action = find_rpc_action(rpc_endpoint_specs, endpoint.path, method.name);
307
+ if (!action) {
308
+ test(`${method.name} — missing RpcAction source spec`, () => {
309
+ assert.fail(`surface has method '${method.name}' but no matching RpcAction in rpc_endpoints`);
310
+ });
311
+ continue;
312
+ }
313
+ // filter out structural cases (non-object body) — those fail at
314
+ // envelope validation (invalid_request) not params validation (invalid_params).
315
+ // Envelope-level structural errors are covered by adversarial envelopes.
316
+ const test_cases = generate_input_test_cases(action.spec.input).filter((tc) => tc.expected_error !== ERROR_INVALID_JSON_BODY);
317
+ if (test_cases.length === 0)
318
+ continue;
319
+ total_cases += test_cases.length;
320
+ const app = select_auth_app(apps, method.auth);
321
+ describe(method.name, () => {
322
+ for (const tc of test_cases) {
323
+ test(tc.label, async () => {
324
+ const res = await app.request(endpoint.path, create_rpc_post_init(method.name, tc.body));
325
+ assert.strictEqual(res.status, 400, `Expected 400 for ${method.name} [${tc.label}], got ${res.status}`);
326
+ const body = await res.json();
327
+ assert_jsonrpc_error_response(body, JSONRPC_ERROR_CODES.invalid_params);
328
+ });
329
+ }
330
+ });
331
+ }
332
+ });
333
+ }
334
+ test('generated RPC params test cases', () => {
335
+ // soft check — methods with only null-input schemas produce 0 cases
336
+ if (surface.rpc_endpoints.some((ep) => ep.methods.some((m) => m.input_schema !== null))) {
337
+ assert.ok(total_cases > 0, 'No RPC params test cases generated — schema walking may be broken');
338
+ }
339
+ });
340
+ });
341
+ };
342
+ // --- Helpers (formatting) ---
343
+ /** Format a `RouteAuth` as a human-readable label. */
344
+ const format_auth = (auth) => {
345
+ switch (auth.type) {
346
+ case 'none':
347
+ return 'public';
348
+ case 'authenticated':
349
+ return 'authenticated';
350
+ case 'role':
351
+ return `role: ${auth.role}`;
352
+ case 'keeper':
353
+ return 'keeper';
354
+ }
355
+ };
356
+ // --- Public API ---
357
+ /**
358
+ * Run the standard RPC attack surface test suite.
359
+ *
360
+ * Generates 3 test groups:
361
+ * 1. Auth enforcement — per-method auth checks via JSON-RPC envelopes
362
+ * 2. Adversarial envelopes — malformed JSON-RPC requests
363
+ * 3. Adversarial params — schema-invalid params per method
364
+ *
365
+ * Skips silently when `surface.rpc_endpoints` is empty.
366
+ *
367
+ * @param options - the test configuration
368
+ */
369
+ export const describe_rpc_attack_surface_tests = (options) => {
370
+ const { surface } = options.build();
371
+ if (surface.rpc_endpoints.length === 0)
372
+ return;
373
+ describe_rpc_auth(options);
374
+ describe_rpc_adversarial_envelopes(options);
375
+ describe_rpc_adversarial_params(options);
376
+ };
@@ -0,0 +1,44 @@
1
+ import './assert_dev_env.js';
2
+ import { z } from 'zod';
3
+ import type { JsonrpcErrorCode } from '../http/jsonrpc_errors.js';
4
+ /**
5
+ * Create a `RequestInit` for a JSON-RPC POST request.
6
+ *
7
+ * @param method - JSON-RPC method name
8
+ * @param params - params object (omit for null-input methods)
9
+ * @param id - request id (default `'test'`)
10
+ * @returns a `RequestInit` with the JSON-RPC envelope as body
11
+ */
12
+ export declare const create_rpc_post_init: (method: string, params?: unknown, id?: string | number) => RequestInit;
13
+ /**
14
+ * Build a GET URL with JSON-RPC query parameters.
15
+ *
16
+ * @param endpoint_path - the RPC endpoint path (e.g., `/api/rpc`)
17
+ * @param method - JSON-RPC method name
18
+ * @param params - params object (omit for null-input methods)
19
+ * @param id - request id (default `'test'`)
20
+ * @returns the full URL with query string
21
+ */
22
+ export declare const create_rpc_get_url: (endpoint_path: string, method: string, params?: unknown, id?: string | number) => string;
23
+ /**
24
+ * Assert that a response body is a valid JSON-RPC error response.
25
+ *
26
+ * Validates the structure matches `JsonrpcErrorResponse` and optionally
27
+ * checks the error code.
28
+ *
29
+ * @param body - parsed response body
30
+ * @param expected_code - optional error code to assert
31
+ */
32
+ export declare const assert_jsonrpc_error_response: (body: unknown, expected_code?: JsonrpcErrorCode) => void;
33
+ /**
34
+ * Assert that a response body is a valid JSON-RPC success response.
35
+ *
36
+ * Validates the structure matches `JsonrpcResponse`. When `output_schema`
37
+ * is provided, also validates the `result` field against the declared
38
+ * output schema — matching the REST round-trip's `assert_response_matches_spec`.
39
+ *
40
+ * @param body - parsed response body
41
+ * @param output_schema - optional Zod schema to validate the `result` field against
42
+ */
43
+ export declare const assert_jsonrpc_success_response: (body: unknown, output_schema?: z.ZodType) => void;
44
+ //# sourceMappingURL=rpc_helpers.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rpc_helpers.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/rpc_helpers.ts"],"names":[],"mappings":"AAAA,OAAO,qBAAqB,CAAC;AAW7B,OAAO,EAAC,CAAC,EAAC,MAAM,KAAK,CAAC;AAGtB,OAAO,KAAK,EAAC,gBAAgB,EAAC,MAAM,2BAA2B,CAAC;AAEhE;;;;;;;GAOG;AACH,eAAO,MAAM,oBAAoB,GAChC,QAAQ,MAAM,EACd,SAAS,OAAO,EAChB,KAAI,MAAM,GAAG,MAAe,KAC1B,WAID,CAAC;AAEH;;;;;;;;GAQG;AACH,eAAO,MAAM,kBAAkB,GAC9B,eAAe,MAAM,EACrB,QAAQ,MAAM,EACd,SAAS,OAAO,EAChB,KAAI,MAAM,GAAG,MAAe,KAC1B,MAMF,CAAC;AAEF;;;;;;;;GAQG;AACH,eAAO,MAAM,6BAA6B,GACzC,MAAM,OAAO,EACb,gBAAgB,gBAAgB,KAC9B,IAUF,CAAC;AAEF;;;;;;;;;GASG;AACH,eAAO,MAAM,+BAA+B,GAAI,MAAM,OAAO,EAAE,gBAAgB,CAAC,CAAC,OAAO,KAAG,IAU1F,CAAC"}
@@ -0,0 +1,74 @@
1
+ import './assert_dev_env.js';
2
+ /**
3
+ * JSON-RPC request construction and response assertion helpers.
4
+ *
5
+ * Shared by `rpc_attack_surface.ts` and `rpc_round_trip.ts`.
6
+ *
7
+ * @module
8
+ */
9
+ import { assert } from 'vitest';
10
+ import { z } from 'zod';
11
+ import { JSONRPC_VERSION, JsonrpcErrorResponse, JsonrpcResponse } from '../http/jsonrpc.js';
12
+ /**
13
+ * Create a `RequestInit` for a JSON-RPC POST request.
14
+ *
15
+ * @param method - JSON-RPC method name
16
+ * @param params - params object (omit for null-input methods)
17
+ * @param id - request id (default `'test'`)
18
+ * @returns a `RequestInit` with the JSON-RPC envelope as body
19
+ */
20
+ export const create_rpc_post_init = (method, params, id = 'test') => ({
21
+ method: 'POST',
22
+ headers: { 'Content-Type': 'application/json' },
23
+ body: JSON.stringify({ jsonrpc: JSONRPC_VERSION, method, params, id }),
24
+ });
25
+ /**
26
+ * Build a GET URL with JSON-RPC query parameters.
27
+ *
28
+ * @param endpoint_path - the RPC endpoint path (e.g., `/api/rpc`)
29
+ * @param method - JSON-RPC method name
30
+ * @param params - params object (omit for null-input methods)
31
+ * @param id - request id (default `'test'`)
32
+ * @returns the full URL with query string
33
+ */
34
+ export const create_rpc_get_url = (endpoint_path, method, params, id = 'test') => {
35
+ const search = new URLSearchParams({ method, id: String(id) });
36
+ if (params !== undefined && params !== null) {
37
+ search.set('params', JSON.stringify(params));
38
+ }
39
+ return `${endpoint_path}?${search.toString()}`;
40
+ };
41
+ /**
42
+ * Assert that a response body is a valid JSON-RPC error response.
43
+ *
44
+ * Validates the structure matches `JsonrpcErrorResponse` and optionally
45
+ * checks the error code.
46
+ *
47
+ * @param body - parsed response body
48
+ * @param expected_code - optional error code to assert
49
+ */
50
+ export const assert_jsonrpc_error_response = (body, expected_code) => {
51
+ const result = JsonrpcErrorResponse.safeParse(body);
52
+ assert.ok(result.success, `not a valid JSON-RPC error response: ${JSON.stringify(body)}`);
53
+ if (expected_code !== undefined) {
54
+ assert.strictEqual(result.data.error.code, expected_code, `expected error code ${expected_code}, got ${result.data.error.code}`);
55
+ }
56
+ };
57
+ /**
58
+ * Assert that a response body is a valid JSON-RPC success response.
59
+ *
60
+ * Validates the structure matches `JsonrpcResponse`. When `output_schema`
61
+ * is provided, also validates the `result` field against the declared
62
+ * output schema — matching the REST round-trip's `assert_response_matches_spec`.
63
+ *
64
+ * @param body - parsed response body
65
+ * @param output_schema - optional Zod schema to validate the `result` field against
66
+ */
67
+ export const assert_jsonrpc_success_response = (body, output_schema) => {
68
+ const result = JsonrpcResponse.safeParse(body);
69
+ assert.ok(result.success, `not a valid JSON-RPC success response: ${JSON.stringify(body)}`);
70
+ if (output_schema) {
71
+ const output_result = output_schema.safeParse(result.data.result);
72
+ assert.ok(output_result.success, `JSON-RPC result does not match output schema: ${JSON.stringify(output_result.error?.issues)}`);
73
+ }
74
+ };
@@ -0,0 +1,41 @@
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 DbFactory } from './db.js';
6
+ import type { RpcEndpointSpec } from '../http/surface.js';
7
+ /** Options for `describe_rpc_round_trip_tests`. */
8
+ export interface RpcRoundTripTestOptions {
9
+ /** Session config for cookie-based auth. */
10
+ session_options: SessionOptions<string>;
11
+ /** Route spec factory — same one used in production. */
12
+ create_route_specs: (ctx: AppServerContext) => Array<RouteSpec>;
13
+ /** RPC endpoint specs — the source `RpcAction` arrays for params generation. */
14
+ rpc_endpoints: Array<RpcEndpointSpec>;
15
+ /** Optional overrides for `AppServerOptions`. */
16
+ app_options?: Partial<Omit<AppServerOptions, 'backend' | 'session_options' | 'create_route_specs'>>;
17
+ /** Database factories to run tests against. Default: pglite only. */
18
+ db_factories?: Array<DbFactory>;
19
+ /** Methods to skip, by name (e.g., `'tx_plan'`). */
20
+ skip_methods?: Array<string>;
21
+ /** Override generated params for specific methods (method name → params). */
22
+ input_overrides?: Map<string, Record<string, unknown>>;
23
+ }
24
+ /**
25
+ * Run schema-driven round-trip validation for RPC endpoints.
26
+ *
27
+ * For each method:
28
+ * 1. Generate valid params from the action's input schema
29
+ * 2. Fire a POST request with JSON-RPC envelope
30
+ * 3. For `side_effects: false` methods, also fire a GET request
31
+ * 4. Validate response is well-formed JSON-RPC; successful responses are
32
+ * also validated against the method's declared output schema
33
+ *
34
+ * Error responses (from missing DB state, etc.) are expected and validated
35
+ * as well-formed JSON-RPC errors. Successful responses are validated against
36
+ * `action.spec.output`.
37
+ *
38
+ * @param options - round-trip test configuration
39
+ */
40
+ export declare const describe_rpc_round_trip_tests: (options: RpcRoundTripTestOptions) => void;
41
+ //# sourceMappingURL=rpc_round_trip.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rpc_round_trip.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/rpc_round_trip.ts"],"names":[],"mappings":"AAAA,OAAO,qBAAqB,CAAC;AAe7B,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;AAG9D,OAAO,EAAwB,KAAK,SAAS,EAAC,MAAM,SAAS,CAAC;AAK9D,OAAO,KAAK,EAAC,eAAe,EAAsB,MAAM,oBAAoB,CAAC;AAQ7E,mDAAmD;AACnD,MAAM,WAAW,uBAAuB;IACvC,4CAA4C;IAC5C,eAAe,EAAE,cAAc,CAAC,MAAM,CAAC,CAAC;IACxC,wDAAwD;IACxD,kBAAkB,EAAE,CAAC,GAAG,EAAE,gBAAgB,KAAK,KAAK,CAAC,SAAS,CAAC,CAAC;IAChE,gFAAgF;IAChF,aAAa,EAAE,KAAK,CAAC,eAAe,CAAC,CAAC;IACtC,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,oDAAoD;IACpD,YAAY,CAAC,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;IAC7B,6EAA6E;IAC7E,eAAe,CAAC,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;CACvD;AA2BD;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,6BAA6B,GAAI,SAAS,uBAAuB,KAAG,IAsIhF,CAAC"}