diff --git a/backend/src/routes/invitations.ts b/backend/src/routes/invitations.ts index 592246c..5876ce4 100644 --- a/backend/src/routes/invitations.ts +++ b/backend/src/routes/invitations.ts @@ -11,10 +11,11 @@ import { members, users, teamMembers, + chatRooms, orgRoleEnum, } from "../db/schema.js"; import { sendMail } from "../services/mail.js"; -import { createUser, toMatrixUserId, toLocalpart, SynapseError } from "../services/synapse.js"; +import { createUser, addUserToRoom, toMatrixUserId, toLocalpart, SynapseError } from "../services/synapse.js"; import { enqueueSyncJob } from "../services/sync-worker.js"; import { requesterIsOrganizationAdmin } from "../lib/authz.js"; import { logAudit } from "../lib/audit.js"; @@ -231,6 +232,42 @@ export async function invitationRoutes(app: FastifyInstance) { memberId: member.id, role: invitation.role, }); + + // BUG-18: this used to only write the team_members row — the member never + // actually joined the Matrix room, so PROJ-11 chat stayed stuck on "Raum + // wird eingerichtet" forever for anyone who joined via invitation. Mirror + // the join logic teams.ts uses when adding a member to a team. + const [room] = await db + .select({ matrixRoomId: chatRooms.matrixRoomId }) + .from(chatRooms) + .where(eq(chatRooms.teamId, invitation.teamId)) + .limit(1); + const [provisioned] = await db + .select({ matrixUserId: members.matrixUserId }) + .from(members) + .where(eq(members.id, member.id)) + .limit(1); + + if (room && provisioned?.matrixUserId) { + try { + await addUserToRoom(room.matrixRoomId, provisioned.matrixUserId); + } catch (err) { + console.warn( + `[synapse] inline room join failed for member ${member.id}, queuing retry:`, + err, + ); + await enqueueSyncJob(invitation.organizationId, "join_team_room", { + teamId: invitation.teamId, + memberId: member.id, + }); + } + } else { + // Room or matrix account not provisioned yet — worker retries once both exist. + await enqueueSyncJob(invitation.organizationId, "join_team_room", { + teamId: invitation.teamId, + memberId: member.id, + }); + } } await db diff --git a/backend/src/server.ts b/backend/src/server.ts index 1e5ffd2..fad8f25 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -1,5 +1,5 @@ import "dotenv/config"; -import Fastify, { type FastifyRequest, type FastifyReply } from "fastify"; +import Fastify, { type FastifyRequest, type FastifyReply, type FastifyError } from "fastify"; import cors from "@fastify/cors"; import jwt from "@fastify/jwt"; import rateLimit from "@fastify/rate-limit"; @@ -32,6 +32,21 @@ app.decorate("authenticate", async (request: FastifyRequest, reply: FastifyReply } }); +// BUG-19: without this, an uncaught error (e.g. a Postgres unique-constraint +// violation) falls through to Fastify's default handler, which echoes +// `err.message` verbatim — for Drizzle that includes the full SQL statement +// and bound parameters. 5xx now always gets a generic message; 4xx (route +// validation, rate limiting) keeps its original, already-safe message. +app.setErrorHandler((err: FastifyError, request, reply) => { + const statusCode = err.statusCode ?? 500; + if (statusCode >= 500) { + app.log.error({ err, url: request.url, method: request.method }, "unhandled route error"); + reply.code(statusCode).send({ error: "Internal server error" }); + return; + } + reply.code(statusCode).send({ error: err.message }); +}); + await app.register(healthRoutes); await app.register(authRoutes); await app.register(dashboardRoutes); diff --git a/features/PROJ-11-chat.md b/features/PROJ-11-chat.md index da0d025..73f1b6c 100644 --- a/features/PROJ-11-chat.md +++ b/features/PROJ-11-chat.md @@ -394,3 +394,260 @@ Priorität: P3. - Der BUG-4-Fix hat **zwei neue P1-Befunde** eingeführt (BUG-11 Bundle-Regression, BUG-12 kein Token-Refresh/Fehlerbehandlung im App-weiten Client) sowie drei P2 (BUG-13/14/15). **Empfohlene Fix-Reihenfolge:** BUG-1 (committen + verifizieren) → Synapse-TLS-Erreichbarkeit → BUG-12 → BUG-11 → BUG-13/14 → BUG-2 (Architekturentscheidung) → BUG-5/15 → BUG-6/8 → Rest. + +--- + +## QA Re-Test (2026-09-11, Runde 3 — LIVE) + +**Getestet gegen:** commit `c200faf` (Working Tree: nur `DEVLOG.md` + `backend/.env.example` modifiziert, kein Code-Delta zu Runde 2) +**Testart:** **Erstmals vollständiger Live-End-to-End-Test** gegen die produktiven TLS-Domains `https://talkhub.perlbach24.de` (App/API) und `https://matrix.perlbach24.de` (Synapse). Frisch angelegte Testdaten: 2 Vereine, 4 Nutzer, 1 Abteilung, 1 Saison, 1 Mannschaft, echter Matrix-Raum. Dazu Red-Team-Probing mit echten, vom Backend ausgestellten Matrix-Access-Tokens. +**Einschränkung:** Cross-Browser (Chrome/Firefox/Safari) und Responsive (375/768/1440 px) konnten weiterhin **nicht** getestet werden — in dieser Umgebung steht kein Browser/Playwright zur Verfügung. Die Bewertung bleibt auf Markup-/CSS-Ebene. Alles andere wurde diesmal am laufenden System verifiziert, nicht statisch. + +### Infrastruktur-Status (Voraussetzung aus Runde 2) + +| Prüfung | Runde 2 | Runde 3 | +|---------|---------|---------| +| `https://matrix.perlbach24.de/_matrix/client/versions` | **502** | **200** — Synapse v1.12, TLS ok | +| `https://talkhub.perlbach24.de/` | n/a | **200** | +| `GET /api/health` | n/a | **200** `{"status":"ok"}` | +| `GET /api/synapse/health` (authentifiziert) | n/a | **200** `{"status":"ok"}` | +| `POST /api/chat/token` → `homeserverUrl` | nicht prüfbar | **`https://matrix.perlbach24.de`** — korrekt TLS, kein stiller HTTP-Fallback | + +Der Blocker aus Runde 1/2 („Synapse von außen nicht erreichbar") ist **aufgelöst**. + +### Live-Verifikation des Chat-Flows (neu möglich) + +Vollständig durchlaufen und bestätigt: + +1. `POST /api/auth/register` → JWT +2. `POST /api/organizations` → Verein + Mitglied + Matrix-User automatisch provisioniert +3. `POST /api/departments`, `/api/seasons`, `/api/teams` → Team angelegt, **Matrix-Raum inline erfolgreich erzeugt** (`!WpjmnFUqIjFbcJJPTR:matrix.perlbach24.de`) +4. `POST /api/chat/token` → `{homeserverUrl, userId, accessToken, expiresInMs: 3599983}` +5. `PUT /_matrix/client/v3/rooms/{id}/send/m.room.message` direkt an Synapse → **HTTP 200, Event-ID zurück** +6. `GET /_matrix/client/v3/sync` → Nachricht im Timeline-Verlauf, korrekter Sender +7. Zweites Mitglied hinzugefügt → sieht die **vor seinem Beitritt** gesendete Nachricht (AC 5 live bestätigt) +8. Ungelesen-Zähler: nach 3 Nachrichten `unread_notifications: {notification_count: 3}` +9. Read Receipt gesendet → eigener Zähler auf 0, **Sender sieht `m.receipt` mit `@erika.beispiel` im `ephemeral`-Stream** (AC 6 live bestätigt) + +**Raum-State live geprüft:** `m.room.history_visibility` = **`shared`** ✔, `m.room.join_rules` = **`invite`** ✔. + +Damit ist auch das **Risiko aus PROJ-9 aufgelöst**: `initial_state` zusammen mit `preset: private_chat` bricht die Raum-Anlage *nicht* — gegen echte Synapse verifiziert. + +### Status der Vorrunden-Bugs (live nachgeprüft) + +| Bug | Runde 2 | Runde 3 | Verifikation | +|-----|---------|---------|--------------| +| **BUG-1** (Critical) Env-Doku | offen | **weiterhin offen (Doku), funktional erledigt** | `backend/.env.example` bleibt für mich permission-gesperrt (Read *und* `git diff` verweigert) und ist im Working Tree **weiterhin uncommittet `M`**. Live ist die echte `.env` korrekt: `homeserverUrl` liefert die HTTPS-Domain. Funktional kein Blocker mehr, aber der Fix ist **nicht committet** → geht bei jedem Clone/Redeploy verloren. Runterstufung auf **P1 (Doku/Deployment-Reproduzierbarkeit)**, nicht mehr Critical. | +| **BUG-2** (High) Tenant-Isolation | teilgemildert | **Mitigation live bestätigt — Restrisiko live ausnutzbar** | `user_directory/search` mit echtem Nutzer-Token → `{"limited":false,"results":[]}` ✔. Aber siehe **BUG-20**: das Restrisiko ist jetzt praktisch demonstriert, nicht mehr theoretisch. | +| **BUG-3** (High) Kick-Handling | behoben (statisch) | **BEHOBEN — live bestätigt** | Long-Poll-`/sync` offen gehalten, parallel `DELETE /api/teams/{id}/members/{mid}` → der Client erhält den Raum prompt unter `rooms.leave` mit `m.room.member {"membership":"leave","reason":"Removed from TalkTime group"}`. Genau das Event, auf das `RoomEvent.MyMembership` reagiert. Senden danach: `M_FORBIDDEN`. `GET /api/chat/room` danach: `403`. | +| **BUG-4** (High) Ungelesen-Zähler | behoben | **bestätigt** | `GET /api/teams` liefert `matrixRoomId`; Zähler live korrekt (3 → 0 nach Receipt). | +| **BUG-5** (Medium) Kein Backoff | offen | offen | unverändert im Code. | +| **BUG-6** (Medium) history_visibility Bestandsräume | offen | offen | Neue Räume ✔ (live). Für Bestandsräume weiterhin keine Migration. | +| **BUG-7** (Medium) Bundle | offen | offen | unverändert. | +| **BUG-8** (High) Helmet/CORS | offen | **offen — live bestätigt** | siehe unten, jetzt am laufenden System gemessen. | +| **BUG-9** (Low) Diverses | offen | offen | `GET /api/chat/room` 12× hintereinander → **12× 200**, kein Route-Limit ✔bestätigt. In `frontend/` existiert **gar keine** ESLint-Config (`ls | grep eslint` leer). | +| **BUG-10** (Low) Logout widerruft Token nicht | offen | offen | unverändert. | +| **BUG-11** (High) SDK lädt zu früh | offen | offen | `TeamsPage.tsx:64` mountet `useTeamUnreadCounts` weiterhin unbedingt. | +| **BUG-12** (High) Kein Token-Refresh/Error-Handling | offen | offen | `use-team-unread-counts.ts:85-92` hat weiterhin nur `PREPARED`/`SYNCING`, keinen `ERROR`-Zweig und keinen `refresh`-Pfad. | +| **BUG-13** (Medium) `matrixRoomId`-Leak | offen | offen | `teams.ts:50-64` prüft weiterhin nur `requesterIsOrganizationMember`. Ausnutzbarkeit live entkräftet: `join_rule=invite` bestätigt, Join wird abgelehnt. | +| **BUG-14** (Medium) `leftJoin` ohne `scope`-Filter | offen | offen | `teams.ts:62` weiterhin ohne `scope='team'`. (Hinweis: `chat.ts:137` filtert korrekt auf `scope` — die beiden Routen sind inkonsistent.) | +| **BUG-15** (Medium) Doppelte Clients / Rate-Limit | offen | **offen — Limit live bestätigt** | 14× `POST /api/chat/token` → `200`×10, dann `429`×4. Das 10/min-Limit greift exakt wie beschrieben; das Risiko aus BUG-15 ist damit real. | +| **BUG-16 / BUG-17** (Low) | offen | offen | `refreshCounts` (Zeile 59-67) hat weiterhin keinen `cancelled`-Guard vor `setCounts`; `getUnreadNotificationCount()` weiterhin in Zeile 64. | + +### Neue Bugs (Runde 3) + +**BUG-18 (Critical) — Über eine Einladung mit `teamId` beigetretene Mitglieder landen NIE im Matrix-Raum; Chat bleibt dauerhaft bei „Raum wird eingerichtet"** + +Der primäre Onboarding-Pfad aus [[PROJ-7]] ist für den Chat kaputt. `backend/src/routes/invitations.ts:228-234` legt beim Annehmen einer Einladung nur die DB-Zeile an: +```ts +if (invitation.teamId) { + await db.insert(teamMembers).values({ + teamId: invitation.teamId, + memberId: member.id, + role: invitation.role, + }); +} +``` +Es fehlt — anders als im korrekten Pfad `teams.ts:129-152` — jeder Aufruf von `addUserToRoom()` **und** jedes `enqueueSyncJob(..., "join_team_room", ...)`. + +Live reproduziert: +- Einladung mit `teamId` erstellt, angenommen (`Max Mustermann`) → Matrix-User `@max.mustermann:matrix.perlbach24.de` wird korrekt provisioniert, `POST /api/chat/token` liefert 200. +- `GET /api/chat/room?teamId=…` liefert **200 mit der Raum-ID** (TalkTime hält ihn für ein Team-Mitglied). +- `GET /_matrix/client/v3/joined_rooms` → **`{"joined_rooms":[]}`**, `/sync` → weder `join` noch `invite`. Der Nutzer ist auf Synapse-Seite in **keinem** Raum. +- Nach >70 s (Sync-Worker-Intervall 30 s) unverändert leer → **heilt nicht von selbst**. `runReconciliation()` prüft ausschließlich User-Provisionierung (`getUser`), **nicht** Raum-Mitgliedschaften — es gibt also keinen Reparaturmechanismus. + +Frontend-Symptom (aus `use-team-chat.ts:144-153`): `/api/chat/room` liefert 200, also greift der `ChatSetupPendingError`-Poll nicht; stattdessen läuft der Matrix-Client an, `client.getRoom(roomId)` ist `null` → `status="pending"`, `pendingReason="room_not_provisioned"`. Weil dieser Zweig **innerhalb** des Sync-Handlers liegt, wird **kein `pollTimer` gesetzt** — die UI zeigt „Raum wird eingerichtet" **dauerhaft und unveränderlich**, auch nach Reload. Ein stilles, permanentes Versagen ohne Fehlermeldung und ohne Retry. + +Repro: Einladung mit `teamId` erstellen → annehmen → einloggen → Team öffnen → Chat-Tab. Erwartet: Chat. Tatsächlich: endlos „Raum wird eingerichtet". +Erwartet: im Accept-Handler denselben `addUserToRoom()`-/`enqueueSyncJob("join_team_room")`-Pfad wie in `teams.ts` fahren; zusätzlich `runReconciliation()` um Raum-Mitgliedschaften erweitern. +Priorität: **P0** — betrifft jedes per Einladung ins Team geholte Mitglied, also den Regelfall. Höher als alle bisherigen offenen Punkte. + +**BUG-19 (High) — Doppelter `teamMembers`-Insert wirft ungefiltertes 500 mit vollem SQL-Statement und Parametern an den Client; blockiert zugleich die Reparatur von BUG-18** + +`POST /api/teams/:teamId/members` macht `db.insert(teamMembers).values(...)` ohne Duplikatsprüfung und ohne `onConflictDoNothing`. Existiert die Zeile schon (genau der BUG-18-Fall), antwortet die API: +```json +{"statusCode":500,"error":"Internal Server Error","message":"Failed query: insert into \"team_members\" (\"id\", \"team_id\", \"member_id\", \"role\", \"created_at\") values (default, $1, $2, default, default)\nparams: 4d78b3e3-4455-4703-ba64-87eedea2fde8,16406e9a-8751-4756-bcf7-4bc452d455b5"} +``` +Zwei Probleme: +1. **Information Disclosure:** Tabellen-/Spaltennamen und Query-Parameter gehen an den Client. Ursache ist ein fehlender `app.setErrorHandler(...)` in `backend/src/server.ts` (bestätigt: `grep setErrorHandler` → kein Treffer) — jede unbehandelte DB-Exception in **jeder** Route leakt so ihre Query. Verstößt gegen `.claude/rules/backend.md` („Always handle and log errors from pg/Drizzle calls"). +2. **Kein Reparaturweg für BUG-18:** Ein Admin, der ein per Einladung beigetretenes Mitglied nachträglich in den Raum bekommen will, kann es nicht über die UI/API nachtragen — der einzige funktionierende Pfad `POST /api/teams/:teamId/members` schlägt für genau diese Mitglieder mit 500 fehl. Verifiziert: für ein Mitglied *ohne* vorhandene `teamMembers`-Zeile liefert dieselbe Route `201` und der Nutzer landet sofort im Raum. + +Priorität: **P1** (Fix: globaler Error-Handler, der 500er generisch macht und intern loggt, + `onConflictDoNothing()` bzw. Vorabprüfung mit 409). + +**BUG-20 (High) — Matrix-IDs sind deterministisch aus Klarnamen ableitbar; die BUG-2-Mitigation (`user_directory` aus) ist dadurch weitgehend wirkungslos** + +`toLocalpart()` in `backend/src/services/synapse.ts:40-48` bildet `vorname.nachname` (nur Suffix-Zähler bei Kollision). Die Matrix-ID eines Mitglieds ist damit **ratbar**, ohne das abgeschaltete User-Directory zu brauchen. Live bestätigt: `Max Mustermann` → `@max.mustermann:matrix.perlbach24.de`. + +Vollständig live ausgenutzt, mit einem regulär über `POST /api/chat/token` bezogenen Token eines Nutzers aus **Verein A**: +``` +POST /_matrix/client/v3/createRoom {"is_direct":true,"preset":"trusted_private_chat", + "invite":["@max.mustermann:matrix.perlbach24.de"]} + → 200 {"room_id":"!UxMHdCCDXJRywHvyGK:…"} +PUT /_matrix/client/v3/rooms/…/send/m.room.message + → 200 {"event_id":"$HGZtH6mS6yldeDcFa-mP6_zFcK-7cYuwbwKRV88j4kE"} +``` +Beim Zielnutzer erscheint im `/sync` tatsächlich `rooms.invite: ['!UxMHdCCDXJRywHvyGK:…']`, Raum-State zeigt ihn als `invite`. Raum-Erstellung ist für Nutzer-Tokens also uneingeschränkt erlaubt (`publicRooms` ist immerhin leer). + +Bewertung: Das in Runde 2 als „Restrisiko, nur bei bekannter Matrix-ID" eingestufte Problem ist **keine theoretische Einschränkung** — die IDs sind durch das Namensschema systematisch erratbar (Vereinsmitglieder-Namen sind oft öffentlich, z. B. Mannschaftslisten). Damit ist Spam/Belästigung über Vereinsgrenzen hinweg praktisch möglich, was der PRD-Erfolgsmetrik „0 Tenant-Isolation-Vorfälle" widerspricht. +Erwartet: mindestens eines von — (a) Raum-Erstellung für provisionierte Nutzer per Synapse-Konfiguration/Modul unterbinden, (b) zufällige/opake Localparts statt Klarnamen, (c) verbindlich ein Synapse pro Verein (dann ist der Radius auf den eigenen Verein begrenzt). +Priorität: **P1** — vor Multi-Tenant-Betrieb zwingend; bei „ein Synapse pro Verein" auf P2 reduzierbar. + +**BUG-21 (Medium) — Fehlende Security-Header und reflektierendes CORS am laufenden System bestätigt (Konkretisierung von BUG-8)** + +Live gemessen an `https://talkhub.perlbach24.de`: +``` +$ curl -sI https://talkhub.perlbach24.de/api/health +HTTP/2 200 … server: openresty +(keine Strict-Transport-Security, kein X-Frame-Options, + kein X-Content-Type-Options, keine Referrer-Policy) + +$ curl -sI -H "Origin: https://evil.example.com" …/api/health +access-control-allow-origin: https://evil.example.com + +$ curl -si -X OPTIONS -H "Origin: https://evil.example.com" \ + -H "Access-Control-Request-Headers: authorization" …/api/chat/token +HTTP/2 204 +access-control-allow-origin: https://evil.example.com +access-control-allow-headers: authorization +``` +Der Reverse Proxy setzt die Header also **nicht** ersatzweise — die Annahme aus Runde 1 („sofern nicht am Reverse Proxy gesetzt") ist damit widerlegt. `server.ts:23` ist unverändert `cors, { origin: true }`. +Entlastend (live geprüft): `access-control-allow-credentials` wird **nicht** gesetzt, und der JWT wird als `Authorization`-Header geführt, nicht als Cookie — eine fremde Origin kann also nicht einfach im Namen des Nutzers `/api/chat/token` abrufen. Das Risiko bleibt „Härtung fehlt / Schadensbegrenzung bei XSS", keine direkte Ausnutzbarkeit. Deshalb Medium statt High. +Priorität: P2 (Fix in PROJ-1/PROJ-10, nicht PROJ-11-Scope). + +**BUG-22 (Low) — Matrix-Localpart und Anzeigename sind bei Vereins-Erstanlage kaputt** + +`organizations.ts:53-54` setzt `firstName = requester.email.split("@")[0]` und `lastName = ""`. Ergebnis live: E-Mail `qa-a-18686@example.org` → Matrix-ID **`@qaa18686.:matrix.perlbach24.de`** (führende Trennzeichen entfernt, **Localpart endet auf einem Punkt**) und Anzeigename **`"qa-a-18686 "`** mit Leerzeichen am Ende (sichtbar in jeder Chat-Nachricht des Vereinsadministrators). +Der Vereinsadministrator — meist der erste und sichtbarste Nutzer — tritt im Chat also mit seinem E-Mail-Präfix statt mit seinem Namen auf. Zudem kollidieren zwei Administratoren mit gleichem E-Mail-Präfix bei verschiedenen Providern im Localpart (wird per Suffix aufgelöst, aber unschön). +Erwartet: bei der Vereinsanlage Vor-/Nachname abfragen, oder den Anzeigenamen nachträglich setzbar machen und den Trailing-Punkt/-Leerraum trimmen. +Priorität: P3. + +### Akzeptanzkriterien (Runde 3, live) + +| # | Kriterium | R1 | R2 | R3 | Live-Beleg | +|---|-----------|----|----|----|------------| +| 1 | Chat-Tab in Team-Detailansicht | PASS | PASS | PASS | `TeamDetail.tsx` (statisch, kein Browser verfügbar) | +| 2 | Backend liefert Token, kein Passwort/Admin-Token im Frontend | PASS | PASS | **PASS (live)** | Response enthält nur `homeserverUrl/userId/accessToken/expiresInMs` | +| 3 | `/sync` via matrix-js-sdk, kein eigener WebSocket-Server | PASS | PASS | **PASS (live)** | `/sync` direkt gegen `matrix.perlbach24.de` funktioniert | +| 4 | Alle Raum-Mitglieder dürfen senden | PASS | PASS | **PASS (live)** | zwei verschiedene Mitglieder haben erfolgreich gesendet | +| 5 | Volle Historie für neue Mitglieder | Vorbehalt | Vorbehalt | **PASS (live), Vorbehalt Bestandsräume** | `history_visibility: shared`; später beigetretenes Mitglied sieht die ältere Nachricht. BUG-6 (Altbestand) offen | +| 6 | Ungelesen-Zähler pro Raum + Read Receipts | FAIL | PASS | **PASS (live)** | `notification_count: 3` → 0; `m.receipt` beim Sender sichtbar | +| 7 | Fehlendes `matrixUserId` → Hinweis + Polling | PASS | PASS | **PASS (live)** | Verein B: `POST /api/chat/token` → `409 matrix_not_provisioned` | +| 8 | Kein Medien-Upload, kein Push | PASS | PASS | PASS | unverändert | + +**Summe: 7 PASS, 1 PASS mit Vorbehalt, 0 FAIL** — erstmals überwiegend live belegt statt nur aus dem Code abgeleitet. + +> Wichtig: Die Kriterien sind auf dem **API-/Protokollpfad** erfüllt. Für über Einladungen ins Team geholte Mitglieder greift durch **BUG-18** faktisch keines der Kriterien 3-6, weil sie nie im Raum landen. + +### Edge Cases (Runde 3) + +| Edge Case | R2 | R3 | Beleg | +|-----------|----|----|-------| +| `matrixUserId = null` | PASS | **PASS (live)** | `409 matrix_not_provisioned` bei frisch angelegtem Verein B | +| `create_team_room` nicht gelaufen | PASS | PASS | Inline-Anlage hat live funktioniert, Fallback-Job unverändert | +| Synapse nicht erreichbar, kein Flut-Retry | FAIL | **FAIL** | BUG-5/BUG-12 unverändert | +| Token abgelaufen beim Senden → 1 Retry | PASS | PASS | Detail-Chat ok; Unread-Client weiterhin ohne Refresh (BUG-12) | +| Rauswurf bei offenem Chat | PASS | **PASS (live)** | Leave-Event im Long-Poll-`/sync` empfangen | +| Lange Historie → Pagination | PASS | PASS | unverändert | +| **Neu:** Beitritt über Einladung mit `teamId` | — | **FAIL** | BUG-18 | +| **Neu:** Mitglied doppelt zum Team hinzufügen | — | **FAIL** | BUG-19, 500 + SQL-Leak | + +### Security-Audit (Runde 3, Red Team gegen das laufende System) + +| Prüfung | Ergebnis | Beleg | +|---------|----------|-------| +| Auth-Bypass auf Chat-Routen | PASS | ohne JWT 401 | +| IDOR: Raum-ID eines fremden Teams | PASS (live) | `/api/chat/room` vor Team-Beitritt → `403 Forbidden` | +| Zugriff nach Entfernung aus dem Team | PASS (live) | `/api/chat/room` → 403, Matrix-Send → `M_FORBIDDEN` | +| Raum-Vorschau ohne Mitgliedschaft | PASS (live) | `M_FORBIDDEN … room previews are disabled` | +| Raum beitreten mit geleakter Raum-ID (BUG-13) | MITIGIERT (live) | `join_rule: invite` bestätigt | +| Admin-Token-Leak an Browser | PASS | nur `syt_…`-Nutzertoken im Response | +| Matrix-Registrierung offen? | PASS | `M_FORBIDDEN Registration has been disabled` | +| Öffentliches Raumverzeichnis | PASS | `publicRooms` → leer | +| **`user_directory` (BUG-2-Mitigation)** | **PASS (live)** | `user_directory/search` → `{"limited":false,"results":[]}` | +| **Raum-Erstellung durch Nutzer-Token** | **FAIL** | erlaubt → BUG-20 | +| **Cross-Club-DM an geratene Matrix-ID** | **FAIL** | live ausgenutzt → BUG-20 | +| Rate Limiting `/api/chat/token` | PASS (live) | 10× 200, dann 429 | +| Rate Limiting `/api/chat/room` | FAIL | 12× 200, kein Route-Limit (BUG-9) | +| Security-Header / CORS | **FAIL (live)** | BUG-21 | +| Fehlerbehandlung / Info-Disclosure | **FAIL (live)** | BUG-19, SQL im 500-Response | +| XSS über Nachrichteninhalt | PASS | `` als Nachricht gesendet, kommt im `/sync` als reiner `body`-String zurück; Rendering als JSX-Textknoten, kein `dangerouslySetInnerHTML` (Browser-Gegenprobe steht aus) | +| SQL-Injection / Input-Validierung | PASS | Drizzle parametrisiert, Zod `safeParse`; invalide UUID → 400 | +| Token in Logs/DB/Response | PASS | unverändert | + +### Regressionstest (Features mit Status „Deployed") + +| Feature | Ergebnis | +|---------|----------| +| PROJ-1 Auth | **PASS (live)** — Register/Login/JWT funktionieren gegen Produktion | +| PROJ-2 Verein + Vereinsadministrator | **PASS mit Mangel (live)** — Anlage inkl. Matrix-Provisionierung funktioniert; Namens-/Localpart-Ableitung fehlerhaft (BUG-22) | +| PROJ-3 Mitgliederverwaltung | **PASS (live)** — `GET /api/members` inkl. Pagination ok | +| PROJ-4 Abteilungsverwaltung | **PASS (live)** | +| PROJ-5 Mannschaftsverwaltung | **FAIL (live)** — Team-Anlage und Mitglied-Hinzufügen ok, aber `POST /api/teams/:id/members` wirft bei Duplikat 500 mit SQL-Leak (BUG-19). `GET /api/teams` weiterhin mit `leftJoin`-Kardinalitätsrisiko (BUG-14) | +| PROJ-7 Einladungssystem | **FAIL (live)** — Einladung/Accept/Rollenvergabe funktionieren, aber die `teamId`-Variante führt zu einem Mitglied, das nie im Chat-Raum landet (BUG-18). Kernregression für den Chat | +| PROJ-8 Audit Log | PASS — `chat.token_issued` unverändert | +| PROJ-9 Synapse-Integration | **PASS (live) — Risiko aus R1/R2 aufgelöst** — `initial_state` + `preset: private_chat` erzeugt live korrekt einen Raum mit `history_visibility: shared`. Einschränkung: `runReconciliation()` deckt Raum-Mitgliedschaften nicht ab (Grundlage von BUG-18) | +| PROJ-10 Deployment | **PASS mit Mangel** — Live-Deployment läuft mit korrekten Werten; `.env.example` weiterhin uncommittet/undokumentiert (BUG-1) | + +### Cross-Browser & Responsive + +Weiterhin **nicht durchgeführt** — kein Browser/Playwright in dieser Umgebung. Unverändert offen aus Runde 1: +- `TeamChat.tsx:68` nutzt weiterhin `h-[60vh] … sm:h-[65vh]`. Auf iOS Safari mit eingeblendeter Tastatur kann der Nachrichtenbereich kollabieren; `dvh` wäre robuster. **Muss manuell bei 375 px auf echtem iOS Safari geprüft werden.** +- Breakpoints/Accessibility (`role="log"`, `aria-live`, `