diff --git a/__tests__/actionUtils.test.ts b/__tests__/actionUtils.test.ts index c2e6823..e16dd4e 100644 --- a/__tests__/actionUtils.test.ts +++ b/__tests__/actionUtils.test.ts @@ -1,5 +1,7 @@ import * as cache from "@actions/cache"; import * as core from "@actions/core"; +import { RequestError } from "@octokit/request-error"; +import nock from "nock"; import { Events, RefKey } from "../src/constants"; import * as actionUtils from "../src/utils/actionUtils"; @@ -12,9 +14,13 @@ let pristineEnv: NodeJS.ProcessEnv; beforeAll(() => { pristineEnv = process.env; + nock.disableNetConnect(); jest.spyOn(core, "getInput").mockImplementation((name, options) => { return jest.requireActual("@actions/core").getInput(name, options); }); + testUtils.mockServer.listen({ + onUnhandledRequest: "warn" + }); }); beforeEach(() => { @@ -22,10 +28,15 @@ beforeEach(() => { process.env = pristineEnv; delete process.env[Events.Key]; delete process.env[RefKey]; + delete process.env["GITHUB_REPOSITORY"]; + delete process.env["GITHUB_TOKEN"]; + delete process.env["GITHUB_ACTION"]; }); afterAll(() => { process.env = pristineEnv; + testUtils.mockServer.close(); + nock.enableNetConnect(); }); test("isGhes returns true if server url is not github.com", () => { @@ -203,6 +214,94 @@ test("getInputAsBool throws if required and value missing", () => { ).toThrowError(); }); +test("deleteCacheByKey returns 'HttpError: 404' when cache is not found.", async () => { + const event = Events.Push; + + process.env["GITHUB_REPOSITORY"] = "owner/repo"; + process.env["GITHUB_TOKEN"] = + "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"; + process.env["GITHUB_ACTION"] = "__owner___run-repo"; + process.env[Events.Key] = event; + process.env[RefKey] = "ref/heads/feature"; + const logWarningMock = jest.spyOn(actionUtils, "logWarning"); + const response = await actionUtils.deleteCacheByKey( + testUtils.failureCacheKey, + "owner", + "repo" + ); + expect(logWarningMock).toHaveBeenCalledWith( + expect.stringMatching(/404: Not Found/i) + ); + expect(response).toBeInstanceOf(RequestError); + expect(response).toMatchObject({ + name: "HttpError", + status: 404 + }); +}); + +test("deleteCacheByKey returns 'HttpError: 401' on an invalid non-mocked request.", async () => { + const event = Events.Push; + + process.env["GITHUB_REPOSITORY"] = "owner/repo"; + process.env["GITHUB_TOKEN"] = + "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"; + process.env["GITHUB_ACTION"] = "__owner___run-repo"; + process.env[Events.Key] = event; + process.env[RefKey] = "ref/heads/feature"; + await nock.enableNetConnect(); + const logWarningMock = jest.spyOn(actionUtils, "logWarning"); + const response = await actionUtils.deleteCacheByKey( + testUtils.passThroughCacheKey, + "owner", + "repo" + ); + expect(logWarningMock).toHaveBeenCalledWith( + expect.stringMatching(/401: Bad Credentials/i) + ); + expect(response).toBeInstanceOf(RequestError); + expect(response).toMatchObject({ + name: "HttpError", + status: 401 + }); + nock.disableNetConnect(); +}); + +test("deleteCacheByKey returns matched cache data when successful.", async () => { + const event = Events.Push; + + process.env["GITHUB_REPOSITORY"] = "owner/repo"; + process.env["GITHUB_TOKEN"] = + "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"; + process.env["GITHUB_ACTION"] = "__owner___run-repo"; + process.env[Events.Key] = event; + process.env[RefKey] = "ref/heads/feature"; + + const expectedResponse = { + id: expect.any(Number), + ref: expect.any(String), + key: expect.any(String), + version: expect.any(String), + last_accessed_at: expect.any(String), + created_at: expect.any(String), + size_in_bytes: expect.any(Number) + }; + const logWarningMock = jest.spyOn(actionUtils, "logWarning"); + const response = await actionUtils.deleteCacheByKey( + testUtils.successCacheKey, + "owner", + "repo" + ); + expect(response).toMatchObject({ + data: expect.objectContaining({ + total_count: expect.any(Number), + actions_caches: expect.arrayContaining([ + expect.objectContaining(expectedResponse) + ]) + }) + }); + expect(logWarningMock).toHaveBeenCalledTimes(0); +}); + test("isCacheFeatureAvailable for ac enabled", () => { jest.spyOn(cache, "isFeatureAvailable").mockImplementation(() => true); diff --git a/__tests__/restore.test.ts b/__tests__/restore.test.ts index 250f7ef..8324397 100644 --- a/__tests__/restore.test.ts +++ b/__tests__/restore.test.ts @@ -1,5 +1,6 @@ import * as cache from "@actions/cache"; import * as core from "@actions/core"; +import nock from "nock"; import { Events, RefKey } from "../src/constants"; import { restoreRun } from "../src/restoreImpl"; @@ -9,6 +10,7 @@ import * as testUtils from "../src/utils/testUtils"; jest.mock("../src/utils/actionUtils"); beforeAll(() => { + nock.disableNetConnect(); jest.spyOn(actionUtils, "isExactKeyMatch").mockImplementation( (key, cacheResult) => { const actualUtils = jest.requireActual("../src/utils/actionUtils"); @@ -53,6 +55,10 @@ afterEach(() => { delete process.env[RefKey]; }); +afterAll(() => { + nock.enableNetConnect(); +}); + test("restore with no cache found", async () => { const path = "node_modules"; const key = "node-test"; diff --git a/__tests__/restoreImpl.test.ts b/__tests__/restoreImpl.test.ts index 16f5f72..38bd9b8 100644 --- a/__tests__/restoreImpl.test.ts +++ b/__tests__/restoreImpl.test.ts @@ -1,5 +1,6 @@ import * as cache from "@actions/cache"; import * as core from "@actions/core"; +import nock from "nock"; import { Events, Inputs, RefKey } from "../src/constants"; import { restoreImpl } from "../src/restoreImpl"; @@ -10,6 +11,7 @@ import * as testUtils from "../src/utils/testUtils"; jest.mock("../src/utils/actionUtils"); beforeAll(() => { + nock.disableNetConnect(); jest.spyOn(actionUtils, "isExactKeyMatch").mockImplementation( (key, cacheResult) => { const actualUtils = jest.requireActual("../src/utils/actionUtils"); @@ -54,6 +56,10 @@ afterEach(() => { delete process.env[RefKey]; }); +afterAll(() => { + nock.enableNetConnect(); +}); + test("restore with invalid event outputs warning", async () => { const logWarningMock = jest.spyOn(actionUtils, "logWarning"); const failedMock = jest.spyOn(core, "setFailed"); diff --git a/__tests__/restoreOnly.test.ts b/__tests__/restoreOnly.test.ts index 81e5bca..964ac09 100644 --- a/__tests__/restoreOnly.test.ts +++ b/__tests__/restoreOnly.test.ts @@ -1,5 +1,6 @@ import * as cache from "@actions/cache"; import * as core from "@actions/core"; +import nock from "nock"; import { Events, RefKey } from "../src/constants"; import { restoreOnlyRun } from "../src/restoreImpl"; @@ -9,6 +10,7 @@ import * as testUtils from "../src/utils/testUtils"; jest.mock("../src/utils/actionUtils"); beforeAll(() => { + nock.disableNetConnect(); jest.spyOn(actionUtils, "isExactKeyMatch").mockImplementation( (key, cacheResult) => { const actualUtils = jest.requireActual("../src/utils/actionUtils"); @@ -54,6 +56,10 @@ afterEach(() => { delete process.env[RefKey]; }); +afterAll(() => { + nock.enableNetConnect(); +}); + test("restore with no cache found", async () => { const path = "node_modules"; const key = "node-test"; diff --git a/__tests__/save.test.ts b/__tests__/save.test.ts index 4678c43..6139b5c 100644 --- a/__tests__/save.test.ts +++ b/__tests__/save.test.ts @@ -1,5 +1,6 @@ import * as cache from "@actions/cache"; import * as core from "@actions/core"; +import nock from "nock"; import { Events, Inputs, RefKey } from "../src/constants"; import { saveRun } from "../src/saveImpl"; @@ -11,6 +12,7 @@ jest.mock("@actions/cache"); jest.mock("../src/utils/actionUtils"); beforeAll(() => { + nock.disableNetConnect(); jest.spyOn(core, "getInput").mockImplementation((name, options) => { return jest.requireActual("@actions/core").getInput(name, options); }); @@ -73,10 +75,14 @@ afterEach(() => { delete process.env[RefKey]; }); +afterAll(() => { + nock.enableNetConnect(); +}); + test("save with valid inputs uploads a cache", async () => { const failedMock = jest.spyOn(core, "setFailed"); - const primaryKey = "Linux-node-bb828da54c148048dd17899ba9fda624811cfb43"; + const primaryKey = testUtils.successCacheKey; const savedCacheKey = "Linux-node-"; jest.spyOn(core, "getState") diff --git a/__tests__/saveImpl.test.ts b/__tests__/saveImpl.test.ts index ad154f6..42a4d4b 100644 --- a/__tests__/saveImpl.test.ts +++ b/__tests__/saveImpl.test.ts @@ -1,5 +1,6 @@ import * as cache from "@actions/cache"; import * as core from "@actions/core"; +import nock from "nock"; import { Events, Inputs, RefKey } from "../src/constants"; import { saveImpl } from "../src/saveImpl"; @@ -12,6 +13,19 @@ jest.mock("@actions/cache"); jest.mock("../src/utils/actionUtils"); beforeAll(() => { + nock.disableNetConnect(); + testUtils.mockServer.listen({ + onUnhandledRequest: "warn" + }); + + jest.spyOn(actionUtils, "deleteCacheByKey").mockImplementation( + (key: string, owner: string, repo: string) => { + return jest + .requireActual("../src/utils/actionUtils") + .deleteCacheByKey(key, owner, repo); + } + ); + jest.spyOn(core, "getInput").mockImplementation((name, options) => { return jest.requireActual("@actions/core").getInput(name, options); }); @@ -52,6 +66,14 @@ beforeAll(() => { const actualUtils = jest.requireActual("../src/utils/actionUtils"); return actualUtils.isValidEvent(); }); + + jest.spyOn(actionUtils, "logWarning").mockImplementation( + (message: string) => { + return jest + .requireActual("../src/utils/actionUtils") + .logWarning(message); + } + ); }); beforeEach(() => { @@ -69,6 +91,13 @@ afterEach(() => { testUtils.clearInputs(); delete process.env[Events.Key]; delete process.env[RefKey]; + delete process.env["GITHUB_TOKEN"]; + delete process.env["GITHUB_REPOSITORY"]; +}); + +afterAll(() => { + testUtils.mockServer.close(); + nock.enableNetConnect(); }); test("save with invalid event outputs warning", async () => { @@ -88,7 +117,7 @@ test("save with no primary key in state outputs warning", async () => { const logWarningMock = jest.spyOn(actionUtils, "logWarning"); const failedMock = jest.spyOn(core, "setFailed"); - const savedCacheKey = "Linux-node-bb828da54c148048dd17899ba9fda624811cfb43"; + const savedCacheKey = testUtils.successCacheKey; jest.spyOn(core, "getState") // Cache Entry State .mockImplementationOnce(() => { @@ -137,7 +166,7 @@ test("save on GHES with AC available", async () => { jest.spyOn(actionUtils, "isGhes").mockImplementation(() => true); const failedMock = jest.spyOn(core, "setFailed"); - const primaryKey = "Linux-node-bb828da54c148048dd17899ba9fda624811cfb43"; + const primaryKey = testUtils.successCacheKey; const savedCacheKey = "Linux-node-"; jest.spyOn(core, "getState") @@ -179,8 +208,10 @@ test("save on GHES with AC available", async () => { test("save with exact match returns early", async () => { const infoMock = jest.spyOn(core, "info"); const failedMock = jest.spyOn(core, "setFailed"); + testUtils.setInput(Inputs.RefreshCache, "false"); + + const primaryKey = testUtils.successCacheKey; - const primaryKey = "Linux-node-bb828da54c148048dd17899ba9fda624811cfb43"; const savedCacheKey = primaryKey; jest.spyOn(core, "getState") @@ -207,7 +238,7 @@ test("save with missing input outputs warning", async () => { const logWarningMock = jest.spyOn(actionUtils, "logWarning"); const failedMock = jest.spyOn(core, "setFailed"); - const primaryKey = "Linux-node-bb828da54c148048dd17899ba9fda624811cfb43"; + const primaryKey = testUtils.successCacheKey; const savedCacheKey = "Linux-node-"; jest.spyOn(core, "getState") @@ -235,7 +266,7 @@ test("save with large cache outputs warning", async () => { const logWarningMock = jest.spyOn(actionUtils, "logWarning"); const failedMock = jest.spyOn(core, "setFailed"); - const primaryKey = "Linux-node-bb828da54c148048dd17899ba9fda624811cfb43"; + const primaryKey = testUtils.successCacheKey; const savedCacheKey = "Linux-node-"; jest.spyOn(core, "getState") @@ -280,7 +311,7 @@ test("save with reserve cache failure outputs warning", async () => { const logWarningMock = jest.spyOn(actionUtils, "logWarning"); const failedMock = jest.spyOn(core, "setFailed"); - const primaryKey = "Linux-node-bb828da54c148048dd17899ba9fda624811cfb43"; + const primaryKey = testUtils.successCacheKey; const savedCacheKey = "Linux-node-"; jest.spyOn(core, "getState") @@ -327,7 +358,7 @@ test("save with server error outputs warning", async () => { const logWarningMock = jest.spyOn(actionUtils, "logWarning"); const failedMock = jest.spyOn(core, "setFailed"); - const primaryKey = "Linux-node-bb828da54c148048dd17899ba9fda624811cfb43"; + const primaryKey = testUtils.successCacheKey; const savedCacheKey = "Linux-node-"; jest.spyOn(core, "getState") @@ -368,7 +399,7 @@ test("save with server error outputs warning", async () => { test("save with valid inputs uploads a cache", async () => { const failedMock = jest.spyOn(core, "setFailed"); - const primaryKey = "Linux-node-bb828da54c148048dd17899ba9fda624811cfb43"; + const primaryKey = testUtils.successCacheKey; const savedCacheKey = "Linux-node-"; jest.spyOn(core, "getState") @@ -406,3 +437,98 @@ test("save with valid inputs uploads a cache", async () => { expect(failedMock).toHaveBeenCalledTimes(0); }); + +test("save with cache hit and refresh-cache will try to delete and re-create entry", async () => { + process.env["GITHUB_REPOSITORY"] = "owner/repo"; + process.env["GITHUB_TOKEN"] = + "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"; + process.env["GITHUB_ACTION"] = "__owner___run-repo"; + + const infoMock = jest.spyOn(core, "info"); + const logWarningMock = jest.spyOn(actionUtils, "logWarning"); + const failedMock = jest.spyOn(core, "setFailed"); + + const primaryKey = testUtils.successCacheKey; + const savedCacheKey = primaryKey; + + jest.spyOn(core, "getState") + // Cache Entry State + .mockImplementationOnce(() => { + return savedCacheKey; + }) + .mockImplementationOnce(() => { + return primaryKey; + }); + + const inputPath = "node_modules"; + testUtils.setInput(Inputs.RefreshCache, "true"); + testUtils.setInput(Inputs.Path, inputPath); + testUtils.setInput(Inputs.UploadChunkSize, "4000000"); + + const cacheId = 4; + const saveCacheMock = jest + .spyOn(cache, "saveCache") + .mockImplementationOnce(() => { + return Promise.resolve(cacheId); + }); + await run(new StateProvider()); + + expect(saveCacheMock).toHaveBeenCalledTimes(1); + expect(saveCacheMock).toHaveBeenCalledWith( + [inputPath], + primaryKey, + { + uploadChunkSize: 4000000 + }, + false + ); + + expect(logWarningMock).toHaveBeenCalledTimes(0); + expect(infoMock).toHaveBeenCalledTimes(3); + + expect(infoMock).toHaveBeenNthCalledWith( + 1, + `Cache hit occurred on the primary key ${primaryKey}, attempting to refresh the contents of the cache.` + ); + expect(infoMock).toHaveBeenNthCalledWith( + 2, + `Succesfully deleted cache with key: ${primaryKey}` + ); + expect(infoMock).toHaveBeenNthCalledWith( + 3, + `Cache saved with key: ${primaryKey}` + ); + + expect(failedMock).toHaveBeenCalledTimes(0); +}); + +test("save with cache hit and refresh-cache will throw a warning if there's no GITHUB_TOKEN", async () => { + const logWarningMock = jest.spyOn(actionUtils, "logWarning"); + const failedMock = jest.spyOn(core, "setFailed"); + + const primaryKey = testUtils.successCacheKey; + const savedCacheKey = primaryKey; + + const inputPath = "node_modules"; + testUtils.setInput(Inputs.Path, inputPath); + testUtils.setInput(Inputs.RefreshCache, "true"); + + jest.spyOn(core, "getState") + // Cache Entry State + .mockImplementationOnce(() => { + return savedCacheKey; + }) + // Cache Key State + .mockImplementationOnce(() => { + return primaryKey; + }); + + const saveCacheMock = jest.spyOn(cache, "saveCache"); + await run(new StateProvider()); + + expect(saveCacheMock).toHaveBeenCalledTimes(0); + expect(logWarningMock).toHaveBeenCalledWith( + `Can't refresh cache, either the repository info or a valid token are missing.` + ); + expect(failedMock).toHaveBeenCalledTimes(0); +}); diff --git a/__tests__/saveOnly.test.ts b/__tests__/saveOnly.test.ts index 0437739..8589525 100644 --- a/__tests__/saveOnly.test.ts +++ b/__tests__/saveOnly.test.ts @@ -1,5 +1,6 @@ import * as cache from "@actions/cache"; import * as core from "@actions/core"; +import nock from "nock"; import { Events, Inputs, RefKey } from "../src/constants"; import { saveOnlyRun } from "../src/saveImpl"; @@ -11,6 +12,7 @@ jest.mock("@actions/cache"); jest.mock("../src/utils/actionUtils"); beforeAll(() => { + nock.disableNetConnect(); jest.spyOn(core, "getInput").mockImplementation((name, options) => { return jest.requireActual("@actions/core").getInput(name, options); }); @@ -73,6 +75,10 @@ afterEach(() => { delete process.env[RefKey]; }); +afterAll(() => { + nock.enableNetConnect(); +}); + test("save with valid inputs uploads a cache", async () => { const failedMock = jest.spyOn(core, "setFailed"); diff --git a/jest.config.js b/jest.config.js index 39c1469..dd7d6b2 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,5 +1,3 @@ -require("nock").disableNetConnect(); - module.exports = { clearMocks: true, moduleFileExtensions: ["js", "ts"], diff --git a/package.json b/package.json index c0af23a..9387e0c 100644 --- a/package.json +++ b/package.json @@ -44,11 +44,15 @@ "eslint-plugin-simple-import-sort": "^12.1.1", "jest": "^29.7.0", "jest-circus": "^29.7.0", + "msw": "^0.49.3", "nock": "^13.2.9", "prettier": "^3.6.2", "ts-jest": "^29.4.0", "typescript": "^5.8.3" }, + "overrides": { + "@mswjs/interceptors": "^0.17.7" + }, "engines": { "node": ">=24" } diff --git a/src/utils/testUtils.ts b/src/utils/testUtils.ts index ba0670b..8a24b78 100644 --- a/src/utils/testUtils.ts +++ b/src/utils/testUtils.ts @@ -1,4 +1,12 @@ import { Inputs } from "../constants"; +import { rest } from "msw"; +import { setupServer } from "msw/node"; +import nock from "nock"; + +export const successCacheKey = "Linux-node-bb828da54c148048dd17899ba9fda624811cfb43"; +export const failureCacheKey = "Windows-node-bb828da54c148048dd17899ba9fda624811cfb43"; +export const passThroughCacheKey = "macOS-node-bb828da54c148048dd17899ba9fda624811cfb43"; + // See: https://github.com/actions/toolkit/blob/master/packages/core/src/core.ts#L67 function getInputName(name: string): string { @@ -16,6 +24,7 @@ interface CacheInput { enableCrossOsArchive?: boolean; failOnCacheMiss?: boolean; lookupOnly?: boolean; + refreshCache?: boolean; } export function setInputs(input: CacheInput): void { @@ -32,6 +41,8 @@ export function setInputs(input: CacheInput): void { setInput(Inputs.FailOnCacheMiss, input.failOnCacheMiss.toString()); input.lookupOnly !== undefined && setInput(Inputs.LookupOnly, input.lookupOnly.toString()); + input.refreshCache !== undefined && + setInput(Inputs.RefreshCache, input.refreshCache.toString()); } export function clearInputs(): void { @@ -42,4 +53,34 @@ export function clearInputs(): void { delete process.env[getInputName(Inputs.EnableCrossOsArchive)]; delete process.env[getInputName(Inputs.FailOnCacheMiss)]; delete process.env[getInputName(Inputs.LookupOnly)]; + delete process.env[getInputName(Inputs.RefreshCache)]; } + +/* istanbul ignore next */ +export const mockServer = setupServer(rest.delete('https://api.github.com/repos/owner/repo/actions/caches', (req, res, ctx) => { + if (req.url?.searchParams?.get('key') === failureCacheKey) { + return res(ctx.status(404), + ctx.json({ + message: "Not Found", + documentation_url: "https://docs.github.com/rest/actions/cache#delete-github-actions-caches-for-a-repository-using-a-cache-key" + })); + } + else if (req.url?.searchParams?.get('key') === successCacheKey) { + return res(ctx.status(200), + ctx.json({ + total_count: 1, + actions_caches: [{ + id: 15, + ref: "refs/heads/main", + key: successCacheKey, + version: "93a0f912fdb70083e929c1bf564bca2050be1c4e0932f7f9e78465ddcfbcc8f6", + last_accessed_at: "2022-12-29T22:06:42.683333300Z", + created_at: "2022-12-29T22:06:42.683333300Z", + size_in_bytes: 6057793 + }] + })); + } + else if (req.url?.searchParams?.get('key') === passThroughCacheKey) { + return req.passthrough(); + } +})); \ No newline at end of file