diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 30e0ee664f2e..8aa53768e62e 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -409,10 +409,10 @@ jobs: done RC=0 if [ ${#REST[@]} -gt 0 ]; then - node --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 "${REST[@]}" || RC=$? + node --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --import ./tests/_setup/blockNetwork.ts --test --test-force-exit --test-concurrency=4 "${REST[@]}" || RC=$? fi if [ ${#DASH[@]} -gt 0 ]; then - node --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 "${DASH[@]}" || RC=$? + node --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --import ./tests/_setup/blockNetwork.ts --test --test-force-exit --test-concurrency=4 "${DASH[@]}" || RC=$? fi exit $RC # #8084 shadow evidence: keep the raw selection downloadable so TIA misses can be diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index fceacf7f48c0..7a2186a2e7f0 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -197,6 +197,7 @@ "cap": 1200, "testCap": 1200, "testFrozen": { + "_rebaseline_2026_09_19_network_guard_hermetic_outbound": "Network guard (unit tests cannot reach the network): every test file that production code made an incidental outbound call from now installs the hermetic outbound layer, a fixed 3-line block (comment + `await (await import(\"./_helpers/offlineOutbound.ts\")).installOfflineOutbound();`). Three frozen test files carry that block and had zero headroom: models-catalog-route.test.ts 1652->1656, route-edge-coverage.test.ts 1244->1248, vscode-token-routes.test.ts 1267->1271 (+4 each: the 3-line block plus the blank line that separates it). The block cannot be smaller and cannot move to a shared setup — it must run AFTER each file's own imports, because open-sse/utils/proxyFetch.ts replaces globalThis.fetch at import time. Structural shrink of these files stays tracked in #3501.", "_rebaseline_2026_06_27_5193_antigravity_test": "#5193 own test growth: oauth-providers-config.test.ts 870->873 (+3: antigravity projectId assertion + 50ms tick for the now fire-and-forget onboarding, matching the no-PKCE/no-openid flow).", "_rebaseline_2026_07_02_5928_base_red": "web-cookie-providers-new.test.ts 845->850: #5928 (test(security) Kimi Web URL host parse, CodeQL #689) grew the file +5 lines and merged into release/v3.8.44 WITHOUT rebaselining, leaving a fast-gates base-red that blocked every subsequent PR->release. Test growth is legitimate (a security regression test); maintainer absorbs the drift here. Frozen at 850.", "_rebaseline_2026_07_09_6126_clinepass_dualauth": "#6126 (ClinePass dual-auth) own test growth: oauth-providers-config.test.ts 842->845 (+3: clinepass key/config/required-fields entries reusing the Cline WorkOS flow config, needed after registering clinepass in the oauth.ts PROVIDERS enum).", @@ -221,12 +222,12 @@ "tests/unit/executor-default-base.test.ts": 1632, "tests/unit/grok-web.test.ts": 2985, "tests/unit/image-generation-handler.test.ts": 2133, - "tests/unit/models-catalog-route.test.ts": 1652, + "tests/unit/models-catalog-route.test.ts": 1656, "tests/unit/perplexity-web.test.ts": 1384, "tests/unit/provider-models-route.test.ts": 1783, "tests/unit/provider-validation-specialty.test.ts": 2912, "tests/unit/reasoning-cache.test.ts": 1291, - "tests/unit/route-edge-coverage.test.ts": 1244, + "tests/unit/route-edge-coverage.test.ts": 1248, "tests/unit/sse-auth.test.ts": 1729, "tests/unit/stream-utils.test.ts": 2517, "tests/unit/token-refresh-service.test.ts": 1407, @@ -235,7 +236,7 @@ "tests/unit/translator-openai-to-kiro.test.ts": 1275, "tests/unit/translator-resp-gemini-to-openai.test.ts": 1234, "tests/unit/usage-service-hardening.test.ts": 1487, - "tests/unit/vscode-token-routes.test.ts": 1267, + "tests/unit/vscode-token-routes.test.ts": 1271, "tests/unit/tls-client-wreq-migration.test.ts": 1374 }, "_rebaseline_2026_06_09": "Re-baseline consciente pre-release v3.8.19: 9 arquivos cresceram durante o ciclo (features mergeadas: RequestLoggerV2 +281 request-logger rework, stream +101, combo +73, chatCore +45, catalog +32 fable-5/catalog-flag, callLogs +4, accountFallback +2, usageHistory novo 840) + core.ts +7 (fix resetAllDbModuleState, PR 3536). A catraca segue valendo destes valores — proximo crescimento falha. Decisao: encolher (esp. RequestLoggerV2/chatCore) e a issue #3501 ficam para o ciclo seguinte.", diff --git a/package.json b/package.json index 71c666a52825..a3ff79568608 100644 --- a/package.json +++ b/package.json @@ -129,21 +129,21 @@ "electron:build:mac": "npm run build && cd electron && npm run build:mac", "electron:build:linux": "npm run build && cd electron && npm run build:linux", "electron:smoke:packaged": "node scripts/dev/smoke-electron-packaged.mjs", - "test": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-concurrency=20 tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,translator,ui,usage}/**/*.test.ts\" \"tests/unit/**/*.test.mjs\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-concurrency=20 \"tests/unit/dashboard/**/*.test.ts\" && npm run test:unit:serial", - "test:unit": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=20 tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,translator,ui,usage}/**/*.test.ts\" \"tests/unit/**/*.test.mjs\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=20 \"tests/unit/dashboard/**/*.test.ts\" && npm run test:unit:serial", - "test:unit:ci": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,translator,ui,usage}/**/*.test.ts\" \"tests/unit/**/*.test.mjs\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 \"tests/unit/dashboard/**/*.test.ts\" && npm run test:unit:serial", - "test:unit:ci:shard": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 --test-shard=$TEST_SHARD tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,translator,ui,usage}/**/*.test.ts\" \"tests/unit/**/*.test.mjs\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 --test-shard=$TEST_SHARD \"tests/unit/dashboard/**/*.test.ts\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=1 --test-shard=$TEST_SHARD \"tests/unit/serial/**/*.test.ts\"", - "test:unit:fast": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-isolation=none tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,translator,ui,usage}/**/*.test.ts\" \"tests/unit/**/*.test.mjs\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-isolation=none \"tests/unit/dashboard/**/*.test.ts\" && npm run test:unit:serial", + "test": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --import ./tests/_setup/blockNetwork.ts --test --test-concurrency=20 tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,translator,ui,usage}/**/*.test.ts\" \"tests/unit/**/*.test.mjs\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --import ./tests/_setup/blockNetwork.ts --test --test-concurrency=20 \"tests/unit/dashboard/**/*.test.ts\" && npm run test:unit:serial", + "test:unit": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --import ./tests/_setup/blockNetwork.ts --test --test-force-exit --test-concurrency=20 tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,translator,ui,usage}/**/*.test.ts\" \"tests/unit/**/*.test.mjs\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --import ./tests/_setup/blockNetwork.ts --test --test-force-exit --test-concurrency=20 \"tests/unit/dashboard/**/*.test.ts\" && npm run test:unit:serial", + "test:unit:ci": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --import ./tests/_setup/blockNetwork.ts --test --test-force-exit --test-concurrency=4 tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,translator,ui,usage}/**/*.test.ts\" \"tests/unit/**/*.test.mjs\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --import ./tests/_setup/blockNetwork.ts --test --test-force-exit --test-concurrency=4 \"tests/unit/dashboard/**/*.test.ts\" && npm run test:unit:serial", + "test:unit:ci:shard": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --import ./tests/_setup/blockNetwork.ts --test --test-force-exit --test-concurrency=4 --test-shard=$TEST_SHARD tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,translator,ui,usage}/**/*.test.ts\" \"tests/unit/**/*.test.mjs\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --import ./tests/_setup/blockNetwork.ts --test --test-force-exit --test-concurrency=4 --test-shard=$TEST_SHARD \"tests/unit/dashboard/**/*.test.ts\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --import ./tests/_setup/blockNetwork.ts --test --test-force-exit --test-concurrency=1 --test-shard=$TEST_SHARD \"tests/unit/serial/**/*.test.ts\"", + "test:unit:fast": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --import ./tests/_setup/blockNetwork.ts --test --test-force-exit --test-isolation=none tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,translator,ui,usage}/**/*.test.ts\" \"tests/unit/**/*.test.mjs\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --import ./tests/_setup/blockNetwork.ts --test --test-force-exit --test-isolation=none \"tests/unit/dashboard/**/*.test.ts\" && npm run test:unit:serial", "test:scoped": "bash scripts/quality/test-scoped.sh", "test:scoped:staged": "bash scripts/quality/test-scoped.sh --staged", "test:scoped:full": "bash scripts/quality/test-scoped.sh --full", "test:unit:shard": "concurrently --kill-others-on-fail -n s1,s2 \"npm:test:unit:shard:1\" \"npm:test:unit:shard:2\"", - "test:unit:shard:1": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=10 --test-shard=1/2 tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,translator,ui,usage}/**/*.test.ts\" \"tests/unit/**/*.test.mjs\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=10 --test-shard=1/2 \"tests/unit/dashboard/**/*.test.ts\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=1 --test-shard=1/2 \"tests/unit/serial/**/*.test.ts\"", - "test:unit:shard:2": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=10 --test-shard=2/2 tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,translator,ui,usage}/**/*.test.ts\" \"tests/unit/**/*.test.mjs\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=10 --test-shard=2/2 \"tests/unit/dashboard/**/*.test.ts\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=1 --test-shard=2/2 \"tests/unit/serial/**/*.test.ts\"", + "test:unit:shard:1": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --import ./tests/_setup/blockNetwork.ts --test --test-force-exit --test-concurrency=10 --test-shard=1/2 tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,translator,ui,usage}/**/*.test.ts\" \"tests/unit/**/*.test.mjs\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --import ./tests/_setup/blockNetwork.ts --test --test-force-exit --test-concurrency=10 --test-shard=1/2 \"tests/unit/dashboard/**/*.test.ts\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --import ./tests/_setup/blockNetwork.ts --test --test-force-exit --test-concurrency=1 --test-shard=1/2 \"tests/unit/serial/**/*.test.ts\"", + "test:unit:shard:2": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --import ./tests/_setup/blockNetwork.ts --test --test-force-exit --test-concurrency=10 --test-shard=2/2 tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,translator,ui,usage}/**/*.test.ts\" \"tests/unit/**/*.test.mjs\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --import ./tests/_setup/blockNetwork.ts --test --test-force-exit --test-concurrency=10 --test-shard=2/2 \"tests/unit/dashboard/**/*.test.ts\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --import ./tests/_setup/blockNetwork.ts --test --test-force-exit --test-concurrency=1 --test-shard=2/2 \"tests/unit/serial/**/*.test.ts\"", "test:bun:db": "bun test tests/unit/db-adapters/bunSqliteAdapter.test.ts tests/unit/db-adapters/driverFactory.test.ts tests/unit/db-adapters/cliSqlite.test.mjs", - "test:plan3": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test tests/unit/plan3-p0.test.ts", - "test:fixes": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test tests/unit/fixes-p1.test.ts", - "test:security": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test tests/unit/security-fase01.test.ts", + "test:plan3": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --import ./tests/_setup/blockNetwork.ts --test tests/unit/plan3-p0.test.ts", + "test:fixes": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --import ./tests/_setup/blockNetwork.ts --test tests/unit/fixes-p1.test.ts", + "test:security": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --import ./tests/_setup/blockNetwork.ts --test tests/unit/security-fase01.test.ts", "test:property": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx/esm --test --test-force-exit tests/unit/correctness/*.property.test.ts", "check:cycles": "node scripts/check/check-cycles.mjs", "check:route-validation:t06": "node scripts/check/check-route-validation.mjs", @@ -250,24 +250,24 @@ "check:ts7-diagnostics-ratchet": "node scripts/check/check-ts7-diagnostics-ratchet.mjs", "backfill-aggregation": "node --import tsx src/scripts/backfillAggregation.ts", "env:sync": "node scripts/dev/sync-env.mjs", - "test:integration": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=1 tests/integration/*.test.ts \"tests/integration/combo-matrix/*.test.ts\"", - "test:integration:ci": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=1 --test-shard=$TEST_SHARD tests/integration/*.test.ts \"tests/integration/combo-matrix/*.test.ts\"", - "test:combo:matrix": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=1 \"tests/integration/combo-matrix/*.test.ts\"", - "test:combo:live": "cross-env RUN_COMBO_LIVE=1 DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=1 \"tests/integration/combo-live/*.live.test.ts\"", + "test:integration": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --import ./tests/_setup/blockNetwork.ts --test --test-force-exit --test-concurrency=1 tests/integration/*.test.ts \"tests/integration/combo-matrix/*.test.ts\"", + "test:integration:ci": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --import ./tests/_setup/blockNetwork.ts --test --test-force-exit --test-concurrency=1 --test-shard=$TEST_SHARD tests/integration/*.test.ts \"tests/integration/combo-matrix/*.test.ts\"", + "test:combo:matrix": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --import ./tests/_setup/blockNetwork.ts --test --test-force-exit --test-concurrency=1 \"tests/integration/combo-matrix/*.test.ts\"", + "test:combo:live": "cross-env RUN_COMBO_LIVE=1 DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --import ./tests/_setup/blockNetwork.ts --test --test-force-exit --test-concurrency=1 \"tests/integration/combo-live/*.live.test.ts\"", "test:combo:live:vps": "node scripts/test/combo-live-vps.mjs", "test:combo:live:vps:failover": "node scripts/test/combo-live-vps.mjs --failover", - "test:boundary:live": "cross-env RUN_BOUNDARY_LIVE=1 DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=1 \"tests/boundary/*.live.test.ts\"", - "test:heap": "node --expose-gc --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit tests/integration/heap-growth.test.ts", - "test:chaos": "cross-env RUN_CHAOS_INT=1 node --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=1 tests/integration/resilience-chaos.test.ts", + "test:boundary:live": "cross-env RUN_BOUNDARY_LIVE=1 DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --import ./tests/_setup/blockNetwork.ts --test --test-force-exit --test-concurrency=1 \"tests/boundary/*.live.test.ts\"", + "test:heap": "node --expose-gc --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --import ./tests/_setup/blockNetwork.ts --test --test-force-exit tests/integration/heap-growth.test.ts", + "test:chaos": "cross-env RUN_CHAOS_INT=1 node --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --import ./tests/_setup/blockNetwork.ts --test --test-force-exit --test-concurrency=1 tests/integration/resilience-chaos.test.ts", "test:e2e": "node scripts/dev/run-playwright-tests.mjs test tests/e2e/*.spec.ts", "test:protocols:e2e": "node scripts/dev/run-protocol-clients-tests.mjs", "test:vitest": "vitest run --config vitest.mcp.config.ts", "test:vitest:ui": "vitest run --config vitest.config.ts", "test:mutation": "stryker run", "test:ecosystem": "node scripts/dev/run-ecosystem-tests.mjs", - "test:compat": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=1 tests/e2e/compat-isolated.test.ts", - "test:compat:ollama": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=1 tests/e2e/ollama-real-provider.test.ts", - "test:system": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=1 tests/e2e/system-failover.test.ts", + "test:compat": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --import ./tests/_setup/blockNetwork.ts --test --test-force-exit --test-concurrency=1 tests/e2e/compat-isolated.test.ts", + "test:compat:ollama": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --import ./tests/_setup/blockNetwork.ts --test --test-force-exit --test-concurrency=1 tests/e2e/ollama-real-provider.test.ts", + "test:system": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --import ./tests/_setup/blockNetwork.ts --test --test-force-exit --test-concurrency=1 tests/e2e/system-failover.test.ts", "test:coverage": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true NODE_OPTIONS=--max-old-space-size=8192 c8 --merge-async --output-dir coverage --exclude=tests/** --exclude=**/*.test.* --reporter=text-summary --reporter=html --reporter=json-summary --reporter=lcov --check-coverage --statements 60 --lines 60 --functions 60 --branches 60 npm run test:coverage:runner", "test:coverage:legacy": "c8 --output-dir coverage --exclude=open-sse --check-coverage --lines 50 --functions 50 --branches 50 node --import tsx/esm --test tests/unit/*.test.ts", "coverage:report": "cross-env NODE_OPTIONS=--max-old-space-size=8192 c8 report --merge-async --output-dir coverage --exclude=tests/** --exclude=**/*.test.* --reporter=text --reporter=text-summary --reporter=html --reporter=json-summary --reporter=lcov", @@ -287,8 +287,8 @@ "release:contributors": "node scripts/release/gen-contributors.mjs", "release:uncovered": "node scripts/release/list-uncovered-commits.mjs", "release:lock": "node scripts/release/lock-released-branch.mjs", - "test:coverage:runner": "node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=8 tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,translator,ui,usage}/**/*.test.ts\" \"tests/unit/**/*.test.mjs\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true NODE_OPTIONS=--max-old-space-size=8192 c8 --merge-async --output-dir coverage --exclude=tests/** --exclude=**/*.test.* --reporter=text-summary --reporter=html --reporter=json-summary --reporter=lcov --check-coverage --statements 60 --lines 60 --functions 60 --branches 60 node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=8 \"tests/unit/dashboard/**/*.test.ts\" && npm run test:unit:serial", - "test:unit:serial": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=1 \"tests/unit/serial/**/*.test.ts\"", + "test:coverage:runner": "node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --import ./tests/_setup/blockNetwork.ts --test --test-force-exit --test-concurrency=8 tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,translator,ui,usage}/**/*.test.ts\" \"tests/unit/**/*.test.mjs\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true NODE_OPTIONS=--max-old-space-size=8192 c8 --merge-async --output-dir coverage --exclude=tests/** --exclude=**/*.test.* --reporter=text-summary --reporter=html --reporter=json-summary --reporter=lcov --check-coverage --statements 60 --lines 60 --functions 60 --branches 60 node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --import ./tests/_setup/blockNetwork.ts --test --test-force-exit --test-concurrency=8 \"tests/unit/dashboard/**/*.test.ts\" && npm run test:unit:serial", + "test:unit:serial": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --import ./tests/_setup/blockNetwork.ts --test --test-force-exit --test-concurrency=1 \"tests/unit/serial/**/*.test.ts\"", "alibaba:sync-allowlist": "node --import tsx/esm scripts/ops/sync-alibaba-allowlist.mjs" }, "dependencies": { diff --git a/scripts/quality/test-scoped.sh b/scripts/quality/test-scoped.sh index f57c5140e747..8f54ed3e06d2 100755 --- a/scripts/quality/test-scoped.sh +++ b/scripts/quality/test-scoped.sh @@ -88,7 +88,7 @@ for f in "${RUN_TESTS[@]}"; do done cd "$REPO_ROOT" -NODE_COMMON=(--max-old-space-size=8192 --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit) +NODE_COMMON=(--max-old-space-size=8192 --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --import ./tests/_setup/blockNetwork.ts --test --test-force-exit) export DISABLE_SQLITE_AUTO_BACKUP=true RC=0 if [ ${#REST[@]} -gt 0 ]; then diff --git a/scripts/release/merge-train.sh b/scripts/release/merge-train.sh index e74745d73e81..32d03a0077b5 100755 --- a/scripts/release/merge-train.sh +++ b/scripts/release/merge-train.sh @@ -192,13 +192,13 @@ if [ "$FAST" = "1" ]; then done # Mirror package.json's three test:unit groups exactly (loader + concurrency). if [ ${#MAIN[@]} -gt 0 ]; then - run_gate "DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=20 ${MAIN[*]}" + run_gate "DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --import ./tests/_setup/blockNetwork.ts --test --test-force-exit --test-concurrency=20 ${MAIN[*]}" fi if [ ${#DASH[@]} -gt 0 ]; then - run_gate "DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=20 ${DASH[*]}" + run_gate "DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --import ./tests/_setup/blockNetwork.ts --test --test-force-exit --test-concurrency=20 ${DASH[*]}" fi if [ ${#SERIAL[@]} -gt 0 ]; then - run_gate "DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=1 ${SERIAL[*]}" + run_gate "DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --import ./tests/_setup/blockNetwork.ts --test --test-force-exit --test-concurrency=1 ${SERIAL[*]}" fi if [ ${#MAIN[@]} -eq 0 ] && [ ${#DASH[@]} -eq 0 ] && [ ${#SERIAL[@]} -eq 0 ]; then echo "[merge-train] (fast) no changed node:test files under tests/unit — static gates + vitest only" diff --git a/stryker.conf.json b/stryker.conf.json index bb116ab13acd..97a27905ac81 100644 --- a/stryker.conf.json +++ b/stryker.conf.json @@ -442,6 +442,8 @@ "./open-sse/utils/setupPolyfill.ts", "--import", "./tests/_setup/isolateDataDir.ts", + "--import", + "./tests/_setup/blockNetwork.ts", "--test-reporter=tap", "-r", "{{hookFile}}", diff --git a/tests/_run_dns_guard_test.sh b/tests/_run_dns_guard_test.sh index 9c39d25f6cb4..3b567c35d38f 100755 --- a/tests/_run_dns_guard_test.sh +++ b/tests/_run_dns_guard_test.sh @@ -21,5 +21,5 @@ if ! grep -q "$TEST_HOST" "$HOSTS_FILE" 2>/dev/null; then fi cd "$REPO_ROOT" -node --import tsx/esm --import ./tests/_setup/isolateDataDir.ts \ +node --import tsx/esm --import ./tests/_setup/isolateDataDir.ts --import ./tests/_setup/blockNetwork.ts \ --test tests/unit/mitm-dnsConfig.test.ts diff --git a/tests/_setup/blockNetwork.ts b/tests/_setup/blockNetwork.ts new file mode 100644 index 000000000000..5789b7ef0f1f --- /dev/null +++ b/tests/_setup/blockNetwork.ts @@ -0,0 +1,389 @@ +// Test-only network guard: unit tests must never reach a real provider. +// +// Loaded via `node --import ./tests/_setup/blockNetwork.ts` next to +// tests/_setup/isolateDataDir.ts in every test invocation (package.json test scripts, +// stryker.conf.json tap.nodeArgs, the quality.yml TIA step, scripts/quality/test-scoped.sh +// and scripts/release/merge-train.sh). tests/unit/block-network-wiring.test.ts fails if +// any of those places loads isolateDataDir.ts without this module. NEVER import it from +// production code. +// +// Why it sits BELOW fetch: importing a route loads open-sse/utils/proxyFetch.ts, which +// replaces globalThis.fetch at import time with its own wrapper around the real fetch. +// A test that stubbed globalThis.fetch before that import was silently bypassed and sent +// real requests to api.anthropic.com. Any guard that patches globalThis.fetch can be +// bypassed the same way, and an undici global dispatcher is not enough either, because a +// request can carry its own dispatcher (proxyFetch does). So the guard hooks: +// +// 1. net.Socket.prototype.connect: every TCP client in Node ends here — the built-in +// fetch, undici with any Agent/dispatcher, http/https/http2, net.connect and +// tls.connect (a TLSSocket is a net.Socket and tls.connect calls its connect()). +// 2. The wreq-js native binding (request / createTransport / websocketConnect*): its +// Rust HTTP client opens sockets outside Node's net module, so the socket hook +// cannot see it. The binding is patched lazily, the first time anything requires it. +// +// Allowed: loopback (127.0.0.0/8, ::1, IPv4-mapped loopback, `localhost`) and Unix +// sockets / Windows named pipes, so tests that start a local server keep working. +// +// Modes (env OMNIROUTE_TEST_NETWORK_GUARD): +// "enforce" the attempt is refused, reported on stderr, and the process exits +// non-zero even if the test caught and swallowed the error. +// "report" the attempt is still refused (nothing leaves the machine) and reported, +// but the process exit code is left alone — used to inventory offenders +// without failing the run on the guard itself. +// unset DEFAULT_GUARD_MODE. +// Any other value is rejected at startup. +// +// Live tests: when one of LIVE_TEST_FLAGS is exactly "1" the guard stands aside entirely. +import net from "node:net"; +import { Module } from "node:module"; + +export const GUARD_MODE_ENV = "OMNIROUTE_TEST_NETWORK_GUARD"; +export const BLOCKED_ERROR_CODE = "ERR_TEST_NETWORK_BLOCKED"; +export const LOG_PREFIX = "[network-guard]"; + +/** + * Existing opt-in flags whose tests are meant to send real traffic to external hosts. + * Every one is compared with === "1" by the tests that read it. + */ +export const LIVE_TEST_FLAGS = [ + "RUN_LIVE_TESTS", // tests/helpers/liveOptIn.ts — tests/integration live suites + "RUN_COMBO_LIVE", // tests/integration/combo-live (npm run test:combo:live) + "RUN_BOUNDARY_LIVE", // tests/boundary/*.live.test.ts (npm run test:boundary:live) + "RUN_LIVE_WIRE_CAPTURE", // tests/integration/live-default-combo-wire-capture.test.ts + "RUN_CLI_SMOKE", // tests/integration/upstream-cli-smoke.int.test.ts + "RUN_CONTRACT_INT", // tests/integration/provider-journey.contract.test.ts + "RUN_SERVICES_INT", // tests/integration/services — npm registry + binary downloads + "RUN_LLMLINGUA_INT", // tests/unit/compression/llmlingua-ultra-entry.test.ts — model download + "RUN_QUOTA_REDIS_INT", // tests/unit/quota-redis-store.test.ts — real Redis +] as const; + +export type GuardMode = "enforce" | "report" | "off"; + +/** + * Mode used when OMNIROUTE_TEST_NETWORK_GUARD is unset: a non-loopback attempt fails the + * test process. Report mode stays available for a future rollout (run the suite with + * OMNIROUTE_TEST_NETWORK_GUARD=report to inventory attempts without failing on them). + */ +export const DEFAULT_GUARD_MODE: "enforce" | "report" = "enforce"; + +export interface GuardDecision { + mode: GuardMode; + reason: string; +} + +type Env = Readonly>; + +export function resolveGuardMode(env: Env): GuardDecision { + const liveFlag = LIVE_TEST_FLAGS.find((flag) => env[flag] === "1"); + if (liveFlag) return { mode: "off", reason: `${liveFlag}=1 (live test run)` }; + const raw = env[GUARD_MODE_ENV]; + if (raw === undefined || raw === "") return { mode: DEFAULT_GUARD_MODE, reason: "default" }; + if (raw === "enforce" || raw === "report") { + return { mode: raw, reason: `${GUARD_MODE_ENV}=${raw}` }; + } + throw new Error( + `${LOG_PREFIX} ${GUARD_MODE_ENV}="${raw}" is not supported. Use "enforce" or "report" ` + + `(default: ${DEFAULT_GUARD_MODE}). To send real traffic, run a live suite with its live-test flag ` + + `(${LIVE_TEST_FLAGS.join(", ")}).` + ); +} + +const loopback = new net.BlockList(); +loopback.addSubnet("127.0.0.0", 8, "ipv4"); +loopback.addAddress("::1", "ipv6"); + +/** True for loopback IPs (v4, v6, IPv4-mapped v6) and the name `localhost`. */ +export function isLoopbackHost(host: string): boolean { + const normalized = host + .trim() + .replace(/^\[(.*)\]$/, "$1") + .replace(/\.$/, "") + .toLowerCase(); + if (normalized === "localhost") return true; + const family = net.isIP(normalized); + if (family === 4) return loopback.check(normalized, "ipv4"); + if (family === 6) return loopback.check(normalized, "ipv6"); + return false; +} + +export type ConnectTarget = + { kind: "local-socket"; path: string } | { kind: "tcp"; host: string; port: string }; + +function isPipeName(value: string): boolean { + return value.length > 0 && Number.isNaN(Number(value)); +} + +/** Mirrors net's normalizeArgs() closely enough to know where a connect() goes. */ +export function targetFromConnectArgs(args: readonly unknown[]): ConnectTarget { + const first: unknown = Array.isArray(args[0]) ? args[0][0] : args[0]; + if (typeof first === "object" && first !== null) { + if ("path" in first && typeof first.path === "string" && first.path !== "") { + return { kind: "local-socket", path: first.path }; + } + const host = "host" in first && typeof first.host === "string" ? first.host : "localhost"; + const port = "port" in first ? String(first.port) : ""; + return { kind: "tcp", host, port }; + } + if (typeof first === "string" && isPipeName(first)) { + return { kind: "local-socket", path: first }; + } + const host = typeof args[1] === "string" ? args[1] : "localhost"; + return { kind: "tcp", host, port: String(first) }; +} + +/** + * The `lookup` of a connect() call, when the caller pinned its own resolver + * (`new Agent({ connect: { lookup } })` in src/shared/network/guardedFetch.ts, and the + * webhook/obsidian dispatchers). The hostname then says nothing about where the socket + * goes — the resolved address does — so the guard defers to what that lookup returns. + */ +export function pinnedLookupOf(args: readonly unknown[]): unknown { + const first: unknown = Array.isArray(args[0]) ? args[0][0] : args[0]; + if (typeof first !== "object" || first === null || !("lookup" in first)) return undefined; + const lookup: unknown = Reflect.get(first, "lookup"); + return typeof lookup === "function" ? lookup : undefined; +} + +function setLookup(args: readonly unknown[], lookup: unknown): void { + const first: unknown = Array.isArray(args[0]) ? args[0][0] : args[0]; + if (typeof first === "object" && first !== null) Reflect.set(first, "lookup", lookup); +} + +/** Addresses a dns.lookup callback reported, in either of its two shapes. */ +export function lookupResultAddresses(address: unknown): string[] { + if (typeof address === "string") return [address]; + if (!Array.isArray(address)) return []; + return address + .map((entry: unknown) => + typeof entry === "object" && entry !== null && "address" in entry + ? Reflect.get(entry, "address") + : entry + ) + .filter((entry): entry is string => typeof entry === "string"); +} + +export class NetworkAccessBlockedError extends Error { + override name = "NetworkAccessBlockedError"; + readonly code = BLOCKED_ERROR_CODE; + + constructor( + readonly host: string, + readonly port: string, + readonly via: string, + readonly testFile: string + ) { + super( + `Unit tests must not reach the network: blocked ${via} connection to ${host}:${port} ` + + `(test process: ${testFile}). Stub the call AFTER all imports and assert the stub is ` + + `the live one (open-sse/utils/proxyFetch.ts replaces globalThis.fetch at import ` + + `time); a real live test must run under its live-test flag ` + + `(${LIVE_TEST_FLAGS.join(", ")}). Guard: tests/_setup/blockNetwork.ts ` + + `[${BLOCKED_ERROR_CODE}]` + ); + } +} + +export interface Violation { + host: string; + port: string; + via: string; + testFile: string; + stack: string; +} + +function currentTestFile(): string { + return process.argv[1] ?? "(unknown)"; +} + +function captureStack(): string { + const previousLimit = Error.stackTraceLimit; + Error.stackTraceLimit = 60; + const stack = new Error("network attempt").stack ?? ""; + Error.stackTraceLimit = previousLimit; + return stack + .split("\n") + .slice(1) + .filter((line) => !line.includes("blockNetwork.ts")) + .join("\n"); +} + +function hostPortFromUrl(raw: string): { host: string; port: string } { + try { + const url = new URL(raw); + const defaultPort = + url.protocol === "https:" || url.protocol === "wss:" + ? "443" + : url.protocol === "socks5:" || url.protocol === "socks5h:" + ? "1080" + : "80"; + return { host: url.hostname, port: url.port || defaultPort }; + } catch { + return { host: raw, port: "" }; + } +} + +function stringField(value: unknown, key: string): string | undefined { + if (typeof value !== "object" || value === null || !(key in value)) return undefined; + const field: unknown = Reflect.get(value, key); + return typeof field === "string" && field !== "" ? field : undefined; +} + +export interface NetworkGuard { + readonly decision: GuardDecision; + readonly violations: readonly Violation[]; +} + +const INSTALLED = Symbol.for("omniroute.tests.networkGuard"); + +function isNetworkGuard(value: unknown): value is NetworkGuard { + return typeof value === "object" && value !== null && "decision" in value; +} + +function installNetworkGuard(decision: GuardDecision): NetworkGuard { + const violations: Violation[] = []; + + function block(host: string, port: string, via: string): NetworkAccessBlockedError { + const testFile = currentTestFile(); + const violation: Violation = { host, port, via, testFile, stack: captureStack() }; + violations.push(violation); + process.stderr.write( + `${LOG_PREFIX} ${decision.mode === "report" ? "REPORT" : "BLOCKED"} ` + + `host=${host} port=${port} via=${via} file=${testFile}\n${violation.stack}\n` + ); + return new NetworkAccessBlockedError(host, port, via, testFile); + } + + // 1. Socket layer. + const originalConnect = net.Socket.prototype.connect; + function guardedConnect(this: net.Socket, ...args: unknown[]): net.Socket { + const target = targetFromConnectArgs(args); + if (target.kind === "local-socket" || isLoopbackHost(target.host)) { + return Reflect.apply(originalConnect, this, args); + } + const pinned = pinnedLookupOf(args); + if (typeof pinned === "function") { + // Caller pinned its own resolver: the hostname is not where the socket goes, so + // decide on the address that lookup actually returns. + setLookup(args, function guardedLookup(this: unknown, ...lookupArgs: unknown[]): unknown { + const callbackIndex = lookupArgs.findIndex((arg) => typeof arg === "function"); + const callback = lookupArgs[callbackIndex]; + if (typeof callback !== "function") return Reflect.apply(pinned, this, lookupArgs); + const guardedCallback = (...results: unknown[]): unknown => { + const [lookupError, address] = results; + if (lookupError) return Reflect.apply(callback, this, results); + const offending = lookupResultAddresses(address).find( + (candidate) => !isLoopbackHost(candidate) + ); + if (offending === undefined) return Reflect.apply(callback, this, results); + return Reflect.apply(callback, this, [ + block(`${target.host}->${offending}`, target.port, "socket(pinned-lookup)"), + ]); + }; + const patched = [...lookupArgs]; + patched[callbackIndex] = guardedCallback; + return Reflect.apply(pinned, this, patched); + }); + return Reflect.apply(originalConnect, this, args); + } + const error = block(target.host, target.port, "socket"); + // Fail the way a refused connection fails (async 'error' on the socket), so every + // client — fetch, undici, http, tls — surfaces it through its normal error path. + process.nextTick(() => this.destroy(error)); + return this; + } + Object.defineProperty(net.Socket.prototype, "connect", { + value: guardedConnect, + writable: true, + configurable: true, + }); + + // 2. wreq-js native binding (Rust sockets never touch net.Socket). + const transportProxies = new Map(); + const patchedBindings = new WeakSet(); + + function wreqTarget(options: unknown): { host: string; port: string } | null { + const transportId = stringField(options, "transportId"); + const egress = + stringField(options, "proxy") ?? + (transportId ? transportProxies.get(transportId) : undefined) ?? + stringField(options, "url"); + if (!egress) return null; + const target = hostPortFromUrl(egress); + return isLoopbackHost(target.host) ? null : target; + } + + function wrapBindingCall(binding: object, name: string): void { + const original: unknown = Reflect.get(binding, name); + if (typeof original !== "function") return; + Object.defineProperty(binding, name, { + value: function guardedBindingCall(this: unknown, ...args: unknown[]): unknown { + const target = wreqTarget(args[0]); + if (target) return Promise.reject(block(target.host, target.port, `wreq-js.${name}`)); + return Reflect.apply(original, this, args); + }, + writable: true, + configurable: true, + }); + } + + function patchWreqBinding(exported: unknown): void { + if (typeof exported !== "object" || exported === null) return; + if (patchedBindings.has(exported)) return; + const createTransport: unknown = Reflect.get(exported, "createTransport"); + const request: unknown = Reflect.get(exported, "request"); + if (typeof createTransport !== "function" || typeof request !== "function") return; + patchedBindings.add(exported); + Object.defineProperty(exported, "createTransport", { + value: function guardedCreateTransport(this: unknown, ...args: unknown[]): unknown { + const id: unknown = Reflect.apply(createTransport, this, args); + const proxy = stringField(args[0], "proxy"); + if (typeof id === "string" && proxy) transportProxies.set(id, proxy); + return id; + }, + writable: true, + configurable: true, + }); + for (const name of ["request", "websocketConnect", "websocketConnectSession"]) { + wrapBindingCall(exported, name); + } + } + + // wreq-js (both its ESM and CJS builds) loads the binding through createRequire(), + // whose require() delegates to Module.prototype.require. Patch lazily there so test + // processes that never touch wreq-js do not pay for loading the native addon. + const originalRequire = Module.prototype.require; + Object.defineProperty(Module.prototype, "require", { + value: function guardedRequire(this: Module, id: string): unknown { + const exported: unknown = Reflect.apply(originalRequire, this, [id]); + if (id.includes("wreq-js")) patchWreqBinding(exported); + return exported; + }, + writable: true, + configurable: true, + }); + + process.on("exit", () => { + if (violations.length === 0) return; + const hosts = [...new Set(violations.map((v) => `${v.host}:${v.port}`))].join(", "); + process.stderr.write( + `${LOG_PREFIX} ${violations.length} non-loopback connection attempt(s) in ` + + `${currentTestFile()}: ${hosts}\n` + ); + if (decision.mode === "enforce") process.exitCode = 1; + }); + + return { decision, violations }; +} + +function activate(): NetworkGuard { + const existing: unknown = Reflect.get(globalThis, INSTALLED); + if (isNetworkGuard(existing)) return existing; + const decision = resolveGuardMode(process.env); + const guard: NetworkGuard = + decision.mode === "off" ? { decision, violations: [] } : installNetworkGuard(decision); + Object.defineProperty(globalThis, INSTALLED, { value: guard, enumerable: false }); + return guard; +} + +/** The active guard of this process (mode, and the attempts recorded so far). */ +export const networkGuard: NetworkGuard = activate(); diff --git a/tests/unit/10313-catalog-cache-key-hashing.test.ts b/tests/unit/10313-catalog-cache-key-hashing.test.ts index ca5b4d1947d5..b9d43fa51cd5 100644 --- a/tests/unit/10313-catalog-cache-key-hashing.test.ts +++ b/tests/unit/10313-catalog-cache-key-hashing.test.ts @@ -13,6 +13,10 @@ const apiKeysDb = await import("../../src/lib/db/apiKeys.ts"); const v1ModelsCatalog = await import("../../src/app/api/v1/models/catalog.ts"); const catalogCacheMod = await import("../../src/app/api/v1/models/catalogCache.ts"); +// Hermetic outbound layer — installed AFTER every import (proxyFetch replaces +// globalThis.fetch at import time). See tests/unit/_helpers/offlineOutbound.ts. +await (await import("./_helpers/offlineOutbound.ts")).installOfflineOutbound(); + const SECRET = "sk-live-PROBE-10313-SUPER-SECRET-TOKEN"; test.beforeEach(() => { diff --git a/tests/unit/11759-embedding-registry-width-and-type.test.ts b/tests/unit/11759-embedding-registry-width-and-type.test.ts index 95cfbb5115fa..98d7f81ddd3b 100644 --- a/tests/unit/11759-embedding-registry-width-and-type.test.ts +++ b/tests/unit/11759-embedding-registry-width-and-type.test.ts @@ -39,6 +39,10 @@ const modelsDb = await import("../../src/lib/db/models.ts"); const v1ModelsCatalog = await import("../../src/app/api/v1/models/catalog.ts"); const embeddingRegistry = await import("../../open-sse/config/embeddingRegistry.ts"); +// Hermetic outbound layer — installed AFTER every import (proxyFetch replaces +// globalThis.fetch at import time). See tests/unit/_helpers/offlineOutbound.ts. +await (await import("./_helpers/offlineOutbound.ts")).installOfflineOutbound(); + /** Both are real registry entries, so the expected widths come from the registry itself. */ const OPENROUTER_MODEL = "qwen/qwen3-embedding-8b"; const OPENAI_MODEL = "text-embedding-3-small"; diff --git a/tests/unit/11947-auto-combo-modalities.test.ts b/tests/unit/11947-auto-combo-modalities.test.ts index b9e4552c247c..d71a5d6359ea 100644 --- a/tests/unit/11947-auto-combo-modalities.test.ts +++ b/tests/unit/11947-auto-combo-modalities.test.ts @@ -23,6 +23,10 @@ process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "catalog-11947-secret const core = await import("../../src/lib/db/core.ts"); const catalog = await import("../../src/app/api/v1/models/catalog.ts"); +// Hermetic outbound layer — installed AFTER every import (proxyFetch replaces +// globalThis.fetch at import time). See tests/unit/_helpers/offlineOutbound.ts. +await (await import("./_helpers/offlineOutbound.ts")).installOfflineOutbound(); + type CatalogEntry = { id: string; owned_by?: string; diff --git a/tests/unit/12058-models-catalog-canonical-self-aliased.test.ts b/tests/unit/12058-models-catalog-canonical-self-aliased.test.ts index d029146c0bf4..7708f6fd4191 100644 --- a/tests/unit/12058-models-catalog-canonical-self-aliased.test.ts +++ b/tests/unit/12058-models-catalog-canonical-self-aliased.test.ts @@ -31,6 +31,23 @@ const modelsDb = await import("../../src/lib/db/models.ts"); const aliasesDb = await import("../../src/lib/db/models/aliases.ts"); const v1ModelsCatalog = await import("../../src/app/api/v1/models/catalog.ts"); +// Hermetic outbound layer — installed AFTER every import, because +// open-sse/utils/proxyFetch.ts replaces globalThis.fetch at import time and would +// discard a stub installed before it. Production code under test makes best-effort +// calls (catalog polls, egress probes) that must never leave the machine. +const { installOfflineOutbound } = await import("./_helpers/offlineOutbound.ts"); +// The providers seeded below (antigravity, agy, claude, groq) make the catalog builder +// run their live model discovery. Left unanswered it retries until the builder's own +// deadline and the route 500s with `catalog_build_timeout`, so every discovery URL gets +// an immediate empty catalog here — this suite asserts the CURATED rows, not discovery. +await installOfflineOutbound({ + respond: () => + new Response(JSON.stringify({ data: [], models: [] }), { + status: 200, + headers: { "content-type": "application/json" }, + }), +}); + type CatalogRow = { id: string; parent: string | null; root: string | null }; type PrefixMode = "alias" | "canonical" | "dual"; diff --git a/tests/unit/8326-compatible-id-regex.test.ts b/tests/unit/8326-compatible-id-regex.test.ts index a3d491e734f4..4c616d1df31f 100644 --- a/tests/unit/8326-compatible-id-regex.test.ts +++ b/tests/unit/8326-compatible-id-regex.test.ts @@ -33,6 +33,10 @@ const { isCompatibleProviderConnectionId } = await import("../../src/shared/utils/compatibleProviderId.ts"); const { getProviderDisplayName } = await import("../../src/lib/display/names.ts"); +// Hermetic outbound layer — installed AFTER every import (proxyFetch replaces +// globalThis.fetch at import time). See tests/unit/_helpers/offlineOutbound.ts. +await (await import("./_helpers/offlineOutbound.ts")).installOfflineOutbound(); + function makeRequest(provider: string) { return new Request(`http://localhost/api/v1/providers/${encodeURIComponent(provider)}/models`); } diff --git a/tests/unit/8327-models-owned-by-prefix.test.ts b/tests/unit/8327-models-owned-by-prefix.test.ts index 8a4147ffaa42..254e66c82b6f 100644 --- a/tests/unit/8327-models-owned-by-prefix.test.ts +++ b/tests/unit/8327-models-owned-by-prefix.test.ts @@ -31,6 +31,10 @@ const providersDb = await import("../../src/lib/db/providers.ts"); const modelsDb = await import("../../src/lib/db/models.ts"); const v1ModelsCatalog = await import("../../src/app/api/v1/models/catalog.ts"); +// Hermetic outbound layer — installed AFTER every import (proxyFetch replaces +// globalThis.fetch at import time). See tests/unit/_helpers/offlineOutbound.ts. +await (await import("./_helpers/offlineOutbound.ts")).installOfflineOutbound(); + // A realistic provider-node id shape, matching `openai-compatible-chat-` per // src/app/api/provider-nodes/route.ts / createProviderNode()'s `id: data.id || uuidv4()`. const NODE_ID = "openai-compatible-chat-550e8400-e29b-41d4-a716-446655440000"; diff --git a/tests/unit/8958-alias-backed-node-prefix.test.ts b/tests/unit/8958-alias-backed-node-prefix.test.ts index 1ec37506f734..fa36e467d5db 100644 --- a/tests/unit/8958-alias-backed-node-prefix.test.ts +++ b/tests/unit/8958-alias-backed-node-prefix.test.ts @@ -31,6 +31,10 @@ const modelsDb = await import("../../src/lib/db/models.ts"); const aliasesDb = await import("../../src/lib/db/models/aliases.ts"); const v1ModelsCatalog = await import("../../src/app/api/v1/models/catalog.ts"); +// Hermetic outbound layer — installed AFTER every import (proxyFetch replaces +// globalThis.fetch at import time). See tests/unit/_helpers/offlineOutbound.ts. +await (await import("./_helpers/offlineOutbound.ts")).installOfflineOutbound(); + const NODE_ID = "openai-compatible-chat-550e8400-e29b-41d4-a716-446655440000"; const UUID_SHAPE_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i; const CONFIGURED_PREFIX = "fta"; diff --git a/tests/unit/9034-alias-backed-prefix-id-repro.test.ts b/tests/unit/9034-alias-backed-prefix-id-repro.test.ts index 81d470c7ff93..2afa43e93b7c 100644 --- a/tests/unit/9034-alias-backed-prefix-id-repro.test.ts +++ b/tests/unit/9034-alias-backed-prefix-id-repro.test.ts @@ -21,6 +21,10 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +// Hermetic outbound layer — installed AFTER every import (proxyFetch replaces +// globalThis.fetch at import time). See tests/unit/_helpers/offlineOutbound.ts. +await (await import("./_helpers/offlineOutbound.ts")).installOfflineOutbound(); + const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-9034-")); process.env.DATA_DIR = TEST_DATA_DIR; diff --git a/tests/unit/_helpers/deadLoopback.ts b/tests/unit/_helpers/deadLoopback.ts new file mode 100644 index 000000000000..e49ce9359435 --- /dev/null +++ b/tests/unit/_helpers/deadLoopback.ts @@ -0,0 +1,25 @@ +/** + * A loopback address:port with nothing listening — the hermetic way to say "this host is + * unreachable" in a test. + * + * Tests used to point at a made-up public hostname (`p.example.com`, `bifrost.test.local`, + * `192.0.2.1`) and rely on DNS failing or the packet being dropped. That is a real outbound + * attempt: it depends on the machine's resolver, it is slow, and tests/_setup/blockNetwork.ts + * (rightly) refuses it. A closed loopback port gives the same observable outcome — + * ECONNREFUSED, immediately — without leaving the machine. + */ +import net from "node:net"; + +/** Binds an ephemeral loopback port, releases it, and returns it. */ +export async function reserveDeadLoopbackPort(): Promise { + const server = net.createServer(); + const port = await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + resolve(typeof address === "object" && address !== null ? address.port : 0); + }); + }); + await new Promise((resolve) => server.close(() => resolve())); + return port; +} diff --git a/tests/unit/_helpers/offlineOutbound.ts b/tests/unit/_helpers/offlineOutbound.ts new file mode 100644 index 000000000000..90822c5b9936 --- /dev/null +++ b/tests/unit/_helpers/offlineOutbound.ts @@ -0,0 +1,226 @@ +/** + * Hermetic outbound layer for a unit test file. + * + * Most tests that reached the network did not ask for it: they exercise a route or a + * service, and production code makes an incidental best-effort call on the side (the AI + * Horde image-catalog poll behind /v1/models, the egress-IP probe warmed by the chat + * route, a provider's live model discovery). The call is swallowed by a try/catch, so + * the test passes either way — while real requests leave the machine. + * + * `installOfflineOutbound()` makes those calls fail immediately, in-process, with a + * message naming the URL: + * + * const offline = await installOfflineOutbound(); // AFTER all imports + * + * It MUST be called after the file's imports. open-sse/utils/proxyFetch.ts replaces + * globalThis.fetch at import time, so a stub installed before a route import is silently + * discarded — that is exactly how the incident happened. The call asserts that its stub + * is the live globalThis.fetch once installed. + * + * Loopback stays open (tests that start a local server keep working) and a test that + * WANTS a specific outbound URL answers it through `respond`; everything else throws. + * The egress probe never touches globalThis.fetch (it calls undici's request() directly), + * so it is neutralised through its own seam, `_setEgressProbeForTests`. The wreq-js TLS + * transport (a native binding, also invisible to a fetch stub) is left to the test by + * default — tests/_setup/blockNetwork.ts blocks it at the binding — and is routed through + * `respond` when a suite passes `interceptTlsClient: true`. + * + * This is the in-process complement to tests/_setup/blockNetwork.ts: the setup module + * makes the rule unbypassable at the socket layer, this helper keeps an individual test + * from relying on the socket ever being attempted. + */ +import assert from "node:assert/strict"; + +import { isLoopbackHost } from "../../_setup/blockNetwork.ts"; + +export const STUBBED_ERROR_CODE = "ERR_TEST_OUTBOUND_STUBBED"; + +export class OutboundStubbedError extends Error { + override name = "OutboundStubbedError"; + readonly code = STUBBED_ERROR_CODE; + + constructor(readonly url: string) { + super( + `Unexpected outbound request to ${url}. This unit test is offline: answer the URL ` + + `through installOfflineOutbound({ respond }) if the test needs it, or leave it ` + + `unanswered if production code is only making a best-effort call. ` + + `[${STUBBED_ERROR_CODE}]` + ); + } +} + +/** Answers a request the test expects. Return undefined to leave a URL unanswered. */ +export type OutboundResponder = ( + url: URL, + init: RequestInit | undefined +) => Response | Promise | undefined; + +export interface OfflineOutbound { + /** Every non-loopback URL the code under test tried to reach. */ + readonly attempts: readonly string[]; + /** Restore the real fetch and the production seams. */ + restore(): void; +} + +function urlOf(input: RequestInfo | URL): string { + if (typeof input === "string") return input; + if (input instanceof URL) return input.href; + return input.url; +} + +export interface OfflineOutboundOptions { + /** Answer the URLs the test legitimately expects; everything else throws. */ + respond?: OutboundResponder; + /** + * Leave globalThis.fetch alone (default: replace it). proxyFetch's patchedFetch IS + * globalThis.fetch, so replacing it also removes the proxy/TLS-fingerprint routing a + * few tests assert on. Those tests pass `interceptFetch: false` and rely on the + * production seams above plus tests/_setup/blockNetwork.ts at the socket layer. + */ + interceptFetch?: boolean; + /** + * Also route open-sse/utils/proxyFetch.ts's wreq-js TLS client through `respond` + * (default: leave that seam to the test). Suites that exercise the browser-impersonating + * executors need it — their transport is a native binding, not globalThis.fetch. + */ + interceptTlsClient?: boolean; + /** + * Refuse proxyFetch's own undici dispatchers for non-loopback hosts (default: on). + * Turn it off only for a suite that drives proxyFetch against a real dispatcher. + */ + interceptProxyDispatchers?: boolean; +} + +export async function installOfflineOutbound( + options: OfflineOutboundOptions = {} +): Promise { + const restores: Array<() => void> = []; + + // Seams for transports that bypass globalThis.fetch, taken FIRST: importing them pulls + // module graphs of their own (proxyDispatcher → proxyFetch), and a proxyFetch instance + // loaded after the stub was installed would re-patch globalThis.fetch on top of it. + try { + const egress = await import("../../../src/lib/proxyEgress.ts"); + egress._setEgressProbeForTests(async () => ({ + ip: null, + latencyMs: 0, + error: "offline unit test", + })); + restores.push(() => egress._setEgressProbeForTests(null)); + } catch { + // The module is optional for the caller's graph. + } + + // proxyFetch's direct (no-proxy) path does NOT go through globalThis.fetch: it calls + // undici's fetch with a dispatcher of its own, taken from open-sse/utils/proxyDispatcherCache + // (symbol-keyed globals). A caller holding the proxyFetch export therefore opens a socket + // before any fetch stub is consulted. Seeding those globals with a MockAgent that refuses + // everything except loopback closes that path in-process — the request fails before connect, + // and proxyFetch's fallback to globalThis.fetch lands on the stub below. + if (options.interceptProxyDispatchers !== false) { + const { MockAgent, getGlobalDispatcher, setGlobalDispatcher } = await import("undici"); + const mock = new MockAgent(); + mock.disableNetConnect(); + mock.enableNetConnect((host: string) => isLoopbackHost(host.replace(/:\d+$/, ""))); + // Anything that calls undici without an explicit dispatcher uses the global one. + const previousGlobal = getGlobalDispatcher(); + setGlobalDispatcher(mock); + restores.push(() => setGlobalDispatcher(previousGlobal)); + const scope: Record = globalThis; + const keys = [ + Symbol.for("omniroute.proxyDispatcher.default"), + Symbol.for("omniroute.proxyDispatcher.retry"), + Symbol.for("omniroute.proxyDispatcher.cache"), + ]; + const previous = keys.map((key) => scope[key]); + scope[keys[0]] = mock; + scope[keys[1]] = mock; + // Per-proxy dispatchers are looked up in this Map and created on a miss; always + // answering with the mock keeps proxied egress in-process too. + scope[keys[2]] = new Map([]) as unknown; + const cache = scope[keys[2]]; + if (cache instanceof Map) { + Object.defineProperty(cache, "get", { value: () => mock, configurable: true }); + } + restores.push(() => { + keys.forEach((key, index) => { + scope[key] = previous[index]; + }); + void mock.close(); + }); + } + + const attempts: string[] = []; + + async function answerOrThrow(raw: string, init?: RequestInit): Promise { + attempts.push(raw); + let parsed: URL | null = null; + try { + parsed = new URL(raw); + } catch { + parsed = null; + } + const answer = parsed ? await options.respond?.(parsed, init) : undefined; + if (answer) return answer; + throw new OutboundStubbedError(raw); + } + + if (options.interceptTlsClient) { + const proxyFetch = await import("../../../open-sse/utils/proxyFetch.ts"); + proxyFetch.setTlsClientForTest({ available: true, fetch: (url: string) => answerOrThrow(url) }); + restores.push(() => proxyFetch.setTlsClientForTest(null)); + } + + const liveFetch = globalThis.fetch; + if (options.interceptFetch === false) { + return { + attempts, + restore() { + for (const restore of restores.reverse()) restore(); + }, + }; + } + + // A route imported LATER (inside a test) pulls proxyFetch, whose module body assigns + // globalThis.fetch — that would drop this stub silently. proxyFetch guards that + // assignment with the `isPatched` flag of its symbol-keyed global state, so claiming the + // flag keeps later instances off globalThis.fetch. Tests that install a stub of their own + // still win: this is a plain assignment, not an accessor. + const patchState = Reflect.get(globalThis, Symbol.for("omniroute.proxyFetch.state")); + if (typeof patchState === "object" && patchState !== null && "isPatched" in patchState) { + const wasPatched: unknown = Reflect.get(patchState, "isPatched"); + Reflect.set(patchState, "isPatched", true); + restores.push(() => { + Reflect.set(patchState, "isPatched", wasPatched); + }); + } + + const stub: typeof globalThis.fetch = async (input, init) => { + const raw = urlOf(input); + let host: string | null = null; + try { + host = new URL(raw).hostname; + } catch { + host = null; + } + if (host !== null && isLoopbackHost(host)) return liveFetch(input, init); + return answerOrThrow(raw, init ?? undefined); + }; + globalThis.fetch = stub; + assert.equal( + globalThis.fetch, + stub, + "installOfflineOutbound must run AFTER every import: something replaced globalThis.fetch" + ); + + restores.push(() => { + globalThis.fetch = liveFetch; + }); + + return { + attempts, + restore() { + for (const restore of restores.reverse()) restore(); + }, + }; +} diff --git a/tests/unit/admin-audit-events.test.ts b/tests/unit/admin-audit-events.test.ts index d521d2a0d443..35a47acca841 100644 --- a/tests/unit/admin-audit-events.test.ts +++ b/tests/unit/admin-audit-events.test.ts @@ -19,6 +19,10 @@ const providersRoute = await import("../../src/app/api/providers/route.ts"); const providerByIdRoute = await import("../../src/app/api/providers/[id]/route.ts"); const providersDb = await import("../../src/lib/db/providers.ts"); const modelsDb = await import("../../src/lib/db/models.ts"); + +// Hermetic outbound layer — installed AFTER every import (proxyFetch replaces +// globalThis.fetch at import time). See tests/unit/_helpers/offlineOutbound.ts. +await (await import("./_helpers/offlineOutbound.ts")).installOfflineOutbound(); const originalGetLoginCookieStore = loginRoute.authRouteInternals.getCookieStore; const originalGetLogoutCookieStore = logoutRoute.logoutRouteInternals.getCookieStore; diff --git a/tests/unit/antigravity-byop-account-rotation.test.ts b/tests/unit/antigravity-byop-account-rotation.test.ts index e73b22c18479..3a3ce9dbb256 100644 --- a/tests/unit/antigravity-byop-account-rotation.test.ts +++ b/tests/unit/antigravity-byop-account-rotation.test.ts @@ -17,6 +17,10 @@ const { clearAntigravityProjectCache } = const { seedAntigravityIdeVersionCache, seedAntigravityCliVersionCache } = await import("../../open-sse/services/antigravityVersion.ts"); +// Hermetic outbound layer — installed AFTER every import (proxyFetch replaces +// globalThis.fetch at import time). See tests/unit/_helpers/offlineOutbound.ts. +await (await import("./_helpers/offlineOutbound.ts")).installOfflineOutbound(); + test.beforeEach(async () => { BaseExecutor.RETRY_CONFIG.delayMs = 0; process.env.ANTIGRAVITY_CREDITS = "off"; diff --git a/tests/unit/antigravity-missing-project-chat.test.ts b/tests/unit/antigravity-missing-project-chat.test.ts index d049e123932f..d7e2907251bc 100644 --- a/tests/unit/antigravity-missing-project-chat.test.ts +++ b/tests/unit/antigravity-missing-project-chat.test.ts @@ -11,6 +11,10 @@ const { clearAntigravityProjectCache } = const { seedAntigravityIdeVersionCache, seedAntigravityCliVersionCache } = await import("../../open-sse/services/antigravityVersion.ts"); +// Hermetic outbound layer — installed AFTER every import (proxyFetch replaces +// globalThis.fetch at import time). See tests/unit/_helpers/offlineOutbound.ts. +await (await import("./_helpers/offlineOutbound.ts")).installOfflineOutbound(); + const BOOTSTRAP_URL = "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist"; test.beforeEach(async () => { diff --git a/tests/unit/api-models-v1-models-mismatch-10615.test.ts b/tests/unit/api-models-v1-models-mismatch-10615.test.ts index aed82060e8fa..fdf035c13674 100644 --- a/tests/unit/api-models-v1-models-mismatch-10615.test.ts +++ b/tests/unit/api-models-v1-models-mismatch-10615.test.ts @@ -14,6 +14,10 @@ const localDb = { replaceSyncedAvailableModelsForConnection }; const modelsRoute = await import("../../src/app/api/models/route.ts"); const v1ModelsCatalog = await import("../../src/app/api/v1/models/catalog.ts"); +// Hermetic outbound layer — installed AFTER every import (proxyFetch replaces +// globalThis.fetch at import time). See tests/unit/_helpers/offlineOutbound.ts. +await (await import("./_helpers/offlineOutbound.ts")).installOfflineOutbound(); + test.after(() => { core.resetDbInstance(); try { diff --git a/tests/unit/api/v1/relay-completions-errors.test.ts b/tests/unit/api/v1/relay-completions-errors.test.ts index 96b19d5d6f74..bef88db230b5 100644 --- a/tests/unit/api/v1/relay-completions-errors.test.ts +++ b/tests/unit/api/v1/relay-completions-errors.test.ts @@ -8,6 +8,13 @@ import { } from "../../../../src/app/api/v1/relay/chat/completions/relaySecurity.ts"; import { getDbInstance } from "../../../../src/lib/db/core.ts"; import { getRelayLogs } from "../../../../src/lib/db/relayProxies.ts"; +import { reserveDeadLoopbackPort } from "../../_helpers/deadLoopback.ts"; + +// Every test below stubs globalThis.fetch, but the relay path also dispatches through +// proxyFetch's own undici agent, which never sees that stub. Pointing BIFROST_BASE_URL at +// a closed loopback port keeps that escape on this machine (immediate ECONNREFUSED) +// instead of resolving the made-up host `bifrost.test.local` on the network. +const DEAD_BIFROST_PORT = await reserveDeadLoopbackPort(); // ─── Relay completions route: Bifrost upstream error normalization ────────── // @@ -74,7 +81,7 @@ function restoreEnv() { function setupBifrostEnv() { process.env.OMNIROUTE_RELAY_BACKEND = "bifrost"; - process.env.BIFROST_BASE_URL = "http://bifrost.test.local:8080"; + process.env.BIFROST_BASE_URL = `http://127.0.0.1:${DEAD_BIFROST_PORT}`; process.env.BIFROST_TIMEOUT_MS = "5000"; delete process.env.BIFROST_API_KEY; delete process.env.OMNIROUTE_BIFROST_KEY; @@ -200,8 +207,16 @@ test("relay route: strips stale upstream content-length before serializing JSON const res = await POST(req); assert.equal(res.status, 404); - assert.equal(res.headers.get("content-encoding"), null, "stale content-encoding must be stripped"); - assert.equal(res.headers.get("transfer-encoding"), null, "stale transfer-encoding must be stripped"); + assert.equal( + res.headers.get("content-encoding"), + null, + "stale content-encoding must be stripped" + ); + assert.equal( + res.headers.get("transfer-encoding"), + null, + "stale transfer-encoding must be stripped" + ); const raw = await res.text(); const declaredLength = res.headers.get("content-length"); diff --git a/tests/unit/apikey-connection-health-check.test.ts b/tests/unit/apikey-connection-health-check.test.ts index 87d31523423e..6b403b1e8cb5 100644 --- a/tests/unit/apikey-connection-health-check.test.ts +++ b/tests/unit/apikey-connection-health-check.test.ts @@ -24,6 +24,28 @@ const core = await import("../../src/lib/db/core.ts"); const providersDb = await import("../../src/lib/db/providers.ts"); const { checkConnection } = await import("../../src/lib/tokenHealthCheck.ts"); +// Hermetic outbound layer — installed AFTER every import, because +// open-sse/utils/proxyFetch.ts replaces globalThis.fetch at import time and would +// discard a stub installed before it. Production code under test makes best-effort +// calls (catalog polls, egress probes) that must never leave the machine. +const { installOfflineOutbound } = await import("./_helpers/offlineOutbound.ts"); +// The refresh path of the dual-auth case below used to call the REAL Google token +// endpoint and depend on it rejecting the fixture token. It now answers here with the +// exact response Google returns for a stale refresh token, so the "expired" outcome is +// a property of our classification, not of the network. +await installOfflineOutbound({ + respond: (url) => + url.href === "https://oauth2.googleapis.com/token" + ? new Response( + JSON.stringify({ + error: "invalid_grant", + error_description: "Token has been expired or revoked.", + }), + { status: 400, headers: { "content-type": "application/json" } } + ) + : undefined, +}); + async function resetStorage() { core.resetDbInstance(); for (let attempt = 0; attempt < 10; attempt++) { diff --git a/tests/unit/auto-combos-suffixes-4235.test.ts b/tests/unit/auto-combos-suffixes-4235.test.ts index e09e7e9a4692..a08230d264f2 100644 --- a/tests/unit/auto-combos-suffixes-4235.test.ts +++ b/tests/unit/auto-combos-suffixes-4235.test.ts @@ -20,6 +20,10 @@ const modePacks = await import("../../open-sse/services/autoCombo/modePacks.ts") const builtinCatalog = await import("../../open-sse/services/autoCombo/builtinCatalog.ts"); const v1ModelsCatalog = await import("../../src/app/api/v1/models/catalog.ts"); +// Hermetic outbound layer — installed AFTER every import (proxyFetch replaces +// globalThis.fetch at import time). See tests/unit/_helpers/offlineOutbound.ts. +await (await import("./_helpers/offlineOutbound.ts")).installOfflineOutbound(); + function resetStorage() { core.resetDbInstance(); fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); diff --git a/tests/unit/block-network-guard.test.ts b/tests/unit/block-network-guard.test.ts new file mode 100644 index 000000000000..e732aa71a399 --- /dev/null +++ b/tests/unit/block-network-guard.test.ts @@ -0,0 +1,249 @@ +// Regression guard for tests/_setup/blockNetwork.ts — the test-only module that makes +// "unit tests never reach a real provider" a property of the runner instead of a matter +// of discipline. Every attempt below runs in a CHILD process (the probe in +// tests/unit/fixtures/network-guard-probe.ts), because a blocked attempt in THIS process +// would, by design, fail this file. Non-loopback targets are 192.0.2.1 (RFC 5737 +// TEST-NET-1) or `.invalid` names, so a broken guard still cannot reach a provider. +import test from "node:test"; +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; + +import { + BLOCKED_ERROR_CODE, + DEFAULT_GUARD_MODE, + GUARD_MODE_ENV, + LIVE_TEST_FLAGS, + NetworkAccessBlockedError, + isLoopbackHost, + networkGuard, + resolveGuardMode, + targetFromConnectArgs, +} from "../_setup/blockNetwork.ts"; + +const PROBE = "tests/unit/fixtures/network-guard-probe.ts"; + +interface ProbeRun { + status: number | null; + stderr: string; + result: Record; +} + +function runProbe(scenario: string, extraEnv: Record = {}): ProbeRun { + const env: Record = { ...process.env }; + for (const flag of LIVE_TEST_FLAGS) delete env[flag]; + delete env[GUARD_MODE_ENV]; + const child = spawnSync( + process.execPath, + [ + "--import", + "tsx/esm", + "--import", + "./open-sse/utils/setupPolyfill.ts", + "--import", + "./tests/_setup/isolateDataDir.ts", + "--import", + "./tests/_setup/blockNetwork.ts", + PROBE, + scenario, + ], + { + cwd: process.cwd(), + encoding: "utf8", + env: { ...env, [GUARD_MODE_ENV]: "enforce", ...extraEnv }, + timeout: 300_000, + } + ); + const line = child.stdout.split("\n").find((l) => l.startsWith("PROBE_RESULT=")); + const parsed: unknown = line ? JSON.parse(line.slice("PROBE_RESULT=".length)) : {}; + const result: Record = {}; + if (typeof parsed === "object" && parsed !== null) { + for (const [key, value] of Object.entries(parsed)) { + result[key] = Array.isArray(value) ? value.map(String) : []; + } + } + return { status: child.status, stderr: child.stderr, result }; +} + +function blocked(codes: string[] | undefined): boolean { + return ( + codes !== undefined && + (codes.includes(BLOCKED_ERROR_CODE) || codes.includes(`message:${BLOCKED_ERROR_CODE}`)) + ); +} + +test("this suite itself runs under the guard", () => { + // Loaded by the runner's --import, not only by this file's import: the decision was + // taken before this module graph ran, and it matches the environment. + assert.equal(networkGuard.decision.mode, resolveGuardMode(process.env).mode); + if (!LIVE_TEST_FLAGS.some((flag) => process.env[flag] === "1")) { + assert.notEqual(networkGuard.decision.mode, "off"); + } + assert.equal(networkGuard.violations.length, 0); +}); + +test("isLoopbackHost accepts loopback only", () => { + for (const host of ["127.0.0.1", "127.8.9.10", "::1", "[::1]", "localhost", "LOCALHOST."]) { + assert.equal(isLoopbackHost(host), true, host); + } + assert.equal(isLoopbackHost("::ffff:127.0.0.1"), true); + for (const host of [ + "api.anthropic.com", + "192.0.2.1", + "10.0.0.1", + "0.0.0.0", + "::", + "128.0.0.1", + "localhost.example.com", + "::ffff:8.8.8.8", + ]) { + assert.equal(isLoopbackHost(host), false, host); + } +}); + +test("targetFromConnectArgs mirrors net's argument forms", () => { + assert.deepEqual(targetFromConnectArgs([443, "api.anthropic.com"]), { + kind: "tcp", + host: "api.anthropic.com", + port: "443", + }); + assert.deepEqual(targetFromConnectArgs([8080]), { kind: "tcp", host: "localhost", port: "8080" }); + assert.deepEqual(targetFromConnectArgs([{ host: "10.0.0.1", port: 80 }]), { + kind: "tcp", + host: "10.0.0.1", + port: "80", + }); + assert.deepEqual(targetFromConnectArgs([[{ host: "10.0.0.2", port: 81 }, null]]), { + kind: "tcp", + host: "10.0.0.2", + port: "81", + }); + assert.deepEqual(targetFromConnectArgs([{ path: "/tmp/s.sock" }]), { + kind: "local-socket", + path: "/tmp/s.sock", + }); + assert.deepEqual(targetFromConnectArgs(["\\\\.\\pipe\\x"]), { + kind: "local-socket", + path: "\\\\.\\pipe\\x", + }); +}); + +test("resolveGuardMode: explicit enforce/report, default, off only for live flags", () => { + assert.equal(resolveGuardMode({}).mode, DEFAULT_GUARD_MODE); + assert.equal(resolveGuardMode({ [GUARD_MODE_ENV]: "" }).mode, DEFAULT_GUARD_MODE); + assert.equal(resolveGuardMode({ [GUARD_MODE_ENV]: "enforce" }).mode, "enforce"); + assert.equal(resolveGuardMode({ [GUARD_MODE_ENV]: "report" }).mode, "report"); + for (const flag of LIVE_TEST_FLAGS) { + assert.equal(resolveGuardMode({ [flag]: "1" }).mode, "off", flag); + assert.equal( + resolveGuardMode({ [flag]: "true", [GUARD_MODE_ENV]: "enforce" }).mode, + "enforce", + `${flag}=true` + ); + assert.equal(resolveGuardMode({ [flag]: "1", [GUARD_MODE_ENV]: "enforce" }).mode, "off"); + } + assert.throws(() => resolveGuardMode({ [GUARD_MODE_ENV]: "off" }), /not supported/); +}); + +test("the blocked error names the host, the port and the test process", () => { + const error = new NetworkAccessBlockedError("api.anthropic.com", "443", "socket", "x.test.ts"); + assert.equal(error.code, BLOCKED_ERROR_CODE); + assert.match(error.message, /api\.anthropic\.com:443/); + assert.match(error.message, /x\.test\.ts/); +}); + +test("non-loopback attempts fail with the specific error, and a swallowed one still fails the process", () => { + const run = runProbe("non-loopback"); + for (const via of ["net", "tls", "fetch", "http", "hostname"]) { + assert.ok(blocked(run.result[via]), `${via}: ${JSON.stringify(run.result[via])}`); + } + // The probe catches every error, yet the guard forces a failing exit code. + assert.equal(run.status, 1, run.stderr); + assert.match(run.stderr, /\[network-guard\] BLOCKED host=192\.0\.2\.1 port=443 via=socket/); + assert.match(run.stderr, /host=provider\.example\.invalid port=443/); + assert.match(run.stderr, /network-guard-probe\.ts/, "stack/file must point at the caller"); +}); + +test("loopback and local sockets keep working", () => { + const run = runProbe("loopback"); + assert.deepEqual(run.result.ipv4, ["OK"]); + assert.deepEqual(run.result.localhost, ["CONNECTED"]); + assert.deepEqual(run.result.netDefaultHost, ["CONNECTED"]); + assert.deepEqual(run.result.localSocket, ["CONNECTED"]); + assert.ok( + run.result.ipv6?.[0] === "OK" || run.result.ipv6?.[0] === "IPV6_UNAVAILABLE", + JSON.stringify(run.result.ipv6) + ); + assert.equal(run.status, 0, run.stderr); + assert.doesNotMatch(run.stderr, /\[network-guard\]/); +}); + +test("a live-test flag makes the guard stand aside", () => { + // This host's own non-loopback interface: the guard treats it as network, but the + // connection never leaves the machine. + const enforced = runProbe("own-interface"); + const live = runProbe("own-interface", { RUN_LIVE_TESTS: "1" }); + if (enforced.result.ownInterface?.[0] === "NO_INTERFACE") { + assert.deepEqual(live.result.ownInterface, ["NO_INTERFACE"]); + } else { + assert.ok(blocked(enforced.result.ownInterface), JSON.stringify(enforced.result)); + assert.equal(enforced.status, 1); + assert.deepEqual(live.result.ownInterface, ["CONNECTED"], live.stderr); + } + assert.equal(live.status, 0, live.stderr); + assert.doesNotMatch(live.stderr, /\[network-guard\]/); +}); + +test("a pinned resolver is judged by the address it returns, not the hostname", () => { + const run = runProbe("pinned-lookup"); + assert.deepEqual(run.result.pinnedToLoopback, ["OK"], run.stderr); + assert.ok(blocked(run.result.pinnedToPublic), JSON.stringify(run.result)); + assert.match( + run.stderr, + /host=pinned\.example\.test->192\.0\.2\.1 .*via=socket\(pinned-lookup\)/ + ); + assert.equal(run.status, 1); +}); + +test("report mode still refuses the connection but leaves the exit code alone", () => { + const run = runProbe("non-loopback", { [GUARD_MODE_ENV]: "report" }); + assert.ok(blocked(run.result.fetch), JSON.stringify(run.result)); + assert.match(run.stderr, /\[network-guard\] REPORT host=192\.0\.2\.1 port=443/); + assert.equal(run.status, 0, run.stderr); +}); + +test("an unknown guard mode is rejected at startup", () => { + const run = runProbe("loopback", { [GUARD_MODE_ENV]: "off" }); + assert.notEqual(run.status, 0); + assert.match(run.stderr, /is not supported/); +}); + +test("the guard survives proxyFetch replacing globalThis.fetch at route import", () => { + const run = runProbe("proxy-fetch"); + assert.deepEqual(run.result.fetchReplacedByRouteImport, ["true"], run.stderr); + assert.ok(blocked(run.result.outbound), JSON.stringify(run.result)); + assert.equal(run.status, 1); + assert.match(run.stderr, /\[network-guard\] BLOCKED host=192\.0\.2\.1 port=443 via=socket/); +}); + +test("the guardedFetch bypass (its own pinned undici Agent) is blocked too", () => { + // Second known way around a fetch stub: src/shared/network/guardedFetch.ts runs vendor + // token validation on its own dispatcher, so globalThis.fetch never sees it. + const run = runProbe("guarded-fetch"); + assert.deepEqual(run.result.loopback, ["OK"], run.stderr); + assert.ok(blocked(run.result.outbound), JSON.stringify(run.result)); + assert.match( + run.stderr, + /host=guarded\.example\.test->192\.0\.2\.1 .*via=socket\(pinned-lookup\)/ + ); + assert.equal(run.status, 1); +}); + +test("the wreq-js native transport (outside net.Socket) is blocked too", () => { + const run = runProbe("wreq"); + assert.ok(blocked(run.result.wreq), JSON.stringify(run.result)); + assert.equal(run.status, 1); + assert.match( + run.stderr, + /\[network-guard\] BLOCKED host=192\.0\.2\.1 port=443 via=wreq-js\.request/ + ); +}); diff --git a/tests/unit/block-network-wiring.test.ts b/tests/unit/block-network-wiring.test.ts new file mode 100644 index 000000000000..5025d08c0f26 --- /dev/null +++ b/tests/unit/block-network-wiring.test.ts @@ -0,0 +1,69 @@ +// Every test entry point that loads tests/_setup/isolateDataDir.ts must also load +// tests/_setup/blockNetwork.ts, so no runner path (npm scripts, CI workflows, the scoped +// and merge-train scripts, Stryker) escapes the network guard. +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; + +const ISOLATE = "./tests/_setup/isolateDataDir.ts"; +const GUARD = "./tests/_setup/blockNetwork.ts"; +const ROOT = process.cwd(); + +function read(relative: string): string { + return fs.readFileSync(path.join(ROOT, relative), "utf8"); +} + +function filesUnder(relative: string, extensions: readonly string[]): string[] { + const dir = path.join(ROOT, relative); + if (!fs.existsSync(dir)) return []; + return fs + .readdirSync(dir, { recursive: true, withFileTypes: true }) + .filter((entry) => entry.isFile() && extensions.some((ext) => entry.name.endsWith(ext))) + .map((entry) => path.relative(ROOT, path.join(entry.parentPath, entry.name))); +} + +function unguardedLines(relative: string): string[] { + return read(relative) + .split("\n") + .map((line, index) => ({ line, number: index + 1 })) + .filter( + ({ line }) => line.includes(`--import ${ISOLATE}`) && !line.includes(`--import ${GUARD}`) + ) + .map(({ number }) => `${relative}:${number}`); +} + +test("every npm script that isolates DATA_DIR also loads the network guard", () => { + const pkg: unknown = JSON.parse(read("package.json")); + const scripts: unknown = + typeof pkg === "object" && pkg !== null ? Reflect.get(pkg, "scripts") : undefined; + assert.ok(typeof scripts === "object" && scripts !== null); + const offenders = Object.entries(scripts) + .filter(([, command]) => typeof command === "string" && command.includes(ISOLATE)) + .filter(([, command]) => typeof command === "string" && !command.includes(`--import ${GUARD}`)) + .map(([name]) => name); + assert.deepEqual(offenders, []); + assert.ok(Object.values(scripts).some((c) => typeof c === "string" && c.includes(GUARD))); +}); + +test("every workflow and shell entry point that isolates DATA_DIR also loads the guard", () => { + const candidates = [ + ...filesUnder(".github/workflows", [".yml", ".yaml"]), + ...filesUnder("scripts", [".sh", ".mjs", ".ts"]), + ...filesUnder("tests", [".sh"]), + ]; + const offenders = candidates.flatMap(unguardedLines); + assert.deepEqual(offenders, []); +}); + +test("Stryker's node args load the guard right after isolateDataDir", () => { + const config: unknown = JSON.parse(read("stryker.conf.json")); + const tap: unknown = + typeof config === "object" && config !== null ? Reflect.get(config, "tap") : undefined; + const nodeArgs: unknown = + typeof tap === "object" && tap !== null ? Reflect.get(tap, "nodeArgs") : undefined; + assert.ok(Array.isArray(nodeArgs)); + const isolateAt = nodeArgs.indexOf(ISOLATE); + assert.ok(isolateAt > 0); + assert.deepEqual(nodeArgs.slice(isolateAt + 1, isolateAt + 3), ["--import", GUARD]); +}); diff --git a/tests/unit/c05-v1-models-local-cli-availability.test.ts b/tests/unit/c05-v1-models-local-cli-availability.test.ts index f6c16f5cc7ac..fd5276cfdf5b 100644 --- a/tests/unit/c05-v1-models-local-cli-availability.test.ts +++ b/tests/unit/c05-v1-models-local-cli-availability.test.ts @@ -42,6 +42,10 @@ const core = await import("../../src/lib/db/core.ts"); const providersDb = await import("../../src/lib/db/providers.ts"); const v1ModelsCatalog = await import("../../src/app/api/v1/models/catalog.ts"); +// Hermetic outbound layer — installed AFTER every import (proxyFetch replaces +// globalThis.fetch at import time). See tests/unit/_helpers/offlineOutbound.ts. +await (await import("./_helpers/offlineOutbound.ts")).installOfflineOutbound(); + type CatalogRow = { id: string; object: string; diff --git a/tests/unit/catalog-auto-routing-disabled-10831.test.ts b/tests/unit/catalog-auto-routing-disabled-10831.test.ts index cd40b633c3d3..5db25a589f7c 100644 --- a/tests/unit/catalog-auto-routing-disabled-10831.test.ts +++ b/tests/unit/catalog-auto-routing-disabled-10831.test.ts @@ -23,6 +23,10 @@ const settingsDb = await import("../../src/lib/db/settings.ts"); const providersDb = await import("../../src/lib/db/providers.ts"); const v1ModelsCatalog = await import("../../src/app/api/v1/models/catalog.ts"); +// Hermetic outbound layer — installed AFTER every import (proxyFetch replaces +// globalThis.fetch at import time). See tests/unit/_helpers/offlineOutbound.ts. +await (await import("./_helpers/offlineOutbound.ts")).installOfflineOutbound(); + async function fetchCatalog(): Promise> { const res = await v1ModelsCatalog.getUnifiedModelsResponse( new Request("http://localhost/api/v1/models", { method: "GET" }) diff --git a/tests/unit/catalog-hide-auto-no-think.test.ts b/tests/unit/catalog-hide-auto-no-think.test.ts index e4f2bff11454..7799126abc6c 100644 --- a/tests/unit/catalog-hide-auto-no-think.test.ts +++ b/tests/unit/catalog-hide-auto-no-think.test.ts @@ -18,6 +18,10 @@ const settingsDb = await import("../../src/lib/db/settings.ts"); const providersDb = await import("../../src/lib/db/providers.ts"); const v1ModelsCatalog = await import("../../src/app/api/v1/models/catalog.ts"); +// Hermetic outbound layer — installed AFTER every import (proxyFetch replaces +// globalThis.fetch at import time). See tests/unit/_helpers/offlineOutbound.ts. +await (await import("./_helpers/offlineOutbound.ts")).installOfflineOutbound(); + async function fetchCatalog(): Promise> { const res = await v1ModelsCatalog.getUnifiedModelsResponse( new Request("http://localhost/api/v1/models", { method: "GET" }) diff --git a/tests/unit/catalog-order-contract.test.ts b/tests/unit/catalog-order-contract.test.ts index d18dd13d721f..af2873027475 100644 --- a/tests/unit/catalog-order-contract.test.ts +++ b/tests/unit/catalog-order-contract.test.ts @@ -26,6 +26,10 @@ const combosDb = await import("../../src/lib/db/combos.ts"); const apiKeysDb = await import("../../src/lib/db/apiKeys.ts"); const v1ModelsCatalog = await import("../../src/app/api/v1/models/catalog.ts"); +// Hermetic outbound layer — installed AFTER every import (proxyFetch replaces +// globalThis.fetch at import time). See tests/unit/_helpers/offlineOutbound.ts. +await (await import("./_helpers/offlineOutbound.ts")).installOfflineOutbound(); + async function resetStorage() { core.resetDbInstance(); apiKeysDb.resetApiKeyState(); diff --git a/tests/unit/cc-compatible-model-catalog.test.ts b/tests/unit/cc-compatible-model-catalog.test.ts index 54fc4907711f..a959c4a9a71f 100644 --- a/tests/unit/cc-compatible-model-catalog.test.ts +++ b/tests/unit/cc-compatible-model-catalog.test.ts @@ -11,6 +11,10 @@ const core = await import("../../src/lib/db/core.ts"); const providersDb = await import("../../src/lib/db/providers.ts"); const v1ModelsCatalog = await import("../../src/app/api/v1/models/catalog.ts"); +// Hermetic outbound layer — installed AFTER every import (proxyFetch replaces +// globalThis.fetch at import time). See tests/unit/_helpers/offlineOutbound.ts. +await (await import("./_helpers/offlineOutbound.ts")).installOfflineOutbound(); + async function resetStorage() { core.resetDbInstance(); fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); diff --git a/tests/unit/chat-adaptive-admission-binding.test.ts b/tests/unit/chat-adaptive-admission-binding.test.ts index 1b5f826eeadf..fc0b9f4c258e 100644 --- a/tests/unit/chat-adaptive-admission-binding.test.ts +++ b/tests/unit/chat-adaptive-admission-binding.test.ts @@ -20,6 +20,10 @@ const { reloadResourcePressureRuntime } = await import("../../open-sse/utils/res const { getCircuitBreaker, resetAllCircuitBreakers, STATE } = await import("../../src/shared/utils/circuitBreaker.ts"); +// Hermetic outbound layer — installed AFTER every import (proxyFetch replaces +// globalThis.fetch at import time). See tests/unit/_helpers/offlineOutbound.ts. +await (await import("./_helpers/offlineOutbound.ts")).installOfflineOutbound(); + const originalFetch = globalThis.fetch; const MiB = 1024 ** 2; diff --git a/tests/unit/chat-combo-live-test.test.ts b/tests/unit/chat-combo-live-test.test.ts index 8cafcbbccc95..97ae727f26e0 100644 --- a/tests/unit/chat-combo-live-test.test.ts +++ b/tests/unit/chat-combo-live-test.test.ts @@ -21,6 +21,10 @@ const { const { getCircuitBreaker, resetAllCircuitBreakers, STATE } = await import("../../src/shared/utils/circuitBreaker.ts"); +// Hermetic outbound layer — installed AFTER every import (proxyFetch replaces +// globalThis.fetch at import time). See tests/unit/_helpers/offlineOutbound.ts. +await (await import("./_helpers/offlineOutbound.ts")).installOfflineOutbound(); + const originalFetch = globalThis.fetch; async function flushBackgroundWork() { diff --git a/tests/unit/chat-completions-route-shape-gate.test.ts b/tests/unit/chat-completions-route-shape-gate.test.ts index b0f7922a7002..c1bb251e67b2 100644 --- a/tests/unit/chat-completions-route-shape-gate.test.ts +++ b/tests/unit/chat-completions-route-shape-gate.test.ts @@ -36,6 +36,10 @@ const core = await import("../../src/lib/db/core.ts"); const providersDb = await import("../../src/lib/db/providers.ts"); const chatRoute = await import("../../src/app/api/v1/chat/completions/route.ts"); +// Hermetic outbound layer — installed AFTER every import (proxyFetch replaces +// globalThis.fetch at import time). See tests/unit/_helpers/offlineOutbound.ts. +await (await import("./_helpers/offlineOutbound.ts")).installOfflineOutbound(); + const originalFetch = globalThis.fetch; async function flushBackgroundWork() { diff --git a/tests/unit/chat-context-relay.test.ts b/tests/unit/chat-context-relay.test.ts index e9c766a6996f..fe14849a24aa 100644 --- a/tests/unit/chat-context-relay.test.ts +++ b/tests/unit/chat-context-relay.test.ts @@ -9,6 +9,10 @@ const { BaseExecutor, buildRequest, combosDb, handleChat, resetStorage, waitFor, const providersDb = await import("../../src/lib/db/providers.ts"); const handoffDb = await import("../../src/lib/db/contextHandoffs.ts"); +// Hermetic outbound layer — installed AFTER every import (proxyFetch replaces +// globalThis.fetch at import time). See tests/unit/_helpers/offlineOutbound.ts. +await (await import("./_helpers/offlineOutbound.ts")).installOfflineOutbound(); + function buildResponsesResponse(text = "ok", model = "gpt-5.6-sol") { return new Response( JSON.stringify({ diff --git a/tests/unit/chat-cooldown-aware-retry.test.ts b/tests/unit/chat-cooldown-aware-retry.test.ts index 0ab22ec70338..a0739b848e23 100644 --- a/tests/unit/chat-cooldown-aware-retry.test.ts +++ b/tests/unit/chat-cooldown-aware-retry.test.ts @@ -11,6 +11,10 @@ const auth = await import("../../src/sse/services/auth.ts"); const { getProviderConnectionById } = await import("../../src/lib/db/providers.ts"); const { __setTlsFetchOverrideForTesting } = await import("../../open-sse/services/claudeTlsClient.ts"); + +// Hermetic outbound layer — installed AFTER every import (proxyFetch replaces +// globalThis.fetch at import time). See tests/unit/_helpers/offlineOutbound.ts. +await (await import("./_helpers/offlineOutbound.ts")).installOfflineOutbound(); const { BaseExecutor, buildOpenAIResponse, diff --git a/tests/unit/chat-helpers.test.ts b/tests/unit/chat-helpers.test.ts index 710bb6f21c25..8c8582e20437 100644 --- a/tests/unit/chat-helpers.test.ts +++ b/tests/unit/chat-helpers.test.ts @@ -25,6 +25,15 @@ const { getCircuitBreaker, resetAllCircuitBreakers, STATE } = // DATA_DIR must be fixed before these modules load; keep this test seam dynamic. const { setTlsClientForTest } = await import("../../open-sse/utils/proxyFetch.ts"); +// Hermetic outbound layer — installed AFTER every import, because +// open-sse/utils/proxyFetch.ts replaces globalThis.fetch at import time and would +// discard a stub installed before it. Production code under test makes best-effort +// calls (catalog polls, egress probes) that must never leave the machine. +const { installOfflineOutbound } = await import("./_helpers/offlineOutbound.ts"); +// The chat path's only outbound call here is the egress-IP probe; globalThis.fetch is +// left alone because this file asserts proxyFetch's own proxy/TLS routing. +await installOfflineOutbound({ interceptFetch: false }); + type ApiErrorJson = { error?: { message?: string; diff --git a/tests/unit/chat-managed-lease-routing.test.ts b/tests/unit/chat-managed-lease-routing.test.ts index c44ee484f2df..91f8f6cd980a 100644 --- a/tests/unit/chat-managed-lease-routing.test.ts +++ b/tests/unit/chat-managed-lease-routing.test.ts @@ -18,6 +18,10 @@ const providersDb = await import("../../src/lib/db/providers.ts"); const accountSemaphores = await import("../../open-sse/services/accountSemaphore.ts"); const { POST: handleCompletions } = await import("../../src/app/api/v1/completions/route.ts"); +// Hermetic outbound layer — installed AFTER every import (proxyFetch replaces +// globalThis.fetch at import time). See tests/unit/_helpers/offlineOutbound.ts. +await (await import("./_helpers/offlineOutbound.ts")).installOfflineOutbound(); + const OWNER = "vlo_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; const OWNER_B = "vlo_BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"; diff --git a/tests/unit/chat-messages-validation-6402.test.ts b/tests/unit/chat-messages-validation-6402.test.ts index d75c8df3f1a5..f54b5f7609fd 100644 --- a/tests/unit/chat-messages-validation-6402.test.ts +++ b/tests/unit/chat-messages-validation-6402.test.ts @@ -8,6 +8,10 @@ import { import { AGY_PUBLIC_MODELS } from "../../open-sse/config/agyModels.ts"; import { createChatPipelineHarness } from "../integration/_chatPipelineHarness.ts"; +// Hermetic outbound layer — installed AFTER every import (proxyFetch replaces +// globalThis.fetch at import time). See tests/unit/_helpers/offlineOutbound.ts. +await (await import("./_helpers/offlineOutbound.ts")).installOfflineOutbound(); + // Regression tests for #6402 — schema-invalid `messages` fields fell through // the existing empty-array guard and reached model resolution, where an // unresolvable model surfaced as a misleading 404 `model_not_found` from diff --git a/tests/unit/chat-non-string-model-6407.test.ts b/tests/unit/chat-non-string-model-6407.test.ts index 955240251907..76c694691d2a 100644 --- a/tests/unit/chat-non-string-model-6407.test.ts +++ b/tests/unit/chat-non-string-model-6407.test.ts @@ -3,6 +3,10 @@ import assert from "node:assert/strict"; import { createChatPipelineHarness } from "../integration/_chatPipelineHarness.ts"; +// Hermetic outbound layer — installed AFTER every import (proxyFetch replaces +// globalThis.fetch at import time). See tests/unit/_helpers/offlineOutbound.ts. +await (await import("./_helpers/offlineOutbound.ts")).installOfflineOutbound(); + // Regression test for #6407 — a `model` field of a non-string type // (`number`/`boolean`/`array`/`object`) crashed downstream string ops // (`.toLowerCase()`/`.split()`/`.startsWith()`) and returned HTTP 500 with an diff --git a/tests/unit/chat-rate-limit-body-lock.test.ts b/tests/unit/chat-rate-limit-body-lock.test.ts index 82b8a8ea1d93..6e217966c6c4 100644 --- a/tests/unit/chat-rate-limit-body-lock.test.ts +++ b/tests/unit/chat-rate-limit-body-lock.test.ts @@ -9,6 +9,10 @@ const { BaseExecutor, buildRequest, handleChat, resetStorage, seedConnection, se const rateLimitManager = await import("../../open-sse/services/rateLimitManager.ts"); +// Hermetic outbound layer — installed AFTER every import (proxyFetch replaces +// globalThis.fetch at import time). See tests/unit/_helpers/offlineOutbound.ts. +await (await import("./_helpers/offlineOutbound.ts")).installOfflineOutbound(); + test.beforeEach(async () => { BaseExecutor.RETRY_CONFIG.delayMs = 0; await rateLimitManager.__resetRateLimitManagerForTests(); diff --git a/tests/unit/chat-route-coverage.test.ts b/tests/unit/chat-route-coverage.test.ts index db46de811e6f..5517b571be45 100644 --- a/tests/unit/chat-route-coverage.test.ts +++ b/tests/unit/chat-route-coverage.test.ts @@ -23,6 +23,10 @@ const { clearProviderFailure } = await import("../../open-sse/services/accountFa const { getDefaultTaskModelMap, resetTaskRoutingStats, setTaskRoutingConfig } = await import("../../open-sse/services/taskAwareRouter.ts"); +// Hermetic outbound layer — installed AFTER every import (proxyFetch replaces +// globalThis.fetch at import time). See tests/unit/_helpers/offlineOutbound.ts. +await (await import("./_helpers/offlineOutbound.ts")).installOfflineOutbound(); + function buildOpenAIStreamResponse(text = "streamed from openai") { return new Response( [ diff --git a/tests/unit/chat-route-edge-cases.test.ts b/tests/unit/chat-route-edge-cases.test.ts index aea04b4b0104..540e03dbd4c9 100644 --- a/tests/unit/chat-route-edge-cases.test.ts +++ b/tests/unit/chat-route-edge-cases.test.ts @@ -23,6 +23,10 @@ const { getBackgroundDegradationConfig } = const { setCustomAliases } = await import("../../open-sse/services/modelDeprecation.ts"); const { setModelAlias } = await import("../../src/lib/db/models.ts"); +// Hermetic outbound layer — installed AFTER every import (proxyFetch replaces +// globalThis.fetch at import time). See tests/unit/_helpers/offlineOutbound.ts. +await (await import("./_helpers/offlineOutbound.ts")).installOfflineOutbound(); + test.beforeEach(async () => { BaseExecutor.RETRY_CONFIG.delayMs = 0; await resetStorage(); diff --git a/tests/unit/chat-safetynet-reqid-6097.test.ts b/tests/unit/chat-safetynet-reqid-6097.test.ts index 9989b8778fdc..94fb4240ce40 100644 --- a/tests/unit/chat-safetynet-reqid-6097.test.ts +++ b/tests/unit/chat-safetynet-reqid-6097.test.ts @@ -36,6 +36,10 @@ const combosDb = await import("../../src/lib/db/combos.ts"); const providersDb = await import("../../src/lib/db/providers.ts"); const chatRoute = await import("../../src/app/api/v1/chat/completions/route.ts"); +// Hermetic outbound layer — installed AFTER every import (proxyFetch replaces +// globalThis.fetch at import time). See tests/unit/_helpers/offlineOutbound.ts. +await (await import("./_helpers/offlineOutbound.ts")).installOfflineOutbound(); + const originalFetch = globalThis.fetch; async function flushBackgroundWork() { diff --git a/tests/unit/chatgpt-web-runtime-block.test.ts b/tests/unit/chatgpt-web-runtime-block.test.ts index e05eb1eed8f4..efd0e91bad89 100644 --- a/tests/unit/chatgpt-web-runtime-block.test.ts +++ b/tests/unit/chatgpt-web-runtime-block.test.ts @@ -19,6 +19,10 @@ const { resolveModelOrError } = await import("../../src/sse/handlers/chatHelpers const auth = await import("../../src/sse/services/auth.ts"); const chatRoute = await import("../../src/app/api/v1/chat/completions/route.ts"); +// Hermetic outbound layer — installed AFTER every import (proxyFetch replaces +// globalThis.fetch at import time). See tests/unit/_helpers/offlineOutbound.ts. +await (await import("./_helpers/offlineOutbound.ts")).installOfflineOutbound(); + const originalFetch = globalThis.fetch; function isRetiredError(error: unknown): boolean { diff --git a/tests/unit/circuit-breaker-resolved-5xx-12254.test.ts b/tests/unit/circuit-breaker-resolved-5xx-12254.test.ts index 613f1d846dca..8452386331be 100644 --- a/tests/unit/circuit-breaker-resolved-5xx-12254.test.ts +++ b/tests/unit/circuit-breaker-resolved-5xx-12254.test.ts @@ -21,6 +21,10 @@ const { BaseExecutor, buildRequest, handleChat, resetStorage, seedConnection, se const { CircuitBreaker, getCircuitBreaker, STATE } = await import("../../src/shared/utils/circuitBreaker.ts"); +// Hermetic outbound layer — installed AFTER every import (proxyFetch replaces +// globalThis.fetch at import time). See tests/unit/_helpers/offlineOutbound.ts. +await (await import("./_helpers/offlineOutbound.ts")).installOfflineOutbound(); + const originalFetch = globalThis.fetch; const originalRetryConfig = { maxAttempts: BaseExecutor.RETRY_CONFIG.maxAttempts, diff --git a/tests/unit/claude-web.test.ts b/tests/unit/claude-web.test.ts index 1ec40feb13ac..2a438578222a 100644 --- a/tests/unit/claude-web.test.ts +++ b/tests/unit/claude-web.test.ts @@ -6,12 +6,30 @@ const { getExecutor, hasSpecializedExecutor } = await import("../../open-sse/exe const { __setTlsFetchOverrideForTesting } = await import("../../open-sse/services/claudeTlsClient.ts"); +// Hermetic outbound layer — installed AFTER every import (proxyFetch replaces +// globalThis.fetch at import time). See tests/unit/_helpers/offlineOutbound.ts. +await (await import("./_helpers/offlineOutbound.ts")).installOfflineOutbound(); + // ─── Helpers ──────────────────────────────────────────────────────────────── +// `null` would hand the executor back its REAL wreq transport, and the tests that do not +// install their own override then dial claude.ai for real (4 requests per run before the +// network guard caught them). Reset to an offline 401 instead. +function offlineTlsFetch() { + return Promise.resolve({ + status: 401, + headers: new Headers({ "content-type": "application/json" }), + text: JSON.stringify({ error: { message: "offline unit test" } }), + body: null, + }); +} + function reset() { - __setTlsFetchOverrideForTesting(null); + __setTlsFetchOverrideForTesting(offlineTlsFetch); } +reset(); + // ─── Tests ────────────────────────────────────────────────────────────────── test("A: ClaudeWebExecutor is registered in executor index", () => { diff --git a/tests/unit/codex-models-catalog-refresh.test.ts b/tests/unit/codex-models-catalog-refresh.test.ts index 87c3522c762c..e698a2d43f17 100644 --- a/tests/unit/codex-models-catalog-refresh.test.ts +++ b/tests/unit/codex-models-catalog-refresh.test.ts @@ -36,6 +36,10 @@ const modelsDb = await import("../../src/lib/db/models.ts"); const providersDb = await import("../../src/lib/db/providers.ts"); const v1ModelsCatalog = await import("../../src/app/api/v1/models/catalog.ts"); +// Hermetic outbound layer — installed AFTER every import (proxyFetch replaces +// globalThis.fetch at import time). See tests/unit/_helpers/offlineOutbound.ts. +await (await import("./_helpers/offlineOutbound.ts")).installOfflineOutbound(); + type CatalogResponse = { data?: Array<{ id: string }>; }; diff --git a/tests/unit/combo-context-generic-default-10734.test.ts b/tests/unit/combo-context-generic-default-10734.test.ts index 78d932d22eeb..a9b8cc6d21cd 100644 --- a/tests/unit/combo-context-generic-default-10734.test.ts +++ b/tests/unit/combo-context-generic-default-10734.test.ts @@ -17,6 +17,10 @@ const combosDb = await import("../../src/lib/db/combos.ts"); const contextOverrides = await import("../../src/lib/db/modelContextOverrides.ts"); const catalog = await import("../../src/app/api/v1/models/catalog.ts"); +// Hermetic outbound layer — installed AFTER every import (proxyFetch replaces +// globalThis.fetch at import time). See tests/unit/_helpers/offlineOutbound.ts. +await (await import("./_helpers/offlineOutbound.ts")).installOfflineOutbound(); + test.after(() => { core.resetDbInstance(); fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); diff --git a/tests/unit/combo-provider-cooldown.test.ts b/tests/unit/combo-provider-cooldown.test.ts index 03ae9b695b63..e3c5aaf7af4e 100644 --- a/tests/unit/combo-provider-cooldown.test.ts +++ b/tests/unit/combo-provider-cooldown.test.ts @@ -21,6 +21,10 @@ const { getCircuitBreaker } = await import( "../../src/shared/utils/circuitBreaker.ts" ); +// Hermetic outbound layer — installed AFTER every import (proxyFetch replaces +// globalThis.fetch at import time). See tests/unit/_helpers/offlineOutbound.ts. +await (await import("./_helpers/offlineOutbound.ts")).installOfflineOutbound(); + test.beforeEach(async () => { await resetStorage(); }); diff --git a/tests/unit/combo-same-provider-cascade.test.ts b/tests/unit/combo-same-provider-cascade.test.ts index 0787c6fe6169..4138d857fd42 100644 --- a/tests/unit/combo-same-provider-cascade.test.ts +++ b/tests/unit/combo-same-provider-cascade.test.ts @@ -4,6 +4,10 @@ import assert from "node:assert/strict"; import { normalizeHeaders } from "../../open-sse/utils/headers.ts"; import { createChatPipelineHarness } from "../integration/_chatPipelineHarness.ts"; +// Hermetic outbound layer — installed AFTER every import (proxyFetch replaces +// globalThis.fetch at import time). See tests/unit/_helpers/offlineOutbound.ts. +await (await import("./_helpers/offlineOutbound.ts")).installOfflineOutbound(); + /** * Guard for the same-provider cascade (issue #3200): when a combo has SEVERAL * targets from the same provider and that provider fails, the combo must NOT diff --git a/tests/unit/combo-success-selected-connection-header-11810.test.ts b/tests/unit/combo-success-selected-connection-header-11810.test.ts index 0d8b039a3812..44794a4f6883 100644 --- a/tests/unit/combo-success-selected-connection-header-11810.test.ts +++ b/tests/unit/combo-success-selected-connection-header-11810.test.ts @@ -21,6 +21,10 @@ import assert from "node:assert/strict"; import { createChatPipelineHarness } from "../integration/_chatPipelineHarness.ts"; +// Hermetic outbound layer — installed AFTER every import (proxyFetch replaces +// globalThis.fetch at import time). See tests/unit/_helpers/offlineOutbound.ts. +await (await import("./_helpers/offlineOutbound.ts")).installOfflineOutbound(); + const harness = await createChatPipelineHarness("combo-success-sel-conn-11810"); const { buildOpenAIResponse, buildRequest, combosDb, handleChat, resetStorage, seedConnection } = harness; diff --git a/tests/unit/dashscope-text-models-discovery.test.ts b/tests/unit/dashscope-text-models-discovery.test.ts index bef519cc29ab..3ceb41363b8b 100644 --- a/tests/unit/dashscope-text-models-discovery.test.ts +++ b/tests/unit/dashscope-text-models-discovery.test.ts @@ -12,6 +12,10 @@ const providersDb = await import("../../src/lib/db/providers.ts"); const modelsDb = await import("../../src/lib/db/models.ts"); const modelsRoute = await import("../../src/app/api/providers/[id]/models/route.ts"); +// Hermetic outbound layer — installed AFTER every import (proxyFetch replaces +// globalThis.fetch at import time). See tests/unit/_helpers/offlineOutbound.ts. +await (await import("./_helpers/offlineOutbound.ts")).installOfflineOutbound(); + const ALIBABA_MODEL_STUDIO_MODEL_IDS = [ "qwen3.7-max", "qwen3.7-plus", diff --git a/tests/unit/deepseek-thinking-efforts.test.ts b/tests/unit/deepseek-thinking-efforts.test.ts index 899402eab87b..823994b67227 100644 --- a/tests/unit/deepseek-thinking-efforts.test.ts +++ b/tests/unit/deepseek-thinking-efforts.test.ts @@ -17,6 +17,10 @@ const v1ModelsCatalog = await import("../../src/app/api/v1/models/catalog.ts"); const { REGISTRY } = await import("../../open-sse/config/providerRegistry.ts"); const { sanitizeReasoningEffortForProvider } = await import("../../open-sse/executors/base.ts"); +// Hermetic outbound layer — installed AFTER every import (proxyFetch replaces +// globalThis.fetch at import time). See tests/unit/_helpers/offlineOutbound.ts. +await (await import("./_helpers/offlineOutbound.ts")).installOfflineOutbound(); + test.beforeEach(() => { core.resetDbInstance(); fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); diff --git a/tests/unit/docker-healthcheck-3151.test.ts b/tests/unit/docker-healthcheck-3151.test.ts index 945c516302bc..5654a8d18daa 100644 --- a/tests/unit/docker-healthcheck-3151.test.ts +++ b/tests/unit/docker-healthcheck-3151.test.ts @@ -66,11 +66,13 @@ test("probeHealth falls through to a later host when 127.0.0.1 is unreachable", const { server, port } = await startServer("127.0.0.1"); servers.push(server); - // First host resolves to nothing listening (use a host alias that will fail), - // second host is the working loopback. + // First host is a loopback address with nothing listening on `port` (the server is + // bound to 127.0.0.1 only), so the probe gets an immediate ECONNREFUSED and falls + // through to the second host. It used to be 192.0.2.1 (TEST-NET-1), which is a real + // outbound packet: slow, resolver-dependent, and refused by the network guard. const ok = await probeHealth({ port, - hosts: ["192.0.2.1", "127.0.0.1"], // 192.0.2.1 = TEST-NET-1, unroutable + hosts: ["127.0.0.2", "127.0.0.1"], timeoutMs: 300, }); assert.equal(ok, "127.0.0.1"); diff --git a/tests/unit/duckduckgo-web-executor.test.ts b/tests/unit/duckduckgo-web-executor.test.ts index f816a63663a6..2aa5ccf6f011 100644 --- a/tests/unit/duckduckgo-web-executor.test.ts +++ b/tests/unit/duckduckgo-web-executor.test.ts @@ -9,6 +9,10 @@ import { STATUS_URL, } from "../../open-sse/executors/duckduckgo-web.ts"; +// Hermetic outbound layer — installed AFTER every import (proxyFetch replaces +// globalThis.fetch at import time). See tests/unit/_helpers/offlineOutbound.ts. +await (await import("./_helpers/offlineOutbound.ts")).installOfflineOutbound(); + describe("DuckDuckGoWebExecutor", () => { describe("class instantiation", () => { it("should instantiate executor", () => { diff --git a/tests/unit/effort-tiers-loop-catalog-e2e.test.ts b/tests/unit/effort-tiers-loop-catalog-e2e.test.ts index a3818d8ca941..da5669e67d43 100644 --- a/tests/unit/effort-tiers-loop-catalog-e2e.test.ts +++ b/tests/unit/effort-tiers-loop-catalog-e2e.test.ts @@ -21,6 +21,10 @@ const v1ModelsCatalog = await import("../../src/app/api/v1/models/catalog.ts"); const { recordLearnedReasoningEffort, __test_resetLearnedReasoningEffortCaps } = await import("../../open-sse/services/learnedReasoningEffortCaps.ts"); +// Hermetic outbound layer — installed AFTER every import (proxyFetch replaces +// globalThis.fetch at import time). See tests/unit/_helpers/offlineOutbound.ts. +await (await import("./_helpers/offlineOutbound.ts")).installOfflineOutbound(); + async function resetStorage() { core.resetDbInstance(); fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); diff --git a/tests/unit/exclusive-lease-auxiliary-isolation.test.ts b/tests/unit/exclusive-lease-auxiliary-isolation.test.ts index 3404e6c3dfec..68ade129484f 100644 --- a/tests/unit/exclusive-lease-auxiliary-isolation.test.ts +++ b/tests/unit/exclusive-lease-auxiliary-isolation.test.ts @@ -28,6 +28,10 @@ const vnc = await import("../../src/lib/vncSession/service.ts"); const usageRoute = await import("../../src/app/api/usage/[connectionId]/route.ts"); const providerLimits = await import("../../src/lib/usage/providerLimits.ts"); +// Hermetic outbound layer — installed AFTER every import (proxyFetch replaces +// globalThis.fetch at import time). See tests/unit/_helpers/offlineOutbound.ts. +await (await import("./_helpers/offlineOutbound.ts")).installOfflineOutbound(); + const OWNER = "vlo_UUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUU"; async function seedConnection(name: string, provider = "openai"): Promise<{ id: string }> { diff --git a/tests/unit/executor-command-code.test.ts b/tests/unit/executor-command-code.test.ts index 56ee16b72752..3388cea78e95 100644 --- a/tests/unit/executor-command-code.test.ts +++ b/tests/unit/executor-command-code.test.ts @@ -3,6 +3,10 @@ import assert from "node:assert/strict"; const mod = await import("../../open-sse/executors/commandCode.ts"); +// Hermetic outbound layer — installed AFTER every import (proxyFetch replaces +// globalThis.fetch at import time). See tests/unit/_helpers/offlineOutbound.ts. +await (await import("./_helpers/offlineOutbound.ts")).installOfflineOutbound(); + describe("CommandCodeExecutor", () => { it("can be instantiated", () => { const executor = new mod.CommandCodeExecutor(); diff --git a/tests/unit/executor-inner-ai.test.ts b/tests/unit/executor-inner-ai.test.ts index 1d455c831e73..858dc0c31dc4 100644 --- a/tests/unit/executor-inner-ai.test.ts +++ b/tests/unit/executor-inner-ai.test.ts @@ -3,6 +3,10 @@ import assert from "node:assert/strict"; const mod = await import("../../open-sse/executors/inner-ai.ts"); +// Hermetic outbound layer — installed AFTER every import (proxyFetch replaces +// globalThis.fetch at import time). See tests/unit/_helpers/offlineOutbound.ts. +await (await import("./_helpers/offlineOutbound.ts")).installOfflineOutbound(); + describe("InnerAiExecutor", () => { it("can be instantiated", () => { const executor = new mod.InnerAiExecutor(); diff --git a/tests/unit/executor-poe-web.test.ts b/tests/unit/executor-poe-web.test.ts index 44324c5a7908..1205cd62c2f8 100644 --- a/tests/unit/executor-poe-web.test.ts +++ b/tests/unit/executor-poe-web.test.ts @@ -3,6 +3,10 @@ import assert from "node:assert/strict"; const mod = await import("../../open-sse/executors/poe-web.ts"); +// Hermetic outbound layer — installed AFTER every import (proxyFetch replaces +// globalThis.fetch at import time). See tests/unit/_helpers/offlineOutbound.ts. +await (await import("./_helpers/offlineOutbound.ts")).installOfflineOutbound(); + describe("PoeWebExecutor", () => { it("can be instantiated", () => { const executor = new mod.PoeWebExecutor(); diff --git a/tests/unit/executor-v0-vercel-web.test.ts b/tests/unit/executor-v0-vercel-web.test.ts index 934046c13c2a..e1fa4317a20c 100644 --- a/tests/unit/executor-v0-vercel-web.test.ts +++ b/tests/unit/executor-v0-vercel-web.test.ts @@ -3,6 +3,10 @@ import assert from "node:assert/strict"; const mod = await import("../../open-sse/executors/v0-vercel-web.ts"); +// Hermetic outbound layer — installed AFTER every import (proxyFetch replaces +// globalThis.fetch at import time). See tests/unit/_helpers/offlineOutbound.ts. +await (await import("./_helpers/offlineOutbound.ts")).installOfflineOutbound(); + describe("V0VercelWebExecutor", () => { it("can be instantiated", () => { const executor = new mod.V0VercelWebExecutor(); diff --git a/tests/unit/executor-venice-web.test.ts b/tests/unit/executor-venice-web.test.ts index 9ff2a7cb140e..86e9bb932516 100644 --- a/tests/unit/executor-venice-web.test.ts +++ b/tests/unit/executor-venice-web.test.ts @@ -3,6 +3,10 @@ import assert from "node:assert/strict"; const mod = await import("../../open-sse/executors/venice-web.ts"); +// Hermetic outbound layer — installed AFTER every import (proxyFetch replaces +// globalThis.fetch at import time). See tests/unit/_helpers/offlineOutbound.ts. +await (await import("./_helpers/offlineOutbound.ts")).installOfflineOutbound(); + describe("VeniceWebExecutor", () => { it("can be instantiated", () => { const executor = new mod.VeniceWebExecutor(); diff --git a/tests/unit/executor-web-cookie-sweep.test.ts b/tests/unit/executor-web-cookie-sweep.test.ts index f9e8865e59de..eba33a6f3ab5 100644 --- a/tests/unit/executor-web-cookie-sweep.test.ts +++ b/tests/unit/executor-web-cookie-sweep.test.ts @@ -29,6 +29,45 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; import { getExecutor } from "../../open-sse/executors/index.ts"; import { WEB_COOKIE_PROVIDERS, NOAUTH_PROVIDERS } from "../../src/shared/constants/providers.ts"; +import { installOfflineOutbound } from "./_helpers/offlineOutbound.ts"; + +// The header above says "no real upstream call is needed". It was not true: with fake +// credentials each executor still dialled its real provider (61 requests to grok.com, +// claude.ai, chat.deepseek.com, huggingface.co, … on every run) and depended on the +// provider answering 4xx. Both transports the executors use — globalThis.fetch and the +// wreq-js TLS client — now answer with the synthetic 401 the sweep always described, +// which is what makes the wrapper-shape assertions deterministic. +const OFFLINE_401_BODY = JSON.stringify({ + error: { message: "invalid credentials (offline test)" }, +}); +await installOfflineOutbound({ + interceptTlsClient: true, + respond: () => + new Response(OFFLINE_401_BODY, { + status: 401, + headers: { "content-type": "application/json" }, + }), +}); + +// Five executors reach their provider through a per-provider wreq-js client of their own +// (open-sse/services/*TlsClient.ts), which is neither globalThis.fetch nor the proxyFetch +// seam above. Each exposes the same test override; without them grok.com, perplexity.ai, +// claude.ai and arena.ai were still contacted for real. +const tlsClientModules = await Promise.all([ + import("../../open-sse/services/claudeTlsClient.ts"), + import("../../open-sse/services/grokTlsClient.ts"), + import("../../open-sse/services/lmarenaTlsClient.ts"), + import("../../open-sse/services/notionTlsClient.ts"), + import("../../open-sse/services/perplexityTlsClient.ts"), +]); +for (const tlsClient of tlsClientModules) { + tlsClient.__setTlsFetchOverrideForTesting(async () => ({ + status: 401, + headers: new Headers({ "content-type": "application/json" }), + text: OFFLINE_401_BODY, + body: null, + })); +} type WebCookieId = keyof typeof WEB_COOKIE_PROVIDERS; type NoauthId = keyof typeof NOAUTH_PROVIDERS; diff --git a/tests/unit/felo-web-runtime-block.test.ts b/tests/unit/felo-web-runtime-block.test.ts index f161bd2996b4..1918dc192326 100644 --- a/tests/unit/felo-web-runtime-block.test.ts +++ b/tests/unit/felo-web-runtime-block.test.ts @@ -21,6 +21,10 @@ const { getModelInfo } = await import("../../src/sse/services/model.ts"); const { resolveModelOrError } = await import("../../src/sse/handlers/chatHelpers.ts"); const chatRoute = await import("../../src/app/api/v1/chat/completions/route.ts"); +// Hermetic outbound layer — installed AFTER every import (proxyFetch replaces +// globalThis.fetch at import time). See tests/unit/_helpers/offlineOutbound.ts. +await (await import("./_helpers/offlineOutbound.ts")).installOfflineOutbound(); + const originalFetch = globalThis.fetch; const RETIRED_PROVIDER_VARIANTS = [ diff --git a/tests/unit/firefly-cookie-validation-10522.test.ts b/tests/unit/firefly-cookie-validation-10522.test.ts index edda73f14188..a50dc1f191fe 100644 --- a/tests/unit/firefly-cookie-validation-10522.test.ts +++ b/tests/unit/firefly-cookie-validation-10522.test.ts @@ -6,6 +6,10 @@ import { validateAdobeFireflyProvider } from "../../src/lib/providers/validation import { resolveProviderId } from "../../src/shared/constants/providers.ts"; import { ADOBE_FIREFLY_CREDITS_BALANCE_URL } from "../../open-sse/services/adobeFireflyClient.ts"; +// Hermetic outbound layer — installed AFTER every import (proxyFetch replaces +// globalThis.fetch at import time). See tests/unit/_helpers/offlineOutbound.ts. +await (await import("./_helpers/offlineOutbound.ts")).installOfflineOutbound(); + // A well-formed Adobe IMS *user* access token (3-segment JWT, non-guest payload, long // enough to satisfy looksLikeAdobeJwt). Only used as a routing/shape fixture — never a // real credential. diff --git a/tests/unit/fixtures/network-guard-probe.ts b/tests/unit/fixtures/network-guard-probe.ts new file mode 100644 index 000000000000..9740c80270d9 --- /dev/null +++ b/tests/unit/fixtures/network-guard-probe.ts @@ -0,0 +1,251 @@ +// Child-process probe for tests/unit/block-network-guard.test.ts. +// +// Run as `node --import tsx/esm ... --import ./tests/_setup/blockNetwork.ts +// tests/unit/fixtures/network-guard-probe.ts `; prints ONE JSON line prefixed +// with PROBE_RESULT= and lets the guard decide the exit code. Non-loopback targets are +// 192.0.2.1 (RFC 5737 TEST-NET-1, never routed to a real host), so even a broken guard +// cannot reach a provider from here. +import fs from "node:fs"; +import http from "node:http"; +import net from "node:net"; +import os from "node:os"; +import path from "node:path"; +import tls from "node:tls"; +import { createRequire } from "node:module"; + +const BLACKHOLE = "192.0.2.1"; + +function errorCodes(error: unknown): string[] { + const codes: string[] = []; + let current: unknown = error; + for (let depth = 0; depth < 5 && typeof current === "object" && current !== null; depth++) { + const code: unknown = Reflect.get(current, "code"); + if (typeof code === "string") codes.push(code); + const message: unknown = Reflect.get(current, "message"); + if (typeof message === "string" && message.includes("ERR_TEST_NETWORK_BLOCKED")) { + codes.push("message:ERR_TEST_NETWORK_BLOCKED"); + } + current = Reflect.get(current, "cause"); + } + return codes; +} + +function socketOutcome(socket: net.Socket, timeoutMs = 3000): Promise { + return new Promise((resolve) => { + const timer = setTimeout(() => { + socket.destroy(); + resolve(["TIMEOUT"]); + }, timeoutMs); + socket.once("connect", () => { + clearTimeout(timer); + socket.destroy(); + resolve(["CONNECTED"]); + }); + socket.once("error", (error) => { + clearTimeout(timer); + resolve(errorCodes(error)); + }); + }); +} + +async function settle(run: () => Promise): Promise { + try { + await run(); + return ["OK"]; + } catch (error) { + return errorCodes(error); + } +} + +async function withServer( + listen: (server: http.Server) => Promise, + use: (server: http.Server) => Promise +): Promise { + const server = http.createServer((_req, res) => res.end("ok")); + await listen(server); + try { + return await use(server); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } +} + +function listenOn(host: string): (server: http.Server) => Promise { + return (server) => + new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, host, () => resolve()); + }); +} + +function portOf(server: http.Server): number { + const address = server.address(); + return typeof address === "object" && address !== null ? address.port : 0; +} + +async function nonLoopback(): Promise> { + return { + net: await socketOutcome(net.connect(443, BLACKHOLE)), + tls: await socketOutcome(tls.connect({ host: BLACKHOLE, port: 443 })), + fetch: await settle(() => fetch(`https://${BLACKHOLE}/v1/messages`)), + http: await new Promise((resolve) => { + http + .get(`http://${BLACKHOLE}/`, (res) => { + res.resume(); + resolve(["OK"]); + }) + .on("error", (error) => resolve(errorCodes(error))); + }), + hostname: await settle(() => fetch("https://provider.example.invalid/v1/chat")), + }; +} + +async function loopback(): Promise> { + const result: Record = {}; + await withServer(listenOn("127.0.0.1"), async (server) => { + const port = portOf(server); + result.ipv4 = await settle(() => fetch(`http://127.0.0.1:${port}/`).then((r) => r.text())); + result.localhost = await socketOutcome(net.connect(port, "localhost")); + result.netDefaultHost = await socketOutcome(net.connect(port)); + }); + try { + await withServer(listenOn("::1"), async (server) => { + result.ipv6 = await settle(() => + fetch(`http://[::1]:${portOf(server)}/`).then((r) => r.text()) + ); + }); + } catch { + result.ipv6 = ["IPV6_UNAVAILABLE"]; + } + const pipePath = + process.platform === "win32" + ? `\\\\.\\pipe\\omniroute-guard-${process.pid}` + : path.join(fs.mkdtempSync(path.join(os.tmpdir(), "guard-")), "s.sock"); + const pipeServer = net.createServer((socket) => socket.end()); + await new Promise((resolve) => pipeServer.listen(pipePath, () => resolve())); + result.localSocket = await socketOutcome(net.connect(pipePath)); + await new Promise((resolve) => pipeServer.close(() => resolve())); + return result; +} + +/** A non-loopback address of THIS host: the connection never leaves the machine. */ +async function ownInterface(): Promise> { + const address = Object.values(os.networkInterfaces()) + .flat() + .find((entry) => entry !== undefined && entry.family === "IPv4" && !entry.internal)?.address; + if (!address) return { ownInterface: ["NO_INTERFACE"] }; + return withServer(listenOn("0.0.0.0"), async (server) => ({ + ownInterface: await socketOutcome(net.connect(portOf(server), address)), + })); +} + +/** + * A dispatcher that pins its own resolver (src/shared/network/guardedFetch.ts does this): + * the hostname says nothing about where the socket goes, the resolved address does. + */ +async function pinnedLookup(): Promise> { + const { Agent, request } = await import("undici"); + const pinTo = (address: string) => + new Agent({ + connect: { + // Same shape as src/shared/network/hardenedWebhookFetch.ts::pinnedLookup. + lookup: (_hostname: string, options: unknown, callback: unknown) => { + if (typeof callback !== "function") return; + const wantsAll = + typeof options === "object" && options !== null && Reflect.get(options, "all") === true; + if (wantsAll) callback(null, [{ address, family: 4 }]); + else callback(null, address, 4); + }, + }, + }); + const result: Record = {}; + await withServer(listenOn("127.0.0.1"), async (server) => { + const port = portOf(server); + result.pinnedToLoopback = await settle(async () => { + const response = await request(`http://pinned.example.test:${port}/`, { + dispatcher: pinTo("127.0.0.1"), + }); + await response.body.text(); + }); + result.pinnedToPublic = await settle(async () => { + const response = await request(`http://pinned.example.test:${port}/`, { + dispatcher: pinTo(BLACKHOLE), + }); + await response.body.text(); + }); + }); + return result; +} + +/** + * The second known fetch-stub bypass (after proxyFetch): src/shared/network/guardedFetch.ts + * validates the target, then runs the request on its OWN undici Agent with a pinned + * resolver — a stub on globalThis.fetch never sees it. Its resolver is injected here so + * the probe performs no DNS of its own. + */ +async function guardedFetchBypass(): Promise> { + const { guardedFetch } = await import("../../../src/shared/network/guardedFetch.ts"); + const resolvesTo = (address: string) => async () => [{ address, family: 4 as const }]; + const result: Record = {}; + await withServer(listenOn("127.0.0.1"), async (server) => { + result.loopback = await settle(() => + guardedFetch(`http://guarded.example.test:${portOf(server)}/`, { + lookup: resolvesTo("127.0.0.1"), + allowPrivate: true, + }).then((response) => response.text()) + ); + }); + result.outbound = await settle(() => + guardedFetch("https://guarded.example.test/v1/models", { + lookup: resolvesTo(BLACKHOLE), + }) + ); + return result; +} + +async function proxyFetchPatched(): Promise> { + const fetchBefore = globalThis.fetch; + // The incident path: a route import pulls open-sse/utils/proxyFetch.ts, which + // replaces globalThis.fetch with its own wrapper around the real fetch. + await import("../../../src/app/api/providers/claude-auth/import/route.ts"); + const replaced = globalThis.fetch !== fetchBefore; + return { + fetchReplacedByRouteImport: [String(replaced)], + outbound: await settle(() => + globalThis.fetch(`https://${BLACKHOLE}/api/claude_cli/bootstrap`, { method: "GET" }) + ), + }; +} + +async function wreq(): Promise> { + const wreqJs: unknown = createRequire(import.meta.url)("wreq-js"); + const wreqFetch: unknown = + typeof wreqJs === "object" && wreqJs !== null ? Reflect.get(wreqJs, "fetch") : undefined; + if (typeof wreqFetch !== "function") return { wreq: ["WREQ_UNAVAILABLE"] }; + return { + wreq: await settle(() => + Promise.race([ + Reflect.apply(wreqFetch, undefined, [`https://${BLACKHOLE}/`]), + new Promise((_resolve, reject) => + setTimeout(() => reject(Object.assign(new Error("timeout"), { code: "TIMEOUT" })), 5000) + ), + ]) + ), + }; +} + +const scenarios: Record Promise>> = { + "non-loopback": nonLoopback, + loopback, + "own-interface": ownInterface, + "pinned-lookup": pinnedLookup, + "proxy-fetch": proxyFetchPatched, + "guarded-fetch": guardedFetchBypass, + wreq, +}; + +const scenario = process.argv[2] ?? ""; +const run = scenarios[scenario]; +if (!run) throw new Error(`unknown scenario "${scenario}"`); +const outcome = await run(); +process.stdout.write(`PROBE_RESULT=${JSON.stringify(outcome)}\n`); diff --git a/tests/unit/ghe-copilot.test.ts b/tests/unit/ghe-copilot.test.ts index d71a1fd44491..4c659a96cb3e 100644 --- a/tests/unit/ghe-copilot.test.ts +++ b/tests/unit/ghe-copilot.test.ts @@ -5,6 +5,10 @@ import { gheCopilotProvider } from "../../open-sse/config/providers/registry/ghe import { GHE_COPILOT_TARGET } from "../../src/mitm/targets/ghe-copilot.ts"; import type { ProviderCredentials } from "../../open-sse/executors/base.ts"; +// Hermetic outbound layer — installed AFTER every import (proxyFetch replaces +// globalThis.fetch at import time). See tests/unit/_helpers/offlineOutbound.ts. +await (await import("./_helpers/offlineOutbound.ts")).installOfflineOutbound(); + test("GHE Copilot registry exposes Claude Opus 5", () => { const opus5 = gheCopilotProvider.models.find((model) => model.id === "claude-opus-5"); diff --git a/tests/unit/hidden-models-leak-v1-models-11300.test.ts b/tests/unit/hidden-models-leak-v1-models-11300.test.ts index f9ae10780e1d..2fff40bf21a5 100644 --- a/tests/unit/hidden-models-leak-v1-models-11300.test.ts +++ b/tests/unit/hidden-models-leak-v1-models-11300.test.ts @@ -34,6 +34,10 @@ const modelsDb = await import("../../src/lib/db/models.ts"); const { mergeModelCompatOverride } = await import("@/lib/db/models"); const v1ModelsCatalog = await import("../../src/app/api/v1/models/catalog.ts"); +// Hermetic outbound layer — installed AFTER every import (proxyFetch replaces +// globalThis.fetch at import time). See tests/unit/_helpers/offlineOutbound.ts. +await (await import("./_helpers/offlineOutbound.ts")).installOfflineOutbound(); + async function resetStorage() { core.resetDbInstance(); fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); diff --git a/tests/unit/image-generation-route.test.ts b/tests/unit/image-generation-route.test.ts index 11e3dbb53b2b..20e6b4d93d43 100644 --- a/tests/unit/image-generation-route.test.ts +++ b/tests/unit/image-generation-route.test.ts @@ -20,6 +20,10 @@ const providerChatRoute = const imageEditRoute = await import("../../src/app/api/v1/images/edits/route.ts"); const v1ModelsCatalog = await import("../../src/app/api/v1/models/catalog.ts"); +// Hermetic outbound layer — installed AFTER every import (proxyFetch replaces +// globalThis.fetch at import time). See tests/unit/_helpers/offlineOutbound.ts. +await (await import("./_helpers/offlineOutbound.ts")).installOfflineOutbound(); + const originalFetch = globalThis.fetch; interface ImageModelRow { diff --git a/tests/unit/image-model-not-in-chat-catalog-6457.test.ts b/tests/unit/image-model-not-in-chat-catalog-6457.test.ts index 0e02a4e6154c..11f4f300e6a6 100644 --- a/tests/unit/image-model-not-in-chat-catalog-6457.test.ts +++ b/tests/unit/image-model-not-in-chat-catalog-6457.test.ts @@ -31,6 +31,10 @@ const providersDb = await import("../../src/lib/db/providers.ts"); const modelsDb = await import("../../src/lib/db/models.ts"); const v1ModelsCatalog = await import("../../src/app/api/v1/models/catalog.ts"); +// Hermetic outbound layer — installed AFTER every import (proxyFetch replaces +// globalThis.fetch at import time). See tests/unit/_helpers/offlineOutbound.ts. +await (await import("./_helpers/offlineOutbound.ts")).installOfflineOutbound(); + async function resetStorage() { core.resetDbInstance(); fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); diff --git a/tests/unit/issue-agent-route-execution.test.ts b/tests/unit/issue-agent-route-execution.test.ts index 1d90b6d9ab40..a6e3732d1b5b 100644 --- a/tests/unit/issue-agent-route-execution.test.ts +++ b/tests/unit/issue-agent-route-execution.test.ts @@ -13,6 +13,10 @@ process.env.APP_LOG_TO_FILE = "false"; const core = await import("../../src/lib/db/core.ts"); const providersDb = await import("../../src/lib/db/providers.ts"); const issueAgentRoute = await import("../../src/app/api/issue-agent/runs/route.ts"); + +// Hermetic outbound layer — installed AFTER every import (proxyFetch replaces +// globalThis.fetch at import time). See tests/unit/_helpers/offlineOutbound.ts. +await (await import("./_helpers/offlineOutbound.ts")).installOfflineOutbound(); const originalFetch = globalThis.fetch; async function resetStorage() { diff --git a/tests/unit/model-alias-route.test.ts b/tests/unit/model-alias-route.test.ts index a82562eaaa85..0be82c725825 100644 --- a/tests/unit/model-alias-route.test.ts +++ b/tests/unit/model-alias-route.test.ts @@ -18,6 +18,10 @@ const route = await import("../../src/app/api/models/alias/route.ts"); const catalogRoute = await import("../../src/app/api/models/catalog/route.ts"); const v1Catalog = await import("../../src/app/api/v1/models/catalog.ts"); +// Hermetic outbound layer — installed AFTER every import (proxyFetch replaces +// globalThis.fetch at import time). See tests/unit/_helpers/offlineOutbound.ts. +await (await import("./_helpers/offlineOutbound.ts")).installOfflineOutbound(); + async function resetStorage() { delete process.env.INITIAL_PASSWORD; core.resetDbInstance(); diff --git a/tests/unit/model-catalog-runtime-invalidation.test.ts b/tests/unit/model-catalog-runtime-invalidation.test.ts index 4c3abe20990c..a8166bb97bca 100644 --- a/tests/unit/model-catalog-runtime-invalidation.test.ts +++ b/tests/unit/model-catalog-runtime-invalidation.test.ts @@ -22,6 +22,10 @@ const modelsDevSync = await import("../../src/lib/modelsDevSync.ts"); const v1ModelsCatalog = await import("../../src/app/api/v1/models/catalog.ts"); const auth = await import("../../src/sse/services/auth.ts"); +// Hermetic outbound layer — installed AFTER every import (proxyFetch replaces +// globalThis.fetch at import time). See tests/unit/_helpers/offlineOutbound.ts. +await (await import("./_helpers/offlineOutbound.ts")).installOfflineOutbound(); + async function resetStorage() { core.resetDbInstance(); apiKeysDb.resetApiKeyState(); diff --git a/tests/unit/model-lifecycle-integration.test.ts b/tests/unit/model-lifecycle-integration.test.ts index d62923d90ee2..0fdbe58f9293 100644 --- a/tests/unit/model-lifecycle-integration.test.ts +++ b/tests/unit/model-lifecycle-integration.test.ts @@ -16,6 +16,10 @@ const v1ModelsCatalog = await import("../../src/app/api/v1/models/catalog.ts"); const providerModelsRoute = await import("../../src/app/api/providers/[id]/models/route.ts"); const { handleChatCore } = await import("../../open-sse/handlers/chatCore.ts"); +// Hermetic outbound layer — installed AFTER every import (proxyFetch replaces +// globalThis.fetch at import time). See tests/unit/_helpers/offlineOutbound.ts. +await (await import("./_helpers/offlineOutbound.ts")).installOfflineOutbound(); + const originalFetch = globalThis.fetch; async function resetStorage() { diff --git a/tests/unit/model-test-route.test.ts b/tests/unit/model-test-route.test.ts index 5e1c1174e56b..ea1ea4a900ba 100644 --- a/tests/unit/model-test-route.test.ts +++ b/tests/unit/model-test-route.test.ts @@ -18,6 +18,10 @@ const providersDb = await import("../../src/lib/db/providers.ts"); const settingsDb = await import("../../src/lib/db/settings.ts"); const route = await import("../../src/app/api/models/test/route.ts"); +// Hermetic outbound layer — installed AFTER every import (proxyFetch replaces +// globalThis.fetch at import time). See tests/unit/_helpers/offlineOutbound.ts. +await (await import("./_helpers/offlineOutbound.ts")).installOfflineOutbound(); + const originalFetch = globalThis.fetch; async function resetStorage() { diff --git a/tests/unit/model-test-runner.test.ts b/tests/unit/model-test-runner.test.ts index c717ea0bb0c2..0a2a7d244f50 100644 --- a/tests/unit/model-test-runner.test.ts +++ b/tests/unit/model-test-runner.test.ts @@ -20,6 +20,10 @@ import { RATE_LIMIT_QUEUE_WEDGED_CODE, } from "@omniroute/open-sse/services/rateLimitManager/errors.ts"; +// Hermetic outbound layer — installed AFTER every import (proxyFetch replaces +// globalThis.fetch at import time). See tests/unit/_helpers/offlineOutbound.ts. +await (await import("./_helpers/offlineOutbound.ts")).installOfflineOutbound(); + // --------------------------------------------------------------------------- // parseRetryAfterHeader — Retry-After is either delta-seconds or an HTTP-date. // Regression guard for the rate-limit handling in runSingleModelTest (#3267). diff --git a/tests/unit/model-token-limit-catalog.test.ts b/tests/unit/model-token-limit-catalog.test.ts index 7d36565f0b3b..a87769c9143d 100644 --- a/tests/unit/model-token-limit-catalog.test.ts +++ b/tests/unit/model-token-limit-catalog.test.ts @@ -15,6 +15,10 @@ const providers = await import("../../src/lib/db/providers.ts"); const catalog = await import("../../src/app/api/v1/models/catalog.ts"); const overrideRoute = await import("../../src/app/api/model-capability-overrides/route.ts"); +// Hermetic outbound layer — installed AFTER every import (proxyFetch replaces +// globalThis.fetch at import time). See tests/unit/_helpers/offlineOutbound.ts. +await (await import("./_helpers/offlineOutbound.ts")).installOfflineOutbound(); + const TARGET = "openai/gpt-5.6"; const LIMITS = { context: 372000, input: 353400, output: 128000 }; diff --git a/tests/unit/models-catalog-auto-combos-4164.test.ts b/tests/unit/models-catalog-auto-combos-4164.test.ts index 440878c3704c..f1b03bbee033 100644 --- a/tests/unit/models-catalog-auto-combos-4164.test.ts +++ b/tests/unit/models-catalog-auto-combos-4164.test.ts @@ -23,6 +23,10 @@ const core = await import("../../src/lib/db/core.ts"); const v1ModelsCatalog = await import("../../src/app/api/v1/models/catalog.ts"); const builtinCatalog = await import("../../open-sse/services/autoCombo/builtinCatalog.ts"); +// Hermetic outbound layer — installed AFTER every import (proxyFetch replaces +// globalThis.fetch at import time). See tests/unit/_helpers/offlineOutbound.ts. +await (await import("./_helpers/offlineOutbound.ts")).installOfflineOutbound(); + function resetStorage() { core.resetDbInstance(); fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); diff --git a/tests/unit/models-catalog-block-auto-5192.test.ts b/tests/unit/models-catalog-block-auto-5192.test.ts index 256e4930d9b4..0ae13bd61885 100644 --- a/tests/unit/models-catalog-block-auto-5192.test.ts +++ b/tests/unit/models-catalog-block-auto-5192.test.ts @@ -23,6 +23,10 @@ const settingsDb = await import("../../src/lib/db/settings.ts"); const apiKeysDb = await import("../../src/lib/db/apiKeys.ts"); const v1ModelsCatalog = await import("../../src/app/api/v1/models/catalog.ts"); +// Hermetic outbound layer — installed AFTER every import (proxyFetch replaces +// globalThis.fetch at import time). See tests/unit/_helpers/offlineOutbound.ts. +await (await import("./_helpers/offlineOutbound.ts")).installOfflineOutbound(); + type ModelsResponseBody = { data: Array<{ id: string }> }; async function resetStorage() { diff --git a/tests/unit/models-catalog-combo-metadata.test.ts b/tests/unit/models-catalog-combo-metadata.test.ts index c64b46bd1a34..3d8d3f55479a 100644 --- a/tests/unit/models-catalog-combo-metadata.test.ts +++ b/tests/unit/models-catalog-combo-metadata.test.ts @@ -17,6 +17,10 @@ const capabilityOverrides = await import("../../src/lib/db/modelCapabilityOverri const overrideRoute = await import("../../src/app/api/model-capability-overrides/route.ts"); const catalog = await import("../../src/app/api/v1/models/catalog.ts"); +// Hermetic outbound layer — installed AFTER every import (proxyFetch replaces +// globalThis.fetch at import time). See tests/unit/_helpers/offlineOutbound.ts. +await (await import("./_helpers/offlineOutbound.ts")).installOfflineOutbound(); + test.after(() => { core.resetDbInstance(); fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); diff --git a/tests/unit/models-catalog-custom-node-prefix.test.ts b/tests/unit/models-catalog-custom-node-prefix.test.ts index 8fe177ed599f..3024c085eb4f 100644 --- a/tests/unit/models-catalog-custom-node-prefix.test.ts +++ b/tests/unit/models-catalog-custom-node-prefix.test.ts @@ -14,6 +14,10 @@ const providersDb = await import("../../src/lib/db/providers.ts"); const modelsCatalog = await import("../../src/app/api/v1/models/catalog.ts"); const modelsRoute = await import("../../src/app/api/v1/models/route.ts"); +// Hermetic outbound layer — installed AFTER every import (proxyFetch replaces +// globalThis.fetch at import time). See tests/unit/_helpers/offlineOutbound.ts. +await (await import("./_helpers/offlineOutbound.ts")).installOfflineOutbound(); + const NODE_ID = "openai-compatible-chat-550e8400-e29b-41d4-a716-446655440000"; const PREFIX = "infrex"; const EXPECTED_IDS = [ diff --git a/tests/unit/models-catalog-envkey-6406.test.ts b/tests/unit/models-catalog-envkey-6406.test.ts index 5127c9d5012e..a5fee4091fb7 100644 --- a/tests/unit/models-catalog-envkey-6406.test.ts +++ b/tests/unit/models-catalog-envkey-6406.test.ts @@ -19,6 +19,10 @@ const apiKeysDb = await import("../../src/lib/db/apiKeys.ts"); const providersDb = await import("../../src/lib/db/providers.ts"); const v1ModelsCatalog = await import("../../src/app/api/v1/models/catalog.ts"); +// Hermetic outbound layer — installed AFTER every import (proxyFetch replaces +// globalThis.fetch at import time). See tests/unit/_helpers/offlineOutbound.ts. +await (await import("./_helpers/offlineOutbound.ts")).installOfflineOutbound(); + // `getUnifiedModelsResponse` returns the OpenAI-shaped `{object, data}` catalog // list (see catalog.ts's `responseBody`); only `data[].id` is asserted here. interface ModelsCatalogResponseBody { diff --git a/tests/unit/models-catalog-functional-gateway-permissions.test.ts b/tests/unit/models-catalog-functional-gateway-permissions.test.ts index f7f0aad41c23..1d2f57e87cb7 100644 --- a/tests/unit/models-catalog-functional-gateway-permissions.test.ts +++ b/tests/unit/models-catalog-functional-gateway-permissions.test.ts @@ -17,6 +17,10 @@ const featureFlagsDb = await import("../../src/lib/db/featureFlags.ts"); const functionalGatewayDb = await import("../../src/lib/db/functionalGatewayMirrors.ts"); const v1ModelsCatalog = await import("../../src/app/api/v1/models/catalog.ts"); +// Hermetic outbound layer — installed AFTER every import (proxyFetch replaces +// globalThis.fetch at import time). See tests/unit/_helpers/offlineOutbound.ts. +await (await import("./_helpers/offlineOutbound.ts")).installOfflineOutbound(); + async function resetStorage() { core.resetDbInstance(); apiKeysDb.resetApiKeyState(); diff --git a/tests/unit/models-catalog-hidden-combo-leaves.test.ts b/tests/unit/models-catalog-hidden-combo-leaves.test.ts index 44e7e264d07d..a077367ba6dc 100644 --- a/tests/unit/models-catalog-hidden-combo-leaves.test.ts +++ b/tests/unit/models-catalog-hidden-combo-leaves.test.ts @@ -15,6 +15,10 @@ const modelsDb = await import("../../src/lib/db/models.ts"); const modelsDevSync = await import("../../src/lib/modelsDevSync.ts"); const v1ModelsCatalog = await import("../../src/app/api/v1/models/catalog.ts"); +// Hermetic outbound layer — installed AFTER every import (proxyFetch replaces +// globalThis.fetch at import time). See tests/unit/_helpers/offlineOutbound.ts. +await (await import("./_helpers/offlineOutbound.ts")).installOfflineOutbound(); + type CatalogModel = { id: string; context_length?: number; diff --git a/tests/unit/models-catalog-hide-paid.test.ts b/tests/unit/models-catalog-hide-paid.test.ts index 8df5176bb039..2a10ea10df1f 100644 --- a/tests/unit/models-catalog-hide-paid.test.ts +++ b/tests/unit/models-catalog-hide-paid.test.ts @@ -19,6 +19,10 @@ const settingsDb = await import("../../src/lib/db/settings.ts"); const providersDb = await import("../../src/lib/db/providers.ts"); const v1ModelsCatalog = await import("../../src/app/api/v1/models/catalog.ts"); +// Hermetic outbound layer — installed AFTER every import (proxyFetch replaces +// globalThis.fetch at import time). See tests/unit/_helpers/offlineOutbound.ts. +await (await import("./_helpers/offlineOutbound.ts")).installOfflineOutbound(); + async function fetchCatalog(): Promise> { const res = await v1ModelsCatalog.getUnifiedModelsResponse( new Request("http://localhost/api/v1/models", { method: "GET" }) diff --git a/tests/unit/models-catalog-low-noise-flag.test.ts b/tests/unit/models-catalog-low-noise-flag.test.ts index e30d9ae38ea6..a68c176afba4 100644 --- a/tests/unit/models-catalog-low-noise-flag.test.ts +++ b/tests/unit/models-catalog-low-noise-flag.test.ts @@ -17,6 +17,71 @@ const v1ModelsCatalog = await import("../../src/app/api/v1/models/catalog.ts"); const localCliAvailability = await import("../../src/app/api/v1/models/catalogLocalCliAvailability.ts"); +// Hermetic outbound layer — installed AFTER every import (proxyFetch replaces +// globalThis.fetch at import time). See tests/unit/_helpers/offlineOutbound.ts. +// +// The `rows.length > 100` non-vacuity guards below used to be satisfied with help from +// the NETWORK: building /v1/models polls the live AI Horde image catalog +// (open-sse/services/aihordeImageCatalog.ts, reached from catalog.ts), and this file +// alone sent 54 requests to aihorde.net per run. With the poll blocked the catalog drops +// to 71 rows and those guards fail — the fixture below restores the image half of the +// catalog deterministically, with the exact shape `/v2/status/models?type=image` returns +// (only workers with `count > 0` are listed). +const HORDE_IMAGE_MODELS = [ + "stable_diffusion", + "SDXL 1.0", + "AlbedoBase XL (SDXL)", + "Anything Diffusion", + "Deliberate", + "Dreamshaper", + "Realistic Vision", + "ICBINP - I Can't Believe It's Not Photography", + "Analog Madness", + "Epic Diffusion", + "majicMIX realistic", + "Counterfeit", + "MeinaMix", + "Nova Anime XL", + "Pony Diffusion XL", + "FLUX.1-schnell fp8 (Compact)", + "Juggernaut XL", + "AbsoluteReality", + "CyberRealistic", + "Photon", + "Rev Animated", + "Hassaku", + "AOM3", + "Pastel Mix", + "Cetus-Mix", + "GhostMix", + "DucHaiten", + "Yiffy", + "Zack3D", + "Midjourney Diffusion", + "Openjourney Diffusion", + "Vintedois Diffusion", + "Seek.art MEGA", + "Papercut Diffusion", + "Arcane Diffusion", + "Redshift Diffusion", + "Trinart Characters", + "Waifu Diffusion", + "Dark Sushi Mix", + "Hentai Diffusion", +].map((name) => ({ name, count: 4, queued: 0, eta: 0, performance: 1, jobs: 0 })); + +await ( + await import("./_helpers/offlineOutbound.ts") +).installOfflineOutbound({ + respond: (url) => + url.href.startsWith("https://aihorde.net/api/v2/status/models") + ? new Response(JSON.stringify(HORDE_IMAGE_MODELS), { + status: 200, + headers: { "content-type": "application/json" }, + }) + : undefined, +}); + // C-05: the `cxa/` rows asserted below belong to `codex-app-server`, a local-CLI // no-auth provider that is now listed only while its app-server is reachable. // This file tests prefix gating, not runtime detection, so declare the transport diff --git a/tests/unit/models-catalog-model-exposure-list.test.ts b/tests/unit/models-catalog-model-exposure-list.test.ts index 914829474779..a6e5d7c7f150 100644 --- a/tests/unit/models-catalog-model-exposure-list.test.ts +++ b/tests/unit/models-catalog-model-exposure-list.test.ts @@ -17,6 +17,10 @@ const settingsDb = await import("../../src/lib/db/settings.ts"); const providersDb = await import("../../src/lib/db/providers.ts"); const v1ModelsCatalog = await import("../../src/app/api/v1/models/catalog.ts"); +// Hermetic outbound layer — installed AFTER every import (proxyFetch replaces +// globalThis.fetch at import time). See tests/unit/_helpers/offlineOutbound.ts. +await (await import("./_helpers/offlineOutbound.ts")).installOfflineOutbound(); + async function fetchCatalog(): Promise> { const res = await v1ModelsCatalog.getUnifiedModelsResponse( new Request("http://localhost/api/v1/models", { method: "GET" }) diff --git a/tests/unit/models-catalog-route.test.ts b/tests/unit/models-catalog-route.test.ts index cfecc23c969d..e8fbe1e63b8f 100644 --- a/tests/unit/models-catalog-route.test.ts +++ b/tests/unit/models-catalog-route.test.ts @@ -18,6 +18,10 @@ const featureFlagsDb = await import("../../src/lib/db/featureFlags.ts"); const modelsDevSync = await import("../../src/lib/modelsDevSync.ts"); const v1ModelsCatalog = await import("../../src/app/api/v1/models/catalog.ts"); +// Hermetic outbound layer — installed AFTER every import (proxyFetch replaces +// globalThis.fetch at import time). See tests/unit/_helpers/offlineOutbound.ts. +await (await import("./_helpers/offlineOutbound.ts")).installOfflineOutbound(); + async function resetStorage() { core.resetDbInstance(); apiKeysDb.resetApiKeyState(); diff --git a/tests/unit/models-catalog-static-synced-suppression.test.ts b/tests/unit/models-catalog-static-synced-suppression.test.ts index 52dc64564e82..68d99e195d66 100644 --- a/tests/unit/models-catalog-static-synced-suppression.test.ts +++ b/tests/unit/models-catalog-static-synced-suppression.test.ts @@ -18,6 +18,10 @@ const modelsDb = await import("../../src/lib/db/models.ts"); const catalog = await import("../../src/app/api/v1/models/catalog.ts"); const { REGISTRY } = await import("@omniroute/open-sse/config/providerRegistry"); +// Hermetic outbound layer — installed AFTER every import (proxyFetch replaces +// globalThis.fetch at import time). See tests/unit/_helpers/offlineOutbound.ts. +await (await import("./_helpers/offlineOutbound.ts")).installOfflineOutbound(); + const LIVE_MODEL = "google/gemma-4-31b-it"; function getStaticModel(provider: string) { diff --git a/tests/unit/modelsDevSync-extended.test.ts b/tests/unit/modelsDevSync-extended.test.ts index 4a6edc3dc05e..3a61a84aadef 100644 --- a/tests/unit/modelsDevSync-extended.test.ts +++ b/tests/unit/modelsDevSync-extended.test.ts @@ -173,6 +173,9 @@ test.describe("modelsDevSync-extended", { concurrency: 1 }, async () => { }); test("modelsDev interval falls back to the default when env values are invalid or non-positive", async () => { + // startPeriodicSync() launches an immediate sync: without this stub the interval + // assertions below fired a REAL request to models.dev on every run. + mockFetchWith(MOCK_MODELS_DEV_DATA); process.env.MODELS_DEV_SYNC_INTERVAL = "0"; const zeroInterval = await importFresh("interval-zero"); zeroInterval.startPeriodicSync(); diff --git a/tests/unit/modelsDevSync.test.ts b/tests/unit/modelsDevSync.test.ts index 16afd7b7f448..cd6a5ecb93b9 100644 --- a/tests/unit/modelsDevSync.test.ts +++ b/tests/unit/modelsDevSync.test.ts @@ -13,6 +13,17 @@ import { mapProviderId, fetchModelsDev, } from "../../src/lib/modelsDevSync.ts"; +import { liveSkipReason } from "../helpers/liveOptIn.ts"; +import { installOfflineOutbound } from "./_helpers/offlineOutbound.ts"; + +// The four assertions at the bottom of this file describe models.dev's CONTENT (100+ +// providers, 4000+ models): they can only hold against the real service, and they used +// to download its whole catalog on every unit run. They are now what they always were — +// live tests — behind the existing RUN_LIVE_TESTS flag, and the fetch/cache contract of +// fetchModelsDev() is covered offline just below (plus the error paths in +// tests/unit/modelsDevSync-extended.test.ts). +const LIVE_SKIP = liveSkipReason({ requiredEnv: [] }); +const MODELS_DEV_API_URL = "https://models.dev/api.json"; // ─── Mock data ─────────────────────────────────────────── @@ -427,7 +438,37 @@ describe("modelsDevSync — mapProviderId", () => { }); }); -describe("modelsDevSync — fetchModelsDev (live API)", () => { +describe( + "modelsDevSync — fetchModelsDev (offline contract)", + { skip: LIVE_SKIP ? false : "live mode: the live-API suite below owns fetchModelsDev" }, + () => { + it("requests models.dev once and caches the parsed catalog", async () => { + // Installed here, not at import time: the stub must be the live globalThis.fetch. + const offline = await installOfflineOutbound({ + respond: (url) => + url.href === MODELS_DEV_API_URL + ? new Response(JSON.stringify(MOCK_MODELS_DEV_DATA), { + status: 200, + headers: { "content-type": "application/json" }, + }) + : undefined, + }); + try { + const first = await fetchModelsDev(); + const second = await fetchModelsDev(); + + assert.deepEqual(Object.keys(first).sort(), Object.keys(MOCK_MODELS_DEV_DATA).sort()); + assert.ok(first.openai?.models["gpt-4o"], "the parsed catalog keeps provider/model shape"); + assert.strictEqual(first, second, "the second call must come from the cache"); + assert.deepEqual(offline.attempts, [MODELS_DEV_API_URL], "exactly one upstream request"); + } finally { + offline.restore(); + } + }); + } +); + +describe("modelsDevSync — fetchModelsDev (live API)", { skip: LIVE_SKIP }, () => { it("fetches data from models.dev API", async () => { const data = await fetchModelsDev(); assert.ok(typeof data === "object", "data should be an object"); diff --git a/tests/unit/noauth-imported-models-3200.test.ts b/tests/unit/noauth-imported-models-3200.test.ts index 83ed6e9766c8..10f0e37c8870 100644 --- a/tests/unit/noauth-imported-models-3200.test.ts +++ b/tests/unit/noauth-imported-models-3200.test.ts @@ -25,6 +25,10 @@ const apiKeysDb = await import("../../src/lib/db/apiKeys.ts"); const settingsDb = await import("../../src/lib/db/settings.ts"); const v1ModelsCatalog = await import("../../src/app/api/v1/models/catalog.ts"); +// Hermetic outbound layer — installed AFTER every import (proxyFetch replaces +// globalThis.fetch at import time). See tests/unit/_helpers/offlineOutbound.ts. +await (await import("./_helpers/offlineOutbound.ts")).installOfflineOutbound(); + async function resetStorage() { core.resetDbInstance(); apiKeysDb.resetApiKeyState(); diff --git a/tests/unit/opencode-noauth-models-route.test.ts b/tests/unit/opencode-noauth-models-route.test.ts index 2b4abeaee82b..1a7485d55935 100644 --- a/tests/unit/opencode-noauth-models-route.test.ts +++ b/tests/unit/opencode-noauth-models-route.test.ts @@ -11,6 +11,10 @@ const core = await import("../../src/lib/db/core.ts"); const providersDb = await import("../../src/lib/db/providers.ts"); const modelsRoute = await import("../../src/app/api/providers/[id]/models/route.ts"); +// Hermetic outbound layer — installed AFTER every import (proxyFetch replaces +// globalThis.fetch at import time). See tests/unit/_helpers/offlineOutbound.ts. +await (await import("./_helpers/offlineOutbound.ts")).installOfflineOutbound(); + test.after(() => { core.resetDbInstance(); fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); diff --git a/tests/unit/openrouter-vision-sync-4264.test.ts b/tests/unit/openrouter-vision-sync-4264.test.ts index 3b0117a1aa89..e1a642754b7e 100644 --- a/tests/unit/openrouter-vision-sync-4264.test.ts +++ b/tests/unit/openrouter-vision-sync-4264.test.ts @@ -21,6 +21,10 @@ const apiKeysDb = await import("../../src/lib/db/apiKeys.ts"); const modelDiscovery = await import("../../src/lib/providerModels/modelDiscovery.ts"); const v1ModelsCatalog = await import("../../src/app/api/v1/models/catalog.ts"); +// Hermetic outbound layer — installed AFTER every import (proxyFetch replaces +// globalThis.fetch at import time). See tests/unit/_helpers/offlineOutbound.ts. +await (await import("./_helpers/offlineOutbound.ts")).installOfflineOutbound(); + async function resetStorage() { core.resetDbInstance(); apiKeysDb.resetApiKeyState(); diff --git a/tests/unit/probe-gate-autodisable.test.ts b/tests/unit/probe-gate-autodisable.test.ts index 4eb8b4c106f2..21504266022e 100644 --- a/tests/unit/probe-gate-autodisable.test.ts +++ b/tests/unit/probe-gate-autodisable.test.ts @@ -18,6 +18,10 @@ const { resetAllCircuitBreakers, getCircuitBreaker } = await import("../../src/shared/utils/circuitBreaker.ts"); const { invalidateDbCache } = await import("../../src/lib/db/readCache.ts"); +// Hermetic outbound layer — installed AFTER every import (proxyFetch replaces +// globalThis.fetch at import time). See tests/unit/_helpers/offlineOutbound.ts. +await (await import("./_helpers/offlineOutbound.ts")).installOfflineOutbound(); + const originalFetch = globalThis.fetch; test.beforeEach(() => { diff --git a/tests/unit/probe-testall-isolation.test.ts b/tests/unit/probe-testall-isolation.test.ts index 201fafc9098a..5bcbe9457e34 100644 --- a/tests/unit/probe-testall-isolation.test.ts +++ b/tests/unit/probe-testall-isolation.test.ts @@ -15,6 +15,10 @@ const { invalidateDbCache } = await import("../../src/lib/db/readCache.ts"); const { refreshConnectionRateLimits, enableRateLimitProtection } = await import("@omniroute/open-sse/services/rateLimitManager.ts"); +// Hermetic outbound layer — installed AFTER every import (proxyFetch replaces +// globalThis.fetch at import time). See tests/unit/_helpers/offlineOutbound.ts. +await (await import("./_helpers/offlineOutbound.ts")).installOfflineOutbound(); + const originalFetch = globalThis.fetch; // A test-all 403 can also open the provider circuit breaker and stale the diff --git a/tests/unit/provider-limits-apikey-proxy-context.test.ts b/tests/unit/provider-limits-apikey-proxy-context.test.ts index 95017cecda08..6174be57f3ec 100644 --- a/tests/unit/provider-limits-apikey-proxy-context.test.ts +++ b/tests/unit/provider-limits-apikey-proxy-context.test.ts @@ -2,6 +2,12 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; import { proxyConfigToUrl } from "../../open-sse/utils/proxyDispatcher.ts"; import { runWithProxyContext, resolveProxyForRequest } from "../../open-sse/utils/proxyFetch.ts"; +import { reserveDeadLoopbackPort } from "./_helpers/deadLoopback.ts"; + +// An unreachable proxy that stays on this machine: a loopback port with nothing +// listening, instead of the made-up public host this test used to rely on +// (p.example.com — a real DNS lookup and outbound attempt, refused by the network guard). +const DEAD_PROXY = { host: "127.0.0.1", port: await reserveDeadLoopbackPort() }; // L3 contract: the API-key usage/quota branch in src/lib/usage/providerLimits.ts must // resolve the connection's proxy and run getUsageForProvider inside runWithProxyContext, @@ -11,13 +17,13 @@ describe("API-key usage egresses through proxy context", () => { it("resolves an api-key connection proxy config to a usable URL", () => { // Deterministic, no network dependency: this is the core mechanism the L3 fix uses // when wrapping getUsageForProvider in runWithProxyContext(apiKeyProxy?.proxy ?? null). - const url = proxyConfigToUrl({ type: "http", host: "p.example.com", port: 8080 }); + const url = proxyConfigToUrl({ type: "http", host: DEAD_PROXY.host, port: DEAD_PROXY.port }); assert.ok(url, `expected proxy url, got ${url}`); // Parse and compare host/port exactly (substring matching on a URL is unsafe — CodeQL // js/incomplete-url-substring-sanitization — and a weaker assertion than equality). const parsed = new URL(url); - assert.equal(parsed.hostname, "p.example.com"); - assert.equal(parsed.port, "8080"); + assert.equal(parsed.hostname, DEAD_PROXY.host); + assert.equal(parsed.port, String(DEAD_PROXY.port)); }); it("a null proxy config (no connection proxy) resolves to no proxy", () => { @@ -26,15 +32,15 @@ describe("API-key usage egresses through proxy context", () => { it("context proxy is visible to fetch resolution inside runWithProxyContext", async () => { // runWithProxyContext fast-fails with PROXY_UNREACHABLE before invoking the callback - // when the proxy is not reachable. p.example.com:8080 is unreachable in CI, so this - // assertion guards against the (unlikely) case the host is reachable. The deterministic - // proof lives in the proxyConfigToUrl tests above. + // when the proxy is not reachable. DEAD_PROXY is a closed loopback port: unreachable + // deterministically and without any outbound traffic. The deterministic proof of the + // mechanism lives in the proxyConfigToUrl tests above. try { - await runWithProxyContext({ type: "http", host: "p.example.com", port: 8080 }, async () => { + await runWithProxyContext({ type: "http", ...DEAD_PROXY }, async () => { const r = resolveProxyForRequest("https://api.example.com"); assert.equal(r.source, "context"); assert.ok(r.proxyUrl, "expected a proxy url from context"); - assert.equal(new URL(r.proxyUrl).hostname, "p.example.com"); + assert.equal(new URL(r.proxyUrl).hostname, DEAD_PROXY.host); }); } catch (err) { // Expected when the proxy host is unreachable; the mechanism is still proven by the diff --git a/tests/unit/provider-models-v1-route.test.ts b/tests/unit/provider-models-v1-route.test.ts index af92b95b4f05..89ccb7cea7d0 100644 --- a/tests/unit/provider-models-v1-route.test.ts +++ b/tests/unit/provider-models-v1-route.test.ts @@ -18,6 +18,10 @@ const core = await import("../../src/lib/db/core.ts"); const serviceModelsDb = await import("../../src/lib/db/serviceModels.ts"); const routeModule = await import("../../src/app/api/v1/providers/[provider]/models/route.ts"); +// Hermetic outbound layer — installed AFTER every import (proxyFetch replaces +// globalThis.fetch at import time). See tests/unit/_helpers/offlineOutbound.ts. +await (await import("./_helpers/offlineOutbound.ts")).installOfflineOutbound(); + function makeRequest(provider: string) { return new Request(`http://localhost/api/v1/providers/${encodeURIComponent(provider)}/models`); } diff --git a/tests/unit/provider-scoped-models-route.test.ts b/tests/unit/provider-scoped-models-route.test.ts index cc90dc3a3929..fc070f443654 100644 --- a/tests/unit/provider-scoped-models-route.test.ts +++ b/tests/unit/provider-scoped-models-route.test.ts @@ -14,6 +14,10 @@ const serviceModelsDb = await import("../../src/lib/db/serviceModels.ts"); const providerModelsRoute = await import("../../src/app/api/v1/providers/[provider]/models/route.ts"); +// Hermetic outbound layer — installed AFTER every import (proxyFetch replaces +// globalThis.fetch at import time). See tests/unit/_helpers/offlineOutbound.ts. +await (await import("./_helpers/offlineOutbound.ts")).installOfflineOutbound(); + interface SeedConnectionOverrides { authType?: string; name?: string; diff --git a/tests/unit/providers-route-managed-catalog.test.ts b/tests/unit/providers-route-managed-catalog.test.ts index 02e8012d203b..0bbcbab3c90c 100644 --- a/tests/unit/providers-route-managed-catalog.test.ts +++ b/tests/unit/providers-route-managed-catalog.test.ts @@ -14,6 +14,25 @@ const core = await import("../../src/lib/db/core.ts"); const providersRoute = await import("../../src/app/api/providers/route.ts"); const modelsDb = await import("../../src/lib/db/models.ts"); +// Hermetic outbound layer — installed AFTER every import (proxyFetch replaces +// globalThis.fetch at import time). See tests/unit/_helpers/offlineOutbound.ts. +await (await import("./_helpers/offlineOutbound.ts")).installOfflineOutbound(); + +// Two managed-catalog providers reach their upstream through a per-provider wreq-js client +// (a native binding, invisible to a fetch stub): without these overrides the suite dialled +// grok.com and www.perplexity.ai for real. +for (const tlsClient of await Promise.all([ + import("../../open-sse/services/grokTlsClient.ts"), + import("../../open-sse/services/perplexityTlsClient.ts"), +])) { + tlsClient.__setTlsFetchOverrideForTesting(async () => ({ + status: 401, + headers: new Headers({ "content-type": "application/json" }), + text: JSON.stringify({ error: { message: "offline unit test" } }), + body: null, + })); +} + function resetDb() { core.resetDbInstance(); fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); diff --git a/tests/unit/quota-exclusive-catalog-short-circuit.test.ts b/tests/unit/quota-exclusive-catalog-short-circuit.test.ts index db0c37d8a49a..45acd7dac364 100644 --- a/tests/unit/quota-exclusive-catalog-short-circuit.test.ts +++ b/tests/unit/quota-exclusive-catalog-short-circuit.test.ts @@ -42,6 +42,10 @@ const v1ModelsCatalog = await import("../../src/app/api/v1/models/catalog.ts"); const { syncQuotaCombos } = await import("../../src/lib/quota/quotaCombos.ts"); const { isQuotaModelName } = await import("../../src/lib/quota/quotaModelNaming.ts"); +// Hermetic outbound layer — installed AFTER every import (proxyFetch replaces +// globalThis.fetch at import time). See tests/unit/_helpers/offlineOutbound.ts. +await (await import("./_helpers/offlineOutbound.ts")).installOfflineOutbound(); + const originalFetch = globalThis.fetch; let openRouterCalls = 0; diff --git a/tests/unit/qwen-web-runtime-block.test.ts b/tests/unit/qwen-web-runtime-block.test.ts index 8cb2e3cc4525..eafd2a9a2660 100644 --- a/tests/unit/qwen-web-runtime-block.test.ts +++ b/tests/unit/qwen-web-runtime-block.test.ts @@ -18,6 +18,10 @@ const { getModelInfo } = await import("../../src/sse/services/model.ts"); const { resolveModelOrError } = await import("../../src/sse/handlers/chatHelpers.ts"); const chatRoute = await import("../../src/app/api/v1/chat/completions/route.ts"); +// Hermetic outbound layer — installed AFTER every import (proxyFetch replaces +// globalThis.fetch at import time). See tests/unit/_helpers/offlineOutbound.ts. +await (await import("./_helpers/offlineOutbound.ts")).installOfflineOutbound(); + const originalFetch = globalThis.fetch; const RETIRED_PROVIDER_VARIANTS = [ diff --git a/tests/unit/radar-api-routes.test.ts b/tests/unit/radar-api-routes.test.ts index 92200d9a776e..e4a0ffaef5fd 100644 --- a/tests/unit/radar-api-routes.test.ts +++ b/tests/unit/radar-api-routes.test.ts @@ -39,6 +39,10 @@ const core = await import("../../src/lib/db/core.ts"); const radarDb = await import("../../src/lib/db/radar.ts"); const featureFlags = await import("../../src/shared/utils/featureFlags.ts"); +// Hermetic outbound layer — installed AFTER every import (proxyFetch replaces +// globalThis.fetch at import time). See tests/unit/_helpers/offlineOutbound.ts. +await (await import("./_helpers/offlineOutbound.ts")).installOfflineOutbound(); + // We need to test the route handlers. Since Next.js route handlers are just // exported functions, we can import and call them directly with mock Request // objects. However, the routes import from @/lib/radar which reads the DB, diff --git a/tests/unit/repro-6142-devin-cloud-agent-unwired.test.ts b/tests/unit/repro-6142-devin-cloud-agent-unwired.test.ts index 015e7e13009c..e8a4bceee9c5 100644 --- a/tests/unit/repro-6142-devin-cloud-agent-unwired.test.ts +++ b/tests/unit/repro-6142-devin-cloud-agent-unwired.test.ts @@ -5,6 +5,10 @@ import assert from "node:assert/strict"; import { validateProviderApiKey } from "../../src/lib/providers/validation"; import { getStaticModelsForProvider } from "../../src/lib/providers/staticModels"; +// Hermetic outbound layer — installed AFTER every import (proxyFetch replaces +// globalThis.fetch at import time). See tests/unit/_helpers/offlineOutbound.ts. +await (await import("./_helpers/offlineOutbound.ts")).installOfflineOutbound(); + test("#6142 (fixed): saving a Devin cloud-agent API key should not be 'unsupported' by the generic provider flow (parity with jules)", async () => { const result = await validateProviderApiKey({ provider: "devin", diff --git a/tests/unit/rerank-proxy-pinning-7350.test.ts b/tests/unit/rerank-proxy-pinning-7350.test.ts index 3852814b75de..7826b70bba7f 100644 --- a/tests/unit/rerank-proxy-pinning-7350.test.ts +++ b/tests/unit/rerank-proxy-pinning-7350.test.ts @@ -25,6 +25,13 @@ const providersDb = await import("../../src/lib/db/providers.ts"); const proxiesDb = await import("../../src/lib/db/proxies.ts"); const proxyFetch = await import("../../open-sse/utils/proxyFetch.ts"); const { handleRerank } = await import("../../open-sse/handlers/rerank.ts"); +const { reserveDeadLoopbackPort } = await import("./_helpers/deadLoopback.ts"); + +// A pinned-but-dead proxy that never leaves this machine: a loopback port with nothing +// listening. It used to be the made-up host `rerank-egress.local`, whose "unreachable" +// depended on the resolver answering NXDOMAIN — a real outbound attempt, and one the +// network guard (tests/_setup/blockNetwork.ts) refuses. +const DEAD_PROXY = { host: "127.0.0.1", port: await reserveDeadLoopbackPort() }; const originalFetch = globalThis.fetch; @@ -57,8 +64,8 @@ test("#7350 handleRerank routes the upstream call through the connection's pinne const proxy = await proxiesDb.createProxy({ name: "Rerank Egress Proxy", type: "http", - host: "rerank-egress.local", - port: 8080, + host: DEAD_PROXY.host, + port: DEAD_PROXY.port, }); await proxiesDb.assignProxyToScope("account", (conn as { id: string }).id, proxy.id); @@ -70,7 +77,7 @@ test("#7350 handleRerank routes the upstream call through the connection's pinne // #9100: the T14 reachability probe is now NON-BLOCKING — dispatch is optimistic, so // fn() (and hence this stub) runs immediately. To still observe the dead-proxy // failure the stub must stay pending long enough for the probe (fast NXDOMAIN / - // ECONNREFUSED on rerank-egress.local) to resolve unreachable and abort the + // ECONNREFUSED on the dead loopback port) to resolve unreachable and abort the // in-flight request with PROXY_UNREACHABLE instead of a direct 200. const seen: { proxyUrl: string | null | undefined }[] = []; let releaseStub: () => void = () => {}; diff --git a/tests/unit/route-edge-coverage.test.ts b/tests/unit/route-edge-coverage.test.ts index c03e2b0b8f9a..57bbb1f69660 100644 --- a/tests/unit/route-edge-coverage.test.ts +++ b/tests/unit/route-edge-coverage.test.ts @@ -30,6 +30,10 @@ const rerankRoute = await import("../../src/app/api/v1/rerank/route.ts"); const searchRoute = await import("../../src/app/api/v1/search/route.ts"); const videosRoute = await import("../../src/app/api/v1/videos/generations/route.ts"); +// Hermetic outbound layer — installed AFTER every import (proxyFetch replaces +// globalThis.fetch at import time). See tests/unit/_helpers/offlineOutbound.ts. +await (await import("./_helpers/offlineOutbound.ts")).installOfflineOutbound(); + const MACHINE_ID = "1234567890abcdef"; async function resetStorage() { diff --git a/tests/unit/serial/9147-catalog-eventloop-yield.test.ts b/tests/unit/serial/9147-catalog-eventloop-yield.test.ts index ec22ede9fb62..7f26efc8c4ce 100644 --- a/tests/unit/serial/9147-catalog-eventloop-yield.test.ts +++ b/tests/unit/serial/9147-catalog-eventloop-yield.test.ts @@ -29,6 +29,10 @@ const core = await import("../../../src/lib/db/core.ts"); const apiKeysDb = await import("../../../src/lib/db/apiKeys.ts"); const v1ModelsCatalog = await import("../../../src/app/api/v1/models/catalog.ts"); +// Hermetic outbound layer — installed AFTER every import (proxyFetch replaces +// globalThis.fetch at import time). See tests/unit/_helpers/offlineOutbound.ts. +await (await import("../_helpers/offlineOutbound.ts")).installOfflineOutbound(); + const CONNECTION_COUNT = 60; const MODELS_PER_CONNECTION = 12; // ~720 synced models total diff --git a/tests/unit/services/end-to-end-shape.test.ts b/tests/unit/services/end-to-end-shape.test.ts index f03f7ef1c454..17a753bae9a6 100644 --- a/tests/unit/services/end-to-end-shape.test.ts +++ b/tests/unit/services/end-to-end-shape.test.ts @@ -31,6 +31,10 @@ process.env.NINEROUTER_PORT = "20130"; const core = await import("../../../src/lib/db/core.ts"); const { upsertVersionManagerTool } = await import("../../../src/lib/db/versionManager.ts"); +// Hermetic outbound layer — installed AFTER every import (proxyFetch replaces +// globalThis.fetch at import time). See tests/unit/_helpers/offlineOutbound.ts. +await (await import("../_helpers/offlineOutbound.ts")).installOfflineOutbound(); + // Seed both services as "stopped" (installed) so lifecycle routes proceed. await upsertVersionManagerTool({ tool: "9router", status: "stopped" }); await upsertVersionManagerTool({ tool: "cliproxy", status: "stopped" }); diff --git a/tests/unit/specialty-model-catalog-routes.test.ts b/tests/unit/specialty-model-catalog-routes.test.ts index db1bd6421100..adeec49d7f93 100644 --- a/tests/unit/specialty-model-catalog-routes.test.ts +++ b/tests/unit/specialty-model-catalog-routes.test.ts @@ -16,6 +16,10 @@ const videoRoute = await import("../../src/app/api/v1/videos/generations/route.t const musicRoute = await import("../../src/app/api/v1/music/generations/route.ts"); const v1ModelsCatalog = await import("../../src/app/api/v1/models/catalog.ts"); +// Hermetic outbound layer — installed AFTER every import (proxyFetch replaces +// globalThis.fetch at import time). See tests/unit/_helpers/offlineOutbound.ts. +await (await import("./_helpers/offlineOutbound.ts")).installOfflineOutbound(); + async function resetStorage() { core.resetDbInstance(); fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); diff --git a/tests/unit/specialty-model-hidden-openrouter-9293.test.ts b/tests/unit/specialty-model-hidden-openrouter-9293.test.ts index fc4d8ff4ff71..4d64a04c8848 100644 --- a/tests/unit/specialty-model-hidden-openrouter-9293.test.ts +++ b/tests/unit/specialty-model-hidden-openrouter-9293.test.ts @@ -29,6 +29,10 @@ const apiKeysDb = await import("../../src/lib/db/apiKeys.ts"); const { mergeModelCompatOverride, getModelIsHidden } = await import("@/lib/db/models"); const v1ModelsCatalog = await import("../../src/app/api/v1/models/catalog.ts"); +// Hermetic outbound layer — installed AFTER every import (proxyFetch replaces +// globalThis.fetch at import time). See tests/unit/_helpers/offlineOutbound.ts. +await (await import("./_helpers/offlineOutbound.ts")).installOfflineOutbound(); + async function resetStorage() { core.resetDbInstance(); apiKeysDb.resetApiKeyState(); diff --git a/tests/unit/sync-reasoning-supported-efforts-7694.test.ts b/tests/unit/sync-reasoning-supported-efforts-7694.test.ts index 9f3514b350bb..33b596f4ba98 100644 --- a/tests/unit/sync-reasoning-supported-efforts-7694.test.ts +++ b/tests/unit/sync-reasoning-supported-efforts-7694.test.ts @@ -35,6 +35,10 @@ const { const { applyDefaultReasoningEffort } = await import("../../open-sse/services/defaultReasoningEffort.ts"); +// Hermetic outbound layer — installed AFTER every import (proxyFetch replaces +// globalThis.fetch at import time). See tests/unit/_helpers/offlineOutbound.ts. +await (await import("./_helpers/offlineOutbound.ts")).installOfflineOutbound(); + async function resetStorage() { core.resetDbInstance(); fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); diff --git a/tests/unit/v1-models-auth-leak-9320.test.ts b/tests/unit/v1-models-auth-leak-9320.test.ts index 0eb5875253e8..7af80e828bb8 100644 --- a/tests/unit/v1-models-auth-leak-9320.test.ts +++ b/tests/unit/v1-models-auth-leak-9320.test.ts @@ -24,6 +24,10 @@ const apiKeysDb = await import("../../src/lib/db/apiKeys.ts"); const settingsModule = await import("../../src/lib/db/settings.ts"); const v1ModelsCatalog = await import("../../src/app/api/v1/models/catalog.ts"); +// Hermetic outbound layer — installed AFTER every import (proxyFetch replaces +// globalThis.fetch at import time). See tests/unit/_helpers/offlineOutbound.ts. +await (await import("./_helpers/offlineOutbound.ts")).installOfflineOutbound(); + async function resetStorage() { core.resetDbInstance(); apiKeysDb.resetApiKeyState(); diff --git a/tests/unit/v1-models-catalog-ttl.test.ts b/tests/unit/v1-models-catalog-ttl.test.ts index 279e824f17a3..4711fb977180 100644 --- a/tests/unit/v1-models-catalog-ttl.test.ts +++ b/tests/unit/v1-models-catalog-ttl.test.ts @@ -36,6 +36,10 @@ const apiKeysDb = await import("../../src/lib/db/apiKeys.ts"); const v1ModelsCatalog = await import("../../src/app/api/v1/models/catalog.ts"); const catalogCache = await import("../../src/app/api/v1/models/catalogCache.ts"); +// Hermetic outbound layer — installed AFTER every import (proxyFetch replaces +// globalThis.fetch at import time). See tests/unit/_helpers/offlineOutbound.ts. +await (await import("./_helpers/offlineOutbound.ts")).installOfflineOutbound(); + /** Comfortably past the old 1500 ms TTL, and a realistic client poll gap. */ const GAP_MS = 10_000; diff --git a/tests/unit/v1-models-concurrent-6408.test.ts b/tests/unit/v1-models-concurrent-6408.test.ts index d1c0b8fd9d5b..5a0d06acd8d2 100644 --- a/tests/unit/v1-models-concurrent-6408.test.ts +++ b/tests/unit/v1-models-concurrent-6408.test.ts @@ -26,6 +26,10 @@ const core = await import("../../src/lib/db/core.ts"); const apiKeysDb = await import("../../src/lib/db/apiKeys.ts"); const v1ModelsCatalog = await import("../../src/app/api/v1/models/catalog.ts"); +// Hermetic outbound layer — installed AFTER every import (proxyFetch replaces +// globalThis.fetch at import time). See tests/unit/_helpers/offlineOutbound.ts. +await (await import("./_helpers/offlineOutbound.ts")).installOfflineOutbound(); + async function resetStorage() { core.resetDbInstance(); apiKeysDb.resetApiKeyState(); diff --git a/tests/unit/v1-models-discovery-conformance.test.ts b/tests/unit/v1-models-discovery-conformance.test.ts index bd6e0cea536d..8ba3790643e4 100644 --- a/tests/unit/v1-models-discovery-conformance.test.ts +++ b/tests/unit/v1-models-discovery-conformance.test.ts @@ -28,6 +28,10 @@ const apiKeysDb = await import("../../src/lib/db/apiKeys.ts"); const readCache = await import("../../src/lib/db/readCache.ts"); const v1ModelsCatalog = await import("../../src/app/api/v1/models/catalog.ts"); +// Hermetic outbound layer — installed AFTER every import (proxyFetch replaces +// globalThis.fetch at import time). See tests/unit/_helpers/offlineOutbound.ts. +await (await import("./_helpers/offlineOutbound.ts")).installOfflineOutbound(); + async function resetStorage() { core.resetDbInstance(); apiKeysDb.resetApiKeyState(); diff --git a/tests/unit/verified-connection-activation-11446.test.ts b/tests/unit/verified-connection-activation-11446.test.ts index dd91dd2e6d89..5ada4bd13e69 100644 --- a/tests/unit/verified-connection-activation-11446.test.ts +++ b/tests/unit/verified-connection-activation-11446.test.ts @@ -37,8 +37,18 @@ process.env.DISABLE_SQLITE_AUTO_BACKUP = "true"; // /api/providers) gets a harmless 404 instead of touching the network. const VALIDATION_BASE_URL = "https://proxy.activation-11446.example.com/v1"; let nextModelsProbeStatus: number | null = 200; + +const core = await import("../../src/lib/db/core.ts"); +const providerNodesRoute = await import("../../src/app/api/provider-nodes/route.ts"); +const providersRoute = await import("../../src/app/api/providers/route.ts"); +const { testSingleConnection } = await import("../../src/app/api/providers/[id]/test/route.ts"); + +// The stub goes in AFTER the route imports, and is asserted to be the live fetch: +// open-sse/utils/proxyFetch.ts replaces globalThis.fetch at import time, so a stub +// installed above this line was silently discarded and the probes went to the real +// network (the guard in tests/_setup/blockNetwork.ts caught 10 such attempts). const originalFetch = globalThis.fetch; -globalThis.fetch = (async (input: string | URL | Request) => { +const probeStub = (async (input: string | URL | Request) => { const url = typeof input === "string" ? input : input instanceof Request ? input.url : input.toString(); if (url === `${VALIDATION_BASE_URL}/models`) { @@ -47,13 +57,10 @@ globalThis.fetch = (async (input: string | URL | Request) => { } return new Response(JSON.stringify({ data: [] }), { status: nextModelsProbeStatus }); } - return new Response("not found", { status: 404 }); + throw new Error(`unexpected outbound request in this suite: ${url}`); }) as typeof fetch; - -const core = await import("../../src/lib/db/core.ts"); -const providerNodesRoute = await import("../../src/app/api/provider-nodes/route.ts"); -const providersRoute = await import("../../src/app/api/providers/route.ts"); -const { testSingleConnection } = await import("../../src/app/api/providers/[id]/test/route.ts"); +globalThis.fetch = probeStub; +assert.equal(globalThis.fetch, probeStub, "the probe stub must be the live globalThis.fetch"); async function readJsonObject(response: Response): Promise> { const text = await response.text(); diff --git a/tests/unit/vision-bridge-policy-reroute-6640.test.ts b/tests/unit/vision-bridge-policy-reroute-6640.test.ts index e710bb068bb3..db2b4fdb1fe3 100644 --- a/tests/unit/vision-bridge-policy-reroute-6640.test.ts +++ b/tests/unit/vision-bridge-policy-reroute-6640.test.ts @@ -34,6 +34,10 @@ import assert from "node:assert/strict"; import { createChatPipelineHarness } from "../integration/_chatPipelineHarness.ts"; +// Hermetic outbound layer — installed AFTER every import (proxyFetch replaces +// globalThis.fetch at import time). See tests/unit/_helpers/offlineOutbound.ts. +await (await import("./_helpers/offlineOutbound.ts")).installOfflineOutbound(); + const harness = await createChatPipelineHarness("vision-bridge-policy-reroute-6640"); const { handleChat, buildRequest, buildOpenAIResponse, resetStorage, seedConnection, seedApiKey, settingsDb } = harness; diff --git a/tests/unit/vscode-responses-models.test.ts b/tests/unit/vscode-responses-models.test.ts index 336e664a65a4..127f84c84416 100644 --- a/tests/unit/vscode-responses-models.test.ts +++ b/tests/unit/vscode-responses-models.test.ts @@ -17,6 +17,10 @@ const vscodeModelsRoute = await import("../../src/app/api/v1/vscode/[token]/mode const vscodeRawModelsRoute = await import("../../src/app/api/v1/vscode/raw/[token]/models/route.ts"); +// Hermetic outbound layer — installed AFTER every import (proxyFetch replaces +// globalThis.fetch at import time). See tests/unit/_helpers/offlineOutbound.ts. +await (await import("./_helpers/offlineOutbound.ts")).installOfflineOutbound(); + type MetadataModel = { id?: string; root?: string; diff --git a/tests/unit/vscode-token-routes-gpt56.test.ts b/tests/unit/vscode-token-routes-gpt56.test.ts index 7589634824e8..91ec5c7045de 100644 --- a/tests/unit/vscode-token-routes-gpt56.test.ts +++ b/tests/unit/vscode-token-routes-gpt56.test.ts @@ -17,6 +17,10 @@ const apiKeysDb = await import("../../src/lib/db/apiKeys.ts"); const vscodeRawModelsRoute = await import("../../src/app/api/v1/vscode/raw/[token]/models/route.ts"); +// Hermetic outbound layer — installed AFTER every import (proxyFetch replaces +// globalThis.fetch at import time). See tests/unit/_helpers/offlineOutbound.ts. +await (await import("./_helpers/offlineOutbound.ts")).installOfflineOutbound(); + interface RawModel { id: string; [key: string]: unknown; diff --git a/tests/unit/vscode-token-routes-responses-listing.test.ts b/tests/unit/vscode-token-routes-responses-listing.test.ts index 647f4fd00e11..231fbb4c7d8e 100644 --- a/tests/unit/vscode-token-routes-responses-listing.test.ts +++ b/tests/unit/vscode-token-routes-responses-listing.test.ts @@ -30,6 +30,10 @@ const vscodeRawTagsRoute = const vscodeRawShowRoute = await import("../../src/app/api/v1/vscode/raw/[token]/api/show/route.ts"); +// Hermetic outbound layer — installed AFTER every import (proxyFetch replaces +// globalThis.fetch at import time). See tests/unit/_helpers/offlineOutbound.ts. +await (await import("./_helpers/offlineOutbound.ts")).installOfflineOutbound(); + async function resetStorage() { core.resetDbInstance(); apiKeysDb.resetApiKeyState(); diff --git a/tests/unit/vscode-token-routes.test.ts b/tests/unit/vscode-token-routes.test.ts index 9ff141d10fde..1c504b255f15 100644 --- a/tests/unit/vscode-token-routes.test.ts +++ b/tests/unit/vscode-token-routes.test.ts @@ -36,6 +36,10 @@ const serviceTierVariants = await import("../../src/app/api/v1/vscode/[token]/serviceTierVariants.ts"); const combosDb = await import("../../src/lib/db/combos.ts"); +// Hermetic outbound layer — installed AFTER every import (proxyFetch replaces +// globalThis.fetch at import time). See tests/unit/_helpers/offlineOutbound.ts. +await (await import("./_helpers/offlineOutbound.ts")).installOfflineOutbound(); + async function resetStorage() { core.resetDbInstance(); apiKeysDb.resetApiKeyState();