feat(platzhalter): Aufloesung, Namensregeln und Shell-Maskierung #26

Merged
florian.wilde merged 1 commits from feat/12-platzhalter-namensregeln into main 2026-09-25 13:30:31 +00:00
21 changed files with 4069 additions and 166 deletions
+19
View File
@@ -84,6 +84,16 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
Anführungszeichen und Zeilenumbrüchen im Wert zurecht (#011) Anführungszeichen und Zeilenumbrüchen im Wert zurecht (#011)
- Die Seitenleiste führt Personen und Geräte unter einem Eintrag; ein Umschalter wechselt zwischen - Die Seitenleiste führt Personen und Geräte unter einem Eintrag; ein Umschalter wechselt zwischen
beiden Listen (#011) beiden Listen (#011)
- Platzhalter werden aufgelöst: eingebaute Werte aus Gerät, Lauf, Mitarbeiter und Abteilung, dazu
die Variablen der Vorlage. Ein unbekannter Platzhalter bleibt sichtbar stehen, statt still zu
verschwinden (#012)
- `{{ou}}` einer Person aus dem Verzeichnis ist der Eltern-Container ihres DN — genau der Pfad, den
ein `New-ADUser -Path` braucht (#012)
- Namensregeln für `upn`, `sam`, `mail` und `hostname` mit Transliteration, `{v}`, `{nnn}` und
`{kundenkuerzel}`; im Modus Dienstleistung überlagern Kundenregeln die globalen Feld für Feld
(#012)
- „Alle Befehle als Skript kopieren“ je Abschnitt: die Befehle in Reihenfolge, je mit einer
Kommentarzeile darüber und dem Zeilenumbruch der jeweiligen Shell (#012)
### Sicherheit ### Sicherheit
@@ -140,3 +150,12 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
- Ein Gerät, an dem Läufe hängen, lässt sich nicht löschen, nur archivieren (#011) - Ein Gerät, an dem Läufe hängen, lässt sich nicht löschen, nur archivieren (#011)
- Der Tag-Code entsteht beim Anlegen, steht sofort im Vergabebuch und lässt sich nie ändern — beides - Der Tag-Code entsteht beim Anlegen, steht sofort im Vergabebuch und lässt sich nie ändern — beides
in einer Transaktion, damit kein Code ohne Eintrag entsteht (#011) in einer Transaktion, damit kein Code ohne Eintrag entsteht (#011)
- Werte werden für die jeweilige Shell maskiert, bevor sie in einen Befehl kommen: PowerShell
verdoppelt `"` und Backtick und entschärft `$`, Bash setzt einfache Anführungszeichen. Für Cmd
wird ein Wert mit `"` oder Zeilenumbruch **abgelehnt** statt unsicher maskiert — dort gibt es
keine Regel, die in jedem Fall stimmt (#012)
- `{{generiertes_passwort}}` entsteht bei jeder Anzeige neu, besteht aus 20 Zeichen ohne
verwechselbare Buchstaben und wird nirgends gespeichert; in der Druckansicht steht
`[Passwort – nicht gespeichert]` (#012)
- Ein eingesetzter Wert wird nicht doppelt maskiert: React maskiert Textknoten selbst, und ein Test
am gerenderten HTML hält fest, dass ein `<script>` im Namen wirklich als Text ankommt (#012)
+26
View File
@@ -150,6 +150,32 @@ Die Content-Security-Policy in `src/proxy.ts` ist deshalb zweigeteilt: im Entwic
Prüfung der Funktion mit selbst gesetztem Argument hätte genau den Fehler durchgelassen, vor dem Prüfung der Funktion mit selbst gesetztem Argument hätte genau den Fehler durchgelassen, vor dem
der Kommentar darüber warnt. der Kommentar darüber warnt.
## 5c. Warum die Maskierung dort sitzt, wo sie sitzt
Aus einer Vorlage entsteht ein PowerShell-Befehl, den ein Techniker kopiert und auf einem
Domänencontroller ausführt. Drei Entscheidungen tragen das (#012):
1. **Maskiert wird der eingesetzte Wert, nicht der fertige Befehl.** Sonst maskierte man die
Anführungszeichen des Befehls gleich mit. `platzhalterErsetzen` bekommt die Maskierung deshalb
als Funktion und wendet sie auf jeden Wert einzeln an.
2. **Die Maskierung setzt keine Anführungszeichen dazu** — die stehen in der Vorlage. Das ist eine
Annahme, und sie wird geprüft: steht ein `{{platzhalter}}` in einem `BEFEHL`-Schritt nicht
zwischen den erwarteten Anführungszeichen, wird der Schritt abgewiesen statt gefüllt. Ein halb
maskierter Befehl zum Kopieren wäre die gefährlichste aller Antworten.
3. **Was sich nicht sicher maskieren lässt, wird abgelehnt.** Für Cmd gibt es keine Regel, die ein
`"` in jedem Fall richtig behandelt — ein solcher Wert bekommt eine Fehlermeldung, keinen
Befehl. PowerShell liest auch `“`, `”` und `„` als Begrenzer; die werden auf ihre ASCII-Form
gebracht und dann maskiert, weil sie sich als Text ohnehin nicht durchreichen lassen.
Geprüft wird das **ausgeführt**, nicht gelesen: `tests/shell.test.ts` schickt bösartige Werte durch
ein echtes `bash -c` und durch ein echtes PowerShell — und wenn auf der Maschine kein `pwsh` liegt,
nimmt der Test das Container-Abbild. In CI bricht er ab, statt sich zu überspringen; ein stilles
Überspringen sieht genauso grün aus wie ein bestandener Lauf.
Das **generierte Passwort** entsteht erst beim Ersetzen und wird nirgends zurückgegeben — es gibt
keinen Weg, es außerhalb des fertigen Texts zu bekommen. Gäbe es ein Feld dafür, wäre die
Versuchung groß, es „nur fürs Protokoll“ mitzuschreiben.
## 6. Betrieb: zwei Wege, eine Regel ## 6. Betrieb: zwei Wege, eine Regel
Die Datenbank ist auf beiden Wegen von außen nicht erreichbar: Die Datenbank ist auf beiden Wegen von außen nicht erreichbar:
+55 -2
View File
@@ -14,11 +14,12 @@ als Unterpunkte mit Aufgabe und Dauer.
| #009 | M4 – Vorlagen-Versionierung im Service | 1 h 20 min | | #009 | M4 – Vorlagen-Versionierung im Service | 1 h 20 min |
| #010 | M5 – Vorlagen-Editor | 2 h 10 min | | #010 | M5 – Vorlagen-Editor | 2 h 10 min |
| #011 | M6 – Mitarbeiter, Abteilungen, Geräte | 2 h 15 min | | #011 | M6 – Mitarbeiter, Abteilungen, Geräte | 2 h 15 min |
| | **Summe** | **11 h 15 min** | | #012 | M7 – Platzhalter, Namensregeln, Escaping | 2 h 30 min |
| | **Summe** | **13 h 45 min** |
| Meilenstein | Tatsächlich | Status | | Meilenstein | Tatsächlich | Status |
|---|---|---| |---|---|---|
| v0.1.0 | 11 h 15 min | in Arbeit | | v0.1.0 | 13 h 45 min | in Arbeit |
--- ---
@@ -452,3 +453,55 @@ als Unterpunkte mit Aufgabe und Dauer.
- #012 Platzhalter, Namensregeln und Escaping: die reine Service-Schicht, aus der die fertigen - #012 Platzhalter, Namensregeln und Escaping: die reine Service-Schicht, aus der die fertigen
PowerShell-Befehle entstehen — mit dichter Testabdeckung, denn hier darf nichts schiefgehen. PowerShell-Befehle entstehen — mit dichter Testabdeckung, denn hier darf nichts schiefgehen.
---
## 2026-09-25 · Sitzung 8 · 12:55–15:35
**Issue:** #012 (M7)
**Version:** 0.1.0
### Erledigt
- `src/lib/shell.ts` (Sub-Agent): Maskierung je Shell, Skript-Kopie mit Kommentarzeilen
- `src/lib/namensregeln.ts` (Sub-Agent): Transliteration, `{v}`, `{nnn}`, `{kundenkuerzel}`,
Grenzen für `sam` und `hostname`
- `src/lib/passwort.ts`, `src/lib/platzhalter-werte.ts`, `src/server/services/platzhalter.ts` und
`src/server/services/befehle.ts`: das generierte Passwort, das Ersetzen und die fertigen Befehle
- Unabhängiger Review: **drei Blocker**, sieben weitere Befunde, alle abgearbeitet
- **810 Tests** grün (vorher 797 mit einem stillen Übersprung)
### Entscheidungen
- **PowerShell wird wirklich gegen PowerShell geprüft.** Auf dieser Maschine gibt es kein `pwsh`,
also nimmt der Test das Container-Abbild — und in CI bricht er ab, statt sich zu überspringen.
Der schwerste Befund des Meilensteins wäre ohne das nie aufgefallen.
- **Typografische Anführungszeichen werden auf ihre ASCII-Form gebracht und dann maskiert.** Als
Text durchreichen lassen sie sich nicht: PowerShell liest sie ohnehin als Begrenzer. Lieber ein
Zeichen, das anders aussieht als gedacht, als ein Befehl, der etwas anderes tut als er liest.
- **Ein Platzhalter außerhalb von Anführungszeichen wird abgewiesen**, nicht gefüllt. Die
Maskierung kann daraus kein Argument machen, und ein halb maskierter Befehl zum Kopieren wäre die
gefährlichste aller Antworten.
- **Gesperrt sind fünf Variablennamen, nicht alle eingebauten.** `techniker`, `datum`, `kunde`,
`kundenkuerzel` und `generiertes_passwort` machen eine Aussage über wer, wann und für wen. `upn`,
`sam` und `mail` bleiben frei — beim Onboarding gibt es die Person im Verzeichnis noch nicht.
- **Ein `sam` hält drei Stellen frei.** Ein einmal notierter Anmeldename muss auch morgen aus der
Regel herausfallen, wenn ein zweiter Träger desselben Namens dazukommt.
### Probleme
- **Ich bin in genau die Falle getappt, die ich in #010 repariert habe:** `$'` als Rückverweis in
`String.replaceAll`. Gefunden hat es mein eigener Test gegen echtes Bash — Lesen hätte es nicht
gefunden.
- **Meine erste HTML-Maskierung war doppelt**, weil ich vergessen hatte, dass React Textknoten
selbst maskiert. Aufgefallen beim Nachdenken über den Test, nicht beim Schreiben des Codes.
- **Der Review hat sich PowerShell in einem Container geholt.** Das ist der Unterschied zwischen
„nach bestem Wissen richtig“ und „gemessen“ — und der Unterschied war hier eine
Codeausführung auf dem Domänencontroller.
- **Vierter Meilenstein in Folge mit einem Kommentar, der ins Leere zeigt:** ein Testkommentar
verwies auf einen Beweis in einer anderen Datei, den es dort nicht gab.
### Nächster Schritt
- #013 Läufe: Service und Abarbeitung — Lauf anlegen, abarbeiten, abschließen, archivieren, mit
der Abarbeitungsansicht am Handy.
+87 -22
View File
@@ -1,9 +1,9 @@
# #012 M7 – Platzhalter, Namensregeln, Escaping # #012 M7 – Platzhalter, Namensregeln, Escaping
**Status:** offen **Status:** erledigt
**Meilenstein:** v0.1.0 **Meilenstein:** v0.1.0
**Labels:** phase-1, backend, security **Labels:** phase-1, backend, security
**Geschätzt:** 8 h **Tatsächlich:** – **Geschätzt:** 8 h **Tatsächlich:** 2 h 30 min
## Ziel ## Ziel
@@ -11,26 +11,91 @@ Reine, framework-freie Service-Schicht mit dichter Testabdeckung. Hier entstehen
## Aufgaben ## Aufgaben
- [ ] Platzhalter-Auflösung `{{key}}` in Schritt-Titel, Hilfetext und Befehlstext - [x] Platzhalter-Auflösung `{{key}}` in Schritt-Titel, Hilfetext und Befehlstext
- [ ] Eingebaut aus Gerät und Lauf: `{{hostname}}`, `{{seriennummer}}`, `{{inventarnummer}}`, `{{ticket}}`, `{{datum}}`, `{{techniker}}`; im Modus `DIENSTLEISTUNG` zusätzlich `{{kunde}}` und `{{kundenkuerzel}}`, im Modus `INTERN` leer - [x] Eingebaut aus Gerät und Lauf: `{{hostname}}`, `{{seriennummer}}`, `{{inventarnummer}}`, `{{ticket}}`, `{{datum}}`, `{{techniker}}`; im Modus `DIENSTLEISTUNG` zusätzlich `{{kunde}}` und `{{kundenkuerzel}}`, im Modus `INTERN` leer
- [ ] Eingebaut aus dem Mitarbeiter: `{{mitarbeiter}}` (Alias `{{benutzer}}`), `{{vorname}}`, `{{nachname}}`, `{{upn}}`, `{{sam}}`, `{{mail}}`, `{{abteilung}}`, `{{titel}}`, `{{vorgesetzter}}`, `{{vorgesetzter_sam}}`, `{{dn}}`, `{{ou}}` - [x] Eingebaut aus dem Mitarbeiter: `{{mitarbeiter}}` (Alias `{{benutzer}}`), `{{vorname}}`, `{{nachname}}`, `{{upn}}`, `{{sam}}`, `{{mail}}`, `{{abteilung}}`, `{{titel}}`, `{{vorgesetzter}}`, `{{vorgesetzter_sam}}`, `{{dn}}`, `{{ou}}`
- [ ] Aus der gewählten Abteilung: `{{ou}}` falls nicht vom Mitarbeiter, `{{gruppen}}` kommagetrennt - [x] Aus der gewählten Abteilung: `{{ou}}` falls nicht vom Mitarbeiter, `{{gruppen}}` kommagetrennt
- [ ] `{{ou}}` eines synchronisierten Mitarbeiters ist der Eltern-Container seines `dn` (alles nach dem ersten Komma) - [x] `{{ou}}` eines synchronisierten Mitarbeiters ist der Eltern-Container seines `dn` (alles nach dem ersten Komma)
- [ ] Dazu die in der Vorlage definierten Variablen; unbekannte Platzhalter bleiben sichtbar stehen - [x] Dazu die in der Vorlage definierten Variablen; unbekannte Platzhalter bleiben sichtbar stehen
- [ ] `{{generiertes_passwort}}`: 20 Zeichen aus Groß, Klein, Ziffern und Sonderzeichen ohne verwechselbare Zeichen, je Anzeige neu erzeugt, **nie** gespeichert; Hinweis „Passwort sofort in den Passwortmanager übernehmen“; in der Druckansicht `[Passwort – nicht gespeichert]` - [x] `{{generiertes_passwort}}`: 20 Zeichen aus Groß, Klein, Ziffern und Sonderzeichen ohne verwechselbare Zeichen, je Anzeige neu erzeugt, **nie** gespeichert; Hinweis „Passwort sofort in den Passwortmanager übernehmen“; in der Druckansicht `[Passwort – nicht gespeichert]`
- [ ] Namensregeln (`Einstellung.namensregeln` global, im Modus `DIENSTLEISTUNG` zusätzlich `Kunde.namensregeln` mit Fallback): `upn`, `sam` (höchstens 20 Zeichen), `mail`, `hostname` - [x] Namensregeln (`Einstellung.namensregeln` global, im Modus `DIENSTLEISTUNG` zusätzlich `Kunde.namensregeln` mit Fallback): `upn`, `sam` (höchstens 20 Zeichen), `mail`, `hostname`
- [ ] Transliteration (ä→ae, ö→oe, ü→ue, ß→ss, Akzente entfernt), Kleinschreibung, Leerzeichen entfernt, `{nnn}`-Zähler, `{v}` für den ersten Buchstaben des Vornamens, `{kundenkuerzel}` - [x] Transliteration (ä→ae, ö→oe, ü→ue, ß→ss, Akzente entfernt), Kleinschreibung, Leerzeichen entfernt, `{nnn}`-Zähler, `{v}` für den ersten Buchstaben des Vornamens, `{kundenkuerzel}`
- [ ] Shell-Escaping je Shell: PowerShell (doppelte Anführungszeichen verdoppeln, Backtick maskieren), Bash, Cmd - [x] Shell-Escaping je Shell: PowerShell (doppelte Anführungszeichen verdoppeln, Backtick maskieren), Bash, Cmd
- [ ] HTML-Escaping ersetzter Werte; Markdown im Hilfetext sicher gerendert, kein rohes HTML - [x] HTML-Escaping ersetzter Werte; Markdown im Hilfetext sicher gerendert, kein rohes HTML
- [ ] „Alle Befehle als Skript kopieren“ je Abschnitt: alle `BEFEHL`-Schritte in Reihenfolge, getrennt durch Leerzeilen, je mit `# Schritt: <Titel>` davor, Zeilenumbruch passend zur Shell - [x] „Alle Befehle als Skript kopieren“ je Abschnitt: alle `BEFEHL`-Schritte in Reihenfolge, getrennt durch Leerzeilen, je mit `# Schritt: <Titel>` davor, Zeilenumbruch passend zur Shell
## Akzeptanzkriterien ## Akzeptanzkriterien
- [ ] Test: „Jörg Müller-Lüdenscheid“ ergibt `joerg.mueller-luedenscheid@firma.de` und `jmuellerluedensch` (auf 20 Zeichen gekürzt) - [x] Test: „Jörg Müller-Lüdenscheid“ ergibt `joerg.mueller-luedenscheid@firma.de` und `jmuellerluedensch` (17 Zeichen — siehe unten, die Klammer im ursprünglichen Text war falsch)
- [ ] Test: `{{generiertes_passwort}}` erscheint im angezeigten Befehl, steht aber nicht in `Schrittergebnis`, `Kommentar`, `Protokoll` oder der Druckansicht; zwei Aufrufe liefern verschiedene Passwörter - [x] Test: `{{generiertes_passwort}}` erscheint im angezeigten Befehl, steht aber nicht in `Schrittergebnis`, `Kommentar`, `Protokoll` oder der Druckansicht; zwei Aufrufe liefern verschiedene Passwörter
- [ ] Test: ein Wert mit `"` und `` ` `` wird für PowerShell korrekt maskiert und bricht den Befehl nicht - [x] Test: ein Wert mit `"` und `` ` `` wird für PowerShell korrekt maskiert und bricht den Befehl nicht
- [ ] Test: unbekannte Platzhalter bleiben als `{{key}}` stehen - [x] Test: unbekannte Platzhalter bleiben als `{{key}}` stehen
- [ ] Test: `{{ou}}` wird aus `CN=Maria Huber,OU=Buchhaltung,OU=Benutzer,DC=firma,DC=local` zu `OU=Buchhaltung,OU=Benutzer,DC=firma,DC=local` - [x] Test: `{{ou}}` wird aus `CN=Maria Huber,OU=Buchhaltung,OU=Benutzer,DC=firma,DC=local` zu `OU=Buchhaltung,OU=Benutzer,DC=firma,DC=local`
- [ ] Test: Skript-Kopie liefert die Befehle in Abschnittsreihenfolge mit Kommentarzeilen - [x] Test: Skript-Kopie liefert die Befehle in Abschnittsreihenfolge mit Kommentarzeilen
- [ ] Test: im Modus `INTERN` sind `{{kunde}}` und `{{kundenkuerzel}}` leer - [x] Test: im Modus `INTERN` sind `{{kunde}}` und `{{kundenkuerzel}}` leer
- [ ] Test: ein Wert mit `<script>` landet HTML-escaped in der Anzeige - [x] Test: ein Wert mit `<script>` landet HTML-escaped in der Anzeige
## Beim Bauen aufgefallen
- **PowerShell liest auch typografische Anführungszeichen als Begrenzer.** `“`, `”` und `„`
beenden eine `"…"`-Zeichenkette genauso wie `"`, `‘`, `’`, `‚` und `‛` eine `'…'`-Zeichenkette.
Die Maskierung kannte nur das ASCII-Zeichen. Ein Abteilungsname, den jemand aus Word kopiert —
Word wandelt `"` von selbst in `“`/`”` —, führte damit fremden Code auf dem Domänencontroller
aus. Der Review hat es nicht gelesen, sondern gemessen: er hat sich PowerShell 7 in einem
Container geholt und die Markierungsdatei entstehen lassen. Behoben und mit demselben Aufbau
gegengeprüft: alle Werte kommen unverändert an, nichts wird ausgeführt.
- **Der pwsh-Test hat sich still übersprungen** — dieselbe Falle, die `tests/helpers/db.ts` für die
Datenbank längst zugemacht hat: ein stilles Überspringen sieht genauso grün aus wie ein
bestandener Lauf. Er nimmt jetzt das Container-Abbild, wenn kein `pwsh` da ist, und bricht in CI
ab, statt zu schweigen.
- **Zwei verschiedene Passwörter in einer Antwort.** `befehleDesLaufs` hat jeden Schritt zweimal
gefüllt — einmal für die Anzeige, einmal für das Skript —, und weil das Passwort bei jedem Füllen
neu entsteht, standen auf dem Bildschirm und in der Kopie verschiedene. Der Techniker legt das
Konto mit dem einen an und nennt dem Benutzer das andere; weil nichts gespeichert wird, ist es
danach unwiederbringlich.
- **Eine Vorlagenvariable konnte jeden eingebauten Platzhalter überschreiben.** Eine Variable
`techniker` ließ einen Befehl mit fremdem Namen unterschreiben, eine Variable `kunde` holte im
Modus Intern die Kundenebene zurück, die es dort ausdrücklich nicht gibt. Gesperrt sind jetzt
genau fünf Namen; alles andere bleibt erlaubt, weil eine Onboarding-Vorlage nach `upn`, `sam` und
`mail` fragen können muss — die Person steht ja noch nicht im Verzeichnis.
- **Die tragende Annahme der Maskierung hat niemand geprüft.** Sie gilt nur, wenn der Platzhalter
in der Vorlage in Anführungszeichen steht; `Write-Host {{vorname}}` lässt sich nicht sicher
füllen. Das stand als Kommentar da und wurde nirgends eingelöst — der Review hat mit
`$(touch …)` eine Datei entstehen lassen. Jetzt wird es geprüft und der Schritt abgewiesen.
- **Ich bin selbst in die Falle getappt, die ich in #010 repariert habe:** `$'` ist in
`String.replaceAll` ein Rückverweis auf alles hinter dem Treffer. Aus `erste\nzweite` wurde
`erste'zweite\n''zweite` — ein Befehl, der nicht mehr zugeht. Gefunden hat es mein eigener Test,
der gegen echtes Bash läuft.
- **Meine erste HTML-Maskierung war doppelt gemoppelt.** React maskiert Textknoten selbst; eine
zweite Maskierung hätte „Forschung &amp; Entwicklung“ sichtbar gemacht. Der Beweis für das
Akzeptanzkriterium steht jetzt am gerenderten HTML statt in einer eigenen Funktion.
- **`{{ou}}` bei einem maskierten Backslash im DN.** `CN=X\\,OU=Y` ist ein literaler Backslash im
Wert, das Komma **ist** der Trenner — die Vorausschau hielt es für maskiert, lieferte nichts, und
der Aufrufer fiel still auf die Ziel-OU der Abteilung zurück. Das Konto wäre in der falschen OU
gelandet.
- **Hostnamen, die Windows ablehnt:** ein Nachname „Con“ reichte, damit die Regel `CON`
vorschlägt — das ist die MS-DOS-Konsole. Reservierte Gerätenamen und rein numerische Namen
liefern jetzt keinen Vorschlag.
- **`!` ist aus dem Passwort geflogen**, und ein Passwort fängt nie mit `= + - @` an: bei
eingeschalteter verzögerter Ersetzung frisst `cmd.exe` das `!` still, und die vier anderen liest
eine Tabellenkalkulation am Zellenanfang als Formel.
## Eine Entscheidung, die den Auftragstext korrigiert
Das Akzeptanzkriterium nennt `jmuellerluedensch` und setzt in Klammern „auf 20 Zeichen gekürzt“.
Der Name hat **17** Zeichen, und beides zusammen geht nur mit zwei Regeln, die so jetzt im Code
stehen: der Bindestrich fällt im `sam` weg, und eine Regel ohne eigenen `{nnn}` hält drei Stellen
für die spätere Unterscheidung frei (20 − 3 = 17). Der Vorrat wird **immer** zurückgehalten, auch
solange der Name frei ist — sonst hieße derselbe Mensch `jmuellerluedenscheid`, solange er der
Erste ist, und `jmuellerluedensch2`, sobald ein Zweiter dazukommt. Ein einmal notierter
Anmeldename muss auch morgen noch aus der Regel herausfallen.
## Offen geblieben
- **Cmd ist nur begründet, nicht gemessen.** Es gibt hier kein Windows. Die Ablehnung von `"`, CR,
LF und Null-Byte und die Bewertung von `%` und `!` stehen als Begründung im Code — mit dem
ausdrücklichen Hinweis, was daran Annahme ist.
- **Kein Aufrufer.** Dieser Meilenstein hat keine Oberfläche; die Funktionen werden von #013
(Abarbeitung) und #014 (Geräteseite) gerufen. Der Hinweis „Passwort sofort in den
Passwortmanager übernehmen“ und die Druckansicht `[Passwort – nicht gespeichert]` sind damit
vorbereitet, aber noch nirgends zu sehen.
+2 -2
View File
@@ -16,7 +16,7 @@ Diese Dateien sind die Quelle; `pnpm issues:sync` spiegelt sie in den Gitea-Trac
| #009 | [M4 – Vorlagen-Versionierung im Service](009-vorlagen-versionierung.md) | erledigt | v0.1.0 | 6 h | 1 h 20 min | | #009 | [M4 – Vorlagen-Versionierung im Service](009-vorlagen-versionierung.md) | erledigt | v0.1.0 | 6 h | 1 h 20 min |
| #010 | [M5 – Vorlagen-Editor](010-vorlagen-editor.md) | erledigt | v0.1.0 | 10 h | 2 h 10 min | | #010 | [M5 – Vorlagen-Editor](010-vorlagen-editor.md) | erledigt | v0.1.0 | 10 h | 2 h 10 min |
| #011 | [M6 – Mitarbeiter, Abteilungen, Geräte](011-mitarbeiter-abteilungen-geraete.md) | erledigt | v0.1.0 | 9 h | 2 h 15 min | | #011 | [M6 – Mitarbeiter, Abteilungen, Geräte](011-mitarbeiter-abteilungen-geraete.md) | erledigt | v0.1.0 | 9 h | 2 h 15 min |
| #012 | [M7 – Platzhalter, Namensregeln, Escaping](012-platzhalter-namensregeln.md) | offen | v0.1.0 | 8 h | – | | #012 | [M7 – Platzhalter, Namensregeln, Escaping](012-platzhalter-namensregeln.md) | erledigt | v0.1.0 | 8 h | 2 h 30 min |
| #013 | [M8 – Läufe: Service und Abarbeitung](013-laeufe-abarbeitung.md) | offen | v0.1.0 | 14 h | – | | #013 | [M8 – Läufe: Service und Abarbeitung](013-laeufe-abarbeitung.md) | offen | v0.1.0 | 14 h | – |
| #014 | [M9 – QR-Tags, Geräteseite, Etiketten, Scanner](014-qr-tags-geraeteseite.md) | offen | v0.1.0 | 12 h | – | | #014 | [M9 – QR-Tags, Geräteseite, Etiketten, Scanner](014-qr-tags-geraeteseite.md) | offen | v0.1.0 | 12 h | – |
| #015 | [M10 – Dashboard und Archiv](015-dashboard-archiv.md) | offen | v0.1.0 | 8 h | – | | #015 | [M10 – Dashboard und Archiv](015-dashboard-archiv.md) | offen | v0.1.0 | 8 h | – |
@@ -24,4 +24,4 @@ Diese Dateien sind die Quelle; `pnpm issues:sync` spiegelt sie in den Gitea-Trac
| #017 | [M12 – Seed der sieben Vorlagen](017-seed-sieben-vorlagen.md) | offen | v0.1.0 | 8 h | – | | #017 | [M12 – Seed der sieben Vorlagen](017-seed-sieben-vorlagen.md) | offen | v0.1.0 | 8 h | – |
| #018 | [M13 – E2E-Tests, Anleitung, PDF](018-e2e-anleitung-pdf.md) | offen | v0.1.0 | 14 h | – | | #018 | [M13 – E2E-Tests, Anleitung, PDF](018-e2e-anleitung-pdf.md) | offen | v0.1.0 | 14 h | – |
| #019 | [M14 – Betriebswege und Release v0.1.0](019-betrieb-release.md) | offen | v0.1.0 | 10 h | – | | #019 | [M14 – Betriebswege und Release v0.1.0](019-betrieb-release.md) | offen | v0.1.0 | 10 h | – |
| | **Summe** | | | **136 h** | **11 h 15 min** | | | **Summe** | | | **136 h** | **13 h 45 min** |
+550
View File
@@ -0,0 +1,550 @@
import { z } from "@/lib/validierung";
/**
* Namensregeln: aus einem Personennamen werden upn, sam, mail und hostname (#012).
*
* Aus diesen Werten entstehen die PowerShell-Befehle, die ein Techniker auf einem
* Domaenencontroller ausfuehrt. Ein Name, der hier krumm herauskommt, legt dort
* ein Konto mit krummem Namen an — und zwar unbemerkt, weil niemand mehr
* hinsieht, wenn der Befehl einmal kopiert ist. Deshalb gilt durchgehend:
*
* 1. **Fehlt etwas, kommt nichts heraus.** Keine Regel, ein unbekannter
* Platzhalter darin, ein leerer Vorname — jedes Mal `undefined`. Ein
* erfundener oder halber Anmeldename ist schlimmer als gar keiner, weil er
* ausgefuehrt wird.
* 2. **Die Grenzen des Zielsystems werden eingehalten, nicht gemeldet.** Das
* Active Directory kuerzt einen zu langen `sAMAccountName` nicht, es lehnt ihn
* ab; der NetBIOS-Name bricht bei 15 Zeichen.
* 3. **Framework- und datenbankfrei.** Ob ein Kandidat schon vergeben ist, weiss
* nur der Aufrufer; er reicht die Pruefung herein.
*/
export class NamensregelFehler extends Error {
constructor(nachricht: string) {
super(nachricht);
this.name = "NamensregelFehler";
}
}
/**
* Vorlagen fuer abgeleitete Werte, wie sie in `Einstellung.namensregeln` und
* `Kunde.namensregeln` liegen.
*
* Steht hier und nicht mehr im Einstellungs-Service: das Aufloesen gehoert in
* `src/lib`, und Schema und Aufloesung durften nicht in verschiedenen Schichten
* liegen — sonst haette der Service das Schema und die lib die Regeln, und
* niemand haette gemerkt, wenn beide unterschiedliche Platzhalter kennen.
*/
export const NamensregelnSchema = z.object({
upn: z.string().trim().max(200).optional(),
sam: z.string().trim().max(200).optional(),
mail: z.string().trim().max(200).optional(),
hostname: z.string().trim().max(200).optional(),
});
export type Namensregeln = z.infer<typeof NamensregelnSchema>;
export const ZIELFORMATE = ["upn", "sam", "mail", "hostname"] as const;
export type Zielformat = (typeof ZIELFORMATE)[number];
/** Grenze des Active Directory fuer `sAMAccountName`. Laenger wird nicht gekuerzt, sondern abgelehnt. */
export const SAM_HOECHSTLAENGE = 20;
/** Grenze des NetBIOS-Namens. Windows zeigt und registriert nur die ersten 15 Zeichen. */
export const HOSTNAME_HOECHSTLAENGE = 15;
/**
* Stellen, die eine Regel **ohne** eigenen Zaehler fuer die Unterscheidung frei haelt.
*
* Drei Stellen erlauben 999 Träger desselben Namensstamms. Der Vorrat wird immer
* zurueckgehalten, auch wenn der Name gerade noch frei ist: sonst hiesse
* derselbe Mensch `jmuellerluedenscheid`, solange er der erste ist, und
* `jmuellerluedensch2`, sobald ein zweiter dazukommt — und dann stimmt kein
* einmal notierter Name mehr mit dem ueberein, was die Regel heute liefert.
* Enthaelt die Regel einen Zaehler `{nnn}`, ist der die Unterscheidung und die
* volle Laenge steht zur Verfuegung.
*/
export const UNTERSCHEIDUNG_STELLEN = 3;
/** Hoechster Wert, der als Unterscheidung angehaengt wird — er passt in `UNTERSCHEIDUNG_STELLEN`. */
export const UNTERSCHEIDUNG_HOECHSTWERT = 10 ** UNTERSCHEIDUNG_STELLEN - 1;
/**
* Zeichen, die zwei Buchstaben ergeben oder nach Unicode keine Zerlegung haben.
*
* Alles andere erledigt die Zerlegung nach NFD: `é`, `ç`, `å` und ihre
* Verwandten verlieren dort einfach ihr Beizeichen. Hier stehen nur die beiden
* Sonderfaelle: die deutschen Umlaute und das scharfe S, die zu **zwei**
* Buchstaben werden statt einen zu verlieren, und die Buchstaben, die Unicode
* gar nicht zerlegt (`ø`, `ł`, `æ`, `þ`) und die deshalb ohne diese Tabelle als
* Nicht-ASCII ganz wegfielen — aus „Kowalski“ mit `ł` wuerde „Kowaski“.
*/
const FALTUNG: readonly (readonly [string, string])[] = [
["ä", "ae"],
["ö", "oe"],
["ü", "ue"],
["Ä", "Ae"],
["Ö", "Oe"],
["Ü", "Ue"],
["ß", "ss"],
["ẞ", "Ss"],
["æ", "ae"],
["Æ", "Ae"],
["œ", "oe"],
["Œ", "Oe"],
["ø", "o"],
["Ø", "O"],
["ł", "l"],
["Ł", "L"],
["đ", "d"],
["Đ", "D"],
["ð", "d"],
["Ð", "D"],
["þ", "th"],
["Þ", "Th"],
["ı", "i"],
];
/**
* Faltet einen Text auf druckbares ASCII.
*
* Drei Stufen, und die Reihenfolge ist der ganze Trick: erst die Zeichen, die
* **zwei** Buchstaben ergeben (`ä` → `ae`), dann die Zerlegung nach NFD samt
* Wegwerfen der Akzentzeichen (`é` → `e` + Akut → `e`), dann alles uebrige
* Nicht-ASCII. Umgekehrt waere `ä` schon in der zweiten Stufe zu `a` geworden,
* und „Jörg“ hiesse „Jorg“.
*
* Die Gross- und Kleinschreibung bleibt, wie sie ist — welche das Zielformat
* braucht, entscheidet das Zielformat. Aus „MÜLLER“ wird deshalb „MUeLLER“;
* sichtbar wird das nie, weil jedes der vier Formate die Schreibweise
* anschliessend vereinheitlicht.
*/
export function transliterieren(text: string): string {
// Erst zusammensetzen: „ü“ kann als ein Zeichen oder als `u` + Trema
// ankommen — aus einer CSV-Datei eines Mac regelmaessig als zweites. Ohne NFC
// griffe die Tabelle unten nicht, und aus „Müller“ wuerde „Muller“ statt
// „Mueller“: ein Anmeldename, der beinahe stimmt und deshalb erst im Betrieb
// auffaellt.
let gefaltet = text.normalize("NFC");
for (const [von, nach] of FALTUNG) {
gefaltet = gefaltet.replaceAll(von, nach);
}
return (
gefaltet
.normalize("NFD")
// Alle kombinierenden Zeichen: Akut, Gravis, Cedille, Ring, Haken.
.replace(/\p{M}+/gu, "")
// Nur druckbares ASCII bleibt — der Bereich vom Leerzeichen bis zur Tilde.
// Steuerzeichen und Zeilenumbrueche fallen weg, statt zu Leerzeichen zu
// werden: in einem Namen haben sie nichts zu suchen, und ein Umbruch
// mitten in einem Befehl waere gefaehrlich.
.replace(/[^ -~]/g, "")
);
}
/** Derselbe Text klein geschrieben — die Form, in der Namensteile in eine Regel gehen. */
export function kleinGefaltet(text: string): string {
return transliterieren(text).toLowerCase();
}
export type Namenswerte = {
readonly vorname?: string | null;
readonly nachname?: string | null;
/** Nur im Modus `DIENSTLEISTUNG` gesetzt; im Modus `INTERN` gibt es kein Kuerzel. */
readonly kundenkuerzel?: string | null;
};
/**
* Ein Namensteil fuer die Regel: gefaltet, klein, ohne Leerzeichen.
*
* „Anna Lena“ wird zu `annalena`. Der Bindestrich bleibt — er gehoert zum Namen
* und ist in Adressen wie in Anmeldenamen erlaubt.
*/
function namensteil(roh: string | null | undefined): string | undefined {
if (!roh) return undefined;
return kleinGefaltet(roh).replace(/\s+/g, "") || undefined;
}
/** Platzhalter einer Regel. `{nnn}` wird getrennt behandelt, weil die Zahl der `n` zaehlt. */
const REGEL_PLATZHALTER = /\{([a-z]+)\}/g;
type Regelteil =
{ readonly art: "text"; readonly text: string } | { readonly art: "zaehler"; readonly stellen: number };
function platzhalterWert(name: string, werte: Namenswerte): string | undefined {
switch (name) {
case "vorname":
return namensteil(werte.vorname);
case "nachname":
return namensteil(werte.nachname);
case "v": {
// Der erste **Buchstabe**, nicht das erste Zeichen: ein Vorname wie
// „'Abd al-Rahman“ faengt nach der Faltung mit einem Apostroph an, und ein
// Anmeldename `'abdalrahman` hilft niemandem.
const vorname = namensteil(werte.vorname);
return vorname?.match(/[a-z0-9]/)?.[0];
}
case "kundenkuerzel":
return namensteil(werte.kundenkuerzel);
default:
return undefined;
}
}
/**
* Zerlegt eine Regel in aufgeloeste Textstuecke und hoechstens einen Zaehler.
*
* `undefined` heisst „diese Regel ergibt hier keinen Namen“: ein Platzhalter, den
* es nicht gibt, eine uebrig gebliebene geschweifte Klammer, ein zweiter Zaehler
* oder ein Wert, der fehlt. Die ersten drei sind Fehler in der Einstellung, der
* letzte einer in den Stammdaten. Keiner davon laesst sich hier beheben, und
* geraten wird nicht.
*/
function regelZerlegen(regel: string, werte: Namenswerte): readonly Regelteil[] | undefined {
const teile: Regelteil[] = [];
let zaehlerGesehen = false;
let gelesenBis = 0;
/**
* Eine geschweifte Klammer im **festen** Teil der Regel ist ein verunglueckter
* Platzhalter: `{Nachname}`, `{NACHNAME}` oder ein fehlendes `}`. Unbemerkt
* wuerde daraus ein Anmeldename `nachname@firma.de` — die Klammern fallen beim
* Normalisieren weg, der Text bleibt. Also lieber kein Name.
*/
const festerText = (text: string): boolean => {
if (/[{}]/.test(text)) return false;
teile.push({ art: "text", text });
return true;
};
for (const treffer of regel.matchAll(REGEL_PLATZHALTER)) {
const name = treffer[1] ?? "";
const beginn = treffer.index;
if (beginn > gelesenBis && !festerText(regel.slice(gelesenBis, beginn))) {
return undefined;
}
gelesenBis = beginn + treffer[0].length;
if (/^n+$/.test(name)) {
if (zaehlerGesehen) return undefined;
zaehlerGesehen = true;
teile.push({ art: "zaehler", stellen: name.length });
continue;
}
const wert = platzhalterWert(name, werte);
if (wert === undefined) return undefined;
teile.push({ art: "text", text: wert });
}
if (gelesenBis < regel.length && !festerText(regel.slice(gelesenBis))) {
return undefined;
}
return teile;
}
function textVon(teil: Regelteil): string {
return teil.art === "text" ? teil.text : "";
}
/**
* Adressform fuer `upn` und `mail`.
*
* Klein geschrieben, genau ein `@`, beide Haelften gefuellt, keine doppelten
* Punkte und kein Punkt unmittelbar vor dem `@` — das lehnen Exchange und das
* Active Directory ab. Passt die Form nicht, kommt `undefined` heraus: eine
* Adresse ohne `@` ist keine, und sie in einen `New-ADUser`-Befehl zu setzen,
* hiesse dem Techniker eine Zusage zu machen, die nicht stimmt.
*/
function adresseNormalisieren(roh: string): string | undefined {
const teile = roh.toLowerCase().split("@");
if (teile.length !== 2) return undefined;
const lokal = (teile[0] ?? "")
.replace(/[^a-z0-9._+-]/g, "")
.replace(/\.{2,}/g, ".")
.replace(/^\.+/, "")
.replace(/\.+$/, "");
const bereich = (teile[1] ?? "")
.replace(/[^a-z0-9.-]/g, "")
.replace(/\.{2,}/g, ".")
.replace(/^[.-]+/, "")
.replace(/[.-]+$/, "");
if (!lokal || !bereich) return undefined;
// Am Rand raeumen die Schritte oben auf: aus `-firma.de-` wird `firma.de`.
// Innen geht das nicht, ohne den Namen zu erfinden — `firma.-de` bleibt also
// falsch und wird abgelehnt.
if (bereich.split(".").some((stufe) => !stufe || stufe.startsWith("-") || stufe.endsWith("-"))) {
return undefined;
}
return `${lokal}@${bereich}`;
}
/**
* Form des `sAMAccountName`.
*
* Erlaubt bleiben Buchstaben, Ziffern, Punkt und Unterstrich. Der Bindestrich
* faellt weg, obwohl das Active Directory ihn zuliesse: der Anmeldename wird
* abgetippt und am Telefon buchstabiert, und `mueller-luedenscheid` kostet dabei
* ein Zeichen von zwanzig fuer eine Information, die im Anzeigenamen ohnehin
* steht. Ein Punkt am Ende ist unzulaessig und fliegt auch nach dem Kuerzen
* wieder heraus, weil gekuerzt wird, bevor diese Form hergestellt ist.
*/
function samNormalisieren(roh: string): string | undefined {
return (
roh
.toLowerCase()
.replace(/[^a-z0-9._]/g, "")
.replace(/\.{2,}/g, ".")
.replace(/^\.+/, "")
.replace(/\.+$/, "") || undefined
);
}
/**
* Form des Hostnamens.
*
* **Grossbuchstaben**, weil Windows den NetBIOS-Namen gross fuehrt und gross
* anzeigt: steht auf dem Aufkleber `knd-pc-007` und meldet das Geraet
* `KND-PC-007`, sieht der naechste Techniker zwei Namen und sucht nach einem
* Fehler, den es nicht gibt. DNS unterscheidet die Schreibweise ohnehin nicht.
*
* Unterstrich und Punkt fallen weg — der Unterstrich ist in Hostnamen nach
* RFC 1123 nicht erlaubt und bringt Windows-DNS zum Stolpern, der Punkt trennt
* den Namen von der Domaene und hat im NetBIOS-Namen nichts zu suchen. Ein
* fuehrender oder abschliessender Bindestrich faellt ebenfalls weg; er entsteht
* beim Kuerzen von selbst.
*/
/**
* Namen, die Windows als Computernamen ablehnt.
*
* Es sind die alten Geraetenamen von MS-DOS. `CON` ist die Konsole, `PRN` der
* Drucker, `NUL` der Papierkorb — ein Rechner darf so nicht heissen, und ein
* Nachname „Con“ oder eine Abteilung „Aux“ reicht, damit die Regel es
* vorschlaegt. Gefunden im Review zu #012.
*/
const HOSTNAME_RESERVIERT: ReadonlySet<string> = new Set([
"CON",
"PRN",
"AUX",
"NUL",
...Array.from({ length: 9 }, (_, i) => `COM${i + 1}`),
...Array.from({ length: 9 }, (_, i) => `LPT${i + 1}`),
]);
function hostnameNormalisieren(roh: string): string | undefined {
const name =
roh
.toUpperCase()
.replace(/[^A-Z0-9-]/g, "")
.replace(/^-+/, "")
.replace(/-+$/, "") || undefined;
if (name === undefined) return undefined;
// Beides lehnt Windows ab: einen reservierten Geraetenamen und einen rein
// numerischen Namen (er waere von einer IP-Adresse nicht zu unterscheiden).
if (HOSTNAME_RESERVIERT.has(name)) return undefined;
if (/^[0-9]+$/.test(name)) return undefined;
return name;
}
function normalisieren(zielformat: Zielformat, roh: string): string | undefined {
switch (zielformat) {
case "upn":
case "mail":
return adresseNormalisieren(roh);
case "sam":
return samNormalisieren(roh);
case "hostname":
return hostnameNormalisieren(roh);
}
}
/**
* Wie lang darf das Ergebnis werden?
*
* `undefined` heisst „keine Grenze“: Adressen kuerzt niemand, eine gekuerzte
* Mail-Adresse waere schlicht falsch. `mitUnterscheidung` sagt, ob die
* Unterscheidung schon im Namen steckt — dann ist die volle Laenge nutzbar,
* sonst bleibt der Vorrat fuer die Unterscheidung frei.
*/
function grenzeFuer(zielformat: Zielformat, mitUnterscheidung: boolean): number | undefined {
const voll =
zielformat === "sam" ? SAM_HOECHSTLAENGE : zielformat === "hostname" ? HOSTNAME_HOECHSTLAENGE : undefined;
if (voll === undefined) return undefined;
return mitUnterscheidung ? voll : voll - UNTERSCHEIDUNG_STELLEN;
}
/**
* Setzt Text, Zahl und Text zusammen und haelt dabei die Grenze ein.
*
* Gekuerzt wird am Ende — aber **nie in der Zahl**. Genau daran waere sonst die
* Eindeutigkeit gestorben: `{nachname}-{nnn}` liefert fuer einen langen Namen
* zweimal denselben Hostnamen, wenn die 15 Zeichen mitten in der Nummer
* abschneiden. Deshalb wird Stueck fuer Stueck erst hinter, dann vor der Zahl
* weggenommen und jedes Mal neu normalisiert: Normalisieren kann die Laenge
* veraendern (ein Bindestrich am neuen Ende faellt weg), Kuerzen und Pruefen
* muessen also in derselben Schleife sitzen.
*/
function zusammensetzen(
zielformat: Zielformat,
vorText: string,
zahlText: string,
nachText: string,
grenze: number | undefined,
): string | undefined {
for (let weg = 0; weg <= vorText.length + nachText.length; weg += 1) {
const wegNach = Math.min(weg, nachText.length);
const wegVor = weg - wegNach;
const roh =
vorText.slice(0, vorText.length - wegVor) + zahlText + nachText.slice(0, nachText.length - wegNach);
const kandidat = normalisieren(zielformat, roh);
// Wird das Ergebnis beim Kuerzen unbrauchbar (leer, oder eine Adresse ohne
// `@`), hilft weiteres Kuerzen nicht mehr.
if (kandidat === undefined) return undefined;
if (grenze === undefined || kandidat.length <= grenze) return kandidat;
}
return undefined;
}
function zaehlerStellen(teile: readonly Regelteil[]): number | undefined {
for (const teil of teile) {
if (teil.art === "zaehler") return teil.stellen;
}
return undefined;
}
/** Enthaelt die Regel einen Zaehler `{n}`, `{nn}`, `{nnn}`, …? */
export function hatZaehler(regel: string): boolean {
for (const treffer of regel.matchAll(REGEL_PLATZHALTER)) {
if (/^n+$/.test(treffer[1] ?? "")) return true;
}
return false;
}
/**
* Bildet einen Namen aus einer Regel.
*
* `zaehler` fuellt ein `{nnn}` in der Regel; die Zahl der Stellen kommt aus der
* Zahl der `n`, also `{nn}` → `07`. Enthaelt die Regel einen Zaehler, aber es
* wird keiner mitgegeben, kommt `undefined` heraus — nicht `001`. Welche Nummer
* frei ist, weiss diese Schicht nicht, und die erste zu raten hiesse einen
* Hostnamen zu liefern, der schon an einem anderen Geraet haengt. Wer einen
* Zaehler braucht, ruft `namenEindeutigBilden`.
*/
export function namenBilden(
zielformat: Zielformat,
regel: string | undefined | null,
werte: Namenswerte,
zaehler?: number,
): string | undefined {
const bereinigt = regel?.trim();
if (!bereinigt) return undefined;
const teile = regelZerlegen(bereinigt, werte);
if (!teile) return undefined;
const stellen = zaehlerStellen(teile);
const index = teile.findIndex((teil) => teil.art === "zaehler");
const vorText = teile
.slice(0, index < 0 ? teile.length : index)
.map(textVon)
.join("");
const nachText =
index < 0
? ""
: teile
.slice(index + 1)
.map(textVon)
.join("");
if (stellen === undefined) {
return zusammensetzen(zielformat, vorText, "", "", grenzeFuer(zielformat, false));
}
if (zaehler === undefined || !Number.isInteger(zaehler) || zaehler < 0) return undefined;
const zahlText = String(zaehler).padStart(stellen, "0");
// Passt die Nummer nicht in ihre Stellen, waere das Ergebnis ein Zeichen zu
// lang und die Regel damit ueberdehnt — `{nn}` kann keine 100 tragen.
if (zahlText.length > stellen) return undefined;
return zusammensetzen(zielformat, vorText, zahlText, nachText, grenzeFuer(zielformat, true));
}
/**
* Haengt eine Unterscheidung an einen fertigen Namen.
*
* Bei Adressen steht sie vor dem `@` — `joerg.mueller2@firma.de`, nicht
* `joerg.mueller@firma.de2`. Bei `sam` und `hostname` steht sie am Ende und
* darf die Grenze ausschoepfen, weil sie selbst die Unterscheidung ist.
*/
function variante(zielformat: Zielformat, basis: string, nummer: number): string | undefined {
const nummerText = String(nummer);
if (zielformat === "upn" || zielformat === "mail") {
const trenner = basis.lastIndexOf("@");
if (trenner < 0) return undefined;
return zusammensetzen(zielformat, basis.slice(0, trenner), nummerText, basis.slice(trenner), undefined);
}
return zusammensetzen(zielformat, basis, nummerText, "", grenzeFuer(zielformat, true));
}
/**
* Bildet einen Namen, der noch frei ist.
*
* Die Vergabepruefung wird **hereingereicht**, nicht als Liste vergebener Werte
* entgegengenommen: schon fuer den Hostnamen hiesse eine Liste, alle Geraete
* eines Kunden zu laden, um eine einzige Nummer zu finden, und fuer `upn` und
* `mail` alle Mitarbeiter. Eine Funktion fragt genau einen Kandidaten und
* uebersetzt sich in ein `findFirst` mit Index; im Test ist sie ein `vi.fn`,
* dessen Aufrufe sich einzeln nachlesen lassen. Denselben Weg geht
* `tagCodeEindeutigErzeugen` in `src/lib/tagcode.ts`.
*
* **Was diese Schleife nicht leistet:** Die Datenbank sichert die Eindeutigkeit
* hier nicht ab — auf `Mitarbeiter.upn`, `.sam`, `.mail` und `Geraet.hostname`
* liegen Suchindizes, keine Unique-Indizes (anders als beim `tagCode`). Zwischen
* der Frage und dem Schreiben kann also ein zweiter Lauf denselben Namen
* belegen. Diese Schleife nimmt dem Techniker den absehbaren Fall ab, nicht das
* Wettrennen; die letzte Instanz ist das Zielsystem, das ein doppeltes
* `sAMAccountName` selbst ablehnt.
*/
export async function namenEindeutigBilden(
zielformat: Zielformat,
regel: string | undefined | null,
werte: Namenswerte,
istVergeben: (kandidat: string) => Promise<boolean>,
): Promise<string | undefined> {
const bereinigt = regel?.trim();
if (!bereinigt) return undefined;
const teile = regelZerlegen(bereinigt, werte);
if (!teile) return undefined;
const stellen = zaehlerStellen(teile);
if (stellen !== undefined) {
const hoechster = 10 ** stellen - 1;
for (let zaehler = 1; zaehler <= hoechster; zaehler += 1) {
const kandidat = namenBilden(zielformat, bereinigt, werte, zaehler);
if (kandidat === undefined) return undefined;
if (!(await istVergeben(kandidat))) return kandidat;
}
throw new NamensregelFehler(
`Die Regel „${bereinigt}“ gibt nur ${hoechster} Nummern her, und alle sind vergeben. ` +
"Die Regel braucht eine Stelle mehr.",
);
}
const basis = namenBilden(zielformat, bereinigt, werte);
if (basis === undefined) return undefined;
if (!(await istVergeben(basis))) return basis;
for (let nummer = 2; nummer <= UNTERSCHEIDUNG_HOECHSTWERT; nummer += 1) {
const kandidat = variante(zielformat, basis, nummer);
if (kandidat === undefined) return undefined;
if (!(await istVergeben(kandidat))) return kandidat;
}
throw new NamensregelFehler(
`„${basis}“ ist vergeben, und auch mit den Zusätzen 2 bis ${UNTERSCHEIDUNG_HOECHSTWERT} ` +
"war kein freier Name zu finden. Das deutet auf eine defekte Vergabeprüfung hin.",
);
}
+117
View File
@@ -0,0 +1,117 @@
import { randomInt } from "node:crypto";
/**
* Das generierte Passwort (#012).
*
* `{{generiertes_passwort}}` wird **bei jeder Anzeige neu erzeugt und nie
* gespeichert** — nicht im `Schrittergebnis`, nicht im `Kommentar`, nicht im
* `Protokoll` und nicht in der Druckansicht. Das ist keine Bequemlichkeit,
* sondern die Zusage: ein archivierter Lauf ist ein Dokument, das Jahre liegt
* und von vielen gelesen wird. Ein Anfangspasswort darin waere ein Fundstueck.
*
* Die Folge muss man aushalten: wer den Befehl ausfuehrt und das Passwort nicht
* sofort weiterreicht, hat es verloren. Genau deshalb steht der Hinweis
* „Passwort sofort in den Passwortmanager übernehmen“ daneben, und genau deshalb
* ist ein zweiter Aufruf ein **anderes** Passwort — nicht dasselbe noch einmal.
*/
/**
* Zeichen ohne Verwechslungsgefahr.
*
* Weggelassen sind `I l 1 |`, `O 0` und `B 8`: das Passwort wird von einem
* Bildschirm abgelesen und in ein anderes Fenster getippt, oft von der Person,
* der das Konto gehoert. Ein Zeichen, das man falsch liest, kostet einen
* Anruf — und der Sicherheitsgewinn dieser paar Zeichen ist dagegen nichts.
*/
export const PASSWORT_GROSS = "ACDEFGHJKLMNPQRSTUVWXYZ";
export const PASSWORT_KLEIN = "abcdefghijkmnopqrstuvwxyz";
export const PASSWORT_ZIFFERN = "23456789";
/**
* Sonderzeichen, die in keiner der drei Shells etwas anrichten.
*
* Bewusst ohne `" ' $ \` \\ ; & | < > %`: das Passwort landet in einem Befehl,
* den jemand kopiert. Die Maskierung in `src/lib/shell.ts` faengt das zwar ab —
* aber ein Passwort, das nur wegen korrekter Maskierung funktioniert, ist eine
* Wette auf jeden kuenftigen Aufrufer. Was gar nicht erst drin ist, kann auch
* nichts zerbrechen.
*
* Auch **ohne `!`**: bei eingeschalteter verzoegerter Ersetzung
* (`setlocal enabledelayedexpansion`) frisst `cmd.exe` es still, und das Konto
* bekaeme ein anderes Passwort als angezeigt. Gefunden im Review zu #012 — der
* Kommentar behauptete vorher Sicherheit in einer CSV-Zelle, und `= + - @`
* stehen dort am Zellenanfang fuer eine Formel. Deshalb faengt ein Passwort
* auch nie mit einem Sonderzeichen an (siehe `passwortErzeugen`).
*/
export const PASSWORT_SONDER = "#*+-=?@_~";
const ALPHABET = PASSWORT_GROSS + PASSWORT_KLEIN + PASSWORT_ZIFFERN + PASSWORT_SONDER;
export const PASSWORT_LAENGE = 20;
/** Steht in der Druckansicht statt des Passworts. */
export const PASSWORT_IM_DRUCK = "[Passwort – nicht gespeichert]";
/** Der Satz, der neben jedem angezeigten Passwort steht. */
export const PASSWORT_HINWEIS = "Passwort sofort in den Passwortmanager übernehmen";
function zeichenAus(vorrat: string): string {
// `randomInt` aus node:crypto statt Math.random: gleichverteilt und aus einer
// Quelle, die fuer Geheimnisse gedacht ist.
return vorrat.charAt(randomInt(vorrat.length));
}
/**
* Erzeugt ein Passwort mit 20 Zeichen.
*
* Aus jeder der vier Gruppen kommt mindestens ein Zeichen — nicht aus
* Aberglauben, sondern weil die Passwortrichtlinie eines Active Directory
* ueblicherweise drei von vier verlangt und ein abgewiesenes `New-ADUser` den
* Techniker vor dem Kunden stehen laesst. Danach wird gemischt, damit die
* Gruppen nicht an festen Stellen stehen.
*/
export function passwortErzeugen(laenge: number = PASSWORT_LAENGE): string {
if (laenge < 4) {
throw new Error("Ein Passwort unter vier Zeichen kann nicht aus vier Gruppen bestehen.");
}
const zeichen = [
zeichenAus(PASSWORT_GROSS),
zeichenAus(PASSWORT_KLEIN),
zeichenAus(PASSWORT_ZIFFERN),
zeichenAus(PASSWORT_SONDER),
];
while (zeichen.length < laenge) {
zeichen.push(zeichenAus(ALPHABET));
}
// Fisher-Yates mit `randomInt`: ein `sort(() => Math.random() - 0.5)` mischt
// nachweislich ungleich und wuerde die vier Pflichtzeichen bevorzugt vorn
// lassen.
for (let i = zeichen.length - 1; i > 0; i -= 1) {
const j = randomInt(i + 1);
[zeichen[i], zeichen[j]] = [zeichen[j] as string, zeichen[i] as string];
}
/*
* Das erste Zeichen ist nie ein Sonderzeichen.
*
* `= + - @` am Anfang einer Zelle liest eine Tabellenkalkulation als Formel,
* und ein Passwort geht seinen Weg oft ueber Zwischenablage und Tabelle. Statt
* die vier zu streichen — sie erhoehen den Vorrat spuerbar — wandert ein
* fuehrendes Sonderzeichen nach hinten. Die Laenge und die vier Gruppen
* bleiben unberuehrt.
*/
const erstes = zeichen[0] as string;
if (PASSWORT_SONDER.includes(erstes)) {
const tausch = zeichen.findIndex((zeichenAnStelle, index) => {
return index > 0 && !PASSWORT_SONDER.includes(zeichenAnStelle as string);
});
if (tausch > 0) {
zeichen[0] = zeichen[tausch] as string;
zeichen[tausch] = erstes;
}
}
return zeichen.join("");
}
+136
View File
@@ -0,0 +1,136 @@
import { istEingebaut } from "@/lib/platzhalter";
import { PASSWORT_IM_DRUCK, passwortErzeugen } from "@/lib/passwort";
/**
* Das Ersetzen der Platzhalter (#012).
*
* Framework- und datenbankfrei: was ein Platzhalter bedeutet, entscheidet der
* Aufrufer, indem er die Werte hereinreicht. Hier steht nur, **wie** ersetzt
* wird — und das ist die Stelle, an der ein Fehler einen PowerShell-Befehl
* verfaelscht, den jemand auf einem Domaenencontroller ausfuehrt.
*
* Drei Regeln tragen das:
*
* 1. **Ein unbekannter Platzhalter bleibt stehen.** `{{tipfehler}}` wird nicht
* zu einer leeren Stelle, sondern bleibt sichtbar — im Befehl faellt das auf,
* ein stillschweigend leerer Parameter nicht.
* 2. **Ein bekannter Platzhalter ohne Wert wird leer.** `{{kunde}}` im Modus
* Intern ist nicht unbekannt, er ist leer. Der Unterschied ist der zwischen
* „falsch geschrieben“ und „hier gibt es das nicht“.
* 3. **Das generierte Passwort entsteht erst beim Ersetzen** und wird nie
* zurueckgegeben. Es gibt keinen Weg, es nachtraeglich zu erfragen.
*/
/**
* Wie der Text weiterverwendet wird – das entscheidet ueber Maskierung und Passwort.
*
* **Warum `anzeige` nicht maskiert:** der Text landet als React-Kind im Baum,
* und React maskiert Textknoten selbst. Eine zweite Maskierung hier machte aus
* „Forschung & Entwicklung“ sichtbar „Forschung &amp;amp; Entwicklung“ — der
* Versuch, sicherer zu sein, waere ein Anzeigefehler in jedem zweiten
* Abteilungsnamen. `tests/platzhalter-anzeige.test.tsx` haelt fest, dass ein
* `<script>` im Wert wirklich maskiert ankommt; bewiesen wird das am gerenderten
* HTML und nicht an einer Behauptung.
*
* Wer den Text je **als rohes HTML** einsetzt, maskiert selbst — und begruendet
* im selben Atemzug, warum er rohes HTML einsetzt.
*/
export type Verwendung =
/** Anzeige im Browser. React maskiert selbst; hier geschieht nichts. */
| { readonly art: "anzeige" }
/** In einem Befehl. Werte werden fuer die Shell maskiert. */
| { readonly art: "befehl"; readonly maskieren: (wert: string) => string }
/**
* Druckansicht. Wie `anzeige`, aber das generierte Passwort erscheint als
* `[Passwort – nicht gespeichert]`: ein Ausdruck liegt herum.
*/
| { readonly art: "druck" };
export type ErsetzungsErgebnis = {
readonly text: string;
/** Platzhalter, die niemand kennt – die Oberflaeche macht daraus eine Warnung. */
readonly unbekannt: readonly string[];
/**
* Kam `{{generiertes_passwort}}` vor? Dann gehoert der Hinweis daneben.
* Das Passwort selbst steht **nicht** hier; es existiert nur im Text.
*/
readonly mitPasswort: boolean;
};
const PASSWORT_SCHLUESSEL = "generiertes_passwort";
/**
* Ersetzt die Platzhalter in einem Text.
*
* `werte` ist die Zuordnung Schluessel → Wert. Ein Schluessel, der dort fehlt,
* aber laut `istEingebaut` existiert, wird leer; alles andere bleibt stehen.
*
* Die Ersatzfunktion bekommt den Wert als **Funktion** und nicht als
* Zeichenkette: `String.replace` deutet `$&`, `` $` ``, `$'` und `$1` im
* Ersatztext als Rueckverweise. Ein Nachname `A$&B` haette sonst den gefundenen
* Platzhalter wieder eingesetzt.
*/
export function platzhalterErsetzen(
text: string,
werte: Readonly<Record<string, string>>,
verwendung: Verwendung = { art: "anzeige" },
): ErsetzungsErgebnis {
const unbekannt = new Set<string>();
let mitPasswort = false;
/*
* Erkannt wird **jede** doppelte Klammer, nicht nur die mit gueltigem
* Schluessel. `{{Hostname}}` blieb sonst stehen **und** wurde nicht gemeldet —
* der Redakteur bekam keine Warnung fuer einen Platzhalter, der nie ersetzt
* wird. Ein falsch geschriebener ist gefaehrlicher als ein unbekannter: er
* sieht richtig aus.
*/
const ergebnis = text.replace(/\{\{\s*([^}]*?)\s*\}\}/gu, (ganzes, roh: string) => {
const schluessel = roh;
if (!/^[a-z][a-z0-9_]*$/u.test(schluessel)) {
// Kein gueltiger Schluessel – stehen lassen und melden.
unbekannt.add(schluessel);
return ganzes as string;
}
if (schluessel === PASSWORT_SCHLUESSEL) {
mitPasswort = true;
// Im Druck steht ein Hinweis statt des Passworts: ein Ausdruck liegt
// herum, und das Passwort ist nirgends gespeichert, also auch nicht
// nachvollziehbar, wenn es dort stuende.
if (verwendung.art === "druck") return PASSWORT_IM_DRUCK;
return aufbereiten(passwortErzeugen(), verwendung);
}
const wert = werte[schluessel];
if (wert !== undefined) return aufbereiten(wert, verwendung);
// Bekannt, aber ohne Wert: leer. Unbekannt: stehen lassen.
if (istEingebaut(schluessel)) return "";
unbekannt.add(schluessel);
return ganzes as string;
});
return { text: ergebnis, unbekannt: [...unbekannt], mitPasswort };
}
function aufbereiten(wert: string, verwendung: Verwendung): string {
switch (verwendung.art) {
case "befehl":
return verwendung.maskieren(wert);
case "anzeige":
case "druck":
// Unveraendert: React maskiert Textknoten selbst, siehe `Verwendung`.
return wert;
}
}
/**
* Wie `platzhalterErsetzen` mit `anzeige` – als sprechender Name fuer Texte, die
* weder in HTML noch in einen Befehl gehen, etwa einen vorgeschlagenen Lauftitel.
*/
export function platzhalterErsetzenRoh(
text: string,
werte: Readonly<Record<string, string>>,
): ErsetzungsErgebnis {
return platzhalterErsetzen(text, werte, { art: "anzeige" });
}
+31
View File
@@ -89,6 +89,37 @@ export function istEingebaut(schluessel: string): boolean {
return EINGEBAUTE_SCHLUESSEL.has(schluessel); return EINGEBAUTE_SCHLUESSEL.has(schluessel);
} }
/**
* Eingebaute Platzhalter, die eine Vorlagenvariable **nicht** belegen darf.
*
* Es sind die fuenf, die eine Aussage ueber **wer, wann und fuer wen** machen:
*
* - `techniker` und `datum` unterschreiben einen Befehl. Eine Variable dafuer
* hiesse, dass in einem fertigen Befehl ein fremder Name und ein falsches
* Datum stehen koennen, ohne dass es auffaellt.
* - `kunde` und `kundenkuerzel` sind im Modus INTERN ausdruecklich leer (#008).
* Eine Variable haette die Kundenebene in den Befehl zurueckgeholt, die es
* dort nicht gibt.
* - `generiertes_passwort` wird ohnehin vor jeder Zuordnung abgefangen; hier
* steht es, damit niemand eine Variable anlegt, die nie wirkt.
*
* Alles andere bleibt erlaubt, und das ist wichtig: beim Onboarding gibt es die
* Person im Verzeichnis noch nicht, die Vorlage fragt `upn`, `sam` und `mail`
* also selbst ab. Genau dafuer sind Variablen da.
*/
export const GESPERRTE_VARIABLENNAMEN: ReadonlySet<string> = new Set([
"techniker",
"datum",
"kunde",
"kundenkuerzel",
"generiertes_passwort",
]);
/** Darf eine Vorlagenvariable so heissen? */
export function istAlsVariableErlaubt(schluessel: string): boolean {
return !GESPERRTE_VARIABLENNAMEN.has(schluessel);
}
/** /**
* Die Platzhalter, die weder eingebaut noch als Variable definiert sind. * Die Platzhalter, die weder eingebaut noch als Variable definiert sind.
* *
+341
View File
@@ -0,0 +1,341 @@
import { SHELLS } from "@/lib/vorlagen-format";
/**
* Maskieren von Werten fuer eine Shell und die Skript-Kopie (#012).
*
* Hier endet der Weg eines Werts aus dem Verzeichnis oder aus einem
* Eingabefeld: als Teil eines Befehls, den ein Techniker kopiert und auf einem
* Domaenencontroller ausfuehrt. Ein Wert, der die Anfuehrungszeichen des Befehls
* sprengt, fuehrt dort fremden Code aus — mit den Rechten, die der Techniker
* gerade hat, und das sind die hoechsten im Haus.
*
* **Die tragende Annahme, ausgesprochen:** `wertMaskieren` maskiert fuer die
* Einbettung **innerhalb** der Anfuehrungszeichen, die schon in der Vorlage
* stehen — bei PowerShell und Cmd doppelte, bei Bash einfache:
*
* ```
* New-ADUser -Name "{{vorname}} {{nachname}}" → ... -Name "Jörg Müller"
* useradd -c '{{mitarbeiter}}' → ... -c 'O'\''Brien'
* ```
*
* Diese Funktion setzt **keine** Anfuehrungszeichen dazu, weil sie sonst die
* schon vorhandenen verdoppeln wuerde. Steht der Platzhalter in der Vorlage
* nackt da (`-Name {{vorname}}`), kann keine Maskierung der Welt daraus ein
* einzelnes Argument machen; dann schuetzt nur, wer die Vorlage schreibt. Das ist
* eine Annahme und keine Zusage, und sie steht hier, damit niemand sie fuer eine
* Zusage haelt.
*/
export type Shell = (typeof SHELLS)[number];
export class ShellFehler extends Error {
constructor(nachricht: string) {
super(nachricht);
this.name = "ShellFehler";
}
}
/**
* Zeilenumbruch je Shell.
*
* PowerShell und Cmd leben in der CRLF-Welt: eine `.cmd`-Datei mit reinen
* Zeilenvorschueben bringt `cmd.exe` bei Sprungmarken zum Stolpern. Bash lebt in
* der LF-Welt, und zwar unversoehnlich: ein Wagenruecklauf am Zeilenende landet
* als Zeichen im letzten Argument und macht aus `fi` ein `fi\r`.
*/
export const UMBRUCH: Record<Shell, string> = {
powershell: "\r\n",
bash: "\n",
cmd: "\r\n",
};
/** Wie eine Kommentarzeile beginnt. Cmd kennt kein `#`. */
export const KOMMENTAR_ZEICHEN: Record<Shell, string> = {
powershell: "#",
bash: "#",
cmd: "REM",
};
/**
* Zeichen, die Cmd innerhalb doppelter Anfuehrungszeichen nicht tragen kann.
*
* Siehe `cmdMaskieren` — diese vier werden abgelehnt, statt sie unsicher
* durchzulassen.
*/
const CMD_UNMOEGLICH: readonly (readonly [RegExp, string])[] = [
[/"/, "ein doppeltes Anführungszeichen"],
[/\r/, "einen Wagenrücklauf"],
[/\n/, "einen Zeilenumbruch"],
[/\u0000/, "ein Null-Byte"],
];
/**
* Zeichen, die PowerShell als doppeltes Anfuehrungszeichen liest.
*
* Nicht nur `U+0022`: `about_Quoting_Rules` nennt ausdruecklich auch die
* typografischen Varianten. Gegen PowerShell 7 nachgemessen — `“`, `”` und `„`
* **beenden eine `"…"`-Zeichenkette**, alles danach ist Code.
*
* Das ist kein erfundener Angriff: `„` ist das deutsche Anfuehrungszeichen, und
* Word wie Outlook wandeln ein getipptes `"` von selbst in `“`/`”`. Ein
* Abteilungsname, den jemand aus Word in ein Formular kopiert, reicht.
*/
const PS_DOPPELTE_ANFUEHRUNG = /[\u201C\u201D\u201E]/gu;
/**
* Dieselbe Familie fuer das einfache Anfuehrungszeichen.
*
* Innerhalb von `"…"` sind sie harmlos und werden **nicht** verdoppelt — das
* verfaelschte jeden Namen wie „O’Neill“. Angeglichen werden sie trotzdem:
* benutzt eine Vorlage die andere Form (`'{{vorname}}'`), beenden sie dort die
* Zeichenkette (gemessen gegen PowerShell 7). Dass ein gewoehnliches `'` dieselbe
* Vorlage genauso bricht, faengt `platzhalterAusserhalbAnfuehrungszeichen` ab.
*/
const PS_EINFACHE_ANFUEHRUNG = /[\u2018\u2019\u201A\u201B]/gu;
/**
* Maskiert fuer eine PowerShell-Zeichenkette in doppelten Anfuehrungszeichen.
*
* Innerhalb von `"…"` sind genau drei Zeichen besonders, und alle drei werden
* hier entschaerft:
*
* - `` ` `` ist das Fluchtzeichen und wird verdoppelt. Zuerst, sonst wuerden die
* Fluchtzeichen der naechsten Schritte gleich wieder mitverdoppelt.
* - `"` beendet die Zeichenkette und wird verdoppelt (`""` ist innerhalb von
* `"…"` das literale Anfuehrungszeichen).
* - `$` fuehrt die Ersetzung ein. Das schliesst `$(…)` ein, also die Ausfuehrung:
* aus `$(rm -rf /)` wird `` `$(rm -rf /) `` und damit sichtbarer Text statt
* eines Unterausdrucks.
*
* **Typografische Anfuehrungszeichen werden vorher auf ihre ASCII-Form
* gebracht** und danach wie diese behandelt. Das aendert das Zeichen — aus `”`
* wird `"` —, und das ist Absicht: PowerShell liest es ohnehin als Begrenzer,
* es laesst sich also gar nicht als Text durchreichen. Lieber ein Zeichen, das
* anders aussieht als gedacht, als ein Befehl, der etwas anderes tut als er
* liest.
*
* Zeilenumbrueche werden zu `` `r `` und `` `n ``. Innerhalb von `"…"` waeren sie
* auch unmaskiert unschaedlich — die Zeichenkette laeuft ueber Zeilen weiter —,
* aber der Wert bleibt so in **einer** Zeile, und was in einer Zeile steht,
* ueberlebt jedes Kopieren, jedes Einfuegen in eine Konsole und jede
* Skript-Kopie, die Zeilen einzeln behandelt.
*/
function powershellMaskieren(wert: string): string {
return wert
.replace(PS_DOPPELTE_ANFUEHRUNG, '"')
.replace(PS_EINFACHE_ANFUEHRUNG, "'")
.replaceAll("`", "``")
.replaceAll('"', '""')
.replaceAll("$", "`$")
.replaceAll("\r", "`r")
.replaceAll("\n", "`n");
}
/**
* Maskiert fuer eine Bash-Zeichenkette in einfachen Anfuehrungszeichen.
*
* In `'…'` ist **kein** Zeichen besonders ausser dem einfachen
* Anfuehrungszeichen selbst — kein `$`, kein Backtick, kein `\`, kein
* Zeilenumbruch. Also genuegt der bekannte Kunstgriff: das Anfuehrungszeichen
* schliesst die Zeichenkette, setzt ein maskiertes `\'` daneben und oeffnet
* wieder. Fuer die Shell bleibt das ein zusammenhaengendes Wort.
*/
function bashMaskieren(wert: string): string {
/*
* Zeilenumbrueche bekommen eine eigene Behandlung: `'…'` traegt sie zwar
* woertlich, aber `skriptBauen` bringt danach alle Umbrueche des fertigen
* Befehls auf die Form der Shell — und kann dabei nicht wissen, welcher zum
* Befehl gehoert und welcher aus einem Wert stammt. Aus einem `\r` im Wert
* wurde so ein `\n`; bei CRLF-Werten aus CSV oder LDAP der Normalfall.
*
* `'…'$'\r''…'` ist Bashs eigene Schreibweise dafuer: die Zeichenkette wird
* geschlossen, das Zeichen als Fluchtfolge angehaengt und wieder geoeffnet.
* Fuer die Shell bleibt es ein Wort, und im fertigen Befehl steht kein
* wirklicher Umbruch mehr.
*/
return (
wert
.replaceAll("'", `'\\''`)
/*
* Ersatz**funktionen**, keine Zeichenketten: `$'` ist in `String.replace`
* ein Rueckverweis auf alles hinter dem Treffer. Aus `erste\nzweite` wurde
* damit `erste'zweite\n''zweite` — ein Befehl, der nicht mehr zugeht.
* Derselbe Fehler steckte in `mitBeispielwerten`, gefunden im Review zu #010.
*/
.replaceAll("\r", () => `'$'\\r''`)
.replaceAll("\n", () => `'$'\\n''`)
);
}
/**
* Maskiert fuer eine Cmd-Zeichenkette in doppelten Anfuehrungszeichen — soweit das geht.
*
* **Was hier nicht geht, und zwar grundsaetzlich:**
*
* - Ein `"` im Wert laesst sich nicht maskieren. `\"` ist keine Regel von
* `cmd.exe`, sondern eine Verabredung der C-Laufzeit des aufgerufenen
* Programms; `""` deuten verschiedene Programme verschieden, und `^"` wirkt
* innerhalb von Anfuehrungszeichen nicht. Ein `"` beendet das Argument, und
* alles danach liest `cmd.exe` als Befehl — inklusive `&`. Deshalb wird ein
* solcher Wert **abgelehnt**, mit CR, LF und dem Null-Byte zusammen, die eine
* Cmd-Befehlszeile ebenfalls nicht tragen kann.
* - Ein `%` bleibt stehen und wird von `cmd.exe` **ersetzt**, wenn er einen
* Variablennamen einschliesst: aus `%PATH%` wird der Suchpfad. Innerhalb von
* Anfuehrungszeichen aendert das nichts daran. Verdoppeln waere nur in einer
* Batch-Datei richtig und in einer interaktiven Eingabe falsch, und dieselbe
* Zeichenkette dient hier beidem — es gibt also keine Maskierung, die in
* beiden Faellen stimmt.
*
* **Das ist nicht harmlos**, auch wenn Cmd keine Befehlsersetzung kennt: in
* einer Batch-Datei wird `%VAR%` **vor** der Zerlegung in Befehl und
* Trennzeichen ersetzt. Enthaelt die Variable ein `&` oder ein `"`, wird das
* danach als Trennzeichen bzw. Anfuehrungszeichen gelesen — genau deshalb gibt
* es die verzoegerte Ersetzung `!VAR!` ueberhaupt. Wer einen Wert mit `%`
* erwartet, stellt den Schritt besser auf PowerShell um.
* - Ein `!` bleibt stehen. Er wird nur ersetzt, wenn die verzoegerte Ersetzung
* eingeschaltet ist (`setlocal enabledelayedexpansion`). Die Skript-Kopie
* dieses Moduls schaltet sie nicht ein; eine fremde Umgebung kann es.
*
* Harmlos sind dagegen `&`, `|`, `<`, `>` und `^`: `cmd.exe` behandelt
* Anfuehrungszeichen vor den Trennzeichen, innerhalb von `"…"` sind sie
* gewoehnliche Zeichen. Genau deshalb ist das `"` das einzige, was zaehlt — und
* genau deshalb wird es abgelehnt.
*/
function cmdMaskieren(wert: string): string {
for (const [muster, benennung] of CMD_UNMOEGLICH) {
if (muster.test(wert)) {
throw new ShellFehler(
`Der Wert enthält ${benennung}. In einem Cmd-Befehl lässt sich das nicht sicher maskieren; ` +
"bitte den Schritt auf PowerShell umstellen oder den Wert bereinigen.",
);
}
}
return wert;
}
/**
* Maskiert einen Wert fuer die angegebene Shell.
*
* Erwartet, dass der Platzhalter in der Vorlage in Anfuehrungszeichen steht —
* doppelte bei `powershell` und `cmd`, einfache bei `bash`. Siehe die Anmerkung
* am Kopf dieser Datei; sie ist die einzige Annahme, die dieses Modul nicht
* selbst pruefen kann.
*
* Wirft `ShellFehler`, wenn ein Wert in einem Cmd-Befehl nicht sicher
* darstellbar ist. Ein Fehler an der Stelle ist gewollt: der Techniker sieht
* dann, dass hier etwas nicht geht, statt einen Befehl zu kopieren, der etwas
* anderes tut als er liest.
*/
export function wertMaskieren(wert: string, shell: Shell): string {
switch (shell) {
case "powershell":
return powershellMaskieren(wert);
case "bash":
return bashMaskieren(wert);
case "cmd":
return cmdMaskieren(wert);
}
}
/**
* Platzhalter, die **nicht** zwischen den erwarteten Anfuehrungszeichen stehen.
*
* Die ganze Maskierung ruht auf einer Annahme: `{{name}}` steht in der Vorlage
* in Anfuehrungszeichen — doppelten bei `powershell` und `cmd`, einfachen bei
* `bash`. Steht er nackt da (`Write-Host {{vorname}}`), kann keine Maskierung der
* Welt daraus noch **ein** Argument machen: ein Leerzeichen im Wert zerlegt ihn,
* ein `;` beendet den Befehl. Bis zum Review von #012 hat das niemand geprueft —
* die Annahme stand als Kommentar da und wurde nirgends eingeloest.
*
* Geprueft wird durch schlichtes Durchzaehlen: ein Anfuehrungszeichen oeffnet,
* das naechste gleicher Art schliesst. Das ist grob — Fluchtfolgen innerhalb
* einer Zeichenkette kann es nicht auseinanderhalten — aber es faellt in die
* **sichere** Richtung: im Zweifel meldet es einen Platzhalter, der in Ordnung
* ist, statt einen durchzulassen, der es nicht ist.
*/
export function platzhalterAusserhalbAnfuehrungszeichen(befehl: string, shell: Shell): readonly string[] {
const zeichen = shell === "bash" ? "'" : '"';
const gefunden: string[] = [];
let offen = false;
for (let pos = 0; pos < befehl.length; pos += 1) {
if (befehl.charAt(pos) === zeichen) {
offen = !offen;
continue;
}
if (befehl.startsWith("{{", pos)) {
const ende = befehl.indexOf("}}", pos);
if (ende < 0) break;
const name = befehl.slice(pos + 2, ende).trim();
if (!offen && /^[a-z][a-z0-9_]*$/u.test(name)) gefunden.push(name);
pos = ende + 1;
}
}
return gefunden;
}
export type SkriptSchritt = {
readonly titel: string;
readonly shell: Shell;
readonly befehl: string;
};
/**
* Macht aus einem Titel eine Kommentarzeile, die nicht aufbrechen kann.
*
* Ein Titel mit Zeilenumbruch schiebt seine zweite Haelfte aus dem Kommentar
* heraus und laesst sie als Befehl stehen — aus einem Titel „Konto anlegen“ mit
* angehaengtem Umbruch und `format c:` wuerde eine ausfuehrbare Zeile. Alle
* Steuerzeichen werden deshalb zu Leerzeichen und mehrere Leerzeichen zu einem.
*/
function kommentarTitel(titel: string): string {
const eineZeile = titel
.replace(/[\u0000-\u001F\u007F]+/g, " ")
.replace(/\s{2,}/g, " ")
.trim();
return eineZeile || "(ohne Titel)";
}
/**
* Der Umbruch fuer ein ganzes Skript.
*
* Bei einheitlicher Shell der ihre. Eine gemischte Liste laesst sich ohnehin von
* keinem Interpreter als Ganzes ausfuehren — dort gibt es keine richtige Wahl,
* nur eine weniger schaedliche: LF, weil ein Wagenruecklauf jede Bash-Zeile
* sicher beschaedigt, waehrend PowerShell LF ohne weiteres liest. Fuer Cmd bleibt
* es ein Zugestaendnis.
*/
function umbruchFuer(schritte: readonly SkriptSchritt[]): string {
const ersteShell = schritte[0]?.shell;
if (ersteShell === undefined) return UMBRUCH.bash;
return schritte.every((schritt) => schritt.shell === ersteShell) ? UMBRUCH[ersteShell] : "\n";
}
/**
* Baut aus den Befehlsschritten eines Abschnitts ein Skript zum Kopieren.
*
* Alle Befehle in der uebergebenen Reihenfolge, je mit `# Schritt: <Titel>`
* davor (`REM Schritt: <Titel>` bei Cmd), getrennt durch eine Leerzeile. Die
* Umbrueche **innerhalb** eines mehrzeiligen Befehls werden auf denselben Umbruch
* gebracht: eine CRLF-Datei mit einzelnen Zeilenvorschuechen darin ist keine
* CRLF-Datei.
*
* Schritte ohne Befehl fallen heraus: eine Kommentarzeile mit einer Leerzeile
* darunter und nichts dazwischen hilft niemandem.
*
* Das Skript endet mit einem Umbruch, damit die letzte Zeile auch dann
* ausgefuehrt wird, wenn sie als Datei gespeichert wird.
*/
export function skriptBauen(schritte: readonly SkriptSchritt[]): string {
const mitBefehl = schritte.filter((schritt) => schritt.befehl.trim() !== "");
if (mitBefehl.length === 0) return "";
const umbruch = umbruchFuer(mitBefehl);
const bloecke = mitBefehl.map((schritt) => {
const kopf = `${KOMMENTAR_ZEICHEN[schritt.shell]} Schritt: ${kommentarTitel(schritt.titel)}`;
const befehl = schritt.befehl.trim().replace(/\r\n|\r|\n/g, umbruch);
return kopf + umbruch + befehl;
});
return bloecke.join(umbruch + umbruch) + umbruch;
}
+14 -1
View File
@@ -1,3 +1,4 @@
import { istAlsVariableErlaubt } from "@/lib/platzhalter";
import { z } from "@/lib/validierung"; import { z } from "@/lib/validierung";
/** /**
@@ -119,7 +120,19 @@ export const VariableSchema = z.object({
.regex( .regex(
/^[a-z][a-z0-9_]{0,39}$/, /^[a-z][a-z0-9_]{0,39}$/,
"Ein Variablenschlüssel beginnt mit einem Kleinbuchstaben und besteht aus Kleinbuchstaben, Ziffern und Unterstrich.", "Ein Variablenschlüssel beginnt mit einem Kleinbuchstaben und besteht aus Kleinbuchstaben, Ziffern und Unterstrich.",
), )
/*
* Ein eingebauter Name ist als Variable gesperrt – mit einer Ausnahme,
* `hostname` (siehe `UEBERSCHREIBBARE_PLATZHALTER`). Sonst hiesse eine
* Variable `techniker`, und der fertige Befehl unterschriebe mit einem
* Namen, der nicht stimmt. Die Aufloesung wehrt das noch einmal ab; hier
* faellt es dem Redakteur auf, **bevor** er die Vorlage veroeffentlicht.
*/
.refine(istAlsVariableErlaubt, {
message:
"Diesen Namen gibt es schon als eingebauten Platzhalter. Bitte einen anderen wählen — " +
"sonst stünde im fertigen Befehl etwas anderes, als der eingebaute Wert sagt.",
}),
beschriftung: z.string().trim().min(1, "Eine Variable braucht eine Beschriftung.").max(GRENZEN.text), beschriftung: z.string().trim().min(1, "Eine Variable braucht eine Beschriftung.").max(GRENZEN.text),
typ: z.enum(VARIABLEN_TYPEN).default("TEXT"), typ: z.enum(VARIABLEN_TYPEN).default("TEXT"),
optionen: z.array(z.string().trim().min(1).max(GRENZEN.text)).max(GRENZEN.optionen).default([]), optionen: z.array(z.string().trim().min(1).max(GRENZEN.text)).max(GRENZEN.optionen).default([]),
+367
View File
@@ -0,0 +1,367 @@
import { Betriebsmodus } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db";
import {
namenEindeutigBilden,
NamensregelnSchema,
type Namensregeln,
type Namenswerte,
type Zielformat,
} from "@/lib/namensregeln";
import { PASSWORT_HINWEIS } from "@/lib/passwort";
import { platzhalterErsetzen, type ErsetzungsErgebnis } from "@/lib/platzhalter-werte";
import {
platzhalterAusserhalbAnfuehrungszeichen,
ShellFehler,
skriptBauen,
wertMaskieren,
type Shell,
type SkriptSchritt,
} from "@/lib/shell";
import { STANDARD_SHELL } from "@/lib/editor-aufbau";
import { betriebsmodus, kundeErmitteln, type HandelnderBenutzer } from "@/server/mandant";
import { platzhalterWerteDesLaufs, type LaufKontext, type PlatzhalterWerte } from "./platzhalter";
/**
* Fertige Befehle und Namensvorschlaege (#012).
*
* Hier laufen die drei Teile zusammen: die Werte eines Laufs
* (`services/platzhalter.ts`), das Ersetzen (`lib/platzhalter-werte.ts`) und die
* Maskierung je Shell (`lib/shell.ts`). Das Ergebnis ist der Text, den ein
* Techniker kopiert — und alles, was ihn verfaelscht, wird hier verfaelscht.
*
* Die Reihenfolge ist nicht beliebig: **erst ersetzen, dann maskieren** waere
* falsch. Maskiert wird der **eingesetzte Wert**, nicht der fertige Befehl —
* sonst maskierte man die Anfuehrungszeichen des Befehls gleich mit.
* `platzhalterErsetzen` bekommt die Maskierung deshalb als Funktion und wendet
* sie auf jeden Wert einzeln an.
*/
export type BefehlsSchritt = {
readonly titel: string;
readonly shell: Shell;
readonly befehl: string;
};
/** Ein Schritt mit eingesetzten Werten, bereit zum Kopieren. */
export type FertigerBefehl = {
readonly titel: string;
readonly shell: Shell;
readonly befehl: string;
/** Platzhalter, die niemand kennt – sie stehen im Befehl noch als `{{name}}`. */
readonly unbekannt: readonly string[];
/** Kam `{{generiertes_passwort}}` vor? Dann gehoert der Hinweis daneben. */
readonly mitPasswort: boolean;
/**
* Der Satz, der neben einem Passwort stehen muss. Steht hier und nicht in der
* Oberflaeche, damit jede Anzeige denselben bekommt — ein Passwort ohne diesen
* Hinweis ist ein verlorenes Passwort.
*/
readonly hinweis?: string;
/**
* Gesetzt, wenn sich ein Wert fuer diese Shell **nicht sicher** maskieren
* laesst. Dann steht in `befehl` der Befehl mit stehen gebliebenen
* Platzhaltern, und die Oberflaeche bietet kein Kopieren an.
*/
readonly fehler?: string;
};
/**
* Setzt die Werte in einen einzelnen Befehl ein.
*
* Der Titel wird **ohne** Shell-Maskierung ersetzt: er wird gelesen, nicht
* ausgefuehrt. Ein maskierter Titel stuende mit Backticks auf dem Bildschirm.
*/
export function befehlFuellen(schritt: BefehlsSchritt, werte: PlatzhalterWerte): FertigerBefehl {
const titel = platzhalterErsetzen(schritt.titel, werte).text;
/*
* Erst die Annahme pruefen, auf der die ganze Maskierung ruht: steht der
* Platzhalter nicht in Anfuehrungszeichen, kann keine Maskierung daraus noch
* **ein** Argument machen. Ein Leerzeichen im Wert zerlegt ihn, ein `;`
* beendet den Befehl.
*/
const nackt = platzhalterAusserhalbAnfuehrungszeichen(schritt.befehl, schritt.shell);
if (nackt.length > 0) {
const zeichen = schritt.shell === "bash" ? "einfache" : "doppelte";
return {
titel,
shell: schritt.shell,
befehl: schritt.befehl,
unbekannt: [],
mitPasswort: false,
fehler:
`${nackt.map((name) => `„{{${name}}}“`).join(", ")} steht nicht in Anführungszeichen. ` +
`Ein eingesetzter Wert lässt sich dann nicht sicher maskieren — bitte ${zeichen} ` +
"Anführungszeichen um den Platzhalter setzen.",
};
}
try {
const befehl = platzhalterErsetzen(schritt.befehl, werte, {
art: "befehl",
maskieren: (wert) => wertMaskieren(wert, schritt.shell),
});
return {
titel,
shell: schritt.shell,
befehl: befehl.text,
unbekannt: befehl.unbekannt,
mitPasswort: befehl.mitPasswort,
...(befehl.mitPasswort ? { hinweis: PASSWORT_HINWEIS } : {}),
};
} catch (fehler) {
/*
* `wertMaskieren` **wirft** fuer `cmd`, wenn ein Wert ein
* Anfuehrungszeichen oder einen Umbruch enthaelt: dort laesst sich das nicht
* sicher maskieren, und ein `"` beendet das Argument — alles danach liest
* cmd.exe als Befehl.
*
* Ein ungefangener Wurf risse hier die ganze Abarbeitungsansicht ab. Statt
* dessen bleibt der Befehl mit seinen Platzhaltern stehen und traegt die
* Begruendung: der Techniker sieht, was gemeint war, und dass er es von Hand
* einsetzen muss. Einen halb maskierten Befehl zum Kopieren anzubieten waere
* die gefaehrlichste aller Antworten.
*/
if (fehler instanceof ShellFehler) {
return {
titel,
shell: schritt.shell,
befehl: schritt.befehl,
unbekannt: [],
mitPasswort: false,
fehler: fehler.message,
};
}
throw fehler;
}
}
/**
* Alle Befehle eines Abschnitts als ein Skript zum Kopieren.
*
* **Die Passwoerter darin entstehen genau jetzt.** Wer das Skript zweimal
* kopiert, bekommt zweimal verschiedene — das ist die ehrliche Folge der Regel
* „bei jeder Anzeige neu“ und der Grund, warum der Hinweis daneben steht.
*/
/**
* Ein Skript aus **bereits gefuellten** Schritten.
*
* Entscheidend, sobald ein `{{generiertes_passwort}}` im Spiel ist: es entsteht
* bei jedem Fuellen neu. Wer dieselben Schritte zweimal fuellt — einmal fuer die
* Anzeige, einmal fuer das Skript —, zeigt dem Techniker Passwort X und gibt ihm
* Passwort Y zum Kopieren. Er legt das Konto mit Y an und nennt dem Benutzer X,
* und weil nichts gespeichert wird, ist Y danach unwiederbringlich.
*/
export function skriptAusFertigen(fertige: readonly FertigerBefehl[]): {
readonly skript: string;
readonly mitPasswort: boolean;
readonly unbekannt: readonly string[];
readonly nichtMaskierbar: readonly string[];
} {
/*
* Ein Schritt, der sich nicht sicher maskieren laesst, faellt aus dem Skript
* heraus — mit seinen Platzhaltern drin waere es ein Skript, das etwas anderes
* tut, als es liest. Gemeldet wird er trotzdem, sonst fehlte er stillschweigend.
*/
const alsSkript: SkriptSchritt[] = fertige
.filter((schritt) => schritt.fehler === undefined)
.map((schritt) => ({ titel: schritt.titel, shell: schritt.shell, befehl: schritt.befehl }));
const unbekannt = new Set<string>();
for (const schritt of fertige) for (const name of schritt.unbekannt) unbekannt.add(name);
return {
skript: skriptBauen(alsSkript),
mitPasswort: fertige.some((schritt) => schritt.mitPasswort),
unbekannt: [...unbekannt],
nichtMaskierbar: fertige
.filter((schritt) => schritt.fehler !== undefined)
.map((schritt) => schritt.titel),
};
}
/** Fuellt die Schritte und baut daraus das Skript – in **einem** Durchgang. */
export function skriptFuerAbschnitt(
schritte: readonly BefehlsSchritt[],
werte: PlatzhalterWerte,
): {
readonly skript: string;
readonly mitPasswort: boolean;
readonly unbekannt: readonly string[];
/** Schritte, deren Werte sich nicht sicher maskieren liessen. */
readonly nichtMaskierbar: readonly string[];
} {
return skriptAusFertigen(schritte.map((schritt) => befehlFuellen(schritt, werte)));
}
/**
* Die Befehle eines Laufs, abschnittsweise.
*
* Liest die Version, an der der Lauf haengt — nicht die neueste. Ein Lauf zeigt
* fuer immer die Schritte, mit denen er gestartet wurde (#009).
*/
export async function befehleDesLaufs(kontext: LaufKontext): Promise<
readonly {
readonly abschnitt: string;
readonly schritte: readonly FertigerBefehl[];
readonly skript: string;
/** Platzhalter, die niemand kennt – sie stehen noch als `{{name}}` im Befehl. */
readonly unbekannt: readonly string[];
/** Schritte, die nicht ins Skript konnten, weil ihre Werte nicht maskierbar sind. */
readonly nichtMaskierbar: readonly string[];
}[]
> {
const lauf = await prisma.lauf.findUnique({
where: { id: kontext.laufId },
select: {
vorlagenVersion: {
select: {
abschnitte: {
orderBy: { position: "asc" },
select: {
titel: true,
schritte: {
where: { typ: "BEFEHL" },
orderBy: { position: "asc" },
select: { titel: true, konfig: true },
},
},
},
},
},
},
});
if (!lauf) return [];
const werte = await platzhalterWerteDesLaufs(kontext);
return lauf.vorlagenVersion.abschnitte
.map((abschnitt) => {
const schritte: BefehlsSchritt[] = abschnitt.schritte.map((schritt) => ({
titel: schritt.titel,
shell: shellAus(schritt.konfig),
befehl: befehlAus(schritt.konfig),
}));
// **Einmal** fuellen und daraus beides bauen: ein zweiter Durchgang
// erzeugte ein zweites Passwort (siehe skriptAusFertigen).
const gefuellt = schritte.map((schritt) => befehlFuellen(schritt, werte));
const skript = skriptAusFertigen(gefuellt);
return {
abschnitt: abschnitt.titel,
schritte: gefuellt,
skript: skript.skript,
unbekannt: skript.unbekannt,
nichtMaskierbar: skript.nichtMaskierbar,
};
})
.filter((abschnitt) => abschnitt.schritte.length > 0);
}
/**
* Shell und Befehl aus dem `konfig`-Json.
*
* Das Feld ist ein Json und kann alles enthalten — eine aeltere Fassung, ein
* Import, ein Eingriff per psql. Was nicht passt, wird zum Standard bzw. leer;
* ein leerer Befehl faellt aus dem Skript heraus, statt es zu sprengen.
*/
function shellAus(konfig: unknown): Shell {
const wert = lesen(konfig, "shell");
return wert === "powershell" || wert === "bash" || wert === "cmd" ? wert : STANDARD_SHELL;
}
function befehlAus(konfig: unknown): string {
return lesen(konfig, "befehl") ?? "";
}
function lesen(konfig: unknown, schluessel: string): string | undefined {
if (typeof konfig !== "object" || konfig === null || Array.isArray(konfig)) return undefined;
const wert = (konfig as Record<string, unknown>)[schluessel];
return typeof wert === "string" ? wert : undefined;
}
/**
* Die geltenden Namensregeln.
*
* Global aus `Einstellung`, im Modus DIENSTLEISTUNG je Kunde ueberlagert: eine
* gesetzte Kundenregel gewinnt, eine fehlende faellt auf die globale zurueck.
* **Feld fuer Feld**, nicht als Ganzes — ein Kunde, der nur eine eigene
* Mail-Domain hat, soll nicht auch seine Hostname-Regel neu schreiben muessen.
*/
export async function namensregelnFuer(kundeId: string | null): Promise<Namensregeln> {
const [einstellung, kunde] = await Promise.all([
prisma.einstellung.findUnique({ where: { id: "global" }, select: { namensregeln: true } }),
kundeId
? prisma.kunde.findUnique({ where: { id: kundeId }, select: { namensregeln: true } })
: Promise.resolve(null),
]);
const global = regelnLesen(einstellung?.namensregeln);
if ((await betriebsmodus()) !== Betriebsmodus.DIENSTLEISTUNG) return global;
const eigene = regelnLesen(kunde?.namensregeln);
return {
...global,
...Object.fromEntries(Object.entries(eigene).filter(([, wert]) => (wert ?? "").trim() !== "")),
};
}
/** Ein unbrauchbarer Json-Inhalt darf keinen Namensvorschlag sprengen. */
function regelnLesen(roh: unknown): Namensregeln {
const gelesen = NamensregelnSchema.safeParse(roh);
return gelesen.success ? gelesen.data : {};
}
export type NamensVorschlaege = Partial<Record<Zielformat, string>>;
/**
* Vorschlaege fuer `upn`, `sam`, `mail` und `hostname`.
*
* Eindeutigkeit wird gegen die Datenbank geprueft: `sam` und `upn` gegen die
* Mitarbeiter desselben Kunden, `hostname` gegen dessen Geraete. Verbindlich ist
* sie trotzdem erst beim Schreiben — zwischen Frage und Anlegen kann ein
* zweiter Lauf denselben Namen belegen. Ein Vorschlag ist ein Vorschlag.
*/
export async function namenVorschlagen(
kundeIdAusEingabe: string | null,
werte: Namenswerte,
benutzer: HandelnderBenutzer,
): Promise<NamensVorschlaege> {
/*
* Ueber `kundeErmitteln`, nicht ueber die mitgeschickte Kennung: im Modus
* INTERN wird sie verworfen, im Modus DIENSTLEISTUNG gegen die sichtbaren
* Kunden geprueft (CLAUDE.md, Regel „Mandant“). Ohne das waere `istVergeben`
* ein Orakel ueber Kundengrenzen hinweg — „gibt es bei euch einen mhuber?“.
*/
const kundeId = await kundeErmitteln(kundeIdAusEingabe, benutzer);
const regeln = await namensregelnFuer(kundeId);
const vorschlaege: NamensVorschlaege = {};
for (const zielformat of ["upn", "sam", "mail", "hostname"] as const) {
const regel = regeln[zielformat];
if (!regel) continue;
const name = await namenEindeutigBilden(zielformat, regel, werte, (kandidat) =>
istVergeben(zielformat, kandidat, kundeId),
);
if (name) vorschlaege[zielformat] = name;
}
return vorschlaege;
}
async function istVergeben(zielformat: Zielformat, kandidat: string, kundeId: string): Promise<boolean> {
if (zielformat === "hostname") {
const geraet = await prisma.geraet.findFirst({
where: { kundeId, hostname: { equals: kandidat, mode: "insensitive" } },
select: { id: true },
});
return geraet !== null;
}
const feld = zielformat === "mail" ? "mail" : zielformat;
const person = await prisma.mitarbeiter.findFirst({
where: { kundeId, [feld]: { equals: kandidat, mode: "insensitive" } },
select: { id: true },
});
return person !== null;
}
export type { ErsetzungsErgebnis };
+7 -12
View File
@@ -1,7 +1,7 @@
import { z } from "@/lib/validierung";
import type { Prisma } from "@/generated/prisma/client"; import type { Prisma } from "@/generated/prisma/client";
import { Betriebsmodus } from "@/generated/prisma/enums"; import { Betriebsmodus } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { NamensregelnSchema, type Namensregeln } from "@/lib/namensregeln";
import { betriebsmodus, eigeneOrganisation } from "@/server/mandant"; import { betriebsmodus, eigeneOrganisation } from "@/server/mandant";
import { kuerzelPruefen } from "@/server/services/kunden"; import { kuerzelPruefen } from "@/server/services/kunden";
@@ -21,18 +21,13 @@ export class EinstellungsFehler extends Error {
} }
/** /**
* Vorlagen fuer abgeleitete Werte. Die Platzhalter darin (`{vorname}`, * Schema und Typ der Namensregeln liegen seit #012 in `src/lib/namensregeln.ts`,
* `{nachname}`, `{v}`, `{nnn}`, `{kundenkuerzel}`) loest #012 auf; hier werden * zusammen mit der Aufloesung der Platzhalter darin (`{vorname}`, `{nachname}`,
* sie nur gespeichert und bearbeitet. * `{v}`, `{nnn}`, `{kundenkuerzel}`). Hier werden sie nur gespeichert und
* bearbeitet — weitergereicht werden sie, damit die Server Action und das
* Formular ihren Import nicht aendern muessen.
*/ */
export const NamensregelnSchema = z.object({ export { NamensregelnSchema, type Namensregeln };
upn: z.string().trim().max(200).optional(),
sam: z.string().trim().max(200).optional(),
mail: z.string().trim().max(200).optional(),
hostname: z.string().trim().max(200).optional(),
});
export type Namensregeln = z.infer<typeof NamensregelnSchema>;
export type Einstellungen = { export type Einstellungen = {
readonly modus: Betriebsmodus; readonly modus: Betriebsmodus;
+221
View File
@@ -0,0 +1,221 @@
import { Betriebsmodus } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db";
import { istAlsVariableErlaubt } from "@/lib/platzhalter";
import { betriebsmodus } from "@/server/mandant";
/**
* Die Werte hinter den Platzhaltern (#012).
*
* Diese Datei traegt zusammen, was ein Lauf ueber sich weiss: Geraet,
* Mitarbeiter, Abteilung, Kunde, Vorlagenvariablen. Das **Ersetzen** selbst
* steht framework-frei in `src/lib/platzhalter-werte.ts`, die Liste der
* eingebauten Namen in `src/lib/platzhalter.ts`. Drei Dateien, drei Aufgaben —
* und nur diese eine kennt die Datenbank.
*
* Eine Regel zieht sich durch: **ein fehlender Wert ist leer, kein Fehler.** Ein
* Lauf ohne Mitarbeiter ist gueltig (Server, Firewall, Patchday), und ein
* Befehl mit einem leeren `-Department` faellt beim Lesen auf. Abzubrechen
* hiesse, dem Techniker die Arbeit zu verweigern, weil ein Feld fehlt, das er
* vielleicht gar nicht braucht.
*/
export type PlatzhalterWerte = Readonly<Record<string, string>>;
/**
* Der Eltern-Container eines `dn`.
*
* Aus `CN=Maria Huber,OU=Buchhaltung,OU=Benutzer,DC=firma,DC=local` wird
* `OU=Buchhaltung,OU=Benutzer,DC=firma,DC=local` — alles nach dem ersten Komma.
* Das ist der Ort, an dem das Konto liegt, und genau den braucht ein
* `New-ADUser -Path`.
*
* Ein Komma **innerhalb** eines Namens ist im DN maskiert (`CN=Huber\, Maria`),
* deshalb wird ein Komma hinter einem Backslash nicht als Trenner gelesen.
*/
export function ouAusDn(dn: string): string {
/*
* Gezaehlt statt geraten: `(?<!\\),` uebersieht, dass der Backslash selbst
* maskiert sein kann. In `CN=X\\,OU=Y` ist `\\` ein literaler Backslash im
* Wert (RFC 4514) und das Komma **ist** der Trenner — die Vorausschau hielt
* es faelschlich fuer maskiert, lieferte nichts, und der Aufrufer fiel still
* auf die Ziel-OU der Abteilung zurueck. Das Konto landete in der falschen OU.
*
* Ein Komma ist maskiert, wenn davor **ungerade** viele Backslashes stehen.
*/
for (let pos = 0; pos < dn.length; pos += 1) {
if (dn.charAt(pos) !== ",") continue;
let schraegstriche = 0;
while (dn.charAt(pos - 1 - schraegstriche) === "\\") schraegstriche += 1;
if (schraegstriche % 2 === 0) return dn.slice(pos + 1).trim();
}
return "";
}
function alsText(wert: string | null | undefined): string {
return wert?.trim() ?? "";
}
/** Nur gesetzte Werte aufnehmen – ein leerer Eintrag und ein fehlender sind dasselbe. */
function setzen(ziel: Record<string, string>, schluessel: string, wert: string): void {
if (wert !== "") ziel[schluessel] = wert;
}
export type LaufKontext = {
readonly laufId: string;
/** Wer den Lauf gerade abarbeitet – speist `{{techniker}}`. */
readonly technikerName?: string | null;
/** Fuer `{{datum}}`; hereingereicht, damit Tests nicht an der Uhr haengen. */
readonly jetzt?: Date;
};
const DATUM = new Intl.DateTimeFormat("de-DE", { day: "2-digit", month: "2-digit", year: "numeric" });
/**
* Traegt alle Werte eines Laufs zusammen.
*
* Die Reihenfolge ist Absicht: erst Geraet und Lauf, dann Mitarbeiter, dann
* Abteilung, zuletzt die Vorlagenvariablen. Wer weiter hinten steht, gewinnt —
* eine Variable, die der Redakteur `hostname` nennt, ueberschreibt also den
* Hostnamen des Geraets. Das ist richtig so: sie ist die ausdrueckliche Angabe
* dieses Laufs, der Geraetewert nur die Voreinstellung.
*
* `{{ou}}` ist der einzige Wert mit zwei Quellen: der Eltern-Container des `dn`
* gewinnt, weil er sagt, wo das Konto **wirklich** liegt; die Ziel-OU der
* Abteilung ist die Absicht fuer ein Konto, das es noch nicht gibt.
*/
export async function platzhalterWerteDesLaufs(kontext: LaufKontext): Promise<PlatzhalterWerte> {
const lauf = await prisma.lauf.findUnique({
where: { id: kontext.laufId },
select: {
ticket: true,
variablen: true,
mitarbeiterName: true,
kunde: { select: { name: true, kuerzel: true } },
geraet: {
select: { hostname: true, bezeichnung: true, seriennummer: true, inventarnummer: true },
},
mitarbeiter: {
select: {
anzeigename: true,
vorname: true,
nachname: true,
upn: true,
sam: true,
mail: true,
abteilung: true,
titel: true,
dn: true,
quelleId: true,
vorgesetzterExterneId: true,
},
},
abteilung: { select: { name: true, zielOu: true, standardGruppen: true } },
},
});
if (!lauf) return {};
const werte: Record<string, string> = {};
const jetzt = kontext.jetzt ?? new Date();
// --- Geraet und Lauf ---
setzen(werte, "hostname", alsText(lauf.geraet?.hostname ?? lauf.geraet?.bezeichnung));
setzen(werte, "seriennummer", alsText(lauf.geraet?.seriennummer));
setzen(werte, "inventarnummer", alsText(lauf.geraet?.inventarnummer));
setzen(werte, "ticket", alsText(lauf.ticket));
setzen(werte, "datum", DATUM.format(jetzt));
setzen(werte, "techniker", alsText(kontext.technikerName));
/*
* Kunde und Kuerzel gibt es nur im Modus Dienstleistung. Im Modus Intern ist
* die eigene Organisation fuer den Benutzer unsichtbar (#008) — ihren Namen in
* einen Befehl zu schreiben waere eine Auskunft ueber eine Ebene, die es dort
* gar nicht gibt. Die Platzhalter bleiben deshalb leer, nicht unbekannt.
*/
if ((await betriebsmodus()) === Betriebsmodus.DIENSTLEISTUNG) {
setzen(werte, "kunde", alsText(lauf.kunde.name));
setzen(werte, "kundenkuerzel", alsText(lauf.kunde.kuerzel));
}
// --- Mitarbeiter ---
const person = lauf.mitarbeiter;
if (person) {
setzen(werte, "mitarbeiter", alsText(person.anzeigename));
setzen(werte, "benutzer", alsText(person.anzeigename));
setzen(werte, "vorname", alsText(person.vorname));
setzen(werte, "nachname", alsText(person.nachname));
setzen(werte, "upn", alsText(person.upn));
setzen(werte, "sam", alsText(person.sam));
setzen(werte, "mail", alsText(person.mail));
setzen(werte, "abteilung", alsText(person.abteilung));
setzen(werte, "titel", alsText(person.titel));
setzen(werte, "dn", alsText(person.dn));
if (person.dn) setzen(werte, "ou", ouAusDn(person.dn));
const vorgesetzter = await vorgesetztenLesen(person);
if (vorgesetzter) {
setzen(werte, "vorgesetzter", alsText(vorgesetzter.anzeigename));
setzen(werte, "vorgesetzter_sam", alsText(vorgesetzter.sam));
}
} else {
// Beim Onboarding steht die Person noch nicht im Verzeichnis; dann traegt
// der Lauf ihren Namen als Freitext.
setzen(werte, "mitarbeiter", alsText(lauf.mitarbeiterName));
setzen(werte, "benutzer", alsText(lauf.mitarbeiterName));
}
// --- Abteilung ---
if (lauf.abteilung) {
setzen(werte, "abteilung", alsText(lauf.abteilung.name));
setzen(werte, "gruppen", lauf.abteilung.standardGruppen.join(", "));
// Nur, wenn der Mitarbeiter keine bessere Auskunft hatte.
if (werte["ou"] === undefined) setzen(werte, "ou", alsText(lauf.abteilung.zielOu));
}
/*
* --- Vorlagenvariablen ---
*
* Sie kommen zuletzt und gewinnen — aber nur, wo sie duerfen. Eine Variable
* `techniker` oder `datum` liesse sonst einen Befehl etwas unterschreiben, das
* nicht stimmt, und eine Variable `kunde` haette im Modus INTERN die
* Kundenebene zurueck in den Befehl geholt, die es dort nicht gibt.
* Gesperrt sind genau fuenf Namen (siehe `GESPERRTE_VARIABLENNAMEN`); alles
* andere darf eine Vorlage belegen — beim Onboarding gibt es die Person im
* Verzeichnis noch nicht, und `upn`, `sam` und `mail` kommen dann aus dem Lauf.
*/
for (const [schluessel, wert] of Object.entries(variablenLesen(lauf.variablen))) {
if (!istAlsVariableErlaubt(schluessel)) continue;
setzen(werte, schluessel, wert);
}
return werte;
}
/**
* Der Vorgesetzte, ueber `vorgesetzterExterneId` **derselben** Verzeichnisquelle.
*
* Die Kennung ist nur innerhalb einer Quelle eindeutig; ohne diese
* Einschraenkung zoege ein zweites Verzeichnis fremde Treffer herbei.
*/
async function vorgesetztenLesen(person: {
readonly quelleId: string | null;
readonly vorgesetzterExterneId: string | null;
}): Promise<{ anzeigename: string; sam: string | null } | null> {
if (!person.vorgesetzterExterneId || !person.quelleId) return null;
return prisma.mitarbeiter.findFirst({
where: { quelleId: person.quelleId, externeId: person.vorgesetzterExterneId },
select: { anzeigename: true, sam: true },
});
}
/** `Lauf.variablen` ist ein Json-Feld und kann alles enthalten. */
function variablenLesen(roh: unknown): Record<string, string> {
if (typeof roh !== "object" || roh === null || Array.isArray(roh)) return {};
const werte: Record<string, string> = {};
for (const [schluessel, wert] of Object.entries(roh as Record<string, unknown>)) {
// Zahlen und Wahrheitswerte kommen aus den Variablentypen ZAHL und HAKEN;
// alles andere waere ein verunglueckter Eintrag und bleibt draussen.
if (typeof wert === "string") werte[schluessel] = wert;
else if (typeof wert === "number" || typeof wert === "boolean") werte[schluessel] = String(wert);
}
return werte;
}
+423
View File
@@ -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([]);
});
});
+553
View File
@@ -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");
});
});
+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("!");
}
});
});
+51
View File
@@ -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;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("&lt;script&gt;");
});
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;amp;“.
const { text } = platzhalterErsetzen("{{abteilung}}", {
abteilung: "Forschung & Entwicklung",
});
const html = renderToStaticMarkup(<p>{text}</p>);
expect(html).toBe("<p>Forschung &amp; 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("&lt;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>");
});
});
+175
View File
@@ -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 `&amp;` 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;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
View File
@@ -1,150 +1,385 @@
import { describe, expect, it } from "vitest"; import { beforeEach, describe, expect, it } from "vitest";
import { import { Betriebsmodus, Herkunft } from "@/generated/prisma/enums";
EINGEBAUTE_PLATZHALTER, import { ouAusDn, platzhalterWerteDesLaufs } from "@/server/services/platzhalter";
QUELLEN_TEXT, import { platzhalterErsetzen } from "@/lib/platzhalter-werte";
istEingebaut, import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
unbekanntePlatzhalter,
unbenutzteVariablen,
} from "@/lib/platzhalter";
import { platzhalterDerStruktur, VariableSchema, type Struktur } from "@/lib/vorlagen-format";
/** /**
* Die eingebauten Platzhalter (#010). * Die Werte hinter den Platzhaltern (#012).
* *
* Die Liste ist Daten, und Daten koennen still falsch sein: ein Schluessel mit * Aus diesen Werten entsteht der PowerShell-Befehl, den ein Techniker auf einem
* Bindestrich wuerde im Lauf nie ersetzt, weil `platzhalterFinden` ihn nicht als * Domaenencontroller ausfuehrt. Deshalb steht hier jede Regel des Auftrags
* Platzhalter erkennt. Deshalb wird die Liste hier gegen genau dieselbe Regel * einzeln, mit den Daten, die im Haus wirklich vorkommen — einschliesslich
* geprueft, die auch fuer Variablen gilt. * „Jörg Müller-Lüdenscheid“.
*/ */
describe("Liste der eingebauten Platzhalter", () => { const DN = "CN=Maria Huber,OU=Buchhaltung,OU=Benutzer,DC=firma,DC=local";
it("haelt sich an die Schluesselregel der Variablen", () => {
const schluesselRegel = VariableSchema.shape.schluessel; async function aufbau(modus: Betriebsmodus = Betriebsmodus.INTERN) {
for (const platzhalter of EINGEBAUTE_PLATZHALTER) { const eigene = await testPrisma.kunde.create({
const ergebnis = schluesselRegel.safeParse(platzhalter.schluessel); data: { name: "Muster GmbH", kuerzel: "MUS", istEigeneOrganisation: true },
expect(ergebnis.success, `${platzhalter.schluessel}: ${ergebnis.error?.message}`).toBe(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 quelle = await testPrisma.verzeichnisquelle.create({
const schluessel = EINGEBAUTE_PLATZHALTER.map((p) => p.schluessel); data: {
expect(new Set(schluessel).size).toBe(schluessel.length); 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", () => { const chefin = await testPrisma.mitarbeiter.create({
for (const platzhalter of EINGEBAUTE_PLATZHALTER) { data: {
expect(platzhalter.erklaerung.trim().length, platzhalter.schluessel).toBeGreaterThan(0); kundeId: eigene.id,
expect(QUELLEN_TEXT[platzhalter.quelle], platzhalter.schluessel).toBeTruthy(); 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", () => { const person = await testPrisma.mitarbeiter.create({
// Namentlich, damit das Verschwinden eines einzelnen auffaellt – eine data: {
// Vorlage, die ihn benutzt, bekaeme sonst plötzlich eine Warnung. kundeId: eigene.id,
for (const schluessel of [ quelleId: quelle.id,
"hostname", externeId: "guid-maria",
"seriennummer", vorname: "Maria",
"inventarnummer", nachname: "Huber",
"ticket", anzeigename: "Maria Huber",
"datum", mail: "maria.huber@firma.de",
"techniker", upn: "maria.huber@firma.de",
"kunde", sam: "mhuber",
"kundenkuerzel", abteilung: "Buchhaltung",
"mitarbeiter", titel: "Sachbearbeiterin",
"benutzer", dn: DN,
"vorname", vorgesetzterExterneId: "guid-chefin",
"nachname", herkunft: Herkunft.ABGLEICH,
"upn", },
"sam",
"mail",
"abteilung",
"titel",
"vorgesetzter",
"vorgesetzter_sam",
"dn",
"ou",
"gruppen",
"generiertes_passwort",
]) {
expect(istEingebaut(schluessel), schluessel).toBe(true);
}
}); });
it("kennzeichnet die Platzhalter, die nur im Modus Dienstleistung gefuellt sind", () => { const geraet = await testPrisma.geraet.create({
const nurDienstleistung = EINGEBAUTE_PLATZHALTER.filter((p) => p.nurDienstleistung).map( data: {
(p) => p.schluessel, 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", () => { describe.runIf(datenbankVerfuegbar)("Werte eines Laufs", () => {
it("meldet, was weder eingebaut noch als Variable definiert ist", () => { beforeEach(async () => {
expect(unbekanntePlatzhalter(["hostname", "os_version", "tipfehler"], ["os_version"])).toEqual([ await datenbankLeeren();
"tipfehler",
]);
}); });
it("meldet nichts, wenn alles bekannt ist", () => { it("liefert Gerät, Lauf, Mitarbeiter und Abteilung", async () => {
expect(unbekanntePlatzhalter(["hostname", "generiertes_passwort"], [])).toEqual([]); 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", () => { it("nimmt für die OU den Eltern-Container des dn, nicht die Ziel-OU", async () => {
// Der praktische Fall: „{{hostnmae}}" im Befehl. Ohne Warnung merkt es erst // Der dn sagt, wo das Konto **wirklich** liegt; die Ziel-OU der Abteilung
// der Techniker, wenn der Befehl den Platzhalter im Klartext enthaelt. // ist die Absicht fuer ein Konto, das es noch nicht gibt.
const struktur: Struktur = { const { eigene, abteilung, person, versionId } = await aufbau();
variablen: [ const laufId = await laufAnlegen({
{ vorlagenVersionId: versionId,
schluessel: "os_version", titel: "Umzug",
beschriftung: "Betriebssystem", kundeId: eigene.id,
typ: "TEXT", mitarbeiterId: person.id,
optionen: [], abteilungId: abteilung.id,
pflicht: false, });
}, const werte = await platzhalterWerteDesLaufs({ laufId });
], expect(werte["ou"]).toBe("OU=Buchhaltung,OU=Benutzer,DC=firma,DC=local");
abschnitte: [ });
{
titel: "Installation", it("nimmt die Ziel-OU der Abteilung, wenn die Person keinen dn hat", async () => {
schritte: [ // Der Onboarding-Fall: das Konto gibt es noch nicht.
{ const { eigene, abteilung, versionId } = await aufbau();
titel: "Rechner umbenennen", const laufId = await laufAnlegen({
typ: "BEFEHL", vorlagenVersionId: versionId,
pflicht: true, titel: "Onboarding",
konfig: { kundeId: eigene.id,
shell: "powershell", mitarbeiterName: "Jörg Müller-Lüdenscheid",
befehl: 'Rename-Computer -NewName "{{hostnmae}}" # soll {{hostname}} sein', abteilungId: abteilung.id,
}, });
}, const werte = await platzhalterWerteDesLaufs({ laufId });
{ expect(werte["ou"]).toBe("OU=Buchhaltung,OU=Benutzer,DC=firma,DC=de");
titel: "{{os_version}} installieren", expect(werte["mitarbeiter"]).toBe("Jörg Müller-Lüdenscheid");
hilfetext: "Danach {{ datum }} im Ticket vermerken.", expect(werte["benutzer"]).toBe("Jörg Müller-Lüdenscheid");
typ: "HAKEN", });
pflicht: true,
konfig: {}, 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({
const verwendet = platzhalterDerStruktur(struktur); vorlagenVersionId: versionId,
expect(verwendet).toContain("hostnmae"); titel: "Patchday",
expect( kundeId: eigene.id,
unbekanntePlatzhalter( });
verwendet, const werte = await platzhalterWerteDesLaufs({ laufId });
struktur.variablen.map((v) => v.schluessel), expect(werte["kunde"]).toBeUndefined();
), expect(werte["kundenkuerzel"]).toBeUndefined();
).toEqual(["hostnmae"]);
}); // Und in der Ersetzung wird daraus leer, nicht „unbekannt“.
}); const ergebnis = platzhalterErsetzen("[{{kunde}}]", werte);
expect(ergebnis.text).toBe("[]");
describe("Unbenutzte Variablen", () => { expect(ergebnis.unbekannt).toEqual([]);
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. it("füllt im Modus Dienstleistung Kunde und Kürzel", async () => {
expect(unbenutzteVariablen(["hostname"], ["os_version", "join_typ"])).toEqual(["os_version", "join_typ"]); const { eigene, versionId } = await aufbau(Betriebsmodus.DIENSTLEISTUNG);
}); const laufId = await laufAnlegen({
vorlagenVersionId: versionId,
it("meldet nichts, wenn jede Variable benutzt wird", () => { titel: "Patchday",
expect(unbenutzteVariablen(["os_version"], ["os_version"])).toEqual([]); 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");
}); });
}); });
+394
View File
@@ -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 });
}
});
});
});