cache/__tests__/restoreImpl.test.ts
2026-05-20 12:24:44 -10:00

729 lines
24 KiB
TypeScript

import * as cache from "@actions/cache";
import * as core from "@actions/core";
import { Events, Inputs, RefKey } from "../src/constants";
import { restoreImpl } from "../src/restoreImpl";
import { StateProvider } from "../src/stateProvider";
import * as actionUtils from "../src/utils/actionUtils";
import * as testUtils from "../src/utils/testUtils";
jest.mock("../src/utils/actionUtils");
beforeAll(() => {
jest.spyOn(actionUtils, "isExactKeyMatch").mockImplementation(
(key, cacheResult) => {
const actualUtils = jest.requireActual("../src/utils/actionUtils");
return actualUtils.isExactKeyMatch(key, cacheResult);
}
);
jest.spyOn(actionUtils, "isValidEvent").mockImplementation(() => {
const actualUtils = jest.requireActual("../src/utils/actionUtils");
return actualUtils.isValidEvent();
});
jest.spyOn(actionUtils, "getInputAsArray").mockImplementation(
(name, options) => {
const actualUtils = jest.requireActual("../src/utils/actionUtils");
return actualUtils.getInputAsArray(name, options);
}
);
jest.spyOn(actionUtils, "getInputAsBool").mockImplementation(
(name, options) => {
const actualUtils = jest.requireActual("../src/utils/actionUtils");
return actualUtils.getInputAsBool(name, options);
}
);
jest.spyOn(actionUtils, "getPathValidationInput").mockImplementation(() => {
const actualUtils = jest.requireActual("../src/utils/actionUtils");
return actualUtils.getPathValidationInput();
});
jest.spyOn(actionUtils, "logWarning").mockImplementation(message => {
const actualUtils = jest.requireActual("../src/utils/actionUtils");
return actualUtils.logWarning(message);
});
});
beforeEach(() => {
jest.restoreAllMocks();
process.env[Events.Key] = Events.Push;
process.env[RefKey] = "refs/heads/feature-branch";
jest.spyOn(actionUtils, "isGhes").mockImplementation(() => false);
jest.spyOn(actionUtils, "isCacheFeatureAvailable").mockImplementation(
() => true
);
});
afterEach(() => {
testUtils.clearInputs();
delete process.env[Events.Key];
delete process.env[RefKey];
});
test("restore with invalid event outputs warning", async () => {
const logWarningMock = jest.spyOn(actionUtils, "logWarning");
const failedMock = jest.spyOn(core, "setFailed");
const invalidEvent = "commit_comment";
process.env[Events.Key] = invalidEvent;
delete process.env[RefKey];
await restoreImpl(new StateProvider());
expect(logWarningMock).toHaveBeenCalledWith(
`Event Validation Error: The event type ${invalidEvent} is not supported because it's not tied to a branch or tag ref.`
);
expect(failedMock).toHaveBeenCalledTimes(0);
});
test("restore without AC available should no-op", async () => {
jest.spyOn(actionUtils, "isGhes").mockImplementation(() => false);
jest.spyOn(actionUtils, "isCacheFeatureAvailable").mockImplementation(
() => false
);
const restoreCacheMock = jest.spyOn(cache, "restoreCache");
const setCacheHitOutputMock = jest.spyOn(core, "setOutput");
await restoreImpl(new StateProvider());
expect(restoreCacheMock).toHaveBeenCalledTimes(0);
expect(setCacheHitOutputMock).toHaveBeenCalledTimes(1);
expect(setCacheHitOutputMock).toHaveBeenCalledWith("cache-hit", "false");
});
test("restore on GHES without AC available should no-op", async () => {
jest.spyOn(actionUtils, "isGhes").mockImplementation(() => true);
jest.spyOn(actionUtils, "isCacheFeatureAvailable").mockImplementation(
() => false
);
const restoreCacheMock = jest.spyOn(cache, "restoreCache");
const setCacheHitOutputMock = jest.spyOn(core, "setOutput");
await restoreImpl(new StateProvider());
expect(restoreCacheMock).toHaveBeenCalledTimes(0);
expect(setCacheHitOutputMock).toHaveBeenCalledTimes(1);
expect(setCacheHitOutputMock).toHaveBeenCalledWith("cache-hit", "false");
});
test("restore on GHES with AC available ", async () => {
jest.spyOn(actionUtils, "isGhes").mockImplementation(() => true);
const path = "node_modules";
const key = "node-test";
testUtils.setInputs({
path: path,
key,
enableCrossOsArchive: false
});
const infoMock = jest.spyOn(core, "info");
const failedMock = jest.spyOn(core, "setFailed");
const stateMock = jest.spyOn(core, "saveState");
const setCacheHitOutputMock = jest.spyOn(core, "setOutput");
const restoreCacheMock = jest
.spyOn(cache, "restoreCache")
.mockImplementationOnce(() => {
return Promise.resolve(key);
});
await restoreImpl(new StateProvider());
expect(restoreCacheMock).toHaveBeenCalledTimes(1);
expect(restoreCacheMock).toHaveBeenCalledWith(
[path],
key,
[],
{
lookupOnly: false,
pathValidation: "warn"
},
false
);
expect(stateMock).toHaveBeenCalledWith("CACHE_KEY", key);
expect(setCacheHitOutputMock).toHaveBeenCalledTimes(1);
expect(setCacheHitOutputMock).toHaveBeenCalledWith("cache-hit", "true");
expect(infoMock).toHaveBeenCalledWith(`Cache restored from key: ${key}`);
expect(failedMock).toHaveBeenCalledTimes(0);
});
test("restore with no path should fail", async () => {
const failedMock = jest.spyOn(core, "setFailed");
const restoreCacheMock = jest.spyOn(cache, "restoreCache");
await restoreImpl(new StateProvider());
expect(restoreCacheMock).toHaveBeenCalledTimes(0);
// this input isn't necessary for restore b/c tarball contains entries relative to workspace
expect(failedMock).not.toHaveBeenCalledWith(
"Input required and not supplied: path"
);
});
test("restore with no key", async () => {
testUtils.setInput(Inputs.Path, "node_modules");
const failedMock = jest.spyOn(core, "setFailed");
const restoreCacheMock = jest.spyOn(cache, "restoreCache");
await restoreImpl(new StateProvider());
expect(restoreCacheMock).toHaveBeenCalledTimes(0);
expect(failedMock).toHaveBeenCalledWith(
"Input required and not supplied: key"
);
});
test("restore with too many keys should fail", async () => {
const path = "node_modules";
const key = "node-test";
const restoreKeys = [...Array(20).keys()].map(x => x.toString());
testUtils.setInputs({
path: path,
key,
restoreKeys,
enableCrossOsArchive: false
});
const failedMock = jest.spyOn(core, "setFailed");
const restoreCacheMock = jest.spyOn(cache, "restoreCache");
await restoreImpl(new StateProvider());
expect(restoreCacheMock).toHaveBeenCalledTimes(1);
expect(restoreCacheMock).toHaveBeenCalledWith(
[path],
key,
restoreKeys,
{
lookupOnly: false,
pathValidation: "warn"
},
false
);
expect(failedMock).toHaveBeenCalledWith(
`Key Validation Error: Keys are limited to a maximum of 10.`
);
});
test("restore with large key should fail", async () => {
const path = "node_modules";
const key = "foo".repeat(512); // Over the 512 character limit
testUtils.setInputs({
path: path,
key,
enableCrossOsArchive: false
});
const failedMock = jest.spyOn(core, "setFailed");
const restoreCacheMock = jest.spyOn(cache, "restoreCache");
await restoreImpl(new StateProvider());
expect(restoreCacheMock).toHaveBeenCalledTimes(1);
expect(restoreCacheMock).toHaveBeenCalledWith(
[path],
key,
[],
{
lookupOnly: false,
pathValidation: "warn"
},
false
);
expect(failedMock).toHaveBeenCalledWith(
`Key Validation Error: ${key} cannot be larger than 512 characters.`
);
});
test("restore with invalid key should fail", async () => {
const path = "node_modules";
const key = "comma,comma";
testUtils.setInputs({
path: path,
key,
enableCrossOsArchive: false
});
const failedMock = jest.spyOn(core, "setFailed");
const restoreCacheMock = jest.spyOn(cache, "restoreCache");
await restoreImpl(new StateProvider());
expect(restoreCacheMock).toHaveBeenCalledTimes(1);
expect(restoreCacheMock).toHaveBeenCalledWith(
[path],
key,
[],
{
lookupOnly: false,
pathValidation: "warn"
},
false
);
expect(failedMock).toHaveBeenCalledWith(
`Key Validation Error: ${key} cannot contain commas.`
);
});
test("restore with no cache found", async () => {
const path = "node_modules";
const key = "node-test";
testUtils.setInputs({
path: path,
key,
enableCrossOsArchive: false
});
const infoMock = jest.spyOn(core, "info");
const failedMock = jest.spyOn(core, "setFailed");
const stateMock = jest.spyOn(core, "saveState");
const restoreCacheMock = jest
.spyOn(cache, "restoreCache")
.mockImplementationOnce(() => {
return Promise.resolve(undefined);
});
await restoreImpl(new StateProvider());
expect(restoreCacheMock).toHaveBeenCalledTimes(1);
expect(restoreCacheMock).toHaveBeenCalledWith(
[path],
key,
[],
{
lookupOnly: false,
pathValidation: "warn"
},
false
);
expect(stateMock).toHaveBeenCalledWith("CACHE_KEY", key);
expect(failedMock).toHaveBeenCalledTimes(0);
expect(infoMock).toHaveBeenCalledWith(
`Cache not found for input keys: ${key}`
);
});
test("restore with restore keys and no cache found", async () => {
const path = "node_modules";
const key = "node-test";
const restoreKey = "node-";
testUtils.setInputs({
path: path,
key,
restoreKeys: [restoreKey],
enableCrossOsArchive: false
});
const infoMock = jest.spyOn(core, "info");
const failedMock = jest.spyOn(core, "setFailed");
const stateMock = jest.spyOn(core, "saveState");
const restoreCacheMock = jest
.spyOn(cache, "restoreCache")
.mockImplementationOnce(() => {
return Promise.resolve(undefined);
});
await restoreImpl(new StateProvider());
expect(restoreCacheMock).toHaveBeenCalledTimes(1);
expect(restoreCacheMock).toHaveBeenCalledWith(
[path],
key,
[restoreKey],
{
lookupOnly: false,
pathValidation: "warn"
},
false
);
expect(stateMock).toHaveBeenCalledWith("CACHE_KEY", key);
expect(failedMock).toHaveBeenCalledTimes(0);
expect(infoMock).toHaveBeenCalledWith(
`Cache not found for input keys: ${key}, ${restoreKey}`
);
});
test("restore with cache found for key", async () => {
const path = "node_modules";
const key = "node-test";
testUtils.setInputs({
path: path,
key,
enableCrossOsArchive: false
});
const infoMock = jest.spyOn(core, "info");
const failedMock = jest.spyOn(core, "setFailed");
const stateMock = jest.spyOn(core, "saveState");
const setCacheHitOutputMock = jest.spyOn(core, "setOutput");
const restoreCacheMock = jest
.spyOn(cache, "restoreCache")
.mockImplementationOnce(() => {
return Promise.resolve(key);
});
await restoreImpl(new StateProvider());
expect(restoreCacheMock).toHaveBeenCalledTimes(1);
expect(restoreCacheMock).toHaveBeenCalledWith(
[path],
key,
[],
{
lookupOnly: false,
pathValidation: "warn"
},
false
);
expect(stateMock).toHaveBeenCalledWith("CACHE_KEY", key);
expect(setCacheHitOutputMock).toHaveBeenCalledTimes(1);
expect(setCacheHitOutputMock).toHaveBeenCalledWith("cache-hit", "true");
expect(infoMock).toHaveBeenCalledWith(`Cache restored from key: ${key}`);
expect(failedMock).toHaveBeenCalledTimes(0);
});
test("restore with cache found for restore key", async () => {
const path = "node_modules";
const key = "node-test";
const restoreKey = "node-";
testUtils.setInputs({
path: path,
key,
restoreKeys: [restoreKey],
enableCrossOsArchive: false
});
const infoMock = jest.spyOn(core, "info");
const failedMock = jest.spyOn(core, "setFailed");
const stateMock = jest.spyOn(core, "saveState");
const setCacheHitOutputMock = jest.spyOn(core, "setOutput");
const restoreCacheMock = jest
.spyOn(cache, "restoreCache")
.mockImplementationOnce(() => {
return Promise.resolve(restoreKey);
});
await restoreImpl(new StateProvider());
expect(restoreCacheMock).toHaveBeenCalledTimes(1);
expect(restoreCacheMock).toHaveBeenCalledWith(
[path],
key,
[restoreKey],
{
lookupOnly: false,
pathValidation: "warn"
},
false
);
expect(stateMock).toHaveBeenCalledWith("CACHE_KEY", key);
expect(setCacheHitOutputMock).toHaveBeenCalledTimes(1);
expect(setCacheHitOutputMock).toHaveBeenCalledWith("cache-hit", "false");
expect(infoMock).toHaveBeenCalledWith(
`Cache restored from key: ${restoreKey}`
);
expect(failedMock).toHaveBeenCalledTimes(0);
});
test("restore with lookup-only set", async () => {
const path = "node_modules";
const key = "node-test";
testUtils.setInputs({
path: path,
key,
lookupOnly: true
});
const infoMock = jest.spyOn(core, "info");
const failedMock = jest.spyOn(core, "setFailed");
const stateMock = jest.spyOn(core, "saveState");
const setCacheHitOutputMock = jest.spyOn(core, "setOutput");
const restoreCacheMock = jest
.spyOn(cache, "restoreCache")
.mockImplementationOnce(() => {
return Promise.resolve(key);
});
await restoreImpl(new StateProvider());
expect(restoreCacheMock).toHaveBeenCalledTimes(1);
expect(restoreCacheMock).toHaveBeenCalledWith(
[path],
key,
[],
{
lookupOnly: true,
pathValidation: "warn"
},
false
);
expect(stateMock).toHaveBeenCalledWith("CACHE_KEY", key);
expect(stateMock).toHaveBeenCalledWith("CACHE_RESULT", key);
expect(stateMock).toHaveBeenCalledTimes(2);
expect(setCacheHitOutputMock).toHaveBeenCalledTimes(1);
expect(setCacheHitOutputMock).toHaveBeenCalledWith("cache-hit", "true");
expect(infoMock).toHaveBeenCalledWith(
`Cache found and can be restored from key: ${key}`
);
expect(failedMock).toHaveBeenCalledTimes(0);
});
test("restore failure with earlyExit should call process exit", async () => {
testUtils.setInput(Inputs.Path, "node_modules");
const failedMock = jest.spyOn(core, "setFailed");
const restoreCacheMock = jest.spyOn(cache, "restoreCache");
const processExitMock = jest.spyOn(process, "exit").mockImplementation();
// call restoreImpl with `earlyExit` set to true
await restoreImpl(new StateProvider(), true);
expect(restoreCacheMock).toHaveBeenCalledTimes(0);
expect(failedMock).toHaveBeenCalledWith(
"Input required and not supplied: key"
);
expect(processExitMock).toHaveBeenCalledWith(1);
});
// ---------------------------------------------------------------------------
// Path validation tests
//
// These tests verify that the action correctly forwards the `strict-paths`
// input to the @actions/cache toolkit and handles `CacheIntegrityError`
// rejections according to the `fail-on-cache-invalid` input.
// ---------------------------------------------------------------------------
test("restore defaults strict-paths to 'warn' and forwards it to restoreCache", async () => {
const path = "node_modules";
const key = "node-test";
testUtils.setInputs({ path, key });
const restoreCacheMock = jest
.spyOn(cache, "restoreCache")
.mockResolvedValueOnce(key);
await restoreImpl(new StateProvider());
expect(restoreCacheMock).toHaveBeenCalledTimes(1);
expect(restoreCacheMock).toHaveBeenCalledWith(
[path],
key,
[],
{ lookupOnly: false, pathValidation: "warn" },
false
);
});
test.each(["off", "warn", "error"])(
"restore forwards strict-paths value '%s' to restoreCache",
async value => {
const path = "node_modules";
const key = "node-test";
testUtils.setInputs({ path, key, strictPaths: value });
const restoreCacheMock = jest
.spyOn(cache, "restoreCache")
.mockResolvedValueOnce(key);
await restoreImpl(new StateProvider());
expect(restoreCacheMock).toHaveBeenCalledTimes(1);
expect(restoreCacheMock).toHaveBeenCalledWith(
[path],
key,
[],
{ lookupOnly: false, pathValidation: value },
false
);
}
);
test("restore falls back to 'warn' when strict-paths input is unrecognized", async () => {
const path = "node_modules";
const key = "node-test";
testUtils.setInputs({ path, key, strictPaths: "STRICT" });
const restoreCacheMock = jest
.spyOn(cache, "restoreCache")
.mockResolvedValueOnce(key);
// getPathValidationInput() emits the misconfiguration notice via
// core.warning() so it surfaces as a real `::warning::` workflow
// annotation. Suppress the real implementation to keep the Jest log
// clean while asserting it was called.
const warningMock = jest
.spyOn(core, "warning")
.mockImplementation(() => undefined);
await restoreImpl(new StateProvider());
expect(restoreCacheMock).toHaveBeenCalledWith(
[path],
key,
[],
{ lookupOnly: false, pathValidation: "warn" },
false
);
expect(warningMock).toHaveBeenCalledWith(
expect.stringContaining("Unrecognized value for strict-paths")
);
});
test("restore treats CacheIntegrityError as a cache miss by default", async () => {
const path = "node_modules";
const key = "node-test";
testUtils.setInputs({ path, key, strictPaths: "error" });
const integrityError = new Error("entries escape declared paths");
integrityError.name = "CacheIntegrityError";
(integrityError as Error & { code?: string }).code = "PATH_VIOLATION";
const restoreCacheMock = jest
.spyOn(cache, "restoreCache")
.mockRejectedValueOnce(integrityError);
const setOutputMock = jest.spyOn(core, "setOutput");
const failedMock = jest.spyOn(core, "setFailed");
// Suppress the real logWarning so the discarded-cache warning does not
// pollute test output. beforeEach's jest.restoreAllMocks() handles
// cross-test cleanup.
const logWarningMock = jest
.spyOn(actionUtils, "logWarning")
.mockImplementation(() => undefined);
await restoreImpl(new StateProvider());
expect(restoreCacheMock).toHaveBeenCalledTimes(1);
// Intentionally NOT set: a discarded cache must look identical to a
// regular cache miss to downstream `if:` checks (see issue #1466).
const cacheHitCalls = setOutputMock.mock.calls.filter(
c => c[0] === "cache-hit"
);
expect(cacheHitCalls).toHaveLength(0);
expect(failedMock).not.toHaveBeenCalled();
expect(logWarningMock).toHaveBeenCalledWith(
expect.stringContaining("PATH_VIOLATION")
);
});
test("restore fails when CacheIntegrityError is raised and fail-on-cache-invalid is true", async () => {
const path = "node_modules";
const key = "node-test";
testUtils.setInputs({
path,
key,
strictPaths: "error",
failOnCacheInvalid: true
});
const integrityError = new Error("entry escapes workspace");
integrityError.name = "CacheIntegrityError";
(integrityError as Error & { code?: string }).code = "PATH_VIOLATION";
jest.spyOn(cache, "restoreCache").mockRejectedValueOnce(integrityError);
const failedMock = jest.spyOn(core, "setFailed");
await restoreImpl(new StateProvider());
expect(failedMock).toHaveBeenCalledTimes(1);
expect(failedMock.mock.calls[0][0]).toContain("integrity validation");
expect(failedMock.mock.calls[0][0]).toContain("PATH_VIOLATION");
});
test("restore propagates non-integrity errors normally", async () => {
const path = "node_modules";
const key = "node-test";
testUtils.setInputs({ path, key });
const networkError = new Error("Network timeout");
jest.spyOn(cache, "restoreCache").mockRejectedValueOnce(networkError);
const failedMock = jest.spyOn(core, "setFailed");
const logWarningMock = jest
.spyOn(actionUtils, "logWarning")
.mockImplementation(() => undefined);
await restoreImpl(new StateProvider());
expect(failedMock).toHaveBeenCalledWith("Network timeout");
expect(logWarningMock).not.toHaveBeenCalledWith(
expect.stringContaining("integrity")
);
});
test("restore parse-error integrity failure also treated as miss by default", async () => {
const path = "node_modules";
const key = "node-test";
testUtils.setInputs({ path, key, strictPaths: "error" });
const parseError = new Error("malformed gzip header");
parseError.name = "CacheIntegrityError";
(parseError as Error & { code?: string }).code = "PARSE_ERROR";
jest.spyOn(cache, "restoreCache").mockRejectedValueOnce(parseError);
const setOutputMock = jest.spyOn(core, "setOutput");
const failedMock = jest.spyOn(core, "setFailed");
const logWarningMock = jest
.spyOn(actionUtils, "logWarning")
.mockImplementation(() => undefined);
await restoreImpl(new StateProvider());
const cacheHitCalls = setOutputMock.mock.calls.filter(
c => c[0] === "cache-hit"
);
expect(cacheHitCalls).toHaveLength(0);
expect(failedMock).not.toHaveBeenCalled();
expect(logWarningMock).toHaveBeenCalledWith(
expect.stringContaining("PARSE_ERROR")
);
});
test("restore tolerates CacheIntegrityError without explicit code", async () => {
const path = "node_modules";
const key = "node-test";
testUtils.setInputs({ path, key, strictPaths: "error" });
const integrityError = new Error("bad archive");
integrityError.name = "CacheIntegrityError";
// intentionally no .code property
jest.spyOn(cache, "restoreCache").mockRejectedValueOnce(integrityError);
const setOutputMock = jest.spyOn(core, "setOutput");
const logWarningMock = jest
.spyOn(actionUtils, "logWarning")
.mockImplementation(() => undefined);
await restoreImpl(new StateProvider());
const cacheHitCalls = setOutputMock.mock.calls.filter(
c => c[0] === "cache-hit"
);
expect(cacheHitCalls).toHaveLength(0);
expect(logWarningMock).toHaveBeenCalledWith(
expect.stringContaining("unknown")
);
});
test("restore does not set cache-hit output when integrity error is rethrown", async () => {
const path = "node_modules";
const key = "node-test";
testUtils.setInputs({
path,
key,
strictPaths: "error",
failOnCacheInvalid: true
});
const integrityError = new Error("rejected");
integrityError.name = "CacheIntegrityError";
(integrityError as Error & { code?: string }).code = "PATH_VIOLATION";
jest.spyOn(cache, "restoreCache").mockRejectedValueOnce(integrityError);
const setOutputMock = jest.spyOn(core, "setOutput");
await restoreImpl(new StateProvider());
// setOutput should NOT have been called with cache-hit at all in this path
const cacheHitCalls = setOutputMock.mock.calls.filter(
c => c[0] === "cache-hit"
);
expect(cacheHitCalls).toHaveLength(0);
});