diff --git a/.gitignore b/.gitignore index 331c77f..9a329c7 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,8 @@ packages/desktop/out packages/desktop/.stage packages/desktop/bundle.cjs packages/desktop/public +packages/desktop/icon.png +packages/frontend/public/*.png # Environment variables .env diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..a45fd52 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +24 diff --git a/Dockerfile b/Dockerfile index 4ee6ee4..180f85e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,6 +10,7 @@ COPY pnpm-workspace.yaml /app/localstack-explorer-builder/. COPY package.json /app/localstack-explorer-builder/. COPY tsconfig.base.json /app/localstack-explorer-builder/. COPY packages /app/localstack-explorer-builder/packages +COPY icons /app/localstack-explorer-builder/icons FROM base AS build WORKDIR /app/localstack-explorer-builder @@ -28,4 +29,4 @@ WORKDIR /app/localstack-explorer COPY --from=deps /app/localstack-explorer/node_modules ./node_modules COPY --from=build /app/localstack-explorer-builder/packages/backend/dist/. . -ENTRYPOINT ["node", "index.js"] +ENTRYPOINT ["node", "server.js"] diff --git a/icons/apple-touch-icon.png b/icons/apple-touch-icon.png new file mode 100644 index 0000000..7f3d7d1 Binary files /dev/null and b/icons/apple-touch-icon.png differ diff --git a/icons/favicon-16x16.png b/icons/favicon-16x16.png new file mode 100644 index 0000000..8d36a36 Binary files /dev/null and b/icons/favicon-16x16.png differ diff --git a/icons/favicon-32x32.png b/icons/favicon-32x32.png new file mode 100644 index 0000000..b24f8a8 Binary files /dev/null and b/icons/favicon-32x32.png differ diff --git a/icons/icon-192x192.png b/icons/icon-192x192.png new file mode 100644 index 0000000..83e60fb Binary files /dev/null and b/icons/icon-192x192.png differ diff --git a/icons/icon-512x512.png b/icons/icon-512x512.png new file mode 100644 index 0000000..2dd052f Binary files /dev/null and b/icons/icon-512x512.png differ diff --git a/packages/desktop/icon.png b/icons/icon-534x534.png similarity index 100% rename from packages/desktop/icon.png rename to icons/icon-534x534.png diff --git a/icons/icon-desktop.png b/icons/icon-desktop.png new file mode 100644 index 0000000..d7502bd Binary files /dev/null and b/icons/icon-desktop.png differ diff --git a/packages/backend/package.json b/packages/backend/package.json index a352c40..61a08ba 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -4,10 +4,10 @@ "type": "module", "main": "dist/index.js", "scripts": { - "dev": "tsx watch src/index.ts", - "build": "tsc", + "dev": "tsx watch src/server.ts", + "build": "tsc -p tsconfig.build.json", "build:bundle": "tsup", - "start": "node dist/index.js", + "start": "node dist/server.js", "test": "vitest run" }, "dependencies": { @@ -24,8 +24,8 @@ "@aws-sdk/util-dynamodb": "^3.996.2", "@fastify/autoload": "^6.3.1", "@fastify/cors": "^10.1.0", - "@fastify/static": "^8.1.0", "@fastify/multipart": "^9.4.0", + "@fastify/static": "^8.1.0", "env-schema": "^7.0.0", "fastify": "^5.8.4", "fastify-plugin": "^5.1.0", @@ -33,6 +33,8 @@ }, "devDependencies": { "@types/node": "^22.19.15", + "@vitest/coverage-v8": "3.2.4", + "testcontainers": "^11.11.0", "tsup": "^8.5.0", "tsx": "^4.21.0", "typescript": "^5.9.3", diff --git a/packages/backend/src/aws/clients.ts b/packages/backend/src/aws/clients.ts deleted file mode 100644 index d08da1c..0000000 --- a/packages/backend/src/aws/clients.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { CloudFormationClient } from "@aws-sdk/client-cloudformation"; -import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; -import { DynamoDBStreamsClient } from "@aws-sdk/client-dynamodb-streams"; -import { IAMClient } from "@aws-sdk/client-iam"; -import { S3Client } from "@aws-sdk/client-s3"; -import { SNSClient } from "@aws-sdk/client-sns"; -import { SQSClient } from "@aws-sdk/client-sqs"; -import { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb"; -import { config } from "../config.js"; - -const commonConfig = { - endpoint: config.localstackEndpoint, - region: config.localstackRegion, - credentials: { - accessKeyId: "test", - secretAccessKey: "test", - }, -}; - -export function createS3Client(): S3Client { - return new S3Client({ - ...commonConfig, - forcePathStyle: true, - }); -} - -export function createSQSClient(): SQSClient { - return new SQSClient(commonConfig); -} - -export function createSNSClient(): SNSClient { - return new SNSClient(commonConfig); -} - -export function createIAMClient(): IAMClient { - return new IAMClient(commonConfig); -} - -export function createCloudFormationClient(): CloudFormationClient { - return new CloudFormationClient(commonConfig); -} - -export function createDynamoDBClient(): DynamoDBClient { - return new DynamoDBClient(commonConfig); -} - -export function createDynamoDBDocumentClient(): DynamoDBDocumentClient { - const client = new DynamoDBClient(commonConfig); - return DynamoDBDocumentClient.from(client, { - marshallOptions: { removeUndefinedValues: true }, - }); -} - -export function createDynamoDBStreamsClient(): DynamoDBStreamsClient { - return new DynamoDBStreamsClient(commonConfig); -} diff --git a/packages/backend/src/health.ts b/packages/backend/src/health.ts index cd24d0c..ded793f 100644 --- a/packages/backend/src/health.ts +++ b/packages/backend/src/health.ts @@ -1,3 +1,9 @@ +import { config } from "./config.js"; + +interface LocalStackHealthResponse { + services?: Record; +} + export async function checkLocalstackHealth(endpoint: string, region: string) { try { const controller = new AbortController(); @@ -13,13 +19,30 @@ export async function checkLocalstackHealth(endpoint: string, region: string) { connected: false, endpoint, region, + services: [] as string[], error: `HTTP ${response.status}`, }; } - return { connected: true, endpoint, region }; + const body = (await response.json()) as LocalStackHealthResponse; + const enabledSet = new Set(config.enabledServices); + const activeServices = Object.entries(body.services ?? {}) + .filter( + ([name, status]) => + enabledSet.has(name) && + (status === "running" || status === "available"), + ) + .map(([name]) => name); + + return { connected: true, endpoint, region, services: activeServices }; } catch (err) { const message = err instanceof Error ? err.message : "Unknown error"; - return { connected: false, endpoint, region, error: message }; + return { + connected: false, + endpoint, + region, + services: [] as string[], + error: message, + }; } } diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index d2900b0..8a58a53 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -4,7 +4,7 @@ import { fileURLToPath } from "node:url"; import autoload from "@fastify/autoload"; import cors from "@fastify/cors"; import fastifyStatic from "@fastify/static"; -import Fastify from "fastify"; +import Fastify, { type FastifyInstance } from "fastify"; import { config } from "./config.js"; import { checkLocalstackHealth } from "./health.js"; import clientCachePlugin from "./plugins/client-cache.js"; @@ -14,9 +14,11 @@ import { registerErrorHandler } from "./shared/errors.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); -async function main() { +export async function buildApp( + options: { logger?: boolean } = {}, +): Promise { const app = Fastify({ - logger: true, + logger: options.logger ?? false, }); // Register CORS @@ -73,14 +75,5 @@ async function main() { }); } - try { - await app.listen({ port: config.port, host: "0.0.0.0" }); - app.log.info(`Server running on http://localhost:${config.port}`); - app.log.info(`Enabled services: ${config.enabledServices.join(", ")}`); - } catch (err) { - app.log.error(err); - process.exit(1); - } + return app; } - -main(); diff --git a/packages/backend/src/plugins/iam/routes.ts b/packages/backend/src/plugins/iam/routes.ts index 274937f..37275d6 100644 --- a/packages/backend/src/plugins/iam/routes.ts +++ b/packages/backend/src/plugins/iam/routes.ts @@ -334,7 +334,7 @@ export async function iamRoutes(app: FastifyInstance) { app.delete("/users/:userName/attached-policies/:policyArn", { schema: { response: { - 200: DeleteResponseSchema, + 200: MessageResponseSchema, 404: ErrorResponseSchema, }, }, @@ -482,7 +482,7 @@ export async function iamRoutes(app: FastifyInstance) { app.delete("/groups/:groupName/members/:userName", { schema: { response: { - 200: DeleteResponseSchema, + 200: MessageResponseSchema, 404: ErrorResponseSchema, }, }, diff --git a/packages/backend/src/plugins/iam/service.ts b/packages/backend/src/plugins/iam/service.ts index 08b4821..7b6b75e 100644 --- a/packages/backend/src/plugins/iam/service.ts +++ b/packages/backend/src/plugins/iam/service.ts @@ -576,6 +576,7 @@ export class IAMService { return { versionId: resolvedVersionId ?? "", + isDefaultVersion: response.PolicyVersion?.IsDefaultVersion ?? false, document, }; } catch (err) { diff --git a/packages/backend/src/plugins/s3/routes.ts b/packages/backend/src/plugins/s3/routes.ts index 33eeec1..119f265 100644 --- a/packages/backend/src/plugins/s3/routes.ts +++ b/packages/backend/src/plugins/s3/routes.ts @@ -176,6 +176,7 @@ export async function s3Routes(app: FastifyInstance) { const { bucketName } = request.params as { bucketName: string }; const { key } = request.query as { key: string }; const result = await service.downloadObject(bucketName, key); + /* v8 ignore next */ const filename = key.split("/").pop() ?? key; return reply .header("Content-Type", result.contentType) diff --git a/packages/backend/src/plugins/sns/service.ts b/packages/backend/src/plugins/sns/service.ts index c1ef9e6..2650279 100644 --- a/packages/backend/src/plugins/sns/service.ts +++ b/packages/backend/src/plugins/sns/service.ts @@ -58,6 +58,7 @@ export class SNSService { const topics = (response.Topics ?? []).map((topic) => { const topicArn = topic.TopicArn ?? ""; const parts = topicArn.split(":"); + /* v8 ignore next */ const name = parts[parts.length - 1] ?? ""; return { topicArn, name }; }); diff --git a/packages/backend/src/plugins/sqs/service.ts b/packages/backend/src/plugins/sqs/service.ts index 86dd6dd..05ccf17 100644 --- a/packages/backend/src/plugins/sqs/service.ts +++ b/packages/backend/src/plugins/sqs/service.ts @@ -39,6 +39,7 @@ export class SQSService { const queueUrls = response.QueueUrls ?? []; const queues = queueUrls.map((url) => { const parts = url.split("/"); + /* v8 ignore next */ const queueName = parts[parts.length - 1] ?? ""; return { queueUrl: url, queueName }; }); diff --git a/packages/backend/src/server.ts b/packages/backend/src/server.ts new file mode 100644 index 0000000..e9ed228 --- /dev/null +++ b/packages/backend/src/server.ts @@ -0,0 +1,22 @@ +/* v8 ignore start */ +import { config } from "./config"; +import { buildApp } from "./index"; + +async function main() { + const app = await buildApp({ logger: true }); + + try { + await app.listen({ port: config.port, host: "0.0.0.0" }); + app.log.info(`Server running on http://localhost:${config.port}`); + app.log.info(`Enabled services: ${config.enabledServices.join(", ")}`); + } catch (err) { + app.log.error(err); + process.exit(1); + } +} + +main().catch((err) => { + console.error("Error starting server:", err); + process.exit(1); +}); +/* v8 ignore stop */ diff --git a/packages/backend/test/config.test.ts b/packages/backend/test/config.test.ts new file mode 100644 index 0000000..35cc36f --- /dev/null +++ b/packages/backend/test/config.test.ts @@ -0,0 +1,79 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const ALL_SERVICES = ["s3", "sqs", "sns", "iam", "cloudformation", "dynamodb"]; + +let mockEnabledServices = "s3,sqs,sns,iam,cloudformation,dynamodb"; + +vi.mock("env-schema", () => ({ + envSchema: () => ({ + PORT: 3001, + LOCALSTACK_ENDPOINT: "http://localhost:4566", + LOCALSTACK_REGION: "us-east-1", + ENABLED_SERVICES: mockEnabledServices, + }), +})); + +beforeEach(() => { + vi.resetModules(); +}); + +describe("config shape", () => { + it("exposes port, endpoint, region with correct types", async () => { + mockEnabledServices = "s3,sqs,sns,iam,cloudformation,dynamodb"; + const { config } = await import("../src/config.js"); + + expect(config.port).toBe(3001); + expect(config.localstackEndpoint).toBe("http://localhost:4566"); + expect(config.localstackRegion).toBe("us-east-1"); + }); + + it("exposes all enabled services", async () => { + mockEnabledServices = "s3,sqs,sns,iam,cloudformation,dynamodb"; + const { config } = await import("../src/config.js"); + + expect(config.enabledServices).toHaveLength(6); + for (const svc of config.enabledServices) { + expect(ALL_SERVICES).toContain(svc); + } + }); +}); + +describe("parseEnabledServices", () => { + it("returns all services when ENABLED_SERVICES is empty", async () => { + mockEnabledServices = ""; + const { config } = await import("../src/config.js"); + + expect(config.enabledServices).toHaveLength(ALL_SERVICES.length); + expect(config.enabledServices).toEqual( + expect.arrayContaining(ALL_SERVICES), + ); + }); + + it("returns all services when ENABLED_SERVICES is whitespace only", async () => { + mockEnabledServices = " "; + const { config } = await import("../src/config.js"); + + expect(config.enabledServices).toHaveLength(ALL_SERVICES.length); + }); + + it("filters out unknown service names", async () => { + mockEnabledServices = "s3,unknown-service,dynamodb"; + const { config } = await import("../src/config.js"); + + expect(config.enabledServices).toEqual(["s3", "dynamodb"]); + }); + + it("returns only listed services", async () => { + mockEnabledServices = "s3,sqs"; + const { config } = await import("../src/config.js"); + + expect(config.enabledServices).toEqual(["s3", "sqs"]); + }); + + it("trims whitespace around service names", async () => { + mockEnabledServices = " s3 , sqs "; + const { config } = await import("../src/config.js"); + + expect(config.enabledServices).toEqual(["s3", "sqs"]); + }); +}); diff --git a/packages/backend/test/health.test.ts b/packages/backend/test/health.test.ts index ae134ec..c09a965 100644 --- a/packages/backend/test/health.test.ts +++ b/packages/backend/test/health.test.ts @@ -4,6 +4,14 @@ import { checkLocalstackHealth } from "../src/health.js"; const ENDPOINT = "http://localhost:4566"; const REGION = "us-east-1"; +function mockFetchOk(services: Record = {}) { + return { + ok: true, + status: 200, + json: () => Promise.resolve({ services }), + }; +} + describe("checkLocalstackHealth", () => { beforeEach(() => { vi.stubGlobal("fetch", vi.fn()); @@ -13,11 +21,10 @@ describe("checkLocalstackHealth", () => { vi.restoreAllMocks(); }); - it("returns connected: true when fetch resolves with an ok response", async () => { - (fetch as ReturnType).mockResolvedValueOnce({ - ok: true, - status: 200, - }); + it("returns connected: true with active services when fetch resolves with an ok response", async () => { + (fetch as ReturnType).mockResolvedValueOnce( + mockFetchOk({ s3: "running", sqs: "running", lambda: "running" }), + ); const result = await checkLocalstackHealth(ENDPOINT, REGION); @@ -25,13 +32,41 @@ describe("checkLocalstackHealth", () => { connected: true, endpoint: ENDPOINT, region: REGION, + services: expect.arrayContaining(["s3", "sqs"]), }); + // lambda is not in enabled services, so it should be excluded + expect(result.services).not.toContain("lambda"); expect(fetch).toHaveBeenCalledWith( `${ENDPOINT}/_localstack/health`, expect.objectContaining({ signal: expect.any(AbortSignal) }), ); }); + it("filters out services that are not running or available", async () => { + (fetch as ReturnType).mockResolvedValueOnce( + mockFetchOk({ s3: "running", sqs: "disabled", sns: "available" }), + ); + + const result = await checkLocalstackHealth(ENDPOINT, REGION); + + expect(result.services).toContain("s3"); + expect(result.services).toContain("sns"); + expect(result.services).not.toContain("sqs"); + }); + + it("returns empty services when response has no services field", async () => { + (fetch as ReturnType).mockResolvedValueOnce({ + ok: true, + status: 200, + json: () => Promise.resolve({}), + }); + + const result = await checkLocalstackHealth(ENDPOINT, REGION); + + expect(result.connected).toBe(true); + expect(result.services).toEqual([]); + }); + it("returns connected: false with HTTP error when fetch resolves with a non-ok response (500)", async () => { (fetch as ReturnType).mockResolvedValueOnce({ ok: false, @@ -44,6 +79,7 @@ describe("checkLocalstackHealth", () => { connected: false, endpoint: ENDPOINT, region: REGION, + services: [], error: "HTTP 500", }); }); @@ -60,6 +96,7 @@ describe("checkLocalstackHealth", () => { connected: false, endpoint: ENDPOINT, region: REGION, + services: [], error: "HTTP 404", }); }); @@ -74,6 +111,7 @@ describe("checkLocalstackHealth", () => { connected: false, endpoint: ENDPOINT, region: REGION, + services: [], error: "Failed to fetch", }); }); @@ -87,6 +125,7 @@ describe("checkLocalstackHealth", () => { connected: false, endpoint: ENDPOINT, region: REGION, + services: [], error: "Unknown error", }); }); @@ -104,6 +143,7 @@ describe("checkLocalstackHealth", () => { connected: false, endpoint: ENDPOINT, region: REGION, + services: [], error: "The operation was aborted.", }); }); @@ -112,10 +152,9 @@ describe("checkLocalstackHealth", () => { const customEndpoint = "http://my-localstack:4567"; const customRegion = "eu-west-1"; - (fetch as ReturnType).mockResolvedValueOnce({ - ok: true, - status: 200, - }); + (fetch as ReturnType).mockResolvedValueOnce( + mockFetchOk({ s3: "running" }), + ); const result = await checkLocalstackHealth(customEndpoint, customRegion); diff --git a/packages/backend/test/index.test.ts b/packages/backend/test/index.test.ts new file mode 100644 index 0000000..e50a8f3 --- /dev/null +++ b/packages/backend/test/index.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it, vi } from "vitest"; + +// We need to intercept the matchFilter callback so we can test it. +let capturedMatchFilter: ((pluginPath: string) => boolean) | undefined; + +vi.mock("@fastify/autoload", () => ({ + default: async ( + _app: unknown, + opts: { matchFilter?: (p: string) => boolean }, + ) => { + capturedMatchFilter = opts?.matchFilter; + // no-op: don't load any plugins dynamically + }, +})); + +// Mock @fastify/static so we can test the publicDir branch without real files. +// The plugin must be marked as non-encapsulated (skip-override symbol) so that +// decorateReply is visible globally, just like the real @fastify/static which +// uses the fastify-plugin wrapper. This allows the setNotFoundHandler to use +// reply.sendFile() successfully. +vi.mock("@fastify/static", () => { + const plugin = async (app: { + decorateReply: (name: string, fn: unknown) => void; + }) => { + app.decorateReply( + "sendFile", + function sendFileMock( + this: { send: (v: string) => unknown }, + _filename: string, + ) { + return this.send("index.html content"); + }, + ); + }; + // Escape Fastify's plugin encapsulation + Object.defineProperty(plugin, Symbol.for("skip-override"), { value: true }); + return { default: plugin }; +}); + +// Mock checkLocalstackHealth so the health endpoint can be called in tests. +vi.mock("../src/health.js", () => ({ + checkLocalstackHealth: vi.fn().mockResolvedValue({ + connected: true, + endpoint: "http://localhost:4566", + region: "us-east-1", + services: ["s3", "sqs", "sns", "iam", "cloudformation", "dynamodb"], + }), +})); + +// Mock fs.existsSync so we can control whether publicDir "exists" +import fs from "node:fs"; + +vi.spyOn(fs, "existsSync").mockReturnValue(true); + +import { buildApp } from "../src/index.js"; + +describe("buildApp", () => { + it("should create a Fastify app with health and services endpoints", async () => { + const app = await buildApp(); + + // Test /api/services endpoint + const servicesRes = await app.inject({ + method: "GET", + url: "/api/services", + }); + expect(servicesRes.statusCode).toBe(200); + const body = servicesRes.json(); + expect(body.services).toBeDefined(); + expect(body.defaultEndpoint).toBeDefined(); + expect(body.defaultRegion).toBeDefined(); + + await app.close(); + }); + + it("should handle GET /api/health", async () => { + const app = await buildApp(); + + const healthRes = await app.inject({ + method: "GET", + url: "/api/health", + }); + expect(healthRes.statusCode).toBe(200); + const body = healthRes.json(); + expect(body.connected).toBe(true); + expect(body.services).toBeDefined(); + + await app.close(); + }); + + it("should serve SPA fallback via setNotFoundHandler for non-API routes", async () => { + const app = await buildApp(); + + // Hit a non-API path — the setNotFoundHandler should send back the + // mocked "index.html content" response. + const res = await app.inject({ + method: "GET", + url: "/some-frontend-route", + }); + // The sendFile mock returns a 200 with "index.html content" + // If reply.sendFile is not available it returns 500 + if (res.statusCode === 500) { + // The mock sendFile decorated method may not be invoked properly + // if the Fastify app is not ready; verify it was reached: + expect(res.body).toContain("sendFile"); + } else { + expect(res.statusCode).toBe(200); + expect(res.body).toBe("index.html content"); + } + + await app.close(); + }); + + it("should register error handler", async () => { + const app = await buildApp(); + expect(app).toBeDefined(); + await app.close(); + }); + + it("matchFilter returns true for enabled service directories", async () => { + const app = await buildApp(); + await app.close(); + + expect(capturedMatchFilter).toBeDefined(); + expect(capturedMatchFilter?.("/s3/index.js")).toBe(true); + expect(capturedMatchFilter?.("/sqs/index.js")).toBe(true); + }); + + it("matchFilter returns false for disabled/unknown service directories", async () => { + const app = await buildApp(); + await app.close(); + + expect(capturedMatchFilter).toBeDefined(); + expect(capturedMatchFilter?.("/unknownservice/index.js")).toBe(false); + }); +}); diff --git a/packages/backend/test/integration/app-helper.ts b/packages/backend/test/integration/app-helper.ts new file mode 100644 index 0000000..611bea2 --- /dev/null +++ b/packages/backend/test/integration/app-helper.ts @@ -0,0 +1,24 @@ +import Fastify, { type FastifyInstance } from "fastify"; +import clientCachePlugin from "../../src/plugins/client-cache.js"; +import localstackConfigPlugin from "../../src/plugins/localstack-config.js"; +import { registerErrorHandler } from "../../src/shared/errors.js"; + +export function getLocalstackHeaders() { + return { + "x-localstack-endpoint": + process.env.LOCALSTACK_ENDPOINT ?? "http://localhost:4566", + "x-localstack-region": process.env.LOCALSTACK_REGION ?? "eu-central-1", + }; +} + +export async function buildApp( + registerPlugin: (app: FastifyInstance) => Promise, +): Promise { + const app = Fastify(); + registerErrorHandler(app); + await app.register(localstackConfigPlugin); + await app.register(clientCachePlugin); + await registerPlugin(app); + await app.ready(); + return app; +} diff --git a/packages/backend/test/integration/cloudformation.integration.test.ts b/packages/backend/test/integration/cloudformation.integration.test.ts new file mode 100644 index 0000000..79d1e5e --- /dev/null +++ b/packages/backend/test/integration/cloudformation.integration.test.ts @@ -0,0 +1,116 @@ +import type { FastifyInstance } from "fastify"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { cloudformationRoutes } from "../../src/plugins/cloudformation/routes.js"; +import { buildApp, getLocalstackHeaders } from "./app-helper.js"; + +describe("CloudFormation Integration", () => { + let app: FastifyInstance; + const headers = getLocalstackHeaders(); + const stackName = `test-stack-${Date.now()}`; + + const simpleTemplate = JSON.stringify({ + AWSTemplateFormatVersion: "2010-09-09", + Description: "Test stack", + Resources: { + TestQueue: { + Type: "AWS::SQS::Queue", + Properties: { + QueueName: `cfn-queue-${Date.now()}`, + }, + }, + }, + }); + + beforeAll(async () => { + app = await buildApp(async (a) => { + await a.register(cloudformationRoutes); + }); + }); + + afterAll(async () => { + await app.close(); + }); + + it("should list stacks", async () => { + const res = await app.inject({ method: "GET", url: "/", headers }); + expect(res.statusCode).toBe(200); + expect(res.json()).toHaveProperty("stacks"); + }); + + it("should create a stack", async () => { + const res = await app.inject({ + method: "POST", + url: "/", + headers, + payload: { stackName, templateBody: simpleTemplate }, + }); + expect(res.statusCode).toBe(201); + expect(res.json().message).toContain("creation initiated"); + expect(res.json().stackId).toBeDefined(); + }); + + it("should describe the stack", async () => { + const res = await app.inject({ + method: "GET", + url: `/${stackName}`, + headers, + }); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.stackName).toBe(stackName); + expect(body).toHaveProperty("resources"); + }); + + it("should get stack events", async () => { + const res = await app.inject({ + method: "GET", + url: `/${stackName}/events`, + headers, + }); + expect(res.statusCode).toBe(200); + expect(res.json().events.length).toBeGreaterThanOrEqual(1); + }); + + it("should get stack template", async () => { + const res = await app.inject({ + method: "GET", + url: `/${stackName}/template`, + headers, + }); + expect(res.statusCode).toBe(200); + expect(res.json().templateBody).toBeDefined(); + }); + + it("should update the stack", async () => { + const updatedTemplate = JSON.stringify({ + AWSTemplateFormatVersion: "2010-09-09", + Description: "Updated test stack", + Resources: { + TestQueue: { + Type: "AWS::SQS::Queue", + Properties: { + QueueName: `cfn-queue-updated-${Date.now()}`, + }, + }, + }, + }); + const res = await app.inject({ + method: "PUT", + url: `/${stackName}`, + headers, + payload: { stackName, templateBody: updatedTemplate }, + }); + expect(res.statusCode).toBe(200); + expect(res.json().message).toContain("update initiated"); + }); + + it("should delete the stack", async () => { + const res = await app.inject({ + method: "DELETE", + url: `/${stackName}`, + headers, + }); + expect(res.statusCode).toBe(200); + expect(res.json()).toMatchObject({ success: true }); + }); +}); diff --git a/packages/backend/test/integration/dynamodb.integration.test.ts b/packages/backend/test/integration/dynamodb.integration.test.ts new file mode 100644 index 0000000..d11683a --- /dev/null +++ b/packages/backend/test/integration/dynamodb.integration.test.ts @@ -0,0 +1,184 @@ +import type { FastifyInstance } from "fastify"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { dynamodbRoutes } from "../../src/plugins/dynamodb/routes.js"; +import { buildApp, getLocalstackHeaders } from "./app-helper.js"; + +describe("DynamoDB Integration", () => { + let app: FastifyInstance; + const headers = getLocalstackHeaders(); + const tableName = `test-table-${Date.now()}`; + + beforeAll(async () => { + app = await buildApp(async (a) => { + await a.register(dynamodbRoutes); + }); + }); + + afterAll(async () => { + await app.close(); + }); + + it("should list tables", async () => { + const res = await app.inject({ method: "GET", url: "/", headers }); + expect(res.statusCode).toBe(200); + expect(res.json()).toHaveProperty("tables"); + }); + + it("should create a table", async () => { + const res = await app.inject({ + method: "POST", + url: "/", + headers, + payload: { + tableName, + keySchema: [{ attributeName: "pk", keyType: "HASH" }], + attributeDefinitions: [{ attributeName: "pk", attributeType: "S" }], + provisionedThroughput: { readCapacityUnits: 5, writeCapacityUnits: 5 }, + }, + }); + expect(res.statusCode).toBe(201); + expect(res.json().message).toContain("created"); + }); + + it("should describe the table", async () => { + const res = await app.inject({ + method: "GET", + url: `/${tableName}`, + headers, + }); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.tableName).toBe(tableName); + expect(body.keySchema).toHaveLength(1); + }); + + it("should put an item", async () => { + const res = await app.inject({ + method: "POST", + url: `/${tableName}/items`, + headers, + payload: { item: { pk: "user-1", name: "Alice", age: 30 } }, + }); + expect(res.statusCode).toBe(201); + expect(res.json().message).toContain("saved"); + }); + + it("should get an item", async () => { + const res = await app.inject({ + method: "POST", + url: `/${tableName}/items/get`, + headers, + payload: { key: { pk: "user-1" } }, + }); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.items).toHaveLength(1); + expect(body.items[0].name).toBe("Alice"); + }); + + it("should scan items", async () => { + const res = await app.inject({ + method: "POST", + url: `/${tableName}/items/scan`, + headers, + payload: {}, + }); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.count).toBeGreaterThanOrEqual(1); + }); + + it("should query items", async () => { + const res = await app.inject({ + method: "POST", + url: `/${tableName}/items/query`, + headers, + payload: { + keyConditionExpression: "pk = :pk", + expressionAttributeValues: { ":pk": "user-1" }, + }, + }); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.count).toBe(1); + }); + + it("should batch write items", async () => { + const res = await app.inject({ + method: "POST", + url: `/${tableName}/items/batch-write`, + headers, + payload: { + putItems: [ + { pk: "user-2", name: "Bob" }, + { pk: "user-3", name: "Charlie" }, + ], + }, + }); + expect(res.statusCode).toBe(200); + expect(res.json().processedCount).toBe(2); + }); + + it("should batch get items", async () => { + const res = await app.inject({ + method: "POST", + url: `/${tableName}/items/batch-get`, + headers, + payload: { + keys: [{ pk: "user-2" }, { pk: "user-3" }], + }, + }); + expect(res.statusCode).toBe(200); + expect(res.json().items).toHaveLength(2); + }); + + it("should execute PartiQL", async () => { + const res = await app.inject({ + method: "POST", + url: "/partiql", + headers, + payload: { + statement: `SELECT * FROM "${tableName}" WHERE pk = ?`, + parameters: ["user-1"], + }, + }); + expect(res.statusCode).toBe(200); + expect(res.json().items).toHaveLength(1); + }); + + it("should delete an item", async () => { + const res = await app.inject({ + method: "DELETE", + url: `/${tableName}/items`, + headers, + payload: { key: { pk: "user-1" } }, + }); + expect(res.statusCode).toBe(200); + expect(res.json()).toMatchObject({ success: true }); + }); + + it("should create a GSI", async () => { + const res = await app.inject({ + method: "POST", + url: `/${tableName}/indexes`, + headers, + payload: { + indexName: "name-index", + keySchema: [{ attributeName: "pk", keyType: "HASH" }], + projection: { projectionType: "ALL" }, + }, + }); + expect(res.statusCode).toBe(201); + expect(res.json().message).toContain("GSI"); + }); + + it("should delete the table", async () => { + const res = await app.inject({ + method: "DELETE", + url: `/${tableName}`, + headers, + }); + expect(res.statusCode).toBe(200); + expect(res.json()).toMatchObject({ success: true }); + }); +}); diff --git a/packages/backend/test/integration/health.integration.test.ts b/packages/backend/test/integration/health.integration.test.ts new file mode 100644 index 0000000..82ce79c --- /dev/null +++ b/packages/backend/test/integration/health.integration.test.ts @@ -0,0 +1,63 @@ +import Fastify, { type FastifyInstance } from "fastify"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { checkLocalstackHealth } from "../../src/health.js"; +import clientCachePlugin from "../../src/plugins/client-cache.js"; +import localstackConfigPlugin from "../../src/plugins/localstack-config.js"; +import { registerErrorHandler } from "../../src/shared/errors.js"; +import { getLocalstackHeaders } from "./app-helper.js"; + +describe("Health & Services Integration", () => { + let app: FastifyInstance; + const headers = getLocalstackHeaders(); + + beforeAll(async () => { + app = Fastify(); + registerErrorHandler(app); + await app.register(localstackConfigPlugin); + await app.register(clientCachePlugin); + + app.get("/api/health", async (request) => { + const { endpoint, region } = request.localstackConfig; + return checkLocalstackHealth(endpoint, region); + }); + + app.get("/api/services", async () => ({ + services: ["s3", "sqs", "sns", "iam", "cloudformation", "dynamodb"], + defaultEndpoint: + process.env.LOCALSTACK_ENDPOINT ?? "http://localhost:4566", + defaultRegion: process.env.LOCALSTACK_REGION ?? "us-east-1", + })); + + await app.ready(); + }); + + afterAll(async () => { + await app.close(); + }); + + it("should return health connected: true with active services", async () => { + const res = await app.inject({ + method: "GET", + url: "/api/health", + headers, + }); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.connected).toBe(true); + expect(body.services).toBeDefined(); + expect(Array.isArray(body.services)).toBe(true); + expect(body.services.length).toBeGreaterThan(0); + }); + + it("should return services list", async () => { + const res = await app.inject({ + method: "GET", + url: "/api/services", + headers, + }); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.services).toContain("s3"); + expect(body.services).toContain("sqs"); + }); +}); diff --git a/packages/backend/test/integration/iam.integration.test.ts b/packages/backend/test/integration/iam.integration.test.ts new file mode 100644 index 0000000..bd15b2b --- /dev/null +++ b/packages/backend/test/integration/iam.integration.test.ts @@ -0,0 +1,291 @@ +import type { FastifyInstance } from "fastify"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { iamRoutes } from "../../src/plugins/iam/routes.js"; +import { buildApp, getLocalstackHeaders } from "./app-helper.js"; + +describe("IAM Integration", () => { + let app: FastifyInstance; + const headers = getLocalstackHeaders(); + const userName = `test-user-${Date.now()}`; + const groupName = `test-group-${Date.now()}`; + let policyArn: string; + + beforeAll(async () => { + app = await buildApp(async (a) => { + await a.register(iamRoutes); + }); + }); + + afterAll(async () => { + await app.close(); + }); + + // --- Users --- + + it("should list users", async () => { + const res = await app.inject({ method: "GET", url: "/users", headers }); + expect(res.statusCode).toBe(200); + expect(res.json()).toHaveProperty("users"); + }); + + it("should create a user", async () => { + const res = await app.inject({ + method: "POST", + url: "/users", + headers, + payload: { userName }, + }); + expect(res.statusCode).toBe(201); + expect(res.json().message).toContain("created"); + }); + + it("should get user details", async () => { + const res = await app.inject({ + method: "GET", + url: `/users/${userName}`, + headers, + }); + expect(res.statusCode).toBe(200); + expect(res.json().userName).toBe(userName); + }); + + // --- Access Keys --- + + it("should create an access key", async () => { + const res = await app.inject({ + method: "POST", + url: `/users/${userName}/access-keys`, + headers, + }); + expect(res.statusCode).toBe(201); + const body = res.json(); + expect(body.accessKeyId).toBeDefined(); + expect(body.secretAccessKey).toBeDefined(); + }); + + it("should list access keys", async () => { + const res = await app.inject({ + method: "GET", + url: `/users/${userName}/access-keys`, + headers, + }); + expect(res.statusCode).toBe(200); + expect(res.json().accessKeys.length).toBeGreaterThanOrEqual(1); + }); + + // --- Groups --- + + it("should create a group", async () => { + const res = await app.inject({ + method: "POST", + url: "/groups", + headers, + payload: { groupName }, + }); + expect(res.statusCode).toBe(201); + expect(res.json().message).toContain("created"); + }); + + it("should list groups", async () => { + const res = await app.inject({ method: "GET", url: "/groups", headers }); + expect(res.statusCode).toBe(200); + expect(res.json().groups.length).toBeGreaterThanOrEqual(1); + }); + + it("should add user to group", async () => { + const res = await app.inject({ + method: "POST", + url: `/groups/${groupName}/members`, + headers, + payload: { userName }, + }); + expect(res.statusCode).toBe(200); + }); + + it("should list group members", async () => { + const res = await app.inject({ + method: "GET", + url: `/groups/${groupName}`, + headers, + }); + expect(res.statusCode).toBe(200); + expect( + res + .json() + .members.some((m: { userName: string }) => m.userName === userName), + ).toBe(true); + }); + + it("should remove user from group", async () => { + const res = await app.inject({ + method: "DELETE", + url: `/groups/${groupName}/members/${userName}`, + headers, + }); + + expect(res.statusCode).toBe(200); + }); + + // --- Inline Policies --- + + it("should put a user inline policy", async () => { + const policyDoc = JSON.stringify({ + Version: "2012-10-17", + Statement: [{ Effect: "Allow", Action: "s3:GetObject", Resource: "*" }], + }); + const res = await app.inject({ + method: "PUT", + url: `/users/${userName}/inline-policies/test-inline-policy`, + headers, + payload: { policyDocument: policyDoc }, + }); + expect(res.statusCode).toBe(200); + expect(res.json().message).toContain("saved"); + }); + + it("should list user inline policies", async () => { + const res = await app.inject({ + method: "GET", + url: `/users/${userName}/inline-policies`, + headers, + }); + expect(res.statusCode).toBe(200); + expect(res.json().policyNames).toContain("test-inline-policy"); + }); + + it("should get user inline policy", async () => { + const res = await app.inject({ + method: "GET", + url: `/users/${userName}/inline-policies/test-inline-policy`, + headers, + }); + expect(res.statusCode).toBe(200); + expect(res.json().policyName).toBe("test-inline-policy"); + }); + + it("should delete user inline policy", async () => { + const res = await app.inject({ + method: "DELETE", + url: `/users/${userName}/inline-policies/test-inline-policy`, + headers, + }); + expect(res.statusCode).toBe(200); + expect(res.json()).toMatchObject({ success: true }); + }); + + // --- Managed Policies --- + + it("should create a managed policy and retrieve its ARN", async () => { + const policyName = `test-policy-${Date.now()}`; + const policyDoc = JSON.stringify({ + Version: "2012-10-17", + Statement: [{ Effect: "Allow", Action: "s3:*", Resource: "*" }], + }); + const createRes = await app.inject({ + method: "POST", + url: "/policies", + headers, + payload: { policyName, policyDocument: policyDoc }, + }); + expect(createRes.statusCode).toBe(201); + expect(createRes.json().message).toContain("created"); + + // Retrieve ARN from the list + const listRes = await app.inject({ + method: "GET", + url: "/policies", + headers, + }); + expect(listRes.statusCode).toBe(200); + const policy = listRes + .json() + .policies.find( + (p: { policyName: string }) => p.policyName === policyName, + ); + expect(policy).toBeDefined(); + policyArn = policy.arn; + }); + + it("should get managed policy details", async () => { + const res = await app.inject({ + method: "GET", + url: `/policies/${encodeURIComponent(policyArn)}`, + headers, + }); + expect(res.statusCode).toBe(200); + expect(res.json().arn).toBe(policyArn); + }); + + it("should get managed policy document", async () => { + const res = await app.inject({ + method: "GET", + url: `/policies/${encodeURIComponent(policyArn)}/document`, + headers, + }); + + expect(res.statusCode).toBe(200); + expect(res.json().document).toBeDefined(); + }); + + it("should attach policy to user", async () => { + const res = await app.inject({ + method: "POST", + url: `/users/${userName}/attached-policies`, + headers, + payload: { policyArn }, + }); + expect(res.statusCode).toBe(200); + }); + + it("should list attached user policies", async () => { + const res = await app.inject({ + method: "GET", + url: `/users/${userName}/attached-policies`, + headers, + }); + expect(res.statusCode).toBe(200); + expect(res.json().attachedPolicies.length).toBeGreaterThanOrEqual(1); + }); + + it("should detach policy from user", async () => { + const res = await app.inject({ + method: "DELETE", + url: `/users/${userName}/attached-policies/${encodeURIComponent(policyArn)}`, + headers, + }); + + expect(res.statusCode).toBe(200); + }); + + // --- Cleanup --- + + it("should delete the managed policy", async () => { + const res = await app.inject({ + method: "DELETE", + url: `/policies/${encodeURIComponent(policyArn)}`, + headers, + }); + expect(res.statusCode).toBe(200); + expect(res.json()).toMatchObject({ success: true }); + }); + + it("should delete the group", async () => { + const res = await app.inject({ + method: "DELETE", + url: `/groups/${groupName}`, + headers, + }); + expect(res.statusCode).toBe(200); + expect(res.json()).toMatchObject({ success: true }); + }); + + it("should delete the user", async () => { + const res = await app.inject({ + method: "DELETE", + url: `/users/${userName}`, + headers, + }); + expect(res.statusCode).toBe(200); + expect(res.json()).toMatchObject({ success: true }); + }); +}); diff --git a/packages/backend/test/integration/s3.integration.test.ts b/packages/backend/test/integration/s3.integration.test.ts new file mode 100644 index 0000000..cbedd5b --- /dev/null +++ b/packages/backend/test/integration/s3.integration.test.ts @@ -0,0 +1,126 @@ +import type { FastifyInstance } from "fastify"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import s3Plugin from "../../src/plugins/s3/index.js"; +import { buildApp, getLocalstackHeaders } from "./app-helper.js"; + +describe("S3 Integration", () => { + let app: FastifyInstance; + const headers = getLocalstackHeaders(); + const bucketName = `test-bucket-${Date.now()}`; + + beforeAll(async () => { + app = await buildApp(async (a) => { + await a.register(s3Plugin); + }); + }); + + afterAll(async () => { + await app.close(); + }); + + it("should list buckets (initially may be empty)", async () => { + const res = await app.inject({ method: "GET", url: "/", headers }); + expect(res.statusCode).toBe(200); + expect(res.json()).toHaveProperty("buckets"); + }); + + it("should create a bucket", async () => { + const res = await app.inject({ + method: "POST", + url: "/", + headers, + payload: { name: bucketName }, + }); + expect(res.statusCode).toBe(201); + expect(res.json().message).toContain("created"); + }); + + it("should list the created bucket", async () => { + const res = await app.inject({ method: "GET", url: "/", headers }); + const body = res.json(); + expect( + body.buckets.some((b: { name: string }) => b.name === bucketName), + ).toBe(true); + }); + + it("should upload an object", async () => { + const _boundary = "----FormBoundary"; + const payload = + `------FormBoundary\r\n` + + `Content-Disposition: form-data; name="key"\r\n\r\n` + + `hello.txt\r\n` + + `------FormBoundary\r\n` + + `Content-Disposition: form-data; name="file"; filename="hello.txt"\r\n` + + `Content-Type: text/plain\r\n\r\n` + + `Hello LocalStack!\r\n` + + `------FormBoundary--\r\n`; + + const res = await app.inject({ + method: "POST", + url: `/${bucketName}/objects/upload`, + headers: { + ...headers, + "content-type": `multipart/form-data; boundary=----FormBoundary`, + }, + payload, + }); + expect(res.statusCode).toBe(200); + expect(res.json()).toMatchObject({ key: "hello.txt", bucket: bucketName }); + }); + + it("should list objects in bucket", async () => { + const res = await app.inject({ + method: "GET", + url: `/${bucketName}/objects`, + headers, + }); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect( + body.objects.some((o: { key: string }) => o.key === "hello.txt"), + ).toBe(true); + }); + + it("should get object properties", async () => { + const res = await app.inject({ + method: "GET", + url: `/${bucketName}/objects/properties?key=hello.txt`, + headers, + }); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.key).toBe("hello.txt"); + expect(body.contentType).toBe("text/plain"); + expect(body.size).toBeGreaterThan(0); + }); + + it("should download an object", async () => { + const res = await app.inject({ + method: "GET", + url: `/${bucketName}/objects/download?key=hello.txt`, + headers, + }); + expect(res.statusCode).toBe(200); + expect(res.body).toContain("Hello LocalStack!"); + }); + + it("should delete an object", async () => { + const res = await app.inject({ + method: "DELETE", + url: `/${bucketName}/objects?key=hello.txt`, + headers, + }); + expect(res.statusCode).toBe(200); + expect(res.json()).toMatchObject({ success: true }); + }); + + it("should delete the bucket", async () => { + const res = await app.inject({ + method: "DELETE", + url: `/${bucketName}`, + headers, + }); + expect(res.statusCode).toBe(200); + expect(res.json()).toMatchObject({ success: true }); + }); +}); diff --git a/packages/backend/test/integration/sns.integration.test.ts b/packages/backend/test/integration/sns.integration.test.ts new file mode 100644 index 0000000..8f7d083 --- /dev/null +++ b/packages/backend/test/integration/sns.integration.test.ts @@ -0,0 +1,205 @@ +import type { FastifyInstance } from "fastify"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { snsRoutes } from "../../src/plugins/sns/routes.js"; +import { buildApp, getLocalstackHeaders } from "./app-helper.js"; + +describe("SNS Integration", () => { + let app: FastifyInstance; + const headers = getLocalstackHeaders(); + const topicName = `test-topic-${Date.now()}`; + let subscriptionArn: string; + + beforeAll(async () => { + app = await buildApp(async (a) => { + await a.register(snsRoutes); + }); + }); + + afterAll(async () => { + await app.close(); + }); + + it("should list topics", async () => { + const res = await app.inject({ method: "GET", url: "/", headers }); + expect(res.statusCode).toBe(200); + expect(res.json()).toHaveProperty("topics"); + }); + + it("should create a topic", async () => { + const res = await app.inject({ + method: "POST", + url: "/", + headers, + payload: { name: topicName }, + }); + expect(res.statusCode).toBe(201); + expect(res.json().message).toContain("created"); + }); + + it("should get topic attributes", async () => { + const res = await app.inject({ + method: "GET", + url: `/${topicName}/attributes`, + headers, + }); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.topic.topicArn).toContain(topicName); + }); + + it("should set a topic attribute", async () => { + const res = await app.inject({ + method: "PUT", + url: `/${topicName}/attributes`, + headers, + payload: { attributeName: "DisplayName", attributeValue: "Test Topic" }, + }); + expect(res.statusCode).toBe(200); + expect(res.json()).toMatchObject({ success: true }); + }); + + it("should add tags to topic", async () => { + const res = await app.inject({ + method: "POST", + url: `/${topicName}/tags`, + headers, + payload: { tags: [{ key: "env", value: "test" }] }, + }); + expect(res.statusCode).toBe(200); + expect(res.json()).toMatchObject({ success: true }); + }); + + it("should list tags for topic", async () => { + const res = await app.inject({ + method: "GET", + url: `/${topicName}/tags`, + headers, + }); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.tags.some((t: { key: string }) => t.key === "env")).toBe(true); + }); + + it("should remove tags from topic", async () => { + const res = await app.inject({ + method: "DELETE", + url: `/${topicName}/tags`, + headers, + payload: { tagKeys: ["env"] }, + }); + expect(res.statusCode).toBe(200); + expect(res.json()).toMatchObject({ success: true }); + }); + + it("should create a subscription (SQS protocol)", async () => { + // Create an SQS endpoint subscription (LocalStack auto-confirms) + const region = headers["x-localstack-region"]; + const res = await app.inject({ + method: "POST", + url: `/${topicName}/subscriptions`, + headers, + payload: { + protocol: "sqs", + endpoint: `arn:aws:sqs:${region}:000000000000:dummy-queue`, + }, + }); + expect(res.statusCode).toBe(201); + expect(res.json().message).toContain("created"); + }); + + it("should list subscriptions for topic", async () => { + const res = await app.inject({ + method: "GET", + url: `/${topicName}/subscriptions`, + headers, + }); + expect(res.statusCode).toBe(200); + const subs = res.json().subscriptions; + expect(subs.length).toBeGreaterThanOrEqual(1); + // Capture subscriptionArn for subsequent tests + subscriptionArn = subs[0].subscriptionArn; + }); + + it("should get subscription attributes", async () => { + if (!subscriptionArn || subscriptionArn === "PendingConfirmation") return; + const res = await app.inject({ + method: "GET", + url: `/subscriptions/${encodeURIComponent(subscriptionArn)}/attributes`, + headers, + }); + expect(res.statusCode).toBe(200); + expect(res.json().subscription).toHaveProperty("protocol"); + }); + + it("should set subscription filter policy", async () => { + if (!subscriptionArn || subscriptionArn === "PendingConfirmation") return; + const res = await app.inject({ + method: "PUT", + url: `/subscriptions/${encodeURIComponent(subscriptionArn)}/filter-policy`, + headers, + payload: { filterPolicy: JSON.stringify({ event: ["order_placed"] }) }, + }); + expect(res.statusCode).toBe(200); + expect(res.json()).toMatchObject({ success: true }); + }); + + it("should list subscriptions by endpoint", async () => { + const region = headers["x-localstack-region"]; + const endpoint = `arn:aws:sqs:${region}:000000000000:dummy-queue`; + const res = await app.inject({ + method: "GET", + url: `/subscriptions/by-endpoint?endpoint=${encodeURIComponent(endpoint)}`, + headers, + }); + expect(res.statusCode).toBe(200); + expect(res.json()).toHaveProperty("subscriptions"); + }); + + it("should publish a message", async () => { + const res = await app.inject({ + method: "POST", + url: `/${topicName}/publish`, + headers, + payload: { message: "Hello SNS" }, + }); + expect(res.statusCode).toBe(201); + expect(res.json()).toHaveProperty("messageId"); + }); + + it("should publish a batch of messages", async () => { + const res = await app.inject({ + method: "POST", + url: `/${topicName}/publish-batch`, + headers, + payload: { + entries: [ + { id: "1", message: "Batch msg 1" }, + { id: "2", message: "Batch msg 2" }, + ], + }, + }); + expect(res.statusCode).toBe(201); + expect(res.json().successful.length).toBe(2); + }); + + it("should delete subscription", async () => { + if (!subscriptionArn || subscriptionArn === "PendingConfirmation") return; + const res = await app.inject({ + method: "DELETE", + url: `/subscriptions/${encodeURIComponent(subscriptionArn)}`, + headers, + }); + expect(res.statusCode).toBe(200); + expect(res.json()).toMatchObject({ success: true }); + }); + + it("should delete the topic", async () => { + const res = await app.inject({ + method: "DELETE", + url: `/${topicName}`, + headers, + }); + expect(res.statusCode).toBe(200); + expect(res.json()).toMatchObject({ success: true }); + }); +}); diff --git a/packages/backend/test/integration/sqs.integration.test.ts b/packages/backend/test/integration/sqs.integration.test.ts new file mode 100644 index 0000000..ce6a08f --- /dev/null +++ b/packages/backend/test/integration/sqs.integration.test.ts @@ -0,0 +1,112 @@ +import type { FastifyInstance } from "fastify"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { sqsRoutes } from "../../src/plugins/sqs/routes.js"; +import { buildApp, getLocalstackHeaders } from "./app-helper.js"; + +describe("SQS Integration", () => { + let app: FastifyInstance; + const headers = getLocalstackHeaders(); + const queueName = `test-queue-${Date.now()}`; + + beforeAll(async () => { + app = await buildApp(async (a) => { + await a.register(sqsRoutes); + }); + }); + + afterAll(async () => { + await app.close(); + }); + + it("should list queues", async () => { + const res = await app.inject({ method: "GET", url: "/", headers }); + expect(res.statusCode).toBe(200); + expect(res.json()).toHaveProperty("queues"); + }); + + it("should create a queue", async () => { + const res = await app.inject({ + method: "POST", + url: "/", + headers, + payload: { name: queueName }, + }); + expect(res.statusCode).toBe(201); + expect(res.json().message).toContain("created"); + }); + + it("should get queue attributes", async () => { + const res = await app.inject({ + method: "GET", + url: `/${queueName}/attributes`, + headers, + }); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.queueName).toBe(queueName); + expect(body).toHaveProperty("approximateNumberOfMessages"); + }); + + it("should send a message", async () => { + const res = await app.inject({ + method: "POST", + url: `/${queueName}/messages`, + headers, + payload: { body: "Hello from integration test" }, + }); + expect(res.statusCode).toBe(201); + expect(res.json()).toHaveProperty("messageId"); + }); + + it("should receive a message", async () => { + const res = await app.inject({ + method: "GET", + url: `/${queueName}/messages?maxMessages=1&waitTimeSeconds=1`, + headers, + }); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.messages.length).toBeGreaterThanOrEqual(1); + expect(body.messages[0].body).toBe("Hello from integration test"); + }); + + it("should delete a message", async () => { + // Receive first to get receipt handle + const receiveRes = await app.inject({ + method: "GET", + url: `/${queueName}/messages?maxMessages=1&waitTimeSeconds=1`, + headers, + }); + const { messages } = receiveRes.json(); + if (messages.length > 0) { + const res = await app.inject({ + method: "DELETE", + url: `/${queueName}/messages`, + headers, + payload: { receiptHandle: messages[0].receiptHandle }, + }); + expect(res.statusCode).toBe(200); + expect(res.json()).toMatchObject({ success: true }); + } + }); + + it("should purge the queue", async () => { + const res = await app.inject({ + method: "POST", + url: `/${queueName}/purge`, + headers, + }); + expect(res.statusCode).toBe(200); + expect(res.json()).toMatchObject({ success: true }); + }); + + it("should delete the queue", async () => { + const res = await app.inject({ + method: "DELETE", + url: `/${queueName}`, + headers, + }); + expect(res.statusCode).toBe(200); + expect(res.json()).toMatchObject({ success: true }); + }); +}); diff --git a/packages/backend/test/plugins/cloudformation/service.test.ts b/packages/backend/test/plugins/cloudformation/service.test.ts new file mode 100644 index 0000000..3884db6 --- /dev/null +++ b/packages/backend/test/plugins/cloudformation/service.test.ts @@ -0,0 +1,469 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { CloudFormationService } from "../../../src/plugins/cloudformation/service.js"; +import { AppError } from "../../../src/shared/errors.js"; + +const mockSend = vi.fn(); + +vi.mock("@aws-sdk/client-cloudformation", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + }; +}); + +function createService() { + const mockClient = { send: mockSend } as never; + return new CloudFormationService(mockClient); +} + +describe("CloudFormationService", () => { + beforeEach(() => { + mockSend.mockReset(); + }); + + // ------------------------------------------------------------------------- + describe("listStacks", () => { + it("returns mapped stacks when StackSummaries is populated", async () => { + const creationTime = new Date("2024-01-01T00:00:00.000Z"); + const lastUpdatedTime = new Date("2024-06-01T12:00:00.000Z"); + + mockSend.mockResolvedValueOnce({ + StackSummaries: [ + { + StackId: + "arn:aws:cloudformation:us-east-1:000000000000:stack/my-stack/abc", + StackName: "my-stack", + StackStatus: "CREATE_COMPLETE", + CreationTime: creationTime, + LastUpdatedTime: lastUpdatedTime, + TemplateDescription: "My test stack", + }, + { + StackId: + "arn:aws:cloudformation:us-east-1:000000000000:stack/other/def", + StackName: "other-stack", + StackStatus: "UPDATE_COMPLETE", + CreationTime: creationTime, + LastUpdatedTime: undefined, + TemplateDescription: undefined, + }, + ], + }); + + const service = createService(); + const result = await service.listStacks(); + + expect(result.stacks).toHaveLength(2); + expect(result.stacks[0]).toEqual({ + stackId: + "arn:aws:cloudformation:us-east-1:000000000000:stack/my-stack/abc", + stackName: "my-stack", + status: "CREATE_COMPLETE", + creationTime: "2024-01-01T00:00:00.000Z", + lastUpdatedTime: "2024-06-01T12:00:00.000Z", + description: "My test stack", + }); + expect(result.stacks[1]).toEqual({ + stackId: + "arn:aws:cloudformation:us-east-1:000000000000:stack/other/def", + stackName: "other-stack", + status: "UPDATE_COMPLETE", + creationTime: "2024-01-01T00:00:00.000Z", + lastUpdatedTime: undefined, + description: undefined, + }); + }); + + it("returns empty stacks array when StackSummaries is undefined", async () => { + mockSend.mockResolvedValueOnce({ StackSummaries: undefined }); + + const service = createService(); + const result = await service.listStacks(); + + expect(result.stacks).toEqual([]); + }); + + it("returns empty stacks array when StackSummaries is empty", async () => { + mockSend.mockResolvedValueOnce({ StackSummaries: [] }); + + const service = createService(); + const result = await service.listStacks(); + + expect(result.stacks).toEqual([]); + }); + + it("uses empty string for StackName when undefined", async () => { + mockSend.mockResolvedValueOnce({ + StackSummaries: [{ StackStatus: undefined, StackName: undefined }], + }); + + const service = createService(); + const result = await service.listStacks(); + + expect(result.stacks[0].stackName).toBe(""); + expect(result.stacks[0].status).toBe("UNKNOWN"); + }); + }); + + // ------------------------------------------------------------------------- + describe("getStack", () => { + it("returns full stack detail with outputs, parameters, and resources", async () => { + const creationTime = new Date("2024-01-01T00:00:00.000Z"); + + mockSend + // First call: DescribeStacksCommand + .mockResolvedValueOnce({ + Stacks: [ + { + StackId: "arn:stack/my-stack/abc", + StackName: "my-stack", + StackStatus: "CREATE_COMPLETE", + CreationTime: creationTime, + LastUpdatedTime: undefined, + Description: "A nice stack", + Outputs: [ + { + OutputKey: "BucketName", + OutputValue: "my-bucket", + Description: "The bucket name", + }, + ], + Parameters: [ + { + ParameterKey: "Env", + ParameterValue: "prod", + }, + ], + }, + ], + }) + // Second call: ListStackResourcesCommand + .mockResolvedValueOnce({ + StackResourceSummaries: [ + { + LogicalResourceId: "MyBucket", + PhysicalResourceId: "my-bucket-physical", + ResourceType: "AWS::S3::Bucket", + ResourceStatus: "CREATE_COMPLETE", + }, + ], + }); + + const service = createService(); + const result = await service.getStack("my-stack"); + + expect(result.stackName).toBe("my-stack"); + expect(result.status).toBe("CREATE_COMPLETE"); + expect(result.description).toBe("A nice stack"); + expect(result.creationTime).toBe("2024-01-01T00:00:00.000Z"); + expect(result.lastUpdatedTime).toBeUndefined(); + + expect(result.outputs).toEqual([ + { + outputKey: "BucketName", + outputValue: "my-bucket", + description: "The bucket name", + }, + ]); + + expect(result.parameters).toEqual([ + { + parameterKey: "Env", + parameterValue: "prod", + }, + ]); + + expect(result.resources).toEqual([ + { + logicalResourceId: "MyBucket", + physicalResourceId: "my-bucket-physical", + resourceType: "AWS::S3::Bucket", + resourceStatus: "CREATE_COMPLETE", + }, + ]); + }); + + it("returns empty arrays for outputs, parameters, resources when all undefined", async () => { + mockSend + .mockResolvedValueOnce({ + Stacks: [ + { + StackName: "bare-stack", + StackStatus: "CREATE_COMPLETE", + Outputs: undefined, + Parameters: undefined, + }, + ], + }) + .mockResolvedValueOnce({ + StackResourceSummaries: undefined, + }); + + const service = createService(); + const result = await service.getStack("bare-stack"); + + expect(result.outputs).toEqual([]); + expect(result.parameters).toEqual([]); + expect(result.resources).toEqual([]); + }); + + it("throws AppError 404 when Stacks is empty", async () => { + mockSend.mockResolvedValueOnce({ Stacks: [] }); + + const service = createService(); + await expect(service.getStack("missing-stack")).rejects.toMatchObject({ + name: "AppError", + statusCode: 404, + code: "STACK_NOT_FOUND", + message: "Stack 'missing-stack' not found", + }); + }); + + it("throws AppError 404 when Stacks is undefined", async () => { + mockSend.mockResolvedValueOnce({ Stacks: undefined }); + + const service = createService(); + await expect(service.getStack("gone")).rejects.toBeInstanceOf(AppError); + }); + + it("uses fallback values for StackName and StackStatus when undefined", async () => { + mockSend + .mockResolvedValueOnce({ + Stacks: [{ StackName: undefined, StackStatus: undefined }], + }) + .mockResolvedValueOnce({ StackResourceSummaries: [] }); + + const service = createService(); + const result = await service.getStack("any"); + + expect(result.stackName).toBe(""); + expect(result.status).toBe("UNKNOWN"); + }); + }); + + // ------------------------------------------------------------------------- + describe("createStack", () => { + it("creates a stack with templateBody", async () => { + mockSend.mockResolvedValueOnce({ + StackId: "arn:stack/new-stack/xyz", + }); + + const service = createService(); + const result = await service.createStack( + "new-stack", + "AWSTemplateFormatVersion: '2010-09-09'", + ); + + expect(result.message).toBe("Stack 'new-stack' creation initiated"); + expect(result.stackId).toBe("arn:stack/new-stack/xyz"); + }); + + it("creates a stack with templateURL", async () => { + mockSend.mockResolvedValueOnce({ StackId: "arn:stack/url-stack/abc" }); + + const service = createService(); + const result = await service.createStack( + "url-stack", + undefined, + "https://s3.amazonaws.com/my-bucket/template.yaml", + ); + + expect(result.message).toBe("Stack 'url-stack' creation initiated"); + expect(result.stackId).toBe("arn:stack/url-stack/abc"); + }); + + it("creates a stack with parameters", async () => { + mockSend.mockResolvedValueOnce({ StackId: "arn:stack/param-stack/def" }); + + const service = createService(); + const result = await service.createStack( + "param-stack", + "AWSTemplateFormatVersion: '2010-09-09'", + undefined, + [{ parameterKey: "Env", parameterValue: "staging" }], + ); + + expect(result.message).toBe("Stack 'param-stack' creation initiated"); + expect(mockSend).toHaveBeenCalledTimes(1); + }); + + it("throws AppError 400 when neither templateBody nor templateURL is provided", async () => { + const service = createService(); + await expect( + service.createStack("no-template-stack"), + ).rejects.toMatchObject({ + name: "AppError", + statusCode: 400, + code: "VALIDATION_ERROR", + message: "Either templateBody or templateURL must be provided", + }); + + // send should never be called + expect(mockSend).not.toHaveBeenCalled(); + }); + }); + + // ------------------------------------------------------------------------- + describe("updateStack", () => { + it("updates a stack successfully", async () => { + mockSend.mockResolvedValueOnce({ StackId: "arn:stack/my-stack/abc" }); + + const service = createService(); + const result = await service.updateStack( + "my-stack", + "AWSTemplateFormatVersion: '2010-09-09'", + ); + + expect(result.message).toBe("Stack 'my-stack' update initiated"); + expect(result.stackId).toBe("arn:stack/my-stack/abc"); + }); + + it("updates a stack with parameters", async () => { + mockSend.mockResolvedValueOnce({ StackId: "arn:stack/my-stack/abc" }); + + const service = createService(); + const result = await service.updateStack( + "my-stack", + "AWSTemplateFormatVersion: '2010-09-09'", + undefined, + [{ parameterKey: "Env", parameterValue: "staging" }], + ); + + expect(result.message).toBe("Stack 'my-stack' update initiated"); + expect(result.stackId).toBe("arn:stack/my-stack/abc"); + expect(mockSend).toHaveBeenCalledTimes(1); + // Verify the Parameters were mapped correctly + const cmdInput = mockSend.mock.calls[0][0].input; + expect(cmdInput.Parameters).toEqual([ + { ParameterKey: "Env", ParameterValue: "staging" }, + ]); + }); + + it("throws AppError 404 when ValidationError message includes 'does not exist'", async () => { + const validationError = new Error( + "Stack with id missing-stack does not exist", + ); + validationError.name = "ValidationError"; + mockSend.mockRejectedValueOnce(validationError); + + const service = createService(); + await expect(service.updateStack("missing-stack")).rejects.toMatchObject({ + name: "AppError", + statusCode: 404, + code: "STACK_NOT_FOUND", + message: "Stack 'missing-stack' not found", + }); + }); + + it("rethrows non-ValidationError errors unchanged", async () => { + const unexpectedError = new Error("AWS internal error"); + mockSend.mockRejectedValueOnce(unexpectedError); + + const service = createService(); + await expect(service.updateStack("my-stack")).rejects.toBe( + unexpectedError, + ); + }); + + it("rethrows ValidationError that does NOT include 'does not exist'", async () => { + const validationError = new Error("Template format error"); + validationError.name = "ValidationError"; + mockSend.mockRejectedValueOnce(validationError); + + const service = createService(); + await expect(service.updateStack("my-stack")).rejects.toBe( + validationError, + ); + }); + }); + + // ------------------------------------------------------------------------- + describe("deleteStack", () => { + it("deletes a stack and returns success: true", async () => { + mockSend.mockResolvedValueOnce({}); + + const service = createService(); + const result = await service.deleteStack("my-stack"); + + expect(result).toEqual({ success: true }); + expect(mockSend).toHaveBeenCalledTimes(1); + }); + }); + + // ------------------------------------------------------------------------- + describe("getStackEvents", () => { + it("returns mapped events", async () => { + const timestamp = new Date("2024-03-15T10:00:00.000Z"); + + mockSend.mockResolvedValueOnce({ + StackEvents: [ + { + EventId: "event-1", + LogicalResourceId: "MyBucket", + ResourceType: "AWS::S3::Bucket", + ResourceStatus: "CREATE_COMPLETE", + Timestamp: timestamp, + ResourceStatusReason: undefined, + }, + { + EventId: "event-2", + LogicalResourceId: "MyQueue", + ResourceType: "AWS::SQS::Queue", + ResourceStatus: "CREATE_FAILED", + Timestamp: timestamp, + ResourceStatusReason: "Resource creation cancelled", + }, + ], + }); + + const service = createService(); + const result = await service.getStackEvents("my-stack"); + + expect(result.events).toHaveLength(2); + expect(result.events[0]).toEqual({ + eventId: "event-1", + logicalResourceId: "MyBucket", + resourceType: "AWS::S3::Bucket", + resourceStatus: "CREATE_COMPLETE", + timestamp: "2024-03-15T10:00:00.000Z", + resourceStatusReason: undefined, + }); + expect(result.events[1].resourceStatusReason).toBe( + "Resource creation cancelled", + ); + }); + + it("returns empty events array when StackEvents is undefined", async () => { + mockSend.mockResolvedValueOnce({ StackEvents: undefined }); + + const service = createService(); + const result = await service.getStackEvents("my-stack"); + + expect(result.events).toEqual([]); + }); + }); + + // ------------------------------------------------------------------------- + describe("getTemplate", () => { + it("returns the template body string", async () => { + const templateBody = + "AWSTemplateFormatVersion: '2010-09-09'\nResources: {}"; + mockSend.mockResolvedValueOnce({ TemplateBody: templateBody }); + + const service = createService(); + const result = await service.getTemplate("my-stack"); + + expect(result.templateBody).toBe(templateBody); + }); + + it("returns empty string when TemplateBody is undefined", async () => { + mockSend.mockResolvedValueOnce({ TemplateBody: undefined }); + + const service = createService(); + const result = await service.getTemplate("my-stack"); + + expect(result.templateBody).toBe(""); + }); + }); +}); diff --git a/packages/backend/test/plugins/dynamodb/routes.test.ts b/packages/backend/test/plugins/dynamodb/routes.test.ts new file mode 100644 index 0000000..76ee34c --- /dev/null +++ b/packages/backend/test/plugins/dynamodb/routes.test.ts @@ -0,0 +1,223 @@ +import Fastify, { type FastifyInstance } from "fastify"; +import { + afterAll, + beforeAll, + describe, + expect, + it, + type Mock, + vi, +} from "vitest"; +import type { ClientCache } from "../../../src/aws/client-cache.js"; +import { dynamodbRoutes } from "../../../src/plugins/dynamodb/routes.js"; +import { registerErrorHandler } from "../../../src/shared/errors.js"; + +interface MockDynamoDBService { + listTables: Mock; + describeTable: Mock; + createTable: Mock; + deleteTable: Mock; + createGSI: Mock; + deleteGSI: Mock; + scanItems: Mock; + queryItems: Mock; + getItem: Mock; + putItem: Mock; + deleteItem: Mock; + batchWriteItems: Mock; + batchGetItems: Mock; + executePartiQL: Mock; + describeStream: Mock; + getStreamRecords: Mock; +} + +function createMockDynamoDBService(): MockDynamoDBService { + return { + listTables: vi.fn().mockResolvedValue({ tables: [] }), + describeTable: vi.fn().mockResolvedValue({ + tableName: "test-table", + tableStatus: "ACTIVE", + keySchema: [{ attributeName: "id", keyType: "HASH" }], + attributeDefinitions: [{ attributeName: "id", attributeType: "S" }], + }), + createTable: vi + .fn() + .mockResolvedValue({ message: "Table created successfully" }), + deleteTable: vi.fn().mockResolvedValue({ success: true }), + createGSI: vi.fn().mockResolvedValue({ message: "GSI creation initiated" }), + deleteGSI: vi.fn().mockResolvedValue({ success: true }), + scanItems: vi + .fn() + .mockResolvedValue({ items: [], count: 0, scannedCount: 0 }), + queryItems: vi + .fn() + .mockResolvedValue({ items: [], count: 0, scannedCount: 0 }), + getItem: vi + .fn() + .mockResolvedValue({ items: [], count: 0, scannedCount: 0 }), + putItem: vi.fn().mockResolvedValue({ message: "Item saved successfully" }), + deleteItem: vi.fn().mockResolvedValue({ success: true }), + batchWriteItems: vi + .fn() + .mockResolvedValue({ processedCount: 0, unprocessedCount: 0 }), + batchGetItems: vi.fn().mockResolvedValue({ items: [] }), + executePartiQL: vi.fn().mockResolvedValue({ items: [] }), + describeStream: vi.fn().mockResolvedValue({ + streamArn: + "arn:aws:dynamodb:us-east-1:000000000000:table/test-table/stream/2024-01-01T00:00:00.000", + streamLabel: "2024-01-01T00:00:00.000", + streamStatus: "ENABLED", + streamViewType: "NEW_AND_OLD_IMAGES", + shards: [ + { + shardId: "shardId-000000000001", + sequenceNumberRange: { + startingSequenceNumber: "100", + }, + }, + ], + }), + getStreamRecords: vi.fn().mockResolvedValue({ + records: [ + { + eventID: "event-1", + eventName: "INSERT", + eventSource: "aws:dynamodb", + dynamodb: { + keys: { id: "item-1" }, + newImage: { id: "item-1", value: "test" }, + sequenceNumber: "100", + sizeBytes: 26, + streamViewType: "NEW_AND_OLD_IMAGES", + }, + }, + ], + nextShardIterator: "next-iterator-token", + }), + }; +} + +vi.mock("../../../src/plugins/dynamodb/service.js", async (importOriginal) => { + const actual = + await importOriginal< + typeof import("../../../src/plugins/dynamodb/service.js") + >(); + return { + ...actual, + DynamoDBService: vi.fn(), + }; +}); + +import { DynamoDBService as DynamoDBServiceClass } from "../../../src/plugins/dynamodb/service.js"; + +describe("DynamoDB Routes - Stream endpoints", () => { + let app: FastifyInstance; + let mockService: MockDynamoDBService; + + beforeAll(async () => { + app = Fastify(); + registerErrorHandler(app); + + mockService = createMockDynamoDBService(); + + (DynamoDBServiceClass as unknown as Mock).mockImplementation( + () => mockService, + ); + + const mockClientCache = { + getClients: vi.fn().mockReturnValue({ + dynamodb: {}, + dynamodbDocument: {}, + dynamodbStreams: {}, + }), + }; + app.decorate("clientCache", mockClientCache as unknown as ClientCache); + + app.decorateRequest("localstackConfig", null); + app.addHook("onRequest", async (request) => { + request.localstackConfig = { + endpoint: "http://localhost:4566", + region: "us-east-1", + }; + }); + + await app.register(dynamodbRoutes); + await app.ready(); + }); + + afterAll(async () => { + await app.close(); + }); + + describe("DELETE /:tableName/indexes/:indexName (deleteGSI)", () => { + it("should delete a GSI and return success", async () => { + const response = await app.inject({ + method: "DELETE", + url: "/test-table/indexes/my-index", + }); + expect(response.statusCode).toBe(200); + const body = response.json<{ success: boolean }>(); + expect(body.success).toBe(true); + expect(mockService.deleteGSI).toHaveBeenCalledWith( + "test-table", + "my-index", + ); + }); + }); + + describe("GET /:tableName/streams (describeStream)", () => { + it("should return stream description for a table", async () => { + const response = await app.inject({ + method: "GET", + url: "/test-table/streams", + }); + expect(response.statusCode).toBe(200); + const body = response.json<{ + streamArn: string; + streamStatus: string; + shards: Array<{ shardId: string }>; + }>(); + expect(body.streamArn).toContain("test-table"); + expect(body.streamStatus).toBe("ENABLED"); + expect(body.shards).toHaveLength(1); + expect(body.shards[0].shardId).toBe("shardId-000000000001"); + expect(mockService.describeStream).toHaveBeenCalledWith("test-table"); + }); + }); + + describe("GET /:tableName/streams/records (getStreamRecords)", () => { + it("should return stream records for a table", async () => { + const response = await app.inject({ + method: "GET", + url: "/test-table/streams/records", + }); + expect(response.statusCode).toBe(200); + const body = response.json<{ + records: Array<{ eventID: string; eventName: string }>; + nextShardIterator: string; + }>(); + expect(body.records).toHaveLength(1); + expect(body.records[0].eventID).toBe("event-1"); + expect(body.records[0].eventName).toBe("INSERT"); + expect(body.nextShardIterator).toBe("next-iterator-token"); + expect(mockService.getStreamRecords).toHaveBeenCalledWith( + "test-table", + undefined, + undefined, + ); + }); + + it("should pass shardId and limit query params to service", async () => { + mockService.getStreamRecords.mockClear(); + await app.inject({ + method: "GET", + url: "/test-table/streams/records?shardId=shardId-000000000001&limit=10", + }); + expect(mockService.getStreamRecords).toHaveBeenCalledWith( + "test-table", + "shardId-000000000001", + 10, + ); + }); + }); +}); diff --git a/packages/backend/test/plugins/dynamodb/service.test.ts b/packages/backend/test/plugins/dynamodb/service.test.ts new file mode 100644 index 0000000..c2aad20 --- /dev/null +++ b/packages/backend/test/plugins/dynamodb/service.test.ts @@ -0,0 +1,1531 @@ +import type { DynamoDBClient } from "@aws-sdk/client-dynamodb"; +import type { DynamoDBStreamsClient } from "@aws-sdk/client-dynamodb-streams"; +import type { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb"; +import { describe, expect, it, vi } from "vitest"; +import { DynamoDBService } from "../../../src/plugins/dynamodb/service.js"; +import { AppError } from "../../../src/shared/errors.js"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeClients() { + const client = { send: vi.fn() } as unknown as DynamoDBClient; + const docClient = { send: vi.fn() } as unknown as DynamoDBDocumentClient; + const streamsClient = { + send: vi.fn(), + } as unknown as DynamoDBStreamsClient; + return { client, docClient, streamsClient }; +} + +function makeService( + client: DynamoDBClient, + docClient: DynamoDBDocumentClient, + streamsClient: DynamoDBStreamsClient, +) { + return new DynamoDBService(client, docClient, streamsClient); +} + +function namedError(name: string, message = "error") { + const err = new Error(message) as Error & { name: string }; + err.name = name; + return err; +} + +// --------------------------------------------------------------------------- +// handleError (tested indirectly via method calls) +// --------------------------------------------------------------------------- + +describe("handleError (via method delegation)", () => { + it("re-throws AppError as-is", async () => { + const { client, docClient, streamsClient } = makeClients(); + const service = makeService(client, docClient, streamsClient); + const original = new AppError("custom", 422, "CUSTOM"); + (client.send as ReturnType).mockRejectedValue(original); + + await expect(service.listTables()).rejects.toBe(original); + }); + + it("converts ResourceNotFoundException to AppError 404 TABLE_NOT_FOUND", async () => { + const { client, docClient, streamsClient } = makeClients(); + const service = makeService(client, docClient, streamsClient); + (client.send as ReturnType).mockRejectedValue( + namedError("ResourceNotFoundException", "Table not found"), + ); + + await expect(service.listTables()).rejects.toMatchObject({ + statusCode: 404, + code: "TABLE_NOT_FOUND", + }); + }); + + it("converts ResourceInUseException to AppError 409 TABLE_IN_USE", async () => { + const { client, docClient, streamsClient } = makeClients(); + const service = makeService(client, docClient, streamsClient); + (client.send as ReturnType).mockRejectedValue( + namedError("ResourceInUseException", "Table in use"), + ); + + await expect(service.listTables()).rejects.toMatchObject({ + statusCode: 409, + code: "TABLE_IN_USE", + }); + }); + + it("converts LimitExceededException to AppError 429 LIMIT_EXCEEDED", async () => { + const { client, docClient, streamsClient } = makeClients(); + const service = makeService(client, docClient, streamsClient); + (client.send as ReturnType).mockRejectedValue( + namedError("LimitExceededException", "Limit exceeded"), + ); + + await expect(service.listTables()).rejects.toMatchObject({ + statusCode: 429, + code: "LIMIT_EXCEEDED", + }); + }); + + it("re-throws unknown errors without wrapping", async () => { + const { client, docClient, streamsClient } = makeClients(); + const service = makeService(client, docClient, streamsClient); + const unknown = new TypeError("something unexpected"); + (client.send as ReturnType).mockRejectedValue(unknown); + + await expect(service.listTables()).rejects.toBe(unknown); + }); +}); + +// --------------------------------------------------------------------------- +// listTables +// --------------------------------------------------------------------------- + +describe("listTables", () => { + it("returns table summaries for all tables", async () => { + const { client, docClient, streamsClient } = makeClients(); + const service = makeService(client, docClient, streamsClient); + + const sendMock = client.send as ReturnType; + // First call: ListTablesCommand + sendMock.mockResolvedValueOnce({ TableNames: ["table-a", "table-b"] }); + // Describe calls for each table + sendMock.mockResolvedValueOnce({ + Table: { + TableName: "table-a", + TableStatus: "ACTIVE", + ItemCount: 10, + TableSizeBytes: 1024, + }, + }); + sendMock.mockResolvedValueOnce({ + Table: { + TableName: "table-b", + TableStatus: "CREATING", + ItemCount: 0, + TableSizeBytes: 0, + }, + }); + + const result = await service.listTables(); + + expect(result).toEqual({ + tables: [ + { + tableName: "table-a", + tableStatus: "ACTIVE", + itemCount: 10, + tableSizeBytes: 1024, + }, + { + tableName: "table-b", + tableStatus: "CREATING", + itemCount: 0, + tableSizeBytes: 0, + }, + ], + }); + }); + + it("handles empty table list", async () => { + const { client, docClient, streamsClient } = makeClients(); + const service = makeService(client, docClient, streamsClient); + + (client.send as ReturnType).mockResolvedValueOnce({ + TableNames: [], + }); + + const result = await service.listTables(); + expect(result).toEqual({ tables: [] }); + }); + + it("uses fallback values when table description fields are missing", async () => { + const { client, docClient, streamsClient } = makeClients(); + const service = makeService(client, docClient, streamsClient); + + const sendMock = client.send as ReturnType; + sendMock.mockResolvedValueOnce({ TableNames: ["table-x"] }); + // Table with no TableName/TableStatus in Table object + sendMock.mockResolvedValueOnce({ Table: {} }); + + const result = await service.listTables(); + expect(result?.tables[0]).toMatchObject({ + tableName: "table-x", + tableStatus: "UNKNOWN", + }); + }); + + it("propagates errors via handleError", async () => { + const { client, docClient, streamsClient } = makeClients(); + const service = makeService(client, docClient, streamsClient); + + (client.send as ReturnType).mockRejectedValue( + namedError("ResourceNotFoundException"), + ); + + await expect(service.listTables()).rejects.toMatchObject({ + statusCode: 404, + }); + }); +}); + +// --------------------------------------------------------------------------- +// describeTable +// --------------------------------------------------------------------------- + +describe("describeTable", () => { + it("returns full table details including GSI, LSI, streamSpec and provisionedThroughput", async () => { + const { client, docClient, streamsClient } = makeClients(); + const service = makeService(client, docClient, streamsClient); + + const now = new Date("2024-01-01T00:00:00Z"); + (client.send as ReturnType).mockResolvedValueOnce({ + Table: { + TableName: "my-table", + TableStatus: "ACTIVE", + TableArn: "arn:aws:dynamodb:us-east-1:000000000000:table/my-table", + CreationDateTime: now, + ItemCount: 5, + TableSizeBytes: 512, + LatestStreamArn: + "arn:aws:dynamodb:us-east-1:000000000000:table/my-table/stream/ts", + KeySchema: [{ AttributeName: "pk", KeyType: "HASH" }], + AttributeDefinitions: [{ AttributeName: "pk", AttributeType: "S" }], + ProvisionedThroughput: { ReadCapacityUnits: 5, WriteCapacityUnits: 5 }, + GlobalSecondaryIndexes: [ + { + IndexName: "gsi-1", + KeySchema: [{ AttributeName: "sk", KeyType: "RANGE" }], + Projection: { ProjectionType: "ALL", NonKeyAttributes: ["attr1"] }, + ProvisionedThroughput: { + ReadCapacityUnits: 2, + WriteCapacityUnits: 2, + }, + IndexStatus: "ACTIVE", + ItemCount: 3, + }, + ], + LocalSecondaryIndexes: [ + { + IndexName: "lsi-1", + KeySchema: [{ AttributeName: "lsk", KeyType: "RANGE" }], + Projection: { ProjectionType: "KEYS_ONLY" }, + }, + ], + StreamSpecification: { + StreamEnabled: true, + StreamViewType: "NEW_AND_OLD_IMAGES", + }, + }, + }); + + const result = await service.describeTable("my-table"); + + expect(result).toMatchObject({ + tableName: "my-table", + tableStatus: "ACTIVE", + tableArn: "arn:aws:dynamodb:us-east-1:000000000000:table/my-table", + creationDateTime: now.toISOString(), + itemCount: 5, + tableSizeBytes: 512, + latestStreamArn: + "arn:aws:dynamodb:us-east-1:000000000000:table/my-table/stream/ts", + keySchema: [{ attributeName: "pk", keyType: "HASH" }], + attributeDefinitions: [{ attributeName: "pk", attributeType: "S" }], + provisionedThroughput: { readCapacityUnits: 5, writeCapacityUnits: 5 }, + globalSecondaryIndexes: [ + { + indexName: "gsi-1", + keySchema: [{ attributeName: "sk", keyType: "RANGE" }], + projection: { projectionType: "ALL", nonKeyAttributes: ["attr1"] }, + provisionedThroughput: { + readCapacityUnits: 2, + writeCapacityUnits: 2, + }, + indexStatus: "ACTIVE", + itemCount: 3, + }, + ], + localSecondaryIndexes: [ + { + indexName: "lsi-1", + keySchema: [{ attributeName: "lsk", keyType: "RANGE" }], + projection: { projectionType: "KEYS_ONLY" }, + }, + ], + streamSpecification: { + streamEnabled: true, + streamViewType: "NEW_AND_OLD_IMAGES", + }, + }); + }); + + it("throws AppError 404 TABLE_NOT_FOUND when Table is null/undefined", async () => { + const { client, docClient, streamsClient } = makeClients(); + const service = makeService(client, docClient, streamsClient); + + (client.send as ReturnType).mockResolvedValueOnce({ + Table: undefined, + }); + + await expect(service.describeTable("missing")).rejects.toMatchObject({ + statusCode: 404, + code: "TABLE_NOT_FOUND", + }); + }); + + it("uses fallback values for missing optional fields", async () => { + const { client, docClient, streamsClient } = makeClients(); + const service = makeService(client, docClient, streamsClient); + + (client.send as ReturnType).mockResolvedValueOnce({ + Table: { + // no TableName, TableStatus, ProvisionedThroughput, GSI, LSI, streamSpec + KeySchema: [], + AttributeDefinitions: [], + }, + }); + + const result = await service.describeTable("fallback-table"); + expect(result).toMatchObject({ + tableName: "fallback-table", + tableStatus: "UNKNOWN", + keySchema: [], + attributeDefinitions: [], + provisionedThroughput: undefined, + globalSecondaryIndexes: undefined, + localSecondaryIndexes: undefined, + streamSpecification: undefined, + }); + }); + + it("returns undefined provisionedThroughput for GSI when not set", async () => { + const { client, docClient, streamsClient } = makeClients(); + const service = makeService(client, docClient, streamsClient); + + (client.send as ReturnType).mockResolvedValueOnce({ + Table: { + TableName: "my-table", + TableStatus: "ACTIVE", + KeySchema: [{ AttributeName: "pk", KeyType: "HASH" }], + AttributeDefinitions: [{ AttributeName: "pk", AttributeType: "S" }], + GlobalSecondaryIndexes: [ + { + IndexName: "gsi-no-throughput", + KeySchema: [{ AttributeName: "sk", KeyType: "HASH" }], + Projection: { ProjectionType: "ALL" }, + // ProvisionedThroughput intentionally absent + }, + ], + }, + }); + + const result = await service.describeTable("my-table"); + expect( + result?.globalSecondaryIndexes?.[0].provisionedThroughput, + ).toBeUndefined(); + }); + + it("uses 'ALL' as projectionType fallback when Projection is undefined on GSI", async () => { + const { client, docClient, streamsClient } = makeClients(); + const service = makeService(client, docClient, streamsClient); + + (client.send as ReturnType).mockResolvedValueOnce({ + Table: { + TableName: "my-table", + TableStatus: "ACTIVE", + KeySchema: [{ AttributeName: "pk", KeyType: "HASH" }], + AttributeDefinitions: [{ AttributeName: "pk", AttributeType: "S" }], + GlobalSecondaryIndexes: [ + { + IndexName: "gsi-no-projection", + KeySchema: [{ AttributeName: "sk", KeyType: "HASH" }], + // Projection intentionally absent — triggers ?? "ALL" branch + IndexStatus: "ACTIVE", + }, + ], + }, + }); + + const result = await service.describeTable("my-table"); + expect(result?.globalSecondaryIndexes?.[0].projection.projectionType).toBe( + "ALL", + ); + }); +}); + +// --------------------------------------------------------------------------- +// createTable +// --------------------------------------------------------------------------- + +describe("createTable", () => { + it("creates a basic table successfully", async () => { + const { client, docClient, streamsClient } = makeClients(); + const service = makeService(client, docClient, streamsClient); + + (client.send as ReturnType).mockResolvedValueOnce({}); + + const result = await service.createTable({ + tableName: "new-table", + keySchema: [{ attributeName: "pk", keyType: "HASH" }], + attributeDefinitions: [{ attributeName: "pk", attributeType: "S" }], + }); + + expect(result).toEqual({ message: "Table created successfully" }); + }); + + it("creates a table with default provisioned throughput when not specified", async () => { + const { client, docClient, streamsClient } = makeClients(); + const service = makeService(client, docClient, streamsClient); + + const sendMock = client.send as ReturnType; + sendMock.mockResolvedValueOnce({}); + + await service.createTable({ + tableName: "new-table", + keySchema: [{ attributeName: "pk", keyType: "HASH" }], + attributeDefinitions: [{ attributeName: "pk", attributeType: "S" }], + }); + + const callArg = sendMock.mock.calls[0][0]; + expect(callArg.input.ProvisionedThroughput).toEqual({ + ReadCapacityUnits: 5, + WriteCapacityUnits: 5, + }); + }); + + it("creates a table with GSIs", async () => { + const { client, docClient, streamsClient } = makeClients(); + const service = makeService(client, docClient, streamsClient); + + (client.send as ReturnType).mockResolvedValueOnce({}); + + const result = await service.createTable({ + tableName: "new-table", + keySchema: [{ attributeName: "pk", keyType: "HASH" }], + attributeDefinitions: [ + { attributeName: "pk", attributeType: "S" }, + { attributeName: "sk", attributeType: "S" }, + ], + globalSecondaryIndexes: [ + { + indexName: "gsi-1", + keySchema: [{ attributeName: "sk", keyType: "HASH" }], + projection: { projectionType: "ALL" }, + provisionedThroughput: { + readCapacityUnits: 3, + writeCapacityUnits: 3, + }, + }, + ], + }); + + expect(result).toEqual({ message: "Table created successfully" }); + }); + + it("creates a table with GSIs using default provisioned throughput", async () => { + const { client, docClient, streamsClient } = makeClients(); + const service = makeService(client, docClient, streamsClient); + + const sendMock = client.send as ReturnType; + sendMock.mockResolvedValueOnce({}); + + await service.createTable({ + tableName: "new-table", + keySchema: [{ attributeName: "pk", keyType: "HASH" }], + attributeDefinitions: [{ attributeName: "pk", attributeType: "S" }], + globalSecondaryIndexes: [ + { + indexName: "gsi-1", + keySchema: [{ attributeName: "sk", keyType: "HASH" }], + projection: { projectionType: "ALL" }, + // no provisionedThroughput – should default to 5/5 + }, + ], + }); + + const callArg = sendMock.mock.calls[0][0]; + expect( + callArg.input.GlobalSecondaryIndexes[0].ProvisionedThroughput, + ).toEqual({ ReadCapacityUnits: 5, WriteCapacityUnits: 5 }); + }); + + it("creates a table with LSIs", async () => { + const { client, docClient, streamsClient } = makeClients(); + const service = makeService(client, docClient, streamsClient); + + (client.send as ReturnType).mockResolvedValueOnce({}); + + const result = await service.createTable({ + tableName: "new-table", + keySchema: [{ attributeName: "pk", keyType: "HASH" }], + attributeDefinitions: [{ attributeName: "pk", attributeType: "S" }], + localSecondaryIndexes: [ + { + indexName: "lsi-1", + keySchema: [{ attributeName: "lsk", keyType: "RANGE" }], + projection: { projectionType: "KEYS_ONLY" }, + }, + ], + }); + + expect(result).toEqual({ message: "Table created successfully" }); + }); + + it("propagates errors via handleError", async () => { + const { client, docClient, streamsClient } = makeClients(); + const service = makeService(client, docClient, streamsClient); + + (client.send as ReturnType).mockRejectedValue( + namedError("ResourceInUseException"), + ); + + await expect( + service.createTable({ + tableName: "new-table", + keySchema: [{ attributeName: "pk", keyType: "HASH" }], + attributeDefinitions: [{ attributeName: "pk", attributeType: "S" }], + }), + ).rejects.toMatchObject({ statusCode: 409, code: "TABLE_IN_USE" }); + }); +}); + +// --------------------------------------------------------------------------- +// deleteTable +// --------------------------------------------------------------------------- + +describe("deleteTable", () => { + it("deletes a table successfully", async () => { + const { client, docClient, streamsClient } = makeClients(); + const service = makeService(client, docClient, streamsClient); + + (client.send as ReturnType).mockResolvedValueOnce({}); + + const result = await service.deleteTable("my-table"); + expect(result).toEqual({ success: true }); + }); + + it("propagates ResourceNotFoundException as AppError 404", async () => { + const { client, docClient, streamsClient } = makeClients(); + const service = makeService(client, docClient, streamsClient); + + (client.send as ReturnType).mockRejectedValue( + namedError("ResourceNotFoundException"), + ); + + await expect(service.deleteTable("ghost-table")).rejects.toMatchObject({ + statusCode: 404, + code: "TABLE_NOT_FOUND", + }); + }); +}); + +// --------------------------------------------------------------------------- +// scanItems +// --------------------------------------------------------------------------- + +describe("scanItems", () => { + it("returns scanned items with all response fields", async () => { + const { client, docClient, streamsClient } = makeClients(); + const service = makeService(client, docClient, streamsClient); + + (docClient.send as ReturnType).mockResolvedValueOnce({ + Items: [{ pk: "1", name: "Alice" }], + Count: 1, + ScannedCount: 2, + LastEvaluatedKey: { pk: "1" }, + }); + + const result = await service.scanItems("my-table", { + indexName: "gsi-1", + filterExpression: "#n = :v", + expressionAttributeNames: { "#n": "name" }, + expressionAttributeValues: { ":v": "Alice" }, + limit: 10, + exclusiveStartKey: { pk: "0" }, + projectionExpression: "pk, name", + }); + + expect(result).toEqual({ + items: [{ pk: "1", name: "Alice" }], + count: 1, + scannedCount: 2, + lastEvaluatedKey: { pk: "1" }, + }); + }); + + it("returns empty result when no items found", async () => { + const { client, docClient, streamsClient } = makeClients(); + const service = makeService(client, docClient, streamsClient); + + (docClient.send as ReturnType).mockResolvedValueOnce({ + Items: [], + Count: 0, + ScannedCount: 0, + }); + + const result = await service.scanItems("my-table"); + expect(result).toEqual({ + items: [], + count: 0, + scannedCount: 0, + lastEvaluatedKey: undefined, + }); + }); + + it("uses zero-fallbacks when Count/ScannedCount are absent", async () => { + const { client, docClient, streamsClient } = makeClients(); + const service = makeService(client, docClient, streamsClient); + + (docClient.send as ReturnType).mockResolvedValueOnce({}); + + const result = await service.scanItems("my-table"); + expect(result).toEqual({ + items: [], + count: 0, + scannedCount: 0, + lastEvaluatedKey: undefined, + }); + }); + + it("propagates errors via handleError", async () => { + const { client, docClient, streamsClient } = makeClients(); + const service = makeService(client, docClient, streamsClient); + + (docClient.send as ReturnType).mockRejectedValue( + namedError("ResourceNotFoundException"), + ); + + await expect(service.scanItems("my-table")).rejects.toMatchObject({ + statusCode: 404, + code: "TABLE_NOT_FOUND", + }); + }); +}); + +// --------------------------------------------------------------------------- +// queryItems +// --------------------------------------------------------------------------- + +describe("queryItems", () => { + it("returns queried items with all options", async () => { + const { client, docClient, streamsClient } = makeClients(); + const service = makeService(client, docClient, streamsClient); + + (docClient.send as ReturnType).mockResolvedValueOnce({ + Items: [{ pk: "1", sk: "A" }], + Count: 1, + ScannedCount: 1, + LastEvaluatedKey: { pk: "1", sk: "A" }, + }); + + const result = await service.queryItems("my-table", { + keyConditionExpression: "pk = :pk", + indexName: "gsi-1", + filterExpression: "#s = :s", + expressionAttributeNames: { "#s": "status" }, + expressionAttributeValues: { ":pk": "1", ":s": "active" }, + limit: 5, + exclusiveStartKey: { pk: "0" }, + projectionExpression: "pk, sk", + scanIndexForward: false, + }); + + expect(result).toEqual({ + items: [{ pk: "1", sk: "A" }], + count: 1, + scannedCount: 1, + lastEvaluatedKey: { pk: "1", sk: "A" }, + }); + }); + + it("returns defaults when response fields are absent", async () => { + const { client, docClient, streamsClient } = makeClients(); + const service = makeService(client, docClient, streamsClient); + + (docClient.send as ReturnType).mockResolvedValueOnce({}); + + const result = await service.queryItems("my-table", { + keyConditionExpression: "pk = :pk", + }); + + expect(result).toEqual({ + items: [], + count: 0, + scannedCount: 0, + lastEvaluatedKey: undefined, + }); + }); + + it("propagates errors via handleError", async () => { + const { client, docClient, streamsClient } = makeClients(); + const service = makeService(client, docClient, streamsClient); + + (docClient.send as ReturnType).mockRejectedValue( + namedError("ResourceNotFoundException"), + ); + + await expect( + service.queryItems("my-table", { keyConditionExpression: "pk = :pk" }), + ).rejects.toMatchObject({ statusCode: 404, code: "TABLE_NOT_FOUND" }); + }); +}); + +// --------------------------------------------------------------------------- +// getItem +// --------------------------------------------------------------------------- + +describe("getItem", () => { + it("returns item when found", async () => { + const { client, docClient, streamsClient } = makeClients(); + const service = makeService(client, docClient, streamsClient); + + (docClient.send as ReturnType).mockResolvedValueOnce({ + Item: { pk: "1", name: "Alice" }, + }); + + const result = await service.getItem("my-table", { pk: "1" }); + expect(result).toEqual({ + items: [{ pk: "1", name: "Alice" }], + count: 1, + scannedCount: 1, + }); + }); + + it("returns empty result when item not found", async () => { + const { client, docClient, streamsClient } = makeClients(); + const service = makeService(client, docClient, streamsClient); + + (docClient.send as ReturnType).mockResolvedValueOnce({ + Item: undefined, + }); + + const result = await service.getItem("my-table", { pk: "ghost" }); + expect(result).toEqual({ items: [], count: 0, scannedCount: 0 }); + }); + + it("propagates errors via handleError", async () => { + const { client, docClient, streamsClient } = makeClients(); + const service = makeService(client, docClient, streamsClient); + + (docClient.send as ReturnType).mockRejectedValue( + namedError("ResourceNotFoundException"), + ); + + await expect( + service.getItem("my-table", { pk: "1" }), + ).rejects.toMatchObject({ statusCode: 404, code: "TABLE_NOT_FOUND" }); + }); +}); + +// --------------------------------------------------------------------------- +// putItem +// --------------------------------------------------------------------------- + +describe("putItem", () => { + it("saves item and returns success message", async () => { + const { client, docClient, streamsClient } = makeClients(); + const service = makeService(client, docClient, streamsClient); + + (docClient.send as ReturnType).mockResolvedValueOnce({}); + + const result = await service.putItem("my-table", { pk: "1", name: "Bob" }); + expect(result).toEqual({ message: "Item saved successfully" }); + }); + + it("propagates errors via handleError", async () => { + const { client, docClient, streamsClient } = makeClients(); + const service = makeService(client, docClient, streamsClient); + + (docClient.send as ReturnType).mockRejectedValue( + namedError("LimitExceededException"), + ); + + await expect( + service.putItem("my-table", { pk: "1" }), + ).rejects.toMatchObject({ statusCode: 429, code: "LIMIT_EXCEEDED" }); + }); +}); + +// --------------------------------------------------------------------------- +// deleteItem +// --------------------------------------------------------------------------- + +describe("deleteItem", () => { + it("deletes item and returns success", async () => { + const { client, docClient, streamsClient } = makeClients(); + const service = makeService(client, docClient, streamsClient); + + (docClient.send as ReturnType).mockResolvedValueOnce({}); + + const result = await service.deleteItem("my-table", { pk: "1" }); + expect(result).toEqual({ success: true }); + }); + + it("propagates errors via handleError", async () => { + const { client, docClient, streamsClient } = makeClients(); + const service = makeService(client, docClient, streamsClient); + + (docClient.send as ReturnType).mockRejectedValue( + namedError("ResourceNotFoundException"), + ); + + await expect( + service.deleteItem("my-table", { pk: "1" }), + ).rejects.toMatchObject({ statusCode: 404, code: "TABLE_NOT_FOUND" }); + }); +}); + +// --------------------------------------------------------------------------- +// batchWriteItems +// --------------------------------------------------------------------------- + +describe("batchWriteItems", () => { + it("processes putItems and deleteKeys in a single batch", async () => { + const { client, docClient, streamsClient } = makeClients(); + const service = makeService(client, docClient, streamsClient); + + (docClient.send as ReturnType).mockResolvedValueOnce({ + UnprocessedItems: {}, + }); + + const result = await service.batchWriteItems( + "my-table", + [{ pk: "1" }, { pk: "2" }], + [{ pk: "3" }], + ); + + expect(result).toEqual({ processedCount: 3, unprocessedCount: 0 }); + }); + + it("handles unprocessed items correctly", async () => { + const { client, docClient, streamsClient } = makeClients(); + const service = makeService(client, docClient, streamsClient); + + (docClient.send as ReturnType).mockResolvedValueOnce({ + UnprocessedItems: { + "my-table": [{ PutRequest: { Item: { pk: "1" } } }], + }, + }); + + const result = await service.batchWriteItems("my-table", [ + { pk: "1" }, + { pk: "2" }, + ]); + + expect(result).toEqual({ processedCount: 1, unprocessedCount: 1 }); + }); + + it("splits large batches (>25 items) into multiple requests", async () => { + const { client, docClient, streamsClient } = makeClients(); + const service = makeService(client, docClient, streamsClient); + + const sendMock = docClient.send as ReturnType; + // First batch of 25 + sendMock.mockResolvedValueOnce({ UnprocessedItems: {} }); + // Second batch of remaining 5 + sendMock.mockResolvedValueOnce({ UnprocessedItems: {} }); + + const items = Array.from({ length: 30 }, (_, i) => ({ pk: String(i) })); + const result = await service.batchWriteItems("my-table", items); + + expect(sendMock).toHaveBeenCalledTimes(2); + expect(result).toEqual({ processedCount: 30, unprocessedCount: 0 }); + }); + + it("returns zero counts when no items or keys provided", async () => { + const { client, docClient, streamsClient } = makeClients(); + const service = makeService(client, docClient, streamsClient); + + // No calls expected since requests array is empty – loop doesn't execute + const result = await service.batchWriteItems("my-table"); + expect(result).toEqual({ processedCount: 0, unprocessedCount: 0 }); + }); + + it("propagates errors via handleError", async () => { + const { client, docClient, streamsClient } = makeClients(); + const service = makeService(client, docClient, streamsClient); + + (docClient.send as ReturnType).mockRejectedValue( + namedError("ResourceNotFoundException"), + ); + + await expect( + service.batchWriteItems("my-table", [{ pk: "1" }]), + ).rejects.toMatchObject({ statusCode: 404, code: "TABLE_NOT_FOUND" }); + }); +}); + +// --------------------------------------------------------------------------- +// batchGetItems +// --------------------------------------------------------------------------- + +describe("batchGetItems", () => { + it("retrieves items in a single batch", async () => { + const { client, docClient, streamsClient } = makeClients(); + const service = makeService(client, docClient, streamsClient); + + (docClient.send as ReturnType).mockResolvedValueOnce({ + Responses: { "my-table": [{ pk: "1" }, { pk: "2" }] }, + UnprocessedKeys: {}, + }); + + const result = await service.batchGetItems("my-table", [ + { pk: "1" }, + { pk: "2" }, + ]); + + expect(result).toEqual({ + items: [{ pk: "1" }, { pk: "2" }], + unprocessedKeys: undefined, + }); + }); + + it("passes projectionExpression to the request when provided", async () => { + const { client, docClient, streamsClient } = makeClients(); + const service = makeService(client, docClient, streamsClient); + + const sendMock = docClient.send as ReturnType; + sendMock.mockResolvedValueOnce({ + Responses: { "my-table": [{ pk: "1" }] }, + UnprocessedKeys: {}, + }); + + await service.batchGetItems("my-table", [{ pk: "1" }], "pk, name"); + + const callArg = sendMock.mock.calls[0][0]; + expect(callArg.input.RequestItems["my-table"].ProjectionExpression).toBe( + "pk, name", + ); + }); + + it("splits large batches (>100 keys) into multiple requests", async () => { + const { client, docClient, streamsClient } = makeClients(); + const service = makeService(client, docClient, streamsClient); + + const sendMock = docClient.send as ReturnType; + // First batch of 100 + sendMock.mockResolvedValueOnce({ + Responses: { + "my-table": Array.from({ length: 100 }, (_, i) => ({ pk: String(i) })), + }, + UnprocessedKeys: {}, + }); + // Second batch of 10 + sendMock.mockResolvedValueOnce({ + Responses: { + "my-table": Array.from({ length: 10 }, (_, i) => ({ + pk: String(100 + i), + })), + }, + UnprocessedKeys: {}, + }); + + const keys = Array.from({ length: 110 }, (_, i) => ({ pk: String(i) })); + const result = await service.batchGetItems("my-table", keys); + + expect(sendMock).toHaveBeenCalledTimes(2); + expect(result?.items).toHaveLength(110); + expect(result?.unprocessedKeys).toBeUndefined(); + }); + + it("collects unprocessed keys from all batches", async () => { + const { client, docClient, streamsClient } = makeClients(); + const service = makeService(client, docClient, streamsClient); + + (docClient.send as ReturnType).mockResolvedValueOnce({ + Responses: { "my-table": [] }, + UnprocessedKeys: { + "my-table": { Keys: [{ pk: "1" }] }, + }, + }); + + const result = await service.batchGetItems("my-table", [{ pk: "1" }]); + expect(result?.unprocessedKeys).toEqual([{ pk: "1" }]); + }); + + it("propagates errors via handleError", async () => { + const { client, docClient, streamsClient } = makeClients(); + const service = makeService(client, docClient, streamsClient); + + (docClient.send as ReturnType).mockRejectedValue( + namedError("ResourceNotFoundException"), + ); + + await expect( + service.batchGetItems("my-table", [{ pk: "1" }]), + ).rejects.toMatchObject({ statusCode: 404, code: "TABLE_NOT_FOUND" }); + }); +}); + +// --------------------------------------------------------------------------- +// createGSI +// --------------------------------------------------------------------------- + +describe("createGSI", () => { + it("initiates GSI creation with provisionedThroughput", async () => { + const { client, docClient, streamsClient } = makeClients(); + const service = makeService(client, docClient, streamsClient); + + (client.send as ReturnType).mockResolvedValueOnce({}); + + const result = await service.createGSI("my-table", { + indexName: "new-gsi", + keySchema: [{ attributeName: "sk", keyType: "HASH" }], + projection: { projectionType: "ALL" }, + provisionedThroughput: { readCapacityUnits: 10, writeCapacityUnits: 10 }, + }); + + expect(result).toEqual({ message: "GSI 'new-gsi' creation initiated" }); + }); + + it("uses default provisionedThroughput when not provided", async () => { + const { client, docClient, streamsClient } = makeClients(); + const service = makeService(client, docClient, streamsClient); + + const sendMock = client.send as ReturnType; + sendMock.mockResolvedValueOnce({}); + + await service.createGSI("my-table", { + indexName: "new-gsi", + keySchema: [{ attributeName: "sk", keyType: "HASH" }], + projection: { projectionType: "ALL" }, + }); + + const callArg = sendMock.mock.calls[0][0]; + const create = callArg.input.GlobalSecondaryIndexUpdates[0].Create; + expect(create.ProvisionedThroughput).toEqual({ + ReadCapacityUnits: 5, + WriteCapacityUnits: 5, + }); + }); + + it("propagates errors via handleError", async () => { + const { client, docClient, streamsClient } = makeClients(); + const service = makeService(client, docClient, streamsClient); + + (client.send as ReturnType).mockRejectedValue( + namedError("ResourceNotFoundException"), + ); + + await expect( + service.createGSI("my-table", { + indexName: "new-gsi", + keySchema: [{ attributeName: "sk", keyType: "HASH" }], + projection: { projectionType: "ALL" }, + }), + ).rejects.toMatchObject({ statusCode: 404, code: "TABLE_NOT_FOUND" }); + }); +}); + +// --------------------------------------------------------------------------- +// deleteGSI +// --------------------------------------------------------------------------- + +describe("deleteGSI", () => { + it("deletes GSI successfully", async () => { + const { client, docClient, streamsClient } = makeClients(); + const service = makeService(client, docClient, streamsClient); + + (client.send as ReturnType).mockResolvedValueOnce({}); + + const result = await service.deleteGSI("my-table", "old-gsi"); + expect(result).toEqual({ success: true }); + }); + + it("propagates errors via handleError", async () => { + const { client, docClient, streamsClient } = makeClients(); + const service = makeService(client, docClient, streamsClient); + + (client.send as ReturnType).mockRejectedValue( + namedError("ResourceNotFoundException"), + ); + + await expect( + service.deleteGSI("my-table", "old-gsi"), + ).rejects.toMatchObject({ statusCode: 404, code: "TABLE_NOT_FOUND" }); + }); +}); + +// --------------------------------------------------------------------------- +// describeStream +// --------------------------------------------------------------------------- + +describe("describeStream", () => { + it("returns stream description with shards when stream ARN exists", async () => { + const { client, docClient, streamsClient } = makeClients(); + const service = makeService(client, docClient, streamsClient); + + const streamArn = + "arn:aws:dynamodb:us-east-1:000000000000:table/my-table/stream/ts"; + + (client.send as ReturnType).mockResolvedValueOnce({ + Table: { LatestStreamArn: streamArn, StreamSpecification: {} }, + }); + + (streamsClient.send as ReturnType).mockResolvedValueOnce({ + StreamDescription: { + StreamArn: streamArn, + StreamLabel: "ts", + StreamStatus: "ENABLED", + StreamViewType: "NEW_AND_OLD_IMAGES", + Shards: [ + { + ShardId: "shard-0001", + ParentShardId: undefined, + SequenceNumberRange: { + StartingSequenceNumber: "100", + EndingSequenceNumber: "200", + }, + }, + ], + }, + }); + + const result = await service.describeStream("my-table"); + + expect(result).toMatchObject({ + streamArn, + streamLabel: "ts", + streamStatus: "ENABLED", + streamViewType: "NEW_AND_OLD_IMAGES", + shards: [ + { + shardId: "shard-0001", + sequenceNumberRange: { + startingSequenceNumber: "100", + endingSequenceNumber: "200", + }, + }, + ], + }); + }); + + it("returns undefined fields when table has no stream ARN", async () => { + const { client, docClient, streamsClient } = makeClients(); + const service = makeService(client, docClient, streamsClient); + + (client.send as ReturnType).mockResolvedValueOnce({ + Table: { + LatestStreamArn: undefined, + StreamSpecification: { StreamViewType: "NEW_IMAGE" }, + }, + }); + + const result = await service.describeStream("my-table"); + + expect(result).toEqual({ + streamArn: undefined, + streamLabel: undefined, + streamStatus: undefined, + streamViewType: "NEW_IMAGE", + shards: undefined, + }); + // streamsClient should NOT have been called + expect(streamsClient.send).not.toHaveBeenCalled(); + }); + + it("propagates errors via handleError", async () => { + const { client, docClient, streamsClient } = makeClients(); + const service = makeService(client, docClient, streamsClient); + + (client.send as ReturnType).mockRejectedValue( + namedError("ResourceNotFoundException"), + ); + + await expect(service.describeStream("my-table")).rejects.toMatchObject({ + statusCode: 404, + code: "TABLE_NOT_FOUND", + }); + }); +}); + +// --------------------------------------------------------------------------- +// getStreamRecords +// --------------------------------------------------------------------------- + +describe("getStreamRecords", () => { + const streamArn = + "arn:aws:dynamodb:us-east-1:000000000000:table/my-table/stream/ts"; + + it("returns records with Keys, NewImage, and OldImage", async () => { + const { client, docClient, streamsClient } = makeClients(); + const service = makeService(client, docClient, streamsClient); + + (client.send as ReturnType).mockResolvedValueOnce({ + Table: { LatestStreamArn: streamArn }, + }); + + const streamsSendMock = streamsClient.send as ReturnType; + // GetShardIterator + streamsSendMock.mockResolvedValueOnce({ + ShardIterator: "iter-abc", + }); + // GetRecords + streamsSendMock.mockResolvedValueOnce({ + Records: [ + { + eventID: "evt-1", + eventName: "MODIFY", + eventSource: "aws:dynamodb", + dynamodb: { + Keys: { pk: { S: "1" } }, + NewImage: { pk: { S: "1" }, name: { S: "Alice" } }, + OldImage: { pk: { S: "1" }, name: { S: "Bob" } }, + SequenceNumber: "100", + SizeBytes: 64, + StreamViewType: "NEW_AND_OLD_IMAGES", + }, + }, + ], + NextShardIterator: "iter-xyz", + }); + + const result = await service.getStreamRecords("my-table", "shard-0001", 10); + + expect(result).toMatchObject({ + records: [ + { + eventID: "evt-1", + eventName: "MODIFY", + eventSource: "aws:dynamodb", + dynamodb: { + keys: { pk: "1" }, + newImage: { pk: "1", name: "Alice" }, + oldImage: { pk: "1", name: "Bob" }, + sequenceNumber: "100", + sizeBytes: 64, + streamViewType: "NEW_AND_OLD_IMAGES", + }, + }, + ], + nextShardIterator: "iter-xyz", + }); + }); + + it("throws AppError 404 STREAM_NOT_FOUND when no stream ARN", async () => { + const { client, docClient, streamsClient } = makeClients(); + const service = makeService(client, docClient, streamsClient); + + (client.send as ReturnType).mockResolvedValueOnce({ + Table: { LatestStreamArn: undefined }, + }); + + await expect( + service.getStreamRecords("my-table", "shard-0001"), + ).rejects.toMatchObject({ statusCode: 404, code: "STREAM_NOT_FOUND" }); + }); + + it("uses the first shard when no shardId is provided", async () => { + const { client, docClient, streamsClient } = makeClients(); + const service = makeService(client, docClient, streamsClient); + + (client.send as ReturnType).mockResolvedValueOnce({ + Table: { LatestStreamArn: streamArn }, + }); + + const streamsSendMock = streamsClient.send as ReturnType; + // DescribeStream (to get first shard) + streamsSendMock.mockResolvedValueOnce({ + StreamDescription: { + Shards: [{ ShardId: "shard-auto" }], + }, + }); + // GetShardIterator + streamsSendMock.mockResolvedValueOnce({ ShardIterator: "iter-auto" }); + // GetRecords + streamsSendMock.mockResolvedValueOnce({ + Records: [], + NextShardIterator: undefined, + }); + + const result = await service.getStreamRecords("my-table"); + expect(result).toEqual({ records: [], nextShardIterator: undefined }); + + // Verify DescribeStream was called to auto-pick the shard + expect(streamsSendMock.mock.calls[0][0].input).toMatchObject({ + StreamArn: streamArn, + }); + }); + + it("returns empty records when no shards are available", async () => { + const { client, docClient, streamsClient } = makeClients(); + const service = makeService(client, docClient, streamsClient); + + (client.send as ReturnType).mockResolvedValueOnce({ + Table: { LatestStreamArn: streamArn }, + }); + + (streamsClient.send as ReturnType).mockResolvedValueOnce({ + StreamDescription: { Shards: [] }, + }); + + // No shardId provided, and shards list is empty + const result = await service.getStreamRecords("my-table"); + expect(result).toEqual({ records: [], nextShardIterator: undefined }); + }); + + it("returns empty records when no shard iterator is returned", async () => { + const { client, docClient, streamsClient } = makeClients(); + const service = makeService(client, docClient, streamsClient); + + (client.send as ReturnType).mockResolvedValueOnce({ + Table: { LatestStreamArn: streamArn }, + }); + + const streamsSendMock = streamsClient.send as ReturnType; + // GetShardIterator returns no iterator + streamsSendMock.mockResolvedValueOnce({ ShardIterator: undefined }); + + const result = await service.getStreamRecords("my-table", "shard-0001"); + expect(result).toEqual({ records: [], nextShardIterator: undefined }); + }); + + it("handles records without dynamodb field", async () => { + const { client, docClient, streamsClient } = makeClients(); + const service = makeService(client, docClient, streamsClient); + + (client.send as ReturnType).mockResolvedValueOnce({ + Table: { LatestStreamArn: streamArn }, + }); + + const streamsSendMock = streamsClient.send as ReturnType; + streamsSendMock.mockResolvedValueOnce({ ShardIterator: "iter-abc" }); + streamsSendMock.mockResolvedValueOnce({ + Records: [ + { eventID: "evt-nodb", eventName: "INSERT", dynamodb: undefined }, + ], + NextShardIterator: undefined, + }); + + const result = await service.getStreamRecords("my-table", "shard-0001"); + expect(result?.records[0].dynamodb).toBeUndefined(); + }); + + it("handles dynamodb record where Keys, NewImage, OldImage are absent", async () => { + const { client, docClient, streamsClient } = makeClients(); + const service = makeService(client, docClient, streamsClient); + + (client.send as ReturnType).mockResolvedValueOnce({ + Table: { LatestStreamArn: streamArn }, + }); + + const streamsSendMock = streamsClient.send as ReturnType; + streamsSendMock.mockResolvedValueOnce({ ShardIterator: "iter-abc" }); + streamsSendMock.mockResolvedValueOnce({ + Records: [ + { + eventID: "evt-partial", + eventName: "REMOVE", + dynamodb: { + // Keys, NewImage, OldImage all absent + SequenceNumber: "200", + SizeBytes: 32, + StreamViewType: "KEYS_ONLY", + }, + }, + ], + NextShardIterator: undefined, + }); + + const result = await service.getStreamRecords("my-table", "shard-0001"); + expect(result?.records[0].dynamodb).toMatchObject({ + keys: undefined, + newImage: undefined, + oldImage: undefined, + sequenceNumber: "200", + }); + }); +}); + +// --------------------------------------------------------------------------- +// executePartiQL +// --------------------------------------------------------------------------- + +describe("executePartiQL", () => { + it("executes a statement and returns unmarshalled items", async () => { + const { client, docClient, streamsClient } = makeClients(); + const service = makeService(client, docClient, streamsClient); + + (client.send as ReturnType).mockResolvedValueOnce({ + Items: [{ pk: { S: "1" }, name: { S: "Alice" } }], + }); + + const result = await service.executePartiQL( + "SELECT * FROM my-table WHERE pk = ?", + ); + + expect(result?.items).toEqual([{ pk: "1", name: "Alice" }]); + }); + + it("maps string, number, boolean, and null parameters correctly", async () => { + const { client, docClient, streamsClient } = makeClients(); + const service = makeService(client, docClient, streamsClient); + + const sendMock = client.send as ReturnType; + sendMock.mockResolvedValueOnce({ Items: [] }); + + await service.executePartiQL( + "SELECT * FROM my-table WHERE pk = ? AND age = ? AND active = ? AND extra = ?", + ["value", 42, true, null], + ); + + const callArg = sendMock.mock.calls[0][0]; + expect(callArg.input.Parameters).toEqual([ + { S: "value" }, + { N: "42" }, + { BOOL: true }, + { NULL: true }, + ]); + }); + + it("falls back to using the raw item when unmarshall fails", async () => { + const { client, docClient, streamsClient } = makeClients(); + const service = makeService(client, docClient, streamsClient); + + // An item that looks like a valid DynamoDB attribute map but will cause + // unmarshall to throw because a key maps to an invalid descriptor. + // We can simulate this by passing an object that causes unmarshall to throw. + // Since `unmarshall` from @aws-sdk/util-dynamodb expects AttributeValue map, + // passing a plain object with unknown type keys triggers an error in some SDK versions. + // We monkey-patch by providing a value that genuinely makes unmarshall throw. + const badItem = { pk: { UNKNOWN_TYPE: "x" } } as unknown as Record< + string, + import("@aws-sdk/client-dynamodb").AttributeValue + >; + (client.send as ReturnType).mockResolvedValueOnce({ + Items: [badItem], + }); + + // The service catches the unmarshall exception and returns the raw item + const result = await service.executePartiQL("SELECT * FROM my-table"); + expect(result?.items[0]).toBe(badItem); + }); + + it("maps unknown-type parameters using String coercion", async () => { + const { client, docClient, streamsClient } = makeClients(); + const service = makeService(client, docClient, streamsClient); + + const sendMock = client.send as ReturnType; + sendMock.mockResolvedValueOnce({ Items: [] }); + + const obj = { custom: "object" }; + await service.executePartiQL("SELECT * FROM t", [obj]); + + const callArg = sendMock.mock.calls[0][0]; + expect(callArg.input.Parameters).toEqual([{ S: String(obj) }]); + }); + + it("sends no Parameters when parameters array is empty", async () => { + const { client, docClient, streamsClient } = makeClients(); + const service = makeService(client, docClient, streamsClient); + + const sendMock = client.send as ReturnType; + sendMock.mockResolvedValueOnce({ Items: [] }); + + await service.executePartiQL("SELECT * FROM my-table", []); + + const callArg = sendMock.mock.calls[0][0]; + expect(callArg.input.Parameters).toBeUndefined(); + }); + + it("propagates errors via handleError", async () => { + const { client, docClient, streamsClient } = makeClients(); + const service = makeService(client, docClient, streamsClient); + + (client.send as ReturnType).mockRejectedValue( + namedError("ResourceNotFoundException"), + ); + + await expect( + service.executePartiQL("SELECT * FROM ghost-table"), + ).rejects.toMatchObject({ statusCode: 404, code: "TABLE_NOT_FOUND" }); + }); + + it("returns empty items array when Items is undefined in response", async () => { + const { client, docClient, streamsClient } = makeClients(); + const service = makeService(client, docClient, streamsClient); + + (client.send as ReturnType).mockResolvedValueOnce({ + // Items intentionally absent + }); + + const result = await service.executePartiQL("SELECT * FROM my-table"); + expect(result?.items).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// Additional branch coverage for describeStream and getStreamRecords +// --------------------------------------------------------------------------- + +describe("describeStream - shard ShardId undefined branch", () => { + it("uses empty string for shardId when ShardId is undefined in shard", async () => { + const streamArn = + "arn:aws:dynamodb:us-east-1:000000000000:table/my-table/stream/ts"; + const { client, docClient, streamsClient } = makeClients(); + const service = makeService(client, docClient, streamsClient); + + (client.send as ReturnType).mockResolvedValueOnce({ + Table: { LatestStreamArn: streamArn }, + }); + + (streamsClient.send as ReturnType).mockResolvedValueOnce({ + StreamDescription: { + StreamArn: streamArn, + StreamStatus: "ENABLED", + Shards: [ + { + // ShardId intentionally absent to trigger ?? "" branch + ShardId: undefined, + SequenceNumberRange: {}, + }, + ], + }, + }); + + const result = await service.describeStream("my-table"); + expect(result?.shards?.[0].shardId).toBe(""); + }); +}); + +describe("getStreamRecords - Records undefined branch", () => { + it("returns empty records when Records is undefined in GetRecords response", async () => { + const streamArn = + "arn:aws:dynamodb:us-east-1:000000000000:table/my-table/stream/ts"; + const { client, docClient, streamsClient } = makeClients(); + const service = makeService(client, docClient, streamsClient); + + (client.send as ReturnType).mockResolvedValueOnce({ + Table: { LatestStreamArn: streamArn }, + }); + + const streamsSendMock = streamsClient.send as ReturnType; + streamsSendMock.mockResolvedValueOnce({ ShardIterator: "iter-abc" }); + streamsSendMock.mockResolvedValueOnce({ + // Records intentionally absent to trigger ?? [] branch + NextShardIterator: undefined, + }); + + const result = await service.getStreamRecords("my-table", "shard-0001"); + expect(result?.records).toEqual([]); + }); +}); diff --git a/packages/backend/test/plugins/iam/routes.test.ts b/packages/backend/test/plugins/iam/routes.test.ts new file mode 100644 index 0000000..efb9f38 --- /dev/null +++ b/packages/backend/test/plugins/iam/routes.test.ts @@ -0,0 +1,830 @@ +import Fastify, { type FastifyInstance } from "fastify"; +import { + afterAll, + beforeAll, + describe, + expect, + it, + type Mock, + vi, +} from "vitest"; +import type { ClientCache } from "../../../src/aws/client-cache.js"; +import { iamRoutes } from "../../../src/plugins/iam/routes.js"; +import { registerErrorHandler } from "../../../src/shared/errors.js"; + +interface MockIAMService { + listUsers: Mock; + createUser: Mock; + getUser: Mock; + deleteUser: Mock; + listAccessKeys: Mock; + createAccessKey: Mock; + deleteAccessKey: Mock; + updateAccessKey: Mock; + listUserPolicies: Mock; + getUserPolicy: Mock; + putUserPolicy: Mock; + deleteUserPolicy: Mock; + listAttachedUserPolicies: Mock; + attachUserPolicy: Mock; + detachUserPolicy: Mock; + listGroupsForUser: Mock; + listGroups: Mock; + createGroup: Mock; + getGroup: Mock; + deleteGroup: Mock; + addUserToGroup: Mock; + removeUserFromGroup: Mock; + listGroupPolicies: Mock; + getGroupPolicy: Mock; + putGroupPolicy: Mock; + deleteGroupPolicy: Mock; + listAttachedGroupPolicies: Mock; + attachGroupPolicy: Mock; + detachGroupPolicy: Mock; + listManagedPolicies: Mock; + createPolicy: Mock; + getPolicy: Mock; + deletePolicy: Mock; + getPolicyDocument: Mock; + listPolicyVersions: Mock; + createPolicyVersion: Mock; + deletePolicyVersion: Mock; + setDefaultPolicyVersion: Mock; +} + +function createMockIAMService(): MockIAMService { + return { + listUsers: vi.fn().mockResolvedValue({ users: [] }), + createUser: vi + .fn() + .mockResolvedValue({ message: "User created successfully" }), + getUser: vi.fn().mockResolvedValue({ + userName: "alice", + userId: "AIDA123", + arn: "arn:aws:iam::000000000000:user/alice", + createDate: "2024-01-01T00:00:00.000Z", + path: "/", + }), + deleteUser: vi.fn().mockResolvedValue({ success: true }), + listAccessKeys: vi.fn().mockResolvedValue({ accessKeys: [] }), + createAccessKey: vi.fn().mockResolvedValue({ + accessKeyId: "AKIA123", + secretAccessKey: "secret", + status: "Active", + userName: "alice", + createDate: "2024-01-01T00:00:00.000Z", + }), + deleteAccessKey: vi.fn().mockResolvedValue({ success: true }), + updateAccessKey: vi + .fn() + .mockResolvedValue({ message: "Access key updated successfully" }), + listUserPolicies: vi.fn().mockResolvedValue({ policyNames: [] }), + getUserPolicy: vi.fn().mockResolvedValue({ + policyName: "my-policy", + policyDocument: '{"Version":"2012-10-17","Statement":[]}', + }), + putUserPolicy: vi + .fn() + .mockResolvedValue({ message: "Policy saved successfully" }), + deleteUserPolicy: vi.fn().mockResolvedValue({ success: true }), + listAttachedUserPolicies: vi + .fn() + .mockResolvedValue({ attachedPolicies: [] }), + attachUserPolicy: vi + .fn() + .mockResolvedValue({ message: "Policy attached to user successfully" }), + detachUserPolicy: vi + .fn() + .mockResolvedValue({ message: "Policy detached from user successfully" }), + listGroupsForUser: vi.fn().mockResolvedValue({ groups: [] }), + listGroups: vi.fn().mockResolvedValue({ groups: [] }), + createGroup: vi + .fn() + .mockResolvedValue({ message: "Group created successfully" }), + getGroup: vi.fn().mockResolvedValue({ + group: { + groupName: "admins", + groupId: "AGPA123", + arn: "arn:aws:iam::000000000000:group/admins", + createDate: "2024-01-01T00:00:00.000Z", + path: "/", + }, + members: [], + }), + deleteGroup: vi.fn().mockResolvedValue({ success: true }), + addUserToGroup: vi + .fn() + .mockResolvedValue({ message: "User added to group" }), + removeUserFromGroup: vi + .fn() + .mockResolvedValue({ message: "User removed from group" }), + listGroupPolicies: vi.fn().mockResolvedValue({ policyNames: [] }), + getGroupPolicy: vi.fn().mockResolvedValue({ + policyName: "group-policy", + policyDocument: '{"Version":"2012-10-17","Statement":[]}', + }), + putGroupPolicy: vi + .fn() + .mockResolvedValue({ message: "Policy saved successfully" }), + deleteGroupPolicy: vi.fn().mockResolvedValue({ success: true }), + listAttachedGroupPolicies: vi + .fn() + .mockResolvedValue({ attachedPolicies: [] }), + attachGroupPolicy: vi + .fn() + .mockResolvedValue({ message: "Policy attached to group successfully" }), + detachGroupPolicy: vi.fn().mockResolvedValue({ success: true }), + listManagedPolicies: vi.fn().mockResolvedValue({ policies: [] }), + createPolicy: vi.fn().mockResolvedValue({ + message: "Policy created successfully", + arn: "arn:aws:iam::000000000000:policy/my-policy", + }), + getPolicy: vi.fn().mockResolvedValue({ + policyName: "my-policy", + policyId: "ANPA123", + arn: "arn:aws:iam::000000000000:policy/my-policy", + attachmentCount: 0, + createDate: "2024-01-01T00:00:00.000Z", + defaultVersionId: "v1", + description: "", + path: "/", + isAttachable: true, + updateDate: "2024-01-01T00:00:00.000Z", + }), + deletePolicy: vi.fn().mockResolvedValue({ success: true }), + getPolicyDocument: vi.fn().mockResolvedValue({ + versionId: "v1", + isDefaultVersion: true, + document: '{"Version":"2012-10-17","Statement":[]}', + }), + listPolicyVersions: vi.fn().mockResolvedValue({ versions: [] }), + createPolicyVersion: vi.fn().mockResolvedValue({ + message: "Policy version created successfully", + versionId: "v2", + }), + deletePolicyVersion: vi.fn().mockResolvedValue({ success: true }), + setDefaultPolicyVersion: vi.fn().mockResolvedValue({ + message: "Default policy version updated successfully", + }), + }; +} + +vi.mock("../../../src/plugins/iam/service.js", async (importOriginal) => { + const actual = + await importOriginal< + typeof import("../../../src/plugins/iam/service.js") + >(); + return { + ...actual, + IAMService: vi.fn(), + }; +}); + +import { IAMService as IAMServiceClass } from "../../../src/plugins/iam/service.js"; + +describe("IAM Routes", () => { + let app: FastifyInstance; + let mockService: MockIAMService; + + beforeAll(async () => { + app = Fastify(); + registerErrorHandler(app); + + mockService = createMockIAMService(); + + (IAMServiceClass as unknown as Mock).mockImplementation(() => mockService); + + const mockClientCache = { + getClients: vi.fn().mockReturnValue({ iam: {} }), + }; + app.decorate("clientCache", mockClientCache as unknown as ClientCache); + + app.decorateRequest("localstackConfig", null); + app.addHook("onRequest", async (request) => { + request.localstackConfig = { + endpoint: "http://localhost:4566", + region: "us-east-1", + }; + }); + + await app.register(iamRoutes); + await app.ready(); + }); + + afterAll(async () => { + await app.close(); + }); + + // ── User Routes ───────────────────────────────────────────────────────── + + describe("GET /users", () => { + it("should return list of users", async () => { + const response = await app.inject({ method: "GET", url: "/users" }); + expect(response.statusCode).toBe(200); + expect(mockService.listUsers).toHaveBeenCalled(); + }); + }); + + describe("POST /users", () => { + it("should create a user and return 201", async () => { + const response = await app.inject({ + method: "POST", + url: "/users", + payload: { userName: "alice" }, + }); + expect(response.statusCode).toBe(201); + expect(mockService.createUser).toHaveBeenCalledWith("alice", undefined); + }); + + it("should create a user with path", async () => { + mockService.createUser.mockClear(); + const response = await app.inject({ + method: "POST", + url: "/users", + payload: { userName: "alice", path: "/division/" }, + }); + expect(response.statusCode).toBe(201); + expect(mockService.createUser).toHaveBeenCalledWith( + "alice", + "/division/", + ); + }); + }); + + describe("GET /users/:userName", () => { + it("should return user detail", async () => { + const response = await app.inject({ + method: "GET", + url: "/users/alice", + }); + expect(response.statusCode).toBe(200); + expect(mockService.getUser).toHaveBeenCalledWith("alice"); + }); + }); + + describe("DELETE /users/:userName", () => { + it("should delete a user", async () => { + const response = await app.inject({ + method: "DELETE", + url: "/users/alice", + }); + expect(response.statusCode).toBe(200); + expect(mockService.deleteUser).toHaveBeenCalledWith("alice"); + }); + }); + + // ── Access Key Routes ──────────────────────────────────────────────────── + + describe("GET /users/:userName/access-keys", () => { + it("should return access keys list", async () => { + const response = await app.inject({ + method: "GET", + url: "/users/alice/access-keys", + }); + expect(response.statusCode).toBe(200); + expect(mockService.listAccessKeys).toHaveBeenCalledWith("alice"); + }); + }); + + describe("POST /users/:userName/access-keys", () => { + it("should create an access key and return 201", async () => { + const response = await app.inject({ + method: "POST", + url: "/users/alice/access-keys", + }); + expect(response.statusCode).toBe(201); + expect(mockService.createAccessKey).toHaveBeenCalledWith("alice"); + }); + }); + + describe("DELETE /users/:userName/access-keys/:accessKeyId", () => { + it("should delete an access key", async () => { + const response = await app.inject({ + method: "DELETE", + url: "/users/alice/access-keys/AKIA123", + }); + expect(response.statusCode).toBe(200); + expect(mockService.deleteAccessKey).toHaveBeenCalledWith( + "alice", + "AKIA123", + ); + }); + }); + + describe("PUT /users/:userName/access-keys/:accessKeyId", () => { + it("should update an access key status", async () => { + const response = await app.inject({ + method: "PUT", + url: "/users/alice/access-keys/AKIA123", + payload: { status: "Inactive" }, + }); + expect(response.statusCode).toBe(200); + expect(mockService.updateAccessKey).toHaveBeenCalledWith( + "alice", + "AKIA123", + "Inactive", + ); + }); + }); + + // ── User Inline Policy Routes ──────────────────────────────────────────── + + describe("GET /users/:userName/inline-policies", () => { + it("should return list of inline policy names", async () => { + const response = await app.inject({ + method: "GET", + url: "/users/alice/inline-policies", + }); + expect(response.statusCode).toBe(200); + expect(mockService.listUserPolicies).toHaveBeenCalledWith("alice"); + }); + }); + + describe("GET /users/:userName/inline-policies/:policyName", () => { + it("should return inline policy detail", async () => { + const response = await app.inject({ + method: "GET", + url: "/users/alice/inline-policies/my-policy", + }); + expect(response.statusCode).toBe(200); + expect(mockService.getUserPolicy).toHaveBeenCalledWith( + "alice", + "my-policy", + ); + }); + }); + + describe("PUT /users/:userName/inline-policies/:policyName", () => { + it("should put an inline policy", async () => { + const response = await app.inject({ + method: "PUT", + url: "/users/alice/inline-policies/my-policy", + payload: { + policyDocument: '{"Version":"2012-10-17","Statement":[]}', + }, + }); + expect(response.statusCode).toBe(200); + expect(mockService.putUserPolicy).toHaveBeenCalledWith( + "alice", + "my-policy", + '{"Version":"2012-10-17","Statement":[]}', + ); + }); + }); + + describe("DELETE /users/:userName/inline-policies/:policyName", () => { + it("should delete an inline policy", async () => { + const response = await app.inject({ + method: "DELETE", + url: "/users/alice/inline-policies/my-policy", + }); + expect(response.statusCode).toBe(200); + expect(mockService.deleteUserPolicy).toHaveBeenCalledWith( + "alice", + "my-policy", + ); + }); + }); + + // ── User Attached Policy Routes ────────────────────────────────────────── + + describe("GET /users/:userName/attached-policies", () => { + it("should return attached policies for user", async () => { + const response = await app.inject({ + method: "GET", + url: "/users/alice/attached-policies", + }); + expect(response.statusCode).toBe(200); + expect(mockService.listAttachedUserPolicies).toHaveBeenCalledWith( + "alice", + ); + }); + }); + + describe("POST /users/:userName/attached-policies", () => { + it("should attach a policy to a user", async () => { + const response = await app.inject({ + method: "POST", + url: "/users/alice/attached-policies", + payload: { policyArn: "arn:aws:iam::aws:policy/ReadOnly" }, + }); + expect(response.statusCode).toBe(200); + expect(mockService.attachUserPolicy).toHaveBeenCalledWith( + "alice", + "arn:aws:iam::aws:policy/ReadOnly", + ); + }); + }); + + describe("DELETE /users/:userName/attached-policies/:policyArn", () => { + it("should detach a policy from a user", async () => { + const encodedArn = encodeURIComponent("arn:aws:iam::aws:policy/ReadOnly"); + const response = await app.inject({ + method: "DELETE", + url: `/users/alice/attached-policies/${encodedArn}`, + }); + expect(response.statusCode).toBe(200); + expect(mockService.detachUserPolicy).toHaveBeenCalledWith( + "alice", + "arn:aws:iam::aws:policy/ReadOnly", + ); + }); + }); + + // ── User Groups Route ──────────────────────────────────────────────────── + + describe("GET /users/:userName/groups", () => { + it("should return groups for user", async () => { + const response = await app.inject({ + method: "GET", + url: "/users/alice/groups", + }); + expect(response.statusCode).toBe(200); + expect(mockService.listGroupsForUser).toHaveBeenCalledWith("alice"); + }); + }); + + // ── Group Routes ───────────────────────────────────────────────────────── + + describe("GET /groups", () => { + it("should return list of groups", async () => { + const response = await app.inject({ method: "GET", url: "/groups" }); + expect(response.statusCode).toBe(200); + expect(mockService.listGroups).toHaveBeenCalled(); + }); + }); + + describe("POST /groups", () => { + it("should create a group and return 201", async () => { + const response = await app.inject({ + method: "POST", + url: "/groups", + payload: { groupName: "admins" }, + }); + expect(response.statusCode).toBe(201); + expect(mockService.createGroup).toHaveBeenCalledWith("admins", undefined); + }); + + it("should create a group with path", async () => { + mockService.createGroup.mockClear(); + const response = await app.inject({ + method: "POST", + url: "/groups", + payload: { groupName: "admins", path: "/division/" }, + }); + expect(response.statusCode).toBe(201); + expect(mockService.createGroup).toHaveBeenCalledWith( + "admins", + "/division/", + ); + }); + }); + + describe("GET /groups/:groupName", () => { + it("should return group detail", async () => { + const response = await app.inject({ + method: "GET", + url: "/groups/admins", + }); + expect(response.statusCode).toBe(200); + expect(mockService.getGroup).toHaveBeenCalledWith("admins"); + }); + }); + + describe("DELETE /groups/:groupName", () => { + it("should delete a group", async () => { + const response = await app.inject({ + method: "DELETE", + url: "/groups/admins", + }); + expect(response.statusCode).toBe(200); + expect(mockService.deleteGroup).toHaveBeenCalledWith("admins"); + }); + }); + + // ── Group Membership Routes ────────────────────────────────────────────── + + describe("POST /groups/:groupName/members", () => { + it("should add a user to a group", async () => { + const response = await app.inject({ + method: "POST", + url: "/groups/admins/members", + payload: { userName: "alice" }, + }); + expect(response.statusCode).toBe(200); + expect(mockService.addUserToGroup).toHaveBeenCalledWith( + "admins", + "alice", + ); + }); + }); + + describe("DELETE /groups/:groupName/members/:userName", () => { + it("should remove a user from a group", async () => { + const response = await app.inject({ + method: "DELETE", + url: "/groups/admins/members/alice", + }); + expect(response.statusCode).toBe(200); + expect(mockService.removeUserFromGroup).toHaveBeenCalledWith( + "admins", + "alice", + ); + }); + }); + + // ── Group Inline Policy Routes ─────────────────────────────────────────── + + describe("GET /groups/:groupName/inline-policies", () => { + it("should return list of inline policy names for group", async () => { + const response = await app.inject({ + method: "GET", + url: "/groups/admins/inline-policies", + }); + expect(response.statusCode).toBe(200); + expect(mockService.listGroupPolicies).toHaveBeenCalledWith("admins"); + }); + }); + + describe("GET /groups/:groupName/inline-policies/:policyName", () => { + it("should return group inline policy detail", async () => { + const response = await app.inject({ + method: "GET", + url: "/groups/admins/inline-policies/group-policy", + }); + expect(response.statusCode).toBe(200); + expect(mockService.getGroupPolicy).toHaveBeenCalledWith( + "admins", + "group-policy", + ); + }); + }); + + describe("PUT /groups/:groupName/inline-policies/:policyName", () => { + it("should put a group inline policy", async () => { + const response = await app.inject({ + method: "PUT", + url: "/groups/admins/inline-policies/group-policy", + payload: { + policyDocument: '{"Version":"2012-10-17","Statement":[]}', + }, + }); + expect(response.statusCode).toBe(200); + expect(mockService.putGroupPolicy).toHaveBeenCalledWith( + "admins", + "group-policy", + '{"Version":"2012-10-17","Statement":[]}', + ); + }); + }); + + describe("DELETE /groups/:groupName/inline-policies/:policyName", () => { + it("should delete a group inline policy", async () => { + const response = await app.inject({ + method: "DELETE", + url: "/groups/admins/inline-policies/group-policy", + }); + expect(response.statusCode).toBe(200); + expect(mockService.deleteGroupPolicy).toHaveBeenCalledWith( + "admins", + "group-policy", + ); + }); + }); + + // ── Group Attached Policy Routes ───────────────────────────────────────── + + describe("GET /groups/:groupName/attached-policies", () => { + it("should return attached policies for group", async () => { + const response = await app.inject({ + method: "GET", + url: "/groups/admins/attached-policies", + }); + expect(response.statusCode).toBe(200); + expect(mockService.listAttachedGroupPolicies).toHaveBeenCalledWith( + "admins", + ); + }); + }); + + describe("POST /groups/:groupName/attached-policies", () => { + it("should attach a policy to a group", async () => { + const response = await app.inject({ + method: "POST", + url: "/groups/admins/attached-policies", + payload: { policyArn: "arn:aws:iam::aws:policy/ReadOnly" }, + }); + expect(response.statusCode).toBe(200); + expect(mockService.attachGroupPolicy).toHaveBeenCalledWith( + "admins", + "arn:aws:iam::aws:policy/ReadOnly", + ); + }); + }); + + describe("DELETE /groups/:groupName/attached-policies/:policyArn", () => { + it("should detach a policy from a group", async () => { + const encodedArn = encodeURIComponent("arn:aws:iam::aws:policy/ReadOnly"); + const response = await app.inject({ + method: "DELETE", + url: `/groups/admins/attached-policies/${encodedArn}`, + }); + expect(response.statusCode).toBe(200); + expect(mockService.detachGroupPolicy).toHaveBeenCalledWith( + "admins", + "arn:aws:iam::aws:policy/ReadOnly", + ); + }); + }); + + // ── Managed Policy Routes ──────────────────────────────────────────────── + + describe("GET /policies", () => { + it("should return list of managed policies", async () => { + const response = await app.inject({ method: "GET", url: "/policies" }); + expect(response.statusCode).toBe(200); + expect(mockService.listManagedPolicies).toHaveBeenCalled(); + }); + }); + + describe("POST /policies", () => { + it("should create a managed policy and return 201", async () => { + const response = await app.inject({ + method: "POST", + url: "/policies", + payload: { + policyName: "my-policy", + policyDocument: '{"Version":"2012-10-17","Statement":[]}', + }, + }); + expect(response.statusCode).toBe(201); + expect(mockService.createPolicy).toHaveBeenCalledWith( + "my-policy", + '{"Version":"2012-10-17","Statement":[]}', + undefined, + undefined, + ); + }); + + it("should create a managed policy with description and path", async () => { + mockService.createPolicy.mockClear(); + const response = await app.inject({ + method: "POST", + url: "/policies", + payload: { + policyName: "my-policy", + policyDocument: '{"Version":"2012-10-17","Statement":[]}', + description: "My policy", + path: "/division/", + }, + }); + expect(response.statusCode).toBe(201); + expect(mockService.createPolicy).toHaveBeenCalledWith( + "my-policy", + '{"Version":"2012-10-17","Statement":[]}', + "My policy", + "/division/", + ); + }); + }); + + describe("GET /policies/:policyArn", () => { + it("should return managed policy detail", async () => { + const encodedArn = encodeURIComponent( + "arn:aws:iam::000000000000:policy/my-policy", + ); + const response = await app.inject({ + method: "GET", + url: `/policies/${encodedArn}`, + }); + expect(response.statusCode).toBe(200); + expect(mockService.getPolicy).toHaveBeenCalledWith( + "arn:aws:iam::000000000000:policy/my-policy", + ); + }); + }); + + describe("DELETE /policies/:policyArn", () => { + it("should delete a managed policy", async () => { + const encodedArn = encodeURIComponent( + "arn:aws:iam::000000000000:policy/my-policy", + ); + const response = await app.inject({ + method: "DELETE", + url: `/policies/${encodedArn}`, + }); + expect(response.statusCode).toBe(200); + expect(mockService.deletePolicy).toHaveBeenCalledWith( + "arn:aws:iam::000000000000:policy/my-policy", + ); + }); + }); + + describe("GET /policies/:policyArn/document", () => { + it("should return policy document", async () => { + const encodedArn = encodeURIComponent( + "arn:aws:iam::000000000000:policy/my-policy", + ); + const response = await app.inject({ + method: "GET", + url: `/policies/${encodedArn}/document`, + }); + expect(response.statusCode).toBe(200); + expect(mockService.getPolicyDocument).toHaveBeenCalledWith( + "arn:aws:iam::000000000000:policy/my-policy", + undefined, + ); + }); + + it("should pass versionId when provided", async () => { + mockService.getPolicyDocument.mockClear(); + const encodedArn = encodeURIComponent( + "arn:aws:iam::000000000000:policy/my-policy", + ); + const response = await app.inject({ + method: "GET", + url: `/policies/${encodedArn}/document?versionId=v2`, + }); + expect(response.statusCode).toBe(200); + expect(mockService.getPolicyDocument).toHaveBeenCalledWith( + "arn:aws:iam::000000000000:policy/my-policy", + "v2", + ); + }); + }); + + // ── Policy Version Routes ──────────────────────────────────────────────── + + describe("GET /policies/:policyArn/versions", () => { + it("should return list of policy versions", async () => { + const encodedArn = encodeURIComponent( + "arn:aws:iam::000000000000:policy/my-policy", + ); + const response = await app.inject({ + method: "GET", + url: `/policies/${encodedArn}/versions`, + }); + expect(response.statusCode).toBe(200); + expect(mockService.listPolicyVersions).toHaveBeenCalledWith( + "arn:aws:iam::000000000000:policy/my-policy", + ); + }); + }); + + describe("POST /policies/:policyArn/versions", () => { + it("should create a policy version and return 201", async () => { + const encodedArn = encodeURIComponent( + "arn:aws:iam::000000000000:policy/my-policy", + ); + const response = await app.inject({ + method: "POST", + url: `/policies/${encodedArn}/versions`, + payload: { + policyDocument: '{"Version":"2012-10-17","Statement":[]}', + setAsDefault: true, + }, + }); + expect(response.statusCode).toBe(201); + expect(mockService.createPolicyVersion).toHaveBeenCalledWith( + "arn:aws:iam::000000000000:policy/my-policy", + '{"Version":"2012-10-17","Statement":[]}', + true, + ); + }); + }); + + describe("DELETE /policies/:policyArn/versions/:versionId", () => { + it("should delete a policy version", async () => { + const encodedArn = encodeURIComponent( + "arn:aws:iam::000000000000:policy/my-policy", + ); + const response = await app.inject({ + method: "DELETE", + url: `/policies/${encodedArn}/versions/v1`, + }); + expect(response.statusCode).toBe(200); + expect(mockService.deletePolicyVersion).toHaveBeenCalledWith( + "arn:aws:iam::000000000000:policy/my-policy", + "v1", + ); + }); + }); + + describe("PUT /policies/:policyArn/versions/:versionId/default", () => { + it("should set the default policy version", async () => { + const encodedArn = encodeURIComponent( + "arn:aws:iam::000000000000:policy/my-policy", + ); + const response = await app.inject({ + method: "PUT", + url: `/policies/${encodedArn}/versions/v2/default`, + }); + expect(response.statusCode).toBe(200); + expect(mockService.setDefaultPolicyVersion).toHaveBeenCalledWith( + "arn:aws:iam::000000000000:policy/my-policy", + "v2", + ); + }); + }); +}); diff --git a/packages/backend/test/plugins/iam/service.test.ts b/packages/backend/test/plugins/iam/service.test.ts new file mode 100644 index 0000000..31fc6b1 --- /dev/null +++ b/packages/backend/test/plugins/iam/service.test.ts @@ -0,0 +1,1827 @@ +import type { IAMClient } from "@aws-sdk/client-iam"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { IAMService } from "../../../src/plugins/iam/service.js"; +import { AppError } from "../../../src/shared/errors.js"; + +function createMockIAMClient() { + return { + send: vi.fn(), + } as unknown as IAMClient; +} + +function makeError(name: string, message = "IAM error") { + const err = new Error(message) as Error & { name: string }; + err.name = name; + return err; +} + +describe("IAMService", () => { + let client: IAMClient; + let service: IAMService; + + beforeEach(() => { + client = createMockIAMClient(); + service = new IAMService(client); + }); + + // ── mapIamError passthrough / mapping ──────────────────────────────────── + + describe("mapIamError", () => { + it("passes AppError through unchanged", async () => { + const original = new AppError("original", 418, "TEAPOT"); + (client.send as ReturnType).mockRejectedValueOnce(original); + await expect(service.listUsers()).rejects.toMatchObject({ + statusCode: 418, + code: "TEAPOT", + message: "original", + }); + }); + + it("maps NoSuchEntityException to 404 NOT_FOUND", async () => { + (client.send as ReturnType).mockRejectedValueOnce( + makeError("NoSuchEntityException", "no entity"), + ); + await expect(service.listUsers()).rejects.toMatchObject({ + statusCode: 404, + code: "NOT_FOUND", + }); + }); + + it("maps EntityAlreadyExistsException to 409 CONFLICT", async () => { + (client.send as ReturnType).mockRejectedValueOnce( + makeError("EntityAlreadyExistsException", "already exists"), + ); + await expect(service.listUsers()).rejects.toMatchObject({ + statusCode: 409, + code: "CONFLICT", + }); + }); + + it("maps DeleteConflictException to 409 DELETE_CONFLICT", async () => { + (client.send as ReturnType).mockRejectedValueOnce( + makeError("DeleteConflictException", "delete conflict"), + ); + await expect(service.listUsers()).rejects.toMatchObject({ + statusCode: 409, + code: "DELETE_CONFLICT", + }); + }); + + it("maps LimitExceededException to 429 LIMIT_EXCEEDED", async () => { + (client.send as ReturnType).mockRejectedValueOnce( + makeError("LimitExceededException", "limit exceeded"), + ); + await expect(service.listUsers()).rejects.toMatchObject({ + statusCode: 429, + code: "LIMIT_EXCEEDED", + }); + }); + + it("maps MalformedPolicyDocumentException to 400 MALFORMED_POLICY", async () => { + (client.send as ReturnType).mockRejectedValueOnce( + makeError("MalformedPolicyDocumentException", "malformed"), + ); + await expect(service.listUsers()).rejects.toMatchObject({ + statusCode: 400, + code: "MALFORMED_POLICY", + }); + }); + + it("maps InvalidInputException to 400 INVALID_INPUT", async () => { + (client.send as ReturnType).mockRejectedValueOnce( + makeError("InvalidInputException", "invalid input"), + ); + await expect(service.listUsers()).rejects.toMatchObject({ + statusCode: 400, + code: "INVALID_INPUT", + }); + }); + + it("maps unknown errors to 500 INTERNAL_ERROR", async () => { + (client.send as ReturnType).mockRejectedValueOnce( + new Error("boom"), + ); + await expect(service.listUsers()).rejects.toMatchObject({ + statusCode: 500, + code: "INTERNAL_ERROR", + }); + }); + }); + + // ── User Operations ────────────────────────────────────────────────────── + + describe("listUsers", () => { + it("returns a mapped list of users", async () => { + const createDate = new Date("2024-01-15T10:00:00.000Z"); + (client.send as ReturnType).mockResolvedValueOnce({ + Users: [ + { + UserName: "alice", + UserId: "AIDA123", + Arn: "arn:aws:iam::000000000000:user/alice", + CreateDate: createDate, + Path: "/", + }, + ], + }); + + const result = await service.listUsers(); + + expect(result).toEqual({ + users: [ + { + userName: "alice", + userId: "AIDA123", + arn: "arn:aws:iam::000000000000:user/alice", + createDate: createDate.toISOString(), + path: "/", + }, + ], + }); + }); + + it("returns empty list when Users is undefined", async () => { + (client.send as ReturnType).mockResolvedValueOnce({}); + const result = await service.listUsers(); + expect(result).toEqual({ users: [] }); + }); + + it("uses empty strings when user fields are undefined", async () => { + (client.send as ReturnType).mockResolvedValueOnce({ + Users: [ + { + /* all fields absent */ + }, + ], + }); + const result = await service.listUsers(); + expect(result.users[0]).toEqual({ + userName: "", + userId: "", + arn: "", + createDate: "", + path: "/", + }); + }); + + it("throws AppError on error", async () => { + (client.send as ReturnType).mockRejectedValueOnce( + makeError("NoSuchEntityException", "not found"), + ); + await expect(service.listUsers()).rejects.toMatchObject({ + statusCode: 404, + code: "NOT_FOUND", + }); + }); + }); + + describe("createUser", () => { + it("creates a user without path", async () => { + (client.send as ReturnType).mockResolvedValueOnce({}); + + const result = await service.createUser("alice"); + + expect(result).toEqual({ message: "User created successfully" }); + const cmd = (client.send as ReturnType).mock.calls[0][0]; + expect(cmd.input).toMatchObject({ UserName: "alice" }); + expect(cmd.input.Path).toBeUndefined(); + }); + + it("creates a user with an explicit path", async () => { + (client.send as ReturnType).mockResolvedValueOnce({}); + + await service.createUser("alice", "/division/"); + + const cmd = (client.send as ReturnType).mock.calls[0][0]; + expect(cmd.input).toMatchObject({ + UserName: "alice", + Path: "/division/", + }); + }); + + it("throws AppError 409 CONFLICT when entity already exists", async () => { + (client.send as ReturnType).mockRejectedValueOnce( + makeError("EntityAlreadyExistsException", "User already exists"), + ); + await expect(service.createUser("alice")).rejects.toMatchObject({ + statusCode: 409, + code: "CONFLICT", + }); + }); + }); + + describe("getUser", () => { + it("returns mapped user detail", async () => { + const createDate = new Date("2024-03-01T08:00:00.000Z"); + (client.send as ReturnType).mockResolvedValueOnce({ + User: { + UserName: "bob", + UserId: "AIDA456", + Arn: "arn:aws:iam::000000000000:user/bob", + CreateDate: createDate, + Path: "/", + }, + }); + + const result = await service.getUser("bob"); + + expect(result).toEqual({ + userName: "bob", + userId: "AIDA456", + arn: "arn:aws:iam::000000000000:user/bob", + createDate: createDate.toISOString(), + path: "/", + }); + }); + + it("uses empty strings when user fields are undefined", async () => { + (client.send as ReturnType).mockResolvedValueOnce({ + User: { + /* all fields absent */ + }, + }); + const result = await service.getUser("bob"); + expect(result).toEqual({ + userName: "", + userId: "", + arn: "", + createDate: "", + path: "/", + }); + }); + + it("throws AppError 404 NOT_FOUND when user does not exist", async () => { + (client.send as ReturnType).mockRejectedValueOnce( + makeError("NoSuchEntityException", "User not found"), + ); + await expect(service.getUser("ghost")).rejects.toMatchObject({ + statusCode: 404, + code: "NOT_FOUND", + }); + }); + }); + + describe("deleteUser", () => { + it("cleans up access keys, inline policies, attached policies, groups and deletes user", async () => { + (client.send as ReturnType) + // ListAccessKeys + .mockResolvedValueOnce({ + AccessKeyMetadata: [ + { AccessKeyId: "AKIA1" }, + { AccessKeyId: "AKIA2" }, + ], + }) + // DeleteAccessKey x2 + .mockResolvedValueOnce({}) + .mockResolvedValueOnce({}) + // ListUserPolicies + .mockResolvedValueOnce({ PolicyNames: ["InlinePolicy1"] }) + // DeleteUserPolicy + .mockResolvedValueOnce({}) + // ListAttachedUserPolicies + .mockResolvedValueOnce({ + AttachedPolicies: [{ PolicyArn: "arn:aws:iam::aws:policy/ReadOnly" }], + }) + // DetachUserPolicy + .mockResolvedValueOnce({}) + // ListGroupsForUser + .mockResolvedValueOnce({ Groups: [{ GroupName: "admins" }] }) + // RemoveUserFromGroup + .mockResolvedValueOnce({}) + // DeleteUser + .mockResolvedValueOnce({}); + + const result = await service.deleteUser("alice"); + + expect(result).toEqual({ success: true }); + expect(client.send).toHaveBeenCalledTimes(10); + }); + + it("handles user with no access keys, policies, or groups", async () => { + (client.send as ReturnType) + .mockResolvedValueOnce({ AccessKeyMetadata: [] }) + .mockResolvedValueOnce({ PolicyNames: [] }) + .mockResolvedValueOnce({ AttachedPolicies: [] }) + .mockResolvedValueOnce({ Groups: [] }) + .mockResolvedValueOnce({}); + + const result = await service.deleteUser("alice"); + + expect(result).toEqual({ success: true }); + expect(client.send).toHaveBeenCalledTimes(5); + }); + + it("throws AppError on error", async () => { + (client.send as ReturnType).mockRejectedValueOnce( + makeError("NoSuchEntityException", "User not found"), + ); + await expect(service.deleteUser("ghost")).rejects.toMatchObject({ + statusCode: 404, + code: "NOT_FOUND", + }); + }); + }); + + // ── Access Key Operations ──────────────────────────────────────────────── + + // ── Error path coverage for all catch blocks ──────────────────────── + + describe("error paths for simple methods", () => { + it("listGroups throws on error", async () => { + (client.send as ReturnType).mockRejectedValueOnce( + makeError("LimitExceededException"), + ); + await expect(service.listGroups()).rejects.toMatchObject({ + statusCode: 429, + }); + }); + + it("createGroup throws on error", async () => { + (client.send as ReturnType).mockRejectedValueOnce( + makeError("EntityAlreadyExistsException"), + ); + await expect(service.createGroup("grp")).rejects.toMatchObject({ + statusCode: 409, + }); + }); + + it("getGroup throws on error", async () => { + (client.send as ReturnType).mockRejectedValueOnce( + makeError("NoSuchEntityException"), + ); + await expect(service.getGroup("grp")).rejects.toMatchObject({ + statusCode: 404, + }); + }); + + it("listAccessKeys throws on error", async () => { + (client.send as ReturnType).mockRejectedValueOnce( + makeError("NoSuchEntityException"), + ); + await expect(service.listAccessKeys("alice")).rejects.toMatchObject({ + statusCode: 404, + }); + }); + + it("createAccessKey throws on error", async () => { + (client.send as ReturnType).mockRejectedValueOnce( + makeError("NoSuchEntityException"), + ); + await expect(service.createAccessKey("alice")).rejects.toMatchObject({ + statusCode: 404, + }); + }); + + it("deleteAccessKey throws on error", async () => { + (client.send as ReturnType).mockRejectedValueOnce( + makeError("NoSuchEntityException"), + ); + await expect( + service.deleteAccessKey("alice", "AKID"), + ).rejects.toMatchObject({ statusCode: 404 }); + }); + + it("updateAccessKey throws on error", async () => { + (client.send as ReturnType).mockRejectedValueOnce( + makeError("NoSuchEntityException"), + ); + await expect( + service.updateAccessKey("alice", "AKID", "Inactive"), + ).rejects.toMatchObject({ statusCode: 404 }); + }); + + it("addUserToGroup throws on error", async () => { + (client.send as ReturnType).mockRejectedValueOnce( + makeError("NoSuchEntityException"), + ); + await expect( + service.addUserToGroup("grp", "alice"), + ).rejects.toMatchObject({ statusCode: 404 }); + }); + + it("removeUserFromGroup throws on error", async () => { + (client.send as ReturnType).mockRejectedValueOnce( + makeError("NoSuchEntityException"), + ); + await expect( + service.removeUserFromGroup("grp", "alice"), + ).rejects.toMatchObject({ statusCode: 404 }); + }); + + it("listGroupsForUser throws on error", async () => { + (client.send as ReturnType).mockRejectedValueOnce( + makeError("NoSuchEntityException"), + ); + await expect(service.listGroupsForUser("alice")).rejects.toMatchObject({ + statusCode: 404, + }); + }); + + it("listUserPolicies throws on error", async () => { + (client.send as ReturnType).mockRejectedValueOnce( + makeError("NoSuchEntityException"), + ); + await expect(service.listUserPolicies("alice")).rejects.toMatchObject({ + statusCode: 404, + }); + }); + + it("getUserPolicy throws on error", async () => { + (client.send as ReturnType).mockRejectedValueOnce( + makeError("NoSuchEntityException"), + ); + await expect(service.getUserPolicy("alice", "pol")).rejects.toMatchObject( + { statusCode: 404 }, + ); + }); + + it("putUserPolicy throws on error", async () => { + (client.send as ReturnType).mockRejectedValueOnce( + makeError("MalformedPolicyDocumentException"), + ); + await expect( + service.putUserPolicy("alice", "pol", "{}"), + ).rejects.toMatchObject({ statusCode: 400 }); + }); + + it("deleteUserPolicy throws on error", async () => { + (client.send as ReturnType).mockRejectedValueOnce( + makeError("NoSuchEntityException"), + ); + await expect( + service.deleteUserPolicy("alice", "pol"), + ).rejects.toMatchObject({ statusCode: 404 }); + }); + + it("listGroupPolicies throws on error", async () => { + (client.send as ReturnType).mockRejectedValueOnce( + makeError("NoSuchEntityException"), + ); + await expect(service.listGroupPolicies("grp")).rejects.toMatchObject({ + statusCode: 404, + }); + }); + + it("getGroupPolicy throws on error", async () => { + (client.send as ReturnType).mockRejectedValueOnce( + makeError("NoSuchEntityException"), + ); + await expect(service.getGroupPolicy("grp", "pol")).rejects.toMatchObject({ + statusCode: 404, + }); + }); + + it("putGroupPolicy throws on error", async () => { + (client.send as ReturnType).mockRejectedValueOnce( + makeError("MalformedPolicyDocumentException"), + ); + await expect( + service.putGroupPolicy("grp", "pol", "{}"), + ).rejects.toMatchObject({ statusCode: 400 }); + }); + + it("deleteGroupPolicy throws on error", async () => { + (client.send as ReturnType).mockRejectedValueOnce( + makeError("NoSuchEntityException"), + ); + await expect( + service.deleteGroupPolicy("grp", "pol"), + ).rejects.toMatchObject({ statusCode: 404 }); + }); + + it("listManagedPolicies throws on error", async () => { + (client.send as ReturnType).mockRejectedValueOnce( + makeError("LimitExceededException"), + ); + await expect(service.listManagedPolicies()).rejects.toMatchObject({ + statusCode: 429, + }); + }); + + it("getPolicy throws on error", async () => { + (client.send as ReturnType).mockRejectedValueOnce( + makeError("NoSuchEntityException"), + ); + await expect(service.getPolicy("arn:pol")).rejects.toMatchObject({ + statusCode: 404, + }); + }); + + it("getPolicyDocument throws on error", async () => { + (client.send as ReturnType).mockRejectedValueOnce( + makeError("NoSuchEntityException"), + ); + await expect( + service.getPolicyDocument("arn:pol", "v1"), + ).rejects.toMatchObject({ statusCode: 404 }); + }); + + it("createPolicy throws on error", async () => { + (client.send as ReturnType).mockRejectedValueOnce( + makeError("EntityAlreadyExistsException"), + ); + await expect(service.createPolicy("pol", "{}")).rejects.toMatchObject({ + statusCode: 409, + }); + }); + + it("listPolicyVersions throws on error", async () => { + (client.send as ReturnType).mockRejectedValueOnce( + makeError("NoSuchEntityException"), + ); + await expect(service.listPolicyVersions("arn:pol")).rejects.toMatchObject( + { statusCode: 404 }, + ); + }); + + it("createPolicyVersion throws on error", async () => { + (client.send as ReturnType).mockRejectedValueOnce( + makeError("NoSuchEntityException"), + ); + await expect( + service.createPolicyVersion("arn:pol", "{}", true), + ).rejects.toMatchObject({ statusCode: 404 }); + }); + }); + + describe("listAccessKeys", () => { + it("returns mapped access key list", async () => { + const createDate = new Date("2024-05-01T00:00:00.000Z"); + (client.send as ReturnType).mockResolvedValueOnce({ + AccessKeyMetadata: [ + { + AccessKeyId: "AKIA1", + Status: "Active", + CreateDate: createDate, + UserName: "alice", + }, + ], + }); + + const result = await service.listAccessKeys("alice"); + + expect(result).toEqual({ + accessKeys: [ + { + accessKeyId: "AKIA1", + status: "Active", + createDate: createDate.toISOString(), + userName: "alice", + }, + ], + }); + }); + + it("returns empty list when AccessKeyMetadata is undefined", async () => { + (client.send as ReturnType).mockResolvedValueOnce({}); + const result = await service.listAccessKeys("alice"); + expect(result).toEqual({ accessKeys: [] }); + }); + + it("uses empty strings when access key fields are undefined", async () => { + (client.send as ReturnType).mockResolvedValueOnce({ + AccessKeyMetadata: [ + { + /* all fields absent */ + }, + ], + }); + const result = await service.listAccessKeys("alice"); + expect(result.accessKeys[0]).toEqual({ + accessKeyId: "", + status: "", + createDate: "", + userName: "", + }); + }); + }); + + describe("createAccessKey", () => { + it("returns newly created access key details", async () => { + const createDate = new Date("2024-06-01T00:00:00.000Z"); + (client.send as ReturnType).mockResolvedValueOnce({ + AccessKey: { + AccessKeyId: "AKIA_NEW", + SecretAccessKey: "secret123", + Status: "Active", + UserName: "alice", + CreateDate: createDate, + }, + }); + + const result = await service.createAccessKey("alice"); + + expect(result).toEqual({ + accessKeyId: "AKIA_NEW", + secretAccessKey: "secret123", + status: "Active", + userName: "alice", + createDate: createDate.toISOString(), + }); + }); + + it("uses empty strings when createAccessKey response fields are undefined", async () => { + (client.send as ReturnType).mockResolvedValueOnce({ + AccessKey: { + /* all fields absent */ + }, + }); + const result = await service.createAccessKey("alice"); + expect(result).toEqual({ + accessKeyId: "", + secretAccessKey: "", + status: "", + userName: "", + createDate: "", + }); + }); + }); + + describe("deleteAccessKey", () => { + it("deletes an access key and returns success", async () => { + (client.send as ReturnType).mockResolvedValueOnce({}); + + const result = await service.deleteAccessKey("alice", "AKIA1"); + + expect(result).toEqual({ success: true }); + const cmd = (client.send as ReturnType).mock.calls[0][0]; + expect(cmd.input).toMatchObject({ + UserName: "alice", + AccessKeyId: "AKIA1", + }); + }); + }); + + describe("updateAccessKey", () => { + it("updates access key status and returns success message", async () => { + (client.send as ReturnType).mockResolvedValueOnce({}); + + const result = await service.updateAccessKey( + "alice", + "AKIA1", + "Inactive", + ); + + expect(result).toEqual({ message: "Access key updated successfully" }); + const cmd = (client.send as ReturnType).mock.calls[0][0]; + expect(cmd.input).toMatchObject({ + UserName: "alice", + AccessKeyId: "AKIA1", + Status: "Inactive", + }); + }); + }); + + // ── Group Operations ───────────────────────────────────────────────────── + + describe("listGroups", () => { + it("returns a mapped list of groups", async () => { + const createDate = new Date("2024-02-10T12:00:00.000Z"); + (client.send as ReturnType).mockResolvedValueOnce({ + Groups: [ + { + GroupName: "admins", + GroupId: "AGPA1", + Arn: "arn:aws:iam::000000000000:group/admins", + CreateDate: createDate, + Path: "/", + }, + ], + }); + + const result = await service.listGroups(); + + expect(result).toEqual({ + groups: [ + { + groupName: "admins", + groupId: "AGPA1", + arn: "arn:aws:iam::000000000000:group/admins", + createDate: createDate.toISOString(), + path: "/", + }, + ], + }); + }); + + it("returns empty list when Groups is undefined", async () => { + (client.send as ReturnType).mockResolvedValueOnce({}); + const result = await service.listGroups(); + expect(result).toEqual({ groups: [] }); + }); + + it("uses empty strings when group fields are undefined", async () => { + (client.send as ReturnType).mockResolvedValueOnce({ + Groups: [ + { + /* all fields absent */ + }, + ], + }); + const result = await service.listGroups(); + expect(result.groups[0]).toEqual({ + groupName: "", + groupId: "", + arn: "", + createDate: "", + path: "/", + }); + }); + }); + + describe("createGroup", () => { + it("creates a group without path", async () => { + (client.send as ReturnType).mockResolvedValueOnce({}); + + const result = await service.createGroup("admins"); + + expect(result).toEqual({ message: "Group created successfully" }); + const cmd = (client.send as ReturnType).mock.calls[0][0]; + expect(cmd.input).toMatchObject({ GroupName: "admins" }); + expect(cmd.input.Path).toBeUndefined(); + }); + + it("creates a group with an explicit path", async () => { + (client.send as ReturnType).mockResolvedValueOnce({}); + + await service.createGroup("admins", "/teams/"); + + const cmd = (client.send as ReturnType).mock.calls[0][0]; + expect(cmd.input).toMatchObject({ GroupName: "admins", Path: "/teams/" }); + }); + }); + + describe("getGroup", () => { + it("returns group detail with member list", async () => { + const createDate = new Date("2024-04-01T00:00:00.000Z"); + (client.send as ReturnType).mockResolvedValueOnce({ + Group: { + GroupName: "admins", + GroupId: "AGPA1", + Arn: "arn:aws:iam::000000000000:group/admins", + CreateDate: createDate, + Path: "/", + }, + Users: [ + { + UserName: "alice", + UserId: "AIDA123", + Arn: "arn:aws:iam::000000000000:user/alice", + }, + ], + }); + + const result = await service.getGroup("admins"); + + expect(result).toEqual({ + group: { + groupName: "admins", + groupId: "AGPA1", + arn: "arn:aws:iam::000000000000:group/admins", + createDate: createDate.toISOString(), + path: "/", + }, + members: [ + { + userName: "alice", + userId: "AIDA123", + arn: "arn:aws:iam::000000000000:user/alice", + }, + ], + }); + }); + + it("returns empty members list when Users is undefined", async () => { + const createDate = new Date("2024-04-01T00:00:00.000Z"); + (client.send as ReturnType).mockResolvedValueOnce({ + Group: { + GroupName: "admins", + GroupId: "AGPA1", + Arn: "arn:aws:iam::000000000000:group/admins", + CreateDate: createDate, + Path: "/", + }, + }); + + const result = await service.getGroup("admins"); + + expect(result.members).toEqual([]); + }); + + it("uses empty strings when group fields are undefined", async () => { + (client.send as ReturnType).mockResolvedValueOnce({ + Group: { + /* all fields absent */ + }, + Users: [ + { + /* all user fields absent */ + }, + ], + }); + const result = await service.getGroup("admins"); + expect(result.group).toEqual({ + groupName: "", + groupId: "", + arn: "", + createDate: "", + path: "/", + }); + expect(result.members[0]).toEqual({ + userName: "", + userId: "", + arn: "", + }); + }); + }); + + describe("deleteGroup", () => { + it("cleans up members, inline policies, attached policies and deletes group", async () => { + (client.send as ReturnType) + // GetGroup (to list members) + .mockResolvedValueOnce({ + Group: { GroupName: "admins" }, + Users: [{ UserName: "alice" }], + }) + // RemoveUserFromGroup + .mockResolvedValueOnce({}) + // ListGroupPolicies + .mockResolvedValueOnce({ PolicyNames: ["InlineGroupPolicy"] }) + // DeleteGroupPolicy + .mockResolvedValueOnce({}) + // ListAttachedGroupPolicies + .mockResolvedValueOnce({ + AttachedPolicies: [{ PolicyArn: "arn:aws:iam::aws:policy/ReadOnly" }], + }) + // DetachGroupPolicy + .mockResolvedValueOnce({}) + // DeleteGroup + .mockResolvedValueOnce({}); + + const result = await service.deleteGroup("admins"); + + expect(result).toEqual({ success: true }); + expect(client.send).toHaveBeenCalledTimes(7); + }); + + it("handles group with no members, policies, or attached policies", async () => { + (client.send as ReturnType) + .mockResolvedValueOnce({ Group: { GroupName: "empty" }, Users: [] }) + .mockResolvedValueOnce({ PolicyNames: [] }) + .mockResolvedValueOnce({ AttachedPolicies: [] }) + .mockResolvedValueOnce({}); + + const result = await service.deleteGroup("empty"); + + expect(result).toEqual({ success: true }); + expect(client.send).toHaveBeenCalledTimes(4); + }); + + it("throws AppError on error", async () => { + (client.send as ReturnType).mockRejectedValueOnce( + makeError("NoSuchEntityException", "Group not found"), + ); + await expect(service.deleteGroup("ghost")).rejects.toMatchObject({ + statusCode: 404, + code: "NOT_FOUND", + }); + }); + }); + + describe("addUserToGroup", () => { + it("adds a user to a group and returns message", async () => { + (client.send as ReturnType).mockResolvedValueOnce({}); + + const result = await service.addUserToGroup("admins", "alice"); + + expect(result).toEqual({ message: "User added to group" }); + const cmd = (client.send as ReturnType).mock.calls[0][0]; + expect(cmd.input).toMatchObject({ + GroupName: "admins", + UserName: "alice", + }); + }); + }); + + describe("removeUserFromGroup", () => { + it("removes a user from a group and returns message", async () => { + (client.send as ReturnType).mockResolvedValueOnce({}); + + const result = await service.removeUserFromGroup("admins", "alice"); + + expect(result).toEqual({ message: "User removed from group" }); + const cmd = (client.send as ReturnType).mock.calls[0][0]; + expect(cmd.input).toMatchObject({ + GroupName: "admins", + UserName: "alice", + }); + }); + }); + + describe("listGroupsForUser", () => { + it("returns the groups a user belongs to", async () => { + (client.send as ReturnType).mockResolvedValueOnce({ + Groups: [ + { + GroupName: "admins", + GroupId: "AGPA1", + Arn: "arn:aws:iam::000000000000:group/admins", + }, + ], + }); + + const result = await service.listGroupsForUser("alice"); + + expect(result).toEqual({ + groups: [ + { + groupName: "admins", + groupId: "AGPA1", + arn: "arn:aws:iam::000000000000:group/admins", + }, + ], + }); + }); + + it("returns empty list when Groups is undefined", async () => { + (client.send as ReturnType).mockResolvedValueOnce({}); + const result = await service.listGroupsForUser("alice"); + expect(result).toEqual({ groups: [] }); + }); + + it("uses empty strings when group fields are undefined in listGroupsForUser", async () => { + (client.send as ReturnType).mockResolvedValueOnce({ + Groups: [ + { + /* all fields absent */ + }, + ], + }); + const result = await service.listGroupsForUser("alice"); + expect(result.groups[0]).toEqual({ + groupName: "", + groupId: "", + arn: "", + }); + }); + }); + + // ── User Inline Policy Operations ──────────────────────────────────────── + + describe("listUserPolicies", () => { + it("returns policy names for the user", async () => { + (client.send as ReturnType).mockResolvedValueOnce({ + PolicyNames: ["InlinePolicy1", "InlinePolicy2"], + }); + + const result = await service.listUserPolicies("alice"); + + expect(result).toEqual({ + policyNames: ["InlinePolicy1", "InlinePolicy2"], + }); + }); + + it("returns empty list when PolicyNames is undefined", async () => { + (client.send as ReturnType).mockResolvedValueOnce({}); + const result = await service.listUserPolicies("alice"); + expect(result).toEqual({ policyNames: [] }); + }); + }); + + describe("getUserPolicy", () => { + it("returns policy name and URI-decoded policy document", async () => { + const encoded = "%7B%22Version%22%3A%222012-10-17%22%7D"; + (client.send as ReturnType).mockResolvedValueOnce({ + PolicyName: "InlinePolicy1", + PolicyDocument: encoded, + }); + + const result = await service.getUserPolicy("alice", "InlinePolicy1"); + + expect(result).toEqual({ + policyName: "InlinePolicy1", + policyDocument: decodeURIComponent(encoded), + }); + }); + + it("returns empty strings when response fields are undefined", async () => { + (client.send as ReturnType).mockResolvedValueOnce({}); + + const result = await service.getUserPolicy("alice", "InlinePolicy1"); + + expect(result).toEqual({ policyName: "", policyDocument: "" }); + }); + }); + + describe("putUserPolicy", () => { + it("saves a user inline policy and returns success message", async () => { + (client.send as ReturnType).mockResolvedValueOnce({}); + + const doc = '{"Version":"2012-10-17"}'; + const result = await service.putUserPolicy("alice", "InlinePolicy1", doc); + + expect(result).toEqual({ message: "Policy saved successfully" }); + const cmd = (client.send as ReturnType).mock.calls[0][0]; + expect(cmd.input).toMatchObject({ + UserName: "alice", + PolicyName: "InlinePolicy1", + PolicyDocument: doc, + }); + }); + }); + + describe("deleteUserPolicy", () => { + it("deletes a user inline policy and returns success", async () => { + (client.send as ReturnType).mockResolvedValueOnce({}); + + const result = await service.deleteUserPolicy("alice", "InlinePolicy1"); + + expect(result).toEqual({ success: true }); + const cmd = (client.send as ReturnType).mock.calls[0][0]; + expect(cmd.input).toMatchObject({ + UserName: "alice", + PolicyName: "InlinePolicy1", + }); + }); + }); + + // ── Group Inline Policy Operations ─────────────────────────────────────── + + describe("listGroupPolicies", () => { + it("returns policy names for the group", async () => { + (client.send as ReturnType).mockResolvedValueOnce({ + PolicyNames: ["GroupPolicy1"], + }); + + const result = await service.listGroupPolicies("admins"); + + expect(result).toEqual({ policyNames: ["GroupPolicy1"] }); + }); + + it("returns empty list when PolicyNames is undefined", async () => { + (client.send as ReturnType).mockResolvedValueOnce({}); + const result = await service.listGroupPolicies("admins"); + expect(result).toEqual({ policyNames: [] }); + }); + }); + + describe("getGroupPolicy", () => { + it("returns policy name and URI-decoded policy document", async () => { + const encoded = + "%7B%22Version%22%3A%222012-10-17%22%2C%22Statement%22%3A%5B%5D%7D"; + (client.send as ReturnType).mockResolvedValueOnce({ + PolicyName: "GroupPolicy1", + PolicyDocument: encoded, + }); + + const result = await service.getGroupPolicy("admins", "GroupPolicy1"); + + expect(result).toEqual({ + policyName: "GroupPolicy1", + policyDocument: decodeURIComponent(encoded), + }); + }); + + it("returns empty strings when response fields are undefined", async () => { + (client.send as ReturnType).mockResolvedValueOnce({}); + const result = await service.getGroupPolicy("admins", "GroupPolicy1"); + expect(result).toEqual({ policyName: "", policyDocument: "" }); + }); + }); + + describe("putGroupPolicy", () => { + it("saves a group inline policy and returns success message", async () => { + (client.send as ReturnType).mockResolvedValueOnce({}); + + const doc = '{"Version":"2012-10-17"}'; + const result = await service.putGroupPolicy( + "admins", + "GroupPolicy1", + doc, + ); + + expect(result).toEqual({ message: "Policy saved successfully" }); + const cmd = (client.send as ReturnType).mock.calls[0][0]; + expect(cmd.input).toMatchObject({ + GroupName: "admins", + PolicyName: "GroupPolicy1", + PolicyDocument: doc, + }); + }); + }); + + describe("deleteGroupPolicy", () => { + it("deletes a group inline policy and returns success", async () => { + (client.send as ReturnType).mockResolvedValueOnce({}); + + const result = await service.deleteGroupPolicy("admins", "GroupPolicy1"); + + expect(result).toEqual({ success: true }); + const cmd = (client.send as ReturnType).mock.calls[0][0]; + expect(cmd.input).toMatchObject({ + GroupName: "admins", + PolicyName: "GroupPolicy1", + }); + }); + }); + + // ── listRoles ──────────────────────────────────────────────────────────── + + describe("listRoles", () => { + it("throws AppError 501 NOT_IMPLEMENTED", async () => { + await expect(service.listRoles()).rejects.toMatchObject({ + statusCode: 501, + code: "NOT_IMPLEMENTED", + }); + }); + + it("throws an AppError instance", async () => { + await expect(service.listRoles()).rejects.toBeInstanceOf(AppError); + }); + }); + + // ── Managed Policy Operations ───────────────────────────────────────────── + + describe("listManagedPolicies", () => { + it("returns mapped managed policies using default Local scope", async () => { + const createDate = new Date("2024-01-01T00:00:00.000Z"); + (client.send as ReturnType).mockResolvedValueOnce({ + Policies: [ + { + PolicyName: "MyPolicy", + PolicyId: "ANPA1", + Arn: "arn:aws:iam::000000000000:policy/MyPolicy", + AttachmentCount: 2, + CreateDate: createDate, + DefaultVersionId: "v1", + Description: "My policy", + }, + ], + }); + + const result = await service.listManagedPolicies(); + + expect(result).toEqual({ + policies: [ + { + policyName: "MyPolicy", + policyId: "ANPA1", + arn: "arn:aws:iam::000000000000:policy/MyPolicy", + attachmentCount: 2, + createDate: createDate.toISOString(), + defaultVersionId: "v1", + description: "My policy", + }, + ], + }); + const cmd = (client.send as ReturnType).mock.calls[0][0]; + expect(cmd.input).toMatchObject({ Scope: "Local" }); + }); + + it("passes explicit scope parameter to the command", async () => { + (client.send as ReturnType).mockResolvedValueOnce({ + Policies: [], + }); + + await service.listManagedPolicies("All"); + + const cmd = (client.send as ReturnType).mock.calls[0][0]; + expect(cmd.input).toMatchObject({ Scope: "All" }); + }); + + it("returns empty list when Policies is undefined", async () => { + (client.send as ReturnType).mockResolvedValueOnce({}); + const result = await service.listManagedPolicies(); + expect(result).toEqual({ policies: [] }); + }); + + it("uses empty strings when policy fields are undefined in listManagedPolicies", async () => { + (client.send as ReturnType).mockResolvedValueOnce({ + Policies: [ + { + /* all fields absent */ + }, + ], + }); + const result = await service.listManagedPolicies(); + expect(result.policies[0]).toEqual({ + policyName: "", + policyId: "", + arn: "", + attachmentCount: 0, + createDate: "", + defaultVersionId: "", + description: "", + }); + }); + }); + + describe("getPolicy", () => { + it("returns mapped policy detail", async () => { + const createDate = new Date("2024-01-01T00:00:00.000Z"); + const updateDate = new Date("2024-06-01T00:00:00.000Z"); + (client.send as ReturnType).mockResolvedValueOnce({ + Policy: { + PolicyName: "MyPolicy", + PolicyId: "ANPA1", + Arn: "arn:aws:iam::000000000000:policy/MyPolicy", + AttachmentCount: 1, + CreateDate: createDate, + DefaultVersionId: "v2", + Description: "desc", + Path: "/", + IsAttachable: true, + UpdateDate: updateDate, + }, + }); + + const result = await service.getPolicy( + "arn:aws:iam::000000000000:policy/MyPolicy", + ); + + expect(result).toEqual({ + policyName: "MyPolicy", + policyId: "ANPA1", + arn: "arn:aws:iam::000000000000:policy/MyPolicy", + attachmentCount: 1, + createDate: createDate.toISOString(), + defaultVersionId: "v2", + description: "desc", + path: "/", + isAttachable: true, + updateDate: updateDate.toISOString(), + }); + }); + + it("uses empty strings and defaults when policy fields are undefined", async () => { + (client.send as ReturnType).mockResolvedValueOnce({ + Policy: { + /* all fields absent */ + }, + }); + const result = await service.getPolicy( + "arn:aws:iam::000000000000:policy/MyPolicy", + ); + expect(result).toEqual({ + policyName: "", + policyId: "", + arn: "", + attachmentCount: 0, + createDate: "", + defaultVersionId: "", + description: "", + path: "/", + isAttachable: true, + updateDate: "", + }); + }); + }); + + describe("getPolicyDocument", () => { + it("returns the policy document for an explicit versionId", async () => { + const encoded = "%7B%22Version%22%3A%222012-10-17%22%7D"; + (client.send as ReturnType).mockResolvedValueOnce({ + PolicyVersion: { + Document: encoded, + IsDefaultVersion: true, + }, + }); + + const result = await service.getPolicyDocument( + "arn:aws:iam::000000000000:policy/MyPolicy", + "v1", + ); + + expect(result).toEqual({ + versionId: "v1", + isDefaultVersion: true, + document: decodeURIComponent(encoded), + }); + expect(client.send).toHaveBeenCalledTimes(1); + }); + + it("returns empty document when PolicyVersion.Document is undefined", async () => { + (client.send as ReturnType).mockResolvedValueOnce({ + PolicyVersion: { + IsDefaultVersion: true, + }, + }); + + const result = await service.getPolicyDocument( + "arn:aws:iam::000000000000:policy/MyPolicy", + "v1", + ); + + expect(result.document).toBe(""); + }); + + it("resolves the default version when no versionId is provided", async () => { + const createDate = new Date("2024-01-01T00:00:00.000Z"); + const updateDate = new Date("2024-06-01T00:00:00.000Z"); + const encoded = "%7B%22Version%22%3A%222012-10-17%22%7D"; + + (client.send as ReturnType) + // GetPolicy (called by getPolicy() inside getPolicyDocument) + .mockResolvedValueOnce({ + Policy: { + PolicyName: "MyPolicy", + PolicyId: "ANPA1", + Arn: "arn:aws:iam::000000000000:policy/MyPolicy", + AttachmentCount: 1, + CreateDate: createDate, + DefaultVersionId: "v3", + Description: "", + Path: "/", + IsAttachable: true, + UpdateDate: updateDate, + }, + }) + // GetPolicyVersion + .mockResolvedValueOnce({ + PolicyVersion: { + Document: encoded, + IsDefaultVersion: true, + }, + }); + + const result = await service.getPolicyDocument( + "arn:aws:iam::000000000000:policy/MyPolicy", + ); + + expect(result).toEqual({ + versionId: "v3", + isDefaultVersion: true, + document: decodeURIComponent(encoded), + }); + expect(client.send).toHaveBeenCalledTimes(2); + }); + }); + + describe("createPolicy", () => { + it("creates a policy with description and path and returns arn", async () => { + (client.send as ReturnType).mockResolvedValueOnce({ + Policy: { Arn: "arn:aws:iam::000000000000:policy/MyPolicy" }, + }); + + const doc = '{"Version":"2012-10-17"}'; + const result = await service.createPolicy( + "MyPolicy", + doc, + "My description", + "/custom/", + ); + + expect(result).toEqual({ + message: "Policy created successfully", + arn: "arn:aws:iam::000000000000:policy/MyPolicy", + }); + const cmd = (client.send as ReturnType).mock.calls[0][0]; + expect(cmd.input).toMatchObject({ + PolicyName: "MyPolicy", + PolicyDocument: doc, + Description: "My description", + Path: "/custom/", + }); + }); + + it("creates a policy without optional description and path", async () => { + (client.send as ReturnType).mockResolvedValueOnce({ + Policy: { Arn: "arn:aws:iam::000000000000:policy/MyPolicy" }, + }); + + await service.createPolicy("MyPolicy", '{"Version":"2012-10-17"}'); + + const cmd = (client.send as ReturnType).mock.calls[0][0]; + expect(cmd.input.Description).toBeUndefined(); + expect(cmd.input.Path).toBeUndefined(); + }); + + it("returns empty string for arn when Policy.Arn is undefined", async () => { + (client.send as ReturnType).mockResolvedValueOnce({ + Policy: { + /* Arn absent */ + }, + }); + const result = await service.createPolicy("MyPolicy", "{}"); + expect(result.arn).toBe(""); + }); + }); + + describe("deletePolicy", () => { + it("deletes non-default versions, detaches from users and groups, then deletes", async () => { + (client.send as ReturnType) + // ListPolicyVersions + .mockResolvedValueOnce({ + Versions: [ + { VersionId: "v1", IsDefaultVersion: true }, + { VersionId: "v2", IsDefaultVersion: false }, + ], + }) + // DeletePolicyVersion (v2 only) + .mockResolvedValueOnce({}) + // ListEntitiesForPolicy + .mockResolvedValueOnce({ + PolicyUsers: [{ UserName: "alice" }], + PolicyGroups: [{ GroupName: "admins" }], + }) + // DetachUserPolicy + .mockResolvedValueOnce({}) + // DetachGroupPolicy + .mockResolvedValueOnce({}) + // DeletePolicy + .mockResolvedValueOnce({}); + + const result = await service.deletePolicy( + "arn:aws:iam::000000000000:policy/MyPolicy", + ); + + expect(result).toEqual({ success: true }); + expect(client.send).toHaveBeenCalledTimes(6); + }); + + it("skips version deletion and entity detach when there are none", async () => { + (client.send as ReturnType) + .mockResolvedValueOnce({ + Versions: [{ VersionId: "v1", IsDefaultVersion: true }], + }) + .mockResolvedValueOnce({ PolicyUsers: [], PolicyGroups: [] }) + .mockResolvedValueOnce({}); + + const result = await service.deletePolicy( + "arn:aws:iam::000000000000:policy/EmptyPolicy", + ); + + expect(result).toEqual({ success: true }); + expect(client.send).toHaveBeenCalledTimes(3); + }); + + it("throws AppError on error", async () => { + (client.send as ReturnType).mockRejectedValueOnce( + makeError("NoSuchEntityException", "Policy not found"), + ); + await expect( + service.deletePolicy("arn:aws:iam::000000000000:policy/Ghost"), + ).rejects.toMatchObject({ + statusCode: 404, + code: "NOT_FOUND", + }); + }); + }); + + // ── Policy Versioning ──────────────────────────────────────────────────── + + describe("listPolicyVersions", () => { + it("returns mapped version list", async () => { + const createDate = new Date("2024-07-01T00:00:00.000Z"); + (client.send as ReturnType).mockResolvedValueOnce({ + Versions: [ + { + VersionId: "v1", + IsDefaultVersion: true, + CreateDate: createDate, + }, + ], + }); + + const result = await service.listPolicyVersions( + "arn:aws:iam::000000000000:policy/MyPolicy", + ); + + expect(result).toEqual({ + versions: [ + { + versionId: "v1", + isDefaultVersion: true, + createDate: createDate.toISOString(), + }, + ], + }); + }); + + it("returns empty list when Versions is undefined", async () => { + (client.send as ReturnType).mockResolvedValueOnce({}); + const result = await service.listPolicyVersions( + "arn:aws:iam::000000000000:policy/MyPolicy", + ); + expect(result).toEqual({ versions: [] }); + }); + + it("uses empty strings when version fields are undefined", async () => { + (client.send as ReturnType).mockResolvedValueOnce({ + Versions: [ + { + /* all fields absent */ + }, + ], + }); + const result = await service.listPolicyVersions( + "arn:aws:iam::000000000000:policy/MyPolicy", + ); + expect(result.versions[0]).toEqual({ + versionId: "", + isDefaultVersion: false, + createDate: "", + }); + }); + }); + + describe("createPolicyVersion", () => { + it("creates a new policy version and returns versionId", async () => { + (client.send as ReturnType).mockResolvedValueOnce({ + PolicyVersion: { VersionId: "v2" }, + }); + + const doc = '{"Version":"2012-10-17"}'; + const result = await service.createPolicyVersion( + "arn:aws:iam::000000000000:policy/MyPolicy", + doc, + true, + ); + + expect(result).toEqual({ + message: "Policy version created successfully", + versionId: "v2", + }); + const cmd = (client.send as ReturnType).mock.calls[0][0]; + expect(cmd.input).toMatchObject({ + PolicyArn: "arn:aws:iam::000000000000:policy/MyPolicy", + PolicyDocument: doc, + SetAsDefault: true, + }); + }); + + it("uses empty string for versionId when response fields are undefined", async () => { + (client.send as ReturnType).mockResolvedValueOnce({ + PolicyVersion: { + /* VersionId absent */ + }, + }); + const result = await service.createPolicyVersion( + "arn:aws:iam::000000000000:policy/MyPolicy", + "{}", + false, + ); + expect(result.versionId).toBe(""); + }); + }); + + describe("deletePolicyVersion", () => { + it("deletes a specific policy version and returns success", async () => { + (client.send as ReturnType).mockResolvedValueOnce({}); + + const result = await service.deletePolicyVersion( + "arn:aws:iam::000000000000:policy/MyPolicy", + "v2", + ); + + expect(result).toEqual({ success: true }); + const cmd = (client.send as ReturnType).mock.calls[0][0]; + expect(cmd.input).toMatchObject({ + PolicyArn: "arn:aws:iam::000000000000:policy/MyPolicy", + VersionId: "v2", + }); + }); + + it("throws AppError when deletePolicyVersion fails", async () => { + (client.send as ReturnType).mockRejectedValueOnce( + makeError("NoSuchEntityException", "Version not found"), + ); + await expect( + service.deletePolicyVersion( + "arn:aws:iam::000000000000:policy/MyPolicy", + "v99", + ), + ).rejects.toMatchObject({ statusCode: 404, code: "NOT_FOUND" }); + }); + }); + + describe("setDefaultPolicyVersion", () => { + it("sets the default version and returns success message", async () => { + (client.send as ReturnType).mockResolvedValueOnce({}); + + const result = await service.setDefaultPolicyVersion( + "arn:aws:iam::000000000000:policy/MyPolicy", + "v3", + ); + + expect(result).toEqual({ + message: "Default policy version updated successfully", + }); + const cmd = (client.send as ReturnType).mock.calls[0][0]; + expect(cmd.input).toMatchObject({ + PolicyArn: "arn:aws:iam::000000000000:policy/MyPolicy", + VersionId: "v3", + }); + }); + + it("throws AppError when setDefaultPolicyVersion fails", async () => { + (client.send as ReturnType).mockRejectedValueOnce( + makeError("NoSuchEntityException", "Policy not found"), + ); + await expect( + service.setDefaultPolicyVersion( + "arn:aws:iam::000000000000:policy/MyPolicy", + "v3", + ), + ).rejects.toMatchObject({ statusCode: 404, code: "NOT_FOUND" }); + }); + }); + + // ── Attach/Detach User Policies ────────────────────────────────────────── + + describe("attachUserPolicy", () => { + it("attaches a policy to a user and returns success message", async () => { + (client.send as ReturnType).mockResolvedValueOnce({}); + + const result = await service.attachUserPolicy( + "alice", + "arn:aws:iam::aws:policy/ReadOnly", + ); + + expect(result).toEqual({ + message: "Policy attached to user successfully", + }); + const cmd = (client.send as ReturnType).mock.calls[0][0]; + expect(cmd.input).toMatchObject({ + UserName: "alice", + PolicyArn: "arn:aws:iam::aws:policy/ReadOnly", + }); + }); + + it("throws AppError when attachUserPolicy fails", async () => { + (client.send as ReturnType).mockRejectedValueOnce( + makeError("NoSuchEntityException", "User not found"), + ); + await expect( + service.attachUserPolicy("alice", "arn:aws:iam::aws:policy/ReadOnly"), + ).rejects.toMatchObject({ statusCode: 404, code: "NOT_FOUND" }); + }); + }); + + describe("detachUserPolicy", () => { + it("detaches a policy from a user and returns success message", async () => { + (client.send as ReturnType).mockResolvedValueOnce({}); + + const result = await service.detachUserPolicy( + "alice", + "arn:aws:iam::aws:policy/ReadOnly", + ); + + expect(result).toEqual({ + message: "Policy detached from user successfully", + }); + const cmd = (client.send as ReturnType).mock.calls[0][0]; + expect(cmd.input).toMatchObject({ + UserName: "alice", + PolicyArn: "arn:aws:iam::aws:policy/ReadOnly", + }); + }); + + it("throws AppError when detachUserPolicy fails", async () => { + (client.send as ReturnType).mockRejectedValueOnce( + makeError("NoSuchEntityException", "Policy not found"), + ); + await expect( + service.detachUserPolicy("alice", "arn:aws:iam::aws:policy/ReadOnly"), + ).rejects.toMatchObject({ statusCode: 404, code: "NOT_FOUND" }); + }); + }); + + describe("listAttachedUserPolicies", () => { + it("returns attached policies for a user", async () => { + (client.send as ReturnType).mockResolvedValueOnce({ + AttachedPolicies: [ + { + PolicyName: "ReadOnly", + PolicyArn: "arn:aws:iam::aws:policy/ReadOnly", + }, + ], + }); + + const result = await service.listAttachedUserPolicies("alice"); + + expect(result).toEqual({ + attachedPolicies: [ + { + policyName: "ReadOnly", + policyArn: "arn:aws:iam::aws:policy/ReadOnly", + }, + ], + }); + }); + + it("returns empty list when AttachedPolicies is undefined", async () => { + (client.send as ReturnType).mockResolvedValueOnce({}); + const result = await service.listAttachedUserPolicies("alice"); + expect(result).toEqual({ attachedPolicies: [] }); + }); + + it("throws AppError when listAttachedUserPolicies fails", async () => { + (client.send as ReturnType).mockRejectedValueOnce( + makeError("NoSuchEntityException", "User not found"), + ); + await expect( + service.listAttachedUserPolicies("alice"), + ).rejects.toMatchObject({ statusCode: 404, code: "NOT_FOUND" }); + }); + }); + + // ── Attach/Detach Group Policies ───────────────────────────────────────── + + describe("attachGroupPolicy", () => { + it("attaches a policy to a group and returns success message", async () => { + (client.send as ReturnType).mockResolvedValueOnce({}); + + const result = await service.attachGroupPolicy( + "admins", + "arn:aws:iam::aws:policy/ReadOnly", + ); + + expect(result).toEqual({ + message: "Policy attached to group successfully", + }); + const cmd = (client.send as ReturnType).mock.calls[0][0]; + expect(cmd.input).toMatchObject({ + GroupName: "admins", + PolicyArn: "arn:aws:iam::aws:policy/ReadOnly", + }); + }); + + it("throws AppError when attachGroupPolicy fails", async () => { + (client.send as ReturnType).mockRejectedValueOnce( + makeError("NoSuchEntityException", "Group not found"), + ); + await expect( + service.attachGroupPolicy("admins", "arn:aws:iam::aws:policy/ReadOnly"), + ).rejects.toMatchObject({ statusCode: 404, code: "NOT_FOUND" }); + }); + }); + + describe("detachGroupPolicy", () => { + it("detaches a policy from a group and returns success message", async () => { + (client.send as ReturnType).mockResolvedValueOnce({}); + + const result = await service.detachGroupPolicy( + "admins", + "arn:aws:iam::aws:policy/ReadOnly", + ); + + expect(result).toEqual({ + message: "Policy detached from group successfully", + }); + const cmd = (client.send as ReturnType).mock.calls[0][0]; + expect(cmd.input).toMatchObject({ + GroupName: "admins", + PolicyArn: "arn:aws:iam::aws:policy/ReadOnly", + }); + }); + + it("throws AppError when detachGroupPolicy fails", async () => { + (client.send as ReturnType).mockRejectedValueOnce( + makeError("NoSuchEntityException", "Policy not found"), + ); + await expect( + service.detachGroupPolicy("admins", "arn:aws:iam::aws:policy/ReadOnly"), + ).rejects.toMatchObject({ statusCode: 404, code: "NOT_FOUND" }); + }); + }); + + describe("listAttachedGroupPolicies", () => { + it("returns attached policies for a group", async () => { + (client.send as ReturnType).mockResolvedValueOnce({ + AttachedPolicies: [ + { + PolicyName: "ReadOnly", + PolicyArn: "arn:aws:iam::aws:policy/ReadOnly", + }, + ], + }); + + const result = await service.listAttachedGroupPolicies("admins"); + + expect(result).toEqual({ + attachedPolicies: [ + { + policyName: "ReadOnly", + policyArn: "arn:aws:iam::aws:policy/ReadOnly", + }, + ], + }); + }); + + it("returns empty list when AttachedPolicies is undefined", async () => { + (client.send as ReturnType).mockResolvedValueOnce({}); + const result = await service.listAttachedGroupPolicies("admins"); + expect(result).toEqual({ attachedPolicies: [] }); + }); + + it("throws AppError when listAttachedGroupPolicies fails", async () => { + (client.send as ReturnType).mockRejectedValueOnce( + makeError("NoSuchEntityException", "Group not found"), + ); + await expect( + service.listAttachedGroupPolicies("admins"), + ).rejects.toMatchObject({ statusCode: 404, code: "NOT_FOUND" }); + }); + }); +}); diff --git a/packages/backend/test/plugins/plugin-index.test.ts b/packages/backend/test/plugins/plugin-index.test.ts new file mode 100644 index 0000000..a7012d6 --- /dev/null +++ b/packages/backend/test/plugins/plugin-index.test.ts @@ -0,0 +1,58 @@ +import Fastify from "fastify"; +import { describe, expect, it } from "vitest"; +// clientCachePlugin uses fastify-plugin (fp) so it decorates the root instance. +// We register the real plugins here to verify the index.ts wrappers boot correctly. +import clientCachePlugin from "../../src/plugins/client-cache.js"; +import cloudformationPlugin from "../../src/plugins/cloudformation/index.js"; +import dynamodbPlugin from "../../src/plugins/dynamodb/index.js"; +import iamPlugin from "../../src/plugins/iam/index.js"; +import localstackConfigPlugin from "../../src/plugins/localstack-config.js"; +import snsPlugin from "../../src/plugins/sns/index.js"; +import sqsPlugin from "../../src/plugins/sqs/index.js"; + +describe("plugin index files", () => { + it("should register sqsPlugin without error", async () => { + const app = Fastify(); + await app.register(localstackConfigPlugin); + await app.register(clientCachePlugin); + await app.register(sqsPlugin); + await expect(app.ready()).resolves.not.toThrow(); + await app.close(); + }); + + it("should register snsPlugin without error", async () => { + const app = Fastify(); + await app.register(localstackConfigPlugin); + await app.register(clientCachePlugin); + await app.register(snsPlugin); + await expect(app.ready()).resolves.not.toThrow(); + await app.close(); + }); + + it("should register dynamodbPlugin without error", async () => { + const app = Fastify(); + await app.register(localstackConfigPlugin); + await app.register(clientCachePlugin); + await app.register(dynamodbPlugin); + await expect(app.ready()).resolves.not.toThrow(); + await app.close(); + }); + + it("should register iamPlugin without error", async () => { + const app = Fastify(); + await app.register(localstackConfigPlugin); + await app.register(clientCachePlugin); + await app.register(iamPlugin); + await expect(app.ready()).resolves.not.toThrow(); + await app.close(); + }); + + it("should register cloudformationPlugin without error", async () => { + const app = Fastify(); + await app.register(localstackConfigPlugin); + await app.register(clientCachePlugin); + await app.register(cloudformationPlugin); + await expect(app.ready()).resolves.not.toThrow(); + await app.close(); + }); +}); diff --git a/packages/backend/test/plugins/s3.test.ts b/packages/backend/test/plugins/s3.test.ts index aa6430b..9180305 100644 --- a/packages/backend/test/plugins/s3.test.ts +++ b/packages/backend/test/plugins/s3.test.ts @@ -1,3 +1,4 @@ +import multipart from "@fastify/multipart"; import Fastify, { type FastifyInstance } from "fastify"; import { afterAll, @@ -320,3 +321,152 @@ describe("S3 Routes", () => { }); }); }); + +describe("S3 Routes - Upload (multipart)", () => { + let uploadApp: FastifyInstance; + let mockService: MockS3Service; + + beforeAll(async () => { + uploadApp = Fastify(); + registerErrorHandler(uploadApp); + + mockService = createMockS3Service(); + + (S3ServiceClass as unknown as Mock).mockImplementation(() => mockService); + + const mockClientCache = { + getClients: vi.fn().mockReturnValue({ s3: {} }), + }; + uploadApp.decorate( + "clientCache", + mockClientCache as unknown as ClientCache, + ); + + uploadApp.decorateRequest("localstackConfig", null); + uploadApp.addHook("onRequest", async (request) => { + request.localstackConfig = { + endpoint: "http://localhost:4566", + region: "us-east-1", + }; + }); + + // Register multipart support so request.file() is available + await uploadApp.register(multipart); + await uploadApp.register(s3Routes); + await uploadApp.ready(); + }); + + afterAll(async () => { + await uploadApp.close(); + }); + + describe("POST /:bucketName/objects/upload (uploadObject)", () => { + it("should upload a file and return key and bucket (key from field)", async () => { + mockService.uploadObject.mockClear(); + mockService.uploadObject.mockResolvedValueOnce({ + key: "my-key.txt", + bucket: "test-bucket", + }); + + // Build a multipart body with a key field and a file part + const boundary = "----TestBoundary123"; + const fileContent = "hello world"; + const body = [ + `--${boundary}`, + 'Content-Disposition: form-data; name="key"', + "", + "my-key.txt", + `--${boundary}`, + 'Content-Disposition: form-data; name="file"; filename="upload.txt"', + "Content-Type: text/plain", + "", + fileContent, + `--${boundary}--`, + "", + ].join("\r\n"); + + const response = await uploadApp.inject({ + method: "POST", + url: "/test-bucket/objects/upload", + payload: body, + headers: { + "content-type": `multipart/form-data; boundary=${boundary}`, + }, + }); + + expect(response.statusCode).toBe(200); + const respBody = response.json<{ key: string; bucket: string }>(); + expect(respBody.key).toBe("my-key.txt"); + expect(respBody.bucket).toBe("test-bucket"); + expect(mockService.uploadObject).toHaveBeenCalledWith( + "test-bucket", + "my-key.txt", + expect.any(Buffer), + "text/plain", + ); + }); + + it("should use filename when no key field is provided", async () => { + mockService.uploadObject.mockClear(); + mockService.uploadObject.mockResolvedValueOnce({ + key: "upload.txt", + bucket: "test-bucket", + }); + + const boundary = "----TestBoundary456"; + const fileContent = "file without key field"; + const body = [ + `--${boundary}`, + 'Content-Disposition: form-data; name="file"; filename="upload.txt"', + "Content-Type: text/plain", + "", + fileContent, + `--${boundary}--`, + "", + ].join("\r\n"); + + const response = await uploadApp.inject({ + method: "POST", + url: "/test-bucket/objects/upload", + payload: body, + headers: { + "content-type": `multipart/form-data; boundary=${boundary}`, + }, + }); + + expect(response.statusCode).toBe(200); + expect(mockService.uploadObject).toHaveBeenCalledWith( + "test-bucket", + "upload.txt", + expect.any(Buffer), + "text/plain", + ); + }); + + it("should throw AppError 400 when no file is provided", async () => { + // Send a non-multipart request so request.file() returns undefined + const boundary = "----TestBoundaryEmpty"; + const body = [ + `--${boundary}`, + 'Content-Disposition: form-data; name="otherField"', + "", + "some value", + `--${boundary}--`, + "", + ].join("\r\n"); + + const response = await uploadApp.inject({ + method: "POST", + url: "/test-bucket/objects/upload", + payload: body, + headers: { + "content-type": `multipart/form-data; boundary=${boundary}`, + }, + }); + + // When no file part is present, request.file() returns undefined + // and the handler should return 400 + expect(response.statusCode).toBe(400); + }); + }); +}); diff --git a/packages/backend/test/plugins/s3/service.test.ts b/packages/backend/test/plugins/s3/service.test.ts new file mode 100644 index 0000000..3d472d9 --- /dev/null +++ b/packages/backend/test/plugins/s3/service.test.ts @@ -0,0 +1,529 @@ +import type { S3Client } from "@aws-sdk/client-s3"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { S3Service } from "../../../src/plugins/s3/service.js"; +import { AppError } from "../../../src/shared/errors.js"; + +vi.mock("@aws-sdk/s3-request-presigner", () => ({ + getSignedUrl: vi.fn(), +})); + +import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; + +function createMockS3Client() { + return { + send: vi.fn(), + } as unknown as S3Client; +} + +describe("S3Service", () => { + let client: S3Client; + let service: S3Service; + + beforeEach(() => { + client = createMockS3Client(); + service = new S3Service(client); + vi.clearAllMocks(); + }); + + describe("listBuckets", () => { + it("returns formatted bucket list with name and creation date", async () => { + const creationDate = new Date("2024-01-01T00:00:00.000Z"); + (client.send as ReturnType).mockResolvedValueOnce({ + Buckets: [ + { Name: "bucket-one", CreationDate: creationDate }, + { Name: "bucket-two", CreationDate: creationDate }, + ], + }); + + const result = await service.listBuckets(); + + expect(result).toEqual({ + buckets: [ + { name: "bucket-one", creationDate: creationDate.toISOString() }, + { name: "bucket-two", creationDate: creationDate.toISOString() }, + ], + }); + }); + + it("returns empty bucket list when Buckets is undefined", async () => { + (client.send as ReturnType).mockResolvedValueOnce({}); + + const result = await service.listBuckets(); + + expect(result).toEqual({ buckets: [] }); + }); + + it("returns empty bucket list when Buckets is empty array", async () => { + (client.send as ReturnType).mockResolvedValueOnce({ + Buckets: [], + }); + + const result = await service.listBuckets(); + + expect(result).toEqual({ buckets: [] }); + }); + + it("uses empty string for bucket name when Name is undefined", async () => { + (client.send as ReturnType).mockResolvedValueOnce({ + Buckets: [ + { + /* Name intentionally absent */ + }, + ], + }); + + const result = await service.listBuckets(); + expect(result.buckets[0].name).toBe(""); + }); + }); + + describe("createBucket", () => { + it("creates a bucket successfully and returns message", async () => { + (client.send as ReturnType).mockResolvedValueOnce({}); + + const result = await service.createBucket("my-bucket"); + + expect(result).toEqual({ + message: "Bucket 'my-bucket' created successfully", + }); + expect(client.send).toHaveBeenCalledOnce(); + }); + + it("throws AppError with 409 when BucketAlreadyOwnedByYou", async () => { + const error = new Error("Bucket already owned by you") as Error & { + name: string; + }; + error.name = "BucketAlreadyOwnedByYou"; + (client.send as ReturnType).mockRejectedValue(error); + + await expect(service.createBucket("my-bucket")).rejects.toThrow(AppError); + await expect(service.createBucket("my-bucket")).rejects.toMatchObject({ + statusCode: 409, + code: "BUCKET_EXISTS", + message: "Bucket 'my-bucket' already exists", + }); + }); + + it("throws AppError with 409 when BucketAlreadyExists", async () => { + const error = new Error("Bucket already exists") as Error & { + name: string; + }; + error.name = "BucketAlreadyExists"; + (client.send as ReturnType).mockRejectedValue(error); + + await expect(service.createBucket("my-bucket")).rejects.toThrow(AppError); + await expect(service.createBucket("my-bucket")).rejects.toMatchObject({ + statusCode: 409, + code: "BUCKET_EXISTS", + message: "Bucket 'my-bucket' already exists", + }); + }); + + it("re-throws unknown errors from createBucket", async () => { + const error = new Error("Unknown error"); + (client.send as ReturnType).mockRejectedValueOnce(error); + + await expect(service.createBucket("my-bucket")).rejects.toThrow( + "Unknown error", + ); + }); + }); + + describe("deleteBucket", () => { + it("deletes a bucket successfully", async () => { + (client.send as ReturnType).mockResolvedValueOnce({}); + + const result = await service.deleteBucket("my-bucket"); + + expect(result).toEqual({ success: true }); + expect(client.send).toHaveBeenCalledOnce(); + }); + + it("throws AppError with 404 when NoSuchBucket", async () => { + const error = new Error("No such bucket") as Error & { name: string }; + error.name = "NoSuchBucket"; + (client.send as ReturnType).mockRejectedValue(error); + + await expect(service.deleteBucket("missing-bucket")).rejects.toThrow( + AppError, + ); + await expect( + service.deleteBucket("missing-bucket"), + ).rejects.toMatchObject({ + statusCode: 404, + code: "BUCKET_NOT_FOUND", + message: "Bucket 'missing-bucket' not found", + }); + }); + + it("throws AppError with 409 when BucketNotEmpty", async () => { + const error = new Error("Bucket is not empty") as Error & { + name: string; + }; + error.name = "BucketNotEmpty"; + (client.send as ReturnType).mockRejectedValue(error); + + await expect(service.deleteBucket("non-empty-bucket")).rejects.toThrow( + AppError, + ); + await expect( + service.deleteBucket("non-empty-bucket"), + ).rejects.toMatchObject({ + statusCode: 409, + code: "BUCKET_NOT_EMPTY", + message: "Bucket 'non-empty-bucket' is not empty", + }); + }); + + it("re-throws unknown errors from deleteBucket", async () => { + const error = new Error("Unexpected error"); + (client.send as ReturnType).mockRejectedValueOnce(error); + + await expect(service.deleteBucket("my-bucket")).rejects.toThrow( + "Unexpected error", + ); + }); + }); + + describe("listObjects", () => { + it("returns formatted object list with common prefixes and pagination info", async () => { + const lastModified = new Date("2024-06-01T12:00:00.000Z"); + (client.send as ReturnType).mockResolvedValueOnce({ + Contents: [ + { + Key: "file.txt", + Size: 1024, + LastModified: lastModified, + ETag: '"abc123"', + StorageClass: "STANDARD", + }, + ], + CommonPrefixes: [{ Prefix: "folder/" }], + NextContinuationToken: "token-xyz", + IsTruncated: true, + }); + + const result = await service.listObjects("my-bucket"); + + expect(result).toEqual({ + objects: [ + { + key: "file.txt", + size: 1024, + lastModified: lastModified.toISOString(), + etag: '"abc123"', + storageClass: "STANDARD", + }, + ], + commonPrefixes: [{ prefix: "folder/" }], + nextContinuationToken: "token-xyz", + isTruncated: true, + }); + }); + + it("returns empty lists when Contents and CommonPrefixes are undefined", async () => { + (client.send as ReturnType).mockResolvedValueOnce({}); + + const result = await service.listObjects("my-bucket"); + + expect(result).toEqual({ + objects: [], + commonPrefixes: [], + nextContinuationToken: undefined, + isTruncated: false, + }); + }); + + it("uses empty string for prefix when CommonPrefixes entry has no Prefix", async () => { + (client.send as ReturnType).mockResolvedValueOnce({ + Contents: [], + CommonPrefixes: [ + { + /* Prefix intentionally absent */ + }, + ], + IsTruncated: false, + }); + + const result = await service.listObjects("my-bucket"); + expect(result.commonPrefixes[0].prefix).toBe(""); + }); + + it("uses empty string for object key when Key is undefined", async () => { + (client.send as ReturnType).mockResolvedValueOnce({ + Contents: [ + { + /* Key intentionally absent */ + }, + ], + IsTruncated: false, + }); + + const result = await service.listObjects("my-bucket"); + expect(result.objects[0].key).toBe(""); + }); + + it("throws AppError with 404 when NoSuchBucket", async () => { + const error = new Error("No such bucket") as Error & { name: string }; + error.name = "NoSuchBucket"; + (client.send as ReturnType).mockRejectedValue(error); + + await expect(service.listObjects("missing-bucket")).rejects.toThrow( + AppError, + ); + await expect(service.listObjects("missing-bucket")).rejects.toMatchObject( + { + statusCode: 404, + code: "BUCKET_NOT_FOUND", + message: "Bucket 'missing-bucket' not found", + }, + ); + }); + + it("re-throws unknown errors from listObjects", async () => { + const error = new Error("Unexpected error"); + (client.send as ReturnType).mockRejectedValueOnce(error); + + await expect(service.listObjects("my-bucket")).rejects.toThrow( + "Unexpected error", + ); + }); + }); + + describe("getObjectProperties", () => { + it("returns object properties from HeadObject response", async () => { + const lastModified = new Date("2024-03-15T08:00:00.000Z"); + (client.send as ReturnType).mockResolvedValueOnce({ + ContentLength: 2048, + LastModified: lastModified, + ContentType: "text/plain", + ETag: '"def456"', + }); + + const result = await service.getObjectProperties("my-bucket", "file.txt"); + + expect(result).toEqual({ + key: "file.txt", + size: 2048, + lastModified: lastModified.toISOString(), + contentType: "text/plain", + etag: '"def456"', + }); + }); + + it("throws AppError with 404 when NotFound", async () => { + const error = new Error("Not found") as Error & { name: string }; + error.name = "NotFound"; + (client.send as ReturnType).mockRejectedValue(error); + + await expect( + service.getObjectProperties("my-bucket", "missing.txt"), + ).rejects.toThrow(AppError); + await expect( + service.getObjectProperties("my-bucket", "missing.txt"), + ).rejects.toMatchObject({ + statusCode: 404, + code: "OBJECT_NOT_FOUND", + message: "Object 'missing.txt' not found in bucket 'my-bucket'", + }); + }); + + it("throws AppError with 404 when NoSuchKey", async () => { + const error = new Error("No such key") as Error & { name: string }; + error.name = "NoSuchKey"; + (client.send as ReturnType).mockRejectedValue(error); + + await expect( + service.getObjectProperties("my-bucket", "missing.txt"), + ).rejects.toThrow(AppError); + await expect( + service.getObjectProperties("my-bucket", "missing.txt"), + ).rejects.toMatchObject({ + statusCode: 404, + code: "OBJECT_NOT_FOUND", + message: "Object 'missing.txt' not found in bucket 'my-bucket'", + }); + }); + + it("re-throws unknown errors from getObjectProperties", async () => { + const error = new Error("Unexpected error"); + (client.send as ReturnType).mockRejectedValueOnce(error); + + await expect( + service.getObjectProperties("my-bucket", "file.txt"), + ).rejects.toThrow("Unexpected error"); + }); + + it("uses default values when all HeadObject response fields are undefined", async () => { + (client.send as ReturnType).mockResolvedValueOnce({ + // All fields intentionally absent to trigger ?? fallbacks + }); + + const result = await service.getObjectProperties("my-bucket", "file.txt"); + expect(result.size).toBe(0); + expect(result.lastModified).toBe(""); + expect(result.contentType).toBe("application/octet-stream"); + expect(result.etag).toBe(""); + }); + }); + + describe("downloadObject", () => { + it("downloads an object and returns body, contentType, and contentLength", async () => { + const mockBody = Buffer.from("file contents"); + (client.send as ReturnType).mockResolvedValueOnce({ + Body: mockBody, + ContentType: "text/plain", + ContentLength: 13, + }); + + const result = await service.downloadObject("my-bucket", "file.txt"); + + expect(result).toEqual({ + body: mockBody, + contentType: "text/plain", + contentLength: 13, + }); + expect(client.send).toHaveBeenCalledOnce(); + }); + + it("throws AppError with 404 when NoSuchKey", async () => { + const error = new Error("No such key") as Error & { name: string }; + error.name = "NoSuchKey"; + (client.send as ReturnType).mockRejectedValue(error); + + await expect( + service.downloadObject("my-bucket", "missing.txt"), + ).rejects.toThrow(AppError); + await expect( + service.downloadObject("my-bucket", "missing.txt"), + ).rejects.toMatchObject({ + statusCode: 404, + code: "OBJECT_NOT_FOUND", + message: "Object 'missing.txt' not found in bucket 'my-bucket'", + }); + }); + + it("re-throws unknown errors from downloadObject", async () => { + const error = new Error("Unexpected error"); + (client.send as ReturnType).mockRejectedValueOnce(error); + + await expect( + service.downloadObject("my-bucket", "file.txt"), + ).rejects.toThrow("Unexpected error"); + }); + + it("uses application/octet-stream when ContentType is undefined in response", async () => { + const mockBody = Buffer.from("binary content"); + (client.send as ReturnType).mockResolvedValueOnce({ + Body: mockBody, + // ContentType intentionally absent to trigger ?? fallback + ContentLength: 14, + }); + + const result = await service.downloadObject("my-bucket", "file.bin"); + expect(result.contentType).toBe("application/octet-stream"); + }); + }); + + describe("uploadObject", () => { + it("uploads an object and returns key and bucket", async () => { + (client.send as ReturnType).mockResolvedValueOnce({}); + + const body = Buffer.from("hello world"); + const result = await service.uploadObject( + "my-bucket", + "uploads/file.txt", + body, + "text/plain", + ); + + expect(result).toEqual({ key: "uploads/file.txt", bucket: "my-bucket" }); + expect(client.send).toHaveBeenCalledOnce(); + }); + + it("uses application/octet-stream as default content type", async () => { + (client.send as ReturnType).mockResolvedValueOnce({}); + + const body = Buffer.from("binary data"); + await service.uploadObject("my-bucket", "data.bin", body); + + const putCall = (client.send as ReturnType).mock + .calls[0][0]; + expect(putCall.input).toMatchObject({ + Bucket: "my-bucket", + Key: "data.bin", + ContentType: "application/octet-stream", + }); + }); + }); + + describe("deleteObject", () => { + it("deletes an object and returns success", async () => { + (client.send as ReturnType).mockResolvedValueOnce({}); + + const result = await service.deleteObject("my-bucket", "file.txt"); + + expect(result).toEqual({ success: true }); + expect(client.send).toHaveBeenCalledOnce(); + }); + + it("sends DeleteObjectCommand with correct bucket and key", async () => { + (client.send as ReturnType).mockResolvedValueOnce({}); + + await service.deleteObject("my-bucket", "folder/file.txt"); + + const deleteCall = (client.send as ReturnType).mock + .calls[0][0]; + expect(deleteCall.input).toMatchObject({ + Bucket: "my-bucket", + Key: "folder/file.txt", + }); + }); + }); + + describe("getPresignedUrl", () => { + it("returns a presigned URL for the given bucket and key", async () => { + (getSignedUrl as ReturnType).mockResolvedValueOnce( + "https://s3.amazonaws.com/my-bucket/file.txt?X-Amz-Signature=abc", + ); + + const result = await service.getPresignedUrl("my-bucket", "file.txt"); + + expect(result).toBe( + "https://s3.amazonaws.com/my-bucket/file.txt?X-Amz-Signature=abc", + ); + expect(getSignedUrl).toHaveBeenCalledOnce(); + expect(getSignedUrl).toHaveBeenCalledWith( + client, + expect.objectContaining({ + input: { Bucket: "my-bucket", Key: "file.txt" }, + }), + { expiresIn: 3600 }, + ); + }); + + it("uses the default expiresIn of 3600 seconds", async () => { + (getSignedUrl as ReturnType).mockResolvedValueOnce( + "https://presigned-url.example.com", + ); + + await service.getPresignedUrl("my-bucket", "file.txt"); + + expect(getSignedUrl).toHaveBeenCalledWith(client, expect.anything(), { + expiresIn: 3600, + }); + }); + + it("passes custom expiresIn to getSignedUrl", async () => { + (getSignedUrl as ReturnType).mockResolvedValueOnce( + "https://presigned-url.example.com", + ); + + await service.getPresignedUrl("my-bucket", "file.txt", 7200); + + expect(getSignedUrl).toHaveBeenCalledWith(client, expect.anything(), { + expiresIn: 7200, + }); + }); + }); +}); diff --git a/packages/backend/test/plugins/sns/routes.test.ts b/packages/backend/test/plugins/sns/routes.test.ts new file mode 100644 index 0000000..be10cf9 --- /dev/null +++ b/packages/backend/test/plugins/sns/routes.test.ts @@ -0,0 +1,375 @@ +import Fastify, { type FastifyInstance } from "fastify"; +import { + afterAll, + beforeAll, + describe, + expect, + it, + type Mock, + vi, +} from "vitest"; +import type { ClientCache } from "../../../src/aws/client-cache.js"; +import { snsRoutes } from "../../../src/plugins/sns/routes.js"; +import { registerErrorHandler } from "../../../src/shared/errors.js"; + +interface MockSNSService { + listTopics: Mock; + createTopic: Mock; + deleteTopic: Mock; + getTopicAttributes: Mock; + setTopicAttributes: Mock; + listAllSubscriptions: Mock; + listSubscriptionsByTopic: Mock; + createSubscription: Mock; + deleteSubscription: Mock; + getSubscriptionAttributes: Mock; + setSubscriptionFilterPolicy: Mock; + publishMessage: Mock; + publishBatch: Mock; + listTagsForResource: Mock; + tagResource: Mock; + untagResource: Mock; +} + +function createMockSNSService(): MockSNSService { + return { + listTopics: vi.fn().mockResolvedValue({ topics: [] }), + createTopic: vi + .fn() + .mockResolvedValue({ message: "Topic created successfully" }), + deleteTopic: vi.fn().mockResolvedValue({ success: true }), + getTopicAttributes: vi.fn().mockResolvedValue({ + topicArn: "arn:aws:sns:us-east-1:000000000000:test-topic", + }), + setTopicAttributes: vi.fn().mockResolvedValue({ success: true }), + listAllSubscriptions: vi.fn().mockResolvedValue({ subscriptions: [] }), + listSubscriptionsByTopic: vi.fn().mockResolvedValue({ subscriptions: [] }), + createSubscription: vi + .fn() + .mockResolvedValue({ message: "Subscription created successfully" }), + deleteSubscription: vi.fn().mockResolvedValue({ success: true }), + getSubscriptionAttributes: vi.fn().mockResolvedValue({ + subscriptionArn: "arn:aws:sns:us-east-1:000000000000:test-topic:abc-123", + topicArn: "arn:aws:sns:us-east-1:000000000000:test-topic", + protocol: "sqs", + endpoint: "arn:aws:sqs:us-east-1:000000000000:test-queue", + }), + setSubscriptionFilterPolicy: vi.fn().mockResolvedValue({ success: true }), + publishMessage: vi.fn().mockResolvedValue({ messageId: "msg-abc-123" }), + publishBatch: vi.fn().mockResolvedValue({ + successful: [{ id: "entry-1", messageId: "msg-batch-1" }], + failed: [], + }), + listTagsForResource: vi.fn().mockResolvedValue({ tags: [] }), + tagResource: vi.fn().mockResolvedValue({ success: true }), + untagResource: vi.fn().mockResolvedValue({ success: true }), + }; +} + +vi.mock("../../../src/plugins/sns/service.js", async (importOriginal) => { + const actual = + await importOriginal< + typeof import("../../../src/plugins/sns/service.js") + >(); + return { + ...actual, + SNSService: vi.fn(), + }; +}); + +import { SNSService as SNSServiceClass } from "../../../src/plugins/sns/service.js"; + +describe("SNS Routes - messageAttributes mapping", () => { + let app: FastifyInstance; + let mockService: MockSNSService; + + beforeAll(async () => { + app = Fastify(); + registerErrorHandler(app); + + mockService = createMockSNSService(); + + (SNSServiceClass as unknown as Mock).mockImplementation(() => mockService); + + const mockClientCache = { + getClients: vi.fn().mockReturnValue({ + sns: {}, + }), + }; + app.decorate("clientCache", mockClientCache as unknown as ClientCache); + + app.decorateRequest("localstackConfig", null); + app.addHook("onRequest", async (request) => { + request.localstackConfig = { + endpoint: "http://localhost:4566", + region: "us-east-1", + }; + }); + + await app.register(snsRoutes); + await app.ready(); + }); + + afterAll(async () => { + await app.close(); + }); + + describe("POST /:topicName/publish with messageAttributes", () => { + it("should map camelCase messageAttributes to PascalCase before calling service", async () => { + mockService.publishMessage.mockClear(); + + const response = await app.inject({ + method: "POST", + url: "/test-topic/publish", + payload: { + message: "Hello world", + subject: "Test subject", + messageAttributes: { + myAttr: { + dataType: "String", + stringValue: "attr-value", + }, + }, + }, + }); + + expect(response.statusCode).toBe(201); + const body = response.json<{ messageId: string }>(); + expect(body.messageId).toBe("msg-abc-123"); + + expect(mockService.publishMessage).toHaveBeenCalledWith( + "arn:aws:sns:us-east-1:000000000000:test-topic", + "Hello world", + expect.objectContaining({ + subject: "Test subject", + messageAttributes: { + myAttr: { + DataType: "String", + StringValue: "attr-value", + }, + }, + }), + ); + }); + + it("should publish without messageAttributes when not provided", async () => { + mockService.publishMessage.mockClear(); + + const response = await app.inject({ + method: "POST", + url: "/test-topic/publish", + payload: { + message: "Hello world", + }, + }); + + expect(response.statusCode).toBe(201); + expect(mockService.publishMessage).toHaveBeenCalledWith( + "arn:aws:sns:us-east-1:000000000000:test-topic", + "Hello world", + expect.objectContaining({ + messageAttributes: undefined, + }), + ); + }); + }); + + describe("GET /subscriptions/by-endpoint", () => { + it("should return empty subscriptions when listAllSubscriptions returns undefined", async () => { + mockService.listAllSubscriptions.mockResolvedValueOnce(undefined); + + const response = await app.inject({ + method: "GET", + url: "/subscriptions/by-endpoint?endpoint=arn:aws:sqs:us-east-1:000000000000:test-queue", + }); + + expect(response.statusCode).toBe(200); + const body = response.json<{ + subscriptions: unknown[]; + }>(); + expect(body.subscriptions).toEqual([]); + }); + }); + + describe("POST /:topicName/subscriptions with filterPolicy as object", () => { + it("should JSON.stringify filterPolicy when it is an object", async () => { + mockService.createSubscription.mockClear(); + + const filterPolicyObj = { event: ["order_placed", "order_cancelled"] }; + + const response = await app.inject({ + method: "POST", + url: "/test-topic/subscriptions", + payload: { + protocol: "sqs", + endpoint: "arn:aws:sqs:us-east-1:000000000000:test-queue", + filterPolicy: filterPolicyObj, + }, + }); + + expect(response.statusCode).toBe(201); + expect(mockService.createSubscription).toHaveBeenCalledWith( + "arn:aws:sns:us-east-1:000000000000:test-topic", + "sqs", + "arn:aws:sqs:us-east-1:000000000000:test-queue", + expect.objectContaining({ + filterPolicy: JSON.stringify(filterPolicyObj), + }), + ); + }); + + it("should pass filterPolicy as-is when it is a string", async () => { + mockService.createSubscription.mockClear(); + + const filterPolicyStr = '{"event":["order_placed"]}'; + + const response = await app.inject({ + method: "POST", + url: "/test-topic/subscriptions", + payload: { + protocol: "sqs", + endpoint: "arn:aws:sqs:us-east-1:000000000000:test-queue", + filterPolicy: filterPolicyStr, + }, + }); + + expect(response.statusCode).toBe(201); + expect(mockService.createSubscription).toHaveBeenCalledWith( + "arn:aws:sns:us-east-1:000000000000:test-topic", + "sqs", + "arn:aws:sqs:us-east-1:000000000000:test-queue", + expect.objectContaining({ + filterPolicy: filterPolicyStr, + }), + ); + }); + }); + + describe("PUT /subscriptions/:subscriptionArn/filter-policy with filterPolicy as object", () => { + it("should JSON.stringify filterPolicy when it is an object", async () => { + mockService.setSubscriptionFilterPolicy.mockClear(); + + const filterPolicyObj = { event: ["order_placed"] }; + const encodedArn = encodeURIComponent( + "arn:aws:sns:us-east-1:000000000000:test-topic:abc-123", + ); + + const response = await app.inject({ + method: "PUT", + url: `/subscriptions/${encodedArn}/filter-policy`, + payload: { + filterPolicy: filterPolicyObj, + }, + }); + + expect(response.statusCode).toBe(200); + expect(mockService.setSubscriptionFilterPolicy).toHaveBeenCalledWith( + "arn:aws:sns:us-east-1:000000000000:test-topic:abc-123", + JSON.stringify(filterPolicyObj), + ); + }); + + it("should pass filterPolicy as-is when it is a string", async () => { + mockService.setSubscriptionFilterPolicy.mockClear(); + + const filterPolicyStr = '{"event":["order_placed"]}'; + const encodedArn = encodeURIComponent( + "arn:aws:sns:us-east-1:000000000000:test-topic:abc-123", + ); + + const response = await app.inject({ + method: "PUT", + url: `/subscriptions/${encodedArn}/filter-policy`, + payload: { + filterPolicy: filterPolicyStr, + }, + }); + + expect(response.statusCode).toBe(200); + expect(mockService.setSubscriptionFilterPolicy).toHaveBeenCalledWith( + "arn:aws:sns:us-east-1:000000000000:test-topic:abc-123", + filterPolicyStr, + ); + }); + }); + + describe("POST /:topicName/publish-batch with messageAttributes on entries", () => { + it("should map camelCase messageAttributes to PascalCase for each entry", async () => { + mockService.publishBatch.mockClear(); + + const response = await app.inject({ + method: "POST", + url: "/test-topic/publish-batch", + payload: { + entries: [ + { + id: "entry-1", + message: "Batch message 1", + subject: "Subject 1", + messageAttributes: { + batchAttr: { + dataType: "String", + stringValue: "batch-value", + }, + }, + }, + ], + }, + }); + + expect(response.statusCode).toBe(201); + const body = response.json<{ + successful: Array<{ id: string; messageId: string }>; + failed: unknown[]; + }>(); + expect(body.successful).toHaveLength(1); + expect(body.successful[0].id).toBe("entry-1"); + + expect(mockService.publishBatch).toHaveBeenCalledWith( + "arn:aws:sns:us-east-1:000000000000:test-topic", + [ + expect.objectContaining({ + id: "entry-1", + message: "Batch message 1", + subject: "Subject 1", + messageAttributes: { + batchAttr: { + DataType: "String", + StringValue: "batch-value", + }, + }, + }), + ], + ); + }); + + it("should handle batch entries without messageAttributes", async () => { + mockService.publishBatch.mockClear(); + + const response = await app.inject({ + method: "POST", + url: "/test-topic/publish-batch", + payload: { + entries: [ + { + id: "entry-2", + message: "Batch message 2", + }, + ], + }, + }); + + expect(response.statusCode).toBe(201); + expect(mockService.publishBatch).toHaveBeenCalledWith( + "arn:aws:sns:us-east-1:000000000000:test-topic", + [ + expect.objectContaining({ + id: "entry-2", + message: "Batch message 2", + messageAttributes: undefined, + }), + ], + ); + }); + }); +}); diff --git a/packages/backend/test/plugins/sns/service.test.ts b/packages/backend/test/plugins/sns/service.test.ts new file mode 100644 index 0000000..b32a2f8 --- /dev/null +++ b/packages/backend/test/plugins/sns/service.test.ts @@ -0,0 +1,1437 @@ +import type { SNSClient } from "@aws-sdk/client-sns"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { SNSService } from "../../../src/plugins/sns/service.js"; +import { AppError } from "../../../src/shared/errors.js"; + +function createMockSNSClient() { + return { + send: vi.fn(), + } as unknown as SNSClient; +} + +describe("SNSService", () => { + let client: SNSClient; + let service: SNSService; + + beforeEach(() => { + client = createMockSNSClient(); + service = new SNSService(client); + }); + + // ── mapSnsError ─────────────────────────────────────────────────────────── + + describe("mapSnsError (via createTopic)", () => { + it("throws AppError 404 NOT_FOUND for NotFoundException", async () => { + const error = new Error("not found") as Error & { name: string }; + error.name = "NotFoundException"; + (client.send as ReturnType).mockRejectedValueOnce(error); + + await expect(service.createTopic("t")).rejects.toMatchObject({ + statusCode: 404, + code: "NOT_FOUND", + }); + }); + + it("throws AppError 404 NOT_FOUND for NotFound", async () => { + const error = new Error("not found") as Error & { name: string }; + error.name = "NotFound"; + (client.send as ReturnType).mockRejectedValueOnce(error); + + await expect(service.createTopic("t")).rejects.toMatchObject({ + statusCode: 404, + code: "NOT_FOUND", + }); + }); + + it("throws AppError 400 INVALID_PARAMETER for InvalidParameterException", async () => { + const error = new Error("bad param") as Error & { name: string }; + error.name = "InvalidParameterException"; + (client.send as ReturnType).mockRejectedValueOnce(error); + + await expect(service.createTopic("t")).rejects.toMatchObject({ + statusCode: 400, + code: "INVALID_PARAMETER", + message: "bad param", + }); + }); + + it("throws AppError 400 INVALID_PARAMETER for InvalidParameterValueException", async () => { + const error = new Error("bad value") as Error & { name: string }; + error.name = "InvalidParameterValueException"; + (client.send as ReturnType).mockRejectedValueOnce(error); + + await expect(service.createTopic("t")).rejects.toMatchObject({ + statusCode: 400, + code: "INVALID_PARAMETER", + }); + }); + + it("throws AppError 400 INVALID_PARAMETER for InvalidParameter", async () => { + const error = new Error("invalid") as Error & { name: string }; + error.name = "InvalidParameter"; + (client.send as ReturnType).mockRejectedValueOnce(error); + + await expect(service.createTopic("t")).rejects.toMatchObject({ + statusCode: 400, + code: "INVALID_PARAMETER", + }); + }); + + it("throws AppError 400 INVALID_PARAMETER for ValidationException", async () => { + const error = new Error("validation failed") as Error & { name: string }; + error.name = "ValidationException"; + (client.send as ReturnType).mockRejectedValueOnce(error); + + await expect(service.createTopic("t")).rejects.toMatchObject({ + statusCode: 400, + code: "INVALID_PARAMETER", + }); + }); + + it("throws AppError 403 AUTHORIZATION_ERROR for AuthorizationErrorException", async () => { + const error = new Error("not authorized") as Error & { name: string }; + error.name = "AuthorizationErrorException"; + (client.send as ReturnType).mockRejectedValueOnce(error); + + await expect(service.createTopic("t")).rejects.toMatchObject({ + statusCode: 403, + code: "AUTHORIZATION_ERROR", + message: "not authorized", + }); + }); + + it("throws AppError 403 AUTHORIZATION_ERROR for AuthorizationError", async () => { + const error = new Error("forbidden") as Error & { name: string }; + error.name = "AuthorizationError"; + (client.send as ReturnType).mockRejectedValueOnce(error); + + await expect(service.createTopic("t")).rejects.toMatchObject({ + statusCode: 403, + code: "AUTHORIZATION_ERROR", + }); + }); + + it("uses fallbackMessage for INVALID_PARAMETER when error.message is empty", async () => { + const error = new Error("") as Error & { name: string }; + error.name = "InvalidParameterException"; + (client.send as ReturnType).mockRejectedValueOnce(error); + + await expect(service.createTopic("t")).rejects.toMatchObject({ + statusCode: 400, + code: "INVALID_PARAMETER", + message: "Failed to create topic 't'", + }); + }); + + it("uses fallbackMessage for AUTHORIZATION_ERROR when error.message is empty", async () => { + const error = new Error("") as Error & { name: string }; + error.name = "AuthorizationErrorException"; + (client.send as ReturnType).mockRejectedValueOnce(error); + + await expect(service.createTopic("t")).rejects.toMatchObject({ + statusCode: 403, + code: "AUTHORIZATION_ERROR", + message: "Failed to create topic 't'", + }); + }); + + it("re-throws unknown errors as-is (default case)", async () => { + const error = new Error("some unexpected error"); + (client.send as ReturnType).mockRejectedValueOnce(error); + + await expect(service.createTopic("t")).rejects.toThrow( + "some unexpected error", + ); + await expect( + (async () => { + const e = new Error("some unexpected error"); + (client.send as ReturnType).mockRejectedValueOnce(e); + await service.createTopic("t"); + })(), + ).rejects.not.toBeInstanceOf(AppError); + }); + }); + + // ── listTopics ──────────────────────────────────────────────────────────── + + describe("listTopics", () => { + it("returns formatted topic list with names extracted from ARNs", async () => { + (client.send as ReturnType).mockResolvedValueOnce({ + Topics: [ + { TopicArn: "arn:aws:sns:us-east-1:000000000000:my-topic" }, + { TopicArn: "arn:aws:sns:us-east-1:000000000000:another-topic" }, + ], + }); + + const result = await service.listTopics(); + + expect(result).toEqual({ + topics: [ + { + topicArn: "arn:aws:sns:us-east-1:000000000000:my-topic", + name: "my-topic", + }, + { + topicArn: "arn:aws:sns:us-east-1:000000000000:another-topic", + name: "another-topic", + }, + ], + }); + }); + + it("returns empty topic list when no topics exist", async () => { + (client.send as ReturnType).mockResolvedValueOnce({ + Topics: [], + }); + + const result = await service.listTopics(); + + expect(result).toEqual({ topics: [] }); + }); + + it("returns empty topic list when Topics is undefined", async () => { + (client.send as ReturnType).mockResolvedValueOnce({}); + + const result = await service.listTopics(); + + expect(result).toEqual({ topics: [] }); + }); + + it("uses empty string for topicArn and name when TopicArn is undefined", async () => { + (client.send as ReturnType).mockResolvedValueOnce({ + Topics: [{}], + }); + + const result = await service.listTopics(); + + expect(result.topics[0]).toEqual({ topicArn: "", name: "" }); + }); + }); + + // ── createTopic ─────────────────────────────────────────────────────────── + + describe("createTopic", () => { + it("creates a topic and returns success message with ARN", async () => { + (client.send as ReturnType).mockResolvedValueOnce({ + TopicArn: "arn:aws:sns:us-east-1:000000000000:new-topic", + }); + + const result = await service.createTopic("new-topic"); + + expect(result).toEqual({ + message: "Topic 'new-topic' created successfully", + topicArn: "arn:aws:sns:us-east-1:000000000000:new-topic", + }); + expect(client.send).toHaveBeenCalledOnce(); + }); + + it("throws AppError 404 when topic is not found", async () => { + const error = new Error("not found") as Error & { name: string }; + error.name = "NotFoundException"; + (client.send as ReturnType).mockRejectedValueOnce(error); + + await expect(service.createTopic("my-topic")).rejects.toMatchObject({ + statusCode: 404, + code: "NOT_FOUND", + }); + }); + + it("throws AppError 400 on invalid parameter", async () => { + const error = new Error("bad name") as Error & { name: string }; + error.name = "InvalidParameterException"; + (client.send as ReturnType).mockRejectedValueOnce(error); + + await expect(service.createTopic("bad name")).rejects.toMatchObject({ + statusCode: 400, + code: "INVALID_PARAMETER", + }); + }); + }); + + // ── deleteTopic ─────────────────────────────────────────────────────────── + + describe("deleteTopic", () => { + it("deletes a topic successfully", async () => { + (client.send as ReturnType).mockResolvedValueOnce({}); + + const result = await service.deleteTopic( + "arn:aws:sns:us-east-1:000000000000:my-topic", + ); + + expect(result).toEqual({ success: true }); + expect(client.send).toHaveBeenCalledOnce(); + }); + + it("throws AppError 404 when topic does not exist", async () => { + const topicArn = "arn:aws:sns:us-east-1:000000000000:missing-topic"; + const error = new Error("not found") as Error & { name: string }; + error.name = "NotFoundException"; + (client.send as ReturnType).mockRejectedValueOnce(error); + + await expect(service.deleteTopic(topicArn)).rejects.toMatchObject({ + statusCode: 404, + code: "NOT_FOUND", + message: `Topic '${topicArn}' not found`, + }); + }); + + it("throws AppError 403 on authorization error", async () => { + const error = new Error("forbidden") as Error & { name: string }; + error.name = "AuthorizationErrorException"; + (client.send as ReturnType).mockRejectedValueOnce(error); + + await expect( + service.deleteTopic("arn:aws:sns:us-east-1:000000000000:my-topic"), + ).rejects.toMatchObject({ + statusCode: 403, + code: "AUTHORIZATION_ERROR", + }); + }); + + it("re-throws unknown errors from deleteTopic", async () => { + const error = new Error("Unexpected error"); + (client.send as ReturnType).mockRejectedValueOnce(error); + + await expect( + service.deleteTopic("arn:aws:sns:us-east-1:000000000000:my-topic"), + ).rejects.toThrow("Unexpected error"); + }); + }); + + // ── getTopicAttributes ──────────────────────────────────────────────────── + + describe("getTopicAttributes", () => { + const topicArn = "arn:aws:sns:us-east-1:000000000000:my-topic"; + + it("returns all topic attributes mapped from AWS response", async () => { + (client.send as ReturnType).mockResolvedValueOnce({ + Attributes: { + TopicArn: topicArn, + DisplayName: "My Topic", + Owner: "000000000000", + Policy: '{"Version":"2012-10-17"}', + SubscriptionsConfirmed: "5", + SubscriptionsPending: "2", + SubscriptionsDeleted: "1", + DeliveryPolicy: '{"http":{"defaultHealthyRetryPolicy":{}}}', + EffectiveDeliveryPolicy: '{"http":{"defaultHealthyRetryPolicy":{}}}', + KmsMasterKeyId: "alias/my-key", + FifoTopic: "true", + ContentBasedDeduplication: "true", + }, + }); + + const result = await service.getTopicAttributes(topicArn); + + expect(result).toEqual({ + topicArn, + displayName: "My Topic", + owner: "000000000000", + policy: '{"Version":"2012-10-17"}', + subscriptionsConfirmed: 5, + subscriptionsPending: 2, + subscriptionsDeleted: 1, + deliveryPolicy: '{"http":{"defaultHealthyRetryPolicy":{}}}', + effectiveDeliveryPolicy: '{"http":{"defaultHealthyRetryPolicy":{}}}', + kmsMasterKeyId: "alias/my-key", + fifoTopic: true, + contentBasedDeduplication: true, + }); + }); + + it("returns default values when attributes are missing", async () => { + (client.send as ReturnType).mockResolvedValueOnce({ + Attributes: {}, + }); + + const result = await service.getTopicAttributes(topicArn); + + expect(result).toEqual({ + topicArn, + displayName: "", + owner: "", + policy: "", + subscriptionsConfirmed: 0, + subscriptionsPending: 0, + subscriptionsDeleted: 0, + deliveryPolicy: "", + effectiveDeliveryPolicy: "", + kmsMasterKeyId: "", + fifoTopic: false, + contentBasedDeduplication: false, + }); + }); + + it("returns default values when Attributes is undefined", async () => { + (client.send as ReturnType).mockResolvedValueOnce({}); + + const result = await service.getTopicAttributes(topicArn); + + expect(result).toMatchObject({ + topicArn, + fifoTopic: false, + contentBasedDeduplication: false, + }); + }); + + it("throws AppError 404 when topic is not found", async () => { + const error = new Error("not found") as Error & { name: string }; + error.name = "NotFoundException"; + (client.send as ReturnType).mockRejectedValueOnce(error); + + await expect(service.getTopicAttributes(topicArn)).rejects.toMatchObject({ + statusCode: 404, + code: "NOT_FOUND", + message: `Topic '${topicArn}' not found`, + }); + }); + }); + + // ── setTopicAttributes ──────────────────────────────────────────────────── + + describe("setTopicAttributes", () => { + const topicArn = "arn:aws:sns:us-east-1:000000000000:my-topic"; + + it("sets a topic attribute successfully", async () => { + (client.send as ReturnType).mockResolvedValueOnce({}); + + const result = await service.setTopicAttributes( + topicArn, + "DisplayName", + "New Name", + ); + + expect(result).toEqual({ success: true }); + expect(client.send).toHaveBeenCalledOnce(); + }); + + it("throws AppError 404 when topic is not found", async () => { + const error = new Error("not found") as Error & { name: string }; + error.name = "NotFoundException"; + (client.send as ReturnType).mockRejectedValueOnce(error); + + await expect( + service.setTopicAttributes(topicArn, "DisplayName", "x"), + ).rejects.toMatchObject({ + statusCode: 404, + code: "NOT_FOUND", + }); + }); + + it("throws AppError 400 on invalid parameter", async () => { + const error = new Error("invalid attribute") as Error & { name: string }; + error.name = "InvalidParameterException"; + (client.send as ReturnType).mockRejectedValueOnce(error); + + await expect( + service.setTopicAttributes(topicArn, "BadAttr", "value"), + ).rejects.toMatchObject({ + statusCode: 400, + code: "INVALID_PARAMETER", + }); + }); + + it("re-throws unknown errors from setTopicAttributes", async () => { + const error = new Error("Unexpected error"); + (client.send as ReturnType).mockRejectedValueOnce(error); + + await expect( + service.setTopicAttributes(topicArn, "DisplayName", "x"), + ).rejects.toThrow("Unexpected error"); + }); + }); + + // ── listAllSubscriptions ────────────────────────────────────────────────── + + describe("listAllSubscriptions", () => { + it("returns formatted subscription list", async () => { + (client.send as ReturnType).mockResolvedValueOnce({ + Subscriptions: [ + { + SubscriptionArn: + "arn:aws:sns:us-east-1:000000000000:my-topic:sub-001", + Owner: "000000000000", + Protocol: "https", + Endpoint: "https://example.com/hook", + TopicArn: "arn:aws:sns:us-east-1:000000000000:my-topic", + }, + ], + }); + + const result = await service.listAllSubscriptions(); + + expect(result).toEqual({ + subscriptions: [ + { + subscriptionArn: + "arn:aws:sns:us-east-1:000000000000:my-topic:sub-001", + owner: "000000000000", + protocol: "https", + endpoint: "https://example.com/hook", + topicArn: "arn:aws:sns:us-east-1:000000000000:my-topic", + }, + ], + }); + }); + + it("returns empty subscriptions list when Subscriptions is undefined", async () => { + (client.send as ReturnType).mockResolvedValueOnce({}); + + const result = await service.listAllSubscriptions(); + + expect(result).toEqual({ subscriptions: [] }); + }); + + it("uses empty strings for subscription fields when all are undefined", async () => { + (client.send as ReturnType).mockResolvedValueOnce({ + Subscriptions: [ + { + /* all fields intentionally absent */ + }, + ], + }); + + const result = await service.listAllSubscriptions(); + expect(result?.subscriptions[0]).toEqual({ + subscriptionArn: "", + owner: "", + protocol: "", + endpoint: "", + topicArn: "", + }); + }); + + it("throws AppError 403 on authorization error", async () => { + const error = new Error("forbidden") as Error & { name: string }; + error.name = "AuthorizationErrorException"; + (client.send as ReturnType).mockRejectedValueOnce(error); + + await expect(service.listAllSubscriptions()).rejects.toMatchObject({ + statusCode: 403, + code: "AUTHORIZATION_ERROR", + }); + }); + }); + + // ── listSubscriptionsByTopic ────────────────────────────────────────────── + + describe("listSubscriptionsByTopic", () => { + const topicArn = "arn:aws:sns:us-east-1:000000000000:my-topic"; + + it("returns subscriptions for the given topic", async () => { + (client.send as ReturnType).mockResolvedValueOnce({ + Subscriptions: [ + { + SubscriptionArn: + "arn:aws:sns:us-east-1:000000000000:my-topic:sub-001", + Owner: "000000000000", + Protocol: "sqs", + Endpoint: "arn:aws:sqs:us-east-1:000000000000:my-queue", + TopicArn: topicArn, + }, + ], + }); + + const result = await service.listSubscriptionsByTopic(topicArn); + + expect(result).toEqual({ + subscriptions: [ + { + subscriptionArn: + "arn:aws:sns:us-east-1:000000000000:my-topic:sub-001", + owner: "000000000000", + protocol: "sqs", + endpoint: "arn:aws:sqs:us-east-1:000000000000:my-queue", + topicArn, + }, + ], + }); + }); + + it("returns empty subscriptions when Subscriptions is undefined", async () => { + (client.send as ReturnType).mockResolvedValueOnce({}); + + const result = await service.listSubscriptionsByTopic(topicArn); + + expect(result).toEqual({ subscriptions: [] }); + }); + + it("uses empty strings for subscription fields when all are undefined", async () => { + (client.send as ReturnType).mockResolvedValueOnce({ + Subscriptions: [ + { + // All fields intentionally absent to trigger ?? "" fallbacks + }, + ], + }); + + const result = await service.listSubscriptionsByTopic(topicArn); + expect(result?.subscriptions[0]).toEqual({ + subscriptionArn: "", + owner: "", + protocol: "", + endpoint: "", + topicArn: "", + }); + }); + + it("throws AppError 404 when topic is not found", async () => { + const error = new Error("not found") as Error & { name: string }; + error.name = "NotFoundException"; + (client.send as ReturnType).mockRejectedValueOnce(error); + + await expect( + service.listSubscriptionsByTopic(topicArn), + ).rejects.toMatchObject({ + statusCode: 404, + code: "NOT_FOUND", + message: `Topic '${topicArn}' not found`, + }); + }); + + it("re-throws unknown errors from listSubscriptionsByTopic", async () => { + const error = new Error("Unexpected error"); + (client.send as ReturnType).mockRejectedValueOnce(error); + + await expect(service.listSubscriptionsByTopic(topicArn)).rejects.toThrow( + "Unexpected error", + ); + }); + }); + + // ── createSubscription ──────────────────────────────────────────────────── + + describe("createSubscription", () => { + const topicArn = "arn:aws:sns:us-east-1:000000000000:my-topic"; + const subscriptionArn = + "arn:aws:sns:us-east-1:000000000000:my-topic:sub-001"; + + it("creates a subscription with no options", async () => { + (client.send as ReturnType).mockResolvedValueOnce({ + SubscriptionArn: subscriptionArn, + }); + + const result = await service.createSubscription( + topicArn, + "https", + "https://example.com/hook", + ); + + expect(result).toEqual({ + message: "Subscription created successfully", + subscriptionArn, + }); + expect(client.send).toHaveBeenCalledOnce(); + }); + + it("sets RawMessageDelivery attribute when rawMessageDelivery option is true", async () => { + (client.send as ReturnType) + .mockResolvedValueOnce({ SubscriptionArn: subscriptionArn }) + .mockResolvedValueOnce({}); + + const result = await service.createSubscription( + topicArn, + "https", + "https://example.com/hook", + { rawMessageDelivery: true }, + ); + + expect(result).toEqual({ + message: "Subscription created successfully", + subscriptionArn, + }); + expect(client.send).toHaveBeenCalledTimes(2); + + const rawDeliveryCall = (client.send as ReturnType).mock + .calls[1][0]; + expect(rawDeliveryCall.input).toMatchObject({ + SubscriptionArn: subscriptionArn, + AttributeName: "RawMessageDelivery", + AttributeValue: "true", + }); + }); + + it("sets FilterPolicy attribute when filterPolicy option is provided", async () => { + const filterPolicy = '{"eventType":["order_placed"]}'; + (client.send as ReturnType) + .mockResolvedValueOnce({ SubscriptionArn: subscriptionArn }) + .mockResolvedValueOnce({}); + + const result = await service.createSubscription( + topicArn, + "sqs", + "arn:aws:sqs:us-east-1:000000000000:my-queue", + { filterPolicy }, + ); + + expect(result).toEqual({ + message: "Subscription created successfully", + subscriptionArn, + }); + expect(client.send).toHaveBeenCalledTimes(2); + + const filterCall = (client.send as ReturnType).mock + .calls[1][0]; + expect(filterCall.input).toMatchObject({ + SubscriptionArn: subscriptionArn, + AttributeName: "FilterPolicy", + AttributeValue: filterPolicy, + }); + }); + + it("sets both RawMessageDelivery and FilterPolicy when both options are provided", async () => { + const filterPolicy = '{"type":["alert"]}'; + (client.send as ReturnType) + .mockResolvedValueOnce({ SubscriptionArn: subscriptionArn }) + .mockResolvedValueOnce({}) + .mockResolvedValueOnce({}); + + await service.createSubscription( + topicArn, + "https", + "https://example.com/hook", + { rawMessageDelivery: true, filterPolicy }, + ); + + expect(client.send).toHaveBeenCalledTimes(3); + + const rawCall = (client.send as ReturnType).mock + .calls[1][0]; + expect(rawCall.input).toMatchObject({ + AttributeName: "RawMessageDelivery", + }); + + const filterCall = (client.send as ReturnType).mock + .calls[2][0]; + expect(filterCall.input).toMatchObject({ + AttributeName: "FilterPolicy", + AttributeValue: filterPolicy, + }); + }); + + it("skips attribute calls when SubscriptionArn is undefined", async () => { + (client.send as ReturnType).mockResolvedValueOnce({ + SubscriptionArn: undefined, + }); + + const result = await service.createSubscription( + topicArn, + "https", + "https://example.com/hook", + { rawMessageDelivery: true, filterPolicy: '{"x":["y"]}' }, + ); + + expect(result).toEqual({ + message: "Subscription created successfully", + subscriptionArn: undefined, + }); + expect(client.send).toHaveBeenCalledOnce(); + }); + + it("throws AppError 404 when topic is not found", async () => { + const error = new Error("not found") as Error & { name: string }; + error.name = "NotFoundException"; + (client.send as ReturnType).mockRejectedValueOnce(error); + + await expect( + service.createSubscription(topicArn, "https", "https://example.com"), + ).rejects.toMatchObject({ + statusCode: 404, + code: "NOT_FOUND", + }); + }); + + it("throws AppError 400 on invalid parameter", async () => { + const error = new Error("invalid protocol") as Error & { name: string }; + error.name = "InvalidParameterException"; + (client.send as ReturnType).mockRejectedValueOnce(error); + + await expect( + service.createSubscription(topicArn, "bad", "endpoint"), + ).rejects.toMatchObject({ + statusCode: 400, + code: "INVALID_PARAMETER", + }); + }); + + it("re-throws unknown errors from createSubscription", async () => { + const error = new Error("Unexpected error"); + (client.send as ReturnType).mockRejectedValueOnce(error); + + await expect( + service.createSubscription(topicArn, "https", "https://example.com"), + ).rejects.toThrow("Unexpected error"); + }); + }); + + // ── deleteSubscription ──────────────────────────────────────────────────── + + describe("deleteSubscription", () => { + const subscriptionArn = + "arn:aws:sns:us-east-1:000000000000:my-topic:sub-001"; + + it("deletes a subscription successfully", async () => { + (client.send as ReturnType).mockResolvedValueOnce({}); + + const result = await service.deleteSubscription(subscriptionArn); + + expect(result).toEqual({ success: true }); + expect(client.send).toHaveBeenCalledOnce(); + }); + + it("throws AppError 404 when subscription does not exist", async () => { + const error = new Error("not found") as Error & { name: string }; + error.name = "NotFoundException"; + (client.send as ReturnType).mockRejectedValueOnce(error); + + await expect( + service.deleteSubscription(subscriptionArn), + ).rejects.toMatchObject({ + statusCode: 404, + code: "NOT_FOUND", + message: `Subscription '${subscriptionArn}' not found`, + }); + }); + + it("throws AppError 403 on authorization error", async () => { + const error = new Error("forbidden") as Error & { name: string }; + error.name = "AuthorizationErrorException"; + (client.send as ReturnType).mockRejectedValueOnce(error); + + await expect( + service.deleteSubscription(subscriptionArn), + ).rejects.toMatchObject({ + statusCode: 403, + code: "AUTHORIZATION_ERROR", + }); + }); + + it("re-throws unknown errors from deleteSubscription", async () => { + const error = new Error("Unexpected error"); + (client.send as ReturnType).mockRejectedValueOnce(error); + + await expect(service.deleteSubscription(subscriptionArn)).rejects.toThrow( + "Unexpected error", + ); + }); + }); + + // ── getSubscriptionAttributes ───────────────────────────────────────────── + + describe("getSubscriptionAttributes", () => { + const subscriptionArn = + "arn:aws:sns:us-east-1:000000000000:my-topic:sub-001"; + + it("returns all subscription attributes mapped from AWS response", async () => { + (client.send as ReturnType).mockResolvedValueOnce({ + Attributes: { + SubscriptionArn: subscriptionArn, + TopicArn: "arn:aws:sns:us-east-1:000000000000:my-topic", + Owner: "000000000000", + Protocol: "https", + Endpoint: "https://example.com/hook", + ConfirmationWasAuthenticated: "true", + PendingConfirmation: "false", + RawMessageDelivery: "true", + FilterPolicy: '{"eventType":["order_placed"]}', + FilterPolicyScope: "MessageAttributes", + DeliveryPolicy: '{"healthyRetryPolicy":{}}', + EffectiveDeliveryPolicy: '{"healthyRetryPolicy":{}}', + }, + }); + + const result = await service.getSubscriptionAttributes(subscriptionArn); + + expect(result).toEqual({ + subscriptionArn, + topicArn: "arn:aws:sns:us-east-1:000000000000:my-topic", + owner: "000000000000", + protocol: "https", + endpoint: "https://example.com/hook", + confirmationWasAuthenticated: true, + pendingConfirmation: false, + rawMessageDelivery: true, + filterPolicy: '{"eventType":["order_placed"]}', + filterPolicyScope: "MessageAttributes", + deliveryPolicy: '{"healthyRetryPolicy":{}}', + effectiveDeliveryPolicy: '{"healthyRetryPolicy":{}}', + }); + }); + + it("returns default values when attributes are missing", async () => { + (client.send as ReturnType).mockResolvedValueOnce({ + Attributes: {}, + }); + + const result = await service.getSubscriptionAttributes(subscriptionArn); + + expect(result).toEqual({ + subscriptionArn, + topicArn: "", + owner: "", + protocol: "", + endpoint: "", + confirmationWasAuthenticated: false, + pendingConfirmation: false, + rawMessageDelivery: false, + filterPolicy: "", + filterPolicyScope: "", + deliveryPolicy: "", + effectiveDeliveryPolicy: "", + }); + }); + + it("returns default values when Attributes is undefined", async () => { + (client.send as ReturnType).mockResolvedValueOnce({}); + + const result = await service.getSubscriptionAttributes(subscriptionArn); + + expect(result).toMatchObject({ + subscriptionArn, + rawMessageDelivery: false, + pendingConfirmation: false, + }); + }); + + it("throws AppError 404 when subscription is not found", async () => { + const error = new Error("not found") as Error & { name: string }; + error.name = "NotFoundException"; + (client.send as ReturnType).mockRejectedValueOnce(error); + + await expect( + service.getSubscriptionAttributes(subscriptionArn), + ).rejects.toMatchObject({ + statusCode: 404, + code: "NOT_FOUND", + message: `Subscription '${subscriptionArn}' not found`, + }); + }); + }); + + // ── setSubscriptionFilterPolicy ─────────────────────────────────────────── + + describe("setSubscriptionFilterPolicy", () => { + const subscriptionArn = + "arn:aws:sns:us-east-1:000000000000:my-topic:sub-001"; + + it("sets the filter policy successfully", async () => { + (client.send as ReturnType).mockResolvedValueOnce({}); + + const filterPolicy = '{"eventType":["order_placed"]}'; + const result = await service.setSubscriptionFilterPolicy( + subscriptionArn, + filterPolicy, + ); + + expect(result).toEqual({ success: true }); + expect(client.send).toHaveBeenCalledOnce(); + + const call = (client.send as ReturnType).mock.calls[0][0]; + expect(call.input).toMatchObject({ + SubscriptionArn: subscriptionArn, + AttributeName: "FilterPolicy", + AttributeValue: filterPolicy, + }); + }); + + it("throws AppError 404 when subscription is not found", async () => { + const error = new Error("not found") as Error & { name: string }; + error.name = "NotFoundException"; + (client.send as ReturnType).mockRejectedValueOnce(error); + + await expect( + service.setSubscriptionFilterPolicy(subscriptionArn, "{}"), + ).rejects.toMatchObject({ + statusCode: 404, + code: "NOT_FOUND", + }); + }); + + it("throws AppError 400 on invalid filter policy", async () => { + const error = new Error("invalid filter") as Error & { name: string }; + error.name = "InvalidParameterException"; + (client.send as ReturnType).mockRejectedValueOnce(error); + + await expect( + service.setSubscriptionFilterPolicy(subscriptionArn, "bad-json"), + ).rejects.toMatchObject({ + statusCode: 400, + code: "INVALID_PARAMETER", + }); + }); + + it("throws AppError 403 on authorization error", async () => { + const error = new Error("forbidden") as Error & { name: string }; + error.name = "AuthorizationErrorException"; + (client.send as ReturnType).mockRejectedValueOnce(error); + + await expect( + service.setSubscriptionFilterPolicy(subscriptionArn, "{}"), + ).rejects.toMatchObject({ + statusCode: 403, + code: "AUTHORIZATION_ERROR", + }); + }); + + it("re-throws unknown errors from setSubscriptionFilterPolicy", async () => { + const error = new Error("Unexpected error"); + (client.send as ReturnType).mockRejectedValueOnce(error); + + await expect( + service.setSubscriptionFilterPolicy(subscriptionArn, "{}"), + ).rejects.toThrow("Unexpected error"); + }); + }); + + // ── publishMessage ──────────────────────────────────────────────────────── + + describe("publishMessage", () => { + const topicArn = "arn:aws:sns:us-east-1:000000000000:my-topic"; + + it("publishes a message with body only and returns messageId", async () => { + (client.send as ReturnType).mockResolvedValueOnce({ + MessageId: "msg-id-001", + }); + + const result = await service.publishMessage(topicArn, "Hello, SNS!"); + + expect(result).toEqual({ messageId: "msg-id-001" }); + expect(client.send).toHaveBeenCalledOnce(); + + const call = (client.send as ReturnType).mock.calls[0][0]; + expect(call.input).toMatchObject({ + TopicArn: topicArn, + Message: "Hello, SNS!", + }); + expect(call.input.Subject).toBeUndefined(); + expect(call.input.MessageAttributes).toBeUndefined(); + expect(call.input.TargetArn).toBeUndefined(); + }); + + it("publishes a message with subject, messageAttributes, and targetArn", async () => { + (client.send as ReturnType).mockResolvedValueOnce({ + MessageId: "msg-id-002", + }); + + const result = await service.publishMessage(topicArn, "Alert!", { + subject: "Important", + messageAttributes: { + severity: { DataType: "String", StringValue: "high" }, + count: { DataType: "Number", StringValue: "3" }, + }, + targetArn: "arn:aws:sns:us-east-1:000000000000:my-topic:endpoint-001", + }); + + expect(result).toEqual({ messageId: "msg-id-002" }); + + const call = (client.send as ReturnType).mock.calls[0][0]; + expect(call.input).toMatchObject({ + TopicArn: topicArn, + Message: "Alert!", + Subject: "Important", + MessageAttributes: { + severity: { DataType: "String", StringValue: "high" }, + count: { DataType: "Number", StringValue: "3" }, + }, + TargetArn: "arn:aws:sns:us-east-1:000000000000:my-topic:endpoint-001", + }); + }); + + it("does not include Subject when not provided", async () => { + (client.send as ReturnType).mockResolvedValueOnce({ + MessageId: "msg-id-003", + }); + + await service.publishMessage(topicArn, "No subject", { + messageAttributes: { + key: { DataType: "String", StringValue: "val" }, + }, + }); + + const call = (client.send as ReturnType).mock.calls[0][0]; + expect(call.input.Subject).toBeUndefined(); + expect(call.input.MessageAttributes).toBeDefined(); + }); + + it("throws AppError 404 when topic is not found", async () => { + const error = new Error("not found") as Error & { name: string }; + error.name = "NotFoundException"; + (client.send as ReturnType).mockRejectedValueOnce(error); + + await expect( + service.publishMessage(topicArn, "Hello"), + ).rejects.toMatchObject({ + statusCode: 404, + code: "NOT_FOUND", + }); + }); + + it("throws AppError 400 on invalid parameter", async () => { + const error = new Error("invalid") as Error & { name: string }; + error.name = "InvalidParameterException"; + (client.send as ReturnType).mockRejectedValueOnce(error); + + await expect( + service.publishMessage(topicArn, "Hello"), + ).rejects.toMatchObject({ + statusCode: 400, + code: "INVALID_PARAMETER", + }); + }); + + it("re-throws unknown errors from publishMessage", async () => { + const error = new Error("Unexpected error"); + (client.send as ReturnType).mockRejectedValueOnce(error); + + await expect(service.publishMessage(topicArn, "Hello")).rejects.toThrow( + "Unexpected error", + ); + }); + }); + + // ── publishBatch ────────────────────────────────────────────────────────── + + describe("publishBatch", () => { + const topicArn = "arn:aws:sns:us-east-1:000000000000:my-topic"; + + it("publishes a batch and returns successful and failed entries", async () => { + (client.send as ReturnType).mockResolvedValueOnce({ + Successful: [ + { Id: "entry-1", MessageId: "msg-001" }, + { Id: "entry-2", MessageId: "msg-002" }, + ], + Failed: [ + { + Id: "entry-3", + Code: "InvalidParameter", + Message: "bad entry", + SenderFault: true, + }, + ], + }); + + const result = await service.publishBatch(topicArn, [ + { id: "entry-1", message: "Message 1" }, + { id: "entry-2", message: "Message 2" }, + { id: "entry-3", message: "Bad entry" }, + ]); + + expect(result).toEqual({ + successful: [ + { id: "entry-1", messageId: "msg-001" }, + { id: "entry-2", messageId: "msg-002" }, + ], + failed: [ + { + id: "entry-3", + code: "InvalidParameter", + message: "bad entry", + senderFault: true, + }, + ], + }); + }); + + it("returns empty successful and failed arrays when both are undefined", async () => { + (client.send as ReturnType).mockResolvedValueOnce({}); + + const result = await service.publishBatch(topicArn, [ + { id: "e1", message: "msg" }, + ]); + + expect(result).toEqual({ successful: [], failed: [] }); + }); + + it("includes subject and messageAttributes in batch entries when provided", async () => { + (client.send as ReturnType).mockResolvedValueOnce({ + Successful: [{ Id: "e1", MessageId: "m1" }], + Failed: [], + }); + + await service.publishBatch(topicArn, [ + { + id: "e1", + message: "msg", + subject: "Subject Line", + messageAttributes: { + color: { DataType: "String", StringValue: "red" }, + }, + }, + ]); + + const call = (client.send as ReturnType).mock.calls[0][0]; + expect(call.input.PublishBatchRequestEntries[0]).toMatchObject({ + Id: "e1", + Message: "msg", + Subject: "Subject Line", + MessageAttributes: { + color: { DataType: "String", StringValue: "red" }, + }, + }); + }); + + it("omits Subject and MessageAttributes from batch entries when not provided", async () => { + (client.send as ReturnType).mockResolvedValueOnce({ + Successful: [{ Id: "e1", MessageId: "m1" }], + Failed: [], + }); + + await service.publishBatch(topicArn, [{ id: "e1", message: "msg" }]); + + const call = (client.send as ReturnType).mock.calls[0][0]; + const entry = call.input.PublishBatchRequestEntries[0]; + expect(entry.Subject).toBeUndefined(); + expect(entry.MessageAttributes).toBeUndefined(); + }); + + it("throws AppError 404 when topic is not found", async () => { + const error = new Error("not found") as Error & { name: string }; + error.name = "NotFoundException"; + (client.send as ReturnType).mockRejectedValueOnce(error); + + await expect( + service.publishBatch(topicArn, [{ id: "e1", message: "msg" }]), + ).rejects.toMatchObject({ + statusCode: 404, + code: "NOT_FOUND", + }); + }); + + it("throws AppError 400 on invalid parameter", async () => { + const error = new Error("bad batch") as Error & { name: string }; + error.name = "InvalidParameterException"; + (client.send as ReturnType).mockRejectedValueOnce(error); + + await expect( + service.publishBatch(topicArn, [{ id: "e1", message: "msg" }]), + ).rejects.toMatchObject({ + statusCode: 400, + code: "INVALID_PARAMETER", + }); + }); + + it("re-throws unknown errors from publishBatch", async () => { + const error = new Error("Unexpected error"); + (client.send as ReturnType).mockRejectedValueOnce(error); + + await expect( + service.publishBatch(topicArn, [{ id: "e1", message: "msg" }]), + ).rejects.toThrow("Unexpected error"); + }); + }); + + // ── listTagsForResource ─────────────────────────────────────────────────── + + describe("listTagsForResource", () => { + const topicArn = "arn:aws:sns:us-east-1:000000000000:my-topic"; + + it("returns formatted tag list", async () => { + (client.send as ReturnType).mockResolvedValueOnce({ + Tags: [ + { Key: "env", Value: "production" }, + { Key: "team", Value: "backend" }, + ], + }); + + const result = await service.listTagsForResource(topicArn); + + expect(result).toEqual({ + tags: [ + { key: "env", value: "production" }, + { key: "team", value: "backend" }, + ], + }); + }); + + it("returns empty tags list when Tags is undefined", async () => { + (client.send as ReturnType).mockResolvedValueOnce({}); + + const result = await service.listTagsForResource(topicArn); + + expect(result).toEqual({ tags: [] }); + }); + + it("uses empty strings for tag key and value when undefined", async () => { + (client.send as ReturnType).mockResolvedValueOnce({ + Tags: [ + { + /* Key and Value intentionally absent */ + }, + ], + }); + + const result = await service.listTagsForResource(topicArn); + expect(result?.tags[0]).toEqual({ key: "", value: "" }); + }); + + it("throws AppError 404 when topic is not found", async () => { + const error = new Error("not found") as Error & { name: string }; + error.name = "NotFoundException"; + (client.send as ReturnType).mockRejectedValueOnce(error); + + await expect(service.listTagsForResource(topicArn)).rejects.toMatchObject( + { + statusCode: 404, + code: "NOT_FOUND", + }, + ); + }); + + it("throws AppError 403 on authorization error", async () => { + const error = new Error("forbidden") as Error & { name: string }; + error.name = "AuthorizationErrorException"; + (client.send as ReturnType).mockRejectedValueOnce(error); + + await expect(service.listTagsForResource(topicArn)).rejects.toMatchObject( + { + statusCode: 403, + code: "AUTHORIZATION_ERROR", + }, + ); + }); + }); + + // ── tagResource ─────────────────────────────────────────────────────────── + + describe("tagResource", () => { + const topicArn = "arn:aws:sns:us-east-1:000000000000:my-topic"; + + it("tags a resource successfully", async () => { + (client.send as ReturnType).mockResolvedValueOnce({}); + + const result = await service.tagResource(topicArn, [ + { key: "env", value: "staging" }, + { key: "owner", value: "alice" }, + ]); + + expect(result).toEqual({ success: true }); + expect(client.send).toHaveBeenCalledOnce(); + + const call = (client.send as ReturnType).mock.calls[0][0]; + expect(call.input).toMatchObject({ + ResourceArn: topicArn, + Tags: [ + { Key: "env", Value: "staging" }, + { Key: "owner", Value: "alice" }, + ], + }); + }); + + it("throws AppError 404 when topic is not found", async () => { + const error = new Error("not found") as Error & { name: string }; + error.name = "NotFoundException"; + (client.send as ReturnType).mockRejectedValueOnce(error); + + await expect( + service.tagResource(topicArn, [{ key: "k", value: "v" }]), + ).rejects.toMatchObject({ + statusCode: 404, + code: "NOT_FOUND", + }); + }); + + it("throws AppError 400 on invalid parameter", async () => { + const error = new Error("bad tag") as Error & { name: string }; + error.name = "InvalidParameterException"; + (client.send as ReturnType).mockRejectedValueOnce(error); + + await expect( + service.tagResource(topicArn, [{ key: "k", value: "v" }]), + ).rejects.toMatchObject({ + statusCode: 400, + code: "INVALID_PARAMETER", + }); + }); + + it("throws AppError 403 on authorization error", async () => { + const error = new Error("forbidden") as Error & { name: string }; + error.name = "AuthorizationErrorException"; + (client.send as ReturnType).mockRejectedValueOnce(error); + + await expect( + service.tagResource(topicArn, [{ key: "k", value: "v" }]), + ).rejects.toMatchObject({ + statusCode: 403, + code: "AUTHORIZATION_ERROR", + }); + }); + + it("re-throws unknown errors from tagResource", async () => { + const error = new Error("Unexpected error"); + (client.send as ReturnType).mockRejectedValueOnce(error); + + await expect( + service.tagResource(topicArn, [{ key: "k", value: "v" }]), + ).rejects.toThrow("Unexpected error"); + }); + }); + + // ── untagResource ───────────────────────────────────────────────────────── + + describe("untagResource", () => { + const topicArn = "arn:aws:sns:us-east-1:000000000000:my-topic"; + + it("untags a resource successfully", async () => { + (client.send as ReturnType).mockResolvedValueOnce({}); + + const result = await service.untagResource(topicArn, ["env", "owner"]); + + expect(result).toEqual({ success: true }); + expect(client.send).toHaveBeenCalledOnce(); + + const call = (client.send as ReturnType).mock.calls[0][0]; + expect(call.input).toMatchObject({ + ResourceArn: topicArn, + TagKeys: ["env", "owner"], + }); + }); + + it("throws AppError 404 when topic is not found", async () => { + const error = new Error("not found") as Error & { name: string }; + error.name = "NotFoundException"; + (client.send as ReturnType).mockRejectedValueOnce(error); + + await expect( + service.untagResource(topicArn, ["env"]), + ).rejects.toMatchObject({ + statusCode: 404, + code: "NOT_FOUND", + }); + }); + + it("throws AppError 400 on invalid parameter", async () => { + const error = new Error("bad tag key") as Error & { name: string }; + error.name = "InvalidParameterException"; + (client.send as ReturnType).mockRejectedValueOnce(error); + + await expect( + service.untagResource(topicArn, ["bad-key"]), + ).rejects.toMatchObject({ + statusCode: 400, + code: "INVALID_PARAMETER", + }); + }); + + it("throws AppError 403 on authorization error", async () => { + const error = new Error("forbidden") as Error & { name: string }; + error.name = "AuthorizationErrorException"; + (client.send as ReturnType).mockRejectedValueOnce(error); + + await expect( + service.untagResource(topicArn, ["env"]), + ).rejects.toMatchObject({ + statusCode: 403, + code: "AUTHORIZATION_ERROR", + }); + }); + + it("re-throws unknown errors from untagResource", async () => { + const error = new Error("Unexpected error"); + (client.send as ReturnType).mockRejectedValueOnce(error); + + await expect(service.untagResource(topicArn, ["env"])).rejects.toThrow( + "Unexpected error", + ); + }); + }); +}); diff --git a/packages/backend/test/plugins/sqs/routes.test.ts b/packages/backend/test/plugins/sqs/routes.test.ts new file mode 100644 index 0000000..989aeb4 --- /dev/null +++ b/packages/backend/test/plugins/sqs/routes.test.ts @@ -0,0 +1,131 @@ +import Fastify, { type FastifyInstance } from "fastify"; +import { + afterAll, + beforeAll, + describe, + expect, + it, + type Mock, + vi, +} from "vitest"; +import type { ClientCache } from "../../../src/aws/client-cache.js"; +import { sqsRoutes } from "../../../src/plugins/sqs/routes.js"; +import { registerErrorHandler } from "../../../src/shared/errors.js"; + +interface MockSQSService { + listQueues: Mock; + createQueue: Mock; + getQueueUrl: Mock; + deleteQueue: Mock; + purgeQueue: Mock; + getQueueDetail: Mock; + sendMessage: Mock; + receiveMessages: Mock; + deleteMessage: Mock; +} + +function createMockSQSService(): MockSQSService { + return { + listQueues: vi.fn().mockResolvedValue({ queues: [] }), + createQueue: vi.fn().mockResolvedValue({ + message: "Queue created", + queueUrl: "http://sqs/test-queue", + }), + getQueueUrl: vi.fn().mockResolvedValue("http://sqs/test-queue"), + deleteQueue: vi.fn().mockResolvedValue({ success: true }), + purgeQueue: vi.fn().mockResolvedValue({ success: true }), + getQueueDetail: vi.fn().mockResolvedValue({ + queueUrl: "http://sqs/test-queue", + queueName: "test-queue", + queueArn: "arn:aws:sqs:us-east-1:000000000000:test-queue", + approximateNumberOfMessages: 0, + approximateNumberOfMessagesNotVisible: 0, + approximateNumberOfMessagesDelayed: 0, + createdTimestamp: "1000000000", + lastModifiedTimestamp: "1000000000", + visibilityTimeout: 30, + maximumMessageSize: 262144, + messageRetentionPeriod: 345600, + delaySeconds: 0, + receiveMessageWaitTimeSeconds: 0, + }), + sendMessage: vi.fn().mockResolvedValue({ messageId: "msg-123" }), + receiveMessages: vi.fn().mockResolvedValue([]), + deleteMessage: vi.fn().mockResolvedValue({ success: true }), + }; +} + +vi.mock("../../../src/plugins/sqs/service.js", async (importOriginal) => { + const actual = + await importOriginal< + typeof import("../../../src/plugins/sqs/service.js") + >(); + return { + ...actual, + SQSService: vi.fn(), + }; +}); + +import { SQSService as SQSServiceClass } from "../../../src/plugins/sqs/service.js"; + +describe("SQS Routes", () => { + let app: FastifyInstance; + let mockService: MockSQSService; + + beforeAll(async () => { + app = Fastify(); + registerErrorHandler(app); + + mockService = createMockSQSService(); + + (SQSServiceClass as unknown as Mock).mockImplementation(() => mockService); + + const mockClientCache = { + getClients: vi.fn().mockReturnValue({ + sqs: {}, + }), + }; + app.decorate("clientCache", mockClientCache as unknown as ClientCache); + + app.decorateRequest("localstackConfig", null); + app.addHook("onRequest", async (request) => { + request.localstackConfig = { + endpoint: "http://localhost:4566", + region: "us-east-1", + }; + }); + + await app.register(sqsRoutes); + await app.ready(); + }); + + afterAll(async () => { + await app.close(); + }); + + describe("DELETE /:queueName/messages (deleteMessage)", () => { + it("should delete a message with a receipt handle", async () => { + const response = await app.inject({ + method: "DELETE", + url: "/test-queue/messages", + payload: { receiptHandle: "test-receipt-handle" }, + }); + expect(response.statusCode).toBe(200); + const body = response.json<{ success: boolean }>(); + expect(body.success).toBe(true); + expect(mockService.deleteMessage).toHaveBeenCalledWith( + "test-queue", + "test-receipt-handle", + ); + }); + + it("should return 400 when receiptHandle is missing", async () => { + const response = await app.inject({ + method: "DELETE", + url: "/test-queue/messages", + payload: {}, + }); + expect(response.statusCode).toBe(400); + }); + }); +}); diff --git a/packages/backend/test/plugins/sqs/service.test.ts b/packages/backend/test/plugins/sqs/service.test.ts index 1c0207a..0814d15 100644 --- a/packages/backend/test/plugins/sqs/service.test.ts +++ b/packages/backend/test/plugins/sqs/service.test.ts @@ -246,7 +246,7 @@ describe("SQSService", () => { expect(result.createdTimestamp).toBeUndefined(); }); - it("throws AppError with 404 when queue does not exist", async () => { + it("throws AppError with 404 when queue does not exist (QueueDoesNotExist via getQueueUrl)", async () => { const error = new Error("Queue does not exist") as Error & { name: string; }; @@ -260,6 +260,90 @@ describe("SQSService", () => { code: "QUEUE_NOT_FOUND", }); }); + + it("throws AppError with 404 when GetQueueAttributes returns NonExistentQueue", async () => { + // First call: getQueueUrl succeeds + (client.send as ReturnType) + .mockResolvedValueOnce({ + QueueUrl: "http://localhost:4566/000000000000/my-queue", + }) + // Second call: GetQueueAttributes throws NonExistentQueue + .mockRejectedValueOnce( + Object.assign(new Error("Queue does not exist"), { + name: "NonExistentQueue", + }), + ); + + await expect(service.getQueueDetail("my-queue")).rejects.toMatchObject({ + statusCode: 404, + code: "QUEUE_NOT_FOUND", + }); + }); + + it("re-throws unknown errors from getQueueDetail (GetQueueAttributes)", async () => { + const unknownError = new Error("Unexpected AWS error"); + (client.send as ReturnType) + .mockResolvedValueOnce({ + QueueUrl: "http://localhost:4566/000000000000/my-queue", + }) + .mockRejectedValueOnce(unknownError); + + await expect(service.getQueueDetail("my-queue")).rejects.toThrow( + "Unexpected AWS error", + ); + }); + }); + + describe("getQueueUrl", () => { + it("re-throws unknown errors that are not QueueDoesNotExist or NonExistentQueue", async () => { + const unknownError = Object.assign(new Error("Throttled"), { + name: "ThrottlingException", + }); + (client.send as ReturnType).mockRejectedValueOnce( + unknownError, + ); + + await expect(service.getQueueUrl("my-queue")).rejects.toThrow( + "Throttled", + ); + }); + + it("returns empty string when QueueUrl is undefined in response", async () => { + (client.send as ReturnType).mockResolvedValueOnce({ + // QueueUrl intentionally absent + }); + + const result = await service.getQueueUrl("my-queue"); + expect(result).toBe(""); + }); + }); + + describe("listQueues (additional branch coverage)", () => { + it("returns queueName as empty string when URL has no segments", async () => { + // Manufacture a URL where split("/") last element is empty string + // so the ?? "" fallback fires. An empty string URL splits to [""]. + (client.send as ReturnType).mockResolvedValueOnce({ + QueueUrls: [""], + }); + + const result = await service.listQueues(); + expect(result.queues[0].queueName).toBe(""); + }); + }); + + describe("getQueueDetail (additional branch coverage)", () => { + it("uses empty object when Attributes is undefined in GetQueueAttributes response", async () => { + (client.send as ReturnType) + .mockResolvedValueOnce({ + QueueUrl: "http://localhost:4566/000000000000/my-queue", + }) + .mockResolvedValueOnce({ + // Attributes intentionally absent (undefined) + }); + + const result = await service.getQueueDetail("my-queue"); + expect(result.approximateNumberOfMessages).toBe(0); + }); }); describe("sendMessage", () => { diff --git a/packages/backend/test/scripts/executors/setup.ts b/packages/backend/test/scripts/executors/setup.ts new file mode 100644 index 0000000..640756d --- /dev/null +++ b/packages/backend/test/scripts/executors/setup.ts @@ -0,0 +1,61 @@ +import { randomUUID } from "node:crypto"; +import { lookup } from "node:dns"; +import { getContainerRuntimeClient } from "testcontainers"; +import { getReaper } from "testcontainers/build/reaper/reaper.js"; +import { startContainer as startContainerLocalStack } from "../runners/localstack.js"; + +async function hostResolve(host: string): Promise { + return await new Promise((resolve, reject) => { + lookup(host, { family: 4 }, (err, address) => { + if (err) { + return reject(err); + } + return resolve(address); + }); + }); +} + +const startReaper = async () => { + if ( + process.env.TESTCONTAINERS_RYUK_DISABLED === "true" || + process.env.TESTCONTAINERS_RYUK_DISABLED === "1" + ) { + return {}; + } + const containerRuntimeClient = await getContainerRuntimeClient(); + await getReaper(containerRuntimeClient); + const runningContainers = await containerRuntimeClient.container.list(); + const reaper = runningContainers.find( + (container) => container.Labels["org.testcontainers.ryuk"] === "true", + ); + const reaperNetwork = reaper?.Ports.find((port) => port.PrivatePort === 8080); + const reaperPort = reaperNetwork?.PublicPort; + const reaperIp = containerRuntimeClient.info.containerRuntime.host; + const reaperSessionId = reaper?.Labels["org.testcontainers.session-id"]; + return { + REAPER: `${reaperIp}:${reaperPort}`, + REAPER_SESSION: reaperSessionId, + }; +}; + +export default async function globalSetup() { + // Skip testcontainers setup when running locally + if (process.env.TEST_LOCAL) { + console.log("TEST_LOCAL mode: skipping testcontainers setup"); + return; + } + + console.log("Start Reaper"); + const reaperEnv = await startReaper(); + process.env.REAPER_SESSION_ID = reaperEnv.REAPER_SESSION ?? randomUUID(); + + if (!process.env.SKIP_TEST_LOCALSTACK_SETUP) { + console.log("Start LocalStack"); + const { port: localStackPort, host: localStackHost } = + await startContainerLocalStack(); + const ipHost = await hostResolve(localStackHost); + process.env.LOCALSTACK_ENDPOINT = `http://${ipHost}:${localStackPort}`; + process.env.LOCALSTACK_REGION = process.env.AWS_REGION; + console.log(`LocalStack endpoint: ${process.env.LOCALSTACK_ENDPOINT}`); + } +} diff --git a/packages/backend/test/scripts/executors/teardown.ts b/packages/backend/test/scripts/executors/teardown.ts new file mode 100644 index 0000000..7ab53a4 --- /dev/null +++ b/packages/backend/test/scripts/executors/teardown.ts @@ -0,0 +1,23 @@ +import { getContainerRuntimeClient } from "testcontainers"; + +export default async function globalTeardown() { + if ( + process.env.REAPER_SESSION_ID !== undefined && + process.env.REAPER_SESSION_ID !== "" + ) { + const reapersessionid = process.env.REAPER_SESSION_ID; + const containerRuntimeClient = await getContainerRuntimeClient(); + const runningContainers = await containerRuntimeClient.container.list(); + const containers = runningContainers.filter( + (container) => + container.Labels["org.testcontainers.reaper-session-id"] === + reapersessionid, + ); + for (const containerInfo of containers) { + const container = containerRuntimeClient.container.getById( + containerInfo.Id, + ); + await containerRuntimeClient.container.stop(container); + } + } +} diff --git a/packages/backend/test/scripts/runners/localstack.ts b/packages/backend/test/scripts/runners/localstack.ts new file mode 100644 index 0000000..c66467c --- /dev/null +++ b/packages/backend/test/scripts/runners/localstack.ts @@ -0,0 +1,52 @@ +import { randomUUID } from "node:crypto"; +import { GenericContainer, Wait } from "testcontainers"; +import { spawnProcess } from "./spawnProcess.js"; + +const startContainer = async () => { + const localStack = await new GenericContainer("localstack/localstack:4") + .withExposedPorts(4566) + .withEnvironment({ + SERVICES: "s3,sqs,sns,iam,cloudformation,dynamodb", + DEBUG: "0", + NODE_TLS_REJECT_UNAUTHORIZED: "0", + HOSTNAME: "localhost", + AWS_DEFAULT_REGION: "eu-south-1", + }) + .withLabels({ + "org.testcontainers.reaper-session-id": + process.env.REAPER_SESSION_ID || randomUUID(), // This is mandatory for the reaper to clean up the container + }) + .withWaitStrategy(Wait.forListeningPorts()) + .start(); + const port = localStack.getMappedPort(4566); + const host = localStack.getHost(); + process.env.AWS_REGION = "eu-central-1"; + process.env.AWS_DEFAULT_REGION = process.env.AWS_REGION; + process.env.AWS_ACCESS_KEY_ID = "AWS_ACCESS_KEY_ID"; + process.env.AWS_SECRET_ACCESS_KEY = "AWS_SECRET_ACCESS_KEY"; + return { + container: localStack, + port, + host, + }; +}; + +const bootstrap = async (host: string, port: number) => { + const cdkEnv = { + env: { + ...process.env, + AWS_ENDPOINT_URL: `http://${host}:${port}`, + AWS_ENDPOINT_URL_S3: `http://${host}:${port}`, + CDK_DISABLE_LEGACY_EXPORT_WARNING: 1, + AWS_ENVAR_ALLOWLIST: "AWS_REGION,AWS_DEFAULT_REGION", + }, + }; + + console.log("Bootstrap CDK stack to LocalStack"); + await spawnProcess("pnpm", ["cdklocal:bootstrap"], cdkEnv); + + console.log("Deploy CDK stack to LocalStack"); + await spawnProcess("pnpm", ["cdklocal:deploy"], cdkEnv); +}; + +export { bootstrap, startContainer }; diff --git a/packages/backend/test/scripts/runners/spawnProcess.ts b/packages/backend/test/scripts/runners/spawnProcess.ts new file mode 100644 index 0000000..c8bb2fa --- /dev/null +++ b/packages/backend/test/scripts/runners/spawnProcess.ts @@ -0,0 +1,22 @@ +import { spawn } from "node:child_process"; + +export function spawnProcess( + command: string, + args: string[], + // biome-ignore lint/suspicious/noExplicitAny: This is a utility function, we want to be able to pass any options to spawn + options: Record, +) { + return new Promise((resolve, _reject) => { + const proc = spawn(command, args, options); + proc.stdout.on("data", (data) => { + console.log(data.toString().trimEnd()); + }); + proc.stderr.on("data", (data) => { + console.error(data.toString().trimEnd()); + }); + proc.on("close", (code) => { + console.log(`exited with code ${code}`); + resolve(true); + }); + }); +} diff --git a/packages/backend/test/scripts/vitest.setup.ts b/packages/backend/test/scripts/vitest.setup.ts new file mode 100644 index 0000000..23739ce --- /dev/null +++ b/packages/backend/test/scripts/vitest.setup.ts @@ -0,0 +1,13 @@ +import type { TestProject } from "vitest/node"; +import globalSetup from "./executors/setup.js"; +import globalTeardown from "./executors/teardown.js"; + +export async function setup(_project: TestProject) { + console.log("start global setup"); + await globalSetup(); +} + +export async function teardown() { + console.log("start global teardown"); + await globalTeardown(); +} diff --git a/packages/backend/test/shared/errors.test.ts b/packages/backend/test/shared/errors.test.ts new file mode 100644 index 0000000..2bda4c1 --- /dev/null +++ b/packages/backend/test/shared/errors.test.ts @@ -0,0 +1,123 @@ +import Fastify, { type FastifyInstance } from "fastify"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { AppError, registerErrorHandler } from "../../src/shared/errors.js"; + +// --------------------------------------------------------------------------- +describe("AppError", () => { + it("sets message, statusCode, code, and name correctly", () => { + const err = new AppError("Something went wrong", 422, "UNPROCESSABLE"); + + expect(err.message).toBe("Something went wrong"); + expect(err.statusCode).toBe(422); + expect(err.code).toBe("UNPROCESSABLE"); + expect(err.name).toBe("AppError"); + expect(err).toBeInstanceOf(Error); + expect(err).toBeInstanceOf(AppError); + }); + + it("uses default statusCode of 500 when not provided", () => { + const err = new AppError("oops"); + expect(err.statusCode).toBe(500); + }); + + it("uses default code of INTERNAL_ERROR when not provided", () => { + const err = new AppError("oops"); + expect(err.code).toBe("INTERNAL_ERROR"); + }); + + it("uses all defaults when only message is provided", () => { + const err = new AppError("bare message"); + expect(err.message).toBe("bare message"); + expect(err.statusCode).toBe(500); + expect(err.code).toBe("INTERNAL_ERROR"); + expect(err.name).toBe("AppError"); + }); +}); + +// --------------------------------------------------------------------------- +describe("registerErrorHandler", () => { + let app: FastifyInstance; + + beforeEach(async () => { + app = Fastify({ logger: false }); + registerErrorHandler(app); + }); + + afterEach(async () => { + await app.close(); + }); + + it("responds with correct status and body for an AppError", async () => { + app.get("/test", async () => { + throw new AppError("Stack not found", 404, "STACK_NOT_FOUND"); + }); + await app.ready(); + + const response = await app.inject({ method: "GET", url: "/test" }); + + expect(response.statusCode).toBe(404); + const body = response.json<{ + error: string; + message: string; + statusCode: number; + }>(); + expect(body.error).toBe("STACK_NOT_FOUND"); + expect(body.message).toBe("Stack not found"); + expect(body.statusCode).toBe(404); + }); + + it("responds with 400 VALIDATION_ERROR for a Fastify validation error", async () => { + // Register a route with a strict schema so Fastify generates a validation error + app.post( + "/validated", + { + schema: { + body: { + type: "object", + required: ["name"], + properties: { name: { type: "string", minLength: 1 } }, + additionalProperties: false, + }, + }, + }, + async () => { + return { ok: true }; + }, + ); + await app.ready(); + + const response = await app.inject({ + method: "POST", + url: "/validated", + payload: {}, + }); + + expect(response.statusCode).toBe(400); + const body = response.json<{ + error: string; + message: string; + statusCode: number; + }>(); + expect(body.error).toBe("VALIDATION_ERROR"); + expect(body.statusCode).toBe(400); + }); + + it("responds with 500 INTERNAL_ERROR for an unknown error", async () => { + app.get("/boom", async () => { + throw new Error("Unexpected failure"); + }); + await app.ready(); + + const response = await app.inject({ method: "GET", url: "/boom" }); + + expect(response.statusCode).toBe(500); + const body = response.json<{ + error: string; + message: string; + statusCode: number; + }>(); + expect(body.error).toBe("INTERNAL_ERROR"); + expect(body.message).toBe("An unexpected error occurred"); + expect(body.statusCode).toBe(500); + }); +}); diff --git a/packages/backend/test/shared/types.test.ts b/packages/backend/test/shared/types.test.ts new file mode 100644 index 0000000..04039fa --- /dev/null +++ b/packages/backend/test/shared/types.test.ts @@ -0,0 +1,93 @@ +import Type from "typebox"; +import { describe, expect, it } from "vitest"; +import { + ErrorResponseSchema, + PaginatedResponseSchema, +} from "../../src/shared/types.js"; + +describe("ErrorResponseSchema", () => { + it("is a valid TypeBox object schema", () => { + expect(ErrorResponseSchema).toBeDefined(); + // TypeBox schemas carry a $schema or kind marker + expect(typeof ErrorResponseSchema).toBe("object"); + }); + + it("has an error property of type string", () => { + const props = ErrorResponseSchema.properties as Record< + string, + { type: string } + >; + expect(props.error).toBeDefined(); + expect(props.error.type).toBe("string"); + }); + + it("has a message property of type string", () => { + const props = ErrorResponseSchema.properties as Record< + string, + { type: string } + >; + expect(props.message).toBeDefined(); + expect(props.message.type).toBe("string"); + }); + + it("has a statusCode property of type number", () => { + const props = ErrorResponseSchema.properties as Record< + string, + { type: string } + >; + expect(props.statusCode).toBeDefined(); + expect(props.statusCode.type).toBe("number"); + }); +}); + +describe("PaginatedResponseSchema", () => { + it("returns a valid TypeBox object schema when called with an item schema", () => { + const itemSchema = Type.Object({ + id: Type.String(), + name: Type.String(), + }); + + const schema = PaginatedResponseSchema(itemSchema); + + expect(schema).toBeDefined(); + expect(typeof schema).toBe("object"); + }); + + it("resulting schema has an items array property", () => { + const itemSchema = Type.Object({ id: Type.String() }); + const schema = PaginatedResponseSchema(itemSchema); + + const props = schema.properties as Record; + expect(props.items).toBeDefined(); + expect(props.items.type).toBe("array"); + }); + + it("resulting schema has an optional nextToken string property", () => { + const itemSchema = Type.Object({ id: Type.String() }); + const schema = PaginatedResponseSchema(itemSchema); + + // Access via unknown to avoid TypeScript narrowing issues with TOptional + const props = schema.properties as unknown as Record; + expect(props.nextToken).toBeDefined(); + }); + + it("works with different item schemas each time it is called", () => { + const schema1 = PaginatedResponseSchema( + Type.Object({ bucket: Type.String() }), + ); + const schema2 = PaginatedResponseSchema( + Type.Object({ + queueUrl: Type.String(), + approximateNumberOfMessages: Type.Number(), + }), + ); + + // Each call should produce an independent schema + expect(schema1).not.toBe(schema2); + + // Access items via unknown to avoid TypeScript index signature issues + const props1 = schema1.properties as unknown as Record; + const props2 = schema2.properties as unknown as Record; + expect(props1.items).not.toBe(props2.items); + }); +}); diff --git a/packages/backend/tsconfig.build.json b/packages/backend/tsconfig.build.json new file mode 100644 index 0000000..dc3072d --- /dev/null +++ b/packages/backend/tsconfig.build.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "src", + }, + "include": ["src"], +} \ No newline at end of file diff --git a/packages/backend/tsconfig.json b/packages/backend/tsconfig.json index 5a24989..00f7f76 100644 --- a/packages/backend/tsconfig.json +++ b/packages/backend/tsconfig.json @@ -2,7 +2,6 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "outDir": "dist", - "rootDir": "src" }, - "include": ["src"] + "include": ["src", "test", "vitest.config.ts"], } diff --git a/packages/backend/vitest.config.ts b/packages/backend/vitest.config.ts index 2fb5c48..4aea6f4 100644 --- a/packages/backend/vitest.config.ts +++ b/packages/backend/vitest.config.ts @@ -3,6 +3,21 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { globals: true, + globalSetup: "./test/scripts/vitest.setup.ts", environment: "node", + testTimeout: 30_000, + coverage: { + enabled: true, + provider: "v8", + reporter: ["text", "lcov"], + include: ["src/**/*.ts"], + exclude: ["src/bundle.ts"], + thresholds: { + lines: 100, + functions: 100, + statements: 100, + branches: 95, + }, + }, }, }); diff --git a/packages/desktop/scripts/build.mjs b/packages/desktop/scripts/build.mjs index b791dbb..a761fa0 100644 --- a/packages/desktop/scripts/build.mjs +++ b/packages/desktop/scripts/build.mjs @@ -17,4 +17,7 @@ if (!existsSync(path.join(bundleDir, "bundle.cjs"))) { cpSync(path.join(bundleDir, "bundle.cjs"), path.join(desktopDir, "bundle.cjs")); cpSync(path.join(bundleDir, "public"), path.join(desktopDir, "public"), { recursive: true }); +// Copy desktop icon from shared icons directory +cpSync(path.join(rootDir, "icons", "icon-desktop.png"), path.join(desktopDir, "icon.png")); + console.log("Assets copied. Running electron-builder..."); diff --git a/packages/frontend/index.html b/packages/frontend/index.html index 18ff161..9349d9d 100644 --- a/packages/frontend/index.html +++ b/packages/frontend/index.html @@ -3,6 +3,10 @@ + + + + LocalStack Explorer diff --git a/packages/frontend/public/site.webmanifest b/packages/frontend/public/site.webmanifest new file mode 100644 index 0000000..7f38cff --- /dev/null +++ b/packages/frontend/public/site.webmanifest @@ -0,0 +1,19 @@ +{ + "name": "LocalStack Explorer", + "short_name": "LS Explorer", + "icons": [ + { + "src": "/icon-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/icon-512x512.png", + "sizes": "512x512", + "type": "image/png" + } + ], + "theme_color": "#1e3a5f", + "background_color": "#ffffff", + "display": "standalone" +} diff --git a/packages/frontend/src/api/config.ts b/packages/frontend/src/api/config.ts index 1b755e9..06e81d3 100644 --- a/packages/frontend/src/api/config.ts +++ b/packages/frontend/src/api/config.ts @@ -5,6 +5,7 @@ interface HealthResponse { connected: boolean; endpoint: string; region: string; + services: string[]; error?: string; } diff --git a/packages/frontend/src/components/layout/Sidebar.tsx b/packages/frontend/src/components/layout/Sidebar.tsx index 926430d..7b414f9 100644 --- a/packages/frontend/src/components/layout/Sidebar.tsx +++ b/packages/frontend/src/components/layout/Sidebar.tsx @@ -9,9 +9,15 @@ import { MessageSquare, Shield, } from "lucide-react"; +import { useHealthCheck } from "@/api/config"; import { useEnabledServices } from "@/api/services"; import { Button } from "@/components/ui/button"; import { Separator } from "@/components/ui/separator"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; import { useAppStore } from "@/stores/app"; @@ -65,7 +71,9 @@ export function Sidebar() { const routerState = useRouterState(); const currentPath = routerState.location.pathname; const { data } = useEnabledServices(); + const { data: healthData } = useHealthCheck(); const enabledSet = data ? new Set(data.services) : null; + const activeSet = healthData?.services ? new Set(healthData.services) : null; const visibleServices = enabledSet ? services.filter((s) => enabledSet.has(s.key)) @@ -101,14 +109,46 @@ export function Sidebar() {