179 lines
7.9 KiB
TypeScript
179 lines
7.9 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { readFileSync } from "node:fs";
|
|
import test from "node:test";
|
|
import { GET as healthGet } from "../src/app/api/health/route.ts";
|
|
|
|
function serviceBlock(compose: string, service: string) {
|
|
const match = compose.match(new RegExp(`^ ${service}:\\n([\\s\\S]*?)(?=^ [a-z][a-z0-9_-]*:|^volumes:)`, "m"));
|
|
assert.ok(match, `expected ${service} service in compose file`);
|
|
return match[1];
|
|
}
|
|
|
|
function webHealthcheckBlock(web: string) {
|
|
const match = web.match(/^ healthcheck:\n((?: .*\n?)*)/m);
|
|
assert.ok(match, "expected a web healthcheck in compose file");
|
|
return match[1];
|
|
}
|
|
|
|
function workflowStepBlock(workflow: string, stepName: string) {
|
|
const match = workflow.match(new RegExp(`^ - name: ${stepName}\\n([\\s\\S]*?)(?=^ - name:|(?![\\s\\S]))`, "m"));
|
|
assert.ok(match, `expected ${stepName} workflow step`);
|
|
return match[1];
|
|
}
|
|
|
|
function escapeRegExp(value: string) {
|
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
}
|
|
|
|
const testedShaExpression = "${{ github.event.workflow_run.head_sha || github.sha }}";
|
|
|
|
function assertWorkflowUsesTestedSha(workflow: string) {
|
|
const checkout = workflowStepBlock(workflow, "Checkout tested revision");
|
|
const sync = workflowStepBlock(workflow, "Sync and rebuild");
|
|
const verification = workflowStepBlock(workflow, "Verify production");
|
|
const shaExpression = escapeRegExp(testedShaExpression);
|
|
|
|
assert.match(checkout, new RegExp(`ref: ${shaExpression}`), "Checkout tested revision must check out the tested SHA");
|
|
assert.match(sync, new RegExp(`DEPLOY_GIT_SHA: ${shaExpression}`), "Sync and rebuild must use the tested SHA");
|
|
assert.match(sync, /GITHUB_SHA='\$DEPLOY_GIT_SHA'/, "Sync and rebuild must inject its SHA into the web runtime");
|
|
assert.match(verification, new RegExp(`DEPLOY_GIT_SHA: ${shaExpression}`), "Verify production must use the tested SHA");
|
|
assert.match(verification, /curl --fail --silent --show-error --retry 12 --retry-delay 5 https:\/\/jyotisha\.chat\/api\/health/);
|
|
assert.match(verification, /body\.deployment\?\.gitCommit !== process\.env\.DEPLOY_GIT_SHA/);
|
|
}
|
|
|
|
test("health endpoint exposes deployment identity for production verification", () => {
|
|
const source = [
|
|
readFileSync(new URL("../src/app/api/health/route.ts", import.meta.url), "utf8"),
|
|
readFileSync(new URL("../src/lib/conversational-rectification/creation-policy.ts", import.meta.url), "utf8"),
|
|
].join("\n");
|
|
|
|
assert.match(source, /deployment:/);
|
|
assert.match(source, /GITHUB_SHA/);
|
|
assert.match(source, /VERCEL_GIT_COMMIT_SHA/);
|
|
assert.match(source, /gitCommit/);
|
|
});
|
|
|
|
test("production traffic waits for a healthy web container and retries short replacement gaps", () => {
|
|
const compose = readFileSync(new URL("../../deploy/docker-compose.server.yml", import.meta.url), "utf8");
|
|
const caddyfile = readFileSync(new URL("../../deploy/Caddyfile", import.meta.url), "utf8");
|
|
const web = serviceBlock(compose, "web");
|
|
const caddy = serviceBlock(compose, "caddy");
|
|
const healthcheck = webHealthcheckBlock(web);
|
|
|
|
assert.match(web, /GITHUB_SHA: \$\{GITHUB_SHA\}/);
|
|
assert.match(web, /healthcheck:\n\s+test: \["CMD", "node", "-e", "fetch\('http:\/\/127\.0\.0\.1:3000\/api\/health'\)\.then\(r=>\{if\(!r\.ok\)process\.exit\(1\)\}\)"\]/);
|
|
assert.match(healthcheck, /^ interval: 30s$/m);
|
|
assert.match(healthcheck, /^ timeout: 5s$/m);
|
|
assert.match(healthcheck, /^ retries: 5$/m);
|
|
assert.match(healthcheck, /^ start_period: 30s$/m);
|
|
assert.match(healthcheck, /^ start_interval: 1s$/m);
|
|
assert.match(caddy, /web:\n\s+condition: service_healthy/);
|
|
assert.match(caddyfile, /reverse_proxy web:3000 \{\n\s+lb_try_duration 10s\n\s+lb_try_interval 250ms\n\s+\}/);
|
|
});
|
|
|
|
test("production workflow consistently uses its tested revision from checkout through verification", () => {
|
|
const workflow = readFileSync(new URL("../../.github/workflows/deploy-production.yml", import.meta.url), "utf8");
|
|
|
|
assertWorkflowUsesTestedSha(workflow);
|
|
});
|
|
|
|
test("production workflow rejects a SHA mismatch in verification", () => {
|
|
const workflow = readFileSync(new URL("../../.github/workflows/deploy-production.yml", import.meta.url), "utf8");
|
|
const verification = workflowStepBlock(workflow, "Verify production");
|
|
const mismatchedVerification = verification.replace(testedShaExpression, "${{ github.sha }}");
|
|
|
|
assert.throws(
|
|
() => assertWorkflowUsesTestedSha(workflow.replace(verification, mismatchedVerification)),
|
|
/Verify production must use the tested SHA/,
|
|
);
|
|
});
|
|
|
|
test("v3 readiness requires healthy dependencies and smoke proof for the exact full deployment SHA", async () => {
|
|
const keys = [
|
|
"GITHUB_SHA", "NEXT_PUBLIC_SUPABASE_URL", "NEXT_PUBLIC_SUPABASE_ANON_KEY",
|
|
"SUPABASE_SERVICE_ROLE_KEY", "OPENAI_API_KEY",
|
|
"RECTIFICATION_V3_CREATE_ENABLED", "RECTIFICATION_V3_MIGRATIONS_READY",
|
|
"RECTIFICATION_V3_SYNTHETIC_SMOKE_SHA", "RECTIFICATION_V3_SYNTHETIC_SMOKE_USER_IDS",
|
|
] as const;
|
|
const prior = Object.fromEntries(keys.map((key) => [key, process.env[key]]));
|
|
const originalFetch = globalThis.fetch;
|
|
const currentSha = "0123456789abcdef0123456789abcdef01234567";
|
|
const oldSha = "89abcdef0123456789abcdef0123456789abcdef";
|
|
Object.assign(process.env, {
|
|
GITHUB_SHA: currentSha,
|
|
NEXT_PUBLIC_SUPABASE_URL: "https://example.invalid",
|
|
NEXT_PUBLIC_SUPABASE_ANON_KEY: "synthetic-public-key",
|
|
SUPABASE_SERVICE_ROLE_KEY: "synthetic-service-key",
|
|
OPENAI_API_KEY: "synthetic-model-key",
|
|
RECTIFICATION_V3_CREATE_ENABLED: "true",
|
|
RECTIFICATION_V3_MIGRATIONS_READY: "true",
|
|
RECTIFICATION_V3_SYNTHETIC_SMOKE_USER_IDS: "00000000-0000-4000-8000-000000009001",
|
|
});
|
|
globalThis.fetch = async () => Response.json({ status: "ok" });
|
|
|
|
async function readiness() {
|
|
const response = await healthGet();
|
|
const body = await response.json() as {
|
|
status: string;
|
|
rollout: { conversationalRectificationV3: {
|
|
syntheticSmoke: string;
|
|
creationAudience: string;
|
|
readyForNewCases: boolean;
|
|
} };
|
|
};
|
|
return body;
|
|
}
|
|
|
|
try {
|
|
delete process.env.RECTIFICATION_V3_SYNTHETIC_SMOKE_SHA;
|
|
assert.deepEqual((await readiness()).rollout.conversationalRectificationV3, {
|
|
protocol: "conversational-evidence-v3",
|
|
newCaseCreation: "enabled",
|
|
creationAudience: "smoke_only",
|
|
migrations: "ready",
|
|
syntheticSmoke: "pending",
|
|
readyForNewCases: false,
|
|
});
|
|
|
|
process.env.GITHUB_SHA = "deadbee";
|
|
process.env.RECTIFICATION_V3_SYNTHETIC_SMOKE_SHA = "deadbee";
|
|
assert.deepEqual((await readiness()).rollout.conversationalRectificationV3, {
|
|
protocol: "conversational-evidence-v3",
|
|
newCaseCreation: "paused",
|
|
creationAudience: "paused",
|
|
migrations: "ready",
|
|
syntheticSmoke: "pending",
|
|
readyForNewCases: false,
|
|
});
|
|
|
|
process.env.GITHUB_SHA = currentSha;
|
|
process.env.RECTIFICATION_V3_SYNTHETIC_SMOKE_SHA = oldSha;
|
|
assert.equal((await readiness()).rollout.conversationalRectificationV3.creationAudience, "smoke_only");
|
|
|
|
process.env.RECTIFICATION_V3_SYNTHETIC_SMOKE_SHA = currentSha;
|
|
const ready = await readiness();
|
|
assert.equal(ready.status, "ok");
|
|
assert.deepEqual(ready.rollout.conversationalRectificationV3, {
|
|
protocol: "conversational-evidence-v3",
|
|
newCaseCreation: "enabled",
|
|
creationAudience: "public",
|
|
migrations: "ready",
|
|
syntheticSmoke: "matched",
|
|
readyForNewCases: true,
|
|
});
|
|
|
|
delete process.env.SUPABASE_SERVICE_ROLE_KEY;
|
|
const blocked = await readiness();
|
|
assert.equal(blocked.status, "blocked");
|
|
assert.equal(blocked.rollout.conversationalRectificationV3.syntheticSmoke, "matched");
|
|
assert.equal(blocked.rollout.conversationalRectificationV3.readyForNewCases, false);
|
|
} finally {
|
|
globalThis.fetch = originalFetch;
|
|
for (const key of keys) {
|
|
const value = prior[key];
|
|
if (value === undefined) delete process.env[key];
|
|
else process.env[key] = value;
|
|
}
|
|
}
|
|
});
|