import { afterAll, beforeAll, beforeEach, afterEach, describe, vi, it, expect } from "vitest"; import { GenericContainer, Wait } from "testcontainers"; import * as core from "@actions/core"; import fs from "fs/promises"; import os from "os"; import path from "path"; import crypto from "crypto"; import { restore } from "../src/restore.js"; import { save } from "../src/save.js"; vi.mock("@actions/core"); const fileSha256 = async (filePath) => { const buf = await fs.readFile(filePath); return crypto.createHash("sha256").update(buf).digest("hex"); }; describe("S3 Cache Action Integration Tests", () => { // defaults const s3bucket = "integration-test"; const s3region = "us-east-1"; // env leak prevention const originalEnv = process.env; // endpoint generated by testcontainers let s3mockContainer; let s3endpoint; // tmpdirs used by runner let runnerWorkspace; let runnerTemp; // tmpdirs and files used by action let testDataFolder; let testDataFile; // mocked inputs and states let inputs; let states; beforeAll(async () => { process.env = { ...originalEnv }; // gnutar fix for macos if (os.platform() == "darwin") { const gnuTarPath = os.arch() === "arm64" ? "/opt/homebrew/opt/gnu-tar/libexec/gnubin" : "/usr/local/opt/gnu-tar/libexec/gnubin"; process.env.PATH = `${gnuTarPath}:${process.env.PATH}`; } if (process.env.S3_ENDPOINT) { s3endpoint = process.env.S3_ENDPOINT; } else { // start testcontainers s3mockContainer = await new GenericContainer("adobe/s3mock:5.0.0") .withExposedPorts(9090) .withEnvironment({ COM_ADOBE_TESTING_S3MOCK_STORE_INITIAL_BUCKETS: s3bucket, COM_ADOBE_TESTING_S3MOCK_STORE_REGION: s3region, }) .withWaitStrategy(Wait.forLogMessage(/.*Started S3MockApplication\.Companion in.*/)) .start(); s3endpoint = `http://${s3mockContainer.getHost()}:${s3mockContainer.getMappedPort(9090)}`; } // setup runner environment const workspacePrefix = path.join(os.tmpdir(), "runner-workspace-"); const tempPrefix = path.join(os.tmpdir(), "runner-temp-"); runnerWorkspace = await fs.mkdtemp(workspacePrefix); runnerTemp = await fs.mkdtemp(tempPrefix); process.env.GITHUB_WORKSPACE = runnerWorkspace; process.env.RUNNER_TEMP = runnerTemp; }, 60000); beforeEach(async () => { vi.clearAllMocks(); // create new data folder for each test run const testDataPrefix = path.join(os.tmpdir(), "runner-data-"); testDataFolder = await fs.mkdtemp(testDataPrefix); // default mock inputs inputs = { key: `test-${crypto.randomUUID()}`, path: testDataFolder, "s3-bucket": s3bucket, "s3-endpoint": s3endpoint, "s3-access-key": "mock-access", "s3-secret-key": "mock-secret", "s3-region": s3region, "lookup-only": "false", "fail-on-cache-miss": "false", "restore-keys": "", }; // mock state states = {}; // mock function calls vi.mocked(core.getInput).mockImplementation((name) => inputs[name] || ""); vi.mocked(core.getMultilineInput).mockImplementation((name) => { if (name === "path") { return [inputs["path"]]; } if (name === "restore-keys") { return inputs["restore-keys"] ? inputs["restore-keys"].split("\n") : []; } return []; }); vi.mocked(core.getState).mockImplementation((name) => states[name] || ""); vi.mocked(core.saveState).mockImplementation((name, value) => { states[name] = value; }); // generate test data file with random data testDataFile = path.join(testDataFolder, `${crypto.randomUUID()}`); await fs.writeFile(testDataFile, crypto.randomBytes(4 * 1024 * 1024)); }); afterEach(async () => { await fs.rm(testDataFolder, { force: true, recursive: true }); }); it("should result in cache-miss when key is not found", async () => { await restore(); expect(core.setOutput).toHaveBeenCalledWith("cache-hit", "false"); expect(core.saveState).toHaveBeenCalledWith("exactMatch", "false"); }); it("should save the cache successfully", async () => { await save(); expect(core.info).toHaveBeenCalledWith("Upload complete"); }); it("should restore matched cache exactly", async () => { const originalChecksum = await fileSha256(testDataFile); await save(); await fs.rm(testDataFile); await restore(); expect(core.setOutput).toHaveBeenCalledWith("cache-hit", "true"); expect(core.saveState).toHaveBeenCalledWith("exactMatch", "true"); const restoredChecksum = await fileSha256(testDataFile); expect(restoredChecksum).toBe(originalChecksum); }); it("should skip save if exact match exists", async () => { states["exactMatch"] = "true"; await save(); expect(core.info).toHaveBeenCalledWith("Exact match found, skipping cache upload"); }); it("should match prefix using restore-keys", async () => { inputs["key"] = "test-key-12345"; const originalChecksum = await fileSha256(testDataFile); await save(); await fs.rm(testDataFile); inputs["key"] = "completely-different-key"; inputs["restore-keys"] = "test-key-"; await restore(); expect(core.setOutput).toHaveBeenCalledWith("cache-hit", "false"); expect(core.saveState).toHaveBeenCalledWith("exactMatch", "false"); const restoredChecksum = await fileSha256(testDataFile); expect(restoredChecksum).toBe(originalChecksum); }); it("should respect lookup-only and not extract files", async () => { await save(); await fs.rm(testDataFile); inputs["lookup-only"] = "true"; await restore(); expect(core.setOutput).toHaveBeenCalledWith("cache-hit", "true"); expect(core.saveState).toHaveBeenCalledWith("exactMatch", "true"); await expect(fs.readFile(testDataFile)).rejects.toThrow(/ENOENT/); }); it("should respect fail-on-cache-miss", async () => { inputs["fail-on-cache-miss"] = "true"; await restore(); expect(core.setOutput).toHaveBeenCalledWith("cache-hit", "false"); expect(core.setFailed).toHaveBeenCalledWith("No matching cache key found."); }); afterAll(async () => { // reset env process.env = originalEnv; if (s3mockContainer) { await s3mockContainer.stop(); } if (runnerWorkspace) { await fs.rm(runnerWorkspace, { recursive: true, force: true }); } if (runnerTemp) { await fs.rm(runnerTemp, { recursive: true, force: true }); } if (testDataFolder) { await fs.rm(testDataFolder, { recursive: true, force: true }); } }); });