feat(platzhalter): Aufloesung, Namensregeln und Shell-Maskierung #26
@@ -84,6 +84,16 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
|
||||
Anführungszeichen und Zeilenumbrüchen im Wert zurecht (#011)
|
||||
- Die Seitenleiste führt Personen und Geräte unter einem Eintrag; ein Umschalter wechselt zwischen
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
- 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)
|
||||
- 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)
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
Die Datenbank ist auf beiden Wegen von außen nicht erreichbar:
|
||||
|
||||
+55
-2
@@ -14,11 +14,12 @@ als Unterpunkte mit Aufgabe und Dauer.
|
||||
| #009 | M4 – Vorlagen-Versionierung im Service | 1 h 20 min |
|
||||
| #010 | M5 – Vorlagen-Editor | 2 h 10 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 |
|
||||
|---|---|---|
|
||||
| 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
|
||||
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.
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# #012 M7 – Platzhalter, Namensregeln, Escaping
|
||||
|
||||
**Status:** offen
|
||||
**Status:** erledigt
|
||||
**Meilenstein:** v0.1.0
|
||||
**Labels:** phase-1, backend, security
|
||||
**Geschätzt:** 8 h **Tatsächlich:** –
|
||||
**Geschätzt:** 8 h **Tatsächlich:** 2 h 30 min
|
||||
|
||||
## Ziel
|
||||
|
||||
@@ -11,26 +11,91 @@ Reine, framework-freie Service-Schicht mit dichter Testabdeckung. Hier entstehen
|
||||
|
||||
## Aufgaben
|
||||
|
||||
- [ ] 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
|
||||
- [ ] 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
|
||||
- [ ] `{{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
|
||||
- [ ] `{{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`
|
||||
- [ ] 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
|
||||
- [ ] 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] Platzhalter-Auflösung `{{key}}` in Schritt-Titel, Hilfetext und Befehlstext
|
||||
- [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
|
||||
- [x] Eingebaut aus dem Mitarbeiter: `{{mitarbeiter}}` (Alias `{{benutzer}}`), `{{vorname}}`, `{{nachname}}`, `{{upn}}`, `{{sam}}`, `{{mail}}`, `{{abteilung}}`, `{{titel}}`, `{{vorgesetzter}}`, `{{vorgesetzter_sam}}`, `{{dn}}`, `{{ou}}`
|
||||
- [x] Aus der gewählten Abteilung: `{{ou}}` falls nicht vom Mitarbeiter, `{{gruppen}}` kommagetrennt
|
||||
- [x] `{{ou}}` eines synchronisierten Mitarbeiters ist der Eltern-Container seines `dn` (alles nach dem ersten Komma)
|
||||
- [x] Dazu die in der Vorlage definierten Variablen; unbekannte Platzhalter bleiben sichtbar stehen
|
||||
- [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]`
|
||||
- [x] Namensregeln (`Einstellung.namensregeln` global, im Modus `DIENSTLEISTUNG` zusätzlich `Kunde.namensregeln` mit Fallback): `upn`, `sam` (höchstens 20 Zeichen), `mail`, `hostname`
|
||||
- [x] Transliteration (ä→ae, ö→oe, ü→ue, ß→ss, Akzente entfernt), Kleinschreibung, Leerzeichen entfernt, `{nnn}`-Zähler, `{v}` für den ersten Buchstaben des Vornamens, `{kundenkuerzel}`
|
||||
- [x] Shell-Escaping je Shell: PowerShell (doppelte Anführungszeichen verdoppeln, Backtick maskieren), Bash, Cmd
|
||||
- [x] HTML-Escaping ersetzter Werte; Markdown im Hilfetext sicher gerendert, kein rohes HTML
|
||||
- [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
|
||||
|
||||
- [ ] Test: „Jörg Müller-Lüdenscheid“ ergibt `joerg.mueller-luedenscheid@firma.de` und `jmuellerluedensch` (auf 20 Zeichen gekürzt)
|
||||
- [ ] 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
|
||||
- [ ] 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`
|
||||
- [ ] Test: Skript-Kopie liefert die Befehle in Abschnittsreihenfolge mit Kommentarzeilen
|
||||
- [ ] Test: im Modus `INTERN` sind `{{kunde}}` und `{{kundenkuerzel}}` leer
|
||||
- [ ] Test: ein Wert mit `<script>` landet HTML-escaped in der Anzeige
|
||||
- [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)
|
||||
- [x] 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: ein Wert mit `"` und `` ` `` wird für PowerShell korrekt maskiert und bricht den Befehl nicht
|
||||
- [x] Test: unbekannte Platzhalter bleiben als `{{key}}` stehen
|
||||
- [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`
|
||||
- [x] Test: Skript-Kopie liefert die Befehle in Abschnittsreihenfolge mit Kommentarzeilen
|
||||
- [x] Test: im Modus `INTERN` sind `{{kunde}}` und `{{kundenkuerzel}}` leer
|
||||
- [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 & 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.
|
||||
|
||||
@@ -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 |
|
||||
| #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 |
|
||||
| #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 | – |
|
||||
| #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 | – |
|
||||
@@ -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 | – |
|
||||
| #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 | – |
|
||||
| | **Summe** | | | **136 h** | **11 h 15 min** |
|
||||
| | **Summe** | | | **136 h** | **13 h 45 min** |
|
||||
|
||||
@@ -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.",
|
||||
);
|
||||
}
|
||||
@@ -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("");
|
||||
}
|
||||
@@ -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; 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" });
|
||||
}
|
||||
@@ -89,6 +89,37 @@ export function istEingebaut(schluessel: string): boolean {
|
||||
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.
|
||||
*
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { istAlsVariableErlaubt } from "@/lib/platzhalter";
|
||||
import { z } from "@/lib/validierung";
|
||||
|
||||
/**
|
||||
@@ -119,7 +120,19 @@ export const VariableSchema = z.object({
|
||||
.regex(
|
||||
/^[a-z][a-z0-9_]{0,39}$/,
|
||||
"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),
|
||||
typ: z.enum(VARIABLEN_TYPEN).default("TEXT"),
|
||||
optionen: z.array(z.string().trim().min(1).max(GRENZEN.text)).max(GRENZEN.optionen).default([]),
|
||||
|
||||
@@ -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 };
|
||||
@@ -1,7 +1,7 @@
|
||||
import { z } from "@/lib/validierung";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { Betriebsmodus } from "@/generated/prisma/enums";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { NamensregelnSchema, type Namensregeln } from "@/lib/namensregeln";
|
||||
import { betriebsmodus, eigeneOrganisation } from "@/server/mandant";
|
||||
import { kuerzelPruefen } from "@/server/services/kunden";
|
||||
|
||||
@@ -21,18 +21,13 @@ export class EinstellungsFehler extends Error {
|
||||
}
|
||||
|
||||
/**
|
||||
* Vorlagen fuer abgeleitete Werte. Die Platzhalter darin (`{vorname}`,
|
||||
* `{nachname}`, `{v}`, `{nnn}`, `{kundenkuerzel}`) loest #012 auf; hier werden
|
||||
* sie nur gespeichert und bearbeitet.
|
||||
* Schema und Typ der Namensregeln liegen seit #012 in `src/lib/namensregeln.ts`,
|
||||
* zusammen mit der Aufloesung der Platzhalter darin (`{vorname}`, `{nachname}`,
|
||||
* `{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({
|
||||
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 { NamensregelnSchema, type Namensregeln };
|
||||
|
||||
export type Einstellungen = {
|
||||
readonly modus: Betriebsmodus;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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("!");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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; 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("<script>");
|
||||
});
|
||||
|
||||
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;“.
|
||||
const { text } = platzhalterErsetzen("{{abteilung}}", {
|
||||
abteilung: "Forschung & Entwicklung",
|
||||
});
|
||||
const html = renderToStaticMarkup(<p>{text}</p>);
|
||||
expect(html).toBe("<p>Forschung & 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("<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>");
|
||||
});
|
||||
});
|
||||
@@ -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 `&` 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; 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
@@ -1,150 +1,385 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
EINGEBAUTE_PLATZHALTER,
|
||||
QUELLEN_TEXT,
|
||||
istEingebaut,
|
||||
unbekanntePlatzhalter,
|
||||
unbenutzteVariablen,
|
||||
} from "@/lib/platzhalter";
|
||||
import { platzhalterDerStruktur, VariableSchema, type Struktur } from "@/lib/vorlagen-format";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { Betriebsmodus, Herkunft } from "@/generated/prisma/enums";
|
||||
import { ouAusDn, platzhalterWerteDesLaufs } from "@/server/services/platzhalter";
|
||||
import { platzhalterErsetzen } from "@/lib/platzhalter-werte";
|
||||
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
|
||||
|
||||
/**
|
||||
* Die eingebauten Platzhalter (#010).
|
||||
* Die Werte hinter den Platzhaltern (#012).
|
||||
*
|
||||
* Die Liste ist Daten, und Daten koennen still falsch sein: ein Schluessel mit
|
||||
* Bindestrich wuerde im Lauf nie ersetzt, weil `platzhalterFinden` ihn nicht als
|
||||
* Platzhalter erkennt. Deshalb wird die Liste hier gegen genau dieselbe Regel
|
||||
* geprueft, die auch fuer Variablen gilt.
|
||||
* Aus diesen Werten entsteht der PowerShell-Befehl, den ein Techniker auf einem
|
||||
* Domaenencontroller ausfuehrt. Deshalb steht hier jede Regel des Auftrags
|
||||
* einzeln, mit den Daten, die im Haus wirklich vorkommen — einschliesslich
|
||||
* „Jörg Müller-Lüdenscheid“.
|
||||
*/
|
||||
|
||||
describe("Liste der eingebauten Platzhalter", () => {
|
||||
it("haelt sich an die Schluesselregel der Variablen", () => {
|
||||
const schluesselRegel = VariableSchema.shape.schluessel;
|
||||
for (const platzhalter of EINGEBAUTE_PLATZHALTER) {
|
||||
const ergebnis = schluesselRegel.safeParse(platzhalter.schluessel);
|
||||
expect(ergebnis.success, `${platzhalter.schluessel}: ${ergebnis.error?.message}`).toBe(true);
|
||||
}
|
||||
const DN = "CN=Maria Huber,OU=Buchhaltung,OU=Benutzer,DC=firma,DC=local";
|
||||
|
||||
async function aufbau(modus: Betriebsmodus = Betriebsmodus.INTERN) {
|
||||
const eigene = await testPrisma.kunde.create({
|
||||
data: { name: "Muster GmbH", kuerzel: "MUS", istEigeneOrganisation: true },
|
||||
});
|
||||
await testPrisma.einstellung.create({ data: { id: "global", modus } });
|
||||
|
||||
const abteilung = await testPrisma.abteilung.create({
|
||||
data: {
|
||||
kundeId: eigene.id,
|
||||
name: "Buchhaltung",
|
||||
zielOu: "OU=Buchhaltung,OU=Benutzer,DC=firma,DC=de",
|
||||
standardGruppen: ["G-Buchhaltung", "G-Alle"],
|
||||
},
|
||||
});
|
||||
|
||||
it("nennt jeden Schluessel nur einmal", () => {
|
||||
const schluessel = EINGEBAUTE_PLATZHALTER.map((p) => p.schluessel);
|
||||
expect(new Set(schluessel).size).toBe(schluessel.length);
|
||||
const quelle = await testPrisma.verzeichnisquelle.create({
|
||||
data: {
|
||||
kundeId: eigene.id,
|
||||
name: "AD",
|
||||
url: "ldaps://dc01.firma.local:636",
|
||||
basisDn: "DC=firma,DC=local",
|
||||
filter: "(objectClass=user)",
|
||||
attributZuordnung: {},
|
||||
bindDn: "CN=svc,DC=firma,DC=local",
|
||||
bindPasswortVerschluesselt: "v1.egal",
|
||||
},
|
||||
});
|
||||
|
||||
it("erklaert jeden Platzhalter und ordnet ihn einer benannten Quelle zu", () => {
|
||||
for (const platzhalter of EINGEBAUTE_PLATZHALTER) {
|
||||
expect(platzhalter.erklaerung.trim().length, platzhalter.schluessel).toBeGreaterThan(0);
|
||||
expect(QUELLEN_TEXT[platzhalter.quelle], platzhalter.schluessel).toBeTruthy();
|
||||
}
|
||||
const chefin = await testPrisma.mitarbeiter.create({
|
||||
data: {
|
||||
kundeId: eigene.id,
|
||||
quelleId: quelle.id,
|
||||
externeId: "guid-chefin",
|
||||
vorname: "Sonja",
|
||||
nachname: "Brand",
|
||||
anzeigename: "Sonja Brand",
|
||||
sam: "sbrand",
|
||||
herkunft: Herkunft.ABGLEICH,
|
||||
},
|
||||
});
|
||||
|
||||
it("enthaelt die im Auftrag genannten Platzhalter", () => {
|
||||
// Namentlich, damit das Verschwinden eines einzelnen auffaellt – eine
|
||||
// Vorlage, die ihn benutzt, bekaeme sonst plötzlich eine Warnung.
|
||||
for (const schluessel of [
|
||||
"hostname",
|
||||
"seriennummer",
|
||||
"inventarnummer",
|
||||
"ticket",
|
||||
"datum",
|
||||
"techniker",
|
||||
"kunde",
|
||||
"kundenkuerzel",
|
||||
"mitarbeiter",
|
||||
"benutzer",
|
||||
"vorname",
|
||||
"nachname",
|
||||
"upn",
|
||||
"sam",
|
||||
"mail",
|
||||
"abteilung",
|
||||
"titel",
|
||||
"vorgesetzter",
|
||||
"vorgesetzter_sam",
|
||||
"dn",
|
||||
"ou",
|
||||
"gruppen",
|
||||
"generiertes_passwort",
|
||||
]) {
|
||||
expect(istEingebaut(schluessel), schluessel).toBe(true);
|
||||
}
|
||||
const person = await testPrisma.mitarbeiter.create({
|
||||
data: {
|
||||
kundeId: eigene.id,
|
||||
quelleId: quelle.id,
|
||||
externeId: "guid-maria",
|
||||
vorname: "Maria",
|
||||
nachname: "Huber",
|
||||
anzeigename: "Maria Huber",
|
||||
mail: "maria.huber@firma.de",
|
||||
upn: "maria.huber@firma.de",
|
||||
sam: "mhuber",
|
||||
abteilung: "Buchhaltung",
|
||||
titel: "Sachbearbeiterin",
|
||||
dn: DN,
|
||||
vorgesetzterExterneId: "guid-chefin",
|
||||
herkunft: Herkunft.ABGLEICH,
|
||||
},
|
||||
});
|
||||
|
||||
it("kennzeichnet die Platzhalter, die nur im Modus Dienstleistung gefuellt sind", () => {
|
||||
const nurDienstleistung = EINGEBAUTE_PLATZHALTER.filter((p) => p.nurDienstleistung).map(
|
||||
(p) => p.schluessel,
|
||||
const geraet = await testPrisma.geraet.create({
|
||||
data: {
|
||||
kundeId: eigene.id,
|
||||
tagCode: "LZ-7K3F9Q",
|
||||
hostname: "PC-001",
|
||||
seriennummer: "SN-4711",
|
||||
inventarnummer: "INV-99",
|
||||
registriertAm: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
const vorlage = await testPrisma.vorlage.create({ data: { name: "Onboarding" } });
|
||||
const version = await testPrisma.vorlagenVersion.create({
|
||||
data: { vorlageId: vorlage.id, version: 1, status: "ENTWURF" },
|
||||
});
|
||||
await testPrisma.vorlagenVersion.update({
|
||||
where: { id: version.id },
|
||||
data: { status: "VEROEFFENTLICHT", veroeffentlichtAm: new Date() },
|
||||
});
|
||||
|
||||
return { eigene, abteilung, person, chefin, geraet, versionId: version.id };
|
||||
}
|
||||
|
||||
async function laufAnlegen(daten: Parameters<typeof testPrisma.lauf.create>[0]["data"]): Promise<string> {
|
||||
const lauf = await testPrisma.lauf.create({ data: daten });
|
||||
return lauf.id;
|
||||
}
|
||||
|
||||
describe("OU aus dem DN", () => {
|
||||
it("nimmt alles nach dem ersten Komma", () => {
|
||||
// Akzeptanzkriterium. Das ist der Ort, an dem das Konto liegt – genau den
|
||||
// braucht ein New-ADUser -Path.
|
||||
expect(ouAusDn(DN)).toBe("OU=Buchhaltung,OU=Benutzer,DC=firma,DC=local");
|
||||
});
|
||||
|
||||
it("übergeht ein maskiertes Komma im Namen", () => {
|
||||
// Im DN ist ein Komma innerhalb eines Namens mit Backslash maskiert. Ohne
|
||||
// diese Regel bliebe von „Huber\, Maria“ nur „ Maria,OU=…“ übrig.
|
||||
expect(ouAusDn("CN=Huber\\, Maria,OU=Buchhaltung,DC=firma,DC=local")).toBe(
|
||||
"OU=Buchhaltung,DC=firma,DC=local",
|
||||
);
|
||||
expect(nurDienstleistung.sort()).toEqual(["kunde", "kundenkuerzel"]);
|
||||
});
|
||||
|
||||
it("liefert nichts bei einem DN ohne Komma", () => {
|
||||
expect(ouAusDn("CN=Allein")).toBe("");
|
||||
expect(ouAusDn("")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Unbekannte Platzhalter", () => {
|
||||
it("meldet, was weder eingebaut noch als Variable definiert ist", () => {
|
||||
expect(unbekanntePlatzhalter(["hostname", "os_version", "tipfehler"], ["os_version"])).toEqual([
|
||||
"tipfehler",
|
||||
]);
|
||||
describe.runIf(datenbankVerfuegbar)("Werte eines Laufs", () => {
|
||||
beforeEach(async () => {
|
||||
await datenbankLeeren();
|
||||
});
|
||||
|
||||
it("meldet nichts, wenn alles bekannt ist", () => {
|
||||
expect(unbekanntePlatzhalter(["hostname", "generiertes_passwort"], [])).toEqual([]);
|
||||
it("liefert Gerät, Lauf, Mitarbeiter und Abteilung", async () => {
|
||||
const { eigene, abteilung, person, geraet, versionId } = await aufbau();
|
||||
const laufId = await laufAnlegen({
|
||||
vorlagenVersionId: versionId,
|
||||
titel: "Onboarding Maria Huber",
|
||||
kundeId: eigene.id,
|
||||
mitarbeiterId: person.id,
|
||||
geraetId: geraet.id,
|
||||
abteilungId: abteilung.id,
|
||||
ticket: "INC-4711",
|
||||
});
|
||||
|
||||
const werte = await platzhalterWerteDesLaufs({
|
||||
laufId,
|
||||
technikerName: "Ali Demir",
|
||||
jetzt: new Date("2026-09-25T10:00:00Z"),
|
||||
});
|
||||
|
||||
expect(werte).toMatchObject({
|
||||
hostname: "PC-001",
|
||||
seriennummer: "SN-4711",
|
||||
inventarnummer: "INV-99",
|
||||
ticket: "INC-4711",
|
||||
datum: "25.09.2026",
|
||||
techniker: "Ali Demir",
|
||||
mitarbeiter: "Maria Huber",
|
||||
benutzer: "Maria Huber",
|
||||
vorname: "Maria",
|
||||
nachname: "Huber",
|
||||
upn: "maria.huber@firma.de",
|
||||
sam: "mhuber",
|
||||
mail: "maria.huber@firma.de",
|
||||
abteilung: "Buchhaltung",
|
||||
titel: "Sachbearbeiterin",
|
||||
dn: DN,
|
||||
gruppen: "G-Buchhaltung, G-Alle",
|
||||
vorgesetzter: "Sonja Brand",
|
||||
vorgesetzter_sam: "sbrand",
|
||||
});
|
||||
});
|
||||
|
||||
it("findet einen Tippfehler in einem echten Aufbau", () => {
|
||||
// Der praktische Fall: „{{hostnmae}}" im Befehl. Ohne Warnung merkt es erst
|
||||
// der Techniker, wenn der Befehl den Platzhalter im Klartext enthaelt.
|
||||
const struktur: Struktur = {
|
||||
variablen: [
|
||||
{
|
||||
schluessel: "os_version",
|
||||
beschriftung: "Betriebssystem",
|
||||
typ: "TEXT",
|
||||
optionen: [],
|
||||
pflicht: false,
|
||||
},
|
||||
],
|
||||
abschnitte: [
|
||||
{
|
||||
titel: "Installation",
|
||||
schritte: [
|
||||
{
|
||||
titel: "Rechner umbenennen",
|
||||
typ: "BEFEHL",
|
||||
pflicht: true,
|
||||
konfig: {
|
||||
shell: "powershell",
|
||||
befehl: 'Rename-Computer -NewName "{{hostnmae}}" # soll {{hostname}} sein',
|
||||
},
|
||||
},
|
||||
{
|
||||
titel: "{{os_version}} installieren",
|
||||
hilfetext: "Danach {{ datum }} im Ticket vermerken.",
|
||||
typ: "HAKEN",
|
||||
pflicht: true,
|
||||
konfig: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const verwendet = platzhalterDerStruktur(struktur);
|
||||
expect(verwendet).toContain("hostnmae");
|
||||
expect(
|
||||
unbekanntePlatzhalter(
|
||||
verwendet,
|
||||
struktur.variablen.map((v) => v.schluessel),
|
||||
),
|
||||
).toEqual(["hostnmae"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Unbenutzte Variablen", () => {
|
||||
it("meldet eine Variable, die nirgends vorkommt", () => {
|
||||
// Eine definierte Variable, die kein Schritt benutzt, fragt den Techniker
|
||||
// beim Start nach etwas, das nie irgendwo auftaucht.
|
||||
expect(unbenutzteVariablen(["hostname"], ["os_version", "join_typ"])).toEqual(["os_version", "join_typ"]);
|
||||
});
|
||||
|
||||
it("meldet nichts, wenn jede Variable benutzt wird", () => {
|
||||
expect(unbenutzteVariablen(["os_version"], ["os_version"])).toEqual([]);
|
||||
it("nimmt für die OU den Eltern-Container des dn, nicht die Ziel-OU", async () => {
|
||||
// Der dn sagt, wo das Konto **wirklich** liegt; die Ziel-OU der Abteilung
|
||||
// ist die Absicht fuer ein Konto, das es noch nicht gibt.
|
||||
const { eigene, abteilung, person, versionId } = await aufbau();
|
||||
const laufId = await laufAnlegen({
|
||||
vorlagenVersionId: versionId,
|
||||
titel: "Umzug",
|
||||
kundeId: eigene.id,
|
||||
mitarbeiterId: person.id,
|
||||
abteilungId: abteilung.id,
|
||||
});
|
||||
const werte = await platzhalterWerteDesLaufs({ laufId });
|
||||
expect(werte["ou"]).toBe("OU=Buchhaltung,OU=Benutzer,DC=firma,DC=local");
|
||||
});
|
||||
|
||||
it("nimmt die Ziel-OU der Abteilung, wenn die Person keinen dn hat", async () => {
|
||||
// Der Onboarding-Fall: das Konto gibt es noch nicht.
|
||||
const { eigene, abteilung, versionId } = await aufbau();
|
||||
const laufId = await laufAnlegen({
|
||||
vorlagenVersionId: versionId,
|
||||
titel: "Onboarding",
|
||||
kundeId: eigene.id,
|
||||
mitarbeiterName: "Jörg Müller-Lüdenscheid",
|
||||
abteilungId: abteilung.id,
|
||||
});
|
||||
const werte = await platzhalterWerteDesLaufs({ laufId });
|
||||
expect(werte["ou"]).toBe("OU=Buchhaltung,OU=Benutzer,DC=firma,DC=de");
|
||||
expect(werte["mitarbeiter"]).toBe("Jörg Müller-Lüdenscheid");
|
||||
expect(werte["benutzer"]).toBe("Jörg Müller-Lüdenscheid");
|
||||
});
|
||||
|
||||
it("lässt im Modus Intern Kunde und Kürzel leer", async () => {
|
||||
// Akzeptanzkriterium: im Modus Intern gibt es die Kundenebene fuer den
|
||||
// Benutzer nicht – ihren Namen in einen Befehl zu schreiben waere eine
|
||||
// Auskunft ueber etwas, das es dort nicht gibt.
|
||||
const { eigene, versionId } = await aufbau(Betriebsmodus.INTERN);
|
||||
const laufId = await laufAnlegen({
|
||||
vorlagenVersionId: versionId,
|
||||
titel: "Patchday",
|
||||
kundeId: eigene.id,
|
||||
});
|
||||
const werte = await platzhalterWerteDesLaufs({ laufId });
|
||||
expect(werte["kunde"]).toBeUndefined();
|
||||
expect(werte["kundenkuerzel"]).toBeUndefined();
|
||||
|
||||
// Und in der Ersetzung wird daraus leer, nicht „unbekannt“.
|
||||
const ergebnis = platzhalterErsetzen("[{{kunde}}]", werte);
|
||||
expect(ergebnis.text).toBe("[]");
|
||||
expect(ergebnis.unbekannt).toEqual([]);
|
||||
});
|
||||
|
||||
it("füllt im Modus Dienstleistung Kunde und Kürzel", async () => {
|
||||
const { eigene, versionId } = await aufbau(Betriebsmodus.DIENSTLEISTUNG);
|
||||
const laufId = await laufAnlegen({
|
||||
vorlagenVersionId: versionId,
|
||||
titel: "Patchday",
|
||||
kundeId: eigene.id,
|
||||
});
|
||||
const werte = await platzhalterWerteDesLaufs({ laufId });
|
||||
expect(werte["kunde"]).toBe("Muster GmbH");
|
||||
expect(werte["kundenkuerzel"]).toBe("MUS");
|
||||
});
|
||||
|
||||
it("nimmt die Bezeichnung, wenn ein Gerät keinen Hostname hat", async () => {
|
||||
const { eigene, versionId } = await aufbau();
|
||||
const schrank = await testPrisma.geraet.create({
|
||||
data: {
|
||||
kundeId: eigene.id,
|
||||
tagCode: "LZ-8M4G2R",
|
||||
bezeichnung: "Serverschrank Keller",
|
||||
registriertAm: new Date(),
|
||||
},
|
||||
});
|
||||
const laufId = await laufAnlegen({
|
||||
vorlagenVersionId: versionId,
|
||||
titel: "Wartung",
|
||||
kundeId: eigene.id,
|
||||
geraetId: schrank.id,
|
||||
});
|
||||
expect((await platzhalterWerteDesLaufs({ laufId }))["hostname"]).toBe("Serverschrank Keller");
|
||||
});
|
||||
|
||||
it("lässt eine Vorlagenvariable den Gerätewert überschreiben", async () => {
|
||||
/*
|
||||
* Die Variable ist die ausdrueckliche Angabe **dieses** Laufs, der
|
||||
* Geraetewert nur die Voreinstellung. Beim Umbenennen eines Rechners steht
|
||||
* im Befehl der neue Name, nicht der alte aus der Datenbank.
|
||||
*/
|
||||
const { eigene, geraet, versionId } = await aufbau();
|
||||
const laufId = await laufAnlegen({
|
||||
vorlagenVersionId: versionId,
|
||||
titel: "Umbenennen",
|
||||
kundeId: eigene.id,
|
||||
geraetId: geraet.id,
|
||||
variablen: { hostname: "PC-NEU-007", os_version: "Windows 11 Pro" },
|
||||
});
|
||||
const werte = await platzhalterWerteDesLaufs({ laufId });
|
||||
expect(werte["hostname"]).toBe("PC-NEU-007");
|
||||
expect(werte["os_version"]).toBe("Windows 11 Pro");
|
||||
});
|
||||
|
||||
it("lässt eine Variable die gesperrten Platzhalter nicht überschreiben", async () => {
|
||||
/*
|
||||
* Der Blocker aus dem Review: die Vorlagenvariablen gewannen gegen **alles**.
|
||||
* Eine Variable `techniker` liess einen Befehl mit fremdem Namen
|
||||
* unterschreiben, eine Variable `kunde` holte im Modus INTERN die
|
||||
* Kundenebene zurueck, die es dort ausdruecklich nicht gibt.
|
||||
*/
|
||||
const { eigene, versionId } = await aufbau(Betriebsmodus.INTERN);
|
||||
const laufId = await laufAnlegen({
|
||||
vorlagenVersionId: versionId,
|
||||
titel: "Angriff",
|
||||
kundeId: eigene.id,
|
||||
variablen: {
|
||||
kunde: "Fremde AG",
|
||||
kundenkuerzel: "FRD",
|
||||
techniker: "Nicht ich",
|
||||
datum: "01.01.1999",
|
||||
generiertes_passwort: "geheim",
|
||||
},
|
||||
});
|
||||
|
||||
const werte = await platzhalterWerteDesLaufs({
|
||||
laufId,
|
||||
technikerName: "Ali Demir",
|
||||
jetzt: new Date("2026-09-25T10:00:00Z"),
|
||||
});
|
||||
|
||||
expect(werte["kunde"]).toBeUndefined();
|
||||
expect(werte["kundenkuerzel"]).toBeUndefined();
|
||||
expect(werte["techniker"]).toBe("Ali Demir");
|
||||
expect(werte["datum"]).toBe("25.09.2026");
|
||||
expect(werte["generiertes_passwort"]).toBeUndefined();
|
||||
|
||||
// Und das Passwort bleibt das erzeugte, nicht das aus der Variablen.
|
||||
const ergebnis = platzhalterErsetzen("{{generiertes_passwort}}", werte);
|
||||
expect(ergebnis.text).not.toBe("geheim");
|
||||
expect(ergebnis.text).toHaveLength(20);
|
||||
});
|
||||
|
||||
it("nimmt Zahlen und Haken aus den Variablen als Text auf", async () => {
|
||||
const { eigene, versionId } = await aufbau();
|
||||
const laufId = await laufAnlegen({
|
||||
vorlagenVersionId: versionId,
|
||||
titel: "Prüfung",
|
||||
kundeId: eigene.id,
|
||||
variablen: { arbeitsspeicher: 32, verschluesselt: true, kaputt: { tief: 1 } },
|
||||
});
|
||||
const werte = await platzhalterWerteDesLaufs({ laufId });
|
||||
expect(werte["arbeitsspeicher"]).toBe("32");
|
||||
expect(werte["verschluesselt"]).toBe("true");
|
||||
// Was kein einfacher Wert ist, bleibt draussen – im Befehl stuende sonst
|
||||
// „[object Object]“.
|
||||
expect(werte["kaputt"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("kommt mit einem Lauf ohne Gerät und ohne Person aus", async () => {
|
||||
// Ein Lauf ohne beides ist gueltig: Server, Firewall, Patchday. Abzubrechen
|
||||
// hiesse, dem Techniker die Arbeit zu verweigern.
|
||||
const { eigene, versionId } = await aufbau();
|
||||
const laufId = await laufAnlegen({
|
||||
vorlagenVersionId: versionId,
|
||||
titel: "Patchday",
|
||||
kundeId: eigene.id,
|
||||
});
|
||||
const werte = await platzhalterWerteDesLaufs({ laufId });
|
||||
expect(werte["hostname"]).toBeUndefined();
|
||||
expect(werte["mitarbeiter"]).toBeUndefined();
|
||||
expect(werte["datum"]).toBeTruthy();
|
||||
});
|
||||
|
||||
it("liefert nichts für einen Lauf, den es nicht gibt", async () => {
|
||||
expect(await platzhalterWerteDesLaufs({ laufId: "gibtsnicht" })).toEqual({});
|
||||
});
|
||||
|
||||
it("baut einen vollständigen New-ADUser-Befehl", async () => {
|
||||
/*
|
||||
* Der Ablauf aus dem Auftrag, Ende zu Ende: Abteilung „Buchhaltung“ gewählt,
|
||||
* daraus `-Path "OU=Buchhaltung,…"` und die Gruppen `G-Buchhaltung, G-Alle`.
|
||||
*/
|
||||
const { eigene, abteilung, versionId } = await aufbau();
|
||||
const laufId = await laufAnlegen({
|
||||
vorlagenVersionId: versionId,
|
||||
titel: "Onboarding",
|
||||
kundeId: eigene.id,
|
||||
mitarbeiterName: "Jörg Müller-Lüdenscheid",
|
||||
abteilungId: abteilung.id,
|
||||
variablen: { upn: "joerg.mueller-luedenscheid@firma.de", sam: "jmuellerluedensch" },
|
||||
});
|
||||
const werte = await platzhalterWerteDesLaufs({ laufId });
|
||||
|
||||
const befehl = platzhalterErsetzen(
|
||||
'New-ADUser -Name "{{mitarbeiter}}" -UserPrincipalName "{{upn}}" ' +
|
||||
'-SamAccountName "{{sam}}" -Path "{{ou}}"',
|
||||
werte,
|
||||
{ art: "befehl", maskieren: (wert) => wert },
|
||||
);
|
||||
|
||||
expect(befehl.text).toBe(
|
||||
'New-ADUser -Name "Jörg Müller-Lüdenscheid" ' +
|
||||
'-UserPrincipalName "joerg.mueller-luedenscheid@firma.de" ' +
|
||||
'-SamAccountName "jmuellerluedensch" ' +
|
||||
'-Path "OU=Buchhaltung,OU=Benutzer,DC=firma,DC=de"',
|
||||
);
|
||||
expect(befehl.unbekannt).toEqual([]);
|
||||
|
||||
const gruppen = platzhalterErsetzen("Add-ADGroupMember {{gruppen}}", werte, {
|
||||
art: "befehl",
|
||||
maskieren: (wert) => wert,
|
||||
});
|
||||
expect(gruppen.text).toBe("Add-ADGroupMember G-Buchhaltung, G-Alle");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user