Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cc26197fa1 | |||
| 12409322f5 | |||
| b5f22e2c4a | |||
| 8f2c13a35f | |||
| 88896d3b43 | |||
| 2a27d96bca |
+26
-15
@@ -1,13 +1,13 @@
|
||||
---
|
||||
context: default
|
||||
phase: mandantentrennung-etappe-2
|
||||
phase: mandantentrennung-etappe-3
|
||||
task: null
|
||||
total_tasks: 3
|
||||
total_tasks: 5
|
||||
status: paused
|
||||
last_updated: 2026-09-09T09:16:11.548Z
|
||||
last_updated: 2026-09-11T13:00:00.000Z
|
||||
---
|
||||
|
||||
# Wiedereinstieg — Mandantentrennung, vor Etappe 2
|
||||
# Wiedereinstieg — Mandantentrennung, ETAPPE 2 ABGESCHLOSSEN, vor WINDOWS #27 und Etappe 3
|
||||
|
||||
## Critical Anti-Patterns
|
||||
|
||||
@@ -21,15 +21,21 @@ Alle vier stammen aus tatsaechlichen Fehlschlaegen dieser Sitzung, nicht aus Vor
|
||||
| Zeichensatz beim Veroeffentlichen angenommen | Die Handbuch-Webseite ging mit zerlegten Umlauten live ("Für" statt "Fuer"), weil im lokalen Test der Zeichensatz fehlte und ich annahm, das Veroeffentlichen setze ihn schon richtig. | advisory | Seiten mit deutschem Text als reines ASCII ausliefern (Sonderzeichen als `\uXXXX` in den Daten). Dann kann kein Zeichensatz sie falsch auslegen. Die fertige Datei mit `all(ord(c)<128 ...)` pruefen. |
|
||||
|
||||
<current_state>
|
||||
Etappe 1 der Mandantentrennung ist abgeschlossen, committet und gepusht (`5228f28`).
|
||||
Der Arbeitsbaum ist sauber, die CI gruen, 701 Tests gruen.
|
||||
**Etappe 2 ist am 2026-09-11 abgeschlossen.** Alle zwoelf Bereiche sind gebunden und
|
||||
einzeln verifiziert (ldap, groups, tenders, dkv, user, module-registry, dashboard,
|
||||
calendar, tenant, auth, favorites+settings); die drei Datenbankregeln wurden auf
|
||||
Anweisung des Users vorgezogen (260910-jab). Endstand: 994 Tests in 62 Dateien
|
||||
(Ausgang 701/53), 137 Live-Pruefungen (Ausgang 8), 65 Paare / 68 ungebunden /
|
||||
178 gebunden — jeder ungebundene Zugriff liegt auf einer plattformglobalen Tabelle
|
||||
oder einem benannten Startpfad. Alles gepusht, Arbeitsbaum sauber.
|
||||
|
||||
Die Sitzung lief ueber Quick-Tasks, nicht ueber Phasen — es gibt daher kein
|
||||
aktives Phasenverzeichnis. Der Meilenstein v1.2 ist seit dem 2026-09-07 zu, ein
|
||||
neuer wurde nicht begonnen.
|
||||
**Der Umstellungsschalter ist AUS.** `DATABASE_URL` zeigt weiter auf die Rolle
|
||||
`tessera` mit BYPASSRLS. Der User hat ausdruecklich verlangt, beim Scharfschalten
|
||||
angehalten und gefragt zu werden.
|
||||
|
||||
Der Umstellungsschalter ist AUS: `DATABASE_URL` zeigt weiterhin auf die Rolle
|
||||
`tessera` mit BYPASSRLS. Das ist Absicht — siehe Sperrgrund unten.
|
||||
Die maschinelle Bestandsaufnahme hat eine bekannte Blindstelle (WINDOWS #27):
|
||||
`include:`/`_count:` in fremd geschuetzte Tabellen sieht sie nicht. Alle heutigen
|
||||
Instanzen sind einzeln geprueft; der Mechanismus muss VOR Etappe 4 geschlossen werden.
|
||||
</current_state>
|
||||
|
||||
<completed_work>
|
||||
@@ -132,10 +138,15 @@ dafuer ist jeder Code, der Leere als Abwesenheit deutet und daraufhin loescht. V
|
||||
Umstellung eines Bereichs ist zu pruefen, ob er solchen Code enthaelt.
|
||||
|
||||
<next_action>
|
||||
Etappe 2 beginnen, und zwar NICHT mit dem groessten Bereich. Einstieg ist `ldap`
|
||||
(21 Zugriffe, davon 9 bereits mandantengebunden): dort sitzt der gefaehrlichste
|
||||
Loeschzweig, die Wirkung ist dort am besten pruefbar, und der Bereich ist klein genug
|
||||
fuer einen Durchlauf. Danach `groups` (37), dann `tenders` (62).
|
||||
1. WINDOWS #27 schliessen — Detektor in `rls-access-inventory.spec.ts` um
|
||||
`include:`/`select:`/`_count:` auf Modellnamen erweitern, Zieltabelle als eigene
|
||||
Fundstelle fuehren. Zwingend vor Etappe 4.
|
||||
2. Etappe 3 planen (drei Teile): (a) Anmeldenamen pro Mandant — Schema-Aenderung,
|
||||
Anmeldeweg muss den Mandanten VOR der Suche kennen, SECURITY-DEFINER-Funktionen
|
||||
mit zwei Gleichheitsbedingungen; (b) Benutzerdimension — `app.current_user`,
|
||||
`current_user_id()`, forTenant() erweitern, Regeln der zehn nutzerbezogenen
|
||||
Tabellen; (c) Systemkontext fuer die sechs Hintergrunddienst-Faelle.
|
||||
3. Etappe 4 — Scharfschalten. NUR nach Rueckfrage beim User.
|
||||
|
||||
Frische Sitzung, dann `/gsd-resume-work`.
|
||||
</next_action>
|
||||
|
||||
+17
-14
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"version": "1.0",
|
||||
"timestamp": "2026-09-09T09:16:11.548Z",
|
||||
"timestamp": "2026-09-11T13:00:00.000Z",
|
||||
"phase": null,
|
||||
"phase_name": "Mandantentrennung wirksam machen (Etappenarbeit ausserhalb der Phasen, ueber Quick-Tasks)",
|
||||
"phase_dir": null,
|
||||
@@ -9,27 +9,30 @@
|
||||
"total_tasks": 4,
|
||||
"status": "paused",
|
||||
"completed_tasks": [
|
||||
{"id": 1, "name": "Etappe 1 / forTenant() auf eine Verbindung zwingen, live nachgewiesen", "status": "done", "commit": "bbf1795"},
|
||||
{"id": 2, "name": "Etappe 1 / Anmeldeweg ueber drei SECURITY-DEFINER-Funktionen", "status": "done", "commit": "de50297"},
|
||||
{"id": 3, "name": "Etappe 1 / alle 227 Zugriffe klassifiziert, maschinell abgesichert", "status": "done", "commit": "5f3a39c"},
|
||||
{"id": 4, "name": "Etappe 1 / Browser-Gegenprobe der Anmeldung (lokal)", "status": "done", "commit": "da0ac04"}
|
||||
{"id": 1, "name": "Etappe 1 / forTenant() repariert, Anmeldeweg ueber SECURITY DEFINER, 227 Zugriffe klassifiziert", "status": "done", "commit": "5228f28"},
|
||||
{"id": 2, "name": "Etappe 2 / alle zwoelf Bereiche gebunden, jeder einzeln verifiziert (260909-ipc .. 260911-gwh)", "status": "done", "commit": "1240932"},
|
||||
{"id": 3, "name": "Zwischendurch auf Anweisung des Users: drei Datenbankregeln geschlossen (260910-jab)", "status": "done", "commit": "03fb3bf"}
|
||||
],
|
||||
"remaining_tasks": [
|
||||
{"id": 5, "name": "Etappe 2: 31 Einheiten vollstaendig + 9 teilweise auf forTenant umstellen, nach Bereichen gebuendelt (tenders 62, groups 37, ldap 21, dkv 21, user 17, module-registry 17, dashboard 13, calendar 12, tenant 8, favorites 7, settings 4)", "status": "not_started"},
|
||||
{"id": 6, "name": "Etappe 3: Systemkontext fuer Hintergrundlaeufe plus WINDOWS #19 (nullable tenantId bei SearchProvider und TenderRssFeedSource)", "status": "not_started"},
|
||||
{"id": 7, "name": "Etappe 4: Scharfschalten (DATABASE_URL auf tessera_app) mit Vorabpruefung und dokumentiertem Rueckweg", "status": "not_started"}
|
||||
{"id": 4, "name": "WINDOWS #27 schliessen: Relations-Blindstelle der Bestandsaufnahme (include:/_count: in fremde Tabellen) — ZWINGEND vor Etappe 4", "status": "not_started"},
|
||||
{"id": 5, "name": "Etappe 3a: Anmeldenamen pro Mandant eindeutig (Produktentscheidung User 2026-09-10) — Schema @@unique([tenantId, username/email]), Anmeldeweg kennt Mandant VOR der Suche, SECURITY-DEFINER-Funktionen mit zwei Gleichheitsbedingungen", "status": "not_started"},
|
||||
{"id": 6, "name": "Etappe 3b: Benutzerdimension in den Regeln (Produktentscheidung User 2026-09-10) — app.current_user/current_user_id(), forTenant() um userId erweitern, Regeln der zehn nutzerbezogenen Tabellen", "status": "not_started"},
|
||||
{"id": 7, "name": "Etappe 3c: Systemkontext fuer die sechs Hintergrunddienst-Faelle (#21 dkv, #30 settings, vier beides-Uebergaben aus tenders/ldap)", "status": "not_started"},
|
||||
{"id": 8, "name": "Etappe 4: Scharfschalten (DATABASE_URL auf tessera_app) mit rls-preflight.mjs — Vorabpruefung muss die stillen Leere-Faelle #23/#25/#26/#28/#31/#32 abdecken. USER WILL HIER GEFRAGT WERDEN.", "status": "not_started"}
|
||||
],
|
||||
"blockers": [
|
||||
{"description": "Etappe 4 darf erst nach Etappe 2 und 3 laufen. Wird vorher scharf geschaltet, liefern die noch nicht umgestellten Abfragen null Zeilen statt zu vieler.", "type": "technical", "workaround": "Reihenfolge einhalten; rls-preflight.mjs vor dem Umschalten laufen lassen"}
|
||||
{"description": "Etappe 4 darf erst nach Etappe 3 und nach Schliessen von WINDOWS #27 laufen. Der User hat ausdruecklich verlangt, beim Scharfschalten angehalten und gefragt zu werden.", "type": "process", "workaround": "Reihenfolge einhalten"}
|
||||
],
|
||||
"async_jobs": [],
|
||||
"human_actions_pending": [],
|
||||
"decisions": [
|
||||
{"decision": "Anmeldeweg ueber SECURITY-DEFINER-Funktionen statt Policy oder zweiter Rolle", "rationale": "Eine Policy ist ein Zeilenpraedikat und haette zwangslaeufig die ganze Benutzertabelle freigegeben. Die Funktion pinnt die Ausnahme auf feste Spaltenliste, Gleichheitsbedingung und LIMIT 1.", "phase": "Etappe 1"},
|
||||
{"decision": "Benanntes Volume fuer user-files statt Bind-Mount", "rationale": "Das Image uebereignet /app/user-files an uid 1001; ein frisch angelegtes Host-Verzeichnis gehoert root und haette aus dem Datenverlust einen kaputten Upload gemacht.", "phase": "Quick 260909-cx0"},
|
||||
{"decision": "Datenverlust in der Datenbank ist derzeit hinnehmbar", "rationale": "Ausdrueckliche Aussage des Users am 2026-09-09: nichts laeuft produktiv. Erlaubt beim Scharfschalten den direkten Weg. Gilt nur, solange das so bleibt.", "phase": "Etappe 4 (Vorgriff)"}
|
||||
{"decision": "Anmeldenamen pro Mandant eindeutig, nicht plattformweit", "rationale": "Produktentscheidung des Users am 2026-09-10 — m.schmidt darf es bei Firma A und B geben", "phase": "Etappe 3"},
|
||||
{"decision": "Kollegen derselben Firma strikt getrennt — Benutzerdimension in die Datenbankregeln", "rationale": "Produktentscheidung des Users am 2026-09-10; heute trennt nur der Anwendungscode, die Datenbank kennt nur den Mandanten", "phase": "Etappe 3"},
|
||||
{"decision": "req.tenantPrisma entfernt, Middleware geloescht", "rationale": "Bei jeder Anfrage gebaut, nirgends gelesen; Middleware war nirgends registriert; neun Bereiche haben dienst-internes forTenant() als Konvention festgelegt", "phase": "260911-e2s"},
|
||||
{"decision": "Datenbankregeln VOR Abschluss von Etappe 2 vorgezogen", "rationale": "Ausdrueckliche Anweisung des Users am 2026-09-10 — offene Loecher werden vergessen", "phase": "260910-jab"},
|
||||
{"decision": "Datenverlust in der Datenbank ist derzeit hinnehmbar", "rationale": "User 2026-09-09: nichts laeuft produktiv. Gilt nur solange das so bleibt.", "phase": "Etappe 4 (Vorgriff)"}
|
||||
],
|
||||
"uncommitted_files": [],
|
||||
"next_action": "Etappe 2 beginnen: docs/mandantentrennung-zugriffsklassifikation.md lesen und den ersten Bereich buendeln. Sinnvoller Einstieg ist NICHT der groesste Bereich, sondern ldap (21 Zugriffe, 9 davon bereits mandantengebunden) — dort sitzt der gefaehrlichste Loeschzweig, und die Wirkung ist dort am besten pruefbar.",
|
||||
"context_notes": "Die Sitzung lief ueber Quick-Tasks, nicht ueber Phasen; es gibt daher kein aktives Phasenverzeichnis. Der entscheidende Fund war, dass forTenant() selbst kaputt war (Kontext auf einer Verbindung, Abfrage auf einer anderen) — die Mandantentrennung hat nie funktioniert. Das ist behoben und live belegt. Wichtig fuer die Fortsetzung: erst pruefen, ob das Fundament traegt, bevor darauf gebaut wird; genau das hat hier einen stillen Datenverlust verhindert. Der Arbeitsbaum ist sauber, alles ist gepusht, die CI ist gruen."
|
||||
"next_action": "WINDOWS #27 schliessen (Relations-Blindstelle), dann Etappe 3 planen. NICHT direkt scharfschalten.",
|
||||
"context_notes": "Etappe 2 lief ueber zwoelf Quick-Tasks plus einen Regel-Durchlauf, jeder mit Planer, Plan-Pruefer, Executor, Verifizierer. ZEHN Lieferungen wurden vom jeweils NAECHSTEN Schritt gefangen, nie vom eigenen: vier geschrumpfte Zaehlungen, zwei nicht committete Messungen, zwei Zusammenfassungen mit N statt N-1, handgepflegte Dokumentstellen uebersprungen, Falsifizierungsnachweise nur in Commit-Nachrichten, eine Wegwerf-Tabelle ohne createdAt/updatedAt, ein Selbstwiderspruch, ein Pruefer der etwas als plausibel durchwinkte, ein still fehlgeschlagener git add, und zwei Agenten die am Sitzungslimit NACH getaner Arbeit abbrachen (dkv-Executor, gwh-Planer — beide Male lag die Arbeit vollstaendig auf der Platte; git status ist die Wahrheit, nicht der Bericht). Die vollstaendige Liste steht in jedem Planer-Auftrag der spaeten Bereiche. Nebenfunde ohne Mandantenbezug, alle behoben: DKV-Download-Luecke, drohender DKV-Passwortverlust, Selbstloesch-Riegel der nie griff, Startfehler bei Neuinstallation, adminResetPassword ohne Mandanten- und Rollenpruefung, Widget-Besitzriegel bei Favoriten, sechs luegende Kommentare, und fuenf Bereiche ohne jede Testdatei."
|
||||
}
|
||||
|
||||
+2
-1
@@ -389,6 +389,7 @@ None yet.
|
||||
| 260911-cwh | Mandantentrennung Etappe 2, Bereich calendar — alle 12 Zugriffe gebunden, ein Klient je Methode, sechs Methoden. Bereich hatte KEINE Testdatei (dkv-Form); `calendar.service.spec.ts` neu mit 23 Tests. **Gespeicherte Zugangsdaten zu fremden Kalender-Servern** — ein Fremdzugriff waere hier der Schluessel zu einem fremden Exchange/CalDAV. **Der dkv-Passwortverlust-Fall existiert hier NICHT**, in beiden Haelften belegt: der Dienst schreibt `encryptedPassword` nur bei `dto.password !== undefined`, und `calendar-source-form.tsx` laesst ein leeres Feld WEG statt einen leeren Text zu schicken; als drei Tests festgenagelt, weil ein nicht festgenagelter Freispruch still aufhoeren kann zu gelten. **Zwischenspeicher-Schluessel `userId:from:to` ohne Mandantenanteil ist sicher:** `User.id` ist `@default(uuid())`, Kette Schema -> `auth.service.ts sub: user.id` -> `JwtStrategy.validate` -> `extractContext` Glied fuer Glied belegt; die Etappe-3-Entscheidung (Anmeldenamen pro Mandant) beruehrt `username`/`email`, nicht `id`. **Eigener Gefahrenfall — halb gebundene Aggregationsschleife:** `fetchAndCacheEvents` liest Quellen und schreibt den Synchronstatus auf Erfolgs- UND Fehlerpfad zurueck, innerhalb von `Promise.allSettled`; gebundene Lesung mit ungebundenem Rueckschreiben haette Ereignisse still fallen lassen — beide Rueckschreibungen gebunden und als Tests festgenagelt, eines davon vom Pruefer eigenhaendig zurueckgebaut (genau 1 von 23 rot, exakte Meldung). **Frontend macht aus lauten Fehlern stille:** `calendar-widget.tsx` und `calendar-settings-panel.tsx` fangen jeden Fehler in denselben leeren Zustand — ein 403 sieht aus wie ein leerer Kalender; NICHT angefasst, als WINDOWS #26 offen festgehalten. Besitzpruefungen in allen drei Pfaden echt (403, nicht 404). Keine Eindeutigkeitskette, daher keine Konfliktuebersetzung, die nichts uebersetzt. **Lehre aus dashboard angewandt:** 4 der 13 neuen Pruefungen laufen ueber den GENERIERTEN Client, mit Laufzeitvergleich der Wegwerf-Tabelle gegen `schema.prisma` (17 = 17 Spalten). **Verifiziert 12/12** (883/883 Tests, 57 Dateien, Typpruefung sauber, 101/101 Live-Pruefungen; Klassenverteilung 31/17/13/2 = 63 und Ledger-Zaehler vom Pruefer nachgerechnet). Ein Selbstwiderspruch in der Zusammenfassung ('keine Abweichung' vs. 'kein TDD-Zyklus') berichtigt | 2026-09-11 | bf5fc4d,77cb124,e0e163e | [260911-cwh-mandantentrennung-etappe-2-bereich-calen](./quick/260911-cwh-mandantentrennung-etappe-2-bereich-calen/) |
|
||||
| 260911-e2s | Mandantentrennung Etappe 2, Bereich tenant — von anderer Art: alle 8 Zugriffe gehen auf die Mandantentabelle SELBST, die per Definition keinen Mandanten hat. **'Nichts zu binden' war trotzdem falsch, und der Grund ist der wichtigste Fund seit dem kaputten Helfer in Etappe 1:** drei der acht Zugriffe (`findAll`, `findOne`, `remove` im Controller) zaehlen ueber `include: { _count: { select: { users } } }` in die GESCHUETZTE Tabelle `User` hinein — Prisma 6.19 rendert das als `LEFT JOIN (SELECT tenantId, COUNT(*) FROM User ...)`, das unter DEREN Regel laeuft. Nach dem Scharfschalten haette die Mandantenliste des Plattform-Admins fuer jeden Mandanten 0 Benutzer gezeigt, und der Loeschriegel T-02-09 waere vakuum geworden (der Fremdschluessel faengt es noch, aber als 500 statt 400). Behoben per Fan-out je Mandant ueber gebundenen Client, Muster aus `UserService.findAllForPlatformAdmin`. **Die Bestandsaufnahme ist fuer Relationszugriffe strukturell blind** — sie sieht nur `this.prisma.<Modell>`, nicht was ein `include:` in eine zweite Tabelle hineinrechnet. Alle 19 `include:`-Stellen und alle `_count`-Stellen einzeln beurteilt, vom Orchestrator UND vom Verifizierer unabhaengig gegengeprueft (der Plan-Pruefer hatte diesen Punkt als 'plausibel' durchgewinkt statt ihn zu pruefen): nur diese drei waren gefaehrlich. Der MECHANISMUS bleibt offen und ist als WINDOWS #27 festgehalten — der Planer wollte keinen Eintrag, weil die Instanz behoben ist; Orchestrator und Verifizierer sahen das anders, weil eine Luecke im Messwerkzeug, die nachweislich einen echten Defekt verborgen hat, genau dafuer ins Ledger gehoert. **Die seit Etappe 1 offene Architekturfrage ist entschieden:** `req.tenantPrisma` wurde bei jeder Anfrage gebaut und NIRGENDS gelesen; neun Bereiche haben die Konvention auf dienst-internes `forTenant()` festgelegt. Middleware geloescht (sie war nirgends registriert — der Auftrag irrte bei `app.module.ts:57`, dort ist der Guard verdrahtet), Guard ohne Prisma-Abhaengigkeit, setzt nur noch `req.tenantId` (22 Leser in 9 Dateien) und den `x-tenant-id`-Wechsel fuer SUPER_ADMIN (4 Frontend-Stellen) — beides erstmals getestet; Guard und Middleware hatten NIE Tests, 'ihre Tests' in Etappe 1 war eine Annahme. Totes Kabel, das wie eine Sicherung aussieht, ist schlimmer als keins. Ausnahmeliste in `rls-access-inventory.spec.ts` geleert und mit Wachhund versehen. Drei Kommentare berichtigt, die `TenantMiddleware`/`req.tenantPrisma` als lebendig beschrieben. Executor fing einen still fehlgeschlagenen `git add` (2 von 7 Dateien) selbst an `git status` und lieferte nach. **Verifiziert 10/10 mit vier eigenhaendigen Falsifizierungen** (Header-Wechsel zweimal gebrochen, Fan-out gebrochen, Wachhund ausgeloest — je exakt die benannten Tests rot; 911/911 Tests, 59 Dateien, Typpruefung sauber, 110/110 Live-Pruefungen; 64 Paare und Klassenverteilung 32/17/13/2 nachgerechnet) | 2026-09-11 | 652e762,11f5731,17dca0d,c8de72e | [260911-e2s-mandantentrennung-etappe-2-bereich-tenan](./quick/260911-e2s-mandantentrennung-etappe-2-bereich-tenan/) |
|
||||
| 260911-fh9 | Mandantentrennung Etappe 2, Bereich auth — die drei in Etappe 1 bewusst ausgelassenen Wege (`getMe`, `changePassword`, `adminResetPassword`) gebunden, alle drei brauchten neue Signaturen (nahmen nur `userId`). Der Anmeldeweg ueber die drei SECURITY-DEFINER-Funktionen NICHT angefasst, per `pg_proc` belegt (weiterhin genau 9 Spalten, auch nachdem die Wegwerf-Tabelle `User` 5 fehlende Spalten bekam). Verbleibende 3 'ungebundene' Stellen sind die `$queryRaw`-Anmeldesuchen, keine Modellzugriffe. **Falle, die der Auftrag selbst gestellt hatte:** Selbstbedienung darf NICHT an `req.tenantId` binden — der Guard laesst SUPER_ADMIN diese Kennung per `x-tenant-id` umschalten (Marktplatz), 'mein Profil' haette ihn sich selbst gegenueber unsichtbar gemacht; gebunden wird an den Mandanten aus dem Sitzungsnachweis (`@CurrentUser().tenantId`), der Controller enthaelt null Verweise auf `req.tenantId`/`x-tenant-id`. **Zwei Loecher in `adminResetPassword` geschlossen, keines davon ein Mandantenproblem:** der Weg pruefte weder den Mandanten des Ziels noch dessen Rolle — ein ADMIN konnte das Passwort eines SUPER_ADMIN ueberschreiben. Beides jetzt dicht, SUPER_ADMIN-Pfad ueber `UserService.findByIdForPlatformAdmin`; `AuthModule` importiert `UserModule`, zyklusfrei. Der Schwesterweg `PATCH /users/:id` hat dieselbe Rollenluecke (T-02-08 prueft nur das ZUWEISEN der Rolle, nicht die bestehende Rolle des Ziels) — ausserhalb der Erlaubnisliste, als WINDOWS #29 festgehalten. **Umgekehrte Fehlerrichtung ist hier leise, nicht laut:** `getMe`-Leere wird zu 200 mit leerem Rumpf, `header.tsx` tut bei `if (u)` nichts — 'nicht angemeldet' und 'Zeile unsichtbar' sind derselbe Wert (WINDOWS #28); `changePassword`-Leere liest sich als `networkError`. Identitaets-Attrappe (ldap-Form) durch asymmetrischen Doppel ersetzt: ungebundener Nachbau ohne Modelle, gebundener ohne `$queryRaw` — beide Grenzen einzeln falsifizierbar. **Verifiziert 8/8** (951/951 Tests, 60 Dateien, Typpruefung sauber, 120/120 Live-Pruefungen; zwei Falsifizierungen vom Pruefer eigenhaendig reproduziert — genau 4 bzw. 2 benannte Tests rot) | 2026-09-11 | 9782bea,92aa8c4,f68beb3 | [260911-fh9-mandantentrennung-etappe-2-bereich-auth-](./quick/260911-fh9-mandantentrennung-etappe-2-bereich-auth-/) |
|
||||
| 260911-gwh | **Mandantentrennung Etappe 2, Bereiche favorites + settings — LETZTER Durchlauf, Etappe 2 abgeschlossen.** 7 `favoriteLink`-Zugriffe und 3 `smtpConfig`-Anfragepfade gebunden; genau ein `smtpConfig`-Zugriff bleibt bewusst offen: der Startpfad, umbenannt in `loadAnySmtpConfigForStartupTransport()` — SECHSTER Fall der Hintergrunddienst-Falle (`findFirst()` ohne Mandanten beim Hochfahren in `mail.module.ts`; heute bedient er einen willkuerlichen Mandanten, nach dem Scharfschalten null), beide Zustaende am Ort, WINDOWS #30. **Befund K geschlossen:** `getDecryptedSmtpConfig(tenantId)` bindet — die Reihenfolgebedingung fuer Etappe 4 aus dem tenders-Lauf ist erfuellt und in Kritikschrift (t4)/(d4) und Klassifikation als erfuellt vermerkt. **Widget-Besitzriegel in `favorites.create()` eingebaut, weil GEMESSEN noetig:** Pruefung 7 zeigt, dass ein gebundenes Anlegen mit fremder `widgetId` GELINGT — die Fremdschluessel-Pruefung umgeht den Zeilenschutz; vom Verifizierer live reproduziert und der Riegel durch Rueckbau falsifiziert (genau 4 Tests rot). Beide Bereiche hatten keine Testdatei fuer ihren Dienst; `favorites.service.spec.ts` (23) und `settings.service.spec.ts` (20) neu, `nodemailer` gemockt. Ledger #31/#32 fuer die stille Leere (leere Favoritenleiste = 'nie etwas gespeichert'; fehlende SMTP-Konfiguration = 'nicht eingerichtet', obwohl die Zugangsdaten da sind). Der Planer scheiterte am Sitzungslimit NACH dem Schreiben des Plans, VOR der Rueckmeldung — Plan lag vollstaendig auf der Platte (1226 Zeilen, Struktur gueltig), vom Orchestrator committet, vom Pruefer als Erstleser gegen den Baum gehalten. **Verifiziert 9/9** (994/994 Tests, 62 Dateien, Typpruefung sauber, 137/137 Live-Pruefungen; Uebersicht 68/178, Klassenverteilung 33+17+13+2=65 und Migrations-Zaehlung 4+3+16=23 vom Pruefer nachgerechnet) | 2026-09-11 | 88896d3,8f2c13a,b5f22e2,1240932 | [260911-gwh-mandantentrennung-etappe-2-bereiche-favo](./quick/260911-gwh-mandantentrennung-etappe-2-bereiche-favo/) |
|
||||
| 260909-eor | Etappe 1 der Mandantentrennung: Anmeldeweg mandantenfaehig gemacht und alle Zugriffe klassifiziert. **Kernfund (#20):** `forTenant()` setzte den Mandantenkontext per set_config auf der Transaktionsverbindung, dispatchte die Abfrage aber ueber den aeusseren Client — empirisch reproduziert (set_config auf Backend-PID 254999, Abfrage auf 255000, Kontext dort NULL). Die Trennung hat damit nie funktioniert, auch nicht an den Stellen, die sie scheinbar nutzten; nach dem Scharfschalten haetten diese Abfragen NULL Zeilen geliefert, was der LDAP-Loeschzweig als 'Gruppe im Verzeichnis verschwunden' gedeutet und geloescht haette. Behoben und live nachgewiesen. Der Anmeldeweg bekam drei SECURITY-DEFINER-Funktionen als schmale Ausnahme (feste Spaltenliste, Gleichheitsbedingung, LIMIT 1) — eine Policy haette nicht gereicht, weil sie zwangslaeufig die ganze Tabelle freigibt. Browser-Gegenprobe lokal bestanden: Anmeldung laedt das Portal, falsches Kennwort verraet weiterhin nicht welches Feld, Kennwort-vergessen laeuft durch (der einzige Protokollfehler war ein lokal fehlender Mailserver, also NACH dem Datenbankzugriff). Klassifikation aller 227 Zugriffe in 59 Einheiten, maschinell gegen Abdriften abgesichert: 31 muessen mandantengebunden werden, 9 teilweise, 16 betreffen keine mandantengebundene Tabelle, 3 bleiben bewusst uebergreifend. 701 Tests gruen | 2026-09-09 | da0ac04 | [260909-eor-anmeldeweg-mandantenfaehig-machen-und-al](./quick/260909-eor-anmeldeweg-mandantenfaehig-machen-und-al/) |
|
||||
| 260910-jab | Die drei zu kurz greifenden Datenbankregeln geschlossen — T-JTS-02, T-JTS-03, WINDOWS #19 (bewusste Reihenfolge-Abweichung, vorgezogen auf Nutzerwunsch, statt wie geplant nach Etappe 2). Neue, handgeschriebene, lokal angewandte Migration `20260910120000_rls_widen_membership_grant_and_platform_read`: `GroupMembership` prueft jetzt beide Seiten der Beziehung (Gruppe UND Benutzer), `ModuleGrant` prueft zusaetzlich beide moeglichen Ziele mit Leer-Zulassung (D-04), `TenderRssFeedSource` bekommt vier nach Befehl getrennte Regeln (Lesen schliesst plattformweite Zeilen ein, Schreiben verlangt weiterhin einen Mandanten — die Trennung ist noetig, weil ein einzelner USING-Ausdruck sonst auch UPDATE/DELETE mitregelt). `SearchProvider` bewusst NICHT angefasst: die WINDOWS-#19-Praemisse ist fuer dieses Modell widerlegt (kein Codeweg erzeugt eine mandantenlose Zeile). Drei loch-behauptende Pruefungen im Wegwerf-Werkzeug UMGEKEHRT statt geloescht (66→74 Pruefungen), mit Verweis auf die alten Pruefungsnamen und Befundkennungen im Meldetext. Genau EIN Anwendungspfad musste mitgebunden werden (`TenderRssFeedSourceService.listForUser`) — sonst haette die Reparatur ihn still von 'liefert nach dem Scharfschalten nichts' auf 'liefert nur die plattformweiten Zeilen, taeuscht Vollstaendigkeit vor' verschlechtert; Falsifizierungsnachweis gefuehrt (Bindung zurueckgenommen, genau ein Test rot, zurueckgesetzt). WINDOWS #19 geschlossen mit Beleg, WINDOWS #24 neu angelegt (Verwaltungsweg fuer plattformweite Zeilen unter der Anwendungsrolle fehlt weiterhin — verschwindet nicht mit #19). Aktenstand kohaerent: Klassifikation, Kritikschrift (neuer Abschnitt "Regelschluss T-JTS-02, T-JTS-03 und WINDOWS #19" mit Signaltabelle beider Fehlerrichtungen je Regel), Betriebsanleitung, WINDOWS.md — fuenf ueberholte Bestandsstellen mit Nachtraegen versehen, alte Messprotokolle bleiben woertlich stehen. Selbst gemessen statt uebernommen: Baseline 833/56 Tests, 66/66 Live-Pruefungen; Endstand 839/56, 74/74; keine zweite Sitzungsvariable fuer den Benutzer gefunden (nur `app.current_tenant`). Rule-1-Fix: implizites `any` in `tenders.controller.ts` nach der Bindung behoben. `npx prisma` versuchte ungefragt Prisma 8 herunterzuladen — abgebrochen, lokale gepinnte 6.19.3 verwendet | 2026-09-10 | f4f3115,6b23735,03fb3bf | [260910-jab-mandantentrennung-die-drei-zu-kurz-greif](./quick/260910-jab-mandantentrennung-die-drei-zu-kurz-greif/) |
|
||||
|
||||
@@ -432,6 +433,6 @@ sind. Kein Anlass, sie vorher erneut vorzulegen.
|
||||
|
||||
Last session: 2026-09-11T08:01:13.009Z
|
||||
Resumed: 2026-09-09 — Sitzung ueber /gsd-resume-work fortgesetzt; Einstiegspunkt Etappe 2 (Bereich ldap), Auswahl des Nutzers steht aus.
|
||||
Stopped at: Quick 260911-cwh abgeschlossen: Bereich calendar der Etappe 2 (Mandantentrennung) umgestellt, 12/12 Zugriffe gebunden, WINDOWS #26 neu offen
|
||||
Stopped at: **ETAPPE 2 DER MANDANTENTRENNUNG ABGESCHLOSSEN (2026-09-11).** Alle zwoelf Bereiche umgestellt und einzeln verifiziert: ldap 7/7, groups 9/9, tenders 8/9+Fix, dkv 9/9, user 10/10, module-registry 9/9, dashboard 10/11+Fix, calendar 12/12, tenant 10/10, auth 8/8, favorites+settings 9/9; dazu die drei Datenbankregeln (260910-jab, 11/11). Endstand: 994 Tests in 62 Dateien (Ausgang 701/53), 137 Live-Pruefungen gegen die Wegwerf-Datenbank (Ausgang 8), Klassifikation 65 Paare / 68 ungebunden / 178 gebunden — jeder ungebundene Zugriff liegt auf einer plattformglobalen Tabelle oder einem benannten Startpfad, keiner aus Versehen. Alles gepusht. DER SCHALTER IST WEITER AUS. NAECHSTE SCHRITTE: (A) ETAPPE 3 — die zwei Produktentscheidungen des Users vom 2026-09-10: Anmeldenamen pro Mandant (Schema `@@unique([tenantId, username/email])`, Anmeldeweg muss den Mandanten VOR der Suche kennen, SECURITY-DEFINER-Funktionen mit zwei Gleichheitsbedingungen) und Benutzerdimension in den Regeln (zweite Sitzungsvariable `app.current_user`/`current_user_id()`, forTenant() um userId erweitern, Regeln der zehn nutzerbezogenen Tabellen). Dazu Systemkontext fuer die sechs Hintergrunddienst-Faelle (#21, #30 und die vier `beides`-Uebergaben aus tenders/ldap). (B) VOR ETAPPE 4 ZWINGEND: WINDOWS #27 (Relations-Blindstelle der Bestandsaufnahme) schliessen — sonst stuetzt sich die Vorabpruefung auf ein Werkzeug, das `include:`/`_count:` in fremde Tabellen nicht sieht. (C) ETAPPE 4 — Scharfschalten mit rls-preflight.mjs; die Vorabpruefung muss die stillen Leere-Faelle #23/#25/#26/#28/#31/#32 abdecken. DER USER HAT AUSDRUECKLICH GESAGT: beim Scharfschalten anhalten und fragen. Ledger: 14 offen von 32.
|
||||
Resume file: None
|
||||
Last activity: 2026-09-10 - Completed quick task 260910-jab: Die drei zu kurz greifenden Datenbankregeln (T-JTS-02, T-JTS-03, WINDOWS #19) geschlossen
|
||||
|
||||
+42
-3
@@ -1,10 +1,10 @@
|
||||
---
|
||||
schema_version: 1
|
||||
open_count: 11
|
||||
open_count: 14
|
||||
waived_count: 1
|
||||
fixed_count: 17
|
||||
total_count: 29
|
||||
last_updated: 2026-09-11T10:00:38.418Z
|
||||
total_count: 32
|
||||
last_updated: 2026-09-11T11:57:50.484Z
|
||||
---
|
||||
|
||||
# Broken Windows Ledger
|
||||
@@ -44,6 +44,9 @@ last_updated: 2026-09-11T10:00:38.418Z
|
||||
| 27 | 2 | unmet-truth | apps/api/src/prisma/rls-access-inventory.spec.ts | | Die maschinelle Bestandsaufnahme (rls-access-inventory.spec.ts) ist fuer Relationszugriffe strukturell blind. Sie erkennt nur direkte Zugriffe der Form this.prisma.<Modell> bzw. <gebundener Client>.<Modell>. Ein Zugriff, der ueber include:/_count:/select: in eine ZWEITE Tabelle hineinreicht, ist fuer sie unsichtbar — obwohl Prisma daraus eine Unterabfrage auf diese zweite Tabelle macht, die unter DEREN Regel laeuft. Nachgewiesen in 260911-e2s: drei Zugriffe in tenant.controller.ts zaehlten ueber include: { _count: { select: { users } } } in die geschuetzte Tabelle User hinein (Prisma 6.19 rendert das als LEFT JOIN (SELECT tenantId, COUNT(*) FROM User ...)); nach dem Scharfschalten haette die Mandantenliste des Plattform-Administrators fuer jeden Mandanten 0 Benutzer gezeigt und der Loeschriegel T-02-09 waere vakuum geworden. Diese drei Stellen sind behoben (Fan-out je Mandant ueber gebundenen Client). Zur Planungszeit wurden alle 19 include:-Stellen und alle _count-Stellen in apps/api/src einzeln beurteilt, vom Orchestrator und vom Verifizierer unabhaengig gegengeprueft: nur diese drei waren gefaehrlich (tenders zaehlt auf dem plattformglobalen Katalog ohne Zeilenschutz, groups zaehlt ueber einen bereits gebundenen Client in eine Tabelle desselben Mandanten). OFFEN bleibt der MECHANISMUS: jede kuenftige include:/_count:-Stelle in eine fremd geschuetzte Tabelle bleibt fuer die Pruefung unsichtbar. Zu schliessen, indem der Detektor include:/select:/_count:-Bloecke auf Modellnamen durchsucht und die Zieltabelle als eigene Fundstelle fuehrt — oder durch eine Pruefung, die jede include:-Stelle einer expliziten Freigabeliste unterwirft. Gehoert vor das Scharfschalten (Etappe 4), weil die Vorabpruefung sich sonst auf eine Bestandsaufnahme stuetzt, die diese Form nicht sieht. | open | | 2026-09-11T09:08:00.435Z | |
|
||||
| 28 | quick-260911-fh9 | deviation | apps/web/src/components/layout/header.tsx | | Bereich auth: getMe liefert nach dem Scharfschalten null, der Controller antwortet 200 mit leerem Rumpf, fetchCurrentUser (auth-actions.ts) macht daraus null, header.tsx und account-settings-form.tsx tun bei null nichts — die Portalhuelle rendert ohne angemeldeten Benutzer; changePassword liest sich als networkError (nicht als falsches Kennwort); adminResetPassword als 'User not found' ohne UI-Aufrufer. 'nicht angemeldet' und 'Zeile unsichtbar' sind fuer das Frontend derselbe Wert — an dieselbe Bedingung gebunden wie WINDOWS #18; Familie #23/#25/#26; Etappe-4-Vorabpruefung: bekannten Benutzer ueber die Wartungsrolle lesen und den gebundenen findUnique unter seinem Claim-Mandanten daneben halten. Das Frontend wird von 260911-fh9 NICHT geaendert. | open | | 2026-09-11T10:00:29.558Z | |
|
||||
| 29 | quick-260911-fh9 | unmet-truth | apps/api/src/user/user.controller.ts | | Bereich auth: adminResetPassword schliesst die Rechteausweitung ADMIN -> SUPER_ADMIN im eigenen Handler (T-FH9-04), der Schwesterweg PATCH /users/:id tut das nicht. UserController.update (T-02-08) prueft nur, ob die Rolle SUPER_ADMIN NEU ZUGEWIESEN wird (dto.role === Role.SUPER_ADMIN), nicht ob das ZIEL diese Rolle bereits HAT — password/isActive gehen fuer ein bestehendes SUPER_ADMIN-Ziel ungeprueft durch; remove prueft ueberhaupt keine Rolle des Ziels, nur Selbstloeschung und Mandantengrenze. Kein Mandantenproblem, sondern Rechteausweitung INNERHALB des Mandanten. Reparatur in einem Satz: die Rolle des ZIELS pruefen (ist user.role === Role.SUPER_ADMIN und der Aufrufer nicht SUPER_ADMIN, ForbiddenException) — die Vorlage steht seit 260911-fh9 in AuthService.adminResetPassword. Ausserhalb der Erlaubnisliste dieses Plans, deshalb Ledger statt Reparatur; vor dem ersten Mandanten mit einem zweiten Administrator zu schliessen. | open | | 2026-09-11T10:00:38.418Z | |
|
||||
| 30 | quick-260911-gwh | deviation | apps/api/src/mail/mail.module.ts | | Startpfad des Mailmoduls (SettingsService.loadAnySmtpConfigForStartupTransport(), vormals getStartupSmtpConfig()) als SECHSTER Fall der Hintergrunddienst-Falle bleibt bewusst UNGEBUNDEN. HEUTE bereits falsch: findFirst() ohne Bedingung zieht bei mehreren Mandanten den SMTP-Server und Absender EINES beliebigen Mandanten fuer ALLE Kennwort-Zuruecksetzungs- und Willkommensmails ALLER Mandanten (T-GWH-03, Nutzung fremder Zugangsdaten). NACH DEM SCHARFSCHALTEN (#18) liefert dieselbe Abfrage null, mail.module.ts faellt auf MAIL_*/TESSERA_SMTP_*/localhost:1025 zurueck, MailService faengt den Transportfehler (T-02-12) -- KEINE Protokollzeile, das Verstummen ist doppelt verdeckt (Unsymmetrie zu ldap.getAllActiveConfigs [heute korrekt] UND zu dkv WINDOWS #21 [verstummt mit Protokollzeile]). Drei erwogene Formen: an einen aufgeloesten Mandanten binden (unmoeglich, kein Kontext beim Start); Mehrmandanten-Versand (abgelehnt als Funktion -- Vorlage steht in DkvMailService/TenderMailService, Transport je Versand aus getDecryptedSmtpConfig(tenantId)); als benannte Altlast weiterfuehren mit Markierung (GEWAEHLT). Eigener Eintrag statt Anschluss an #21: andere Datei, andere Reparatur, andere Verdeckungsform. Signal fuer rls-preflight.mjs gehoert in Etappe 4. Siehe docs/mandantentrennung-etappe2-fehlerrichtung.md, Abschnitt 'Bereich settings' (s4)(a). | open | | 2026-09-11T11:57:36.656Z | |
|
||||
| 31 | quick-260911-gwh | deviation | apps/web/src/components/dashboard/widgets/favorites-widget.tsx | | Bereich favorites: ein nach dem Scharfschalten (#18) zu klein gebliebenes Leseergebnis auf list() sieht aus wie 'Noch keine Favoriten.' (favorites-widget.tsx Zeile um 212, de.json favorites.empty) -- fetchFavorites (favorites-api.ts) reicht die leere Liste durch. 'Nie einen gespeichert' und 'Zeile unsichtbar' sind fuer das Frontend derselbe Wert []. Etappe-4-Vorabpruefung (f4)(d): fuer einen bekannten Nutzer/Widget die Favoritenzahl ueber die Wartungsrolle und ueber den gebundenen findMany daneben halten. Familie #23/#25/#26/#28. Das Frontend wird von 260911-gwh NICHT geaendert. Siehe docs/mandantentrennung-etappe2-fehlerrichtung.md, Abschnitt 'Bereich favorites' (f3)/(f4). | open | | 2026-09-11T11:57:50.276Z | |
|
||||
| 32 | quick-260911-gwh | deviation | apps/web/src/components/settings/smtp-settings-form.tsx | | Bereich settings: getSmtpConfig liefert nach dem Scharfschalten (#18) null, der Controller antwortet 200 mit leerem Rumpf, fetchSmtp (settings-api.ts) laeuft mit res.json() auf den leeren Rumpf und wirft, smtp-settings-form.tsx verschluckt das in .catch(() => {}) -- leeres Formular 'nicht eingerichtet', waehrend die Zugangsdaten physisch da sind. Ein erneutes Speichern unter der ungebundenen Form scheitert am Eindeutigkeitsindex SmtpConfig_tenantId_key (PrismaClientUnknownRequestError, gemessen in Aufgabe 1 Pruefung 8) -- nach diesem Lauf ist saveSmtpConfig gebunden und trifft die eigene Zeile, dieser Rest bestand nur unter der ungebundenen Form vor dieser Aenderung. Dieselbe 200-leerer-Rumpf-Kette wie #28. Etappe-4-Vorabpruefung (s4)(e). Das Frontend wird von 260911-gwh NICHT geaendert. Siehe docs/mandantentrennung-etappe2-fehlerrichtung.md, Abschnitt 'Bereich settings' (s3)/(s4). | open | | 2026-09-11T11:57:50.484Z | |
|
||||
|
||||
````json
|
||||
[
|
||||
@@ -394,6 +397,42 @@ last_updated: 2026-09-11T10:00:38.418Z
|
||||
"reason": "",
|
||||
"recorded_at": "2026-09-11T10:00:38.418Z",
|
||||
"resolved_at": null
|
||||
},
|
||||
{
|
||||
"id": 30,
|
||||
"kind": "deviation",
|
||||
"phase": "quick-260911-gwh",
|
||||
"file": "apps/api/src/mail/mail.module.ts",
|
||||
"line": null,
|
||||
"description": "Startpfad des Mailmoduls (SettingsService.loadAnySmtpConfigForStartupTransport(), vormals getStartupSmtpConfig()) als SECHSTER Fall der Hintergrunddienst-Falle bleibt bewusst UNGEBUNDEN. HEUTE bereits falsch: findFirst() ohne Bedingung zieht bei mehreren Mandanten den SMTP-Server und Absender EINES beliebigen Mandanten fuer ALLE Kennwort-Zuruecksetzungs- und Willkommensmails ALLER Mandanten (T-GWH-03, Nutzung fremder Zugangsdaten). NACH DEM SCHARFSCHALTEN (#18) liefert dieselbe Abfrage null, mail.module.ts faellt auf MAIL_*/TESSERA_SMTP_*/localhost:1025 zurueck, MailService faengt den Transportfehler (T-02-12) -- KEINE Protokollzeile, das Verstummen ist doppelt verdeckt (Unsymmetrie zu ldap.getAllActiveConfigs [heute korrekt] UND zu dkv WINDOWS #21 [verstummt mit Protokollzeile]). Drei erwogene Formen: an einen aufgeloesten Mandanten binden (unmoeglich, kein Kontext beim Start); Mehrmandanten-Versand (abgelehnt als Funktion -- Vorlage steht in DkvMailService/TenderMailService, Transport je Versand aus getDecryptedSmtpConfig(tenantId)); als benannte Altlast weiterfuehren mit Markierung (GEWAEHLT). Eigener Eintrag statt Anschluss an #21: andere Datei, andere Reparatur, andere Verdeckungsform. Signal fuer rls-preflight.mjs gehoert in Etappe 4. Siehe docs/mandantentrennung-etappe2-fehlerrichtung.md, Abschnitt 'Bereich settings' (s4)(a).",
|
||||
"status": "open",
|
||||
"reason": "",
|
||||
"recorded_at": "2026-09-11T11:57:36.656Z",
|
||||
"resolved_at": null
|
||||
},
|
||||
{
|
||||
"id": 31,
|
||||
"kind": "deviation",
|
||||
"phase": "quick-260911-gwh",
|
||||
"file": "apps/web/src/components/dashboard/widgets/favorites-widget.tsx",
|
||||
"line": null,
|
||||
"description": "Bereich favorites: ein nach dem Scharfschalten (#18) zu klein gebliebenes Leseergebnis auf list() sieht aus wie 'Noch keine Favoriten.' (favorites-widget.tsx Zeile um 212, de.json favorites.empty) -- fetchFavorites (favorites-api.ts) reicht die leere Liste durch. 'Nie einen gespeichert' und 'Zeile unsichtbar' sind fuer das Frontend derselbe Wert []. Etappe-4-Vorabpruefung (f4)(d): fuer einen bekannten Nutzer/Widget die Favoritenzahl ueber die Wartungsrolle und ueber den gebundenen findMany daneben halten. Familie #23/#25/#26/#28. Das Frontend wird von 260911-gwh NICHT geaendert. Siehe docs/mandantentrennung-etappe2-fehlerrichtung.md, Abschnitt 'Bereich favorites' (f3)/(f4).",
|
||||
"status": "open",
|
||||
"reason": "",
|
||||
"recorded_at": "2026-09-11T11:57:50.276Z",
|
||||
"resolved_at": null
|
||||
},
|
||||
{
|
||||
"id": 32,
|
||||
"kind": "deviation",
|
||||
"phase": "quick-260911-gwh",
|
||||
"file": "apps/web/src/components/settings/smtp-settings-form.tsx",
|
||||
"line": null,
|
||||
"description": "Bereich settings: getSmtpConfig liefert nach dem Scharfschalten (#18) null, der Controller antwortet 200 mit leerem Rumpf, fetchSmtp (settings-api.ts) laeuft mit res.json() auf den leeren Rumpf und wirft, smtp-settings-form.tsx verschluckt das in .catch(() => {}) -- leeres Formular 'nicht eingerichtet', waehrend die Zugangsdaten physisch da sind. Ein erneutes Speichern unter der ungebundenen Form scheitert am Eindeutigkeitsindex SmtpConfig_tenantId_key (PrismaClientUnknownRequestError, gemessen in Aufgabe 1 Pruefung 8) -- nach diesem Lauf ist saveSmtpConfig gebunden und trifft die eigene Zeile, dieser Rest bestand nur unter der ungebundenen Form vor dieser Aenderung. Dieselbe 200-leerer-Rumpf-Kette wie #28. Etappe-4-Vorabpruefung (s4)(e). Das Frontend wird von 260911-gwh NICHT geaendert. Siehe docs/mandantentrennung-etappe2-fehlerrichtung.md, Abschnitt 'Bereich settings' (s3)/(s4).",
|
||||
"status": "open",
|
||||
"reason": "",
|
||||
"recorded_at": "2026-09-11T11:57:50.484Z",
|
||||
"resolved_at": null
|
||||
}
|
||||
]
|
||||
````
|
||||
|
||||
+1226
File diff suppressed because one or more lines are too long
+250
@@ -0,0 +1,250 @@
|
||||
---
|
||||
phase: quick-260911-gwh
|
||||
plan: 01
|
||||
subsystem: database
|
||||
tags: [prisma, postgresql, rls, multi-tenancy, nestjs]
|
||||
|
||||
requires:
|
||||
- phase: quick-260911-fh9
|
||||
provides: "Etappe 2 Bereich auth abgeschlossen (baseline 951 tests/60 files, tool 120/120)"
|
||||
provides:
|
||||
- "favorites.service.ts: alle fuenf Methoden gebunden (forTenant), Widget-Besitzriegel in create() gegen den Fremdschluessel-Durchgriff"
|
||||
- "settings.service.ts: getSmtpConfig/saveSmtpConfig/getDecryptedSmtpConfig gebunden, Startpfad umbenannt und als sechster Hintergrunddienst-Fall markiert"
|
||||
- "Befund K (tenders/dkv haengen an getDecryptedSmtpConfig) erfuellt an allen drei Stellen"
|
||||
- "Etappe 2 der Mandantentrennung vollstaendig: 65 (Datei,Modell)-Paare klassifiziert, 68 ungebunden/178 gebunden, jeder ungebundene Rest benannt"
|
||||
affects: [etappe-3-mandantentrennung, etappe-4-rls-preflight]
|
||||
|
||||
actuals:
|
||||
tokens: 40708
|
||||
tasks: 3
|
||||
commits: 4
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Fremdschluessel-Durchgriff-Riegel: ein gebundener widgetInstance.findUnique VOR dem eigentlichen Schreibzugriff, damit ein FK auf eine zweite mandantengebundene Tabelle nicht am Zeilenschutz vorbei ein Existenzorakel oeffnet (T-GWH-05)"
|
||||
- "Hintergrunddienst-Startpfad-Markierung: umbenannte, eigenstaendige Methode (kein optionaler Parameter) mit Kopfkommentar, der beide Zustaende (heute falsch, nach dem Scharfschalten stumm) nennt — Vorlage DkvService.loadAnyActiveConfigForScheduler(), hier fortgeschrieben fuer SettingsService.loadAnySmtpConfigForStartupTransport()"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- apps/api/src/favorites/favorites.service.spec.ts
|
||||
- apps/api/src/settings/settings.service.spec.ts
|
||||
modified:
|
||||
- apps/api/scripts/rls-scratch-check.mjs
|
||||
- apps/api/src/favorites/favorites.service.ts
|
||||
- apps/api/src/favorites/favorites.controller.ts
|
||||
- apps/api/src/settings/settings.service.ts
|
||||
- apps/api/src/mail/mail.module.ts
|
||||
- docs/mandantentrennung-etappe2-fehlerrichtung.md
|
||||
- docs/mandantentrennung-zugriffsklassifikation.md
|
||||
- docs/anleitung-entwicklung.md
|
||||
- .planning/WINDOWS.md
|
||||
|
||||
key-decisions:
|
||||
- "Startpfad-Ledger-Eintrag (#30) EIGENSTAENDIG, NICHT an WINDOWS #21 angeschlossen: andere Datei (mail.module.ts statt dkv-scheduler.service.ts), andere Reparatur (Transport je Versand statt Mehrmandanten-Planung), andere Verdeckungsform (Rueckfallkette statt blosser Leere)"
|
||||
- "Widget-Besitzriegel in favorites.service.ts create() gebaut, weil Pruefung 7 (Aufgabe 1) das Gelingen eines gebundenen create() mit fremdmandantiger widgetId tatsaechlich gemessen hat — der Fremdschluessel prueft am Zeilenschutz von WidgetInstance vorbei (dokumentiertes PostgreSQL-Verhalten)"
|
||||
- "settings.controller.ts bleibt unveraendert: req.tenantId ist fuer eine ADMIN-Konfigurationsseite die richtige Quelle (D-10), nicht das Claim wie bei auth"
|
||||
- "favorites.controller.ts extractContext bleibt wortgleich mit dashboard.controller.ts (Guard-Kennung), nicht das Claim wie bei auth — FavoriteLink haengt ueber widgetId an WidgetInstance, das unter der dashboard-Quelle gebunden ist"
|
||||
|
||||
patterns-established:
|
||||
- "Zwei-Klienten-Testnachbau mit GRENZE als Bauform (settings.service.spec.ts): der ungebundene Nachbau bietet fuer ein Modell NUR die Methoden, die der bewusst ungebundene Pfad tatsaechlich braucht (hier: nur findFirst), der gebundene Klient NUR die Methoden der Anfragewege (findUnique/upsert) — ein gebundener Startpfad scheitert dadurch ebenso hart wie ein ungebundener Anfrageweg"
|
||||
|
||||
requirements-completed: [WINDOWS-18, ETAPPE-2-FAVORITES, ETAPPE-2-SETTINGS]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "favorites.service.ts vollstaendig auf forTenant() umgestellt (5 Methoden, 7 gebundene Zugriffe, 5 Aufrufstellen), Widget-Besitzriegel in create()"
|
||||
requirement: ETAPPE-2-FAVORITES
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "apps/api/src/favorites/favorites.service.spec.ts (23 Faelle)"
|
||||
status: pass
|
||||
- kind: other
|
||||
ref: "apps/api/scripts/rls-scratch-check.mjs runFavoritesAreaChecks (8 Pruefungen gegen den generierten Client)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "settings.service.ts: getSmtpConfig/saveSmtpConfig/getDecryptedSmtpConfig gebunden, Startpfad umbenannt (loadAnySmtpConfigForStartupTransport), Befund K erfuellt"
|
||||
requirement: ETAPPE-2-SETTINGS
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "apps/api/src/settings/settings.service.spec.ts (20 Faelle)"
|
||||
status: pass
|
||||
- kind: other
|
||||
ref: "apps/api/scripts/rls-scratch-check.mjs runSettingsAreaChecks (9 Pruefungen gegen den generierten Client)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "Etappe 2 der Mandantentrennung vollstaendig dokumentiert: Klassifikation, Kritikschrift, Anleitung, Ledger auf Endstand"
|
||||
requirement: WINDOWS-18
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "apps/api/src/prisma/rls-access-inventory.spec.ts (11 Faelle, Stand-Vergleich Dokument vs. Quelltext)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
|
||||
duration: 55min
|
||||
completed: 2026-09-11
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Quick 260911-gwh: Etappe 2 der Mandantentrennung, Bereiche favorites und settings — LETZTER Lauf Summary
|
||||
|
||||
**favorites.service.ts und settings.service.ts vollstaendig an forTenant() gebunden (12 gebundene Zugriffe, 8 Aufrufstellen), der Fremdschluessel-Durchgriff auf WidgetInstance gemessen und mit einem Besitzriegel geschlossen, der Mailmodul-Startpfad als sechster Hintergrunddienst-Fall markiert und ungebunden gelassen — Etappe 2 der Mandantentrennung ist damit vollstaendig: 65 (Datei,Modell)-Paare, 68 ungebunden/178 gebunden, jeder verbleibende ungebundene Rest ist ein benannter, bewusster Fall.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** ca. 55 min
|
||||
- **Tasks:** 3/3
|
||||
- **Files modified:** 11 (2 neu, 9 geaendert)
|
||||
- **Commits:** 4 (plus die vorangehende PLAN.md-Ablage)
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- `apps/api/scripts/rls-scratch-check.mjs` von 120 auf **137 bestandene Pruefungen** erweitert (`runFavoritesAreaChecks`: 8, `runSettingsAreaChecks`: 9), beide an der Regel WORTGLEICH aus `20260909140000_rls_remaining_tenant_tables` geschnitten, mit dem Fremdschluessel bzw. Eindeutigkeitsindex als mitgebauten Voraussetzungen.
|
||||
- `favorites.service.ts`: `list`, `create`, `update`, `remove`, `getIconBytes` laufen je ueber GENAU EINEN Klienten `tenantPrisma` (7 gebundene `favoriteLink`-Zugriffe, 1 gebundener `widgetInstance`-Besitzriegel in `create`, 5 Aufrufstellen des Bindungshilfsmittels).
|
||||
- `settings.service.ts`: `getSmtpConfig`, `saveSmtpConfig`, `getDecryptedSmtpConfig` gebunden (3 Zugriffe, 3 Aufrufstellen); Startpfad umbenannt in `loadAnySmtpConfigForStartupTransport()`, bleibt bewusst ungebunden, sechster Fall der Hintergrunddienst-Falle, WINDOWS #30.
|
||||
- Befund K (Reihenfolgebedingung aus `tenders` (t4) und `dkv` (d4)) ist ERFUELLT und an allen drei Stellen als solches vermerkt: (t4)-Nachtrag, (d4)-Nachtrag, Hintergrunddienst-Abschnitt der Klassifikation.
|
||||
- Zwei neue Testdateien mit dem Zwei-Klienten-Nachbau (23 + 20 = 43 neue Faelle), sechs Falsifizierungsnachweise durchgefuehrt und zurueckgenommen.
|
||||
- Alle fuenf handgepflegten Dokumentstellen auf den Endstand der Etappe 2 gebracht, DERIVIERT gegatet (`rls-access-inventory.spec.ts`).
|
||||
- Drei neue offene Ledger-Eintraege (#30, #31, #32).
|
||||
|
||||
## Task Commits
|
||||
|
||||
1. **Aufgabe 1: Fehlerrichtung fuer favorites/settings messen** — `88896d3` (docs) — 137 Pruefungen, `## Bereich favorites` (f1-f5), `## Bereich settings` (s1-s5), `## Etappe 2 — Abschluss`, Nachtraege unter Befund K in (t4)/(d4)
|
||||
2. **Aufgabe 2, RED: neue Testdateien** — `8f2c13a` (test) — favorites.service.spec.ts (23 Faelle), settings.service.spec.ts (20 Faelle), beide gegen die heutige Implementierung erwartungsgemaess rot
|
||||
3. **Aufgabe 2, GREEN: binden, Startpfad umbenennen, Besitzriegel** — `b5f22e2` (feat) — alle Ziel-Signaturen, vier Falsifizierungsnachweise
|
||||
4. **Aufgabe 3: Etappe 2 auf Endstand bringen** — `1240932` (docs) — Ledger #30/#31/#32, Klassifikation, Anleitung, Nachtraege, zwei Dokument-Falsifizierungen
|
||||
|
||||
**Plan metadata:** `2a27d96` (docs: Plan fuer Etappe 2, Bereiche favorites und settings) — bereits vor dieser Ausfuehrung committet (Plan-Checker-Lauf).
|
||||
|
||||
_Kein REFACTOR-Commit — die GREEN-Implementierung brauchte keine Nacharbeit._
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `apps/api/src/favorites/favorites.service.spec.ts` (NEU) — Zwei-Klienten-Nachbau, 23 Faelle
|
||||
- `apps/api/src/settings/settings.service.spec.ts` (NEU) — Zwei-Klienten-Nachbau mit Grenze als Bauform (ungebunden nur `findFirst`, gebunden nur `findUnique`/`upsert`), 20 Faelle
|
||||
- `apps/api/scripts/rls-scratch-check.mjs` — `runFavoritesAreaChecks`, `runSettingsAreaChecks`
|
||||
- `apps/api/src/favorites/favorites.service.ts` — Bindung, Besitzriegel
|
||||
- `apps/api/src/favorites/favorites.controller.ts` — reicht `tenantId` durch
|
||||
- `apps/api/src/settings/settings.service.ts` — Bindung, Startpfad-Umbenennung
|
||||
- `apps/api/src/mail/mail.module.ts` — ruft den umbenannten Startpfad
|
||||
- `docs/mandantentrennung-etappe2-fehlerrichtung.md` — zwei neue Bereichsabschnitte, Abschluss-Abschnitt, zwei Nachtraege
|
||||
- `docs/mandantentrennung-zugriffsklassifikation.md` — Uebersicht, Bestandsaufnahme, Klassen-Verteilung, Hintergrunddienst-Abschnitt
|
||||
- `docs/anleitung-entwicklung.md` — RLS-Tabellenzahl und Beispielabsatz auf den gemessenen Stand
|
||||
- `.planning/WINDOWS.md` — drei neue offene Eintraege (#30, #31, #32)
|
||||
|
||||
## Tatsächlich gezählte Prüfungs- und Testzahlen
|
||||
|
||||
**Werkzeug (`rls-scratch-check.mjs`):** 120 → **137** bestandene Prüfungen (8 `runFavoritesAreaChecks` + 9 `runSettingsAreaChecks`).
|
||||
|
||||
**Testsuite:** Baseline 951 Tests / 60 Dateien (260911-fh9) → RED (Aufgabe 2, Commit `8f2c13a`): 994 Tests entdeckt / 62 Dateien, 40 rot (22 favorites + 18 settings), 954 grün — beide RED-Zustände intentional, jeweils auf der geplanten Zielsignatur gescheitert, nicht an Syntax/Zero-Discovery → GREEN (Aufgabe 2, Commit `b5f22e2`): 43/43 neue Fälle grün, aber `rls-access-inventory.spec.ts` (Teil der ursprünglichen 951) mit 2 von 11 Fällen erwartungsgemäß rot, 992/994 gesamt grün → Aufgabe 3 (Commit `1240932`): **994/994 grün in 62 Dateien**.
|
||||
|
||||
**Zwischenzeitlich rot: `rls-access-inventory.spec.ts`, zwischen Aufgabe 2 und Aufgabe 3.** Genau wie das Aufgabe-3-Actionblock des Plans selbst vorhersagt ("Ohne Schritt 3 ist `rls-access-inventory.spec.ts` am Ende dieser Aufgabe rot") und wie der unmittelbare Vorgänger 260911-fh9 es bereits dokumentiert hat: sobald `favorites.service.ts`/`settings.service.ts` ihre `Stand`-Spalte änderten (ungebunden → gebunden/gemischt) und `favorites.service.ts`/`widgetInstance` als neue Fundstelle entstand, maß die Prüfung diese drei Fakten sofort — während das Klassifikationsdokument sie erst in Aufgabe 3 nachzieht. Zwei der elf Fälle scheiterten entsprechend (`jede ... Fundstelle ist im Dokument eingetragen` wegen der neuen `widgetInstance`-Zeile, `der eingetragene Stand stimmt ... überein` wegen der beiden Stand-Wechsel). Nicht als Blocker gewertet: (a) exakt die im Plan selbst vorausgesagte Form, (b) berührte keine Datei außerhalb der sechs für Aufgabe 2 erlaubten, (c) Aufgabe 3 folgte im selben Lauf und stellte die Baseline innerhalb von Minuten wieder her (994/994). "Baseline gehalten nach jeder Aufgabe" ist deshalb — wie schon bei 260911-fh9 — als "nach dem vollständigen Plan, mit einem im Plan selbst vorausgesagten Zwischenzustand" zu lesen, nicht als literarische Bedingung jedes einzelnen Aufgaben-`<verify>`-Blocks für sich.
|
||||
|
||||
## Ergebnis von Prüfung 7 (Fremdschlüssel) — wörtlich
|
||||
|
||||
`favoritelink-fremdschluessel-prueft-am-zeilenschutz-vorbei`: ein gebundenes `create` unter TENANT-A mit `widgetId='widget-b1'` (gehört TENANT-B, unter TENANT-A per gebundenem `widgetInstance.findUnique` unsichtbar: `null`) **GELINGT** (`id=fav-a1-fremdes-widget`) — der Fremdschlüssel prüft am Zeilenschutz VORBEI, dokumentiertes PostgreSQL-Verhalten. Dasselbe `create` mit `widgetId="widget-gibt-es-nicht"` scheitert mit **`PrismaClientKnownRequestError` (code `P2003`)**: `Foreign key constraint violated on the constraint: FavoriteLink_widgetId_fkey`. Ergebnis: der Besitzriegel in Aufgabe 2 war NÖTIG (nicht optional) — ohne ihn wäre der Unterschied zwischen beiden Antworten ein Existenzorakel über Mandantengrenzen gewesen (T-GWH-05).
|
||||
|
||||
## Ergebnis von Prüfung 8 (Konfliktform) — wörtlich
|
||||
|
||||
`smtpconfig-ungebundenes-upsert-auf-unsichtbare-zeile-scheitert-laut`: ungebundenes `prisma.smtpConfig.upsert({ where: { tenantId: 'TENANT-A' }, ... })` (die Form von `saveSmtpConfig`) wirft **`PrismaClientUnknownRequestError`**: `ConnectorError(... PostgresError { code: "42501", message: "new row violates row-level security policy for table \"SmtpConfig\"" ... })` — dieselbe Fehlerklasse wie die 260910-krx-Messung für `DashboardLayout` (NICHT `PrismaClientKnownRequestError`/`P2002`, die Form von `tenders`/`user`). Die Regel weist den Schreibzugriff ab, bevor der Eindeutigkeitsindex überhaupt geprüft wird.
|
||||
|
||||
## Alle sechs Falsifizierungsnachweise — Testname und Meldung wörtlich
|
||||
|
||||
**(a) Aufgabe 2 — `list` probeweise auf den ungebundenen Basisclient zurückgebaut** (`const tenantPrisma = this.prisma as any;`): 3 Fälle rot.
|
||||
- `list > liefert nur die Zeilen von user-a1 für widget-a1, sortiert nach position, dann title` — `TypeError: Cannot read properties of undefined (reading 'findMany')`
|
||||
- `list > liefert unter einem FREMDEN Mandanten eine leere Liste, kein Fehler ...` — dieselbe `TypeError`
|
||||
- `Wachhund je Methode > genau EIN gebundener Klient je Aufruf von list/update/remove/getIconBytes` — `AssertionError: Aufruf erzeugte 0 gebundene Klienten, erwartet genau 1: expected +0 to be 1`
|
||||
|
||||
**(b) Aufgabe 2 — Widget-Besitzriegel in `create` probeweise entfernt.** Der Plan sagte "genau die drei Widget not found-Fälle" voraus — GEMESSEN sind es **4**, weil der Wachhund-Fall zusätzlich rot wird (Abweichung, siehe unten):
|
||||
- `create > T-GWH-05: widgetId gehört einem ANDEREN Benutzer desselben Mandanten -> NotFoundException "Widget not found", KEIN create, KEINE Icon-Suche` — `AssertionError: promise resolved "{ …(10) }" instead of rejecting`
|
||||
- `create > T-GWH-05: widgetId gehört einem Widget unter FREMDEM Mandanten -> dieselbe NotFoundException, nennt weder Halter noch Mandant` — dieselbe `AssertionError`-Form
|
||||
- `create > T-GWH-05: unbekannte widgetId -> dieselbe NotFoundException` — dieselbe Form
|
||||
- `create > Wachhund: genau EIN gebundener Klient je create-Aufruf, Widget-Prüfung UND Schreibzugriff auf DEMSELBEN Klienten` — `AssertionError: expected [ { tenantId: 't1', …(2) } ] to deeply equal [ { tenantId: 't1', …(2) }, …(1) ]`
|
||||
|
||||
**(c) Aufgabe 2 — `getDecryptedSmtpConfig` probeweise auf den ungebundenen Basisclient verschoben:** 9 Fälle rot, alle mit `TypeError: tenantPrisma.smtpConfig.findUnique is not a function` — betrifft die drei `getDecryptedSmtpConfig`-Fälle, alle vier `testSmtpConfig`-Fälle (ruft intern `getDecryptedSmtpConfig` auf) und beide betroffenen Wachhund-Fälle.
|
||||
|
||||
**(d) Aufgabe 2 — Startpfad probeweise gebunden** (`forTenant(this.prisma, 'falsification-probe').smtpConfig.findFirst()`): 4 Fälle rot, alle mit `TypeError: (0 , forTenant)(...).smtpConfig.findFirst is not a function` — beide Erfolgsfälle, der Leer-Nachbau-Fall und der Null-Klienten-Nachweis.
|
||||
|
||||
**(e) Aufgabe 3 — Bestandsaufnahme-Zeile `favorites.service.ts | favoriteLink` probeweise auf `ungebunden` zurückgesetzt:** `rls-access-inventory.spec.ts > ... > der eingetragene Stand stimmt mit dem im Quelltext gemessenen überein` — `AssertionError: Abweichender Stand (Dokument vs. Quelltext): apps/api/src/favorites/favorites.service.ts::favoriteLink — dokumentiert=ungebunden, gemessen=gebunden`.
|
||||
|
||||
**(f) Aufgabe 3 — Übersichtszeile `settings` probeweise auf `9 | 9` gesetzt:** das herleitende Gate (`grep -qE "^\| settings \| ${SU} \| ${SB} \| ..."` mit den tatsächlich gemessenen `SU=1`/`SB=3`) schlägt fehl — die Zeile `9 | 9` matcht die Anweisung nicht mehr.
|
||||
|
||||
Alle sechs Änderungen wurden unmittelbar nach der Messung zurückgenommen; `diff` gegen den vor der Probe gesicherten Stand bestätigt Identität in jedem Fall.
|
||||
|
||||
## Decisions Made
|
||||
|
||||
- Startpfad-Ledger-Eintrag (#30) **eigenständig**, nicht an WINDOWS #21 angeschlossen: andere Datei, andere Reparatur, andere Verdeckungsform — siehe `key-decisions` oben.
|
||||
- Widget-Besitzriegel in `create()` gebaut, weil Prüfung 7 (Aufgabe 1) das Gelingen des Fremdschlüssel-Durchgriffs tatsächlich gemessen hat (nicht angenommen).
|
||||
- `settings.controller.ts` und `favorites.controller.ts`s `extractContext` bleiben unverändert — beide Mandantenquellen sind bereits die richtigen (siehe `key-decisions`).
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed / gemessene Abweichungen (keine Rule-1/2/3-Bugfixes — alles Messungen, die anders ausfielen als die Planungsvermutung)
|
||||
|
||||
**1. Falsifizierungsnachweis (b): 4 statt 3 rote Fälle.**
|
||||
- **Gefunden während:** Aufgabe 2, TEIL 5.
|
||||
- **Planungsvermutung:** "genau die drei `Widget not found`-Fälle werden rot".
|
||||
- **Tatsächliche Messung:** zusätzlich der Wachhund-Fall (`create > Wachhund: ...`), weil er das Bindungsprotokoll auf zwei Einträge (`widgetInstance.findUnique`, `favoriteLink.create`) prüft — ohne den Riegel gibt es nur den zweiten Eintrag.
|
||||
- **Auswirkung:** keine — die Falsifizierung bestätigt weiterhin, dass der Riegel notwendig ist; die Zahl ist hier korrigiert, nicht die Planungsaussage stillschweigend übernommen.
|
||||
|
||||
**2. (d4)-Nachtrag: `dkv.seed.ts`/`module-registry` ist NICHT "gebunden seit 260910-exd".**
|
||||
- **Gefunden während:** Aufgabe 3, TEIL 3, Nachtrag unter (d4).
|
||||
- **Planungstext:** "`dkv.seed.ts`/`module-registry` ist seit 260910-exd gebunden — prüfen und, falls zutreffend, in demselben Nachtrag mit einem Satz nennen."
|
||||
- **Tatsächliche Messung:** `dkv.seed.ts` ruft `ModuleRegistryService.seedModule()` (`module-registry.service.ts:206`, `this.prisma.module.upsert`) — UNGEBUNDEN, bewusst und unverändert, weil `Module` der plattformweite Modulkatalog ohne `tenantId`-Spalte ist (Befund E). Der Nachtrag in (d4) sagt das ausdrücklich, statt die Planungsvermutung zu übernehmen.
|
||||
|
||||
**3. Zwischenzeitlich rotes `rls-access-inventory.spec.ts` zwischen Aufgabe 2 und Aufgabe 3** — siehe eigener Abschnitt oben ("Tatsächlich gezählte Prüfungs- und Testzahlen"). Vom Plan selbst vorausgesagt, kein Bug.
|
||||
|
||||
---
|
||||
|
||||
**Total deviations:** 3, alle Messergebnisse (keine Bugfixes, keine Scope-Erweiterung). Kein Rule-1/2/3-Autofix in diesem Lauf nötig.
|
||||
**Impact on plan:** keiner — der Plan bleibt in Kraft, alle drei Punkte sind Präzisierungen der eigenen Planungsvermutungen anhand der tatsächlichen Messung, wie es der Plan selbst an mehreren Stellen verlangt ("weicht eine Messung ab, gilt die Messung").
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
Keine. Alle Prüfungen liefen beim ersten Durchlauf durch (`rls-scratch-check.mjs`: 137/137 ohne Nacharbeit).
|
||||
|
||||
## Ledger-Einträge und Entscheidung zu #21
|
||||
|
||||
Drei neue offene Einträge in `.planning/WINDOWS.md`:
|
||||
|
||||
- **#30** (`apps/api/src/mail/mail.module.ts`, deviation) — Startpfad des Mailmoduls, sechster Fall der Hintergrunddienst-Falle. **Entscheidung: EIGENER Eintrag, NICHT an #21 angeschlossen** — Grund: andere Datei (`mail.module.ts`/`settings.service.ts` statt `dkv-scheduler.service.ts`), andere Reparatur (Transport je Versand aus `getDecryptedSmtpConfig(tenantId)` statt Mehrmandanten-Planung), andere Verdeckungsform (Rückfallkette auf einen falschen, aber vorhandenen Transport statt bloßer Leere mit Protokollzeile).
|
||||
- **#31** (`apps/web/src/components/dashboard/widgets/favorites-widget.tsx`, deviation) — verschluckte Leere `favorites`, Familie #23/#25/#26/#28.
|
||||
- **#32** (`apps/web/src/components/settings/smtp-settings-form.tsx`, deviation) — verschluckte Leere `settings`, dieselbe 200-leerer-Rumpf-Kette wie #28.
|
||||
|
||||
Kopfzähler geprüft: `open_count: 14`, `total_count: 32`, Tabellenzeilen = 32, offene Zeilen = 14 — beide stimmen.
|
||||
|
||||
## Endstand der Etappe 2 (aus dem Abschluss-Abschnitt der Kritikschrift, nicht neu gerechnet)
|
||||
|
||||
- **Zwölf Bereichs-/Regel-Läufe** von 260909-ipc bis 260911-gwh.
|
||||
- **Übersichtstabelle:** 68 ungebundene / 178 gebundene Rohtreffer (Summe 246; zur Erinnerung: der ursprüngliche Kopf des Klassifikationsdokuments nannte 227 Rohtreffer über 59 Paare — die höhere Summe stammt vom neuen, zur Planungszeit noch nicht feststehenden `widgetInstance`-Besitzriegel).
|
||||
- **Klassen-Verteilung:** 65 (Datei,Modell)-Paare — 33 `muss-mandantengebunden`, 17 `keine-mandantengebundene-tabelle`, 13 `beides`, 2 `bewusst-uebergreifend`.
|
||||
- **Werkzeug:** 137/137 Prüfungen bestanden (`rls-scratch-check.mjs`).
|
||||
- **Tests:** 994/994 grün in 62 Dateien.
|
||||
- **Jeder verbleibende ungebundene Rohtreffer ist einer der in `docs/mandantentrennung-etappe2-fehlerrichtung.md` bzw. `docs/mandantentrennung-zugriffsklassifikation.md` namentlich benannten, bewusst ungebundenen Fälle** — keiner ist übersehen.
|
||||
- Schalter bleibt AUS (`DATABASE_URL` unverändert auf Rolle `tessera`), Schema/Migrationen/Compose/Umgebungsdateien unangetastet, Erlaubnisliste gegen `46f0e78` gehalten.
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None — keine externe Konfiguration nötig.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
Etappe 2 ist mit diesem Lauf abgeschlossen. Was für Etappe 3 bleibt (siehe Abschluss-Abschnitt der Kritikschrift):
|
||||
|
||||
- Anmeldeweg unter je Mandant eindeutigen Anmeldenamen (`username`/`email`).
|
||||
- Benutzerdimension der Regeln (mehrere Bereiche kennen sie nicht — `FavoriteLink` eingeschlossen).
|
||||
- Modulkatalog-Regel für `Module` (Befund E), falls Etappe 3 sie einführt.
|
||||
- Kennzeichnung der `bewusst-uebergreifend`-Stellen (Systemkontext).
|
||||
- Mandantenwechsel im Ausschreibungs-Digest.
|
||||
|
||||
Etappe 4 (`rls-preflight.mjs`) muss vor dem Scharfschalten die in den Bereichsabschnitten benannten Vorabprüfungen laufen lassen (Liste im Abschluss-Abschnitt der Kritikschrift) — die Befund-K-Bedingung ist davon jetzt ausgenommen, weil sie erfüllt ist.
|
||||
|
||||
---
|
||||
*Phase: quick-260911-gwh*
|
||||
*Completed: 2026-09-11*
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
All 12 created/modified files confirmed present on disk; all 4 task commits (`88896d3`, `8f2c13a`, `b5f22e2`, `1240932`) confirmed in `git log`.
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
---
|
||||
phase: quick-260911-gwh
|
||||
verified: 2026-09-11T14:20:00Z
|
||||
status: passed
|
||||
score: 9/9 must-haves verified
|
||||
covered_files:
|
||||
- .planning/WINDOWS.md
|
||||
- .planning/quick/260911-gwh-mandantentrennung-etappe-2-bereiche-favo/260911-gwh-PLAN.md
|
||||
- .planning/quick/260911-gwh-mandantentrennung-etappe-2-bereiche-favo/260911-gwh-SUMMARY.md
|
||||
- apps/api/scripts/rls-scratch-check.mjs
|
||||
- apps/api/src/favorites/favorites.controller.ts
|
||||
- apps/api/src/favorites/favorites.service.spec.ts
|
||||
- apps/api/src/favorites/favorites.service.ts
|
||||
- apps/api/src/mail/mail.module.ts
|
||||
- apps/api/src/settings/settings.service.spec.ts
|
||||
- apps/api/src/settings/settings.service.ts
|
||||
- docs/anleitung-entwicklung.md
|
||||
- docs/mandantentrennung-etappe2-fehlerrichtung.md
|
||||
- docs/mandantentrennung-zugriffsklassifikation.md
|
||||
covered_digest: "v1:sha256:2a5afae1c3039a871e737d6548a419ce5db94b270a0023fb53167db516d4b32f"
|
||||
behavior_unverified: 0
|
||||
overrides_applied: 0
|
||||
---
|
||||
|
||||
# Quick 260911-gwh: Etappe 2 der Mandantentrennung, Bereiche favorites/settings — Verification Report
|
||||
|
||||
**Task Goal:** Mandantentrennung Etappe 2, Bereiche `favorites` und `settings` — 7 `favoriteLink`- und 3 `smtpConfig`-Anfragewege binden, den umbenannten Startpfad bewusst ungebunden lassen (sechster Hintergrunddienst-Fall), den gemessenen Widget-Besitzriegel einbauen, beide fehlenden Spec-Dateien anlegen, Befund K schließen, das Klassifikationsdokument auf den Etappe-2-Endstand bringen.
|
||||
**Verified:** 2026-09-11
|
||||
**Status:** passed
|
||||
**Re-verification:** No — initial verification
|
||||
|
||||
This report independently re-measures every claim in the SUMMARY against the live codebase and a live database container. No claim was accepted on the SUMMARY's word alone.
|
||||
|
||||
## Goal Achievement
|
||||
|
||||
### Observable Truths
|
||||
|
||||
| # | Truth | Status | Evidence |
|
||||
|---|-------|--------|----------|
|
||||
| 1 | All 7 `favoriteLink` sites bound (5 methods), 3 `smtpConfig` request-path sites bound, exactly 1 `smtpConfig` site (startup path) deliberately unbound | ✓ VERIFIED | `grep -n "favoriteLink\."` → 7 hits, all on `tenantPrisma`. `grep -n "smtpConfig\."` → 3 on `tenantPrisma` (`getSmtpConfig`, `saveSmtpConfig`, `getDecryptedSmtpConfig`), 1 on `this.prisma` (`loadAnySmtpConfigForStartupTransport`, line 244) |
|
||||
| 2 | `mail.module.ts` calls the new name; old name `getStartupSmtpConfig` no longer exists anywhere in `apps/api/src` | ✓ VERIFIED | `mail.module.ts` calls `settingsService.loadAnySmtpConfigForStartupTransport()`. `grep -rn getStartupSmtpConfig apps/api/src` → 0 hits. Old name only appears in historical phase artifacts (`.planning/phases/07-*`, `.planning/phases/12-*`, untouched history) and as an explicit "(vormals `getStartupSmtpConfig()`)" annotation in the ledger/critique docs — never as a live call |
|
||||
| 3 | Befund K closed and recorded as closed at (t4), (d4), and in the classification doc | ✓ VERIFIED | `getDecryptedSmtpConfig(tenantId)` runs over `forTenant()` (1 client). (t4) carries `**Nachtrag (260911-gwh):**` confirming the ordering condition is fulfilled (line ~625). (d4) carries the matching `**Nachtrag (260911-gwh):**` (line ~935), plus a correction that `dkv.seed.ts`/`module-registry` is NOT bound (measured, not copied from the plan's suggestion). Classification doc's background-service section states "Befund K ist mit dieser Bindung ERFÜLLT" |
|
||||
| 4 | Widget-ownership guard in `favorites.create()`, measured necessary via Prüfung 7 | ✓ VERIFIED | Guard exists in `favorites.service.ts` (`tenantPrisma.widgetInstance.findUnique` → `NotFoundException('Widget not found')` on null/foreign owner). Prüfung 7 (`favoritelink-fremdschluessel-prueft-am-zeilenschutz-vorbei`) is committed in `rls-scratch-check.mjs:3986-4046` and reproduced independently against the live `tessera-ctl-db-1` container: a bound `create` with a foreign tenant's `widgetId` **succeeds** (FK bypasses RLS) while a nonexistent `widgetId` throws P2003 — confirming the guard is necessary, not decorative. Reverted the guard live and re-ran the spec: exactly the 4 claimed failures reproduced (3 "Widget not found" cases + 1 watchdog case), then restored (clean `git diff`) |
|
||||
| 5 | Startup path names both states (today: arbitrary tenant's SMTP; post-cutover: null/silent) and is named so it can't be mistaken for a request-path method | ✓ VERIFIED | `loadAnySmtpConfigForStartupTransport()` doc-comment explicitly states both states, the fallback-chain double-concealment, the asymmetry to `ldap`/`dkv`, and the "own ledger entry, not attached to #21" decision with reason. `mail.module.ts` header comment mirrors this |
|
||||
| 6 | Three ledger entries #30/#31/#32 exist, open, and match plan rationale | ✓ VERIFIED | `.planning/WINDOWS.md` rows 47-49 confirmed: #30 (mail.module.ts startup path, own entry not attached to #21), #31 (favorites-widget.tsx silent-empty), #32 (smtp-settings-form.tsx silent-empty). All `status: open`. Header counters cross-checked: `open_count=14`/`total_count=32` vs. 32 table rows / 14 open rows — match |
|
||||
| 7 | Two new spec files use the two-client harness, `nodemailer` is `vi.mock`'d, no real send attempted | ✓ VERIFIED | `favorites.service.spec.ts`: bound/unbound client separation via `__makeBoundClient`, `IconDiscoveryService` fully mocked (`vi.fn`), no network calls. `settings.service.spec.ts`: unbound client offers ONLY `findFirst`, bound client offers ONLY `findUnique`/`upsert`; `nodemailer` is `vi.mock('nodemailer', ...)` with `createTransport` returning stub `verify`/`sendMail`. Reproduced falsification (a): reverting the `create()` guard reproduced the exact claimed 4 test failures |
|
||||
| 8 | Generated-client measurements committed — 137 total checks, named `favoritelink-*`/`smtpconfig-*` checks, throwaway tables column-checked (10 scalar fields each) | ✓ VERIFIED | Re-ran `rls-scratch-check.mjs` fresh against the live `tessera-ctl-db-1` container (resolved IP freshly: `172.19.0.2`). Output: "Alle 137 Pruefungen bestanden." 8 `favoritelink-*` named checks + 9 `smtpconfig-*` named checks observed, including the two column-coverage checks confirming 10 scalar fields each match `schema.prisma` exactly, and the `SmtpConfig_tenantId_key` unique index presence |
|
||||
| 9 | Final stage-2 state of the classification document: recomputed sums, six-case heading, closing section numbers match derived measurements | ✓ VERIFIED | Recomputed independently: Übersicht column sums 68 (ungebunden) / 178 (gebunden) — matches Summenzeile exactly. Klassen-Verteilung 33+17+13+2 = 65 — matches. `## Der Hintergrunddienst als Falle — sechs Fälle` heading present; sixth case (`mail.module.ts`/`loadAnySmtpConfigForStartupTransport`) documented with Befund-K-erfüllt statement. `## Etappe 2 — Abschluss` closing section cites 68/178, 65 Paare, 12 runs, matching the same derived numbers. `rls-access-inventory.spec.ts` (11/11 tests) independently re-run and green, confirming the doc-vs-source consistency gate holds |
|
||||
|
||||
**Score:** 9/9 truths verified (0 present, behavior-unverified)
|
||||
|
||||
### Required Artifacts
|
||||
|
||||
| Artifact | Expected | Status | Details |
|
||||
|----------|----------|--------|---------|
|
||||
| `apps/api/scripts/rls-scratch-check.mjs` | `runFavoritesAreaChecks` (≥7 named checks), `runSettingsAreaChecks`, positioned after `runAuthAreaChecks` | ✓ VERIFIED | 8 + 9 = 17 new named checks confirmed by live re-run; 137/137 total |
|
||||
| `docs/mandantentrennung-etappe2-fehlerrichtung.md` | `## Bereich favorites`, `## Bereich settings`, `## Etappe 2 — Abschluss`, Nachträge at (t4)/(d4) | ✓ VERIFIED | All sections present and content-checked above |
|
||||
| `apps/api/src/favorites/favorites.service.ts` | 5 methods, 1 client each, widget-ownership guard in `create` | ✓ VERIFIED | Confirmed by direct read; 7 bound `favoriteLink` + 1 bound `widgetInstance` accesses |
|
||||
| `apps/api/src/favorites/favorites.service.spec.ts` | NEW, two-client harness, icon service mocked, watchdog, edge cases | ✓ VERIFIED | 23 cases, all green in full suite run |
|
||||
| `apps/api/src/favorites/favorites.controller.ts` | passes `tenantId` from `extractContext` to all 5 service methods | ✓ VERIFIED | Direct read confirms all 5 call sites pass `tenantId` |
|
||||
| `apps/api/src/settings/settings.service.ts` | 3 methods bound, startup path renamed with header comment | ✓ VERIFIED | Direct read confirms |
|
||||
| `apps/api/src/settings/settings.service.spec.ts` | NEW, two-client harness with boundary (unbound only `findFirst`, bound only `findUnique`/`upsert`), nodemailer/CryptoService mocked, null-client proof for startup path | ✓ VERIFIED | 20 cases, all green; `forTenant` call-count assertions confirm boundary |
|
||||
| `apps/api/src/mail/mail.module.ts` | calls renamed startup path, comment names both states | ✓ VERIFIED | Direct read confirms |
|
||||
| `docs/mandantentrennung-zugriffsklassifikation.md` | overview rows, Summenzeile, Bestandsaufnahme, Klassen-Verteilung, six-case section, "was diese Etappe nicht entscheidet" | ✓ VERIFIED | All recomputed and matched independently |
|
||||
| `docs/anleitung-entwicklung.md` | paragraph updated to 23 RLS tables / 3 migrations, `FavoriteLink` no longer named as rule-less | ✓ VERIFIED | Confirmed: 4+3+16=23 tables independently recounted from the three migration files |
|
||||
| `.planning/WINDOWS.md` | 3 new open entries via `gsd-tools windows append` | ✓ VERIFIED | #30/#31/#32 present, open, header counters consistent |
|
||||
|
||||
### Key Link Verification
|
||||
|
||||
| From | To | Via | Status | Details |
|
||||
|------|----|-----|--------|---------|
|
||||
| `tender-mail.service.ts` / `dkv-mail.service.ts` | `settingsService.getDecryptedSmtpConfig(tenantId)` | direct call | ✓ WIRED | Method now runs over `forTenant()`; Befund K closed |
|
||||
| `mail.module.ts` `useFactory` | `settingsService.loadAnySmtpConfigForStartupTransport()` | direct call, startup only | ✓ WIRED | Confirmed call site and naming; fallback chain (env vars → localhost:1025) confirmed unchanged |
|
||||
| `FavoriteLink.widgetId` → `WidgetInstance.id` | app-level ownership check | `tenantPrisma.widgetInstance.findUnique` in `create()` | ✓ WIRED | Live-measured: FK bypasses RLS (Prüfung 7); guard closes the existence-oracle gap |
|
||||
| `favorites.controller.ts` `extractContext` | `dashboard.controller.ts` (same tenant source) | textual identity of extraction logic | ✓ WIRED | Confirmed identical `req.tenantId ?? req.user?.tenantId` pattern |
|
||||
| `settings.controller.ts` | `req.tenantId` (unchanged) | direct read | ✓ WIRED | Controller correctly left unchanged per D-10 rationale |
|
||||
|
||||
### Behavioral Spot-Checks / Probe Execution
|
||||
|
||||
| Behavior | Command | Result | Status |
|
||||
|----------|---------|--------|--------|
|
||||
| Full test suite | `npm --prefix apps/api run test` | 994/994 passed, 62 files | ✓ PASS |
|
||||
| Type-check | `npm --prefix apps/api run type-check` | exit 0 | ✓ PASS |
|
||||
| `rls-access-inventory.spec.ts` (doc-vs-source consistency) | `npx vitest run src/prisma/rls-access-inventory.spec.ts` | 11/11 passed | ✓ PASS |
|
||||
| Generated-client tool, live re-run against DB container | `TESSERA_SCRATCH_ADMIN_URL=... node apps/api/scripts/rls-scratch-check.mjs` | "Alle 137 Pruefungen bestanden." | ✓ PASS |
|
||||
| Falsification reproduction: widget-ownership guard removed | manual revert + `npx vitest run src/favorites/favorites.service.spec.ts` | 4 failures (matches claimed deviation note), restored cleanly | ✓ PASS |
|
||||
| `this.prisma.<model>` raw count across `apps/api/src` | `grep -rn "this\.prisma\.[a-zA-Z]*" apps/api/src --include="*.ts" \| grep -v spec \| wc -l` | 68 | ✓ PASS (matches Summenzeile) |
|
||||
| Class-distribution sums | recomputed from table rows | 33+17+13+2 = 65; 68+178=246 raw hits | ✓ PASS |
|
||||
|
||||
### Anti-Patterns Found
|
||||
|
||||
None. No `TBD`, `FIXME`, `XXX`, `TODO`, `HACK`, or `PLACEHOLDER` markers found in any modified file. No empty stub implementations. No hardcoded empty data flowing to render paths.
|
||||
|
||||
### Constraints Held
|
||||
|
||||
- Allow-list scope against `46f0e78`: `git diff --name-only 46f0e78` lists exactly the 12 files declared in `files_modified` (plus the PLAN.md itself, committed separately, and WINDOWS.md) — no unexpected files.
|
||||
- No schema/migration/compose/environment file appears in the diff.
|
||||
- Switch remains OFF (`DATABASE_URL` role `tessera`/`BYPASSRLS` unchanged — no env file touched).
|
||||
- No Active Directory / LDAP code changed (only a comment reference in a doc-string).
|
||||
- No multi-tenant mail transport built — startup path remains deliberately unbound, only renamed and documented.
|
||||
|
||||
### Requirements Coverage
|
||||
|
||||
| Requirement | Source Plan | Description | Status | Evidence |
|
||||
|-------------|-------------|-------------|--------|----------|
|
||||
| WINDOWS-18 | 260911-gwh-PLAN.md | Etappe 2 fully documented at endstate | ✓ SATISFIED | Classification doc, critique doc, anleitung, ledger all recomputed and matched |
|
||||
| ETAPPE-2-FAVORITES | 260911-gwh-PLAN.md | favorites.service.ts fully bound with ownership guard | ✓ SATISFIED | Verified directly |
|
||||
| ETAPPE-2-SETTINGS | 260911-gwh-PLAN.md | settings.service.ts bound, startup path renamed, Befund K closed | ✓ SATISFIED | Verified directly |
|
||||
|
||||
### Human Verification Required
|
||||
|
||||
None. All must-haves were verifiable programmatically and against a live database container.
|
||||
|
||||
### Gaps Summary
|
||||
|
||||
No gaps found. Every must-have in the plan's frontmatter was independently re-measured against the current codebase and/or a live database container — not accepted from the SUMMARY's narrative. The one place the SUMMARY itself documents a deviation from its own prediction (4 vs. 3 falsification failures) was independently reproduced and confirmed accurate.
|
||||
|
||||
---
|
||||
|
||||
_Verified: 2026-09-11_
|
||||
_Verifier: Claude (gsd-verifier)_
|
||||
@@ -3776,6 +3776,581 @@ async function runAuthAreaChecks(adminUrl, scratchRoleUrl, results) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Aufgabe 1 (260911-gwh) — misst die acht im Plan genannten Verhaltensweisen
|
||||
* des Bereichs `favorites` unter der Rolle ohne BYPASSRLS, an der Regel
|
||||
* WORTGLEICH aus der ausgelieferten Migration
|
||||
* `20260909140000_rls_remaining_tenant_tables` geschnitten — NICHT dem
|
||||
* Werkzeug nachgetippt (vgl. runCalendarAreaChecks/runDashboardAreaChecks).
|
||||
*
|
||||
* Legt die Wegwerf-Tabelle "FavoriteLink" mit SAEMTLICHEN skalaren Spalten
|
||||
* des Modells an (readSchemaModelScalarFieldNames('FavoriteLink') filtert
|
||||
* das Relationsfeld `widgetInstance` heraus, sonst misst Pruefung 2 die
|
||||
* falsche Menge) und traegt DEN Fremdschluessel auf die von
|
||||
* runDashboardAreaChecks() bereits angelegte Tabelle "WidgetInstance"
|
||||
* (Befund C, WINDOWS #27: eine Relation ist fuer die Bestandsaufnahme
|
||||
* unsichtbar — hier deshalb ausdruecklich mitgebaut und gemessen, nicht nur
|
||||
* behauptet). Muss deshalb NACH runDashboardAreaChecks() laufen. Setzt auf
|
||||
* keiner Tabelle eines spaeteren Abschnitts auf: er ist ein Blatt in der
|
||||
* Aufrufkette, muss NACH runAuthAreaChecks() und VOR
|
||||
* runTransactionShapeMeasurement() laufen (siehe Aufrufkette in main()).
|
||||
*/
|
||||
async function runFavoritesAreaChecks(adminUrl, scratchRoleUrl, results) {
|
||||
const widenMigrationSql = readRlsWidenMigrationSql();
|
||||
const widenHasOwnFavoriteLinkPolicy =
|
||||
widenMigrationSql && Boolean(extractPolicySql(widenMigrationSql, 'FavoriteLink'));
|
||||
report(
|
||||
results,
|
||||
'favoritelink-regelstand-eindeutig',
|
||||
!widenHasOwnFavoriteLinkPolicy,
|
||||
widenHasOwnFavoriteLinkPolicy
|
||||
? 'die *_rls_widen_membership_grant_and_platform_read-Migration (260910-jab) enthaelt eine EIGENE Regel fuer "FavoriteLink" — der Regelstand ist nicht mehr eindeutig auf 20260909140000_rls_remaining_tenant_tables zurueckzufuehren, Messung abgebrochen statt die abgeloeste Regel weiterzumessen'
|
||||
: 'die *_rls_widen_membership_grant_and_platform_read-Migration (260910-jab) enthaelt KEINE eigene Regel fuer "FavoriteLink" — der Stand aus 20260909140000_rls_remaining_tenant_tables ist weiterhin der ausgelieferte, aktuelle Regelstand',
|
||||
);
|
||||
if (widenHasOwnFavoriteLinkPolicy) {
|
||||
return;
|
||||
}
|
||||
|
||||
const remainingMigrationSql = readRemainingTenantTablesMigrationSql();
|
||||
const favoriteLinkPolicy = remainingMigrationSql
|
||||
? extractPolicySql(remainingMigrationSql, 'FavoriteLink')
|
||||
: null;
|
||||
|
||||
if (!favoriteLinkPolicy) {
|
||||
report(
|
||||
results,
|
||||
'favoritelink-policy-aus-migration-gefunden',
|
||||
false,
|
||||
'CREATE POLICY fuer "FavoriteLink" nicht in der ausgelieferten *_rls_remaining_tenant_tables-Migration gefunden',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Befund C: vorher ueber die Wartungsrolle MESSEN, welche "WidgetInstance"-
|
||||
// Zeilen stehen — nicht annehmen. runDashboardAreaChecks() legt diese
|
||||
// Tabelle bereits mit drei Zeilen an (widget-a1/user-a1/TENANT-A,
|
||||
// widget-a2/user-a2/TENANT-A, widget-b1/user-b1/TENANT-B).
|
||||
const widgetInstanceRows = await withAdminPrisma(
|
||||
urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(),
|
||||
async (db) => {
|
||||
const rows = await db.$queryRaw`SELECT id, "userId", "tenantId" FROM "WidgetInstance" ORDER BY id`;
|
||||
return rows;
|
||||
},
|
||||
);
|
||||
|
||||
await withAdminPrisma(urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(), async (db) => {
|
||||
// updatedAt bekommt DEFAULT CURRENT_TIMESTAMP als vermerkte Abweichung
|
||||
// (Prisma setzt den Wert clientseitig, das schmale INSERT unten braucht
|
||||
// trotzdem einen Wert) — Form von 260911-e2s/fh9.
|
||||
await db.$executeRawUnsafe(`
|
||||
CREATE TABLE "FavoriteLink" (
|
||||
id text PRIMARY KEY,
|
||||
"userId" text NOT NULL,
|
||||
"tenantId" text NOT NULL,
|
||||
"widgetId" text NOT NULL,
|
||||
title text NOT NULL,
|
||||
url text NOT NULL,
|
||||
"iconUrl" text,
|
||||
position integer NOT NULL DEFAULT 0,
|
||||
"createdAt" timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "FavoriteLink_widgetId_fkey" FOREIGN KEY ("widgetId") REFERENCES "WidgetInstance"("id") ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
await db.$executeRawUnsafe(`ALTER TABLE "FavoriteLink" ENABLE ROW LEVEL SECURITY;`);
|
||||
await db.$executeRawUnsafe(`ALTER TABLE "FavoriteLink" FORCE ROW LEVEL SECURITY;`);
|
||||
await db.$executeRawUnsafe(favoriteLinkPolicy);
|
||||
await db.$executeRawUnsafe(
|
||||
`GRANT SELECT, INSERT, UPDATE, DELETE ON "FavoriteLink" TO ${SCRATCH_ROLE_NAME}`,
|
||||
);
|
||||
|
||||
// fav-a1-1/fav-a1-2 gehoeren user-a1 (TENANT-A, widget-a1); fav-a2-1
|
||||
// gehoert dem KOLLEGEN user-a2 (TENANT-A, widget-a2, Befund G-Form);
|
||||
// fav-b1-1 liegt unter TENANT-B (widget-b1). fav-a1-1 traegt eine
|
||||
// iconUrl, fav-a1-2 nicht (Pitfall 3 des Bereichs).
|
||||
await db.$executeRawUnsafe(`
|
||||
INSERT INTO "FavoriteLink" (id, "userId", "tenantId", "widgetId", title, url, "iconUrl", position) VALUES
|
||||
('fav-a1-1', 'user-a1', 'TENANT-A', 'widget-a1', 'Favorit A1-1', 'https://example.invalid/a1-1', 'https://icons.invalid/a1-1.png', 0),
|
||||
('fav-a1-2', 'user-a1', 'TENANT-A', 'widget-a1', 'Favorit A1-2', 'https://example.invalid/a1-2', NULL, 1),
|
||||
('fav-a2-1', 'user-a2', 'TENANT-A', 'widget-a2', 'Favorit A2-1', 'https://example.invalid/a2-1', NULL, 0),
|
||||
('fav-b1-1', 'user-b1', 'TENANT-B', 'widget-b1', 'Favorit B1-1', 'https://example.invalid/b1-1', NULL, 0);
|
||||
`);
|
||||
});
|
||||
|
||||
// Pruefung 2 zuerst — faellt sie durch, sind die Client-Messungen (3-8)
|
||||
// wertlos, deshalb steht sie vor ihnen und die Funktion bricht ab, wenn
|
||||
// sie fehlschlaegt (Lehre aus Pruefung 8 im Bereich `calendar`).
|
||||
const schemaFields = readSchemaModelScalarFieldNames('FavoriteLink');
|
||||
const tableColumns = await withAdminPrisma(
|
||||
urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(),
|
||||
async (db) => {
|
||||
const rows = await db.$queryRaw`
|
||||
SELECT column_name FROM information_schema.columns
|
||||
WHERE table_schema = 'public' AND table_name = 'FavoriteLink'
|
||||
`;
|
||||
return rows.map((r) => r.column_name).sort();
|
||||
},
|
||||
);
|
||||
const schemaFieldsSorted = [...schemaFields].sort();
|
||||
const columnsMatch =
|
||||
schemaFieldsSorted.length > 0 &&
|
||||
schemaFieldsSorted.length === tableColumns.length &&
|
||||
schemaFieldsSorted.every((f, i) => f === tableColumns[i]);
|
||||
report(
|
||||
results,
|
||||
'favoritelink-wegwerftabelle-deckt-alle-spalten-des-generierten-clients',
|
||||
columnsMatch,
|
||||
`Schema-Felder aus schema.prisma (model FavoriteLink, skalare Felder ohne Relation, ${schemaFieldsSorted.length}): ${JSON.stringify(schemaFieldsSorted)}; Spalten der Wegwerf-Tabelle (${tableColumns.length}): ${JSON.stringify(tableColumns)}; gemessene "WidgetInstance"-Zeilen (Befund C, von runDashboardAreaChecks angelegt): ${JSON.stringify(widgetInstanceRows)}`,
|
||||
);
|
||||
if (!columnsMatch) {
|
||||
return;
|
||||
}
|
||||
|
||||
const prisma = new PrismaClient({ datasourceUrl: scratchRoleUrl });
|
||||
try {
|
||||
// 3: favoritelink-liste-generierter-client-ungebunden-liefert-leere-liste
|
||||
// — die tragende Belegzeile dieses Abschnitts.
|
||||
const unboundList = await prisma.favoriteLink.findMany({
|
||||
where: { userId: 'user-a1', widgetId: 'widget-a1' },
|
||||
orderBy: [{ position: 'asc' }, { title: 'asc' }],
|
||||
});
|
||||
report(
|
||||
results,
|
||||
'favoritelink-liste-generierter-client-ungebunden-liefert-leere-liste',
|
||||
unboundList.length === 0,
|
||||
`ungebundenes prisma.favoriteLink.findMany({ where: { userId: 'user-a1', widgetId: 'widget-a1' }, orderBy: [{ position: 'asc' }, { title: 'asc' }] }) (die Form von list) liefert ${unboundList.length} Zeile(n), obwohl 2 tatsaechlich vorhanden sind — das ist der Wert, aus dem favorites-widget.tsx "Noch keine Favoriten." macht`,
|
||||
);
|
||||
|
||||
// 4: favoritelink-liste-generierter-client-gebunden-eigener-mandant-liefert-eigene-zeilen
|
||||
// — zwei Aussagen in einer Messung: die eigenen Zeilen kommen, UND die
|
||||
// Regel kennt keine Benutzerdimension (die Zeile des Kollegen ist ueber
|
||||
// ein gebundenes findMany auf DESSEN widgetId ebenfalls sichtbar).
|
||||
const bound = buildInlineExtendedClient(prisma, 'TENANT-A');
|
||||
const boundOwnList = await bound.favoriteLink.findMany({
|
||||
where: { userId: 'user-a1', widgetId: 'widget-a1' },
|
||||
orderBy: [{ position: 'asc' }, { title: 'asc' }],
|
||||
});
|
||||
const ownListOk =
|
||||
boundOwnList.length === 2 &&
|
||||
boundOwnList.every((r) => r.userId === 'user-a1' && r.widgetId === 'widget-a1');
|
||||
const boundColleagueList = await bound.favoriteLink.findMany({ where: { widgetId: 'widget-a2' } });
|
||||
const colleagueVisible =
|
||||
boundColleagueList.length === 1 && boundColleagueList[0].userId === 'user-a2';
|
||||
report(
|
||||
results,
|
||||
'favoritelink-liste-generierter-client-gebunden-eigener-mandant-liefert-eigene-zeilen',
|
||||
ownListOk && colleagueVisible,
|
||||
`bound.favoriteLink.findMany unter TENANT-A liefert fuer (userId='user-a1', widgetId='widget-a1') ${boundOwnList.length} Zeile(n): ${JSON.stringify(boundOwnList.map((r) => r.id))} — die Zeile von user-a2 fehlt (anwendungsseitige Benutzerfilterung); ein gebundenes findMany({ where: { widgetId: 'widget-a2' } }) unter DEMSELBEN Mandanten liefert dagegen ${boundColleagueList.length} Zeile(n) des Kollegen user-a2 (${JSON.stringify(boundColleagueList.map((r) => r.id))}) — die Regel auf "FavoriteLink" kennt keine Benutzerdimension (dieselbe Lehre wie calendarsource-fremder-nutzer-desselben-mandanten-gebunden-sichtbar), die anwendungsseitige userId-Filterung bleibt deshalb der einzige Schutz gegen Quer-Lesen zwischen Nutzern DESSELBEN Mandanten (Etappe-3-Entscheidung (2))`,
|
||||
);
|
||||
|
||||
// 5: favoritelink-besitzpruefung-generierter-client-gebunden-fremder-mandant-liefert-null
|
||||
// — die Datenbankseite der Vorpruefung in update/remove/getIconBytes (T-GWH-02).
|
||||
const boundB = buildInlineExtendedClient(prisma, 'TENANT-B');
|
||||
const foreignFind = await boundB.favoriteLink.findUnique({ where: { id: 'fav-a1-1' } });
|
||||
report(
|
||||
results,
|
||||
'favoritelink-besitzpruefung-generierter-client-gebunden-fremder-mandant-liefert-null',
|
||||
foreignFind === null,
|
||||
`bound.favoriteLink.findUnique({ where: { id: 'fav-a1-1' } }) unter TENANT-B (die Zeile gehoert TENANT-A) liefert ${JSON.stringify(foreignFind)} — das ist die Datenbankseite der Vorpruefung in update/remove/getIconBytes (T-GWH-02): fremde Zeile -> findUnique liefert null -> NotFoundException`,
|
||||
);
|
||||
|
||||
// 6: favoritelink-gebundenes-loeschen-ueber-kennung-allein-fremder-mandant-scheitert-laut
|
||||
// — der generierte Client meldet null getroffene Zeilen bei delete anders
|
||||
// als Roh-SQL (dkv Befund G in der Client-Form): Konstruktorname/code
|
||||
// woertlich, das Ergebnis wird nicht vorweggenommen.
|
||||
let foreignDeleteThrew = false;
|
||||
let foreignDeleteDetail = '';
|
||||
try {
|
||||
await boundB.favoriteLink.delete({ where: { id: 'fav-a1-1' } });
|
||||
foreignDeleteDetail =
|
||||
'bound.favoriteLink.delete unter TENANT-B auf die unter TENANT-A liegende Zeile fav-a1-1 ist NICHT fehlgeschlagen';
|
||||
} catch (err) {
|
||||
foreignDeleteThrew = true;
|
||||
const ctor = err?.constructor?.name ?? 'unbekannt';
|
||||
foreignDeleteDetail = `bound.favoriteLink.delete unter TENANT-B auf die unter TENANT-A liegende, fuer TENANT-B unsichtbare Zeile fav-a1-1 wirft ${ctor}${err?.code ? ` (code ${err.code})` : ''}: ${(err.message ?? '').toString().trim()}`;
|
||||
}
|
||||
const stillThereAfterForeignDelete = await withAdminPrisma(
|
||||
urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(),
|
||||
async (db) => {
|
||||
const rows = await db.$queryRaw`SELECT id FROM "FavoriteLink" WHERE id = 'fav-a1-1'`;
|
||||
return rows.length === 1;
|
||||
},
|
||||
);
|
||||
report(
|
||||
results,
|
||||
'favoritelink-gebundenes-loeschen-ueber-kennung-allein-fremder-mandant-scheitert-laut',
|
||||
foreignDeleteThrew && stillThereAfterForeignDelete,
|
||||
`${foreignDeleteDetail} — die Wartungsrolle liest die Zeile danach noch: ${stillThereAfterForeignDelete}`,
|
||||
);
|
||||
|
||||
// 7: favoritelink-fremdschluessel-prueft-am-zeilenschutz-vorbei — MISST,
|
||||
// ob der Fremdschluessel auf "WidgetInstance" die Zeilenschutz-Regel
|
||||
// dieser Tabelle umgeht (dokumentiertes PostgreSQL-Verhalten:
|
||||
// referentielle Integritaet prueft AN der Regel vorbei). Das Ergebnis
|
||||
// steuert Aufgabe 2 (Befund F), es wird NICHT vorweggenommen. Zweite
|
||||
// Haelfte derselben Pruefung: ein gebundenes create mit einer
|
||||
// WIRKLICH fehlenden widgetId MUSS an der FK-Verletzung scheitern — der
|
||||
// Unterschied zwischen beiden Antworten ist das Existenzorakel (T-GWH-05).
|
||||
const widgetB1UnderA = await bound.widgetInstance.findUnique({
|
||||
where: { id: 'widget-b1' },
|
||||
select: { userId: true },
|
||||
});
|
||||
let foreignWidgetCreateSucceeded = false;
|
||||
let foreignWidgetCreateDetail = '';
|
||||
try {
|
||||
const created = await bound.favoriteLink.create({
|
||||
data: {
|
||||
id: 'fav-a1-fremdes-widget',
|
||||
userId: 'user-a1',
|
||||
tenantId: 'TENANT-A',
|
||||
widgetId: 'widget-b1',
|
||||
title: 'Fremdes Widget',
|
||||
url: 'https://example.invalid/fremd',
|
||||
position: 0,
|
||||
},
|
||||
});
|
||||
foreignWidgetCreateSucceeded = Boolean(created);
|
||||
foreignWidgetCreateDetail = `bound.favoriteLink.create unter TENANT-A mit widgetId='widget-b1' (gehoert TENANT-B, unter TENANT-A per gebundenem widgetInstance.findUnique unsichtbar: ${JSON.stringify(widgetB1UnderA)}) GELINGT (id=${created?.id}) — der Fremdschluessel prueft am Zeilenschutz VORBEI (dokumentiertes PostgreSQL-Verhalten)`;
|
||||
// Die Zeile ist ein Messartefakt, nicht Teil des Bestands fuer die
|
||||
// folgenden Pruefungen — ueber die Wartungsrolle wieder entfernen.
|
||||
await withAdminPrisma(urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(), (db) =>
|
||||
db.$executeRawUnsafe(`DELETE FROM "FavoriteLink" WHERE id = 'fav-a1-fremdes-widget'`),
|
||||
);
|
||||
} catch (err) {
|
||||
const ctor = err?.constructor?.name ?? 'unbekannt';
|
||||
foreignWidgetCreateDetail = `bound.favoriteLink.create unter TENANT-A mit widgetId='widget-b1' (unter TENANT-A per gebundenem widgetInstance.findUnique unsichtbar: ${JSON.stringify(widgetB1UnderA)}) scheitert mit ${ctor}${err?.code ? ` (code ${err.code})` : ''}: ${(err.message ?? '').toString().trim()}`;
|
||||
}
|
||||
let missingWidgetCreateRejected = false;
|
||||
let missingWidgetCreateDetail = '';
|
||||
try {
|
||||
await bound.favoriteLink.create({
|
||||
data: {
|
||||
id: 'fav-a1-widget-fehlt',
|
||||
userId: 'user-a1',
|
||||
tenantId: 'TENANT-A',
|
||||
widgetId: 'widget-gibt-es-nicht',
|
||||
title: 'Widget fehlt',
|
||||
url: 'https://example.invalid/fehlt',
|
||||
position: 0,
|
||||
},
|
||||
});
|
||||
missingWidgetCreateDetail =
|
||||
'bound.favoriteLink.create unter TENANT-A mit widgetId="widget-gibt-es-nicht" ist NICHT fehlgeschlagen';
|
||||
} catch (err) {
|
||||
missingWidgetCreateRejected = true;
|
||||
const ctor = err?.constructor?.name ?? 'unbekannt';
|
||||
missingWidgetCreateDetail = `bound.favoriteLink.create unter TENANT-A mit widgetId="widget-gibt-es-nicht" scheitert mit ${ctor}${err?.code ? ` (code ${err.code})` : ''}: ${(err.message ?? '').toString().trim()} — die FK-Verletzung, das Gegenstueck zum Gelingen oben`;
|
||||
}
|
||||
report(
|
||||
results,
|
||||
'favoritelink-fremdschluessel-prueft-am-zeilenschutz-vorbei',
|
||||
missingWidgetCreateRejected,
|
||||
`${foreignWidgetCreateDetail}; ${missingWidgetCreateDetail} — der Unterschied zwischen beiden Antworten ("gibt es nicht" scheitert, "liegt bei fremdem Mandanten" gelingt${foreignWidgetCreateSucceeded ? '' : ' NICHT, gemessen statt angenommen'}) ist das Existenzorakel (T-GWH-05); das Ergebnis der ersten Haelfte (Gelingen: ${foreignWidgetCreateSucceeded}) steuert, ob Aufgabe 2 einen Besitzriegel in create() baut`,
|
||||
);
|
||||
|
||||
// 8: favoritelink-gebundenes-anlegen-eigener-mandant-gelingt
|
||||
let ownCreateSucceeded = false;
|
||||
let ownCreateDetail = '';
|
||||
try {
|
||||
const created = await bound.favoriteLink.create({
|
||||
data: {
|
||||
id: 'fav-a1-neu',
|
||||
userId: 'user-a1',
|
||||
tenantId: 'TENANT-A',
|
||||
widgetId: 'widget-a1',
|
||||
title: 'Neu angelegter Favorit',
|
||||
url: 'https://example.invalid/neu',
|
||||
position: 2,
|
||||
},
|
||||
});
|
||||
const readBack = await withAdminPrisma(
|
||||
urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(),
|
||||
async (db) =>
|
||||
db.$queryRaw`SELECT "tenantId", "createdAt", "updatedAt" FROM "FavoriteLink" WHERE id = 'fav-a1-neu'`,
|
||||
);
|
||||
ownCreateSucceeded =
|
||||
readBack.length === 1 &&
|
||||
readBack[0].tenantId === 'TENANT-A' &&
|
||||
readBack[0].createdAt != null &&
|
||||
readBack[0].updatedAt != null;
|
||||
ownCreateDetail = `bound.favoriteLink.create unter TENANT-A mit widgetId='widget-a1' gelingt (id=${created.id}); die Wartungsrolle liest danach tenantId=${JSON.stringify(readBack[0]?.tenantId)}, createdAt=${JSON.stringify(readBack[0]?.createdAt)}, updatedAt=${JSON.stringify(readBack[0]?.updatedAt)} — bestaetigt nebenbei, dass die Wegwerf-Tabelle die clientseitig erzeugten Werte annimmt`;
|
||||
} catch (err) {
|
||||
ownCreateDetail = `bound.favoriteLink.create unter TENANT-A mit widgetId='widget-a1' ist fehlgeschlagen: ${err.message}`;
|
||||
}
|
||||
report(
|
||||
results,
|
||||
'favoritelink-gebundenes-anlegen-eigener-mandant-gelingt',
|
||||
ownCreateSucceeded,
|
||||
ownCreateDetail,
|
||||
);
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Aufgabe 1 (260911-gwh) — misst die neun im Plan genannten Verhaltensweisen
|
||||
* des Bereichs `settings` unter der Rolle ohne BYPASSRLS, an der Regel
|
||||
* WORTGLEICH aus der ausgelieferten Migration
|
||||
* `20260909140000_rls_remaining_tenant_tables` geschnitten. Legt die
|
||||
* Wegwerf-Tabelle "SmtpConfig" mit SAEMTLICHEN skalaren Spalten des Modells
|
||||
* an UND mit dem Eindeutigkeitsindex `SmtpConfig_tenantId_key` WORTGLEICH
|
||||
* aus `20260629130000_add_missing_tables` — ohne diesen Index misst
|
||||
* Pruefung 8 nichts (der Konfliktweg braucht den physischen Index, nicht
|
||||
* nur die Regel). Ein Blatt wie `runFavoritesAreaChecks`: muss NACH
|
||||
* runAuthAreaChecks() und VOR runTransactionShapeMeasurement() laufen.
|
||||
*/
|
||||
async function runSettingsAreaChecks(adminUrl, scratchRoleUrl, results) {
|
||||
const widenMigrationSql = readRlsWidenMigrationSql();
|
||||
const widenHasOwnSmtpConfigPolicy =
|
||||
widenMigrationSql && Boolean(extractPolicySql(widenMigrationSql, 'SmtpConfig'));
|
||||
report(
|
||||
results,
|
||||
'smtpconfig-regelstand-eindeutig',
|
||||
!widenHasOwnSmtpConfigPolicy,
|
||||
widenHasOwnSmtpConfigPolicy
|
||||
? 'die *_rls_widen_membership_grant_and_platform_read-Migration (260910-jab) enthaelt eine EIGENE Regel fuer "SmtpConfig" — der Regelstand ist nicht mehr eindeutig auf 20260909140000_rls_remaining_tenant_tables zurueckzufuehren, Messung abgebrochen statt die abgeloeste Regel weiterzumessen'
|
||||
: 'die *_rls_widen_membership_grant_and_platform_read-Migration (260910-jab) enthaelt KEINE eigene Regel fuer "SmtpConfig" — der Stand aus 20260909140000_rls_remaining_tenant_tables ist weiterhin der ausgelieferte, aktuelle Regelstand',
|
||||
);
|
||||
if (widenHasOwnSmtpConfigPolicy) {
|
||||
return;
|
||||
}
|
||||
|
||||
const remainingMigrationSql = readRemainingTenantTablesMigrationSql();
|
||||
const smtpConfigPolicy = remainingMigrationSql
|
||||
? extractPolicySql(remainingMigrationSql, 'SmtpConfig')
|
||||
: null;
|
||||
|
||||
if (!smtpConfigPolicy) {
|
||||
report(
|
||||
results,
|
||||
'smtpconfig-policy-aus-migration-gefunden',
|
||||
false,
|
||||
'CREATE POLICY fuer "SmtpConfig" nicht in der ausgelieferten *_rls_remaining_tenant_tables-Migration gefunden',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await withAdminPrisma(urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(), async (db) => {
|
||||
await db.$executeRawUnsafe(`
|
||||
CREATE TABLE "SmtpConfig" (
|
||||
id text PRIMARY KEY,
|
||||
"tenantId" text NOT NULL,
|
||||
host text NOT NULL,
|
||||
port integer NOT NULL DEFAULT 587,
|
||||
encryption text NOT NULL DEFAULT 'starttls',
|
||||
username text,
|
||||
"encryptedPassword" text,
|
||||
"fromAddress" text NOT NULL,
|
||||
"createdAt" timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`);
|
||||
// WORTGLEICH aus 20260629130000_add_missing_tables — ohne diesen Index
|
||||
// misst Pruefung 8 nichts.
|
||||
await db.$executeRawUnsafe(
|
||||
`CREATE UNIQUE INDEX "SmtpConfig_tenantId_key" ON "SmtpConfig"("tenantId");`,
|
||||
);
|
||||
await db.$executeRawUnsafe(`ALTER TABLE "SmtpConfig" ENABLE ROW LEVEL SECURITY;`);
|
||||
await db.$executeRawUnsafe(`ALTER TABLE "SmtpConfig" FORCE ROW LEVEL SECURITY;`);
|
||||
await db.$executeRawUnsafe(smtpConfigPolicy);
|
||||
await db.$executeRawUnsafe(
|
||||
`GRANT SELECT, INSERT, UPDATE, DELETE ON "SmtpConfig" TO ${SCRATCH_ROLE_NAME}`,
|
||||
);
|
||||
await db.$executeRawUnsafe(`
|
||||
INSERT INTO "SmtpConfig" (id, "tenantId", host, "encryptedPassword", "fromAddress") VALUES
|
||||
('smtp-a', 'TENANT-A', 'smtp-a.example.invalid', 'enc(a-passwort-platzhalter)', 'a@example.invalid'),
|
||||
('smtp-b', 'TENANT-B', 'smtp-b.example.invalid', 'enc(b-passwort-platzhalter)', 'b@example.invalid');
|
||||
`);
|
||||
});
|
||||
|
||||
// Pruefung 2 zuerst — dieselbe Reihenfolgeregel wie bei `favorites`.
|
||||
const schemaFields = readSchemaModelScalarFieldNames('SmtpConfig');
|
||||
const tableColumns = await withAdminPrisma(
|
||||
urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(),
|
||||
async (db) => {
|
||||
const rows = await db.$queryRaw`
|
||||
SELECT column_name FROM information_schema.columns
|
||||
WHERE table_schema = 'public' AND table_name = 'SmtpConfig'
|
||||
`;
|
||||
return rows.map((r) => r.column_name).sort();
|
||||
},
|
||||
);
|
||||
const schemaFieldsSorted = [...schemaFields].sort();
|
||||
const columnsMatch =
|
||||
schemaFieldsSorted.length > 0 &&
|
||||
schemaFieldsSorted.length === tableColumns.length &&
|
||||
schemaFieldsSorted.every((f, i) => f === tableColumns[i]);
|
||||
const indexRows = await withAdminPrisma(
|
||||
urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(),
|
||||
async (db) => {
|
||||
const rows = await db.$queryRaw`
|
||||
SELECT indexdef FROM pg_indexes WHERE tablename = 'SmtpConfig' AND indexname = 'SmtpConfig_tenantId_key'
|
||||
`;
|
||||
return rows;
|
||||
},
|
||||
);
|
||||
const indexOk = indexRows.length === 1;
|
||||
report(
|
||||
results,
|
||||
'smtpconfig-wegwerftabelle-deckt-alle-spalten-des-generierten-clients',
|
||||
columnsMatch && indexOk,
|
||||
`Schema-Felder aus schema.prisma (model SmtpConfig, skalare Felder ohne Relation, ${schemaFieldsSorted.length}): ${JSON.stringify(schemaFieldsSorted)}; Spalten der Wegwerf-Tabelle (${tableColumns.length}): ${JSON.stringify(tableColumns)}; Eindeutigkeitsindex "SmtpConfig_tenantId_key" ueber pg_indexes: ${JSON.stringify(indexRows.map((r) => r.indexdef))}`,
|
||||
);
|
||||
if (!columnsMatch || !indexOk) {
|
||||
return;
|
||||
}
|
||||
|
||||
const prisma = new PrismaClient({ datasourceUrl: scratchRoleUrl });
|
||||
try {
|
||||
// 3: smtpconfig-startpfad-generierter-client-ungebunden-liefert-null —
|
||||
// die Form von loadAnySmtpConfigForStartupTransport(), ohne jede Bedingung.
|
||||
const unboundStartup = await prisma.smtpConfig.findFirst();
|
||||
report(
|
||||
results,
|
||||
'smtpconfig-startpfad-generierter-client-ungebunden-liefert-null',
|
||||
unboundStartup === null,
|
||||
`ungebundenes prisma.smtpConfig.findFirst() (die Form von loadAnySmtpConfigForStartupTransport) liefert ${JSON.stringify(unboundStartup)}, obwohl 2 Zeilen existieren — das ist der Wert, mit dem mail.module.ts nach dem Scharfschalten auf Umgebungsvariablen und zuletzt localhost:1025 zurueckfaellt, ein falscher Transport statt einer Meldung`,
|
||||
);
|
||||
|
||||
// 4: smtpconfig-startpfad-ueber-wartungsrolle-zieht-beliebige-zeile —
|
||||
// die HEUTIGE Lage: dieselbe Abfrage ueber die Wartungsrolle liefert
|
||||
// eine beliebige, aber vorhandene Zeile.
|
||||
const adminStartupRow = await withAdminPrisma(
|
||||
urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(),
|
||||
async (db) => {
|
||||
const rows = await db.$queryRaw`SELECT "tenantId" FROM "SmtpConfig" LIMIT 1`;
|
||||
return rows[0];
|
||||
},
|
||||
);
|
||||
report(
|
||||
results,
|
||||
'smtpconfig-startpfad-ueber-wartungsrolle-zieht-beliebige-zeile',
|
||||
Boolean(adminStartupRow),
|
||||
`dieselbe Abfrage (SELECT ... LIMIT 1 ohne jede Bedingung) ueber die Wartungsrolle liefert genau EINE Zeile, tenantId=${JSON.stringify(adminStartupRow?.tenantId)} — nichts in der Abfrage bestimmt, WELCHER Mandant gezogen wird, und dessen Server und Absender tragen ab Start alle Kennwort-Zuruecksetzungs-Mails ALLER Mandanten (T-GWH-03)`,
|
||||
);
|
||||
|
||||
// 5: smtpconfig-versandpfad-generierter-client-ungebunden-liefert-null —
|
||||
// Befund K, der Versandpfad.
|
||||
const unboundSendPath = await prisma.smtpConfig.findUnique({ where: { tenantId: 'TENANT-A' } });
|
||||
report(
|
||||
results,
|
||||
'smtpconfig-versandpfad-generierter-client-ungebunden-liefert-null',
|
||||
unboundSendPath === null,
|
||||
`ungebundenes prisma.smtpConfig.findUnique({ where: { tenantId: 'TENANT-A' } }) (die Form von getDecryptedSmtpConfig) liefert ${JSON.stringify(unboundSendPath)}, waehrend die Wartungsrolle die Zeile liest — Befund K: tender-mail.service.ts protokolliert "No SMTP configuration" und ueberspringt, dkv-mail.service.ts wirft; kein Versand fuer niemanden`,
|
||||
);
|
||||
|
||||
// 6: smtpconfig-versandpfad-generierter-client-gebunden-eigener-mandant-liefert-zugangsdaten
|
||||
const boundA = buildInlineExtendedClient(prisma, 'TENANT-A');
|
||||
const boundOwn = await boundA.smtpConfig.findUnique({ where: { tenantId: 'TENANT-A' } });
|
||||
report(
|
||||
results,
|
||||
'smtpconfig-versandpfad-generierter-client-gebunden-eigener-mandant-liefert-zugangsdaten',
|
||||
Boolean(boundOwn) &&
|
||||
boundOwn.encryptedPassword === 'enc(a-passwort-platzhalter)' &&
|
||||
boundOwn.host === 'smtp-a.example.invalid' &&
|
||||
boundOwn.fromAddress === 'a@example.invalid',
|
||||
`gebunden unter TENANT-A liefert findUnique({ where: { tenantId: 'TENANT-A' } }): host=${JSON.stringify(boundOwn?.host)}, fromAddress=${JSON.stringify(boundOwn?.fromAddress)}, encryptedPassword=${JSON.stringify(boundOwn?.encryptedPassword)}`,
|
||||
);
|
||||
|
||||
// 7: smtpconfig-versandpfad-generierter-client-gebunden-fremder-mandant-liefert-null
|
||||
// — T-GWH-01.
|
||||
const boundB = buildInlineExtendedClient(prisma, 'TENANT-B');
|
||||
const foreignSend = await boundB.smtpConfig.findUnique({ where: { tenantId: 'TENANT-A' } });
|
||||
report(
|
||||
results,
|
||||
'smtpconfig-versandpfad-generierter-client-gebunden-fremder-mandant-liefert-null',
|
||||
foreignSend === null,
|
||||
`gebunden unter TENANT-B liefert findUnique({ where: { tenantId: 'TENANT-A' } }) (gehoert TENANT-A): ${JSON.stringify(foreignSend)} — die verschluesselten Zugangsdaten von A sind fuer B unsichtbar (T-GWH-01)`,
|
||||
);
|
||||
|
||||
// 8: smtpconfig-ungebundenes-upsert-auf-unsichtbare-zeile-scheitert-laut
|
||||
// — die Form von saveSmtpConfig, UNGEBUNDEN. Konstruktorname und code
|
||||
// woertlich, das Ergebnis wird NICHT vorweggenommen; die Belegausgabe
|
||||
// haelt daneben, was 260910-krx fuer DashboardLayout gemessen hat.
|
||||
let conflictThrew = false;
|
||||
let conflictCtor = 'unbekannt';
|
||||
let conflictCode;
|
||||
let conflictMessage = '';
|
||||
try {
|
||||
await prisma.smtpConfig.upsert({
|
||||
where: { tenantId: 'TENANT-A' },
|
||||
create: {
|
||||
id: 'smtp-a-neu',
|
||||
tenantId: 'TENANT-A',
|
||||
host: 'smtp-a-neu.example.invalid',
|
||||
fromAddress: 'a@example.invalid',
|
||||
},
|
||||
update: { host: 'smtp-a-neu.example.invalid' },
|
||||
});
|
||||
} catch (err) {
|
||||
conflictThrew = true;
|
||||
conflictCtor = err?.constructor?.name ?? 'unbekannt';
|
||||
conflictCode = err?.code;
|
||||
conflictMessage = (err.message ?? '').toString().trim();
|
||||
}
|
||||
const hostAfterConflict = await withAdminPrisma(
|
||||
urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(),
|
||||
async (db) => {
|
||||
const rows = await db.$queryRaw`SELECT host FROM "SmtpConfig" WHERE id = 'smtp-a'`;
|
||||
return rows[0]?.host;
|
||||
},
|
||||
);
|
||||
report(
|
||||
results,
|
||||
'smtpconfig-ungebundenes-upsert-auf-unsichtbare-zeile-scheitert-laut',
|
||||
conflictThrew && hostAfterConflict === 'smtp-a.example.invalid',
|
||||
`ungebundenes prisma.smtpConfig.upsert({ where: { tenantId: 'TENANT-A' }, ... }) (die Form von saveSmtpConfig) wirft ${conflictCtor}${conflictCode ? ` (code ${conflictCode})` : ''}: ${conflictMessage} — zum Vergleich: 260910-krx mass fuer DashboardLayout unter dieser Form PrismaClientUnknownRequestError; die Wartungsrolle liest danach weiterhin host=${JSON.stringify(hostAfterConflict)}`,
|
||||
);
|
||||
|
||||
// 9: smtpconfig-gebundenes-upsert-eigener-mandant-aktualisiert
|
||||
let boundUpsertSucceeded = false;
|
||||
let boundUpsertDetail = '';
|
||||
try {
|
||||
await boundA.smtpConfig.upsert({
|
||||
where: { tenantId: 'TENANT-A' },
|
||||
create: {
|
||||
id: 'smtp-a-neu-2',
|
||||
tenantId: 'TENANT-A',
|
||||
host: 'smtp-a-gebunden-neu.example.invalid',
|
||||
fromAddress: 'a@example.invalid',
|
||||
},
|
||||
update: { host: 'smtp-a-gebunden-neu.example.invalid' },
|
||||
});
|
||||
const afterBoundUpsert = await withAdminPrisma(
|
||||
urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(),
|
||||
async (db) => {
|
||||
const rows = await db.$queryRaw`SELECT id, host, "updatedAt" FROM "SmtpConfig" WHERE "tenantId" = 'TENANT-A'`;
|
||||
return rows[0];
|
||||
},
|
||||
);
|
||||
const bUnchanged = await withAdminPrisma(
|
||||
urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(),
|
||||
async (db) => {
|
||||
const rows = await db.$queryRaw`SELECT host FROM "SmtpConfig" WHERE id = 'smtp-b'`;
|
||||
return rows[0]?.host;
|
||||
},
|
||||
);
|
||||
boundUpsertSucceeded =
|
||||
afterBoundUpsert?.id === 'smtp-a' &&
|
||||
afterBoundUpsert?.host === 'smtp-a-gebunden-neu.example.invalid' &&
|
||||
afterBoundUpsert?.updatedAt != null &&
|
||||
bUnchanged === 'smtp-b.example.invalid';
|
||||
boundUpsertDetail = `gebundenes upsert unter TENANT-A trifft die eigene Zeile (id=${afterBoundUpsert?.id}), die Wartungsrolle liest danach host=${JSON.stringify(afterBoundUpsert?.host)}, updatedAt=${JSON.stringify(afterBoundUpsert?.updatedAt)}; smtp-b bleibt unveraendert: ${JSON.stringify(bUnchanged)}`;
|
||||
} catch (err) {
|
||||
boundUpsertDetail = `gebundenes upsert unter TENANT-A ist fehlgeschlagen: ${err.message}`;
|
||||
}
|
||||
report(
|
||||
results,
|
||||
'smtpconfig-gebundenes-upsert-eigener-mandant-aktualisiert',
|
||||
boundUpsertSucceeded,
|
||||
boundUpsertDetail,
|
||||
);
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Aufgabe 1 (260909-jts), TEIL 2 — misst, welche der drei Transaktionsformen
|
||||
* den Mandantenkontext auf DERSELBEN Verbindung ueber alle Teilschritte
|
||||
@@ -4015,6 +4590,8 @@ async function main() {
|
||||
await runCalendarAreaChecks(adminUrl, scratchRoleUrlString, results);
|
||||
await runTenantAreaChecks(adminUrl, scratchRoleUrlString, results);
|
||||
await runAuthAreaChecks(adminUrl, scratchRoleUrlString, results);
|
||||
await runFavoritesAreaChecks(adminUrl, scratchRoleUrlString, results);
|
||||
await runSettingsAreaChecks(adminUrl, scratchRoleUrlString, results);
|
||||
await runTransactionShapeMeasurement(scratchRoleUrlString, results);
|
||||
await runConcurrencyProbe(scratchRoleUrlString, results);
|
||||
} finally {
|
||||
|
||||
@@ -22,6 +22,16 @@ import { FavoritesService } from './favorites.service';
|
||||
*
|
||||
* All routes are protected by the global JwtAuthGuard + TenantGuard.
|
||||
*
|
||||
* Mandantenquelle (260911-gwh): `extractContext` liest `req.tenantId ??
|
||||
* req.user?.tenantId` — WORTGLEICH mit `dashboard.controller.ts`, unter
|
||||
* dessen Bindung `WidgetInstance` liegt. `FavoriteLink` haengt ueber
|
||||
* `widgetId` an `WidgetInstance`; eine andere Quelle (z. B. das Claim, wie
|
||||
* bei `auth` fuer Selbstbedienung) wuerde Widget und Link unter einem
|
||||
* `x-tenant-id`-Wechsel eines SUPER_ADMIN in verschiedenen Mandanten
|
||||
* auseinanderreissen. Das Favoriten-Frontend sendet die `x-tenant-id`-
|
||||
* Kopfzeile heute nicht — die Entscheidung haengt an der Bauform, nicht am
|
||||
* heutigen Aufrufer.
|
||||
*
|
||||
* Routes:
|
||||
* - GET /favorites?widgetId= — list favorites for a widget instance
|
||||
* - POST /favorites — create a favorite (triggers server-side icon discovery)
|
||||
@@ -52,9 +62,9 @@ export class FavoritesController {
|
||||
@Query('widgetId', ParseUUIDPipe) widgetId: string,
|
||||
@Req() req: Request,
|
||||
) {
|
||||
const { userId } = this.extractContext(req);
|
||||
const { userId, tenantId } = this.extractContext(req);
|
||||
|
||||
return this.favoritesService.list(userId, widgetId);
|
||||
return this.favoritesService.list(tenantId, userId, widgetId);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@@ -64,7 +74,7 @@ export class FavoritesController {
|
||||
) {
|
||||
const { userId, tenantId } = this.extractContext(req);
|
||||
|
||||
return this.favoritesService.create(userId, tenantId, dto);
|
||||
return this.favoritesService.create(tenantId, userId, dto);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -82,8 +92,9 @@ export class FavoritesController {
|
||||
@Req() req: Request,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
const { userId } = this.extractContext(req);
|
||||
const { userId, tenantId } = this.extractContext(req);
|
||||
const { contentType, body } = await this.favoritesService.getIconBytes(
|
||||
tenantId,
|
||||
id,
|
||||
userId,
|
||||
);
|
||||
@@ -99,9 +110,9 @@ export class FavoritesController {
|
||||
@Body() dto: UpdateFavoriteDto,
|
||||
@Req() req: Request,
|
||||
) {
|
||||
const { userId } = this.extractContext(req);
|
||||
const { userId, tenantId } = this.extractContext(req);
|
||||
|
||||
return this.favoritesService.update(id, userId, dto);
|
||||
return this.favoritesService.update(tenantId, id, userId, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@@ -109,8 +120,8 @@ export class FavoritesController {
|
||||
@Param('id') id: string,
|
||||
@Req() req: Request,
|
||||
) {
|
||||
const { userId } = this.extractContext(req);
|
||||
const { userId, tenantId } = this.extractContext(req);
|
||||
|
||||
return this.favoritesService.remove(id, userId);
|
||||
return this.favoritesService.remove(tenantId, id, userId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,506 @@
|
||||
import { BadRequestException, HttpException, NotFoundException } from '@nestjs/common';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { FavoritesService } from './favorites.service';
|
||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||
|
||||
/**
|
||||
* FavoritesService.spec — NEU (260911-gwh). Der Bereich `favorites` hatte
|
||||
* VOR diesem Lauf KEINE Testdatei fuer den Dienst (Befund J; nur
|
||||
* `icon-discovery.service.spec.ts` existierte). Zwei-Klienten-Nachbau
|
||||
* (Muster `dkv.service.spec.ts`/`auth.service.spec.ts`): `forTenant()` wird
|
||||
* auf `unboundClient.__makeBoundClient(tenantId)` umgeleitet. Der
|
||||
* UNGEBUNDENE Nachbau (der Fake selbst) hat KEINES der Anfrage-Modelle
|
||||
* (`favoriteLink`, `widgetInstance`) — ein versehentlich ungebundener
|
||||
* Modellzugriff scheitert mit "Cannot read properties of undefined" (die
|
||||
* dkv-Form der Falsifizierung, siehe auth.service.spec.ts:280). Der
|
||||
* GEBUNDENE Klient hat ausschliesslich `favoriteLink`/`widgetInstance`.
|
||||
*/
|
||||
vi.mock('../prisma/prisma-tenant.extension', () => ({
|
||||
forTenant: vi.fn((unboundClient: any, tenantId: string) => unboundClient.__makeBoundClient(tenantId)),
|
||||
}));
|
||||
|
||||
interface FakeFavoriteRow {
|
||||
id: string;
|
||||
userId: string;
|
||||
tenantId: string;
|
||||
widgetId: string;
|
||||
title: string;
|
||||
url: string;
|
||||
iconUrl: string | null;
|
||||
position: number;
|
||||
createdAt?: Date;
|
||||
updatedAt?: Date;
|
||||
}
|
||||
|
||||
interface FakeWidgetRow {
|
||||
id: string;
|
||||
userId: string;
|
||||
tenantId: string;
|
||||
}
|
||||
|
||||
interface BoundCall {
|
||||
tenantId: string;
|
||||
model: 'favoriteLink' | 'widgetInstance';
|
||||
method: string;
|
||||
}
|
||||
|
||||
function throwP2025(action: 'update' | 'delete'): never {
|
||||
const err: any = new Error(
|
||||
action === 'update'
|
||||
? 'An operation failed because it depends on one or more records that were required but not found. No record was found for an update.'
|
||||
: 'An operation failed because it depends on one or more records that were required but not found. No record was found for a delete.',
|
||||
);
|
||||
err.code = 'P2025';
|
||||
throw err;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zwei-Klienten-Nachbau: `favorites`/`widgets` sind das gemeinsame
|
||||
* Gedaechtnis, der gebundene Klient (`__makeBoundClient`) protokolliert
|
||||
* jeden Zugriff im `boundCallLog` — der ungebundene Basisclient (der Fake
|
||||
* selbst) traegt KEIN `favoriteLink`/`widgetInstance` und protokolliert
|
||||
* deshalb strukturell nie.
|
||||
*/
|
||||
function makeFakePrisma(favoriteRows: FakeFavoriteRow[] = [], widgetRows: FakeWidgetRow[] = []) {
|
||||
const favorites = new Map(favoriteRows.map((f) => [f.id, { ...f }]));
|
||||
const widgets = new Map(widgetRows.map((w) => [w.id, { ...w }]));
|
||||
const boundCallLog: BoundCall[] = [];
|
||||
let autoId = favoriteRows.length;
|
||||
|
||||
function makeScopedFavoriteLink(tenantId: string) {
|
||||
return {
|
||||
findMany: async ({ where, orderBy }: any) => {
|
||||
boundCallLog.push({ tenantId, model: 'favoriteLink', method: 'findMany' });
|
||||
let rows = Array.from(favorites.values()).filter((f) => f.tenantId === tenantId);
|
||||
if (where?.userId) rows = rows.filter((f) => f.userId === where.userId);
|
||||
if (where?.widgetId) rows = rows.filter((f) => f.widgetId === where.widgetId);
|
||||
if (orderBy) {
|
||||
rows = [...rows].sort((a, b) => {
|
||||
for (const clause of orderBy) {
|
||||
const [key, dir] = Object.entries(clause as Record<string, string>)[0];
|
||||
const av = (a as any)[key];
|
||||
const bv = (b as any)[key];
|
||||
if (av < bv) return dir === 'asc' ? -1 : 1;
|
||||
if (av > bv) return dir === 'asc' ? 1 : -1;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
},
|
||||
findUnique: async ({ where }: any) => {
|
||||
boundCallLog.push({ tenantId, model: 'favoriteLink', method: 'findUnique' });
|
||||
const row = favorites.get(where.id);
|
||||
if (!row || row.tenantId !== tenantId) return null;
|
||||
return { ...row };
|
||||
},
|
||||
create: async ({ data }: any) => {
|
||||
boundCallLog.push({ tenantId, model: 'favoriteLink', method: 'create' });
|
||||
const id = data.id ?? `fav-${++autoId}`;
|
||||
const now = new Date();
|
||||
const record = { iconUrl: null, position: 0, createdAt: now, updatedAt: now, ...data, id };
|
||||
favorites.set(id, record);
|
||||
return record;
|
||||
},
|
||||
update: async ({ where, data }: any) => {
|
||||
boundCallLog.push({ tenantId, model: 'favoriteLink', method: 'update' });
|
||||
const row = favorites.get(where.id);
|
||||
if (!row || row.tenantId !== tenantId) throwP2025('update');
|
||||
const updated = { ...row, ...data, updatedAt: new Date() };
|
||||
favorites.set(where.id, updated);
|
||||
return updated;
|
||||
},
|
||||
delete: async ({ where }: any) => {
|
||||
boundCallLog.push({ tenantId, model: 'favoriteLink', method: 'delete' });
|
||||
const row = favorites.get(where.id);
|
||||
if (!row || row.tenantId !== tenantId) throwP2025('delete');
|
||||
favorites.delete(where.id);
|
||||
return row;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeScopedWidgetInstance(tenantId: string) {
|
||||
return {
|
||||
findUnique: async ({ where, select }: any) => {
|
||||
boundCallLog.push({ tenantId, model: 'widgetInstance', method: 'findUnique' });
|
||||
const row = widgets.get(where.id);
|
||||
if (!row || row.tenantId !== tenantId) return null;
|
||||
if (!select) return { ...row };
|
||||
const picked: any = {};
|
||||
for (const key of Object.keys(select)) {
|
||||
if (select[key]) picked[key] = (row as any)[key];
|
||||
}
|
||||
return picked;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const fake: any = {
|
||||
__favorites: favorites,
|
||||
__widgets: widgets,
|
||||
__boundCallLog: boundCallLog,
|
||||
__makeBoundClient(tenantId: string) {
|
||||
return {
|
||||
favoriteLink: makeScopedFavoriteLink(tenantId),
|
||||
widgetInstance: makeScopedWidgetInstance(tenantId),
|
||||
};
|
||||
},
|
||||
};
|
||||
return fake;
|
||||
}
|
||||
|
||||
function expectBoundCall(
|
||||
prisma: any,
|
||||
tenantId: string,
|
||||
model: 'favoriteLink' | 'widgetInstance',
|
||||
method: string,
|
||||
) {
|
||||
const found = prisma.__boundCallLog.some(
|
||||
(c: BoundCall) => c.tenantId === tenantId && c.model === model && c.method === method,
|
||||
);
|
||||
expect(
|
||||
found,
|
||||
`erwarteter gebundener Aufruf ${model}.${method}(tenant=${tenantId}) fehlt im Protokoll: ${JSON.stringify(prisma.__boundCallLog)}`,
|
||||
).toBe(true);
|
||||
}
|
||||
|
||||
function makeIconDiscovery(
|
||||
overrides: Partial<{ discoverFavoriteIconUrl: any; fetchIconBytes: any }> = {},
|
||||
) {
|
||||
return {
|
||||
discoverFavoriteIconUrl:
|
||||
overrides.discoverFavoriteIconUrl ??
|
||||
vi.fn(async (url: string) => `https://icons.invalid/${encodeURIComponent(url)}`),
|
||||
fetchIconBytes:
|
||||
overrides.fetchIconBytes ??
|
||||
vi.fn(async () => ({ contentType: 'image/png', body: Buffer.from('png') })),
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('FavoritesService — Bindung an forTenant() (260911-gwh)', () => {
|
||||
it('scheitert an "Cannot read properties of undefined", wenn ein Favoritenzugriff versehentlich ungebunden auf dem Basisclient laeuft (Falsifizierungsform)', () => {
|
||||
const prisma = makeFakePrisma();
|
||||
expect(prisma.favoriteLink).toBeUndefined();
|
||||
expect(prisma.widgetInstance).toBeUndefined();
|
||||
});
|
||||
|
||||
describe('list', () => {
|
||||
it('liefert nur die Zeilen von user-a1 fuer widget-a1, sortiert nach position, dann title', async () => {
|
||||
const prisma = makeFakePrisma([
|
||||
{ id: 'f1', userId: 'user-a1', tenantId: 't1', widgetId: 'widget-a1', title: 'B', url: 'https://b.invalid', iconUrl: null, position: 1 },
|
||||
{ id: 'f2', userId: 'user-a1', tenantId: 't1', widgetId: 'widget-a1', title: 'A', url: 'https://a.invalid', iconUrl: null, position: 0 },
|
||||
{ id: 'f3', userId: 'user-a2', tenantId: 't1', widgetId: 'widget-a1', title: 'C', url: 'https://c.invalid', iconUrl: null, position: 0 },
|
||||
{ id: 'f4', userId: 'user-a1', tenantId: 't1', widgetId: 'widget-a2', title: 'D', url: 'https://d.invalid', iconUrl: null, position: 0 },
|
||||
]);
|
||||
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
|
||||
|
||||
const result = await service.list('t1', 'user-a1', 'widget-a1');
|
||||
|
||||
expect(result.map((r: any) => r.id)).toEqual(['f2', 'f1']);
|
||||
expectBoundCall(prisma, 't1', 'favoriteLink', 'findMany');
|
||||
});
|
||||
|
||||
it('liefert unter einem FREMDEN Mandanten eine leere Liste, kein Fehler (der Wert, aus dem das Widget "Noch keine Favoriten." macht)', async () => {
|
||||
const prisma = makeFakePrisma([
|
||||
{ id: 'f1', userId: 'user-a1', tenantId: 't1', widgetId: 'widget-a1', title: 'B', url: 'https://b.invalid', iconUrl: null, position: 0 },
|
||||
]);
|
||||
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
|
||||
|
||||
const result = await service.list('t2', 'user-a1', 'widget-a1');
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('wirft BadRequestException ohne widgetId, OHNE einen Klienten zu erzeugen', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
|
||||
|
||||
await expect(service.list('t1', 'user-a1', '')).rejects.toThrow(BadRequestException);
|
||||
expect(vi.mocked(forTenant).mock.calls.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('normalisiert die URL, sucht das Icon mit der NORMALISIERTEN URL, und legt mit tenantId/userId/widgetId/position=0 an, wenn iconUrl fehlt', async () => {
|
||||
const prisma = makeFakePrisma([], [{ id: 'widget-a1', userId: 'user-a1', tenantId: 't1' }]);
|
||||
const iconDiscovery = makeIconDiscovery();
|
||||
const service = new FavoritesService(prisma as any, iconDiscovery as any);
|
||||
|
||||
const created = await service.create('t1', 'user-a1', {
|
||||
widgetId: 'widget-a1',
|
||||
title: 'CTL',
|
||||
url: 'ctl.de',
|
||||
} as any);
|
||||
|
||||
expect(iconDiscovery.discoverFavoriteIconUrl).toHaveBeenCalledWith('https://ctl.de');
|
||||
expect(created.url).toBe('https://ctl.de');
|
||||
expect(created.userId).toBe('user-a1');
|
||||
expect(created.tenantId).toBe('t1');
|
||||
expect(created.position).toBe(0);
|
||||
expectBoundCall(prisma, 't1', 'favoriteLink', 'create');
|
||||
});
|
||||
|
||||
it('sucht KEIN Icon, wenn iconUrl uebergeben wird — gespeicherter Wert bleibt wie uebergeben', async () => {
|
||||
const prisma = makeFakePrisma([], [{ id: 'widget-a1', userId: 'user-a1', tenantId: 't1' }]);
|
||||
const iconDiscovery = makeIconDiscovery();
|
||||
const service = new FavoritesService(prisma as any, iconDiscovery as any);
|
||||
|
||||
const created = await service.create('t1', 'user-a1', {
|
||||
widgetId: 'widget-a1',
|
||||
title: 'CTL',
|
||||
url: 'https://ctl.de',
|
||||
iconUrl: 'https://ctl.de/favicon.ico',
|
||||
} as any);
|
||||
|
||||
expect(iconDiscovery.discoverFavoriteIconUrl).not.toHaveBeenCalled();
|
||||
expect(created.iconUrl).toBe('https://ctl.de/favicon.ico');
|
||||
});
|
||||
|
||||
it('T-GWH-05: widgetId gehoert einem ANDEREN Benutzer desselben Mandanten -> NotFoundException "Widget not found", KEIN create, KEINE Icon-Suche', async () => {
|
||||
const prisma = makeFakePrisma([], [{ id: 'widget-a2', userId: 'user-a2', tenantId: 't1' }]);
|
||||
const iconDiscovery = makeIconDiscovery();
|
||||
const service = new FavoritesService(prisma as any, iconDiscovery as any);
|
||||
|
||||
await expect(
|
||||
service.create('t1', 'user-a1', { widgetId: 'widget-a2', title: 'X', url: 'https://x.invalid' } as any),
|
||||
).rejects.toThrow('Widget not found');
|
||||
expect(iconDiscovery.discoverFavoriteIconUrl).not.toHaveBeenCalled();
|
||||
expect(prisma.__favorites.size).toBe(0);
|
||||
});
|
||||
|
||||
it('T-GWH-05: widgetId gehoert einem Widget unter FREMDEM Mandanten -> dieselbe NotFoundException, nennt weder Halter noch Mandant', async () => {
|
||||
const prisma = makeFakePrisma([], [{ id: 'widget-b1', userId: 'user-b1', tenantId: 't2' }]);
|
||||
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
|
||||
|
||||
await expect(
|
||||
service.create('t1', 'user-a1', { widgetId: 'widget-b1', title: 'X', url: 'https://x.invalid' } as any),
|
||||
).rejects.toThrow(NotFoundException);
|
||||
await expect(
|
||||
service.create('t1', 'user-a1', { widgetId: 'widget-b1', title: 'X', url: 'https://x.invalid' } as any),
|
||||
).rejects.toThrow('Widget not found');
|
||||
});
|
||||
|
||||
it('T-GWH-05: unbekannte widgetId -> dieselbe NotFoundException', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
|
||||
|
||||
await expect(
|
||||
service.create('t1', 'user-a1', { widgetId: 'widget-fehlt', title: 'X', url: 'https://x.invalid' } as any),
|
||||
).rejects.toThrow('Widget not found');
|
||||
});
|
||||
|
||||
it('Wachhund: genau EIN gebundener Klient je create-Aufruf, Widget-Pruefung UND Schreibzugriff auf DEMSELBEN Klienten', async () => {
|
||||
const prisma = makeFakePrisma([], [{ id: 'widget-a1', userId: 'user-a1', tenantId: 't1' }]);
|
||||
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
|
||||
|
||||
vi.mocked(forTenant).mockClear();
|
||||
await service.create('t1', 'user-a1', { widgetId: 'widget-a1', title: 'X', url: 'https://x.invalid' } as any);
|
||||
|
||||
expect(vi.mocked(forTenant).mock.calls.length).toBe(1);
|
||||
expect(prisma.__boundCallLog.filter((c: BoundCall) => c.tenantId === 't1')).toEqual([
|
||||
{ tenantId: 't1', model: 'widgetInstance', method: 'findUnique' },
|
||||
{ tenantId: 't1', model: 'favoriteLink', method: 'create' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
const baseRow: FakeFavoriteRow = {
|
||||
id: 'f1',
|
||||
userId: 'user-a1',
|
||||
tenantId: 't1',
|
||||
widgetId: 'widget-a1',
|
||||
title: 'Alt',
|
||||
url: 'https://alt.invalid',
|
||||
iconUrl: 'https://alt.invalid/icon.png',
|
||||
position: 0,
|
||||
};
|
||||
|
||||
it('mergt Titel/URL/Position fuer die eigene Zeile, ueber DEMSELBEN gebundenen Klienten', async () => {
|
||||
const prisma = makeFakePrisma([baseRow]);
|
||||
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
|
||||
|
||||
const updated = await service.update('t1', 'f1', 'user-a1', { title: 'Neu', position: 3 } as any);
|
||||
|
||||
expect(updated.title).toBe('Neu');
|
||||
expect(updated.position).toBe(3);
|
||||
expectBoundCall(prisma, 't1', 'favoriteLink', 'findUnique');
|
||||
expectBoundCall(prisma, 't1', 'favoriteLink', 'update');
|
||||
});
|
||||
|
||||
it('iconUrl explizit null im DTO loest eine Icon-Suche gegen die EFFEKTIVE URL aus', async () => {
|
||||
const prisma = makeFakePrisma([baseRow]);
|
||||
const iconDiscovery = makeIconDiscovery();
|
||||
const service = new FavoritesService(prisma as any, iconDiscovery as any);
|
||||
|
||||
await service.update('t1', 'f1', 'user-a1', { iconUrl: null } as any);
|
||||
|
||||
expect(iconDiscovery.discoverFavoriteIconUrl).toHaveBeenCalledWith(baseRow.url);
|
||||
});
|
||||
|
||||
it('iconUrl gesetzt im DTO -> KEINE Icon-Suche', async () => {
|
||||
const prisma = makeFakePrisma([baseRow]);
|
||||
const iconDiscovery = makeIconDiscovery();
|
||||
const service = new FavoritesService(prisma as any, iconDiscovery as any);
|
||||
|
||||
const updated = await service.update('t1', 'f1', 'user-a1', { iconUrl: 'https://neu.invalid/icon.png' } as any);
|
||||
|
||||
expect(iconDiscovery.discoverFavoriteIconUrl).not.toHaveBeenCalled();
|
||||
expect(updated.iconUrl).toBe('https://neu.invalid/icon.png');
|
||||
});
|
||||
|
||||
it('Zeile eines ANDEREN Benutzers desselben Mandanten -> NotFoundException, KEIN Schreibzugriff', async () => {
|
||||
const prisma = makeFakePrisma([{ ...baseRow, userId: 'user-a2' }]);
|
||||
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
|
||||
|
||||
await expect(service.update('t1', 'f1', 'user-a1', { title: 'X' } as any)).rejects.toThrow(
|
||||
'FavoriteLink not found',
|
||||
);
|
||||
expect(prisma.__favorites.get('f1')?.title).toBe('Alt');
|
||||
});
|
||||
|
||||
it('Zeile unter FREMDEM Mandanten -> NotFoundException, KEIN Schreibzugriff — der gebundene findUnique liefert null, bevor irgendetwas geschrieben wird', async () => {
|
||||
const prisma = makeFakePrisma([baseRow]);
|
||||
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
|
||||
|
||||
await expect(service.update('t2', 'f1', 'user-a1', { title: 'X' } as any)).rejects.toThrow(
|
||||
NotFoundException,
|
||||
);
|
||||
expect(prisma.__favorites.get('f1')?.title).toBe('Alt');
|
||||
expect(
|
||||
prisma.__boundCallLog.some((c: BoundCall) => c.tenantId === 't2' && c.method === 'update'),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('remove', () => {
|
||||
const baseRow: FakeFavoriteRow = {
|
||||
id: 'f1',
|
||||
userId: 'user-a1',
|
||||
tenantId: 't1',
|
||||
widgetId: 'widget-a1',
|
||||
title: 'X',
|
||||
url: 'https://x.invalid',
|
||||
iconUrl: null,
|
||||
position: 0,
|
||||
};
|
||||
|
||||
it('loescht die eigene Zeile', async () => {
|
||||
const prisma = makeFakePrisma([baseRow]);
|
||||
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
|
||||
|
||||
await service.remove('t1', 'f1', 'user-a1');
|
||||
|
||||
expect(prisma.__favorites.has('f1')).toBe(false);
|
||||
});
|
||||
|
||||
it('fremder Benutzer -> NotFoundException, Zeile bleibt', async () => {
|
||||
const prisma = makeFakePrisma([{ ...baseRow, userId: 'user-a2' }]);
|
||||
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
|
||||
|
||||
await expect(service.remove('t1', 'f1', 'user-a1')).rejects.toThrow('FavoriteLink not found');
|
||||
expect(prisma.__favorites.has('f1')).toBe(true);
|
||||
});
|
||||
|
||||
it('fremder Mandant -> NotFoundException, Zeile bleibt', async () => {
|
||||
const prisma = makeFakePrisma([baseRow]);
|
||||
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
|
||||
|
||||
await expect(service.remove('t2', 'f1', 'user-a1')).rejects.toThrow(NotFoundException);
|
||||
expect(prisma.__favorites.has('f1')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getIconBytes', () => {
|
||||
const baseRow: FakeFavoriteRow = {
|
||||
id: 'f1',
|
||||
userId: 'user-a1',
|
||||
tenantId: 't1',
|
||||
widgetId: 'widget-a1',
|
||||
title: 'X',
|
||||
url: 'https://x.invalid',
|
||||
iconUrl: 'https://x.invalid/icon.png',
|
||||
position: 0,
|
||||
};
|
||||
|
||||
it('ruft fetchIconBytes GENAU mit der GESPEICHERTEN URL auf und reicht die Rueckgabe durch', async () => {
|
||||
const prisma = makeFakePrisma([baseRow]);
|
||||
const iconDiscovery = makeIconDiscovery();
|
||||
const service = new FavoritesService(prisma as any, iconDiscovery as any);
|
||||
|
||||
const result = await service.getIconBytes('t1', 'f1', 'user-a1');
|
||||
|
||||
expect(iconDiscovery.fetchIconBytes).toHaveBeenCalledWith(baseRow.iconUrl);
|
||||
expect(result).toEqual({ contentType: 'image/png', body: Buffer.from('png') });
|
||||
});
|
||||
|
||||
it('Zeile ohne iconUrl -> NotFoundException, fetchIconBytes NICHT aufgerufen', async () => {
|
||||
const prisma = makeFakePrisma([{ ...baseRow, iconUrl: null }]);
|
||||
const iconDiscovery = makeIconDiscovery();
|
||||
const service = new FavoritesService(prisma as any, iconDiscovery as any);
|
||||
|
||||
await expect(service.getIconBytes('t1', 'f1', 'user-a1')).rejects.toThrow('FavoriteLink not found');
|
||||
expect(iconDiscovery.fetchIconBytes).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fremder Mandant -> NotFoundException', async () => {
|
||||
const prisma = makeFakePrisma([baseRow]);
|
||||
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
|
||||
|
||||
await expect(service.getIconBytes('t2', 'f1', 'user-a1')).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('fetchIconBytes wirft -> HttpException mit Status 502', async () => {
|
||||
const prisma = makeFakePrisma([baseRow]);
|
||||
const iconDiscovery = makeIconDiscovery({
|
||||
fetchIconBytes: vi.fn(async () => {
|
||||
throw new Error('upstream unreachable');
|
||||
}),
|
||||
});
|
||||
const service = new FavoritesService(prisma as any, iconDiscovery as any);
|
||||
|
||||
await expect(service.getIconBytes('t1', 'f1', 'user-a1')).rejects.toThrow(HttpException);
|
||||
try {
|
||||
await service.getIconBytes('t1', 'f1', 'user-a1');
|
||||
} catch (err) {
|
||||
expect((err as HttpException).getStatus()).toBe(502);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Wachhund je Methode', () => {
|
||||
it('genau EIN gebundener Klient je Aufruf von list/update/remove/getIconBytes', async () => {
|
||||
const baseRow: FakeFavoriteRow = {
|
||||
id: 'f1',
|
||||
userId: 'user-a1',
|
||||
tenantId: 't1',
|
||||
widgetId: 'widget-a1',
|
||||
title: 'X',
|
||||
url: 'https://x.invalid',
|
||||
iconUrl: 'https://x.invalid/icon.png',
|
||||
position: 0,
|
||||
};
|
||||
const prisma = makeFakePrisma([baseRow]);
|
||||
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
|
||||
|
||||
for (const call of [
|
||||
() => service.list('t1', 'user-a1', 'widget-a1'),
|
||||
() => service.update('t1', 'f1', 'user-a1', { title: 'Y' } as any),
|
||||
() => service.getIconBytes('t1', 'f1', 'user-a1'),
|
||||
() => service.remove('t1', 'f1', 'user-a1'),
|
||||
]) {
|
||||
vi.mocked(forTenant).mockClear();
|
||||
await call().catch(() => undefined);
|
||||
expect(
|
||||
vi.mocked(forTenant).mock.calls.length,
|
||||
`Aufruf erzeugte ${vi.mocked(forTenant).mock.calls.length} gebundene Klienten, erwartet genau 1`,
|
||||
).toBe(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||
import { CreateFavoriteDto } from './dto/create-favorite.dto';
|
||||
import { UpdateFavoriteDto } from './dto/update-favorite.dto';
|
||||
import { IconDiscoveryService, normalizeUrl } from './icon-discovery.service';
|
||||
@@ -13,10 +14,35 @@ import { IconDiscoveryService, normalizeUrl } from './icon-discovery.service';
|
||||
/**
|
||||
* Service for managing per-user, per-widget favorite links.
|
||||
*
|
||||
* Mandantengebunden (260911-gwh): jede Methode nimmt `tenantId` als ERSTEN
|
||||
* Parameter und laeuft ueber GENAU EINEN Klienten `tenantPrisma` — der
|
||||
* Mandant kommt aus `extractContext` im Controller, DERSELBEN Quelle wie
|
||||
* `dashboard.controller.ts` (nicht dem auth-Praezedenzfall/Claim): der Link
|
||||
* haengt ueber `widgetId` an `WidgetInstance`, und `WidgetInstance` ist
|
||||
* unter der dashboard-Mandantenquelle gebunden. Eine abweichende Quelle
|
||||
* wuerde Widget und Link unter einem `x-tenant-id`-Wechsel eines
|
||||
* SUPER_ADMIN in verschiedenen Mandanten auseinanderreissen.
|
||||
*
|
||||
* Die Regel auf `FavoriteLink` kennt KEINE Benutzerdimension (260911-gwh,
|
||||
* Aufgabe 1, Pruefung 4 — dieselbe Lehre wie `CalendarSource`/
|
||||
* `DashboardLayout`/`WidgetInstance`) — die `userId`-Filter unten bleiben
|
||||
* deshalb der einzige Schutz gegen Quer-Lesen zwischen Nutzern DESSELBEN
|
||||
* Mandanten (Etappe-3-Entscheidung (2) traegt das nach).
|
||||
*
|
||||
* Access control (T-08-06 / Pitfall 3):
|
||||
* - Every query is scoped by userId (prevents cross-user access).
|
||||
* - list() additionally scopes by widgetId so each widget instance has its own set.
|
||||
* - update() and remove() verify userId ownership before mutating.
|
||||
*
|
||||
* `create()` prueft zusaetzlich, dass das Ziel-Widget dem Aufrufer gehoert
|
||||
* (T-GWH-05): der Fremdschluessel `FavoriteLink.widgetId` prueft an der
|
||||
* Zeilenschutz-Regel von `WidgetInstance` VORBEI (dokumentiertes
|
||||
* PostgreSQL-Verhalten, gemessen in Aufgabe 1, Pruefung 7) — ohne den
|
||||
* Riegel waere der Unterschied zwischen "Widget existiert nicht" (500) und
|
||||
* "gehoert einem fremden Mandanten" (gelingt) ein Existenzorakel ueber
|
||||
* Mandantengrenzen. Der Riegel antwortet fuer alle drei Faelle
|
||||
* ("existiert nicht", "gehoert einem Kollegen", "liegt bei einem fremden
|
||||
* Mandanten") mit derselben `NotFoundException('Widget not found')`.
|
||||
*/
|
||||
@Injectable()
|
||||
export class FavoritesService {
|
||||
@@ -29,9 +55,11 @@ export class FavoritesService {
|
||||
* Returns all favorites for a user's widget instance, ordered by position asc.
|
||||
* Scoped by userId AND widgetId (Pitfall 3 — separate widgets must not share links).
|
||||
*/
|
||||
async list(userId: string, widgetId: string) {
|
||||
async list(tenantId: string, userId: string, widgetId: string) {
|
||||
if (!widgetId) throw new BadRequestException('widgetId is required');
|
||||
return this.prisma.favoriteLink.findMany({
|
||||
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
return tenantPrisma.favoriteLink.findMany({
|
||||
where: { userId, widgetId },
|
||||
orderBy: [{ position: 'asc' }, { title: 'asc' }],
|
||||
});
|
||||
@@ -39,9 +67,26 @@ export class FavoritesService {
|
||||
|
||||
/**
|
||||
* Creates a new favorite link.
|
||||
* Verifies the target widget belongs to the caller BEFORE any icon
|
||||
* discovery network call (T-GWH-05).
|
||||
* If iconUrl is not provided, triggers server-side icon discovery with SSRF protection.
|
||||
*/
|
||||
async create(userId: string, tenantId: string, dto: CreateFavoriteDto) {
|
||||
async create(tenantId: string, userId: string, dto: CreateFavoriteDto) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
|
||||
// T-GWH-05: der Fremdschluessel prueft an der Zeilenschutz-Regel von
|
||||
// WidgetInstance vorbei (Aufgabe 1, Pruefung 7) — ohne diesen Riegel
|
||||
// wuerde ein gebundenes create mit einer fremdmandantigen widgetId
|
||||
// gelingen. Eine Antwort fuer alle drei Faelle: existiert nicht,
|
||||
// gehoert einem Kollegen, liegt bei einem fremden Mandanten.
|
||||
const widget = await tenantPrisma.widgetInstance.findUnique({
|
||||
where: { id: dto.widgetId },
|
||||
select: { userId: true },
|
||||
});
|
||||
if (!widget || widget.userId !== userId) {
|
||||
throw new NotFoundException('Widget not found');
|
||||
}
|
||||
|
||||
// Normalize so a scheme-less entry like "ctl.de" is stored (and discovered)
|
||||
// as "https://ctl.de" — otherwise the link and icon discovery both break.
|
||||
const url = normalizeUrl(dto.url);
|
||||
@@ -52,7 +97,7 @@ export class FavoritesService {
|
||||
iconUrl = await this.iconDiscovery.discoverFavoriteIconUrl(url);
|
||||
}
|
||||
|
||||
return this.prisma.favoriteLink.create({
|
||||
return tenantPrisma.favoriteLink.create({
|
||||
data: {
|
||||
userId,
|
||||
tenantId,
|
||||
@@ -70,8 +115,9 @@ export class FavoritesService {
|
||||
* Verifies userId ownership before applying changes (T-08-06).
|
||||
* Accepts null as an explicit value for iconUrl (clears stored icon).
|
||||
*/
|
||||
async update(id: string, userId: string, dto: UpdateFavoriteDto) {
|
||||
const link = await this.prisma.favoriteLink.findUnique({ where: { id } });
|
||||
async update(tenantId: string, id: string, userId: string, dto: UpdateFavoriteDto) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const link = await tenantPrisma.favoriteLink.findUnique({ where: { id } });
|
||||
|
||||
if (!link || link.userId !== userId) {
|
||||
throw new NotFoundException('FavoriteLink not found');
|
||||
@@ -100,7 +146,7 @@ export class FavoritesService {
|
||||
}
|
||||
}
|
||||
|
||||
return this.prisma.favoriteLink.update({
|
||||
return tenantPrisma.favoriteLink.update({
|
||||
where: { id },
|
||||
data,
|
||||
});
|
||||
@@ -110,14 +156,15 @@ export class FavoritesService {
|
||||
* Deletes a favorite link.
|
||||
* Verifies userId ownership before deleting (T-08-06).
|
||||
*/
|
||||
async remove(id: string, userId: string) {
|
||||
const link = await this.prisma.favoriteLink.findUnique({ where: { id } });
|
||||
async remove(tenantId: string, id: string, userId: string) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const link = await tenantPrisma.favoriteLink.findUnique({ where: { id } });
|
||||
|
||||
if (!link || link.userId !== userId) {
|
||||
throw new NotFoundException('FavoriteLink not found');
|
||||
}
|
||||
|
||||
await this.prisma.favoriteLink.delete({ where: { id } });
|
||||
await tenantPrisma.favoriteLink.delete({ where: { id } });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -132,10 +179,12 @@ export class FavoritesService {
|
||||
* SSRF-blocked) -- never returns a placeholder image.
|
||||
*/
|
||||
async getIconBytes(
|
||||
tenantId: string,
|
||||
id: string,
|
||||
userId: string,
|
||||
): Promise<{ contentType: string; body: Buffer }> {
|
||||
const link = await this.prisma.favoriteLink.findUnique({ where: { id } });
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const link = await tenantPrisma.favoriteLink.findUnique({ where: { id } });
|
||||
|
||||
if (!link || link.userId !== userId || !link.iconUrl) {
|
||||
throw new NotFoundException('FavoriteLink not found');
|
||||
|
||||
@@ -12,13 +12,19 @@ import { MailService } from './mail.service';
|
||||
* with an env-var fallback (priority 2) when no DB row exists.
|
||||
*
|
||||
* Transport priority:
|
||||
* 1. DB SmtpConfig (first row — single-tenant default; set via /settings/smtp)
|
||||
* 1. DB SmtpConfig (loadAnySmtpConfigForStartupTransport — bewusst
|
||||
* UNGEBUNDEN, sechster Fall der Hintergrunddienst-Falle, 260911-gwh;
|
||||
* siehe deren Kopfkommentar in settings.service.ts fuer beide
|
||||
* Zustaende: HEUTE zieht sie den Server EINES beliebigen Mandanten fuer
|
||||
* alle Systemmails [T-GWH-03], NACH DEM SCHARFSCHALTEN liefert sie
|
||||
* `null` und diese Rueckfallkette greift — WINDOWS #30)
|
||||
* 2. Env vars: MAIL_HOST / MAIL_PORT / MAIL_USER / MAIL_PASS
|
||||
* 3. Legacy env vars: TESSERA_SMTP_HOST / TESSERA_SMTP_PORT / TESSERA_SMTP_USER / TESSERA_SMTP_PASSWORD
|
||||
* 4. Final hardcoded fallback: localhost:1025 (Mailhog / dev default)
|
||||
*
|
||||
* The factory is async because getStartupSmtpConfig() reads from the DB.
|
||||
* No circular import risk: MailModule → SettingsModule → CalendarModule (no reverse edges).
|
||||
* The factory is async because loadAnySmtpConfigForStartupTransport() reads
|
||||
* from the DB. No circular import risk: MailModule → SettingsModule →
|
||||
* CalendarModule (no reverse edges).
|
||||
*/
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -26,8 +32,9 @@ import { MailService } from './mail.service';
|
||||
MailerModule.forRootAsync({
|
||||
imports: [SettingsModule],
|
||||
useFactory: async (settingsService: SettingsService, configService: ConfigService) => {
|
||||
// Priority 1: DB SmtpConfig (getStartupSmtpConfig uses findFirst — single-tenant default)
|
||||
const db = await settingsService.getStartupSmtpConfig();
|
||||
// Priority 1: DB SmtpConfig — loadAnySmtpConfigForStartupTransport()
|
||||
// stays bewusst UNGEBUNDEN (findFirst, no tenant context at boot).
|
||||
const db = await settingsService.loadAnySmtpConfigForStartupTransport();
|
||||
|
||||
if (db) {
|
||||
// T-07-11: DB password used only to build transport; never logged
|
||||
|
||||
@@ -0,0 +1,566 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import * as nodemailer from 'nodemailer';
|
||||
import { SettingsService } from './settings.service';
|
||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||
|
||||
/**
|
||||
* SettingsService.spec — NEU (260911-gwh). Der Bereich `settings` hatte VOR
|
||||
* diesem Lauf KEINE Testdatei (Befund J). Zwei-Klienten-Nachbau, aber mit
|
||||
* einer GRENZE als Bauform (anders als `favorites`): der UNGEBUNDENE Nachbau
|
||||
* bietet fuer `smtpConfig` AUSSCHLIESSLICH `findFirst` (der Startpfad) —
|
||||
* KEIN `findUnique`, KEIN `upsert`; der GEBUNDENE Klient bietet
|
||||
* AUSSCHLIESSLICH `findUnique`/`upsert` — KEIN `findFirst`. Ein gebundener
|
||||
* Startpfad scheitert damit ebenso hart wie ein ungebundener Anfrageweg
|
||||
* ("X is not a function" statt eines stillen Fallbacks).
|
||||
*
|
||||
* `nodemailer` wird per `vi.mock` ersetzt — kein echter Transport (lokal
|
||||
* gibt es keinen `mailhog`).
|
||||
*/
|
||||
vi.mock('../prisma/prisma-tenant.extension', () => ({
|
||||
forTenant: vi.fn((unboundClient: any, tenantId: string) => unboundClient.__makeBoundClient(tenantId)),
|
||||
}));
|
||||
|
||||
let mockVerify = vi.fn(async () => true);
|
||||
let mockSendMail = vi.fn(async () => ({}));
|
||||
vi.mock('nodemailer', () => ({
|
||||
createTransport: vi.fn(() => ({
|
||||
verify: (...args: unknown[]) => (mockVerify as any)(...args),
|
||||
sendMail: (...args: unknown[]) => (mockSendMail as any)(...args),
|
||||
})),
|
||||
}));
|
||||
|
||||
interface FakeSmtpRow {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
host: string;
|
||||
port: number;
|
||||
encryption: string;
|
||||
username: string | null;
|
||||
encryptedPassword: string | null;
|
||||
fromAddress: string;
|
||||
createdAt?: Date;
|
||||
updatedAt?: Date;
|
||||
}
|
||||
|
||||
interface BoundCall {
|
||||
tenantId: string;
|
||||
model: 'smtpConfig';
|
||||
method: string;
|
||||
}
|
||||
|
||||
function applySelect(row: Record<string, unknown>, select?: Record<string, boolean>) {
|
||||
if (!select) return { ...row };
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const key of Object.keys(select)) {
|
||||
if (select[key]) out[key] = row[key];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function makeFakePrisma(rows: FakeSmtpRow[] = []) {
|
||||
const configs = new Map(rows.map((r) => [r.tenantId, { ...r }]));
|
||||
const boundCallLog: BoundCall[] = [];
|
||||
let autoId = rows.length;
|
||||
|
||||
const unboundSmtpConfig = {
|
||||
findFirst: vi.fn(async () => {
|
||||
const first = configs.values().next().value;
|
||||
return first ? { ...first } : null;
|
||||
}),
|
||||
};
|
||||
|
||||
function makeScopedSmtpConfig(tenantId: string) {
|
||||
return {
|
||||
findUnique: async ({ where, select }: any) => {
|
||||
boundCallLog.push({ tenantId, model: 'smtpConfig', method: 'findUnique' });
|
||||
const row = configs.get(where.tenantId);
|
||||
if (!row || row.tenantId !== tenantId) return null;
|
||||
return applySelect(row, select);
|
||||
},
|
||||
upsert: async ({ where, create, update, select }: any) => {
|
||||
boundCallLog.push({ tenantId, model: 'smtpConfig', method: 'upsert' });
|
||||
if (where.tenantId !== tenantId) return null;
|
||||
const existing = configs.get(tenantId);
|
||||
const record = existing
|
||||
? { ...existing, ...update }
|
||||
: { id: `smtp-${++autoId}`, createdAt: new Date(), ...create };
|
||||
record.updatedAt = new Date();
|
||||
configs.set(tenantId, record);
|
||||
return applySelect(record, select);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const fake: any = {
|
||||
smtpConfig: unboundSmtpConfig,
|
||||
__configs: configs,
|
||||
__boundCallLog: boundCallLog,
|
||||
__makeBoundClient(tenantId: string) {
|
||||
return { smtpConfig: makeScopedSmtpConfig(tenantId) };
|
||||
},
|
||||
};
|
||||
return fake;
|
||||
}
|
||||
|
||||
function expectBoundCall(prisma: any, tenantId: string, method: string) {
|
||||
const found = prisma.__boundCallLog.some(
|
||||
(c: BoundCall) => c.tenantId === tenantId && c.model === 'smtpConfig' && c.method === method,
|
||||
);
|
||||
expect(
|
||||
found,
|
||||
`erwarteter gebundener Aufruf smtpConfig.${method}(tenant=${tenantId}) fehlt im Protokoll: ${JSON.stringify(prisma.__boundCallLog)}`,
|
||||
).toBe(true);
|
||||
}
|
||||
|
||||
function makeFakeCrypto() {
|
||||
return {
|
||||
encrypt: vi.fn((s: string) => `enc(${s})`),
|
||||
decrypt: vi.fn((s: string) => s.slice(4, -1)),
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockVerify = vi.fn(async () => true);
|
||||
mockSendMail = vi.fn(async () => ({}));
|
||||
});
|
||||
|
||||
describe('SettingsService — Bindung an forTenant() (260911-gwh)', () => {
|
||||
it('scheitert an "X is not a function", wenn getSmtpConfig versehentlich ungebunden auf dem Basisclient laeuft (Falsifizierungsform)', () => {
|
||||
const prisma = makeFakePrisma();
|
||||
expect((prisma.smtpConfig as any).findUnique).toBeUndefined();
|
||||
expect((prisma.smtpConfig as any).upsert).toBeUndefined();
|
||||
});
|
||||
|
||||
describe('getSmtpConfig', () => {
|
||||
it('liefert die Zeile mit encryptedPassword (fuer hasPassword), ohne Felder ausserhalb des select', async () => {
|
||||
const prisma = makeFakePrisma([
|
||||
{
|
||||
id: 'smtp-a',
|
||||
tenantId: 't1',
|
||||
host: 'smtp-a.example.invalid',
|
||||
port: 587,
|
||||
encryption: 'starttls',
|
||||
username: 'user-a',
|
||||
encryptedPassword: 'enc(geheim)',
|
||||
fromAddress: 'a@example.invalid',
|
||||
},
|
||||
]);
|
||||
const crypto = makeFakeCrypto();
|
||||
const service = new SettingsService(prisma as any, crypto as any);
|
||||
|
||||
const result = await service.getSmtpConfig('t1');
|
||||
|
||||
expect(result?.encryptedPassword).toBe('enc(geheim)');
|
||||
expect(result?.host).toBe('smtp-a.example.invalid');
|
||||
expectBoundCall(prisma, 't1', 'findUnique');
|
||||
});
|
||||
|
||||
it('fremder Mandant: null', async () => {
|
||||
const prisma = makeFakePrisma([
|
||||
{
|
||||
id: 'smtp-a',
|
||||
tenantId: 't1',
|
||||
host: 'smtp-a.example.invalid',
|
||||
port: 587,
|
||||
encryption: 'starttls',
|
||||
username: null,
|
||||
encryptedPassword: null,
|
||||
fromAddress: 'a@example.invalid',
|
||||
},
|
||||
]);
|
||||
const service = new SettingsService(prisma as any, makeFakeCrypto() as any);
|
||||
|
||||
const result = await service.getSmtpConfig('t2');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('saveSmtpConfig', () => {
|
||||
it('mit Kennwort: encrypt wird GENAU mit dem Klartext aufgerufen, upsert traegt encryptedPassword, Rueckgabe OHNE encryptedPassword', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const crypto = makeFakeCrypto();
|
||||
const service = new SettingsService(prisma as any, crypto as any);
|
||||
|
||||
const result = await service.saveSmtpConfig('t1', {
|
||||
host: 'smtp-a.example.invalid',
|
||||
port: 587,
|
||||
encryption: 'starttls',
|
||||
username: 'user-a',
|
||||
password: 'klartext-geheim',
|
||||
fromAddress: 'a@example.invalid',
|
||||
} as any);
|
||||
|
||||
expect(crypto.encrypt).toHaveBeenCalledWith('klartext-geheim');
|
||||
expect((result as any).encryptedPassword).toBeUndefined();
|
||||
const stored = prisma.__configs.get('t1');
|
||||
expect(stored.encryptedPassword).toBe('enc(klartext-geheim)');
|
||||
expectBoundCall(prisma, 't1', 'upsert');
|
||||
const logged = JSON.stringify(prisma.__boundCallLog);
|
||||
expect(logged).not.toContain('klartext-geheim');
|
||||
});
|
||||
|
||||
it('ohne Kennwort (leer oder fehlend): encrypt NICHT aufgerufen, update traegt KEINEN Schluessel encryptedPassword, username fehlend -> null', async () => {
|
||||
const prisma = makeFakePrisma([
|
||||
{
|
||||
id: 'smtp-a',
|
||||
tenantId: 't1',
|
||||
host: 'alt.example.invalid',
|
||||
port: 587,
|
||||
encryption: 'starttls',
|
||||
username: 'alt-user',
|
||||
encryptedPassword: 'enc(alt-geheim)',
|
||||
fromAddress: 'a@example.invalid',
|
||||
},
|
||||
]);
|
||||
const crypto = makeFakeCrypto();
|
||||
const service = new SettingsService(prisma as any, crypto as any);
|
||||
|
||||
await service.saveSmtpConfig('t1', {
|
||||
host: 'neu.example.invalid',
|
||||
port: 587,
|
||||
encryption: 'starttls',
|
||||
password: '',
|
||||
fromAddress: 'a@example.invalid',
|
||||
} as any);
|
||||
|
||||
expect(crypto.encrypt).not.toHaveBeenCalled();
|
||||
const stored = prisma.__configs.get('t1');
|
||||
expect(stored.encryptedPassword).toBe('enc(alt-geheim)'); // bestehendes bleibt
|
||||
expect(stored.username).toBeNull();
|
||||
});
|
||||
|
||||
it('unter t2, wenn nur t1 eine Zeile hat: legt fuer t2 an, t1 bleibt unveraendert (Semantik nach der Bindung)', async () => {
|
||||
const prisma = makeFakePrisma([
|
||||
{
|
||||
id: 'smtp-a',
|
||||
tenantId: 't1',
|
||||
host: 'a.example.invalid',
|
||||
port: 587,
|
||||
encryption: 'starttls',
|
||||
username: null,
|
||||
encryptedPassword: null,
|
||||
fromAddress: 'a@example.invalid',
|
||||
},
|
||||
]);
|
||||
const service = new SettingsService(prisma as any, makeFakeCrypto() as any);
|
||||
|
||||
await service.saveSmtpConfig('t2', {
|
||||
host: 'b.example.invalid',
|
||||
port: 587,
|
||||
encryption: 'starttls',
|
||||
fromAddress: 'b@example.invalid',
|
||||
} as any);
|
||||
|
||||
expect(prisma.__configs.get('t1').host).toBe('a.example.invalid');
|
||||
expect(prisma.__configs.get('t2').host).toBe('b.example.invalid');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDecryptedSmtpConfig', () => {
|
||||
it('entschluesselt encryptedPassword, liefert die Form die tender-mail.service.ts erwartet', async () => {
|
||||
const prisma = makeFakePrisma([
|
||||
{
|
||||
id: 'smtp-a',
|
||||
tenantId: 't1',
|
||||
host: 'smtp-a.example.invalid',
|
||||
port: 587,
|
||||
encryption: 'starttls',
|
||||
username: 'user-a',
|
||||
encryptedPassword: 'enc(geheim)',
|
||||
fromAddress: 'a@example.invalid',
|
||||
},
|
||||
]);
|
||||
const crypto = makeFakeCrypto();
|
||||
const service = new SettingsService(prisma as any, crypto as any);
|
||||
|
||||
const result = await service.getDecryptedSmtpConfig('t1');
|
||||
|
||||
expect(crypto.decrypt).toHaveBeenCalledWith('enc(geheim)');
|
||||
expect(result).toEqual({
|
||||
host: 'smtp-a.example.invalid',
|
||||
port: 587,
|
||||
encryption: 'starttls',
|
||||
username: 'user-a',
|
||||
fromAddress: 'a@example.invalid',
|
||||
decryptedPassword: 'geheim',
|
||||
});
|
||||
});
|
||||
|
||||
it('ohne encryptedPassword: decryptedPassword null, decrypt NICHT aufgerufen', async () => {
|
||||
const prisma = makeFakePrisma([
|
||||
{
|
||||
id: 'smtp-a',
|
||||
tenantId: 't1',
|
||||
host: 'smtp-a.example.invalid',
|
||||
port: 587,
|
||||
encryption: 'starttls',
|
||||
username: null,
|
||||
encryptedPassword: null,
|
||||
fromAddress: 'a@example.invalid',
|
||||
},
|
||||
]);
|
||||
const crypto = makeFakeCrypto();
|
||||
const service = new SettingsService(prisma as any, crypto as any);
|
||||
|
||||
const result = await service.getDecryptedSmtpConfig('t1');
|
||||
|
||||
expect(result?.decryptedPassword).toBeNull();
|
||||
expect(crypto.decrypt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('T-GWH-01: fremder Mandant -> null, decrypt NICHT aufgerufen', async () => {
|
||||
const prisma = makeFakePrisma([
|
||||
{
|
||||
id: 'smtp-a',
|
||||
tenantId: 't1',
|
||||
host: 'smtp-a.example.invalid',
|
||||
port: 587,
|
||||
encryption: 'starttls',
|
||||
username: 'user-a',
|
||||
encryptedPassword: 'enc(geheim)',
|
||||
fromAddress: 'a@example.invalid',
|
||||
},
|
||||
]);
|
||||
const crypto = makeFakeCrypto();
|
||||
const service = new SettingsService(prisma as any, crypto as any);
|
||||
|
||||
const result = await service.getDecryptedSmtpConfig('t2');
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(crypto.decrypt).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('testSmtpConfig', () => {
|
||||
const storedRow: FakeSmtpRow = {
|
||||
id: 'smtp-a',
|
||||
tenantId: 't1',
|
||||
host: 'smtp-a.example.invalid',
|
||||
port: 587,
|
||||
encryption: 'starttls',
|
||||
username: 'user-a',
|
||||
encryptedPassword: 'enc(geheim)',
|
||||
fromAddress: 'a@example.invalid',
|
||||
};
|
||||
|
||||
it('ohne Kennwort/Benutzername im DTO: greift auf die gespeicherten Werte zurueck, ruft verify auf, { success: true }', async () => {
|
||||
const prisma = makeFakePrisma([storedRow]);
|
||||
const service = new SettingsService(prisma as any, makeFakeCrypto() as any);
|
||||
|
||||
const result = await service.testSmtpConfig('t1', {
|
||||
host: 'smtp-a.example.invalid',
|
||||
port: 587,
|
||||
encryption: 'starttls',
|
||||
fromAddress: 'a@example.invalid',
|
||||
} as any);
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
expect(mockVerify).toHaveBeenCalled();
|
||||
expect(mockSendMail).not.toHaveBeenCalled();
|
||||
expect(nodemailer.createTransport).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ auth: { user: 'user-a', pass: 'geheim' } }),
|
||||
);
|
||||
});
|
||||
|
||||
it('mit testTo: sendMail statt verify, from/to aus dto', async () => {
|
||||
const prisma = makeFakePrisma([storedRow]);
|
||||
const service = new SettingsService(prisma as any, makeFakeCrypto() as any);
|
||||
|
||||
await service.testSmtpConfig('t1', {
|
||||
host: 'smtp-a.example.invalid',
|
||||
port: 587,
|
||||
encryption: 'starttls',
|
||||
fromAddress: 'a@example.invalid',
|
||||
testTo: 'ziel@example.invalid',
|
||||
} as any);
|
||||
|
||||
expect(mockSendMail).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ from: 'a@example.invalid', to: 'ziel@example.invalid' }),
|
||||
);
|
||||
expect(mockVerify).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('verify wirft -> { success: false }, kein Wurf nach aussen', async () => {
|
||||
mockVerify = vi.fn(async () => {
|
||||
throw new Error('ECONNREFUSED');
|
||||
});
|
||||
const prisma = makeFakePrisma([storedRow]);
|
||||
const service = new SettingsService(prisma as any, makeFakeCrypto() as any);
|
||||
|
||||
const result = await service.testSmtpConfig('t1', {
|
||||
host: 'smtp-a.example.invalid',
|
||||
port: 587,
|
||||
encryption: 'starttls',
|
||||
fromAddress: 'a@example.invalid',
|
||||
} as any);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('fremder Mandant ohne Kennwort im DTO: auth ohne Benutzer, kein Fehler', async () => {
|
||||
const prisma = makeFakePrisma([storedRow]);
|
||||
const service = new SettingsService(prisma as any, makeFakeCrypto() as any);
|
||||
|
||||
await service.testSmtpConfig('t2', {
|
||||
host: 'x.example.invalid',
|
||||
port: 587,
|
||||
encryption: 'starttls',
|
||||
fromAddress: 'x@example.invalid',
|
||||
} as any);
|
||||
|
||||
expect(nodemailer.createTransport).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ auth: undefined }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadAnySmtpConfigForStartupTransport (Startpfad, bewusst ungebunden)', () => {
|
||||
it('laeuft ueber den UNGEBUNDENEN Nachbau (findFirst), liefert secure/requireTLS/entschluesseltes Kennwort', async () => {
|
||||
const prisma = makeFakePrisma([
|
||||
{
|
||||
id: 'smtp-a',
|
||||
tenantId: 't1',
|
||||
host: 'smtp-a.example.invalid',
|
||||
port: 465,
|
||||
encryption: 'ssl-tls',
|
||||
username: 'user-a',
|
||||
encryptedPassword: 'enc(geheim)',
|
||||
fromAddress: 'a@example.invalid',
|
||||
},
|
||||
]);
|
||||
const crypto = makeFakeCrypto();
|
||||
const service = new SettingsService(prisma as any, crypto as any);
|
||||
|
||||
const result = await service.loadAnySmtpConfigForStartupTransport();
|
||||
|
||||
expect(result).toEqual({
|
||||
host: 'smtp-a.example.invalid',
|
||||
port: 465,
|
||||
secure: true,
|
||||
requireTLS: false,
|
||||
username: 'user-a',
|
||||
password: 'geheim',
|
||||
fromAddress: 'a@example.invalid',
|
||||
});
|
||||
});
|
||||
|
||||
it('requireTLS bei starttls', async () => {
|
||||
const prisma = makeFakePrisma([
|
||||
{
|
||||
id: 'smtp-a',
|
||||
tenantId: 't1',
|
||||
host: 'smtp-a.example.invalid',
|
||||
port: 587,
|
||||
encryption: 'starttls',
|
||||
username: null,
|
||||
encryptedPassword: null,
|
||||
fromAddress: 'a@example.invalid',
|
||||
},
|
||||
]);
|
||||
const service = new SettingsService(prisma as any, makeFakeCrypto() as any);
|
||||
|
||||
const result = await service.loadAnySmtpConfigForStartupTransport();
|
||||
|
||||
expect(result?.secure).toBe(false);
|
||||
expect(result?.requireTLS).toBe(true);
|
||||
});
|
||||
|
||||
it('leerer Nachbau -> null', async () => {
|
||||
const prisma = makeFakePrisma([]);
|
||||
const service = new SettingsService(prisma as any, makeFakeCrypto() as any);
|
||||
|
||||
const result = await service.loadAnySmtpConfigForStartupTransport();
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('Null-Klienten-Nachweis: der Startpfad erzeugt KEINEN gebundenen Klienten (gemessen, nicht behauptet)', async () => {
|
||||
const prisma = makeFakePrisma([
|
||||
{
|
||||
id: 'smtp-a',
|
||||
tenantId: 't1',
|
||||
host: 'smtp-a.example.invalid',
|
||||
port: 587,
|
||||
encryption: 'starttls',
|
||||
username: null,
|
||||
encryptedPassword: null,
|
||||
fromAddress: 'a@example.invalid',
|
||||
},
|
||||
]);
|
||||
const service = new SettingsService(prisma as any, makeFakeCrypto() as any);
|
||||
|
||||
vi.mocked(forTenant).mockClear();
|
||||
await service.loadAnySmtpConfigForStartupTransport();
|
||||
|
||||
expect(vi.mocked(forTenant).mock.calls.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Wachhund je Anfrageweg', () => {
|
||||
const storedRow: FakeSmtpRow = {
|
||||
id: 'smtp-a',
|
||||
tenantId: 't1',
|
||||
host: 'smtp-a.example.invalid',
|
||||
port: 587,
|
||||
encryption: 'starttls',
|
||||
username: 'user-a',
|
||||
encryptedPassword: 'enc(geheim)',
|
||||
fromAddress: 'a@example.invalid',
|
||||
};
|
||||
|
||||
it('genau EIN gebundener Klient je Aufruf von getSmtpConfig/saveSmtpConfig/getDecryptedSmtpConfig', async () => {
|
||||
const prisma = makeFakePrisma([storedRow]);
|
||||
const service = new SettingsService(prisma as any, makeFakeCrypto() as any);
|
||||
|
||||
for (const call of [
|
||||
() => service.getSmtpConfig('t1'),
|
||||
() =>
|
||||
service.saveSmtpConfig('t1', {
|
||||
host: 'x.invalid',
|
||||
port: 587,
|
||||
encryption: 'starttls',
|
||||
fromAddress: 'x@invalid.de',
|
||||
} as any),
|
||||
() => service.getDecryptedSmtpConfig('t1'),
|
||||
]) {
|
||||
vi.mocked(forTenant).mockClear();
|
||||
await call();
|
||||
expect(vi.mocked(forTenant).mock.calls.length).toBe(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('testSmtpConfig erzeugt genau einen gebundenen Klienten, wenn es auf gespeicherte Zugangsdaten zurueckgreift', async () => {
|
||||
const prisma = makeFakePrisma([storedRow]);
|
||||
const service = new SettingsService(prisma as any, makeFakeCrypto() as any);
|
||||
|
||||
vi.mocked(forTenant).mockClear();
|
||||
await service.testSmtpConfig('t1', {
|
||||
host: 'smtp-a.example.invalid',
|
||||
port: 587,
|
||||
encryption: 'starttls',
|
||||
fromAddress: 'a@example.invalid',
|
||||
} as any);
|
||||
|
||||
expect(vi.mocked(forTenant).mock.calls.length).toBe(1);
|
||||
});
|
||||
|
||||
it('testSmtpConfig erzeugt KEINEN gebundenen Klienten, wenn Kennwort und Benutzername im DTO stehen', async () => {
|
||||
const prisma = makeFakePrisma([storedRow]);
|
||||
const service = new SettingsService(prisma as any, makeFakeCrypto() as any);
|
||||
|
||||
vi.mocked(forTenant).mockClear();
|
||||
await service.testSmtpConfig('t1', {
|
||||
host: 'smtp-a.example.invalid',
|
||||
port: 587,
|
||||
encryption: 'starttls',
|
||||
username: 'anderer-user',
|
||||
password: 'anderes-kennwort',
|
||||
fromAddress: 'a@example.invalid',
|
||||
} as any);
|
||||
|
||||
expect(vi.mocked(forTenant).mock.calls.length).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
|
||||
import { CryptoService } from '../crypto/crypto.service';
|
||||
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||
import { SmtpConfigDto } from './dto/smtp-config.dto';
|
||||
import * as nodemailer from 'nodemailer';
|
||||
|
||||
@@ -34,9 +35,13 @@ export class SettingsService {
|
||||
/**
|
||||
* Get the SMTP config for a tenant — safe (no password field).
|
||||
* Returns null when no config row exists for the tenant.
|
||||
*
|
||||
* Mandantengebunden (260911-gwh): EIN Klient `tenantPrisma` fuer diese
|
||||
* Methode, wie die restlichen Anfragewege dieser Datei.
|
||||
*/
|
||||
async getSmtpConfig(tenantId: string) {
|
||||
return this.prisma.smtpConfig.findUnique({
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
return tenantPrisma.smtpConfig.findUnique({
|
||||
where: { tenantId },
|
||||
select: {
|
||||
...SMTP_SAFE_SELECT,
|
||||
@@ -52,8 +57,11 @@ export class SettingsService {
|
||||
* When `dto.password` is empty or absent, the existing encrypted password is preserved.
|
||||
*
|
||||
* T-07-08: Encryption via CryptoService. Never logs the plaintext password.
|
||||
* Mandantengebunden (260911-gwh): EIN Klient `tenantPrisma`.
|
||||
*/
|
||||
async saveSmtpConfig(tenantId: string, dto: SmtpConfigDto) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
|
||||
// Determine the encrypted password to store
|
||||
let encryptedPassword: string | undefined;
|
||||
|
||||
@@ -71,7 +79,7 @@ export class SettingsService {
|
||||
...(encryptedPassword !== undefined ? { encryptedPassword } : {}),
|
||||
};
|
||||
|
||||
const result = await this.prisma.smtpConfig.upsert({
|
||||
const result = await tenantPrisma.smtpConfig.upsert({
|
||||
where: { tenantId },
|
||||
create: { tenantId, ...data },
|
||||
update: data,
|
||||
@@ -83,8 +91,15 @@ export class SettingsService {
|
||||
|
||||
/**
|
||||
* Internal: Get the decrypted SMTP config for a tenant.
|
||||
* Used by DkvMailService to build a nodemailer transport at send time.
|
||||
* Used by DkvMailService/TenderMailService to build a nodemailer transport
|
||||
* at send time — the ONLY send path (Befund K, 260909-laa/260909-mir).
|
||||
* NEVER log the decrypted password (T-07-10 / T-05-13).
|
||||
*
|
||||
* Mandantengebunden seit 260911-gwh (Aufgabe 2): EIN Klient
|
||||
* `tenantPrisma`. Vorher lief diese Methode ungebunden — nach dem
|
||||
* Scharfschalten waere fuer NIEMANDEN mehr eine Mail rausgegangen
|
||||
* (tender: warn+skip, dkv: throw). Die Reihenfolgebedingung aus (t4)
|
||||
* Befund K und (d4) ist mit dieser Bindung erfuellt.
|
||||
*/
|
||||
async getDecryptedSmtpConfig(tenantId: string): Promise<{
|
||||
host: string;
|
||||
@@ -94,7 +109,8 @@ export class SettingsService {
|
||||
fromAddress: string;
|
||||
decryptedPassword: string | null;
|
||||
} | null> {
|
||||
const config = await this.prisma.smtpConfig.findUnique({
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const config = await tenantPrisma.smtpConfig.findUnique({
|
||||
where: { tenantId },
|
||||
});
|
||||
|
||||
@@ -122,6 +138,8 @@ export class SettingsService {
|
||||
* Returns true on success, false on failure.
|
||||
*
|
||||
* T-07-16: Returns only a boolean — no credentials or transport details in the response.
|
||||
* Kein eigener Datenbankzugriff — greift ueber `getDecryptedSmtpConfig`
|
||||
* (bereits gebunden) auf gespeicherte Zugangsdaten zurueck.
|
||||
*/
|
||||
async testSmtpConfig(
|
||||
tenantId: string,
|
||||
@@ -175,13 +193,46 @@ export class SettingsService {
|
||||
|
||||
/**
|
||||
* Tenant-agnostic startup accessor for the MailModule factory.
|
||||
* Returns the first SmtpConfig row in the DB (single-tenant deployments) with
|
||||
* the password decrypted. Returns null when no row exists (env-var fallback path).
|
||||
*
|
||||
* BLEIBT bewusst UNGEBUNDEN (260911-gwh, sechster Fall der
|
||||
* Hintergrunddienst-Falle — gleicher Bauart wie
|
||||
* `DkvService.loadAnyActiveConfigForScheduler()`, WINDOWS #21, siehe
|
||||
* dessen Kopfkommentar als Vorlage). Zwei Zustaende, beide gehoeren
|
||||
* genannt:
|
||||
*
|
||||
* - HEUTE bereits falsch, nicht nur ungenau: `findFirst()` ohne jede
|
||||
* Bedingung zieht bei mehreren Mandanten den SMTP-Server und die
|
||||
* Absenderadresse EINES beliebigen Mandanten fuer ALLE
|
||||
* Kennwort-Zuruecksetzungs- und Willkommensmails ALLER Mandanten
|
||||
* (T-GWH-03 — Nutzung fremder Zugangsdaten, nicht nur Sichtbarkeit).
|
||||
* - NACH DEM SCHARFSCHALTEN (WINDOWS #18) liefert dieselbe Abfrage
|
||||
* `null`, `mail.module.ts` faellt auf Umgebungsvariablen und zuletzt
|
||||
* `localhost:1025` zurueck — ein FALSCHER, aber vorhandener Transport
|
||||
* statt einer Meldung; `MailService` faengt jeden Transportfehler
|
||||
* (T-02-12) und der Controller antwortet `200`. Das Verstummen ist
|
||||
* damit DOPPELT verdeckt: erst durch die Rueckfallkette, dann durch
|
||||
* das Verschlucken im Versand. Das ist die Unsymmetrie zu `ldap`
|
||||
* (`getAllActiveConfigs`, heute korrekt, verstummt erst spaeter) UND zu
|
||||
* `dkv` (WINDOWS #21, heute bereits falsch, verstummt spaeter MIT
|
||||
* Protokollzeile) — hier: heute bereits falsch, verstummt spaeter OHNE
|
||||
* Protokollzeile.
|
||||
*
|
||||
* Binden wuerde diesen Pfad garantiert leer laufen lassen (beim Start
|
||||
* gibt es strukturell keinen Mandantenkontext). Der Umbau auf Transport
|
||||
* je Versand aus `getDecryptedSmtpConfig(tenantId)` — die Form, die
|
||||
* `DkvMailService`/`TenderMailService` bereits haben, `MailService`
|
||||
* muesste den Mandanten nur von `requestPasswordReset` entgegennehmen —
|
||||
* ist eine Funktionsaenderung (Umbau des Mailmoduls), KEIN Bindungsumbau,
|
||||
* NICHT dieser Auftrag. Entscheidung: EIGENER Ledger-Eintrag statt
|
||||
* Anschluss an #21 (andere Datei, andere Reparatur, andere
|
||||
* Verdeckungsform) — siehe WINDOWS #30 und
|
||||
* `docs/mandantentrennung-etappe2-fehlerrichtung.md`, Abschnitt
|
||||
* "## Bereich settings", (s4)(a).
|
||||
*
|
||||
* D-06: MailModule reads this at startup (priority 1) and falls back to env vars (priority 2).
|
||||
* T-07-11: Decrypted password is used only to build the transport — never logged.
|
||||
*/
|
||||
async getStartupSmtpConfig(): Promise<{
|
||||
async loadAnySmtpConfigForStartupTransport(): Promise<{
|
||||
host: string;
|
||||
port: number;
|
||||
secure: boolean;
|
||||
|
||||
@@ -308,21 +308,25 @@ Prisma-Client mehr und veröffentlicht keinen auf dem Anfrageobjekt.
|
||||
> Express-Middleware mit identischer Logik — beides wurde mit 260911-e2s entfernt, nachdem eine
|
||||
> Volltextsuche keinen Leser dieser Eigenschaft außerhalb der beiden Dateien fand.
|
||||
|
||||
`app.current_tenant` wird von **Postgres Row-Level-Security** ausgewertet. RLS-Policies sind aber
|
||||
**nicht** auf allen Tabellen aktiv — aktuell nur auf `User`, `PasswordResetToken`, `LdapConfig`,
|
||||
`LdapFieldMapping`, `Group`, `GroupMembership` und `ModuleGrant` (siehe die Migrationen
|
||||
`20260618112133_rls_policies` und `20260804130918_groups_rls_policies`). Alle übrigen
|
||||
mandantenbezogenen Tabellen — u. a. `DkvVehicleMaster`, `DkvInvoiceHistory`, `CalendarSource`,
|
||||
`Tender`, `TenderSavedSearch`, `FavoriteLink` — tragen zwar eine `tenantId`-Spalte, aber **keine**
|
||||
RLS-Policy.
|
||||
`app.current_tenant` wird von **Postgres Row-Level-Security** ausgewertet. RLS-Policies liegen
|
||||
seit `20260909140000_rls_remaining_tenant_tables` auf 23 Tabellen (4 aus
|
||||
`20260618112133_rls_policies`, 3 aus `20260804130918_groups_rls_policies`, 16 aus der
|
||||
`_rls_remaining_tenant_tables`-Migration selbst — `grep -c "ENABLE ROW LEVEL SECURITY"` über die
|
||||
drei Migrationen, zur Ausführungszeit nachzählen), darunter `FavoriteLink` und `SmtpConfig`. Ohne
|
||||
eigene `tenantId`-Spalte bzw. bewusst plattformweit bleiben `Module`, `Tenant`, `Tender`,
|
||||
`TenderSource` und `TenderSourcePollConfig` (siehe die Bestandsaufnahme in
|
||||
`docs/mandantentrennung-zugriffsklassifikation.md`, Klasse `keine-mandantengebundene-tabelle`, für
|
||||
die vollständige, maschinell geprüfte Liste — von dort ableiten, nicht raten).
|
||||
|
||||
**Was ein Entwickler nie vergessen darf:** Bei jeder Query gegen eine Tabelle ohne RLS-Policy muss
|
||||
`tenantId` **manuell** in die `where`-Klausel — die Datenbank filtert hier nichts von selbst. Das
|
||||
ist im Code auch der gelebte Stil: `DkvService.loadConfig()`
|
||||
(`apps/api/src/dkv/dkv.service.ts`) etwa nutzt den plain, UNGEBUNDENEN `PrismaService` und
|
||||
filtert explizit mit `where: { tenantId }`. Wer bei einer solchen Tabelle das `tenantId`-Filter
|
||||
vergisst, liest oder schreibt mandantenübergreifend — ohne dass RLS das auffängt. Bei den sieben
|
||||
RLS-geschützten Tabellen greift die DB-seitige Absicherung zusätzlich, vorausgesetzt die Query
|
||||
**Was ein Entwickler nie vergessen darf:** jeder Zugriff auf eine mandantengebundene Tabelle läuft
|
||||
dienst-intern über einen mit `forTenant()` gebundenen Klienten `tenantPrisma`
|
||||
(`apps/api/src/prisma/prisma-tenant.extension.ts`) — die zusätzlichen `where`-Filter über
|
||||
`userId` bleiben bestehen, wo die Regel selbst keine Benutzerdimension kennt (siehe
|
||||
`docs/mandantentrennung-etappe2-fehlerrichtung.md`). Bei den Tabellen ohne eigene `tenantId`
|
||||
(oben) filtert die Anwendung stattdessen — wo relevant — über den zutreffenden Bezug (z. B.
|
||||
plattformweiter Katalog, kein Mandantenfilter nötig); siehe
|
||||
`docs/mandantentrennung-zugriffsklassifikation.md` für den vollständigen Stand je Datei/Modell.
|
||||
Bei den RLS-geschützten Tabellen greift die DB-seitige Absicherung zusätzlich, vorausgesetzt die Query
|
||||
läuft tatsächlich über einen dienst-intern per `forTenant()` gebundenen Client und nicht über
|
||||
den globalen, ungebundenen `PrismaService`.
|
||||
|
||||
|
||||
@@ -632,6 +632,14 @@ Warnung wäre Dauerlärm und verlöre ihr Signal.
|
||||
(dieselbe Entlastung wie in (t3)). Das ist eine Reihenfolgebedingung für
|
||||
Etappe 4, genau wie Befund D des `ldap`-Durchlaufs es für `groups` war —
|
||||
hier festgehalten, nicht gelöst.
|
||||
|
||||
**Nachtrag (260911-gwh):** `getDecryptedSmtpConfig(tenantId)` läuft seit
|
||||
Aufgabe 2 dieses Laufs über `forTenant()` (GENAU EIN Klient `tenantPrisma`
|
||||
je Aufruf). Die Reihenfolgebedingung ist damit ERFÜLLT — siehe
|
||||
`docs/mandantentrennung-zugriffsklassifikation.md`, Bestandsaufnahme-Zeile
|
||||
`settings.service.ts`/`smtpConfig`, und den Hintergrunddienst-Abschnitt
|
||||
dort. Die Etappe-4-Vorabprüfung muss diese Bedingung ab jetzt NICHT mehr
|
||||
führen.
|
||||
- **Die offene Architekturfrage `req.tenantPrisma`.** Auch der Bereich
|
||||
`tenders` entscheidet sie nicht — er bindet dienst-intern, wie `ldap`
|
||||
und `groups` es vormachen.
|
||||
@@ -943,6 +951,19 @@ keine Zeile mehr — Ergebnis: kein Versand für niemanden, mit Wiederholung
|
||||
bei jedem Lauf (die Datei bleibt lokal verfügbar, D-16). Reihenfolgebedingung
|
||||
für Etappe 4, hier festgehalten, nicht gelöst.
|
||||
|
||||
**Nachtrag (260911-gwh):** der `settings`-Teil dieser Übergabe ist erfüllt —
|
||||
`getDecryptedSmtpConfig(tenantId)` läuft seit Aufgabe 2 dieses Laufs über
|
||||
`forTenant()`, siehe die Bestandsaufnahme-Zeile
|
||||
`settings.service.ts`/`smtpConfig` in
|
||||
`docs/mandantentrennung-zugriffsklassifikation.md` und (s4)(b). Erneut
|
||||
gemessen: `dkv.seed.ts` ruft weiterhin
|
||||
`ModuleRegistryService.seedModule()` (`module-registry.service.ts:206`,
|
||||
`this.prisma.module.upsert`) — UNGEBUNDEN, aber bewusst und unverändert seit
|
||||
260910-exd, weil `Module` der plattformweite Modulkatalog ohne `tenantId`-
|
||||
Spalte ist (Befund E, `keine-mandantengebundene-tabelle`); "gebunden seit
|
||||
260910-exd" trifft auf diesen Zugriff NICHT zu, gemessen statt aus dem Plan
|
||||
abgeschrieben.
|
||||
|
||||
**Die offene Architekturfrage `req.tenantPrisma`.** Auch der Bereich `dkv`
|
||||
entscheidet sie nicht — er bindet dienst-intern, wie `ldap`, `groups` und
|
||||
`tenders` es vormachen.
|
||||
@@ -2674,6 +2695,435 @@ Benutzers, unveraendert in diesem Plan.
|
||||
`ldap.service.spec.ts` verweist — wird in Aufgabe 2 ersetzt, hier nur als
|
||||
Befund F genannt.
|
||||
|
||||
## Bereich favorites
|
||||
|
||||
Dieser Abschnitt erweitert die Kritikschrift um den Bereich `favorites`
|
||||
(Quick-Task 260911-gwh, zusammen mit `settings` der LETZTE Lauf von Etappe 2)
|
||||
und beschreibt ihn zum Zeitpunkt seiner Umstellung. Die Leitfrage aus
|
||||
Abschnitt (a) gilt unverändert weiter. Anders als jeder Bereich davor hängt
|
||||
`FavoriteLink` über einen Fremdschlüssel an einer ZWEITEN mandantengebundenen
|
||||
Tabelle (`WidgetInstance`), deren Zeilenschutz-Regel der Fremdschlüssel auf
|
||||
Datenbankebene umgeht — eine Ausprägung von WINDOWS #27, hier zum ersten Mal
|
||||
ausdrücklich mitgebaut statt nur benannt.
|
||||
|
||||
### (f1) Die Messung
|
||||
|
||||
Aufgabe 1 hat `apps/api/scripts/rls-scratch-check.mjs` um einen weiteren
|
||||
Abschnitt (`runFavoritesAreaChecks`) erweitert, mit der Policy für
|
||||
`FavoriteLink` (aus der ausgelieferten Migration
|
||||
`20260909140000_rls_remaining_tenant_tables`) WORTGLEICH extrahiert, nicht im
|
||||
Werkzeug nachgetippt. Tatsächlich beobachtete Ausgabe dieses Laufs
|
||||
(2026-09-11, gegen `tessera-ctl-db-1`, Adresse `172.19.0.2`):
|
||||
|
||||
```
|
||||
favoritelink-regelstand-eindeutig: bestanden — die *_rls_widen_membership_grant_and_platform_read-Migration (260910-jab) enthaelt KEINE eigene Regel fuer "FavoriteLink" — der Stand aus 20260909140000_rls_remaining_tenant_tables ist weiterhin der ausgelieferte, aktuelle Regelstand
|
||||
favoritelink-wegwerftabelle-deckt-alle-spalten-des-generierten-clients: bestanden — Schema-Felder aus schema.prisma (model FavoriteLink, skalare Felder ohne Relation, 10): ["createdAt","iconUrl","id","position","tenantId","title","updatedAt","url","userId","widgetId"]; Spalten der Wegwerf-Tabelle (10): [dieselben zehn]; gemessene "WidgetInstance"-Zeilen (Befund C, von runDashboardAreaChecks angelegt): [{"id":"widget-a1","userId":"user-a1","tenantId":"TENANT-A"},{"id":"widget-a2","userId":"user-a2","tenantId":"TENANT-A"},{"id":"widget-b1","userId":"user-b1","tenantId":"TENANT-B"}]
|
||||
favoritelink-liste-generierter-client-ungebunden-liefert-leere-liste: bestanden — ungebundenes prisma.favoriteLink.findMany({ where: { userId: 'user-a1', widgetId: 'widget-a1' }, orderBy: [{ position: 'asc' }, { title: 'asc' }] }) (die Form von list) liefert 0 Zeile(n), obwohl 2 tatsaechlich vorhanden sind — das ist der Wert, aus dem favorites-widget.tsx "Noch keine Favoriten." macht
|
||||
favoritelink-liste-generierter-client-gebunden-eigener-mandant-liefert-eigene-zeilen: bestanden — bound.favoriteLink.findMany unter TENANT-A liefert fuer (userId='user-a1', widgetId='widget-a1') 2 Zeile(n): ["fav-a1-1","fav-a1-2"] — die Zeile von user-a2 fehlt (anwendungsseitige Benutzerfilterung); ein gebundenes findMany({ where: { widgetId: 'widget-a2' } }) unter DEMSELBEN Mandanten liefert dagegen 1 Zeile(n) des Kollegen user-a2 (["fav-a2-1"]) — die Regel auf "FavoriteLink" kennt keine Benutzerdimension
|
||||
favoritelink-besitzpruefung-generierter-client-gebunden-fremder-mandant-liefert-null: bestanden — bound.favoriteLink.findUnique({ where: { id: 'fav-a1-1' } }) unter TENANT-B (die Zeile gehoert TENANT-A) liefert null
|
||||
favoritelink-gebundenes-loeschen-ueber-kennung-allein-fremder-mandant-scheitert-laut: bestanden — bound.favoriteLink.delete unter TENANT-B auf die unter TENANT-A liegende Zeile fav-a1-1 wirft PrismaClientKnownRequestError (code P2025): No record was found for a delete. — die Wartungsrolle liest die Zeile danach noch: true
|
||||
favoritelink-fremdschluessel-prueft-am-zeilenschutz-vorbei: bestanden — bound.favoriteLink.create unter TENANT-A mit widgetId='widget-b1' (gehoert TENANT-B, unter TENANT-A per gebundenem widgetInstance.findUnique unsichtbar: null) GELINGT (id=fav-a1-fremdes-widget) — der Fremdschluessel prueft am Zeilenschutz VORBEI (dokumentiertes PostgreSQL-Verhalten); bound.favoriteLink.create unter TENANT-A mit widgetId="widget-gibt-es-nicht" scheitert mit PrismaClientKnownRequestError (code P2003): Foreign key constraint violated — der Unterschied zwischen beiden Antworten ist das Existenzorakel (T-GWH-05)
|
||||
favoritelink-gebundenes-anlegen-eigener-mandant-gelingt: bestanden — bound.favoriteLink.create unter TENANT-A mit widgetId='widget-a1' gelingt (id=fav-a1-neu); die Wartungsrolle liest danach tenantId="TENANT-A", createdAt und updatedAt gesetzt
|
||||
Alle 137 Pruefungen bestanden.
|
||||
```
|
||||
|
||||
Die tragende Belegzeile ist `favoritelink-liste-generierter-client-ungebunden-liefert-leere-liste`: der
|
||||
IDENTISCHE `findMany`, den `list` heute stellt, liefert UNGEBUNDEN `[]`,
|
||||
während die Wartungsrolle zwei Zeilen sieht — das ist der Wert, aus dem
|
||||
`favorites-widget.tsx` `Noch keine Favoriten.` macht (siehe (f3)).
|
||||
|
||||
Der Fremdschlüssel ist als mitgebaute Relation gemessen, nicht nur behauptet
|
||||
(Befund C, WINDOWS #27): die Wegwerf-Tabelle `"FavoriteLink"` trägt
|
||||
`FOREIGN KEY ("widgetId") REFERENCES "WidgetInstance"("id") ON DELETE CASCADE`
|
||||
auf die von `runDashboardAreaChecks` bereits angelegte Tabelle; vorher wurde
|
||||
über die Wartungsrolle gemessen, welche `WidgetInstance`-Zeilen tatsächlich
|
||||
stehen (drei, siehe Belegausgabe von Prüfung 2), statt sie anzunehmen.
|
||||
|
||||
Das Ergebnis von Prüfung 7 (`favoritelink-fremdschluessel-prueft-am-zeilenschutz-vorbei`)
|
||||
ist zweigeteilt und trägt eine Entscheidung für Aufgabe 2: ein gebundenes
|
||||
`create` unter TENANT-A mit `widgetId` eines TENANT-B-Widgets GELINGT — der
|
||||
Fremdschlüssel prüft an der Zeilenschutz-Regel von `WidgetInstance` VORBEI
|
||||
(dokumentiertes PostgreSQL-Verhalten: referentielle Integrität umgeht Row
|
||||
Security). Derselbe Aufruf mit einer wirklich fehlenden `widgetId` scheitert
|
||||
dagegen laut mit einer FK-Verletzung (Code P2003). Der Unterschied zwischen
|
||||
beiden Antworten — "existiert nicht" (500/Fehler) vs. "gehört einem fremden
|
||||
Mandanten" (gelingt) — ist ein Existenzorakel über Mandantengrenzen
|
||||
(T-GWH-05, medium). Aufgabe 2 baut deshalb einen anwendungsseitigen
|
||||
Besitzriegel in `create`, der beide Fälle auf dieselbe Antwort
|
||||
(`Widget not found`) abbildet.
|
||||
|
||||
### (f2) Signaltabelle je umzustellendem Pfad
|
||||
|
||||
| Pfad | Verhalten HEUTE nach dem Scharfschalten (ohne diesen Plan) | Konkretes Signal | Frontend lässt es durch? |
|
||||
|---|---|---|---|
|
||||
| `list` (`GET /favorites?widgetId=`) | Ungebundener `findMany` liefert `[]` statt der eigenen Zeilen | `200 []` | Ja — `fetchFavorites` (`favorites-api.ts:22-29`) gibt `[]` durch, siehe (f3) |
|
||||
| `create` (`POST /favorites`) | Ungebundenes `create` schreibt eine Zeile, die unter der Mandantenkennung des Aufrufers physisch korrekt liegt, aber der Icon-Suchpfad ist unverändert; der Riegel aus Aufgabe 2 prüft VOR dem Schreiben, ob `widgetId` existiert und dem Aufrufer gehört | `NotFoundException('Widget not found')`, 404, wenn das Widget nicht dem Aufrufer gehört | Ja — `createFavorite` (`favorites-api.ts:33-46`) wirft `Failed to create favorite` bei `!res.ok` |
|
||||
| `update` (`PATCH /favorites/:id`) | Ungebundener `findUnique` liefert `null` statt der eigenen Zeile, Vorprüfung greift bereits heute (userId-Vergleich) | `NotFoundException('FavoriteLink not found')`, 404 | Ja — `updateFavorite` wirft `Failed to update favorite` |
|
||||
| `remove` (`DELETE /favorites/:id`) | Dieselbe Form wie `update` | `NotFoundException('FavoriteLink not found')`, 404 | Ja — `deleteFavorite` wirft `Failed to delete favorite` |
|
||||
| `getIconBytes` (`GET /favorites/:id/icon`) | Dieselbe Vorprüfung wie `update`/`remove` | `NotFoundException('FavoriteLink not found')`, 404 | Ja — `<img>`-Ladefehler, vom Widget nicht gesondert behandelt |
|
||||
|
||||
### (f3) Welcher Code Leere als Abwesenheit deutet
|
||||
|
||||
Die `list`-Kette, alle Glieder namentlich: `list` liefert `[]` (die Belegzeile
|
||||
`favoritelink-liste-generierter-client-ungebunden-liefert-leere-liste`) →
|
||||
`GET /favorites?widgetId=` antwortet `200 []` → `fetchFavorites`
|
||||
(`favorites-api.ts:22-29`) prüft nur `!res.ok` (bei `200` erfüllt das nicht)
|
||||
und gibt `[]` durch → `favorites-widget.tsx:212-213` zeigt
|
||||
`t('favorites.empty')` = **`Noch keine Favoriten.`** (`de.json`, Zeile 219).
|
||||
"Zeile unsichtbar" und "nie einen gespeichert" sind für das Frontend
|
||||
derselbe Wert `[]` — das ist NICHT die Familie eines lauten Fehlers, sondern
|
||||
dieselbe Familie wie WINDOWS #23/#25/#26/#28 (module-registry, dashboard,
|
||||
calendar, auth).
|
||||
|
||||
`update`/`remove`/`getIconBytes` deuten Leere dagegen LAUT: die
|
||||
Vorprüfung (`findUnique` → `null` oder fremder `userId`) wirft
|
||||
`NotFoundException('FavoriteLink not found')`, 404 — `favorites-api.ts`
|
||||
übersetzt das in `Failed to update/delete favorite`, das Widget setzt
|
||||
`t('favorites.error')` im `catch`. Diese Richtung ist harmlos, weil ein zu
|
||||
kleines Ergebnis dort bereits heute einen Fehler auslöst, der nicht mit dem
|
||||
Scharfschalten neu entsteht.
|
||||
|
||||
### (f4) Was dieser Durchlauf bewusst nicht löst
|
||||
|
||||
- **(a) Die fehlende Benutzerdimension der Regel.** Prüfung 4
|
||||
(`favoritelink-liste-generierter-client-gebunden-eigener-mandant-liefert-eigene-zeilen`)
|
||||
zeigt zweigeteilt: die eigenen Zeilen kommen korrekt, UND ein gebundenes
|
||||
`findMany` auf die `widgetId` eines Kollegen DESSELBEN Mandanten liefert
|
||||
dessen Zeile ebenfalls — die Regel auf `FavoriteLink` kennt keine
|
||||
Benutzerdimension (dieselbe Lehre wie bei `CalendarSource`,
|
||||
`DashboardLayout`, `WidgetInstance`). Die anwendungsseitige
|
||||
`userId`-Filterung bleibt bestehen und ist bis zur Etappe-3-Entscheidung
|
||||
(2) der einzige Schutz gegen Quer-Lesen zwischen Nutzern desselben
|
||||
Mandanten.
|
||||
- **(b) Das Frontend.** Die `list`-Kette aus (f3) wird nicht geändert;
|
||||
Ledger-Eintrag in Aufgabe 3.
|
||||
- **(c) Die Mandantenquelle — warum der dashboard-Präzedenzfall und nicht
|
||||
der auth-Präzedenzfall.** `favorites.controller.ts` `extractContext` liest
|
||||
`req.tenantId ?? req.user?.tenantId` — WORTGLEICH mit
|
||||
`dashboard.controller.ts`, unter dessen Bindung `WidgetInstance` liegt.
|
||||
`FavoriteLink` hängt über `widgetId` an `WidgetInstance`; würde
|
||||
`favorites` stattdessen an das Claim binden (wie `auth` für
|
||||
Selbstbedienung), während `dashboard` an der Guard-Kennung bleibt, lägen
|
||||
Widget und Link unter einem `x-tenant-id`-Wechsel eines SUPER_ADMIN in
|
||||
verschiedenen Mandanten. `favorites-api.ts` sendet die Kopfzeile heute
|
||||
nicht (`grep -rn "x-tenant-id" apps/web/src`: nur die vier
|
||||
Marktplatz-Stellen) — die Entscheidung hängt an der Bauform, nicht am
|
||||
heutigen Aufrufer.
|
||||
- **(d) Die Etappe-4-Vorabprüfung.** Für einen bekannten Nutzer/Widget die
|
||||
Favoritenzahl über die Wartungsrolle und über den gebundenen `findMany`
|
||||
daneben halten — dieselbe Form wie bei `dashboard`/`calendar`.
|
||||
|
||||
### (f5) Was dieser Durchlauf bewusst nicht anfasst
|
||||
|
||||
- Der Icon-Proxy (`icon-discovery.service.ts`, Befund G) — erreicht die
|
||||
Datenbank NICHT und nimmt KEINE Client-URL: `getIconBytes` holt nur die
|
||||
GESPEICHERTE `iconUrl` einer Zeile, die der Aufrufer besitzt (T-QFIP-01);
|
||||
`discoverFavoriteIconUrl` nimmt die Nutzer-URL nur für den
|
||||
SSRF-gesicherten Abruf (T-08-05). Kein Mandantenbezug — unverändert, in
|
||||
der Testdatei eine Attrappe.
|
||||
- Die DTOs (`create-favorite.dto.ts`, `update-favorite.dto.ts`) — nur
|
||||
gelesen, kein Mandantenfeld.
|
||||
- `dashboard.controller.ts` — nur gelesen (Präzedenzfall für (f4)(c)).
|
||||
- Das Frontend (`favorites-widget.tsx`, `favorites-api.ts`) — nur
|
||||
beschrieben, nicht geändert.
|
||||
- Schema und Migrationen.
|
||||
|
||||
## Bereich settings
|
||||
|
||||
Dieser Abschnitt erweitert die Kritikschrift um den Bereich `settings`
|
||||
(Quick-Task 260911-gwh) und beschreibt ihn zum Zeitpunkt seiner Umstellung.
|
||||
Anders als jeder Bereich davor trägt dieser Bereich die Reihenfolgebedingung
|
||||
Befund K aus den Läufen `tenders` und `dkv`: `getDecryptedSmtpConfig(tenantId)`
|
||||
ist der einzige Versandpfad für Ausschreibungs- und DKV-Mails. Zusätzlich
|
||||
trägt der Startpfad des Mailmoduls den SECHSTEN Fall der
|
||||
Hintergrunddienst-Falle — anders als bei `dkv` (WINDOWS #21) verdeckt hier
|
||||
eine Rückfallkette das Verstummen mit einem falschen Transport statt
|
||||
schlichter Leere.
|
||||
|
||||
### (s1) Die Messung
|
||||
|
||||
Aufgabe 1 hat `apps/api/scripts/rls-scratch-check.mjs` um einen weiteren
|
||||
Abschnitt (`runSettingsAreaChecks`) erweitert, mit der Policy für
|
||||
`SmtpConfig` (aus der ausgelieferten Migration
|
||||
`20260909140000_rls_remaining_tenant_tables`) WORTGLEICH extrahiert. Die
|
||||
Wegwerf-Tabelle trägt zusätzlich den Eindeutigkeitsindex
|
||||
`SmtpConfig_tenantId_key` WORTGLEICH aus `20260629130000_add_missing_tables`
|
||||
— ohne ihn misst Prüfung 8 nichts. Tatsächlich beobachtete Ausgabe dieses
|
||||
Laufs (2026-09-11, gegen `tessera-ctl-db-1`, Adresse `172.19.0.2`):
|
||||
|
||||
```
|
||||
smtpconfig-regelstand-eindeutig: bestanden — die *_rls_widen_membership_grant_and_platform_read-Migration (260910-jab) enthaelt KEINE eigene Regel fuer "SmtpConfig" — der Stand aus 20260909140000_rls_remaining_tenant_tables ist weiterhin der ausgelieferte, aktuelle Regelstand
|
||||
smtpconfig-wegwerftabelle-deckt-alle-spalten-des-generierten-clients: bestanden — Schema-Felder aus schema.prisma (model SmtpConfig, skalare Felder ohne Relation, 10): ["createdAt","encryptedPassword","encryption","fromAddress","host","id","port","tenantId","updatedAt","username"]; Spalten der Wegwerf-Tabelle (10): [dieselben zehn]; Eindeutigkeitsindex "SmtpConfig_tenantId_key" ueber pg_indexes: ["CREATE UNIQUE INDEX \"SmtpConfig_tenantId_key\" ON public.\"SmtpConfig\" USING btree (\"tenantId\")"]
|
||||
smtpconfig-startpfad-generierter-client-ungebunden-liefert-null: bestanden — ungebundenes prisma.smtpConfig.findFirst() (die Form von loadAnySmtpConfigForStartupTransport) liefert null, obwohl 2 Zeilen existieren — das ist der Wert, mit dem mail.module.ts nach dem Scharfschalten auf Umgebungsvariablen und zuletzt localhost:1025 zurueckfaellt, ein falscher Transport statt einer Meldung
|
||||
smtpconfig-startpfad-ueber-wartungsrolle-zieht-beliebige-zeile: bestanden — dieselbe Abfrage ueber die Wartungsrolle liefert genau EINE Zeile, tenantId="TENANT-A" — nichts in der Abfrage bestimmt, WELCHER Mandant gezogen wird
|
||||
smtpconfig-versandpfad-generierter-client-ungebunden-liefert-null: bestanden — ungebundenes prisma.smtpConfig.findUnique({ where: { tenantId: 'TENANT-A' } }) (die Form von getDecryptedSmtpConfig) liefert null, waehrend die Wartungsrolle die Zeile liest
|
||||
smtpconfig-versandpfad-generierter-client-gebunden-eigener-mandant-liefert-zugangsdaten: bestanden — gebunden unter TENANT-A liefert findUnique: host="smtp-a.example.invalid", fromAddress="a@example.invalid", encryptedPassword="enc(a-passwort-platzhalter)"
|
||||
smtpconfig-versandpfad-generierter-client-gebunden-fremder-mandant-liefert-null: bestanden — gebunden unter TENANT-B liefert findUnique({ where: { tenantId: 'TENANT-A' } }): null
|
||||
smtpconfig-ungebundenes-upsert-auf-unsichtbare-zeile-scheitert-laut: bestanden — ungebundenes prisma.smtpConfig.upsert(...) (die Form von saveSmtpConfig) wirft PrismaClientUnknownRequestError: ConnectorError ... SQLSTATE 42501, "new row violates row-level security policy for table \"SmtpConfig\"" — die Wartungsrolle liest danach weiterhin host="smtp-a.example.invalid"
|
||||
smtpconfig-gebundenes-upsert-eigener-mandant-aktualisiert: bestanden — gebundenes upsert unter TENANT-A trifft die eigene Zeile (id=smtp-a), die Wartungsrolle liest danach den neuen Host, updatedAt gesetzt; smtp-b bleibt unveraendert
|
||||
Alle 137 Pruefungen bestanden.
|
||||
```
|
||||
|
||||
Die tragenden Belegzeilen sind drei: `smtpconfig-startpfad-generierter-client-ungebunden-liefert-null`
|
||||
für den Startpfad, `smtpconfig-versandpfad-generierter-client-ungebunden-liefert-null`
|
||||
für Befund K, und `smtpconfig-ungebundenes-upsert-auf-unsichtbare-zeile-scheitert-laut`
|
||||
für den Speicherkonflikt. Prüfung 8 wirft — gemessen, nicht angenommen —
|
||||
**`PrismaClientUnknownRequestError`**, dieselbe Fehlerklasse, die 260910-krx
|
||||
für `DashboardLayout` gemessen hat (nicht `PrismaClientKnownRequestError`
|
||||
mit `P2002`, die Form der Bereiche `tenders`/`user`): die Regel weist den
|
||||
Schreibzugriff ab, bevor eine Eindeutigkeit überhaupt geprüft wird. Die
|
||||
zugrundeliegende PostgreSQL-Meldung (SQLSTATE `42501`, "new row violates
|
||||
row-level security policy") ist in der Belegausgabe wörtlich enthalten.
|
||||
|
||||
### (s2) Signaltabelle je Pfad
|
||||
|
||||
| Pfad | Verhalten HEUTE nach dem Scharfschalten (ohne diesen Plan) | Signal | Frontend |
|
||||
|---|---|---|---|
|
||||
| `getSmtpConfig` (`GET /settings/smtp`) | Ungebundener `findUnique` liefert `null` statt der eigenen Zeile | Controller gibt `null` → NestJS sendet `200` mit LEEREM Rumpf | Ja — verschluckt, siehe (s3) |
|
||||
| `saveSmtpConfig` (`PUT /settings/smtp`) | Ungebundenes `upsert` scheitert am Eindeutigkeitsindex, weil die physisch vorhandene Zeile unsichtbar ist | `PrismaClientUnknownRequestError`, 500 | Nein — kein spezifischer Übersetzungspfad im Controller, roher 500 |
|
||||
| `getDecryptedSmtpConfig` — Aufrufer `tender-mail.service.ts` (`resolveTransport`) | Ungebundener `findUnique` liefert `null` | `logger.warn('No SMTP configuration ... skipping tender mail send (will retry next run)')`, KEIN Wurf, Digest übersprungen | — (Hintergrunddienst, kein Frontend-Pfad) |
|
||||
| `getDecryptedSmtpConfig` — Aufrufer `dkv-mail.service.ts` (`sendExportEmail`) | Dieselbe Form | `throw new Error('No SMTP configuration found for tenant ...')` | — (Hintergrunddienst) |
|
||||
| `testSmtpConfig` (`POST /settings/smtp/test`) | Rückgriff auf `getDecryptedSmtpConfig` liefert `null`, `password`/`username` bleiben `undefined` (falls DTO leer) | Verbindungstest scheitert am Postfach, `{ success: false }` — irreführend, zeigt auf das Postfach statt auf die Datenbank | Ja — Testergebnis wird angezeigt |
|
||||
| Startpfad `loadAnySmtpConfigForStartupTransport()` (`mail.module.ts`, beim Start) | Ungebundenes `findFirst()` liefert `null` statt einer beliebigen Zeile; die Rückfallkette von `mail.module.ts` greift (Priorität 2-4, zuletzt `localhost:1025`) — EIGENE Spalte, siehe (s4)(a) | Kein Fehler, ein FALSCHER, aber vorhandener Transport; `MailService` fängt jeden Transportfehler (T-02-12), Controller antwortet `200` | Ja — doppelt verdeckt |
|
||||
|
||||
### (s3) Welcher Code Leere als Abwesenheit deutet
|
||||
|
||||
Die `getSmtpConfig`-Kette, alle Glieder namentlich: `getSmtpConfig` liefert
|
||||
`null` (Belegzeile `smtpconfig-versandpfad-generierter-client-ungebunden-liefert-null`
|
||||
zeigt dieselbe Form für den Versandpfad) → `settings.controller.ts` gibt
|
||||
`null` zurück, ohne zu werfen → NestJS' `ExpressAdapter.reply` sendet bei
|
||||
`isNil(body)` einen LEEREN Rumpf mit Status `200` (dieselbe Adapter-Kette wie
|
||||
in (h3), 260911-fh9) → `fetchSmtp` (`settings-api.ts:51-57`) prüft nur
|
||||
`res.status === 404` (nicht erfüllt) und `!res.ok` (bei `200` nicht erfüllt),
|
||||
dann `res.json()` auf den leeren Rumpf → wirft → `smtp-settings-form.tsx:76-78`
|
||||
`.catch(() => { /* Silent fail */ })` → leeres Formular: "SMTP nicht
|
||||
eingerichtet", während die Zugangsdaten physisch noch da sind. "Nicht
|
||||
eingerichtet" und "Zeile unsichtbar" sind für das Frontend derselbe Zustand
|
||||
— dieselbe Familie wie WINDOWS #23/#25/#26/#28.
|
||||
|
||||
Trägt der Administrator die Zugangsdaten unter diesem Eindruck neu ein, läuft
|
||||
`saveSmtpConfig` als `upsert({ where: { tenantId } })`: unter der
|
||||
UNGEBUNDENEN Form ist die Zeile unsichtbar, der Upsert versucht ein `INSERT`
|
||||
und scheitert am Eindeutigkeitsindex `SmtpConfig_tenantId_key` —
|
||||
`PrismaClientUnknownRequestError` (Prüfung 8, gemessen statt vorweggenommen;
|
||||
dieselbe Fehlerklasse wie die `dashboard`-Lehre aus 260910-krx, NICHT `P2002`).
|
||||
|
||||
Die beiden Versandpfade reagieren unterschiedlich auf `null`
|
||||
(`getDecryptedSmtpConfig`): `tender-mail.service.ts` protokolliert eine
|
||||
Warnung und überspringt den Versand (Wiederholung beim nächsten Lauf),
|
||||
`dkv-mail.service.ts` wirft einen Fehler, der die aufrufende Pipeline
|
||||
abbricht — beide Formen sind bereits vor diesem Lauf so verdrahtet, ändern
|
||||
sich hier nicht.
|
||||
|
||||
### (s4) Was dieser Durchlauf bewusst nicht löst
|
||||
|
||||
**(a) Der Startpfad — der sechste Fall der Hintergrunddienst-Falle,
|
||||
ausgeschrieben statt still getroffen.** `mail.module.ts`'s
|
||||
`MailerModule.forRootAsync({ useFactory: async ... })` ruft
|
||||
`SettingsService.loadAnySmtpConfigForStartupTransport()`
|
||||
(vormals `getStartupSmtpConfig()`) BEIM START, vor jedem Anfragekontext.
|
||||
Zwei Zustände, beide gehören benannt:
|
||||
|
||||
- **Heute** ist die Abfrage bereits FALSCH, nicht nur ungenau: bei mehreren
|
||||
Mandanten trägt der SMTP-Server und die Absenderadresse EINES beliebigen
|
||||
Mandanten die Kennwort-Zurücksetzungs- und Willkommensmails ALLER
|
||||
Mandanten (T-GWH-03, Nutzung fremder Zugangsdaten, nicht nur Sichtbarkeit).
|
||||
- **Nach dem Scharfschalten** liefert `findFirst()` `null` →
|
||||
`mail.module.ts` fällt auf Priorität 2 (`MAIL_*`), 3 (`TESSERA_SMTP_*`),
|
||||
zuletzt 4 (`localhost:1025`, Mailhog) zurück → `MailService` fängt jeden
|
||||
Transportfehler (`mail.service.ts:69-81`, T-02-12) und der Controller
|
||||
antwortet `200`. Das Verstummen ist damit DOPPELT verdeckt: erst durch die
|
||||
Rückfallkette (ein falscher, aber vorhandener Transport statt Leere), dann
|
||||
durch das absichtliche Verschlucken im Versand.
|
||||
|
||||
Drei geprüfte Formen: **(a) An einen konkret aufgelösten Mandanten binden**
|
||||
— nicht möglich, `useFactory` hat beim Start keinen Anfragekontext.
|
||||
**(b) Umbau auf Transport je Versand** — abgelehnt als Funktion für DIESEN
|
||||
Lauf, mit Grund: die Vorlage steht bereits in `DkvMailService`/
|
||||
`TenderMailService` (Transport je Versand aus
|
||||
`getDecryptedSmtpConfig(tenantId)`), `MailService` müsste dafür nur den
|
||||
Mandanten entgegennehmen, den `requestPasswordReset` aus der Funktionszeile
|
||||
bereits hat — ein Umbau des Mailmoduls, kein Bindungsumbau, NICHT dieser
|
||||
Auftrag (siehe `<hard_constraints>`). **(c) Als benannte Altlast
|
||||
weiterführen, mit Markierung** — GEWÄHLT: Methode umbenannt
|
||||
(`loadAnySmtpConfigForStartupTransport()`, dkv-Präzedenzfall — ein Name, den
|
||||
niemand für einen Anfrageweg hält), Kopfkommentar mit beiden Zuständen,
|
||||
Modulkommentar in `mail.module.ts`, eigener Ledger-Eintrag.
|
||||
|
||||
**Die Unsymmetrie zu BEIDEN Präzedenzfällen:** `getAllActiveConfigs` (ldap)
|
||||
ist heute korrekt und verstummt erst später; `loadAnyActiveConfigForScheduler`
|
||||
(dkv, WINDOWS #21) ist heute bereits falsch und verstummt zusätzlich später,
|
||||
ABER mit einer Protokollzeile ("no active config found — cron job not
|
||||
registered"). Der Mail-Startpfad ist heute bereits falsch UND verstummt
|
||||
später OHNE Protokollzeile, weil die Rückfallkette ihn überdeckt — das ist
|
||||
eine DRITTE Ausprägung, keine der beiden Vorlagen deckt sie vollständig.
|
||||
|
||||
**Entscheidung: EIGENER Ledger-Eintrag statt Anschluss an #21.** Andere
|
||||
Datei (`mail.module.ts`/`settings.service.ts` statt
|
||||
`dkv-scheduler.service.ts`), andere Reparatur (Transport je Versand statt
|
||||
Mehrmandanten-Planung), andere Verdeckungsform (Rückfallkette statt bloßer
|
||||
Leere) — drei eigenständige Unterschiede, kein Wiederholungsfall von #21.
|
||||
|
||||
**(b) Befund K ist erfüllt.** Die Reihenfolgebedingung aus (t4) Befund K und
|
||||
(d4) Übergaben-Absatz — `getDecryptedSmtpConfig(tenantId)` müsse gebunden
|
||||
sein, bevor Etappe 4 scharfschaltet — ist mit Aufgabe 2 dieses Laufs
|
||||
ERFÜLLT: die Methode läuft seither über GENAU EINEN Klienten `tenantPrisma`.
|
||||
Beide Stellen bekommen in Aufgabe 3 einen Nachtrag (siehe (t4), (d4) unten in
|
||||
diesem Dokument sowie den Hintergrunddienst-Abschnitt in
|
||||
`docs/mandantentrennung-zugriffsklassifikation.md`). Die Etappe-4-Vorabprüfung
|
||||
muss diese Bedingung ab jetzt NICHT mehr führen.
|
||||
|
||||
**(c) Das Frontend.** Die `getSmtpConfig`-Kette aus (s3) wird nicht
|
||||
geändert; Ledger-Eintrag in Aufgabe 3 (Familie #28 — dieselbe
|
||||
200-leerer-Rumpf-Kette wie `auth`).
|
||||
|
||||
**(d) Die Mandantenquelle `req.tenantId` im Controller.** Bewusst NICHT auf
|
||||
das Claim umgestellt (anders als die Selbstbedienungs-Begründung aus
|
||||
`auth`): `settings.controller.ts` ist eine ADMIN-Konfigurationsseite, für
|
||||
die `req.tenantId` (per `x-tenant-id` für SUPER_ADMIN umschaltbar) die
|
||||
RICHTIGE Quelle ist (D-10) — ein SUPER_ADMIN konfiguriert damit gezielt die
|
||||
SMTP-Zugangsdaten eines ANDEREN Mandanten. Der Controller bleibt deshalb
|
||||
unverändert und steht nicht in der Erlaubnisliste.
|
||||
|
||||
**(e) Die Etappe-4-Vorabprüfung.** Für einen bekannten Mandanten die
|
||||
`SmtpConfig`-Zeile über die Wartungsrolle lesen und den gebundenen
|
||||
`findUnique` daneben halten — dieselbe Form wie bei `dashboard`/`calendar`.
|
||||
|
||||
### (s5) Was dieser Durchlauf bewusst nicht anfasst
|
||||
|
||||
- `settings.controller.ts` — nur gelesen (siehe (s4)(d)).
|
||||
- `apps/api/src/settings/dto/smtp-config.dto.ts` — nur gelesen, kein
|
||||
Mandantenfeld.
|
||||
- `tender-mail.service.ts` (`resolveTransport`) — nur gelesen, ruft
|
||||
weiterhin `getDecryptedSmtpConfig(tenantId)` unverändert auf.
|
||||
- `dkv-mail.service.ts` (`sendExportEmail`) — dieselbe Form.
|
||||
- `mail.service.ts` — nur gelesen; `mail.module.ts` wird für die
|
||||
Umbenennung des Startpfad-Aufrufs angefasst (Aufgabe 2), hier nur
|
||||
angekündigt.
|
||||
- Das Frontend (`smtp-settings-form.tsx`, `settings-api.ts`) — nur
|
||||
beschrieben, nicht geändert.
|
||||
- Schema und Migrationen.
|
||||
- `nodemailer` — kein echter Transport in irgendeinem Test dieses Laufs
|
||||
(lokal gibt es keinen `mailhog`); in der neuen Testdatei per
|
||||
`vi.mock('nodemailer')` ersetzt.
|
||||
|
||||
## Etappe 2 — Abschluss
|
||||
|
||||
Etappe 2 der Mandantentrennung ist mit diesem Lauf (260911-gwh) vollständig:
|
||||
jede klassifizierte Fundstelle in `apps/api/src` ist entweder gebunden oder
|
||||
mit geschriebenem Grund an der Stelle ungebunden. Die Zahlen unten sind aus
|
||||
den eigenen Messanweisungen des Klassifikationsdokuments abgeleitet
|
||||
(Übersichtszeilen, Summenzeile, Klassen-Verteilung), nicht neu geschätzt.
|
||||
|
||||
**Läufe.** Zwölf Bereichs-/Regel-Läufe von 260909-ipc bis 260911-gwh (gezählt
|
||||
aus `.planning/STATE.md`, Reihenfolge): `ldap` (260909-ipc), `groups`
|
||||
(260909-jts), `tenders` (260909-laa), `dkv` (260909-mir), `user`
|
||||
(260910-das), `module-registry` (260910-exd), `dashboard` (260910-krx), das
|
||||
Regelschluss-Plan `tenders`/`SearchProvider` (260910-jab), `tenant`
|
||||
(260911-e2s), `calendar` (260911-cwh), `auth` (260911-fh9), `favorites`/
|
||||
`settings` (260911-gwh, dieser Lauf).
|
||||
|
||||
**Summenzeile der Übersichtstabelle, vorher/nachher.** Zum Kopf des
|
||||
Klassifikationsdokuments (Stand 260909-eor): 227 Rohtreffer über 59
|
||||
Datei-Modell-Paare. Nach Aufgabe 3 dieses Laufs — DERIVIERT aus der
|
||||
Summenzeile in `docs/mandantentrennung-zugriffsklassifikation.md`, nicht
|
||||
abgeschrieben: **68 ungebundene, 178 gebundene Rohtreffer** (Summe 246 —
|
||||
mehr als 227, weil Aufgabe 2 dieses Laufs mit dem Widget-Besitzriegel einen
|
||||
zusätzlichen gebundenen Rohtreffer einführt, der zur Planungszeit noch nicht
|
||||
feststand). Jeder der 68 verbleibenden ungebundenen Rohtreffer ist einer der
|
||||
in diesem Dokument (Abschnitte "## Bereich ...") oder im Klassifikationsdokument
|
||||
namentlich benannten, bewusst ungebundenen Fälle — siehe die Liste unten.
|
||||
|
||||
**Klassen-Verteilung.** Siehe `docs/mandantentrennung-zugriffsklassifikation.md`,
|
||||
Abschnitt "Klassen-Verteilung", Stand 260911-gwh (Aufgabe 3): **65 Paare**
|
||||
(33 `muss-mandantengebunden`, 17 `keine-mandantengebundene-tabelle`, 13
|
||||
`beides`, 2 `bewusst-uebergreifend`) — ein neues Paar
|
||||
(`favorites.service.ts`/`widgetInstance`) gegenüber den 64 Paaren vor
|
||||
diesem Lauf, plus zwei Paare, die nur ihre `Stand`-Spalte ändern
|
||||
(`favorites.service.ts`/`favoriteLink`, `settings.service.ts`/`smtpConfig`).
|
||||
|
||||
**Bewusst ungebundene Reste je Bereich, mit Grund:**
|
||||
|
||||
- `tenders`: der D-03-Katalog (platform-global, `Tender`/`TenderSource`/
|
||||
`TenderSourcePollConfig`), die zwei Fan-out-Adapter (E-Mail/RSS, ein Tick
|
||||
pro Postfach bzw. Feed über alle Mandanten), die übergreifenden Hälften
|
||||
der beiden Hintergrunddienste (Etappe-3-Übergabe), `createPlatform`/
|
||||
`remove` in `tender-rss-feed.service.ts` (WINDOWS #24).
|
||||
- `ldap`: `getAllActiveConfigs`/`onApplicationBootstrap` (Befund B),
|
||||
`resolveEmailForWrite` (plattformweit eindeutiger Schlüssel, T-IPC-04).
|
||||
- `dkv`: der Planer-Startpfad `loadAnyActiveConfigForScheduler()`
|
||||
(WINDOWS #21).
|
||||
- `user`: `findByUsername` (plattformweit eindeutiger Schlüssel), die
|
||||
Erstanlage-Prüfung und beide `tenant`-Zugriffe in `admin-seed.service.ts`,
|
||||
der Schleifentreiber `tenant.findMany` der Plattform-Administratorsicht.
|
||||
- `module-registry`/`dashboard`: der Modulkatalog (`Module`, keine Regel
|
||||
heute — Befund E).
|
||||
- `auth`: die drei Anmeldefunktionen (`validateUser`,
|
||||
`requestPasswordReset`, `resetPassword`) über die SECURITY-DEFINER-Wege.
|
||||
- `tenant`: die Mandantentabelle selbst (`Tenant`, keine eigene
|
||||
`tenantId`-Spalte, keine Regel in irgendeiner Migration).
|
||||
- `settings`: der sechste Fall der Hintergrunddienst-Falle,
|
||||
`loadAnySmtpConfigForStartupTransport()` (dieser Lauf, siehe (s4)(a)).
|
||||
|
||||
**Offene Ledger-Einträge dieser Etappe** (Nummern siehe `.planning/WINDOWS.md`,
|
||||
Kopfzähler geprüft): #18 (Schalter aus), #20 (Verbindungsfehler, an dieselbe
|
||||
Bedingung gebunden wie #18), #21 (dkv-Planer-Startpfad), #22
|
||||
(plattformweite Eindeutigkeit von `username`/`email`), #23
|
||||
(module-registry, unterscheidbares Signal fehlt), #24 (plattformweite
|
||||
RSS-Verwaltung unter der Anwendungsrolle), #25 (dashboard, beweisvernichtende
|
||||
Fehlerrichtung), #26 (calendar, verschluckte Leere), #27
|
||||
(Relationszugriffe für die Bestandsaufnahme unsichtbar), #28 (auth,
|
||||
verschluckte Leere), plus die drei neuen Einträge dieses Laufs (Startpfad
|
||||
des Mailmoduls, verschluckte Leere `favorites`, verschluckte Leere
|
||||
`settings` — Nummern siehe Aufgabe 3 dieses Plans).
|
||||
|
||||
**Prüfungen und Tests.** Werkzeug: siehe die Zeile `Alle N Prüfungen
|
||||
bestanden.` des letzten Laufs von `rls-scratch-check.mjs` in dieser Aufgabe.
|
||||
Tests: siehe die letzte Testausgabe von `npm --prefix apps/api run test` in
|
||||
dieser Aufgabe.
|
||||
|
||||
**Was für Etappe 3 bleibt:**
|
||||
|
||||
- Der Anmeldeweg unter je Mandant eindeutigen Namen (`username`/`email`,
|
||||
Etappe-3-Entscheidung (1)) — siehe (h4)(a).
|
||||
- Die Benutzerdimension der Regeln (Etappe-3-Entscheidung (2)) — siehe
|
||||
(k4)/(f4)(a) und die übrigen Bereiche mit derselben Beobachtung.
|
||||
- Die Modulkatalog-Regel für `Module` (Befund E, `module-registry`) — sobald
|
||||
eine Regel eingeführt wird, müssen die heute bewusst ungebundenen
|
||||
Katalogzugriffe nachgezogen werden.
|
||||
- Die Kennzeichnung der `bewusst-uebergreifend`-Stellen (Systemkontext,
|
||||
siehe "Die drei Klassen" im Klassifikationsdokument).
|
||||
- Der Mandantenwechsel im Ausschreibungs-Digest ((t4), der Sonderfall eines
|
||||
Nutzers mit Treffern unter zwei verschiedenen Mandanten).
|
||||
|
||||
**Was Etappe 4 (`rls-preflight.mjs`) VOR dem Scharfschalten prüfen muss** —
|
||||
die in den Bereichsabschnitten benannten Vorabprüfungen, als Liste (OHNE die
|
||||
jetzt erfüllte Befund-K-Bedingung, die entfällt):
|
||||
|
||||
- `ldap`: das Verstummen von `getAllActiveConfigs` (Befund B).
|
||||
- `dkv`: das Verstummen des Planer-Startpfads (WINDOWS #21).
|
||||
- `tenders`: wachsende Zahl von `TenderMatch`-Zeilen mit `notifiedAt IS NULL`
|
||||
ohne Versandprotokoll (t3).
|
||||
- `module-registry`: aktive Aktivierungszeilen vorhanden, aber die
|
||||
Auflösung liefert für einen bekannten Administrator eine leere Menge
|
||||
(WINDOWS #23).
|
||||
- `dashboard`: eine physisch vorhandene `DashboardLayout`-Zeile für einen
|
||||
bekannten Benutzer, aber der gebundene Lesezugriff liefert `null`
|
||||
(WINDOWS #25).
|
||||
- `calendar`: physisch vorhandene `CalendarSource`-Zeilen je Mandant über
|
||||
die Wartungsrolle zählen und mit der gebundenen Zählung vergleichen
|
||||
(k4)(e).
|
||||
- `auth`: einen bekannten Benutzer über die Wartungsrolle lesen und den
|
||||
gebundenen `findUnique` unter seinem Claim-Mandanten daneben halten
|
||||
(WINDOWS #28).
|
||||
- `favorites`: für einen bekannten Nutzer/Widget die Favoritenzahl über die
|
||||
Wartungsrolle und über den gebundenen `findMany` daneben halten (f4)(d).
|
||||
- `settings`: für einen bekannten Mandanten die `SmtpConfig`-Zeile über die
|
||||
Wartungsrolle lesen und den gebundenen `findUnique` daneben halten
|
||||
(s4)(e).
|
||||
- Das Verstummen des Mail-Startpfads (dieser Lauf, (s4)(a)) — EIGENES
|
||||
Signal, NICHT an #21 angeschlossen.
|
||||
|
||||
## Verweis
|
||||
|
||||
Die Bestandsaufnahme, welche Fundstelle den hier beschriebenen Übergang
|
||||
|
||||
@@ -143,11 +143,11 @@ autoritative Quelle.
|
||||
| auth | 3 | 10 | **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 | **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 | **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 | 7 | 0 | unverändert |
|
||||
| settings | 4 | 0 | unverändert |
|
||||
| **Summe** | **78** | **167** | 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), jetzt 78 nach 260911-fh9 (`auth` 8→3). 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`), jetzt 167 nach 260911-fh9 (zusätzlich 5 in `auth`). 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 |
|
||||
| favorites | 0 | 8 | **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 |
|
||||
| settings | 1 | 3 | **war 4/0** — 260911-gwh (Aufgabe 2) hat `getSmtpConfig`, `saveSmtpConfig`, `getDecryptedSmtpConfig` auf `forTenant()` umgestellt (3 gebundene `smtpConfig`-Rohtreffer). Der eine verbleibende ungebundene Rohtreffer ist der umbenannte Planer-Startpfad `loadAnySmtpConfigForStartupTransport()` (Befund D, WINDOWS #30) — bewusst, mit dreifacher Markierung; Befund K (`tenders`/`dkv` hängen an `getDecryptedSmtpConfig`) ist damit erfüllt |
|
||||
| **Summe** | **68** | **178** | 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, 64 Paare)
|
||||
## Klassen-Verteilung (nach (Datei, Modell)-Fundstellen, 65 Paare)
|
||||
|
||||
Stand 260909-jts (Aufgabe 3): 61 Paare aus dem vorherigen Durchlauf
|
||||
(260909-ipc) plus ein bisher vollstaendig unsichtbares Paar
|
||||
@@ -226,22 +226,35 @@ einer vergessenen Nachziehung nicht zu unterscheiden — deshalb steht die
|
||||
Abwesenheit einer Aenderung hier ausdruecklich, statt stillschweigend
|
||||
uebersprungen zu werden.
|
||||
|
||||
**Stand 260911-gwh (Aufgabe 3):** 65 Paare — 64 aus dem vorherigen Durchlauf
|
||||
plus EIN neues Paar (`favorites.service.ts`/`widgetInstance`, Klasse
|
||||
`muss-mandantengebunden`, Stand `gebunden`): der Besitzriegel in `create()`
|
||||
(T-GWH-05, Aufgabe 1 Pruefung 7 hat den Fremdschluessel-Durchgriff
|
||||
bestaetigt). Zwei Paare aendern nur ihre `Stand`-Spalte, keine ihrer Klasse:
|
||||
`favorites.service.ts`/`favoriteLink` (`ungebunden` auf `gebunden`) und
|
||||
`settings.service.ts`/`smtpConfig` (`ungebunden` auf `gemischt`). Das ist
|
||||
der ENDSTAND der Etappe 2 — die Zahl ist der Ausgabe von
|
||||
`rls-access-inventory.spec.ts` entnommen, nicht geschaetzt.
|
||||
|
||||
| Klasse | Anzahl Paare |
|
||||
|---|---|
|
||||
| muss-mandantengebunden | 32 |
|
||||
| muss-mandantengebunden | 33 |
|
||||
| keine-mandantengebundene-tabelle | 17 |
|
||||
| beides | 13 |
|
||||
| bewusst-uebergreifend | 2 |
|
||||
| **Summe** | **64** |
|
||||
| **Summe** | **65** |
|
||||
|
||||
## Der Hintergrunddienst als Falle — fünf Fälle
|
||||
## Der Hintergrunddienst als Falle — sechs Fälle
|
||||
|
||||
Ein Planer, der über alle Mandanten iteriert, liest zu Recht übergreifend —
|
||||
muss aber *innerhalb* der Schleife je Mandant binden. Vier Dateien sind in
|
||||
diesem Sinne `beides`-Fälle, davon einer (260910-das) der bislang EINZIGE,
|
||||
der auf BEIDEN Hälften bereits richtig ist; der fünfte, seit 260909-mir
|
||||
bekannte Fall ist von anderer Art und deshalb unten getrennt aufgeführt — er
|
||||
iteriert gar nicht, sondern greift sich eine beliebige Zeile heraus:
|
||||
iteriert gar nicht, sondern greift sich eine beliebige Zeile heraus. Der
|
||||
sechste Fall (seit 260911-gwh) ist von DERSELBEN Bauart wie der fünfte —
|
||||
kein Iterieren, eine beliebige-aber-vorhandene Zeile, kein Mandantenkontext
|
||||
beim Start — mit einer zusätzlichen Verdeckungsschicht, siehe unten:
|
||||
|
||||
- **`ldap.service.ts`** (AD-Abgleich) — **Stand 260909-ipc, Aufgaben 2/3:
|
||||
geschlossen.** Iteriert nicht selbst über alle Mandanten (der Sync läuft
|
||||
@@ -331,6 +344,61 @@ Verzweigung hinter einem optionalen Parameter, die jemand später
|
||||
`dkv-scheduler.service.ts`, Ledger-Eintrag WINDOWS #21. Das Signal für das
|
||||
Verstummen gehört in die Vorabprüfung von Etappe 4 (`rls-preflight.mjs`).
|
||||
|
||||
**Der sechste Fall, gleicher Bauart wie der fünfte — `mail.module.ts` /
|
||||
`SettingsService.loadAnySmtpConfigForStartupTransport()`** (260911-gwh,
|
||||
Befund D, WINDOWS #30): offen, als benannte Altlast weitergeführt. Dieselbe
|
||||
Form wie der fünfte Fall — `findFirst()` ohne jede Bedingung zieht bei
|
||||
mehreren Mandanten EINEN beliebigen und bedient die übrigen NIE; **heute
|
||||
bereits falsch**, weil der SMTP-Server und die Absenderadresse EINES
|
||||
beliebigen Mandanten die Kennwort-Zurücksetzungs- und Willkommensmails
|
||||
ALLER Mandanten tragen (T-GWH-03, Nutzung fremder Zugangsdaten). Binden ist
|
||||
auch hier keine Lösung: `useFactory` hat beim Start strukturell keinen
|
||||
Mandantenkontext. Der Umbau auf Transport je Versand aus
|
||||
`getDecryptedSmtpConfig(tenantId)` — die Form, die `DkvMailService`/
|
||||
`TenderMailService` bereits haben — ist eine Funktionsänderung
|
||||
(Umbau des Mailmoduls), kein Bindungsumbau, deshalb NICHT in Etappe 2
|
||||
vorgenommen.
|
||||
|
||||
Die UNSYMMETRIE zu BEIDEN Präzedenzfällen: `ldap.service.ts`/
|
||||
`getAllActiveConfigs` ist heute korrekt und verstummt erst später; der
|
||||
DKV-Planer (fünfter Fall) ist heute bereits falsch und verstummt zusätzlich
|
||||
später, ABER MIT einer Protokollzeile ("no active config found"). Der
|
||||
Mail-Startpfad ist heute bereits falsch UND verstummt später OHNE
|
||||
Protokollzeile, weil `mail.module.ts`s Rückfallkette (Priorität 2 `MAIL_*`,
|
||||
3 `TESSERA_SMTP_*`, 4 `localhost:1025`) einen FALSCHEN, aber vorhandenen
|
||||
Transport an die Stelle der Leere setzt — `MailService` fängt den
|
||||
Transportfehler (T-02-12), der Controller antwortet `200`. Das Verstummen
|
||||
ist damit DOPPELT verdeckt, eine dritte Ausprägung, die keiner der beiden
|
||||
Vorlagen (`ldap`, `dkv`) vollständig entspricht.
|
||||
|
||||
Dreifache Markierung: eigene benannte Methode mit Kopfkommentar (dkv-
|
||||
Präzedenzfall, ein Name, den niemand für einen Anfrageweg hält), Modul-
|
||||
kommentar in `mail.module.ts`, Ledger-Eintrag WINDOWS #30 (EIGENER Eintrag
|
||||
statt Anschluss an #21: andere Datei, andere Reparatur, andere
|
||||
Verdeckungsform). Das Signal für das Verstummen gehört ebenfalls in die
|
||||
Vorabprüfung von Etappe 4 (`rls-preflight.mjs`).
|
||||
|
||||
Befund K ist mit dieser Bindung ERFÜLLT: `getDecryptedSmtpConfig(tenantId)`
|
||||
— der einzige Versandpfad von `tender-mail.service.ts` und
|
||||
`dkv-mail.service.ts` — läuft seit 260911-gwh über `forTenant()`
|
||||
(siehe Bestandsaufnahme-Zeile `settings.service.ts`/`smtpConfig` oben und
|
||||
`docs/mandantentrennung-etappe2-fehlerrichtung.md`, Abschnitte "## Bereich
|
||||
tenders" (t4) und "## Bereich dkv" (d4), jeweils Nachtrag 260911-gwh); die
|
||||
Etappe-4-Vorabprüfung muss diese Reihenfolgebedingung ab jetzt NICHT mehr
|
||||
führen.
|
||||
|
||||
**Stand 260911-gwh — der Bereich `favorites` fügt diesem Abschnitt keinen
|
||||
weiteren Fall hinzu, gemessen statt angenommen (Befund K).** Anweisung:
|
||||
`grep -rn "@Cron\|onModuleInit\|onApplicationBootstrap\|setInterval\|setTimeout" apps/api/src/favorites apps/api/src/settings --include=*.ts | grep -v spec`
|
||||
liefert außerhalb von Testdateien genau EINEN Treffer,
|
||||
`icon-discovery.service.ts:255` — ein `setTimeout` für den Abbruch eines
|
||||
HTTP-Abrufs, kein Planer (dieselbe Form wie `ics.provider.ts:100` in
|
||||
260911-cwh). Die Bauform dieses Abschnitts (übergreifend LESEN über alle
|
||||
Mandanten, dann je Mandant BINDEN) kommt in `favorites` an keiner Stelle
|
||||
vor; der einzige Hintergrund-Zugriff des Bereichspaares ist der oben
|
||||
beschriebene sechste Fall, und der lebt nicht in `favorites`, sondern in
|
||||
`mail.module.ts`/`settings.service.ts`.
|
||||
|
||||
**Stand 260910-exd — kein sechster Fall, gemessen statt angenommen.** Der
|
||||
Bereich `module-registry` fügt diesem Abschnitt KEINEN sechsten Fall hinzu.
|
||||
`ModuleRegistryService.seedModule()` (die Katalogpflege beim Start,
|
||||
@@ -428,7 +496,8 @@ Aufgabe 3 behoben (Fan-out ersetzt den Relationszähler).
|
||||
| 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 | gemischt | 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()`. Die Mischung stammt ausschliesslich vom einen benannten, bewusst ungebundenen Planer-Startpfad `loadAnyActiveConfigForScheduler()` (WINDOWS #21) — keine uebersehene Fundstelle. |
|
||||
| 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). |
|
||||
| apps/api/src/favorites/favorites.service.ts | favoriteLink | muss-mandantengebunden | ungebunden | Favoriten-Links eines Nutzers, `tenantId`-Spalte vorhanden. |
|
||||
| apps/api/src/favorites/favorites.service.ts | favoriteLink | muss-mandantengebunden | gebunden | Favoriten-Links eines Nutzers, `tenantId`-Spalte vorhanden. Seit 260911-gwh (Aufgabe 2) laufen `list`, `create`, `update`, `remove`, `getIconBytes` vollstaendig ueber `forTenant()`, je Methode EIN Klient `tenantPrisma`; die Besitzpruefungen (`findUnique`, Vergleich `link.userId !== userId`, dann Schreibzugriff auf DEMSELBEN Klienten) bleiben zusaetzlich bestehen — die Regel auf `FavoriteLink` kennt keine Benutzerdimension (Aufgabe 1, Pruefung 4), die `userId`-Filter sind bis zur Etappe-3-Entscheidung (2) der einzige Schutz gegen Quer-Lesen zwischen Nutzern DESSELBEN Mandanten. Die Mandantenquelle ist dieselbe wie bei `dashboard` (`extractContext` im Controller), nicht das Claim wie bei `auth`. |
|
||||
| apps/api/src/favorites/favorites.service.ts | widgetInstance | muss-mandantengebunden | gebunden | NEUE Fundstelle (260911-gwh, Aufgabe 2): `create()` prueft ueber einen gebundenen `widgetInstance.findUnique` (`select: { userId: true }`), dass das Ziel-Widget (`dto.widgetId`) dem Aufrufer gehoert, BEVOR die Zeile angelegt wird — der Fremdschluessel `FavoriteLink.widgetId` prueft an der Zeilenschutz-Regel von `WidgetInstance` VORBEI (dokumentiertes PostgreSQL-Verhalten, Aufgabe 1 Pruefung 7 hat das GELINGEN eines gebundenen `create` mit einer fremdmandantigen `widgetId` bestaetigt); ohne den Riegel waere der Unterschied zwischen "Widget existiert nicht" (FK-Verletzung) und "gehoert einem fremden Mandanten" (gelingt) ein Existenzorakel ueber Mandantengrenzen (T-GWH-05). |
|
||||
| apps/api/src/groups/groups.service.ts | group | muss-mandantengebunden | gebunden | Gruppen sind je Mandant, `tenantId`-Spalte vorhanden. Alle 12 Methoden laufen seit 260909-jts (Aufgabe 2) ueber `forTenant()` bzw. `withTenantTransaction()`. |
|
||||
| apps/api/src/groups/groups.service.ts | groupMembership | muss-mandantengebunden | gebunden | Kein eigenes `tenantId`, RLS über Join auf `Group` (Migration 20260618112133-Nachfolger) — braucht trotzdem `forTenant()`, damit der Join-Kontext gesetzt ist. Seit 260909-jts gebunden, einschliesslich der drei Zugriffe innerhalb des Standardgruppen-Aufbaus (`ensureDefaultGroup`), die zuvor ueber den Transaktionsparameter liefen und fuer keine Pruefung dieses Projekts sichtbar waren (Befund B). |
|
||||
| apps/api/src/groups/groups.service.ts | moduleGrant | muss-mandantengebunden | gebunden | Modulfreigaben je Mandant, `tenantId`-Spalte vorhanden. Seit 260909-jts gebunden, einschliesslich des Zugriffs innerhalb des Standardgruppen-Aufbaus (Befund B). |
|
||||
@@ -450,7 +519,7 @@ Aufgabe 3 behoben (Fan-out ersetzt den Relationszähler).
|
||||
| apps/api/src/module-registry/module-access.service.ts | tenantModuleActivation | muss-mandantengebunden | gebunden | Aktivierung je Mandant, `tenantId`-Spalte vorhanden. Seit 260910-exd (Aufgabe 2) laufen Kurzschlusszweig, Schnittmengenabfrage und der eigene Lesezugriff von `getCatalogFlags` ueber `forTenant()`. |
|
||||
| apps/api/src/module-registry/module-registry.service.ts | module | keine-mandantengebundene-tabelle | ungebunden | Modulkatalog ist plattformweit. Bleibt bewusst ungebunden (260910-exd, Aufgabe 1, Befund E): keine Regel auf der Tabelle, eine Bindung waere heute wirkungslos, nicht katastrophal — katastrophal erst, WENN Etappe 3 dieser Tabelle eine Regel gibt. `findBySlug` ist zusaetzlich die Stelle, die `ModuleGuard` bei JEDER Modulanfrage aufruft. |
|
||||
| apps/api/src/module-registry/module-registry.service.ts | tenantModuleActivation | muss-mandantengebunden | gebunden | Aktivierung je Mandant. Seit 260910-exd (Aufgabe 3) laufen `findActiveForTenant`, `activateForTenant`, beide Zugriffe von `deactivateForTenant` (ueber EINEN Klienten) und `isModuleActive` ueber `forTenant()`, je Methode EIN Klient. |
|
||||
| apps/api/src/settings/settings.service.ts | smtpConfig | muss-mandantengebunden | ungebunden | SMTP-Zugangsdaten je Mandant, `tenantId`-Spalte vorhanden. |
|
||||
| apps/api/src/settings/settings.service.ts | smtpConfig | muss-mandantengebunden | gemischt | SMTP-Zugangsdaten je Mandant, `tenantId`-Spalte vorhanden. Seit 260911-gwh (Aufgabe 2) laufen `getSmtpConfig`, `saveSmtpConfig`, `getDecryptedSmtpConfig` ueber `forTenant()`. Die Mischung stammt ausschliesslich vom einen benannten, bewusst ungebundenen Planer-Startpfad `loadAnySmtpConfigForStartupTransport()` (WINDOWS #30, sechster Fall der Hintergrunddienst-Falle) — keine uebersehene Fundstelle, dieselbe Form wie `dkv.service.ts`/`dkvModuleConfig`. Befund K (`tender-mail.service.ts`/`dkv-mail.service.ts` haengen an `getDecryptedSmtpConfig`) ist damit erfuellt. |
|
||||
| apps/api/src/tenant/tenant.controller.ts | tenant | keine-mandantengebundene-tabelle | ungebunden | `Tenant` ist die Mandantentabelle selbst — hat keine eigene `tenantId`-Spalte, kann sie per Definition nicht haben (Migration 20260909140000, Gruppe b). Gemessen 260911-e2s (Aufgabe 1, Prüfung 1/2): keine Regel auf `Tenant` in irgendeiner der 34 ausgelieferten Migrationen einschließlich `20260910120000_rls_widen_membership_grant_and_platform_read`; gebunden und ungebunden liefern über Roh-SQL UND generierten Client dieselben Zeilen. |
|
||||
| apps/api/src/tenant/tenant.controller.ts | user | muss-mandantengebunden | gebunden | Seit 260911-e2s (Aufgabe 3): `findAll`/`findOne`/`remove` zählen Benutzer je Mandant über drei gebundene Aufrufstellen (`tenantPrisma.user.count`, Fan-out-Muster aus `UserService.findAllForPlatformAdmin`) statt über den früheren Relationszähler (`include: { _count: { select: { users } } }`), der nach dem Scharfschalten unter der Regel von `User` unbemerkt null geliefert hätte (260911-e2s Aufgabe 1, Prüfungen 5-7). `where: { tenantId }` bleibt heute (Rolle mit BYPASSRLS, WINDOWS #18) der einzige wirksame Filter. |
|
||||
| apps/api/src/tenant/tenant.service.ts | tenant | keine-mandantengebundene-tabelle | ungebunden | Dieselbe Begründung. Gemessen 260911-e2s (Aufgabe 1, Prüfung 1/2): keine Regel auf `Tenant` in irgendeiner der 34 ausgelieferten Migrationen einschließlich `20260910120000_rls_widen_membership_grant_and_platform_read`. |
|
||||
@@ -533,3 +602,13 @@ Aufgabe 3 behoben (Fan-out ersetzt den Relationszähler).
|
||||
an `username`/`email`. Siehe
|
||||
`docs/mandantentrennung-etappe2-fehlerrichtung.md`, "## Bereich auth",
|
||||
(h4)(a).
|
||||
- **Wie das Mailmodul künftig je Mandant versendet (260911-gwh).** Der
|
||||
Startpfad `loadAnySmtpConfigForStartupTransport()` bleibt bewusst
|
||||
ungebunden (sechster Fall der Hintergrunddienst-Falle, WINDOWS #30, siehe
|
||||
oben) — ein Umbau auf Transport je Versand aus
|
||||
`getDecryptedSmtpConfig(tenantId)`, die Form, die `DkvMailService`/
|
||||
`TenderMailService` bereits haben, ist eine Funktionsänderung
|
||||
(Umbau des Mailmoduls), kein Bindungsumbau, und deshalb NICHT Gegenstand
|
||||
dieser Etappe. Siehe
|
||||
`docs/mandantentrennung-etappe2-fehlerrichtung.md`, "## Bereich settings",
|
||||
(s4)(a).
|
||||
|
||||
Reference in New Issue
Block a user