feat(platzhalter): Aufloesung, Namensregeln und Shell-Maskierung (#26)
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s

This commit was merged in pull request #26.
This commit is contained in:
2026-09-25 13:30:31 +00:00
parent f85bc87324
commit eec5de404a
21 changed files with 4069 additions and 166 deletions
+423
View File
@@ -0,0 +1,423 @@
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([]);
});
});