From de10409b6b44f984e46c77a3ffd3513ebdcf0011 Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Sat, 29 Aug 2026 16:42:45 +0300 Subject: [PATCH] =?UTF-8?q?chore:=20Phase=209=20=E2=80=94=20cross-cutting?= =?UTF-8?q?=20invariants,=20conformance,=20and=20the=20shrink=20guard.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audits what Phases 0-8 built against product spec §19 (XCUT-1..24) and §20 (NFR-1..17). This is the first systematic tabulation of the XCUT family: before this phase, zero XCUT-N citations existed in any source file. Ships: - @dexpace/shrink-test (NFR-9) — private, unpublished. esbuild bundle+minify+tree-shake of a synthetic consumer, a 24 KiB budget against a measured 16,671 bytes, then a child-process round trip. NFR-8's keep configuration ships nothing, deliberately: this port has no reflective discovery surface to keep-configure, so the guard targets the dual-package hazard instead (deliberate-deviations.md:32). Verified non-vacuous — a separately bundled IoError has a different class identity and instanceof is false across the boundary. - tests/conformance/xcut/ — six suites, 36 tests driving the real standardResilience() pipeline over live sockets, plus 17 retrofit citations across 12 existing test files. All 24 XCUT and 17 NFR IDs are dispositioned with evidence in a new phase checklist; no silent gaps. - NFR-13 SPDX sweep across every tracked source file: 3 offenders fixed, now 0. Four findings filed as docs/open-items.md Section N, not patched here — Phase 9 audits rather than edits another phase's code: N1 cancellation surfaces CancellationError from the transport but a bare AbortError under a SuppressedError from a retry backoff wait, so `e instanceof CancellationError` handles one path and silently misses the other (owner 5a) N2 HttpStatusError's public constructor accepts a 200, fabricating the "successful exception" XCUT-8 names and contradicting its own TSDoc (owner 3b) N3 the plan's `grep unresolved 2026-07-25 docs/knowledge/` step cannot return empty as written (owner Phase 10) N4 rxjs restated in three places against NFR-14's single source of truth (owner Phase 10) Toolchain: - build:deps gains codec-json and transport-fetch. Phase 9 made them the third and fourth packages imported by name with exports pointing at dist/, from shrink-test/src/ and from tests/. A warm tree hid this; a clean preflight failed typecheck and lint exactly as PR #52 did. - bunfig pins [test] root = "packages", so a bare `bun test` never sees tests/. The root `test` script now passes both trees (bun test ./packages ./tests) and CI runs `bun run test --coverage`. typecheck gains tests/tsconfig.json, which was linted but never typechecked. - CLAUDE.md documents the two-tree split, the bare-`bun test` trap, and the grown build:deps list. Ten plan amendments are recorded in the checklist — the plan predates the packages and several of its code blocks assume APIs that shipped differently. The two that would otherwise have produced silently-passing tests: the dispatch counter belongs on the transport rather than Runtime.send, and Runtime.close() is a documented no-op (PIPE-27). Gates: ci-preflight --clean green, all 14 steps, on .bun-version's Bun 1.3.14; test:node also green on the 20.3.0 engines.node floor; 2171 tests passing at 99.72% line coverage against the 80% floor. --- .github/workflows/ci.yml | 5 +- CLAUDE.md | 45 ++-- bun.lock | 66 ++++++ docs/open-items.md | 98 +++++++++ ...se9-cross-cutting-conformance-checklist.md | 120 +++++++++++ eslint.config.js | 1 + package.json | 7 +- packages/core/src/auth/auth-step.test.ts | 5 +- packages/core/src/auth/bearer-cache.test.ts | 5 +- packages/core/src/auth/digest.test.ts | 5 +- .../core/src/body/http-status-error.test.ts | 6 +- packages/core/src/context/store.test.ts | 5 +- packages/core/src/http/headers.test.ts | 8 +- packages/core/src/http/request.test.ts | 6 +- .../src/observability/logging-step.test.ts | 5 +- .../core/src/observability/redaction.test.ts | 6 +- packages/core/src/redirect/decide.test.ts | 4 + packages/core/src/retry/classify.test.ts | 6 +- packages/shrink-test/package.json | 15 ++ packages/shrink-test/shrink-test.config.ts | 35 +++ packages/shrink-test/src/bundle.test.ts | 31 +++ packages/shrink-test/src/bundle.ts | 46 ++++ packages/shrink-test/src/fixture-app.ts | 74 +++++++ .../shrink-test/src/run-shrink-guard.test.ts | 17 ++ packages/shrink-test/src/run-shrink-guard.ts | 76 +++++++ packages/shrink-test/tsconfig.json | 16 ++ .../src/fetch-transport.test.ts | 5 +- .../src/undici-transport.test.ts | 3 + scripts/knowledge.mjs | 1 + scripts/knowledge.test.mjs | 1 + ...ncellation-and-timeout.conformance.test.ts | 204 ++++++++++++++++++ ...currency-and-lifecycle.conformance.test.ts | 142 ++++++++++++ .../diagnostic-previews.conformance.test.ts | 122 +++++++++++ .../xcut/error-taxonomy.conformance.test.ts | 201 +++++++++++++++++ .../xcut/fixtures/composed-pipeline.ts | 103 +++++++++ tests/conformance/xcut/fixtures/server.ts | 168 +++++++++++++++ .../conformance/xcut/fixtures/settle.test.ts | 28 +++ tests/conformance/xcut/fixtures/settle.ts | 25 +++ .../xcut/retry-safety.conformance.test.ts | 106 +++++++++ .../security-by-default.conformance.test.ts | 161 ++++++++++++++ tests/tsconfig.json | 18 ++ 41 files changed, 1972 insertions(+), 29 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-28-phase9-cross-cutting-conformance-checklist.md create mode 100644 packages/shrink-test/package.json create mode 100644 packages/shrink-test/shrink-test.config.ts create mode 100644 packages/shrink-test/src/bundle.test.ts create mode 100644 packages/shrink-test/src/bundle.ts create mode 100644 packages/shrink-test/src/fixture-app.ts create mode 100644 packages/shrink-test/src/run-shrink-guard.test.ts create mode 100644 packages/shrink-test/src/run-shrink-guard.ts create mode 100644 packages/shrink-test/tsconfig.json create mode 100644 tests/conformance/xcut/cancellation-and-timeout.conformance.test.ts create mode 100644 tests/conformance/xcut/concurrency-and-lifecycle.conformance.test.ts create mode 100644 tests/conformance/xcut/diagnostic-previews.conformance.test.ts create mode 100644 tests/conformance/xcut/error-taxonomy.conformance.test.ts create mode 100644 tests/conformance/xcut/fixtures/composed-pipeline.ts create mode 100644 tests/conformance/xcut/fixtures/server.ts create mode 100644 tests/conformance/xcut/fixtures/settle.test.ts create mode 100644 tests/conformance/xcut/fixtures/settle.ts create mode 100644 tests/conformance/xcut/retry-safety.conformance.test.ts create mode 100644 tests/conformance/xcut/security-by-default.conformance.test.ts create mode 100644 tests/tsconfig.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5a555b5..426f48e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,8 +28,11 @@ jobs: - name: Build run: bun run build + # `bun run test`, not a bare `bun test`: the root script passes both test + # trees explicitly (`./packages ./tests`), and bunfig's `root = "packages"` + # means a bare invocation silently skips `tests/`. See CLAUDE.md. - name: Test (with coverage) - run: bun test --coverage + run: bun run test --coverage - name: API surface check run: bun run api diff --git a/CLAUDE.md b/CLAUDE.md index 9a5ab7a..90e78e6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,7 +27,7 @@ bun run typecheck # build:deps, then tsc --noEmit per package bun run lint # build:deps, then gts lint . — formatting AND type-aware rules; fatal bun run fix # build:deps, then gts fix . — autofixes formatting/lint bun run build # build:deps, then plain tsc for the rest → each package's dist/ -bun test # needs `build` first (see below); coverage on by default, 80% line floor +bun run test # BOTH test trees (see below); needs `build` first; coverage on, 80% line floor bun run test:node # Node-runtime conformance against the BUILT artifact; needs `build` first ``` @@ -38,15 +38,19 @@ reason, so every one of them works on a fresh clone. Both legs are `tsc`, so a w Do not drop that prefix to "save a step": without it `typecheck` fails with unresolved-module errors the moment `dist/` is absent, which is exactly what a CI runner sees. -**`build:deps` is the list, and it grows.** It is core plus `@dexpace/transport-shared` today. A package -belongs in it the moment another package's `src/` imports it *by name* and its `exports` point at `dist/`. +**`build:deps` is the list, and it grows.** It is core, `@dexpace/transport-shared`, `@dexpace/codec-json` and +`@dexpace/transport-fetch` today. A package belongs in it the moment another package's `src/` — or the top-level +`tests/` tree — imports it *by name* and its `exports` point at `dist/`. Phase 8a proved the cost of missing one: `transport-shared` landed as the second such package, `build:core` stayed the prefix, and CI failed on `typecheck` at the first fresh clone while every local gate stayed green -against a warm `dist/`. `@dexpace/transport-conformance` is deliberately absent — it is `private` and its -`exports` name `./src/index.ts`, so it resolves unbuilt. Check the graph, not this sentence: +against a warm `dist/`. Phase 9 grew it twice over for one reason: `packages/shrink-test/src/` imports `codec-json` and +`transport-fetch` by name, and so does `tests/conformance/xcut/`. `@dexpace/transport-conformance` is +deliberately absent — it is `private` and its `exports` name `./src/index.ts`, so it resolves unbuilt. Check the +graph, not this sentence: ```bash for d in packages/*/; do grep -rhoE "from '@dexpace/[a-z-]+'" "$d/src" | sort -u; done +grep -rhoE "from '@dexpace/[a-z-]+'" tests | sort -u # the second test tree counts too ``` `node .claude/skills/ci-preflight/run-ci.mjs --clean` is what catches a missing entry — it sweeps every @@ -56,23 +60,36 @@ banner): CI resolves that file, and Bun's `fetch`/`node:http` differ enough betw transport rows passed on 1.4.0 and failed three ways on the pinned 1.3.14. `--clean` plus that default is the difference between "the gates pass here" and "CI will be green". -`bun test` runs the unit suite on **Bun** and is scoped to `packages/` (`bunfig.toml`'s `[test] root`). -**It needs `bun run build` to have run first**, from Phase 6a on: `@dexpace/codec-json`'s tests reach core -through its published entry point, which Bun resolves to `packages/core/dist/`. On a fresh clone they cannot -resolve core at all; against a stale `dist/` they report green over yesterday's core. CI is safe — its Build -step precedes its Test step. The root `test` script deliberately does not build first, so the inner loop stays -fast; rebuild when you have changed `packages/core/src/`. +**There are two test trees, and `bun run test` is the only command that runs both.** Colocated unit tests +live under `packages/*/src/`; cross-package conformance suites that drive a composed pipeline over a real +socket live under `tests/` (styleguide 11-testing: integration tests crossing a process or network boundary +belong in a top-level `tests/`, not beside one module). The root script is `bun test ./packages ./tests` — +two trees, one process, one coverage report, one exit code. + +**A bare `bun test` silently runs only the first tree.** `bunfig.toml`'s `[test] root = "packages"` governs +discovery, so a bare invocation never visits `tests/` and reports green over a suite it never opened, with +no "0 files matched" to notice. Explicit `./`-prefixed paths override the root — a plain `tests/...` +argument is treated as a name filter and matches nothing, which is its own quiet failure. The coverage floor +does still fire on the combined run (confirmed by raising `coverageThreshold` and watching it exit 1), so +CI's Test step is `bun run test --coverage` rather than the bare form. + +**Either form needs `bun run build` to have run first**, from Phase 6a on: `@dexpace/codec-json`'s tests reach +core through its published entry point, which Bun resolves to `packages/core/dist/`. On a fresh clone they +cannot resolve core at all; against a stale `dist/` they report green over yesterday's core. CI is safe — its +Build step precedes its Test step. The root `test` script deliberately does not build first, so the inner loop +stays fast; rebuild when you have changed `packages/core/src/`. `test:node` is a separate, thin layer under `test/node-conformance/` that runs the same built package under `node --test`, because Bun's Web Streams / `AbortSignal` / `Uint8Array` behavior is an independent implementation of Node's and `src/io/` is where they diverge. **A phase that touches a runtime-divergent -surface adds a case there, not only to `bun test`** — see `test/node-conformance/README.md`. +surface adds a case there, not only to `bun run test`** — see `test/node-conformance/README.md`. Single test file or single test: ```bash bun test packages/core/src/http/media-type.test.ts -bun test -t 'rejects blank input' # filter by test name +bun test -t 'rejects blank input' # filter by test name +bun test ./tests/conformance/xcut # a tests/ path needs the ./ prefix ``` API surface — one committed report per package (`packages/core/etc/core.api.md`, @@ -98,7 +115,7 @@ bun run audit # bun audit --audit-level=high --prod ``` **Every one of these is a blocking CI step** (`.github/workflows/ci.yml`). Run the full set before claiming -work is done — `bun test` passing is not sufficient evidence. +work is done — `bun run test` passing is not sufficient evidence. `bun run test:scripts` (`node --test scripts/*.test.mjs`) tests the *gates themselves* — the knowledge CLI and `verify-seam-1.mjs`. It is **not** wired into CI yet (`docs/open-items.md` H13), so run it by hand after diff --git a/bun.lock b/bun.lock index 9cb038d..1636b72 100644 --- a/bun.lock +++ b/bun.lock @@ -118,6 +118,16 @@ "rxjs": "^7.8.0", }, }, + "packages/shrink-test": { + "name": "@dexpace/shrink-test", + "version": "0.0.0", + "devDependencies": { + "@dexpace/codec-json": "workspace:*", + "@dexpace/core": "workspace:*", + "@dexpace/transport-fetch": "workspace:*", + "esbuild": "^0.28.2", + }, + }, "packages/transport-conformance": { "name": "@dexpace/transport-conformance", "version": "0.0.0", @@ -252,6 +262,8 @@ "@dexpace/rx": ["@dexpace/rx@workspace:packages/rx"], + "@dexpace/shrink-test": ["@dexpace/shrink-test@workspace:packages/shrink-test"], + "@dexpace/transport-conformance": ["@dexpace/transport-conformance@workspace:packages/transport-conformance"], "@dexpace/transport-fetch": ["@dexpace/transport-fetch@workspace:packages/transport-fetch"], @@ -260,6 +272,58 @@ "@dexpace/transport-undici": ["@dexpace/transport-undici@workspace:packages/transport-undici"], + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.28.2", "", { "os": "android", "cpu": "arm" }, "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.2", "", { "os": "android", "cpu": "arm64" }, "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.28.2", "", { "os": "android", "cpu": "x64" }, "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.2", "", { "os": "linux", "cpu": "arm" }, "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.2", "", { "os": "linux", "cpu": "x64" }, "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.2", "", { "os": "none", "cpu": "x64" }, "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g=="], + "@eslint-community/eslint-plugin-eslint-comments": ["@eslint-community/eslint-plugin-eslint-comments@4.7.2", "", { "dependencies": { "escape-string-regexp": "^4.0.0", "ignore": "^7.0.5" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0" } }, "sha512-LF03qURSwEWm2dz5wtdDCzNk+7Opl0X7q6I3undsaIuNsEiNvRV3BCtqu14Q/6Pzg1tBj44LcxpW2EpSLZStZw=="], "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.10.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg=="], @@ -460,6 +524,8 @@ "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + "esbuild": ["esbuild@0.28.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.2", "@esbuild/android-arm": "0.28.2", "@esbuild/android-arm64": "0.28.2", "@esbuild/android-x64": "0.28.2", "@esbuild/darwin-arm64": "0.28.2", "@esbuild/darwin-x64": "0.28.2", "@esbuild/freebsd-arm64": "0.28.2", "@esbuild/freebsd-x64": "0.28.2", "@esbuild/linux-arm": "0.28.2", "@esbuild/linux-arm64": "0.28.2", "@esbuild/linux-ia32": "0.28.2", "@esbuild/linux-loong64": "0.28.2", "@esbuild/linux-mips64el": "0.28.2", "@esbuild/linux-ppc64": "0.28.2", "@esbuild/linux-riscv64": "0.28.2", "@esbuild/linux-s390x": "0.28.2", "@esbuild/linux-x64": "0.28.2", "@esbuild/netbsd-arm64": "0.28.2", "@esbuild/netbsd-x64": "0.28.2", "@esbuild/openbsd-arm64": "0.28.2", "@esbuild/openbsd-x64": "0.28.2", "@esbuild/openharmony-arm64": "0.28.2", "@esbuild/sunos-x64": "0.28.2", "@esbuild/win32-arm64": "0.28.2", "@esbuild/win32-ia32": "0.28.2", "@esbuild/win32-x64": "0.28.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA=="], + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], diff --git a/docs/open-items.md b/docs/open-items.md index bcf9835..f8904b4 100644 --- a/docs/open-items.md +++ b/docs/open-items.md @@ -1459,6 +1459,104 @@ reconnection stays caller-owned (`SSE-38`) and retry/backoff stays in 5a's engin `docs/sdk-design-nodejs/10-deliberate-deviations-from-the-reference-contract.md` Item 1; no further action. +## Section N — Phase 9 (Cross-Cutting Invariants & Conformance) + +Findings from the first systematic `XCUT-1`–`XCUT-24` / `NFR-1`–`NFR-17` pass. Every row here was found by +driving the **composed** pipeline (`standardResilience()` over a real `fetchTransport()` against a local +`node:http` fixture), which is the shape no earlier phase's own unit tests exercise. + +### N1 — Cancellation surfaces two different types depending on which layer was cancelled — **ACT** + +`XCUT-1` requires cancellation to surface "as a distinct, terminal, NON-retryable signal". The port has a type +for exactly that, `CancellationError`. It is produced on one path and not the other: + +| Cancelled during | Surfaced as | +|---|---| +| the transport dispatch | `CancellationError` — `transport-shared`'s `abortToSdkError` maps the abort | +| a retry backoff wait | `SuppressedError('retry attempts exhausted')` wrapping a bare `DOMException` `AbortError` | + +`retry/engine.ts` surfaces `config.signal.reason` (line 375) and whatever `Clock.sleep` rejected with (line 410) +verbatim, so no mapping to `CancellationError` ever happens on the retry path. Verified against the composed +pipeline: `.error` is `DOMException{name:'AbortError'}`, `.suppressed` is the prior `HttpStatusError`. + +**Not a MUST violation on its own reading of `XCUT-3`** — a cancellation *is* surfaced, it aborts +near-immediately (measured well under 5s against a 60s backoff), no further attempt is dispatched, and it is +unambiguously not a timeout, which is all `XCUT-3` demands. The defect is consistency: a caller writing +`catch (e) { if (e instanceof CancellationError) … }` handles the transport case and silently misses the +backoff case. `XCUT-2`'s "told apart by ambient state, not a message string" still holds either way, since +`AbortError` vs `TimeoutError` is a `name` check. + +**Decision needed:** map the retry engine's two cancellation exits through the same `abortToSdkError`-shaped +helper the transports use, or state in `CancellationError`'s own TSDoc that it is a transport-layer type and a +caller must check the chain. Deliberately **not** patched in Phase 9 — the phase's plan says a failure found +here "is in an earlier phase's shipped behavior, not something this task builds; file against that phase's own +plan rather than patching around it here". Owner: 5a. + +`tests/conformance/xcut/cancellation-and-timeout.conformance.test.ts` asserts the invariant `XCUT-3` actually +states (a cancellation is carried somewhere in the chain, and no timeout is) rather than pinning the current +wrapper shape, so whichever way the decision goes the suite keeps passing. + +### N2 — `HttpStatusError`'s public constructor fabricates the "successful exception" `XCUT-8` forbids — **ACT** + +`XCUT-8` requires the status-to-exception mapping factory to reject a non-error status "rather than fabricate a +'successful exception'", and permits a convenience form returning absent/null instead of throwing. The port +ships only the convenience form, `toHttpError`, and it is correct — `toHttpError(200)` and `toHttpError(304)` +both return `null` (asserted in `body/http-status-error.test.ts`). On that reading `XCUT-8` is satisfied. + +The hole is one level down. `HttpStatusError`'s constructor is `@public` in `core.api.md` and validates +nothing: + +``` +new HttpStatusError(200, undefined, undefined) → HttpStatusError: HTTP 200 (status: 200) +``` + +That is precisely the "successful exception" the requirement names, and it contradicts the class's own TSDoc, +which asserts `status` is "always in HTTP-11's 400-599 error band (BODY-31)" — an invariant documented but +never enforced. Nothing in `packages/core` constructs one this way; the exposure is a consumer's. + +**Decision needed:** validate in the constructor and throw for a status outside 400-599 (a breaking change to a +published constructor, so it wants a changeset), or drop the constructor from the public surface and let +`toHttpError` be the only way to obtain one. The second is closer to the domain-model pattern the rest of +`src/http/` follows, where a TS-`private` constructor keeps construction behind validation. + +Not patched in Phase 9: this is 3b's shipped surface, and Phase 9 audits rather than edits another phase's +code. Owner: 3b, with Phase 10 as the natural landing spot since it already carries an API-surface pass. + +### N3 — Phase 9's plan asks for a `docs/knowledge/` grep that cannot return empty — **SCHEDULED** (Phase 10) + +The plan's Task 11 Step 4 runs `grep -rn "unresolved 2026-07-25" docs/knowledge/` and expects no output. It +cannot pass as written. The design scoped §4 to the **three** markers in `tooling-and-quality-gates.md`; the +grep is repo-wide and two further markers live elsewhere: + +| Marker | File | State in the code | +|---|---|---| +| `#private` fields as the default for model state | `http-domain-model.md:131` | Settled in practice — `#private` throughout `src/http/`, documented as the pattern in CLAUDE.md | +| `enum` for the pipeline `Stage` ordering | `pipeline.md:179` | Settled in practice — `erasableSyntaxOnly` bans `enum`; the port ships `STAGE_ORDER`/`PILLAR_STAGES` frozen constant objects | + +Both are resolved *by the implementation* but never marked resolved *in the corpus*, which is exactly the +silent-gap shape this register exists to prevent. Deliberately not marked here: writing a resolution into +`docs/knowledge/` is a decision record, the checkpoint's rule only obliges markers its own §5 touched, and +neither is an `XCUT`/`NFR` question — Phase 9's scope. Phase 10 owns deviation reconciliation and is the +right place. The three markers Phase 9 *was* scoped to were already backported at planning time (`c6603aa`) +and were confirmed still correct, not re-made. + +### N4 — `rxjs` version restated in three places against `NFR-14` — **ACT** + +`NFR-14` asks that dependency and tool versions live in a single source of truth so a bump is one edit. The +root `workspaces.catalog` holds `@microsoft/api-extractor`, `expect-type`, `fast-check` and `typescript`. +`rxjs@^7.8.0` is stated three times instead: root `devDependencies`, `packages/rx` `devDependencies`, and +`packages/rx` `peerDependencies`. A bump is three edits, two of which are easy to miss. + +The peer range is legitimately per-package — it is part of what `@dexpace/rx` publishes, not a build +coordinate. The two `devDependencies` restatements are the defect; a `rxjs` catalog entry collapses them. + +`debug >=4.0.0` and `pino >=8.0.0` are **not** defects for the same reason: each appears once, as a published +peer range. `undici ^6.21.1` likewise appears once, as `transport-undici`'s own runtime dependency. + +Not fixed in Phase 9: it edits another package's manifest and changes the lockfile, which is 8b's surface. +Owner: Phase 10, alongside its own dependency pass. + + ## Maintaining this file Add an entry the moment a gap is found, not when it is fixed — the failure mode this file prevents is a diff --git a/docs/superpowers/plans/2026-07-28-phase9-cross-cutting-conformance-checklist.md b/docs/superpowers/plans/2026-07-28-phase9-cross-cutting-conformance-checklist.md new file mode 100644 index 0000000..e63c4c5 --- /dev/null +++ b/docs/superpowers/plans/2026-07-28-phase9-cross-cutting-conformance-checklist.md @@ -0,0 +1,120 @@ +# Phase 9 — Cross-Cutting Invariants & Conformance — Requirement Checklist + +Every `XCUT-1`–`XCUT-24` and `NFR-1`–`NFR-17` ID, mapped to the task that satisfies it and the evidence that +proves it. This is the first systematic tabulation of the `XCUT` family in this project — before Phase 9 the +grep across every spec and plan turned up incidental citations only, and **zero** `XCUT-N` citations in any +source file. + +**Legend.** ✅ satisfied, evidence cited · 🔁 satisfied by an earlier phase, retrofit citation added here · +📋 documented disposition, no test · ⚠️ satisfied with a finding filed against another phase. + +**Gate status at close:** `typecheck`, `lint`, `build`, `test` (2171 pass / 0 fail, 99.72% lines vs. the 80% +floor), `api`, `lint:publish`, `verify:dual-consumption`, `verify:consumer-types`, `verify:seam-1`, +`verify:sse-37`, `verify:runtime-floor`, `audit`, `shrink-test`, `test:node`, `test:scripts` — all green. + +--- + +## `XCUT` — cross-cutting invariants + +| ID | Status | Task | Evidence | +|---|---|---|---| +| `XCUT-1` | ✅ | 5 | `cancellation-and-timeout.conformance.test.ts` — 3 rows: an aborted in-flight request surfaces `CancellationError`; the retry pillar does not spend its remaining attempts (`dispatches() === 1`); the ambient signal stays aborted | +| `XCUT-2` | 🔁 | 5 | `seams/transport.test.ts` — citation already present from Phase 2; `isTimeoutSignal` discriminates on `signal.reason.name`, never a message string. No change needed | +| `XCUT-3` | ⚠️ | 5 | Same file — 3 rows: a 60 s backoff aborts in well under 5 s; a cancellation (not a timeout) is carried in the surfaced chain; no further attempt dispatched. **Finding N1** — the retry path surfaces a bare `AbortError`, not `CancellationError` | +| `XCUT-4` | ✅ | 6 | `error-taxonomy.conformance.test.ts` — 3 rows: a 5xx returns its fully-received response and converts via `toHttpError` to a status+body-carrying error; a refused connection rejects as `IoError` | +| `XCUT-5` | 🔁 | 6 | `retry/classify.test.ts` — already asserts 408, 429, 500/503/599 retryable and 501/505 not. This port has no separately-cached flag: the classifier is a pure function of the immutable `.status` | +| `XCUT-6` | ✅ | 6 | Same file — 2 rows: a `CustomTransientError extends IoError` declared *in the test file* is retried with no edit to `classify.ts`; a plain `Error` is not. Subtyping is this port's retryability capability (deviation ledger item 17) | +| `XCUT-7` | ✅ | 6 | Same file — 2 rows against a live `/status?code=N`: widening to `{501}` retries a 501; narrowing to `{503}` stops a 500 whose built-in classification is retryable | +| `XCUT-8` | ⚠️🔁 | 6 | `body/http-status-error.test.ts` — `toHttpError` returns `null` for 200/304, the absent/null convenience form `XCUT-8` permits. **Finding N2** — the public constructor still builds `HttpStatusError(200, …)` | +| `XCUT-9` | ✅ | 6 | `error-taxonomy.conformance.test.ts` — a self-referential `cause` is classified and surfaced unchanged; the test completing at all is the assertion | +| `XCUT-10` | ✅ | 7 | `retry-safety.conformance.test.ts` — all five rows of the requirement's own conformance clause, including the load-bearing one: a body-less POST failing with a *transport* error is still not retried | +| `XCUT-11` | ✅ | 8 | `concurrency-and-lifecycle.conformance.test.ts` — 24 interleaved requests through one shared pipeline pair every response to its own request; exactly one dispatch each, no double-sends | +| `XCUT-12` | 🔁 | 8 | `auth/bearer-cache.test.ts` — N concurrent callers coalesce to exactly one provider invocation, in both the expired and post-eviction zones | +| `XCUT-13` | ✅🔁 | 8 | `concurrency-and-lifecycle.conformance.test.ts` — 4 rows incl. a real transport closed twice, and an aborted signal not cleared by close. Retrofits on `fetch-transport.test.ts` / `undici-transport.test.ts` | +| `XCUT-14` | 🔁 | 8 | `context/store.test.ts` ("a burst past the cap converges to at or under the cap") and `auth/digest.test.ts` (1024-entry nonce counter, drain-to-cap). Retrofit rather than a new test — **neither map is reachable from a consumer-shaped test**, so a burst driven from `tests/` could assert liveness but never a bound | +| `XCUT-15` | 🔁 | 9 | `http/request.test.ts` (URL cloned per access, setters yield new instances) and `http/headers.test.ts` (builder defensively copies an ingested collection) | +| `XCUT-16` | ✅🔁 | 9 | `security-by-default.conformance.test.ts` — a bearer credential over `http://` throws `PlaintextCredentialError` with **`providerInvocations === 0`** and `dispatches() === 0`: the refusal lands before any token fetch. Retrofit on `auth/auth-step.test.ts` | +| `XCUT-17` | ✅🔁 | 9 | Same file — 4 rows over two genuinely distinct origins: Authorization dropped even same-origin; Cookie kept same-origin but dropped cross-origin. Clauses (c) userinfo and (d) downgrade retrofitted onto `redirect/decide.test.ts`, being unreachable over a plaintext fixture | +| `XCUT-18` | 🔁 | 9 | `http/headers.test.ts` — names reject C0 incl. HTAB, DEL and non-ASCII; outbound values reject the same except HTAB; inbound lenient on obs-text but not control bytes | +| `XCUT-19` | 🔁 | 9 | `observability/redaction.test.ts` (userinfo never allow-listable, query/fragment default-deny) and `auth/credential.test.ts` (all three credentials redact their secret in every string form) | +| `XCUT-20` | 🔁 | 9 | `observability/logging-step.test.ts` — a throwing `Logger` is caught and re-surfaced as `http.instrumentation.*`; the request still completes | +| `XCUT-21` | 🔁 | 8 | `auth/digest.test.ts` — the cnonce is drawn from `crypto.getRandomValues` at ≥128 bits, fresh per call (AUTH-20) | +| `XCUT-22` | 🔁 | 8 | `undici-transport.test.ts` ("a bring-your-own dispatcher is never closed by the transport") and `fetch-transport.test.ts`. Also asserted end-to-end at pipeline level: `Runtime.close()` leaves the caller's transport usable | +| `XCUT-23` | 📋 | — | **N/A by construction.** Every seam this port ships (`Transport`, `Serde`, the logger facade) is explicit-call-only; the classpath auto-discovery `SEAM-5`–`SEAM-10` describes is a permanent simplification never built. The ordering holds vacuously — there is nothing for an explicit install to beat. Deviation ledger, Phase 9 row 1 | +| `XCUT-24` | ✅🔁 | 10 | `diagnostic-previews.conformance.test.ts` — the requirement's own clause verbatim: a **10 MB** body with a 1 KiB cap. Text previews cap at 1024 chars, binary at `[binary 1024 bytes captured]`, `body.size` is 1024 not 10485760, no event field exceeds the cap, and the caller still reads all 10485760 bytes | + +**All 24 dispositioned. No silent gaps.** + +--- + +## `NFR` — non-functional requirements + +| ID | Status | Evidence | +|---|---|---| +| `NFR-1` | ✅ | Audited across all 11 packages: `@dexpace/core` declares zero `dependencies`. Gate-enforced by `verify:seam-1` | +| `NFR-2` | ✅ | Every adapter is core-as-peer plus at most one external library — `logging-debug`→`debug`, `logging-pino`→`pino`, `rx`→`rxjs`, `transport-undici`→`undici`, `transport-fetch`/`body-file`/`codec-json`→none. `@dexpace/transport-shared` is an internal sibling, not a third-party lib | +| `NFR-3` | ✅ | One committed `etc/*.api.md` per published package (9 of them); internals stay unexported | +| `NFR-4` | ✅ | `bun run api` verifies all 9 reports; blocking in CI | +| `NFR-5` | ✅ | `bunfig.toml` `coverageThreshold = 0.8`, blocking. Actual: 99.72% lines / 98.76% funcs. **Verified live twice** — raising the threshold to 0.999 makes the run exit 1, and a single new file at 66.67% function coverage failed the run on its own while the aggregate stayed at 98.5%. So Bun enforces the floor **per file**, not only in aggregate: stricter than `NFR-5`'s "minimum aggregate" wording requires, and the gate is demonstrably not dormant | +| `NFR-6` | ✅ | `tsc --noEmit` per package under `strict`; `typecheck` now covers `shrink-test` and `tests/` too | +| `NFR-7` | ✅ | `gts` + `strictTypeChecked`/`stylisticTypeChecked`, fatal. Every `eslint-disable` carries a `-- reason`, including the one added this phase in `error-taxonomy.conformance.test.ts` | +| `NFR-8` | 📋 | **Not applicable by design** — no reflection-driven discovery surface to keep-configure. Deviation ledger, Phase 9 row 2; `docs/knowledge/deliberate-deviations.md:32` | +| `NFR-9` | ✅ | `@dexpace/shrink-test` (Tasks 1–3): esbuild bundle+minify+tree-shake, 24 KiB budget against a measured 16,671 bytes, then a **child-process** round trip. Guard proven non-vacuous: a separately-bundled `IoError` has a different class identity and `instanceof` is false across the boundary | +| `NFR-10` | ✅ | All 10 published packages declare `engines.node >= 20.3`; `verify:runtime-floor` gates target-vs-floor; `test:node` runs the floor and current LTS in CI | +| `NFR-11` | ✅ | No `Observable`/`rxjs`/`Subscriber`/`EventEmitter` anywhere in `core.api.md`; `rxjs` appears in no core source file | +| `NFR-12` | 📋 | Deferred to Phase 10 / first release, unchanged | +| `NFR-13` | ✅ | Swept every tracked `.ts`/`.mjs`/`.js`: **3 offenders fixed** (`eslint.config.js`, `scripts/knowledge.mjs`, `scripts/knowledge.test.mjs`). Now 0. `packages/core/scripts/gen-version.mjs` correctly carries it on line 2 under a shebang | +| `NFR-14` | ⚠️ | Root catalog holds `api-extractor`, `expect-type`, `fast-check`, `typescript`. **Finding N4** — `rxjs@^7.8.0` is restated in three places. Peer ranges (`debug`, `pino`) are correctly per-package, being part of each package's published contract | +| `NFR-15` | ✅ | `SDK_VERSION` generated at build time from `package.json`; resolves to the real version, never an "unknown" placeholder | +| `NFR-16` | 📋 | Deferred to first actual publish, unchanged | +| `NFR-17` | ✅ | Every gate above is a blocking CI step. `shrink-test` needed no fourteenth step: the suite lives under `packages/`, so `bun run test` already runs it | + +**All 17 dispositioned.** + +--- + +## Deviations recorded for Phase 10 + +| Deviation | Reference behavior | Justification | +|---|---|---| +| `XCUT-23`'s explicit-install / auto-discovery / loud-fail ordering is satisfied vacuously, not tested as a race | The JVM reference arbitrates a real classpath auto-discovery race for its SPI seams | Every seam this port ships is explicit-call-only; the auto-discovery mechanism was never built, so no competing resolution path exists for an explicit install to beat | +| `NFR-8`'s shrinker keep-configuration ships nothing | The reference ships ProGuard/R8 keep rules for its reflective/SPI surface | No reflection-driven discovery surface exists here. The risk that *does* carry over is the dual-package hazard, and `@dexpace/shrink-test` targets it — now with measured proof the hazard is real | +| `XCUT-6`'s "retryability capability" is subtyping, not a duck-typed flag | The reference queries a capability interface | `classify.ts`'s allow-list returns true for any `IoError`, so extending it opts a new failure in with no classifier edit. Already ledgered as item 17 | + +## Findings filed — `docs/open-items.md` Section N + +| # | Summary | Owner | +|---|---|---| +| N1 | Cancellation surfaces `CancellationError` from the transport but a bare `AbortError` from a retry backoff wait | 5a | +| N2 | `HttpStatusError`'s public constructor accepts a 200, fabricating the "successful exception" `XCUT-8` names | 3b | +| N3 | The plan's `grep -rn "unresolved 2026-07-25" docs/knowledge/` step cannot pass as written | Phase 10 | +| N4 | `rxjs` version restated in three places against `NFR-14`'s single-source-of-truth | Phase 10 | + +## Plan amendments + +The plan was written before any package existed, and several of its code blocks assume APIs that shipped +differently. Recorded so the next reader does not treat the plan as as-built: + +1. **Suite location.** `bunfig.toml` pins `[test] root = "packages"`, so a top-level `tests/` tree is invisible + to `bun test`. The root `test` script now passes both trees (`bun test ./packages ./tests`); CI runs + `bun run test --coverage`. The design's "exactly one new root script" no longer holds — `test` and + `typecheck` each grew, and CI's Test step changed. +2. **`StandardResilienceOptions.retry` is `RetryStepOptions`**, so settings nest under `.settings` — not the + plan's `Partial`. +3. **`isRetryableFailure` is `@internal`** and absent from core's barrel. Every `XCUT-6`/`7`/`9` row drives the + composed pipeline instead, which is what this suite is for anyway. +4. **`XCUT-6`'s test error extends `IoError`**, not a duck-typed `{isRetryable: true}` — no such capability + exists, by design. +5. **The dispatch counter wraps the transport, not `Runtime.send`.** The plan's placement counts caller + invocations and would read 1 whether retry re-issued four times or none, inverting every `XCUT-10` row. +6. **Two real listeners for the cross-origin hop.** The plan reused one port under `localhost` vs `127.0.0.1`; + the server binds `127.0.0.1` explicitly, so that name is not reliably resolvable. +7. **`XCUT-17`'s auth-re-stamp row is unreachable over a plaintext fixture** — `XCUT-16` forbids stamping a + credential over `http://`, which the suite asserts directly instead. +8. **`XCUT-13` on `Runtime` proves nothing about close.** `Runtime.close()` is a documented no-op (PIPE-27); + the real idempotence lives in the transports, so the test asserts both, plus the trap that a consumer who + only calls `runtime.close()` never closes the transport. +9. **Task 11's three documentation fixes were already applied at planning time** (commit `c6603aa`), so this + phase confirmed them rather than making them. +10. **`scripts/verify-nfr-audit.mjs` was never created.** The plan had it written, run once, then deleted; the + same checks were run directly, and their results are the `NFR-1`/`NFR-2` rows above. diff --git a/eslint.config.js b/eslint.config.js index 9dacc22..7cca4d7 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,3 +1,4 @@ +// SPDX-License-Identifier: MIT import {createRequire} from 'node:module'; import tseslint from 'typescript-eslint'; import gts from 'gts'; diff --git a/package.json b/package.json index add0c71..f439e49 100644 --- a/package.json +++ b/package.json @@ -46,14 +46,15 @@ "lint": "bun run build:deps && gts lint .", "fix": "bun run build:deps && gts fix .", "build:core": "tsc -b packages/core/tsconfig.build.json", - "build:deps": "bun run build:core && tsc -p packages/transport-shared/tsconfig.build.json", - "typecheck": "bun run build:deps && tsc -p packages/core/tsconfig.json --noEmit && tsc -p packages/codec-json/tsconfig.json --noEmit && tsc -p packages/logging-pino/tsconfig.json --noEmit && tsc -p packages/logging-debug/tsconfig.json --noEmit && tsc -p packages/body-file/tsconfig.json --noEmit && tsc -p packages/transport-shared/tsconfig.json --noEmit && tsc -p packages/transport-conformance/tsconfig.json --noEmit && tsc -p packages/transport-fetch/tsconfig.json --noEmit && tsc -p packages/transport-undici/tsconfig.json --noEmit && tsc -p packages/rx/tsconfig.json --noEmit", + "build:deps": "bun run build:core && tsc -p packages/transport-shared/tsconfig.build.json && tsc -p packages/codec-json/tsconfig.build.json && tsc -p packages/transport-fetch/tsconfig.build.json", + "typecheck": "bun run build:deps && tsc -p packages/core/tsconfig.json --noEmit && tsc -p packages/codec-json/tsconfig.json --noEmit && tsc -p packages/logging-pino/tsconfig.json --noEmit && tsc -p packages/logging-debug/tsconfig.json --noEmit && tsc -p packages/body-file/tsconfig.json --noEmit && tsc -p packages/transport-shared/tsconfig.json --noEmit && tsc -p packages/transport-conformance/tsconfig.json --noEmit && tsc -p packages/transport-fetch/tsconfig.json --noEmit && tsc -p packages/transport-undici/tsconfig.json --noEmit && tsc -p packages/rx/tsconfig.json --noEmit && tsc -p packages/shrink-test/tsconfig.json --noEmit && tsc -p tests/tsconfig.json --noEmit", "prebuild": "bun run --cwd packages/core prebuild", "build": "bun run build:deps && tsc -p packages/codec-json/tsconfig.build.json && tsc -p packages/logging-pino/tsconfig.build.json && tsc -p packages/logging-debug/tsconfig.build.json && tsc -p packages/body-file/tsconfig.build.json && tsc -p packages/transport-fetch/tsconfig.build.json && tsc -p packages/transport-undici/tsconfig.build.json && tsc -p packages/rx/tsconfig.build.json", - "test": "bun test", + "test": "bun test ./packages ./tests", "knowledge": "node scripts/knowledge.mjs", "test:scripts": "node --test 'scripts/*.test.mjs'", "test:node": "node --test test/node-conformance/*.test.mjs", + "shrink-test": "bun test ./packages/shrink-test", "bench": "bun run packages/core/src/io/byte-queue.bench.ts", "api": "cd packages/core && bun run api:ci && cd ../codec-json && bun run api:ci && cd ../logging-pino && bun run api:ci && cd ../logging-debug && bun run api:ci && cd ../body-file && bun run api:ci && cd ../transport-shared && bun run api:ci && cd ../transport-fetch && bun run api:ci && cd ../transport-undici && bun run api:ci && cd ../rx && bun run api:ci", "lint:publish": "publint packages/core && attw --pack packages/core --ignore-rules cjs-resolves-to-esm && publint packages/codec-json && attw --pack packages/codec-json --ignore-rules cjs-resolves-to-esm && publint packages/logging-pino && attw --pack packages/logging-pino --ignore-rules cjs-resolves-to-esm && publint packages/logging-debug && attw --pack packages/logging-debug --ignore-rules cjs-resolves-to-esm && publint packages/body-file && attw --pack packages/body-file --ignore-rules cjs-resolves-to-esm && publint packages/transport-shared && attw --pack packages/transport-shared --ignore-rules cjs-resolves-to-esm && publint packages/transport-fetch && attw --pack packages/transport-fetch --ignore-rules cjs-resolves-to-esm && publint packages/transport-undici && attw --pack packages/transport-undici --ignore-rules cjs-resolves-to-esm && publint packages/rx && attw --pack packages/rx --ignore-rules cjs-resolves-to-esm", diff --git a/packages/core/src/auth/auth-step.test.ts b/packages/core/src/auth/auth-step.test.ts index 94873f4..f7462ca 100644 --- a/packages/core/src/auth/auth-step.test.ts +++ b/packages/core/src/auth/auth-step.test.ts @@ -1,6 +1,9 @@ // SPDX-License-Identifier: MIT // packages/core/src/auth/auth-step.test.ts -// Exercises: AUTH-27 (exactly one AUTH-stage descriptor, pinned to the pillar), AUTH-28 (HTTPS guard, +// Exercises: XCUT-16 (a credential is NEVER stamped over a non-HTTPS transport; the refusal is loud and +// lands before any token fetch or header write, and it applies only on the credential-attaching path -- +// a marker-suppressed cross-origin re-issue may proceed credential-free over any scheme), +// AUTH-27 (exactly one AUTH-stage descriptor, pinned to the pillar), AUTH-28 (HTTPS guard, // NO_AUTH exempt, re-applied on the replay path), AUTH-29 (the cross-origin marker skips the guard and // stamping, is cleared from the outbound headers, and suppresses the challenge reaction too -- so the // credential cannot re-enter via the 401), AUTH-25 (a 407 is answered from Proxy-Authenticate into diff --git a/packages/core/src/auth/bearer-cache.test.ts b/packages/core/src/auth/bearer-cache.test.ts index 23b1654..1b58bce 100644 --- a/packages/core/src/auth/bearer-cache.test.ts +++ b/packages/core/src/auth/bearer-cache.test.ts @@ -1,6 +1,9 @@ // SPDX-License-Identifier: MIT // packages/core/src/auth/bearer-cache.test.ts -// Exercises: AUTH-34 (fresh-zone hot-path read, no refresh), AUTH-35 (null/expired provider result +// Exercises: XCUT-12 (hot-path credential-cache reads take no lock while valid, and refresh is +// single-flight under a lock scoped to THIS cache -- the concurrent-coalescing tests below are the +// SHOULD's conformance clause: "race N threads on an expiring token; assert exactly one fetch"), +// AUTH-34 (fresh-zone hot-path read, no refresh), AUTH-35 (null/expired provider result // throws and is never cached; a rejecting provider propagates and is never cached), AUTH-37 // (expiring-but-valid zone: stale value returned, background refresh fired, a FAILED background // refresh non-fatal and not an unhandled rejection; expired/missing zone: single-flight await, diff --git a/packages/core/src/auth/digest.test.ts b/packages/core/src/auth/digest.test.ts index c43fd80..d887af9 100644 --- a/packages/core/src/auth/digest.test.ts +++ b/packages/core/src/auth/digest.test.ts @@ -1,6 +1,9 @@ // SPDX-License-Identifier: MIT // packages/core/src/auth/digest.test.ts -// Exercises: AUTH-15 (exactly {MD5, MD5-sess, SHA-256, SHA-256-sess}, qop=auth or absent, declines +// Exercises: XCUT-14 (the per-nonce counter is a server-keyed, process-lived map, so it carries a hard +// 1024-entry cap and drains back under it with a loop after each insert -- asserted below), XCUT-21 +// (the client nonce is drawn from a CSPRNG with >= 128 bits of entropy, never a non-cryptographic RNG), +// AUTH-15 (exactly {MD5, MD5-sess, SHA-256, SHA-256-sess}, qop=auth or absent, declines // auth-int and unsupported algorithms), AUTH-16 (satisfiability: scheme/realm/nonce/qop/algorithm, // and configured-preference order over wire order), AUTH-17 (HA1/HA2/response per RFC 7616/2069, // verified against independently-computed vectors), AUTH-18/AUTH-19 (nonce count: starts at 1, diff --git a/packages/core/src/body/http-status-error.test.ts b/packages/core/src/body/http-status-error.test.ts index cd85c01..c277d1f 100644 --- a/packages/core/src/body/http-status-error.test.ts +++ b/packages/core/src/body/http-status-error.test.ts @@ -2,7 +2,11 @@ // packages/core/src/body/http-status-error.test.ts // Exercises: HTTP-52/BODY-30 (1 MiB cap, replayable re-serve, buffered inside close-guaranteeing scope), // BODY-31 (4xx/5xx only, no-body response returned unchanged), BODY-33 (non-consuming preview), -// HTTP-42 (preview decodes with the media type's charset, falling back to UTF-8, never throwing) +// HTTP-42 (preview decodes with the media type's charset, falling back to UTF-8, never throwing), +// XCUT-8 (the status-to-exception mapping factory refuses to fabricate a "successful exception": +// toHttpError returns null for 1xx/2xx/3xx rather than an error, which is the absent/null +// convenience form XCUT-8 explicitly permits in place of a throwing strict mapper. The port ships +// only that form -- see docs/open-items.md N2 for the constructor-level hole in the same guarantee). import {describe, expect, test} from 'bun:test'; import {Headers} from '../http/headers.js'; import {Protocol} from '../http/protocol.js'; diff --git a/packages/core/src/context/store.test.ts b/packages/core/src/context/store.test.ts index f040c9f..17db477 100644 --- a/packages/core/src/context/store.test.ts +++ b/packages/core/src/context/store.test.ts @@ -1,6 +1,9 @@ // SPDX-License-Identifier: MIT // packages/core/src/context/store.test.ts -// Exercises: CTX-3 (all three flavors collapse to one slot, successive promotions overwriting it), +// Exercises: XCUT-14 (a process-lived map whose key space callers influence carries a hard cap and +// drains back under it with a LOOP after each insert, so an insert burst converges to the bound +// instead of sitting above it -- see the burst test near the end of this file), +// CTX-3 (all three flavors collapse to one slot, successive promotions overwriting it), // CTX-4 (two contexts sharing identical trace AND span id get distinct keys and both // register), CTX-8 (install-or-replace never throws; reject-on-duplicate fails naming the key), // CTX-9/CTX-10 (identity-conditional close, intermediate-link close is a no-op), CTX-11/CTX-12 (bounded, diff --git a/packages/core/src/http/headers.test.ts b/packages/core/src/http/headers.test.ts index 08cde02..762158a 100644 --- a/packages/core/src/http/headers.test.ts +++ b/packages/core/src/http/headers.test.ts @@ -1,7 +1,13 @@ // SPDX-License-Identifier: MIT // packages/core/src/http/headers.test.ts -// Exercises: HTTP-13 (case-insensitive storage), HTTP-14 (multi-value add/set), HTTP-15 (null removes), +// Exercises: XCUT-18 (header name/value validation is the request-splitting defense, and it lives at the +// transport-agnostic model layer so no transport can be reached with a CR/LF-bearing header: names reject +// every C0 control byte INCLUDING HTAB plus DEL and non-ASCII; outbound values reject the same set EXCEPT +// HTAB; inbound values are lenient about obs-text but still reject control bytes), +// HTTP-13 (case-insensitive storage), HTTP-14 (multi-value add/set), HTTP-15 (null removes), // HTTP-16 (insertion order), HTTP-3 (newBuilder derivation doesn't alias), HTTP-5 (no live-builder leak), +// XCUT-15's ingested-collection clause (a builder defensively copies what it is handed, so mutating that +// collection after build() cannot alter the built model, and a derived builder never aliases its source), // HTTP-17 (outbound name validation + trim), HTTP-18 (outbound value validation), HTTP-19 (inbound leniency), // HTTP-20 (no value echo, escaped name), HTTP-21 (typed HeaderName interop) import {describe, expect, test} from 'bun:test'; diff --git a/packages/core/src/http/request.test.ts b/packages/core/src/http/request.test.ts index 2506d7e..216b150 100644 --- a/packages/core/src/http/request.test.ts +++ b/packages/core/src/http/request.test.ts @@ -1,6 +1,10 @@ // SPDX-License-Identifier: MIT // packages/core/src/http/request.test.ts -// Exercises: HTTP-6 (required fields), HTTP-7 (body/method legality), HTTP-8 (GET default / missing method), +// Exercises: XCUT-15's alias and new-instance clauses (a model retains no alias to externally-mutable +// state -- the returned URL is cloned per access, so mutating it cannot reach the request -- and every +// "setter" yields a NEW instance rather than mutating in place: the HTTP-3/5 rows below. The +// ingested-collection clause is asserted in headers.test.ts and query-params.test.ts), +// HTTP-6 (required fields), HTTP-7 (body/method legality), HTTP-8 (GET default / missing method), // HTTP-9 (method), HTTP-46 (textual URL equality, no DNS), HTTP-47 (malformed URL), HTTP-3/5 (derivation, // immutability) import {describe, expect, test} from 'bun:test'; diff --git a/packages/core/src/observability/logging-step.test.ts b/packages/core/src/observability/logging-step.test.ts index be9c025..82894e6 100644 --- a/packages/core/src/observability/logging-step.test.ts +++ b/packages/core/src/observability/logging-step.test.ts @@ -1,6 +1,9 @@ // SPDX-License-Identifier: MIT // packages/core/src/observability/logging-step.test.ts -// Exercises: OBS-34 (granularity gates log events, not span/metrics), OBS-35 (level resolves from +// Exercises: XCUT-20 (observability code paths NEVER throw into the caller's request path -- a failing +// sink degrades to a self-describing http.instrumentation.* event and the request still completes), +// XCUT-24 (diagnostic body previews are byte-capped and non-consuming: OBS-36/37/38 below), +// OBS-34 (granularity gates log events, not span/metrics), OBS-35 (level resolves from // Configuration, tolerant/case-insensitive), OBS-39 (stable http.request/http.response event names/keys, // url.full always redacted), OBS-20 (a throwing Logger is caught and re-surfaced as http.instrumentation.*; // a throwing tracer/meter propagates, NOT caught), OBS-36, OBS-37, OBS-38 (body previews). diff --git a/packages/core/src/observability/redaction.test.ts b/packages/core/src/observability/redaction.test.ts index 16516af..6374a3c 100644 --- a/packages/core/src/observability/redaction.test.ts +++ b/packages/core/src/observability/redaction.test.ts @@ -1,6 +1,10 @@ // SPDX-License-Identifier: MIT // packages/core/src/observability/redaction.test.ts -// Exercises: OBS-11 (userinfo always redacted), OBS-12 (query allow-list, default {api-version}), OBS-13 +// Exercises: XCUT-19 (logging/telemetry redacts secrets BY DEFAULT: userinfo is never allow-listable, +// query and fragment key=value tokens are redacted unless explicitly allow-listed, and header logging is +// default-deny -- the whole clause is asserted across this file and credential.test.ts's no-secret-in- +// string-form rows), +// OBS-11 (userinfo always redacted), OBS-12 (query allow-list, default {api-version}), OBS-13 // (fragment key=value tokens redacted the same way, plain fragment preserved), OBS-14 (scheme/host/port/path // untouched, no spurious "?"), OBS-15 (malformed URL -> fixed sentinel, never throws), OBS-16 (header-value // URL: absolute redacted like a request URL, relative keeps path + "?***" marker), OBS-18 (header-name diff --git a/packages/core/src/redirect/decide.test.ts b/packages/core/src/redirect/decide.test.ts index f1b8bb9..264b55c 100644 --- a/packages/core/src/redirect/decide.test.ts +++ b/packages/core/src/redirect/decide.test.ts @@ -1,5 +1,9 @@ // SPDX-License-Identifier: MIT // packages/core/src/redirect/decide.test.ts +// Exercises: XCUT-17's two clauses a plaintext conformance fixture cannot reach -- (c) userinfo embedded +// in a Location is dropped before re-issue (REDIR-12), and (d) an HTTPS-to-HTTP downgrade is denied by +// default and permitted only by explicit opt-in (REDIR-14/15). The stripping clauses (a)/(b) are asserted +// end-to-end in tests/conformance/xcut/security-by-default.conformance.test.ts. // Exercises every numbered step of decide()'s contract: REDIR-1/REDIR-2 (the non-redirect fast path and // the never-followed 300/304/305), REDIR-21 (a recognized 3xx always allocates the snapshot and consults // the predicate, even with no usable Location; a non-redirect status never does), REDIR-20 (the predicate diff --git a/packages/core/src/retry/classify.test.ts b/packages/core/src/retry/classify.test.ts index 9b5206c..521fd5a 100644 --- a/packages/core/src/retry/classify.test.ts +++ b/packages/core/src/retry/classify.test.ts @@ -4,7 +4,11 @@ // identity-tracking cause walk, cycle-safe), RETRY-3 (retryability derived from status, not a stored // flag), RETRY-4 (transport failures always retryable), RETRY-5/6/7 (re-sendability), RETRY-8 (both // axes required), RETRY-23/24 (cancellation vs timeout), RETRY-25 (allow-list makes the fatal -// exclusion vacuous), RETRY-37 (configured set is authoritative -- widens AND narrows). +// exclusion vacuous), RETRY-37 (configured set is authoritative -- widens AND narrows), +// XCUT-5 (the baked retryability flag comes from ONE shared status classifier covering 408/429/all +// 5xx except 501 and 505 -- asserted below. This port has no separately-cached boolean field: the +// classifier is a pure function of HttpStatusError.status, which never changes post-construction +// (XCUT-15), so querying it at any later time is equivalent to reading a flag baked at construction). import {describe, expect, test} from 'bun:test'; import fc from 'fast-check'; import {HttpStatusError} from '../body/http-status-error.js'; diff --git a/packages/shrink-test/package.json b/packages/shrink-test/package.json new file mode 100644 index 0000000..ef09166 --- /dev/null +++ b/packages/shrink-test/package.json @@ -0,0 +1,15 @@ +{ + "name": "@dexpace/shrink-test", + "version": "0.0.0", + "private": true, + "type": "module", + "devDependencies": { + "@dexpace/codec-json": "workspace:*", + "@dexpace/core": "workspace:*", + "@dexpace/transport-fetch": "workspace:*", + "esbuild": "^0.28.2" + }, + "scripts": { + "test": "bun test" + } +} diff --git a/packages/shrink-test/shrink-test.config.ts b/packages/shrink-test/shrink-test.config.ts new file mode 100644 index 0000000..7fbd58a --- /dev/null +++ b/packages/shrink-test/shrink-test.config.ts @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: MIT +// packages/shrink-test/shrink-test.config.ts + +/** The knobs {@link SHRINK_TEST_CONFIG} fixes for the `NFR-9` guard. */ +export interface ShrinkTestConfig { + /** + * The hard ceiling `run-shrink-guard.ts` fails the build on, in bytes of minified, tree-shaken + * output. Not a footprint target: the number exists to catch a *regression in shape* -- a barrel + * that stops tree-shaking, a side-effectful module pulled in wholesale -- not to police normal + * growth. Raise it only with the measured before/after in the commit message. + */ + readonly budgetBytes: number; + /** + * The packages `fixture-app.ts` imports, and therefore the ones this guard proves survive a + * bundle-and-tree-shake round trip. Recorded here so the set is reviewable in one place rather + * than inferred from the fixture's import list. + */ + readonly participatingPackages: readonly string[]; +} + +/** + * Measured at 16,671 bytes on 2026-08-29 (esbuild 0.28.2; `@dexpace/core` + `@dexpace/transport-fetch` + * + `@dexpace/codec-json`, all three reached through their published entry points). The budget is + * 24 KiB -- ~47% headroom, which absorbs ordinary growth while still catching the failure this guard + * exists for: a tree-shaking regression pulls in core's barrel wholesale and shows up as a multiple + * of this figure, not a few percent over it. A loose budget would catch nothing. + */ +export const SHRINK_TEST_CONFIG: ShrinkTestConfig = Object.freeze({ + budgetBytes: 24_576, + participatingPackages: Object.freeze([ + '@dexpace/core', + '@dexpace/transport-fetch', + '@dexpace/codec-json', + ]), +}); diff --git a/packages/shrink-test/src/bundle.test.ts b/packages/shrink-test/src/bundle.test.ts new file mode 100644 index 0000000..3e0192d --- /dev/null +++ b/packages/shrink-test/src/bundle.test.ts @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: MIT +// packages/shrink-test/src/bundle.test.ts +// Exercises: NFR-9 (the shrink-and-run guard's bundle half -- a real minify + tree-shake pass over +// the shipped packages, which is what the round-trip check in run-shrink-guard.test.ts then runs). +import {describe, expect, test} from 'bun:test'; +import {SHRINK_TEST_CONFIG} from '../shrink-test.config.js'; +import {buildShrinkBundle} from './bundle.js'; + +describe('buildShrinkBundle', () => { + test('produces a single bundle within the configured budget', async () => { + const {code, bytes} = await buildShrinkBundle(); + + expect(code.length).toBeGreaterThan(0); + expect(bytes).toBeLessThanOrEqual(SHRINK_TEST_CONFIG.budgetBytes); + }); + + test('minifies, rather than emitting the readable source verbatim', async () => { + const {code} = await buildShrinkBundle(); + + // Source indentation would survive verbatim if `minify` silently stopped applying. + expect(code).not.toContain('\n runFixtureApp'); + }); + + test('tree-shakes, rather than inlining every package the workspace publishes', async () => { + const {bytes} = await buildShrinkBundle(); + + // The fixture touches a narrow slice of core. Pulling the barrel in wholesale -- the regression + // this guard exists to catch -- lands as a multiple of the budget, not a few bytes over it. + expect(bytes).toBeLessThan(SHRINK_TEST_CONFIG.budgetBytes * 2); + }); +}); diff --git a/packages/shrink-test/src/bundle.ts b/packages/shrink-test/src/bundle.ts new file mode 100644 index 0000000..48ac81f --- /dev/null +++ b/packages/shrink-test/src/bundle.ts @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: MIT +// packages/shrink-test/src/bundle.ts +import {fileURLToPath} from 'node:url'; +import {build} from 'esbuild'; + +/** The in-memory result of one bundle-and-minify pass; nothing is written to disk here. */ +export interface ShrinkBundle { + /** The bundled, minified, tree-shaken ESM source. */ + readonly code: string; + /** Its size in bytes, as the budget in `shrink-test.config.ts` measures it. */ + readonly bytes: number; +} + +/** + * Bundles `fixture-app.ts` and everything it imports into one minified, tree-shaken ESM module, the + * way a downstream consumer's bundler would. + * + * `write: false` keeps the pass in memory -- `run-shrink-guard.ts` is the only caller that needs the + * bytes on disk, and it writes them to a temp dir it owns. `platform: 'node'` matches the runtime the + * guard then executes the output on, so `node:` builtins stay external instead of being inlined or + * shimmed. + * + * @returns the bundled code and its byte length. + * @throws Error - when esbuild reports success but produces no output file, which would otherwise + * surface later as an unreadable `undefined` and be mistaken for a size regression. + */ +export async function buildShrinkBundle(): Promise { + const entryPoint = fileURLToPath( + new URL('./fixture-app.ts', import.meta.url), + ); + const result = await build({ + entryPoints: [entryPoint], + bundle: true, + minify: true, + treeShaking: true, + platform: 'node', + format: 'esm', + write: false, + }); + + const output = result.outputFiles[0]; + if (output === undefined) { + throw new Error('esbuild reported success but produced no output file'); + } + return {code: output.text, bytes: output.contents.byteLength}; +} diff --git a/packages/shrink-test/src/fixture-app.ts b/packages/shrink-test/src/fixture-app.ts new file mode 100644 index 0000000..e59174f --- /dev/null +++ b/packages/shrink-test/src/fixture-app.ts @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: MIT +// packages/shrink-test/src/fixture-app.ts +import {jsonSerde} from '@dexpace/codec-json'; +import {IoError, Request, type Schema} from '@dexpace/core'; +import {fetchTransport} from '@dexpace/transport-fetch'; + +/** What {@link runFixtureApp} reports back to the guard, over stdout, from the child process. */ +export interface FixtureResult { + /** True when the error thrown by `transport-fetch` matched `IoError` imported from `core`. */ + readonly caughtViaCoreImport: boolean; + /** True when a serialize/deserialize round trip through `codec-json` returned the input. */ + readonly serdeRoundTripOk: boolean; +} + +/** The one shape the round trip carries; a hand-written `Schema` keeps the fixture codec-agnostic. */ +interface ShrinkProbe { + readonly shrinkTest: boolean; +} + +/** + * `Deserializer.deserialize` takes a `Schema` witness rather than a reflected type token + * (`docs/sdk-design-nodejs/10` item 7's schema-as-witness substitution), so the fixture supplies a + * minimal one instead of reaching for a validation library it would then have to bundle. + */ +const shrinkProbeSchema: Schema = { + parse(input: unknown): ShrinkProbe { + if ( + typeof input !== 'object' || + input === null || + typeof (input as {shrinkTest?: unknown}).shrinkTest !== 'boolean' + ) { + throw new TypeError('not a ShrinkProbe'); + } + return {shrinkTest: (input as ShrinkProbe).shrinkTest}; + }, +}; + +/** + * Runs inside the bundled, tree-shaken artifact -- never against `src/` directly, which is the whole + * point (see `run-shrink-guard.ts`). + * + * Proves the two properties a bundler round trip can silently break. First, cross-package + * `instanceof`: `TransportFailureError` is thrown by `@dexpace/transport-fetch` and its base class + * `IoError` is imported here from `@dexpace/core`, so the check passes only if the bundle contains + * exactly ONE copy of core's class identity. Two copies -- the dual-package hazard + * `docs/knowledge/tooling-and-quality-gates.md` names, and the risk this port substitutes for the + * reference's reflective keep-rules (`NFR-8`, deviation-ledger item 10) -- make it silently false + * while every type still checks. Second, that a real serde round trip still works once the codec has + * been through the same minifier. + * + * Port 1 is chosen because nothing listens there: the connection is refused immediately, so the + * guard needs no fixture server and cannot hang on a slow socket. + * + * @returns both checks, for the parent process to assert on. + */ +export async function runFixtureApp(): Promise { + const transport = fetchTransport(); + let caughtViaCoreImport = false; + try { + await transport.send( + Request.newBuilder().url('http://127.0.0.1:1/').method('GET').build(), + ); + } catch (error) { + caughtViaCoreImport = error instanceof IoError; + } finally { + await transport.close(); + } + + const serde = jsonSerde(); + const bytes = serde.serializer.serialize({shrinkTest: true}); + const decoded = serde.deserializer.deserialize(bytes, shrinkProbeSchema); + + return {caughtViaCoreImport, serdeRoundTripOk: decoded.shrinkTest}; +} diff --git a/packages/shrink-test/src/run-shrink-guard.test.ts b/packages/shrink-test/src/run-shrink-guard.test.ts new file mode 100644 index 0000000..e887bd6 --- /dev/null +++ b/packages/shrink-test/src/run-shrink-guard.test.ts @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: MIT +// packages/shrink-test/src/run-shrink-guard.test.ts +// Exercises: NFR-9 (shrink-and-run regression guard, wired into the default build via the root +// `shrink-test` script), NFR-17 (that gate is blocking, not advisory). +// Substitutes for NFR-8's keep-configuration, which this port ships nothing for by design -- see the +// Phase 9 deviation ledger and docs/knowledge/deliberate-deviations.md:32. +import {describe, expect, test} from 'bun:test'; +import {runShrinkGuard} from './run-shrink-guard.js'; + +describe('runShrinkGuard', () => { + test('the shrunk bundle stays within budget and still runs standalone', async () => { + const result = await runShrinkGuard(); + + expect(result.bundleBytes).toBeLessThanOrEqual(result.budgetBytes); + expect(result.roundTripSucceeded).toBe(true); + }); +}); diff --git a/packages/shrink-test/src/run-shrink-guard.ts b/packages/shrink-test/src/run-shrink-guard.ts new file mode 100644 index 0000000..b8bacee --- /dev/null +++ b/packages/shrink-test/src/run-shrink-guard.ts @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: MIT +// packages/shrink-test/src/run-shrink-guard.ts +import {spawn} from 'node:child_process'; +import {mkdtemp, rm, writeFile} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {SHRINK_TEST_CONFIG} from '../shrink-test.config.js'; +import {buildShrinkBundle} from './bundle.js'; + +/** The `NFR-9` guard's verdict: the size half and the still-runs half, reported together. */ +export interface ShrinkGuardResult { + /** Size of the minified, tree-shaken bundle. */ + readonly bundleBytes: number; + /** The ceiling from `shrink-test.config.ts` it must not exceed. */ + readonly budgetBytes: number; + /** True when the bundled artifact ran standalone and both of its checks passed. */ + readonly roundTripSucceeded: boolean; +} + +/** Runs `runnerPath` under this process's own Node and resolves true on a clean exit. */ +function runInChild(runnerPath: string): Promise { + return new Promise(resolve => { + const child = spawn(process.execPath, [runnerPath], {stdio: 'ignore'}); + child.on('error', () => { + resolve(false); + }); + child.on('exit', code => { + resolve(code === 0); + }); + }); +} + +/** + * The `NFR-9` regression guard: bundle, shrink, then actually run the result. + * + * The bundled code executes in a **child process**, not through `eval` or a dynamic import of this + * one. That is the entire point of the guard rather than an implementation detail -- importing it + * here would resolve `@dexpace/core` through this process's already-warm module graph and prove + * nothing about the artifact standing on its own. A separate `node` sees only the bytes esbuild + * emitted, which is what a downstream consumer ships. + * + * The child is spawned with `stdio: 'ignore'` and reports through its exit code alone; the runner it + * executes exits non-zero when either fixture check comes back false, so a stripped `instanceof` + * surfaces as a failed guard rather than as parsed output this function would have to trust. + * + * @returns the measured size, the configured budget, and whether the artifact still worked. The + * caller decides what fails the build -- see `run-shrink-guard.test.ts`. + */ +export async function runShrinkGuard(): Promise { + const {code, bytes} = await buildShrinkBundle(); + const dir = await mkdtemp(join(tmpdir(), 'dexpace-shrink-test-')); + try { + const entryPath = join(dir, 'bundle.mjs'); + const runnerPath = join(dir, 'runner.mjs'); + await writeFile(entryPath, code, 'utf8'); + await writeFile( + runnerPath, + [ + `import {runFixtureApp} from ${JSON.stringify(entryPath)};`, + 'const result = await runFixtureApp();', + 'process.exit(result.caughtViaCoreImport && result.serdeRoundTripOk ? 0 : 1);', + '', + ].join('\n'), + 'utf8', + ); + + const roundTripSucceeded = await runInChild(runnerPath); + return { + bundleBytes: bytes, + budgetBytes: SHRINK_TEST_CONFIG.budgetBytes, + roundTripSucceeded, + }; + } finally { + await rm(dir, {recursive: true, force: true}); + } +} diff --git a/packages/shrink-test/tsconfig.json b/packages/shrink-test/tsconfig.json new file mode 100644 index 0000000..258bbb9 --- /dev/null +++ b/packages/shrink-test/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": ".", + "target": "ES2023", + "lib": [ + "ES2023", + "DOM", + "DOM.AsyncIterable" + ] + }, + "include": [ + "src/**/*.ts", + "shrink-test.config.ts" + ] +} diff --git a/packages/transport-fetch/src/fetch-transport.test.ts b/packages/transport-fetch/src/fetch-transport.test.ts index 97fd065..c4b3796 100644 --- a/packages/transport-fetch/src/fetch-transport.test.ts +++ b/packages/transport-fetch/src/fetch-transport.test.ts @@ -1,6 +1,9 @@ // SPDX-License-Identifier: MIT // packages/transport-fetch/src/fetch-transport.test.ts -// Exercises: TRANSPORT-2 (no retrying/redirecting dispatcher is ever composed), TRANSPORT-15/16 +// Exercises: XCUT-13 (close is idempotent -- a repeat call is a latched no-op that neither throws nor +// blocks), XCUT-22 (the SDK closes only what it created; this transport creates no pooled resource, so +// its close owns nothing to release), +// TRANSPORT-2 (no retrying/redirecting dispatcher is ever composed), TRANSPORT-15/16 // (close is a documented no-op), TRANSPORT-17/19 (single-use body written once, abandoned producer // unblocked), TRANSPORT-22 (an adaptation throw still closes the native response), TRANSPORT-30 // (no proxy option exists at all) diff --git a/packages/transport-undici/src/undici-transport.test.ts b/packages/transport-undici/src/undici-transport.test.ts index ea8f166..053deda 100644 --- a/packages/transport-undici/src/undici-transport.test.ts +++ b/packages/transport-undici/src/undici-transport.test.ts @@ -2,6 +2,9 @@ // packages/transport-undici/src/undici-transport.test.ts // Exercises: TRANSPORT-2 (no redirect interceptor is composed), TRANSPORT-8 (a native-internal cancel // is terminal while a timeout stays retryable), TRANSPORT-11 (undici keeps `Connection`), +// XCUT-22 (the SDK closes only resources it created: a caller-supplied dispatcher is never closed and +// stays usable afterwards), XCUT-13 (close is idempotent -- a second call is a no-op that neither +// throws nor blocks), // TRANSPORT-15/16 (ownership-aware, idempotent close), TRANSPORT-22 (an adaptation throw destroys the // native body), TRANSPORT-20 (a permanent argument error is terminal, a no-response failure is // retryable), TRANSPORT-28 (a file body dispatches its declared byte range), SEAM-14 diff --git a/scripts/knowledge.mjs b/scripts/knowledge.mjs index e10150b..fd24927 100644 --- a/scripts/knowledge.mjs +++ b/scripts/knowledge.mjs @@ -1,3 +1,4 @@ +// SPDX-License-Identifier: MIT // scripts/knowledge.mjs // // Query surface over `docs/knowledge/`. The corpus is 39 topic files and ~1470 diff --git a/scripts/knowledge.test.mjs b/scripts/knowledge.test.mjs index 309f466..720a681 100644 --- a/scripts/knowledge.test.mjs +++ b/scripts/knowledge.test.mjs @@ -1,3 +1,4 @@ +// SPDX-License-Identifier: MIT // scripts/knowledge.test.mjs // // Run with `bun run test:scripts` (`node --test 'scripts/*.test.mjs'` — Node diff --git a/tests/conformance/xcut/cancellation-and-timeout.conformance.test.ts b/tests/conformance/xcut/cancellation-and-timeout.conformance.test.ts new file mode 100644 index 0000000..d31fd65 --- /dev/null +++ b/tests/conformance/xcut/cancellation-and-timeout.conformance.test.ts @@ -0,0 +1,204 @@ +// SPDX-License-Identifier: MIT +// tests/conformance/xcut/cancellation-and-timeout.conformance.test.ts +// Exercises: XCUT-1 (cancellation is terminal, non-retryable, and the ambient cancel flag survives), +// XCUT-3 (an inter-attempt retry wait is promptly cancellable and surfaces the cancellation signal, +// not a spurious timeout). +// XCUT-2 stays a retrofit citation at its Phase 2 source (packages/core/src/seams/transport.test.ts), +// where isTimeoutSignal's two branches are asserted directly. +// +// These run the invariants through the fully composed retry+redirect+auth+logging pipeline over a +// real socket, which is what this suite adds over 5a's and Phase 2's own unit-level coverage. +import {afterAll, beforeAll, describe, expect, test} from 'bun:test'; +import {CancellationError, Request} from '@dexpace/core'; +import {buildComposedPipeline} from './fixtures/composed-pipeline.js'; +import {startFixtureServer, type XcutFixtureServer} from './fixtures/server.js'; +import {rejectionOf} from './fixtures/settle.js'; + +let server: XcutFixtureServer; + +/** + * Walks everything a surfaced failure can nest a prior error under, visiting by identity so a cyclic + * chain terminates -- the same discipline `XCUT-9` puts on the classifier. + * + * The walk is necessary because 5a's engine folds the retry trail into a `SuppressedError` + * (`RETRY-34`), so the cancellation that ended a backoff wait arrives as `.error` beneath a wrapper + * rather than as the top-level throwable. Asserting on the top level alone would test the wrapping, + * not the invariant. + */ +function* chainOf(error: unknown): Generator { + const seen = new Set(); + const queue: unknown[] = [error]; + while (queue.length > 0) { + const current = queue.shift(); + if (current === null || current === undefined || seen.has(current)) + continue; + seen.add(current); + yield current; + if (typeof current !== 'object') continue; + const node = current as Record; + queue.push(node.cause, node.error, node.suppressed); + } +} + +/** True when anything in the chain is a caller cancellation, by type or by `AbortSignal` reason name. */ +function carriesCancellation(error: unknown): boolean { + for (const link of chainOf(error)) { + if (link instanceof CancellationError) return true; + if ((link as {name?: unknown} | null)?.name === 'AbortError') return true; + } + return false; +} + +/** True when anything in the chain is a timeout -- the classification `XCUT-3` forbids here. */ +function carriesTimeout(error: unknown): boolean { + for (const link of chainOf(error)) { + if ((link as {name?: unknown} | null)?.name === 'TimeoutError') return true; + } + return false; +} + +beforeAll(async () => { + server = await startFixtureServer(); +}); + +afterAll(async () => { + await server.close(); +}); + +describe('XCUT-1: cancellation is terminal and never retried', () => { + test('surfaces CancellationError when an in-flight request is aborted', async () => { + const pipeline = buildComposedPipeline({ + retry: {settings: {maxAttempts: 3, initialDelayMs: 1}}, + }); + const controller = new AbortController(); + const pending = pipeline.runtime.send( + Request.newBuilder().url(`${server.url}/slow?ms=2000`).build(), + undefined, + controller.signal, + ); + setTimeout(() => { + controller.abort(); + }, 25).unref(); + + expect(await rejectionOf(pending)).toBeInstanceOf(CancellationError); + + await pipeline.close(); + }); + + test('does not re-dispatch a cancelled request when retries remain', async () => { + const pipeline = buildComposedPipeline({ + retry: {settings: {maxAttempts: 3, initialDelayMs: 1}}, + }); + const controller = new AbortController(); + const pending = pipeline.runtime.send( + Request.newBuilder().url(`${server.url}/slow?ms=2000`).build(), + undefined, + controller.signal, + ); + setTimeout(() => { + controller.abort(); + }, 25).unref(); + await pending.catch(() => undefined); + + // The retry pillar had two attempts left and must not have spent them: XCUT-1 makes + // cancellation terminal at the condition level, distinct from the safety gate. + expect(pipeline.dispatches()).toBe(1); + + await pipeline.close(); + }); + + test('leaves the ambient cancellation flag set after the error surfaces', async () => { + const pipeline = buildComposedPipeline({ + retry: {settings: {maxAttempts: 1}}, + }); + const controller = new AbortController(); + const pending = pipeline.runtime.send( + Request.newBuilder().url(`${server.url}/slow?ms=2000`).build(), + undefined, + controller.signal, + ); + setTimeout(() => { + controller.abort(); + }, 25).unref(); + await pending.catch(() => undefined); + + // The JVM reference re-asserts the interrupt flag; the port's equivalent is that the signal it + // was handed is still aborted, never reset on the way out (deviation ledger item 11). + expect(controller.signal.aborted).toBe(true); + + await pipeline.close(); + }); +}); + +describe('XCUT-3: an inter-attempt wait is promptly cancellable', () => { + test('aborts a 60s backoff near-immediately instead of waiting it out', async () => { + const pipeline = buildComposedPipeline({ + retry: {settings: {maxAttempts: 5, initialDelayMs: 60_000}}, + }); + const controller = new AbortController(); + const startedAt = Date.now(); + const pending = pipeline.runtime.send( + Request.newBuilder().url(`${server.url}/fail-500`).build(), + undefined, + controller.signal, + ); + setTimeout(() => { + controller.abort(); + }, 50).unref(); + await pending.catch(() => undefined); + + // Nowhere near the 60s backoff: the wait aborted rather than expiring. + expect(Date.now() - startedAt).toBeLessThan(5_000); + + await pipeline.close(); + }); + + test('surfaces a cancellation, not a spurious timeout, from inside the wait', async () => { + const pipeline = buildComposedPipeline({ + retry: {settings: {maxAttempts: 5, initialDelayMs: 60_000}}, + }); + const controller = new AbortController(); + const pending = pipeline.runtime.send( + Request.newBuilder().url(`${server.url}/fail-500`).build(), + undefined, + controller.signal, + ); + setTimeout(() => { + controller.abort(); + }, 50).unref(); + const surfaced = await rejectionOf(pending); + + // Asserted on the chain rather than the top-level type ON PURPOSE. The cancellation arrives + // here as a bare DOMException `AbortError` under a `SuppressedError`, NOT as the SDK's own + // `CancellationError` -- 5a's engine surfaces `signal.reason` and whatever `clock.sleep` threw + // verbatim, while the transport path maps the same abort through `abortToSdkError` and does + // produce `CancellationError`. XCUT-3's letter is met either way (a cancellation is surfaced + // and it is not a timeout), so this asserts exactly that, and the cross-layer type + // inconsistency is filed as a finding rather than pinned here -- see docs/open-items.md N1. + expect(carriesCancellation(surfaced)).toBe(true); + expect(carriesTimeout(surfaced)).toBe(false); + + await pipeline.close(); + }); + + test('does not dispatch a further attempt once the wait is cancelled', async () => { + const pipeline = buildComposedPipeline({ + retry: {settings: {maxAttempts: 5, initialDelayMs: 60_000}}, + }); + const controller = new AbortController(); + const pending = pipeline.runtime.send( + Request.newBuilder().url(`${server.url}/fail-500`).build(), + undefined, + controller.signal, + ); + setTimeout(() => { + controller.abort(); + }, 50).unref(); + await pending.catch(() => undefined); + + // One dispatch produced the 500; the cancelled wait must not produce a second. + expect(pipeline.dispatches()).toBe(1); + + await pipeline.close(); + }); +}); diff --git a/tests/conformance/xcut/concurrency-and-lifecycle.conformance.test.ts b/tests/conformance/xcut/concurrency-and-lifecycle.conformance.test.ts new file mode 100644 index 0000000..0d83eb9 --- /dev/null +++ b/tests/conformance/xcut/concurrency-and-lifecycle.conformance.test.ts @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MIT +// tests/conformance/xcut/concurrency-and-lifecycle.conformance.test.ts +// Exercises: XCUT-11 (a shared, reusable pipeline instance is safe under concurrent invocation and +// keeps per-call state on the call, not the instance), XCUT-13 (close is idempotent and does not +// block). +// +// XCUT-12, XCUT-14 and XCUT-22 stay retrofit citations at their own phases' tests, which assert them +// better than anything reachable from here could: +// XCUT-12 -> packages/core/src/auth/bearer-cache.test.ts (N concurrent callers coalesce to ONE +// provider invocation, in both the expired and post-eviction zones) +// XCUT-14 -> packages/core/src/context/store.test.ts ("a burst of inserts past the cap converges +// the store to at or under the cap") and packages/core/src/auth/digest.test.ts (the +// 1024-entry nonce counter, drain-to-cap under a long run of fresh nonces) +// XCUT-22 -> packages/transport-undici/src/undici-transport.test.ts ("a bring-your-own dispatcher +// is never closed by the transport") +// Neither bounded map is reachable from a consumer-shaped test -- `contextStore` and +// `NonceCountStore` are both absent from core's barrel -- so a burst driven from out here could only +// assert that the stack stays alive, never that a cap held. Asserting the cap where it is observable +// and citing it from here is the honest split. +import {afterAll, beforeAll, describe, expect, test} from 'bun:test'; +import {Headers, Request} from '@dexpace/core'; +import {fetchTransport} from '@dexpace/transport-fetch'; +import {buildComposedPipeline} from './fixtures/composed-pipeline.js'; +import {startFixtureServer, type XcutFixtureServer} from './fixtures/server.js'; + +let server: XcutFixtureServer; + +/** Enough concurrency to interleave, small enough not to pace the suite. */ +const CONCURRENCY = 24; + +beforeAll(async () => { + server = await startFixtureServer(); +}); + +afterAll(async () => { + await server.close(); +}); + +describe('XCUT-11: one shared pipeline instance is safe under concurrent use', () => { + test('answers 24 interleaved requests without pairing any response to the wrong request', async () => { + const pipeline = buildComposedPipeline(); + const requests = Array.from({length: CONCURRENCY}, (_, index) => + Request.newBuilder() + .url(`${server.url}/echo?n=${String(index)}`) + .headers( + Headers.newBuilder() + .set('x-correlation', `call-${String(index)}`) + .build(), + ) + .build(), + ); + + const bodies = await Promise.all( + requests.map(async request => { + const response = await pipeline.runtime.send(request); + const text = await response.text(); + await response.close(); + return JSON.parse(text) as {query: Record}; + }), + ); + + // Cross-talk would show up as a response carrying another call's correlation value: per-call + // state (attempt counters, deadlines, seen-URI sets) has to live on the call, not the instance. + expect(bodies.map(body => body.query.n)).toEqual( + Array.from({length: CONCURRENCY}, (_, index) => String(index)), + ); + + await pipeline.close(); + }); + + test('dispatches exactly one attempt per concurrent call, with no double-sends', async () => { + const pipeline = buildComposedPipeline(); + const requests = Array.from({length: CONCURRENCY}, (_, index) => + Request.newBuilder() + .url(`${server.url}/ok?n=${String(index)}`) + .build(), + ); + + const responses = await Promise.all( + requests.map(request => pipeline.runtime.send(request)), + ); + await Promise.all(responses.map(response => response.close())); + + expect(pipeline.dispatches()).toBe(CONCURRENCY); + await pipeline.close(); + }); +}); + +describe('XCUT-13: close is idempotent and non-blocking', () => { + test('closing a real transport twice makes the second call a no-op', async () => { + const transport = fetchTransport(); + + await transport.close(); + await transport.close(); + + // Reaching this line is the assertion: the second close neither threw nor hung. + expect(true).toBe(true); + }); + + test('closing a composed pipeline twice makes the second call a no-op', async () => { + const pipeline = buildComposedPipeline(); + + await pipeline.runtime.close(); + await pipeline.runtime.close(); + + // Reaching this line is the assertion: the second close neither threw nor hung -- the same + // shape the transports' own TRANSPORT-16 rows use. + expect(pipeline.dispatches()).toBe(0); + + await pipeline.close(); + }); + + test('closing the pipeline leaves the caller-supplied transport usable (XCUT-22 at pipeline level)', async () => { + const pipeline = buildComposedPipeline(); + + await pipeline.runtime.close(); + + // PIPE-27: the pipeline never OWNS its terminal transport, so `Runtime.close()` is deliberately + // a no-op and the transport a caller handed it stays usable. That is XCUT-22's "close only what + // you created" applied one level up -- and the trap it implies is real: a consumer who only ever + // calls `runtime.close()` never closes the transport. Asserted, not assumed. + const response = await pipeline.runtime.send( + Request.newBuilder().url(`${server.url}/ok`).build(), + ); + expect(response.status.code).toBe(200); + + await response.close(); + await pipeline.close(); + }); + + test('does not clear an already-aborted signal on the way out', async () => { + const pipeline = buildComposedPipeline(); + const controller = new AbortController(); + controller.abort(); + + await pipeline.runtime.close(); + + // XCUT-13's "preserves the ambient interrupt/cancel flag as-is" half. + expect(controller.signal.aborted).toBe(true); + await pipeline.close(); + }); +}); diff --git a/tests/conformance/xcut/diagnostic-previews.conformance.test.ts b/tests/conformance/xcut/diagnostic-previews.conformance.test.ts new file mode 100644 index 0000000..3a3bfb6 --- /dev/null +++ b/tests/conformance/xcut/diagnostic-previews.conformance.test.ts @@ -0,0 +1,122 @@ +// SPDX-License-Identifier: MIT +// tests/conformance/xcut/diagnostic-previews.conformance.test.ts +// Exercises: XCUT-24 (a diagnostic preview of a caller- or server-controlled payload is byte-capped +// and non-consuming -- it must not materialize an unbounded payload, and must not disturb the +// primary read path the consumer will use). +// +// Diagnostic previews are not a standalone Response method in this port: they surface through 7b's +// LOGGING step at `granularity: 'body'`, which tees the body bounded to `previewSizeBytes` into the +// emitted `http.response` event (OBS-36). 7b's own logging-step.test.ts asserts that against a fake +// transport and a 50 KB in-memory string; this file runs XCUT-24's own conformance clause verbatim -- +// "a 10 MB response with a small cap" -- over a real socket through the whole composed pipeline, +// which is where a tee that buffered the entire body would actually show up. +import {afterAll, beforeAll, describe, expect, test} from 'bun:test'; +import {Request, createLogger, type LogLevel} from '@dexpace/core'; +import {buildComposedPipeline} from './fixtures/composed-pipeline.js'; +import {startFixtureServer, type XcutFixtureServer} from './fixtures/server.js'; + +let server: XcutFixtureServer; + +/** XCUT-24's own figure: "take a body snapshot/preview of a 10 MB response with a small cap". */ +const BODY_BYTES = 10 * 1024 * 1024; +const PREVIEW_CAP = 1024; + +beforeAll(async () => { + server = await startFixtureServer(); +}); + +afterAll(async () => { + await server.close(); +}); + +/** Collects every emitted event as a plain field map, the same shape 7b's own spy logger uses. */ +function spyLogger(): { + logger: ReturnType; + events: Record[]; +} { + const events: Record[] = []; + const logger = createLogger((_level: LogLevel, fields) => { + events.push(Object.fromEntries(fields)); + }); + return {logger, events}; +} + +/** Drives a 10 MB response through the composed pipeline with body logging capped low. */ +async function captureLargeBody( + mediaType = 'application/octet-stream', +): Promise<{ + events: Record[]; + bodyLength: number; +}> { + const {logger, events} = spyLogger(); + const pipeline = buildComposedPipeline({ + retry: {settings: {maxAttempts: 1}}, + logging: {logger, granularity: 'body', previewSizeBytes: PREVIEW_CAP}, + }); + try { + const response = await pipeline.runtime.send( + Request.newBuilder() + .url( + `${server.url}/large-body?bytes=${String(BODY_BYTES)}&type=${encodeURIComponent(mediaType)}`, + ) + .build(), + ); + const body = await response.bytes(); + await response.close(); + return {events, bodyLength: body.byteLength}; + } finally { + await pipeline.close(); + } +} + +describe('XCUT-24: a diagnostic preview is byte-capped', () => { + test('caps a decoded text preview at previewSizeBytes across a 10 MB body', async () => { + const {events} = await captureLargeBody('text/plain'); + + const responseEvent = events.find(event => event.event === 'http.response'); + expect(String(responseEvent?.['http.response.body.preview'])).toHaveLength( + PREVIEW_CAP, + ); + }); + + test('caps a binary body at the same figure, reported as a size-only marker', async () => { + const {events} = await captureLargeBody('application/octet-stream'); + + const responseEvent = events.find(event => event.event === 'http.response'); + // OBS-38: a binary payload is never decoded into the log. The marker still has to report a + // capped capture, which is the half XCUT-24 cares about. + expect(responseEvent?.['http.response.body.preview']).toBe( + `[binary ${String(PREVIEW_CAP)} bytes captured]`, + ); + }); + + test('reports the captured size as the cap, not the payload size', async () => { + const {events} = await captureLargeBody(); + + const responseEvent = events.find(event => event.event === 'http.response'); + // Had the tee buffered the whole body to slice a preview off the end, this would read 10485760 -- + // which is the memory-exhaustion shape XCUT-24 exists to forbid, not merely a wrong number. + expect(responseEvent?.['http.response.body.size']).toBe(PREVIEW_CAP); + }); + + test('emits no field carrying more than the cap', async () => { + const {events} = await captureLargeBody(); + + const responseEvent = events.find(event => event.event === 'http.response'); + const oversized = Object.entries(responseEvent ?? {}).filter( + ([, value]) => typeof value === 'string' && value.length > PREVIEW_CAP, + ); + // Guards the whole event, not just the field key this port happens to use today. + expect(oversized).toEqual([]); + }); +}); + +describe('XCUT-24: a diagnostic preview is non-consuming', () => { + test('leaves the caller reading every one of the 10485760 bytes', async () => { + const {bodyLength} = await captureLargeBody(); + + // The primary read path must be undisturbed: the consumer sees the full body, not the truncation + // the log saw. + expect(bodyLength).toBe(BODY_BYTES); + }); +}); diff --git a/tests/conformance/xcut/error-taxonomy.conformance.test.ts b/tests/conformance/xcut/error-taxonomy.conformance.test.ts new file mode 100644 index 0000000..e8f9a31 --- /dev/null +++ b/tests/conformance/xcut/error-taxonomy.conformance.test.ts @@ -0,0 +1,201 @@ +// SPDX-License-Identifier: MIT +// tests/conformance/xcut/error-taxonomy.conformance.test.ts +// Exercises: XCUT-4 (two-branch taxonomy -- response-carrying protocol errors vs. response-less +// I/O-family transport errors), XCUT-6 (a custom error type participates in retry with no edit to +// the classifier), XCUT-7 (the CONFIGURED retryable-status set is authoritative and both widens and +// narrows), XCUT-9 (a cyclic cause chain terminates instead of hanging). +// XCUT-5 and XCUT-8 stay retrofit citations at their own phases' tests -- see this file's closing note. +// +// Every row drives the composed pipeline rather than calling `isRetryableFailure` directly. That is +// deliberate on two counts: the classifier is `@internal` and absent from core's barrel, so a +// consumer-shaped test cannot reach it at all; and calling it directly would restate 5a's own +// classify.test.ts, which this suite is explicitly not for. +import {afterAll, beforeAll, describe, expect, test} from 'bun:test'; +import { + IoError, + Request, + toHttpError, + type Response, + type Transport, +} from '@dexpace/core'; +import {buildComposedPipeline} from './fixtures/composed-pipeline.js'; +import {startFixtureServer, type XcutFixtureServer} from './fixtures/server.js'; +import {rejectionOf} from './fixtures/settle.js'; + +let server: XcutFixtureServer; + +beforeAll(async () => { + server = await startFixtureServer(); +}); + +afterAll(async () => { + await server.close(); +}); + +/** A transport that always throws whatever it was handed, to drive classification from the inside. */ +class ThrowingTransport implements Transport { + readonly #error: unknown; + + constructor(error: unknown) { + this.#error = error; + } + + send(): Promise { + // This transport exists to reject with values that are deliberately NOT Errors, so XCUT-9's + // cyclic-cause row and XCUT-6's opted-out row can drive the classifier with whatever they like. + // An `async` + `throw` rewrite only trades this rule for `require-await`. + /* eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- rejecting with a non-Error IS the behavior under test; re-enable if XCUT-6/XCUT-9 stop needing non-Error rejections */ + return Promise.reject(this.#error); + } + + async close(): Promise { + // Nothing to release: this transport never opens anything. + } +} + +describe('XCUT-4: the taxonomy has exactly two branches', () => { + test('a 5xx arrives as a protocol failure carrying its fully-received response', async () => { + const pipeline = buildComposedPipeline({ + retry: {settings: {maxAttempts: 1}}, + }); + + const response = await pipeline.runtime.send( + Request.newBuilder().url(`${server.url}/fail-500`).build(), + ); + + expect(response.status.code).toBe(500); + await response.close(); + await pipeline.close(); + }); + + test('that response converts to the response-carrying error exposing status and body', async () => { + const pipeline = buildComposedPipeline({ + retry: {settings: {maxAttempts: 1}}, + }); + const response = await pipeline.runtime.send( + Request.newBuilder().url(`${server.url}/fail-500`).build(), + ); + + const error = await toHttpError(response); + + expect(error?.status).toBe(500); + expect(error?.preview()).toContain('server error'); + await pipeline.close(); + }); + + test('a connection failure arrives as the response-less I/O-family error', async () => { + const pipeline = buildComposedPipeline({ + retry: {settings: {maxAttempts: 1}}, + }); + + const pending = pipeline.runtime.send( + // Port 1: nothing listens, so the connection is refused rather than merely slow. + Request.newBuilder().url('http://127.0.0.1:1/').build(), + ); + + // Catchable as the generic I/O family, which is XCUT-4's "existing I/O catch sites keep matching". + expect(await rejectionOf(pending)).toBeInstanceOf(IoError); + await pipeline.close(); + }); +}); + +describe('XCUT-6: a custom error type participates without editing the classifier', () => { + test('retries an error type declared in this test file, unknown to classify.ts', async () => { + // The port's retryability capability is subtyping, not a duck-typed `isRetryable` flag: the + // cause-walk returns true for anything `instanceof IoError`, so extending it is what opts a new + // failure in with no classifier edit (deviation ledger item 17, deliberate-deviations.md:168). + class CustomTransientError extends IoError {} + const pipeline = buildComposedPipeline({ + transport: new ThrowingTransport(new CustomTransientError('transient')), + retry: {settings: {maxAttempts: 3, initialDelayMs: 1}}, + }); + + await pipeline.runtime + .send(Request.newBuilder().url(`${server.url}/ok`).build()) + .catch(() => undefined); + + expect(pipeline.dispatches()).toBe(3); + await pipeline.close(); + }); + + test('does not retry a plain Error, which opted into nothing', async () => { + const pipeline = buildComposedPipeline({ + transport: new ThrowingTransport(new Error('not opted in')), + retry: {settings: {maxAttempts: 3, initialDelayMs: 1}}, + }); + + await pipeline.runtime + .send(Request.newBuilder().url(`${server.url}/ok`).build()) + .catch(() => undefined); + + // The allow-list shape is the whole point: unknown failures are terminal by default. + expect(pipeline.dispatches()).toBe(1); + await pipeline.close(); + }); +}); + +describe('XCUT-7: the configured retryable-status set is authoritative', () => { + test('widening it to include 501 retries a status the built-in classifier excludes', async () => { + const pipeline = buildComposedPipeline({ + retry: { + settings: { + maxAttempts: 3, + initialDelayMs: 1, + retryableStatuses: new Set([501]), + }, + }, + }); + + const response = await pipeline.runtime.send( + Request.newBuilder().url(`${server.url}/status?code=501`).build(), + ); + + expect(pipeline.dispatches()).toBe(3); + await response.close(); + await pipeline.close(); + }); + + test('narrowing it to exclude 500 stops a status the built-in classifier includes', async () => { + const pipeline = buildComposedPipeline({ + retry: { + settings: { + maxAttempts: 3, + initialDelayMs: 1, + retryableStatuses: new Set([503]), + }, + }, + }); + + const response = await pipeline.runtime.send( + Request.newBuilder().url(`${server.url}/status?code=500`).build(), + ); + + // 500's built-in classification is retryable; the configured set overrides it rather than + // being AND-ed with it (RETRY-37). + expect(pipeline.dispatches()).toBe(1); + await response.close(); + await pipeline.close(); + }); +}); + +describe('XCUT-9: a cyclic cause chain terminates', () => { + test('classifies a self-referential error without hanging', async () => { + const cyclic = new Error('cyclic'); + cyclic.cause = cyclic; + const pipeline = buildComposedPipeline({ + transport: new ThrowingTransport(cyclic), + retry: {settings: {maxAttempts: 3, initialDelayMs: 1}}, + }); + + const surfaced = await rejectionOf( + pipeline.runtime.send( + Request.newBuilder().url(`${server.url}/ok`).build(), + ), + ); + + // Reaching this line at all is the assertion: an identity-tracking walk terminates, a naive + // recursive one would have spun until the test timed out. + expect(surfaced).toBe(cyclic); + await pipeline.close(); + }); +}); diff --git a/tests/conformance/xcut/fixtures/composed-pipeline.ts b/tests/conformance/xcut/fixtures/composed-pipeline.ts new file mode 100644 index 0000000..9303d68 --- /dev/null +++ b/tests/conformance/xcut/fixtures/composed-pipeline.ts @@ -0,0 +1,103 @@ +// SPDX-License-Identifier: MIT +// tests/conformance/xcut/fixtures/composed-pipeline.ts +import { + standardResilience, + type AuthStepSettings, + type LoggingStepSettings, + type RedirectSettings, + type Request, + type RequestOptions, + type Response, + type RetryStepOptions, + type Runtime, + type Transport, +} from '@dexpace/core'; +import {fetchTransport} from '@dexpace/transport-fetch'; + +/** + * Per-pillar overrides, mirroring `StandardResilienceOptions` exactly rather than restating a + * narrowed copy of it -- `retry` is a `RetryStepOptions` (settings nest under `.settings`), not a + * `Partial`. + */ +export interface ComposedPipelineOverrides { + readonly retry?: RetryStepOptions | undefined; + readonly redirect?: Partial | undefined; + readonly auth?: AuthStepSettings | undefined; + readonly logging?: LoggingStepSettings | undefined; + /** Swap the terminal transport, e.g. for `undiciTransport()`. Defaults to `fetchTransport()`. */ + readonly transport?: Transport | undefined; +} + +/** + * Counts dispatches to the terminal transport. + * + * Wrapping the TRANSPORT is the only placement that answers "was this retried?". Wrapping + * `Runtime.send` -- one call in, one call out -- counts the caller's own invocations and would read + * 1 whether the retry pillar re-issued four times or none, which is the opposite of what every + * `XCUT-10` row asserts. + */ +class CountingTransport implements Transport { + #dispatches = 0; + readonly #inner: Transport; + + // Not a constructor parameter property: `erasableSyntaxOnly` bans those repo-wide. + constructor(inner: Transport) { + this.#inner = inner; + } + + get dispatches(): number { + return this.#dispatches; + } + + async send( + request: Request, + options?: RequestOptions, + signal?: AbortSignal, + ): Promise { + this.#dispatches += 1; + return this.#inner.send(request, options, signal); + } + + async close(): Promise { + return this.#inner.close(); + } +} + +/** A built pipeline plus the two things a conformance row needs around it. */ +export interface ComposedPipeline { + /** The composed runtime: redirect wraps retry wraps auth wraps logging (AUTH-27). */ + readonly runtime: Runtime; + /** Dispatches that actually reached the terminal transport, i.e. attempts including retries. */ + readonly dispatches: () => number; + /** Closes the terminal transport. `Runtime.close()` is a documented no-op (PIPE-27). */ + close(): Promise; +} + +/** + * The one real, fully composed pipeline every `XCUT-N` test in this directory drives -- + * retry + redirect + auth + logging via 5c/7b's `standardResilience()` over a real + * `fetchTransport()`. Never a per-test hand-rolled subset: the value this suite adds over each + * pillar's own unit tests is proving the invariants still hold when all of them run together. + * + * @param overrides - per-pillar settings; omitted pillars take their shipped defaults. + * @returns the runtime, its dispatch counter, and a close that reaches the transport. + */ +export function buildComposedPipeline( + overrides: ComposedPipelineOverrides = {}, +): ComposedPipeline { + const counting = new CountingTransport( + overrides.transport ?? fetchTransport(), + ); + const runtime = standardResilience(counting, { + retry: overrides.retry, + redirect: overrides.redirect, + auth: overrides.auth, + logging: overrides.logging, + }); + + return { + runtime, + dispatches: () => counting.dispatches, + close: () => counting.close(), + }; +} diff --git a/tests/conformance/xcut/fixtures/server.ts b/tests/conformance/xcut/fixtures/server.ts new file mode 100644 index 0000000..2ac2dfa --- /dev/null +++ b/tests/conformance/xcut/fixtures/server.ts @@ -0,0 +1,168 @@ +// SPDX-License-Identifier: MIT +// tests/conformance/xcut/fixtures/server.ts +import { + createServer, + type IncomingMessage, + type Server, + type ServerResponse, +} from 'node:http'; + +/** + * A pair of running fixture origins. Two real listeners, not one listener addressed by two hostnames: + * the server binds `127.0.0.1` explicitly, so a second name for the same port is not reliably + * resolvable (`localhost` may resolve to `::1` first), and `XCUT-17`'s cross-origin hop has to be a + * genuinely different origin for the assertion to mean anything. + */ +export interface XcutFixtureServer { + /** The primary origin every path is resolved against, e.g. `http://127.0.0.1:38211`. */ + readonly url: string; + /** A second, independently-listening origin -- a different port, so a different origin. */ + readonly crossOriginUrl: string; + /** Stops both listeners and resolves once each has released its port. */ + close(): Promise; +} + +/** `/large-body`'s default payload: comfortably past any preview cap `XCUT-24` would configure. */ +const LARGE_BODY_BYTES = 10 * 1024 * 1024; +/** `/slow`'s default stall, long enough that no cancellation under test wins its race by luck. */ +const SLOW_RESPONSE_MS = 5_000; + +/** Reflects back what actually arrived, so a test can prove which credentials survived a hop. */ +function echo(req: IncomingMessage, res: ServerResponse, url: URL): void { + res.writeHead(200, {'content-type': 'application/json'}); + res.end( + JSON.stringify({ + path: url.pathname, + query: Object.fromEntries(url.searchParams), + method: req.method ?? null, + authorization: req.headers.authorization ?? null, + cookie: req.headers.cookie ?? null, + proxyAuthorization: req.headers['proxy-authorization'] ?? null, + }), + ); +} + +/** The routes shared by both origins. `crossOrigin` is the OTHER origin, for the two-hop redirect. */ +function route( + req: IncomingMessage, + res: ServerResponse, + crossOrigin: string, +): void { + const url = new URL(req.url ?? '/', 'http://127.0.0.1'); + switch (url.pathname) { + case '/ok': + res.writeHead(200, {'content-type': 'text/plain'}); + res.end('ok'); + return; + case '/echo': + echo(req, res, url); + return; + case '/slow': { + const delayMs = Number( + url.searchParams.get('ms') ?? String(SLOW_RESPONSE_MS), + ); + // `unref` so a stalled timer can never hold the suite open past its own afterAll. + setTimeout(() => { + res.writeHead(200); + res.end('done'); + }, delayMs).unref(); + return; + } + case '/large-body': { + const size = Number( + url.searchParams.get('bytes') ?? String(LARGE_BODY_BYTES), + ); + // content-length is declared, not derived. Writing the body without it leaves Node no length + // to precompute and it falls back to chunked -- and OBS-37 deliberately skips preview capture + // on an unknown-length body, so the XCUT-24 rows would silently assert against no preview + // at all rather than against a capped one. + // The media type is selectable because OBS-38 forks on it: a text body is previewed as + // decoded text, a binary one as a size-only `[binary N bytes captured]` marker. XCUT-24's cap + // has to hold on both paths, so both are driven. + res.writeHead(200, { + 'content-type': + url.searchParams.get('type') ?? 'application/octet-stream', + 'content-length': String(size), + }); + res.end(Buffer.alloc(size, 'x')); + return; + } + case '/redirect-same-origin': + res.writeHead(302, {location: '/echo'}); + res.end(); + return; + case '/redirect-cross-origin': + res.writeHead(302, {location: `${crossOrigin}/echo`}); + res.end(); + return; + case '/fail-500': + res.writeHead(500, {'content-type': 'text/plain'}); + res.end('server error'); + return; + case '/status': { + // Any status on demand, for XCUT-7's widen/narrow rows: 501 is excluded from the built-in + // retryable set and 500 is in it, so both directions need a live endpoint to prove against. + const code = Number(url.searchParams.get('code') ?? '500'); + res.writeHead(code, {'content-type': 'text/plain'}); + res.end(`status ${String(code)}`); + return; + } + default: + res.writeHead(404, {'content-length': '0'}); + res.end(); + } +} + +/** Starts one listener on an ephemeral port and resolves its origin alongside the handle. */ +function listen( + crossOrigin: () => string, +): Promise<{origin: string; server: Server}> { + return new Promise(resolve => { + const server = createServer((req, res) => { + route(req, res, crossOrigin()); + }); + server.listen(0, '127.0.0.1', () => { + const address = server.address(); + const port = + typeof address === 'object' && address !== null ? address.port : 0; + resolve({origin: `http://127.0.0.1:${String(port)}`, server}); + }); + }); +} + +/** Closes one listener, dropping keep-alive sockets a transport may still be holding. */ +function shutdown(server: Server): Promise { + return new Promise(done => { + // closeAllConnections, not close alone: a pooled socket would otherwise stall this for the + // server's whole idle timeout (the same reason 8a's own fixture does it). + server.closeAllConnections(); + server.close(() => { + done(); + }); + }); +} + +/** + * Starts the two fixture origins every `XCUT-N` suite in this directory shares, each on an ephemeral + * port so parallel test files never collide. + * + * The secondary comes up first so the primary's `/redirect-cross-origin` can name it; the secondary's + * own cross-origin route points back at the primary, which is why both are handed a late-bound + * getter rather than a string. + * + * @returns both origins; the caller closes them in its own `afterAll`. + */ +export async function startFixtureServer(): Promise { + let primaryOrigin = ''; + const secondary = await listen(() => primaryOrigin); + const primary = await listen(() => secondary.origin); + primaryOrigin = primary.origin; + + return { + url: primary.origin, + crossOriginUrl: secondary.origin, + close: async (): Promise => { + await Promise.all([shutdown(primary.server), shutdown(secondary.server)]); + }, + }; +} diff --git a/tests/conformance/xcut/fixtures/settle.test.ts b/tests/conformance/xcut/fixtures/settle.test.ts new file mode 100644 index 0000000..821462a --- /dev/null +++ b/tests/conformance/xcut/fixtures/settle.test.ts @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: MIT +// tests/conformance/xcut/fixtures/settle.test.ts +// Exercises: the shared rejection-capturing helper this directory's XCUT-N suites assert through. +// Both branches matter -- a `rejectionOf` that reported `undefined` for a REJECTED promise would make +// every `expect(await rejectionOf(p)).toBeInstanceOf(...)` row in this directory vacuously wrong. +import {describe, expect, test} from 'bun:test'; +import {rejectionOf} from './settle.js'; + +describe('rejectionOf', () => { + test('hands back the reason a rejected promise carried', async () => { + const reason = new TypeError('boom'); + + expect(await rejectionOf(Promise.reject(reason))).toBe(reason); + }); + + test('hands back a non-Error rejection reason unchanged', async () => { + // The XCUT-9 row rejects with a cyclic plain object, so the helper must not coerce or wrap. + const cyclic: {self?: unknown} = {}; + cyclic.self = cyclic; + + /* eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- a non-Error rejection is exactly the case under test; re-enable if XCUT-9 stops rejecting with a bare cyclic object */ + expect(await rejectionOf(Promise.reject(cyclic))).toBe(cyclic); + }); + + test('reports undefined when the promise resolved instead', async () => { + expect(await rejectionOf(Promise.resolve('fulfilled'))).toBeUndefined(); + }); +}); diff --git a/tests/conformance/xcut/fixtures/settle.ts b/tests/conformance/xcut/fixtures/settle.ts new file mode 100644 index 0000000..1c99681 --- /dev/null +++ b/tests/conformance/xcut/fixtures/settle.ts @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: MIT +// tests/conformance/xcut/fixtures/settle.ts + +/** + * Awaits `promise` and hands back the reason it rejected with, or `undefined` if it resolved. + * + * Used instead of `await expect(p).rejects.toBeInstanceOf(...)` throughout this suite. Bun types the + * `.rejects` matchers as returning `void`, so awaiting one trips `await-thenable` and + * `no-confusing-void-expression` under this repo's type-aware lint tier, and the idiom the packages + * settled on -- dropping the `await` -- makes the assertion fire-and-forget: the matcher's own + * failure surfaces after the test has already returned, if at all. + * + * Capturing the rejection and asserting on the value synchronously is both lint-clean and genuinely + * awaited, which matters here because every row in this directory is asserting on WHICH error came + * back, not merely that one did. + * + * @param promise - the operation under test. + * @returns the rejection reason, or `undefined` when the promise resolved. + */ +export async function rejectionOf(promise: Promise): Promise { + return promise.then( + () => undefined, + (reason: unknown) => reason, + ); +} diff --git a/tests/conformance/xcut/retry-safety.conformance.test.ts b/tests/conformance/xcut/retry-safety.conformance.test.ts new file mode 100644 index 0000000..67aa4d7 --- /dev/null +++ b/tests/conformance/xcut/retry-safety.conformance.test.ts @@ -0,0 +1,106 @@ +// SPDX-License-Identifier: MIT +// tests/conformance/xcut/retry-safety.conformance.test.ts +// Exercises: XCUT-10 (retry-SAFETY is decided at the retry step independently of retryability, and +// applies uniformly to protocol AND transport failures -- the gate must not special-case a transport +// error that never reached the server). +// +// The five rows are the ones XCUT-10's own conformance clause names, run for the first time against +// the composed pipeline rather than 5a's unit-level harness. Each asserts on dispatches that actually +// reached the terminal transport, which is the only vantage point where "was it re-sent?" is visible. +import {afterAll, beforeAll, describe, expect, test} from 'bun:test'; +import {Request, streamBody, stringBody} from '@dexpace/core'; +import {buildComposedPipeline} from './fixtures/composed-pipeline.js'; +import {startFixtureServer, type XcutFixtureServer} from './fixtures/server.js'; + +let server: XcutFixtureServer; + +beforeAll(async () => { + server = await startFixtureServer(); +}); + +afterAll(async () => { + await server.close(); +}); + +/** Three attempts with a negligible backoff, so a retried row is unmistakable from a non-retried one. */ +function retrying(): {settings: {maxAttempts: number; initialDelayMs: number}} { + return {settings: {maxAttempts: 3, initialDelayMs: 1}}; +} + +describe('XCUT-10: retry-safety on a body-less request follows method idempotence', () => { + test('retries a body-less GET against a retryable protocol failure', async () => { + const pipeline = buildComposedPipeline({retry: retrying()}); + + const response = await pipeline.runtime.send( + Request.newBuilder().url(`${server.url}/fail-500`).method('GET').build(), + ); + + expect(pipeline.dispatches()).toBe(3); + await response.close(); + await pipeline.close(); + }); + + test('does not retry a body-less POST failing with a protocol error', async () => { + const pipeline = buildComposedPipeline({retry: retrying()}); + + const response = await pipeline.runtime.send( + Request.newBuilder().url(`${server.url}/fail-500`).method('POST').build(), + ); + + // The 500 is retryABLE; the bare POST is not retry-SAFE. Two orthogonal axes, and safety wins. + expect(pipeline.dispatches()).toBe(1); + await response.close(); + await pipeline.close(); + }); + + test('still does not retry a body-less POST failing with a transport error', async () => { + const pipeline = buildComposedPipeline({retry: retrying()}); + + const pending = pipeline.runtime.send( + Request.newBuilder().url('http://127.0.0.1:1/').method('POST').build(), + ); + await pending.catch(() => undefined); + + // The row XCUT-10 calls out explicitly: the request demonstrably never reached the server, and + // the gate MUST still refuse. A safety gate that special-cased transport errors would read 3. + expect(pipeline.dispatches()).toBe(1); + await pipeline.close(); + }); +}); + +describe('XCUT-10: retry-safety on a body-bearing request follows body replayability', () => { + test('retries a POST whose body is replayable', async () => { + const pipeline = buildComposedPipeline({retry: retrying()}); + + const response = await pipeline.runtime.send( + Request.newBuilder() + .url(`${server.url}/fail-500`) + .method('POST') + .body(stringBody('payload')) + .build(), + ); + + // A replayable body makes a non-idempotent method safe to re-send: the body clause governs + // once a body is present, rather than being AND-ed with method idempotence. + expect(pipeline.dispatches()).toBe(3); + await response.close(); + await pipeline.close(); + }); + + test('does not retry a POST whose body is a single-use stream', async () => { + const {readable} = new TransformStream(); + const pipeline = buildComposedPipeline({retry: retrying()}); + + const pending = pipeline.runtime.send( + Request.newBuilder() + .url(`${server.url}/fail-500`) + .method('POST') + .body(streamBody(readable)) + .build(), + ); + await pending.catch(() => undefined).then(r => r?.close()); + + expect(pipeline.dispatches()).toBe(1); + await pipeline.close(); + }); +}); diff --git a/tests/conformance/xcut/security-by-default.conformance.test.ts b/tests/conformance/xcut/security-by-default.conformance.test.ts new file mode 100644 index 0000000..dd32f93 --- /dev/null +++ b/tests/conformance/xcut/security-by-default.conformance.test.ts @@ -0,0 +1,161 @@ +// SPDX-License-Identifier: MIT +// tests/conformance/xcut/security-by-default.conformance.test.ts +// Exercises: XCUT-17 (redirect credential hygiene -- Authorization stripped before EVERY re-issue, +// origin-scoped credentials additionally stripped cross-origin), XCUT-16 (no credential is ever +// stamped over a non-HTTPS transport, and the refusal lands BEFORE any token fetch). +// +// These run over a real two-origin socket pair through the composed retry+redirect+auth+logging +// pipeline. 5b's own tests decide the hop in isolation against constructed inputs; this is the first +// place the decision runs with a live auth step installed behind it. +// +// Clauses that stay retrofit citations at their own phases' tests, because a plaintext fixture cannot +// reach them and XCUT-16 is precisely why: +// XCUT-17(c) userinfo dropped -> packages/core/src/redirect/decide.test.ts (REDIR-12) +// XCUT-17(d) HTTPS->HTTP denied -> packages/core/src/redirect/decide.test.ts (REDIR-14/15) and +// redirect-step.test.ts +// XCUT-16 unit-level -> packages/core/src/auth/auth-step.test.ts (AUTH-28) +// XCUT-18 header splitting -> packages/core/src/http/headers.test.ts +// XCUT-19 default-deny redaction-> packages/core/src/observability/redaction.test.ts +// XCUT-20 observability never throws -> packages/core/src/observability/logging-step.test.ts +// XCUT-21 CSPRNG cnonce -> packages/core/src/auth/digest.test.ts (AUTH-20) +import {afterAll, beforeAll, describe, expect, test} from 'bun:test'; +import { + createAuthDescriptor, + createAuthRequirement, + createBearerToken, + Headers, + PlaintextCredentialError, + Request, + type AuthStepSettings, +} from '@dexpace/core'; +import {buildComposedPipeline} from './fixtures/composed-pipeline.js'; +import {startFixtureServer, type XcutFixtureServer} from './fixtures/server.js'; +import {rejectionOf} from './fixtures/settle.js'; + +let server: XcutFixtureServer; + +beforeAll(async () => { + server = await startFixtureServer(); +}); + +afterAll(async () => { + await server.close(); +}); + +/** The credentials a caller sets by hand, which the redirect pillar must police on every hop. */ +function callerCredentials(): Headers { + return Headers.newBuilder() + .set('authorization', 'Bearer caller-set') + .set('cookie', 'sid=abc') + .build(); +} + +/** Reads the fixture's echo of what actually arrived at the final hop. */ +async function followAndEcho( + path: string, +): Promise<{authorization: string | null; cookie: string | null}> { + const pipeline = buildComposedPipeline(); + try { + const response = await pipeline.runtime.send( + Request.newBuilder() + .url(`${server.url}${path}`) + .headers(callerCredentials()) + .build(), + ); + const body = JSON.parse(await response.text()) as { + authorization: string | null; + cookie: string | null; + }; + await response.close(); + return body; + } finally { + await pipeline.close(); + } +} + +describe('XCUT-17: Authorization is stripped before every redirect re-issue', () => { + test('drops Authorization even on a same-origin hop', async () => { + const echoed = await followAndEcho('/redirect-same-origin'); + + // "even same-origin" is the clause that catches the tempting optimisation. + expect(echoed.authorization).toBeNull(); + }); + + test('keeps an origin-scoped Cookie on a same-origin hop', async () => { + const echoed = await followAndEcho('/redirect-same-origin'); + + // Cookie is origin-scoped, and this hop has not left the origin: stripping it here would be + // over-broad, and XCUT-17 scopes the extra stripping to the cross-origin case. + expect(echoed.cookie).toBe('sid=abc'); + }); +}); + +describe('XCUT-17: origin-scoped credentials are additionally stripped cross-origin', () => { + test('drops Authorization on a cross-origin hop', async () => { + const echoed = await followAndEcho('/redirect-cross-origin'); + + expect(echoed.authorization).toBeNull(); + }); + + test('drops the Cookie on a cross-origin hop', async () => { + const echoed = await followAndEcho('/redirect-cross-origin'); + + // Judged against the seed origin, not the previous hop -- the two servers are genuinely + // different origins (different ports), not one origin under two names. + expect(echoed.cookie).toBeNull(); + }); +}); + +describe('XCUT-16: a credential is never stamped over a non-HTTPS transport', () => { + test('refuses a bearer credential over http:// before fetching the token', async () => { + let providerInvocations = 0; + const auth: AuthStepSettings = { + credentials: { + bearer: { + provider: () => { + providerInvocations += 1; + return Promise.resolve( + createBearerToken('secret', Date.now() + 60_000), + ); + }, + }, + }, + tiers: { + operation: createAuthDescriptor([createAuthRequirement('OAUTH2')]), + }, + }; + const pipeline = buildComposedPipeline({auth}); + + const pending = pipeline.runtime.send( + Request.newBuilder().url(`${server.url}/echo`).build(), + ); + + expect(await rejectionOf(pending)).toBeInstanceOf(PlaintextCredentialError); + // "fail loudly BEFORE any token fetch or header write" -- a guard that ran after the fetch would + // already have pulled a live secret over the wire, which is the leak the ordering prevents. + expect(providerInvocations).toBe(0); + await pipeline.close(); + }); + + test('never dispatches the credentialed request at all', async () => { + const auth: AuthStepSettings = { + credentials: { + bearer: { + provider: () => + Promise.resolve(createBearerToken('secret', Date.now() + 60_000)), + }, + }, + tiers: { + operation: createAuthDescriptor([createAuthRequirement('OAUTH2')]), + }, + }; + const pipeline = buildComposedPipeline({auth}); + + await pipeline.runtime + .send(Request.newBuilder().url(`${server.url}/echo`).build()) + .catch(() => undefined); + + expect(pipeline.dispatches()).toBe(0); + await pipeline.close(); + }); +}); diff --git a/tests/tsconfig.json b/tests/tsconfig.json new file mode 100644 index 0000000..192445d --- /dev/null +++ b/tests/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "rootDir": ".", + "target": "ES2023", + "lib": [ + "ES2023", + "DOM", + "DOM.AsyncIterable" + ], + "types": [ + "bun" + ] + }, + "include": [ + "**/*.ts" + ] +}