mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
The password-reset runbook tells an operator to run `docker compose exec app node scripts/reset-password.mjs`, and the intake-repair runbook runs `healthlog-tsx scripts/repair-intake-anomalies.ts` inside the container. The runner stage copied nothing from scripts/, so both failed with "Cannot find module", the reset at the moment an admin is locked out. The runner stage now copies both scripts and the Argon2 parameter module the reset imports, and proves at build time that each reaches its own first check with every import resolved. A structural test holds every in-container command in docs/ and the Dockerfile copies in step. Key rotation is documented the way it works on the published image: the Rotate now button under Admin > Encryption is the primary path. The rotation script imports the application graph and runs from a source checkout of the same release; the runbook, the admin hint in all seven languages and the decrypt error now say so.
112 lines
4.3 KiB
TypeScript
112 lines
4.3 KiB
TypeScript
/**
|
|
* Structural guard: a script a runbook tells the operator to run inside the
|
|
* container is actually in the image, together with every local file it
|
|
* imports.
|
|
*
|
|
* ## The failure this freezes
|
|
*
|
|
* `docs/ops/password-reset.md` told operators to run
|
|
* `docker compose exec app node scripts/reset-password.mjs`, and the runner
|
|
* stage of the Dockerfile copied nothing from `scripts/`: the standalone
|
|
* output does not carry it. The recovery path for a locked-out admin failed
|
|
* with "Cannot find module" at the moment it was needed. The intake-repair
|
|
* runbook had the same gap under the `healthlog-tsx` launcher.
|
|
*
|
|
* ## What it proves and what it does not
|
|
*
|
|
* Every `docker compose exec … node scripts/…` or `healthlog-tsx scripts/…`
|
|
* command in `docs/` names
|
|
* a file the runner stage copies, and every relative import of that file is
|
|
* copied too. Whether the package imports resolve is proved at image build by
|
|
* the `RUN` checks next to the copies; this file cannot see the traced tree.
|
|
* A command written in some other form (a different launcher) is outside the
|
|
* matcher.
|
|
*/
|
|
import { describe, expect, it } from "vitest";
|
|
import { readFileSync, readdirSync } from "node:fs";
|
|
import { dirname, join, normalize } from "node:path";
|
|
|
|
const ROOT = process.cwd();
|
|
const DOCKERFILE = readFileSync(join(ROOT, "Dockerfile"), "utf8");
|
|
|
|
/**
|
|
* In-container command forms: `docker compose exec <service> node scripts/…`
|
|
* or the image's own `healthlog-tsx scripts/…` launcher. A bare
|
|
* `node scripts/…` is a workstation or source-checkout command and is out of
|
|
* scope.
|
|
*/
|
|
const CONTAINER_COMMAND =
|
|
/(?:docker compose exec\s+(?:-\S+\s+)*\S+\s+node|\bhealthlog-tsx)\s+(scripts\/[\w.-]+)/g;
|
|
|
|
function markdownFiles(dir: string): string[] {
|
|
return readdirSync(dir, { recursive: true, encoding: "utf8" })
|
|
.filter((rel) => rel.endsWith(".md"))
|
|
.map((rel) => join(dir, rel));
|
|
}
|
|
|
|
function runnerStage(): string {
|
|
const start = DOCKERFILE.search(/^FROM .* AS runner$/m);
|
|
expect(start, "the Dockerfile has a runner stage").toBeGreaterThan(0);
|
|
return DOCKERFILE.slice(start);
|
|
}
|
|
|
|
function copiedIntoRunner(path: string): boolean {
|
|
const escaped = path.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
return new RegExp(
|
|
`^COPY --from=builder (?:--chown=\\S+ )?/app/${escaped} \\./${escaped}$`,
|
|
"m",
|
|
).test(runnerStage());
|
|
}
|
|
|
|
/** Scripts named by a container command anywhere under docs/. */
|
|
function documentedContainerScripts(): string[] {
|
|
const found = new Set<string>();
|
|
for (const file of markdownFiles(join(ROOT, "docs"))) {
|
|
for (const m of readFileSync(file, "utf8").matchAll(CONTAINER_COMMAND)) {
|
|
found.add(m[1]);
|
|
}
|
|
}
|
|
return [...found].sort();
|
|
}
|
|
|
|
/** Relative import specifiers of a script, resolved against its location. */
|
|
function localImports(script: string): string[] {
|
|
const src = readFileSync(join(ROOT, script), "utf8");
|
|
const specs = [
|
|
...src.matchAll(/^\s*import\s[^;]*?from\s+["'](\.{1,2}\/[^"']+)["']/gm),
|
|
].map((m) => m[1]);
|
|
return specs.map((spec) => normalize(join(dirname(script), spec)));
|
|
}
|
|
|
|
describe("scripts the runbooks run inside the container ship in the image", () => {
|
|
it("finds the commands it is meant to check", () => {
|
|
const scripts = documentedContainerScripts();
|
|
// A matcher that found nothing would agree with any Dockerfile.
|
|
expect(scripts).toContain("scripts/reset-password.mjs");
|
|
expect(scripts).toContain("scripts/repair-intake-anomalies.ts");
|
|
expect(localImports("scripts/reset-password.mjs")).toContain(
|
|
"src/lib/auth/argon2-params.mjs",
|
|
);
|
|
expect(copiedIntoRunner("scripts/not-a-real-script.mjs")).toBe(false);
|
|
});
|
|
|
|
it("copies every documented script into the runner stage", () => {
|
|
const missing = documentedContainerScripts().filter(
|
|
(script) => !copiedIntoRunner(script),
|
|
);
|
|
expect(
|
|
missing,
|
|
"A runbook runs these inside the container, but the Dockerfile runner " +
|
|
"stage does not COPY them. Add the COPY (and its local imports), or " +
|
|
"change the runbook to a source-checkout command.",
|
|
).toEqual([]);
|
|
});
|
|
|
|
it("copies every local file those scripts import", () => {
|
|
const missing = documentedContainerScripts()
|
|
.flatMap((script) => localImports(script))
|
|
.filter((path) => !copiedIntoRunner(path));
|
|
expect(missing).toEqual([]);
|
|
});
|
|
});
|