diff --git a/cdk-postgresql/package.json b/cdk-postgresql/package.json index 2572706..b46721d 100644 --- a/cdk-postgresql/package.json +++ b/cdk-postgresql/package.json @@ -32,7 +32,7 @@ "watch": "pnpm exec tsdown -w", "check": "pnpm run check:type", "check:type": "pnpm exec tsc --noEmit", - "test": "pnpm run test:unit && pnpm run test:integration", + "test": "pnpm run build && pnpm exec vitest --run", "test:unit": "pnpm run build && pnpm exec vitest --run --project=unit", "test:integration": "pnpm run build && pnpm exec vitest --run --project=integration", "prepublishOnly": "pnpm run build" diff --git a/cdk-postgresql/test/fixtures/fake-secrets-manager.ts b/cdk-postgresql/test/fixtures/fake-secrets-manager.ts new file mode 100644 index 0000000..56b7aca --- /dev/null +++ b/cdk-postgresql/test/fixtures/fake-secrets-manager.ts @@ -0,0 +1,57 @@ +import { createServer, Server } from "node:http"; + +const SECRETS_MANAGER_PORT = 14566; +export const SECRETS_MANAGER_ENDPOINT = `http://localhost:${SECRETS_MANAGER_PORT}`; + +/** + * Serves the two Secrets Manager operations the lambda handlers call. + */ +export const startFakeSecretsManager = () => { + const secretsByArn = new Map(); + + const server = createServer((request, response) => { + let body = ""; + + request.on("data", (chunk) => (body += chunk)); + + request.on("end", () => { + const operation = request.headers["x-amz-target"]; + const payload = JSON.parse(body); + + response.setHeader("content-type", "application/x-amz-json-1.1"); + + if (operation === "secretsmanager.CreateSecret") { + const arn = `arn:aws:secretsmanager:us-east-1:123456789012:secret:${payload.Name}`; + secretsByArn.set(arn, payload.SecretString); + response.end(JSON.stringify({ ARN: arn, Name: payload.Name })); + } else if (operation === "secretsmanager.GetSecretValue") { + const secretString = secretsByArn.get(payload.SecretId); + if (secretString === undefined) { + response.statusCode = 400; + response.end(JSON.stringify({ __type: "ResourceNotFoundException" })); + } else { + response.end(JSON.stringify({ ARN: payload.SecretId, SecretString: secretString })); + } + } else { + response.statusCode = 400; + response.end(JSON.stringify({ __type: "UnknownOperationException" })); + } + }); + }); + + return new Promise((resolve, reject) => { + const onListening = () => { + server.removeListener("error", onError); + resolve(server); + }; + + const onError = (error: Error) => { + server.removeListener("listening", onListening); + reject(error); + }; + + server.once("error", onError); + server.once("listening", onListening); + server.listen(SECRETS_MANAGER_PORT); + }); +}; diff --git a/cdk-postgresql/test/fixtures/postgres-cluster.ts b/cdk-postgresql/test/fixtures/postgres-cluster.ts new file mode 100644 index 0000000..519995d --- /dev/null +++ b/cdk-postgresql/test/fixtures/postgres-cluster.ts @@ -0,0 +1,115 @@ +import { GenericContainer, Wait } from "testcontainers"; +import { Client } from "pg"; + +export const DB_MASTER_USERNAME = "postgres"; +export const DB_MASTER_PASSWORD = "masterpwd"; +export const DB_DEFAULT_DB = "postgres"; + +const DB_PORT = 5432; + +export type PostgresCluster = { + host: string; + port: number; + connectTo: (database: string) => Promise; + /** + * Returns the cluster to a blank state by dropping every replication slot, + * extra database, publication, table, and role a test created. + */ + reset: () => Promise; + stop: () => Promise; +}; + +export const startPostgresCluster = async (): Promise => { + const container = await new GenericContainer("postgres:16") + .withExposedPorts(DB_PORT) + .withEnvironment({ POSTGRES_PASSWORD: DB_MASTER_PASSWORD }) + // Logical replication slots require logical write-ahead-log decoding. + // Durability is off because the data is throwaway: + .withCommand([ + "postgres", + "-c", + "wal_level=logical", + "-c", + "fsync=off", + "-c", + "synchronous_commit=off", + "-c", + "full_page_writes=off", + ]) + // The image's setup script starts and stops the server once before the + // final start, so connections are only reliable after the second + // "ready" line: + .withWaitStrategy( + Wait.forLogMessage(/database system is ready to accept connections/, 2) + ) + .start(); + + const host = container.getHost(); + const port = container.getMappedPort(DB_PORT); + + const connectTo = async (database: string) => { + const client = new Client({ + host, + port, + database, + user: DB_MASTER_USERNAME, + password: DB_MASTER_PASSWORD, + }); + await client.connect(); + return client; + }; + + const resetClient = await connectTo(DB_DEFAULT_DB); + + const reset = async () => { + const { rows: slots } = await resetClient.query( + "SELECT slot_name, database FROM pg_replication_slots" + ); + for (const { slot_name, database } of slots) { + if (database && database !== DB_DEFAULT_DB) { + // A logical slot can only be dropped from the database it decodes: + const client = await connectTo(database); + await client.query("SELECT pg_drop_replication_slot($1)", [slot_name]); + await client.end(); + } else { + await resetClient.query("SELECT pg_drop_replication_slot($1)", [slot_name]); + } + } + + const { rows: databases } = await resetClient.query( + "SELECT datname FROM pg_database WHERE NOT datistemplate AND datname <> current_database()" + ); + for (const { datname } of databases) { + await resetClient.query(`DROP DATABASE "${datname}" WITH (FORCE)`); + } + + const { rows: publications } = await resetClient.query( + "SELECT pubname FROM pg_publication" + ); + for (const { pubname } of publications) { + await resetClient.query(`DROP PUBLICATION "${pubname}"`); + } + + const { rows: tables } = await resetClient.query( + "SELECT tablename FROM pg_tables WHERE schemaname = 'public'" + ); + for (const { tablename } of tables) { + await resetClient.query(`DROP TABLE "${tablename}" CASCADE`); + } + + const { rows: roles } = await resetClient.query( + "SELECT rolname FROM pg_roles WHERE rolname NOT LIKE 'pg\\_%' AND rolname <> current_user" + ); + for (const { rolname } of roles) { + await resetClient.query(`DROP OWNED BY "${rolname}"`); + await resetClient.query(`DROP ROLE "${rolname}"`); + } + }; + + const stop = async () => { + await resetClient.end(); + await container.stop(); + }; + + return { host, port, connectTo, reset, stop }; +}; diff --git a/cdk-postgresql/test/lambda.integration.test.ts b/cdk-postgresql/test/lambda.integration.test.ts index 426fc6a..20fcc4e 100644 --- a/cdk-postgresql/test/lambda.integration.test.ts +++ b/cdk-postgresql/test/lambda.integration.test.ts @@ -1,6 +1,5 @@ import { handler as dbHandler } from "../lib/database.handler"; import { handler as roleHandler } from "../lib/role.handler"; -import { GenericContainer, StartedTestContainer } from "testcontainers"; import ms from "ms"; import { CreateDatabaseEvent, @@ -24,51 +23,63 @@ import { roleExists, } from "./helpers"; import { secretsmanager } from "../lib/util"; -import { beforeEach, afterEach, describe, test, expect, vi } from "vitest"; +import { beforeAll, afterAll, beforeEach, describe, test, expect, vi } from "vitest"; import { createRequire } from "node:module"; - -const DB_PORT = 5432; -const DB_MASTER_USERNAME = "postgres"; -const DB_MASTER_PASSWORD = "masterpwd"; -const DB_DEFAULT_DB = "postgres"; -const LOCALSTACK_PORT = 4566; - -// The AWS SDK resolves AWS_ENDPOINT_URL once per client and caches it, and -// lib/util.ts holds a single client for the whole process, so LocalStack has -// to answer on the same host port for every test in this file: -const LOCALSTACK_HOST_PORT = 14566; - -let pgContainer: StartedTestContainer; -let localstackContainer: StartedTestContainer; +import { Server } from "node:http"; +import { + SECRETS_MANAGER_ENDPOINT, + startFakeSecretsManager, +} from "./fixtures/fake-secrets-manager"; +import { + DB_DEFAULT_DB, + DB_MASTER_PASSWORD, + DB_MASTER_USERNAME, + PostgresCluster, + startPostgresCluster, +} from "./fixtures/postgres-cluster"; + +let cluster: PostgresCluster; +let secretsManagerServer: Server; let masterPasswordArn: string; let pgHost: string; let pgPort: number; -beforeEach(async () => { - pgContainer = await new GenericContainer("postgres:16") - .withExposedPorts(DB_PORT) - .withEnvironment({ POSTGRES_PASSWORD: DB_MASTER_PASSWORD }) - // Logical replication slots require logical write-ahead-log decoding: - .withCommand(["postgres", "-c", "wal_level=logical"]) - .start(); - localstackContainer = await new GenericContainer("localstack/localstack:3") - .withEnvironment({ SERVICES: "secretsmanager" }) - .withExposedPorts({ container: LOCALSTACK_PORT, host: LOCALSTACK_HOST_PORT }) - .start(); - - pgHost = pgContainer.getHost(); - pgPort = pgContainer.getMappedPort(DB_PORT); - - vi.stubEnv("AWS_ENDPOINT_URL", `http://localhost:${LOCALSTACK_HOST_PORT}`); +beforeAll(async () => { + [cluster, secretsManagerServer] = await Promise.all([ + startPostgresCluster(), + startFakeSecretsManager(), + ]); + + pgHost = cluster.host; + pgPort = cluster.port; + + vi.stubEnv("AWS_ENDPOINT_URL", SECRETS_MANAGER_ENDPOINT); masterPasswordArn = await createSecret(secretsmanager, DB_MASTER_PASSWORD); }, ms("2m")); -afterEach(async () => { +afterAll(async () => { vi.unstubAllEnvs(); - await pgContainer?.stop(); - await localstackContainer?.stop(); + + if (secretsManagerServer) { + await new Promise((resolve, reject) => { + secretsManagerServer.close((error) => { + if (error) { + reject(error); + return; + } + + resolve(); + }); + }); + } + + await cluster?.stop(); }); +// The database container is shared by every test in this file, so each test +// starts from a blank cluster: +beforeEach(() => cluster.reset()); + describe("role", () => { test("create", async () => { const newRolePwd = "rolepwd";