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> { 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([ ["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"'); }); });