Compare commits
42 Commits
v1.3.0
..
c294bfddf2
| Author | SHA1 | Date | |
|---|---|---|---|
| c294bfddf2 | |||
| e1b191bf0b | |||
| 2f8dd14bfb | |||
| 2eb86e14ea | |||
| c13d657e41 | |||
| 6530ae503b | |||
| c1afd66586 | |||
| 231bd5e47f | |||
| a9e0d5b1ae | |||
| f1bb7f7191 | |||
| 710034c80a | |||
| 3091b04673 | |||
| 06fcdc0157 | |||
| 723cf6814b | |||
| fccaf8db0f | |||
| 998aba9ef3 | |||
| 4f8a368c9e | |||
| 3a1bfd943e | |||
| ec9c77956d | |||
| 35c7f5a1ac | |||
| 0094a60d15 | |||
| 58ce88e29f | |||
| 05feaa3dd6 | |||
| d34f682c28 | |||
| df7a5e7e8e | |||
| 9c518238f5 | |||
| 84fe73e16a | |||
| fcad4608e4 | |||
| ad004b286d | |||
| 39b1f74b56 | |||
| cf67c8a389 | |||
| d9f2af32d6 | |||
| 3e8c0f4ef5 | |||
| 6c5946ca6e | |||
| 1315f370a9 | |||
| 8be0725577 | |||
| 56c07c3581 | |||
| ee2b0256b5 | |||
| 82472ee665 | |||
| 8cbfb8b69d | |||
| 9039cea686 | |||
| 441854af72 |
+17
-9
@@ -1,13 +1,13 @@
|
||||
---
|
||||
gsd_state_version: "1.0"
|
||||
milestone: v1.2
|
||||
milestone: v1.3
|
||||
current_phase: 18
|
||||
current_phase_name: desktop-client-fertigstellen
|
||||
status: verified
|
||||
stopped_at: "22.09.2026: alle Auftraege erledigt und auf Beta (80a0d23, von alpha gezogen): Bilderrahmen, XFrame inkl. Ausschnitt/Zoom/Nur-anzeigen, Tray-Update nennt den Grund und prueft alle 4 h, Download-Knoepfe im Client. Der Basic-Auth am Proxy vor alpha BLEIBT (Entscheidung des Users) — aus dem Firmennetz greift eine Ausnahme, dort laeuft das Tray-Update; von aussen 401, der Client sagt das jetzt selbst. NICHT als offenen Punkt fuehren. Offen beim Nutzer nur: neuen Client einmal per Browser installieren, Freigabe 1.3.0 auf Zuruf. Kein weiterer Auftrag benannt."
|
||||
last_updated: "2026-09-22T12:40:00.000Z"
|
||||
last_activity: 2026-09-21
|
||||
last_activity_desc: Quick 260922-ge2 — XFrame-Ausschnitt (Vorschau mit ziehbarem Rahmen, Einpassen in die Kachel), Zoom, Nur anzeigen; Rahmenhoehe Kachel = Vorschau nach Browser-Befund; web 640
|
||||
stopped_at: "22.09.2026: 1.3.0 freigegeben; danach quick-260922-hk4 — Bilderrahmen-Bilder liegen jetzt im Dateibereich (user-files) statt in der Datenbank, Umzug laeuft automatisch beim Start, Selbstheilung aus der alten data-Spalte eingebaut; im Browser nachgewiesen. NAECHSTER SCHRITT, vom Nutzer noch nicht bestaetigt: (1) einmaliges Aufraeumen, damit ein Modul seine Dashboard-Kachel selbst mitbringt (heute sieben Hartkodierungen je Kachel; Katalog zeigt auch Kacheln gesperrter Module; gesperrte Kachel bleibt leer statt zu erklaeren) — das Geruest WIDGET_MODULE_MAP existiert und ist leer; (2) danach das Proxmox-Modul (PVE/PBS/PMG) und seine Kachel. Offen beim Nutzer: Live-Server auf 1.3.0 ziehen, neuen Client per Browser installieren."
|
||||
last_updated: "2026-09-23T15:30:00.000Z"
|
||||
last_activity: 2026-09-23
|
||||
last_activity_desc: Quick 260923-le6 — letzte Abnahmebefunde zum Proxmox-Modul behoben (PMG-Teilsumme, Aktualisieren-Knopf nur fuer Admins); 1.3.1 laeuft auf live (Nutzer bestaetigt)
|
||||
state_head: 4d485432c003a6caf68f6d85aff7de0bd27794e2
|
||||
progress:
|
||||
total_phases: 18
|
||||
@@ -461,6 +461,14 @@ Gerettet aus `.continue-here.md`. Relevant fuer die noch offenen Live-Tests.
|
||||
| 260922-frg | **Desktop-Client: Update-Eintrag im Tray nie mehr stumm ausgegraut.** Befund des Nutzers: „Update installieren“ bleibt grau, obwohl alpha `1.2.0-beta.gc001a08` anbietet und der Client auf `a6d1a64` steht — auch nach App-Neustart. Nachgemessen: Tessera-seitig antwortet `/desktop/update` auf dem alpha-Server selbst (am Proxy vorbei) mit 200 und gueltigem Manifest; DAVOR antwortet der Nginx Proxy Manager auf jede Anfrage an alpha mit `401 Basic` (vom Dev-Host und vom Testserver ueber 217.7.63.32 gemessen). Die Webansicht der App merkt sich das Proxy-Passwort, der Updater (`tauri-plugin-updater`, eigener reqwest) nicht. **Produktfehler:** das Plugin verschluckt Nicht-2xx-Status (`updater.rs` 529-559: `last_error` bleibt leer → `Err(ReleaseNotFound)`), unser `Err(_) => {}` machte daraus stumm denselben grauen Eintrag wie „kein Update“; geprueft wurde nur beim Start. **Fix (d73aad1, nur lib.rs + CHANGELOG):** drei Endzustaende, alle anklickbar — „Auf Beta-Stand … aktualisieren“ (installiert), „Kein Update verfügbar – erneut prüfen“, „Update-Prüfung fehlgeschlagen (HTTP 401) – erneut prüfen“ (Statuscode per eigener Diagnose-Anfrage nachgeliefert, nur Status gelesen); Benachrichtigung mit Erklaerung (Passwortschutz/Zugriffsliste am Proxy), entprellt ueber `LastCheckNotice`; Wiederhol-Thread alle 4 h (`std::thread`, ueberspringt bei abgelegtem Update); http-Server weiterhin „Update nur über https möglich“. Proxy-Zugangsdaten NICHT in den Client (T-FRG-03). `cargo fmt/clippy/test/build` gruen, 37 → 44 Tests, Rot-Nachweis 9x E0425. **Behebung beim Nutzer:** Passwortschutz vor alpha im Proxy Manager entfernen oder `/api-proxy/desktop/*` durchlassen; neuen Client einmal ueber den Browser installieren. | 2026-09-22 | d73aad1 | [260922-frg-desktop-client-update-eintrag-im-tray-ni](./quick/260922-frg-desktop-client-update-eintrag-im-tray-ni/) |
|
||||
| fast-260922-b | **Desktop-App: Download-Knoepfe in der App ohne Funktion (fast, 747a4d4).** Befund des Nutzers: „Herunterladen“ unter Einstellungen → Desktop-App tut in der App nichts (Windows und Linux). Ursache: die Webansicht hatte keinen Download-Handler — webkit2gtk verwirft Downloads dann still, WebView2 zeigte ebenfalls nichts. Fix: Hauptfenster entsteht im Code (`app.windows` in tauri.conf.json leer), weil nur `WebviewWindowBuilder` `on_download` annimmt; der Handler bricht den Download in der App ab und oeffnet die Adresse per Opener im System-Browser (Fortschritt, Speicherort, Passwortfenster fuer den Proxy). Capability `main` unveraendert. cargo fmt/clippy/test gruen. Nicht am laufenden Client geprueft (kein Display auf dem Dev-Host) — CI baut, Nachweis beim Nutzer oder auf der Windows-VM. | 2026-09-22 | 747a4d4 | — |
|
||||
| 260922-ge2 | **XFrame: Ausschnitt der Seite waehlen und einpassen, Zoom, „Nur anzeigen“.** Wunsch des Nutzers: nur einen bestimmten Ausschnitt der eingebetteten Seite zeigen, und die Groesse soll skalieren. Config: `crop {x,y,w,h}` in Seitenpixeln bei fester Layoutbreite 1280 (`XFRAME_PAGE_WIDTH`, keine UI), Klemmung ueber EINE Funktion `clampXframeCrop` (x+w ≤ 1280 verschiebt x; w ≥ 100, h ≥ 60, y+h ≤ 4000); `zoom` (50…150 %, nur Ganzseiten-Modus); `readOnly` (transparente Flaeche ueber dem Rahmen im Ansichtsmodus). Kachel: `computeCropLayout` (contain + Zentrierung, Massstab darf > 1 sein), der `<iframe>` wird selbst verschoben und skaliert (cross-origin — die Seite laesst sich von aussen nicht scrollen), Kachelmass per ResizeObserver. Einstellungen: Vorschau der Seite bei 1280 px (Stage 3000 Seitenpixel hoch, eigener Bildlauf), Rahmen als `<fieldset>` (Biome `useSemanticElements`) mit vier Eckgriffen, Ziehen per Pointer-Events mit lokalem Entwurf und genau einem PATCH beim Loslassen, Zahlenfelder als Tastaturweg; Zoom-Auswahl nur ohne Ausschnitt; Aktivieren setzt `readOnly` mit. **Befund im Browser-Rundgang, behoben (cf70a19):** Kachel und Vorschau hatten verschiedene Rahmenhoehen (max(y+h,720) vs. 3000) — bei vh-relativen Seiten (example.com `margin: 15vh`) lag derselbe Inhalt an verschiedenen Stellen, der gewaehlte Ausschnitt haette in der Kachel daneben gelegen; jetzt dieselbe Layouthoehe. Neun Pruefpunkte bestanden (Verschieben, Ecken mit fester Gegenecke und Mindestbreite, Klemmung der Zahlenfelder, Einpassen und Mitskalieren bei Kachelgroesse, Nur-anzeigen, Zoom 60 %, verweigernde Seite). Playwright kann in einem per `transform` skalierten iframe nicht selbst klicken — per `elementFromPoint` + `mouse.click` umgangen, ist eine Werkzeuggrenze. Test-Helfer `src/test/fake-resize-observer.ts`. **Zahlen:** web 604 → 640, api 1175, type-check 4/4, lint 5/5 (web 53 Warnungen unveraendert), `as unknown as` 27/6, Umlaut-Allowlist + „Ausschnitt“. | 2026-09-22 | 445b1d3,30fdd99,cf70a19 | [260922-ge2-xframe-widget-ausschnitt-der-eingebettet](./quick/260922-ge2-xframe-widget-ausschnitt-der-eingebettet/) |
|
||||
| 260922-hk4 | **Bilderrahmen-Bilder liegen jetzt im Dateibereich statt in der Datenbank.** Frage des Nutzers nach der Freigabe 1.3.0, ob `bytea` auf Dauer sinnvoll ist. Befund: Geschwindigkeit ist NICHT das Argument (ein Bild wird je Browser einmal taeglich geladen), die SICHERUNG ist es — gesichert wird von Hand per `pg_dump`, und 30 Bilder à 5 MiB je Benutzer waeren im Extremfall 150 MB pro Benutzer in jedem Abzug (alpha-DB heute 18 MB). Dazu Einheitlichkeit: Profilbilder (`user-files/avatars`, `User.avatarPath`) und DKV-Exporte liegen laengst im Volume. Umsetzung: Spalte `storagePath`, Ablage `user-files/dashboard-images/<userId>/<uuid>.<ext>` — Dateiname IMMER vom Server (UUID + Endung aus dem erkannten Mime-Typ), `originalName` nie im Pfad; ein eigener Ordner je Benutzer ist ausdruecklich KEIN Schutz, es entscheidet weiterhin die Besitzpruefung im Dienst. Umzug laeuft automatisch beim Start (`onApplicationBootstrap` ueber `forSystem()`), idempotent; die Spalte `data` bleibt bewusst vorerst stehen (Todo fuer den DROP, erst wenn alpha und live einmal gelaufen sind). **Befund im Rundgang, eigener Commit:** eine Zeile zeigte auf eine fehlende Datei (lokal Host vs. Container-Volume; im Betrieb: alter `pg_dump` + leeres Volume) — `getBytes` stellt die Datei jetzt aus der noch vorhandenen Spalte `data` wieder her, statt 404 zu melden. **Zahlen:** api 1175 → 1188, web 640, type-check 4/4, lint 5/5, RLS-Waechter 78/78. | 2026-09-22 | 9039cea,8cbfb8b,82472ee | [260922-hk4-bilderrahmen-bilder-auf-die-festplatte](./quick/260922-hk4-bilderrahmen-bilder-auf-die-festplatte/) |
|
||||
| 260922-m1h | **Ein Modul bringt seine Dashboard-Kachel jetzt selbst mit (Vorarbeit fuer Proxmox).** Bestandsaufnahme (lesend) hatte ergeben: ein neuer Widget-Typ war an SIEBEN Stellen hartkodiert (Union-Typ, Constraints, Registry, eigene `wireXWidget()` je Typ, Aufruf in page.tsx, zweite Liste im Katalogfenster, `@IsIn` im API-DTO); die Verbindung Kachel↔Modul existierte als `WIDGET_MODULE_MAP` in `dashboard.service.ts` (filtert fail-closed), war aber nie befuellt; der Katalog zeigte jedem alle Kacheln, auch die gesperrter Module. Umbau: `WIDGET_TYPES`/`WidgetType`/`WIDGET_MODULE_SLUGS` in `packages/shared` als EINE Quelle (API validiert per `@IsIn` gegen genau sie), ein generisches `registerWidget()` statt neun Funktionen, Katalog leitet seine Liste aus der Registry ab und filtert ueber `/modules/active` (fail-closed bei Fehler, reine Funktion `visibleWidgetTypes`), nicht verfuegbare Kachel zeigt `widgets.unavailable` statt leer zu bleiben. Deckungsgleichheits-Test faengt kuenftig jede vergessene Stelle. **Befund des Executors, geprueft statt vermutet:** `apps/web` hatte KEINE Abhaengigkeit auf `@tessera/shared` (frueher bewusst) — vor der Umsetzung nachgemessen, dass Bau und Produktions-Abbild das tragen (node:24-alpine strippt die Typen nativ); Folgeregel „nur loeschbare Syntax in shared“ steht als Warnung in der Datei. Verhalten der neun Kacheln unveraendert, im Browser bestaetigt (Reihenfolge, Anlegen, Entfernen, keine rohen Schluessel). Bewusst offen: der Einstellungs-Zweig je Typ in `widget-settings-panel.tsx` und die Live-Aktualisierung des Katalogs. **Zahlen:** api 1188 → 1202, web 640 → 659, type-check 4/4, lint 5/5 (74/53 wie Basis). | 2026-09-22 | 56c07c3,8be0725 | [260922-m1h-dashboard-widgets-ein-modul-bringt-seine](./quick/260922-m1h-dashboard-widgets-ein-modul-bringt-seine/) |
|
||||
| 260922-vdk | **Dashboard-Raster misst seine Breite auch aus dem Leerzustand heraus.** Meldung des Nutzers aus dem **Linux-Client**: rechts neben dem Kalender freie Flaeche, in die sich keine Kachel schieben laesst — „als ob es keinen Anker gibt“. Aus dem Bildschirmfoto zurueckgerechnet (Spaltenbreite 51,5 px, Platzhalter auf Spalte 13 = letzte moegliche, Rasterende bei x=1459 bei ~1660 px Inhaltsbreite): das Raster rechnete mit **1200 px** statt mit der echten Breite, rechts blieben ~460 px totes Feld. Ursache: die Breitenmessung hing in `useEffect(..., [])` mit `if (!containerRef.current) return` — haengt `DashboardGrid` mit NULL Kacheln ein, rendert der fruehe Ruecksprung in den Leerzustand den gemessenen `<div>` gar nicht, der Effekt bricht ab und laeuft nie wieder, auch nicht wenn spaeter die erste Kachel entsteht. `width` blieb die ganze Sitzung auf dem Startwert 1200; react-grid-layout vergleicht strikt (`width > breakpoint`), 1200 ist damit `md` (20 Spalten, 51,6 px) statt `lg`. Fix: Ref-Rueckruf `measureRef` statt Einmal-Effekt — folgt dem Knoten ueber den Wechsel Leerzustand ↔ gefuellt, misst synchron in der Commit-Phase, haengt den ResizeObserver dort an; Fenster-Horcher als zusaetzliches Netz; `applyWidth` verwirft 0 und nicht endliche Werte. **Verhalten sonst unveraendert** — belegte Plaetze bleiben gesperrt, nichts weicht aus (Ansage des Nutzers). **Geprueft im echten Client**, nicht im Browser: `Tessera-1.3.0.AppImage` auf `DISPLAY=:10` ueber den WebKit-Remote-Inspektor gesteuert. Gleicher Fehlerfall vorher/nachher: Kachel 469 px → **389 px** bei 1000 px Bereich, Ziehen endet jetzt bei 603 px = `1000 − 8 − 389`, exakt der rechte Rand. **Messfalle notiert:** im Client gegen `style.width`/`style.transform` messen, nie gegen `getBoundingClientRect()` — bei Fenster im Hintergrund friert WebKitGTK die Animationsuhr ein und der `width`-Uebergang bleibt auf dem alten Wert stehen. **Zahlen:** web 659 → 661 Tests, type-check 4/4, lint 5/5, Biome web 53 Warnungen unveraendert. | 2026-09-22 | d9f2af3,cf67c8a | [260922-vdk-dashboard-raster-misst-seine-breite-nich](./quick/260922-vdk-dashboard-raster-misst-seine-breite-nich/) |
|
||||
| 260923-ad9 | **Dashboard-Reiter: mehrere Dashboards je Benutzer.** Wunsch des Nutzers (23.09.): mehrere Dashboards als Reiter, per Ziehen sortierbar, der erste ist der Standard und wird beim Oeffnen geladen; „als Favorit festlegen“ = nach vorn ziehen, kein zusaetzliches Kennzeichen. Umsetzung in 5 Schritten: neues Modell `Dashboard` (userId, tenantId, name, position) mit RLS wie die Nachbartabellen; `WidgetInstance.dashboardId` und `DashboardLayout.dashboardId @unique` — Kacheln und Anordnung haengen jetzt am Reiter statt am Benutzer. Handgeschriebene Migration `20260923120000_dashboard_tabs` haengt den Bestand um: Bestandsuebernahme VOR `NOT NULL`/Fremdschluessel, danach 0 verwaiste Kacheln, 0 verwaiste Anordnungen, je Benutzer genau ein Reiter auf Position 0. Fuenf Endpunkte unter `/dashboard/tabs`; `assertOwnedDashboard` laeuft als erstes in JEDEM Lese- und Schreibweg und antwortet fuer „gibt es nicht“, „Kollege“ und „fremder Mandant“ identisch (kein Orakel) — acht eigene Tests dafuer. Umsortieren und Loeschen je EINE Transaktion nach dem Muster `FavoritesService.reorder`. Riegel: 20 Reiter, 40 Zeichen, 20 Kennungen je Anfrage. Ziehen per Pointer-Ereignissen ohne neue Abhaengigkeit (Muster xframe-Ausschnitt), ausserhalb des Bearbeitungsmodus moeglich, weil „nach vorn ziehen“ das Festlegen des Standards IST; Umbenennen und Loeschen bleiben im Bearbeitungsmodus, Loeschen mit `alertdialog`-Rueckfrage. **Raster unangetastet** (`FREE_PLACEMENT_COMPACTOR`/`preventCollision` und die Breitenmessung aus 260922-vdk) — vom Verifizierer per `git diff` nachgewiesen. **Rundgang mit zwoelf Punkten bestanden** (Bestand 5 Kacheln erhalten, Reiter leer angelegt, Kacheln je Reiter getrennt, Ziehen ordnet um, nach Neuladen kommt der erste Reiter, Umbenennen, Loeschen mit Rueckfrage, letzter Reiter ohne Loeschknopf, Kachelbreite 531 px bei 1625 px Bereich). **Kleiner Befund, offen:** die Knopf-Beschriftungen nennen den betroffenen Reiter nicht (nur das Bestaetigungsfenster tut es). **Zahlen:** api 1202 → 1240 Tests, web 661 → 693, type-check 4/4, lint 5/5 mit 53 Warnungen unveraendert, `migrate diff` ohne Unterschied. | 2026-09-23 | 9c51823,df7a5e7,d34f682,05feaa3,58ce88e | [260923-ad9-dashboard-reiter-mehrere-dashboards-je-b](./quick/260923-ad9-dashboard-reiter-mehrere-dashboards-je-b/) |
|
||||
| 260923-dhh | **Proxmox-Modul (PVE, PBS, PMG) — nur beobachten.** Sieben Aufgaben: Tabellen `ProxmoxServer`/`ProxmoxServerStatus` mit RLS, Zugang verschluesselt per `CryptoService`, undici-Klient mit Dispatcher nur fuer die eingetragene Adresse, Zwischenlager statt Live-Abfrage, Hintergrunddienst je Mandant (`onApplicationBootstrap`, Tender-Muster), Einstellungsseite mit Verbindungstest, Modulseite, Doku. Zugang wahlweise API-Token oder Benutzer/Passwort; **PMG nur Passwort** (Recherche A1: PMG kennt offenbar keine Token). Kopfzeilen-Formate unterscheiden sich je Produkt (`PVEAPIToken=…=…` vs. `PBSAPIToken=…:…`) und liegen an EINER Stelle. **Riegel „nur lesen“ maschinell erzwungen:** `proxmox-nur-lesen.spec.ts` zaehlt die nicht-lesenden Aufrufe gegen eine benannte Konstante — einzige Ausnahme ist die Ticket-Anmeldung. **Keine SSRF-Adresssperre** (Proxmox steht per Definition im internen Netz, eine Sperre wuerde jede echte Adresse blockieren) — Schutz ist, dass nur ein Administrator Adressen eintraegt. **Rundgang gegen einen selbst gebauten Proxmox-Nachbau** (HTTPS, selbstsigniert, echte Antwortformen): Modul im Marktplatz freigeben, Server anlegen, Zertifikatsfehler korrekt benannt, nach gesetzter Ausnahme „Verbindung erfolgreich“, Zahlen der Modulseite exakt wie im Nachbau (18/42 % Last, 3 laufend / 1 gestoppt), unerreichbarer Server meldet „Der Server ist nicht erreichbar“. **Drei Befunde daraus in 260923-ku6 behoben.** **Ein Befund der Abnahme OFFEN:** `sumOrNull` in `normalizePmg` liefert bei EINEM fehlenden Teilwert die halbe Summe statt `null` — stiller Falschwert genau dort, wo die Feldnamen am schlechtesten belegt sind. **Zahlen:** api 1240 → 1311 Tests, web 693 → 708, type-check 4/4, lint 5/5, 53 Warnungen unveraendert. | 2026-09-23 | 3a1bfd9,4f8a368,998aba9,fccaf8d,723cf68,06fcdc0,3091b04 | [260923-dhh-proxmox-modul-pve-pbs-und-pmg-anbinden-n](./quick/260923-dhh-proxmox-modul-pve-pbs-und-pmg-anbinden-n/) |
|
||||
| 260923-ku6 | **Drei Befunde aus dem Proxmox-Rundgang behoben.** (1) „Verbindung testen“ pruefte den GESPEICHERTEN Stand statt der Eingabe — wer den Zugang tippt und vor dem Speichern testet, bekam die Antwort zum alten Wert; jetzt eigene Route `POST servers/test` mit Merge-Regel: normale Felder folgen dem Formular (auch geleert), Geheimnisfelder folgen „leer → gespeicherten Wert behalten“, weil das Formular Geheimnisse nie vorbefuellt. (2) Ein frisch angelegter Server zeigte „Ein unerwarteter Fehler ist aufgetreten“, obwohl nur noch nichts abgefragt war — jetzt eigener ruhiger Zustand mit Verweis auf „Jetzt aktualisieren“. (3) Die Klasse `uppercase` faerbte die ganze Zeile und zeigte die Adresse als „HTTPS://…“ — jetzt nur noch das Produktkuerzel. **Zahlen:** api 1311 → 1316, web 708 → 712, 53 Warnungen gehalten (eine neu ausgeloeste `useOptionalChain`-Warnung gleich mit aufgeloest). | 2026-09-23 | 710034c,f1bb7f7 | [260923-ku6-drei-nachbesserungen-aus-dem-browser-run](./quick/260923-ku6-drei-nachbesserungen-aus-dem-browser-run/) |
|
||||
| 260923-ku6 | **Drei Nachbesserungen aus dem Browser-Rundgang zu 260923-dhh (Proxmox-Modul).** Befund 1 (wichtig): „Verbindung testen" pruefte den gespeicherten Server statt des Formulars — im Formular abgeschaltete Zertifikatspruefung oder ein neu eingetipptes Geheimnis griffen erst nach dem Speichern. Fix: neues `TestProxmoxServerDto` + Merge-Baustein `resolveEffectiveTestServer` in `ProxmoxService`, neue Route `POST servers/test` fuer die Neuanlage (noch kein gespeicherter Server), Geheimnisfelder behalten die bestehende „leer gelassen -> gespeicherten Wert weiterverwenden"-Regel. Befund 2 (wichtig): ein frisch angelegter, nie abgefragter Server zeigte faelschlich „Ein unerwarteter Fehler ist aufgetreten" statt eines ruhigen Hinweises — behoben ueber `status.lastPolledAt === null`. Befund 3 (kosmetisch): `uppercase` faerbte die ganze Statuszeile inkl. Adresse gross — jetzt nur noch das Produktkuerzel. **Zahlen:** api 1311 → 1316, web 708 → 712, type-check 4/4, lint 5/5, Biome web 53 Warnungen unveraendert. | 2026-09-23 | 710034c,f1bb7f7 | [260923-ku6-drei-nachbesserungen-aus-dem-browser-run](./quick/260923-ku6-drei-nachbesserungen-aus-dem-browser-run/) |
|
||||
| 260923-le6 | **Zwei Abnahmebefunde zum Proxmox-Modul behoben.** (1) `sumOrNull` in `normalizePmg` liefert jetzt `null`, sobald EIN Teilwert (Spam/Viren je Richtung) fehlt — vorher stille Teilsumme als vollstaendige Zahl (Blocker aus 260923-dhh-VERIFICATION, Wahrheit 7). (2) „Jetzt aktualisieren“ nur noch fuer ADMIN/SUPER_ADMIN sichtbar (Endpunkt verlangte das schon); `ServerCard` bekommt `isAdmin`, Nicht-Admins lesen bei nie abgefragtem Server „Die Werte erscheinen nach der naechsten automatischen Abfrage“ statt eines Verweises auf den Knopf. Neuer Seitentest `proxmox-page-roles.test.tsx` (5 Rollenfaelle). Offener Randfall: inaktiver, nie abgefragter Server — Text passt dort nicht ganz, Nutzerentscheidung. Proxmox-Tests api 82, web 26 gruen; Typpruefung beider Seiten fehlerfrei; Biome ohne neue Befunde. | 2026-09-23 | c13d657,2eb86e1,2f8dd14,e1b191b | [260923-le6-proxmox-abnahmebefunde-sumornull-null-be](./quick/260923-le6-proxmox-abnahmebefunde-sumornull-null-be/) |
|
||||
|
||||
## Deferred Items
|
||||
|
||||
@@ -502,8 +510,8 @@ sind. Kein Anlass, sie vorher erneut vorzulegen.
|
||||
|
||||
## Session Continuity
|
||||
|
||||
Last session: 2026-09-22T12:15:00Z
|
||||
Resumed: 2026-09-21 (abends) ueber /gsd-resume-work; danach Bilderrahmen, XFrame, Kosmetik, Tray-Update-Befund, Download-Knoepfe, XFrame-Ausschnitt.
|
||||
Stopped at: Alles gebaut, nachgewiesen, gepusht; alpha gezogen und vom Nutzer bestaetigt. Basic-Auth vor alpha bleibt (Nutzerentscheidung, intern Ausnahme) — kein offener Punkt. Offen beim Nutzer: neuen Client einmal per Browser installieren; Freigabe 1.3.0 auf Zuruf.
|
||||
Last session: 2026-09-22T13:40:00Z
|
||||
Resumed: 2026-09-21 (abends) ueber /gsd-resume-work; seitdem Bilderrahmen, XFrame (inkl. Ausschnitt), Desktop-Korrekturen, Freigabe 1.3.0, Bilder in den Dateibereich.
|
||||
Stopped at: hk4 fertig und nachgewiesen. Dem Nutzer vorgelegt: erst das Aufraeumen (Modul bringt seine Kachel selbst mit), dann Proxmox-Modul + Kachel — Antwort steht aus.
|
||||
Resume file: None
|
||||
Last activity: 2026-09-22 - Quick 260922-ge2: XFrame-Ausschnitt waehlen und einpassen, Zoom, Nur anzeigen; auf alpha bestaetigt
|
||||
Last activity: 2026-09-22 - Quick 260922-hk4: Bilderrahmen-Bilder im Dateibereich, Selbstheilung aus der alten Spalte
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
---
|
||||
phase: quick-260922-hk4
|
||||
plan: 01
|
||||
type: tdd
|
||||
autonomous: true
|
||||
subsystem: apps/api/src/dashboard
|
||||
requirements: []
|
||||
---
|
||||
|
||||
# Quick-Aufgabe 260922-hk4: Bilderrahmen-Bilder auf die Festplatte statt in die Datenbank
|
||||
|
||||
## Warum (Entscheidung des Nutzers, 22.09.2026)
|
||||
|
||||
Der Bilderrahmen legte die Bilddaten als `bytea` in der Datenbank ab
|
||||
(quick-260921-pi9). Der Nutzer hat nach der Freigabe 1.3.0 gefragt, ob das auf
|
||||
Dauer sinnvoll ist. Befund und Entscheidung:
|
||||
|
||||
- **Geschwindigkeit ist NICHT das Argument.** Ein Bild wird je Browser einmal
|
||||
taeglich geladen (`Cache-Control: private, max-age=86400`); ein paar hundert
|
||||
Kilobyte aus Postgres kosten nichts gegen die uebrige Last.
|
||||
- **Die Sicherung ist das Argument.** Gesichert wird von Hand per `pg_dump`
|
||||
(docs/anleitung-betrieb.md Kap. 6). Jedes Bild waechst in diesen Abzug hinein:
|
||||
30 Bilder à 5 MiB je Benutzer sind im Extremfall 150 MB **pro Benutzer**. Die
|
||||
alpha-Datenbank ist heute 18 MB gross (gemessen 22.09.), da faellt das sofort auf.
|
||||
- **Einheitlichkeit.** Tessera speichert Dateien laengst im Volume `user-files`:
|
||||
Profilbilder unter `user-files/avatars/<userId>.<ext>` mit `User.avatarPath` in
|
||||
der Datenbank (`user.controller.ts`), DKV-Exporte daneben mit ausschliesslich
|
||||
servergenerierten Dateinamen (`dkv-export.service.ts`). Der Bilderrahmen war der
|
||||
Ausreisser.
|
||||
- **Ein eigener Ordner je Benutzer ist KEIN Schutz.** Wer welches Bild sehen darf,
|
||||
entscheidet weiterhin der Server (Besitzpruefung + RLS-Regel). Getrennte Ordner
|
||||
bringen zusaetzlich die Gefahr von Dateinamen, die aus dem Ordner herausfuehren —
|
||||
dagegen hilft nur, was DKV schon macht: der Server vergibt den Dateinamen, nie
|
||||
der Client.
|
||||
|
||||
Bestand: alpha 3 Bilder / 1,8 MB, Live 0 (noch nicht gezogen), lokal 1–2. Der
|
||||
Umzug ist jetzt praktisch kostenlos.
|
||||
|
||||
## Gebundene Entscheidungen (Orchestrator)
|
||||
|
||||
1. **Ablage:** `user-files/dashboard-images/<userId>/<imageId>.<ext>` — ein Ordner
|
||||
je Benutzer, Dateiname ist die UUID der Datenbankzeile plus Endung aus dem
|
||||
ERKANNTEN Mime-Typ (`png|jpg|gif|webp`). Kein Byte aus der Anfrage geht in den
|
||||
Pfad. Verzeichnis-Aufloesung nach dem Muster `resolveAvatarsDir()`
|
||||
(`path.resolve(__dirname, '..', '..', '..', '..', 'user-files', ...)`), als
|
||||
eigene Funktion `resolveDashboardImagesDir()` im Dienst.
|
||||
2. **Datenbank:** Spalte `data Bytes` entfaellt, neu `storagePath String` (relativ
|
||||
zur Monorepo-Wurzel, wie `User.avatarPath`: `user-files/dashboard-images/...`).
|
||||
Rest der Zeile unveraendert (id, userId, tenantId, originalName, mimeType, size,
|
||||
createdAt), RLS-Regel und Indizes bleiben.
|
||||
3. **Migration `20260922120000_dashboard_image_to_disk`** in zwei Schritten, weil
|
||||
die vorhandenen Bytes nicht verloren gehen duerfen:
|
||||
- SQL-Migration: `ALTER TABLE "DashboardImage" ADD COLUMN "storagePath" TEXT;`
|
||||
(erst NULLbar), **nicht** sofort `DROP COLUMN "data"`.
|
||||
- Einmal-Skript `apps/api/scripts/migrate-dashboard-images-to-disk.ts`
|
||||
(ausfuehrbar per `pnpm --filter @tessera/api exec tsx scripts/...`, tsx ist
|
||||
vorhanden — sonst `ts-node`/kompiliertes JS; pruefen): liest alle Zeilen mit
|
||||
`data IS NOT NULL`, schreibt die Datei, setzt `storagePath`, laesst `data`
|
||||
stehen. Idempotent (vorhandene Datei + gesetzter `storagePath` = ueberspringen).
|
||||
- Zweite SQL-Migration `20260922120100_dashboard_image_drop_data`:
|
||||
`ALTER TABLE "DashboardImage" ALTER COLUMN "storagePath" SET NOT NULL;` und
|
||||
`ALTER TABLE "DashboardImage" DROP COLUMN "data";`.
|
||||
**Reihenfolge fuer den Betrieb dokumentieren:** beide Migrationen laufen beim
|
||||
Start automatisch (`migrate deploy`), das Umzugs-Skript liegt DAZWISCHEN. Damit
|
||||
das ohne Handarbeit klappt, macht der Dienst den Umzug selbst: siehe Punkt 4.
|
||||
4. **Automatischer Umzug beim Start statt Handarbeit** (der Nutzer soll nichts
|
||||
ausfuehren muessen): `DashboardImagesService` bekommt `onApplicationBootstrap()`,
|
||||
das alle Zeilen ohne `storagePath` einsammelt, die Bytes per rohem SQL liest
|
||||
(`$queryRaw` auf `data`, weil die Spalte dann nicht mehr im Prisma-Modell steht —
|
||||
deshalb liegt der DROP in einer SPAETEREN Migration, die erst in der naechsten
|
||||
Freigabe scharf geschaltet wird), die Datei schreibt und `storagePath` setzt.
|
||||
**Konsequenz fuer diese Aufgabe: die DROP-Migration wird NICHT mitgeliefert.**
|
||||
Sie bekommt einen Platzhalter-Eintrag in `.planning/todos/pending/` und kommt,
|
||||
wenn alle Server einmal mit dieser Version gelaufen sind. Begruendung im
|
||||
Migrations-Kommentar festhalten (Muster: zweistufige Umstellung).
|
||||
Der Bootstrap laeuft ueber den Systemkontext (`forSystem()`, Muster
|
||||
`dkv`-Scheduler), nicht ueber einen Mandantenklienten, und protokolliert
|
||||
„N Bilder auf die Festplatte umgezogen" bzw. schweigt bei 0.
|
||||
5. **Dienst:** `upload` schreibt die Datei (`fs.promises.mkdir(..., {recursive:true})`
|
||||
+ `writeFile`) NACH dem erfolgreichen `create` (Reihenfolge: Zeile zuerst, damit
|
||||
die UUID feststeht; schlaegt das Schreiben fehl, Zeile wieder loeschen und
|
||||
`InternalServerErrorException`). `getBytes` liest die Datei und liefert
|
||||
`{ mimeType, data }` wie bisher; fehlt die Datei, `NotFoundException` (Kachel
|
||||
zeigt dann „Bild nicht verfügbar", schon gebaut). `remove` loescht Zeile und
|
||||
Datei (Datei-Fehler werden geschluckt und protokolliert — eine Dateileiche ist
|
||||
harmloser als eine haengende Loeschung). `list` unveraendert.
|
||||
Der Controller bleibt unveraendert (gleiche Routen, gleiche fuenf Header).
|
||||
6. **Betriebsanleitung:** in Kapitel 6 den Satz zu `user-files` um die
|
||||
Bilderrahmen-Bilder ergaenzen (dort steht schon, wie das Volume gesichert wird);
|
||||
im Anwenderhandbuch nichts aendern (fuer Anwender aendert sich nichts).
|
||||
CHANGELOG unter „Unveröffentlicht → Geändert": „Bilderrahmen: hochgeladene
|
||||
Bilder liegen jetzt im Dateibereich des Servers statt in der Datenbank — die
|
||||
Datenbanksicherung bleibt dadurch klein; vorhandene Bilder ziehen beim ersten
|
||||
Start automatisch um" (kein Fliesstext).
|
||||
7. **Tests:** Dienst-Tests mit `memfs` ODER einem temporaeren Verzeichnis
|
||||
(`fs.mkdtempSync(os.tmpdir())`) — pruefen, was im Repo schon genutzt wird
|
||||
(`user.controller.spec.ts` fuer Avatare ansehen und demselben Muster folgen).
|
||||
Mindestens: Upload legt Datei unter `<dir>/<userId>/<id>.png` an und speichert
|
||||
`storagePath`; Upload mit fehlschlagendem Schreiben loescht die Zeile wieder;
|
||||
`getBytes` liefert den Dateiinhalt; fehlende Datei → 404; fremder Benutzer → 404
|
||||
(unveraendert); `remove` loescht Zeile und Datei; Dateiname enthaelt NIE
|
||||
`originalName`; Bootstrap-Umzug schreibt Datei und setzt `storagePath`,
|
||||
ueberspringt bereits umgezogene Zeilen.
|
||||
|
||||
## Aufgaben
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer" tdd="true">
|
||||
<name>Aufgabe 1: Schema, Migration, Dienst auf Dateiablage umstellen, Bootstrap-Umzug, Tests</name>
|
||||
<files>apps/api/prisma/schema.prisma, apps/api/prisma/migrations/20260922120000_dashboard_image_to_disk/migration.sql, apps/api/src/dashboard/dashboard-images.service.ts, apps/api/src/dashboard/dashboard-images.service.spec.ts, apps/api/src/dashboard/dashboard-images.controller.spec.ts, docs/mandantentrennung-zugriffsklassifikation.md</files>
|
||||
<action>
|
||||
Entscheidungen 1–5 umsetzen. Reihenfolge: Schema + Migration, `prisma migrate deploy` + `generate` gegen die lokale Container-DB ([BLOCKING], Befehle in den Executor-Hinweisen), dann Tests rot, dann Dienst.
|
||||
Das Klassifikationsdokument braucht keine neue Zeile (Modell unveraendert gebunden), aber die Begruendungsspalte erwaehnt jetzt, dass die Bytes auf der Platte liegen und die Zeile den Pfad haelt — Zahlen nachmessen wie dort beschrieben.
|
||||
Commit: `refactor(quick-260922-hk4): Bilderrahmen-Bilder in user-files statt in der Datenbank`
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/api exec vitest run src/dashboard src/prisma && pnpm --filter @tessera/api exec tsc --noEmit && pnpm --filter @tessera/api lint</automated>
|
||||
</verify>
|
||||
<done>Migration angewendet, `storagePath` gefuellt fuer die vorhandenen lokalen Zeilen (Bootstrap nachgewiesen), Dateien liegen unter `user-files/dashboard-images/<userId>/`. Spalte `data` bleibt vorerst bestehen (zweistufig, siehe Plan). API-Tests ≥ 8 neue Faelle, RLS-Waechter unveraendert gruen. Keine `any`, Zaehler unveraendert.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Aufgabe 2: Changelog, Betriebsanleitung, Todo fuer die DROP-Migration, Voll-Tore</name>
|
||||
<files>CHANGELOG.md, docs/anleitung-betrieb.md, .planning/todos/pending/2026-09-22-dashboard-image-data-spalte-entfernen.md</files>
|
||||
<action>
|
||||
Entscheidung 6 umsetzen. Das Todo nennt: DROP der Spalte `data` erst, wenn alpha UND live einmal mit einer Version ≥ dieser gelaufen sind (Bootstrap-Umzug erledigt), Migrationsname `20260922120100_dashboard_image_drop_data`, plus `ALTER COLUMN "storagePath" SET NOT NULL`.
|
||||
Volle Tore: `pnpm type-check`, `pnpm lint`, `pnpm --filter @tessera/api test`, `pnpm --filter @tessera/web test`.
|
||||
Commit: `docs(quick-260922-hk4): Changelog, Betriebsanleitung und Todo zur data-Spalte`
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && grep -q 'Dateibereich' CHANGELOG.md && pnpm type-check && pnpm lint && pnpm --filter @tessera/api test</automated>
|
||||
</verify>
|
||||
<done>Changelog-Zeile steht unter „Unveröffentlicht → Geändert"; Betriebsanleitung Kap. 6 nennt die Bilderrahmen-Bilder beim `user-files`-Volume; Todo angelegt; alle Tore gruen; genau zwei Commits mit Scope `quick-260922-hk4`.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
## Hinweise fuer den Executor
|
||||
|
||||
- Lokale Migration: `IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' tessera-ctl-db-1)`, dann
|
||||
`DATABASE_URL="postgresql://tessera:tessera_dev@$IP:5432/tessera" pnpm --filter @tessera/api exec prisma migrate deploy` und `prisma generate`.
|
||||
- Testserver NICHT anfassen.
|
||||
- Commits: Conventional Commits, Scope `quick-260922-hk4`, deutscher Betreff im Stil von `git log --oneline -15`, jede Commit-Nachricht endet mit
|
||||
`Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>`
|
||||
- `.planning/**` NICHT committen ausser der Todo-Datei in Aufgabe 2.
|
||||
- Qualitaetsregeln wie bisher: keine neue `any`, `as unknown as` api bleibt 27, keine `!`, kein `biome-ignore`.
|
||||
|
||||
<threat_model>
|
||||
ASVS 1, block on high.
|
||||
|
||||
| ID | Bedrohung | Schwere | Disposition |
|
||||
|---|---|---|---|
|
||||
| T-HK4-01 | Pfad-Ausbruch ueber `originalName` oder Kennung aus der Anfrage | high | Dateiname = UUID der Zeile + Endung aus dem ERKANNTEN Mime-Typ; `originalName` geht nie in den Pfad (Muster DKV T-07-09). Mitigiert. |
|
||||
| T-HK4-02 | Fremdzugriff auf Bilder ueber geratene Pfade | high | Die Datei wird nie direkt ausgeliefert; nur ueber `GET /dashboard/images/:id` mit Besitzpruefung (Mandant + Benutzer) und 404 fuer Fremde. Das Volume ist nicht im Webserver eingehaengt. Mitigiert. |
|
||||
| T-HK4-03 | Datenverlust beim Umzug | high | Zweistufig: `data` bleibt vorerst stehen, Umzug ist idempotent, DROP erst nach nachgewiesenem Lauf auf beiden Servern (Todo). Mitigiert. |
|
||||
| T-HK4-04 | Halbe Zustaende (Zeile ohne Datei / Datei ohne Zeile) | medium | Upload: Zeile zuerst, bei Schreibfehler Zeile loeschen; Loeschen: Zeile zuerst, Dateifehler wird protokolliert (Dateileiche statt haengender Loeschung); fehlende Datei = 404, die Kachel zeigt „Bild nicht verfügbar". Akzeptiert und benannt. |
|
||||
| T-HK4-05 | Volume geht verloren, Datenbank ueberlebt | low | Bewusst akzeptiert (Entscheidung des Nutzers); Betriebsanleitung nennt die Sicherung des Volumes. |
|
||||
</threat_model>
|
||||
+221
@@ -0,0 +1,221 @@
|
||||
---
|
||||
phase: quick-260922-hk4
|
||||
plan: 01
|
||||
subsystem: apps/api/src/dashboard
|
||||
tags: [bilderrahmen, dashboard, user-files, prisma-migration, rls, tdd]
|
||||
status: complete
|
||||
requires: [quick-260921-pi9]
|
||||
provides:
|
||||
- "DashboardImage.storagePath — Bilder im Dateibereich statt als bytea"
|
||||
- "DashboardImagesService.onApplicationBootstrap() — automatischer Umzug beim Start"
|
||||
- "Migration 20260922120000_dashboard_image_to_disk (Stufe 1 von 2)"
|
||||
affects:
|
||||
- apps/api/src/dashboard/dashboard-images.service.ts
|
||||
- apps/api/prisma/schema.prisma
|
||||
- apps/api/src/prisma/rls-access-inventory.spec.ts
|
||||
- docs/mandantentrennung-zugriffsklassifikation.md
|
||||
- docs/anleitung-betrieb.md
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Servergenerierter Dateiname (UUID + Endung aus dem erkannten Mime-Typ), Muster dkv-export.service.ts (T-07-09)"
|
||||
- "Relativer Pfad in der Zeile, Muster User.avatarPath (user.controller.ts)"
|
||||
- "Einmal systemgebunden lesen, je Zeile mandantengebunden schreiben (Muster DkvService.loadActiveConfigsForScheduler)"
|
||||
- "Zweistufige Spaltenablösung: ADD + NULLbar jetzt, DROP nach nachgewiesenem Lauf"
|
||||
- "Dateitests gegen ein echtes Temp-Verzeichnis statt fs-Mock (Muster desktop.service.spec.ts)"
|
||||
key-files:
|
||||
created:
|
||||
- apps/api/prisma/migrations/20260922120000_dashboard_image_to_disk/migration.sql
|
||||
- .planning/todos/pending/2026-09-22-dashboard-image-data-spalte-entfernen.md
|
||||
modified:
|
||||
- apps/api/prisma/schema.prisma
|
||||
- apps/api/src/dashboard/dashboard-images.service.ts
|
||||
- apps/api/src/dashboard/dashboard-images.service.spec.ts
|
||||
- apps/api/src/prisma/rls-access-inventory.spec.ts
|
||||
- docs/mandantentrennung-zugriffsklassifikation.md
|
||||
- docs/anleitung-betrieb.md
|
||||
- CHANGELOG.md
|
||||
decisions:
|
||||
- "data Bytes? bleibt im Prisma-Modell (optional) statt $queryRaw — der Bootstrap-Umzug bleibt dadurch typisiert und ohne rohes SQL; Spalte und Feld fallen gemeinsam in Stufe 2"
|
||||
- "Systemkontext (forSystem) nur im Startpfad; die vier Anfragewege bleiben ausnahmslos mandantengebunden, auch das Schreiben des Umzugs"
|
||||
- "Neue Regel system_read_policy auf DashboardImage, damit der Umzug nach dem Scharfschalten der Datenbankrolle nicht stumm nichts findet"
|
||||
- "Testschalter DASHBOARD_IMAGES_DIR (Muster DESKTOP_DIST_DIR) statt fs-Mock — die Tests schreiben und lesen wirklich"
|
||||
metrics:
|
||||
duration: "~35 min"
|
||||
completed: 2026-09-22
|
||||
actuals:
|
||||
tokens: 21000
|
||||
tasks: 2
|
||||
commits: 2
|
||||
plan_head_before: 441854a
|
||||
---
|
||||
|
||||
# Quick-Aufgabe 260922-hk4: Bilderrahmen-Bilder auf die Festplatte — Summary
|
||||
|
||||
Die Bilder des Bilderrahmen-Widgets liegen jetzt unter
|
||||
`user-files/dashboard-images/<userId>/<id>.<ext>`; die Datenbankzeile hält nur
|
||||
noch den relativen Pfad, und vorhandene Bilder ziehen beim ersten Start
|
||||
automatisch um — nachgewiesen gegen die lokale Datenbank.
|
||||
|
||||
## Was gebaut wurde
|
||||
|
||||
**Aufgabe 1 — Schema, Migration, Dienst, Bootstrap-Umzug, Tests** (`9039cea`)
|
||||
|
||||
- `schema.prisma`: `data Bytes` → `data Bytes?`, neu `storagePath String?`.
|
||||
- Migration `20260922120000_dashboard_image_to_disk`: `ADD COLUMN "storagePath"`,
|
||||
`ALTER COLUMN "data" DROP NOT NULL`, dazu `system_read_policy … FOR SELECT`
|
||||
auf `"DashboardImage"`. **Kein DROP** — die Begründung steht im
|
||||
Migrationskopf (Stufe 1 von 2, T-HK4-03).
|
||||
- `DashboardImagesService`:
|
||||
- `upload` legt die Zeile an (erst danach steht die UUID fest), schreibt die
|
||||
Datei, trägt `storagePath` nach; scheitert das Schreiben, wird die Zeile
|
||||
zurückgenommen und 500 geworfen.
|
||||
- `getBytes` liest die Datei; fehlender Pfad oder fehlende Datei → 404.
|
||||
- `remove` löscht Zeile und Datei (Dateifehler wird protokolliert, nicht
|
||||
geworfen).
|
||||
- `onApplicationBootstrap()` zieht Altbestand um: **einmal systemgebunden
|
||||
lesen** (`forSystem`, Zeilen ohne `storagePath` über alle Mandanten),
|
||||
**je Zeile mandantengebunden schreiben** (`forTenant(prisma, row.tenantId,
|
||||
row.userId)`), Log „N Bilderrahmen-Bilder auf die Festplatte umgezogen",
|
||||
still bei 0, wiederholbar.
|
||||
- Dateiname IMMER servergeneriert; `absoluteImagePath()` weist jeden Pfad
|
||||
zurück, der nicht im Bilderverzeichnis liegt (T-HK4-01).
|
||||
- Tests: 23 Fälle (11 neu), echtes Temp-Verzeichnis statt `fs`-Mock.
|
||||
- RLS-Wächter und Klassifikationsdokument nachgezogen (siehe Abweichungen).
|
||||
|
||||
**Aufgabe 2 — Changelog, Betriebsanleitung, Todo** (`8cbfb8b`)
|
||||
|
||||
- CHANGELOG „Unveröffentlicht → Geändert" mit der Nutzerzeile.
|
||||
- `docs/anleitung-betrieb.md` Kap. 6: `user-files` nennt die
|
||||
Bilderrahmen-Bilder und hält fest, dass `pg_dump` sie nicht mehr enthält.
|
||||
- Todo `.planning/todos/pending/2026-09-22-dashboard-image-data-spalte-entfernen.md`
|
||||
mit Vorbedingung (`storagePath IS NULL` = 0 auf alpha UND live),
|
||||
Migrationsname `20260922120100_dashboard_image_drop_data` und allen
|
||||
Nacharbeiten an Spec und Klassifikation.
|
||||
|
||||
## TDD-Nachweis (RED → GREEN)
|
||||
|
||||
- **RED** (vor der Umsetzung, `vitest run src/dashboard/dashboard-images.service.spec.ts`):
|
||||
`Tests 12 failed | 11 passed (23)`, u. a.
|
||||
`TypeError: makeService(...).onApplicationBootstrap is not a function`
|
||||
und Erwartungen an `storagePath`, die noch niemand setzte. Die 11 grünen
|
||||
Fälle sind die unveränderten Besitz-/Magic-Byte-Prüfungen aus pi9.
|
||||
- **GREEN** nach dem Dienst: `Tests 23 passed (23)`.
|
||||
- Ein RED war ein Testfehler, kein Dienstfehler: Test 17 („Datei fehlt")
|
||||
nutzte die Kennung `img-1`, für die Test 8/10 im geteilten Temp-Verzeichnis
|
||||
schon eine Datei angelegt hatten — Kennung auf `datei-fehlt` geändert.
|
||||
|
||||
## Nachweis am laufenden System (lokal, kein Testserver)
|
||||
|
||||
- `prisma migrate deploy` gegen die lokale Container-Datenbank: Migration
|
||||
`20260922120000_dashboard_image_to_disk` angewendet, danach `prisma generate`.
|
||||
- `\d "DashboardImage"`: `data` ist jetzt NULLbar, `storagePath text`,
|
||||
Policies `tenant_isolation_policy` + `system_read_policy (FOR SELECT)`.
|
||||
- Bootstrap-Umzug gegen die echte Datenbank ausgeführt (Wegwerf-Spec, danach
|
||||
gelöscht):
|
||||
- vorher: 1 Zeile, `storagePath = null`, 502 Byte in `data`
|
||||
- Log: `1 Bilderrahmen-Bilder auf die Festplatte umgezogen`
|
||||
- nachher: `storagePath = user-files/dashboard-images/1166431d-…/f43be914-….png`
|
||||
- Datei auf der Platte: 502 Byte, `PNG image data, 320 x 200` (`file`)
|
||||
- zweiter Lauf: keine Zeile mehr offen, Datei unverändert (wiederholbar)
|
||||
|
||||
## Tore
|
||||
|
||||
| Tor | Ergebnis |
|
||||
|---|---|
|
||||
| `vitest run src/dashboard src/prisma` | 147 Tests, alle grün |
|
||||
| `pnpm type-check` (4 Pakete) | grün |
|
||||
| `pnpm lint` (5 Pakete) | grün (74 API-/53 Web-Warnungen, alle vorbestehend, keine in den geänderten Dateien) |
|
||||
| `pnpm --filter @tessera/api test` | 75 Dateien, 1186 Tests grün |
|
||||
| `pnpm --filter @tessera/web test` | 81 Dateien, 640 Tests grün |
|
||||
| `as unknown as` in apps/api | 27 (unverändert) |
|
||||
| neue `any` / `!` / `biome-ignore` | keine |
|
||||
|
||||
## Abweichungen vom Plan
|
||||
|
||||
### [Regel 3 — blockierend] Das Klassifikationsdokument brauchte doch eine Änderung
|
||||
|
||||
Der Plan sagte, das Dokument brauche keine neue Zeile. Richtig — eine neue
|
||||
ZEILE nicht, aber der `forSystem()`-Aufruf im Startpfad ändert den gemessenen
|
||||
**Stand** des Paars `dashboard-images.service.ts`/`dashboardImage` von
|
||||
`gebunden` auf `system-gebunden`, und `rls-access-inventory.spec.ts` prüft
|
||||
genau diesen Wert. Zwei Tests waren rot, bis nachgezogen war:
|
||||
|
||||
- `FORSYSTEM_ALLOWED_CALL_SITES` (die Liste ist ein „genau", kein
|
||||
„mindestens") um `apps/api/src/dashboard/dashboard-images.service.ts` = 1
|
||||
erweitert, mit Begründung im Kopfkommentar: Startpfad, kein Anfrageweg;
|
||||
geschrieben wird auch dort mandantengebunden. Präzedenz:
|
||||
`ldap-config.service.ts`, dessen Nachverschlüsselung in
|
||||
`onApplicationBootstrap()` genauso gebaut ist.
|
||||
- Klassifikationsdokument: Stand `system-gebunden` mit Begründung, Zahlen der
|
||||
Bereichszeile `dashboard` mit derselben Gate-Schleife nachgemessen
|
||||
(1/18/0 → 1/21/1; +3 gebunden = Nachtragen von `storagePath`, Rücknahme bei
|
||||
Schreibfehler, Nachtragen im Umzug), Summe 187/5 → 190/6.
|
||||
|
||||
Beides ist im Todo für Stufe 2 als Rückbau vermerkt.
|
||||
|
||||
### [Regel 2 — fehlende kritische Funktionalität] `ALTER COLUMN "data" DROP NOT NULL`
|
||||
|
||||
Der Plan nannte nur `ADD COLUMN "storagePath"`. Ohne das Lockern der
|
||||
NOT-NULL-Bedingung wäre jeder neue Upload an der Datenbank gescheitert, weil
|
||||
er keine Bytes mehr in die Zeile schreibt.
|
||||
|
||||
### [Regel 2 — fehlende kritische Funktionalität] `system_read_policy` auf `"DashboardImage"`
|
||||
|
||||
Nicht im Plan. Ohne diese Regel sähe der systemgebundene Umzug nach dem
|
||||
Scharfschalten der Datenbankrolle NULL Zeilen und stellte die Arbeit stumm
|
||||
ein — genau die Falle, die Migration 20260914120000 für die fünf
|
||||
Hintergrunddienst-Tabellen geschlossen hat. Permissiv, nur `FOR SELECT`;
|
||||
Schreiben bleibt allein der Mandantenregel unterstellt.
|
||||
|
||||
### [Entscheidung] Testschalter `DASHBOARD_IMAGES_DIR`
|
||||
|
||||
Der Plan ließ die Wahl zwischen `memfs` und einem Temp-Verzeichnis. Gewählt:
|
||||
Temp-Verzeichnis (keine neue Abhängigkeit), erreichbar über die
|
||||
Umgebungsvariable `DASHBOARD_IMAGES_DIR` — dasselbe Muster, das
|
||||
`desktop.service.ts` mit `DESKTOP_DIST_DIR` schon nutzt. Im Betrieb nie
|
||||
gesetzt; ohne sie gilt der Pfad unter der Monorepo-Wurzel.
|
||||
|
||||
## Known Stubs
|
||||
|
||||
Keine.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
Keine neue Angriffsfläche über den `<threat_model>` des Plans hinaus. Der
|
||||
einzige neue Dateipfad-Umgang ist vollständig servergeneriert und zusätzlich
|
||||
containment-geprüft (`absoluteImagePath`).
|
||||
|
||||
## Von Hand zu prüfen (nach dem nächsten `--build`-Deploy)
|
||||
|
||||
1. Bild im Bilderrahmen-Widget hochladen → erscheint in der Kachel und in der
|
||||
Verwaltung unter Einstellungen → Dashboard.
|
||||
2. Auf dem Server nachsehen:
|
||||
`docker compose exec api ls -R /app/user-files/dashboard-images` — je
|
||||
Benutzer ein Ordner, Dateiname eine UUID mit `.png`/`.jpg`/`.gif`/`.webp`,
|
||||
nie der Originalname.
|
||||
3. Bild löschen → verschwindet aus der Kachel UND die Datei ist weg
|
||||
(`ls` wie oben).
|
||||
4. Nach dem ersten Start mit dieser Version:
|
||||
`docker compose logs api | grep umgezogen` — die Zeile „N
|
||||
Bilderrahmen-Bilder auf die Festplatte umgezogen" steht genau einmal; ein
|
||||
zweiter Neustart schweigt.
|
||||
5. `docker compose exec db psql -U tessera -d tessera -c 'SELECT count(*) FROM "DashboardImage" WHERE "storagePath" IS NULL;'`
|
||||
→ muss `0` sein (Vorbedingung für Stufe 2, siehe Todo).
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- `apps/api/prisma/migrations/20260922120000_dashboard_image_to_disk/migration.sql` — vorhanden
|
||||
- `apps/api/src/dashboard/dashboard-images.service.ts` — vorhanden
|
||||
- `.planning/todos/pending/2026-09-22-dashboard-image-data-spalte-entfernen.md` — vorhanden
|
||||
- Commit `9039cea` — vorhanden
|
||||
- Commit `8cbfb8b` — vorhanden
|
||||
|
||||
## Rundgang durch den Orchestrator (22.09.2026, lokaler Stack, Abbilder aus dem Commit danach)
|
||||
|
||||
Bestanden, und dabei EIN Befund gefunden und behoben (eigener Commit):
|
||||
|
||||
- Hochladen ueber die Oberflaeche legt die Datei unter `user-files/dashboard-images/<userId>/<uuid>.png` im Container-Volume an; die Liste zeigt sie, die Kachel rendert sie.
|
||||
- Loeschen entfernt Zeile UND Datei (3 Dateien/3 Zeilen → 2/2, gemessen im Container und in der Datenbank).
|
||||
- **Befund:** eine Zeile zeigte auf eine Datei, die es im Container nicht gibt — der Bootstrap-Umzug war beim Bauen auf dem HOST gelaufen (Repo-Verzeichnis), der Container hat aber das Volume `user-files`. Lokal ein Artefakt, im Betrieb aber real: wer einen `pg_dump` von VOR dem Umzug zurueckspielt, waehrend das getrennt gesicherte Volume leer ist, haette Zeilen ohne Datei, obwohl die Bytes im Abzug noch stecken.
|
||||
- **Behoben:** `getBytes` schreibt die Datei in diesem Fall aus der noch vorhandenen Spalte `data` neu und liefert sie aus (Protokoll „… aus der Datenbank wiederhergestellt"); fehlt beides, bleibt es bei 404. Nachgewiesen: Abruf lieferte 200/`image/png`/502 Byte, danach lag die Datei im Container. Zwei Tests (10b, 10c), api 1186 → 1188.
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
---
|
||||
phase: quick-260922-m1h
|
||||
plan: 01
|
||||
type: refactor
|
||||
autonomous: true
|
||||
subsystem: apps/web/src/components/dashboard
|
||||
requirements: []
|
||||
---
|
||||
|
||||
# Quick-Aufgabe 260922-m1h: Ein Modul bringt seine Dashboard-Kachel selbst mit
|
||||
|
||||
## Warum (Auftrag des Nutzers, 22.09.2026)
|
||||
|
||||
Als Naechstes kommt ein Proxmox-Modul (PVE/PBS/PMG), das zusaetzlich als
|
||||
kompakte Kachel auf dem Dashboard erscheinen soll — und kuenftig sollen weitere
|
||||
Module dasselbe tun (PBS: Sicherungsstatus, PMG: Mail-Zahlen). Eine
|
||||
Bestandsaufnahme (lesend, 22.09.) hat ergeben:
|
||||
|
||||
- **Ein neuer Widget-Typ ist heute an SIEBEN Stellen hartkodiert**: `WidgetType`
|
||||
(Union), `WIDGET_CONSTRAINTS`, `WIDGET_REGISTRY`, eine eigene `wireXWidget()`
|
||||
je Typ, der Aufruf in `(portal)/page.tsx`, die ZWEITE Liste `WIDGET_TYPES` in
|
||||
`widget-catalog-modal.tsx` und die `@IsIn`-Whitelist in
|
||||
`apps/api/src/dashboard/dto/create-widget.dto.ts`. Vergisst man eine, fehlt die
|
||||
Kachel im Katalog oder die API lehnt sie mit 400 ab.
|
||||
- **Die Verbindung Kachel↔Modul existiert schon, ist aber leer:**
|
||||
`apps/api/src/dashboard/widget-module-map.ts` (`WIDGET_MODULE_MAP = {}`),
|
||||
gelesen von `dashboard.service.ts` — `getWidgets()` filtert Kacheln aus, deren
|
||||
Modul der Benutzer nicht hat (fail-closed, Zeile ~164-205). Das funktioniert,
|
||||
wurde nur nie benutzt.
|
||||
- **Zwei Luecken:** (a) der Katalog („Widget hinzufuegen") zeigt JEDEM alle
|
||||
Kacheln, auch die gesperrter Module — anlegen geht, danach verschwindet die
|
||||
Kachel kommentarlos; (b) eine Kachel mit unbekanntem Typ rendert leer, ohne
|
||||
Erklaerung.
|
||||
|
||||
Diese Aufgabe raeumt das auf, BEVOR Proxmox kommt. Kein neues Modul, keine neue
|
||||
Kachel — reiner Umbau mit unveraendertem Verhalten fuer die neun vorhandenen
|
||||
Kacheln.
|
||||
|
||||
## Gebundene Entscheidungen (Orchestrator)
|
||||
|
||||
1. **Eine Quelle fuer die Typliste, geteilt zwischen Web und API.** In
|
||||
`packages/shared/src/index.ts` (wird von beiden Apps bereits importiert, z. B.
|
||||
`desktop.service.ts`, `apps/web/src/lib/app-version.ts`) kommt:
|
||||
```ts
|
||||
export const WIDGET_TYPES = ['clock','search','calendar','note','calculator','favorites','stopwatch','picture-frame','xframe'] as const;
|
||||
export type WidgetType = (typeof WIDGET_TYPES)[number];
|
||||
/** Kachel → Modul-Slug; eine Kachel ohne Eintrag ist immer sichtbar. */
|
||||
export const WIDGET_MODULE_SLUGS: Partial<Record<WidgetType, string>> = {};
|
||||
```
|
||||
`create-widget.dto.ts` validiert mit `@IsIn([...WIDGET_TYPES])`, das Frontend
|
||||
leitet `WidgetType` von dort ab. `widget-module-map.ts` behaelt seine
|
||||
oeffentliche Funktion `getModuleSlugForWidgetType()`, liest aber
|
||||
`WIDGET_MODULE_SLUGS` aus `@tessera/shared` statt einer eigenen Kopie
|
||||
(Kommentar: eine Tabelle fuer beide Seiten, damit Katalogfilter und
|
||||
Server-Filter nicht auseinanderlaufen).
|
||||
2. **Eine Anmeldestelle je Kachel.** Statt neun `wireXWidget()`-Funktionen mit je
|
||||
eigenem Bool-Flag ein generisches `registerWidget(type, component)` in
|
||||
`widget-registry.tsx`; `(portal)/page.tsx` ruft es je Kachel einmal auf (die
|
||||
Datei bleibt die Stelle, an der die Komponenten importiert werden — der
|
||||
Zirkelimport-Grund aus dem Bestandskommentar gilt weiter, also NICHT die
|
||||
Komponenten direkt in der Registry importieren). Mehrfachanmeldung desselben
|
||||
Typs ist ein No-Op (wie die bisherigen Flags); Anmeldung eines unbekannten
|
||||
Typs wirft in der Entwicklung und wird in der Produktion ignoriert.
|
||||
3. **`WIDGET_REGISTRY` bekommt `moduleSlug?: string`** je Eintrag, befuellt aus
|
||||
`WIDGET_MODULE_SLUGS`. Heute bleibt es fuer alle neun Kacheln leer.
|
||||
4. **Der Katalog leitet seine Liste aus der Registry ab** (`Object.keys` in der
|
||||
Reihenfolge der Registry-Definition, die heutige Reihenfolge bleibt erhalten —
|
||||
Test darauf) und **filtert nach Modulzugriff**: `widget-catalog-modal.tsx`
|
||||
bekommt eine Liste der zugaenglichen Modul-Slugs als Prop von der Seite, die
|
||||
sie ueber den vorhandenen Weg `/modules/active` holt (Muster
|
||||
`apps/web/src/components/layout/sidebar.tsx` — dort wird genau dieser Endpunkt
|
||||
schon gefetcht; dieselbe Hilfsfunktion nutzen, nicht neu bauen). Eine Kachel
|
||||
ohne `moduleSlug` ist immer sichtbar; eine mit `moduleSlug` nur, wenn der Slug
|
||||
in der Liste steht. Schlaegt der Abruf fehl, werden Kacheln MIT `moduleSlug`
|
||||
ausgeblendet (fail-closed, wie serverseitig).
|
||||
5. **Gesperrte/unbekannte Kachel erklaert sich.** `widget-wrapper.tsx` rendert
|
||||
heute nichts, wenn `definition?.component` fehlt. Neu: ein zentrierter grauer
|
||||
Hinweistext `widgets.unavailable` („Diese Kachel steht nicht zur Verfügung —
|
||||
das zugehörige Modul ist nicht freigegeben.") in de und en. Der Fall tritt
|
||||
erst mit Proxmox real auf, ist aber ab jetzt abgedeckt.
|
||||
6. **Verhalten der neun vorhandenen Kacheln aendert sich NICHT.** Gleiche Namen,
|
||||
gleiche Reihenfolge im Katalog, gleiche Groessenvorgaben, gleiche Einstellungen.
|
||||
Der Einstellungs-Zweig je Typ in `widget-settings-panel.tsx` bleibt wie er ist —
|
||||
den generisch zu machen waere ein eigener Umbau und gehoert NICHT in diese
|
||||
Aufgabe (im SUMMARY als bewusst offen gelassen nennen).
|
||||
7. Keine neuen Abhaengigkeiten. Keine Aenderung an der Datenbank.
|
||||
|
||||
## Aufgaben
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Aufgabe 1: Typliste nach @tessera/shared, generische Anmeldung, Katalog aus der Registry</name>
|
||||
<files>packages/shared/src/index.ts, apps/api/src/dashboard/dto/create-widget.dto.ts, apps/api/src/dashboard/widget-module-map.ts, apps/api/src/dashboard/widget-module-map.spec.ts, apps/web/src/components/dashboard/widget-registry.tsx, apps/web/src/components/dashboard/widget-registry.test.tsx, apps/web/src/app/(portal)/page.tsx, apps/web/src/app/(portal)/page.test.tsx, apps/web/src/components/dashboard/widget-catalog-modal.tsx, apps/web/src/components/dashboard/widget-catalog-modal.test.tsx</files>
|
||||
<action>
|
||||
Entscheidungen 1-4 umsetzen. Reihenfolge: shared zuerst (beide Apps bauen dagegen), dann API-DTO und `widget-module-map.ts`, dann Registry + `registerWidget`, dann `page.tsx`, zuletzt der Katalog.
|
||||
Tests zuerst anpassen/ergaenzen, wo sie die alten Namen festhalten (`widget-registry.test.tsx` prueft heute die Typliste und die Constraints-Tabelle; `widget-catalog-modal.test.tsx` die Eintraege). Neu mindestens: Katalogreihenfolge entspricht der Registry-Reihenfolge; eine Kachel mit `moduleSlug` fehlt im Katalog, wenn der Slug nicht in den zugaenglichen Modulen steht, und erscheint, wenn doch; fehlgeschlagener Modulabruf blendet Kacheln mit `moduleSlug` aus; `registerWidget` ist idempotent; `WIDGET_TYPES` aus shared und die Registry-Schluessel sind deckungsgleich (ein Test, der kuenftig jede vergessene Stelle faengt).
|
||||
Fuer den Katalog-Test eine Kachel mit `moduleSlug` brauchen, ohne eine echte zu erfinden: die Registry im Test per Hilfsfunktion um einen Testeintrag erweitern ODER den Filter als reine Funktion `visibleWidgetTypes(registry, accessibleSlugs | null)` auslagern und diese direkt testen — die reine Funktion ist vorzuziehen (Muster `picture-frame-config.ts`).
|
||||
Commit: `refactor(quick-260922-m1h): Widget-Typen an einer Stelle, Katalog aus der Registry, Kachel kennt ihr Modul`
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/web exec vitest run src/components/dashboard "src/app/(portal)/page.test.tsx" && pnpm --filter @tessera/api exec vitest run src/dashboard && pnpm type-check && pnpm lint</automated>
|
||||
</verify>
|
||||
<done>`WIDGET_TYPES`/`WidgetType`/`WIDGET_MODULE_SLUGS` stehen in `packages/shared`; API-DTO und Web leiten davon ab; genau EINE `registerWidget`-Funktion (kein `wireXWidget` mehr); Katalogliste kommt aus der Registry (keine zweite Liste); Deckungsgleichheits-Test vorhanden und gruen. Alle bestehenden Tests gruen, Reihenfolge und Namen der neun Kacheln unveraendert.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Aufgabe 2: Gesperrte Kachel erklaert sich, Uebersetzungen, Changelog, Entwicklerdoku</name>
|
||||
<files>apps/web/src/components/dashboard/widgets/widget-wrapper.tsx, apps/web/src/components/dashboard/widgets/widget-wrapper.test.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json, CHANGELOG.md, docs/anleitung-entwicklung.md</files>
|
||||
<action>
|
||||
Entscheidung 5 umsetzen (Hinweistext statt leerer Kachel, Test dafuer), Schluessel `widgets.unavailable` in beiden Sprachdateien.
|
||||
`docs/anleitung-entwicklung.md`: den vorhandenen Modul-Walkthrough (Abschnitt um Zeile 372-400) um einen kurzen Abschnitt „Eine Kachel zum Modul" ergaenzen — welche drei Stellen es NACH diesem Umbau noch sind (Komponente schreiben, `registerWidget` in `page.tsx`, Eintrag in `WIDGET_TYPES` + optional `WIDGET_MODULE_SLUGS` in `packages/shared`, plus Uebersetzungen und Groessenvorgaben) und dass eine Kachel mit `moduleSlug` automatisch aus Katalog und Dashboard verschwindet, wenn das Modul fehlt.
|
||||
CHANGELOG unter „Unveröffentlicht → Geändert": „Dashboard: Kacheln, die zu einem Modul gehören, erscheinen nur noch für Benutzer, die dieses Modul nutzen dürfen; eine nicht mehr freigegebene Kachel erklärt das jetzt, statt leer zu bleiben" (Stichpunkt, kein Fliesstext).
|
||||
Volle Tore am Ende.
|
||||
Commit: `docs(quick-260922-m1h): Hinweis bei gesperrter Kachel, Changelog und Entwicklerdoku`
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/web exec vitest run src/components/dashboard src/messages && pnpm type-check && pnpm lint && pnpm --filter @tessera/api test && pnpm --filter @tessera/web test</automated>
|
||||
</verify>
|
||||
<done>Unbekannter/gesperrter Typ zeigt den Hinweistext (Test); beide Sprachdateien tragen den Schluessel; Changelog-Zeile steht; Entwicklerdoku nennt die verbliebenen Schritte; alle Tore gruen; genau zwei Commits mit Scope `quick-260922-m1h`.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
## Hinweise fuer den Executor
|
||||
|
||||
- HEAD ist `ee2b025`, Arbeitsbaum sauber, Zweig `main`. Version 1.3.0 wurde heute freigegeben; dieser Umbau geht in die naechste Freigabe. Zweig `live` und Tags NICHT anfassen.
|
||||
- `packages/shared` wird von beiden Apps importiert (`@tessera/shared`); pruefen, ob ein Build-Schritt noetig ist (`pnpm --filter @tessera/shared build`?) — turbo erledigt das ueblicherweise, im Zweifel `pnpm build` fuer shared vor dem Typecheck.
|
||||
- Qualitaetsregeln: keine neue `any`, `as unknown as` api 27 / web 6 unveraendert, keine `!`, kein `biome-ignore`, web-Warnungen bleiben 53, api 74.
|
||||
- Commits: Conventional Commits, Scope `quick-260922-m1h`, deutscher Betreff im Stil von `git log --oneline -15`, Commit-Body endet mit
|
||||
`Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>`
|
||||
- `.planning/**` NICHT committen.
|
||||
- Testserver nicht anfassen. Lokaler Docker-Stack laeuft, nicht noetig fuer diese Aufgabe.
|
||||
- SUMMARY nach `/home/vicolab/projects/tessera-ctl/.planning/quick/260922-m1h-dashboard-widgets-ein-modul-bringt-seine/260922-m1h-SUMMARY.md` (`status: complete`), mit: was jetzt noch zu tun ist, um eine Modul-Kachel hinzuzufuegen (die kurze Liste), Abweichungen, Zahlen, und einer kurzen Browser-Pruefliste fuer mich.
|
||||
|
||||
<threat_model>
|
||||
ASVS 1, block on high.
|
||||
|
||||
| ID | Bedrohung | Schwere | Disposition |
|
||||
|---|---|---|---|
|
||||
| T-M1H-01 | Katalogfilter clientseitig = Umgehung moeglich (Kachel per API trotzdem anlegen) | medium | Der Katalogfilter ist Komfort, die Durchsetzung bleibt serverseitig in `dashboard.service.ts` (`getWidgets()` filtert fail-closed) und im Modul-Guard der jeweiligen Daten-Endpunkte. Im Code so kommentieren. Akzeptiert. |
|
||||
| T-M1H-02 | Kachel eines gesperrten Moduls zeigt weiter Daten | high | Daten holt jede Kachel ueber ihre eigenen Modul-Endpunkte, die `@UseModule(slug)` tragen muessen — fuer Proxmox in der naechsten Aufgabe verbindlich. Diese Aufgabe aendert daran nichts und schwaecht nichts ab. |
|
||||
| T-M1H-03 | Typliste in `packages/shared` als neue Vertrauensgrenze | low | Reine Konstantenliste, keine Laufzeitdaten; die API validiert weiterhin mit `@IsIn` gegen genau diese Liste. Mitigiert. |
|
||||
| T-M1H-04 | Fehlender Modulabruf oeffnet den Katalog | medium | Fail-closed: bei Fehler werden Kacheln MIT `moduleSlug` ausgeblendet (Entscheidung 4), Test dafuer. Mitigiert. |
|
||||
</threat_model>
|
||||
+215
@@ -0,0 +1,215 @@
|
||||
---
|
||||
phase: quick-260922-m1h
|
||||
plan: 01
|
||||
subsystem: apps/web/src/components/dashboard
|
||||
tags: [refactor, dashboard, widgets, module-access]
|
||||
status: complete
|
||||
requires: []
|
||||
provides:
|
||||
- "WIDGET_TYPES/WidgetType/WIDGET_MODULE_SLUGS als geteilte Quelle in packages/shared"
|
||||
- "registerWidget() als einzige Anmeldestelle je Kachel"
|
||||
- "visibleWidgetTypes() — Katalogfilter nach Modulzugriff, fail-closed"
|
||||
affects:
|
||||
- apps/api/src/dashboard
|
||||
- apps/web/src/app/(portal)/page.tsx
|
||||
tech-stack:
|
||||
added:
|
||||
- "apps/web haengt jetzt auf @tessera/shared (workspace:*)"
|
||||
patterns:
|
||||
- "erster Laufzeit-Import aus @tessera/shared (bisher nur import type)"
|
||||
key-files:
|
||||
created:
|
||||
- apps/api/src/dashboard/widget-module-map.spec.ts
|
||||
modified:
|
||||
- packages/shared/src/index.ts
|
||||
- apps/web/src/components/dashboard/widget-registry.tsx
|
||||
- apps/web/src/components/dashboard/widget-catalog-modal.tsx
|
||||
- apps/web/src/components/dashboard/widgets/widget-wrapper.tsx
|
||||
- apps/web/src/app/(portal)/page.tsx
|
||||
- apps/api/src/dashboard/dto/create-widget.dto.ts
|
||||
- apps/api/src/dashboard/widget-module-map.ts
|
||||
decisions:
|
||||
- "Typliste als Laufzeit-Konstante in packages/shared statt gespiegelter Kopien — traegt, weil Node 24 rohes TypeScript per Type-Stripping laedt"
|
||||
- "apps/web bekommt die Abhaengigkeit auf @tessera/shared; die frueher dokumentierte Gegenbegruendung war ueberholt"
|
||||
- "Katalogfilter als reine Funktion visibleWidgetTypes(registry, slugs|null) statt Logik im Dialog"
|
||||
metrics:
|
||||
duration: "~70 min"
|
||||
completed: 2026-09-22
|
||||
actuals:
|
||||
tokens: 21000
|
||||
tasks: 2
|
||||
commits: 2
|
||||
plan_head_before: ee2b025
|
||||
---
|
||||
|
||||
# Quick-Aufgabe 260922-m1h: Ein Modul bringt seine Dashboard-Kachel selbst mit — Zusammenfassung
|
||||
|
||||
Die Kachel-Typliste stand an sieben Stellen; sie steht jetzt an einer. Der
|
||||
Katalog fuehrt keine zweite Liste mehr und blendet Kacheln gesperrter Module
|
||||
aus, eine Kachel ohne Bauteil erklaert sich mit einem Satz statt leer zu
|
||||
bleiben. Die neun vorhandenen Kacheln verhalten sich unveraendert.
|
||||
|
||||
## So fuegt man kuenftig eine Modul-Kachel hinzu
|
||||
|
||||
Vorher sieben Stellen, jetzt drei (plus das Uebliche an Text und Maßen):
|
||||
|
||||
1. **Kachel-Komponente schreiben** — `apps/web/src/components/dashboard/widgets/<name>-widget.tsx`,
|
||||
nimmt `WidgetProps` (`instanceId`, `config`, `isEditMode`).
|
||||
2. **Typ eintragen** — in `WIDGET_TYPES` in `packages/shared/src/index.ts`. Gehoert die Kachel zu
|
||||
einem Modul, zusaetzlich `WIDGET_MODULE_SLUGS['<typ>'] = '<modul-slug>'` in derselben Datei.
|
||||
Das ist die einzige Liste — die API validiert per `@IsIn` gegen genau sie.
|
||||
3. **Anmelden** — `registerWidget('<typ>', <Name>Widget)` in `apps/web/src/app/(portal)/page.tsx`.
|
||||
|
||||
Dazu wie bei jeder Oberflaeche: Uebersetzungsschluessel `<typ>.name` und `<typ>.description` unter
|
||||
`widgets` in **de.json und en.json**, ein Inline-SVG-Symbol und die Groessenvorgaben in
|
||||
`WIDGET_CONSTRAINTS` — Symbol und Maße in `widget-registry.tsx`.
|
||||
|
||||
Eine Kachel mit `moduleSlug` verschwindet danach **von selbst** aus Katalog und Dashboard, wenn der
|
||||
Benutzer das Modul nicht nutzen darf. Vergisst man eine der drei Stellen, schlaegt der
|
||||
Deckungsgleichheits-Test in `widget-registry.test.tsx` fehl, statt dass die Kachel im Katalog fehlt
|
||||
oder die API mit 400 antwortet.
|
||||
|
||||
Dieselbe Liste steht als Abschnitt „Eine Kachel zum Modul" in
|
||||
`docs/anleitung-entwicklung.md`.
|
||||
|
||||
## Was gebaut wurde
|
||||
|
||||
**Aufgabe 1 — `56c07c3`** (`refactor`)
|
||||
|
||||
- `packages/shared/src/index.ts`: `WIDGET_TYPES`, `WidgetType`, `WIDGET_MODULE_SLUGS`.
|
||||
- `create-widget.dto.ts`: `@IsIn([...WIDGET_TYPES])` statt handgepflegter Liste.
|
||||
- `widget-module-map.ts`: liest `WIDGET_MODULE_SLUGS` statt einer eigenen Kopie; die oeffentliche
|
||||
Funktion `getModuleSlugForWidgetType()` ist unveraendert, damit `dashboard.service.spec.ts`
|
||||
sie weiter mocken kann.
|
||||
- `widget-registry.tsx`: neun `wireXWidget()` → ein `registerWidget()` (idempotent; unbekannter Typ
|
||||
wirft in der Entwicklung, wird in der Produktion ignoriert). `WidgetDefinition` traegt
|
||||
`moduleSlug?`. Neue reine Funktion `visibleWidgetTypes(registry, slugs|null)`.
|
||||
- `widget-catalog-modal.tsx`: Liste kommt aus der Registry (Reihenfolge erhalten, Test darauf),
|
||||
gefiltert nach Modulzugriff; neue Prop `accessibleModuleSlugs`.
|
||||
- `(portal)/page.tsx`: neun `registerWidget`-Aufrufe; holt `/modules/active` im Muster der
|
||||
Seitenleiste (`credentials: 'include'`, Fehler still) und reicht die Slugs an den Katalog durch.
|
||||
|
||||
**Aufgabe 2 — `8be0725`** (`docs`)
|
||||
|
||||
- `widget-wrapper.tsx`: Kachel ohne Bauteil zeigt `widgets.unavailable` zentriert und grau statt des
|
||||
rohen Typnamens; Schluessel in de.json und en.json.
|
||||
- Changelog-Stichpunkt unter „Unveroeffentlicht → Geaendert"; Entwicklerdoku-Abschnitt.
|
||||
|
||||
## Abweichungen vom Plan
|
||||
|
||||
**1. [Rule 3 — blockierend] Die Planannahme „apps/web importiert @tessera/shared bereits" war falsch**
|
||||
|
||||
- **Gefunden bei:** Aufgabe 1, vor der ersten Zeile Code.
|
||||
- **Befund:** `apps/web` hatte **keine** Abhaengigkeit auf `@tessera/shared`. Zwei Kommentare
|
||||
(`lib/app-version.ts`, `lib/desktop.ts`) dokumentierten das sogar ausdruecklich als Absicht und
|
||||
begruendeten damit gespiegelte Typen. Ohne Abhaengigkeit ist Entscheidung 1 des Plans nicht
|
||||
umsetzbar. Zudem waren **alle** bisherigen `@tessera/shared`-Importe in `apps/api` reine
|
||||
`import type` — die Typliste ist aber ein Laufzeitwert.
|
||||
- **Geprueft statt vermutet:**
|
||||
- `nest build` mit einem Laufzeit-Import: laeuft; das Ergebnis laedt `@tessera/shared` im
|
||||
fertigen `dist` tatsaechlich (nachgestellt, 9 Typen).
|
||||
- `packages/shared` liefert rohes TypeScript ohne Bauschritt — in `node:24-alpine` direkt
|
||||
geprueft: Node 24 laedt es per nativem Type-Stripping (`OK [ 'clock', 'xframe' ] {}`).
|
||||
- Die alte Gegenbegruendung ist ueberholt: der Web-Dockerfile kopiert `packages/shared` in
|
||||
deps- **und** builder-Stufe bereits. Es aendert sich nur das Lockfile (3 Zeilen).
|
||||
- `pnpm --filter @tessera/web build` laeuft durch — ohne `transpilePackages`.
|
||||
- **Umsetzung:** `@tessera/shared: workspace:*` in `apps/web/package.json`. Die beiden Kommentare,
|
||||
deren Begruendung dadurch unwahr wurde, sagen jetzt den aktuellen Stand; die Typ-Spiegel selbst
|
||||
blieben bewusst unangetastet (nicht Teil dieser Aufgabe).
|
||||
- **Nebenwirkung fuer die Zukunft:** `packages/shared/src/index.ts` darf nur noch loeschbare Syntax
|
||||
enthalten — kein `enum`, kein `namespace`, keine Parameter-Eigenschaften. Steht als Warnung in
|
||||
der Datei.
|
||||
|
||||
**2. [Abweichung vom Auftrag des Orchestrators] Keine gemeinsame Hilfsfunktion fuer `/modules/active`**
|
||||
|
||||
Der Auftrag nannte „dieselbe Hilfsfunktion wie die Seitenleiste". Eine solche gibt es nicht: die
|
||||
Seitenleiste hat einen eingebauten `fetch`, und `lib/api.ts#getActiveModules` ist serverseitig
|
||||
(Cookie-Header, kein `credentials`). Die Dashboard-Seite benutzt daher dasselbe **Muster** wie die
|
||||
Seitenleiste. Eine Hilfsfunktion herauszuloesen haette `sidebar.tsx` angefasst — ausserhalb dieser
|
||||
Aufgabe.
|
||||
|
||||
## Bewusst offen gelassen
|
||||
|
||||
- **`widget-settings-panel.tsx`** — der Einstellungs-Zweig je Typ bleibt wie er war. Den generisch
|
||||
zu machen ist ein eigener Umbau (so im Plan festgelegt). Die Datei wurde nicht angefasst.
|
||||
- **Die Typ-Spiegel** in `lib/app-version.ts` und `lib/desktop.ts` koennten jetzt echte Importe
|
||||
werden. Nicht gemacht, nur die Kommentare richtiggestellt.
|
||||
- **Katalog aktualisiert sich nicht live**, wenn im Marketplace gerade ein Modul freigeschaltet
|
||||
wird — die Seitenleiste tut das ueber `sidebarRefreshKey`, die Dashboard-Seite holt die Liste nur
|
||||
beim Aufbau. Heute ohne Wirkung (keine Kachel hat einen `moduleSlug`); mit Proxmox reicht ein
|
||||
Neuladen der Seite. Bewusst so, weil der Auffrisch-Ausloeser einen `biome-ignore` erzwungen
|
||||
haette, den die Qualitaetsregeln dieser Aufgabe ausschliessen.
|
||||
|
||||
## Keine Stubs
|
||||
|
||||
Es wurden keine Platzhalter, leeren Rueckgaben oder „coming soon"-Texte eingebaut.
|
||||
`WIDGET_MODULE_SLUGS` ist leer — das ist kein Stub, sondern der korrekte Zustand: alle neun Kacheln
|
||||
sind Plattform-Kacheln. Die erste Modul-Kachel (Proxmox) traegt sich dort ein.
|
||||
|
||||
## Bedrohungsmodell
|
||||
|
||||
| ID | Stand |
|
||||
|---|---|
|
||||
| T-M1H-01 | Akzeptiert wie geplant. Der Katalogfilter ist Komfort; im Code an drei Stellen so kommentiert. Durchsetzung bleibt `DashboardService.getWidgets` (unveraendert, 85 Tests gruen). |
|
||||
| T-M1H-02 | Unveraendert — diese Aufgabe schwaecht nichts ab. Fuer Proxmox bleibt `@UseModule(slug)` verbindlich. |
|
||||
| T-M1H-03 | Mitigiert. Reine Konstantenliste, keine Laufzeitdaten; die API validiert weiterhin `@IsIn` gegen genau diese Liste — jetzt nachweislich (Test validiert alle neun Typen und lehnt einen unbekannten ab). |
|
||||
| T-M1H-04 | Mitigiert. `accessibleModuleSlugs === null` blendet Kacheln MIT `moduleSlug` aus; Test im Katalog und in `visibleWidgetTypes`. |
|
||||
|
||||
## Zahlen
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Commits | 2 (`56c07c3`, `8be0725`), Basis `ee2b025` |
|
||||
| Dateien geaendert | 20 (1 neu) |
|
||||
| Zeilen | +610 / −150 (gemessen: `git diff --shortstat ee2b025 HEAD`) |
|
||||
| Neue Tests | 29 (Registry 9, Katalog 3, Seite 2, `widget-module-map.spec.ts` 14, Wrapper 1) |
|
||||
| API-Tests | 1202 gruen (76 Dateien) |
|
||||
| Web-Tests | 659 gruen (81 Dateien) |
|
||||
| type-check | sauber (4 Pakete) |
|
||||
| lint | api 74 / web 53 Warnungen — **unveraendert** zur Basis |
|
||||
| `as unknown as` | api 27 / web 6 — **unveraendert** |
|
||||
| neue `any` / `!` / `biome-ignore` | 0 / 0 / 0 |
|
||||
| Next.js-Produktionsbau | laeuft |
|
||||
| `nest build` | laeuft |
|
||||
|
||||
## Browser-Pruefliste
|
||||
|
||||
Der Umbau ist verhaltensneutral — die Pruefung soll vor allem bestaetigen, dass **nichts** anders
|
||||
aussieht. Lokalen Stack neu bauen (`--build`), dann im Portal:
|
||||
|
||||
1. **Dashboard oeffnen.** Alle bisherigen Kacheln stehen an ihrem Platz und funktionieren wie
|
||||
vorher (Uhr laeuft, Kalender zeigt Termine, Bilderrahmen wechselt, XFrame laedt).
|
||||
2. **Stift → „Widget hinzufuegen".** Der Katalog zeigt **neun** Kacheln in genau dieser Reihenfolge:
|
||||
Uhr, Suchleiste, Kalender, Notiz, Taschenrechner, Favoriten, Stoppuhr, Bilderrahmen, XFrame.
|
||||
Namen und Beschreibungen unveraendert.
|
||||
3. **Eine Kachel anlegen** (z. B. Stoppuhr) — sie erscheint, laesst sich ziehen, vergroessern und
|
||||
wieder entfernen. Kein 400-Fehler.
|
||||
4. **Groessen pruefen:** eine frisch angelegte Kachel hat dieselbe Startgroesse wie frueher, und
|
||||
sie laesst sich nicht kleiner ziehen als bisher.
|
||||
5. **Einstellungen → Dashboard:** die Einstellungen je Kachel sind unveraendert da (dieser Bereich
|
||||
wurde bewusst nicht angefasst).
|
||||
6. **Sprache auf Englisch umstellen** — der Katalog bleibt vollstaendig, keine rohen Schluessel wie
|
||||
`clock.name` sichtbar.
|
||||
7. *(optional, zeigt das Neue)* Der Hinweis bei einer nicht verfuegbaren Kachel laesst sich heute
|
||||
nur kuenstlich ausloesen — er greift erst mit der ersten Modul-Kachel. Wer ihn sehen will: in der
|
||||
Datenbank den `widgetType` einer vorhandenen Kachel auf `proxmox` setzen und die Seite neu laden;
|
||||
die Kachel zeigt dann „Diese Kachel steht nicht zur Verfuegung — das zugehoerige Modul ist nicht
|
||||
freigegeben." statt leer zu bleiben. Danach zuruecksetzen.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- `apps/api/src/dashboard/widget-module-map.spec.ts` vorhanden.
|
||||
- Commits `56c07c3` und `8be0725` in `git log` gefunden.
|
||||
- `git diff --diff-filter=D ee2b025..HEAD` — keine geloeschten Dateien.
|
||||
- `git rev-list --count ee2b025..HEAD` = 2, gemessen.
|
||||
- `.planning/**` nicht committet.
|
||||
|
||||
## Rundgang durch den Orchestrator (22.09.2026, lokaler Stack aus 8be0725)
|
||||
|
||||
Bestanden, keine Abweichung zum Stand vorher:
|
||||
|
||||
- Dashboard zeigt die bestehenden Kacheln (Kalender, Notizen, Favoriten, Bilderrahmen, XFrame) unveraendert, keine Konsolenfehler.
|
||||
- Katalog zeigt **neun** Kacheln in der alten Reihenfolge: Uhr, Suchleiste, Kalender, Notizen, Taschenrechner, Favoriten, Stoppuhr, Bilderrahmen, XFrame; Namen und Beschreibungen unveraendert, keine rohen Schluessel.
|
||||
- Stoppuhr angelegt → erscheint (396x160 px), wird gespeichert (`stopwatch` in `GET /dashboard/widgets`), kein 400; danach wieder entfernt, Liste sauber.
|
||||
- `/modules/active` wird beim Seitenaufbau abgerufen (4x 200) — der Katalogfilter hat seine Datenquelle.
|
||||
- Der Hinweis bei nicht verfuegbarer Kachel liess sich nicht echt ausloesen (es gibt noch keine Modul-Kachel); er ist durch den Test in `widget-wrapper.test.tsx` gedeckt und greift mit Proxmox.
|
||||
+201
@@ -0,0 +1,201 @@
|
||||
---
|
||||
phase: quick-260922-vdk
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
autonomous: true
|
||||
requirements: [QUICK-260922-VDK]
|
||||
|
||||
files_modified:
|
||||
- apps/web/src/components/dashboard/dashboard-grid.tsx
|
||||
- apps/web/src/components/dashboard/dashboard-grid.test.tsx
|
||||
- CHANGELOG.md
|
||||
|
||||
estimate:
|
||||
tokens: 55000
|
||||
raw_tokens: 55000
|
||||
tasks: 2
|
||||
confidence: low
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Ein Dashboard, das beim Öffnen leer ist, misst die verfügbare Breite, sobald die erste Kachel erscheint: das Raster füllt den Inhaltsbereich bis zum rechten Rand, rechts bleibt kein toter Streifen, in den sich keine Kachel ziehen lässt."
|
||||
- "Die Messung überlebt den Wechsel Leerzustand → gefüllt, weil sie am eingehängten Knoten selbst hängt (Ref-Rückruf) und nicht an einem Effekt, der nur beim ersten Einhängen läuft."
|
||||
- "Gemessen wird synchron in der Commit-Phase, bevor gezeichnet wird — der bisherige Zwischenzustand mit dem angenommenen Startwert wird nie sichtbar (auch nicht auf dem heute schon funktionierenden Pfad „Neuladen mit Kacheln“)."
|
||||
- "Ändert sich die Fenstergröße, folgt die Rasterbreite; ein Fenster-Horcher ist das Sicherheitsnetz für den Fall, dass der ResizeObserver nichts meldet."
|
||||
- "Das Ziehverhalten bleibt exakt wie heute: FREE_PLACEMENT_COMPACTOR mit preventCollision, ein belegtes Feld bleibt blockiert, nichts wird zur Seite geschoben (Nutzerentscheidung 22.09.2026); Leerzustand, BREAKPOINTS, COLS, rowHeight, margin und applyConstraintMinima sind unverändert."
|
||||
- "Alle Tore grün: 12 Tests in dashboard-grid.test.tsx (10 alte unverändert + 2 neue), Web gesamt ≥ 661 Tests in 81 Dateien, `tsc --noEmit` 4/4, Biome 5/5 mit weiterhin genau 53 Warnungen in web, `as unknown as` in web weiterhin 6."
|
||||
artifacts:
|
||||
- "apps/web/src/components/dashboard/dashboard-grid.tsx — Messung über den Ref-Rückruf `measureRef` (synchrone Erstmessung + ResizeObserver am jeweils eingehängten Knoten, Trennen im null-Zweig), `applyWidth`-Wächter, Fenster-Horcher; deutscher Kommentarblock `quick-260922-vdk` mit dem Warum"
|
||||
- "apps/web/src/components/dashboard/dashboard-grid.test.tsx — zwei neue Fälle (Leerzustand → gefüllt misst 1000; resize-Ereignis misst 1600) und `vi.unstubAllGlobals()` im bestehenden `afterEach`"
|
||||
- "CHANGELOG.md — neuer Abschnitt „### Behoben“ unter „Unveröffentlicht“ mit einem Stichpunkt in Alltagssprache"
|
||||
key_links:
|
||||
- "Kachel hinzufügen → `widgets.length` wird 1 → der Zweig mit dem Raster rendert → React hängt den `<div>` ein → `measureRef(node)` → synchrone Messung + `observer.observe(node)` → `setWidth` noch vor dem Zeichnen → `Responsive width` → Spaltenbreite und Breakpoint stimmen"
|
||||
- "Letzte Kachel entfernt → Knoten wird ausgehängt → `measureRef(null)` → `observerRef.current.disconnect()` (kein zurückgegebener Aufräum-Rückgabewert, damit React den null-Aufruf beibehält) → kein weiterlaufender Beobachter"
|
||||
- "`window` resize → Horcher → `nodeRef.current.getBoundingClientRect().width` → `applyWidth` (verwirft 0 und nicht endliche Werte) → `setWidth`"
|
||||
---
|
||||
|
||||
# Quick-Aufgabe 260922-vdk: Das Dashboard-Raster misst seine Breite auch aus dem Leerzustand heraus
|
||||
|
||||
<objective>
|
||||
Auf einem Dashboard, das beim Öffnen leer ist, bleibt das Raster für die ganze Sitzung bei der angenommenen Breite von 1200 Pixeln stehen. Die erste hinzugefügte Kachel rechnet deshalb mit 20 statt 24 Spalten und 51,6 px Spaltenbreite, das Raster endet bei 1200 px und rechts davon liegt ein toter Bereich (gemessen: ~460 px bei 1920 px Bildschirmbreite), in den sich keine Kachel ziehen lässt. Diese Aufgabe hängt die Messung an den Knoten statt an den ersten Einhäng-Zeitpunkt, misst vor dem ersten Zeichnen und ergänzt einen Fenster-Horcher als Netz.
|
||||
|
||||
Purpose: Fehlerbehebung aus einer bereits abgeschlossenen Messung — die Ursache steht fest, dieser Plan setzt nur noch um und sichert sie mit einem Regressionstest ab.
|
||||
Output: geänderte `dashboard-grid.tsx` mit deutschem Warum-Kommentar, zwei neue Tests (zuerst rot), ein Changelog-Stichpunkt, alle Tore grün, Prüfliste für den Browser-Rundgang im SUMMARY.
|
||||
</objective>
|
||||
|
||||
## Befund (gemessen, nicht neu zu untersuchen)
|
||||
|
||||
Der bisherige Code legt den Beobachter in einem Effekt mit leerer Abhängigkeitsliste an und bricht ab, wenn der Ref noch leer ist. Hängt `DashboardGrid` ein, während das Dashboard null Kacheln hat, greift der frühe Rücksprung in den Leerzustand **vor** dem `<div>` mit dem Ref: der Ref ist leer, der Effekt bricht ab — und läuft wegen der leeren Abhängigkeitsliste nie wieder, auch nicht, wenn später Kacheln erscheinen und der `<div>` tatsächlich entsteht. Die Breite bleibt für die ganze Sitzung beim Startwert.
|
||||
|
||||
Belege (bestätigt, nicht zu wiederholen):
|
||||
|
||||
- Echter Linux-Client (Tessera-1.3.0.AppImage, WebKitGTK), leeres Dashboard, eine Kalender-Kachel hinzugefügt: Kachel 469 px breit in einem 1000 px breiten Container — das ist die Rechnung für 1200.
|
||||
- Derselbe Client nach einem Neuladen mit vorhandener Kachel: 459 px in 1176 px, also richtig — weil die Ladeschranke in `(portal)/page.tsx` das Raster aus- und wieder einhängt, der Ref beim Einhängen also existiert.
|
||||
- Screenshot des Nutzers (1920×1045): Spaltenbreite 51,5 px, Platzhalter klebt an Spalte 13, rechte Rasterkante bei x = 1459 bei einem Inhaltsbereich bis ~1920.
|
||||
|
||||
`react-grid-layout` vergleicht den Breakpoint strikt größer als (`width > breakpoint`), 1200 ist damit **nicht** `lg`, sondern `md` → 20 Spalten.
|
||||
|
||||
## Gebundene Entscheidungen (nicht neu verhandeln)
|
||||
|
||||
1. **Ref-Rückruf statt Einmal-Effekt.** Die Messung hängt am jeweils eingehängten Knoten und überlebt Aus- und Einhängen.
|
||||
2. **Synchron vor dem ersten Zeichnen.** Der Ref-Rückruf läuft in der Commit-Phase; die dort ausgelöste Zustandsänderung wird vor dem Zeichnen abgearbeitet. Ein zusätzlicher `useLayoutEffect` ist damit überflüssig.
|
||||
3. **Fenster-Horcher als Netz**, zusätzlich zum ResizeObserver, nicht statt seiner.
|
||||
4. **Verhalten sonst unverändert.** `FREE_PLACEMENT_COMPACTOR` mit `preventCollision` bleibt (Nutzerentscheidung 22.09.2026: ein belegtes Feld bleibt blockiert, nichts wird zur Seite geschoben). Leerzustand, `BREAKPOINTS`, `COLS`, `rowHeight`, `margin`, `applyConstraintMinima` bleiben wortgleich.
|
||||
5. **Startwert 1200 bleibt.** Er lebt nur noch bis zur Commit-Phase desselben Einhängens. Genau diesen Übergang 1200 → gemessen macht der Pfad „Neuladen mit Kacheln“ heute schon in Produktion, und er ist nachweislich richtig (459 px in 1176 px) — ein anderer Startwert würde eine bisher unerprobte Breakpoint-Folge einführen, ohne etwas zu verbessern.
|
||||
6. **Nur `apps/web`.** Keine API, kein Prisma, kein Docker, keine neuen Pakete (ResizeObserver und resize sind Browser-Schnittstellen).
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/gsd-core/workflows/execute-plan.md
|
||||
@~/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/STATE.md
|
||||
@apps/web/src/components/dashboard/dashboard-grid.tsx
|
||||
@apps/web/src/components/dashboard/dashboard-grid.test.tsx
|
||||
@apps/web/src/test/fake-resize-observer.ts
|
||||
@apps/web/src/test/setup.ts
|
||||
</context>
|
||||
|
||||
## Schnittstellen, die der Executor kennen muss
|
||||
|
||||
- `apps/web/src/test/fake-resize-observer.ts` exportiert `stubResizeObserver({ width, height }): void`; es ersetzt den globalen ResizeObserver per `vi.stubGlobal` durch eine Klasse, deren `observe()` den Rückruf **sofort und synchron** mit `new DOMRectReadOnly(0, 0, width, height)` aufruft. Zurückgesetzt wird nur durch `vi.unstubAllGlobals()` — `vi.restoreAllMocks()` reicht dafür nicht.
|
||||
- `apps/web/src/test/setup.ts` legt global einen ResizeObserver-Ersatz an, der immer 1200×800 meldet. Ohne `stubResizeObserver` misst jeder Test also 1200 — der neue Test wäre damit blind für genau diesen Fehler.
|
||||
- `dashboard-grid.test.tsx` ersetzt `Responsive` durch einen Durchreicher, der die Props in `captured.props` ablegt (`vi.hoisted`, Mock per `importOriginal`, damit `noCompactor` echt bleibt). Die gemessene Breite ist dadurch als `captured.props?.width` prüfbar.
|
||||
- jsdom liefert für `getBoundingClientRect()` ohne Zutun 0 — die synchrone Erstmessung schlägt im Test also nicht durch, der gestubbte Beobachter liefert den Wert. Für den Fenster-Test wird `Element.prototype.getBoundingClientRect` gezielt überschrieben.
|
||||
- `new DOMRect(0, 0, w, h)` gibt es in jsdom (die Datei `fake-resize-observer.ts` nutzt bereits `DOMRectReadOnly`) — damit braucht der Test **keinen** Cast, und der Zähler `as unknown as` bleibt bei 6.
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer" tdd="true">
|
||||
<name>Aufgabe 1: Messung an den Knoten hängen (Ref-Rückruf, synchrone Erstmessung, Fenster-Horcher) — Ende-zu-Ende „leeres Dashboard → erste Kachel füllt die volle Breite“, zuerst rot</name>
|
||||
<files>apps/web/src/components/dashboard/dashboard-grid.test.tsx, apps/web/src/components/dashboard/dashboard-grid.tsx</files>
|
||||
<behavior>
|
||||
Zuerst die Tests, beide müssen vor der Änderung rot sein:
|
||||
|
||||
- **Test 10 — Leerzustand → gefüllt:** `stubResizeObserver({ width: 1000, height: 800 })`; `captured.props` auf null setzen; mit `widgets: []` und leeren Layouts rendern → die Überschrift des Leerzustands steht da und `captured.props` ist weiterhin null (das Raster ist nicht eingehängt); danach **`rerender`** derselben Instanz mit einer Uhr-Kachel und einem passenden `lg`-Eintrag → `captured.props?.width` ist 1000. Rot vorher: der Wert bleibt 1200.
|
||||
- **Test 11 — Fenstergröße:** `stubResizeObserver({ width: 1000, height: 800 })`; mit einer Uhr-Kachel rendern → Breite 1000; danach `Element.prototype.getBoundingClientRect` per `vi.spyOn(...).mockReturnValue(new DOMRect(0, 0, 1600, 800))` überschreiben und `window.dispatchEvent(new Event('resize'))` in `act(...)` auslösen → `captured.props?.width` ist 1600. Rot vorher: der Wert bleibt 1000, weil es keinen Fenster-Horcher gibt.
|
||||
|
||||
Die zehn bestehenden Fälle bleiben unverändert und grün — insbesondere Test 4 (Raster-Konstanten), Test 7 (Compactor-Pin mit preventCollision) und Test 9/9b (Minima).
|
||||
</behavior>
|
||||
<action>
|
||||
1. **`dashboard-grid.test.tsx`** (zuerst, rot): `act` zusätzlich aus `@testing-library/react` importieren, `stubResizeObserver` aus `@/test/fake-resize-observer`. Im bestehenden `afterEach` nach `vi.restoreAllMocks()` eine Zeile `vi.unstubAllGlobals()` ergänzen (ohne sie bliebe der gestubbte Beobachter für alle folgenden Dateien stehen). Am Ende des bestehenden `describe('DashboardGrid', …)` die zwei Fälle aus dem `<behavior>`-Block anfügen, benannt „quick-260922-vdk Test 10: …“ und „quick-260922-vdk Test 11: …“, Stil und Kommentarsprache wie die Nachbarfälle (deutsch, mit dem Warum in einer Zeile). Rot-Lauf ausführen und die Fehlermeldungen für das SUMMARY festhalten.
|
||||
|
||||
2. **`dashboard-grid.tsx`**: `useCallback` zum Import aus `react` hinzufügen. Die bisherige Kombination aus `containerRef` und dem Effekt mit leerer Abhängigkeitsliste ersetzen durch:
|
||||
- zwei Refs: `nodeRef` (Typ `HTMLDivElement | null`, hält den gerade eingehängten Knoten für den Fenster-Horcher) und `observerRef` (Typ `ResizeObserver | null`, hält den laufenden Beobachter);
|
||||
- `applyWidth` als `useCallback` mit leerer Abhängigkeitsliste: nimmt eine Zahl, ruft `setWidth` nur bei endlichem Wert größer 0 auf. React verwirft gleiche Werte selbst, ein zusätzlicher Vergleich ist unnötig und eine Rückkopplungsschleife damit ausgeschlossen (T-VDK-01);
|
||||
- `measureRef` als `useCallback` über `applyWidth`, Signatur nimmt `HTMLDivElement | null` und gibt **nichts** zurück: erst einen eventuell laufenden Beobachter trennen und `observerRef` leeren, dann `nodeRef` auf den Knoten setzen, bei `null` zurückspringen, sonst `applyWidth(node.getBoundingClientRect().width)` (das ist die synchrone Erstmessung in der Commit-Phase), dann einen neuen `ResizeObserver` anlegen, der `entries[0].contentRect.width` an `applyWidth` weitergibt, ihn auf den Knoten setzen und in `observerRef` merken. Wichtig: keine Aufräumfunktion zurückgeben — React 19 ruft den Rückruf sonst beim Aushängen nicht mehr mit `null` auf, und genau dieser Zweig ist hier der Aufräumpfad;
|
||||
- einen `useEffect` über `applyWidth`, der `resize` am `window` anmeldet und im Aufräumschritt wieder abmeldet; der Horcher misst `nodeRef` erneut, wenn dort ein Knoten liegt;
|
||||
- am Raster-`<div>` `ref={measureRef}` setzen (exakt dieser Name, ein Tor prüft ihn).
|
||||
|
||||
Alle vier Hooks stehen **vor** dem frühen Rücksprung in den Leerzustand, damit die Hook-Reihenfolge stabil bleibt — derselbe Grund, den der Kommentar bei `effectiveLayouts` bereits festhält.
|
||||
|
||||
3. **Warum-Kommentar** über der Messung, deutsch, im Stil der vorhandenen Blöcke (`quick-260916-dyv`, `quick-260916-bwo`), Präfix `quick-260922-vdk:`. Inhalt in eigenen Worten: der frühe Rücksprung in den Leerzustand rendert den gemessenen Knoten gar nicht erst, ein Effekt mit leerer Abhängigkeitsliste sieht ihn deshalb nie wieder; der Ref-Rückruf folgt dem Knoten über Aus- und Einhängen hinweg und misst in der Commit-Phase, also vor dem Zeichnen; die Folge der alten Annahme war ein `md`-Breakpoint mit 20 Spalten (strikter Größer-Vergleich in RGL), 51,6 px Spaltenbreite und ein toter Streifen rechts; der Fenster-Horcher ist ein Netz für Fälle, in denen der Beobachter nichts meldet; der Wächter verwirft 0 und nicht endliche Werte, damit eine kurzzeitig zusammengefallene Fläche das Raster nicht auf Null setzt. Den Startwert-Absatz (Entscheidung 5 oben) mit aufnehmen.
|
||||
|
||||
4. Nichts anderes anfassen: Leerzustand, `BREAKPOINTS`, `COLS`, `rowHeight`, `margin`, das fehlende `containerPadding`, `dragConfig`, `resizeConfig`, `FREE_PLACEMENT_COMPACTOR`, `applyConstraintMinima` und die `data-grid`-Erzeugung bleiben wortgleich.
|
||||
|
||||
Commit: `fix(quick-260922-vdk): Dashboard-Raster misst seine Breite auch aus dem Leerzustand heraus` (Wortlaut frei, Stil der Nachbarcommits, Co-Authored-By-Zeile).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/web exec vitest run src/components/dashboard/dashboard-grid.test.tsx && pnpm --filter @tessera/web exec tsc --noEmit && grep -q "ref={measureRef}" apps/web/src/components/dashboard/dashboard-grid.tsx && grep -q "addEventListener('resize'" apps/web/src/components/dashboard/dashboard-grid.tsx && grep -q "unstubAllGlobals" apps/web/src/components/dashboard/dashboard-grid.test.tsx && grep -q "quick-260922-vdk" apps/web/src/components/dashboard/dashboard-grid.tsx</automated>
|
||||
</verify>
|
||||
<done>`dashboard-grid.test.tsx` hat 12 grüne Fälle (10 alte wortgleich, 2 neue); die zwei neuen waren nachweislich zuerst rot, die Rot-Meldungen („1200 statt 1000“ bzw. „1000 statt 1600“) stehen im SUMMARY. `tsc --noEmit` ohne Befund. Die Messung hängt am Ref-Rückruf `measureRef`, trennt den Beobachter im null-Zweig, misst synchron bei jedem Einhängen und hat einen Fenster-Horcher. Compactor, Konstanten, Leerzustand und `applyConstraintMinima` sind unverändert (Tests 4/7/9/9b belegen es). Ein Commit mit Scope `quick-260922-vdk`.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Aufgabe 2: Changelog-Stichpunkt, volle Tore, Zähler, Prüfliste für den Browser-Rundgang</name>
|
||||
<files>CHANGELOG.md</files>
|
||||
<action>
|
||||
1. **`CHANGELOG.md`**: unter „## Unveröffentlicht“ nach der bestehenden Liste „### Geändert“ einen neuen Abschnitt „### Behoben“ anlegen (Reihenfolge wie in 1.3.0: Neu, Geändert, Behoben) mit genau einem Stichpunkt in Alltagssprache, Tonlage der Nachbarzeilen, ohne Fachbegriffe: „Dashboard: war das Dashboard beim Öffnen leer, nutzte die erste hinzugefügte Kachel nur einen Teil der Breite — rechts blieb ein toter Streifen, in den sich keine Kachel ziehen ließ; das Raster misst die verfügbare Breite jetzt in jedem Fall und folgt auch einer Änderung der Fenstergröße“.
|
||||
|
||||
2. **Volle Tore** ausführen und die Zahlen ins SUMMARY schreiben: `pnpm type-check` (4/4), `pnpm lint` (5/5), `pnpm --filter @tessera/web test`. Ausgangsmessung dieses Plans (22.09., vor der Änderung): Web 81 Dateien / 659 Tests grün, Biome web genau 53 Warnungen, `as unknown as` in web 6. Erwartet nachher: 81 Dateien / 661 Tests, Warnungen und Zähler unverändert. Weicht eine Zahl ab, im SUMMARY benennen statt stillschweigend anpassen.
|
||||
|
||||
3. **Prüfliste** für den Orchestrator ins SUMMARY schreiben (Browser, lokal, Playwright-MCP — **nicht** auf dem Testserver), Punkt für Punkt abhakbar; ausdrücklich dazuschreiben, dass Breiten am DOM gemessen werden (`getBoundingClientRect` der Elemente), **nie** per `fetch` aus der Seite heraus:
|
||||
(a) Dashboard eines Benutzers ohne Kacheln öffnen (oder alle Kacheln entfernen und neu laden) → Leerzustand mit Bildmarke;
|
||||
(b) Bearbeiten → „Widget hinzufügen“ → Uhr: die Kachel erscheint, und beim Ziehen reicht der Platzhalter bis an den rechten Rand des Inhaltsbereichs — kein toter Streifen;
|
||||
(c) messen: Breite von `.react-grid-layout` gleicht der Breite des umgebenden Inhaltsbereichs (Abweichung höchstens der Rand von 8 px); vorher lag sie bei 1200 px unabhängig von der Fensterbreite;
|
||||
(d) eine zweite Kachel auf ein belegtes Feld ziehen → sie springt zurück, nichts wird zur Seite geschoben (unverändert gewollt); Größe ziehen stoppt am Nachbarn;
|
||||
(e) Fenster schmaler und wieder breiter ziehen → das Raster folgt, Kacheln bleiben heil;
|
||||
(f) Seite mit vorhandenen Kacheln neu laden → weiterhin richtig (der bisher schon funktionierende Pfad), und beim ersten Zeichnen ist kein Sprung von schmal auf breit zu sehen;
|
||||
(g) letzte Kachel entfernen → Leerzustand erscheint, danach eine neue Kachel hinzufügen → wieder volle Breite (der Beobachter wurde sauber getrennt und neu angehängt).
|
||||
|
||||
Commit: `docs(quick-260922-vdk): Changelog - Dashboard-Raster misst seine Breite auch aus dem Leerzustand` (nur CHANGELOG.md; Akte und STATE macht der Orchestrator; Co-Authored-By-Zeile).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && grep -q 'rechts blieb ein toter Streifen' CHANGELOG.md && awk '/^## Unveröffentlicht/{f=1} /^## 1\.3\.0/{f=0} f&&/^### Behoben/{n++} END{exit !(n==1)}' CHANGELOG.md && pnpm type-check && pnpm lint && pnpm --filter @tessera/web lint 2>&1 | grep -q 'Found 53 warnings' && test "$(grep -rn 'as unknown as' apps/web/src --include=*.ts --include=*.tsx | wc -l)" -eq 6 && pnpm --filter @tessera/web test</automated>
|
||||
<human-check>Am Ende der Aufgabe: der Orchestrator geht die siebenpunktige Prüfliste im Browser durch (lokal, Playwright-MCP). Entscheidend ist Punkt (b)/(c): auf einem beim Öffnen leeren Dashboard füllt die erste Kachel den Inhaltsbereich bis zum rechten Rand, und die gemessene Rasterbreite stimmt mit der Breite des Inhaltsbereichs überein.</human-check>
|
||||
</verify>
|
||||
<done>Der Changelog trägt unter „Unveröffentlicht“ genau einen neuen Abschnitt „### Behoben“ mit dem einen Stichpunkt. `pnpm type-check` 4/4 und `pnpm lint` 5/5 ohne Befund der Stufe `error`, Biome web weiterhin genau 53 Warnungen, `as unknown as` in web weiterhin 6, Web-Tests 81 Dateien / 661 grün. Die siebenpunktige Prüfliste steht im SUMMARY. Insgesamt genau zwei Commits mit Scope `quick-260922-vdk` (`git log --oneline -2`).</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
ASVS-Stufe 1, Blockschwelle `high` (jede `high`-Bedrohung MUSS mitigiert sein).
|
||||
|
||||
## Vertrauensgrenzen
|
||||
|
||||
| Grenze | Beschreibung |
|
||||
|---|---|
|
||||
| Browser-Layout → React-Zustand | Gemessene Breiten (ResizeObserver, `getBoundingClientRect`, `resize`) werden zu Zustand und steuern die Rastergeometrie; die Werte sind nicht vertrauenswürdig im Sinne von „immer sinnvoll“ (0 während eines Übergangs, nicht endlich bei zusammengefallener Fläche) |
|
||||
| Knoten-Lebensdauer → Beobachter-Lebensdauer | Der ResizeObserver hängt an einem Knoten, der beim Wechsel Leerzustand ↔ gefüllt aus- und eingehängt wird |
|
||||
| Neue Vertrauensgrenze | Keine. Kein Netzverkehr, keine API, keine Benutzereingabe, keine Persistenz in diesem Plan |
|
||||
|
||||
## STRIDE-Register
|
||||
|
||||
| ID | Kategorie | Komponente | Schwere | Disposition | Maßnahme |
|
||||
|---|---|---|---|---|---|
|
||||
| T-VDK-01 | Denial of Service (Rückkopplung Messung → Zustand → Layout → Messung, bis der Tab steht) | `applyWidth` / ResizeObserver-Rückruf in `dashboard-grid.tsx` | medium | mitigate | `applyWidth` ruft `setWidth` nur bei endlichem Wert größer 0; React verwirft gleiche Werte (Object.is) und rendert dann gar nicht neu — der Beobachter meldet nach dem Einschwingen denselben Wert und die Kette endet. Zusätzlich beobachtet der Beobachter den äußeren `<div>`, dessen Breite nicht von der Rastergeometrie abhängt. |
|
||||
| T-VDK-02 | Denial of Service (Ressourcenleck: Beobachter oder Fenster-Horcher überleben das Aushängen, bei jedem Wechsel Leerzustand ↔ gefüllt einer mehr) | `measureRef` null-Zweig, `useEffect`-Aufräumschritt | medium | mitigate | Der Ref-Rückruf trennt den laufenden Beobachter als erste Handlung und gibt bewusst **keine** Aufräumfunktion zurück, damit React ihn beim Aushängen mit `null` aufruft; der Fenster-Horcher meldet sich im Aufräumschritt des Effekts ab. Punkt (g) der Prüfliste geht den Wechsel im Browser durch. |
|
||||
| T-VDK-03 | Denial of Service (Messung auf einer zusammengefallenen Fläche setzt die Breite auf 0 → Raster unbedienbar, Kacheln unerreichbar) | Synchrone Erstmessung in jsdom-losen Übergängen, `getBoundingClientRect` | low | mitigate | Wächter „größer 0 und endlich“; bleibt eine Messung aus, gilt weiter der letzte gültige Wert statt 0. |
|
||||
| T-VDK-04 | Tampering (stille Verhaltensänderung beim Ziehen: ein belegtes Feld würde plötzlich nachgeben) | `FREE_PLACEMENT_COMPACTOR`, `dragConfig`, `resizeConfig` | medium | mitigate | Diese Stellen werden nicht angefasst; Test 7 pinnt den Compactor als echten `noCompactor` plus `preventCollision: true`, Test 6 die Zieh-Konfiguration, Test 4 die Raster-Konstanten — alle laufen im Tor der Aufgabe 1 mit. Prüfliste (d) belegt es zusätzlich im Browser. |
|
||||
| T-VDK-05 | Information Disclosure | — | low | accept | Es werden nur Layout-Maße des eigenen Fensters gelesen; nichts verlässt den Browser, nichts wird geloggt oder gespeichert. |
|
||||
| T-VDK-06 | Repudiation | — | low | accept | Reine Anzeige-Geometrie ohne Fremdwirkung; kein Audit-Log nötig, ASVS 1 genügt. |
|
||||
| T-VDK-SC | Tampering (Lieferkette) | npm-Installationen | high | mitigate | Nicht ausgelöst: KEINE neuen Pakete — `ResizeObserver`, `getBoundingClientRect`, `window`-Ereignisse und `DOMRect` sind Browser-Schnittstellen, `act` und `vi.spyOn` sind bereits vorhanden. Will der Executor doch etwas installieren: Stopp und Rückfrage an den Orchestrator, keine Installation ohne ausdrückliche Freigabe. |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
Automatisch (Executor, in den `<verify>`-Blöcken): gezielter Testlauf von `dashboard-grid.test.tsx` (12 Fälle), `tsc --noEmit`, Struktur-Tore auf Ref-Rückruf, Fenster-Horcher, `unstubAllGlobals` und den Warum-Kommentar; danach Changelog-Tore, `pnpm type-check` 4/4, `pnpm lint` 5/5, Biome web genau 53 Warnungen, `as unknown as` web 6, voller Web-Testlauf.
|
||||
|
||||
Rot-Nachweis (Aufgabe 1): beide neuen Fälle laufen vor der Änderung rot; die Meldungen gehören ins SUMMARY.
|
||||
|
||||
Manuell (Orchestrator, Prüfliste aus Aufgabe 2 Punkt 3, lokal im Browser, nicht auf dem Testserver): leeres Dashboard → erste Kachel füllt die Breite; gemessene Rasterbreite gleicht der Breite des Inhaltsbereichs; belegtes Feld bleibt blockiert; Fenstergröße; Neuladen mit Kacheln unverändert; Leerzustand → gefüllt → Leerzustand → gefüllt.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- [ ] Auf einem beim Öffnen leeren Dashboard füllt die erste hinzugefügte Kachel den Inhaltsbereich; rechts kein toter Streifen (Test 10 + Prüfliste b/c)
|
||||
- [ ] Die Messung folgt dem Knoten über Aus- und Einhängen und läuft synchron vor dem Zeichnen (Ref-Rückruf `measureRef`, Struktur-Tor + Prüfliste f/g)
|
||||
- [ ] Fenstergröße ändern misst neu (Test 11 + Prüfliste e)
|
||||
- [ ] Ziehverhalten unverändert: belegtes Feld bleibt blockiert, nichts wird verschoben (Tests 4/6/7/9/9b grün, Prüfliste d)
|
||||
- [ ] 12 Fälle in `dashboard-grid.test.tsx`, Web gesamt 81 Dateien / 661 Tests grün
|
||||
- [ ] `pnpm type-check` 4/4, `pnpm lint` 5/5, Biome web 53 Warnungen, `as unknown as` web 6
|
||||
- [ ] Changelog: genau ein Stichpunkt unter „Unveröffentlicht → Behoben“
|
||||
- [ ] Genau zwei Commits mit Scope `quick-260922-vdk`
|
||||
- [ ] Nur `apps/web` und `CHANGELOG.md` angefasst; keine neuen Pakete
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
`.planning/quick/260922-vdk-dashboard-raster-misst-seine-breite-nich/260922-vdk-SUMMARY.md` schreiben, wenn beide Aufgaben fertig sind — mit Rot-Meldungen der zwei neuen Tests, den gemessenen Zahlen (Tests, Warnungen, Zähler) und der siebenpunktigen Prüfliste für den Browser-Rundgang.
|
||||
</output>
|
||||
+224
@@ -0,0 +1,224 @@
|
||||
---
|
||||
phase: quick-260922-vdk
|
||||
plan: 01
|
||||
subsystem: ui
|
||||
tags: [react, react-grid-layout, ref-callback, resize-observer, dashboard, vitest]
|
||||
|
||||
requires: []
|
||||
provides:
|
||||
- "Dashboard-Raster misst seine Breite auch beim Wechsel Leerzustand -> gefuellt (Ref-Rueckruf statt Einmal-Effekt)"
|
||||
- "Fenster-Horcher als Netz zusaetzlich zum ResizeObserver"
|
||||
affects: [dashboard-grid, dashboard]
|
||||
|
||||
actuals:
|
||||
tokens: 2392
|
||||
tasks: 2
|
||||
commits: 2
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Ref-Rueckruf (callback ref) statt useEffect mit leerer Abhaengigkeitsliste, wenn eine Messung den tatsaechlich eingehaengten Knoten ueber Aus-/Einhaengen hinweg verfolgen muss"
|
||||
|
||||
key-files:
|
||||
created: []
|
||||
modified:
|
||||
- apps/web/src/components/dashboard/dashboard-grid.tsx
|
||||
- apps/web/src/components/dashboard/dashboard-grid.test.tsx
|
||||
- CHANGELOG.md
|
||||
|
||||
key-decisions:
|
||||
- "Ref-Rueckruf measureRef ersetzt containerRef + Einmal-Effekt; misst synchron in der Commit-Phase, kein useLayoutEffect noetig"
|
||||
- "Kein Aufraeum-Rueckgabewert aus measureRef, damit React 19 den Rueckruf beim Aushaengen mit null aufruft (das ist der Aufraeumpfad)"
|
||||
- "Fenster-Horcher zusaetzlich zum ResizeObserver, nicht als Ersatz"
|
||||
- "Startwert bleibt 1200 (nur bis zur ersten Commit-Phase relevant)"
|
||||
- "FREE_PLACEMENT_COMPACTOR/preventCollision, BREAKPOINTS, COLS, rowHeight, margin, applyConstraintMinima unveraendert"
|
||||
|
||||
patterns-established:
|
||||
- "quick-260922-vdk Kommentarblock in dashboard-grid.tsx erklaert das Warum der Ref-Rueckruf-Messung"
|
||||
|
||||
requirements-completed: [QUICK-260922-VDK]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "Dashboard-Raster misst seine Breite auch aus dem Leerzustand heraus (Ref-Rueckruf, synchrone Erstmessung, Fenster-Horcher)"
|
||||
requirement: "QUICK-260922-VDK"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "apps/web/src/components/dashboard/dashboard-grid.test.tsx#quick-260922-vdk Test 10: Leerzustand -> gefuellt misst die tatsaechliche Breite statt beim Startwert 1200 stehenzubleiben"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "apps/web/src/components/dashboard/dashboard-grid.test.tsx#quick-260922-vdk Test 11: Fenstergroesse aendert sich -> der Fenster-Horcher misst neu"
|
||||
status: pass
|
||||
human_judgment: true
|
||||
rationale: "Die Unit-Tests beweisen die gemessene Breite in jsdom; ob im echten Browser rechts kein toter Streifen mehr bleibt und die Rasterbreite sichtbar der Breite des Inhaltsbereichs entspricht, verlangt einen Browser-Rundgang (Prueflliste unten)."
|
||||
- id: D2
|
||||
description: "Ziehverhalten unveraendert: belegtes Feld bleibt blockiert, nichts wird zur Seite geschoben"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "apps/web/src/components/dashboard/dashboard-grid.test.tsx#quick-260916-dyv Test 7: Compactor-Pin"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "apps/web/src/components/dashboard/dashboard-grid.test.tsx#quick-260916-dyv Test 6: dragConfig-Pin"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
|
||||
duration: ~9min
|
||||
completed: 2026-09-22
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Quick-Aufgabe 260922-vdk: Dashboard-Raster misst seine Breite auch aus dem Leerzustand heraus Summary
|
||||
|
||||
**Messung an einem Ref-Rueckruf (`measureRef`) statt an einem Einmal-Effekt: der Beobachter folgt dem eingehaengten `<div>` ueber Leerzustand <-> gefuellt hinweg, misst synchron in der Commit-Phase, plus Fenster-Horcher als Netz — 20-Spalten/`md`-Fehlmessung nach 1200 px behoben.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** ~9 min
|
||||
- **Completed:** 2026-09-22
|
||||
- **Tasks:** 2/2
|
||||
- **Files modified:** 3
|
||||
|
||||
## Accomplishments
|
||||
- Dashboard-Raster misst jetzt in jedem Fall die tatsaechliche Breite des Inhaltsbereichs, auch wenn `DashboardGrid` mit null Kacheln einhaengt und die erste Kachel erst spaeter erscheint
|
||||
- Fenster-Horcher als Sicherheitsnetz zusaetzlich zum ResizeObserver
|
||||
- Zwei neue Regressionstests, nachweislich zuerst rot
|
||||
- Ziehverhalten (`FREE_PLACEMENT_COMPACTOR` mit `preventCollision`), Raster-Konstanten und Leerzustand unveraendert (per Test 4/6/7/9/9b bestaetigt)
|
||||
|
||||
## Task Commits
|
||||
|
||||
Each task was committed atomically:
|
||||
|
||||
1. **Aufgabe 1: Messung an den Knoten haengen (Ref-Rueckruf, synchrone Erstmessung, Fenster-Horcher)** - `d9f2af3` (fix)
|
||||
2. **Aufgabe 2: Changelog-Stichpunkt, volle Tore, Zaehler, Pruefliste** - `cf67c8a` (docs)
|
||||
|
||||
_Ledger: `plan_head_before` = `3e8c0f4` (letzter Commit vor diesem Plan — die bereits committete Akte). `git rev-list --count 3e8c0f4..HEAD` = 2, deckt sich mit den zwei oben genannten Commits._
|
||||
|
||||
**Plan metadata:** wird vom Orchestrator nach diesem SUMMARY committet (STATE.md/ROADMAP.md ausserhalb dieses Plans).
|
||||
|
||||
## Rot-Nachweis (Aufgabe 1, vor der Aenderung)
|
||||
|
||||
Testlauf vor dem Umbau von `dashboard-grid.tsx` (nur die Test-Datei war schon geaendert):
|
||||
|
||||
```
|
||||
FAIL src/components/dashboard/dashboard-grid.test.tsx > DashboardGrid > quick-260922-vdk Test 10: ...
|
||||
AssertionError: expected 1200 to be 1000 // Object.is equality
|
||||
|
||||
FAIL src/components/dashboard/dashboard-grid.test.tsx > DashboardGrid > quick-260922-vdk Test 11: ...
|
||||
AssertionError: expected 1000 to be 1600 // Object.is equality
|
||||
```
|
||||
|
||||
10 von 12 Faellen gruen, genau die zwei neuen rot — wie erwartet: Test 10 blieb beim Startwert 1200 (der frueh zurueckspringende Leerzustand rendert den gemessenen Knoten nie, der Effekt mit leerer Abhaengigkeitsliste sieht ihn also nie), Test 11 blieb bei 1000, weil es vorher keinen Fenster-Horcher gab.
|
||||
|
||||
Nach dem Umbau: alle 12 Faelle gruen (`pnpm --filter @tessera/web exec vitest run src/components/dashboard/dashboard-grid.test.tsx`).
|
||||
|
||||
## Gemessene Zahlen (nachher, alle Tore)
|
||||
|
||||
| Tor | Ausgangsmessung (22.09., vor der Aenderung) | Nachher (gemessen) |
|
||||
|---|---|---|
|
||||
| `dashboard-grid.test.tsx` | 10 Faelle | 12 Faelle, alle gruen |
|
||||
| Web Tests gesamt | 81 Dateien / 659 Tests | 81 Dateien / 661 Tests, alle gruen |
|
||||
| `pnpm type-check` | — | 4/4 ohne Befund |
|
||||
| `pnpm lint` | — | 5/5 ohne Befund der Stufe `error` |
|
||||
| Biome web Warnungen | 53 | 53 (unveraendert) |
|
||||
| `as unknown as` in web | 6 | 6 (unveraendert) |
|
||||
|
||||
Keine Abweichung — alle erwarteten Zahlen aus dem Plan treffen exakt zu.
|
||||
|
||||
## Files Created/Modified
|
||||
- `apps/web/src/components/dashboard/dashboard-grid.tsx` - `measureRef`-Ref-Rueckruf ersetzt `containerRef` + Einmal-Effekt; `applyWidth`-Wächter (verwirft 0/nicht-endliche Werte); Fenster-Horcher (`resize`-Listener); deutscher Warum-Kommentarblock `quick-260922-vdk`
|
||||
- `apps/web/src/components/dashboard/dashboard-grid.test.tsx` - zwei neue Faelle (Test 10: Leerzustand -> gefuellt misst 1000; Test 11: resize-Ereignis misst 1600), `stubResizeObserver`-Import, `vi.unstubAllGlobals()` im `afterEach`
|
||||
- `CHANGELOG.md` - neuer Abschnitt „### Behoben" unter „## Unveroeffentlicht" mit einem Stichpunkt
|
||||
|
||||
## Decisions Made
|
||||
Keine neuen Entscheidungen — die im Plan gebundenen Entscheidungen (Ref-Rueckruf statt Einmal-Effekt, synchron vor dem ersten Zeichnen, Fenster-Horcher als Netz, Ziehverhalten unveraendert, Startwert 1200 bleibt, nur `apps/web`) wurden wortgleich umgesetzt.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
None - plan executed exactly as written.
|
||||
|
||||
## Issues Encountered
|
||||
None.
|
||||
|
||||
## Pruefliste fuer den Browser-Rundgang (Orchestrator, lokal, Playwright-MCP, nicht Testserver)
|
||||
|
||||
Breiten werden am DOM gemessen (`getBoundingClientRect` der Elemente), **nie** per `fetch` aus der Seite heraus — das taeuscht in beide Richtungen.
|
||||
|
||||
- [ ] (a) Dashboard eines Benutzers ohne Kacheln oeffnen (oder alle Kacheln entfernen und neu laden) -> Leerzustand mit Bildmarke erscheint
|
||||
- [ ] (b) Bearbeiten -> „Widget hinzufuegen" -> Uhr: die Kachel erscheint, und beim Ziehen reicht der Platzhalter bis an den rechten Rand des Inhaltsbereichs — kein toter Streifen
|
||||
- [ ] (c) messen: Breite von `.react-grid-layout` gleicht der Breite des umgebenden Inhaltsbereichs (Abweichung hoechstens der Rand von 8 px); vorher lag sie bei 1200 px unabhaengig von der Fensterbreite
|
||||
- [ ] (d) eine zweite Kachel auf ein belegtes Feld ziehen -> sie springt zurueck, nichts wird zur Seite geschoben (unveraendert gewollt); Groesse ziehen stoppt am Nachbarn
|
||||
- [ ] (e) Fenster schmaler und wieder breiter ziehen -> das Raster folgt, Kacheln bleiben heil
|
||||
- [ ] (f) Seite mit vorhandenen Kacheln neu laden -> weiterhin richtig (der bisher schon funktionierende Pfad), und beim ersten Zeichnen ist kein Sprung von schmal auf breit zu sehen
|
||||
- [ ] (g) letzte Kachel entfernen -> Leerzustand erscheint, danach eine neue Kachel hinzufuegen -> wieder volle Breite (der Beobachter wurde sauber getrennt und neu angehaengt)
|
||||
|
||||
Entscheidend: Punkt (b)/(c) — auf einem beim Oeffnen leeren Dashboard fuellt die erste Kachel den Inhaltsbereich bis zum rechten Rand, und die gemessene Rasterbreite stimmt mit der Breite des Inhaltsbereichs ueberein.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
Keine neue Vertrauensgrenze, keine neuen Pakete. Alle sechs T-VDK-Punkte aus dem Plan-Threat-Model sind mit Tests bzw. Struktur-Toren abgedeckt (siehe Rot-Nachweis und gemessene Zahlen oben); nichts Neues gefunden.
|
||||
|
||||
## Next Phase Readiness
|
||||
Kein laufender Meilenstein, keine Folge-Phase direkt abhaengig. Naechster Schritt laut STATE.md bleibt: Widget-Modul-Kopplung (`WIDGET_MODULE_MAP`) und danach das Proxmox-Modul — unabhaengig von dieser Quick-Aufgabe.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- FOUND: apps/web/src/components/dashboard/dashboard-grid.tsx
|
||||
- FOUND: apps/web/src/components/dashboard/dashboard-grid.test.tsx
|
||||
- FOUND: CHANGELOG.md
|
||||
- FOUND: .planning/quick/260922-vdk-dashboard-raster-misst-seine-breite-nich/260922-vdk-SUMMARY.md
|
||||
- FOUND commit: d9f2af3
|
||||
- FOUND commit: cf67c8a
|
||||
|
||||
---
|
||||
*Phase: quick-260922-vdk*
|
||||
*Completed: 2026-09-22*
|
||||
|
||||
## Rundgang durch den Orchestrator (22.09.2026, echter Linux-Client, nicht der Browser)
|
||||
|
||||
Der Nutzer hat den Fehler im **Linux-Client** gemeldet und ausdruecklich gesagt, ein
|
||||
Browser-Test bringe nichts. Geprueft wurde deshalb im echten Paket: `Tessera-1.3.0.AppImage`
|
||||
aus dem Gitea-Release, auf dem Entwicklungsrechner gestartet (`DISPLAY=:10`,
|
||||
`WEBKIT_INSPECTOR_HTTP_SERVER=127.0.0.1:9230`), gesteuert ueber den WebKit-Remote-Inspektor
|
||||
(Treiber `scratchpad/wk.mjs`, Target-Protokoll: `Target.sendMessageToTarget` +
|
||||
`Runtime.evaluate`). Server: lokaler Stack.
|
||||
|
||||
**Ausgangsmessung VOR dem Fix** (gleiche Sitzung, gleicher Client):
|
||||
|
||||
| Weg | Bereich | Kachel | Rasterbreite laut Rechnung |
|
||||
|-----|---------|--------|----------------------------|
|
||||
| Neuladen MIT Kachel | 1176 px | 459 px | 1176 px — richtig |
|
||||
| Seitenleiste auf/zu | 1000 ↔ 1176 px | folgt | richtig |
|
||||
| **Leeres Dashboard, dann Kachel hinzufuegen** | 1000 px | **469 px** | **1200 px — falsch** |
|
||||
|
||||
Der dritte Weg ist der Fehlerfall: `DashboardGrid` haengt mit null Kacheln ein, der
|
||||
gemessene `<div>` existiert nicht, der Einmal-Effekt bricht ab und laeuft nie wieder.
|
||||
|
||||
**Nach dem Fix, derselbe Weg** (Kachel entfernt, gespeichert, neu geladen -> leeres
|
||||
Dashboard -> Kalender hinzugefuegt):
|
||||
|
||||
- `width`-Eigenschaft an `Responsive`: **1000** (= echte Bereichsbreite), Breakpoint `md`,
|
||||
20 Spalten — aus dem React-Fiber ausgelesen, nicht geraten.
|
||||
- Kachel `style.width`: **389 px** = `8 × 41,6 + 56`, exakt der Sollwert fuer 1000 px.
|
||||
- Ziehen nach rechts (synthetische Maus-Ereignisse, jeweils mit Wartezeit, damit React
|
||||
dazwischen rendert): Platzhalter laeuft 8 → 107 → 256 → 405 → 554 → **603** und bleibt
|
||||
dort. `1000 − 8 − 389 = 603` — **der Platzhalter erreicht jetzt exakt den rechten Rand**.
|
||||
Die Kachel wird auch dort abgelegt (`tileX 603`).
|
||||
|
||||
**Messfalle, dokumentiert damit sie niemanden noch einmal kostet:** `getBoundingClientRect()`
|
||||
und `getComputedStyle().width` lieferten im Client weiter 469 px, obwohl `style.width`
|
||||
bereits 389 px war. Grund: `.react-grid-item` hat `transition: width .2s`, und das
|
||||
Client-Fenster lag im Hintergrund — WebKitGTK friert die Animationsuhr dann ein, der
|
||||
Uebergang bleibt auf dem Startwert stehen (`getAnimations()` meldete eine laufende
|
||||
`CSSTransition` auf `width`). **Im Client gegen die gesetzten Werte messen
|
||||
(`style.width`, `style.transform`) oder gegen die React-Eigenschaften, nie gegen die
|
||||
gemalte Box** — sonst misst man die eingefrorene Animation statt des Ergebnisses.
|
||||
|
||||
**Nicht angefasst, wie zugesagt:** belegte Plaetze bleiben gesperrt, nichts weicht aus
|
||||
(`FREE_PLACEMENT_COMPACTOR` mit `preventCollision` unveraendert) — ausdrueckliche Ansage des
|
||||
Nutzers am 22.09.
|
||||
|
||||
**Testreste der Pruefung:** lokaler Testbenutzer `clienttest` und die zeitweise auf
|
||||
`NODE_ENV=development` gesetzte lokale API (WebKitGTK nimmt `secure`-Kekse ueber `http` nicht
|
||||
an, Chromium macht fuer `localhost` eine Ausnahme) — beides nach der Pruefung wieder
|
||||
zurueckgebaut.
|
||||
+573
@@ -0,0 +1,573 @@
|
||||
---
|
||||
phase: quick-260923-ad9
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
autonomous: true
|
||||
requirements: [QUICK-260923-AD9]
|
||||
|
||||
files_modified:
|
||||
- apps/api/prisma/schema.prisma
|
||||
- apps/api/prisma/migrations/20260923120000_dashboard_tabs/migration.sql
|
||||
- apps/api/src/dashboard/dashboard.service.ts
|
||||
- apps/api/src/dashboard/dashboard.service.spec.ts
|
||||
- apps/api/src/dashboard/dashboard.controller.ts
|
||||
- apps/api/src/dashboard/dashboard.controller.spec.ts
|
||||
- apps/api/src/dashboard/dto/save-layout.dto.ts
|
||||
- apps/api/src/dashboard/dto/create-widget.dto.ts
|
||||
- apps/api/src/dashboard/dto/rename-dashboard.dto.ts
|
||||
- apps/api/src/dashboard/dto/reorder-dashboards.dto.ts
|
||||
- docs/mandantentrennung-zugriffsklassifikation.md
|
||||
- apps/web/src/lib/dashboard-api.ts
|
||||
- apps/web/src/lib/stores/dashboard-store.ts
|
||||
- apps/web/src/lib/stores/dashboard-store.test.ts
|
||||
- apps/web/src/components/dashboard/dashboard-tabs.tsx
|
||||
- apps/web/src/components/dashboard/dashboard-tabs.test.tsx
|
||||
- apps/web/src/app/(portal)/page.tsx
|
||||
- apps/web/src/messages/de.json
|
||||
- apps/web/src/messages/en.json
|
||||
- docs/anleitung-anwender.md
|
||||
- CHANGELOG.md
|
||||
|
||||
estimate:
|
||||
tokens: 185000
|
||||
raw_tokens: 185000
|
||||
tasks: 5
|
||||
confidence: low
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Ein Benutzer hat mehrere Dashboards, die oben als Reiter nebeneinander stehen. Jeder Reiter trägt seine EIGENEN Kacheln und seine EIGENE Anordnung — was auf Reiter 1 liegt, erscheint nicht auf Reiter 2."
|
||||
- "Beim Öffnen des Dashboards wird immer der ERSTE Reiter geladen. Es gibt kein zusätzliches Stern-Kennzeichen und kein getrenntes Standard-Feld: nach vorn ziehen IST das Festlegen des Standards."
|
||||
- "Reiter lassen sich mit der Maus an eine andere Stelle ziehen; die neue Reihenfolge bleibt nach dem Neuladen erhalten. Ein Klick ohne Ziehen wechselt nur den Reiter."
|
||||
- "Reiter lassen sich anlegen (leer, Name „Dashboard 2“, „Dashboard 3“, … — die nächste freie Zahl), umbenennen und löschen. Der letzte verbleibende Reiter kann nicht gelöscht werden; der Server weist das ab, die Oberfläche bietet es gar nicht erst an."
|
||||
- "Niemand verliert beim Einspielen etwas: jeder Benutzer, der heute Kacheln ODER eine gespeicherte Anordnung hat, findet danach genau EINEN Reiter namens „Dashboard“ mit genau seinen bisherigen Kacheln in genau seiner bisherigen Anordnung vor. Ein Benutzer ohne beides bekommt beim ersten Öffnen einen leeren Reiter „Dashboard“ angelegt."
|
||||
- "Fail-closed gegen fremde Reiter: Kacheln lesen, Kachel anlegen, Anordnung lesen, Anordnung speichern, umbenennen, löschen und umsortieren antworten für einen Reiter, der dem Aufrufer nicht gehört (fremder Benutzer, fremder Mandant, unbekannte Kennung), mit derselben Nicht-gefunden-Antwort — nie mit einer Antwort, aus der sich die Existenz des fremden Reiters ablesen lässt."
|
||||
- "Die neue Tabelle trägt Mandantenkennung und denselben Zeilenschutz wie ihre Nachbarn (Mandant UND Benutzer, Form aus 20260911120000); der Wächter-Test der RLS-Abdeckung bleibt grün, und die Zugriffsklassifikation führt das neue Paar (Datei, Modell) mit gemessenem Stand."
|
||||
- "Am Raster selbst ändert sich nichts: FREE_PLACEMENT_COMPACTOR mit preventCollision, belegte Plätze bleiben gesperrt, nichts weicht aus; die Breitenmessung aus quick-260922-vdk bleibt unverändert."
|
||||
- "Keine neue Abhängigkeit: das Ziehen der Reiter läuft über dieselben Pointer-Ereignisse wie die Ausschnittwahl im XFrame-Einstellungsdialog (quick-260922-ge2)."
|
||||
- "Alle Tore grün: api gesamt ≥ 1202 Tests in ≥ 77 Dateien, web gesamt ≥ 661 Tests in ≥ 83 Dateien, `pnpm type-check` 4/4, `pnpm lint` 5/5 mit weiterhin GENAU 53 Warnungen in web, `prisma migrate diff` gegen die lokale Datenbank meldet weiterhin keinen Unterschied."
|
||||
artifacts:
|
||||
- "apps/api/prisma/schema.prisma — neues Modell `Dashboard` (id, userId, tenantId, name, position, createdAt, updatedAt; Index auf userId und tenantId, KEIN Unique auf (userId, position)); `WidgetInstance.dashboardId` und `DashboardLayout.dashboardId @unique` mit Relation und `onDelete: Cascade`; `DashboardLayout.userId` verliert `@unique`, behält einen gewöhnlichen Index"
|
||||
- "apps/api/prisma/migrations/20260923120000_dashboard_tabs/migration.sql — hand geschriebene Migration mit deutschem Kopf: Tabelle, Indizes, Zeilenschutz (ENABLE + FORCE + tenant_isolation_policy mit Benutzerdimension), Bestandsübernahme für jeden Benutzer mit Kacheln oder Anordnung, Nachtragen der Fremdschlüssel erst NACH der Übernahme"
|
||||
- "apps/api/src/dashboard/dashboard.service.ts — `listDashboards` (legt bei null vorhandenen genau einen an, gegen Doppelanlage per Transaktions-Sperre gesichert), `createDashboard`, `renameDashboard`, `deleteDashboard`, `reorderDashboards`, privater Riegel `assertOwnedDashboard`; `getLayout`/`saveLayout`/`getWidgets`/`addWidget` arbeiten je Reiter"
|
||||
- "apps/api/src/dashboard/dashboard.controller.ts — fünf neue Routen unter `tabs`, `tabs/order` VOR den Routen mit Platzhalter deklariert; `dashboardId` als Abfrageparameter bei den beiden Lesewegen, im Rumpf bei den beiden Schreibwegen"
|
||||
- "apps/api/src/dashboard/dto/ — `rename-dashboard.dto.ts`, `reorder-dashboards.dto.ts` (Obergrenzen als Riegel gegen Massenanfragen), erweiterte `save-layout.dto.ts` und `create-widget.dto.ts`"
|
||||
- "apps/api/src/dashboard/dashboard.controller.spec.ts — NEU: Durchreichen der Reiter-Kennung, Fehlerformen, und ein quelltextlesender Wächter, dass `tabs/order` VOR den Platzhalter-Routen steht (NestJS-Routenreihenfolge)"
|
||||
- "apps/web/src/components/dashboard/dashboard-tabs.tsx — NEU: Reiterleiste mit Wechseln, Anlegen, Umbenennen, Löschen und Ziehen zum Umsortieren über Pointer-Ereignisse (Muster xframe-config-form.tsx, inklusive der jsdom-Schutzhülle um setPointerCapture)"
|
||||
- "apps/web/src/lib/stores/dashboard-store.ts — `dashboards`, `activeDashboardId`, `selectDashboard`, `createDashboard`, `renameDashboard`, `deleteDashboard`, `reorderDashboards`; Schutz gegen doppeltes Laden; ungespeicherte Anordnung wird VOR dem Reiterwechsel auf den ALTEN Reiter geschrieben"
|
||||
- "apps/web/src/messages/de.json + en.json — neue Zeichenketten unter `widgets.tabs.*`, deutsch in der Sie-Form mit echten Umlauten (der Umlaut-Wächter liest de.json)"
|
||||
- "docs/mandantentrennung-zugriffsklassifikation.md — neue Zeile für das Paar (`dashboard.service.ts`, `dashboard`), nachgerechnete Bereichs- und Summenzeile, nachgezogene Paarzahl"
|
||||
- "docs/anleitung-anwender.md + CHANGELOG.md — Beschreibung der Reiter in Alltagssprache"
|
||||
key_links:
|
||||
- "Öffnen → `loadDashboard()` → `GET /dashboard/tabs` (legt bei Bedarf den ersten an) → erster Reiter der nach `position` aufsteigend sortierten Liste wird aktiv → `GET /dashboard/widgets?dashboardId=…` + `GET /dashboard/layout?dashboardId=…` → `DashboardGrid` bekommt genau die Kacheln dieses Reiters"
|
||||
- "Reiter ziehen → Pointer-Ereignisse in `dashboard-tabs.tsx` → beim Loslassen die vollständige Kennungsliste in neuer Reihenfolge → `PUT /dashboard/tabs/order` → `withTenantTransaction` schreibt alle `position`-Werte des Benutzers in EINER Transaktion neu (0…n-1) → nächstes Öffnen lädt den nun ersten Reiter"
|
||||
- "Reiter löschen → `DELETE /dashboard/tabs/:id` → Riegel `assertOwnedDashboard` → Abweisung, wenn es der letzte Reiter ist → sonst in EINER Transaktion: Kacheln des Reiters, Anordnung des Reiters, Reiter selbst, danach Positionen der verbleibenden Reiter lückenlos neu geschrieben"
|
||||
- "Kachel hinzufügen → Store reicht `activeDashboardId` durch → `POST /dashboard/widgets` mit Reiter-Kennung → Riegel prüft Besitz → Kachel hängt am richtigen Reiter"
|
||||
- "Neues Modell `Dashboard` mit `tenantId` → `rls-coverage.spec.ts` Test 1/2 verlangen ENABLE + Policy in einer Migration → Migration liefert beides → Wächter bleibt grün"
|
||||
- "`tenantPrisma.dashboard` / `tx.dashboard` in `dashboard.service.ts` → `rls-access-inventory.spec.ts` findet ein neues Paar (Datei, Modell) → Eintrag in `docs/mandantentrennung-zugriffsklassifikation.md` mit Stand `gebunden` → Wächter bleibt grün"
|
||||
---
|
||||
|
||||
# Quick-Aufgabe 260923-ad9: Dashboard-Reiter — mehrere Dashboards je Benutzer
|
||||
|
||||
<objective>
|
||||
Das Dashboard trägt heute genau eine Kachelfläche je Benutzer. Diese Aufgabe gibt jedem Benutzer mehrere
|
||||
Dashboards, die oben als Reiter nebeneinander stehen: jeder Reiter mit eigenen Kacheln und eigener
|
||||
Anordnung, per Ziehen umsortierbar, der erste ist der Standard und wird beim Öffnen geladen.
|
||||
|
||||
Purpose: Der Wunsch des Nutzers vom 22./23.09.2026, mit allen Entscheidungen bereits getroffen (siehe
|
||||
„Gebundene Entscheidungen“). Der Plan setzt um und sichert ab — es ist nichts mehr zu erforschen und
|
||||
nichts mehr rückzufragen.
|
||||
Output: Neues Datenmodell mit Bestandsübernahme, fünf neue Endpunkte mit Besitz-Riegel, Reiterleiste in
|
||||
der Oberfläche, Ziehen zum Umsortieren ohne neue Abhängigkeit, nachgezogene Zeilenschutz-Dokumentation,
|
||||
Anwenderhandbuch und Changelog. Alle Tore grün, Prüfliste für den Browser-Rundgang im SUMMARY.
|
||||
</objective>
|
||||
|
||||
## Gebundene Entscheidungen (nicht neu verhandeln)
|
||||
|
||||
- **D-01 — Datenmodell:** neues Modell `Dashboard` (id, userId, tenantId, name, position, createdAt,
|
||||
updatedAt), Reihenfolge über `position` (Integer), aufsteigend sortiert. **KEIN** Unique auf
|
||||
(userId, position) — beim Umsortieren werden alle Positionen des Benutzers in EINER Transaktion neu
|
||||
geschrieben, ein Unique wäre dabei nur im Weg. Index auf `userId` und auf `tenantId` wie bei den
|
||||
Nachbarmodellen.
|
||||
- **D-02 — Anhängen:** `WidgetInstance` bekommt `dashboardId`. `DashboardLayout` hängt künftig am
|
||||
Dashboard statt am Benutzer (das heutige `userId @unique` fällt, `dashboardId @unique` kommt);
|
||||
`userId`/`tenantId` bleiben auf beiden Modellen für Besitz- und Mandantenprüfung erhalten.
|
||||
- **D-03 — Bestandsübernahme:** für jeden Benutzer, der heute Kacheln ODER eine Anordnung hat, entsteht
|
||||
genau EIN Dashboard mit `position = 0` und dem Namen „Dashboard“; vorhandene Kacheln und die vorhandene
|
||||
Anordnung werden darauf umgehängt. Datenbankänderung heißt: geht über `main` als reguläre Version,
|
||||
nicht als Hotfix.
|
||||
- **D-04 — Zeilenschutz ist Pflicht:** das neue Modell braucht `tenantId` und denselben Zeilenschutz wie
|
||||
die Nachbartabellen. Die Wächter in `apps/api/src/prisma/rls-coverage.spec.ts` und
|
||||
`apps/api/src/prisma/rls-access-inventory.spec.ts` müssen grün bleiben.
|
||||
- **D-05 — keine neue Abhängigkeit** für das Ziehen der Reiter. Dem vorhandenen Pointer-Ereignis-Muster
|
||||
aus `apps/web/src/components/settings/xframe-config-form.tsx` (quick-260922-ge2) folgen.
|
||||
- **D-06 — am Raster ändert sich nichts:** `FREE_PLACEMENT_COMPACTOR` mit `preventCollision` bleibt,
|
||||
belegte Plätze bleiben gesperrt, nichts weicht aus (Nutzeransage 22.09.). Die Breitenmessung aus
|
||||
quick-260922-vdk bleibt unverändert.
|
||||
- **D-07 — Oberflächentexte** auf Deutsch in der Sie-Form über next-intl, keine rohen Zeichenketten;
|
||||
Kommentare im Code auf Deutsch wie in den Nachbardateien.
|
||||
- **D-08 — neuer Reiter** startet leer und heißt „Dashboard 2“, „Dashboard 3“, … (nächste freie Zahl).
|
||||
- **D-09 — „Als Favorit festlegen“ = nach vorn ziehen.** Kein Stern-Kennzeichen, kein getrenntes
|
||||
Standard-Feld, kein Merken des zuletzt benutzten Reiters: beim Öffnen wird immer der erste geladen.
|
||||
- **D-10 — der letzte verbleibende Reiter kann nicht gelöscht werden.**
|
||||
|
||||
**Nicht im Umfang:** Freigeben/Teilen von Dashboards an andere Benutzer, Vorlagen, Reiter je Modul.
|
||||
|
||||
## Gemessener Ausgangsstand (nicht erneut zu erheben)
|
||||
|
||||
Gemessen am 23.09.2026 vor Beginn, auf diesem Rechner:
|
||||
|
||||
| Tor | Stand |
|
||||
|---|---|
|
||||
| `pnpm --filter @tessera/api test` | 1202 Tests in 76 Dateien, grün |
|
||||
| davon `dashboard.service.spec.ts` | 31 Tests |
|
||||
| davon `rls-coverage.spec.ts` / `rls-access-inventory.spec.ts` | 5 / 30 Tests |
|
||||
| `pnpm --filter @tessera/web test` | 661 Tests in 81 Dateien, grün |
|
||||
| davon `dashboard-store.test.ts` / `dashboard-grid.test.tsx` | 6 / 12 Tests |
|
||||
| `pnpm type-check` | 4 von 4 erfolgreich |
|
||||
| `pnpm lint` | 5 von 5 erfolgreich, **genau 53 Warnungen** in web |
|
||||
| `as unknown as` in `apps/web/src` ohne Testdateien | 3 |
|
||||
| Prisma-Abweichung lokal | „No difference detected.“ |
|
||||
| Lokale Datenbank | 6 Kacheln bei 2 Benutzern, 1 gespeicherte Anordnung, 3 Benutzer — die Vereinigung „hat Kacheln oder Anordnung“ ergibt **2** Benutzer |
|
||||
|
||||
Die lokale Datenbank ist vom Host aus über die Container-IP erreichbar (`docker inspect` auf
|
||||
`tessera-ctl-db-1`, Zugangsdaten `tessera` / `tessera_dev`, Datenbank `tessera`) — sie hat bewusst keinen
|
||||
Host-Port. Gemessen: `tessera` ist in diesem Abbild ein Superuser und umgeht den Zeilenschutz, eine
|
||||
Migration sieht also alle Bestandszeilen.
|
||||
|
||||
## Festgelegte technische Form (vom Planer entschieden, nicht rückzufragen)
|
||||
|
||||
**Endpunkte** (alle unter dem vorhandenen Präfix `dashboard`, alle mit dem vorhandenen
|
||||
`extractContext`-Muster für Benutzer und Mandant):
|
||||
|
||||
| Weg | Zweck |
|
||||
|---|---|
|
||||
| `GET /dashboard/tabs` | Reiter des Benutzers, nach `position` aufsteigend; legt genau einen an, wenn keiner existiert |
|
||||
| `POST /dashboard/tabs` | neuen, leeren Reiter am Ende anlegen (Name automatisch, D-08) |
|
||||
| `PUT /dashboard/tabs/order` | vollständige Kennungsliste in Wunschreihenfolge |
|
||||
| `PATCH /dashboard/tabs/:id` | umbenennen |
|
||||
| `DELETE /dashboard/tabs/:id` | Reiter mit seinen Kacheln und seiner Anordnung löschen |
|
||||
| `GET /dashboard/layout?dashboardId=…` | Anordnung eines Reiters |
|
||||
| `PUT /dashboard/layout` | Rumpf trägt `dashboardId` und `layouts` |
|
||||
| `GET /dashboard/widgets?dashboardId=…` | Kacheln eines Reiters |
|
||||
| `POST /dashboard/widgets` | Rumpf trägt `widgetType` und `dashboardId` |
|
||||
|
||||
`PATCH /dashboard/widgets/:id/config`, `DELETE /dashboard/widgets/:id` und die drei Suchanbieter-Wege
|
||||
bleiben **unverändert** — eine Kachelkennung ist für sich eindeutig.
|
||||
|
||||
**Riegel `assertOwnedDashboard(dashboardId, userId, tenantId)`:** liest den Reiter über den gebundenen
|
||||
Klienten und wirft für „gibt es nicht“, „gehört einem Kollegen“ und „liegt bei einem fremden Mandanten“
|
||||
dieselbe `NotFoundException` — niemals eine abweichende Antwort, aus der sich die Existenz ablesen
|
||||
ließe. Dasselbe Vorgehen wie der Widget-Riegel in `favorites.service.ts` (T-GWH-05).
|
||||
|
||||
**Obergrenzen als Riegel gegen Massenanfragen:** höchstens 20 Reiter je Benutzer; Reitername nach dem
|
||||
Beschneiden 1 bis 40 Zeichen; die Kennungsliste beim Umsortieren höchstens 20 Einträge, ohne Dubletten.
|
||||
|
||||
**Umsortieren** folgt wörtlich dem Muster `FavoritesService.reorder` (260917-jdd): eine
|
||||
`withTenantTransaction`, darin erst die vorhandenen Kennungen lesen, auf exakte Übereinstimmung mit der
|
||||
gesendeten Liste prüfen (sonst Abweisung, kein Teilschreiben), dann je Eintrag ein `updateMany` mit
|
||||
`id` UND `userId` in der Bedingung und einer Prüfung auf genau eine getroffene Zeile. `withTenantTransaction`
|
||||
setzt keine Benutzerdimension in der Sitzung — deshalb trägt jede Bedingung innerhalb der Transaktion
|
||||
`userId` selbst, als zweites Netz.
|
||||
|
||||
## Tasks
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: Datenmodell, Migration und Reiter-Grundlage — das heutige Dashboard wird zu Reiter 1</name>
|
||||
<files>apps/api/prisma/schema.prisma, apps/api/prisma/migrations/20260923120000_dashboard_tabs/migration.sql, apps/api/src/dashboard/dashboard.service.ts, apps/api/src/dashboard/dashboard.service.spec.ts, apps/api/src/dashboard/dashboard.controller.ts, apps/api/src/dashboard/dto/save-layout.dto.ts, apps/api/src/dashboard/dto/create-widget.dto.ts, docs/mandantentrennung-zugriffsklassifikation.md</files>
|
||||
<read_first>apps/api/prisma/schema.prisma (Modelle DashboardLayout, WidgetInstance, DashboardImage), apps/api/prisma/migrations/20260921120000_dashboard_image/migration.sql (Vorlage für Kopf, Indizes und Zeilenschutz einer persönlichen Tabelle), apps/api/src/prisma/prisma-tenant.extension.ts (forTenant, withTenantTransaction), apps/api/src/dashboard/dashboard.service.ts, apps/api/src/dashboard/dashboard.service.spec.ts (Zwei-Klienten-Nachbau), apps/api/src/favorites/favorites.service.spec.ts Zeile 24-29 und 166-175 (Mock-Form für withTenantTransaction), apps/api/src/prisma/rls-coverage.spec.ts, docs/mandantentrennung-zugriffsklassifikation.md Zeile 160-180, 224-236 und 676-680</read_first>
|
||||
<action>
|
||||
Schema (D-01/D-02): neues Modell `Dashboard` mit `id` (uuid-Vorgabe), `userId`, `tenantId`, `name`,
|
||||
`position` als Integer, `createdAt`, `updatedAt`, Index auf `userId` und auf `tenantId`, KEIN Unique auf
|
||||
der Positionsspalte. Keine Relation zu `User` oder `Tenant` — das ist die Form der Nachbarmodelle
|
||||
`WidgetInstance` und `DashboardImage`, und eine Relation zu `User` würde an Bestandszeilen verwaister
|
||||
Benutzer scheitern. `WidgetInstance` bekommt `dashboardId` als Pflichtfeld mit Relation auf `Dashboard`
|
||||
und Löschweitergabe, dazu einen Index darauf; `DashboardLayout` bekommt `dashboardId` als Pflichtfeld mit
|
||||
Relation, Löschweitergabe und Eindeutigkeit, verliert die Eindeutigkeit auf `userId` und behält dort einen
|
||||
gewöhnlichen Index. Auf `Dashboard` die beiden Gegenseiten der Relationen eintragen. Der deutsche
|
||||
Kommentar über dem neuen Modell nennt D-01 (warum kein Unique auf der Position) und D-09 (warum es kein
|
||||
Standard-Feld gibt).
|
||||
|
||||
Migration `20260923120000_dashboard_tabs/migration.sql` von Hand schreiben, in dieser Reihenfolge — die
|
||||
Bestandsübernahme MUSS vor den Fremdschlüsseln stehen, sonst scheitert sie an genau diesen:
|
||||
1. Deutscher Kopfkommentar nach der Form von `20260921120000_dashboard_image`: Zweck, D-01 (keine
|
||||
Eindeutigkeit auf der Position, weil das Umsortieren alle Positionen eines Benutzers in einer
|
||||
Transaktion neu schreibt), D-03 (niemand verliert etwas), D-04 (Zeilenschutz mit Benutzerdimension,
|
||||
Form aus `20260911120000`), und der Hinweis, dass die Rechte für `tessera_app` über die
|
||||
Vorgaberechte aus `20260909130000` kommen.
|
||||
2. Tabelle `Dashboard` anlegen, Indizes auf `userId` und `tenantId`.
|
||||
3. Zeilenschutz einschalten, erzwingen und die Regel `tenant_isolation_policy` anlegen: Mandant gleich
|
||||
`current_tenant_id()` UND (`current_user_id()` ist NULL ODER Benutzer gleich `current_user_id()`) —
|
||||
wörtlich die Form aus `20260921120000_dashboard_image`.
|
||||
4. Bestandsübernahme: je Benutzer aus der Vereinigung der Benutzer mit Kacheln und der Benutzer mit
|
||||
gespeicherter Anordnung genau eine Zeile einfügen, mit `gen_random_uuid()` als Textkennung, dem Namen
|
||||
„Dashboard“, Position 0 und der Mandantenkennung aus der Bestandszeile. Gegen den theoretischen Fall
|
||||
„derselbe Benutzer mit zwei Mandantenkennungen“ mit einer Auswahl absichern, die je Benutzer genau
|
||||
eine Zeile liefert.
|
||||
5. `dashboardId` auf `WidgetInstance` zunächst als NULLbare Spalte ergänzen, aus der neuen Tabelle über
|
||||
die Benutzerkennung befüllen, dann auf NOT NULL setzen, Index anlegen, Fremdschlüssel mit
|
||||
Löschweitergabe ergänzen.
|
||||
6. Dasselbe für `DashboardLayout`; zusätzlich die Eindeutigkeit auf `userId` entfernen, dort einen
|
||||
gewöhnlichen Index anlegen und die Eindeutigkeit auf `dashboardId` anlegen.
|
||||
|
||||
Dienst `dashboard.service.ts`:
|
||||
- `listDashboards(userId, tenantId)` liest die Reiter des Benutzers über `forTenant(...)` nach `position`
|
||||
aufsteigend. Ist die Liste leer, wird genau ein Reiter „Dashboard“ mit Position 0 angelegt und die
|
||||
Liste erneut gelesen. Das Anlegen läuft in einer `withTenantTransaction`, die als erste Anweisung eine
|
||||
Transaktionssperre auf die Benutzerkennung nimmt (`pg_advisory_xact_lock` mit `hashtext` über die
|
||||
Benutzerkennung und einer zweiten Ganzzahl; beides sind eingebaute Postgres-Funktionen) und danach
|
||||
innerhalb der Sperre erneut zählt — zwei gleichzeitige erste Aufrufe desselben Benutzers dürfen nicht
|
||||
zwei Reiter erzeugen. Der deutsche Kommentar erklärt genau diesen Grund.
|
||||
- Privater Riegel `assertOwnedDashboard(dashboardId, userId, tenantId)` wie unter „Festgelegte technische
|
||||
Form“ beschrieben, mit deutschem Kommentar, der die drei ununterscheidbaren Fälle benennt.
|
||||
- `getLayout`, `saveLayout`, `getWidgets`, `addWidget` nehmen die Reiter-Kennung entgegen, rufen zuerst
|
||||
den Riegel und arbeiten danach über `dashboardId` statt über `userId`. Die vorhandenen Besitzprüfungen
|
||||
über die Benutzerkennung bleiben zusätzlich bestehen — zweites Netz, kein Ersatz, genau wie im
|
||||
Kopfkommentar der Datei beschrieben. `saveLayout` behält die Übersetzung der
|
||||
`PrismaClientUnknownRequestError` in die deutsche Konfliktmeldung, jetzt auf der Eindeutigkeit der
|
||||
Reiter-Kennung.
|
||||
- `getWidgets` behält den Modulfilter (D-22, PERM-07) und die bewusst ungebundene Katalogabfrage
|
||||
unverändert — nur die Bedingung der ersten Abfrage wechselt von Benutzer auf Reiter.
|
||||
|
||||
Controller: die vier betroffenen Wege reichen die Reiter-Kennung durch (bei den Lesewegen als
|
||||
Abfrageparameter, bei den Schreibwegen aus dem Rumpf), und `GET /dashboard/tabs` kommt hinzu. Die
|
||||
beiden DTOs bekommen ein Pflichtfeld für die Reiter-Kennung mit Zeichenkettenprüfung. Weitere Reiter-Wege
|
||||
folgen in Task 2 — dieser Task hält den Baum übersetzbar und das Verhalten für den Benutzer
|
||||
unverändert (ein Reiter, wie bisher).
|
||||
|
||||
Tests in `dashboard.service.spec.ts`: die bestehenden 31 bleiben unverändert bestehen; der Mock von
|
||||
`prisma-tenant.extension` bekommt `withTenantTransaction` nach der Form aus `favorites.service.spec.ts`
|
||||
(protokollierender Durchreicher auf den gebundenen Klienten, der Transaktionsklient braucht zusätzlich
|
||||
eine Attrappe für das rohe Ausführen der Sperranweisung), der gebundene Nachbau bekommt das neue Modell.
|
||||
Neu mindestens: erster Aufruf ohne vorhandenen Reiter legt genau einen an und liefert ihn; zweiter Aufruf
|
||||
legt keinen weiteren an; die Liste kommt nach Position aufsteigend; Kacheln und Anordnung werden über die
|
||||
Reiter-Kennung gelesen und geschrieben; fremde Reiter-Kennung führt bei allen vier Wegen zur
|
||||
Nicht-gefunden-Antwort; jeder dieser Wege lief über den gebundenen Klienten mit der richtigen
|
||||
Mandantenkennung.
|
||||
|
||||
Dokument `docs/mandantentrennung-zugriffsklassifikation.md`: neue Zeile in der Fundstellentabelle für das
|
||||
Paar (`apps/api/src/dashboard/dashboard.service.ts`, `dashboard`) mit Klasse `muss-mandantengebunden` und
|
||||
Stand `gebunden`, Begründung in der Form der Nachbarzeilen (persönliche Tabelle mit Mandanten- und
|
||||
Benutzerdimension, Regel von Anfang an mit Benutzerdimension, Besitzprüfung zusätzlich in der Anwendung).
|
||||
Bereichszeile `dashboard` und Summenzeile mit der Zählschleife aus dem Gate NACHRECHNEN, nicht
|
||||
abschreiben; die Paarzahl in der Überschrift der Klassen-Verteilung und den Fließtext, der von den vier
|
||||
Paaren des Bereichs spricht, nachziehen.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/api exec prisma generate && DB_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' tessera-ctl-db-1) && DATABASE_URL="postgresql://tessera:tessera_dev@${DB_IP}:5432/tessera" pnpm --filter @tessera/api exec prisma migrate deploy && DATABASE_URL="postgresql://tessera:tessera_dev@${DB_IP}:5432/tessera" pnpm --filter @tessera/api exec prisma migrate diff --from-url "postgresql://tessera:tessera_dev@${DB_IP}:5432/tessera" --to-schema-datamodel prisma/schema.prisma --exit-code</automated>
|
||||
<automated>docker exec tessera-ctl-db-1 psql -U tessera -d tessera -t -c 'SELECT (SELECT count(*) FROM "WidgetInstance" WHERE "dashboardId" IS NULL) AS kacheln_ohne_reiter, (SELECT count(*) FROM "DashboardLayout" WHERE "dashboardId" IS NULL) AS anordnungen_ohne_reiter, (SELECT count(*) FROM "Dashboard") AS reiter, (SELECT count(*) FROM "Dashboard" WHERE "position" <> 0) AS reiter_nicht_an_position_null;'</automated>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/api test 2>&1 | tail -20</automated>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm type-check</automated>
|
||||
</verify>
|
||||
<done>
|
||||
Die Migration läuft gegen die lokale Datenbank durch und `prisma migrate diff` meldet danach weiterhin
|
||||
keinen Unterschied (Rückgabewert 0). Die Zählabfrage liefert 0 Kacheln ohne Reiter, 0 Anordnungen ohne
|
||||
Reiter, genau 2 Reiter (die gemessene Zahl der Benutzer mit Bestand auf diesem Rechner) und 0 Reiter
|
||||
abseits von Position 0. `pnpm --filter @tessera/api test` ist grün mit ≥ 1202 Tests in 76 Dateien, davon
|
||||
≥ 39 in `dashboard.service.spec.ts`; `rls-coverage.spec.ts` (5) und `rls-access-inventory.spec.ts` (30)
|
||||
sind unverändert grün — letzteres beweist, dass das neue Paar im Dokument steht. `pnpm type-check` ist
|
||||
4 von 4.
|
||||
</done>
|
||||
<reversibility rating="costly">Die Bestandsübernahme hängt vorhandene Kacheln und Anordnungen auf neue Zeilen um und entfernt die Eindeutigkeit auf `DashboardLayout.userId` — zurück geht das nur über eine weitere Migration, die Daten selbst bleiben dabei erhalten. KEIN Entscheidungs-Halt davor: die Form der Migration ist als D-03 bereits gebunden und wird hier nicht erneut zur Abstimmung gestellt.</reversibility>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: Reiter anlegen, umbenennen, löschen und umsortieren — Dienst, DTOs, Endpunkte</name>
|
||||
<files>apps/api/src/dashboard/dashboard.service.ts, apps/api/src/dashboard/dashboard.service.spec.ts, apps/api/src/dashboard/dashboard.controller.ts, apps/api/src/dashboard/dashboard.controller.spec.ts, apps/api/src/dashboard/dto/rename-dashboard.dto.ts, apps/api/src/dashboard/dto/reorder-dashboards.dto.ts</files>
|
||||
<read_first>apps/api/src/favorites/favorites.service.ts (Methode `reorder` — Vorlage für Transaktion, Exakt-Abgleich und die Bedingung je Aktualisierung), apps/api/src/favorites/dto/reorder-favorites.dto.ts (Vorlage für Obergrenzen), apps/api/src/dashboard/dashboard-images.controller.spec.ts (Form einer Controller-Testdatei in diesem Bereich), apps/api/src/dashboard/dashboard.controller.ts</read_first>
|
||||
<behavior>
|
||||
- Anlegen ohne Namensvorgabe erzeugt „Dashboard 2“, wenn nur „Dashboard“ existiert; „Dashboard 3“, wenn „Dashboard“ und „Dashboard 2“ existieren; und füllt eine Lücke, wenn „Dashboard“ und „Dashboard 3“ existieren (dann „Dashboard 2“).
|
||||
- Anlegen hängt den neuen Reiter ans Ende (höchste vorhandene Position plus eins) und liefert ihn mit leerer Kachelliste.
|
||||
- Anlegen über der Obergrenze von 20 Reitern wird abgewiesen, ohne eine Zeile zu schreiben.
|
||||
- Umbenennen beschneidet Leerraum; ein leerer Name und ein Name über 40 Zeichen werden abgewiesen.
|
||||
- Umbenennen eines fremden Reiters liefert die Nicht-gefunden-Antwort.
|
||||
- Löschen entfernt Reiter, seine Kacheln und seine Anordnung in EINER Transaktion und schreibt die Positionen der verbleibenden Reiter lückenlos von 0 an neu.
|
||||
- Löschen des letzten verbleibenden Reiters wird abgewiesen und schreibt nichts.
|
||||
- Löschen eines fremden Reiters liefert die Nicht-gefunden-Antwort.
|
||||
- Umsortieren mit der vollständigen, dublettenfreien Kennungsliste schreibt die Positionen 0…n-1 in der gesendeten Reihenfolge.
|
||||
- Umsortieren mit einer unvollständigen Liste, mit einer unbekannten Kennung oder mit der Kennung eines fremden Reiters wird abgewiesen, ohne eine einzige Position zu ändern.
|
||||
</behavior>
|
||||
<action>
|
||||
Zuerst die Testfälle aus dem Verhaltensblock in `dashboard.service.spec.ts` schreiben (rot), dann den
|
||||
Dienst ergänzen.
|
||||
|
||||
Dienst: `createDashboard`, `renameDashboard`, `deleteDashboard`, `reorderDashboards`. Anlegen und
|
||||
Umbenennen laufen als Einzeloperationen über `forTenant(...)`; Löschen und Umsortieren laufen je als EINE
|
||||
`withTenantTransaction`, weil sie mehrere Schritte atomar brauchen — dieselbe Begründung, die der
|
||||
Kopfkommentar von `prisma-tenant.extension.ts` für `favorites.service.ts` festhält. Innerhalb der
|
||||
Transaktion trägt jede Bedingung die Benutzerkennung selbst, weil diese Form keine Benutzerdimension in
|
||||
der Sitzung setzt.
|
||||
|
||||
Namensvergabe (D-08): die vorhandenen Namen des Benutzers lesen und die kleinste Zahl ab 2 wählen, für
|
||||
die der zusammengesetzte Name noch frei ist. Der deutsche Kommentar hält fest, dass dieser Name ein
|
||||
gespeicherter Datenwert ist und keine Oberflächenbeschriftung — deshalb steht er hier und nicht in den
|
||||
Übersetzungsdateien, genau wie der Name, den die Migration vergibt.
|
||||
|
||||
Löschen: Riegel zuerst, dann die Zahl der Reiter des Benutzers prüfen (bei eins abweisen mit einer
|
||||
deutschen Konfliktmeldung in der Sie-Form), dann in der Transaktion die Kacheln des Reiters, die
|
||||
Anordnung des Reiters und den Reiter selbst entfernen und zuletzt die Positionen der verbleibenden Reiter
|
||||
lückenlos neu schreiben. Die Löschweitergabe in der Datenbank bleibt als zweites Netz bestehen; der
|
||||
geschriebene Weg ist der gebundene.
|
||||
|
||||
Umsortieren: wörtlich nach dem Muster `FavoritesService.reorder`.
|
||||
|
||||
DTOs: `rename-dashboard.dto.ts` mit Beschneiden und Längenprüfung 1 bis 40; `reorder-dashboards.dto.ts`
|
||||
mit Feldprüfung auf ein dublettenfreies Feld von 1 bis 20 Kennungen. Beide mit deutschem Kommentar, der
|
||||
die Obergrenze als Riegel gegen Massenanfragen benennt.
|
||||
|
||||
Controller: die fünf Reiter-Wege ergänzen. Die Route mit dem festen Bestandteil für das Umsortieren MUSS
|
||||
vor den Routen mit Platzhalter stehen — in dieser Anwendung hat eine Route mit Platzhalter schon einmal
|
||||
eine dahinter stehende feste Route verdeckt, und Einzeltests am Dienst fangen das nicht.
|
||||
|
||||
`dashboard.controller.spec.ts` neu anlegen (Form aus `dashboard-images.controller.spec.ts`): die
|
||||
Reiter-Kennung wird aus Abfrageparameter bzw. Rumpf an den Dienst durchgereicht; fehlender Benutzer- oder
|
||||
Mandantenkontext führt zur vorhandenen Abweisung; und ein quelltextlesender Wächter prüft, dass die
|
||||
Stelle des festen Wegs für das Umsortieren im Dateitext VOR der ersten Stelle mit Platzhalter unter
|
||||
demselben Präfix liegt — mit einer Fehlermeldung, die den Grund nennt.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/api test 2>&1 | tail -20</automated>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm type-check && pnpm lint</automated>
|
||||
</verify>
|
||||
<done>
|
||||
`pnpm --filter @tessera/api test` grün mit ≥ 1202 Tests in ≥ 77 Dateien; `dashboard.service.spec.ts`
|
||||
trägt ≥ 45 Tests (die 31 alten unverändert), `dashboard.controller.spec.ts` ≥ 6. Jeder Punkt des
|
||||
Verhaltensblocks hat einen eigenen Testfall, insbesondere je einer für „fremder Reiter“ bei Umbenennen,
|
||||
Löschen und Umsortieren und einer für „letzter Reiter bleibt“. `pnpm type-check` 4/4, `pnpm lint` 5/5 mit
|
||||
unverändert 53 Warnungen in web.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 3: Reiterleiste in der Oberfläche — wechseln, anlegen, umbenennen, löschen</name>
|
||||
<files>apps/web/src/lib/dashboard-api.ts, apps/web/src/lib/stores/dashboard-store.ts, apps/web/src/lib/stores/dashboard-store.test.ts, apps/web/src/components/dashboard/dashboard-tabs.tsx, apps/web/src/components/dashboard/dashboard-tabs.test.tsx, apps/web/src/app/(portal)/page.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json</files>
|
||||
<read_first>apps/web/src/lib/stores/dashboard-store.ts, apps/web/src/lib/stores/dashboard-store.test.ts, apps/web/src/lib/dashboard-api.ts, apps/web/src/app/(portal)/page.tsx, apps/web/src/components/dashboard/widget-catalog-modal.tsx (Form eines deutschen Bedienbausteins mit next-intl), apps/web/src/messages/de.json Abschnitt `widgets`</read_first>
|
||||
<behavior>
|
||||
- Beim Laden holt der Store zuerst die Reiter, macht den ERSTEN aktiv und lädt erst danach dessen Kacheln und Anordnung.
|
||||
- Zweimaliges Aufrufen des Ladens hintereinander löst nur EINEN Abruf der Reiterliste aus (Schutz gegen doppeltes Einhängen).
|
||||
- Ein Reiterwechsel mit ungespeicherter Anordnung schreibt die Anordnung zuerst für den ALTEN Reiter und wechselt erst danach — die gespeicherte Kennung ist die des alten Reiters, nicht die des neuen.
|
||||
- Nach einem Reiterwechsel stehen im Zustand ausschließlich die Kacheln und die Anordnung des neuen Reiters.
|
||||
- Eine hinzugefügte Kachel wird mit der Kennung des aktiven Reiters angelegt.
|
||||
- Anlegen eines Reiters hängt ihn hinten an und macht ihn aktiv; die Kachelfläche ist leer.
|
||||
- Löschen des aktiven Reiters macht den dann ersten Reiter aktiv und lädt dessen Inhalt.
|
||||
- Die Reiterleiste zeigt Umbenennen und Löschen nur im Bearbeitungsmodus; beim letzten verbleibenden Reiter wird Löschen gar nicht erst angeboten.
|
||||
</behavior>
|
||||
<action>
|
||||
Zuerst die Testfälle aus dem Verhaltensblock schreiben (rot), dann umsetzen.
|
||||
|
||||
`dashboard-api.ts`: Abrufe für die fünf Reiter-Wege ergänzen und die vier bestehenden Aufrufe um die
|
||||
Reiter-Kennung erweitern (Leseweg als Abfrageparameter, Schreibweg im Rumpf), in derselben Form wie die
|
||||
vorhandenen Funktionen (gleiche Basisadresse, `credentials`, Fehlerwurf bei nicht erfolgreicher Antwort).
|
||||
|
||||
`dashboard-store.ts`: Zustand um die Reiterliste, die aktive Reiter-Kennung und ein Kennzeichen für den
|
||||
laufenden Reiterwechsel erweitern. `loadDashboard` holt die Reiter, setzt den ersten als aktiv und lädt
|
||||
dessen Inhalt; ein auf Modulebene gehaltenes Versprechen verhindert, dass ein zweites Einhängen einen
|
||||
zweiten Abruf auslöst. `selectDashboard` schreibt bei ungespeicherter Anordnung zuerst für den alten
|
||||
Reiter (Kennung zum Aufrufzeitpunkt festhalten, nicht nach dem Wechsel lesen) und lädt danach Kacheln und
|
||||
Anordnung des neuen Reiters. `createDashboard`, `renameDashboard`, `deleteDashboard` pflegen die
|
||||
Reiterliste und den aktiven Reiter. Die einmalige Umrechnung alter Rastereinheiten
|
||||
(`migrateGridLayouts`/`withGridVersion`, quick-260916-bwo) bleibt unverändert und gilt weiterhin je
|
||||
Reiter — der Marker muss auch beim Speichern nach einem Reiterwechsel mitgeschrieben werden.
|
||||
|
||||
`dashboard-tabs.tsx` neu: waagerechte Leiste über dem Raster, jeder Reiter ein Bedienelement mit seinem
|
||||
Namen, der aktive sichtbar hervorgehoben, Beschriftungen und Hinweise ausschließlich über next-intl.
|
||||
Klick wechselt. Im Bearbeitungsmodus zusätzlich: ein Knopf zum Anlegen am Ende der Leiste, je Reiter ein
|
||||
Knopf zum Löschen (beim letzten verbleibenden nicht vorhanden) und auf dem aktiven Reiter ein Knopf zum
|
||||
Umbenennen, der den Namen an Ort und Stelle in ein Eingabefeld verwandelt (Eingabetaste übernimmt,
|
||||
Escape verwirft). Vor dem Löschen eine kurze Rückfrage mit dem Namen des Reiters. Die Bedienelemente
|
||||
tragen barrierefreie Beschriftungen in derselben Form wie die vorhandenen Dashboard-Bausteine.
|
||||
|
||||
`(portal)/page.tsx`: die Leiste über dem Raster einhängen und während eines Reiterwechsels statt des
|
||||
Rasters eine kurze Ladezeile zeigen — die Leiste selbst bleibt dabei stehen. Die bestehende
|
||||
ganzseitige Ladeschranke für den allerersten Abruf bleibt unverändert, ebenso die Aktionsleiste unten
|
||||
rechts und der Kachelkatalog. An `DashboardGrid` wird NICHTS geändert (D-06).
|
||||
|
||||
Übersetzungen: neue Schlüssel unter `widgets.tabs.*` in `de.json` UND `en.json` anlegen. Deutsch in der
|
||||
Sie-Form mit echten Umlauten — der Wächter in `apps/web/src/messages/umlaut-guard.spec.ts` liest `de.json`
|
||||
und schlägt bei Ersatzschreibweisen fehl.
|
||||
|
||||
Die Tests für die Leiste laufen unter jsdom; der Store wird darin nach dem Muster der vorhandenen
|
||||
Dashboard-Tests über gemockte Abrufe bedient.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/web test 2>&1 | tail -12</automated>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm type-check && pnpm lint</automated>
|
||||
</verify>
|
||||
<done>
|
||||
`pnpm --filter @tessera/web test` grün mit ≥ 661 Tests in ≥ 82 Dateien; `dashboard-store.test.ts` trägt
|
||||
≥ 14 Tests (die 6 alten unverändert), `dashboard-tabs.test.tsx` ≥ 6. `dashboard-grid.test.tsx` bleibt bei
|
||||
12 Tests und unverändertem Inhalt. `pnpm type-check` 4/4, `pnpm lint` 5/5 mit unverändert 53 Warnungen in
|
||||
web, `as unknown as` in `apps/web/src` ohne Testdateien weiterhin 3.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 4: Reiter per Ziehen umsortieren — der erste ist der Standard</name>
|
||||
<files>apps/web/src/components/dashboard/dashboard-tabs.tsx, apps/web/src/components/dashboard/dashboard-tabs.test.tsx, apps/web/src/lib/stores/dashboard-store.ts, apps/web/src/lib/stores/dashboard-store.test.ts, apps/web/src/messages/de.json, apps/web/src/messages/en.json</files>
|
||||
<read_first>apps/web/src/components/settings/xframe-config-form.tsx (Pointer-Muster: Schutzhülle um setPointerCapture/releasePointerCapture für jsdom, Merken des Ziehzustands in einem Ref, Behandlung von Bewegen, Loslassen und Abbruch), apps/web/src/components/settings/xframe-config-form.test.tsx (wie Ziehen unter jsdom gemessen wird)</read_first>
|
||||
<behavior>
|
||||
- Drücken und Loslassen ohne nennenswerte Bewegung wechselt nur den Reiter und sendet KEINE neue Reihenfolge.
|
||||
- Drücken, um mehr als die Schwelle nach rechts bewegen und loslassen verschiebt den Reiter hinter seinen rechten Nachbarn und sendet die vollständige Kennungsliste in der neuen Reihenfolge.
|
||||
- Dasselbe nach links verschiebt vor den linken Nachbarn.
|
||||
- Während des Ziehens zeigt die Leiste die Vorschau der neuen Reihenfolge; beim Abbruch des Zeigers wird die Vorschau verworfen und nichts gesendet.
|
||||
- Ein Ziehen, das den ersten Reiter verdrängt, macht den vorgezogenen Reiter zum ersten — ein erneutes Laden beginnt bei diesem Reiter.
|
||||
- Schlägt das Speichern der Reihenfolge fehl, steht die vorherige Reihenfolge wieder in der Leiste.
|
||||
</behavior>
|
||||
<action>
|
||||
Zuerst die Testfälle aus dem Verhaltensblock schreiben (rot), dann umsetzen.
|
||||
|
||||
Das Ziehen in `dashboard-tabs.tsx` über Pointer-Ereignisse nach dem Muster aus `xframe-config-form.tsx`
|
||||
(D-05, keine neue Abhängigkeit): beim Drücken Startpunkt, Zeigerkennung und Ausgangsindex in einem Ref
|
||||
merken; beim Bewegen erst ab einer Schwelle von 4 Pixeln waagerechter Auslenkung in den Ziehzustand
|
||||
wechseln und den Zeiger einfangen — darunter bleibt es ein Klick; den Zielindex aus den Mittelpunkten der
|
||||
gemessenen Reiterflächen gegen die Zeigerposition bestimmen und die Leiste in der Vorschau-Reihenfolge
|
||||
zeichnen; beim Loslassen den Zeiger freigeben, die Vorschau leeren und die vollständige Kennungsliste an
|
||||
den Store geben; beim Abbruch den Zeiger freigeben und die Vorschau verwerfen, ohne zu senden. Das
|
||||
Einfangen und Freigeben des Zeigers läuft über dieselben kleinen Schutzhüllen wie im Vorbild, weil jsdom
|
||||
diese beiden Fähigkeiten nicht kennt.
|
||||
|
||||
Ziehen ist IMMER möglich, nicht nur im Bearbeitungsmodus: nach vorn ziehen IST das Festlegen des
|
||||
Standards (D-09), und dafür soll der Benutzer nicht erst in den Bearbeitungsmodus wechseln müssen. Ein
|
||||
kurzer Hinweistext über next-intl erklärt, dass der erste Reiter beim Öffnen geladen wird.
|
||||
|
||||
Im Store: `reorderDashboards` setzt die neue Reihenfolge sofort im Zustand, sendet sie und stellt bei
|
||||
einem Fehler die vorherige Reihenfolge wieder her. Der aktive Reiter bleibt dabei aktiv, auch wenn er
|
||||
seine Position wechselt.
|
||||
|
||||
Für die Messung unter jsdom: die Reiterflächen liefern dort keine echten Maße. In den Tests wird die
|
||||
Flächenmessung der Reiterknöpfe so ersetzt, dass Reiter i die Spanne von i mal 100 bis i mal 100 plus 100
|
||||
belegt; die Zeigerpositionen der Tests rechnen gegen genau diese Spannen. Der deutsche Kommentar im Test
|
||||
hält fest, warum das nötig ist.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/web test 2>&1 | tail -12</automated>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm type-check && pnpm lint</automated>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && grep -c "react-grid-layout\|dnd\|sortable" apps/web/package.json</automated>
|
||||
</verify>
|
||||
<done>
|
||||
`pnpm --filter @tessera/web test` grün mit ≥ 661 Tests in ≥ 83 Dateien; `dashboard-tabs.test.tsx` trägt
|
||||
≥ 12 Tests, davon je einer für jeden Punkt des Verhaltensblocks. Die Zählung in `apps/web/package.json`
|
||||
liefert weiterhin genau 1 (der vorhandene Rastereintrag) — es ist keine Zieh-Abhängigkeit hinzugekommen
|
||||
(D-05). `pnpm type-check` 4/4, `pnpm lint` 5/5 mit unverändert 53 Warnungen in web.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 5: Anwenderhandbuch, Changelog und Nachmessung aller Tore</name>
|
||||
<files>docs/anleitung-anwender.md, CHANGELOG.md, docs/mandantentrennung-zugriffsklassifikation.md</files>
|
||||
<read_first>docs/anleitung-anwender.md Zeile 59-70 (Abschnitt Dashboard), CHANGELOG.md Kopf (Abschnitt „Unveröffentlicht“)</read_first>
|
||||
<action>
|
||||
`docs/anleitung-anwender.md`, Abschnitt Dashboard: die Reiter in Alltagssprache beschreiben — mehrere
|
||||
Dashboards nebeneinander, jeder Reiter mit eigenen Kacheln und eigener Anordnung, Reiter mit der Maus an
|
||||
eine andere Stelle ziehen, der erste Reiter wird beim Öffnen geladen, Anlegen/Umbenennen/Löschen im
|
||||
Bearbeitungsmodus, der letzte Reiter bleibt. Kein Fachbegriff, Sie-Form, echte Umlaute, Stil der
|
||||
umliegenden Absätze.
|
||||
|
||||
`CHANGELOG.md`: unter „Unveröffentlicht“ einen Abschnitt „### Neu“ mit EINEM Stichpunkt in derselben
|
||||
Sprache wie die Nachbareinträge — was der Benutzer sieht und kann, nicht wie es gebaut ist. Der
|
||||
Stichpunkt sagt ausdrücklich, dass vorhandene Kacheln unverändert auf dem ersten Reiter liegen bleiben.
|
||||
|
||||
`docs/mandantentrennung-zugriffsklassifikation.md`: die in Task 1 eingetragenen Zahlen (Bereichszeile
|
||||
`dashboard`, Summenzeile, Paarzahl) gegen den ENDSTAND nach Task 2 erneut mit der Zählschleife des Gates
|
||||
nachrechnen und, falls Task 2 weitere Rohtreffer hinzugefügt hat, korrigieren. Die Begründungsspalte der
|
||||
neuen Zeile um den Hinweis ergänzen, dass Löschen und Umsortieren über `withTenantTransaction` laufen und
|
||||
jede Bedingung darin die Benutzerkennung selbst trägt (Form `favorites.service.ts`/`reorder`).
|
||||
|
||||
Zum Schluss alle Tore in einem Durchgang nachmessen und die Zahlen im SUMMARY festhalten.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/api test 2>&1 | tail -6 && pnpm --filter @tessera/web test 2>&1 | tail -6 && pnpm type-check && pnpm lint</automated>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && grep -c "Reiter" docs/anleitung-anwender.md CHANGELOG.md</automated>
|
||||
</verify>
|
||||
<done>
|
||||
Handbuch und Changelog beschreiben die Reiter in Alltagssprache (die Zählung liefert für beide Dateien
|
||||
mindestens 1). Die Zugriffsklassifikation trägt nachgerechnete, nicht abgeschriebene Zahlen. Nachgemessen
|
||||
und im SUMMARY festgehalten: api ≥ 1202 Tests in ≥ 77 Dateien grün, web ≥ 661 Tests in ≥ 83 Dateien grün,
|
||||
`pnpm type-check` 4/4, `pnpm lint` 5/5 mit genau 53 Warnungen in web.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Browser → API | Jede Reiter-Kennung kommt aus dem Browser und ist unvertrauenswürdig — Abfrageparameter und Rümpfe sind frei wählbar |
|
||||
| Benutzer → Benutzer (derselbe Mandant) | Die Regeln der persönlichen Tabellen tragen die Benutzerdimension, wirken aber erst mit der Rolle ohne Umgehungsrecht (Schalter heute aus) — die anwendungsseitigen Besitzprüfungen sind bis dahin der einzige wirksame Schutz |
|
||||
| Mandant → Mandant | `forTenant()` setzt die Mandantenkennung je Abfrage; die neue Tabelle braucht dieselbe Regel wie ihre Nachbarn |
|
||||
| Migration → Bestandsdaten | Die Migration läuft als Superuser und umgeht den Zeilenschutz — eine falsche Zuordnung würde Kacheln über Benutzergrenzen verschieben |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-AD9-01 | Information Disclosure | `GET /dashboard/widgets`, `GET /dashboard/layout` mit fremder Reiter-Kennung | high | mitigate | `assertOwnedDashboard` vor jeder Abfrage; identische Nicht-gefunden-Antwort für „gibt es nicht“, „Kollege“, „fremder Mandant“ (Task 1, je ein Test) |
|
||||
| T-AD9-02 | Tampering | `POST /dashboard/widgets`, `PUT /dashboard/layout` mit fremder Reiter-Kennung | high | mitigate | Derselbe Riegel vor dem Schreiben, auf demselben gebundenen Klienten wie die anschließende Schreiboperation (Task 1, je ein Test) |
|
||||
| T-AD9-03 | Tampering | `PATCH`/`DELETE /dashboard/tabs/:id` mit fremder Kennung | high | mitigate | Riegel zuerst; Löschen zusätzlich mit Bedingung auf die Benutzerkennung innerhalb der Transaktion (Task 2, je ein Test) |
|
||||
| T-AD9-04 | Tampering | `PUT /dashboard/tabs/order` mit fremden oder unbekannten Kennungen | high | mitigate | Exakt-Abgleich gegen die gelesenen Kennungen innerhalb der Transaktion, Prüfung auf genau eine getroffene Zeile je Schritt, Abweisung ohne Teilschreiben (Task 2, zwei Tests) |
|
||||
| T-AD9-05 | Information Disclosure | Neue Tabelle `Dashboard` ohne Zeilenschutz | high | mitigate | `tenantId` als Pflichtspalte, ENABLE + FORCE + `tenant_isolation_policy` mit Benutzerdimension in der Migration; `rls-coverage.spec.ts` bleibt grün (Task 1) |
|
||||
| T-AD9-06 | Denial of Service | Unbegrenzt viele Reiter, unbegrenzt lange Kennungsliste | medium | mitigate | Höchstens 20 Reiter je Benutzer, Kennungsliste höchstens 20 Einträge und dublettenfrei, Name höchstens 40 Zeichen (Task 2) |
|
||||
| T-AD9-07 | Tampering | Doppelte Anlage des ersten Reiters bei zwei gleichzeitigen ersten Aufrufen | low | mitigate | Transaktionssperre auf die Benutzerkennung mit erneuter Zählung innerhalb der Sperre; zusätzlich Schutz gegen doppeltes Laden im Store (Task 1 und Task 3) |
|
||||
| T-AD9-08 | Elevation of Privilege | Migration ordnet Kacheln dem falschen Benutzer zu | high | mitigate | Zuordnung ausschließlich über die Benutzerkennung der Bestandszeile; Nachweis per Zählabfrage (0 Kacheln ohne Reiter, Reiterzahl gleich der Zahl der Benutzer mit Bestand) direkt im Verify von Task 1 |
|
||||
| T-AD9-09 | Spoofing | Reitername mit eingebettetem Markup | low | accept | Namen werden als Text gerendert, React maskiert von sich aus; zusätzlich Längenbegrenzung. Kein eigener Filter — er wäre die zweite Wahrheit neben dem Maskieren |
|
||||
| T-AD9-SC | Tampering | Lieferkette (Paketinstallation) | low | accept | Dieser Plan installiert KEIN Paket (D-05) — das Ziehen läuft über das im Repo vorhandene Pointer-Muster. Der Prüfpunkt für Paketechtheit entfällt mangels Installation; Task 4 misst die Abwesenheit einer neuen Zieh-Abhängigkeit nach |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
Nach Task 5, in einem Durchgang und mit notierten Zahlen:
|
||||
|
||||
```
|
||||
cd /home/vicolab/projects/tessera-ctl
|
||||
pnpm --filter @tessera/api test
|
||||
pnpm --filter @tessera/web test
|
||||
pnpm type-check
|
||||
pnpm lint
|
||||
DB_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' tessera-ctl-db-1)
|
||||
DATABASE_URL="postgresql://tessera:tessera_dev@${DB_IP}:5432/tessera" \
|
||||
pnpm --filter @tessera/api exec prisma migrate diff \
|
||||
--from-url "postgresql://tessera:tessera_dev@${DB_IP}:5432/tessera" \
|
||||
--to-schema-datamodel prisma/schema.prisma --exit-code
|
||||
```
|
||||
|
||||
**Prüfliste für den Browser-Rundgang** (gehört ins SUMMARY, nicht in diesen Plan auszuführen — der Nutzer
|
||||
baut und startet die Container selbst):
|
||||
|
||||
1. Anmelden, Dashboard öffnen: genau ein Reiter „Dashboard“, alle bisherigen Kacheln liegen unverändert
|
||||
an ihrem Platz.
|
||||
2. Bearbeitungsmodus, Reiter hinzufügen: neuer Reiter „Dashboard 2“ am Ende, Fläche leer, der neue Reiter
|
||||
ist aktiv.
|
||||
3. Auf „Dashboard 2“ eine Kachel setzen, zurück auf „Dashboard“ wechseln: die alten Kacheln stehen
|
||||
unverändert da, die neue Kachel ist NICHT dabei.
|
||||
4. Kachel auf „Dashboard 2“ verschieben, ohne zu speichern den Reiter wechseln und zurückwechseln: die
|
||||
verschobene Anordnung ist erhalten.
|
||||
5. „Dashboard 2“ an die erste Stelle ziehen, Seite neu laden: „Dashboard 2“ steht vorn und wird geladen.
|
||||
6. „Dashboard 2“ umbenennen, Seite neu laden: der neue Name steht da.
|
||||
7. „Dashboard 2“ löschen: Kacheln dieses Reiters sind weg, der andere Reiter ist vollständig da.
|
||||
8. Bis auf einen Reiter alles löschen: beim letzten wird Löschen nicht mehr angeboten.
|
||||
9. Raster gegenmessen (D-06): eine Kachel auf einen belegten Platz ziehen — sie bleibt am Ausgangsort,
|
||||
nichts weicht aus; das Raster reicht bis zum rechten Rand des Inhaltsbereichs.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Jeder Punkt unter `must_haves.truths` ist erfüllt und durch einen Test oder eine Messung belegt.
|
||||
- Fünf Aufgaben, fünf abgeschlossene Commits in der Form der Nachbarcommits
|
||||
(`feat(quick-260923-ad9): …`, `docs(quick-260923-ad9): …`).
|
||||
- Kein Punkt aus „Nicht im Umfang“ wurde angefasst; keine neue Abhängigkeit in `apps/web/package.json`
|
||||
oder `apps/api/package.json`.
|
||||
- `DashboardGrid` ist unverändert; `dashboard-grid.test.tsx` steht unverändert bei 12 Tests.
|
||||
- Die Zugriffsklassifikation ist nachgerechnet, nicht abgeschrieben.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
SUMMARY nach `.planning/quick/260923-ad9-dashboard-reiter-mehrere-dashboards-je-b/260923-ad9-SUMMARY.md`
|
||||
schreiben: gemessene Torzahlen vorher/nachher, die Zählabfrage der Bestandsübernahme mit ihrem Ergebnis,
|
||||
die getroffenen Detailentscheidungen mit Begründung, die Prüfliste für den Browser-Rundgang und der
|
||||
Hinweis, dass diese Änderung eine Datenbankänderung enthält und deshalb als reguläre Version über `main`
|
||||
geht (D-03).
|
||||
</output>
|
||||
+168
@@ -0,0 +1,168 @@
|
||||
---
|
||||
phase: quick-260923-ad9
|
||||
plan: 01
|
||||
subsystem: dashboard
|
||||
tags: [dashboard, reiter, rls, migration, frontend, drag-and-drop]
|
||||
dependency-graph:
|
||||
requires: []
|
||||
provides: [dashboard-tabs, multi-dashboard-model]
|
||||
affects: [apps/api/src/dashboard, apps/web/src/components/dashboard, apps/web/src/lib/stores/dashboard-store.ts]
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns: [forTenant-per-method, withTenantTransaction-multi-step, pointer-drag-with-threshold]
|
||||
key-files:
|
||||
created:
|
||||
- apps/api/prisma/migrations/20260923120000_dashboard_tabs/migration.sql
|
||||
- apps/api/src/dashboard/dto/rename-dashboard.dto.ts
|
||||
- apps/api/src/dashboard/dto/reorder-dashboards.dto.ts
|
||||
- apps/api/src/dashboard/dashboard.controller.spec.ts
|
||||
- apps/web/src/components/dashboard/dashboard-tabs.tsx
|
||||
- apps/web/src/components/dashboard/dashboard-tabs.test.tsx
|
||||
modified:
|
||||
- apps/api/prisma/schema.prisma
|
||||
- apps/api/src/dashboard/dashboard.service.ts
|
||||
- apps/api/src/dashboard/dashboard.service.spec.ts
|
||||
- apps/api/src/dashboard/dashboard.controller.ts
|
||||
- apps/api/src/dashboard/dto/save-layout.dto.ts
|
||||
- apps/api/src/dashboard/dto/create-widget.dto.ts
|
||||
- apps/api/src/dashboard/widget-module-map.spec.ts
|
||||
- apps/web/src/lib/dashboard-api.ts
|
||||
- apps/web/src/lib/stores/dashboard-store.ts
|
||||
- apps/web/src/lib/stores/dashboard-store.test.ts
|
||||
- apps/web/src/app/(portal)/page.tsx
|
||||
- apps/web/src/app/(portal)/page.test.tsx
|
||||
- apps/web/src/app/(portal)/settings/dashboard/page.tsx
|
||||
- apps/web/src/messages/de.json
|
||||
- apps/web/src/messages/en.json
|
||||
- docs/mandantentrennung-zugriffsklassifikation.md
|
||||
- docs/anleitung-anwender.md
|
||||
- CHANGELOG.md
|
||||
decisions:
|
||||
- "D-01 bis D-10 aus dem Plan wörtlich umgesetzt, keine Abweichung: kein Unique auf (userId, position), kein Standard-Feld (Nach-vorn-Ziehen IST der Standard), Namensvergabe füllt Lücken, letzter Reiter bleibt."
|
||||
- "assertOwnedDashboard nimmt den bereits gebundenen Klienten als Parameter (statt selbst forTenant() zu rufen) — hält die bestehende Testinvariante 'genau ein gebundener Klient je Methode' aufrecht."
|
||||
- "Reiterwechsel: ungespeicherte Anordnung wird über get().saveLayout() VOR dem set() der neuen activeDashboardId geschrieben — kein Parameter nötig, die Store-Closure liest die alte Kennung von selbst."
|
||||
- "Ziehen der Reiter läuft über Pointer-Events mit 4px-Schwelle, computeReorderedIds bestimmt die Zielposition über Mittelpunkte der (in Tests gestubbten) Reiter-Rects — Muster xframe-config-form.tsx, keine neue Abhängigkeit (D-05)."
|
||||
metrics:
|
||||
duration: "~2.5h"
|
||||
completed: 2026-09-23
|
||||
actuals:
|
||||
tokens: 45514
|
||||
tasks: 5
|
||||
commits: 5
|
||||
plan_head_before: 84fe73e
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Phase quick-260923-ad9 Plan 01: Dashboard-Reiter — mehrere Dashboards je Benutzer Summary
|
||||
|
||||
Jeder Benutzer hat jetzt mehrere Dashboards ("Reiter"), oben nebeneinander in einer Leiste, jeder mit eigenen Kacheln und eigener Anordnung, per Ziehen umsortierbar, der erste geladen beim Öffnen — bestehende Kacheln landeten unverändert auf einem einzigen Reiter "Dashboard".
|
||||
|
||||
## Datenbankänderung — geht als reguläre Version über main
|
||||
|
||||
**Wichtig für die Freigabe (D-03):** dieser Plan enthält eine Datenbankmigration (`20260923120000_dashboard_tabs`), die Bestandsdaten umhängt (`WidgetInstance`/`DashboardLayout` bekommen `dashboardId`, `DashboardLayout` verliert die Eindeutigkeit auf `userId`). Das geht als reguläre Version über `main`, **nicht als Hotfix**.
|
||||
|
||||
## Gemessene Torzahlen
|
||||
|
||||
| Tor | Vorher (gemessen 23.09. vor Beginn) | Nachher (gemessen nach Task 5) |
|
||||
|---|---|---|
|
||||
| `pnpm --filter @tessera/api test` | 1202 Tests, 76 Dateien | **1240 Tests, 77 Dateien** |
|
||||
| davon `dashboard.service.spec.ts` | 31 Tests | **61 Tests** |
|
||||
| davon `dashboard.controller.spec.ts` | (Datei existierte nicht) | **8 Tests** (neu) |
|
||||
| `rls-coverage.spec.ts` / `rls-access-inventory.spec.ts` | 5 / 30 | **5 / 30** (unverändert grün) |
|
||||
| `pnpm --filter @tessera/web test` | 661 Tests, 81 Dateien | **693 Tests, 82 Dateien** |
|
||||
| davon `dashboard-store.test.ts` | 6 Tests | **17 Tests** |
|
||||
| davon `dashboard-tabs.test.tsx` | (Datei existierte nicht) | **21 Tests** (neu) |
|
||||
| `dashboard-grid.test.tsx` | 12 Tests | **12 Tests** (unverändert, Datei nicht angefasst — D-06) |
|
||||
| `pnpm type-check` | 4/4 | **4/4** |
|
||||
| `pnpm lint` | 5/5, genau 53 Warnungen in web | **5/5, genau 53 Warnungen in web** |
|
||||
| `as unknown as` in `apps/web/src` ohne Testdateien | 3 | **3** |
|
||||
| Prisma-Abweichung lokal | „No difference detected.“ | **„No difference detected.“** |
|
||||
|
||||
## Zählabfrage der Bestandsübernahme (Task 1, gegen die lokale Datenbank)
|
||||
|
||||
```
|
||||
kacheln_ohne_reiter | anordnungen_ohne_reiter | reiter | reiter_nicht_an_position_null
|
||||
0 | 0 | 2 | 0
|
||||
```
|
||||
|
||||
0 Kacheln ohne Reiter, 0 Anordnungen ohne Reiter, genau 2 Reiter (deckt sich mit der vor Beginn gemessenen Zahl von 2 Benutzern mit Bestand), 0 Reiter abseits von Position 0 — jeder der beiden vorhandenen Benutzer mit Kacheln/Anordnung hat jetzt genau einen Reiter „Dashboard" auf Position 0.
|
||||
|
||||
## Abweichungen von der im Plan gemessenen Erwartung (keine Rule-1/2/3-Fälle — reine Zahlendifferenzen, dokumentiert statt stillschweigend übersprungen)
|
||||
|
||||
1. **`apps/web` Dateizahl 82 statt der für Task 4 erwarteten 83.** Task 3 brachte die Web-Testdateizahl bereits auf 82 (neue Datei `dashboard-tabs.test.tsx`); Task 4 fügt laut seiner eigenen `files_modified`-Liste keine weitere neue Testdatei hinzu, sondern erweitert nur bestehende. Die Plan-Erwartung „≥83" für Task 4 war auf eine damals noch nicht vorhersehbare zusätzliche Datei ausgelegt, die nie gebraucht wurde — alle Verhaltenspunkte sind vollständig mit 21 Tests in `dashboard-tabs.test.tsx` und 17 in `dashboard-store.test.ts` abgedeckt.
|
||||
2. **`grep -c "react-grid-layout|dnd|sortable" apps/web/package.json` liefert 2 statt 1.** Der zweite Treffer ist `@types/react-grid-layout`, bereits vor diesem Plan vorhanden — `git diff --stat` auf `apps/web/package.json` über alle fünf Commits ist leer. D-05 (keine neue Zieh-Abhängigkeit) ist damit nachgewiesen, nur über die leere package.json-Diff statt über die im Plan vorausgesagte Grep-Zahl.
|
||||
|
||||
## Auto-fixed Issues (Deviations, Rule 1/3)
|
||||
|
||||
**1. [Rule 1] `widget-module-map.spec.ts` — `CreateWidgetDto`-Validierungstest ohne `dashboardId`-Fixture**
|
||||
- **Gefunden während:** Task 1, nach Hinzufügen von `dashboardId` als Pflichtfeld auf `CreateWidgetDto`.
|
||||
- **Problem:** Der bestehende Whitelist-Test rief `plainToInstance(CreateWidgetDto, { widgetType })` ohne `dashboardId` — schlug jetzt mit einem zusätzlichen Validierungsfehler fehl.
|
||||
- **Fix:** `dashboardId: 'dash-1'` fest mitgegeben, Kommentar ergänzt.
|
||||
- **Commit:** 9c51823 (Task 1)
|
||||
|
||||
**2. [Rule 3] `settings/dashboard/page.tsx` — `fetchWidgets()` verlangt jetzt eine Reiter-Kennung**
|
||||
- **Gefunden während:** Task 3, nach Umstellung von `fetchWidgets` auf `fetchWidgets(dashboardId)`.
|
||||
- **Problem:** Diese Einstellungsseite (Widget-Konfiguration) war nicht Teil des Plan-Umfangs für Reiterbewusstsein, hätte aber nicht mehr kompiliert.
|
||||
- **Fix:** Die Seite holt jetzt zuerst `fetchDashboards()` und zeigt die Kacheln des ERSTEN Reiters — deckungsgleich mit dem bisherigen Verhalten für den (weit überwiegenden) Fall genau eines Reiters. Volle Reiterauswahl auf dieser Seite ist außerhalb des Umfangs dieses Plans.
|
||||
- **Commit:** d34f682 (Task 3)
|
||||
|
||||
**3. [Rule 3] `(portal)/page.test.tsx` — `mockStore` ohne die neuen Reiter-Felder**
|
||||
- **Gefunden während:** Task 3, nach Einbau von `<DashboardTabs>` in `page.tsx`.
|
||||
- **Problem:** `dashboards` wäre `undefined` gewesen — `DashboardTabs` hätte auf `.map` einer `undefined`-Liste geworfen.
|
||||
- **Fix:** `dashboards: []`, `activeDashboardId: null`, `isSwitchingDashboard: false` sowie die vier neuen Store-Methoden als `vi.fn()` ergänzt.
|
||||
- **Commit:** d34f682 (Task 3)
|
||||
|
||||
Kein Punkt aus „Nicht im Umfang" wurde angefasst (kein Freigeben/Teilen von Dashboards, keine Vorlagen, keine Reiter je Modul). Keine neue Abhängigkeit in `apps/web/package.json` oder `apps/api/package.json`. `DashboardGrid` selbst ist unverändert.
|
||||
|
||||
## Prüfliste für den Browser-Rundgang (vom Nutzer auszuführen — Container-Neubau nötig)
|
||||
|
||||
1. Anmelden, Dashboard öffnen: genau ein Reiter „Dashboard", alle bisherigen Kacheln liegen unverändert an ihrem Platz.
|
||||
2. Bearbeitungsmodus, Reiter hinzufügen: neuer Reiter „Dashboard 2" am Ende, Fläche leer, der neue Reiter ist aktiv.
|
||||
3. Auf „Dashboard 2" eine Kachel setzen, zurück auf „Dashboard" wechseln: die alten Kacheln stehen unverändert da, die neue Kachel ist NICHT dabei.
|
||||
4. Kachel auf „Dashboard 2" verschieben, ohne zu speichern den Reiter wechseln und zurückwechseln: die verschobene Anordnung ist erhalten.
|
||||
5. „Dashboard 2" an die erste Stelle ziehen, Seite neu laden: „Dashboard 2" steht vorn und wird geladen.
|
||||
6. „Dashboard 2" umbenennen, Seite neu laden: der neue Name steht da.
|
||||
7. „Dashboard 2" löschen: Kacheln dieses Reiters sind weg, der andere Reiter ist vollständig da.
|
||||
8. Bis auf einen Reiter alles löschen: beim letzten wird Löschen nicht mehr angeboten.
|
||||
9. Raster gegenmessen (D-06): eine Kachel auf einen belegten Platz ziehen — sie bleibt am Ausgangsort, nichts weicht aus; das Raster reicht bis zum rechten Rand des Inhaltsbereichs.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
Keine — alle Punkte des Threat-Registers (T-AD9-01 bis T-AD9-SC) sind wie im Plan geplant mitigiert und mit eigenen Tests belegt (`assertOwnedDashboard` fail-closed über alle vier bestehenden Wege plus die vier neuen Reiter-Wege, Exakt-Abgleich vor jedem Schreiben bei `reorderDashboards`, Obergrenzen 20 Reiter/40 Zeichen, Transaktionssperre gegen Doppelanlage). Kein neuer Netzwerk-Endpunkt oder Auth-Pfad außerhalb der im Plan benannten fünf `tabs`-Routen.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
Alle sechs im Plan neu erwarteten Dateien gefunden, alle fünf Task-Commits im Log gefunden (siehe git log).
|
||||
|
||||
## Rundgang durch den Orchestrator (23.09.2026, lokaler Stack, Abbilder aus dem Commit danach)
|
||||
|
||||
Container neu gebaut (`up -d --build web api`), Migration lag beim Start bereits an
|
||||
(`41 migrations found`, `No pending migrations to apply`). Gemessen wurde am DOM, nicht per
|
||||
`fetch` — und beim Raster gegen die GESETZTEN Werte (`style.width`), nicht gegen die gemalte
|
||||
Box (Messfalle aus quick-260922-vdk).
|
||||
|
||||
| # | Geprueft | Ergebnis |
|
||||
|---|----------|----------|
|
||||
| 1 | Bestand nach der Migration | EIN Reiter „Dashboard" mit allen **5** vorhandenen Kacheln — nichts verloren |
|
||||
| 2 | Reiterleiste vorhanden | `<nav aria-label="Dashboard-Reiter">`, aktiver Reiter traegt `aria-current="true"` |
|
||||
| 3 | Ansichtsmodus | nur Reiter, keine Verwaltungsknoepfe |
|
||||
| 4 | Bearbeitungsmodus | „Dashboard umbenennen", „Dashboard hinzufuegen", je Reiter „Dashboard loeschen" |
|
||||
| 5 | Reiter anlegen | neuer Reiter „Dashboard 2", sofort aktiv, **leer** (0 Kacheln) |
|
||||
| 6 | Kacheln je Reiter getrennt | Uhr auf Reiter 2 → Reiter 2 hat 1 Kachel, Reiter 1 unveraendert 5 |
|
||||
| 7 | Ziehen ordnet um | echte Maus-Ereignisse: `[Dashboard, Dashboard 2]` → `[Dashboard 2, Dashboard]` |
|
||||
| 8 | **Erster Reiter ist Standard** | nach vollem Neuladen: „Dashboard 2" steht vorn, ist aktiv, zeigt seine eigene Kachel |
|
||||
| 9 | Umbenennen | Eingabefeld in der Leiste, `maxlength="40"`, Enter uebernimmt → „Technik" |
|
||||
| 10 | Loeschen mit Rueckfrage | `role="alertdialog"` + `aria-modal`, Text benennt den Reiter und warnt, dass Kacheln und Anordnung mitgehen |
|
||||
| 11 | Letzter Reiter bleibt | nach dem Loeschen: ein Reiter, **null** Loeschknoepfe |
|
||||
| 12 | Raster unveraendert | Bereich 1625 px → Kachel `style.width: 531px` = `8 × 59,375 + 56`, exakt der Sollwert (lg, 24 Spalten) |
|
||||
|
||||
**Kleiner Befund, nicht behoben (kein Blocker):** die Beschriftungen der Verwaltungsknoepfe
|
||||
lauten generisch „Dashboard umbenennen" / „Dashboard loeschen" und nennen nicht, WELCHEN Reiter
|
||||
sie treffen; bei mehreren Reitern liest eine Sprachausgabe also mehrfach denselben Text. Das
|
||||
Bestaetigungsfenster benennt den Reiter korrekt, der Schaden ist also begrenzt. Vorgemerkt fuer
|
||||
die naechste Arbeit an der Leiste.
|
||||
|
||||
**Eigener Messfehler, damit er nicht als Produktfehler stehenbleibt:** der erste Loeschversuch
|
||||
sah wie „passiert nichts" aus — tatsaechlich war das Bestaetigungsfenster offen, meine Abfrage
|
||||
suchte aber nur nach `[role="dialog"]`. Das Fenster traegt `role="alertdialog"`. Beim Pruefen
|
||||
auf beide Rollen abfragen.
|
||||
+170
@@ -0,0 +1,170 @@
|
||||
---
|
||||
phase: quick-260923-ad9
|
||||
verified: 2026-09-23T08:30:00Z
|
||||
status: human_needed
|
||||
score: 10/10 must-haves verified
|
||||
covered_files:
|
||||
- ".planning/quick/260923-ad9-dashboard-reiter-mehrere-dashboards-je-b/260923-ad9-PLAN.md"
|
||||
- ".planning/quick/260923-ad9-dashboard-reiter-mehrere-dashboards-je-b/260923-ad9-SUMMARY.md"
|
||||
- "CHANGELOG.md"
|
||||
- "apps/api/prisma/migrations/20260923120000_dashboard_tabs/migration.sql"
|
||||
- "apps/api/prisma/schema.prisma"
|
||||
- "apps/api/src/dashboard/dashboard.controller.spec.ts"
|
||||
- "apps/api/src/dashboard/dashboard.controller.ts"
|
||||
- "apps/api/src/dashboard/dashboard.service.spec.ts"
|
||||
- "apps/api/src/dashboard/dashboard.service.ts"
|
||||
- "apps/api/src/dashboard/dto/create-widget.dto.ts"
|
||||
- "apps/api/src/dashboard/dto/rename-dashboard.dto.ts"
|
||||
- "apps/api/src/dashboard/dto/reorder-dashboards.dto.ts"
|
||||
- "apps/api/src/dashboard/dto/save-layout.dto.ts"
|
||||
- "apps/api/src/dashboard/widget-module-map.spec.ts"
|
||||
- "apps/web/src/app/(portal)/page.test.tsx"
|
||||
- "apps/web/src/app/(portal)/page.tsx"
|
||||
- "apps/web/src/app/(portal)/settings/dashboard/page.tsx"
|
||||
- "apps/web/src/components/dashboard/dashboard-tabs.test.tsx"
|
||||
- "apps/web/src/components/dashboard/dashboard-tabs.tsx"
|
||||
- "apps/web/src/lib/dashboard-api.ts"
|
||||
- "apps/web/src/lib/stores/dashboard-store.test.ts"
|
||||
- "apps/web/src/lib/stores/dashboard-store.ts"
|
||||
- "apps/web/src/messages/de.json"
|
||||
- "apps/web/src/messages/en.json"
|
||||
- "docs/anleitung-anwender.md"
|
||||
- "docs/mandantentrennung-zugriffsklassifikation.md"
|
||||
covered_digest: "v1:sha256:9504969e14709ebba347c4443d9b00de67a4cbdc10aa49695103728d822abec9"
|
||||
behavior_unverified: 0
|
||||
overrides_applied: 0
|
||||
human_verification:
|
||||
- test: "Browser-Rundgang Punkte 1-9 aus dem SUMMARY (Anmelden, Reiter anlegen, Kachel setzen und Reiter wechseln, ungespeichert wechseln, per Ziehen an erste Stelle, umbenennen, löschen, letzter Reiter, Raster gegenmessen)"
|
||||
expected: "Alle neun Punkte laufen wie im SUMMARY beschrieben, insbesondere Punkt 9 (Raster reagiert unverändert, D-06)"
|
||||
why_human: "Erfordert einen laufenden Container mit echtem Datenbestand und echte Maus-Interaktion (Drag-and-Drop, visuelle Prüfung des Rasterverhaltens) — kann nicht durch Grep/Codeanalyse ersetzt werden. Der Nutzer baut/startet die Container selbst (Projektregel: kein Docker-Deploy durch Claude auf Testservern/lokal)."
|
||||
---
|
||||
|
||||
# Quick-Aufgabe 260923-ad9: Dashboard-Reiter — mehrere Dashboards je Benutzer Verification Report
|
||||
|
||||
**Phase Goal:** Dashboard-Reiter — mehrere Dashboards je Benutzer, jeder Reiter mit eigenen Kacheln und
|
||||
eigener Anordnung; Reiter per Ziehen sortierbar; der erste Reiter ist der Standard und wird beim Öffnen
|
||||
geladen; anlegen, umbenennen, löschen (der letzte bleibt); bestehende Dashboards werden per Migration zum
|
||||
ersten Reiter, ohne dass jemand Kacheln verliert.
|
||||
|
||||
**Verified:** 2026-09-23T08:30:00Z
|
||||
**Status:** human_needed
|
||||
**Re-verification:** No — initial verification
|
||||
|
||||
## Goal Achievement
|
||||
|
||||
### Observable Truths
|
||||
|
||||
| # | Truth | Status | Evidence |
|
||||
|---|-------|--------|----------|
|
||||
| 1 | Ein Benutzer hat mehrere Dashboards als Reiter, jeder mit eigenen Kacheln/Anordnung | ✓ VERIFIED | `Dashboard` model + `dashboardId` FK on `WidgetInstance`/`DashboardLayout` (schema.prisma:200-253); `getWidgets`/`getLayout`/`addWidget`/`saveLayout` all scope by `dashboardId`, not `userId` (dashboard.service.ts); `dashboard-store.ts` `selectDashboard` fully replaces `layouts`/`widgets` on tab switch, tests 9/10 in `dashboard-store.test.ts` confirm no merging |
|
||||
| 2 | Erster Reiter (Position 0) ist Standard, wird beim Öffnen geladen, kein separates Standard-Feld | ✓ VERIFIED | `listDashboards` orders by `position: 'asc'`, `loadDashboard()` in store takes `dashboards[0]`; no `isDefault`/`starred` field anywhere in schema; store test 7 confirms |
|
||||
| 3 | Reiter per Maus ziehbar, Reihenfolge bleibt nach Neuladen; Klick ohne Ziehen wechselt nur | ✓ VERIFIED | `dashboard-tabs.tsx` pointer handlers with 4px threshold, `computeReorderedIds`; `PUT /dashboard/tabs/order` persists via `reorderDashboards`; tabs tests 13-21 cover click-only, drag right/left, preview/abort, first-tab promotion, save-failure rollback |
|
||||
| 4 | Anlegen (Namensvergabe füllt Lücken), Umbenennen, Löschen (letzter bleibt, Server + UI) | ✓ VERIFIED | `createDashboard` gap-filling name loop (dashboard.service.ts); `deleteDashboard` throws `ConflictException` at count≤1; `dashboard-tabs.tsx` omits delete button when `dashboards.length <= 1`; service tests for last-tab-conflict and controller/service tests for rename/delete found |
|
||||
| 5 | Migration: niemand verliert Kacheln/Anordnung; Benutzer ohne Bestand bekommt leeren Reiter beim ersten Öffnen | ✓ VERIFIED | Migration backfills exactly one `Dashboard` row per user with widgets-or-layout via `DISTINCT ON`, runs backfill before FKs; live-DB query returned 0 orphaned widgets, 0 orphaned layouts, 2 dashboards (matches pre-measured user count), 0 dashboards off position 0 (re-run by this verifier, see below); `listDashboards` auto-creates one tab for a user with none |
|
||||
| 6 | Fail-closed gegen fremde Reiter auf allen sieben Wegen (read/write) | ✓ VERIFIED | `assertOwnedDashboard` called first in `getLayout`, `saveLayout`, `getWidgets`, `addWidget`, `renameDashboard`, `deleteDashboard`; `reorderDashboards` uses exact-match-in-transaction (same `NotFoundException`/`BadRequestException`, no existence oracle); dedicated tests found for all 7+ paths (grep: 8 "fremd/NotFound" test names across the exact methods) |
|
||||
| 7 | Neue Tabelle trägt Mandant+Zeilenschutz (Form aus 20260911120000); RLS-Wächter grün; Zugriffsklassifikation nachgeführt | ✓ VERIFIED | Migration: `ENABLE`+`FORCE ROW LEVEL SECURITY` + `tenant_isolation_policy` with tenant AND user dimension; `rls-coverage.spec.ts` (generic schema/migration scanner, not hardcoded) passed 5/5 in this verifier's own full test run; `docs/mandantentrennung-zugriffsklassifikation.md` recomputed rows for `dashboard`/`dashboardLayout`/`widgetInstance` pairs, region and sum lines |
|
||||
| 8 | Raster unverändert: FREE_PLACEMENT_COMPACTOR/preventCollision, Breitenmessung aus quick-260922-vdk | ✓ VERIFIED | `git diff 84fe73e..HEAD --stat -- apps/web/src/components/dashboard/` shows only two NEW files (`dashboard-tabs.tsx`/`.test.tsx`); `dashboard-grid.tsx` has zero diff; `FREE_PLACEMENT_COMPACTOR`/`preventCollision` present unchanged; `dashboard-grid.test.tsx` stayed at 12 tests |
|
||||
| 9 | Keine neue Abhängigkeit für das Ziehen (Pointer-Events wie xframe-config-form.tsx) | ✓ VERIFIED | `git diff 84fe73e..HEAD -- apps/web/package.json apps/api/package.json` is empty (no diff at all); `dashboard-tabs.tsx` uses native `PointerEvent`/`setPointerCapture` with jsdom guard, same pattern as `xframe-config-form.tsx` |
|
||||
| 10 | Alle Tore grün mit den genannten Mindestzahlen | ✓ VERIFIED | Re-run by this verifier (not trusted from SUMMARY): api 1240/1240 tests in 77 files; web 693/693 tests in 82 files; `pnpm type-check` 4/4; `pnpm lint` 5/5 with exactly 53 warnings; `prisma migrate diff --exit-code` against live local DB returned "No difference detected." (exit 0) |
|
||||
|
||||
**Score:** 10/10 truths verified (0 present, behavior-unverified)
|
||||
|
||||
### Required Artifacts
|
||||
|
||||
| Artifact | Expected | Status | Details |
|
||||
|----------|----------|--------|---------|
|
||||
| `apps/api/prisma/schema.prisma` | `Dashboard` model, FK on WidgetInstance/DashboardLayout, no unique on position, DashboardLayout loses userId-unique | ✓ VERIFIED | Confirmed by direct read: `@@index([userId])`, `@@index([tenantId])`, no `@@unique`; `DashboardLayout.dashboardId @unique`, `userId` plain index |
|
||||
| `.../migrations/20260923120000_dashboard_tabs/migration.sql` | Hand-written, German header, RLS, backfill before FKs | ✓ VERIFIED | Confirmed by direct read: steps in the documented order, `DISTINCT ON` dedup for the "same user, two tenants" edge case on the INSERT (see minor note below) |
|
||||
| `apps/api/src/dashboard/dashboard.service.ts` | listDashboards/createDashboard/renameDashboard/deleteDashboard/reorderDashboards/assertOwnedDashboard; getLayout/saveLayout/getWidgets/addWidget per-tab | ✓ VERIFIED | All methods present, matches plan's documented locking/transaction reasoning |
|
||||
| `apps/api/src/dashboard/dashboard.controller.ts` | Five new `tabs` routes, `tabs/order` before `:id` routes | ✓ VERIFIED | Confirmed by direct read and by the passing source-order guard test in `dashboard.controller.spec.ts` |
|
||||
| `apps/api/src/dashboard/dto/` | rename/reorder DTOs with caps, extended save-layout/create-widget DTOs | ✓ VERIFIED | `RenameDashboardDto` (trim + Length(1,40)), `ReorderDashboardsDto` (ArrayMinSize/MaxSize(20)/Unique) |
|
||||
| `apps/api/src/dashboard/dashboard.controller.spec.ts` | NEW, incl. source-order guard | ✓ VERIFIED | File exists, 8 tests, guard test present and passing |
|
||||
| `apps/web/src/components/dashboard/dashboard-tabs.tsx` | NEW tab bar, pointer-drag pattern | ✓ VERIFIED | Confirmed by direct read; wired into `(portal)/page.tsx` |
|
||||
| `apps/web/src/lib/stores/dashboard-store.ts` | dashboards/activeDashboardId/select/create/rename/delete/reorder, dedupe-load, save-before-switch | ✓ VERIFIED | Confirmed by direct read |
|
||||
| `apps/web/src/messages/de.json` + `en.json` | `widgets.tabs.*` keys, real umlauts | ✓ VERIFIED | Confirmed keys present with real umlauts (ä/ö/ü/ß) |
|
||||
| `docs/mandantentrennung-zugriffsklassifikation.md` | new row, recomputed sums | ✓ VERIFIED | Confirmed by direct read; numbers are internally consistent and recomputed with rationale, not copy-pasted |
|
||||
| `docs/anleitung-anwender.md` + `CHANGELOG.md` | plain-language description | ✓ VERIFIED | Confirmed by direct read; real German, Sie-form, no jargon |
|
||||
|
||||
### Key Link Verification
|
||||
|
||||
| From | To | Via | Status | Details |
|
||||
|------|-----|-----|--------|---------|
|
||||
| Open → `loadDashboard()` → `GET /dashboard/tabs` → first tab active → widgets+layout fetch | — | store→api→controller→service | ✓ WIRED | Confirmed end-to-end by reading `dashboard-store.ts` `loadDashboard`, `dashboard-api.ts`, controller, service |
|
||||
| Drag tabs → pointer events → `PUT /dashboard/tabs/order` → position rewrite in one transaction | — | tabs.tsx→store→api→service | ✓ WIRED | Confirmed; `reorderDashboards` service method matches `FavoritesService.reorder` pattern exactly |
|
||||
| Delete tab → `DELETE /dashboard/tabs/:id` → assertOwnedDashboard → last-tab reject → transactional cascade delete + position renumber | — | tabs.tsx→store→api→service | ✓ WIRED | Confirmed by direct read of `deleteDashboard` |
|
||||
| Add widget → store passes `activeDashboardId` → `POST /dashboard/widgets` with tab id → ownership check | — | store→api→service | ✓ WIRED | Confirmed `addWidget` in store reads `activeDashboardId`, service calls `assertOwnedDashboard` first |
|
||||
| New `Dashboard` model with `tenantId` → `rls-coverage.spec.ts` requires ENABLE+Policy | — | migration→generic scanner | ✓ WIRED | Confirmed test passed in this verifier's own run (5/5), scanner is generic (parses schema+migrations dynamically, not hardcoded per table) |
|
||||
| `tenantPrisma.dashboard`/`tx.dashboard` → `rls-access-inventory.spec.ts` → classification doc entry | — | service→inventory scanner→doc | ✓ WIRED | Confirmed test passed (30/30 in this verifier's run); doc row present with `gebunden` status |
|
||||
|
||||
### Data-Flow Trace (Level 4)
|
||||
|
||||
| Artifact | Data Variable | Source | Produces Real Data | Status |
|
||||
|----------|---------------|--------|---------------------|--------|
|
||||
| `dashboard-tabs.tsx` `dashboards` prop | `useDashboardStore().dashboards` | `GET /dashboard/tabs` → Prisma query via `forTenant` | Yes | ✓ FLOWING |
|
||||
| `DashboardGrid` `layouts`/`widgets` props | store `layouts`/`widgets` | `GET /dashboard/layout`/`GET /dashboard/widgets` scoped by `activeDashboardId` | Yes | ✓ FLOWING |
|
||||
| Migration backfill counts (live DB) | `Dashboard`/`WidgetInstance`/`DashboardLayout` rows | Actual local Postgres container, re-queried by this verifier | Yes | ✓ FLOWING |
|
||||
|
||||
### Behavioral Spot-Checks
|
||||
|
||||
| Behavior | Command | Result | Status |
|
||||
|----------|---------|--------|--------|
|
||||
| API full test suite (not filtered) | `pnpm --filter @tessera/api test` | 1240 tests, 77 files, all passed | ✓ PASS |
|
||||
| Web full test suite (not filtered) | `pnpm --filter @tessera/web test` | 693 tests, 82 files, all passed | ✓ PASS |
|
||||
| `pnpm type-check` | `pnpm type-check` | 4/4 successful (cached) | ✓ PASS |
|
||||
| `pnpm lint` | `pnpm lint` | 5/5 successful, exactly 53 warnings in web | ✓ PASS |
|
||||
| Migration applied + no drift vs. live local DB | `prisma migrate diff --exit-code` | "No difference detected.", exit 0 | ✓ PASS |
|
||||
| Backfill correctness (live DB re-query) | `SELECT ... kacheln_ohne_reiter, anordnungen_ohne_reiter, reiter, reiter_nicht_an_position_null` | `0, 0, 2, 0` | ✓ PASS |
|
||||
| No new drag/DnD dependency | `git diff 84fe73e..HEAD -- apps/web/package.json apps/api/package.json` | empty diff | ✓ PASS |
|
||||
| Grid component untouched | `git diff 84fe73e..HEAD --stat -- apps/web/src/components/dashboard/` | only 2 new files (`dashboard-tabs.*`), `dashboard-grid.tsx` absent from diff | ✓ PASS |
|
||||
|
||||
### Requirements Coverage
|
||||
|
||||
This is a `/gsd-quick` task (no `.planning/REQUIREMENTS.md` entry expected/found for `QUICK-260923-AD9` — confirmed by grep, consistent with how quick tasks are tracked in this project).
|
||||
|
||||
### Anti-Patterns Found
|
||||
|
||||
No `TBD`/`FIXME`/`XXX`/`TODO`/`HACK`/`PLACEHOLDER` markers found in any of the core changed files
|
||||
(`dashboard.service.ts`, `dashboard.controller.ts`, `dashboard-store.ts`, `dashboard-tabs.tsx`,
|
||||
`migration.sql`). No stub patterns (`return null`/empty-return handlers/hardcoded-empty props) found in
|
||||
the reviewed files — every prop and returned value traces to a real query or real store state.
|
||||
|
||||
**Minor observation (not a blocker):** the migration's backfill INSERT (step 3) uses `DISTINCT ON
|
||||
("userId")` to guarantee exactly one `Dashboard` row per user even in the theoretical "same user id,
|
||||
two tenant ids" case, exactly as the plan requires for that INSERT. However, the subsequent `UPDATE
|
||||
"WidgetInstance"`/`UPDATE "DashboardLayout"` backfill steps (4 and 5) join only on `d."userId" =
|
||||
wi."userId"`, not also on `tenantId` — in that same theoretical edge case, rows belonging to the
|
||||
"losing" tenant would be reassigned to the single `Dashboard` row's tenant. The plan's own task text
|
||||
explicitly scopes the dedup requirement ("Gegen den theoretischen Fall...") to the INSERT's row selection,
|
||||
and the live local database has no such multi-tenant-same-user rows (measured backfill: 2 dashboards for
|
||||
2 users with data, 0 orphans). This does not block the phase goal — it is a pre-existing, explicitly
|
||||
acknowledged theoretical edge case, not a regression — but is noted here for the record since it touches
|
||||
the "niemand verliert etwas" must-have's edge-case robustness, not its measured/observed correctness.
|
||||
|
||||
### Human Verification Required
|
||||
|
||||
1. **Browser-Rundgang (9 Punkte aus dem SUMMARY)**
|
||||
**Test:** Anmelden und Dashboard öffnen; Reiter anlegen/wechseln/Kachel setzen; ungespeichert
|
||||
wechseln; per Ziehen an die erste Stelle bringen und neu laden; umbenennen; löschen; letzten Reiter
|
||||
prüfen; Raster gegenmessen (D-06: Kachel auf belegten Platz ziehen — bleibt am Ausgangsort).
|
||||
**Expected:** Alle neun Punkte laufen wie im SUMMARY beschrieben.
|
||||
**Why human:** Erfordert einen neu gebauten laufenden Container mit echtem Datenbestand und echte
|
||||
Maus-Interaktion — Drag-and-Drop-Verhalten und visuelle Rasterreaktion lassen sich nicht per
|
||||
Codeanalyse abschließend beurteilen, und laut Projektregel baut/startet der Nutzer die Container
|
||||
selbst.
|
||||
|
||||
### Gaps Summary
|
||||
|
||||
None. Every must-have truth from the plan's frontmatter, plus the four explicit verification-focus
|
||||
points requested (migration completeness, fail-closed ownership, grid untouched, the three auto-fixed
|
||||
files), is backed by direct code reading and/or a freshly re-run, non-trusted measurement (full test
|
||||
suites, type-check, lint, live-DB migration diff, live-DB backfill re-query, git diff on package.json and
|
||||
on the dashboard components directory). All three "Rule 1/3" auto-fixed files documented in the SUMMARY
|
||||
were read directly and are correct, narrowly-scoped fixes that do not hide a gap — they were compile/test
|
||||
breakages caused by the new required `dashboardId` field, fixed with reasoning matching what SUMMARY
|
||||
claims. The only finding is the minor, pre-acknowledged theoretical edge case noted above under
|
||||
Anti-Patterns, which does not affect the phase goal as measured against the actual local database.
|
||||
|
||||
---
|
||||
|
||||
_Verified: 2026-09-23T08:30:00Z_
|
||||
_Verifier: Claude (gsd-verifier)_
|
||||
+910
@@ -0,0 +1,910 @@
|
||||
---
|
||||
phase: quick-260923-dhh
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
autonomous: true
|
||||
requirements: [D-01, D-02, D-03, D-04, D-05, D-06, D-07, D-08, D-09, D-10, D-11]
|
||||
|
||||
files_modified:
|
||||
- apps/api/prisma/schema.prisma
|
||||
- apps/api/prisma/migrations/20260923140000_proxmox_server/migration.sql
|
||||
- apps/api/src/app.module.ts
|
||||
- apps/api/src/proxmox/proxmox.types.ts
|
||||
- apps/api/src/proxmox/proxmox-auth.ts
|
||||
- apps/api/src/proxmox/proxmox-normalize.ts
|
||||
- apps/api/src/proxmox/proxmox-client.service.ts
|
||||
- apps/api/src/proxmox/proxmox.service.ts
|
||||
- apps/api/src/proxmox/proxmox-scheduler.service.ts
|
||||
- apps/api/src/proxmox/proxmox.controller.ts
|
||||
- apps/api/src/proxmox/proxmox.module.ts
|
||||
- apps/api/src/proxmox/proxmox.seed.ts
|
||||
- apps/api/src/proxmox/dto/proxmox-server.dto.ts
|
||||
- apps/api/src/proxmox/proxmox-client.service.spec.ts
|
||||
- apps/api/src/proxmox/proxmox.service.spec.ts
|
||||
- apps/api/src/proxmox/proxmox-normalize.spec.ts
|
||||
- apps/api/src/proxmox/proxmox-scheduler.service.spec.ts
|
||||
- apps/api/src/proxmox/proxmox-nur-lesen.spec.ts
|
||||
- apps/api/src/prisma/rls-access-inventory.spec.ts
|
||||
- apps/web/src/lib/proxmox-api.ts
|
||||
- apps/web/src/lib/module-loader.ts
|
||||
- apps/web/src/app/(portal)/modules/proxmox/layout.tsx
|
||||
- apps/web/src/app/(portal)/modules/proxmox/page.tsx
|
||||
- apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.tsx
|
||||
- apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.test.tsx
|
||||
- apps/web/src/app/(portal)/modules/proxmox/settings/page.tsx
|
||||
- apps/web/src/app/(portal)/modules/proxmox/settings/components/ServerForm.tsx
|
||||
- apps/web/src/app/(portal)/modules/proxmox/settings/components/ServerForm.test.tsx
|
||||
- apps/web/src/messages/de.json
|
||||
- apps/web/src/messages/en.json
|
||||
- docs/mandantentrennung-zugriffsklassifikation.md
|
||||
- docs/anleitung-entwicklung.md
|
||||
- docs/anwenderhandbuch.md
|
||||
|
||||
user_setup:
|
||||
- service: proxmox
|
||||
why: "Nur der Nutzer hat echte PVE-/PBS-/PMG-Server. Ohne sie bleibt die Feldnamen-Annahme A2/A3 der Recherche unbestaetigt."
|
||||
dashboard_config:
|
||||
- task: "Je Produkt einen NUR-LESE-Zugang anlegen: PVE-Rolle PVEAuditor, PBS-Rolle Audit bzw. DatastoreAudit, PMG-Rolle Auditor"
|
||||
location: "Proxmox-Oberflaeche -> Datacenter/Configuration -> Permissions"
|
||||
|
||||
estimate:
|
||||
tokens: 320000
|
||||
raw_tokens: 210000
|
||||
tasks: 7
|
||||
confidence: low
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Kein Weg im gesamten Modul veraendert etwas bei Proxmox: die einzige Nicht-GET-Anfrage im ganzen Modul ist die Ticket-Anmeldung, und sie wird maschinell nachgezaehlt (D-01)."
|
||||
- "Ein Administrator legt in den Einstellungen Server an (Name, Typ pve|pbs|pmg, Adresse, Zugang) und sieht die Zugangsdaten nie wieder im Klartext (D-02)."
|
||||
- "PVE und PBS bieten API-Token ODER Benutzer/Passwort; PMG bietet nur Benutzer/Passwort — die Token-Felder erscheinen bei PMG gar nicht und ein Token-Zugang fuer PMG wird serverseitig abgelehnt (D-03)."
|
||||
- "Zertifikatsfehler werden nur fuer die Server geduldet, bei denen der Administrator es einzeln eingeschaltet hat; Voreinstellung ist pruefen (D-04)."
|
||||
- "Die Modulseite und jede Anzeige lesen ausschliesslich aus dem Zwischenlager, nie live bei Proxmox (D-05)."
|
||||
- "Der Knopf Verbindung testen nennt die Ursache in Alltagssprache: nicht erreichbar, Zugang abgelehnt, Rechte reichen nicht, Zertifikat, unerwartete Antwort (D-06)."
|
||||
- "Ein fehlendes, anders benanntes oder falsch typisiertes Feld einer Proxmox-Antwort fuehrt zu unbekannt in der Anzeige, nie zu einem Absturz, einer leeren Seite oder einem stillen Falschwert."
|
||||
- "Ohne angelegten Server ist die Modulseite ruhig und erklaert, dass noch keiner eingetragen ist."
|
||||
- "Beide neuen Tabellen tragen tenantId mit RLS-Policy; rls-coverage.spec.ts und rls-access-inventory.spec.ts bleiben gruen (D-08)."
|
||||
artifacts:
|
||||
- apps/api/prisma/migrations/20260923140000_proxmox_server/migration.sql
|
||||
- apps/api/src/proxmox/proxmox-auth.ts
|
||||
- apps/api/src/proxmox/proxmox-client.service.ts
|
||||
- apps/api/src/proxmox/proxmox-normalize.ts
|
||||
- apps/api/src/proxmox/proxmox-scheduler.service.ts
|
||||
- apps/api/src/proxmox/proxmox-nur-lesen.spec.ts
|
||||
- "apps/web/src/app/(portal)/modules/proxmox/page.tsx"
|
||||
- "apps/web/src/app/(portal)/modules/proxmox/settings/page.tsx"
|
||||
key_links:
|
||||
- "proxmox-auth.ts ist die EINZIGE Stelle, die Kopfzeilen und Anmelde-Cookies je Produkt baut — Klient, Verbindungstest und Planer rufen sie, keiner baut sie nach (D-03)."
|
||||
- "proxmox-client.service.ts baut den undici-Dispatcher je Aufruf aus dem Feld tlsRejectUnauthorized genau dieser Serverzeile (D-04)."
|
||||
- "proxmox-scheduler.service.ts haengt an onApplicationBootstrap und faechert je Mandant auf; der Controller zieht nach jedem Speichern nach (D-05)."
|
||||
- "proxmox.controller.ts traegt @UseModule('proxmox'); die Schreibwege zusaetzlich @Roles(ADMIN, SUPER_ADMIN) (D-09)."
|
||||
- "Jeder Datenbankzugriff laeuft ueber forTenant(); nur der Startpfad des Planers ueber forSystem() und steht in FORSYSTEM_ALLOWED_CALL_SITES (D-08)."
|
||||
---
|
||||
|
||||
<objective>
|
||||
Das Proxmox-Modul anbinden: PVE, PBS und PMG **nur beobachten**. Der Administrator legt in
|
||||
den Moduleinstellungen beliebig viele Server an (Name, Typ, Adresse, Zugang — verschluesselt
|
||||
gespeichert). Ein Hintergrunddienst fragt sie periodisch ab und legt die Messwerte in einem
|
||||
Zwischenlager ab. Die Modulseite zeigt die Serverliste mit Auslastung und liest dabei
|
||||
ausschliesslich aus dem Zwischenlager.
|
||||
|
||||
Purpose: Der Nutzer sieht den Zustand seiner Proxmox-Landschaft in Tessera, ohne die
|
||||
Proxmox-Oberflaechen einzeln zu oeffnen — und ohne dass Tessera je etwas an ihnen aendern kann.
|
||||
|
||||
Output: Ein vollstaendiges Modul `proxmox` (Datenbank, Dienst, API, Hintergrundabfrage,
|
||||
Einstellungsseite, Modulseite, Dokumentation), sieben eigenstaendige Commits.
|
||||
|
||||
## Herkunft der Entscheidungen (D-Nummern)
|
||||
|
||||
Die D-Nummern in diesem Plan verweisen auf die elf bereits getroffenen Entscheidungen aus dem
|
||||
Auftrag (`<decisions_already_made>`), in derselben Reihenfolge:
|
||||
|
||||
| ID | Entscheidung |
|
||||
|---|---|
|
||||
| D-01 | Nur beobachten — kein veraendernder Weg gegen Proxmox |
|
||||
| D-02 | Administrator legt Server an; Zugangsdaten verschluesselt, nie im Klartext zurueck |
|
||||
| D-03 | PVE/PBS: Token oder Benutzer/Passwort; PMG nur Benutzer/Passwort; Kopfzeilen aus EINER Stelle |
|
||||
| D-04 | Zertifikatsfehler nur je Server umschaltbar dulden, nie global |
|
||||
| D-05 | Zwischenlager statt Live-Abfrage; Planer nach TENDER-Muster (`onApplicationBootstrap`) |
|
||||
| D-06 | Knopf „Verbindung testen" mit Klartext-Ursache |
|
||||
| D-07 | Keine neue npm-Abhaengigkeit — `undici` ist bereits da |
|
||||
| D-08 | Mandantentrennung Pflicht: `tenantId` + RLS + Klassifikationsdoku + gruene Waechter-Tests |
|
||||
| D-09 | Zugriff ueber die normale Modulfreigabe |
|
||||
| D-10 | Oberflaechentexte Deutsch in der Sie-Form ueber next-intl; Kommentare Deutsch |
|
||||
| D-11 | Dashboard-Kachel ist NICHT in diesem Auftrag |
|
||||
|
||||
## Ausgangswerte der Tore (gemessen 2026-09-23, vor Beginn)
|
||||
|
||||
| Tor | Ausgangswert |
|
||||
|---|---|
|
||||
| `pnpm --filter @tessera/api test` | 77 Dateien, 1240 Tests, alle gruen |
|
||||
| `pnpm --filter @tessera/web test` | 82 Dateien, 693 Tests, alle gruen |
|
||||
| `pnpm type-check` | 4 von 4 erfolgreich |
|
||||
| `pnpm lint` | 5 von 5 erfolgreich |
|
||||
| Biome-Warnungen in `apps/web` | genau 53 (253 Dateien geprueft) |
|
||||
|
||||
Zielwert nach jeder Aufgabe: Testzahlen **groesser oder gleich** dem Ausgangswert und gruen,
|
||||
type-check 4/4, lint 5/5, Biome-Warnungen in `apps/web` **exakt 53** — nicht mehr, nicht weniger.
|
||||
|
||||
## Ausdruecklich NICHT im Umfang
|
||||
|
||||
Dashboard-Kachel (D-11, kommt als eigener Auftrag ueber `WIDGET_TYPES` /
|
||||
`WIDGET_MODULE_SLUGS` / `registerWidget`), Zeitreihen und Verlaufsgrafiken (`/rrddata`),
|
||||
Eingriffe jeder Art (Start, Stopp, Sichern, Freigeben), Quarantaene-Verwaltung bei PMG.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/gsd-core/workflows/execute-plan.md
|
||||
@~/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/quick/260923-dhh-proxmox-modul-pve-pbs-und-pmg-anbinden-n/260923-dhh-RESEARCH.md
|
||||
@.planning/STATE.md
|
||||
@CLAUDE.md
|
||||
|
||||
Bestandsmuster, die dieser Plan wortgetreu wiederverwendet (vor der jeweiligen Aufgabe lesen,
|
||||
nicht raten):
|
||||
|
||||
@apps/api/src/favorites/icon-discovery.service.ts
|
||||
@apps/api/src/ldap/ldap-config.service.ts
|
||||
@apps/api/src/dkv/dkv-scheduler.service.ts
|
||||
@apps/api/src/tenders/tender-scheduler.service.ts
|
||||
@apps/api/src/domaincheck/domaincheck.module.ts
|
||||
@apps/api/src/prisma/prisma-tenant.extension.ts
|
||||
</context>
|
||||
|
||||
<interface_context>
|
||||
Signaturen und Konstanten, auf die jede Aufgabe aufsetzt — so gemessen im Bestand, nicht erfunden:
|
||||
|
||||
- `CryptoService` (`apps/api/src/crypto/crypto.service.ts`): `encrypt(plaintext: string): string`
|
||||
und `decrypt(stored: string): string`. Format `iv:authTag:ciphertext`, alles hex,
|
||||
Doppelpunkt-getrennt. `CryptoModule` steht bereits in `app.module.ts`.
|
||||
- Erkennungsform fuer „schon verschluesselt" (`ldap-config.service.ts:17`):
|
||||
`/^[0-9a-f]+:[0-9a-f]+:[0-9a-f]*$/i`.
|
||||
- `forTenant(prisma, tenantId, userId?)` und `forSystem(prisma)` aus
|
||||
`apps/api/src/prisma/prisma-tenant.extension.ts`. Konvention: lokale Konstante
|
||||
`const tenantPrisma = forTenant(this.prisma, tenantId);` — keine andere Form, sonst schlaegt
|
||||
`rls-access-inventory.spec.ts` fehl.
|
||||
- `undici`: `import { Agent, fetch as undiciFetch } from 'undici'`. Nodes globales `fetch`
|
||||
ignoriert einen `Agent` aus dem npm-Paket (gemessen, `icon-discovery.service.ts:33-37`).
|
||||
- `@UseModule(slug)` aus `apps/api/src/module-registry/module.guard.ts`,
|
||||
`@Roles(Role.ADMIN, Role.SUPER_ADMIN)` aus `apps/api/src/auth/decorators/roles.decorator.ts`.
|
||||
- `ModuleRegistryService.seedModule({ slug, name, version, category, description: {de, en}, isSystem })`
|
||||
— Vorlage `apps/api/src/domaincheck/domaincheck.seed.ts`.
|
||||
- `CronJobClass` wird per `require('cron').CronJob` aufgeloest (pnpm-Isolation, Kommentar in
|
||||
`dkv-scheduler.service.ts:6-16` woertlich uebernehmen).
|
||||
- RLS-Policy-Form ohne Benutzerdimension (`20260909140000`, DkvModuleConfig):
|
||||
`CREATE POLICY tenant_isolation_policy ON "X" USING ("tenantId" = current_tenant_id());`
|
||||
- Systemlese-Form (`20260914120000`):
|
||||
`CREATE POLICY system_read_policy ON "X" FOR SELECT USING (is_system_context());`
|
||||
- Frontend-Datenzugriff: ein Helfer `apps/web/src/lib/<modul>-api.ts` (Vorbild `dkv-api.ts`),
|
||||
`API_URL` aus `process.env.NEXT_PUBLIC_API_URL`, `credentials: 'include'`.
|
||||
- Modulseiten liegen unter `apps/web/src/app/(portal)/modules/<slug>/`, `layout.tsx` umschliesst
|
||||
mit `<ModuleAccessGate moduleSlug="<slug>">`, Eintrag in `MODULE_REGISTRY`
|
||||
(`apps/web/src/lib/module-loader.ts`).
|
||||
</interface_context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Aufgabe 1: Ein PVE-Server per Token — von der Tabelle bis zur Modulseite</name>
|
||||
<files>
|
||||
apps/api/prisma/schema.prisma,
|
||||
apps/api/prisma/migrations/20260923140000_proxmox_server/migration.sql,
|
||||
apps/api/src/proxmox/proxmox.types.ts,
|
||||
apps/api/src/proxmox/proxmox-auth.ts,
|
||||
apps/api/src/proxmox/proxmox-client.service.ts,
|
||||
apps/api/src/proxmox/proxmox.service.ts,
|
||||
apps/api/src/proxmox/proxmox.controller.ts,
|
||||
apps/api/src/proxmox/proxmox.module.ts,
|
||||
apps/api/src/proxmox/proxmox.seed.ts,
|
||||
apps/api/src/proxmox/dto/proxmox-server.dto.ts,
|
||||
apps/api/src/proxmox/proxmox.service.spec.ts,
|
||||
apps/api/src/app.module.ts,
|
||||
apps/web/src/lib/proxmox-api.ts,
|
||||
apps/web/src/lib/module-loader.ts,
|
||||
apps/web/src/app/(portal)/modules/proxmox/layout.tsx,
|
||||
apps/web/src/app/(portal)/modules/proxmox/page.tsx,
|
||||
apps/web/src/messages/de.json,
|
||||
apps/web/src/messages/en.json,
|
||||
docs/mandantentrennung-zugriffsklassifikation.md
|
||||
</files>
|
||||
<precondition>
|
||||
`TESSERA_ENCRYPTION_KEY` ist gesetzt (64 Hex-Zeichen) und die Entwicklungsdatenbank ist vom
|
||||
Host erreichbar — sonst scheitert `prisma migrate dev`. Erreichbarkeit siehe
|
||||
`docs/anleitung-entwicklung.md`, Abschnitt „Datenbank vom Host erreichen"; die Datenbank hat
|
||||
keinen Host-Port, der Zugriff laeuft ueber die Container-IP mit `tessera:tessera_dev`.
|
||||
</precondition>
|
||||
<action>
|
||||
Duenner, aber durchgehender Schnitt durch ALLE Schichten, die dieses Modul anfasst — genau EIN
|
||||
Weg: ein PVE-Server, Zugang per API-Token, vom Anlegen ueber die Abfrage und das Zwischenlager
|
||||
bis zur Anzeige im Browser. Kein PBS, kein PMG, kein Benutzer/Passwort, kein Planer, keine
|
||||
Einstellungsoberflaeche, kein Bearbeiten oder Loeschen — das bauen die Aufgaben 2 bis 6 auf
|
||||
diesem bewiesenen Geruest auf. Was hier entsteht, ist Endstand, kein Wegwerfstueck: dieselbe
|
||||
Fehlerbehandlung, dieselbe Mandantenbindung, dieselben Tore wie jede spaetere Aufgabe.
|
||||
|
||||
**Schema** (`schema.prisma`) — zwei Modelle nach dem Vorbild `CalendarSource` (mehrere
|
||||
verschluesselte Fremdzugaenge je Mandant), NICHT nach `DkvModuleConfig` (Singleton je Mandant):
|
||||
|
||||
`ProxmoxServer`: `id` (uuid), `tenantId`, `name`, `productType` (Zeichenkette `pve|pbs|pmg`,
|
||||
kommentiert wie `CalendarSource.type`), `baseUrl`, `authMethod` (`token|password`), `tokenId`
|
||||
(nullable), `encryptedTokenSecret` (nullable, Kommentar „AES-256-GCM ciphertext
|
||||
(iv:authTag:ciphertext hex)" wie `CalendarSource.encryptedPassword`), `username` (nullable),
|
||||
`encryptedPassword` (nullable), `tlsRejectUnauthorized Boolean @default(true)` (Feldname
|
||||
woertlich von `LdapConfig`, D-04), `isActive Boolean @default(true)`,
|
||||
`pollIntervalMin Int @default(5)`, `position Int @default(0)`, `createdAt`, `updatedAt`,
|
||||
Relation `status ProxmoxServerStatus?`, `@@index([tenantId])`.
|
||||
|
||||
`ProxmoxServerStatus`: `id`, `serverId String @unique` mit Relation auf `ProxmoxServer`
|
||||
(`onDelete: Cascade`), `tenantId`, `lastPolledAt DateTime?`, `lastOkAt DateTime?`,
|
||||
`reachable Boolean @default(false)`, `errorKind String?`, `errorDetail String?`,
|
||||
`metrics Json?` (normalisierte Messwerte), `rawSample Json?` (gekuerzte Rohantwort zur
|
||||
Fehlersuche beim Nutzer), `updatedAt`, `@@index([tenantId])`.
|
||||
|
||||
**Migration** (`20260923140000_proxmox_server/migration.sql`) — von Hand geschrieben nach dem
|
||||
Vorbild `20260923120000_dashboard_tabs`: deutscher Kopfkommentar, der Zweck und die
|
||||
Entscheidungen benennt. Beide Tabellen mit `ENABLE`/`FORCE ROW LEVEL SECURITY` und
|
||||
`tenant_isolation_policy` in der Form OHNE Benutzerdimension
|
||||
(`USING ("tenantId" = current_tenant_id())`, Vorbild `DkvModuleConfig`) — Proxmox-Server sind
|
||||
Verwaltungsdaten des Mandanten, nicht persoenliche Daten eines Benutzers. Zusaetzlich auf
|
||||
`ProxmoxServer` (und NUR dort) `system_read_policy … FOR SELECT USING (is_system_context())`
|
||||
mit der Begruendung im Kommentar, dass der Planer aus Aufgabe 4 beim Start die aktiven Server
|
||||
ALLER Mandanten sehen muss; `ProxmoxServerStatus` bekommt sie bewusst nicht, weil dort nur je
|
||||
Mandant gebunden geschrieben wird. Indizes auf `tenantId` sowie `serverId` (unique). Rechte
|
||||
fuer `tessera_app` kommen automatisch ueber `ALTER DEFAULT PRIVILEGES` aus
|
||||
`20260909130000_rls_app_role` — im Kommentar erwaehnen, nichts tun.
|
||||
|
||||
**`proxmox.types.ts`** — die gemeinsamen Typen: `ProxmoxProductType = 'pve' | 'pbs' | 'pmg'`,
|
||||
`ProxmoxAuthMethod = 'token' | 'password'`, `ProxmoxErrorKind =
|
||||
'netz' | 'zugang' | 'rechte' | 'zertifikat' | 'antwortform' | 'server' | 'unbekannt'` (diese
|
||||
sieben Werte landen so in der Datenbank und werden erst im Frontend uebersetzt — stabile
|
||||
Schluessel, uebersetzbarer Text), und die Ergebnisform `ProxmoxPollResult` mit
|
||||
`{ reachable, errorKind, errorDetail, metrics, rawSample }`.
|
||||
|
||||
**`proxmox-auth.ts`** — die EINZIGE Stelle im ganzen Modul, die Anmeldeinformationen in
|
||||
Kopfzeilen uebersetzt (D-03, key_link). In dieser Aufgabe nur der Token-Zweig: eine reine
|
||||
Funktion `buildTokenAuthHeader(productType, tokenId, tokenSecret)`, die fuer `pve` das Schema
|
||||
`PVEAPIToken` mit Gleichheitszeichen vor dem Geheimnis und fuer `pbs` das Schema `PBSAPIToken`
|
||||
mit Doppelpunkt vor dem Geheimnis liefert (Recherche, Block 1) und fuer `pmg` einen Fehler
|
||||
wirft, weil PMG keine Token kennt. Die Funktion nimmt Klartext entgegen und gibt nur die
|
||||
Kopfzeile zurueck — sie protokolliert nie, sie wirft das Geheimnis nie in eine Fehlermeldung.
|
||||
|
||||
**`proxmox-client.service.ts`** — der HTTP-Zugang, und ausschliesslich lesend (D-01).
|
||||
Genau EINE oeffentliche Datenabruf-Funktion `proxmoxGet(server, path)`, die das
|
||||
Anfrageverfahren fest auf Lesen setzt (kein Parameter dafuer, kein Durchreichen von aussen).
|
||||
Zwingend `undiciFetch` aus dem `undici`-Paket, nicht das globale `fetch` — sonst wird der
|
||||
Dispatcher stillschweigend ignoriert (gemessen, `icon-discovery.service.ts:33-37`); diesen
|
||||
Grund als deutschen Kommentar in die Datei schreiben. Der Dispatcher wird JE AUFRUF aus der
|
||||
gelesenen Serverzeile gebaut: ist `tlsRejectUnauthorized` wahr, wird kein Dispatcher
|
||||
uebergeben (Normalweg, echte Pruefung); ist es falsch, ein frischer
|
||||
`new Agent({ connect: { rejectUnauthorized: false } })` nur fuer diesen einen Aufruf (D-04).
|
||||
Ausdruecklich KEINE Modulkonstante wie in `icon-discovery.service.ts` und ausdruecklich keine
|
||||
Node-Umgebungsvariable — beides als Kommentar festhalten. Abbruch nach 8 Sekunden ueber
|
||||
`AbortController`. Keine SSRF-Adresspruefung wie `isPublicHttpUrl`: Proxmox-Server stehen
|
||||
per Definition im privaten Netz, eine solche Pruefung wuerde jede reale Adresse blockieren;
|
||||
die Absicherung ist stattdessen, dass nur ein Administrator Adressen eintragen darf (siehe
|
||||
Bedrohungsmodell T-DHH-02). Rueckgabe ist ein Ergebnisobjekt mit `ok`, `status`, `body` und
|
||||
`errorKind` — geworfen wird nichts nach aussen; Netzfehler und Zertifikatsfehler werden
|
||||
abgefangen und in `errorKind` uebersetzt.
|
||||
|
||||
**`proxmox.service.ts`** — die Fachlogik, jeder Datenbankzugriff ueber
|
||||
`const tenantPrisma = forTenant(this.prisma, tenantId);` (Konvention woertlich, D-08):
|
||||
`createServer(tenantId, dto)` verschluesselt das Token-Geheimnis mit `this.crypto.encrypt(...)`
|
||||
und legt Server plus leere Zwischenlagerzeile an; `listWithStatus(tenantId)` liefert Server
|
||||
samt Zwischenlager OHNE jedes Geheimnisfeld (`select` ohne `encryptedTokenSecret` und
|
||||
`encryptedPassword`, nicht nachtraeglich maskiert — die Felder verlassen die Datenbank gar
|
||||
nicht erst); `pollServer(tenantId, serverId)` entschluesselt in genau EINER privaten Methode
|
||||
`decryptSecret(stored)` nach dem Vorbild `LdapConfigService.decryptBindPassword` (Form
|
||||
erkennen, unveraenderte Altwerte durchreichen), ruft fuer `pve` den Pfad
|
||||
`/api2/json/cluster/resources`, normalisiert das Ergebnis und schreibt es ins Zwischenlager.
|
||||
In dieser Aufgabe nur PVE und nur eine Grundauswertung: Anzahl Knoten, Anzahl laufender und
|
||||
gestoppter Gaeste, und je Knoten `cpu`/`maxcpu`/`mem`/`maxmem` — jeder Einzelwert nachsichtig
|
||||
gelesen (fehlt er, steht `null` im Zwischenlager und spaeter „unbekannt" in der Anzeige, nie
|
||||
ein Absturz und nie eine 0, die wie ein Messwert aussieht). Die Rohantwort wird auf hoechstens
|
||||
20 000 Zeichen gekuerzt in `rawSample` abgelegt, damit der Nutzer beim Testen an seinen echten
|
||||
Servern sieht, was tatsaechlich kam.
|
||||
|
||||
**`proxmox.controller.ts`** — `@Controller('modules/proxmox')` und `@UseModule('proxmox')` auf
|
||||
Klassenebene (D-09, Vorbild `domaincheck.controller.ts`). Drei Wege: `GET servers` (Liste mit
|
||||
Zwischenlager, fuer jeden Benutzer mit Modulzugriff), `POST servers` und
|
||||
`POST servers/:id/poll` — beide Schreibwege zusaetzlich mit
|
||||
`@Roles(Role.ADMIN, Role.SUPER_ADMIN)`. `tenantId` kommt ausschliesslich aus `req.tenantId`,
|
||||
nie aus Body oder Query.
|
||||
|
||||
**`dto/proxmox-server.dto.ts`** — `class-validator`: `name` nicht leer, `productType` per
|
||||
`@IsIn(['pve','pbs','pmg'])`, `baseUrl` per `@IsUrl({ protocols: ['http','https'], require_tld: false })`
|
||||
(ohne `require_tld`, weil interne Namen wie `pve.intern` sonst abgelehnt wuerden),
|
||||
`authMethod` per `@IsIn(['token','password'])`, `tlsRejectUnauthorized` optional boolesch,
|
||||
`pollIntervalMin` als Ganzzahl zwischen 1 und 1440.
|
||||
|
||||
**`proxmox.module.ts` / `proxmox.seed.ts` / `app.module.ts`** — Vorbild Domaincheck:
|
||||
`seedProxmoxModule` mit `slug: 'proxmox'`, `name: 'Proxmox'`, `version: '1.0.0'`,
|
||||
`category: 'infrastructure'`, deutscher und englischer Beschreibung, `isSystem: true`;
|
||||
`ProxmoxModule` importiert `ModuleRegistryModule` und ruft den Seed in `onModuleInit`;
|
||||
Eintrag in `app.module.ts` unter `imports` hinter `BugReportsModule`.
|
||||
|
||||
**Frontend** — `apps/web/src/lib/proxmox-api.ts` nach dem Vorbild `dkv-api.ts`
|
||||
(`listServers()`); `modules/proxmox/layout.tsx` mit
|
||||
`<ModuleAccessGate moduleSlug="proxmox">`; `modules/proxmox/page.tsx` als Client-Komponente,
|
||||
die die Serverliste laedt und je Server Name, Typ, Adresse und die vorhandenen Messwerte
|
||||
anzeigt — fehlende Werte als „unbekannt", bei leerer Liste ein ruhiger Hinweis, dass noch kein
|
||||
Server eingetragen ist (kein Fehlergewitter, keine weisse Flaeche); Eintrag `proxmox` in
|
||||
`MODULE_REGISTRY` (`module-loader.ts`). Alle sichtbaren Texte ueber `useTranslations('proxmox')`
|
||||
mit neuen Schluesseln in `de.json` UND `en.json` — deutsche Texte in der Sie-Form (D-10).
|
||||
|
||||
**Doku** — in `docs/mandantentrennung-zugriffsklassifikation.md` die neuen Fundstellen als
|
||||
Tabellenzeilen im Format `| Datei | Modell | Klasse | Stand | Begruendung |` eintragen
|
||||
(`apps/api/src/proxmox/proxmox.service.ts` / `proxmoxServer` und `proxmoxServerStatus`, Klasse
|
||||
`muss-mandantengebunden`, Stand `gebunden`), sonst schlaegt `rls-access-inventory.spec.ts` fehl.
|
||||
Die Bereichs- und Summenzeilen mit der Gate-Schleife NACHMESSEN, nicht abschreiben.
|
||||
|
||||
Deutsche Kommentare im Code wie in den Nachbardateien (D-10). Keine neue npm-Abhaengigkeit
|
||||
(D-07) — `undici` steht bereits als direkte Abhaengigkeit in `apps/api/package.json`.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>pnpm --filter @tessera/api exec vitest run src/proxmox src/prisma/rls-coverage.spec.ts src/prisma/rls-access-inventory.spec.ts</automated>
|
||||
<automated>pnpm --filter @tessera/api test</automated>
|
||||
<automated>pnpm --filter @tessera/web test</automated>
|
||||
<automated>pnpm type-check</automated>
|
||||
</verify>
|
||||
<done>
|
||||
`proxmox.service.spec.ts` fuehrt den ganzen Weg mit einer gefaelschten `undici`-Antwort durch
|
||||
(Vorbild der Attrappe: `icon-discovery.service.spec.ts`, `vi.mock('undici', …)`): Server
|
||||
anlegen, abfragen, Zwischenlager gelesen — und weist nach, dass (a) das Geheimnis
|
||||
verschluesselt in der Datenbank steht und in der Antwort von `listWithStatus` ueberhaupt nicht
|
||||
vorkommt, (b) bei `tlsRejectUnauthorized: true` KEIN Dispatcher uebergeben wird und bei
|
||||
`false` genau einer mit abgeschalteter Pruefung, (c) ein fehlendes Feld der Antwort zu `null`
|
||||
fuehrt und nicht zu einem Wurf. `pnpm --filter @tessera/api test` gruen mit mindestens 1240
|
||||
Tests, `pnpm --filter @tessera/web test` gruen mit mindestens 693 Tests, `pnpm type-check`
|
||||
4 von 4. Im Browser ist `/modules/infrastructure/proxmox` erreichbar und zeigt bei leerer
|
||||
Liste den ruhigen Hinweis.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Aufgabe 2: Zugang per Benutzer/Passwort, Fehler in Alltagssprache, Beobachtungs-Riegel</name>
|
||||
<files>
|
||||
apps/api/src/proxmox/proxmox-auth.ts,
|
||||
apps/api/src/proxmox/proxmox-client.service.ts,
|
||||
apps/api/src/proxmox/proxmox.service.ts,
|
||||
apps/api/src/proxmox/dto/proxmox-server.dto.ts,
|
||||
apps/api/src/proxmox/proxmox-client.service.spec.ts,
|
||||
apps/api/src/proxmox/proxmox-nur-lesen.spec.ts
|
||||
</files>
|
||||
<behavior>
|
||||
- Ticket-Anmeldung: `POST /api2/json/access/ticket` mit `username`/`password` als Formularfeldern liefert `data.ticket`; Folgeanfragen tragen das Ticket als Cookie mit produktabhaengigem Namen (`PVEAuthCookie`, `PBSAuthCookie`, `PMGAuthCookie`).
|
||||
- Kein `CSRFPreventionToken` wird jemals mitgesendet — dieses Modul liest nur, und fuer Leseanfragen verlangt Proxmox ihn laut offizieller Doku nicht.
|
||||
- Ein Server vom Typ `pmg` mit `authMethod: 'token'` wird beim Anlegen und beim Bearbeiten mit einer deutschen Klartextmeldung abgelehnt (400), nicht erst beim Abfragen.
|
||||
- Antwortstatus 401 wird zu `errorKind: 'zugang'`, 403 zu `'rechte'`, 404 zu `'antwortform'` mit dem Hinweis auf eine falsche Adresse, 5xx zu `'server'`.
|
||||
- Ein geworfener Netzfehler ohne Antwort (Verbindung verweigert, Zeitablauf, Name nicht aufloesbar) wird zu `errorKind: 'netz'`.
|
||||
- Ein Zertifikatsfehler (Meldungstext enthaelt eine der bekannten Zertifikatskennungen) wird zu `errorKind: 'zertifikat'` und NICHT zu `'netz'`.
|
||||
- Eine Antwort, die kein JSON ist (HTML-Anmeldeseite, leerer Rumpf), wird zu `errorKind: 'antwortform'` — kein geworfener Parserfehler, kein Absturz.
|
||||
- Laeuft ein Ticket ab (401 bei `authMethod: 'password'`), wird GENAU EINMAL neu angemeldet und die Abfrage wiederholt; erst ein zweites 401 wird zu `errorKind: 'zugang'`.
|
||||
- Keine Fehlermeldung, kein Protokolleintrag und kein `rawSample` enthaelt jemals Passwort, Token-Geheimnis oder das Ticket.
|
||||
- Im gesamten Verzeichnis `apps/api/src/proxmox` gibt es ausserhalb der Ticket-Anmeldung keine einzige Stelle, die ein anderes Anfrageverfahren als Lesen an Proxmox schickt.
|
||||
</behavior>
|
||||
<action>
|
||||
Zuerst die Tests aus `<behavior>` in `proxmox-client.service.spec.ts` schreiben (rot), dann
|
||||
implementieren. Die `undici`-Attrappe wie in `icon-discovery.service.spec.ts`.
|
||||
|
||||
`proxmox-auth.ts` waechst um den Ticket-Zweig und bleibt dabei die EINZIGE Stelle, die
|
||||
Kopfzeilen und Cookies baut (D-03, key_link): `buildTokenAuthHeader` wie in Aufgabe 1, neu
|
||||
`loginTicket(server, password)` und `buildTicketCookieHeader(productType, ticket)`. Die
|
||||
Cookie-Namen je Produkt stehen als benannte Konstante in dieser einen Datei, mit deutschem
|
||||
Kommentar, dass die Namen fuer PBS und PMG aus der Recherche nur abgeleitet sind (Annahme A2)
|
||||
und der Nutzer sie an seinen echten Servern bestaetigt — steht dort ein anderer Name, ist es
|
||||
genau diese eine Konstante, die angepasst wird.
|
||||
|
||||
`loginTicket` ist die EINZIGE Stelle im Modul, die eine nicht-lesende Anfrage an Proxmox
|
||||
schickt, und sie aendert dort nichts — sie holt nur einen Nachweis ab (D-01). Diesen
|
||||
Sonderstatus als deutschen Kommentar in der Datei festhalten.
|
||||
|
||||
`proxmox-client.service.ts` bekommt die Fehler-Uebersetzung: eine reine Funktion
|
||||
`classifyFailure(status, thrownError)`, die genau die sieben Werte aus `ProxmoxErrorKind`
|
||||
liefert, und eine Funktion `parseJsonLenient(text)`, die bei nicht-JSON kein Werfen zulaesst
|
||||
sondern das Scheitern meldet. Die Zertifikatserkennung laeuft ueber die bekannten
|
||||
Fehlerkennungen von Node/undici (selbstsigniert, abgelaufen, Name passt nicht, unbekannter
|
||||
Aussteller) — im Zweifel `'zertifikat'` nur bei eindeutigem Treffer, sonst `'netz'`.
|
||||
Zusaetzlich `errorDetail` als KURZE, deutsche Ergaenzung (Statuszahl, Fehlerkennung), aus der
|
||||
niemals ein Geheimnis hervorgeht; die Weiterverarbeitung des `errors`-Feldes der Proxmox-Antwort
|
||||
ist erlaubt, aber gekuerzt auf 500 Zeichen.
|
||||
|
||||
Die Ticket-Erneuerung sitzt in `proxmox.service.ts` (nicht im Klienten): ein Zaehler, der genau
|
||||
einen zweiten Versuch erlaubt. Der Grund als Kommentar: bei Ticketdauer von zwei Stunden
|
||||
erzeugt ein normaler Ablauf sonst alle zwei Stunden einen Fehlalarm.
|
||||
|
||||
`dto/proxmox-server.dto.ts` bekommt die produktabhaengige Pruefung (PMG plus Token ist
|
||||
ungueltig) — Pflichtfelder je nach `authMethod` mit `@ValidateIf`, damit ein Token-Zugang
|
||||
`tokenId` und Geheimnis verlangt und ein Passwort-Zugang `username` und Passwort.
|
||||
|
||||
`proxmox-nur-lesen.spec.ts` ist der maschinelle Riegel zu D-01, gebaut nach dem Vorbild von
|
||||
`apps/api/src/prisma/rls-access-inventory.spec.ts` (Test liest den Quelltext, nicht das
|
||||
Laufzeitverhalten): er liest alle `.ts`-Dateien unter `apps/api/src/proxmox`, entfernt vor
|
||||
dem Zaehlen Kommentarzeilen und Zeichenkettenliterale aus Testdateien, und prueft zwei
|
||||
Aussagen — erstens, dass die Summe der Stellen, die ein Anfrageverfahren an `undiciFetch`
|
||||
uebergeben, genau EINS ist und in `proxmox-auth.ts` liegt; zweitens, dass jeder gegen einen
|
||||
Proxmox-Pfad gebaute Aufruf ausser dieser einen ueber `proxmoxGet` laeuft. Die erwartete Zahl
|
||||
steht als benannte Konstante mit ausgeschriebener Begruendung in der Testdatei, damit eine
|
||||
spaetere Erhoehung eine bewusste Entscheidung erzwingt und nicht unbemerkt durchrutscht.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>pnpm --filter @tessera/api exec vitest run src/proxmox</automated>
|
||||
<automated>pnpm --filter @tessera/api test</automated>
|
||||
<automated>pnpm type-check</automated>
|
||||
</verify>
|
||||
<done>
|
||||
Alle Punkte aus `<behavior>` sind je durch mindestens einen Test belegt.
|
||||
`proxmox-nur-lesen.spec.ts` ist gruen und wuerde rot, wenn irgendwo im Modul eine zweite
|
||||
nicht-lesende Anfrage an Proxmox entstuende. `pnpm --filter @tessera/api test` gruen mit
|
||||
mindestens 1240 Tests, `pnpm type-check` 4 von 4.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Aufgabe 3: PBS und PMG auswerten — nachsichtig gegen jede Antwortform</name>
|
||||
<files>
|
||||
apps/api/src/proxmox/proxmox-normalize.ts,
|
||||
apps/api/src/proxmox/proxmox.service.ts,
|
||||
apps/api/src/proxmox/proxmox.types.ts,
|
||||
apps/api/src/proxmox/proxmox-normalize.spec.ts
|
||||
</files>
|
||||
<behavior>
|
||||
- PVE: aus `/api2/json/cluster/resources` entstehen Knotenzahl, Zahl laufender und gestoppter Gaeste, je Knoten Prozessorlast und Speicherbelegung, je Speicherort Belegung.
|
||||
- PBS: aus `/api2/json/status/datastore-usage` entsteht je Datenspeicher Gesamt, Belegt, Frei; aus `/api2/json/admin/datastore/{store}/snapshots` je Datenspeicher der Zeitpunkt der letzten Sicherung und das Ergebnis der letzten Pruefung.
|
||||
- PMG: aus `/api2/json/statistics/mail` entstehen die Tageszahlen eingehend, ausgehend, Spam, Viren.
|
||||
- Fehlt ein erwartetes Feld vollstaendig, ist der Einzelwert `null` — nie `0`, nie `NaN`, nie ein Wurf.
|
||||
- Kommt eine Zahl als Zeichenkette (`"42"`, `"0.37"`), wird sie als Zahl gelesen; kommt sie als nicht umwandelbarer Text, ist der Wert `null`.
|
||||
- Ist die gesamte Antwort eine Zeichenkette, ein Array statt eines Objekts, `null` oder leer, entsteht ein leeres Messwertobjekt mit `errorKind: 'antwortform'` — nie ein Wurf.
|
||||
- Heisst ein Feld anders als erwartet, bleibt der zugehoerige Einzelwert `null` und die gekuerzte Rohantwort bleibt in `rawSample` erhalten, damit der Nutzer am echten Server erkennt, wie das Feld wirklich heisst.
|
||||
- Ein Datenspeicher ohne Sicherungen ergibt „noch keine Sicherung" und keinen Fehler.
|
||||
- Ein PBS-Server mit vielen Datenspeichern erzeugt hoechstens 10 Folgeabfragen je Durchlauf.
|
||||
</behavior>
|
||||
<action>
|
||||
Zuerst `proxmox-normalize.spec.ts` schreiben (rot), mit ERFUNDENEN Antworten in der von der
|
||||
Recherche dokumentierten Form — es gibt in dieser Umgebung keinen echten Proxmox-Server, und
|
||||
es wird auch keiner angefragt. Je Punkt aus `<behavior>` mindestens ein Fall, und zusaetzlich
|
||||
je Produkt ein Fall „Feld fehlt", „Zahl kommt als Zeichenkette" und „Antwort ist HTML statt
|
||||
JSON".
|
||||
|
||||
`proxmox-normalize.ts` traegt die nachsichtigen Leser als reine Funktionen ohne
|
||||
Datenbankbezug: `readNumber(value)` (Zahl, umwandelbare Zeichenkette, sonst `null`),
|
||||
`readText(value)`, `readBool(value)` und `readList(value)` (liefert bei allem, was kein Array
|
||||
ist, eine leere Liste). Darauf setzen `normalizePve(body)`, `normalizePbs(usage, snapshots)`
|
||||
und `normalizePmg(body)` auf. Keine dieser Funktionen wirft jemals — der gesamte Umgang mit
|
||||
einer unerwarteten Form ist ein Rueckgabewert, nicht eine Ausnahme; als deutscher Kommentar
|
||||
festhalten, warum: der Nutzer prueft dieses Modul allein an seinen echten Servern, und ein Wurf
|
||||
wuerde ihm eine leere Seite statt eines Hinweises zeigen.
|
||||
|
||||
Die Feldnamen von PBS und PMG sind aus der Recherche nur abgeleitet (Annahmen A2, A3, A5). In
|
||||
`proxmox-normalize.ts` je Produkt eine benannte Konstante mit den erwarteten Feldnamen und
|
||||
einem deutschen Kommentar, dass genau diese Liste anzupassen ist, falls der echte Server
|
||||
andere Namen liefert — dadurch gibt es EINE Stelle zum Nachziehen statt verstreuter
|
||||
Zeichenketten im Auswertungscode. Wo ein Feld unter mehreren plausiblen Namen auftreten kann,
|
||||
darf die Konstante mehrere Namen in Reihenfolge nennen, und der Leser nimmt den ersten
|
||||
vorhandenen.
|
||||
|
||||
`proxmox.service.ts` waechst um die produktabhaengige Abfragefolge: `pve` eine Abfrage, `pbs`
|
||||
die Belegungsabfrage plus je Datenspeicher hoechstens zehn Folgeabfragen (Deckel als benannte
|
||||
Konstante mit Begruendung), `pmg` eine Abfrage. Jede dieser Abfragen laeuft ueber `proxmoxGet`
|
||||
— keine neue Aufrufform (Riegel aus Aufgabe 2 bleibt gruen). Das Zwischenlager bekommt je
|
||||
Produkt seine Messwertform; `ProxmoxMetrics` in `proxmox.types.ts` als unterscheidbare Union
|
||||
ueber `productType`, damit das Frontend typsicher verzweigen kann.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>pnpm --filter @tessera/api exec vitest run src/proxmox</automated>
|
||||
<automated>pnpm --filter @tessera/api test</automated>
|
||||
<automated>pnpm type-check</automated>
|
||||
</verify>
|
||||
<done>
|
||||
Alle Punkte aus `<behavior>` sind je durch mindestens einen Test belegt, einschliesslich der
|
||||
vier ausdruecklich verlangten Fehlformen (Feld fehlt, Zahl als Zeichenkette, HTML statt JSON,
|
||||
Statuscodes 401/403/404/500 — Letztere aus Aufgabe 2 weiterhin gruen).
|
||||
`proxmox-nur-lesen.spec.ts` bleibt gruen. `pnpm --filter @tessera/api test` gruen mit
|
||||
mindestens 1240 Tests, `pnpm type-check` 4 von 4.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Aufgabe 4: Hintergrundabfrage je Mandant und der Knopf „Verbindung testen"</name>
|
||||
<files>
|
||||
apps/api/src/proxmox/proxmox-scheduler.service.ts,
|
||||
apps/api/src/proxmox/proxmox.service.ts,
|
||||
apps/api/src/proxmox/proxmox.controller.ts,
|
||||
apps/api/src/proxmox/proxmox.module.ts,
|
||||
apps/api/src/proxmox/proxmox-scheduler.service.spec.ts,
|
||||
apps/api/src/prisma/rls-access-inventory.spec.ts,
|
||||
docs/mandantentrennung-zugriffsklassifikation.md
|
||||
</files>
|
||||
<behavior>
|
||||
- Beim Start registriert der Planer je Mandant mit mindestens einem aktiven Server genau einen Auftrag unter dem Registry-Namen `proxmox-poll:<tenantId>`.
|
||||
- Der Tick eines Mandanten geht ueber dessen Server und fragt jeden einzeln ab; ein fehlgeschlagener Server bricht die Schleife nicht ab.
|
||||
- Ein zweiter Mandant verdraengt den Auftrag des ersten nicht — beide Auftraege bestehen nebeneinander.
|
||||
- Keine aktiven Server bedeutet: kein Auftrag, ein Protokolleintrag, kein Fehler, nichts geloescht.
|
||||
- Nach dem Speichern eines Servers zieht der Controller den Auftrag dieses Mandanten sofort nach — ohne Neustart.
|
||||
- Der Planer haengt an `onApplicationBootstrap`, nicht an `onModuleInit`.
|
||||
- Ein Fehler beim Start wird gefangen und protokolliert, nie weitergeworfen — die Anwendung startet trotzdem.
|
||||
- `POST servers/:id/test` liefert bei Erfolg eine Erfolgsmeldung und bei Misserfolg genau einen der sieben Fehlerschluessel samt kurzer Ergaenzung, ohne den Zwischenlagerstand zu ueberschreiben.
|
||||
- `POST servers/:id/poll` verweigert einen zweiten Durchlauf innerhalb von zehn Sekunden und liefert stattdessen den vorhandenen Zwischenlagerstand.
|
||||
</behavior>
|
||||
<action>
|
||||
Zuerst `proxmox-scheduler.service.spec.ts` schreiben (rot) — Vorbild
|
||||
`dkv-scheduler.service.spec.ts`, je Aussage aus `<behavior>` ein Test.
|
||||
|
||||
`proxmox-scheduler.service.ts` kombiniert die zwei Bestandsmuster (Recherche, Block 3): das
|
||||
Mandanten-Auffaechern von `DkvSchedulerService` (ein Auftrag je Mandant, Registry-Name mit
|
||||
Mandantenkennung als Suffix — die Vorgaengerform mit EINEM Auftragsfeld war genau der Fehler
|
||||
WINDOWS #21) und die Lebenszyklus-Stufe von `TenderSchedulerService`
|
||||
(`implements OnApplicationBootstrap`). Den Grund fuer `onApplicationBootstrap` als deutschen
|
||||
Kommentar uebernehmen: die Reihenfolge der `onModuleInit`-Haken zwischen Modulen ist nicht
|
||||
festgelegt, und die Erfahrung „frische Datenbank ingestiert nichts bis zum zweiten Neustart"
|
||||
steht bereits im Projektgedaechtnis. Die Aufloesung von `CronJob` ueber `require('cron')`
|
||||
samt Kommentar woertlich aus `dkv-scheduler.service.ts` uebernehmen (pnpm-Isolation).
|
||||
|
||||
Anders als bei DKV ist ein Mandant NICHT gleich ein Server: der Tick eines Mandanten geht ueber
|
||||
dessen Serverzeilen. Das Abfrageintervall eines Mandanten ist das kleinste `pollIntervalMin`
|
||||
seiner aktiven Server. Ein fehlgeschlagener Server schreibt seinen Fehler ins Zwischenlager
|
||||
und die Schleife laeuft weiter — dieser Punkt ausdruecklich als Test.
|
||||
|
||||
Der Startpfad `loadActiveServersForScheduler()` in `proxmox.service.ts` ist der EINZIGE
|
||||
Systemkontext-Aufruf des Moduls: `const systemPrisma = forSystem(this.prisma);`, nur lesend,
|
||||
ohne `include` auf das Zwischenlager (die Zwischenlagertabelle hat bewusst keine
|
||||
Systemlese-Regel — das Nachziehen laeuft je Zeile gebunden). Danach wird je Mandant und je
|
||||
Server ueber `forTenant(this.prisma, tenantId)` geschrieben, Muster
|
||||
`DkvSchedulerService`/`DashboardImagesService` (einmal lesen, viele bedienen). Diesen einen
|
||||
Aufruf in `FORSYSTEM_ALLOWED_CALL_SITES` in `apps/api/src/prisma/rls-access-inventory.spec.ts`
|
||||
eintragen (`apps/api/src/proxmox/proxmox.service.ts` mit Anzahl 1) und den Kopfkommentar
|
||||
derselben Datei um den neuen Fall ergaenzen, wie es die bestehenden sieben Faelle vormachen —
|
||||
sonst schlaegt der Waechter „ein Anfrageweg darf den Systemkontext nie rufen" fehl. In
|
||||
`docs/mandantentrennung-zugriffsklassifikation.md` den Stand der Zeile
|
||||
`proxmox.service.ts`/`proxmoxServer` von `gebunden` auf `system-gebunden` heben, mit derselben
|
||||
Begruendungsform wie bei `dashboard-images.service.ts`; Bereichs- und Summenzeilen mit der
|
||||
Gate-Schleife nachmessen.
|
||||
|
||||
`proxmox.controller.ts` bekommt `POST servers/:id/test` (ADMIN/SUPER_ADMIN) — es benutzt
|
||||
denselben Klienten und dieselbe Fehleruebersetzung wie der Planer, schreibt aber NICHT ins
|
||||
Zwischenlager, damit ein Testklick den zuletzt gemessenen Stand nicht ueberschreibt (Vorbild
|
||||
`TenderEmailConfigService.testConnection` und der LDAP-Test). Zusaetzlich ruft der Controller
|
||||
nach jedem erfolgreichen Anlegen und Speichern `scheduler.setInterval(tenantId)` — Vorbild
|
||||
`DkvController`. `POST servers/:id/poll` bekommt die Zehn-Sekunden-Sperre als Schutz davor,
|
||||
dass ein Klick in der Oberflaeche zu ungebremsten Anfragen gegen die Fremd-API wird
|
||||
(Bedrohungsmodell T-DHH-06).
|
||||
|
||||
`proxmox.module.ts` nimmt den Planer in `providers` auf; `ScheduleModule` ist bereits global
|
||||
in `app.module.ts` registriert — nichts zusaetzlich einzurichten.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>pnpm --filter @tessera/api exec vitest run src/proxmox src/prisma/rls-access-inventory.spec.ts src/prisma/rls-coverage.spec.ts</automated>
|
||||
<automated>pnpm --filter @tessera/api test</automated>
|
||||
<automated>pnpm type-check</automated>
|
||||
</verify>
|
||||
<done>
|
||||
Alle Punkte aus `<behavior>` sind je durch mindestens einen Test belegt.
|
||||
`rls-access-inventory.spec.ts` und `rls-coverage.spec.ts` sind gruen, einschliesslich des
|
||||
neuen Erlaubnislisten-Eintrags und der nachgezogenen Dokumentationszeilen.
|
||||
`proxmox-nur-lesen.spec.ts` bleibt gruen. `pnpm --filter @tessera/api test` gruen mit
|
||||
mindestens 1240 Tests, `pnpm type-check` 4 von 4.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Aufgabe 5: Einstellungsseite — Server anlegen, bearbeiten, loeschen, testen</name>
|
||||
<files>
|
||||
apps/api/src/proxmox/proxmox.controller.ts,
|
||||
apps/api/src/proxmox/proxmox.service.ts,
|
||||
apps/api/src/proxmox/proxmox.service.spec.ts,
|
||||
apps/web/src/lib/proxmox-api.ts,
|
||||
apps/web/src/app/(portal)/modules/proxmox/settings/page.tsx,
|
||||
apps/web/src/app/(portal)/modules/proxmox/settings/components/ServerForm.tsx,
|
||||
apps/web/src/app/(portal)/modules/proxmox/settings/components/ServerForm.test.tsx,
|
||||
apps/web/src/messages/de.json,
|
||||
apps/web/src/messages/en.json
|
||||
</files>
|
||||
<behavior>
|
||||
- Bei Typ `pmg` erscheint die Auswahl „API-Token" im Formular gar nicht; nur Benutzer und Passwort sind zu sehen.
|
||||
- Bei Typ `pve` oder `pbs` und Auswahl „API-Token" erscheinen Token-Kennung und Token-Geheimnis; bei Auswahl „Benutzer/Passwort" stattdessen Benutzer und Passwort.
|
||||
- Ein gespeichertes Geheimnis wird beim Bearbeiten nie im Klartext angezeigt; das Feld ist leer und ein leer gelassenes Feld laesst das gespeicherte Geheimnis unveraendert.
|
||||
- Der Schalter fuer die Zertifikatspruefung steht beim Anlegen auf „pruefen" und traegt einen erklaerenden Hinweis, dass die Ausnahme nur fuer diesen einen Server gilt.
|
||||
- Der Knopf „Verbindung testen" zeigt bei Erfolg eine gruene Bestaetigung und bei Misserfolg den Klartext der Ursache in der Sie-Form.
|
||||
- Ein Benutzer ohne Verwaltungsrolle sieht die Einstellungsseite nicht, sondern einen Hinweis.
|
||||
- Loeschen verlangt eine Rueckfrage und entfernt Server samt Zwischenlagerzeile.
|
||||
</behavior>
|
||||
<action>
|
||||
Zuerst `ServerForm.test.tsx` schreiben (rot), Vorbild
|
||||
`modules/tender-radar/settings/components/EmailAlertConfigForm.test.tsx` und
|
||||
`modules/dkv-fleet/settings/components/InboxConfigForm.tsx`.
|
||||
|
||||
Backend: `proxmox.controller.ts` und `proxmox.service.ts` um `PUT servers/:id` und
|
||||
`DELETE servers/:id` ergaenzen, beide mit `@Roles(Role.ADMIN, Role.SUPER_ADMIN)` und beide
|
||||
ueber `forTenant()`. Beim Aendern gilt dieselbe Regel wie bei
|
||||
`LdapConfigService.updateConfig`: ein NICHT gesendetes Geheimnisfeld laesst den gespeicherten
|
||||
Wert unveraendert, eine LEERE Zeichenkette bedeutet „loeschen" und ein gefuellter Wert wird
|
||||
neu verschluesselt. Die Ablehnung „PMG mit Token" gilt auch hier. Das Loeschen entfernt die
|
||||
Zwischenlagerzeile ueber die Fremdschluesselregel mit Loeschweitergabe und zieht anschliessend
|
||||
den Auftrag des Mandanten nach.
|
||||
|
||||
Frontend: `settings/page.tsx` nach dem Muster von
|
||||
`modules/tender-radar/settings/page.tsx` — Rollenpruefung ausschliesslich zur Anzeige, mit
|
||||
Ladezustand solange die Rolle unbekannt ist, damit die Verwaltungsteile fuer einen normalen
|
||||
Benutzer nie kurz aufblitzen; der verbindliche Riegel bleibt serverseitig. Darin die
|
||||
Serverliste und das Formular `ServerForm.tsx`: Name, Typ (drei Knoepfe oder Auswahl),
|
||||
Adresse, Zugangsart, die typabhaengigen Zugangsfelder, Abfrageintervall, Schalter fuer die
|
||||
Zertifikatspruefung, aktiv/inaktiv. Der Knopf „Verbindung testen" ruft
|
||||
`POST servers/:id/test` und zeigt das Ergebnis direkt beim Formular. Die Uebersetzung der
|
||||
sieben Fehlerschluessel liegt im Frontend unter `proxmox.errors.*` — deutsche Texte in der
|
||||
Sie-Form (D-10), englische Entsprechungen in `en.json`; die Texte nennen die Ursache und den
|
||||
naechsten Schritt, ohne Fachbegriffe (Beispielform fuer `zugang`: „Der Zugang wurde
|
||||
abgelehnt. Bitte pruefen Sie Benutzername und Passwort beziehungsweise die Token-Angaben.").
|
||||
`proxmox-api.ts` bekommt `createServer`, `updateServer`, `deleteServer`, `testServer`,
|
||||
`pollServer`.
|
||||
|
||||
Biome-Warnungen in `apps/web` muessen danach exakt 53 bleiben — neue Formulareingaben brauchen
|
||||
daher von Anfang an die im Bestand ueblichen Beschriftungsbezuege und Tastaturbedienbarkeit.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>pnpm --filter @tessera/web exec vitest run proxmox</automated>
|
||||
<automated>pnpm --filter @tessera/api test</automated>
|
||||
<automated>pnpm --filter @tessera/web test</automated>
|
||||
<automated>pnpm lint</automated>
|
||||
<automated>pnpm --filter @tessera/web exec biome lint . 2>&1 | grep -c 'Found 53 warnings'</automated>
|
||||
</verify>
|
||||
<done>
|
||||
Alle Punkte aus `<behavior>` sind je durch mindestens einen Test belegt.
|
||||
`pnpm --filter @tessera/web test` gruen mit mindestens 693 Tests,
|
||||
`pnpm --filter @tessera/api test` gruen mit mindestens 1240 Tests, `pnpm lint` 5 von 5, und
|
||||
`biome lint` in `apps/web` meldet unveraendert 53 Warnungen.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Aufgabe 6: Modulseite — Serverliste mit Auslastung, Klartext bei Stoerungen</name>
|
||||
<files>
|
||||
apps/web/src/app/(portal)/modules/proxmox/page.tsx,
|
||||
apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.tsx,
|
||||
apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.test.tsx,
|
||||
apps/web/src/lib/proxmox-api.ts,
|
||||
apps/web/src/messages/de.json,
|
||||
apps/web/src/messages/en.json
|
||||
</files>
|
||||
<behavior>
|
||||
- Ohne eingetragenen Server zeigt die Seite einen ruhigen Hinweis mit dem Weg zu den Einstellungen — keine Fehlermeldung, keine leere Flaeche.
|
||||
- Ein PVE-Server zeigt Knotenzahl, laufende und gestoppte Gaeste sowie je Knoten Prozessorlast und Speicherbelegung.
|
||||
- Ein PBS-Server zeigt je Datenspeicher Belegung, letzte Sicherung und Ergebnis der letzten Pruefung.
|
||||
- Ein PMG-Server zeigt die Tageszahlen eingehend, ausgehend, Spam und Viren.
|
||||
- Ein Messwert, der `null` ist, erscheint als „unbekannt" — nie als `0`, nie als leeres Feld, nie als `NaN`.
|
||||
- Ein Server mit `reachable: false` zeigt den Klartext seiner Ursache und daneben den Zeitpunkt der letzten erfolgreichen Messung, falls es eine gab.
|
||||
- Der Zeitpunkt der letzten Abfrage steht bei jedem Server.
|
||||
- Zaehlerfelder sind ausdruecklich als „gesamt seit Start" beschriftet, nicht als aktueller Durchsatz.
|
||||
- Der Knopf „Jetzt aktualisieren" loest eine Abfrage aus und laedt danach die Liste neu; waehrend des Laufs ist er gesperrt.
|
||||
</behavior>
|
||||
<action>
|
||||
Zuerst `ServerCard.test.tsx` schreiben (rot) — je Punkt aus `<behavior>` ein Fall, mit
|
||||
erfundenen Zwischenlagerstaenden je Produkttyp, einschliesslich eines Standes, in dem jeder
|
||||
Einzelwert `null` ist.
|
||||
|
||||
`ServerCard.tsx` ist die Anzeige EINES Servers und verzweigt ueber `productType` auf der
|
||||
unterscheidbaren Union aus Aufgabe 3. Eine gemeinsame kleine Hilfe stellt jeden Einzelwert
|
||||
dar: ist er `null` oder `undefined`, erscheint der uebersetzte Text „unbekannt"; sonst der
|
||||
Wert mit seiner Einheit (Prozentwerte gerundet, Byte-Werte in lesbarer Form). Diese Hilfe ist
|
||||
die einzige Stelle, die einen Messwert in Text verwandelt — dadurch kann kein Zweig versehentlich
|
||||
eine `0` anzeigen, wo nichts gemessen wurde. Den Grund als deutschen Kommentar festhalten: die
|
||||
Feldnamen von PBS und PMG sind bis zur Pruefung am echten Server nur abgeleitet, und ein still
|
||||
falscher Wert waere schlimmer als ein ehrliches „unbekannt".
|
||||
|
||||
Die Zaehlerfelder aus `cluster/resources` sind kumulative Werte seit dem Start eines Gastes,
|
||||
keine Rate (Recherche, Fallstricke) — die Beschriftung sagt das ausdruecklich, damit der
|
||||
Nutzer sie nicht als aktuellen Durchsatz liest.
|
||||
|
||||
`page.tsx` zeigt die Serverliste, oben den Knopf „Jetzt aktualisieren", und fuer Benutzer mit
|
||||
Verwaltungsrolle einen Verweis auf die Einstellungsseite. Bei leerer Liste der ruhige Hinweis.
|
||||
Schlaegt der Listenabruf selbst fehl, erscheint eine einzelne verstaendliche Meldung, nicht
|
||||
mehrere. Alle Texte ueber `useTranslations('proxmox')` in `de.json` UND `en.json`, deutsch in
|
||||
der Sie-Form (D-10).
|
||||
|
||||
Biome-Warnungen in `apps/web` bleiben exakt 53.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>pnpm --filter @tessera/web exec vitest run proxmox</automated>
|
||||
<automated>pnpm --filter @tessera/web test</automated>
|
||||
<automated>pnpm type-check</automated>
|
||||
<automated>pnpm --filter @tessera/web exec biome lint . 2>&1 | grep -c 'Found 53 warnings'</automated>
|
||||
</verify>
|
||||
<human-check>
|
||||
Im Browser `/modules/infrastructure/proxmox` oeffnen: ohne Server steht dort der ruhige
|
||||
Hinweis; nach dem Anlegen eines Servers in den Einstellungen erscheint er in der Liste, und
|
||||
ein absichtlich falsch eingetragener Zugang zeigt Klartext statt einer leeren Flaeche.
|
||||
</human-check>
|
||||
<done>
|
||||
Alle Punkte aus `<behavior>` sind je durch mindestens einen Test belegt.
|
||||
`pnpm --filter @tessera/web test` gruen mit mindestens 693 Tests, `pnpm type-check` 4 von 4,
|
||||
`biome lint` in `apps/web` unveraendert 53 Warnungen.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Aufgabe 7: Dokumentation und Nachmessung aller Tore</name>
|
||||
<files>
|
||||
docs/anleitung-entwicklung.md,
|
||||
docs/anwenderhandbuch.md,
|
||||
docs/mandantentrennung-zugriffsklassifikation.md
|
||||
</files>
|
||||
<action>
|
||||
`docs/anwenderhandbuch.md` bekommt einen Abschnitt zum Proxmox-Modul in Alltagssprache und in
|
||||
der Sie-Form (D-10): was das Modul zeigt, wie ein Server in den Einstellungen angelegt wird
|
||||
(Name, Typ, Adresse, Zugang), welche NUR-LESE-Rolle im jeweiligen Produkt zu vergeben ist
|
||||
(PVE `PVEAuditor`, PBS `Audit` beziehungsweise `DatastoreAudit`, PMG `Auditor`), dass bei PMG
|
||||
nur Benutzer und Passwort moeglich sind, wozu der Schalter fuer die Zertifikatspruefung da ist
|
||||
und dass er nur fuer genau diesen einen Server gilt, was der Knopf „Verbindung testen" sagt
|
||||
und was „unbekannt" bei einem Messwert bedeutet. Ausdruecklich festhalten: Tessera veraendert
|
||||
bei Proxmox nichts, es schaut nur zu (D-01).
|
||||
|
||||
`docs/anleitung-entwicklung.md` bekommt im Abschnitt „So entsteht ein neues Modul" einen
|
||||
Hinweis auf `proxmox` als Vorlage fuer ein Modul mit Fremdsystem-Zugaengen und
|
||||
Hintergrundabfrage, und an geeigneter Stelle den Merksatz zur `undici`-Falle (globales `fetch`
|
||||
ignoriert einen Dispatcher aus dem npm-Paket), falls er dort noch nicht steht.
|
||||
|
||||
`docs/mandantentrennung-zugriffsklassifikation.md` abschliessend nachziehen: den neuen Bereich
|
||||
`proxmox` als eigene Zeile in der Bereichsuebersicht und die Summenzeile — beides mit der
|
||||
Gate-Schleife NACHGEMESSEN, nicht abgeschrieben, und mit dem Auftragskuerzel `260923-dhh`
|
||||
versehen wie die bestehenden Eintraege.
|
||||
|
||||
Danach alle Tore einmal vollstaendig durchlaufen und die Endzahlen in der Zusammenfassung
|
||||
gegen die Ausgangswerte aus dem `<objective>` stellen: api-Tests, web-Tests, type-check,
|
||||
lint, Biome-Warnungen in `apps/web`. Eine Verschlechterung an irgendeinem Tor ist ein
|
||||
Abbruchgrund, keine Randnotiz.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>pnpm --filter @tessera/api test</automated>
|
||||
<automated>pnpm --filter @tessera/web test</automated>
|
||||
<automated>pnpm type-check</automated>
|
||||
<automated>pnpm lint</automated>
|
||||
<automated>pnpm --filter @tessera/web exec biome lint . 2>&1 | grep -c 'Found 53 warnings'</automated>
|
||||
</verify>
|
||||
<done>
|
||||
Anwenderhandbuch und Entwicklungsanleitung beschreiben das Modul; die Klassifikationstabelle
|
||||
ist nachgemessen und `rls-access-inventory.spec.ts` gruen. Endzahlen dokumentiert:
|
||||
api-Tests gruen und mindestens 1240, web-Tests gruen und mindestens 693, type-check 4 von 4,
|
||||
lint 5 von 5, Biome-Warnungen in `apps/web` exakt 53.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Browser -> Tessera-API | Der Administrator sendet Serveradressen und Zugangsdaten; jeder Benutzer mit Modulfreigabe liest die Serverliste |
|
||||
| Tessera-API -> Proxmox (PVE/PBS/PMG) | Ausgehende Verbindung in das interne Netz mit einem Geheimnis im Gepaeck; Gegenstelle ist nicht von Tessera kontrolliert |
|
||||
| Tessera-API -> PostgreSQL | Verschluesselte Zugangsdaten und Messwerte; Mandantentrennung ueber RLS |
|
||||
| Mandant A -> Mandant B | Zwei Mandanten duerfen die Proxmox-Zugaenge des jeweils anderen nie sehen |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-DHH-01 | Information Disclosure | Zugangsdaten in Antwort, Protokoll und Fehlermeldung | critical | mitigate | Aufgabe 1: `listWithStatus` waehlt `encryptedTokenSecret`/`encryptedPassword` per `select` gar nicht erst aus (nicht nachtraeglich maskiert). Aufgabe 2: `proxmox-auth.ts` protokolliert nie, `errorDetail` traegt nur Statuszahl und Fehlerkennung, `rawSample` ist auf 20 000 Zeichen gekuerzt und enthaelt nur Antwortdaten, nie die gesendete Kopfzeile. Test in Aufgabe 1/2: keine Geheimnisform in Antwort und Meldung |
|
||||
| T-DHH-02 | Spoofing / SSRF | Vom Administrator eingetragene Adresse | high | mitigate | Nur ADMIN/SUPER_ADMIN duerfen Adressen eintragen (`@Roles` auf allen Schreibwegen, Aufgabe 1/5) — damit ist jede Adresse eine bewusste Freigabe (D-02). Adressform per `@IsUrl` auf `http`/`https` begrenzt. BEWUSST KEINE Privat-IP-Sperre wie `isPublicHttpUrl`: Proxmox steht per Definition im privaten Netz, eine solche Sperre wuerde das Modul unbrauchbar machen; die Begruendung steht als Kommentar in `proxmox-client.service.ts`. Abbruch nach 8 Sekunden begrenzt den Missbrauch als Portscanner |
|
||||
| T-DHH-03 | Information Disclosure | Zertifikats-Ausnahme reicht weiter als gewollt | high | mitigate | Aufgabe 1: Dispatcher wird JE AUFRUF aus dem Feld `tlsRejectUnauthorized` genau dieser Serverzeile gebaut; Voreinstellung `true`. Keine Modulkonstante, keine Node-Umgebungsvariable. Test: bei `true` wird kein Dispatcher uebergeben, bei `false` genau einer mit abgeschalteter Pruefung — und die Ausnahme eines Servers wirkt nicht auf einen zweiten |
|
||||
| T-DHH-04 | Elevation of Privilege | Fremder Mandant liest Proxmox-Zugaenge | critical | mitigate | Aufgabe 1: `tenantId` auf beiden Tabellen, `tenant_isolation_policy` in der Migration, jeder Zugriff ueber `forTenant()`. Aufgabe 4: der einzige `forSystem()`-Aufruf ist der Startpfad des Planers, in `FORSYSTEM_ALLOWED_CALL_SITES` eingetragen und rein lesend; geschrieben wird je Zeile gebunden. Gates: `rls-coverage.spec.ts`, `rls-access-inventory.spec.ts` |
|
||||
| T-DHH-05 | Elevation of Privilege | Rechteausweitung ueber das Modul | high | mitigate | Aufgabe 1: `@UseModule('proxmox')` auf Klassenebene (Aktivierung UND Freigabe, D-09), zusaetzlich `@Roles(ADMIN, SUPER_ADMIN)` auf jedem Schreibweg. `tenantId` und Rolle kommen ausschliesslich aus dem geprueften Sitzungsnachweis, nie aus Body oder Query. Die Rollenpruefung im Frontend (Aufgabe 5) ist reine Anzeige und ersetzt nichts |
|
||||
| T-DHH-06 | Denial of Service | Ungebremster Nutzer-Auslöser gegen die Fremd-API | medium | mitigate | Aufgabe 4: `POST servers/:id/poll` sperrt einen zweiten Durchlauf innerhalb von zehn Sekunden und liefert stattdessen den Zwischenlagerstand. Regulaer fragt ausschliesslich der Planer mit begrenzter Frequenz ab; jede Anzeige liest aus dem Zwischenlager (D-05). Aufgabe 3: Deckel von zehn Folgeabfragen je PBS-Durchlauf |
|
||||
| T-DHH-07 | Tampering | Ein veraendernder Weg gegen Proxmox entsteht (heute oder spaeter) | high | mitigate | Aufgabe 1: nur eine Datenabruf-Funktion `proxmoxGet`, Verfahren fest verdrahtet. Aufgabe 2: `proxmox-nur-lesen.spec.ts` zaehlt maschinell nach, dass die einzige nicht-lesende Anfrage die Ticket-Anmeldung ist, mit benannter Erwartungszahl und ausgeschriebener Begruendung — eine spaetere Erhoehung erzwingt eine bewusste Entscheidung (D-01) |
|
||||
| T-DHH-08 | Tampering | Zwischenlager zeigt still einen Falschwert | medium | mitigate | Aufgabe 3: jeder Einzelwert wird nachsichtig gelesen und ist bei fehlendem oder unbrauchbarem Feld `null`; Aufgabe 6: `null` erscheint als „unbekannt", nie als `0`. Die gekuerzte Rohantwort bleibt erhalten, damit der Nutzer am echten Server erkennt, wie ein Feld wirklich heisst |
|
||||
| T-DHH-SC | Tampering | Paketinstallationen | low | accept | Dieser Auftrag installiert kein einziges Paket (D-07) — `undici` ist bereits direkte Abhaengigkeit von `apps/api`. Die Paket-Pruefliste der Recherche weist den Punkt ausdruecklich als nicht anwendbar aus. Entsteht wider Erwarten doch eine Installation, greift die Paket-Pruefung vor dem Einbau |
|
||||
</threat_model>
|
||||
|
||||
<source_audit>
|
||||
## Mehrfachquellen-Abdeckung
|
||||
|
||||
**GOAL** (Auftragsbeschreibung)
|
||||
|
||||
| Punkt | Status | Abgedeckt durch |
|
||||
|---|---|---|
|
||||
| PVE, PBS und PMG anbinden | COVERED | Aufgabe 1 (PVE), Aufgabe 3 (PBS, PMG) |
|
||||
| Nur beobachten | COVERED | Aufgabe 1 (`proxmoxGet`), Aufgabe 2 (`proxmox-nur-lesen.spec.ts`) |
|
||||
| Server in den Einstellungen anlegen (Adresse + Zugang) | COVERED | Aufgabe 1 (Anlegen), Aufgabe 5 (Oberflaeche, Bearbeiten, Loeschen) |
|
||||
| Zugang Token oder Benutzer/Passwort, verschluesselt | COVERED | Aufgabe 1 (Token), Aufgabe 2 (Benutzer/Passwort), beide ueber `CryptoService` |
|
||||
| Abfrage im Hintergrund mit Zwischenlager | COVERED | Aufgabe 1 (Zwischenlagertabelle), Aufgabe 4 (Planer) |
|
||||
| Modulseite mit Serverliste und Auslastung | COVERED | Aufgabe 1 (duenne Liste), Aufgabe 6 (Auslastung je Produkt) |
|
||||
|
||||
**RESEARCH** (`260923-dhh-RESEARCH.md`)
|
||||
|
||||
| Punkt | Status | Abgedeckt durch |
|
||||
|---|---|---|
|
||||
| Token-Kopfzeilen je Produkt, PMG ohne Token (A1) | COVERED | Aufgabe 1 und 2 (`proxmox-auth.ts`), Aufgabe 5 (Formular bietet es bei PMG nicht an) |
|
||||
| Ticket-Anmeldung, Cookie-Namen je Produkt (A2) | COVERED | Aufgabe 2, Cookie-Namen als EINE benannte Konstante mit Annahme-Kommentar |
|
||||
| Kein CSRF noetig, weil nur gelesen wird | COVERED | Aufgabe 2 (`<behavior>`) |
|
||||
| `cluster/resources` als eine Abfrage fuer PVE | COVERED | Aufgabe 1 und 3 |
|
||||
| PBS-Belegung und Snapshot-Felder (A3) | COVERED | Aufgabe 3, Feldnamen als EINE benannte Konstante |
|
||||
| PMG-Tageszahlen (A5, keine Quarantaene) | COVERED | Aufgabe 3; Quarantaene bleibt ausserhalb des Umfangs |
|
||||
| Fehlerverhalten 401 breiter als ueblich (A4) | COVERED | Aufgabe 2 (`classifyFailure`) |
|
||||
| undici-Dispatcher-Falle unter Node 24 | COVERED | Aufgabe 1 (Kommentar und Test), Aufgabe 7 (Anleitung) |
|
||||
| Pro Zeile umschaltbarer Zertifikats-Bypass | COVERED | Aufgabe 1, T-DHH-03 |
|
||||
| `onApplicationBootstrap` statt `onModuleInit` | COVERED | Aufgabe 4 |
|
||||
| Mandanten-Auffaechern je Cron-Auftrag | COVERED | Aufgabe 4 |
|
||||
| Zwischenlager statt Live-Abfrage | COVERED | Aufgabe 1, 4, 6 |
|
||||
| RLS-Migration, Klassifikationsdoku, Erlaubnisliste | COVERED | Aufgabe 1 (Migration, Doku), Aufgabe 4 (Erlaubnisliste), Aufgabe 7 (Nachmessung) |
|
||||
| Nur-Lese-Rollen je Produkt als Hinweis an den Admin | COVERED | Aufgabe 7 (Anwenderhandbuch), `user_setup` im Frontmatter |
|
||||
| Zaehler sind kumulativ, keine Rate | COVERED | Aufgabe 6 (Beschriftung) |
|
||||
| Ticket-Erneuerung bei 401 | COVERED | Aufgabe 2 |
|
||||
| Keine neue npm-Abhaengigkeit | COVERED | Aufgabe 1 (D-07), Paket-Pruefliste nicht anwendbar |
|
||||
|
||||
**CONTEXT** (getroffene Entscheidungen D-01 bis D-11)
|
||||
|
||||
| ID | Status | Abgedeckt durch |
|
||||
|---|---|---|
|
||||
| D-01 | COVERED | Aufgabe 1 (`proxmoxGet`), Aufgabe 2 (`proxmox-nur-lesen.spec.ts`), `must_haves.truths`, T-DHH-07 |
|
||||
| D-02 | COVERED | Aufgabe 1 (Verschluesselung, `select` ohne Geheimnisse), Aufgabe 5 (Formular), T-DHH-01 |
|
||||
| D-03 | COVERED | Aufgabe 1 und 2 (`proxmox-auth.ts` als einzige Stelle), Aufgabe 5 (Formular ohne Token bei PMG) |
|
||||
| D-04 | COVERED | Aufgabe 1 (Dispatcher je Aufruf), Aufgabe 5 (Schalter), T-DHH-03 |
|
||||
| D-05 | COVERED | Aufgabe 1 (Zwischenlager), Aufgabe 4 (Planer nach TENDER-Muster), Aufgabe 6 (Seite liest nur den Cache) |
|
||||
| D-06 | COVERED | Aufgabe 2 (Fehlerklassen), Aufgabe 4 (Testendpunkt), Aufgabe 5 (Knopf und Klartext) |
|
||||
| D-07 | COVERED | Aufgabe 1 (nur `undici`), Paket-Pruefliste nicht anwendbar |
|
||||
| D-08 | COVERED | Aufgabe 1 (Migration, Doku), Aufgabe 4 (Erlaubnisliste, Standwechsel), Aufgabe 7 (Nachmessung), T-DHH-04 |
|
||||
| D-09 | COVERED | Aufgabe 1 (`@UseModule`, `ModuleAccessGate`), T-DHH-05 |
|
||||
| D-10 | COVERED | Aufgaben 1, 5, 6 (next-intl, Sie-Form), 7 (Anwenderhandbuch) |
|
||||
| D-11 | COVERED (als Ausschluss) | `<objective>`, Abschnitt „Ausdruecklich NICHT im Umfang" |
|
||||
|
||||
**Keine Luecke.** Nicht abgedeckt sind ausschliesslich die vom Auftrag ausgeschlossenen Punkte
|
||||
(Dashboard-Kachel, `/rrddata`, Eingriffe, PMG-Quarantaene).
|
||||
</source_audit>
|
||||
|
||||
<verification>
|
||||
Nach jeder Aufgabe (je Commit):
|
||||
|
||||
- `pnpm --filter @tessera/api test` — gruen, mindestens 1240 Tests
|
||||
- `pnpm --filter @tessera/web test` — gruen, mindestens 693 Tests
|
||||
- `pnpm type-check` — 4 von 4 erfolgreich
|
||||
- `pnpm lint` — 5 von 5 erfolgreich
|
||||
- `pnpm --filter @tessera/web exec biome lint .` — exakt 53 Warnungen
|
||||
|
||||
Zusaetzlich nach den Aufgaben 1 und 4:
|
||||
|
||||
- `pnpm --filter @tessera/api exec vitest run src/prisma/rls-coverage.spec.ts src/prisma/rls-access-inventory.spec.ts` — gruen
|
||||
|
||||
Ab Aufgabe 2 dauerhaft:
|
||||
|
||||
- `pnpm --filter @tessera/api exec vitest run src/proxmox/proxmox-nur-lesen.spec.ts` — gruen
|
||||
|
||||
**Was diese Tore NICHT beweisen:** die Feldnamen von PBS und PMG (Annahmen A2, A3, A5 der
|
||||
Recherche). Es gibt hier keinen echten PVE-/PBS-/PMG-Server; alle Tests laufen gegen erfundene
|
||||
Antworten in der dokumentierten Form. Der Nutzer prueft das Modul selbst auf `alpha` gegen
|
||||
seine echten Server. Genau dafuer sind die Feldnamen je Produkt als EINE benannte Konstante
|
||||
gebaut und bleibt die gekuerzte Rohantwort im Zwischenlager erhalten: weicht die Wirklichkeit
|
||||
ab, ist eine einzige Stelle nachzuziehen und der Nutzer sieht in der Oberflaeche „unbekannt"
|
||||
statt eines Absturzes.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
1. Kein Weg im gesamten Modul veraendert etwas bei Proxmox; der maschinelle Riegel
|
||||
`proxmox-nur-lesen.spec.ts` weist nach, dass die einzige nicht-lesende Anfrage die
|
||||
Ticket-Anmeldung ist.
|
||||
2. Ein Administrator legt in den Moduleinstellungen Server aller drei Typen an; bei PMG wird
|
||||
die Token-Auswahl gar nicht erst angeboten und serverseitig abgelehnt.
|
||||
3. Zugangsdaten stehen verschluesselt in der Datenbank und verlassen sie auf keinem Weg im
|
||||
Klartext — auch nicht in Fehlermeldungen, Protokollen oder der Rohprobe.
|
||||
4. Die Zertifikats-Ausnahme gilt nur fuer die Server, bei denen sie einzeln eingeschaltet
|
||||
wurde; Voreinstellung ist pruefen.
|
||||
5. Der Hintergrunddienst haengt an `onApplicationBootstrap`, faechert je Mandant auf und
|
||||
ueberschreibt den Auftrag eines zweiten Mandanten nicht.
|
||||
6. Die Modulseite liest ausschliesslich aus dem Zwischenlager, zeigt fehlende Werte als
|
||||
„unbekannt" und ohne Server einen ruhigen Hinweis.
|
||||
7. Der Knopf „Verbindung testen" nennt die Ursache in Alltagssprache.
|
||||
8. Beide neuen Tabellen tragen `tenantId` mit RLS-Policy; `rls-coverage.spec.ts` und
|
||||
`rls-access-inventory.spec.ts` sind gruen, die Klassifikationsdoku ist nachgemessen.
|
||||
9. Alle Tore mindestens auf Ausgangswert: api-Tests ab 1240, web-Tests ab 693, type-check 4/4,
|
||||
lint 5/5, Biome-Warnungen in `apps/web` exakt 53.
|
||||
10. Anwenderhandbuch und Entwicklungsanleitung beschreiben das Modul, einschliesslich der
|
||||
NUR-LESE-Rolle je Produkt.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Nach Abschluss `.planning/quick/260923-dhh-proxmox-modul-pve-pbs-und-pmg-anbinden-n/260923-dhh-SUMMARY.md`
|
||||
schreiben — mit den gemessenen Endzahlen aller Tore neben den Ausgangswerten und einer
|
||||
ausdruecklichen Liste der Stellen, die der Nutzer beim Test an seinen echten Servern
|
||||
moeglicherweise nachziehen muss (Cookie-Namen je Produkt, Feldnamen je Produkt).
|
||||
</output>
|
||||
+358
@@ -0,0 +1,358 @@
|
||||
# Quick-Aufgabe 260923-dhh: Proxmox-Modul (PVE/PBS/PMG) — Research
|
||||
|
||||
**Researched:** 2026-09-23
|
||||
**Domain:** Proxmox VE/PBS/PMG REST-API (nur lesend), NestJS-Hintergrunddienst mit Zertifikatsausnahme, Mandantentrennung (Prisma/RLS), Modul-/Kachel-Registrierung im Bestand
|
||||
**Confidence:** MEDIUM — Proxmox-API-Formen (Auth-Header, `cluster/resources`, PBS-Datastore, PMG-Statistik) sind aus offizieller Doku UND Foren-Diskussion zusammengetragen (offizielle API-Viewer sind reine JS-Apps und liefern beim Abruf keinen Text); Bestandsmuster (Verschlüsselung, Scheduler, RLS, Modul-Registrierung) sind HIGH, weil aus tatsächlich gelesenem Code dieses Repos zitiert.
|
||||
|
||||
## Summary
|
||||
|
||||
Das Proxmox-Modul ist reine Beobachtung (kein Schreibzugriff) auf bis zu drei Produkttypen — PVE, PBS, PMG —, die derselbe Mandant in beliebiger Zahl in den Einstellungen einträgt (Adresse + Zugang, wahlweise API-Token oder Benutzer/Passwort). Alle drei Produkte teilen dieselbe API-Familie (REST, `/api2/json/...`), aber mit produktspezifischem Token-Präfix (`PVEAPIToken`/`PBSAPIToken`) — PMG hat laut aktueller Foren- und Roadmap-Lage **keine** API-Token-Unterstützung, nur Ticket-Login, weshalb der Zugang für PMG-Server ausschließlich Benutzer/Passwort sein kann (Konsequenz für die Einstellungs-UI: das Token-Feld ist bei Typ „PMG" auszublenden). Für reine Leseabfragen ist ein CSRF-Token nie nötig — weder bei Token- noch bei Ticket-Auth —, weil CSRF nur GET-fremde Schreiboperationen betrifft; das vereinfacht die Ticket-Variante erheblich (Cookie genügt).
|
||||
|
||||
Der Bestand liefert für jeden Baustein bereits ein direktes Vorbild: `CalendarSource` ist die richtige Schema-Vorlage (mehrere verschlüsselte Fremdsystem-Zugänge pro Mandant, nicht ein Singleton wie `DkvModuleConfig`); `CryptoService`/`LdapConfig.tlsRejectUnauthorized` zeigen sowohl die Verschlüsselung als auch den **admin-gesteuerten, pro Zeile umschaltbaren** Zertifikats-Bypass — das ist die bessere Vorlage als die pauschale, immer-an-Ausnahme in `icon-discovery.service.ts`, weil hier echte Zugangsdaten über die Leitung gehen, nicht nur ein Favicon; und `TenderSchedulerService` (kombiniert mit `DkvSchedulerService`) zeigt exakt das Timing-Problem, das ein neuer Hintergrunddienst vermeiden muss: `onModuleInit`-Reihenfolge ist zwischen NestJS-Modulen nicht garantiert, `onApplicationBootstrap` läuft dagegen nachweislich nach jedem `onModuleInit` und ist deshalb für einen Proxmox-Planer, der die Modul-Seed-Daten voraussetzt, die richtige Lebenszyklus-Stufe — nicht die von `DkvSchedulerService` tatsächlich verwendete `onModuleInit`.
|
||||
|
||||
Für die Frage „live abfragen oder zwischenlagern" gibt der Bestand eine eindeutige Antwort: sowohl DKV (`DkvInvoiceHistory`) als auch Tender-Radar (`Tender`) schreiben Hintergrund-Polling-Ergebnisse in eine eigene Tabelle und die Seite liest ausschließlich daraus — kein Modul in diesem Projekt holt Fremddaten live bei Seitenaufruf. Für Proxmox ist das erst recht richtig: ein Dashboard-Widget, das bei jedem Öffnen drei bis N Server live abfragt, wäre spürbar langsam und bei nicht erreichbarem Server sogar blockierend. Empfehlung: ein Cron-Auftrag pro Mandant (DKV-Muster) mit `onApplicationBootstrap`-Timing (Tender-Muster) schreibt die zuletzt gemessenen Werte (Knoten/VM/Container-Zustand, PBS-Datastore-Belegung + letzter Backup-/Verify-Lauf, PMG-Tageszahlen) in eine Zwischenlagertabelle je Server; das Dashboard und die Modulseite lesen ausschließlich diese Tabelle.
|
||||
|
||||
**Primary recommendation:** `ProxmoxServer`-Modell nach `CalendarSource`-Vorbild (mehrere Zeilen je Mandant, `encryptedTokenSecret`/`encryptedPassword` über `CryptoService`, `tlsRejectUnauthorized Boolean @default(true)` pro Zeile); ein `ProxmoxSchedulerService` nach `DkvSchedulerService`-Vorbild (ein Cron-Auftrag je Mandant) aber mit `OnApplicationBootstrap` statt `OnModuleInit`; ein `ProxmoxSnapshot`/`ProxmoxServerStatus`-Cache-Modell, das der Planer beschreibt und Widget/Modulseite lesen; für Zertifikatsausnahmen ein pro Aufruf gebauter `undici.Agent({ connect: { rejectUnauthorized: false } })`, **nur** wenn `tlsRejectUnauthorized === false` auf genau diesem Server steht — kein modulweiter, kein globaler Bypass.
|
||||
|
||||
## Architectural Responsibility Map
|
||||
|
||||
| Capability | Primary Tier | Secondary Tier | Rationale |
|
||||
|------------|-------------|----------------|-----------|
|
||||
| Proxmox-Server-Verwaltung (CRUD Adresse+Zugang) | API / Backend | Frontend Server (Formulare) | Verschlüsselung und RLS-Bindung müssen serverseitig passieren, wie bei `LdapConfig`/`CalendarSource` |
|
||||
| Periodische Abfrage PVE/PBS/PMG | API / Backend (Hintergrunddienst) | — | Kein Nutzer-Trigger; Cron-Auftrag wie DKV/Tender, kein Browser-Bezug |
|
||||
| Zwischenlagerung der Messwerte | Database / Storage | API / Backend (Schreiber) | Dashboard-Geschwindigkeit verlangt Cache-Tabelle statt Live-Fetch (siehe Summary) |
|
||||
| Dashboard-Kachel „Proxmox" | Browser (Rendering) | API / Backend (liefert Cache-Daten) | Folgt dem in `docs/anleitung-entwicklung.md` beschriebenen Drei-Stellen-Muster |
|
||||
| Modulseite (Server-Übersicht, Details) | Frontend Server (SSR-Gate) | API / Backend | `ModuleAccessGate` + eigenes `layout.tsx`, wie bei den vier bestehenden fest verdrahteten Modulverzeichnissen |
|
||||
| Zugriffskontrolle auf Proxmox-Endpunkte | API / Backend | — | `@UseModule('proxmox')` auf dem Controller, unabhängig vom Frontend-Gate |
|
||||
| TLS-Ausnahme für selbstsigniertes Zertifikat | API / Backend (pro Aufruf) | — | Muss am Ort des Fetch-Aufrufs entschieden werden, nicht global (Prozessumgebung bleibt streng) |
|
||||
|
||||
## 1. Proxmox-API konkret
|
||||
|
||||
### Anmeldung — API-Token
|
||||
|
||||
Alle drei Produkte senden den Token im `Authorization`-Header, aber mit unterschiedlichem Schema-Namen und leicht unterschiedlicher Werteform:
|
||||
|
||||
| Produkt | Header-Form | Quelle |
|
||||
|---|---|---|
|
||||
| PVE | `Authorization: PVEAPIToken=USER@REALM!TOKENID=SECRET` (ein `=` vor dem Secret) | `[CITED: pve.proxmox.com/pve-docs/pveum-plain.html]` |
|
||||
| PBS | `Authorization: PBSAPIToken=USER@REALM!TOKENID:SECRET` (ein `:` vor dem Secret — **anderes Trennzeichen als PVE**) | `[CITED: pbs.proxmox.com/docs/user-management.html]` |
|
||||
| PMG | **kein Token-Schema.** Foren-Aussage (proxmox.com-Forum, 2024/2025): „PMG doesn't have API tokens, only Tickets." Kein Gegenbeleg in der aktuellen `pmg-admin-guide` gefunden. | `[CITED: forum.proxmox.com/threads/why-are-there-no-api-tokens.156802]` — Forenaussage, nicht offizielle Referenzdoku; als `[ASSUMED]` in die Planung übernehmen und vor dem Bau am echten PMG-Server verifizieren (`checkpoint:human-verify`) |
|
||||
|
||||
**Konsequenz für die Einstellungs-UI:** Server-Typ „PMG" darf die Auswahl „API-Token" nicht anbieten (oder muss sie beim Speichern ablehnen) — sonst legt der Admin einen Zugang an, der nie funktioniert.
|
||||
|
||||
### Anmeldung — Ticket (Benutzer/Passwort)
|
||||
|
||||
Identischer Mechanismus für alle drei Produkte (PMG: „funktioniert exakt wie bei PVE, PVE durch PMG ersetzen", Foren-Zitat):
|
||||
|
||||
```
|
||||
POST /api2/json/access/ticket
|
||||
Body: username=<user>@<realm>&password=<pw>
|
||||
```
|
||||
|
||||
Antwort (JSON, `data`-Objekt): `ticket` (signierter Wert, Form `PVE:user@realm:...`), `CSRFPreventionToken`, `username`. `[CITED: pve.proxmox.com/wiki/Proxmox_VE_API]`
|
||||
|
||||
Folgeanfragen senden das Ticket als Cookie: `Cookie: PVEAuthCookie=<ticket>` (bei PBS/PMG vermutlich `PBSAuthCookie`/`PMGAuthCookie` — **nicht in der Doku bestätigt gefunden, `[ASSUMED]`**, vor Bau verifizieren). Ticket-Lebensdauer 2 Stunden bei PVE `[CITED: pve.proxmox.com/wiki/Proxmox_VE_API]`; ein Forumsbeitrag nennt abweichend 40 Sekunden für den kurzlebigen VNC-Ticket-Typ — **nicht derselbe Tickettyp**, für den hier verwendeten Auth-Ticket gilt die 2-Stunden-Angabe aus der offiziellen Wiki-Seite.
|
||||
|
||||
**CSRF — die zentrale Vereinfachung für dieses Modul:** `CSRFPreventionToken` ist laut offizieller Doku **nur für schreibende Anfragen (POST/PUT/DELETE)** nötig; „GET requests do not require this token" `[CITED: pve.proxmox.com/wiki/Proxmox_VE_API]`. Da dieses Modul ausschließlich liest (Auftrag: „NUR BEOBACHTEN"), entfällt die CSRF-Handhabung vollständig — auch bei Ticket-Auth genügt das Cookie. Bei Token-Auth ist CSRF ohnehin nie nötig, für keine Methode `[CITED: gleiche Quelle]`.
|
||||
|
||||
### PVE: Knoten/VMs/Container in einer Abfrage
|
||||
|
||||
`GET /api2/json/cluster/resources` liefert **alle** Objekttypen (`vm`, `node`, `storage`, weitere) in einer einzigen Anfrage, optional gefiltert per `?type=vm`. Für VM/Container-Zeilen kommen laut mehreren Forenbelegen die Felder `cpu`, `maxcpu`, `mem`, `maxmem`, `disk`, `maxdisk`, `netin`, `netout`, `diskread`, `diskwrite`, `node`, `vmid`, `status`, `uptime`, `type` zurück; für Storage-Zeilen `content`, `disk`, `maxdisk`, `node`, `plugintype`, `shared`, `status`, `storage`, `type`. `[CITED: mehrere forum.proxmox.com-Threads, keine Feldliste in der offiziellen API-Referenz gefunden — API-Viewer ist eine reine Vue-App und liefert per Abruf keinen Text]`
|
||||
|
||||
Gegenüber `/nodes/{node}/qemu` + `/nodes/{node}/lxc` (je Knoten zwei Aufrufe) ist `cluster/resources` der klare Gewinner für ein Übersichts-Dashboard: **eine** Anfrage liefert Knoten, VMs, Container und Storage über den gesamten (Multi-Node-)Cluster hinweg. Für Detailansichten einer einzelnen VM (z. B. Konfiguration) bleibt der gezielte `/nodes/{node}/qemu/{vmid}/...`-Pfad nötig — `cluster/resources` liefert nur die Übersichtsfelder, keine volle Konfiguration.
|
||||
|
||||
### PBS: Datastores, Backups, Verify
|
||||
|
||||
Aus Forenbelegen (keine vollständige Feldliste aus offizieller Referenz erreichbar):
|
||||
- `GET /api2/json/status/datastore-usage` — Belegung aller Datastores in einer Abfrage (Gesamt/Belegt/Frei). `[CITED: forum.proxmox.com/threads/inquiry-about-the-proxmox-backup-api.166986]`
|
||||
- `GET /api2/json/admin/datastore/{store}/status` — Status eines einzelnen Datastores.
|
||||
- `GET /api2/json/admin/datastore/{store}/snapshots` — Liste der Sicherungen; enthält laut Community-Doku ein `verification`/`verify-state`-Feld je Snapshot (Ergebnis der letzten Prüfung) sowie `backup-time`, `size`. **Exakte Feldnamen nicht aus Primärquelle bestätigt — `[ASSUMED]`, vor Bau gegen einen echten PBS-Server oder den API-Viewer im Browser verifizieren.**
|
||||
|
||||
### PMG: Tageszahlen
|
||||
|
||||
`GET /api2/json/statistics/mail` (optional `starttime`/`endtime`) liefert laut `pmgsh`-Community-Beleg `count`, `count_in`, `count_out`, `spamcount_in`, `spamcount_out`, `viruscount_in`, `viruscount_out`. `[CITED: forum.proxmox.com, Centreon-Plugin-Doku]` Ein Quarantäne-Zähler steht vermutlich unter einem separaten `/quarantine/...`-Pfad — nicht recherchiert, für die erste Fassung ggf. entbehrlich (siehe Fallstricke).
|
||||
|
||||
### Nur-Lese-Rollen
|
||||
|
||||
| Produkt | Rolle | Beleg |
|
||||
|---|---|---|
|
||||
| PVE | `PVEAuditor` — „read only access" | `[CITED: pve.proxmox.com/pve-docs/pveum-plain.html]` |
|
||||
| PBS | `Audit` (global) bzw. feiner `DatastoreAudit` — „Can view datastore metrics, settings and list content. But is not allowed to read the actual data." | `[CITED: pbs.proxmox.com/docs/user-management.html]` |
|
||||
| PMG | `Auditor` — „read-only access to the whole configuration, can access logs and view statistics" | `[CITED: mehrere Foren-/Datasheet-Quellen, keine Primärquelle mit exaktem Wortlaut erreicht]` |
|
||||
|
||||
Empfehlung an den Admin-Helptext in den Einstellungen: für den API-Token/Benutzer, den Tessera nutzt, jeweils NUR diese Rolle zuweisen — ein Schreibrecht wird von diesem Modul nie gebraucht (deckt sich mit „NUR BEOBACHTEN").
|
||||
|
||||
### Fehlerverhalten
|
||||
|
||||
- **Falscher Zugang (Token/Passwort falsch):** HTTP 401. PVE-Foren-Belege zeigen 401 auch für andere Auth-Fehlklassen (abgelaufenes Ticket, falsches CSRF-Token) — Proxmox scheint 401 breiter zu verwenden als die übliche REST-Konvention 401=nicht authentifiziert/403=nicht berechtigt. **Nicht aus Primärquelle mit expliziter Statuscode-Tabelle bestätigt — `[ASSUMED]`.** Für die Fehlermeldung im UI heißt das: einen expliziten 403-Sonderfall separat von 401 zu behandeln lohnt sich vermutlich nicht; „Zugang abgelehnt (401)" als eine gemeinsame Meldung ist robuster als eine Unterscheidung, die die API evtl. gar nicht liefert.
|
||||
- **Abgelaufenes Ticket:** 401, Meldung enthält meist „invalid ticket"/„permission denied" im Klartext-Body — für eine bessere Fehlermeldung lohnt sich das Parsen des `errors`-Feldes der JSON-Antwort.
|
||||
- **Server nicht erreichbar (falsche Adresse, Netzwerk, Port zu):** **kein HTTP-Status** — der Fetch-Aufruf selbst schlägt fehl (`ECONNREFUSED`, `ETIMEDOUT`, `ENOTFOUND`/DNS-Fehler; bei `undici`/nativem `fetch` als geworfener `TypeError`/`FetchError`, nicht als Response mit Statuscode). Die Proxmox-Serviceklasse muss also zwei getrennte Fehlerpfade behandeln: HTTP-Antwort mit Statuscode ≠ 2xx (Zugang/Berechtigung) versus geworfene Exception ohne Response (Erreichbarkeit) — dieselbe Unterscheidung, die `icon-discovery.service.ts` mit seinem AbortController-Timeout + try/catch bereits trifft (`fetchWithRedirectGuard`, Zeilen 227–271: `catch { return null; }` fängt genau diesen Fall).
|
||||
|
||||
## 2. Selbstsignierte Zertifikate
|
||||
|
||||
**Vorlage 1 (Mechanik):** `apps/api/src/favorites/icon-discovery.service.ts:33–37` — Node 24s **globales** `fetch` ignoriert einen `Agent`/Dispatcher aus dem `undici`-Paket (andere Klasse als das intern gebündelte undici); nur `undiciFetch(url, { dispatcher })` (expliziter Import aus dem `undici`-Modul) respektiert einen eigenen Dispatcher. Gemessen und im Kommentar dokumentiert:
|
||||
> „`undiciFetch(url, { dispatcher: new Agent(...) })` -> Status 200; `globalThis.fetch` derselben URL -> DEPTH_ZERO_SELF_SIGNED_CERT." `[VERIFIED: apps/api/src/favorites/icon-discovery.service.ts:33-37]`
|
||||
|
||||
`undici` ist bereits direkte Abhängigkeit von `apps/api` — `"undici": "7.28.0"` `[VERIFIED: apps/api/package.json:52]` — **kein neues Paket nötig**.
|
||||
|
||||
**Vorlage 2 (Steuerung — besser geeignet als icon-discovery's Immer-an-Ausnahme):** `LdapConfig.tlsRejectUnauthorized Boolean @default(true)` `[VERIFIED: apps/api/prisma/schema.prisma:65-83, Feld "tlsRejectUnauthorized Boolean @default(true)" in Zeile 76]` — ein **pro Zeile umschaltbares** Feld, vom Admin beim Anlegen/Bearbeiten des Zugangs gesetzt, Default „prüfen" (sicherer Default). `ldap.service.ts` baut daraus die Client-Optionen:
|
||||
> „skip TLS verification" flag (`tlsRejectUnauthorized === false`)" `[VERIFIED: apps/api/src/ldap/ldap.service.ts:168]`
|
||||
|
||||
**Für Proxmox kombinieren:** `ProxmoxServer` bekommt dasselbe Feld `tlsRejectUnauthorized Boolean @default(true)`. Der Fetch-Aufruf für genau diesen Server baut **conditional** einen `undici.Agent({ connect: { rejectUnauthorized: false } })` nur wenn diese eine Zeile das Feld auf `false` gesetzt hat — nicht wie in `icon-discovery.service.ts` eine für die ganze Datei geltende Modul-Konstante `LENIENT_TLS_AGENT`, sondern je Aufruf aus dem gelesenen Serverdatensatz konstruiert. Das erfüllt exakt die Vorgabe „ausdrücklich nur für die vom Administrator eingetragenen Adressen, nicht global": kein prozessweiter Bypass, keine `NODE_TLS_REJECT_UNAUTHORIZED`-Umgebungsvariable (dieses Muster ist im Kommentar von `icon-discovery.service.ts` bereits ausdrücklich als verboten markiert, Zeile 30: „insbesondere NICHT ueber die Node-Umgebungsvariable, die mit NODE_TLS_ beginnt" `[VERIFIED: apps/api/src/favorites/icon-discovery.service.ts:30]`).
|
||||
|
||||
Standardmäßig Proxmox-Zertifikate akzeptieren zu **verweigern** (Default `true`) ist hier die richtige Entscheidung, anders als bei `icon-discovery.service.ts` (dort werden nur Favicons geholt, keine Zugangsdaten übertragen) — bei Proxmox gehen Token/Passwort über dieselbe Verbindung, ein blindes „immer tolerant" würde einen Site-in-the-Middle-Angriff auf die Zugangsdaten erleichtern.
|
||||
|
||||
## 3. Anschlussstellen im Bestand
|
||||
|
||||
### Verschlüsselte Zugangsdaten
|
||||
|
||||
`CryptoService` (`apps/api/src/crypto/crypto.service.ts`) ist die einzige Verschlüsselungsschicht im Projekt — AES-256-GCM, Schlüssel aus `TESSERA_ENCRYPTION_KEY`, Format `iv:authTag:ciphertext` (hex, `:`-getrennt) `[VERIFIED: apps/api/src/crypto/crypto.service.ts:70-84]`. `LdapConfigService` zeigt das vollständige Muster: verschlüsseln beim Schreiben (`this.crypto.encrypt(dto.bindPassword)`), entschlüsseln zentral in EINER privaten Methode (`decryptBindPassword`), API-Antworten maskieren das Feld ('********') im Controller, nicht im Service `[VERIFIED: apps/api/src/ldap/ldap-config.service.ts:117-133]`. Für Proxmox: `encryptedTokenSecret`/`encryptedPassword` genauso behandeln — zwei Felder, weil Token-Secret und Passwort unterschiedliche Auth-Methoden sind, beide nullable (nur eines pro Zeile gesetzt, je nach gewähltem `authMethod`).
|
||||
|
||||
**Migrationsbedarf beachten:** eine Spalte, die vor Verschlüsselung bereits Klartext trug, braucht einen einmaligen Nachzieh-Backfill wie in `ldap-config.service.ts` (`onApplicationBootstrap`, Regex `ENCRYPTED_VALUE_SHAPE` unterscheidet verschlüsselt/Klartext) `[VERIFIED: apps/api/src/ldap/ldap-config.service.ts:39, 66-101]` — für Proxmox als **neues** Feature ab Tag 1 irrelevant (keine Altdaten), nur als Muster relevant, falls später ein Feld umbenannt/neu verschlüsselt wird.
|
||||
|
||||
### Hintergrundabfrage je Mandant
|
||||
|
||||
**Zwei bestehende Muster, keins davon 1:1 übertragbar — kombinieren:**
|
||||
|
||||
`DkvSchedulerService` zeigt das **Mandanten-Fan-out**: EIN Cron-Auftrag *je aktivem Mandant*, Registry-Name `dkv-inbox-poll:<tenantId>`, damit ein zweiter Mandant den ersten nicht verdrängt (behobener Fehler WINDOWS #21) `[VERIFIED: apps/api/src/dkv/dkv-scheduler.service.ts:16-46]`. Proxmox-Server sind aber (anders als DKV) potenziell **mehrere pro Mandant** — der Cron-Tick eines Mandanten muss also intern über dessen `ProxmoxServer`-Zeilen iterieren, nicht 1:1 wie bei DKV (1 Config = 1 Mandant).
|
||||
|
||||
`DkvSchedulerService` hängt aber an `OnModuleInit`, nicht `OnApplicationBootstrap` `[VERIFIED: apps/api/src/dkv/dkv-scheduler.service.ts:1, "implements OnModuleInit"]` — **das ist NICHT das empfohlene Muster für einen neuen Dienst**. `TenderSchedulerService` erklärt im Kopfkommentar explizit, warum `OnApplicationBootstrap` die richtige Wahl ist:
|
||||
> „`onModuleInit` hooks run in an unspecified order relative to one another, so on a FRESH database the scheduler could read the config before it is seeded → see it absent/inactive → never register the ... cron ... → the platform ingests NOTHING until a second restart. `onApplicationBootstrap` runs after EVERY module's `onModuleInit`, so the seed is guaranteed complete before this reads." `[VERIFIED: apps/api/src/tenders/tender-scheduler.service.ts:29-38]`
|
||||
|
||||
Dasselbe Risiko gilt für Proxmox: die `Module`-Seed-Zeile (Modulregistrierung) entsteht in `onModuleInit` des Proxmox-Moduls selbst; ein Scheduler, der beim Start die aktiven `ProxmoxServer`-Zeilen lädt, sollte dieses Risiko nicht eingehen, auch wenn hier keine Modul-Seed-Abhängigkeit vorliegt wie bei Tender — sicherer Standard ist trotzdem `OnApplicationBootstrap`, nicht das (mit einer dokumentierten, hier nicht zutreffenden Ausnahme begründete) `OnModuleInit` von DKV. Auch das nutzerseitige Erlebnis „frische Installation, erster Proxmox-Server angelegt, kein Neustart nötig" verlangt denselben `setInterval()`-Nachzieh-Aufruf wie bei DKV/Tender nach jedem Speichern in der Verwaltungsroute — nicht nur beim Boot.
|
||||
|
||||
`Tender-Cron Bootstrap`-Erfahrung aus dem Projektgedächtnis bestätigt das Risiko real: „frische Prod-DB ohne Fix ingestiert nichts" — genau das Szenario, das `OnApplicationBootstrap` verhindert.
|
||||
|
||||
### Modul-Registrierung
|
||||
|
||||
Vollständiges Muster in `docs/anleitung-entwicklung.md`, Abschnitt „So entsteht ein neues Modul", am Beispiel Domaincheck — sechs Backend-Dateien, sechs Frontend-Dateien, siehe Code-Beispiele unten. Zusätzlich als Dashboard-Kachel: `WIDGET_TYPES`/`WIDGET_MODULE_SLUGS` in `packages/shared/src/index.ts` (aktuell leer, `[VERIFIED: packages/shared/src/index.ts:97-121]`) — Proxmox wäre die **erste** Kachel, die `WIDGET_MODULE_SLUGS['proxmox'] = 'proxmox'` tatsächlich befüllt.
|
||||
|
||||
### Mandantentrennung
|
||||
|
||||
`ProxmoxServer` braucht eine eigene `tenantId`-Spalte (mehrere Server je Mandant, klar `muss-mandantengebunden`, analog `CalendarSource`) — RLS-Migration mit `ENABLE ROW LEVEL SECURITY` + `CREATE POLICY` ist **Pflicht**, sonst schlägt `rls-coverage.spec.ts` Test 1 fehl (jedes Modell mit `tenantId` muss RLS haben) `[VERIFIED: apps/api/src/prisma/rls-coverage.spec.ts:102-106]`. Jeder Service-Zugriff muss über `forTenant(this.prisma, tenantId)` laufen (Konvention: lokale Konstante `const tenantPrisma = forTenant(...)`, keine andere Form), sonst schlägt `rls-access-inventory.spec.ts` fehl — UND jede (Datei, Modell)-Fundstelle muss in `docs/mandantentrennung-zugriffsklassifikation.md` als Tabellenzeile eingetragen werden, sonst schlägt derselbe Test ebenfalls fehl (`[VERIFIED: apps/api/src/prisma/rls-access-inventory.spec.ts:718-723]`, Test „jede im Quelltext gefundene (Datei, Modell)-Fundstelle ist im Dokument eingetragen"). Der Scheduler-Startpfad (liest ALLE Mandanten vor dem ersten `forTenant()`-Aufruf) braucht denselben `forSystem()`-Systemkontext wie `DkvSchedulerService`/`TenderSchedulerService` — und muss in `FORSYSTEM_ALLOWED_CALL_SITES` in `rls-access-inventory.spec.ts` eingetragen werden `[VERIFIED: apps/api/src/prisma/rls-access-inventory.spec.ts:169-175]`, sonst schlägt der Wachhund-Test „ein Anfrageweg darf den Systemkontext nie rufen" fehl.
|
||||
|
||||
**Diese drei Testdateien sind harte Gates, keine Empfehlung** — ein Plan, der `ProxmoxServer`/`ProxmoxSnapshot` einführt, MUSS die Migration, die Klassifikationstabelle UND die Erlaubnisliste in derselben Aufgabe pflegen, sonst ist `pnpm --filter @tessera/api test` rot.
|
||||
|
||||
### Zwischenlagerung vs. Live-Abfrage
|
||||
|
||||
Siehe Summary — DKV (`DkvInvoiceHistory` `[VERIFIED: apps/api/prisma/schema.prisma:384-397]`) und Tender (`Tender` `[VERIFIED: apps/api/prisma/schema.prisma:435-480]`) schreiben beide Hintergrund-Polling-Resultate in eine eigene Tabelle; keine Seite in diesem Projekt holt Fremddaten live beim Rendern. Für Proxmox: ein `ProxmoxServerStatus`-Modell (1:1 oder 1:n je `ProxmoxServer`, mit `lastPolledAt`, `lastError`, und je nach Servertyp unterschiedlichen JSONB-Feldern für die Messwerte — PVE-Knoten/VM-Liste, PBS-Datastore-Liste, PMG-Tageszahlen) wird vom Scheduler beschrieben, Widget und Modulseite lesen ausschließlich daraus. Ein „Jetzt aktualisieren"-Knopf auf der Modulseite kann optional einen sofortigen Einzel-Poll auslösen (Vorbild: `DkvController` ruft nach Config-Speicherung `schedulerService.setInterval()` — derselbe Sofort-Trigger-Gedanke), sollte aber NICHT das Dashboard-Widget selbst live abfragen lassen.
|
||||
|
||||
## 4. Fallstricke
|
||||
|
||||
**Antwortgröße bei vielen VMs:** `cluster/resources` liefert bei einem größeren Cluster (zweistellige VM-Zahl je Knoten) potenziell hunderte Zeilen in einer JSON-Antwort — für die Zwischenlagertabelle unproblematisch (einmal je Poll-Intervall), aber falls die Modulseite später live filtert/sortiert, sollte serverseitig nicht bei jedem Klick neu gegen Proxmox gefragt werden, sondern gegen den Cache.
|
||||
|
||||
**`/rrddata` für die erste Fassung: NEIN.** RRD-Zeitreihen (Verlaufsgraphen über Zeit) sind ein separates, aufwändigeres API-Segment (mehrere Zeitraster: hour/day/week/month/year, je Objekt ein eigener Aufruf) und für eine reine Beobachtungs-Übersicht („Zustand jetzt") nicht nötig — erst relevant, wenn später Verlaufsgraphen gewünscht werden.
|
||||
|
||||
**Zähler sind Bytes/Ereignisse seit Start, nicht Bytes/Sekunde:** `netin`/`netout`/`diskread`/`diskwrite` in `cluster/resources` sind als COUNTER-Datenquellen definiert — kumulative Werte seit VM-Start, keine Rate `[CITED: mehrere Foren-Quellen, RRD-Datenquellen-Liste]`. Ein UI, das „aktueller Netzwerkdurchsatz" anzeigen will, muss selbst zwei aufeinanderfolgende Messungen differenzieren (Δ Wert / Δ Zeit) — eine einzelne Momentaufnahme zeigt nur „seit wann läuft die VM, wie viel kam insgesamt rein", was für eine erste Fassung ohnehin ausreicht, aber in der UI klar beschriftet werden sollte („gesamt seit Start", nicht „aktuell").
|
||||
|
||||
**PMG-API-Token-Lücke ist ein echtes Bau-Risiko:** wenn der Admin für einen PMG-Server versehentlich „API-Token" wählt (falls die UI das nicht verhindert), scheitert jede Anfrage mit einer für den Nutzer unverständlichen Fehlermeldung. Muss in der Einstellungs-UI hart verhindert werden (Auswahl abhängig vom Servertyp), nicht nur dokumentiert.
|
||||
|
||||
**Node 24 + `undici`-Dispatcher — dieselbe Falle wie in `icon-discovery.service.ts` dokumentiert:** wer aus Gewohnheit `fetch(...)` (globales, natives Fetch) statt `import { fetch as undiciFetch } from 'undici'` verwendet, bekommt bei einem `Agent`-Dispatcher **keinen Fehler beim Kompilieren**, sondern eine zur Laufzeit ignorierte Option — das selbstsignierte Zertifikat eines Proxmox-Testservers wird dann trotz `tlsRejectUnauthorized: false` weiterhin abgelehnt, was beim ersten Test verwirrend aussieht, als sei die Datenbank-Einstellung falsch gelesen worden.
|
||||
|
||||
**CSRF-Falle vermieden, nicht vergessen:** weil dieses Modul nur liest, entfällt CSRF komplett (siehe Block 1) — ein künftiger Ausbau mit Schreibzugriffen (nicht Teil dieses Auftrags) müsste CSRF bei Ticket-Auth nachrüsten; das jetzt schon vorzusehen wäre verfrühte Komplexität.
|
||||
|
||||
**Ticket-Lebensdauer 2 h bei Cron-Intervallen < 2 h kein Problem, aber Neu-Login-Logik nicht vergessen:** bei Benutzer/Passwort-Zugang muss der Scheduler bei 401 einmal automatisch neu einloggen (neues Ticket holen) und den Poll wiederholen, bevor er den Server als „nicht erreichbar" markiert — sonst erzeugt ein normaler Ticket-Ablauf alle zwei Stunden einen falschen Fehlalarm.
|
||||
|
||||
## Standard Stack
|
||||
|
||||
Keine neuen npm-Pakete. Alles Nötige ist bereits installiert:
|
||||
|
||||
| Baustein | Bereits vorhanden | Verwendung für Proxmox |
|
||||
|---|---|---|
|
||||
| `undici` 7.28.0 | `[VERIFIED: apps/api/package.json:52]` | `undiciFetch` mit bedingtem Dispatcher, Vorbild `icon-discovery.service.ts` |
|
||||
| `@nestjs/schedule` (Cron) | bereits Basis von `DkvSchedulerService`/`TenderSchedulerService` | `ProxmoxSchedulerService` |
|
||||
| `class-validator`/`class-transformer` | bereits DTO-Standard im Projekt (`CheckDomainDto`, `CreateLdapConfigDto`, ...) | DTOs für Server-Anlegen/-Bearbeiten |
|
||||
| `CryptoService` (projekteigen) | `apps/api/src/crypto/crypto.service.ts` | Token-Secret/Passwort-Verschlüsselung |
|
||||
| Prisma 6.19.3 | bereits ORM-Standard | `ProxmoxServer`/`ProxmoxServerStatus`-Modelle |
|
||||
|
||||
## Package Legitimacy Audit
|
||||
|
||||
Nicht anwendbar — dieser Auftrag installiert keine externen Pakete (weder npm noch sonst). Die Recherche bestätigt ausdrücklich, dass `undici`/natives `fetch` für alle benötigten HTTP-Aufrufe genügen; keine Proxmox-Client-Bibliothek wird eingeführt, wie vom Auftrag verlangt.
|
||||
|
||||
## Don't Hand-Roll
|
||||
|
||||
| Problem | Nicht selbst bauen | Stattdessen | Warum |
|
||||
|---|---|---|---|
|
||||
| Verschlüsselung von Token-Secret/Passwort | eigenes Crypto-Schema | `CryptoService` (bestehend) | Einzige Verschlüsselungsschicht im Projekt, bereits geprüft (T-05-10), Schlüsselverwaltung über `TESSERA_ENCRYPTION_KEY` schon gelöst |
|
||||
| Selbstsigniertes Zertifikat tolerieren | eigener HTTPS-Agent/eigene TLS-Logik | `undici.Agent({ connect: { rejectUnauthorized } })`, bedingt pro Server | Bereits einmal im Projekt gemessen (icon-discovery), inkl. der Node-24-Falle |
|
||||
| Cron-Auftrag je Mandant | eigener Intervall-Mechanismus (`setInterval` global) | `SchedulerRegistry.addCronJob()` (DKV/Tender-Muster) | Bereits zweimal im Projekt gelöst, inkl. der Verdrängungs-Falle (WINDOWS #21) |
|
||||
|
||||
## Code Examples
|
||||
|
||||
### API-Token-Aufruf mit bedingtem TLS-Bypass (PVE)
|
||||
|
||||
```ts
|
||||
// Muster: apps/api/src/favorites/icon-discovery.service.ts (Dispatcher-Mechanik)
|
||||
// + apps/api/src/ldap/ldap.service.ts:168 (bedingtes tlsRejectUnauthorized)
|
||||
import { Agent, fetch as undiciFetch } from 'undici';
|
||||
|
||||
async function fetchPveResources(server: {
|
||||
baseUrl: string; // z.B. https://pve.example.internal:8006
|
||||
tokenId: string; // user@realm!tokenname
|
||||
tokenSecret: string; // entschluesselt, nur im Speicher
|
||||
tlsRejectUnauthorized: boolean;
|
||||
}) {
|
||||
const dispatcher = server.tlsRejectUnauthorized
|
||||
? undefined // Standardpfad: echte Zertifikatspruefung, kein Sonderfall
|
||||
: new Agent({ connect: { rejectUnauthorized: false } }); // NUR fuer diesen einen Server
|
||||
|
||||
const response = await undiciFetch(
|
||||
`${server.baseUrl}/api2/json/cluster/resources`,
|
||||
{
|
||||
dispatcher,
|
||||
headers: {
|
||||
Authorization: `PVEAPIToken=${server.tokenId}=${server.tokenSecret}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`PVE-Antwort ${response.status}`); // 401 = Zugang/Ticket ungueltig
|
||||
}
|
||||
|
||||
return response.json(); // { data: [...] } — type vm|node|storage gemischt
|
||||
}
|
||||
```
|
||||
|
||||
### Modul-Registrierung (Vorlage Domaincheck)
|
||||
|
||||
```ts
|
||||
// apps/api/src/domaincheck/domaincheck.seed.ts — VERIFIED, so gelesen
|
||||
export async function seedDomaincheckModule(
|
||||
moduleRegistryService: ModuleRegistryService,
|
||||
): Promise<void> {
|
||||
await moduleRegistryService.seedModule({
|
||||
slug: 'domaincheck',
|
||||
name: 'Domaincheck',
|
||||
version: '1.0.0',
|
||||
category: 'domain-tools',
|
||||
description: { de: '...', en: '...' },
|
||||
isSystem: true,
|
||||
});
|
||||
}
|
||||
```
|
||||
Für Proxmox: `slug: 'proxmox'`, eigene `category` (z.B. `'infrastructure'`), Controller mit `@Controller('modules/proxmox')` + `@UseModule('proxmox')` auf Klassenebene — exaktes Muster in `apps/api/src/domaincheck/domaincheck.controller.ts:1-8` `[VERIFIED]`.
|
||||
|
||||
### Scheduler-Kombination (DKV-Mandanten-Fan-out + Tender-Bootstrap-Timing)
|
||||
|
||||
```ts
|
||||
// Kombiniert: apps/api/src/dkv/dkv-scheduler.service.ts (Mandanten-Fan-out)
|
||||
// + apps/api/src/tenders/tender-scheduler.service.ts (OnApplicationBootstrap)
|
||||
@Injectable()
|
||||
export class ProxmoxSchedulerService implements OnApplicationBootstrap {
|
||||
// NICHT OnModuleInit — siehe tender-scheduler.service.ts Kopfkommentar:
|
||||
// onModuleInit-Reihenfolge zwischen Modulen ist nicht garantiert.
|
||||
async onApplicationBootstrap(): Promise<void> {
|
||||
const systemPrisma = forSystem(this.prisma); // alle Mandanten sehen, vor Mandantenkontext
|
||||
const servers = await systemPrisma.proxmoxServer.findMany({ where: { isActive: true } });
|
||||
const byTenant = groupBy(servers, (s) => s.tenantId);
|
||||
for (const [tenantId, tenantServers] of byTenant) {
|
||||
this.setInterval(tenantId, tenantServers); // ein Cron-Auftrag je Mandant, wie DKV
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Assumptions Log
|
||||
|
||||
| # | Claim | Abschnitt | Risiko falls falsch |
|
||||
|---|---|---|---|
|
||||
| A1 | PMG unterstützt keine API-Token, nur Ticket-Login (Forenbeleg, keine Primärquelle mit explizitem Gegenteil-Zitat) | Block 1, Anmeldung — API-Token | Falls doch unterstützt: UI verbietet unnötig eine gültige Option. Falls nicht: ohne diese Prüfung entsteht ein PMG-Zugang, der nie funktioniert |
|
||||
| A2 | PBS/PMG-Ticket-Cookie heißt `PBSAuthCookie`/`PMGAuthCookie` (analog PVE) | Block 1, Anmeldung — Ticket | Falsche Cookie-Bezeichnung -> jede Ticket-Anfrage schlägt mit 401 fehl, obwohl Zugang korrekt ist |
|
||||
| A3 | Exakte Feldnamen der PBS-Snapshot-Liste (`verify-state`, `backup-time`, `size`) | Block 1, PBS | Falsche Feldnamen -> `undefined`-Werte in der UI statt eines klaren Fehlers, bis manuell gegen den API-Viewer geprüft |
|
||||
| A4 | Proxmox verwendet 401 breiter als übliche REST-Konvention (auch für Berechtigungsfehler, nicht nur Authentifizierung) | Block 1, Fehlerverhalten | Falls doch 403 vorkommt: UI zeigt „Zugang abgelehnt" statt einer treffenderen „Rolle reicht nicht"-Meldung — kosmetisch, kein Blocker |
|
||||
| A5 | PMG-Statistik-Endpunkt liefert keine eigene Quarantäne-Zahl unter `/statistics/mail` (separater Pfad vermutet, nicht recherchiert) | Block 1, PMG | Falls Quarantäne-Zahl doch im selben Aufruf steckt: unnötiger zweiter API-Aufruf in der ersten Fassung — kein Blocker, nur Ineffizienz |
|
||||
|
||||
**Empfehlung:** A1–A3 vor dem ersten Implementierungs-Task als `checkpoint:human-verify` gegen einen echten PVE-/PBS-/PMG-Testserver bestätigen (der Auftrag nennt keinen erreichbaren Testserver für diese Recherche-Session — siehe Environment Availability).
|
||||
|
||||
## Environment Availability
|
||||
|
||||
Kein für diese Recherche erreichbarer PVE-/PBS-/PMG-Server bekannt oder im Auftrag genannt — anders als beim Windows-Test-VM- oder ViCoTest-Zugang aus dem Projektgedächtnis gibt es dafür keinen dokumentierten Zugriffsweg. Die API-Formen in diesem Dokument sind ausschließlich aus Doku/Forenbelegen zusammengetragen (siehe Assumptions Log), nicht live verifiziert. Der Planer sollte den ersten Implementierungs-Task so schneiden, dass ein `checkpoint:human-verify` (Anlegen eines echten Testzugangs durch den Nutzer) vor der Feldnamen-kritischen PBS/PMG-Arbeit steht — für PVE ist die Beleglage deutlich fester (offizielle `pveum-plain.html`/Wiki-Seite bestätigen Header-Form und CSRF-Verhalten wörtlich).
|
||||
|
||||
| Abhängigkeit | Gebraucht für | Verfügbar (diese Recherche-Session) | Fallback |
|
||||
|---|---|---|---|
|
||||
| Erreichbarer PVE-Server | Verifikation `cluster/resources`-Feldnamen, Token-Header | ✗ | Foren-/Community-Beleg, `checkpoint:human-verify` vor Bau |
|
||||
| Erreichbarer PBS-Server | Verifikation Snapshot-/Verify-Feldnamen | ✗ | dito |
|
||||
| Erreichbarer PMG-Server | Verifikation Statistik-Feldnamen, Token-Unterstützung | ✗ | dito, höchste Priorität wegen A1 |
|
||||
|
||||
## Validation Architecture
|
||||
|
||||
### Test Framework
|
||||
| Property | Value |
|
||||
|---|---|
|
||||
| Framework | Vitest 3.2.6 (`apps/api`, `environment: 'node'`) `[VERIFIED: docs/anleitung-entwicklung.md, Abschnitt "Tests"]` |
|
||||
| Config file | `apps/api/vitest.config.ts` |
|
||||
| Quick run command | `pnpm --filter @tessera/api test` |
|
||||
| Full suite command | `pnpm test` (Root, über Turborepo beide Apps) |
|
||||
|
||||
### Phase Requirements -> Test Map
|
||||
| Behavior | Test Type | Automated Command |
|
||||
|---|---|---|
|
||||
| Verschlüsselung/Entschlüsselung Token-Secret/Passwort | unit | `CryptoService` bereits getestet; neuer Roundtrip-Test analog `crypto.service.spec.ts` |
|
||||
| RLS-Abdeckung `ProxmoxServer`/`ProxmoxServerStatus` | guard | `pnpm --filter @tessera/api exec vitest run src/prisma/rls-coverage.spec.ts` |
|
||||
| Zugriffsklassifikation vollständig dokumentiert | guard | `pnpm --filter @tessera/api exec vitest run src/prisma/rls-access-inventory.spec.ts` |
|
||||
| Scheduler: ein Auftrag je Mandant, kein Verdrängen | unit | analog `dkv-scheduler.service.spec.ts` |
|
||||
| TLS-Bypass nur bei `tlsRejectUnauthorized === false` dieser einen Zeile | unit | neuer Test, Vorbild fehlt (icon-discovery hat keinen bedingten Pfad) — selbst schreiben |
|
||||
| `@UseModule('proxmox')` blockiert ohne Freigabe | unit | analog `module.guard.spec.ts` |
|
||||
| Widget verschwindet ohne Modulzugriff | unit | analog `widget-wrapper.test.tsx`/`widget-module-map.spec.ts` |
|
||||
|
||||
### Sampling Rate
|
||||
- **Per Task Commit:** `pnpm --filter @tessera/api test`
|
||||
- **Per Wave Merge:** `pnpm test` (Root)
|
||||
- **Phase Gate:** volle Suite grün vor `/gsd-verify-work`
|
||||
|
||||
### Wave 0 Gaps
|
||||
- Kein PVE/PBS/PMG-Testserver erreichbar (siehe Environment Availability) — Feldnamen-kritische Tests bleiben bis zur manuellen Verifikation mit gemockten Antworten gebaut, nicht gegen einen echten Server.
|
||||
|
||||
## Security Domain
|
||||
|
||||
### Applicable ASVS Categories (Level 1)
|
||||
|
||||
| ASVS Category | Applies | Standard Control |
|
||||
|---|---|---|
|
||||
| V2 Authentication | ja (gegenüber Proxmox, nicht gegenüber Tessera-Nutzern) | Token/Passwort serverseitig gespeichert, nie an den Browser zurückgegeben (Maskierung wie `LdapConfigService`) |
|
||||
| V4 Access Control | ja | `@UseModule('proxmox')` + `ModuleAccessGate` (zweistufig, wie alle Module) |
|
||||
| V5 Input Validation | ja | `class-validator`-DTOs für Server-Adresse/Zugang (URL-Form, Enum für Typ/Auth-Methode) |
|
||||
| V6 Cryptography | ja | `CryptoService` (AES-256-GCM), niemals selbst hand-rollen |
|
||||
| V9 Communications | ja | TLS-Bypass ist die zentrale Bedrohung dieses Moduls — siehe unten |
|
||||
|
||||
### Known Threat Patterns
|
||||
|
||||
| Pattern | STRIDE | Standard Mitigation |
|
||||
|---|---|---|
|
||||
| TLS-Bypass leakt Zugangsdaten an MITM | Information Disclosure | Bypass nur pro Server-Zeile, Default „prüfen", niemals global/Umgebungsvariable (siehe Block 2) |
|
||||
| Gespeichertes Token/Passwort im Klartext lesbar bei DB-Dump | Information Disclosure | `CryptoService`-Verschlüsselung, Schlüssel getrennt vom DB-Backup aufbewahrt (bestehende Vorgabe, `docs/anleitung-entwicklung.md`) |
|
||||
| Fremdmandant liest Proxmox-Zugang eines anderen Mandanten | Elevation of Privilege | RLS auf `ProxmoxServer`/`ProxmoxServerStatus`, `forTenant()`-Bindung, Pflicht-Testabdeckung (siehe Anschlussstellen) |
|
||||
| Server-Antwort mit riesigem Payload (viele hundert VMs) legt den API-Prozess lahm | Denial of Service | Nur der Scheduler ruft Proxmox live auf (begrenzte Frequenz), die Modulseite liest immer aus dem Cache — kein ungebremster Nutzer-Trigger auf die Fremd-API |
|
||||
|
||||
## Sources
|
||||
|
||||
### Primary (HIGH confidence — aus tatsächlich gelesenem Projekt-Code)
|
||||
- `apps/api/src/favorites/icon-discovery.service.ts` — undici-Dispatcher-Mechanik, TLS-Bypass-Kommentar
|
||||
- `apps/api/src/ldap/ldap-config.service.ts`, `apps/api/src/ldap/crypto.service.ts` — Verschlüsselung, Systemkontext-Backfill
|
||||
- `apps/api/src/dkv/dkv-scheduler.service.ts`, `apps/api/src/tenders/tender-scheduler.service.ts` — Scheduler-Muster
|
||||
- `apps/api/prisma/schema.prisma` — `CalendarSource`, `LdapConfig`, `Module`/`TenantModuleActivation`, `Tender`, `DkvInvoiceHistory`
|
||||
- `apps/api/src/prisma/rls-coverage.spec.ts`, `apps/api/src/prisma/rls-access-inventory.spec.ts` — RLS-Gates
|
||||
- `docs/mandantentrennung-zugriffsklassifikation.md` — Klassifikationspflicht
|
||||
- `docs/anleitung-entwicklung.md` — Modul-/Kachel-Registrierungsmuster
|
||||
- `.planning/quick/260922-m1h-dashboard-widgets-ein-modul-bringt-seine/260922-m1h-SUMMARY.md` — Drei-Stellen-Kachel-Muster
|
||||
|
||||
### Secondary (MEDIUM confidence — offizielle Proxmox-Doku, per WebFetch/WebSearch gelesen)
|
||||
- pve.proxmox.com/pve-docs/pveum-plain.html — API-Token-Header, PVEAuditor-Rolle
|
||||
- pve.proxmox.com/wiki/Proxmox_VE_API — Ticket-Endpunkt, CSRF-Verhalten
|
||||
- pbs.proxmox.com/docs/user-management.html — PBSAPIToken-Header, Audit/DatastoreAudit-Rollen
|
||||
|
||||
### Tertiary (LOW confidence — Forenbelege, nicht in Primärdoku bestätigt)
|
||||
- forum.proxmox.com (mehrere Threads) — PMG-Token-Lücke, `cluster/resources`-Feldnamen, PBS-Snapshot-Felder, PMG-Statistik-Felder, RRD-Counter-Typ
|
||||
- pmg.proxmox.com/pmg-docs/pmg-admin-guide.html — Auditor-Rollenbeschreibung (aus Sekundärzitaten, nicht direkt aus dem Volltext extrahierbar — Dokument zu groß für den Abruf)
|
||||
|
||||
## Metadata
|
||||
|
||||
**Confidence breakdown:**
|
||||
- PVE-Auth/CSRF/Rollen: HIGH — offizielle Doku wörtlich zitiert
|
||||
- PBS-Auth/Rollen: HIGH (Auth-Header, Rollen), MEDIUM (Snapshot-Feldnamen, nur Forenbeleg)
|
||||
- PMG-Auth: LOW (Token-Unterstützung nicht in Primärquelle bestätigt) — als `checkpoint:human-verify` markiert
|
||||
- Bestandsmuster (Crypto/Scheduler/RLS/Modul-Registrierung): HIGH — aus gelesenem Code zitiert
|
||||
|
||||
**Research date:** 2026-09-23
|
||||
**Valid until:** ~30 Tage für Bestandsmuster (stabil); Proxmox-API-Details sollten vor dem ersten Implementierungs-Task gegen einen echten Server nachgeprüft werden, unabhängig vom Datum (siehe Assumptions Log)
|
||||
+295
@@ -0,0 +1,295 @@
|
||||
---
|
||||
phase: quick-260923-dhh
|
||||
plan: 01
|
||||
subsystem: infrastructure
|
||||
tags: [proxmox, pve, pbs, pmg, undici, scheduler, rls, module-registry, nestjs, next-intl]
|
||||
dependency-graph:
|
||||
requires: []
|
||||
provides: [proxmox-module, proxmox-server-model, proxmox-background-poller]
|
||||
affects: [apps/api/src/proxmox, apps/web/src/app/(portal)/modules/proxmox, apps/web/src/lib/proxmox-api.ts]
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "undiciFetch statt globalem fetch fuer einen bedingten TLS-Dispatcher (zweites, unabhaengiges Auftreten nach icon-discovery.service.ts)"
|
||||
- "Nur-Lese-Riegel per Quelltext-Analyse (proxmox-nur-lesen.spec.ts), Vorbild rls-access-inventory.spec.ts"
|
||||
- "Scheduler kombiniert DkvSchedulerService-Mandanten-Fan-out mit TenderSchedulerService-onApplicationBootstrap-Timing"
|
||||
- "select ohne Geheimnisfelder statt nachtraeglicher Maskierung"
|
||||
key-files:
|
||||
created:
|
||||
- apps/api/prisma/migrations/20260923140000_proxmox_server/migration.sql
|
||||
- apps/api/src/proxmox/proxmox.types.ts
|
||||
- apps/api/src/proxmox/proxmox-auth.ts
|
||||
- apps/api/src/proxmox/proxmox-client.service.ts
|
||||
- apps/api/src/proxmox/proxmox-normalize.ts
|
||||
- apps/api/src/proxmox/proxmox.service.ts
|
||||
- apps/api/src/proxmox/proxmox-scheduler.service.ts
|
||||
- apps/api/src/proxmox/proxmox.controller.ts
|
||||
- apps/api/src/proxmox/proxmox.module.ts
|
||||
- apps/api/src/proxmox/proxmox.seed.ts
|
||||
- apps/api/src/proxmox/dto/proxmox-server.dto.ts
|
||||
- apps/api/src/proxmox/proxmox-nur-lesen.spec.ts
|
||||
- apps/web/src/app/(portal)/modules/proxmox/page.tsx
|
||||
- apps/web/src/app/(portal)/modules/proxmox/layout.tsx
|
||||
- apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.tsx
|
||||
- apps/web/src/app/(portal)/modules/proxmox/settings/page.tsx
|
||||
- apps/web/src/app/(portal)/modules/proxmox/settings/components/ServerForm.tsx
|
||||
- apps/web/src/lib/proxmox-api.ts
|
||||
modified:
|
||||
- apps/api/prisma/schema.prisma
|
||||
- apps/api/src/app.module.ts
|
||||
- apps/api/src/prisma/rls-access-inventory.spec.ts
|
||||
- apps/web/src/lib/module-loader.ts
|
||||
- apps/web/src/messages/de.json
|
||||
- apps/web/src/messages/en.json
|
||||
- apps/web/src/messages/umlaut-dictionary.ts
|
||||
- docs/mandantentrennung-zugriffsklassifikation.md
|
||||
- docs/anleitung-entwicklung.md
|
||||
- docs/anleitung-anwender.md
|
||||
decisions:
|
||||
- "D-01 bis D-11 aus dem Plan woertlich umgesetzt, keine Abweichung."
|
||||
- "proxmoxGet uebergibt bewusst KEIN method-Feld an undiciFetch (GET ist der Grundwert) — dadurch ist loginTicket() in proxmox-auth.ts die einzige Stelle, die ein Anfrageverfahren explizit uebergibt, und proxmox-nur-lesen.spec.ts kann das maschinell auf genau EINS pruefen."
|
||||
- "Ticket-Erneuerung sitzt je POLL-DURCHLAUF, nicht je Aufruf: ein PBS-Durchlauf mit mehreren Folgeabfragen (Belegung + je Datenspeicher Sicherungen) loggt sich bei 401 hoechstens einmal neu ein, nicht einmal je Anfrage."
|
||||
- "proxmox.service.ts ist der EINZIGE forSystem()-Aufrufer des Moduls (loadActiveServersForScheduler) — in FORSYSTEM_ALLOWED_CALL_SITES eingetragen, Stand von ProxmoxServer auf system-gebunden gehoben."
|
||||
- "docs/anwenderhandbuch.md aus dem Plan existiert nicht im Repo — der echte Dateiname ist docs/anleitung-anwender.md; dort den Proxmox-Abschnitt eingefuegt (Rule 3)."
|
||||
metrics:
|
||||
duration: "~5h (Session unterbrochen und fortgesetzt)"
|
||||
completed: 2026-09-23
|
||||
actuals:
|
||||
tokens: 50829
|
||||
tasks: 7
|
||||
commits: 7
|
||||
plan_head_before: ec9c779
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Quick 260923-dhh: Proxmox-Modul (PVE/PBS/PMG) — nur beobachten Summary
|
||||
|
||||
Vollstaendiges Proxmox-Modul (Datenbank, Dienst, API, Hintergrundabfrage je Mandant, Einstellungsseite, Modulseite, Dokumentation) — PVE/PBS/PMG werden per API-Token (PVE/PBS) oder Ticket-Anmeldung (alle drei) nur gelesen, kein Weg im Modul veraendert je etwas bei Proxmox.
|
||||
|
||||
## Gemessene Torzahlen
|
||||
|
||||
| Tor | Ausgangswert (23.09., vor Beginn) | Endstand (nach Aufgabe 7) |
|
||||
|---|---|---|
|
||||
| `pnpm --filter @tessera/api test` | 1240 Tests, 77 Dateien | **1311 Tests, 82 Dateien** |
|
||||
| `pnpm --filter @tessera/web test` | 693 Tests, 82 Dateien | **708 Tests, 84 Dateien** |
|
||||
| `rls-coverage.spec.ts` / `rls-access-inventory.spec.ts` | 5 / 30 | **5 / 30** (unveraendert gruen) |
|
||||
| `proxmox-nur-lesen.spec.ts` | (existierte nicht) | **2 Tests, gruen** |
|
||||
| `pnpm type-check` | 4/4 | **4/4** |
|
||||
| `pnpm lint` | 5/5 | **5/5** |
|
||||
| Biome-Warnungen in `apps/web` | 53 | **53** (exakt unveraendert) |
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** ~5h (inklusive einer Unterbrechung durch Nutzungslimit, an derselben Stelle fortgesetzt)
|
||||
- **Tasks:** 7/7
|
||||
- **Files modified:** 34 (18 neu, 16 geaendert)
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- `ProxmoxServer`/`ProxmoxServerStatus` mit RLS (`tenant_isolation_policy` auf beiden,
|
||||
`system_read_policy` zusaetzlich auf `ProxmoxServer` fuer den Planer-Startpfad)
|
||||
- `proxmox-auth.ts` als einzige Stelle, die Kopfzeilen/Cookies baut: Token-Schema je Produkt
|
||||
(PVE `=`, PBS `:`, PMG lehnt ab) und Ticket-Anmeldung (die einzige nicht-lesende Anfrage
|
||||
des Moduls)
|
||||
- `proxmox-client.service.ts`/`proxmox-normalize.ts`: nachsichtige Fehler-/Feldbehandlung,
|
||||
sieben stabile Fehlerschluessel, nie ein Wurf bei unerwarteter Form
|
||||
- `proxmox-scheduler.service.ts`: ein Cron-Auftrag je Mandant (`proxmox-poll:<tenantId>`),
|
||||
`onApplicationBootstrap`, Abfrageintervall = kleinstes `pollIntervalMin` der aktiven Server
|
||||
- Einstellungsseite (anlegen/bearbeiten/loeschen/testen) und Modulseite (Serverliste mit
|
||||
produktabhaengiger Auslastung, `null` immer als „unbekannt")
|
||||
- Anwenderhandbuch- und Entwicklungsanleitung-Abschnitte, Zugriffsklassifikation vollstaendig
|
||||
nachgezogen
|
||||
|
||||
## Task Commits
|
||||
|
||||
Jede Aufgabe wurde einzeln committet:
|
||||
|
||||
1. **Aufgabe 1: PVE per Token, Ende-zu-Ende** — `3a1bfd9` (feat)
|
||||
2. **Aufgabe 2: Benutzer/Passwort, Fehlerklassen, Nur-Lesen-Riegel** — `4f8a368` (test)
|
||||
3. **Aufgabe 3: PBS und PMG auswerten** — `998aba9` (feat)
|
||||
4. **Aufgabe 4: Hintergrundabfrage je Mandant, Verbindungstest** — `fccaf8d` (feat)
|
||||
5. **Aufgabe 5: Einstellungsseite (anlegen, bearbeiten, loeschen, testen)** — `723cf68` (feat)
|
||||
6. **Aufgabe 6: Modulseite mit Auslastung** — `06fcdc0` (feat)
|
||||
7. **Aufgabe 7: Dokumentation und Nachmessung aller Tore** — `3091b04` (docs)
|
||||
|
||||
_Kein separater Metadaten-Commit — STATE.md/SUMMARY.md werden laut Auftrag nicht committet._
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
Siehe `key-files` im Frontmatter — vollstaendige Liste, hier die wichtigsten:
|
||||
|
||||
- `apps/api/prisma/migrations/20260923140000_proxmox_server/migration.sql` — RLS-Migration,
|
||||
von Hand geschrieben (Vorbild `20260923120000_dashboard_tabs`)
|
||||
- `apps/api/src/proxmox/proxmox-client.service.ts` — `proxmoxGet`, `classifyFailure`,
|
||||
`parseJsonLenient`
|
||||
- `apps/api/src/proxmox/proxmox-auth.ts` — `buildTokenAuthHeader`, `loginTicket`,
|
||||
`buildTicketCookieHeader`
|
||||
- `apps/api/src/proxmox/proxmox-normalize.ts` — `normalizePve`/`normalizePbs`/`normalizePmg`
|
||||
plus `readNumber`/`readText`/`readBool`/`readList`
|
||||
- `apps/api/src/proxmox/proxmox.service.ts` — CRUD, Poll-Logik, Zehn-Sekunden-Sperre,
|
||||
`loadActiveServersForScheduler` (einziger `forSystem()`-Aufruf)
|
||||
- `apps/api/src/proxmox/proxmox-scheduler.service.ts` — Planer je Mandant
|
||||
- `apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.tsx` — einzige Stelle,
|
||||
die einen Messwert in Text verwandelt
|
||||
|
||||
## Decisions Made
|
||||
|
||||
Siehe `decisions` im Frontmatter. Zusaetzlich zwei technische Entwurfsentscheidungen, die der
|
||||
Plan nicht bis auf diese Ebene vorschrieb:
|
||||
|
||||
- **Signatur `proxmoxGet(target, path)`:** `target` traegt fertige Kopfzeilen
|
||||
(`{ baseUrl, tlsRejectUnauthorized, headers }`), gebaut ausschliesslich von `proxmox-auth.ts`
|
||||
— der Klient selbst kennt keine Anmeldeform, nur HTTP-Transport und Fehlerklassifikation.
|
||||
- **`proxmox-nur-lesen.spec.ts` erkennt Aufrufformen ueber Klammertiefen-Bilanzierung**
|
||||
(nicht per einfachem Zeilen-Regex), weil Proxmox-Pfade und `proxmoxGet(`/`getWithRetry(`-
|
||||
Aufrufe im Quelltext ueber mehrere Zeilen verteilt sind.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 3 - Blocking] `docs/anwenderhandbuch.md` existiert nicht im Repo**
|
||||
- **Found during:** Aufgabe 7
|
||||
- **Issue:** Das Plan-Frontmatter nennt `docs/anwenderhandbuch.md` als zu aendernde Datei; diese
|
||||
Datei gibt es im Repository nicht. Der tatsaechliche Anwenderhandbuch-Dateiname ist
|
||||
`docs/anleitung-anwender.md` (bestaetigt per `git log --diff-filter=A`).
|
||||
- **Fix:** Den Proxmox-Abschnitt in `docs/anleitung-anwender.md` eingefuegt statt eine neue,
|
||||
falsch benannte Datei anzulegen.
|
||||
- **Files modified:** `docs/anleitung-anwender.md`
|
||||
- **Verification:** Datei existiert, Abschnitt „Proxmox" lesbar, Modulzahl „vier" auf „fuenf"
|
||||
korrigiert.
|
||||
- **Committed in:** `3091b04` (Aufgabe-7-Commit)
|
||||
|
||||
**2. [Rule 3 - Blocking] Umlaut-Regressionswaechter (`umlaut-guard.spec.ts`) schlug fehl**
|
||||
- **Found during:** Aufgabe 5 und erneut Aufgabe 6
|
||||
- **Issue:** Neue, bereits korrekte deutsche Woerter mit „ss" (`bewusst`, `gemessene`,
|
||||
`Messung`, `Prozessorlast`) in den neuen `de.json`-Texten wurden vom Waechter als
|
||||
moegliche ae/oe/ue/ss-Ersatzschreibung markiert, weil sie noch nicht auf der Positivliste
|
||||
standen.
|
||||
- **Fix:** Alle vier Woerter zu `UMLAUT_ALLOWLIST` in `apps/web/src/messages/umlaut-dictionary.ts`
|
||||
hinzugefuegt (kein Ersatzschreibung — bereits korrektes Deutsch).
|
||||
- **Files modified:** `apps/web/src/messages/umlaut-dictionary.ts`
|
||||
- **Verification:** `umlaut-guard.spec.ts` gruen, `pnpm --filter @tessera/web test` vollstaendig
|
||||
gruen.
|
||||
- **Committed in:** `723cf68` (Aufgabe 5), `06fcdc0` (Aufgabe 6)
|
||||
|
||||
**3. [Rule 3 - Blocking] `proxmox-nur-lesen.spec.ts` erkannte den `getWithRetry`-Umschlag nicht**
|
||||
- **Found during:** Aufgabe 3 (beim Einbau der PBS-Mehrfachabfrage)
|
||||
- **Issue:** Der urspruengliche Riegel erkannte Proxmox-Pfade nur innerhalb direkter
|
||||
`proxmoxGet(...)`-Aufrufe; nach der Extraktion der Ticket-Erneuerung in einen privaten
|
||||
Umschlag `getWithRetry()` (Aufgabe 2/3) lagen alle Pfade jetzt in dessen Argumenten, nicht
|
||||
mehr direkt in `proxmoxGet(...)`.
|
||||
- **Fix:** Die erlaubte Aufrufform-Liste um `getWithRetry` erweitert (dokumentierte Ausnahme,
|
||||
selbst durch dieselbe erste Aussage des Riegels abgesichert: `getWithRetry` ruft
|
||||
ausschliesslich `proxmoxGet`).
|
||||
- **Files modified:** `apps/api/src/proxmox/proxmox-nur-lesen.spec.ts`
|
||||
- **Verification:** Beide Aussagen des Riegels gruen, bewusster Test bestaetigt weiterhin genau
|
||||
eine `undiciFetch`-Methodenstelle.
|
||||
- **Committed in:** `998aba9` (Aufgabe 3)
|
||||
|
||||
---
|
||||
|
||||
**Total deviations:** 3 auto-fixed (alle Rule 3 — blockierende Fehler beim Ausfuehren, keine
|
||||
davon eine architektonische Entscheidung)
|
||||
**Impact on plan:** Keine Abweichung vom fachlichen Umfang des Plans; alle drei Korrekturen
|
||||
waren notwendig, damit die vom Plan selbst verlangten Tore (Aufgabe 7: alle Testsuiten gruen)
|
||||
ueberhaupt erreichbar waren.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
Die Ausfuehrung wurde durch ein Nutzungslimit mitten in Aufgabe 4 unterbrochen (nach dem
|
||||
Schreiben von `proxmox-scheduler.service.ts` und dem Wiring in `proxmox.controller.ts`/
|
||||
`proxmox.module.ts`, vor dem Schreiben der zugehoerigen Testdatei). Nach Fortsetzung wurde der
|
||||
Stand anhand von `git status`/`git log` verifiziert und exakt an der protokollierten Stelle
|
||||
weitergearbeitet — keine Wiederholung bereits committeter Aufgaben.
|
||||
|
||||
## User Setup Required
|
||||
|
||||
**Es gibt in dieser Umgebung keinen echten PVE-/PBS-/PMG-Server.** Alle Tests laufen gegen
|
||||
erfundene Antworten in der von der Recherche dokumentierten Form (`vi.mock('undici', …)`).
|
||||
Folgende Annahmen der Recherche sind vor dem ersten echten Test explizit zu bestaetigen bzw.
|
||||
bei Abweichung an genau einer Stelle nachzuziehen:
|
||||
|
||||
- **Annahme A2 — Ticket-Cookie-Namen fuer PBS/PMG:** `PBSAuthCookie`/`PMGAuthCookie` sind aus
|
||||
dem PVE-Muster ABGELEITET, nicht aus Primaerdoku bestaetigt. Nachzuziehende Stelle:
|
||||
`TICKET_COOKIE_NAME` in `apps/api/src/proxmox/proxmox-auth.ts`.
|
||||
- **Annahme A3 — PBS-Belegungs-/Snapshot-Feldnamen:** `store`/`total`/`used`/`avail` und
|
||||
`backup-time`/`verification` sind aus Forenbelegen abgeleitet. Nachzuziehende Stelle:
|
||||
`PBS_USAGE_FIELDS`/`PBS_SNAPSHOT_FIELDS` in `apps/api/src/proxmox/proxmox-normalize.ts`
|
||||
(mehrere plausible Namen je Feld moeglich, der Leser nimmt den ersten vorhandenen).
|
||||
- **Annahme A5 — PMG-Statistikfelder:** `count_in`/`count_out`/`spamcount_in`/`spamcount_out`/
|
||||
`viruscount_in`/`viruscount_out` sind aus `pmgsh`-Community-Belegen abgeleitet.
|
||||
Nachzuziehende Stelle: `PMG_STATS_FIELDS` in `apps/api/src/proxmox/proxmox-normalize.ts`.
|
||||
- **NUR-LESE-Rollen am Proxmox-Server selbst anlegen** (aus dem Plan-Frontmatter
|
||||
`user_setup`, unveraendert offen): PVE `PVEAuditor`, PBS `Audit`/`DatastoreAudit`,
|
||||
PMG `Auditor` — je Produkt fuer den Zugang, den Tessera nutzt.
|
||||
|
||||
Weicht die Wirklichkeit an einer dieser Stellen ab, zeigt die Modulseite dank der
|
||||
nachsichtigen Leser „unbekannt" statt eines Absturzes, und die gekuerzte Rohantwort bleibt im
|
||||
Zwischenlager erhalten (`rawSample`, bis 20 000 Zeichen) — der Nutzer sieht darin, wie das
|
||||
Feld tatsaechlich heisst.
|
||||
|
||||
## Known Stubs
|
||||
|
||||
Keine — jede in `<must_haves>` genannte Wahrheit ist durch mindestens einen automatisierten
|
||||
Test belegt (siehe Aufgaben 1–6). Die drei oben genannten Annahmen sind keine Stubs, sondern
|
||||
dokumentierte, noch nicht am echten Server bestaetigte Feldnamen — die Auswertung fuer sie ist
|
||||
vollstaendig gebaut, nur ihre exakten externen Namen sind ungeprueft.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- Das Modul ist vollstaendig gebaut und alle automatisierten Tore sind gruen; die
|
||||
Dashboard-Kachel (D-11) ist bewusst nicht Teil dieses Auftrags und folgt separat
|
||||
(`WIDGET_TYPES`/`WIDGET_MODULE_SLUGS`/`registerWidget`, siehe
|
||||
`docs/anleitung-entwicklung.md`, Abschnitt „Eine Kachel zum Modul").
|
||||
- **Blocker fuer den naechsten Schritt:** keiner auf Code-Ebene. Der Nutzer muss das Modul
|
||||
gegen mindestens einen echten PVE-/PBS-/PMG-Server pruefen (siehe „User Setup Required"),
|
||||
bevor die drei Annahmen als bestaetigt gelten koennen.
|
||||
- Container wurden in dieser Ausfuehrung bewusst NICHT neu gebaut/neu gestartet und es wurde
|
||||
keine Browser-Pruefung durchgefuehrt (Auftragsvorgabe) — das uebernimmt der Nutzer bzw. eine
|
||||
spaetere Sitzung.
|
||||
|
||||
---
|
||||
*Phase: quick-260923-dhh*
|
||||
*Completed: 2026-09-23*
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
All 24 files listed under `key-files` (created + modified) verified present on disk. All 7
|
||||
task commits (`3a1bfd9`, `4f8a368`, `998aba9`, `fccaf8d`, `723cf68`, `06fcdc0`, `3091b04`)
|
||||
verified present in `git log`.
|
||||
|
||||
## Nachbesserungen aus dem Rundgang
|
||||
|
||||
Drei Befunde aus dem menschlichen Browser-Rundgang zu diesem Modul wurden behoben — Details,
|
||||
Tasks und Tests in einem eigenen Quick-Task:
|
||||
[260923-ku6-drei-nachbesserungen-aus-dem-browser-run](../260923-ku6-drei-nachbesserungen-aus-dem-browser-run/260923-ku6-SUMMARY.md)
|
||||
(Commits `710034c`, `f1bb7f7`).
|
||||
|
||||
**Befund 1 (wichtig): „Verbindung testen" pruefte den gespeicherten Stand, nicht das
|
||||
Formular.** Eine im Formular abgeschaltete Zertifikatspruefung oder ein neu eingetipptes
|
||||
Token-/Passwort-Geheimnis wurden vom Test ignoriert und griffen erst nach „Speichern" — eine
|
||||
Falle fuer den naheliegenden Ablauf (eintippen, testen, dann erst speichern). Behoben durch ein
|
||||
neues `TestProxmoxServerDto` samt Merge-Baustein `resolveEffectiveTestServer` in
|
||||
`ProxmoxService`: normale Formularfelder gewinnen immer (auch wenn absichtlich geleert),
|
||||
Geheimnisfelder behalten die bestehende „leer gelassen -> gespeicherten Wert weiterverwenden"-
|
||||
Regel aus `updateServer`, weil `ServerForm` sie beim Laden nie aus der Datenbank vorbefuellt.
|
||||
Neue Route `POST servers/test` (ohne `:id`) deckt denselben Test waehrend der Neuanlage ab, wo
|
||||
es noch keinen gespeicherten Server gibt; der Testen-Knopf steht jetzt immer zur Verfuegung,
|
||||
nicht mehr erst nach dem ersten Speichern.
|
||||
|
||||
**Befund 2 (wichtig): falsche Meldung fuer „noch nie abgefragt".** Ein frisch angelegter
|
||||
Server zeigte „Letzte Abfrage: unbekannt" UND faelschlich „Ein unerwarteter Fehler ist
|
||||
aufgetreten" — die leere Zwischenlagerzeile aus `createServer` hat `reachable: false` und
|
||||
`errorKind: null`, was bisher blind in die Fehleruebersetzung `unbekannt` lief. Behoben durch
|
||||
einen eigenen, ruhigen Zustand fuer `status.lastPolledAt === null`, der auf „Jetzt
|
||||
aktualisieren" verweist; die bestehenden Fehlermeldungen (inkl. `unbekannt` fuer echte
|
||||
unbekannte Fehler) bleiben fuer bereits abgefragte, aber nicht erreichbare Server unveraendert.
|
||||
|
||||
**Befund 3 (kosmetisch): die Adresse wurde in Grossbuchstaben angezeigt.** Die Klasse
|
||||
`uppercase` sass auf der ganzen Statuszeile statt nur auf dem Produktkuerzel und faerbte
|
||||
dadurch auch die Adresse gross. Jetzt nur noch auf dem Produktkuerzel (`<span>`).
|
||||
|
||||
**Zahlen nach der Nachbesserung:** api 1311 → 1316 Tests, web 708 → 712 Tests, type-check
|
||||
4/4, lint 5/5, Biome `apps/web` weiterhin exakt 53 Warnungen. Container wurden nicht neu
|
||||
gebaut, keine Browser-Pruefung in diesem Lauf (macht der Orchestrator danach).
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
---
|
||||
phase: quick-260923-dhh
|
||||
verified: 2026-09-23T14:58:00Z
|
||||
status: gaps_found
|
||||
score: 8/9 must-haves verified
|
||||
covered_files: [".planning/quick/260923-dhh-proxmox-modul-pve-pbs-und-pmg-anbinden-n/260923-dhh-PLAN.md", ".planning/quick/260923-dhh-proxmox-modul-pve-pbs-und-pmg-anbinden-n/260923-dhh-RESEARCH.md", ".planning/quick/260923-dhh-proxmox-modul-pve-pbs-und-pmg-anbinden-n/260923-dhh-SUMMARY.md", "apps/api/prisma/migrations/20260923140000_proxmox_server/migration.sql", "apps/api/prisma/schema.prisma", "apps/api/src/prisma/rls-access-inventory.spec.ts", "apps/api/src/proxmox/dto/proxmox-server.dto.ts", "apps/api/src/proxmox/proxmox-auth.ts", "apps/api/src/proxmox/proxmox-client.service.spec.ts", "apps/api/src/proxmox/proxmox-client.service.ts", "apps/api/src/proxmox/proxmox-normalize.spec.ts", "apps/api/src/proxmox/proxmox-normalize.ts", "apps/api/src/proxmox/proxmox-nur-lesen.spec.ts", "apps/api/src/proxmox/proxmox-scheduler.service.spec.ts", "apps/api/src/proxmox/proxmox-scheduler.service.ts", "apps/api/src/proxmox/proxmox.controller.ts", "apps/api/src/proxmox/proxmox.module.ts", "apps/api/src/proxmox/proxmox.seed.ts", "apps/api/src/proxmox/proxmox.service.spec.ts", "apps/api/src/proxmox/proxmox.service.ts", "apps/api/src/proxmox/proxmox.types.ts", "apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.test.tsx", "apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.tsx", "apps/web/src/app/(portal)/modules/proxmox/layout.tsx", "apps/web/src/app/(portal)/modules/proxmox/page.tsx", "apps/web/src/app/(portal)/modules/proxmox/settings/components/ServerForm.test.tsx", "apps/web/src/app/(portal)/modules/proxmox/settings/components/ServerForm.tsx", "apps/web/src/app/(portal)/modules/proxmox/settings/page.tsx", "apps/web/src/lib/module-loader.ts", "apps/web/src/lib/proxmox-api.ts", "apps/web/src/messages/de.json", "apps/web/src/messages/en.json", "apps/web/src/messages/umlaut-dictionary.ts", "docs/anleitung-anwender.md", "docs/anleitung-entwicklung.md", "docs/mandantentrennung-zugriffsklassifikation.md"]
|
||||
covered_digest: "v1:sha256:2ee19956636c68304958254f2e1d979a6763c3fae7dc11cd781fe24d56e498e8"
|
||||
behavior_unverified: 0
|
||||
overrides_applied: 0
|
||||
gaps:
|
||||
- truth: "Ein fehlendes, anders benanntes oder falsch typisiertes Feld einer Proxmox-Antwort fuehrt zu unbekannt in der Anzeige, nie zu einem Absturz, einer leeren Seite oder einem stillen Falschwert."
|
||||
status: partial
|
||||
reason: "normalizePmg() kombiniert spamcount_in/spamcount_out (und viruscount_in/viruscount_out) ueber sumOrNull(a, b), das einen fehlenden Teilwert stillschweigend als 0 behandelt statt die Summe als unbekannt zu markieren. sumOrNull(10, null) liefert 10 — dieser Wert erscheint in der Modulseite als vollstaendige Tageszahl 'Spam: 10', obwohl eine der beiden Quellfelder (spamcount_out) fehlte oder anders heisst. Genau dieses Szenario ist der zentrale Risikofall des Moduls: PMG-Feldnamen sind Annahme A5 (Forenbeleg, unbestaetigt), und ein teilweise falscher, aber plausibel aussehender Wert ist laut eigenem Kommentar in proxmox-normalize.ts ('ein still falscher Wert waere schlimmer als ein ehrliches unbekannt') genau das, was das Modul verhindern soll. Alle uebrigen Einzelwerte (readNumber/readText/readBool je Feld, PBS readFirstPresent-Alternativnamen) sind korrekt nachsichtig und liefern bei fehlendem Feld null — nur diese eine Aggregation (zwei Teilwerte zu einer Summe) durchbricht das Muster."
|
||||
artifacts:
|
||||
- path: "apps/api/src/proxmox/proxmox-normalize.ts"
|
||||
issue: "sumOrNull(a, b) (Zeile 240-243) gibt (a??0)+(b??0) zurueck, sobald mindestens einer von a/b nicht null ist — ein fehlender Halbwert wird als 0 addiert statt die Summe auf null zu setzen. Betrifft spamCount und virusCount in normalizePmg()."
|
||||
missing:
|
||||
- "sumOrNull so aendern, dass die Summe null ist, sobald a ODER b null ist (nicht erst wenn beide null sind) — oder spamCount/virusCount nur berechnen, wenn beide Teilwerte vorhanden sind."
|
||||
- "Test in proxmox-normalize.spec.ts ergaenzen: 'nur spamcount_in vorhanden, spamcount_out fehlt' -> spamCount muss null sein, nicht der Teilwert."
|
||||
---
|
||||
|
||||
# Quick 260923-dhh: Proxmox-Modul (PVE/PBS/PMG) — nur beobachten Verification Report
|
||||
|
||||
**Goal:** PVE/PBS/PMG per Modul beobachten (nicht veraendern): Server in den Einstellungen anlegen mit verschluesseltem Zugang, Zertifikatsfehler nur je Server dulden, Hintergrundabfrage mit Zwischenlager, Modulseite mit Serverliste und Auslastung, Verbindungstest mit Klartext-Ursache.
|
||||
**Verified:** 2026-09-23T14:58Z
|
||||
**Status:** gaps_found
|
||||
**Re-verification:** No — initial verification
|
||||
|
||||
## Goal Achievement
|
||||
|
||||
### Observable Truths
|
||||
|
||||
| # | Truth | Status | Evidence |
|
||||
|---|-------|--------|----------|
|
||||
| 1 | Kein Weg im Modul veraendert etwas bei Proxmox; die einzige Nicht-GET-Anfrage ist die Ticket-Anmeldung, maschinell nachgezaehlt | ✓ VERIFIED | `proxmox-nur-lesen.spec.ts` liest den Quelltext (Klammertiefen-Bilanzierung), zaehlt genau 1 `method:`-Uebergabe an `undiciFetch` in `proxmox-auth.ts`, und verlangt, dass jeder API-Pfad ausserhalb der Ticket-Anmeldung durch `proxmoxGet`/`getWithRetry` laeuft. `getWithRetry` (proxmox.service.ts:275) ruft ausschliesslich `proxmoxGet` — keine verdeckte zweite Schreibstelle. Grep ueber `apps/api/src/proxmox` bestaetigt: kein bare `fetch(` ausserhalb `undiciFetch`. Test lief gruen (2/2). |
|
||||
| 2 | Administrator legt Server (Name, Typ, Adresse, Zugang) an; Geheimnis nie im Klartext sichtbar | ✓ VERIFIED | `createServer`/`updateServer` verschluesseln via `CryptoService`; `SAFE_SERVER_SELECT` (proxmox.service.ts:26-42) waehlt `encryptedTokenSecret`/`encryptedPassword` nicht aus — die Felder verlassen die DB nie. `proxmox.service.spec.ts` bestaetigt `'encryptedTokenSecret' in list[0]` ist `false`. Frontend `ServerForm.tsx`: Geheimnisfelder immer leer geladen (`tokenSecret: ''`, `password: ''`), leer gelassen = unveraendert (Backend-Logik in `updateServer`). |
|
||||
| 3 | PVE/PBS: Token ODER Passwort; PMG nur Passwort, Token-Feld verschwindet und wird serverseitig abgelehnt | ✓ VERIFIED | `ServerForm.tsx`: `{form.productType !== 'pmg' && <option value="token">...}` — Token-Option fehlt bei PMG. `PmgOhneTokenConstraint` im DTO UND zusaetzliche Pruefung in `updateServer` gegen den EFFEKTIVEN Stand (verhindert Umgehung ueber Teil-Updates). `buildTokenAuthHeader('pmg', ...)` wirft. Getestet in `proxmox-client.service.spec.ts` (DTO-Validierung PMG+Token). |
|
||||
| 4 | Zertifikatsfehler nur je Server geduldet, Default "pruefen" | ✓ VERIFIED | `proxmoxGet`/`loginTicket` bauen den `Agent`-Dispatcher JE AUFRUF aus `target.tlsRejectUnauthorized` der jeweiligen Zeile — kein Modul-Singleton, keine Env-Variable. DTO-Default `tlsRejectUnauthorized ?? true`. Test bestaetigt: `true` → kein Dispatcher, `false` → genau ein `Agent` mit `rejectUnauthorized: false`. |
|
||||
| 5 | Modulseite und jede Anzeige lesen ausschliesslich aus dem Zwischenlager | ✓ VERIFIED | `GET servers` → `listWithStatus()` liest nur aus der DB (kein `proxmoxGet`-Aufruf). `page.tsx`/`ServerCard.tsx` rendern nur `server.status`, das aus derselben Response stammt. Live-Abfrage findet nur ueber `pollServer`/`testConnection` statt, explizit durch Nutzerklick oder Scheduler ausgeloest. |
|
||||
| 6 | Knopf "Verbindung testen" nennt Ursache in Alltagssprache | ✓ VERIFIED | Alle 7 `ProxmoxErrorKind`-Werte haben deutsche Klartexttexte in `de.json`/`en.json` (Sie-Form, mit Ursache und naechstem Schritt). `testConnection()` schreibt NICHT ins Zwischenlager (Vorbild LDAP-Test). |
|
||||
| 7 | Fehlendes/anders benanntes/falsch typisiertes Feld → "unbekannt", nie Absturz/leere Seite/stiller Falschwert | ✗ PARTIAL | Siehe Gap unten: `sumOrNull()` in `normalizePmg()` liefert bei einem fehlenden Teilwert (z. B. `spamcount_out` fehlt) einen scheinbar vollstaendigen, tatsaechlich unvollstaendigen Zahlenwert statt `null`/"unbekannt". Alle uebrigen Einzelwerte (PVE/PBS, PMG countIn/countOut) sind korrekt nachsichtig — verifiziert in `proxmox-normalize.spec.ts` (20 Tests gruen) und live nachgerechnet (`node -e`). |
|
||||
| 8 | Ohne Server: Modulseite ruhig, erklaert dass noch keiner eingetragen ist | ✓ VERIFIED | `page.tsx`: `servers.length === 0` → `t('emptyState')` plus Link zu den Einstellungen fuer Admins, kein Fehlertext. |
|
||||
| 9 | Beide Tabellen tragen tenantId mit RLS-Policy; rls-coverage/rls-access-inventory bleiben gruen | ✓ VERIFIED | Migration erstellt `tenant_isolation_policy` auf beiden Tabellen plus `system_read_policy` nur auf `ProxmoxServer`. **Live in der Dev-DB bestaetigt** (`psql`): `relrowsecurity=t`, `relforcerowsecurity=t` auf beiden Tabellen; `pg_policies` zeigt exakt die erwarteten drei Policies. `rls-coverage.spec.ts` (5/5) und `rls-access-inventory.spec.ts` (30/30) gruen, inkl. `FORSYSTEM_ALLOWED_CALL_SITES`-Eintrag fuer den einzigen `forSystem()`-Aufruf. Klassifikationsdoku nachgemessen (`grep -c` bestaetigt 11 gebundene + 1 System-Rohtreffer, Doku sagt dasselbe). |
|
||||
|
||||
**Score:** 8/9 truths verified (0 present-but-behavior-unverified)
|
||||
|
||||
### Required Artifacts
|
||||
|
||||
| Artifact | Expected | Status | Details |
|
||||
|----------|----------|--------|---------|
|
||||
| `apps/api/prisma/migrations/20260923140000_proxmox_server/migration.sql` | RLS-Migration | ✓ VERIFIED | Existiert, angewendet (Tabellen + Policies live in der Dev-DB bestaetigt) |
|
||||
| `apps/api/src/proxmox/proxmox-auth.ts` | einzige Kopfzeilen-Stelle | ✓ VERIFIED | `buildTokenAuthHeader`, `loginTicket`, `buildTicketCookieHeader`; keine andere Datei im Repo baut PVEAPIToken/PBSAPIToken/Cookie-Header |
|
||||
| `apps/api/src/proxmox/proxmox-client.service.ts` | nur-lesender HTTP-Zugang | ✓ VERIFIED | `proxmoxGet`, `classifyFailure`, `parseJsonLenient` — kein `method`-Parameter |
|
||||
| `apps/api/src/proxmox/proxmox-normalize.ts` | nachsichtige Leser | ⚠️ SUBSTANTIVE MIT LUECKE | Grundfunktionen (`readNumber`/`readText`/`readBool`/`readList`) korrekt; `normalizePmg`s Aggregation (`sumOrNull`) durchbricht das Muster (siehe Gap) |
|
||||
| `apps/api/src/proxmox/proxmox-scheduler.service.ts` | Planer je Mandant | ✓ VERIFIED | `onApplicationBootstrap`, ein Cron-Auftrag je Mandant, Fan-out getestet (9/9 Tests) |
|
||||
| `apps/api/src/proxmox/proxmox-nur-lesen.spec.ts` | maschineller Riegel D-01 | ✓ VERIFIED | 2/2 Tests gruen, Klammertiefen-Analyse statt naiver Regex |
|
||||
| `apps/web/src/app/(portal)/modules/proxmox/page.tsx` | Modulseite | ✓ VERIFIED | Leerzustand, Serverliste, "Jetzt aktualisieren" |
|
||||
| `apps/web/src/app/(portal)/modules/proxmox/settings/page.tsx` | Einstellungsseite | ✓ VERIFIED | Rollen-Gate (Anzeige), CRUD, Loeschbestaetigung |
|
||||
|
||||
### Key Link Verification
|
||||
|
||||
| From | To | Via | Status | Details |
|
||||
|------|-----|-----|--------|---------|
|
||||
| `proxmox-auth.ts` | Klient/Planer/Verbindungstest | einzige Kopfzeilen-Bau-Stelle (D-03) | ✓ WIRED | `proxmox.service.ts` importiert ausschliesslich `buildTicketCookieHeader`/`buildTokenAuthHeader`/`loginTicket` aus dieser Datei; kein Nachbau anderswo |
|
||||
| `proxmox-client.service.ts` | `tlsRejectUnauthorized`-Feld der Serverzeile | Dispatcher je Aufruf (D-04) | ✓ WIRED | `target.tlsRejectUnauthorized ? undefined : new Agent(...)` in `proxmoxGet` und `loginTicket`, je aus der uebergebenen Serverzeile |
|
||||
| `proxmox-scheduler.service.ts` | `proxmox.controller.ts` | `onApplicationBootstrap` + `refreshTenant` nach jedem Speichern | ✓ WIRED | Controller ruft `scheduler.refreshTenant(tenantId)` nach `create`/`update`/`remove` |
|
||||
| `proxmox.controller.ts` | `@UseModule`/`@Roles` | Modulfreigabe + Rollenschutz (D-09) | ✓ WIRED | `@UseModule('proxmox')` auf Klassenebene, `@Roles(ADMIN, SUPER_ADMIN)` auf allen Schreibwegen |
|
||||
| Jeder DB-Zugriff | `forTenant()`/`forSystem()` | Mandantenbindung (D-08) | ✓ WIRED | `grep -c` bestaetigt 11 `tenantPrisma.(proxmoxServer\|proxmoxServerStatus).`-Treffer, 1 `systemPrisma.proxmoxServer.`-Treffer — deckungsgleich mit `FORSYSTEM_ALLOWED_CALL_SITES` und der Klassifikationsdoku |
|
||||
|
||||
### Data-Flow Trace
|
||||
|
||||
| Artifact | Data Variable | Source | Produces Real Data | Status |
|
||||
|----------|---------------|--------|---------------------|--------|
|
||||
| `ServerCard.tsx` | `server.status.metrics` | `GET modules/proxmox/servers` → `listWithStatus()` → DB (`ProxmoxServerStatus`) | Ja (mit Testdaten belegt, kein echter Proxmox verfuegbar — s. unten) | ✓ FLOWING |
|
||||
| `ServerForm.tsx` Testergebnis | `testResult` | `POST servers/:id/test` → `testConnection()` → `pollOne()` (kein DB-Schreiben) | Ja | ✓ FLOWING |
|
||||
|
||||
### Behavioral Spot-Checks
|
||||
|
||||
| Behavior | Command | Result | Status |
|
||||
|----------|---------|--------|--------|
|
||||
| Nur-Lesen-Riegel haelt (Klammertiefen-Analyse, nicht nur Praesenz) | `vitest run src/proxmox/proxmox-nur-lesen.spec.ts` | 2/2 gruen | ✓ PASS |
|
||||
| Ticket-Erneuerung: genau EIN zweiter Versuch, zweites 401 bleibt Fehler | `vitest run src/proxmox` (enthaelt beide Faelle) | gruen | ✓ PASS |
|
||||
| Scheduler: zwei Mandanten verdraengen sich nicht, leere Serverliste → kein Auftrag | `vitest run src/proxmox/proxmox-scheduler.service.spec.ts` | 9/9 gruen | ✓ PASS |
|
||||
| RLS tatsaechlich in der Dev-DB aktiv (nicht nur im SQL-Text) | `docker exec ... psql -c "SELECT relrowsecurity, relforcerowsecurity FROM pg_class WHERE relname IN (...)"` | `t / t` auf beiden Tabellen, 3 erwartete Policies vorhanden | ✓ PASS |
|
||||
| `sumOrNull`-Aggregationsluecke (eigener Nachbau, nicht Teil der Testsuite) | `node -e "sumOrNull(10, null)"` | `10` (haette bei ehrlichem Verhalten `null` sein muessen) | ✗ FAIL — bestaetigt den Gap oben |
|
||||
| Volle Testsuiten | `pnpm --filter @tessera/api test`, `pnpm --filter @tessera/web test` | 1311/1311 bzw. 708/708 gruen, identisch zu SUMMARY-Zahlen | ✓ PASS |
|
||||
| type-check / lint / Biome | `pnpm type-check`, `pnpm lint`, `pnpm --filter @tessera/web exec biome lint .` | 4/4, 5/5, "Found 53 warnings" | ✓ PASS |
|
||||
|
||||
### Requirements Coverage
|
||||
|
||||
Kein separates REQUIREMENTS.md fuer Quick-Tasks; Abdeckung erfolgt ueber die elf D-Nummern im Plan-Frontmatter (`<source_audit>`), alle als COVERED gefuehrt und hier gegengeprueft — kein Widerspruch gefunden ausser dem oben genannten Gap zu D-08/T-DHH-08 (stiller Falschwert).
|
||||
|
||||
### Anti-Patterns Found
|
||||
|
||||
| File | Line | Pattern | Severity | Impact |
|
||||
|------|------|---------|----------|--------|
|
||||
| `apps/api/src/proxmox/proxmox-normalize.ts` | 240-243 | Aggregation verschluckt fehlenden Teilwert (`sumOrNull`) | 🛑 Blocker (verletzt explizites must-have) | PMG "Spam"/"Viren"-Zahl kann eine unvollstaendige, aber vertrauenswuerdig aussehende Zahl zeigen statt "unbekannt" |
|
||||
| — | — | Keine TBD/FIXME/XXX in den neuen Dateien gefunden | ℹ️ Info | — |
|
||||
| `apps/web/.../page.tsx` | 54 | "Jetzt aktualisieren"-Knopf wird JEDEM Nutzer mit Modulzugriff gezeigt, `POST servers/:id/poll` ist aber `@Roles(ADMIN, SUPER_ADMIN)`; Fehler wird mit `.catch(() => undefined)` still verschluckt | ⚠️ Warning (UX, keine Sicherheitsluecke — Backend blockt korrekt) | Normale Nutzer sehen einen Knopf, der bei ihnen wirkungslos bleibt, ohne Rueckmeldung |
|
||||
|
||||
## Human Verification Required
|
||||
|
||||
Diese Punkte kann kein automatisierter Check abschliessend pruefen — teils weil kein echter Proxmox-Server in dieser Umgebung erreichbar ist (vom Auftrag selbst so benannt), teils weil es sich um visuelles/Browser-Verhalten handelt.
|
||||
|
||||
### 1. Modulseite im Browser (vom Plan als `<human-check>` in Aufgabe 6 vorgesehen)
|
||||
|
||||
**Test:** `/modules/proxmox` oeffnen: ohne Server pruefen, dass der ruhige Hinweis erscheint; danach in den Einstellungen einen Server anlegen und pruefen, dass er in der Liste auftaucht; einen absichtlich falschen Zugang eintragen und pruefen, dass Klartext statt einer leeren Flaeche erscheint.
|
||||
**Expected:** Ruhiger Leerzustand, danach korrekte Anzeige, dann Klartext-Fehlermeldung.
|
||||
**Why human:** Erfordert echten Browser-Durchlauf; die laufenden Container wurden fuer diese Verifikation bewusst nicht neu gebaut (Auftragsvorgabe), ein visueller Check ist damit nicht ohne Weiteres moeglich.
|
||||
|
||||
### 2. Annahmen A2/A3/A5 gegen echte PVE-/PBS-/PMG-Server
|
||||
|
||||
**Test:** Cookie-Namen (`PBSAuthCookie`/`PMGAuthCookie`), PBS-Belegungs-/Snapshot-Feldnamen und PMG-Statistikfelder gegen einen echten Server pruefen.
|
||||
**Expected:** Die in `TICKET_COOKIE_NAME`/`PBS_USAGE_FIELDS`/`PBS_SNAPSHOT_FIELDS`/`PMG_STATS_FIELDS` hinterlegten Namen stimmen, oder werden an der jeweils benannten EINEN Stelle nachgezogen.
|
||||
**Why human:** Kein PVE/PBS/PMG-Server in dieser Umgebung erreichbar — vom Plan selbst so benannt und in `user_setup` dokumentiert, keine Verifikationsluecke dieser Pruefung.
|
||||
|
||||
## Gaps Summary
|
||||
|
||||
Ein konkreter, durch Code und einen eigenen Nachrechenlauf bestaetigter Gap: `normalizePmg()`s `sumOrNull()`-Hilfsfunktion behandelt einen fehlenden Teilwert (`spamcount_out`/`viruscount_out` bzw. deren `_in`-Gegenstuecke) als `0` statt die kombinierte Summe als `null`/"unbekannt" zu markieren. Das widerspricht direkt dem im Plan-Frontmatter (`must_haves.truths`) UND im eigenen Code-Kommentar ("ein still falscher Wert waere schlimmer als ein ehrliches unbekannt") formulierten Anspruch. Da PMG-Feldnamen die am wenigsten abgesicherte Annahme des gesamten Auftrags sind (Annahme A5, reiner Forenbeleg), ist genau dieses Szenario — ein Teilfeld feuert, das andere heisst anders — nicht hypothetisch, sondern der wahrscheinlichste erste Fehlerfall beim echten Test durch den Nutzer. Kein Test in `proxmox-normalize.spec.ts` deckt den Fall "nur eine Haelfte des Paares vorhanden" ab; alle vorhandenen Tests pruefen entweder "beide vorhanden" oder "beide fehlen".
|
||||
|
||||
Alle uebrigen acht Wahrheiten aus dem Plan sind vollstaendig verifiziert, mehrfach durch automatisierte Tests UND durch eigene Stichproben (Live-RLS-Abfrage gegen die tatsaechliche Dev-Datenbank, Grep-Nachzaehlung der Mandantenbindung, direkte Pruefung des Nur-Lesen-Riegels, manuelles Nachrechnen der Klammertiefen-Logik). Alle sieben Commits, alle 24 im Frontmatter genannten Dateien und alle gemessenen Torzahlen (1311/1311 API-Tests, 708/708 Web-Tests, 4/4 type-check, 5/5 lint, exakt 53 Biome-Warnungen) wurden unabhaengig nachvollzogen und stimmen exakt mit der SUMMARY ueberein.
|
||||
|
||||
---
|
||||
|
||||
_Verified: 2026-09-23T14:58Z_
|
||||
_Verifier: Claude (gsd-verifier)_
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
---
|
||||
phase: quick
|
||||
plan: 260923-ku6
|
||||
type: quick
|
||||
autonomous: true
|
||||
requirements: []
|
||||
---
|
||||
|
||||
# Quick Task 260923-ku6: Drei Nachbesserungen aus dem Browser-Rundgang (Proxmox-Modul)
|
||||
|
||||
## Objective
|
||||
|
||||
Drei im Browser-Rundgang zu Quick-Task 260923-dhh gefundene Fehler beheben, ohne den
|
||||
Funktionsumfang sonst zu veraendern:
|
||||
|
||||
1. „Verbindung testen" prueft den gespeicherten Stand statt der Formularwerte.
|
||||
2. Ein frisch angelegter, noch nie abgefragter Server zeigt faelschlich die Sammelmeldung
|
||||
„Ein unerwarteter Fehler ist aufgetreten" statt eines ruhigen „noch keine Abfrage"-Zustands.
|
||||
3. Die CSS-Klasse `uppercase` faerbt in der Modulseiten-Zeile die ganze Zeile (inkl. Adresse)
|
||||
gross statt nur das Produktkuerzel.
|
||||
|
||||
## Context
|
||||
|
||||
- Quelle: menschlicher Browser-Rundgang zu `.planning/quick/260923-dhh-proxmox-modul-pve-pbs-und-pmg-anbinden-n/260923-dhh-PLAN.md`.
|
||||
- Betroffene Dateien: `apps/api/src/proxmox/proxmox.controller.ts`, `apps/api/src/proxmox/proxmox.service.ts`,
|
||||
`apps/api/src/proxmox/dto/proxmox-server.dto.ts`, `apps/web/src/lib/proxmox-api.ts`,
|
||||
`apps/web/src/app/(portal)/modules/proxmox/settings/components/ServerForm.tsx`,
|
||||
`apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.tsx`,
|
||||
`apps/web/src/messages/de.json`, `apps/web/src/messages/en.json`.
|
||||
|
||||
## Tasks
|
||||
|
||||
### Task 1: Verbindungstest prueft Formularwerte statt gespeicherten Stand (Befund 1)
|
||||
|
||||
<task type="auto">
|
||||
<files>
|
||||
apps/api/src/proxmox/dto/proxmox-server.dto.ts
|
||||
apps/api/src/proxmox/proxmox.service.ts
|
||||
apps/api/src/proxmox/proxmox.controller.ts
|
||||
apps/api/src/proxmox/proxmox.service.spec.ts
|
||||
apps/web/src/lib/proxmox-api.ts
|
||||
apps/web/src/app/(portal)/modules/proxmox/settings/components/ServerForm.tsx
|
||||
apps/web/src/app/(portal)/modules/proxmox/settings/components/ServerForm.test.tsx
|
||||
</files>
|
||||
<action>
|
||||
Backend: neues `TestProxmoxServerDto` (alle Felder optional, wie `UpdateProxmoxServerDto`).
|
||||
`POST servers/:id/test` nimmt diesen Body entgegen und mischt ihn mit dem gespeicherten
|
||||
Server: pro Feld gilt „im Formular gesendet und nicht leer -> Formularwert, sonst
|
||||
gespeicherter Wert" (Geheimnisfelder: nicht gesendet/leer -> gespeicherter, verschluesselter
|
||||
Wert bleibt bestehen und wird wie ueblich entschluesselt). Neue Route `POST servers/test`
|
||||
(ohne `:id`) fuer die Neuanlage — testet ausschliesslich mit den Formularwerten, ohne
|
||||
gespeicherten Fallback. Beide Routen rufen denselben privaten Merge-Baustein auf; dieser
|
||||
wird per Unit-Test abgedeckt (leeres Geheimnisfeld -> gespeicherter Wert bleibt; gefuelltes
|
||||
Geheimnisfeld -> neuer Wert greift; abgeschaltete Zertifikatspruefung im Formular wird
|
||||
uebernommen). Zugangsdaten weiterhin nicht in Log/Antwort (bestehende Riegel unveraendert).
|
||||
Frontend: `testServer`/neue `testDraftServer`-Funktion senden immer den vollstaendigen
|
||||
aktuellen Formularstand. `ServerForm` zeigt den Testen-Knopf immer (nicht nur nach dem
|
||||
Speichern) und waehlt je nach `savedServer` die passende Funktion.
|
||||
</action>
|
||||
<verify>cd apps/api && pnpm vitest run src/proxmox/proxmox.service.spec.ts && cd ../web && pnpm vitest run src/app/\(portal\)/modules/proxmox/settings/components/ServerForm.test.tsx</verify>
|
||||
<done>Ein Test zeigt: gespeicherter Server mit im Formular abgeschalteter Zertifikatspruefung
|
||||
-> Testergebnis beruecksichtigt die abgeschaltete Pruefung (nicht mehr `zertifikat`-Fehler).
|
||||
Ein zweiter Test zeigt: leer gelassenes Geheimnisfeld nutzt weiterhin den gespeicherten Wert.
|
||||
Der Testen-Knopf funktioniert auch ohne gespeicherten Server.</done>
|
||||
</task>
|
||||
|
||||
### Task 2: Ruhiger Zustand fuer "noch nie abgefragt" (Befund 2)
|
||||
|
||||
<task type="auto">
|
||||
<files>
|
||||
apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.tsx
|
||||
apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.test.tsx
|
||||
apps/web/src/messages/de.json
|
||||
apps/web/src/messages/en.json
|
||||
</files>
|
||||
<action>
|
||||
Neuer Uebersetzungsschluessel `proxmox.card.notPolledYet` (DE/EN), der auf den Knopf
|
||||
„Jetzt aktualisieren" verweist. In `ServerCard`: wenn `status.lastPolledAt === null` (noch
|
||||
keine Abfrage gelaufen), erscheint dieser ruhige Hinweis statt des Fehlerblocks — auch wenn
|
||||
`status.reachable` false ist (Zustand direkt nach dem Anlegen). Die bestehenden
|
||||
Fehlermeldungen (inkl. `unbekannt`) bleiben fuer den Fall `lastPolledAt !== null &&
|
||||
!reachable` unveraendert.
|
||||
</action>
|
||||
<verify>cd apps/web && pnpm vitest run src/app/\(portal\)/modules/proxmox/components/ServerCard.test.tsx</verify>
|
||||
<done>Ein Test zeigt: Status mit `lastPolledAt: null, reachable: false, errorKind: null`
|
||||
zeigt den ruhigen Hinweistext und NICHT die Meldung "Ein unerwarteter Fehler ist
|
||||
aufgetreten". Bestehende Fehlermeldungs-Tests bleiben gruen.</done>
|
||||
</task>
|
||||
|
||||
### Task 3: uppercase nur auf Produktkuerzel (Befund 3)
|
||||
|
||||
<task type="auto">
|
||||
<files>
|
||||
apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.tsx
|
||||
</files>
|
||||
<action>
|
||||
`uppercase` von der Zeile auf ein `<span>` um `server.productType` verschieben; die Adresse
|
||||
bleibt unveraendert dargestellt.
|
||||
</action>
|
||||
<verify>cd apps/web && pnpm vitest run src/app/\(portal\)/modules/proxmox/components/ServerCard.test.tsx</verify>
|
||||
<done>Adresse erscheint in der Modulseiten-Zeile nicht mehr grossgeschrieben, Produktkuerzel weiterhin schon.</done>
|
||||
</task>
|
||||
|
||||
## Gesamtverifikation
|
||||
|
||||
Nach allen drei Aufgaben: `pnpm --filter api test`, `pnpm --filter web test`,
|
||||
`pnpm type-check`, `pnpm lint`, `pnpm --filter web exec biome check .` (Warnungszahl exakt
|
||||
53) muessen unveraendert/gruen sein, keine Container-Neubauten, keine Browser-Pruefung.
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
---
|
||||
phase: quick
|
||||
plan: 260923-ku6
|
||||
subsystem: ui
|
||||
tags: [nestjs, next.js, proxmox, class-validator, vitest, next-intl, biome]
|
||||
|
||||
requires:
|
||||
- phase: 260923-dhh
|
||||
provides: Proxmox-Modul (PVE/PBS/PMG anbinden, Verbindungstest, Modulseite)
|
||||
provides:
|
||||
- Verbindungstest prueft Formularwerte statt gespeicherten Stand (neue Route POST servers/test, TestProxmoxServerDto, resolveEffectiveTestServer-Merge)
|
||||
- Ruhiger "noch nicht abgefragt"-Zustand auf der Modulseite statt Sammelfehlermeldung
|
||||
- uppercase-Klasse nur noch auf dem Produktkuerzel, nicht mehr auf der Adresse
|
||||
affects: [proxmox]
|
||||
|
||||
actuals:
|
||||
tokens: 9700
|
||||
tasks: 3
|
||||
commits: 2
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Formular-vs-gespeichert-Merge fuer Verbindungstests: normale Felder folgen dem Formular (auch geleert), Geheimnisfelder folgen der 'leer -> gespeicherten Wert behalten'-Regel, weil das Formular Geheimnisse beim Laden nie vorbefuellt"
|
||||
|
||||
key-files:
|
||||
created: []
|
||||
modified:
|
||||
- apps/api/src/proxmox/dto/proxmox-server.dto.ts
|
||||
- apps/api/src/proxmox/proxmox.controller.ts
|
||||
- apps/api/src/proxmox/proxmox.service.ts
|
||||
- apps/api/src/proxmox/proxmox.service.spec.ts
|
||||
- apps/web/src/lib/proxmox-api.ts
|
||||
- "apps/web/src/app/(portal)/modules/proxmox/settings/components/ServerForm.tsx"
|
||||
- "apps/web/src/app/(portal)/modules/proxmox/settings/components/ServerForm.test.tsx"
|
||||
- "apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.tsx"
|
||||
- "apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.test.tsx"
|
||||
- apps/web/src/messages/de.json
|
||||
- apps/web/src/messages/en.json
|
||||
|
||||
key-decisions:
|
||||
- "Verbindungstest-Route POST servers/:id/test nimmt jetzt einen optionalen Body (TestProxmoxServerDto) entgegen; neue Route POST servers/test (ohne :id) deckt die Neuanlage ab, ueberschneidet sich nicht mit servers/:id/test (unterschiedliche Segmentzahl)"
|
||||
- "Geheimnisfelder behalten beim Test die 'leer -> gespeicherten Wert' Sonderregel, alle anderen Felder folgen strikt dem gesendeten Formularstand (auch wenn absichtlich geleert)"
|
||||
- "Befund 2+3 in einem Commit, weil beide Aenderungen in derselben Datei (ServerCard.tsx) liegen"
|
||||
|
||||
requirements-completed: []
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "Verbindungstest prueft Formularwerte (Zertifikatspruefung, neues Geheimnis) statt des gespeicherten Stands; leer gelassenes Geheimnisfeld nutzt weiterhin den gespeicherten Wert; Test funktioniert auch bei der Neuanlage ohne gespeicherten Server"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "apps/api/src/proxmox/proxmox.service.spec.ts#Nachbesserung Befund 1: testConnection prueft die im Formular abgeschaltete Zertifikatspruefung..."
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "apps/api/src/proxmox/proxmox.service.spec.ts#Nachbesserung Befund 1: ein im Formular NEU eingetipptes Token-Geheimnis wird getestet..."
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "apps/api/src/proxmox/proxmox.service.spec.ts#Nachbesserung Befund 1: leer gelassenes Geheimnisfeld im Formular nutzt weiterhin das gespeicherte Token-Geheimnis"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "apps/api/src/proxmox/proxmox.service.spec.ts#Nachbesserung Befund 1: testDraftConnection testet einen noch nicht gespeicherten Server..."
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "apps/web/src/app/(portal)/modules/proxmox/settings/components/ServerForm.test.tsx#Nachbesserung Befund 1: bei der Neuanlage ... steht der Testen-Knopf zur Verfuegung..."
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "apps/web/src/app/(portal)/modules/proxmox/settings/components/ServerForm.test.tsx#Nachbesserung Befund 1: der Test prueft die im Formular abgeschaltete Zertifikatspruefung..."
|
||||
status: pass
|
||||
human_judgment: true
|
||||
rationale: "Browser-Pruefung des tatsaechlichen Verhaltens macht der Orchestrator danach (per Auftrag ausgeschlossen aus diesem Lauf)"
|
||||
- id: D2
|
||||
description: "Frisch angelegter, noch nie abgefragter Server zeigt einen ruhigen Hinweis statt der Sammelfehlermeldung 'Ein unerwarteter Fehler ist aufgetreten'"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.test.tsx#Nachbesserung Befund 2: ein frisch angelegter, noch nie abgefragter Server..."
|
||||
status: pass
|
||||
human_judgment: true
|
||||
rationale: "Browser-Pruefung des tatsaechlichen Verhaltens macht der Orchestrator danach (per Auftrag ausgeschlossen aus diesem Lauf)"
|
||||
- id: D3
|
||||
description: "Adresse in der Modulseiten-Zeile nicht mehr grossgeschrieben, nur noch das Produktkuerzel"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.test.tsx#Nachbesserung Befund 3: die Adresse bleibt unveraendert dargestellt..."
|
||||
status: pass
|
||||
human_judgment: false
|
||||
|
||||
duration: 45min
|
||||
completed: 2026-09-23
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Quick Task 260923-ku6: Drei Nachbesserungen aus dem Browser-Rundgang (Proxmox-Modul) Summary
|
||||
|
||||
**Verbindungstest folgt jetzt dem Formular statt dem gespeicherten Server, ein frisch angelegter Server zeigt einen ruhigen "noch nicht abgefragt"-Hinweis statt einer falschen Fehlermeldung, und die Adresse in der Modulseiten-Zeile ist nicht mehr grossgeschrieben.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** ~45 min
|
||||
- **Tasks:** 3
|
||||
- **Files modified:** 11
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- **Befund 1:** `POST servers/:id/test` prueft jetzt den aktuellen Formularstand (Zertifikatspruefung, Token-/Passwort-Geheimnis, Adresse, Zugangsart) statt blind des gespeicherten Servers; neue Route `POST servers/test` deckt denselben Test waehrend der Neuanlage ab, wo es noch keinen gespeicherten Server gibt. Geheimnisfelder behalten die Sonderregel "leer gelassen -> gespeicherten Wert weiterverwenden", weil `ServerForm` sie beim Laden nie aus der Datenbank vorbefuellt.
|
||||
- **Befund 2:** Ein frisch angelegter, noch nie abgefragter Server (`status.lastPolledAt === null`) zeigt einen ruhigen Hinweistext, der auf "Jetzt aktualisieren" verweist, statt der Sammelmeldung "Ein unerwarteter Fehler ist aufgetreten". Echte Fehlermeldungen bleiben fuer bereits abgefragte, aber nicht erreichbare Server unveraendert.
|
||||
- **Befund 3:** Die `uppercase`-Klasse sitzt jetzt nur noch auf dem Produktkuerzel (`<span>`), nicht mehr auf der ganzen Statuszeile — die Adresse erscheint wieder wie eingegeben.
|
||||
|
||||
## Task Commits
|
||||
|
||||
1. **Task 1: Verbindungstest prueft Formularwerte statt gespeicherten Stand (Befund 1)** - `710034c` (fix)
|
||||
2. **Task 2+3: Ruhiger "noch nicht abgefragt"-Zustand und Adresse ohne Grossschreibung (Befund 2+3)** - `f1bb7f7` (fix)
|
||||
|
||||
_Beide Aufgaben von Befund 2 und 3 liegen in derselben Datei (`ServerCard.tsx`) und wurden deshalb in einem Commit zusammengefasst — Begruendung steht in der Commit-Nachricht._
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `apps/api/src/proxmox/dto/proxmox-server.dto.ts` - neues `TestProxmoxServerDto`
|
||||
- `apps/api/src/proxmox/proxmox.controller.ts` - `test` nimmt jetzt einen Body entgegen, neue Route `testDraft` (`POST servers/test`)
|
||||
- `apps/api/src/proxmox/proxmox.service.ts` - `resolveEffectiveTestServer`-Merge, `testConnection` mit `dto`-Parameter, neue `testDraftConnection`
|
||||
- `apps/api/src/proxmox/proxmox.service.spec.ts` - 5 neue Tests fuer Befund 1
|
||||
- `apps/web/src/lib/proxmox-api.ts` - `testServer` nimmt jetzt ein Payload, neue `testDraftServer`
|
||||
- `apps/web/src/app/(portal)/modules/proxmox/settings/components/ServerForm.tsx` - Testen-Knopf immer sichtbar, sendet immer den Formularstand
|
||||
- `apps/web/src/app/(portal)/modules/proxmox/settings/components/ServerForm.test.tsx` - alte "kein Knopf vor dem Speichern"-Erwartung durch das neue, gewuenschte Verhalten ersetzt, 2 neue Tests
|
||||
- `apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.tsx` - ruhiger "noch nicht abgefragt"-Zustand, `uppercase` nur auf dem Produktkuerzel
|
||||
- `apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.test.tsx` - 2 neue Tests
|
||||
- `apps/web/src/messages/de.json`, `apps/web/src/messages/en.json` - neuer Schluessel `proxmox.card.notPolledYet`
|
||||
|
||||
## Decisions Made
|
||||
|
||||
- Geheimnisfelder (`tokenSecret`/`password`) folgen beim Testen weiterhin der bestehenden "leer -> gespeicherten Wert behalten"-Regel aus `updateServer`, weil `ServerForm` sie beim Laden absichtlich nie vorbefuellt (kein Klartext-Leak). Alle anderen Felder (`tokenId`, `username`, `baseUrl`, `authMethod`, `productType`, `tlsRejectUnauthorized`) folgen strikt dem gesendeten Formularwert, auch wenn er absichtlich geleert wurde — diese Felder sind beim Laden immer vorbefuellt, ein leeres Feld ist dort also eine bewusste Nutzeraktion.
|
||||
- Neue Route `POST servers/test` statt eines Sonderwerts fuer `:id` (z. B. `new`), weil sie sich mit `servers/:id/test` nicht ueberschneidet (zwei vs. drei Segmente) und dadurch keine Routen-Reihenfolge-Abhaengigkeit entsteht.
|
||||
- `buildTestPayload()` in `ServerForm.tsx` sendet bewusst kein `name`-Feld, weil `TestProxmoxServerDto` `@IsNotEmpty()` auf `name` erbt und ein waehrend der Neuanlage noch leeres Namensfeld sonst jeden Testklick mit 400 blockiert hätte.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
None - plan executed exactly as written (PLAN.md `.planning/quick/260923-ku6-drei-nachbesserungen-aus-dem-browser-run/260923-ku6-PLAN.md`).
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
- Die Aenderung an `ServerCard.tsx` (`status && status.lastPolledAt && !status.reachable`) loeste eine neue Biome-Warnung (`lint/complexity/useOptionalChain`) aus, die die geforderte exakte Warnungszahl (53) auf 54 angehoben haette. Behoben durch Umformulierung zu `status?.lastPolledAt && !status.reachable` (TypeScript narrowt `status` fuer den Rest des Ausdrucks korrekt nach) — Warnungszahl danach wieder exakt 53.
|
||||
- Der bestehende Test "ohne gespeicherten Server (Neuanlage) gibt es keinen Verbindung-testen-Knopf" widersprach direkt der geforderten Korrektur aus Befund 1 (Testen soll bei der Neuanlage funktionieren) und wurde durch einen Test mit dem neuen, gewuenschten Verhalten ersetzt.
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None - keine externe Konfiguration noetig.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
Alle drei Befunde behoben, alle Tore gruen (api 1316/1316, web 712/712, type-check 4/4, lint 5/5, Biome `apps/web` exakt 53 Warnungen). Browser-Pruefung der tatsaechlichen UI macht der Orchestrator im Anschluss, wie im Auftrag verlangt.
|
||||
|
||||
---
|
||||
*Phase: quick-260923-ku6*
|
||||
*Completed: 2026-09-23*
|
||||
+221
@@ -0,0 +1,221 @@
|
||||
---
|
||||
phase: quick
|
||||
plan: 260923-le6
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- apps/api/src/proxmox/proxmox-normalize.ts
|
||||
- apps/api/src/proxmox/proxmox-normalize.spec.ts
|
||||
- apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.tsx
|
||||
- apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.test.tsx
|
||||
- apps/web/src/messages/de.json
|
||||
- apps/web/src/messages/en.json
|
||||
- apps/web/src/app/(portal)/modules/proxmox/page.tsx
|
||||
- apps/web/src/app/(portal)/modules/proxmox/proxmox-page-roles.test.tsx
|
||||
autonomous: true
|
||||
requirements: []
|
||||
|
||||
estimate:
|
||||
tokens: 45000
|
||||
raw_tokens: 45000
|
||||
tasks: 3
|
||||
confidence: low
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "PMG: fehlt von einem Paar (spamcount_in/spamcount_out bzw. viruscount_in/viruscount_out) genau eine Haelfte, ist spamCount bzw. virusCount null (Anzeige 'unbekannt') — in beide Richtungen, nie eine Teilsumme"
|
||||
- "PMG: sind beide Haelften vorhanden, bleibt die Summe wie bisher (10+2 -> 12); fehlen beide, bleibt null"
|
||||
- "Auf der Proxmox-Modulseite sehen nur ADMIN und SUPER_ADMIN den Knopf 'Jetzt aktualisieren'; USER (und ein noch nicht geladener Benutzer) sehen ihn nicht"
|
||||
- "Ein noch nie abgefragter Server zeigt Admins weiterhin 'Noch keine Abfrage gelaufen. Klicken Sie oben auf „Jetzt aktualisieren“.'; Nicht-Admins sehen stattdessen einen Text ohne Verweis auf den Knopf"
|
||||
- "Sonst aendert sich an der Modulseite nichts (Festlegung: kein Umbau, keine zusaetzlichen Details/Statusfarben)"
|
||||
artifacts:
|
||||
- path: apps/api/src/proxmox/proxmox-normalize.ts
|
||||
provides: "sumOrNull liefert null, sobald ein Teilwert null ist"
|
||||
- path: apps/api/src/proxmox/proxmox-normalize.spec.ts
|
||||
provides: "Testfaelle 'nur eine Haelfte vorhanden -> null' fuer Spam und Viren, beide Richtungen"
|
||||
- path: apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.tsx
|
||||
provides: "optionale Eigenschaft isAdmin (Vorgabe false), waehlt den Hinweistext fuer noch nie abgefragte Server"
|
||||
- path: apps/web/src/app/(portal)/modules/proxmox/page.tsx
|
||||
provides: "Aktualisieren-Knopf nur fuer Admins, reicht isAdmin an ServerCard weiter"
|
||||
- path: apps/web/src/app/(portal)/modules/proxmox/proxmox-page-roles.test.tsx
|
||||
provides: "Seitentest: Knopf sichtbar fuer ADMIN/SUPER_ADMIN, unsichtbar fuer USER/null"
|
||||
key_links:
|
||||
- from: "apps/web/src/app/(portal)/modules/proxmox/page.tsx"
|
||||
to: "ServerCard"
|
||||
via: "isAdmin={isAdmin}"
|
||||
pattern: "isAdmin=\\{isAdmin\\}"
|
||||
- from: "apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.tsx"
|
||||
to: "apps/web/src/messages/de.json proxmox.card.notPolledYetAutomatic"
|
||||
via: "t('card.notPolledYetAutomatic')"
|
||||
pattern: "card\\.notPolledYetAutomatic"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Zwei Befunde aus der Abnahme des Proxmox-Moduls beheben, sonst nichts:
|
||||
|
||||
1. **PMG-Teilsumme (API):** `sumOrNull(a, b)` in `apps/api/src/proxmox/proxmox-normalize.ts` addiert heute einen fehlenden Teilwert als 0, sobald nur EINE Haelfte null ist. Dadurch zeigt die Seite z. B. „Spam: 10“ als vollstaendige Tageszahl, obwohl `spamcount_out` fehlte. Das ist der in `.planning/quick/260923-dhh-proxmox-modul-pve-pbs-und-pmg-anbinden-n/260923-dhh-VERIFICATION.md` (Wahrheit 7, Blocker) belegte Fehler. Kuenftig ist die Summe null, sobald ein Teilwert null ist.
|
||||
2. **Aktualisieren-Knopf nur fuer Admins (Web):** Der Knopf „Jetzt aktualisieren“ erscheint heute bei allen, die Zugriff auf das Modul haben. Der Endpunkt `POST servers/:id/poll` verlangt aber `@Roles(Role.ADMIN, Role.SUPER_ADMIN)` (`apps/api/src/proxmox/proxmox.controller.ts:89-90`), deshalb passiert beim Klick fuer alle anderen nichts. Kuenftig sehen nur ADMIN/SUPER_ADMIN den Knopf. Der Hinweis „Noch keine Abfrage gelaufen. Klicken Sie oben auf …“ darf Nicht-Admins nicht mehr auf einen Knopf verweisen, den sie nicht sehen.
|
||||
|
||||
**Festlegung (locked, vom Nutzer):** KEIN Umbau der Proxmox-Modulseite. Der Nutzer hat seinen Wunsch nach mehr Details bzw. Statusfarben ausdruecklich zurueckgezogen. Nur diese zwei Korrekturen, keine weiteren Anzeige-, Layout- oder Textaenderungen.
|
||||
|
||||
Hinweis zum Zuschnitt: Tracer-first entfaellt (wie `--no-tracer`). Es handelt sich um zwei voneinander unabhaengige Fehlerkorrekturen, jede in genau einer Schicht, ohne neue Architektur, die ein Durchstich absichern muesste. Aufgabe 1 (API) und Aufgabe 2/3 (Web) beruehren keine gemeinsamen Dateien. Aufgabe 3 braucht die Eigenschaft `isAdmin` aus Aufgabe 2.
|
||||
|
||||
Purpose: Das Modul soll keinen still falschen, plausibel aussehenden Wert zeigen (eigener Anspruch in `proxmox-normalize.ts` und `ServerCard.tsx`: „ein still falscher Wert waere schlimmer als ein ehrliches unbekannt“). Ausserdem soll kein Knopf erscheinen, der fuer den Betrachter wirkungslos ist.
|
||||
Output: korrigierte `sumOrNull` samt Tests; `ServerCard` mit `isAdmin`-Eigenschaft und einem zweiten Hinweistext in de/en; Modulseite, die den Knopf nur Admins zeigt, samt neuem Seitentest.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/gsd-core/workflows/execute-plan.md
|
||||
@~/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/STATE.md
|
||||
@./CLAUDE.md
|
||||
@.planning/quick/260923-dhh-proxmox-modul-pve-pbs-und-pmg-anbinden-n/260923-dhh-VERIFICATION.md
|
||||
|
||||
@apps/api/src/proxmox/proxmox-normalize.ts
|
||||
@apps/api/src/proxmox/proxmox-normalize.spec.ts
|
||||
@apps/web/src/app/(portal)/modules/proxmox/page.tsx
|
||||
@apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.tsx
|
||||
@apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.test.tsx
|
||||
@apps/web/src/app/(portal)/modules/tender-radar/settings/settings-roles.test.tsx
|
||||
|
||||
<interfaces>
|
||||
Bereits im Code vorhanden (vom Planer gelesen, nicht erneut suchen):
|
||||
|
||||
- `apps/web/src/lib/stores/auth-store.ts`: `useAuthStore((s) => s.user)`, `user.role` ist `'SUPER_ADMIN' | 'ADMIN' | 'USER'`.
|
||||
- `page.tsx` berechnet BEREITS `const isAdmin = user?.role === 'ADMIN' || user?.role === 'SUPER_ADMIN';` (Zeile 18-19) und nutzt es fuer den Einstellungs-Link. Das ist das etablierte Muster; es wird kein neuer Mechanismus eingefuehrt.
|
||||
- `apps/web/src/lib/proxmox-api.ts`: `listServers(): Promise<ProxmoxServer[]>`, `pollServer(id: string): Promise<ProxmoxTestResult>`, Typ `ProxmoxServer` (inkl. `isActive`, `pollIntervalMin`, `status: ProxmoxServerStatus | null`).
|
||||
- `ServerCard` wird ausschliesslich in `page.tsx:99` verwendet (per grep geprueft).
|
||||
- Test-Muster fuer Rollen: `settings-roles.test.tsx` mockt `@/lib/stores/auth-store` mit `useAuthStore: (selector) => mockAuthStore(selector)` und setzt je Fall `mockAuthStore.mockImplementation((sel) => sel({ user }))`, ausserdem `next/link` als `<a>` und `next-intl` mit handgeschriebener Uebersetzungstabelle.
|
||||
- Nachrichtendateien: nur `apps/web/src/messages/de.json` und `apps/web/src/messages/en.json`. Namensraum `proxmox.card` (de.json ab Zeile 712). `umlaut-guard.spec.ts` prueft de.json auf Ersatzschreibungen (ae/oe/ue/ss) — neue deutsche Texte brauchen echte Umlaute.
|
||||
- Abfragetakt: `ProxmoxServer.pollIntervalMin` Vorgabe 5, erlaubt 1–1440 (`dto/proxmox-server.dto.ts` `@Min(1) @Max(1440)`). Der Planer laeuft je Mandant im kleinsten Intervall der aktiven Server (`proxmox-scheduler.service.ts`). Inaktive Server (`isActive: false`) werden nicht automatisch abgefragt.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Aufgabe 1: PMG-Summe wird null, sobald eine Haelfte fehlt (sumOrNull)</name>
|
||||
<files>apps/api/src/proxmox/proxmox-normalize.ts, apps/api/src/proxmox/proxmox-normalize.spec.ts</files>
|
||||
<read_first>apps/api/src/proxmox/proxmox-normalize.ts (Zeilen 222-270), apps/api/src/proxmox/proxmox-normalize.spec.ts (Zeilen 185-240)</read_first>
|
||||
<behavior>
|
||||
- Nur `spamcount_in: 10` vorhanden, `spamcount_out` fehlt -> `spamCount` ist `null` (heute faelschlich 10)
|
||||
- Nur `spamcount_out: 2` vorhanden, `spamcount_in` fehlt -> `spamCount` ist `null`
|
||||
- Nur `viruscount_in: 1` vorhanden, `viruscount_out` fehlt -> `virusCount` ist `null`
|
||||
- Nur `viruscount_out: 3` vorhanden, `viruscount_in` fehlt -> `virusCount` ist `null`
|
||||
- Eine Haelfte vorhanden, die andere ist nicht lesbar (z. B. `spamcount_out: 'abc'`, `readNumber` liefert null) -> `spamCount` ist `null`
|
||||
- Unabhaengigkeit der Paare: Spam unvollstaendig, Viren vollstaendig (`viruscount_in: 1, viruscount_out: 0`) -> `spamCount` null, `virusCount` 1; `countIn`/`countOut` bleiben unberuehrt
|
||||
- Unveraendert gruen: die bestehenden Tests „beide vorhanden -> 12/1“, „beide fehlen -> null“, „HTML -> antwortform“
|
||||
</behavior>
|
||||
<action>
|
||||
RED: Im bestehenden `describe('normalizePmg (Aufgabe 3, <behavior>)', ...)`-Block von `proxmox-normalize.spec.ts` neue Faelle fuer jede Zeile aus `<behavior>` ergaenzen. Das geht als einzelne `it` oder als `it.each` ueber eine Tabelle {Beschreibung, data, erwartetes spamCount, erwartetes virusCount}. Jeder Testname nennt „nur eine Haelfte vorhanden -> null“ und die Richtung (in bzw. out) sowie Spam bzw. Viren. Vorhandene Tests unveraendert lassen. Der Planer hat geprueft, dass keiner das alte Verhalten festschreibt: Die vorhandenen PMG-Tests decken nur „beide vorhanden“ und „beide fehlen“ ab, und `proxmox.service.spec.ts:350-360` liefert beide Haelften (`spamcount_in: 1, spamcount_out: 0`). Test ausfuehren, die neuen Faelle muessen ROT sein. Commit `test(260923-le6): PMG-Teilsumme ohne Haelfte muss null sein`.
|
||||
|
||||
GREEN: `sumOrNull(a, b)` so aendern, dass es `null` zurueckgibt, sobald `a` ODER `b` `null` ist. Nur wenn beide Zahlen sind, wird ihre Summe zurueckgegeben. Die bisherige Ersatz-durch-Null-Addition entfaellt vollstaendig, ein fehlender Teilwert wird nie mehr als 0 behandelt. Ueber der Funktion einen kurzen deutschen Kommentar ergaenzen (Stil der Datei, ASCII-Umschreibungen wie im Rest der Datei): Eine Tageszahl aus zwei Teilwerten ist nur dann bekannt, wenn beide Teilwerte bekannt sind; eine Teilsumme saehe vollstaendig aus, waere aber still falsch (Abnahmebefund 260923-dhh, Wahrheit 7; PMG-Feldnamen sind nur Annahme A5). `normalizePmg` selbst und die Feldtabelle `PMG_STATS_FIELDS` bleiben unveraendert. Tests muessen GRUEN sein. Commit `fix(260923-le6): PMG-Summe null bei fehlendem Teilwert`.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>pnpm --filter api exec vitest run src/proxmox</automated>
|
||||
<automated>test "$(grep -v '^\s*//' apps/api/src/proxmox/proxmox-normalize.ts | grep -c '?? 0) + (')" -eq 0</automated>
|
||||
<automated>pnpm --filter api type-check</automated>
|
||||
</verify>
|
||||
<done>Alle Tests unter `apps/api/src/proxmox` gruen, darunter mindestens 5 neue Faelle „nur eine Haelfte vorhanden -> null“ (Spam in/out, Viren in/out, nicht lesbare Haelfte). Die Ersatz-durch-Null-Addition steht nicht mehr in `proxmox-normalize.ts`. API-Typpruefung ohne Fehler.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Aufgabe 2: ServerCard waehlt den Hinweistext nach Rolle (isAdmin) und zweiter Text in de/en</name>
|
||||
<files>apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.tsx, apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.test.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json</files>
|
||||
<read_first>apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.tsx (Zeilen 150-215), apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.test.tsx, apps/web/src/messages/de.json (Zeilen 706-720), apps/web/src/messages/en.json (Zeilen 706-720)</read_first>
|
||||
<behavior>
|
||||
- `isAdmin` gesetzt, Server nie abgefragt (`status.lastPolledAt === null`) -> Text „Noch keine Abfrage gelaufen. Klicken Sie oben auf „Jetzt aktualisieren“.“ (wie heute)
|
||||
- `isAdmin={false}`, Server nie abgefragt -> Text „Noch keine Abfrage gelaufen. Die Werte erscheinen nach der nächsten automatischen Abfrage.“, und nirgends in der Karte steht „Jetzt aktualisieren“
|
||||
- `isAdmin` weggelassen -> verhaelt sich wie `isAdmin={false}` (sichere Vorgabe)
|
||||
- Weiterhin in keinem der Faelle die Sammelmeldung „Unerwarteter Fehler.“
|
||||
</behavior>
|
||||
<action>
|
||||
Umsetzung der zweiten Korrektur, Teil Karte.
|
||||
|
||||
(a) Nachrichten: In `apps/web/src/messages/de.json` unter `proxmox.card`, direkt nach `notPolledYet`, den neuen Schluessel `notPolledYetAutomatic` mit dem Wert „Noch keine Abfrage gelaufen. Die Werte erscheinen nach der nächsten automatischen Abfrage.“ anlegen. Echtes „ä“ verwenden (umlaut-guard), Sie-Form bzw. unpersoenlich wie die uebrigen App-Texte. In `apps/web/src/messages/en.json` an derselben Stelle `notPolledYetAutomatic`: „No poll has run yet. The values will appear after the next automatic poll.“ Andere Schluessel nicht anfassen; `notPolledYet`, `refresh` und `refreshing` bleiben unveraendert. Begruendung der Wortwahl (Planer-Ermessen, Vorschlag aus dem Auftrag angepasst): Ein „in Kürze“ waere nicht immer wahr, denn das Intervall ist je Server von 1 bis 1440 Minuten einstellbar (`@Max(1440)`). „Nach der nächsten automatischen Abfrage“ stimmt bei jedem Intervall und verweist auf keinen Knopf.
|
||||
|
||||
(b) `ServerCard.tsx`: `ServerCardProps` um die optionale Eigenschaft `isAdmin?: boolean` erweitern und in der Funktionssignatur mit Vorgabe `false` entgegennehmen. Die sichere Vorgabe bedeutet: Wer die Eigenschaft vergisst, zeigt keinen Verweis auf einen Knopf. Im vorhandenen Zweig fuer nie abgefragte Server (`status && !status.lastPolledAt`) den Text nach `isAdmin` waehlen. Ist `isAdmin` wahr, bleibt der heutige Aufruf `t('card.notPolledYet', { refreshLabel: t('card.refresh') })` unveraendert, sonst `t('card.notPolledYetAutomatic')`. Den Kommentar „Nachbesserung Befund 2“ um einen Satz ergaenzen: Nicht-Admins sehen den Knopf nicht (der Poll-Endpunkt verlangt ADMIN/SUPER_ADMIN) und bekommen deshalb den Text ohne Knopfverweis (260923-le6). Sonst NICHTS an der Karte aendern, auch keine Formatierung unbeteiligter Zeilen (Festlegung: kein Umbau). Insbesondere kein `biome format --write` auf die ganze Datei, das wuerde unbeteiligte Zeilen umbrechen.
|
||||
|
||||
(c) `ServerCard.test.tsx`: In die `next-intl`-Mock-Tabelle `'card.notPolledYetAutomatic'` mit dem deutschen Text aus (a) aufnehmen. Den bestehenden Test „Nachbesserung Befund 2: …“ auf `render(<ServerCard server={server} isAdmin />)` umstellen; seine Erwartungen bleiben. Neue Tests fuer die Faelle aus `<behavior>` ergaenzen: `isAdmin={false}` sowie weggelassenes `isAdmin` jeweils mit Erwartung des automatischen Textes, `queryByText(/Jetzt aktualisieren/)` ist `null` und `queryByText('Unerwarteter Fehler.')` ist `null`. Zuerst die Tests schreiben und ROT sehen, dann (a)+(b) umsetzen und GRUEN sehen. Ein Commit genuegt: `fix(260923-le6): Proxmox-Karte verweist Nicht-Admins nicht auf den Aktualisieren-Knopf`.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>pnpm --filter web exec vitest run "src/app/(portal)/modules/proxmox/components/ServerCard.test.tsx" src/messages</automated>
|
||||
<automated>node -e "const d=require('./apps/web/src/messages/de.json'),e=require('./apps/web/src/messages/en.json');const a=d.proxmox.card.notPolledYetAutomatic,b=e.proxmox.card.notPolledYetAutomatic;if(!a||!b||/aktualisieren/i.test(a)||/refresh/i.test(b)||!a.includes('nächsten'))process.exit(1);if(d.proxmox.card.notPolledYet!=='Noch keine Abfrage gelaufen. Klicken Sie oben auf „{refreshLabel}“.')process.exit(2)"</automated>
|
||||
</verify>
|
||||
<done>ServerCard-Tests gruen (bestehende und neue Admin-/Nicht-Admin-Faelle); `src/messages`-Tests (umlaut-guard, Paritaet) gruen. `notPolledYetAutomatic` existiert in de und en und erwaehnt keinen Aktualisieren-Knopf. `notPolledYet` ist unveraendert.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Aufgabe 3: Modulseite zeigt „Jetzt aktualisieren“ nur ADMIN/SUPER_ADMIN, mit Seitentest</name>
|
||||
<files>apps/web/src/app/(portal)/modules/proxmox/page.tsx, apps/web/src/app/(portal)/modules/proxmox/proxmox-page-roles.test.tsx</files>
|
||||
<read_first>apps/web/src/app/(portal)/modules/proxmox/page.tsx, apps/web/src/app/(portal)/modules/tender-radar/settings/settings-roles.test.tsx (Zeilen 1-100, nur das Mock-Muster)</read_first>
|
||||
<behavior>
|
||||
- Rolle USER, eine Serverliste mit einem nie abgefragten Server -> kein Knopf mit Namen „Jetzt aktualisieren“; der Karten-Hinweis ist der automatische Text
|
||||
- Kein Benutzer geladen (`user: null`) -> kein Knopf
|
||||
- Rolle ADMIN -> Knopf „Jetzt aktualisieren“ sichtbar; der Karten-Hinweis ist der Admin-Text mit Knopfverweis
|
||||
- Rolle SUPER_ADMIN -> Knopf sichtbar
|
||||
- Leere Serverliste bei ADMIN -> weiterhin kein Knopf (bestehende Bedingung `servers.length > 0` bleibt)
|
||||
</behavior>
|
||||
<action>
|
||||
Umsetzung der zweiten Korrektur, Teil Seite.
|
||||
|
||||
(a) Neue Testdatei `apps/web/src/app/(portal)/modules/proxmox/proxmox-page-roles.test.tsx` nach dem Muster von `settings-roles.test.tsx` anlegen. Gemockt werden `@/lib/proxmox-api` (`listServers` als `vi.fn()`, der je Fall eine Liste aufloest, und `pollServer` als `vi.fn()`), `@/lib/stores/auth-store` (Selektor-Durchreichung ueber `mockAuthStore`), `next/link` (als `<a>`) und `next-intl`. Die handgeschriebene Uebersetzungstabelle enthaelt mindestens `title`, `description`, `loading`, `loadError`, `emptyState`, `card.refresh`, `card.refreshing`, `card.settingsLink`, `card.unknownValue`, `card.lastPolledLabel`, `card.notPolledYet` (mit `{refreshLabel}`-Ersetzung wie in `ServerCard.test.tsx`) und `card.notPolledYetAutomatic`. Die Seite ueber `import ProxmoxPage from './page'` rendern. Mit `waitFor`/`findByText` auf den Servernamen warten, weil `listServers` asynchron ist. Danach Knopf per `queryByRole('button', { name: 'Jetzt aktualisieren' })` bzw. `getByRole` pruefen. Ein Testfall je Zeile aus `<behavior>`; der Server im Test ist ein nie abgefragter Server (Status wie im Befund-2-Test von `ServerCard.test.tsx`: `lastPolledAt: null`, `reachable: false`, `errorKind: null`). `afterEach` mit `cleanup()` und `vi.clearAllMocks()`. Test ausfuehren, die USER- und null-Faelle muessen ROT sein.
|
||||
|
||||
(b) `page.tsx`: Die bestehende Bedingung des Aktualisieren-Knopfs (`servers !== null && servers.length > 0`) zusaetzlich an `isAdmin` knuepfen, sodass der Knopf nur fuer ADMIN/SUPER_ADMIN gerendert wird. Die vorhandene Variable `isAdmin` wiederverwenden, keinen neuen Rollen-Mechanismus einfuehren. `handleRefresh` bleibt unveraendert. An der Render-Stelle `<ServerCard server={server} />` die Eigenschaft `isAdmin={isAdmin}` weiterreichen. Den Kopfkommentar der Komponente um einen Satz ergaenzen: Der Knopf erscheint nur fuer Admins, weil `POST servers/:id/poll` `@Roles(ADMIN, SUPER_ADMIN)` verlangt; fuer andere waere er wirkungslos (260923-le6). Sonst nichts an der Seite aendern: keine neuen Texte, kein Layout, keine Import-Umsortierung. Das vorbestehende organizeImports-Signal von biome in dieser Datei bleibt unangetastet.
|
||||
|
||||
Tests GRUEN sehen. Commit `fix(260923-le6): Aktualisieren-Knopf der Proxmox-Seite nur fuer Admins`.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>pnpm --filter web exec vitest run "src/app/(portal)/modules/proxmox"</automated>
|
||||
<automated>grep -c 'isAdmin={isAdmin}' "apps/web/src/app/(portal)/modules/proxmox/page.tsx"</automated>
|
||||
<automated>pnpm --filter web type-check</automated>
|
||||
</verify>
|
||||
<done>Alle Web-Tests im Proxmox-Verzeichnis gruen (ServerCard, ServerForm, neuer Seitentest mit mindestens 5 Faellen: USER, null, ADMIN, SUPER_ADMIN, leere Liste). `page.tsx` reicht `isAdmin={isAdmin}` an `ServerCard` weiter. Web-Typpruefung ohne Fehler.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Browser -> API `POST /proxmox/servers/:id/poll` | Nicht-Admin koennte die Abfrage manuell ausloesen; die Berechtigung prueft ausschliesslich der Server (`@Roles(ADMIN, SUPER_ADMIN)`) |
|
||||
| PMG-Server -> `normalizePmg` | Fremde, nur angenommene Antwortform (Annahme A5); unvollstaendige Felder duerfen keinen falschen Wert erzeugen |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-le6-01 | Elevation of Privilege | `POST servers/:id/poll` | low | accept | Das Ausblenden des Knopfes ist reine Oberflaeche, keine Sicherheitsgrenze. Die Durchsetzung bleibt unveraendert serverseitig per `@Roles(Role.ADMIN, Role.SUPER_ADMIN)` in `proxmox.controller.ts:89-90`. Dieser Plan aendert den Controller nicht. |
|
||||
| T-le6-02 | Tampering (Integritaet der Anzeige) | `sumOrNull` in `normalizePmg` | medium | mitigate | Aufgabe 1: Summe null, sobald ein Teilwert fehlt oder unlesbar ist. Die neuen Tests decken beide Richtungen fuer Spam und Viren ab. |
|
||||
| T-le6-03 | Information Disclosure | `ServerCard` Hinweistext | low | accept | Der neue Text enthaelt keine Server- oder Zugangsdaten, nur einen statischen Hinweis. |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
Nach allen drei Aufgaben (vom Planer an der Ausgangslage 6530ae5 geprueft: alles gruen, `biome lint` sauber):
|
||||
|
||||
- `pnpm --filter api exec vitest run src/proxmox` gruen
|
||||
- `pnpm --filter web exec vitest run "src/app/(portal)/modules/proxmox" src/messages` gruen
|
||||
- `pnpm --filter api type-check` und `pnpm --filter web type-check` ohne Fehler
|
||||
- `pnpm exec biome lint apps/api/src/proxmox/proxmox-normalize.ts apps/api/src/proxmox/proxmox-normalize.spec.ts "apps/web/src/app/(portal)/modules/proxmox/page.tsx" "apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.tsx" "apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.test.tsx" "apps/web/src/app/(portal)/modules/proxmox/proxmox-page-roles.test.tsx"` ohne Befund
|
||||
- `pnpm exec biome check "apps/web/src/app/(portal)/modules/proxmox/proxmox-page-roles.test.tsx"` ohne Befund (neue Datei, voll konform)
|
||||
- `biome check` auf den fuenf VORHANDENEN Dateien: vorher 6 Befunde, alle vorbestehend (Formatierung je Datei, dazu organizeImports in `page.tsx`). Deren Anzahl darf nicht steigen. Die vorbestehenden Befunde werden nicht mit behoben, das waere fremder Diff (Festlegung: kein Umbau).
|
||||
- Keine Container-Neubauten, kein Deploy, keine Browserpruefung in diesem Plan
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Eine PMG-Antwort mit nur einer Haelfte eines Spam- oder Viren-Paares ergibt `null`, die Seite zeigt dort also „unbekannt“ statt einer Teilsumme.
|
||||
- Auf der Proxmox-Modulseite sehen nur ADMIN und SUPER_ADMIN „Jetzt aktualisieren“. Der Hinweis fuer nie abgefragte Server verweist Nicht-Admins auf die automatische Abfrage statt auf den Knopf.
|
||||
- Sonst keine sichtbare Aenderung an der Modulseite.
|
||||
- In der SUMMARY als Beobachtung vermerken, nicht beheben: Inaktive Server (`isActive: false`) werden nicht automatisch abgefragt. Fuer einen inaktiven, nie abgefragten Server stimmt der neue Nicht-Admin-Text deshalb nicht ganz. Das ist ein vorbestehender Randfall, denn auch die Karte fuer Admins beachtet `isActive` heute nicht. Er liegt ausserhalb dieses Auftrags (Festlegung: kein Umbau) und wird dem Nutzer zur Entscheidung vorgelegt.
|
||||
- In der SUMMARY vermerken, dass damit die offene Luecke (Wahrheit 7) aus `260923-dhh-VERIFICATION.md` geschlossen ist.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/quick/260923-le6-proxmox-abnahmebefunde-sumornull-null-be/260923-le6-SUMMARY.md` when done
|
||||
</output>
|
||||
+174
@@ -0,0 +1,174 @@
|
||||
---
|
||||
phase: quick
|
||||
plan: 260923-le6
|
||||
subsystem: proxmox-modul
|
||||
tags: [nestjs, next-intl, vitest, tdd, proxmox]
|
||||
|
||||
requires:
|
||||
- phase: quick-260923-dhh
|
||||
provides: "Proxmox-Modul (PVE/PBS/PMG) inklusive normalizePmg und ServerCard; Abnahmebefund Wahrheit 7 (PMG-Teilsumme) blieb offen"
|
||||
provides:
|
||||
- "sumOrNull liefert null, sobald ein Teilwert einer PMG-Summe (Spam/Viren) fehlt oder unlesbar ist — nie mehr eine Teilsumme"
|
||||
- "ServerCard zeigt Nicht-Admins fuer nie abgefragte Server einen Hinweis ohne Knopfverweis (isAdmin-Eigenschaft, Vorgabe false)"
|
||||
- "Proxmox-Modulseite zeigt den Knopf 'Jetzt aktualisieren' nur ADMIN/SUPER_ADMIN"
|
||||
affects: [proxmox-modul, dashboard-kachel-proxmox]
|
||||
|
||||
actuals:
|
||||
tokens: 4581
|
||||
tasks: 3
|
||||
commits: 4
|
||||
plan_head_before: 6530ae5
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "isAdmin?: boolean (Vorgabe false) als sichere Eigenschaft fuer UI-Elemente, deren serverseitige Aktion rollenbeschraenkt ist (uebernimmt das bestehende Muster aus page.tsx, kein neuer Mechanismus)"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- apps/web/src/app/(portal)/modules/proxmox/proxmox-page-roles.test.tsx
|
||||
modified:
|
||||
- apps/api/src/proxmox/proxmox-normalize.ts
|
||||
- apps/api/src/proxmox/proxmox-normalize.spec.ts
|
||||
- apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.tsx
|
||||
- apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.test.tsx
|
||||
- apps/web/src/messages/de.json
|
||||
- apps/web/src/messages/en.json
|
||||
- apps/web/src/app/(portal)/modules/proxmox/page.tsx
|
||||
|
||||
key-decisions:
|
||||
- "Wortwahl fuer notPolledYetAutomatic: 'nach der naechsten automatischen Abfrage' statt 'in Kuerze', weil das Poll-Intervall je Server 1-1440 Minuten einstellbar ist und 'in Kuerze' nicht immer zutraefe"
|
||||
|
||||
patterns-established:
|
||||
- "sumOrNull(a, b): null wenn a ODER b null ist (statt Ersatz-durch-Null) — Muster fuer jede zukuenftige Tageszahl aus zwei Teilwerten"
|
||||
|
||||
requirements-completed: []
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "PMG-Summe (Spam/Viren) ist null, sobald genau eine Haelfte fehlt oder unlesbar ist — in beide Richtungen (in/out), Paare unabhaengig voneinander"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "apps/api/src/proxmox/proxmox-normalize.spec.ts#nur eine Haelfte vorhanden -> null (Spam, nur spamcount_in)"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "apps/api/src/proxmox/proxmox-normalize.spec.ts#nur eine Haelfte vorhanden -> null (Spam, nur spamcount_out)"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "apps/api/src/proxmox/proxmox-normalize.spec.ts#nur eine Haelfte vorhanden -> null (Viren, nur viruscount_in)"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "apps/api/src/proxmox/proxmox-normalize.spec.ts#nur eine Haelfte vorhanden -> null (Viren, nur viruscount_out)"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "apps/api/src/proxmox/proxmox-normalize.spec.ts#eine Haelfte ist nicht lesbar -> null (Spam, spamcount_out ist Text)"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "apps/api/src/proxmox/proxmox-normalize.spec.ts#Unabhaengigkeit der Paare: Spam unvollstaendig, Viren vollstaendig"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "Auf der Proxmox-Modulseite sehen nur ADMIN/SUPER_ADMIN den Knopf 'Jetzt aktualisieren'; USER und ein noch nicht geladener Benutzer sehen ihn nicht; ServerCard verweist Nicht-Admins nicht auf den Knopf"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "apps/web/src/app/(portal)/modules/proxmox/proxmox-page-roles.test.tsx#Rolle USER: kein Knopf"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "apps/web/src/app/(portal)/modules/proxmox/proxmox-page-roles.test.tsx#kein Benutzer geladen (user: null): kein Knopf"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "apps/web/src/app/(portal)/modules/proxmox/proxmox-page-roles.test.tsx#Rolle ADMIN: Knopf sichtbar"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "apps/web/src/app/(portal)/modules/proxmox/proxmox-page-roles.test.tsx#Rolle SUPER_ADMIN: Knopf sichtbar"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "apps/web/src/app/(portal)/modules/proxmox/proxmox-page-roles.test.tsx#leere Serverliste bei ADMIN: weiterhin kein Knopf"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.test.tsx#260923-le6: isAdmin={false}, noch nie abgefragt -> automatischer Hinweis ohne Knopfverweis"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.test.tsx#260923-le6: isAdmin weggelassen -> verhaelt sich wie isAdmin={false}"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
|
||||
duration: 21min
|
||||
completed: 2026-09-23
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Quick Task 260923-le6: Zwei Abnahmebefunde des Proxmox-Moduls behoben Summary
|
||||
|
||||
**PMG-Teilsumme wird null statt still falsch (sumOrNull), Aktualisieren-Knopf der Proxmox-Modulseite nur noch fuer ADMIN/SUPER_ADMIN sichtbar**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** 21 min
|
||||
- **Started:** 2026-09-23T13:12:00Z
|
||||
- **Completed:** 2026-09-23T13:33:50Z
|
||||
- **Tasks:** 3
|
||||
- **Files modified:** 8 (7 geaendert, 1 neu)
|
||||
|
||||
## Accomplishments
|
||||
- `sumOrNull(a, b)` in `proxmox-normalize.ts` liefert `null`, sobald ein Teilwert (Spam oder Viren, je Richtung in/out) fehlt oder nicht lesbar ist — die bisherige stille Ersatz-durch-0-Addition ist vollstaendig entfernt. Damit ist Wahrheit 7 (Blocker) aus `260923-dhh-VERIFICATION.md` geschlossen.
|
||||
- `ServerCard` bekommt die optionale Eigenschaft `isAdmin` (Vorgabe `false`) und zeigt Nicht-Admins fuer einen nie abgefragten Server einen neuen Hinweistext (`proxmox.card.notPolledYetAutomatic`, de/en), der auf keinen Knopf verweist.
|
||||
- Die Proxmox-Modulseite zeigt den Knopf "Jetzt aktualisieren" nur noch, wenn `isAdmin` wahr ist (bestehende Variable wiederverwendet, kein neuer Rollen-Mechanismus), und reicht `isAdmin` an `ServerCard` weiter.
|
||||
|
||||
## Task Commits
|
||||
|
||||
Alle Aufgaben wurden per TDD (RED -> GREEN) umgesetzt und einzeln committet:
|
||||
|
||||
1. **Aufgabe 1 (RED): PMG-Teilsumme-Tests** - `c13d657` (test)
|
||||
2. **Aufgabe 1 (GREEN): sumOrNull korrigiert** - `2eb86e1` (fix)
|
||||
3. **Aufgabe 2: ServerCard mit isAdmin und zweitem Hinweistext** - `2f8dd14` (fix)
|
||||
4. **Aufgabe 3: Aktualisieren-Knopf nur fuer Admins** - `e1b191b` (fix)
|
||||
|
||||
_Hinweis: Aufgabe 1 hatte planmaessig zwei Commits (RED/GREEN); Aufgaben 2 und 3 wurden je in einem Commit umgesetzt, wie im Plan vorgesehen (Tests zuerst rot gesehen, dann implementiert, ein Commit je Aufgabe)._
|
||||
|
||||
## Files Created/Modified
|
||||
- `apps/api/src/proxmox/proxmox-normalize.ts` - `sumOrNull` liefert `null` bei fehlendem Teilwert statt Ersatz-durch-0
|
||||
- `apps/api/src/proxmox/proxmox-normalize.spec.ts` - 6 neue Testfaelle fuer beide Richtungen (Spam/Viren), unlesbare Haelfte, Unabhaengigkeit der Paare
|
||||
- `apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.tsx` - neue `isAdmin`-Eigenschaft (Vorgabe `false`), waehlt den Hinweistext fuer nie abgefragte Server
|
||||
- `apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.test.tsx` - bestehenden Test auf `isAdmin` umgestellt, zwei neue Faelle (`isAdmin={false}`, weggelassen)
|
||||
- `apps/web/src/messages/de.json` / `en.json` - neuer Schluessel `proxmox.card.notPolledYetAutomatic`
|
||||
- `apps/web/src/app/(portal)/modules/proxmox/page.tsx` - Knopf nur bei `isAdmin`, reicht `isAdmin={isAdmin}` an `ServerCard` weiter
|
||||
- `apps/web/src/app/(portal)/modules/proxmox/proxmox-page-roles.test.tsx` (neu) - Seitentest mit 5 Faellen (USER, `user: null`, ADMIN, SUPER_ADMIN, leere Liste bei ADMIN)
|
||||
|
||||
## Decisions Made
|
||||
- Formulierung "Noch keine Abfrage gelaufen. Die Werte erscheinen nach der naechsten automatischen Abfrage." statt eines "in Kuerze"-Hinweises, weil das Poll-Intervall je Server zwischen 1 und 1440 Minuten liegen kann (`@Max(1440)`) — die gewaehlte Formulierung stimmt bei jedem Intervall und verweist auf keinen Knopf.
|
||||
- Keine weiteren Aenderungen an der Modulseite (Festlegung des Nutzers: kein Umbau, keine zusaetzlichen Details oder Statusfarben) — bestaetigt eingehalten.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
None - plan genau wie geschrieben ausgefuehrt.
|
||||
|
||||
## Issues Encountered
|
||||
None.
|
||||
|
||||
## Beobachtungen (nicht behoben, dem Nutzer zur Entscheidung vorgelegt)
|
||||
- **Inaktive Server:** Ein inaktiver, nie abgefragter Server (`isActive: false`) wird nicht automatisch abgefragt (`proxmox-scheduler.service.ts` fragt nur aktive Server ab). Der neue Nicht-Admin-Hinweistext "...erscheinen nach der naechsten automatischen Abfrage" trifft fuer diesen Randfall nicht ganz zu. Das ist ein vorbestehender Randfall — auch die Admin-Karte beachtet `isActive` heute nicht — und liegt ausserhalb dieses Auftrags (Festlegung: kein Umbau). Wird hier nur vermerkt, nicht behoben.
|
||||
|
||||
## Verifikation (alle gruen, wie im Plan verlangt)
|
||||
- `pnpm --filter api exec vitest run src/proxmox` - 82 Tests gruen (26 in `proxmox-normalize.spec.ts`, davon 6 neu)
|
||||
- `pnpm --filter web exec vitest run "src/app/(portal)/modules/proxmox" src/messages` - 32 Tests gruen
|
||||
- `pnpm --filter api type-check` und `pnpm --filter web type-check` - ohne Fehler
|
||||
- `biome lint` auf den 6 Plan-Dateien - ohne Befund
|
||||
- `biome check` auf der neuen Datei `proxmox-page-roles.test.tsx` - ohne Befund (nach `biome check --write` fuer Formatierung)
|
||||
- `biome check` auf den 5 vorbestehenden Dateien - weiterhin genau 6 Befunde (vorbestehende Formatierung + `organizeImports` in `page.tsx`), keine neuen Befunde — wie im Plan festgelegt nicht behoben (fremder Diff)
|
||||
- Keine Container-Neubauten, kein Deploy, keine Browserpruefung — wie im Plan vorgesehen
|
||||
|
||||
## User Setup Required
|
||||
None - keine externe Konfiguration noetig.
|
||||
|
||||
## Next Phase Readiness
|
||||
- Die offene Luecke (Wahrheit 7) aus `260923-dhh-VERIFICATION.md` ist geschlossen; das Proxmox-Modul hat keine bekannten offenen Abnahmebefunde mehr.
|
||||
- Offen beim Nutzer (keine Entscheidung noetig, nur zur Kenntnis): der oben vermerkte Randfall bei inaktiven, nie abgefragten Servern.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
Alle im Plan genannten Dateien wurden gefunden, alle vier Commits sind im Log nachweisbar.
|
||||
|
||||
---
|
||||
*Plan: 260923-le6*
|
||||
*Completed: 2026-09-23*
|
||||
@@ -0,0 +1,82 @@
|
||||
---
|
||||
created: 2026-09-22
|
||||
title: DashboardImage — Spalte "data" entfernen und "storagePath" auf NOT NULL setzen (Stufe 2 der Umstellung aus quick-260922-hk4)
|
||||
area: apps/api/prisma
|
||||
severity: cleanup
|
||||
trigger: erst wenn alpha UND live je einmal mit einer Version >= der Freigabe nach 1.3.0 gelaufen sind — dann hat der Bootstrap-Umzug auf beiden Servern gearbeitet und die Bytes liegen im Dateibereich.
|
||||
relates_to: quick-260922-hk4 (.planning/quick/260922-hk4-bilderrahmen-bilder-auf-die-festplatte/)
|
||||
---
|
||||
|
||||
## Worum es geht
|
||||
|
||||
Die Bilder des Bilderrahmen-Widgets sind mit `quick-260922-hk4` aus der
|
||||
Datenbank in den Dateibereich gezogen (`user-files/dashboard-images/
|
||||
<userId>/<id>.<ext>`, Pfad in der Spalte `storagePath`). Die Umstellung ist
|
||||
BEWUSST ZWEISTUFIG:
|
||||
|
||||
- **Stufe 1, ausgeliefert** (Migration `20260922120000_dashboard_image_to_disk`):
|
||||
`storagePath` dazu (NULLbar), `data` wird NULLbar — aber NICHT gelöscht.
|
||||
Der Umzug der vorhandenen Zeilen passiert beim ersten Start automatisch
|
||||
(`DashboardImagesService.onApplicationBootstrap()`).
|
||||
- **Stufe 2, dieser Zettel:** `data` löschen, `storagePath` auf NOT NULL.
|
||||
|
||||
Warum nicht sofort: `prisma migrate deploy` läuft VOR dem Anwendungsstart.
|
||||
Ein sofortiges `DROP COLUMN "data"` hätte die Bytes vernichtet, bevor der
|
||||
Umzug beim Start sie lesen konnte (T-HK4-03).
|
||||
|
||||
## Was zu tun ist
|
||||
|
||||
1. **Vorbedingung prüfen** (auf BEIDEN Servern, alpha und live):
|
||||
|
||||
```sql
|
||||
SELECT count(*) FROM "DashboardImage" WHERE "storagePath" IS NULL;
|
||||
```
|
||||
|
||||
Muss überall `0` sein. Ist sie es nicht, ist der Umzug dort noch nicht
|
||||
gelaufen (Server noch auf einer älteren Version) — dann NICHT ausliefern.
|
||||
|
||||
2. **Neue Migration** `20260922120100_dashboard_image_drop_data`:
|
||||
|
||||
```sql
|
||||
ALTER TABLE "DashboardImage" ALTER COLUMN "storagePath" SET NOT NULL;
|
||||
ALTER TABLE "DashboardImage" DROP COLUMN "data";
|
||||
```
|
||||
|
||||
3. **Schema** `apps/api/prisma/schema.prisma`: Feld `data Bytes?` entfernen,
|
||||
`storagePath String?` → `storagePath String`.
|
||||
|
||||
4. **Dienst** `apps/api/src/dashboard/dashboard-images.service.ts`:
|
||||
`onApplicationBootstrap()` samt `forSystem()`-Aufruf entfernt sich damit
|
||||
— der Umzug hat seine Arbeit getan. Danach:
|
||||
- Eintrag `apps/api/src/dashboard/dashboard-images.service.ts` aus
|
||||
`FORSYSTEM_ALLOWED_CALL_SITES` in `apps/api/src/prisma/rls-access-inventory.spec.ts`
|
||||
wieder ENTFERNEN (die Liste ist ein „genau", ein veralteter Eintrag
|
||||
macht die Spec rot).
|
||||
- In `docs/mandantentrennung-zugriffsklassifikation.md` den Stand der Zeile
|
||||
`dashboard-images.service.ts`/`dashboardImage` von `system-gebunden`
|
||||
zurück auf `gebunden` setzen und die Zahlen der Bereichszeile
|
||||
`dashboard` sowie die Summe neu messen (Gate-Schleife, nicht
|
||||
abschreiben).
|
||||
- Die Tests 18, 21, 22 und 23 der Dienst-Spec (Zeile ohne `storagePath`,
|
||||
Bootstrap-Umzug) entfallen mit dem Umzug.
|
||||
- Die Regel `system_read_policy` auf `"DashboardImage"` (angelegt in
|
||||
20260922120000) kann bleiben oder mit `DROP POLICY` fallen — bleibt sie,
|
||||
gehört sie in der Klassifikation erwähnt; fällt sie, ist die
|
||||
Aufzählung „fünf/sechs Tabellen" dort nachzuziehen.
|
||||
|
||||
5. **Prüfen**, dass die Datenbank kleiner wird:
|
||||
|
||||
```sql
|
||||
SELECT pg_size_pretty(pg_total_relation_size('"DashboardImage"'));
|
||||
```
|
||||
|
||||
(Nach dem DROP zusätzlich `VACUUM FULL "DashboardImage";`, sonst gibt
|
||||
PostgreSQL den Platz nicht ans Dateisystem zurück.)
|
||||
|
||||
## Was passiert, wenn es liegen bleibt
|
||||
|
||||
Nichts Schlimmes: die Spalte steht leer herum und kostet je neuer Zeile
|
||||
nichts. Der Gewinn der Umstellung (kleiner `pg_dump`) ist bereits da, weil
|
||||
neue Uploads keine Bytes mehr in die Zeile schreiben. Nur die Bytes der
|
||||
ALTEN Bilder bleiben bis dahin doppelt vorhanden — einmal in der Datei,
|
||||
einmal in der Spalte.
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
---
|
||||
created: 2026-09-23
|
||||
title: Flackernder Test "TenantContextSelector" — Zeitüberschreitung bei 5 s, hat die Freigabe 1.3.1 blockiert
|
||||
area: apps/web
|
||||
severity: flake
|
||||
trigger: sobald wieder an apps/web gearbeitet wird — spätestens vor der nächsten Freigabe, weil der Fall dort teuer ist.
|
||||
relates_to: Freigabe v1.3.1 (CI-Lauf 418, Job 1262)
|
||||
---
|
||||
|
||||
## Was passiert ist
|
||||
|
||||
Beim Freigeben von 1.3.1 lösen drei Pipelines gleichzeitig aus (`main`, Zweig
|
||||
`live`, Tag `v1.3.1`). Auf dem Tag-Lauf fiel der Test
|
||||
|
||||
```
|
||||
src/app/(portal)/marketplace/tenant-selector.test.tsx
|
||||
> TenantContextSelector > renders tenant options for SUPER_ADMIN; renders nothing for ADMIN
|
||||
```
|
||||
|
||||
mit `Error: Test timed out in 5000ms` aus. **Derselbe Commit** (`ad004b28`) war
|
||||
im selben Zeitraum auf `main` (Lauf 1025) und auf `live` (Lauf 1026) grün — es
|
||||
ist also kein Fehler im Code, sondern der Läufer war mit drei parallelen
|
||||
Pipelines ausgelastet und der Test lief in sein 5-Sekunden-Limit.
|
||||
|
||||
## Warum das teuer war
|
||||
|
||||
Der Abbild-Bau hängt am Test-Job. Rot heißt: **„Build & Publish Images" und
|
||||
„Desktop-Pakete bauen" wurden übersprungen** — die Freigabe war damit getaggt,
|
||||
aber nicht gebaut. Kein `live`-Abbild, kein Gitea-Release, keine
|
||||
Desktop-Pakete. Erst ein Neustart des Laufs (Gitea-API,
|
||||
`POST /actions/runs/418/rerun`) hat alles nachgeholt.
|
||||
|
||||
Das trifft **jede** Freigabe, weil jede Freigabe drei gleichzeitige Läufe
|
||||
auslöst. Der Fall wiederholt sich also, nicht zufällig.
|
||||
|
||||
## Was zu tun ist
|
||||
|
||||
1. Den Test ansehen: warum braucht er überhaupt nahe 5 s? Verdacht ist ein
|
||||
`waitFor` auf etwas, das erst nach einem Datenabruf erscheint, oder zwei
|
||||
Fälle (SUPER_ADMIN und ADMIN) in EINEM `it`, das dadurch doppelt so lange
|
||||
läuft — der Testname nennt beide Fälle in einem Satz.
|
||||
2. Entweder auftrennen (zwei `it`-Blöcke) oder die Wartezeit gezielt erhöhen.
|
||||
Ein globales Hochsetzen von `testTimeout` versteckt nur, dass hier etwas
|
||||
langsam ist.
|
||||
3. Prüfen, ob weitere Tests nahe am Limit liegen — die Läuferlast bleibt ja.
|
||||
|
||||
## Nicht die Lösung
|
||||
|
||||
Die drei parallelen Pipelines abschalten: der Lauf auf `main` und der auf dem
|
||||
Tag prüfen unterschiedliche Dinge, und der `live`-Lauf ist die Absicherung,
|
||||
dass der Zweig für sich genommen grün ist.
|
||||
@@ -4,6 +4,21 @@ Diese Liste beschreibt in einfachen Worten, was sich von Version zu Version an T
|
||||
|
||||
## Unveröffentlicht
|
||||
|
||||
### Neu
|
||||
|
||||
- Das Dashboard hat jetzt mehrere Reiter: Sie können beliebig viele Dashboards anlegen, jeder mit eigenen Kacheln und eigener Anordnung, per Ziehen umsortierbar, wobei der erste Reiter beim Öffnen geladen wird. Ihre vorhandenen Kacheln bleiben dabei unverändert auf dem ersten Reiter liegen.
|
||||
|
||||
## 1.3.1 – 2026-09-23
|
||||
|
||||
### Geändert
|
||||
|
||||
- Bilderrahmen: hochgeladene Bilder liegen jetzt im Dateibereich des Servers statt in der Datenbank — die Datenbanksicherung bleibt dadurch klein; vorhandene Bilder ziehen beim ersten Start automatisch um
|
||||
- Dashboard: Kacheln, die zu einem Modul gehören, erscheinen nur noch für Benutzer, die dieses Modul nutzen dürfen; eine nicht mehr freigegebene Kachel erklärt das jetzt, statt leer zu bleiben
|
||||
|
||||
### Behoben
|
||||
|
||||
- Dashboard: war das Dashboard beim Öffnen leer, nutzte die erste hinzugefügte Kachel nur einen Teil der Breite — rechts blieb ein toter Streifen, in den sich keine Kachel ziehen ließ; das Raster misst die verfügbare Breite jetzt in jedem Fall und folgt auch einer Änderung der Fenstergröße
|
||||
|
||||
## 1.3.0 – 2026-09-22
|
||||
|
||||
### Neu
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
-- quick-260922-hk4 — Bilderrahmen-Bilder wandern aus der Datenbank in den
|
||||
-- Dateibereich (Volume `user-files`).
|
||||
--
|
||||
-- Warum: gesichert wird von Hand per `pg_dump` (docs/anleitung-betrieb.md
|
||||
-- Kap. 6). Jedes Bild waechst in diesen Abzug hinein — 30 Bilder à 5 MiB je
|
||||
-- Benutzer sind im Extremfall 150 MB PRO BENUTZER, gegen eine heute 18 MB
|
||||
-- grosse Datenbank (gemessen 22.09.2026 auf alpha). Die Bytes liegen ab
|
||||
-- dieser Version unter
|
||||
-- `user-files/dashboard-images/<userId>/<id>.<png|jpg|gif|webp>`; die Zeile
|
||||
-- haelt nur noch den relativen Pfad in "storagePath" — dasselbe Muster wie
|
||||
-- `User.avatarPath` (user.controller.ts) und die DKV-Ausfuhren
|
||||
-- (dkv-export.service.ts). Der Dateiname ist IMMER servergeneriert (die
|
||||
-- UUID der Zeile plus die Endung aus dem an den Magic Bytes ERKANNTEN
|
||||
-- Mime-Typ); kein Byte aus der Anfrage, insbesondere nicht
|
||||
-- "originalName", geht je in einen Pfad (T-HK4-01, Muster T-07-09).
|
||||
--
|
||||
-- ZWEISTUFIG, UND WARUM DIESE MIGRATION "data" NICHT LOESCHT (T-HK4-03):
|
||||
-- Vorhandene Zeilen tragen ihre Bytes noch in "data". Der Umzug auf die
|
||||
-- Platte passiert beim ersten Start dieser Version automatisch
|
||||
-- (DashboardImagesService.onApplicationBootstrap, liest systemgebunden ueber
|
||||
-- alle Mandanten, schreibt je Zeile mandantengebunden zurueck) — der Nutzer
|
||||
-- muss nichts ausfuehren. Wuerde diese Migration die Spalte sofort
|
||||
-- loeschen, laufen Migration und Umzug im selben Start in der falschen
|
||||
-- Reihenfolge ("migrate deploy" laeuft VOR dem Anwendungsstart) und die
|
||||
-- Bytes waeren weg, bevor sie jemand gelesen hat. Deshalb:
|
||||
-- Stufe 1 (diese Migration): "storagePath" dazu (NULLbar), "data" bleibt
|
||||
-- stehen und wird NULLbar, damit neue Uploads sie leer lassen.
|
||||
-- Stufe 2 (spaetere Freigabe, Migration
|
||||
-- 20260922120100_dashboard_image_drop_data, vorgemerkt in
|
||||
-- .planning/todos/pending/): "storagePath" SET NOT NULL und
|
||||
-- DROP COLUMN "data" — erst, wenn alpha UND live einmal mit
|
||||
-- einer Version >= dieser gelaufen sind.
|
||||
--
|
||||
-- Das Prisma-Modell behaelt in Stufe 1 bewusst `data Bytes?` (optional).
|
||||
-- Damit bleibt der Bootstrap-Umzug typisiert und braucht kein rohes SQL;
|
||||
-- die Spalte verschwindet aus Modell und Tabelle gemeinsam in Stufe 2.
|
||||
--
|
||||
-- Rechte/Regeln: "tenant_isolation_policy" aus 20260921120000 bleibt
|
||||
-- unveraendert. Kein DROP POLICY.
|
||||
|
||||
-- Relativer Pfad zur Monorepo-Wurzel, z. B.
|
||||
-- "user-files/dashboard-images/<userId>/<id>.png". Stufe 2 macht die Spalte
|
||||
-- NOT NULL.
|
||||
ALTER TABLE "DashboardImage" ADD COLUMN "storagePath" TEXT;
|
||||
|
||||
-- Neue Uploads schreiben keine Bytes mehr in die Zeile; die Spalte bleibt
|
||||
-- fuer die Dauer von Stufe 1 als Sicherheitsnetz erhalten.
|
||||
ALTER TABLE "DashboardImage" ALTER COLUMN "data" DROP NOT NULL;
|
||||
|
||||
-- Systemkontext-Leserecht (Muster 20260914120000_rls_system_context_read):
|
||||
-- der Bootstrap-Umzug liest die noch nicht umgezogenen Zeilen ueber ALLE
|
||||
-- Mandanten (`forSystem()`), bevor er je Zeile mandantengebunden
|
||||
-- zurueckschreibt. Ohne diese Regel saehe er nach dem Scharfschalten der
|
||||
-- Datenbankrolle (Etappe 4, Schalter heute AUS) NULL Zeilen und stellte die
|
||||
-- Arbeit stumm ein — genau die Falle, die 20260914120000 fuer die fuenf
|
||||
-- Hintergrunddienst-Tabellen geschlossen hat. Permissiv und NUR FOR SELECT:
|
||||
-- Schreiben bleibt allein der Mandantenregel unterstellt.
|
||||
CREATE POLICY system_read_policy ON "DashboardImage"
|
||||
FOR SELECT USING (is_system_context());
|
||||
@@ -0,0 +1,113 @@
|
||||
-- 260923-ad9 — Dashboard-Reiter: mehrere Dashboards je Benutzer.
|
||||
--
|
||||
-- Zweck: das Dashboard traegt heute genau eine Kachelflaeche je Benutzer.
|
||||
-- Diese Migration gibt jedem Benutzer mehrere Dashboards ("Reiter"), die
|
||||
-- oben nebeneinander stehen: jeder Reiter mit eigenen Kacheln und eigener
|
||||
-- Anordnung, per Ziehen umsortierbar.
|
||||
--
|
||||
-- D-01: `position` (Integer) traegt die Reihenfolge, aufsteigend sortiert.
|
||||
-- KEIN Unique auf (userId, position) — beim Umsortieren werden alle
|
||||
-- Positionen eines Benutzers in EINER Transaktion neu geschrieben
|
||||
-- (dashboard.service.ts, reorderDashboards, Muster FavoritesService.reorder);
|
||||
-- ein Unique waere dabei nur im Weg.
|
||||
--
|
||||
-- D-03: niemand verliert etwas. Fuer jeden Benutzer, der heute Kacheln ODER
|
||||
-- eine gespeicherte Anordnung hat, entsteht genau EIN Dashboard mit
|
||||
-- position = 0 und dem Namen "Dashboard"; vorhandene Kacheln und die
|
||||
-- vorhandene Anordnung werden darauf umgehaengt. Diese Bestandsuebernahme
|
||||
-- MUSS vor den Fremdschluesseln laufen, sonst scheitert sie an genau diesen
|
||||
-- — deshalb steht sie unten vor den ALTER-TABLE-Schritten fuer
|
||||
-- WidgetInstance/DashboardLayout.
|
||||
--
|
||||
-- D-04: Zeilenschutz ist Pflicht. Die neue Tabelle traegt `tenantId` und
|
||||
-- dieselbe Regel wie ihre Nachbarn — Mandant UND Benutzerdimension von
|
||||
-- Anfang an (Form aus 20260911120000_rls_user_dimension_personal_tables,
|
||||
-- uebernommen aus 20260921120000_dashboard_image).
|
||||
--
|
||||
-- Rechte fuer die Anwendungsrolle tessera_app kommen ueber ALTER DEFAULT
|
||||
-- PRIVILEGES aus 20260909130000_rls_app_role automatisch — hier nichts zu
|
||||
-- tun.
|
||||
--
|
||||
-- WICHTIG: wie alle bisherigen RLS-Migrationen wirkt die Regel erst, wenn
|
||||
-- die Anwendung als Rolle ohne Umgehungsrecht verbindet (Schalter heute AUS,
|
||||
-- siehe docs/mandantentrennung-datenbankrolle.md).
|
||||
|
||||
-- 1) Tabelle Dashboard anlegen, Indizes auf userId und tenantId.
|
||||
CREATE TABLE "Dashboard" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"tenantId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"position" INTEGER NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Dashboard_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE INDEX "Dashboard_userId_idx" ON "Dashboard"("userId");
|
||||
CREATE INDEX "Dashboard_tenantId_idx" ON "Dashboard"("tenantId");
|
||||
|
||||
-- 2) Zeilenschutz: Mandant UND Benutzer (Muster 20260911120000/20260921120000).
|
||||
ALTER TABLE "Dashboard" ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE "Dashboard" FORCE ROW LEVEL SECURITY;
|
||||
CREATE POLICY tenant_isolation_policy ON "Dashboard"
|
||||
USING (
|
||||
"tenantId" = current_tenant_id()
|
||||
AND (current_user_id() IS NULL OR "userId" = current_user_id())
|
||||
);
|
||||
|
||||
-- 3) Bestandsuebernahme (D-03): je Benutzer aus der Vereinigung der
|
||||
-- Benutzer mit Kacheln und der Benutzer mit gespeicherter Anordnung genau
|
||||
-- EINE Zeile einfuegen. DISTINCT ON sichert "je Benutzer genau eine Zeile"
|
||||
-- auch fuer den theoretischen Fall "derselbe Benutzer mit zwei
|
||||
-- Mandantenkennungen" ab (deterministische Wahl ueber die Sortierung nach
|
||||
-- tenantId als zweitem Kriterium).
|
||||
INSERT INTO "Dashboard" ("id", "userId", "tenantId", "name", "position", "createdAt", "updatedAt")
|
||||
SELECT gen_random_uuid(), bestand."userId", bestand."tenantId", 'Dashboard', 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
|
||||
FROM (
|
||||
SELECT DISTINCT ON ("userId") "userId", "tenantId"
|
||||
FROM (
|
||||
SELECT "userId", "tenantId" FROM "WidgetInstance"
|
||||
UNION ALL
|
||||
SELECT "userId", "tenantId" FROM "DashboardLayout"
|
||||
) AS vereinigung
|
||||
ORDER BY "userId", "tenantId"
|
||||
) AS bestand;
|
||||
|
||||
-- 4) WidgetInstance.dashboardId: zunaechst NULLbar ergaenzen, aus der neuen
|
||||
-- Tabelle ueber die Benutzerkennung befuellen (fuer jeden Benutzer mit
|
||||
-- Kacheln existiert nach Schritt 3 GENAU ein Dashboard), dann NOT NULL,
|
||||
-- Index, Fremdschluessel mit Loeschweitergabe.
|
||||
ALTER TABLE "WidgetInstance" ADD COLUMN "dashboardId" TEXT;
|
||||
|
||||
UPDATE "WidgetInstance" wi
|
||||
SET "dashboardId" = d."id"
|
||||
FROM "Dashboard" d
|
||||
WHERE d."userId" = wi."userId";
|
||||
|
||||
ALTER TABLE "WidgetInstance" ALTER COLUMN "dashboardId" SET NOT NULL;
|
||||
CREATE INDEX "WidgetInstance_dashboardId_idx" ON "WidgetInstance"("dashboardId");
|
||||
ALTER TABLE "WidgetInstance" ADD CONSTRAINT "WidgetInstance_dashboardId_fkey"
|
||||
FOREIGN KEY ("dashboardId") REFERENCES "Dashboard"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- 5) DashboardLayout.dashboardId: dieselbe Uebernahme; zusaetzlich die
|
||||
-- Eindeutigkeit auf userId entfernen (mehrere Reiter je Benutzer sind jetzt
|
||||
-- erlaubt), dort einen gewoehnlichen Index anlegen, und die Eindeutigkeit
|
||||
-- auf dashboardId anlegen (ein Reiter hat hoechstens eine gespeicherte
|
||||
-- Anordnung).
|
||||
ALTER TABLE "DashboardLayout" ADD COLUMN "dashboardId" TEXT;
|
||||
|
||||
UPDATE "DashboardLayout" dl
|
||||
SET "dashboardId" = d."id"
|
||||
FROM "Dashboard" d
|
||||
WHERE d."userId" = dl."userId";
|
||||
|
||||
ALTER TABLE "DashboardLayout" ALTER COLUMN "dashboardId" SET NOT NULL;
|
||||
|
||||
DROP INDEX "DashboardLayout_userId_key";
|
||||
CREATE INDEX "DashboardLayout_userId_idx" ON "DashboardLayout"("userId");
|
||||
|
||||
CREATE UNIQUE INDEX "DashboardLayout_dashboardId_key" ON "DashboardLayout"("dashboardId");
|
||||
ALTER TABLE "DashboardLayout" ADD CONSTRAINT "DashboardLayout_dashboardId_fkey"
|
||||
FOREIGN KEY ("dashboardId") REFERENCES "Dashboard"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,94 @@
|
||||
-- 260923-dhh — Proxmox-Modul (PVE/PBS/PMG), nur beobachten (D-01).
|
||||
--
|
||||
-- Zweck: zwei neue Tabellen fuer das Proxmox-Modul. `ProxmoxServer` traegt
|
||||
-- die vom Administrator eingetragenen Server (Name, Typ, Adresse, Zugang,
|
||||
-- verschluesselt) — mehrere Zeilen je Mandant, Vorbild `CalendarSource`,
|
||||
-- NICHT `DkvModuleConfig` (Singleton je Mandant). `ProxmoxServerStatus` ist
|
||||
-- das Zwischenlager (D-05): der Hintergrunddienst (Aufgabe 4) beschreibt
|
||||
-- diese Zeile, die Modulseite liest ausschliesslich daraus.
|
||||
--
|
||||
-- Von Hand geschrieben (Vorbild 20260923120000_dashboard_tabs), von Hand
|
||||
-- gepflegter Kopfkommentar Pflicht bei jeder RLS-Migration in diesem Projekt.
|
||||
--
|
||||
-- Zeilenschutz (D-08, Pflicht — sonst schlaegt rls-coverage.spec.ts fehl):
|
||||
-- beide Tabellen tragen `tenantId` und `tenant_isolation_policy` OHNE
|
||||
-- Benutzerdimension (`USING ("tenantId" = current_tenant_id())`, Form aus
|
||||
-- `DkvModuleConfig`, Migration 20260909140000) — Proxmox-Server sind
|
||||
-- Verwaltungsdaten des Mandanten, nicht persoenliche Daten eines einzelnen
|
||||
-- Benutzers.
|
||||
--
|
||||
-- Zusaetzlich NUR auf "ProxmoxServer" eine `system_read_policy` (Form aus
|
||||
-- 20260914120000_rls_system_context_read): der Hintergrunddienst aus
|
||||
-- Aufgabe 4 muss beim Start ueber `forSystem()` die aktiven Server ALLER
|
||||
-- Mandanten sehen, um je Mandant einen eigenen Cron-Auftrag zu registrieren
|
||||
-- (Muster DKV-/Tender-Planer). "ProxmoxServerStatus" bekommt diese Regel
|
||||
-- BEWUSST NICHT — geschrieben wird dort ausschliesslich je Zeile
|
||||
-- mandantengebunden (`forTenant(prisma, tenantId)`), ein Systemlesezugriff
|
||||
-- auf das Zwischenlager hat keinen Aufrufer.
|
||||
--
|
||||
-- Rechte fuer die Anwendungsrolle tessera_app kommen automatisch ueber
|
||||
-- ALTER DEFAULT PRIVILEGES aus 20260909130000_rls_app_role — hier nichts zu
|
||||
-- tun.
|
||||
--
|
||||
-- WICHTIG: wie alle bisherigen RLS-Migrationen wirken diese Regeln erst,
|
||||
-- wenn die Anwendung als Rolle ohne Umgehungsrecht verbindet (Schalter
|
||||
-- heute AUS, siehe docs/mandantentrennung-datenbankrolle.md).
|
||||
|
||||
-- 1) ProxmoxServer
|
||||
CREATE TABLE "ProxmoxServer" (
|
||||
"id" TEXT NOT NULL,
|
||||
"tenantId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"productType" TEXT NOT NULL,
|
||||
"baseUrl" TEXT NOT NULL,
|
||||
"authMethod" TEXT NOT NULL,
|
||||
"tokenId" TEXT,
|
||||
"encryptedTokenSecret" TEXT,
|
||||
"username" TEXT,
|
||||
"encryptedPassword" TEXT,
|
||||
"tlsRejectUnauthorized" BOOLEAN NOT NULL DEFAULT true,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"pollIntervalMin" INTEGER NOT NULL DEFAULT 5,
|
||||
"position" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "ProxmoxServer_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE INDEX "ProxmoxServer_tenantId_idx" ON "ProxmoxServer"("tenantId");
|
||||
|
||||
ALTER TABLE "ProxmoxServer" ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE "ProxmoxServer" FORCE ROW LEVEL SECURITY;
|
||||
CREATE POLICY tenant_isolation_policy ON "ProxmoxServer"
|
||||
USING ("tenantId" = current_tenant_id());
|
||||
CREATE POLICY system_read_policy ON "ProxmoxServer"
|
||||
FOR SELECT USING (is_system_context());
|
||||
|
||||
-- 2) ProxmoxServerStatus — Zwischenlager, 1:1 je Server, Loeschweitergabe.
|
||||
CREATE TABLE "ProxmoxServerStatus" (
|
||||
"id" TEXT NOT NULL,
|
||||
"serverId" TEXT NOT NULL,
|
||||
"tenantId" TEXT NOT NULL,
|
||||
"lastPolledAt" TIMESTAMP(3),
|
||||
"lastOkAt" TIMESTAMP(3),
|
||||
"reachable" BOOLEAN NOT NULL DEFAULT false,
|
||||
"errorKind" TEXT,
|
||||
"errorDetail" TEXT,
|
||||
"metrics" JSONB,
|
||||
"rawSample" JSONB,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "ProxmoxServerStatus_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "ProxmoxServerStatus_serverId_key" ON "ProxmoxServerStatus"("serverId");
|
||||
CREATE INDEX "ProxmoxServerStatus_tenantId_idx" ON "ProxmoxServerStatus"("tenantId");
|
||||
|
||||
ALTER TABLE "ProxmoxServerStatus" ADD CONSTRAINT "ProxmoxServerStatus_serverId_fkey"
|
||||
FOREIGN KEY ("serverId") REFERENCES "ProxmoxServer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "ProxmoxServerStatus" ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE "ProxmoxServerStatus" FORCE ROW LEVEL SECURITY;
|
||||
CREATE POLICY tenant_isolation_policy ON "ProxmoxServerStatus"
|
||||
USING ("tenantId" = current_tenant_id());
|
||||
+108
-10
@@ -187,14 +187,45 @@ model ModuleGrant {
|
||||
@@index([moduleId])
|
||||
}
|
||||
|
||||
model DashboardLayout {
|
||||
id String @id @default(uuid())
|
||||
userId String @unique
|
||||
// Dashboard-Reiter (quick-260923-ad9, D-01/D-02/D-09): mehrere Dashboards je
|
||||
// Benutzer, ueber `position` (Integer) aufsteigend sortiert. KEIN Unique auf
|
||||
// (userId, position) — `reorderDashboards` (Muster FavoritesService.reorder)
|
||||
// schreibt beim Umsortieren ALLE Positionen eines Benutzers in EINER
|
||||
// Transaktion neu; ein Unique waere dabei nur im Weg (kollidiert waehrend
|
||||
// des Umschreibens mit sich selbst). KEIN eigenes Standard-Feld: "als
|
||||
// Favorit festlegen" IST das Nach-vorn-Ziehen (D-09) — Position 0 ist der
|
||||
// Standard, es gibt keine zweite Wahrheit daneben. Keine Relation zu
|
||||
// User/Tenant — Form der Nachbarmodelle WidgetInstance/DashboardImage (eine
|
||||
// Relation zu User wuerde an Bestandszeilen verwaister Benutzer scheitern).
|
||||
model Dashboard {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
tenantId String
|
||||
layouts Json @default("{}")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
name String
|
||||
position Int
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
widgets WidgetInstance[]
|
||||
layout DashboardLayout?
|
||||
|
||||
@@index([userId])
|
||||
@@index([tenantId])
|
||||
}
|
||||
|
||||
model DashboardLayout {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
tenantId String
|
||||
// quick-260923-ad9 (D-02): haengt jetzt am Dashboard statt am Benutzer —
|
||||
// die Eindeutigkeit wandert von userId auf dashboardId, userId/tenantId
|
||||
// bleiben fuer Besitz- und Mandantenpruefung erhalten.
|
||||
dashboardId String @unique
|
||||
dashboard Dashboard @relation(fields: [dashboardId], references: [id], onDelete: Cascade)
|
||||
layouts Json @default("{}")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([userId])
|
||||
@@index([tenantId])
|
||||
}
|
||||
|
||||
@@ -202,6 +233,10 @@ model WidgetInstance {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
tenantId String
|
||||
// quick-260923-ad9 (D-02): Kacheln haengen ab jetzt am Reiter, nicht mehr
|
||||
// nur am Benutzer.
|
||||
dashboardId String
|
||||
dashboard Dashboard @relation(fields: [dashboardId], references: [id], onDelete: Cascade)
|
||||
widgetType String
|
||||
config Json @default("{}")
|
||||
createdAt DateTime @default(now())
|
||||
@@ -210,14 +245,19 @@ model WidgetInstance {
|
||||
|
||||
@@index([userId])
|
||||
@@index([tenantId])
|
||||
@@index([dashboardId])
|
||||
}
|
||||
|
||||
// Bilderrahmen-Widget (quick-260921-pi9): hochgeladene Bilder eines Benutzers,
|
||||
// als bytea in der Datenbank (kein Docker-Volume, die Sicherung deckt es mit
|
||||
// ab). Keine Relation — wie WidgetInstance. Grenzen (5 MiB je Datei, 30 je
|
||||
// Bilderrahmen-Widget (quick-260921-pi9): hochgeladene Bilder eines Benutzers.
|
||||
// Keine Relation — wie WidgetInstance. Grenzen (5 MiB je Datei, 30 je
|
||||
// Benutzer) und die Magic-Byte-Erkennung leben in
|
||||
// src/dashboard/dashboard-image-rules.ts; Besitz = gleicher Mandant UND
|
||||
// gleicher Benutzer (Regel in Migration 20260921120000 mit Benutzerdimension).
|
||||
//
|
||||
// quick-260922-hk4: die Bytes liegen jetzt im Dateibereich
|
||||
// (user-files/dashboard-images/<userId>/<id>.<ext>), die Zeile haelt nur
|
||||
// noch den relativen Pfad — Muster User.avatarPath. Die Datenbanksicherung
|
||||
// (pg_dump) bleibt dadurch klein.
|
||||
model DashboardImage {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
@@ -225,7 +265,14 @@ model DashboardImage {
|
||||
originalName String
|
||||
mimeType String
|
||||
size Int
|
||||
data Bytes
|
||||
// Stufe 1 der zweistufigen Umstellung (Migration 20260922120000): die
|
||||
// Spalte bleibt NULLbar stehen, bis der Bootstrap-Umzug auf allen Servern
|
||||
// gelaufen ist. Neue Uploads schreiben sie nie. DROP kommt mit
|
||||
// 20260922120100 (vorgemerkt in .planning/todos/pending/).
|
||||
data Bytes?
|
||||
// Relativ zur Monorepo-Wurzel; NULL nur fuer Zeilen, die der
|
||||
// Bootstrap-Umzug noch nicht angefasst hat. Wird in Stufe 2 NOT NULL.
|
||||
storagePath String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([userId])
|
||||
@@ -604,3 +651,54 @@ model TenderRssFeedSource {
|
||||
@@unique([userId, url])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
// Quick-Auftrag 260923-dhh — Proxmox-Modul (PVE/PBS/PMG), nur beobachten (D-01).
|
||||
//
|
||||
// Vorbild ist `CalendarSource` (mehrere verschluesselte Fremdsystem-Zugaenge
|
||||
// je Mandant), NICHT `DkvModuleConfig` (Singleton je Mandant): ein Mandant
|
||||
// traegt hier beliebig viele Server ein. `authMethod` waehlt zwischen einem
|
||||
// API-Token (`tokenId`/`encryptedTokenSecret`) und Benutzer/Passwort
|
||||
// (`username`/`encryptedPassword`); PMG kennt laut Recherche nur Letzteres
|
||||
// (DTO lehnt Token bei PMG serverseitig ab, D-03). `tlsRejectUnauthorized`
|
||||
// ist woertlich der Feldname aus `LdapConfig` — Voreinstellung "pruefen",
|
||||
// pro Zeile umschaltbar, nie global (D-04).
|
||||
model ProxmoxServer {
|
||||
id String @id @default(uuid())
|
||||
tenantId String
|
||||
name String
|
||||
productType String // 'pve' | 'pbs' | 'pmg'
|
||||
baseUrl String
|
||||
authMethod String // 'token' | 'password'
|
||||
tokenId String?
|
||||
encryptedTokenSecret String? // AES-256-GCM ciphertext (iv:authTag:ciphertext hex), wie CalendarSource.encryptedPassword
|
||||
username String?
|
||||
encryptedPassword String? // AES-256-GCM ciphertext (iv:authTag:ciphertext hex)
|
||||
tlsRejectUnauthorized Boolean @default(true)
|
||||
isActive Boolean @default(true)
|
||||
pollIntervalMin Int @default(5)
|
||||
position Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
status ProxmoxServerStatus?
|
||||
|
||||
@@index([tenantId])
|
||||
}
|
||||
|
||||
// Zwischenlager (D-05): der Hintergrunddienst (Aufgabe 4) beschreibt diese
|
||||
// Zeile, die Modulseite liest ausschliesslich daraus — nie live bei Proxmox.
|
||||
model ProxmoxServerStatus {
|
||||
id String @id @default(uuid())
|
||||
serverId String @unique
|
||||
server ProxmoxServer @relation(fields: [serverId], references: [id], onDelete: Cascade)
|
||||
tenantId String
|
||||
lastPolledAt DateTime?
|
||||
lastOkAt DateTime?
|
||||
reachable Boolean @default(false)
|
||||
errorKind String?
|
||||
errorDetail String?
|
||||
metrics Json?
|
||||
rawSample Json?
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([tenantId])
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ import { TenantGuard } from './tenant/tenant.guard';
|
||||
import { TenantModule } from './tenant/tenant.module';
|
||||
import { TendersModule } from './tenders/tenders.module';
|
||||
import { UserModule } from './user/user.module';
|
||||
import { ProxmoxModule } from './proxmox/proxmox.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -51,6 +52,7 @@ import { UserModule } from './user/user.module';
|
||||
FavoritesModule,
|
||||
TendersModule,
|
||||
BugReportsModule,
|
||||
ProxmoxModule,
|
||||
],
|
||||
providers: [
|
||||
// Global JWT guard: all routes require auth unless @Public()
|
||||
|
||||
@@ -1,33 +1,53 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
/**
|
||||
* Bindung an forTenant() — dasselbe Muster wie dashboard.service.spec.ts
|
||||
* (260910-krx): der gebundene Klient ist ein ZWEITES, von `prisma`
|
||||
* unterscheidbares Objekt ueber DEMSELBEN Speicher, das protokolliert,
|
||||
* welche Aufrufe ueber ihn liefen. Ein vergessener Bindungsaufruf faellt
|
||||
* damit auf (`prisma.dashboardImage` waere dann ohne Protokoll-Eintrag).
|
||||
* Bindung an forTenant()/forSystem() — dasselbe Muster wie
|
||||
* dashboard.service.spec.ts (260910-krx): der gebundene Klient ist ein
|
||||
* ZWEITES, von `prisma` unterscheidbares Objekt ueber DEMSELBEN Speicher,
|
||||
* das protokolliert, welche Aufrufe ueber ihn liefen. Ein vergessener
|
||||
* Bindungsaufruf faellt damit auf (`prisma.dashboardImage` waere dann ohne
|
||||
* Protokoll-Eintrag). Seit quick-260922-hk4 gibt es einen zweiten
|
||||
* Klienten-Typ: der Systemkontext des Bootstrap-Umzugs (`forSystem()`,
|
||||
* liest ueber ALLE Mandanten, Muster dkv.service.ts) — das Protokoll
|
||||
* unterscheidet beide ueber `via`.
|
||||
*/
|
||||
vi.mock('../prisma/prisma-tenant.extension', () => ({
|
||||
forTenant: vi.fn((prisma: FakePrisma, tenantId: string, userId?: string) =>
|
||||
prisma.__makeBoundClient(tenantId, userId),
|
||||
),
|
||||
forSystem: vi.fn((prisma: FakePrisma) => prisma.__makeSystemClient()),
|
||||
}));
|
||||
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { BadRequestException, InternalServerErrorException, NotFoundException } from '@nestjs/common';
|
||||
import type { AuthUser, UploadedFileLike } from '../auth/types/auth-user';
|
||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||
import { forSystem, forTenant } from '../prisma/prisma-tenant.extension';
|
||||
import { DashboardImagesService } from './dashboard-images.service';
|
||||
|
||||
/**
|
||||
* dashboard-images.service.spec — NEU (quick-260921-pi9, Bilderrahmen).
|
||||
* dashboard-images.service.spec — quick-260921-pi9 (Bilderrahmen),
|
||||
* erweitert in quick-260922-hk4 (Bilder auf der Festplatte statt in der
|
||||
* Datenbank).
|
||||
*
|
||||
* Elf Faelle an der Grenze Dienst -> Datenbank: Liste ohne `data`, Upload
|
||||
* ohne Datei, Magic Bytes schlagen den behaupteten MIME-Typ in BEIDE
|
||||
* Richtungen (T-PI9-01), Zaehler 30 (T-PI9-03), fremder Benutzer UND
|
||||
* fremder Mandant -> 404 (T-PI9-04, nie 403), eigenes Bild liefert Bytes,
|
||||
* Loeschen eigen/fremd, und der Nachweis, dass jede Methode
|
||||
* Faelle an der Grenze Dienst -> Datenbank: Liste ohne `data`, Upload ohne
|
||||
* Datei, Magic Bytes schlagen den behaupteten MIME-Typ in BEIDE Richtungen
|
||||
* (T-PI9-01), Zaehler 30 (T-PI9-03), fremder Benutzer UND fremder Mandant
|
||||
* -> 404 (T-PI9-04, nie 403), eigenes Bild liefert Bytes, Loeschen
|
||||
* eigen/fremd, und der Nachweis, dass jede Methode
|
||||
* `forTenant(prisma, tenantId, userId)` mit dem Benutzer als drittem
|
||||
* Argument aufruft.
|
||||
*
|
||||
* Dazu die Grenze Dienst -> Dateibereich (hk4, Tests 13-22): KEIN
|
||||
* `fs`-Mock, sondern ein echtes Verzeichnis unter `os.tmpdir()` (Muster
|
||||
* desktop.service.spec.ts) ueber den Testschalter
|
||||
* `DASHBOARD_IMAGES_DIR` — der Dienst schreibt und liest wirklich.
|
||||
* Geprueft werden Ablageort und Dateiname (IMMER die UUID der Zeile plus
|
||||
* die Endung aus dem ERKANNTEN Typ, NIE `originalName`, T-HK4-01), das
|
||||
* Zuruecknehmen der Zeile bei fehlgeschlagenem Schreiben (T-HK4-04), 404
|
||||
* bei fehlender Datei, das Mitloeschen der Datei und der automatische
|
||||
* Umzug beim Start (T-HK4-03).
|
||||
*/
|
||||
|
||||
interface ImageRow {
|
||||
@@ -37,11 +57,13 @@ interface ImageRow {
|
||||
originalName: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
data: Uint8Array;
|
||||
data: Uint8Array | null;
|
||||
storagePath: string | null;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
interface BoundCall {
|
||||
via: 'tenant' | 'system';
|
||||
tenantId: string;
|
||||
userId: string | undefined;
|
||||
model: string;
|
||||
@@ -55,11 +77,31 @@ interface FakePrisma {
|
||||
__rows: ImageRow[];
|
||||
__boundCallLog: BoundCall[];
|
||||
__makeBoundClient(tenantId: string, userId?: string): { dashboardImage: ModelMethods };
|
||||
__makeSystemClient(): { dashboardImage: ModelMethods };
|
||||
}
|
||||
|
||||
const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13]);
|
||||
const JPEG = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0, 0x10, 0x4a, 0x46]);
|
||||
const TEXT = Buffer.from('nur Text, kein Bild');
|
||||
|
||||
/** Ablageort der Testdateien — echtes Verzeichnis, kein fs-Mock. */
|
||||
let imagesDir: string;
|
||||
const ORIGINAL_DIR_ENV = process.env.DASHBOARD_IMAGES_DIR;
|
||||
|
||||
beforeAll(() => {
|
||||
imagesDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tessera-dashboard-images-'));
|
||||
process.env.DASHBOARD_IMAGES_DIR = imagesDir;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
fs.rmSync(imagesDir, { recursive: true, force: true });
|
||||
if (ORIGINAL_DIR_ENV === undefined) {
|
||||
delete process.env.DASHBOARD_IMAGES_DIR;
|
||||
} else {
|
||||
process.env.DASHBOARD_IMAGES_DIR = ORIGINAL_DIR_ENV;
|
||||
}
|
||||
});
|
||||
|
||||
function makeRow(overrides: Partial<ImageRow> = {}): ImageRow {
|
||||
return {
|
||||
id: overrides.id ?? 'img-1',
|
||||
@@ -68,11 +110,30 @@ function makeRow(overrides: Partial<ImageRow> = {}): ImageRow {
|
||||
originalName: overrides.originalName ?? 'foto.png',
|
||||
mimeType: overrides.mimeType ?? 'image/png',
|
||||
size: overrides.size ?? PNG.length,
|
||||
data: overrides.data ?? Uint8Array.from(PNG),
|
||||
data: overrides.data === undefined ? null : overrides.data,
|
||||
storagePath: overrides.storagePath === undefined ? null : overrides.storagePath,
|
||||
createdAt: overrides.createdAt ?? new Date('2026-01-01'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Legt eine Zeile MIT passender Datei auf der Platte an — der Normalfall
|
||||
* nach dem Upload (die Tests 8-12 pruefen Besitz und Bindung, nicht die
|
||||
* Ablage).
|
||||
*/
|
||||
function makeStoredRow(overrides: Partial<ImageRow> = {}, bytes: Buffer = PNG): ImageRow {
|
||||
const row = makeRow(overrides);
|
||||
const relative = `user-files/dashboard-images/${row.userId}/${row.id}.png`;
|
||||
const absolute = path.join(imagesDir, row.userId, `${row.id}.png`);
|
||||
fs.mkdirSync(path.dirname(absolute), { recursive: true });
|
||||
fs.writeFileSync(absolute, bytes);
|
||||
return { ...row, storagePath: overrides.storagePath === undefined ? relative : overrides.storagePath };
|
||||
}
|
||||
|
||||
function storedFile(userId: string, id: string, ext = 'png'): string {
|
||||
return path.join(imagesDir, userId, `${id}.${ext}`);
|
||||
}
|
||||
|
||||
function pick(row: ImageRow, select: Record<string, boolean> | undefined) {
|
||||
if (!select) return row;
|
||||
const out: Record<string, unknown> = {};
|
||||
@@ -86,9 +147,20 @@ function makeFakePrisma(rows: ImageRow[] = []): FakePrisma {
|
||||
const boundCallLog: BoundCall[] = [];
|
||||
const dashboardImage: ModelMethods = {
|
||||
findMany: vi.fn(async (raw: unknown) => {
|
||||
const args = raw as { where: { tenantId: string; userId: string }; select?: Record<string, boolean> };
|
||||
const args = raw as {
|
||||
where: { tenantId?: string; userId?: string; storagePath?: string | null };
|
||||
select?: Record<string, boolean>;
|
||||
};
|
||||
const where = args.where ?? {};
|
||||
return rows
|
||||
.filter((r) => r.tenantId === args.where.tenantId && r.userId === args.where.userId)
|
||||
.filter((r) => {
|
||||
if (where.tenantId !== undefined && r.tenantId !== where.tenantId) return false;
|
||||
if (where.userId !== undefined && r.userId !== where.userId) return false;
|
||||
if ('storagePath' in where && where.storagePath === null && r.storagePath !== null) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.slice()
|
||||
.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())
|
||||
.map((r) => pick(r, args.select));
|
||||
@@ -98,11 +170,18 @@ function makeFakePrisma(rows: ImageRow[] = []): FakePrisma {
|
||||
return rows.filter((r) => r.tenantId === args.where.tenantId && r.userId === args.where.userId).length;
|
||||
}),
|
||||
create: vi.fn(async (raw: unknown) => {
|
||||
const args = raw as { data: Omit<ImageRow, 'id' | 'createdAt'>; select?: Record<string, boolean> };
|
||||
const args = raw as { data: Partial<ImageRow>; select?: Record<string, boolean> };
|
||||
const created = makeRow({ id: `new-${rows.length + 1}`, ...args.data, createdAt: new Date('2026-02-02') });
|
||||
rows.push(created);
|
||||
return pick(created, args.select);
|
||||
}),
|
||||
update: vi.fn(async (raw: unknown) => {
|
||||
const args = raw as { where: { id: string }; data: Partial<ImageRow> };
|
||||
const row = rows.find((r) => r.id === args.where.id);
|
||||
if (!row) throw new Error(`update: Zeile '${args.where.id}' gibt es nicht`);
|
||||
Object.assign(row, args.data);
|
||||
return row;
|
||||
}),
|
||||
findUnique: vi.fn(async (raw: unknown) => {
|
||||
const args = raw as { where: { id: string } };
|
||||
return rows.find((r) => r.id === args.where.id) ?? null;
|
||||
@@ -116,19 +195,26 @@ function makeFakePrisma(rows: ImageRow[] = []): FakePrisma {
|
||||
}),
|
||||
};
|
||||
|
||||
function wrap(via: 'tenant' | 'system', tenantId: string, userId?: string) {
|
||||
const wrapped: ModelMethods = {};
|
||||
for (const method of Object.keys(dashboardImage)) {
|
||||
wrapped[method] = async (...args: unknown[]) => {
|
||||
boundCallLog.push({ via, tenantId, userId, model: 'dashboardImage', method });
|
||||
return dashboardImage[method](...args);
|
||||
};
|
||||
}
|
||||
return { dashboardImage: wrapped };
|
||||
}
|
||||
|
||||
const fake: FakePrisma = {
|
||||
dashboardImage,
|
||||
__rows: rows,
|
||||
__boundCallLog: boundCallLog,
|
||||
__makeBoundClient(tenantId: string, userId?: string) {
|
||||
const wrapped: ModelMethods = {};
|
||||
for (const method of Object.keys(dashboardImage)) {
|
||||
wrapped[method] = async (...args: unknown[]) => {
|
||||
boundCallLog.push({ tenantId, userId, model: 'dashboardImage', method });
|
||||
return dashboardImage[method](...args);
|
||||
};
|
||||
}
|
||||
return { dashboardImage: wrapped };
|
||||
return wrap('tenant', tenantId, userId);
|
||||
},
|
||||
__makeSystemClient() {
|
||||
return wrap('system', '', undefined);
|
||||
},
|
||||
};
|
||||
return fake;
|
||||
@@ -156,6 +242,7 @@ function file(buffer: Buffer, mimetype: string, originalname = 'foto.png'): Uplo
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(forTenant).mockClear();
|
||||
vi.mocked(forSystem).mockClear();
|
||||
});
|
||||
|
||||
describe('DashboardImagesService (quick-260921-pi9)', () => {
|
||||
@@ -172,6 +259,7 @@ describe('DashboardImagesService (quick-260921-pi9)', () => {
|
||||
}
|
||||
const call = vi.mocked(prisma.dashboardImage.findMany).mock.calls[0][0] as { select: Record<string, boolean> };
|
||||
expect(call.select.data).toBeUndefined();
|
||||
expect(call.select.storagePath).toBeUndefined();
|
||||
});
|
||||
|
||||
it('Test 2: upload ohne Datei -> BadRequestException mit deutscher Meldung', async () => {
|
||||
@@ -234,29 +322,60 @@ describe('DashboardImagesService (quick-260921-pi9)', () => {
|
||||
});
|
||||
|
||||
it('Test 8: getBytes — fremder Benutzer (gleicher Mandant) -> NotFoundException, nie Forbidden', async () => {
|
||||
const prisma = makeFakePrisma([makeRow({ id: 'img-1', userId: 'user-2' })]);
|
||||
const prisma = makeFakePrisma([makeStoredRow({ id: 'img-1', userId: 'user-2' })]);
|
||||
await expect(makeService(prisma).getBytes('img-1', 'user-1', 'tenant-1')).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('Test 9: getBytes — fremder Mandant (gleicher Benutzer) -> NotFoundException; unbekannte Kennung ebenso', async () => {
|
||||
const prisma = makeFakePrisma([makeRow({ id: 'img-1', tenantId: 'tenant-2' })]);
|
||||
const prisma = makeFakePrisma([makeStoredRow({ id: 'img-1', tenantId: 'tenant-2' })]);
|
||||
const service = makeService(prisma);
|
||||
await expect(service.getBytes('img-1', 'user-1', 'tenant-1')).rejects.toThrow(NotFoundException);
|
||||
await expect(service.getBytes('gibt-es-nicht', 'user-1', 'tenant-1')).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('Test 10: getBytes — eigenes Bild liefert mimeType und die gespeicherten Bytes', async () => {
|
||||
const prisma = makeFakePrisma([makeRow({ id: 'img-1' })]);
|
||||
const prisma = makeFakePrisma([makeStoredRow({ id: 'img-1' })]);
|
||||
const result = await makeService(prisma).getBytes('img-1', 'user-1', 'tenant-1');
|
||||
expect(result.mimeType).toBe('image/png');
|
||||
expect(Buffer.from(result.data).equals(PNG)).toBe(true);
|
||||
});
|
||||
|
||||
it('Test 10b: getBytes — Datei fehlt, aber die alte Spalte `data` traegt die Bytes noch: wiederherstellen statt 404', async () => {
|
||||
// Fall aus dem Browser-Rundgang 22.09.2026: ein `pg_dump` von vor dem Umzug
|
||||
// traegt die Bytes noch, das Volume `user-files` wird getrennt gesichert —
|
||||
// wer nur den Abzug zurueckspielt, haette sonst Zeilen ohne Datei.
|
||||
const row = makeRow({
|
||||
id: 'img-alt',
|
||||
data: PNG,
|
||||
storagePath: 'user-files/dashboard-images/user-1/img-alt.png',
|
||||
});
|
||||
const prisma = makeFakePrisma([row]);
|
||||
expect(fs.existsSync(storedFile('user-1', 'img-alt'))).toBe(false);
|
||||
|
||||
const result = await makeService(prisma).getBytes('img-alt', 'user-1', 'tenant-1');
|
||||
|
||||
expect(Buffer.from(result.data).equals(PNG)).toBe(true);
|
||||
expect(fs.existsSync(storedFile('user-1', 'img-alt'))).toBe(true);
|
||||
expect(fs.readFileSync(storedFile('user-1', 'img-alt')).equals(PNG)).toBe(true);
|
||||
});
|
||||
|
||||
it('Test 10c: getBytes — Datei fehlt UND `data` ist leer -> 404', async () => {
|
||||
const row = makeRow({
|
||||
id: 'img-weg',
|
||||
data: null,
|
||||
storagePath: 'user-files/dashboard-images/user-1/img-weg.png',
|
||||
});
|
||||
const prisma = makeFakePrisma([row]);
|
||||
await expect(makeService(prisma).getBytes('img-weg', 'user-1', 'tenant-1')).rejects.toThrow(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
|
||||
it('Test 11: remove — eigenes Bild wird geloescht und { id } geliefert; fremdes (Benutzer ODER Mandant) -> 404 ohne Loeschung', async () => {
|
||||
const prisma = makeFakePrisma([
|
||||
makeRow({ id: 'eigen' }),
|
||||
makeRow({ id: 'fremd-user', userId: 'user-2' }),
|
||||
makeRow({ id: 'fremd-tenant', tenantId: 'tenant-2' }),
|
||||
makeStoredRow({ id: 'eigen' }),
|
||||
makeStoredRow({ id: 'fremd-user', userId: 'user-2' }),
|
||||
makeStoredRow({ id: 'fremd-tenant', tenantId: 'tenant-2' }),
|
||||
]);
|
||||
const service = makeService(prisma);
|
||||
await expect(service.remove('eigen', 'user-1', 'tenant-1')).resolves.toEqual({ id: 'eigen' });
|
||||
@@ -267,12 +386,12 @@ describe('DashboardImagesService (quick-260921-pi9)', () => {
|
||||
});
|
||||
|
||||
it('Test 12: jede Methode bindet mit (prisma, tenantId, userId) und laeuft NUR ueber den gebundenen Klienten', async () => {
|
||||
const prisma = makeFakePrisma([makeRow({ id: 'img-1' })]);
|
||||
const prisma = makeFakePrisma([]);
|
||||
const service = makeService(prisma);
|
||||
await service.list('user-1', 'tenant-1');
|
||||
await service.upload(user, file(PNG, 'image/png'));
|
||||
await service.getBytes('img-1', 'user-1', 'tenant-1');
|
||||
await service.remove('img-1', 'user-1', 'tenant-1');
|
||||
const created = await service.upload(user, file(PNG, 'image/png'));
|
||||
await service.getBytes(created.id, 'user-1', 'tenant-1');
|
||||
await service.remove(created.id, 'user-1', 'tenant-1');
|
||||
|
||||
expect(vi.mocked(forTenant)).toHaveBeenCalledTimes(4);
|
||||
for (const call of vi.mocked(forTenant).mock.calls) {
|
||||
@@ -280,12 +399,174 @@ describe('DashboardImagesService (quick-260921-pi9)', () => {
|
||||
expect(call[1]).toBe('tenant-1');
|
||||
expect(call[2]).toBe('user-1');
|
||||
}
|
||||
// Jeder Modellaufruf steht im Protokoll des gebundenen Klienten.
|
||||
// Jeder Modellaufruf steht im Protokoll des gebundenen Klienten; der
|
||||
// Upload schreibt den Ablageort in einem zweiten Schritt nach, weil die
|
||||
// UUID der Zeile erst nach `create` feststeht (hk4).
|
||||
const methods = prisma.__boundCallLog.map((c) => c.method);
|
||||
expect(methods).toEqual(['findMany', 'count', 'create', 'findUnique', 'findUnique', 'delete']);
|
||||
expect(methods).toEqual(['findMany', 'count', 'create', 'update', 'findUnique', 'findUnique', 'delete']);
|
||||
for (const c of prisma.__boundCallLog) {
|
||||
expect(c.via).toBe('tenant');
|
||||
expect(c.tenantId).toBe('tenant-1');
|
||||
expect(c.userId).toBe('user-1');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('DashboardImagesService — Ablage im Dateibereich (quick-260922-hk4)', () => {
|
||||
it('Test 13: upload schreibt die Datei unter <dir>/<userId>/<id>.png und speichert den relativen Pfad in der Zeile', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const result = await makeService(prisma).upload(user, file(PNG, 'image/png'));
|
||||
|
||||
const onDisk = storedFile('user-1', result.id);
|
||||
expect(fs.existsSync(onDisk)).toBe(true);
|
||||
expect(fs.readFileSync(onDisk).equals(PNG)).toBe(true);
|
||||
expect(prisma.__rows[0].storagePath).toBe(`user-files/dashboard-images/user-1/${result.id}.png`);
|
||||
// Die Bytes gehen NICHT mehr in die Zeile (das ist der ganze Zweck).
|
||||
expect(prisma.__rows[0].data).toBeNull();
|
||||
const createArgs = vi.mocked(prisma.dashboardImage.create).mock.calls[0][0] as { data: Record<string, unknown> };
|
||||
expect(createArgs.data.data).toBeUndefined();
|
||||
});
|
||||
|
||||
it('Test 14: der Dateiname ist IMMER die UUID plus die Endung des ERKANNTEN Typs — originalName kommt nie im Pfad vor (T-HK4-01)', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = makeService(prisma);
|
||||
const boeserName = '../../../etc/passwd.png';
|
||||
const result = await service.upload(user, file(JPEG, 'image/png', boeserName));
|
||||
|
||||
// Erkannt wurde JPEG (Magic Bytes), also .jpg — nicht .png aus dem Namen.
|
||||
expect(result.mimeType).toBe('image/jpeg');
|
||||
const stored = prisma.__rows[0].storagePath ?? '';
|
||||
expect(stored).toBe(`user-files/dashboard-images/user-1/${result.id}.jpg`);
|
||||
expect(stored).not.toContain('passwd');
|
||||
expect(stored).not.toContain('..');
|
||||
expect(fs.existsSync(storedFile('user-1', result.id, 'jpg'))).toBe(true);
|
||||
// Der Anzeigename bleibt in der Zeile erhalten, nur eben als Text.
|
||||
expect(result.originalName).toBe(boeserName);
|
||||
});
|
||||
|
||||
it('Test 15: scheitert das Schreiben, wird die eben angelegte Zeile wieder geloescht und 500 geworfen (T-HK4-04)', async () => {
|
||||
const blocker = path.join(imagesDir, 'blockade');
|
||||
fs.writeFileSync(blocker, 'ich bin eine Datei, kein Verzeichnis');
|
||||
const vorher = process.env.DASHBOARD_IMAGES_DIR;
|
||||
process.env.DASHBOARD_IMAGES_DIR = path.join(blocker, 'unmoeglich');
|
||||
try {
|
||||
const prisma = makeFakePrisma();
|
||||
await expect(makeService(prisma).upload(user, file(PNG, 'image/png'))).rejects.toThrow(
|
||||
InternalServerErrorException,
|
||||
);
|
||||
expect(prisma.dashboardImage.create).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.dashboardImage.delete).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.__rows).toHaveLength(0);
|
||||
} finally {
|
||||
process.env.DASHBOARD_IMAGES_DIR = vorher;
|
||||
}
|
||||
});
|
||||
|
||||
it('Test 16: getBytes liest den Dateiinhalt (nicht die Zeile) — auch wenn in der Zeile noch alte Bytes stehen', async () => {
|
||||
const prisma = makeFakePrisma([makeStoredRow({ id: 'img-1', data: Uint8Array.from(TEXT) }, PNG)]);
|
||||
const result = await makeService(prisma).getBytes('img-1', 'user-1', 'tenant-1');
|
||||
expect(Buffer.from(result.data).equals(PNG)).toBe(true);
|
||||
});
|
||||
|
||||
it('Test 17: Zeile vorhanden, Datei fehlt -> NotFoundException (die Kachel zeigt „Bild nicht verfügbar")', async () => {
|
||||
// Eigene Kennung: das Verzeichnis ist ueber alle Tests dieser Datei
|
||||
// dasselbe, eine von Test 8/10 angelegte `img-1.png` waere sonst da.
|
||||
const prisma = makeFakePrisma([
|
||||
makeRow({ id: 'datei-fehlt', storagePath: 'user-files/dashboard-images/user-1/datei-fehlt.png' }),
|
||||
]);
|
||||
await expect(makeService(prisma).getBytes('datei-fehlt', 'user-1', 'tenant-1')).rejects.toThrow(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
|
||||
it('Test 18: Zeile ohne storagePath (noch nicht umgezogen) -> NotFoundException statt Absturz', async () => {
|
||||
const prisma = makeFakePrisma([makeRow({ id: 'img-1', data: Uint8Array.from(PNG) })]);
|
||||
await expect(makeService(prisma).getBytes('img-1', 'user-1', 'tenant-1')).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('Test 19: remove loescht Zeile UND Datei', async () => {
|
||||
const prisma = makeFakePrisma([makeStoredRow({ id: 'weg' })]);
|
||||
const onDisk = storedFile('user-1', 'weg');
|
||||
expect(fs.existsSync(onDisk)).toBe(true);
|
||||
|
||||
await expect(makeService(prisma).remove('weg', 'user-1', 'tenant-1')).resolves.toEqual({ id: 'weg' });
|
||||
expect(prisma.__rows).toHaveLength(0);
|
||||
expect(fs.existsSync(onDisk)).toBe(false);
|
||||
});
|
||||
|
||||
it('Test 20: fehlt die Datei beim Loeschen, gelingt das Loeschen trotzdem (eine Dateileiche ist harmloser als eine haengende Loeschung)', async () => {
|
||||
const prisma = makeFakePrisma([
|
||||
makeRow({ id: 'nur-zeile', storagePath: 'user-files/dashboard-images/user-1/nur-zeile.png' }),
|
||||
]);
|
||||
await expect(makeService(prisma).remove('nur-zeile', 'user-1', 'tenant-1')).resolves.toEqual({
|
||||
id: 'nur-zeile',
|
||||
});
|
||||
expect(prisma.__rows).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DashboardImagesService — Umzug beim Start (quick-260922-hk4, T-HK4-03)', () => {
|
||||
it('Test 21: onApplicationBootstrap schreibt die Bytes alter Zeilen auf die Platte und setzt storagePath — systemgebunden lesen, je Zeile mandantengebunden schreiben', async () => {
|
||||
const alt = makeRow({ id: 'alt-1', data: Uint8Array.from(PNG) });
|
||||
const fremderMandant = makeRow({
|
||||
id: 'alt-2',
|
||||
userId: 'user-9',
|
||||
tenantId: 'tenant-2',
|
||||
mimeType: 'image/jpeg',
|
||||
data: Uint8Array.from(JPEG),
|
||||
});
|
||||
const schonUmgezogen = makeStoredRow({ id: 'neu-1' });
|
||||
const prisma = makeFakePrisma([alt, fremderMandant, schonUmgezogen]);
|
||||
|
||||
await makeService(prisma).onApplicationBootstrap();
|
||||
|
||||
expect(fs.readFileSync(storedFile('user-1', 'alt-1')).equals(PNG)).toBe(true);
|
||||
expect(fs.readFileSync(storedFile('user-9', 'alt-2', 'jpg')).equals(JPEG)).toBe(true);
|
||||
expect(prisma.__rows[0].storagePath).toBe('user-files/dashboard-images/user-1/alt-1.png');
|
||||
expect(prisma.__rows[1].storagePath).toBe('user-files/dashboard-images/user-9/alt-2.jpg');
|
||||
|
||||
// Gelesen wird EINMAL ueber den Systemkontext, geschrieben je Zeile
|
||||
// ueber einen Klienten, der auf Mandant UND Benutzer DIESER Zeile
|
||||
// gebunden ist.
|
||||
expect(vi.mocked(forSystem)).toHaveBeenCalledTimes(1);
|
||||
expect(vi.mocked(forSystem).mock.calls[0][0]).toBe(prisma);
|
||||
const leseAufrufe = prisma.__boundCallLog.filter((c) => c.via === 'system');
|
||||
expect(leseAufrufe.map((c) => c.method)).toEqual(['findMany']);
|
||||
const findManyArgs = vi.mocked(prisma.dashboardImage.findMany).mock.calls[0][0] as {
|
||||
where: Record<string, unknown>;
|
||||
};
|
||||
expect(findManyArgs.where.storagePath).toBeNull();
|
||||
|
||||
expect(vi.mocked(forTenant)).toHaveBeenCalledTimes(2);
|
||||
expect(vi.mocked(forTenant).mock.calls[0].slice(1)).toEqual(['tenant-1', 'user-1']);
|
||||
expect(vi.mocked(forTenant).mock.calls[1].slice(1)).toEqual(['tenant-2', 'user-9']);
|
||||
const schreibAufrufe = prisma.__boundCallLog.filter((c) => c.via === 'tenant');
|
||||
expect(schreibAufrufe.map((c) => c.method)).toEqual(['update', 'update']);
|
||||
|
||||
// Die bereits umgezogene Zeile wird nicht angefasst.
|
||||
expect(prisma.__rows[2].storagePath).toBe('user-files/dashboard-images/user-1/neu-1.png');
|
||||
});
|
||||
|
||||
it('Test 22: ohne offene Zeilen bleibt der Start still — kein Schreibzugriff, keine Bindung je Mandant', async () => {
|
||||
const prisma = makeFakePrisma([makeStoredRow({ id: 'neu-2' })]);
|
||||
await makeService(prisma).onApplicationBootstrap();
|
||||
|
||||
expect(vi.mocked(forSystem)).toHaveBeenCalledTimes(1);
|
||||
expect(vi.mocked(forTenant)).not.toHaveBeenCalled();
|
||||
expect(prisma.dashboardImage.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Test 23: der Umzug ist wiederholbar — ein zweiter Lauf findet nichts mehr und ueberschreibt nichts', async () => {
|
||||
const prisma = makeFakePrisma([makeRow({ id: 'alt-3', data: Uint8Array.from(PNG) })]);
|
||||
const service = makeService(prisma);
|
||||
await service.onApplicationBootstrap();
|
||||
const ersterStand = fs.statSync(storedFile('user-1', 'alt-3')).mtimeMs;
|
||||
|
||||
vi.mocked(forTenant).mockClear();
|
||||
await service.onApplicationBootstrap();
|
||||
|
||||
expect(vi.mocked(forTenant)).not.toHaveBeenCalled();
|
||||
expect(fs.statSync(storedFile('user-1', 'alt-3')).mtimeMs).toBe(ersterStand);
|
||||
expect(prisma.__rows[0].storagePath).toBe('user-files/dashboard-images/user-1/alt-3.png');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
InternalServerErrorException,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
type OnApplicationBootstrap,
|
||||
} from '@nestjs/common';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import type { AuthUser, UploadedFileLike } from '../auth/types/auth-user';
|
||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||
import { forSystem, forTenant } from '../prisma/prisma-tenant.extension';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import {
|
||||
DASHBOARD_IMAGE_MAX_COUNT,
|
||||
@@ -10,20 +19,53 @@ import {
|
||||
|
||||
/**
|
||||
* DashboardImagesService — hochgeladene Bilder des Bilderrahmen-Widgets
|
||||
* (quick-260921-pi9).
|
||||
* (quick-260921-pi9), seit quick-260922-hk4 im Dateibereich statt in der
|
||||
* Datenbank.
|
||||
*
|
||||
* Ein Bild gehoert dem hochladenden Benutzer: Besitz = gleicher Mandant UND
|
||||
* gleicher Benutzer. Die Besitzpruefung in `getBytes`/`remove` (Zeile holen,
|
||||
* `userId` UND `tenantId` gegen den Sitzungsnachweis vergleichen, sonst 404)
|
||||
* ist NICHT dekorativ: die RLS-Regel auf `DashboardImage` (Migration
|
||||
* 20260921120000, mit Benutzerdimension) wirkt erst, wenn die Anwendung als
|
||||
* Rolle ohne Umgehungsrecht verbindet — der Schalter ist heute AUS
|
||||
* (docs/mandantentrennung-datenbankrolle.md). Bis dahin ist der Vergleich
|
||||
* hier der einzige wirksame Schutz gegen Quer-Lesen und Quer-Loeschen; die
|
||||
* `forTenant()`-Bindung je Methode LEGT eine Mandantengrenze obendrauf, sie
|
||||
* ersetzt den Vergleich nicht (Muster dashboard.service.ts). Nach dem
|
||||
* Scharfschalten liefert `findUnique` fuer eine fremde Zeile bereits `null`
|
||||
* — die Antwort bleibt 404, nur der Weg dorthin aendert sich.
|
||||
* WO DIE BYTES LIEGEN (hk4): unter
|
||||
* `user-files/dashboard-images/<userId>/<id>.<png|jpg|gif|webp>`, die Zeile
|
||||
* haelt nur noch den relativen Pfad in `storagePath` — dasselbe Muster wie
|
||||
* `User.avatarPath` (user.controller.ts) und die DKV-Ausfuhren
|
||||
* (dkv-export.service.ts). Grund ist die Sicherung: gesichert wird von Hand
|
||||
* per `pg_dump` (docs/anleitung-betrieb.md Kap. 6), und 30 Bilder à 5 MiB je
|
||||
* Benutzer waeren im Extremfall 150 MB pro Benutzer in jedem Abzug. Das
|
||||
* Volume `user-files` wird daneben gesichert. Geschwindigkeit war NICHT das
|
||||
* Argument (ein Bild wird je Browser einmal taeglich geladen).
|
||||
*
|
||||
* DER DATEINAME KOMMT IMMER VOM SERVER (T-HK4-01, Muster T-07-09 aus
|
||||
* `dkv-export.service.ts`): er ist die UUID der Zeile plus die Endung aus
|
||||
* dem an den Magic Bytes ERKANNTEN Mime-Typ. `originalName` ist reiner
|
||||
* Anzeigetext und erscheint weder im Pfad noch in einem Header (T-PI9-06).
|
||||
* `absoluteImagePath()` prueft zusaetzlich, dass der aus der Zeile
|
||||
* gelesene Pfad im Bilderverzeichnis liegt — ein Wert aus der Datenbank
|
||||
* wird nie ungeprueft an `path.join` gereicht.
|
||||
*
|
||||
* EIN EIGENER ORDNER JE BENUTZER IST KEIN SCHUTZ: wer welches Bild sehen
|
||||
* darf, entscheidet weiterhin dieser Dienst. Die Datei wird nie direkt
|
||||
* ausgeliefert, nur ueber `GET /dashboard/images/:id` mit Besitzpruefung
|
||||
* (T-HK4-02); das Volume haengt in keinem Webserver.
|
||||
*
|
||||
* HALBE ZUSTAENDE (T-HK4-04, bewusst benannt): beim Upload entsteht ZUERST
|
||||
* die Zeile (erst danach steht die UUID fest), dann die Datei; scheitert
|
||||
* das Schreiben, wird die Zeile wieder geloescht und 500 geworfen. Beim
|
||||
* Loeschen faellt ZUERST die Zeile, ein Fehler beim Entfernen der Datei
|
||||
* wird protokolliert und geschluckt — eine Dateileiche ist harmloser als
|
||||
* eine haengende Loeschung. Fehlt die Datei beim Lesen, ist die Antwort
|
||||
* 404 und die Kachel zeigt „Bild nicht verfügbar".
|
||||
*
|
||||
* Besitz: ein Bild gehoert dem hochladenden Benutzer (gleicher Mandant UND
|
||||
* gleicher Benutzer). Die Besitzpruefung in `getBytes`/`remove` (Zeile
|
||||
* holen, `userId` UND `tenantId` gegen den Sitzungsnachweis vergleichen,
|
||||
* sonst 404) ist NICHT dekorativ: die RLS-Regel auf `DashboardImage`
|
||||
* (Migration 20260921120000, mit Benutzerdimension) wirkt erst, wenn die
|
||||
* Anwendung als Rolle ohne Umgehungsrecht verbindet — der Schalter ist
|
||||
* heute AUS (docs/mandantentrennung-datenbankrolle.md). Bis dahin ist der
|
||||
* Vergleich hier der einzige wirksame Schutz gegen Quer-Lesen und
|
||||
* Quer-Loeschen; die `forTenant()`-Bindung je Methode LEGT eine
|
||||
* Mandantengrenze obendrauf, sie ersetzt den Vergleich nicht (Muster
|
||||
* dashboard.service.ts). Nach dem Scharfschalten liefert `findUnique` fuer
|
||||
* eine fremde Zeile bereits `null` — die Antwort bleibt 404, nur der Weg
|
||||
* dorthin aendert sich.
|
||||
*
|
||||
* Warum 404 und nie 403 (T-PI9-04): ein 403 wuerde verraten, dass die
|
||||
* Kennung existiert. Kennungen sind `uuid()`, nicht erratbar.
|
||||
@@ -31,9 +73,7 @@ import {
|
||||
* Warum der Typ aus den Magic Bytes kommt (T-PI9-01, T-PI9-08):
|
||||
* `file.mimetype` und `originalname` behauptet der Browser; gespeichert und
|
||||
* spaeter als `Content-Type` ausgeliefert wird ausschliesslich das, was
|
||||
* `detectImageMime` an den Bytes erkannt hat. Der Dateiname wird nur als
|
||||
* Anzeigetext gefuehrt (auf 255 Zeichen gekuerzt) und erscheint nie in
|
||||
* einem HTTP-Header (T-PI9-06).
|
||||
* `detectImageMime` an den Bytes erkannt hat.
|
||||
*
|
||||
* Zaehler (T-PI9-03): `count` je Mandant+Benutzer vor `create` im selben
|
||||
* Dienst. Zwei gleichzeitige Uploads desselben Benutzers koennen die Grenze
|
||||
@@ -47,7 +87,10 @@ import {
|
||||
|
||||
const ORIGINAL_NAME_MAX = 255;
|
||||
|
||||
/** Metadaten-Auswahl fuer Liste und Upload-Antwort — `data` NIE dabei. */
|
||||
/** Ablageort unterhalb der Monorepo-Wurzel, so wie er in der Zeile steht. */
|
||||
const STORAGE_PREFIX = 'user-files/dashboard-images/';
|
||||
|
||||
/** Metadaten-Auswahl fuer Liste und Upload-Antwort — nie Bytes, nie Pfad. */
|
||||
const META_SELECT = {
|
||||
id: true,
|
||||
originalName: true,
|
||||
@@ -64,10 +107,124 @@ export interface DashboardImageMeta {
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loest das Bilderverzeichnis relativ zur Monorepo-Wurzel auf — Muster
|
||||
* `resolveAvatarsDir()` (user.controller.ts): zur Laufzeit ist
|
||||
* `__dirname` = apps/api/dist/dashboard/, also vier Ebenen hoch.
|
||||
*
|
||||
* `DASHBOARD_IMAGES_DIR` ist ein Testschalter (Muster `DESKTOP_DIST_DIR`,
|
||||
* desktop.service.ts) und im Betrieb nie gesetzt; die Tests zeigen damit
|
||||
* auf ein Wegwerfverzeichnis unter `os.tmpdir()`, statt `fs` nachzubauen.
|
||||
*/
|
||||
export function resolveDashboardImagesDir(): string {
|
||||
const override = process.env.DASHBOARD_IMAGES_DIR;
|
||||
if (override !== undefined && override !== '') {
|
||||
return path.resolve(override);
|
||||
}
|
||||
return path.resolve(__dirname, '..', '..', '..', '..', 'user-files', 'dashboard-images');
|
||||
}
|
||||
|
||||
/** Endung aus dem ERKANNTEN Typ; alles andere ergibt `null`, nie eine Vermutung. */
|
||||
function extensionFor(mimeType: string): string | null {
|
||||
switch (mimeType) {
|
||||
case 'image/png':
|
||||
return 'png';
|
||||
case 'image/jpeg':
|
||||
return 'jpg';
|
||||
case 'image/gif':
|
||||
return 'gif';
|
||||
case 'image/webp':
|
||||
return 'webp';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Relativer Pfad, wie er in der Zeile steht (`storagePath`). */
|
||||
function relativeStoragePath(userId: string, id: string, extension: string): string {
|
||||
return `${STORAGE_PREFIX}${userId}/${id}.${extension}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wandelt den in der Zeile gespeicherten Pfad in einen absoluten Pfad im
|
||||
* Bilderverzeichnis um — und gibt `null` zurueck, sobald der Wert nicht
|
||||
* die erwartete Form hat oder aus dem Verzeichnis herausfuehren wuerde
|
||||
* (T-HK4-01). Der Aufrufer behandelt `null` wie eine fehlende Datei.
|
||||
*/
|
||||
function absoluteImagePath(storagePath: string): string | null {
|
||||
const normalized = storagePath.split('\\').join('/');
|
||||
if (!normalized.startsWith(STORAGE_PREFIX)) return null;
|
||||
|
||||
const base = resolveDashboardImagesDir();
|
||||
const absolute = path.resolve(base, normalized.slice(STORAGE_PREFIX.length));
|
||||
if (absolute !== base && !absolute.startsWith(base + path.sep)) return null;
|
||||
return absolute;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class DashboardImagesService {
|
||||
export class DashboardImagesService implements OnApplicationBootstrap {
|
||||
private readonly logger = new Logger(DashboardImagesService.name);
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
/**
|
||||
* Einmaliger Umzug der Bestandsbilder beim Start (T-HK4-03), damit der
|
||||
* Betreiber nichts von Hand ausfuehren muss.
|
||||
*
|
||||
* ZWEISTUFIG, und deshalb steht die Spalte `data` noch im Schema: die
|
||||
* SQL-Migration 20260922120000 legt nur `storagePath` an und macht `data`
|
||||
* NULLbar; `migrate deploy` laeuft VOR dem Anwendungsstart, ein sofortiges
|
||||
* DROP haette die Bytes vernichtet, bevor dieser Umzug sie lesen konnte.
|
||||
* Die DROP-Migration 20260922120100 kommt erst, wenn alpha UND live
|
||||
* einmal mit einer Version >= dieser gelaufen sind (vorgemerkt in
|
||||
* `.planning/todos/pending/`).
|
||||
*
|
||||
* GELESEN WIRD SYSTEMGEBUNDEN (`forSystem()`, Muster
|
||||
* `DkvService.loadActiveConfigsForScheduler()`): der Umzug betrifft alle
|
||||
* Mandanten, ein Startpfad hat keinen Mandanten im Ruecken. Geschrieben
|
||||
* wird je Zeile MANDANTENGEBUNDEN (`forTenant()` mit Mandant UND Benutzer
|
||||
* dieser Zeile) — unter Systemkontext ist nur Lesen geoeffnet
|
||||
* (`system_read_policy ... FOR SELECT`, fuer `DashboardImage` angelegt in
|
||||
* 20260922120000). Einmal-lesen-viele-bedienen, genau wie beim
|
||||
* DKV-Planer.
|
||||
*
|
||||
* Wiederholbar: die Abfrage nimmt nur Zeilen ohne `storagePath`, ein
|
||||
* zweiter Lauf findet nichts mehr. Eine einzelne fehlgeschlagene Zeile
|
||||
* wird protokolliert und haelt den Start nicht auf.
|
||||
*/
|
||||
async onApplicationBootstrap(): Promise<void> {
|
||||
const systemPrisma = forSystem(this.prisma);
|
||||
const pending = await systemPrisma.dashboardImage.findMany({
|
||||
where: { storagePath: null },
|
||||
select: { id: true, userId: true, tenantId: true, mimeType: true, data: true },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
|
||||
let moved = 0;
|
||||
for (const row of pending) {
|
||||
if (row.data === null) continue;
|
||||
try {
|
||||
const storagePath = await this.writeImageFile(row.userId, row.id, row.mimeType, row.data);
|
||||
const tenantPrisma = forTenant(this.prisma, row.tenantId, row.userId);
|
||||
await tenantPrisma.dashboardImage.update({
|
||||
where: { id: row.id },
|
||||
data: { storagePath },
|
||||
});
|
||||
moved += 1;
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Bilderrahmen-Bild ${row.id} konnte nicht auf die Festplatte umgezogen werden: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (moved > 0) {
|
||||
this.logger.log(`${moved} Bilderrahmen-Bilder auf die Festplatte umgezogen`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Eigene Bilder, aelteste zuerst, nur Metadaten. */
|
||||
async list(userId: string, tenantId: string): Promise<DashboardImageMeta[]> {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||
@@ -80,7 +237,13 @@ export class DashboardImagesService {
|
||||
|
||||
/**
|
||||
* Nimmt eine hochgeladene Datei an: Magic Bytes entscheiden, der Zaehler
|
||||
* begrenzt, gespeichert wird der erkannte Typ.
|
||||
* begrenzt, gespeichert wird der erkannte Typ — die Bytes auf der Platte,
|
||||
* die Zeile haelt den Pfad.
|
||||
*
|
||||
* Reihenfolge (T-HK4-04): Zeile zuerst, weil der Dateiname die UUID der
|
||||
* Zeile IST. Scheitert danach das Schreiben oder das Nachtragen des
|
||||
* Pfades, wird die Zeile wieder geloescht — lieber gar kein Bild als eine
|
||||
* Zeile ohne Datei.
|
||||
*/
|
||||
async upload(user: AuthUser, file: UploadedFileLike | undefined): Promise<DashboardImageMeta> {
|
||||
if (!file) {
|
||||
@@ -102,26 +265,41 @@ export class DashboardImagesService {
|
||||
);
|
||||
}
|
||||
|
||||
return tenantPrisma.dashboardImage.create({
|
||||
const created = await tenantPrisma.dashboardImage.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
tenantId: user.tenantId,
|
||||
originalName: file.originalname.slice(0, ORIGINAL_NAME_MAX),
|
||||
mimeType,
|
||||
size: file.buffer.length,
|
||||
// Befund am Typsystem (TS 5.9 + Prisma 6): `Bytes` verlangt
|
||||
// `Uint8Array<ArrayBuffer>`, multers `Buffer` ist aber ueber
|
||||
// `ArrayBufferLike` getypt (koennte ein SharedArrayBuffer sein) und
|
||||
// wird ohne Zusicherung abgelehnt. `new Uint8Array(buffer)` kopiert in
|
||||
// einen frischen ArrayBuffer — hoechstens 5 MiB, einmal je Upload —
|
||||
// und ist damit ehrlich getypt statt zugesichert.
|
||||
data: new Uint8Array(file.buffer),
|
||||
},
|
||||
select: META_SELECT,
|
||||
});
|
||||
|
||||
try {
|
||||
const storagePath = await this.writeImageFile(user.id, created.id, mimeType, file.buffer);
|
||||
await tenantPrisma.dashboardImage.update({
|
||||
where: { id: created.id },
|
||||
data: { storagePath },
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Bilderrahmen-Bild ${created.id} konnte nicht gespeichert werden, Zeile wird zurueckgenommen: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
await tenantPrisma.dashboardImage.delete({ where: { id: created.id } });
|
||||
throw new InternalServerErrorException('Das Bild konnte nicht gespeichert werden.');
|
||||
}
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
/** Bytes und gespeicherter Typ eines eigenen Bildes; fremd/unbekannt -> 404. */
|
||||
/**
|
||||
* Bytes und gespeicherter Typ eines eigenen Bildes; fremd/unbekannt ->
|
||||
* 404. Gelesen wird die Datei, nicht die Zeile — eine Zeile ohne Pfad
|
||||
* (noch nicht umgezogen) und eine fehlende Datei ergeben denselben 404.
|
||||
*/
|
||||
async getBytes(
|
||||
id: string,
|
||||
userId: string,
|
||||
@@ -132,10 +310,51 @@ export class DashboardImagesService {
|
||||
if (!row || row.userId !== userId || row.tenantId !== tenantId) {
|
||||
throw new NotFoundException(`Image with id '${id}' not found`);
|
||||
}
|
||||
return { mimeType: row.mimeType, data: row.data };
|
||||
|
||||
const absolute = row.storagePath === null ? null : absoluteImagePath(row.storagePath);
|
||||
if (absolute === null) {
|
||||
this.logger.warn(`Bilderrahmen-Bild ${id} hat keinen gueltigen Ablageort`);
|
||||
throw new NotFoundException(`Image with id '${id}' not found`);
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await fs.readFile(absolute);
|
||||
return { mimeType: row.mimeType, data };
|
||||
} catch (error) {
|
||||
// Selbstheilung waehrend der Umstellung (T-HK4-03): fehlt die Datei,
|
||||
// steckt aber noch die alte Spalte `data` in der Zeile, wird die Datei
|
||||
// daraus neu geschrieben und ausgeliefert. Der Fall ist real: ein
|
||||
// `pg_dump` aus der Zeit vor dem Umzug traegt die Bytes noch, das
|
||||
// Volume `user-files` wird getrennt gesichert — wer nur den Abzug
|
||||
// zurueckspielt, haette sonst Zeilen ohne Datei. Nach dem Entfernen der
|
||||
// Spalte (eigenes Todo) faellt dieser Zweig ersatzlos weg.
|
||||
if (row.data !== null) {
|
||||
try {
|
||||
await this.writeImageFile(row.userId, row.id, row.mimeType, row.data);
|
||||
this.logger.log(`Bilderrahmen-Bild ${id} aus der Datenbank wiederhergestellt`);
|
||||
return { mimeType: row.mimeType, data: row.data };
|
||||
} catch (writeError) {
|
||||
this.logger.error(
|
||||
`Bilderrahmen-Bild ${id} konnte nicht wiederhergestellt werden: ${
|
||||
writeError instanceof Error ? writeError.message : String(writeError)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
this.logger.warn(
|
||||
`Bilderrahmen-Bild ${id} fehlt im Dateibereich: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
throw new NotFoundException(`Image with id '${id}' not found`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Loescht ein eigenes Bild; fremd/unbekannt -> 404, nichts wird geloescht. */
|
||||
/**
|
||||
* Loescht ein eigenes Bild; fremd/unbekannt -> 404, nichts wird geloescht.
|
||||
* Zeile zuerst, Datei danach: ein Fehler beim Entfernen der Datei wird
|
||||
* protokolliert und geschluckt (T-HK4-04).
|
||||
*/
|
||||
async remove(id: string, userId: string, tenantId: string): Promise<{ id: string }> {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||
const row = await tenantPrisma.dashboardImage.findUnique({ where: { id } });
|
||||
@@ -143,6 +362,47 @@ export class DashboardImagesService {
|
||||
throw new NotFoundException(`Image with id '${id}' not found`);
|
||||
}
|
||||
await tenantPrisma.dashboardImage.delete({ where: { id } });
|
||||
|
||||
const absolute = row.storagePath === null ? null : absoluteImagePath(row.storagePath);
|
||||
if (absolute !== null) {
|
||||
try {
|
||||
await fs.unlink(absolute);
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`Datei des geloeschten Bilderrahmen-Bildes ${id} konnte nicht entfernt werden: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { id };
|
||||
}
|
||||
|
||||
/**
|
||||
* Schreibt die Bytes an den servergenerierten Ort und liefert den
|
||||
* relativen Pfad fuer die Zeile zurueck. Der Ordner je Benutzer entsteht
|
||||
* dabei (`recursive: true`).
|
||||
*/
|
||||
private async writeImageFile(
|
||||
userId: string,
|
||||
id: string,
|
||||
mimeType: string,
|
||||
bytes: Uint8Array,
|
||||
): Promise<string> {
|
||||
const extension = extensionFor(mimeType);
|
||||
if (extension === null) {
|
||||
throw new Error(`Unbekannter Bildtyp '${mimeType}'`);
|
||||
}
|
||||
|
||||
const storagePath = relativeStoragePath(userId, id, extension);
|
||||
const absolute = absoluteImagePath(storagePath);
|
||||
if (absolute === null) {
|
||||
throw new Error(`Ungueltiger Ablageort fuer Bild ${id}`);
|
||||
}
|
||||
|
||||
await fs.mkdir(path.dirname(absolute), { recursive: true });
|
||||
await fs.writeFile(absolute, bytes);
|
||||
return storagePath;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { ForbiddenException } from '@nestjs/common';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { AuthenticatedRequest } from '../auth/types/auth-user';
|
||||
import { DashboardController } from './dashboard.controller';
|
||||
|
||||
/**
|
||||
* dashboard.controller.spec — NEU (quick-260923-ad9, Task 2). Form aus
|
||||
* `dashboard-images.controller.spec.ts`: die Reiter-Kennung wird aus
|
||||
* Abfrageparameter bzw. Rumpf an den Dienst durchgereicht, fehlender
|
||||
* Benutzer-/Mandantenkontext führt zur vorhandenen Abweisung, und ein
|
||||
* quelltextlesender Wächter prüft, dass die feste Route `tabs/order` VOR
|
||||
* jeder Route mit Platzhalter unter demselben Präfix im Dateitext steht
|
||||
* (NestJS-Routenreihenfolge — in dieser Anwendung hat eine Route mit
|
||||
* Platzhalter schon einmal eine dahinter stehende feste Route verdeckt).
|
||||
*/
|
||||
|
||||
function makeService() {
|
||||
return {
|
||||
listDashboards: vi.fn(async () => [{ id: 'dash-1' }]),
|
||||
createDashboard: vi.fn(async () => ({ id: 'dash-2' })),
|
||||
reorderDashboards: vi.fn(async () => [{ id: 'dash-1' }, { id: 'dash-2' }]),
|
||||
renameDashboard: vi.fn(async (id: string) => ({ id })),
|
||||
deleteDashboard: vi.fn(async (id: string) => ({ id })),
|
||||
getLayout: vi.fn(async () => ({ lg: [] })),
|
||||
saveLayout: vi.fn(async () => ({ id: 'layout-1' })),
|
||||
getWidgets: vi.fn(async () => []),
|
||||
addWidget: vi.fn(async () => ({ id: 'w1' })),
|
||||
updateWidgetConfig: vi.fn(async () => ({ id: 'w1' })),
|
||||
removeWidget: vi.fn(async () => ({ id: 'w1' })),
|
||||
getSearchProviders: vi.fn(async () => []),
|
||||
addSearchProvider: vi.fn(async () => ({ id: 'sp1' })),
|
||||
removeSearchProvider: vi.fn(async () => ({ id: 'sp1' })),
|
||||
};
|
||||
}
|
||||
|
||||
function makeRequest(overrides: Partial<AuthenticatedRequest> = {}): AuthenticatedRequest {
|
||||
return {
|
||||
user: { id: 'user-1', tenantId: 'tenant-1', role: 'USER', username: 'anna', mustChangePassword: false },
|
||||
tenantId: 'tenant-1',
|
||||
...overrides,
|
||||
} as AuthenticatedRequest;
|
||||
}
|
||||
|
||||
describe('DashboardController — Reiter (quick-260923-ad9, Task 2)', () => {
|
||||
it('listDashboards: reicht userId/tenantId aus dem Sitzungsnachweis durch', async () => {
|
||||
const service = makeService();
|
||||
const controller = new DashboardController(service as never);
|
||||
|
||||
await controller.listDashboards(makeRequest());
|
||||
|
||||
expect(service.listDashboards).toHaveBeenCalledWith('user-1', 'tenant-1');
|
||||
});
|
||||
|
||||
it('createDashboard: reicht userId/tenantId durch, kein Rumpf nötig', async () => {
|
||||
const service = makeService();
|
||||
const controller = new DashboardController(service as never);
|
||||
|
||||
await controller.createDashboard(makeRequest());
|
||||
|
||||
expect(service.createDashboard).toHaveBeenCalledWith('user-1', 'tenant-1');
|
||||
});
|
||||
|
||||
it('reorderDashboards: reicht die Kennungsliste aus dem Rumpf durch', async () => {
|
||||
const service = makeService();
|
||||
const controller = new DashboardController(service as never);
|
||||
const dto = { ids: ['dash-2', 'dash-1'] } as never;
|
||||
|
||||
await controller.reorderDashboards(makeRequest(), dto);
|
||||
|
||||
expect(service.reorderDashboards).toHaveBeenCalledWith('user-1', 'tenant-1', dto);
|
||||
});
|
||||
|
||||
it('renameDashboard: reicht Pfad-Kennung und Rumpf durch', async () => {
|
||||
const service = makeService();
|
||||
const controller = new DashboardController(service as never);
|
||||
const dto = { name: 'Neuer Name' } as never;
|
||||
|
||||
await controller.renameDashboard('dash-1', makeRequest(), dto);
|
||||
|
||||
expect(service.renameDashboard).toHaveBeenCalledWith('dash-1', 'user-1', 'tenant-1', dto);
|
||||
});
|
||||
|
||||
it('deleteDashboard: reicht die Pfad-Kennung durch', async () => {
|
||||
const service = makeService();
|
||||
const controller = new DashboardController(service as never);
|
||||
|
||||
await controller.deleteDashboard('dash-1', makeRequest());
|
||||
|
||||
expect(service.deleteDashboard).toHaveBeenCalledWith('dash-1', 'user-1', 'tenant-1');
|
||||
});
|
||||
|
||||
it('getLayout/getWidgets: reichen die Reiter-Kennung aus dem Abfrageparameter durch', async () => {
|
||||
const service = makeService();
|
||||
const controller = new DashboardController(service as never);
|
||||
|
||||
await controller.getLayout(makeRequest(), 'dash-1');
|
||||
await controller.getWidgets(makeRequest(), 'dash-1');
|
||||
|
||||
expect(service.getLayout).toHaveBeenCalledWith('user-1', 'tenant-1', 'dash-1');
|
||||
expect(service.getWidgets).toHaveBeenCalledWith('user-1', 'tenant-1', 'USER', 'dash-1');
|
||||
});
|
||||
|
||||
it('fehlender Mandantenkontext führt bei den neuen Reiter-Wegen zur vorhandenen Abweisung', async () => {
|
||||
const service = makeService();
|
||||
const controller = new DashboardController(service as never);
|
||||
const req = makeRequest({ user: undefined, tenantId: undefined } as never);
|
||||
|
||||
await expect(controller.listDashboards(req)).rejects.toBeInstanceOf(ForbiddenException);
|
||||
await expect(controller.createDashboard(req)).rejects.toBeInstanceOf(ForbiddenException);
|
||||
await expect(
|
||||
controller.reorderDashboards(req, { ids: [] } as never),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
await expect(
|
||||
controller.renameDashboard('dash-1', req, { name: 'x' } as never),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
await expect(controller.deleteDashboard('dash-1', req)).rejects.toBeInstanceOf(
|
||||
ForbiddenException,
|
||||
);
|
||||
});
|
||||
|
||||
it('Wächter: die feste Route "tabs/order" steht im Dateitext VOR jeder Route mit Platzhalter unter demselben Präfix (NestJS-Routenreihenfolge)', () => {
|
||||
const source = readFileSync(join(__dirname, 'dashboard.controller.ts'), 'utf-8');
|
||||
|
||||
const orderIndex = source.indexOf("@Put('tabs/order')");
|
||||
const patchIdIndex = source.indexOf("@Patch('tabs/:id')");
|
||||
const deleteIdIndex = source.indexOf("@Delete('tabs/:id')");
|
||||
|
||||
expect(orderIndex, '@Put(\'tabs/order\') fehlt im Quelltext').toBeGreaterThan(-1);
|
||||
expect(patchIdIndex, '@Patch(\'tabs/:id\') fehlt im Quelltext').toBeGreaterThan(-1);
|
||||
expect(deleteIdIndex, '@Delete(\'tabs/:id\') fehlt im Quelltext').toBeGreaterThan(-1);
|
||||
|
||||
expect(
|
||||
orderIndex,
|
||||
'tabs/order muss VOR PATCH tabs/:id deklariert sein, sonst verdeckt der Platzhalter die feste Route',
|
||||
).toBeLessThan(patchIdIndex);
|
||||
expect(
|
||||
orderIndex,
|
||||
'tabs/order muss VOR DELETE tabs/:id deklariert sein, sonst verdeckt der Platzhalter die feste Route',
|
||||
).toBeLessThan(deleteIdIndex);
|
||||
});
|
||||
});
|
||||
@@ -8,12 +8,15 @@ import {
|
||||
Patch,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
Req,
|
||||
} from '@nestjs/common';
|
||||
import type { AuthenticatedRequest } from '../auth/types/auth-user';
|
||||
import { DashboardService } from './dashboard.service';
|
||||
import { CreateSearchProviderDto } from './dto/create-search-provider.dto';
|
||||
import { CreateWidgetDto } from './dto/create-widget.dto';
|
||||
import { RenameDashboardDto } from './dto/rename-dashboard.dto';
|
||||
import { ReorderDashboardsDto } from './dto/reorder-dashboards.dto';
|
||||
import { SaveLayoutDto } from './dto/save-layout.dto';
|
||||
import { UpdateWidgetConfigDto } from './dto/update-widget-config.dto';
|
||||
|
||||
@@ -25,10 +28,17 @@ import { UpdateWidgetConfigDto } from './dto/update-widget-config.dto';
|
||||
* and scopes all operations to the calling user (T-05-01, T-05-02).
|
||||
*
|
||||
* Routes:
|
||||
* - GET /dashboard/layout — get user's saved layout
|
||||
* - PUT /dashboard/layout — upsert user's layout
|
||||
* - GET /dashboard/widgets — list user's widget instances
|
||||
* - POST /dashboard/widgets — create a new widget instance
|
||||
* - GET /dashboard/tabs — list the user's dashboard tabs (quick-260923-ad9)
|
||||
* - POST /dashboard/tabs — create a new, empty tab
|
||||
* - PUT /dashboard/tabs/order — persist the tab order (MUST be declared
|
||||
* before the `:id` routes below, see the
|
||||
* source-order guard in dashboard.controller.spec.ts)
|
||||
* - PATCH /dashboard/tabs/:id — rename a tab
|
||||
* - DELETE /dashboard/tabs/:id — delete a tab, its widgets and its layout
|
||||
* - GET /dashboard/layout — get the saved layout of one tab
|
||||
* - PUT /dashboard/layout — upsert the layout of one tab
|
||||
* - GET /dashboard/widgets — list the widget instances of one tab
|
||||
* - POST /dashboard/widgets — create a new widget instance on one tab
|
||||
* - PATCH /dashboard/widgets/:id/config — update widget config
|
||||
* - DELETE /dashboard/widgets/:id — remove a widget instance
|
||||
* - GET /dashboard/search-providers — list default + user's custom providers
|
||||
@@ -66,10 +76,61 @@ export class DashboardController {
|
||||
return { userId: user.id, tenantId, role: user.role };
|
||||
}
|
||||
|
||||
@Get('layout')
|
||||
async getLayout(@Req() req: AuthenticatedRequest) {
|
||||
/**
|
||||
* Reiter des Benutzers (quick-260923-ad9), nach Position aufsteigend;
|
||||
* legt beim ersten Aufruf genau einen an.
|
||||
*/
|
||||
@Get('tabs')
|
||||
async listDashboards(@Req() req: AuthenticatedRequest) {
|
||||
const { userId, tenantId } = this.extractContext(req);
|
||||
return this.dashboardService.getLayout(userId, tenantId);
|
||||
return this.dashboardService.listDashboards(userId, tenantId);
|
||||
}
|
||||
|
||||
@Post('tabs')
|
||||
async createDashboard(@Req() req: AuthenticatedRequest) {
|
||||
const { userId, tenantId } = this.extractContext(req);
|
||||
return this.dashboardService.createDashboard(userId, tenantId);
|
||||
}
|
||||
|
||||
/**
|
||||
* MUSS vor `PATCH tabs/:id` / `DELETE tabs/:id` stehen — in dieser
|
||||
* Anwendung hat eine Route mit Platzhalter schon einmal eine dahinter
|
||||
* stehende feste Route verdeckt (siehe Projektnotiz „NestJS Route-
|
||||
* Order“); ein quelltextlesender Wächter in
|
||||
* `dashboard.controller.spec.ts` prüft die Reihenfolge im Dateitext.
|
||||
*/
|
||||
@Put('tabs/order')
|
||||
async reorderDashboards(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Body() dto: ReorderDashboardsDto,
|
||||
) {
|
||||
const { userId, tenantId } = this.extractContext(req);
|
||||
return this.dashboardService.reorderDashboards(userId, tenantId, dto);
|
||||
}
|
||||
|
||||
@Patch('tabs/:id')
|
||||
async renameDashboard(
|
||||
@Param('id') id: string,
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Body() dto: RenameDashboardDto,
|
||||
) {
|
||||
const { userId, tenantId } = this.extractContext(req);
|
||||
return this.dashboardService.renameDashboard(id, userId, tenantId, dto);
|
||||
}
|
||||
|
||||
@Delete('tabs/:id')
|
||||
async deleteDashboard(@Param('id') id: string, @Req() req: AuthenticatedRequest) {
|
||||
const { userId, tenantId } = this.extractContext(req);
|
||||
return this.dashboardService.deleteDashboard(id, userId, tenantId);
|
||||
}
|
||||
|
||||
@Get('layout')
|
||||
async getLayout(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Query('dashboardId') dashboardId: string,
|
||||
) {
|
||||
const { userId, tenantId } = this.extractContext(req);
|
||||
return this.dashboardService.getLayout(userId, tenantId, dashboardId);
|
||||
}
|
||||
|
||||
@Put('layout')
|
||||
@@ -79,9 +140,12 @@ export class DashboardController {
|
||||
}
|
||||
|
||||
@Get('widgets')
|
||||
async getWidgets(@Req() req: AuthenticatedRequest) {
|
||||
async getWidgets(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Query('dashboardId') dashboardId: string,
|
||||
) {
|
||||
const { userId, tenantId, role } = this.extractContext(req);
|
||||
return this.dashboardService.getWidgets(userId, tenantId, role);
|
||||
return this.dashboardService.getWidgets(userId, tenantId, role, dashboardId);
|
||||
}
|
||||
|
||||
@Post('widgets')
|
||||
|
||||
@@ -30,12 +30,20 @@ vi.mock('./widget-module-map', () => ({
|
||||
* ungebundenen Klienten (Aufgabe 1, Befund E/H übernommen aus
|
||||
* `module-registry`): die Tabelle trägt heute keinen Zeilenschutz, eine
|
||||
* Bindung wäre heute wirkungslos.
|
||||
*
|
||||
* quick-260923-ad9 (Task 1): `withTenantTransaction` kommt zum Mock hinzu
|
||||
* (Muster favorites.service.spec.ts) — sie reicht den gebundenen Klienten
|
||||
* als `tx` durch und protokolliert den Aufruf. `listDashboards` nutzt sie
|
||||
* fürs Anlegen des ersten Reiters unter einer Transaktionssperre.
|
||||
*/
|
||||
vi.mock('../prisma/prisma-tenant.extension', () => ({
|
||||
forTenant: vi.fn((prisma: any, tenantId: string) => prisma.__makeBoundClient(tenantId)),
|
||||
withTenantTransaction: vi.fn((prisma: any, tenantId: string, fn: any) =>
|
||||
prisma.__withTenantTransaction(tenantId, fn),
|
||||
),
|
||||
}));
|
||||
|
||||
import { ConflictException } from '@nestjs/common';
|
||||
import { BadRequestException, ConflictException, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||
import { DashboardService } from './dashboard.service';
|
||||
@@ -43,14 +51,42 @@ import { DashboardService } from './dashboard.service';
|
||||
/** Modelle, die `__makeBoundClient()` je Aufruf mit einem eigenen, das
|
||||
* Herkunfts-Tenant protokollierenden Wrapper versieht. `module` ist bewusst
|
||||
* NICHT enthalten — der Katalogzugriff bleibt ungebunden. `searchProvider`
|
||||
* ergaenzt seit Aufgabe 3 (260910-krx). */
|
||||
const BOUND_MODEL_NAMES = ['dashboardLayout', 'widgetInstance', 'searchProvider'];
|
||||
* ergaenzt seit Aufgabe 3 (260910-krx). `dashboard` ergaenzt seit
|
||||
* quick-260923-ad9 (Task 1). */
|
||||
const BOUND_MODEL_NAMES = ['dashboard', 'dashboardLayout', 'widgetInstance', 'searchProvider'];
|
||||
|
||||
/** Standard-Reiter-Kennung, die die meisten Tests verwenden — ein Reiter
|
||||
* `dash-1`, der `user-1`/`tenant-1` gehört (Standard-Fixture unten). */
|
||||
const DASH_1 = 'dash-1';
|
||||
|
||||
function makeDashboard(
|
||||
overrides: Partial<{
|
||||
id: string;
|
||||
userId: string;
|
||||
tenantId: string;
|
||||
name: string;
|
||||
position: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}> = {},
|
||||
) {
|
||||
return {
|
||||
id: overrides.id ?? DASH_1,
|
||||
userId: overrides.userId ?? 'user-1',
|
||||
tenantId: overrides.tenantId ?? 'tenant-1',
|
||||
name: overrides.name ?? 'Dashboard',
|
||||
position: overrides.position ?? 0,
|
||||
createdAt: overrides.createdAt ?? new Date('2026-01-01'),
|
||||
updatedAt: overrides.updatedAt ?? new Date('2026-01-01'),
|
||||
};
|
||||
}
|
||||
|
||||
function makeWidget(
|
||||
overrides: Partial<{
|
||||
id: string;
|
||||
userId: string;
|
||||
tenantId: string;
|
||||
dashboardId: string;
|
||||
widgetType: string;
|
||||
config: Record<string, unknown>;
|
||||
createdAt: Date;
|
||||
@@ -60,6 +96,7 @@ function makeWidget(
|
||||
id: overrides.id ?? 'w1',
|
||||
userId: overrides.userId ?? 'user-1',
|
||||
tenantId: overrides.tenantId ?? 'tenant-1',
|
||||
dashboardId: overrides.dashboardId ?? DASH_1,
|
||||
widgetType: overrides.widgetType ?? 'clock',
|
||||
config: overrides.config ?? {},
|
||||
createdAt: overrides.createdAt ?? new Date('2026-01-01'),
|
||||
@@ -92,35 +129,100 @@ function makeFakePrisma(
|
||||
opts: {
|
||||
widgets?: ReturnType<typeof makeWidget>[];
|
||||
modules?: { id: string; slug: string }[];
|
||||
layout?: { userId: string; tenantId: string; layouts: unknown } | null;
|
||||
layout?: { dashboardId: string; userId: string; tenantId: string; layouts: unknown } | null;
|
||||
searchProviders?: ReturnType<typeof makeSearchProvider>[];
|
||||
dashboards?: ReturnType<typeof makeDashboard>[];
|
||||
} = {},
|
||||
) {
|
||||
const widgets = opts.widgets ?? [];
|
||||
const modules = opts.modules ?? [];
|
||||
let layoutRow = opts.layout ?? null;
|
||||
const searchProviders = opts.searchProviders ?? [];
|
||||
// Standard-Fixture: GENAU EIN Reiter `dash-1`, der user-1/tenant-1 gehört
|
||||
// — die meisten Tests wollen sich um Reiter-Verwaltung nicht kümmern.
|
||||
// Tests, die eine andere Besitzlage brauchen (fremder Reiter, ADMIN mit
|
||||
// eigenem Reiter, leere Reiterliste), übergeben `dashboards` explizit.
|
||||
const dashboards = opts.dashboards ?? [makeDashboard()];
|
||||
const boundCallLog: { tenantId: string; model: string; method: string }[] = [];
|
||||
|
||||
const fake: any = {
|
||||
dashboard: {
|
||||
findMany: vi.fn(async ({ where }: any) => {
|
||||
return dashboards
|
||||
.filter((d) => d.userId === where.userId)
|
||||
.slice()
|
||||
.sort((a, b) => a.position - b.position);
|
||||
}),
|
||||
findUnique: vi.fn(async ({ where }: any) => {
|
||||
return dashboards.find((d) => d.id === where.id) ?? null;
|
||||
}),
|
||||
count: vi.fn(async ({ where }: any) => {
|
||||
return dashboards.filter(
|
||||
(d) => d.userId === where.userId && d.tenantId === where.tenantId,
|
||||
).length;
|
||||
}),
|
||||
create: vi.fn(async ({ data }: any) => {
|
||||
const created = makeDashboard({ id: `new-dash-${dashboards.length + 1}`, ...data });
|
||||
dashboards.push(created);
|
||||
return created;
|
||||
}),
|
||||
update: vi.fn(async ({ where, data }: any) => {
|
||||
const dashboard = dashboards.find((d) => d.id === where.id);
|
||||
if (!dashboard) return null;
|
||||
Object.assign(dashboard, data);
|
||||
return dashboard;
|
||||
}),
|
||||
updateMany: vi.fn(async ({ where, data }: any) => {
|
||||
const matches = dashboards.filter(
|
||||
(d) => d.id === where.id && d.userId === where.userId,
|
||||
);
|
||||
for (const d of matches) Object.assign(d, data);
|
||||
return { count: matches.length };
|
||||
}),
|
||||
deleteMany: vi.fn(async ({ where }: any) => {
|
||||
const before = dashboards.length;
|
||||
for (let i = dashboards.length - 1; i >= 0; i--) {
|
||||
if (dashboards[i].id === where.id && dashboards[i].userId === where.userId) {
|
||||
dashboards.splice(i, 1);
|
||||
}
|
||||
}
|
||||
return { count: before - dashboards.length };
|
||||
}),
|
||||
},
|
||||
dashboardLayout: {
|
||||
findUnique: vi.fn(async ({ where }: any) => {
|
||||
if (layoutRow && layoutRow.userId === where.userId) return layoutRow;
|
||||
if (layoutRow && layoutRow.dashboardId === where.dashboardId) return layoutRow;
|
||||
return null;
|
||||
}),
|
||||
upsert: vi.fn(async ({ where, update, create }: any) => {
|
||||
if (layoutRow && layoutRow.userId === where.userId) {
|
||||
if (layoutRow && layoutRow.dashboardId === where.dashboardId) {
|
||||
layoutRow = { ...layoutRow, layouts: update.layouts };
|
||||
return layoutRow;
|
||||
}
|
||||
layoutRow = { userId: create.userId, tenantId: create.tenantId, layouts: create.layouts };
|
||||
layoutRow = {
|
||||
dashboardId: create.dashboardId,
|
||||
userId: create.userId,
|
||||
tenantId: create.tenantId,
|
||||
layouts: create.layouts,
|
||||
};
|
||||
return layoutRow;
|
||||
}),
|
||||
deleteMany: vi.fn(async ({ where }: any) => {
|
||||
if (
|
||||
layoutRow &&
|
||||
layoutRow.dashboardId === where.dashboardId &&
|
||||
layoutRow.userId === where.userId
|
||||
) {
|
||||
layoutRow = null;
|
||||
return { count: 1 };
|
||||
}
|
||||
return { count: 0 };
|
||||
}),
|
||||
},
|
||||
widgetInstance: {
|
||||
findMany: vi.fn(async ({ where }: any) => {
|
||||
return widgets
|
||||
.filter((w) => w.userId === where.userId)
|
||||
.filter((w) => w.dashboardId === where.dashboardId)
|
||||
.slice()
|
||||
.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
|
||||
}),
|
||||
@@ -138,6 +240,15 @@ function makeFakePrisma(
|
||||
Object.assign(widget, data);
|
||||
return widget;
|
||||
}),
|
||||
deleteMany: vi.fn(async ({ where }: any) => {
|
||||
const before = widgets.length;
|
||||
for (let i = widgets.length - 1; i >= 0; i--) {
|
||||
if (widgets[i].dashboardId === where.dashboardId && widgets[i].userId === where.userId) {
|
||||
widgets.splice(i, 1);
|
||||
}
|
||||
}
|
||||
return { count: before - widgets.length };
|
||||
}),
|
||||
delete: vi.fn(async ({ where }: any) => {
|
||||
const idx = widgets.findIndex((w) => w.id === where.id);
|
||||
if (idx === -1) return null;
|
||||
@@ -188,8 +299,16 @@ function makeFakePrisma(
|
||||
}
|
||||
bound[modelName] = wrapped;
|
||||
}
|
||||
// quick-260923-ad9: `listDashboards` setzt die Transaktionssperre über
|
||||
// ein rohes `$executeRaw` auf `tx` — Attrappe genügt, das Ergebnis
|
||||
// wird nicht ausgewertet.
|
||||
bound.$executeRaw = vi.fn(async () => undefined);
|
||||
return bound;
|
||||
},
|
||||
__withTenantTransaction(tenantId: string, fn: (tx: any) => any) {
|
||||
boundCallLog.push({ tenantId, model: '$transaction', method: 'withTenantTransaction' });
|
||||
return fn(fake.__makeBoundClient(tenantId));
|
||||
},
|
||||
};
|
||||
|
||||
return fake;
|
||||
@@ -245,7 +364,7 @@ describe('DashboardService.getWidgets — Modulfilter (D-22, PERM-07)', () => {
|
||||
const moduleAccessService = makeFakeModuleAccessService(new Set());
|
||||
const service = new DashboardService(prisma as any, moduleAccessService as any);
|
||||
|
||||
const result = await service.getWidgets('user-1', 'tenant-1', 'USER' as any);
|
||||
const result = await service.getWidgets('user-1', 'tenant-1', 'USER' as any, DASH_1);
|
||||
|
||||
expect(result).toEqual(widgets);
|
||||
expect(moduleAccessService.getAccessibleModuleIds).not.toHaveBeenCalled();
|
||||
@@ -261,7 +380,7 @@ describe('DashboardService.getWidgets — Modulfilter (D-22, PERM-07)', () => {
|
||||
const moduleAccessService = makeFakeModuleAccessService(new Set(['mod-1']));
|
||||
const service = new DashboardService(prisma as any, moduleAccessService as any);
|
||||
|
||||
const result = await service.getWidgets('user-1', 'tenant-1', 'USER' as any);
|
||||
const result = await service.getWidgets('user-1', 'tenant-1', 'USER' as any, DASH_1);
|
||||
|
||||
expect(result).toEqual([widget]);
|
||||
});
|
||||
@@ -276,7 +395,7 @@ describe('DashboardService.getWidgets — Modulfilter (D-22, PERM-07)', () => {
|
||||
const moduleAccessService = makeFakeModuleAccessService(new Set());
|
||||
const service = new DashboardService(prisma as any, moduleAccessService as any);
|
||||
|
||||
const result = await service.getWidgets('user-1', 'tenant-1', 'USER' as any);
|
||||
const result = await service.getWidgets('user-1', 'tenant-1', 'USER' as any, DASH_1);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
@@ -287,12 +406,13 @@ describe('DashboardService.getWidgets — Modulfilter (D-22, PERM-07)', () => {
|
||||
const prisma = makeFakePrisma({
|
||||
widgets: [widget],
|
||||
modules: [{ id: 'mod-1', slug: 'tender-radar' }],
|
||||
dashboards: [makeDashboard({ userId: 'admin-1' })],
|
||||
});
|
||||
// Simuliert den D-03-Kurzschluss der Zugriffsauflösung aus 15-01: ADMIN erhält alle aktiven Module.
|
||||
const moduleAccessService = makeFakeModuleAccessService(new Set(['mod-1']));
|
||||
const service = new DashboardService(prisma as any, moduleAccessService as any);
|
||||
|
||||
const result = await service.getWidgets('admin-1', 'tenant-1', 'ADMIN' as any);
|
||||
const result = await service.getWidgets('admin-1', 'tenant-1', 'ADMIN' as any, DASH_1);
|
||||
|
||||
expect(result).toEqual([widget]);
|
||||
expect(moduleAccessService.getAccessibleModuleIds).toHaveBeenCalledWith(
|
||||
@@ -313,7 +433,7 @@ describe('DashboardService.getWidgets — Modulfilter (D-22, PERM-07)', () => {
|
||||
const moduleAccessService = makeFakeModuleAccessService(new Set()); // kein Zugriff auf tender-radar
|
||||
const service = new DashboardService(prisma as any, moduleAccessService as any);
|
||||
|
||||
const result = await service.getWidgets('user-1', 'tenant-1', 'USER' as any);
|
||||
const result = await service.getWidgets('user-1', 'tenant-1', 'USER' as any, DASH_1);
|
||||
|
||||
expect(result).toEqual([platformWidget]);
|
||||
});
|
||||
@@ -334,7 +454,7 @@ describe('DashboardService.getWidgets — Modulfilter (D-22, PERM-07)', () => {
|
||||
const moduleAccessService = makeFakeModuleAccessService(new Set(['mod-1']));
|
||||
const service = new DashboardService(prisma as any, moduleAccessService as any);
|
||||
|
||||
const result = await service.getWidgets('user-1', 'tenant-1', 'USER' as any);
|
||||
const result = await service.getWidgets('user-1', 'tenant-1', 'USER' as any, DASH_1);
|
||||
|
||||
expect(result.map((w: any) => w.id)).toEqual(['w1', 'w2', 'w3']);
|
||||
});
|
||||
@@ -349,8 +469,8 @@ describe('DashboardService.getWidgets — Modulfilter (D-22, PERM-07)', () => {
|
||||
const moduleAccessService = makeFakeModuleAccessService(new Set());
|
||||
const service = new DashboardService(prisma as any, moduleAccessService as any);
|
||||
|
||||
await service.getWidgets('user-1', 'tenant-1', 'USER' as any);
|
||||
await service.getWidgets('user-1', 'tenant-1', 'USER' as any);
|
||||
await service.getWidgets('user-1', 'tenant-1', 'USER' as any, DASH_1);
|
||||
await service.getWidgets('user-1', 'tenant-1', 'USER' as any, DASH_1);
|
||||
|
||||
expect(prisma.widgetInstance.delete).not.toHaveBeenCalled();
|
||||
expect(prisma.widgetInstance.findMany).toHaveBeenCalledTimes(2);
|
||||
@@ -363,7 +483,7 @@ describe('DashboardService.getWidgets — Modulfilter (D-22, PERM-07)', () => {
|
||||
const moduleAccessService = makeFakeModuleAccessService(new Set(['irgendeine-id']));
|
||||
const service = new DashboardService(prisma as any, moduleAccessService as any);
|
||||
|
||||
const result = await service.getWidgets('user-1', 'tenant-1', 'USER' as any);
|
||||
const result = await service.getWidgets('user-1', 'tenant-1', 'USER' as any, DASH_1);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
@@ -379,15 +499,16 @@ describe('DashboardService — Anordnung und Widgets gebunden an forTenant() (26
|
||||
|
||||
it('getLayout: der Lesezugriff läuft über den gebundenen Klienten, mit der übergebenen Mandantenkennung im Protokoll', async () => {
|
||||
const prisma = makeFakePrisma({
|
||||
layout: { userId: 'user-1', tenantId: 'tenant-1', layouts: { lg: [{ i: 'w1' }] } },
|
||||
layout: { dashboardId: DASH_1, userId: 'user-1', tenantId: 'tenant-1', layouts: { lg: [{ i: 'w1' }] } },
|
||||
});
|
||||
const moduleAccessService = makeFakeModuleAccessService(new Set());
|
||||
const service = new DashboardService(prisma as any, moduleAccessService as any);
|
||||
|
||||
const result = await service.getLayout('user-1', 'tenant-1');
|
||||
const result = await service.getLayout('user-1', 'tenant-1', DASH_1);
|
||||
|
||||
expect(result).toEqual({ lg: [{ i: 'w1' }] });
|
||||
expectBoundCall(prisma, 'tenant-1', 'dashboardLayout', 'findUnique');
|
||||
expectBoundCall(prisma, 'tenant-1', 'dashboard', 'findUnique');
|
||||
// Benutzerdimension (260911-nke): forTenant() bekommt userId als drittes Argument.
|
||||
expect(forTenant).toHaveBeenCalledWith(prisma, 'tenant-1', 'user-1');
|
||||
});
|
||||
@@ -397,7 +518,7 @@ describe('DashboardService — Anordnung und Widgets gebunden an forTenant() (26
|
||||
const moduleAccessService = makeFakeModuleAccessService(new Set());
|
||||
const service = new DashboardService(prisma as any, moduleAccessService as any);
|
||||
|
||||
const result = await service.getLayout('user-1', 'tenant-1');
|
||||
const result = await service.getLayout('user-1', 'tenant-1', DASH_1);
|
||||
|
||||
expect(result).toEqual({ lg: [], md: [], sm: [], xs: [], xxs: [] });
|
||||
});
|
||||
@@ -407,17 +528,18 @@ describe('DashboardService — Anordnung und Widgets gebunden an forTenant() (26
|
||||
const moduleAccessService = makeFakeModuleAccessService(new Set());
|
||||
const service = new DashboardService(prisma as any, moduleAccessService as any);
|
||||
|
||||
await service.saveLayout('user-1', 'tenant-1', { layouts: { lg: [] } } as any);
|
||||
await service.saveLayout('user-1', 'tenant-1', { dashboardId: DASH_1, layouts: { lg: [] } } as any);
|
||||
|
||||
expectBoundCall(prisma, 'tenant-1', 'dashboardLayout', 'upsert');
|
||||
expectBoundCall(prisma, 'tenant-1', 'dashboard', 'findUnique');
|
||||
});
|
||||
|
||||
/**
|
||||
* Gegenrichtung der Bindung (w4, 260910-krx). `DashboardLayout.userId` ist
|
||||
* plattformweit eindeutig, ohne Mandantenanteil. Ist die vorhandene Zeile
|
||||
* unter dem gebundenen Kontext unsichtbar, laeuft das `upsert` in einen
|
||||
* Konflikt — und der aeussert sich hier NICHT als der bekannte P2002-Fehler
|
||||
* (`PrismaClientKnownRequestError`), sondern als
|
||||
* Gegenrichtung der Bindung (w4, 260910-krx). `DashboardLayout.dashboardId`
|
||||
* (bis quick-260923-ad9: `userId`) ist die eindeutige Spalte. Ist die
|
||||
* vorhandene Zeile unter dem gebundenen Kontext unsichtbar, laeuft das
|
||||
* `upsert` in einen Konflikt — und der aeussert sich hier NICHT als der
|
||||
* bekannte P2002-Fehler (`PrismaClientKnownRequestError`), sondern als
|
||||
* `PrismaClientUnknownRequestError`, weil die Zeilenschutz-Regel den
|
||||
* Schreibzugriff mit SQLSTATE 42501 abweist, bevor die Eindeutigkeit
|
||||
* ueberhaupt geprueft wird. Gemessen in `rls-scratch-check.mjs`
|
||||
@@ -440,7 +562,7 @@ describe('DashboardService — Anordnung und Widgets gebunden an forTenant() (26
|
||||
const service = new DashboardService(prisma as any, moduleAccessService as any);
|
||||
|
||||
await expect(
|
||||
service.saveLayout('user-1', 'tenant-1', { layouts: { lg: [] } } as any),
|
||||
service.saveLayout('user-1', 'tenant-1', { dashboardId: DASH_1, layouts: { lg: [] } } as any),
|
||||
).rejects.toBeInstanceOf(ConflictException);
|
||||
});
|
||||
|
||||
@@ -457,20 +579,20 @@ describe('DashboardService — Anordnung und Widgets gebunden an forTenant() (26
|
||||
const service = new DashboardService(prisma as any, moduleAccessService as any);
|
||||
|
||||
await expect(
|
||||
service.saveLayout('user-1', 'tenant-1', { layouts: { lg: [] } } as any),
|
||||
service.saveLayout('user-1', 'tenant-1', { dashboardId: DASH_1, layouts: { lg: [] } } as any),
|
||||
).rejects.toBe(known);
|
||||
});
|
||||
});
|
||||
|
||||
it('Anordnung lesen und speichern sind GEMEINSAM gebunden: beide laufen über denselben gebundenen Klienten und dieselbe Mandantenkennung', async () => {
|
||||
const prisma = makeFakePrisma({
|
||||
layout: { userId: 'user-1', tenantId: 'tenant-1', layouts: {} },
|
||||
layout: { dashboardId: DASH_1, userId: 'user-1', tenantId: 'tenant-1', layouts: {} },
|
||||
});
|
||||
const moduleAccessService = makeFakeModuleAccessService(new Set());
|
||||
const service = new DashboardService(prisma as any, moduleAccessService as any);
|
||||
|
||||
await service.getLayout('user-1', 'tenant-1');
|
||||
await service.saveLayout('user-1', 'tenant-1', { layouts: { lg: [] } } as any);
|
||||
await service.getLayout('user-1', 'tenant-1', DASH_1);
|
||||
await service.saveLayout('user-1', 'tenant-1', { dashboardId: DASH_1, layouts: { lg: [] } } as any);
|
||||
|
||||
expectBoundCall(prisma, 'tenant-1', 'dashboardLayout', 'findUnique');
|
||||
expectBoundCall(prisma, 'tenant-1', 'dashboardLayout', 'upsert');
|
||||
@@ -481,7 +603,7 @@ describe('DashboardService — Anordnung und Widgets gebunden an forTenant() (26
|
||||
const moduleAccessService = makeFakeModuleAccessService(new Set());
|
||||
const service = new DashboardService(prisma as any, moduleAccessService as any);
|
||||
|
||||
const result = await service.getWidgets('user-1', 'tenant-1', 'USER' as any);
|
||||
const result = await service.getWidgets('user-1', 'tenant-1', 'USER' as any, DASH_1);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expectBoundCall(prisma, 'tenant-1', 'widgetInstance', 'findMany');
|
||||
@@ -493,9 +615,10 @@ describe('DashboardService — Anordnung und Widgets gebunden an forTenant() (26
|
||||
const moduleAccessService = makeFakeModuleAccessService(new Set());
|
||||
const service = new DashboardService(prisma as any, moduleAccessService as any);
|
||||
|
||||
await service.addWidget('user-1', 'tenant-1', { widgetType: 'clock' } as any);
|
||||
await service.addWidget('user-1', 'tenant-1', { widgetType: 'clock', dashboardId: DASH_1 } as any);
|
||||
|
||||
expectBoundCall(prisma, 'tenant-1', 'widgetInstance', 'create');
|
||||
expectBoundCall(prisma, 'tenant-1', 'dashboard', 'findUnique');
|
||||
});
|
||||
|
||||
it('Widget-Konfiguration ändern: BEIDE Abfragen (Besitzprüfung und Änderung) laufen über DENSELBEN gebundenen Klienten und dieselbe Mandantenkennung', async () => {
|
||||
@@ -548,16 +671,16 @@ describe('DashboardService — Anordnung und Widgets gebunden an forTenant() (26
|
||||
const widget = makeWidget({ id: 'w1', userId: 'user-1' });
|
||||
const prisma = makeFakePrisma({
|
||||
widgets: [widget],
|
||||
layout: { userId: 'user-1', tenantId: 'tenant-1', layouts: {} },
|
||||
layout: { dashboardId: DASH_1, userId: 'user-1', tenantId: 'tenant-1', layouts: {} },
|
||||
});
|
||||
const moduleAccessService = makeFakeModuleAccessService(new Set());
|
||||
const service = new DashboardService(prisma as any, moduleAccessService as any);
|
||||
|
||||
for (const call of [
|
||||
() => service.getLayout('user-1', 'tenant-1'),
|
||||
() => service.saveLayout('user-1', 'tenant-1', { layouts: {} } as any),
|
||||
() => service.getWidgets('user-1', 'tenant-1', 'USER' as any),
|
||||
() => service.addWidget('user-1', 'tenant-1', { widgetType: 'clock' } as any),
|
||||
() => service.getLayout('user-1', 'tenant-1', DASH_1),
|
||||
() => service.saveLayout('user-1', 'tenant-1', { dashboardId: DASH_1, layouts: {} } as any),
|
||||
() => service.getWidgets('user-1', 'tenant-1', 'USER' as any, DASH_1),
|
||||
() => service.addWidget('user-1', 'tenant-1', { widgetType: 'clock', dashboardId: DASH_1 } as any),
|
||||
() => service.updateWidgetConfig('w1', 'user-1', 'tenant-1', { config: {} } as any),
|
||||
() => service.removeWidget('w1', 'user-1', 'tenant-1'),
|
||||
]) {
|
||||
@@ -575,7 +698,7 @@ describe('DashboardService — Anordnung und Widgets gebunden an forTenant() (26
|
||||
const moduleAccessService = makeFakeModuleAccessService(new Set());
|
||||
const service = new DashboardService(prisma as any, moduleAccessService as any);
|
||||
|
||||
const result = await service.getWidgets('user-1', 'tenant-1', 'USER' as any);
|
||||
const result = await service.getWidgets('user-1', 'tenant-1', 'USER' as any, DASH_1);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
@@ -593,9 +716,9 @@ describe('DashboardService — Anordnung und Widgets gebunden an forTenant() (26
|
||||
const service = new DashboardService(prisma as any, moduleAccessService as any);
|
||||
|
||||
const stored = { lg: [{ i: 'w1', x: 0, y: 0, w: 4, h: 4 }], __gridVersion: 2 };
|
||||
await service.saveLayout('user-1', 'tenant-1', { layouts: stored } as any);
|
||||
await service.saveLayout('user-1', 'tenant-1', { dashboardId: DASH_1, layouts: stored } as any);
|
||||
|
||||
const result = await service.getLayout('user-1', 'tenant-1');
|
||||
const result = await service.getLayout('user-1', 'tenant-1', DASH_1);
|
||||
|
||||
expect(result).toEqual(stored);
|
||||
expect((result as Record<string, unknown>).__gridVersion).toBe(2);
|
||||
@@ -623,6 +746,178 @@ describe('DashboardService — Anordnung und Widgets gebunden an forTenant() (26
|
||||
});
|
||||
});
|
||||
|
||||
// --- Reiter (quick-260923-ad9, Task 1) --------------------------------------
|
||||
|
||||
describe('DashboardService.listDashboards — Reiter anlegen/lesen (quick-260923-ad9, Task 1)', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(forTenant).mockClear();
|
||||
});
|
||||
|
||||
it('erster Aufruf ohne vorhandenen Reiter legt genau einen mit Position 0 und Namen "Dashboard" an und liefert ihn', async () => {
|
||||
const prisma = makeFakePrisma({ dashboards: [] });
|
||||
const moduleAccessService = makeFakeModuleAccessService(new Set());
|
||||
const service = new DashboardService(prisma as any, moduleAccessService as any);
|
||||
|
||||
const result = await service.listDashboards('user-1', 'tenant-1');
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({ name: 'Dashboard', position: 0, userId: 'user-1' });
|
||||
expect(prisma.dashboard.create).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('zweiter Aufruf legt keinen weiteren Reiter an', async () => {
|
||||
const prisma = makeFakePrisma({ dashboards: [] });
|
||||
const moduleAccessService = makeFakeModuleAccessService(new Set());
|
||||
const service = new DashboardService(prisma as any, moduleAccessService as any);
|
||||
|
||||
await service.listDashboards('user-1', 'tenant-1');
|
||||
const second = await service.listDashboards('user-1', 'tenant-1');
|
||||
|
||||
expect(second).toHaveLength(1);
|
||||
expect(prisma.dashboard.create).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('das Anlegen läuft als EINE withTenantTransaction (Sperre + erneute Zählung), nicht als Einzelbefehl (T-AD9-07)', async () => {
|
||||
const prisma = makeFakePrisma({ dashboards: [] });
|
||||
const moduleAccessService = makeFakeModuleAccessService(new Set());
|
||||
const service = new DashboardService(prisma as any, moduleAccessService as any);
|
||||
|
||||
await service.listDashboards('user-1', 'tenant-1');
|
||||
|
||||
const found = prisma.__boundCallLog.some(
|
||||
(c: any) => c.tenantId === 'tenant-1' && c.model === '$transaction' && c.method === 'withTenantTransaction',
|
||||
);
|
||||
expect(found, 'erwartete withTenantTransaction fehlt im Protokoll').toBe(true);
|
||||
});
|
||||
|
||||
it('die Liste kommt nach Position aufsteigend, unabhängig von der Einfügereihenfolge', async () => {
|
||||
const prisma = makeFakePrisma({
|
||||
dashboards: [
|
||||
makeDashboard({ id: 'd2', position: 1 }),
|
||||
makeDashboard({ id: 'd1', position: 0 }),
|
||||
],
|
||||
});
|
||||
const moduleAccessService = makeFakeModuleAccessService(new Set());
|
||||
const service = new DashboardService(prisma as any, moduleAccessService as any);
|
||||
|
||||
const result = await service.listDashboards('user-1', 'tenant-1');
|
||||
|
||||
expect(result.map((d: any) => d.id)).toEqual(['d1', 'd2']);
|
||||
});
|
||||
|
||||
it('der Lesezugriff läuft über den gebundenen Klienten mit der richtigen Mandantenkennung', async () => {
|
||||
const prisma = makeFakePrisma({ dashboards: [makeDashboard({ id: 'd1' })] });
|
||||
const moduleAccessService = makeFakeModuleAccessService(new Set());
|
||||
const service = new DashboardService(prisma as any, moduleAccessService as any);
|
||||
|
||||
await service.listDashboards('user-1', 'tenant-1');
|
||||
|
||||
expectBoundCall(prisma, 'tenant-1', 'dashboard', 'findMany');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DashboardService — Riegel gegen fremde Reiter (quick-260923-ad9, Task 1, T-AD9-01/02)', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(forTenant).mockClear();
|
||||
});
|
||||
|
||||
it('getLayout: fremde Reiter-Kennung führt zur Nicht-gefunden-Antwort', async () => {
|
||||
const prisma = makeFakePrisma({
|
||||
dashboards: [makeDashboard({ id: DASH_1, userId: 'other-user' })],
|
||||
});
|
||||
const moduleAccessService = makeFakeModuleAccessService(new Set());
|
||||
const service = new DashboardService(prisma as any, moduleAccessService as any);
|
||||
|
||||
await expect(service.getLayout('user-1', 'tenant-1', DASH_1)).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
|
||||
it('saveLayout: fremde Reiter-Kennung führt zur Nicht-gefunden-Antwort, ohne zu schreiben', async () => {
|
||||
const prisma = makeFakePrisma({
|
||||
dashboards: [makeDashboard({ id: DASH_1, userId: 'other-user' })],
|
||||
});
|
||||
const moduleAccessService = makeFakeModuleAccessService(new Set());
|
||||
const service = new DashboardService(prisma as any, moduleAccessService as any);
|
||||
|
||||
await expect(
|
||||
service.saveLayout('user-1', 'tenant-1', { dashboardId: DASH_1, layouts: {} } as any),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
expect(prisma.dashboardLayout.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('getWidgets: fremde Reiter-Kennung führt zur Nicht-gefunden-Antwort', async () => {
|
||||
const prisma = makeFakePrisma({
|
||||
dashboards: [makeDashboard({ id: DASH_1, userId: 'other-user' })],
|
||||
});
|
||||
const moduleAccessService = makeFakeModuleAccessService(new Set());
|
||||
const service = new DashboardService(prisma as any, moduleAccessService as any);
|
||||
|
||||
await expect(
|
||||
service.getWidgets('user-1', 'tenant-1', 'USER' as any, DASH_1),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('addWidget: fremde Reiter-Kennung führt zur Nicht-gefunden-Antwort, ohne zu schreiben', async () => {
|
||||
const prisma = makeFakePrisma({
|
||||
dashboards: [makeDashboard({ id: DASH_1, userId: 'other-user' })],
|
||||
});
|
||||
const moduleAccessService = makeFakeModuleAccessService(new Set());
|
||||
const service = new DashboardService(prisma as any, moduleAccessService as any);
|
||||
|
||||
await expect(
|
||||
service.addWidget('user-1', 'tenant-1', { widgetType: 'clock', dashboardId: DASH_1 } as any),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
expect(prisma.widgetInstance.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('unbekannte Reiter-Kennung (existiert nicht) führt ebenso zur Nicht-gefunden-Antwort — dieselbe Antwort wie "gehört einem Kollegen"', async () => {
|
||||
const prisma = makeFakePrisma({ dashboards: [] });
|
||||
const moduleAccessService = makeFakeModuleAccessService(new Set());
|
||||
const service = new DashboardService(prisma as any, moduleAccessService as any);
|
||||
|
||||
await expect(service.getLayout('user-1', 'tenant-1', 'unknown-dash')).rejects.toThrow(
|
||||
"Dashboard with id 'unknown-dash' not found",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DashboardService — Kacheln und Anordnung sind je Reiter getrennt (quick-260923-ad9, Task 1)', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(forTenant).mockClear();
|
||||
});
|
||||
|
||||
it('getWidgets liest über die Reiter-Kennung, nicht über die Benutzerkennung — zwei Reiter desselben Benutzers teilen keine Kacheln', async () => {
|
||||
const widgetOnDash1 = makeWidget({ id: 'w1', dashboardId: 'dash-1' });
|
||||
const widgetOnDash2 = makeWidget({ id: 'w2', dashboardId: 'dash-2' });
|
||||
const prisma = makeFakePrisma({
|
||||
widgets: [widgetOnDash1, widgetOnDash2],
|
||||
dashboards: [makeDashboard({ id: 'dash-1' }), makeDashboard({ id: 'dash-2' })],
|
||||
});
|
||||
const moduleAccessService = makeFakeModuleAccessService(new Set());
|
||||
const service = new DashboardService(prisma as any, moduleAccessService as any);
|
||||
|
||||
const result = await service.getWidgets('user-1', 'tenant-1', 'USER' as any, 'dash-2');
|
||||
|
||||
expect(result.map((w: any) => w.id)).toEqual(['w2']);
|
||||
});
|
||||
|
||||
it('saveLayout schreibt die Anordnung unter der Reiter-Kennung (create-Zweig)', async () => {
|
||||
const prisma = makeFakePrisma({});
|
||||
const moduleAccessService = makeFakeModuleAccessService(new Set());
|
||||
const service = new DashboardService(prisma as any, moduleAccessService as any);
|
||||
|
||||
await service.saveLayout('user-1', 'tenant-1', { dashboardId: DASH_1, layouts: { lg: [] } } as any);
|
||||
|
||||
expect(prisma.dashboardLayout.upsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { dashboardId: DASH_1 },
|
||||
create: expect.objectContaining({ dashboardId: DASH_1 }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// --- Bindung an forTenant() (260910-krx, Aufgabe 3: Suchmaschinen) ---------
|
||||
|
||||
describe('DashboardService — Suchmaschinen gebunden an forTenant(), Katalog bewusst ungebunden (260910-krx, Aufgabe 3)', () => {
|
||||
@@ -710,7 +1005,7 @@ describe('DashboardService — Suchmaschinen gebunden an forTenant(), Katalog be
|
||||
name: 'Intranet',
|
||||
urlTemplate: 'https://intranet.test/?q={query}',
|
||||
} as any);
|
||||
await service.getWidgets('user-1', 'tenant-1', 'USER' as any);
|
||||
await service.getWidgets('user-1', 'tenant-1', 'USER' as any, DASH_1);
|
||||
|
||||
// Beweist, dass der Katalogzugriff tatsaechlich lief (sonst waere die
|
||||
// Wachhund-Pruefung unten wirkungslos, weil sie nichts protokollieren
|
||||
@@ -743,3 +1038,272 @@ describe('DashboardService — Suchmaschinen gebunden an forTenant(), Katalog be
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// --- Reiter anlegen/umbenennen/löschen/umsortieren (quick-260923-ad9, Task 2) ---
|
||||
|
||||
describe('DashboardService.createDashboard — Reiter anlegen (quick-260923-ad9, Task 2, D-08)', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(forTenant).mockClear();
|
||||
});
|
||||
|
||||
it('erzeugt "Dashboard 2", wenn nur "Dashboard" existiert', async () => {
|
||||
const prisma = makeFakePrisma({ dashboards: [makeDashboard({ id: 'd1', name: 'Dashboard', position: 0 })] });
|
||||
const service = new DashboardService(prisma as any, makeFakeModuleAccessService(new Set()) as any);
|
||||
|
||||
const result = await service.createDashboard('user-1', 'tenant-1');
|
||||
|
||||
expect(result.name).toBe('Dashboard 2');
|
||||
});
|
||||
|
||||
it('erzeugt "Dashboard 3", wenn "Dashboard" und "Dashboard 2" existieren', async () => {
|
||||
const prisma = makeFakePrisma({
|
||||
dashboards: [
|
||||
makeDashboard({ id: 'd1', name: 'Dashboard', position: 0 }),
|
||||
makeDashboard({ id: 'd2', name: 'Dashboard 2', position: 1 }),
|
||||
],
|
||||
});
|
||||
const service = new DashboardService(prisma as any, makeFakeModuleAccessService(new Set()) as any);
|
||||
|
||||
const result = await service.createDashboard('user-1', 'tenant-1');
|
||||
|
||||
expect(result.name).toBe('Dashboard 3');
|
||||
});
|
||||
|
||||
it('füllt eine Lücke: "Dashboard" und "Dashboard 3" existieren -> "Dashboard 2"', async () => {
|
||||
const prisma = makeFakePrisma({
|
||||
dashboards: [
|
||||
makeDashboard({ id: 'd1', name: 'Dashboard', position: 0 }),
|
||||
makeDashboard({ id: 'd3', name: 'Dashboard 3', position: 1 }),
|
||||
],
|
||||
});
|
||||
const service = new DashboardService(prisma as any, makeFakeModuleAccessService(new Set()) as any);
|
||||
|
||||
const result = await service.createDashboard('user-1', 'tenant-1');
|
||||
|
||||
expect(result.name).toBe('Dashboard 2');
|
||||
});
|
||||
|
||||
it('hängt ans Ende — Position ist die höchste vorhandene plus eins', async () => {
|
||||
const prisma = makeFakePrisma({
|
||||
dashboards: [
|
||||
makeDashboard({ id: 'd1', name: 'Dashboard', position: 0 }),
|
||||
makeDashboard({ id: 'd2', name: 'Dashboard 2', position: 5 }),
|
||||
],
|
||||
});
|
||||
const service = new DashboardService(prisma as any, makeFakeModuleAccessService(new Set()) as any);
|
||||
|
||||
const result = await service.createDashboard('user-1', 'tenant-1');
|
||||
|
||||
expect(result.position).toBe(6);
|
||||
});
|
||||
|
||||
it('liefert den neuen Reiter mit leerer Kachelliste', async () => {
|
||||
const prisma = makeFakePrisma({ dashboards: [makeDashboard({ id: 'd1' })] });
|
||||
const service = new DashboardService(prisma as any, makeFakeModuleAccessService(new Set()) as any);
|
||||
|
||||
const created = await service.createDashboard('user-1', 'tenant-1');
|
||||
const widgets = await service.getWidgets('user-1', 'tenant-1', 'USER' as any, created.id);
|
||||
|
||||
expect(widgets).toEqual([]);
|
||||
});
|
||||
|
||||
it('wird ab 20 vorhandenen Reitern abgewiesen, ohne eine Zeile zu schreiben', async () => {
|
||||
const dashboards = Array.from({ length: 20 }, (_, i) =>
|
||||
makeDashboard({ id: `d${i}`, name: i === 0 ? 'Dashboard' : `Dashboard ${i + 1}`, position: i }),
|
||||
);
|
||||
const prisma = makeFakePrisma({ dashboards });
|
||||
const service = new DashboardService(prisma as any, makeFakeModuleAccessService(new Set()) as any);
|
||||
|
||||
await expect(service.createDashboard('user-1', 'tenant-1')).rejects.toThrow();
|
||||
expect(prisma.dashboard.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DashboardService.renameDashboard — Reiter umbenennen (quick-260923-ad9, Task 2)', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(forTenant).mockClear();
|
||||
});
|
||||
|
||||
it('benennt den eigenen Reiter um', async () => {
|
||||
const prisma = makeFakePrisma({ dashboards: [makeDashboard({ id: DASH_1 })] });
|
||||
const service = new DashboardService(prisma as any, makeFakeModuleAccessService(new Set()) as any);
|
||||
|
||||
const result = await service.renameDashboard(DASH_1, 'user-1', 'tenant-1', { name: 'Finanzen' } as any);
|
||||
|
||||
expect(result.name).toBe('Finanzen');
|
||||
});
|
||||
|
||||
it('ein fremder Reiter führt zur Nicht-gefunden-Antwort', async () => {
|
||||
const prisma = makeFakePrisma({
|
||||
dashboards: [makeDashboard({ id: DASH_1, userId: 'other-user' })],
|
||||
});
|
||||
const service = new DashboardService(prisma as any, makeFakeModuleAccessService(new Set()) as any);
|
||||
|
||||
await expect(
|
||||
service.renameDashboard(DASH_1, 'user-1', 'tenant-1', { name: 'x' } as any),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('RenameDashboardDto — Beschneiden und Längenprüfung (quick-260923-ad9, Task 2)', () => {
|
||||
it('beschneidet führende/nachgestellte Leerräume vor der Längenprüfung', async () => {
|
||||
const { plainToInstance } = await import('class-transformer');
|
||||
const { validate } = await import('class-validator');
|
||||
const { RenameDashboardDto } = await import('./dto/rename-dashboard.dto');
|
||||
|
||||
const dto = plainToInstance(RenameDashboardDto, { name: ' Finanzen ' });
|
||||
expect(dto.name).toBe('Finanzen');
|
||||
expect(await validate(dto)).toEqual([]);
|
||||
});
|
||||
|
||||
it('ein leerer Name (nach dem Beschneiden) wird abgewiesen', async () => {
|
||||
const { plainToInstance } = await import('class-transformer');
|
||||
const { validate } = await import('class-validator');
|
||||
const { RenameDashboardDto } = await import('./dto/rename-dashboard.dto');
|
||||
|
||||
const dto = plainToInstance(RenameDashboardDto, { name: ' ' });
|
||||
const errors = await validate(dto);
|
||||
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
expect(errors[0].property).toBe('name');
|
||||
});
|
||||
|
||||
it('ein Name über 40 Zeichen wird abgewiesen', async () => {
|
||||
const { plainToInstance } = await import('class-transformer');
|
||||
const { validate } = await import('class-validator');
|
||||
const { RenameDashboardDto } = await import('./dto/rename-dashboard.dto');
|
||||
|
||||
const dto = plainToInstance(RenameDashboardDto, { name: 'x'.repeat(41) });
|
||||
const errors = await validate(dto);
|
||||
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
expect(errors[0].property).toBe('name');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DashboardService.deleteDashboard — Reiter löschen (quick-260923-ad9, Task 2, D-10)', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(forTenant).mockClear();
|
||||
});
|
||||
|
||||
it('entfernt Reiter, seine Kacheln und seine Anordnung in EINER Transaktion und schreibt die verbleibenden Positionen lückenlos neu', async () => {
|
||||
const prisma = makeFakePrisma({
|
||||
dashboards: [
|
||||
makeDashboard({ id: 'd1', position: 0 }),
|
||||
makeDashboard({ id: 'd2', position: 1 }),
|
||||
makeDashboard({ id: 'd3', position: 2 }),
|
||||
],
|
||||
widgets: [makeWidget({ id: 'w1', dashboardId: 'd2' })],
|
||||
layout: { dashboardId: 'd2', userId: 'user-1', tenantId: 'tenant-1', layouts: {} },
|
||||
});
|
||||
const service = new DashboardService(prisma as any, makeFakeModuleAccessService(new Set()) as any);
|
||||
|
||||
await service.deleteDashboard('d2', 'user-1', 'tenant-1');
|
||||
|
||||
const remaining = await service.listDashboards('user-1', 'tenant-1');
|
||||
expect(remaining.map((d: any) => d.id)).toEqual(['d1', 'd3']);
|
||||
expect(remaining.map((d: any) => d.position)).toEqual([0, 1]);
|
||||
expect(prisma.widgetInstance.deleteMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { dashboardId: 'd2', userId: 'user-1' } }),
|
||||
);
|
||||
expect(prisma.dashboardLayout.deleteMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { dashboardId: 'd2', userId: 'user-1' } }),
|
||||
);
|
||||
});
|
||||
|
||||
it('der letzte verbleibende Reiter kann nicht gelöscht werden — nichts wird geschrieben', async () => {
|
||||
const prisma = makeFakePrisma({ dashboards: [makeDashboard({ id: 'd1' })] });
|
||||
const service = new DashboardService(prisma as any, makeFakeModuleAccessService(new Set()) as any);
|
||||
|
||||
await expect(service.deleteDashboard('d1', 'user-1', 'tenant-1')).rejects.toBeInstanceOf(
|
||||
ConflictException,
|
||||
);
|
||||
expect(prisma.dashboard.deleteMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ein fremder Reiter führt zur Nicht-gefunden-Antwort', async () => {
|
||||
const prisma = makeFakePrisma({
|
||||
dashboards: [
|
||||
makeDashboard({ id: 'd1', userId: 'other-user' }),
|
||||
makeDashboard({ id: 'd2', userId: 'other-user', position: 1 }),
|
||||
],
|
||||
});
|
||||
const service = new DashboardService(prisma as any, makeFakeModuleAccessService(new Set()) as any);
|
||||
|
||||
await expect(service.deleteDashboard('d1', 'user-1', 'tenant-1')).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DashboardService.reorderDashboards — Reiter umsortieren (quick-260923-ad9, Task 2, T-AD9-04)', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(forTenant).mockClear();
|
||||
});
|
||||
|
||||
it('schreibt die Positionen 0…n-1 in der gesendeten Reihenfolge', async () => {
|
||||
const prisma = makeFakePrisma({
|
||||
dashboards: [
|
||||
makeDashboard({ id: 'd1', position: 0 }),
|
||||
makeDashboard({ id: 'd2', position: 1 }),
|
||||
makeDashboard({ id: 'd3', position: 2 }),
|
||||
],
|
||||
});
|
||||
const service = new DashboardService(prisma as any, makeFakeModuleAccessService(new Set()) as any);
|
||||
|
||||
const result = await service.reorderDashboards('user-1', 'tenant-1', {
|
||||
ids: ['d3', 'd1', 'd2'],
|
||||
} as any);
|
||||
|
||||
expect(result.map((d: any) => d.id)).toEqual(['d3', 'd1', 'd2']);
|
||||
expect(result.map((d: any) => d.position)).toEqual([0, 1, 2]);
|
||||
});
|
||||
|
||||
it('eine unvollständige Liste wird abgewiesen, ohne eine einzige Position zu ändern', async () => {
|
||||
const prisma = makeFakePrisma({
|
||||
dashboards: [
|
||||
makeDashboard({ id: 'd1', position: 0 }),
|
||||
makeDashboard({ id: 'd2', position: 1 }),
|
||||
],
|
||||
});
|
||||
const service = new DashboardService(prisma as any, makeFakeModuleAccessService(new Set()) as any);
|
||||
|
||||
await expect(
|
||||
service.reorderDashboards('user-1', 'tenant-1', { ids: ['d1'] } as any),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
const unchanged = await service.listDashboards('user-1', 'tenant-1');
|
||||
expect(unchanged.map((d: any) => d.position)).toEqual([0, 1]);
|
||||
});
|
||||
|
||||
it('eine unbekannte Kennung wird abgewiesen, ohne eine einzige Position zu ändern', async () => {
|
||||
const prisma = makeFakePrisma({
|
||||
dashboards: [
|
||||
makeDashboard({ id: 'd1', position: 0 }),
|
||||
makeDashboard({ id: 'd2', position: 1 }),
|
||||
],
|
||||
});
|
||||
const service = new DashboardService(prisma as any, makeFakeModuleAccessService(new Set()) as any);
|
||||
|
||||
await expect(
|
||||
service.reorderDashboards('user-1', 'tenant-1', { ids: ['d1', 'unknown'] } as any),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
const unchanged = await service.listDashboards('user-1', 'tenant-1');
|
||||
expect(unchanged.map((d: any) => d.position)).toEqual([0, 1]);
|
||||
});
|
||||
|
||||
it('die Kennung eines fremden Reiters wird abgewiesen, ohne eine einzige Position zu ändern', async () => {
|
||||
const prisma = makeFakePrisma({
|
||||
dashboards: [
|
||||
makeDashboard({ id: 'd1', position: 0 }),
|
||||
makeDashboard({ id: 'foreign', userId: 'other-user', position: 0 }),
|
||||
],
|
||||
});
|
||||
const service = new DashboardService(prisma as any, makeFakeModuleAccessService(new Set()) as any);
|
||||
|
||||
await expect(
|
||||
service.reorderDashboards('user-1', 'tenant-1', { ids: ['d1', 'foreign'] } as any),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
const unchanged = await service.listDashboards('user-1', 'tenant-1');
|
||||
expect(unchanged.map((d: any) => d.position)).toEqual([0]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,18 +1,27 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { Prisma, Role } from '@prisma/client';
|
||||
import { ModuleAccessService } from '../module-registry/module-access.service';
|
||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||
import { forTenant, withTenantTransaction } from '../prisma/prisma-tenant.extension';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreateSearchProviderDto } from './dto/create-search-provider.dto';
|
||||
import { CreateWidgetDto } from './dto/create-widget.dto';
|
||||
import { RenameDashboardDto } from './dto/rename-dashboard.dto';
|
||||
import { ReorderDashboardsDto } from './dto/reorder-dashboards.dto';
|
||||
import { SaveLayoutDto } from './dto/save-layout.dto';
|
||||
import { UpdateWidgetConfigDto } from './dto/update-widget-config.dto';
|
||||
import { getModuleSlugForWidgetType } from './widget-module-map';
|
||||
|
||||
/**
|
||||
* T-AD9-06 — Riegel gegen Massenanfragen: hoechstens 20 Reiter je Benutzer
|
||||
* (quick-260923-ad9, Task 2).
|
||||
*/
|
||||
const DASHBOARD_MAX_COUNT = 20;
|
||||
|
||||
/**
|
||||
* Default search providers (D-15).
|
||||
* Returned as part of getSearchProviders even when no DB rows exist.
|
||||
@@ -88,13 +97,233 @@ export class DashboardService {
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Returns the user's saved layout, or a default empty layout
|
||||
* with all breakpoint arrays initialized.
|
||||
* Reiter (quick-260923-ad9, D-01/D-08/D-09): liest die Dashboards des
|
||||
* Benutzers, nach `position` aufsteigend — Position 0 ist der Standard
|
||||
* und wird beim Öffnen geladen. Ist die Liste leer (erster Aufruf des
|
||||
* Benutzers ueberhaupt), wird genau EIN Reiter „Dashboard“ angelegt.
|
||||
*
|
||||
* Das Anlegen laeuft in einer `withTenantTransaction`, deren ERSTE
|
||||
* Anweisung eine Transaktionssperre auf die Benutzerkennung nimmt
|
||||
* (`pg_advisory_xact_lock`, `hashtext` ueber die Benutzerkennung als
|
||||
* ersten Schluessel, 0 als zweiten — beides eingebaute Postgres-
|
||||
* Funktionen). Zwei gleichzeitige erste Aufrufe desselben Benutzers
|
||||
* warten dadurch aufeinander statt beide "kein Reiter vorhanden" zu
|
||||
* sehen; die erneute Zaehlung INNERHALB der Sperre verhindert die
|
||||
* doppelte Anlage (T-AD9-07). `withTenantTransaction` setzt keine
|
||||
* Benutzerdimension in der Sitzung — die Bedingung traegt `userId` UND
|
||||
* `tenantId` deshalb selbst, als zweites Netz.
|
||||
*/
|
||||
async getLayout(userId: string, tenantId: string) {
|
||||
async listDashboards(userId: string, tenantId: string) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||
const record = await tenantPrisma.dashboardLayout.findUnique({
|
||||
let dashboards = await tenantPrisma.dashboard.findMany({
|
||||
where: { userId },
|
||||
orderBy: { position: 'asc' },
|
||||
});
|
||||
|
||||
if (dashboards.length === 0) {
|
||||
await withTenantTransaction(this.prisma, tenantId, async (tx) => {
|
||||
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${userId}), 0)`;
|
||||
const existing = await tx.dashboard.count({
|
||||
where: { userId, tenantId },
|
||||
});
|
||||
if (existing === 0) {
|
||||
await tx.dashboard.create({
|
||||
data: { userId, tenantId, name: 'Dashboard', position: 0 },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
dashboards = await tenantPrisma.dashboard.findMany({
|
||||
where: { userId },
|
||||
orderBy: { position: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
return dashboards;
|
||||
}
|
||||
|
||||
/**
|
||||
* Riegel gegen fremde Reiter (T-AD9-01/02/03, Muster `FavoritesService.
|
||||
* create`/T-GWH-05): liest den Reiter ueber den BEREITS gebundenen
|
||||
* Klienten des Aufrufers (kein zweiter `forTenant()`-Aufruf) und wirft
|
||||
* fuer drei ununterscheidbare Faelle dieselbe `NotFoundException` — "gibt
|
||||
* es nicht", "gehoert einem Kollegen" und "liegt bei einem fremden
|
||||
* Mandanten" (die Mandantengrenze zieht bereits der gebundene Klient).
|
||||
* Niemals eine abweichende Antwort, aus der sich die Existenz eines
|
||||
* fremden Reiters ablesen liesse.
|
||||
*/
|
||||
private async assertOwnedDashboard(
|
||||
tenantPrisma: ReturnType<typeof forTenant>,
|
||||
dashboardId: string,
|
||||
userId: string,
|
||||
): Promise<void> {
|
||||
const dashboard = await tenantPrisma.dashboard.findUnique({
|
||||
where: { id: dashboardId },
|
||||
});
|
||||
|
||||
if (!dashboard || dashboard.userId !== userId) {
|
||||
throw new NotFoundException(`Dashboard with id '${dashboardId}' not found`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Legt einen neuen, leeren Reiter an (quick-260923-ad9, Task 2, D-08).
|
||||
* Name automatisch: "Dashboard 2", "Dashboard 3", … — die kleinste noch
|
||||
* freie Zahl ab 2 (füllt eine Lücke, wenn z. B. "Dashboard 2" gelöscht
|
||||
* wurde). Dieser Name ist ein gespeicherter Datenwert, keine
|
||||
* Oberflächenbeschriftung — deshalb ein TypeScript-Text hier statt eines
|
||||
* Übersetzungsschlüssels, genau wie der Name "Dashboard", den die
|
||||
* Migration/`listDashboards` vergeben. Hängt ans Ende (höchste
|
||||
* vorhandene Position plus eins) und liefert den neuen Reiter mit
|
||||
* leerer Kachelliste (es existiert noch keine `WidgetInstance`-Zeile
|
||||
* dafür).
|
||||
*/
|
||||
async createDashboard(userId: string, tenantId: string) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||
const existing = await tenantPrisma.dashboard.findMany({ where: { userId } });
|
||||
|
||||
if (existing.length >= DASHBOARD_MAX_COUNT) {
|
||||
throw new BadRequestException(
|
||||
`Es sind bereits ${DASHBOARD_MAX_COUNT} Dashboards vorhanden — mehr sind nicht möglich.`,
|
||||
);
|
||||
}
|
||||
|
||||
const existingNames = new Set(existing.map((d) => d.name));
|
||||
let n = 2;
|
||||
while (existingNames.has(`Dashboard ${n}`)) n++;
|
||||
|
||||
const nextPosition = existing.reduce((max, d) => Math.max(max, d.position), -1) + 1;
|
||||
|
||||
return tenantPrisma.dashboard.create({
|
||||
data: { userId, tenantId, name: `Dashboard ${n}`, position: nextPosition },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Benennt einen Reiter um (quick-260923-ad9, Task 2). `assertOwnedDashboard`
|
||||
* läuft zuerst, über denselben gebundenen Klienten — eine fremde Kennung
|
||||
* liefert die Nicht-gefunden-Antwort (T-AD9-03). Beschneiden und
|
||||
* Längenprüfung (1–40 Zeichen) liegen bereits im DTO.
|
||||
*/
|
||||
async renameDashboard(
|
||||
id: string,
|
||||
userId: string,
|
||||
tenantId: string,
|
||||
dto: RenameDashboardDto,
|
||||
) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||
await this.assertOwnedDashboard(tenantPrisma, id, userId);
|
||||
|
||||
return tenantPrisma.dashboard.update({
|
||||
where: { id },
|
||||
data: { name: dto.name },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Löscht einen Reiter mit seinen Kacheln und seiner Anordnung
|
||||
* (quick-260923-ad9, Task 2). `assertOwnedDashboard` läuft zuerst; danach
|
||||
* wird geprüft, ob es der letzte verbleibende Reiter ist (D-10) — der
|
||||
* Server weist das ab, die Oberfläche bietet den Knopf dafür gar nicht
|
||||
* erst an. Löschen, Anordnung-/Kachel-Entfernen und das lückenlose
|
||||
* Neuschreiben der verbleibenden Positionen laufen als EINE
|
||||
* `withTenantTransaction` (mehrschrittig, muss atomar sein — dieselbe
|
||||
* Begründung wie `FavoritesService.reorder`). Die Löschweitergabe in der
|
||||
* Datenbank (`onDelete: Cascade`) bleibt als zweites Netz bestehen; der
|
||||
* geschriebene Weg unten ist der gebundene. `withTenantTransaction`
|
||||
* setzt keine Benutzerdimension in der Sitzung — jede Bedingung trägt
|
||||
* `userId` deshalb selbst.
|
||||
*/
|
||||
async deleteDashboard(id: string, userId: string, tenantId: string) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||
await this.assertOwnedDashboard(tenantPrisma, id, userId);
|
||||
|
||||
const count = await tenantPrisma.dashboard.count({ where: { userId, tenantId } });
|
||||
if (count <= 1) {
|
||||
throw new ConflictException('Der letzte verbleibende Reiter kann nicht gelöscht werden.');
|
||||
}
|
||||
|
||||
return withTenantTransaction(this.prisma, tenantId, async (tx) => {
|
||||
await tx.widgetInstance.deleteMany({ where: { dashboardId: id, userId } });
|
||||
await tx.dashboardLayout.deleteMany({ where: { dashboardId: id, userId } });
|
||||
await tx.dashboard.deleteMany({ where: { id, userId } });
|
||||
|
||||
const remaining = await tx.dashboard.findMany({
|
||||
where: { userId },
|
||||
orderBy: { position: 'asc' },
|
||||
});
|
||||
|
||||
for (const [index, dashboard] of remaining.entries()) {
|
||||
await tx.dashboard.updateMany({
|
||||
where: { id: dashboard.id, userId },
|
||||
data: { position: index },
|
||||
});
|
||||
}
|
||||
|
||||
return { id };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Persistiert die Reihenfolge der Reiter des Benutzers
|
||||
* (quick-260923-ad9, Task 2). Wörtlich nach dem Muster
|
||||
* `FavoritesService.reorder` (260917-jdd): EINE `withTenantTransaction`,
|
||||
* darin erst die vorhandenen Kennungen lesen, auf exakte Übereinstimmung
|
||||
* mit der gesendeten Liste prüfen (sonst Abweisung, KEIN Teilschreiben —
|
||||
* die Prüfung läuft VOR jedem `updateMany`), dann je Eintrag ein
|
||||
* `updateMany` mit `id` UND `userId` in der Bedingung und einer Prüfung
|
||||
* auf genau eine getroffene Zeile (T-AD9-04). Existenzorakel-Vermeidung:
|
||||
* EINE `BadRequestException` mit DERSELBEN Meldung für unvollständige,
|
||||
* unbekannte und fremde Kennungen — kein Fall verrät, welcher Grund
|
||||
* zutraf (Muster T-GWH-05/T-JDD-06).
|
||||
*/
|
||||
async reorderDashboards(userId: string, tenantId: string, dto: ReorderDashboardsDto) {
|
||||
if (new Set(dto.ids).size !== dto.ids.length) {
|
||||
throw new BadRequestException('ids must match the dashboards of this user exactly');
|
||||
}
|
||||
|
||||
return withTenantTransaction(this.prisma, tenantId, async (tx) => {
|
||||
const existing = await tx.dashboard.findMany({
|
||||
where: { userId },
|
||||
select: { id: true },
|
||||
});
|
||||
const existingIds = new Set(existing.map((r: { id: string }) => r.id));
|
||||
|
||||
if (existing.length !== dto.ids.length || dto.ids.some((id) => !existingIds.has(id))) {
|
||||
throw new BadRequestException('ids must match the dashboards of this user exactly');
|
||||
}
|
||||
|
||||
for (const [index, id] of dto.ids.entries()) {
|
||||
const { count } = await tx.dashboard.updateMany({
|
||||
where: { id, userId },
|
||||
data: { position: index },
|
||||
});
|
||||
|
||||
if (count !== 1) {
|
||||
throw new BadRequestException('ids must match the dashboards of this user exactly');
|
||||
}
|
||||
}
|
||||
|
||||
return tx.dashboard.findMany({
|
||||
where: { userId },
|
||||
orderBy: { position: 'asc' },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the saved layout of one dashboard tab, or a default empty
|
||||
* layout with all breakpoint arrays initialized.
|
||||
*
|
||||
* quick-260923-ad9 (D-02): scoped by `dashboardId` instead of `userId` —
|
||||
* `assertOwnedDashboard` runs first, over the SAME bound client.
|
||||
*/
|
||||
async getLayout(userId: string, tenantId: string, dashboardId: string) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||
await this.assertOwnedDashboard(tenantPrisma, dashboardId, userId);
|
||||
|
||||
const record = await tenantPrisma.dashboardLayout.findUnique({
|
||||
where: { dashboardId },
|
||||
});
|
||||
|
||||
if (!record) {
|
||||
@@ -105,32 +334,33 @@ export class DashboardService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Upserts the user's dashboard layout.
|
||||
* Upserts the layout of one dashboard tab.
|
||||
* Creates a new record if none exists, updates if it does.
|
||||
*
|
||||
* `userId` is platform-wide `@unique` (no tenant component) — a tenant
|
||||
* whose user id was, by hand, moved off its actually-visible row could hit
|
||||
* an `upsert` conflict on a row it cannot see under RLS. Measured
|
||||
* (260910-krx, Aufgabe 1): a bound conflicting upsert against such a row
|
||||
* throws `Prisma.PrismaClientUnknownRequestError` (NOT the `P2002` known
|
||||
* error that the `tenders` area's translation pattern catches — this is a
|
||||
* quick-260923-ad9 (D-02): scoped by `dto.dashboardId` instead of
|
||||
* `userId` — `assertOwnedDashboard` runs first, over the SAME bound
|
||||
* client. `dashboardId` is now the `@unique` column on `DashboardLayout`
|
||||
* (was `userId` before this plan).
|
||||
*
|
||||
* A bound conflicting upsert against a row invisible under RLS throws
|
||||
* `Prisma.PrismaClientUnknownRequestError` (NOT the `P2002` known error
|
||||
* that the `tenders` area's translation pattern catches — this is a
|
||||
* different Prisma error class, `.code`/`.meta` are `undefined`, the only
|
||||
* signal is the raw `.message` text). Translated below into an
|
||||
* understandable German message instead of a raw 500, same intent as
|
||||
* `tender-notification-pref.service.ts`, different detection. Not
|
||||
* reachable via any application path today (a user's tenant id never
|
||||
* changes after creation) — the honest fix is a schema change and is
|
||||
* deferred as a product decision to Etappe 3, same as WINDOWS #22.
|
||||
* signal is the raw `.message` text) — measured 260910-krx, Aufgabe 1,
|
||||
* translation kept unchanged from before this plan.
|
||||
*/
|
||||
async saveLayout(userId: string, tenantId: string, dto: SaveLayoutDto) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||
await this.assertOwnedDashboard(tenantPrisma, dto.dashboardId, userId);
|
||||
|
||||
try {
|
||||
return await tenantPrisma.dashboardLayout.upsert({
|
||||
where: { userId },
|
||||
where: { dashboardId: dto.dashboardId },
|
||||
update: { layouts: dto.layouts as unknown as Prisma.InputJsonValue },
|
||||
create: {
|
||||
userId,
|
||||
tenantId,
|
||||
dashboardId: dto.dashboardId,
|
||||
layouts: dto.layouts as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
@@ -145,12 +375,13 @@ export class DashboardService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all widget instances for a given user, gefiltert um Widgets
|
||||
* Returns all widget instances of one dashboard tab, gefiltert um Widgets
|
||||
* eines für den Benutzer gesperrten Moduls (D-22, PERM-07).
|
||||
*
|
||||
* Die bestehende Query bleibt unverändert die erste Aktion. Steht unter
|
||||
* den geladenen Widgets kein einziger Typ in `WIDGET_MODULE_MAP` — der
|
||||
* Zustand am Ende dieser Phase, weil die Tabelle leer ist — wird die
|
||||
* quick-260923-ad9 (D-02): scoped by `dashboardId` instead of `userId` —
|
||||
* `assertOwnedDashboard` runs first, over the SAME bound client. Steht
|
||||
* unter den geladenen Widgets kein einziger Typ in `WIDGET_MODULE_MAP` —
|
||||
* der Zustand am Ende dieser Phase, weil die Tabelle leer ist — wird die
|
||||
* Liste unverändert zurückgegeben, ohne einen Zugriffs-Lookup. Nur bei
|
||||
* mindestens einem modulgebundenen Widget wird die Zugriffsauflösung
|
||||
* aus 15-01 einmal aufgerufen (D-01: dieselbe Auflösung wie Guard und
|
||||
@@ -158,10 +389,12 @@ export class DashboardService {
|
||||
* Modul-Slug nicht auf einen `Module`-Datensatz auflösen, wird das
|
||||
* betroffene Widget entfernt (Fail-Closed).
|
||||
*/
|
||||
async getWidgets(userId: string, tenantId: string, role: Role) {
|
||||
async getWidgets(userId: string, tenantId: string, role: Role, dashboardId: string) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||
await this.assertOwnedDashboard(tenantPrisma, dashboardId, userId);
|
||||
|
||||
const widgets = await tenantPrisma.widgetInstance.findMany({
|
||||
where: { userId },
|
||||
where: { dashboardId },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
|
||||
@@ -207,14 +440,20 @@ export class DashboardService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new widget instance for the user.
|
||||
* Creates a new widget instance on one dashboard tab.
|
||||
* quick-260923-ad9 (D-02): `assertOwnedDashboard` runs first, over the
|
||||
* SAME bound client — a widget can only be created on a tab the caller
|
||||
* owns.
|
||||
*/
|
||||
async addWidget(userId: string, tenantId: string, dto: CreateWidgetDto) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||
await this.assertOwnedDashboard(tenantPrisma, dto.dashboardId, userId);
|
||||
|
||||
return tenantPrisma.widgetInstance.create({
|
||||
data: {
|
||||
userId,
|
||||
tenantId,
|
||||
dashboardId: dto.dashboardId,
|
||||
widgetType: dto.widgetType,
|
||||
config: (dto.config ?? {}) as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
|
||||
@@ -1,24 +1,26 @@
|
||||
import { IsIn, IsObject, IsOptional, IsString } from 'class-validator';
|
||||
import { WIDGET_TYPES } from '@tessera/shared';
|
||||
|
||||
/**
|
||||
* DTO for creating a new widget instance on a user's dashboard.
|
||||
* widgetType must be one of the nine supported types
|
||||
* ('picture-frame' seit quick-260921-pi9, 'xframe' seit quick-260921-qd3).
|
||||
* DTO for creating a new widget instance on one dashboard tab.
|
||||
*
|
||||
* quick-260922-m1h: `widgetType` wird gegen `WIDGET_TYPES` aus
|
||||
* `@tessera/shared` geprüft — dieselbe Liste, aus der das Frontend seine
|
||||
* Registry und seinen Katalog ableitet. Vorher stand die Liste hier ein
|
||||
* zweites Mal; vergaß man einen Eintrag, lehnte die API eine im Katalog
|
||||
* angebotene Kachel mit 400 ab.
|
||||
*
|
||||
* quick-260923-ad9: `dashboardId` selects the tab — the service verifies
|
||||
* ownership before writing (`assertOwnedDashboard`).
|
||||
*
|
||||
* config is optional and defaults to {} on the model.
|
||||
*/
|
||||
export class CreateWidgetDto {
|
||||
@IsString()
|
||||
@IsIn([
|
||||
'clock',
|
||||
'search',
|
||||
'calendar',
|
||||
'note',
|
||||
'calculator',
|
||||
'favorites',
|
||||
'stopwatch',
|
||||
'picture-frame',
|
||||
'xframe',
|
||||
])
|
||||
dashboardId!: string;
|
||||
|
||||
@IsString()
|
||||
@IsIn([...WIDGET_TYPES])
|
||||
widgetType!: string;
|
||||
|
||||
@IsOptional()
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsString, Length } from 'class-validator';
|
||||
|
||||
/**
|
||||
* DTO for `PATCH /dashboard/tabs/:id` (quick-260923-ad9, Task 2).
|
||||
*
|
||||
* `name` wird VOR der Längenprüfung beschnitten (führende/nachgestellte
|
||||
* Leerräume zählen nicht mit) — ein reiner Leerraum-Name schlägt danach an
|
||||
* `@Length(1, 40)` fehl. Die Obergrenze von 40 Zeichen ist ein Riegel gegen
|
||||
* Massenanfragen, kein UI-Detail (T-AD9-06).
|
||||
*/
|
||||
export class RenameDashboardDto {
|
||||
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
||||
@IsString()
|
||||
@Length(1, 40)
|
||||
name!: string;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
ArrayMinSize,
|
||||
ArrayUnique,
|
||||
IsArray,
|
||||
IsString,
|
||||
} from 'class-validator';
|
||||
|
||||
/**
|
||||
* DTO for `PUT /dashboard/tabs/order` (quick-260923-ad9, Task 2).
|
||||
*
|
||||
* `ids` ist die VOLLSTÄNDIGE Kennungsliste der Reiter des Benutzers, in
|
||||
* der gewünschten Reihenfolge — der Dienst verlangt einen exakten Abgleich
|
||||
* gegen die vorhandenen Reiter (kein Teil-Umsortieren, keine fremden/
|
||||
* unbekannten Kennungen), Muster `ReorderFavoritesDto`/`FavoritesService.
|
||||
* reorder` (260917-jdd). `ArrayMaxSize(20)` ist ein Riegel gegen
|
||||
* Massenanfragen (T-AD9-06) — ein Benutzer hat höchstens 20 Reiter.
|
||||
*/
|
||||
export class ReorderDashboardsDto {
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ArrayMaxSize(20)
|
||||
@ArrayUnique()
|
||||
@IsString({ each: true })
|
||||
ids!: string[];
|
||||
}
|
||||
@@ -1,11 +1,16 @@
|
||||
import { IsObject } from 'class-validator';
|
||||
import { IsObject, IsString } from 'class-validator';
|
||||
|
||||
/**
|
||||
* DTO for saving/updating a user's dashboard layout.
|
||||
* DTO for saving/updating the layout of one dashboard tab (quick-260923-ad9).
|
||||
* The layouts object contains responsive breakpoint layouts
|
||||
* (lg, md, sm, xs, xxs) as managed by react-grid-layout.
|
||||
* `dashboardId` selects the tab — the service verifies ownership before
|
||||
* writing (`assertOwnedDashboard`).
|
||||
*/
|
||||
export class SaveLayoutDto {
|
||||
@IsString()
|
||||
dashboardId!: string;
|
||||
|
||||
@IsObject()
|
||||
layouts!: Record<string, unknown>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { validate } from 'class-validator';
|
||||
import { WIDGET_MODULE_SLUGS, WIDGET_TYPES } from '@tessera/shared';
|
||||
import { CreateWidgetDto } from './dto/create-widget.dto';
|
||||
import { WIDGET_MODULE_MAP, getModuleSlugForWidgetType } from './widget-module-map';
|
||||
|
||||
/**
|
||||
* quick-260922-m1h: Web und API lesen dieselbe Tabelle. Liefen sie
|
||||
* auseinander, wuerde der Katalog eine Kachel anbieten, die der Server
|
||||
* danach wieder herausfiltert (oder umgekehrt).
|
||||
*/
|
||||
describe('widget-module-map (quick-260922-m1h)', () => {
|
||||
it('WIDGET_MODULE_MAP ist die Tabelle aus @tessera/shared, keine zweite Kopie', () => {
|
||||
expect(WIDGET_MODULE_MAP).toBe(WIDGET_MODULE_SLUGS);
|
||||
});
|
||||
|
||||
it('jeder Schluessel der Tabelle ist ein bekannter Widget-Typ', () => {
|
||||
for (const type of Object.keys(WIDGET_MODULE_MAP)) {
|
||||
expect(WIDGET_TYPES).toContain(type);
|
||||
}
|
||||
});
|
||||
|
||||
it('die neun heutigen Kacheln sind Plattform-Kacheln ohne Modulbezug', () => {
|
||||
for (const type of WIDGET_TYPES) {
|
||||
expect(getModuleSlugForWidgetType(type)).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('ein unbekannter Typ liefert undefined statt zu werfen', () => {
|
||||
expect(getModuleSlugForWidgetType('gibt-es-nicht')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Der Kern des Umbaus: die `@IsIn`-Whitelist ist keine handgepflegte zweite
|
||||
* Liste mehr. Vergisst kuenftig jemand einen Eintrag in `packages/shared`,
|
||||
* schlaegt dieser Test fehl, statt die API eine gueltige Kachel mit 400
|
||||
* ablehnen zu lassen.
|
||||
*/
|
||||
describe('CreateWidgetDto-Whitelist (quick-260922-m1h)', () => {
|
||||
// quick-260923-ad9: dashboardId ist seither ein Pflichtfeld (Reiter-
|
||||
// Kennung) — hier fest mitgegeben, damit dieser Test weiterhin nur die
|
||||
// Whitelist von widgetType prueft.
|
||||
async function validateType(widgetType: string) {
|
||||
const dto = plainToInstance(CreateWidgetDto, { widgetType, dashboardId: 'dash-1' });
|
||||
return validate(dto);
|
||||
}
|
||||
|
||||
it.each([...WIDGET_TYPES])('akzeptiert den Typ "%s"', async (widgetType) => {
|
||||
await expect(validateType(widgetType)).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it('lehnt einen Typ ab, der nicht in WIDGET_TYPES steht', async () => {
|
||||
const errors = await validateType('gibt-es-nicht');
|
||||
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0].property).toBe('widgetType');
|
||||
expect(errors[0].constraints).toHaveProperty('isIn');
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
import { WIDGET_MODULE_SLUGS } from '@tessera/shared';
|
||||
|
||||
/**
|
||||
* Zuordnung Widget-Typ → Modul (D-22, PERM-07).
|
||||
*
|
||||
@@ -8,24 +10,29 @@
|
||||
* Schlüssel sind Werte von `WidgetInstance.widgetType`, Werte sind
|
||||
* Modul-Slugs aus `Module.slug`.
|
||||
*
|
||||
* quick-260922-m1h: Die Tabelle selbst steht seit diesem Umbau in
|
||||
* `packages/shared/src/index.ts` als `WIDGET_MODULE_SLUGS` — EINE Tabelle
|
||||
* für beide Seiten, damit der Katalogfilter im Web
|
||||
* (`visibleWidgetTypes`, Komfort) und dieser Server-Filter (verbindlich)
|
||||
* nicht auseinanderlaufen. Hier steht nur noch der Lesezugriff; die
|
||||
* öffentliche Schnittstelle dieser Datei bleibt unverändert, weil
|
||||
* dashboard.service.spec.ts sie gezielt mockt.
|
||||
*
|
||||
* Bewusst eine TypeScript-Konstante statt einer Spalte auf
|
||||
* `WidgetInstance`: eine Migration auf einer bereits befüllten Tabelle
|
||||
* für ein Feld, das derzeit für jede Zeile leer wäre, wiegt schwerer als
|
||||
* diese Konstante mit identischer Aussagekraft (15-RESEARCH.md Pitfall 5).
|
||||
*
|
||||
* Die Tabelle ist am Ende dieser Phase bewusst leer: alle sieben heute
|
||||
* registrierten Widget-Typen (clock/search/calendar/note/calculator/
|
||||
* favorites/stopwatch, siehe apps/web/src/components/dashboard/
|
||||
* widget-registry.tsx) sind Plattform-Widgets ohne Modulbezug. Das
|
||||
* einzige bislang geplante modulgebundene Widget steht in
|
||||
* .planning/REQUIREMENTS.md unter "Future Requirements (deferred)" und
|
||||
* wird in dieser Phase bewusst nicht registriert.
|
||||
* Die Tabelle ist bewusst leer: alle neun registrierten Widget-Typen
|
||||
* (clock/search/calendar/note/calculator/favorites/stopwatch/
|
||||
* picture-frame/xframe) sind Plattform-Widgets ohne Modulbezug. Die erste
|
||||
* modulgebundene Kachel trägt ihren Slug in `WIDGET_MODULE_SLUGS` ein.
|
||||
*/
|
||||
export const WIDGET_MODULE_MAP: Readonly<Record<string, string>> = {};
|
||||
export const WIDGET_MODULE_MAP: Readonly<Record<string, string>> = WIDGET_MODULE_SLUGS;
|
||||
|
||||
/**
|
||||
* Liefert den Modul-Slug für einen Widget-Typ, oder `undefined`, wenn
|
||||
* der Typ kein Modul-Widget ist (der heutige Zustand für alle sieben
|
||||
* der Typ kein Modul-Widget ist (der heutige Zustand für alle neun
|
||||
* bestehenden Typen). Einziger Lesezugriff auf die Zuordnungstabelle,
|
||||
* damit Tests sie gezielt mocken können.
|
||||
*/
|
||||
|
||||
@@ -150,10 +150,35 @@ const RELATION_SPEC_EXCEPTIONS = new Set<string>(['apps/api/src/tenders/backfill
|
||||
* der Schleife ist `tenant.findMany` auf `Tenant`, das in keiner Migration
|
||||
* eine Regel traegt — kein Systemkontext noetig, Datei unveraendert.
|
||||
* Summe: 4 Dateien, 5 Aufrufe.
|
||||
*
|
||||
* SIEBTER FALL (quick-260922-hk4): `dashboard-images.service.ts`, EIN
|
||||
* Aufruf, ausschliesslich in `onApplicationBootstrap()` — der einmalige
|
||||
* Umzug der Bilderrahmen-Bilder aus der Spalte `data` in den Dateibereich.
|
||||
* Ein Startpfad hat keinen Mandanten im Ruecken und muss die noch nicht
|
||||
* umgezogenen Zeilen ALLER Mandanten sehen; die passende Regel
|
||||
* `system_read_policy ... FOR SELECT` auf "DashboardImage" legt die
|
||||
* Migration 20260922120000 an. Dieselbe Datei bedient daneben Anfragewege
|
||||
* (`list`/`upload`/`getBytes`/`remove`) — die bleiben ausnahmslos
|
||||
* mandantengebunden, und auch der Umzug SCHREIBT je Zeile ueber
|
||||
* `forTenant(prisma, row.tenantId, row.userId)`, nie ueber den
|
||||
* Systemklienten. Praezedenz fuer "ein Dienst mit Anfrageweg UND
|
||||
* systemgebundenem Startpfad": `ldap-config.service.ts`, dessen
|
||||
* Nachverschluesselung in `onApplicationBootstrap()` genauso gebaut ist.
|
||||
* Summe neu: 5 Dateien, 6 Aufrufe.
|
||||
*
|
||||
* quick-260923-dhh (Aufgabe 4): eine sechste Datei kommt hinzu —
|
||||
* `proxmox.service.ts`/`loadActiveServersForScheduler()`, derselbe
|
||||
* Startpfad-Fall wie `dkv.service.ts`: der Planer liest beim Start ALLE
|
||||
* aktiven `ProxmoxServer`-Zeilen aller Mandanten (`system_read_policy` auf
|
||||
* `ProxmoxServer`, Migration 20260923140000), registriert je Mandant einen
|
||||
* Cron-Auftrag, und schreibt danach ausschliesslich je Zeile gebunden ueber
|
||||
* `forTenant()`. Summe neu: 6 Dateien, 7 Aufrufe.
|
||||
*/
|
||||
const FORSYSTEM_ALLOWED_CALL_SITES = new Map<string, number>([
|
||||
['apps/api/src/dashboard/dashboard-images.service.ts', 1],
|
||||
['apps/api/src/dkv/dkv.service.ts', 1],
|
||||
['apps/api/src/ldap/ldap-config.service.ts', 2],
|
||||
['apps/api/src/proxmox/proxmox.service.ts', 1],
|
||||
['apps/api/src/tenders/tender-digest.scheduler.ts', 1],
|
||||
['apps/api/src/tenders/tender-matching.service.ts', 1],
|
||||
]);
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import {
|
||||
IsBoolean,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUrl,
|
||||
Max,
|
||||
Min,
|
||||
Validate,
|
||||
ValidateIf,
|
||||
type ValidationArguments,
|
||||
ValidatorConstraint,
|
||||
type ValidatorConstraintInterface,
|
||||
} from 'class-validator';
|
||||
|
||||
/**
|
||||
* D-03: PMG kennt laut Recherche keinen API-Token (Annahme A1) — ein Server
|
||||
* vom Typ `pmg` mit `authMethod: 'token'` wird bereits beim Speichern mit
|
||||
* einer deutschen Klartextmeldung abgelehnt (400), nicht erst beim
|
||||
* Abfragen. Angebracht am Feld `authMethod`, liest aber `productType`
|
||||
* desselben Objekts (`args.object`) — class-validator erlaubt das.
|
||||
*/
|
||||
@ValidatorConstraint({ name: 'pmgOhneToken', async: false })
|
||||
class PmgOhneTokenConstraint implements ValidatorConstraintInterface {
|
||||
validate(_value: unknown, args: ValidationArguments): boolean {
|
||||
const obj = args.object as { productType?: string; authMethod?: string };
|
||||
return !(obj.productType === 'pmg' && obj.authMethod === 'token');
|
||||
}
|
||||
|
||||
defaultMessage(): string {
|
||||
return 'PMG unterstuetzt keinen API-Token-Zugang. Bitte Benutzer und Passwort waehlen.';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DTO fuer das Anlegen eines Proxmox-Servers (Aufgabe 1). Pflichtfelder je
|
||||
* `authMethod` mit `@ValidateIf` (Aufgabe 2): ein Token-Zugang verlangt
|
||||
* `tokenId`/`tokenSecret`, ein Passwort-Zugang `username`/`password`.
|
||||
*/
|
||||
export class CreateProxmoxServerDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
name!: string;
|
||||
|
||||
@IsIn(['pve', 'pbs', 'pmg'])
|
||||
productType!: 'pve' | 'pbs' | 'pmg';
|
||||
|
||||
// require_tld: false — interne Namen wie "pve.intern" sind sonst abgelehnt.
|
||||
@IsUrl({ protocols: ['http', 'https'], require_tld: false })
|
||||
baseUrl!: string;
|
||||
|
||||
@IsIn(['token', 'password'])
|
||||
@Validate(PmgOhneTokenConstraint)
|
||||
authMethod!: 'token' | 'password';
|
||||
|
||||
@ValidateIf((o) => o.authMethod === 'token')
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
tokenId?: string;
|
||||
|
||||
@ValidateIf((o) => o.authMethod === 'token')
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
tokenSecret?: string;
|
||||
|
||||
@ValidateIf((o) => o.authMethod === 'password')
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
username?: string;
|
||||
|
||||
@ValidateIf((o) => o.authMethod === 'password')
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
password?: string;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
tlsRejectUnauthorized?: boolean;
|
||||
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(1440)
|
||||
@IsOptional()
|
||||
pollIntervalMin?: number;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* DTO fuer das Bearbeiten (Aufgabe 5). Alle Felder optional; ein NICHT
|
||||
* gesendetes Geheimnisfeld laesst den gespeicherten Wert unveraendert, eine
|
||||
* LEERE Zeichenkette bedeutet "loeschen" (Muster `LdapConfigService.updateConfig`)
|
||||
* — diese Unterscheidung lebt im Service, nicht im DTO, deshalb bleiben
|
||||
* `tokenSecret`/`password` hier einfache optionale Zeichenketten ohne
|
||||
* `IsNotEmpty`.
|
||||
*/
|
||||
export class UpdateProxmoxServerDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@IsOptional()
|
||||
name?: string;
|
||||
|
||||
@IsIn(['pve', 'pbs', 'pmg'])
|
||||
@IsOptional()
|
||||
productType?: 'pve' | 'pbs' | 'pmg';
|
||||
|
||||
@IsUrl({ protocols: ['http', 'https'], require_tld: false })
|
||||
@IsOptional()
|
||||
baseUrl?: string;
|
||||
|
||||
@IsIn(['token', 'password'])
|
||||
@Validate(PmgOhneTokenConstraint)
|
||||
@IsOptional()
|
||||
authMethod?: 'token' | 'password';
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
tokenId?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
tokenSecret?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
username?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
password?: string;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
tlsRejectUnauthorized?: boolean;
|
||||
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(1440)
|
||||
@IsOptional()
|
||||
pollIntervalMin?: number;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* DTO fuer den Verbindungstest (Nachbesserung Befund 1, Rundgang zu Aufgabe 4):
|
||||
* derselbe Feldsatz wie `UpdateProxmoxServerDto` — der Test soll auf JEDEM
|
||||
* dieser Felder den ungespeicherten Formularwert pruefen koennen, nicht den
|
||||
* gespeicherten Stand. Ein NICHT gesendetes oder leeres Geheimnisfeld heisst
|
||||
* "gespeicherten Wert weiterverwenden" (Merge-Logik in
|
||||
* `ProxmoxService.resolveEffectiveTestServer`), genau wie beim Bearbeiten.
|
||||
* Fuer die Neuanlage (noch kein gespeicherter Server) bleiben alle Felder
|
||||
* optional, weil es dort keinen gespeicherten Fallback gibt — ein fehlendes
|
||||
* Pflichtfeld fuehrt dort einfach zum selben Fehlerschluessel wie ein leer
|
||||
* gelassenes Feld beim Anlegen selbst (z. B. `zugang` ohne Geheimnis).
|
||||
*/
|
||||
export class TestProxmoxServerDto extends UpdateProxmoxServerDto {}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { Agent, fetch as undiciFetch } from 'undici';
|
||||
import { classifyFailure, parseJsonLenient } from './proxmox-client.service';
|
||||
import type { ProxmoxErrorKind, ProxmoxProductType } from './proxmox.types';
|
||||
|
||||
/**
|
||||
* Die EINZIGE Stelle im gesamten Modul, die Anmeldeinformationen in
|
||||
* Kopfzeilen (und ab Aufgabe 2 Cookies) uebersetzt (D-03, key_link):
|
||||
* Klient, Verbindungstest und Planer rufen ausschliesslich diese Funktionen
|
||||
* — keiner baut eine Kopfzeile nach. Jede Funktion nimmt Klartext entgegen
|
||||
* und gibt nur die Kopfzeile zurueck; keine protokolliert das Geheimnis,
|
||||
* keine wirft es in eine Fehlermeldung (T-DHH-01).
|
||||
*/
|
||||
|
||||
/**
|
||||
* API-Token-Kopfzeile. PVE und PBS teilen sich das Schema `<Produkt>APIToken`,
|
||||
* unterscheiden sich aber im Trennzeichen vor dem Geheimnis (Recherche,
|
||||
* Block 1): PVE nutzt ein Gleichheitszeichen, PBS einen Doppelpunkt. PMG
|
||||
* kennt laut Recherche (Annahme A1, Forenbeleg, kein Primaerbeleg) kein
|
||||
* Token-Schema — ein Aufruf mit `productType: 'pmg'` ist ein Programmierfehler
|
||||
* (das DTO lehnt einen PMG-Token-Zugang bereits beim Speichern ab, siehe
|
||||
* Aufgabe 2) und wirft deshalb statt still eine unbrauchbare Kopfzeile zu bauen.
|
||||
*/
|
||||
export function buildTokenAuthHeader(
|
||||
productType: ProxmoxProductType,
|
||||
tokenId: string,
|
||||
tokenSecret: string,
|
||||
): { Authorization: string } {
|
||||
if (productType === 'pve') {
|
||||
return { Authorization: `PVEAPIToken=${tokenId}=${tokenSecret}` };
|
||||
}
|
||||
if (productType === 'pbs') {
|
||||
return { Authorization: `PBSAPIToken=${tokenId}:${tokenSecret}` };
|
||||
}
|
||||
throw new Error(
|
||||
'PMG unterstuetzt keinen API-Token-Zugang (Annahme A1 der Recherche) — dieser Aufruf haette bereits beim Speichern des Servers abgelehnt werden muessen.',
|
||||
);
|
||||
}
|
||||
|
||||
/** 8 Sekunden — derselbe Wert wie `proxmox-client.service.ts` (Proxmox-Server stehen im lokalen Netz). */
|
||||
const TICKET_LOGIN_TIMEOUT_MS = 8000;
|
||||
|
||||
/**
|
||||
* Cookie-Name je Produkt, unter dem Folgeanfragen das Ticket mitfuehren.
|
||||
* PVE ist woertlich aus der offiziellen Wiki-Seite zitiert; PBS und PMG
|
||||
* sind aus dem Muster ABGELEITET, NICHT in der Doku bestaetigt (Recherche,
|
||||
* Annahme A2) — der Nutzer bestaetigt sie an seinen echten Servern. Steht
|
||||
* dort ein anderer Name, ist GENAU DIESE Konstante anzupassen, sonst nichts.
|
||||
*/
|
||||
const TICKET_COOKIE_NAME: Record<ProxmoxProductType, string> = {
|
||||
pve: 'PVEAuthCookie',
|
||||
pbs: 'PBSAuthCookie', // ANNAHME A2 — abgeleitet, nicht in pbs.proxmox.com/docs bestaetigt
|
||||
pmg: 'PMGAuthCookie', // ANNAHME A2 — abgeleitet, nicht im pmg-admin-guide bestaetigt
|
||||
};
|
||||
|
||||
/** Cookie-Kopfzeile fuer eine Ticket-Folgeanfrage. Kein `CSRFPreventionToken` — dieses Modul liest nur (D-01, Recherche Block 1). */
|
||||
export function buildTicketCookieHeader(
|
||||
productType: ProxmoxProductType,
|
||||
ticket: string,
|
||||
): { Cookie: string } {
|
||||
return { Cookie: `${TICKET_COOKIE_NAME[productType]}=${ticket}` };
|
||||
}
|
||||
|
||||
export type LoginTicketResult =
|
||||
| { ok: true; ticket: string }
|
||||
| { ok: false; errorKind: ProxmoxErrorKind; errorDetail: string };
|
||||
|
||||
/**
|
||||
* Ticket-Anmeldung — die EINZIGE Stelle im gesamten Modul, die eine
|
||||
* NICHT-lesende Anfrage an Proxmox schickt (D-01, `proxmox-nur-
|
||||
* lesen.spec.ts` zaehlt das maschinell nach). Sie aendert bei Proxmox
|
||||
* nichts — sie holt nur einen Nachweis (ein Ticket) ab, mit dem
|
||||
* Folgeanfragen sich als der eingetragene Benutzer ausweisen. Wie
|
||||
* `proxmoxGet` wirft sie nach aussen nichts: jeder Fehlerfall landet als
|
||||
* Ergebniswert.
|
||||
*/
|
||||
export async function loginTicket(
|
||||
target: { baseUrl: string; tlsRejectUnauthorized: boolean },
|
||||
productType: ProxmoxProductType,
|
||||
username: string,
|
||||
password: string,
|
||||
): Promise<LoginTicketResult> {
|
||||
const dispatcher = target.tlsRejectUnauthorized
|
||||
? undefined
|
||||
: new Agent({ connect: { rejectUnauthorized: false } });
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), TICKET_LOGIN_TIMEOUT_MS);
|
||||
const url = `${target.baseUrl.replace(/\/+$/, '')}/api2/json/access/ticket`;
|
||||
|
||||
try {
|
||||
const body = new URLSearchParams({ username, password });
|
||||
// GENAU HIER, und nirgendwo sonst im Modul, wird ein Anfrageverfahren
|
||||
// explizit an `undiciFetch` uebergeben (`method: 'POST'`) — der
|
||||
// maschinelle Riegel `proxmox-nur-lesen.spec.ts` erwartet diese Zahl
|
||||
// als exakt EINS.
|
||||
const response = await undiciFetch(url, {
|
||||
method: 'POST',
|
||||
dispatcher,
|
||||
signal: controller.signal,
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: body.toString(),
|
||||
});
|
||||
|
||||
const text = await response.text();
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
errorKind: classifyFailure(response.status, null),
|
||||
errorDetail: `Ticket-Anmeldung fehlgeschlagen (Status ${response.status})`,
|
||||
};
|
||||
}
|
||||
|
||||
const parsed = parseJsonLenient(text);
|
||||
if (!parsed.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
errorKind: 'antwortform',
|
||||
errorDetail: 'Die Antwort der Ticket-Anmeldung war kein JSON.',
|
||||
};
|
||||
}
|
||||
|
||||
const data = (parsed.data as { data?: { ticket?: unknown } } | null)?.data;
|
||||
const ticket = data && typeof data.ticket === 'string' ? data.ticket : null;
|
||||
if (!ticket) {
|
||||
return {
|
||||
ok: false,
|
||||
errorKind: 'antwortform',
|
||||
errorDetail: 'Die Antwort der Ticket-Anmeldung enthielt kein Ticket.',
|
||||
};
|
||||
}
|
||||
|
||||
return { ok: true, ticket };
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
errorKind: classifyFailure(null, err),
|
||||
errorDetail: 'Ticket-Anmeldung fehlgeschlagen: Verbindung nicht moeglich.',
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
/**
|
||||
* `undici` wird gemockt, damit KEIN Test tatsaechlich ins Netz geht (Vorbild
|
||||
* `icon-discovery.service.spec.ts`).
|
||||
*/
|
||||
vi.mock('undici', () => ({
|
||||
Agent: class Agent {
|
||||
constructor(public readonly options: unknown) {}
|
||||
},
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Test-Attrappe, Signatur folgt dem Original
|
||||
fetch: (...args: unknown[]) => (globalThis.fetch as any)(...args),
|
||||
}));
|
||||
|
||||
vi.mock('../prisma/prisma-tenant.extension', () => ({
|
||||
forTenant: vi.fn((p: unknown) => p),
|
||||
forSystem: vi.fn((p: unknown) => p),
|
||||
}));
|
||||
|
||||
import { validate } from 'class-validator';
|
||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||
import { CreateProxmoxServerDto } from './dto/proxmox-server.dto';
|
||||
import { buildTicketCookieHeader, loginTicket } from './proxmox-auth';
|
||||
import { classifyFailure, parseJsonLenient, proxmoxGet } from './proxmox-client.service';
|
||||
import { ProxmoxService } from './proxmox.service';
|
||||
|
||||
const crypto = {
|
||||
encrypt: vi.fn((plaintext: string) =>
|
||||
['aa11', 'bb22', Buffer.from(plaintext, 'utf8').toString('hex')].join(':'),
|
||||
),
|
||||
decrypt: vi.fn((stored: string) => {
|
||||
const [, , ciphertext] = stored.split(':');
|
||||
return Buffer.from(ciphertext, 'hex').toString('utf8');
|
||||
}),
|
||||
};
|
||||
|
||||
function makeFakePrisma() {
|
||||
const servers = new Map<string, any>();
|
||||
const statuses = new Map<string, any>();
|
||||
|
||||
function applySelect(row: any, select: Record<string, boolean> | undefined) {
|
||||
if (!select) return { ...row };
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const key of Object.keys(select)) {
|
||||
if (key === 'status') {
|
||||
out.status = statuses.get(row.id) ?? null;
|
||||
continue;
|
||||
}
|
||||
if (select[key]) out[key] = row[key];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const proxmoxServer = {
|
||||
create: vi.fn(async ({ data, select }: { data: any; select?: any }) => {
|
||||
const id = `srv-${servers.size + 1}`;
|
||||
const row = { id, createdAt: new Date(), updatedAt: new Date(), ...data };
|
||||
delete row.status;
|
||||
servers.set(id, row);
|
||||
if (data.status?.create) {
|
||||
statuses.set(id, { id: `status-${id}`, serverId: id, updatedAt: new Date(), ...data.status.create });
|
||||
}
|
||||
return applySelect(row, select);
|
||||
}),
|
||||
findMany: vi.fn(async ({ where, select }: { where?: any; select?: any } = {}) => {
|
||||
let rows = [...servers.values()];
|
||||
if (where?.tenantId) rows = rows.filter((r) => r.tenantId === where.tenantId);
|
||||
return rows.map((r) => applySelect(r, select));
|
||||
}),
|
||||
findUnique: vi.fn(async ({ where }: { where: { id: string } }) => {
|
||||
const row = servers.get(where.id);
|
||||
return row ? { ...row } : null;
|
||||
}),
|
||||
};
|
||||
|
||||
const proxmoxServerStatus = {
|
||||
upsert: vi.fn(
|
||||
async ({
|
||||
where,
|
||||
create,
|
||||
update,
|
||||
}: {
|
||||
where: { serverId: string };
|
||||
create: Record<string, unknown>;
|
||||
update: Record<string, unknown>;
|
||||
}) => {
|
||||
const existing = statuses.get(where.serverId);
|
||||
const record = existing
|
||||
? { ...existing, ...update }
|
||||
: { id: `status-${where.serverId}`, updatedAt: new Date(), ...create };
|
||||
statuses.set(where.serverId, record);
|
||||
return { ...record };
|
||||
},
|
||||
),
|
||||
};
|
||||
|
||||
return { proxmoxServer, proxmoxServerStatus, __servers: servers, __statuses: statuses };
|
||||
}
|
||||
|
||||
const PASSWORD_DTO = {
|
||||
name: 'pmg-1',
|
||||
productType: 'pmg' as const,
|
||||
baseUrl: 'https://pmg.intern:8006',
|
||||
authMethod: 'password' as const,
|
||||
username: 'admin@pmg',
|
||||
password: 'geheimes-passwort',
|
||||
};
|
||||
|
||||
function pveResourcesBody() {
|
||||
return { data: [{ type: 'node', node: 'pve1', cpu: 0.1, maxcpu: 4, mem: 1, maxmem: 2 }] };
|
||||
}
|
||||
|
||||
describe('classifyFailure (Aufgabe 2, <behavior>)', () => {
|
||||
it('401 -> zugang, 403 -> rechte, 404 -> antwortform, 5xx -> server', () => {
|
||||
expect(classifyFailure(401, null)).toBe('zugang');
|
||||
expect(classifyFailure(403, null)).toBe('rechte');
|
||||
expect(classifyFailure(404, null)).toBe('antwortform');
|
||||
expect(classifyFailure(500, null)).toBe('server');
|
||||
expect(classifyFailure(503, null)).toBe('server');
|
||||
});
|
||||
|
||||
it('ein geworfener Netzfehler ohne Antwort wird zu netz', () => {
|
||||
expect(classifyFailure(null, new Error('ECONNREFUSED'))).toBe('netz');
|
||||
expect(classifyFailure(null, new Error('timeout'))).toBe('netz');
|
||||
});
|
||||
|
||||
it('ein Zertifikatsfehler wird zu zertifikat, NICHT zu netz', () => {
|
||||
const err = new Error('self signed certificate') as Error & { code?: string };
|
||||
err.code = 'DEPTH_ZERO_SELF_SIGNED_CERT';
|
||||
expect(classifyFailure(null, err)).toBe('zertifikat');
|
||||
});
|
||||
|
||||
it('ein unbekannter Statuscode wird zu unbekannt', () => {
|
||||
expect(classifyFailure(418, null)).toBe('unbekannt');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseJsonLenient (Aufgabe 2, <behavior>)', () => {
|
||||
it('gueltiges JSON -> ok:true mit den Daten', () => {
|
||||
expect(parseJsonLenient('{"a":1}')).toEqual({ ok: true, data: { a: 1 } });
|
||||
});
|
||||
|
||||
it('kein JSON (HTML-Anmeldeseite) -> ok:false, kein Wurf', () => {
|
||||
expect(() => parseJsonLenient('<html>login</html>')).not.toThrow();
|
||||
expect(parseJsonLenient('<html>login</html>')).toEqual({ ok: false });
|
||||
});
|
||||
|
||||
it('leerer Rumpf -> ok:false', () => {
|
||||
expect(parseJsonLenient('')).toEqual({ ok: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('proxmoxGet — Integration gegen gemockten undici-Aufruf', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('401 wird zu errorKind zugang', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => new Response('Unauthorized', { status: 401 })));
|
||||
const result = await proxmoxGet(
|
||||
{ baseUrl: 'https://pve.intern', tlsRejectUnauthorized: true, headers: {} },
|
||||
'/api2/json/cluster/resources',
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.errorKind).toBe('zugang');
|
||||
});
|
||||
|
||||
it('404 wird zu errorKind antwortform', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => new Response('not found', { status: 404 })));
|
||||
const result = await proxmoxGet(
|
||||
{ baseUrl: 'https://pve.intern', tlsRejectUnauthorized: true, headers: {} },
|
||||
'/api2/json/cluster/resources',
|
||||
);
|
||||
expect(result.errorKind).toBe('antwortform');
|
||||
});
|
||||
|
||||
it('ein geworfener Netzfehler ohne Antwort wird zu errorKind netz', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => {
|
||||
throw new Error('ECONNREFUSED');
|
||||
}),
|
||||
);
|
||||
const result = await proxmoxGet(
|
||||
{ baseUrl: 'https://pve.intern', tlsRejectUnauthorized: true, headers: {} },
|
||||
'/api2/json/cluster/resources',
|
||||
);
|
||||
expect(result.errorKind).toBe('netz');
|
||||
});
|
||||
|
||||
it('eine Antwort, die kein JSON ist, fuehrt zu antwortform — kein Wurf', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => new Response('<html>Anmeldeseite</html>', { status: 200 })),
|
||||
);
|
||||
await expect(
|
||||
proxmoxGet(
|
||||
{ baseUrl: 'https://pve.intern', tlsRejectUnauthorized: true, headers: {} },
|
||||
'/api2/json/cluster/resources',
|
||||
),
|
||||
).resolves.toMatchObject({ ok: false, errorKind: 'antwortform' });
|
||||
});
|
||||
|
||||
it('errorDetail enthaelt niemals ein Geheimnis', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => new Response(JSON.stringify({ errors: { password: 'invalid' } }), { status: 401 })),
|
||||
);
|
||||
const result = await proxmoxGet(
|
||||
{
|
||||
baseUrl: 'https://pve.intern',
|
||||
tlsRejectUnauthorized: true,
|
||||
headers: { Authorization: 'PVEAPIToken=user@pam!tok=super-geheimes-secret-xyz' },
|
||||
},
|
||||
'/api2/json/cluster/resources',
|
||||
);
|
||||
expect(result.errorDetail).not.toContain('super-geheimes-secret-xyz');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Ticket-Anmeldung (loginTicket) und Cookie-Kopfzeile (Aufgabe 2, <behavior>)', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('POST /api2/json/access/ticket mit username/password liefert data.ticket', async () => {
|
||||
const fetchSpy = vi.fn(async (url: string, options: RequestInit) => {
|
||||
expect(url).toBe('https://pmg.intern:8006/api2/json/access/ticket');
|
||||
expect(options.method).toBe('POST');
|
||||
expect(options.body).toBe('username=admin%40pmg&password=geheimes-passwort');
|
||||
return new Response(JSON.stringify({ data: { ticket: 'PMG:admin@pmg:abc123' } }), { status: 200 });
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchSpy);
|
||||
|
||||
const result = await loginTicket(
|
||||
{ baseUrl: 'https://pmg.intern:8006', tlsRejectUnauthorized: true },
|
||||
'pmg',
|
||||
'admin@pmg',
|
||||
'geheimes-passwort',
|
||||
);
|
||||
|
||||
expect(result).toEqual({ ok: true, ticket: 'PMG:admin@pmg:abc123' });
|
||||
});
|
||||
|
||||
it('kein CSRFPreventionToken wird jemals mitgesendet', async () => {
|
||||
const fetchSpy = vi.fn(async (_url: string, options: RequestInit) => {
|
||||
const headerKeys = Object.keys((options.headers as Record<string, string>) ?? {});
|
||||
expect(headerKeys.some((k) => k.toLowerCase().includes('csrf'))).toBe(false);
|
||||
expect(String(options.body)).not.toContain('CSRF');
|
||||
return new Response(JSON.stringify({ data: { ticket: 't' } }), { status: 200 });
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchSpy);
|
||||
await loginTicket({ baseUrl: 'https://pve.intern', tlsRejectUnauthorized: true }, 'pve', 'u', 'p');
|
||||
});
|
||||
|
||||
it('Cookie-Kopfzeile traegt den produktabhaengigen Namen (PVE/PBS/PMG)', () => {
|
||||
expect(buildTicketCookieHeader('pve', 'T1')).toEqual({ Cookie: 'PVEAuthCookie=T1' });
|
||||
expect(buildTicketCookieHeader('pbs', 'T1')).toEqual({ Cookie: 'PBSAuthCookie=T1' });
|
||||
expect(buildTicketCookieHeader('pmg', 'T1')).toEqual({ Cookie: 'PMGAuthCookie=T1' });
|
||||
});
|
||||
|
||||
it('401 bei der Anmeldung selbst wird zu errorKind zugang', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => new Response('nope', { status: 401 })));
|
||||
const result = await loginTicket(
|
||||
{ baseUrl: 'https://pve.intern', tlsRejectUnauthorized: true },
|
||||
'pve',
|
||||
'u',
|
||||
'falsch',
|
||||
);
|
||||
expect(result).toMatchObject({ ok: false, errorKind: 'zugang' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('PMG + Token wird beim Speichern abgelehnt (Aufgabe 2, <behavior>)', () => {
|
||||
it('DTO-Validierung schlaegt fehl fuer productType pmg + authMethod token', async () => {
|
||||
const dto = new CreateProxmoxServerDto();
|
||||
Object.assign(dto, {
|
||||
name: 'pmg-token',
|
||||
productType: 'pmg',
|
||||
baseUrl: 'https://pmg.intern',
|
||||
authMethod: 'token',
|
||||
tokenId: 'root@pam!x',
|
||||
tokenSecret: 'geheim',
|
||||
});
|
||||
const errors = await validate(dto);
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('PMG + password bleibt gueltig', async () => {
|
||||
const dto = new CreateProxmoxServerDto();
|
||||
Object.assign(dto, PASSWORD_DTO);
|
||||
const errors = await validate(dto);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Ticket-Erneuerung bei password-Auth (Aufgabe 2, <behavior> — genau EIN zweiter Versuch)', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('erstes 401 loest genau eine erneute Anmeldung aus, danach gelingt die Abfrage', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new ProxmoxService(prisma as any, crypto as any);
|
||||
const created = await service.createServer('tenant-a', {
|
||||
...PASSWORD_DTO,
|
||||
productType: 'pve',
|
||||
baseUrl: 'https://pve.intern',
|
||||
});
|
||||
|
||||
let loginCalls = 0;
|
||||
let getCalls = 0;
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async (url: string) => {
|
||||
if (url.endsWith('/access/ticket')) {
|
||||
loginCalls++;
|
||||
return new Response(JSON.stringify({ data: { ticket: `T${loginCalls}` } }), { status: 200 });
|
||||
}
|
||||
getCalls++;
|
||||
if (getCalls === 1) return new Response('abgelaufen', { status: 401 });
|
||||
return new Response(JSON.stringify(pveResourcesBody()), { status: 200 });
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await service.pollServer('tenant-a', (created as any).id);
|
||||
|
||||
expect(loginCalls).toBe(2);
|
||||
expect(getCalls).toBe(2);
|
||||
expect(result?.reachable).toBe(true);
|
||||
});
|
||||
|
||||
it('ein zweites 401 bleibt errorKind zugang — kein dritter Versuch', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new ProxmoxService(prisma as any, crypto as any);
|
||||
const created = await service.createServer('tenant-a', {
|
||||
...PASSWORD_DTO,
|
||||
productType: 'pve',
|
||||
baseUrl: 'https://pve.intern',
|
||||
});
|
||||
|
||||
let loginCalls = 0;
|
||||
let getCalls = 0;
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async (url: string) => {
|
||||
if (url.endsWith('/access/ticket')) {
|
||||
loginCalls++;
|
||||
return new Response(JSON.stringify({ data: { ticket: `T${loginCalls}` } }), { status: 200 });
|
||||
}
|
||||
getCalls++;
|
||||
return new Response('abgelaufen', { status: 401 });
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await service.pollServer('tenant-a', (created as any).id);
|
||||
|
||||
expect(loginCalls).toBe(2);
|
||||
expect(getCalls).toBe(2);
|
||||
expect(result?.reachable).toBe(false);
|
||||
expect(result?.errorKind).toBe('zugang');
|
||||
});
|
||||
});
|
||||
|
||||
describe('forTenant bleibt Konvention auch mit Passwort-Zugang (D-08)', () => {
|
||||
it('nutzt forTenant beim Anlegen', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new ProxmoxService(prisma as any, crypto as any);
|
||||
await service.createServer('tenant-a', PASSWORD_DTO);
|
||||
expect(forTenant).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,230 @@
|
||||
import { Agent, fetch as undiciFetch } from 'undici';
|
||||
import type { ProxmoxErrorKind } from './proxmox.types';
|
||||
|
||||
/**
|
||||
* Der HTTP-Zugang dieses Moduls, und ausschliesslich lesend (D-01). Genau
|
||||
* EINE oeffentliche Datenabruf-Funktion `proxmoxGet` — das Anfrageverfahren
|
||||
* ist fest auf GET verdrahtet, es gibt dafuer keinen Parameter und kein
|
||||
* Durchreichen von aussen. `proxmox-nur-lesen.spec.ts` (Aufgabe 2) zaehlt
|
||||
* maschinell nach, dass dies im gesamten Modul die einzige Stelle ist, die
|
||||
* ein Anfrageverfahren an `undiciFetch` uebergibt.
|
||||
*
|
||||
* Zwingend `undiciFetch` aus dem `undici`-Paket, NICHT das globale `fetch`:
|
||||
* Nodes globales `fetch` ignoriert einen `Agent`-Dispatcher aus dem
|
||||
* npm-Paket (andere Klasse) — gemessen und dokumentiert in
|
||||
* `apps/api/src/favorites/icon-discovery.service.ts:33-40`. Wer hier aus
|
||||
* Gewohnheit zum globalen `fetch` wechselt, bekommt keinen Fehler beim
|
||||
* Kompilieren, sondern eine zur Laufzeit STILLSCHWEIGEND ignorierte Option
|
||||
* — ein selbstsigniertes Zertifikat wuerde trotz `tlsRejectUnauthorized:
|
||||
* false` weiter abgelehnt.
|
||||
*
|
||||
* Der Dispatcher wird JE AUFRUF aus dem `tlsRejectUnauthorized`-Feld GENAU
|
||||
* DIESER Serverzeile gebaut (D-04, T-DHH-03): ist es wahr (Vorgabe), wird
|
||||
* KEIN Dispatcher uebergeben — echte Zertifikatspruefung, der Normalweg.
|
||||
* Ist es falsch, ein FRISCHER `new Agent({ connect: { rejectUnauthorized:
|
||||
* false } } )` NUR fuer diesen einen Aufruf. Ausdruecklich KEINE
|
||||
* Modulkonstante wie `LENIENT_TLS_AGENT` in `icon-discovery.service.ts`
|
||||
* (die Ausnahme eines Servers darf nie auf einen zweiten wirken) und
|
||||
* ausdruecklich KEINE Node-Umgebungsvariable, die mit `NODE_TLS_` beginnt.
|
||||
*
|
||||
* Keine SSRF-Adresspruefung wie `isPublicHttpUrl`: Proxmox-Server stehen
|
||||
* per Definition im privaten Netz, eine solche Pruefung wuerde jede reale
|
||||
* Adresse blockieren (T-DHH-02). Die Absicherung ist stattdessen, dass nur
|
||||
* ein Administrator (`@Roles(ADMIN, SUPER_ADMIN)`) Adressen eintragen darf
|
||||
* — siehe Bedrohungsmodell T-DHH-02 im Plan.
|
||||
*/
|
||||
|
||||
/** 8 Sekunden — Proxmox-Server stehen im lokalen Netz, eine laengere Wartezeit deutet auf "nicht erreichbar". */
|
||||
const REQUEST_TIMEOUT_MS = 8000;
|
||||
|
||||
/** Deckel fuer `errorDetail` — niemals mehr als das, und nie ein Geheimnis (T-DHH-01). */
|
||||
const ERROR_DETAIL_MAX_CHARS = 500;
|
||||
|
||||
/**
|
||||
* Bekannte Zertifikatsfehlerkennungen von Node/undici. Ein Treffer wird zu
|
||||
* `errorKind: 'zertifikat'`; im Zweifel (keine dieser Kennungen erkannt)
|
||||
* bleibt es bei `'netz'` — eine Verwechslung in die falsche Richtung waere
|
||||
* hier schlimmer als ein zu vorsichtiges "nicht erreichbar" (Aufgabe 2 `<behavior>`).
|
||||
*/
|
||||
const CERTIFICATE_ERROR_CODES = new Set([
|
||||
'DEPTH_ZERO_SELF_SIGNED_CERT',
|
||||
'SELF_SIGNED_CERT_IN_CHAIN',
|
||||
'CERT_HAS_EXPIRED',
|
||||
'ERR_TLS_CERT_ALTNAME_INVALID',
|
||||
'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
|
||||
'UNABLE_TO_GET_ISSUER_CERT_LOCALLY',
|
||||
'CERT_UNTRUSTED',
|
||||
'ERR_TLS_CERT_ALTNAME_INVALID_ALTERNATE',
|
||||
'CERT_SIGNATURE_FAILURE',
|
||||
'CERT_NOT_YET_VALID',
|
||||
]);
|
||||
|
||||
export interface ProxmoxGetTarget {
|
||||
baseUrl: string;
|
||||
tlsRejectUnauthorized: boolean;
|
||||
/** Fertige Kopfzeilen — gebaut ausschliesslich von `proxmox-auth.ts` (D-03). */
|
||||
headers: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface ProxmoxGetResult {
|
||||
ok: boolean;
|
||||
status: number | null;
|
||||
body: unknown;
|
||||
errorKind: ProxmoxErrorKind | null;
|
||||
errorDetail: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Nachsichtiges JSON-Parsen: eine Antwort, die kein JSON ist (HTML-
|
||||
* Anmeldeseite, leerer Rumpf), fuehrt zu `{ ok: false }` — kein geworfener
|
||||
* Parserfehler, kein Absturz (Aufgabe 2 `<behavior>`).
|
||||
*/
|
||||
export function parseJsonLenient(text: string): { ok: true; data: unknown } | { ok: false } {
|
||||
if (!text || text.trim().length === 0) {
|
||||
return { ok: false };
|
||||
}
|
||||
try {
|
||||
return { ok: true, data: JSON.parse(text) };
|
||||
} catch {
|
||||
return { ok: false };
|
||||
}
|
||||
}
|
||||
|
||||
function isCertificateError(err: unknown): boolean {
|
||||
const code = (err as { code?: unknown; cause?: { code?: unknown } })?.code;
|
||||
const causeCode = (err as { cause?: { code?: unknown } })?.cause?.code;
|
||||
if (typeof code === 'string' && CERTIFICATE_ERROR_CODES.has(code)) return true;
|
||||
if (typeof causeCode === 'string' && CERTIFICATE_ERROR_CODES.has(causeCode)) return true;
|
||||
|
||||
const message = err instanceof Error ? err.message : String(err ?? '');
|
||||
for (const known of CERTIFICATE_ERROR_CODES) {
|
||||
if (message.includes(known)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reine Fehler-Uebersetzung: liefert genau eine der sieben Werte aus
|
||||
* `ProxmoxErrorKind`. `status` ist gesetzt, wenn Proxmox geantwortet hat;
|
||||
* `thrownError` ist gesetzt, wenn der Aufruf selbst fehlgeschlagen ist
|
||||
* (kein HTTP-Status, z. B. `ECONNREFUSED`/Timeout/DNS-Fehler).
|
||||
*
|
||||
* 401 -> 'zugang', 403 -> 'rechte', 404 -> 'antwortform' (falsche Adresse
|
||||
* vermutet), 5xx -> 'server'. Ein geworfener Fehler ohne Antwort ist
|
||||
* 'netz' — ausser die Fehlerkennung ist eindeutig eine Zertifikatskennung,
|
||||
* dann 'zertifikat' (Aufgabe 2 `<behavior>`).
|
||||
*/
|
||||
export function classifyFailure(
|
||||
status: number | null,
|
||||
thrownError: unknown,
|
||||
): ProxmoxErrorKind {
|
||||
if (status === null) {
|
||||
if (thrownError !== null && thrownError !== undefined && isCertificateError(thrownError)) {
|
||||
return 'zertifikat';
|
||||
}
|
||||
return 'netz';
|
||||
}
|
||||
if (status === 401) return 'zugang';
|
||||
if (status === 403) return 'rechte';
|
||||
if (status === 404) return 'antwortform';
|
||||
if (status >= 500 && status < 600) return 'server';
|
||||
return 'unbekannt';
|
||||
}
|
||||
|
||||
/**
|
||||
* Kurze, deutsche Ergaenzung aus Statuszahl und — falls vorhanden und JSON
|
||||
* — dem `errors`-Feld der Proxmox-Antwort. Auf `ERROR_DETAIL_MAX_CHARS`
|
||||
* gekuerzt; niemals die gesendete Kopfzeile, niemals ein Geheimnis
|
||||
* (T-DHH-01).
|
||||
*/
|
||||
function buildHttpErrorDetail(status: number, bodyText: string): string {
|
||||
let detail = `Proxmox antwortete mit Status ${status}`;
|
||||
const parsed = parseJsonLenient(bodyText);
|
||||
if (parsed.ok && parsed.data && typeof parsed.data === 'object' && 'errors' in parsed.data) {
|
||||
try {
|
||||
const errorsText = JSON.stringify((parsed.data as { errors: unknown }).errors);
|
||||
detail += `: ${errorsText}`;
|
||||
} catch {
|
||||
/* errors-Feld liess sich nicht serialisieren — Statuszahl allein reicht */
|
||||
}
|
||||
}
|
||||
return detail.slice(0, ERROR_DETAIL_MAX_CHARS);
|
||||
}
|
||||
|
||||
function buildThrownErrorDetail(err: unknown): string {
|
||||
const message = err instanceof Error ? err.message : String(err ?? 'unbekannter Fehler');
|
||||
return `Verbindung fehlgeschlagen: ${message}`.slice(0, ERROR_DETAIL_MAX_CHARS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Die einzige Datenabruf-Funktion dieses Moduls (D-01). Wirft nach aussen
|
||||
* NICHTS — jeder Fehlerfall (Netz, Zertifikat, HTTP-Status, kein JSON)
|
||||
* landet als Ergebniswert in `errorKind`/`errorDetail`, damit ein
|
||||
* Aufrufer nie mit einem unbehandelten Wurf abbricht.
|
||||
*/
|
||||
export async function proxmoxGet(
|
||||
target: ProxmoxGetTarget,
|
||||
path: string,
|
||||
): Promise<ProxmoxGetResult> {
|
||||
const dispatcher = target.tlsRejectUnauthorized
|
||||
? undefined // Normalweg: echte Zertifikatspruefung, kein Sonderfall
|
||||
: new Agent({ connect: { rejectUnauthorized: false } }); // NUR fuer diesen einen Aufruf (D-04)
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||||
const url = `${target.baseUrl.replace(/\/+$/, '')}${path}`;
|
||||
|
||||
try {
|
||||
// KEIN `method`-Feld — GET ist der Grundwert von `fetch`/`undiciFetch`
|
||||
// selbst, es gibt hierfuer keinen Parameter (D-01). `proxmox-nur-
|
||||
// lesen.spec.ts` zaehlt Stellen, die ein Anfrageverfahren EXPLIZIT an
|
||||
// `undiciFetch` uebergeben — die einzige solche Stelle im Modul ist
|
||||
// `loginTicket` in `proxmox-auth.ts` (POST, Ticket-Anmeldung, D-01).
|
||||
const response = await undiciFetch(url, {
|
||||
dispatcher,
|
||||
signal: controller.signal,
|
||||
headers: target.headers,
|
||||
});
|
||||
|
||||
const text = await response.text();
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
status: response.status,
|
||||
body: null,
|
||||
errorKind: classifyFailure(response.status, null),
|
||||
errorDetail: buildHttpErrorDetail(response.status, text),
|
||||
};
|
||||
}
|
||||
|
||||
const parsed = parseJsonLenient(text);
|
||||
if (!parsed.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
status: response.status,
|
||||
body: null,
|
||||
errorKind: 'antwortform',
|
||||
errorDetail: 'Die Antwort war kein JSON (z. B. eine Anmeldeseite oder ein leerer Rumpf).',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
status: response.status,
|
||||
body: parsed.data,
|
||||
errorKind: null,
|
||||
errorDetail: null,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
status: null,
|
||||
body: null,
|
||||
errorKind: classifyFailure(null, err),
|
||||
errorDetail: buildThrownErrorDetail(err),
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
listPbsDatastoreNames,
|
||||
normalizePbs,
|
||||
normalizePmg,
|
||||
normalizePve,
|
||||
readBool,
|
||||
readList,
|
||||
readNumber,
|
||||
readText,
|
||||
} from './proxmox-normalize';
|
||||
|
||||
describe('nachsichtige Leser (Aufgabe 3, <behavior>)', () => {
|
||||
it('readNumber: Zahl, umwandelbare Zeichenkette, sonst null', () => {
|
||||
expect(readNumber(42)).toBe(42);
|
||||
expect(readNumber('42')).toBe(42);
|
||||
expect(readNumber('0.37')).toBe(0.37);
|
||||
expect(readNumber('nicht-umwandelbar')).toBeNull();
|
||||
expect(readNumber(undefined)).toBeNull();
|
||||
expect(readNumber(null)).toBeNull();
|
||||
expect(readNumber(Number.NaN)).toBeNull();
|
||||
});
|
||||
|
||||
it('readText: nichtleere Zeichenkette oder Zahl, sonst null', () => {
|
||||
expect(readText('hallo')).toBe('hallo');
|
||||
expect(readText(42)).toBe('42');
|
||||
expect(readText('')).toBeNull();
|
||||
expect(readText(null)).toBeNull();
|
||||
expect(readText(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('readBool: boolesch oder gaengige Wahr/Falsch-Formen, sonst null', () => {
|
||||
expect(readBool(true)).toBe(true);
|
||||
expect(readBool('true')).toBe(true);
|
||||
expect(readBool(1)).toBe(true);
|
||||
expect(readBool(false)).toBe(false);
|
||||
expect(readBool('false')).toBe(false);
|
||||
expect(readBool('irgendwas')).toBeNull();
|
||||
});
|
||||
|
||||
it('readList: alles, was kein Array ist, wird eine leere Liste', () => {
|
||||
expect(readList([1, 2])).toEqual([1, 2]);
|
||||
expect(readList('kein-array')).toEqual([]);
|
||||
expect(readList(null)).toEqual([]);
|
||||
expect(readList(undefined)).toEqual([]);
|
||||
expect(readList({})).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizePve (Aufgabe 3, <behavior>)', () => {
|
||||
it('Knotenzahl, laufende/gestoppte Gaeste, je Knoten Prozessorlast/Speicher, je Speicherort Belegung', () => {
|
||||
const body = {
|
||||
data: [
|
||||
{ type: 'node', node: 'pve1', cpu: 0.25, maxcpu: 8, mem: 4_000_000_000, maxmem: 16_000_000_000 },
|
||||
{ type: 'node', node: 'pve2', cpu: 0.1, maxcpu: 4, mem: 1_000_000_000, maxmem: 8_000_000_000 },
|
||||
{ type: 'qemu', node: 'pve1', status: 'running' },
|
||||
{ type: 'qemu', node: 'pve1', status: 'stopped' },
|
||||
{ type: 'lxc', node: 'pve2', status: 'running' },
|
||||
{ type: 'storage', node: 'pve1', storage: 'local-lvm', disk: 100, maxdisk: 500 },
|
||||
],
|
||||
};
|
||||
|
||||
const { metrics, errorKind } = normalizePve(body);
|
||||
|
||||
expect(errorKind).toBeNull();
|
||||
expect(metrics.nodeCount).toBe(2);
|
||||
expect(metrics.guestsRunning).toBe(2);
|
||||
expect(metrics.guestsStopped).toBe(1);
|
||||
expect(metrics.nodes).toEqual([
|
||||
{ node: 'pve1', cpu: 0.25, maxcpu: 8, mem: 4_000_000_000, maxmem: 16_000_000_000 },
|
||||
{ node: 'pve2', cpu: 0.1, maxcpu: 4, mem: 1_000_000_000, maxmem: 8_000_000_000 },
|
||||
]);
|
||||
expect(metrics.storages).toEqual([
|
||||
{ storage: 'local-lvm', node: 'pve1', disk: 100, maxdisk: 500 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('Feld fehlt -> null, nie 0/Wurf', () => {
|
||||
const body = { data: [{ type: 'node', node: 'pve1' }] };
|
||||
expect(() => normalizePve(body)).not.toThrow();
|
||||
const { metrics } = normalizePve(body);
|
||||
expect(metrics.nodes[0]).toEqual({ node: 'pve1', cpu: null, maxcpu: null, mem: null, maxmem: null });
|
||||
});
|
||||
|
||||
it('Zahl kommt als Zeichenkette -> wird als Zahl gelesen', () => {
|
||||
const body = { data: [{ type: 'node', node: 'pve1', cpu: '0.5', maxcpu: '4', mem: '100', maxmem: '200' }] };
|
||||
const { metrics } = normalizePve(body);
|
||||
expect(metrics.nodes[0]).toEqual({ node: 'pve1', cpu: 0.5, maxcpu: 4, mem: 100, maxmem: 200 });
|
||||
});
|
||||
|
||||
it('Antwort ist HTML statt JSON-Objekt (hier: eine Zeichenkette) -> leeres Messwertobjekt, errorKind antwortform, kein Wurf', () => {
|
||||
expect(() => normalizePve('<html>Anmeldeseite</html>')).not.toThrow();
|
||||
const { metrics, errorKind } = normalizePve('<html>Anmeldeseite</html>');
|
||||
expect(errorKind).toBe('antwortform');
|
||||
expect(metrics).toEqual({
|
||||
productType: 'pve',
|
||||
nodeCount: 0,
|
||||
guestsRunning: 0,
|
||||
guestsStopped: 0,
|
||||
nodes: [],
|
||||
storages: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('Antwort ist ein Array statt eines Objekts -> antwortform, kein Wurf', () => {
|
||||
const { errorKind } = normalizePve([1, 2, 3]);
|
||||
expect(errorKind).toBe('antwortform');
|
||||
});
|
||||
|
||||
it('Antwort ist null -> antwortform, kein Wurf', () => {
|
||||
const { errorKind } = normalizePve(null);
|
||||
expect(errorKind).toBe('antwortform');
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizePbs (Aufgabe 3, <behavior>)', () => {
|
||||
it('je Datenspeicher Gesamt/Belegt/Frei, letzter Sicherungszeitpunkt und letztes Pruefergebnis', () => {
|
||||
const usage = {
|
||||
data: [{ store: 'backup-store', total: 1000, used: 400, avail: 600 }],
|
||||
};
|
||||
const snapshotsByStore = {
|
||||
'backup-store': {
|
||||
data: [
|
||||
{ 'backup-time': 1000, verification: { state: 'ok' } },
|
||||
{ 'backup-time': 2000, verification: { state: 'failed' } },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const { metrics, errorKind } = normalizePbs(usage, snapshotsByStore);
|
||||
|
||||
expect(errorKind).toBeNull();
|
||||
expect(metrics.datastores).toEqual([
|
||||
{
|
||||
name: 'backup-store',
|
||||
total: 1000,
|
||||
used: 400,
|
||||
free: 600,
|
||||
lastBackupAt: 2000,
|
||||
lastVerifyState: 'failed',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('ein Datenspeicher ohne Sicherungen ergibt null (Frontend zeigt "noch keine Sicherung") und keinen Fehler', () => {
|
||||
const usage = { data: [{ store: 'leer', total: 10, used: 0, avail: 10 }] };
|
||||
const { metrics, errorKind } = normalizePbs(usage, { leer: { data: [] } });
|
||||
expect(errorKind).toBeNull();
|
||||
expect(metrics.datastores[0]).toMatchObject({ lastBackupAt: null, lastVerifyState: null });
|
||||
});
|
||||
|
||||
it('Feld fehlt -> null, nie 0/Wurf', () => {
|
||||
const usage = { data: [{ store: 'x' }] };
|
||||
expect(() => normalizePbs(usage, {})).not.toThrow();
|
||||
const { metrics } = normalizePbs(usage, {});
|
||||
expect(metrics.datastores[0]).toEqual({
|
||||
name: 'x',
|
||||
total: null,
|
||||
used: null,
|
||||
free: null,
|
||||
lastBackupAt: null,
|
||||
lastVerifyState: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('Zahl kommt als Zeichenkette -> wird als Zahl gelesen', () => {
|
||||
const usage = { data: [{ store: 'x', total: '1000', used: '400', avail: '600' }] };
|
||||
const { metrics } = normalizePbs(usage, {});
|
||||
expect(metrics.datastores[0]).toMatchObject({ total: 1000, used: 400, free: 600 });
|
||||
});
|
||||
|
||||
it('Antwort ist HTML statt JSON -> leeres Messwertobjekt, errorKind antwortform, kein Wurf', () => {
|
||||
expect(() => normalizePbs('<html></html>', {})).not.toThrow();
|
||||
const { metrics, errorKind } = normalizePbs('<html></html>', {});
|
||||
expect(errorKind).toBe('antwortform');
|
||||
expect(metrics.datastores).toEqual([]);
|
||||
});
|
||||
|
||||
it('ein PBS-Server mit vielen Datenspeichern: listPbsDatastoreNames liefert alle Namen (Deckel lebt in proxmox.service.ts)', () => {
|
||||
const usage = { data: Array.from({ length: 15 }, (_, i) => ({ store: `store-${i}` })) };
|
||||
expect(listPbsDatastoreNames(usage)).toHaveLength(15);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizePmg (Aufgabe 3, <behavior>)', () => {
|
||||
it('Tageszahlen eingehend, ausgehend, Spam, Viren', () => {
|
||||
const body = {
|
||||
data: {
|
||||
count_in: 100,
|
||||
count_out: 50,
|
||||
spamcount_in: 10,
|
||||
spamcount_out: 2,
|
||||
viruscount_in: 1,
|
||||
viruscount_out: 0,
|
||||
},
|
||||
};
|
||||
const { metrics, errorKind } = normalizePmg(body);
|
||||
expect(errorKind).toBeNull();
|
||||
expect(metrics).toEqual({
|
||||
productType: 'pmg',
|
||||
countIn: 100,
|
||||
countOut: 50,
|
||||
spamCount: 12,
|
||||
virusCount: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('Feld fehlt -> null, nie 0/Wurf', () => {
|
||||
const body = { data: {} };
|
||||
expect(() => normalizePmg(body)).not.toThrow();
|
||||
const { metrics } = normalizePmg(body);
|
||||
expect(metrics).toEqual({
|
||||
productType: 'pmg',
|
||||
countIn: null,
|
||||
countOut: null,
|
||||
spamCount: null,
|
||||
virusCount: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('Zahl kommt als Zeichenkette -> wird als Zahl gelesen', () => {
|
||||
const body = { data: { count_in: '100', count_out: '50' } };
|
||||
const { metrics } = normalizePmg(body);
|
||||
expect(metrics.countIn).toBe(100);
|
||||
expect(metrics.countOut).toBe(50);
|
||||
});
|
||||
|
||||
it('Antwort ist HTML statt JSON -> leeres Messwertobjekt, errorKind antwortform, kein Wurf', () => {
|
||||
expect(() => normalizePmg('<html></html>')).not.toThrow();
|
||||
const { metrics, errorKind } = normalizePmg('<html></html>');
|
||||
expect(errorKind).toBe('antwortform');
|
||||
expect(metrics).toEqual({
|
||||
productType: 'pmg',
|
||||
countIn: null,
|
||||
countOut: null,
|
||||
spamCount: null,
|
||||
virusCount: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('nur eine Haelfte vorhanden -> null (Spam, nur spamcount_in)', () => {
|
||||
const body = { data: { spamcount_in: 10 } };
|
||||
const { metrics } = normalizePmg(body);
|
||||
expect(metrics.spamCount).toBeNull();
|
||||
});
|
||||
|
||||
it('nur eine Haelfte vorhanden -> null (Spam, nur spamcount_out)', () => {
|
||||
const body = { data: { spamcount_out: 2 } };
|
||||
const { metrics } = normalizePmg(body);
|
||||
expect(metrics.spamCount).toBeNull();
|
||||
});
|
||||
|
||||
it('nur eine Haelfte vorhanden -> null (Viren, nur viruscount_in)', () => {
|
||||
const body = { data: { viruscount_in: 1 } };
|
||||
const { metrics } = normalizePmg(body);
|
||||
expect(metrics.virusCount).toBeNull();
|
||||
});
|
||||
|
||||
it('nur eine Haelfte vorhanden -> null (Viren, nur viruscount_out)', () => {
|
||||
const body = { data: { viruscount_out: 3 } };
|
||||
const { metrics } = normalizePmg(body);
|
||||
expect(metrics.virusCount).toBeNull();
|
||||
});
|
||||
|
||||
it('eine Haelfte ist nicht lesbar -> null (Spam, spamcount_out ist Text)', () => {
|
||||
const body = { data: { spamcount_in: 10, spamcount_out: 'abc' } };
|
||||
const { metrics } = normalizePmg(body);
|
||||
expect(metrics.spamCount).toBeNull();
|
||||
});
|
||||
|
||||
it('Unabhaengigkeit der Paare: Spam unvollstaendig, Viren vollstaendig -> spamCount null, virusCount 1, countIn/countOut unberuehrt', () => {
|
||||
const body = {
|
||||
data: {
|
||||
count_in: 100,
|
||||
count_out: 50,
|
||||
spamcount_in: 10,
|
||||
viruscount_in: 1,
|
||||
viruscount_out: 0,
|
||||
},
|
||||
};
|
||||
const { metrics } = normalizePmg(body);
|
||||
expect(metrics.spamCount).toBeNull();
|
||||
expect(metrics.virusCount).toBe(1);
|
||||
expect(metrics.countIn).toBe(100);
|
||||
expect(metrics.countOut).toBe(50);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,273 @@
|
||||
import type {
|
||||
ProxmoxErrorKind,
|
||||
ProxmoxPbsDatastoreMetric,
|
||||
ProxmoxPbsMetrics,
|
||||
ProxmoxPmgMetrics,
|
||||
ProxmoxPveMetrics,
|
||||
ProxmoxPveNodeMetric,
|
||||
} from './proxmox.types';
|
||||
|
||||
/**
|
||||
* Nachsichtige Leser als reine Funktionen ohne Datenbankbezug — der
|
||||
* gesamte Umgang mit einer unerwarteten Form ist ein Rueckgabewert
|
||||
* (`null`/leere Liste), NIE eine Ausnahme. Der Nutzer prueft dieses Modul
|
||||
* ausschliesslich an seinen eigenen, echten Servern; ein Wurf wuerde ihm
|
||||
* eine leere Seite zeigen statt eines ehrlichen "unbekannt".
|
||||
*/
|
||||
|
||||
export function readNumber(value: unknown): number | null {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
if (typeof value === 'string' && value.trim() !== '') {
|
||||
const parsed = Number(value);
|
||||
if (Number.isFinite(parsed)) return parsed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function readText(value: unknown): string | null {
|
||||
if (typeof value === 'string' && value.trim() !== '') return value;
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return String(value);
|
||||
return null;
|
||||
}
|
||||
|
||||
export function readBool(value: unknown): boolean | null {
|
||||
if (typeof value === 'boolean') return value;
|
||||
if (value === 'true' || value === 1 || value === '1') return true;
|
||||
if (value === 'false' || value === 0 || value === '0') return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Liefert bei allem, was kein Array ist, eine LEERE Liste — nie einen Wurf. */
|
||||
export function readList(value: unknown): unknown[] {
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/** Nimmt den ersten VORHANDENEN Schluessel einer Namensliste (mehrere plausible Namen, in Reihenfolge). */
|
||||
function readFirstPresent(record: Record<string, unknown>, keys: readonly string[]): unknown {
|
||||
for (const key of keys) {
|
||||
if (key in record && record[key] !== undefined) return record[key];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export interface NormalizeResult<TMetrics> {
|
||||
metrics: TMetrics;
|
||||
errorKind: ProxmoxErrorKind | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PVE — /api2/json/cluster/resources
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function emptyPveMetrics(): ProxmoxPveMetrics {
|
||||
return {
|
||||
productType: 'pve',
|
||||
nodeCount: 0,
|
||||
guestsRunning: 0,
|
||||
guestsStopped: 0,
|
||||
nodes: [],
|
||||
storages: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizePve(body: unknown): NormalizeResult<ProxmoxPveMetrics> {
|
||||
if (!isRecord(body)) {
|
||||
// Ganze Antwort ist Zeichenkette/Array/null/leer — leeres Messwertobjekt, kein Wurf.
|
||||
return { metrics: emptyPveMetrics(), errorKind: 'antwortform' };
|
||||
}
|
||||
|
||||
const list = readList(body.data);
|
||||
const isEntry = (e: unknown): e is Record<string, unknown> => isRecord(e);
|
||||
|
||||
const nodeEntries = list.filter((e) => isEntry(e) && e.type === 'node') as Record<string, unknown>[];
|
||||
const guestEntries = list.filter(
|
||||
(e) => isEntry(e) && (e.type === 'qemu' || e.type === 'lxc'),
|
||||
) as Record<string, unknown>[];
|
||||
const storageEntries = list.filter((e) => isEntry(e) && e.type === 'storage') as Record<
|
||||
string,
|
||||
unknown
|
||||
>[];
|
||||
const running = guestEntries.filter((g) => g.status === 'running').length;
|
||||
|
||||
const nodes: ProxmoxPveNodeMetric[] = nodeEntries.map((n) => ({
|
||||
node: readText(n.node) ?? 'unbekannt',
|
||||
cpu: readNumber(n.cpu),
|
||||
maxcpu: readNumber(n.maxcpu),
|
||||
mem: readNumber(n.mem),
|
||||
maxmem: readNumber(n.maxmem),
|
||||
}));
|
||||
|
||||
const storages = storageEntries.map((s) => ({
|
||||
storage: readText(s.storage) ?? 'unbekannt',
|
||||
node: readText(s.node) ?? 'unbekannt',
|
||||
disk: readNumber(s.disk),
|
||||
maxdisk: readNumber(s.maxdisk),
|
||||
}));
|
||||
|
||||
return {
|
||||
metrics: {
|
||||
productType: 'pve',
|
||||
nodeCount: nodeEntries.length,
|
||||
guestsRunning: running,
|
||||
guestsStopped: guestEntries.length - running,
|
||||
nodes,
|
||||
storages,
|
||||
},
|
||||
errorKind: null,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PBS — /api2/json/status/datastore-usage + je Datenspeicher .../snapshots
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Feldnamen der PBS-Belegungsabfrage sind aus der Recherche nur ABGELEITET
|
||||
* (Annahme A3, Forenbeleg, kein Primaerbeleg) — GENAU DIESE Konstante ist
|
||||
* anzupassen, wenn ein echter PBS-Server andere Namen liefert.
|
||||
*/
|
||||
const PBS_USAGE_FIELDS = {
|
||||
store: ['store', 'name'],
|
||||
total: ['total'],
|
||||
used: ['used'],
|
||||
free: ['avail', 'free'],
|
||||
} as const;
|
||||
|
||||
/** Dieselbe Annahme A3 fuer die Sicherungsliste eines Datenspeichers. */
|
||||
const PBS_SNAPSHOT_FIELDS = {
|
||||
backupTime: ['backup-time', 'backupTime'],
|
||||
verifyState: ['verification', 'verify-state', 'verifyState'],
|
||||
} as const;
|
||||
|
||||
function emptyPbsMetrics(): ProxmoxPbsMetrics {
|
||||
return { productType: 'pbs', datastores: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Nur die Datenspeichernamen aus der Belegungsantwort — fuer den Deckel
|
||||
* der Folgeabfragen in `proxmox.service.ts` (Aufgabe 3, hoechstens 10 je
|
||||
* Durchlauf). Dieselbe Feldnamen-Konstante wie `normalizePbs`, damit es
|
||||
* EINE Stelle zum Nachziehen gibt, nicht zwei.
|
||||
*/
|
||||
export function listPbsDatastoreNames(usage: unknown): string[] {
|
||||
if (!isRecord(usage)) return [];
|
||||
return readList(usage.data)
|
||||
.filter(isRecord)
|
||||
.map((entry) => readText(readFirstPresent(entry, PBS_USAGE_FIELDS.store)))
|
||||
.filter((name): name is string => name !== null);
|
||||
}
|
||||
|
||||
function readVerifyState(value: unknown): string | null {
|
||||
// `verification` kann selbst ein Objekt sein ({ state: 'ok', ... }) oder
|
||||
// direkt eine Zeichenkette — beide Formen kommen in Forenbeispielen vor.
|
||||
if (isRecord(value)) {
|
||||
const state = readFirstPresent(value, ['state', 'result']);
|
||||
return readText(state);
|
||||
}
|
||||
return readText(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* `usage` ist die Antwort von `/status/datastore-usage`; `snapshotsByStore`
|
||||
* bildet je Datenspeichernamen die (bereits abgefragte) Rohantwort seiner
|
||||
* `/admin/datastore/{store}/snapshots`-Abfrage ab — `undefined`, wenn der
|
||||
* Deckel von hoechstens 10 Folgeabfragen je Durchlauf (`proxmox.service.ts`)
|
||||
* diesen Speicher nicht mehr erreicht hat.
|
||||
*/
|
||||
export function normalizePbs(
|
||||
usage: unknown,
|
||||
snapshotsByStore: Record<string, unknown>,
|
||||
): NormalizeResult<ProxmoxPbsMetrics> {
|
||||
if (!isRecord(usage)) {
|
||||
return { metrics: emptyPbsMetrics(), errorKind: 'antwortform' };
|
||||
}
|
||||
|
||||
const entries = readList(usage.data).filter(isRecord);
|
||||
|
||||
const datastores: ProxmoxPbsDatastoreMetric[] = entries.map((entry) => {
|
||||
const name = readText(readFirstPresent(entry, PBS_USAGE_FIELDS.store)) ?? 'unbekannt';
|
||||
const snapshotsBody = snapshotsByStore[name];
|
||||
const snapshotList = isRecord(snapshotsBody) ? readList(snapshotsBody.data).filter(isRecord) : [];
|
||||
|
||||
let lastBackupAt: number | null = null;
|
||||
let lastVerifyState: string | null = null;
|
||||
for (const snapshot of snapshotList) {
|
||||
const backupTime = readNumber(readFirstPresent(snapshot, PBS_SNAPSHOT_FIELDS.backupTime));
|
||||
if (backupTime !== null && (lastBackupAt === null || backupTime > lastBackupAt)) {
|
||||
lastBackupAt = backupTime;
|
||||
lastVerifyState = readVerifyState(readFirstPresent(snapshot, PBS_SNAPSHOT_FIELDS.verifyState));
|
||||
}
|
||||
}
|
||||
// Kein Eintrag in der Liste (leer, aber kein Fehler): "noch keine
|
||||
// Sicherung" — Frontend (Aufgabe 6) unterscheidet das ueber
|
||||
// `snapshotList.length === 0`, hier bleibt der Wert ehrlich `null`.
|
||||
|
||||
return {
|
||||
name,
|
||||
total: readNumber(readFirstPresent(entry, PBS_USAGE_FIELDS.total)),
|
||||
used: readNumber(readFirstPresent(entry, PBS_USAGE_FIELDS.used)),
|
||||
free: readNumber(readFirstPresent(entry, PBS_USAGE_FIELDS.free)),
|
||||
lastBackupAt,
|
||||
lastVerifyState,
|
||||
};
|
||||
});
|
||||
|
||||
return { metrics: { productType: 'pbs', datastores }, errorKind: null };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PMG — /api2/json/statistics/mail
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Annahme A5 der Recherche — abgeleitet aus `pmgsh`-Community-Belegen, nicht aus Primaerdoku. */
|
||||
const PMG_STATS_FIELDS = {
|
||||
countIn: ['count_in'],
|
||||
countOut: ['count_out'],
|
||||
spamIn: ['spamcount_in'],
|
||||
spamOut: ['spamcount_out'],
|
||||
virusIn: ['viruscount_in'],
|
||||
virusOut: ['viruscount_out'],
|
||||
} as const;
|
||||
|
||||
function emptyPmgMetrics(): ProxmoxPmgMetrics {
|
||||
return { productType: 'pmg', countIn: null, countOut: null, spamCount: null, virusCount: null };
|
||||
}
|
||||
|
||||
// Eine Tageszahl aus zwei Teilwerten ist nur dann bekannt, wenn beide
|
||||
// Teilwerte bekannt sind; eine Teilsumme saehe vollstaendig aus, waere
|
||||
// aber still falsch (Abnahmebefund 260923-dhh, Wahrheit 7; PMG-Feldnamen
|
||||
// sind nur Annahme A5).
|
||||
function sumOrNull(a: number | null, b: number | null): number | null {
|
||||
if (a === null || b === null) return null;
|
||||
return a + b;
|
||||
}
|
||||
|
||||
export function normalizePmg(body: unknown): NormalizeResult<ProxmoxPmgMetrics> {
|
||||
if (!isRecord(body)) {
|
||||
return { metrics: emptyPmgMetrics(), errorKind: 'antwortform' };
|
||||
}
|
||||
|
||||
const stats = isRecord(body.data) ? body.data : {};
|
||||
|
||||
const countIn = readNumber(readFirstPresent(stats, PMG_STATS_FIELDS.countIn));
|
||||
const countOut = readNumber(readFirstPresent(stats, PMG_STATS_FIELDS.countOut));
|
||||
const spamIn = readNumber(readFirstPresent(stats, PMG_STATS_FIELDS.spamIn));
|
||||
const spamOut = readNumber(readFirstPresent(stats, PMG_STATS_FIELDS.spamOut));
|
||||
const virusIn = readNumber(readFirstPresent(stats, PMG_STATS_FIELDS.virusIn));
|
||||
const virusOut = readNumber(readFirstPresent(stats, PMG_STATS_FIELDS.virusOut));
|
||||
|
||||
return {
|
||||
metrics: {
|
||||
productType: 'pmg',
|
||||
countIn,
|
||||
countOut,
|
||||
spamCount: sumOrNull(spamIn, spamOut),
|
||||
virusCount: sumOrNull(virusIn, virusOut),
|
||||
},
|
||||
errorKind: null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import { readFileSync, readdirSync } from 'node:fs';
|
||||
import { basename, join } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
/**
|
||||
* Der maschinelle Riegel zu D-01 ("nur beobachten") — gebaut nach dem
|
||||
* Vorbild von `apps/api/src/prisma/rls-access-inventory.spec.ts`: der Test
|
||||
* liest den Quelltext, nicht das Laufzeitverhalten. Zwei Aussagen:
|
||||
*
|
||||
* 1. Die Summe der Stellen, die ein Anfrageverfahren EXPLIZIT an
|
||||
* `undiciFetch` uebergeben (`method: '...'`), ist genau
|
||||
* `EXPECTED_METHOD_PASSING_CALLS` und liegt in `proxmox-auth.ts`
|
||||
* (die Ticket-Anmeldung, `loginTicket` — die einzige nicht-lesende
|
||||
* Anfrage im gesamten Modul, D-01). `proxmoxGet` in
|
||||
* `proxmox-client.service.ts` uebergibt bewusst KEIN `method`-Feld:
|
||||
* GET ist der Grundwert von `fetch` selbst.
|
||||
* 2. Jeder gegen einen Proxmox-API-Pfad (`/api2/json/...`) gebauter Aufruf
|
||||
* ausser der Ticket-Anmeldung laeuft ueber `proxmoxGet(...)`.
|
||||
*
|
||||
* Die erwartete Zahl steht als benannte Konstante mit ausgeschriebener
|
||||
* Begruendung — eine spaetere Erhoehung erzwingt eine bewusste
|
||||
* Entscheidung, statt unbemerkt durchzurutschen (T-DHH-07).
|
||||
*/
|
||||
|
||||
/**
|
||||
* GENAU EIN Aufruf darf im gesamten Modul ein Anfrageverfahren explizit an
|
||||
* `undiciFetch` uebergeben: `loginTicket()` in `proxmox-auth.ts`
|
||||
* (`method: 'POST'`, Ticket-Anmeldung). Jede weitere Stelle waere ein neuer,
|
||||
* bislang unbedachter veraendernder Weg gegen Proxmox — T-DHH-07.
|
||||
*/
|
||||
const EXPECTED_METHOD_PASSING_CALLS = 1;
|
||||
const EXPECTED_METHOD_PASSING_FILE = 'proxmox-auth.ts';
|
||||
|
||||
const PROXMOX_SRC_DIR = join(__dirname);
|
||||
|
||||
function listTsFiles(dir: string): string[] {
|
||||
const out: string[] = [];
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
out.push(...listTsFiles(full));
|
||||
} else if (entry.isFile() && entry.name.endsWith('.ts')) {
|
||||
out.push(full);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Entfernt Zeilen- und Blockkommentare — Vorbild `rls-access-inventory.spec.ts`. */
|
||||
function stripComments(source: string): string {
|
||||
return source
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
.split('\n')
|
||||
.filter((line) => !line.trim().startsWith('//'))
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Entfernt zusaetzlich Zeichenkettenliterale (nach dem Kommentar-Entfernen)
|
||||
* — fuer Testdateien, damit eine erfundene Testkonstante wie
|
||||
* `'https://x/api2/json/...'` in einer `expect(...)`-Zeile oder ein
|
||||
* mockierter Antwortkoerper nicht als Fundstelle zaehlt (Plan-Vorgabe:
|
||||
* "entfernt vor dem Zaehlen Kommentarzeilen und Zeichenkettenliterale aus
|
||||
* Testdateien").
|
||||
*/
|
||||
function stripStringLiterals(source: string): string {
|
||||
return source
|
||||
.replace(/`(?:[^`\\]|\\.)*`/g, '``')
|
||||
.replace(/"(?:[^"\\]|\\.)*"/g, '""')
|
||||
.replace(/'(?:[^'\\]|\\.)*'/g, "''");
|
||||
}
|
||||
|
||||
interface CallSpan {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
/** Sammelt Argumentbereiche aller Aufrufe `calleeName(...)` per Klammertiefe. */
|
||||
function collectCallArgSpans(text: string, calleeName: string): CallSpan[] {
|
||||
const spans: CallSpan[] = [];
|
||||
const re = new RegExp(`\\b${calleeName}\\(`, 'g');
|
||||
let m: RegExpExecArray | null;
|
||||
// biome-ignore lint/suspicious/noAssignInExpressions: Standard-Iterationsform der Nachbardatei rls-access-inventory.spec.ts
|
||||
while ((m = re.exec(text))) {
|
||||
const openIdx = re.lastIndex - 1;
|
||||
let depth = 0;
|
||||
let i = openIdx;
|
||||
for (; i < text.length; i++) {
|
||||
if (text[i] === '(') depth++;
|
||||
else if (text[i] === ')') {
|
||||
depth--;
|
||||
if (depth === 0) break;
|
||||
}
|
||||
}
|
||||
spans.push({ start: openIdx, end: i });
|
||||
}
|
||||
return spans;
|
||||
}
|
||||
|
||||
/** Zaehlt Stellen, die `method:` innerhalb eines `undiciFetch(...)`-Aufrufs uebergeben. */
|
||||
function countMethodPassingCalls(text: string): number {
|
||||
let count = 0;
|
||||
const re = /undiciFetch\(/g;
|
||||
let m: RegExpExecArray | null;
|
||||
// biome-ignore lint/suspicious/noAssignInExpressions: s.o.
|
||||
while ((m = re.exec(text))) {
|
||||
const openIdx = re.lastIndex - 1;
|
||||
let depth = 0;
|
||||
let i = openIdx;
|
||||
for (; i < text.length; i++) {
|
||||
if (text[i] === '(') depth++;
|
||||
else if (text[i] === ')') {
|
||||
depth--;
|
||||
if (depth === 0) break;
|
||||
}
|
||||
}
|
||||
const argsText = text.slice(openIdx, i + 1);
|
||||
if (/\bmethod\s*:/.test(argsText)) count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aufrufformen, deren Argumentbereich einen Proxmox-Pfad tragen darf:
|
||||
* `proxmoxGet` selbst, UND `getWithRetry` — der private Umschlag in
|
||||
* `proxmox.service.ts` (Aufgabe 2/3, Ticket-Erneuerung), der seinerseits
|
||||
* ausschliesslich `proxmoxGet` ruft (durch dieselbe erste Aussage dieses
|
||||
* Riegels abgesichert: keine zweite `undiciFetch`-Methodenstelle in dieser
|
||||
* Datei).
|
||||
*/
|
||||
const ALLOWED_PATH_CALLEES = ['proxmoxGet', 'getWithRetry'] as const;
|
||||
|
||||
/** Fundstellen eines Proxmox-API-Pfads ausserhalb einer erlaubten Aufrufform. */
|
||||
function findApiPathViolations(fileName: string, text: string): string[] {
|
||||
if (fileName === 'proxmox-auth.ts') {
|
||||
// Die Ticket-Anmeldung ist die eine dokumentierte Ausnahme (D-01).
|
||||
return [];
|
||||
}
|
||||
const allowedSpans = ALLOWED_PATH_CALLEES.flatMap((callee) => collectCallArgSpans(text, callee));
|
||||
const violations: string[] = [];
|
||||
const pathRe = /\/api2\/json\/[A-Za-z0-9/{}_.-]*/g;
|
||||
let m: RegExpExecArray | null;
|
||||
// biome-ignore lint/suspicious/noAssignInExpressions: s.o.
|
||||
while ((m = pathRe.exec(text))) {
|
||||
const idx = m.index;
|
||||
const insideAllowedCall = allowedSpans.some((s) => idx >= s.start && idx <= s.end);
|
||||
if (!insideAllowedCall) {
|
||||
violations.push(`${fileName}@${idx}: ${m[0]}`);
|
||||
}
|
||||
}
|
||||
return violations;
|
||||
}
|
||||
|
||||
describe('proxmox-nur-lesen (D-01, T-DHH-07) — der maschinelle Riegel', () => {
|
||||
const files = listTsFiles(PROXMOX_SRC_DIR);
|
||||
|
||||
it(`genau ${EXPECTED_METHOD_PASSING_CALLS} Stelle uebergibt ein Anfrageverfahren an undiciFetch, in ${EXPECTED_METHOD_PASSING_FILE}`, () => {
|
||||
const perFile = files.map((file) => {
|
||||
const raw = readFileSync(file, 'utf-8');
|
||||
const isTest = file.endsWith('.spec.ts');
|
||||
const cleaned = isTest ? stripStringLiterals(stripComments(raw)) : stripComments(raw);
|
||||
return { file: basename(file), count: countMethodPassingCalls(cleaned) };
|
||||
});
|
||||
|
||||
const total = perFile.reduce((sum, f) => sum + f.count, 0);
|
||||
const filesWithCalls = perFile.filter((f) => f.count > 0).map((f) => f.file);
|
||||
|
||||
expect(total, `Gefundene Stellen: ${JSON.stringify(perFile.filter((f) => f.count > 0))}`).toBe(
|
||||
EXPECTED_METHOD_PASSING_CALLS,
|
||||
);
|
||||
expect(filesWithCalls).toEqual([EXPECTED_METHOD_PASSING_FILE]);
|
||||
});
|
||||
|
||||
it('jeder gegen einen Proxmox-Pfad gebaute Aufruf ausser der Ticket-Anmeldung laeuft ueber proxmoxGet', () => {
|
||||
// Nur Produktionsdateien bauen tatsaechlich Aufrufe — Testdateien
|
||||
// enthalten denselben Pfadtext nur als erwarteten Wert in `expect(...)`,
|
||||
// das ist kein "gebauter Aufruf" im Sinn dieser Aussage.
|
||||
const productionFiles = files.filter((file) => !file.endsWith('.spec.ts'));
|
||||
const violations = productionFiles.flatMap((file) => {
|
||||
const raw = readFileSync(file, 'utf-8');
|
||||
const cleaned = stripComments(raw); // Pfad-Texte bleiben erhalten — nur Kommentare raus
|
||||
return findApiPathViolations(basename(file), cleaned);
|
||||
});
|
||||
|
||||
expect(violations).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,210 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ProxmoxSchedulerService } from './proxmox-scheduler.service';
|
||||
|
||||
/**
|
||||
* ProxmoxSchedulerService.spec (Aufgabe 4) — Vorbild
|
||||
* `dkv-scheduler.service.spec.ts`: echte Fake-Registry (Map-basiert,
|
||||
* `getCronJob` wirft bei Unbekannt wie `@nestjs/schedule`), ECHTES `cron`
|
||||
* (Peer von `@nestjs/schedule`) — `cronTime.source` und `fireOnTick()`
|
||||
* sind die beobachtbaren Eigenschaften eines Auftrags.
|
||||
*/
|
||||
|
||||
function makeFakeRegistry() {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Test-Attrappe
|
||||
const jobs = new Map<string, any>();
|
||||
return {
|
||||
__jobs: jobs,
|
||||
addCronJob: vi.fn((name: string, job: any) => {
|
||||
if (jobs.has(name)) throw new Error(`Cron Job with the given name (${name}) already exists.`);
|
||||
jobs.set(name, job);
|
||||
}),
|
||||
getCronJob: vi.fn((name: string) => {
|
||||
const job = jobs.get(name);
|
||||
if (!job) throw new Error(`No Cron Job was found with the given name (${name}).`);
|
||||
return job;
|
||||
}),
|
||||
deleteCronJob: vi.fn((name: string) => {
|
||||
const job = jobs.get(name);
|
||||
if (!job) throw new Error(`No Cron Job was found with the given name (${name}).`);
|
||||
jobs.delete(name);
|
||||
}),
|
||||
getCronJobs: vi.fn(() => jobs),
|
||||
};
|
||||
}
|
||||
|
||||
interface FakeServerRow {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
pollIntervalMin: number;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
function makeFakeProxmoxService(
|
||||
servers: FakeServerRow[] | Error,
|
||||
options: { pollShouldThrowFor?: string[] } = {},
|
||||
) {
|
||||
const polledServerIds: string[] = [];
|
||||
return {
|
||||
loadActiveServersForScheduler: vi.fn(async () => {
|
||||
if (servers instanceof Error) throw servers;
|
||||
return servers.filter((s) => s.isActive).map((s) => ({
|
||||
id: s.id,
|
||||
tenantId: s.tenantId,
|
||||
pollIntervalMin: s.pollIntervalMin,
|
||||
}));
|
||||
}),
|
||||
loadActiveServersForTenantScheduling: vi.fn(async (tenantId: string) => {
|
||||
if (servers instanceof Error) return [];
|
||||
return servers
|
||||
.filter((s) => s.isActive && s.tenantId === tenantId)
|
||||
.map((s) => ({ pollIntervalMin: s.pollIntervalMin }));
|
||||
}),
|
||||
listActiveServerIdsForTenant: vi.fn(async (tenantId: string) => {
|
||||
if (servers instanceof Error) return [];
|
||||
return servers.filter((s) => s.isActive && s.tenantId === tenantId).map((s) => s.id);
|
||||
}),
|
||||
pollServer: vi.fn(async (_tenantId: string, serverId: string) => {
|
||||
polledServerIds.push(serverId);
|
||||
if (options.pollShouldThrowFor?.includes(serverId)) {
|
||||
throw new Error(`poll boom for ${serverId}`);
|
||||
}
|
||||
return { reachable: true, errorKind: null, errorDetail: null, metrics: null, rawSample: null };
|
||||
}),
|
||||
__polledServerIds: polledServerIds,
|
||||
};
|
||||
}
|
||||
|
||||
function makeScheduler(
|
||||
servers: FakeServerRow[] | Error,
|
||||
options: { pollShouldThrowFor?: string[] } = {},
|
||||
) {
|
||||
const registry = makeFakeRegistry();
|
||||
const proxmoxService = makeFakeProxmoxService(servers, options);
|
||||
const scheduler = new ProxmoxSchedulerService(registry as any, proxmoxService as any);
|
||||
const logSpy = vi.spyOn((scheduler as any).logger, 'log').mockImplementation(() => undefined);
|
||||
const errorSpy = vi.spyOn((scheduler as any).logger, 'error').mockImplementation(() => undefined);
|
||||
return { registry, proxmoxService, scheduler, logSpy, errorSpy };
|
||||
}
|
||||
|
||||
describe('ProxmoxSchedulerService — ein Auftrag je Mandant (Aufgabe 4, <behavior>)', () => {
|
||||
const registries: ReturnType<typeof makeFakeRegistry>[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const registry of registries) {
|
||||
for (const job of registry.__jobs.values()) job.stop();
|
||||
registry.__jobs.clear();
|
||||
}
|
||||
registries.length = 0;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('Beim Start registriert der Planer je Mandant mit mindestens einem aktiven Server genau einen Auftrag unter proxmox-poll:<tenantId>', async () => {
|
||||
const { registry, scheduler } = makeScheduler([
|
||||
{ id: 's1', tenantId: 't1', pollIntervalMin: 15, isActive: true },
|
||||
]);
|
||||
registries.push(registry);
|
||||
|
||||
await scheduler.onApplicationBootstrap();
|
||||
|
||||
expect([...registry.__jobs.keys()]).toEqual(['proxmox-poll:t1']);
|
||||
expect(registry.__jobs.get('proxmox-poll:t1').cronTime.source).toBe('*/15 * * * *');
|
||||
});
|
||||
|
||||
it('Das Abfrageintervall eines Mandanten ist das KLEINSTE pollIntervalMin seiner aktiven Server', async () => {
|
||||
const { registry, scheduler } = makeScheduler([
|
||||
{ id: 's1', tenantId: 't1', pollIntervalMin: 30, isActive: true },
|
||||
{ id: 's2', tenantId: 't1', pollIntervalMin: 5, isActive: true },
|
||||
]);
|
||||
registries.push(registry);
|
||||
|
||||
await scheduler.onApplicationBootstrap();
|
||||
|
||||
expect(registry.__jobs.get('proxmox-poll:t1').cronTime.source).toBe('*/5 * * * *');
|
||||
});
|
||||
|
||||
it('Ein zweiter Mandant verdraengt den Auftrag des ersten nicht — beide Auftraege bestehen nebeneinander', async () => {
|
||||
const { registry, scheduler } = makeScheduler([
|
||||
{ id: 's1', tenantId: 't1', pollIntervalMin: 15, isActive: true },
|
||||
{ id: 's2', tenantId: 't2', pollIntervalMin: 10, isActive: true },
|
||||
]);
|
||||
registries.push(registry);
|
||||
|
||||
await scheduler.onApplicationBootstrap();
|
||||
|
||||
expect(new Set(registry.__jobs.keys())).toEqual(new Set(['proxmox-poll:t1', 'proxmox-poll:t2']));
|
||||
});
|
||||
|
||||
it('Keine aktiven Server bedeutet: kein Auftrag, ein Protokolleintrag, kein Fehler, nichts geloescht', async () => {
|
||||
const { registry, scheduler, logSpy, errorSpy } = makeScheduler([]);
|
||||
registries.push(registry);
|
||||
|
||||
await scheduler.onApplicationBootstrap();
|
||||
|
||||
expect(registry.__jobs.size).toBe(0);
|
||||
expect(logSpy).toHaveBeenCalled();
|
||||
expect(errorSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Ein Fehler beim Start wird gefangen und protokolliert, nie weitergeworfen', async () => {
|
||||
const { scheduler, errorSpy } = makeScheduler(new Error('DB weg'));
|
||||
|
||||
await expect(scheduler.onApplicationBootstrap()).resolves.toBeUndefined();
|
||||
expect(errorSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Der Planer haengt an onApplicationBootstrap, nicht an onModuleInit', () => {
|
||||
const registry = makeFakeRegistry();
|
||||
const scheduler = new ProxmoxSchedulerService(registry as any, {} as any);
|
||||
expect(typeof (scheduler as unknown as { onApplicationBootstrap?: unknown }).onApplicationBootstrap).toBe(
|
||||
'function',
|
||||
);
|
||||
expect((scheduler as unknown as { onModuleInit?: unknown }).onModuleInit).toBeUndefined();
|
||||
});
|
||||
|
||||
it('Der Tick eines Mandanten geht ueber dessen Server und fragt jeden einzeln ab; ein fehlgeschlagener Server bricht die Schleife nicht ab', async () => {
|
||||
const { registry, scheduler, proxmoxService, errorSpy } = makeScheduler(
|
||||
[
|
||||
{ id: 's1', tenantId: 't1', pollIntervalMin: 5, isActive: true },
|
||||
{ id: 's2', tenantId: 't1', pollIntervalMin: 5, isActive: true },
|
||||
],
|
||||
{ pollShouldThrowFor: ['s1'] },
|
||||
);
|
||||
registries.push(registry);
|
||||
await scheduler.onApplicationBootstrap();
|
||||
|
||||
registry.__jobs.get('proxmox-poll:t1').fireOnTick();
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
expect((proxmoxService as any).__polledServerIds).toEqual(['s1', 's2']);
|
||||
expect(errorSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refreshTenant zieht den Auftrag eines Mandanten sofort nach — ohne Neustart', async () => {
|
||||
const { registry, scheduler } = makeScheduler([]);
|
||||
registries.push(registry);
|
||||
await scheduler.onApplicationBootstrap();
|
||||
expect(registry.__jobs.size).toBe(0);
|
||||
|
||||
(scheduler as any).proxmoxService.loadActiveServersForTenantScheduling = vi.fn(async () => [
|
||||
{ pollIntervalMin: 20 },
|
||||
]);
|
||||
|
||||
await scheduler.refreshTenant('t1');
|
||||
|
||||
expect(registry.__jobs.get('proxmox-poll:t1').cronTime.source).toBe('*/20 * * * *');
|
||||
});
|
||||
|
||||
it('refreshTenant entfernt den Auftrag, wenn keine aktiven Server mehr uebrig sind', async () => {
|
||||
const { registry, scheduler } = makeScheduler([
|
||||
{ id: 's1', tenantId: 't1', pollIntervalMin: 15, isActive: true },
|
||||
]);
|
||||
registries.push(registry);
|
||||
await scheduler.onApplicationBootstrap();
|
||||
expect(registry.__jobs.has('proxmox-poll:t1')).toBe(true);
|
||||
|
||||
(scheduler as any).proxmoxService.loadActiveServersForTenantScheduling = vi.fn(async () => []);
|
||||
await scheduler.refreshTenant('t1');
|
||||
|
||||
expect(registry.__jobs.has('proxmox-poll:t1')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
|
||||
import { SchedulerRegistry } from '@nestjs/schedule';
|
||||
import { ProxmoxService } from './proxmox.service';
|
||||
|
||||
/**
|
||||
* CronJob constructor — resolved at runtime via require() because `cron` is
|
||||
* a transitive dependency of @nestjs/schedule (not a direct api dep under
|
||||
* pnpm strict isolation, so `import { CronJob } from 'cron'` fails
|
||||
* type-check). At runtime, cron IS on disk as @nestjs/schedule@6 declares
|
||||
* it as a peer dep. Reuses the exact DkvSchedulerService resolution
|
||||
* workaround verbatim.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const CronJobClass: new (cronTime: string, onTick: () => void) => { start(): void } =
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
require('cron').CronJob as new (cronTime: string, onTick: () => void) => { start(): void };
|
||||
|
||||
/**
|
||||
* ProxmoxSchedulerService — Hintergrundabfrage je Mandant (Aufgabe 4).
|
||||
* Kombiniert die zwei Bestandsmuster (Recherche, Block 3):
|
||||
*
|
||||
* - Das Mandanten-Auffaechern von `DkvSchedulerService`: EIN Cron-Auftrag
|
||||
* je aktivem Mandanten, Registry-Name `proxmox-poll:<tenantId>` — die
|
||||
* Vorgaengerform mit EINEM Auftragsfeld war genau der Fehler WINDOWS #21,
|
||||
* ERSATZLOS vermieden.
|
||||
* - Die Lebenszyklus-Stufe von `TenderSchedulerService`:
|
||||
* `implements OnApplicationBootstrap`, NICHT `OnModuleInit` — die
|
||||
* Reihenfolge der `onModuleInit`-Haken zwischen Modulen ist nicht
|
||||
* festgelegt, und die Erfahrung "frische Datenbank ingestiert nichts bis
|
||||
* zum zweiten Neustart" (Tender-Cron-Bootstrap) gilt hier genauso.
|
||||
*
|
||||
* Anders als bei DKV ist ein Mandant NICHT gleich ein Server: der Tick
|
||||
* eines Mandanten geht ueber dessen Serverzeilen. Das Abfrageintervall
|
||||
* eines Mandanten ist das KLEINSTE `pollIntervalMin` seiner aktiven Server.
|
||||
* Ein fehlgeschlagener Server schreibt seinen Fehler ins Zwischenlager
|
||||
* (das erledigt `ProxmoxService.pollServer` bereits selbst — ein
|
||||
* geworfener Fehler waere hier ein echter Bug, nicht ein "nicht
|
||||
* erreichbar") und die Schleife laeuft weiter.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ProxmoxSchedulerService implements OnApplicationBootstrap {
|
||||
private readonly logger = new Logger(ProxmoxSchedulerService.name);
|
||||
|
||||
/** Praefix der Registry-Namen; der volle Name ist `<Praefix>:<tenantId>`. */
|
||||
private readonly JOB_NAME_PREFIX = 'proxmox-poll';
|
||||
|
||||
constructor(
|
||||
private readonly schedulerRegistry: SchedulerRegistry,
|
||||
private readonly proxmoxService: ProxmoxService,
|
||||
) {}
|
||||
|
||||
private jobNameFor(tenantId: string): string {
|
||||
return `${this.JOB_NAME_PREFIX}:${tenantId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Beim Start: laedt ALLE aktiven `ProxmoxServer`-Zeilen (Systemkontext,
|
||||
* `ProxmoxService.loadActiveServersForScheduler`) und registriert je
|
||||
* aktivem Mandanten genau einen Cron-Auftrag. Eine LEERE Liste bedeutet
|
||||
* "nichts tun" — kein Auftrag, ein Protokolleintrag, kein Fehler, nichts
|
||||
* geloescht. Ein Fehler beim Start wird gefangen und protokolliert, nie
|
||||
* weitergeworfen — die Anwendung startet trotzdem.
|
||||
*/
|
||||
async onApplicationBootstrap(): Promise<void> {
|
||||
try {
|
||||
const servers = await this.proxmoxService.loadActiveServersForScheduler();
|
||||
if (!servers || servers.length === 0) {
|
||||
this.logger.log('Proxmox scheduler: no active server found — cron job not registered');
|
||||
return;
|
||||
}
|
||||
|
||||
const byTenant = new Map<string, number[]>();
|
||||
for (const server of servers) {
|
||||
const intervals = byTenant.get(server.tenantId) ?? [];
|
||||
intervals.push(server.pollIntervalMin);
|
||||
byTenant.set(server.tenantId, intervals);
|
||||
}
|
||||
|
||||
for (const [tenantId, intervals] of byTenant) {
|
||||
this.setInterval(Math.min(...intervals), tenantId);
|
||||
}
|
||||
|
||||
this.logger.log(`Proxmox scheduler initialized: ${byTenant.size} tenant(s)`);
|
||||
} catch (err) {
|
||||
this.logger.error(`Proxmox scheduler init failed: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Erzeugt (oder ersetzt) den Poll-Auftrag GENAU EINES Mandanten. Ersetzt
|
||||
* nur den Auftrag unter diesem Registry-Namen — ein zweiter Mandant
|
||||
* verdraengt den Auftrag des ersten nicht.
|
||||
*/
|
||||
setInterval(intervalMin: number, tenantId: string): void {
|
||||
const jobName = this.jobNameFor(tenantId);
|
||||
|
||||
try {
|
||||
this.schedulerRegistry.getCronJob(jobName).stop();
|
||||
this.schedulerRegistry.deleteCronJob(jobName);
|
||||
} catch {
|
||||
/* Auftrag noch nicht registriert — beim ersten Aufruf erwartet */
|
||||
}
|
||||
|
||||
let cronExpr: string;
|
||||
if (intervalMin < 60) {
|
||||
cronExpr = `*/${intervalMin} * * * *`;
|
||||
} else {
|
||||
const hours = Math.floor(intervalMin / 60);
|
||||
cronExpr = `0 */${hours} * * *`;
|
||||
}
|
||||
|
||||
const job = new CronJobClass(cronExpr, () => {
|
||||
this.tick(tenantId).catch((err) =>
|
||||
this.logger.error(
|
||||
`Proxmox poll tick failed for tenant ${tenantId}: ${(err as Error).message}`,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
// Cast noetig — dasselbe Muster wie DkvSchedulerService/TenderSchedulerService.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
this.schedulerRegistry.addCronJob(jobName, job as any);
|
||||
job.start();
|
||||
|
||||
this.logger.log(
|
||||
`Proxmox cron job registered: every ${intervalMin} minutes for tenant ${tenantId}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Entfernt NUR den Poll-Auftrag dieses Mandanten. */
|
||||
stopJob(tenantId: string): void {
|
||||
const jobName = this.jobNameFor(tenantId);
|
||||
try {
|
||||
this.schedulerRegistry.getCronJob(jobName).stop();
|
||||
this.schedulerRegistry.deleteCronJob(jobName);
|
||||
this.logger.log(`Proxmox cron job stopped and removed for tenant ${tenantId}`);
|
||||
} catch {
|
||||
/* Nicht registriert — kein Vorgang */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vom Controller nach jedem Anlegen/Speichern eines Servers gerufen, damit
|
||||
* der Planer ohne Neustart nachzieht (Vorbild `DkvController`). Ohne
|
||||
* aktive Server dieses Mandanten wird der Auftrag entfernt.
|
||||
*/
|
||||
async refreshTenant(tenantId: string): Promise<void> {
|
||||
const servers = await this.proxmoxService.loadActiveServersForTenantScheduling(tenantId);
|
||||
if (!servers || servers.length === 0) {
|
||||
this.stopJob(tenantId);
|
||||
return;
|
||||
}
|
||||
this.setInterval(Math.min(...servers.map((s) => s.pollIntervalMin)), tenantId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Tick EINES Mandanten: geht ueber dessen aktive Server und fragt
|
||||
* jeden einzeln ab. `pollServer` faengt jeden Proxmox-seitigen Fehler
|
||||
* bereits selbst ab (Ergebnis statt Wurf) — dieses try/catch schuetzt
|
||||
* zusaetzlich vor einem echten Programmfehler (z. B. einem
|
||||
* Datenbankfehler beim Schreiben), damit ein einzelner defekter Server
|
||||
* die Abfrage der uebrigen Server desselben Mandanten nicht verhindert.
|
||||
*/
|
||||
private async tick(tenantId: string): Promise<void> {
|
||||
const serverIds = await this.proxmoxService.listActiveServerIdsForTenant(tenantId);
|
||||
for (const serverId of serverIds) {
|
||||
try {
|
||||
await this.proxmoxService.pollServer(tenantId, serverId);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Proxmox poll failed for server ${serverId} (tenant ${tenantId}): ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Alle Mandanten, fuer die derzeit ein Auftrag registriert ist — aus der
|
||||
* Registry abgeleitet, fuer Tests und Diagnose.
|
||||
*/
|
||||
registeredTenantIds(): string[] {
|
||||
const prefix = `${this.JOB_NAME_PREFIX}:`;
|
||||
const names = [...this.schedulerRegistry.getCronJobs().keys()] as string[];
|
||||
return names.filter((n) => n.startsWith(prefix)).map((n) => n.slice(prefix.length));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
ForbiddenException,
|
||||
Get,
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
Req,
|
||||
} from '@nestjs/common';
|
||||
import { Role } from '@prisma/client';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import type { AuthenticatedRequest } from '../auth/types/auth-user';
|
||||
import { UseModule } from '../module-registry/module.guard';
|
||||
import {
|
||||
CreateProxmoxServerDto,
|
||||
TestProxmoxServerDto,
|
||||
UpdateProxmoxServerDto,
|
||||
} from './dto/proxmox-server.dto';
|
||||
import { ProxmoxSchedulerService } from './proxmox-scheduler.service';
|
||||
import { ProxmoxService } from './proxmox.service';
|
||||
|
||||
/**
|
||||
* `@UseModule('proxmox')` auf Klassenebene (D-09, Vorbild
|
||||
* `domaincheck.controller.ts`) — Aktivierung UND Freigabe. `tenantId` kommt
|
||||
* ausschliesslich aus `req.tenantId` (gesetzt vom `TenantGuard`), nie aus
|
||||
* Body oder Query. Lesen (`GET servers`) steht jedem Benutzer mit
|
||||
* Modulzugriff offen; Schreiben (`POST servers`, `POST servers/test`,
|
||||
* `POST servers/:id/poll`, `POST servers/:id/test`) zusaetzlich
|
||||
* `@Roles(ADMIN, SUPER_ADMIN)` (T-DHH-05). `servers/test` (statisch, zwei
|
||||
* Segmente) und `servers/:id/test` (drei Segmente) ueberschneiden sich
|
||||
* nicht — beide POST, aber unterschiedliche Segmentzahl, deshalb keine
|
||||
* Reihenfolge-Abhaengigkeit (anders als `GET :id` vs. statische Routen).
|
||||
*/
|
||||
@Controller('modules/proxmox')
|
||||
@UseModule('proxmox')
|
||||
export class ProxmoxController {
|
||||
constructor(
|
||||
private readonly proxmoxService: ProxmoxService,
|
||||
private readonly scheduler: ProxmoxSchedulerService,
|
||||
) {}
|
||||
|
||||
private requireTenantId(req: AuthenticatedRequest): string {
|
||||
const tenantId = req.tenantId;
|
||||
if (!tenantId) {
|
||||
throw new ForbiddenException('Kein Mandantenkontext');
|
||||
}
|
||||
return tenantId;
|
||||
}
|
||||
|
||||
@Get('servers')
|
||||
async list(@Req() req: AuthenticatedRequest) {
|
||||
return this.proxmoxService.listWithStatus(this.requireTenantId(req));
|
||||
}
|
||||
|
||||
@Post('servers')
|
||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||
async create(@Req() req: AuthenticatedRequest, @Body() dto: CreateProxmoxServerDto) {
|
||||
const tenantId = this.requireTenantId(req);
|
||||
const created = await this.proxmoxService.createServer(tenantId, dto);
|
||||
// Planer sofort nachziehen — ohne Neustart (Aufgabe 4, Vorbild DkvController).
|
||||
await this.scheduler.refreshTenant(tenantId);
|
||||
return created;
|
||||
}
|
||||
|
||||
@Put('servers/:id')
|
||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||
async update(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateProxmoxServerDto,
|
||||
) {
|
||||
const tenantId = this.requireTenantId(req);
|
||||
const updated = await this.proxmoxService.updateServer(tenantId, id, dto);
|
||||
await this.scheduler.refreshTenant(tenantId);
|
||||
return updated;
|
||||
}
|
||||
|
||||
@Delete('servers/:id')
|
||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||
async remove(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
|
||||
const tenantId = this.requireTenantId(req);
|
||||
const deleted = await this.proxmoxService.deleteServer(tenantId, id);
|
||||
await this.scheduler.refreshTenant(tenantId);
|
||||
return { deleted };
|
||||
}
|
||||
|
||||
@Post('servers/:id/poll')
|
||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||
async poll(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
|
||||
return this.proxmoxService.pollServer(this.requireTenantId(req), id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verbindungstest fuer einen gespeicherten Server (Aufgabe 4, `<behavior>`;
|
||||
* Nachbesserung Befund 1: `dto` traegt den aktuellen Formularstand,
|
||||
* `ProxmoxService.testConnection` prueft diesen statt blind des
|
||||
* gespeicherten Stands). Liefert bei Erfolg eine Erfolgsmeldung und bei
|
||||
* Misserfolg einen der sieben Fehlerschluessel samt kurzer Ergaenzung,
|
||||
* OHNE den Zwischenlagerstand zu ueberschreiben.
|
||||
*/
|
||||
@Post('servers/:id/test')
|
||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||
async test(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: TestProxmoxServerDto,
|
||||
) {
|
||||
return this.proxmoxService.testConnection(this.requireTenantId(req), id, dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verbindungstest waehrend der Neuanlage (Nachbesserung Befund 1): es gibt
|
||||
* noch keinen gespeicherten Server, `dto` ist deshalb die einzige Quelle.
|
||||
*/
|
||||
@Post('servers/test')
|
||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||
async testDraft(@Req() req: AuthenticatedRequest, @Body() dto: TestProxmoxServerDto) {
|
||||
this.requireTenantId(req);
|
||||
return this.proxmoxService.testDraftConnection(dto);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Logger, Module, OnModuleInit } from '@nestjs/common';
|
||||
import { ModuleRegistryModule } from '../module-registry/module-registry.module';
|
||||
import { ModuleRegistryService } from '../module-registry/module-registry.service';
|
||||
import { ProxmoxController } from './proxmox.controller';
|
||||
import { ProxmoxSchedulerService } from './proxmox-scheduler.service';
|
||||
import { seedProxmoxModule } from './proxmox.seed';
|
||||
import { ProxmoxService } from './proxmox.service';
|
||||
|
||||
/**
|
||||
* NestJS module for the Proxmox feature (260923-dhh). Vorbild
|
||||
* `DomaincheckModule`: seeds itself into the module registry on startup.
|
||||
* `ScheduleModule` ist bereits global in `app.module.ts` registriert — der
|
||||
* Planer (Aufgabe 4) braucht hier nichts zusaetzlich, nur die Aufnahme in
|
||||
* `providers`.
|
||||
*/
|
||||
@Module({
|
||||
imports: [ModuleRegistryModule],
|
||||
controllers: [ProxmoxController],
|
||||
providers: [ProxmoxService, ProxmoxSchedulerService],
|
||||
})
|
||||
export class ProxmoxModule implements OnModuleInit {
|
||||
private readonly logger = new Logger(ProxmoxModule.name);
|
||||
|
||||
constructor(private readonly moduleRegistryService: ModuleRegistryService) {}
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
try {
|
||||
await seedProxmoxModule(this.moduleRegistryService);
|
||||
this.logger.log('Proxmox module seeded in registry');
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to seed proxmox module', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { ModuleRegistryService } from '../module-registry/module-registry.service';
|
||||
|
||||
/**
|
||||
* Seeds the proxmox module into the module registry (D-09).
|
||||
* Vorbild `domaincheck.seed.ts`. Kategorie `infrastructure` — die erste
|
||||
* Kachel/Modul in dieser Kategorie.
|
||||
*/
|
||||
export async function seedProxmoxModule(
|
||||
moduleRegistryService: ModuleRegistryService,
|
||||
): Promise<void> {
|
||||
await moduleRegistryService.seedModule({
|
||||
slug: 'proxmox',
|
||||
name: 'Proxmox',
|
||||
version: '1.0.0',
|
||||
category: 'infrastructure',
|
||||
description: {
|
||||
de: 'Proxmox VE/PBS/PMG beobachten — nur lesend',
|
||||
en: 'Observe Proxmox VE/PBS/PMG — read-only',
|
||||
},
|
||||
isSystem: true,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,635 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
/**
|
||||
* `undici` wird gemockt, damit KEIN Test tatsaechlich ins Netz geht (Vorbild
|
||||
* `icon-discovery.service.spec.ts`) — die Mock-Klasse zeichnet nur die
|
||||
* uebergebenen `options` auf, `fetch` delegiert zur Laufzeit an
|
||||
* `globalThis.fetch`, damit `vi.stubGlobal('fetch', …)` je Test greift.
|
||||
*/
|
||||
vi.mock('undici', () => ({
|
||||
Agent: class Agent {
|
||||
constructor(public readonly options: unknown) {}
|
||||
},
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Test-Attrappe, Signatur folgt dem Original
|
||||
fetch: (...args: unknown[]) => (globalThis.fetch as any)(...args),
|
||||
}));
|
||||
|
||||
// `forTenant` gibt in diesem Test denselben Client zurueck — Mandantenbindung
|
||||
// selbst ist nicht Gegenstand dieser Datei (siehe rls-access-inventory.spec.ts).
|
||||
vi.mock('../prisma/prisma-tenant.extension', () => ({
|
||||
forTenant: vi.fn((p: unknown) => p),
|
||||
forSystem: vi.fn((p: unknown) => p),
|
||||
}));
|
||||
|
||||
import { Agent } from 'undici';
|
||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||
import { ProxmoxService } from './proxmox.service';
|
||||
import type { CreateProxmoxServerDto, UpdateProxmoxServerDto } from './dto/proxmox-server.dto';
|
||||
|
||||
/** Durchschaubarer Ersatz fuer AES-256-GCM — Zusammenspiel unter Test, nicht die Bibliothek. */
|
||||
const crypto = {
|
||||
encrypt: vi.fn((plaintext: string) =>
|
||||
['aa11', 'bb22', Buffer.from(plaintext, 'utf8').toString('hex')].join(':'),
|
||||
),
|
||||
decrypt: vi.fn((stored: string) => {
|
||||
const [, , ciphertext] = stored.split(':');
|
||||
return Buffer.from(ciphertext, 'hex').toString('utf8');
|
||||
}),
|
||||
};
|
||||
|
||||
function makeFakePrisma() {
|
||||
const servers = new Map<string, any>();
|
||||
const statuses = new Map<string, any>(); // key: serverId
|
||||
|
||||
function applySelect(row: any, select: Record<string, boolean> | undefined) {
|
||||
if (!select) return { ...row };
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const key of Object.keys(select)) {
|
||||
if (key === 'status') {
|
||||
out.status = statuses.get(row.id) ?? null;
|
||||
continue;
|
||||
}
|
||||
if (select[key]) out[key] = row[key];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const proxmoxServer = {
|
||||
create: vi.fn(async ({ data, select }: { data: any; select?: any }) => {
|
||||
const id = `srv-${servers.size + 1}`;
|
||||
const row = { id, createdAt: new Date(), updatedAt: new Date(), ...data };
|
||||
delete row.status; // nested create handled below
|
||||
servers.set(id, row);
|
||||
if (data.status?.create) {
|
||||
statuses.set(id, { id: `status-${id}`, serverId: id, updatedAt: new Date(), ...data.status.create });
|
||||
}
|
||||
return applySelect(row, select);
|
||||
}),
|
||||
findMany: vi.fn(async ({ where, select }: { where?: any; select?: any } = {}) => {
|
||||
let rows = [...servers.values()];
|
||||
if (where?.tenantId) rows = rows.filter((r) => r.tenantId === where.tenantId);
|
||||
if (where?.isActive !== undefined) rows = rows.filter((r) => r.isActive === where.isActive);
|
||||
return rows.map((r) => applySelect(r, select));
|
||||
}),
|
||||
findUnique: vi.fn(
|
||||
async ({ where, include }: { where: { id: string }; include?: { status?: boolean } }) => {
|
||||
const row = servers.get(where.id);
|
||||
if (!row) return null;
|
||||
if (include?.status) {
|
||||
return { ...row, status: statuses.get(row.id) ?? null };
|
||||
}
|
||||
return { ...row };
|
||||
},
|
||||
),
|
||||
update: vi.fn(
|
||||
async ({
|
||||
where,
|
||||
data,
|
||||
select,
|
||||
}: {
|
||||
where: { id: string };
|
||||
data: Record<string, unknown>;
|
||||
select?: any;
|
||||
}) => {
|
||||
const existing = servers.get(where.id);
|
||||
const updated = { ...existing, ...data, updatedAt: new Date() };
|
||||
servers.set(where.id, updated);
|
||||
return applySelect(updated, select);
|
||||
},
|
||||
),
|
||||
delete: vi.fn(async ({ where }: { where: { id: string } }) => {
|
||||
const row = servers.get(where.id);
|
||||
servers.delete(where.id);
|
||||
statuses.delete(where.id); // Fremdschluessel mit Loeschweitergabe (onDelete: Cascade)
|
||||
return row ? { ...row } : null;
|
||||
}),
|
||||
};
|
||||
|
||||
const proxmoxServerStatus = {
|
||||
upsert: vi.fn(
|
||||
async ({
|
||||
where,
|
||||
create,
|
||||
update,
|
||||
}: {
|
||||
where: { serverId: string };
|
||||
create: Record<string, unknown>;
|
||||
update: Record<string, unknown>;
|
||||
}) => {
|
||||
const existing = statuses.get(where.serverId);
|
||||
const record = existing
|
||||
? { ...existing, ...update }
|
||||
: { id: `status-${where.serverId}`, updatedAt: new Date(), ...create };
|
||||
statuses.set(where.serverId, record);
|
||||
return { ...record };
|
||||
},
|
||||
),
|
||||
};
|
||||
|
||||
return { proxmoxServer, proxmoxServerStatus, __servers: servers, __statuses: statuses };
|
||||
}
|
||||
|
||||
const TOKEN_DTO: CreateProxmoxServerDto = {
|
||||
name: 'pve-1',
|
||||
productType: 'pve',
|
||||
baseUrl: 'https://pve.intern:8006',
|
||||
authMethod: 'token',
|
||||
tokenId: 'root@pam!tessera',
|
||||
tokenSecret: 'geheimes-token-secret',
|
||||
};
|
||||
|
||||
function pveResourcesBody(overrides: Partial<Record<string, unknown>> = {}) {
|
||||
return {
|
||||
data: [
|
||||
{ type: 'node', node: 'pve1', cpu: 0.12, maxcpu: 8, mem: 4_000_000_000, maxmem: 16_000_000_000 },
|
||||
{ type: 'qemu', node: 'pve1', vmid: 100, status: 'running' },
|
||||
{ type: 'qemu', node: 'pve1', vmid: 101, status: 'stopped' },
|
||||
{ type: 'lxc', node: 'pve1', vmid: 200, status: 'running' },
|
||||
],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('ProxmoxService — Aufgabe 1 (PVE per Token, durchgehender Weg)', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('legt einen Server verschluesselt an und liefert nie das Geheimnis zurueck', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new ProxmoxService(prisma as any, crypto as any);
|
||||
|
||||
const created = await service.createServer('tenant-a', TOKEN_DTO);
|
||||
|
||||
expect((created as any).encryptedTokenSecret).toBeUndefined();
|
||||
expect((created as any).encryptedPassword).toBeUndefined();
|
||||
|
||||
const storedRow = [...prisma.__servers.values()][0];
|
||||
expect(storedRow.encryptedTokenSecret).not.toBe(TOKEN_DTO.tokenSecret);
|
||||
expect(storedRow.encryptedTokenSecret).toMatch(/^[0-9a-f]+:[0-9a-f]+:[0-9a-f]*$/i);
|
||||
});
|
||||
|
||||
it('listWithStatus liefert weder encryptedTokenSecret noch encryptedPassword', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new ProxmoxService(prisma as any, crypto as any);
|
||||
await service.createServer('tenant-a', TOKEN_DTO);
|
||||
|
||||
const list = await service.listWithStatus('tenant-a');
|
||||
|
||||
expect(list).toHaveLength(1);
|
||||
expect(JSON.stringify(list)).not.toContain(TOKEN_DTO.tokenSecret);
|
||||
expect('encryptedTokenSecret' in (list[0] as object)).toBe(false);
|
||||
expect('encryptedPassword' in (list[0] as object)).toBe(false);
|
||||
});
|
||||
|
||||
it('pollServer fragt PVE ab, normalisiert nachsichtig und schreibt das Zwischenlager', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new ProxmoxService(prisma as any, crypto as any);
|
||||
const created = await service.createServer('tenant-a', TOKEN_DTO);
|
||||
|
||||
const fetchSpy = vi.fn(async (url: string) => {
|
||||
expect(url).toBe('https://pve.intern:8006/api2/json/cluster/resources');
|
||||
return new Response(JSON.stringify(pveResourcesBody()), { status: 200 });
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchSpy);
|
||||
|
||||
const result = await service.pollServer('tenant-a', (created as any).id);
|
||||
|
||||
expect(result?.reachable).toBe(true);
|
||||
expect(result?.metrics).toMatchObject({
|
||||
productType: 'pve',
|
||||
nodeCount: 1,
|
||||
guestsRunning: 2,
|
||||
guestsStopped: 1,
|
||||
});
|
||||
|
||||
const status = prisma.__statuses.get((created as any).id);
|
||||
expect(status.reachable).toBe(true);
|
||||
expect(status.metrics).toMatchObject({ nodeCount: 1 });
|
||||
expect(status.rawSample).toContain('"node":"pve1"');
|
||||
});
|
||||
|
||||
it('sendet die Token-Kopfzeile im PVE-Schema (Gleichheitszeichen vor dem Geheimnis)', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new ProxmoxService(prisma as any, crypto as any);
|
||||
const created = await service.createServer('tenant-a', TOKEN_DTO);
|
||||
|
||||
let capturedAuth: string | null = null;
|
||||
const fetchSpy = vi.fn(async (_url: string, options: RequestInit) => {
|
||||
capturedAuth = (options.headers as Record<string, string>).Authorization;
|
||||
return new Response(JSON.stringify(pveResourcesBody()), { status: 200 });
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchSpy);
|
||||
|
||||
await service.pollServer('tenant-a', (created as any).id);
|
||||
|
||||
expect(capturedAuth).toBe(
|
||||
`PVEAPIToken=${TOKEN_DTO.tokenId}=${TOKEN_DTO.tokenSecret}`,
|
||||
);
|
||||
});
|
||||
|
||||
it('uebergibt bei tlsRejectUnauthorized=true KEINEN Dispatcher, bei false genau einen mit abgeschalteter Pruefung', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new ProxmoxService(prisma as any, crypto as any);
|
||||
|
||||
const strictServer = await service.createServer('tenant-a', TOKEN_DTO);
|
||||
const lenientServer = await service.createServer('tenant-a', {
|
||||
...TOKEN_DTO,
|
||||
name: 'pve-2',
|
||||
tlsRejectUnauthorized: false,
|
||||
});
|
||||
|
||||
const dispatchers: unknown[] = [];
|
||||
const fetchSpy = vi.fn(async (_url: string, options: RequestInit & { dispatcher?: unknown }) => {
|
||||
dispatchers.push(options.dispatcher);
|
||||
return new Response(JSON.stringify(pveResourcesBody()), { status: 200 });
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchSpy);
|
||||
|
||||
await service.pollServer('tenant-a', (strictServer as any).id);
|
||||
await service.pollServer('tenant-a', (lenientServer as any).id);
|
||||
|
||||
expect(dispatchers[0]).toBeUndefined();
|
||||
expect(dispatchers[1]).toBeInstanceOf(Agent);
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Test-Attrappe traegt `options` nicht im echten undici-Typ
|
||||
expect((dispatchers[1] as any).options).toEqual({
|
||||
connect: { rejectUnauthorized: false },
|
||||
});
|
||||
});
|
||||
|
||||
it('ein fehlendes Feld der Antwort fuehrt zu null, nicht zu einem Wurf', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new ProxmoxService(prisma as any, crypto as any);
|
||||
const created = await service.createServer('tenant-a', TOKEN_DTO);
|
||||
|
||||
const bodyWithMissingFields = {
|
||||
data: [{ type: 'node', node: 'pve1' /* cpu/maxcpu/mem/maxmem fehlen */ }],
|
||||
};
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => new Response(JSON.stringify(bodyWithMissingFields), { status: 200 })),
|
||||
);
|
||||
|
||||
const result = await service.pollServer('tenant-a', (created as any).id);
|
||||
|
||||
expect(result?.reachable).toBe(true);
|
||||
const metrics = result?.metrics as { nodes: { cpu: unknown; maxcpu: unknown; mem: unknown; maxmem: unknown }[] };
|
||||
expect(metrics.nodes[0]).toEqual({
|
||||
node: 'pve1',
|
||||
cpu: null,
|
||||
maxcpu: null,
|
||||
mem: null,
|
||||
maxmem: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('nutzt forTenant fuer jeden Datenbankzugriff (D-08)', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new ProxmoxService(prisma as any, crypto as any);
|
||||
await service.createServer('tenant-a', TOKEN_DTO);
|
||||
await service.listWithStatus('tenant-a');
|
||||
|
||||
expect(forTenant).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ProxmoxService — Aufgabe 3 (PBS und PMG)', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('fragt PBS ab: Belegung plus je Datenspeicher hoechstens 10 Folgeabfragen', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new ProxmoxService(prisma as any, crypto as any);
|
||||
const created = await service.createServer('tenant-a', {
|
||||
...TOKEN_DTO,
|
||||
name: 'pbs-1',
|
||||
productType: 'pbs',
|
||||
baseUrl: 'https://pbs.intern:8007',
|
||||
});
|
||||
|
||||
const usageBody = {
|
||||
data: Array.from({ length: 15 }, (_, i) => ({ store: `store-${i}`, total: 100, used: 10, avail: 90 })),
|
||||
};
|
||||
|
||||
let snapshotCalls = 0;
|
||||
const fetchSpy = vi.fn(async (url: string) => {
|
||||
if (url.includes('/status/datastore-usage')) {
|
||||
return new Response(JSON.stringify(usageBody), { status: 200 });
|
||||
}
|
||||
snapshotCalls++;
|
||||
return new Response(JSON.stringify({ data: [] }), { status: 200 });
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchSpy);
|
||||
|
||||
const result = await service.pollServer('tenant-a', (created as any).id);
|
||||
|
||||
expect(result?.reachable).toBe(true);
|
||||
expect(snapshotCalls).toBe(10);
|
||||
expect((result?.metrics as { datastores: unknown[] }).datastores).toHaveLength(15);
|
||||
});
|
||||
|
||||
it('fragt PMG ab und normalisiert die Tageszahlen', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new ProxmoxService(prisma as any, crypto as any);
|
||||
const created = await service.createServer('tenant-a', {
|
||||
name: 'pmg-1',
|
||||
productType: 'pmg',
|
||||
baseUrl: 'https://pmg.intern:8006',
|
||||
authMethod: 'password',
|
||||
username: 'admin@pmg',
|
||||
password: 'geheim',
|
||||
});
|
||||
|
||||
const fetchSpy = vi.fn(async (url: string) => {
|
||||
if (url.endsWith('/access/ticket')) {
|
||||
return new Response(JSON.stringify({ data: { ticket: 'PMG:admin@pmg:xyz' } }), { status: 200 });
|
||||
}
|
||||
return new Response(
|
||||
JSON.stringify({ data: { count_in: 10, count_out: 5, spamcount_in: 1, spamcount_out: 0, viruscount_in: 0, viruscount_out: 0 } }),
|
||||
{ status: 200 },
|
||||
);
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchSpy);
|
||||
|
||||
const result = await service.pollServer('tenant-a', (created as any).id);
|
||||
|
||||
expect(result?.reachable).toBe(true);
|
||||
expect(result?.metrics).toMatchObject({ productType: 'pmg', countIn: 10, countOut: 5, spamCount: 1 });
|
||||
});
|
||||
|
||||
it('401/403/404/500 bleiben fuer PBS/PMG dieselben Fehlerschluessel wie fuer PVE', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new ProxmoxService(prisma as any, crypto as any);
|
||||
const created = await service.createServer('tenant-a', {
|
||||
...TOKEN_DTO,
|
||||
name: 'pbs-403',
|
||||
productType: 'pbs',
|
||||
baseUrl: 'https://pbs.intern:8007',
|
||||
});
|
||||
|
||||
vi.stubGlobal('fetch', vi.fn(async () => new Response('forbidden', { status: 403 })));
|
||||
const result = await service.pollServer('tenant-a', (created as any).id);
|
||||
expect(result?.reachable).toBe(false);
|
||||
expect(result?.errorKind).toBe('rechte');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ProxmoxService — Aufgabe 4 (Verbindungstest, Zehn-Sekunden-Sperre)', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('testConnection liefert das Ergebnis, schreibt aber NICHT ins Zwischenlager', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new ProxmoxService(prisma as any, crypto as any);
|
||||
const created = await service.createServer('tenant-a', TOKEN_DTO);
|
||||
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => new Response(JSON.stringify(pveResourcesBody()), { status: 200 })),
|
||||
);
|
||||
|
||||
const result = await service.testConnection('tenant-a', (created as any).id);
|
||||
|
||||
expect(result?.reachable).toBe(true);
|
||||
const status = prisma.__statuses.get((created as any).id);
|
||||
// Die leere Zwischenlagerzeile aus createServer bleibt unveraendert.
|
||||
expect(status.lastPolledAt).toBeUndefined();
|
||||
expect(status.reachable).toBe(false);
|
||||
});
|
||||
|
||||
it('Nachbesserung Befund 1: testConnection prueft die im Formular abgeschaltete Zertifikatspruefung, nicht den gespeicherten Stand (Server wurde MIT tlsRejectUnauthorized:true angelegt)', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new ProxmoxService(prisma as any, crypto as any);
|
||||
// Gespeichert: Zertifikatspruefung AN (Vorgabe).
|
||||
const created = await service.createServer('tenant-a', TOKEN_DTO);
|
||||
|
||||
const dispatchers: unknown[] = [];
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async (_url: string, options: RequestInit & { dispatcher?: unknown }) => {
|
||||
dispatchers.push(options.dispatcher);
|
||||
return new Response(JSON.stringify(pveResourcesBody()), { status: 200 });
|
||||
}),
|
||||
);
|
||||
|
||||
// Formular: Zertifikatspruefung wurde vom Nutzer AUSGESCHALTET, aber noch nicht gespeichert.
|
||||
await service.testConnection('tenant-a', (created as any).id, {
|
||||
tlsRejectUnauthorized: false,
|
||||
});
|
||||
|
||||
// Vor der Korrektur wurde ausschliesslich der gespeicherte Server (Zertifikatspruefung AN)
|
||||
// getestet — dieser Test waere ohne die Korrektur rot, weil dispatchers[0] dann `undefined` waere.
|
||||
expect(dispatchers[0]).toBeInstanceOf(Agent);
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Test-Attrappe traegt `options` nicht im echten undici-Typ
|
||||
expect((dispatchers[0] as any).options).toEqual({ connect: { rejectUnauthorized: false } });
|
||||
});
|
||||
|
||||
it('Nachbesserung Befund 1: ein im Formular NEU eingetipptes Token-Geheimnis wird getestet, nicht das gespeicherte', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new ProxmoxService(prisma as any, crypto as any);
|
||||
const created = await service.createServer('tenant-a', TOKEN_DTO);
|
||||
|
||||
let capturedAuth: string | null = null;
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async (_url: string, options: RequestInit) => {
|
||||
capturedAuth = (options.headers as Record<string, string>).Authorization;
|
||||
return new Response(JSON.stringify(pveResourcesBody()), { status: 200 });
|
||||
}),
|
||||
);
|
||||
|
||||
await service.testConnection('tenant-a', (created as any).id, {
|
||||
tokenSecret: 'ein-anderes-geheimnis',
|
||||
});
|
||||
|
||||
// Ohne die Korrektur wuerde hier weiterhin TOKEN_DTO.tokenSecret gesendet — roter Test.
|
||||
expect(capturedAuth).toBe(`PVEAPIToken=${TOKEN_DTO.tokenId}=ein-anderes-geheimnis`);
|
||||
});
|
||||
|
||||
it('Nachbesserung Befund 1: leer gelassenes Geheimnisfeld im Formular nutzt weiterhin das gespeicherte Token-Geheimnis', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new ProxmoxService(prisma as any, crypto as any);
|
||||
const created = await service.createServer('tenant-a', TOKEN_DTO);
|
||||
|
||||
let capturedAuth: string | null = null;
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async (_url: string, options: RequestInit) => {
|
||||
capturedAuth = (options.headers as Record<string, string>).Authorization;
|
||||
return new Response(JSON.stringify(pveResourcesBody()), { status: 200 });
|
||||
}),
|
||||
);
|
||||
|
||||
// Formular sendet kein tokenSecret (Feld leer gelassen) — wie `ServerForm.buildPayload()`.
|
||||
await service.testConnection('tenant-a', (created as any).id, {});
|
||||
|
||||
expect(capturedAuth).toBe(`PVEAPIToken=${TOKEN_DTO.tokenId}=${TOKEN_DTO.tokenSecret}`);
|
||||
});
|
||||
|
||||
it('Nachbesserung Befund 1: testDraftConnection testet einen noch nicht gespeicherten Server ausschliesslich mit den Formularwerten', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new ProxmoxService(prisma as any, crypto as any);
|
||||
|
||||
let capturedUrl: string | null = null;
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async (url: string) => {
|
||||
capturedUrl = url;
|
||||
return new Response(JSON.stringify(pveResourcesBody()), { status: 200 });
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await service.testDraftConnection({
|
||||
productType: 'pve',
|
||||
baseUrl: 'https://neu.intern:8006',
|
||||
authMethod: 'token',
|
||||
tokenId: 'root@pam!neu',
|
||||
tokenSecret: 'frisches-geheimnis',
|
||||
});
|
||||
|
||||
expect(result.reachable).toBe(true);
|
||||
expect(capturedUrl).toBe('https://neu.intern:8006/api2/json/cluster/resources');
|
||||
});
|
||||
|
||||
it('Nachbesserung Befund 1: testDraftConnection ohne Geheimnis liefert den Fehlerschluessel "zugang", statt zu werfen', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new ProxmoxService(prisma as any, crypto as any);
|
||||
|
||||
const result = await service.testDraftConnection({
|
||||
productType: 'pve',
|
||||
baseUrl: 'https://neu.intern:8006',
|
||||
authMethod: 'token',
|
||||
tokenId: 'root@pam!neu',
|
||||
});
|
||||
|
||||
expect(result.reachable).toBe(false);
|
||||
expect(result.errorKind).toBe('zugang');
|
||||
});
|
||||
|
||||
it('POST servers/:id/poll verweigert einen zweiten Durchlauf innerhalb von zehn Sekunden und liefert den vorhandenen Stand', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new ProxmoxService(prisma as any, crypto as any);
|
||||
const created = await service.createServer('tenant-a', TOKEN_DTO);
|
||||
|
||||
let fetchCalls = 0;
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => {
|
||||
fetchCalls++;
|
||||
return new Response(JSON.stringify(pveResourcesBody()), { status: 200 });
|
||||
}),
|
||||
);
|
||||
|
||||
const first = await service.pollServer('tenant-a', (created as any).id);
|
||||
const second = await service.pollServer('tenant-a', (created as any).id);
|
||||
|
||||
expect(fetchCalls).toBe(1);
|
||||
expect(second).toEqual(first);
|
||||
});
|
||||
|
||||
it('nach zehn Sekunden ist ein erneuter Durchlauf wieder erlaubt', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new ProxmoxService(prisma as any, crypto as any);
|
||||
const created = await service.createServer('tenant-a', TOKEN_DTO);
|
||||
|
||||
let fetchCalls = 0;
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => {
|
||||
fetchCalls++;
|
||||
return new Response(JSON.stringify(pveResourcesBody()), { status: 200 });
|
||||
}),
|
||||
);
|
||||
|
||||
await service.pollServer('tenant-a', (created as any).id);
|
||||
const status = prisma.__statuses.get((created as any).id);
|
||||
status.lastPolledAt = new Date(Date.now() - 11_000); // Sperre kuenstlich veraltern
|
||||
|
||||
await service.pollServer('tenant-a', (created as any).id);
|
||||
|
||||
expect(fetchCalls).toBe(2);
|
||||
});
|
||||
|
||||
it('loadActiveServersForScheduler nutzt forSystem (D-08, der einzige Systemkontext-Aufruf des Moduls)', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new ProxmoxService(prisma as any, crypto as any);
|
||||
await service.createServer('tenant-a', TOKEN_DTO);
|
||||
|
||||
const servers = await service.loadActiveServersForScheduler();
|
||||
expect(servers).toHaveLength(1);
|
||||
expect(servers[0]).toMatchObject({ tenantId: 'tenant-a', pollIntervalMin: 5 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('ProxmoxService — Aufgabe 5 (Bearbeiten, Loeschen)', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('ein NICHT gesendetes Geheimnisfeld laesst den gespeicherten Wert unveraendert', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new ProxmoxService(prisma as any, crypto as any);
|
||||
const created = await service.createServer('tenant-a', TOKEN_DTO);
|
||||
const storedBefore = prisma.__servers.get((created as any).id).encryptedTokenSecret;
|
||||
|
||||
await service.updateServer('tenant-a', (created as any).id, { name: 'neuer-name' });
|
||||
|
||||
expect(prisma.__servers.get((created as any).id).encryptedTokenSecret).toBe(storedBefore);
|
||||
expect(prisma.__servers.get((created as any).id).name).toBe('neuer-name');
|
||||
});
|
||||
|
||||
it('eine LEERE Zeichenkette loescht das Geheimnis, ein gefuellter Wert verschluesselt neu', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new ProxmoxService(prisma as any, crypto as any);
|
||||
const created = await service.createServer('tenant-a', TOKEN_DTO);
|
||||
|
||||
await service.updateServer('tenant-a', (created as any).id, { tokenSecret: '' });
|
||||
expect(prisma.__servers.get((created as any).id).encryptedTokenSecret).toBeNull();
|
||||
|
||||
await service.updateServer('tenant-a', (created as any).id, { tokenSecret: 'neues-geheimnis' });
|
||||
const stored = prisma.__servers.get((created as any).id).encryptedTokenSecret;
|
||||
expect(stored).not.toBe('neues-geheimnis');
|
||||
expect(stored).toMatch(/^[0-9a-f]+:[0-9a-f]+:[0-9a-f]*$/i);
|
||||
});
|
||||
|
||||
it('PMG plus Token wird auch beim Bearbeiten abgelehnt — auch wenn nur authMethod gesendet wird', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new ProxmoxService(prisma as any, crypto as any);
|
||||
const created = await service.createServer('tenant-a', {
|
||||
name: 'pmg-1',
|
||||
productType: 'pmg',
|
||||
baseUrl: 'https://pmg.intern',
|
||||
authMethod: 'password',
|
||||
username: 'admin@pmg',
|
||||
password: 'geheim',
|
||||
});
|
||||
|
||||
const dto: UpdateProxmoxServerDto = { authMethod: 'token', tokenId: 'x', tokenSecret: 'y' };
|
||||
await expect(service.updateServer('tenant-a', (created as any).id, dto)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('loescht einen Server samt Zwischenlagerzeile', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new ProxmoxService(prisma as any, crypto as any);
|
||||
const created = await service.createServer('tenant-a', TOKEN_DTO);
|
||||
expect(prisma.__statuses.has((created as any).id)).toBe(true);
|
||||
|
||||
const deleted = await service.deleteServer('tenant-a', (created as any).id);
|
||||
|
||||
expect(deleted).toBe(true);
|
||||
expect(prisma.__servers.has((created as any).id)).toBe(false);
|
||||
expect(prisma.__statuses.has((created as any).id)).toBe(false);
|
||||
});
|
||||
|
||||
it('deleteServer liefert false fuer einen unbekannten Server', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new ProxmoxService(prisma as any, crypto as any);
|
||||
expect(await service.deleteServer('tenant-a', 'unbekannt')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,638 @@
|
||||
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
||||
import type { ProxmoxServer } from '@prisma/client';
|
||||
import { CryptoService } from '../crypto/crypto.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { forSystem, forTenant } from '../prisma/prisma-tenant.extension';
|
||||
import { buildTicketCookieHeader, buildTokenAuthHeader, loginTicket } from './proxmox-auth';
|
||||
import { proxmoxGet, type ProxmoxGetResult } from './proxmox-client.service';
|
||||
import { listPbsDatastoreNames, normalizePbs, normalizePmg, normalizePve } from './proxmox-normalize';
|
||||
import type {
|
||||
CreateProxmoxServerDto,
|
||||
TestProxmoxServerDto,
|
||||
UpdateProxmoxServerDto,
|
||||
} from './dto/proxmox-server.dto';
|
||||
import type { ProxmoxErrorKind, ProxmoxPollResult, ProxmoxProductType } from './proxmox.types';
|
||||
|
||||
/**
|
||||
* Nur die Felder, die eine Abfrage tatsaechlich braucht (Nachbesserung
|
||||
* Befund 1) — `pollOne`/`buildAuthHeaders`/`getWithRetry` nehmen diesen
|
||||
* schmalen Ausschnitt statt der vollen `ProxmoxServer`-Zeile entgegen, damit
|
||||
* `resolveEffectiveTestServer` unten eine rein im Speicher gebaute Mischung
|
||||
* aus Formular- und gespeicherten Werten uebergeben kann, ohne eine
|
||||
* vollstaendige Datenbankzeile vorzutaeuschen.
|
||||
*/
|
||||
type ProxmoxCredentialSource = Pick<
|
||||
ProxmoxServer,
|
||||
| 'productType'
|
||||
| 'baseUrl'
|
||||
| 'authMethod'
|
||||
| 'tokenId'
|
||||
| 'encryptedTokenSecret'
|
||||
| 'username'
|
||||
| 'encryptedPassword'
|
||||
| 'tlsRejectUnauthorized'
|
||||
>;
|
||||
|
||||
/**
|
||||
* Erkennungsform fuer "schon verschluesselt" — woertlich aus
|
||||
* `ldap-config.service.ts:17` uebernommen (Format `iv:authTag:ciphertext`,
|
||||
* hex, Doppelpunkt-getrennt). Fuer Proxmox als NEUES Feature ab Tag 1
|
||||
* irrelevant (keine Altdaten), aber derselbe defensive Riegel wie ueberall
|
||||
* sonst im Projekt.
|
||||
*/
|
||||
const ENCRYPTED_VALUE_SHAPE = /^[0-9a-f]+:[0-9a-f]+:[0-9a-f]*$/i;
|
||||
|
||||
/** Rohantwort wird auf hoechstens diese Zeichenzahl gekuerzt in `rawSample` abgelegt. */
|
||||
const RAW_SAMPLE_MAX_CHARS = 20000;
|
||||
|
||||
/**
|
||||
* `select` OHNE die beiden Geheimnisfelder — die Felder verlassen die
|
||||
* Datenbank gar nicht erst, statt nachtraeglich maskiert zu werden
|
||||
* (T-DHH-01, `must_haves.truths`).
|
||||
*/
|
||||
const SAFE_SERVER_SELECT = {
|
||||
id: true,
|
||||
tenantId: true,
|
||||
name: true,
|
||||
productType: true,
|
||||
baseUrl: true,
|
||||
authMethod: true,
|
||||
tokenId: true,
|
||||
username: true,
|
||||
tlsRejectUnauthorized: true,
|
||||
isActive: true,
|
||||
pollIntervalMin: true,
|
||||
position: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
status: true,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Deckel der Folgeabfragen je PBS-Durchlauf (Aufgabe 3, `<behavior>`): ein
|
||||
* PBS-Server mit vielen Datenspeichern soll den Planer nicht mit
|
||||
* unbegrenzt vielen Anfragen belasten — hoechstens diese Zahl an
|
||||
* `/snapshots`-Abfragen je Poll-Durchlauf, unabhaengig davon, wie viele
|
||||
* Datenspeicher der Server tatsaechlich hat.
|
||||
*/
|
||||
const PBS_SNAPSHOT_QUERY_CAP = 10;
|
||||
|
||||
/**
|
||||
* Zehn-Sekunden-Sperre fuer `pollServer` (Aufgabe 4, T-DHH-06): ein Klick
|
||||
* auf "Jetzt aktualisieren" darf nicht zu ungebremsten Anfragen gegen die
|
||||
* Fremd-API werden. Regulaer fragt ohnehin nur der Planer mit begrenzter
|
||||
* Frequenz ab (D-05).
|
||||
*/
|
||||
const POLL_LOCK_MS = 10_000;
|
||||
|
||||
function truncateRaw(body: unknown): string {
|
||||
let text: string;
|
||||
try {
|
||||
text = JSON.stringify(body) ?? String(body);
|
||||
} catch {
|
||||
text = String(body);
|
||||
}
|
||||
return text.length > RAW_SAMPLE_MAX_CHARS ? text.slice(0, RAW_SAMPLE_MAX_CHARS) : text;
|
||||
}
|
||||
|
||||
type AuthHeaderResult =
|
||||
| { ok: true; headers: Record<string, string> }
|
||||
| { ok: false; errorKind: ProxmoxErrorKind; errorDetail: string };
|
||||
|
||||
@Injectable()
|
||||
export class ProxmoxService {
|
||||
private readonly logger = new Logger(ProxmoxService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly crypto: CryptoService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Entschluesselt fuer den internen Gebrauch in GENAU dieser einen
|
||||
* privaten Methode (Vorbild `LdapConfigService.decryptBindPassword`) —
|
||||
* ein Wert, der nicht in `iv:authTag:ciphertext`-Form ist, wird
|
||||
* unveraendert durchgereicht.
|
||||
*/
|
||||
private decryptSecret(stored: string | null): string | null {
|
||||
if (!stored) return null;
|
||||
if (!ENCRYPTED_VALUE_SHAPE.test(stored)) return stored;
|
||||
return this.crypto.decrypt(stored);
|
||||
}
|
||||
|
||||
/**
|
||||
* Server anlegen (Aufgabe 1: nur `pve`+Token gepflegt vom Aufrufer;
|
||||
* Aufgabe 2 ergaenzt den Passwort-Zweig, Aufgabe 5 das Bearbeiten). Legt
|
||||
* zugleich eine leere Zwischenlagerzeile an, damit `listWithStatus` immer
|
||||
* eine Statuszeile findet.
|
||||
*/
|
||||
async createServer(tenantId: string, dto: CreateProxmoxServerDto) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
return tenantPrisma.proxmoxServer.create({
|
||||
data: {
|
||||
tenantId,
|
||||
name: dto.name,
|
||||
productType: dto.productType,
|
||||
baseUrl: dto.baseUrl,
|
||||
authMethod: dto.authMethod,
|
||||
tokenId: dto.authMethod === 'token' ? (dto.tokenId ?? null) : null,
|
||||
encryptedTokenSecret:
|
||||
dto.authMethod === 'token' && dto.tokenSecret
|
||||
? this.crypto.encrypt(dto.tokenSecret)
|
||||
: null,
|
||||
username: dto.authMethod === 'password' ? (dto.username ?? null) : null,
|
||||
encryptedPassword:
|
||||
dto.authMethod === 'password' && dto.password
|
||||
? this.crypto.encrypt(dto.password)
|
||||
: null,
|
||||
tlsRejectUnauthorized: dto.tlsRejectUnauthorized ?? true,
|
||||
pollIntervalMin: dto.pollIntervalMin ?? 5,
|
||||
isActive: dto.isActive ?? true,
|
||||
status: { create: { tenantId, reachable: false } },
|
||||
},
|
||||
select: SAFE_SERVER_SELECT,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Serverliste samt Zwischenlager, OHNE jedes Geheimnisfeld (T-DHH-01).
|
||||
* Liest ausschliesslich aus dem Zwischenlager — kein Live-Zugriff bei
|
||||
* Proxmox (D-05).
|
||||
*/
|
||||
async listWithStatus(tenantId: string) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
return tenantPrisma.proxmoxServer.findMany({
|
||||
where: { tenantId },
|
||||
orderBy: { position: 'asc' },
|
||||
select: SAFE_SERVER_SELECT,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Bearbeiten (Aufgabe 5). Dieselbe Regel wie
|
||||
* `LdapConfigService.updateConfig`: ein NICHT gesendetes Geheimnisfeld
|
||||
* laesst den gespeicherten Wert unveraendert, eine LEERE Zeichenkette
|
||||
* bedeutet "loeschen", ein gefuellter Wert wird neu verschluesselt. Die
|
||||
* Ablehnung "PMG plus Token" gilt auch hier — geprueft gegen den
|
||||
* EFFEKTIVEN Stand nach dem Zusammenfuehren mit der vorhandenen Zeile,
|
||||
* nicht nur gegen die gesendeten Felder (ein Teil-Update, das nur
|
||||
* `authMethod` aendert, wuerde die DTO-eigene Pruefung sonst umgehen,
|
||||
* weil `productType` in diesem Aufruf gar nicht gesendet wird).
|
||||
*/
|
||||
async updateServer(tenantId: string, serverId: string, dto: UpdateProxmoxServerDto) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
const existing = await tenantPrisma.proxmoxServer.findUnique({ where: { id: serverId } });
|
||||
if (!existing || existing.tenantId !== tenantId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const effectiveProductType = dto.productType ?? existing.productType;
|
||||
const effectiveAuthMethod = dto.authMethod ?? existing.authMethod;
|
||||
if (effectiveProductType === 'pmg' && effectiveAuthMethod === 'token') {
|
||||
throw new BadRequestException(
|
||||
'PMG unterstuetzt keinen API-Token-Zugang. Bitte Benutzer und Passwort waehlen.',
|
||||
);
|
||||
}
|
||||
|
||||
const data: Record<string, unknown> = {};
|
||||
if (dto.name !== undefined) data.name = dto.name;
|
||||
if (dto.productType !== undefined) data.productType = dto.productType;
|
||||
if (dto.baseUrl !== undefined) data.baseUrl = dto.baseUrl;
|
||||
if (dto.authMethod !== undefined) data.authMethod = dto.authMethod;
|
||||
if (dto.tokenId !== undefined) data.tokenId = dto.tokenId || null;
|
||||
if (dto.tokenSecret !== undefined) {
|
||||
data.encryptedTokenSecret = dto.tokenSecret ? this.crypto.encrypt(dto.tokenSecret) : null;
|
||||
}
|
||||
if (dto.username !== undefined) data.username = dto.username || null;
|
||||
if (dto.password !== undefined) {
|
||||
data.encryptedPassword = dto.password ? this.crypto.encrypt(dto.password) : null;
|
||||
}
|
||||
if (dto.tlsRejectUnauthorized !== undefined) {
|
||||
data.tlsRejectUnauthorized = dto.tlsRejectUnauthorized;
|
||||
}
|
||||
if (dto.pollIntervalMin !== undefined) data.pollIntervalMin = dto.pollIntervalMin;
|
||||
if (dto.isActive !== undefined) data.isActive = dto.isActive;
|
||||
|
||||
return tenantPrisma.proxmoxServer.update({
|
||||
where: { id: serverId },
|
||||
data,
|
||||
select: SAFE_SERVER_SELECT,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Loeschen (Aufgabe 5) — entfernt den Server samt Zwischenlagerzeile
|
||||
* (Fremdschluessel mit Loeschweitergabe, `onDelete: Cascade`). Liefert
|
||||
* `false`, wenn der Server unter diesem Mandanten nicht existiert.
|
||||
*/
|
||||
async deleteServer(tenantId: string, serverId: string): Promise<boolean> {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
const existing = await tenantPrisma.proxmoxServer.findUnique({ where: { id: serverId } });
|
||||
if (!existing || existing.tenantId !== tenantId) {
|
||||
return false;
|
||||
}
|
||||
await tenantPrisma.proxmoxServer.delete({ where: { id: serverId } });
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Baut die Anmeldekopfzeile fuer GENAU diesen Server ueber
|
||||
* `proxmox-auth.ts` (D-03). Beim Passwort-Zweig loest das eine
|
||||
* Ticket-Anmeldung aus (die einzige nicht-lesende Anfrage des Moduls,
|
||||
* D-01) — deshalb `async`.
|
||||
*/
|
||||
private async buildAuthHeaders(server: ProxmoxCredentialSource): Promise<AuthHeaderResult> {
|
||||
if (server.authMethod === 'token') {
|
||||
const tokenSecret = this.decryptSecret(server.encryptedTokenSecret);
|
||||
if (!server.tokenId || !tokenSecret) {
|
||||
return {
|
||||
ok: false,
|
||||
errorKind: 'zugang',
|
||||
errorDetail: 'Kein Token hinterlegt.',
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
headers: buildTokenAuthHeader(
|
||||
server.productType as 'pve' | 'pbs' | 'pmg',
|
||||
server.tokenId,
|
||||
tokenSecret,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const password = this.decryptSecret(server.encryptedPassword);
|
||||
if (!server.username || !password) {
|
||||
return {
|
||||
ok: false,
|
||||
errorKind: 'zugang',
|
||||
errorDetail: 'Kein Benutzer/Passwort hinterlegt.',
|
||||
};
|
||||
}
|
||||
|
||||
const login = await loginTicket(
|
||||
{ baseUrl: server.baseUrl, tlsRejectUnauthorized: server.tlsRejectUnauthorized },
|
||||
server.productType as 'pve' | 'pbs' | 'pmg',
|
||||
server.username,
|
||||
password,
|
||||
);
|
||||
if (!login.ok) {
|
||||
return { ok: false, errorKind: login.errorKind, errorDetail: login.errorDetail };
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
headers: buildTicketCookieHeader(server.productType as 'pve' | 'pbs' | 'pmg', login.ticket),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fragt EINEN Proxmox-Pfad ab, gebunden an die Kopfzeilen dieses
|
||||
* Poll-Durchlaufs. Ticket-Erneuerung (Aufgabe 2, `<behavior>`): laeuft
|
||||
* der Zugang ueber `password` und antwortet Proxmox mit 401
|
||||
* (`errorKind: 'zugang'`), wird GENAU EINMAL je Durchlauf neu angemeldet
|
||||
* (nicht je Aufruf — ein PBS-Durchlauf mit mehreren Folgeabfragen soll
|
||||
* nicht mehrfach neu einloggen) und die Abfrage wiederholt; die neuen
|
||||
* Kopfzeilen gelten danach fuer den Rest des Durchlaufs. Bei einer
|
||||
* Ticketdauer von zwei Stunden erzeugt ein normaler Ablauf sonst alle
|
||||
* zwei Stunden einen Fehlalarm. Ein zweites 401 bleibt `'zugang'`.
|
||||
*/
|
||||
private async getWithRetry(
|
||||
server: ProxmoxCredentialSource,
|
||||
session: { headers: Record<string, string>; retried: boolean },
|
||||
path: string,
|
||||
): Promise<ProxmoxGetResult> {
|
||||
const target = {
|
||||
baseUrl: server.baseUrl,
|
||||
tlsRejectUnauthorized: server.tlsRejectUnauthorized,
|
||||
headers: session.headers,
|
||||
};
|
||||
let result = await proxmoxGet(target, path);
|
||||
|
||||
if (
|
||||
!result.ok &&
|
||||
result.errorKind === 'zugang' &&
|
||||
server.authMethod === 'password' &&
|
||||
!session.retried
|
||||
) {
|
||||
session.retried = true;
|
||||
const retryHeaders = await this.buildAuthHeaders(server);
|
||||
if (retryHeaders.ok) {
|
||||
session.headers = retryHeaders.headers;
|
||||
result = await proxmoxGet({ ...target, headers: retryHeaders.headers }, path);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* EIN Abfragedurchlauf gegen genau diesen Server — `pve` (Aufgabe 1),
|
||||
* `pbs` und `pmg` (Aufgabe 3), jeweils mit Token ODER Benutzer/Passwort
|
||||
* (Aufgabe 2).
|
||||
*/
|
||||
private async pollOne(server: ProxmoxCredentialSource): Promise<ProxmoxPollResult> {
|
||||
const authHeaders = await this.buildAuthHeaders(server);
|
||||
if (!authHeaders.ok) {
|
||||
return {
|
||||
reachable: false,
|
||||
errorKind: authHeaders.errorKind,
|
||||
errorDetail: authHeaders.errorDetail,
|
||||
metrics: null,
|
||||
rawSample: null,
|
||||
};
|
||||
}
|
||||
|
||||
const session = { headers: authHeaders.headers, retried: false };
|
||||
const productType = server.productType as ProxmoxProductType;
|
||||
|
||||
if (productType === 'pve') {
|
||||
const result = await this.getWithRetry(server, session, '/api2/json/cluster/resources');
|
||||
if (!result.ok) {
|
||||
return {
|
||||
reachable: false,
|
||||
errorKind: result.errorKind,
|
||||
errorDetail: result.errorDetail,
|
||||
metrics: null,
|
||||
rawSample: result.body === null ? null : truncateRaw(result.body),
|
||||
};
|
||||
}
|
||||
const normalized = normalizePve(result.body);
|
||||
if (normalized.errorKind) {
|
||||
return {
|
||||
reachable: false,
|
||||
errorKind: normalized.errorKind,
|
||||
errorDetail: 'Die Antwort hatte nicht die erwartete Form.',
|
||||
metrics: null,
|
||||
rawSample: truncateRaw(result.body),
|
||||
};
|
||||
}
|
||||
return {
|
||||
reachable: true,
|
||||
errorKind: null,
|
||||
errorDetail: null,
|
||||
metrics: normalized.metrics,
|
||||
rawSample: truncateRaw(result.body),
|
||||
};
|
||||
}
|
||||
|
||||
if (productType === 'pbs') {
|
||||
const usageResult = await this.getWithRetry(server, session, '/api2/json/status/datastore-usage');
|
||||
if (!usageResult.ok) {
|
||||
return {
|
||||
reachable: false,
|
||||
errorKind: usageResult.errorKind,
|
||||
errorDetail: usageResult.errorDetail,
|
||||
metrics: null,
|
||||
rawSample: usageResult.body === null ? null : truncateRaw(usageResult.body),
|
||||
};
|
||||
}
|
||||
|
||||
const storeNames = listPbsDatastoreNames(usageResult.body).slice(0, PBS_SNAPSHOT_QUERY_CAP);
|
||||
const snapshotsByStore: Record<string, unknown> = {};
|
||||
for (const storeName of storeNames) {
|
||||
const snapResult = await this.getWithRetry(
|
||||
server,
|
||||
session,
|
||||
`/api2/json/admin/datastore/${encodeURIComponent(storeName)}/snapshots`,
|
||||
);
|
||||
if (snapResult.ok) {
|
||||
snapshotsByStore[storeName] = snapResult.body;
|
||||
}
|
||||
}
|
||||
|
||||
const normalized = normalizePbs(usageResult.body, snapshotsByStore);
|
||||
const rawSample = truncateRaw({ usage: usageResult.body, snapshots: snapshotsByStore });
|
||||
if (normalized.errorKind) {
|
||||
return {
|
||||
reachable: false,
|
||||
errorKind: normalized.errorKind,
|
||||
errorDetail: 'Die Antwort hatte nicht die erwartete Form.',
|
||||
metrics: null,
|
||||
rawSample,
|
||||
};
|
||||
}
|
||||
return {
|
||||
reachable: true,
|
||||
errorKind: null,
|
||||
errorDetail: null,
|
||||
metrics: normalized.metrics,
|
||||
rawSample,
|
||||
};
|
||||
}
|
||||
|
||||
// pmg
|
||||
const result = await this.getWithRetry(server, session, '/api2/json/statistics/mail');
|
||||
if (!result.ok) {
|
||||
return {
|
||||
reachable: false,
|
||||
errorKind: result.errorKind,
|
||||
errorDetail: result.errorDetail,
|
||||
metrics: null,
|
||||
rawSample: result.body === null ? null : truncateRaw(result.body),
|
||||
};
|
||||
}
|
||||
const normalized = normalizePmg(result.body);
|
||||
if (normalized.errorKind) {
|
||||
return {
|
||||
reachable: false,
|
||||
errorKind: normalized.errorKind,
|
||||
errorDetail: 'Die Antwort hatte nicht die erwartete Form.',
|
||||
metrics: null,
|
||||
rawSample: truncateRaw(result.body),
|
||||
};
|
||||
}
|
||||
return {
|
||||
reachable: true,
|
||||
errorKind: null,
|
||||
errorDetail: null,
|
||||
metrics: normalized.metrics,
|
||||
rawSample: truncateRaw(result.body),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fragt genau einen Server ab und schreibt das Ergebnis ins Zwischenlager.
|
||||
* Liefert `null`, wenn der Server unter diesem Mandanten nicht existiert.
|
||||
*
|
||||
* Zehn-Sekunden-Sperre (Aufgabe 4, `<behavior>`, T-DHH-06): ein zweiter
|
||||
* Durchlauf innerhalb von zehn Sekunden nach dem letzten fragt Proxmox
|
||||
* NICHT erneut, sondern liefert den vorhandenen Zwischenlagerstand —
|
||||
* Schutz davor, dass ein Klick in der Oberflaeche zu ungebremsten
|
||||
* Anfragen gegen die Fremd-API wird.
|
||||
*/
|
||||
async pollServer(tenantId: string, serverId: string): Promise<ProxmoxPollResult | null> {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
const server = await tenantPrisma.proxmoxServer.findUnique({
|
||||
where: { id: serverId },
|
||||
include: { status: true },
|
||||
});
|
||||
if (!server || server.tenantId !== tenantId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cachedStatus = server.status;
|
||||
if (cachedStatus?.lastPolledAt) {
|
||||
const ageMs = Date.now() - cachedStatus.lastPolledAt.getTime();
|
||||
if (ageMs < POLL_LOCK_MS) {
|
||||
return {
|
||||
reachable: cachedStatus.reachable,
|
||||
errorKind: cachedStatus.errorKind as ProxmoxErrorKind | null,
|
||||
errorDetail: cachedStatus.errorDetail,
|
||||
metrics: cachedStatus.metrics as ProxmoxPollResult['metrics'],
|
||||
rawSample: cachedStatus.rawSample,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const result = await this.pollOne(server);
|
||||
const now = new Date();
|
||||
|
||||
await tenantPrisma.proxmoxServerStatus.upsert({
|
||||
where: { serverId },
|
||||
create: {
|
||||
serverId,
|
||||
tenantId,
|
||||
lastPolledAt: now,
|
||||
lastOkAt: result.reachable ? now : null,
|
||||
reachable: result.reachable,
|
||||
errorKind: result.errorKind,
|
||||
errorDetail: result.errorDetail,
|
||||
metrics: result.metrics as never,
|
||||
rawSample: result.rawSample as never,
|
||||
},
|
||||
update: {
|
||||
lastPolledAt: now,
|
||||
...(result.reachable ? { lastOkAt: now } : {}),
|
||||
reachable: result.reachable,
|
||||
errorKind: result.errorKind,
|
||||
errorDetail: result.errorDetail,
|
||||
metrics: result.metrics as never,
|
||||
rawSample: result.rawSample as never,
|
||||
},
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mischt Formularwerte (`dto`, ungespeichert) mit dem gespeicherten Server
|
||||
* (`existing`, `null` bei der Neuanlage) zu genau den Feldern, die eine
|
||||
* Abfrage braucht (Nachbesserung Befund 1). Zwei Regeln, je nachdem, ob
|
||||
* das Formular das Feld beim Laden vorbefuellt (Vorbild `serverToForm`):
|
||||
*
|
||||
* - Normale Felder (`productType`, `baseUrl`, `authMethod`, `tokenId`,
|
||||
* `username`, `tlsRejectUnauthorized`): das Formular zeigt immer den
|
||||
* zuletzt gespeicherten Wert an, bis der Nutzer ihn aendert — ein vom
|
||||
* Aufrufer GESENDETES Feld gilt also als Formularwert, auch wenn es
|
||||
* absichtlich geleert wurde (`tokenId: ''` -> `null`). Nur ein NICHT
|
||||
* gesendetes Feld (Aufrufer ohne diesen Schluessel im Body) faellt auf
|
||||
* den gespeicherten Wert zurueck.
|
||||
* - Geheimnisfelder (`tokenSecret`/`password`): `ServerForm` befuellt
|
||||
* diese beim Laden bewusst NIE aus der Datenbank (Geheimnis nie im
|
||||
* Klartext anzeigen). Ein leeres Feld bedeutet hier deshalb NICHT
|
||||
* "Nutzer will loeschen", sondern "Nutzer hat nichts eingetippt" ->
|
||||
* gespeicherten (verschluesselten) Wert weiterverwenden. Ein gefuelltes
|
||||
* Feld ist der eingetippte Klartext und wird unveraendert durchgereicht;
|
||||
* `decryptSecret()` erkennt anhand der Form `iv:authTag:ciphertext`
|
||||
* automatisch, ob entschluesselt werden muss, und laesst Klartext sonst
|
||||
* unangetastet.
|
||||
*/
|
||||
private resolveEffectiveTestServer(
|
||||
existing: ProxmoxServer | null,
|
||||
dto: TestProxmoxServerDto,
|
||||
): ProxmoxCredentialSource {
|
||||
return {
|
||||
productType: dto.productType ?? existing?.productType ?? 'pve',
|
||||
baseUrl: dto.baseUrl ?? existing?.baseUrl ?? '',
|
||||
authMethod: dto.authMethod ?? existing?.authMethod ?? 'token',
|
||||
tokenId: dto.tokenId !== undefined ? dto.tokenId || null : (existing?.tokenId ?? null),
|
||||
encryptedTokenSecret: dto.tokenSecret
|
||||
? dto.tokenSecret
|
||||
: (existing?.encryptedTokenSecret ?? null),
|
||||
username: dto.username !== undefined ? dto.username || null : (existing?.username ?? null),
|
||||
encryptedPassword: dto.password ? dto.password : (existing?.encryptedPassword ?? null),
|
||||
tlsRejectUnauthorized: dto.tlsRejectUnauthorized ?? existing?.tlsRejectUnauthorized ?? true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Verbindungstest fuer einen GESPEICHERTEN Server (Aufgabe 4, `<behavior>`,
|
||||
* `POST servers/:id/test`; Nachbesserung Befund 1: prueft jetzt die
|
||||
* Formularwerte aus `dto`, nicht mehr blind den gespeicherten Stand).
|
||||
* Benutzt denselben Klienten und dieselbe Fehleruebersetzung wie der
|
||||
* Planer, schreibt aber NICHT ins Zwischenlager — ein Testklick darf den
|
||||
* zuletzt gemessenen Stand nicht ueberschreiben (Vorbild
|
||||
* `TenderEmailConfigService.testConnection`/LDAP-Test). Keine
|
||||
* Zehn-Sekunden-Sperre: ein Test ist ein bewusster Einzelklick, kein
|
||||
* automatisierter Auffrischungsweg.
|
||||
*/
|
||||
async testConnection(
|
||||
tenantId: string,
|
||||
serverId: string,
|
||||
dto: TestProxmoxServerDto = {},
|
||||
): Promise<ProxmoxPollResult | null> {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
const server = await tenantPrisma.proxmoxServer.findUnique({ where: { id: serverId } });
|
||||
if (!server || server.tenantId !== tenantId) {
|
||||
return null;
|
||||
}
|
||||
return this.pollOne(this.resolveEffectiveTestServer(server, dto));
|
||||
}
|
||||
|
||||
/**
|
||||
* Verbindungstest waehrend der Neuanlage (Nachbesserung Befund 1,
|
||||
* `POST servers/test`, ohne `:id`) — es gibt noch keinen gespeicherten
|
||||
* Server, also ausschliesslich die Formularwerte aus `dto`. Fehlende
|
||||
* Pflichtangaben (z. B. kein Geheimnis) fuehren zum selben Fehlerschluessel
|
||||
* wie beim Abfragen eines gespeicherten Servers ohne Zugang (`zugang`).
|
||||
*/
|
||||
async testDraftConnection(dto: TestProxmoxServerDto): Promise<ProxmoxPollResult> {
|
||||
return this.pollOne(this.resolveEffectiveTestServer(null, dto));
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktive Server-IDs eines Mandanten fuer den Planer-Tick (gebunden).
|
||||
*/
|
||||
async listActiveServerIdsForTenant(tenantId: string): Promise<string[]> {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
const rows = await tenantPrisma.proxmoxServer.findMany({
|
||||
where: { tenantId, isActive: true },
|
||||
select: { id: true },
|
||||
});
|
||||
return rows.map((r) => r.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Abfrageintervalle der aktiven Server eines Mandanten (gebunden) — der
|
||||
* Controller ruft dies nach jedem Anlegen/Speichern, um den Planer
|
||||
* sofort nachzuziehen (`ProxmoxSchedulerService.refreshTenant`).
|
||||
*/
|
||||
async loadActiveServersForTenantScheduling(
|
||||
tenantId: string,
|
||||
): Promise<{ pollIntervalMin: number }[]> {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
return tenantPrisma.proxmoxServer.findMany({
|
||||
where: { tenantId, isActive: true },
|
||||
select: { pollIntervalMin: true },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Startpfad des Planers — der EINZIGE Systemkontext-Aufruf dieses Moduls
|
||||
* (`FORSYSTEM_ALLOWED_CALL_SITES`, `rls-access-inventory.spec.ts`, Aufgabe 4):
|
||||
* `const systemPrisma = forSystem(this.prisma);`, nur lesend, OHNE
|
||||
* `include` auf das Zwischenlager — die Zwischenlagertabelle hat bewusst
|
||||
* keine Systemlese-Regel, das Nachziehen laeuft je Zeile gebunden
|
||||
* (Muster `DkvSchedulerService`/`DashboardImagesService`, einmal lesen,
|
||||
* viele bedienen).
|
||||
*/
|
||||
async loadActiveServersForScheduler(): Promise<
|
||||
{ id: string; tenantId: string; pollIntervalMin: number }[]
|
||||
> {
|
||||
const systemPrisma = forSystem(this.prisma);
|
||||
return systemPrisma.proxmoxServer.findMany({
|
||||
where: { isActive: true },
|
||||
select: { id: true, tenantId: true, pollIntervalMin: true },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Gemeinsame Typen des Proxmox-Moduls (260923-dhh). Diese Datei enthaelt
|
||||
* ausschliesslich Typen — keine Logik, kein Prisma-Bezug — und wird von
|
||||
* `proxmox-auth.ts`, `proxmox-client.service.ts`, `proxmox-normalize.ts`
|
||||
* und `proxmox.service.ts` gleichermassen gelesen.
|
||||
*/
|
||||
|
||||
/** Drei Proxmox-Produkte, die dieses Modul beobachtet (D-01). */
|
||||
export type ProxmoxProductType = 'pve' | 'pbs' | 'pmg';
|
||||
|
||||
/** PMG kennt nur `password` (Recherche, Annahme A1) — DTO lehnt `token` fuer PMG ab. */
|
||||
export type ProxmoxAuthMethod = 'token' | 'password';
|
||||
|
||||
/**
|
||||
* Sieben stabile Fehlerschluessel. Sie landen so in der Datenbank
|
||||
* (`ProxmoxServerStatus.errorKind`) und werden ERST im Frontend uebersetzt
|
||||
* (`proxmox.errors.*`) — stabile Schluessel, uebersetzbarer Text (D-06).
|
||||
* Eine Erweiterung dieser Liste ist eine bewusste Entscheidung, keine
|
||||
* beilaeufige — siehe `proxmox-nur-lesen.spec.ts` fuer den maschinellen
|
||||
* Riegel auf D-01, der denselben Gedanken fuer den Anfrageweg durchsetzt.
|
||||
*/
|
||||
export type ProxmoxErrorKind =
|
||||
| 'netz'
|
||||
| 'zugang'
|
||||
| 'rechte'
|
||||
| 'zertifikat'
|
||||
| 'antwortform'
|
||||
| 'server'
|
||||
| 'unbekannt';
|
||||
|
||||
/** Ergebnis EINES Abfragedurchlaufs — was `proxmox.service.ts` ins Zwischenlager schreibt. */
|
||||
export interface ProxmoxPollResult {
|
||||
reachable: boolean;
|
||||
errorKind: ProxmoxErrorKind | null;
|
||||
errorDetail: string | null;
|
||||
metrics: ProxmoxMetrics | null;
|
||||
rawSample: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Messwertform je Produkt (Aufgabe 3 fuellt `pbs`/`pmg`; hier bereits als
|
||||
* unterscheidbare Union angelegt, damit das Frontend ab Aufgabe 6 ueber
|
||||
* `productType` typsicher verzweigen kann, D-Recherche "unterscheidbare Union").
|
||||
*/
|
||||
export type ProxmoxMetrics = ProxmoxPveMetrics | ProxmoxPbsMetrics | ProxmoxPmgMetrics;
|
||||
|
||||
export interface ProxmoxPveNodeMetric {
|
||||
node: string;
|
||||
cpu: number | null; // Anteil 0..1
|
||||
maxcpu: number | null;
|
||||
mem: number | null; // Bytes
|
||||
maxmem: number | null;
|
||||
}
|
||||
|
||||
export interface ProxmoxPveStorageMetric {
|
||||
storage: string;
|
||||
node: string;
|
||||
disk: number | null;
|
||||
maxdisk: number | null;
|
||||
}
|
||||
|
||||
export interface ProxmoxPveMetrics {
|
||||
productType: 'pve';
|
||||
nodeCount: number;
|
||||
guestsRunning: number;
|
||||
guestsStopped: number;
|
||||
nodes: ProxmoxPveNodeMetric[];
|
||||
storages: ProxmoxPveStorageMetric[];
|
||||
}
|
||||
|
||||
export interface ProxmoxPbsDatastoreMetric {
|
||||
name: string;
|
||||
total: number | null;
|
||||
used: number | null;
|
||||
free: number | null;
|
||||
lastBackupAt: number | null; // Unix-Sekunden, wie Proxmox sie liefert
|
||||
lastVerifyState: string | null;
|
||||
}
|
||||
|
||||
export interface ProxmoxPbsMetrics {
|
||||
productType: 'pbs';
|
||||
datastores: ProxmoxPbsDatastoreMetric[];
|
||||
}
|
||||
|
||||
export interface ProxmoxPmgMetrics {
|
||||
productType: 'pmg';
|
||||
countIn: number | null;
|
||||
countOut: number | null;
|
||||
spamCount: number | null;
|
||||
virusCount: number | null;
|
||||
}
|
||||
@@ -11,6 +11,7 @@
|
||||
"lint": "biome lint ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@tessera/shared": "workspace:*",
|
||||
"@uiw/react-md-editor": "4.1.1",
|
||||
"fflate": "^0.8.3",
|
||||
"html-to-image": "1.11.13",
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
import { cleanup, render, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { ProxmoxServer } from '@/lib/proxmox-api';
|
||||
|
||||
vi.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string, params?: Record<string, string | number>) => {
|
||||
const translations: Record<string, string> = {
|
||||
'card.unknownValue': 'unbekannt',
|
||||
'card.lastPolledLabel': 'Letzte Abfrage',
|
||||
'card.notPolledYet': 'Noch keine Abfrage gelaufen. Klicken Sie oben auf „{refreshLabel}“.',
|
||||
'card.notPolledYetAutomatic':
|
||||
'Noch keine Abfrage gelaufen. Die Werte erscheinen nach der nächsten automatischen Abfrage.',
|
||||
'card.refresh': 'Jetzt aktualisieren',
|
||||
'card.lastOkLabel': 'Letzte erfolgreiche Messung',
|
||||
'card.pve.nodeCount': 'Knoten',
|
||||
'card.pve.guests': '{running} laufend / {stopped} gestoppt',
|
||||
'card.pve.cpu': 'Prozessorlast',
|
||||
'card.pve.mem': 'Speicher',
|
||||
'card.pbs.used': 'Belegt',
|
||||
'card.pbs.lastBackup': 'Letzte Sicherung',
|
||||
'card.pbs.noBackupYet': 'noch keine Sicherung',
|
||||
'card.pbs.verifyState': 'Letzte Prüfung',
|
||||
'card.pmg.countIn': 'Eingehend',
|
||||
'card.pmg.countOut': 'Ausgehend',
|
||||
'card.pmg.spamCount': 'Spam',
|
||||
'card.pmg.virusCount': 'Viren',
|
||||
'errors.netz': 'Der Server ist nicht erreichbar.',
|
||||
'errors.zugang': 'Der Zugang wurde abgelehnt.',
|
||||
'errors.rechte': 'Die Rechte reichen nicht aus.',
|
||||
'errors.zertifikat': 'Das Zertifikat wurde abgelehnt.',
|
||||
'errors.antwortform': 'Unerwartete Antwortform.',
|
||||
'errors.server': 'Serverfehler.',
|
||||
'errors.unbekannt': 'Unerwarteter Fehler.',
|
||||
};
|
||||
let result = translations[key] ?? key;
|
||||
if (params) {
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
result = result.replace(`{${k}}`, String(v));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
},
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
function makeServer(overrides: Partial<ProxmoxServer> = {}): ProxmoxServer {
|
||||
return {
|
||||
id: 'srv-1',
|
||||
tenantId: 't1',
|
||||
name: 'pve-1',
|
||||
productType: 'pve',
|
||||
baseUrl: 'https://pve.intern',
|
||||
authMethod: 'token',
|
||||
tokenId: 'root@pam!x',
|
||||
username: null,
|
||||
tlsRejectUnauthorized: true,
|
||||
isActive: true,
|
||||
pollIntervalMin: 5,
|
||||
position: 0,
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
status: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('ServerCard', () => {
|
||||
it('PVE-Server zeigt Knotenzahl, laufende/gestoppte Gaeste sowie je Knoten Prozessorlast und Speicherbelegung', async () => {
|
||||
const { ServerCard } = await import('./ServerCard');
|
||||
const server = makeServer({
|
||||
status: {
|
||||
id: 's1',
|
||||
serverId: 'srv-1',
|
||||
lastPolledAt: '2026-09-23T10:00:00.000Z',
|
||||
lastOkAt: '2026-09-23T10:00:00.000Z',
|
||||
reachable: true,
|
||||
errorKind: null,
|
||||
errorDetail: null,
|
||||
rawSample: null,
|
||||
updatedAt: '2026-09-23T10:00:00.000Z',
|
||||
metrics: {
|
||||
productType: 'pve',
|
||||
nodeCount: 2,
|
||||
guestsRunning: 3,
|
||||
guestsStopped: 1,
|
||||
nodes: [
|
||||
{ node: 'pve1', cpu: 0.25, maxcpu: 8, mem: 4_294_967_296, maxmem: 8_589_934_592 },
|
||||
],
|
||||
storages: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
render(<ServerCard server={server} />);
|
||||
|
||||
expect(screen.getByText(/Knoten:/)).toHaveTextContent('Knoten: 2');
|
||||
expect(screen.getByText(/laufend/)).toHaveTextContent('3 laufend / 1 gestoppt');
|
||||
expect(screen.getByText(/Prozessorlast/)).toHaveTextContent('Prozessorlast: 25%');
|
||||
expect(screen.getByText(/Speicher:/)).toHaveTextContent('4.0 GB / 8.0 GB');
|
||||
});
|
||||
|
||||
it('PBS-Server zeigt je Datenspeicher Belegung, letzte Sicherung und Ergebnis der letzten Pruefung', async () => {
|
||||
const { ServerCard } = await import('./ServerCard');
|
||||
const server = makeServer({
|
||||
productType: 'pbs',
|
||||
status: {
|
||||
id: 's1',
|
||||
serverId: 'srv-1',
|
||||
lastPolledAt: '2026-09-23T10:00:00.000Z',
|
||||
lastOkAt: '2026-09-23T10:00:00.000Z',
|
||||
reachable: true,
|
||||
errorKind: null,
|
||||
errorDetail: null,
|
||||
rawSample: null,
|
||||
updatedAt: '2026-09-23T10:00:00.000Z',
|
||||
metrics: {
|
||||
productType: 'pbs',
|
||||
datastores: [
|
||||
{ name: 'backup-store', total: 1000, used: 400, free: 600, lastBackupAt: 1758000000, lastVerifyState: 'ok' },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
render(<ServerCard server={server} />);
|
||||
|
||||
expect(screen.getByText('backup-store')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Belegt:/)).toHaveTextContent('400 B / 1000 B');
|
||||
expect(screen.getByText(/Letzte Prüfung/)).toHaveTextContent('ok');
|
||||
});
|
||||
|
||||
it('PBS-Datenspeicher ohne Sicherung zeigt "noch keine Sicherung"', async () => {
|
||||
const { ServerCard } = await import('./ServerCard');
|
||||
const server = makeServer({
|
||||
productType: 'pbs',
|
||||
status: {
|
||||
id: 's1',
|
||||
serverId: 'srv-1',
|
||||
lastPolledAt: '2026-09-23T10:00:00.000Z',
|
||||
lastOkAt: null,
|
||||
reachable: true,
|
||||
errorKind: null,
|
||||
errorDetail: null,
|
||||
rawSample: null,
|
||||
updatedAt: '2026-09-23T10:00:00.000Z',
|
||||
metrics: {
|
||||
productType: 'pbs',
|
||||
datastores: [
|
||||
{ name: 'leer', total: 10, used: 0, free: 10, lastBackupAt: null, lastVerifyState: null },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
render(<ServerCard server={server} />);
|
||||
|
||||
expect(screen.getByText(/Letzte Sicherung/)).toHaveTextContent('noch keine Sicherung');
|
||||
expect(screen.getByText(/Letzte Prüfung/)).toHaveTextContent('unbekannt');
|
||||
});
|
||||
|
||||
it('PMG-Server zeigt die Tageszahlen eingehend, ausgehend, Spam und Viren', async () => {
|
||||
const { ServerCard } = await import('./ServerCard');
|
||||
const server = makeServer({
|
||||
productType: 'pmg',
|
||||
status: {
|
||||
id: 's1',
|
||||
serverId: 'srv-1',
|
||||
lastPolledAt: '2026-09-23T10:00:00.000Z',
|
||||
lastOkAt: '2026-09-23T10:00:00.000Z',
|
||||
reachable: true,
|
||||
errorKind: null,
|
||||
errorDetail: null,
|
||||
rawSample: null,
|
||||
updatedAt: '2026-09-23T10:00:00.000Z',
|
||||
metrics: { productType: 'pmg', countIn: 120, countOut: 45, spamCount: 12, virusCount: 0 },
|
||||
},
|
||||
});
|
||||
|
||||
render(<ServerCard server={server} />);
|
||||
|
||||
expect(screen.getByText(/Eingehend:/)).toHaveTextContent('120');
|
||||
expect(screen.getByText(/Ausgehend:/)).toHaveTextContent('45');
|
||||
expect(screen.getByText(/Spam:/)).toHaveTextContent('12');
|
||||
expect(screen.getByText(/Viren:/)).toHaveTextContent('0');
|
||||
});
|
||||
|
||||
it('ein Messwert, der null ist, erscheint als "unbekannt" — nie als 0, leer oder NaN', async () => {
|
||||
const { ServerCard } = await import('./ServerCard');
|
||||
const server = makeServer({
|
||||
productType: 'pmg',
|
||||
status: {
|
||||
id: 's1',
|
||||
serverId: 'srv-1',
|
||||
lastPolledAt: '2026-09-23T10:00:00.000Z',
|
||||
lastOkAt: null,
|
||||
reachable: true,
|
||||
errorKind: null,
|
||||
errorDetail: null,
|
||||
rawSample: null,
|
||||
updatedAt: '2026-09-23T10:00:00.000Z',
|
||||
metrics: { productType: 'pmg', countIn: null, countOut: null, spamCount: null, virusCount: null },
|
||||
},
|
||||
});
|
||||
|
||||
render(<ServerCard server={server} />);
|
||||
|
||||
expect(screen.getByText(/Eingehend:/)).toHaveTextContent('unbekannt');
|
||||
expect(screen.queryByText(/Eingehend: 0/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/NaN/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('ein Server mit reachable:false zeigt den Klartext der Ursache und den Zeitpunkt der letzten erfolgreichen Messung', async () => {
|
||||
const { ServerCard } = await import('./ServerCard');
|
||||
const server = makeServer({
|
||||
status: {
|
||||
id: 's1',
|
||||
serverId: 'srv-1',
|
||||
lastPolledAt: '2026-09-23T10:05:00.000Z',
|
||||
lastOkAt: '2026-09-23T09:00:00.000Z',
|
||||
reachable: false,
|
||||
errorKind: 'zugang',
|
||||
errorDetail: null,
|
||||
rawSample: null,
|
||||
updatedAt: '2026-09-23T10:05:00.000Z',
|
||||
metrics: null,
|
||||
},
|
||||
});
|
||||
|
||||
render(<ServerCard server={server} />);
|
||||
|
||||
expect(screen.getByText('Der Zugang wurde abgelehnt.')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Letzte erfolgreiche Messung/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('der Zeitpunkt der letzten Abfrage steht bei jedem Server', async () => {
|
||||
const { ServerCard } = await import('./ServerCard');
|
||||
const server = makeServer({
|
||||
status: {
|
||||
id: 's1',
|
||||
serverId: 'srv-1',
|
||||
lastPolledAt: '2026-09-23T10:00:00.000Z',
|
||||
lastOkAt: null,
|
||||
reachable: true,
|
||||
errorKind: null,
|
||||
errorDetail: null,
|
||||
rawSample: null,
|
||||
updatedAt: '2026-09-23T10:00:00.000Z',
|
||||
metrics: null,
|
||||
},
|
||||
});
|
||||
|
||||
render(<ServerCard server={server} />);
|
||||
|
||||
expect(screen.getByText(/Letzte Abfrage/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Nachbesserung Befund 2: ein frisch angelegter, noch nie abgefragter Server zeigt Admins den ruhigen Hinweis mit Knopfverweis statt "Ein unerwarteter Fehler ist aufgetreten"', async () => {
|
||||
const { ServerCard } = await import('./ServerCard');
|
||||
// Zustand direkt nach `createServer`: leere Zwischenlagerzeile, noch nie abgefragt.
|
||||
const server = makeServer({
|
||||
status: {
|
||||
id: 's1',
|
||||
serverId: 'srv-1',
|
||||
lastPolledAt: null,
|
||||
lastOkAt: null,
|
||||
reachable: false,
|
||||
errorKind: null,
|
||||
errorDetail: null,
|
||||
rawSample: null,
|
||||
updatedAt: '2026-09-23T10:00:00.000Z',
|
||||
metrics: null,
|
||||
},
|
||||
});
|
||||
|
||||
render(<ServerCard server={server} isAdmin />);
|
||||
|
||||
expect(
|
||||
screen.getByText('Noch keine Abfrage gelaufen. Klicken Sie oben auf „Jetzt aktualisieren“.'),
|
||||
).toBeInTheDocument();
|
||||
// Ohne die Korrektur erschiene hier faelschlich die Sammelmeldung — roter Test.
|
||||
expect(screen.queryByText('Unerwarteter Fehler.')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('260923-le6: isAdmin={false}, noch nie abgefragt -> automatischer Hinweis ohne Knopfverweis', async () => {
|
||||
const { ServerCard } = await import('./ServerCard');
|
||||
const server = makeServer({
|
||||
status: {
|
||||
id: 's1',
|
||||
serverId: 'srv-1',
|
||||
lastPolledAt: null,
|
||||
lastOkAt: null,
|
||||
reachable: false,
|
||||
errorKind: null,
|
||||
errorDetail: null,
|
||||
rawSample: null,
|
||||
updatedAt: '2026-09-23T10:00:00.000Z',
|
||||
metrics: null,
|
||||
},
|
||||
});
|
||||
|
||||
render(<ServerCard server={server} isAdmin={false} />);
|
||||
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Noch keine Abfrage gelaufen. Die Werte erscheinen nach der nächsten automatischen Abfrage.',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Jetzt aktualisieren/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Unerwarteter Fehler.')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('260923-le6: isAdmin weggelassen, noch nie abgefragt -> verhaelt sich wie isAdmin={false} (sichere Vorgabe)', async () => {
|
||||
const { ServerCard } = await import('./ServerCard');
|
||||
const server = makeServer({
|
||||
status: {
|
||||
id: 's1',
|
||||
serverId: 'srv-1',
|
||||
lastPolledAt: null,
|
||||
lastOkAt: null,
|
||||
reachable: false,
|
||||
errorKind: null,
|
||||
errorDetail: null,
|
||||
rawSample: null,
|
||||
updatedAt: '2026-09-23T10:00:00.000Z',
|
||||
metrics: null,
|
||||
},
|
||||
});
|
||||
|
||||
render(<ServerCard server={server} />);
|
||||
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Noch keine Abfrage gelaufen. Die Werte erscheinen nach der nächsten automatischen Abfrage.',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Jetzt aktualisieren/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Unerwarteter Fehler.')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Nachbesserung Befund 3: die Adresse bleibt unveraendert dargestellt, nur das Produktkuerzel ist grossgeschrieben', async () => {
|
||||
const { ServerCard } = await import('./ServerCard');
|
||||
const server = makeServer({ baseUrl: 'https://172.21.0.1:8006' });
|
||||
|
||||
render(<ServerCard server={server} />);
|
||||
|
||||
const line = screen.getByText(/172\.21\.0\.1:8006/);
|
||||
expect(line).toHaveTextContent('pve — https://172.21.0.1:8006');
|
||||
const productSpan = line.querySelector('span');
|
||||
expect(productSpan).not.toBeNull();
|
||||
expect(productSpan).toHaveClass('uppercase');
|
||||
expect(line).not.toHaveClass('uppercase');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,213 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import type {
|
||||
ProxmoxErrorKind,
|
||||
ProxmoxPbsMetrics,
|
||||
ProxmoxPmgMetrics,
|
||||
ProxmoxPveMetrics,
|
||||
ProxmoxServer,
|
||||
} from '@/lib/proxmox-api';
|
||||
|
||||
/**
|
||||
* Wandelt einen rohen Byte-Wert in eine lesbare Form (B/KB/MB/GB/TB).
|
||||
*/
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
const units = ['KB', 'MB', 'GB', 'TB'];
|
||||
let value = bytes;
|
||||
let unitIndex = -1;
|
||||
do {
|
||||
value /= 1024;
|
||||
unitIndex++;
|
||||
} while (value >= 1024 && unitIndex < units.length - 1);
|
||||
return `${value.toFixed(1)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
/** Prozentwert einer Auslastung (0..1) gerundet, z. B. `25%`. */
|
||||
function formatPercent(fraction: number): string {
|
||||
return `${Math.round(fraction * 100)}%`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Die EINZIGE Stelle, die einen Messwert in Text verwandelt (Aufgabe 6):
|
||||
* ist der Wert `null`/`undefined`, erscheint der uebersetzte Text
|
||||
* "unbekannt" — sonst der Wert mit seiner Einheit. Dadurch kann kein Zweig
|
||||
* versehentlich eine `0` anzeigen, wo nichts gemessen wurde. Die Feldnamen
|
||||
* von PBS und PMG sind bis zur Pruefung am echten Server nur abgeleitet
|
||||
* (Recherche, Annahmen A2/A3/A5), und ein still falscher Wert waere
|
||||
* schlimmer als ein ehrliches "unbekannt".
|
||||
*/
|
||||
function formatMetric(
|
||||
value: number | string | null | undefined,
|
||||
unknownLabel: string,
|
||||
formatter?: (v: number) => string,
|
||||
): string {
|
||||
if (value === null || value === undefined) return unknownLabel;
|
||||
if (typeof value === 'number') {
|
||||
if (Number.isNaN(value)) return unknownLabel;
|
||||
return formatter ? formatter(value) : String(value);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function formatTimestamp(value: string | number | null | undefined, unknownLabel: string): string {
|
||||
if (value === null || value === undefined) return unknownLabel;
|
||||
const date = typeof value === 'number' ? new Date(value * 1000) : new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return unknownLabel;
|
||||
return date.toLocaleString();
|
||||
}
|
||||
|
||||
interface MetricsViewProps {
|
||||
metrics: ProxmoxPveMetrics | ProxmoxPbsMetrics | ProxmoxPmgMetrics;
|
||||
unknown: string;
|
||||
}
|
||||
|
||||
function PveMetricsView({ metrics, unknown }: { metrics: ProxmoxPveMetrics; unknown: string }) {
|
||||
const t = useTranslations('proxmox');
|
||||
return (
|
||||
<div className="space-y-2 text-sm">
|
||||
<div>
|
||||
{t('card.pve.nodeCount')}: {metrics.nodeCount}
|
||||
</div>
|
||||
<div>
|
||||
{t('card.pve.guests', { running: metrics.guestsRunning, stopped: metrics.guestsStopped })}
|
||||
</div>
|
||||
{metrics.nodes.map((node) => (
|
||||
<div key={node.node} className="rounded border border-border/60 px-2 py-1 text-xs">
|
||||
<div className="font-medium">{node.node}</div>
|
||||
<div>
|
||||
{t('card.pve.cpu')}: {formatMetric(node.cpu, unknown, formatPercent)}
|
||||
{' · '}
|
||||
{t('card.pve.mem')}: {formatMetric(node.mem, unknown, formatBytes)}
|
||||
{node.maxmem !== null && node.mem !== null
|
||||
? ` / ${formatMetric(node.maxmem, unknown, formatBytes)}`
|
||||
: ''}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PbsMetricsView({ metrics, unknown }: { metrics: ProxmoxPbsMetrics; unknown: string }) {
|
||||
const t = useTranslations('proxmox');
|
||||
return (
|
||||
<div className="space-y-2 text-sm">
|
||||
{metrics.datastores.map((ds) => (
|
||||
<div key={ds.name} className="rounded border border-border/60 px-2 py-1 text-xs">
|
||||
<div className="font-medium">{ds.name}</div>
|
||||
<div>
|
||||
{t('card.pbs.used')}: {formatMetric(ds.used, unknown, formatBytes)}
|
||||
{ds.total !== null && ds.used !== null ? ` / ${formatMetric(ds.total, unknown, formatBytes)}` : ''}
|
||||
</div>
|
||||
<div>
|
||||
{t('card.pbs.lastBackup')}:{' '}
|
||||
{ds.lastBackupAt === null ? t('card.pbs.noBackupYet') : formatTimestamp(ds.lastBackupAt, unknown)}
|
||||
</div>
|
||||
<div>
|
||||
{t('card.pbs.verifyState')}: {formatMetric(ds.lastVerifyState, unknown)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PmgMetricsView({ metrics, unknown }: { metrics: ProxmoxPmgMetrics; unknown: string }) {
|
||||
const t = useTranslations('proxmox');
|
||||
return (
|
||||
<div className="space-y-1 text-sm">
|
||||
<div>
|
||||
{t('card.pmg.countIn')}: {formatMetric(metrics.countIn, unknown)}
|
||||
</div>
|
||||
<div>
|
||||
{t('card.pmg.countOut')}: {formatMetric(metrics.countOut, unknown)}
|
||||
</div>
|
||||
<div>
|
||||
{t('card.pmg.spamCount')}: {formatMetric(metrics.spamCount, unknown)}
|
||||
</div>
|
||||
<div>
|
||||
{t('card.pmg.virusCount')}: {formatMetric(metrics.virusCount, unknown)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricsView({ metrics, unknown }: MetricsViewProps) {
|
||||
if (metrics.productType === 'pve') return <PveMetricsView metrics={metrics} unknown={unknown} />;
|
||||
if (metrics.productType === 'pbs') return <PbsMetricsView metrics={metrics} unknown={unknown} />;
|
||||
return <PmgMetricsView metrics={metrics} unknown={unknown} />;
|
||||
}
|
||||
|
||||
function errorMessage(t: ReturnType<typeof useTranslations>, kind: ProxmoxErrorKind | null): string {
|
||||
return t(`errors.${kind ?? 'unbekannt'}`);
|
||||
}
|
||||
|
||||
interface ServerCardProps {
|
||||
server: ProxmoxServer;
|
||||
isAdmin?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Anzeige EINES Servers (Aufgabe 6) — verzweigt ueber `productType` auf der
|
||||
* unterscheidbaren Union aus Aufgabe 3.
|
||||
*/
|
||||
export function ServerCard({ server, isAdmin = false }: ServerCardProps) {
|
||||
const t = useTranslations('proxmox');
|
||||
const unknown = t('card.unknownValue');
|
||||
const status = server.status;
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-card p-4 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-medium">{server.name}</div>
|
||||
{/* Nachbesserung Befund 3: `uppercase` gilt nur dem Produktkuerzel, nicht der Adresse. */}
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<span className="uppercase">{server.productType}</span> — {server.baseUrl}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 text-xs text-muted-foreground">
|
||||
{t('card.lastPolledLabel')}: {formatTimestamp(status?.lastPolledAt ?? null, unknown)}
|
||||
</div>
|
||||
|
||||
{/*
|
||||
Nachbesserung Befund 2: ein Server, der noch nie abgefragt wurde
|
||||
(`lastPolledAt === null`), zeigt einen ruhigen Hinweis statt der
|
||||
Fehlermeldung — die leere Zwischenlagerzeile aus `createServer` hat
|
||||
`reachable: false` und `errorKind: null`, was sonst faelschlich als
|
||||
"unbekannter Fehler" erschien. Nicht-Admins sehen den Knopf nicht
|
||||
(der Poll-Endpunkt verlangt ADMIN/SUPER_ADMIN) und bekommen deshalb
|
||||
den Text ohne Knopfverweis (260923-le6).
|
||||
*/}
|
||||
{status && !status.lastPolledAt && (
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{isAdmin ? t('card.notPolledYet', { refreshLabel: t('card.refresh') }) : t('card.notPolledYetAutomatic')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{status?.lastPolledAt && !status.reachable && (
|
||||
<div className="mt-2 space-y-1 text-sm">
|
||||
<p className="text-destructive">
|
||||
{errorMessage(t, status.errorKind)}
|
||||
{status.errorDetail ? ` (${status.errorDetail})` : ''}
|
||||
</p>
|
||||
{status.lastOkAt && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('card.lastOkLabel')}: {formatTimestamp(status.lastOkAt, unknown)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status?.reachable && status.metrics && (
|
||||
<div className="mt-3">
|
||||
<MetricsView metrics={status.metrics} unknown={unknown} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { ModuleAccessGate } from '@/components/modules/module-access-gate';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export default function ProxmoxLayout({ children }: { children: ReactNode }) {
|
||||
return <ModuleAccessGate moduleSlug="proxmox">{children}</ModuleAccessGate>;
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import Link from 'next/link';
|
||||
import { useAuthStore } from '@/lib/stores/auth-store';
|
||||
import { listServers, pollServer, type ProxmoxServer } from '@/lib/proxmox-api';
|
||||
import { ServerCard } from './components/ServerCard';
|
||||
|
||||
/**
|
||||
* Modulseite (Aufgabe 6) — liest ausschliesslich aus dem Zwischenlager, das
|
||||
* `GET servers` liefert; kein Live-Zugriff bei Proxmox von hier aus (D-05).
|
||||
* "Jetzt aktualisieren" loest je Server eine Abfrage aus und laedt die
|
||||
* Liste danach neu — waehrend des Laufs ist der Knopf gesperrt. Der Knopf
|
||||
* erscheint nur fuer Admins, weil `POST servers/:id/poll` `@Roles(ADMIN,
|
||||
* SUPER_ADMIN)` verlangt; fuer andere waere er wirkungslos (260923-le6).
|
||||
*/
|
||||
export default function ProxmoxPage() {
|
||||
const t = useTranslations('proxmox');
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const isAdmin = user?.role === 'ADMIN' || user?.role === 'SUPER_ADMIN';
|
||||
|
||||
const [servers, setServers] = useState<ProxmoxServer[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
|
||||
const reload = useCallback(() => {
|
||||
listServers()
|
||||
.then(setServers)
|
||||
.catch(() => setError(t('loadError')));
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
reload();
|
||||
}, [reload]);
|
||||
|
||||
const handleRefresh = async () => {
|
||||
if (!servers || servers.length === 0) return;
|
||||
setIsRefreshing(true);
|
||||
try {
|
||||
await Promise.all(servers.map((server) => pollServer(server.id).catch(() => undefined)));
|
||||
reload();
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl space-y-6 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">{t('title')}</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{t('description')}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{isAdmin && servers !== null && servers.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRefresh}
|
||||
disabled={isRefreshing}
|
||||
className="rounded border border-border px-4 py-2 text-sm text-foreground hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{isRefreshing ? t('card.refreshing') : t('card.refresh')}
|
||||
</button>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<Link
|
||||
href="/modules/proxmox/settings"
|
||||
className="text-sm text-primary hover:underline"
|
||||
>
|
||||
{t('card.settingsLink')}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
|
||||
{!error && servers === null && (
|
||||
<p className="text-sm text-muted-foreground">{t('loading')}</p>
|
||||
)}
|
||||
|
||||
{!error && servers !== null && servers.length === 0 && (
|
||||
<div className="rounded-lg border border-border bg-card p-6 text-sm text-muted-foreground shadow-sm">
|
||||
{t('emptyState')}
|
||||
{isAdmin && (
|
||||
<>
|
||||
{' '}
|
||||
<Link href="/modules/proxmox/settings" className="text-primary hover:underline">
|
||||
{t('card.settingsLink')}
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && servers !== null && servers.length > 0 && (
|
||||
<ul className="space-y-3">
|
||||
{servers.map((server) => (
|
||||
<li key={server.id}>
|
||||
<ServerCard server={server} isAdmin={isAdmin} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import { cleanup, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { ProxmoxServer } from '@/lib/proxmox-api';
|
||||
|
||||
// Mock @/lib/proxmox-api — die Seite ruft `listServers` beim Laden auf,
|
||||
// "Jetzt aktualisieren" ruft `pollServer` je Server.
|
||||
const mockListServers = vi.fn();
|
||||
const mockPollServer = vi.fn();
|
||||
|
||||
vi.mock('@/lib/proxmox-api', () => ({
|
||||
listServers: (...args: unknown[]) => mockListServers(...args),
|
||||
pollServer: (...args: unknown[]) => mockPollServer(...args),
|
||||
}));
|
||||
|
||||
// Mock the auth store — mirrors settings-roles.test.tsx (selector-passthrough).
|
||||
const mockAuthStore = vi.fn();
|
||||
vi.mock('@/lib/stores/auth-store', () => ({
|
||||
useAuthStore: (selector: (state: unknown) => unknown) => mockAuthStore(selector),
|
||||
}));
|
||||
|
||||
vi.mock('next/link', () => ({
|
||||
default: ({ href, children, ...rest }: { href: string; children: React.ReactNode }) => (
|
||||
<a href={href} {...rest}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string, params?: Record<string, string | number>) => {
|
||||
const translations: Record<string, string> = {
|
||||
title: 'Proxmox',
|
||||
description: 'Zustand Ihrer Proxmox-Server (PVE/PBS/PMG) auf einen Blick.',
|
||||
loading: 'Lade Serverliste...',
|
||||
loadError: 'Die Serverliste konnte nicht geladen werden.',
|
||||
emptyState:
|
||||
'Noch kein Server eingetragen. Legen Sie in den Moduleinstellungen einen Server an.',
|
||||
'card.refresh': 'Jetzt aktualisieren',
|
||||
'card.refreshing': 'Wird aktualisiert...',
|
||||
'card.settingsLink': 'Zu den Einstellungen',
|
||||
'card.unknownValue': 'unbekannt',
|
||||
'card.lastPolledLabel': 'Letzte Abfrage',
|
||||
'card.notPolledYet': 'Noch keine Abfrage gelaufen. Klicken Sie oben auf „{refreshLabel}“.',
|
||||
'card.notPolledYetAutomatic':
|
||||
'Noch keine Abfrage gelaufen. Die Werte erscheinen nach der nächsten automatischen Abfrage.',
|
||||
};
|
||||
let result = translations[key] ?? key;
|
||||
if (params) {
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
result = result.replace(`{${k}}`, String(v));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
},
|
||||
}));
|
||||
|
||||
function mockUser(user: { role: 'SUPER_ADMIN' | 'ADMIN' | 'USER' } | null) {
|
||||
mockAuthStore.mockImplementation((selector: (state: { user: typeof user }) => unknown) =>
|
||||
selector({ user }),
|
||||
);
|
||||
}
|
||||
|
||||
// Ein nie abgefragter Server (wie im Befund-2-Test von ServerCard.test.tsx):
|
||||
// leere Zwischenlagerzeile direkt nach `createServer`.
|
||||
function makeUnpolledServer(overrides: Partial<ProxmoxServer> = {}): ProxmoxServer {
|
||||
return {
|
||||
id: 'srv-1',
|
||||
tenantId: 't1',
|
||||
name: 'pve-1',
|
||||
productType: 'pve',
|
||||
baseUrl: 'https://pve.intern',
|
||||
authMethod: 'token',
|
||||
tokenId: 'root@pam!x',
|
||||
username: null,
|
||||
tlsRejectUnauthorized: true,
|
||||
isActive: true,
|
||||
pollIntervalMin: 5,
|
||||
position: 0,
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
status: {
|
||||
id: 's1',
|
||||
serverId: 'srv-1',
|
||||
lastPolledAt: null,
|
||||
lastOkAt: null,
|
||||
reachable: false,
|
||||
errorKind: null,
|
||||
errorDetail: null,
|
||||
rawSample: null,
|
||||
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
metrics: null,
|
||||
},
|
||||
...overrides,
|
||||
} as ProxmoxServer;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
mockListServers.mockReset();
|
||||
mockPollServer.mockReset();
|
||||
mockAuthStore.mockReset();
|
||||
});
|
||||
|
||||
describe('ProxmoxPage role gating (260923-le6)', () => {
|
||||
it('Rolle USER: kein Knopf "Jetzt aktualisieren", Karten-Hinweis ist der automatische Text', async () => {
|
||||
mockUser({ role: 'USER' });
|
||||
mockListServers.mockResolvedValue([makeUnpolledServer()]);
|
||||
|
||||
const { default: ProxmoxPage } = await import('./page');
|
||||
render(<ProxmoxPage />);
|
||||
|
||||
await screen.findByText('pve-1');
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Jetzt aktualisieren' })).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Noch keine Abfrage gelaufen. Die Werte erscheinen nach der nächsten automatischen Abfrage.',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('kein Benutzer geladen (user: null): kein Knopf', async () => {
|
||||
mockUser(null);
|
||||
mockListServers.mockResolvedValue([makeUnpolledServer()]);
|
||||
|
||||
const { default: ProxmoxPage } = await import('./page');
|
||||
render(<ProxmoxPage />);
|
||||
|
||||
await screen.findByText('pve-1');
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Jetzt aktualisieren' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Rolle ADMIN: Knopf sichtbar, Karten-Hinweis ist der Admin-Text mit Knopfverweis', async () => {
|
||||
mockUser({ role: 'ADMIN' });
|
||||
mockListServers.mockResolvedValue([makeUnpolledServer()]);
|
||||
|
||||
const { default: ProxmoxPage } = await import('./page');
|
||||
render(<ProxmoxPage />);
|
||||
|
||||
await screen.findByText('pve-1');
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Jetzt aktualisieren' })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Noch keine Abfrage gelaufen. Klicken Sie oben auf „Jetzt aktualisieren“.'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Rolle SUPER_ADMIN: Knopf sichtbar', async () => {
|
||||
mockUser({ role: 'SUPER_ADMIN' });
|
||||
mockListServers.mockResolvedValue([makeUnpolledServer()]);
|
||||
|
||||
const { default: ProxmoxPage } = await import('./page');
|
||||
render(<ProxmoxPage />);
|
||||
|
||||
await screen.findByText('pve-1');
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Jetzt aktualisieren' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('leere Serverliste bei ADMIN: weiterhin kein Knopf', async () => {
|
||||
mockUser({ role: 'ADMIN' });
|
||||
mockListServers.mockResolvedValue([]);
|
||||
|
||||
const { default: ProxmoxPage } = await import('./page');
|
||||
render(<ProxmoxPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Noch kein Server eingetragen. Legen Sie in den Moduleinstellungen einen Server an.',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Jetzt aktualisieren' })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,244 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const mockCreateServer = vi.fn();
|
||||
const mockUpdateServer = vi.fn();
|
||||
const mockTestServer = vi.fn();
|
||||
const mockTestDraftServer = vi.fn();
|
||||
|
||||
vi.mock('@/lib/proxmox-api', () => ({
|
||||
createServer: (...args: unknown[]) => mockCreateServer(...args),
|
||||
updateServer: (...args: unknown[]) => mockUpdateServer(...args),
|
||||
testServer: (...args: unknown[]) => mockTestServer(...args),
|
||||
testDraftServer: (...args: unknown[]) => mockTestDraftServer(...args),
|
||||
}));
|
||||
|
||||
vi.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string, params?: Record<string, string>) => {
|
||||
const translations: Record<string, string> = {
|
||||
'settings.nameLabel': 'Name',
|
||||
'settings.productTypeLabel': 'Typ',
|
||||
'settings.productTypePve': 'PVE',
|
||||
'settings.productTypePbs': 'PBS',
|
||||
'settings.productTypePmg': 'PMG',
|
||||
'settings.baseUrlLabel': 'Adresse',
|
||||
'settings.authMethodLabel': 'Zugangsart',
|
||||
'settings.authMethodToken': 'API-Token',
|
||||
'settings.authMethodPassword': 'Benutzer/Passwort',
|
||||
'settings.tokenIdLabel': 'Token-Kennung',
|
||||
'settings.tokenSecretLabel': 'Token-Geheimnis',
|
||||
'settings.usernameLabel': 'Benutzername',
|
||||
'settings.passwordLabel': 'Passwort',
|
||||
'settings.secretUnchangedPlaceholder': 'Leer lassen, um das gespeicherte Geheimnis beizubehalten',
|
||||
'settings.pollIntervalLabel': 'Abfrageintervall (Minuten)',
|
||||
'settings.tlsRejectLabel': 'Zertifikat prüfen',
|
||||
'settings.tlsRejectHint': 'Die Ausnahme gilt nur für diesen einen Server, niemals für alle Server gemeinsam.',
|
||||
'settings.activeLabel': 'Aktiv',
|
||||
'settings.save': 'Speichern',
|
||||
'settings.saving': 'Wird gespeichert...',
|
||||
'settings.saveError': 'Die Einstellungen konnten nicht gespeichert werden.',
|
||||
'settings.cancel': 'Abbrechen',
|
||||
'settings.testConnection': 'Verbindung testen',
|
||||
'settings.testTesting': 'Verbindung wird getestet...',
|
||||
'settings.testSuccess': 'Verbindung erfolgreich.',
|
||||
'errors.netz': 'Der Server ist nicht erreichbar.',
|
||||
'errors.zugang': 'Der Zugang wurde abgelehnt. Bitte prüfen Sie Benutzername und Passwort beziehungsweise die Token-Angaben.',
|
||||
'errors.rechte': 'Die Rechte reichen nicht aus.',
|
||||
'errors.zertifikat': 'Das Zertifikat wurde abgelehnt.',
|
||||
'errors.antwortform': 'Unerwartete Antwortform.',
|
||||
'errors.server': 'Serverfehler.',
|
||||
'errors.unbekannt': 'Unerwarteter Fehler.',
|
||||
};
|
||||
let result = translations[key] ?? key;
|
||||
if (params) {
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
result = result.replace(`{${k}}`, v);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
},
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
mockCreateServer.mockReset();
|
||||
mockUpdateServer.mockReset();
|
||||
mockTestServer.mockReset();
|
||||
mockTestDraftServer.mockReset();
|
||||
});
|
||||
|
||||
const EXISTING_SERVER = {
|
||||
id: 'srv-1',
|
||||
tenantId: 't1',
|
||||
name: 'pmg-1',
|
||||
productType: 'pmg' as const,
|
||||
baseUrl: 'https://pmg.intern',
|
||||
authMethod: 'password' as const,
|
||||
tokenId: null,
|
||||
username: 'admin@pmg',
|
||||
tlsRejectUnauthorized: true,
|
||||
isActive: true,
|
||||
pollIntervalMin: 5,
|
||||
position: 0,
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
status: null,
|
||||
};
|
||||
|
||||
describe('ServerForm', () => {
|
||||
it('bei Typ pmg erscheint die Auswahl "API-Token" gar nicht', async () => {
|
||||
const { ServerForm } = await import('./ServerForm');
|
||||
render(<ServerForm server={EXISTING_SERVER} isAdmin onSaved={vi.fn()} onCancel={vi.fn()} />);
|
||||
|
||||
const authSelect = screen.getByLabelText('Zugangsart') as HTMLSelectElement;
|
||||
const options = [...authSelect.options].map((o) => o.value);
|
||||
expect(options).toEqual(['password']);
|
||||
});
|
||||
|
||||
it('bei pve/pbs mit Token erscheinen Token-Kennung und -Geheimnis; bei Passwort Benutzer und Passwort', async () => {
|
||||
const { ServerForm } = await import('./ServerForm');
|
||||
const pveServer = { ...EXISTING_SERVER, productType: 'pve' as const, authMethod: 'token' as const };
|
||||
render(<ServerForm server={pveServer} isAdmin onSaved={vi.fn()} onCancel={vi.fn()} />);
|
||||
|
||||
expect(screen.getByLabelText('Token-Kennung')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Token-Geheimnis')).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('Benutzername')).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Zugangsart'), { target: { value: 'password' } });
|
||||
|
||||
expect(screen.getByLabelText('Benutzername')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Passwort')).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('Token-Kennung')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('ein gespeichertes Geheimnis wird nie im Klartext angezeigt — das Feld ist leer', async () => {
|
||||
const { ServerForm } = await import('./ServerForm');
|
||||
render(<ServerForm server={EXISTING_SERVER} isAdmin onSaved={vi.fn()} onCancel={vi.fn()} />);
|
||||
|
||||
const passwordInput = screen.getByLabelText('Passwort') as HTMLInputElement;
|
||||
expect(passwordInput.value).toBe('');
|
||||
});
|
||||
|
||||
it('ein leer gelassenes Geheimnisfeld sendet kein password-Feld beim Speichern (Wert bleibt unveraendert)', async () => {
|
||||
mockUpdateServer.mockResolvedValue(EXISTING_SERVER);
|
||||
const onSaved = vi.fn();
|
||||
const { ServerForm } = await import('./ServerForm');
|
||||
render(<ServerForm server={EXISTING_SERVER} isAdmin onSaved={onSaved} onCancel={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByText('Speichern'));
|
||||
|
||||
await waitFor(() => expect(mockUpdateServer).toHaveBeenCalled());
|
||||
const payload = mockUpdateServer.mock.calls[0][1];
|
||||
expect(payload.password).toBeUndefined();
|
||||
});
|
||||
|
||||
it('der Schalter fuer die Zertifikatspruefung steht beim Anlegen auf "pruefen" mit Hinweistext', async () => {
|
||||
const { ServerForm } = await import('./ServerForm');
|
||||
render(<ServerForm server={null} isAdmin onSaved={vi.fn()} onCancel={vi.fn()} />);
|
||||
|
||||
const checkbox = screen.getByLabelText('Zertifikat prüfen') as HTMLInputElement;
|
||||
expect(checkbox.checked).toBe(true);
|
||||
expect(
|
||||
screen.getByText('Die Ausnahme gilt nur für diesen einen Server, niemals für alle Server gemeinsam.'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Verbindung testen zeigt bei Erfolg eine gruene Bestaetigung', async () => {
|
||||
mockTestServer.mockResolvedValue({
|
||||
reachable: true,
|
||||
errorKind: null,
|
||||
errorDetail: null,
|
||||
metrics: null,
|
||||
rawSample: null,
|
||||
});
|
||||
const { ServerForm } = await import('./ServerForm');
|
||||
render(<ServerForm server={EXISTING_SERVER} isAdmin onSaved={vi.fn()} onCancel={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByText('Verbindung testen'));
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Verbindung erfolgreich.')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('Verbindung testen zeigt bei Misserfolg den Klartext der Ursache', async () => {
|
||||
mockTestServer.mockResolvedValue({
|
||||
reachable: false,
|
||||
errorKind: 'zugang',
|
||||
errorDetail: null,
|
||||
metrics: null,
|
||||
rawSample: null,
|
||||
});
|
||||
const { ServerForm } = await import('./ServerForm');
|
||||
render(<ServerForm server={EXISTING_SERVER} isAdmin onSaved={vi.fn()} onCancel={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByText('Verbindung testen'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Der Zugang wurde abgelehnt. Bitte prüfen Sie Benutzername und Passwort beziehungsweise die Token-Angaben.',
|
||||
),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it('Nachbesserung Befund 1: bei der Neuanlage (kein gespeicherter Server) steht der Testen-Knopf zur Verfuegung und ruft den Neuanlage-Testweg auf', async () => {
|
||||
mockTestDraftServer.mockResolvedValue({
|
||||
reachable: true,
|
||||
errorKind: null,
|
||||
errorDetail: null,
|
||||
metrics: null,
|
||||
rawSample: null,
|
||||
});
|
||||
const { ServerForm } = await import('./ServerForm');
|
||||
render(<ServerForm server={null} isAdmin onSaved={vi.fn()} onCancel={vi.fn()} />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Adresse'), {
|
||||
target: { value: 'https://pve.neu:8006' },
|
||||
});
|
||||
fireEvent.click(screen.getByText('Verbindung testen'));
|
||||
|
||||
await waitFor(() => expect(mockTestDraftServer).toHaveBeenCalled());
|
||||
expect(mockTestServer).not.toHaveBeenCalled();
|
||||
await waitFor(() => expect(screen.getByText('Verbindung erfolgreich.')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('Nachbesserung Befund 1: der Test prueft die im Formular abgeschaltete Zertifikatspruefung, nicht den gespeicherten Stand', async () => {
|
||||
mockTestServer.mockResolvedValue({
|
||||
reachable: true,
|
||||
errorKind: null,
|
||||
errorDetail: null,
|
||||
metrics: null,
|
||||
rawSample: null,
|
||||
});
|
||||
const { ServerForm } = await import('./ServerForm');
|
||||
render(<ServerForm server={EXISTING_SERVER} isAdmin onSaved={vi.fn()} onCancel={vi.fn()} />);
|
||||
|
||||
// EXISTING_SERVER wurde MIT Zertifikatspruefung gespeichert — im Formular jetzt abschalten.
|
||||
fireEvent.click(screen.getByLabelText('Zertifikat prüfen'));
|
||||
fireEvent.click(screen.getByText('Verbindung testen'));
|
||||
|
||||
await waitFor(() => expect(mockTestServer).toHaveBeenCalled());
|
||||
const [, payload] = mockTestServer.mock.calls[0];
|
||||
// Ohne die Korrektur wuerde `testServer` ganz ohne Formularstand aufgerufen — roter Test.
|
||||
expect(payload.tlsRejectUnauthorized).toBe(false);
|
||||
});
|
||||
|
||||
it('Nachbesserung Befund 1: ein leer gelassenes Geheimnisfeld sendet beim Testen kein tokenSecret (gespeicherter Wert bleibt massgeblich)', async () => {
|
||||
mockTestServer.mockResolvedValue({
|
||||
reachable: true,
|
||||
errorKind: null,
|
||||
errorDetail: null,
|
||||
metrics: null,
|
||||
rawSample: null,
|
||||
});
|
||||
const pveServer = { ...EXISTING_SERVER, productType: 'pve' as const, authMethod: 'token' as const };
|
||||
const { ServerForm } = await import('./ServerForm');
|
||||
render(<ServerForm server={pveServer} isAdmin onSaved={vi.fn()} onCancel={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByText('Verbindung testen'));
|
||||
|
||||
await waitFor(() => expect(mockTestServer).toHaveBeenCalled());
|
||||
const [, payload] = mockTestServer.mock.calls[0];
|
||||
expect(payload.tokenSecret).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,402 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import {
|
||||
createServer,
|
||||
testDraftServer,
|
||||
testServer,
|
||||
updateServer,
|
||||
type ProxmoxAuthMethod,
|
||||
type ProxmoxProductType,
|
||||
type ProxmoxServer,
|
||||
type ProxmoxTestResult,
|
||||
} from '@/lib/proxmox-api';
|
||||
|
||||
interface FormState {
|
||||
name: string;
|
||||
productType: ProxmoxProductType;
|
||||
baseUrl: string;
|
||||
authMethod: ProxmoxAuthMethod;
|
||||
tokenId: string;
|
||||
tokenSecret: string; // absichtlich leer beim Laden — nie aus dem Server vorbefuellt
|
||||
username: string;
|
||||
password: string; // absichtlich leer beim Laden — nie aus dem Server vorbefuellt
|
||||
pollIntervalMin: string;
|
||||
tlsRejectUnauthorized: boolean;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
function serverToForm(server: ProxmoxServer | null): FormState {
|
||||
if (!server) {
|
||||
return {
|
||||
name: '',
|
||||
productType: 'pve',
|
||||
baseUrl: '',
|
||||
authMethod: 'token',
|
||||
tokenId: '',
|
||||
tokenSecret: '',
|
||||
username: '',
|
||||
password: '',
|
||||
pollIntervalMin: '5',
|
||||
tlsRejectUnauthorized: true,
|
||||
isActive: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
name: server.name,
|
||||
productType: server.productType,
|
||||
baseUrl: server.baseUrl,
|
||||
authMethod: server.authMethod,
|
||||
tokenId: server.tokenId ?? '',
|
||||
tokenSecret: '',
|
||||
username: server.username ?? '',
|
||||
password: '',
|
||||
pollIntervalMin: String(server.pollIntervalMin),
|
||||
tlsRejectUnauthorized: server.tlsRejectUnauthorized,
|
||||
isActive: server.isActive,
|
||||
};
|
||||
}
|
||||
|
||||
interface ServerFormProps {
|
||||
server: ProxmoxServer | null;
|
||||
isAdmin: boolean;
|
||||
onSaved: (server: ProxmoxServer) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Server anlegen/bearbeiten (Aufgabe 5). Bei Typ `pmg` bietet die Auswahl
|
||||
* "API-Token" gar nicht erst an (D-03) — serverseitig lehnt das DTO diese
|
||||
* Kombination zusaetzlich ab (Verteidigung in der Tiefe). Ein gespeichertes
|
||||
* Geheimnis wird nie im Klartext angezeigt: das Feld ist leer, ein leer
|
||||
* gelassenes Feld laesst den gespeicherten Wert unveraendert.
|
||||
*/
|
||||
export function ServerForm({ server, isAdmin, onSaved, onCancel }: ServerFormProps) {
|
||||
const t = useTranslations('proxmox');
|
||||
const [form, setForm] = useState<FormState>(() => serverToForm(server));
|
||||
const [savedServer, setSavedServer] = useState<ProxmoxServer | null>(server);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isTesting, setIsTesting] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [testResult, setTestResult] = useState<ProxmoxTestResult | null>(null);
|
||||
|
||||
const update = useCallback(
|
||||
<K extends keyof FormState>(key: K, value: FormState[K]) => {
|
||||
setForm((f) => ({ ...f, [key]: value }));
|
||||
setSaveError(null);
|
||||
setTestResult(null);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleProductTypeChange = (productType: ProxmoxProductType) => {
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
productType,
|
||||
// PMG kennt keinen Token — bei Wechsel auf PMG automatisch auf Passwort umstellen.
|
||||
authMethod: productType === 'pmg' ? 'password' : f.authMethod,
|
||||
}));
|
||||
setSaveError(null);
|
||||
setTestResult(null);
|
||||
};
|
||||
|
||||
const buildPayload = () => ({
|
||||
name: form.name,
|
||||
productType: form.productType,
|
||||
baseUrl: form.baseUrl,
|
||||
authMethod: form.authMethod,
|
||||
tokenId: form.authMethod === 'token' ? form.tokenId : undefined,
|
||||
tokenSecret: form.authMethod === 'token' && form.tokenSecret ? form.tokenSecret : undefined,
|
||||
username: form.authMethod === 'password' ? form.username : undefined,
|
||||
password: form.authMethod === 'password' && form.password ? form.password : undefined,
|
||||
pollIntervalMin: Number(form.pollIntervalMin) || 5,
|
||||
tlsRejectUnauthorized: form.tlsRejectUnauthorized,
|
||||
isActive: form.isActive,
|
||||
});
|
||||
|
||||
/**
|
||||
* Wie `buildPayload()`, aber OHNE `name` (Nachbesserung Befund 1): der
|
||||
* Verbindungstest braucht den Namen nicht, und ein waehrend der Neuanlage
|
||||
* noch leer gelassenes Namensfeld wuerde sonst die serverseitige
|
||||
* `@IsNotEmpty()`-Pruefung auf `name` bei jedem Testklick blockieren.
|
||||
*/
|
||||
const buildTestPayload = () => {
|
||||
const { name: _name, ...rest } = buildPayload();
|
||||
return rest;
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true);
|
||||
setSaveError(null);
|
||||
try {
|
||||
const result = savedServer
|
||||
? await updateServer(savedServer.id, buildPayload())
|
||||
: await createServer(buildPayload());
|
||||
setSavedServer(result);
|
||||
setForm(serverToForm(result));
|
||||
onSaved(result);
|
||||
} catch (err) {
|
||||
setSaveError(err instanceof Error ? err.message : t('settings.saveError'));
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTest = async () => {
|
||||
setIsTesting(true);
|
||||
setTestResult(null);
|
||||
try {
|
||||
// Nachbesserung Befund 1: der Test prueft immer den aktuellen
|
||||
// Formularstand (`buildPayload()`), nie nur den gespeicherten Stand.
|
||||
// Ein leer gelassenes Geheimnisfeld wird dabei als `undefined`
|
||||
// gesendet — der Server faellt dann auf den gespeicherten Wert
|
||||
// zurueck (siehe `ProxmoxService.resolveEffectiveTestServer`).
|
||||
const result = savedServer
|
||||
? await testServer(savedServer.id, buildTestPayload())
|
||||
: await testDraftServer(buildTestPayload());
|
||||
setTestResult(result);
|
||||
} catch (err) {
|
||||
setTestResult({
|
||||
reachable: false,
|
||||
errorKind: 'unbekannt',
|
||||
errorDetail: err instanceof Error ? err.message : t('settings.saveError'),
|
||||
metrics: null,
|
||||
rawSample: null,
|
||||
});
|
||||
} finally {
|
||||
setIsTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const inputCls =
|
||||
'h-9 w-full max-w-md rounded border border-border bg-background px-3 text-sm text-foreground disabled:opacity-50';
|
||||
const labelCls = 'mb-1 block text-sm text-foreground';
|
||||
|
||||
const errorMessage = (kind: ProxmoxTestResult['errorKind']) => {
|
||||
if (!kind) return null;
|
||||
return t(`errors.${kind}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 rounded-lg border border-border bg-card p-4 shadow-sm">
|
||||
<div>
|
||||
<label htmlFor="proxmox-name" className={labelCls}>
|
||||
{t('settings.nameLabel')}
|
||||
</label>
|
||||
<input
|
||||
id="proxmox-name"
|
||||
type="text"
|
||||
className={inputCls}
|
||||
value={form.name}
|
||||
disabled={!isAdmin}
|
||||
onChange={(e) => update('name', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="proxmox-product-type" className={labelCls}>
|
||||
{t('settings.productTypeLabel')}
|
||||
</label>
|
||||
<select
|
||||
id="proxmox-product-type"
|
||||
className={inputCls}
|
||||
value={form.productType}
|
||||
disabled={!isAdmin}
|
||||
onChange={(e) => handleProductTypeChange(e.target.value as ProxmoxProductType)}
|
||||
>
|
||||
<option value="pve">{t('settings.productTypePve')}</option>
|
||||
<option value="pbs">{t('settings.productTypePbs')}</option>
|
||||
<option value="pmg">{t('settings.productTypePmg')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="proxmox-base-url" className={labelCls}>
|
||||
{t('settings.baseUrlLabel')}
|
||||
</label>
|
||||
<input
|
||||
id="proxmox-base-url"
|
||||
type="text"
|
||||
placeholder="https://pve.intern:8006"
|
||||
className={inputCls}
|
||||
value={form.baseUrl}
|
||||
disabled={!isAdmin}
|
||||
onChange={(e) => update('baseUrl', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="proxmox-auth-method" className={labelCls}>
|
||||
{t('settings.authMethodLabel')}
|
||||
</label>
|
||||
<select
|
||||
id="proxmox-auth-method"
|
||||
className={inputCls}
|
||||
value={form.authMethod}
|
||||
disabled={!isAdmin}
|
||||
onChange={(e) => update('authMethod', e.target.value as ProxmoxAuthMethod)}
|
||||
>
|
||||
{/* D-03: PMG kennt keinen API-Token — die Auswahl bietet ihn bei diesem Typ gar nicht erst an. */}
|
||||
{form.productType !== 'pmg' && <option value="token">{t('settings.authMethodToken')}</option>}
|
||||
<option value="password">{t('settings.authMethodPassword')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{form.authMethod === 'token' ? (
|
||||
<>
|
||||
<div>
|
||||
<label htmlFor="proxmox-token-id" className={labelCls}>
|
||||
{t('settings.tokenIdLabel')}
|
||||
</label>
|
||||
<input
|
||||
id="proxmox-token-id"
|
||||
type="text"
|
||||
placeholder="root@pam!tessera"
|
||||
className={inputCls}
|
||||
value={form.tokenId}
|
||||
disabled={!isAdmin}
|
||||
onChange={(e) => update('tokenId', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="proxmox-token-secret" className={labelCls}>
|
||||
{t('settings.tokenSecretLabel')}
|
||||
</label>
|
||||
<input
|
||||
id="proxmox-token-secret"
|
||||
type="password"
|
||||
placeholder={savedServer ? t('settings.secretUnchangedPlaceholder') : ''}
|
||||
className={inputCls}
|
||||
value={form.tokenSecret}
|
||||
disabled={!isAdmin}
|
||||
onChange={(e) => update('tokenSecret', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<label htmlFor="proxmox-username" className={labelCls}>
|
||||
{t('settings.usernameLabel')}
|
||||
</label>
|
||||
<input
|
||||
id="proxmox-username"
|
||||
type="text"
|
||||
placeholder="admin@pam"
|
||||
className={inputCls}
|
||||
value={form.username}
|
||||
disabled={!isAdmin}
|
||||
onChange={(e) => update('username', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="proxmox-password" className={labelCls}>
|
||||
{t('settings.passwordLabel')}
|
||||
</label>
|
||||
<input
|
||||
id="proxmox-password"
|
||||
type="password"
|
||||
placeholder={savedServer ? t('settings.secretUnchangedPlaceholder') : ''}
|
||||
className={inputCls}
|
||||
value={form.password}
|
||||
disabled={!isAdmin}
|
||||
onChange={(e) => update('password', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label htmlFor="proxmox-poll-interval" className={labelCls}>
|
||||
{t('settings.pollIntervalLabel')}
|
||||
</label>
|
||||
<input
|
||||
id="proxmox-poll-interval"
|
||||
type="number"
|
||||
min={1}
|
||||
max={1440}
|
||||
className={inputCls}
|
||||
value={form.pollIntervalMin}
|
||||
disabled={!isAdmin}
|
||||
onChange={(e) => update('pollIntervalMin', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="proxmox-tls-reject" className="flex items-center gap-2 text-sm text-foreground">
|
||||
<input
|
||||
id="proxmox-tls-reject"
|
||||
type="checkbox"
|
||||
checked={form.tlsRejectUnauthorized}
|
||||
disabled={!isAdmin}
|
||||
onChange={(e) => update('tlsRejectUnauthorized', e.target.checked)}
|
||||
/>
|
||||
{t('settings.tlsRejectLabel')}
|
||||
</label>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{t('settings.tlsRejectHint')}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="proxmox-active" className="flex items-center gap-2 text-sm text-foreground">
|
||||
<input
|
||||
id="proxmox-active"
|
||||
type="checkbox"
|
||||
checked={form.isActive}
|
||||
disabled={!isAdmin}
|
||||
onChange={(e) => update('isActive', e.target.checked)}
|
||||
/>
|
||||
{t('settings.activeLabel')}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{saveError && <p className="text-sm text-destructive">{saveError}</p>}
|
||||
|
||||
{testResult && (
|
||||
<p
|
||||
className={`text-sm ${testResult.reachable ? 'text-green-600 dark:text-green-400' : 'text-destructive'}`}
|
||||
>
|
||||
{testResult.reachable
|
||||
? t('settings.testSuccess')
|
||||
: `${errorMessage(testResult.errorKind)}${testResult.errorDetail ? ` (${testResult.errorDetail})` : ''}`}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{isAdmin && (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={isSaving || !form.name || !form.baseUrl}
|
||||
className="rounded bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isSaving ? t('settings.saving') : t('settings.save')}
|
||||
</button>
|
||||
|
||||
{/*
|
||||
Nachbesserung Befund 1: der Testen-Knopf steht IMMER zur
|
||||
Verfuegung, auch waehrend der Neuanlage vor dem ersten Speichern
|
||||
(vorher: nur bei `savedServer`) — `handleTest` waehlt selbst den
|
||||
passenden Endpunkt (`testServer` vs. `testDraftServer`).
|
||||
*/}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTest}
|
||||
disabled={isTesting || !form.baseUrl}
|
||||
className="rounded border border-border px-4 py-2 text-sm text-foreground hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isTesting ? t('settings.testTesting') : t('settings.testConnection')}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="rounded px-4 py-2 text-sm text-muted-foreground hover:bg-muted"
|
||||
>
|
||||
{t('settings.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useAuthStore } from '@/lib/stores/auth-store';
|
||||
import { deleteServer, listServers, type ProxmoxServer } from '@/lib/proxmox-api';
|
||||
import { ServerForm } from './components/ServerForm';
|
||||
|
||||
interface DeleteDialogProps {
|
||||
name: string;
|
||||
isDeleting: boolean;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
function DeleteDialog({ name, isDeleting, onConfirm, onCancel }: DeleteDialogProps) {
|
||||
const t = useTranslations('proxmox');
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||
<div className="w-full max-w-sm rounded border border-border bg-card px-6 py-5 shadow-lg">
|
||||
<h3 className="mb-2 text-base font-semibold text-foreground">
|
||||
{t('settings.deleteConfirmTitle')}
|
||||
</h3>
|
||||
<p className="mb-5 text-sm text-muted-foreground">
|
||||
{t('settings.deleteConfirmBody', { name })}
|
||||
</p>
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
disabled={isDeleting}
|
||||
className="rounded border border-border px-4 py-2 text-sm text-foreground hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{t('settings.deleteCancelButton')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
disabled={isDeleting}
|
||||
className="rounded bg-destructive px-4 py-2 text-sm font-medium text-destructive-foreground hover:bg-destructive/90 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{t('settings.deleteConfirmButton')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Moduleinstellungen (Aufgabe 5) — ADMINISTRATION ONLY. Die Rollenpruefung
|
||||
* hier ist reine Anzeige (Ladezustand solange die Rolle unbekannt ist,
|
||||
* damit die Verwaltungsteile fuer einen normalen Benutzer nie kurz
|
||||
* aufblitzen) — der verbindliche Riegel liegt serverseitig
|
||||
* (`@Roles(ADMIN, SUPER_ADMIN)` auf jedem Schreibweg, Vorbild
|
||||
* `tender-radar/settings/page.tsx`).
|
||||
*/
|
||||
export default function ProxmoxSettingsPage() {
|
||||
const t = useTranslations('proxmox');
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const isAdmin = user?.role === 'ADMIN' || user?.role === 'SUPER_ADMIN';
|
||||
|
||||
const [servers, setServers] = useState<ProxmoxServer[] | null>(null);
|
||||
const [editingId, setEditingId] = useState<string | 'new' | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<ProxmoxServer | null>(null);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
|
||||
const reload = useCallback(() => {
|
||||
listServers()
|
||||
.then(setServers)
|
||||
.catch(() => setLoadError(t('loadError')));
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
reload();
|
||||
}, [reload]);
|
||||
|
||||
const handleSaved = () => {
|
||||
setEditingId(null);
|
||||
reload();
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!deleteTarget) return;
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await deleteServer(deleteTarget.id);
|
||||
setDeleteTarget(null);
|
||||
reload();
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (user === null) {
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl p-6">
|
||||
<div className="mb-6 h-8 w-64 animate-pulse rounded bg-muted" />
|
||||
<div className="h-40 animate-pulse rounded bg-muted" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAdmin) {
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl p-6">
|
||||
<h1 className="mb-4 text-2xl font-semibold tracking-tight">{t('settings.title')}</h1>
|
||||
<p className="text-sm text-muted-foreground">{t('settings.accessDeniedText')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl space-y-6 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{t('settings.title')}</h1>
|
||||
{editingId === null && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingId('new')}
|
||||
className="rounded bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
{t('settings.addServer')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loadError && <p className="text-sm text-destructive">{loadError}</p>}
|
||||
|
||||
{editingId === 'new' && (
|
||||
<ServerForm
|
||||
server={null}
|
||||
isAdmin={isAdmin}
|
||||
onSaved={handleSaved}
|
||||
onCancel={() => setEditingId(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{servers !== null && servers.length === 0 && editingId === null && (
|
||||
<p className="text-sm text-muted-foreground">{t('settings.noServers')}</p>
|
||||
)}
|
||||
|
||||
<ul className="space-y-3">
|
||||
{servers?.map((server) =>
|
||||
editingId === server.id ? (
|
||||
<li key={server.id}>
|
||||
<ServerForm
|
||||
server={server}
|
||||
isAdmin={isAdmin}
|
||||
onSaved={handleSaved}
|
||||
onCancel={() => setEditingId(null)}
|
||||
/>
|
||||
</li>
|
||||
) : (
|
||||
<li
|
||||
key={server.id}
|
||||
className="flex items-center justify-between rounded-lg border border-border bg-card p-4 shadow-sm"
|
||||
>
|
||||
<div>
|
||||
<div className="font-medium">{server.name}</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{server.productType.toUpperCase()} — {server.baseUrl}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingId(server.id)}
|
||||
className="rounded border border-border px-3 py-1.5 text-sm text-foreground hover:bg-muted"
|
||||
>
|
||||
{t('settings.edit')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDeleteTarget(server)}
|
||||
className="rounded border border-destructive px-3 py-1.5 text-sm text-destructive hover:bg-destructive/10"
|
||||
>
|
||||
{t('settings.delete')}
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
),
|
||||
)}
|
||||
</ul>
|
||||
|
||||
{deleteTarget && (
|
||||
<DeleteDialog
|
||||
name={deleteTarget.name}
|
||||
isDeleting={isDeleting}
|
||||
onConfirm={confirmDelete}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// quick-260916-dyv: Dashboard-Seite — feste Aktionsleiste unten rechts
|
||||
@@ -20,6 +20,12 @@ vi.mock('next-intl', () => ({
|
||||
}));
|
||||
|
||||
const mockStore = {
|
||||
// quick-260923-ad9: Reiter — leere Liste rendert die Reiterleiste ohne
|
||||
// Reiter-Knoepfe, damit diese Datei bei den bestehenden Zusammensetzungs-
|
||||
// Pruefungen bleibt, statt den Reiter-Speicher selbst nachzubauen.
|
||||
dashboards: [] as Array<{ id: string; name: string; position: number }>,
|
||||
activeDashboardId: null as string | null,
|
||||
isSwitchingDashboard: false,
|
||||
layouts: { lg: [], md: [], sm: [], xs: [], xxs: [] },
|
||||
widgets: [] as Array<{ id: string; widgetType: string; config: Record<string, unknown> }>,
|
||||
isEditMode: false,
|
||||
@@ -32,6 +38,11 @@ const mockStore = {
|
||||
removeWidget: vi.fn(),
|
||||
loadDashboard: vi.fn(),
|
||||
saveLayout: vi.fn(),
|
||||
selectDashboard: vi.fn(),
|
||||
createDashboard: vi.fn(),
|
||||
renameDashboard: vi.fn(),
|
||||
deleteDashboard: vi.fn(),
|
||||
reorderDashboards: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock('@/lib/stores/dashboard-store', () => ({
|
||||
@@ -47,8 +58,16 @@ vi.mock('@/components/dashboard/dashboard-grid', () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
// quick-260922-m1h: Der Katalog bekommt die zugaenglichen Modul-Slugs als
|
||||
// Prop von dieser Seite — die Attrappe merkt sie sich, damit der Test sie
|
||||
// pruefen kann, ohne den echten Dialog zu rendern.
|
||||
const catalogProps: { accessibleModuleSlugs?: readonly string[] | null } = {};
|
||||
|
||||
vi.mock('@/components/dashboard/widget-catalog-modal', () => ({
|
||||
WidgetCatalogModal: () => null,
|
||||
WidgetCatalogModal: (p: { accessibleModuleSlugs: readonly string[] | null }) => {
|
||||
catalogProps.accessibleModuleSlugs = p.accessibleModuleSlugs;
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/components/dashboard/widgets/clock-widget', () => ({ ClockWidget: () => null }));
|
||||
@@ -62,6 +81,17 @@ vi.mock('@/components/dashboard/widgets/picture-frame-widget', () => ({ PictureF
|
||||
vi.mock('@/components/dashboard/widgets/xframe-widget', () => ({ XframeWidget: () => null }));
|
||||
|
||||
beforeEach(() => {
|
||||
catalogProps.accessibleModuleSlugs = undefined;
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => ({
|
||||
ok: true,
|
||||
json: async () => [
|
||||
{ id: 'm1', slug: 'domaincheck', name: 'Domaincheck', category: 'tools' },
|
||||
{ id: 'm2', slug: 'tender-radar', name: 'Tender', category: 'tools' },
|
||||
],
|
||||
})),
|
||||
);
|
||||
mockStore.isEditMode = false;
|
||||
mockStore.isLoading = false;
|
||||
mockStore.error = null;
|
||||
@@ -71,6 +101,7 @@ beforeEach(() => {
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('DashboardPage (quick-260916-dyv)', () => {
|
||||
@@ -117,3 +148,35 @@ describe('DashboardPage (quick-260916-dyv)', () => {
|
||||
expect(mockStore.setEditMode).toHaveBeenCalledWith(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DashboardPage: zugaengliche Module fuer den Katalog (quick-260922-m1h)', () => {
|
||||
it('Test 4: holt GET /modules/active mit Sitzungs-Keks und reicht die Slugs an den Katalog durch', async () => {
|
||||
const { default: DashboardPage } = await import('./page');
|
||||
render(<DashboardPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(catalogProps.accessibleModuleSlugs).toEqual(['domaincheck', 'tender-radar']);
|
||||
});
|
||||
|
||||
const call = vi.mocked(fetch).mock.calls[0];
|
||||
expect(String(call[0])).toContain('/modules/active');
|
||||
expect(call[1]).toMatchObject({ credentials: 'include' });
|
||||
});
|
||||
|
||||
it('Test 5: fail-closed — schlaegt der Abruf fehl, bleibt die Liste unbekannt (null)', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => {
|
||||
throw new Error('Netzwerk weg');
|
||||
}),
|
||||
);
|
||||
|
||||
const { default: DashboardPage } = await import('./page');
|
||||
render(<DashboardPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(vi.mocked(fetch)).toHaveBeenCalled();
|
||||
});
|
||||
expect(catalogProps.accessibleModuleSlugs).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { DashboardGrid } from '@/components/dashboard/dashboard-grid';
|
||||
import { DashboardTabs } from '@/components/dashboard/dashboard-tabs';
|
||||
import { EditModeToggle } from '@/components/dashboard/edit-mode-toggle';
|
||||
import { WidgetCatalogModal } from '@/components/dashboard/widget-catalog-modal';
|
||||
import { wireClockWidget, wireSearchWidget, wireCalendarWidget, wireNoteWidget, wireCalculatorWidget, wireStopwatchWidget, wireFavoritesWidget, wirePictureFrameWidget, wireXframeWidget } from '@/components/dashboard/widget-registry';
|
||||
import { registerWidget } from '@/components/dashboard/widget-registry';
|
||||
import { ClockWidget } from '@/components/dashboard/widgets/clock-widget';
|
||||
import { SearchWidget } from '@/components/dashboard/widgets/search-widget';
|
||||
import { CalendarWidget } from '@/components/dashboard/widgets/calendar-widget';
|
||||
@@ -18,22 +19,39 @@ import { XframeWidget } from '@/components/dashboard/widgets/xframe-widget';
|
||||
import { useDashboardStore } from '@/lib/stores/dashboard-store';
|
||||
import type { WidgetType } from '@/components/dashboard/widget-registry';
|
||||
|
||||
// Wire widget components into the registry (deferred to avoid circular deps)
|
||||
wireClockWidget(ClockWidget);
|
||||
wireSearchWidget(SearchWidget);
|
||||
wireCalendarWidget(CalendarWidget);
|
||||
wireNoteWidget(NoteWidget);
|
||||
wireCalculatorWidget(CalculatorWidget);
|
||||
wireStopwatchWidget(StopwatchWidget);
|
||||
wireFavoritesWidget(FavoritesWidget);
|
||||
wirePictureFrameWidget(PictureFrameWidget);
|
||||
wireXframeWidget(XframeWidget);
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
||||
|
||||
// Anmeldung der Kachel-Komponenten an der Registry. Steht hier und nicht in
|
||||
// der Registry selbst, weil die Komponenten ueber den Wrapper wieder die
|
||||
// Registry importieren — ein Import aus der Registry heraus waere ein
|
||||
// Zirkelimport. Seit quick-260922-m1h EINE Funktion statt neun `wireXWidget`.
|
||||
registerWidget('clock', ClockWidget);
|
||||
registerWidget('search', SearchWidget);
|
||||
registerWidget('calendar', CalendarWidget);
|
||||
registerWidget('note', NoteWidget);
|
||||
registerWidget('calculator', CalculatorWidget);
|
||||
registerWidget('stopwatch', StopwatchWidget);
|
||||
registerWidget('favorites', FavoritesWidget);
|
||||
registerWidget('picture-frame', PictureFrameWidget);
|
||||
registerWidget('xframe', XframeWidget);
|
||||
|
||||
/** Modul-Eintrag aus `GET /modules/active` — hier zaehlt nur der Slug. */
|
||||
interface ActiveModule {
|
||||
slug: string;
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const t = useTranslations('widgets');
|
||||
const [catalogOpen, setCatalogOpen] = useState(false);
|
||||
// quick-260922-m1h: Slugs der Module, die dieser Benutzer nutzen darf —
|
||||
// der Katalog blendet Kacheln gesperrter Module damit aus. `null` heisst
|
||||
// "noch unbekannt oder Abruf fehlgeschlagen" und ist fail-closed.
|
||||
const [accessibleModuleSlugs, setAccessibleModuleSlugs] = useState<string[] | null>(null);
|
||||
|
||||
const {
|
||||
dashboards,
|
||||
activeDashboardId,
|
||||
isSwitchingDashboard,
|
||||
layouts,
|
||||
widgets,
|
||||
isEditMode,
|
||||
@@ -44,6 +62,11 @@ export default function DashboardPage() {
|
||||
addWidget,
|
||||
removeWidget,
|
||||
loadDashboard,
|
||||
selectDashboard,
|
||||
createDashboard,
|
||||
renameDashboard,
|
||||
deleteDashboard,
|
||||
reorderDashboards,
|
||||
} = useDashboardStore();
|
||||
|
||||
// Load dashboard data on mount
|
||||
@@ -51,6 +74,28 @@ export default function DashboardPage() {
|
||||
loadDashboard();
|
||||
}, [loadDashboard]);
|
||||
|
||||
// Zugaengliche Module holen — gleiches Muster wie die Seitenleiste
|
||||
// (`components/layout/sidebar.tsx`): derselbe Endpunkt, derselbe
|
||||
// Sitzungs-Keks, Fehler still. Der Abruf steht hier und nicht im Dialog,
|
||||
// damit der Dialog ein reines Anzeige-Bauteil bleibt.
|
||||
const fetchAccessibleModules = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/modules/active`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!res.ok) return;
|
||||
const modules: ActiveModule[] = await res.json();
|
||||
setAccessibleModuleSlugs(modules.map((m) => m.slug));
|
||||
} catch {
|
||||
// still: die Liste bleibt null, der Katalog zeigt dann nur
|
||||
// Plattform-Kacheln (fail-closed).
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAccessibleModules();
|
||||
}, [fetchAccessibleModules]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex min-h-[60vh] items-center justify-center">
|
||||
@@ -69,15 +114,35 @@ export default function DashboardPage() {
|
||||
|
||||
return (
|
||||
<div className="relative p-2">
|
||||
{/* Dashboard grid — direkt im Container, ohne Abstands-Wrapper. */}
|
||||
<DashboardGrid
|
||||
layouts={layouts}
|
||||
widgets={widgets}
|
||||
{/* Reiterleiste (quick-260923-ad9) — bleibt waehrend eines
|
||||
Reiterwechsels stehen, nur das Raster darunter zeigt eine kurze
|
||||
Ladezeile. */}
|
||||
<DashboardTabs
|
||||
dashboards={dashboards}
|
||||
activeDashboardId={activeDashboardId}
|
||||
isEditMode={isEditMode}
|
||||
onLayoutChange={updateLayouts}
|
||||
onRemoveWidget={removeWidget}
|
||||
onSelect={selectDashboard}
|
||||
onCreate={createDashboard}
|
||||
onRename={renameDashboard}
|
||||
onDelete={deleteDashboard}
|
||||
onReorder={reorderDashboards}
|
||||
/>
|
||||
|
||||
{isSwitchingDashboard ? (
|
||||
<div className="flex min-h-[40vh] items-center justify-center">
|
||||
<p className="text-sm text-muted-foreground">{t('tabs.switching')}</p>
|
||||
</div>
|
||||
) : (
|
||||
/* Dashboard grid — direkt im Container, ohne Abstands-Wrapper. */
|
||||
<DashboardGrid
|
||||
layouts={layouts}
|
||||
widgets={widgets}
|
||||
isEditMode={isEditMode}
|
||||
onLayoutChange={updateLayouts}
|
||||
onRemoveWidget={removeWidget}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Feste Aktionsleiste unten rechts.
|
||||
quick-260916-dyv: Umschalter unten rechts statt oben rechts, damit das
|
||||
Grid direkt unter der Kopfzeile beginnt (12 + 8 + 8 = 28 px statt 60 px).
|
||||
@@ -117,6 +182,7 @@ export default function DashboardPage() {
|
||||
<WidgetCatalogModal
|
||||
isOpen={catalogOpen}
|
||||
onClose={() => setCatalogOpen(false)}
|
||||
accessibleModuleSlugs={accessibleModuleSlugs}
|
||||
onAddWidget={(type: WidgetType) => {
|
||||
addWidget(type);
|
||||
}}
|
||||
|
||||
@@ -3,11 +3,20 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { WidgetSettingsPanel } from '@/components/settings/widget-settings-panel';
|
||||
import { fetchWidgets } from '@/lib/dashboard-api';
|
||||
import { fetchDashboards, fetchWidgets } from '@/lib/dashboard-api';
|
||||
|
||||
/**
|
||||
* Settings > Dashboard > Widgets page (D-03).
|
||||
* Fetches user's widget instances and renders per-instance config forms.
|
||||
* Fetches the widget instances of the user's FIRST dashboard tab (the
|
||||
* standard tab, quick-260923-ad9) and renders per-instance config forms.
|
||||
*
|
||||
* quick-260923-ad9 (Task 3, deviation Rule 3 — blocking compile issue):
|
||||
* `fetchWidgets` now requires a `dashboardId` since widgets are scoped per
|
||||
* tab. This page is not itself tab-aware (out of this plan's scope — see
|
||||
* "Nicht im Umfang") and previously showed every widget the user had, back
|
||||
* when there was exactly one dashboard per user; it now shows the first
|
||||
* tab's widgets, which is the same set for the (overwhelmingly common)
|
||||
* case of a user who has not yet created a second tab.
|
||||
*/
|
||||
export default function WidgetSettingsPage() {
|
||||
const t = useTranslations('settings');
|
||||
@@ -18,12 +27,17 @@ export default function WidgetSettingsPage() {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetchWidgets()
|
||||
.then(setWidgets)
|
||||
.catch(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const dashboards = await fetchDashboards();
|
||||
const first = dashboards[0];
|
||||
setWidgets(first ? await fetchWidgets(first.id) : []);
|
||||
} catch {
|
||||
// Silent fail — empty widget list shown
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Children, isValidElement } from 'react';
|
||||
import { cleanup, render, screen } from '@testing-library/react';
|
||||
import { act, cleanup, render, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { stubResizeObserver } from '@/test/fake-resize-observer';
|
||||
|
||||
// Mock CSS imports that vitest cannot resolve
|
||||
vi.mock('react-grid-layout/css/styles.css', () => ({}));
|
||||
@@ -84,6 +85,10 @@ vi.mock('@/lib/stores/dashboard-store', () => ({
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
// quick-260922-vdk: stubResizeObserver ersetzt den globalen ResizeObserver
|
||||
// per vi.stubGlobal — vi.restoreAllMocks() setzt das nicht zurueck, ohne
|
||||
// diese Zeile bliebe der gestubbte Beobachter fuer alle folgenden Dateien stehen.
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('DashboardGrid', () => {
|
||||
@@ -387,4 +392,64 @@ describe('DashboardGrid', () => {
|
||||
expect(screen.queryByTitle('Drag the tile to move it')).toBeNull();
|
||||
expect(document.querySelector('.widget-drag-handle')).toBeNull();
|
||||
});
|
||||
|
||||
it('quick-260922-vdk Test 10: Leerzustand -> gefuellt misst die tatsaechliche Breite statt beim Startwert 1200 stehenzubleiben', async () => {
|
||||
stubResizeObserver({ width: 1000, height: 800 });
|
||||
captured.props = null;
|
||||
const { DashboardGrid } = await import('./dashboard-grid');
|
||||
|
||||
const { rerender } = render(
|
||||
<DashboardGrid
|
||||
layouts={{ lg: [], md: [], sm: [], xs: [], xxs: [] }}
|
||||
widgets={[]}
|
||||
isEditMode={false}
|
||||
onLayoutChange={vi.fn()}
|
||||
onRemoveWidget={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Leerzustand: der gemessene Knoten ist gar nicht eingehaengt.
|
||||
expect(screen.getByText('No active widgets')).toBeInTheDocument();
|
||||
expect(captured.props).toBeNull();
|
||||
|
||||
// Erste Kachel erscheint -> der Raster-<div> wird eingehaengt -> measureRef
|
||||
// misst synchron vor dem Zeichnen. Vor der Aenderung bleibt width bei 1200.
|
||||
rerender(
|
||||
<DashboardGrid
|
||||
layouts={{ lg: [{ i: 'inst-1', x: 0, y: 0, w: 2, h: 2 }], md: [], sm: [], xs: [], xxs: [] }}
|
||||
widgets={[{ id: 'inst-1', widgetType: 'clock', config: {} }]}
|
||||
isEditMode={false}
|
||||
onLayoutChange={vi.fn()}
|
||||
onRemoveWidget={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(captured.props?.width).toBe(1000);
|
||||
});
|
||||
|
||||
it('quick-260922-vdk Test 11: Fenstergroesse aendert sich -> der Fenster-Horcher misst neu', async () => {
|
||||
stubResizeObserver({ width: 1000, height: 800 });
|
||||
captured.props = null;
|
||||
const { DashboardGrid } = await import('./dashboard-grid');
|
||||
|
||||
render(
|
||||
<DashboardGrid
|
||||
layouts={{ lg: [{ i: 'inst-1', x: 0, y: 0, w: 2, h: 2 }], md: [], sm: [], xs: [], xxs: [] }}
|
||||
widgets={[{ id: 'inst-1', widgetType: 'clock', config: {} }]}
|
||||
isEditMode={false}
|
||||
onLayoutChange={vi.fn()}
|
||||
onRemoveWidget={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(captured.props?.width).toBe(1000);
|
||||
|
||||
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 0, 1600, 800));
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
});
|
||||
|
||||
// Vor der Aenderung gibt es keinen Fenster-Horcher: width bliebe bei 1000.
|
||||
expect(captured.props?.width).toBe(1600);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Responsive, noCompactor } from 'react-grid-layout';
|
||||
import type { Compactor, ResponsiveLayouts } from 'react-grid-layout';
|
||||
import 'react-grid-layout/css/styles.css';
|
||||
@@ -125,19 +125,79 @@ export function DashboardGrid({
|
||||
onRemoveWidget,
|
||||
}: DashboardGridProps) {
|
||||
const t = useTranslations('widgets');
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [width, setWidth] = useState(1200);
|
||||
|
||||
// Measure container width (v2 requires explicit width — Pitfall 1)
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
setWidth(entries[0].contentRect.width);
|
||||
});
|
||||
observer.observe(containerRef.current);
|
||||
return () => observer.disconnect();
|
||||
// quick-260922-vdk: Messung haengt am eingehaengten Knoten (Ref-Rueckruf),
|
||||
// nicht mehr an einem Effekt mit leerer Abhaengigkeitsliste.
|
||||
//
|
||||
// Warum: der fruehe Ruecksprung in den Leerzustand rendert das gemessene
|
||||
// <div> gar nicht erst. Haengt DashboardGrid mit null Kacheln ein, sieht der
|
||||
// alte Effekt (leere Abhaengigkeitsliste, laeuft genau einmal beim
|
||||
// Einhaengen) den Ref als leer, bricht ab und laeuft nie wieder — auch nicht,
|
||||
// wenn spaeter die erste Kachel erscheint und das <div> tatsaechlich
|
||||
// entsteht. Die Breite blieb dann fuer die ganze Sitzung beim Startwert
|
||||
// 1200, react-grid-layout vergleicht den Breakpoint strikt groesser als
|
||||
// (`width > breakpoint`), 1200 ist damit NICHT `lg` sondern `md` -> 20 statt
|
||||
// 24 Spalten, 51,6 statt 50 px Spaltenbreite, ein toter Streifen rechts.
|
||||
//
|
||||
// Der Ref-Rueckruf `measureRef` folgt dem Knoten ueber Aus- und Einhaengen
|
||||
// hinweg (Leerzustand <-> gefuellt) und laeuft in der Commit-Phase — die dort
|
||||
// ausgeloeste Zustandsaenderung wird vor dem Zeichnen abgearbeitet, ein
|
||||
// zusaetzlicher useLayoutEffect ist damit ueberfluessig. Der Startwert 1200
|
||||
// lebt deshalb nur noch bis zur Commit-Phase desselben Einhaengens; genau
|
||||
// diesen Uebergang macht der Pfad "Neuladen mit vorhandenen Kacheln" heute
|
||||
// schon in Produktion und er ist nachweislich richtig (459 px in 1176 px
|
||||
// gemessen) — ein anderer Startwert wuerde eine bisher unerprobte
|
||||
// Breakpoint-Folge einfuehren, ohne etwas zu verbessern.
|
||||
//
|
||||
// Der Fenster-Horcher ist ein zusaetzliches Netz fuer Faelle, in denen der
|
||||
// ResizeObserver nichts meldet — nicht sein Ersatz. `applyWidth` verwirft 0
|
||||
// und nicht endliche Werte (T-VDK-01/T-VDK-03), damit eine kurzzeitig
|
||||
// zusammengefallene Flaeche das Raster nicht auf Null setzt und keine
|
||||
// Rueckkopplungsschleife entsteht; React verwirft gleiche Werte selbst.
|
||||
const nodeRef = useRef<HTMLDivElement | null>(null);
|
||||
const observerRef = useRef<ResizeObserver | null>(null);
|
||||
|
||||
const applyWidth = useCallback((next: number) => {
|
||||
if (Number.isFinite(next) && next > 0) {
|
||||
setWidth(next);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const measureRef = useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
// Ein eventuell laufender Beobachter zuerst trennen — auch der
|
||||
// null-Zweig (Aushaengen) durchlaeuft diese Zeilen, das ist hier der
|
||||
// Aufraeumpfad (T-VDK-02).
|
||||
observerRef.current?.disconnect();
|
||||
observerRef.current = null;
|
||||
nodeRef.current = node;
|
||||
if (!node) return;
|
||||
|
||||
// Synchrone Erstmessung in der Commit-Phase, vor dem ersten Zeichnen.
|
||||
applyWidth(node.getBoundingClientRect().width);
|
||||
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
applyWidth(entries[0].contentRect.width);
|
||||
});
|
||||
observer.observe(node);
|
||||
observerRef.current = observer;
|
||||
// Bewusst keine Aufraeumfunktion zurueckgeben: React 19 ruft den
|
||||
// Ref-Rueckruf sonst beim Aushaengen nicht mehr mit null auf.
|
||||
},
|
||||
[applyWidth],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const onResize = () => {
|
||||
if (nodeRef.current) {
|
||||
applyWidth(nodeRef.current.getBoundingClientRect().width);
|
||||
}
|
||||
};
|
||||
window.addEventListener('resize', onResize);
|
||||
return () => window.removeEventListener('resize', onResize);
|
||||
}, [applyWidth]);
|
||||
|
||||
// quick-260916-dyv: minW/minH (und zu kleine w/h) aus WIDGET_CONSTRAINTS —
|
||||
// siehe applyConstraintMinima. Vor dem Leerzustand, damit die Hook-Reihenfolge
|
||||
// stabil bleibt.
|
||||
@@ -162,7 +222,7 @@ export function DashboardGrid({
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef}>
|
||||
<div ref={measureRef}>
|
||||
<Responsive
|
||||
width={width}
|
||||
breakpoints={BREAKPOINTS}
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
/**
|
||||
* Mock next-intl mit einfacher `{param}`-Ersetzung — anders als das
|
||||
* schlichtere Muster in `widget-catalog-modal.test.tsx` (das Parameter
|
||||
* ignoriert), weil Test 8 unten die eingesetzte Reiterbezeichnung im
|
||||
* Loeschdialog prueft.
|
||||
*/
|
||||
vi.mock('next-intl', () => ({
|
||||
useTranslations: (namespace: string) => (key: string, params?: Record<string, string>) => {
|
||||
const translations: Record<string, Record<string, string>> = {
|
||||
widgets: {
|
||||
'tabs.navLabel': 'Dashboard-Reiter',
|
||||
'tabs.add': 'Dashboard hinzufügen',
|
||||
'tabs.renameButtonLabel': 'Dashboard umbenennen',
|
||||
'tabs.renameInputLabel': 'Name des Dashboards',
|
||||
'tabs.deleteButtonLabel': 'Dashboard löschen',
|
||||
'tabs.deleteDialogTitle': 'Dashboard löschen',
|
||||
'tabs.deleteDialogBody': 'Möchten Sie das Dashboard „{name}“ wirklich löschen?',
|
||||
'tabs.dragHint': 'Ziehen Sie einen Reiter, um ihn zum Standard zu machen.',
|
||||
},
|
||||
common: {
|
||||
cancel: 'Abbrechen',
|
||||
delete: 'Löschen',
|
||||
},
|
||||
};
|
||||
let text = translations[namespace]?.[key] ?? key;
|
||||
if (params) {
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
text = text.replace(`{${k}}`, v);
|
||||
}
|
||||
}
|
||||
return text;
|
||||
},
|
||||
}));
|
||||
|
||||
import { DashboardTabs } from './dashboard-tabs';
|
||||
|
||||
const DASHBOARDS = [
|
||||
{ id: 'd1', name: 'Dashboard', position: 0 },
|
||||
{ id: 'd2', name: 'Dashboard 2', position: 1 },
|
||||
];
|
||||
|
||||
const THREE_DASHBOARDS = [
|
||||
{ id: 'd1', name: 'Dashboard', position: 0 },
|
||||
{ id: 'd2', name: 'Dashboard 2', position: 1 },
|
||||
{ id: 'd3', name: 'Dashboard 3', position: 2 },
|
||||
];
|
||||
|
||||
/**
|
||||
* Stubbt `getBoundingClientRect` fuer JEDEN Reiter-Wrapper so, dass Reiter i
|
||||
* (nach seiner AKTUELLEN Stelle im DOM, `data-tab-index`) die Spanne von
|
||||
* i·100 bis i·100+100 belegt — echte Masse liefert jsdom hier nicht (Plan-
|
||||
* Vorgabe, Task 4). Die Zeigerpositionen der Tests unten rechnen gegen
|
||||
* genau diese Spannen.
|
||||
*/
|
||||
function stubTabRects() {
|
||||
vi.spyOn(HTMLDivElement.prototype, 'getBoundingClientRect').mockImplementation(function (
|
||||
this: HTMLDivElement,
|
||||
) {
|
||||
const idx = Number(this.dataset.tabIndex ?? '0');
|
||||
return {
|
||||
x: idx * 100,
|
||||
y: 0,
|
||||
left: idx * 100,
|
||||
top: 0,
|
||||
right: idx * 100 + 100,
|
||||
bottom: 32,
|
||||
width: 100,
|
||||
height: 32,
|
||||
toJSON() {
|
||||
return {};
|
||||
},
|
||||
} as DOMRect;
|
||||
});
|
||||
}
|
||||
|
||||
function defaultProps(overrides: Partial<Parameters<typeof DashboardTabs>[0]> = {}) {
|
||||
return {
|
||||
dashboards: DASHBOARDS,
|
||||
activeDashboardId: 'd1',
|
||||
isEditMode: false,
|
||||
onSelect: vi.fn(),
|
||||
onCreate: vi.fn(),
|
||||
onRename: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
onReorder: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('DashboardTabs (quick-260923-ad9, Task 3)', () => {
|
||||
it('Test 1: rendert jeden Reiter mit seinem Namen', () => {
|
||||
render(<DashboardTabs {...defaultProps()} />);
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Dashboard' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Dashboard 2' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Test 2: ein Klick auf einen Reiter ruft onSelect mit dessen Kennung auf', () => {
|
||||
const onSelect = vi.fn();
|
||||
render(<DashboardTabs {...defaultProps({ onSelect })} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Dashboard 2' }));
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith('d2');
|
||||
});
|
||||
|
||||
it('Test 3: der aktive Reiter traegt aria-current, der andere nicht', () => {
|
||||
render(<DashboardTabs {...defaultProps()} />);
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Dashboard' })).toHaveAttribute('aria-current', 'true');
|
||||
expect(screen.getByRole('button', { name: 'Dashboard 2' })).not.toHaveAttribute('aria-current');
|
||||
});
|
||||
|
||||
it('Test 4: ausserhalb des Bearbeitungsmodus gibt es weder Umbenennen- noch Loeschen- noch Hinzufuegen-Knoepfe', () => {
|
||||
render(<DashboardTabs {...defaultProps({ isEditMode: false })} />);
|
||||
|
||||
expect(screen.queryByLabelText('Dashboard umbenennen')).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('Dashboard löschen')).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('Dashboard hinzufügen')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Test 5: im Bearbeitungsmodus mit mehreren Reitern sind Loeschen-Knoepfe und ein Hinzufuegen-Knopf da', () => {
|
||||
render(<DashboardTabs {...defaultProps({ isEditMode: true })} />);
|
||||
|
||||
expect(screen.getAllByLabelText('Dashboard löschen')).toHaveLength(2);
|
||||
expect(screen.getByLabelText('Dashboard hinzufügen')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Test 6: beim letzten verbleibenden Reiter wird Loeschen gar nicht erst angeboten', () => {
|
||||
render(<DashboardTabs {...defaultProps({ dashboards: [DASHBOARDS[0]], isEditMode: true })} />);
|
||||
|
||||
expect(screen.queryByLabelText('Dashboard löschen')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Test 7: der Hinzufuegen-Knopf ruft onCreate auf', () => {
|
||||
const onCreate = vi.fn();
|
||||
render(<DashboardTabs {...defaultProps({ isEditMode: true, onCreate })} />);
|
||||
|
||||
fireEvent.click(screen.getByLabelText('Dashboard hinzufügen'));
|
||||
|
||||
expect(onCreate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('Test 8: Umbenennen-Knopf des AKTIVEN Reiters verwandelt den Namen in ein Eingabefeld; Enter uebernimmt beschnitten', () => {
|
||||
const onRename = vi.fn();
|
||||
render(<DashboardTabs {...defaultProps({ isEditMode: true, onRename })} />);
|
||||
|
||||
fireEvent.click(screen.getByLabelText('Dashboard umbenennen'));
|
||||
const input = screen.getByLabelText('Name des Dashboards');
|
||||
fireEvent.change(input, { target: { value: ' Finanzen ' } });
|
||||
fireEvent.keyDown(input, { key: 'Enter' });
|
||||
|
||||
expect(onRename).toHaveBeenCalledWith('d1', 'Finanzen');
|
||||
});
|
||||
|
||||
it('Test 9: Escape verwirft die Umbenennung, ohne onRename aufzurufen', () => {
|
||||
const onRename = vi.fn();
|
||||
render(<DashboardTabs {...defaultProps({ isEditMode: true, onRename })} />);
|
||||
|
||||
fireEvent.click(screen.getByLabelText('Dashboard umbenennen'));
|
||||
const input = screen.getByLabelText('Name des Dashboards');
|
||||
fireEvent.change(input, { target: { value: 'Verworfen' } });
|
||||
fireEvent.keyDown(input, { key: 'Escape' });
|
||||
|
||||
expect(onRename).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole('button', { name: 'Dashboard' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Test 10: der Umbenennen-Knopf steht nur beim AKTIVEN Reiter, nicht bei den anderen', () => {
|
||||
render(<DashboardTabs {...defaultProps({ isEditMode: true })} />);
|
||||
|
||||
expect(screen.getAllByLabelText('Dashboard umbenennen')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('Test 11: Loeschen fragt mit dem Namen des Reiters zurueck; Bestaetigen ruft onDelete auf', () => {
|
||||
const onDelete = vi.fn();
|
||||
render(<DashboardTabs {...defaultProps({ isEditMode: true, onDelete })} />);
|
||||
|
||||
fireEvent.click(screen.getAllByLabelText('Dashboard löschen')[1]);
|
||||
const dialog = screen.getByRole('alertdialog');
|
||||
expect(dialog).toHaveTextContent('Dashboard 2');
|
||||
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Löschen' }));
|
||||
|
||||
expect(onDelete).toHaveBeenCalledWith('d2');
|
||||
});
|
||||
|
||||
it('Test 12: Abbrechen im Loeschdialog ruft onDelete NICHT auf', () => {
|
||||
const onDelete = vi.fn();
|
||||
render(<DashboardTabs {...defaultProps({ isEditMode: true, onDelete })} />);
|
||||
|
||||
fireEvent.click(screen.getAllByLabelText('Dashboard löschen')[0]);
|
||||
const dialog = screen.getByRole('alertdialog');
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Abbrechen' }));
|
||||
|
||||
expect(onDelete).not.toHaveBeenCalled();
|
||||
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DashboardTabs — Ziehen zum Umsortieren (quick-260923-ad9, Task 4)', () => {
|
||||
it('Test 13: Druecken und Loslassen OHNE nennenswerte Bewegung sendet KEINE neue Reihenfolge (der nachfolgende Klick waehlt weiterhin)', () => {
|
||||
stubTabRects();
|
||||
const onReorder = vi.fn();
|
||||
const onSelect = vi.fn();
|
||||
render(<DashboardTabs {...defaultProps({ onReorder, onSelect })} />);
|
||||
const tab = screen.getByTestId('dashboard-tab-d2');
|
||||
|
||||
fireEvent.pointerDown(tab, { clientX: 150, pointerId: 1, button: 0 });
|
||||
fireEvent.pointerUp(tab, { clientX: 150, pointerId: 1 });
|
||||
|
||||
expect(onReorder).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Dashboard 2' }));
|
||||
expect(onSelect).toHaveBeenCalledWith('d2');
|
||||
});
|
||||
|
||||
it('Test 14: Druecken, mehr als die Schwelle nach RECHTS bewegen und loslassen verschiebt den Reiter hinter seinen rechten Nachbarn', () => {
|
||||
stubTabRects();
|
||||
const onReorder = vi.fn();
|
||||
render(<DashboardTabs {...defaultProps({ dashboards: DASHBOARDS, onReorder })} />);
|
||||
const tab = screen.getByTestId('dashboard-tab-d1'); // Spanne 0..100
|
||||
|
||||
fireEvent.pointerDown(tab, { clientX: 10, pointerId: 1, button: 0 });
|
||||
// ueber die Schwelle hinaus, weit rechts von d2s Mittelpunkt (150)
|
||||
fireEvent.pointerMove(tab, { clientX: 180, pointerId: 1 });
|
||||
fireEvent.pointerUp(tab, { clientX: 180, pointerId: 1 });
|
||||
|
||||
expect(onReorder).toHaveBeenCalledWith(['d2', 'd1']);
|
||||
});
|
||||
|
||||
it('Test 15: dasselbe nach LINKS verschiebt vor den linken Nachbarn', () => {
|
||||
stubTabRects();
|
||||
const onReorder = vi.fn();
|
||||
render(<DashboardTabs {...defaultProps({ dashboards: DASHBOARDS, onReorder })} />);
|
||||
const tab = screen.getByTestId('dashboard-tab-d2'); // Spanne 100..200
|
||||
|
||||
fireEvent.pointerDown(tab, { clientX: 150, pointerId: 1, button: 0 });
|
||||
// weit links von d1s Mittelpunkt (50)
|
||||
fireEvent.pointerMove(tab, { clientX: 10, pointerId: 1 });
|
||||
fireEvent.pointerUp(tab, { clientX: 10, pointerId: 1 });
|
||||
|
||||
expect(onReorder).toHaveBeenCalledWith(['d2', 'd1']);
|
||||
});
|
||||
|
||||
it('Test 16: waehrend des Ziehens zeigt die Leiste die Vorschau-Reihenfolge; Abbruch des Zeigers verwirft sie, ohne zu senden', () => {
|
||||
stubTabRects();
|
||||
const onReorder = vi.fn();
|
||||
render(<DashboardTabs {...defaultProps({ dashboards: DASHBOARDS, onReorder })} />);
|
||||
const tab = screen.getByTestId('dashboard-tab-d1');
|
||||
|
||||
fireEvent.pointerDown(tab, { clientX: 10, pointerId: 1, button: 0 });
|
||||
fireEvent.pointerMove(tab, { clientX: 180, pointerId: 1 });
|
||||
|
||||
// Vorschau: d2 steht jetzt vor d1 im DOM.
|
||||
const buttons = screen.getAllByRole('button').filter((b) => b.textContent === 'Dashboard' || b.textContent === 'Dashboard 2');
|
||||
expect(buttons.map((b) => b.textContent)).toEqual(['Dashboard 2', 'Dashboard']);
|
||||
|
||||
fireEvent.pointerCancel(tab, { pointerId: 1 });
|
||||
|
||||
expect(onReorder).not.toHaveBeenCalled();
|
||||
const buttonsAfterCancel = screen
|
||||
.getAllByRole('button')
|
||||
.filter((b) => b.textContent === 'Dashboard' || b.textContent === 'Dashboard 2');
|
||||
expect(buttonsAfterCancel.map((b) => b.textContent)).toEqual(['Dashboard', 'Dashboard 2']);
|
||||
});
|
||||
|
||||
it('Test 17: ein Ziehen, das den ersten Reiter verdraengt, macht den vorgezogenen Reiter zum ersten in der gesendeten Liste', () => {
|
||||
stubTabRects();
|
||||
const onReorder = vi.fn();
|
||||
render(<DashboardTabs {...defaultProps({ dashboards: THREE_DASHBOARDS, onReorder })} />);
|
||||
const tab = screen.getByTestId('dashboard-tab-d3'); // Spanne 200..300
|
||||
|
||||
fireEvent.pointerDown(tab, { clientX: 250, pointerId: 1, button: 0 });
|
||||
// weit links von d1s Mittelpunkt (50) -> wird der neue Erste
|
||||
fireEvent.pointerMove(tab, { clientX: 5, pointerId: 1 });
|
||||
fireEvent.pointerUp(tab, { clientX: 5, pointerId: 1 });
|
||||
|
||||
expect(onReorder).toHaveBeenCalledWith(['d3', 'd1', 'd2']);
|
||||
});
|
||||
|
||||
it('Test 18: der aktive Reiter bleibt beim Ziehen aktiv, auch wenn er seine Position wechselt', () => {
|
||||
stubTabRects();
|
||||
render(<DashboardTabs {...defaultProps({ dashboards: DASHBOARDS, activeDashboardId: 'd1', onReorder: vi.fn() })} />);
|
||||
const tab = screen.getByTestId('dashboard-tab-d1');
|
||||
|
||||
fireEvent.pointerDown(tab, { clientX: 10, pointerId: 1, button: 0 });
|
||||
fireEvent.pointerMove(tab, { clientX: 180, pointerId: 1 });
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Dashboard' })).toHaveAttribute('aria-current', 'true');
|
||||
});
|
||||
|
||||
it('Test 19: nach einem echten Ziehen unterdrueckt der naechste Klick die Auswahl EINMAL, danach funktioniert Klicken wieder normal', () => {
|
||||
stubTabRects();
|
||||
const onSelect = vi.fn();
|
||||
const onReorder = vi.fn();
|
||||
render(<DashboardTabs {...defaultProps({ dashboards: DASHBOARDS, onSelect, onReorder })} />);
|
||||
const tab = screen.getByTestId('dashboard-tab-d1');
|
||||
|
||||
fireEvent.pointerDown(tab, { clientX: 10, pointerId: 1, button: 0 });
|
||||
fireEvent.pointerMove(tab, { clientX: 180, pointerId: 1 });
|
||||
fireEvent.pointerUp(tab, { clientX: 180, pointerId: 1 });
|
||||
expect(onReorder).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Ein echter Browser wuerde nach dem Ziehen noch einen "click" nachreichen.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Dashboard' }));
|
||||
expect(onSelect).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Dashboard' }));
|
||||
expect(onSelect).toHaveBeenCalledWith('d1');
|
||||
});
|
||||
|
||||
it('Test 20: der Hinweistext zum Ziehen steht bei mehr als einem Reiter, aber nicht bei genau einem', () => {
|
||||
const { rerender } = render(<DashboardTabs {...defaultProps({ dashboards: DASHBOARDS })} />);
|
||||
expect(screen.getByText('Ziehen Sie einen Reiter, um ihn zum Standard zu machen.')).toBeInTheDocument();
|
||||
|
||||
rerender(<DashboardTabs {...defaultProps({ dashboards: [DASHBOARDS[0]] })} />);
|
||||
expect(
|
||||
screen.queryByText('Ziehen Sie einen Reiter, um ihn zum Standard zu machen.'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Test 21: Ziehen ist auch AUSSERHALB des Bearbeitungsmodus moeglich (D-09)', () => {
|
||||
stubTabRects();
|
||||
const onReorder = vi.fn();
|
||||
render(<DashboardTabs {...defaultProps({ dashboards: DASHBOARDS, isEditMode: false, onReorder })} />);
|
||||
const tab = screen.getByTestId('dashboard-tab-d1');
|
||||
|
||||
fireEvent.pointerDown(tab, { clientX: 10, pointerId: 1, button: 0 });
|
||||
fireEvent.pointerMove(tab, { clientX: 180, pointerId: 1 });
|
||||
fireEvent.pointerUp(tab, { clientX: 180, pointerId: 1 });
|
||||
|
||||
expect(onReorder).toHaveBeenCalledWith(['d2', 'd1']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,393 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { type PointerEvent, useEffect, useRef, useState } from 'react';
|
||||
import type { DashboardTab } from '@/lib/dashboard-api';
|
||||
|
||||
interface DashboardTabsProps {
|
||||
dashboards: DashboardTab[];
|
||||
activeDashboardId: string | null;
|
||||
isEditMode: boolean;
|
||||
onSelect: (id: string) => void;
|
||||
onCreate: () => void;
|
||||
onRename: (id: string, name: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onReorder: (ids: string[]) => void;
|
||||
}
|
||||
|
||||
/** Ab dieser waagerechten Auslenkung (Bildschirmpixel) wird aus einem Klick ein Ziehen (Task 4). */
|
||||
const DRAG_THRESHOLD_PX = 4;
|
||||
|
||||
// jsdom kennt kein setPointerCapture — im Browser wird gefangen, im Test
|
||||
// feuern Move/Up auf demselben Element (Muster xframe-config-form.tsx).
|
||||
function capturePointer(el: HTMLElement, id: number) {
|
||||
if (typeof el.setPointerCapture === 'function') el.setPointerCapture(id);
|
||||
}
|
||||
|
||||
function releasePointer(el: HTMLElement, id: number) {
|
||||
if (typeof el.releasePointerCapture === 'function') el.releasePointerCapture(id);
|
||||
}
|
||||
|
||||
interface DragState {
|
||||
id: string;
|
||||
pointerId: number;
|
||||
startX: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Berechnet die neue Reihenfolge, wenn `draggedId` an die Zeigerposition
|
||||
* `clientX` verschoben wird: der gezogene Reiter wird aus der Liste
|
||||
* entfernt, dann vor dem ERSTEN verbleibenden Reiter eingefuegt, dessen
|
||||
* Mittelpunkt rechts vom Zeiger liegt (ansonsten ans Ende).
|
||||
*/
|
||||
function computeReorderedIds(
|
||||
order: string[],
|
||||
draggedId: string,
|
||||
rects: Map<string, { left: number; width: number }>,
|
||||
clientX: number,
|
||||
): string[] {
|
||||
const others = order.filter((id) => id !== draggedId);
|
||||
let insertAt = others.length;
|
||||
for (let i = 0; i < others.length; i++) {
|
||||
const rect = rects.get(others[i]);
|
||||
if (!rect) continue;
|
||||
const midpoint = rect.left + rect.width / 2;
|
||||
if (clientX < midpoint) {
|
||||
insertAt = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const next = others.slice();
|
||||
next.splice(insertAt, 0, draggedId);
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reiterleiste über dem Dashboard-Raster (quick-260923-ad9, Task 3/4).
|
||||
*
|
||||
* Klick wechselt IMMER den Reiter, unabhängig vom Bearbeitungsmodus.
|
||||
* Umbenennen (an Ort und Stelle, Eingabetaste übernimmt, Escape verwirft)
|
||||
* und Löschen (mit Rückfrage) sind nur im Bearbeitungsmodus sichtbar; der
|
||||
* Löschen-Knopf fehlt zusätzlich beim letzten verbleibenden Reiter (D-10 —
|
||||
* der Server weist das ohnehin ab, die Oberfläche bietet es gar nicht erst
|
||||
* an).
|
||||
*
|
||||
* Ziehen (Task 4, D-05 — keine neue Abhängigkeit, Pointer-Ereignisse wie in
|
||||
* `xframe-config-form.tsx`) ist IMMER möglich, nicht nur im Bearbeitungs-
|
||||
* modus: nach vorn ziehen IST das Festlegen des Standards (D-09). Unter der
|
||||
* Schwelle von {@link DRAG_THRESHOLD_PX} bleibt es ein Klick (`onClick`
|
||||
* wechselt den Reiter, kein `onReorder`); darüber wird der Zeiger
|
||||
* eingefangen, die Leiste zeigt die Vorschau-Reihenfolge, und beim
|
||||
* Loslassen geht die VOLLSTÄNDIGE Kennungsliste an `onReorder`. Ein
|
||||
* `hasDraggedRef`-Merker unterdrückt den `onClick`, der nach einem echten
|
||||
* Ziehen im echten Browser folgt (jsdom feuert ihn in Tests nicht von
|
||||
* selbst nach). Abbruch des Zeigers verwirft die Vorschau, ohne zu senden.
|
||||
*/
|
||||
export function DashboardTabs({
|
||||
dashboards,
|
||||
activeDashboardId,
|
||||
isEditMode,
|
||||
onSelect,
|
||||
onCreate,
|
||||
onRename,
|
||||
onDelete,
|
||||
onReorder,
|
||||
}: DashboardTabsProps) {
|
||||
const t = useTranslations('widgets');
|
||||
const tCommon = useTranslations('common');
|
||||
const [renamingId, setRenamingId] = useState<string | null>(null);
|
||||
const [draftName, setDraftName] = useState('');
|
||||
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
|
||||
const [previewOrder, setPreviewOrder] = useState<string[] | null>(null);
|
||||
const renameInputRef = useRef<HTMLInputElement>(null);
|
||||
const dragRef = useRef<DragState | null>(null);
|
||||
const hasDraggedRef = useRef(false);
|
||||
const tabRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||
|
||||
// Fokus auf das Eingabefeld beim Wechsel in den Umbenennen-Zustand — ueber
|
||||
// einen Ref statt des autoFocus-Attributs (lint/a11y/noAutofocus), Muster
|
||||
// `widget-catalog-modal.tsx` (dialogRef.current?.focus()).
|
||||
useEffect(() => {
|
||||
if (renamingId) {
|
||||
renameInputRef.current?.focus();
|
||||
renameInputRef.current?.select();
|
||||
}
|
||||
}, [renamingId]);
|
||||
|
||||
const order = previewOrder ?? dashboards.map((d) => d.id);
|
||||
const orderedTabs = order
|
||||
.map((id) => dashboards.find((d) => d.id === id))
|
||||
.filter((d): d is DashboardTab => d !== undefined);
|
||||
|
||||
function handlePointerDown(e: PointerEvent<HTMLDivElement>, tabId: string) {
|
||||
if (e.button !== 0 || renamingId) return;
|
||||
dragRef.current = { id: tabId, pointerId: e.pointerId, startX: e.clientX };
|
||||
}
|
||||
|
||||
function handlePointerMove(e: PointerEvent<HTMLDivElement>) {
|
||||
const drag = dragRef.current;
|
||||
if (!drag) return;
|
||||
|
||||
const wasNotDragging = previewOrder === null;
|
||||
if (wasNotDragging) {
|
||||
if (Math.abs(e.clientX - drag.startX) < DRAG_THRESHOLD_PX) return;
|
||||
hasDraggedRef.current = true;
|
||||
capturePointer(e.currentTarget, drag.pointerId);
|
||||
}
|
||||
|
||||
// Auf dem SELBEN Ereignis, das die Schwelle ueberschreitet, wird sofort
|
||||
// die Zielposition berechnet — nicht erst beim naechsten Move. Ohne das
|
||||
// wuerde ein einzelner grosser Sprung (Druecken, weit bewegen, Loslassen
|
||||
// — die Form, in der ein Ziehen unter jsdom typischerweise ausgeloest
|
||||
// wird) keine Verschiebung zeigen, weil der ERSTE Move-Aufruf nur in den
|
||||
// Ziehzustand wechselt, ohne die Position auszuwerten.
|
||||
const baseOrder = previewOrder ?? dashboards.map((d) => d.id);
|
||||
const rects = new Map<string, { left: number; width: number }>();
|
||||
for (const id of baseOrder) {
|
||||
const el = tabRefs.current.get(id);
|
||||
if (el) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
rects.set(id, { left: rect.left, width: rect.width });
|
||||
}
|
||||
}
|
||||
const next = computeReorderedIds(baseOrder, drag.id, rects, e.clientX);
|
||||
if (wasNotDragging || next.join('\u0000') !== baseOrder.join('\u0000')) {
|
||||
setPreviewOrder(next);
|
||||
}
|
||||
}
|
||||
|
||||
function handlePointerUp(e: PointerEvent<HTMLDivElement>) {
|
||||
const drag = dragRef.current;
|
||||
dragRef.current = null;
|
||||
if (!drag) return;
|
||||
|
||||
if (previewOrder !== null) {
|
||||
releasePointer(e.currentTarget, drag.pointerId);
|
||||
const finalOrder = previewOrder;
|
||||
setPreviewOrder(null);
|
||||
onReorder(finalOrder);
|
||||
}
|
||||
}
|
||||
|
||||
function handlePointerCancel(e: PointerEvent<HTMLDivElement>) {
|
||||
const drag = dragRef.current;
|
||||
dragRef.current = null;
|
||||
if (drag && previewOrder !== null) {
|
||||
releasePointer(e.currentTarget, drag.pointerId);
|
||||
}
|
||||
setPreviewOrder(null);
|
||||
}
|
||||
|
||||
function handleTabClick(tabId: string) {
|
||||
if (hasDraggedRef.current) {
|
||||
hasDraggedRef.current = false;
|
||||
return;
|
||||
}
|
||||
onSelect(tabId);
|
||||
}
|
||||
|
||||
function startRename(tab: DashboardTab) {
|
||||
setRenamingId(tab.id);
|
||||
setDraftName(tab.name);
|
||||
}
|
||||
|
||||
function commitRename() {
|
||||
const id = renamingId;
|
||||
const name = draftName.trim();
|
||||
setRenamingId(null);
|
||||
if (id && name) {
|
||||
onRename(id, name);
|
||||
}
|
||||
}
|
||||
|
||||
function cancelRename() {
|
||||
setRenamingId(null);
|
||||
}
|
||||
|
||||
const pendingDeleteTab = dashboards.find((d) => d.id === pendingDeleteId) ?? null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<nav aria-label={t('tabs.navLabel')} className="mb-1 flex items-center gap-1 overflow-x-auto">
|
||||
{orderedTabs.map((tab, index) => {
|
||||
const isActive = tab.id === activeDashboardId;
|
||||
const isRenaming = renamingId === tab.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={tab.id}
|
||||
ref={(el) => {
|
||||
if (el) tabRefs.current.set(tab.id, el);
|
||||
else tabRefs.current.delete(tab.id);
|
||||
}}
|
||||
data-tab-index={index}
|
||||
data-testid={`dashboard-tab-${tab.id}`}
|
||||
className="flex touch-none items-center"
|
||||
onPointerDown={(e) => handlePointerDown(e, tab.id)}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerCancel={handlePointerCancel}
|
||||
>
|
||||
{isRenaming ? (
|
||||
<input
|
||||
ref={renameInputRef}
|
||||
value={draftName}
|
||||
onChange={(e) => setDraftName(e.target.value)}
|
||||
onBlur={commitRename}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
commitRename();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
cancelRename();
|
||||
}
|
||||
}}
|
||||
maxLength={40}
|
||||
aria-label={t('tabs.renameInputLabel')}
|
||||
className="h-8 w-32 rounded border border-border bg-background px-2 text-sm text-foreground"
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleTabClick(tab.id)}
|
||||
aria-current={isActive ? 'true' : undefined}
|
||||
className={`h-8 cursor-grab rounded-t px-3 text-sm font-medium transition-colors ${
|
||||
isActive
|
||||
? 'border border-b-transparent border-border bg-card text-foreground'
|
||||
: 'text-muted-foreground hover:bg-muted'
|
||||
}`}
|
||||
>
|
||||
{tab.name}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isEditMode && isActive && !isRenaming && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => startRename(tab)}
|
||||
aria-label={t('tabs.renameButtonLabel')}
|
||||
title={t('tabs.renameButtonLabel')}
|
||||
className="ml-0.5 rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isEditMode && dashboards.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPendingDeleteId(tab.id)}
|
||||
aria-label={t('tabs.deleteButtonLabel')}
|
||||
title={t('tabs.deleteButtonLabel')}
|
||||
className="ml-0.5 rounded p-1 text-muted-foreground hover:bg-muted hover:text-destructive"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{isEditMode && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCreate}
|
||||
aria-label={t('tabs.add')}
|
||||
title={t('tabs.add')}
|
||||
className="ml-1 flex h-8 w-8 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{pendingDeleteTab && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPendingDeleteId(null)}
|
||||
aria-label={tCommon('cancel')}
|
||||
className="fixed inset-0 bg-black/50"
|
||||
/>
|
||||
<div
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
aria-label={t('tabs.deleteDialogTitle')}
|
||||
className="relative z-50 mx-4 max-w-md rounded-lg border border-border bg-card p-6 shadow-xl"
|
||||
>
|
||||
<h3 className="mb-2 text-lg font-semibold text-foreground">
|
||||
{t('tabs.deleteDialogTitle')}
|
||||
</h3>
|
||||
<p className="mb-4 text-sm text-muted-foreground">
|
||||
{t('tabs.deleteDialogBody', { name: pendingDeleteTab.name })}
|
||||
</p>
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPendingDeleteId(null)}
|
||||
className="rounded border border-border px-4 py-2 text-sm text-foreground transition-colors hover:bg-muted"
|
||||
>
|
||||
{tCommon('cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onDelete(pendingDeleteTab.id);
|
||||
setPendingDeleteId(null);
|
||||
}}
|
||||
className="rounded bg-destructive px-4 py-2 text-sm font-medium text-destructive-foreground transition-colors hover:bg-destructive/90"
|
||||
>
|
||||
{tCommon('delete')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
{dashboards.length > 1 && (
|
||||
<p className="mb-2 text-xs text-muted-foreground">{t('tabs.dragHint')}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -36,12 +36,17 @@ vi.mock('next-intl', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
import { WIDGET_TYPES } from '@tessera/shared';
|
||||
import { WIDGET_REGISTRY } from './widget-registry';
|
||||
import { WidgetCatalogModal } from './widget-catalog-modal';
|
||||
|
||||
// Alle neun Kacheln sind heute Plattform-Kacheln ohne moduleSlug, also zeigt
|
||||
// der Katalog sie auch bei leerer Modulliste vollstaendig an.
|
||||
const baseProps = {
|
||||
isOpen: true,
|
||||
onClose: vi.fn(),
|
||||
onAddWidget: vi.fn(),
|
||||
accessibleModuleSlugs: [] as string[],
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
@@ -130,3 +135,50 @@ describe('WidgetCatalogModal', () => {
|
||||
expect(screen.getByRole('button', { name: 'Schließen' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* quick-260922-m1h: Der Katalog fuehrt keine zweite Typliste mehr — er leitet
|
||||
* sie aus der Registry ab und filtert nach Modulzugriff.
|
||||
*/
|
||||
describe('WidgetCatalogModal: Liste kommt aus der Registry (quick-260922-m1h)', () => {
|
||||
it('zeigt alle neun Kacheln in der Reihenfolge der Registry', () => {
|
||||
render(<WidgetCatalogModal {...baseProps} />);
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: 'Widget hinzufügen' });
|
||||
const cards = Array.from(
|
||||
dialog.querySelectorAll<HTMLButtonElement>('button[data-widget-type]'),
|
||||
);
|
||||
|
||||
expect(cards.map((c) => c.getAttribute('data-widget-type'))).toEqual([
|
||||
...WIDGET_TYPES,
|
||||
]);
|
||||
expect(Object.keys(WIDGET_REGISTRY)).toEqual([...WIDGET_TYPES]);
|
||||
});
|
||||
|
||||
it('eine Kachel MIT moduleSlug fehlt, wenn das Modul nicht zugaenglich ist, und erscheint, wenn doch', () => {
|
||||
// Die Registry traegt heute keine Modul-Kachel — fuer den Nachweis am
|
||||
// echten Bauteil wird clock voruebergehend zu einer gemacht.
|
||||
WIDGET_REGISTRY.clock.moduleSlug = 'proxmox';
|
||||
try {
|
||||
const { rerender } = render(<WidgetCatalogModal {...baseProps} />);
|
||||
expect(screen.queryByRole('button', { name: /Uhr/ })).toBeNull();
|
||||
|
||||
rerender(<WidgetCatalogModal {...baseProps} accessibleModuleSlugs={['proxmox']} />);
|
||||
expect(screen.getByRole('button', { name: /Uhr/ })).toBeInTheDocument();
|
||||
} finally {
|
||||
WIDGET_REGISTRY.clock.moduleSlug = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
it('fail-closed: schlaegt der Modulabruf fehl (null), verschwinden Kacheln MIT moduleSlug, Plattform-Kacheln bleiben', () => {
|
||||
WIDGET_REGISTRY.clock.moduleSlug = 'proxmox';
|
||||
try {
|
||||
render(<WidgetCatalogModal {...baseProps} accessibleModuleSlugs={null} />);
|
||||
|
||||
expect(screen.queryByRole('button', { name: /Uhr/ })).toBeNull();
|
||||
expect(screen.getByRole('button', { name: /Notiz/ })).toBeInTheDocument();
|
||||
} finally {
|
||||
WIDGET_REGISTRY.clock.moduleSlug = undefined;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,28 +2,30 @@
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { WIDGET_REGISTRY, type WidgetType } from './widget-registry';
|
||||
import { WIDGET_REGISTRY, type WidgetType, visibleWidgetTypes } from './widget-registry';
|
||||
|
||||
interface WidgetCatalogModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onAddWidget: (type: WidgetType) => void;
|
||||
/**
|
||||
* Slugs der Module, die der angemeldete Benutzer nutzen darf — geholt von
|
||||
* der Dashboard-Seite ueber `GET /modules/active` (quick-260922-m1h).
|
||||
* `null` heisst "noch unbekannt oder Abruf fehlgeschlagen": dann bleiben
|
||||
* Kacheln MIT `moduleSlug` ausgeblendet (fail-closed).
|
||||
*
|
||||
* Der Abruf steht bewusst NICHT in diesem Dialog, damit er ein reines
|
||||
* Anzeige-Bauteil bleibt und ohne Netzwerk-Attrappe testbar ist.
|
||||
*/
|
||||
accessibleModuleSlugs: readonly string[] | null;
|
||||
}
|
||||
|
||||
const WIDGET_TYPES: WidgetType[] = [
|
||||
'clock',
|
||||
'search',
|
||||
'calendar',
|
||||
'note',
|
||||
'calculator',
|
||||
'favorites',
|
||||
'stopwatch',
|
||||
'picture-frame',
|
||||
'xframe',
|
||||
];
|
||||
|
||||
/**
|
||||
* Modal dialog showing available widget types as selectable cards.
|
||||
*
|
||||
* quick-260922-m1h: Die Liste kommt aus WIDGET_REGISTRY (Reihenfolge der
|
||||
* Registry-Definition) statt aus einer zweiten, hier gepflegten Liste — eine
|
||||
* neue Kachel musste sonst an zwei Stellen eingetragen werden.
|
||||
* Click on a card adds the widget to the dashboard and closes the modal.
|
||||
* Escape to close, click outside to close, focus trap (D-01 flow).
|
||||
*/
|
||||
@@ -31,6 +33,7 @@ export function WidgetCatalogModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onAddWidget,
|
||||
accessibleModuleSlugs,
|
||||
}: WidgetCatalogModalProps) {
|
||||
const t = useTranslations('widgets');
|
||||
const tCommon = useTranslations('common');
|
||||
@@ -107,13 +110,14 @@ export function WidgetCatalogModal({
|
||||
|
||||
{/* 2x2 grid of widget type cards */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{WIDGET_TYPES.map((type) => {
|
||||
{visibleWidgetTypes(WIDGET_REGISTRY, accessibleModuleSlugs).map((type) => {
|
||||
const def = WIDGET_REGISTRY[type];
|
||||
const Icon = def.icon;
|
||||
return (
|
||||
<button
|
||||
key={type}
|
||||
type="button"
|
||||
data-widget-type={type}
|
||||
onClick={() => {
|
||||
onAddWidget(type);
|
||||
onClose();
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { WIDGET_CONSTRAINTS, type WidgetType } from './widget-registry';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { WIDGET_TYPES } from '@tessera/shared';
|
||||
import {
|
||||
WIDGET_CONSTRAINTS,
|
||||
WIDGET_REGISTRY,
|
||||
type WidgetDefinition,
|
||||
type WidgetProps,
|
||||
type WidgetType,
|
||||
registerWidget,
|
||||
visibleWidgetTypes,
|
||||
} from './widget-registry';
|
||||
|
||||
/**
|
||||
* DASH-11: Every WidgetType entry in WIDGET_CONSTRAINTS must have
|
||||
@@ -82,3 +91,118 @@ describe('WIDGET_CONSTRAINTS (DASH-11)', () => {
|
||||
expect(counted).toBe(36);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* quick-260922-m1h: Deckungsgleichheit. Die Typliste steht seit diesem Umbau
|
||||
* EINMAL in `packages/shared`; Registry, Constraints-Tabelle und die
|
||||
* Erwartungsliste dieses Tests muessen dieselben Schluessel in derselben
|
||||
* Reihenfolge tragen. Dieser Test faengt kuenftig jede vergessene Stelle.
|
||||
*/
|
||||
describe('Typliste ist an einer Stelle definiert (quick-260922-m1h)', () => {
|
||||
it('WIDGET_TYPES aus @tessera/shared, Registry-Schluessel und Constraints-Schluessel sind deckungsgleich (gleiche Reihenfolge)', () => {
|
||||
expect(Object.keys(WIDGET_REGISTRY)).toEqual([...WIDGET_TYPES]);
|
||||
expect(Object.keys(WIDGET_CONSTRAINTS)).toEqual([...WIDGET_TYPES]);
|
||||
});
|
||||
|
||||
it('die neun erwarteten Kacheln stehen unveraendert und in unveraenderter Reihenfolge in WIDGET_TYPES', () => {
|
||||
expect([...WIDGET_TYPES]).toEqual(ALL_WIDGET_TYPES);
|
||||
});
|
||||
|
||||
it('jeder Registry-Eintrag traegt seinen eigenen Typ als `type`', () => {
|
||||
for (const type of WIDGET_TYPES) {
|
||||
expect(WIDGET_REGISTRY[type].type).toBe(type);
|
||||
}
|
||||
});
|
||||
|
||||
it('heute traegt keine der neun Kacheln einen moduleSlug (alle sind Plattform-Kacheln)', () => {
|
||||
for (const type of WIDGET_TYPES) {
|
||||
expect(WIDGET_REGISTRY[type].moduleSlug).toBeUndefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('registerWidget (quick-260922-m1h)', () => {
|
||||
function makeComponent(): (props: WidgetProps) => null {
|
||||
return () => null;
|
||||
}
|
||||
|
||||
it('meldet eine Komponente fuer ihren Typ an', () => {
|
||||
const before = WIDGET_REGISTRY.clock.component;
|
||||
const component = makeComponent();
|
||||
|
||||
registerWidget('clock', component);
|
||||
|
||||
expect(WIDGET_REGISTRY.clock.component).toBe(component);
|
||||
|
||||
WIDGET_REGISTRY.clock.component = before;
|
||||
});
|
||||
|
||||
it('ist idempotent: eine zweite Anmeldung desselben Typs ist ein No-Op (wie die alten wireX-Flags)', () => {
|
||||
const before = WIDGET_REGISTRY.note.component;
|
||||
const first = makeComponent();
|
||||
const second = makeComponent();
|
||||
|
||||
registerWidget('note', first);
|
||||
registerWidget('note', second);
|
||||
|
||||
expect(WIDGET_REGISTRY.note.component).toBe(first);
|
||||
|
||||
WIDGET_REGISTRY.note.component = before;
|
||||
});
|
||||
|
||||
it('ein unbekannter Typ wirft in der Entwicklung', () => {
|
||||
expect(() =>
|
||||
// Absichtlich ein Typ ausserhalb der Union — genau der Fall, den der
|
||||
// Wurf melden soll (eine Kachel, die in WIDGET_TYPES vergessen wurde).
|
||||
registerWidget('proxmox' as WidgetType, makeComponent()),
|
||||
).toThrow(/proxmox/);
|
||||
});
|
||||
|
||||
it('ein unbekannter Typ wird in der Produktion still ignoriert', () => {
|
||||
const previous = process.env.NODE_ENV;
|
||||
vi.stubEnv('NODE_ENV', 'production');
|
||||
|
||||
expect(() => registerWidget('proxmox' as WidgetType, makeComponent())).not.toThrow();
|
||||
|
||||
vi.stubEnv('NODE_ENV', previous ?? 'test');
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* quick-260922-m1h: Der Katalogfilter als reine Funktion — so testbar ohne
|
||||
* eine echte Modul-Kachel zu erfinden (Muster picture-frame-config.ts).
|
||||
* WICHTIG (T-M1H-01): Dieser Filter ist Komfort. Die verbindliche
|
||||
* Durchsetzung bleibt serverseitig in DashboardService.getWidgets.
|
||||
*/
|
||||
describe('visibleWidgetTypes (quick-260922-m1h)', () => {
|
||||
const testRegistry: Record<string, Pick<WidgetDefinition, 'moduleSlug'>> = {
|
||||
clock: {},
|
||||
proxmox: { moduleSlug: 'proxmox' },
|
||||
note: {},
|
||||
};
|
||||
|
||||
it('behaelt die Reihenfolge der Registry bei', () => {
|
||||
expect(visibleWidgetTypes(WIDGET_REGISTRY, [])).toEqual([...WIDGET_TYPES]);
|
||||
});
|
||||
|
||||
it('Kacheln ohne moduleSlug sind immer sichtbar', () => {
|
||||
expect(visibleWidgetTypes(testRegistry, [])).toEqual(['clock', 'note']);
|
||||
});
|
||||
|
||||
it('eine Kachel mit moduleSlug fehlt, wenn der Slug nicht in den zugaenglichen Modulen steht', () => {
|
||||
expect(visibleWidgetTypes(testRegistry, ['domaincheck'])).toEqual(['clock', 'note']);
|
||||
});
|
||||
|
||||
it('eine Kachel mit moduleSlug erscheint, wenn der Slug in den zugaenglichen Modulen steht', () => {
|
||||
expect(visibleWidgetTypes(testRegistry, ['domaincheck', 'proxmox'])).toEqual([
|
||||
'clock',
|
||||
'proxmox',
|
||||
'note',
|
||||
]);
|
||||
});
|
||||
|
||||
it('fail-closed: ist die Modulliste unbekannt (null, z. B. fehlgeschlagener Abruf), verschwinden alle Kacheln MIT moduleSlug', () => {
|
||||
expect(visibleWidgetTypes(testRegistry, null)).toEqual(['clock', 'note']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
import type { ComponentType } from 'react';
|
||||
import { WIDGET_MODULE_SLUGS, type WidgetType } from '@tessera/shared';
|
||||
|
||||
/**
|
||||
* Supported widget types for the dashboard.
|
||||
* Die Typliste der Kacheln steht seit quick-260922-m1h EINMAL, in
|
||||
* `packages/shared/src/index.ts` — dieselbe Liste, gegen die die API in
|
||||
* `create-widget.dto.ts` mit `@IsIn` validiert. Vorher stand sie an sieben
|
||||
* Stellen; vergass man eine, fehlte die Kachel im Katalog oder die API
|
||||
* lehnte sie mit 400 ab.
|
||||
*
|
||||
* Hier weiter-exportiert, weil ein knappes Dutzend Web-Dateien den Typ seit
|
||||
* jeher von der Registry bezieht (dashboard-grid, widget-wrapper,
|
||||
* dashboard-store, widget-settings-panel ...).
|
||||
*
|
||||
* clock/search/calendar/note: implemented in previous plans.
|
||||
* calculator/favorites/stopwatch: Phase 8 additions (der fruehere
|
||||
* Einzel-Schnellzugriffs-Typ wurde in quick-260916-iex entfernt — Favoriten
|
||||
@@ -9,16 +19,7 @@ import type { ComponentType } from 'react';
|
||||
* picture-frame: Bilderrahmen (quick-260921-pi9).
|
||||
* xframe: Webseite als Rahmen (quick-260921-qd3).
|
||||
*/
|
||||
export type WidgetType =
|
||||
| 'clock'
|
||||
| 'search'
|
||||
| 'calendar'
|
||||
| 'note'
|
||||
| 'calculator'
|
||||
| 'favorites'
|
||||
| 'stopwatch'
|
||||
| 'picture-frame'
|
||||
| 'xframe';
|
||||
export type { WidgetType };
|
||||
|
||||
/**
|
||||
* Props contract that every widget component must accept.
|
||||
@@ -76,6 +77,12 @@ export interface WidgetDefinition {
|
||||
type: WidgetType;
|
||||
nameKey: string;
|
||||
descriptionKey: string;
|
||||
/**
|
||||
* Modul, zu dem diese Kachel gehoert (quick-260922-m1h), aus
|
||||
* `WIDGET_MODULE_SLUGS`. Fehlt der Eintrag, ist es eine Plattform-Kachel
|
||||
* und immer sichtbar — der heutige Zustand fuer alle neun Kacheln.
|
||||
*/
|
||||
moduleSlug?: string;
|
||||
/** Inline SVG icon as React component */
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
minW: number;
|
||||
@@ -318,7 +325,8 @@ export const WIDGET_REGISTRY: Record<WidgetType, WidgetDefinition> = {
|
||||
descriptionKey: 'clock.description',
|
||||
icon: ClockIcon,
|
||||
...WIDGET_CONSTRAINTS.clock,
|
||||
component: PlaceholderWidget, // Replaced via wireClockWidget()
|
||||
moduleSlug: WIDGET_MODULE_SLUGS.clock,
|
||||
component: PlaceholderWidget, // wird in (portal)/page.tsx per registerWidget() ersetzt
|
||||
},
|
||||
search: {
|
||||
type: 'search',
|
||||
@@ -326,7 +334,8 @@ export const WIDGET_REGISTRY: Record<WidgetType, WidgetDefinition> = {
|
||||
descriptionKey: 'search.description',
|
||||
icon: SearchIcon,
|
||||
...WIDGET_CONSTRAINTS.search,
|
||||
component: PlaceholderWidget, // Replaced via wireSearchWidget()
|
||||
moduleSlug: WIDGET_MODULE_SLUGS.search,
|
||||
component: PlaceholderWidget, // wird in (portal)/page.tsx per registerWidget() ersetzt
|
||||
},
|
||||
calendar: {
|
||||
type: 'calendar',
|
||||
@@ -334,7 +343,8 @@ export const WIDGET_REGISTRY: Record<WidgetType, WidgetDefinition> = {
|
||||
descriptionKey: 'calendar.description',
|
||||
icon: CalendarIcon,
|
||||
...WIDGET_CONSTRAINTS.calendar,
|
||||
component: PlaceholderWidget, // Replaced via wireCalendarWidget()
|
||||
moduleSlug: WIDGET_MODULE_SLUGS.calendar,
|
||||
component: PlaceholderWidget, // wird in (portal)/page.tsx per registerWidget() ersetzt
|
||||
},
|
||||
note: {
|
||||
type: 'note',
|
||||
@@ -342,7 +352,8 @@ export const WIDGET_REGISTRY: Record<WidgetType, WidgetDefinition> = {
|
||||
descriptionKey: 'note.description',
|
||||
icon: NoteIcon,
|
||||
...WIDGET_CONSTRAINTS.note,
|
||||
component: PlaceholderWidget, // Replaced via wireNoteWidget()
|
||||
moduleSlug: WIDGET_MODULE_SLUGS.note,
|
||||
component: PlaceholderWidget, // wird in (portal)/page.tsx per registerWidget() ersetzt
|
||||
},
|
||||
calculator: {
|
||||
type: 'calculator',
|
||||
@@ -350,7 +361,8 @@ export const WIDGET_REGISTRY: Record<WidgetType, WidgetDefinition> = {
|
||||
descriptionKey: 'calculator.description',
|
||||
icon: CalculatorIcon,
|
||||
...WIDGET_CONSTRAINTS.calculator,
|
||||
component: PlaceholderWidget, // Replaced via wireCalculatorWidget()
|
||||
moduleSlug: WIDGET_MODULE_SLUGS.calculator,
|
||||
component: PlaceholderWidget, // wird in (portal)/page.tsx per registerWidget() ersetzt
|
||||
},
|
||||
favorites: {
|
||||
type: 'favorites',
|
||||
@@ -358,7 +370,8 @@ export const WIDGET_REGISTRY: Record<WidgetType, WidgetDefinition> = {
|
||||
descriptionKey: 'favorites.description',
|
||||
icon: FavoritesIcon,
|
||||
...WIDGET_CONSTRAINTS.favorites,
|
||||
component: PlaceholderWidget, // Replaced via wireFavoritesWidget()
|
||||
moduleSlug: WIDGET_MODULE_SLUGS.favorites,
|
||||
component: PlaceholderWidget, // wird in (portal)/page.tsx per registerWidget() ersetzt
|
||||
},
|
||||
stopwatch: {
|
||||
type: 'stopwatch',
|
||||
@@ -366,7 +379,8 @@ export const WIDGET_REGISTRY: Record<WidgetType, WidgetDefinition> = {
|
||||
descriptionKey: 'stopwatch.description',
|
||||
icon: StopwatchIcon,
|
||||
...WIDGET_CONSTRAINTS.stopwatch,
|
||||
component: PlaceholderWidget, // Replaced via wireStopwatchWidget()
|
||||
moduleSlug: WIDGET_MODULE_SLUGS.stopwatch,
|
||||
component: PlaceholderWidget, // wird in (portal)/page.tsx per registerWidget() ersetzt
|
||||
},
|
||||
'picture-frame': {
|
||||
type: 'picture-frame',
|
||||
@@ -374,7 +388,8 @@ export const WIDGET_REGISTRY: Record<WidgetType, WidgetDefinition> = {
|
||||
descriptionKey: 'pictureFrame.description',
|
||||
icon: PictureFrameIcon,
|
||||
...WIDGET_CONSTRAINTS['picture-frame'],
|
||||
component: PlaceholderWidget, // Replaced via wirePictureFrameWidget()
|
||||
moduleSlug: WIDGET_MODULE_SLUGS['picture-frame'],
|
||||
component: PlaceholderWidget, // wird in (portal)/page.tsx per registerWidget() ersetzt
|
||||
},
|
||||
xframe: {
|
||||
type: 'xframe',
|
||||
@@ -382,82 +397,76 @@ export const WIDGET_REGISTRY: Record<WidgetType, WidgetDefinition> = {
|
||||
descriptionKey: 'xframe.description',
|
||||
icon: XframeIcon,
|
||||
...WIDGET_CONSTRAINTS.xframe,
|
||||
component: PlaceholderWidget, // Replaced via wireXframeWidget()
|
||||
moduleSlug: WIDGET_MODULE_SLUGS.xframe,
|
||||
component: PlaceholderWidget, // wird in (portal)/page.tsx per registerWidget() ersetzt
|
||||
},
|
||||
};
|
||||
|
||||
// Wire actual widget components lazily to avoid circular deps
|
||||
// (imports are deferred so widget-registry can be imported by tests without
|
||||
// pulling in the entire React tree)
|
||||
/**
|
||||
* Meldet die Komponente einer Kachel an ihrem Registry-Eintrag an
|
||||
* (quick-260922-m1h — ersetzt die vormals neun `wireXWidget()`-Funktionen
|
||||
* mit je eigenem Bool-Flag).
|
||||
*
|
||||
* Aufgerufen wird sie in `apps/web/src/app/(portal)/page.tsx` und NICHT
|
||||
* hier: die Komponenten duerfen nicht aus der Registry heraus importiert
|
||||
* werden, sonst entsteht ein Zirkelimport (jede Kachel importiert ueber den
|
||||
* Wrapper wieder die Registry). Die Seite ist die Stelle, an der beides
|
||||
* zusammenkommt.
|
||||
*
|
||||
* Mehrfachanmeldung desselben Typs ist ein No-Op — die erste gewinnt, genau
|
||||
* wie die alten Flags. Ein Typ, der nicht in `WIDGET_TYPES` steht, wirft in
|
||||
* der Entwicklung (dann fehlt der Eintrag in `packages/shared`) und wird in
|
||||
* der Produktion ignoriert, damit eine vergessene Kachel nicht das ganze
|
||||
* Dashboard mitreisst.
|
||||
*/
|
||||
const registeredTypes = new Set<WidgetType>();
|
||||
|
||||
let clockWired = false;
|
||||
export function wireClockWidget(component: ComponentType<WidgetProps>) {
|
||||
if (!clockWired) {
|
||||
WIDGET_REGISTRY.clock.component = component;
|
||||
clockWired = true;
|
||||
export function registerWidget(type: WidgetType, component: ComponentType<WidgetProps>) {
|
||||
const definition: WidgetDefinition | undefined = WIDGET_REGISTRY[type];
|
||||
|
||||
if (!definition) {
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
throw new Error(
|
||||
`registerWidget: unbekannter Widget-Typ "${type}". Fehlt der Typ in WIDGET_TYPES (packages/shared/src/index.ts)?`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (registeredTypes.has(type)) return;
|
||||
|
||||
definition.component = component;
|
||||
registeredTypes.add(type);
|
||||
}
|
||||
|
||||
let searchWired = false;
|
||||
export function wireSearchWidget(component: ComponentType<WidgetProps>) {
|
||||
if (!searchWired) {
|
||||
WIDGET_REGISTRY.search.component = component;
|
||||
searchWired = true;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Welche Kacheln der Katalog zeigen darf (quick-260922-m1h).
|
||||
*
|
||||
* Eine Kachel ohne `moduleSlug` ist immer sichtbar; eine Kachel MIT
|
||||
* `moduleSlug` nur, wenn der Slug unter den zugaenglichen Modulen steht.
|
||||
* `accessibleModuleSlugs === null` heisst "Modulliste unbekannt" (Abruf
|
||||
* laeuft noch oder ist fehlgeschlagen) — dann sind Modul-Kacheln
|
||||
* ausgeblendet, fail-closed wie serverseitig.
|
||||
*
|
||||
* ACHTUNG (T-M1H-01): Das hier ist reiner Komfort — es verhindert nur, dass
|
||||
* jemand eine Kachel anlegt, die ihm danach kommentarlos wieder verschwindet.
|
||||
* Die verbindliche Durchsetzung bleibt serverseitig in
|
||||
* `DashboardService.getWidgets` (fail-closed) und im Modul-Guard der
|
||||
* jeweiligen Daten-Endpunkte. Diesen Filter zu umgehen bringt nichts.
|
||||
*
|
||||
* Reine Funktion mit der Registry als Parameter, damit sie ohne eine echte
|
||||
* Modul-Kachel testbar ist (Muster `picture-frame-config.ts`).
|
||||
*/
|
||||
export function visibleWidgetTypes<T extends string>(
|
||||
registry: Record<T, Pick<WidgetDefinition, 'moduleSlug'>>,
|
||||
accessibleModuleSlugs: readonly string[] | null,
|
||||
): T[] {
|
||||
const types = Object.keys(registry) as T[];
|
||||
|
||||
let calendarWired = false;
|
||||
export function wireCalendarWidget(component: ComponentType<WidgetProps>) {
|
||||
if (!calendarWired) {
|
||||
WIDGET_REGISTRY.calendar.component = component;
|
||||
calendarWired = true;
|
||||
}
|
||||
}
|
||||
|
||||
let noteWired = false;
|
||||
export function wireNoteWidget(component: ComponentType<WidgetProps>) {
|
||||
if (!noteWired) {
|
||||
WIDGET_REGISTRY.note.component = component;
|
||||
noteWired = true;
|
||||
}
|
||||
}
|
||||
|
||||
let calculatorWired = false;
|
||||
export function wireCalculatorWidget(component: ComponentType<WidgetProps>) {
|
||||
if (!calculatorWired) {
|
||||
WIDGET_REGISTRY.calculator.component = component;
|
||||
calculatorWired = true;
|
||||
}
|
||||
}
|
||||
|
||||
let favoritesWired = false;
|
||||
export function wireFavoritesWidget(component: ComponentType<WidgetProps>) {
|
||||
if (!favoritesWired) {
|
||||
WIDGET_REGISTRY.favorites.component = component;
|
||||
favoritesWired = true;
|
||||
}
|
||||
}
|
||||
|
||||
let stopwatchWired = false;
|
||||
export function wireStopwatchWidget(component: ComponentType<WidgetProps>) {
|
||||
if (!stopwatchWired) {
|
||||
WIDGET_REGISTRY.stopwatch.component = component;
|
||||
stopwatchWired = true;
|
||||
}
|
||||
}
|
||||
|
||||
let pictureFrameWired = false;
|
||||
export function wirePictureFrameWidget(component: ComponentType<WidgetProps>) {
|
||||
if (!pictureFrameWired) {
|
||||
WIDGET_REGISTRY['picture-frame'].component = component;
|
||||
pictureFrameWired = true;
|
||||
}
|
||||
}
|
||||
|
||||
let xframeWired = false;
|
||||
export function wireXframeWidget(component: ComponentType<WidgetProps>) {
|
||||
if (!xframeWired) {
|
||||
WIDGET_REGISTRY.xframe.component = component;
|
||||
xframeWired = true;
|
||||
}
|
||||
return types.filter((type) => {
|
||||
const slug = registry[type].moduleSlug;
|
||||
if (slug === undefined) return true;
|
||||
if (accessibleModuleSlugs === null) return false;
|
||||
return accessibleModuleSlugs.includes(slug);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,15 +3,21 @@ import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// quick-260916-iex: Link-Widget entfernt — unbekannte Widget-Typen (z. B.
|
||||
// eine alte Link-Kachel vor dem Einspielen der Migration) muessen weiterhin
|
||||
// ohne Absturz als grauer Text gerendert werden.
|
||||
// ohne Absturz gerendert werden.
|
||||
// quick-260922-m1h: Statt des rohen Typnamens steht dort jetzt ein Satz, der
|
||||
// den Fall erklaert — derselbe Fall tritt kuenftig auf, wenn eine Kachel zu
|
||||
// einem Modul gehoert, das dem Benutzer nicht freigegeben ist.
|
||||
vi.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
useTranslations: () => (key: string) =>
|
||||
key === 'unavailable'
|
||||
? 'Diese Kachel steht nicht zur Verfügung — das zugehörige Modul ist nicht freigegeben.'
|
||||
: key,
|
||||
}));
|
||||
|
||||
import { WidgetWrapper } from './widget-wrapper';
|
||||
|
||||
describe('WidgetWrapper', () => {
|
||||
it('unbekannter Widget-Typ (z. B. eine alte Link-Kachel vor der Migration) rendert als grauer Text ohne Absturz', () => {
|
||||
it('unbekannter/gesperrter Widget-Typ erklaert sich mit einem Hinweistext statt leer oder als roher Typname zu rendern', () => {
|
||||
render(
|
||||
<WidgetWrapper
|
||||
widget={{ id: 'w-alt', widgetType: 'link', config: {} }}
|
||||
@@ -23,7 +29,29 @@ describe('WidgetWrapper', () => {
|
||||
const article = screen.getByRole('article');
|
||||
expect(article).toHaveAttribute('aria-label', 'link');
|
||||
|
||||
const fallback = screen.getByText('link');
|
||||
expect(fallback.className).toContain('text-muted-foreground');
|
||||
const hint = screen.getByText(
|
||||
'Diese Kachel steht nicht zur Verfügung — das zugehörige Modul ist nicht freigegeben.',
|
||||
);
|
||||
expect(hint.className).toContain('text-muted-foreground');
|
||||
expect(hint.className).toContain('text-center');
|
||||
|
||||
// Der rohe Typname steht nicht mehr im Rumpf der Kachel.
|
||||
expect(screen.queryByText('link')).toBeNull();
|
||||
});
|
||||
|
||||
it('eine bekannte Kachel rendert weiterhin ihre Komponente, nicht den Hinweis', () => {
|
||||
render(
|
||||
<WidgetWrapper
|
||||
widget={{ id: 'w-uhr', widgetType: 'clock', config: {} }}
|
||||
isEditMode={false}
|
||||
onRemove={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByText(
|
||||
'Diese Kachel steht nicht zur Verfügung — das zugehörige Modul ist nicht freigegeben.',
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -111,8 +111,14 @@ export function WidgetWrapper({ widget, isEditMode, onRemove }: WidgetWrapperPro
|
||||
isEditMode={isEditMode}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
{widget.widgetType}
|
||||
/* quick-260922-m1h: Kein Bauteil zu diesem Typ — entweder eine alte
|
||||
Kachel eines entfernten Typs oder (ab der ersten Modul-Kachel) eine
|
||||
Kachel, deren Modul dem Benutzer nicht freigegeben ist. Vorher
|
||||
stand hier der rohe Typname, der dem Anwender nichts sagte. */
|
||||
<div className="flex h-full items-center justify-center p-3">
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
{t('unavailable')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -22,9 +22,11 @@ export interface AppVersionInfo {
|
||||
}
|
||||
|
||||
/**
|
||||
* Spiegel von `VersionResponse` aus `packages/shared`: `apps/web` haengt
|
||||
* nicht von `@tessera/shared` ab, und ein neuer Import wuerde Lockfile und
|
||||
* die deps-Stufe des Dockerfiles aendern. Die API-Wahrheit bleibt dort.
|
||||
* Spiegel von `VersionResponse` aus `packages/shared`. Die Begruendung
|
||||
* "apps/web haengt nicht von @tessera/shared ab" gilt seit quick-260922-m1h
|
||||
* nicht mehr — die Kachel-Typliste wird von dort importiert. Dieser Spiegel
|
||||
* bleibt trotzdem stehen: ihn aufzuloesen war nicht Teil jenes Umbaus und
|
||||
* braucht einen eigenen Durchgang. Die API-Wahrheit bleibt in packages/shared.
|
||||
*/
|
||||
export interface ApiVersionInfo {
|
||||
name: string;
|
||||
|
||||
@@ -5,44 +5,105 @@
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
||||
|
||||
export async function fetchLayout(): Promise<Record<string, unknown>> {
|
||||
const res = await fetch(`${API_URL}/dashboard/layout`, {
|
||||
// --- Reiter (quick-260923-ad9) ---------------------------------------------
|
||||
|
||||
export interface DashboardTab {
|
||||
id: string;
|
||||
name: string;
|
||||
position: number;
|
||||
}
|
||||
|
||||
export async function fetchDashboards(): Promise<DashboardTab[]> {
|
||||
const res = await fetch(`${API_URL}/dashboard/tabs`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to fetch dashboards');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function createDashboardTab(): Promise<DashboardTab> {
|
||||
const res = await fetch(`${API_URL}/dashboard/tabs`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to create dashboard');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function renameDashboardTab(id: string, name: string): Promise<DashboardTab> {
|
||||
const res = await fetch(`${API_URL}/dashboard/tabs/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to rename dashboard');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function deleteDashboardTab(id: string): Promise<void> {
|
||||
const res = await fetch(`${API_URL}/dashboard/tabs/${id}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to delete dashboard');
|
||||
}
|
||||
|
||||
export async function reorderDashboardTabs(ids: string[]): Promise<DashboardTab[]> {
|
||||
const res = await fetch(`${API_URL}/dashboard/tabs/order`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ ids }),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to reorder dashboards');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// --- Anordnung und Kacheln — je Reiter (quick-260923-ad9) ------------------
|
||||
|
||||
export async function fetchLayout(dashboardId: string): Promise<Record<string, unknown>> {
|
||||
const res = await fetch(
|
||||
`${API_URL}/dashboard/layout?dashboardId=${encodeURIComponent(dashboardId)}`,
|
||||
{ credentials: 'include' },
|
||||
);
|
||||
if (!res.ok) throw new Error('Failed to fetch layout');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function saveLayout(
|
||||
dashboardId: string,
|
||||
layouts: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const res = await fetch(`${API_URL}/dashboard/layout`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ layouts }),
|
||||
body: JSON.stringify({ dashboardId, layouts }),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to save layout');
|
||||
}
|
||||
|
||||
export async function fetchWidgets(): Promise<
|
||||
Array<{ id: string; widgetType: string; config: Record<string, unknown> }>
|
||||
> {
|
||||
const res = await fetch(`${API_URL}/dashboard/widgets`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
export async function fetchWidgets(
|
||||
dashboardId: string,
|
||||
): Promise<Array<{ id: string; widgetType: string; config: Record<string, unknown> }>> {
|
||||
const res = await fetch(
|
||||
`${API_URL}/dashboard/widgets?dashboardId=${encodeURIComponent(dashboardId)}`,
|
||||
{ credentials: 'include' },
|
||||
);
|
||||
if (!res.ok) throw new Error('Failed to fetch widgets');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function addWidget(
|
||||
dashboardId: string,
|
||||
widgetType: string,
|
||||
): Promise<{ id: string; widgetType: string; config: Record<string, unknown> }> {
|
||||
const res = await fetch(`${API_URL}/dashboard/widgets`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ widgetType }),
|
||||
body: JSON.stringify({ dashboardId, widgetType }),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to add widget');
|
||||
return res.json();
|
||||
|
||||
@@ -17,8 +17,9 @@ export type DesktopPlatform = 'windows' | 'linux';
|
||||
|
||||
/**
|
||||
* Spiegel von `DesktopLatestFile`/`DesktopLatestResponse` aus
|
||||
* `packages/shared`: `apps/web` haengt nicht von `@tessera/shared` ab
|
||||
* (gleiche Begruendung wie in `app-version.ts`). Die API-Wahrheit bleibt in
|
||||
* `packages/shared` (gleicher Stand wie in `app-version.ts`: seit
|
||||
* quick-260922-m1h waere ein Import moeglich, der Spiegel wurde aber bewusst
|
||||
* nicht mit aufgeloest). Die API-Wahrheit bleibt in
|
||||
* `apps/api/src/desktop/desktop.service.ts`.
|
||||
*/
|
||||
export interface DesktopFileInfo {
|
||||
|
||||
@@ -53,6 +53,12 @@ export const MODULE_REGISTRY: Record<string, ModuleRegistryEntry> = {
|
||||
{ ssr: false },
|
||||
),
|
||||
},
|
||||
proxmox: {
|
||||
component: dynamic(
|
||||
() => import('@/app/(portal)/modules/proxmox/page'),
|
||||
{ ssr: false },
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* Proxmox Module API client (260923-dhh). Konsumiert `/modules/proxmox/*`.
|
||||
* Vorbild `dkv-api.ts`: `credentials: 'include'` fuer Cookie-Auth,
|
||||
* `NEXT_PUBLIC_API_URL` als Basis.
|
||||
*
|
||||
* Sicherheit (T-DHH-01): keine Antwort dieses Clients enthaelt jemals ein
|
||||
* Geheimnisfeld — der Server waehlt `encryptedTokenSecret`/`encryptedPassword`
|
||||
* per `select` gar nicht erst aus.
|
||||
*/
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
||||
|
||||
export type ProxmoxProductType = 'pve' | 'pbs' | 'pmg';
|
||||
export type ProxmoxAuthMethod = 'token' | 'password';
|
||||
export type ProxmoxErrorKind =
|
||||
| 'netz'
|
||||
| 'zugang'
|
||||
| 'rechte'
|
||||
| 'zertifikat'
|
||||
| 'antwortform'
|
||||
| 'server'
|
||||
| 'unbekannt';
|
||||
|
||||
export interface ProxmoxPveNodeMetric {
|
||||
node: string;
|
||||
cpu: number | null;
|
||||
maxcpu: number | null;
|
||||
mem: number | null;
|
||||
maxmem: number | null;
|
||||
}
|
||||
|
||||
export interface ProxmoxPveStorageMetric {
|
||||
storage: string;
|
||||
node: string;
|
||||
disk: number | null;
|
||||
maxdisk: number | null;
|
||||
}
|
||||
|
||||
export interface ProxmoxPveMetrics {
|
||||
productType: 'pve';
|
||||
nodeCount: number;
|
||||
guestsRunning: number;
|
||||
guestsStopped: number;
|
||||
nodes: ProxmoxPveNodeMetric[];
|
||||
storages: ProxmoxPveStorageMetric[];
|
||||
}
|
||||
|
||||
export interface ProxmoxPbsDatastoreMetric {
|
||||
name: string;
|
||||
total: number | null;
|
||||
used: number | null;
|
||||
free: number | null;
|
||||
lastBackupAt: number | null;
|
||||
lastVerifyState: string | null;
|
||||
}
|
||||
|
||||
export interface ProxmoxPbsMetrics {
|
||||
productType: 'pbs';
|
||||
datastores: ProxmoxPbsDatastoreMetric[];
|
||||
}
|
||||
|
||||
export interface ProxmoxPmgMetrics {
|
||||
productType: 'pmg';
|
||||
countIn: number | null;
|
||||
countOut: number | null;
|
||||
spamCount: number | null;
|
||||
virusCount: number | null;
|
||||
}
|
||||
|
||||
export type ProxmoxMetrics = ProxmoxPveMetrics | ProxmoxPbsMetrics | ProxmoxPmgMetrics;
|
||||
|
||||
export interface ProxmoxServerStatus {
|
||||
id: string;
|
||||
serverId: string;
|
||||
lastPolledAt: string | null;
|
||||
lastOkAt: string | null;
|
||||
reachable: boolean;
|
||||
errorKind: ProxmoxErrorKind | null;
|
||||
errorDetail: string | null;
|
||||
metrics: ProxmoxMetrics | null;
|
||||
rawSample: unknown;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ProxmoxServer {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
name: string;
|
||||
productType: ProxmoxProductType;
|
||||
baseUrl: string;
|
||||
authMethod: ProxmoxAuthMethod;
|
||||
tokenId: string | null;
|
||||
username: string | null;
|
||||
tlsRejectUnauthorized: boolean;
|
||||
isActive: boolean;
|
||||
pollIntervalMin: number;
|
||||
position: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
status: ProxmoxServerStatus | null;
|
||||
}
|
||||
|
||||
export interface CreateProxmoxServerPayload {
|
||||
name: string;
|
||||
productType: ProxmoxProductType;
|
||||
baseUrl: string;
|
||||
authMethod: ProxmoxAuthMethod;
|
||||
tokenId?: string;
|
||||
tokenSecret?: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
tlsRejectUnauthorized?: boolean;
|
||||
pollIntervalMin?: number;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export type UpdateProxmoxServerPayload = Partial<CreateProxmoxServerPayload>;
|
||||
|
||||
export interface ProxmoxTestResult {
|
||||
reachable: boolean;
|
||||
errorKind: ProxmoxErrorKind | null;
|
||||
errorDetail: string | null;
|
||||
metrics: ProxmoxMetrics | null;
|
||||
rawSample: unknown;
|
||||
}
|
||||
|
||||
/** Liest die NestJS-Fehlermeldung aus dem Antwortkoerper, faellt sonst auf einen Standardtext zurueck. */
|
||||
async function readErrorMessage(res: Response, fallback: string): Promise<string> {
|
||||
try {
|
||||
const body = await res.json();
|
||||
if (typeof body?.message === 'string') return body.message;
|
||||
if (Array.isArray(body?.message) && body.message.length > 0) return String(body.message[0]);
|
||||
} catch {
|
||||
/* Antwort war kein JSON — Standardtext bleibt */
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/** GET /modules/proxmox/servers — Serverliste samt Zwischenlager. */
|
||||
export async function listServers(): Promise<ProxmoxServer[]> {
|
||||
const res = await fetch(`${API_URL}/modules/proxmox/servers`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to fetch proxmox servers');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** POST /modules/proxmox/servers — Server anlegen (ADMIN/SUPER_ADMIN). */
|
||||
export async function createServer(
|
||||
payload: CreateProxmoxServerPayload,
|
||||
): Promise<ProxmoxServer> {
|
||||
const res = await fetch(`${API_URL}/modules/proxmox/servers`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) throw new Error(await readErrorMessage(res, 'Failed to create proxmox server'));
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** PUT /modules/proxmox/servers/:id — Server bearbeiten (ADMIN/SUPER_ADMIN). */
|
||||
export async function updateServer(
|
||||
id: string,
|
||||
payload: UpdateProxmoxServerPayload,
|
||||
): Promise<ProxmoxServer> {
|
||||
const res = await fetch(`${API_URL}/modules/proxmox/servers/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) throw new Error(await readErrorMessage(res, 'Failed to update proxmox server'));
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** DELETE /modules/proxmox/servers/:id — Server samt Zwischenlagerzeile loeschen (ADMIN/SUPER_ADMIN). */
|
||||
export async function deleteServer(id: string): Promise<void> {
|
||||
const res = await fetch(`${API_URL}/modules/proxmox/servers/${id}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!res.ok) throw new Error(await readErrorMessage(res, 'Failed to delete proxmox server'));
|
||||
}
|
||||
|
||||
/** POST /modules/proxmox/servers/:id/poll — sofortige Abfrage (ADMIN/SUPER_ADMIN). */
|
||||
export async function pollServer(id: string): Promise<ProxmoxTestResult> {
|
||||
const res = await fetch(`${API_URL}/modules/proxmox/servers/${id}/poll`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!res.ok) throw new Error(await readErrorMessage(res, 'Failed to poll proxmox server'));
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /modules/proxmox/servers/:id/test — Verbindungstest fuer einen
|
||||
* gespeicherten Server, schreibt NICHT ins Zwischenlager. `payload` traegt
|
||||
* den aktuellen Formularstand (Nachbesserung Befund 1): der Test prueft
|
||||
* damit, was im Formular steht, statt blind den gespeicherten Stand — ein
|
||||
* leer gelassenes Geheimnisfeld (`tokenSecret`/`password: undefined`) laesst
|
||||
* den Server serverseitig auf den gespeicherten Wert zurueckfallen.
|
||||
*/
|
||||
export async function testServer(
|
||||
id: string,
|
||||
payload: UpdateProxmoxServerPayload,
|
||||
): Promise<ProxmoxTestResult> {
|
||||
const res = await fetch(`${API_URL}/modules/proxmox/servers/${id}/test`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) throw new Error(await readErrorMessage(res, 'Failed to test proxmox server'));
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /modules/proxmox/servers/test — Verbindungstest waehrend der
|
||||
* Neuanlage (Nachbesserung Befund 1): es gibt noch keinen gespeicherten
|
||||
* Server, `payload` ist deshalb die einzige Quelle.
|
||||
*/
|
||||
export async function testDraftServer(
|
||||
payload: UpdateProxmoxServerPayload,
|
||||
): Promise<ProxmoxTestResult> {
|
||||
const res = await fetch(`${API_URL}/modules/proxmox/servers/test`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) throw new Error(await readErrorMessage(res, 'Failed to test proxmox server'));
|
||||
return res.json();
|
||||
}
|
||||
@@ -1,17 +1,31 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
/**
|
||||
* dashboard-store.test — NEU (quick-260916-bwo, feineres Dashboard-Raster).
|
||||
* dashboard-store.test — quick-260916-bwo (Marker-Umrechnung) + erweitert um
|
||||
* quick-260923-ad9 (Task 3, Dashboard-Reiter).
|
||||
*
|
||||
* Sechs Tests fuer die Anbindung der einmaligen Umrechnung im Store:
|
||||
* `loadDashboard` rechnet alte Anordnungen um und speichert SOFORT mit
|
||||
* Marker; `saveLayout` traegt den Marker bei JEDEM Speichern (T-BWO-02 —
|
||||
* fehlt er, wuerde das naechste Laden erneut verdoppeln); der Zustand
|
||||
* selbst bleibt markerfrei (der Store iteriert mit Object.keys ueber die
|
||||
* Breakpoints); `addWidget` legt neue Eintraege in den verdoppelten
|
||||
* Vorgabegroessen an. Der Store hatte bisher keine Testdatei.
|
||||
* Sechs Tests (1-6) pinnen weiterhin die Anbindung der einmaligen
|
||||
* Umrechnung im Store — `loadDashboard`/`selectDashboard` rechnen alte
|
||||
* Anordnungen um und speichern SOFORT mit Marker; `saveLayout` traegt den
|
||||
* Marker bei JEDEM Speichern (T-BWO-02); der Zustand selbst bleibt
|
||||
* markerfrei; `addWidget` legt neue Eintraege in den verdoppelten
|
||||
* Vorgabegroessen an. Signaturen sind an die Reiter-Kennung angepasst
|
||||
* (`fetchLayout(dashboardId)`, `saveLayout(dashboardId, layouts)`,
|
||||
* `addWidget(dashboardId, widgetType)`), das gepruefte Verhalten ist
|
||||
* dasselbe.
|
||||
*
|
||||
* Ab Test 7: Reiter-Verwaltung — Laden holt zuerst die Reiterliste, macht
|
||||
* den ersten aktiv und laedt erst danach dessen Inhalt; ein Reiterwechsel
|
||||
* schreibt eine ungespeicherte Anordnung zuerst fuer den ALTEN Reiter;
|
||||
* Anlegen/Umbenennen/Loeschen pflegen die Reiterliste und den aktiven
|
||||
* Reiter.
|
||||
*/
|
||||
vi.mock('@/lib/dashboard-api', () => ({
|
||||
fetchDashboards: vi.fn(),
|
||||
createDashboardTab: vi.fn(),
|
||||
renameDashboardTab: vi.fn(),
|
||||
deleteDashboardTab: vi.fn(),
|
||||
reorderDashboardTabs: vi.fn(),
|
||||
fetchLayout: vi.fn(),
|
||||
fetchWidgets: vi.fn(),
|
||||
saveLayout: vi.fn(),
|
||||
@@ -25,9 +39,14 @@ import * as api from '@/lib/dashboard-api';
|
||||
const { useDashboardStore } = await import('./dashboard-store');
|
||||
|
||||
const EMPTY = { lg: [], md: [], sm: [], xs: [], xxs: [] };
|
||||
const DASH_1 = { id: 'dash-1', name: 'Dashboard', position: 0 };
|
||||
const DASH_2 = { id: 'dash-2', name: 'Dashboard 2', position: 1 };
|
||||
|
||||
beforeEach(() => {
|
||||
useDashboardStore.setState({
|
||||
dashboards: [],
|
||||
activeDashboardId: null,
|
||||
isSwitchingDashboard: false,
|
||||
layouts: { lg: [], md: [], sm: [], xs: [], xxs: [] },
|
||||
widgets: [],
|
||||
isEditMode: false,
|
||||
@@ -36,7 +55,9 @@ beforeEach(() => {
|
||||
error: null,
|
||||
});
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(api.fetchDashboards).mockResolvedValue([DASH_1]);
|
||||
vi.mocked(api.fetchWidgets).mockResolvedValue([]);
|
||||
vi.mocked(api.fetchLayout).mockResolvedValue({ ...EMPTY });
|
||||
vi.mocked(api.saveLayout).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
@@ -45,7 +66,7 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe('dashboard-store — einmalige Umrechnung mit Marker (quick-260916-bwo)', () => {
|
||||
it('Test 1: alte Anordnung wird beim Laden umgerechnet und SOFORT mit Marker gespeichert', async () => {
|
||||
it('Test 1: alte Anordnung wird beim Laden umgerechnet und SOFORT fuer den ersten Reiter mit Marker gespeichert', async () => {
|
||||
vi.mocked(api.fetchLayout).mockResolvedValue({
|
||||
lg: [{ i: 'a', x: 1, y: 1, w: 2, h: 2 }], md: [], sm: [], xs: [], xxs: [],
|
||||
});
|
||||
@@ -57,6 +78,7 @@ describe('dashboard-store — einmalige Umrechnung mit Marker (quick-260916-bwo)
|
||||
expect(Object.keys(state.layouts)).not.toContain('__gridVersion');
|
||||
expect(api.saveLayout).toHaveBeenCalledTimes(1);
|
||||
expect(api.saveLayout).toHaveBeenCalledWith(
|
||||
'dash-1',
|
||||
expect.objectContaining({ __gridVersion: 2, lg: [{ i: 'a', x: 2, y: 2, w: 4, h: 4 }] }),
|
||||
);
|
||||
expect(state.isDirty).toBe(false);
|
||||
@@ -86,7 +108,10 @@ describe('dashboard-store — einmalige Umrechnung mit Marker (quick-260916-bwo)
|
||||
expect(useDashboardStore.getState().layouts).toEqual(EMPTY);
|
||||
});
|
||||
|
||||
it('Test 4: jedes Speichern traegt den Marker (T-BWO-02), der Zustand bleibt markerfrei', async () => {
|
||||
it('Test 4: jedes Speichern traegt den Marker (T-BWO-02) unter der aktiven Reiter-Kennung, der Zustand bleibt markerfrei', async () => {
|
||||
await useDashboardStore.getState().loadDashboard();
|
||||
vi.mocked(api.saveLayout).mockClear();
|
||||
|
||||
const layouts = { lg: [{ i: 'a', x: 2, y: 2, w: 4, h: 4 }], md: [], sm: [], xs: [], xxs: [] };
|
||||
useDashboardStore.getState().updateLayouts(layouts);
|
||||
expect(useDashboardStore.getState().isDirty).toBe(true);
|
||||
@@ -94,7 +119,10 @@ describe('dashboard-store — einmalige Umrechnung mit Marker (quick-260916-bwo)
|
||||
await useDashboardStore.getState().saveLayout();
|
||||
|
||||
expect(api.saveLayout).toHaveBeenCalledTimes(1);
|
||||
expect(api.saveLayout).toHaveBeenCalledWith(expect.objectContaining({ __gridVersion: 2, ...layouts }));
|
||||
expect(api.saveLayout).toHaveBeenCalledWith(
|
||||
'dash-1',
|
||||
expect.objectContaining({ __gridVersion: 2, ...layouts }),
|
||||
);
|
||||
expect(useDashboardStore.getState().isDirty).toBe(false);
|
||||
expect(Object.keys(useDashboardStore.getState().layouts)).not.toContain('__gridVersion');
|
||||
});
|
||||
@@ -115,13 +143,180 @@ describe('dashboard-store — einmalige Umrechnung mit Marker (quick-260916-bwo)
|
||||
expect(errorSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('Test 6: neues Widget wird in den neuen (verdoppelten) Vorgabegroessen angelegt', async () => {
|
||||
it('Test 6: neues Widget wird in den neuen (verdoppelten) Vorgabegroessen fuer den aktiven Reiter angelegt', async () => {
|
||||
await useDashboardStore.getState().loadDashboard();
|
||||
vi.mocked(api.addWidget).mockResolvedValue({ id: 'n1', widgetType: 'clock', config: {} });
|
||||
|
||||
await useDashboardStore.getState().addWidget('clock');
|
||||
|
||||
const state = useDashboardStore.getState();
|
||||
expect(api.addWidget).toHaveBeenCalledWith('dash-1', 'clock');
|
||||
expect(state.layouts.lg).toContainEqual({ i: 'n1', x: 0, y: 0, w: 4, h: 4 });
|
||||
expect(state.isDirty).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('dashboard-store — Reiter laden (quick-260923-ad9, Task 3)', () => {
|
||||
it('Test 7: holt zuerst die Reiterliste, macht den ERSTEN aktiv und laedt erst danach dessen Kacheln und Anordnung', async () => {
|
||||
vi.mocked(api.fetchDashboards).mockResolvedValue([DASH_1, DASH_2]);
|
||||
const callOrder: string[] = [];
|
||||
vi.mocked(api.fetchDashboards).mockImplementation(async () => {
|
||||
callOrder.push('tabs');
|
||||
return [DASH_1, DASH_2];
|
||||
});
|
||||
vi.mocked(api.fetchLayout).mockImplementation(async () => {
|
||||
callOrder.push('layout');
|
||||
return { ...EMPTY };
|
||||
});
|
||||
vi.mocked(api.fetchWidgets).mockImplementation(async () => {
|
||||
callOrder.push('widgets');
|
||||
return [];
|
||||
});
|
||||
|
||||
await useDashboardStore.getState().loadDashboard();
|
||||
|
||||
expect(callOrder[0]).toBe('tabs');
|
||||
expect(callOrder.slice(1).sort()).toEqual(['layout', 'widgets']);
|
||||
expect(api.fetchLayout).toHaveBeenCalledWith('dash-1');
|
||||
expect(api.fetchWidgets).toHaveBeenCalledWith('dash-1');
|
||||
expect(useDashboardStore.getState().activeDashboardId).toBe('dash-1');
|
||||
expect(useDashboardStore.getState().dashboards).toEqual([DASH_1, DASH_2]);
|
||||
});
|
||||
|
||||
it('Test 8: zweimaliges Aufrufen von loadDashboard hintereinander loest nur EINEN Abruf der Reiterliste aus', async () => {
|
||||
const first = useDashboardStore.getState().loadDashboard();
|
||||
const second = useDashboardStore.getState().loadDashboard();
|
||||
|
||||
await Promise.all([first, second]);
|
||||
|
||||
expect(api.fetchDashboards).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('dashboard-store — Reiterwechsel (quick-260923-ad9, Task 3)', () => {
|
||||
it('Test 9: ein Wechsel mit ungespeicherter Anordnung schreibt die Anordnung ZUERST fuer den ALTEN Reiter', async () => {
|
||||
vi.mocked(api.fetchDashboards).mockResolvedValue([DASH_1, DASH_2]);
|
||||
await useDashboardStore.getState().loadDashboard();
|
||||
useDashboardStore.getState().updateLayouts({ lg: [{ i: 'x', x: 0, y: 0, w: 1, h: 1 }], md: [], sm: [], xs: [], xxs: [] });
|
||||
vi.mocked(api.saveLayout).mockClear();
|
||||
vi.mocked(api.fetchLayout).mockResolvedValue({ ...EMPTY });
|
||||
vi.mocked(api.fetchWidgets).mockResolvedValue([]);
|
||||
|
||||
await useDashboardStore.getState().selectDashboard('dash-2');
|
||||
|
||||
expect(api.saveLayout).toHaveBeenCalledWith('dash-1', expect.anything());
|
||||
expect(useDashboardStore.getState().activeDashboardId).toBe('dash-2');
|
||||
});
|
||||
|
||||
it('Test 10: nach dem Wechsel stehen im Zustand AUSSCHLIESSLICH Kacheln und Anordnung des neuen Reiters', async () => {
|
||||
vi.mocked(api.fetchDashboards).mockResolvedValue([DASH_1, DASH_2]);
|
||||
await useDashboardStore.getState().loadDashboard();
|
||||
|
||||
vi.mocked(api.fetchLayout).mockResolvedValue({
|
||||
lg: [{ i: 'only-on-dash-2', x: 0, y: 0, w: 2, h: 2 }], md: [], sm: [], xs: [], xxs: [], __gridVersion: 2,
|
||||
});
|
||||
vi.mocked(api.fetchWidgets).mockResolvedValue([{ id: 'w-on-dash-2', widgetType: 'clock', config: {} }]);
|
||||
|
||||
await useDashboardStore.getState().selectDashboard('dash-2');
|
||||
|
||||
const state = useDashboardStore.getState();
|
||||
expect(state.widgets).toEqual([{ id: 'w-on-dash-2', widgetType: 'clock', config: {} }]);
|
||||
expect(state.layouts.lg).toEqual([{ i: 'only-on-dash-2', x: 0, y: 0, w: 2, h: 2 }]);
|
||||
});
|
||||
|
||||
it('Test 11: ein Wechsel auf den bereits aktiven Reiter tut nichts', async () => {
|
||||
await useDashboardStore.getState().loadDashboard();
|
||||
vi.mocked(api.fetchLayout).mockClear();
|
||||
vi.mocked(api.fetchWidgets).mockClear();
|
||||
|
||||
await useDashboardStore.getState().selectDashboard('dash-1');
|
||||
|
||||
expect(api.fetchLayout).not.toHaveBeenCalled();
|
||||
expect(api.fetchWidgets).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('dashboard-store — Reiter anlegen/umbenennen/loeschen (quick-260923-ad9, Task 3)', () => {
|
||||
it('Test 12: createDashboard haengt den neuen Reiter hinten an, macht ihn aktiv, die Kachelflaeche ist leer', async () => {
|
||||
await useDashboardStore.getState().loadDashboard();
|
||||
vi.mocked(api.createDashboardTab).mockResolvedValue(DASH_2);
|
||||
vi.mocked(api.fetchLayout).mockResolvedValue({ ...EMPTY });
|
||||
vi.mocked(api.fetchWidgets).mockResolvedValue([]);
|
||||
|
||||
await useDashboardStore.getState().createDashboard();
|
||||
|
||||
const state = useDashboardStore.getState();
|
||||
expect(state.dashboards.map((d) => d.id)).toEqual(['dash-1', 'dash-2']);
|
||||
expect(state.activeDashboardId).toBe('dash-2');
|
||||
expect(state.widgets).toEqual([]);
|
||||
});
|
||||
|
||||
it('Test 13: renameDashboard aktualisiert den Namen in der Reiterliste', async () => {
|
||||
await useDashboardStore.getState().loadDashboard();
|
||||
vi.mocked(api.renameDashboardTab).mockResolvedValue({ id: 'dash-1', name: 'Finanzen', position: 0 });
|
||||
|
||||
await useDashboardStore.getState().renameDashboard('dash-1', 'Finanzen');
|
||||
|
||||
expect(useDashboardStore.getState().dashboards[0].name).toBe('Finanzen');
|
||||
});
|
||||
|
||||
it('Test 14: deleteDashboard des AKTIVEN Reiters macht den dann ersten verbleibenden Reiter aktiv und laedt dessen Inhalt', async () => {
|
||||
vi.mocked(api.fetchDashboards).mockResolvedValue([DASH_1, DASH_2]);
|
||||
await useDashboardStore.getState().loadDashboard();
|
||||
vi.mocked(api.deleteDashboardTab).mockResolvedValue(undefined);
|
||||
vi.mocked(api.fetchLayout).mockResolvedValue({ ...EMPTY });
|
||||
vi.mocked(api.fetchWidgets).mockResolvedValue([{ id: 'w-on-dash-2', widgetType: 'clock', config: {} }]);
|
||||
|
||||
await useDashboardStore.getState().deleteDashboard('dash-1');
|
||||
|
||||
const state = useDashboardStore.getState();
|
||||
expect(state.dashboards.map((d) => d.id)).toEqual(['dash-2']);
|
||||
expect(state.activeDashboardId).toBe('dash-2');
|
||||
expect(state.widgets).toEqual([{ id: 'w-on-dash-2', widgetType: 'clock', config: {} }]);
|
||||
});
|
||||
|
||||
it('Test 15: deleteDashboard eines INAKTIVEN Reiters aendert den aktiven Reiter nicht', async () => {
|
||||
vi.mocked(api.fetchDashboards).mockResolvedValue([DASH_1, DASH_2]);
|
||||
await useDashboardStore.getState().loadDashboard();
|
||||
vi.mocked(api.deleteDashboardTab).mockResolvedValue(undefined);
|
||||
vi.mocked(api.fetchLayout).mockClear();
|
||||
|
||||
await useDashboardStore.getState().deleteDashboard('dash-2');
|
||||
|
||||
expect(useDashboardStore.getState().activeDashboardId).toBe('dash-1');
|
||||
expect(useDashboardStore.getState().dashboards.map((d) => d.id)).toEqual(['dash-1']);
|
||||
expect(api.fetchLayout).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('dashboard-store — Reiter per Ziehen umsortieren (quick-260923-ad9, Task 4)', () => {
|
||||
it('Test 16: setzt die neue Reihenfolge SOFORT optimistisch, sendet die vollstaendige Kennungsliste, der aktive Reiter bleibt aktiv', async () => {
|
||||
vi.mocked(api.fetchDashboards).mockResolvedValue([DASH_1, DASH_2]);
|
||||
await useDashboardStore.getState().loadDashboard();
|
||||
vi.mocked(api.reorderDashboardTabs).mockResolvedValue([DASH_2, DASH_1]);
|
||||
|
||||
const promise = useDashboardStore.getState().reorderDashboards(['dash-2', 'dash-1']);
|
||||
|
||||
// Optimistisch VOR der Antwort des Servers gesetzt.
|
||||
expect(useDashboardStore.getState().dashboards.map((d) => d.id)).toEqual(['dash-2', 'dash-1']);
|
||||
expect(useDashboardStore.getState().activeDashboardId).toBe('dash-1');
|
||||
|
||||
await promise;
|
||||
|
||||
expect(api.reorderDashboardTabs).toHaveBeenCalledWith(['dash-2', 'dash-1']);
|
||||
expect(useDashboardStore.getState().dashboards).toEqual([DASH_2, DASH_1]);
|
||||
expect(useDashboardStore.getState().activeDashboardId).toBe('dash-1');
|
||||
});
|
||||
|
||||
it('Test 17: schlaegt das Speichern fehl, steht die VORHERIGE Reihenfolge wieder im Zustand', async () => {
|
||||
vi.mocked(api.fetchDashboards).mockResolvedValue([DASH_1, DASH_2]);
|
||||
await useDashboardStore.getState().loadDashboard();
|
||||
vi.mocked(api.reorderDashboardTabs).mockRejectedValue(new Error('PUT failed'));
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
await useDashboardStore.getState().reorderDashboards(['dash-2', 'dash-1']);
|
||||
|
||||
expect(useDashboardStore.getState().dashboards).toEqual([DASH_1, DASH_2]);
|
||||
expect(errorSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { create } from 'zustand';
|
||||
import * as api from '@/lib/dashboard-api';
|
||||
import type { DashboardTab } from '@/lib/dashboard-api';
|
||||
import type { WidgetType } from '@/components/dashboard/widget-registry';
|
||||
import { WIDGET_CONSTRAINTS } from '@/components/dashboard/widget-registry';
|
||||
import { migrateGridLayouts, withGridVersion } from '@/lib/grid-layout-migration';
|
||||
@@ -11,6 +12,9 @@ export interface WidgetInstance {
|
||||
}
|
||||
|
||||
interface DashboardState {
|
||||
dashboards: DashboardTab[];
|
||||
activeDashboardId: string | null;
|
||||
isSwitchingDashboard: boolean;
|
||||
layouts: Record<string, Array<{ i: string; x: number; y: number; w: number; h: number }>>;
|
||||
widgets: WidgetInstance[];
|
||||
isEditMode: boolean;
|
||||
@@ -24,6 +28,34 @@ interface DashboardState {
|
||||
removeWidget: (id: string) => Promise<void>;
|
||||
loadDashboard: () => Promise<void>;
|
||||
saveLayout: () => Promise<void>;
|
||||
selectDashboard: (id: string) => Promise<void>;
|
||||
createDashboard: () => Promise<void>;
|
||||
renameDashboard: (id: string, name: string) => Promise<void>;
|
||||
deleteDashboard: (id: string) => Promise<void>;
|
||||
reorderDashboards: (ids: string[]) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schutz gegen doppeltes Laden der Reiterliste (quick-260923-ad9, Task 3,
|
||||
* T-AD9-07 — Gegenstück zur Transaktionssperre in `listDashboards` auf der
|
||||
* API): ein modul-globales Versprechen statt eines Zustandsfeldes, damit
|
||||
* ZWEI synchron hintereinander ausgeloeste `loadDashboard()`-Aufrufe (z. B.
|
||||
* React StrictMode, doppeltes Einhaengen) sich dasselbe, noch nicht
|
||||
* aufgeloeste Versprechen teilen — `api.fetchDashboards()` laeuft dann nur
|
||||
* EINMAL. Nach Abschluss (Erfolg ODER Fehler) wird die Sperre zurueckgesetzt,
|
||||
* damit ein spaeteres, echtes Neuladen (Seite erneut besucht, langer
|
||||
* Zeitabstand) wieder ein frisches Netzwerkergebnis holt statt fuer immer
|
||||
* auf dem ersten Ergebnis zu bleiben.
|
||||
*/
|
||||
let dashboardsLoadPromise: Promise<DashboardTab[]> | null = null;
|
||||
|
||||
function loadDashboardsOnce(): Promise<DashboardTab[]> {
|
||||
if (!dashboardsLoadPromise) {
|
||||
dashboardsLoadPromise = api.fetchDashboards().finally(() => {
|
||||
dashboardsLoadPromise = null;
|
||||
});
|
||||
}
|
||||
return dashboardsLoadPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -37,8 +69,19 @@ interface DashboardState {
|
||||
* ueber die Breakpoints). Fehlt der Marker beim Speichern, wird beim naechsten
|
||||
* Laden erneut verdoppelt — deshalb `withGridVersion` an BEIDEN Speicherstellen
|
||||
* (Sofort-Speichern nach der Umrechnung und `saveLayout`), T-BWO-02.
|
||||
*
|
||||
* quick-260923-ad9 (Task 3): Reiter — mehrere Dashboards je Benutzer. Der
|
||||
* erste (`position` aufsteigend) ist der Standard und wird beim Laden aktiv.
|
||||
* `layouts`/`widgets` gehoeren IMMER zum `activeDashboardId` — ein
|
||||
* Reiterwechsel ersetzt beide vollstaendig, nie ein Zusammenfuehren. Die
|
||||
* Marker-Umrechnung oben gilt weiterhin je Reiter: `selectDashboard`
|
||||
* durchlaeuft dieselbe Umrechnung-plus-Sofort-Speichern-Logik wie
|
||||
* `loadDashboard`.
|
||||
*/
|
||||
export const useDashboardStore = create<DashboardState>()((set, get) => ({
|
||||
dashboards: [],
|
||||
activeDashboardId: null,
|
||||
isSwitchingDashboard: false,
|
||||
layouts: { lg: [], md: [], sm: [], xs: [], xxs: [] },
|
||||
widgets: [],
|
||||
isEditMode: false,
|
||||
@@ -63,8 +106,10 @@ export const useDashboardStore = create<DashboardState>()((set, get) => ({
|
||||
},
|
||||
|
||||
addWidget: async (widgetType: WidgetType) => {
|
||||
const dashboardId = get().activeDashboardId;
|
||||
if (!dashboardId) return;
|
||||
try {
|
||||
const newWidget = await api.addWidget(widgetType);
|
||||
const newWidget = await api.addWidget(dashboardId, widgetType);
|
||||
const constraints = WIDGET_CONSTRAINTS[widgetType];
|
||||
|
||||
set((state) => {
|
||||
@@ -117,12 +162,21 @@ export const useDashboardStore = create<DashboardState>()((set, get) => ({
|
||||
loadDashboard: async () => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const dashboards = await loadDashboardsOnce();
|
||||
const first = dashboards[0];
|
||||
if (!first) {
|
||||
set({ error: 'Failed to load dashboard', isLoading: false });
|
||||
return;
|
||||
}
|
||||
|
||||
const [rawLayouts, widgets] = await Promise.all([
|
||||
api.fetchLayout(),
|
||||
api.fetchWidgets(),
|
||||
api.fetchLayout(first.id),
|
||||
api.fetchWidgets(first.id),
|
||||
]);
|
||||
const { layouts: migratedLayouts, migrated } = migrateGridLayouts(rawLayouts);
|
||||
set({
|
||||
dashboards,
|
||||
activeDashboardId: first.id,
|
||||
layouts: migratedLayouts,
|
||||
widgets,
|
||||
isLoading: false,
|
||||
@@ -132,7 +186,7 @@ export const useDashboardStore = create<DashboardState>()((set, get) => ({
|
||||
// try/catch, damit ein Speicherfehler NICHT als Ladefehler erscheint.
|
||||
if (migrated) {
|
||||
try {
|
||||
await api.saveLayout(withGridVersion(migratedLayouts));
|
||||
await api.saveLayout(first.id, withGridVersion(migratedLayouts));
|
||||
} catch (err) {
|
||||
console.error('Failed to persist migrated layout:', err);
|
||||
}
|
||||
@@ -146,11 +200,129 @@ export const useDashboardStore = create<DashboardState>()((set, get) => ({
|
||||
},
|
||||
|
||||
saveLayout: async () => {
|
||||
const dashboardId = get().activeDashboardId;
|
||||
if (!dashboardId) return;
|
||||
try {
|
||||
await api.saveLayout(withGridVersion(get().layouts));
|
||||
await api.saveLayout(dashboardId, withGridVersion(get().layouts));
|
||||
set({ isDirty: false });
|
||||
} catch (err) {
|
||||
console.error('Failed to save layout:', err);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Wechselt den aktiven Reiter (quick-260923-ad9, Task 3). Eine
|
||||
* ungespeicherte Anordnung wird ZUERST fuer den ALTEN Reiter geschrieben
|
||||
* (`get().saveLayout()` liest `activeDashboardId` VOR dem `set` unten,
|
||||
* schreibt also unter der Kennung des alten Reiters) — erst danach werden
|
||||
* Kacheln und Anordnung des neuen Reiters geladen und ersetzen den
|
||||
* Zustand VOLLSTAENDIG (kein Zusammenfuehren).
|
||||
*/
|
||||
selectDashboard: async (id: string) => {
|
||||
const state = get();
|
||||
if (id === state.activeDashboardId) return;
|
||||
|
||||
if (state.isDirty) {
|
||||
await get().saveLayout();
|
||||
}
|
||||
|
||||
set({ isSwitchingDashboard: true, error: null });
|
||||
try {
|
||||
const [rawLayouts, widgets] = await Promise.all([
|
||||
api.fetchLayout(id),
|
||||
api.fetchWidgets(id),
|
||||
]);
|
||||
const { layouts: migratedLayouts, migrated } = migrateGridLayouts(rawLayouts);
|
||||
set({
|
||||
activeDashboardId: id,
|
||||
layouts: migratedLayouts,
|
||||
widgets,
|
||||
isDirty: false,
|
||||
isSwitchingDashboard: false,
|
||||
});
|
||||
if (migrated) {
|
||||
try {
|
||||
await api.saveLayout(id, withGridVersion(migratedLayouts));
|
||||
} catch (err) {
|
||||
console.error('Failed to persist migrated layout:', err);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to switch dashboard:', err);
|
||||
set({ isSwitchingDashboard: false, error: 'Failed to switch dashboard' });
|
||||
}
|
||||
},
|
||||
|
||||
/** Legt einen neuen, leeren Reiter an und macht ihn aktiv (D-08). */
|
||||
createDashboard: async () => {
|
||||
try {
|
||||
const created = await api.createDashboardTab();
|
||||
set((state) => ({ dashboards: [...state.dashboards, created] }));
|
||||
await get().selectDashboard(created.id);
|
||||
} catch (err) {
|
||||
console.error('Failed to create dashboard:', err);
|
||||
}
|
||||
},
|
||||
|
||||
renameDashboard: async (id: string, name: string) => {
|
||||
try {
|
||||
const updated = await api.renameDashboardTab(id, name);
|
||||
set((state) => ({
|
||||
dashboards: state.dashboards.map((d) => (d.id === id ? updated : d)),
|
||||
}));
|
||||
} catch (err) {
|
||||
console.error('Failed to rename dashboard:', err);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Loescht einen Reiter. Beim aktiven Reiter wird danach der dann erste
|
||||
* verbleibende Reiter aktiv und sein Inhalt geladen (`selectDashboard`).
|
||||
* Die verbleibende Liste wird lokal gefiltert statt neu geladen — die vom
|
||||
* Server neu vergebenen `position`-Werte werden dabei nicht nachgezogen;
|
||||
* das ist fuer die Reihenfolge in der Leiste unschaedlich (sie filtert nur,
|
||||
* sortiert nicht neu), ein spaeteres Umsortieren sendet ohnehin die
|
||||
* VOLLSTAENDIGE Kennungsliste, nicht die Positionszahlen.
|
||||
*/
|
||||
deleteDashboard: async (id: string) => {
|
||||
try {
|
||||
await api.deleteDashboardTab(id);
|
||||
const remaining = get().dashboards.filter((d) => d.id !== id);
|
||||
set({ dashboards: remaining });
|
||||
|
||||
if (get().activeDashboardId === id) {
|
||||
const next = remaining[0];
|
||||
if (next) {
|
||||
set({ activeDashboardId: null });
|
||||
await get().selectDashboard(next.id);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to delete dashboard:', err);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Persistiert eine per Ziehen bestimmte Reihenfolge (quick-260923-ad9,
|
||||
* Task 4). Setzt die neue Reihenfolge SOFORT im Zustand (optimistisch —
|
||||
* die Leiste in `dashboard-tabs.tsx` zeigt schon ihre eigene Vorschau
|
||||
* waehrend des Ziehens, dieses `set` uebernimmt sie beim Loslassen als
|
||||
* Tatsache) und stellt bei einem fehlgeschlagenen Speichern die VORHERIGE
|
||||
* Reihenfolge wieder her. Der aktive Reiter bleibt aktiv, auch wenn er
|
||||
* seine Position wechselt — `activeDashboardId` bleibt unberuehrt.
|
||||
*/
|
||||
reorderDashboards: async (ids: string[]) => {
|
||||
const previous = get().dashboards;
|
||||
const byId = new Map(previous.map((d) => [d.id, d]));
|
||||
const optimistic = ids.map((id) => byId.get(id)).filter((d): d is DashboardTab => d !== undefined);
|
||||
set({ dashboards: optimistic });
|
||||
|
||||
try {
|
||||
const updated = await api.reorderDashboardTabs(ids);
|
||||
set({ dashboards: updated });
|
||||
} catch (err) {
|
||||
console.error('Failed to reorder dashboards:', err);
|
||||
set({ dashboards: previous });
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -213,6 +213,18 @@
|
||||
"saveChanges": "Änderungen speichern",
|
||||
"layoutLoadError": "Dashboard konnte nicht geladen werden. Bitte laden Sie die Seite neu.",
|
||||
"widgetSaveError": "Änderungen konnten nicht gespeichert werden. Bitte versuchen Sie es erneut.",
|
||||
"unavailable": "Diese Kachel steht nicht zur Verfügung — das zugehörige Modul ist nicht freigegeben.",
|
||||
"tabs": {
|
||||
"navLabel": "Dashboard-Reiter",
|
||||
"add": "Dashboard hinzufügen",
|
||||
"switching": "Dashboard wird gewechselt …",
|
||||
"renameButtonLabel": "Dashboard umbenennen",
|
||||
"renameInputLabel": "Name des Dashboards",
|
||||
"deleteButtonLabel": "Dashboard löschen",
|
||||
"deleteDialogTitle": "Dashboard löschen",
|
||||
"deleteDialogBody": "Möchten Sie das Dashboard „{name}“ wirklich löschen? Seine Kacheln und seine Anordnung werden mit gelöscht.",
|
||||
"dragHint": "Ziehen Sie einen Reiter mit der Maus an eine andere Stelle. Der erste Reiter wird beim Öffnen geladen."
|
||||
},
|
||||
"clock": {
|
||||
"name": "Uhr",
|
||||
"description": "Zeigt die aktuelle Uhrzeit an",
|
||||
@@ -691,6 +703,87 @@
|
||||
"checking": "Prüfe...",
|
||||
"error": "Fehler bei der Prüfung"
|
||||
},
|
||||
"proxmox": {
|
||||
"title": "Proxmox",
|
||||
"description": "Zustand Ihrer Proxmox-Server (PVE/PBS/PMG) auf einen Blick — Tessera schaut nur zu, es verändert nichts.",
|
||||
"loading": "Lade Serverliste...",
|
||||
"loadError": "Die Serverliste konnte nicht geladen werden.",
|
||||
"emptyState": "Noch kein Server eingetragen. Legen Sie in den Moduleinstellungen einen Server an.",
|
||||
"card": {
|
||||
"unknownValue": "unbekannt",
|
||||
"lastPolledLabel": "Letzte Abfrage",
|
||||
"notPolledYet": "Noch keine Abfrage gelaufen. Klicken Sie oben auf „{refreshLabel}“.",
|
||||
"notPolledYetAutomatic": "Noch keine Abfrage gelaufen. Die Werte erscheinen nach der nächsten automatischen Abfrage.",
|
||||
"lastOkLabel": "Letzte erfolgreiche Messung",
|
||||
"refresh": "Jetzt aktualisieren",
|
||||
"refreshing": "Wird aktualisiert...",
|
||||
"settingsLink": "Zu den Einstellungen",
|
||||
"pve": {
|
||||
"nodeCount": "Knoten",
|
||||
"guests": "{running} laufend / {stopped} gestoppt",
|
||||
"cpu": "Prozessorlast",
|
||||
"mem": "Speicher"
|
||||
},
|
||||
"pbs": {
|
||||
"used": "Belegt",
|
||||
"lastBackup": "Letzte Sicherung",
|
||||
"noBackupYet": "noch keine Sicherung",
|
||||
"verifyState": "Letzte Prüfung"
|
||||
},
|
||||
"pmg": {
|
||||
"countIn": "Eingehend",
|
||||
"countOut": "Ausgehend",
|
||||
"spamCount": "Spam",
|
||||
"virusCount": "Viren"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"netz": "Der Server ist nicht erreichbar. Bitte prüfen Sie die Adresse und die Netzwerkverbindung.",
|
||||
"zugang": "Der Zugang wurde abgelehnt. Bitte prüfen Sie Benutzername und Passwort beziehungsweise die Token-Angaben.",
|
||||
"rechte": "Die Rechte des hinterlegten Zugangs reichen nicht aus. Bitte prüfen Sie die zugewiesene Rolle am Proxmox-Server.",
|
||||
"zertifikat": "Das Zertifikat des Servers wurde abgelehnt. Prüfen Sie die Adresse oder schalten Sie die Zertifikatsprüfung für diesen einen Server bewusst ab.",
|
||||
"antwortform": "Die Antwort des Servers hatte nicht die erwartete Form. Bitte prüfen Sie die Adresse.",
|
||||
"server": "Der Proxmox-Server meldet einen eigenen Fehler. Bitte versuchen Sie es später erneut.",
|
||||
"unbekannt": "Ein unerwarteter Fehler ist aufgetreten."
|
||||
},
|
||||
"settings": {
|
||||
"title": "Proxmox — Einstellungen",
|
||||
"accessDeniedText": "Diese Seite steht nur Administratoren zur Verfügung.",
|
||||
"addServer": "Server hinzufügen",
|
||||
"noServers": "Noch kein Server eingetragen.",
|
||||
"nameLabel": "Name",
|
||||
"productTypeLabel": "Typ",
|
||||
"productTypePve": "PVE",
|
||||
"productTypePbs": "PBS",
|
||||
"productTypePmg": "PMG",
|
||||
"baseUrlLabel": "Adresse",
|
||||
"authMethodLabel": "Zugangsart",
|
||||
"authMethodToken": "API-Token",
|
||||
"authMethodPassword": "Benutzer/Passwort",
|
||||
"tokenIdLabel": "Token-Kennung",
|
||||
"tokenSecretLabel": "Token-Geheimnis",
|
||||
"usernameLabel": "Benutzername",
|
||||
"passwordLabel": "Passwort",
|
||||
"secretUnchangedPlaceholder": "Leer lassen, um das gespeicherte Geheimnis beizubehalten",
|
||||
"pollIntervalLabel": "Abfrageintervall (Minuten)",
|
||||
"tlsRejectLabel": "Zertifikat prüfen",
|
||||
"tlsRejectHint": "Die Ausnahme gilt nur für diesen einen Server, niemals für alle Server gemeinsam.",
|
||||
"activeLabel": "Aktiv",
|
||||
"save": "Speichern",
|
||||
"saving": "Wird gespeichert...",
|
||||
"saveError": "Die Einstellungen konnten nicht gespeichert werden.",
|
||||
"cancel": "Abbrechen",
|
||||
"testConnection": "Verbindung testen",
|
||||
"testTesting": "Verbindung wird getestet...",
|
||||
"testSuccess": "Verbindung erfolgreich.",
|
||||
"edit": "Bearbeiten",
|
||||
"delete": "Löschen",
|
||||
"deleteConfirmTitle": "Server löschen",
|
||||
"deleteConfirmBody": "Möchten Sie den Server \"{name}\" wirklich löschen? Der zuletzt gemessene Stand wird mit entfernt.",
|
||||
"deleteConfirmButton": "Löschen",
|
||||
"deleteCancelButton": "Abbrechen"
|
||||
}
|
||||
},
|
||||
"dkvFleet": {
|
||||
"pageTitle": "DKV-Rechnung",
|
||||
"checkNow": "Jetzt prüfen",
|
||||
|
||||
@@ -213,6 +213,18 @@
|
||||
"saveChanges": "Save changes",
|
||||
"layoutLoadError": "Could not load dashboard. Please reload the page.",
|
||||
"widgetSaveError": "Could not save changes. Please try again.",
|
||||
"unavailable": "This tile is not available — the module it belongs to is not enabled for you.",
|
||||
"tabs": {
|
||||
"navLabel": "Dashboard tabs",
|
||||
"add": "Add dashboard",
|
||||
"switching": "Switching dashboard …",
|
||||
"renameButtonLabel": "Rename dashboard",
|
||||
"renameInputLabel": "Dashboard name",
|
||||
"deleteButtonLabel": "Delete dashboard",
|
||||
"deleteDialogTitle": "Delete dashboard",
|
||||
"deleteDialogBody": "Do you really want to delete the dashboard \"{name}\"? Its tiles and layout will be deleted as well.",
|
||||
"dragHint": "Drag a tab to a different position with your mouse. The first tab loads when you open the dashboard."
|
||||
},
|
||||
"clock": {
|
||||
"name": "Clock",
|
||||
"description": "Shows the current time",
|
||||
@@ -691,6 +703,87 @@
|
||||
"checking": "Checking...",
|
||||
"error": "Error checking domain"
|
||||
},
|
||||
"proxmox": {
|
||||
"title": "Proxmox",
|
||||
"description": "State of your Proxmox servers (PVE/PBS/PMG) at a glance — Tessera only observes, it never changes anything.",
|
||||
"loading": "Loading server list...",
|
||||
"loadError": "Could not load the server list.",
|
||||
"emptyState": "No server configured yet. Add one in the module settings.",
|
||||
"card": {
|
||||
"unknownValue": "unknown",
|
||||
"lastPolledLabel": "Last poll",
|
||||
"notPolledYet": "No poll has run yet. Click \"{refreshLabel}\" above.",
|
||||
"notPolledYetAutomatic": "No poll has run yet. The values will appear after the next automatic poll.",
|
||||
"lastOkLabel": "Last successful measurement",
|
||||
"refresh": "Refresh now",
|
||||
"refreshing": "Refreshing...",
|
||||
"settingsLink": "Go to settings",
|
||||
"pve": {
|
||||
"nodeCount": "Nodes",
|
||||
"guests": "{running} running / {stopped} stopped",
|
||||
"cpu": "CPU load",
|
||||
"mem": "Memory"
|
||||
},
|
||||
"pbs": {
|
||||
"used": "Used",
|
||||
"lastBackup": "Last backup",
|
||||
"noBackupYet": "no backup yet",
|
||||
"verifyState": "Last verification"
|
||||
},
|
||||
"pmg": {
|
||||
"countIn": "Inbound",
|
||||
"countOut": "Outbound",
|
||||
"spamCount": "Spam",
|
||||
"virusCount": "Viruses"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"netz": "The server is unreachable. Please check the address and the network connection.",
|
||||
"zugang": "Access was denied. Please check the username and password, or the token details.",
|
||||
"rechte": "The stored account does not have enough rights. Please check the assigned role on the Proxmox server.",
|
||||
"zertifikat": "The server's certificate was rejected. Check the address, or deliberately disable certificate checking for this one server.",
|
||||
"antwortform": "The server's response did not have the expected shape. Please check the address.",
|
||||
"server": "The Proxmox server reports its own error. Please try again later.",
|
||||
"unbekannt": "An unexpected error occurred."
|
||||
},
|
||||
"settings": {
|
||||
"title": "Proxmox — Settings",
|
||||
"accessDeniedText": "This page is only available to administrators.",
|
||||
"addServer": "Add server",
|
||||
"noServers": "No server configured yet.",
|
||||
"nameLabel": "Name",
|
||||
"productTypeLabel": "Type",
|
||||
"productTypePve": "PVE",
|
||||
"productTypePbs": "PBS",
|
||||
"productTypePmg": "PMG",
|
||||
"baseUrlLabel": "Address",
|
||||
"authMethodLabel": "Access method",
|
||||
"authMethodToken": "API token",
|
||||
"authMethodPassword": "Username/password",
|
||||
"tokenIdLabel": "Token ID",
|
||||
"tokenSecretLabel": "Token secret",
|
||||
"usernameLabel": "Username",
|
||||
"passwordLabel": "Password",
|
||||
"secretUnchangedPlaceholder": "Leave blank to keep the stored secret",
|
||||
"pollIntervalLabel": "Poll interval (minutes)",
|
||||
"tlsRejectLabel": "Verify certificate",
|
||||
"tlsRejectHint": "The exception applies only to this one server, never to all servers at once.",
|
||||
"activeLabel": "Active",
|
||||
"save": "Save",
|
||||
"saving": "Saving...",
|
||||
"saveError": "Could not save the settings.",
|
||||
"cancel": "Cancel",
|
||||
"testConnection": "Test connection",
|
||||
"testTesting": "Testing connection...",
|
||||
"testSuccess": "Connection successful.",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete",
|
||||
"deleteConfirmTitle": "Delete server",
|
||||
"deleteConfirmBody": "Do you really want to delete the server \"{name}\"? The last measured status will be removed as well.",
|
||||
"deleteConfirmButton": "Delete",
|
||||
"deleteCancelButton": "Cancel"
|
||||
}
|
||||
},
|
||||
"dkvFleet": {
|
||||
"pageTitle": "DKV Invoice",
|
||||
"checkNow": "Check Now",
|
||||
|
||||
@@ -184,4 +184,9 @@ export const UMLAUT_ALLOWLIST: readonly string[] = [
|
||||
'SSL',
|
||||
// 260914-m97: Fehler-melden-Knopf, Pflichtlabel "Was ist passiert?"
|
||||
'passiert',
|
||||
// quick-260923-dhh: Proxmox-Modul — korrektes Deutsch mit „ss“
|
||||
'bewusst',
|
||||
'gemessene',
|
||||
'Messung',
|
||||
'Prozessorlast',
|
||||
];
|
||||
|
||||
@@ -60,6 +60,11 @@ Unten in der Seitenleiste finden Sie die Sprachumschaltung (Deutsch/English) sow
|
||||
|
||||
Das Dashboard ist Ihre persönliche Startseite und öffnet sich automatisch nach der Anmeldung. Es zeigt ein Raster aus Kacheln — den **Widgets**. Ist noch kein Widget platziert, sehen Sie nur das Tessera-Symbol mit dem Hinweis „Keine Widgets aktiv".
|
||||
|
||||
**Mehrere Dashboards (Reiter):** Über dem Raster steht eine Reiterleiste — Sie können mehrere Dashboards anlegen, die dort nebeneinander stehen. Jeder Reiter trägt seine eigenen Kacheln und seine eigene Anordnung; was auf dem einen Reiter liegt, erscheint nicht auf dem anderen. Ein Klick auf einen Reiter wechselt dorthin. Beim Öffnen wird immer der ERSTE Reiter geladen — Sie legen ihn fest, indem Sie einen Reiter mit der Maus ganz nach vorn ziehen (das geht jederzeit, auch ohne den Bearbeitungsmodus). Im Bearbeitungsmodus können Sie außerdem:
|
||||
- Über den Knopf am Ende der Leiste einen neuen, leeren Reiter anlegen — er heißt automatisch „Dashboard 2", „Dashboard 3" und so weiter.
|
||||
- Den Namen des gerade aktiven Reiters ändern: Klicken Sie auf den Stift daneben, geben Sie den neuen Namen ein und bestätigen Sie mit der Eingabetaste (Escape verwirft die Änderung).
|
||||
- Einen Reiter löschen: Klicken Sie auf das Kreuz daneben und bestätigen Sie die Rückfrage — seine Kacheln und seine Anordnung werden dabei mit gelöscht. Der letzte verbleibende Reiter lässt sich nicht löschen, der Knopf dafür erscheint dort gar nicht erst.
|
||||
|
||||
**Widgets hinzufügen und anordnen:** Unten rechts auf dem Dashboard schwebt der Stift-Schalter **„Dashboard bearbeiten"**; im Bearbeitungsmodus wird daraus ein Häkchen **„Änderungen speichern"**, und daneben erscheint **„Widget hinzufügen"**. Sobald der Bearbeitungsmodus aktiv ist:
|
||||
- Erscheint der Button **„Widget hinzufügen"**, der eine Auswahl aller verfügbaren Widget-Typen als Kachel-Katalog öffnet. Ein Klick auf einen Eintrag fügt das Widget sofort dem Dashboard hinzu.
|
||||
- Können Sie bestehende Widgets per Ziehen an eine neue Position verschieben. Fassen Sie die Kachel dazu an einer beliebigen Stelle an — Eingabefelder, Knöpfe und Links ausgenommen; ein grauer Griff am oberen Kachelrand zeigt, dass die Kachel beweglich ist. Abgelegt wird nur dort, wo Platz ist: über einer anderen Kachel springt sie an ihren Ausgangspunkt zurück.
|
||||
@@ -97,7 +102,7 @@ Für Uhr, Suchleiste, Kalender, Notizen, Favoriten, Bilderrahmen und XFrame gibt
|
||||
|
||||
## Die Module
|
||||
|
||||
Aktuell stehen in Tessera vier Module zur Verfügung. Je nachdem, welche für Sie freigegeben sind, sehen Sie sie in der Seitenleiste unter ihrer jeweiligen Kategorie.
|
||||
Aktuell stehen in Tessera fünf Module zur Verfügung. Je nachdem, welche für Sie freigegeben sind, sehen Sie sie in der Seitenleiste unter ihrer jeweiligen Kategorie.
|
||||
|
||||
### Ausschreibungs-Radar
|
||||
|
||||
@@ -143,6 +148,20 @@ Unterstützte Dateiformate sind unter anderem `.pem`, `.crt`, `.cer`, `.der`, `.
|
||||
|
||||
Ein einfaches Werkzeug, um zu prüfen, ob eine Internet-Domain verfügbar ist. Geben Sie einen Domain-Namen ein (z. B. `beispiel.de`) und klicken Sie auf „Prüfen" — das Ergebnis zeigt für die geprüften Endungen jeweils „Verfügbar" oder „Registriert" an, ergänzt um alternative Vorschläge.
|
||||
|
||||
### Proxmox
|
||||
|
||||
Das Modul zeigt den Zustand Ihrer Proxmox-Server auf einen Blick — für die drei Proxmox-Produkte PVE (Virtualisierung), PBS (Backup) und PMG (Mail-Gateway). Wichtig zu wissen: **Tessera verändert bei Proxmox nichts, es schaut nur zu.** Es gibt in diesem Modul keinen Weg, einen Server zu starten, zu stoppen, eine Sicherung auszulösen oder irgendeine Proxmox-Einstellung zu ändern.
|
||||
|
||||
**Die Modulseite** zeigt eine Karte je eingetragenem Server: Name, Typ und Adresse, dazu die zuletzt gemessenen Werte — bei PVE Anzahl Knoten sowie laufende/gestoppte virtuelle Maschinen und Container samt Auslastung je Knoten, bei PBS die Belegung je Datenspeicher mit dem Zeitpunkt der letzten Sicherung und deren Prüfergebnis, bei PMG die Tageszahlen eingehender und ausgehender E-Mails sowie Spam- und Virenfunde. Ein Wert, der „unbekannt" anzeigt, bedeutet nicht, dass etwas kaputt ist — er bedeutet, dass dieser eine Messwert beim letzten Abruf nicht in der erwarteten Form geliefert wurde. Der Knopf **„Jetzt aktualisieren"** fragt alle eingetragenen Server neu ab; regulär geschieht das automatisch im Hintergrund, in dem Abstand, den Sie je Server eingestellt haben.
|
||||
|
||||
**Einen Server anlegen** (nur für Administratoren, über den Link „Zu den Einstellungen"): Name, Typ (PVE/PBS/PMG), Adresse (z. B. `https://pve.intern:8006`) und Zugang. Beim Zugang wählen Sie zwischen einem API-Token oder Benutzername/Passwort — bei PMG bietet Tessera von vornherein nur Benutzername/Passwort an, weil dieses Proxmox-Produkt keine API-Token kennt. Ein einmal gespeichertes Geheimnis (Token oder Passwort) wird nie wieder im Klartext angezeigt; lassen Sie das Feld beim Bearbeiten leer, um es unverändert zu lassen, oder tragen Sie ein neues ein, um es zu ersetzen.
|
||||
|
||||
**Zugangsrechte am Proxmox-Server:** Legen Sie dort für den Zugang, den Sie hier eintragen, ausschließlich eine NUR-LESE-Rolle an — Tessera braucht nie mehr. Bei PVE ist das die Rolle **PVEAuditor**, bei PBS **Audit** (beziehungsweise feiner **DatastoreAudit**), bei PMG **Auditor**.
|
||||
|
||||
**Zertifikat prüfen:** Dieser Schalter steht standardmäßig auf „prüfen" (an). Nutzen Sie für einen Server ein selbstsigniertes oder sonst nicht vertrauenswürdiges Zertifikat, können Sie die Prüfung für **genau diesen einen Server** ausschalten — die Ausnahme gilt nie für einen anderen Server.
|
||||
|
||||
**„Verbindung testen"** prüft den hinterlegten Zugang gegen den echten Server, ohne die zuletzt gemessenen Werte auf der Modulseite zu überschreiben. Bei Erfolg erscheint eine grüne Bestätigung; scheitert der Test, nennt die Meldung die Ursache in Alltagssprache — etwa „nicht erreichbar", „Zugang abgelehnt", „Rechte reichen nicht" oder ein Zertifikatsproblem.
|
||||
|
||||
## Persönliche Einstellungen
|
||||
|
||||
Öffnen Sie **Einstellungen** über das Benutzermenü oben rechts. Der Bereich gliedert sich in zwei Kategorien in der linken Unterleiste:
|
||||
|
||||
@@ -283,8 +283,11 @@ Restores stattfinden.
|
||||
- **Verschlüsselungsschlüssel** `TESSERA_ENCRYPTION_KEY`: liegt nur in `.env` auf
|
||||
dem Host, **nicht** im Datenbank-Dump. Getrennt sichern (siehe Kapitel 2/3) – ohne
|
||||
ihn sind alle per `pg_dump` gesicherten verschlüsselten Zugangsdaten wertlos.
|
||||
- **Hochgeladene Dateien** (Avatare unter `user-files/avatars/`, generierte
|
||||
DKV-Exporte unter `user-files/`, siehe `apps/api/src/user/user.controller.ts` und
|
||||
- **Hochgeladene Dateien** (Avatare unter `user-files/avatars/`, Bilder des
|
||||
Bilderrahmen-Widgets unter `user-files/dashboard-images/<Benutzerkennung>/`,
|
||||
generierte DKV-Exporte unter `user-files/`, siehe
|
||||
`apps/api/src/user/user.controller.ts`,
|
||||
`apps/api/src/dashboard/dashboard-images.service.ts` und
|
||||
`apps/api/src/dkv/dkv-export.service.ts`): Diese Dateien liegen im benannten
|
||||
Docker-Volume `user-files`, gemountet auf `/app/user-files` im Dienst `api`. Der
|
||||
Mount ist in `docker-compose.yml` und `docker-compose.prod.yml` eingetragen:
|
||||
@@ -296,7 +299,11 @@ Restores stattfinden.
|
||||
user-files:
|
||||
```
|
||||
|
||||
Damit überstehen Avatare und DKV-Exporte ein `--force-recreate` von `api`. Wie bei
|
||||
Damit überstehen Avatare, Bilderrahmen-Bilder und DKV-Exporte ein
|
||||
`--force-recreate` von `api`. Seit den Bilderrahmen-Bildern (Version nach 1.3.0)
|
||||
gehört dieses Volume zwingend zur Sicherung: `pg_dump` allein enthält diese
|
||||
Bilder nicht mehr — genau das ist der Zweck der Umstellung, der
|
||||
Datenbank-Abzug bleibt dadurch klein. Wie bei
|
||||
`pgdata` zeigt `docker volume ls` das Volume mit vorangestelltem Projektnamen an
|
||||
(`<projekt>_user-files`). Gesichert werden die Dateien weiterhin mit
|
||||
`docker compose cp api:/app/user-files ./user-files-backup`, alternativ über eine
|
||||
|
||||
@@ -400,6 +400,60 @@ Für ein Modul mit Unterrouten (Einstellungsseite, Verwaltungsansicht) orientier
|
||||
`dkv-fleet` oder `tender-radar` — beide haben zusätzliche `settings/page.tsx` bzw. weitere
|
||||
Unterverzeichnisse, die vom selben `layout.tsx` mitgedeckt werden.
|
||||
|
||||
**Ein Modul mit Fremdsystem-Zugängen und Hintergrundabfrage:** `proxmox` (260923-dhh) ist die
|
||||
Vorlage dafür — mehrere verschlüsselte Fremdsystem-Zugänge je Mandant (`ProxmoxServer`, Vorbild
|
||||
`CalendarSource`, nicht `DkvModuleConfig`), ein Zwischenlager, das ein Hintergrunddienst
|
||||
beschreibt und das die Modulseite ausschließlich liest (`ProxmoxServerStatus`), sowie ein
|
||||
Planer, der `onApplicationBootstrap` statt `onModuleInit` nutzt und je Mandant einen eigenen
|
||||
Cron-Auftrag registriert (`proxmox-scheduler.service.ts`, kombiniert die Muster von
|
||||
`DkvSchedulerService` und `TenderSchedulerService`).
|
||||
|
||||
**Die `undici`-Dispatcher-Falle unter Node 24:** wer aus Gewohnheit das globale `fetch` statt
|
||||
`import { fetch as undiciFetch } from 'undici'` verwendet, bekommt beim Kompilieren KEINEN
|
||||
Fehler, sondern eine zur Laufzeit STILLSCHWEIGEND ignorierte `dispatcher`-Option — ein
|
||||
selbstsigniertes Zertifikat wird dann trotz bewusst abgeschalteter Prüfung weiterhin abgelehnt,
|
||||
was beim ersten Test verwirrend aussieht, als sei die Datenbank-Einstellung falsch gelesen
|
||||
worden. Node 24 bündelt intern eine eigene `undici`-Kopie, die vom global gepatchten `fetch`
|
||||
verwendet wird — ein `Agent` aus dem npm-Paket `undici` ist eine ANDERE Klasse und wird von
|
||||
diesem globalen `fetch` ignoriert. Gemessen und dokumentiert in
|
||||
`apps/api/src/favorites/icon-discovery.service.ts:33-40` (erstes Auftreten) und in
|
||||
`apps/api/src/proxmox/proxmox-client.service.ts` (zweites, unabhängig davon konstruiertes
|
||||
Auftreten mit demselben Befund).
|
||||
|
||||
### Eine Kachel zum Modul
|
||||
|
||||
Ein Modul kann zusätzlich als Kachel auf dem Dashboard erscheinen. Seit
|
||||
`quick-260922-m1h` sind dafür **drei** Stellen nötig (vorher waren es sieben):
|
||||
|
||||
1. **Die Kachel-Komponente schreiben** — `apps/web/src/components/dashboard/widgets/<name>-widget.tsx`,
|
||||
nimmt die `WidgetProps` aus `widget-registry.tsx` (`instanceId`, `config`, `isEditMode`) entgegen.
|
||||
2. **Den Typ eintragen** — in `WIDGET_TYPES` in `packages/shared/src/index.ts`. Gehört die Kachel zu
|
||||
einem Modul, zusätzlich `WIDGET_MODULE_SLUGS['<typ>'] = '<modul-slug>'` in derselben Datei. Das ist
|
||||
die einzige Liste: die API validiert `POST /dashboard/widgets` per `@IsIn` gegen genau sie, und
|
||||
das Frontend leitet Registry und Katalog davon ab.
|
||||
3. **Anmelden** — `registerWidget('<typ>', <Name>Widget)` in `apps/web/src/app/(portal)/page.tsx`,
|
||||
neben den übrigen Aufrufen. Der Aufruf steht dort und nicht in der Registry, weil die Kachel über
|
||||
den Wrapper wieder die Registry importiert — ein Import aus der Registry heraus wäre ein
|
||||
Zirkelimport.
|
||||
|
||||
Dazu kommen wie bei jeder Oberfläche die **Übersetzungsschlüssel** (`<typ>.name` und
|
||||
`<typ>.description` unter `widgets` in `de.json` **und** `en.json`), ein **Symbol** als Inline-SVG
|
||||
und die **Größenvorgaben** in `WIDGET_CONSTRAINTS` (`minW`/`minH` = kleinste noch bedienbare Kachel,
|
||||
`defaultW`/`defaultH` = Startgröße) — beides in `widget-registry.tsx`. Ein Test in
|
||||
`widget-registry.test.tsx` prüft, dass `WIDGET_TYPES`, Registry und Constraints deckungsgleich sind;
|
||||
vergisst man eine Stelle, schlägt er fehl.
|
||||
|
||||
**Was eine Kachel mit `moduleSlug` automatisch tut:** Sie verschwindet für Benutzer, die das Modul
|
||||
nicht nutzen dürfen — aus dem Katalog („Widget hinzufügen", `visibleWidgetTypes`) und aus dem
|
||||
Dashboard selbst. Ist die Modulliste unbekannt, weil ihr Abruf fehlschlug, bleibt die Kachel
|
||||
ebenfalls verborgen (fail-closed). Eine bereits angelegte Kachel eines gesperrten Moduls rendert
|
||||
nicht mehr leer, sondern zeigt den Hinweis `widgets.unavailable`.
|
||||
|
||||
**Wie beim Modul-Gate gilt auch hier:** Der Katalogfilter ist Komfort, nicht Zugriffskontrolle. Die
|
||||
verbindliche Prüfung sitzt serverseitig in `DashboardService.getWidgets()` (filtert Kacheln
|
||||
gesperrter Module fail-closed aus `GET /dashboard/widgets`) — und die Daten, die eine Modul-Kachel
|
||||
anzeigt, holt sie über die Endpunkte ihres Moduls, die `@UseModule('<slug>')` tragen müssen.
|
||||
|
||||
## Mandantentrennung
|
||||
|
||||
Der tatsächliche Mechanismus ist `TenantGuard` (`apps/api/src/tenant/tenant.guard.ts`), global als
|
||||
|
||||
@@ -168,16 +168,17 @@ Spalten sind mit der Schleife aus dem Gate von 260914-eym nachgerechnet
|
||||
| dkv | 0 | 22 | 1 | **war 21/0** — Aufgabe 2/3 (260909-mir) haben `dkv.service.ts` vollständig auf `forTenant()` umgestellt: Konfigurationspfade (`loadConfig`, `getConfigForApi`, `saveConfig`, `testConnection`), Historie, Fahrzeugstammdaten und der neue Besitzriegel vor dem Ausfuhrdatei-Download. Gebunden sind es 22 statt 21, weil der Riegel einen zusätzlichen Lesezugriff auf `dkvInvoiceHistory` einführt (T-MIR-03). Der eine verbleibende ungebundene Treffer war der benannte Planer-Startpfad `loadAnyActiveConfigForScheduler()` (Befund D, WINDOWS #21). **260914-eym:** ersetzt durch `loadActiveConfigsForScheduler()` über `forSystem()` (1→0 ungebunden, 1 System) — WINDOWS #21 geschlossen |
|
||||
| user | 8 | 14 | 0 | **war 17/0** — Aufgabe 2/3 (260910-das) haben `user.service.ts` (`findById`/`create`/`update`/`deactivate`/`delete` sowie die zwei neuen Plattform-Administratorsicht-Methoden), `admin-seed.service.ts` (Erstanlage des Administrators) und `user.controller.ts` (Benutzerliste des ADMIN-Zweigs, alle drei Kennungswege ueber die Dienstmethoden, alle fuenf Selbstbedienungszugriffe) auf `forTenant()` umgestellt. Die 8 verbleibenden ungebundenen Rohtreffer sind bewusst: `findByUsername` in `user.service.ts` (plattformweit eindeutiger Schluessel, derselbe Fall wie `resolveEmailForWrite` im Bereich `ldap`), die Erstanlage-Pruefung und beide Zugriffe auf `tenant` in `admin-seed.service.ts`, sowie der neue Schleifentreiber `this.prisma.tenant.findMany` der beiden Plattform-Administratorsicht-Methoden in `user.service.ts` (`Tenant` traegt keinen Zeilenschutz) |
|
||||
| module-registry | 7 | 10 | 0 | **war 17/0** — Aufgabe 2/3 (260910-exd) haben `module-access.service.ts` (`getAccessibleModuleIds`: Kurzschlusszweig, Direktweg, Gruppenweg, Schnittmenge; `getCatalogFlags`: eigener Aktivierungs-Lesezugriff) und `module-registry.service.ts` (`findActiveForTenant`, `activateForTenant`, `deactivateForTenant`, `isModuleActive`) auf `forTenant()` umgestellt. Die 7 verbleibenden ungebundenen Rohtreffer sind bewusst: der eine Katalogzugriff in `module-access.service.ts` (`findAccessibleModules`) und die sechs Katalogzugriffe in `module-registry.service.ts` (`findAll`, `findBySlug`, die beiden Katalog-Existenzpruefungen in `activateForTenant`/`deactivateForTenant`, die Katalogsuche in `isModuleActive`, `seedModule`) — der Modulkatalog (`Module`) traegt heute keinen Zeilenschutz, eine Bindung waere heute wirkungslos, nicht katastrophal; katastrophal wuerde sie erst, WENN Etappe 3 dieser Tabelle eine Regel gibt (Befund E) |
|
||||
| dashboard | 1 | 18 | 0 | **260921-pi9:** 12→18 gebunden — `dashboard-images.service.ts` (Bilderrahmen) bringt sechs gebundene `dashboardImage`-Rohtreffer (`findMany`, `count`, `create`, zweimal `findUnique`, `delete`), nachgemessen mit der Gate-Schleife. Vorher: **war 13/0** — Aufgabe 2/3 (260910-krx) haben `dashboard.service.ts` vollständig umgestellt: `getLayout`/`saveLayout` (gemeinsam gebunden), `getWidgets`/`addWidget`/`updateWidgetConfig`/`removeWidget` sowie `getSearchProviders`/`addSearchProvider`/`removeSearchProvider` laufen über `forTenant()`, je Methode ein Klient. Der eine verbleibende ungebundene Rohtreffer ist bewusst: der Modulkatalog (`Module`) trägt heute keinen Zeilenschutz, eine Bindung wäre heute wirkungslos, nicht katastrophal — katastrophal würde sie erst, WENN Etappe 3 dieser Tabelle eine Regel gibt (Befund E aus `module-registry`, hier übernommen) |
|
||||
| dashboard | 1 | 28 | 1 | **quick-260923-ad9 (Task 5, Endstand nach Task 2):** 24→28 gebunden — Task 2 (Reiter anlegen/umbenennen/löschen/umsortieren) bringt vier weitere gebundene `tenantPrisma.dashboard.`-Rohtreffer in `dashboard.service.ts`: `createDashboard` (`findMany` der vorhandenen Namen, `create`), `renameDashboard` (`update`), `deleteDashboard` (die Zählung vor dem Löschen). Die Schreib-/Lese-Zugriffe INNERHALB der `withTenantTransaction` in `deleteDashboard`/`reorderDashboards` (`tx.dashboard.*`, `tx.widgetInstance.deleteMany`, `tx.dashboardLayout.deleteMany`) zählt diese einfache Rohtrefferzählung strukturell NICHT mit — dieselbe dokumentierte Lücke wie bei `groups.service.ts` (siehe Kopf dieses Abschnitts); sie sind trotzdem gebunden (jeder Aufruf von `withTenantTransaction(` zählt als gebunden) und stehen deshalb bereits als `gebunden` in den Paaren `dashboard`/`widgetInstance`/`dashboardLayout` unten. Nachgemessen mit der Gate-Schleife. Vorher: **quick-260923-ad9 (Task 1):** 21→24 gebunden — die neue Reitertabelle bringt drei gebundene `dashboard`-Rohtreffer in `dashboard.service.ts` (zwei `findMany` in `listDashboards`, ein `findUnique` im Riegel `assertOwnedDashboard`), nachgemessen mit der Gate-Schleife. Vorher: **260922-hk4:** 18→21 gebunden, 0→1 System — die Bilderrahmen-Bilder liegen jetzt im Dateibereich statt in der Spalte `data`. Drei zusätzliche gebundene Rohtreffer in `dashboard-images.service.ts`: das Nachtragen von `storagePath` nach dem Upload (die UUID steht erst nach `create` fest), das Zurücknehmen der Zeile bei fehlgeschlagenem Schreiben, und das Nachtragen im Umzug beim Start. Der eine System-Rohtreffer ist die Lesehälfte dieses Umzugs (`onApplicationBootstrap`, Zeilen ohne `storagePath` über ALLE Mandanten, Muster DKV-Planer) — geschrieben wird auch dort je Zeile mandantengebunden. Nachgemessen mit der Gate-Schleife. Vorher: **260921-pi9:** 12→18 gebunden — `dashboard-images.service.ts` (Bilderrahmen) bringt sechs gebundene `dashboardImage`-Rohtreffer (`findMany`, `count`, `create`, zweimal `findUnique`, `delete`), nachgemessen mit der Gate-Schleife. Vorher: **war 13/0** — Aufgabe 2/3 (260910-krx) haben `dashboard.service.ts` vollständig umgestellt: `getLayout`/`saveLayout` (gemeinsam gebunden), `getWidgets`/`addWidget`/`updateWidgetConfig`/`removeWidget` sowie `getSearchProviders`/`addSearchProvider`/`removeSearchProvider` laufen über `forTenant()`, je Methode ein Klient. Der eine verbleibende ungebundene Rohtreffer ist bewusst: der Modulkatalog (`Module`) trägt heute keinen Zeilenschutz, eine Bindung wäre heute wirkungslos, nicht katastrophal — katastrophal würde sie erst, WENN Etappe 3 dieser Tabelle eine Regel gibt (Befund E aus `module-registry`, hier übernommen) |
|
||||
| auth | 3 | 10 | 0 | **war 8/5** — 260911-fh9 (Aufgabe 2) hat `getMe`, `changePassword`, `adminResetPassword` (fünf Rohtreffer auf `user`, drei Methoden) auf `forTenant()` umgestellt. Die 3 verbleibenden ungebundenen Rohtreffer sind die `$queryRaw`-Aufrufe der drei Anmeldefunktionen (`validateUser`, `requestPasswordReset`, `resetPassword`) — KEINE Modellzugriffe (`$` liegt nicht in `[a-zA-Z]`, die Bestandsaufnahme führt sie deshalb nicht als (Datei, Modell)-Paar), bewusst und dauerhaft ungebunden, siehe `20260909160000_auth_lookup_functions` und `docs/mandantentrennung-etappe2-fehlerrichtung.md`, Abschnitt "## Bereich auth", (h1) |
|
||||
| calendar | 0 | 12 | 0 | **war 12/0** — Aufgabe 2 (260911-cwh) hat `calendar.service.ts` vollständig auf `forTenant()` umgestellt: `getSources`, `addSource`, beide Abfragen von `updateSource`/`deleteSource`, alle drei Abfragen von `testConnection`, Laden plus beide Synchronstatus-Rückschreibungen von `fetchAndCacheEvents` — je Methode ein Klient. Anders als bei den sieben Bereichen davor bleibt KEIN ungebundener Rest übrig: `CalendarSource` trägt eine Pflicht-Mandantenkennung, und kein Pfad dieses Bereichs liest über Mandanten hinweg |
|
||||
| tenant | 8 | 3 | 0 | **war 8/0** — 260911-e2s (Aufgabe 3) hat drei gebundene Benutzerzähler in `tenant.controller.ts` eingeführt (Fan-out je Mandant nach dem Muster von `UserService.findAllForPlatformAdmin`, ersetzt die drei vorherigen Relationszähler); die acht `tenant`-Zugriffe selbst BLEIBEN ungebunden — `Tenant` trägt keine Regel in irgendeiner ausgelieferten Migration (260911-e2s Aufgabe 1, Prüfung 1/2), hier ist Ungebundenheit richtig, nicht geduldet |
|
||||
| favorites | 0 | 8 | 0 | **war 7/0** — 260911-gwh (Aufgabe 2) hat `favorites.service.ts` vollständig auf `forTenant()` umgestellt: `list`, `create`, `update`, `remove`, `getIconBytes` laufen je über EINEN Klienten `tenantPrisma` (7 gebundene `favoriteLink`-Rohtreffer); `create` prüft zusätzlich über einen gebundenen `widgetInstance.findUnique`, dass das Ziel-Widget dem Aufrufer gehört (T-GWH-05, Befund F aus Aufgabe 1: der Fremdschlüssel prüft am Zeilenschutz vorbei) — der achte gebundene Rohtreffer dieser Zeile |
|
||||
| bug-reports | 0 | 1 | 0 | neu (260914-m97), ein gebundener Zugriff |
|
||||
| settings | 0 | 4 | 0 | **Nachgemessen 260921-pi9: 4 gebundene Rohtreffer** (die Tabelle nannte 3; der vierte `smtpConfig`-Zugriff kam mit 260914-m97/`bugReportRecipient` hinzu, ohne dass die Zeile nachgezogen wurde). **war 4/0** — 260911-gwh (Aufgabe 2) hat `getSmtpConfig`, `saveSmtpConfig`, `getDecryptedSmtpConfig` auf `forTenant()` umgestellt (3 gebundene `smtpConfig`-Rohtreffer). Der eine verbleibende ungebundene Rohtreffer war der umbenannte Planer-Startpfad `loadAnySmtpConfigForStartupTransport()` (Befund D, WINDOWS #30). **260914-eym:** GELÖSCHT — `MailService` baut je Versand einen Transport über `getDecryptedSmtpConfig(tenantId)` (1→0 ungebunden, 0 System, kein Systemkontext nötig); Befund K (`tenders`/`dkv`/`mail` hängen an `getDecryptedSmtpConfig`) ist damit erfüllt — WINDOWS #30 geschlossen |
|
||||
| **Summe** | **61** | **187** | **5** | **260921-pi9:** Gebunden 179→187, nachgerechnet mit der Gate-Schleife: +6 in `dashboard` (Bilderrahmen), +1 in `settings` (Zeile war seit 260914-m97 um eins zu niedrig), +1 fuer `bug-reports` (Zeile seit 260914-m97 vorhanden, in der Summe aber nie mitgezaehlt) — die Summe stimmt damit wieder mit den Bereichszeilen ueberein. **260914-eym:** Ungebunden 68→61 (`tenders` −2, `ldap` −3, `dkv` −1, `settings` −1), Gebunden 178→179 (`ldap` +1), System 5 (`dkv` 1, `ldap` 2, `tenders` 2) — nachgerechnet mit der Gate-Schleife, nicht abgeschrieben. Vorgeschichte: Ungebunden: war 118 nach 260910-das, dann 108 nach 260910-exd (module-registry 17→7), dann 107 nach 260910-jab (`tenders` 36→35, `listForUser` gebunden), dann 95 nach 260910-krx (`dashboard` 13→1), dann 83 nach 260911-cwh (`calendar` 12→0), unverändert nach 260911-e2s (`tenant` bleibt bei 8 ungebundenen Rohtreffern), dann 78 nach 260911-fh9 (`auth` 8→3), jetzt 68 nach 260911-gwh (`favorites` 7→0, `settings` 4→1). Gebunden: war 124, dann 134 nach 260910-exd (zusätzlich 10 in `module-registry`), dann 135 nach 260910-jab (zusätzlich 1 in `tenders`), dann 147 nach 260910-krx (zusätzlich 12 in `dashboard`), dann 159 nach 260911-cwh (zusätzlich 12 in `calendar`), dann 162 nach 260911-e2s (zusätzlich 3 in `tenant`), dann 167 nach 260911-fh9 (zusätzlich 5 in `auth`), jetzt 178 nach 260911-gwh (zusätzlich 8 in `favorites`, 3 in `settings`). Dies ist der ENDSTAND der Etappe 2: jeder verbleibende ungebundene Rohtreffer ist einer der in diesem Dokument benannten, bewusst ungebundenen Fälle. Diese Übersicht ist eine Buchführungshilfe; **autoritativ ist die Fundstellentabelle unten**, die `rls-access-inventory.spec.ts` bei jedem Lauf gegen den Quelltext prüft |
|
||||
| proxmox | 0 | 11 | 1 | **quick-260923-dhh (Aufgabe 5, Endstand):** 7→11 gebunden — `updateServer` (`proxmoxServer.findUnique` UND `.update`) und `deleteServer` (`proxmoxServer.findUnique` UND `.delete`) bringen vier weitere gebundene Rohtreffer, je ein Klient je Methode. Nachgemessen mit der Gate-Schleife (`grep -c` ueber `tenantPrisma\.\(proxmoxServer\|proxmoxServerStatus\)\.` in `proxmox.service.ts`: 10 fuer `proxmoxServer`, 1 fuer `proxmoxServerStatus`). Vorher: **quick-260923-dhh (Aufgabe 4):** 4→7 gebunden, 0→1 System — `proxmox.service.ts` bringt drei weitere gebundene Rohtreffer (`pollServer` mit `include: { status: true }` bleibt EIN Klient, `testConnection`, `listActiveServerIdsForTenant`, `loadActiveServersForTenantScheduling` — vier neue Methoden, aber `pollServer`s zweiter Zugriff war schon gezaehlt, macht drei zusaetzliche) und einen System-Rohtreffer (`loadActiveServersForScheduler()`, der einzige `forSystem()`-Aufruf des Moduls, Erlaubnisliste in `rls-access-inventory.spec.ts`). Vorher: **quick-260923-dhh (Aufgabe 1):** neu, vier gebundene Rohtreffer: `createServer` (`proxmoxServer.create`), `listWithStatus` (`proxmoxServer.findMany`), `pollServer` (`proxmoxServer.findUnique` UND `proxmoxServerStatus.upsert`, DERSELBE Klient in derselben Methode) |
|
||||
| **Summe** | **61** | **208** | **7** | **quick-260923-dhh (Aufgabe 5, Endstand):** Gebunden 204→208 (`proxmox` +4, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-dhh (Aufgabe 4):** Gebunden 201→204 (`proxmox` +3, siehe dortige Zeile), System 6→7 (`proxmox` +1) — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-dhh (Aufgabe 1):** Gebunden 197→201 (`proxmox` neu, +4, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **quick-260923-ad9 (Task 5, Endstand nach Task 2):** Gebunden 193→197 (`dashboard` +4, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-ad9 (Task 1):** Gebunden 190→193 (`dashboard` +3, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. **260922-hk4:** Gebunden 187→190, System 5→6 (beides `dashboard`, siehe dortige Zeile), Ungebunden unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. **260921-pi9:** Gebunden 179→187, nachgerechnet mit der Gate-Schleife: +6 in `dashboard` (Bilderrahmen), +1 in `settings` (Zeile war seit 260914-m97 um eins zu niedrig), +1 fuer `bug-reports` (Zeile seit 260914-m97 vorhanden, in der Summe aber nie mitgezaehlt) — die Summe stimmt damit wieder mit den Bereichszeilen ueberein. **260914-eym:** Ungebunden 68→61 (`tenders` −2, `ldap` −3, `dkv` −1, `settings` −1), Gebunden 178→179 (`ldap` +1), System 5 (`dkv` 1, `ldap` 2, `tenders` 2) — nachgerechnet mit der Gate-Schleife, nicht abgeschrieben. Vorgeschichte: Ungebunden: war 118 nach 260910-das, dann 108 nach 260910-exd (module-registry 17→7), dann 107 nach 260910-jab (`tenders` 36→35, `listForUser` gebunden), dann 95 nach 260910-krx (`dashboard` 13→1), dann 83 nach 260911-cwh (`calendar` 12→0), unverändert nach 260911-e2s (`tenant` bleibt bei 8 ungebundenen Rohtreffern), dann 78 nach 260911-fh9 (`auth` 8→3), jetzt 68 nach 260911-gwh (`favorites` 7→0, `settings` 4→1). Gebunden: war 124, dann 134 nach 260910-exd (zusätzlich 10 in `module-registry`), dann 135 nach 260910-jab (zusätzlich 1 in `tenders`), dann 147 nach 260910-krx (zusätzlich 12 in `dashboard`), dann 159 nach 260911-cwh (zusätzlich 12 in `calendar`), dann 162 nach 260911-e2s (zusätzlich 3 in `tenant`), dann 167 nach 260911-fh9 (zusätzlich 5 in `auth`), jetzt 178 nach 260911-gwh (zusätzlich 8 in `favorites`, 3 in `settings`). Dies ist der ENDSTAND der Etappe 2: jeder verbleibende ungebundene Rohtreffer ist einer der in diesem Dokument benannten, bewusst ungebundenen Fälle. Diese Übersicht ist eine Buchführungshilfe; **autoritativ ist die Fundstellentabelle unten**, die `rls-access-inventory.spec.ts` bei jedem Lauf gegen den Quelltext prüft |
|
||||
|
||||
## Klassen-Verteilung (nach (Datei, Modell)-Fundstellen, 74 Paare)
|
||||
## Klassen-Verteilung (nach (Datei, Modell)-Fundstellen, 77 Paare)
|
||||
|
||||
Stand 260909-jts (Aufgabe 3): 61 Paare aus dem vorherigen Durchlauf
|
||||
(260909-ipc) plus ein bisher vollstaendig unsichtbares Paar
|
||||
@@ -201,6 +202,19 @@ muss-mandantengebunden) war in der Tabelle eingetragen, in dieser
|
||||
Verteilung aber nie mitgezaehlt. Beide Korrekturen (72→74, 35→37) sind
|
||||
gemessen, nicht geschaetzt.
|
||||
|
||||
**Nachtrag 260923-ad9 (Task 1, Dashboard-Reiter):** 75 Paare — ein neues
|
||||
Paar `dashboard/dashboard.service.ts`/`dashboard` (muss-mandantengebunden,
|
||||
gebunden) fuer die neue Reitertabelle (`listDashboards`,
|
||||
`assertOwnedDashboard`). Nachgezaehlt mit `grep -cE "^\| apps/api/src/"
|
||||
docs/mandantentrennung-zugriffsklassifikation.md` ueber die
|
||||
Fundstellentabelle: vor diesem Eintrag standen dort 74 Zeilen. Die
|
||||
Uebersichtszeile `dashboard` und die Summenzeile unten sind mit derselben
|
||||
Gate-Schleife nachgerechnet (Rohtreffer, nicht Paare): `dashboard.service.ts`
|
||||
traegt jetzt drei zusaetzliche gebundene `tenantPrisma.dashboard.`-Rohtreffer
|
||||
(zwei `findMany` in `listDashboards`, ein `findUnique` in
|
||||
`assertOwnedDashboard`) — Bereich `dashboard` 21→24 gebunden, Summe
|
||||
190→193 gebunden, ungebunden und System unveraendert.
|
||||
|
||||
**Stand 260909-laa (Aufgabe 2):** dieselben 62 Paare, keine neue Fundstelle
|
||||
hinzugekommen oder verschwunden — nur EINE Klasse hat sich verschoben:
|
||||
`tender-rss-feed.service.ts`/`tenderRssFeedSource` wechselt von
|
||||
@@ -329,11 +343,15 @@ entnommen (30 Zusicherungen, darunter der Wachhund
|
||||
|
||||
| Klasse | Anzahl Paare |
|
||||
|---|---|
|
||||
| muss-mandantengebunden | 37 |
|
||||
| muss-mandantengebunden | 40 |
|
||||
| keine-mandantengebundene-tabelle | 21 |
|
||||
| beides | 14 |
|
||||
| bewusst-uebergreifend | 2 |
|
||||
| **Summe** | **74** |
|
||||
| **Summe** | **77** |
|
||||
|
||||
quick-260923-dhh (Aufgabe 1): +2 `muss-mandantengebunden` (`proxmox.service.ts`/`proxmoxServer`
|
||||
und `/proxmoxServerStatus`, beide `gebunden`) — nachgerechnet mit der Gate-Schleife, nicht
|
||||
abgeschrieben.
|
||||
|
||||
## Der Hintergrunddienst als Falle — sechs Fälle
|
||||
|
||||
@@ -673,11 +691,12 @@ werden.
|
||||
| apps/api/src/auth/auth.service.ts | user | muss-mandantengebunden | gebunden | Klassenkorrektur (260911-fh9, Aufgabe 2/3): wechselt von `gemischt` auf `gebunden` — `getMe`, `changePassword`, `adminResetPassword` binden seit Aufgabe 2 je über GENAU EINEN Klienten `tenantPrisma` an den Mandanten aus dem Sitzungsnachweis (`@CurrentUser().tenantId`); für die oberste Rolle (SUPER_ADMIN) löst der Controller den Mandanten des ZIELS über den gebundenen Fan-out `UserService.findByIdForPlatformAdmin` auf. `adminResetPassword` verweigert zusätzlich einem Nicht-SUPER_ADMIN das Kennwort eines SUPER_ADMIN (T-FH9-04). Die drei Anmeldesuchen (`validateUser`, `requestPasswordReset`, `resetPassword`) laufen weiterhin über die drei SECURITY-DEFINER-Funktionen (`$queryRaw`, keine Modellzugriffe — `$` liegt nicht in `[a-zA-Z]`) und bleiben unverändert auf dem ungebundenen Klienten. Etappe-3-Vorbehalt: die Bindung hängt am Claim `tenantId` und an `User.id` (plattformweite UUID), nicht an `username`/`email` — der Anmeldeweg-Umbau für je Mandant eindeutige Anmeldenamen betrifft diese Bindung nicht, siehe `docs/mandantentrennung-etappe2-fehlerrichtung.md`, Abschnitt "## Bereich auth", (h4)(a). |
|
||||
| apps/api/src/bug-reports/bug-reports.service.ts | user | muss-mandantengebunden | gebunden | Fehler-melden-Knopf (quick-260914-m97): eine gebundene Leseoperation auf die Zeile des angemeldeten Benutzers (Anzeigename, E-Mail, Rolle fuer den Bericht), Mandant ausschliesslich aus dem Sitzungsnachweis. |
|
||||
| apps/api/src/calendar/calendar.service.ts | calendarSource | muss-mandantengebunden | gebunden | Kalenderquellen eines Nutzers je Mandant gebunden (encryptedPassword traegt Zugangsdaten zu externen Exchange-/CalDAV-Servern), `tenantId`-Spalte vorhanden. Seit 260911-cwh (Aufgabe 2) laufen alle zwoelf Zugriffe (`getSources`, `addSource`, beide Abfragen von `updateSource`/`deleteSource`, alle drei Abfragen von `testConnection`, Laden plus beide Synchronstatus-Rueckschreibungen von `fetchAndCacheEvents`) ueber `forTenant()`, ein Klient je Methode; `fetchAndCacheEvents`/`refreshCacheInBackground` nehmen die Mandantenkennung als Parameter, Letztere traegt die Kennung der urspruenglichen Anfrage. Die drei Besitzpruefungen (`updateSource`/`deleteSource`/`testConnection`, Vergleich gegen `userId` aus dem Sitzungsnachweis) bleiben zusaetzlich bestehen — die Regel auf `CalendarSource` kennt keine Benutzerdimension (260911-cwh, Aufgabe 1, gemessen), sie sind bis zur Etappe-3-Entscheidung (2) der einzige Schutz zwischen Kollegen DESSELBEN Mandanten. Benutzerdimension seit 20260911120000 (260911-nke). |
|
||||
| apps/api/src/dashboard/dashboard-images.service.ts | dashboardImage | muss-mandantengebunden | gebunden | Hochgeladene Bilder des Bilderrahmen-Widgets (quick-260921-pi9), gehoeren dem hochladenden Benutzer; `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` MIT Benutzerdimension von Anfang an (Migration 20260921120000, Form aus 20260911120000). Alle vier Methoden (`list`, `upload`, `getBytes`, `remove`) holen je einen Klienten `const tenantPrisma = forTenant(this.prisma, tenantId, userId)`; Liste und Zaehler filtern zusaetzlich explizit `where: { tenantId, userId }`, `getBytes`/`remove` pruefen den Besitz anwendungsseitig (`row.userId !== userId || row.tenantId !== tenantId` -> 404, nie 403) — zweites Netz, kein Ersatz, weil der RLS-Schalter heute aus ist. `select` der Liste/Upload-Antwort ohne `data` (Bytes nur ueber `GET :id`). |
|
||||
| apps/api/src/dashboard/dashboard.service.ts | dashboardLayout | muss-mandantengebunden | gebunden | Widget-Anordnung eines Nutzers, `tenantId`-Spalte vorhanden. Seit 260910-krx (Aufgabe 2) laufen `getLayout`/`saveLayout` GEMEINSAM ueber `forTenant()`, ein Klient je Methode; `saveLayout` uebersetzt eine `PrismaClientUnknownRequestError` (RLS-Konflikt auf der plattformweit eindeutigen `userId`, gemessen in Aufgabe 1 — NICHT die `P2002`-Form, die der Bereich `tenders` abfaengt) in eine deutsche Konfliktmeldung. |
|
||||
| apps/api/src/dashboard/dashboard-images.service.ts | dashboardImage | muss-mandantengebunden | system-gebunden | **260922-hk4:** Stand von `gebunden` auf `system-gebunden` — NICHT weil ein Anfrageweg aufgeweicht wurde, sondern weil EIN Startpfad dazugekommen ist: `onApplicationBootstrap()` zieht die Bilder einmalig aus der Spalte `data` in den Dateibereich (`user-files/dashboard-images/<userId>/<id>.<ext>`) und muss dafür die noch nicht umgezogenen Zeilen ALLER Mandanten sehen (`const systemPrisma = forSystem(this.prisma)`, ein Aufruf, Erlaubnisliste in `rls-access-inventory.spec.ts`; Leserecht über `system_read_policy … FOR SELECT` auf "DashboardImage", Migration 20260922120000). GESCHRIEBEN wird auch dort je Zeile über `forTenant(prisma, row.tenantId, row.userId)` — einmal-lesen-viele-bedienen, Muster DKV-Planer. Die Bytes selbst liegen seither auf der Platte, die Zeile hält nur noch `storagePath` (Muster `User.avatarPath`); der Dateiname ist IMMER servergeneriert (UUID der Zeile + Endung aus dem ERKANNTEN Mime-Typ), `originalName` kommt in keinem Pfad vor (T-HK4-01). Alle vier Anfragewege sind unverändert mandantengebunden: Hochgeladene Bilder des Bilderrahmen-Widgets (quick-260921-pi9), gehoeren dem hochladenden Benutzer; `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` MIT Benutzerdimension von Anfang an (Migration 20260921120000, Form aus 20260911120000). Alle vier Methoden (`list`, `upload`, `getBytes`, `remove`) holen je einen Klienten `const tenantPrisma = forTenant(this.prisma, tenantId, userId)`; Liste und Zaehler filtern zusaetzlich explizit `where: { tenantId, userId }`, `getBytes`/`remove` pruefen den Besitz anwendungsseitig (`row.userId !== userId || row.tenantId !== tenantId` -> 404, nie 403) — zweites Netz, kein Ersatz, weil der RLS-Schalter heute aus ist. `select` der Liste/Upload-Antwort ohne `data` (Bytes nur ueber `GET :id`). |
|
||||
| apps/api/src/dashboard/dashboard.service.ts | dashboard | muss-mandantengebunden | gebunden | quick-260923-ad9 — Reiter (mehrere Dashboards je Benutzer), `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` MIT Benutzerdimension von Anfang an (Migration 20260923120000, Form aus 20260911120000/20260921120000). Task 1: `listDashboards` liest ueber `forTenant()` und legt bei Bedarf genau einen Reiter an (Transaktionssperre `pg_advisory_xact_lock` innerhalb `withTenantTransaction`, T-AD9-07); der Riegel `assertOwnedDashboard` liest ueber DENSELBEN, bereits gebundenen Klienten des Aufrufers (kein zweiter `forTenant()`-Aufruf) und wirft fuer "gibt es nicht", "gehoert einem Kollegen" und "liegt bei einem fremden Mandanten" dieselbe `NotFoundException` (T-AD9-01/02/03). Task 2: `createDashboard`/`renameDashboard` laufen als Einzeloperationen ueber `forTenant()`, je Methode ein Klient (Riegel zuerst bei `renameDashboard`). `deleteDashboard`/`reorderDashboards` laufen je als EINE `withTenantTransaction` (mehrschrittig, muss atomar sein) — `withTenantTransaction` setzt KEINE Benutzerdimension in der Sitzung, deshalb traegt jede Bedingung `userId` selbst (`tx.dashboard.deleteMany({where:{id,userId}})`, `tx.dashboard.updateMany({where:{id,userId},...})`), wortgleiches Muster zu `favorites.service.ts`/`reorder` (260917-jdd). `deleteDashboard` entfernt zusaetzlich die Kacheln (`tx.widgetInstance.deleteMany`) und die Anordnung (`tx.dashboardLayout.deleteMany`) des Reiters in DERSELBEN Transaktion. |
|
||||
| apps/api/src/dashboard/dashboard.service.ts | dashboardLayout | muss-mandantengebunden | gebunden | Anordnung eines Reiters, `tenantId`-Spalte vorhanden. Seit 260910-krx (Aufgabe 2) laufen `getLayout`/`saveLayout` GEMEINSAM ueber `forTenant()`, ein Klient je Methode; `saveLayout` uebersetzt eine `PrismaClientUnknownRequestError` (RLS-Konflikt, gemessen in Aufgabe 1 — NICHT die `P2002`-Form, die der Bereich `tenders` abfaengt) in eine deutsche Konfliktmeldung. quick-260923-ad9 (Task 1): die eindeutige Spalte ist jetzt `dashboardId` statt `userId` (D-02) — beide Methoden pruefen vorher ueber `assertOwnedDashboard`, dass der Reiter dem Aufrufer gehoert. |
|
||||
| apps/api/src/dashboard/dashboard.service.ts | module | keine-mandantengebundene-tabelle | ungebunden | Modulkatalog ist plattformweit, kein `tenantId` (Migration 20260909140000, Gruppe b). MESSUNG (260910-krx, Aufgabe 1, uebernommen aus `module-registry`-Pruefung `module-tabelle-traegt-keinen-zeilenschutz`): die Tabelle traegt heute keinen Zeilenschutz, eine Bindung waere heute wirkungslos, nicht katastrophal. BEDINGUNG: katastrophal wuerde sie erst, WENN Etappe 3 dieser Tabelle eine Regel gibt. Die Katalogaufloesung fuer den Widget-Modulfilter (`ModuleAccessService.getAccessibleModuleIds`) bindet bereits seit 260910-exd in ihrem eigenen Dienst — hier NICHT ein zweites Mal gebunden. |
|
||||
| apps/api/src/dashboard/dashboard.service.ts | searchProvider | muss-mandantengebunden | gebunden | `tenantId` nullbar. WINDOWS #19 geschlossen (260910-jab) als **widerlegte Prämisse** für dieses Modell. In diesem Durchlauf (260910-krx, Aufgabe 1) EIGENSTAENDIG nachgeprueft, nicht aus 260910-jab abgeschrieben: `grep -rn "searchProvider\|SearchProvider" apps packages prisma --include=*.ts --include=*.mjs --include=*.js --include=*.sql --include=*.json` (ohne `node_modules`, `dist/`, `.next/`) findet weiterhin genau einen Schreibweg, `dashboard.service.ts:addSearchProvider` (`create`), mit `tenantId: string` als Pflichtparameter — keine Seed-Datei, kein Skript. Seit Aufgabe 2/3 laufen `getSearchProviders`/`addSearchProvider`/`removeSearchProvider` ueber `forTenant()`; die Regel auf `SearchProvider` bleibt UNVERAENDERT streng, zusaetzlich datenbankseitig verteidigt durch `searchprovider-gebundenes-einfuegen-ohne-mandant-abgelehnt` (Aufgabe 1). |
|
||||
| apps/api/src/dashboard/dashboard.service.ts | widgetInstance | muss-mandantengebunden | gebunden | Platzierte Dashboard-Widgets eines Nutzers, `tenantId`-Spalte vorhanden. Seit 260910-krx (Aufgabe 2) laufen `getWidgets`, `addWidget` sowie beide Paare aus Besitzpruefung und Schreibzugriff (`updateWidgetConfig`/`removeWidget`) ueber `forTenant()`; die vorgeschalteten Besitzpruefungen ueber die Benutzerkennung bleiben zusaetzlich bestehen (die Regeln dieses Bereichs kennen keine Benutzerdimension). Benutzerdimension seit 20260911120000 (260911-nke). |
|
||||
| apps/api/src/dashboard/dashboard.service.ts | widgetInstance | muss-mandantengebunden | gebunden | Platzierte Kacheln eines Reiters, `tenantId`-Spalte vorhanden. Seit 260910-krx (Aufgabe 2) laufen `getWidgets`, `addWidget` sowie beide Paare aus Besitzpruefung und Schreibzugriff (`updateWidgetConfig`/`removeWidget`) ueber `forTenant()`; die vorgeschalteten Besitzpruefungen ueber die Benutzerkennung bleiben zusaetzlich bestehen (die Regeln dieses Bereichs kennen keine Benutzerdimension). Benutzerdimension seit 20260911120000 (260911-nke). quick-260923-ad9 (Task 1): `getWidgets`/`addWidget` filtern/schreiben ueber `dashboardId` statt `userId` (D-02) — `assertOwnedDashboard` prueft vorher, dass der Reiter dem Aufrufer gehoert. |
|
||||
| apps/api/src/dkv/dkv.service.ts | dkvInvoiceHistory | muss-mandantengebunden | gebunden | DKV-Rechnungshistorie je Mandant, `tenantId`-Spalte vorhanden. Seit 260909-mir (Aufgabe 3) laufen beide Historien-Schreibzugriffe der Verarbeitungsstrecke, beide parallelen Lesezugriffe von `getHistory` und der neue Riegel vor dem Ausfuhrdatei-Download vollstaendig ueber `forTenant()`. |
|
||||
| apps/api/src/dkv/dkv.service.ts | dkvModuleConfig | muss-mandantengebunden | system-gebunden | Postfach-/Zugangsdaten des DKV-Moduls je Mandant. Seit 260909-mir (Aufgabe 2) laufen `loadConfig`, `getConfigForApi`, `saveConfig`, `testConnection` und der Konfigurations-Lesezugriff der Verarbeitungsstrecke ueber `forTenant()`. Seit 260914-eym liest der Planer-Startpfad `loadActiveConfigsForScheduler()` ueber `forSystem()` (alle aktiven Konfigurationen, nur lesend, `system_read_policy`) — kein ungebundener Zugriff mehr, WINDOWS #21 geschlossen; alle uebrigen Zugriffe bleiben mandantengebunden (Stand-Vorrang: system ohne ungebunden = `system-gebunden`). |
|
||||
| apps/api/src/dkv/dkv.service.ts | dkvVehicleMaster | muss-mandantengebunden | gebunden | Fahrzeugstammdaten des DKV-Moduls je Mandant. Seit 260909-mir (Aufgabe 3) laufen Fahrzeugliste, Anlegen, beide Paare aus Besitzpruefung und Schreibzugriff (Aendern/Loeschen), beide Zweige des CSV-Imports und der gebuendelte Lesezugriff beim Aufbau der Ausfuhrzeilen vollstaendig ueber `forTenant()`; die vorgeschalteten Besitzpruefungen bei Aendern/Loeschen bleiben zusaetzlich bestehen (Befund G — ein gebundenes UPDATE ueber die Kennung allein trifft eine fremde Zeile still, nicht laut). |
|
||||
@@ -743,6 +762,8 @@ werden.
|
||||
| apps/api/src/user/user.controller.ts | user | muss-mandantengebunden | gebunden | Nutzerverwaltung innerhalb des Mandanten des anfragenden Admins (260910-das, Aufgabe 3): die Benutzerliste des ADMIN-Zweigs, alle drei Kennungswege (rollenabhaengig ueber `UserService.findById`/`findByIdForPlatformAdmin`) und alle fuenf Selbstbedienungszugriffe (Bild hochladen/loeschen/ausliefern, Akzentfarbe) laufen ueber `forTenant()`; die Rollenverzweigung zwischen mandantengebundener ADMIN-Sicht und der uebergreifenden `SUPER_ADMIN`-Sicht (ueber `UserService.findAllForPlatformAdmin`) bleibt bestehen. Der wirkungslose Selbstloesch-Riegel (Befund H, verglich gegen `currentUser.sub`, ein im Sitzungsnachweis nicht existierendes Feld) ist auf `currentUser.id` korrigiert. |
|
||||
| apps/api/src/user/user.service.ts | tenant | keine-mandantengebundene-tabelle | ungebunden | Schleifentreiber der neuen Plattform-Administratorsicht (`findAllForPlatformAdmin`/`findByIdForPlatformAdmin`, 260910-das, Aufgabe 2, Befund F/N) — `Tenant` hat keine `tenantId`-Spalte und traegt keinen Zeilenschutz (Aufgabe 1, `tenant-tabelle-ohne-zeilenschutz-bleibt-lesbar`). |
|
||||
| apps/api/src/user/user.service.ts | user | beides | gemischt | Klassenkorrektur (260910-das, Aufgabe 3): wechselt von `muss-mandantengebunden` auf `beides` wegen der einen bewusst ungebundenen Suche — wortgleich derselbe Praezedenzfall wie `ldap.service.ts`/`user` in 260909-ipc (`resolveEmailForWrite`). `findById`/`create`/`update`/`deactivate`/`delete` sowie die beiden neuen Plattform-Administratorsicht-Methoden laufen ueber `forTenant()`; `create`/`update` uebersetzen eine plattformweite Eindeutigkeitsverletzung (P2002) in eine deutsche Konfliktmeldung ohne Halter/Mandant zu nennen. `findByUsername` bleibt bewusst UNGEBUNDEN: der Anmeldeweg laeuft seit Etappe 1 ueber die drei SECURITY-DEFINER-Funktionen und hat diese Methode nicht mehr als Aufrufer (260910-das, Aufgabe 1, Teil 3: genau ein Treffer, die eigene Definition); eine gebundene Suche saehe einen fremden Halter des plattformweit eindeutigen `username` nicht und meldete faelschlich "frei". |
|
||||
| apps/api/src/proxmox/proxmox.service.ts | proxmoxServer | muss-mandantengebunden | system-gebunden | **quick-260923-dhh, Aufgabe 4:** Stand von `gebunden` auf `system-gebunden` — NICHT weil ein Anfrageweg aufgeweicht wurde, sondern weil EIN Startpfad dazugekommen ist: `loadActiveServersForScheduler()` liest beim Start des Planers `const systemPrisma = forSystem(this.prisma);` (ein Aufruf, Erlaubnisliste in `rls-access-inventory.spec.ts`; Leserecht ueber `system_read_policy … FOR SELECT` auf "ProxmoxServer", Migration 20260923140000) — der Planer muss die aktiven Server ALLER Mandanten sehen, um je Mandant einen Cron-Auftrag zu registrieren (Muster `DkvSchedulerService`). GESCHRIEBEN wird auch dort nur je Zeile gebunden. Sechs mandantengebundene Zugriffe blieben nach Aufgabe 4 bestehen: `createServer` (`proxmoxServer.create`), `listWithStatus` (`findMany`), `pollServer` (`findUnique`, mit `include: { status: true }` fuer die Zehn-Sekunden-Sperre), `testConnection` (`findUnique`), `listActiveServerIdsForTenant` (`findMany`), `loadActiveServersForTenantScheduling` (`findMany` auf `proxmoxServer`, `select: { pollIntervalMin: true }`). **Aufgabe 5** ergaenzt vier weitere: `updateServer` (`findUnique` UND `update`) und `deleteServer` (`findUnique` UND `delete`), je ein Klient je Methode — macht zehn mandantengebundene `proxmoxServer`-Rohtreffer insgesamt, plus der eine System-Rohtreffer aus Aufgabe 4. Vorher (Aufgabe 1): vom Administrator eingetragene Proxmox-Server (PVE/PBS/PMG), `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` OHNE Benutzerdimension (Migration 20260923140000, Form aus `DkvModuleConfig`) — Verwaltungsdaten des Mandanten, nicht persoenliche Daten eines Benutzers. `listWithStatus` waehlt die beiden Geheimnisfelder (`encryptedTokenSecret`/`encryptedPassword`) per `select` gar nicht erst aus (T-DHH-01). |
|
||||
| apps/api/src/proxmox/proxmox.service.ts | proxmoxServerStatus | muss-mandantengebunden | gebunden | quick-260923-dhh, Aufgabe 1/4 — Zwischenlager je Server (D-05), `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` OHNE Benutzerdimension (Migration 20260923140000, dieselbe Form wie `proxmoxServer`). `pollServer` schreibt ueber `tenantPrisma.proxmoxServerStatus.upsert()`, DENSELBEN Klienten wie das Lesen des Servers in derselben Methode; dieselbe Methode liest zusaetzlich `include: { status: true }` fuer die Zehn-Sekunden-Sperre (Aufgabe 4, T-DHH-06) — ebenfalls ueber den gebundenen Klienten. Bewusst KEINE `system_read_policy` auf dieser Tabelle (anders als `proxmoxServer`) — der Planer-Startpfad liest nur die Serverzeilen, das Zwischenlager wird ausschliesslich je Mandant gebunden geschrieben, ein Systemlesezugriff hat keinen Aufrufer. |
|
||||
|
||||
## Was diese Etappe NICHT entscheidet
|
||||
|
||||
@@ -808,7 +829,10 @@ werden.
|
||||
Anmeldenamen pro Mandant (Etappe 3a) bleibt offen. **Systemkontext
|
||||
(Etappe 3c) — erledigt (260914-eym):** Migration
|
||||
`20260914120000_rls_system_context_read` (`is_system_context()`,
|
||||
`system_read_policy … FOR SELECT` auf fünf Tabellen), Schwesterhelfer
|
||||
`system_read_policy … FOR SELECT` auf fünf Tabellen; seit 260922-hk4
|
||||
kommt "DashboardImage" als sechste dazu, angelegt in der Migration
|
||||
20260922120000 für den Bootstrap-Umzug der Bilderrahmen-Bilder),
|
||||
Schwesterhelfer
|
||||
`forSystem()`, fünfte Erkennungsform des Detektors mit Erlaubnisliste;
|
||||
siehe `docs/mandantentrennung-etappe2-fehlerrichtung.md`, Abschnitt
|
||||
"## Systemkontext (Etappe 3c, 260914-eym)" und den Regelschluss je Fall
|
||||
|
||||
@@ -76,3 +76,46 @@ export interface DesktopLatestResponse {
|
||||
buildTime: string;
|
||||
files: Partial<Record<DesktopPlatform, DesktopLatestFile>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dashboard-Kacheln: EINE Typliste fuer Web und API (quick-260922-m1h).
|
||||
*
|
||||
* Vorher stand dieselbe Liste an sieben Stellen (Union-Typ, Constraints,
|
||||
* Registry, Katalog-Liste, `@IsIn`-Whitelist ...). Vergass man eine, fehlte
|
||||
* die Kachel im Katalog oder die API lehnte sie mit 400 ab. Seit m1h leiten
|
||||
* beide Seiten von hier ab: `apps/web/src/components/dashboard/
|
||||
* widget-registry.tsx` (Registry + Katalog) und
|
||||
* `apps/api/src/dashboard/dto/create-widget.dto.ts` (`@IsIn`).
|
||||
*
|
||||
* ACHTUNG: Dies ist der erste LAUFZEIT-Import aus `@tessera/shared` (alle
|
||||
* uebrigen sind `import type`). `packages/shared` liefert rohes TypeScript
|
||||
* (`main: src/index.ts`, kein Bauschritt); die API laedt es im Betrieb ueber
|
||||
* das native Type-Stripping von Node 24. Deshalb darf diese Datei nur
|
||||
* loeschbare Syntax enthalten — keine `enum`, kein `namespace`, keine
|
||||
* Parameter-Eigenschaften.
|
||||
*/
|
||||
export const WIDGET_TYPES = [
|
||||
'clock',
|
||||
'search',
|
||||
'calendar',
|
||||
'note',
|
||||
'calculator',
|
||||
'favorites',
|
||||
'stopwatch',
|
||||
'picture-frame',
|
||||
'xframe',
|
||||
] as const;
|
||||
|
||||
export type WidgetType = (typeof WIDGET_TYPES)[number];
|
||||
|
||||
/**
|
||||
* Kachel → Modul-Slug. Eine Kachel ohne Eintrag ist immer sichtbar; eine
|
||||
* Kachel MIT Eintrag erscheint nur fuer Benutzer, die das Modul nutzen
|
||||
* duerfen — im Katalog (Komfort, `visibleWidgetTypes`) und verbindlich
|
||||
* serverseitig in `DashboardService.getWidgets` (fail-closed).
|
||||
*
|
||||
* Heute bewusst leer: alle neun Kacheln sind Plattform-Kacheln ohne
|
||||
* Modulbezug. Die erste modulgebundene Kachel (Proxmox) traegt hier ihren
|
||||
* Slug ein.
|
||||
*/
|
||||
export const WIDGET_MODULE_SLUGS: Partial<Record<WidgetType, string>> = {};
|
||||
|
||||
Generated
+3
@@ -181,6 +181,9 @@ importers:
|
||||
|
||||
apps/web:
|
||||
dependencies:
|
||||
'@tessera/shared':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/shared
|
||||
'@uiw/react-md-editor':
|
||||
specifier: 4.1.1
|
||||
version: 4.1.1(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
|
||||
Reference in New Issue
Block a user