mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
191 lines
7.4 KiB
TypeScript
191 lines
7.4 KiB
TypeScript
// @vitest-environment node
|
|||
|
|
import { globSync, readFileSync, statSync } from "node:fs";
|
||
|
|
import path from "node:path";
|
||
|
|
import { describe, expect, it } from "vitest";
|
||
|
|
|
||
|
|
/**
|
||
|
|
* The guide's env-var tables are promises, and they drifted quietly. AUTH_ENABLED
|
||
|
|
* was documented as defaulting to false when the schema defaults it to true, and
|
||
|
|
* SKIP_MUST_CHANGE_PASSWORD was documented as taking "any non-empty value" when
|
||
|
|
* it is a two-member enum that kills the process on anything else.
|
||
|
|
*
|
||
|
|
* So the tables get checked against the three places a default can come from:
|
||
|
|
* the Zod schema in apps/api/src/lib/env.ts, the ENV block in docker/Dockerfile,
|
||
|
|
* and the shell layer for the handful of vars the entrypoint reads directly.
|
||
|
|
*/
|
||
|
|
|
||
|
|
const ROOT = path.resolve(__dirname, "../../..");
|
||
|
|
// Both pages carry an env-var table with the same shape. deployment.md kept
|
||
|
|
// TRUST_PROXY=true in three places after the default moved to a trust list.
|
||
|
|
const DOCS = ["apps/docs/guide/configuration.md", "apps/docs/guide/deployment.md"];
|
||
|
|
|
||
|
|
interface Declared {
|
||
|
|
default?: string;
|
||
|
|
/** Set when a `.default(...)` exists but its value could not be read. */
|
||
|
|
unresolvedDefault?: string;
|
||
|
|
enum?: string[];
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Every `NAME: z...` entry in the env schema, with its default and enum members.
|
||
|
|
* A default can be a literal or an imported const (TRUST_PROXY holds its own in
|
||
|
|
* lib/trust-proxy.ts), so named constants are resolved rather than skipped.
|
||
|
|
*/
|
||
|
|
function zodSchema(): Map<string, Declared> {
|
||
|
|
const src = readFileSync(path.join(ROOT, "apps/api/src/lib/env.ts"), "utf8");
|
||
|
|
const libSrc = globSync("apps/api/src/lib/*.ts", { cwd: ROOT })
|
||
|
|
.map((f) => readFileSync(path.join(ROOT, f), "utf8"))
|
||
|
|
.join("\n");
|
||
|
|
const starts = [...src.matchAll(/^ {4}([A-Z][A-Z0-9_]*):\s*z\b/gm)];
|
||
|
|
const out = new Map<string, Declared>();
|
||
|
|
starts.forEach((match, i) => {
|
||
|
|
const from = match.index ?? 0;
|
||
|
|
const to = i + 1 < starts.length ? (starts[i + 1].index ?? src.length) : src.length;
|
||
|
|
const chunk = src.slice(from, to);
|
||
|
|
const def = chunk.match(/\.default\(\s*("([^"]*)"|[\d_]+|[A-Z][A-Z0-9_]*)\s*\)/);
|
||
|
|
const members = chunk.match(/\.enum\(\[([^\]]*)\]\)/);
|
||
|
|
let value: string | undefined;
|
||
|
|
if (def) {
|
||
|
|
if (def[2] !== undefined) value = def[2];
|
||
|
|
else if (/^[\d_]+$/.test(def[1])) value = def[1].replace(/_/g, "");
|
||
|
|
else value = libSrc.match(new RegExp(`export const ${def[1]} = "([^"]*)"`))?.[1];
|
||
|
|
}
|
||
|
|
out.set(match[1], {
|
||
|
|
default: value,
|
||
|
|
unresolvedDefault: def !== null && value === undefined ? def[1] : undefined,
|
||
|
|
enum: members ? [...members[1].matchAll(/"([^"]*)"/g)].map((m) => m[1]) : undefined,
|
||
|
|
});
|
||
|
|
});
|
||
|
|
return out;
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Values baked into the shipped image, which override the schema defaults. */
|
||
|
|
function dockerfileEnv(): Map<string, string> {
|
||
|
|
const lines = readFileSync(path.join(ROOT, "docker/Dockerfile"), "utf8").split("\n");
|
||
|
|
const out = new Map<string, string>();
|
||
|
|
let inEnv = false;
|
||
|
|
for (const raw of lines) {
|
||
|
|
const line = raw.trim();
|
||
|
|
if (line.startsWith("ENV ")) inEnv = true;
|
||
|
|
else if (!inEnv) continue;
|
||
|
|
const assign = line.replace(/^ENV\s+/, "").match(/^([A-Z][A-Z0-9_]*)=(.*?)\s*\\?$/);
|
||
|
|
if (assign) out.set(assign[1], assign[2].replace(/^["']|["']$/g, ""));
|
||
|
|
if (!raw.trimEnd().endsWith("\\")) inEnv = false;
|
||
|
|
}
|
||
|
|
return out;
|
||
|
|
}
|
||
|
|
|
||
|
|
function readAll(patterns: string[]): string {
|
||
|
|
return patterns
|
||
|
|
.flatMap((p) => globSync(p, { cwd: ROOT }))
|
||
|
|
.map((f) => path.join(ROOT, f))
|
||
|
|
.filter((f) => statSync(f).isFile())
|
||
|
|
.map((f) => readFileSync(f, "utf8"))
|
||
|
|
.join("\n");
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Shell layer: vars the container handles before Node ever sees them. */
|
||
|
|
const SHELL_SRC = readAll(["docker/entrypoint.sh", "docker/embedded-lib.sh", "docker/s6/**/*"]);
|
||
|
|
|
||
|
|
/** Application layer: everywhere a parsed env value can be consumed. */
|
||
|
|
const APP_SRC = readAll([
|
||
|
|
"apps/api/src/**/*.ts",
|
||
|
|
"apps/web/src/**/*.ts",
|
||
|
|
"apps/web/src/**/*.tsx",
|
||
|
|
"packages/*/src/**/*.ts",
|
||
|
|
]);
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Declared is not the same as wired. MAX_SPLIT_GRID sat in the schema and in the
|
||
|
|
* docs for releases while the split tool used a hardcoded 100, so a reader could
|
||
|
|
* set it and nothing happened.
|
||
|
|
*/
|
||
|
|
function isRead(name: string): boolean {
|
||
|
|
const inApp = new RegExp(`\\b(?:env|config)\\.${name}\\b|process\\.env\\.${name}\\b`);
|
||
|
|
return inApp.test(APP_SRC) || new RegExp(`\\$\\{?${name}\\b`).test(SHELL_SRC);
|
||
|
|
}
|
||
|
|
|
||
|
|
interface Row {
|
||
|
|
doc: string;
|
||
|
|
name: string;
|
||
|
|
defaultCell: string;
|
||
|
|
description: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
function documentedRows(): Row[] {
|
||
|
|
const rows: Row[] = [];
|
||
|
|
for (const doc of DOCS) {
|
||
|
|
for (const line of readFileSync(path.join(ROOT, doc), "utf8").split("\n")) {
|
||
|
|
const match = line.match(/^\|\s*`([A-Z][A-Z0-9_]*)`\s*\|([^|]*)\|(.*)\|\s*$/);
|
||
|
|
if (match) {
|
||
|
|
rows.push({ doc, name: match[1], defaultCell: match[2], description: match[3] });
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return rows;
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Backticked tokens, minus variable names and anything path- or URL-shaped. */
|
||
|
|
function valueTokens(cell: string): string[] {
|
||
|
|
return [...cell.matchAll(/`([^`]+)`/g)]
|
||
|
|
.map((m) => m[1])
|
||
|
|
.filter((t) => !/^[A-Z][A-Z0-9_*]*$/.test(t) && !/[/.:]/.test(t));
|
||
|
|
}
|
||
|
|
|
||
|
|
const SCHEMA = zodSchema();
|
||
|
|
const IMAGE = dockerfileEnv();
|
||
|
|
const ROWS = documentedRows();
|
||
|
|
|
||
|
|
describe("guide env-var tables", () => {
|
||
|
|
it("parsed all three sources", () => {
|
||
|
|
// Without this, an empty parse would make every case below vacuous.
|
||
|
|
expect(SCHEMA.size).toBeGreaterThan(80);
|
||
|
|
expect(IMAGE.size).toBeGreaterThan(20);
|
||
|
|
expect(ROWS.length).toBeGreaterThan(50);
|
||
|
|
expect(SCHEMA.get("AUTH_ENABLED")).toEqual({ default: "true", enum: ["true", "false"] });
|
||
|
|
expect(APP_SRC.length).toBeGreaterThan(1_000_000);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("resolved every schema default it found", () => {
|
||
|
|
// A default this parser cannot read is silently exempt from the row checks
|
||
|
|
// below, which is how a documented value could go wrong unnoticed.
|
||
|
|
const unresolved = [...SCHEMA.entries()]
|
||
|
|
.filter(([, d]) => d.unresolvedDefault)
|
||
|
|
.map(([name, d]) => `${name}=${d.unresolvedDefault}`);
|
||
|
|
expect(unresolved).toEqual([]);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("documents no variable that nothing reads", () => {
|
||
|
|
expect(ROWS.filter((r) => !isRead(r.name)).map((r) => r.name)).toEqual([]);
|
||
|
|
});
|
||
|
|
|
||
|
|
it.each(ROWS.filter((r) => SCHEMA.get(r.name)?.default))(
|
||
|
|
"$doc $name shows a default that some source actually produces",
|
||
|
|
({ name, defaultCell }) => {
|
||
|
|
const declared = SCHEMA.get(name)?.default;
|
||
|
|
const allowed = new Set([declared, IMAGE.get(name)].filter((v) => v !== undefined));
|
||
|
|
const shown = [...defaultCell.matchAll(/`([^`]+)`/g)].map((m) => m[1]);
|
||
|
|
// An empty-string default is written "(empty)", which carries no token.
|
||
|
|
if (declared === "") {
|
||
|
|
expect(shown.filter((t) => !allowed.has(t))).toEqual([]);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
expect(shown.length).toBeGreaterThan(0);
|
||
|
|
expect(shown.filter((t) => !allowed.has(t))).toEqual([]);
|
||
|
|
},
|
||
|
|
);
|
||
|
|
|
||
|
|
it.each(ROWS.filter((r) => SCHEMA.get(r.name)?.enum))(
|
||
|
|
"$doc $name only offers values its enum accepts",
|
||
|
|
({ name, defaultCell, description }) => {
|
||
|
|
const members = SCHEMA.get(name)?.enum ?? [];
|
||
|
|
const shown = [...defaultCell.matchAll(/`([^`]+)`/g)].map((m) => m[1]);
|
||
|
|
// A strict enum has exactly one default and it has to be spelled out;
|
||
|
|
// "-" or prose in this cell is how the SKIP_MUST_CHANGE_PASSWORD bug read.
|
||
|
|
expect(shown).toHaveLength(1);
|
||
|
|
expect(members).toContain(shown[0]);
|
||
|
|
expect(valueTokens(description).filter((t) => !members.includes(t))).toEqual([]);
|
||
|
|
},
|
||
|
|
);
|
||
|
|
});
|