diff --git a/.github/workflows/self-test.yml b/.github/workflows/self-test.yml index d36cd27..12ea322 100644 --- a/.github/workflows/self-test.yml +++ b/.github/workflows/self-test.yml @@ -137,11 +137,90 @@ jobs: skip-checksum: "true" - run: task --version + # Install with all traffic forced through an HTTP proxy, proving the action's + # fetch (proxy-unaware by default) is routed via the proxy env (#54). + proxy: + name: Install behind an HTTP proxy + needs: changes + if: ${{ needs.changes.outputs.src == 'true' }} + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 24 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm run build + + - name: Start a local HTTP proxy (tinyproxy) + run: | + sudo apt-get update + sudo apt-get install -y tinyproxy + # apt may auto-start a tinyproxy service on :8888 (default config, + # logging to /var/log). Stop it so our instance owns the port and logs + # where we can read it. + sudo systemctl stop tinyproxy 2>/dev/null || sudo service tinyproxy stop 2>/dev/null || true + puser="$(id -un)"; pgroup="$(id -gn)" + { + echo "Port 8888" + echo "Listen 127.0.0.1" + echo "Timeout 600" + echo "Allow 127.0.0.1" + # Stay as the runner user so the log/pid files are writable (avoid the + # packaged tinyproxy user's privilege drop swallowing the log). + echo "User $puser" + echo "Group $pgroup" + echo 'LogFile "/tmp/tinyproxy.log"' + echo 'PidFile "/tmp/tinyproxy.pid"' + echo "LogLevel Connect" + echo "ConnectPort 443" + } > /tmp/tinyproxy.conf + tinyproxy -c /tmp/tinyproxy.conf + ok= + for _ in $(seq 1 10); do + if curl -fsS -x http://127.0.0.1:8888 -o /dev/null https://github.com; then ok=1; break; fi + sleep 1 + done + [ -n "$ok" ] || { echo "::error::tinyproxy did not come up"; cat /tmp/tinyproxy.log 2>/dev/null || true; exit 1; } + # Confirm our instance (not a leftover service) owns the port and logs. + test -f /tmp/tinyproxy.log || { echo "::error::tinyproxy is not logging to /tmp/tinyproxy.log"; exit 1; } + + - name: Run setup-task through the proxy + uses: ./ + env: + HTTP_PROXY: http://127.0.0.1:8888 + HTTPS_PROXY: http://127.0.0.1:8888 + with: + # A range + check-latest forces version resolution via the GitHub API, + # which only the built-in fetch reaches (tool-cache already proxies and + # only hits github.com / the asset CDN) — so this exercises the new + # fetch proxying. check-latest avoids a cache hit short-circuiting the + # API call and making the api.github.com assertion below vacuous. + version: "3.x" + check-latest: "true" + repo-token: ${{ github.token }} + - run: task --version + + - name: Assert the built-in fetch went through the proxy + run: | + echo '--- tinyproxy log ---' + cat /tmp/tinyproxy.log || true + # api.github.com is reached ONLY by the action's fetch (version + # resolution) — never by tool-cache or the readiness curl above. Its + # presence proves configureProxyFromEnv() routed fetch through the proxy; + # if fetch bypassed it (direct egress), this is absent and the job fails. + grep -Eiq "api\.github\.com" /tmp/tinyproxy.log + # Bridge job that always succeeds so branch protection can require it even # when the real self-test jobs are skipped for markdown-only changes. self-test-gate: name: Self-test (gate) - needs: [changes, matrix, skip-checksum] + needs: [changes, matrix, skip-checksum, proxy] if: always() runs-on: ubuntu-latest steps: @@ -150,12 +229,13 @@ jobs: changes="${{ needs.changes.result }}" matrix="${{ needs.matrix.result }}" skip="${{ needs.skip-checksum.result }}" - if [ "$changes" == "failure" ] || [ "$matrix" == "failure" ] || [ "$skip" == "failure" ]; then - echo "::error::Self-test failed (changes=$changes, matrix=$matrix, skip-checksum=$skip)" + proxy="${{ needs.proxy.result }}" + if [ "$changes" == "failure" ] || [ "$matrix" == "failure" ] || [ "$skip" == "failure" ] || [ "$proxy" == "failure" ]; then + echo "::error::Self-test failed (changes=$changes, matrix=$matrix, skip-checksum=$skip, proxy=$proxy)" exit 1 fi - if [ "$changes" == "cancelled" ] || [ "$matrix" == "cancelled" ] || [ "$skip" == "cancelled" ]; then - echo "::error::Self-test was cancelled (changes=$changes, matrix=$matrix, skip-checksum=$skip)" + if [ "$changes" == "cancelled" ] || [ "$matrix" == "cancelled" ] || [ "$skip" == "cancelled" ] || [ "$proxy" == "cancelled" ]; then + echo "::error::Self-test was cancelled (changes=$changes, matrix=$matrix, skip-checksum=$skip, proxy=$proxy)" exit 1 fi echo "Self-test passed or skipped (markdown-only changes)." diff --git a/CLAUDE.md b/CLAUDE.md index 52d9255..9d846fd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,7 +50,9 @@ Key design seams to preserve when editing: - **Retry vs. permanent (`download.ts` `withRetry` + `errors.ts` `PermanentError`)**: `withRetry` does exponential backoff, but `PermanentError` (and HTTP 404, including tool-cache's `httpStatusCode === 404`) bail out immediately with no retry. When adding a new failure mode, decide deliberately which side it falls on — checksum mismatches and 404s are permanent; everything network-ish is transient. - **Platform mapping (`platform.ts`)**: maps Node `process.platform`/`process.arch` to go-task's asset naming (`task__.`) and validates against a hardcoded `SUPPORTED` matrix mirroring go-task's published assets. Update `SUPPORTED` if upstream adds/drops a target. - **`constants.ts`** centralizes the upstream repo (`go-task/task`), the `task_checksums.txt` asset name, URL builders, and retry tuning. -- **Host allowlist (NFR-1, `url-guard.ts` + `github.ts` `secureFetch`)**: `secureFetch` follows redirects manually and validates *every* hop's host against an explicit set — `github.com` / `api.github.com` / `release-assets.githubusercontent.com` / `objects.githubusercontent.com` (`url-guard.ts` `isAllowedHost`); an untrusted host (or a malformed/missing redirect `Location`) is a `PermanentError` (never retried). It's an explicit set, not a `.githubusercontent.com` suffix, because `raw.`/`gist.githubusercontent.com` serve arbitrary user content. GitHub renamed the asset CDN (`objects.githubusercontent.com` → `release-assets.githubusercontent.com`) once — both are listed; if it renames again the download fails loudly until you add the new host. `Authorization` is dropped on cross-origin redirects so the token never leaks off `github.com`. Because tool-cache's downloader follows redirects opaquely, `download.ts` pre-flights the binary URL via `assertRedirectTrusted`; a network/proxy failure there (global `fetch` ignores runner proxy settings) falls through to tool-cache, but a `PermanentError` blocks. +- **Host allowlist (NFR-1, `url-guard.ts` + `github.ts` `secureFetch`)**: `secureFetch` follows redirects manually and validates *every* hop's host against an explicit set — `github.com` / `api.github.com` / `release-assets.githubusercontent.com` / `objects.githubusercontent.com` (`url-guard.ts` `isAllowedHost`); an untrusted host (or a malformed/missing redirect `Location`) is a `PermanentError` (never retried). It's an explicit set, not a `.githubusercontent.com` suffix, because `raw.`/`gist.githubusercontent.com` serve arbitrary user content. GitHub renamed the asset CDN (`objects.githubusercontent.com` → `release-assets.githubusercontent.com`) once — both are listed; if it renames again the download fails loudly until you add the new host. `Authorization` is dropped on cross-origin redirects so the token never leaks off `github.com`. Because tool-cache's downloader follows redirects opaquely, `download.ts` pre-flights the binary URL via `assertRedirectTrusted`; a transient network/proxy failure there falls through to tool-cache, but a `PermanentError` blocks. +- **Fetch shim (`fetch.ts`)**: re-exports `fetch` from the npm `undici` package with the standard string-URL signature. `github.ts` imports from here instead of using Node's built-in fetch so the `EnvHttpProxyAgent` dispatcher configured by `proxy.ts` is guaranteed to be used across Node versions. Tests mock `./fetch` rather than `globalThis.fetch`. +- **Proxy (`proxy.ts`)**: `configureProxyFromEnv()` runs first in `run()` and, when a proxy env var is set, installs undici's `EnvHttpProxyAgent` as the global dispatcher so `fetch.ts` honours `HTTP_PROXY`/`HTTPS_PROXY`/`NO_PROXY`. Because `fetch.ts` imports from the same npm `undici` instance, the dispatcher is picked up reliably on Node 24 and future Node versions; using Node's built-in fetch would break when the runtime's undici symbol version diverges from the package's. Verified by the `proxy` self-test job (tinyproxy). No-op without a proxy env. ## Conventions diff --git a/README.md b/README.md index 068087b..1e60ead 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ Built as a modern, reliable alternative to `arduino/setup-task`: - 🚫 **Host-pinned** — downloads and their redirects are restricted to GitHub hosts; a redirect to any other host is refused, and the token is never forwarded off `github.com` - ♻️ **Cached** — uses the runner tool cache to avoid re-downloading - 🔁 **Resilient** — retries transient network failures with exponential backoff +- 🌐 **Proxy-aware** — honours `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` for installs behind a corporate proxy or on self-hosted runners - 🧩 **Drop-in** — `version` / `repo-token` inputs are compatible with `arduino/setup-task` ## Usage diff --git a/TODO.md b/TODO.md index 4db5a32..f4ee026 100644 --- a/TODO.md +++ b/TODO.md @@ -53,6 +53,8 @@ | [#41](https://github.com/yk-lab/setup-task/issues/41) | `[test]` platform.test.ts を §9 全 os/arch 組合せに拡張 | `P3: low` | `test` | | [#44](https://github.com/yk-lab/setup-task/issues/44) | `[enhancement]` フォールバックソースをサポート(FR-11) | `P3: low` | `enhancement` | | [#45](https://github.com/yk-lab/setup-task/issues/45) | `[chore]` Biome 導入を評価 | `P3: low` | `chore` | +| [#55](https://github.com/yk-lab/setup-task/issues/55) | `[documentation]` テスト規約(CLAUDE.md)を stub-fetch unit test の実態に合わせる | `P3: low` | `documentation` | | +| [#56](https://github.com/yk-lab/setup-task/issues/56) | `[security]` 取得ボディにサイズ上限/タイムアウトを設ける | `P3: low` | `security` | | ### 未 Issue 化 @@ -77,3 +79,4 @@ v1.0.0 実装中に作成・解決済みの Issue(参考)。 | [#40](https://github.com/yk-lab/setup-task/issues/40) | `[enhancement]` リトライ回数・間隔を input 化(FR-4) | #51 | | [#43](https://github.com/yk-lab/setup-task/issues/43) | `[chore]` checksum 改ざんテストの重複ファイルを統合 | #52 | | [#42](https://github.com/yk-lab/setup-task/issues/42) | `[security]` ダウンロード先ホスト/リダイレクト先を検証(NFR-1) | #53 | +| [#54](https://github.com/yk-lab/setup-task/issues/54) | `[enhancement]` HTTP(S) proxy 環境で全 fetch を proxy 経由にする | #57 | diff --git a/package.json b/package.json index 3fb6e03..433e652 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,8 @@ "dependencies": { "@actions/core": "^3.0.1", "@actions/tool-cache": "^4.0.0", - "semver": "^7.6.3" + "semver": "^7.6.3", + "undici": "^6.27.0" }, "devDependencies": { "@eslint/js": "^10.0.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8f09b24..18d2b51 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: semver: specifier: ^7.6.3 version: 7.8.4 + undici: + specifier: ^6.27.0 + version: 6.27.0 devDependencies: '@eslint/js': specifier: ^10.0.1 diff --git a/src/download.ts b/src/download.ts index e612951..eed2391 100644 --- a/src/download.ts +++ b/src/download.ts @@ -58,10 +58,9 @@ export async function withRetry(fn: () => Promise, opts: RetryOptions = {} */ export async function downloadAsset(url: string, token?: string): Promise { // Vet the redirect chain's hosts before tool-cache (which follows redirects - // opaquely) fetches the body (NFR-1). The preflight's global fetch ignores - // runner proxy settings, so only an untrusted host (PermanentError) is fatal; - // a network/proxy failure falls through to the proxy-capable, checksum-verified - // tool-cache download. + // opaquely) fetches the body (NFR-1). Only an untrusted host (PermanentError) + // is fatal; a transient network failure in the preflight falls through to the + // checksum-verified tool-cache download rather than blocking it. try { await assertRedirectTrusted(url, token); } catch (err) { diff --git a/src/fetch.ts b/src/fetch.ts new file mode 100644 index 0000000..5e302bc --- /dev/null +++ b/src/fetch.ts @@ -0,0 +1,11 @@ +import { fetch as undiciFetch } from 'undici'; + +/** + * Re-export undici's fetch with a string-URL signature. + * + * This avoids type mismatches between undici's own Request/Response types and + * the global DOM types exposed by Node, while still using the npm undici + * implementation so the dispatcher configured by `proxy.ts` is guaranteed to + * be picked up across Node versions. + */ +export const fetch = undiciFetch as (input: string, init?: RequestInit) => Promise; diff --git a/src/github.ts b/src/github.ts index 788adf4..d670a59 100644 --- a/src/github.ts +++ b/src/github.ts @@ -1,6 +1,7 @@ import * as semver from 'semver'; import { GITHUB_API, MAX_REDIRECTS, REPO_NAME, REPO_OWNER } from './constants'; import { PermanentError } from './errors'; +import { fetch } from './fetch'; import { assertAllowedHost } from './url-guard'; import type { ReleaseApi } from './version'; diff --git a/src/main.ts b/src/main.ts index fa9d26c..80624f7 100644 --- a/src/main.ts +++ b/src/main.ts @@ -10,9 +10,13 @@ import { errorMessage } from './errors'; import { createReleaseApi } from './github'; import { extract } from './install'; import { resolveAsset } from './platform'; +import { configureProxyFromEnv } from './proxy'; import { resolveFromCache, resolveVersion } from './version'; async function run(): Promise { + // Route fetch through the runner's proxy before any network call (#54). + configureProxyFromEnv(); + const versionSpec = core.getInput('version') || 'latest'; const token = core.getInput('repo-token') || process.env.GITHUB_TOKEN || ''; const archOverride = core.getInput('architecture'); diff --git a/src/proxy.ts b/src/proxy.ts new file mode 100644 index 0000000..51e427b --- /dev/null +++ b/src/proxy.ts @@ -0,0 +1,30 @@ +import * as core from '@actions/core'; +import { EnvHttpProxyAgent, setGlobalDispatcher } from 'undici'; + +/** + * Route the action's `fetch()` through the runner's proxy when one is set. + * Node's built-in fetch ignores `HTTP_PROXY`/`HTTPS_PROXY`/`NO_PROXY`; undici's + * `EnvHttpProxyAgent` reads them and `setGlobalDispatcher` applies it (picked up + * by `fetch.ts`, which uses the same undici instance). No-op without a proxy, + * so direct-egress runners are unchanged (#54). + */ +export function configureProxyFromEnv(): void { + // First non-empty proxy var. A plain truthiness check (not `??`) so an empty + // var (HTTP_PROXY="", a common "unset") doesn't shadow a real proxy in a + // later variable. + const source = ['HTTP_PROXY', 'HTTPS_PROXY', 'http_proxy', 'https_proxy'].find( + (name) => process.env[name], + ); + if (!source) { + return; + } + try { + // EnvHttpProxyAgent's constructor throws on a malformed proxy URL. + setGlobalDispatcher(new EnvHttpProxyAgent()); + } catch (err) { + // Name the offending variable, never its value — a proxy URL can embed + // credentials that must not land in the Actions log. + throw new Error(`Invalid proxy URL in ${source}.`, { cause: err }); + } + core.info('Detected a proxy in the environment; routing requests through it.'); +} diff --git a/tests/checksum.test.ts b/tests/checksum.test.ts index d761cff..57f1325 100644 --- a/tests/checksum.test.ts +++ b/tests/checksum.test.ts @@ -3,9 +3,14 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { afterAll, afterEach, describe, expect, it, vi } from 'vitest'; +import { fetch } from '../src/fetch'; import { fetchChecksum, parseChecksums, sha256File, verifyChecksum } from '../src/checksum'; import { PermanentError } from '../src/errors'; +vi.mock('../src/fetch', () => ({ fetch: vi.fn() })); + +const mockedFetch = vi.mocked(fetch); + describe('parseChecksums', () => { const sample = [ 'd1c2f0...not64hex skip-this-malformed-line', @@ -57,7 +62,9 @@ describe('sha256File / verifyChecksum', () => { // (FR-5, 要求仕様書 §10.3). The action hard-codes its download source (CON-2), // so the published checksums file is injected via a fetch stub. describe('fetchChecksum + verifyChecksum: tamper detection', () => { - afterEach(() => vi.unstubAllGlobals()); + afterEach(() => { + mockedFetch.mockReset(); + }); const ASSET = 'task_linux_amd64.tar.gz'; const genuine = Buffer.from('genuine task archive payload'); @@ -68,9 +75,8 @@ describe('fetchChecksum + verifyChecksum: tamper detection', () => { ].join('\n'); function stubChecksums(body: string): void { - vi.stubGlobal( - 'fetch', - vi.fn(async () => new Response(body, { status: 200, headers: { 'content-type': 'text/plain' } })), + mockedFetch.mockImplementation( + async () => new Response(body, { status: 200, headers: { 'content-type': 'text/plain' } }), ); } diff --git a/tests/download.test.ts b/tests/download.test.ts index 8feebec..e7c1cdf 100644 --- a/tests/download.test.ts +++ b/tests/download.test.ts @@ -96,8 +96,9 @@ describe('downloadAsset (redirect preflight wiring, NFR-1)', () => { }); it('falls through to tool-cache on a network/proxy fetch failure (TypeError)', async () => { - // A failed fetch() throws TypeError; global fetch ignores proxy settings, so - // this must not block the proxy-capable tool-cache download. + // A failed preflight fetch() throws TypeError; only a PermanentError (untrusted + // host) should block the download. Transient network/proxy failures fall through + // to tool-cache so the action still has a chance to fetch the binary. vi.mocked(assertRedirectTrusted).mockRejectedValueOnce(new TypeError('fetch failed')); await expect(downloadAsset(ASSET_URL, 'tok')).resolves.toBe('/tmp/task-archive'); expect(tc.downloadTool).toHaveBeenCalledOnce(); diff --git a/tests/github.test.ts b/tests/github.test.ts index 0f4842f..3a300f3 100644 --- a/tests/github.test.ts +++ b/tests/github.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; +import { fetch } from '../src/fetch'; import { PermanentError } from '../src/errors'; import { assertRedirectTrusted, @@ -8,6 +9,10 @@ import { secureFetch, } from '../src/github'; +vi.mock('../src/fetch', () => ({ fetch: vi.fn() })); + +const mockedFetch = vi.mocked(fetch); + // Real allowlisted URLs (NFR-1): the host guard now rejects unknown hosts, so // tests must hit github.com / api.github.com / *.githubusercontent.com. const API_URL = 'https://api.github.com/repos/go-task/task/releases/latest'; @@ -23,9 +28,8 @@ function stubFetch( const headers: Record = init.contentType ? { 'content-type': init.contentType } : {}; - vi.stubGlobal( - 'fetch', - vi.fn(async () => new Response(body, { status: init.status ?? 200, headers })), + mockedFetch.mockImplementation( + async () => new Response(body, { status: init.status ?? 200, headers }), ); } @@ -34,7 +38,9 @@ function redirectTo(location: string, status = 302): Response { return new Response(null, { status, headers: { location } }); } -afterEach(() => vi.unstubAllGlobals()); +afterEach(() => { + mockedFetch.mockReset(); +}); describe('fetchJson (content-type guard, FR-3)', () => { it('parses and returns the JSON body on a JSON response', async () => { @@ -91,48 +97,44 @@ describe('fetchText (checksums fetch, FR-3)', () => { it('rejects a redirect to an untrusted host with PermanentError (NFR-1)', async () => { // The malicious-redirect injection required by NFR-1: github.com 302s to an // attacker host; the checksums fetch must refuse it rather than follow. - vi.stubGlobal('fetch', vi.fn(async () => redirectTo('https://evil.example.com/checksums'))); + mockedFetch.mockImplementation(async () => redirectTo('https://evil.example.com/checksums')); await expect(fetchText(CHECKSUMS_URL)).rejects.toBeInstanceOf(PermanentError); }); }); describe('secureFetch (redirect host validation, NFR-1)', () => { it('follows a redirect to a trusted host and returns the final response', async () => { - const fetchMock = vi - .fn() + mockedFetch .mockResolvedValueOnce(redirectTo(ASSET_CDN_URL)) .mockResolvedValueOnce(new Response('payload', { status: 200 })); - vi.stubGlobal('fetch', fetchMock); const resp = await secureFetch(CHECKSUMS_URL, { 'User-Agent': 'x' }); expect(resp.status).toBe(200); await expect(resp.text()).resolves.toBe('payload'); - expect(fetchMock).toHaveBeenCalledTimes(2); + expect(mockedFetch).toHaveBeenCalledTimes(2); }); it('rejects a redirect to an untrusted host with PermanentError', async () => { - vi.stubGlobal('fetch', vi.fn(async () => redirectTo('https://evil.example.com/x'))); + mockedFetch.mockImplementation(async () => redirectTo('https://evil.example.com/x')); await expect(secureFetch(CHECKSUMS_URL, {})).rejects.toBeInstanceOf(PermanentError); }); it('rejects an initial untrusted URL without fetching', async () => { - const fetchMock = vi.fn(async () => new Response('should not run', { status: 200 })); - vi.stubGlobal('fetch', fetchMock); + mockedFetch.mockImplementation(async () => new Response('should not run', { status: 200 })); await expect(secureFetch('https://evil.example.com/', {})).rejects.toBeInstanceOf( PermanentError, ); - expect(fetchMock).not.toHaveBeenCalled(); + expect(mockedFetch).not.toHaveBeenCalled(); }); it('drops the Authorization header on a cross-origin redirect (token never leaks)', async () => { const seen: Array> = []; - const fetchMock = vi.fn(async (url: string, init: RequestInit) => { - seen.push({ ...(init.headers as Record) }); - return url.startsWith('https://github.com') + mockedFetch.mockImplementation(async (_url: string, init?: RequestInit) => { + seen.push({ ...(init?.headers as Record) }); + return _url.startsWith('https://github.com') ? redirectTo(ASSET_CDN_URL) : new Response('ok', { status: 200 }); }); - vi.stubGlobal('fetch', fetchMock); await secureFetch(CHECKSUMS_URL, { 'User-Agent': 'x', Authorization: 'Bearer secret' }); @@ -142,17 +144,17 @@ describe('secureFetch (redirect host validation, NFR-1)', () => { it('throws PermanentError after too many redirects', async () => { // Always redirect (to a trusted host) so only the hop cap can stop it. - vi.stubGlobal('fetch', vi.fn(async () => redirectTo(ASSET_CDN_URL))); + mockedFetch.mockImplementation(async () => redirectTo(ASSET_CDN_URL)); await expect(secureFetch(CHECKSUMS_URL, {})).rejects.toThrow(/Too many redirects/); }); it('rejects a redirect status with no Location header (malformed) as PermanentError', async () => { - vi.stubGlobal('fetch', vi.fn(async () => new Response(null, { status: 302 }))); + mockedFetch.mockImplementation(async () => new Response(null, { status: 302 })); await expect(secureFetch(CHECKSUMS_URL, {})).rejects.toBeInstanceOf(PermanentError); }); it('rejects a redirect with an unparsable Location as PermanentError', async () => { - vi.stubGlobal('fetch', vi.fn(async () => redirectTo('http://'))); + mockedFetch.mockImplementation(async () => redirectTo('http://')); await expect(secureFetch(CHECKSUMS_URL, {})).rejects.toBeInstanceOf(PermanentError); }); @@ -161,7 +163,7 @@ describe('secureFetch (redirect host validation, NFR-1)', () => { // attacker host -> PermanentError, before tool-cache ever downloads. const ASSET_URL = 'https://github.com/go-task/task/releases/download/v3.51.1/task_linux_amd64.tar.gz'; - vi.stubGlobal('fetch', vi.fn(async () => redirectTo('https://evil.example.com/asset'))); + mockedFetch.mockImplementation(async () => redirectTo('https://evil.example.com/asset')); await expect(assertRedirectTrusted(ASSET_URL, 'tok')).rejects.toBeInstanceOf(PermanentError); }); }); @@ -187,18 +189,17 @@ describe('createReleaseApi', () => { { tag_name: 'not-semver', draft: false, prerelease: false }, // dropped by semver.clean ]; - const fetchMock = vi.fn(async (url: string) => { + mockedFetch.mockImplementation(async (url: string) => { const page = url.includes('page=2') ? page2 : page1; return new Response(JSON.stringify(page), { status: 200, headers: { 'content-type': 'application/json' }, }); }); - vi.stubGlobal('fetch', fetchMock); const versions = await createReleaseApi().listStableVersions(); - expect(fetchMock).toHaveBeenCalledTimes(2); // followed pagination, then stopped + expect(mockedFetch).toHaveBeenCalledTimes(2); // followed pagination, then stopped expect(versions).toContain('3.0.0'); // from the full first page (cleaned) expect(versions).toContain('3.200.0'); // the only stable entry on page 2 expect(versions).not.toContain('3.201.0'); // draft excluded @@ -209,7 +210,7 @@ describe('createReleaseApi', () => { it('listStableVersions stops on an empty page', async () => { // A full first page then an empty page: loop must terminate on length === 0. let call = 0; - const fetchMock = vi.fn(async () => { + mockedFetch.mockImplementation(async () => { call += 1; const body = call === 1 @@ -224,10 +225,9 @@ describe('createReleaseApi', () => { headers: { 'content-type': 'application/json' }, }); }); - vi.stubGlobal('fetch', fetchMock); const versions = await createReleaseApi().listStableVersions(); - expect(fetchMock).toHaveBeenCalledTimes(2); + expect(mockedFetch).toHaveBeenCalledTimes(2); expect(versions).toHaveLength(100); }); }); diff --git a/tests/proxy.test.ts b/tests/proxy.test.ts new file mode 100644 index 0000000..4eb344d --- /dev/null +++ b/tests/proxy.test.ts @@ -0,0 +1,79 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Hoisted alongside vi.mock so the factory can reference them safely. +const { setGlobalDispatcher, envAgent } = vi.hoisted(() => ({ + setGlobalDispatcher: vi.fn(), + envAgent: vi.fn(), +})); +vi.mock('undici', () => ({ + setGlobalDispatcher, + // Construct via the spy so a test can make the constructor throw. + EnvHttpProxyAgent: class { + constructor(...args: unknown[]) { + envAgent(...args); + } + }, +})); +vi.mock('@actions/core', () => ({ info: vi.fn(), warning: vi.fn() })); + +import { configureProxyFromEnv } from '../src/proxy'; + +const PROXY_VARS = ['HTTP_PROXY', 'HTTPS_PROXY', 'http_proxy', 'https_proxy', 'NO_PROXY', 'no_proxy']; +let saved: Record; + +beforeEach(() => { + saved = {}; + for (const v of PROXY_VARS) { + saved[v] = process.env[v]; + delete process.env[v]; + } + // resetAllMocks (not clearAllMocks) so a mockImplementation from one test + // doesn't leak into the next. + vi.resetAllMocks(); +}); + +afterEach(() => { + for (const v of PROXY_VARS) { + if (saved[v] === undefined) { + delete process.env[v]; + } else { + process.env[v] = saved[v]; + } + } +}); + +describe('configureProxyFromEnv', () => { + it('does nothing when no proxy env var is set', () => { + configureProxyFromEnv(); + expect(setGlobalDispatcher).not.toHaveBeenCalled(); + }); + + it('installs the dispatcher when HTTP_PROXY is set', () => { + process.env.HTTP_PROXY = 'http://proxy:8080'; + configureProxyFromEnv(); + expect(setGlobalDispatcher).toHaveBeenCalledOnce(); + }); + + it('honours the lowercase http_proxy variant', () => { + process.env.http_proxy = 'http://proxy:8080'; + configureProxyFromEnv(); + expect(setGlobalDispatcher).toHaveBeenCalledOnce(); + }); + + it('falls through an empty HTTP_PROXY to a real HTTPS_PROXY', () => { + process.env.HTTP_PROXY = ''; + process.env.HTTPS_PROXY = 'http://proxy:8080'; + configureProxyFromEnv(); + expect(setGlobalDispatcher).toHaveBeenCalledOnce(); + }); + + it('throws naming the variable (not its credential-bearing value) on a bad proxy URL', () => { + process.env.HTTP_PROXY = 'http://user:secret@bad'; + envAgent.mockImplementation(() => { + throw new TypeError('Invalid URL'); + }); + expect(() => configureProxyFromEnv()).toThrow(/Invalid proxy URL in HTTP_PROXY/); + expect(() => configureProxyFromEnv()).not.toThrow(/user:secret/); + expect(setGlobalDispatcher).not.toHaveBeenCalled(); + }); +});