a3ebff2afa
Task #534 — backend, infra, and tooling only. UI ships in Stage 2. Backend - New system_settings table (id=1 singleton): installed flag, base_url, local_domain, local_ip, https_mode, app_version. Pushed to dev DB. - New /api/setup/status (open) and /api/setup/{validate,complete} (gated by requireSetupOpen — 409 once installed). - completeInstall is fully transactional: pg_advisory_xact_lock serializes concurrent callers, double-gates on installed flag and admin existence, then atomically creates the admin user, assigns admin role + Admins/Everyone groups, and flips system_settings to installed=true. Rolls back on any failure. - Zod validation, bcrypt hashing, in-memory rate limiter for the setup endpoints. Backward compat - scripts/src/seed.ts now branches on installed flag + admin existence + SEED_*_PASSWORD env vars. Legacy installs (admin exists, system_settings empty) get backfilled to installed=true via ON CONFLICT DO UPDATE so they are never forced through the wizard. When env passwords are unset and no admin exists, the seed prints a wizard hint instead of seeding. Infra - docker-compose.yml: replaced nginx edge with a Caddy service that mounts ./certs and ./docker/Caddyfile. The web service no longer publishes a port directly — Caddy is the only public ingress. - docker/Caddyfile: HTTPS site for LOCAL_DOMAIN/LOCAL_IP with WebSocket upgrade preserved and a plaintext :80 fallback when HTTPS_MODE=skip (dev-only). - .env.example: added LOCAL_DOMAIN, LOCAL_IP, BASE_URL, HTTP_PORT, HTTPS_PORT, HTTPS_MODE; SEED_*_PASSWORD now optional. Tooling - scripts/local-setup.sh: idempotent OS-aware bootstrap (.env upsert, mkcert hint, cert SAN check, dry-run via LOCAL_SETUP_DRY_RUN). start.sh untouched. Tests - artifacts/api-server/tests/setup-wizard.test.mjs: 7/7 pass (snapshot/restore admin role + system_settings around tests). - scripts/tests/local-setup.test.mjs: 2/2 pass (first-run bootstrap + second-run no-op idempotency with mkcert/openssl stubs). Constraints honored: no force-push, no destructive ops, start.sh preserved, scripts idempotent, volumes/DB never touched, HTTPS skip mode dev-only, wizard does not edit LOCAL_DOMAIN/LOCAL_IP. Out of scope / not addressed: pre-existing TS errors in routes/users.ts and pre-existing failure in executive-meetings-postpone-race.test.mjs.
136 lines
4.4 KiB
JavaScript
136 lines
4.4 KiB
JavaScript
// Smoke test for scripts/local-setup.sh — verifies idempotency and that
|
|
// the .env upserts preserve unrelated keys. Uses a stubbed `mkcert` on
|
|
// the runner so no real CA is touched.
|
|
import { test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { mkdtempSync, writeFileSync, readFileSync, copyFileSync, mkdirSync, existsSync, chmodSync, cpSync, rmSync } from "node:fs";
|
|
import { spawnSync } from "node:child_process";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
|
|
function runLocalSetup(projectDir, extraEnv = {}) {
|
|
const stubDir = path.join(projectDir, ".stub-bin");
|
|
mkdirSync(stubDir, { recursive: true });
|
|
|
|
// mkcert stub: writes minimal PEM-looking files so the script's
|
|
// existence check is satisfied. Also implements `-CAROOT`.
|
|
const mkcertStub = `#!/usr/bin/env bash
|
|
set -e
|
|
if [ "$1" = "-CAROOT" ]; then
|
|
echo "/tmp/fake-caroot"
|
|
exit 0
|
|
fi
|
|
cert=""; key=""
|
|
positional=()
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
-cert-file) cert="$2"; shift 2;;
|
|
-key-file) key="$2"; shift 2;;
|
|
*) positional+=("$1"); shift;;
|
|
esac
|
|
done
|
|
san=""
|
|
for s in "\${positional[@]}"; do
|
|
if [[ "$s" =~ ^[0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+$ ]]; then
|
|
if [ -z "$san" ]; then san="IP:$s"; else san="$san,IP:$s"; fi
|
|
else
|
|
if [ -z "$san" ]; then san="DNS:$s"; else san="$san,DNS:$s"; fi
|
|
fi
|
|
done
|
|
openssl req -x509 -newkey rsa:2048 -nodes -days 1 \\
|
|
-subj "/CN=local-test" \\
|
|
-addext "subjectAltName=$san" \\
|
|
-keyout "$key" -out "$cert" >/dev/null 2>&1
|
|
`;
|
|
const stubPath = path.join(stubDir, "mkcert");
|
|
writeFileSync(stubPath, mkcertStub);
|
|
chmodSync(stubPath, 0o755);
|
|
|
|
return spawnSync("bash", [path.join(projectDir, "scripts", "local-setup.sh")], {
|
|
cwd: projectDir,
|
|
env: {
|
|
...process.env,
|
|
PATH: `${stubDir}:${process.env.PATH}`,
|
|
LOCAL_SETUP_DRY_RUN: "1",
|
|
LOCAL_SETUP_NONINTERACTIVE: "1",
|
|
OSTYPE: "linux-gnu",
|
|
...extraEnv,
|
|
},
|
|
encoding: "utf8",
|
|
});
|
|
}
|
|
|
|
function makeFakeProject() {
|
|
const dir = mkdtempSync(path.join(tmpdir(), "tx-localsetup-"));
|
|
mkdirSync(path.join(dir, "scripts"), { recursive: true });
|
|
// Copy the script under test verbatim.
|
|
copyFileSync(
|
|
path.resolve("scripts/local-setup.sh"),
|
|
path.join(dir, "scripts", "local-setup.sh"),
|
|
);
|
|
chmodSync(path.join(dir, "scripts", "local-setup.sh"), 0o755);
|
|
|
|
// Minimal .env.example so bootstrap works.
|
|
writeFileSync(
|
|
path.join(dir, ".env.example"),
|
|
[
|
|
"SESSION_SECRET=change-me",
|
|
"POSTGRES_PASSWORD=change-me",
|
|
"SOME_USER_KEY=preserve-me",
|
|
"LOCAL_DOMAIN=tx.local",
|
|
"LOCAL_IP=127.0.0.1",
|
|
"BASE_URL=https://tx.local",
|
|
"PUBLIC_BASE_URL=https://tx.local",
|
|
"ALLOWED_ORIGINS=https://tx.local",
|
|
"HTTPS_MODE=local",
|
|
"",
|
|
].join("\n"),
|
|
);
|
|
return dir;
|
|
}
|
|
|
|
test("first run bootstraps .env and writes a cert", () => {
|
|
const dir = makeFakeProject();
|
|
try {
|
|
const r = runLocalSetup(dir);
|
|
assert.equal(r.status, 0, r.stderr || r.stdout);
|
|
const env = readFileSync(path.join(dir, ".env"), "utf8");
|
|
assert.match(env, /^LOCAL_DOMAIN=tx\.local$/m);
|
|
assert.match(env, /^BASE_URL=https:\/\/tx\.local$/m);
|
|
assert.match(env, /^PUBLIC_BASE_URL=https:\/\/tx\.local$/m);
|
|
assert.match(env, /^SOME_USER_KEY=preserve-me$/m);
|
|
assert.ok(existsSync(path.join(dir, "certs", "local-cert.pem")));
|
|
assert.ok(existsSync(path.join(dir, "certs", "local-key.pem")));
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("second run is a no-op for cert generation and preserves user edits", () => {
|
|
const dir = makeFakeProject();
|
|
try {
|
|
const first = runLocalSetup(dir);
|
|
assert.equal(first.status, 0, first.stderr || first.stdout);
|
|
|
|
// Operator edits an unrelated key.
|
|
let env = readFileSync(path.join(dir, ".env"), "utf8");
|
|
env = env.replace(/^SOME_USER_KEY=.*$/m, "SOME_USER_KEY=user-edited");
|
|
writeFileSync(path.join(dir, ".env"), env);
|
|
|
|
// Capture cert mtime to verify second run didn't rewrite it.
|
|
const certPath = path.join(dir, "certs", "local-cert.pem");
|
|
const before = readFileSync(certPath);
|
|
|
|
const second = runLocalSetup(dir);
|
|
assert.equal(second.status, 0, second.stderr || second.stdout);
|
|
|
|
const after = readFileSync(certPath);
|
|
assert.deepEqual(before, after, "cert was rewritten on idempotent re-run");
|
|
|
|
const env2 = readFileSync(path.join(dir, ".env"), "utf8");
|
|
assert.match(env2, /^SOME_USER_KEY=user-edited$/m);
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|