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

Aus einer Vorlage wird hier der PowerShell-Befehl, den ein Techniker kopiert und
auf einem Domaenencontroller ausfuehrt. Entsprechend dicht ist die Abdeckung:
810 Tests, und die Maskierung wird **ausgefuehrt** geprueft, nicht gelesen.

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

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

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

Schliesst #012

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-25 15:29:43 +02:00
co-authored by Claude Opus 5
parent f85bc87324
commit b59fbb4645
21 changed files with 4069 additions and 166 deletions
+362 -127
View File
@@ -1,150 +1,385 @@
import { describe, expect, it } from "vitest";
import {
EINGEBAUTE_PLATZHALTER,
QUELLEN_TEXT,
istEingebaut,
unbekanntePlatzhalter,
unbenutzteVariablen,
} from "@/lib/platzhalter";
import { platzhalterDerStruktur, VariableSchema, type Struktur } from "@/lib/vorlagen-format";
import { beforeEach, describe, expect, it } from "vitest";
import { Betriebsmodus, Herkunft } from "@/generated/prisma/enums";
import { ouAusDn, platzhalterWerteDesLaufs } from "@/server/services/platzhalter";
import { platzhalterErsetzen } from "@/lib/platzhalter-werte";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
/**
* Die eingebauten Platzhalter (#010).
* Die Werte hinter den Platzhaltern (#012).
*
* Die Liste ist Daten, und Daten koennen still falsch sein: ein Schluessel mit
* Bindestrich wuerde im Lauf nie ersetzt, weil `platzhalterFinden` ihn nicht als
* Platzhalter erkennt. Deshalb wird die Liste hier gegen genau dieselbe Regel
* geprueft, die auch fuer Variablen gilt.
* Aus diesen Werten entsteht der PowerShell-Befehl, den ein Techniker auf einem
* Domaenencontroller ausfuehrt. Deshalb steht hier jede Regel des Auftrags
* einzeln, mit den Daten, die im Haus wirklich vorkommen — einschliesslich
* „Jörg Müller-Lüdenscheid“.
*/
describe("Liste der eingebauten Platzhalter", () => {
it("haelt sich an die Schluesselregel der Variablen", () => {
const schluesselRegel = VariableSchema.shape.schluessel;
for (const platzhalter of EINGEBAUTE_PLATZHALTER) {
const ergebnis = schluesselRegel.safeParse(platzhalter.schluessel);
expect(ergebnis.success, `${platzhalter.schluessel}: ${ergebnis.error?.message}`).toBe(true);
}
const DN = "CN=Maria Huber,OU=Buchhaltung,OU=Benutzer,DC=firma,DC=local";
async function aufbau(modus: Betriebsmodus = Betriebsmodus.INTERN) {
const eigene = await testPrisma.kunde.create({
data: { name: "Muster GmbH", kuerzel: "MUS", istEigeneOrganisation: true },
});
await testPrisma.einstellung.create({ data: { id: "global", modus } });
const abteilung = await testPrisma.abteilung.create({
data: {
kundeId: eigene.id,
name: "Buchhaltung",
zielOu: "OU=Buchhaltung,OU=Benutzer,DC=firma,DC=de",
standardGruppen: ["G-Buchhaltung", "G-Alle"],
},
});
it("nennt jeden Schluessel nur einmal", () => {
const schluessel = EINGEBAUTE_PLATZHALTER.map((p) => p.schluessel);
expect(new Set(schluessel).size).toBe(schluessel.length);
const quelle = await testPrisma.verzeichnisquelle.create({
data: {
kundeId: eigene.id,
name: "AD",
url: "ldaps://dc01.firma.local:636",
basisDn: "DC=firma,DC=local",
filter: "(objectClass=user)",
attributZuordnung: {},
bindDn: "CN=svc,DC=firma,DC=local",
bindPasswortVerschluesselt: "v1.egal",
},
});
it("erklaert jeden Platzhalter und ordnet ihn einer benannten Quelle zu", () => {
for (const platzhalter of EINGEBAUTE_PLATZHALTER) {
expect(platzhalter.erklaerung.trim().length, platzhalter.schluessel).toBeGreaterThan(0);
expect(QUELLEN_TEXT[platzhalter.quelle], platzhalter.schluessel).toBeTruthy();
}
const chefin = await testPrisma.mitarbeiter.create({
data: {
kundeId: eigene.id,
quelleId: quelle.id,
externeId: "guid-chefin",
vorname: "Sonja",
nachname: "Brand",
anzeigename: "Sonja Brand",
sam: "sbrand",
herkunft: Herkunft.ABGLEICH,
},
});
it("enthaelt die im Auftrag genannten Platzhalter", () => {
// Namentlich, damit das Verschwinden eines einzelnen auffaellt – eine
// Vorlage, die ihn benutzt, bekaeme sonst plötzlich eine Warnung.
for (const schluessel of [
"hostname",
"seriennummer",
"inventarnummer",
"ticket",
"datum",
"techniker",
"kunde",
"kundenkuerzel",
"mitarbeiter",
"benutzer",
"vorname",
"nachname",
"upn",
"sam",
"mail",
"abteilung",
"titel",
"vorgesetzter",
"vorgesetzter_sam",
"dn",
"ou",
"gruppen",
"generiertes_passwort",
]) {
expect(istEingebaut(schluessel), schluessel).toBe(true);
}
const person = await testPrisma.mitarbeiter.create({
data: {
kundeId: eigene.id,
quelleId: quelle.id,
externeId: "guid-maria",
vorname: "Maria",
nachname: "Huber",
anzeigename: "Maria Huber",
mail: "maria.huber@firma.de",
upn: "maria.huber@firma.de",
sam: "mhuber",
abteilung: "Buchhaltung",
titel: "Sachbearbeiterin",
dn: DN,
vorgesetzterExterneId: "guid-chefin",
herkunft: Herkunft.ABGLEICH,
},
});
it("kennzeichnet die Platzhalter, die nur im Modus Dienstleistung gefuellt sind", () => {
const nurDienstleistung = EINGEBAUTE_PLATZHALTER.filter((p) => p.nurDienstleistung).map(
(p) => p.schluessel,
const geraet = await testPrisma.geraet.create({
data: {
kundeId: eigene.id,
tagCode: "LZ-7K3F9Q",
hostname: "PC-001",
seriennummer: "SN-4711",
inventarnummer: "INV-99",
registriertAm: new Date(),
},
});
const vorlage = await testPrisma.vorlage.create({ data: { name: "Onboarding" } });
const version = await testPrisma.vorlagenVersion.create({
data: { vorlageId: vorlage.id, version: 1, status: "ENTWURF" },
});
await testPrisma.vorlagenVersion.update({
where: { id: version.id },
data: { status: "VEROEFFENTLICHT", veroeffentlichtAm: new Date() },
});
return { eigene, abteilung, person, chefin, geraet, versionId: version.id };
}
async function laufAnlegen(daten: Parameters<typeof testPrisma.lauf.create>[0]["data"]): Promise<string> {
const lauf = await testPrisma.lauf.create({ data: daten });
return lauf.id;
}
describe("OU aus dem DN", () => {
it("nimmt alles nach dem ersten Komma", () => {
// Akzeptanzkriterium. Das ist der Ort, an dem das Konto liegt – genau den
// braucht ein New-ADUser -Path.
expect(ouAusDn(DN)).toBe("OU=Buchhaltung,OU=Benutzer,DC=firma,DC=local");
});
it("übergeht ein maskiertes Komma im Namen", () => {
// Im DN ist ein Komma innerhalb eines Namens mit Backslash maskiert. Ohne
// diese Regel bliebe von „Huber\, Maria“ nur „ Maria,OU=…“ übrig.
expect(ouAusDn("CN=Huber\\, Maria,OU=Buchhaltung,DC=firma,DC=local")).toBe(
"OU=Buchhaltung,DC=firma,DC=local",
);
expect(nurDienstleistung.sort()).toEqual(["kunde", "kundenkuerzel"]);
});
it("liefert nichts bei einem DN ohne Komma", () => {
expect(ouAusDn("CN=Allein")).toBe("");
expect(ouAusDn("")).toBe("");
});
});
describe("Unbekannte Platzhalter", () => {
it("meldet, was weder eingebaut noch als Variable definiert ist", () => {
expect(unbekanntePlatzhalter(["hostname", "os_version", "tipfehler"], ["os_version"])).toEqual([
"tipfehler",
]);
describe.runIf(datenbankVerfuegbar)("Werte eines Laufs", () => {
beforeEach(async () => {
await datenbankLeeren();
});
it("meldet nichts, wenn alles bekannt ist", () => {
expect(unbekanntePlatzhalter(["hostname", "generiertes_passwort"], [])).toEqual([]);
it("liefert Gerät, Lauf, Mitarbeiter und Abteilung", async () => {
const { eigene, abteilung, person, geraet, versionId } = await aufbau();
const laufId = await laufAnlegen({
vorlagenVersionId: versionId,
titel: "Onboarding Maria Huber",
kundeId: eigene.id,
mitarbeiterId: person.id,
geraetId: geraet.id,
abteilungId: abteilung.id,
ticket: "INC-4711",
});
const werte = await platzhalterWerteDesLaufs({
laufId,
technikerName: "Ali Demir",
jetzt: new Date("2026-09-25T10:00:00Z"),
});
expect(werte).toMatchObject({
hostname: "PC-001",
seriennummer: "SN-4711",
inventarnummer: "INV-99",
ticket: "INC-4711",
datum: "25.09.2026",
techniker: "Ali Demir",
mitarbeiter: "Maria Huber",
benutzer: "Maria Huber",
vorname: "Maria",
nachname: "Huber",
upn: "maria.huber@firma.de",
sam: "mhuber",
mail: "maria.huber@firma.de",
abteilung: "Buchhaltung",
titel: "Sachbearbeiterin",
dn: DN,
gruppen: "G-Buchhaltung, G-Alle",
vorgesetzter: "Sonja Brand",
vorgesetzter_sam: "sbrand",
});
});
it("findet einen Tippfehler in einem echten Aufbau", () => {
// Der praktische Fall: „{{hostnmae}}" im Befehl. Ohne Warnung merkt es erst
// der Techniker, wenn der Befehl den Platzhalter im Klartext enthaelt.
const struktur: Struktur = {
variablen: [
{
schluessel: "os_version",
beschriftung: "Betriebssystem",
typ: "TEXT",
optionen: [],
pflicht: false,
},
],
abschnitte: [
{
titel: "Installation",
schritte: [
{
titel: "Rechner umbenennen",
typ: "BEFEHL",
pflicht: true,
konfig: {
shell: "powershell",
befehl: 'Rename-Computer -NewName "{{hostnmae}}" # soll {{hostname}} sein',
},
},
{
titel: "{{os_version}} installieren",
hilfetext: "Danach {{ datum }} im Ticket vermerken.",
typ: "HAKEN",
pflicht: true,
konfig: {},
},
],
},
],
};
const verwendet = platzhalterDerStruktur(struktur);
expect(verwendet).toContain("hostnmae");
expect(
unbekanntePlatzhalter(
verwendet,
struktur.variablen.map((v) => v.schluessel),
),
).toEqual(["hostnmae"]);
});
});
describe("Unbenutzte Variablen", () => {
it("meldet eine Variable, die nirgends vorkommt", () => {
// Eine definierte Variable, die kein Schritt benutzt, fragt den Techniker
// beim Start nach etwas, das nie irgendwo auftaucht.
expect(unbenutzteVariablen(["hostname"], ["os_version", "join_typ"])).toEqual(["os_version", "join_typ"]);
});
it("meldet nichts, wenn jede Variable benutzt wird", () => {
expect(unbenutzteVariablen(["os_version"], ["os_version"])).toEqual([]);
it("nimmt für die OU den Eltern-Container des dn, nicht die Ziel-OU", async () => {
// Der dn sagt, wo das Konto **wirklich** liegt; die Ziel-OU der Abteilung
// ist die Absicht fuer ein Konto, das es noch nicht gibt.
const { eigene, abteilung, person, versionId } = await aufbau();
const laufId = await laufAnlegen({
vorlagenVersionId: versionId,
titel: "Umzug",
kundeId: eigene.id,
mitarbeiterId: person.id,
abteilungId: abteilung.id,
});
const werte = await platzhalterWerteDesLaufs({ laufId });
expect(werte["ou"]).toBe("OU=Buchhaltung,OU=Benutzer,DC=firma,DC=local");
});
it("nimmt die Ziel-OU der Abteilung, wenn die Person keinen dn hat", async () => {
// Der Onboarding-Fall: das Konto gibt es noch nicht.
const { eigene, abteilung, versionId } = await aufbau();
const laufId = await laufAnlegen({
vorlagenVersionId: versionId,
titel: "Onboarding",
kundeId: eigene.id,
mitarbeiterName: "Jörg Müller-Lüdenscheid",
abteilungId: abteilung.id,
});
const werte = await platzhalterWerteDesLaufs({ laufId });
expect(werte["ou"]).toBe("OU=Buchhaltung,OU=Benutzer,DC=firma,DC=de");
expect(werte["mitarbeiter"]).toBe("Jörg Müller-Lüdenscheid");
expect(werte["benutzer"]).toBe("Jörg Müller-Lüdenscheid");
});
it("lässt im Modus Intern Kunde und Kürzel leer", async () => {
// Akzeptanzkriterium: im Modus Intern gibt es die Kundenebene fuer den
// Benutzer nicht – ihren Namen in einen Befehl zu schreiben waere eine
// Auskunft ueber etwas, das es dort nicht gibt.
const { eigene, versionId } = await aufbau(Betriebsmodus.INTERN);
const laufId = await laufAnlegen({
vorlagenVersionId: versionId,
titel: "Patchday",
kundeId: eigene.id,
});
const werte = await platzhalterWerteDesLaufs({ laufId });
expect(werte["kunde"]).toBeUndefined();
expect(werte["kundenkuerzel"]).toBeUndefined();
// Und in der Ersetzung wird daraus leer, nicht „unbekannt“.
const ergebnis = platzhalterErsetzen("[{{kunde}}]", werte);
expect(ergebnis.text).toBe("[]");
expect(ergebnis.unbekannt).toEqual([]);
});
it("füllt im Modus Dienstleistung Kunde und Kürzel", async () => {
const { eigene, versionId } = await aufbau(Betriebsmodus.DIENSTLEISTUNG);
const laufId = await laufAnlegen({
vorlagenVersionId: versionId,
titel: "Patchday",
kundeId: eigene.id,
});
const werte = await platzhalterWerteDesLaufs({ laufId });
expect(werte["kunde"]).toBe("Muster GmbH");
expect(werte["kundenkuerzel"]).toBe("MUS");
});
it("nimmt die Bezeichnung, wenn ein Gerät keinen Hostname hat", async () => {
const { eigene, versionId } = await aufbau();
const schrank = await testPrisma.geraet.create({
data: {
kundeId: eigene.id,
tagCode: "LZ-8M4G2R",
bezeichnung: "Serverschrank Keller",
registriertAm: new Date(),
},
});
const laufId = await laufAnlegen({
vorlagenVersionId: versionId,
titel: "Wartung",
kundeId: eigene.id,
geraetId: schrank.id,
});
expect((await platzhalterWerteDesLaufs({ laufId }))["hostname"]).toBe("Serverschrank Keller");
});
it("lässt eine Vorlagenvariable den Gerätewert überschreiben", async () => {
/*
* Die Variable ist die ausdrueckliche Angabe **dieses** Laufs, der
* Geraetewert nur die Voreinstellung. Beim Umbenennen eines Rechners steht
* im Befehl der neue Name, nicht der alte aus der Datenbank.
*/
const { eigene, geraet, versionId } = await aufbau();
const laufId = await laufAnlegen({
vorlagenVersionId: versionId,
titel: "Umbenennen",
kundeId: eigene.id,
geraetId: geraet.id,
variablen: { hostname: "PC-NEU-007", os_version: "Windows 11 Pro" },
});
const werte = await platzhalterWerteDesLaufs({ laufId });
expect(werte["hostname"]).toBe("PC-NEU-007");
expect(werte["os_version"]).toBe("Windows 11 Pro");
});
it("lässt eine Variable die gesperrten Platzhalter nicht überschreiben", async () => {
/*
* Der Blocker aus dem Review: die Vorlagenvariablen gewannen gegen **alles**.
* Eine Variable `techniker` liess einen Befehl mit fremdem Namen
* unterschreiben, eine Variable `kunde` holte im Modus INTERN die
* Kundenebene zurueck, die es dort ausdruecklich nicht gibt.
*/
const { eigene, versionId } = await aufbau(Betriebsmodus.INTERN);
const laufId = await laufAnlegen({
vorlagenVersionId: versionId,
titel: "Angriff",
kundeId: eigene.id,
variablen: {
kunde: "Fremde AG",
kundenkuerzel: "FRD",
techniker: "Nicht ich",
datum: "01.01.1999",
generiertes_passwort: "geheim",
},
});
const werte = await platzhalterWerteDesLaufs({
laufId,
technikerName: "Ali Demir",
jetzt: new Date("2026-09-25T10:00:00Z"),
});
expect(werte["kunde"]).toBeUndefined();
expect(werte["kundenkuerzel"]).toBeUndefined();
expect(werte["techniker"]).toBe("Ali Demir");
expect(werte["datum"]).toBe("25.09.2026");
expect(werte["generiertes_passwort"]).toBeUndefined();
// Und das Passwort bleibt das erzeugte, nicht das aus der Variablen.
const ergebnis = platzhalterErsetzen("{{generiertes_passwort}}", werte);
expect(ergebnis.text).not.toBe("geheim");
expect(ergebnis.text).toHaveLength(20);
});
it("nimmt Zahlen und Haken aus den Variablen als Text auf", async () => {
const { eigene, versionId } = await aufbau();
const laufId = await laufAnlegen({
vorlagenVersionId: versionId,
titel: "Prüfung",
kundeId: eigene.id,
variablen: { arbeitsspeicher: 32, verschluesselt: true, kaputt: { tief: 1 } },
});
const werte = await platzhalterWerteDesLaufs({ laufId });
expect(werte["arbeitsspeicher"]).toBe("32");
expect(werte["verschluesselt"]).toBe("true");
// Was kein einfacher Wert ist, bleibt draussen – im Befehl stuende sonst
// „[object Object]“.
expect(werte["kaputt"]).toBeUndefined();
});
it("kommt mit einem Lauf ohne Gerät und ohne Person aus", async () => {
// Ein Lauf ohne beides ist gueltig: Server, Firewall, Patchday. Abzubrechen
// hiesse, dem Techniker die Arbeit zu verweigern.
const { eigene, versionId } = await aufbau();
const laufId = await laufAnlegen({
vorlagenVersionId: versionId,
titel: "Patchday",
kundeId: eigene.id,
});
const werte = await platzhalterWerteDesLaufs({ laufId });
expect(werte["hostname"]).toBeUndefined();
expect(werte["mitarbeiter"]).toBeUndefined();
expect(werte["datum"]).toBeTruthy();
});
it("liefert nichts für einen Lauf, den es nicht gibt", async () => {
expect(await platzhalterWerteDesLaufs({ laufId: "gibtsnicht" })).toEqual({});
});
it("baut einen vollständigen New-ADUser-Befehl", async () => {
/*
* Der Ablauf aus dem Auftrag, Ende zu Ende: Abteilung „Buchhaltung“ gewählt,
* daraus `-Path "OU=Buchhaltung,…"` und die Gruppen `G-Buchhaltung, G-Alle`.
*/
const { eigene, abteilung, versionId } = await aufbau();
const laufId = await laufAnlegen({
vorlagenVersionId: versionId,
titel: "Onboarding",
kundeId: eigene.id,
mitarbeiterName: "Jörg Müller-Lüdenscheid",
abteilungId: abteilung.id,
variablen: { upn: "joerg.mueller-luedenscheid@firma.de", sam: "jmuellerluedensch" },
});
const werte = await platzhalterWerteDesLaufs({ laufId });
const befehl = platzhalterErsetzen(
'New-ADUser -Name "{{mitarbeiter}}" -UserPrincipalName "{{upn}}" ' +
'-SamAccountName "{{sam}}" -Path "{{ou}}"',
werte,
{ art: "befehl", maskieren: (wert) => wert },
);
expect(befehl.text).toBe(
'New-ADUser -Name "Jörg Müller-Lüdenscheid" ' +
'-UserPrincipalName "joerg.mueller-luedenscheid@firma.de" ' +
'-SamAccountName "jmuellerluedensch" ' +
'-Path "OU=Buchhaltung,OU=Benutzer,DC=firma,DC=de"',
);
expect(befehl.unbekannt).toEqual([]);
const gruppen = platzhalterErsetzen("Add-ADGroupMember {{gruppen}}", werte, {
art: "befehl",
maskieren: (wert) => wert,
});
expect(gruppen.text).toBe("Add-ADGroupMember G-Buchhaltung, G-Alle");
});
});