mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
Output of `node scripts/strip-planning-tags.mjs --write`: 3,157 comment lines in 1,308 files lose the work-item identifier at their head. Release versions and issue references stay; nothing outside comment text changes, which the script checks per file against a comment-free printout. healthlog/no-planning-tags is enabled at error level with this commit, so the tree it lands on is clean.
335 lines
11 KiB
TypeScript
335 lines
11 KiB
TypeScript
/**
|
|
* v1.4.42 — env-check sanity tests.
|
|
*
|
|
* The script's two pure helpers (`parseEnvFile` + `checkEnv`) are
|
|
* pinned here. The renderResults / main() entrypoint is intentionally
|
|
* not unit-tested — it's a thin shell over the pure helpers and the
|
|
* exit-code behaviour is best left to a smoke test in CI (deferred to
|
|
* v1.4.43).
|
|
*/
|
|
import { readFileSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { describe, expect, it } from "vitest";
|
|
import { checkEnv, parseEnvFile } from "../check-env";
|
|
|
|
describe("parseEnvFile", () => {
|
|
it("parses simple KEY=VALUE pairs", () => {
|
|
expect(parseEnvFile("FOO=bar\nBAZ=qux")).toEqual({
|
|
FOO: "bar",
|
|
BAZ: "qux",
|
|
});
|
|
});
|
|
|
|
it("strips surrounding double quotes", () => {
|
|
expect(parseEnvFile('FOO="bar baz"')).toEqual({ FOO: "bar baz" });
|
|
});
|
|
|
|
it("strips surrounding single quotes", () => {
|
|
expect(parseEnvFile("FOO='bar baz'")).toEqual({ FOO: "bar baz" });
|
|
});
|
|
|
|
it("skips comments and blank lines", () => {
|
|
const src = `# header comment
|
|
FOO=bar
|
|
|
|
# another
|
|
BAZ=qux
|
|
`;
|
|
expect(parseEnvFile(src)).toEqual({ FOO: "bar", BAZ: "qux" });
|
|
});
|
|
|
|
it("preserves an empty value (the v1.4.40 .p8 gap pattern)", () => {
|
|
// An empty string MUST round-trip as "" so the presence-check
|
|
// downstream classifies it as missing.
|
|
expect(parseEnvFile("APNS_KEY=")).toEqual({ APNS_KEY: "" });
|
|
});
|
|
|
|
it("handles CRLF line endings (Coolify exports use \\r\\n)", () => {
|
|
expect(parseEnvFile("FOO=bar\r\nBAZ=qux")).toEqual({
|
|
FOO: "bar",
|
|
BAZ: "qux",
|
|
});
|
|
});
|
|
});
|
|
|
|
const SAMPLE_MANIFEST = {
|
|
description: "test manifest",
|
|
groups: [
|
|
{
|
|
name: "Core",
|
|
description: "required boot vars",
|
|
required: true,
|
|
variables: [
|
|
{ name: "DATABASE_URL", purpose: "db" },
|
|
{ name: "ENCRYPTION_KEY", purpose: "crypto" },
|
|
],
|
|
},
|
|
{
|
|
name: "APNs",
|
|
description: "iOS push",
|
|
required: false,
|
|
allOrNone: true,
|
|
variables: [
|
|
{ name: "APNS_KEY_ID", purpose: "key id" },
|
|
{ name: "APNS_TEAM_ID", purpose: "team id" },
|
|
{
|
|
name: "APNS_KEY",
|
|
purpose: "key body or file",
|
|
anyOf: ["APNS_KEY", "APNS_KEY_FILE"],
|
|
},
|
|
],
|
|
},
|
|
{
|
|
name: "Backups",
|
|
description: "all-or-none",
|
|
required: false,
|
|
allOrNone: true,
|
|
variables: [
|
|
{ name: "BACKUP_S3_BUCKET", purpose: "bucket" },
|
|
{ name: "BACKUP_S3_ACCESS_KEY", purpose: "key" },
|
|
],
|
|
},
|
|
],
|
|
};
|
|
|
|
describe("checkEnv — required group", () => {
|
|
it("flags every missing required var", () => {
|
|
const out = checkEnv(SAMPLE_MANIFEST, {});
|
|
const required = out.filter((r) => r.required && !r.present);
|
|
expect(required.map((r) => r.variable)).toEqual([
|
|
"DATABASE_URL",
|
|
"ENCRYPTION_KEY",
|
|
]);
|
|
});
|
|
|
|
it("classifies whitespace-only values as missing (deploy-failure prevention)", () => {
|
|
// A `DATABASE_URL=" "` would silently boot with a useless value;
|
|
// the deployer expects to see a clear MISSING-REQUIRED row here.
|
|
const out = checkEnv(SAMPLE_MANIFEST, {
|
|
DATABASE_URL: " ",
|
|
ENCRYPTION_KEY: "ok",
|
|
});
|
|
const row = out.find((r) => r.variable === "DATABASE_URL")!;
|
|
expect(row.present).toBe(false);
|
|
expect(row.required).toBe(true);
|
|
});
|
|
|
|
it("greenlights when every required var is non-empty", () => {
|
|
const out = checkEnv(SAMPLE_MANIFEST, {
|
|
DATABASE_URL: "postgresql://x",
|
|
ENCRYPTION_KEY: "deadbeef".repeat(8),
|
|
});
|
|
const required = out.filter((r) => r.required);
|
|
expect(required.every((r) => r.present)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("checkEnv — anyOf alternatives", () => {
|
|
it("treats APNS_KEY as satisfied when APNS_KEY_FILE is set instead", () => {
|
|
const out = checkEnv(SAMPLE_MANIFEST, {
|
|
DATABASE_URL: "x",
|
|
ENCRYPTION_KEY: "y",
|
|
APNS_KEY_ID: "abc",
|
|
APNS_TEAM_ID: "def",
|
|
APNS_KEY_FILE: "/secrets/apns.p8",
|
|
});
|
|
const apnsKey = out.find((r) => r.variable === "APNS_KEY")!;
|
|
expect(apnsKey.present).toBe(true);
|
|
});
|
|
|
|
it("records satisfiedBy when an anyOf alternative satisfies the row", () => {
|
|
// The label `[OK] APNS_KEY` was misleading pre-v1.4.42 — an operator
|
|
// who saw it would grep for `APNS_KEY` in the env block and find
|
|
// nothing, file a ghost issue. `satisfiedBy` lets the renderer
|
|
// surface the alternative that actually matched.
|
|
const out = checkEnv(SAMPLE_MANIFEST, {
|
|
DATABASE_URL: "x",
|
|
ENCRYPTION_KEY: "y",
|
|
APNS_KEY_ID: "abc",
|
|
APNS_TEAM_ID: "def",
|
|
APNS_KEY_FILE: "/secrets/apns.p8",
|
|
});
|
|
const apnsKey = out.find((r) => r.variable === "APNS_KEY")!;
|
|
expect(apnsKey.satisfiedBy).toBe("APNS_KEY_FILE");
|
|
});
|
|
|
|
it("leaves satisfiedBy undefined when the primary name is the one set", () => {
|
|
const out = checkEnv(SAMPLE_MANIFEST, {
|
|
DATABASE_URL: "x",
|
|
ENCRYPTION_KEY: "y",
|
|
APNS_KEY_ID: "abc",
|
|
APNS_TEAM_ID: "def",
|
|
APNS_KEY: "-----BEGIN PRIVATE KEY-----...",
|
|
});
|
|
const apnsKey = out.find((r) => r.variable === "APNS_KEY")!;
|
|
expect(apnsKey.satisfiedBy).toBeUndefined();
|
|
});
|
|
|
|
it("treats APNS_KEY as satisfied when APNS_KEY is set (the 12-factor variant)", () => {
|
|
const out = checkEnv(SAMPLE_MANIFEST, {
|
|
DATABASE_URL: "x",
|
|
ENCRYPTION_KEY: "y",
|
|
APNS_KEY_ID: "abc",
|
|
APNS_TEAM_ID: "def",
|
|
APNS_KEY: "-----BEGIN PRIVATE KEY-----...",
|
|
});
|
|
expect(out.find((r) => r.variable === "APNS_KEY")?.present).toBe(true);
|
|
});
|
|
|
|
it("marks APNS_KEY missing when BOTH alternatives are absent (the v1.4.40 AP-2 gap)", () => {
|
|
const out = checkEnv(SAMPLE_MANIFEST, {
|
|
DATABASE_URL: "x",
|
|
ENCRYPTION_KEY: "y",
|
|
APNS_KEY_ID: "abc",
|
|
APNS_TEAM_ID: "def",
|
|
});
|
|
expect(out.find((r) => r.variable === "APNS_KEY")?.present).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("checkEnv — all-or-none groups", () => {
|
|
it("emits a synthetic <all-or-none> required row when a group is half-populated", () => {
|
|
// The v1.4.40 AP-2 silent-disable pattern: 3 of 4 APNS_* set, .p8
|
|
// missing → app silently falls back to Telegram. The all-or-none
|
|
// synthetic row catches this even when the surrounding group is
|
|
// `required: false`.
|
|
const out = checkEnv(SAMPLE_MANIFEST, {
|
|
DATABASE_URL: "x",
|
|
ENCRYPTION_KEY: "y",
|
|
BACKUP_S3_BUCKET: "my-bucket",
|
|
// BACKUP_S3_ACCESS_KEY deliberately absent
|
|
});
|
|
const synth = out.find((r) => r.variable === "<all-or-none>")!;
|
|
expect(synth).toBeDefined();
|
|
expect(synth.required).toBe(true);
|
|
expect(synth.note).toContain("1/2");
|
|
});
|
|
|
|
it("does NOT emit the synthetic row when the group is fully set", () => {
|
|
const out = checkEnv(SAMPLE_MANIFEST, {
|
|
DATABASE_URL: "x",
|
|
ENCRYPTION_KEY: "y",
|
|
BACKUP_S3_BUCKET: "my-bucket",
|
|
BACKUP_S3_ACCESS_KEY: "key",
|
|
});
|
|
expect(out.find((r) => r.variable === "<all-or-none>")).toBeUndefined();
|
|
});
|
|
|
|
it("does NOT emit the synthetic row when the group is fully empty", () => {
|
|
const out = checkEnv(SAMPLE_MANIFEST, {
|
|
DATABASE_URL: "x",
|
|
ENCRYPTION_KEY: "y",
|
|
});
|
|
expect(out.find((r) => r.variable === "<all-or-none>")).toBeUndefined();
|
|
});
|
|
|
|
it("catches the v1.4.40 AP-2 scenario — 3 of 4 APNS_* set, key missing", () => {
|
|
// Direct regression pin for the gap the env-check wave was conceived
|
|
// to close. Pre-v1.4.42 the APNs group lacked `allOrNone: true` so
|
|
// this exact env-block shape exited 0 and silently disabled APNs.
|
|
const out = checkEnv(SAMPLE_MANIFEST, {
|
|
DATABASE_URL: "x",
|
|
ENCRYPTION_KEY: "y",
|
|
APNS_KEY_ID: "abc",
|
|
APNS_TEAM_ID: "def",
|
|
// APNS_KEY / APNS_KEY_FILE deliberately absent
|
|
});
|
|
const synth = out.find(
|
|
(r) => r.group === "APNs" && r.variable === "<all-or-none>",
|
|
)!;
|
|
expect(synth).toBeDefined();
|
|
expect(synth.required).toBe(true);
|
|
expect(synth.note).toContain("2/3");
|
|
});
|
|
});
|
|
|
|
describe("checkEnv — optional groups (informational)", () => {
|
|
it("reports optional vars without setting required: true", () => {
|
|
const out = checkEnv(SAMPLE_MANIFEST, {
|
|
DATABASE_URL: "x",
|
|
ENCRYPTION_KEY: "y",
|
|
});
|
|
const apnsKeyId = out.find((r) => r.variable === "APNS_KEY_ID")!;
|
|
expect(apnsKeyId.present).toBe(false);
|
|
expect(apnsKeyId.required).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("environment inventory", () => {
|
|
it("lists every optional AI runtime variable in compose, both examples, and the manifest", () => {
|
|
const repositoryRoot = join(__dirname, "../..");
|
|
const variableNames = [
|
|
"ALLOW_LOCAL_AI_PRIVATE_HOSTS",
|
|
"INSIGHTS_MAX_TOKENS",
|
|
];
|
|
const compose = readFileSync(
|
|
join(repositoryRoot, "docker-compose.yml"),
|
|
"utf8",
|
|
);
|
|
const exampleEnvironments = [
|
|
readFileSync(join(repositoryRoot, ".env.example"), "utf8"),
|
|
readFileSync(join(repositoryRoot, ".env.production.example"), "utf8"),
|
|
];
|
|
const manifest = JSON.parse(
|
|
readFileSync(join(repositoryRoot, "scripts/env-manifest.json"), "utf8"),
|
|
) as {
|
|
groups: Array<{ variables: Array<{ name: string }> }>;
|
|
};
|
|
const manifestNames = manifest.groups.flatMap((group) =>
|
|
group.variables.map((variable) => variable.name),
|
|
);
|
|
|
|
for (const variableName of variableNames) {
|
|
expect(compose).toMatch(new RegExp(`^\\s*${variableName}:`, "m"));
|
|
for (const exampleEnvironment of exampleEnvironments) {
|
|
expect(exampleEnvironment).toMatch(
|
|
new RegExp(`^# ${variableName}=`, "m"),
|
|
);
|
|
}
|
|
expect(manifestNames).toContain(variableName);
|
|
}
|
|
});
|
|
|
|
it("forwards GEOLITE2_DIR through the compose whitelist and documents it", () => {
|
|
// issue #659 — `src/lib/geo.ts` has always read this variable, but it
|
|
// was absent from the compose `environment:` block, and a variable
|
|
// that is not on that whitelist never reaches the container even when
|
|
// the operator's `.env` sets it. The override existed in code and was
|
|
// unreachable from the documented path.
|
|
const repositoryRoot = join(__dirname, "../..");
|
|
const compose = readFileSync(
|
|
join(repositoryRoot, "docker-compose.yml"),
|
|
"utf8",
|
|
);
|
|
const manifest = JSON.parse(
|
|
readFileSync(join(repositoryRoot, "scripts/env-manifest.json"), "utf8"),
|
|
) as { groups: Array<{ variables: Array<{ name: string }> }> };
|
|
|
|
// The compose line must SUBSTITUTE the operator's own value — a
|
|
// hardcoded literal would list the key and still ignore `.env`.
|
|
expect(compose).toMatch(
|
|
/^\s*GEOLITE2_DIR: "\$\{GEOLITE2_DIR(:-[^}]*)?\}"$/m,
|
|
);
|
|
// ...and the container needs a place to read the files from.
|
|
expect(compose).toMatch(/geolite2:ro$/m);
|
|
|
|
for (const exampleFile of [".env.example", ".env.production.example"]) {
|
|
const example = readFileSync(join(repositoryRoot, exampleFile), "utf8");
|
|
expect(example, `${exampleFile} does not document GEOLITE2_DIR`).toMatch(
|
|
/^# GEOLITE2_DIR=/m,
|
|
);
|
|
}
|
|
|
|
expect(
|
|
manifest.groups.flatMap((group) =>
|
|
group.variables.map((variable) => variable.name),
|
|
),
|
|
).toContain("GEOLITE2_DIR");
|
|
|
|
// The runbook the admin notification and both examples point at.
|
|
expect(() =>
|
|
readFileSync(join(repositoryRoot, "docs/self-hosting/geolite2.md")),
|
|
).not.toThrow();
|
|
});
|
|
});
|