feat(quick-260911-cwh): Fehlerrichtung Bereich calendar messen (Aufgabe 1)

- rls-scratch-check.mjs: elfter Abschnitt runCalendarAreaChecks mit 13
  namentlich benannten Pruefungen gegen die aus 20260909140000_rls_remaining_tenant_tables
  geschnittene Regel, davon 4 ueber den generierten Client an einer
  schemagleichen Wegwerf-Tabelle (17 Spalten, gegen schema.prisma
  laufzeitgeprueft); Laufzeitpruefung, dass 20260910120000 keine eigene
  CalendarSource-Regel traegt (Messfalle 260910-jab)
- Alle 101 Pruefungen bestehen (88 bisherige + 13 neue)
- docs/mandantentrennung-etappe2-fehlerrichtung.md: Abschnitt
  "## Bereich calendar" mit (k1)-(k5) — Messung, Signaltabelle je Pfad,
  Leere-als-Abwesenheit in Backend UND Frontend samt Fehlerverschluckung,
  bewusst nicht geloest (Cache-Schluessel-Urteil, Zugangsdaten-Erhaltung,
  fehlende Benutzerdimension, 403/404), bewusst nicht angefasst
- Wettlauf-Fehlerklasse gemessen: PrismaClientKnownRequestError (P2025),
  NICHT PrismaClientUnknownRequestError wie im Bereich dashboard — Aufgabe
  2 braucht deshalb keine neue Fehleruebersetzung fuer die Besitzpruefungen

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
This commit is contained in:
2026-09-11 09:48:44 +02:00
parent 508d9e4301
commit bf5fc4d4c7
2 changed files with 591 additions and 0 deletions
@@ -2000,6 +2000,265 @@ Abweichung von seiner eigenen Auswahl liest, bleibt das unbemerkt.
- Schema und Migrationen — geprüft und bewusst gelassen, keine
Schemaänderung in dieser Etappe.
## Bereich calendar
Dieser Abschnitt erweitert die Kritikschrift um den Bereich `calendar`
(Quick-Task 260911-cwh), den neunten Bereich der Etappe und den einzigen
Dienst, der nicht bloß Daten hält, sondern ZUGANGSDATEN zu fremden Servern —
die verschlüsselten Exchange-, CalDAV- und ICS-Anmeldungen eines Nutzers.
Ein Quer-Lesen ist hier nicht Offenlegung eines Termins, sondern Offenlegung
der Anmeldung einer anderen Firma bei ihrem Mailserver. Die umgekehrte
Fehlerrichtung sieht hier nicht wie ein Fehler aus, sondern wie ein leerer
Kalender — und das Frontend verstärkt das, siehe (k3).
### (k1) Die Messung
Aufgabe 1 hat `apps/api/scripts/rls-scratch-check.mjs` um einen elften
Abschnitt (`runCalendarAreaChecks`) erweitert, unmittelbar nach
`runDashboardAreaChecks` und vor `runTransactionShapeMeasurement` aufgerufen.
Er legt die Wegwerf-Tabelle `CalendarSource` selbst neu an, mit SÄMTLICHEN
17 Spalten des Modells (nicht nur den für Roh-SQL nötigen — die Lehre aus
Prüfung 5b im Bereich `dashboard`: der generierte Client wählt standardmäßig
JEDE Spalte des Modells aus und scheitert mit P2022 an jeder fehlenden), mit
der Regel `extractPolicySql()` WORTGLEICH aus der ausgelieferten Migration
`20260909140000_rls_remaining_tenant_tables` geschnitten, **gemessen am
Regelstand NACH der Migration
`20260910120000_rls_widen_membership_grant_and_platform_read`**. Diese
zweite Migration wird zur LAUFZEIT geprüft (Prüfung
`calendarsource-regelstand-eindeutig`), nicht nur zur Planungszeit behauptet:
`extractPolicySql(readRlsWidenMigrationSql(), 'CalendarSource')` liefert
`null` — jene Migration trägt KEINE eigene Regel für `CalendarSource`, der
Stand aus `20260909140000` ist weiterhin der ausgelieferte, aktuelle
Regelstand. Fände sich dort doch eine Regel, bräche der Abschnitt mit einer
FEHLGESCHLAGENEN Prüfung ab, statt die abgelöste Regel weiterzumessen — die
Messfalle aus 260910-jab.
Vier der dreizehn neuen Prüfungen laufen über den GENERIERTEN Prisma-Client
(nicht nur an rohem SQL), weil `calendar.service.ts` in Wahrheit
`this.prisma.calendarSource.findMany/update/create()` aufruft, nicht
`$executeRaw` — dieselbe dashboard-Lehre: Prüfung 8
(`calendarsource-wegwerftabelle-deckt-alle-spalten-des-generierten-clients`)
vergleicht die zur Laufzeit aus `schema.prisma` gelesenen 17 Feldnamen des
Modells `CalendarSource` mit den tatsächlichen Spalten der Wegwerf-Tabelle
über `information_schema.columns` — sie steht VOR den Client-Prüfungen 9-12
und bricht den Abschnitt ab, wenn sie durchfällt, weil alle folgenden
Client-Messungen sonst wertlos wären.
Tatsächlich beobachtete Ausgabe dieses Laufs (2026-09-11, gegen
`tessera-ctl-db-1`, Adresse `172.19.0.2`, nur die dreizehn neuen Zeilen
dieses Abschnitts sowie die abschließende Summenzeile):
```
calendarsource-regelstand-eindeutig: bestanden — die *_rls_widen_membership_grant_and_platform_read-Migration (260910-jab) enthaelt KEINE eigene Regel fuer "CalendarSource" (Befund G) — der Stand aus 20260909140000_rls_remaining_tenant_tables ist weiterhin der ausgelieferte, aktuelle Regelstand
calendarsource-wegwerftabelle-deckt-alle-spalten-des-generierten-clients: bestanden — Schema-Felder aus schema.prisma (model CalendarSource, 17): ["color","createdAt","domain","encryptedPassword","exchangeMode","id","isVisible","lastSyncAt","lastSyncError","name","syncIntervalMin","tenantId","type","updatedAt","url","userId","username"]; Spalten der Wegwerf-Tabelle (17): ["color","createdAt","domain","encryptedPassword","exchangeMode","id","isVisible","lastSyncAt","lastSyncError","name","syncIntervalMin","tenantId","type","updatedAt","url","userId","username"]
calendarsource-gebunden-nur-eigener-mandant: bestanden — forTenant(TENANT-A) liefert 2 Zeile(n): ["source-a1","source-a2"]
calendarsource-fremder-nutzer-desselben-mandanten-gebunden-sichtbar: bestanden — forTenant(TENANT-A) liefert die Quelle von 'user-a2' (anderer Benutzer, gleicher Mandant) mit encryptedPassword="enc(a2-passwort-platzhalter)" — die Regel auf "CalendarSource" kennt keine Benutzerdimension, die verschluesselten Zugangsdaten eines Kollegen DESSELBEN Mandanten sind auf Datenbankebene lesbar; die anwendungsseitige Filterung ueber die Benutzerkennung bleibt deshalb der einzige Schutz, bis die Etappe-3-Entscheidung (2) die Benutzerdimension nachzieht
calendarsource-ungebunden-null-zeilen: bestanden — ungebundener SELECT auf "CalendarSource" liefert 0 Zeile(n), tatsaechlich vorhanden sind 3
calendarsource-ungebundene-einzelabfrage-ueber-kennung-liefert-keine-zeile: bestanden — ungebundenes SELECT ueber die Kennung 'source-b1' (vorhanden) liefert 0 Zeile(n) — die Datenbankseite der drei Besitzpruefungen: ein ungebundenes Nachschlagen ueber eine vorhandene Kennung liefert null Zeilen, das ist der Weg in NotFoundException
calendarsource-gebundenes-einfuegen-fremder-mandant-abgelehnt: bestanden — gebundenes INSERT unter TENANT-A mit tenantId=TENANT-B abgewiesen mit SQLSTATE 42501 (Invalid `prisma.$executeRaw()` invocation: Raw query failed. Code: `42501`. Message: `ERROR: new row violates row-level security policy for table "CalendarSource"`)
calendarsource-gebundenes-loeschen-fremder-zeile-trifft-keine-zeile: bestanden — gebundenes DELETE unter TENANT-A ueber die Kennung 'source-b1' (gehoert TENANT-B) trifft 0 Zeile(n); ueber die Wartungsrolle ist die Zeile danach noch vorhanden: true
calendarsource-gebundenes-update-ueber-kennung-allein-trifft-null-zeilen: bestanden — gebundenes UPDATE ... WHERE id = 'source-b1' (gehoert TENANT-B) unter TENANT-A trifft 0 Zeile(n), lastSyncError bleibt unveraendert
calendarsource-generierter-client-gebundene-quellenliste-nur-eigener-mandant: bestanden — bound.calendarSource.findMany({ where: { userId: 'user-a1', isVisible: true } }) unter TENANT-A liefert 1 Zeile(n): ["source-a1"] — die Abfrage, die fetchAndCacheEvents stellt
calendarsource-generierter-client-ungebundene-quellenliste-null-zeilen: bestanden — dieselbe Abfrage auf dem UNGEBUNDENEN generierten Client liefert 0 Zeile(n) ohne Fehler — exakt der Wert, den getSources als "keine Quelle" und fetchAndCacheEvents als "keine Termine" weiterreicht
calendarsource-generierter-client-gebundenes-update-auf-unsichtbare-zeile-scheitert-laut: bestanden — bound.calendarSource.update unter TENANT-A auf die unter TENANT-B unsichtbare Zeile (source-b1) wirft PrismaClientKnownRequestError (code P2025): Invalid `prisma.calendarSource.update()` invocation: An operation failed because it depends on one or more records that were required but not found. No record was found for an update.
calendarsource-generierter-client-gebundenes-anlegen-eigener-mandant-gelingt: bestanden — bound.calendarSource.create unter TENANT-A gelingt (id=62b383e2-4768-48f2-997a-cc5251174f8b, createdAt="2026-09-11T07:44:53.869Z"), gebunden lesbar: true — bestaetigt nebenbei, dass die Wegwerf-Tabelle die clientseitig erzeugten Werte (id, createdAt, updatedAt) annimmt
Alle 101 Pruefungen bestanden.
```
Dreizehn neue Prüfungen (101 = 88 + 13), nicht zwölf wie in der Aufzählung
des Plans namentlich vorgezeichnet — die dreizehnte
(`calendarsource-regelstand-eindeutig`) wurde ergänzt, weil sie die
Messfalle aus 260910-jab zur LAUFZEIT prüft statt sie nur als Planungsprosa
festzuhalten, derselbe Grund, aus dem der Bereich `dashboard` eine
dreizehnte Prüfung ergänzt hatte.
**Die tragende Belegzeile ist `calendarsource-ungebunden-null-zeilen`:** der
IDENTISCHE `SELECT "tenantId" FROM "CalendarSource"` ohne vorheriges
`set_config` liefert **0 Zeilen**, nicht die 3 tatsächlich vorhandenen — an
der echten, ausgelieferten Policy gemessen.
**Das Wettlauf-Ergebnis aus Befund H/K ist gemessen, nicht vorweggenommen —
und es ist eine ANDERE Fehlerklasse als im Bereich `dashboard`.** Ein
gebundenes `bound.calendarSource.update({ where: { id: 'source-b1' }, ... })`
unter TENANT-A auf die unter TENANT-B unsichtbare Zeile wirft eine
**`PrismaClientKnownRequestError` mit `.code === 'P2025'`** ("Record to
update not found") — NICHT die `PrismaClientUnknownRequestError`, die
`dashboardLayout.upsert()` im Bereich `dashboard` bei genau diesem
Wettlauf-Fall geworfen hat. Der Unterschied erklärt sich aus der Form des
Zugriffs: `dashboardLayout.upsert()` löst ein `INSERT ... ON CONFLICT`
aus, das die RLS-`USING`-Klausel beim `INSERT`-Zweig verletzt (SQLSTATE
`42501`, von Prisma als unbekannter Fehler durchgereicht); ein einfaches
`calendarSource.update({ where: { id } })` ohne Konfliktbehandlung sieht
unter der gebundenen Regel schlicht KEINE passende Zeile — dasselbe
Verhalten wie ein `UPDATE` über eine nicht existierende Kennung, das Prisma
grundsätzlich als P2025 meldet. **Das bedeutet für Aufgabe 2: keine der drei
Besitzprüfungsschreibpfade (`updateSource`/`deleteSource`/`testConnection`)
braucht eine neue Fehlerübersetzung** — P2025 wäre ohnehin nicht der Pfad,
über den ein Nutzer diese Zeile erreicht (die vorgeschaltete
`findUnique`-Besitzprüfung fängt den Fall vorher über `NotFoundException`
ab), sondern ausschließlich der Wettlauf-Fall der Aggregationsschleife
(Befund K, siehe (k2)).
### (k2) Signaltabelle je umgestelltem Pfad
| Pfad | Verhalten bei zu wenig Ergebnis | Konkretes Signal, Ort | Frontend lässt Signal durch? |
|---|---|---|---|
| `CalendarService.getSources` | Der gebundene Lesezugriff liefert eine leere Liste statt der vorhandenen Quellen | `GET /calendar/sources` liefert `[]`, Status 200, kein Fehler | Nein — `calendar-widget.tsx` zeigt `emptyNoSources` ("keine Quelle eingerichtet"), `calendar-settings-panel.tsx` zeigt `sourceEmpty`; beide ununterscheidbar vom echten Erstbenutzer-Zustand |
| `CalendarService.addSource` | Kein Leere-Fall in diese Richtung — die Mandantenkennung ist Pflichtparameter | Schlägt ohne Mandant im Controller bereits mit `ForbiddenException` fehl | — |
| `CalendarService.updateSource`, Besitzprüfung | Die gebundene `findUnique`-Abfrage liefert `null` statt der eigenen Zeile — ununterscheidbar vom echten "gehört jemand anderem" | `NotFoundException('Calendar source not found')` — dieselbe Meldung wie beim echten Besitzverstoß, siehe (k4)(d) für 403/404 | `calendar-settings-panel.tsx` fängt Schreibfehler bei `handleVisibilityToggle` (`catch { revert }`) bzw. beim Formular (`saveError`/`editSaveError`) — sichtbar als Fehlermeldung, NICHT als leerer Zustand |
| `CalendarService.deleteSource`, Besitzprüfung | Wie bei `updateSource`: `null` statt der eigenen Zeile | `NotFoundException` — dieselbe Meldung wie beim echten Besitzverstoß | Wie oben — Löschfehler werden sichtbar gemeldet, nicht verschluckt |
| `CalendarService.testConnection`, Besitzprüfung | Wie oben; zusätzlich der Wettlauf-Fall der beiden Rückschreibungen bei Erfolg/Fehler (Befund K) | `NotFoundException` bei fehlender/fremder Zeile; ein Wettlauf zwischen Nachschlagen und Rückschreiben würfe gemessen `PrismaClientKnownRequestError` (P2025, siehe (k1)) — heute strukturell ausgeschlossen, weil derselbe `tenantPrisma`-Klient beide Schritte trägt | `testResults`-State zeigt `'error'` — sichtbar, nicht verschluckt |
| `CalendarService.fetchAndCacheEvents` | Der gebundene Lesezugriff auf die Quellenliste liefert eine leere Liste statt der sichtbaren Quellen — `if (sources.length === 0) return [];` kehrt VOR dem Cache-Eintrag zurück, ein leeres Ergebnis wird also NIE zwischengespeichert, jeder Aufruf misst neu leer. Der Wettlauf-Fall der beiden Synchronstatus-Rückschreibungen (Befund K) ist gemessen: `PrismaClientKnownRequestError` (P2025) — strukturell ausgeschlossen durch EINEN `tenantPrisma`-Klient je Aufruf | `GET /calendar/events` liefert `[]`, Status 200, kein Fehler, keine Protokollzeile | Nein — `calendar-widget.tsx` zeigt bei leerem `events` (aber `hasSources === true`) `emptyNoEvents` ("keine Termine"); ein LAUTER Fehler von `fetchEvents()` ODER `fetchSources()` landet im selben `catch` und setzt ebenfalls `events = []` — die Unterscheidung zwischen "leer" und "Fehler" existiert im State nicht |
| `CalendarService.refreshCacheInBackground` | Ruft `fetchAndCacheEvents` mit der Mandantenkennung der urspünglichen Anfrage auf (kein eigener Kontext, siehe Befund B) — dasselbe Leere-Verhalten wie oben, zusätzlich verschluckt durch `.catch((error) => this.logger.warn(...))`: selbst ein LAUTER Fehler der Hintergrundauffrischung erzeugt nur eine Protokollzeile, kein Signal an den Aufrufer, der die Antwort bereits erhalten hat | Kein HTTP-Signal — die auslösende Anfrage ist bereits beantwortet, bevor die Hintergrundauffrischung beginnt | Kann das Frontend strukturell nicht erreichen — die Anfrage, die es ausgelöst hat, ist längst beantwortet |
### (k3) Welcher Code Leere als Abwesenheit deutet
**Backend, zwei Stellen (Befund I):** `CalendarService.getSources` liefert
bei null Treffern eine leere Liste, Status 200 — dieselbe Deutung wie überall
in dieser Etappe. `CalendarService.fetchAndCacheEvents`,
`if (sources.length === 0) return [];`: kehrt VOR dem Cache-Eintrag zurück,
ein leeres Quellenergebnis wird also nicht einmal für die TTL festgehalten,
sondern bei JEDEM Aufruf neu leer gemessen — anders als ein echter
Cache-Treffer, der fünf Minuten stehen bleibt. Die drei
Besitzprüfungspfade (`updateSource`, `deleteSource`, `testConnection`) sind
dagegen LAUT: ein zu kleines Nachschlagen wirft `NotFoundException`.
**Frontend, drei Dateien, zur Ausführungszeit erneut nachgeprüft (Befund
J), nicht aus dem Plan abgeschrieben — dieser Plan ändert an KEINER der drei
Dateien etwas:**
1. `apps/web/src/components/dashboard/widgets/calendar-widget.tsx`, Zeile
37: `if (sources.length === 0)` setzt `hasSources = false`, gerendert als
`emptyNoSources` ("keine Quelle eingerichtet"). Zeile 50:
`catch { // Silent fail — show empty state }` fängt jeden Fehler von
`fetchSources()` ODER `fetchEvents()` — bei einem Fehler bleibt
`hasSources` jedoch beim vorherigen Wert (nicht `false`) und `events`
wird auf `[]` gesetzt, wodurch die Render-Logik in den Zweig
`events.length === 0` fällt und `emptyNoEvents` ("keine Termine")
zeigt — NICHT `emptyNoSources`. Ein LAUTER Fehler (403, 500,
Netzwerkfehler) auf `GET /calendar/sources` oder `GET /calendar/events`
ist damit für den Nutzer vom echten "Quellen vorhanden, aber gerade
keine Termine" nicht zu unterscheiden — beide zeigen `emptyNoEvents`.
2. `apps/web/src/components/settings/calendar-settings-panel.tsx`, Zeile
49: `.catch(() => { // Silent fail — show empty state })` — hier bleibt
`sources` beim initialen `[]`, unabhängig davon, ob `fetchSources()` eine
echte leere Antwort ODER einen LAUTEN Fehler liefert; Zeile 127:
`sources.length === 0` zeigt `sourceEmpty`. Auf dieser Seite sind "keine
Quelle" und "Fehler beim Laden" damit VOLLSTÄNDIG ununterscheidbar — eine
schärfere Form derselben Verschluckung als im Widget.
3. `apps/web/src/components/settings/calendar-source-form.tsx`, Zeile 149:
`if (password) payload.password = password;` — nicht Leere, sondern
Weglassen; gehört hierhin, weil es die Erhaltungsfrage beantwortet (siehe
(k4)(b)): ein leer gelassenes Passwortfeld wird aus dem Sendeobjekt
WEGGELASSEN, nicht als leere Zeichenkette übertragen.
**Die Folgekette, abgegrenzt gegen `dashboard` (Befund J):** leerer Kalender
oder leere Quellenliste → der Nutzer liest das als "die Synchronisation ist
kaputt" oder "ich habe keine Quelle eingerichtet" → er legt seine Quelle
NEU an (`addSource`, gebunden, gelingt) → er tippt sein Exchange- oder
CalDAV-Passwort ein ZWEITES MAL in ein System, das gerade aussieht, als wäre
es defekt → die ursprüngliche Zeile bleibt unsichtbar liegen. Anders als bei
`dashboard` wird dabei NICHTS überschrieben (`CalendarSource` hat keinen
automatischen Rückschreibpfad wie `setEditMode` im Dashboard) — die
Zerstörung ist hier keine verlorene Aufzeichnung, sondern eine preisgegebene
Anmeldung: der Nutzer gibt Zugangsdaten zu einem fremden Mailserver in ein
System ein, das er für kaputt hält, und sobald die Ursache behoben ist
(Etappe 4), liegen ZWEI Quellen mit ZWEI Sätzen von Zugangsdaten für
denselben Server vor — Dubletten bei Quellen UND bei Terminen.
**Die Fehlerverschluckung als eigener Punkt:** selbst ein LAUTER
Backend-Fehler auf `GET /calendar/sources` oder `GET /calendar/events` (403,
500, Netzwerkfehler) ist für den Nutzer vom leeren Kalender nicht zu
unterscheiden — das Signal existiert ausschließlich im Netzwerkprotokoll des
Browsers und im API-Log, an keiner Stelle in der Benutzeroberfläche.
### (k4) Was dieser Durchlauf bewusst nicht löst
- **(a) Das Urteil zum Cache-Schlüssel**, vierteilig belegt, jedes Glied
einzeln nachgesehen: `eventCache` (Zeile 109 alt) wird mit
`${userId}:${from}:${to}` beschlüsselt. `userId` kommt aus
`calendar.controller.ts` `extractContext` (`req.user?.id`); das ist laut
`apps/api/src/auth/strategies/jwt.strategy.ts` `validate` (Zeile 27-34)
wörtlich `id: payload.sub`; `payload.sub` ist laut
`apps/api/src/auth/auth.service.ts` (Zeilen 143 und 332, beide
`sub: user.id`) die Datenbankkennung `User.id`; die trägt laut
`apps/api/prisma/schema.prisma` `model User` `@id @default(uuid())`.
**Urteil:** der Schlüssel trägt eine plattformweit eindeutige UUID, kein
Anmeldename — die Etappe-3-Entscheidung (1) des Users (Anmeldenamen
eindeutig PRO MANDANT statt plattformweit) betrifft `User.username` und
`User.email`, nicht `User.id`, und berührt den Schlüssel deshalb NICHT.
Der Schlüssel bleibt unverändert; das Urteil steht zusätzlich als
Kommentar unmittelbar über der `eventCache`-Zuweisung in
`calendar.service.ts` (Aufgabe 2).
- **(b) Der Befund zur Zugangsdaten-Erhaltung (Befund E)**, an vier Stellen
zur Ausführungszeit nachgelesen: `calendar.service.ts` `updateSource`
besitzt KEINEN Lesezugriff, der ein gespeichertes Passwort lädt, um es neu
zu verschlüsseln — die `dkv`-Form (lesen, entschlüsseln, neu
verschlüsseln) existiert hier nicht. Stattdessen: `if (dto.password !== undefined)`
entscheidet, ob überhaupt geschrieben wird — Feld FEHLT im Rumpf →
`encryptedPassword` bleibt im `update`-Aufruf gänzlich unerwähnt und damit
in der Datenbank unverändert; Feld LEER (`''`) → wird explizit auf `null`
gesetzt (Löschen); Feld GESETZT → wird verschlüsselt. Auf der Web-Seite
(`calendar-source-form.tsx`, Zeile 149) wird ein leer gelassenes
Passwortfeld WEGGELASSEN, nicht als leere Zeichenkette gesendet — die
Erhaltung läuft also über das Weglassen des Felds im Rumpf, nicht über
einen Lesezugriff, der nach dem Scharfschalten leerlaufen könnte. Die
beiden lesenden Stellen (`testConnection`, `fetchAndCacheEvents`)
entschlüsseln nur, um den Provider aufzurufen, und schreiben nichts
Entschlüsseltes zurück. Als drei Testfälle in Aufgabe 2 festgenagelt.
- **(c) Die fehlende Benutzerdimension der Regel (Befund G)**, gemessen in
(k1) (`calendarsource-fremder-nutzer-desselben-mandanten-gebunden-sichtbar`):
die verschlüsselten Exchange-/CalDAV-Zugangsdaten eines Kollegen
DESSELBEN Mandanten sind auf Datenbankebene lesbar, bis die
Etappe-3-Entscheidung (2) des Users die Benutzerdimension in die Regel
aufnimmt (`CalendarSource` steht dort ausdrücklich in der Liste). Bis
dahin bleiben der `userId`-Filter in `getSources`/`fetchAndCacheEvents`
und die drei Besitzprüfungen der EINZIGE Schutz.
- **(d) 403 statt 404 als Existenzpreisgabe zwischen Kollegen (Befund D):**
`updateSource`, `deleteSource` und `testConnection` werfen bei fremdem
Besitz `ForbiddenException('Not your calendar source')` (403), bei
unbekannter Kennung `NotFoundException('Calendar source not found')`
(404) — ein Kollege DESSELBEN Mandanten erfährt über 403 die Existenz
einer fremden Quellenkennung, ein Nutzer eines FREMDEN Mandanten bekommt
nach der Bindung durchgängig 404 (die Zeile ist für ihn unsichtbar).
Kennungen sind UUIDs, nicht erratbar. Die Antwortsemantik wird von diesem
Plan NICHT geändert (wäre eine API-Änderung außerhalb des Auftrags).
- **(e) Die fehlende Unterscheidbarkeit von "keine Quelle" und "Quelle
nicht sichtbar".** Beide liefern identisch eine leere Liste, Status 200,
keinen Protokolleintrag — siehe (k3). Die konkrete Vorabprüfung für
Etappe 4 (`rls-preflight.mjs`): physisch vorhandene
`CalendarSource`-Zeilen je Mandant über die Wartungsrolle zählen und mit
der gebundenen Zählung je Mandant vergleichen — jede Abweichung ist ein
Trennungsfehler, kein Erstbenutzer. Eine Laufzeitwarnung an
`getSources`/`fetchAndCacheEvents` wurde erwogen und VERWORFEN, mit
derselben Begründung wie bei `getAllActiveConfigs` im Bereich `ldap` und
bei `getLayout`/`getWidgets`/`getSearchProviders` im Bereich `dashboard`:
keine Quelle ist auf einer frischen Installation oder für einen neuen
Nutzer der NORMALZUSTAND — eine Warnung an dieser Stelle wäre Dauerlärm
und verlöre ihr Signal, bevor sie gebraucht wird.
### (k5) Was dieser Durchlauf bewusst nicht anfasst
- **Das Frontend** — geprüft (Befund I/J, (k3) oben) und bewusst gelassen,
keine Datei dieses Plans. `calendar-widget.tsx`,
`calendar-settings-panel.tsx` und `calendar-source-form.tsx` werden NICHT
geändert.
- **Die drei Provider** (`ics.provider.ts`, `caldav.provider.ts`,
`exchange.provider.ts`) — reden mit echten Servern, werden in Aufgabe 2
NICHT ausgeübt, nur als Attrappen (`fetchEvents`/`testConnection` als
`vi.fn`) verwendet.
- **`testConnectionFromConfig`** — kein Datenbankzugriff, kein Mandant
(prüft eine Konfiguration, bevor sie gespeichert wird), unverändert.
- **Der Bereich `favorites`** — eigener Bereich mit eigener Umstellung,
nicht Teil dieses Plans.
- **Die Antwortsemantik 403/404** — siehe (k4)(d), bewusst nicht geändert.
- **Der Fremdkommentar in `apps/api/src/ldap/ldap-config.service.ts`**
(Zeile 24, nennt `CalendarSource` als Vorbild der Verschlüsselung) —
zutreffend (`CalendarCryptoService`, siehe Dezision `07-01` in
STATE.md), nicht zu ändern.
- Schema und Migrationen — geprüft und bewusst gelassen, keine
Schemaänderung in dieser Etappe.
## Verweis
Die Bestandsaufnahme, welche Fundstelle den hier beschriebenen Übergang