feat(quick-260911-cwh): Bereich calendar binden — alle 12 Zugriffe ueber forTenant() (Aufgabe 2)

- calendar.service.spec.ts: NEU, Zwei-Klienten-Nachweis ueber __makeBoundClient
  (Muster dkv.service.spec.ts), Attrappen fuer CryptoService und die drei
  Provider, 23 Testfaelle: getSources/addSource, updateSource inkl. drei
  Erhaltungsfaelle, alle drei Besitzpruefungen je Ausnahmeart, testConnection
  Erfolgs-/Fehlerpfad, aggregateEvents/fetchAndCacheEvents inkl. beider
  Synchronstatus-Rueckschreibungen, Cache-Verhalten inkl. Nutzer-Trennung,
  testConnectionFromConfig ohne DB-Zugriff, Wachhund fuer genau einen
  gebundenen Klienten je Aufruf
- calendar.service.ts: alle 12 Zugriffe auf forTenant() umgestellt, ein
  tenantPrisma-Klient je Methode (getSources, addSource, updateSource,
  deleteSource, testConnection, fetchAndCacheEvents); Cache-Schluessel-Urteil
  und die kein-sechster-Hintergrunddienst-Begruendung als Kommentare
  festgehalten
- calendar.controller.ts: alle sechs kontextnutzenden Handler reichen
  Benutzer- UND Mandantenkennung aus extractContext durch, keine neue
  Vertrauensquelle
- Falsifizierungsnachweis durchgefuehrt: eine Rueckschreibung testweise
  entbunden, benannter Test ging rot ("aggregateEvents, Erfolgspfad"), Fund
  bestaetigt, zurueckgenommen
- docs/mandantentrennung-zugriffsklassifikation.md: Bestandsaufnahme-Zeile
  calendar.service.ts/calendarSource auf gebunden gezogen

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:55:30 +02:00
parent bf5fc4d4c7
commit 77cb124f59
4 changed files with 649 additions and 32 deletions
+19 -10
View File
@@ -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);
}
}
@@ -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<string, boolean> | undefined) {
if (!select) return { ...row };
const out: Record<string, unknown> = {};
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<string, any>(); // key: id
const boundCallLog: { tenantId: string; model: string; method: string }[] = [];
let autoId = 0;
const calendarSource = {
findMany: vi.fn(
async ({
where,
select,
orderBy,
}: { where?: Record<string, unknown>; select?: Record<string, boolean>; 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<string, boolean> }) => {
const row = sources.get(where.id);
return row ? _applySelect(row, select) : null;
},
),
create: vi.fn(
async ({
data,
select,
}: { data: Record<string, unknown>; select?: Record<string, boolean> }) => {
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<string, unknown>; select?: Record<string, boolean> }) => {
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<ReturnType<typeof makeDefaultSourceFields>>) {
sources.set(row.id, { ...makeDefaultSourceFields(), ...row });
},
__makeBoundClient(tenantId: string) {
const wrapModel = (model: Record<string, any>, 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);
}
});
});
+72 -21
View File
@@ -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<string, CacheEntry>();
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<CalendarEvent[]> {
@@ -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<CalendarEvent[]> {
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}`);
});
}