286 lines
8.7 KiB
TypeScript
286 lines
8.7 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import {
|
|
chmodSync,
|
|
existsSync,
|
|
mkdirSync,
|
|
mkdtempSync,
|
|
readFileSync,
|
|
rmSync,
|
|
writeFileSync,
|
|
} from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { spawnSync } from "node:child_process";
|
|
import test from "node:test";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
test("health endpoint exposes deployment identity for production verification", () => {
|
|
const source = readFileSync(
|
|
new URL("../src/app/api/health/route.ts", import.meta.url),
|
|
"utf8",
|
|
);
|
|
|
|
assert.match(source, /deployment:/);
|
|
assert.match(source, /GITHUB_SHA/);
|
|
assert.match(source, /VERCEL_GIT_COMMIT_SHA/);
|
|
assert.match(source, /gitCommit/);
|
|
});
|
|
|
|
test("manual production deployment passes the selected revision into the web runtime", () => {
|
|
const compose = readFileSync(
|
|
new URL("../../deploy/docker-compose.server.yml", import.meta.url),
|
|
"utf8",
|
|
);
|
|
const workflow = readFileSync(
|
|
new URL("../../.github/workflows/deploy-production.yml", import.meta.url),
|
|
"utf8",
|
|
);
|
|
|
|
assert.match(compose, /GITHUB_SHA: \$\{GITHUB_SHA\}/);
|
|
assert.match(workflow, /workflow_dispatch:/);
|
|
assert.doesNotMatch(workflow, /workflow_run:/);
|
|
assert.match(workflow, /DEPLOY_GIT_SHA: \$\{\{ github\.sha \}\}/);
|
|
assert.match(workflow, /GITHUB_SHA='\$DEPLOY_GIT_SHA'/);
|
|
assert.match(workflow, /get\("deployment", \{\}\)\.get\("gitCommit"/);
|
|
assert.match(workflow, /DEPLOY_GIT_SHA/);
|
|
assert.match(workflow, /Production revision did not converge/);
|
|
assert.match(workflow, /git ls-remote origin refs\/heads\/main/);
|
|
assert.match(workflow, /steps\.revision\.outputs\.deploy == 'true'/);
|
|
});
|
|
|
|
test("server compose accepts staging paths while preserving production defaults", () => {
|
|
const compose = readFileSync(
|
|
new URL("../../deploy/docker-compose.server.yml", import.meta.url),
|
|
"utf8",
|
|
);
|
|
|
|
assert.match(
|
|
compose,
|
|
/env_file:\s*\n\s*- \$\{APP_ENV_FILE:-\.\.\/\.env\.production\}/,
|
|
);
|
|
assert.match(
|
|
compose,
|
|
/\$\{CADDYFILE_PATH:-\.\/Caddyfile\}:\/etc\/caddy\/Caddyfile:ro/,
|
|
);
|
|
assert.match(
|
|
compose,
|
|
/SITE_ADDRESS: \$\{SITE_ADDRESS:-https:\/\/jyotisha\.chat\}/,
|
|
);
|
|
});
|
|
|
|
test("staging Caddy configuration serves only the configured staging address", () => {
|
|
const caddy = readFileSync(
|
|
new URL("../../deploy/Caddyfile.staging", import.meta.url),
|
|
"utf8",
|
|
);
|
|
|
|
assert.match(caddy, /\{\$SITE_ADDRESS:https:\/\/staging\.jyotisha\.chat\}/);
|
|
assert.match(caddy, /reverse_proxy web:3000/);
|
|
assert.doesNotMatch(caddy, /www\.jyotisha\.chat/);
|
|
});
|
|
|
|
test("staging deploy consumes only the isolated staging environment and tested revision", () => {
|
|
const ci = readFileSync(
|
|
new URL("../../.github/workflows/ci.yml", import.meta.url),
|
|
"utf8",
|
|
);
|
|
const workflow = readFileSync(
|
|
new URL("../../.github/workflows/deploy-staging.yml", import.meta.url),
|
|
"utf8",
|
|
);
|
|
|
|
assert.match(ci, /push:\s*\n\s*branches: \[staging\]/);
|
|
assert.match(workflow, /workflows: \["Jyotish Skill CI"\]/);
|
|
assert.match(
|
|
workflow,
|
|
/github\.event\.workflow_run\.head_branch == 'staging'/,
|
|
);
|
|
assert.match(workflow, /actions: read/);
|
|
assert.match(workflow, /environment:\s*\n\s*name: staging/);
|
|
assert.match(workflow, /git_sha:/);
|
|
assert.doesNotMatch(workflow, /default: staging/);
|
|
assert.match(workflow, /test "\$\{#REQUESTED_SHA\}" -eq 40/);
|
|
assert.match(workflow, /actions\/workflows\/ci\.yml\/runs\?head_sha=/);
|
|
assert.match(workflow, /STAGING_SSH_PRIVATE_KEY/);
|
|
assert.match(workflow, /vars\.STAGING_HOST/);
|
|
assert.match(workflow, /vars\.STAGING_KNOWN_HOSTS/);
|
|
assert.match(workflow, /test "\$DEPLOY_HOST" = "118\.26\.111\.127"/);
|
|
assert.match(workflow, /test "\$DEPLOY_USER" = "deploy"/);
|
|
assert.match(workflow, /test "\$DEPLOY_PATH" = "\/opt\/jyotisha-staging"/);
|
|
assert.match(workflow, /--exclude='\.env\*'/);
|
|
assert.match(workflow, /docker compose --env-file \.env\.staging/);
|
|
assert.match(
|
|
workflow,
|
|
/bash deploy\/validate-staging-env\.sh \.env\.staging/,
|
|
);
|
|
assert.match(
|
|
workflow,
|
|
/docker compose --env-file \.env\.staging -f deploy\/docker-compose\.server\.yml config --quiet/,
|
|
);
|
|
assert.match(workflow, /deployment\.gitCommit/);
|
|
assert.doesNotMatch(workflow, /PRODUCTION_SSH_PRIVATE_KEY/);
|
|
assert.doesNotMatch(workflow, /103\.117\.123\.53/);
|
|
|
|
const composeLines = workflow
|
|
.split("\n")
|
|
.filter((line) => line.includes("docker compose"));
|
|
assert.equal(workflow.match(/docker compose/g)?.length, 4);
|
|
assert.equal(composeLines.length, 3);
|
|
for (const line of composeLines) {
|
|
assert.match(line, /APP_ENV_FILE='\.\.\/\.env\.staging'/);
|
|
assert.match(line, /CADDYFILE_PATH='\.\/Caddyfile\.staging'/);
|
|
assert.match(line, /SITE_ADDRESS='https:\/\/staging\.jyotisha\.chat'/);
|
|
}
|
|
});
|
|
|
|
test("staging rsync preserves every destination env variant during delete", () => {
|
|
const workflow = readFileSync(
|
|
new URL("../../.github/workflows/deploy-staging.yml", import.meta.url),
|
|
"utf8",
|
|
);
|
|
const envExclusion = workflow.match(/--exclude='([^']*\.env[^']*)'/)?.[1];
|
|
|
|
assert.equal(envExclusion, ".env*");
|
|
|
|
const root = mkdtempSync(join(tmpdir(), "jyotisha-staging-rsync-"));
|
|
const source = join(root, "source");
|
|
const destination = join(root, "destination");
|
|
mkdirSync(source);
|
|
mkdirSync(destination);
|
|
writeFileSync(join(source, "app.txt"), "new revision\n");
|
|
for (const name of [
|
|
".env",
|
|
".env.local",
|
|
".env.staging",
|
|
".env.staging.backup",
|
|
]) {
|
|
writeFileSync(join(destination, name), "preserve\n");
|
|
}
|
|
|
|
try {
|
|
const result = spawnSync(
|
|
"rsync",
|
|
[
|
|
"-a",
|
|
"--delete",
|
|
`--exclude=${envExclusion}`,
|
|
`${source}/`,
|
|
`${destination}/`,
|
|
],
|
|
{ encoding: "utf8" },
|
|
);
|
|
assert.equal(result.status, 0, result.stderr);
|
|
for (const name of [
|
|
".env",
|
|
".env.local",
|
|
".env.staging",
|
|
".env.staging.backup",
|
|
]) {
|
|
assert.equal(
|
|
existsSync(join(destination, name)),
|
|
true,
|
|
`${name} was deleted`,
|
|
);
|
|
}
|
|
} finally {
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("staging env validator rejects selector drift, duplicates, and unsafe permissions", () => {
|
|
const validator = fileURLToPath(
|
|
new URL("../../deploy/validate-staging-env.sh", import.meta.url),
|
|
);
|
|
const root = mkdtempSync(join(tmpdir(), "jyotisha-staging-env-"));
|
|
const envFile = join(root, ".env.staging");
|
|
const composeFile = join(root, "compose.yml");
|
|
const validSelectors = [
|
|
"APP_ENV_FILE=../.env.staging",
|
|
"CADDYFILE_PATH=./Caddyfile.staging",
|
|
"SITE_ADDRESS=https://staging.jyotisha.chat",
|
|
];
|
|
const run = () =>
|
|
spawnSync("bash", [validator, envFile], { encoding: "utf8" });
|
|
const writeEnv = (lines: string[], mode = 0o600) => {
|
|
writeFileSync(envFile, `${lines.join("\n")}\n`);
|
|
chmodSync(envFile, mode);
|
|
};
|
|
|
|
try {
|
|
writeFileSync(
|
|
composeFile,
|
|
[
|
|
"services:",
|
|
" probe:",
|
|
" image: alpine",
|
|
" environment:",
|
|
" SELECTED: ${APP_ENV_FILE}",
|
|
"",
|
|
].join("\n"),
|
|
);
|
|
writeEnv(validSelectors);
|
|
assert.equal(run().status, 0);
|
|
const shellOverride = spawnSync(
|
|
"docker",
|
|
[
|
|
"compose",
|
|
"--env-file",
|
|
envFile,
|
|
"-f",
|
|
composeFile,
|
|
"config",
|
|
"--format",
|
|
"json",
|
|
],
|
|
{
|
|
encoding: "utf8",
|
|
env: { ...process.env, APP_ENV_FILE: "../.env.production" },
|
|
},
|
|
);
|
|
assert.equal(shellOverride.status, 0, shellOverride.stderr);
|
|
assert.equal(
|
|
JSON.parse(shellOverride.stdout).services.probe.environment.SELECTED,
|
|
"../.env.production",
|
|
);
|
|
|
|
writeEnv(["APP_ENV_FILE=../.env.production", ...validSelectors.slice(1)]);
|
|
assert.notEqual(run().status, 0);
|
|
|
|
writeEnv([...validSelectors, "SITE_ADDRESS=https://example.invalid"]);
|
|
assert.notEqual(run().status, 0);
|
|
|
|
writeEnv([...validSelectors, "APP_ENV_FILE = ../.env.production"]);
|
|
assert.notEqual(run().status, 0);
|
|
const rendered = spawnSync(
|
|
"docker",
|
|
[
|
|
"compose",
|
|
"--env-file",
|
|
envFile,
|
|
"-f",
|
|
composeFile,
|
|
"config",
|
|
"--format",
|
|
"json",
|
|
],
|
|
{ encoding: "utf8" },
|
|
);
|
|
assert.equal(rendered.status, 0, rendered.stderr);
|
|
assert.equal(
|
|
JSON.parse(rendered.stdout).services.probe.environment.SELECTED,
|
|
"../.env.production",
|
|
);
|
|
|
|
writeEnv([...validSelectors, "export CADDYFILE_PATH=./Caddyfile"]);
|
|
assert.notEqual(run().status, 0);
|
|
|
|
writeEnv([...validSelectors, "SITE_ADDRESS"]);
|
|
assert.notEqual(run().status, 0);
|
|
|
|
writeEnv(validSelectors, 0o644);
|
|
assert.notEqual(run().status, 0);
|
|
} finally {
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|