From 3336a6e4198f652cbcc977f528f90253e47217d6 Mon Sep 17 00:00:00 2001 From: Schalli Date: Wed, 9 Sep 2026 15:53:53 +0200 Subject: [PATCH] feat(laa-02): binde die fuenf Nutzer-CRUD-Dienste des Bereichs tenders an forTenant() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tender-saved-search.service.ts, tender-triage.service.ts, tender-notification-pref.service.ts und tender-email-config.service.ts laufen jetzt vollstaendig ueber forTenant() — vier neue Parameter (list, update, remove, listForUser, favoriteIds, getForUser, getConfigForApi, testConnection bekommen tenantId), die anwendungsseitige userId-Filterung bleibt unveraendert (Befund E: die Policies haben keine Benutzerdimension). tender-rss-feed.service.ts bindet nur createForUser (Zaehler + Anlage, beide ausschliesslich auf persoenlichen Zeilen); listForUser, createPlatform und remove bleiben mit Codekommentar bewusst ungebunden (WINDOWS #19 — eine gebundene plattformweite Zeile waere unter jedem Mandanten unsichtbar, ein gebundenes Einfuegen ohne Mandant wuerde abgewiesen). tender-notification-pref.service.ts und tender-email-config.service.ts uebersetzen eine P2002-Verletzung auf dem tenantlosen upsert-Schluessel (Befund F) in eine verstaendliche deutsche Meldung statt eines rohen Fehlers. tenders.controller.ts reicht tenantId an den acht betroffenen Aufrufstellen durch extractTriageContext() durch (kein neuer Aufloesungsweg); die drei RSS-Aufrufstellen bleiben unveraendert, da ihre Dienstmethoden nicht binden. Alle sieben angefassten Testdateien bekommen den Zwei-Client-Nachweis (__makeBoundClient ueber demselben Speicher) und Bindungstests je umgestellter Methode; tender-rss-feed.service.spec.ts zusaetzlich den Gegentest, dass die drei unveraendert bleibenden Pfade forTenant() NICHT aufrufen. Falsifiziert: ein probeweiser Rueckbau der list()-Bindung in tender-saved-search.service.ts machte genau den erwarteten Bindungstest rot, danach zurueckgenommen. docs/mandantentrennung-zugriffsklassifikation.md: Stand der fuenf Paare auf gebunden bzw. gemischt nachgezogen; tenderRssFeedSource von muss-mandantengebunden auf beides umklassifiziert (derselbe Praezedenzfall wie ldapConfig in 260909-ipc). 761 Tests gruen (743 + 18 neue Bindungsnachweise), Typpruefung sauber, Wegwerf-Werkzeug 32/32, kein Schema-/Migrations-/Compose-/ Umgebungsdatei-Diff. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR --- .../tender-email-config.service.spec.ts | 163 +++++++++++++--- .../tenders/tender-email-config.service.ts | 54 ++++-- .../tender-notification-pref.service.spec.ts | 117 ++++++++++-- .../tender-notification-pref.service.ts | 47 +++-- .../tenders/tender-rss-feed.service.spec.ts | 126 ++++++++++++- .../src/tenders/tender-rss-feed.service.ts | 33 +++- .../tender-saved-search.service.spec.ts | 176 +++++++++++++----- .../tenders/tender-saved-search.service.ts | 43 +++-- .../src/tenders/tender-triage.service.spec.ts | 143 ++++++++++---- apps/api/src/tenders/tender-triage.service.ts | 31 +-- .../src/tenders/tenders.controller.spec.ts | 44 +++-- apps/api/src/tenders/tenders.controller.ts | 32 ++-- ...andantentrennung-zugriffsklassifikation.md | 10 +- 13 files changed, 790 insertions(+), 229 deletions(-) diff --git a/apps/api/src/tenders/tender-email-config.service.spec.ts b/apps/api/src/tenders/tender-email-config.service.spec.ts index 1e62c94..f9a59a8 100644 --- a/apps/api/src/tenders/tender-email-config.service.spec.ts +++ b/apps/api/src/tenders/tender-email-config.service.spec.ts @@ -13,8 +13,17 @@ import { TenderEmailConfigService } from './tender-email-config.service'; * `where: { userId }` upsert target), and two new cases prove the actual * new capability: two users of the SAME tenant get two independent rows, * and tenantId is written on create (denormalized, D-01). + * + * Bindung an forTenant() (260909-laa, Befund C/H): `__makeBoundClient()` + * wraps the SAME in-memory Map with a per-call logging layer — a pure + * identity mock would leave a forgotten `forTenant()` call invisible to + * every test. Muster aus `groups.service.spec.ts` (260909-jts). */ +vi.mock('../prisma/prisma-tenant.extension', () => ({ + forTenant: vi.fn((prisma: any, tenantId: string) => prisma.__makeBoundClient(tenantId)), +})); + function makeFakeCrypto() { return { encrypt: vi.fn((plaintext: string) => `enc:${Buffer.from(plaintext).toString('base64')}`), @@ -27,28 +36,55 @@ function makeFakeCrypto() { function makeFakePrisma() { const configs = new Map(); - return { - tenderEmailConfig: { - findUnique: vi.fn(async ({ where, select }: any) => { - const row = configs.get(where.userId); - if (!row) return null; - if (!select) return row; - const out: any = {}; - for (const k of Object.keys(select)) out[k] = row[k]; - return out; - }), - upsert: vi.fn(async ({ where, update, create, select }: any) => { - const existing = configs.get(where.userId); - const row = existing ? { ...existing, ...update } : { id: `cfg-${configs.size + 1}`, ...create }; - configs.set(where.userId, row); - if (!select) return row; - const out: any = {}; - for (const k of Object.keys(select)) out[k] = row[k]; - return out; - }), - }, - __store: configs, + const boundCallLog: { tenantId: string; model: string; method: string }[] = []; + + const tenderEmailConfig = { + findUnique: vi.fn(async ({ where, select }: any) => { + const row = configs.get(where.userId); + if (!row) return null; + if (!select) return row; + const out: any = {}; + for (const k of Object.keys(select)) out[k] = row[k]; + return out; + }), + upsert: vi.fn(async ({ where, update, create, select }: any) => { + const existing = configs.get(where.userId); + const row = existing ? { ...existing, ...update } : { id: `cfg-${configs.size + 1}`, ...create }; + configs.set(where.userId, row); + if (!select) return row; + const out: any = {}; + for (const k of Object.keys(select)) out[k] = row[k]; + return out; + }), }; + + const fake: any = { + tenderEmailConfig, + __store: configs, + __boundCallLog: boundCallLog, + __makeBoundClient(tenantId: string) { + const wrapped: any = {}; + for (const method of Object.keys(tenderEmailConfig)) { + wrapped[method] = async (...args: any[]) => { + boundCallLog.push({ tenantId, model: 'tenderEmailConfig', method }); + return (tenderEmailConfig as any)[method](...args); + }; + } + return { tenderEmailConfig: wrapped }; + }, + }; + + return fake; +} + +function expectBoundCall(prisma: any, tenantId: string, method: string) { + const found = prisma.__boundCallLog.some( + (c: any) => c.tenantId === tenantId && c.model === 'tenderEmailConfig' && c.method === method, + ); + expect( + found, + `erwarteter gebundener Aufruf tenderEmailConfig.${method}(tenant=${tenantId}) fehlt im Protokoll: ${JSON.stringify(prisma.__boundCallLog)}`, + ).toBe(true); } describe('TenderEmailConfigService', () => { @@ -57,7 +93,7 @@ describe('TenderEmailConfigService', () => { const crypto = makeFakeCrypto(); const service = new TenderEmailConfigService(prisma as any, crypto as any); - const result = await service.getConfigForApi('user-missing'); + const result = await service.getConfigForApi('user-missing', 'tenant-x'); expect(result).toBeNull(); }); @@ -81,7 +117,7 @@ describe('TenderEmailConfigService', () => { } as any, ); - const apiResult = await service.getConfigForApi('user-a'); + const apiResult = await service.getConfigForApi('user-a', 'tenant-a'); expect(apiResult).not.toBeNull(); expect(apiResult).not.toHaveProperty('password'); @@ -107,7 +143,7 @@ describe('TenderEmailConfigService', () => { } as any, ); - const apiResult = await service.getConfigForApi('user-b'); + const apiResult = await service.getConfigForApi('user-b', 'tenant-b'); expect(apiResult!.hasPassword).toBe(false); expect(apiResult!.username).toBeNull(); @@ -223,8 +259,8 @@ describe('TenderEmailConfigService', () => { { protocol: 'imap', encryption: 'ssl-tls', host: 'imap.user-g2.test' } as any, ); - const configG1 = await service.getConfigForApi('user-g1'); - const configG2 = await service.getConfigForApi('user-g2'); + const configG1 = await service.getConfigForApi('user-g1', 'tenant-shared'); + const configG2 = await service.getConfigForApi('user-g2', 'tenant-shared'); expect(configG1!.host).toBe('imap.user-g1.test'); expect(configG2!.host).toBe('imap.user-g2.test'); @@ -250,7 +286,7 @@ describe('TenderEmailConfigService', () => { exchangeProvider as any, ); - const result = await service.testConnection('user-h', { + const result = await service.testConnection('user-h', 'tenant-h', { protocol: 'imap', encryption: 'ssl-tls', host: 'imap.example.test', @@ -286,7 +322,7 @@ describe('TenderEmailConfigService', () => { } as any, ); - await service.testConnection('user-i', { + await service.testConnection('user-i', 'tenant-i', { protocol: 'imap', encryption: 'ssl-tls', } as any); @@ -307,7 +343,7 @@ describe('TenderEmailConfigService', () => { exchangeProvider as any, ); - await service.testConnection('user-j', { + await service.testConnection('user-j', 'tenant-j', { protocol: 'exchange', encryption: 'ssl-tls', username: 'ews-user', @@ -318,4 +354,73 @@ describe('TenderEmailConfigService', () => { expect(imapProvider.testConnection).not.toHaveBeenCalled(); }); }); + + // --- Bindung an forTenant() (260909-laa, Aufgabe 2) ----------------------- + + describe('Bindung an forTenant() (260909-laa)', () => { + it('getConfigForApi() bindet beide tenderEmailConfig.findUnique-Zugriffe an den uebergebenen Mandanten', async () => { + const prisma = makeFakePrisma(); + const crypto = makeFakeCrypto(); + const service = new TenderEmailConfigService(prisma as any, crypto as any); + + await service.saveConfig( + { userId: 'user-k', tenantId: 't1' }, + { protocol: 'imap', encryption: 'ssl-tls', username: 'k', password: 'p' } as any, + ); + prisma.__boundCallLog.length = 0; + + await service.getConfigForApi('user-k', 't1'); + + const findUniqueCalls = prisma.__boundCallLog.filter( + (c: any) => c.tenantId === 't1' && c.model === 'tenderEmailConfig' && c.method === 'findUnique', + ); + expect(findUniqueCalls.length).toBe(2); + }); + + it('saveConfig() bindet den credChanged-Lesezugriff UND das upsert an den uebergebenen Mandanten', async () => { + const prisma = makeFakePrisma(); + const crypto = makeFakeCrypto(); + const service = new TenderEmailConfigService(prisma as any, crypto as any); + + await service.saveConfig( + { userId: 'user-l', tenantId: 't1' }, + { protocol: 'imap', encryption: 'ssl-tls', username: 'only-username' } as any, + ); + + expectBoundCall(prisma, 't1', 'findUnique'); + expectBoundCall(prisma, 't1', 'upsert'); + }); + + it('testConnection() bindet den Zugangsdaten-Rueckgriff an den uebergebenen Mandanten', async () => { + const prisma = makeFakePrisma(); + const crypto = makeFakeCrypto(); + const { imapProvider, exchangeProvider } = makeFakeProviders(); + const service = new TenderEmailConfigService( + prisma as any, + crypto as any, + imapProvider as any, + exchangeProvider as any, + ); + + await service.saveConfig( + { userId: 'user-m', tenantId: 't1' }, + { protocol: 'imap', encryption: 'ssl-tls', username: 'm', password: 'p' } as any, + ); + prisma.__boundCallLog.length = 0; + + await service.testConnection('user-m', 't1', { + protocol: 'imap', + encryption: 'ssl-tls', + } as any); + + expectBoundCall(prisma, 't1', 'findUnique'); + }); + + function makeFakeProviders() { + return { + imapProvider: { testConnection: vi.fn(async () => ({ success: true })) }, + exchangeProvider: { testConnection: vi.fn(async () => ({ success: true })) }, + }; + } + }); }); diff --git a/apps/api/src/tenders/tender-email-config.service.ts b/apps/api/src/tenders/tender-email-config.service.ts index 7738034..26a1c81 100644 --- a/apps/api/src/tenders/tender-email-config.service.ts +++ b/apps/api/src/tenders/tender-email-config.service.ts @@ -1,10 +1,11 @@ -import { Injectable, Logger, Optional } from '@nestjs/common'; +import { ConflictException, Injectable, Logger, Optional } from '@nestjs/common'; import { CryptoService } from '../crypto/crypto.service'; import { ExchangeInboxProvider } from '../inbox/exchange-inbox.provider'; import { ImapProvider } from '../inbox/imap.provider'; import type { InboxConfig, InboxProvider } from '../inbox/inbox-provider.interface'; import { PrismaService } from '../prisma/prisma.service'; +import { forTenant } from '../prisma/prisma-tenant.extension'; import type { TenderEmailConfigDto } from './dto/tender-email-config.dto'; /** @@ -43,6 +44,16 @@ const EMAIL_CONFIG_SAFE_SELECT = { * `tenantId` on TenderMatch) and is written on both create and update, * since a user's tenant can in principle change. * + * Mandantengebunden (WINDOWS #20 Etappe 2, 260909-laa): every read/write + * below runs through `forTenant()`. Because `userId` alone (not + * `(userId, tenantId)`) is the row's unique key, a user whose stored + * `tenantId` has gone stale sees their OWN row become invisible under the + * now-bound context — `getConfigForApi`/`testConnection` then correctly + * report "no mailbox configured" (safe direction, no cross-tenant leak), + * and a bound `saveConfig` upsert on that stale row hits the platform-wide + * uniqueness constraint on `userId` and surfaces as a translated + * ConflictException, not a raw 500 (T-LAA-07, Befund F, Aufgabe 1). + * * Security: * - T-07-12: encryptedInboxCreds is excluded from every read-path select; * getConfigForApi returns `hasPassword: boolean` instead of the password. @@ -84,8 +95,9 @@ export class TenderEmailConfigService { * hasPassword flag. T-07-12: password is NEVER returned. Scoped strictly * by userId (T-17-01) — a user only ever reads their own mailbox. */ - async getConfigForApi(userId: string) { - const safe = await this.prisma.tenderEmailConfig.findUnique({ + async getConfigForApi(userId: string, tenantId: string) { + const tenantPrisma = forTenant(this.prisma, tenantId) as any; + const safe = await tenantPrisma.tenderEmailConfig.findUnique({ where: { userId }, select: EMAIL_CONFIG_SAFE_SELECT, }); @@ -94,7 +106,7 @@ export class TenderEmailConfigService { let username: string | null = null; let hasPassword = false; try { - const raw = await this.prisma.tenderEmailConfig.findUnique({ where: { userId } }); + const raw = await tenantPrisma.tenderEmailConfig.findUnique({ where: { userId } }); if (raw?.encryptedInboxCreds) { const creds = JSON.parse(this.crypto.decrypt(raw.encryptedInboxCreds)) as { username?: string; @@ -125,9 +137,16 @@ export class TenderEmailConfigService { * * T-07-12: Returns safe select (no encryptedInboxCreds). * T-05-13: Never logs decrypted credentials. + * + * T-LAA-07 (Befund F): the upsert target (`userId`) has no tenant + * dimension. A P2002 here means the caller's stale-tenant row already + * exists and is now invisible under the bound context — translated into + * a German ConflictException instead of a raw error (same pattern as + * `tender-saved-search.service.ts`). */ async saveConfig(ctx: { userId: string; tenantId: string }, dto: TenderEmailConfigDto) { const { userId, tenantId } = ctx; + const tenantPrisma = forTenant(this.prisma, tenantId) as any; let encryptedInboxCreds: string | undefined; const credChanged = @@ -140,7 +159,7 @@ export class TenderEmailConfigService { if (!dto.password || !dto.username) { try { - const existing = await this.prisma.tenderEmailConfig.findUnique({ where: { userId } }); + const existing = await tenantPrisma.tenderEmailConfig.findUnique({ where: { userId } }); if (existing?.encryptedInboxCreds) { const stored = JSON.parse(this.crypto.decrypt(existing.encryptedInboxCreds)) as { username?: string; @@ -172,12 +191,21 @@ export class TenderEmailConfigService { // tenantId is written on BOTH create and update — it is denormalized // (Phase 17, D-01), so a user's resolved tenant must always overwrite // whatever was stored previously, not just be set once on create. - return this.prisma.tenderEmailConfig.upsert({ - where: { userId }, - create: { userId, tenantId, ...data }, - update: { tenantId, ...data }, - select: EMAIL_CONFIG_SAFE_SELECT, - }); + try { + return await tenantPrisma.tenderEmailConfig.upsert({ + where: { userId }, + create: { userId, tenantId, ...data }, + update: { tenantId, ...data }, + select: EMAIL_CONFIG_SAFE_SELECT, + }); + } catch (error: any) { + if (error?.code === 'P2002') { + throw new ConflictException( + 'Die Postfach-Konfiguration konnte nicht gespeichert werden, weil bereits ein widersprüchlicher Eintrag existiert. Bitte laden Sie die Seite neu und versuchen Sie es erneut.', + ); + } + throw error; + } } /** @@ -198,6 +226,7 @@ export class TenderEmailConfigService { */ async testConnection( userId: string, + tenantId: string, dto: TenderEmailConfigDto, ): Promise<{ success: boolean; message?: string }> { let username: string | undefined = dto.username; @@ -205,7 +234,8 @@ export class TenderEmailConfigService { if (!username || !password) { try { - const existing = await this.prisma.tenderEmailConfig.findUnique({ where: { userId } }); + const tenantPrisma = forTenant(this.prisma, tenantId) as any; + const existing = await tenantPrisma.tenderEmailConfig.findUnique({ where: { userId } }); if (existing?.encryptedInboxCreds) { const stored = JSON.parse(this.crypto.decrypt(existing.encryptedInboxCreds)) as { username?: string; diff --git a/apps/api/src/tenders/tender-notification-pref.service.spec.ts b/apps/api/src/tenders/tender-notification-pref.service.spec.ts index f6f70f2..f3a4cd0 100644 --- a/apps/api/src/tenders/tender-notification-pref.service.spec.ts +++ b/apps/api/src/tenders/tender-notification-pref.service.spec.ts @@ -1,4 +1,5 @@ -import { describe, expect, it } from 'vitest'; +import { ConflictException } from '@nestjs/common'; +import { describe, expect, it, vi } from 'vitest'; import { TenderNotificationPrefService } from './tender-notification-pref.service'; /** @@ -9,28 +10,72 @@ import { TenderNotificationPrefService } from './tender-notification-pref.servic * { digestInterval: 'daily' } (D-01) — no error, no implicit autowrite. * - setForUser() upserts on the @@unique userId (D-03); a second call with * a different value updates the SAME row rather than creating a new one. + * - setForUser() translates a P2002 (unique-constraint violation on a + * stale-tenant upsert, T-LAA-07/Befund F) into a German ConflictException + * instead of a raw error. * - * Uses the same hand-rolled prisma-shaped fake convention as - * tender-saved-search.service.spec.ts / tender-triage.service.spec.ts - * (in-memory Map, no live DB connection). + * Bindung an forTenant() (260909-laa, Befund C/H) — Muster aus + * `groups.service.spec.ts` (260909-jts): `__makeBoundClient()` wraps the + * SAME in-memory Map with a per-call logging layer, so a forgotten + * `forTenant()` call is visible as a missing log entry, not just a passing + * test either way. */ +vi.mock('../prisma/prisma-tenant.extension', () => ({ + forTenant: vi.fn((prisma: any, tenantId: string) => prisma.__makeBoundClient(tenantId)), +})); + function makeFakePrisma() { const rows = new Map(); + const boundCallLog: { tenantId: string; model: string; method: string }[] = []; - return { - tenderNotificationPref: { - findUnique: async ({ where }: any) => rows.get(where.userId) ?? null, - upsert: async ({ where, create, update }: any) => { - const existing = rows.get(where.userId); - const record = existing - ? { ...existing, ...update, updatedAt: new Date() } - : { id: `pref-${rows.size + 1}`, ...create, createdAt: new Date(), updatedAt: new Date() }; - rows.set(where.userId, record); - return record; - }, + function throwUniqueViolation(): never { + const err: any = new Error('Unique constraint failed on the fields: (`userId`)'); + err.code = 'P2002'; + throw err; + } + + const tenderNotificationPref = { + findUnique: async ({ where }: any) => rows.get(where.userId) ?? null, + upsert: async ({ where, create, update }: any) => { + const existing = rows.get(where.userId); + const record = existing + ? { ...existing, ...update, updatedAt: new Date() } + : { id: `pref-${rows.size + 1}`, ...create, createdAt: new Date(), updatedAt: new Date() }; + rows.set(where.userId, record); + return record; }, + __throwUniqueViolationOnNextUpsert: false, }; + + const fake: any = { + tenderNotificationPref, + __boundCallLog: boundCallLog, + __makeBoundClient(tenantId: string) { + const wrapped: any = {}; + for (const method of ['findUnique', 'upsert']) { + wrapped[method] = async (...args: any[]) => { + boundCallLog.push({ tenantId, model: 'tenderNotificationPref', method }); + return (tenderNotificationPref as any)[method](...args); + }; + } + return { tenderNotificationPref: wrapped }; + }, + __throwUniqueViolation: throwUniqueViolation, + }; + + return fake; +} + +function expectBoundCall(prisma: any, tenantId: string, method: string) { + const found = prisma.__boundCallLog.some( + (c: any) => + c.tenantId === tenantId && c.model === 'tenderNotificationPref' && c.method === method, + ); + expect( + found, + `erwarteter gebundener Aufruf tenderNotificationPref.${method}(tenant=${tenantId}) fehlt im Protokoll: ${JSON.stringify(prisma.__boundCallLog)}`, + ).toBe(true); } describe('TenderNotificationPrefService', () => { @@ -38,7 +83,7 @@ describe('TenderNotificationPrefService', () => { const prisma = makeFakePrisma(); const service = new TenderNotificationPrefService(prisma as any); - const result = await service.getForUser('u1'); + const result = await service.getForUser('u1', 'tenant1'); expect(result.digestInterval).toBe('daily'); }); @@ -62,7 +107,7 @@ describe('TenderNotificationPrefService', () => { const second = await service.setForUser('u1', 'tenant1', 'off'); expect(second.digestInterval).toBe('off'); - expect(await service.getForUser('u1')).toMatchObject({ digestInterval: 'off' }); + expect(await service.getForUser('u1', 'tenant1')).toMatchObject({ digestInterval: 'off' }); }); it('getForUser() is scoped strictly by userId — a foreign userId never sees another user\'s pref (V4 / IDOR)', async () => { @@ -71,7 +116,43 @@ describe('TenderNotificationPrefService', () => { await service.setForUser('u1', 'tenant1', 'weekly'); - const foreign = await service.getForUser('u2'); + const foreign = await service.getForUser('u2', 'tenant1'); expect(foreign.digestInterval).toBe('daily'); // default, not u1's 'weekly' }); + + // --- Fehlerbehandlung (260909-laa, Befund F / T-LAA-07) ------------------- + + it('setForUser() translates a P2002 unique-constraint violation into a German ConflictException, never a raw error', async () => { + const prisma = makeFakePrisma(); + prisma.tenderNotificationPref.upsert = vi.fn(async () => { + prisma.__throwUniqueViolation(); + }); + const service = new TenderNotificationPrefService(prisma as any); + + await expect(service.setForUser('u1', 'tenant1', 'weekly')).rejects.toBeInstanceOf( + ConflictException, + ); + }); + + // --- Bindung an forTenant() (260909-laa, Aufgabe 2) ----------------------- + + describe('Bindung an forTenant() (260909-laa)', () => { + it('getForUser() bindet tenderNotificationPref.findUnique an den uebergebenen Mandanten', async () => { + const prisma = makeFakePrisma(); + const service = new TenderNotificationPrefService(prisma as any); + + await service.getForUser('u1', 't1'); + + expectBoundCall(prisma, 't1', 'findUnique'); + }); + + it('setForUser() bindet tenderNotificationPref.upsert an den uebergebenen Mandanten', async () => { + const prisma = makeFakePrisma(); + const service = new TenderNotificationPrefService(prisma as any); + + await service.setForUser('u1', 't1', 'weekly'); + + expectBoundCall(prisma, 't1', 'upsert'); + }); + }); }); diff --git a/apps/api/src/tenders/tender-notification-pref.service.ts b/apps/api/src/tenders/tender-notification-pref.service.ts index 60bc7ec..89123c4 100644 --- a/apps/api/src/tenders/tender-notification-pref.service.ts +++ b/apps/api/src/tenders/tender-notification-pref.service.ts @@ -1,19 +1,31 @@ -import { Injectable } from '@nestjs/common'; +import { ConflictException, Injectable } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; +import { forTenant } from '../prisma/prisma-tenant.extension'; /** * Service for managing the per-user Tender digest interval preference * (NOTIFY-01, D-01/D-03). * * Access control (T-12-14 / V4 — IDOR): scoped by userId exactly like - * TenderSavedSearchService/TenderTriageService (T-11-14/T-08-06) — NOT - * forTenant()/RLS (Pitfall 4). userId must always be derived from the - * caller's auth context (controller), never accepted as a body/query - * parameter here. + * TenderSavedSearchService/TenderTriageService (T-11-14/T-08-06). This + * stays deliberate belt-and-suspenders after binding to `forTenant()` + * (260909-laa) — the delivered policy on TenderNotificationPref has no + * user dimension (Befund E, Aufgabe 1). * * `TenderNotificationPref` has a per-user `@@unique` on `userId` (one row * per user, D-03: the interval is a user setting, not per-profile) — this * service upserts on that key. + * + * T-LAA-07 (260909-laa, Befund F): `userId` has no tenant dimension. If a + * user's stored `tenantId` has gone stale (their resolved tenant changed) + * the existing row can become invisible under the now-bound context — a + * bound `upsert` then falls into the create branch and hits the + * platform-wide uniqueness constraint on `userId`. Aufgabe 1 measured this + * exact shape for the sibling `TenderTriage` upsert + * (`tendertriage-einfuegen-auf-unsichtbare-zeile-verletzt-eindeutigkeit`): + * the failure is a P2002 unique-constraint violation, not an RLS + * rejection. Translated below into a German message, same pattern as + * `tender-saved-search.service.ts`, instead of surfacing as a raw 500. */ @Injectable() export class TenderNotificationPrefService { @@ -26,8 +38,9 @@ export class TenderNotificationPrefService { * with the digest scheduler's own default-daily due-check semantics, no * autowrite needed to represent "using the default". */ - async getForUser(userId: string): Promise<{ digestInterval: string }> { - const existing = await this.prisma.tenderNotificationPref.findUnique({ + async getForUser(userId: string, tenantId: string): Promise<{ digestInterval: string }> { + const tenantPrisma = forTenant(this.prisma, tenantId) as any; + const existing = await tenantPrisma.tenderNotificationPref.findUnique({ where: { userId }, }); @@ -44,10 +57,20 @@ export class TenderNotificationPrefService { * than creating a new one. */ async setForUser(userId: string, tenantId: string, digestInterval: string) { - return this.prisma.tenderNotificationPref.upsert({ - where: { userId }, - create: { userId, tenantId, digestInterval }, - update: { digestInterval }, - }); + const tenantPrisma = forTenant(this.prisma, tenantId) as any; + try { + return await tenantPrisma.tenderNotificationPref.upsert({ + where: { userId }, + create: { userId, tenantId, digestInterval }, + update: { digestInterval }, + }); + } catch (error: any) { + if (error?.code === 'P2002') { + throw new ConflictException( + 'Die Benachrichtigungseinstellung konnte nicht gespeichert werden, weil bereits ein widersprüchlicher Eintrag existiert. Bitte laden Sie die Seite neu und versuchen Sie es erneut.', + ); + } + throw error; + } } } diff --git a/apps/api/src/tenders/tender-rss-feed.service.spec.ts b/apps/api/src/tenders/tender-rss-feed.service.spec.ts index b0ab4a5..5443586 100644 --- a/apps/api/src/tenders/tender-rss-feed.service.spec.ts +++ b/apps/api/src/tenders/tender-rss-feed.service.spec.ts @@ -1,7 +1,23 @@ import { BadRequestException, NotFoundException } from '@nestjs/common'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; +import { forTenant } from '../prisma/prisma-tenant.extension'; import { TenderRssFeedSourceService } from './tender-rss-feed.service'; +/** + * Bindung an forTenant() (260909-laa, WINDOWS #19): nur `createForUser` + * beruehrt ausschliesslich persoenliche Zeilen mit gesetztem Mandanten und + * bindet deshalb. `listForUser`/`createPlatform`/`remove` beruehren (auch) + * plattformweite Zeilen (`userId`/`tenantId` NULL) und MUESSEN ungebunden + * bleiben — eine Bindung wuerde die plattformweite Quelle unter jedem + * Mandanten verschwinden lassen bzw. das Einfuegen/Entfernen ohne Mandant + * ablehnen (Aufgabe 1, Befund D). `__makeBoundClient()` liefert denselben + * protokollierenden Wrapper wie bei den vier vollstaendig gebundenen + * Diensten dieses Bereichs (Muster aus `groups.service.spec.ts`). + */ +vi.mock('../prisma/prisma-tenant.extension', () => ({ + forTenant: vi.fn((prisma: any, tenantId: string) => prisma.__makeBoundClient(tenantId)), +})); + /** * TenderRssFeedSourceService.spec — proves the save-time hostname/SSRF * guard (T-14-02-01, D-14/RESEARCH.md Pitfall 3): a runtime-user-supplied @@ -38,9 +54,9 @@ function matchesWhere(row: any, where: any): boolean { function makeFakePrisma() { const rows = new Map(); let seq = 0; + const boundCallLog: { tenantId: string; model: string; method: string }[] = []; - return { - tenderRssFeedSource: { + const tenderRssFeedSource = { findMany: async ({ where, orderBy }: any = {}) => { let all = [...rows.values()].filter((row) => matchesWhere(row, where)); if (orderBy?.createdAt === 'asc') { @@ -79,9 +95,35 @@ function makeFakePrisma() { for (const row of toDelete) rows.delete(row.id); return { count: toDelete.length }; }, - }, - __rows: rows, }; + + const fake: any = { + tenderRssFeedSource, + __rows: rows, + __boundCallLog: boundCallLog, + __makeBoundClient(tenantId: string) { + const wrapped: any = {}; + for (const method of Object.keys(tenderRssFeedSource)) { + wrapped[method] = async (...args: any[]) => { + boundCallLog.push({ tenantId, model: 'tenderRssFeedSource', method }); + return (tenderRssFeedSource as any)[method](...args); + }; + } + return { tenderRssFeedSource: wrapped }; + }, + }; + + return fake; +} + +function expectBoundCall(prisma: any, tenantId: string, method: string) { + const found = prisma.__boundCallLog.some( + (c: any) => c.tenantId === tenantId && c.model === 'tenderRssFeedSource' && c.method === method, + ); + expect( + found, + `erwarteter gebundener Aufruf tenderRssFeedSource.${method}(tenant=${tenantId}) fehlt im Protokoll: ${JSON.stringify(prisma.__boundCallLog)}`, + ).toBe(true); } describe('TenderRssFeedSourceService', () => { @@ -446,4 +488,78 @@ describe('TenderRssFeedSourceService', () => { expect(prisma.__rows.size).toBe(0); }); }); + + // --- Bindung an forTenant() — WINDOWS #19 (260909-laa, Aufgabe 2) -------- + + describe('Bindung an forTenant() — WINDOWS #19 (260909-laa)', () => { + it('createForUser() bindet Zaehler UND Anlage an den uebergebenen Mandanten', async () => { + const prisma = makeFakePrisma(); + const service = new TenderRssFeedSourceService(prisma as any); + + await service.createForUser( + { userId: 'user-a', tenantId: 'tenant-a' }, + { url: 'https://mine.example-tenders.invalid/rss.xml', label: 'mine' }, + ); + + expectBoundCall(prisma, 'tenant-a', 'count'); + expectBoundCall(prisma, 'tenant-a', 'create'); + }); + + it('listForUser() bindet NICHT — forTenant() wird nicht aufgerufen (WINDOWS #19)', async () => { + const prisma = makeFakePrisma(); + const service = new TenderRssFeedSourceService(prisma as any); + vi.mocked(forTenant).mockClear(); + + await service.createPlatform({ + url: 'https://platform.example-tenders.invalid/rss.xml', + label: 'platform', + }); + vi.mocked(forTenant).mockClear(); + + await service.listForUser('u-anyone'); + + expect(forTenant).not.toHaveBeenCalled(); + }); + + it('createPlatform() bindet NICHT — forTenant() wird nicht aufgerufen (WINDOWS #19)', async () => { + const prisma = makeFakePrisma(); + const service = new TenderRssFeedSourceService(prisma as any); + vi.mocked(forTenant).mockClear(); + + await service.createPlatform({ + url: 'https://platform.example-tenders.invalid/rss.xml', + label: 'platform', + }); + + expect(forTenant).not.toHaveBeenCalled(); + }); + + it('remove() bindet NICHT — forTenant() wird nicht aufgerufen, auch nicht fuer einen Administrator (WINDOWS #19)', async () => { + const prisma = makeFakePrisma(); + const service = new TenderRssFeedSourceService(prisma as any); + const created = await service.createPlatform({ + url: 'https://platform.example-tenders.invalid/rss.xml', + label: 'platform', + }); + vi.mocked(forTenant).mockClear(); + + await service.remove(created.id, { userId: 'admin-1', isAdmin: true }); + + expect(forTenant).not.toHaveBeenCalled(); + }); + + it('listForUser() liefert weiterhin die plattformweite Zeile ohne Besitzer mit', async () => { + const prisma = makeFakePrisma(); + const service = new TenderRssFeedSourceService(prisma as any); + + await service.createPlatform({ + url: 'https://platform.example-tenders.invalid/rss.xml', + label: 'platform', + }); + + const list = await service.listForUser('u-anyone'); + + expect(list.map((f: any) => f.label)).toContain('platform'); + }); + }); }); diff --git a/apps/api/src/tenders/tender-rss-feed.service.ts b/apps/api/src/tenders/tender-rss-feed.service.ts index ba8e607..73e43d8 100644 --- a/apps/api/src/tenders/tender-rss-feed.service.ts +++ b/apps/api/src/tenders/tender-rss-feed.service.ts @@ -1,5 +1,6 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; +import { forTenant } from '../prisma/prisma-tenant.extension'; import type { TenderRssFeedDto } from './dto/tender-rss-feed.dto'; import { DENYLISTED_PORTALS } from './source-registry'; @@ -46,6 +47,14 @@ export class TenderRssFeedSourceService { /** * Every platform-wide feed (`userId = null`) plus this user's own * personal feeds, oldest first (D-02). + * + * Bewusst UNGEBUNDEN (WINDOWS #19, 260909-laa, Aufgabe 1): dieser Pfad + * liest auch die plattformweiten Zeilen (`userId = null`, `tenantId = + * null`) — die ausgelieferte Policy auf `TenderRssFeedSource` lautet + * `"tenantId" = current_tenant_id()` und vergleicht `NULL` nie gleich. + * Ein gebundener Lesezugriff würde die plattformweite Quelle + * (`service.bund.de`) für JEDEN Mandanten verschwinden lassen — gemessen + * in `tenderrssfeed-plattformzeile-unter-jedem-mandanten-unsichtbar`. */ async listForUser(userId: string) { return this.prisma.tenderRssFeedSource.findMany({ @@ -59,6 +68,11 @@ export class TenderRssFeedSourceService { * clear German message once the caller already owns * `MAX_PERSONAL_FEEDS_PER_USER` feeds (T-17-10) — platform-wide feeds are * never counted against this cap. + * + * Gebunden (260909-laa, Aufgabe 2): sowohl der Zaehler als auch die + * Anlage laufen ausschliesslich auf persoenlichen Zeilen mit gesetztem + * Mandanten — anders als `listForUser`/`createPlatform`/`remove` betrifft + * dieser Pfad nie eine plattformweite Zeile. */ async createForUser( ctx: { userId: string; tenantId: string }, @@ -66,7 +80,8 @@ export class TenderRssFeedSourceService { ) { this.assertUrlAllowed(dto.url); - const existingCount = await this.prisma.tenderRssFeedSource.count({ + const tenantPrisma = forTenant(this.prisma, ctx.tenantId) as any; + const existingCount = await tenantPrisma.tenderRssFeedSource.count({ where: { userId: ctx.userId }, }); if (existingCount >= MAX_PERSONAL_FEEDS_PER_USER) { @@ -75,7 +90,7 @@ export class TenderRssFeedSourceService { ); } - return this.prisma.tenderRssFeedSource.create({ + return tenantPrisma.tenderRssFeedSource.create({ data: { url: dto.url, label: dto.label, @@ -90,6 +105,11 @@ export class TenderRssFeedSourceService { * Creates a platform-wide feed (`userId`/`tenantId` stay null). Callers * MUST verify ADMIN/SUPER_ADMIN before calling this — this method itself * enforces no authorization (T-17-08, done in TendersController). + * + * Bewusst UNGEBUNDEN (WINDOWS #19, 260909-laa, Aufgabe 1): das Einfuegen + * setzt `tenantId = NULL` — ein gebundenes INSERT liefe in die + * WITH-CHECK-Wirkung der ausgelieferten Policy und wuerde abgewiesen, + * gemessen in `tenderrssfeed-gebundenes-einfuegen-ohne-mandant-abgelehnt`. */ async createPlatform(dto: TenderRssFeedDto) { this.assertUrlAllowed(dto.url); @@ -114,6 +134,15 @@ export class TenderRssFeedSourceService { * targeting a platform-wide feed), throws `NotFoundException` — never * `ForbiddenException` — so the response never confirms whether a * feed with that id exists at all. + * + * Bewusst UNGEBUNDEN (WINDOWS #19, 260909-laa, Aufgabe 1): fuer einen + * Administrator deckt dieser Pfad auch das Entfernen einer plattformweiten + * Zeile ab (`userId = null`) — gebunden koennte niemand mehr eine + * plattformweite Quelle entfernen. Den einen bedingten `deleteMany` in + * zwei Anweisungen zu zerlegen, um nur die persoenliche Haelfte zu + * binden, wuerde genau das Pruef-/Nutzungsfenster wieder oeffnen, das + * dieser Kommentar oben vermeidet — deshalb bleibt die gesamte Methode + * ungebunden, nicht nur ihre plattformweite Haelfte. */ async remove(id: string, ctx: { userId: string; isAdmin: boolean }) { const { userId, isAdmin } = ctx; diff --git a/apps/api/src/tenders/tender-saved-search.service.spec.ts b/apps/api/src/tenders/tender-saved-search.service.spec.ts index bbc931c..ec8a7dd 100644 --- a/apps/api/src/tenders/tender-saved-search.service.spec.ts +++ b/apps/api/src/tenders/tender-saved-search.service.spec.ts @@ -1,5 +1,5 @@ import { ConflictException, NotFoundException } from '@nestjs/common'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { TenderSavedSearchService } from './tender-saved-search.service'; /** @@ -17,15 +17,23 @@ import { TenderSavedSearchService } from './tender-saved-search.service'; * (FavoritesService pattern — never distinguishes the two, to avoid * leaking existence of another user's profile). * - * Uses the same hand-rolled prisma-shaped fake convention as - * tender-triage.service.spec.ts / tenders.controller.spec.ts (in-memory - * Map, no live DB connection). The fake simulates Prisma's P2002 unique- - * constraint violation the same way a live Postgres unique index would. + * Bindung an forTenant() (260909-laa, Befund C/H): anders als ein reiner + * Identitaets-Mock (`forTenant: vi.fn((p) => p)`, der ldap-Fehler, bei dem + * kein Test in beiden Richtungen etwas merkt) liefert `__makeBoundClient()` + * einen je Modell protokollierenden Wrapper um DIESELBE Map — ein + * vergessener `forTenant()`-Aufruf hinterlaesst im Protokoll keinen + * Eintrag und laesst den Bindungsnachweis fehlschlagen. Muster aus + * `groups.service.spec.ts` (260909-jts) uebertragen. */ +vi.mock('../prisma/prisma-tenant.extension', () => ({ + forTenant: vi.fn((prisma: any, tenantId: string) => prisma.__makeBoundClient(tenantId)), +})); + function makeFakePrisma() { const rows = new Map(); let counter = 0; + const boundCallLog: { tenantId: string; model: string; method: string }[] = []; function findByUserAndName(userId: string, name: string, excludeId?: string) { return Array.from(rows.values()).find( @@ -39,38 +47,68 @@ function makeFakePrisma() { throw err; } - return { - tenderSavedSearch: { - create: async ({ data }: any) => { - if (findByUserAndName(data.userId, data.name)) throwUniqueViolation(); - counter += 1; - const record = { - id: `ss-${counter}`, - ...data, - createdAt: new Date(), - updatedAt: new Date(), - }; - rows.set(record.id, record); - return record; - }, - findMany: async ({ where }: any) => - Array.from(rows.values()).filter((r) => r.userId === where.userId), - findUnique: async ({ where }: any) => rows.get(where.id) ?? null, - update: async ({ where, data }: any) => { - const existing = rows.get(where.id); - const nextName = data.name ?? existing.name; - if (findByUserAndName(existing.userId, nextName, existing.id)) { - throwUniqueViolation(); - } - const record = { ...existing, ...data, updatedAt: new Date() }; - rows.set(where.id, record); - return record; - }, - delete: async ({ where }: any) => { - rows.delete(where.id); - }, + const tenderSavedSearch = { + create: async ({ data }: any) => { + if (findByUserAndName(data.userId, data.name)) throwUniqueViolation(); + counter += 1; + const record = { + id: `ss-${counter}`, + ...data, + createdAt: new Date(), + updatedAt: new Date(), + }; + rows.set(record.id, record); + return record; + }, + findMany: async ({ where }: any) => + Array.from(rows.values()).filter((r) => r.userId === where.userId), + findUnique: async ({ where }: any) => rows.get(where.id) ?? null, + update: async ({ where, data }: any) => { + const existing = rows.get(where.id); + const nextName = data.name ?? existing.name; + if (findByUserAndName(existing.userId, nextName, existing.id)) { + throwUniqueViolation(); + } + const record = { ...existing, ...data, updatedAt: new Date() }; + rows.set(where.id, record); + return record; + }, + delete: async ({ where }: any) => { + rows.delete(where.id); }, }; + + const fake: any = { + tenderSavedSearch, + __boundCallLog: boundCallLog, + __makeBoundClient(tenantId: string) { + const wrapped: any = {}; + for (const method of Object.keys(tenderSavedSearch)) { + wrapped[method] = async (...args: any[]) => { + boundCallLog.push({ tenantId, model: 'tenderSavedSearch', method }); + return (tenderSavedSearch as any)[method](...args); + }; + } + return { tenderSavedSearch: wrapped }; + }, + }; + + return fake; +} + +/** + * Bindungsnachweis: mindestens ein Aufruf von `` lief ueber den + * gebundenen Client, unter dem uebergebenen Mandanten. Ein vergessener + * `forTenant()`-Aufruf hinterlaesst hier KEINEN Eintrag. + */ +function expectBoundCall(prisma: any, tenantId: string, method: string) { + const found = prisma.__boundCallLog.some( + (c: any) => c.tenantId === tenantId && c.model === 'tenderSavedSearch' && c.method === method, + ); + expect( + found, + `erwarteter gebundener Aufruf tenderSavedSearch.${method}(tenant=${tenantId}) fehlt im Protokoll: ${JSON.stringify(prisma.__boundCallLog)}`, + ).toBe(true); } describe('TenderSavedSearchService', () => { @@ -116,8 +154,8 @@ describe('TenderSavedSearchService', () => { await service.create('u1', 'tenant1', { name: 'Bau NRW', filters: {} }); - expect(await service.list('u2')).toEqual([]); - expect(await service.list('u1')).toHaveLength(1); + expect(await service.list('u2', 'tenant1')).toEqual([]); + expect(await service.list('u1', 'tenant1')).toHaveLength(1); }); it('update() applies a rename + filters change when owned by the caller', async () => { @@ -125,7 +163,7 @@ describe('TenderSavedSearchService', () => { const service = new TenderSavedSearchService(prisma as any); const created = await service.create('u1', 'tenant1', { name: 'Alt', filters: { q: 'x' } }); - const updated = await service.update(created.id, 'u1', { + const updated = await service.update(created.id, 'u1', 'tenant1', { name: 'Neu', filters: { q: 'y' }, }); @@ -141,7 +179,7 @@ describe('TenderSavedSearchService', () => { const created = await service.create('u1', 'tenant1', { name: 'Alt', filters: {} }); await expect( - service.update(created.id, 'u2', { name: 'Uebernahme' }), + service.update(created.id, 'u2', 'tenant1', { name: 'Uebernahme' }), ).rejects.toBeInstanceOf(NotFoundException); }); @@ -150,7 +188,7 @@ describe('TenderSavedSearchService', () => { const service = new TenderSavedSearchService(prisma as any); await expect( - service.update('missing', 'u1', { name: 'x' }), + service.update('missing', 'u1', 'tenant1', { name: 'x' }), ).rejects.toBeInstanceOf(NotFoundException); }); @@ -162,7 +200,7 @@ describe('TenderSavedSearchService', () => { const second = await service.create('u1', 'tenant1', { name: 'Zweites Profil', filters: {} }); await expect( - service.update(second.id, 'u1', { name: 'Erstes Profil' }), + service.update(second.id, 'u1', 'tenant1', { name: 'Erstes Profil' }), ).rejects.toBeInstanceOf(ConflictException); }); @@ -172,7 +210,9 @@ describe('TenderSavedSearchService', () => { const created = await service.create('u1', 'tenant1', { name: 'Alt', filters: {} }); - await expect(service.remove(created.id, 'u2')).rejects.toBeInstanceOf(NotFoundException); + await expect(service.remove(created.id, 'u2', 'tenant1')).rejects.toBeInstanceOf( + NotFoundException, + ); }); it('remove() deletes the row when owned by the caller', async () => { @@ -180,9 +220,9 @@ describe('TenderSavedSearchService', () => { const service = new TenderSavedSearchService(prisma as any); const created = await service.create('u1', 'tenant1', { name: 'Alt', filters: {} }); - await service.remove(created.id, 'u1'); + await service.remove(created.id, 'u1', 'tenant1'); - expect(await service.list('u1')).toEqual([]); + expect(await service.list('u1', 'tenant1')).toEqual([]); }); // --- instantAlert passthrough (NOTIFY-02, D-04) --------------------------- @@ -221,8 +261,54 @@ describe('TenderSavedSearchService', () => { filters: {}, instantAlert: false, }); - const updated = await service.update(created.id, 'u1', { instantAlert: true }); + const updated = await service.update(created.id, 'u1', 'tenant1', { instantAlert: true }); expect(updated.instantAlert).toBe(true); }); + + // --- Bindung an forTenant() (260909-laa, Aufgabe 2) ----------------------- + + describe('Bindung an forTenant() (260909-laa)', () => { + it('list() bindet tenderSavedSearch.findMany an den uebergebenen Mandanten', async () => { + const prisma = makeFakePrisma(); + const service = new TenderSavedSearchService(prisma as any); + + await service.list('u1', 't1'); + + expectBoundCall(prisma, 't1', 'findMany'); + }); + + it('create() bindet tenderSavedSearch.create an den uebergebenen Mandanten', async () => { + const prisma = makeFakePrisma(); + const service = new TenderSavedSearchService(prisma as any); + + await service.create('u1', 't1', { name: 'A', filters: {} }); + + expectBoundCall(prisma, 't1', 'create'); + }); + + it('update() bindet die Lesepruefung UND den Schreibzugriff an den uebergebenen Mandanten', async () => { + const prisma = makeFakePrisma(); + const service = new TenderSavedSearchService(prisma as any); + const created = await service.create('u1', 't1', { name: 'A', filters: {} }); + prisma.__boundCallLog.length = 0; + + await service.update(created.id, 'u1', 't1', { name: 'B' }); + + expectBoundCall(prisma, 't1', 'findUnique'); + expectBoundCall(prisma, 't1', 'update'); + }); + + it('remove() bindet die Lesepruefung UND die Loeschung an den uebergebenen Mandanten', async () => { + const prisma = makeFakePrisma(); + const service = new TenderSavedSearchService(prisma as any); + const created = await service.create('u1', 't1', { name: 'A', filters: {} }); + prisma.__boundCallLog.length = 0; + + await service.remove(created.id, 'u1', 't1'); + + expectBoundCall(prisma, 't1', 'findUnique'); + expectBoundCall(prisma, 't1', 'delete'); + }); + }); }); diff --git a/apps/api/src/tenders/tender-saved-search.service.ts b/apps/api/src/tenders/tender-saved-search.service.ts index bff9a6f..8a2ff68 100644 --- a/apps/api/src/tenders/tender-saved-search.service.ts +++ b/apps/api/src/tenders/tender-saved-search.service.ts @@ -1,17 +1,28 @@ import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; +import { forTenant } from '../prisma/prisma-tenant.extension'; import { CreateSavedSearchDto, UpdateSavedSearchDto } from './dto/saved-search.dto'; /** * Service for managing per-user Tender saved searches (Suchprofile, * FILTER-06, D-08/D-11). * - * Access control (T-11-14 / V4 — IDOR): every query is scoped by userId, - * exactly the FavoritesService/TenderTriageService convention (T-08-06) — - * NOT forTenant()/RLS (Pitfall 4). userId must always be derived from the - * caller's auth context (controller), never accepted as a body/query - * parameter here. + * Access control (T-11-14 / V4 — IDOR): every query is ADDITIONALLY scoped + * by userId, exactly the FavoritesService/TenderTriageService convention + * (T-08-06). This is deliberate belt-and-suspenders, not a leftover: + * Aufgabe 1 (260909-laa) measured that the delivered + * `tenant_isolation_policy` on TenderSavedSearch has NO user dimension — + * two users of the SAME tenant are fully visible to each other at the + * database level (`tendersavedsearch-fremder-nutzer-desselben-mandanten-sichtbar`). + * The userId scoping below stays the ONLY protection against cross-user + * reads/writes within one tenant and must never be removed on the grounds + * that "the database handles it now" (Befund E, 260909-laa). + * + * Mandantengebunden (WINDOWS #20 Etappe 2, 260909-laa): every method binds + * via `forTenant()`, as in `groups`/`ldap` — a fresh bound client per + * method call, never shared across methods (same convention as + * `groups.service.ts`). * * @@unique([userId, name]) (T-11-14): a second profile with the same name * for the same user is rejected by Postgres (P2002) — this service @@ -26,8 +37,9 @@ export class TenderSavedSearchService { * Returns all saved searches for a user, ordered by name asc. Scoped * strictly by userId (V4/IDOR) — a foreign userId sees nothing. */ - async list(userId: string) { - return this.prisma.tenderSavedSearch.findMany({ + async list(userId: string, tenantId: string) { + const tenantPrisma = forTenant(this.prisma, tenantId) as any; + return tenantPrisma.tenderSavedSearch.findMany({ where: { userId }, orderBy: { name: 'asc' }, }); @@ -40,8 +52,9 @@ export class TenderSavedSearchService { * users, since the uniqueness is scoped per-user. */ async create(userId: string, tenantId: string, dto: CreateSavedSearchDto) { + const tenantPrisma = forTenant(this.prisma, tenantId) as any; try { - return await this.prisma.tenderSavedSearch.create({ + return await tenantPrisma.tenderSavedSearch.create({ data: { userId, tenantId, @@ -67,8 +80,9 @@ export class TenderSavedSearchService { * (FavoritesService pattern: never distinguishes the two, to avoid * leaking whether another user's profile exists). */ - async update(id: string, userId: string, dto: UpdateSavedSearchDto) { - const existing = await this.prisma.tenderSavedSearch.findUnique({ + async update(id: string, userId: string, tenantId: string, dto: UpdateSavedSearchDto) { + const tenantPrisma = forTenant(this.prisma, tenantId) as any; + const existing = await tenantPrisma.tenderSavedSearch.findUnique({ where: { id }, }); @@ -84,7 +98,7 @@ export class TenderSavedSearchService { if (dto.instantAlert !== undefined) data.instantAlert = dto.instantAlert; try { - return await this.prisma.tenderSavedSearch.update({ + return await tenantPrisma.tenderSavedSearch.update({ where: { id }, data, }); @@ -103,8 +117,9 @@ export class TenderSavedSearchService { * (T-11-14) — same missing-vs-foreign NotFoundException collapse as * update(). */ - async remove(id: string, userId: string) { - const existing = await this.prisma.tenderSavedSearch.findUnique({ + async remove(id: string, userId: string, tenantId: string) { + const tenantPrisma = forTenant(this.prisma, tenantId) as any; + const existing = await tenantPrisma.tenderSavedSearch.findUnique({ where: { id }, }); @@ -112,6 +127,6 @@ export class TenderSavedSearchService { throw new NotFoundException('Suchprofil nicht gefunden'); } - await this.prisma.tenderSavedSearch.delete({ where: { id } }); + await tenantPrisma.tenderSavedSearch.delete({ where: { id } }); } } diff --git a/apps/api/src/tenders/tender-triage.service.spec.ts b/apps/api/src/tenders/tender-triage.service.spec.ts index 3060d44..6d0b905 100644 --- a/apps/api/src/tenders/tender-triage.service.spec.ts +++ b/apps/api/src/tenders/tender-triage.service.spec.ts @@ -14,47 +14,79 @@ import { TenderTriageService } from './tender-triage.service'; * against the applied migration on the live dev DB), the service's read * path must not leak orphaned rows (Pitfall 6). * - * Uses the same hand-rolled prisma-shaped fake convention as - * tender-ingestion.service.spec.ts / tenders.controller.spec.ts (in-memory - * Map, no live DB connection). + * Bindung an forTenant() (260909-laa, Befund C/H): `__makeBoundClient()` + * wraps the SAME in-memory Map with a per-model, per-call logging layer — + * a pure identity mock (`(p) => p`, the ldap-era mistake) would leave a + * forgotten `forTenant()` call invisible to every test. Muster aus + * `groups.service.spec.ts` (260909-jts). */ +vi.mock('../prisma/prisma-tenant.extension', () => ({ + forTenant: vi.fn((prisma: any, tenantId: string) => prisma.__makeBoundClient(tenantId)), +})); + function makeFakePrisma() { const rows = new Map(); const key = (userId: string, tenderId: string) => `${userId}:${tenderId}`; + const boundCallLog: { tenantId: string; model: string; method: string }[] = []; - return { - tenderTriage: { - upsert: vi.fn(async ({ where, update, create }: any) => { - const k = key(where.userId_tenderId.userId, where.userId_tenderId.tenderId); - const existing = rows.get(k); - const record = existing - ? { ...existing, ...update, updatedAt: new Date() } - : { id: `tt-${rows.size + 1}`, ...create, createdAt: new Date(), updatedAt: new Date() }; - rows.set(k, record); - return record; - }), - findMany: vi.fn(async ({ where }: any) => { - return Array.from(rows.values()).filter((r) => { - if (where.userId !== undefined && r.userId !== where.userId) return false; - if (where.isFavorite !== undefined && r.isFavorite !== where.isFavorite) return false; - if (where.tenderId?.in && !where.tenderId.in.includes(r.tenderId)) return false; - return true; - }); - }), - // Simulates the schema-level `onDelete: Cascade` FK constraint - // (Pitfall 6). Real enforcement is verified separately by applying - // the 20260721160000_add_tender_triage migration and checking - // `SELECT * FROM "TenderTriage"` after a live delete — this fake - // proves the service's read path (listForUser/favoriteIds) is - // consistent with that cascade once rows are gone. - _simulateTenderCascadeDelete: (tenderId: string) => { - for (const [k, r] of rows) { - if (r.tenderId === tenderId) rows.delete(k); - } - }, + const tenderTriage = { + upsert: vi.fn(async ({ where, update, create }: any) => { + const k = key(where.userId_tenderId.userId, where.userId_tenderId.tenderId); + const existing = rows.get(k); + const record = existing + ? { ...existing, ...update, updatedAt: new Date() } + : { id: `tt-${rows.size + 1}`, ...create, createdAt: new Date(), updatedAt: new Date() }; + rows.set(k, record); + return record; + }), + findMany: vi.fn(async ({ where }: any) => { + return Array.from(rows.values()).filter((r) => { + if (where.userId !== undefined && r.userId !== where.userId) return false; + if (where.isFavorite !== undefined && r.isFavorite !== where.isFavorite) return false; + if (where.tenderId?.in && !where.tenderId.in.includes(r.tenderId)) return false; + return true; + }); + }), + // Simulates the schema-level `onDelete: Cascade` FK constraint + // (Pitfall 6). Real enforcement is verified separately by applying + // the 20260721160000_add_tender_triage migration and checking + // `SELECT * FROM "TenderTriage"` after a live delete — this fake + // proves the service's read path (listForUser/favoriteIds) is + // consistent with that cascade once rows are gone. + _simulateTenderCascadeDelete: (tenderId: string) => { + for (const [k, r] of rows) { + if (r.tenderId === tenderId) rows.delete(k); + } }, }; + + const fake: any = { + tenderTriage, + __boundCallLog: boundCallLog, + __makeBoundClient(tenantId: string) { + const wrapped: any = {}; + for (const method of ['upsert', 'findMany']) { + wrapped[method] = async (...args: any[]) => { + boundCallLog.push({ tenantId, model: 'tenderTriage', method }); + return (tenderTriage as any)[method](...args); + }; + } + return { tenderTriage: wrapped }; + }, + }; + + return fake; +} + +function expectBoundCall(prisma: any, tenantId: string, method: string) { + const found = prisma.__boundCallLog.some( + (c: any) => c.tenantId === tenantId && c.model === 'tenderTriage' && c.method === method, + ); + expect( + found, + `erwarteter gebundener Aufruf tenderTriage.${method}(tenant=${tenantId}) fehlt im Protokoll: ${JSON.stringify(prisma.__boundCallLog)}`, + ).toBe(true); } describe('TenderTriageService', () => { @@ -65,7 +97,7 @@ describe('TenderTriageService', () => { await service.setTriage('u1', 'tenant1', 't1', { isRead: true }); await service.setTriage('u1', 'tenant1', 't1', { isRead: true }); - const rows = await service.listForUser('u1', ['t1']); + const rows = await service.listForUser('u1', 'tenant1', ['t1']); expect(rows).toHaveLength(1); expect(rows[0].isRead).toBe(true); }); @@ -108,7 +140,7 @@ describe('TenderTriageService', () => { await service.setTriage('u1', 'tenant1', 't1', { isRead: true, isFavorite: true }); - const foreignRows = await service.listForUser('u2', ['t1']); + const foreignRows = await service.listForUser('u2', 'tenant1', ['t1']); expect(foreignRows).toHaveLength(0); }); @@ -116,7 +148,7 @@ describe('TenderTriageService', () => { const prisma = makeFakePrisma(); const service = new TenderTriageService(prisma as any); - const rows = await service.listForUser('u1', []); + const rows = await service.listForUser('u1', 'tenant1', []); expect(rows).toEqual([]); expect(prisma.tenderTriage.findMany).not.toHaveBeenCalled(); @@ -130,8 +162,8 @@ describe('TenderTriageService', () => { await service.setTriage('u1', 'tenant1', 't2', { isFavorite: false }); await service.setTriage('u2', 'tenant1', 't3', { isFavorite: true }); - expect(await service.favoriteIds('u1')).toEqual(['t1']); - expect(await service.favoriteIds('u2')).toEqual(['t3']); + expect(await service.favoriteIds('u1', 'tenant1')).toEqual(['t1']); + expect(await service.favoriteIds('u2', 'tenant1')).toEqual(['t3']); }); it('cascade: after a tender\'s triage rows are removed (DB onDelete: Cascade), it no longer appears for any user', async () => { @@ -141,7 +173,38 @@ describe('TenderTriageService', () => { await service.setTriage('u1', 'tenant1', 't1', { isFavorite: true }); prisma.tenderTriage._simulateTenderCascadeDelete('t1'); - expect(await service.listForUser('u1', ['t1'])).toHaveLength(0); - expect(await service.favoriteIds('u1')).toEqual([]); + expect(await service.listForUser('u1', 'tenant1', ['t1'])).toHaveLength(0); + expect(await service.favoriteIds('u1', 'tenant1')).toEqual([]); + }); + + // --- Bindung an forTenant() (260909-laa, Aufgabe 2) ----------------------- + + describe('Bindung an forTenant() (260909-laa)', () => { + it('setTriage() bindet tenderTriage.upsert an den uebergebenen Mandanten', async () => { + const prisma = makeFakePrisma(); + const service = new TenderTriageService(prisma as any); + + await service.setTriage('u1', 't1', 'tender-x', { isRead: true }); + + expectBoundCall(prisma, 't1', 'upsert'); + }); + + it('listForUser() bindet tenderTriage.findMany an den uebergebenen Mandanten', async () => { + const prisma = makeFakePrisma(); + const service = new TenderTriageService(prisma as any); + + await service.listForUser('u1', 't1', ['tender-x']); + + expectBoundCall(prisma, 't1', 'findMany'); + }); + + it('favoriteIds() bindet tenderTriage.findMany an den uebergebenen Mandanten', async () => { + const prisma = makeFakePrisma(); + const service = new TenderTriageService(prisma as any); + + await service.favoriteIds('u1', 't1'); + + expectBoundCall(prisma, 't1', 'findMany'); + }); }); }); diff --git a/apps/api/src/tenders/tender-triage.service.ts b/apps/api/src/tenders/tender-triage.service.ts index 6d34ee7..f47b3dc 100644 --- a/apps/api/src/tenders/tender-triage.service.ts +++ b/apps/api/src/tenders/tender-triage.service.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; +import { forTenant } from '../prisma/prisma-tenant.extension'; /** * Partial triage update accepted by setTriage(). Both fields are optional @@ -15,10 +16,14 @@ export interface SetTriageInput { * Service for managing per-user Tender triage state (gelesen/ungelesen, * Favorit — UI-03/04, D-09/D-10/D-11). * - * Access control (T-11-10 / V4 — IDOR): every query is scoped by userId, - * exactly the `FavoritesService` convention (T-08-06) — NOT `forTenant()`/ - * RLS (Pitfall 4). userId must always be derived from the caller's auth - * context (controller), never accepted as a body/query parameter here. + * Access control (T-11-10 / V4 — IDOR): every query is ADDITIONALLY scoped + * by userId, exactly the `FavoritesService` convention (T-08-06). This + * stays deliberate belt-and-suspenders after binding to `forTenant()` + * (260909-laa): the delivered `tenant_isolation_policy` on TenderTriage + * has no user dimension — a foreign user of the SAME tenant is not + * excluded by the database alone (Befund E, measured for the sibling + * TenderSavedSearch policy in Aufgabe 1; all five policies of this area + * share the identical `"tenantId" = current_tenant_id()` text). * * Cascade (Pitfall 6): the schema's `Tender @relation(..., onDelete: * Cascade)` removes a tender's triage rows automatically when Phase 10's @@ -53,7 +58,8 @@ export class TenderTriageService { update.favoritedAt = dto.isFavorite ? now : null; } - return this.prisma.tenderTriage.upsert({ + const tenantPrisma = forTenant(this.prisma, tenantId) as any; + return tenantPrisma.tenderTriage.upsert({ where: { userId_tenderId: { userId, tenderId } }, update, create: { @@ -74,11 +80,13 @@ export class TenderTriageService { * Scoped by userId (V4/IDOR) — a foreign userId never sees another * user's rows, even for the same tenderId. Returns [] without querying * prisma when tenderIds is empty (avoids an unbounded `in: []` no-op - * round-trip). + * round-trip) — deliberately BEFORE forTenant() is created, so an empty + * batch never even opens a bound client. */ - async listForUser(userId: string, tenderIds: string[]) { + async listForUser(userId: string, tenantId: string, tenderIds: string[]) { if (!tenderIds.length) return []; - return this.prisma.tenderTriage.findMany({ + const tenantPrisma = forTenant(this.prisma, tenantId) as any; + return tenantPrisma.tenderTriage.findMany({ where: { userId, tenderId: { in: tenderIds } }, }); } @@ -88,11 +96,12 @@ export class TenderTriageService { * Merklisten-Filter). Scoped by userId — feeds the favOnly branch of * tender-query.builder.ts's buildTenderWhere. */ - async favoriteIds(userId: string): Promise { - const rows = await this.prisma.tenderTriage.findMany({ + async favoriteIds(userId: string, tenantId: string): Promise { + const tenantPrisma = forTenant(this.prisma, tenantId) as any; + const rows = await tenantPrisma.tenderTriage.findMany({ where: { userId, isFavorite: true }, select: { tenderId: true }, }); - return rows.map((r) => r.tenderId); + return rows.map((r: { tenderId: string }) => r.tenderId); } } diff --git a/apps/api/src/tenders/tenders.controller.spec.ts b/apps/api/src/tenders/tenders.controller.spec.ts index 59d0db5..07569f2 100644 --- a/apps/api/src/tenders/tenders.controller.spec.ts +++ b/apps/api/src/tenders/tenders.controller.spec.ts @@ -76,8 +76,10 @@ function makeFakePrisma() { */ function makeFakeTriageService() { return { - favoriteIds: vi.fn(async (_userId: string) => [] as string[]), - listForUser: vi.fn(async (_userId: string, _tenderIds: string[]) => [] as any[]), + favoriteIds: vi.fn(async (_userId: string, _tenantId: string) => [] as string[]), + listForUser: vi.fn( + async (_userId: string, _tenantId: string, _tenderIds: string[]) => [] as any[], + ), setTriage: vi.fn(async (_userId: string, _tenantId: string, _tenderId: string, _dto: any) => ({})), }; } @@ -131,12 +133,14 @@ function makeFakeRssFeedService() { */ function makeFakeEmailConfigService() { return { - getConfigForApi: vi.fn(async (_userId: string) => null as any), + getConfigForApi: vi.fn(async (_userId: string, _tenantId: string) => null as any), saveConfig: vi.fn(async (_ctx: { userId: string; tenantId: string }, dto: any) => ({ id: 'ec-1', ...dto, })), - testConnection: vi.fn(async (_userId: string, _dto: any) => ({ success: true }) as any), + testConnection: vi.fn( + async (_userId: string, _tenantId: string, _dto: any) => ({ success: true }) as any, + ), }; } @@ -147,16 +151,16 @@ function makeFakeEmailConfigService() { */ function makeFakeSavedSearchService() { return { - list: vi.fn(async (_userId: string) => [] as any[]), + list: vi.fn(async (_userId: string, _tenantId: string) => [] as any[]), create: vi.fn(async (_userId: string, _tenantId: string, dto: any) => ({ id: 'ss-1', ...dto, })), - update: vi.fn(async (_id: string, _userId: string, dto: any) => ({ + update: vi.fn(async (_id: string, _userId: string, _tenantId: string, dto: any) => ({ id: 'ss-1', ...dto, })), - remove: vi.fn(async (_id: string, _userId: string) => undefined), + remove: vi.fn(async (_id: string, _userId: string, _tenantId: string) => undefined), }; } @@ -168,7 +172,7 @@ function makeFakeSavedSearchService() { */ function makeFakeNotificationPrefService() { return { - getForUser: vi.fn(async (_userId: string) => ({ digestInterval: 'daily' })), + getForUser: vi.fn(async (_userId: string, _tenantId: string) => ({ digestInterval: 'daily' })), setForUser: vi.fn(async (_userId: string, _tenantId: string, digestInterval: string) => ({ digestInterval, })), @@ -490,7 +494,7 @@ describe('TendersController — GET/PUT notification-pref (NOTIFY-01, per-user, await controller.getNotificationPref(makeFakeRequest('u-real')); - expect(prefService.getForUser).toHaveBeenCalledWith('u-real'); + expect(prefService.getForUser).toHaveBeenCalledWith('u-real', 'tenant1'); }); it('PUT /notification-pref delegates to tenderNotificationPref.setForUser with userId/tenantId from the auth context, not the body', async () => { @@ -535,7 +539,7 @@ describe('TendersController — saved-searches CRUD (FILTER-06, per-user, T-11-1 await controller.listSavedSearches(makeFakeRequest('u-real')); - expect(savedSearchService.list).toHaveBeenCalledWith('u-real'); + expect(savedSearchService.list).toHaveBeenCalledWith('u-real', 'tenant1'); }); it('POST /saved-searches delegates to tenderSavedSearch.create with userId/tenantId from the auth context, not the body', async () => { @@ -585,7 +589,7 @@ describe('TendersController — saved-searches CRUD (FILTER-06, per-user, T-11-1 makeFakeRequest('u1'), ); - expect(savedSearchService.update).toHaveBeenCalledWith('ss-1', 'u1', { + expect(savedSearchService.update).toHaveBeenCalledWith('ss-1', 'u1', 'tenant1', { name: 'Neuer Name', }); }); @@ -607,7 +611,7 @@ describe('TendersController — saved-searches CRUD (FILTER-06, per-user, T-11-1 const result = await controller.removeSavedSearch('ss-1', makeFakeRequest('u1')); - expect(savedSearchService.remove).toHaveBeenCalledWith('ss-1', 'u1'); + expect(savedSearchService.remove).toHaveBeenCalledWith('ss-1', 'u1', 'tenant1'); expect(result).toEqual({ success: true }); }); }); @@ -757,7 +761,7 @@ describe('TendersController — GET /triage (batch, per-user, T-11-10/11)', () = await controller.listTriage('t1, t2 ,t3', makeFakeRequest('u1')); - expect(triageService.listForUser).toHaveBeenCalledWith('u1', ['t1', 't2', 't3']); + expect(triageService.listForUser).toHaveBeenCalledWith('u1', 'tenant1', ['t1', 't2', 't3']); }); it('derives userId from req.user, never from the query string (V4 / IDOR)', async () => { @@ -776,7 +780,7 @@ describe('TendersController — GET /triage (batch, per-user, T-11-10/11)', () = await controller.listTriage('t1', makeFakeRequest('u-real')); - expect(triageService.listForUser).toHaveBeenCalledWith('u-real', ['t1']); + expect(triageService.listForUser).toHaveBeenCalledWith('u-real', 'tenant1', ['t1']); }); it('caps the ids batch at MAX_TRIAGE_BATCH_IDS (T-11-11 DoS)', async () => { @@ -796,7 +800,7 @@ describe('TendersController — GET /triage (batch, per-user, T-11-10/11)', () = const manyIds = Array.from({ length: 300 }, (_, i) => `t${i}`).join(','); await controller.listTriage(manyIds, makeFakeRequest('u1')); - const calledIds = triageService.listForUser.mock.calls[0][1]; + const calledIds = triageService.listForUser.mock.calls[0][2]; expect(calledIds.length).toBeLessThanOrEqual(200); }); }); @@ -846,7 +850,7 @@ describe('TendersController — listTenders favOnly wiring (UI-04, T-11-10/11)', await controller.listTenders({ favOnly: true } as any, makeFakeRequest('u1')); - expect(triageService.favoriteIds).toHaveBeenCalledWith('u1'); + expect(triageService.favoriteIds).toHaveBeenCalledWith('u1', 'tenant1'); const callArgs = prisma.tender.findMany.mock.calls[0][0]; expect(callArgs.where.AND).toEqual( expect.arrayContaining([{ id: { in: ['t1'] } }]), @@ -1081,7 +1085,7 @@ describe('TendersController — email-config (Plan 14-03, per-user since Phase 1 const result = await controller.getEmailConfig(makeFakeRequest('u1', 'tenant1')); - expect(emailConfigService.getConfigForApi).toHaveBeenCalledWith('u1'); + expect(emailConfigService.getConfigForApi).toHaveBeenCalledWith('u1', 'tenant1'); expect(result).toEqual({ userId: 'u1', tenantId: 'tenant1', @@ -1130,8 +1134,8 @@ describe('TendersController — email-config (Plan 14-03, per-user since Phase 1 await controller.getEmailConfig(makeFakeRequest('user-a', 'tenant1')); await controller.getEmailConfig(makeFakeRequest('user-b', 'tenant1')); - expect(emailConfigService.getConfigForApi).toHaveBeenNthCalledWith(1, 'user-a'); - expect(emailConfigService.getConfigForApi).toHaveBeenNthCalledWith(2, 'user-b'); + expect(emailConfigService.getConfigForApi).toHaveBeenNthCalledWith(1, 'user-a', 'tenant1'); + expect(emailConfigService.getConfigForApi).toHaveBeenNthCalledWith(2, 'user-b', 'tenant1'); }); it('POST /email-config/test resolves userId from the auth context, even when the body carries a different identity field (T-QT16-01, IDOR)', async () => { @@ -1155,7 +1159,7 @@ describe('TendersController — email-config (Plan 14-03, per-user since Phase 1 } as any; await controller.testEmailConnection(dto, makeFakeRequest('u1', 'tenant1')); - expect(emailConfigService.testConnection).toHaveBeenCalledWith('u1', dto); + expect(emailConfigService.testConnection).toHaveBeenCalledWith('u1', 'tenant1', dto); }); }); diff --git a/apps/api/src/tenders/tenders.controller.ts b/apps/api/src/tenders/tenders.controller.ts index 07a0190..6650cc5 100644 --- a/apps/api/src/tenders/tenders.controller.ts +++ b/apps/api/src/tenders/tenders.controller.ts @@ -175,8 +175,8 @@ export class TendersController { // optional type only accommodates unit tests that call this method // directly without favOnly set (T-11-10: extractTriageContext // throws ForbiddenException if req/user context is genuinely absent). - const { userId } = this.extractTriageContext(req as Request); - favIds = await this.tenderTriage.favoriteIds(userId); + const { userId, tenantId } = this.extractTriageContext(req as Request); + favIds = await this.tenderTriage.favoriteIds(userId, tenantId); } // D-13: resolve the requesting tenant for the private-tender visibility @@ -343,8 +343,8 @@ export class TendersController { @Get('email-config') @UseModule('tender-radar') async getEmailConfig(@Req() req: Request) { - const { userId } = this.extractTriageContext(req); - return this.tenderEmailConfig.getConfigForApi(userId); + const { userId, tenantId } = this.extractTriageContext(req); + return this.tenderEmailConfig.getConfigForApi(userId, tenantId); } /** @@ -382,8 +382,8 @@ export class TendersController { @Post('email-config/test') @UseModule('tender-radar') async testEmailConnection(@Body() dto: TenderEmailConfigDto, @Req() req: Request) { - const { userId } = this.extractTriageContext(req); - return this.tenderEmailConfig.testConnection(userId, dto); + const { userId, tenantId } = this.extractTriageContext(req); + return this.tenderEmailConfig.testConnection(userId, tenantId, dto); } /** @@ -457,14 +457,14 @@ export class TendersController { @Get('triage') @UseModule('tender-radar') async listTriage(@Query('ids') ids: string | undefined, @Req() req: Request) { - const { userId } = this.extractTriageContext(req); + const { userId, tenantId } = this.extractTriageContext(req); const tenderIds = (ids ?? '') .split(',') .map((id) => id.trim()) .filter(Boolean) .slice(0, MAX_TRIAGE_BATCH_IDS); - return this.tenderTriage.listForUser(userId, tenderIds); + return this.tenderTriage.listForUser(userId, tenantId, tenderIds); } /** @@ -503,8 +503,8 @@ export class TendersController { @Get('saved-searches') @UseModule('tender-radar') async listSavedSearches(@Req() req: Request) { - const { userId } = this.extractTriageContext(req); - return this.tenderSavedSearch.list(userId); + const { userId, tenantId } = this.extractTriageContext(req); + return this.tenderSavedSearch.list(userId, tenantId); } /** @@ -537,8 +537,8 @@ export class TendersController { @Body() dto: UpdateSavedSearchDto, @Req() req: Request, ) { - const { userId } = this.extractTriageContext(req); - return this.tenderSavedSearch.update(searchId, userId, dto); + const { userId, tenantId } = this.extractTriageContext(req); + return this.tenderSavedSearch.update(searchId, userId, tenantId, dto); } /** @@ -552,8 +552,8 @@ export class TendersController { @Param('searchId') searchId: string, @Req() req: Request, ) { - const { userId } = this.extractTriageContext(req); - await this.tenderSavedSearch.remove(searchId, userId); + const { userId, tenantId } = this.extractTriageContext(req); + await this.tenderSavedSearch.remove(searchId, userId, tenantId); return { success: true }; } @@ -572,8 +572,8 @@ export class TendersController { @Get('notification-pref') @UseModule('tender-radar') async getNotificationPref(@Req() req: Request) { - const { userId } = this.extractTriageContext(req); - return this.tenderNotificationPref.getForUser(userId); + const { userId, tenantId } = this.extractTriageContext(req); + return this.tenderNotificationPref.getForUser(userId, tenantId); } /** diff --git a/docs/mandantentrennung-zugriffsklassifikation.md b/docs/mandantentrennung-zugriffsklassifikation.md index 0128dc2..75635ad 100644 --- a/docs/mandantentrennung-zugriffsklassifikation.md +++ b/docs/mandantentrennung-zugriffsklassifikation.md @@ -223,7 +223,7 @@ verwendet), Klasse, Stand (`gebunden`/`ungebunden`/`gemischt`, seit | apps/api/src/tenders/tender-digest.scheduler.ts | tenderMatch | beides | ungebunden | Ein einziger globaler Cron-Job liest über ALLE Mandanten (bewusst übergreifend, Pitfall-1-Kommentar im Dateikopf), der Versand je Treffer ist an dessen Mandanten gebunden. | | apps/api/src/tenders/tender-digest.scheduler.ts | tenderNotificationPref | beides | ungebunden | Dieselbe Begründung — Präferenzen werden über alle Mandanten gelesen, aber je Zeile mandantenbezogen ausgewertet. | | apps/api/src/tenders/tender-digest.scheduler.ts | user | beides | ungebunden | E-Mail-Adressen für den Versand werden über alle Mandanten gelesen, der eigentliche Versand ist je Treffer mandantengebunden. | -| apps/api/src/tenders/tender-email-config.service.ts | tenderEmailConfig | muss-mandantengebunden | ungebunden | Nutzer-CRUD für die eigene Postfachanbindung (Phase 17, D-01) — anders als der Fan-out-Adapter oben, hier ist der Mandant aus der Anfrage bekannt. | +| apps/api/src/tenders/tender-email-config.service.ts | tenderEmailConfig | muss-mandantengebunden | gebunden | Nutzer-CRUD für die eigene Postfachanbindung (Phase 17, D-01) — anders als der Fan-out-Adapter oben, hier ist der Mandant aus der Anfrage bekannt. Seit 260909-laa (Aufgabe 2) laufen `getConfigForApi` (beide Lesezugriffe), `saveConfig` und `testConnection` vollständig über `forTenant()`. | | apps/api/src/tenders/tender-fingerprint-backfill.service.ts | tender | keine-mandantengebundene-tabelle | ungebunden | Einmaliges Backfill-Skript über den plattformweiten `Tender`-Katalog (D-03). | | apps/api/src/tenders/tender-ingestion.service.ts | tender | keine-mandantengebundene-tabelle | ungebunden | Explizit im Dateikopf: "Multi-tenant safety (D-03, T-10-09): uses the plain, non-tenant-scoped ... queries ... these are platform-global". | | apps/api/src/tenders/tender-ingestion.service.ts | tenderSourcePollConfig | keine-mandantengebundene-tabelle | ungebunden | Plattformweiter Poll-Status, kein `tenantId` (Migration 20260909140000, Gruppe b). | @@ -231,11 +231,11 @@ verwendet), Klasse, Stand (`gebunden`/`ungebunden`/`gemischt`, seit | apps/api/src/tenders/tender-matching.service.ts | tenderMatch | beides | ungebunden | Sofortmeldung: Treffer über alle betroffenen Mandanten gelesen, Versand je Treffer mandantengebunden — siehe Abschnitt "Der Hintergrunddienst als Falle". | | apps/api/src/tenders/tender-matching.service.ts | tenderSavedSearch | beides | ungebunden | Dieselbe Begründung — gespeicherte Suchprofile aller Mandanten werden gegen neue Treffer geprüft, der Versand ist je Profil mandantengebunden. | | apps/api/src/tenders/tender-matching.service.ts | user | beides | ungebunden | Dieselbe Begründung — E-Mail-Adressen für die Sofortmeldung. | -| apps/api/src/tenders/tender-notification-pref.service.ts | tenderNotificationPref | muss-mandantengebunden | ungebunden | Nutzer-CRUD für die eigenen Benachrichtigungseinstellungen — Mandant aus der Anfrage bekannt. | -| apps/api/src/tenders/tender-rss-feed.service.ts | tenderRssFeedSource | muss-mandantengebunden | ungebunden | Nutzer-CRUD für die eigenen RSS-Quellen (anders als der Fan-out in `adapters/rss.adapter.ts`) — Mandant aus der Anfrage bekannt. WINDOWS #19 betrifft die nullbaren plattformweiten Zeilen, nicht diesen CRUD-Pfad. | -| apps/api/src/tenders/tender-saved-search.service.ts | tenderSavedSearch | muss-mandantengebunden | ungebunden | Nutzer-CRUD für die eigenen gespeicherten Suchprofile — Mandant aus der Anfrage bekannt. | +| apps/api/src/tenders/tender-notification-pref.service.ts | tenderNotificationPref | muss-mandantengebunden | gebunden | Nutzer-CRUD für die eigenen Benachrichtigungseinstellungen — Mandant aus der Anfrage bekannt. Seit 260909-laa (Aufgabe 2) laufen `getForUser`/`setForUser` vollständig über `forTenant()`; `setForUser` übersetzt eine P2002-Verletzung (Befund F) in eine verständliche deutsche Meldung. | +| apps/api/src/tenders/tender-rss-feed.service.ts | tenderRssFeedSource | beides | gemischt | Nutzer-CRUD für die eigenen RSS-Quellen (anders als der Fan-out in `adapters/rss.adapter.ts`). Seit 260909-laa (Aufgabe 2) bindet `createForUser` (Zähler + Anlage, beide ausschließlich auf persönlichen Zeilen mit gesetztem Mandanten) über `forTenant()`; `listForUser`/`createPlatform`/`remove` bleiben bewusst ungebunden, weil sie (auch) die nullbare, plattformweite Zeile berühren (WINDOWS #19, Aufgabe 1 gemessen: eine gebundene Zeile wäre unter jedem Mandanten unsichtbar bzw. ein gebundenes Einfügen ohne Mandant würde abgewiesen). Korrektur der Klasse von `muss-mandantengebunden` auf `beides`, keine Verhaltensänderung — derselbe Präzedenzfall wie `ldapConfig` in 260909-ipc. | +| apps/api/src/tenders/tender-saved-search.service.ts | tenderSavedSearch | muss-mandantengebunden | gebunden | Nutzer-CRUD für die eigenen gespeicherten Suchprofile — Mandant aus der Anfrage bekannt. Seit 260909-laa (Aufgabe 2) laufen `list`/`create`/`update`/`remove` vollständig über `forTenant()`. | | apps/api/src/tenders/tender-scheduler.service.ts | tenderSourcePollConfig | keine-mandantengebundene-tabelle | ungebunden | Plattformweiter DÖE-Poll-Status, kein `tenantId` — im Dateikopf explizit als "genuine platform-wide singleton" begründet. | -| apps/api/src/tenders/tender-triage.service.ts | tenderTriage | muss-mandantengebunden | ungebunden | Favorisierungs-/Ablehnungsstatus eines Nutzers, `tenantId`-Spalte vorhanden. | +| apps/api/src/tenders/tender-triage.service.ts | tenderTriage | muss-mandantengebunden | gebunden | Favorisierungs-/Ablehnungsstatus eines Nutzers, `tenantId`-Spalte vorhanden. Seit 260909-laa (Aufgabe 2) laufen `setTriage`/`listForUser`/`favoriteIds` vollständig über `forTenant()`. | | apps/api/src/tenders/tenders.controller.ts | tender | keine-mandantengebundene-tabelle | ungebunden | Lesezugriff auf den plattformweiten Katalog (D-03). | | apps/api/src/tenders/tenders.controller.ts | tenderSourcePollConfig | keine-mandantengebundene-tabelle | ungebunden | Plattformweiter Poll-Status, admin-verwaltet, kein `tenantId`. | | apps/api/src/tenders/tenders.module.ts | tenderSourcePollConfig | keine-mandantengebundene-tabelle | ungebunden | Singleton-Bestückung beim Boot — im Dateikopf explizit als "global, RLS-exempt (D-03)" begründet. |