diff --git a/tests/providers/gcp/identity.test.ts b/tests/providers/gcp/identity.test.ts new file mode 100644 index 0000000..b814b10 --- /dev/null +++ b/tests/providers/gcp/identity.test.ts @@ -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"); + }); +}); diff --git a/tests/providers/gcp/secrets.test.ts b/tests/providers/gcp/secrets.test.ts new file mode 100644 index 0000000..95cf6d7 --- /dev/null +++ b/tests/providers/gcp/secrets.test.ts @@ -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); + }); +});