cache/docs/path-validation-test-plan.md
2026-05-20 12:24:44 -10:00

15 KiB
Raw Blame History

Path Validation Test Plan (actions/cache@v5.1+)

This document describes the test coverage for the client-side path-validation feature added to actions/cache v5.1.0. The validation feature itself lives in the @actions/cache toolkit (v6.1.0+); this action wires the new strict-paths and fail-on-cache-invalid inputs through to the toolkit and handles the CacheIntegrityError it can throw.

The companion document in the toolkit repo (packages/cache/docs/path-validation-test-plan.md) covers the behavioral tests for the validation engine itself. This document focuses on the action-layer wiring tests.

Summary

Layer Test file Tests What it validates
Input parsing __tests__/actionUtils.test.ts 9 new getPathValidationInput() normalizes input values, defaults to warn, warns on unknown values
restoreImpl integration __tests__/restoreImpl.test.ts 9 new Forwards strict-paths to cache.restoreCache, handles CacheIntegrityError per fail-on-cache-invalid
Regression — existing forward tests __tests__/restoreImpl.test.ts (updated) 11 Existing tests now assert the pathValidation field is present in the options object passed to cache.restoreCache
End-to-end (good cache) .github/workflows/path-validation-e2e.yml 18 A legitimate cache restores successfully under all three strict-paths modes on every supported OS
End-to-end (poisoned cache) .github/workflows/path-validation-e2e.yml 18 A poisoned cache is treated correctly per mode (extracted in off/warn, rejected in error)
End-to-end (fail-on-cache-invalid) .github/workflows/path-validation-e2e.yml 3 When fail-on-cache-invalid: true and the cache is rejected, the restore step itself fails the workflow

Total: 77 new/updated tests at the action layer (29 unit + 39 E2E job runs + 9 input-parsing).

Unit tests

__tests__/actionUtils.test.tsgetPathValidationInput() (9 new)

Verifies the input-parsing helper that the action's restoreImpl uses to translate the strict-paths workflow input into the literal type expected by the toolkit.

Test Asserts
returns 'warn' when input is unset Default behavior matches the action.yml default
normalizes off / warn / error / OFF / Warn / ERROR Case-insensitive parsing of the three valid values
falls back to 'warn' for unrecognized values and logs a warning Typos don't silently disable validation; user gets a workflow warning via core.info
treats empty string as default 'warn' Defensive default in case the workflow runner passes an empty string

__tests__/restoreImpl.test.ts — Path-validation wiring (9 new)

Verifies restoreImpl forwards the input to the toolkit and handles its errors per the fail-on-cache-invalid input.

Test Asserts
defaults strict-paths to 'warn' and forwards it to restoreCache Default option object contains pathValidation: 'warn'
test.each(['off', 'warn', 'error']) forwards each value to restoreCache All three valid values reach the toolkit unchanged
falls back to 'warn' when strict-paths input is unrecognized Unknown values are coerced to 'warn' and a warning is logged
treats CacheIntegrityError as a cache miss by default When the toolkit throws CacheIntegrityError and fail-on-cache-invalid: false, action logs the rejection and returns without setting the cache-hit output (intentionally unset to match regular cache-miss semantics — see issue #1466)
fails when CacheIntegrityError is raised and fail-on-cache-invalid: true When fail-on-cache-invalid: true, core.setFailed() is called with a message containing integrity validation and the code
propagates non-integrity errors normally Network/auth errors still surface via core.setFailed() rather than being mis-classified as integrity failures
PARSE_ERROR integrity failure also treated as miss by default Validation handles both PATH_VIOLATION and PARSE_ERROR codes identically
tolerates CacheIntegrityError without explicit .code If the toolkit ever omits a code, the action still degrades gracefully (logs unknown)
does not set cache-hit output when integrity error is rethrown When fail-on-cache-invalid: true, no cache-hit output is set (preserves existing miss semantics for downstream if: checks)

Detection strategy for CacheIntegrityError

The action detects integrity errors by name (err.name === 'CacheIntegrityError') rather than instanceof. This is intentional:

  • The @actions/cache toolkit is shipped as ESM, while the action's runtime is bundled by ncc into CJS. Cross-module-system instanceof checks are fragile (different module realms, two copies of the class).
  • The toolkit guarantees name === 'CacheIntegrityError' via the class constructor.
  • The toolkit also attaches a stable code property whose value is one of 'PARSE_ERROR' | 'PATH_VIOLATION' | 'CHECKSUM_MISMATCH'. The action surfaces this code in the workflow log so users can diagnose rejections.

This approach is covered by the tolerates CacheIntegrityError without explicit .code test — even if the toolkit changes the shape of its error, the action continues to recognize and handle it.

End-to-end workflow

.github/workflows/path-validation-e2e.yml runs three jobs:

1. good-cache — legitimate cache restores correctly under all modes (18 runs)

Matrix: [ubuntu-latest, ubuntu-22.04, macos-latest, macos-13, windows-latest, windows-2022] × [off, warn, error].

For each combination:

  1. Generates a directory path-validation-cache/ with 5 small files.
  2. Saves it using actions/cache@./ with a unique key.
  3. Removes the local directory.
  4. Restores using actions/cache@./ with strict-paths: ${{ matrix.strict-paths }} and fail-on-cache-invalid: true.
  5. Asserts cache-hit == 'true' and all 5 files are present.

This validates that enabling path validation does not regress legitimate caches — a critical false-positive check on every supported platform and both archive formats (gnu-tar on Linux/macOS, bsdtar on Windows).

2. poisoned-cache — poisoned cache is handled per mode (18 runs)

Same matrix. For each combination:

  1. Generates a directory path-validation-cache/ with one legitimate file.
  2. Generates an escape.txt file outside the declared path (in the workspace root, one level up from path-validation-cache/).
  3. Uses the toolkit's saveCache() directly (via __tests__/e2e/save-poisoned-cache.mjs) to upload a cache that includes BOTH paths. This produces an archive whose entries, when extracted relative to the declared path on restore, would write escape.txt outside the declared path.
  4. Removes local files.
  5. Restores via actions/cache@./ declaring only the legitimate path (this is what an unsuspecting downstream consumer would do).
  6. Asserts per-mode:
    • off: cache-hit == 'true', escape.txt IS present (validation disabled = legacy behavior).
    • warn: cache-hit == 'true', escape.txt IS present (validation does not block extraction in warn mode), and a workflow warning should be visible in the log.
    • error: cache-hit == 'false', NEITHER escape.txt NOR path-validation-cache/legit.txt exists (the archive was rejected before any extraction).

This validates the false-negative axis — the path-validation logic correctly identifies a real cross-path entry on every supported OS.

3. poisoned-cache-fail-on-invalid — rejected cache fails the workflow when configured to (3 runs)

Matrix: [ubuntu-latest, macos-latest, windows-latest].

Same setup as job 2, but with fail-on-cache-invalid: true. The restore step itself is expected to fail (step outcome == 'failure'). The job uses continue-on-error: true on the restore step so we can assert on its outcome rather than the job failing.

This validates the workflow-fail path that strict security-conscious users will enable in production pipelines.

Manual tests

Before tagging a release, run the following manual sanity checks:

Manual test 1: existing workflow regressions

In a workflow file that uses actions/cache@<branch> (no path-validation inputs set), run a normal cache save + restore cycle. Verify:

  • The action behaves identically to v5.0.x.
  • No new warnings appear in the log for a clean cache.
  • cache-hit output is set correctly.

Manual test 2: warn-mode visibility

Configure a workflow that uses actions/cache@<branch> with strict-paths: warn (the default in v5.1+). Confirm that:

  • For a clean cache, no warning is logged.
  • For a poisoned cache (use the E2E helper to create one), a single ::warning:: annotation appears in the workflow summary, mentioning the path violation and the offending entry.

Manual test 3: error mode with fail-on-cache-invalid

Configure a workflow:

- uses: actions/cache@<branch>
  with:
    key: ...
    path: ./build
    strict-paths: error
    fail-on-cache-invalid: true

Trigger a restore of a poisoned cache. Confirm:

  • The workflow fails at the cache restore step.
  • The failure annotation includes the code (e.g., PATH_VIOLATION) and a short message describing the violation.

Manual test 4: cross-OS cache restore (enableCrossOsArchive: true)

Save a cache on Linux with one path, restore it on Windows with the same path under each strict-paths value. Confirm validation behaves the same way across the OS boundary (this exercises both gnu-tar archive production and bsdtar-on-Windows extraction).

Manual test 5: large real-world cache

Restore a realistic, multi-gigabyte cache (e.g., a node_modules deep-dependency tree) with strict-paths: warn. Measure:

  • No measurable regression in restore wall time.
  • No false-positive warnings for paths containing .. segments that resolve to valid in-workspace locations (e.g., symlinks inside the cache).

What's NOT tested here

  • The validation algorithm itself. Comprehensive tests for path resolution, glob expansion, env-var substitution, and parse-error classification live in the toolkit repo (packages/cache/__tests__/pathValidation.test.ts, listAndValidate.test.ts, and tarPathValidation.test.ts).
  • saveCache validation. This change adds restore-side validation only. The save path does not validate the entries it creates (and does not need to — saves operate on paths the user explicitly declared in this action's inputs).
  • Server-side scanning. This is a client-side defence-in-depth control; it does not replace server-side cache-poisoning mitigations.

How to run

# unit tests (Jest + ts-jest)
npm test

# rebuild distribution bundles
npm run build

# full lint + format + tests + build
npm run format-check && npm run lint && npm test && npm run build

For the E2E workflow, push the changes to a branch and trigger the Path Validation E2E workflow. All 39 matrix entries should pass; any matrix-entry failure indicates the validation logic disagrees with this plan on a specific OS/archive-format combination.

Local development note

The @actions/cache toolkit v6.1.0 used by this action is currently unpublished. For local development:

  1. From the toolkit repo: cd packages/cache && npm pack
  2. From this repo: npm install /path/to/actions-toolkit/packages/cache/actions-cache-6.1.0.tgz
  3. After running npm install, restore package.json's "@actions/cache": "^6.1.0" specifier (npm rewrites it to a file: URL when installing from a tarball).

Once the toolkit is published to npm, npm install will resolve ^6.1.0 directly from the registry and step 1-3 are no longer needed.

Jest cannot load the ESM-only toolkit directly. The jest.config.js file includes a moduleNameMapper that redirects @actions/cache imports during tests to a CJS stub at __tests__/__mocks__/actions-cache.ts. The stub re-implements just the surface the action consumes (with the same validation behavior for keys and paths) so tests can spy/mock on it. The production bundle (built by ncc) uses the real ESM module — verified by grepping for pathValidation and CacheIntegrityError symbols in the bundled dist/restore/index.js.