@fuzdev/fuz_app 0.89.0 → 0.91.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/auth/CLAUDE.md +7 -0
- package/dist/auth/account_action_specs.d.ts +1 -1
- package/dist/auth/account_route_schema.d.ts +2 -2
- package/dist/auth/account_schema.d.ts +3 -3
- package/dist/auth/admin_action_specs.d.ts +8 -8
- package/dist/auth/all_cell_actions.d.ts +35 -0
- package/dist/auth/all_cell_actions.d.ts.map +1 -0
- package/dist/auth/all_cell_actions.js +32 -0
- package/dist/auth/audit_log_schema.d.ts +1 -1
- package/dist/auth/invite_schema.d.ts +2 -2
- package/dist/auth/signup_route_schema.d.ts +2 -2
- package/dist/auth/signup_route_schema.d.ts.map +1 -1
- package/dist/auth/signup_route_schema.js +2 -2
- package/dist/primitive_schemas.d.ts +27 -1
- package/dist/primitive_schemas.d.ts.map +1 -1
- package/dist/primitive_schemas.js +55 -2
- package/dist/server/env.d.ts +1 -1
- package/dist/server/env.d.ts.map +1 -1
- package/dist/server/env.js +15 -3
- package/dist/testing/CLAUDE.md +135 -10
- package/dist/testing/app_server.d.ts +16 -5
- package/dist/testing/app_server.d.ts.map +1 -1
- package/dist/testing/app_server.js +3 -1
- package/dist/testing/cross_backend/action_manifest.d.ts +113 -0
- package/dist/testing/cross_backend/action_manifest.d.ts.map +1 -0
- package/dist/testing/cross_backend/action_manifest.js +89 -0
- package/dist/testing/cross_backend/action_manifest_parity.d.ts +67 -0
- package/dist/testing/cross_backend/action_manifest_parity.d.ts.map +1 -0
- package/dist/testing/cross_backend/action_manifest_parity.js +88 -0
- package/dist/testing/cross_backend/capabilities.d.ts +47 -30
- package/dist/testing/cross_backend/capabilities.d.ts.map +1 -1
- package/dist/testing/cross_backend/capabilities.js +19 -4
- package/dist/testing/cross_backend/default_backend_configs.d.ts +31 -15
- package/dist/testing/cross_backend/default_backend_configs.d.ts.map +1 -1
- package/dist/testing/cross_backend/default_backend_configs.js +30 -14
- package/dist/testing/cross_backend/default_spine_surface.d.ts.map +1 -1
- package/dist/testing/cross_backend/default_spine_surface.js +6 -0
- package/dist/testing/cross_backend/full_spine_mount.d.ts +81 -0
- package/dist/testing/cross_backend/full_spine_mount.d.ts.map +1 -0
- package/dist/testing/cross_backend/full_spine_mount.js +49 -0
- package/dist/testing/cross_backend/identity_parity.d.ts +13 -0
- package/dist/testing/cross_backend/identity_parity.d.ts.map +1 -0
- package/dist/testing/cross_backend/identity_parity.js +465 -0
- package/dist/testing/cross_backend/method_coverage.d.ts +71 -0
- package/dist/testing/cross_backend/method_coverage.d.ts.map +1 -0
- package/dist/testing/cross_backend/method_coverage.js +89 -0
- package/dist/testing/cross_backend/setup.d.ts +21 -0
- package/dist/testing/cross_backend/setup.d.ts.map +1 -1
- package/dist/testing/cross_backend/setup.js +33 -1
- package/dist/testing/cross_backend/testing_backdoor.d.ts.map +1 -1
- package/dist/testing/cross_backend/testing_backdoor.js +9 -5
- package/dist/testing/cross_backend/testing_reset_actions.d.ts +76 -2
- package/dist/testing/cross_backend/testing_reset_actions.d.ts.map +1 -1
- package/dist/testing/cross_backend/testing_reset_actions.js +63 -2
- package/dist/testing/schema_generators.d.ts.map +1 -1
- package/dist/testing/schema_generators.js +7 -0
- package/package.json +2 -2
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
import '../assert_dev_env.js';
|
|
2
|
+
/**
|
|
3
|
+
* Cross-backend identity-primitive parity for fuz_app's own spine over real
|
|
4
|
+
* HTTP — the `primitive_schemas` twins (`Username`, `UsernameProvided`,
|
|
5
|
+
* `Email`) and the login/signup input handling that enforces them, pinned
|
|
6
|
+
* facet-by-facet so a TS↔Rust divergence in any one surfaces as a failure:
|
|
7
|
+
* how usernames are **canonicalized** on the login lookup, the **ASCII-only
|
|
8
|
+
* creation invariant** that bounds what can ever be stored, and the **email
|
|
9
|
+
* format** rule applied at signup.
|
|
10
|
+
*
|
|
11
|
+
* `UsernameProvided` (login/lookup) canonicalizes the submitted username via
|
|
12
|
+
* `.trim().toLowerCase()`, and the DB matches case-insensitively
|
|
13
|
+
* (`LOWER(username) = LOWER($1)`); `Username` (creation) lowercases at store
|
|
14
|
+
* **and** restricts to ASCII via `^[a-zA-Z][0-9a-zA-Z_-]*[0-9a-zA-Z]$`. The
|
|
15
|
+
* Rust spine must produce the same canonical form, the same case-insensitive
|
|
16
|
+
* lookup, **and** the same ASCII-only rejection — or a username that logs in on
|
|
17
|
+
* TS silently 401s on Rust, or a non-ASCII username storable on one backend
|
|
18
|
+
* reopens the homograph-collision surface. The spec-derived round-trip +
|
|
19
|
+
* conformance suites never vary username casing or charset, so this corner was
|
|
20
|
+
* unpinned.
|
|
21
|
+
*
|
|
22
|
+
* **Canonicalization (login lookup):**
|
|
23
|
+
*
|
|
24
|
+
* - **case-insensitive login** — an account created `Mixed_Case` logs in via an
|
|
25
|
+
* all-uppercase submission (proves the *lookup* folds case, not merely that
|
|
26
|
+
* the stored form was lowercased).
|
|
27
|
+
* - **whitespace-trim login** — the same shape logs in with surrounding
|
|
28
|
+
* whitespace (proves `.trim()` on the lookup path).
|
|
29
|
+
* - **no Unicode case-fold collision (negative)** — a Turkish-`İ` (U+0130)
|
|
30
|
+
* variant of an existing ASCII username must NOT match. Both JS
|
|
31
|
+
* `.toLowerCase()` and Rust `str::to_lowercase()` map `İ` → `i` + U+0307
|
|
32
|
+
* (combining dot above), never plain `i`, so the cased homograph stays a
|
|
33
|
+
* distinct, non-existent username → 401 on every backend.
|
|
34
|
+
*
|
|
35
|
+
* **Username-or-email login lookup:** the login identifier resolves against
|
|
36
|
+
* **username or email** — TS `query_account_by_username_or_email`, Rust the
|
|
37
|
+
* converged `query_account_with_password_hash` OR-lookup (this was the
|
|
38
|
+
* divergence the suite closed: the Rust spine previously matched username
|
|
39
|
+
* only). An account created with an email logs in via that email,
|
|
40
|
+
* case-insensitively (`Email` stores the original case; folding rides the
|
|
41
|
+
* `LOWER(email) = LOWER($1)` lookup), and username login keeps working when an
|
|
42
|
+
* email is present. A non-existent email → 401.
|
|
43
|
+
*
|
|
44
|
+
* **Login input validation:** malformed login input → 400 `invalid_request_body`
|
|
45
|
+
* on every spine — whitespace-only username (empty after trim), over-long
|
|
46
|
+
* username (> 255), empty password, and an unknown body key (strict object).
|
|
47
|
+
* TS runs the full `LoginInput` Zod schema; the Rust spine enforces the same
|
|
48
|
+
* shape in `account_login` (the convergence the suite closed: the Rust spine
|
|
49
|
+
* previously let three of these fall through to a lookup-miss 401, and TS let a
|
|
50
|
+
* whitespace-only identifier through to a 401). Asserted via the error reason,
|
|
51
|
+
* not just the status, so a same-status-wrong-body backend still fails.
|
|
52
|
+
*
|
|
53
|
+
* **ASCII-only creation invariant:** a non-ASCII username is rejected at signup
|
|
54
|
+
* input validation → 400 on every backend, so no Unicode username is ever
|
|
55
|
+
* stored — the precondition the login no-collision case relies on. (ASCII-only
|
|
56
|
+
* is the intended invariant for both spines, not an accident of the TS regex.)
|
|
57
|
+
*
|
|
58
|
+
* **Length + format creation parity:** the full `Username` shape —
|
|
59
|
+
* `[USERNAME_LENGTH_MIN, USERNAME_LENGTH_MAX]` = `[3, 39]` and the regex
|
|
60
|
+
* `^[a-zA-Z][0-9a-zA-Z_-]*[0-9a-zA-Z]$` — pinned across both backends. The TS
|
|
61
|
+
* Zod `.min()/.max()/.regex()` and the Rust hand-rolled byte-scan
|
|
62
|
+
* (`is_valid_username_for_creation`) are independent reimplementations of the
|
|
63
|
+
* same rule, so each length boundary is tested just-outside (→ 400) and
|
|
64
|
+
* just-inside (→ 403 no-matching-invite, the settle for a valid username on a
|
|
65
|
+
* spine with `open_signup` at its `false` default), and each format violation
|
|
66
|
+
* (leading non-letter, trailing punctuation, embedded disallowed char) → 400,
|
|
67
|
+
* with mid-string `_`/`-` accepted siblings as the don't-over-reject control.
|
|
68
|
+
* Signup is also strict-object on both spines (TS `z.strictObject`, Rust
|
|
69
|
+
* `#[serde(deny_unknown_fields)]`), so an unknown signup body key → 400.
|
|
70
|
+
*
|
|
71
|
+
* **Email format (creation):** the optional signup `email` is validated to a
|
|
72
|
+
* loose `local@domain.tld` shape on both spines — TS `Email`
|
|
73
|
+
* (`^[^\s@]+@[^\s@]+\.[^\s@]+$` plus a 254-byte (RFC 5321 octet) length bound; whitespace `White_Space ∪ {U+FEFF}`), Rust the
|
|
74
|
+
* hand-rolled `is_valid_email`. A malformed email → 400, a well-formed one →
|
|
75
|
+
* 403 no-matching-invite (the same accept/reject settle the username tables
|
|
76
|
+
* use). The accepted siblings include the single-char-TLD `a@b.c` that Zod's
|
|
77
|
+
* `z.email()` would reject — the deliberately-looser rule both spines now
|
|
78
|
+
* share. This closed a real divergence: the Rust spine previously
|
|
79
|
+
* length-checked the email only, accepting any non-empty string TS rejected.
|
|
80
|
+
*
|
|
81
|
+
* Both surfaces are flat REST on every spine, so this is an imperative suite
|
|
82
|
+
* (not a `conformance_table` row) and ungated. Runs both legs via the shared
|
|
83
|
+
* `{setup_test}` protocol: the in-process leg
|
|
84
|
+
* (`cross_backend/identity_parity.db.test.ts`, plain `gro test`) and the
|
|
85
|
+
* cross-process leg (`cross_backend/identity_parity.cross.test.ts`, the TS spine
|
|
86
|
+
* binaries + Rust `testing_spine_stub` over real HTTP).
|
|
87
|
+
*
|
|
88
|
+
* `$lib`-free by contract (relative specifiers only).
|
|
89
|
+
*
|
|
90
|
+
* @module
|
|
91
|
+
*/
|
|
92
|
+
import { describe, test, assert } from 'vitest';
|
|
93
|
+
import { ERROR_INVALID_CREDENTIALS, ERROR_INVALID_REQUEST_BODY, ERROR_NO_MATCHING_INVITE, } from '../../http/error_schemas.js';
|
|
94
|
+
import { DEFAULT_TEST_PASSWORD } from '../test_credentials.js';
|
|
95
|
+
/**
|
|
96
|
+
* The flat-REST `{error}` reason each terminal login/signup status carries on
|
|
97
|
+
* every spine — the assertion target that turns a status-only check into a
|
|
98
|
+
* body-shape check. `200` carries no `error` field (`{ok: true}`), so its
|
|
99
|
+
* expected reason is `undefined`. Asserting the reason (not just the status)
|
|
100
|
+
* catches a backend that returns the right status with a wrong, renamed, or
|
|
101
|
+
* info-leaking body — drift the status comparison is blind to. Wire constants,
|
|
102
|
+
* not literals, so a rename in `error_schemas.ts` fails the typecheck here.
|
|
103
|
+
*/
|
|
104
|
+
const REASON_BY_STATUS = {
|
|
105
|
+
200: undefined,
|
|
106
|
+
400: ERROR_INVALID_REQUEST_BODY,
|
|
107
|
+
401: ERROR_INVALID_CREDENTIALS,
|
|
108
|
+
403: ERROR_NO_MATCHING_INVITE,
|
|
109
|
+
};
|
|
110
|
+
/**
|
|
111
|
+
* U+0130 LATIN CAPITAL LETTER I WITH DOT ABOVE. Its Unicode default lowercase
|
|
112
|
+
* is `i` + U+0307 (combining dot above), never plain `i` — the property the
|
|
113
|
+
* negative case relies on for a stable cross-impl result.
|
|
114
|
+
*/
|
|
115
|
+
const TURKISH_DOTTED_I = 'İ';
|
|
116
|
+
/**
|
|
117
|
+
* Representative non-ASCII usernames spanning distinct scripts — accented Latin
|
|
118
|
+
* (mid-string), Greek (leading), CJK (leading). Each fails the `Username` regex
|
|
119
|
+
* at a different position, so a Rust regex that admitted any one script (but not
|
|
120
|
+
* another) is caught. All → 400 at signup input validation.
|
|
121
|
+
*/
|
|
122
|
+
const USERNAME_NON_ASCII_CASES = [
|
|
123
|
+
{ username: 'café_user', label: 'accented Latin mid-string', status: 400 },
|
|
124
|
+
{ username: 'Ωmega_user', label: 'leading Greek', status: 400 },
|
|
125
|
+
{ username: '日本_user', label: 'leading CJK', status: 400 },
|
|
126
|
+
];
|
|
127
|
+
/**
|
|
128
|
+
* Status a signup with a **valid** username (but no matching invite, on a
|
|
129
|
+
* spine whose `open_signup` stays at the production default `false`) settles
|
|
130
|
+
* to: the username cleared input validation and reached the invite gate, which
|
|
131
|
+
* finds no match → 403 `no_matching_invite`. The accepted boundary/format
|
|
132
|
+
* cases below assert this (not 200): the point is that the username was *not*
|
|
133
|
+
* rejected at validation, distinguishing it from the 400 cases.
|
|
134
|
+
*/
|
|
135
|
+
const VALID_USERNAME_NO_INVITE_STATUS = 403;
|
|
136
|
+
/**
|
|
137
|
+
* Username length boundary pairs for the creation schema, pinning both edges of
|
|
138
|
+
* `[USERNAME_LENGTH_MIN, USERNAME_LENGTH_MAX]` = `[3, 39]`. Each pair is a
|
|
139
|
+
* just-outside length (rejected at input validation → 400) and its just-inside
|
|
140
|
+
* sibling (cleared validation → 403). Pinning both sides on both backends
|
|
141
|
+
* catches an off-by-one in either impl's min/max check — the TS `.min()/.max()`
|
|
142
|
+
* vs the Rust `s.len() < MIN || s.len() > MAX` byte-length scan. Every value is
|
|
143
|
+
* otherwise valid (leading letter, alphanumeric tail, alphanumeric body) so the
|
|
144
|
+
* only variable is length.
|
|
145
|
+
*/
|
|
146
|
+
const USERNAME_LENGTH_CASES = [
|
|
147
|
+
{ username: 'ab', label: '2 chars — below min', status: 400 },
|
|
148
|
+
{ username: 'abc', label: '3 chars — at min', status: VALID_USERNAME_NO_INVITE_STATUS },
|
|
149
|
+
{
|
|
150
|
+
username: `a${'b'.repeat(38)}`,
|
|
151
|
+
label: '39 chars — at max',
|
|
152
|
+
status: VALID_USERNAME_NO_INVITE_STATUS,
|
|
153
|
+
},
|
|
154
|
+
{ username: `a${'b'.repeat(39)}`, label: '40 chars — above max', status: 400 },
|
|
155
|
+
];
|
|
156
|
+
/**
|
|
157
|
+
* Username format edge cases for the creation regex
|
|
158
|
+
* `^[a-zA-Z][0-9a-zA-Z_-]*[0-9a-zA-Z]$`. The rejected cases each violate the
|
|
159
|
+
* shape at a different position — leading non-letter, trailing punctuation,
|
|
160
|
+
* embedded disallowed char — so a Rust byte-scan that policed only one position
|
|
161
|
+
* is caught. The two accepted siblings (`_` / `-` *mid-string*) are the
|
|
162
|
+
* non-divergence control: the same characters that are illegal at the end are
|
|
163
|
+
* legal in the body, so they must clear validation (→ 403) on every backend —
|
|
164
|
+
* pinning that neither impl over-rejects them.
|
|
165
|
+
*/
|
|
166
|
+
const USERNAME_FORMAT_CASES = [
|
|
167
|
+
{ username: '1user', label: 'leading digit', status: 400 },
|
|
168
|
+
{ username: '_user', label: 'leading underscore', status: 400 },
|
|
169
|
+
{ username: '-user', label: 'leading dash', status: 400 },
|
|
170
|
+
{ username: 'user_', label: 'trailing underscore', status: 400 },
|
|
171
|
+
{ username: 'user-', label: 'trailing dash', status: 400 },
|
|
172
|
+
{ username: 'us er', label: 'embedded space', status: 400 },
|
|
173
|
+
{ username: 'user@x', label: 'embedded at-sign', status: 400 },
|
|
174
|
+
{ username: 'user.x', label: 'embedded dot', status: 400 },
|
|
175
|
+
{
|
|
176
|
+
username: 'us_er',
|
|
177
|
+
label: 'underscore mid-string (valid)',
|
|
178
|
+
status: VALID_USERNAME_NO_INVITE_STATUS,
|
|
179
|
+
},
|
|
180
|
+
{ username: 'us-er', label: 'dash mid-string (valid)', status: VALID_USERNAME_NO_INVITE_STATUS },
|
|
181
|
+
];
|
|
182
|
+
/**
|
|
183
|
+
* Signup email-format cases for the loose `Email` shape `local@domain.tld`
|
|
184
|
+
* (TS `^[^\s@]+@[^\s@]+\.[^\s@]+$` plus the 254 length bound; Rust the
|
|
185
|
+
* hand-rolled `is_valid_email`). The rejected cases each violate the shape at
|
|
186
|
+
* a different point — no `@`, no dot in the domain, empty local/domain, a dot
|
|
187
|
+
* at a domain edge, surrounding whitespace, over the length bound — so a
|
|
188
|
+
* backend policing only one of them is caught. The accepted siblings are the
|
|
189
|
+
* don't-over-reject control and include the shapes Zod's `z.email()` rejects
|
|
190
|
+
* but our looser rule accepts — single-char TLD `a@b.c` and consecutive dots
|
|
191
|
+
* `a..b@c.d` — pinning the deliberately-looser shared rule in both directions.
|
|
192
|
+
* Every case rides a valid username so the rejection (or the 403 no-invite
|
|
193
|
+
* settle) isolates the email.
|
|
194
|
+
*/
|
|
195
|
+
const SIGNUP_EMAIL_CASES = [
|
|
196
|
+
{ email: 'not-an-email', label: 'no @ or dot', status: 400 },
|
|
197
|
+
{ email: 'user@host', label: 'no dot in domain', status: 400 },
|
|
198
|
+
{ email: '@example.com', label: 'empty local part', status: 400 },
|
|
199
|
+
{ email: 'user@', label: 'empty domain', status: 400 },
|
|
200
|
+
{ email: 'user@.com', label: 'domain starts with a dot', status: 400 },
|
|
201
|
+
{ email: 'user@com.', label: 'domain ends with a dot', status: 400 },
|
|
202
|
+
{ email: 'a@@b.c', label: 'two @', status: 400 },
|
|
203
|
+
{ email: ' user@example.com', label: 'leading whitespace', status: 400 },
|
|
204
|
+
{ email: 'user@example.com ', label: 'trailing whitespace', status: 400 },
|
|
205
|
+
{ email: 'us er@example.com', label: 'internal whitespace', status: 400 },
|
|
206
|
+
{ email: 'a\u0085b@c.d', label: 'NEL U+0085 (Unicode ws JS \\s omits)', status: 400 },
|
|
207
|
+
{ email: 'a\ufeffb@c.d', label: 'BOM/ZWNBSP U+FEFF', status: 400 },
|
|
208
|
+
// 255 bytes total (243-char local + "@example.com") — one over the 254-byte bound.
|
|
209
|
+
{ email: `${'a'.repeat(243)}@example.com`, label: 'over the 254-byte bound (ASCII)', status: 400 },
|
|
210
|
+
// Multibyte: the bound is UTF-8 BYTES, not code units. `\u00fc` (ü) is 2
|
|
211
|
+
// bytes / 1 code unit, so 121×ü + "@example.com" is 254 bytes (accepted) and
|
|
212
|
+
// 122×ü is 256 bytes (rejected) — both far under 254 code units, so a
|
|
213
|
+
// code-unit `.max(254)` would wrongly accept the over case. Pins the byte
|
|
214
|
+
// bound across both spines (the Rust `is_valid_email` `s.len()`).
|
|
215
|
+
{
|
|
216
|
+
email: `${'\u00fc'.repeat(121)}@example.com`,
|
|
217
|
+
label: 'multibyte at the 254-byte bound',
|
|
218
|
+
status: VALID_USERNAME_NO_INVITE_STATUS,
|
|
219
|
+
},
|
|
220
|
+
{
|
|
221
|
+
email: `${'\u00fc'.repeat(122)}@example.com`,
|
|
222
|
+
label: 'multibyte over the 254-byte bound',
|
|
223
|
+
status: 400,
|
|
224
|
+
},
|
|
225
|
+
{ email: 'user@example.com', label: 'well-formed', status: VALID_USERNAME_NO_INVITE_STATUS },
|
|
226
|
+
{
|
|
227
|
+
email: 'a@b.c',
|
|
228
|
+
label: 'single-char TLD (looser than z.email)',
|
|
229
|
+
status: VALID_USERNAME_NO_INVITE_STATUS,
|
|
230
|
+
},
|
|
231
|
+
{
|
|
232
|
+
email: 'a..b@c.d',
|
|
233
|
+
label: 'consecutive dots (looser than z.email)',
|
|
234
|
+
status: VALID_USERNAME_NO_INVITE_STATUS,
|
|
235
|
+
},
|
|
236
|
+
{
|
|
237
|
+
email: 'user+tag@example.co.uk',
|
|
238
|
+
label: 'plus-tag + multi-label',
|
|
239
|
+
status: VALID_USERNAME_NO_INVITE_STATUS,
|
|
240
|
+
},
|
|
241
|
+
];
|
|
242
|
+
export const describe_identity_parity_cross_tests = (options) => {
|
|
243
|
+
const { setup_test } = options;
|
|
244
|
+
const login_path = options.login_path ?? '/api/account/login';
|
|
245
|
+
const signup_path = options.signup_path ?? '/api/account/signup';
|
|
246
|
+
/**
|
|
247
|
+
* POST JSON on a fresh (cookie-jar-free) transport; return the status and the
|
|
248
|
+
* parsed flat-REST `{error}` reason (`undefined` for success / non-JSON
|
|
249
|
+
* bodies). Always reads the body so the cross-process socket releases.
|
|
250
|
+
*/
|
|
251
|
+
const post_json = async (fixture, path, body) => {
|
|
252
|
+
const res = await fixture.fresh_transport()(path, {
|
|
253
|
+
method: 'POST',
|
|
254
|
+
headers: { 'content-type': 'application/json' },
|
|
255
|
+
body: JSON.stringify(body),
|
|
256
|
+
});
|
|
257
|
+
const text = await res.text().catch(() => '');
|
|
258
|
+
let error;
|
|
259
|
+
try {
|
|
260
|
+
const parsed = text ? JSON.parse(text) : undefined;
|
|
261
|
+
if (parsed &&
|
|
262
|
+
typeof parsed === 'object' &&
|
|
263
|
+
typeof parsed.error === 'string') {
|
|
264
|
+
error = parsed.error;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
catch {
|
|
268
|
+
// non-JSON body — leave `error` undefined.
|
|
269
|
+
}
|
|
270
|
+
return { status: res.status, error };
|
|
271
|
+
};
|
|
272
|
+
/** POST a login with the given identifier (username or email). */
|
|
273
|
+
const login = (fixture, username, password = DEFAULT_TEST_PASSWORD) => post_json(fixture, login_path, { username, password });
|
|
274
|
+
/** POST a signup with a valid password and the given username. */
|
|
275
|
+
const signup = (fixture, username, password = DEFAULT_TEST_PASSWORD) => post_json(fixture, signup_path, { username, password });
|
|
276
|
+
/**
|
|
277
|
+
* Assert an attempt settled to `status` **and** carried the flat-REST error
|
|
278
|
+
* reason that status implies on every spine (see {@link REASON_BY_STATUS}) —
|
|
279
|
+
* a body-shape check, not just a status check, so a backend can't pass by
|
|
280
|
+
* returning the right status with the wrong/leaky body.
|
|
281
|
+
*/
|
|
282
|
+
const assert_attempt = (actual, status, label) => {
|
|
283
|
+
assert.strictEqual(actual.status, status, `${label} — expected status ${status}, got ${actual.status}`);
|
|
284
|
+
const expected_reason = REASON_BY_STATUS[status];
|
|
285
|
+
assert.strictEqual(actual.error, expected_reason, `${label} — status ${status} must carry error reason ${expected_reason ?? '(none)'}, got ${actual.error ?? '(none)'}`);
|
|
286
|
+
};
|
|
287
|
+
/** Run a table of signup username cases, asserting each settles to its expected `{status, reason}`. */
|
|
288
|
+
const describe_signup_username_cases = (suite_name, cases) => {
|
|
289
|
+
describe(suite_name, () => {
|
|
290
|
+
for (const { username, label, status } of cases) {
|
|
291
|
+
test(`${label} — "${username}" → ${status}`, async () => {
|
|
292
|
+
const fixture = await setup_test();
|
|
293
|
+
assert_attempt(await signup(fixture, username), status, `username "${username}" (${label})`);
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
};
|
|
298
|
+
describe('username canonicalization parity', () => {
|
|
299
|
+
test('case-insensitive login — uppercase submission matches a mixed-case account', async () => {
|
|
300
|
+
const fixture = await setup_test();
|
|
301
|
+
// Mixed-case ASCII username (valid per the `Username` regex), stored
|
|
302
|
+
// lowercase; an all-uppercase submission matches only if the lookup
|
|
303
|
+
// folds case on this backend.
|
|
304
|
+
await fixture.create_account({ username: 'Canon_Mixed_User' });
|
|
305
|
+
assert_attempt(await login(fixture, 'CANON_MIXED_USER'), 200, 'uppercase login');
|
|
306
|
+
});
|
|
307
|
+
test('whitespace-trim login — surrounding whitespace is trimmed on lookup', async () => {
|
|
308
|
+
const fixture = await setup_test();
|
|
309
|
+
await fixture.create_account({ username: 'Canon_Trim_User' });
|
|
310
|
+
assert_attempt(await login(fixture, ' canon_trim_user '), 200, 'whitespace-trim login');
|
|
311
|
+
});
|
|
312
|
+
test('no Unicode case-fold collision — Turkish İ variant of an ASCII username → 401', async () => {
|
|
313
|
+
const fixture = await setup_test();
|
|
314
|
+
// `canon_admin_user` carries an ASCII `i`; the probe replaces it with İ
|
|
315
|
+
// (U+0130). A backend that folded İ → plain `i` would match the stored
|
|
316
|
+
// username and authenticate (the bug); both impls fold İ → `i` + U+0307,
|
|
317
|
+
// so the canonical form differs and the lookup misses → 401
|
|
318
|
+
// `invalid_credentials` (asserted, not just the status — a backend that
|
|
319
|
+
// 401'd with a different body would still be wrong).
|
|
320
|
+
await fixture.create_account({ username: 'canon_admin_user' });
|
|
321
|
+
const homograph = `canon_adm${TURKISH_DOTTED_I}n_user`;
|
|
322
|
+
assert_attempt(await login(fixture, homograph), 401, 'Turkish-İ homograph');
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
describe('username-or-email login parity', () => {
|
|
326
|
+
// The login identifier matches against **username or email** (TS
|
|
327
|
+
// `query_account_by_username_or_email`, Rust the converged
|
|
328
|
+
// `query_account_with_password_hash` OR-lookup). An account that carries
|
|
329
|
+
// an email logs in via that email, case-insensitively, on every backend —
|
|
330
|
+
// the property that was unpinned (and divergent: the Rust spine matched
|
|
331
|
+
// username only) until this suite. Email is stored with its original case
|
|
332
|
+
// (`Email` does not lowercase), so case folding rides entirely on the
|
|
333
|
+
// `LOWER(email) = LOWER($1)` lookup, exercised by the mixed-case cases.
|
|
334
|
+
test('login by exact email — an account created with an email logs in via that email', async () => {
|
|
335
|
+
const fixture = await setup_test();
|
|
336
|
+
await fixture.create_account({
|
|
337
|
+
username: 'email_exact_user',
|
|
338
|
+
email: 'email-exact@example.com',
|
|
339
|
+
});
|
|
340
|
+
assert_attempt(await login(fixture, 'email-exact@example.com'), 200, 'exact-email login');
|
|
341
|
+
});
|
|
342
|
+
test('email login is case-insensitive — mixed-case stored email, lowercase submission', async () => {
|
|
343
|
+
const fixture = await setup_test();
|
|
344
|
+
await fixture.create_account({
|
|
345
|
+
username: 'email_mixed_user',
|
|
346
|
+
email: 'Email.Mixed@Example.COM',
|
|
347
|
+
});
|
|
348
|
+
assert_attempt(await login(fixture, 'email.mixed@example.com'), 200, 'mixed-case-email login');
|
|
349
|
+
});
|
|
350
|
+
test('email login is case-insensitive — lowercase stored email, uppercase submission', async () => {
|
|
351
|
+
const fixture = await setup_test();
|
|
352
|
+
await fixture.create_account({
|
|
353
|
+
username: 'email_upper_user',
|
|
354
|
+
email: 'email-upper@example.com',
|
|
355
|
+
});
|
|
356
|
+
assert_attempt(await login(fixture, 'EMAIL-UPPER@EXAMPLE.COM'), 200, 'uppercase-email login');
|
|
357
|
+
});
|
|
358
|
+
test('username login still works when the account also has an email', async () => {
|
|
359
|
+
const fixture = await setup_test();
|
|
360
|
+
await fixture.create_account({
|
|
361
|
+
username: 'email_dual_user',
|
|
362
|
+
email: 'email-dual@example.com',
|
|
363
|
+
});
|
|
364
|
+
assert_attempt(await login(fixture, 'email_dual_user'), 200, 'username login with email present');
|
|
365
|
+
});
|
|
366
|
+
test('login by a non-existent email → 401', async () => {
|
|
367
|
+
const fixture = await setup_test();
|
|
368
|
+
await fixture.create_account({
|
|
369
|
+
username: 'email_present_user',
|
|
370
|
+
email: 'email-present@example.com',
|
|
371
|
+
});
|
|
372
|
+
assert_attempt(await login(fixture, 'email-absent@example.com'), 401, 'non-existent-email login');
|
|
373
|
+
});
|
|
374
|
+
});
|
|
375
|
+
describe('login input validation parity', () => {
|
|
376
|
+
// Malformed login input → 400 `invalid_request_body` on every spine. TS
|
|
377
|
+
// runs the full `LoginInput` Zod schema (`UsernameProvided` non-empty /
|
|
378
|
+
// max 255 / non-empty-after-trim, `PasswordProvided` non-empty / max 300,
|
|
379
|
+
// `z.strictObject`); the Rust spine enforces the same shape in
|
|
380
|
+
// `account_login` (an extra key fails `deny_unknown_fields` → 400, not
|
|
381
|
+
// axum's 422). These each previously fell through to a lookup-miss 401 (or
|
|
382
|
+
// a bespoke-string 400) on one backend — a status *and* body divergence the
|
|
383
|
+
// strengthened `assert_attempt` (reason-checked) now pins.
|
|
384
|
+
test('whitespace-only username → 400 (empty after trim, not a lookup miss)', async () => {
|
|
385
|
+
const fixture = await setup_test();
|
|
386
|
+
assert_attempt(await login(fixture, ' '), 400, 'whitespace-only username');
|
|
387
|
+
});
|
|
388
|
+
test('over-long username (>255) → 400', async () => {
|
|
389
|
+
const fixture = await setup_test();
|
|
390
|
+
assert_attempt(await login(fixture, 'a'.repeat(256)), 400, 'over-long username');
|
|
391
|
+
});
|
|
392
|
+
test('empty password → 400 (rejected as malformed, not a wrong credential)', async () => {
|
|
393
|
+
const fixture = await setup_test();
|
|
394
|
+
assert_attempt(await login(fixture, 'some_user', ''), 400, 'empty password');
|
|
395
|
+
});
|
|
396
|
+
test('over-long password (>300) → 400', async () => {
|
|
397
|
+
const fixture = await setup_test();
|
|
398
|
+
assert_attempt(await login(fixture, 'some_user', 'a'.repeat(301)), 400, 'over-long password');
|
|
399
|
+
});
|
|
400
|
+
test('unknown body key → 400 (strict object on both spines)', async () => {
|
|
401
|
+
const fixture = await setup_test();
|
|
402
|
+
assert_attempt(await post_json(fixture, login_path, {
|
|
403
|
+
username: 'some_user',
|
|
404
|
+
password: DEFAULT_TEST_PASSWORD,
|
|
405
|
+
unexpected_field: 'x',
|
|
406
|
+
}), 400, 'unknown body key');
|
|
407
|
+
});
|
|
408
|
+
});
|
|
409
|
+
// The `Username` creation schema is ASCII-only by regex; both spines reject a
|
|
410
|
+
// non-ASCII username at signup input validation → 400, so no Unicode username
|
|
411
|
+
// is ever stored (the precondition the no-collision login case relies on).
|
|
412
|
+
describe_signup_username_cases('username creation ASCII-only invariant parity', USERNAME_NON_ASCII_CASES);
|
|
413
|
+
// Both edges of `[USERNAME_LENGTH_MIN, USERNAME_LENGTH_MAX]` = [3, 39], each
|
|
414
|
+
// side just-outside (→ 400) and just-inside (→ 403). Catches an off-by-one in
|
|
415
|
+
// either backend's length check.
|
|
416
|
+
describe_signup_username_cases('username creation length boundary parity', USERNAME_LENGTH_CASES);
|
|
417
|
+
// The creation regex `^[a-zA-Z][0-9a-zA-Z_-]*[0-9a-zA-Z]$` — rejected shapes
|
|
418
|
+
// (→ 400) and the mid-string `_`/`-` controls that must be accepted (→ 403),
|
|
419
|
+
// proving neither backend over- or under-rejects.
|
|
420
|
+
describe_signup_username_cases('username creation format parity', USERNAME_FORMAT_CASES);
|
|
421
|
+
// The optional signup `email` is validated to the loose `local@domain.tld`
|
|
422
|
+
// shape on both spines (TS `Email` regex + 254-byte bound, Rust `is_valid_email`):
|
|
423
|
+
// malformed → 400, well-formed → 403 no-invite. The username is fixed-valid so
|
|
424
|
+
// each case isolates the email; the `a@b.c` accept pins the deliberately
|
|
425
|
+
// looser-than-`z.email()` rule the validator port converged on.
|
|
426
|
+
describe('signup email format parity', () => {
|
|
427
|
+
for (const { email, label, status } of SIGNUP_EMAIL_CASES) {
|
|
428
|
+
test(`${label} → ${status}`, async () => {
|
|
429
|
+
const fixture = await setup_test();
|
|
430
|
+
assert_attempt(await post_json(fixture, signup_path, {
|
|
431
|
+
username: 'email_fmt_user',
|
|
432
|
+
password: DEFAULT_TEST_PASSWORD,
|
|
433
|
+
email,
|
|
434
|
+
}), status, `email "${email}" (${label})`);
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
// `null` email (not just absent) — TS `Email.nullish()` + the Rust
|
|
438
|
+
// `Option<String>` (serde maps JSON null → None) both treat it as
|
|
439
|
+
// "no email", so signup reaches the invite gate → 403, not a 400.
|
|
440
|
+
// Pins the null-vs-absent convergence the `.nullish()` loosening made.
|
|
441
|
+
test('null email is treated as absent (403, not 400)', async () => {
|
|
442
|
+
const fixture = await setup_test();
|
|
443
|
+
assert_attempt(await post_json(fixture, signup_path, {
|
|
444
|
+
username: 'email_null_user',
|
|
445
|
+
password: DEFAULT_TEST_PASSWORD,
|
|
446
|
+
email: null,
|
|
447
|
+
}), VALID_USERNAME_NO_INVITE_STATUS, 'null email');
|
|
448
|
+
});
|
|
449
|
+
});
|
|
450
|
+
describe('signup input validation parity', () => {
|
|
451
|
+
// Signup is `z.strictObject` on TS and `#[serde(deny_unknown_fields)]` on
|
|
452
|
+
// the Rust spine — an unknown body key → 400 `invalid_request_body` on
|
|
453
|
+
// both (the Rust spine previously ignored it via serde). The username is
|
|
454
|
+
// valid so the rejection isolates the unknown key (a valid body would
|
|
455
|
+
// otherwise reach the invite gate → 403).
|
|
456
|
+
test('unknown body key → 400 (strict object on both spines)', async () => {
|
|
457
|
+
const fixture = await setup_test();
|
|
458
|
+
assert_attempt(await post_json(fixture, signup_path, {
|
|
459
|
+
username: 'strict_signup_user',
|
|
460
|
+
password: DEFAULT_TEST_PASSWORD,
|
|
461
|
+
unexpected_field: 'x',
|
|
462
|
+
}), 400, 'signup unknown body key');
|
|
463
|
+
});
|
|
464
|
+
});
|
|
465
|
+
};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import '../assert_dev_env.js';
|
|
2
|
+
import type { BackendCapabilities } from './capabilities.js';
|
|
3
|
+
/** How a live-mounted RPC method earns its cross-backend coverage. */
|
|
4
|
+
export type MethodCoverageTier =
|
|
5
|
+
/**
|
|
6
|
+
* On the declared surface (`create_spine_surface_spec`). Auto-enumerated by
|
|
7
|
+
* the spec-derived round-trip + attack-surface suites — no bespoke suite
|
|
8
|
+
* needed.
|
|
9
|
+
*/
|
|
10
|
+
'declared'
|
|
11
|
+
/**
|
|
12
|
+
* Live-mounted but deliberately off the declared surface (stateful or
|
|
13
|
+
* opt-in). Covered only by a dedicated imperative `describe_*_cross_tests`
|
|
14
|
+
* suite, so the entry must name it.
|
|
15
|
+
*/
|
|
16
|
+
| 'off_surface'
|
|
17
|
+
/**
|
|
18
|
+
* A `_testing_*` daemon-token backdoor — live-mounted on the test binary,
|
|
19
|
+
* never on the declared surface. Coverage is the credential-gate suite plus
|
|
20
|
+
* the in-process spec-level gate test.
|
|
21
|
+
*/
|
|
22
|
+
| 'backdoor';
|
|
23
|
+
/** One row of the live-RPC-method coverage manifest. */
|
|
24
|
+
export interface MethodCoverageEntry {
|
|
25
|
+
/** The RPC method name (`action.spec.method`). */
|
|
26
|
+
readonly method: string;
|
|
27
|
+
/** How this method is covered. */
|
|
28
|
+
readonly tier: MethodCoverageTier;
|
|
29
|
+
/**
|
|
30
|
+
* The `BackendCapabilities` flag gating this method's cross suite, when it
|
|
31
|
+
* is capability-gated. Typed against the real interface so a stale flag is a
|
|
32
|
+
* compile error. Omit for ungated off-surface families (always-mounted, the
|
|
33
|
+
* suite runs unconditionally) and for declared / backdoor tiers.
|
|
34
|
+
*/
|
|
35
|
+
readonly capability?: keyof BackendCapabilities;
|
|
36
|
+
/**
|
|
37
|
+
* The cross-backend suite (or test file) that covers this method.
|
|
38
|
+
* **Required** for `off_surface` — the manifest's whole point is to name the
|
|
39
|
+
* suite a method without auto-enumeration relies on. Optional for
|
|
40
|
+
* `declared` (auto-enumerated) / `backdoor` (infra).
|
|
41
|
+
*/
|
|
42
|
+
readonly suite?: string;
|
|
43
|
+
/** Optional free-text note printed nowhere — documentation for the reader. */
|
|
44
|
+
readonly note?: string;
|
|
45
|
+
}
|
|
46
|
+
/** Inputs for {@link assert_rpc_method_coverage}. */
|
|
47
|
+
export interface RpcMethodCoverageInput {
|
|
48
|
+
/** Every method the live RPC endpoint mounts (`action.spec.method`). */
|
|
49
|
+
readonly live_methods: ReadonlyArray<string>;
|
|
50
|
+
/** The declared-surface method names (from `create_*_surface_spec`). */
|
|
51
|
+
readonly declared_methods: ReadonlyArray<string>;
|
|
52
|
+
/** The tagged manifest the live set must reconcile against. */
|
|
53
|
+
readonly manifest: ReadonlyArray<MethodCoverageEntry>;
|
|
54
|
+
/** Backdoor method prefix. Defaults to `'_testing_'`. */
|
|
55
|
+
readonly testing_method_prefix?: string;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Assert the live RPC method set reconciles exactly with the coverage
|
|
59
|
+
* manifest, and that every manifest entry's tier is internally consistent.
|
|
60
|
+
*
|
|
61
|
+
* Fails loud on: a live method missing from the manifest (mounted-but-unclaimed),
|
|
62
|
+
* a manifest row naming a method the live mount no longer exposes (stale row),
|
|
63
|
+
* a declared-surface method absent from the live mount (the full mount must be
|
|
64
|
+
* a superset), a duplicate manifest entry, or any tier/declared/backdoor
|
|
65
|
+
* inconsistency (e.g. an `off_surface` row that is actually on the declared
|
|
66
|
+
* surface, or one missing its `suite`).
|
|
67
|
+
*
|
|
68
|
+
* @throws AssertionError naming the specific divergence.
|
|
69
|
+
*/
|
|
70
|
+
export declare const assert_rpc_method_coverage: (input: RpcMethodCoverageInput) => void;
|
|
71
|
+
//# sourceMappingURL=method_coverage.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"method_coverage.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/method_coverage.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAiC9B,OAAO,KAAK,EAAC,mBAAmB,EAAC,MAAM,mBAAmB,CAAC;AAE3D,sEAAsE;AACtE,MAAM,MAAM,kBAAkB;AAC7B;;;;GAIG;AACD,UAAU;AACZ;;;;GAIG;GACD,aAAa;AACf;;;;GAIG;GACD,UAAU,CAAC;AAEd,wDAAwD;AACxD,MAAM,WAAW,mBAAmB;IACnC,kDAAkD;IAClD,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,kCAAkC;IAClC,QAAQ,CAAC,IAAI,EAAE,kBAAkB,CAAC;IAClC;;;;;OAKG;IACH,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,mBAAmB,CAAC;IAChD;;;;;OAKG;IACH,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IACxB,8EAA8E;IAC9E,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;CACvB;AAED,qDAAqD;AACrD,MAAM,WAAW,sBAAsB;IACtC,wEAAwE;IACxE,QAAQ,CAAC,YAAY,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IAC7C,wEAAwE;IACxE,QAAQ,CAAC,gBAAgB,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IACjD,+DAA+D;IAC/D,QAAQ,CAAC,QAAQ,EAAE,aAAa,CAAC,mBAAmB,CAAC,CAAC;IACtD,yDAAyD;IACzD,QAAQ,CAAC,qBAAqB,CAAC,EAAE,MAAM,CAAC;CACxC;AAID;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,0BAA0B,GAAI,OAAO,sBAAsB,KAAG,IAsF1E,CAAC"}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import '../assert_dev_env.js';
|
|
2
|
+
/**
|
|
3
|
+
* Reconcile a backend's **live-mounted** RPC method set against a tagged
|
|
4
|
+
* coverage manifest, so the off-declared-surface methods stay as drift-proof
|
|
5
|
+
* as the declared ones.
|
|
6
|
+
*
|
|
7
|
+
* The spec-derived suites (`describe_rpc_round_trip_tests` /
|
|
8
|
+
* `describe_rpc_attack_surface_tests`) auto-enumerate the **declared**
|
|
9
|
+
* surface — add a method to a registry in `create_spine_surface_spec` and it
|
|
10
|
+
* is tested automatically. But a backend's live RPC endpoint also mounts
|
|
11
|
+
* methods kept *off* that surface (stateful cell verbs, opt-in resolvers,
|
|
12
|
+
* `_testing_*` backdoors); those rely on hand-wired imperative suites and get
|
|
13
|
+
* no auto-enumeration. Nothing structurally guaranteed every live method was
|
|
14
|
+
* actually claimed by a suite — so a newly mounted-but-untested method could
|
|
15
|
+
* ship silently.
|
|
16
|
+
*
|
|
17
|
+
* {@link assert_rpc_method_coverage} closes that gap: it diffs the live method
|
|
18
|
+
* set against a {@link MethodCoverageEntry} manifest (both directions — a
|
|
19
|
+
* mounted-but-unclaimed method *and* a stale manifest row both fail loud) and
|
|
20
|
+
* checks each entry's tier is consistent with the declared surface + the
|
|
21
|
+
* backdoor prefix. The manifest becomes the forcing function — a new method
|
|
22
|
+
* can't reach the live mount without a manifest row naming the suite that
|
|
23
|
+
* covers it.
|
|
24
|
+
*
|
|
25
|
+
* Pairs with `surface_invariants.ts` `assert_no_testing_methods` (which guards
|
|
26
|
+
* the *reverse* — a backdoor must never leak *onto* the declared surface).
|
|
27
|
+
*
|
|
28
|
+
* @module
|
|
29
|
+
*/
|
|
30
|
+
import { assert } from 'vitest';
|
|
31
|
+
const sorted = (set) => [...set].sort().join('\n ');
|
|
32
|
+
/**
|
|
33
|
+
* Assert the live RPC method set reconciles exactly with the coverage
|
|
34
|
+
* manifest, and that every manifest entry's tier is internally consistent.
|
|
35
|
+
*
|
|
36
|
+
* Fails loud on: a live method missing from the manifest (mounted-but-unclaimed),
|
|
37
|
+
* a manifest row naming a method the live mount no longer exposes (stale row),
|
|
38
|
+
* a declared-surface method absent from the live mount (the full mount must be
|
|
39
|
+
* a superset), a duplicate manifest entry, or any tier/declared/backdoor
|
|
40
|
+
* inconsistency (e.g. an `off_surface` row that is actually on the declared
|
|
41
|
+
* surface, or one missing its `suite`).
|
|
42
|
+
*
|
|
43
|
+
* @throws AssertionError naming the specific divergence.
|
|
44
|
+
*/
|
|
45
|
+
export const assert_rpc_method_coverage = (input) => {
|
|
46
|
+
const prefix = input.testing_method_prefix ?? '_testing_';
|
|
47
|
+
const live = new Set(input.live_methods);
|
|
48
|
+
const declared = new Set(input.declared_methods);
|
|
49
|
+
const manifest_methods = new Set();
|
|
50
|
+
for (const entry of input.manifest) {
|
|
51
|
+
assert.ok(!manifest_methods.has(entry.method), `method coverage: duplicate manifest entry for '${entry.method}'`);
|
|
52
|
+
manifest_methods.add(entry.method);
|
|
53
|
+
}
|
|
54
|
+
const unclaimed = [...live].filter((m) => !manifest_methods.has(m));
|
|
55
|
+
assert.ok(unclaimed.length === 0, `method coverage: ${unclaimed.length} live-mounted RPC method(s) absent from the coverage ` +
|
|
56
|
+
`manifest — add a {method, tier, suite} entry for each (or stop mounting it):\n ${sorted(unclaimed)}`);
|
|
57
|
+
const stale = [...manifest_methods].filter((m) => !live.has(m));
|
|
58
|
+
assert.ok(stale.length === 0, `method coverage: ${stale.length} manifest entr(y/ies) name a method the live mount no longer ` +
|
|
59
|
+
`exposes — remove the stale row(s):\n ${sorted(stale)}`);
|
|
60
|
+
const declared_not_live = [...declared].filter((m) => !live.has(m));
|
|
61
|
+
assert.ok(declared_not_live.length === 0, `method coverage: ${declared_not_live.length} declared-surface method(s) not present in the ` +
|
|
62
|
+
`live mount — the full mount must be a superset of the declared surface:\n ${sorted(declared_not_live)}`);
|
|
63
|
+
for (const entry of input.manifest) {
|
|
64
|
+
const is_backdoor = entry.method.startsWith(prefix);
|
|
65
|
+
const is_declared = declared.has(entry.method);
|
|
66
|
+
switch (entry.tier) {
|
|
67
|
+
case 'declared':
|
|
68
|
+
assert.ok(is_declared, `method coverage: '${entry.method}' is tagged 'declared' but is not on the declared ` +
|
|
69
|
+
`surface (create_*_surface_spec) — retag it 'off_surface' or 'backdoor'`);
|
|
70
|
+
assert.ok(!is_backdoor, `method coverage: '${entry.method}' is tagged 'declared' but starts with '${prefix}' ` +
|
|
71
|
+
`(backdoors must never reach the declared surface) — retag it 'backdoor'`);
|
|
72
|
+
break;
|
|
73
|
+
case 'backdoor':
|
|
74
|
+
assert.ok(is_backdoor, `method coverage: '${entry.method}' is tagged 'backdoor' but does not start with ` +
|
|
75
|
+
`'${prefix}'`);
|
|
76
|
+
assert.ok(!is_declared, `method coverage: '${entry.method}' is tagged 'backdoor' but appears on the declared ` +
|
|
77
|
+
`surface — a '${prefix}*' action must be live-mounted only`);
|
|
78
|
+
break;
|
|
79
|
+
case 'off_surface':
|
|
80
|
+
assert.ok(!is_declared, `method coverage: '${entry.method}' is tagged 'off_surface' but is on the declared ` +
|
|
81
|
+
`surface — retag it 'declared' (the spec-derived suites already enumerate it)`);
|
|
82
|
+
assert.ok(!is_backdoor, `method coverage: '${entry.method}' is tagged 'off_surface' but starts with '${prefix}' ` +
|
|
83
|
+
`— retag it 'backdoor'`);
|
|
84
|
+
assert.ok(!!entry.suite, `method coverage: off-surface method '${entry.method}' must name the cross-backend ` +
|
|
85
|
+
`'suite' that covers it — it gets no auto-enumeration`);
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
};
|