Repository navigation
fix(request-context): add deterministic failure-boundary coverage for runWithContext - #2869
Sagethepeak wants to merge 1 commit into
Conversation
… runWithContext runWithContext is the single point at which an inbound correlation id is admitted to, or refused entry to, the AsyncLocalStorage context that every downstream audit write, outbound RPC call and log line reads, yet it had no coverage of its own. Its combined failure boundary in particular went entirely untested: when the inbound id is unusable *and* the id source is also unavailable, nothing asserted that the run still succeeds, that the id it degrades to is log-safe, or that a caller is never left without a context. The coverage could not have been written as things stood. Commit 4e9bcaf (QuickLendX#2705) changed generateCorrelationId to call ulid() directly, which left _setUlidGeneratorForTesting write-only: it still assigned ulidGenerator, but nothing read it, so every test that set the hook silently exercised the real generator and asserted nothing about the failure it meant to drive. Resolve the seam as a nullable ulidOverride consulted per call, which keeps ulid dereferenced lazily so module-level mocking still reaches the healthy path, and which cannot weaken validation because a value it returns is still checked against ULID_PATTERN and still falls through to the degraded path. Because the package is ESM and a test runner cannot spy on a module namespace object, this exported hook is the only portable way to reach the boundary. Document the invariants the boundary depends on, and add 81 deterministic tests covering success, rejection, length and degraded-source handling, throw and rejection propagation with teardown, work that escapes the run, concurrency and retry, and the middleware and request-logger paths that feed it. No clock, network, database or Express app is involved, and uniqueness is asserted as "all distinct" rather than against fixed values. Measured against the unfixed source, 10 of the 81 tests fail, all on the combined boundary. Against this change the full backend suite fails 209 tests both before and after, with run-with-context.test.ts the only file whose outcome differs, so no pre-existing failure is introduced or masked. Target-file coverage is 96.03% of statements and 84.44% of branches; the remainder needs node:async_hooks and node:crypto module mocking, which this suite avoids by design. Closes QuickLendX#2696
Correction: my "may be recoverable by checking
|
| Line | Corrupt | Intended |
|---|---|---|
| 298 | runner.loa`MigrationsFromFS() |
runner.loadMigrationsFromFS() |
| 257, 370, +2 | QFC_MIGRATION_902_ALLOWEB\x03 |
QFC_MIGRATION_902_ALLOWED |
So this is mechanically fixable, not a rewrite — worth knowing before scoping the sweep.
Separately, one number from this PR is now better explained: all 136 typecheck errors on main come from these 10 files, and not one from healthy code. But it is still not part of #2696 — runWithContext imports only node:async_hooks, node:crypto and ulid, and none of its 7 production consumers touch any corrupted file. This PR remains scoped to the issue.
|
@Baskarayelu — this is the #2696 deliverable (assigned to me), following the merged #2866 on the same module. With ~8 boundary-coverage PRs open and CI red repo-wide, the evidence up front:
The one thing worth knowing: CI on this branch is red for reasons unrelated to it: broken One caveat on my own evidence: |
📝 Description
runWithContextdecides whether a request is traceable and whether an attacker-controlled string can forge a log line. It is the single point at which an inbound correlation id is admitted to, or refused entry to, theAsyncLocalStoragecontext that every downstream audit write, outbound RPC call and log line reads — and it had no coverage of its own.Its combined failure boundary in particular went entirely untested: when the inbound id is unusable and the id source is also unavailable, nothing asserted that the run still succeeds, that the id it degrades to is log-safe, or that a caller is never left without a context. That is the boundary #2697 (
getCorrelationId), #2699 (getOrGenerateCorrelationId) and #2702 (the middleware) each deliberately do not reach.Root cause: the test seam had gone dead
The coverage could not have been written as things stood. Commit
4e9bcaf0(#2705) changedgenerateCorrelationIdto callulid()directly, which left_setUlidGeneratorForTestingwrite-only: it still assignedulidGenerator, but nothing read it. Every test that set the hook silently exercised the real generator and asserted nothing about the failure it meant to drive — a green test proving nothing.🎯 Type of Change
🔧 Changes Made
Files Modified
backend/src/lib/requestContext.ts— restore the seam; document the invariantsNew Files Added
backend/src/tests/run-with-context.test.ts— 81 deterministic testsKey Changes
The seam (
requestContext.ts, 3 functional lines).ulidGeneratorbecame a nullableulidOverridethatgenerateCorrelationIdresolves per call viaulidOverride ?? ulid. This matters in three ways:ulidstays dereferenced lazily, so module-level mocking still reaches the healthy path. The originalulidGeneratorwas captured at module load, which broke that too.ULID_PATTERNand still falls through to the degraded path, so a test double can drive the failure boundary but can never launder a malformed or tainted id into the context.nullis the default, so production behaviour and the healthy path are untouched.Because the package is ESM (
"type": "module") and no test runner can spy on a module namespace object, monkey-patching theulidexport is not available — this exported hook is the only portable way to reach the boundary.The invariants (
runWithContextdocblock). The function's body is unchanged; what was missing was a statement of what it guarantees. Now documented: never stores an unusable id; never throws on the id path; is total under id-source failure; propagatesfn's outcome with original identity and tears the context down on both paths; never pollutes the caller's scope, while work scheduled insidefnkeeps the id.The tests. Success, rejection, length and degraded-source handling; throw and rejection propagation with teardown; work that outlives the run; concurrency, nesting and retry; the middleware and request-logger paths that feed it; plus a bounded deterministic property pass over raw and sanitised input.
🧪 Testing
Test Coverage
node:async_hooksandnode:cryptomodule mocking, which this suite avoids by design; it is not reachable without that machinery.requestContext.tsswapped — the only file whose outcome differs isrun-with-context.test.ts(10F/71P→0F/81P). Suite totals go219 failed / 621 passed→209 failed / 631 passed.Determinism
No clock, network, database, or Express app is involved, and randomness is never asserted against a fixed value. Uniqueness is asserted as "all distinct". ULID ordering is never asserted, because plain
ulid()is not monotonic within a millisecond. The one place time is involved mocksDate.nowexplicitly and restores it in afinally.Edge cases tested
Non-string, empty, blank, and whitespace-only ids (including NBSP); over-length ids at
MAX + 1; ids made only of separator characters; ids whose length is exactly at the limit; CR, LF, CRLF, NUL, tab, C0 controls, DEL and CSI/ANSI escape sequences; a planted sentinel that must never survive into the context; ids whose only defect is case. Also asserted in the other direction — a legitimate id is preserved verbatim, so refusing unsafe ids has not become a blanket "rewrite anything unfamiliar" policy that would break a caller's own correlation.📋 Contract-Specific Checks
Not applicable — backend-only change; no Soroban contract code touched.
📋 Review Checklist
🔍 Code Quality
🚀 Performance & Security
One production-code behaviour change, and it only widens what is reachable in tests. Adding the assertion that a valid id is stored verbatim caught a real assumption gap in the first draft of this suite.
📚 Documentation
🔗 Related Issues
Closes #2696
Related to #2697, #2699 and #2702 — each covers one caller of the boundary this PR covers jointly. Note #2705: the commit that introduced this regression.
📋 Additional Notes
Three pre-existing problems on
mainare out of scope here and deliberately untouched, per triage. Flagging rather than fixing, because each is larger than this issue and several block CI on every branch:npm cifails —backend/package.jsonandbackend/package-lock.jsonare out of sync. The lockfile is missingajv,ajv-formats,cors,dotenv,helmet,ulidandzod, all of which the manifest requires..github/workflows/backend-ci.ymlinvokesnpm run typecheck, but notypecheckscript is defined. It also invokes Prettier, which is not a devDependency.npm test/npm run coverage/jestare dead — Download export #2844 migrated the backend to Vitest but left these scripts in place, andbackend/jest.config.jsis a leftover.Why validation ran in a scratch environment
Because of (1), dependencies cannot be installed from the lockfile at all. I validated in a copy at
/tmp/opencode/be2696with Vitest2.1.9+@vitest/coverage-v82.1.9and a repairednode_modules. CI will not go green on this branch for reasons unrelated to it, so the per-run evidence above is provided instead — please read it as the validation record rather than as a claim thatnpm ci && npm testpasses.Sibling suites still unloadable
Following #2844,
request-context-middleware.test.ts(needsfast-check) andrequest-context.test.ts(needssupertest) both collect 0 tests because those packages are no longer dependencies.src/lib/__tests__/requestContext.test.tsloads and runs but has 2 assertions that went stale. Left as-is by decision; this PR does not depend on them.Source corruption on
main(worth a separate issue)While baselining I found files that do not parse, all pre-existing on
main:src/tests/shutdown-ordering.test.ts— base64-encoded, never decodedsrc/lib/migrations/runner.ts— 1365 lines collapsed onto one (newlines stripped)src/tests/api-key-rotation-integration.test.ts,api-keys-audit-logs.test.ts,conditional-write.test.ts,migration-failure-boundary.test.ts,migration-runner-mocked.test.ts,shutdown-ordering.test.ts— syntax errors from the same corruptionmigration-failure-boundary.test.tsis the same pattern of work as this PR, so this one may be recoverable by checkinggit logfor a recoverable revision. Suggest a dedicated issue for the corruption sweep.🧪 How to Test
To see the coverage earn its keep, revert the source and watch the suite fail:
node_modules; see the note above.None.
🔄 Migration Steps
None.