feat(platzhalter): Aufloesung, Namensregeln und Shell-Maskierung (#26)
This commit was merged in pull request #26.
This commit is contained in:
@@ -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([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user