Files
Laufzettel/tests/befehle.test.ts
T
Florian WildeandClaude Opus 5 b59fbb4645
Prüfung / pruefung (pull_request) Canceled after 0s
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (pull_request) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
feat(platzhalter): Aufloesung, Namensregeln und Shell-Maskierung
Aus einer Vorlage wird hier der PowerShell-Befehl, den ein Techniker kopiert und
auf einem Domaenencontroller ausfuehrt. Entsprechend dicht ist die Abdeckung:
810 Tests, und die Maskierung wird **ausgefuehrt** geprueft, nicht gelesen.

- Platzhalter aus Geraet, Lauf, Mitarbeiter und Abteilung, dazu die Variablen
  der Vorlage; ein unbekannter bleibt sichtbar stehen
- `{{ou}}` ist der Eltern-Container des DN — genau der Pfad fuer New-ADUser
- Namensregeln fuer upn, sam, mail und hostname mit Transliteration, `{v}`,
  `{nnn}` und `{kundenkuerzel}`; Kundenregeln ueberlagern die globalen feldweise
- Maskierung je Shell, Skript-Kopie je Abschnitt mit Kommentarzeilen
- `{{generiertes_passwort}}`: 20 Zeichen, bei jeder Anzeige neu, nirgends
  gespeichert; im Druck `[Passwort – nicht gespeichert]`

Der unabhaengige Review fand drei Blocker. Der schwerste war eine
Codeausfuehrung: PowerShell liest auch `“`, `”` und `„` als Begrenzer einer
Zeichenkette, die Maskierung kannte nur das ASCII-Zeichen. Ein Abteilungsname
aus Word — Word wandelt `"` von selbst um — reichte. Gefunden hat es der Review
nicht durch Lesen, sondern indem er sich PowerShell in einen Container geholt
und die Markierungsdatei hat entstehen lassen. Der Test dagegen hatte sich still
uebersprungen; er nimmt jetzt selbst das Abbild und bricht in CI ab.

Dazu: zwei verschiedene Passwoerter in einer Antwort (Anzeige und Skript wurden
getrennt gefuellt), eine Vorlagenvariable, die `{{techniker}}` und `{{kunde}}`
ueberschreiben konnte, und die tragende Annahme der Maskierung — dass der
Platzhalter in Anfuehrungszeichen steht —, die niemand geprueft hat.

Schliesst #012

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-25 15:29:43 +02:00

424 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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([]);
});
});