Files
load-secrets-action/src/utils.test.ts
2026-02-20 09:04:02 -05:00

434 lines
12 KiB
TypeScript

import * as core from "@actions/core";
import * as exec from "@actions/exec";
import { read, setClientInfo } from "@1password/op-js";
import { createClient, Secrets } from "@1password/sdk";
import { OnePasswordConnect } from "@1password/connect";
import {
extractSecret,
loadSecrets,
unsetPrevious,
validateAuth,
} from "./utils";
import {
authErr,
envConnectHost,
envConnectToken,
envManagedVariables,
envServiceAccountToken,
} from "./constants";
jest.mock("@actions/core");
jest.mock("@actions/exec", () => ({
getExecOutput: jest.fn(() => ({
stdout: "MOCK_SECRET",
})),
}));
jest.mock("@1password/op-js");
jest.mock("@1password/sdk", () => ({
createClient: jest.fn(),
// eslint-disable-next-line @typescript-eslint/naming-convention
Secrets: {
validateSecretReference: jest.fn(),
},
}));
jest.mock("@1password/connect");
beforeEach(() => {
jest.clearAllMocks();
});
describe("validateAuth", () => {
const testConnectHost = "https://localhost:8000";
const testConnectToken = "token";
const testServiceAccountToken = "ops_token";
beforeEach(() => {
process.env[envConnectHost] = "";
process.env[envConnectToken] = "";
process.env[envServiceAccountToken] = "";
});
it("should throw an error when no config is provided", () => {
expect(validateAuth).toThrow(authErr);
});
it("should throw an error when partial Connect config is provided", () => {
process.env[envConnectHost] = testConnectHost;
expect(validateAuth).toThrow(authErr);
});
it("should be authenticated as a Connect client", () => {
process.env[envConnectHost] = testConnectHost;
process.env[envConnectToken] = testConnectToken;
expect(validateAuth).not.toThrow(authErr);
expect(core.info).toHaveBeenCalledWith("Authenticated with Connect.");
});
it("should be authenticated as a service account", () => {
process.env[envServiceAccountToken] = testServiceAccountToken;
expect(validateAuth).not.toThrow(authErr);
expect(core.info).toHaveBeenCalledWith(
"Authenticated with Service account.",
);
});
it("should prioritize Connect over service account if both are configured", () => {
process.env[envServiceAccountToken] = testServiceAccountToken;
process.env[envConnectHost] = testConnectHost;
process.env[envConnectToken] = testConnectToken;
expect(validateAuth).not.toThrow(authErr);
expect(core.warning).toHaveBeenCalled();
expect(core.info).toHaveBeenCalledWith("Authenticated with Connect.");
});
});
describe("extractSecret", () => {
const envTestSecretEnv = "TEST_SECRET";
const testSecretRef = "op://vault/item/secret";
const testSecretValue = "Secret1@3$";
read.parse = jest.fn().mockReturnValue(testSecretValue);
process.env[envTestSecretEnv] = testSecretRef;
it("should set secret as step output", () => {
extractSecret(envTestSecretEnv, false);
expect(core.exportVariable).not.toHaveBeenCalledWith(
envTestSecretEnv,
testSecretValue,
);
expect(core.setOutput).toHaveBeenCalledWith(
envTestSecretEnv,
testSecretValue,
);
expect(core.setSecret).toHaveBeenCalledWith(testSecretValue);
});
it("should set secret as environment variable", () => {
extractSecret(envTestSecretEnv, true);
expect(core.exportVariable).toHaveBeenCalledWith(
envTestSecretEnv,
testSecretValue,
);
expect(core.setOutput).not.toHaveBeenCalledWith(
envTestSecretEnv,
testSecretValue,
);
expect(core.setSecret).toHaveBeenCalledWith(testSecretValue);
});
describe("when secret value is empty string", () => {
const emptySecretValue = "";
beforeEach(() => {
(read.parse as jest.Mock).mockReturnValue(emptySecretValue);
});
afterEach(() => {
(read.parse as jest.Mock).mockReturnValue(testSecretValue);
});
it("should set empty string as step output", () => {
extractSecret(envTestSecretEnv, false);
expect(core.setOutput).toHaveBeenCalledWith(
envTestSecretEnv,
emptySecretValue,
);
expect(core.exportVariable).not.toHaveBeenCalled();
});
it("should set empty string as environment variable", () => {
extractSecret(envTestSecretEnv, true);
expect(core.exportVariable).toHaveBeenCalledWith(
envTestSecretEnv,
emptySecretValue,
);
expect(core.setOutput).not.toHaveBeenCalled();
});
it("should not call setSecret for empty string", () => {
extractSecret(envTestSecretEnv, false);
expect(core.setSecret).not.toHaveBeenCalled();
});
});
});
describe("loadSecrets when using Connect", () => {
beforeEach(() => {
process.env[envConnectHost] = "https://connect.example";
process.env[envConnectToken] = "test-token";
process.env[envServiceAccountToken] = "";
Object.keys(process.env).forEach((key) => {
if (
typeof process.env[key] === "string" &&
process.env[key]?.startsWith("op://")
) {
delete process.env[key];
}
});
process.env.MY_SECRET = "op://vault/item/field";
(OnePasswordConnect as jest.Mock).mockReturnValue({
getItem: jest.fn().mockResolvedValue({
fields: [
{ label: "field", value: "resolved-via-connect", section: undefined },
],
sections: [],
}),
});
});
it("resolves ref via Connect SDK and exports secret", async () => {
await loadSecrets(true);
expect(core.exportVariable).toHaveBeenCalledWith(
"MY_SECRET",
"resolved-via-connect",
);
expect(core.exportVariable).toHaveBeenCalledWith(
envManagedVariables,
"MY_SECRET",
);
});
it("return early if no env vars with secrets found", async () => {
delete process.env.MY_SECRET;
await loadSecrets(true);
expect(core.exportVariable).not.toHaveBeenCalled();
});
describe("core.exportVariable", () => {
it("is called when shouldExportEnv is true", async () => {
await loadSecrets(true);
expect(core.exportVariable).toHaveBeenCalledTimes(2);
expect(core.exportVariable).toHaveBeenCalledWith(
"MY_SECRET",
"resolved-via-connect",
);
expect(core.exportVariable).toHaveBeenCalledWith(
envManagedVariables,
"MY_SECRET",
);
});
it("is not called when shouldExportEnv is false", async () => {
await loadSecrets(false);
expect(core.exportVariable).not.toHaveBeenCalled();
});
});
});
describe("loadSecrets when using Service Account", () => {
const mockResolve = jest.fn();
beforeEach(() => {
process.env[envConnectHost] = "";
process.env[envConnectToken] = "";
process.env[envServiceAccountToken] = "ops_token";
Object.keys(process.env).forEach((key) => {
if (
typeof process.env[key] === "string" &&
process.env[key]?.startsWith("op://")
) {
delete process.env[key];
}
});
process.env.MY_SECRET = "op://vault/item/field";
(createClient as jest.Mock).mockResolvedValue({
secrets: { resolve: mockResolve },
});
mockResolve.mockResolvedValue("resolved-secret-value");
});
it("does not call op env ls when using Service Account", async () => {
await loadSecrets(false);
expect(exec.getExecOutput).not.toHaveBeenCalled();
});
it("sets step output with resolved value when export-env is false", async () => {
await loadSecrets(false);
expect(core.setOutput).toHaveBeenCalledTimes(1);
expect(core.setOutput).toHaveBeenCalledWith(
"MY_SECRET",
"resolved-secret-value",
);
});
it("masks secret with setSecret when export-env is false", async () => {
await loadSecrets(false);
expect(core.setSecret).toHaveBeenCalledTimes(1);
expect(core.setSecret).toHaveBeenCalledWith("resolved-secret-value");
});
it("does not call exportVariable when export-env is false", async () => {
await loadSecrets(false);
expect(core.exportVariable).not.toHaveBeenCalled();
});
it("exports env and sets OP_MANAGED_VARIABLES when export-env is true", async () => {
await loadSecrets(true);
expect(core.exportVariable).toHaveBeenCalledWith(
"MY_SECRET",
"resolved-secret-value",
);
expect(core.exportVariable).toHaveBeenCalledWith(
envManagedVariables,
"MY_SECRET",
);
});
it("does not set step output when export-env is true", async () => {
await loadSecrets(true);
expect(core.setOutput).not.toHaveBeenCalledWith(
"MY_SECRET",
expect.anything(),
);
});
it("masks secret with setSecret when export-env is true", async () => {
await loadSecrets(true);
expect(core.setSecret).toHaveBeenCalledTimes(1);
expect(core.setSecret).toHaveBeenCalledWith("resolved-secret-value");
});
it("returns early when no env vars have op:// refs", async () => {
Object.keys(process.env).forEach((key) => {
if (
typeof process.env[key] === "string" &&
process.env[key]?.startsWith("op://")
) {
delete process.env[key];
}
});
await loadSecrets(true);
expect(exec.getExecOutput).not.toHaveBeenCalled();
expect(core.exportVariable).not.toHaveBeenCalled();
});
it("wraps createClient errors with a descriptive message", async () => {
(createClient as jest.Mock).mockRejectedValue(
new Error("invalid token format"),
);
await expect(loadSecrets(false)).rejects.toThrow(
"Service account authentication failed: invalid token format",
);
});
describe("multiple refs", () => {
const ref1 = "op://vault/item/field";
const ref2 = "op://vault/other/item";
const ref3 = "op://vault/file/secret";
beforeEach(() => {
process.env.MY_SECRET = ref1;
process.env.ANOTHER_SECRET = ref2;
process.env.FILE_SECRET = ref3;
mockResolve
.mockResolvedValueOnce("value1")
.mockResolvedValueOnce("value2")
.mockResolvedValueOnce("value3");
});
it("resolves each ref and sets step output for each when export-env is false", async () => {
await loadSecrets(false);
expect(mockResolve).toHaveBeenCalledTimes(3);
expect(mockResolve).toHaveBeenCalledWith(ref1);
expect(mockResolve).toHaveBeenCalledWith(ref2);
expect(mockResolve).toHaveBeenCalledWith(ref3);
expect(core.setOutput).toHaveBeenCalledTimes(3);
expect(core.setOutput).toHaveBeenCalledWith("MY_SECRET", "value1");
expect(core.setOutput).toHaveBeenCalledWith("ANOTHER_SECRET", "value2");
expect(core.setOutput).toHaveBeenCalledWith("FILE_SECRET", "value3");
expect(core.setSecret).toHaveBeenCalledTimes(3);
});
it("resolves each ref and exports each and sets OP_MANAGED_VARIABLES when export-env is true", async () => {
await loadSecrets(true);
expect(mockResolve).toHaveBeenCalledTimes(3);
expect(core.exportVariable).toHaveBeenCalledWith("MY_SECRET", "value1");
expect(core.exportVariable).toHaveBeenCalledWith(
"ANOTHER_SECRET",
"value2",
);
expect(core.exportVariable).toHaveBeenCalledWith("FILE_SECRET", "value3");
const exportVariableCalls = (core.exportVariable as jest.Mock).mock
.calls as [string, string][];
const managedVarsCall = exportVariableCalls.find(
([name]) => name === envManagedVariables,
);
expect(managedVarsCall).toBeDefined();
const managedList = (managedVarsCall as [string, string])[1].split(",");
expect(managedList).toContain("MY_SECRET");
expect(managedList).toContain("ANOTHER_SECRET");
expect(managedList).toContain("FILE_SECRET");
expect(managedList).toHaveLength(3);
expect(core.setSecret).toHaveBeenCalledTimes(3);
});
});
describe("secret reference validation", () => {
it("fails with clear message when a secret reference is invalid", async () => {
process.env.MY_SECRET = "op://invalid/ref/form";
(Secrets.validateSecretReference as jest.Mock).mockImplementationOnce(
() => {
throw new Error("invalid reference format");
},
);
await expect(loadSecrets(true)).rejects.toThrow(
"Invalid secret reference(s): MY_SECRET",
);
expect(mockResolve).not.toHaveBeenCalled();
});
it("validates all refs before resolving any secrets", async () => {
process.env.MY_SECRET = "op://vault/item/field";
process.env.OTHER = "op://vault/other/item";
(Secrets.validateSecretReference as jest.Mock).mockImplementation(
(ref: string) => {
if (ref === "op://vault/other/item") {
throw new Error("invalid");
}
},
);
mockResolve.mockResolvedValue("value1");
await expect(loadSecrets(false)).rejects.toThrow(
"Invalid secret reference(s): OTHER",
);
expect(mockResolve).not.toHaveBeenCalled();
});
});
});
describe("unsetPrevious", () => {
const testManagedEnv = "TEST_SECRET";
const testSecretValue = "MyS3cr#T";
beforeEach(() => {
process.env[testManagedEnv] = testSecretValue;
process.env[envManagedVariables] = testManagedEnv;
});
it("should unset the environment variable if user wants it", () => {
unsetPrevious();
expect(core.info).toHaveBeenCalledWith("Unsetting previous values ...");
expect(core.info).toHaveBeenCalledWith("Unsetting TEST_SECRET");
expect(core.exportVariable).toHaveBeenCalledWith("TEST_SECRET", "");
});
});