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" + ] +}