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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
f85bc87324
commit
b59fbb4645
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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("!");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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; 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("<script>");
|
||||
});
|
||||
|
||||
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;“.
|
||||
const { text } = platzhalterErsetzen("{{abteilung}}", {
|
||||
abteilung: "Forschung & Entwicklung",
|
||||
});
|
||||
const html = renderToStaticMarkup(<p>{text}</p>);
|
||||
expect(html).toBe("<p>Forschung & 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("<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>");
|
||||
});
|
||||
});
|
||||
@@ -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 `&` 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; 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
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user