@fuzdev/fuz_app 0.88.0 → 0.90.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/account_action_specs.d.ts +1 -1
- package/dist/auth/account_route_schema.d.ts +12 -6
- package/dist/auth/account_route_schema.d.ts.map +1 -1
- package/dist/auth/account_route_schema.js +11 -5
- package/dist/auth/account_routes.d.ts +18 -4
- package/dist/auth/account_routes.d.ts.map +1 -1
- package/dist/auth/account_routes.js +13 -5
- package/dist/auth/account_schema.d.ts +3 -3
- package/dist/auth/admin_action_specs.d.ts +8 -8
- 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 +62 -2
- 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/body_size_smuggling.d.ts +12 -0
- package/dist/testing/cross_backend/body_size_smuggling.d.ts.map +1 -1
- package/dist/testing/cross_backend/body_size_smuggling.js +68 -41
- package/dist/testing/cross_backend/capabilities.d.ts +29 -0
- package/dist/testing/cross_backend/capabilities.d.ts.map +1 -1
- package/dist/testing/cross_backend/capabilities.js +15 -0
- package/dist/testing/cross_backend/default_backend_configs.d.ts.map +1 -1
- package/dist/testing/cross_backend/default_backend_configs.js +13 -0
- package/dist/testing/cross_backend/default_spine_surface.d.ts.map +1 -1
- package/dist/testing/cross_backend/default_spine_surface.js +7 -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/rust_spine_stub_backend_config.d.ts.map +1 -1
- package/dist/testing/cross_backend/rust_spine_stub_backend_config.js +13 -6
- package/dist/testing/cross_backend/setup.d.ts +9 -0
- package/dist/testing/cross_backend/setup.d.ts.map +1 -1
- package/dist/testing/cross_backend/setup.js +14 -1
- package/dist/testing/cross_backend/ts_spine_backend_config.d.ts.map +1 -1
- package/dist/testing/cross_backend/ts_spine_backend_config.js +16 -1
- package/dist/testing/integration.d.ts.map +1 -1
- package/dist/testing/integration.js +13 -16
- package/dist/testing/schema_generators.d.ts.map +1 -1
- package/dist/testing/schema_generators.js +7 -0
- package/package.json +3 -3
|
@@ -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
|
+
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"rust_spine_stub_backend_config.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/rust_spine_stub_backend_config.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAuC9B,OAAO,KAAK,EAAC,aAAa,EAAC,MAAM,qBAAqB,CAAC;AAQvD,uGAAuG;AACvG,eAAO,MAAM,uBAAuB,oCAAoC,CAAC;AAEzE;;;;;GAKG;AACH,eAAO,MAAM,wCAAwC,6CAA6C,CAAC;
|
|
1
|
+
{"version":3,"file":"rust_spine_stub_backend_config.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/rust_spine_stub_backend_config.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAuC9B,OAAO,KAAK,EAAC,aAAa,EAAC,MAAM,qBAAqB,CAAC;AAQvD,uGAAuG;AACvG,eAAO,MAAM,uBAAuB,oCAAoC,CAAC;AAEzE;;;;;GAKG;AACH,eAAO,MAAM,wCAAwC,6CAA6C,CAAC;AAenG,kGAAkG;AAClG,eAAO,MAAM,4BAA4B,OAAO,CAAC;AAEjD,0FAA0F;AAC1F,eAAO,MAAM,oCAAoC,sDACG,CAAC;AAErD,MAAM,WAAW,6BAA6B;IAC7C,8DAA8D;IAC9D,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IACvB,+EAA+E;IAC/E,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAC/B;;;OAGG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;CAC9B;AAED;;;;;;;;;;GAUG;AACH,eAAO,MAAM,8BAA8B,GAC1C,UAAS,6BAAkC,KACzC,aAwCF,CAAC"}
|
|
@@ -46,6 +46,18 @@ export const RUST_SPINE_STUB_BIN_ENV = 'FUZ_TESTING_RUST_SPINE_STUB_BIN';
|
|
|
46
46
|
* column-presence is engine-portable, so one file is the cross-impl contract.
|
|
47
47
|
*/
|
|
48
48
|
export const RUST_SPINE_STUB_EXPECTED_SCHEMA_PATH_ENV = 'FUZ_RUST_SPINE_STUB_EXPECTED_SCHEMA_PATH';
|
|
49
|
+
/**
|
|
50
|
+
* Capabilities for the Rust `testing_spine_stub` — `rust_default_capabilities`
|
|
51
|
+
* plus `sse` (the stub serves `GET /api/admin/audit/stream` over the spine
|
|
52
|
+
* `fuz_realtime::SseRegistry` + audit listener) and `ready` (it live-mounts
|
|
53
|
+
* `/ready` over the env-supplied fixture path). Named (not inline) so every
|
|
54
|
+
* spine preset is greppable, mirroring `ts_spine_capabilities`.
|
|
55
|
+
*/
|
|
56
|
+
const rust_spine_stub_capabilities = Object.freeze({
|
|
57
|
+
...rust_default_capabilities,
|
|
58
|
+
sse: true,
|
|
59
|
+
ready: true,
|
|
60
|
+
});
|
|
49
61
|
/** Default listening port — slots beside zzz's 1175/1176; matches the binary's `DEFAULT_PORT`. */
|
|
50
62
|
export const RUST_SPINE_STUB_DEFAULT_PORT = 1177;
|
|
51
63
|
/** Default Postgres database — real PG (PGlite isn't reachable from `tokio-postgres`). */
|
|
@@ -78,12 +90,7 @@ export const rust_spine_stub_backend_config = (options = {}) => {
|
|
|
78
90
|
// is the lower-precedence fallback — both carry the same value.
|
|
79
91
|
start_command: [binary_path, '--port', String(port)],
|
|
80
92
|
database_url,
|
|
81
|
-
|
|
82
|
-
// `fuz_realtime::SseRegistry` + audit listener), so it advertises `sse`
|
|
83
|
-
// like the TS spines — the cross-process SSE suite's three cases run. It
|
|
84
|
-
// also live-mounts `/ready` over the env-supplied fixture path, so it
|
|
85
|
-
// advertises `ready` for `describe_ready_cross_tests`.
|
|
86
|
-
capabilities: { ...rust_default_capabilities, sse: true, ready: true },
|
|
93
|
+
capabilities: rust_spine_stub_capabilities,
|
|
87
94
|
port_env_var: 'FUZ_RUST_SPINE_STUB_PORT',
|
|
88
95
|
rust_log: 'info,testing_spine_stub=info',
|
|
89
96
|
paths,
|
|
@@ -15,6 +15,15 @@ export interface CreateTestAccountOptions {
|
|
|
15
15
|
readonly username?: string;
|
|
16
16
|
readonly password_value?: string;
|
|
17
17
|
readonly roles?: Array<string>;
|
|
18
|
+
/**
|
|
19
|
+
* Optional email to store on the account, exercising the username-or-email
|
|
20
|
+
* login lookup. Cross-process this rides the production signup body
|
|
21
|
+
* (claimed against the username-scoped invite, which matches regardless of
|
|
22
|
+
* the email); in-process it lands directly on the inserted row. Must be a
|
|
23
|
+
* valid `Email` (the cross-process signup validates it) and unique per test
|
|
24
|
+
* (the `LOWER(email)` partial-unique index rejects collisions).
|
|
25
|
+
*/
|
|
26
|
+
readonly email?: string;
|
|
18
27
|
}
|
|
19
28
|
/**
|
|
20
29
|
* Shape returned by `TestFixture.create_account`. Aliased to the
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"setup.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/setup.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AA0B9B,OAAO,EAAC,IAAI,EAAC,MAAM,wBAAwB,CAAC;AAK5C,OAAO,KAAK,EAAC,WAAW,EAAC,MAAM,kBAAkB,CAAC;AAClD,OAAO,KAAK,EAAC,mBAAmB,EAAC,MAAM,mBAAmB,CAAC;AAC3D,OAAO,EAAyB,KAAK,cAAc,EAAC,MAAM,kCAAkC,CAAC;AAC7F,OAAO,KAAK,EAAC,cAAc,EAAC,MAAM,yBAAyB,CAAC;AAC5D,OAAO,KAAK,EAAC,aAAa,EAAC,MAAM,oBAAoB,CAAC;AAEtD;;;;;GAKG;AACH,MAAM,WAAW,wBAAwB;IACxC,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,cAAc,CAAC,EAAE,MAAM,CAAC;IACjC,QAAQ,CAAC,KAAK,CAAC,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;
|
|
1
|
+
{"version":3,"file":"setup.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/setup.ts"],"names":[],"mappings":"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"}
|
|
@@ -279,10 +279,22 @@ const mint_account = async (handle, options) => {
|
|
|
279
279
|
throw new Error(`invite_create(${handle.config.name}, username=${username}) returned unexpected result: ` +
|
|
280
280
|
JSON.stringify(invite_result));
|
|
281
281
|
}
|
|
282
|
+
// Thread the optional email onto the production signup body — the
|
|
283
|
+
// username-scoped invite above matches on username regardless of the
|
|
284
|
+
// email, so the account lands with the email set (exercising the
|
|
285
|
+
// username-or-email login lookup). Guard on `!== undefined` (not truthy)
|
|
286
|
+
// to match the other three email guards (`create_account` here +
|
|
287
|
+
// `app_server.ts`): "requested" is `undefined` vs present, so a
|
|
288
|
+
// caller-supplied value is forwarded verbatim and signup validates it,
|
|
289
|
+
// rather than the truthy form silently dropping an empty string.
|
|
282
290
|
const signup_response = await transport('/api/account/signup', {
|
|
283
291
|
method: 'POST',
|
|
284
292
|
headers: { 'Content-Type': 'application/json' },
|
|
285
|
-
body: JSON.stringify({
|
|
293
|
+
body: JSON.stringify({
|
|
294
|
+
username,
|
|
295
|
+
password,
|
|
296
|
+
...(options.email !== undefined && { email: options.email }),
|
|
297
|
+
}),
|
|
286
298
|
});
|
|
287
299
|
if (!signup_response.ok) {
|
|
288
300
|
const body = await signup_response.text().catch(() => '<unreadable>');
|
|
@@ -422,6 +434,7 @@ export const default_cross_process_setup = (handle, options) => {
|
|
|
422
434
|
...(account_options?.password_value !== undefined && {
|
|
423
435
|
password_value: account_options.password_value,
|
|
424
436
|
}),
|
|
437
|
+
...(account_options?.email !== undefined && { email: account_options.email }),
|
|
425
438
|
});
|
|
426
439
|
if (account_options?.roles && account_options.roles.length > 0) {
|
|
427
440
|
await grant_roles_via_offer_accept(refreshed_handle, other, account_options.roles);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ts_spine_backend_config.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/ts_spine_backend_config.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAE9B;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,KAAK,EAAC,aAAa,EAAC,MAAM,qBAAqB,CAAC;AAOvD,8GAA8G;AAC9G,eAAO,MAAM,gBAAgB,6BAA6B,CAAC;AAE3D;;;;;;;;;GASG;AACH,eAAO,MAAM,iBAAiB,4BAA4B,CAAC;
|
|
1
|
+
{"version":3,"file":"ts_spine_backend_config.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/ts_spine_backend_config.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAE9B;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,KAAK,EAAC,aAAa,EAAC,MAAM,qBAAqB,CAAC;AAOvD,8GAA8G;AAC9G,eAAO,MAAM,gBAAgB,6BAA6B,CAAC;AAE3D;;;;;;;;;GASG;AACH,eAAO,MAAM,iBAAiB,4BAA4B,CAAC;AAyB3D,6FAA6F;AAC7F,eAAO,MAAM,0BAA0B,OAAO,CAAC;AAE/C,iDAAiD;AACjD,eAAO,MAAM,0BAA0B,OAAO,CAAC;AAE/C,gDAAgD;AAChD,eAAO,MAAM,yBAAyB,OAAO,CAAC;AAE9C,yDAAyD;AACzD,eAAO,MAAM,mBAAmB,wDAAwD,CAAC;AAEzF,yDAAyD;AACzD,eAAO,MAAM,mBAAmB,wDAAwD,CAAC;AAEzF,wDAAwD;AACxD,eAAO,MAAM,kBAAkB,uDAAuD,CAAC;AAEvF,MAAM,WAAW,2BAA2B;IAC3C,mFAAmF;IACnF,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IACvB,8DAA8D;IAC9D,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;CAC/B;AAED;;;GAGG;AACH,eAAO,MAAM,4BAA4B,GACxC,UAAS,2BAAgC,KACvC,aAgBF,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,2BAA2B,GACvC,UAAS,2BAAgC,KACvC,aAgBF,CAAC;AAEF;;;;;;;;;;GAUG;AACH,eAAO,MAAM,4BAA4B,GACxC,UAAS,2BAAgC,KACvC,aA6BF,CAAC"}
|
|
@@ -20,6 +20,21 @@ export const TS_SPINE_SSE_PATH = '/api/admin/audit/stream';
|
|
|
20
20
|
* `/ready` deploy gate in `build_spine_app`).
|
|
21
21
|
*/
|
|
22
22
|
const ts_spine_capabilities = Object.freeze({ ...ts_default_capabilities, sse: true, ready: true });
|
|
23
|
+
/**
|
|
24
|
+
* Capabilities for the **Bun** spine binary — `ts_spine_capabilities` with
|
|
25
|
+
* `oversized_reject_closes_connection: false`. `Bun.serve` drains the declared
|
|
26
|
+
* `Content-Length` of an oversized-body `413` reject and keeps the socket
|
|
27
|
+
* alive (processing the correctly-framed pipelined request) even when the
|
|
28
|
+
* response carries `Connection: close`, unlike `@hono/node-server` / Deno /
|
|
29
|
+
* hyper, which close. Bun is not insecure — it frames on `Content-Length`, so
|
|
30
|
+
* there is no desync — but the smuggling suite's strong "connection closes"
|
|
31
|
+
* assertion doesn't hold; this flag routes Bun onto the suite's no-desync arm.
|
|
32
|
+
* See `docs/security.md` §"Body Size Limiting".
|
|
33
|
+
*/
|
|
34
|
+
const ts_spine_bun_capabilities = Object.freeze({
|
|
35
|
+
...ts_spine_capabilities,
|
|
36
|
+
oversized_reject_closes_connection: false,
|
|
37
|
+
});
|
|
23
38
|
/** Default port for the Node TS spine binary — slots beside the Rust `spine_stub` (1177). */
|
|
24
39
|
export const TS_SPINE_NODE_DEFAULT_PORT = 1178;
|
|
25
40
|
/** Default port for the Deno TS spine binary. */
|
|
@@ -71,7 +86,7 @@ export const ts_spine_bun_backend_config = (options = {}) => {
|
|
|
71
86
|
database_url,
|
|
72
87
|
paths,
|
|
73
88
|
extra_env: { [TS_SPINE_DIR_ENV]: paths.root },
|
|
74
|
-
capabilities:
|
|
89
|
+
capabilities: ts_spine_bun_capabilities,
|
|
75
90
|
}),
|
|
76
91
|
sse_path: TS_SPINE_SSE_PATH,
|
|
77
92
|
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"integration.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/integration.ts"],"names":[],"mappings":"AAAA,OAAO,qBAAqB,CAAC;AAsB7B,OAAO,KAAK,EAAC,cAAc,EAAC,MAAM,2BAA2B,CAAC;AAM9D,OAAO,EAKN,KAAK,uBAAuB,EAC5B,MAAM,kBAAkB,CAAC;AAkB1B,OAAO,KAAK,EAAC,cAAc,EAAC,MAAM,oBAAoB,CAAC;AACvD,OAAO,
|
|
1
|
+
{"version":3,"file":"integration.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/integration.ts"],"names":[],"mappings":"AAAA,OAAO,qBAAqB,CAAC;AAsB7B,OAAO,KAAK,EAAC,cAAc,EAAC,MAAM,2BAA2B,CAAC;AAM9D,OAAO,EAKN,KAAK,uBAAuB,EAC5B,MAAM,kBAAkB,CAAC;AAkB1B,OAAO,KAAK,EAAC,cAAc,EAAC,MAAM,oBAAoB,CAAC;AACvD,OAAO,EAAU,KAAK,mBAAmB,EAAC,MAAM,iCAAiC,CAAC;AAClF,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,0BAA0B,CAAC;AAGxD;;GAEG;AACH,MAAM,WAAW,8BAA8B;IAC9C;;;;OAIG;IACH,UAAU,EAAE,SAAS,CAAC;IACtB;;;;;;;;;OASG;IACH,cAAc,EAAE,cAAc,CAAC;IAC/B,kEAAkE;IAClE,YAAY,EAAE,mBAAmB,CAAC;IAClC;;;;OAIG;IACH,eAAe,EAAE,cAAc,CAAC,MAAM,CAAC,CAAC;IACxC;;;;;;;;;;;;OAYG;IACH,aAAa,EAAE,uBAAuB,CAAC;IACvC;;;;;;;OAOG;IACH,kBAAkB,CAAC,EAAE,MAAM,CAAC;CAC5B;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AACH,eAAO,MAAM,mCAAmC,GAC/C,SAAS,8BAA8B,KACrC,IA66CF,CAAC"}
|