testing/cross_backend/identity_parity.ts view source
(options: IdentityParityCrossTestOptions): void import {describe_identity_parity_cross_tests} from '@fuzdev/fuz_app/testing/cross_backend/identity_parity.js'; options
returns
void Cross-backend identity-primitive parity for fuz_app's own spine over real
HTTP — the primitive_schemas twins (Username, UsernameProvided,
Email) and the login/signup input handling that enforces them, pinned
facet-by-facet so a TS↔Rust divergence in any one surfaces as a failure:
how usernames are canonicalized on the login lookup, the ASCII-only
creation invariant that bounds what can ever be stored, and the email
format rule applied at signup.
UsernameProvided (login/lookup) canonicalizes the submitted username via
.trim().toLowerCase(), and the DB matches case-insensitively
(LOWER(username) = LOWER($1)); Username (creation) lowercases at store
and restricts to ASCII via ^[a-zA-Z][0-9a-zA-Z_-]*[0-9a-zA-Z]$. The
Rust spine must produce the same canonical form, the same case-insensitive
lookup, and the same ASCII-only rejection — or a username that logs in on
TS silently 401s on Rust, or a non-ASCII username storable on one backend
reopens the homograph-collision surface. The spec-derived round-trip +
conformance suites never vary username casing or charset, so this corner was
unpinned.
Canonicalization (login lookup):
Mixed_Case logs in via an
all-uppercase submission (proves the *lookup* folds case, not merely that
the stored form was lowercased)..trim() on the lookup path).İ (U+0130)
variant of an existing ASCII username must NOT match. Both JS
.toLowerCase() and Rust str::to_lowercase() map İ → i + U+0307
(combining dot above), never plain i, so the cased homograph stays a
distinct, non-existent username → 401 on every backend.Username-or-email login lookup: the login identifier resolves against
username or email — TS query_account_by_username_or_email, Rust the
converged query_account_with_password_hash OR-lookup (this was the
divergence the suite closed: the Rust spine previously matched username
only). An account created with an email logs in via that email,
case-insensitively (Email stores the original case; folding rides the
LOWER(email) = LOWER($1) lookup), and username login keeps working when an
email is present. A non-existent email → 401.
Login input validation: malformed login input → 400 invalid_request_body
on every spine — whitespace-only username (empty after trim), over-long
username (> 255), empty password, and an unknown body key (strict object).
TS runs the full LoginInput Zod schema; the Rust spine enforces the same
shape in account_login (the convergence the suite closed: the Rust spine
previously let three of these fall through to a lookup-miss 401, and TS let a
whitespace-only identifier through to a 401). Asserted via the error reason,
not just the status, so a same-status-wrong-body backend still fails.
ASCII-only creation invariant: a non-ASCII username is rejected at signup input validation → 400 on every backend, so no Unicode username is ever stored — the precondition the login no-collision case relies on. (ASCII-only is the intended invariant for both spines, not an accident of the TS regex.)
Length + format creation parity: the full Username shape —
[USERNAME_LENGTH_MIN, USERNAME_LENGTH_MAX] = [3, 39] and the regex
^[a-zA-Z][0-9a-zA-Z_-]*[0-9a-zA-Z]$ — pinned across both backends. The TS
Zod .min()/.max()/.regex() and the Rust hand-rolled byte-scan
(is_valid_username_for_creation) are independent reimplementations of the
same rule, so each length boundary is tested just-outside (→ 400) and
just-inside (→ 403 no-matching-invite, the settle for a valid username on a
spine with open_signup at its false default), and each format violation
(leading non-letter, trailing punctuation, embedded disallowed char) → 400,
with mid-string _/- accepted siblings as the don't-over-reject control.
Signup is also strict-object on both spines (TS z.strictObject, Rust
#[serde(deny_unknown_fields)]), so an unknown signup body key → 400.
Email format (creation): the optional signup email is validated to a
loose local@domain.tld shape on both spines — TS Email
(^[^\s@]+@[^\s@]+\.[^\s@]+$ plus a 254-byte (RFC 5321 octet) length bound; whitespace White_Space ∪ {U+FEFF}), Rust the
hand-rolled is_valid_email. A malformed email → 400, a well-formed one →
403 no-matching-invite (the same accept/reject settle the username tables
use). The accepted siblings include the single-char-TLD a@b.c that Zod's
z.email() would reject — the deliberately-looser rule both spines now
share. This closed a real divergence: the Rust spine previously
length-checked the email only, accepting any non-empty string TS rejected.
Both surfaces are flat REST on every spine, so this is an imperative suite
(not a conformance_table row) and ungated. Runs both legs via the shared
{setup_test} protocol: the in-process leg
(cross_backend/identity_parity.db.test.ts, plain gro test) and the
cross-process leg (cross_backend/identity_parity.cross.test.ts, the TS spine
binaries + Rust testing_spine_stub over real HTTP).
$lib-free by contract (relative specifiers only).
2 declarations
testing/cross_backend/identity_parity.ts view source
(options: IdentityParityCrossTestOptions): void import {describe_identity_parity_cross_tests} from '@fuzdev/fuz_app/testing/cross_backend/identity_parity.js'; optionsvoid testing/cross_backend/identity_parity.ts view source
IdentityParityCrossTestOptions import type {IdentityParityCrossTestOptions} from '@fuzdev/fuz_app/testing/cross_backend/identity_parity.js'; Options for the identity-primitive parity suite.
setup_testPer-test fixture producer (in-process or cross-process).
type SetupTest
login_path?REST login route path. Default /api/account/login (the spine convention).
type string
signup_path?REST signup route path. Default /api/account/signup (the spine convention).
type string