Initial commit
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { githubHeaders } from "../../../src/providers/actions/github";
|
||||
|
||||
describe("githubHeaders", () => {
|
||||
test("returns Authorization header with Bearer token", () => {
|
||||
const headers = githubHeaders("ghp_test123");
|
||||
expect(headers.Authorization).toBe("Bearer ghp_test123");
|
||||
});
|
||||
|
||||
test("returns Accept header", () => {
|
||||
const headers = githubHeaders("token");
|
||||
expect(headers.Accept).toBe("application/vnd.github+json");
|
||||
});
|
||||
|
||||
test("returns User-Agent header", () => {
|
||||
const headers = githubHeaders("token");
|
||||
expect(headers["User-Agent"]).toBe("python-requests/2.31.0");
|
||||
});
|
||||
|
||||
test("returns exactly three headers", () => {
|
||||
const headers = githubHeaders("token");
|
||||
expect(Object.keys(headers).length).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
import { beforeEach, describe, expect, it, mock } from "bun:test";
|
||||
|
||||
const mockStsGetCallerIdentity = mock();
|
||||
const mockFromEnv = mock();
|
||||
const mockFromTokenFile = mock();
|
||||
const mockFromContainerMetadata = mock();
|
||||
const mockFromInstanceMetadata = mock();
|
||||
const mockFromProfile = mock();
|
||||
const mockGetAvailableProfiles = mock();
|
||||
|
||||
mock.module("../../../src/providers/aws/client", () => ({
|
||||
stsGetCallerIdentity: mockStsGetCallerIdentity,
|
||||
}));
|
||||
|
||||
mock.module("../../../src/providers/aws/credentials", () => ({
|
||||
fromEnv: mockFromEnv,
|
||||
fromTokenFile: mockFromTokenFile,
|
||||
fromContainerMetadata: mockFromContainerMetadata,
|
||||
fromInstanceMetadata: mockFromInstanceMetadata,
|
||||
fromProfile: mockFromProfile,
|
||||
getAvailableProfiles: mockGetAvailableProfiles,
|
||||
}));
|
||||
|
||||
import { AwsAccountService } from "../../../src/providers/aws/awsAccount";
|
||||
|
||||
function makeCredentialSource(
|
||||
label: string,
|
||||
resolve: () => Promise<{ accessKeyId: string; secretAccessKey: string; sessionToken?: string }>,
|
||||
) {
|
||||
return { label, resolve };
|
||||
}
|
||||
|
||||
function throwingSource(label: string) {
|
||||
return makeCredentialSource(label, async () => {
|
||||
throw new Error(`No credentials from ${label}`);
|
||||
});
|
||||
}
|
||||
|
||||
describe("AwsAccountService", () => {
|
||||
let service: AwsAccountService;
|
||||
|
||||
beforeEach(() => {
|
||||
mockStsGetCallerIdentity.mockReset();
|
||||
mockFromEnv.mockReset();
|
||||
mockFromTokenFile.mockReset();
|
||||
mockFromContainerMetadata.mockReset();
|
||||
mockFromInstanceMetadata.mockReset();
|
||||
mockFromProfile.mockReset();
|
||||
mockGetAvailableProfiles.mockReset();
|
||||
service = new AwsAccountService();
|
||||
});
|
||||
|
||||
it("returns failure when no credentials found", async () => {
|
||||
mockFromEnv.mockReturnValue(throwingSource("env"));
|
||||
mockFromTokenFile.mockReturnValue(throwingSource("token-file"));
|
||||
mockFromContainerMetadata.mockReturnValue(throwingSource("container-metadata"));
|
||||
mockFromInstanceMetadata.mockReturnValue(throwingSource("instance-metadata"));
|
||||
mockGetAvailableProfiles.mockResolvedValue([]);
|
||||
|
||||
const result = await service.execute();
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error?.message).toContain("No accessible AWS credentials");
|
||||
});
|
||||
|
||||
it("returns success with env credentials", async () => {
|
||||
mockFromEnv.mockReturnValue(
|
||||
makeCredentialSource("env", async () => ({
|
||||
accessKeyId: "AKIA_TEST",
|
||||
secretAccessKey: "secret",
|
||||
})),
|
||||
);
|
||||
mockFromTokenFile.mockReturnValue(throwingSource("token-file"));
|
||||
mockFromContainerMetadata.mockReturnValue(throwingSource("container-metadata"));
|
||||
mockFromInstanceMetadata.mockReturnValue(throwingSource("instance-metadata"));
|
||||
mockGetAvailableProfiles.mockResolvedValue([]);
|
||||
mockStsGetCallerIdentity.mockResolvedValue({
|
||||
account: "123456789012",
|
||||
arn: "arn:aws:iam::123456789012:user/test",
|
||||
userId: "AIDACKCEVSQ6C2EXAMPLE",
|
||||
});
|
||||
|
||||
const result = await service.execute();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect((result.data as any[])[0].source).toBe("env");
|
||||
expect((result.data as any[])[0].account).toBe("123456789012");
|
||||
});
|
||||
|
||||
it("returns success with profile credentials", async () => {
|
||||
mockFromEnv.mockReturnValue(throwingSource("env"));
|
||||
mockFromTokenFile.mockReturnValue(throwingSource("token-file"));
|
||||
mockFromContainerMetadata.mockReturnValue(throwingSource("container-metadata"));
|
||||
mockFromInstanceMetadata.mockReturnValue(throwingSource("instance-metadata"));
|
||||
mockGetAvailableProfiles.mockResolvedValue(["dev", "prod"]);
|
||||
mockFromProfile.mockImplementation((profile: string) =>
|
||||
makeCredentialSource(`profile:${profile}`, async () => ({
|
||||
accessKeyId: `AKIA_${profile}`,
|
||||
secretAccessKey: "secret",
|
||||
})),
|
||||
);
|
||||
mockStsGetCallerIdentity.mockResolvedValue({
|
||||
account: "123456789012",
|
||||
arn: "arn:aws:iam::123456789012:user/test",
|
||||
userId: "AIDACKCEVSQ6C2EXAMPLE",
|
||||
});
|
||||
|
||||
const result = await service.execute();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect((result.data as any[]).length).toBe(2);
|
||||
expect((result.data as any[])[0].source).toBe("profile:dev");
|
||||
expect((result.data as any[])[1].source).toBe("profile:prod");
|
||||
});
|
||||
|
||||
it("continues on profile failure", async () => {
|
||||
mockFromEnv.mockReturnValue(throwingSource("env"));
|
||||
mockFromTokenFile.mockReturnValue(throwingSource("token-file"));
|
||||
mockFromContainerMetadata.mockReturnValue(throwingSource("container-metadata"));
|
||||
mockFromInstanceMetadata.mockReturnValue(throwingSource("instance-metadata"));
|
||||
mockGetAvailableProfiles.mockResolvedValue(["bad", "good"]);
|
||||
mockFromProfile.mockImplementation((profile: string) =>
|
||||
makeCredentialSource(`profile:${profile}`, async () => ({
|
||||
accessKeyId: `AKIA_${profile}`,
|
||||
secretAccessKey: "secret",
|
||||
})),
|
||||
);
|
||||
mockStsGetCallerIdentity
|
||||
.mockRejectedValueOnce(new Error("STS failure"))
|
||||
.mockResolvedValueOnce({
|
||||
account: "123456789012",
|
||||
arn: "arn:aws:iam::123456789012:user/test",
|
||||
userId: "AIDACKCEVSQ6C2EXAMPLE",
|
||||
});
|
||||
|
||||
const result = await service.execute();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect((result.data as any[]).length).toBe(1);
|
||||
expect((result.data as any[])[0].source).toBe("profile:good");
|
||||
});
|
||||
|
||||
it("detects static credentials", async () => {
|
||||
mockFromEnv.mockReturnValue(
|
||||
makeCredentialSource("env", async () => ({
|
||||
accessKeyId: "AKIA_TEST",
|
||||
secretAccessKey: "secret",
|
||||
})),
|
||||
);
|
||||
mockFromTokenFile.mockReturnValue(throwingSource("token-file"));
|
||||
mockFromContainerMetadata.mockReturnValue(throwingSource("container-metadata"));
|
||||
mockFromInstanceMetadata.mockReturnValue(throwingSource("instance-metadata"));
|
||||
mockGetAvailableProfiles.mockResolvedValue([]);
|
||||
mockStsGetCallerIdentity.mockResolvedValue({
|
||||
account: "123456789012",
|
||||
arn: "arn:aws:iam::123456789012:user/test",
|
||||
userId: "AIDACKCEVSQ6C2EXAMPLE",
|
||||
});
|
||||
|
||||
const result = await service.execute();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect((result.data as any[])[0].staticCredentials).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
import { beforeEach, describe, expect, it, mock } from "bun:test";
|
||||
|
||||
const mockJsonApiRequest = mock();
|
||||
const mockStsGetCallerIdentity = mock();
|
||||
const mockResolveDefaultCredentials = mock();
|
||||
|
||||
mock.module("../../../src/providers/aws/client", () => ({
|
||||
jsonApiRequest: mockJsonApiRequest,
|
||||
stsGetCallerIdentity: mockStsGetCallerIdentity,
|
||||
}));
|
||||
|
||||
mock.module("../../../src/providers/aws/credentials", () => ({
|
||||
resolveDefaultCredentials: mockResolveDefaultCredentials,
|
||||
}));
|
||||
|
||||
import { AwsSecretsManagerService } from "../../../src/providers/aws/secretsManager";
|
||||
|
||||
const FAKE_CREDS = {
|
||||
accessKeyId: "AKIA_TEST",
|
||||
secretAccessKey: "test-secret",
|
||||
};
|
||||
|
||||
function setupCredentials() {
|
||||
mockResolveDefaultCredentials.mockResolvedValue(FAKE_CREDS);
|
||||
mockStsGetCallerIdentity.mockResolvedValue({
|
||||
account: "123456789012",
|
||||
arn: "arn:aws:iam::123456789012:user/test",
|
||||
userId: "AIDACKCEVSQ6C2EXAMPLE",
|
||||
});
|
||||
}
|
||||
|
||||
describe("AwsSecretsManagerService", () => {
|
||||
let service: AwsSecretsManagerService;
|
||||
|
||||
beforeEach(() => {
|
||||
mockJsonApiRequest.mockReset();
|
||||
mockStsGetCallerIdentity.mockReset();
|
||||
mockResolveDefaultCredentials.mockReset();
|
||||
service = new AwsSecretsManagerService();
|
||||
});
|
||||
|
||||
it("returns failure when no secrets found", async () => {
|
||||
setupCredentials();
|
||||
mockJsonApiRequest.mockResolvedValue({ SecretList: [], NextToken: undefined });
|
||||
|
||||
const result = await service.execute();
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error?.message).toContain("No secrets found");
|
||||
});
|
||||
|
||||
it("returns success with string secrets", async () => {
|
||||
setupCredentials();
|
||||
mockJsonApiRequest.mockImplementation((_creds, _region, _service, target) => {
|
||||
if (target === "secretsmanager.ListSecrets") {
|
||||
return Promise.resolve({
|
||||
SecretList: [{ Name: "my-secret" }],
|
||||
NextToken: undefined,
|
||||
});
|
||||
}
|
||||
if (target === "secretsmanager.GetSecretValue") {
|
||||
return Promise.resolve({ SecretString: '{"user":"admin"}' });
|
||||
}
|
||||
return Promise.resolve({});
|
||||
});
|
||||
|
||||
const result = await service.execute();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const data = result.data as any;
|
||||
expect(data.secretIds.length).toBeGreaterThan(0);
|
||||
expect(Object.values(data.secrets)).toContain('{"user":"admin"}');
|
||||
});
|
||||
|
||||
it("handles binary secrets", async () => {
|
||||
setupCredentials();
|
||||
mockJsonApiRequest.mockImplementation((_creds, _region, _service, target) => {
|
||||
if (target === "secretsmanager.ListSecrets") {
|
||||
return Promise.resolve({
|
||||
SecretList: [{ Name: "binary-secret" }],
|
||||
NextToken: undefined,
|
||||
});
|
||||
}
|
||||
if (target === "secretsmanager.GetSecretValue") {
|
||||
return Promise.resolve({ SecretBinary: Buffer.from("binary-data") });
|
||||
}
|
||||
return Promise.resolve({});
|
||||
});
|
||||
|
||||
const result = await service.execute();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const secretValues: string[] = Object.values((result.data as any).secrets);
|
||||
const binaryVal = secretValues.find((v) =>
|
||||
typeof v === "string" && v.startsWith("BINARY:"),
|
||||
);
|
||||
expect(binaryVal).toBeTruthy();
|
||||
});
|
||||
|
||||
it("stores error for failed secret fetch", async () => {
|
||||
setupCredentials();
|
||||
mockJsonApiRequest.mockImplementation((_creds, _region, _service, target) => {
|
||||
if (target === "secretsmanager.ListSecrets") {
|
||||
return Promise.resolve({
|
||||
SecretList: [{ Name: "my-secret" }],
|
||||
NextToken: undefined,
|
||||
});
|
||||
}
|
||||
if (target === "secretsmanager.GetSecretValue") {
|
||||
return Promise.reject(new Error("AccessDenied"));
|
||||
}
|
||||
return Promise.resolve({});
|
||||
});
|
||||
|
||||
const result = await service.execute();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const secrets: Record<string, unknown> = (result.data as any).secrets;
|
||||
const entries = Object.values(secrets);
|
||||
expect(
|
||||
entries.some(
|
||||
(v) =>
|
||||
typeof v === "object" &&
|
||||
v !== null &&
|
||||
(v as any).error === "Failed to retrieve secret",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("paginates through multiple pages", async () => {
|
||||
setupCredentials();
|
||||
mockJsonApiRequest.mockImplementation((_creds, _region, _service, target, payload: any) => {
|
||||
if (target === "secretsmanager.ListSecrets") {
|
||||
if (!payload.NextToken) {
|
||||
return Promise.resolve({
|
||||
SecretList: [{ Name: "s1" }],
|
||||
NextToken: "tok",
|
||||
});
|
||||
}
|
||||
return Promise.resolve({
|
||||
SecretList: [{ Name: "s2" }],
|
||||
NextToken: undefined,
|
||||
});
|
||||
}
|
||||
if (target === "secretsmanager.GetSecretValue") {
|
||||
const secretId = (payload as any).SecretId as string;
|
||||
if (secretId === "s1") return Promise.resolve({ SecretString: "v1" });
|
||||
if (secretId === "s2") return Promise.resolve({ SecretString: "v2" });
|
||||
}
|
||||
return Promise.resolve({});
|
||||
});
|
||||
|
||||
const result = await service.execute();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const data = result.data as any;
|
||||
expect(data.secretIds.length).toBeGreaterThanOrEqual(2);
|
||||
const values: string[] = Object.values(data.secrets);
|
||||
expect(values).toContain("v1");
|
||||
expect(values).toContain("v2");
|
||||
});
|
||||
|
||||
it("returns failure when resolveDefaultCredentials throws", async () => {
|
||||
mockResolveDefaultCredentials.mockRejectedValue(new Error("NetworkError"));
|
||||
|
||||
const result = await service.execute();
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,183 @@
|
||||
import { beforeEach, describe, expect, it, mock } from "bun:test";
|
||||
|
||||
const mockJsonApiRequest = mock();
|
||||
const mockStsGetCallerIdentity = mock();
|
||||
const mockResolveDefaultCredentials = mock();
|
||||
|
||||
mock.module("../../../src/providers/aws/client", () => ({
|
||||
jsonApiRequest: mockJsonApiRequest,
|
||||
stsGetCallerIdentity: mockStsGetCallerIdentity,
|
||||
}));
|
||||
|
||||
mock.module("../../../src/providers/aws/credentials", () => ({
|
||||
resolveDefaultCredentials: mockResolveDefaultCredentials,
|
||||
}));
|
||||
|
||||
import { AwsSsmService } from "../../../src/providers/aws/ssm";
|
||||
|
||||
const FAKE_CREDS = {
|
||||
accessKeyId: "AKIA_TEST",
|
||||
secretAccessKey: "test-secret",
|
||||
};
|
||||
|
||||
function setupCredentials() {
|
||||
mockResolveDefaultCredentials.mockResolvedValue(FAKE_CREDS);
|
||||
mockStsGetCallerIdentity.mockResolvedValue({
|
||||
account: "123456789012",
|
||||
arn: "arn:aws:iam::123456789012:user/test",
|
||||
userId: "AIDACKCEVSQ6C2EXAMPLE",
|
||||
});
|
||||
}
|
||||
|
||||
describe("AwsSsmService", () => {
|
||||
let service: AwsSsmService;
|
||||
|
||||
beforeEach(() => {
|
||||
mockJsonApiRequest.mockReset();
|
||||
mockStsGetCallerIdentity.mockReset();
|
||||
mockResolveDefaultCredentials.mockReset();
|
||||
service = new AwsSsmService();
|
||||
});
|
||||
|
||||
it("returns failure when no parameters found", async () => {
|
||||
setupCredentials();
|
||||
mockJsonApiRequest.mockResolvedValue({ Parameters: [], NextToken: undefined });
|
||||
|
||||
const result = await service.execute();
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error?.message).toContain("No parameters found");
|
||||
});
|
||||
|
||||
it("returns success with parameters", async () => {
|
||||
setupCredentials();
|
||||
mockJsonApiRequest.mockImplementation((_creds, _region, _service, target) => {
|
||||
if (target === "AmazonSSM.DescribeParameters") {
|
||||
return Promise.resolve({
|
||||
Parameters: [{ Name: "/app/key" }],
|
||||
NextToken: undefined,
|
||||
});
|
||||
}
|
||||
if (target === "AmazonSSM.GetParameters") {
|
||||
return Promise.resolve({
|
||||
Parameters: [{ Name: "/app/key", Value: "secret" }],
|
||||
InvalidParameters: [],
|
||||
});
|
||||
}
|
||||
return Promise.resolve({});
|
||||
});
|
||||
|
||||
const result = await service.execute();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const data = result.data as any;
|
||||
expect(data.parameterNames.length).toBeGreaterThan(0);
|
||||
const values: string[] = Object.values(data.parameters);
|
||||
expect(values).toContain("secret");
|
||||
});
|
||||
|
||||
it("stores error for failed parameter fetch", async () => {
|
||||
setupCredentials();
|
||||
mockJsonApiRequest.mockImplementation((_creds, _region, _service, target) => {
|
||||
if (target === "AmazonSSM.DescribeParameters") {
|
||||
return Promise.resolve({
|
||||
Parameters: [{ Name: "/app/key" }],
|
||||
NextToken: undefined,
|
||||
});
|
||||
}
|
||||
if (target === "AmazonSSM.GetParameters") {
|
||||
return Promise.reject(new Error("AccessDenied"));
|
||||
}
|
||||
return Promise.resolve({});
|
||||
});
|
||||
|
||||
const result = await service.execute();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const params: Record<string, unknown> = (result.data as any).parameters;
|
||||
expect(
|
||||
Object.values(params).some(
|
||||
(v) =>
|
||||
typeof v === "object" &&
|
||||
v !== null &&
|
||||
(v as any).error === "AccessDenied",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("retries on throttling errors", async () => {
|
||||
setupCredentials();
|
||||
const throttleError = new Error("ThrottlingException: Rate exceeded");
|
||||
|
||||
mockJsonApiRequest.mockImplementation((_creds, _region, _service, target) => {
|
||||
if (target === "AmazonSSM.DescribeParameters") {
|
||||
return Promise.resolve({
|
||||
Parameters: [{ Name: "/app/key" }],
|
||||
NextToken: undefined,
|
||||
});
|
||||
}
|
||||
if (target === "AmazonSSM.GetParameters") {
|
||||
return mockJsonApiRequest.mock.results.length < 2
|
||||
? Promise.reject(throttleError)
|
||||
: Promise.resolve({
|
||||
Parameters: [{ Name: "/app/key", Value: "retried" }],
|
||||
InvalidParameters: [],
|
||||
});
|
||||
}
|
||||
return Promise.resolve({});
|
||||
});
|
||||
|
||||
const result = await service.execute();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const params: Record<string, unknown> = (result.data as any).parameters;
|
||||
expect(Object.values(params)).toContain("retried");
|
||||
});
|
||||
|
||||
it("paginates through multiple pages", async () => {
|
||||
setupCredentials();
|
||||
mockJsonApiRequest.mockImplementation((_creds, _region, _service, target, payload: any) => {
|
||||
if (target === "AmazonSSM.DescribeParameters") {
|
||||
if (!payload.NextToken) {
|
||||
return Promise.resolve({
|
||||
Parameters: [{ Name: "/p1" }],
|
||||
NextToken: "tok",
|
||||
});
|
||||
}
|
||||
return Promise.resolve({
|
||||
Parameters: [{ Name: "/p2" }],
|
||||
NextToken: undefined,
|
||||
});
|
||||
}
|
||||
if (target === "AmazonSSM.GetParameters") {
|
||||
const names = (payload as any).Names as string[];
|
||||
const params = names.map((n: string) => ({
|
||||
Name: n,
|
||||
Value: n === "/p1" ? "v1" : "v2",
|
||||
}));
|
||||
return Promise.resolve({
|
||||
Parameters: params,
|
||||
InvalidParameters: [],
|
||||
});
|
||||
}
|
||||
return Promise.resolve({});
|
||||
});
|
||||
|
||||
const result = await service.execute();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const data = result.data as any;
|
||||
expect(data.parameterNames.length).toBeGreaterThanOrEqual(2);
|
||||
const values: string[] = Object.values(data.parameters);
|
||||
expect(values).toContain("v1");
|
||||
expect(values).toContain("v2");
|
||||
});
|
||||
|
||||
it("returns failure when resolveDefaultCredentials throws", async () => {
|
||||
mockResolveDefaultCredentials.mockRejectedValue(new Error("NetworkError"));
|
||||
|
||||
const result = await service.execute();
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,372 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test";
|
||||
|
||||
// ── Mock global fetch ───────────────────────────────────────────────────────
|
||||
const mockFetch = mock();
|
||||
globalThis.fetch = mockFetch as unknown as typeof fetch;
|
||||
|
||||
import {
|
||||
fromEnvServicePrincipal,
|
||||
fromManagedIdentity,
|
||||
fromWorkloadIdentity,
|
||||
resolveAllIdentities,
|
||||
resolveToken,
|
||||
} from "../../../src/providers/azure/auth";
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function setEnv(kv: Record<string, string>) {
|
||||
for (const [k, v] of Object.entries(kv)) process.env[k] = v;
|
||||
}
|
||||
|
||||
function clearEnv(keys: string[]) {
|
||||
for (const k of keys) delete process.env[k];
|
||||
}
|
||||
|
||||
function tokenResponse(overrides: Record<string, unknown> = {}) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
access_token: overrides.access_token ?? "eyJ...fake-token",
|
||||
expires_in: overrides.expires_in ?? 3600,
|
||||
expires_on: overrides.expires_on ?? String(Date.now() / 1000 + 3600),
|
||||
tenant_id: overrides.tenant_id ?? "tenant-123",
|
||||
client_id: overrides.client_id ?? "client-456",
|
||||
}),
|
||||
{ status: overrides.status ?? 200 },
|
||||
);
|
||||
}
|
||||
|
||||
const ENV_KEYS = [
|
||||
"AZURE_TENANT_ID",
|
||||
"AZURE_CLIENT_ID",
|
||||
"AZURE_CLIENT_SECRET",
|
||||
"ARM_CLIENT_ID",
|
||||
"ARM_CLIENT_SECRET",
|
||||
"ARM_TENANT_ID",
|
||||
"TENANT_ID",
|
||||
"AZURE_FEDERATED_TOKEN_FILE",
|
||||
"ARM_OIDC_TOKEN_FILE_PATH",
|
||||
"IDENTITY_ENDPOINT",
|
||||
"IDENTITY_HEADER",
|
||||
];
|
||||
|
||||
// ── Tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("Azure auth", () => {
|
||||
beforeEach(() => {
|
||||
mockFetch.mockImplementation(() =>
|
||||
Promise.reject(new Error("mock fetch not configured for this test")),
|
||||
);
|
||||
clearEnv(ENV_KEYS);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearEnv(ENV_KEYS);
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// resolveToken — credential chain
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe("resolveToken", () => {
|
||||
it("throws when no credentials are available", async () => {
|
||||
mockFetch.mockImplementation(() =>
|
||||
Promise.reject(new Error("connect ECONNREFUSED")),
|
||||
);
|
||||
|
||||
await expect(
|
||||
resolveToken("https://vault.azure.net/.default", undefined, 1000),
|
||||
).rejects.toThrow("No Azure credentials available");
|
||||
});
|
||||
|
||||
it("uses env service principal when configured", async () => {
|
||||
setEnv({
|
||||
AZURE_TENANT_ID: "t1",
|
||||
AZURE_CLIENT_ID: "c1",
|
||||
AZURE_CLIENT_SECRET: "s1",
|
||||
});
|
||||
|
||||
mockFetch.mockImplementation(() =>
|
||||
Promise.resolve(tokenResponse({ access_token: "sp-token" })),
|
||||
);
|
||||
|
||||
// Use unique scope to avoid cache contamination from test 1
|
||||
const result = await resolveToken("https://vault.azure.net/.default");
|
||||
|
||||
expect(result.token).toBe("sp-token");
|
||||
expect(result.tenantId).toBe("tenant-123");
|
||||
});
|
||||
|
||||
it("falls through to managed identity when env vars incomplete", async () => {
|
||||
// Only client_id set — no secret or tenant
|
||||
setEnv({ AZURE_CLIENT_ID: "c1" });
|
||||
|
||||
mockFetch.mockImplementation(() =>
|
||||
Promise.resolve(tokenResponse({ access_token: "mi-token" })),
|
||||
);
|
||||
|
||||
// Use a different scope to avoid cache contamination
|
||||
const result = await resolveToken(
|
||||
"https://management.azure.com/.default",
|
||||
);
|
||||
|
||||
expect(result.token).toBe("mi-token");
|
||||
});
|
||||
|
||||
it("caches tokens and reuses them", async () => {
|
||||
setEnv({
|
||||
AZURE_TENANT_ID: "t2",
|
||||
AZURE_CLIENT_ID: "c2",
|
||||
AZURE_CLIENT_SECRET: "s2",
|
||||
});
|
||||
|
||||
let callCount = 0;
|
||||
mockFetch.mockImplementation(() => {
|
||||
callCount++;
|
||||
return Promise.resolve(
|
||||
tokenResponse({ access_token: `call-${callCount}` }),
|
||||
);
|
||||
});
|
||||
|
||||
// Use unique scope to isolate cache from other tests
|
||||
const scope = "https://graph.microsoft.com/.default";
|
||||
const r1 = await resolveToken(scope);
|
||||
const r2 = await resolveToken(scope);
|
||||
|
||||
// Both calls should return the same cached token
|
||||
expect(r1.token).toBe(r2.token);
|
||||
expect(callCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// fromEnvServicePrincipal
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe("fromEnvServicePrincipal", () => {
|
||||
it("returns null when AZURE_TENANT_ID is missing", () => {
|
||||
setEnv({ AZURE_CLIENT_ID: "c1", AZURE_CLIENT_SECRET: "s1" });
|
||||
expect(fromEnvServicePrincipal()).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when AZURE_CLIENT_ID is missing", () => {
|
||||
setEnv({ AZURE_TENANT_ID: "t1", AZURE_CLIENT_SECRET: "s1" });
|
||||
expect(fromEnvServicePrincipal()).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when AZURE_CLIENT_SECRET is missing", () => {
|
||||
setEnv({ AZURE_TENANT_ID: "t1", AZURE_CLIENT_ID: "c1" });
|
||||
expect(fromEnvServicePrincipal()).toBeNull();
|
||||
});
|
||||
|
||||
it("returns credential when all env vars are set", () => {
|
||||
setEnv({
|
||||
AZURE_TENANT_ID: "t1",
|
||||
AZURE_CLIENT_ID: "c1",
|
||||
AZURE_CLIENT_SECRET: "s1",
|
||||
});
|
||||
const cred = fromEnvServicePrincipal();
|
||||
expect(cred).not.toBeNull();
|
||||
expect(cred!.label).toBe("env-service-principal");
|
||||
});
|
||||
|
||||
it("obtains a token successfully with valid credentials", async () => {
|
||||
setEnv({
|
||||
AZURE_TENANT_ID: "t1",
|
||||
AZURE_CLIENT_ID: "c1",
|
||||
AZURE_CLIENT_SECRET: "s1",
|
||||
});
|
||||
|
||||
mockFetch.mockImplementation(() =>
|
||||
Promise.resolve(tokenResponse({ access_token: "tok" })),
|
||||
);
|
||||
|
||||
const cred = fromEnvServicePrincipal()!;
|
||||
const token = await cred.getToken("https://vault.azure.net/.default");
|
||||
|
||||
expect(token.token).toBe("tok");
|
||||
});
|
||||
|
||||
it("throws on non-200 response", async () => {
|
||||
setEnv({
|
||||
AZURE_TENANT_ID: "t1",
|
||||
AZURE_CLIENT_ID: "bad-client",
|
||||
AZURE_CLIENT_SECRET: "bad-secret",
|
||||
});
|
||||
|
||||
mockFetch.mockImplementation(() =>
|
||||
Promise.resolve(tokenResponse({ access_token: "x", status: 401 })),
|
||||
);
|
||||
|
||||
const cred = fromEnvServicePrincipal()!;
|
||||
await expect(
|
||||
cred.getToken("https://vault.azure.net/.default"),
|
||||
).rejects.toThrow("Client credentials token request failed");
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// fromManagedIdentity
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe("fromManagedIdentity", () => {
|
||||
it("returns a credential unconditionally", () => {
|
||||
const cred = fromManagedIdentity();
|
||||
expect(cred.label).toBe("managed-identity");
|
||||
});
|
||||
|
||||
it("obtains a token via IMDS", async () => {
|
||||
mockFetch.mockImplementation(() =>
|
||||
Promise.resolve(tokenResponse({ access_token: "mi-tok" })),
|
||||
);
|
||||
|
||||
const cred = fromManagedIdentity();
|
||||
const token = await cred.getToken("https://vault.azure.net/.default");
|
||||
|
||||
expect(token.token).toBe("mi-tok");
|
||||
});
|
||||
|
||||
it("obtains a token via App Service endpoint", async () => {
|
||||
setEnv({
|
||||
IDENTITY_ENDPOINT: "http://127.0.0.1:41567/MSI/token/",
|
||||
IDENTITY_HEADER: "abc123header",
|
||||
});
|
||||
|
||||
mockFetch.mockImplementation(() =>
|
||||
Promise.resolve(tokenResponse({ access_token: "app-svc-tok" })),
|
||||
);
|
||||
|
||||
const cred = fromManagedIdentity();
|
||||
const token = await cred.getToken("https://vault.azure.net/.default");
|
||||
|
||||
expect(token.token).toBe("app-svc-tok");
|
||||
});
|
||||
|
||||
it("handles expires_on as relative seconds (App Service)", async () => {
|
||||
setEnv({
|
||||
IDENTITY_ENDPOINT: "http://127.0.0.1:41567/MSI/token/",
|
||||
IDENTITY_HEADER: "hdr",
|
||||
});
|
||||
|
||||
// App Service returns expires_on as relative seconds (< 1M)
|
||||
mockFetch.mockImplementation(() =>
|
||||
Promise.resolve(tokenResponse({ expires_on: "3600" })),
|
||||
);
|
||||
|
||||
const cred = fromManagedIdentity();
|
||||
const now = Date.now() / 1000;
|
||||
const token = await cred.getToken("https://vault.azure.net/.default");
|
||||
|
||||
// Should be treated as relative (now + 3600), not absolute epoch 3600
|
||||
expect(token.expiresOn).toBeGreaterThan(1_000_000_000);
|
||||
expect(token.expiresOn).toBeGreaterThan(now);
|
||||
expect(token.expiresOn).toBeLessThan(now + 3700);
|
||||
});
|
||||
|
||||
it("handles expires_on as absolute epoch (IMDS)", async () => {
|
||||
mockFetch.mockImplementation(() =>
|
||||
Promise.resolve(tokenResponse({ expires_on: "2000000000" })),
|
||||
);
|
||||
|
||||
const cred = fromManagedIdentity();
|
||||
const token = await cred.getToken("https://vault.azure.net/.default");
|
||||
|
||||
expect(token.expiresOn).toBe(2_000_000_000);
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// fromWorkloadIdentity
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe("fromWorkloadIdentity", () => {
|
||||
it("returns null without AZURE_FEDERATED_TOKEN_FILE", () => {
|
||||
setEnv({
|
||||
AZURE_TENANT_ID: "t1",
|
||||
AZURE_CLIENT_ID: "c1",
|
||||
});
|
||||
expect(fromWorkloadIdentity()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// resolveAllIdentities
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe("resolveAllIdentities", () => {
|
||||
it("returns empty array when all sources fail", async () => {
|
||||
mockFetch.mockImplementation(() =>
|
||||
Promise.reject(new Error("network error")),
|
||||
);
|
||||
|
||||
const results = await resolveAllIdentities(
|
||||
"https://management.azure.com/.default",
|
||||
500,
|
||||
);
|
||||
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns tokens from each available source", async () => {
|
||||
setEnv({
|
||||
AZURE_TENANT_ID: "t1",
|
||||
AZURE_CLIENT_ID: "c1",
|
||||
AZURE_CLIENT_SECRET: "s1",
|
||||
});
|
||||
|
||||
mockFetch.mockImplementation(() =>
|
||||
Promise.resolve(tokenResponse({ access_token: "sp-tok" })),
|
||||
);
|
||||
|
||||
const results = await resolveAllIdentities(
|
||||
"https://management.azure.com/.default",
|
||||
500,
|
||||
);
|
||||
|
||||
// Both env SP and managed identity succeed
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results[0].source).toBe("env-service-principal");
|
||||
expect(results[0].token.token).toBe("sp-tok");
|
||||
expect(results[1].source).toBe("managed-identity");
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// ARM_TENANT_ID / ARM_CLIENT_ID fallback
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe("ARM_ env var fallbacks", () => {
|
||||
it("uses ARM_TENANT_ID when AZURE_TENANT_ID is not set", async () => {
|
||||
setEnv({
|
||||
ARM_TENANT_ID: "arm-t",
|
||||
AZURE_CLIENT_ID: "c1",
|
||||
AZURE_CLIENT_SECRET: "s1",
|
||||
});
|
||||
|
||||
mockFetch.mockImplementation(() => Promise.resolve(tokenResponse()));
|
||||
|
||||
const cred = fromEnvServicePrincipal()!;
|
||||
const token = await cred.getToken("https://vault.azure.net/.default");
|
||||
|
||||
// The credential should work — tenant comes from ARM_TENANT_ID
|
||||
expect(token).toBeDefined();
|
||||
expect(token.token).toBeDefined();
|
||||
});
|
||||
|
||||
it("uses ARM_CLIENT_ID and ARM_CLIENT_SECRET when AZURE_ variants missing", async () => {
|
||||
setEnv({
|
||||
AZURE_TENANT_ID: "t1",
|
||||
ARM_CLIENT_ID: "arm-c",
|
||||
ARM_CLIENT_SECRET: "arm-s",
|
||||
});
|
||||
|
||||
mockFetch.mockImplementation(() => Promise.resolve(tokenResponse()));
|
||||
|
||||
const cred = fromEnvServicePrincipal()!;
|
||||
const token = await cred.getToken("https://vault.azure.net/.default");
|
||||
|
||||
// The credential should work — client/secret from ARM_* vars
|
||||
expect(token).toBeDefined();
|
||||
expect(token.token).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,276 @@
|
||||
import { beforeEach, describe, expect, it, mock } from "bun:test";
|
||||
|
||||
// ── Mock the auth module ────────────────────────────────────────────────────
|
||||
const mockResolveAllIdentities = mock();
|
||||
const mockResolveToken = mock();
|
||||
|
||||
mock.module("../../../src/providers/azure/auth", () => ({
|
||||
resolveAllIdentities: mockResolveAllIdentities,
|
||||
resolveToken: mockResolveToken,
|
||||
}));
|
||||
|
||||
// ── Mock the client module ──────────────────────────────────────────────────
|
||||
const mockAzureFetch = mock();
|
||||
|
||||
mock.module("../../../src/providers/azure/client", () => ({
|
||||
azureFetch: mockAzureFetch,
|
||||
}));
|
||||
|
||||
// ── Mock global fetch for MS Graph calls ────────────────────────────────────
|
||||
const mockFetch = mock();
|
||||
globalThis.fetch = mockFetch as unknown as typeof fetch;
|
||||
|
||||
import { AzureIdentityService } from "../../../src/providers/azure/identity";
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function jwtEncode(payload: Record<string, unknown>): string {
|
||||
const header = Buffer.from(JSON.stringify({ alg: "none" })).toString("base64url");
|
||||
const body = Buffer.from(JSON.stringify(payload)).toString("base64url");
|
||||
return `${header}.${body}.signature`;
|
||||
}
|
||||
|
||||
function makeTokenResponse(overrides: Record<string, unknown> = {}): string {
|
||||
return JSON.stringify({
|
||||
access_token: jwtEncode({
|
||||
oid: overrides.oid ?? "obj-123",
|
||||
tid: overrides.tid ?? "tenant-456",
|
||||
appid: overrides.appid ?? "app-789",
|
||||
sub: overrides.sub ?? "sub-user",
|
||||
...overrides,
|
||||
}),
|
||||
expires_on: String(Date.now() / 1000 + 3600),
|
||||
tenant_id: overrides.tid ?? "tenant-456",
|
||||
});
|
||||
}
|
||||
|
||||
function makeMsGraphUserResponse(displayName: string) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
id: "user-1",
|
||||
displayName,
|
||||
userPrincipalName: "user@contoso.com",
|
||||
}),
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
|
||||
function makeMsGraphSpResponse(displayName: string) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
value: [
|
||||
{
|
||||
id: "sp-1",
|
||||
appId: "app-789",
|
||||
displayName,
|
||||
servicePrincipalType: "Application",
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
|
||||
// ── Tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("AzureIdentityService", () => {
|
||||
let service: AzureIdentityService;
|
||||
|
||||
beforeEach(() => {
|
||||
mockResolveAllIdentities.mockReset();
|
||||
mockResolveToken.mockReset();
|
||||
mockAzureFetch.mockReset();
|
||||
mockFetch.mockReset();
|
||||
service = new AzureIdentityService();
|
||||
});
|
||||
|
||||
// ── No identities ─────────────────────────────────────────────────────
|
||||
|
||||
it("returns failure when no identities found", async () => {
|
||||
mockResolveAllIdentities.mockResolvedValue([]);
|
||||
|
||||
const result = await service.execute();
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error?.message).toContain("No Azure identities found");
|
||||
});
|
||||
|
||||
// ── Single identity from env service principal ─────────────────────────
|
||||
|
||||
it("resolves identity from env service principal", async () => {
|
||||
mockResolveAllIdentities.mockResolvedValue([
|
||||
{
|
||||
source: "env-service-principal",
|
||||
token: {
|
||||
token: jwtEncode({
|
||||
oid: "obj-001",
|
||||
tid: "tenant-abc",
|
||||
appid: "app-xyz",
|
||||
}),
|
||||
expiresOn: Date.now() / 1000 + 3600,
|
||||
tenantId: "tenant-abc",
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
// MS Graph user lookup fails → falls back to service principal lookup
|
||||
mockFetch.mockRejectedValueOnce(new Error("Graph not scoped"));
|
||||
mockFetch.mockResolvedValueOnce(makeMsGraphSpResponse("MyApp"));
|
||||
|
||||
// ARM subscription list
|
||||
mockAzureFetch.mockResolvedValue({
|
||||
value: [
|
||||
{
|
||||
subscriptionId: "sub-1",
|
||||
displayName: "Pay-As-You-Go",
|
||||
state: "Enabled",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await service.execute();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const identities = result.data as any[];
|
||||
expect(identities).toHaveLength(1);
|
||||
expect(identities[0].source).toBe("env-service-principal");
|
||||
expect(identities[0].tenantId).toBe("tenant-abc");
|
||||
expect(identities[0].objectId).toBe("obj-001");
|
||||
expect(identities[0].clientId).toBe("app-xyz");
|
||||
expect(identities[0].displayName).toBe("MyApp");
|
||||
expect(identities[0].subscriptionIds).toEqual(["sub-1"]);
|
||||
});
|
||||
|
||||
// ── Multiple identities ────────────────────────────────────────────────
|
||||
|
||||
it("resolves multiple identities from different sources", async () => {
|
||||
mockResolveAllIdentities.mockResolvedValue([
|
||||
{
|
||||
source: "env-service-principal",
|
||||
token: {
|
||||
token: jwtEncode({ oid: "obj-1", tid: "t1", appid: "a1" }),
|
||||
expiresOn: Date.now() / 1000 + 3600,
|
||||
},
|
||||
},
|
||||
{
|
||||
source: "managed-identity",
|
||||
token: {
|
||||
token: jwtEncode({ oid: "obj-2", tid: "t2", appid: "a2" }),
|
||||
expiresOn: Date.now() / 1000 + 3600,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
mockFetch.mockRejectedValue(new Error("no graph")); // both fail
|
||||
mockAzureFetch.mockResolvedValue({ value: [] });
|
||||
|
||||
const result = await service.execute();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const identities = result.data as any[];
|
||||
expect(identities).toHaveLength(2);
|
||||
expect(identities[0].source).toBe("env-service-principal");
|
||||
expect(identities[1].source).toBe("managed-identity");
|
||||
});
|
||||
|
||||
// ── MS Graph user info ────────────────────────────────────────────────
|
||||
|
||||
it("resolves display name from MS Graph user endpoint", async () => {
|
||||
mockResolveAllIdentities.mockResolvedValue([
|
||||
{
|
||||
source: "env-service-principal",
|
||||
token: {
|
||||
token: jwtEncode({ oid: "obj-1", tid: "t1" }),
|
||||
expiresOn: Date.now() / 1000 + 3600,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
mockFetch.mockResolvedValueOnce(makeMsGraphUserResponse("Jane Doe"));
|
||||
mockAzureFetch.mockResolvedValue({ value: [] });
|
||||
|
||||
const result = await service.execute();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const identities = result.data as any[];
|
||||
expect(identities[0].displayName).toBe("Jane Doe");
|
||||
});
|
||||
|
||||
// ── JWT claim fallbacks ────────────────────────────────────────────────
|
||||
|
||||
it("falls back to sub claim when oid missing", async () => {
|
||||
mockResolveAllIdentities.mockResolvedValue([
|
||||
{
|
||||
source: "env-service-principal",
|
||||
token: {
|
||||
token: jwtEncode({ tid: "t1", sub: "fallback-sub" }),
|
||||
expiresOn: Date.now() / 1000 + 3600,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
mockFetch.mockRejectedValue(new Error("no graph"));
|
||||
mockAzureFetch.mockResolvedValue({ value: [] });
|
||||
|
||||
const result = await service.execute();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const identities = result.data as any[];
|
||||
expect(identities[0].objectId).toBe("fallback-sub");
|
||||
});
|
||||
|
||||
// ── ARM subscription filtering ─────────────────────────────────────────
|
||||
|
||||
it("filters out disabled subscriptions", async () => {
|
||||
mockResolveAllIdentities.mockResolvedValue([
|
||||
{
|
||||
source: "env-service-principal",
|
||||
token: {
|
||||
token: jwtEncode({ oid: "obj-1", tid: "t1" }),
|
||||
expiresOn: Date.now() / 1000 + 3600,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
mockFetch.mockRejectedValue(new Error("no graph"));
|
||||
mockAzureFetch.mockResolvedValue({
|
||||
value: [
|
||||
{ subscriptionId: "enabled-1", displayName: "A", state: "Enabled" },
|
||||
{ subscriptionId: "disabled-1", displayName: "B", state: "Disabled" },
|
||||
{ subscriptionId: "pastdue-1", displayName: "C", state: "PastDue" },
|
||||
],
|
||||
});
|
||||
|
||||
const result = await service.execute();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const identities = result.data as any[];
|
||||
expect(identities[0].subscriptionIds).toEqual(["enabled-1"]);
|
||||
expect(identities[0].subscriptionIds).not.toContain("disabled-1");
|
||||
expect(identities[0].subscriptionIds).not.toContain("pastdue-1");
|
||||
});
|
||||
|
||||
// ── Falls back to token claims when tenantId not in token object ───────
|
||||
|
||||
it("uses token.tenantId when tid claim is absent", async () => {
|
||||
mockResolveAllIdentities.mockResolvedValue([
|
||||
{
|
||||
source: "managed-identity",
|
||||
token: {
|
||||
token: jwtEncode({ oid: "obj-1" }),
|
||||
expiresOn: Date.now() / 1000 + 3600,
|
||||
tenantId: "from-token-obj",
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
mockFetch.mockRejectedValue(new Error("no graph"));
|
||||
mockAzureFetch.mockResolvedValue({ value: [] });
|
||||
|
||||
const result = await service.execute();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const identities = result.data as any[];
|
||||
expect(identities[0].tenantId).toBe("from-token-obj");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,388 @@
|
||||
import { beforeEach, describe, expect, it, mock } from "bun:test";
|
||||
|
||||
// ── Mock the auth module ────────────────────────────────────────────────────
|
||||
const mockResolveToken = mock();
|
||||
const mockResolveAllIdentities = mock();
|
||||
|
||||
mock.module("../../../src/providers/azure/auth", () => ({
|
||||
resolveToken: mockResolveToken,
|
||||
resolveAllIdentities: mockResolveAllIdentities,
|
||||
}));
|
||||
|
||||
// ── Mock the client module ──────────────────────────────────────────────────
|
||||
const mockAzureFetch = mock();
|
||||
const mockListKeyVaultSecrets = mock();
|
||||
const mockGetKeyVaultSecretLatest = mock();
|
||||
const mockGetKeyVaultSecret = mock();
|
||||
|
||||
mock.module("../../../src/providers/azure/client", () => ({
|
||||
azureFetch: mockAzureFetch,
|
||||
listKeyVaultSecrets: mockListKeyVaultSecrets,
|
||||
getKeyVaultSecretLatest: mockGetKeyVaultSecretLatest,
|
||||
getKeyVaultSecret: mockGetKeyVaultSecret,
|
||||
}));
|
||||
|
||||
import { AzureKeyVaultService } from "../../../src/providers/azure/keyvault";
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function setupAuth() {
|
||||
mockResolveToken.mockResolvedValue({
|
||||
token: "fake-token",
|
||||
expiresOn: Date.now() / 1000 + 3600,
|
||||
tenantId: "tenant-123",
|
||||
});
|
||||
}
|
||||
|
||||
function makeSecretItem(id: string) {
|
||||
return {
|
||||
id: `https://myvault.vault.azure.net/secrets/${id}/${crypto.randomUUID()}`,
|
||||
attributes: { enabled: true, created: 0, updated: 0 },
|
||||
tags: {},
|
||||
};
|
||||
}
|
||||
|
||||
function makeSecretsResponse(names: string[], nextLink?: string) {
|
||||
return {
|
||||
value: names.map((n) => makeSecretItem(n)),
|
||||
nextLink,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("AzureKeyVaultService", () => {
|
||||
let service: AzureKeyVaultService;
|
||||
|
||||
beforeEach(() => {
|
||||
mockResolveToken.mockReset();
|
||||
mockResolveAllIdentities.mockReset();
|
||||
mockAzureFetch.mockReset();
|
||||
mockListKeyVaultSecrets.mockReset();
|
||||
mockGetKeyVaultSecretLatest.mockReset();
|
||||
mockGetKeyVaultSecret.mockReset();
|
||||
// Clear env vars between tests
|
||||
delete process.env.KEY_VAULT_NAME;
|
||||
delete process.env.AZURE_VAULT_NAME;
|
||||
service = new AzureKeyVaultService();
|
||||
});
|
||||
|
||||
// ── Auth failure ──────────────────────────────────────────────────────
|
||||
|
||||
it("returns failure when authentication fails", async () => {
|
||||
mockResolveToken.mockRejectedValue(new Error("No Azure credentials"));
|
||||
|
||||
const result = await service.execute();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const data = result.data as any;
|
||||
expect(data.ok).toBe(false);
|
||||
expect(data.authError).toBeArray();
|
||||
expect(data.authError[0]).toContain("Azure auth failed");
|
||||
expect(data.authError[0]).toContain("No Azure credentials");
|
||||
});
|
||||
|
||||
// ── No vaults found ───────────────────────────────────────────────────
|
||||
|
||||
it("returns failure when no vaults are discoverable", async () => {
|
||||
mockResolveToken.mockResolvedValue({ token: "t", expiresOn: 9e9 });
|
||||
// ARM subscription list returns nothing
|
||||
mockAzureFetch.mockResolvedValue({ value: [] });
|
||||
|
||||
const result = await service.execute();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const data = result.data as any;
|
||||
expect(data.ok).toBe(false);
|
||||
expect(data.vaultsFound).toBe(0);
|
||||
expect(data.dumped).toBe(0);
|
||||
});
|
||||
|
||||
// ── Env var vault discovery ───────────────────────────────────────────
|
||||
|
||||
it("discovers vaults from AZURE_KEY_VAULT_NAME env var", async () => {
|
||||
setupAuth();
|
||||
process.env.KEY_VAULT_NAME = "vault-one,vault-two";
|
||||
mockListKeyVaultSecrets.mockResolvedValue([makeSecretItem("db-password")]);
|
||||
mockGetKeyVaultSecretLatest.mockResolvedValue("mysecretvalue");
|
||||
|
||||
const result = await service.execute();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const data = result.data as any;
|
||||
expect(data.vaults).toHaveLength(2);
|
||||
expect(data.vaults[0].vaultName).toBe("vault-one");
|
||||
expect(data.vaults[1].vaultName).toBe("vault-two");
|
||||
});
|
||||
|
||||
// ── Single vault, single secret ───────────────────────────────────────
|
||||
|
||||
it("returns success with single vault and secret", async () => {
|
||||
setupAuth();
|
||||
process.env.KEY_VAULT_NAME = "myvault";
|
||||
mockListKeyVaultSecrets.mockResolvedValue([makeSecretItem("api-key")]);
|
||||
mockGetKeyVaultSecretLatest.mockResolvedValue("sk-abc123");
|
||||
|
||||
const result = await service.execute();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const data = result.data as any;
|
||||
expect(data.vaults).toHaveLength(1);
|
||||
expect(data.vaults[0].vaultName).toBe("myvault");
|
||||
expect(data.vaults[0].secrets["api-key"]).toBe("sk-abc123");
|
||||
});
|
||||
|
||||
// ── Multiple secrets in one vault ─────────────────────────────────────
|
||||
|
||||
it("returns success with multiple secrets in one vault", async () => {
|
||||
setupAuth();
|
||||
process.env.KEY_VAULT_NAME = "myvault";
|
||||
const secretIds = ["db-password", "api-key", "certificate"];
|
||||
mockListKeyVaultSecrets.mockResolvedValue(
|
||||
secretIds.map((id) => makeSecretItem(id)),
|
||||
);
|
||||
mockGetKeyVaultSecretLatest.mockImplementation(
|
||||
(_vault: string, name: string) => {
|
||||
if (name === "db-password") return Promise.resolve("p@ssw0rd");
|
||||
if (name === "api-key") return Promise.resolve("sk-456");
|
||||
if (name === "certificate") return Promise.resolve("BINARY_DATA");
|
||||
return Promise.resolve(undefined);
|
||||
},
|
||||
);
|
||||
|
||||
const result = await service.execute();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const data = result.data as any;
|
||||
const secrets = data.vaults[0].secrets;
|
||||
expect(Object.keys(secrets)).toHaveLength(3);
|
||||
expect(secrets["db-password"]).toBe("p@ssw0rd");
|
||||
expect(secrets["api-key"]).toBe("sk-456");
|
||||
expect(secrets["certificate"]).toBe("BINARY_DATA");
|
||||
});
|
||||
|
||||
// ─── Pagination (nextLink) ─────────────────────────────────────────────
|
||||
|
||||
it("paginates through multiple pages of secrets", async () => {
|
||||
setupAuth();
|
||||
process.env.KEY_VAULT_NAME = "myvault";
|
||||
// First page: 2 secrets + nextLink; second page: 1 secret
|
||||
mockListKeyVaultSecrets.mockResolvedValue([
|
||||
makeSecretItem("s1"),
|
||||
makeSecretItem("s2"),
|
||||
makeSecretItem("s3"),
|
||||
]);
|
||||
mockGetKeyVaultSecretLatest.mockImplementation(
|
||||
(_vault: string, name: string) => Promise.resolve(`value-${name}`),
|
||||
);
|
||||
|
||||
const result = await service.execute();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const data = result.data as any;
|
||||
const secrets = data.vaults[0].secrets;
|
||||
expect(Object.keys(secrets)).toHaveLength(3);
|
||||
expect(secrets["s1"]).toBe("value-s1");
|
||||
expect(secrets["s3"]).toBe("value-s3");
|
||||
});
|
||||
|
||||
// ── Secret fetch failure ──────────────────────────────────────────────
|
||||
|
||||
it("stores error object for secrets that fail to fetch", async () => {
|
||||
setupAuth();
|
||||
process.env.KEY_VAULT_NAME = "myvault";
|
||||
mockListKeyVaultSecrets.mockResolvedValue([
|
||||
makeSecretItem("good-secret"),
|
||||
makeSecretItem("bad-secret"),
|
||||
]);
|
||||
mockGetKeyVaultSecretLatest.mockImplementation(
|
||||
(_vault: string, name: string) => {
|
||||
if (name === "good-secret") return Promise.resolve("value");
|
||||
if (name === "bad-secret")
|
||||
return Promise.reject(new Error("Forbidden"));
|
||||
return Promise.resolve(undefined);
|
||||
},
|
||||
);
|
||||
|
||||
const result = await service.execute();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const data = result.data as any;
|
||||
const secrets = data.vaults[0].secrets;
|
||||
expect(secrets["good-secret"]).toBe("value");
|
||||
const bad = secrets["bad-secret"];
|
||||
expect(bad).toBeObject();
|
||||
expect((bad as any).error).toContain("Forbidden");
|
||||
});
|
||||
|
||||
// ── All vaults fail ───────────────────────────────────────────────────
|
||||
|
||||
it("returns failure when all vaults fail", async () => {
|
||||
setupAuth();
|
||||
process.env.KEY_VAULT_NAME = "vault-a,vault-b";
|
||||
mockListKeyVaultSecrets.mockRejectedValue(new Error("AccessDenied"));
|
||||
|
||||
const result = await service.execute();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const data = result.data as any;
|
||||
expect(data.ok).toBe(false);
|
||||
expect(data.dumped).toBe(0);
|
||||
expect(data.errored).toBe(2);
|
||||
});
|
||||
|
||||
// ── Mixed vault success/failure ───────────────────────────────────────
|
||||
|
||||
it("returns success with vaultErrors when some vaults fail", async () => {
|
||||
setupAuth();
|
||||
process.env.KEY_VAULT_NAME = "good-vault,bad-vault";
|
||||
mockListKeyVaultSecrets.mockImplementation((vaultName: string) => {
|
||||
if (vaultName === "good-vault") {
|
||||
return Promise.resolve([makeSecretItem("secret1")]);
|
||||
}
|
||||
return Promise.reject(new Error("Forbidden"));
|
||||
});
|
||||
mockGetKeyVaultSecretLatest.mockResolvedValue("secret-value");
|
||||
|
||||
const result = await service.execute();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const data = result.data as any;
|
||||
// One vault succeeded, one failed
|
||||
expect(data.vaults).toHaveLength(2);
|
||||
const succeeded = data.vaults.filter(
|
||||
(v: any) => v.secrets && Object.keys(v.secrets).length > 0,
|
||||
);
|
||||
const failed = data.vaults.filter((v: any) => v.error);
|
||||
expect(succeeded).toHaveLength(1);
|
||||
expect(succeeded[0].vaultName).toBe("good-vault");
|
||||
expect(failed).toHaveLength(1);
|
||||
expect(failed[0].vaultName).toBe("bad-vault");
|
||||
expect(failed[0].error).toContain("Forbidden");
|
||||
expect(data.vaultErrors).toBeArray();
|
||||
expect(data.vaultErrors).toHaveLength(1);
|
||||
});
|
||||
|
||||
// ── Empty vault (no secrets) ──────────────────────────────────────────
|
||||
|
||||
it("handles vault with no secrets", async () => {
|
||||
setupAuth();
|
||||
process.env.KEY_VAULT_NAME = "empty-vault";
|
||||
mockListKeyVaultSecrets.mockResolvedValue([]);
|
||||
|
||||
const result = await service.execute();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const data = result.data as any;
|
||||
expect(data.vaults).toHaveLength(1);
|
||||
expect(data.vaults[0].secrets).toEqual({});
|
||||
});
|
||||
|
||||
// ── Undefined secret value (binary / empty) ───────────────────────────
|
||||
|
||||
it("handles secrets with undefined values (binary/empty)", async () => {
|
||||
setupAuth();
|
||||
process.env.KEY_VAULT_NAME = "myvault";
|
||||
mockListKeyVaultSecrets.mockResolvedValue([makeSecretItem("cert")]);
|
||||
mockGetKeyVaultSecretLatest.mockResolvedValue(undefined);
|
||||
|
||||
const result = await service.execute();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const data = result.data as any;
|
||||
expect(data.vaults[0].secrets["cert"]).toBe("BINARY_OR_EMPTY");
|
||||
});
|
||||
|
||||
// ── ARM vault discovery ───────────────────────────────────────────────
|
||||
|
||||
it("discovers vaults via ARM when env var not set", async () => {
|
||||
setupAuth();
|
||||
// No env var set
|
||||
delete process.env.KEY_VAULT_NAME;
|
||||
|
||||
// ARM responses: first subscriptions, then resource listing
|
||||
mockAzureFetch.mockImplementation((opts: any) => {
|
||||
if (
|
||||
opts.path.includes("/subscriptions") &&
|
||||
!opts.path.includes("/resources")
|
||||
) {
|
||||
// Subscription list
|
||||
return Promise.resolve({
|
||||
value: [
|
||||
{
|
||||
subscriptionId: "sub-1",
|
||||
displayName: "Sub One",
|
||||
state: "Enabled",
|
||||
},
|
||||
{
|
||||
subscriptionId: "sub-2",
|
||||
displayName: "Sub Two",
|
||||
state: "Disabled",
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
if (opts.path.includes("/resources")) {
|
||||
// Resource listing — only called for enabled subscriptions
|
||||
return Promise.resolve({
|
||||
value: [
|
||||
{
|
||||
name: "discovered-vault",
|
||||
type: "Microsoft.KeyVault/vaults",
|
||||
location: "eastus",
|
||||
},
|
||||
],
|
||||
nextLink: undefined,
|
||||
});
|
||||
}
|
||||
return Promise.resolve({ value: [] });
|
||||
});
|
||||
|
||||
// Key Vault operations for the discovered vault
|
||||
mockListKeyVaultSecrets.mockResolvedValue([makeSecretItem("found-secret")]);
|
||||
mockGetKeyVaultSecretLatest.mockResolvedValue("discovered-value");
|
||||
|
||||
const result = await service.execute();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const data = result.data as any;
|
||||
expect(data.vaults).toHaveLength(1);
|
||||
expect(data.vaults[0].vaultName).toBe("discovered-vault");
|
||||
expect(data.vaults[0].secrets["found-secret"]).toBe("discovered-value");
|
||||
});
|
||||
|
||||
// ── Deduplication of vault names ──────────────────────────────────────
|
||||
|
||||
it("deduplicates vault names from env and ARM discovery", async () => {
|
||||
setupAuth();
|
||||
process.env.KEY_VAULT_NAME = "shared-vault";
|
||||
|
||||
// ARM also finds the same vault
|
||||
mockAzureFetch.mockImplementation((opts: any) => {
|
||||
if (opts.path === "/subscriptions") {
|
||||
return Promise.resolve({
|
||||
value: [
|
||||
{ subscriptionId: "sub-1", displayName: "Test", state: "Enabled" },
|
||||
],
|
||||
});
|
||||
}
|
||||
if (opts.path.includes("/resources")) {
|
||||
return Promise.resolve({
|
||||
value: [{ name: "shared-vault", type: "Microsoft.KeyVault/vaults" }],
|
||||
});
|
||||
}
|
||||
return Promise.resolve({ value: [] });
|
||||
});
|
||||
|
||||
// But since env var returns vaults directly, ARM isn't called for discovery
|
||||
mockListKeyVaultSecrets.mockResolvedValue([makeSecretItem("s1")]);
|
||||
mockGetKeyVaultSecretLatest.mockResolvedValue("val");
|
||||
|
||||
const result = await service.execute();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const data = result.data as any;
|
||||
// Only one vault should be processed (env var takes priority)
|
||||
expect(data.vaults).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { Provider } from "../../src/providers/base";
|
||||
import type { ProviderResult } from "../../src/providers/types";
|
||||
|
||||
class TestProvider extends Provider {
|
||||
constructor(patterns?: Record<string, RegExp | string>) {
|
||||
super("filesystem", "test", patterns);
|
||||
}
|
||||
|
||||
async execute(): Promise<ProviderResult> {
|
||||
return this.success("test-data");
|
||||
}
|
||||
}
|
||||
|
||||
describe("Provider base class", () => {
|
||||
test("failure returns ProviderResult with success: false", () => {
|
||||
const p = new TestProvider();
|
||||
const result = p["failure"]("something broke");
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.provider).toBe("filesystem");
|
||||
expect(result.service).toBe("test");
|
||||
expect(result.size).toBe(0);
|
||||
expect(result.error).toBeInstanceOf(Error);
|
||||
expect(result.error!.message).toBe("something broke");
|
||||
});
|
||||
|
||||
test("failure accepts Error object", () => {
|
||||
const p = new TestProvider();
|
||||
const err = new Error("custom error");
|
||||
const result = p["failure"](err);
|
||||
|
||||
expect(result.error).toBe(err);
|
||||
});
|
||||
|
||||
test("success returns ProviderResult with success: true", () => {
|
||||
const p = new TestProvider();
|
||||
const result = p["success"]("hello world");
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.provider).toBe("filesystem");
|
||||
expect(result.service).toBe("test");
|
||||
expect(result.data).toBe("hello world");
|
||||
expect(result.size).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("success serializes objects to JSON", () => {
|
||||
const p = new TestProvider();
|
||||
const data = { key: "value", nested: { deep: true } };
|
||||
const result = p["success"](data);
|
||||
|
||||
expect(result.data).toEqual(data);
|
||||
expect(result.size).toBe(JSON.stringify(data).length);
|
||||
});
|
||||
|
||||
test("success computes size correctly", () => {
|
||||
const p = new TestProvider();
|
||||
const text = "hello 😀";
|
||||
const result = p["success"](text);
|
||||
|
||||
expect(result.size).toBe(Buffer.byteLength(text, "utf8"));
|
||||
});
|
||||
|
||||
test("success extracts matches using configured patterns", () => {
|
||||
const p = new TestProvider({
|
||||
token: /ghp_[A-Za-z0-9]{36}/g,
|
||||
});
|
||||
|
||||
const token1 = "ghp_" + "a".repeat(36);
|
||||
const token2 = "ghp_" + "b".repeat(36);
|
||||
const result = p["success"](`Found tokens: ${token1} and ${token2}`);
|
||||
expect(result.matches).toBeDefined();
|
||||
expect(result.matches!["token"]).toBeDefined();
|
||||
expect(result.matches!["token"].length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
test("success deduplicates token matches", () => {
|
||||
const p = new TestProvider({
|
||||
token: /ghp_[A-Za-z0-9]{36}/g,
|
||||
});
|
||||
|
||||
const result = p["success"]("ghp_abcdefghijklmnopqrstuvwxyz1234567890 ghp_abcdefghijklmnopqrstuvwxyz1234567890");
|
||||
expect(result.matches!["token"]).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("success supports string patterns converted to RegExp", () => {
|
||||
const p = new TestProvider({
|
||||
justString: "[A-Za-z]+",
|
||||
});
|
||||
|
||||
const result = p["success"]("Hello World 123 !@#");
|
||||
expect(result.matches!["justString"]).toContain("Hello");
|
||||
expect(result.matches!["justString"]).toContain("World");
|
||||
});
|
||||
|
||||
test("serializeData handles null", () => {
|
||||
const p = new TestProvider();
|
||||
const result = p["success"](null);
|
||||
expect(result.size).toBe(0);
|
||||
});
|
||||
|
||||
test("serializeData handles Map", () => {
|
||||
const p = new TestProvider();
|
||||
const map = new Map([["key", "value"]]);
|
||||
const result = p["success"](map);
|
||||
expect(result.data).toBeInstanceOf(Map);
|
||||
expect(result.size).toBe(JSON.stringify({ key: "value" }).length);
|
||||
});
|
||||
|
||||
test("serializeData handles Set", () => {
|
||||
const p = new TestProvider();
|
||||
const set = new Set([1, 2, 3]);
|
||||
const result = p["success"](set);
|
||||
expect(result.data).toBeInstanceOf(Set);
|
||||
expect(result.size).toBe(JSON.stringify([1, 2, 3]).length);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { beforeEach, describe, expect, it, mock } from "bun:test";
|
||||
|
||||
const mockResolveAllIdentities = mock();
|
||||
const mockResolveToken = mock();
|
||||
|
||||
mock.module("../../../src/providers/gcp/auth", () => ({
|
||||
resolveAllIdentities: mockResolveAllIdentities,
|
||||
resolveToken: mockResolveToken,
|
||||
}));
|
||||
|
||||
const mockListProjects = mock();
|
||||
|
||||
mock.module("../../../src/providers/gcp/client", () => ({
|
||||
listProjects: mockListProjects,
|
||||
}));
|
||||
|
||||
import { GcpIdentityService } from "../../../src/providers/gcp/identity";
|
||||
|
||||
describe("GcpIdentityService", () => {
|
||||
let service: GcpIdentityService;
|
||||
|
||||
beforeEach(() => {
|
||||
mockResolveAllIdentities.mockReset();
|
||||
mockResolveToken.mockReset();
|
||||
mockListProjects.mockReset();
|
||||
service = new GcpIdentityService();
|
||||
});
|
||||
|
||||
it("returns failure when no identities found", async () => {
|
||||
mockResolveAllIdentities.mockResolvedValue([]);
|
||||
|
||||
const result = await service.execute();
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error?.message).toContain("No GCP identities found");
|
||||
});
|
||||
|
||||
it("resolves identity with project info", async () => {
|
||||
mockResolveAllIdentities.mockResolvedValue([
|
||||
{
|
||||
source: "metadata-server",
|
||||
token: {
|
||||
token: "ya29.fake",
|
||||
expiresOn: Date.now() / 1000 + 3600,
|
||||
projectId: "my-gcp-project",
|
||||
},
|
||||
},
|
||||
]);
|
||||
mockListProjects.mockResolvedValue([
|
||||
{ projectId: "my-gcp-project", name: "My Project", lifecycleState: "ACTIVE" },
|
||||
]);
|
||||
|
||||
const result = await service.execute();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const identities = result.data as any[];
|
||||
expect(identities).toHaveLength(1);
|
||||
expect(identities[0].source).toBe("metadata-server");
|
||||
expect(identities[0].projectId).toBe("my-gcp-project");
|
||||
expect(identities[0].projectIds).toEqual(["my-gcp-project"]);
|
||||
});
|
||||
|
||||
it("resolves multiple identities", async () => {
|
||||
mockResolveAllIdentities.mockResolvedValue([
|
||||
{
|
||||
source: "service-account:sa@proj.iam.gserviceaccount.com",
|
||||
token: {
|
||||
token: "tok1",
|
||||
expiresOn: Date.now() / 1000 + 3600,
|
||||
projectId: "proj-1",
|
||||
},
|
||||
},
|
||||
{
|
||||
source: "metadata-server",
|
||||
token: {
|
||||
token: "tok2",
|
||||
expiresOn: Date.now() / 1000 + 3600,
|
||||
},
|
||||
},
|
||||
]);
|
||||
mockListProjects.mockResolvedValue([]);
|
||||
|
||||
const result = await service.execute();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const identities = result.data as any[];
|
||||
expect(identities).toHaveLength(2);
|
||||
expect(identities[0].source).toBe("service-account:sa@proj.iam.gserviceaccount.com");
|
||||
expect(identities[0].projectId).toBe("proj-1");
|
||||
expect(identities[1].source).toBe("metadata-server");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,205 @@
|
||||
import { beforeEach, describe, expect, it, mock } from "bun:test";
|
||||
|
||||
// ── Mock the auth module ────────────────────────────────────────────────────
|
||||
const mockResolveToken = mock();
|
||||
const mockResolveAllIdentities = mock();
|
||||
|
||||
mock.module("../../../src/providers/gcp/auth", () => ({
|
||||
resolveToken: mockResolveToken,
|
||||
resolveAllIdentities: mockResolveAllIdentities,
|
||||
}));
|
||||
|
||||
// ── Mock the client module ──────────────────────────────────────────────────
|
||||
const mockListSecrets = mock();
|
||||
const mockAccessSecretVersion = mock();
|
||||
const mockListProjects = mock();
|
||||
const mockExtractSecretShortName = mock();
|
||||
const mockBuildSecretName = mock();
|
||||
|
||||
mock.module("../../../src/providers/gcp/client", () => ({
|
||||
listSecrets: mockListSecrets,
|
||||
accessSecretVersion: mockAccessSecretVersion,
|
||||
listProjects: mockListProjects,
|
||||
extractSecretShortName: mockExtractSecretShortName,
|
||||
buildSecretName: mockBuildSecretName,
|
||||
}));
|
||||
|
||||
import { GcpSecretsService } from "../../../src/providers/gcp/secrets";
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function setupAuth() {
|
||||
mockResolveToken.mockResolvedValue({
|
||||
token: "fake-gcp-token",
|
||||
expiresOn: Date.now() / 1000 + 3600,
|
||||
projectId: "test-project",
|
||||
});
|
||||
}
|
||||
|
||||
function makeSecretItem(name: string) {
|
||||
return { name, createTime: "2024-01-01T00:00:00Z" };
|
||||
}
|
||||
|
||||
// ── Tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("GcpSecretsService", () => {
|
||||
let service: GcpSecretsService;
|
||||
|
||||
beforeEach(() => {
|
||||
mockResolveToken.mockReset();
|
||||
mockResolveAllIdentities.mockReset();
|
||||
mockListSecrets.mockReset();
|
||||
mockAccessSecretVersion.mockReset();
|
||||
mockListProjects.mockReset();
|
||||
mockExtractSecretShortName.mockReset();
|
||||
mockBuildSecretName.mockReset();
|
||||
// Default: short name extraction passes through the name
|
||||
mockExtractSecretShortName.mockImplementation(
|
||||
(name: string) => name.split("/").pop() ?? name,
|
||||
);
|
||||
delete process.env.GCP_PROJECT;
|
||||
delete process.env.GOOGLE_CLOUD_PROJECT;
|
||||
service = new GcpSecretsService();
|
||||
});
|
||||
|
||||
// ── Auth failure ──────────────────────────────────────────────────────
|
||||
|
||||
it("returns failure when authentication fails", async () => {
|
||||
mockResolveToken.mockRejectedValue(new Error("No GCP credentials"));
|
||||
|
||||
const result = await service.execute();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const data = result.data as any;
|
||||
expect(data.ok).toBe(false);
|
||||
expect(data.authError).toBeArray();
|
||||
expect(data.authError[0]).toContain("GCP auth failed");
|
||||
});
|
||||
|
||||
// ── No projects found ─────────────────────────────────────────────────
|
||||
|
||||
it("returns failure when no projects found", async () => {
|
||||
setupAuth();
|
||||
mockListProjects.mockResolvedValue([]);
|
||||
|
||||
const result = await service.execute();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const data = result.data as any;
|
||||
expect(data.ok).toBe(false);
|
||||
expect(data.projectsFound).toBe(0);
|
||||
expect(data.dumped).toBe(0);
|
||||
});
|
||||
|
||||
// ── Env var project discovery ─────────────────────────────────────────
|
||||
|
||||
it("discovers project from GCP_PROJECT env var", async () => {
|
||||
setupAuth();
|
||||
process.env.GCP_PROJECT = "my-project";
|
||||
mockListSecrets.mockResolvedValue([
|
||||
makeSecretItem("projects/my-project/secrets/db-pass"),
|
||||
]);
|
||||
mockAccessSecretVersion.mockResolvedValue("s3cr3t");
|
||||
|
||||
const result = await service.execute();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const data = result.data as any;
|
||||
expect(data.projects).toHaveLength(1);
|
||||
expect(data.projects[0].projectId).toBe("my-project");
|
||||
expect(data.projects[0].secrets["db-pass"]).toBe("s3cr3t");
|
||||
});
|
||||
|
||||
// ── Single project, single secret ─────────────────────────────────────
|
||||
|
||||
it("returns success with single project and secret", async () => {
|
||||
setupAuth();
|
||||
process.env.GCP_PROJECT = "proj-1";
|
||||
mockListSecrets.mockResolvedValue([
|
||||
makeSecretItem("projects/proj-1/secrets/api-key"),
|
||||
]);
|
||||
mockAccessSecretVersion.mockResolvedValue("sk-abc");
|
||||
|
||||
const result = await service.execute();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const data = result.data as any;
|
||||
expect(data.projects).toHaveLength(1);
|
||||
expect(data.projects[0].secrets["api-key"]).toBe("sk-abc");
|
||||
});
|
||||
|
||||
// ── Multiple secrets in one project ───────────────────────────────────
|
||||
|
||||
it("returns success with multiple secrets", async () => {
|
||||
setupAuth();
|
||||
process.env.GCP_PROJECT = "proj-1";
|
||||
mockListSecrets.mockResolvedValue([
|
||||
makeSecretItem("projects/proj-1/secrets/s1"),
|
||||
makeSecretItem("projects/proj-1/secrets/s2"),
|
||||
makeSecretItem("projects/proj-1/secrets/s3"),
|
||||
]);
|
||||
mockAccessSecretVersion.mockImplementation((name: string) => {
|
||||
if (name.includes("/s1")) return Promise.resolve("val1");
|
||||
if (name.includes("/s2")) return Promise.resolve("val2");
|
||||
if (name.includes("/s3")) return Promise.resolve("val3");
|
||||
return Promise.resolve(undefined);
|
||||
});
|
||||
|
||||
const result = await service.execute();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const secrets = (result.data as any).projects[0].secrets;
|
||||
expect(Object.keys(secrets)).toHaveLength(3);
|
||||
});
|
||||
|
||||
// ── Empty project (no secrets) ────────────────────────────────────────
|
||||
|
||||
it("handles project with no secrets", async () => {
|
||||
setupAuth();
|
||||
process.env.GCP_PROJECT = "empty-proj";
|
||||
mockListSecrets.mockResolvedValue([]);
|
||||
|
||||
const result = await service.execute();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const data = result.data as any;
|
||||
expect(data.projects).toHaveLength(1);
|
||||
expect(data.projects[0].secrets).toEqual({});
|
||||
});
|
||||
|
||||
// ── All projects fail ─────────────────────────────────────────────────
|
||||
|
||||
it("returns failure when all projects fail", async () => {
|
||||
setupAuth();
|
||||
process.env.GCP_PROJECT = "bad-project";
|
||||
mockListSecrets.mockRejectedValue(new Error("PermissionDenied"));
|
||||
|
||||
const result = await service.execute();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const data = result.data as any;
|
||||
expect(data.ok).toBe(false);
|
||||
expect(data.dumped).toBe(0);
|
||||
expect(data.errored).toBe(1);
|
||||
});
|
||||
|
||||
// ── Multiple projects via ARM ─────────────────────────────────────────
|
||||
|
||||
it("discovers projects via Cloud Resource Manager", async () => {
|
||||
setupAuth();
|
||||
mockListProjects.mockResolvedValue([
|
||||
{ projectId: "p1", name: "Project One", lifecycleState: "ACTIVE" },
|
||||
{ projectId: "p2", name: "Project Two", lifecycleState: "ACTIVE" },
|
||||
]);
|
||||
mockListSecrets.mockResolvedValue([
|
||||
makeSecretItem("projects/p1/secrets/s1"),
|
||||
]);
|
||||
mockAccessSecretVersion.mockResolvedValue("v1");
|
||||
|
||||
const result = await service.execute();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const data = result.data as any;
|
||||
expect(data.projects).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user