feat(platzhalter): Aufloesung, Namensregeln und Shell-Maskierung
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

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>
This commit is contained in:
Florian Wilde
2026-09-25 15:29:43 +02:00
co-authored by Claude Opus 5
parent f85bc87324
commit b59fbb4645
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([]);
});
});
+553
View File
@@ -0,0 +1,553 @@
import { describe, expect, it, vi } from "vitest";
import {
HOSTNAME_HOECHSTLAENGE,
NamensregelFehler,
NamensregelnSchema,
SAM_HOECHSTLAENGE,
UNTERSCHEIDUNG_HOECHSTWERT,
UNTERSCHEIDUNG_STELLEN,
ZIELFORMATE,
hatZaehler,
kleinGefaltet,
namenBilden,
namenEindeutigBilden,
transliterieren,
} from "@/lib/namensregeln";
/**
* Namensregeln, Transliteration und Zaehler (#012).
*
* Aus diesen Werten werden Befehle auf einem Domaenencontroller. Die Tests sind
* deshalb nicht nur auf den guten Fall gerichtet, sondern vor allem darauf, dass
* bei fehlenden Angaben **nichts** herauskommt und dass die Grenzen des Ziel-
* systems eingehalten werden, auch wenn gekuerzt werden muss.
*/
/** Der Fall aus dem Akzeptanzkriterium — er kommt in mehreren Tests wieder. */
const JOERG = { vorname: "Jörg", nachname: "Müller-Lüdenscheid" } as const;
const MIT_KUNDE = { ...JOERG, kundenkuerzel: "KND" } as const;
describe("transliterieren", () => {
it("faltet „Jörg Müller-Lüdenscheid“ und laesst den Bindestrich stehen", () => {
expect(transliterieren("Jörg Müller-Lüdenscheid")).toBe("Joerg Mueller-Luedenscheid");
expect(kleinGefaltet("Jörg Müller-Lüdenscheid")).toBe("joerg mueller-luedenscheid");
});
it.each([
["ä", "ae"],
["ö", "oe"],
["ü", "ue"],
["Ä", "Ae"],
["Ö", "Oe"],
["Ü", "Ue"],
["ß", "ss"],
["Straße", "Strasse"],
])("macht aus %s zwei Zeichen: %s", (ein, aus) => {
expect(transliterieren(ein)).toBe(aus);
});
it.each([
["é", "e"],
["è", "e"],
["ç", "c"],
["ñ", "n"],
["å", "a"],
["ě", "e"],
["ą", "a"],
["Ş", "S"],
["René Ćosić", "Rene Cosic"],
])("entfernt den Akzent von %s", (ein, aus) => {
expect(transliterieren(ein)).toBe(aus);
});
it.each([
["ł", "l"],
["Łukasz", "Lukasz"],
["ø", "o"],
["Øyvind", "Oyvind"],
["æ", "ae"],
["œ", "oe"],
["đ", "d"],
["þ", "th"],
["ı", "i"],
])("faltet %s, das Unicode nicht zerlegt, zu %s", (ein, aus) => {
// Ohne eigene Tabelle fiele dieser Buchstabe als Nicht-ASCII ganz weg, und
// aus „Kowalski“ wuerde „Kowaski“ — ein Anmeldename, der nie funktioniert.
expect(transliterieren(ein)).toBe(aus);
});
it("wirft alles uebrige Nicht-ASCII weg", () => {
expect(transliterieren("Александр")).toBe("");
expect(transliterieren("李雷")).toBe("");
expect(transliterieren("Max 🎉 Mustermann")).toBe("Max Mustermann");
});
it("wirft Steuerzeichen weg, statt sie zu Leerzeichen zu machen", () => {
// Ein Zeilenumbruch in einem Namen hat in einem Befehl nichts zu suchen.
expect(transliterieren("Max\nMustermann")).toBe("MaxMustermann");
expect(transliterieren("Max\tMustermann")).toBe("MaxMustermann");
});
it("laesst reines ASCII unveraendert", () => {
expect(transliterieren("Max Mustermann-Meier 1970")).toBe("Max Mustermann-Meier 1970");
});
it("kommt mit einer leeren Eingabe zurecht", () => {
expect(transliterieren("")).toBe("");
expect(kleinGefaltet("")).toBe("");
});
it("faltet auch die zerlegte Schreibweise eines Umlauts zu zwei Zeichen", () => {
// „ü“ kann als ein Zeichen oder als u + Trema ankommen — aus einer CSV-Datei
// eines Mac kommt regelmaessig die zweite Form. Beide muessen `mueller`
// ergeben, nicht einmal `mueller` und einmal `muller`.
// `normalize("NFD")` macht aus dem einen Zeichen die zerlegte Form. Sie hier
// als Fluchtfolge hinzuschreiben geht nicht: Prettier schreibt sie in das
// unsichtbare Zeichen zurueck, und dann steht im Test etwas, was niemand sieht.
expect(kleinGefaltet("Müller".normalize("NFD"))).toBe("mueller");
expect(kleinGefaltet("Müller")).toBe("mueller");
});
});
describe("namenBilden – die Akzeptanzkriterien aus #012", () => {
it("bildet joerg.mueller-luedenscheid@firma.de", () => {
expect(namenBilden("upn", "{vorname}.{nachname}@firma.de", JOERG)).toBe(
"joerg.mueller-luedenscheid@firma.de",
);
expect(namenBilden("mail", "{vorname}.{nachname}@firma.de", JOERG)).toBe(
"joerg.mueller-luedenscheid@firma.de",
);
});
it("bildet den sam jmuellerluedensch", () => {
const sam = namenBilden("sam", "{v}{nachname}", JOERG);
expect(sam).toBe("jmuellerluedensch");
// 17 Zeichen: 20 erlaubt das Active Directory, drei bleiben fuer die
// Unterscheidung frei, damit ein zweiter Traeger desselben Namens den ersten
// nicht umbenennt.
expect(sam).toHaveLength(SAM_HOECHSTLAENGE - UNTERSCHEIDUNG_STELLEN);
});
});
describe("namenBilden – Platzhalter", () => {
it("setzt {v} auf den ersten Buchstaben des Vornamens", () => {
expect(namenBilden("sam", "{v}", JOERG)).toBe("j");
expect(namenBilden("sam", "{v}.{nachname}", { vorname: "Ed", nachname: "Ng" })).toBe("e.ng");
});
it("nimmt fuer {v} den ersten Buchstaben, nicht das erste Zeichen", () => {
expect(namenBilden("sam", "{v}{nachname}", { vorname: "'Abd al-Rahman", nachname: "Farouk" })).toBe(
"afarouk",
);
});
it("entfernt Leerzeichen aus zusammengesetzten Vornamen", () => {
expect(namenBilden("sam", "{vorname}.{nachname}", { vorname: "Anna Lena", nachname: "Meier" })).toBe(
"annalena.meier",
);
});
it("setzt {kundenkuerzel} ein", () => {
expect(namenBilden("hostname", "{kundenkuerzel}-PC-{nnn}", MIT_KUNDE, 7)).toBe("KND-PC-007");
});
it("richtet die Stellen des Zaehlers nach der Zahl der n", () => {
expect(namenBilden("hostname", "PC{n}", MIT_KUNDE, 7)).toBe("PC7");
expect(namenBilden("hostname", "PC{nn}", MIT_KUNDE, 7)).toBe("PC07");
expect(namenBilden("hostname", "PC{nnnn}", MIT_KUNDE, 7)).toBe("PC0007");
expect(namenBilden("hostname", "PC{nnnn}", MIT_KUNDE, 1234)).toBe("PC1234");
});
it("liefert nichts, wenn die Zahl nicht in ihre Stellen passt", () => {
// `{nn}` kann keine 100 tragen; ein Hostname mit einer Stelle mehr waere
// ausserhalb der Regel, die der Admin aufgeschrieben hat.
expect(namenBilden("hostname", "PC{nn}", MIT_KUNDE, 100)).toBeUndefined();
expect(namenBilden("hostname", "PC{nn}", MIT_KUNDE, -1)).toBeUndefined();
expect(namenBilden("hostname", "PC{nn}", MIT_KUNDE, 1.5)).toBeUndefined();
});
it("liefert nichts, wenn die Regel einen Zaehler braucht, aber keiner mitkommt", () => {
// Die naechste freie Nummer kennt nur der Aufrufer. `001` zu raten hiesse
// einen Hostnamen zu liefern, der schon an einem anderen Geraet haengt.
expect(namenBilden("hostname", "{kundenkuerzel}-PC-{nnn}", MIT_KUNDE)).toBeUndefined();
});
it("liefert nichts bei zwei Zaehlern in einer Regel", () => {
expect(namenBilden("hostname", "PC{nn}-{nn}", MIT_KUNDE, 7)).toBeUndefined();
});
});
describe("namenBilden – wenn etwas fehlt, kommt nichts heraus", () => {
it.each([
["keine Regel", undefined],
["null", null],
["leere Regel", ""],
["nur Leerzeichen", " "],
])("liefert bei %s nichts", (_fall, regel) => {
expect(namenBilden("upn", regel, JOERG)).toBeUndefined();
});
it.each([
["fehlender Nachname", { vorname: "Jörg" }],
["leerer Nachname", { vorname: "Jörg", nachname: "" }],
["Nachname nur aus Leerzeichen", { vorname: "Jörg", nachname: " " }],
["fehlender Vorname", { nachname: "Müller" }],
["null als Wert", { vorname: null, nachname: "Müller" }],
])("liefert bei %s nichts", (_fall, werte) => {
expect(namenBilden("upn", "{vorname}.{nachname}@firma.de", werte)).toBeUndefined();
});
it("liefert nichts, wenn das Kundenkuerzel fehlt", () => {
// Im Modus INTERN gibt es kein Kuerzel. Ein Hostname `-PC-001` waere
// schlimmer als keiner: er sieht richtig aus.
expect(namenBilden("hostname", "{kundenkuerzel}-PC-{nnn}", JOERG, 1)).toBeUndefined();
});
it("liefert nichts bei einem Platzhalter, den es nicht gibt", () => {
expect(namenBilden("upn", "{vorname}.{abteilung}@firma.de", JOERG)).toBeUndefined();
expect(namenBilden("sam", "{titel}", JOERG)).toBeUndefined();
});
it("liefert nichts bei einem verunglueckten Platzhalter", () => {
// Sonst faellt die Klammer beim Normalisieren weg und der Text bleibt: aus
// `{Nachname}@firma.de` wuerde die Adresse `nachname@firma.de`, und die
// sieht echt aus.
expect(namenBilden("upn", "{Nachname}@firma.de", JOERG)).toBeUndefined();
expect(namenBilden("upn", "{NACHNAME}@firma.de", JOERG)).toBeUndefined();
expect(namenBilden("upn", "{vorname@firma.de", JOERG)).toBeUndefined();
expect(namenBilden("sam", "{vorname}}", JOERG)).toBeUndefined();
});
it("liefert nichts, wenn vom Namen nach der Faltung nichts bleibt", () => {
expect(namenBilden("sam", "{v}{nachname}", { vorname: "###", nachname: "###" })).toBeUndefined();
expect(namenBilden("upn", "{nachname}@firma.de", { nachname: "###" })).toBeUndefined();
expect(namenBilden("sam", "{nachname}", { nachname: "李雷" })).toBeUndefined();
expect(namenBilden("hostname", "{nachname}", { nachname: "___" })).toBeUndefined();
});
it("liefert bei jedem Zielformat nichts, wenn die Regel fehlt", () => {
for (const zielformat of ZIELFORMATE) {
expect(namenBilden(zielformat, undefined, JOERG)).toBeUndefined();
}
});
});
describe("namenBilden – Adressform fuer upn und mail", () => {
it("schreibt klein", () => {
expect(namenBilden("upn", "{vorname}@FIRMA.DE", JOERG)).toBe("joerg@firma.de");
expect(namenBilden("mail", "{NACHNAME}@firma.de", JOERG)).toBeUndefined();
});
it("laesst keine doppelten Punkte stehen", () => {
expect(namenBilden("upn", "{vorname}..{nachname}@firma.de", JOERG)).toBe(
"joerg.mueller-luedenscheid@firma.de",
);
expect(namenBilden("upn", "{vorname}@firma..de", JOERG)).toBe("joerg@firma.de");
});
it("laesst keinen Punkt vor dem @ stehen", () => {
expect(namenBilden("upn", "{vorname}.@firma.de", JOERG)).toBe("joerg@firma.de");
expect(namenBilden("upn", ".{vorname}@firma.de", JOERG)).toBe("joerg@firma.de");
});
it("verlangt genau ein @ und beide Haelften", () => {
expect(namenBilden("upn", "{vorname}.{nachname}", JOERG)).toBeUndefined();
expect(namenBilden("upn", "{vorname}@{nachname}@firma.de", JOERG)).toBeUndefined();
expect(namenBilden("upn", "{vorname}@", JOERG)).toBeUndefined();
expect(namenBilden("upn", "@firma.de", JOERG)).toBeUndefined();
});
it("weist einen Namensbereich mit unbrauchbarer Stufe ab", () => {
expect(namenBilden("upn", "{vorname}@firma.-de", JOERG)).toBeUndefined();
expect(namenBilden("upn", "{vorname}@firma.de-.x", JOERG)).toBeUndefined();
});
it("raeumt Bindestriche am Rand des Namensbereichs weg", () => {
expect(namenBilden("upn", "{vorname}@-firma.de", JOERG)).toBe("joerg@firma.de");
expect(namenBilden("upn", "{vorname}@firma.de-", JOERG)).toBe("joerg@firma.de");
});
it("wirft Zeichen weg, die in einer Adresse nicht vorkommen duerfen", () => {
// „Müller, Jörg“ aus einer CSV-Spalte, in der Vor- und Nachname zusammen
// stehen: das Komma gehoert in keine Adresse.
expect(namenBilden("mail", "{nachname}@firma.de", { nachname: "Mül,ler" })).toBe("mueller@firma.de");
});
it("kuerzt eine Adresse nicht", () => {
// Eine gekuerzte Mail-Adresse ist keine Adresse, sie ist eine falsche.
const lang = namenBilden("mail", "{vorname}.{nachname}@firma.de", {
vorname: "Jörg",
nachname: "Lüdenscheid".repeat(10),
});
expect(lang?.length).toBeGreaterThan(100);
expect(lang?.endsWith("@firma.de")).toBe(true);
});
});
describe("namenBilden – sam", () => {
it("laesst den Punkt stehen und den Bindestrich weg", () => {
expect(namenBilden("sam", "{vorname}.{nachname}", JOERG)).toBe("joerg.muellerlued");
});
it("kuerzt am Ende auf die nutzbare Laenge", () => {
const sam = namenBilden("sam", "{v}{nachname}", { vorname: "Jörg", nachname: "Lüdenscheid".repeat(20) });
expect(sam).toHaveLength(SAM_HOECHSTLAENGE - UNTERSCHEIDUNG_STELLEN);
expect(sam?.startsWith("jluedenscheid")).toBe(true);
});
it("laesst keinen Punkt am Ende stehen, auch nicht nach dem Kuerzen", () => {
// Ein `sAMAccountName` mit Punkt am Ende ist unzulaessig; beim Kuerzen kann
// er genau dort landen.
const sam = namenBilden("sam", "{nachname}.{vorname}", {
vorname: "Jörg",
nachname: "Mueller-Luedensch",
});
expect(sam).toBe("muellerluedensch");
expect(sam?.endsWith(".")).toBe(false);
});
it("nimmt einen Namen aus einem Zeichen", () => {
expect(namenBilden("sam", "{v}{nachname}", { vorname: "Ed", nachname: "Ng" })).toBe("eng");
expect(namenBilden("sam", "{vorname}", { vorname: "Ü" })).toBe("ue");
});
});
describe("namenBilden – hostname", () => {
it("schreibt gross", () => {
// Windows fuehrt den NetBIOS-Namen gross; stuende auf dem Aufkleber etwas
// anderes als das Geraet meldet, sucht der naechste Techniker einen Fehler,
// den es nicht gibt.
expect(namenBilden("hostname", "{kundenkuerzel}-nb-{nnn}", MIT_KUNDE, 3)).toBe("KND-NB-003");
expect(namenBilden("hostname", "{vorname}", JOERG)).toBe("JOERG");
});
it("laesst Unterstrich und Punkt weg", () => {
expect(namenBilden("hostname", "{kundenkuerzel}_PC_{nn}", MIT_KUNDE, 7)).toBe("KNDPC07");
expect(namenBilden("hostname", "{kundenkuerzel}.pc.{nn}", MIT_KUNDE, 7)).toBe("KNDPC07");
});
it("laesst keinen fuehrenden oder abschliessenden Bindestrich stehen", () => {
expect(namenBilden("hostname", "-{kundenkuerzel}-", MIT_KUNDE)).toBe("KND");
expect(namenBilden("hostname", "{kundenkuerzel}--", MIT_KUNDE)).toBe("KND");
});
it("haelt die 15 Zeichen des NetBIOS-Namens ein und kuerzt nie in der Nummer", () => {
// Das ist die gefaehrliche Stelle: wuerde am Ende gekuerzt, verschwaende die
// Nummer, und zwei Geraete bekaemen denselben Namen.
const hostname = namenBilden("hostname", "{nachname}-{nnn}", JOERG, 1);
expect(hostname).toBe("MUELLER-LUED001");
expect(hostname).toHaveLength(HOSTNAME_HOECHSTLAENGE);
expect(hostname?.endsWith("001")).toBe(true);
});
it("behaelt die Nummer auch bei einem sehr langen Namen", () => {
const hostname = namenBilden("hostname", "{nachname}-{nnnn}", { nachname: "Lüdenscheid".repeat(30) }, 42);
expect(hostname).toHaveLength(HOSTNAME_HOECHSTLAENGE);
expect(hostname?.endsWith("0042")).toBe(true);
});
it("behaelt die Nummer auch, wenn sie nicht am Ende der Regel steht", () => {
const hostname = namenBilden("hostname", "{nnn}-{nachname}", { nachname: "Lüdenscheid".repeat(30) }, 42);
expect(hostname).toHaveLength(HOSTNAME_HOECHSTLAENGE);
expect(hostname?.startsWith("042-")).toBe(true);
});
it("haelt ohne Zaehler in der Regel den Vorrat fuer die Unterscheidung frei", () => {
const hostname = namenBilden("hostname", "{nachname}", { nachname: "Lüdenscheid".repeat(5) });
expect(hostname).toHaveLength(HOSTNAME_HOECHSTLAENGE - UNTERSCHEIDUNG_STELLEN);
});
it("liefert nichts, wenn die Nummer allein schon zu lang ist", () => {
expect(namenBilden("hostname", "{nachname}-{nnnnnnnnnnnnnnnn}", JOERG, 1)).toBeUndefined();
});
});
describe("hatZaehler", () => {
it.each([
["{nnn}", true],
["{kundenkuerzel}-PC-{nnn}", true],
["PC{n}", true],
["{vorname}.{nachname}@firma.de", false],
["{nachname}", false],
["", false],
])("erkennt %s", (regel, erwartet) => {
expect(hatZaehler(regel)).toBe(erwartet);
});
});
describe("namenEindeutigBilden – mit Zaehler in der Regel", () => {
it("nimmt die erste Nummer, wenn sie frei ist", async () => {
const istVergeben = vi.fn(async () => false);
await expect(
namenEindeutigBilden("hostname", "{kundenkuerzel}-PC-{nnn}", MIT_KUNDE, istVergeben),
).resolves.toBe("KND-PC-001");
expect(istVergeben).toHaveBeenCalledTimes(1);
expect(istVergeben).toHaveBeenCalledWith("KND-PC-001");
});
it("zaehlt weiter, solange Nummern vergeben sind", async () => {
const vergeben = new Set(["KND-PC-001", "KND-PC-002"]);
const istVergeben = vi.fn(async (kandidat: string) => vergeben.has(kandidat));
await expect(
namenEindeutigBilden("hostname", "{kundenkuerzel}-PC-{nnn}", MIT_KUNDE, istVergeben),
).resolves.toBe("KND-PC-003");
expect(istVergeben).toHaveBeenCalledTimes(3);
});
it("wirft, wenn der Vorrat der Regel erschoepft ist", async () => {
const istVergeben = vi.fn(async () => true);
// `{n}` gibt neun Nummern her. Danach ist die Regel zu eng — das ist eine
// Einstellung, die geaendert werden muss, und keine Nummer, die man
// erfinden darf.
await expect(namenEindeutigBilden("hostname", "PC{n}", MIT_KUNDE, istVergeben)).rejects.toThrow(
NamensregelFehler,
);
expect(istVergeben).toHaveBeenCalledTimes(9);
});
it("fragt nicht nach, wenn die Regel keinen Namen ergibt", async () => {
const istVergeben = vi.fn(async () => false);
await expect(
namenEindeutigBilden("hostname", "{kundenkuerzel}-PC-{nnn}", JOERG, istVergeben),
).resolves.toBeUndefined();
expect(istVergeben).not.toHaveBeenCalled();
});
});
describe("namenEindeutigBilden – ohne Zaehler in der Regel", () => {
it("liefert den Namen unveraendert, wenn er frei ist", async () => {
const istVergeben = vi.fn(async () => false);
await expect(namenEindeutigBilden("sam", "{v}{nachname}", JOERG, istVergeben)).resolves.toBe(
"jmuellerluedensch",
);
expect(istVergeben).toHaveBeenCalledTimes(1);
});
it("haengt bei einer Kollision eine Unterscheidung an und bleibt in der Grenze", async () => {
const vergeben = new Set(["jmuellerluedensch"]);
const istVergeben = vi.fn(async (kandidat: string) => vergeben.has(kandidat));
const sam = await namenEindeutigBilden("sam", "{v}{nachname}", JOERG, istVergeben);
expect(sam).toBe("jmuellerluedensch2");
expect(sam?.length).toBeLessThanOrEqual(SAM_HOECHSTLAENGE);
});
it("bleibt auch bei dreistelliger Unterscheidung in den 20 Zeichen", async () => {
// Die drei zurueckgehaltenen Stellen sind genau dafuer da.
const istVergeben = vi.fn(async (kandidat: string) => kandidat !== "jmuellerluedensch999");
const sam = await namenEindeutigBilden("sam", "{v}{nachname}", JOERG, istVergeben);
expect(sam).toBe("jmuellerluedensch999");
expect(sam).toHaveLength(SAM_HOECHSTLAENGE);
});
it("setzt die Unterscheidung bei Adressen vor das @", async () => {
const vergeben = new Set(["joerg.mueller-luedenscheid@firma.de"]);
const istVergeben = vi.fn(async (kandidat: string) => vergeben.has(kandidat));
await expect(
namenEindeutigBilden("upn", "{vorname}.{nachname}@firma.de", JOERG, istVergeben),
).resolves.toBe("joerg.mueller-luedenscheid2@firma.de");
});
it("bleibt beim Hostnamen in den 15 Zeichen", async () => {
const istVergeben = vi.fn(async (kandidat: string) => kandidat.length < HOSTNAME_HOECHSTLAENGE);
const hostname = await namenEindeutigBilden(
"hostname",
"{nachname}",
{ nachname: "Lüdenscheid".repeat(5) },
istVergeben,
);
expect(hostname).toHaveLength(HOSTNAME_HOECHSTLAENGE);
});
it("wirft, wenn auch alle Unterscheidungen vergeben sind", async () => {
const istVergeben = vi.fn(async () => true);
await expect(namenEindeutigBilden("sam", "{v}{nachname}", JOERG, istVergeben)).rejects.toThrow(
NamensregelFehler,
);
// Der Name selbst und die Zusaetze 2 bis 999.
expect(istVergeben).toHaveBeenCalledTimes(UNTERSCHEIDUNG_HOECHSTWERT);
});
it("fragt ohne Regel nicht nach", async () => {
const istVergeben = vi.fn(async () => false);
await expect(namenEindeutigBilden("sam", undefined, JOERG, istVergeben)).resolves.toBeUndefined();
await expect(namenEindeutigBilden("sam", " ", JOERG, istVergeben)).resolves.toBeUndefined();
expect(istVergeben).not.toHaveBeenCalled();
});
it("gibt einen Fehler der Vergabepruefung unveraendert weiter", async () => {
const istVergeben = vi.fn(async () => {
throw new Error("Datenbank nicht erreichbar");
});
await expect(namenEindeutigBilden("sam", "{v}{nachname}", JOERG, istVergeben)).rejects.toThrow(
"Datenbank nicht erreichbar",
);
});
});
describe("NamensregelnSchema", () => {
it("nimmt die vier Zielformate und trimmt", () => {
const gelesen = NamensregelnSchema.parse({
upn: " {vorname}.{nachname}@firma.de ",
sam: "{v}{nachname}",
mail: "{vorname}.{nachname}@firma.de",
hostname: "{kundenkuerzel}-PC-{nnn}",
});
expect(gelesen.upn).toBe("{vorname}.{nachname}@firma.de");
expect(gelesen.sam).toBe("{v}{nachname}");
});
it("nimmt ein leeres Objekt", () => {
expect(NamensregelnSchema.parse({})).toEqual({});
});
it("weist eine zu lange Regel ab", () => {
expect(NamensregelnSchema.safeParse({ upn: "x".repeat(201) }).success).toBe(false);
});
});
describe("Hostnamen, die Windows ablehnt", () => {
it("schlägt keinen reservierten Gerätenamen vor", () => {
/*
* `CON`, `PRN`, `NUL` und Geschwister sind die alten Geraetenamen von
* MS-DOS. Ein Nachname „Con“ reicht, damit die Regel sie vorschlaegt — und
* Windows nimmt den Computernamen dann nicht an. Gefunden im Review zu #012.
*/
for (const name of ["con", "prn", "aux", "nul", "com1", "lpt9"]) {
expect(namenBilden("hostname", "{nachname}", { nachname: name }), name).toBeUndefined();
}
// Mit etwas davor oder dahinter ist der Name in Ordnung.
expect(namenBilden("hostname", "PC-{nachname}", { nachname: "con" })).toBe("PC-CON");
});
it("schlägt keinen rein numerischen Hostnamen vor", () => {
// Er waere von einer IP-Adresse nicht zu unterscheiden; Windows lehnt ihn ab.
expect(namenBilden("hostname", "{nachname}", { nachname: "12345" })).toBeUndefined();
expect(namenBilden("hostname", "{nnn}", {}, 1)).toBeUndefined();
expect(namenBilden("hostname", "PC-{nnn}", {}, 1)).toBe("PC-001");
});
});
+138
View File
@@ -0,0 +1,138 @@
import { describe, expect, it } from "vitest";
import {
PASSWORT_GROSS,
PASSWORT_KLEIN,
PASSWORT_LAENGE,
PASSWORT_SONDER,
PASSWORT_ZIFFERN,
passwortErzeugen,
} from "@/lib/passwort";
/**
* Das generierte Passwort (#012).
*
* Die Zusage lautet: bei jeder Anzeige neu und nirgends gespeichert. Hier steht
* die eine Haelfte davon — dass es wirklich jedes Mal ein anderes ist und dass
* es die Anforderungen erfuellt, an denen ein `New-ADUser` sonst scheitert.
*
* Die andere Haelfte — dass es in keiner Tabelle landet — steht in
* `tests/befehle.test.ts` („schreibt beim Fuellen nichts in die Datenbank“). Sie
* prueft, was heute pruefbar ist: dass der Weg vom Lauf zum fertigen Befehl
* nichts schreibt. `Schrittergebnis` und `Kommentar` gibt es erst mit #013; dort
* gehoert derselbe Beweis noch einmal hin, wenn es sie gibt.
*/
const ALLE = PASSWORT_GROSS + PASSWORT_KLEIN + PASSWORT_ZIFFERN + PASSWORT_SONDER;
describe("Erzeugtes Passwort", () => {
it("ist 20 Zeichen lang", () => {
expect(passwortErzeugen()).toHaveLength(20);
expect(PASSWORT_LAENGE).toBe(20);
});
it("benutzt nur Zeichen aus dem vereinbarten Vorrat", () => {
for (let versuch = 0; versuch < 200; versuch += 1) {
for (const zeichen of passwortErzeugen()) {
expect(ALLE.includes(zeichen), `„${zeichen}“ gehört nicht zum Vorrat`).toBe(true);
}
}
});
it("enthält aus jeder Gruppe mindestens ein Zeichen", () => {
// Die Passwortrichtlinie eines Active Directory verlangt ueblicherweise drei
// von vier Gruppen. Ein abgewiesenes New-ADUser laesst den Techniker vor dem
// Kunden stehen.
for (let versuch = 0; versuch < 200; versuch += 1) {
const passwort = passwortErzeugen();
for (const [name, gruppe] of [
["Großbuchstaben", PASSWORT_GROSS],
["Kleinbuchstaben", PASSWORT_KLEIN],
["Ziffern", PASSWORT_ZIFFERN],
["Sonderzeichen", PASSWORT_SONDER],
] as const) {
expect(
[...passwort].some((z) => gruppe.includes(z)),
`${name} fehlen in „${passwort}“`,
).toBe(true);
}
}
});
it("enthält keine verwechselbaren Zeichen", () => {
/*
* Das Passwort wird vom Bildschirm abgelesen und getippt – oft von der
* Person, der das Konto gehoert. Ein falsch gelesenes Zeichen kostet einen
* Anruf.
*
* Verwechselbar ist immer ein **Paar**: aus jedem Paar fehlt genau einer,
* dann ist der andere eindeutig. `B` ist draussen, also darf `8` bleiben;
* `O` ist draussen, also darf nichts mit `0` verwechselt werden — `0` ist
* trotzdem weg, weil es mit `o` zusammenfaellt.
*/
for (let versuch = 0; versuch < 200; versuch += 1) {
expect(passwortErzeugen()).not.toMatch(/[IlB1|O0]/u);
}
});
it("lässt aus jedem verwechselbaren Paar genau einen übrig", () => {
// Haelt die Begruendung oben fest: `8` ohne `B` ist eindeutig, `1` ohne `l`
// und `I` waere es auch — es ist trotzdem draussen, weil `|` in manchen
// Schriften gleich aussieht.
const vorrat = PASSWORT_GROSS + PASSWORT_KLEIN + PASSWORT_ZIFFERN;
for (const paar of ["I1", "l1", "O0", "o0", "B8", "Il"]) {
const drin = [...paar].filter((zeichen) => vorrat.includes(zeichen));
expect(drin.length, `„${paar}“ ist beides im Vorrat`).toBeLessThanOrEqual(1);
}
});
it("enthält nichts, was einen Befehl oder eine CSV-Zelle zerbricht", () => {
// Die Maskierung faengt das ab – aber ein Passwort, das nur wegen korrekter
// Maskierung funktioniert, ist eine Wette auf jeden kuenftigen Aufrufer.
for (let versuch = 0; versuch < 200; versuch += 1) {
expect(passwortErzeugen()).not.toMatch(/["'$`\\;&|<>%,\n\r]/u);
}
});
it("liefert bei zwei Aufrufen verschiedene Passwörter", () => {
// Genau das Akzeptanzkriterium: ein zweiter Aufruf ist ein **anderes**
// Passwort, nicht dasselbe noch einmal.
const gesehen = new Set<string>();
for (let versuch = 0; versuch < 500; versuch += 1) {
gesehen.add(passwortErzeugen());
}
expect(gesehen.size).toBe(500);
});
it("verteilt die Pflichtzeichen über die ganze Länge", () => {
// Ohne Mischen staenden Gross, Klein, Ziffer und Sonderzeichen immer an den
// ersten vier Stellen — erkennbar, und damit vier Zeichen weniger Zufall.
const stellen = new Set<number>();
for (let versuch = 0; versuch < 300; versuch += 1) {
const passwort = passwortErzeugen();
const stelle = [...passwort].findIndex((z) => PASSWORT_SONDER.includes(z));
stellen.add(stelle);
}
expect(stellen.size).toBeGreaterThan(10);
});
it("weist eine Länge ab, die nicht alle vier Gruppen fassen kann", () => {
expect(() => passwortErzeugen(3)).toThrow();
expect(passwortErzeugen(4)).toHaveLength(4);
});
it("fängt nie mit einem Sonderzeichen an", () => {
// `= + - @` am Anfang einer Zelle liest eine Tabellenkalkulation als Formel,
// und ein Passwort geht seinen Weg oft ueber Zwischenablage und Tabelle.
for (let versuch = 0; versuch < 500; versuch += 1) {
expect(PASSWORT_SONDER.includes(passwortErzeugen().charAt(0))).toBe(false);
}
});
it("enthält kein Ausrufezeichen", () => {
// Bei eingeschalteter verzoegerter Ersetzung frisst cmd.exe es still – das
// Konto bekaeme ein anderes Passwort als angezeigt.
for (let versuch = 0; versuch < 200; versuch += 1) {
expect(passwortErzeugen()).not.toContain("!");
}
});
});
+51
View File
@@ -0,0 +1,51 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { Markdown } from "@/components/markdown";
import { platzhalterErsetzen } from "@/lib/platzhalter-werte";
/**
* Was von einem ersetzten Wert wirklich im HTML ankommt (#012).
*
* Das Akzeptanzkriterium lautet: „ein Wert mit `<script>` landet HTML-escaped in
* der Anzeige“. Erfuellt wird es von React und nicht von eigenem Code — und
* genau deshalb steht hier ein Test, der das **am gerenderten HTML** nachweist.
* Eine eigene Maskierung waere doppelt und machte aus „Forschung & Entwicklung“
* sichtbar „Forschung &amp;amp; Entwicklung“.
*/
describe("Ersetzter Wert im gerenderten HTML", () => {
it("maskiert ein script-Element im Schritt-Titel", () => {
const { text } = platzhalterErsetzen("Konto für {{mitarbeiter}} anlegen", {
mitarbeiter: "<script>alert(1)</script>",
});
const html = renderToStaticMarkup(<h3>{text}</h3>);
expect(html).not.toContain("<script>");
expect(html).toContain("&lt;script&gt;");
});
it("zeigt ein Ampersand als Zeichen, nicht als Entität", () => {
// Der alltaegliche Fall – „Forschung & Entwicklung“ ist ein Abteilungsname
// und kein Angriff. Doppelt maskiert stuende hier „&amp;amp;“.
const { text } = platzhalterErsetzen("{{abteilung}}", {
abteilung: "Forschung & Entwicklung",
});
const html = renderToStaticMarkup(<p>{text}</p>);
expect(html).toBe("<p>Forschung &amp; Entwicklung</p>");
});
it("lässt ein script-Element auch im Hilfetext nicht durch", () => {
// Der Hilfetext geht durch Markdown, das ebenfalls maskiert und rohes HTML
// fallen laesst (#010).
const { text } = platzhalterErsetzen("Hinweis für {{mitarbeiter}}", {
mitarbeiter: "<img src=x onerror=alert(1)>",
});
const html = renderToStaticMarkup(<Markdown text={text} />);
expect(html).not.toContain("<img");
expect(html).toContain("&lt;img");
});
it("zeigt einen unbekannten Platzhalter sichtbar an", () => {
const { text } = platzhalterErsetzen("{{tipfehler}} prüfen", {});
expect(renderToStaticMarkup(<span>{text}</span>)).toBe("<span>{{tipfehler}} prüfen</span>");
});
});
+175
View File
@@ -0,0 +1,175 @@
import { describe, expect, it } from "vitest";
import { PASSWORT_IM_DRUCK } from "@/lib/passwort";
import { platzhalterErsetzen, platzhalterErsetzenRoh } from "@/lib/platzhalter-werte";
/**
* Das Ersetzen der Platzhalter (#012).
*
* Hier entsteht der Text, den ein Techniker kopiert und auf einem
* Domaenencontroller ausfuehrt. Entsprechend dicht ist die Abdeckung: jede
* Regel, die der Auftrag nennt, hat einen Test, und jeder Weg, auf dem ein Wert
* einen Befehl zerbrechen koennte, auch.
*/
const WERTE = {
vorname: "Maria",
nachname: "Huber",
mitarbeiter: "Maria Huber",
abteilung: "Buchhaltung",
hostname: "PC-001",
};
describe("Ersetzen", () => {
it("ersetzt einen bekannten Platzhalter", () => {
expect(platzhalterErsetzen("Hallo {{vorname}}", WERTE).text).toBe("Hallo Maria");
});
it("erlaubt Leerzeichen in den Klammern", () => {
expect(platzhalterErsetzen("{{ vorname }} {{ nachname }}", WERTE).text).toBe("Maria Huber");
});
it("ersetzt denselben Platzhalter mehrfach", () => {
expect(platzhalterErsetzen("{{vorname}} und nochmal {{vorname}}", WERTE).text).toBe(
"Maria und nochmal Maria",
);
});
it("lässt einen unbekannten Platzhalter stehen und meldet ihn", () => {
// Akzeptanzkriterium: ein Tippfehler bleibt sichtbar. Eine leere Stelle im
// Befehl faellt niemandem auf, `{{tipfehler}}` schon.
const ergebnis = platzhalterErsetzen("New-ADUser -Name {{tipfehler}}", WERTE);
expect(ergebnis.text).toBe("New-ADUser -Name {{tipfehler}}");
expect(ergebnis.unbekannt).toEqual(["tipfehler"]);
});
it("macht einen bekannten Platzhalter ohne Wert leer", () => {
// Der Unterschied zum unbekannten: „hier gibt es das nicht“ ist etwas
// anderes als „falsch geschrieben“. `{{kunde}}` im Modus Intern ist leer.
const ergebnis = platzhalterErsetzen("Kunde: {{kunde}}!", WERTE);
expect(ergebnis.text).toBe("Kunde: !");
expect(ergebnis.unbekannt).toEqual([]);
});
it("rührt Text ohne Platzhalter nicht an", () => {
expect(platzhalterErsetzen("Nur Text { kein } Platzhalter", WERTE).text).toBe(
"Nur Text { kein } Platzhalter",
);
});
it("lässt einen falsch geschriebenen Platzhalter stehen und meldet ihn", () => {
/*
* Dieselbe Schluesselregel wie bei den Variablen (docs/vorlagen-format.md).
* Gemeldet wird er trotzdem: ein falsch geschriebener Platzhalter ist
* gefaehrlicher als ein unbekannter, weil er richtig aussieht. Bis zum
* Review von #012 blieb er still stehen.
*/
const text = "{{Vorname}} {{vor-name}} {{2te}}";
const ergebnis = platzhalterErsetzen(text, WERTE);
expect(ergebnis.text).toBe(text);
expect([...ergebnis.unbekannt].sort()).toEqual(["2te", "Vorname", "vor-name"]);
});
it("stolpert nicht über eine einzelne geschweifte Klammer", () => {
const text = "Pfad {C:\\} und {{vorname}}";
expect(platzhalterErsetzen(text, WERTE).text).toBe("Pfad {C:\\} und Maria");
});
it("setzt einen Wert mit $-Zeichen wörtlich ein", () => {
// `String.replace` deutet `$&`, `` $` ``, `$'` und `$1` im Ersatztext als
// Rueckverweise. Ein Nachname „A$&B“ haette sonst den gefundenen
// Platzhalter wieder eingesetzt.
for (const wert of ["A$&B", "vor $` danach", "A $' B", "Gruppe $1"]) {
expect(platzhalterErsetzenRoh("Name: {{nachname}}", { nachname: wert }).text, wert).toBe(
`Name: ${wert}`,
);
}
});
});
describe("Maskierung für einen Befehl", () => {
it("reicht jeden Wert durch die mitgegebene Maskierung", () => {
const ergebnis = platzhalterErsetzen(
'New-ADUser -Name "{{mitarbeiter}}"',
{ mitarbeiter: 'Anna "Anni" Meier' },
{ art: "befehl", maskieren: (wert) => wert.replaceAll('"', '""') },
);
expect(ergebnis.text).toBe('New-ADUser -Name "Anna ""Anni"" Meier"');
});
it("maskiert im Befehl nicht zusätzlich für HTML", () => {
// Ein `&amp;` in einem PowerShell-Befehl waere schlicht falsch.
const ergebnis = platzhalterErsetzen(
"Set-Name {{abteilung}}",
{ abteilung: "Forschung & Entwicklung" },
{ art: "befehl", maskieren: (wert) => wert },
);
expect(ergebnis.text).toBe("Set-Name Forschung & Entwicklung");
});
it("lässt einen Wert für die Anzeige unverändert", () => {
/*
* Hier wird **nicht** HTML-maskiert: React maskiert Textknoten selbst, und
* eine zweite Maskierung machte aus „Forschung & Entwicklung“ sichtbar
* „Forschung &amp;amp; Entwicklung“. Dass die Maskierung wirklich
* stattfindet, beweist tests/platzhalter-anzeige.test.tsx am gerenderten
* HTML — nicht dieser Test hier.
*/
expect(platzhalterErsetzen("{{abteilung}}", { abteilung: "Forschung & Entwicklung" }).text).toBe(
"Forschung & Entwicklung",
);
});
});
describe("Generiertes Passwort", () => {
const BEFEHL = '-AccountPassword "{{generiertes_passwort}}"';
it("erscheint im angezeigten Befehl", () => {
const ergebnis = platzhalterErsetzen(BEFEHL, {});
expect(ergebnis.mitPasswort).toBe(true);
expect(ergebnis.text).not.toContain("{{generiertes_passwort}}");
expect(ergebnis.text).toMatch(/-AccountPassword "\S{20}"/u);
});
it("ist bei zwei Aufrufen verschieden", () => {
// Akzeptanzkriterium: je Anzeige neu. Dasselbe Passwort zweimal waere eine
// stillschweigende Speicherung.
const erster = platzhalterErsetzen(BEFEHL, {}).text;
const zweiter = platzhalterErsetzen(BEFEHL, {}).text;
expect(erster).not.toBe(zweiter);
});
it("steht in der Druckansicht als Hinweis statt als Passwort", () => {
// Ein Ausdruck liegt herum – und das Passwort ist nirgends gespeichert,
// also stuende dort ein Geheimnis, das niemand mehr zuordnen kann.
const ergebnis = platzhalterErsetzen(BEFEHL, {}, { art: "druck" });
expect(ergebnis.text).toBe(`-AccountPassword "${PASSWORT_IM_DRUCK}"`);
expect(ergebnis.mitPasswort).toBe(true);
});
it("gibt das Passwort nicht neben dem Text zurück", () => {
/*
* Die harte Zusage des Meilensteins: es gibt **keinen** Weg, das erzeugte
* Passwort ausser im Text zu bekommen. Gaebe es ein Feld dafuer, waere die
* Versuchung gross, es „nur fuers Protokoll“ mitzuschreiben.
*/
const ergebnis = platzhalterErsetzen(BEFEHL, {});
expect(Object.keys(ergebnis).sort()).toEqual(["mitPasswort", "text", "unbekannt"]);
expect(JSON.stringify(ergebnis)).not.toMatch(/passwort":\s*"[A-Za-z0-9]/u);
});
it("meldet kein Passwort, wenn keines vorkam", () => {
expect(platzhalterErsetzen("Hallo {{vorname}}", WERTE).mitPasswort).toBe(false);
});
it("erzeugt für zwei Vorkommen im selben Text zwei verschiedene Passwörter", () => {
/*
* Fachlich fragwuerdig, aber die ehrliche Folge der Regel „bei jeder Anzeige
* neu“: wer den Platzhalter zweimal in einen Befehl schreibt, meint
* vermutlich dasselbe Passwort. Dass es zwei sind, faellt beim Lesen auf —
* eines still zu speichern und wiederzuverwenden fiele nicht auf.
*/
const ergebnis = platzhalterErsetzen("{{generiertes_passwort}} und {{generiertes_passwort}}", {});
const [erstes, zweites] = ergebnis.text.split(" und ");
expect(erstes).not.toBe(zweites);
});
});
+362 -127
View File
@@ -1,150 +1,385 @@
import { describe, expect, it } from "vitest";
import {
EINGEBAUTE_PLATZHALTER,
QUELLEN_TEXT,
istEingebaut,
unbekanntePlatzhalter,
unbenutzteVariablen,
} from "@/lib/platzhalter";
import { platzhalterDerStruktur, VariableSchema, type Struktur } from "@/lib/vorlagen-format";
import { beforeEach, describe, expect, it } from "vitest";
import { Betriebsmodus, Herkunft } from "@/generated/prisma/enums";
import { ouAusDn, platzhalterWerteDesLaufs } from "@/server/services/platzhalter";
import { platzhalterErsetzen } from "@/lib/platzhalter-werte";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
/**
* Die eingebauten Platzhalter (#010).
* Die Werte hinter den Platzhaltern (#012).
*
* Die Liste ist Daten, und Daten koennen still falsch sein: ein Schluessel mit
* Bindestrich wuerde im Lauf nie ersetzt, weil `platzhalterFinden` ihn nicht als
* Platzhalter erkennt. Deshalb wird die Liste hier gegen genau dieselbe Regel
* geprueft, die auch fuer Variablen gilt.
* Aus diesen Werten entsteht der PowerShell-Befehl, den ein Techniker auf einem
* Domaenencontroller ausfuehrt. Deshalb steht hier jede Regel des Auftrags
* einzeln, mit den Daten, die im Haus wirklich vorkommen — einschliesslich
* „Jörg Müller-Lüdenscheid“.
*/
describe("Liste der eingebauten Platzhalter", () => {
it("haelt sich an die Schluesselregel der Variablen", () => {
const schluesselRegel = VariableSchema.shape.schluessel;
for (const platzhalter of EINGEBAUTE_PLATZHALTER) {
const ergebnis = schluesselRegel.safeParse(platzhalter.schluessel);
expect(ergebnis.success, `${platzhalter.schluessel}: ${ergebnis.error?.message}`).toBe(true);
}
const DN = "CN=Maria Huber,OU=Buchhaltung,OU=Benutzer,DC=firma,DC=local";
async function aufbau(modus: Betriebsmodus = Betriebsmodus.INTERN) {
const eigene = await testPrisma.kunde.create({
data: { name: "Muster GmbH", kuerzel: "MUS", istEigeneOrganisation: true },
});
await testPrisma.einstellung.create({ data: { id: "global", modus } });
const abteilung = await testPrisma.abteilung.create({
data: {
kundeId: eigene.id,
name: "Buchhaltung",
zielOu: "OU=Buchhaltung,OU=Benutzer,DC=firma,DC=de",
standardGruppen: ["G-Buchhaltung", "G-Alle"],
},
});
it("nennt jeden Schluessel nur einmal", () => {
const schluessel = EINGEBAUTE_PLATZHALTER.map((p) => p.schluessel);
expect(new Set(schluessel).size).toBe(schluessel.length);
const quelle = await testPrisma.verzeichnisquelle.create({
data: {
kundeId: eigene.id,
name: "AD",
url: "ldaps://dc01.firma.local:636",
basisDn: "DC=firma,DC=local",
filter: "(objectClass=user)",
attributZuordnung: {},
bindDn: "CN=svc,DC=firma,DC=local",
bindPasswortVerschluesselt: "v1.egal",
},
});
it("erklaert jeden Platzhalter und ordnet ihn einer benannten Quelle zu", () => {
for (const platzhalter of EINGEBAUTE_PLATZHALTER) {
expect(platzhalter.erklaerung.trim().length, platzhalter.schluessel).toBeGreaterThan(0);
expect(QUELLEN_TEXT[platzhalter.quelle], platzhalter.schluessel).toBeTruthy();
}
const chefin = await testPrisma.mitarbeiter.create({
data: {
kundeId: eigene.id,
quelleId: quelle.id,
externeId: "guid-chefin",
vorname: "Sonja",
nachname: "Brand",
anzeigename: "Sonja Brand",
sam: "sbrand",
herkunft: Herkunft.ABGLEICH,
},
});
it("enthaelt die im Auftrag genannten Platzhalter", () => {
// Namentlich, damit das Verschwinden eines einzelnen auffaellt – eine
// Vorlage, die ihn benutzt, bekaeme sonst plötzlich eine Warnung.
for (const schluessel of [
"hostname",
"seriennummer",
"inventarnummer",
"ticket",
"datum",
"techniker",
"kunde",
"kundenkuerzel",
"mitarbeiter",
"benutzer",
"vorname",
"nachname",
"upn",
"sam",
"mail",
"abteilung",
"titel",
"vorgesetzter",
"vorgesetzter_sam",
"dn",
"ou",
"gruppen",
"generiertes_passwort",
]) {
expect(istEingebaut(schluessel), schluessel).toBe(true);
}
const person = await testPrisma.mitarbeiter.create({
data: {
kundeId: eigene.id,
quelleId: quelle.id,
externeId: "guid-maria",
vorname: "Maria",
nachname: "Huber",
anzeigename: "Maria Huber",
mail: "maria.huber@firma.de",
upn: "maria.huber@firma.de",
sam: "mhuber",
abteilung: "Buchhaltung",
titel: "Sachbearbeiterin",
dn: DN,
vorgesetzterExterneId: "guid-chefin",
herkunft: Herkunft.ABGLEICH,
},
});
it("kennzeichnet die Platzhalter, die nur im Modus Dienstleistung gefuellt sind", () => {
const nurDienstleistung = EINGEBAUTE_PLATZHALTER.filter((p) => p.nurDienstleistung).map(
(p) => p.schluessel,
const geraet = await testPrisma.geraet.create({
data: {
kundeId: eigene.id,
tagCode: "LZ-7K3F9Q",
hostname: "PC-001",
seriennummer: "SN-4711",
inventarnummer: "INV-99",
registriertAm: new Date(),
},
});
const vorlage = await testPrisma.vorlage.create({ data: { name: "Onboarding" } });
const version = await testPrisma.vorlagenVersion.create({
data: { vorlageId: vorlage.id, version: 1, status: "ENTWURF" },
});
await testPrisma.vorlagenVersion.update({
where: { id: version.id },
data: { status: "VEROEFFENTLICHT", veroeffentlichtAm: new Date() },
});
return { eigene, abteilung, person, chefin, geraet, versionId: version.id };
}
async function laufAnlegen(daten: Parameters<typeof testPrisma.lauf.create>[0]["data"]): Promise<string> {
const lauf = await testPrisma.lauf.create({ data: daten });
return lauf.id;
}
describe("OU aus dem DN", () => {
it("nimmt alles nach dem ersten Komma", () => {
// Akzeptanzkriterium. Das ist der Ort, an dem das Konto liegt – genau den
// braucht ein New-ADUser -Path.
expect(ouAusDn(DN)).toBe("OU=Buchhaltung,OU=Benutzer,DC=firma,DC=local");
});
it("übergeht ein maskiertes Komma im Namen", () => {
// Im DN ist ein Komma innerhalb eines Namens mit Backslash maskiert. Ohne
// diese Regel bliebe von „Huber\, Maria“ nur „ Maria,OU=…“ übrig.
expect(ouAusDn("CN=Huber\\, Maria,OU=Buchhaltung,DC=firma,DC=local")).toBe(
"OU=Buchhaltung,DC=firma,DC=local",
);
expect(nurDienstleistung.sort()).toEqual(["kunde", "kundenkuerzel"]);
});
it("liefert nichts bei einem DN ohne Komma", () => {
expect(ouAusDn("CN=Allein")).toBe("");
expect(ouAusDn("")).toBe("");
});
});
describe("Unbekannte Platzhalter", () => {
it("meldet, was weder eingebaut noch als Variable definiert ist", () => {
expect(unbekanntePlatzhalter(["hostname", "os_version", "tipfehler"], ["os_version"])).toEqual([
"tipfehler",
]);
describe.runIf(datenbankVerfuegbar)("Werte eines Laufs", () => {
beforeEach(async () => {
await datenbankLeeren();
});
it("meldet nichts, wenn alles bekannt ist", () => {
expect(unbekanntePlatzhalter(["hostname", "generiertes_passwort"], [])).toEqual([]);
it("liefert Gerät, Lauf, Mitarbeiter und Abteilung", async () => {
const { eigene, abteilung, person, geraet, versionId } = await aufbau();
const laufId = await laufAnlegen({
vorlagenVersionId: versionId,
titel: "Onboarding Maria Huber",
kundeId: eigene.id,
mitarbeiterId: person.id,
geraetId: geraet.id,
abteilungId: abteilung.id,
ticket: "INC-4711",
});
const werte = await platzhalterWerteDesLaufs({
laufId,
technikerName: "Ali Demir",
jetzt: new Date("2026-09-25T10:00:00Z"),
});
expect(werte).toMatchObject({
hostname: "PC-001",
seriennummer: "SN-4711",
inventarnummer: "INV-99",
ticket: "INC-4711",
datum: "25.09.2026",
techniker: "Ali Demir",
mitarbeiter: "Maria Huber",
benutzer: "Maria Huber",
vorname: "Maria",
nachname: "Huber",
upn: "maria.huber@firma.de",
sam: "mhuber",
mail: "maria.huber@firma.de",
abteilung: "Buchhaltung",
titel: "Sachbearbeiterin",
dn: DN,
gruppen: "G-Buchhaltung, G-Alle",
vorgesetzter: "Sonja Brand",
vorgesetzter_sam: "sbrand",
});
});
it("findet einen Tippfehler in einem echten Aufbau", () => {
// Der praktische Fall: „{{hostnmae}}" im Befehl. Ohne Warnung merkt es erst
// der Techniker, wenn der Befehl den Platzhalter im Klartext enthaelt.
const struktur: Struktur = {
variablen: [
{
schluessel: "os_version",
beschriftung: "Betriebssystem",
typ: "TEXT",
optionen: [],
pflicht: false,
},
],
abschnitte: [
{
titel: "Installation",
schritte: [
{
titel: "Rechner umbenennen",
typ: "BEFEHL",
pflicht: true,
konfig: {
shell: "powershell",
befehl: 'Rename-Computer -NewName "{{hostnmae}}" # soll {{hostname}} sein',
},
},
{
titel: "{{os_version}} installieren",
hilfetext: "Danach {{ datum }} im Ticket vermerken.",
typ: "HAKEN",
pflicht: true,
konfig: {},
},
],
},
],
};
const verwendet = platzhalterDerStruktur(struktur);
expect(verwendet).toContain("hostnmae");
expect(
unbekanntePlatzhalter(
verwendet,
struktur.variablen.map((v) => v.schluessel),
),
).toEqual(["hostnmae"]);
});
});
describe("Unbenutzte Variablen", () => {
it("meldet eine Variable, die nirgends vorkommt", () => {
// Eine definierte Variable, die kein Schritt benutzt, fragt den Techniker
// beim Start nach etwas, das nie irgendwo auftaucht.
expect(unbenutzteVariablen(["hostname"], ["os_version", "join_typ"])).toEqual(["os_version", "join_typ"]);
});
it("meldet nichts, wenn jede Variable benutzt wird", () => {
expect(unbenutzteVariablen(["os_version"], ["os_version"])).toEqual([]);
it("nimmt für die OU den Eltern-Container des dn, nicht die Ziel-OU", async () => {
// Der dn sagt, wo das Konto **wirklich** liegt; die Ziel-OU der Abteilung
// ist die Absicht fuer ein Konto, das es noch nicht gibt.
const { eigene, abteilung, person, versionId } = await aufbau();
const laufId = await laufAnlegen({
vorlagenVersionId: versionId,
titel: "Umzug",
kundeId: eigene.id,
mitarbeiterId: person.id,
abteilungId: abteilung.id,
});
const werte = await platzhalterWerteDesLaufs({ laufId });
expect(werte["ou"]).toBe("OU=Buchhaltung,OU=Benutzer,DC=firma,DC=local");
});
it("nimmt die Ziel-OU der Abteilung, wenn die Person keinen dn hat", async () => {
// Der Onboarding-Fall: das Konto gibt es noch nicht.
const { eigene, abteilung, versionId } = await aufbau();
const laufId = await laufAnlegen({
vorlagenVersionId: versionId,
titel: "Onboarding",
kundeId: eigene.id,
mitarbeiterName: "Jörg Müller-Lüdenscheid",
abteilungId: abteilung.id,
});
const werte = await platzhalterWerteDesLaufs({ laufId });
expect(werte["ou"]).toBe("OU=Buchhaltung,OU=Benutzer,DC=firma,DC=de");
expect(werte["mitarbeiter"]).toBe("Jörg Müller-Lüdenscheid");
expect(werte["benutzer"]).toBe("Jörg Müller-Lüdenscheid");
});
it("lässt im Modus Intern Kunde und Kürzel leer", async () => {
// Akzeptanzkriterium: im Modus Intern gibt es die Kundenebene fuer den
// Benutzer nicht – ihren Namen in einen Befehl zu schreiben waere eine
// Auskunft ueber etwas, das es dort nicht gibt.
const { eigene, versionId } = await aufbau(Betriebsmodus.INTERN);
const laufId = await laufAnlegen({
vorlagenVersionId: versionId,
titel: "Patchday",
kundeId: eigene.id,
});
const werte = await platzhalterWerteDesLaufs({ laufId });
expect(werte["kunde"]).toBeUndefined();
expect(werte["kundenkuerzel"]).toBeUndefined();
// Und in der Ersetzung wird daraus leer, nicht „unbekannt“.
const ergebnis = platzhalterErsetzen("[{{kunde}}]", werte);
expect(ergebnis.text).toBe("[]");
expect(ergebnis.unbekannt).toEqual([]);
});
it("füllt im Modus Dienstleistung Kunde und Kürzel", async () => {
const { eigene, versionId } = await aufbau(Betriebsmodus.DIENSTLEISTUNG);
const laufId = await laufAnlegen({
vorlagenVersionId: versionId,
titel: "Patchday",
kundeId: eigene.id,
});
const werte = await platzhalterWerteDesLaufs({ laufId });
expect(werte["kunde"]).toBe("Muster GmbH");
expect(werte["kundenkuerzel"]).toBe("MUS");
});
it("nimmt die Bezeichnung, wenn ein Gerät keinen Hostname hat", async () => {
const { eigene, versionId } = await aufbau();
const schrank = await testPrisma.geraet.create({
data: {
kundeId: eigene.id,
tagCode: "LZ-8M4G2R",
bezeichnung: "Serverschrank Keller",
registriertAm: new Date(),
},
});
const laufId = await laufAnlegen({
vorlagenVersionId: versionId,
titel: "Wartung",
kundeId: eigene.id,
geraetId: schrank.id,
});
expect((await platzhalterWerteDesLaufs({ laufId }))["hostname"]).toBe("Serverschrank Keller");
});
it("lässt eine Vorlagenvariable den Gerätewert überschreiben", async () => {
/*
* Die Variable ist die ausdrueckliche Angabe **dieses** Laufs, der
* Geraetewert nur die Voreinstellung. Beim Umbenennen eines Rechners steht
* im Befehl der neue Name, nicht der alte aus der Datenbank.
*/
const { eigene, geraet, versionId } = await aufbau();
const laufId = await laufAnlegen({
vorlagenVersionId: versionId,
titel: "Umbenennen",
kundeId: eigene.id,
geraetId: geraet.id,
variablen: { hostname: "PC-NEU-007", os_version: "Windows 11 Pro" },
});
const werte = await platzhalterWerteDesLaufs({ laufId });
expect(werte["hostname"]).toBe("PC-NEU-007");
expect(werte["os_version"]).toBe("Windows 11 Pro");
});
it("lässt eine Variable die gesperrten Platzhalter nicht überschreiben", async () => {
/*
* Der Blocker aus dem Review: die Vorlagenvariablen gewannen gegen **alles**.
* Eine Variable `techniker` liess einen Befehl mit fremdem Namen
* unterschreiben, eine Variable `kunde` holte im Modus INTERN die
* Kundenebene zurueck, die es dort ausdruecklich nicht gibt.
*/
const { eigene, versionId } = await aufbau(Betriebsmodus.INTERN);
const laufId = await laufAnlegen({
vorlagenVersionId: versionId,
titel: "Angriff",
kundeId: eigene.id,
variablen: {
kunde: "Fremde AG",
kundenkuerzel: "FRD",
techniker: "Nicht ich",
datum: "01.01.1999",
generiertes_passwort: "geheim",
},
});
const werte = await platzhalterWerteDesLaufs({
laufId,
technikerName: "Ali Demir",
jetzt: new Date("2026-09-25T10:00:00Z"),
});
expect(werte["kunde"]).toBeUndefined();
expect(werte["kundenkuerzel"]).toBeUndefined();
expect(werte["techniker"]).toBe("Ali Demir");
expect(werte["datum"]).toBe("25.09.2026");
expect(werte["generiertes_passwort"]).toBeUndefined();
// Und das Passwort bleibt das erzeugte, nicht das aus der Variablen.
const ergebnis = platzhalterErsetzen("{{generiertes_passwort}}", werte);
expect(ergebnis.text).not.toBe("geheim");
expect(ergebnis.text).toHaveLength(20);
});
it("nimmt Zahlen und Haken aus den Variablen als Text auf", async () => {
const { eigene, versionId } = await aufbau();
const laufId = await laufAnlegen({
vorlagenVersionId: versionId,
titel: "Prüfung",
kundeId: eigene.id,
variablen: { arbeitsspeicher: 32, verschluesselt: true, kaputt: { tief: 1 } },
});
const werte = await platzhalterWerteDesLaufs({ laufId });
expect(werte["arbeitsspeicher"]).toBe("32");
expect(werte["verschluesselt"]).toBe("true");
// Was kein einfacher Wert ist, bleibt draussen – im Befehl stuende sonst
// „[object Object]“.
expect(werte["kaputt"]).toBeUndefined();
});
it("kommt mit einem Lauf ohne Gerät und ohne Person aus", async () => {
// Ein Lauf ohne beides ist gueltig: Server, Firewall, Patchday. Abzubrechen
// hiesse, dem Techniker die Arbeit zu verweigern.
const { eigene, versionId } = await aufbau();
const laufId = await laufAnlegen({
vorlagenVersionId: versionId,
titel: "Patchday",
kundeId: eigene.id,
});
const werte = await platzhalterWerteDesLaufs({ laufId });
expect(werte["hostname"]).toBeUndefined();
expect(werte["mitarbeiter"]).toBeUndefined();
expect(werte["datum"]).toBeTruthy();
});
it("liefert nichts für einen Lauf, den es nicht gibt", async () => {
expect(await platzhalterWerteDesLaufs({ laufId: "gibtsnicht" })).toEqual({});
});
it("baut einen vollständigen New-ADUser-Befehl", async () => {
/*
* Der Ablauf aus dem Auftrag, Ende zu Ende: Abteilung „Buchhaltung“ gewählt,
* daraus `-Path "OU=Buchhaltung,…"` und die Gruppen `G-Buchhaltung, G-Alle`.
*/
const { eigene, abteilung, versionId } = await aufbau();
const laufId = await laufAnlegen({
vorlagenVersionId: versionId,
titel: "Onboarding",
kundeId: eigene.id,
mitarbeiterName: "Jörg Müller-Lüdenscheid",
abteilungId: abteilung.id,
variablen: { upn: "joerg.mueller-luedenscheid@firma.de", sam: "jmuellerluedensch" },
});
const werte = await platzhalterWerteDesLaufs({ laufId });
const befehl = platzhalterErsetzen(
'New-ADUser -Name "{{mitarbeiter}}" -UserPrincipalName "{{upn}}" ' +
'-SamAccountName "{{sam}}" -Path "{{ou}}"',
werte,
{ art: "befehl", maskieren: (wert) => wert },
);
expect(befehl.text).toBe(
'New-ADUser -Name "Jörg Müller-Lüdenscheid" ' +
'-UserPrincipalName "joerg.mueller-luedenscheid@firma.de" ' +
'-SamAccountName "jmuellerluedensch" ' +
'-Path "OU=Buchhaltung,OU=Benutzer,DC=firma,DC=de"',
);
expect(befehl.unbekannt).toEqual([]);
const gruppen = platzhalterErsetzen("Add-ADGroupMember {{gruppen}}", werte, {
art: "befehl",
maskieren: (wert) => wert,
});
expect(gruppen.text).toBe("Add-ADGroupMember G-Buchhaltung, G-Alle");
});
});
+394
View File
@@ -0,0 +1,394 @@
import { execFileSync } from "node:child_process";
import { existsSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
KOMMENTAR_ZEICHEN,
ShellFehler,
type SkriptSchritt,
UMBRUCH,
skriptBauen,
wertMaskieren,
} from "@/lib/shell";
/**
* Shell-Maskierung und Skript-Kopie (#012).
*
* Die Zeichenketten-Vergleiche halten fest, **was** maskiert wird. Sie beweisen
* aber nicht, dass die Maskierung traegt — das tut nur die Shell selbst. Fuer
* Bash geschieht das hier wirklich: der maskierte Wert wird an `bash -c`
* uebergeben und es wird geprueft, dass genau **ein** Argument ankommt, dass es
* Zeichen fuer Zeichen dem Ausgangswert gleicht und dass nichts ausgefuehrt
* wurde. Fuer PowerShell steht dieselbe Probe bereit, laeuft aber nur, wo `pwsh`
* vorhanden ist.
*/
/** Ein Wert, der alles versammelt, was eine Shell gefaehrlich findet. */
const BOESARTIG = `O'Brien"; rm -rf / && echo $(whoami) \`id\` $HOME | tee %PATH% !x!\nzweite Zeile`;
/**
* Derselbe Angriff mit **typografischen** Anfuehrungszeichen.
*
* PowerShell liest `“`, `”` und `„` als doppeltes und `‘`, `’`, `‚`, `‛` als
* einfaches Anfuehrungszeichen (about_Quoting_Rules, gegen PowerShell 7
* nachgemessen). Bis zum Review von #012 gingen sie unmaskiert durch — ein
* Abteilungsname, den jemand aus Word kopiert, reichte fuer Codeausfuehrung auf
* dem Domaenencontroller.
*/
const TYPOGRAFISCH = "Mueller\u201D; New-Item -ItemType File -Path /tmp/marke > $null; \u201CX";
function vorhanden(programm: string): boolean {
try {
execFileSync(programm, ["--version"], { stdio: "ignore" });
return true;
} catch {
return false;
}
}
const bashDa = vorhanden("bash");
/**
* Wie PowerShell hier erreichbar ist – und warum das nicht egal ist.
*
* Diese Maschine hat kein `pwsh`. Der Test dagegen hat sich deshalb bisher
* **still uebersprungen**, und ein stilles Ueberspringen sieht genauso gruen aus
* wie ein bestandener Lauf — genau die Falle, die `tests/helpers/db.ts` fuer die
* Datenbank bewusst zugemacht hat. Der Review von #012 hat den Blocker mit den
* typografischen Anfuehrungszeichen erst gefunden, nachdem er sich PowerShell
* aus einem Container geholt hat.
*
* Also: erst `pwsh`, sonst das Container-Abbild, und in CI gar kein
* Ueberspringen mehr.
*/
function pwshAufruf(): { readonly programm: string; readonly vorlauf: readonly string[] } | undefined {
if (vorhanden("pwsh")) return { programm: "pwsh", vorlauf: [] };
try {
execFileSync("docker", ["image", "inspect", PWSH_ABBILD], { stdio: "ignore" });
return { programm: "docker", vorlauf: ["run", "--rm", "-i", PWSH_ABBILD, "pwsh"] };
} catch {
return undefined;
}
}
const PWSH_ABBILD = "mcr.microsoft.com/powershell:latest";
const pwsh = pwshAufruf();
if (!pwsh && process.env["CI"]) {
throw new Error(
`Weder pwsh noch das Abbild ${PWSH_ABBILD} ist da. In CI darf die PowerShell-Maskierung ` +
"nicht ungeprueft bleiben: `docker pull " +
PWSH_ABBILD +
"`.",
);
}
describe("wertMaskieren fuer PowerShell", () => {
it("verdoppelt das doppelte Anfuehrungszeichen", () => {
expect(wertMaskieren('Jörg "der Chef" Müller', "powershell")).toBe('Jörg ""der Chef"" Müller');
});
it("verdoppelt den Backtick", () => {
expect(wertMaskieren("a`b", "powershell")).toBe("a``b");
});
it("maskiert das Dollarzeichen, damit $(…) nicht ausgefuehrt wird", () => {
// Genau hier entscheidet sich, ob ein Wert Text bleibt oder zu einem
// Unterausdruck wird, den PowerShell beim Aufbau der Zeichenkette ausfuehrt.
expect(wertMaskieren("$(rm -rf /)", "powershell")).toBe("`$(rm -rf /)");
expect(wertMaskieren("$env:PATH", "powershell")).toBe("`$env:PATH");
});
it("maskiert Backtick und Dollar in der richtigen Reihenfolge", () => {
// Zuerst der Backtick, dann das Dollarzeichen: umgekehrt wuerde die
// Maskierung `$ am Ende selbst wieder verdoppelt und das $ waere frei.
// "```$x" bedeutet in PowerShell: literaler Backtick, literales Dollar, x.
expect(wertMaskieren("`$x", "powershell")).toBe("```$x");
});
it("macht aus Zeilenumbruechen die Fluchtfolgen `r und `n", () => {
expect(wertMaskieren("erste\r\nzweite", "powershell")).toBe("erste`r`nzweite");
});
it("laesst einen harmlosen Wert unveraendert", () => {
expect(wertMaskieren("Jörg Müller-Lüdenscheid", "powershell")).toBe("Jörg Müller-Lüdenscheid");
expect(wertMaskieren("", "powershell")).toBe("");
});
it("maskiert jedes gefaehrliche Zeichen des Sammelwerts", () => {
const maskiert = wertMaskieren(BOESARTIG, "powershell");
// Kein unmaskiertes $ und kein einzelnes " bleibt uebrig.
expect(maskiert).not.toMatch(/(^|[^`])\$/);
expect(maskiert).not.toMatch(/(^|[^"])"([^"]|$)/);
expect(maskiert).not.toMatch(/[\r\n]/);
});
it("entschaerft typografische Anfuehrungszeichen", () => {
/*
* Der Blocker aus dem Review von #012: sie beenden eine
* PowerShell-Zeichenkette genauso wie `"`, gingen aber unmaskiert durch.
* Angeglichen wird auf die ASCII-Form und **dann** verdoppelt — als Text
* durchreichen laesst sich das Zeichen nicht, PowerShell liest es ohnehin
* als Begrenzer.
*/
expect(wertMaskieren("a\u201Db", "powershell")).toBe('a""b');
expect(wertMaskieren("a\u201Cb", "powershell")).toBe('a""b');
expect(wertMaskieren("a\u201Eb", "powershell")).toBe('a""b');
expect(wertMaskieren(TYPOGRAFISCH, "powershell")).not.toMatch(/[\u201C\u201D\u201E]/u);
// Das typografische Apostroph wird zum geraden – in `"…"` ist es harmlos
// und wird deshalb **nicht** verdoppelt.
expect(wertMaskieren("O\u2019Neill", "powershell")).toBe("O'Neill");
});
describe.skipIf(!pwsh)("gegen ein echtes PowerShell geprueft", () => {
it("kommt als genau ein Argument an und fuehrt nichts aus", () => {
for (const wert of [BOESARTIG, TYPOGRAFISCH]) {
const maskiert = wertMaskieren(wert, "powershell");
const skript = `$werte = @("${maskiert}"); "$($werte.Count)|$($werte[0])"`;
const ausgabe = execFileSync(pwsh!.programm, [...pwsh!.vorlauf, "-NoProfile", "-Command", skript], {
encoding: "utf8",
timeout: 60_000,
});
const erwartet = wert
.replace(/[\u201C\u201D\u201E]/gu, '"')
.replace(/[\u2018\u2019\u201A\u201B]/gu, "'")
.replace(/\r?\n/g, "\n");
expect(ausgabe.trimEnd()).toBe(`1|${erwartet}`);
}
});
});
});
describe("wertMaskieren fuer Bash", () => {
it("bricht das einfache Anfuehrungszeichen heraus", () => {
expect(wertMaskieren("O'Brien", "bash")).toBe(`O'\\''Brien`);
});
it("laesst alles uebrige unveraendert – in '…' ist nichts davon besonders", () => {
expect(wertMaskieren("$HOME `id` \\ ; && | > <", "bash")).toBe("$HOME `id` \\ ; && | > <");
// Ein Umbruch bekommt Bashs eigene Schreibweise: die Zeichenkette wird
// geschlossen, das Zeichen als Fluchtfolge angehaengt, wieder geoeffnet.
// Sonst veraendert die Skript-Kopie ihn spaeter (sie normalisiert Umbrueche).
expect(wertMaskieren("erste\nzweite", "bash")).toBe(`erste'$'\\n''zweite`);
expect(wertMaskieren("", "bash")).toBe("");
});
describe.skipIf(!bashDa)("gegen ein echtes bash geprueft", () => {
let verzeichnis = "";
let marke = "";
beforeAll(() => {
verzeichnis = mkdtempSync(join(tmpdir(), "laufzettel-shell-"));
marke = join(verzeichnis, "ausgefuehrt");
});
afterAll(() => {
rmSync(verzeichnis, { recursive: true, force: true });
});
/** Gibt zurueck, wie viele Argumente ankamen und was im ersten stand. */
function alsArgument(wert: string): { anzahl: string; erstes: string } {
const skript = `set -- '${wertMaskieren(wert, "bash")}'\nprintf '%s\\x1f%s' "$#" "$1"`;
const ausgabe = execFileSync("bash", ["-c", skript], { encoding: "utf8" });
const [anzahl = "", erstes = ""] = ausgabe.split("\u001f");
return { anzahl, erstes };
}
it.each([
["einfaches Anfuehrungszeichen", "O'Brien"],
["Befehlsersetzung mit Dollar", "$(whoami)"],
["Befehlsersetzung mit Backtick", "`id`"],
["Trennzeichen", "a; b && c | d"],
["Zeilenumbruch", "erste\nzweite"],
["Rueckwaertsschraegstrich", "C:\\Users\\Jörg"],
["Sternchen und Tilde", "*.* ~ ?"],
["alles zusammen", BOESARTIG],
])("traegt %s als genau ein Argument", (_fall, wert) => {
const { anzahl, erstes } = alsArgument(wert);
expect(anzahl).toBe("1");
expect(erstes).toBe(wert);
});
it("fuehrt nichts aus, was im Wert steht", () => {
// Der Beweis ist die Datei, die nicht entsteht: schlaegt die Maskierung
// fehl, laeuft `touch` und die Marke liegt da.
const wert = `x'; touch ${marke}; echo 'y $(touch ${marke}) \`touch ${marke}\``;
const { anzahl, erstes } = alsArgument(wert);
expect(anzahl).toBe("1");
expect(erstes).toBe(wert);
expect(existsSync(marke)).toBe(false);
});
});
});
describe("wertMaskieren fuer Cmd", () => {
it('laesst die Trennzeichen stehen – in "…" sind sie gewoehnliche Zeichen', () => {
expect(wertMaskieren("a & b | c > d < e ^ f", "cmd")).toBe("a & b | c > d < e ^ f");
expect(wertMaskieren("Jörg Müller-Lüdenscheid", "cmd")).toBe("Jörg Müller-Lüdenscheid");
});
it("laesst Prozent und Ausrufezeichen stehen", () => {
// Bewusst und dokumentiert: `%%` waere nur in einer Batch-Datei richtig, in
// einer interaktiven Eingabe falsch. Die Folge ist im schlimmsten Fall ein
// falscher Wert, nicht fremder Code – Cmd kennt keine Befehlsersetzung.
expect(wertMaskieren("%PATH% !x!", "cmd")).toBe("%PATH% !x!");
});
it.each([
["ein doppeltes Anfuehrungszeichen", 'a" & del /q C:\\*'],
["einen Wagenruecklauf", "a\rb"],
["einen Zeilenumbruch", "a\nb"],
["ein Null-Byte", "a\u0000b"],
])("lehnt einen Wert mit %s ab", (_fall, wert) => {
// Ablehnen statt unsicher durchlassen: ein `"` beendet das Argument, und
// alles danach liest cmd.exe als Befehl.
expect(() => wertMaskieren(wert, "cmd")).toThrow(ShellFehler);
});
it("sagt in der Meldung, was im Weg ist und was zu tun ist", () => {
expect(() => wertMaskieren('a"b', "cmd")).toThrow(/Anführungszeichen/);
expect(() => wertMaskieren('a"b', "cmd")).toThrow(/PowerShell/);
});
});
describe("skriptBauen", () => {
const powershellSchritte: readonly SkriptSchritt[] = [
{ titel: "Konto anlegen", shell: "powershell", befehl: 'New-ADUser -Name "Jörg"' },
{ titel: "Gruppen setzen", shell: "powershell", befehl: "Add-ADGroupMember -Identity IT" },
];
it("reiht die Befehle in der uebergebenen Reihenfolge mit Kommentarzeilen", () => {
expect(skriptBauen(powershellSchritte)).toBe(
[
"# Schritt: Konto anlegen",
'New-ADUser -Name "Jörg"',
"",
"# Schritt: Gruppen setzen",
"Add-ADGroupMember -Identity IT",
"",
].join("\r\n"),
);
});
it("trennt die Bloecke durch genau eine Leerzeile", () => {
const skript = skriptBauen(powershellSchritte);
expect(skript).toContain(`New-ADUser -Name "Jörg"\r\n\r\n# Schritt: Gruppen setzen`);
expect(skript).not.toContain("\r\n\r\n\r\n");
});
it("endet mit einem Umbruch, damit die letzte Zeile ausgefuehrt wird", () => {
expect(skriptBauen(powershellSchritte).endsWith("\r\n")).toBe(true);
});
it("nimmt fuer bash den Zeilenvorschub", () => {
const skript = skriptBauen([{ titel: "Anlegen", shell: "bash", befehl: "useradd joerg" }]);
expect(skript).toBe("# Schritt: Anlegen\nuseradd joerg\n");
expect(skript).not.toContain("\r");
});
it("nimmt fuer cmd REM und den Wagenruecklauf", () => {
const skript = skriptBauen([{ titel: "Anlegen", shell: "cmd", befehl: "net user joerg /add" }]);
expect(skript).toBe("REM Schritt: Anlegen\r\nnet user joerg /add\r\n");
expect(KOMMENTAR_ZEICHEN.cmd).toBe("REM");
});
it("nimmt bei gemischten Shells den Zeilenvorschub", () => {
// Ein gemischtes Skript ist ohnehin von keinem Interpreter als Ganzes
// ausfuehrbar; ein Wagenruecklauf wuerde die bash-Zeilen zusaetzlich
// beschaedigen.
const skript = skriptBauen([
{ titel: "Erst PowerShell", shell: "powershell", befehl: "Get-ADUser joerg" },
{ titel: "Dann bash", shell: "bash", befehl: "id joerg" },
]);
expect(skript).not.toContain("\r");
expect(skript).toContain("# Schritt: Erst PowerShell");
expect(skript).toContain("# Schritt: Dann bash");
});
it("bringt die Umbrueche innerhalb eines mehrzeiligen Befehls auf denselben Stand", () => {
// Eine CRLF-Datei mit einzelnen Zeilenvorschuechen darin ist keine.
const skript = skriptBauen([
{ titel: "Mehrzeilig", shell: "powershell", befehl: "Zeile eins\nZeile zwei\r\nZeile drei" },
]);
expect(skript).toBe("# Schritt: Mehrzeilig\r\nZeile eins\r\nZeile zwei\r\nZeile drei\r\n");
expect(skript).not.toMatch(/[^\r]\n/);
});
it("laesst einen Titel mit Zeilenumbruch den Kommentar nicht sprengen", () => {
// Die zweite Haelfte des Titels wuerde sonst als Befehl in der Zeile
// darunter stehen – und `format c:` fuehrt niemand aus Versehen aus.
const skript = skriptBauen([
{ titel: "Konto anlegen\r\nformat c:", shell: "powershell", befehl: "Get-ADUser joerg" },
]);
expect(skript).toBe("# Schritt: Konto anlegen format c:\r\nGet-ADUser joerg\r\n");
expect(skript.split("\r\n").filter((zeile) => zeile !== "")).toHaveLength(2);
});
it("faengt auch Steuerzeichen und Leerzeichenketten im Titel", () => {
const skript = skriptBauen([{ titel: " Konto\t\tanlegen ", shell: "bash", befehl: "id joerg" }]);
expect(skript).toBe("# Schritt: Konto anlegen\nid joerg\n");
});
it("setzt fuer einen Titel, von dem nichts bleibt, einen Ersatz ein", () => {
const skript = skriptBauen([{ titel: "\n\t", shell: "bash", befehl: "id joerg" }]);
expect(skript).toBe("# Schritt: (ohne Titel)\nid joerg\n");
});
it("laesst Schritte ohne Befehl weg", () => {
const skript = skriptBauen([
{ titel: "Leer", shell: "bash", befehl: " " },
{ titel: "Voll", shell: "bash", befehl: "id joerg" },
]);
expect(skript).toBe("# Schritt: Voll\nid joerg\n");
});
it("liefert bei keinem Schritt und bei nur leeren Befehlen eine leere Zeichenkette", () => {
expect(skriptBauen([])).toBe("");
expect(skriptBauen([{ titel: "Leer", shell: "cmd", befehl: "" }])).toBe("");
});
it("haelt die Umbrueche je Shell fest", () => {
expect(UMBRUCH.powershell).toBe("\r\n");
expect(UMBRUCH.cmd).toBe("\r\n");
expect(UMBRUCH.bash).toBe("\n");
});
describe.skipIf(!bashDa)("das erzeugte bash-Skript laeuft", () => {
it("fuehrt beide Befehle in der Reihenfolge aus und haelt die Kommentare heraus", () => {
const skript = skriptBauen([
{ titel: "Erster # mit Rauten", shell: "bash", befehl: "printf 'eins'" },
{ titel: "Zweiter", shell: "bash", befehl: "printf 'zwei'" },
]);
expect(execFileSync("bash", ["-c", skript], { encoding: "utf8" })).toBe("einszwei");
});
it("fuehrt einen Titel nicht aus, der wie ein Befehl aussieht", () => {
const verzeichnis = mkdtempSync(join(tmpdir(), "laufzettel-skript-"));
const marke = join(verzeichnis, "ausgefuehrt");
try {
const skript = skriptBauen([
{ titel: `Anlegen\ntouch ${marke}`, shell: "bash", befehl: "printf 'eins'" },
]);
expect(execFileSync("bash", ["-c", skript], { encoding: "utf8" })).toBe("eins");
expect(existsSync(marke)).toBe(false);
} finally {
rmSync(verzeichnis, { recursive: true, force: true });
}
});
});
});