diff --git a/apps/api/src/calendar/calendar.controller.ts b/apps/api/src/calendar/calendar.controller.ts index 0237ed3..dbb57eb 100644 --- a/apps/api/src/calendar/calendar.controller.ts +++ b/apps/api/src/calendar/calendar.controller.ts @@ -24,6 +24,15 @@ import { CalendarEventsQueryDto } from './dto/calendar-events-query.dto'; * Every handler extracts userId and tenantId from the request * and scopes all operations to the calling user (T-05-12). * + * `extractContext()` already resolves BOTH userId and tenantId from the + * validated session (throws `ForbiddenException` without either). All six + * context-using handlers destructure both and pass them through to + * `CalendarService` unchanged — this is a pass-through of an + * already-resolved tenant, not a new trust source; nothing is taken from + * the request body or path (Mandantentrennung Etappe 2, 260911-cwh). + * `testSourceConfig` never calls `extractContext()` and stays unchanged — + * it has no database access and no tenant. + * * Routes: * - GET /calendar/sources — list user's calendar sources (no passwords) * - POST /calendar/sources — add a new calendar source @@ -58,8 +67,8 @@ export class CalendarController { */ @Get('sources') async getSources(@Req() req: Request) { - const { userId } = this.extractContext(req); - return this.calendarService.getSources(userId); + const { userId, tenantId } = this.extractContext(req); + return this.calendarService.getSources(userId, tenantId); } /** @@ -87,8 +96,8 @@ export class CalendarController { @Req() req: Request, @Body() dto: UpdateCalendarSourceDto, ) { - const { userId } = this.extractContext(req); - return this.calendarService.updateSource(id, userId, dto); + const { userId, tenantId } = this.extractContext(req); + return this.calendarService.updateSource(id, userId, tenantId, dto); } /** @@ -100,8 +109,8 @@ export class CalendarController { @Param('id') id: string, @Req() req: Request, ) { - const { userId } = this.extractContext(req); - return this.calendarService.deleteSource(id, userId); + const { userId, tenantId } = this.extractContext(req); + return this.calendarService.deleteSource(id, userId, tenantId); } /** @@ -125,8 +134,8 @@ export class CalendarController { @Param('id') id: string, @Req() req: Request, ) { - const { userId } = this.extractContext(req); - return this.calendarService.testConnection(id, userId); + const { userId, tenantId } = this.extractContext(req); + return this.calendarService.testConnection(id, userId, tenantId); } /** @@ -138,7 +147,7 @@ export class CalendarController { @Req() req: Request, @Query() query: CalendarEventsQueryDto, ) { - const { userId } = this.extractContext(req); - return this.calendarService.aggregateEvents(userId, query.from, query.to); + const { userId, tenantId } = this.extractContext(req); + return this.calendarService.aggregateEvents(userId, tenantId, query.from, query.to); } } diff --git a/apps/api/src/calendar/calendar.service.spec.ts b/apps/api/src/calendar/calendar.service.spec.ts new file mode 100644 index 0000000..059aa65 --- /dev/null +++ b/apps/api/src/calendar/calendar.service.spec.ts @@ -0,0 +1,557 @@ +import { describe, expect, it, vi } from 'vitest'; +import { ForbiddenException, NotFoundException } from '@nestjs/common'; + +/** + * CalendarService.spec — Zwei-Klienten-Nachweis fuer die Bindung an + * forTenant() (260911-cwh, Aufgabe 2). Dieser Bereich hatte VOR diesem + * Durchlauf KEINE einzige Testdatei (Befund C) — dieser Fake ist deshalb + * die Voraussetzung dafuer, dass irgendeine Aussage dieses Plans + * nachpruefbar ist, nicht eine Zugabe. + * + * Muster wie `dkv.service.spec.ts` (260909-mir): `__makeBoundClient(tenantId)` + * wrappt DIESELBEN In-Memory-Zeilen mit einer protokollierenden Schicht fuer + * `calendarSource`. Der ungebundene Fake protokolliert NICHT, der gebundene + * schon — eine vergessene Bindung wird dadurch sichtbar, ein reiner + * Identitaets-Mock (`(p) => p`) wuerde das nicht leisten. Der Wachhund + * "genau ein Klient je Aufruf" folgt `dashboard.service.spec.ts` (Zeile + * 545): `vi.mocked(forTenant).mock.calls.length` wird nach jedem Aufruf + * geprueft. + * + * Die drei Provider (ICS/CalDAV/Exchange) reden mit echten Servern und + * werden NICHT ausgeuebt — nur als `vi.fn()`-Attrappen fuer + * `fetchEvents`/`testConnection` eingebunden. + */ + +vi.mock('../prisma/prisma-tenant.extension', () => ({ + forTenant: vi.fn((prisma: any, tenantId: string) => prisma.__makeBoundClient(tenantId)), +})); + +import { forTenant } from '../prisma/prisma-tenant.extension'; +import { CalendarService } from './calendar.service'; + +function _applySelect(row: any, select: Record | undefined) { + if (!select) return { ...row }; + const out: Record = {}; + for (const key of Object.keys(select)) { + if (select[key]) out[key] = (row as any)[key]; + } + return out; +} + +function makeDefaultSourceFields() { + const now = new Date('2026-01-01T00:00:00.000Z'); + return { + name: 'Testquelle', + type: 'ics', + exchangeMode: null, + domain: null, + url: 'https://example.invalid/cal.ics', + username: null, + encryptedPassword: null, + color: '#3B82F6', + isVisible: true, + syncIntervalMin: 15, + lastSyncAt: null, + lastSyncError: null, + createdAt: now, + updatedAt: now, + }; +} + +/** + * Handgerollter Prisma-Nachbau mit In-Memory-Zeilen fuer `calendarSource` + * (`findMany`, `findUnique`, `create`, `update`, `delete`). Die ungebundene + * Form protokolliert NICHT; `__makeBoundClient(tenantId)` liefert eine + * ZWEITE, protokollierende Schicht ueber denselben Zeilen. + */ +function makeFakePrisma() { + const sources = new Map(); // key: id + const boundCallLog: { tenantId: string; model: string; method: string }[] = []; + let autoId = 0; + + const calendarSource = { + findMany: vi.fn( + async ({ + where, + select, + orderBy, + }: { where?: Record; select?: Record; orderBy?: { createdAt?: string } } = {}) => { + let rows = Array.from(sources.values()); + if (where) { + rows = rows.filter((r) => + Object.entries(where).every(([k, v]) => (r as any)[k] === v), + ); + } + if (orderBy?.createdAt === 'asc') { + rows = [...rows].sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()); + } + return rows.map((r) => _applySelect(r, select)); + }, + ), + findUnique: vi.fn( + async ({ where, select }: { where: { id: string }; select?: Record }) => { + const row = sources.get(where.id); + return row ? _applySelect(row, select) : null; + }, + ), + create: vi.fn( + async ({ + data, + select, + }: { data: Record; select?: Record }) => { + const id = (data.id as string) ?? `src-${++autoId}`; + const now = new Date(); + const record = { ...makeDefaultSourceFields(), id, createdAt: now, updatedAt: now, ...data }; + sources.set(id, record); + return _applySelect(record, select); + }, + ), + update: vi.fn( + async ({ + where, + data, + select, + }: { where: { id: string }; data: Record; select?: Record }) => { + const existing = sources.get(where.id); + if (!existing) { + // Nachbau des in Aufgabe 1 gemessenen Wettlauf-Ergebnisses + // (`calendarsource-generierter-client-gebundenes-update-auf-unsichtbare-zeile-scheitert-laut`, + // 260911-cwh): PrismaClientKnownRequestError mit code P2025. + const err: any = new Error('An operation failed because it depends on one or more records that were required but not found.'); + err.code = 'P2025'; + throw err; + } + const record = { ...existing, ...data, updatedAt: new Date() }; + sources.set(where.id, record); + return _applySelect(record, select); + }, + ), + delete: vi.fn(async ({ where }: { where: { id: string } }) => { + const existing = sources.get(where.id); + sources.delete(where.id); + return existing; + }), + }; + + const fake: any = { + calendarSource, + __boundCallLog: boundCallLog, + __seedSource(row: { id: string; userId: string; tenantId: string } & Partial>) { + sources.set(row.id, { ...makeDefaultSourceFields(), ...row }); + }, + __makeBoundClient(tenantId: string) { + const wrapModel = (model: Record, modelName: string, methods: string[]) => { + const wrapped: any = {}; + for (const method of methods) { + wrapped[method] = async (...args: any[]) => { + boundCallLog.push({ tenantId, model: modelName, method }); + return model[method](...args); + }; + } + return wrapped; + }; + return { + calendarSource: wrapModel(calendarSource, 'calendarSource', [ + 'findMany', + 'findUnique', + 'create', + 'update', + 'delete', + ]), + }; + }, + }; + + return fake; +} + +function expectBoundCall(prisma: any, tenantId: string, model: string, method: string) { + const found = prisma.__boundCallLog.some( + (c: any) => 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); +} + +/** Umkehrbare Attrappen fuer CryptoService — kein echtes Verschluesseln. */ +function makeFakeCrypto(overrides: Partial<{ encrypt: any; decrypt: any }> = {}) { + return { + encrypt: overrides.encrypt ?? vi.fn((plain: string) => `enc(${plain})`), + decrypt: overrides.decrypt ?? vi.fn((stored: string) => stored.replace(/^enc\(/, '').replace(/\)$/, '')), + }; +} + +/** Attrappen fuer die drei Provider — NIE ausgeuebt, nur `vi.fn()`. */ +function makeFakeProviders( + overrides: Partial<{ ics: any; caldav: any; exchange: any }> = {}, +) { + const makeProvider = () => ({ + fetchEvents: vi.fn(async () => []), + testConnection: vi.fn(async () => true), + }); + return { + icsProvider: overrides.ics ?? makeProvider(), + caldavProvider: overrides.caldav ?? makeProvider(), + exchangeProvider: overrides.exchange ?? makeProvider(), + }; +} + +function makeCalendarService( + prisma: any, + overrides: Partial<{ + encrypt: any; + decrypt: any; + icsProvider: any; + caldavProvider: any; + exchangeProvider: any; + }> = {}, +) { + const crypto = makeFakeCrypto(overrides); + const { icsProvider, caldavProvider, exchangeProvider } = makeFakeProviders({ + ics: overrides.icsProvider, + caldav: overrides.caldavProvider, + exchange: overrides.exchangeProvider, + }); + const service = new CalendarService( + prisma, + crypto as any, + icsProvider as any, + caldavProvider as any, + exchangeProvider as any, + ); + return { service, crypto, icsProvider, caldavProvider, exchangeProvider }; +} + +describe('CalendarService — Bindung an forTenant() (260911-cwh)', () => { + // ─── getSources ──────────────────────────────────────────────────────── + + it('getSources: laeuft gebunden mit der uebergebenen Mandantenkennung im Protokoll; die Antwort traegt hasCredentials und NIE encryptedPassword', async () => { + const prisma = makeFakePrisma(); + prisma.__seedSource({ id: 'src-a1', userId: 'user-a1', tenantId: 't1', encryptedPassword: 'enc(geheim)' }); + const { service } = makeCalendarService(prisma); + + const result = await service.getSources('user-a1', 't1'); + + expectBoundCall(prisma, 't1', 'calendarSource', 'findMany'); + expect(result).toHaveLength(1); + expect(result[0].hasCredentials).toBe(true); + expect((result[0] as any).encryptedPassword).toBeUndefined(); + }); + + it('getSources von Nutzer A liefert NICHT die Quellen von Nutzer B desselben Mandanten — der userId-Filter bleibt, die Bindung ergaenzt ihn', async () => { + const prisma = makeFakePrisma(); + prisma.__seedSource({ id: 'src-a1', userId: 'user-a1', tenantId: 't1' }); + prisma.__seedSource({ id: 'src-a2', userId: 'user-a2', tenantId: 't1' }); + const { service } = makeCalendarService(prisma); + + const result = await service.getSources('user-a1', 't1'); + + expect(result.map((s: any) => s.id)).toEqual(['src-a1']); + }); + + // ─── addSource ───────────────────────────────────────────────────────── + + it('addSource: gebunden, Mandantenkennung als Pflichtwert geschrieben, Passwort verschluesselt, Antwort ohne encryptedPassword', async () => { + const prisma = makeFakePrisma(); + const { service, crypto } = makeCalendarService(prisma); + + const created = await service.addSource('user-a1', 't1', { + name: 'Neue Quelle', + type: 'ics', + url: 'https://example.invalid/neu.ics', + password: 'geheim-123', + } as any); + + expectBoundCall(prisma, 't1', 'calendarSource', 'create'); + expect(prisma.calendarSource.create).toHaveBeenCalledWith( + expect.objectContaining({ data: expect.objectContaining({ tenantId: 't1', userId: 'user-a1' }) }), + ); + expect(created.hasCredentials).toBe(true); + expect((created as any).encryptedPassword).toBeUndefined(); + expect(vi.mocked(crypto.encrypt)).toHaveBeenCalledWith('geheim-123'); + }); + + // ─── updateSource ────────────────────────────────────────────────────── + + it('updateSource: BEIDE Abfragen (Nachschlagen und Aendern) stehen ueber DENSELBEN gebundenen Klienten und dieselbe Mandantenkennung im Protokoll', async () => { + const prisma = makeFakePrisma(); + prisma.__seedSource({ id: 'src-a1', userId: 'user-a1', tenantId: 't1' }); + const { service } = makeCalendarService(prisma); + + await service.updateSource('src-a1', 'user-a1', 't1', { name: 'Neuer Name' } as any); + + expectBoundCall(prisma, 't1', 'calendarSource', 'findUnique'); + expectBoundCall(prisma, 't1', 'calendarSource', 'update'); + }); + + it('updateSource, Zugangsdaten-Erhaltung — Feld FEHLT: encryptedPassword bleibt unveraendert, kein Lesezugriff laedt ein Passwort (Befund E)', async () => { + const prisma = makeFakePrisma(); + prisma.__seedSource({ id: 'src-a1', userId: 'user-a1', tenantId: 't1', encryptedPassword: 'enc(alt-passwort)' }); + const { service, crypto } = makeCalendarService(prisma); + + const result = await service.updateSource('src-a1', 'user-a1', 't1', { name: 'Umbenannt' } as any); + + expect(result.hasCredentials).toBe(true); + expect(vi.mocked(crypto.decrypt)).not.toHaveBeenCalled(); + expect(vi.mocked(crypto.encrypt)).not.toHaveBeenCalled(); + const updateCall = vi.mocked(prisma.calendarSource.update).mock.calls[0][0] as any; + expect(updateCall.data).not.toHaveProperty('encryptedPassword'); + }); + + it('updateSource, Zugangsdaten-Erhaltung — Feld LEER: encryptedPassword wird null', async () => { + const prisma = makeFakePrisma(); + prisma.__seedSource({ id: 'src-a1', userId: 'user-a1', tenantId: 't1', encryptedPassword: 'enc(alt-passwort)' }); + const { service } = makeCalendarService(prisma); + + const result = await service.updateSource('src-a1', 'user-a1', 't1', { password: '' } as any); + + expect(result.hasCredentials).toBe(false); + }); + + it('updateSource, Zugangsdaten-Erhaltung — Feld GESETZT: encryptedPassword wird neu verschluesselt', async () => { + const prisma = makeFakePrisma(); + prisma.__seedSource({ id: 'src-a1', userId: 'user-a1', tenantId: 't1', encryptedPassword: 'enc(alt-passwort)' }); + const { service, crypto } = makeCalendarService(prisma); + + const result = await service.updateSource('src-a1', 'user-a1', 't1', { password: 'neu-geheim' } as any); + + expect(vi.mocked(crypto.encrypt)).toHaveBeenCalledWith('neu-geheim'); + expect(result.hasCredentials).toBe(true); + }); + + // ─── Besitzpruefungen (updateSource/deleteSource/testConnection) ────── + + it('updateSource: eine Quelle eines ANDEREN Benutzers fuehrt weiterhin zu ForbiddenException', async () => { + const prisma = makeFakePrisma(); + prisma.__seedSource({ id: 'src-b1', userId: 'other-user', tenantId: 't1' }); + const { service } = makeCalendarService(prisma); + + await expect( + service.updateSource('src-b1', 'user-a1', 't1', { name: 'X' } as any), + ).rejects.toThrow(ForbiddenException); + }); + + it('updateSource: eine UNBEKANNTE Kennung fuehrt weiterhin zu NotFoundException', async () => { + const prisma = makeFakePrisma(); + const { service } = makeCalendarService(prisma); + + await expect( + service.updateSource('src-unbekannt', 'user-a1', 't1', { name: 'X' } as any), + ).rejects.toThrow(NotFoundException); + }); + + it('deleteSource: eine Quelle eines ANDEREN Benutzers fuehrt weiterhin zu ForbiddenException', async () => { + const prisma = makeFakePrisma(); + prisma.__seedSource({ id: 'src-b1', userId: 'other-user', tenantId: 't1' }); + const { service } = makeCalendarService(prisma); + + await expect(service.deleteSource('src-b1', 'user-a1', 't1')).rejects.toThrow(ForbiddenException); + }); + + it('deleteSource: eine UNBEKANNTE Kennung fuehrt weiterhin zu NotFoundException', async () => { + const prisma = makeFakePrisma(); + const { service } = makeCalendarService(prisma); + + await expect(service.deleteSource('src-unbekannt', 'user-a1', 't1')).rejects.toThrow(NotFoundException); + }); + + it('testConnection: eine Quelle eines ANDEREN Benutzers fuehrt weiterhin zu ForbiddenException', async () => { + const prisma = makeFakePrisma(); + prisma.__seedSource({ id: 'src-b1', userId: 'other-user', tenantId: 't1' }); + const { service } = makeCalendarService(prisma); + + await expect(service.testConnection('src-b1', 'user-a1', 't1')).rejects.toThrow(ForbiddenException); + }); + + it('testConnection: eine UNBEKANNTE Kennung fuehrt weiterhin zu NotFoundException', async () => { + const prisma = makeFakePrisma(); + const { service } = makeCalendarService(prisma); + + await expect(service.testConnection('src-unbekannt', 'user-a1', 't1')).rejects.toThrow(NotFoundException); + }); + + // ─── deleteSource, positiver Pfad ────────────────────────────────────── + + it('deleteSource: beide Abfragen (Nachschlagen und Loeschen) stehen ueber denselben gebundenen Klienten im Protokoll', async () => { + const prisma = makeFakePrisma(); + prisma.__seedSource({ id: 'src-a1', userId: 'user-a1', tenantId: 't1' }); + const { service } = makeCalendarService(prisma); + + await service.deleteSource('src-a1', 'user-a1', 't1'); + + expectBoundCall(prisma, 't1', 'calendarSource', 'findUnique'); + expectBoundCall(prisma, 't1', 'calendarSource', 'delete'); + }); + + // ─── testConnection, positive Pfade ──────────────────────────────────── + + it('testConnection, Erfolgspfad: alle Abfragen (Nachschlagen, Rueckschreiben bei Erfolg) stehen ueber denselben gebundenen Klienten; der Provider erhaelt das ENTSCHLUESSELTE Passwort', async () => { + const prisma = makeFakePrisma(); + prisma.__seedSource({ id: 'src-a1', userId: 'user-a1', tenantId: 't1', encryptedPassword: 'enc(geheim-123)', type: 'ics' }); + const { service, icsProvider } = makeCalendarService(prisma); + + const result = await service.testConnection('src-a1', 'user-a1', 't1'); + + expectBoundCall(prisma, 't1', 'calendarSource', 'findUnique'); + expectBoundCall(prisma, 't1', 'calendarSource', 'update'); + expect(result.success).toBe(true); + expect(vi.mocked(icsProvider.testConnection)).toHaveBeenCalledWith( + expect.objectContaining({ password: 'geheim-123' }), + ); + }); + + it('testConnection, Fehlerpfad: alle Abfragen (Nachschlagen, Rueckschreiben im catch) stehen ueber denselben gebundenen Klienten; die Antwort ist generisch (kein Passwort, keine Serverdetails)', async () => { + const prisma = makeFakePrisma(); + prisma.__seedSource({ id: 'src-a1', userId: 'user-a1', tenantId: 't1', encryptedPassword: 'enc(geheim-123)', type: 'ics' }); + const icsProvider = { + fetchEvents: vi.fn(async () => []), + testConnection: vi.fn(async () => { + throw new Error('ECONNREFUSED mail.example.invalid:993 password=geheim-123'); + }), + }; + const { service } = makeCalendarService(prisma, { icsProvider }); + + const result = await service.testConnection('src-a1', 'user-a1', 't1'); + + expectBoundCall(prisma, 't1', 'calendarSource', 'findUnique'); + expectBoundCall(prisma, 't1', 'calendarSource', 'update'); + expect(result.success).toBe(false); + expect(result.error).toBe('Connection failed'); + expect(result.error).not.toContain('geheim-123'); + expect(result.error).not.toContain('mail.example.invalid'); + }); + + // ─── aggregateEvents / fetchAndCacheEvents ──────────────────────────── + + it('aggregateEvents, Erfolgspfad: das Laden der Quellen UND die Synchronstatus-Rueckschreibung bei Erfolg stehen gebunden unter derselben Mandantenkennung im Protokoll', async () => { + const prisma = makeFakePrisma(); + prisma.__seedSource({ id: 'src-a1', userId: 'user-a1', tenantId: 't1', isVisible: true, type: 'ics' }); + const { service } = makeCalendarService(prisma); + + await service.aggregateEvents('user-a1', 't1'); + + expectBoundCall(prisma, 't1', 'calendarSource', 'findMany'); + expectBoundCall(prisma, 't1', 'calendarSource', 'update'); + }); + + it('aggregateEvents, Fehlerpfad (Providerfehler): das Laden der Quellen UND die Synchronstatus-Rueckschreibung im catch stehen gebunden unter derselben Mandantenkennung im Protokoll', async () => { + const prisma = makeFakePrisma(); + prisma.__seedSource({ id: 'src-a1', userId: 'user-a1', tenantId: 't1', isVisible: true, type: 'ics' }); + const icsProvider = { + fetchEvents: vi.fn(async () => { + throw new Error('Provider nicht erreichbar'); + }), + testConnection: vi.fn(async () => true), + }; + const { service } = makeCalendarService(prisma, { icsProvider }); + + const events = await service.aggregateEvents('user-a1', 't1'); + + expect(events).toEqual([]); + expectBoundCall(prisma, 't1', 'calendarSource', 'findMany'); + expectBoundCall(prisma, 't1', 'calendarSource', 'update'); + const updateCalls = prisma.__boundCallLog.filter( + (c: any) => c.tenantId === 't1' && c.model === 'calendarSource' && c.method === 'update', + ); + expect(updateCalls.length).toBeGreaterThanOrEqual(1); + }); + + it('aggregateEvents ohne Quellen: Rueckgabe ist eine leere Liste, kein Fehler, und es wird KEIN Cache-Eintrag angelegt — die Deutung von Leere als Abwesenheit als heutiges Verhalten festgehalten', async () => { + const prisma = makeFakePrisma(); + const { service } = makeCalendarService(prisma); + + const first = await service.aggregateEvents('user-ohne-quellen', 't1'); + expect(first).toEqual([]); + + // Zweiter Aufruf misst erneut ueber die Datenbank — waere ein + // Cache-Eintrag angelegt worden, bliebe der Aufrufzaehler von + // findMany bei 1 stehen. + await service.aggregateEvents('user-ohne-quellen', 't1'); + const findManyCalls = prisma.__boundCallLog.filter( + (c: any) => c.model === 'calendarSource' && c.method === 'findMany', + ); + expect(findManyCalls.length).toBe(2); + }); + + // ─── Cache ────────────────────────────────────────────────────────────── + + it('Cache: ein zweiter Aufruf innerhalb der Lebensdauer erzeugt keinen weiteren Datenbankzugriff', async () => { + const prisma = makeFakePrisma(); + prisma.__seedSource({ id: 'src-a1', userId: 'user-a1', tenantId: 't1', isVisible: true, type: 'ics' }); + const { service } = makeCalendarService(prisma); + + await service.aggregateEvents('user-a1', 't1', '2026-01-01T00:00:00.000Z', '2026-01-31T00:00:00.000Z'); + const findManyCallsAfterFirst = prisma.__boundCallLog.filter( + (c: any) => c.model === 'calendarSource' && c.method === 'findMany', + ).length; + + await service.aggregateEvents('user-a1', 't1', '2026-01-01T00:00:00.000Z', '2026-01-31T00:00:00.000Z'); + const findManyCallsAfterSecond = prisma.__boundCallLog.filter( + (c: any) => c.model === 'calendarSource' && c.method === 'findMany', + ).length; + + expect(findManyCallsAfterSecond).toBe(findManyCallsAfterFirst); + }); + + it('Cache: zwei VERSCHIEDENE Benutzerkennungen teilen sich keinen Eintrag — das Urteil aus Aufgabe 1 zum Cache-Schluessel, festgenagelt', async () => { + const prisma = makeFakePrisma(); + prisma.__seedSource({ id: 'src-a1', userId: 'user-a1', tenantId: 't1', isVisible: true, type: 'ics' }); + prisma.__seedSource({ id: 'src-a2', userId: 'user-a2', tenantId: 't1', isVisible: true, type: 'ics' }); + const { service } = makeCalendarService(prisma); + + await service.aggregateEvents('user-a1', 't1', '2026-01-01T00:00:00.000Z', '2026-01-31T00:00:00.000Z'); + const findManyCallsAfterUserA = prisma.__boundCallLog.filter( + (c: any) => c.model === 'calendarSource' && c.method === 'findMany', + ).length; + + await service.aggregateEvents('user-a2', 't1', '2026-01-01T00:00:00.000Z', '2026-01-31T00:00:00.000Z'); + const findManyCallsAfterUserB = prisma.__boundCallLog.filter( + (c: any) => c.model === 'calendarSource' && c.method === 'findMany', + ).length; + + expect(findManyCallsAfterUserB).toBe(findManyCallsAfterUserA + 1); + }); + + // ─── testConnectionFromConfig ─────────────────────────────────────────── + + it('testConnectionFromConfig: kein Datenbankzugriff, weder gebunden noch ungebunden — der Nachbau bleibt unberuehrt', async () => { + const prisma = makeFakePrisma(); + const { service } = makeCalendarService(prisma); + + await service.testConnectionFromConfig({ + type: 'ics', + url: 'https://example.invalid/cal.ics', + } as any); + + expect(prisma.__boundCallLog).toEqual([]); + expect(vi.mocked(prisma.calendarSource.findMany)).not.toHaveBeenCalled(); + expect(vi.mocked(prisma.calendarSource.findUnique)).not.toHaveBeenCalled(); + expect(vi.mocked(prisma.calendarSource.create)).not.toHaveBeenCalled(); + }); + + // ─── Wachhund ──────────────────────────────────────────────────────── + + it('keine Methode dieses Bereichs erzeugt mehr als EINEN gebundenen Klienten je Aufruf', async () => { + const prisma = makeFakePrisma(); + prisma.__seedSource({ id: 'src-a1', userId: 'user-a1', tenantId: 't1', isVisible: true, type: 'ics' }); + const { service } = makeCalendarService(prisma); + + for (const call of [ + () => service.getSources('user-a1', 't1'), + () => service.addSource('user-a1', 't1', { name: 'X', type: 'ics', url: 'https://example.invalid/x.ics' } as any), + () => service.updateSource('src-a1', 'user-a1', 't1', { name: 'Y' } as any), + () => service.testConnection('src-a1', 'user-a1', 't1'), + () => service.aggregateEvents('user-a1', 't1', '2026-02-01T00:00:00.000Z', '2026-02-02T00:00:00.000Z'), + () => service.deleteSource('src-a1', 'user-a1', 't1'), + ]) { + 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); + } + }); +}); diff --git a/apps/api/src/calendar/calendar.service.ts b/apps/api/src/calendar/calendar.service.ts index 1a96968..9895514 100644 --- a/apps/api/src/calendar/calendar.service.ts +++ b/apps/api/src/calendar/calendar.service.ts @@ -5,6 +5,7 @@ import { NotFoundException, } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; +import { forTenant } from '../prisma/prisma-tenant.extension'; import { CryptoService } from '../crypto/crypto.service'; import { CreateCalendarSourceDto } from './dto/create-calendar-source.dto'; import { UpdateCalendarSourceDto } from './dto/update-calendar-source.dto'; @@ -98,14 +99,47 @@ const CACHE_TTL_MS = 5 * 60 * 1000; /** * Service for calendar source CRUD and event aggregation. * - * Source config is per-user (D-09), not per-tenant. + * Source config is per-user AND per-tenant bound (Mandantentrennung Etappe + * 2, 260911-cwh) — `CalendarSource` carries a mandatory `tenantId` + * (D-09/T-05-*: per-user ownership; row-level security: per-tenant + * isolation). Every method with database access binds its own + * `tenantPrisma` via `forTenant()`. + * + * The three ownership checks (`updateSource`/`deleteSource`/ + * `testConnection`, comparing `existing.userId` against the calling user) + * are kept UNCHANGED alongside the binding, not replaced by it: the RLS + * policy on `CalendarSource` carries no user dimension (measured + * 260911-cwh, Aufgabe 1 — `calendarsource-fremder-nutzer-desselben-mandanten-gebunden-sichtbar`), + * so a colleague of the SAME tenant would otherwise see and modify a + * fellow user's encrypted Exchange/CalDAV credentials. Until the RLS + * policy itself gains a user dimension (Etappe-3-Entscheidung (2)), these + * application-level checks remain the only protection between users of the + * same tenant. + * * Credentials encrypted at rest via CryptoService (T-05-10). */ @Injectable() export class CalendarService { private readonly logger = new Logger(CalendarService.name); - /** Per-user event cache with TTL (Pitfall 4). Key: `userId:from:to`. */ + /** + * Per-user event cache with TTL (Pitfall 4). Key: `userId:from:to`. + * + * Cache-key judgment (260911-cwh, Aufgabe 1, Befund F — chain checked + * link by link at execution time): `userId` here is `User.id` + * (`apps/api/prisma/schema.prisma`, `model User`, `@id @default(uuid())`), + * reached via `calendar.controller.ts` `extractContext()` + * (`req.user?.id`), which is `JwtStrategy.validate()`'s `id: payload.sub` + * (`apps/api/src/auth/strategies/jwt.strategy.ts`), which is + * `sub: user.id` at token-issue time (`apps/api/src/auth/auth.service.ts`, + * lines 143/332) — the database identity, not a login name. The + * Etappe-3-Entscheidung (1) (tenant-scoped uniqueness for + * `User.username`/`User.email`) does NOT touch `User.id`, which remains + * a platform-wide UUID no tenant can share. The key therefore stays + * without a tenant component. If any link of this chain changes, the key + * needs a tenant component — the decision follows the measurement, not + * this comment. + */ private readonly eventCache = new Map(); constructor( @@ -120,8 +154,9 @@ export class CalendarService { * Returns all calendar sources for a user WITHOUT encryptedPassword. * Adds a `hasCredentials` boolean so the UI knows if credentials are set. */ - async getSources(userId: string) { - const sources = await this.prisma.calendarSource.findMany({ + async getSources(userId: string, tenantId: string) { + const tenantPrisma = forTenant(this.prisma, tenantId); + const sources = await tenantPrisma.calendarSource.findMany({ where: { userId }, select: { ...SOURCE_SAFE_SELECT, @@ -160,7 +195,8 @@ export class CalendarService { data.encryptedPassword = this.crypto.encrypt(dto.password); } - const created = await this.prisma.calendarSource.create({ + const tenantPrisma = forTenant(this.prisma, tenantId); + const created = await tenantPrisma.calendarSource.create({ data: data as any, select: SOURCE_SAFE_SELECT, }); @@ -172,8 +208,9 @@ export class CalendarService { * Updates a calendar source. Ownership check ensures user can only modify their own sources. * Re-encrypts password if provided; T-05-12 ownership enforcement. */ - async updateSource(id: string, userId: string, dto: UpdateCalendarSourceDto) { - const existing = await this.prisma.calendarSource.findUnique({ + async updateSource(id: string, userId: string, tenantId: string, dto: UpdateCalendarSourceDto) { + const tenantPrisma = forTenant(this.prisma, tenantId); + const existing = await tenantPrisma.calendarSource.findUnique({ where: { id }, select: { userId: true, type: true }, }); @@ -207,7 +244,7 @@ export class CalendarService { : null; } - const updated = await this.prisma.calendarSource.update({ + const updated = await tenantPrisma.calendarSource.update({ where: { id }, data: data as any, select: { @@ -223,8 +260,9 @@ export class CalendarService { /** * Deletes a calendar source. Ownership check enforced (T-05-12). */ - async deleteSource(id: string, userId: string) { - const existing = await this.prisma.calendarSource.findUnique({ + async deleteSource(id: string, userId: string, tenantId: string) { + const tenantPrisma = forTenant(this.prisma, tenantId); + const existing = await tenantPrisma.calendarSource.findUnique({ where: { id }, select: { userId: true }, }); @@ -236,7 +274,7 @@ export class CalendarService { throw new ForbiddenException('Not your calendar source'); } - await this.prisma.calendarSource.delete({ where: { id } }); + await tenantPrisma.calendarSource.delete({ where: { id } }); return { deleted: true }; } @@ -244,8 +282,9 @@ export class CalendarService { * Test connection to a calendar source via its provider. * Updates lastSyncAt/lastSyncError on the source record. */ - async testConnection(id: string, userId: string): Promise<{ success: boolean; error?: string }> { - const source = await this.prisma.calendarSource.findUnique({ where: { id } }); + async testConnection(id: string, userId: string, tenantId: string): Promise<{ success: boolean; error?: string }> { + const tenantPrisma = forTenant(this.prisma, tenantId); + const source = await tenantPrisma.calendarSource.findUnique({ where: { id } }); if (!source) throw new NotFoundException('Calendar source not found'); if (source.userId !== userId) throw new ForbiddenException('Not your calendar source'); @@ -264,7 +303,7 @@ export class CalendarService { try { const success = await provider.testConnection(decryptedSource); - await this.prisma.calendarSource.update({ + await tenantPrisma.calendarSource.update({ where: { id }, data: { lastSyncAt: success ? new Date() : undefined, @@ -275,7 +314,7 @@ export class CalendarService { return { success }; } catch (error) { const errorMsg = 'Connection failed'; // T-05-13: generic error, no credentials - await this.prisma.calendarSource.update({ + await tenantPrisma.calendarSource.update({ where: { id }, data: { lastSyncError: errorMsg }, }); @@ -326,6 +365,7 @@ export class CalendarService { */ async aggregateEvents( userId: string, + tenantId: string, from?: string, to?: string, ): Promise { @@ -339,13 +379,13 @@ export class CalendarService { if (cached && cached.expiresAt > Date.now()) { // Serve cached immediately, trigger background refresh if close to expiry if (cached.expiresAt - Date.now() < CACHE_TTL_MS / 2) { - this.refreshCacheInBackground(userId, fromDate, toDate, cacheKey); + this.refreshCacheInBackground(userId, tenantId, fromDate, toDate, cacheKey); } return cached.events; } // Fetch fresh - const events = await this.fetchAndCacheEvents(userId, fromDate, toDate, cacheKey); + const events = await this.fetchAndCacheEvents(userId, tenantId, fromDate, toDate, cacheKey); return events; } @@ -354,11 +394,13 @@ export class CalendarService { */ private async fetchAndCacheEvents( userId: string, + tenantId: string, from: Date, to: Date, cacheKey: string, ): Promise { - const sources = await this.prisma.calendarSource.findMany({ + const tenantPrisma = forTenant(this.prisma, tenantId); + const sources = await tenantPrisma.calendarSource.findMany({ where: { userId, isVisible: true }, }); @@ -384,7 +426,7 @@ export class CalendarService { const events = await provider.fetchEvents(decryptedSource, from, to); // Update sync status on success - await this.prisma.calendarSource.update({ + await tenantPrisma.calendarSource.update({ where: { id: source.id }, data: { lastSyncAt: new Date(), lastSyncError: null }, }); @@ -395,7 +437,7 @@ export class CalendarService { this.logger.warn( `Failed to fetch events from source ${source.id} (${source.type}): ${(error as Error).message}`, ); - await this.prisma.calendarSource.update({ + await tenantPrisma.calendarSource.update({ where: { id: source.id }, data: { lastSyncError: 'Event fetch failed' }, }); @@ -426,14 +468,23 @@ export class CalendarService { /** * Refreshes cache in the background without blocking the response. + * + * This is a request context that outlives the request (Befund B, + * 260911-cwh): it is NOT the "read across tenants, then bind per tenant" + * shape of the background-service section in + * docs/mandantentrennung-zugriffsklassifikation.md — it carries the + * tenant of the ORIGINAL request that triggered it (`aggregateEvents`) + * and cannot have any other tenant, because it never reads across + * tenants in the first place. */ private refreshCacheInBackground( userId: string, + tenantId: string, from: Date, to: Date, cacheKey: string, ): void { - this.fetchAndCacheEvents(userId, from, to, cacheKey).catch((error) => { + this.fetchAndCacheEvents(userId, tenantId, from, to, cacheKey).catch((error) => { this.logger.warn(`Background cache refresh failed: ${(error as Error).message}`); }); } diff --git a/docs/mandantentrennung-zugriffsklassifikation.md b/docs/mandantentrennung-zugriffsklassifikation.md index 38b524f..f74fac0 100644 --- a/docs/mandantentrennung-zugriffsklassifikation.md +++ b/docs/mandantentrennung-zugriffsklassifikation.md @@ -316,7 +316,7 @@ verwendet), Klasse, Stand (`gebunden`/`ungebunden`/`gemischt`, seit |---|---|---|---|---| | apps/api/src/auth/auth.service.ts | passwordResetToken | muss-mandantengebunden | gebunden | Kein eigenes `tenantId`, RLS ueber Join auf `User` (Migration 20260618112133). `requestPasswordReset`/`resetPassword` laufen vollstaendig ueber `forTenant()` (Etappe 1, WINDOWS #20, Aufgabe 1) — von der alten, nur `this.prisma.*` erkennenden Suche nie erfasst, weil bereits gebunden; die erweiterte Erkennung aus Aufgabe 2 (260909-ipc) macht diese Fundstelle erstmals sichtbar. | | apps/api/src/auth/auth.service.ts | user | muss-mandantengebunden | gemischt | `getMe`, `changePassword`, `adminResetPassword` suchen über die Benutzerkennung aus dem Sitzungsnachweis — der Mandant ist dort bereits bekannt (Aufgabe 2 fasst sie bewusst nicht an, siehe SUMMARY). | -| apps/api/src/calendar/calendar.service.ts | calendarSource | muss-mandantengebunden | ungebunden | Kalenderquellen eines Nutzers, `tenantId`-Spalte vorhanden. | +| apps/api/src/calendar/calendar.service.ts | calendarSource | muss-mandantengebunden | gebunden | Kalenderquellen eines Nutzers je Mandant gebunden (encryptedPassword traegt Zugangsdaten zu externen Exchange-/CalDAV-Servern), `tenantId`-Spalte vorhanden. Seit 260911-cwh (Aufgabe 2) laufen alle zwoelf Zugriffe (`getSources`, `addSource`, beide Abfragen von `updateSource`/`deleteSource`, alle drei Abfragen von `testConnection`, Laden plus beide Synchronstatus-Rueckschreibungen von `fetchAndCacheEvents`) ueber `forTenant()`, ein Klient je Methode; `fetchAndCacheEvents`/`refreshCacheInBackground` nehmen die Mandantenkennung als Parameter, Letztere traegt die Kennung der urspruenglichen Anfrage. Die drei Besitzpruefungen (`updateSource`/`deleteSource`/`testConnection`, Vergleich gegen `userId` aus dem Sitzungsnachweis) bleiben zusaetzlich bestehen — die Regel auf `CalendarSource` kennt keine Benutzerdimension (260911-cwh, Aufgabe 1, gemessen), sie sind bis zur Etappe-3-Entscheidung (2) der einzige Schutz zwischen Kollegen DESSELBEN Mandanten. | | apps/api/src/dashboard/dashboard.service.ts | dashboardLayout | muss-mandantengebunden | gebunden | Widget-Anordnung eines Nutzers, `tenantId`-Spalte vorhanden. Seit 260910-krx (Aufgabe 2) laufen `getLayout`/`saveLayout` GEMEINSAM ueber `forTenant()`, ein Klient je Methode; `saveLayout` uebersetzt eine `PrismaClientUnknownRequestError` (RLS-Konflikt auf der plattformweit eindeutigen `userId`, gemessen in Aufgabe 1 — NICHT die `P2002`-Form, die der Bereich `tenders` abfaengt) in eine deutsche Konfliktmeldung. | | apps/api/src/dashboard/dashboard.service.ts | module | keine-mandantengebundene-tabelle | ungebunden | Modulkatalog ist plattformweit, kein `tenantId` (Migration 20260909140000, Gruppe b). MESSUNG (260910-krx, Aufgabe 1, uebernommen aus `module-registry`-Pruefung `module-tabelle-traegt-keinen-zeilenschutz`): die Tabelle traegt heute keinen Zeilenschutz, eine Bindung waere heute wirkungslos, nicht katastrophal. BEDINGUNG: katastrophal wuerde sie erst, WENN Etappe 3 dieser Tabelle eine Regel gibt. Die Katalogaufloesung fuer den Widget-Modulfilter (`ModuleAccessService.getAccessibleModuleIds`) bindet bereits seit 260910-exd in ihrem eigenen Dienst — hier NICHT ein zweites Mal gebunden. | | apps/api/src/dashboard/dashboard.service.ts | searchProvider | muss-mandantengebunden | gebunden | `tenantId` nullbar. WINDOWS #19 geschlossen (260910-jab) als **widerlegte Prämisse** für dieses Modell. In diesem Durchlauf (260910-krx, Aufgabe 1) EIGENSTAENDIG nachgeprueft, nicht aus 260910-jab abgeschrieben: `grep -rn "searchProvider\|SearchProvider" apps packages prisma --include=*.ts --include=*.mjs --include=*.js --include=*.sql --include=*.json` (ohne `node_modules`, `dist/`, `.next/`) findet weiterhin genau einen Schreibweg, `dashboard.service.ts:addSearchProvider` (`create`), mit `tenantId: string` als Pflichtparameter — keine Seed-Datei, kein Skript. Seit Aufgabe 2/3 laufen `getSearchProviders`/`addSearchProvider`/`removeSearchProvider` ueber `forTenant()`; die Regel auf `SearchProvider` bleibt UNVERAENDERT streng, zusaetzlich datenbankseitig verteidigt durch `searchprovider-gebundenes-einfuegen-ohne-mandant-abgelehnt` (Aufgabe 1). |