398 lines
16 KiB
TypeScript
398 lines
16 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,
|
|
reconcilePostgresClaims,
|
|
} 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 === "Database") throw { code: 404 };
|
|
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,
|
|
);
|
|
const create = spyOn(objectApi, "create").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",
|
|
]);
|
|
expect(create.mock.calls.map(([resource]) => resource.kind)).toEqual(["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 === "Database") throw { code: 404 };
|
|
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) => resource as never);
|
|
spyOn(objectApi, "create").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 requested database claim: Forbidden (HTTP 403)",
|
|
);
|
|
expect((failure as Error).message).not.toContain("private-value");
|
|
});
|
|
|
|
test.each(["read", "patch"])("distinguishes role secret %s failure", async (method) => {
|
|
const secretLike = "DB_PASSWORD_private123";
|
|
spyOn(objectApi, "read").mockImplementation(async (resource) => {
|
|
if (resource.kind === "Database") throw { code: 404 };
|
|
if (method === "read") throw new Error(`token=${secretLike}`);
|
|
return undefined as never;
|
|
});
|
|
spyOn(objectApi, "patch").mockImplementation(async () => {
|
|
throw new Error(`token=${secretLike}`);
|
|
});
|
|
let failure: unknown;
|
|
try {
|
|
await reconcilePostgresClaim("project", {
|
|
service: secretLike, username: secretLike, database: secretLike, secretName: `postgres-${secretLike}`,
|
|
});
|
|
} catch (error) { failure = error; }
|
|
expect(failure).toBeInstanceOf(DatabaseReconciliationError);
|
|
expect((failure as DatabaseReconciliationError).phase).toBe(`role secret ${method === "read" ? "lookup" : "apply"}`);
|
|
expect((failure as Error).message).not.toContain(secretLike);
|
|
});
|
|
|
|
test("uses safe provider status and reason without exposing request bodies or unsafe claim identifiers", () => {
|
|
const cause = Object.assign(new Error("request body DATABASE_URL=postgresql://admin:[email protected]/app"), {
|
|
statusCode: 422,
|
|
body: { reason: "Invalid", code: 422, message: "token=private" },
|
|
});
|
|
const failure = new DatabaseReconciliationError("managed role update", cause, {
|
|
service: "web", database: "postgresql://admin:[email protected]/app", username: "web_role", secretName: "secret",
|
|
});
|
|
expect(failure.message).toBe(
|
|
"Database reconciliation failed during managed role update for requested database claim: Invalid (HTTP 422)",
|
|
);
|
|
expect(failure.cause).toBe(cause);
|
|
expect(failure.message).not.toContain("private");
|
|
});
|
|
|
|
test("rejects an unrecognized phase containing a syntactically valid secret-like name", () => {
|
|
const failure = new DatabaseReconciliationError("DB_PASSWORD_private123", new Error("private"));
|
|
expect(failure.phase).toBe("operation execution");
|
|
expect(failure.message).not.toContain("DB_PASSWORD_private123");
|
|
});
|
|
|
|
test("identifies failure while preparing malformed managed roles before a Cluster write", async () => {
|
|
spyOn(objectApi, "read").mockImplementation(async (resource) => resource.kind === "Secret"
|
|
? { ...resource, data: { password: Buffer.from("private").toString("base64") } } as never
|
|
: resource.kind === "Database" ? Promise.reject({ code: 404 }) : { ...resource, spec: { managed: { roles: {} } } } as never);
|
|
const patch = spyOn(objectApi, "patch").mockImplementation(async (resource) => resource as never);
|
|
let failure: unknown;
|
|
try {
|
|
await reconcilePostgresClaim("project", {
|
|
service: "DB_PASSWORD_private123", username: "role", database: "db", secretName: "postgres-role",
|
|
});
|
|
} catch (error) { failure = error; }
|
|
expect(failure).toBeInstanceOf(DatabaseReconciliationError);
|
|
expect((failure as DatabaseReconciliationError).phase).toBe("managed role preparation");
|
|
expect((failure as Error).message).not.toContain("DB_PASSWORD_private123");
|
|
expect(patch.mock.calls.map(([resource]) => resource.kind)).toEqual(["Secret"]);
|
|
});
|
|
|
|
test("reports malformed claims as claim discovery failures without echoing compose input", async () => {
|
|
await expect(reconcilePostgresClaims("project", {
|
|
services: { web: { volumes: ["postgresql:user:password=private"] } },
|
|
} as ComposeSpecification)).rejects.toThrow(
|
|
"Database reconciliation failed during claim discovery: Check PostgreSQL claim declarations",
|
|
);
|
|
});
|
|
|
|
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 === "Database") throw { code: 404 };
|
|
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;
|
|
},
|
|
);
|
|
spyOn(objectApi, "create").mockImplementation(async (resource) => 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");
|
|
});
|
|
|
|
test("two projects reuse a foreign database without applying its metadata", async () => {
|
|
const claim = { service: "web", username: "sastify", database: "sastify-store", secretName: "postgres-sastify" };
|
|
const live = {
|
|
apiVersion: "postgresql.cnpg.io/v1", kind: "Database",
|
|
metadata: { name: claim.database, namespace: "database", labels: {
|
|
"kuber.dev/project": "sastify-api", "kuber.dev/workspace-uid": "foreign-uid",
|
|
} },
|
|
spec: { owner: claim.username, cluster: { name: "postgres" } },
|
|
};
|
|
const original = structuredClone(live);
|
|
spyOn(objectApi, "read").mockImplementation(async (resource) => resource.kind === "Database"
|
|
? live as never
|
|
: resource.kind === "Secret"
|
|
? { ...resource, data: { password: Buffer.from("shared-password").toString("base64") } } as never
|
|
: { ...resource, spec: { managed: { roles: [] } } } as never);
|
|
const patch = spyOn(objectApi, "patch").mockImplementation(async (resource) => resource as never);
|
|
const create = spyOn(objectApi, "create").mockImplementation(async (resource) => resource as never);
|
|
const compose = { services: { web: { volumes: ["postgresql:sastify/sastify-store"] } } } as ComposeSpecification;
|
|
for (const project of ["sastify-api", "another-project"]) {
|
|
const env = await reconcilePostgresClaims(project, compose);
|
|
expect(env.web?.DATABASE_URL).toBe("postgresql://sastify:[email protected]:5432/sastify-store");
|
|
}
|
|
expect(live).toEqual(original);
|
|
expect(patch.mock.calls.map(([resource]) => resource.kind)).toEqual(["Secret", "Cluster", "Secret", "Cluster"]);
|
|
expect(create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("validates all databases in a compose file before writing the first role", async () => {
|
|
const read = spyOn(objectApi, "read").mockImplementation(async (resource) => {
|
|
if (resource.kind === "Database" && resource.metadata?.name === "first") throw { code: 404 };
|
|
if (resource.kind === "Database") return { ...resource, spec: {
|
|
owner: "another-role", cluster: { name: "postgres" },
|
|
} } as never;
|
|
throw new Error("Role lookup must not run");
|
|
});
|
|
const patch = spyOn(objectApi, "patch").mockImplementation(async (resource) => resource as never);
|
|
const create = spyOn(objectApi, "create").mockImplementation(async (resource) => resource as never);
|
|
await expect(reconcilePostgresClaims("project", { services: {
|
|
first: { volumes: ["postgresql:first"] },
|
|
second: { volumes: ["postgresql:second"] },
|
|
} } as ComposeSpecification)).rejects.toMatchObject({ phase: "database ownership" });
|
|
expect(read.mock.calls.map(([resource]) => resource.kind)).toEqual(["Database", "Database"]);
|
|
expect(patch).not.toHaveBeenCalled();
|
|
expect(create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test.each([
|
|
["wrong role", "other", "postgres"],
|
|
["wrong cluster", "sastify", "other"],
|
|
])("rejects existing database with %s before any writes", async (_case, owner, cluster) => {
|
|
const claim = { service: "web", username: "sastify", database: "sastify-store", secretName: "postgres-sastify" };
|
|
spyOn(objectApi, "read").mockImplementation(async (resource) => resource.kind === "Database"
|
|
? { ...resource, spec: { owner, cluster: { name: cluster } } } as never
|
|
: undefined as never);
|
|
const patch = spyOn(objectApi, "patch").mockImplementation(async (resource) => resource as never);
|
|
const create = spyOn(objectApi, "create").mockImplementation(async (resource) => resource as never);
|
|
await expect(reconcilePostgresClaim("project", claim)).rejects.toMatchObject({ phase: "database ownership" });
|
|
expect(patch).not.toHaveBeenCalled();
|
|
expect(create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("creates missing databases only once and fails closed on create races", async () => {
|
|
spyOn(objectApi, "read").mockImplementation(async (resource) => {
|
|
if (resource.kind === "Database") throw { code: 404 };
|
|
return { ...resource, spec: { managed: { roles: [] } } } as never;
|
|
});
|
|
spyOn(objectApi, "patch").mockImplementation(async (resource) => resource as never);
|
|
const create = spyOn(objectApi, "create").mockImplementation(async () => {
|
|
throw Object.assign(new Error("Conflict"), { code: 409 });
|
|
});
|
|
await expect(reconcilePostgresClaims("project", { services: {
|
|
web: { volumes: ["postgresql:sastify/sastify-store"] },
|
|
worker: { volumes: ["postgresql:sastify/sastify-store"] },
|
|
} } as ComposeSpecification)).rejects.toMatchObject({ phase: "database apply" });
|
|
expect(create).toHaveBeenCalledTimes(1);
|
|
expect(create.mock.calls[0]?.[0]).toMatchObject({
|
|
metadata: { name: "sastify-store", labels: { "kuber.dev/project": "project" } },
|
|
spec: { owner: "sastify", cluster: { name: "postgres" } },
|
|
});
|
|
});
|
|
});
|