Files
proxdeploy/tests/engine/profile.test.ts
T
florian.wildeandClaude Opus 5.5 01d22aac3a feat(engine): Adapter debian und Profil-Engine
Profile sind deklarative Schritte je Distributionsfamilie; der Adapter
übersetzt sie in ein idempotentes Shell-Skript. Ein neuer Adapter (rhel,
alpine) ist eine Datei und ein Eintrag in der Registry.

Variablen werden typisiert geprüft, in run-Schritten jeder Wert für die
Shell gequotet. SSH-Härtung läuft zuletzt, damit sich eine Provisionierung
per SSH nicht selbst aussperrt.

Geprüft mit shellcheck und echt in Containern (Debian 12/13, Ubuntu
22.04/24.04/26.04) inklusive zweitem Lauf. Dabei aufgefallen: frische
Images haben keine Paketlisten – der Vorspann macht jetzt apt-get update.

Closes #005

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:53:38 +02:00

207 lines
8.5 KiB
TypeScript

import { execFileSync } from "node:child_process";
import { mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { adapterFuer, bekannteFamilien } from "@/engine/adapter";
import { AdapterFehler, debianAdapter } from "@/engine/adapter/debian";
import { katalogLaden } from "@/engine/katalog/laden";
import { ProfilSchema, type Profil } from "@/engine/katalog/schema";
import { profileAufloesen } from "@/engine/profile/aufloesen";
import { MARKE, profileRendern } from "@/engine/profile/rendern";
import { einsetzen, VorlagenFehler } from "@/engine/profile/vorlage";
import { q } from "@/engine/shell";
const kontext = {
var: { a: "eins", leer: "" },
os: { id: "debian", version_id: "13", codename: "trixie" },
guest: { name: "web01", user: "florian" },
};
async function katalogProfile(): Promise<Map<string, Profil>> {
const k = await katalogLaden("catalog");
return new Map(k.profile.map((p) => [p.eintrag.id, p.eintrag]));
}
const profil = (roh: unknown) => ProfilSchema.parse(roh);
describe("Shell-Quoting", () => {
it("lässt Harmloses stehen und packt alles andere in einfache Anführungszeichen", () => {
expect(q("docker-ce")).toBe("docker-ce");
expect(q("a b")).toBe("'a b'");
expect(q("$(rm -rf /)")).toBe("'$(rm -rf /)'");
expect(q("it's")).toBe(`'it'"'"'s'`);
expect(q("")).toBe("''");
});
it("ein gequoteter Wert kommt in bash unverändert an", () => {
for (const wert of ["$(id)", "`id`", "a'b\"c", "x; rm -rf /", "ä ö ü", "\\n"]) {
expect(execFileSync("bash", ["-c", `printf '%s' ${q(wert)}`]).toString()).toBe(wert);
}
});
});
describe("Platzhalter", () => {
it("setzt var, os und guest ein; ?? nimmt den ersten nicht leeren Wert", () => {
expect(einsetzen("{{ os.id }}{{ os.version_id }}-{{ guest.name }}", kontext)).toBe("debian13-web01");
expect(einsetzen("{{ var.leer ?? var.a }}", kontext)).toBe("eins");
});
it("wirft bei unbekannten Platzhaltern statt sie leer zu lassen", () => {
expect(() => einsetzen("{{ var.gibtsnicht }}", kontext)).toThrow(VorlagenFehler);
expect(() => einsetzen("{{ env.HOME }}", kontext)).toThrow(/Unbekannter Platzhalter/);
});
it("im Shell-Modus kann eine Variable nie Code werden", () => {
const boese = { ...kontext, var: { name: "x; touch /tmp/gehackt" } };
const befehl = einsetzen("echo {{ var.name }}", boese, "shell");
expect(befehl).toBe("echo 'x; touch /tmp/gehackt'");
expect(execFileSync("bash", ["-c", befehl]).toString().trim()).toBe("x; touch /tmp/gehackt");
});
});
describe("Profile auflösen", () => {
it("zieht Voraussetzungen nach vorne und meldet sie als automatisch", async () => {
const ergebnis = profileAufloesen(["docker", "zabbix-agent"], await katalogProfile(), "debian", {
"zabbix-agent": { server: "zbx" },
});
expect(ergebnis.ok).toBe(true);
if (!ergebnis.ok) return;
expect(ergebnis.profile.map((p) => p.profil.id)).toEqual(["base", "docker", "zabbix-agent"]);
expect(ergebnis.automatisch).toEqual(["base"]);
});
it("setzt Standardwerte und prüft Pflichtvariablen", async () => {
const fehlt = profileAufloesen(["zabbix-agent"], await katalogProfile(), "debian");
expect(fehlt).toEqual({
ok: false,
fehler: [{ profil: "zabbix-agent", variable: "server", meldung: "Zabbix-Server fehlt." }],
});
const ok = profileAufloesen(["base"], await katalogProfile(), "debian");
expect(ok.ok && ok.profile[0]!.werte).toEqual({
zeitzone: "Europe/Berlin",
locale: "de_DE.UTF-8",
tastatur: "de",
fail2ban: false,
});
});
it("lehnt Zeilenumbrüche, falsche Typen, Zyklen und fehlende Familien ab", () => {
const verfuegbar = new Map<string, Profil>([
["a", profil({ id: "a", name: "A", requires: ["b"], families: { debian: [{ update: true }] } })],
["b", profil({ id: "b", name: "B", requires: ["a"], families: { debian: [{ update: true }] } })],
["nur-rhel", profil({ id: "nur-rhel", name: "R", families: { rhel: [{ update: true }] } })],
[
"v",
profil({
id: "v",
name: "V",
variables: [
{ name: "text", label: "Text" },
{ name: "zahl", label: "Zahl", type: "number" },
{ name: "wahl", label: "Wahl", type: "select", options: ["x", "y"] },
],
families: { debian: [{ update: true }] },
}),
],
]);
const ergebnis = profileAufloesen(["a", "nur-rhel", "v"], verfuegbar, "debian", {
v: { text: "a\nb", zahl: "abc", wahl: "z" },
});
expect(ergebnis.ok).toBe(false);
if (ergebnis.ok) return;
const meldungen = ergebnis.fehler.map(
(f) => `${f.profil}${f.variable ? "." + f.variable : ""}: ${f.meldung}`,
);
expect(meldungen).toEqual(
expect.arrayContaining([
expect.stringMatching(/Zyklische Voraussetzung/),
"nur-rhel: Keine Umsetzung für die Familie „debian“.",
"v.text: Text: keine Zeilenumbrüche.",
"v.zahl: Zahl: Zahl erwartet.",
"v.wahl: Wahl: erlaubt sind x, y.",
]),
);
});
});
describe("Rendern", () => {
const renderKontext = {
os: { id: "debian", version_id: "13", codename: "trixie" },
gast: { name: "web01", benutzer: "florian" },
};
async function rendern(auswahl: string[], eingaben = {}) {
const ergebnis = profileAufloesen(auswahl, await katalogProfile(), "debian", eingaben);
if (!ergebnis.ok) throw new Error(JSON.stringify(ergebnis.fehler));
return profileRendern(ergebnis.profile, debianAdapter, renderKontext);
}
it("setzt Marken je Profil und die SSH-Härtung ganz ans Ende", async () => {
const { skript } = await rendern(["docker"]);
const marken = skript.split("\n").filter((z) => z.includes(MARKE));
expect(marken).toEqual([
`echo '${MARKE} profil base'`,
`echo '${MARKE} profil docker'`,
`echo '${MARKE} abschluss'`,
`echo '${MARKE} fertig'`,
]);
expect(skript.indexOf("10-proxdeploy.conf")).toBeGreaterThan(skript.indexOf("docker-compose-plugin"));
});
it("setzt Distribution und Gast ein (Docker-Repo, Zabbix-Paket, Hostname)", async () => {
const { skript } = await rendern(["docker", "zabbix-agent"], {
"zabbix-agent": { server: "zbx.example" },
});
expect(skript).toContain("https://download.docker.com/linux/debian/gpg");
expect(skript).toContain("Suites: trixie");
expect(skript).toContain("zabbix-release_latest_7.0+debian13_all.deb");
expect(skript).toContain("usermod -aG docker florian");
// Der Inhalt der Konfigurationsdatei steht base64-kodiert im Skript.
const b64 = /printf '%s' (\S+) \| base64 -d > \/etc\/zabbix/.exec(skript)![1]!;
expect(Buffer.from(b64, "base64").toString()).toContain("Hostname=web01\n");
});
it("überspringt Schritte, deren Bedingung nicht erfüllt ist", async () => {
expect((await rendern(["base"])).skript).not.toContain("fail2ban");
expect((await rendern(["base"], { base: { fail2ban: true } })).skript).toContain("fail2ban");
});
it("weist ungültige Zeitzone, Locale und Tastatur schon beim Rendern ab", async () => {
await expect(rendern(["base"], { base: { zeitzone: "Europe/Berlin; rm" } })).rejects.toThrow(
AdapterFehler,
);
await expect(rendern(["base"], { base: { locale: "de" } })).rejects.toThrow(/Locale/);
await expect(rendern(["base"], { base: { tastatur: "de'" } })).rejects.toThrow(/Tastatur/);
});
it("ist syntaktisch gültiges bash", async () => {
const { skript } = await rendern(["docker", "zabbix-agent"], {
"zabbix-agent": { server: "zbx" },
base: { fail2ban: true },
});
const datei = join(mkdtempSync(join(tmpdir(), "pd-skript-")), "s.sh");
writeFileSync(datei, skript);
expect(() => execFileSync("bash", ["-n", datei])).not.toThrow();
});
});
describe("Adapter-Registry", () => {
it("kennt debian; eine unbekannte Familie ergibt null statt Absturz", () => {
expect(bekannteFamilien()).toEqual(["debian"]);
expect(adapterFuer("debian")).toBe(debianAdapter);
expect(adapterFuer("rhel")).toBeNull();
});
it("Benutzer: sudo-Datei wird vor dem Aktivieren mit visudo geprüft, Schlüssel ergänzt statt ersetzt", () => {
const skript = debianAdapter.benutzerAnlegen({
name: "florian",
sshSchluessel: ["ssh-ed25519 AAAA x"],
sudo: true,
});
expect(skript).toMatch(/visudo -cf .*\.neu >\/dev\/null && mv/);
expect(skript).toContain('>> "$pd_home/.ssh/authorized_keys"');
});
});