feat(seed): mitgelieferte Vorlagen am Schluessel erkennen #42

Merged
florian.wilde merged 1 commits from feat/32-stabiler-schluessel into main 2026-10-04 14:49:01 +00:00
20 changed files with 316 additions and 36 deletions
+6
View File
@@ -24,6 +24,12 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
### Hinzugefügt ### Hinzugefügt
- Mitgelieferte Vorlagen tragen einen **stabilen Schlüssel** (`seedSchluessel`). Der Seed erkennt
seine sieben Vorlagen daran statt am Namen — wer eine umbenannte, bekam vorher beim nächsten Lauf
eine zweite, samt doppeltem Schnellstart auf der Geräteseite. Eine bestehende Installation bekommt
den Schlüssel beim ersten Lauf danach nachgetragen, ohne Dubletten. Eine über den Import
eingespielte Kopie bekommt **keinen** Schlüssel: sie soll nicht den Platz des Originals einnehmen
(#021)
- `pnpm pruefe:doku` vergleicht die Schranken-Befehle in `CLAUDE.md` und `README.md` und verlangt von - `pnpm pruefe:doku` vergleicht die Schranken-Befehle in `CLAUDE.md` und `README.md` und verlangt von
beiden den LDAP-Container. Genau diese Abweichung war unbemerkt entstanden (#040) beiden den LDAP-Container. Genau diese Abweichung war unbemerkt entstanden (#040)
- Fünf Tests für den Healthcheck, darunter einer, der belegt, dass die Begründung ins Protokoll - Fünf Tests für den Healthcheck, darunter einer, der belegt, dass die Begründung ins Protokoll
@@ -1,11 +1,11 @@
# #021 Stabiler Schlüssel für Seed-Vorlagen # #021 Stabiler Schlüssel für Seed-Vorlagen
**Status:** offen **Status:** erledigt
**Im Tracker:** [#32](https://git.wilde-server.de/florian.wilde/Laufzettel/issues/32) — die Zählung dort schließt Pull Requests ein und läuft deshalb auseinander **Im Tracker:** [#32](https://git.wilde-server.de/florian.wilde/Laufzettel/issues/32) — die Zählung dort schließt Pull Requests ein und läuft deshalb auseinander
**Meilenstein:** v0.2.0 **Meilenstein:** v0.2.0
**Labels:** phase-2, backend **Labels:** phase-2, backend
**Geschätzt:** 2 h **Tatsächlich:** – **Geschätzt:** 2 h **Tatsächlich:** 55 min
## Ziel ## Ziel
@@ -15,17 +15,19 @@ Geräteseite doppelt. Aufgefallen im Review zu [#017](017-seed-sieben-vorlagen.m
## Aufgaben ## Aufgaben
- [ ] Ein optionales Feld `schluessel` im [Austauschformat](../vorlagen-format.md) — gedacht für - [x] Ein optionales Feld `seedSchluessel` im [Austauschformat](../vorlagen-format.md) — **nicht**
`schluessel` wie hier geplant: so heißt im Format schon das Feld einer Variablen, und zwei
Dinge gleichen Namens in derselben Datei wären eine Lesefalle — gedacht für
mitgelieferte Vorlagen, nicht für die Oberfläche; beim Import **nicht** übernommen, damit eine mitgelieferte Vorlagen, nicht für die Oberfläche; beim Import **nicht** übernommen, damit eine
eingespielte Kopie nicht den Platz des Originals einnimmt eingespielte Kopie nicht den Platz des Originals einnimmt
- [ ] `Vorlage.seedSchluessel` mit partiellem eindeutigen Index (nur wo gesetzt) - [x] `Vorlage.seedSchluessel` mit partiellem eindeutigen Index (nur wo gesetzt)
- [ ] Der Seed sucht über den Schlüssel und fällt auf den Namen zurück, solange noch keiner gesetzt - [x] Der Seed sucht über den Schlüssel und fällt auf den Namen zurück, solange noch keiner gesetzt
ist — eine bestehende Installation darf beim ersten Lauf danach keine Dubletten bekommen ist — eine bestehende Installation darf beim ersten Lauf danach keine Dubletten bekommen
- [ ] Dokumentieren, was der Schlüssel **nicht** tut: er macht eine Vorlage nicht unveränderlich und - [x] Dokumentieren, was der Schlüssel **nicht** tut: er macht eine Vorlage nicht unveränderlich und
erlaubt dem Seed nicht, eine bearbeitete Vorlage zu überschreiben erlaubt dem Seed nicht, eine bearbeitete Vorlage zu überschreiben
## Akzeptanzkriterien ## Akzeptanzkriterien
- [ ] Test: umbenannte Seed-Vorlage, zweiter Seed-Lauf legt keine zweite an - [x] Test: umbenannte Seed-Vorlage, zweiter Seed-Lauf legt keine zweite an
- [ ] Test: zweiter Lauf erzeugt keine zweite `GeraetetypVorlage`-Zeile - [x] Test: zweiter Lauf erzeugt keine zweite `GeraetetypVorlage`-Zeile
- [ ] Test: eine über den Import eingespielte Kopie bekommt **keinen** Schlüssel - [x] Test: eine über den Import eingespielte Kopie bekommt **keinen** Schlüssel
+35
View File
@@ -0,0 +1,35 @@
# #027 Die Editor-Abläufe lassen ihre Vorlagen stehen
**Status:** offen
**Im Tracker:** –
**Meilenstein:** v0.2.0
**Labels:** phase-2, test
**Geschätzt:** 1 h **Tatsächlich:** –
## Ziel
In der Entwicklungsdatenbank stehen über hundert Vorlagen mit Namen wie
`ZZ E2E Editor 1790324192596 C`. Die Ende-zu-Ende-Abläufe legen sie an und räumen sie nicht weg; bei
jedem Lauf kommen drei dazu.
Aufgefallen beim Nachsehen zu [#021](021-stabiler-schluessel-vorlagen.md), als eine Abfrage über
`Vorlage` statt sieben Zeilen über hundert lieferte.
Das ist kein Schaden an den Tests — das `ZZ`-Präfix sortiert sie ans Ende, und jeder Ablauf legt
seine eigenen an. Es ist ein Schaden an allem, was **danach** in dieselbe Datenbank sieht: die
Vorlagenliste in der Oberfläche, Screenshots, und jede Abfrage von Hand. Bei einem Fehler, der
„0 Treffer“ oder „zu viele Treffer“ heißt, kostet das Zeit.
## Aufgaben
- [ ] Die Abläufe unter `tests/e2e/`, die Vorlagen anlegen, räumen sie in einem Nachlauf wieder weg —
über ihr Präfix, nicht über gemerkte Kennungen (ein abgebrochener Lauf merkt sich nichts)
- [ ] Dasselbe für Geräte, Personen und Läufe prüfen, die die Abläufe anlegen
- [ ] Einmalig aufräumen: die vorhandenen Reste aus der Entwicklungsdatenbank entfernen
- [ ] Prüfen, ob ein Nachlauf das überhaupt leisten kann — ein Ablauf, der mitten im Lauf scheitert,
darf den Nachlauf nicht überspringen
## Akzeptanzkriterien
- [ ] Nach `pnpm test:e2e` steht in `Vorlage` keine Zeile mit `ZZ E2E`
- [ ] Ein abgebrochener Lauf lässt ebenfalls nichts stehen
+3 -2
View File
@@ -25,10 +25,11 @@ Diese Dateien sind die Quelle; `pnpm issues:sync` spiegelt sie in den Gitea-Trac
| #018 | [M13 – E2E-Tests, Anleitung, PDF](018-e2e-anleitung-pdf.md) | erledigt | v0.1.0 | 14 h | 2 h 15 min | | #018 | [M13 – E2E-Tests, Anleitung, PDF](018-e2e-anleitung-pdf.md) | erledigt | v0.1.0 | 14 h | 2 h 15 min |
| #019 | [M14 – Betriebswege und Release v0.1.0](019-betrieb-release.md) | erledigt | v0.1.0 | 10 h | 5 h 25 min | | #019 | [M14 – Betriebswege und Release v0.1.0](019-betrieb-release.md) | erledigt | v0.1.0 | 10 h | 5 h 25 min |
| #020 | [Vorgesetzter auch für von Hand angelegte Personen](020-vorgesetzter-manuell.md) | offen | v0.2.0 | 3 h | – | | #020 | [Vorgesetzter auch für von Hand angelegte Personen](020-vorgesetzter-manuell.md) | offen | v0.2.0 | 3 h | – |
| #021 | [Stabiler Schlüssel für Seed-Vorlagen](021-stabiler-schluessel-vorlagen.md) | offen | v0.2.0 | 2 h | – | | #021 | [Stabiler Schlüssel für Seed-Vorlagen](021-stabiler-schluessel-vorlagen.md) | erledigt | v0.2.0 | 2 h | 55 min |
| #022 | [Schnellstart je Gerätetyp pflegen](022-schnellstart-pflegen.md) | offen | v0.2.0 | 3 h | – | | #022 | [Schnellstart je Gerätetyp pflegen](022-schnellstart-pflegen.md) | offen | v0.2.0 | 3 h | – |
| #023 | [Protokoll ansehen](023-protokoll-ansicht.md) | offen | v0.2.0 | 4 h | – | | #023 | [Protokoll ansehen](023-protokoll-ansicht.md) | offen | v0.2.0 | 4 h | – |
| #024 | [Proxmox-Einzeiler auf einem echten PVE-Host gegenprüfen](024-proxmox-echtlauf.md) | offen | v0.2.0 | 2 h | – | | #024 | [Proxmox-Einzeiler auf einem echten PVE-Host gegenprüfen](024-proxmox-echtlauf.md) | offen | v0.2.0 | 2 h | – |
| #025 | [Healthcheck gibt die rohe Datenbankfehlermeldung heraus](025-gesundheit-fehlermeldung.md) | erledigt | v0.2.0 | 1 h | 25 min | | #025 | [Healthcheck gibt die rohe Datenbankfehlermeldung heraus](025-gesundheit-fehlermeldung.md) | erledigt | v0.2.0 | 1 h | 25 min |
| #026 | [Zwei Konventionen präzisieren, die strenger klingen als sie gemeint sind](026-konventionen-praezisieren.md) | erledigt | v0.2.0 | 1 h | 20 min | | #026 | [Zwei Konventionen präzisieren, die strenger klingen als sie gemeint sind](026-konventionen-praezisieren.md) | erledigt | v0.2.0 | 1 h | 20 min |
| | **Summe** | | | **152 h** | **36 h 05 min** | | #027 | [Die Editor-Abläufe lassen ihre Vorlagen stehen](027-e2e-reste-vorlagen.md) | offen | v0.2.0 | 1 h | – |
| | **Summe** | | | **153 h** | **37 h 00 min** |
+31
View File
@@ -104,6 +104,7 @@ Platzhaltern. **JSON kennt keine Kommentare** — die Zeilen mit `//` müssen vo
| -------------- | -------- | ------------------------------------------------------------------------------- | | -------------- | -------- | ------------------------------------------------------------------------------- |
| `format` | Pflicht | Fassung des Austauschformats. Derzeit `1`; ein anderer Wert wird abgewiesen. | | `format` | Pflicht | Fassung des Austauschformats. Derzeit `1`; ein anderer Wert wird abgewiesen. |
| `name` | Pflicht | Name der Vorlage, nicht leer. Namen sind **nicht** eindeutig. | | `name` | Pflicht | Name der Vorlage, nicht leer. Namen sind **nicht** eindeutig. |
| `seedSchluessel` | optional | **Nur für mitgelieferte Vorlagen.** Siehe unten — beim Import verfällt das Feld. |
| `beschreibung` | optional | Ein bis zwei Sätze, was die Vorlage abdeckt. | | `beschreibung` | optional | Ein bis zwei Sätze, was die Vorlage abdeckt. |
| `kategorie` | optional | Freitext, gruppiert die Vorlagenliste (etwa `Onboarding`). | | `kategorie` | optional | Freitext, gruppiert die Vorlagenliste (etwa `Onboarding`). |
| `schlagworte` | optional | Liste nicht leerer Texte; Standard `[]`. | | `schlagworte` | optional | Liste nicht leerer Texte; Standard `[]`. |
@@ -230,3 +231,33 @@ Das Feld `format` ist die Fassung des Formats, nicht die der Anwendung. Es gilt:
der die Hälfte versteht und die andere Hälfte still verwirft, wäre schlimmer als ein Fehlschlag. der die Hälfte versteht und die andere Hälfte still verwirft, wäre schlimmer als ein Fehlschlag.
Die Zahl steht als `FORMAT_VERSION` in `src/lib/vorlagen-format.ts` an genau einer Stelle. Die Zahl steht als `FORMAT_VERSION` in `src/lib/vorlagen-format.ts` an genau einer Stelle.
## `seedSchluessel` — nur für mitgelieferte Vorlagen
Die sieben Vorlagen, die Laufzettel mitbringt, tragen in ihrer Datei ein zusätzliches Feld:
```json
{ "format": 1, "name": "PC Neuinstallation", "seedSchluessel": "pc-neuinstallation" }
```
Daran erkennt der Seed sie wieder. Vorher ging das über den **Namen** — und wer eine mitgelieferte
Vorlage umbenannte, bekam beim nächsten Seed-Lauf eine zweite, samt doppeltem Schnellstart auf der
Geräteseite (#021).
**Was der Schlüssel nicht tut:**
- Er macht eine Vorlage **nicht unveränderlich.** Umbenennen, umbauen, archivieren — alles bleibt
erlaubt. Der Seed erkennt sie danach wieder und lässt sie in Ruhe.
- Er erlaubt dem Seed **nicht**, eine bearbeitete Vorlage zu überschreiben. Gibt es die Vorlage, legt
er nichts an und ändert nichts. Das war vorher so und bleibt so.
- Er ist **keine Kennung über Installationen hinweg.** Zwei Installationen haben für dieselbe
mitgelieferte Vorlage denselben Schlüssel, aber verschiedene `id`s — der Schlüssel sagt nur
„diese Zeile stammt aus dem Seed“.
**Beim Import verfällt das Feld.** Wer eine mitgelieferte Vorlage exportiert und wieder einspielt,
bekommt eine gewöhnliche Vorlage ohne Schlüssel. Das ist Absicht: die Kopie soll nicht den Platz des
Originals einnehmen, sonst hielte der Seed sie dafür und würde das Original ein zweites Mal anlegen.
Der Export schreibt das Feld deshalb auch nicht — ein Feld, das beim Einlesen verfällt, verwirrt nur.
Eigene Vorlagen brauchen den Schlüssel nicht und sollten ihn nicht bekommen. Er ist eindeutig, wo er
gesetzt ist; eine zweite Vorlage mit demselben Schlüssel weist die Datenbank ab.
@@ -0,0 +1,24 @@
-- ---------------------------------------------------------------------------
-- HANDGESCHRIEBEN. Prisma erzeugt hier zusaetzlich Loeschbefehle fuer die
-- zusammengesetzten Fremdschluessel (Mandantengrenze), die Trigramm-Indizes
-- (Archivsuche), die partiellen eindeutigen Indizes und die Trigger auf
-- veroeffentlichten Versionen: es kennt sie nicht, weil sie sich im Schema nicht
-- ausdruecken lassen. Diese Loeschbefehle sind bewusst gestrichen.
--
-- tests/datenbankregeln.test.ts prueft, dass alle diese Objekte vorhanden sind –
-- ein versehentlich stehengelassener Loeschbefehl faellt dort sofort auf.
-- ---------------------------------------------------------------------------
-- Stabiler Schluessel fuer mitgelieferte Vorlagen (#021).
--
-- Bisher erkannte der Seed seine sieben Vorlagen am Namen. Wurde eine
-- umbenannt, legte der naechste Lauf sie erneut an, und der Schnellstart stand
-- auf der Geraeteseite doppelt. Der Schluessel ueberlebt das Umbenennen.
ALTER TABLE "Vorlage"
ADD COLUMN "seedSchluessel" TEXT;
-- Eindeutig nur dort, wo gesetzt: eigene Vorlagen tragen keinen Schluessel, und
-- ein gewoehnlicher eindeutiger Index wuerde an der zweiten davon scheitern.
CREATE UNIQUE INDEX "Vorlage_seedSchluessel_einmalig"
ON "Vorlage" ("seedSchluessel")
WHERE "seedSchluessel" IS NOT NULL;
+7
View File
@@ -377,6 +377,13 @@ model Vorlage {
/// "gilt fuer alle". /// "gilt fuer alle".
kundeId String? kundeId String?
archiviert Boolean @default(false) archiviert Boolean @default(false)
/// Kennung einer **mitgelieferten** Vorlage. Der Seed setzt sie und erkennt
/// seine sieben Vorlagen daran statt am Namen, der sich aendern darf (#021).
/// Eindeutig, wo gesetzt — erzwungen durch den partiellen eindeutigen Index
/// `Vorlage_seedSchluessel_einmalig`, den Prisma nicht ausdruecken kann.
/// Der Import setzt sie absichtlich **nicht**: eine eingespielte Kopie soll
/// nicht den Platz des Originals einnehmen.
seedSchluessel String?
erstelltAm DateTime @default(now()) erstelltAm DateTime @default(now())
geaendertAm DateTime @default(now()) @updatedAt geaendertAm DateTime @default(now()) @updatedAt
+54 -15
View File
@@ -68,11 +68,61 @@ export type GesaeteVorlage = {
readonly neu: boolean; readonly neu: boolean;
}; };
/**
* Sucht die mitgelieferte Vorlage – zuerst am Schluessel, dann am Namen.
*
* Der Schluessel ueberlebt ein Umbenennen, der Name nicht: genau daran legte der
* Seed eine umbenannte Vorlage ein zweites Mal an, und der Schnellstart stand
* auf der Geraeteseite doppelt (#021). Der Rueckfall auf den Namen bleibt, weil
* eine Installation von vor diesem Feld noch keinen Schluessel hat — ohne ihn
* bekaeme sie beim ersten Lauf danach genau die Dubletten, die das Feld
* verhindern soll. Wird sie so gefunden, traegt der Seed den Schluessel nach.
*
* Auch eine **archivierte** Vorlage gilt als vorhanden: eine zweite gleichen
* Namens danebenzustellen waere keine Hilfe, sondern eine Dublette in jeder
* Liste. Wer die Beispielvorlage zurueck will, holt sie aus dem Archiv.
*/
async function mitgelieferteFinden(db: PrismaClient, dokument: Austausch) {
const mitVersion = {
versionen: {
where: { status: VersionStatus.VEROEFFENTLICHT },
orderBy: { version: "desc" as const },
take: 1,
},
};
if (dokument.seedSchluessel !== undefined) {
const amSchluessel = await db.vorlage.findFirst({
where: { seedSchluessel: dokument.seedSchluessel },
include: mitVersion,
});
if (amSchluessel) return amSchluessel;
}
const amNamen = await db.vorlage.findFirst({
// Nur eine Zeile **ohne** Schluessel darf hier zugeordnet werden. Sonst
// koennte eine fremde Vorlage, die zufaellig so heisst wie die mitgelieferte,
// deren Schluessel bekommen - und der naechste Lauf wuerde sie fuer das
// Original halten.
where: { name: dokument.name, kundeId: null, seedSchluessel: null },
include: mitVersion,
});
if (amNamen && dokument.seedSchluessel !== undefined) {
await db.vorlage.update({
where: { id: amNamen.id },
data: { seedSchluessel: dokument.seedSchluessel },
});
console.log(` Vorlage „${amNamen.name}“: Schlüssel ${dokument.seedSchluessel} nachgetragen`);
}
return amNamen;
}
/** /**
* Legt die Vorlagen als veroeffentlichte Version 1 an. * Legt die Vorlagen als veroeffentlichte Version 1 an.
* *
* Idempotent ueber den **Namen**: gibt es die Vorlage schon, bleibt sie * Idempotent ueber den **Schluessel** (Rueckfall: Name, siehe
* unangetastet — auch dann, wenn jemand sie inzwischen bearbeitet hat. Ein * `mitgelieferteFinden`): gibt es die Vorlage schon, bleibt sie unangetastet —
* auch dann, wenn jemand sie inzwischen bearbeitet oder umbenannt hat. Ein
* zweiter Seed-Lauf darf die Arbeit eines Redakteurs nicht zurueckdrehen, und * zweiter Seed-Lauf darf die Arbeit eines Redakteurs nicht zurueckdrehen, und
* eine veroeffentlichte Version wird ohnehin nie veraendert. * eine veroeffentlichte Version wird ohnehin nie veraendert.
*/ */
@@ -82,19 +132,7 @@ export async function vorlagenSaeen(db: PrismaClient): Promise<readonly GesaeteV
let uebersprungen = 0; let uebersprungen = 0;
for (const { dokument } of vorlagenLesen()) { for (const { dokument } of vorlagenLesen()) {
const vorhanden = await db.vorlage.findFirst({ const vorhanden = await mitgelieferteFinden(db, dokument);
// Auch eine **archivierte** Vorlage gilt als vorhanden: eine zweite gleichen
// Namens danebenzustellen waere keine Hilfe, sondern eine Dublette in jeder
// Liste. Wer die Beispielvorlage zurueck will, holt sie aus dem Archiv.
where: { name: dokument.name, kundeId: null },
include: {
versionen: {
where: { status: VersionStatus.VEROEFFENTLICHT },
orderBy: { version: "desc" },
take: 1,
},
},
});
const veroeffentlichte = vorhanden?.versionen[0]; const veroeffentlichte = vorhanden?.versionen[0];
if (vorhanden && veroeffentlichte) { if (vorhanden && veroeffentlichte) {
ergebnis.push({ ergebnis.push({
@@ -131,6 +169,7 @@ export async function vorlagenSaeen(db: PrismaClient): Promise<readonly GesaeteV
beschreibung: dokument.beschreibung ?? null, beschreibung: dokument.beschreibung ?? null,
kategorie: dokument.kategorie ?? null, kategorie: dokument.kategorie ?? null,
schlagworte: [...dokument.schlagworte], schlagworte: [...dokument.schlagworte],
seedSchluessel: dokument.seedSchluessel ?? null,
}, },
})); }));
@@ -1,6 +1,7 @@
{ {
"format": 1, "format": 1,
"name": "PC Neuinstallation", "name": "PC Neuinstallation",
"seedSchluessel": "pc-neuinstallation",
"beschreibung": "Vollständige Neuinstallation eines Arbeitsplatzrechners: von der Datensicherung des alten Systems über Installation, Einbindung und Verschlüsselung bis zur Übergabe an den Benutzer.", "beschreibung": "Vollständige Neuinstallation eines Arbeitsplatzrechners: von der Datensicherung des alten Systems über Installation, Einbindung und Verschlüsselung bis zur Übergabe an den Benutzer.",
"kategorie": "Client", "kategorie": "Client",
"schlagworte": ["windows", "client", "neuinstallation", "bitlocker"], "schlagworte": ["windows", "client", "neuinstallation", "bitlocker"],
@@ -1,6 +1,7 @@
{ {
"format": 1, "format": 1,
"name": "Mitarbeiter-Onboarding", "name": "Mitarbeiter-Onboarding",
"seedSchluessel": "mitarbeiter-onboarding",
"beschreibung": "Neues Benutzerkonto im Active Directory oder in Entra ID anlegen, Hardware bereitstellen und den ersten Arbeitstag begleiten.", "beschreibung": "Neues Benutzerkonto im Active Directory oder in Entra ID anlegen, Hardware bereitstellen und den ersten Arbeitstag begleiten.",
"kategorie": "Benutzer", "kategorie": "Benutzer",
"schlagworte": ["onboarding", "eintritt", "benutzerkonto", "active directory", "entra id"], "schlagworte": ["onboarding", "eintritt", "benutzerkonto", "active directory", "entra id"],
@@ -1,6 +1,7 @@
{ {
"format": 1, "format": 1,
"name": "Mitarbeiter-Offboarding", "name": "Mitarbeiter-Offboarding",
"seedSchluessel": "mitarbeiter-offboarding",
"beschreibung": "Konto sperren, Postfach und Daten übergeben, Hardware und Zugänge einsammeln, wenn jemand das Haus verlässt.", "beschreibung": "Konto sperren, Postfach und Daten übergeben, Hardware und Zugänge einsammeln, wenn jemand das Haus verlässt.",
"kategorie": "Benutzer", "kategorie": "Benutzer",
"schlagworte": ["offboarding", "austritt", "benutzerkonto", "sperrung"], "schlagworte": ["offboarding", "austritt", "benutzerkonto", "sperrung"],
@@ -1,6 +1,7 @@
{ {
"format": 1, "format": 1,
"name": "Server-Inbetriebnahme Windows-VM", "name": "Server-Inbetriebnahme Windows-VM",
"seedSchluessel": "server-inbetriebnahme",
"beschreibung": "Inbetriebnahme einer neuen Windows-Server-VM: Planung und Adressvergabe, Installation, Domänenbeitritt, Absicherung sowie Aufnahme in Backup und Monitoring.", "beschreibung": "Inbetriebnahme einer neuen Windows-Server-VM: Planung und Adressvergabe, Installation, Domänenbeitritt, Absicherung sowie Aufnahme in Backup und Monitoring.",
"kategorie": "Server", "kategorie": "Server",
"schlagworte": ["windows", "server", "vm", "inbetriebnahme"], "schlagworte": ["windows", "server", "vm", "inbetriebnahme"],
@@ -1,6 +1,7 @@
{ {
"format": 1, "format": 1,
"name": "Firewall-Rollout OPNsense", "name": "Firewall-Rollout OPNsense",
"seedSchluessel": "firewall-rollout-opnsense",
"beschreibung": "Austausch oder Neuaufbau einer OPNsense-Firewall: von den Providerdaten über Regelwerk und VPN bis zur Abnahme und zur außer Betrieb genommenen Altfirewall.", "beschreibung": "Austausch oder Neuaufbau einer OPNsense-Firewall: von den Providerdaten über Regelwerk und VPN bis zur Abnahme und zur außer Betrieb genommenen Altfirewall.",
"kategorie": "Netzwerk", "kategorie": "Netzwerk",
"schlagworte": ["firewall", "opnsense", "netzwerk", "vpn", "rollout"], "schlagworte": ["firewall", "opnsense", "netzwerk", "vpn", "rollout"],
@@ -1,6 +1,7 @@
{ {
"format": 1, "format": 1,
"name": "Monatlicher Patchday", "name": "Monatlicher Patchday",
"seedSchluessel": "monatlicher-patchday",
"beschreibung": "Der wiederkehrende Ablauf für Updates an Servern, Hypervisor, Netzgeräten und Clients: ankündigen, einspielen, kontrollieren und berichten.", "beschreibung": "Der wiederkehrende Ablauf für Updates an Servern, Hypervisor, Netzgeräten und Clients: ankündigen, einspielen, kontrollieren und berichten.",
"kategorie": "Wartung", "kategorie": "Wartung",
"schlagworte": ["patchday", "updates", "wartung", "monatlich"], "schlagworte": ["patchday", "updates", "wartung", "monatlich"],
@@ -1,6 +1,7 @@
{ {
"format": 1, "format": 1,
"name": "Backup-Restore-Test", "name": "Backup-Restore-Test",
"seedSchluessel": "backup-restore-test",
"beschreibung": "Der regelmäßige Nachweis, dass sich eine Sicherung wirklich zurückholen lässt: Wiederherstellungspunkt prüfen, in einer isolierten Umgebung zurückspielen, Funktion und Dauer festhalten.", "beschreibung": "Der regelmäßige Nachweis, dass sich eine Sicherung wirklich zurückholen lässt: Wiederherstellungspunkt prüfen, in einer isolierten Umgebung zurückspielen, Funktion und Dauer festhalten.",
"kategorie": "Wartung", "kategorie": "Wartung",
"schlagworte": ["backup", "restore", "wiederherstellung", "test", "wartung"], "schlagworte": ["backup", "restore", "wiederherstellung", "test", "wartung"],
+19
View File
@@ -175,6 +175,25 @@ export const AustauschSchema = z.object({
message: `Unbekanntes Format. Diese Fassung liest Format ${FORMAT_VERSION}.`, message: `Unbekanntes Format. Diese Fassung liest Format ${FORMAT_VERSION}.`,
}), }),
name: z.string().trim().min(1, "Die Vorlage braucht einen Namen.").max(200), name: z.string().trim().min(1, "Die Vorlage braucht einen Namen.").max(200),
/**
* Kennung einer **mitgelieferten** Vorlage (#021).
*
* Nur fuer die Dateien unter `prisma/seed-daten/vorlagen/` gedacht, damit der
* Seed seine Vorlagen am Schluessel erkennt und nicht am Namen, der sich
* aendern darf. Der **Import uebernimmt sie nicht** — eine eingespielte Kopie
* soll nicht den Platz des Originals einnehmen —, und der Export schreibt sie
* nicht, weil ein Feld, das beim Einlesen verfaellt, nur verwirrt.
*/
seedSchluessel: leerAlsUndefiniert(
z
.string()
.trim()
.max(60)
.regex(
/^[a-z0-9-]+$/,
"Der Seed-Schlüssel darf nur Kleinbuchstaben, Ziffern und Bindestriche enthalten.",
),
),
beschreibung: leerAlsUndefiniert(z.string().trim().max(GRENZEN.text)), beschreibung: leerAlsUndefiniert(z.string().trim().max(GRENZEN.text)),
kategorie: leerAlsUndefiniert(z.string().trim().max(100)), kategorie: leerAlsUndefiniert(z.string().trim().max(100)),
schlagworte: z.array(z.string().trim().min(1).max(100)).max(50).default([]), schlagworte: z.array(z.string().trim().min(1).max(100)).max(50).default([]),
+5 -1
View File
@@ -62,7 +62,11 @@ describe.runIf(datenbankVerfuegbar)("Handgeschriebene Datenbankobjekte", () => {
await namen( await namen(
`SELECT indexname AS name FROM pg_indexes WHERE schemaname='public' AND indexdef LIKE '%WHERE%'`, `SELECT indexname AS name FROM pg_indexes WHERE schemaname='public' AND indexdef LIKE '%WHERE%'`,
), ),
).toEqual(["Kunde_eigene_organisation_einmalig", "VorlagenVersion_ein_entwurf"]); ).toEqual([
"Kunde_eigene_organisation_einmalig",
"Vorlage_seedSchluessel_einmalig",
"VorlagenVersion_ein_entwurf",
]);
}); });
it("hat alle Prüfbedingungen", async () => { it("hat alle Prüfbedingungen", async () => {
+21
View File
@@ -321,6 +321,27 @@ describe.runIf(datenbankVerfuegbar)("Datenmodell: Regeln in der Datenbank", () =
}); });
}); });
describe("Vorlage", () => {
it("haelt den Seed-Schluessel eindeutig, wo er gesetzt ist", async () => {
await testPrisma.vorlage.create({
data: { name: "PC Neuinstallation", seedSchluessel: "pc-neuinstallation" },
});
await expect(
testPrisma.vorlage.create({
data: { name: "Anders benannt", seedSchluessel: "pc-neuinstallation" },
}),
).rejects.toThrow(/Vorlage_seedSchluessel_einmalig/);
});
it("laesst beliebig viele Vorlagen ohne Seed-Schluessel zu", async () => {
// Der Index ist partiell, sonst waere die zweite eigene Vorlage eine
// Dublette von null (#021).
await testPrisma.vorlage.create({ data: { name: "Eigene A" } });
await testPrisma.vorlage.create({ data: { name: "Eigene B" } });
expect(await testPrisma.vorlage.count({ where: { seedSchluessel: null } })).toBe(2);
});
});
describe("Vorlagen-Version", () => { describe("Vorlagen-Version", () => {
it("haelt die Versionsnummer je Vorlage eindeutig", async () => { it("haelt die Versionsnummer je Vorlage eindeutig", async () => {
const vorlage = await testPrisma.vorlage.create({ data: { name: "PC Neuinstallation" } }); const vorlage = await testPrisma.vorlage.create({ data: { name: "PC Neuinstallation" } });
+53
View File
@@ -342,6 +342,59 @@ describe.runIf(datenbankVerfuegbar)("Seed gegen die Datenbank", () => {
expect(await testPrisma.vorlagenVersion.count({ where: { vorlageId: erste.vorlageId } })).toBe(2); expect(await testPrisma.vorlagenVersion.count({ where: { vorlageId: erste.vorlageId } })).toBe(2);
}); });
it("erkennt eine umbenannte Vorlage am Schluessel und legt keine zweite an", async () => {
// Der Fall, fuer den das Feld da ist (#021). Vorher erkannte der Seed seine
// Vorlagen am Namen: wer eine umbenannte, bekam beim naechsten Lauf eine
// zweite - und der Schnellstart stand auf der Geraeteseite doppelt.
const [erste] = await vorlagenSaeen(testPrisma);
if (!erste) throw new Error("Der Seed hat keine Vorlage angelegt.");
await testPrisma.vorlage.update({
where: { id: erste.vorlageId },
data: { name: "Von Hand umbenannt" },
});
const zweit = await vorlagenSaeen(testPrisma);
expect(zweit.every((eintrag) => !eintrag.neu)).toBe(true);
expect(await testPrisma.vorlage.count()).toBe(7);
// Und der Name bleibt der, den jemand gewaehlt hat.
const nachher = await testPrisma.vorlage.findUniqueOrThrow({ where: { id: erste.vorlageId } });
expect(nachher.name).toBe("Von Hand umbenannt");
});
it("legt zu einer umbenannten Vorlage keine zweite Schnellstart-Zeile an", async () => {
const gesaet = await vorlagenSaeen(testPrisma);
await typVorgabenSaeen(testPrisma, gesaet);
const vorher = await testPrisma.geraetetypVorlage.count();
// „PC Neuinstallation“ steht im Schnellstart fuer PC und NOTEBOOK - genau die
// Zeilen, die sich vorher verdoppelt haben.
const pc = gesaet.find((eintrag) => eintrag.name === "PC Neuinstallation");
if (!pc) throw new Error("Die PC-Vorlage fehlt im Seed.");
await testPrisma.vorlage.update({
where: { id: pc.vorlageId },
data: { name: "Arbeitsplatz neu aufsetzen" },
});
const nochmal = await vorlagenSaeen(testPrisma);
expect(await typVorgabenSaeen(testPrisma, nochmal)).toBe(0);
expect(await testPrisma.geraetetypVorlage.count()).toBe(vorher);
});
it("traegt den Schluessel bei einer Installation nach, die ihn noch nicht hat", async () => {
// Eine Installation von vor diesem Feld: die Zeilen stehen, der Schluessel
// fehlt. Ohne den Rueckfall auf den Namen bekaeme sie beim ersten Lauf
// danach genau die Dubletten, die das Feld verhindern soll.
const gesaet = await vorlagenSaeen(testPrisma);
await testPrisma.vorlage.updateMany({ data: { seedSchluessel: null } });
expect(await testPrisma.vorlage.count({ where: { seedSchluessel: null } })).toBe(7);
const zweit = await vorlagenSaeen(testPrisma);
expect(zweit.every((eintrag) => !eintrag.neu)).toBe(true);
expect(await testPrisma.vorlage.count()).toBe(7);
expect(await testPrisma.vorlage.count({ where: { seedSchluessel: null } })).toBe(0);
expect(zweit.map((e) => e.vorlageId).sort()).toEqual(gesaet.map((e) => e.vorlageId).sort());
});
it("verknuepft die Schnellstart-Vorgaben mit den richtigen Vorlagen", async () => { it("verknuepft die Schnellstart-Vorgaben mit den richtigen Vorlagen", async () => {
const gesaet = await vorlagenSaeen(testPrisma); const gesaet = await vorlagenSaeen(testPrisma);
const angelegt = await typVorgabenSaeen(testPrisma, gesaet); const angelegt = await typVorgabenSaeen(testPrisma, gesaet);
+31
View File
@@ -354,6 +354,24 @@ describe.runIf(datenbankVerfuegbar)("Vorlagen-Austausch", () => {
expect(await testPrisma.vorlagenVersion.count({ where: { vorlageId } })).toBe(1); expect(await testPrisma.vorlagenVersion.count({ where: { vorlageId } })).toBe(1);
}); });
it("gibt einer eingespielten Kopie keinen Seed-Schluessel", async () => {
// Sonst hielte der Seed die Kopie fuer das Original und legte das Original
// ein zweites Mal an (#021). Geprueft wird mit einem Dokument, das den
// Schluessel ausdruecklich mitbringt - ein Angreifer oder ein Versehen
// koennte ihn ja hineinschreiben.
const redakteur = await redakteurAnlegen();
const quelle = await beispielVersionAnlegen();
const austausch = await vorlageExportieren(quelle);
const { vorlageId } = await vorlageImportieren(
{ ...austausch, seedSchluessel: "pc-neuinstallation" },
redakteur.id,
);
const kopie = await testPrisma.vorlage.findUniqueOrThrow({ where: { id: vorlageId } });
expect(kopie.seedSchluessel).toBeNull();
});
it("leitet die Positionen aus der Reihenfolge der Listen ab", async () => { it("leitet die Positionen aus der Reihenfolge der Listen ab", async () => {
const redakteur = await redakteurAnlegen(); const redakteur = await redakteurAnlegen();
const { versionId } = await vorlageImportieren( const { versionId } = await vorlageImportieren(
@@ -437,6 +455,19 @@ describe.runIf(datenbankVerfuegbar)("Vorlagen-Austausch", () => {
}); });
}); });
describe("Seed-Schluessel", () => {
it("steht in keinem Export – auch nicht bei einer mitgelieferten Vorlage", async () => {
// Ein Feld, das beim Einlesen verfaellt, gehoert nicht in die Datei: wer es
// dort sieht, haelt es fuer uebertragbar.
const quelle = await beispielVersionAnlegen();
await testPrisma.vorlage.updateMany({ data: { seedSchluessel: "pc-neuinstallation" } });
const austausch = await vorlageExportieren(quelle);
expect(austausch).not.toHaveProperty("seedSchluessel");
expect(JSON.stringify(austausch)).not.toContain("pc-neuinstallation");
});
});
describe("Rundlauf", () => { describe("Rundlauf", () => {
it("ergibt ein inhaltsgleiches Dokument – bis auf die Version", async () => { it("ergibt ein inhaltsgleiches Dokument – bis auf die Version", async () => {
const redakteur = await redakteurAnlegen(); const redakteur = await redakteurAnlegen();