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
+138
View File
@@ -0,0 +1,138 @@
import { describe, expect, it } from "vitest";
import {
PASSWORT_GROSS,
PASSWORT_KLEIN,
PASSWORT_LAENGE,
PASSWORT_SONDER,
PASSWORT_ZIFFERN,
passwortErzeugen,
} from "@/lib/passwort";
/**
* Das generierte Passwort (#012).
*
* Die Zusage lautet: bei jeder Anzeige neu und nirgends gespeichert. Hier steht
* die eine Haelfte davon — dass es wirklich jedes Mal ein anderes ist und dass
* es die Anforderungen erfuellt, an denen ein `New-ADUser` sonst scheitert.
*
* Die andere Haelfte — dass es in keiner Tabelle landet — steht in
* `tests/befehle.test.ts` („schreibt beim Fuellen nichts in die Datenbank“). Sie
* prueft, was heute pruefbar ist: dass der Weg vom Lauf zum fertigen Befehl
* nichts schreibt. `Schrittergebnis` und `Kommentar` gibt es erst mit #013; dort
* gehoert derselbe Beweis noch einmal hin, wenn es sie gibt.
*/
const ALLE = PASSWORT_GROSS + PASSWORT_KLEIN + PASSWORT_ZIFFERN + PASSWORT_SONDER;
describe("Erzeugtes Passwort", () => {
it("ist 20 Zeichen lang", () => {
expect(passwortErzeugen()).toHaveLength(20);
expect(PASSWORT_LAENGE).toBe(20);
});
it("benutzt nur Zeichen aus dem vereinbarten Vorrat", () => {
for (let versuch = 0; versuch < 200; versuch += 1) {
for (const zeichen of passwortErzeugen()) {
expect(ALLE.includes(zeichen), `„${zeichen}“ gehört nicht zum Vorrat`).toBe(true);
}
}
});
it("enthält aus jeder Gruppe mindestens ein Zeichen", () => {
// Die Passwortrichtlinie eines Active Directory verlangt ueblicherweise drei
// von vier Gruppen. Ein abgewiesenes New-ADUser laesst den Techniker vor dem
// Kunden stehen.
for (let versuch = 0; versuch < 200; versuch += 1) {
const passwort = passwortErzeugen();
for (const [name, gruppe] of [
["Großbuchstaben", PASSWORT_GROSS],
["Kleinbuchstaben", PASSWORT_KLEIN],
["Ziffern", PASSWORT_ZIFFERN],
["Sonderzeichen", PASSWORT_SONDER],
] as const) {
expect(
[...passwort].some((z) => gruppe.includes(z)),
`${name} fehlen in „${passwort}“`,
).toBe(true);
}
}
});
it("enthält keine verwechselbaren Zeichen", () => {
/*
* Das Passwort wird vom Bildschirm abgelesen und getippt – oft von der
* Person, der das Konto gehoert. Ein falsch gelesenes Zeichen kostet einen
* Anruf.
*
* Verwechselbar ist immer ein **Paar**: aus jedem Paar fehlt genau einer,
* dann ist der andere eindeutig. `B` ist draussen, also darf `8` bleiben;
* `O` ist draussen, also darf nichts mit `0` verwechselt werden — `0` ist
* trotzdem weg, weil es mit `o` zusammenfaellt.
*/
for (let versuch = 0; versuch < 200; versuch += 1) {
expect(passwortErzeugen()).not.toMatch(/[IlB1|O0]/u);
}
});
it("lässt aus jedem verwechselbaren Paar genau einen übrig", () => {
// Haelt die Begruendung oben fest: `8` ohne `B` ist eindeutig, `1` ohne `l`
// und `I` waere es auch — es ist trotzdem draussen, weil `|` in manchen
// Schriften gleich aussieht.
const vorrat = PASSWORT_GROSS + PASSWORT_KLEIN + PASSWORT_ZIFFERN;
for (const paar of ["I1", "l1", "O0", "o0", "B8", "Il"]) {
const drin = [...paar].filter((zeichen) => vorrat.includes(zeichen));
expect(drin.length, `„${paar}“ ist beides im Vorrat`).toBeLessThanOrEqual(1);
}
});
it("enthält nichts, was einen Befehl oder eine CSV-Zelle zerbricht", () => {
// Die Maskierung faengt das ab – aber ein Passwort, das nur wegen korrekter
// Maskierung funktioniert, ist eine Wette auf jeden kuenftigen Aufrufer.
for (let versuch = 0; versuch < 200; versuch += 1) {
expect(passwortErzeugen()).not.toMatch(/["'$`\\;&|<>%,\n\r]/u);
}
});
it("liefert bei zwei Aufrufen verschiedene Passwörter", () => {
// Genau das Akzeptanzkriterium: ein zweiter Aufruf ist ein **anderes**
// Passwort, nicht dasselbe noch einmal.
const gesehen = new Set<string>();
for (let versuch = 0; versuch < 500; versuch += 1) {
gesehen.add(passwortErzeugen());
}
expect(gesehen.size).toBe(500);
});
it("verteilt die Pflichtzeichen über die ganze Länge", () => {
// Ohne Mischen staenden Gross, Klein, Ziffer und Sonderzeichen immer an den
// ersten vier Stellen — erkennbar, und damit vier Zeichen weniger Zufall.
const stellen = new Set<number>();
for (let versuch = 0; versuch < 300; versuch += 1) {
const passwort = passwortErzeugen();
const stelle = [...passwort].findIndex((z) => PASSWORT_SONDER.includes(z));
stellen.add(stelle);
}
expect(stellen.size).toBeGreaterThan(10);
});
it("weist eine Länge ab, die nicht alle vier Gruppen fassen kann", () => {
expect(() => passwortErzeugen(3)).toThrow();
expect(passwortErzeugen(4)).toHaveLength(4);
});
it("fängt nie mit einem Sonderzeichen an", () => {
// `= + - @` am Anfang einer Zelle liest eine Tabellenkalkulation als Formel,
// und ein Passwort geht seinen Weg oft ueber Zwischenablage und Tabelle.
for (let versuch = 0; versuch < 500; versuch += 1) {
expect(PASSWORT_SONDER.includes(passwortErzeugen().charAt(0))).toBe(false);
}
});
it("enthält kein Ausrufezeichen", () => {
// Bei eingeschalteter verzoegerter Ersetzung frisst cmd.exe es still – das
// Konto bekaeme ein anderes Passwort als angezeigt.
for (let versuch = 0; versuch < 200; versuch += 1) {
expect(passwortErzeugen()).not.toContain("!");
}
});
});