@fuzdev/fuz_app 0.92.0 → 0.93.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/testing/CLAUDE.md +27 -0
- package/dist/testing/cross_backend/create_dual_spawn_global_setup.d.ts +64 -0
- package/dist/testing/cross_backend/create_dual_spawn_global_setup.d.ts.map +1 -0
- package/dist/testing/cross_backend/{create_schema_parity_global_setup.js → create_dual_spawn_global_setup.js} +1 -1
- package/dist/testing/cross_backend/default_backend_configs.d.ts +12 -0
- package/dist/testing/cross_backend/default_backend_configs.d.ts.map +1 -1
- package/dist/testing/cross_backend/default_backend_configs.js +12 -0
- package/dist/testing/cross_backend/default_spine_surface.d.ts.map +1 -1
- package/dist/testing/cross_backend/default_spine_surface.js +9 -2
- package/dist/testing/cross_backend/login_security.d.ts +11 -0
- package/dist/testing/cross_backend/login_security.d.ts.map +1 -0
- package/dist/testing/cross_backend/login_security.js +130 -0
- package/dist/testing/cross_backend/rust_spine_stub_backend_config.d.ts +17 -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 +7 -2
- package/dist/testing/cross_backend/ts_spine_backend_config.d.ts +11 -0
- package/dist/testing/cross_backend/ts_spine_backend_config.d.ts.map +1 -1
- package/dist/testing/cross_backend/ts_spine_backend_config.js +23 -7
- package/package.json +2 -1
- package/dist/testing/cross_backend/create_schema_parity_global_setup.d.ts +0 -59
- package/dist/testing/cross_backend/create_schema_parity_global_setup.d.ts.map +0 -1
package/dist/testing/CLAUDE.md
CHANGED
|
@@ -1347,6 +1347,33 @@ The accepted-case 403s rely on `create_spine_route_specs` zeroing
|
|
|
1347
1347
|
`signup_fail_floor_ms` (mirroring `login_fail_floor_ms: 0`) so they don't each
|
|
1348
1348
|
pay the 250ms denial floor.
|
|
1349
1349
|
|
|
1350
|
+
### Login rate-limit + trusted-proxy parity — `cross_backend/login_security.ts`
|
|
1351
|
+
|
|
1352
|
+
`describe_login_security_cross_tests({setup_test, login_path?})` — the imperative
|
|
1353
|
+
suite pinning **login rate limiting + `X-Forwarded-For` client-IP resolution**
|
|
1354
|
+
over real HTTP on both spines. Two cases:
|
|
1355
|
+
|
|
1356
|
+
- **per-IP limit fires** — `default_login_ip_rate_limit.max_attempts` (5, the
|
|
1357
|
+
shared cap on both impls) failed logins from one forwarded IP → 401, the next →
|
|
1358
|
+
`429` with the canonical `{error: "rate_limit_exceeded", retry_after}` body +
|
|
1359
|
+
`Retry-After: ceil(retry_after)` header (TS `rate_limit_exceeded_response` ↔
|
|
1360
|
+
Rust `route_response::rate_limit_exceeded`).
|
|
1361
|
+
- **XFF keying** — exhaust one forwarded IP to 429, then a _distinct_ forwarded IP
|
|
1362
|
+
→ 401 (not 429), proving the limiter keys on the resolved `X-Forwarded-For` IP,
|
|
1363
|
+
not the loopback TCP peer (a backend ignoring XFF would 429 the fresh IP too).
|
|
1364
|
+
|
|
1365
|
+
**Cross-process only**, on a **dedicated dual-spawn project** (`cross_backend_security`
|
|
1366
|
+
via `global_setup_login_security.ts`) — the limiters can't be enabled on a backend
|
|
1367
|
+
the standard suites share (they fire many loopback logins a live limiter would
|
|
1368
|
+
429), so the standard backends keep every limiter null. Determinism without a
|
|
1369
|
+
limiter reset: each case uses its own forwarded IP (a fresh bucket) plus its own
|
|
1370
|
+
non-existent username. Zero Rust code change — the stub's `FUZ_LOGIN_RATE_LIMIT_ENABLED`
|
|
1371
|
+
and `FUZ_TRUSTED_PROXIES` env-gates were already wired; the TS side env-gates the
|
|
1372
|
+
limiters in the binary, and `create_spine_route_specs` honors `ctx.ip_rate_limiter`.
|
|
1373
|
+
The in-process counterparts already exist (`describe_rate_limiting_tests` plus the
|
|
1374
|
+
proxy middleware tests), so there's no in-process leg. `npm run test:cross:security`.
|
|
1375
|
+
Cited property: `docs/security.md` §"Rate Limiting" + §"Trusted Proxy / Client IP".
|
|
1376
|
+
|
|
1350
1377
|
### Building a TS test-server binary — `testing_server_core.ts` + adapters
|
|
1351
1378
|
|
|
1352
1379
|
The reusable shape for standing up a **spawnable TS** cross-process test
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import '../assert_dev_env.js';
|
|
2
|
+
/**
|
|
3
|
+
* Generic **dual-spawn** vitest `globalSetup` factory — spawns + bootstraps
|
|
4
|
+
* *two* backends at once and `provide`s both serialized handles, for any
|
|
5
|
+
* cross-impl gate that needs both alive together. (The per-backend
|
|
6
|
+
* `create_cross_backend_global_setup` derives one backend from the project
|
|
7
|
+
* name; this brings up a pair.)
|
|
8
|
+
*
|
|
9
|
+
* Its primary use is the **schema- / action-manifest-parity** gates (capture
|
|
10
|
+
* each backend over a `_testing_*` introspection RPC and diff with
|
|
11
|
+
* `assert_schema_snapshots_equal` / `assert_action_manifests_equal`) — which is
|
|
12
|
+
* why the default `provide_keys` are `parity_handle_*`. The login-security gate
|
|
13
|
+
* (`global_setup_login_security.ts`) reuses it with its own keys; any future
|
|
14
|
+
* two-backend gate can too.
|
|
15
|
+
*
|
|
16
|
+
* A consumer's dual-spawn `global_setup.ts` collapses to:
|
|
17
|
+
*
|
|
18
|
+
* ```ts
|
|
19
|
+
* import {create_dual_spawn_global_setup} from
|
|
20
|
+
* '@fuzdev/fuz_app/testing/cross_backend/create_dual_spawn_global_setup.js';
|
|
21
|
+
* import {deno_backend_config, rust_backend_config} from './my_backend_config.js';
|
|
22
|
+
* import './cross_test_types.js'; // augments the two provide keys
|
|
23
|
+
*
|
|
24
|
+
* export default create_dual_spawn_global_setup({
|
|
25
|
+
* configs: {a: deno_backend_config, b: rust_backend_config},
|
|
26
|
+
* });
|
|
27
|
+
* ```
|
|
28
|
+
*
|
|
29
|
+
* The `.cross.test.ts` `inject`s both keys, rebuilds each with
|
|
30
|
+
* `reconstruct_bootstrapped_handle`, and asserts. Run this project in a
|
|
31
|
+
* later `groupOrder` than the single-backend projects (or with distinct
|
|
32
|
+
* ports) — it reuses both configs' ports, so it must not run concurrently
|
|
33
|
+
* with the per-backend projects.
|
|
34
|
+
*
|
|
35
|
+
* @module
|
|
36
|
+
*/
|
|
37
|
+
import type { TestProject } from 'vitest/node';
|
|
38
|
+
import type { BackendConfig } from './backend_config.js';
|
|
39
|
+
export interface DualSpawnGlobalSetupOptions {
|
|
40
|
+
/**
|
|
41
|
+
* The two backend config factories to spawn. `a` is spawned first; on
|
|
42
|
+
* its success `b` is spawned (with `a` torn down if `b` throws). Label
|
|
43
|
+
* them in the consuming test (e.g. via `assert_schema_snapshots_equal`'s labels).
|
|
44
|
+
*/
|
|
45
|
+
readonly configs: {
|
|
46
|
+
readonly a: () => BackendConfig;
|
|
47
|
+
readonly b: () => BackendConfig;
|
|
48
|
+
};
|
|
49
|
+
/**
|
|
50
|
+
* `project.provide` keys for the two serialized handles (read by
|
|
51
|
+
* `inject` in the test). Default `parity_handle_a` / `parity_handle_b`
|
|
52
|
+
* (the primary parity use); override for other dual-spawn gates. Augment
|
|
53
|
+
* vitest's `ProvidedContext` for whichever keys you use.
|
|
54
|
+
*/
|
|
55
|
+
readonly provide_keys?: {
|
|
56
|
+
readonly a: string;
|
|
57
|
+
readonly b: string;
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Build a vitest `globalSetup` default export that spawns both backends.
|
|
62
|
+
*/
|
|
63
|
+
export declare const create_dual_spawn_global_setup: ({ configs, provide_keys, }: DualSpawnGlobalSetupOptions) => ((project: TestProject) => Promise<() => Promise<void>>);
|
|
64
|
+
//# sourceMappingURL=create_dual_spawn_global_setup.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"create_dual_spawn_global_setup.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/create_dual_spawn_global_setup.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAE9B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AAEH,OAAO,KAAK,EAAC,WAAW,EAAC,MAAM,aAAa,CAAC;AAE7C,OAAO,KAAK,EAAC,aAAa,EAAC,MAAM,qBAAqB,CAAC;AAIvD,MAAM,WAAW,2BAA2B;IAC3C;;;;OAIG;IACH,QAAQ,CAAC,OAAO,EAAE;QAAC,QAAQ,CAAC,CAAC,EAAE,MAAM,aAAa,CAAC;QAAC,QAAQ,CAAC,CAAC,EAAE,MAAM,aAAa,CAAA;KAAC,CAAC;IACrF;;;;;OAKG;IACH,QAAQ,CAAC,YAAY,CAAC,EAAE;QAAC,QAAQ,CAAC,CAAC,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,CAAC,EAAE,MAAM,CAAA;KAAC,CAAC;CACjE;AAED;;GAEG;AACH,eAAO,MAAM,8BAA8B,GAAI,4BAG5C,2BAA2B,KAAG,CAAC,CAAC,OAAO,EAAE,WAAW,KAAK,OAAO,CAAC,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC,CAmBvF,CAAC"}
|
|
@@ -4,7 +4,7 @@ import { serialize_bootstrapped_handle } from './setup.js';
|
|
|
4
4
|
/**
|
|
5
5
|
* Build a vitest `globalSetup` default export that spawns both backends.
|
|
6
6
|
*/
|
|
7
|
-
export const
|
|
7
|
+
export const create_dual_spawn_global_setup = ({ configs, provide_keys = { a: 'parity_handle_a', b: 'parity_handle_b' }, }) => {
|
|
8
8
|
return async (project) => {
|
|
9
9
|
const a = await bootstrap_backend(configs.a());
|
|
10
10
|
// Tear `a` down if `b` fails to spawn — no orphaned child process.
|
|
@@ -35,6 +35,18 @@ import '../assert_dev_env.js';
|
|
|
35
35
|
import type { BackendBootstrapConfig, BackendConfig } from './backend_config.js';
|
|
36
36
|
import type { BackendCapabilities, BackendShapeNotes } from './capabilities.js';
|
|
37
37
|
import { type TestBackendPaths } from './build_test_backend_paths.js';
|
|
38
|
+
/**
|
|
39
|
+
* Env var both spine binaries read to enable their login rate limiters
|
|
40
|
+
* (`'true'` on / unset off). The cross-language contract for the login-security
|
|
41
|
+
* cross project: the TS binary reads it via `runtime.env_get` (a test-only flag,
|
|
42
|
+
* not in `BaseServerEnv`); the Rust `testing_spine_stub` reads it via
|
|
43
|
+
* `std::env::var` — so one backend-config option drives both impls. Shared home
|
|
44
|
+
* here because both `ts_spine_backend_config` and `rust_spine_stub_backend_config`
|
|
45
|
+
* already import this module. (The spawned TS binary re-declares the literal
|
|
46
|
+
* locally — it can't import this module, which transitively pulls `vitest` —
|
|
47
|
+
* mirroring how `testing_spine_server_node.ts` re-declares `TS_SPINE_DIR_ENV`.)
|
|
48
|
+
*/
|
|
49
|
+
export declare const LOGIN_RATE_LIMIT_ENABLED_ENV = "FUZ_LOGIN_RATE_LIMIT_ENABLED";
|
|
38
50
|
/**
|
|
39
51
|
* Capabilities shared by TS-family backends — same canonical implementation,
|
|
40
52
|
* same feature set. The non-gating wiring facts (`bearer_auth` /
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"default_backend_configs.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/default_backend_configs.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAE9B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AAEH,OAAO,KAAK,EAAC,sBAAsB,EAAE,aAAa,EAAC,MAAM,qBAAqB,CAAC;AAC/E,OAAO,KAAK,EAAC,mBAAmB,EAAE,iBAAiB,EAAC,MAAM,mBAAmB,CAAC;AAC9E,OAAO,EAA2B,KAAK,gBAAgB,EAAC,MAAM,+BAA+B,CAAC;AAQ9F;;;;GAIG;AACH,eAAO,MAAM,uBAAuB,EAAE,mBAiBpC,CAAC;AAEH;;;;GAIG;AACH,eAAO,MAAM,yBAAyB,EAAE,mBAgBtC,CAAC;AAEH;;;;;;GAMG;AACH,eAAO,MAAM,sBAAsB,EAAE,iBAInC,CAAC;AAEH;;;;;;GAMG;AACH,eAAO,MAAM,wBAAwB,EAAE,iBAIrC,CAAC;AAeH,MAAM,WAAW,iCAAiC;IACjD,gFAAgF;IAChF,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,sCAAsC;IACtC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,4DAA4D;IAC5D,QAAQ,CAAC,aAAa,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IAC9C,oDAAoD;IACpD,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAC/B,iEAAiE;IACjE,QAAQ,CAAC,SAAS,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IACtD,6CAA6C;IAC7C,QAAQ,CAAC,YAAY,CAAC,EAAE,mBAAmB,CAAC;IAC5C,wEAAwE;IACxE,QAAQ,CAAC,KAAK,CAAC,EAAE,gBAAgB,CAAC;IAClC,sEAAsE;IACtE,QAAQ,CAAC,mBAAmB,CAAC,EAAE,OAAO,CAAC,sBAAsB,CAAC,CAAC;IAC/D;;;;OAIG;IACH,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAC/B;;;;;;OAMG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;CAC9B;AAED;;;;;GAKG;AACH,eAAO,MAAM,8BAA8B,GAC1C,MAAM,iCAAiC,KACrC,aAoCF,CAAC;AAEF,MAAM,WAAW,mCAAmC;IACnD,gFAAgF;IAChF,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,sCAAsC;IACtC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,4DAA4D;IAC5D,QAAQ,CAAC,aAAa,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IAC9C;;;;OAIG;IACH,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,iEAAiE;IACjE,QAAQ,CAAC,SAAS,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IACtD,+CAA+C;IAC/C,QAAQ,CAAC,YAAY,CAAC,EAAE,mBAAmB,CAAC;IAC5C,wEAAwE;IACxE,QAAQ,CAAC,KAAK,CAAC,EAAE,gBAAgB,CAAC;IAClC,sEAAsE;IACtE,QAAQ,CAAC,mBAAmB,CAAC,EAAE,OAAO,CAAC,sBAAsB,CAAC,CAAC;IAC/D;;;;OAIG;IACH,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAC/B;;;;OAIG;IACH,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B;;;OAGG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;CAC9B;AAED;;;;;GAKG;AACH,eAAO,MAAM,gCAAgC,GAC5C,MAAM,mCAAmC,KACvC,aA4CF,CAAC"}
|
|
1
|
+
{"version":3,"file":"default_backend_configs.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/default_backend_configs.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAE9B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AAEH,OAAO,KAAK,EAAC,sBAAsB,EAAE,aAAa,EAAC,MAAM,qBAAqB,CAAC;AAC/E,OAAO,KAAK,EAAC,mBAAmB,EAAE,iBAAiB,EAAC,MAAM,mBAAmB,CAAC;AAC9E,OAAO,EAA2B,KAAK,gBAAgB,EAAC,MAAM,+BAA+B,CAAC;AAQ9F;;;;;;;;;;GAUG;AACH,eAAO,MAAM,4BAA4B,iCAAiC,CAAC;AAE3E;;;;GAIG;AACH,eAAO,MAAM,uBAAuB,EAAE,mBAiBpC,CAAC;AAEH;;;;GAIG;AACH,eAAO,MAAM,yBAAyB,EAAE,mBAgBtC,CAAC;AAEH;;;;;;GAMG;AACH,eAAO,MAAM,sBAAsB,EAAE,iBAInC,CAAC;AAEH;;;;;;GAMG;AACH,eAAO,MAAM,wBAAwB,EAAE,iBAIrC,CAAC;AAeH,MAAM,WAAW,iCAAiC;IACjD,gFAAgF;IAChF,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,sCAAsC;IACtC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,4DAA4D;IAC5D,QAAQ,CAAC,aAAa,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IAC9C,oDAAoD;IACpD,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAC/B,iEAAiE;IACjE,QAAQ,CAAC,SAAS,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IACtD,6CAA6C;IAC7C,QAAQ,CAAC,YAAY,CAAC,EAAE,mBAAmB,CAAC;IAC5C,wEAAwE;IACxE,QAAQ,CAAC,KAAK,CAAC,EAAE,gBAAgB,CAAC;IAClC,sEAAsE;IACtE,QAAQ,CAAC,mBAAmB,CAAC,EAAE,OAAO,CAAC,sBAAsB,CAAC,CAAC;IAC/D;;;;OAIG;IACH,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAC/B;;;;;;OAMG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;CAC9B;AAED;;;;;GAKG;AACH,eAAO,MAAM,8BAA8B,GAC1C,MAAM,iCAAiC,KACrC,aAoCF,CAAC;AAEF,MAAM,WAAW,mCAAmC;IACnD,gFAAgF;IAChF,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,sCAAsC;IACtC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,4DAA4D;IAC5D,QAAQ,CAAC,aAAa,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IAC9C;;;;OAIG;IACH,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,iEAAiE;IACjE,QAAQ,CAAC,SAAS,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IACtD,+CAA+C;IAC/C,QAAQ,CAAC,YAAY,CAAC,EAAE,mBAAmB,CAAC;IAC5C,wEAAwE;IACxE,QAAQ,CAAC,KAAK,CAAC,EAAE,gBAAgB,CAAC;IAClC,sEAAsE;IACtE,QAAQ,CAAC,mBAAmB,CAAC,EAAE,OAAO,CAAC,sBAAsB,CAAC,CAAC;IAC/D;;;;OAIG;IACH,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAC/B;;;;OAIG;IACH,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B;;;OAGG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;CAC9B;AAED;;;;;GAKG;AACH,eAAO,MAAM,gCAAgC,GAC5C,MAAM,mCAAmC,KACvC,aA4CF,CAAC"}
|
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
import '../assert_dev_env.js';
|
|
2
2
|
import { build_test_backend_paths } from './build_test_backend_paths.js';
|
|
3
3
|
import { default_test_bootstrap_token, default_test_cookie_keys, default_test_keeper_password, default_test_keeper_username, } from './default_secrets.js';
|
|
4
|
+
/**
|
|
5
|
+
* Env var both spine binaries read to enable their login rate limiters
|
|
6
|
+
* (`'true'` on / unset off). The cross-language contract for the login-security
|
|
7
|
+
* cross project: the TS binary reads it via `runtime.env_get` (a test-only flag,
|
|
8
|
+
* not in `BaseServerEnv`); the Rust `testing_spine_stub` reads it via
|
|
9
|
+
* `std::env::var` — so one backend-config option drives both impls. Shared home
|
|
10
|
+
* here because both `ts_spine_backend_config` and `rust_spine_stub_backend_config`
|
|
11
|
+
* already import this module. (The spawned TS binary re-declares the literal
|
|
12
|
+
* locally — it can't import this module, which transitively pulls `vitest` —
|
|
13
|
+
* mirroring how `testing_spine_server_node.ts` re-declares `TS_SPINE_DIR_ENV`.)
|
|
14
|
+
*/
|
|
15
|
+
export const LOGIN_RATE_LIMIT_ENABLED_ENV = 'FUZ_LOGIN_RATE_LIMIT_ENABLED';
|
|
4
16
|
/**
|
|
5
17
|
* Capabilities shared by TS-family backends — same canonical implementation,
|
|
6
18
|
* same feature set. The non-gating wiring facts (`bearer_auth` /
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"default_spine_surface.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/default_spine_surface.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAE9B;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAEH,OAAO,KAAK,EAAC,MAAM,EAAC,MAAM,yBAAyB,CAAC;AAIpD,OAAO,KAAK,EAAC,kBAAkB,EAAC,MAAM,8CAA8C,CAAC;AACrF,OAAO,EAAqB,KAAK,gBAAgB,EAAC,MAAM,2BAA2B,CAAC;AACpF,OAAO,EAAwB,KAAK,cAAc,EAAC,MAAM,8BAA8B,CAAC;AAIxF,OAAO,EAAqB,KAAK,SAAS,EAAC,MAAM,0BAA0B,CAAC;AAC5E,OAAO,KAAK,EAAC,cAAc,EAAE,eAAe,EAAC,MAAM,uBAAuB,CAAC;AAC3E,OAAO,KAAK,EAAC,gBAAgB,EAAC,MAAM,oCAAoC,CAAC;AAGzE;;;GAGG;AACH,eAAO,MAAM,qBAAqB,EAAE,cAAc,CAAC,MAAM,CAAwC,CAAC;AAElG;;;;;;GAMG;AACH,eAAO,MAAM,sBAAsB,gBAAgB,CAAC;AAEpD;;;;;;GAMG;AACH,eAAO,MAAM,WAAW,EAAE,gBAExB,CAAC;AAEH,iEAAiE;AACjE,eAAO,MAAM,cAAc,aAAa,CAAC;AAEzC;;;;;;GAMG;AACH,eAAO,MAAM,cAAc,4BAA4B,CAAC;AAExD,+CAA+C;AAC/C,MAAM,WAAW,wBAAwB;IACxC;;;;;;;;;;;;;;;OAeG;IACH,QAAQ,CAAC,mBAAmB,CAAC,EAAE,kBAAkB,GAAG,IAAI,CAAC;CACzD;AAED;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,mBAAmB,GAC/B,KAAK,gBAAgB,EACrB,UAAU,wBAAwB,KAChC,KAAK,CAAC,eAAe,CAQvB,CAAC;AAEF;;;;;;;;;;GAUG;AACH,eAAO,MAAM,wBAAwB,GAAI,KAAK,gBAAgB,KAAG,KAAK,CAAC,SAAS,
|
|
1
|
+
{"version":3,"file":"default_spine_surface.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/default_spine_surface.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAE9B;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAEH,OAAO,KAAK,EAAC,MAAM,EAAC,MAAM,yBAAyB,CAAC;AAIpD,OAAO,KAAK,EAAC,kBAAkB,EAAC,MAAM,8CAA8C,CAAC;AACrF,OAAO,EAAqB,KAAK,gBAAgB,EAAC,MAAM,2BAA2B,CAAC;AACpF,OAAO,EAAwB,KAAK,cAAc,EAAC,MAAM,8BAA8B,CAAC;AAIxF,OAAO,EAAqB,KAAK,SAAS,EAAC,MAAM,0BAA0B,CAAC;AAC5E,OAAO,KAAK,EAAC,cAAc,EAAE,eAAe,EAAC,MAAM,uBAAuB,CAAC;AAC3E,OAAO,KAAK,EAAC,gBAAgB,EAAC,MAAM,oCAAoC,CAAC;AAGzE;;;GAGG;AACH,eAAO,MAAM,qBAAqB,EAAE,cAAc,CAAC,MAAM,CAAwC,CAAC;AAElG;;;;;;GAMG;AACH,eAAO,MAAM,sBAAsB,gBAAgB,CAAC;AAEpD;;;;;;GAMG;AACH,eAAO,MAAM,WAAW,EAAE,gBAExB,CAAC;AAEH,iEAAiE;AACjE,eAAO,MAAM,cAAc,aAAa,CAAC;AAEzC;;;;;;GAMG;AACH,eAAO,MAAM,cAAc,4BAA4B,CAAC;AAExD,+CAA+C;AAC/C,MAAM,WAAW,wBAAwB;IACxC;;;;;;;;;;;;;;;OAeG;IACH,QAAQ,CAAC,mBAAmB,CAAC,EAAE,kBAAkB,GAAG,IAAI,CAAC;CACzD;AAED;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,mBAAmB,GAC/B,KAAK,gBAAgB,EACrB,UAAU,wBAAwB,KAChC,KAAK,CAAC,eAAe,CAQvB,CAAC;AAEF;;;;;;;;;;GAUG;AACH,eAAO,MAAM,wBAAwB,GAAI,KAAK,gBAAgB,KAAG,KAAK,CAAC,SAAS,CA+B/E,CAAC;AAEF;;;;;;;;;;GAUG;AACH,eAAO,MAAM,yBAAyB,EAAE,GAAwD,CAAC;AAEjG;;;;;;;;;GASG;AACH,eAAO,MAAM,6BAA6B,GAAI,MAAM,MAAM,KAAG,SAC6B,CAAC;AAE3F;;;;;;;;GAQG;AACH,eAAO,MAAM,yBAAyB,QAAO,cAM1C,CAAC"}
|
|
@@ -77,8 +77,15 @@ export const create_spine_route_specs = (ctx) => [
|
|
|
77
77
|
...prefix_route_specs('/api/account', [
|
|
78
78
|
...create_account_route_specs(ctx.deps, {
|
|
79
79
|
session_options: spine_session_options,
|
|
80
|
-
|
|
81
|
-
login_account_rate_limiter
|
|
80
|
+
// Honor the limiters the server was assembled with (on `AppServerContext`
|
|
81
|
+
// per the `ip_rate_limiter` / `login_account_rate_limiter` TSDoc) rather
|
|
82
|
+
// than hardcoding `null`. The standard spine binary passes `null` (the
|
|
83
|
+
// cross suites fire many loopback logins and must not trip a limiter), so
|
|
84
|
+
// this is behavior-preserving there; the dedicated login-security cross
|
|
85
|
+
// backend passes real limiters to exercise the 429 + `Retry-After` path
|
|
86
|
+
// and XFF-keyed bucketing over the wire (see `login_security.ts`).
|
|
87
|
+
ip_rate_limiter: ctx.ip_rate_limiter,
|
|
88
|
+
login_account_rate_limiter: ctx.login_account_rate_limiter,
|
|
82
89
|
login_fail_floor_ms: 0,
|
|
83
90
|
bootstrap_status: ctx.bootstrap_status,
|
|
84
91
|
}),
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import '../assert_dev_env.js';
|
|
2
|
+
import type { SetupTest } from './setup.js';
|
|
3
|
+
/** Options for the login-security parity suite. */
|
|
4
|
+
export interface LoginSecurityCrossTestOptions {
|
|
5
|
+
/** Per-test fixture producer (cross-process only — see the module doc). */
|
|
6
|
+
readonly setup_test: SetupTest;
|
|
7
|
+
/** REST login route path. Default `/api/account/login` (the spine convention). */
|
|
8
|
+
readonly login_path?: string;
|
|
9
|
+
}
|
|
10
|
+
export declare const describe_login_security_cross_tests: (options: LoginSecurityCrossTestOptions) => void;
|
|
11
|
+
//# sourceMappingURL=login_security.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"login_security.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/login_security.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAoD9B,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,YAAY,CAAC;AAE1C,mDAAmD;AACnD,MAAM,WAAW,6BAA6B;IAC7C,2EAA2E;IAC3E,QAAQ,CAAC,UAAU,EAAE,SAAS,CAAC;IAC/B,kFAAkF;IAClF,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;CAC7B;AAyBD,eAAO,MAAM,mCAAmC,GAC/C,SAAS,6BAA6B,KACpC,IAoGF,CAAC"}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import '../assert_dev_env.js';
|
|
2
|
+
/**
|
|
3
|
+
* Cross-backend parity suite for **login rate limiting + trusted-proxy
|
|
4
|
+
* (X-Forwarded-For) resolution** over real HTTP.
|
|
5
|
+
*
|
|
6
|
+
* Login throttling and client-IP resolution are wired-but-never-crossed: the
|
|
7
|
+
* in-process `describe_rate_limiting_tests` covers the limiter, and the
|
|
8
|
+
* `fuz_http` / proxy middleware unit tests cover XFF resolution, but no case
|
|
9
|
+
* exercised either over a real socket on both impls — the limiter is nulled
|
|
10
|
+
* on every standard cross backend and the resolved client IP has no
|
|
11
|
+
* wire-observable downstream there. This dedicated suite spawns a backend
|
|
12
|
+
* with the login limiters enabled + the loopback proxy trusted (see
|
|
13
|
+
* `global_setup_login_security.ts`), then pins two properties end-to-end:
|
|
14
|
+
*
|
|
15
|
+
* - **per-IP login limit fires** — the first `default_login_ip_rate_limit`
|
|
16
|
+
* failed logins from one forwarded IP each return `401`, and the next
|
|
17
|
+
* returns `429` with the canonical `{error: "rate_limit_exceeded",
|
|
18
|
+
* retry_after}` body **and** a `Retry-After: ceil(retry_after)` header. The
|
|
19
|
+
* 429 wire shape is the cross-impl contract — TS `rate_limit_exceeded_response`
|
|
20
|
+
* and Rust `route_response::rate_limit_exceeded` must agree.
|
|
21
|
+
* - **trusted-proxy / XFF resolution is honored** — distinct
|
|
22
|
+
* `X-Forwarded-For` IPs get independent buckets: after exhausting one
|
|
23
|
+
* forwarded IP to `429`, a *different* forwarded IP is unaffected (`401`,
|
|
24
|
+
* not `429`). A backend that ignored XFF and keyed on the (loopback) TCP
|
|
25
|
+
* peer would 429 the fresh-IP request too — so the `401` proves the limiter
|
|
26
|
+
* keys on the resolved `X-Forwarded-For` client IP.
|
|
27
|
+
*
|
|
28
|
+
* **Determinism without a limiter reset.** Limiter state is in-memory and the
|
|
29
|
+
* per-test `_testing_reset` wipes only the DB, never the buckets — so each
|
|
30
|
+
* case uses its own forwarded IP and its own (non-existent) username, keeping
|
|
31
|
+
* every bucket independent across cases and across the two impls (separate
|
|
32
|
+
* processes). The login floor is zeroed on both spines, so the failed-login
|
|
33
|
+
* loop stays fast.
|
|
34
|
+
*
|
|
35
|
+
* Both surfaces are flat REST (`POST /api/account/login`) on every spine, so
|
|
36
|
+
* this is an imperative suite (not a `conformance_table` row) — the sibling of
|
|
37
|
+
* `origin.ts` / `identity_parity.ts`. Cross-process only: the limiter+proxy
|
|
38
|
+
* wiring is the point, and the in-process counterparts already exist
|
|
39
|
+
* (`describe_rate_limiting_tests` + the proxy middleware tests). Cited
|
|
40
|
+
* property: `docs/security.md` §"Rate Limiting" + §"Trusted Proxy / Client IP".
|
|
41
|
+
*
|
|
42
|
+
* `$lib`-free by contract (relative specifiers only), like the sibling
|
|
43
|
+
* cross-backend suites.
|
|
44
|
+
*
|
|
45
|
+
* @module
|
|
46
|
+
*/
|
|
47
|
+
import { describe, test, assert } from 'vitest';
|
|
48
|
+
import { ERROR_RATE_LIMIT_EXCEEDED } from '../../http/error_schemas.js';
|
|
49
|
+
import { default_login_account_rate_limit, default_login_ip_rate_limit } from '../../rate_limiter.js';
|
|
50
|
+
/**
|
|
51
|
+
* RFC 5737 TEST-NET-3 (`203.0.113.0/24`) documentation addresses — guaranteed
|
|
52
|
+
* non-routable, so they never collide with a real client IP. One per bucket the
|
|
53
|
+
* suite needs; distinct across cases so no case inherits another's count (the
|
|
54
|
+
* limiter is not reset between cases). Spoofed via `X-Forwarded-For`; the
|
|
55
|
+
* loopback TCP peer is trusted, so the rightmost untrusted hop (these) is the
|
|
56
|
+
* resolved client IP.
|
|
57
|
+
*/
|
|
58
|
+
const XFF_IP_LIMIT = '203.0.113.1';
|
|
59
|
+
const XFF_SEGREGATION_EXHAUST = '203.0.113.2';
|
|
60
|
+
const XFF_SEGREGATION_FRESH = '203.0.113.3';
|
|
61
|
+
/** A non-routable, non-existent identifier — login resolves to the not-found path (records the bucket, returns 401). */
|
|
62
|
+
const PROBE_PASSWORD = 'login_security_wrong_password';
|
|
63
|
+
export const describe_login_security_cross_tests = (options) => {
|
|
64
|
+
const { setup_test } = options;
|
|
65
|
+
const login_path = options.login_path ?? '/api/account/login';
|
|
66
|
+
/** Per-IP cap shared by both impls (TS `default_login_ip_rate_limit`, Rust `DEFAULT_LOGIN_IP_RATE_LIMIT`). */
|
|
67
|
+
const ip_limit = default_login_ip_rate_limit.max_attempts;
|
|
68
|
+
// The XFF-segregation case enables both limiters (one flag drives both), so
|
|
69
|
+
// the per-account bucket is live too. After exhausting an IP, the shared
|
|
70
|
+
// username holds exactly `ip_limit` account records, so the fresh-IP probe
|
|
71
|
+
// only stays a 401 (not 429) while the account cap sits above the IP cap.
|
|
72
|
+
// Assert the ordering so a future cap change fails loud here instead of as a
|
|
73
|
+
// confusing 429-instead-of-401 mid-case.
|
|
74
|
+
assert(default_login_account_rate_limit.max_attempts > ip_limit, 'login-security XFF segregation needs the per-account cap above the per-IP cap');
|
|
75
|
+
/**
|
|
76
|
+
* POST a login with the given `X-Forwarded-For` on a fresh (cookie-jar-free)
|
|
77
|
+
* transport — wrong password against a non-existent username, so the handler
|
|
78
|
+
* clears origin + input validation, records the resolved-IP bucket, and
|
|
79
|
+
* returns `401` (or `429` once the bucket trips). Always reads the body so
|
|
80
|
+
* the cross-process socket releases.
|
|
81
|
+
*/
|
|
82
|
+
const attempt = async (fixture, forwarded_for, username) => {
|
|
83
|
+
const res = await fixture.fresh_transport()(login_path, {
|
|
84
|
+
method: 'POST',
|
|
85
|
+
headers: { 'content-type': 'application/json', 'x-forwarded-for': forwarded_for },
|
|
86
|
+
body: JSON.stringify({ username, password: PROBE_PASSWORD }),
|
|
87
|
+
});
|
|
88
|
+
const body = (await res.json().catch(() => undefined));
|
|
89
|
+
return {
|
|
90
|
+
status: res.status,
|
|
91
|
+
error: typeof body?.error === 'string' ? body.error : undefined,
|
|
92
|
+
retry_after: typeof body?.retry_after === 'number' ? body.retry_after : undefined,
|
|
93
|
+
retry_after_header: res.headers.get('retry-after'),
|
|
94
|
+
};
|
|
95
|
+
};
|
|
96
|
+
describe('login rate limiting + trusted-proxy parity', () => {
|
|
97
|
+
test(`per-IP login limit fires 429 + Retry-After after ${ip_limit} attempts from one forwarded IP`, async () => {
|
|
98
|
+
const fixture = await setup_test();
|
|
99
|
+
// The first `ip_limit` failed logins from one forwarded IP each clear the
|
|
100
|
+
// limiter (a normal 401), recording against the resolved client IP.
|
|
101
|
+
for (let i = 0; i < ip_limit; i++) {
|
|
102
|
+
const r = await attempt(fixture, XFF_IP_LIMIT, 'probe_ip_limit');
|
|
103
|
+
assert.strictEqual(r.status, 401, `attempt ${i + 1}/${ip_limit} must be a normal 401, not rate-limited`);
|
|
104
|
+
}
|
|
105
|
+
// The next request from the SAME forwarded IP trips the per-IP bucket.
|
|
106
|
+
const limited = await attempt(fixture, XFF_IP_LIMIT, 'probe_ip_limit');
|
|
107
|
+
assert.strictEqual(limited.status, 429, `attempt ${ip_limit + 1} must be 429`);
|
|
108
|
+
assert.strictEqual(limited.error, ERROR_RATE_LIMIT_EXCEEDED, '429 body must carry the canonical rate_limit_exceeded reason');
|
|
109
|
+
assert(limited.retry_after !== undefined && limited.retry_after > 0, '429 body must carry a positive numeric retry_after');
|
|
110
|
+
assert(limited.retry_after_header !== null, 'Retry-After header must be present on the 429');
|
|
111
|
+
assert.strictEqual(Number(limited.retry_after_header), Math.ceil(limited.retry_after), 'Retry-After header must equal ceil(retry_after)');
|
|
112
|
+
});
|
|
113
|
+
test('distinct X-Forwarded-For IPs get independent buckets (XFF resolution honored)', async () => {
|
|
114
|
+
const fixture = await setup_test();
|
|
115
|
+
// Exhaust one forwarded IP to its cap (own username, so the per-account
|
|
116
|
+
// bucket never confounds — these stay well under the account cap).
|
|
117
|
+
for (let i = 0; i < ip_limit; i++) {
|
|
118
|
+
await attempt(fixture, XFF_SEGREGATION_EXHAUST, 'probe_xff');
|
|
119
|
+
}
|
|
120
|
+
const exhausted = await attempt(fixture, XFF_SEGREGATION_EXHAUST, 'probe_xff');
|
|
121
|
+
assert.strictEqual(exhausted.status, 429, 'the exhausted forwarded IP must be rate-limited');
|
|
122
|
+
// A DIFFERENT forwarded IP is a fresh bucket → not limited. If the backend
|
|
123
|
+
// ignored XFF and keyed on the loopback TCP peer, this would land in the
|
|
124
|
+
// already-exhausted peer bucket and 429 — so a 401 proves the limiter keys
|
|
125
|
+
// on the resolved X-Forwarded-For client IP.
|
|
126
|
+
const fresh = await attempt(fixture, XFF_SEGREGATION_FRESH, 'probe_xff');
|
|
127
|
+
assert.strictEqual(fresh.status, 401, "a fresh forwarded IP must not inherit another IP's limit");
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
};
|
|
@@ -23,6 +23,23 @@ export interface SpineStubBackendConfigOptions {
|
|
|
23
23
|
* var. When neither is set the preset throws.
|
|
24
24
|
*/
|
|
25
25
|
readonly binary_path?: string;
|
|
26
|
+
/**
|
|
27
|
+
* Enable the per-IP + per-account login rate limiters on the stub
|
|
28
|
+
* (`FUZ_LOGIN_RATE_LIMIT_ENABLED=true`). Off by default — the standard cross
|
|
29
|
+
* suites fire many loopback logins a live limiter would 429. Set `true` only
|
|
30
|
+
* for the dedicated login-security cross project (`global_setup_login_security.ts`).
|
|
31
|
+
* Pair with `trusted_proxies` so the limiter keys on the resolved
|
|
32
|
+
* `X-Forwarded-For` client IP. Mirrors `TsSpineBackendConfigOptions.enable_login_rate_limit`.
|
|
33
|
+
*/
|
|
34
|
+
readonly enable_login_rate_limit?: boolean;
|
|
35
|
+
/**
|
|
36
|
+
* Comma-separated trusted-proxy allowlist passed as `FUZ_TRUSTED_PROXIES`
|
|
37
|
+
* (e.g. `'127.0.0.1,::1'`). Unset by default (the stub leaves XFF parsing
|
|
38
|
+
* off, keying on the raw TCP peer). The login-security project sets the
|
|
39
|
+
* loopback set so the limiter keys on the `X-Forwarded-For` client IP — the
|
|
40
|
+
* TS spine binary wires the equivalent set unconditionally.
|
|
41
|
+
*/
|
|
42
|
+
readonly trusted_proxies?: string;
|
|
26
43
|
}
|
|
27
44
|
/**
|
|
28
45
|
* Build the `BackendConfig` for `testing_spine_stub`. Resolves the binary
|
|
@@ -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;
|
|
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;AASvD,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;IAC9B;;;;;;;OAOG;IACH,QAAQ,CAAC,uBAAuB,CAAC,EAAE,OAAO,CAAC;IAC3C;;;;;;OAMG;IACH,QAAQ,CAAC,eAAe,CAAC,EAAE,MAAM,CAAC;CAClC;AAED;;;;;;;;;;GAUG;AACH,eAAO,MAAM,8BAA8B,GAC1C,UAAS,6BAAkC,KACzC,aA+CF,CAAC"}
|
|
@@ -36,7 +36,7 @@ import '../assert_dev_env.js';
|
|
|
36
36
|
import { fileURLToPath } from 'node:url';
|
|
37
37
|
import { build_test_backend_paths } from './build_test_backend_paths.js';
|
|
38
38
|
import { SPINE_EXPECTED_SCHEMA_URL } from './default_spine_surface.js';
|
|
39
|
-
import { make_default_rust_backend_config, rust_default_capabilities, } from './default_backend_configs.js';
|
|
39
|
+
import { LOGIN_RATE_LIMIT_ENABLED_ENV, make_default_rust_backend_config, rust_default_capabilities, } from './default_backend_configs.js';
|
|
40
40
|
/** Env var naming the prebuilt `testing_spine_stub` binary. Required when `binary_path` is omitted. */
|
|
41
41
|
export const RUST_SPINE_STUB_BIN_ENV = 'FUZ_TESTING_RUST_SPINE_STUB_BIN';
|
|
42
42
|
/**
|
|
@@ -74,7 +74,7 @@ export const RUST_SPINE_STUB_DEFAULT_DATABASE_URL = 'postgres://localhost/fuz_ap
|
|
|
74
74
|
* @throws Error when no binary path is available.
|
|
75
75
|
*/
|
|
76
76
|
export const rust_spine_stub_backend_config = (options = {}) => {
|
|
77
|
-
const { port = RUST_SPINE_STUB_DEFAULT_PORT, database_url = RUST_SPINE_STUB_DEFAULT_DATABASE_URL, binary_path = process.env[RUST_SPINE_STUB_BIN_ENV], } = options;
|
|
77
|
+
const { port = RUST_SPINE_STUB_DEFAULT_PORT, database_url = RUST_SPINE_STUB_DEFAULT_DATABASE_URL, binary_path = process.env[RUST_SPINE_STUB_BIN_ENV], enable_login_rate_limit, trusted_proxies, } = options;
|
|
78
78
|
if (!binary_path) {
|
|
79
79
|
throw new Error(`rust_spine_stub_backend_config: no binary path — set ${RUST_SPINE_STUB_BIN_ENV} to a prebuilt ` +
|
|
80
80
|
'`testing_spine_stub` binary (build it with `cargo build -p testing_spine_stub --release`) ' +
|
|
@@ -105,6 +105,11 @@ export const rust_spine_stub_backend_config = (options = {}) => {
|
|
|
105
105
|
// file the TS spine reads, so the two backends share one cross-impl
|
|
106
106
|
// contract.
|
|
107
107
|
[RUST_SPINE_STUB_EXPECTED_SCHEMA_PATH_ENV]: fileURLToPath(SPINE_EXPECTED_SCHEMA_URL),
|
|
108
|
+
// Login-security project opt-ins (off by default): enable the login
|
|
109
|
+
// limiters + trust the loopback proxy so the limiter keys on the
|
|
110
|
+
// resolved `X-Forwarded-For` IP. The stub reads both directly.
|
|
111
|
+
...(enable_login_rate_limit ? { [LOGIN_RATE_LIMIT_ENABLED_ENV]: 'true' } : {}),
|
|
112
|
+
...(trusted_proxies !== undefined ? { FUZ_TRUSTED_PROXIES: trusted_proxies } : {}),
|
|
108
113
|
},
|
|
109
114
|
});
|
|
110
115
|
};
|
|
@@ -47,6 +47,17 @@ export interface TsSpineBackendConfigOptions {
|
|
|
47
47
|
readonly port?: number;
|
|
48
48
|
/** Database URL. Default `'memory://'` (in-memory PGlite). */
|
|
49
49
|
readonly database_url?: string;
|
|
50
|
+
/**
|
|
51
|
+
* Enable the per-IP + per-account login rate limiters on the spawned binary
|
|
52
|
+
* (`FUZ_LOGIN_RATE_LIMIT_ENABLED=true`). Off by default — the standard cross
|
|
53
|
+
* suites fire many loopback logins a live limiter would 429. Set `true` only
|
|
54
|
+
* for the dedicated login-security cross project (`global_setup_login_security.ts`),
|
|
55
|
+
* which drives the 429 + `Retry-After` path and XFF-keyed bucketing over the
|
|
56
|
+
* wire (`login_security.ts`). The binary always wires `trusted_proxies` for
|
|
57
|
+
* `127.0.0.1`/`::1`, so the limiter keys on the resolved `X-Forwarded-For`
|
|
58
|
+
* client IP. Mirrors `SpineStubBackendConfigOptions.enable_login_rate_limit`.
|
|
59
|
+
*/
|
|
60
|
+
readonly enable_login_rate_limit?: boolean;
|
|
50
61
|
}
|
|
51
62
|
/**
|
|
52
63
|
* `BackendConfig` for the Node TS spine binary — spawned via `gro run`
|
|
@@ -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;
|
|
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;AAQvD,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;IAC/B;;;;;;;;;OASG;IACH,QAAQ,CAAC,uBAAuB,CAAC,EAAE,OAAO,CAAC;CAC3C;AAWD;;;GAGG;AACH,eAAO,MAAM,4BAA4B,GACxC,UAAS,2BAAgC,KACvC,aAmBF,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,2BAA2B,GACvC,UAAS,2BAAgC,KACvC,aAmBF,CAAC;AAEF;;;;;;;;;;GAUG;AACH,eAAO,MAAM,4BAA4B,GACxC,UAAS,2BAAgC,KACvC,aAgCF,CAAC"}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import '../assert_dev_env.js';
|
|
2
2
|
import { build_test_backend_paths } from './build_test_backend_paths.js';
|
|
3
|
-
import { make_default_ts_backend_config, ts_default_capabilities, } from './default_backend_configs.js';
|
|
3
|
+
import { LOGIN_RATE_LIMIT_ENABLED_ENV, make_default_ts_backend_config, ts_default_capabilities, } from './default_backend_configs.js';
|
|
4
4
|
/** Env var naming the backend root dir; `{dir}/run/daemon_token` must match `bootstrap.daemon_token_path`. */
|
|
5
5
|
export const TS_SPINE_DIR_ENV = 'FUZ_TESTING_TS_SPINE_DIR';
|
|
6
6
|
/**
|
|
@@ -47,12 +47,19 @@ export const TS_SPINE_NODE_ENTRY = 'src/test/cross_backend/testing_spine_server_
|
|
|
47
47
|
export const TS_SPINE_DENO_ENTRY = 'src/test/cross_backend/testing_spine_server_deno.ts';
|
|
48
48
|
/** Entry module spawned for the Bun TS spine binary. */
|
|
49
49
|
export const TS_SPINE_BUN_ENTRY = 'src/test/cross_backend/testing_spine_server_bun.ts';
|
|
50
|
+
/**
|
|
51
|
+
* `extra_env` carrying the login-rate-limit toggle when enabled. The TS binary
|
|
52
|
+
* reads `FUZ_LOGIN_RATE_LIMIT_ENABLED` directly (a test-only flag, not in
|
|
53
|
+
* `BaseServerEnv`); the same env-var name the Rust spine-stub reads, so one
|
|
54
|
+
* backend-config option drives both impls.
|
|
55
|
+
*/
|
|
56
|
+
const login_rate_limit_env = (enable) => enable ? { [LOGIN_RATE_LIMIT_ENABLED_ENV]: 'true' } : {};
|
|
50
57
|
/**
|
|
51
58
|
* `BackendConfig` for the Node TS spine binary — spawned via `gro run`
|
|
52
59
|
* (Gro's TS loader resolves the `$lib`-free entry's relative imports).
|
|
53
60
|
*/
|
|
54
61
|
export const ts_spine_node_backend_config = (options = {}) => {
|
|
55
|
-
const { port = TS_SPINE_NODE_DEFAULT_PORT, database_url } = options;
|
|
62
|
+
const { port = TS_SPINE_NODE_DEFAULT_PORT, database_url, enable_login_rate_limit } = options;
|
|
56
63
|
const name = 'ts_spine_node';
|
|
57
64
|
const paths = build_test_backend_paths(name);
|
|
58
65
|
return {
|
|
@@ -62,7 +69,10 @@ export const ts_spine_node_backend_config = (options = {}) => {
|
|
|
62
69
|
start_command: ['gro', 'run', TS_SPINE_NODE_ENTRY],
|
|
63
70
|
database_url,
|
|
64
71
|
paths,
|
|
65
|
-
extra_env: {
|
|
72
|
+
extra_env: {
|
|
73
|
+
[TS_SPINE_DIR_ENV]: paths.root,
|
|
74
|
+
...login_rate_limit_env(enable_login_rate_limit),
|
|
75
|
+
},
|
|
66
76
|
capabilities: ts_spine_capabilities,
|
|
67
77
|
}),
|
|
68
78
|
sse_path: TS_SPINE_SSE_PATH,
|
|
@@ -75,7 +85,7 @@ export const ts_spine_node_backend_config = (options = {}) => {
|
|
|
75
85
|
* the Node path), and `Bun.serve` + `hono/bun` need no extra deps.
|
|
76
86
|
*/
|
|
77
87
|
export const ts_spine_bun_backend_config = (options = {}) => {
|
|
78
|
-
const { port = TS_SPINE_BUN_DEFAULT_PORT, database_url } = options;
|
|
88
|
+
const { port = TS_SPINE_BUN_DEFAULT_PORT, database_url, enable_login_rate_limit } = options;
|
|
79
89
|
const name = 'ts_spine_bun';
|
|
80
90
|
const paths = build_test_backend_paths(name);
|
|
81
91
|
return {
|
|
@@ -85,7 +95,10 @@ export const ts_spine_bun_backend_config = (options = {}) => {
|
|
|
85
95
|
start_command: ['bun', 'run', TS_SPINE_BUN_ENTRY],
|
|
86
96
|
database_url,
|
|
87
97
|
paths,
|
|
88
|
-
extra_env: {
|
|
98
|
+
extra_env: {
|
|
99
|
+
[TS_SPINE_DIR_ENV]: paths.root,
|
|
100
|
+
...login_rate_limit_env(enable_login_rate_limit),
|
|
101
|
+
},
|
|
89
102
|
capabilities: ts_spine_bun_capabilities,
|
|
90
103
|
}),
|
|
91
104
|
sse_path: TS_SPINE_SSE_PATH,
|
|
@@ -103,7 +116,7 @@ export const ts_spine_bun_backend_config = (options = {}) => {
|
|
|
103
116
|
* built package; this binary tests live source instead.)
|
|
104
117
|
*/
|
|
105
118
|
export const ts_spine_deno_backend_config = (options = {}) => {
|
|
106
|
-
const { port = TS_SPINE_DENO_DEFAULT_PORT, database_url } = options;
|
|
119
|
+
const { port = TS_SPINE_DENO_DEFAULT_PORT, database_url, enable_login_rate_limit } = options;
|
|
107
120
|
const name = 'ts_spine_deno';
|
|
108
121
|
const paths = build_test_backend_paths(name);
|
|
109
122
|
return {
|
|
@@ -126,7 +139,10 @@ export const ts_spine_deno_backend_config = (options = {}) => {
|
|
|
126
139
|
],
|
|
127
140
|
database_url,
|
|
128
141
|
paths,
|
|
129
|
-
extra_env: {
|
|
142
|
+
extra_env: {
|
|
143
|
+
[TS_SPINE_DIR_ENV]: paths.root,
|
|
144
|
+
...login_rate_limit_env(enable_login_rate_limit),
|
|
145
|
+
},
|
|
130
146
|
capabilities: ts_spine_capabilities,
|
|
131
147
|
}),
|
|
132
148
|
sse_path: TS_SPINE_SSE_PATH,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fuzdev/fuz_app",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.93.0",
|
|
4
4
|
"description": "fullstack app library",
|
|
5
5
|
"glyph": "🗝",
|
|
6
6
|
"logo": "logo.svg",
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
"test:cross:ts": "FUZ_TEST_CROSS_BACKEND=1 vitest run --project cross_backend_ts_node --project cross_backend_ts_deno --project cross_backend_ts_bun",
|
|
18
18
|
"test:cross:rust-spine-stub": "FUZ_TEST_CROSS_BACKEND=1 vitest run --project cross_backend_rust_spine_stub",
|
|
19
19
|
"test:cross:parity": "FUZ_TEST_CROSS_BACKEND=1 vitest run --project cross_backend_parity",
|
|
20
|
+
"test:cross:security": "FUZ_TEST_CROSS_BACKEND=1 vitest run --project cross_backend_security",
|
|
20
21
|
"benchmark:cross-impl": "gro run src/benchmarks/cross_impl.bench.ts",
|
|
21
22
|
"preview": "vite preview",
|
|
22
23
|
"deploy": "gro deploy"
|
|
@@ -1,59 +0,0 @@
|
|
|
1
|
-
import '../assert_dev_env.js';
|
|
2
|
-
/**
|
|
3
|
-
* Vitest `globalSetup` factory for cross-impl **schema-parity** projects.
|
|
4
|
-
*
|
|
5
|
-
* The per-backend `create_cross_backend_global_setup` spawns one backend
|
|
6
|
-
* (derived from the project name). A schema-parity gate instead needs
|
|
7
|
-
* *two* backends alive at once so the test process can capture each one's
|
|
8
|
-
* schema (via `capture_schema_snapshot`) and diff them with
|
|
9
|
-
* `assert_schema_snapshots_equal`. This factory spawns + bootstraps both
|
|
10
|
-
* configs and `provide`s both serialized handles.
|
|
11
|
-
*
|
|
12
|
-
* A consumer's parity `global_setup.ts` collapses to:
|
|
13
|
-
*
|
|
14
|
-
* ```ts
|
|
15
|
-
* import {create_schema_parity_global_setup} from
|
|
16
|
-
* '@fuzdev/fuz_app/testing/cross_backend/create_schema_parity_global_setup.js';
|
|
17
|
-
* import {deno_backend_config, rust_backend_config} from './my_backend_config.js';
|
|
18
|
-
* import './cross_test_types.js'; // augments the two provide keys
|
|
19
|
-
*
|
|
20
|
-
* export default create_schema_parity_global_setup({
|
|
21
|
-
* configs: {a: deno_backend_config, b: rust_backend_config},
|
|
22
|
-
* });
|
|
23
|
-
* ```
|
|
24
|
-
*
|
|
25
|
-
* The parity `.cross.test.ts` `inject`s both keys, rebuilds each with
|
|
26
|
-
* `reconstruct_bootstrapped_handle`, and asserts. Run this project in a
|
|
27
|
-
* later `groupOrder` than the single-backend projects (or with distinct
|
|
28
|
-
* ports) — it reuses both configs' ports, so it must not run concurrently
|
|
29
|
-
* with the per-backend projects.
|
|
30
|
-
*
|
|
31
|
-
* @module
|
|
32
|
-
*/
|
|
33
|
-
import type { TestProject } from 'vitest/node';
|
|
34
|
-
import type { BackendConfig } from './backend_config.js';
|
|
35
|
-
export interface SchemaParityGlobalSetupOptions {
|
|
36
|
-
/**
|
|
37
|
-
* The two backend config factories to spawn. `a` is spawned first; on
|
|
38
|
-
* its success `b` is spawned (with `a` torn down if `b` throws). Label
|
|
39
|
-
* them in the parity test via `assert_schema_snapshots_equal`'s labels.
|
|
40
|
-
*/
|
|
41
|
-
readonly configs: {
|
|
42
|
-
readonly a: () => BackendConfig;
|
|
43
|
-
readonly b: () => BackendConfig;
|
|
44
|
-
};
|
|
45
|
-
/**
|
|
46
|
-
* `project.provide` keys for the two serialized handles (read by
|
|
47
|
-
* `inject` in the parity test). Default `parity_handle_a` /
|
|
48
|
-
* `parity_handle_b`. Augment vitest's `ProvidedContext` for them.
|
|
49
|
-
*/
|
|
50
|
-
readonly provide_keys?: {
|
|
51
|
-
readonly a: string;
|
|
52
|
-
readonly b: string;
|
|
53
|
-
};
|
|
54
|
-
}
|
|
55
|
-
/**
|
|
56
|
-
* Build a vitest `globalSetup` default export that spawns both backends.
|
|
57
|
-
*/
|
|
58
|
-
export declare const create_schema_parity_global_setup: ({ configs, provide_keys, }: SchemaParityGlobalSetupOptions) => ((project: TestProject) => Promise<() => Promise<void>>);
|
|
59
|
-
//# sourceMappingURL=create_schema_parity_global_setup.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"create_schema_parity_global_setup.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/create_schema_parity_global_setup.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAE9B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AAEH,OAAO,KAAK,EAAC,WAAW,EAAC,MAAM,aAAa,CAAC;AAE7C,OAAO,KAAK,EAAC,aAAa,EAAC,MAAM,qBAAqB,CAAC;AAIvD,MAAM,WAAW,8BAA8B;IAC9C;;;;OAIG;IACH,QAAQ,CAAC,OAAO,EAAE;QAAC,QAAQ,CAAC,CAAC,EAAE,MAAM,aAAa,CAAC;QAAC,QAAQ,CAAC,CAAC,EAAE,MAAM,aAAa,CAAA;KAAC,CAAC;IACrF;;;;OAIG;IACH,QAAQ,CAAC,YAAY,CAAC,EAAE;QAAC,QAAQ,CAAC,CAAC,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,CAAC,EAAE,MAAM,CAAA;KAAC,CAAC;CACjE;AAED;;GAEG;AACH,eAAO,MAAM,iCAAiC,GAAI,4BAG/C,8BAA8B,KAAG,CAAC,CAAC,OAAO,EAAE,WAAW,KAAK,OAAO,CAAC,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC,CAmB1F,CAAC"}
|