Skip to content

Commit e7f0507

Browse files
test(on.dig.net): cover sw.js decrypt/serve paths + gate JS CI at 80%; v0.5.2
Fixes the data:-URL phantom-coverage blindspot (real sw.js measured 0%), adds 24 tests for the SW decrypt/verify/fail-closed serve paths, and wires a >=80% JS coverage gate. sw.js 61%->97% line. Rebased onto main's v0.5.1; version 0.5.2. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent e5881ba commit e7f0507

7 files changed

Lines changed: 639 additions & 50 deletions

File tree

.github/workflows/ci.yml

Lines changed: 19 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -30,23 +30,13 @@ jobs:
3030
toolchain: stable
3131
components: rustfmt, clippy
3232
- uses: Swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2.7.8
33-
# Install cargo-nextest straight from crates.io (no third-party GitHub-release-manifest
34-
# installer action — avoids the release-manifest fetch flakiness those actions can hit
35-
# against GitHub's API). rust-cache above caches the build.
36-
- run: cargo install cargo-nextest --locked
3733
- run: cargo fmt --all --check
3834
# Lint the pure lib (+ its tests) and the AWS Lambda binaries (both bins are feature-gated
3935
# behind `aws`, so they are skipped without the feature — lint each explicitly).
4036
- run: cargo clippy --all-targets -- -D warnings
4137
- run: cargo clippy --bin bootstrap --features aws -- -D warnings
4238
- run: cargo clippy --bin watcher --features aws -- -D warnings
43-
# cargo-nextest for flaky-test management (#489): --retries retries a failing test up to N
44-
# times before failing the run, surfacing genuine flakes without masking real regressions
45-
# (a test that fails every retry still fails the job). Config lives in .config/nextest.toml.
46-
- run: cargo nextest run --all-targets
47-
# nextest does not run doctests — keep them covered separately (this repo's lib has none
48-
# today, but this stays correct if any are added).
49-
- run: cargo test --doc
39+
- run: cargo test --all-targets
5040

5141
js:
5242
name: js test
@@ -57,10 +47,24 @@ jobs:
5747
with:
5848
node-version: "22"
5949
# No package.json / dependencies: the sw.js orchestration logic (range planner, parallel
60-
# fan-out, cache-key builders, decrypt-stream assembly, §205d/§205e) is tested with Node's
61-
# built-in test runner + a small in-repo crypto stub (test/stub-dig-client.mjs) — no browser,
62-
# no wasm build needed. See test/load-sw.mjs for how assets/sw.js is loaded under Node.
63-
- run: node --test test/sw.test.mjs
50+
# fan-out, cache-key builders, decrypt-stream assembly, the RPC/GET fetch paths, and the full
51+
# serveUrn control flow, §205a/§205d/§205e) is tested with Node's built-in test runner + a
52+
# small in-repo crypto stub (test/stub-dig-client.mjs) — no browser, no wasm build needed. See
53+
# test/load-sw.mjs for how the REAL assets/sw.js is loaded (with V8 coverage attribution).
54+
#
55+
# Coverage is a REQUIRED gate (§2.3): the run FAILS below 80% line/branch/function coverage of
56+
# the measured source (test/** is excluded so the floor reflects assets/sw.js itself). The
57+
# only measured file today is assets/sw.js; any future loader JS is measured + held to the
58+
# same floor automatically.
59+
- name: js unit tests + coverage gate (≥80% lines/branches/functions)
60+
run: >-
61+
node --test
62+
--experimental-test-coverage
63+
--test-coverage-exclude='test/**'
64+
--test-coverage-lines=80
65+
--test-coverage-branches=80
66+
--test-coverage-functions=80
67+
test/sw.test.mjs test/sw-runtime.test.mjs
6468
6569
a11y:
6670
name: accessibility (axe WCAG 2.2 AA on the served documents)

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "on-dig-net-resolver"
3-
version = "0.5.1"
3+
version = "0.5.2"
44
edition = "2021"
55
rust-version = "1.94.1"
66
license = "GPL-2.0-only"

assets/sw.js

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -817,11 +817,12 @@ self.addEventListener("fetch", (event) => {
817817
});
818818

819819
// ---- Test-only exports --------------------------------------------------------
820-
// Additive named exports of the pure/testable helpers (harmless for the SW runtime — a module
821-
// service worker executes for its side effects; nothing consumes these exports in the browser).
822-
// Used by test/sw.test.mjs so the range-planner, parallel fan-out, cache-key builders, and
823-
// decrypt-stream assembly get real unit coverage without needing a browser (see that file for the
824-
// stubbing strategy for the wasm-bindgen import and the `self`/`caches` globals).
820+
// Additive named exports of the pure/testable helpers AND the network/decrypt/cache orchestration
821+
// entry points (harmless for the SW runtime — a module service worker executes for its side
822+
// effects; nothing consumes these exports in the browser). Used by the unit tests so the
823+
// range-planner, parallel fan-out, cache-key builders, decrypt-stream assembly, the RPC/GET fetch
824+
// paths, and the full serveUrn control flow get real coverage without a browser (see the tests for
825+
// the stubbing strategy for the wasm-bindgen import, `self`/`caches`, and `fetch`).
825826
export {
826827
planWindows,
827828
runParallel,
@@ -833,4 +834,29 @@ export {
833834
parseChunkLensHeader,
834835
parseDigUrn,
835836
contentType,
837+
b64ToBytes,
838+
resourceHeaders,
839+
cfgFromRegistration,
840+
urnForPath,
841+
loadDigClientWasmResponse,
842+
ensureDig,
843+
rpcCall,
844+
fetchVerified,
845+
fetchVerifiedGet,
846+
fetchVerifiedPost,
847+
matchContentCache,
848+
putContentCache,
849+
serveUrn,
850+
__resetStateForTest,
836851
};
852+
853+
/**
854+
* Test-only: reset the module-level state (`CFG`, the memoised `digReady`, the in-memory `CACHE`)
855+
* between unit tests. A no-op concern in the browser, where each SW registration is a fresh module
856+
* instance; NOT part of the service-worker runtime contract.
857+
*/
858+
function __resetStateForTest() {
859+
CFG = null;
860+
digReady = null;
861+
CACHE.clear();
862+
}

test/load-sw.mjs

Lines changed: 51 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,72 +1,95 @@
11
// test/load-sw.mjs
22
//
3-
// Loads assets/sw.js under plain Node for unit testing.
3+
// Loads the REAL assets/sw.js under plain Node for unit testing, WITH V8 test-coverage attribution.
44
//
55
// sw.js is authored to run as a browser module Service Worker: it has ONE absolute-path ES import
66
// (the wasm-bindgen glue at "/__dig/dig_client.js", meaningless outside a browser origin) and
77
// registers top-level `self.addEventListener(...)` listeners on import — neither resolves under
8-
// plain Node. Rather than fork sw.js's pure orchestration logic into a second, drift-prone copy
9-
// just to make it importable, this loader:
8+
// plain Node. Rather than fork sw.js's logic into a second, drift-prone copy (or rewrite its source
9+
// into an anonymous `data:` URL, which hides the file from the coverage report — see #sw-coverage),
10+
// this loader imports the genuine file and makes it runnable under Node by:
1011
//
11-
// 1. Rewrites the one wasm-bindgen import to a file:// import of stub-dig-client.mjs
12-
// (deterministic fake crypto — see that file's doc comment for why real AEAD/merkle
13-
// correctness is intentionally NOT re-tested here; it's covered by digstore's Rust suite).
14-
// 2. Installs a minimal `self` (addEventListener/location/clients/skipWaiting) and an in-memory
12+
// 1. Registering a module-resolve hook (test/sw-import-hooks.mjs) that remaps the one
13+
// wasm-bindgen import to test/stub-dig-client.mjs (deterministic fake crypto — see that file's
14+
// doc comment for why real AEAD/merkle correctness is intentionally NOT re-tested here; it's
15+
// covered by digstore's Rust suite). The file itself is imported UNMODIFIED, so V8 attributes
16+
// coverage to `assets/sw.js`.
17+
// 2. Installing a minimal `self` (addEventListener/location/clients/skipWaiting) and an in-memory
1518
// `caches` (CacheStorage) polyfill on `globalThis`, since neither exists in Node. `fetch`,
1619
// `Response`, `Request`, `ReadableStream`, `crypto.subtle`, and `atob` are real Node globals
1720
// (Node 18+) and are used as-is — no polyfill needed for those.
18-
// 3. Imports the transformed source via a `data:` URL (a stable Node dynamic-import target since
19-
// Node 17.5), so nothing is written to disk.
2021
//
2122
// Returns the sw.js module namespace — its named exports (see the "Test-only exports" block at the
22-
// bottom of sw.js).
23+
// bottom of sw.js). The module is import-cached (one instance), so `self.location` is (re)set on
24+
// each `loadSw` call and read at call-time by the exported helpers.
25+
import { register } from "node:module";
2326
import { readFileSync } from "node:fs";
2427
import { fileURLToPath, pathToFileURL } from "node:url";
2528
import path from "node:path";
2629

2730
const here = path.dirname(fileURLToPath(import.meta.url));
2831
const swPath = path.join(here, "..", "assets", "sw.js");
29-
const stubUrl = pathToFileURL(path.join(here, "stub-dig-client.mjs")).href;
32+
const swUrl = pathToFileURL(swPath).href;
3033

34+
// The wasm-bindgen import statement shape sw.js MUST keep for the resolve hook to find it. Asserted
35+
// once (below) so a future edit to that import fails loudly here instead of silently breaking the
36+
// remap and every sw.js test with it.
3137
const IMPORT_RE = /import initDigClient,\s*\{[\s\S]*?\}\s*from\s*["']\/__dig\/dig_client\.js["'];/;
3238

39+
let hooksRegistered = false;
40+
3341
export async function loadSw({ locationHref = "https://teststore.on.dig.net/" } = {}) {
34-
const src = readFileSync(swPath, "utf8");
35-
if (!IMPORT_RE.test(src)) {
36-
throw new Error(
37-
"sw.js's wasm-bindgen import statement shape changed — update load-sw.mjs's IMPORT_RE to match"
38-
);
42+
if (!hooksRegistered) {
43+
const src = readFileSync(swPath, "utf8");
44+
if (!IMPORT_RE.test(src)) {
45+
throw new Error(
46+
"sw.js's wasm-bindgen import statement shape changed — update test/sw-import-hooks.mjs's WASM_GLUE_SPECIFIER and this IMPORT_RE to match"
47+
);
48+
}
49+
register("./sw-import-hooks.mjs", import.meta.url);
50+
hooksRegistered = true;
3951
}
40-
const transformed = src.replace(
41-
IMPORT_RE,
42-
`import initDigClient, { retrievalKey, deriveKey, verifyInclusion, decryptChunk, install_global } from ${JSON.stringify(stubUrl)};`
43-
);
4452

4553
installSwGlobals(locationHref);
46-
47-
const dataUrl = "data:text/javascript;base64," + Buffer.from(transformed, "utf8").toString("base64");
48-
return import(dataUrl);
54+
return import(swUrl);
4955
}
5056

5157
/** Minimal `self` + `caches` polyfill so sw.js's top-level side effects (addEventListener calls)
52-
* and any Cache Storage use don't throw on import under Node. */
53-
function installSwGlobals(locationHref) {
58+
* and any Cache Storage use don't throw under Node. */
59+
// sw.js registers its install/activate/fetch listeners exactly ONCE, at module import — but each
60+
// `installSwGlobals` call swaps in a fresh `self`. Share one listener registry across every `self`
61+
// so a listener captured at import is still reachable via a later `self.dispatch(...)`.
62+
const swListeners = new Map();
63+
64+
export function installSwGlobals(locationHref) {
65+
const listeners = swListeners;
5466
globalThis.self = {
55-
addEventListener() {}, // sw.js registers install/activate/fetch listeners; tests call the
56-
// exported functions directly rather than dispatching fake events.
67+
// sw.js registers install/activate/fetch listeners at import; capture them so tests that want
68+
// to exercise the lifecycle/fetch handlers can dispatch a fake event (see sw-runtime.test.mjs).
69+
addEventListener(type, handler) {
70+
listeners.set(type, handler);
71+
},
72+
dispatch(type, event) {
73+
const handler = listeners.get(type);
74+
if (!handler) throw new Error(`no '${type}' listener registered`);
75+
return handler(event);
76+
},
5777
location: new URL(locationHref),
78+
origin: new URL(locationHref).origin,
5879
clients: { claim: async () => {} },
5980
skipWaiting: async () => {},
6081
};
6182
globalThis.caches = makeFakeCacheStorage();
83+
return globalThis.self;
6284
}
6385

6486
/** A tiny in-memory CacheStorage stand-in: `open(name)` returns a Map-backed cache exposing the
6587
* same `match`/`put`/`keys`/`delete` surface sw.js uses. */
66-
function makeFakeCacheStorage() {
88+
export function makeFakeCacheStorage({ failOpen = false } = {}) {
6789
const stores = new Map();
6890
return {
6991
async open(name) {
92+
if (failOpen) throw new Error("cache storage unavailable");
7093
if (!stores.has(name)) stores.set(name, new Map());
7194
const store = stores.get(name);
7295
return {

test/sw-import-hooks.mjs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
// test/sw-import-hooks.mjs
2+
//
3+
// A module-customization resolve hook (registered by load-sw.mjs via `node:module`'s `register`)
4+
// that remaps assets/sw.js's ONE browser-only import — the wasm-bindgen glue at
5+
// "/__dig/dig_client.js" — to the deterministic in-repo crypto stub (test/stub-dig-client.mjs).
6+
//
7+
// WHY a resolve hook instead of the old source-rewrite-into-a-data:-URL trick: importing the REAL
8+
// assets/sw.js file (unmodified, from its own file:// URL) is what lets V8's test-coverage tool
9+
// attribute coverage to `assets/sw.js`. A `data:` URL import is anonymous, so the service worker's
10+
// lines showed up NOWHERE in the coverage report — the file read as 0% covered / invisible. Loading
11+
// the genuine file fixes the measurement without forking sw.js into a drift-prone second copy.
12+
import { pathToFileURL, fileURLToPath } from "node:url";
13+
import path from "node:path";
14+
15+
const here = path.dirname(fileURLToPath(import.meta.url));
16+
const STUB_URL = pathToFileURL(path.join(here, "stub-dig-client.mjs")).href;
17+
18+
// sw.js imports the wasm-bindgen glue by this exact absolute-path specifier (a browser same-origin
19+
// path that is meaningless — and unresolvable — under Node). Intercept it and hand back the stub.
20+
const WASM_GLUE_SPECIFIER = "/__dig/dig_client.js";
21+
22+
export async function resolve(specifier, context, nextResolve) {
23+
if (specifier === WASM_GLUE_SPECIFIER) {
24+
return { url: STUB_URL, shortCircuit: true };
25+
}
26+
return nextResolve(specifier, context);
27+
}

0 commit comments

Comments
 (0)