import { beforeEach, describe, expect, it } from "vitest"; import { Betriebsmodus } from "@/generated/prisma/enums"; import { befehlFuellen, befehleDesLaufs, skriptAusFertigen, namenVorschlagen, namensregelnFuer, skriptFuerAbschnitt, } from "@/server/services/befehle"; import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db"; /** * Fertige Befehle und Namensvorschlaege (#012). * * Hier laufen Werte, Ersetzung und Maskierung zusammen. Was hier falsch ist, * fuehrt ein Techniker auf einem Domaenencontroller aus — entsprechend steht * jede Zusage des Auftrags einzeln als Test. */ const WERTE = { mitarbeiter: 'Anna "Anni" O\u2019Neill', vorname: "Anna", ou: "OU=Buchhaltung,DC=firma,DC=de", }; describe("Einen Befehl füllen", () => { it("maskiert den eingesetzten Wert, nicht den Befehl", () => { /* * Die Reihenfolge ist der Kern: maskiert wird der **Wert**, nicht der * fertige Befehl. Sonst maskierte man die Anfuehrungszeichen des Befehls * gleich mit, und aus `-Name "…"` wuerde `-Name ""…""`. */ const fertig = befehlFuellen( { titel: "Konto anlegen", shell: "powershell", befehl: 'New-ADUser -Name "{{mitarbeiter}}"' }, WERTE, ); /* * Das typografische Apostroph wird dabei zum geraden: PowerShell liest * `U+2019` als Begrenzer einer `'…'`-Zeichenkette, es laesst sich also nicht * als Text durchreichen. Gegen PowerShell 7 nachgemessen — ohne diese * Angleichung fuehrt ein Wert mit `”` fremden Code aus. */ expect(fertig.befehl).toBe(`New-ADUser -Name "Anna ""Anni"" O'Neill"`); expect(fertig.fehler).toBeUndefined(); }); it("hält einen Wert mit $() vom Ausführen ab", () => { // Akzeptanzkriterium: ein Wert mit `"` und Backtick darf den Befehl nicht // brechen. `$(…)` ist der gefaehrlichere Verwandte. const fertig = befehlFuellen( { titel: "x", shell: "powershell", befehl: 'Write-Host "{{vorname}}"' }, { vorname: 'a$(Remove-Item C:\\ -Recurse)b`c"d' }, ); /* * Geprueft wird die **Maskierung**, nicht das Wort: `$(Remove-Item…` steht * danach noch da, aber mit einem Backtick davor — PowerShell liest es dann * als Text und nicht als Befehlsersetzung. Jedes `$` im Ergebnis muss einen * Backtick vor sich haben. */ for (const treffer of fertig.befehl.matchAll(/\$/gu)) { const davor = fertig.befehl.charAt((treffer.index ?? 0) - 1); expect(davor, `unmaskiertes $ an Stelle ${treffer.index}`).toBe("`"); } expect(fertig.befehl).toContain("`$(Remove-Item"); expect(fertig.befehl.startsWith('Write-Host "')).toBe(true); expect(fertig.befehl.endsWith('"')).toBe(true); }); it("maskiert den Titel nicht für die Shell", () => { // Der Titel wird gelesen, nicht ausgefuehrt. Maskiert stuende er mit // Backticks auf dem Bildschirm. const fertig = befehlFuellen( { titel: "Konto für {{mitarbeiter}}", shell: "powershell", befehl: "Get-Date" }, WERTE, ); // Der Titel wird **nicht** maskiert und behaelt deshalb das typografische // Apostroph – er wird gelesen, nicht ausgefuehrt. expect(fertig.titel).toBe('Konto für Anna "Anni" O\u2019Neill'); }); it("lässt einen unbekannten Platzhalter stehen und meldet ihn", () => { const fertig = befehlFuellen({ titel: "x", shell: "bash", befehl: "echo '{{tipfehler}}'" }, WERTE); expect(fertig.befehl).toContain("{{tipfehler}}"); expect(fertig.unbekannt).toEqual(["tipfehler"]); }); it("weist einen Platzhalter ohne Anführungszeichen ab", () => { /* * Die tragende Annahme der ganzen Maskierung — und bis zum Review von #012 * prueffte sie niemand. `Write-Host {{vorname}}` laesst sich nicht sicher * fuellen: ein Leerzeichen im Wert zerlegt das Argument, ein `;` beendet den * Befehl. Belegt im Review mit einer Markierungsdatei, die entstand. */ const fertig = befehlFuellen( { titel: "x", shell: "powershell", befehl: "Write-Host {{vorname}}" }, WERTE, ); expect(fertig.fehler).toMatch(/Anführungszeichen/); expect(fertig.befehl).toContain("{{vorname}}"); }); it("erkennt die richtige Anführungszeichenart je Shell", () => { // Bash arbeitet mit einfachen, PowerShell und Cmd mit doppelten. expect( befehlFuellen({ titel: "x", shell: "bash", befehl: "echo '{{vorname}}'" }, WERTE).fehler, ).toBeUndefined(); expect(befehlFuellen({ titel: "x", shell: "bash", befehl: 'echo "{{vorname}}"' }, WERTE).fehler).toMatch( /Anführungszeichen/, ); expect( befehlFuellen({ titel: "x", shell: "powershell", befehl: "Write-Host '{{vorname}}'" }, WERTE).fehler, ).toMatch(/Anführungszeichen/); }); it("bricht nicht ab, wenn sich ein Wert für cmd nicht sicher maskieren lässt", () => { /* * `wertMaskieren` wirft fuer cmd bei einem Anfuehrungszeichen — dort laesst * es sich nicht sicher maskieren, und alles nach dem `"` liest cmd.exe als * Befehl. Ein ungefangener Wurf risse die ganze Abarbeitungsansicht ab. */ const fertig = befehlFuellen( { titel: "Freigabe setzen", shell: "cmd", befehl: 'net share X="{{mitarbeiter}}"' }, WERTE, ); expect(fertig.fehler).toBeTruthy(); // Der Befehl bleibt mit Platzhaltern stehen: sichtbar, aber nicht kopierbar. expect(fertig.befehl).toContain("{{mitarbeiter}}"); }); }); describe("Skript eines Abschnitts", () => { const SCHRITTE = [ { titel: "Rechner umbenennen", shell: "powershell" as const, befehl: 'Rename-Computer "{{vorname}}"' }, { titel: "Neu starten", shell: "powershell" as const, befehl: "Restart-Computer" }, ]; it("liefert die Befehle in Reihenfolge mit Kommentarzeilen", () => { // Akzeptanzkriterium. const { skript } = skriptFuerAbschnitt(SCHRITTE, WERTE); const zeilen = skript.split(/\r?\n/u).filter((zeile) => zeile !== ""); expect(zeilen).toEqual([ "# Schritt: Rechner umbenennen", 'Rename-Computer "Anna"', "# Schritt: Neu starten", "Restart-Computer", ]); }); it("nimmt einen nicht maskierbaren Schritt aus dem Skript und meldet ihn", () => { // Mit stehen gebliebenen Platzhaltern waere es ein Skript, das etwas // anderes tut, als es liest. const ergebnis = skriptFuerAbschnitt( [ { titel: "Gut", shell: "cmd", befehl: 'echo "{{vorname}}"' }, { titel: "Kaputt", shell: "cmd", befehl: 'echo "{{mitarbeiter}}"' }, ], WERTE, ); expect(ergebnis.skript).toContain("Anna"); expect(ergebnis.skript).not.toContain("{{mitarbeiter}}"); expect(ergebnis.nichtMaskierbar).toEqual(["Kaputt"]); }); it("meldet, wenn ein Passwort darin steckt", () => { const ergebnis = skriptFuerAbschnitt( [{ titel: "Konto", shell: "powershell", befehl: '-Password "{{generiertes_passwort}}"' }], WERTE, ); expect(ergebnis.mitPasswort).toBe(true); expect(ergebnis.skript).not.toContain("{{generiertes_passwort}}"); }); }); describe("Ein Passwort je Antwort", () => { const SCHRITT = { titel: "Konto anlegen", shell: "powershell" as const, befehl: '-AccountPassword "{{generiertes_passwort}}"', }; it("zeigt im Skript dasselbe Passwort wie im Schritt", () => { /* * Der Blocker aus dem Review: `befehleDesLaufs` hat jeden Schritt zweimal * gefuellt — einmal fuer die Anzeige, einmal fuer das Skript. Damit stand auf * dem Bildschirm Passwort X und in der Kopie Passwort Y. Der Techniker legt * das Konto mit Y an und nennt dem Benutzer X, und weil nichts gespeichert * wird, ist Y danach unwiederbringlich. */ const gefuellt = befehlFuellen(SCHRITT, {}); const { skript } = skriptAusFertigen([gefuellt]); const ausSchritt = /"(\S{20})"/u.exec(gefuellt.befehl)?.[1]; const ausSkript = /"(\S{20})"/u.exec(skript)?.[1]; expect(ausSchritt).toBeTruthy(); expect(ausSkript).toBe(ausSchritt); }); it("erzeugt bei einem zweiten Füllen ein anderes Passwort", () => { // Die andere Haelfte derselben Regel: **je Anzeige** neu. Zweimal derselbe // Wert waere eine stillschweigende Speicherung. expect(befehlFuellen(SCHRITT, {}).befehl).not.toBe(befehlFuellen(SCHRITT, {}).befehl); }); }); describe.runIf(datenbankVerfuegbar)("Namensregeln mit Kunden-Fallback", () => { beforeEach(async () => { await datenbankLeeren(); }); async function aufbau(modus: Betriebsmodus) { const eigene = await testPrisma.kunde.create({ data: { name: "Muster GmbH", kuerzel: "MUS", istEigeneOrganisation: true }, }); const kunde = await testPrisma.kunde.create({ data: { name: "Bäckerei Reiter", kuerzel: "REI", namensregeln: { mail: "{vorname}@reiter.de" }, }, }); await testPrisma.einstellung.create({ data: { id: "global", modus, namensregeln: { upn: "{vorname}.{nachname}@firma.de", sam: "{v}{nachname}", mail: "{vorname}.{nachname}@firma.de", hostname: "{kundenkuerzel}-PC-{nnn}", }, }, }); const benutzer = await testPrisma.benutzer.create({ data: { email: "admin@firma.example", name: "Admin", rolle: "ADMIN" }, }); return { eigene, kunde, admin: { id: benutzer.id, rolle: benutzer.rolle } }; } it("überlagert Feld für Feld, nicht als Ganzes", async () => { // Ein Kunde mit eigener Mail-Domain soll nicht auch seine Hostname-Regel // neu schreiben muessen. const { kunde } = await aufbau(Betriebsmodus.DIENSTLEISTUNG); const regeln = await namensregelnFuer(kunde.id); expect(regeln.mail).toBe("{vorname}@reiter.de"); expect(regeln.upn).toBe("{vorname}.{nachname}@firma.de"); expect(regeln.hostname).toBe("{kundenkuerzel}-PC-{nnn}"); }); it("ignoriert Kundenregeln im Modus Intern", async () => { // Dort gibt es keine Kundenebene – eine dort hinterlegte Regel waere eine // stille Ausnahme, die niemand sieht. const { kunde } = await aufbau(Betriebsmodus.INTERN); expect((await namensregelnFuer(kunde.id)).mail).toBe("{vorname}.{nachname}@firma.de"); }); it("schlägt Namen vor und macht sie eindeutig", async () => { /* * Das Akzeptanzkriterium des Meilensteins, Ende zu Ende durch die Datenbank: * „Jörg Müller-Lüdenscheid“ ergibt joerg.mueller-luedenscheid@firma.de und * jmuellerluedensch. */ const { eigene, admin } = await aufbau(Betriebsmodus.INTERN); const vorschlag = await namenVorschlagen( eigene.id, { vorname: "Jörg", nachname: "Müller-Lüdenscheid" }, admin, ); expect(vorschlag.upn).toBe("joerg.mueller-luedenscheid@firma.de"); expect(vorschlag.sam).toBe("jmuellerluedensch"); }); it("weicht aus, wenn der Name schon vergeben ist", async () => { const { eigene, admin } = await aufbau(Betriebsmodus.INTERN); await testPrisma.mitarbeiter.create({ data: { kundeId: eigene.id, vorname: "Jörg", nachname: "Müller-Lüdenscheid", anzeigename: "Jörg Müller-Lüdenscheid", sam: "jmuellerluedensch", }, }); const vorschlag = await namenVorschlagen( eigene.id, { vorname: "Jörg", nachname: "Müller-Lüdenscheid" }, admin, ); expect(vorschlag.sam).not.toBe("jmuellerluedensch"); expect(vorschlag.sam?.length).toBeLessThanOrEqual(20); }); it("zählt Hostnamen hoch, die es schon gibt", async () => { const { eigene, admin } = await aufbau(Betriebsmodus.DIENSTLEISTUNG); await testPrisma.geraet.create({ data: { kundeId: eigene.id, tagCode: "LZ-7K3F9Q", hostname: "MUS-PC-001" }, }); const vorschlag = await namenVorschlagen(eigene.id, { kundenkuerzel: "MUS" }, admin); expect(vorschlag.hostname).toBe("MUS-PC-002"); }); it("schlägt nichts vor, wo keine Regel steht", async () => { // Ein erfundener Anmeldename ist schlimmer als gar keiner. const eigene = await testPrisma.kunde.create({ data: { name: "Ohne Regeln", kuerzel: "OHN", istEigeneOrganisation: true }, }); await testPrisma.einstellung.create({ data: { id: "global", modus: Betriebsmodus.INTERN } }); const benutzer = await testPrisma.benutzer.create({ data: { email: "a@firma.example", name: "Admin", rolle: "ADMIN" }, }); const admin = { id: benutzer.id, rolle: benutzer.rolle }; expect(await namenVorschlagen(eigene.id, { vorname: "Anna", nachname: "Meier" }, admin)).toEqual({}); }); }); describe.runIf(datenbankVerfuegbar)("Befehle eines Laufs", () => { beforeEach(async () => { await datenbankLeeren(); }); it("liefert die Abschnitte der Version, an der der Lauf hängt", async () => { const eigene = await testPrisma.kunde.create({ data: { name: "Muster", kuerzel: "MUS", istEigeneOrganisation: true }, }); await testPrisma.einstellung.create({ data: { id: "global", modus: Betriebsmodus.INTERN } }); const vorlage = await testPrisma.vorlage.create({ data: { name: "PC" } }); const version = await testPrisma.vorlagenVersion.create({ data: { vorlageId: vorlage.id, version: 1, status: "ENTWURF" }, }); const abschnitt = await testPrisma.vorlagenAbschnitt.create({ data: { versionId: version.id, titel: "Installation", position: 0 }, }); await testPrisma.vorlagenSchritt.createMany({ data: [ { abschnittId: abschnitt.id, titel: "Umbenennen", typ: "BEFEHL", position: 0, konfig: { shell: "powershell", befehl: 'Rename-Computer -NewName "{{hostname}}"' }, }, { abschnittId: abschnitt.id, titel: "Haken", typ: "HAKEN", position: 1, konfig: {} }, ], }); await testPrisma.vorlagenVersion.update({ where: { id: version.id }, data: { status: "VEROEFFENTLICHT", veroeffentlichtAm: new Date() }, }); const geraet = await testPrisma.geraet.create({ data: { kundeId: eigene.id, tagCode: "LZ-7K3F9Q", hostname: "PC-001", registriertAm: new Date() }, }); const lauf = await testPrisma.lauf.create({ data: { vorlagenVersionId: version.id, titel: "PC-001 neu aufsetzen", kundeId: eigene.id, geraetId: geraet.id, }, }); const abschnitte = await befehleDesLaufs({ laufId: lauf.id }); expect(abschnitte).toHaveLength(1); expect(abschnitte[0]?.abschnitt).toBe("Installation"); // Nur BEFEHL-Schritte – ein Haken gehoert in kein Skript. expect(abschnitte[0]?.schritte.map((schritt) => schritt.titel)).toEqual(["Umbenennen"]); expect(abschnitte[0]?.schritte[0]?.befehl).toBe('Rename-Computer -NewName "PC-001"'); expect(abschnitte[0]?.skript).toContain("# Schritt: Umbenennen"); }); it("schreibt beim Füllen nichts in die Datenbank", async () => { /* * Die zweite Haelfte der Passwort-Zusage: es landet **nirgends**. Geprueft * wird, was heute pruefbar ist — der Weg vom Lauf zum fertigen Befehl * schreibt keine Zeile, auch keinen Protokolleintrag. `Schrittergebnis` und * `Kommentar` gibt es erst mit #013; dort gehoert derselbe Beweis noch * einmal hin. */ const eigene = await testPrisma.kunde.create({ data: { name: "Muster", kuerzel: "MUS", istEigeneOrganisation: true }, }); await testPrisma.einstellung.create({ data: { id: "global", modus: Betriebsmodus.INTERN } }); const vorlage = await testPrisma.vorlage.create({ data: { name: "Onboarding" } }); const version = await testPrisma.vorlagenVersion.create({ data: { vorlageId: vorlage.id, version: 1, status: "ENTWURF" }, }); const abschnitt = await testPrisma.vorlagenAbschnitt.create({ data: { versionId: version.id, titel: "Konto", position: 0 }, }); await testPrisma.vorlagenSchritt.create({ data: { abschnittId: abschnitt.id, titel: "Konto anlegen", typ: "BEFEHL", position: 0, konfig: { shell: "powershell", befehl: '-AccountPassword "{{generiertes_passwort}}"' }, }, }); await testPrisma.vorlagenVersion.update({ where: { id: version.id }, data: { status: "VEROEFFENTLICHT", veroeffentlichtAm: new Date() }, }); const lauf = await testPrisma.lauf.create({ data: { vorlagenVersionId: version.id, titel: "Onboarding", kundeId: eigene.id }, }); const vorher = await testPrisma.protokoll.count(); const abschnitte = await befehleDesLaufs({ laufId: lauf.id }); const passwort = /"(\S{20})"/u.exec(abschnitte[0]?.schritte[0]?.befehl ?? "")?.[1]; expect(passwort).toBeTruthy(); expect(await testPrisma.protokoll.count()).toBe(vorher); // Und das Passwort steht in keiner Zeile, die wir schreiben koennten. const protokolle = JSON.stringify(await testPrisma.protokoll.findMany()); const laeufe = JSON.stringify(await testPrisma.lauf.findMany()); expect(protokolle).not.toContain(passwort); expect(laeufe).not.toContain(passwort); }); it("liefert nichts für einen Lauf, den es nicht gibt", async () => { expect(await befehleDesLaufs({ laufId: "gibtsnicht" })).toEqual([]); }); });