220 lines
9.2 KiB
TypeScript
220 lines
9.2 KiB
TypeScript
import { readFile } from "node:fs/promises";
|
|
|
|
import { describe, expect, it } from "vitest";
|
|
|
|
async function repositoryFile(path: string): Promise<string> {
|
|
return readFile(new URL(`../${path}`, import.meta.url), "utf8");
|
|
}
|
|
|
|
describe("production deployment contract", () => {
|
|
it("defines a resilient two-replica web workload", async () => {
|
|
const deployment = await repositoryFile("k8s/base/deployment.yaml");
|
|
|
|
expect(deployment).toContain("replicas: 2");
|
|
expect(deployment).toContain("maxSurge: 1");
|
|
expect(deployment).toContain("maxUnavailable: 0");
|
|
expect(deployment).toContain("path: /api/health?ready=1");
|
|
expect(deployment).toContain("path: /api/health");
|
|
expect(deployment).toContain("kubernetes.io/arch: arm64");
|
|
expect(deployment).toMatch(
|
|
/requests:\s+cpu: 500m\s+memory: 1Gi\s+limits:\s+cpu: "1"\s+memory: 2Gi/u,
|
|
);
|
|
expect(deployment).toContain("runAsNonRoot: true");
|
|
expect(deployment).toContain("runAsUser: 1000");
|
|
expect(deployment).toContain("runAsGroup: 1000");
|
|
expect(deployment).toContain("readOnlyRootFilesystem: true");
|
|
expect(deployment).toContain('drop: ["ALL"]');
|
|
});
|
|
|
|
it("exposes only the app through the requested edge-TLS host", async () => {
|
|
const [service, ingress] = await Promise.all([
|
|
repositoryFile("k8s/base/service.yaml"),
|
|
repositoryFile("k8s/base/ingress.yaml"),
|
|
]);
|
|
|
|
expect(service).toContain("type: ClusterIP");
|
|
expect(service).toContain("port: 3000");
|
|
expect(service).toContain("targetPort: http");
|
|
expect(ingress).toContain("ingressClassName: traefik");
|
|
expect(ingress).toContain("host: guide.sudloh.com");
|
|
expect(ingress).toContain(
|
|
'traefik.ingress.kubernetes.io/read-timeout: "200"',
|
|
);
|
|
expect(ingress).not.toContain("secretName:");
|
|
expect(ingress).not.toContain("router.tls");
|
|
});
|
|
|
|
it("keeps availability and scaling bounds explicit", async () => {
|
|
const [hpa, pdb] = await Promise.all([
|
|
repositoryFile("k8s/base/hpa.yaml"),
|
|
repositoryFile("k8s/base/pdb.yaml"),
|
|
]);
|
|
|
|
expect(hpa).toContain("minReplicas: 2");
|
|
expect(hpa).toContain("maxReplicas: 6");
|
|
expect(hpa).toContain("averageUtilization: 70");
|
|
expect(hpa).toContain("name: memory");
|
|
expect(hpa).toContain("averageUtilization: 75");
|
|
expect(pdb).toContain("minAvailable: 1");
|
|
});
|
|
|
|
it("uses externally supplied secrets and does not provision data stores", async () => {
|
|
const manifestPaths = [
|
|
"k8s/base/namespace.yaml",
|
|
"k8s/base/configmap.yaml",
|
|
"k8s/base/deployment.yaml",
|
|
"k8s/base/worker-deployment.yaml",
|
|
"k8s/base/discord-worker-deployment.yaml",
|
|
"k8s/base/service.yaml",
|
|
"k8s/base/ingress.yaml",
|
|
"k8s/base/hpa.yaml",
|
|
"k8s/base/pdb.yaml",
|
|
"k8s/base/ci-rbac.yaml",
|
|
"k8s/base/network-policy.yaml",
|
|
"k8s/migration/job.yaml",
|
|
];
|
|
const manifests = (
|
|
await Promise.all(manifestPaths.map(repositoryFile))
|
|
).join("\n---\n");
|
|
|
|
expect(manifests).toContain("name: buzz-sheet-env");
|
|
expect(manifests).not.toMatch(/kind: (Secret|StatefulSet|PersistentVolumeClaim)/u);
|
|
expect(manifests).not.toContain("resources: [\"secrets\"]");
|
|
expect(manifests).not.toContain("kind: ClusterRole");
|
|
expect(manifests).not.toContain("kind: ClusterRoleBinding");
|
|
expect(manifests).toContain("kind: NetworkPolicy");
|
|
expect(manifests).toContain("key: DATABASE_URL");
|
|
expect(manifests).not.toContain("secretRef:");
|
|
});
|
|
|
|
it("packages non-root Bun application and migration targets", async () => {
|
|
const dockerfile = await repositoryFile("Dockerfile");
|
|
|
|
expect(dockerfile).toContain("FROM ${BUN_IMAGE} AS migration");
|
|
expect(dockerfile).toContain("FROM ${BUN_IMAGE} AS app");
|
|
expect(dockerfile).toMatch(/ARG BUN_IMAGE=oven\/bun:1\.3\.14-alpine@sha256:[a-f0-9]{64}/u);
|
|
expect(dockerfile.match(/^USER 1000:1000$/gmu)).toHaveLength(2);
|
|
expect(dockerfile).toContain('ENTRYPOINT ["bun", "migrate.js"]');
|
|
expect(dockerfile).toContain('CMD ["bun", "server.js"]');
|
|
expect(dockerfile).toContain("ARG BASE_URL");
|
|
expect(dockerfile).toContain("ENV BASE_URL=${BASE_URL}");
|
|
expect(dockerfile).toContain("ARG NEXT_PUBLIC_GOOGLE_ADSENSE_CLIENT_ID");
|
|
expect(dockerfile).toContain(
|
|
"ENV NEXT_PUBLIC_GOOGLE_ADSENSE_CLIENT_ID=${NEXT_PUBLIC_GOOGLE_ADSENSE_CLIENT_ID}",
|
|
);
|
|
expect(dockerfile).toContain("backend/discord.ts");
|
|
expect(dockerfile).toContain("./worker/discord.js");
|
|
});
|
|
|
|
it("verifies first, publishes immutable images, and migrates before rollout", async () => {
|
|
const workflow = await repositoryFile(".gitea/workflows/ci.yml");
|
|
|
|
expect(workflow).toContain("bun install --frozen-lockfile");
|
|
expect(workflow).toContain("bun run test");
|
|
expect(workflow).toContain("bun run typecheck");
|
|
expect(workflow).toContain("bun run lint");
|
|
expect(workflow).toContain("kubectl kustomize k8s/");
|
|
expect(workflow).toContain("needs: verify");
|
|
expect(workflow).not.toContain("pull_request:");
|
|
expect(workflow).toContain("security:audit");
|
|
expect(workflow).toMatch(/gitleaks@sha256:[a-f0-9]{64}/u);
|
|
expect(workflow).not.toContain("insecure-skip-tls-verify=true");
|
|
expect(workflow).toContain("--target app");
|
|
expect(workflow).toContain('--build-arg BASE_URL="$BASE_URL"');
|
|
expect(workflow).toContain(
|
|
'--build-arg NEXT_PUBLIC_GOOGLE_ADSENSE_CLIENT_ID="$NEXT_PUBLIC_GOOGLE_ADSENSE_CLIENT_ID"',
|
|
);
|
|
expect(workflow).toContain('\\"BASE_URL\\":\\"$BASE_URL\\"');
|
|
expect(workflow).toContain("--target migration");
|
|
expect(workflow).not.toContain("platforms: linux/arm64");
|
|
expect(workflow).not.toMatch(/docker\/setup-qemu-action|docker\/setup-buildx-action|docker\/login-action/iu);
|
|
expect(workflow).toContain(
|
|
"registry.neko-piranha.ts.net/astral/buzz-sheet",
|
|
);
|
|
expect(workflow).toContain("migrate-$REVISION");
|
|
expect(workflow).toContain("deployment/buzz-sheet-discord-worker");
|
|
const deleteMigrationJob = workflow.indexOf(
|
|
'delete job buzz-sheet-migrate --ignore-not-found',
|
|
);
|
|
const createMigrationJob = workflow.indexOf(
|
|
'create -f "$migration_manifest"',
|
|
);
|
|
expect(deleteMigrationJob).toBeGreaterThan(-1);
|
|
expect(deleteMigrationJob).toBeLessThan(createMigrationJob);
|
|
expect(workflow.indexOf("condition=complete")).toBeLessThan(
|
|
workflow.indexOf("set image"),
|
|
);
|
|
expect(workflow).not.toMatch(/playwright|chromium/iu);
|
|
});
|
|
|
|
it("mounts the deployment stream and offers a full reload for new releases", async () => {
|
|
const [layout, notifier, route] = await Promise.all([
|
|
repositoryFile("app/layout.tsx"),
|
|
repositoryFile("components/deployment-update-notifier.tsx"),
|
|
repositoryFile("app/api/active/route.ts"),
|
|
]);
|
|
|
|
expect(layout).toContain("<DeploymentUpdateNotifier");
|
|
expect(notifier).toContain('new EventSource("/api/active")');
|
|
expect(notifier).toContain("window.location.reload()");
|
|
expect(notifier).toContain('label: "รีโหลด"');
|
|
expect(route).toContain('channel.sendNamed("deployment", deploymentId)');
|
|
});
|
|
});
|
|
|
|
describe("environment template contract", () => {
|
|
it("documents every externally supplied production secret", async () => {
|
|
const environmentExample = await repositoryFile(".env.example");
|
|
const requiredKeys = [
|
|
"DATABASE_URL",
|
|
"BETTER_AUTH_URL",
|
|
"BETTER_AUTH_SECRET",
|
|
"REDIS_URL",
|
|
"S3_ENDPOINT",
|
|
"S3_BUCKET",
|
|
"S3_ACCESS_KEY_ID",
|
|
"S3_SECRET_ACCESS_KEY",
|
|
"NEXT_SERVER_ACTIONS_ENCRYPTION_KEY",
|
|
"NEXT_DEPLOYMENT_ID",
|
|
"DISCORD_BOT_TOKEN",
|
|
"DISCORD_CHANNEL_ID",
|
|
"DISCORD_LOG_CHANNEL_ID",
|
|
];
|
|
|
|
for (const key of requiredKeys) {
|
|
expect(environmentExample).toMatch(new RegExp(`^${key}=`, "mu"));
|
|
}
|
|
expect(environmentExample).not.toContain("BUZZ_DEMO_MODE");
|
|
expect(environmentExample).toContain(
|
|
"NEXT_PUBLIC_GOOGLE_ADSENSE_CLIENT_ID=ca-pub-9687404323559597",
|
|
);
|
|
});
|
|
|
|
it("runs Discord catalog sync as an isolated worker", async () => {
|
|
const [deployment, rbac] = await Promise.all([
|
|
repositoryFile("k8s/base/discord-worker-deployment.yaml"),
|
|
repositoryFile("k8s/base/ci-rbac.yaml"),
|
|
]);
|
|
|
|
expect(deployment).toContain("name: buzz-sheet-discord-worker");
|
|
expect(deployment).toContain("type: Recreate");
|
|
expect(deployment).toContain('command: ["bun", "worker/discord.js"]');
|
|
expect(deployment).toContain("replicas: 1");
|
|
expect(deployment).toContain("readOnlyRootFilesystem: true");
|
|
expect(deployment).toContain('drop: ["ALL"]');
|
|
expect(rbac).toContain("buzz-sheet-discord-worker");
|
|
});
|
|
|
|
it("checks Lunaris for every new message in the configured Discord channel", async () => {
|
|
const worker = await repositoryFile("backend/discord.ts");
|
|
|
|
expect(worker).toContain("client.on(Events.MessageCreate, handleMessage)");
|
|
expect(worker).toContain("if (message.channelId !== channelId) return;");
|
|
expect(worker).not.toContain("message.author.id === client.user?.id");
|
|
expect(worker).not.toContain("parseLunarisVersionChange");
|
|
expect(worker).toContain("const details = { version, attempt: attempt + 1, messageUrl };");
|
|
expect(worker).not.toContain("channel.messages.fetch");
|
|
expect(worker).not.toContain('source: "history"');
|
|
});
|
|
});
|