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

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

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

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

Schliesst #012

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

386 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { beforeEach, describe, expect, it } from "vitest";
import { Betriebsmodus, 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 Werte hinter den Platzhaltern (#012).
*
* 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“.
*/
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"],
},
});
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",
},
});
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,
},
});
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,
},
});
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",
);
});
it("liefert nichts bei einem DN ohne Komma", () => {
expect(ouAusDn("CN=Allein")).toBe("");
expect(ouAusDn("")).toBe("");
});
});
describe.runIf(datenbankVerfuegbar)("Werte eines Laufs", () => {
beforeEach(async () => {
await datenbankLeeren();
});
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("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");
});
});