@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.
Files changed (57) hide show
  1. package/dist/auth/CLAUDE.md +7 -0
  2. package/dist/auth/account_action_specs.d.ts +1 -1
  3. package/dist/auth/account_route_schema.d.ts +2 -2
  4. package/dist/auth/account_schema.d.ts +3 -3
  5. package/dist/auth/admin_action_specs.d.ts +8 -8
  6. package/dist/auth/all_cell_actions.d.ts +35 -0
  7. package/dist/auth/all_cell_actions.d.ts.map +1 -0
  8. package/dist/auth/all_cell_actions.js +32 -0
  9. package/dist/auth/audit_log_schema.d.ts +1 -1
  10. package/dist/auth/invite_schema.d.ts +2 -2
  11. package/dist/auth/signup_route_schema.d.ts +2 -2
  12. package/dist/auth/signup_route_schema.d.ts.map +1 -1
  13. package/dist/auth/signup_route_schema.js +2 -2
  14. package/dist/primitive_schemas.d.ts +27 -1
  15. package/dist/primitive_schemas.d.ts.map +1 -1
  16. package/dist/primitive_schemas.js +55 -2
  17. package/dist/server/env.d.ts +1 -1
  18. package/dist/server/env.d.ts.map +1 -1
  19. package/dist/server/env.js +15 -3
  20. package/dist/testing/CLAUDE.md +135 -10
  21. package/dist/testing/app_server.d.ts +16 -5
  22. package/dist/testing/app_server.d.ts.map +1 -1
  23. package/dist/testing/app_server.js +3 -1
  24. package/dist/testing/cross_backend/action_manifest.d.ts +113 -0
  25. package/dist/testing/cross_backend/action_manifest.d.ts.map +1 -0
  26. package/dist/testing/cross_backend/action_manifest.js +89 -0
  27. package/dist/testing/cross_backend/action_manifest_parity.d.ts +67 -0
  28. package/dist/testing/cross_backend/action_manifest_parity.d.ts.map +1 -0
  29. package/dist/testing/cross_backend/action_manifest_parity.js +88 -0
  30. package/dist/testing/cross_backend/capabilities.d.ts +47 -30
  31. package/dist/testing/cross_backend/capabilities.d.ts.map +1 -1
  32. package/dist/testing/cross_backend/capabilities.js +19 -4
  33. package/dist/testing/cross_backend/default_backend_configs.d.ts +31 -15
  34. package/dist/testing/cross_backend/default_backend_configs.d.ts.map +1 -1
  35. package/dist/testing/cross_backend/default_backend_configs.js +30 -14
  36. package/dist/testing/cross_backend/default_spine_surface.d.ts.map +1 -1
  37. package/dist/testing/cross_backend/default_spine_surface.js +6 -0
  38. package/dist/testing/cross_backend/full_spine_mount.d.ts +81 -0
  39. package/dist/testing/cross_backend/full_spine_mount.d.ts.map +1 -0
  40. package/dist/testing/cross_backend/full_spine_mount.js +49 -0
  41. package/dist/testing/cross_backend/identity_parity.d.ts +13 -0
  42. package/dist/testing/cross_backend/identity_parity.d.ts.map +1 -0
  43. package/dist/testing/cross_backend/identity_parity.js +465 -0
  44. package/dist/testing/cross_backend/method_coverage.d.ts +71 -0
  45. package/dist/testing/cross_backend/method_coverage.d.ts.map +1 -0
  46. package/dist/testing/cross_backend/method_coverage.js +89 -0
  47. package/dist/testing/cross_backend/setup.d.ts +21 -0
  48. package/dist/testing/cross_backend/setup.d.ts.map +1 -1
  49. package/dist/testing/cross_backend/setup.js +33 -1
  50. package/dist/testing/cross_backend/testing_backdoor.d.ts.map +1 -1
  51. package/dist/testing/cross_backend/testing_backdoor.js +9 -5
  52. package/dist/testing/cross_backend/testing_reset_actions.d.ts +76 -2
  53. package/dist/testing/cross_backend/testing_reset_actions.d.ts.map +1 -1
  54. package/dist/testing/cross_backend/testing_reset_actions.js +63 -2
  55. package/dist/testing/schema_generators.d.ts.map +1 -1
  56. package/dist/testing/schema_generators.js +7 -0
  57. 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
+ };