Files
kuber/tests/lib/database.test.ts
T

243 lines
7.4 KiB
TypeScript

import { afterEach, describe, expect, mock, spyOn, test } from "bun:test";
import type { ComposeSpecification, Service } from "../../schema/docker.d";
import {
DatabaseReconciliationError,
buildDatabaseUrl,
buildPostgresEnvironment,
getComposePostgresClaims,
getServicePostgresClaim,
isPostgresVolumeEntry,
reconcilePostgresClaim,
} from "../../lib/database";
import { objectApi } from "../../lib/k8s";
afterEach(() => mock.restore());
describe("managed PostgreSQL claims", () => {
test("supports short and explicit syntax", () => {
expect(
getServicePostgresClaim("app", {
volumes: ["postgresql:app"],
} as Service),
).toEqual({
service: "app",
username: "app",
database: "app",
secretName: "postgres-app",
});
expect(
getServicePostgresClaim("app", {
volumes: ["postgresql:user/database"],
} as Service),
).toEqual({
service: "app",
username: "user",
database: "database",
secretName: "postgres-user",
});
});
test.each([
"postgresql:",
"postgresql:user/",
"postgresql:/database",
"postgresql:user/database/extra",
"postgresql:user:database",
])("rejects malformed declaration %s", (entry) => {
expect(() =>
getServicePostgresClaim("app", { volumes: [entry] } as Service),
).toThrow("Use postgresql:<name> or postgresql:<user>/<database>");
});
test("ignores unrelated entries and rejects duplicate declarations", () => {
expect(
getServicePostgresClaim("app", {
volumes: ["cache:/cache"],
} as Service),
).toBeUndefined();
expect(isPostgresVolumeEntry("cache:/cache")).toBe(false);
expect(() =>
getServicePostgresClaim("app", {
volumes: ["postgresql:one", "postgresql:two"],
} as Service),
).toThrow("declares multiple postgres volumes");
});
test("allows one owner to share a database", () => {
const compose = {
services: {
app: { volumes: ["postgresql:user/database"] },
worker: { volumes: ["postgresql:user/database"] },
},
} as ComposeSpecification;
expect(getComposePostgresClaims(compose)).toHaveLength(2);
});
test("rejects conflicting database owners", () => {
const compose = {
services: {
app: { volumes: ["postgresql:one/shared"] },
worker: { volumes: ["postgresql:two/shared"] },
},
} as ComposeSpecification;
expect(() => getComposePostgresClaims(compose)).toThrow(
"Database shared is claimed by both one and two",
);
});
test("escapes every connection URL component", () => {
expect(
buildDatabaseUrl(
{
service: "app",
username: "user@host",
database: "my/database",
secretName: "postgres-user",
},
{ username: "user@host", password: "p:a/ss?#" },
),
).toBe(
"postgresql://user%40host:p%3Aa%2Fss%3F%[email protected]:5432/my%2Fdatabase",
);
});
test("injects Redis into services with managed PostgreSQL", () => {
expect(
buildPostgresEnvironment(
{
service: "app",
username: "app",
database: "app",
secretName: "postgres-app",
},
{ username: "app", password: "secret" },
),
).toEqual({
DATABASE_URL:
"postgresql://app:[email protected]:5432/app",
REDIS_URL: "redis://redis.database.svc.cluster.local",
});
});
test("reconciles a selected claim before returning credentials", async () => {
const claim = {
service: "app",
username: "app",
database: "app",
secretName: "postgres-app",
};
spyOn(objectApi, "read").mockImplementation(async (resource) => {
if (resource.kind === "Secret") {
return {
...resource,
data: {
username: Buffer.from("app").toString("base64"),
password: Buffer.from("secret").toString("base64"),
},
} as never;
}
return { ...resource, spec: { managed: { roles: [] } } } as never;
});
const patch = spyOn(objectApi, "patch").mockImplementation(
async (resource) => resource as never,
);
expect(await reconcilePostgresClaim("project", claim)).toEqual({
username: "app",
password: "secret",
});
expect(patch.mock.calls.map(([resource]) => resource.kind)).toEqual([
"Secret",
"Cluster",
"Database",
]);
});
test("reports database apply phase and claim without exposing provider credentials", async () => {
const claim = {
service: "app",
username: "app_role",
database: "app_db",
secretName: "postgres-app_role",
};
spyOn(objectApi, "read").mockImplementation(async (resource) => {
if (resource.kind === "Secret") {
return { ...resource, data: { password: Buffer.from("private-value").toString("base64") } } as never;
}
return { ...resource, spec: { managed: { roles: [] } } } as never;
});
spyOn(objectApi, "patch").mockImplementation(async (resource) => {
if (resource.kind === "Database") {
throw Object.assign(new Error("Forbidden: password=private-value"), { code: 403 });
}
return resource as never;
});
let failure: unknown;
try {
await reconcilePostgresClaim("project", claim);
} catch (error) {
failure = error;
}
expect(failure).toBeInstanceOf(DatabaseReconciliationError);
expect((failure as Error).message).toBe(
"Database reconciliation failed during database apply for database app_db (service app, role app_role): Forbidden (HTTP 403)",
);
expect((failure as Error).message).not.toContain("private-value");
});
test("reconciles a CNPG cluster returned with managed fields without sending them back", async () => {
const claim = {
service: "app",
username: "app",
database: "app",
secretName: "postgres-app",
};
spyOn(objectApi, "read").mockImplementation(async (resource) => {
if (resource.kind === "Secret") {
return {
...resource,
data: {
username: Buffer.from("app").toString("base64"),
password: Buffer.from("secret").toString("base64"),
},
} as never;
}
return {
...resource,
metadata: {
...resource.metadata,
managedFields: [{ manager: "cloudnative-pg" }],
resourceVersion: "42",
uid: "cluster-uid",
creationTimestamp: "2026-09-08T00:00:00Z",
generation: 3,
},
spec: { managed: { roles: [{ name: "existing" }] } },
status: { phase: "Ready" },
} as never;
});
const patch = spyOn(objectApi, "patch").mockImplementation(
async (resource) => {
if (resource.metadata?.managedFields)
throw new Error("metadata.managedFields must be nil");
return resource as never;
},
);
await reconcilePostgresClaim("project", claim);
const cluster = patch.mock.calls.find(
([resource]) => resource.kind === "Cluster",
)?.[0];
expect(cluster).toMatchObject({
metadata: { name: "postgres", namespace: "database" },
spec: {
managed: { roles: [{ name: "existing" }, { name: "app" }] },
},
});
expect(cluster?.metadata).not.toHaveProperty("managedFields");
expect(cluster?.metadata).not.toHaveProperty("resourceVersion");
expect(cluster).not.toHaveProperty("status");
});
});