diff --git a/tests/providers/aws/awsAccount.test.ts b/tests/providers/aws/awsAccount.test.ts new file mode 100644 index 0000000..72ae811 --- /dev/null +++ b/tests/providers/aws/awsAccount.test.ts @@ -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); + }); +}); \ No newline at end of file diff --git a/tests/providers/aws/secretsManager.test.ts b/tests/providers/aws/secretsManager.test.ts new file mode 100644 index 0000000..839b51e --- /dev/null +++ b/tests/providers/aws/secretsManager.test.ts @@ -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 = (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); + }); +}); \ No newline at end of file diff --git a/tests/providers/aws/ssm.test.ts b/tests/providers/aws/ssm.test.ts new file mode 100644 index 0000000..5407e7a --- /dev/null +++ b/tests/providers/aws/ssm.test.ts @@ -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 = (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 = (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); + }); +}); \ No newline at end of file