@fuzdev/fuz_app 0.131.0 → 0.132.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/dist/actions/transports_http.d.ts +7 -0
  2. package/dist/actions/transports_http.d.ts.map +1 -1
  3. package/dist/actions/transports_http.js +18 -1
  4. package/dist/auth/CLAUDE.md +1 -1
  5. package/dist/auth/middleware.d.ts +13 -10
  6. package/dist/auth/middleware.d.ts.map +1 -1
  7. package/dist/auth/middleware.js +14 -10
  8. package/dist/http/CLAUDE.md +3 -2
  9. package/dist/http/error_schemas.d.ts +16 -1
  10. package/dist/http/error_schemas.d.ts.map +1 -1
  11. package/dist/http/error_schemas.js +14 -1
  12. package/dist/http/origin.d.ts +10 -6
  13. package/dist/http/origin.d.ts.map +1 -1
  14. package/dist/http/origin.js +10 -6
  15. package/dist/http/route_spec.d.ts +19 -12
  16. package/dist/http/route_spec.d.ts.map +1 -1
  17. package/dist/http/route_spec.js +33 -6
  18. package/dist/http/schema_helpers.d.ts +3 -1
  19. package/dist/http/schema_helpers.d.ts.map +1 -1
  20. package/dist/http/schema_helpers.js +3 -2
  21. package/dist/http/simple_request.d.ts +63 -0
  22. package/dist/http/simple_request.d.ts.map +1 -0
  23. package/dist/http/simple_request.js +84 -0
  24. package/dist/server/app_server.d.ts +2 -2
  25. package/dist/server/app_server.d.ts.map +1 -1
  26. package/dist/server/app_server.js +5 -1
  27. package/dist/testing/CLAUDE.md +17 -0
  28. package/dist/testing/admin_integration.d.ts.map +1 -1
  29. package/dist/testing/admin_integration.js +4 -2
  30. package/dist/testing/adversarial_404.d.ts.map +1 -1
  31. package/dist/testing/adversarial_404.js +5 -3
  32. package/dist/testing/adversarial_input.d.ts.map +1 -1
  33. package/dist/testing/adversarial_input.js +9 -3
  34. package/dist/testing/attack_surface.d.ts.map +1 -1
  35. package/dist/testing/attack_surface.js +15 -16
  36. package/dist/testing/audit_completeness.js +1 -1
  37. package/dist/testing/cross_backend/simple_request.d.ts +11 -0
  38. package/dist/testing/cross_backend/simple_request.d.ts.map +1 -0
  39. package/dist/testing/cross_backend/simple_request.js +142 -0
  40. package/dist/testing/cross_backend/standard.d.ts +6 -0
  41. package/dist/testing/cross_backend/standard.d.ts.map +1 -1
  42. package/dist/testing/cross_backend/standard.js +2 -1
  43. package/dist/testing/data_exposure.d.ts.map +1 -1
  44. package/dist/testing/data_exposure.js +11 -8
  45. package/dist/testing/integration.d.ts.map +1 -1
  46. package/dist/testing/integration.js +40 -11
  47. package/dist/testing/round_trip.d.ts.map +1 -1
  48. package/dist/testing/round_trip.js +3 -9
  49. package/dist/testing/rpc_helpers.d.ts.map +1 -1
  50. package/dist/testing/rpc_helpers.js +19 -6
  51. package/dist/testing/schema_generators.d.ts +12 -0
  52. package/dist/testing/schema_generators.d.ts.map +1 -1
  53. package/dist/testing/schema_generators.js +10 -0
  54. package/dist/testing/stubs.d.ts.map +1 -1
  55. package/dist/testing/stubs.js +3 -0
  56. package/dist/ui/auth_state.svelte.d.ts.map +1 -1
  57. package/dist/ui/auth_state.svelte.js +5 -1
  58. package/package.json +3 -3
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Refusing CORS simple requests — the cross-origin POSTs a browser sends
3
+ * without asking first.
4
+ *
5
+ * A cross-origin `fetch` or form `POST` skips the CORS preflight when its
6
+ * `Content-Type` is absent or one of the three CORS-safelisted types
7
+ * (`text/plain`, `application/x-www-form-urlencoded`, `multipart/form-data`).
8
+ * The browser sends such a request as-is, with whatever cookies apply — only
9
+ * the response is withheld from the page. Any other content type makes the
10
+ * browser send an `OPTIONS` preflight first, and since fuz_app answers no
11
+ * preflight with CORS headers, the real request is never sent.
12
+ *
13
+ * The session cookie's `SameSite=Strict` doesn't close this on its own:
14
+ * same-site ignores the port, so a page on `http://localhost:8000` is
15
+ * same-site with a server on `http://localhost:4460` and its requests carry
16
+ * the cookie. The origin allowlist (`http/origin.ts`) refuses such a page
17
+ * unless a wildcard (`http://localhost:*`) admits it; this gate refuses it
18
+ * whatever the allowlist says.
19
+ *
20
+ * So every `POST` route carries a content-type check, run first by
21
+ * `apply_route_specs` (`is_post_content_type_refused`, 415
22
+ * `unsupported_content_type`): a JSON route takes only `application/json`,
23
+ * bodyless or not, and a `raw_body` route takes any type that isn't
24
+ * safelisted. Only `POST` needs it: `PUT`, `PATCH`, and `DELETE` are never
25
+ * simple methods, so the browser always preflights them.
26
+ *
27
+ * The check rests on the server answering no preflight. A consumer that adds
28
+ * CORS middleware granting cross-origin requests reopens what this closes.
29
+ *
30
+ * Twin of the Rust spine's `fuz_http::simple_request`: the same predicates,
31
+ * `is_json_content_type` checked in each spine JSON handler and
32
+ * `is_cors_simple_content_type` by the `simple_request_layer` consumers mount
33
+ * over routes with other bodies.
34
+ *
35
+ * @module
36
+ */
37
+ /**
38
+ * True when a browser could have sent a `POST` with this `Content-Type`
39
+ * cross-origin without a preflight: none, or a CORS-safelisted one.
40
+ *
41
+ * Matches more than browsers do (a value they'd refuse to call safelisted —
42
+ * one with odd bytes, say — counts as safelisted here), so it only ever
43
+ * refuses too much, never too little.
44
+ *
45
+ * @param content_type - the request's `Content-Type` header, if any
46
+ */
47
+ export declare const is_cors_simple_content_type: (content_type: string | undefined) => boolean;
48
+ /**
49
+ * True when `Content-Type` is `application/json` (any parameters, such as
50
+ * `charset`, allowed).
51
+ *
52
+ * @param content_type - the request's `Content-Type` header, if any
53
+ */
54
+ export declare const is_json_content_type: (content_type: string | undefined) => boolean;
55
+ /**
56
+ * True when a `POST` route refuses this `Content-Type`: a JSON route anything
57
+ * but `application/json`, a `raw_body` route a CORS-safelisted type or none.
58
+ *
59
+ * @param content_type - the request's `Content-Type` header, if any
60
+ * @param raw_body - the route's `RouteSpec.raw_body`
61
+ */
62
+ export declare const is_post_content_type_refused: (content_type: string | undefined, raw_body: boolean) => boolean;
63
+ //# sourceMappingURL=simple_request.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"simple_request.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/http/simple_request.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AAqBH;;;;;;;;;GASG;AACH,eAAO,MAAM,2BAA2B,GAAI,cAAc,MAAM,GAAG,SAAS,KAAG,OAI9E,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,oBAAoB,GAAI,cAAc,MAAM,GAAG,SAAS,KAAG,OACmB,CAAC;AAE5F;;;;;;GAMG;AACH,eAAO,MAAM,4BAA4B,GACxC,cAAc,MAAM,GAAG,SAAS,EAChC,UAAU,OAAO,KACf,OACwF,CAAC"}
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Refusing CORS simple requests — the cross-origin POSTs a browser sends
3
+ * without asking first.
4
+ *
5
+ * A cross-origin `fetch` or form `POST` skips the CORS preflight when its
6
+ * `Content-Type` is absent or one of the three CORS-safelisted types
7
+ * (`text/plain`, `application/x-www-form-urlencoded`, `multipart/form-data`).
8
+ * The browser sends such a request as-is, with whatever cookies apply — only
9
+ * the response is withheld from the page. Any other content type makes the
10
+ * browser send an `OPTIONS` preflight first, and since fuz_app answers no
11
+ * preflight with CORS headers, the real request is never sent.
12
+ *
13
+ * The session cookie's `SameSite=Strict` doesn't close this on its own:
14
+ * same-site ignores the port, so a page on `http://localhost:8000` is
15
+ * same-site with a server on `http://localhost:4460` and its requests carry
16
+ * the cookie. The origin allowlist (`http/origin.ts`) refuses such a page
17
+ * unless a wildcard (`http://localhost:*`) admits it; this gate refuses it
18
+ * whatever the allowlist says.
19
+ *
20
+ * So every `POST` route carries a content-type check, run first by
21
+ * `apply_route_specs` (`is_post_content_type_refused`, 415
22
+ * `unsupported_content_type`): a JSON route takes only `application/json`,
23
+ * bodyless or not, and a `raw_body` route takes any type that isn't
24
+ * safelisted. Only `POST` needs it: `PUT`, `PATCH`, and `DELETE` are never
25
+ * simple methods, so the browser always preflights them.
26
+ *
27
+ * The check rests on the server answering no preflight. A consumer that adds
28
+ * CORS middleware granting cross-origin requests reopens what this closes.
29
+ *
30
+ * Twin of the Rust spine's `fuz_http::simple_request`: the same predicates,
31
+ * `is_json_content_type` checked in each spine JSON handler and
32
+ * `is_cors_simple_content_type` by the `simple_request_layer` consumers mount
33
+ * over routes with other bodies.
34
+ *
35
+ * @module
36
+ */
37
+ /** The content types a browser sends cross-origin without a preflight. */
38
+ const CORS_SAFELISTED_CONTENT_TYPES = new Set([
39
+ 'text/plain',
40
+ 'application/x-www-form-urlencoded',
41
+ 'multipart/form-data'
42
+ ]);
43
+ /** Any character outside visible ASCII and tab — what Rust's `HeaderValue::to_str` refuses. */
44
+ const NON_VISIBLE_ASCII = /[^\t\x20-\x7e]/;
45
+ /**
46
+ * The MIME essence (`type/subtype`, lowercased, parameters dropped) of a
47
+ * `Content-Type` value, or `null` when it holds anything but visible ASCII.
48
+ */
49
+ const to_content_type_essence = (content_type) => {
50
+ if (NON_VISIBLE_ASCII.test(content_type))
51
+ return null;
52
+ return (content_type.split(';')[0] ?? '').trim().toLowerCase();
53
+ };
54
+ /**
55
+ * True when a browser could have sent a `POST` with this `Content-Type`
56
+ * cross-origin without a preflight: none, or a CORS-safelisted one.
57
+ *
58
+ * Matches more than browsers do (a value they'd refuse to call safelisted —
59
+ * one with odd bytes, say — counts as safelisted here), so it only ever
60
+ * refuses too much, never too little.
61
+ *
62
+ * @param content_type - the request's `Content-Type` header, if any
63
+ */
64
+ export const is_cors_simple_content_type = (content_type) => {
65
+ if (content_type === undefined)
66
+ return true;
67
+ const essence = to_content_type_essence(content_type);
68
+ return essence === null || CORS_SAFELISTED_CONTENT_TYPES.has(essence);
69
+ };
70
+ /**
71
+ * True when `Content-Type` is `application/json` (any parameters, such as
72
+ * `charset`, allowed).
73
+ *
74
+ * @param content_type - the request's `Content-Type` header, if any
75
+ */
76
+ export const is_json_content_type = (content_type) => content_type !== undefined && to_content_type_essence(content_type) === 'application/json';
77
+ /**
78
+ * True when a `POST` route refuses this `Content-Type`: a JSON route anything
79
+ * but `application/json`, a `raw_body` route a CORS-safelisted type or none.
80
+ *
81
+ * @param content_type - the request's `Content-Type` header, if any
82
+ * @param raw_body - the route's `RouteSpec.raw_body`
83
+ */
84
+ export const is_post_content_type_refused = (content_type, raw_body) => raw_body ? is_cors_simple_content_type(content_type) : !is_json_content_type(content_type);
@@ -63,13 +63,13 @@ export interface BootstrapDisabledOptions {
63
63
  }
64
64
  export interface BootstrapSurfaceOnlyOptions {
65
65
  mode: 'surface_only';
66
- /** Route prefix for surface generation. Default `'/api/account'`. */
66
+ /** Route prefix for surface generation. Default `'/api/account'`; must sit under `/api/`. */
67
67
  route_prefix?: string;
68
68
  }
69
69
  export interface BootstrapLiveOptions {
70
70
  mode: 'live';
71
71
  token_path: string;
72
- /** Route prefix for bootstrap routes. Default `'/api/account'`. */
72
+ /** Route prefix for bootstrap routes. Default `'/api/account'`; must sit under `/api/`. */
73
73
  route_prefix?: string;
74
74
  /**
75
75
  * Called after successful bootstrap (account + session created).
@@ -1 +1 @@
1
- {"version":3,"file":"app_server.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/server/app_server.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"app_server.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/server/app_server.ts"],"names":[],"mappings":"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"}
@@ -333,7 +333,11 @@ const assemble_app_server = async (options, releases) => {
333
333
  bootstrap_ip_rate_limiter
334
334
  });
335
335
  const prefix = options.bootstrap.route_prefix ?? '/api/account';
336
- factory_routes.push(...prefix_route_specs(prefix, bootstrap_routes));
336
+ const prefixed = prefix_route_specs(prefix, bootstrap_routes);
337
+ for (const route of prefixed) {
338
+ assert_endpoint_in_auth_scope('bootstrap.route_prefix', route.path);
339
+ }
340
+ factory_routes.push(...prefixed);
337
341
  }
338
342
  // RPC endpoint auto-mount — resolve specs then append their routes so
339
343
  // surface generation and live dispatch share one source of truth.
@@ -383,6 +383,7 @@ Walks Zod schemas to generate valid values for adversarial/round-trip tests.
383
383
  - `detect_format(field_schema)` — reads `format` / `pattern` from the JSON Schema representation.
384
384
  - `generate_valid_value(field, field_schema)` — base-type switch producing a valid sample (UUIDs → nil UUID, strings → `'xxxxxxxxxx'`, numbers → `1`, objects → recurse, enums → first entry, etc.). For branded-string refinements, walks a fallback chain synthesized from the `pattern` string the JSON Schema representation exposes: fixed-length hex (`^[0-9a-f]{N}$` — blake3 / sha256 / md5 digests; `0`.repeat(N)), prefix-lengthed slug (`^<prefix>_[A-Za-z0-9_-]{N}$` — `ApiTokenId`-style ids; `<prefix>_` + `x`.repeat(N)), absolute path prefix, URL prefix. First candidate that `safeParse` accepts is used.
385
385
  - `resolve_valid_path(path, params_schema?)` — swaps `:param` for a value the params schema accepts. Prefers `test_<name>` wherever the field schema admits it (the param name keeps failure messages readable); otherwise a UUID param takes the nil UUID and anything else is synthesized from the schema, percent-encoded. A synthesized value containing `/` is refused (it would route elsewhere) and falls back to `test_<name>`.
386
+ - `to_route_content_type(route)` — the `Content-Type` a well-formed request to the route sends: `application/json`, or `application/octet-stream` for a `raw_body` route. Every `POST` route refuses anything else with 415 before its other gates, so every probe in the harnesses (auth, params and query fuzz, 404, data exposure, round trip) sends it, body or not.
386
387
  - `generate_valid_body(input_schema) => Record<string, unknown> | undefined` — builds a body satisfying the input schema. Throws with Zod `issues` if the generated body fails validation — surfaces broken generation logic with a descriptive error rather than a confusing 400 downstream.
387
388
 
388
389
  ### `integration_helpers.ts` — route lookup + body checks
@@ -1564,6 +1565,22 @@ in-process `auth/origin_parity.db.test.ts` + the cross-process
1564
1565
  Rust spine returned a plain-text body — now converged to the canonical TS
1565
1566
  `{error: "forbidden_origin"}` via `fuz_http::forbidden_origin_response()`.
1566
1567
 
1568
+ ### CORS simple-request parity — `cross_backend/simple_request.ts`
1569
+
1570
+ `describe_simple_request_cross_tests({setup_test, rpc_path?, login_path?, logout_path?})`
1571
+ — a JSON route refuses any `POST` that isn't `application/json` with a flat
1572
+ 415 `{error: 'unsupported_content_type'}` before reading a credential or the
1573
+ body: the CORS-safelisted types in any spelling (`text/plain`, `Text/Plain`,
1574
+ form-encoded, multipart) on RPC with a session, untyped RPC (a byte body, so
1575
+ `fetch` adds no type), `application/octet-stream` / `+json` / empty-typed RPC
1576
+ and login, form-encoded login, a forged `text/plain` logout with a session
1577
+ (which must leave the session valid), and an untyped logout with no session
1578
+ (415, not 401); a bodyless JSON logout reaches the auth check (401), and
1579
+ `application/json` with a charset is dispatched. The TS spine checks per route
1580
+ (`apply_route_specs`), the Rust spine in each JSON handler. Runs both legs:
1581
+ the in-process `auth/simple_request_parity.db.test.ts` and the cross-process
1582
+ `simple_request.cross.test.ts`.
1583
+
1567
1584
  ### Query-string shape parity — `cross_backend/query_shape.ts`
1568
1585
 
1569
1586
  `describe_query_shape_cross_tests({setup_test, capabilities, rpc_path?})` —
@@ -1 +1 @@
1
- {"version":3,"file":"admin_integration.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/admin_integration.ts"],"names":[],"mappings":"AAAA,OAAO,qBAAqB,CAAC;AAgC7B,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,2BAA2B,CAAC;AAChE,OAAO,EAAc,KAAK,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAW3E,OAAO,EAKN,KAAK,uBAAuB,EAC5B,MAAM,kBAAkB,CAAC;AAmB1B,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAC;AACzD,OAAO,KAAK,EAAE,SAAS,EAAe,MAAM,0BAA0B,CAAC;AAYvE;;GAEG;AACH,MAAM,WAAW,mCAAmC;IACnD;;;;OAIG;IACH,UAAU,EAAE,SAAS,CAAC;IACtB;;;;OAIG;IACH,cAAc,EAAE,cAAc,CAAC;IAC/B,uFAAuF;IACvF,eAAe,EAAE,cAAc,CAAC,MAAM,CAAC,CAAC;IACxC,4GAA4G;IAC5G,KAAK,EAAE,gBAAgB,CAAC;IACxB;;;OAGG;IACH,aAAa,EAAE,uBAAuB,CAAC;IACvC;;;;;OAKG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;CACtB;AAiBD;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,yCAAyC,GACrD,SAAS,mCAAmC,KAC1C,IAk5BF,CAAC"}
1
+ {"version":3,"file":"admin_integration.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/admin_integration.ts"],"names":[],"mappings":"AAAA,OAAO,qBAAqB,CAAC;AAgC7B,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,2BAA2B,CAAC;AAChE,OAAO,EAAc,KAAK,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAW3E,OAAO,EAKN,KAAK,uBAAuB,EAC5B,MAAM,kBAAkB,CAAC;AAmB1B,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAC;AACzD,OAAO,KAAK,EAAE,SAAS,EAAe,MAAM,0BAA0B,CAAC;AAavE;;GAEG;AACH,MAAM,WAAW,mCAAmC;IACnD;;;;OAIG;IACH,UAAU,EAAE,SAAS,CAAC;IACtB;;;;OAIG;IACH,cAAc,EAAE,cAAc,CAAC;IAC/B,uFAAuF;IACvF,eAAe,EAAE,cAAc,CAAC,MAAM,CAAC,CAAC;IACxC,4GAA4G;IAC5G,KAAK,EAAE,gBAAgB,CAAC;IACxB;;;OAGG;IACH,aAAa,EAAE,uBAAuB,CAAC;IACvC;;;;;OAKG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;CACtB;AAiBD;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,yCAAyC,GACrD,SAAS,mCAAmC,KAC1C,IAm5BF,CAAC"}
@@ -36,6 +36,7 @@ import { rpc_call_for_spec, require_rpc_endpoint_path, resolve_rpc_endpoints_for
36
36
  import { role_grant_offer_create_action_spec, role_grant_revoke_action_spec } from '../auth/role_grant_offer_action_specs.js';
37
37
  import { admin_account_list_action_spec, ADMIN_ACCOUNT_LIST_LIMIT_MAX, admin_session_list_action_spec, admin_session_revoke_all_action_spec, admin_token_revoke_all_action_spec, audit_log_list_action_spec, audit_log_role_grant_history_action_spec } from '../auth/admin_action_specs.js';
38
38
  import { account_token_create_action_spec, account_verify_action_spec } from '../auth/account_action_specs.js';
39
+ import { to_route_content_type } from './schema_generators.js';
39
40
  /**
40
41
  * How long a failure-audit read waits for the pool-routed row to land.
41
42
  */
@@ -595,7 +596,8 @@ export const describe_standard_admin_integration_tests = (options) => {
595
596
  headers: {
596
597
  host: 'localhost',
597
598
  origin: 'http://localhost:5173',
598
- cookie: `${cookie_name}=${user_two_cookie}`
599
+ cookie: `${cookie_name}=${user_two_cookie}`,
600
+ 'content-type': 'application/json'
599
601
  }
600
602
  });
601
603
  }
@@ -804,7 +806,7 @@ export const describe_standard_admin_integration_tests = (options) => {
804
806
  for (const route of admin_routes) {
805
807
  const res = await fixture.transport(route.path, {
806
808
  method: route.method,
807
- headers: { host: 'localhost' }
809
+ headers: { host: 'localhost', 'content-type': to_route_content_type(route) }
808
810
  });
809
811
  if (res.status === 401 || res.status === 403) {
810
812
  error_collector.record(route_specs, route.method, route.path, res.status);
@@ -1 +1 @@
1
- {"version":3,"file":"adversarial_404.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/adversarial_404.ts"],"names":[],"mappings":"AAAA,OAAO,qBAAqB,CAAC;AAoB7B,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,qBAAqB,CAAC;AAyBlE;;;;;;;;GAQG;AACH,eAAO,MAAM,wBAAwB,GAAI,SAAS,sBAAsB,KAAG,IA6E1E,CAAC"}
1
+ {"version":3,"file":"adversarial_404.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/adversarial_404.ts"],"names":[],"mappings":"AAAA,OAAO,qBAAqB,CAAC;AAoB7B,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,qBAAqB,CAAC;AA6BlE;;;;;;;;GAQG;AACH,eAAO,MAAM,wBAAwB,GAAI,SAAS,sBAAsB,KAAG,IA+E1E,CAAC"}
@@ -14,7 +14,7 @@ import { test, assert, describe } from 'vitest';
14
14
  import { z } from 'zod';
15
15
  import { is_null_schema } from '../http/schema_helpers.js';
16
16
  import { create_auth_test_apps, create_route_skip_filter, select_auth_app } from './auth_apps.js';
17
- import { resolve_valid_path, generate_valid_body } from './schema_generators.js';
17
+ import { resolve_valid_path, generate_valid_body, to_route_content_type } from './schema_generators.js';
18
18
  /**
19
19
  * Extract the error code from a 404 Zod schema for use in the stub handler.
20
20
  *
@@ -98,12 +98,14 @@ export const describe_adversarial_404 = (options) => {
98
98
  const route = surface.routes.find((r) => `${r.method} ${r.path}` === key);
99
99
  const app = select_auth_app(apps, route.auth);
100
100
  const url = resolve_valid_path(route.path, spec.params);
101
- const request_init = { method: route.method };
101
+ const request_init = {
102
+ method: route.method,
103
+ headers: { 'content-type': to_route_content_type(spec) }
104
+ };
102
105
  // Send valid body for routes with input
103
106
  if (!is_null_schema(spec.input)) {
104
107
  const body = generate_valid_body(spec.input);
105
108
  if (body !== undefined) {
106
- request_init.headers = { 'Content-Type': 'application/json' };
107
109
  request_init.body = JSON.stringify(body);
108
110
  }
109
111
  }
@@ -1 +1 @@
1
- {"version":3,"file":"adversarial_input.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/adversarial_input.ts"],"names":[],"mappings":"AAAA,OAAO,qBAAqB,CAAC;AAiB7B,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAUxB,OAAO,EAEN,0BAA0B,EAC1B,uBAAuB,EAGvB,MAAM,0BAA0B,CAAC;AAElC,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,qBAAqB,CAAC;AA8ClE,UAAU,aAAa;IACtB,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,OAAO,CAAC;IACd,cAAc,EAAE,OAAO,0BAA0B,GAAG,OAAO,uBAAuB,CAAC;CACnF;AAED,UAAU,cAAc;IACvB,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAC/B;AAED,UAAU,aAAa;IACtB,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAC9B;AAID;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,yBAAyB,GAAI,cAAc,CAAC,CAAC,OAAO,KAAG,KAAK,CAAC,aAAa,CAsMtF,CAAC;AAIF;;;;;;GAMG;AACH,eAAO,MAAM,0BAA0B,GAAI,eAAe,CAAC,CAAC,SAAS,KAAG,KAAK,CAAC,cAAc,CA+B3F,CAAC;AAIF;;;;;;GAMG;AACH,eAAO,MAAM,yBAAyB,GAAI,cAAc,CAAC,CAAC,SAAS,KAAG,KAAK,CAAC,aAAa,CA4CxF,CAAC;AAqBF;;;;;;GAMG;AACH,eAAO,MAAM,0BAA0B,GAAI,SAAS,sBAAsB,KAAG,IAmN5E,CAAC"}
1
+ {"version":3,"file":"adversarial_input.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/adversarial_input.ts"],"names":[],"mappings":"AAAA,OAAO,qBAAqB,CAAC;AAiB7B,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAUxB,OAAO,EAEN,0BAA0B,EAC1B,uBAAuB,EAGvB,MAAM,0BAA0B,CAAC;AAElC,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,qBAAqB,CAAC;AAmDlE,UAAU,aAAa;IACtB,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,OAAO,CAAC;IACd,cAAc,EAAE,OAAO,0BAA0B,GAAG,OAAO,uBAAuB,CAAC;CACnF;AAED,UAAU,cAAc;IACvB,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAC/B;AAED,UAAU,aAAa;IACtB,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAC9B;AAID;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,yBAAyB,GAAI,cAAc,CAAC,CAAC,OAAO,KAAG,KAAK,CAAC,aAAa,CAsMtF,CAAC;AAIF;;;;;;GAMG;AACH,eAAO,MAAM,0BAA0B,GAAI,eAAe,CAAC,CAAC,SAAS,KAAG,KAAK,CAAC,cAAc,CA+B3F,CAAC;AAIF;;;;;;GAMG;AACH,eAAO,MAAM,yBAAyB,GAAI,cAAc,CAAC,CAAC,SAAS,KAAG,KAAK,CAAC,aAAa,CA4CxF,CAAC;AAqBF;;;;;;GAMG;AACH,eAAO,MAAM,0BAA0B,GAAI,SAAS,sBAAsB,KAAG,IAyN5E,CAAC"}
@@ -19,7 +19,7 @@ import { is_null_schema } from '../http/schema_helpers.js';
19
19
  import { filter_routes_with_input, filter_routes_with_params, filter_routes_with_query } from '../http/surface_query.js';
20
20
  import { ValidationError, ERROR_INVALID_REQUEST_BODY, ERROR_INVALID_JSON_BODY, ERROR_INVALID_ROUTE_PARAMS, ERROR_INVALID_QUERY_PARAMS } from '../http/error_schemas.js';
21
21
  import { create_auth_test_apps, create_route_skip_filter, select_auth_app } from './auth_apps.js';
22
- import { detect_format, generate_valid_value, resolve_valid_path } from './schema_generators.js';
22
+ import { detect_format, generate_valid_value, resolve_valid_path, to_route_content_type } from './schema_generators.js';
23
23
  // --- Payload generation ---
24
24
  /** One wrong-type value per base type — one representative is sufficient. */
25
25
  const wrong_type_for = (base_type) => {
@@ -466,7 +466,10 @@ export const describe_adversarial_input = (options) => {
466
466
  for (const tc of test_cases) {
467
467
  test(tc.label, async () => {
468
468
  const url = build_fuzz_url(route.path, tc.params);
469
- const res = await app.request(url, { method: route.method });
469
+ const res = await app.request(url, {
470
+ method: route.method,
471
+ headers: { 'content-type': to_route_content_type(route) }
472
+ });
470
473
  assert.strictEqual(res.status, 400, `Expected 400 for ${key} [${tc.label}], got ${res.status}`);
471
474
  const body = await res.json();
472
475
  assert.strictEqual(body.error, ERROR_INVALID_ROUTE_PARAMS, `Expected ${ERROR_INVALID_ROUTE_PARAMS} for ${key} [${tc.label}], got: ${body.error}`);
@@ -504,7 +507,10 @@ export const describe_adversarial_input = (options) => {
504
507
  for (const tc of test_cases) {
505
508
  test(tc.label, async () => {
506
509
  const url = build_query_url(base_url, tc.query);
507
- const res = await app.request(url, { method: route.method });
510
+ const res = await app.request(url, {
511
+ method: route.method,
512
+ headers: { 'content-type': to_route_content_type(route) }
513
+ });
508
514
  assert.strictEqual(res.status, 400, `Expected 400 for ${key} [${tc.label}], got ${res.status}`);
509
515
  const body = await res.json();
510
516
  assert.strictEqual(body.error, ERROR_INVALID_QUERY_PARAMS, `Expected ${ERROR_INVALID_QUERY_PARAMS} for ${key} [${tc.label}], got: ${body.error}`);
@@ -1 +1 @@
1
- {"version":3,"file":"attack_surface.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/attack_surface.ts"],"names":[],"mappings":"AAAA,OAAO,qBAAqB,CAAC;AAoB7B,OAAO,EAQN,KAAK,4BAA4B,EACjC,KAAK,2BAA2B,EAChC,MAAM,yBAAyB,CAAC;AAejC,OAAO,EAA6B,KAAK,cAAc,EAAE,MAAM,oBAAoB,CAAC;AAsDpF,oFAAoF;AACpF,MAAM,WAAW,sBAAsB;IACtC,+EAA+E;IAC/E,KAAK,EAAE,MAAM,cAAc,CAAC;IAC5B,yDAAyD;IACzD,KAAK,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;IACrB;;;;;;OAMG;IACH,WAAW,CAAC,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;CAC5B;AAED;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,yBAAyB,GAAI,SAAS,sBAAsB,KAAG,IA2J3E,CAAC;AAIF;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,uCAAuC,GACnD,UAAU,2BAA2B,GAAG,IAAI,GAAG,SAAS,KACtD,2BAA2B,GAAG,IAWhC,CAAC;AAEF,0DAA0D;AAC1D,MAAM,WAAW,4BAA4B;IAC5C,+EAA+E;IAC/E,KAAK,EAAE,MAAM,cAAc,CAAC;IAC5B,yDAAyD;IACzD,aAAa,EAAE,MAAM,CAAC;IACtB,iFAAiF;IACjF,sBAAsB,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;IACtC,iIAAiI;IACjI,uBAAuB,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;IACvC,yDAAyD;IACzD,KAAK,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;IACrB,qEAAqE;IACrE,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,iEAAiE;IACjE,eAAe,CAAC,EAAE,4BAA4B,CAAC;IAC/C;;;;;;;;;;;OAWG;IACH,sBAAsB,CAAC,EAAE,2BAA2B,GAAG,IAAI,CAAC;IAC5D;;;;;;OAMG;IACH,WAAW,CAAC,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;CAC5B;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,eAAO,MAAM,sCAAsC,GAClD,SAAS,4BAA4B,KACnC,IA4EF,CAAC"}
1
+ {"version":3,"file":"attack_surface.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/attack_surface.ts"],"names":[],"mappings":"AAAA,OAAO,qBAAqB,CAAC;AAoB7B,OAAO,EAQN,KAAK,4BAA4B,EACjC,KAAK,2BAA2B,EAChC,MAAM,yBAAyB,CAAC;AAejC,OAAO,EAA6B,KAAK,cAAc,EAAE,MAAM,oBAAoB,CAAC;AAsDpF,oFAAoF;AACpF,MAAM,WAAW,sBAAsB;IACtC,+EAA+E;IAC/E,KAAK,EAAE,MAAM,cAAc,CAAC;IAC5B,yDAAyD;IACzD,KAAK,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;IACrB;;;;;;OAMG;IACH,WAAW,CAAC,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;CAC5B;AAED;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,yBAAyB,GAAI,SAAS,sBAAsB,KAAG,IA6J3E,CAAC;AAIF;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,uCAAuC,GACnD,UAAU,2BAA2B,GAAG,IAAI,GAAG,SAAS,KACtD,2BAA2B,GAAG,IAWhC,CAAC;AAEF,0DAA0D;AAC1D,MAAM,WAAW,4BAA4B;IAC5C,+EAA+E;IAC/E,KAAK,EAAE,MAAM,cAAc,CAAC;IAC5B,yDAAyD;IACzD,aAAa,EAAE,MAAM,CAAC;IACtB,iFAAiF;IACjF,sBAAsB,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;IACtC,iIAAiI;IACjI,uBAAuB,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;IACvC,yDAAyD;IACzD,KAAK,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;IACrB,qEAAqE;IACrE,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,iEAAiE;IACjE,eAAe,CAAC,EAAE,4BAA4B,CAAC;IAC/C;;;;;;;;;;;OAWG;IACH,sBAAsB,CAAC,EAAE,2BAA2B,GAAG,IAAI,CAAC;IAC5D;;;;;;OAMG;IACH,WAAW,CAAC,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;CAC5B;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,eAAO,MAAM,sCAAsC,GAClD,SAAS,4BAA4B,KACnC,IA4EF,CAAC"}
@@ -19,7 +19,7 @@ import { assert_surface_invariants, assert_rpc_ws_surface_invariants, assert_sur
19
19
  import { describe_adversarial_input } from './adversarial_input.js';
20
20
  import { describe_adversarial_404 } from './adversarial_404.js';
21
21
  import { create_auth_test_apps, create_route_skip_filter, select_auth_app } from './auth_apps.js';
22
- import { resolve_valid_path } from './schema_generators.js';
22
+ import { resolve_valid_path, to_route_content_type } from './schema_generators.js';
23
23
  import { assert_surface_matches_snapshot, assert_surface_deterministic, assert_only_expected_public_routes, assert_full_middleware_stack, assert_error_schema_valid } from './assertions.js';
24
24
  import { merge_error_schemas } from '../http/schema_helpers.js';
25
25
  import { collect_middleware_errors } from '../http/surface.js';
@@ -91,13 +91,17 @@ export const describe_adversarial_auth = (options) => {
91
91
  const spec_lookup = build_route_spec_lookup(route_specs);
92
92
  const test_path = (route) => resolve_valid_path(route.path, spec_lookup.get(`${route.method} ${route.path}`)?.params);
93
93
  const apps = create_auth_test_apps(route_specs, roles);
94
+ // every probe carries the content type a real client sends, so the
95
+ // per-route content-type check (415) never answers ahead of the auth gate
96
+ const probe_init = (route) => ({
97
+ method: route.method,
98
+ headers: { 'content-type': to_route_content_type(route) }
99
+ });
94
100
  describe('adversarial HTTP auth enforcement', () => {
95
101
  describe('unauthenticated → 401', () => {
96
102
  for (const route of protected_routes) {
97
103
  test(`${route.method} ${route.path}`, async () => {
98
- const res = await apps.public.request(test_path(route), {
99
- method: route.method
100
- });
104
+ const res = await apps.public.request(test_path(route), probe_init(route));
101
105
  assert.strictEqual(res.status, 401, `${route.method} ${route.path}`);
102
106
  const body = await res.json();
103
107
  assert.strictEqual(body.error, ERROR_AUTHENTICATION_REQUIRED);
@@ -126,9 +130,7 @@ export const describe_adversarial_auth = (options) => {
126
130
  const app = apps.by_role.get(wrong_role);
127
131
  if (!app)
128
132
  throw new Error(`No test app for role '${wrong_role}'`);
129
- const res = await app.request(test_path(route), {
130
- method: route.method
131
- });
133
+ const res = await app.request(test_path(route), probe_init(route));
132
134
  assert.strictEqual(res.status, 403, `${route.method} ${route.path}`);
133
135
  const body = await res.json();
134
136
  assert.strictEqual(body.error, ERROR_INSUFFICIENT_PERMISSIONS);
@@ -141,9 +143,7 @@ export const describe_adversarial_auth = (options) => {
141
143
  describe('authenticated without role → 403', () => {
142
144
  for (const route of role_only_routes) {
143
145
  test(`${route.method} ${route.path}`, async () => {
144
- const res = await apps.authed.request(test_path(route), {
145
- method: route.method
146
- });
146
+ const res = await apps.authed.request(test_path(route), probe_init(route));
147
147
  assert.strictEqual(res.status, 403, `${route.method} ${route.path}`);
148
148
  const body = await res.json();
149
149
  assert.strictEqual(body.error, ERROR_INSUFFICIENT_PERMISSIONS);
@@ -175,9 +175,9 @@ export const describe_adversarial_auth = (options) => {
175
175
  continue;
176
176
  }
177
177
  test(`${route.method} ${route.path} (${refused})`, async () => {
178
- const res = await apps.by_credential_type.get(refused).request(test_path(route), {
179
- method: route.method
180
- });
178
+ const res = await apps.by_credential_type
179
+ .get(refused)
180
+ .request(test_path(route), probe_init(route));
181
181
  assert.strictEqual(res.status, 403, `${route.method} ${route.path}`);
182
182
  const body = await res.json();
183
183
  assert.strictEqual(body.error, ERROR_CREDENTIAL_TYPE_REQUIRED);
@@ -190,12 +190,11 @@ export const describe_adversarial_auth = (options) => {
190
190
  describe('correct auth passes guard', () => {
191
191
  for (const route of protected_routes) {
192
192
  test(`${route.method} ${route.path}`, async () => {
193
- const res = await select_auth_app(apps, route.auth).request(test_path(route), {
194
- method: route.method
195
- });
193
+ const res = await select_auth_app(apps, route.auth).request(test_path(route), probe_init(route));
196
194
  // handler may error (500) or return 404 (stub deps) — that's fine, we only verify auth passed
197
195
  assert.notStrictEqual(res.status, 401, 'should not be 401 (auth rejected)');
198
196
  assert.notStrictEqual(res.status, 403, 'should not be 403 (role rejected)');
197
+ assert.notStrictEqual(res.status, 415, 'should not be 415 (content type refused)');
199
198
  // handler-level 404 (resource not found with stub deps) is fine —
200
199
  // only reject router-level 404 (route not registered)
201
200
  if (res.status === 404) {
@@ -155,7 +155,7 @@ export const describe_audit_completeness_tests = (options) => {
155
155
  assert.ok(logout_route, 'Expected POST /logout route');
156
156
  const res = await fixture.transport(logout_route.path, {
157
157
  method: 'POST',
158
- headers: fixture.create_session_headers()
158
+ headers: fixture.create_session_headers({ 'content-type': 'application/json' })
159
159
  });
160
160
  assert.strictEqual(res.status, 200);
161
161
  const events = await list_audit_events({ request: fixture.transport }, rpc_path, observer);
@@ -0,0 +1,11 @@
1
+ import '../assert_dev_env.js';
2
+ import type { RpcPathCrossSuiteOptions } from './setup.js';
3
+ /** Options for the simple-request parity suite. */
4
+ export interface SimpleRequestCrossTestOptions extends RpcPathCrossSuiteOptions {
5
+ /** REST login route path. Default `/api/account/login` (the spine convention). */
6
+ readonly login_path?: string;
7
+ /** REST logout route path. Default `/api/account/logout` (the spine convention). */
8
+ readonly logout_path?: string;
9
+ }
10
+ export declare const describe_simple_request_cross_tests: (options: SimpleRequestCrossTestOptions) => void;
11
+ //# sourceMappingURL=simple_request.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"simple_request.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/simple_request.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AA6B9B,OAAO,KAAK,EAAE,wBAAwB,EAAe,MAAM,YAAY,CAAC;AAGxE,mDAAmD;AACnD,MAAM,WAAW,6BAA8B,SAAQ,wBAAwB;IAC9E,kFAAkF;IAClF,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;IAC7B,oFAAoF;IACpF,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;CAC9B;AAaD,eAAO,MAAM,mCAAmC,GAC/C,SAAS,6BAA6B,KACpC,IAuHF,CAAC"}
@@ -0,0 +1,142 @@
1
+ import '../assert_dev_env.js';
2
+ /**
3
+ * Cross-backend parity suite for refusing CORS simple requests.
4
+ *
5
+ * A `POST` with no `Content-Type`, or a `text/plain` / form / multipart one,
6
+ * is what a browser sends cross-origin without a preflight — carrying the
7
+ * session cookie when the page is same-site, which any other `localhost`
8
+ * port is. Every spine's JSON routes require `application/json` and refuse
9
+ * anything else with a flat `415` `{error: 'unsupported_content_type'}`
10
+ * before reading a credential or the body: the TS spine per route
11
+ * (`apply_route_specs`, every `POST` that isn't `raw_body`), the Rust spine
12
+ * in each JSON handler. The cases carry an allowed `Origin` and, where it
13
+ * matters, the keeper's session — the exact shape of a same-site page's
14
+ * forged request, which the origin allowlist alone admits when it's a
15
+ * wildcard — and a refused request must leave that session untouched.
16
+ *
17
+ * Runs both legs via the shared `setup_test` protocol: in-process
18
+ * (`auth/simple_request_parity.db.test.ts`) and cross-process
19
+ * (`cross_backend/simple_request.cross.test.ts`). Every spine has the gate,
20
+ * so the suite is ungated.
21
+ *
22
+ * @module
23
+ */
24
+ import { describe, test, assert } from 'vitest';
25
+ import { account_verify_action_spec } from '../../auth/account_action_specs.js';
26
+ import { ERROR_UNSUPPORTED_CONTENT_TYPE } from '../../http/error_schemas.js';
27
+ import { SPINE_RPC_PATH } from './spine_surface_constants.js';
28
+ /** Build the JSON-RPC envelope body for a nullary `account_verify` call. */
29
+ const verify_envelope = (id) => JSON.stringify({ jsonrpc: '2.0', method: account_verify_action_spec.method, id });
30
+ /** Assert a flat `415` content-type refusal. */
31
+ const assert_refused = async (res, label) => {
32
+ assert.strictEqual(res.status, 415, `${label} must be refused with 415`);
33
+ const body = (await res.json().catch(() => undefined));
34
+ assert.strictEqual(body?.error, ERROR_UNSUPPORTED_CONTENT_TYPE, label);
35
+ };
36
+ export const describe_simple_request_cross_tests = (options) => {
37
+ const { setup_test } = options;
38
+ const rpc_path = options.rpc_path ?? SPINE_RPC_PATH;
39
+ const login_path = options.login_path ?? '/api/account/login';
40
+ const logout_path = options.logout_path ?? '/api/account/logout';
41
+ /** POST a JSON `account_verify` with the fixture's session — 200 while it's valid. */
42
+ const verify_session = async (fixture, id) => {
43
+ const res = await fixture.transport(rpc_path, {
44
+ method: 'POST',
45
+ headers: { ...fixture.create_session_headers(), 'content-type': 'application/json' },
46
+ body: verify_envelope(id)
47
+ });
48
+ return res.status;
49
+ };
50
+ describe('CORS simple-request refusal parity', () => {
51
+ test('a CORS-safelisted RPC POST with a session → 415, in any spelling', async () => {
52
+ const fixture = await setup_test();
53
+ for (const content_type of [
54
+ 'text/plain;charset=UTF-8',
55
+ 'Text/Plain',
56
+ 'application/x-www-form-urlencoded',
57
+ 'multipart/form-data; boundary=x'
58
+ ]) {
59
+ const res = await fixture.transport(rpc_path, {
60
+ method: 'POST',
61
+ headers: { ...fixture.create_session_headers(), 'content-type': content_type },
62
+ body: verify_envelope('simple-safelisted')
63
+ });
64
+ await assert_refused(res, `a ${content_type} RPC POST`);
65
+ }
66
+ });
67
+ test('RPC POST with no Content-Type → 415', async () => {
68
+ const fixture = await setup_test();
69
+ // a byte body, so `fetch` adds no `Content-Type` of its own (a string
70
+ // body would get `text/plain;charset=UTF-8`)
71
+ const res = await fixture.transport(rpc_path, {
72
+ method: 'POST',
73
+ headers: fixture.create_session_headers(),
74
+ body: new TextEncoder().encode(verify_envelope('simple-untyped'))
75
+ });
76
+ await assert_refused(res, 'an untyped RPC POST');
77
+ });
78
+ test('a JSON route refuses any type but application/json', async () => {
79
+ const fixture = await setup_test();
80
+ for (const content_type of ['application/octet-stream', 'application/vnd.api+json', '']) {
81
+ const res = await fixture.transport(rpc_path, {
82
+ method: 'POST',
83
+ headers: { ...fixture.create_session_headers(), 'content-type': content_type },
84
+ body: verify_envelope('simple-not-json')
85
+ });
86
+ await assert_refused(res, `an RPC POST typed '${content_type}'`);
87
+ const login = await fixture.fresh_transport()(login_path, {
88
+ method: 'POST',
89
+ headers: { 'content-type': content_type },
90
+ body: JSON.stringify({ username: 'keeper', password: 'whatever' })
91
+ });
92
+ await assert_refused(login, `a login typed '${content_type}'`);
93
+ }
94
+ });
95
+ test('form-encoded login → 415', async () => {
96
+ const fixture = await setup_test();
97
+ const res = await fixture.fresh_transport()(login_path, {
98
+ method: 'POST',
99
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
100
+ body: 'username=keeper&password=whatever'
101
+ });
102
+ await assert_refused(res, 'a form-encoded login');
103
+ });
104
+ test('a forged text/plain logout with a session → 415, and the session survives', async () => {
105
+ const fixture = await setup_test();
106
+ const res = await fixture.transport(logout_path, {
107
+ method: 'POST',
108
+ headers: { ...fixture.create_session_headers(), 'content-type': 'text/plain' }
109
+ });
110
+ await assert_refused(res, 'a text/plain logout');
111
+ assert.strictEqual(await verify_session(fixture, 'after-forged-logout'), 200, 'the refused logout must not end the session');
112
+ });
113
+ test('an untyped logout with no session → 415, before the auth check', async () => {
114
+ const fixture = await setup_test();
115
+ const res = await fixture.fresh_transport()(logout_path, {
116
+ method: 'POST',
117
+ headers: { 'content-type': 'application/octet-stream' }
118
+ });
119
+ await assert_refused(res, 'an octet-stream logout');
120
+ });
121
+ test('JSON logout with no body and no session → 401, not 415', async () => {
122
+ const fixture = await setup_test();
123
+ const res = await fixture.fresh_transport()(logout_path, {
124
+ method: 'POST',
125
+ headers: { 'content-type': 'application/json' }
126
+ });
127
+ assert.strictEqual(res.status, 401, 'a bodyless JSON logout must reach the auth check');
128
+ });
129
+ test('application/json with a charset → dispatched', async () => {
130
+ const fixture = await setup_test();
131
+ const res = await fixture.transport(rpc_path, {
132
+ method: 'POST',
133
+ headers: {
134
+ ...fixture.create_session_headers(),
135
+ 'content-type': 'application/json; charset=utf-8'
136
+ },
137
+ body: verify_envelope('json-charset')
138
+ });
139
+ assert.strictEqual(res.status, 200, 'a JSON RPC POST must be dispatched');
140
+ });
141
+ });
142
+ };
@@ -98,6 +98,12 @@ export interface StandardCrossProcessTestOptions {
98
98
  * / `info/refs`) which stream git protocol bytes.
99
99
  */
100
100
  round_trip_skip_routes?: Array<string>;
101
+ /**
102
+ * Forwarded to `describe_data_exposure_tests` as `skip_routes`
103
+ * (`'METHOD /path'` keys), as the in-process bundle's consumers pass it
104
+ * directly.
105
+ */
106
+ data_exposure_skip_routes?: Array<string>;
101
107
  /**
102
108
  * Forwarded to `describe_rpc_round_trip_tests` as `success_fixtures`
103
109
  * (method name → async params factory). Drives a populated **success**