feat(laa-02): binde die fuenf Nutzer-CRUD-Dienste des Bereichs tenders an forTenant()

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
This commit is contained in:
2026-09-09 15:53:53 +02:00
parent 349814747e
commit 3336a6e419
13 changed files with 790 additions and 229 deletions
@@ -13,8 +13,17 @@ import { TenderEmailConfigService } from './tender-email-config.service';
* `where: { userId }` upsert target), and two new cases prove the actual * `where: { userId }` upsert target), and two new cases prove the actual
* new capability: two users of the SAME tenant get two independent rows, * new capability: two users of the SAME tenant get two independent rows,
* and tenantId is written on create (denormalized, D-01). * 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() { function makeFakeCrypto() {
return { return {
encrypt: vi.fn((plaintext: string) => `enc:${Buffer.from(plaintext).toString('base64')}`), encrypt: vi.fn((plaintext: string) => `enc:${Buffer.from(plaintext).toString('base64')}`),
@@ -27,8 +36,9 @@ function makeFakeCrypto() {
function makeFakePrisma() { function makeFakePrisma() {
const configs = new Map<string, any>(); const configs = new Map<string, any>();
return { const boundCallLog: { tenantId: string; model: string; method: string }[] = [];
tenderEmailConfig: {
const tenderEmailConfig = {
findUnique: vi.fn(async ({ where, select }: any) => { findUnique: vi.fn(async ({ where, select }: any) => {
const row = configs.get(where.userId); const row = configs.get(where.userId);
if (!row) return null; if (!row) return null;
@@ -46,9 +56,35 @@ function makeFakePrisma() {
for (const k of Object.keys(select)) out[k] = row[k]; for (const k of Object.keys(select)) out[k] = row[k];
return out; return out;
}), }),
},
__store: configs,
}; };
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', () => { describe('TenderEmailConfigService', () => {
@@ -57,7 +93,7 @@ describe('TenderEmailConfigService', () => {
const crypto = makeFakeCrypto(); const crypto = makeFakeCrypto();
const service = new TenderEmailConfigService(prisma as any, crypto as any); 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(); expect(result).toBeNull();
}); });
@@ -81,7 +117,7 @@ describe('TenderEmailConfigService', () => {
} as any, } 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.toBeNull();
expect(apiResult).not.toHaveProperty('password'); expect(apiResult).not.toHaveProperty('password');
@@ -107,7 +143,7 @@ describe('TenderEmailConfigService', () => {
} as any, } 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!.hasPassword).toBe(false);
expect(apiResult!.username).toBeNull(); expect(apiResult!.username).toBeNull();
@@ -223,8 +259,8 @@ describe('TenderEmailConfigService', () => {
{ protocol: 'imap', encryption: 'ssl-tls', host: 'imap.user-g2.test' } as any, { protocol: 'imap', encryption: 'ssl-tls', host: 'imap.user-g2.test' } as any,
); );
const configG1 = await service.getConfigForApi('user-g1'); const configG1 = await service.getConfigForApi('user-g1', 'tenant-shared');
const configG2 = await service.getConfigForApi('user-g2'); const configG2 = await service.getConfigForApi('user-g2', 'tenant-shared');
expect(configG1!.host).toBe('imap.user-g1.test'); expect(configG1!.host).toBe('imap.user-g1.test');
expect(configG2!.host).toBe('imap.user-g2.test'); expect(configG2!.host).toBe('imap.user-g2.test');
@@ -250,7 +286,7 @@ describe('TenderEmailConfigService', () => {
exchangeProvider as any, exchangeProvider as any,
); );
const result = await service.testConnection('user-h', { const result = await service.testConnection('user-h', 'tenant-h', {
protocol: 'imap', protocol: 'imap',
encryption: 'ssl-tls', encryption: 'ssl-tls',
host: 'imap.example.test', host: 'imap.example.test',
@@ -286,7 +322,7 @@ describe('TenderEmailConfigService', () => {
} as any, } as any,
); );
await service.testConnection('user-i', { await service.testConnection('user-i', 'tenant-i', {
protocol: 'imap', protocol: 'imap',
encryption: 'ssl-tls', encryption: 'ssl-tls',
} as any); } as any);
@@ -307,7 +343,7 @@ describe('TenderEmailConfigService', () => {
exchangeProvider as any, exchangeProvider as any,
); );
await service.testConnection('user-j', { await service.testConnection('user-j', 'tenant-j', {
protocol: 'exchange', protocol: 'exchange',
encryption: 'ssl-tls', encryption: 'ssl-tls',
username: 'ews-user', username: 'ews-user',
@@ -318,4 +354,73 @@ describe('TenderEmailConfigService', () => {
expect(imapProvider.testConnection).not.toHaveBeenCalled(); 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 })) },
};
}
});
}); });
@@ -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 { CryptoService } from '../crypto/crypto.service';
import { ExchangeInboxProvider } from '../inbox/exchange-inbox.provider'; import { ExchangeInboxProvider } from '../inbox/exchange-inbox.provider';
import { ImapProvider } from '../inbox/imap.provider'; import { ImapProvider } from '../inbox/imap.provider';
import type { InboxConfig, InboxProvider } from '../inbox/inbox-provider.interface'; import type { InboxConfig, InboxProvider } from '../inbox/inbox-provider.interface';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { forTenant } from '../prisma/prisma-tenant.extension';
import type { TenderEmailConfigDto } from './dto/tender-email-config.dto'; 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, * `tenantId` on TenderMatch) and is written on both create and update,
* since a user's tenant can in principle change. * 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: * Security:
* - T-07-12: encryptedInboxCreds is excluded from every read-path select; * - T-07-12: encryptedInboxCreds is excluded from every read-path select;
* getConfigForApi returns `hasPassword: boolean` instead of the password. * 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 * hasPassword flag. T-07-12: password is NEVER returned. Scoped strictly
* by userId (T-17-01) — a user only ever reads their own mailbox. * by userId (T-17-01) — a user only ever reads their own mailbox.
*/ */
async getConfigForApi(userId: string) { async getConfigForApi(userId: string, tenantId: string) {
const safe = await this.prisma.tenderEmailConfig.findUnique({ const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const safe = await tenantPrisma.tenderEmailConfig.findUnique({
where: { userId }, where: { userId },
select: EMAIL_CONFIG_SAFE_SELECT, select: EMAIL_CONFIG_SAFE_SELECT,
}); });
@@ -94,7 +106,7 @@ export class TenderEmailConfigService {
let username: string | null = null; let username: string | null = null;
let hasPassword = false; let hasPassword = false;
try { try {
const raw = await this.prisma.tenderEmailConfig.findUnique({ where: { userId } }); const raw = await tenantPrisma.tenderEmailConfig.findUnique({ where: { userId } });
if (raw?.encryptedInboxCreds) { if (raw?.encryptedInboxCreds) {
const creds = JSON.parse(this.crypto.decrypt(raw.encryptedInboxCreds)) as { const creds = JSON.parse(this.crypto.decrypt(raw.encryptedInboxCreds)) as {
username?: string; username?: string;
@@ -125,9 +137,16 @@ export class TenderEmailConfigService {
* *
* T-07-12: Returns safe select (no encryptedInboxCreds). * T-07-12: Returns safe select (no encryptedInboxCreds).
* T-05-13: Never logs decrypted credentials. * 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) { async saveConfig(ctx: { userId: string; tenantId: string }, dto: TenderEmailConfigDto) {
const { userId, tenantId } = ctx; const { userId, tenantId } = ctx;
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
let encryptedInboxCreds: string | undefined; let encryptedInboxCreds: string | undefined;
const credChanged = const credChanged =
@@ -140,7 +159,7 @@ export class TenderEmailConfigService {
if (!dto.password || !dto.username) { if (!dto.password || !dto.username) {
try { try {
const existing = await this.prisma.tenderEmailConfig.findUnique({ where: { userId } }); const existing = await tenantPrisma.tenderEmailConfig.findUnique({ where: { userId } });
if (existing?.encryptedInboxCreds) { if (existing?.encryptedInboxCreds) {
const stored = JSON.parse(this.crypto.decrypt(existing.encryptedInboxCreds)) as { const stored = JSON.parse(this.crypto.decrypt(existing.encryptedInboxCreds)) as {
username?: string; username?: string;
@@ -172,12 +191,21 @@ export class TenderEmailConfigService {
// tenantId is written on BOTH create and update — it is denormalized // tenantId is written on BOTH create and update — it is denormalized
// (Phase 17, D-01), so a user's resolved tenant must always overwrite // (Phase 17, D-01), so a user's resolved tenant must always overwrite
// whatever was stored previously, not just be set once on create. // whatever was stored previously, not just be set once on create.
return this.prisma.tenderEmailConfig.upsert({ try {
return await tenantPrisma.tenderEmailConfig.upsert({
where: { userId }, where: { userId },
create: { userId, tenantId, ...data }, create: { userId, tenantId, ...data },
update: { tenantId, ...data }, update: { tenantId, ...data },
select: EMAIL_CONFIG_SAFE_SELECT, 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( async testConnection(
userId: string, userId: string,
tenantId: string,
dto: TenderEmailConfigDto, dto: TenderEmailConfigDto,
): Promise<{ success: boolean; message?: string }> { ): Promise<{ success: boolean; message?: string }> {
let username: string | undefined = dto.username; let username: string | undefined = dto.username;
@@ -205,7 +234,8 @@ export class TenderEmailConfigService {
if (!username || !password) { if (!username || !password) {
try { 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) { if (existing?.encryptedInboxCreds) {
const stored = JSON.parse(this.crypto.decrypt(existing.encryptedInboxCreds)) as { const stored = JSON.parse(this.crypto.decrypt(existing.encryptedInboxCreds)) as {
username?: string; username?: string;
@@ -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'; import { TenderNotificationPrefService } from './tender-notification-pref.service';
/** /**
@@ -9,17 +10,32 @@ import { TenderNotificationPrefService } from './tender-notification-pref.servic
* { digestInterval: 'daily' } (D-01) — no error, no implicit autowrite. * { digestInterval: 'daily' } (D-01) — no error, no implicit autowrite.
* - setForUser() upserts on the @@unique userId (D-03); a second call with * - 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. * 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 * Bindung an forTenant() (260909-laa, Befund C/H) — Muster aus
* tender-saved-search.service.spec.ts / tender-triage.service.spec.ts * `groups.service.spec.ts` (260909-jts): `__makeBoundClient()` wraps the
* (in-memory Map, no live DB connection). * 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() { function makeFakePrisma() {
const rows = new Map<string, any>(); const rows = new Map<string, any>();
const boundCallLog: { tenantId: string; model: string; method: string }[] = [];
return { function throwUniqueViolation(): never {
tenderNotificationPref: { 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, findUnique: async ({ where }: any) => rows.get(where.userId) ?? null,
upsert: async ({ where, create, update }: any) => { upsert: async ({ where, create, update }: any) => {
const existing = rows.get(where.userId); const existing = rows.get(where.userId);
@@ -29,8 +45,37 @@ function makeFakePrisma() {
rows.set(where.userId, record); rows.set(where.userId, record);
return 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', () => { describe('TenderNotificationPrefService', () => {
@@ -38,7 +83,7 @@ describe('TenderNotificationPrefService', () => {
const prisma = makeFakePrisma(); const prisma = makeFakePrisma();
const service = new TenderNotificationPrefService(prisma as any); 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'); expect(result.digestInterval).toBe('daily');
}); });
@@ -62,7 +107,7 @@ describe('TenderNotificationPrefService', () => {
const second = await service.setForUser('u1', 'tenant1', 'off'); const second = await service.setForUser('u1', 'tenant1', 'off');
expect(second.digestInterval).toBe('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 () => { 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'); 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' 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');
});
});
}); });
@@ -1,19 +1,31 @@
import { Injectable } from '@nestjs/common'; import { ConflictException, Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { forTenant } from '../prisma/prisma-tenant.extension';
/** /**
* Service for managing the per-user Tender digest interval preference * Service for managing the per-user Tender digest interval preference
* (NOTIFY-01, D-01/D-03). * (NOTIFY-01, D-01/D-03).
* *
* Access control (T-12-14 / V4 — IDOR): scoped by userId exactly like * Access control (T-12-14 / V4 — IDOR): scoped by userId exactly like
* TenderSavedSearchService/TenderTriageService (T-11-14/T-08-06) — NOT * TenderSavedSearchService/TenderTriageService (T-11-14/T-08-06). This
* forTenant()/RLS (Pitfall 4). userId must always be derived from the * stays deliberate belt-and-suspenders after binding to `forTenant()`
* caller's auth context (controller), never accepted as a body/query * (260909-laa) — the delivered policy on TenderNotificationPref has no
* parameter here. * user dimension (Befund E, Aufgabe 1).
* *
* `TenderNotificationPref` has a per-user `@@unique` on `userId` (one row * `TenderNotificationPref` has a per-user `@@unique` on `userId` (one row
* per user, D-03: the interval is a user setting, not per-profile) — this * per user, D-03: the interval is a user setting, not per-profile) — this
* service upserts on that key. * 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() @Injectable()
export class TenderNotificationPrefService { export class TenderNotificationPrefService {
@@ -26,8 +38,9 @@ export class TenderNotificationPrefService {
* with the digest scheduler's own default-daily due-check semantics, no * with the digest scheduler's own default-daily due-check semantics, no
* autowrite needed to represent "using the default". * autowrite needed to represent "using the default".
*/ */
async getForUser(userId: string): Promise<{ digestInterval: string }> { async getForUser(userId: string, tenantId: string): Promise<{ digestInterval: string }> {
const existing = await this.prisma.tenderNotificationPref.findUnique({ const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const existing = await tenantPrisma.tenderNotificationPref.findUnique({
where: { userId }, where: { userId },
}); });
@@ -44,10 +57,20 @@ export class TenderNotificationPrefService {
* than creating a new one. * than creating a new one.
*/ */
async setForUser(userId: string, tenantId: string, digestInterval: string) { async setForUser(userId: string, tenantId: string, digestInterval: string) {
return this.prisma.tenderNotificationPref.upsert({ const tenantPrisma = forTenant(this.prisma, tenantId) as any;
try {
return await tenantPrisma.tenderNotificationPref.upsert({
where: { userId }, where: { userId },
create: { userId, tenantId, digestInterval }, create: { userId, tenantId, digestInterval },
update: { 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;
}
} }
} }
@@ -1,7 +1,23 @@
import { BadRequestException, NotFoundException } from '@nestjs/common'; 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'; 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 * TenderRssFeedSourceService.spec — proves the save-time hostname/SSRF
* guard (T-14-02-01, D-14/RESEARCH.md Pitfall 3): a runtime-user-supplied * 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() { function makeFakePrisma() {
const rows = new Map<string, any>(); const rows = new Map<string, any>();
let seq = 0; let seq = 0;
const boundCallLog: { tenantId: string; model: string; method: string }[] = [];
return { const tenderRssFeedSource = {
tenderRssFeedSource: {
findMany: async ({ where, orderBy }: any = {}) => { findMany: async ({ where, orderBy }: any = {}) => {
let all = [...rows.values()].filter((row) => matchesWhere(row, where)); let all = [...rows.values()].filter((row) => matchesWhere(row, where));
if (orderBy?.createdAt === 'asc') { if (orderBy?.createdAt === 'asc') {
@@ -79,9 +95,35 @@ function makeFakePrisma() {
for (const row of toDelete) rows.delete(row.id); for (const row of toDelete) rows.delete(row.id);
return { count: toDelete.length }; 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', () => { describe('TenderRssFeedSourceService', () => {
@@ -446,4 +488,78 @@ describe('TenderRssFeedSourceService', () => {
expect(prisma.__rows.size).toBe(0); 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');
});
});
}); });
@@ -1,5 +1,6 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { forTenant } from '../prisma/prisma-tenant.extension';
import type { TenderRssFeedDto } from './dto/tender-rss-feed.dto'; import type { TenderRssFeedDto } from './dto/tender-rss-feed.dto';
import { DENYLISTED_PORTALS } from './source-registry'; import { DENYLISTED_PORTALS } from './source-registry';
@@ -46,6 +47,14 @@ export class TenderRssFeedSourceService {
/** /**
* Every platform-wide feed (`userId = null`) plus this user's own * Every platform-wide feed (`userId = null`) plus this user's own
* personal feeds, oldest first (D-02). * 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) { async listForUser(userId: string) {
return this.prisma.tenderRssFeedSource.findMany({ return this.prisma.tenderRssFeedSource.findMany({
@@ -59,6 +68,11 @@ export class TenderRssFeedSourceService {
* clear German message once the caller already owns * clear German message once the caller already owns
* `MAX_PERSONAL_FEEDS_PER_USER` feeds (T-17-10) — platform-wide feeds are * `MAX_PERSONAL_FEEDS_PER_USER` feeds (T-17-10) — platform-wide feeds are
* never counted against this cap. * 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( async createForUser(
ctx: { userId: string; tenantId: string }, ctx: { userId: string; tenantId: string },
@@ -66,7 +80,8 @@ export class TenderRssFeedSourceService {
) { ) {
this.assertUrlAllowed(dto.url); 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 }, where: { userId: ctx.userId },
}); });
if (existingCount >= MAX_PERSONAL_FEEDS_PER_USER) { if (existingCount >= MAX_PERSONAL_FEEDS_PER_USER) {
@@ -75,7 +90,7 @@ export class TenderRssFeedSourceService {
); );
} }
return this.prisma.tenderRssFeedSource.create({ return tenantPrisma.tenderRssFeedSource.create({
data: { data: {
url: dto.url, url: dto.url,
label: dto.label, label: dto.label,
@@ -90,6 +105,11 @@ export class TenderRssFeedSourceService {
* Creates a platform-wide feed (`userId`/`tenantId` stay null). Callers * Creates a platform-wide feed (`userId`/`tenantId` stay null). Callers
* MUST verify ADMIN/SUPER_ADMIN before calling this — this method itself * MUST verify ADMIN/SUPER_ADMIN before calling this — this method itself
* enforces no authorization (T-17-08, done in TendersController). * 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) { async createPlatform(dto: TenderRssFeedDto) {
this.assertUrlAllowed(dto.url); this.assertUrlAllowed(dto.url);
@@ -114,6 +134,15 @@ export class TenderRssFeedSourceService {
* targeting a platform-wide feed), throws `NotFoundException` — never * targeting a platform-wide feed), throws `NotFoundException` — never
* `ForbiddenException` — so the response never confirms whether a * `ForbiddenException` — so the response never confirms whether a
* feed with that id exists at all. * 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 }) { async remove(id: string, ctx: { userId: string; isAdmin: boolean }) {
const { userId, isAdmin } = ctx; const { userId, isAdmin } = ctx;
@@ -1,5 +1,5 @@
import { ConflictException, NotFoundException } from '@nestjs/common'; 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'; 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 * (FavoritesService pattern — never distinguishes the two, to avoid
* leaking existence of another user's profile). * leaking existence of another user's profile).
* *
* Uses the same hand-rolled prisma-shaped fake convention as * Bindung an forTenant() (260909-laa, Befund C/H): anders als ein reiner
* tender-triage.service.spec.ts / tenders.controller.spec.ts (in-memory * Identitaets-Mock (`forTenant: vi.fn((p) => p)`, der ldap-Fehler, bei dem
* Map, no live DB connection). The fake simulates Prisma's P2002 unique- * kein Test in beiden Richtungen etwas merkt) liefert `__makeBoundClient()`
* constraint violation the same way a live Postgres unique index would. * 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() { function makeFakePrisma() {
const rows = new Map<string, any>(); const rows = new Map<string, any>();
let counter = 0; let counter = 0;
const boundCallLog: { tenantId: string; model: string; method: string }[] = [];
function findByUserAndName(userId: string, name: string, excludeId?: string) { function findByUserAndName(userId: string, name: string, excludeId?: string) {
return Array.from(rows.values()).find( return Array.from(rows.values()).find(
@@ -39,8 +47,7 @@ function makeFakePrisma() {
throw err; throw err;
} }
return { const tenderSavedSearch = {
tenderSavedSearch: {
create: async ({ data }: any) => { create: async ({ data }: any) => {
if (findByUserAndName(data.userId, data.name)) throwUniqueViolation(); if (findByUserAndName(data.userId, data.name)) throwUniqueViolation();
counter += 1; counter += 1;
@@ -69,8 +76,39 @@ function makeFakePrisma() {
delete: async ({ where }: any) => { delete: async ({ where }: any) => {
rows.delete(where.id); 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 `<method>` 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', () => { describe('TenderSavedSearchService', () => {
@@ -116,8 +154,8 @@ describe('TenderSavedSearchService', () => {
await service.create('u1', 'tenant1', { name: 'Bau NRW', filters: {} }); await service.create('u1', 'tenant1', { name: 'Bau NRW', filters: {} });
expect(await service.list('u2')).toEqual([]); expect(await service.list('u2', 'tenant1')).toEqual([]);
expect(await service.list('u1')).toHaveLength(1); expect(await service.list('u1', 'tenant1')).toHaveLength(1);
}); });
it('update() applies a rename + filters change when owned by the caller', async () => { 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 service = new TenderSavedSearchService(prisma as any);
const created = await service.create('u1', 'tenant1', { name: 'Alt', filters: { q: 'x' } }); 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', name: 'Neu',
filters: { q: 'y' }, filters: { q: 'y' },
}); });
@@ -141,7 +179,7 @@ describe('TenderSavedSearchService', () => {
const created = await service.create('u1', 'tenant1', { name: 'Alt', filters: {} }); const created = await service.create('u1', 'tenant1', { name: 'Alt', filters: {} });
await expect( await expect(
service.update(created.id, 'u2', { name: 'Uebernahme' }), service.update(created.id, 'u2', 'tenant1', { name: 'Uebernahme' }),
).rejects.toBeInstanceOf(NotFoundException); ).rejects.toBeInstanceOf(NotFoundException);
}); });
@@ -150,7 +188,7 @@ describe('TenderSavedSearchService', () => {
const service = new TenderSavedSearchService(prisma as any); const service = new TenderSavedSearchService(prisma as any);
await expect( await expect(
service.update('missing', 'u1', { name: 'x' }), service.update('missing', 'u1', 'tenant1', { name: 'x' }),
).rejects.toBeInstanceOf(NotFoundException); ).rejects.toBeInstanceOf(NotFoundException);
}); });
@@ -162,7 +200,7 @@ describe('TenderSavedSearchService', () => {
const second = await service.create('u1', 'tenant1', { name: 'Zweites Profil', filters: {} }); const second = await service.create('u1', 'tenant1', { name: 'Zweites Profil', filters: {} });
await expect( await expect(
service.update(second.id, 'u1', { name: 'Erstes Profil' }), service.update(second.id, 'u1', 'tenant1', { name: 'Erstes Profil' }),
).rejects.toBeInstanceOf(ConflictException); ).rejects.toBeInstanceOf(ConflictException);
}); });
@@ -172,7 +210,9 @@ describe('TenderSavedSearchService', () => {
const created = await service.create('u1', 'tenant1', { name: 'Alt', filters: {} }); 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 () => { 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 service = new TenderSavedSearchService(prisma as any);
const created = await service.create('u1', 'tenant1', { name: 'Alt', filters: {} }); 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) --------------------------- // --- instantAlert passthrough (NOTIFY-02, D-04) ---------------------------
@@ -221,8 +261,54 @@ describe('TenderSavedSearchService', () => {
filters: {}, filters: {},
instantAlert: false, 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); 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');
});
});
}); });
@@ -1,17 +1,28 @@
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'; import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { forTenant } from '../prisma/prisma-tenant.extension';
import { CreateSavedSearchDto, UpdateSavedSearchDto } from './dto/saved-search.dto'; import { CreateSavedSearchDto, UpdateSavedSearchDto } from './dto/saved-search.dto';
/** /**
* Service for managing per-user Tender saved searches (Suchprofile, * Service for managing per-user Tender saved searches (Suchprofile,
* FILTER-06, D-08/D-11). * FILTER-06, D-08/D-11).
* *
* Access control (T-11-14 / V4 — IDOR): every query is scoped by userId, * Access control (T-11-14 / V4 — IDOR): every query is ADDITIONALLY scoped
* exactly the FavoritesService/TenderTriageService convention (T-08-06) — * by userId, exactly the FavoritesService/TenderTriageService convention
* NOT forTenant()/RLS (Pitfall 4). userId must always be derived from the * (T-08-06). This is deliberate belt-and-suspenders, not a leftover:
* caller's auth context (controller), never accepted as a body/query * Aufgabe 1 (260909-laa) measured that the delivered
* parameter here. * `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 * @@unique([userId, name]) (T-11-14): a second profile with the same name
* for the same user is rejected by Postgres (P2002) — this service * 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 * Returns all saved searches for a user, ordered by name asc. Scoped
* strictly by userId (V4/IDOR) — a foreign userId sees nothing. * strictly by userId (V4/IDOR) — a foreign userId sees nothing.
*/ */
async list(userId: string) { async list(userId: string, tenantId: string) {
return this.prisma.tenderSavedSearch.findMany({ const tenantPrisma = forTenant(this.prisma, tenantId) as any;
return tenantPrisma.tenderSavedSearch.findMany({
where: { userId }, where: { userId },
orderBy: { name: 'asc' }, orderBy: { name: 'asc' },
}); });
@@ -40,8 +52,9 @@ export class TenderSavedSearchService {
* users, since the uniqueness is scoped per-user. * users, since the uniqueness is scoped per-user.
*/ */
async create(userId: string, tenantId: string, dto: CreateSavedSearchDto) { async create(userId: string, tenantId: string, dto: CreateSavedSearchDto) {
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
try { try {
return await this.prisma.tenderSavedSearch.create({ return await tenantPrisma.tenderSavedSearch.create({
data: { data: {
userId, userId,
tenantId, tenantId,
@@ -67,8 +80,9 @@ export class TenderSavedSearchService {
* (FavoritesService pattern: never distinguishes the two, to avoid * (FavoritesService pattern: never distinguishes the two, to avoid
* leaking whether another user's profile exists). * leaking whether another user's profile exists).
*/ */
async update(id: string, userId: string, dto: UpdateSavedSearchDto) { async update(id: string, userId: string, tenantId: string, dto: UpdateSavedSearchDto) {
const existing = await this.prisma.tenderSavedSearch.findUnique({ const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const existing = await tenantPrisma.tenderSavedSearch.findUnique({
where: { id }, where: { id },
}); });
@@ -84,7 +98,7 @@ export class TenderSavedSearchService {
if (dto.instantAlert !== undefined) data.instantAlert = dto.instantAlert; if (dto.instantAlert !== undefined) data.instantAlert = dto.instantAlert;
try { try {
return await this.prisma.tenderSavedSearch.update({ return await tenantPrisma.tenderSavedSearch.update({
where: { id }, where: { id },
data, data,
}); });
@@ -103,8 +117,9 @@ export class TenderSavedSearchService {
* (T-11-14) — same missing-vs-foreign NotFoundException collapse as * (T-11-14) — same missing-vs-foreign NotFoundException collapse as
* update(). * update().
*/ */
async remove(id: string, userId: string) { async remove(id: string, userId: string, tenantId: string) {
const existing = await this.prisma.tenderSavedSearch.findUnique({ const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const existing = await tenantPrisma.tenderSavedSearch.findUnique({
where: { id }, where: { id },
}); });
@@ -112,6 +127,6 @@ export class TenderSavedSearchService {
throw new NotFoundException('Suchprofil nicht gefunden'); throw new NotFoundException('Suchprofil nicht gefunden');
} }
await this.prisma.tenderSavedSearch.delete({ where: { id } }); await tenantPrisma.tenderSavedSearch.delete({ where: { id } });
} }
} }
@@ -14,17 +14,23 @@ import { TenderTriageService } from './tender-triage.service';
* against the applied migration on the live dev DB), the service's read * against the applied migration on the live dev DB), the service's read
* path must not leak orphaned rows (Pitfall 6). * path must not leak orphaned rows (Pitfall 6).
* *
* Uses the same hand-rolled prisma-shaped fake convention as * Bindung an forTenant() (260909-laa, Befund C/H): `__makeBoundClient()`
* tender-ingestion.service.spec.ts / tenders.controller.spec.ts (in-memory * wraps the SAME in-memory Map with a per-model, per-call logging layer —
* Map, no live DB connection). * 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() { function makeFakePrisma() {
const rows = new Map<string, any>(); const rows = new Map<string, any>();
const key = (userId: string, tenderId: string) => `${userId}:${tenderId}`; const key = (userId: string, tenderId: string) => `${userId}:${tenderId}`;
const boundCallLog: { tenantId: string; model: string; method: string }[] = [];
return { const tenderTriage = {
tenderTriage: {
upsert: vi.fn(async ({ where, update, create }: any) => { upsert: vi.fn(async ({ where, update, create }: any) => {
const k = key(where.userId_tenderId.userId, where.userId_tenderId.tenderId); const k = key(where.userId_tenderId.userId, where.userId_tenderId.tenderId);
const existing = rows.get(k); const existing = rows.get(k);
@@ -53,8 +59,34 @@ function makeFakePrisma() {
if (r.tenderId === tenderId) rows.delete(k); 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', () => { describe('TenderTriageService', () => {
@@ -65,7 +97,7 @@ describe('TenderTriageService', () => {
await service.setTriage('u1', 'tenant1', 't1', { isRead: true }); await service.setTriage('u1', 'tenant1', 't1', { isRead: true });
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).toHaveLength(1);
expect(rows[0].isRead).toBe(true); expect(rows[0].isRead).toBe(true);
}); });
@@ -108,7 +140,7 @@ describe('TenderTriageService', () => {
await service.setTriage('u1', 'tenant1', 't1', { isRead: true, isFavorite: true }); 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); expect(foreignRows).toHaveLength(0);
}); });
@@ -116,7 +148,7 @@ describe('TenderTriageService', () => {
const prisma = makeFakePrisma(); const prisma = makeFakePrisma();
const service = new TenderTriageService(prisma as any); const service = new TenderTriageService(prisma as any);
const rows = await service.listForUser('u1', []); const rows = await service.listForUser('u1', 'tenant1', []);
expect(rows).toEqual([]); expect(rows).toEqual([]);
expect(prisma.tenderTriage.findMany).not.toHaveBeenCalled(); expect(prisma.tenderTriage.findMany).not.toHaveBeenCalled();
@@ -130,8 +162,8 @@ describe('TenderTriageService', () => {
await service.setTriage('u1', 'tenant1', 't2', { isFavorite: false }); await service.setTriage('u1', 'tenant1', 't2', { isFavorite: false });
await service.setTriage('u2', 'tenant1', 't3', { isFavorite: true }); await service.setTriage('u2', 'tenant1', 't3', { isFavorite: true });
expect(await service.favoriteIds('u1')).toEqual(['t1']); expect(await service.favoriteIds('u1', 'tenant1')).toEqual(['t1']);
expect(await service.favoriteIds('u2')).toEqual(['t3']); 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 () => { 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 }); await service.setTriage('u1', 'tenant1', 't1', { isFavorite: true });
prisma.tenderTriage._simulateTenderCascadeDelete('t1'); prisma.tenderTriage._simulateTenderCascadeDelete('t1');
expect(await service.listForUser('u1', ['t1'])).toHaveLength(0); expect(await service.listForUser('u1', 'tenant1', ['t1'])).toHaveLength(0);
expect(await service.favoriteIds('u1')).toEqual([]); 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');
});
}); });
}); });
+20 -11
View File
@@ -1,5 +1,6 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { forTenant } from '../prisma/prisma-tenant.extension';
/** /**
* Partial triage update accepted by setTriage(). Both fields are optional * 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, * Service for managing per-user Tender triage state (gelesen/ungelesen,
* Favorit — UI-03/04, D-09/D-10/D-11). * Favorit — UI-03/04, D-09/D-10/D-11).
* *
* Access control (T-11-10 / V4 — IDOR): every query is scoped by userId, * Access control (T-11-10 / V4 — IDOR): every query is ADDITIONALLY scoped
* exactly the `FavoritesService` convention (T-08-06) — NOT `forTenant()`/ * by userId, exactly the `FavoritesService` convention (T-08-06). This
* RLS (Pitfall 4). userId must always be derived from the caller's auth * stays deliberate belt-and-suspenders after binding to `forTenant()`
* context (controller), never accepted as a body/query parameter here. * (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 (Pitfall 6): the schema's `Tender @relation(..., onDelete:
* Cascade)` removes a tender's triage rows automatically when Phase 10's * 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; 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 } }, where: { userId_tenderId: { userId, tenderId } },
update, update,
create: { create: {
@@ -74,11 +80,13 @@ export class TenderTriageService {
* Scoped by userId (V4/IDOR) — a foreign userId never sees another * Scoped by userId (V4/IDOR) — a foreign userId never sees another
* user's rows, even for the same tenderId. Returns [] without querying * user's rows, even for the same tenderId. Returns [] without querying
* prisma when tenderIds is empty (avoids an unbounded `in: []` no-op * 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 []; 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 } }, where: { userId, tenderId: { in: tenderIds } },
}); });
} }
@@ -88,11 +96,12 @@ export class TenderTriageService {
* Merklisten-Filter). Scoped by userId — feeds the favOnly branch of * Merklisten-Filter). Scoped by userId — feeds the favOnly branch of
* tender-query.builder.ts's buildTenderWhere. * tender-query.builder.ts's buildTenderWhere.
*/ */
async favoriteIds(userId: string): Promise<string[]> { async favoriteIds(userId: string, tenantId: string): Promise<string[]> {
const rows = await this.prisma.tenderTriage.findMany({ const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const rows = await tenantPrisma.tenderTriage.findMany({
where: { userId, isFavorite: true }, where: { userId, isFavorite: true },
select: { tenderId: true }, select: { tenderId: true },
}); });
return rows.map((r) => r.tenderId); return rows.map((r: { tenderId: string }) => r.tenderId);
} }
} }
+24 -20
View File
@@ -76,8 +76,10 @@ function makeFakePrisma() {
*/ */
function makeFakeTriageService() { function makeFakeTriageService() {
return { return {
favoriteIds: vi.fn(async (_userId: string) => [] as string[]), favoriteIds: vi.fn(async (_userId: string, _tenantId: string) => [] as string[]),
listForUser: vi.fn(async (_userId: string, _tenderIds: string[]) => [] as any[]), listForUser: vi.fn(
async (_userId: string, _tenantId: string, _tenderIds: string[]) => [] as any[],
),
setTriage: vi.fn(async (_userId: string, _tenantId: string, _tenderId: string, _dto: any) => ({})), setTriage: vi.fn(async (_userId: string, _tenantId: string, _tenderId: string, _dto: any) => ({})),
}; };
} }
@@ -131,12 +133,14 @@ function makeFakeRssFeedService() {
*/ */
function makeFakeEmailConfigService() { function makeFakeEmailConfigService() {
return { 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) => ({ saveConfig: vi.fn(async (_ctx: { userId: string; tenantId: string }, dto: any) => ({
id: 'ec-1', id: 'ec-1',
...dto, ...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() { function makeFakeSavedSearchService() {
return { 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) => ({ create: vi.fn(async (_userId: string, _tenantId: string, dto: any) => ({
id: 'ss-1', id: 'ss-1',
...dto, ...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', id: 'ss-1',
...dto, ...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() { function makeFakeNotificationPrefService() {
return { 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) => ({ setForUser: vi.fn(async (_userId: string, _tenantId: string, digestInterval: string) => ({
digestInterval, digestInterval,
})), })),
@@ -490,7 +494,7 @@ describe('TendersController — GET/PUT notification-pref (NOTIFY-01, per-user,
await controller.getNotificationPref(makeFakeRequest('u-real')); 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 () => { 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')); 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 () => { 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'), makeFakeRequest('u1'),
); );
expect(savedSearchService.update).toHaveBeenCalledWith('ss-1', 'u1', { expect(savedSearchService.update).toHaveBeenCalledWith('ss-1', 'u1', 'tenant1', {
name: 'Neuer Name', 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')); 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 }); 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')); 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 () => { 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')); 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 () => { 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(','); const manyIds = Array.from({ length: 300 }, (_, i) => `t${i}`).join(',');
await controller.listTriage(manyIds, makeFakeRequest('u1')); 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); 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')); 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]; const callArgs = prisma.tender.findMany.mock.calls[0][0];
expect(callArgs.where.AND).toEqual( expect(callArgs.where.AND).toEqual(
expect.arrayContaining([{ id: { in: ['t1'] } }]), 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')); const result = await controller.getEmailConfig(makeFakeRequest('u1', 'tenant1'));
expect(emailConfigService.getConfigForApi).toHaveBeenCalledWith('u1'); expect(emailConfigService.getConfigForApi).toHaveBeenCalledWith('u1', 'tenant1');
expect(result).toEqual({ expect(result).toEqual({
userId: 'u1', userId: 'u1',
tenantId: 'tenant1', 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-a', 'tenant1'));
await controller.getEmailConfig(makeFakeRequest('user-b', 'tenant1')); await controller.getEmailConfig(makeFakeRequest('user-b', 'tenant1'));
expect(emailConfigService.getConfigForApi).toHaveBeenNthCalledWith(1, 'user-a'); expect(emailConfigService.getConfigForApi).toHaveBeenNthCalledWith(1, 'user-a', 'tenant1');
expect(emailConfigService.getConfigForApi).toHaveBeenNthCalledWith(2, 'user-b'); 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 () => { 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; } as any;
await controller.testEmailConnection(dto, makeFakeRequest('u1', 'tenant1')); await controller.testEmailConnection(dto, makeFakeRequest('u1', 'tenant1'));
expect(emailConfigService.testConnection).toHaveBeenCalledWith('u1', dto); expect(emailConfigService.testConnection).toHaveBeenCalledWith('u1', 'tenant1', dto);
}); });
}); });
+16 -16
View File
@@ -175,8 +175,8 @@ export class TendersController {
// optional type only accommodates unit tests that call this method // optional type only accommodates unit tests that call this method
// directly without favOnly set (T-11-10: extractTriageContext // directly without favOnly set (T-11-10: extractTriageContext
// throws ForbiddenException if req/user context is genuinely absent). // throws ForbiddenException if req/user context is genuinely absent).
const { userId } = this.extractTriageContext(req as Request); const { userId, tenantId } = this.extractTriageContext(req as Request);
favIds = await this.tenderTriage.favoriteIds(userId); favIds = await this.tenderTriage.favoriteIds(userId, tenantId);
} }
// D-13: resolve the requesting tenant for the private-tender visibility // D-13: resolve the requesting tenant for the private-tender visibility
@@ -343,8 +343,8 @@ export class TendersController {
@Get('email-config') @Get('email-config')
@UseModule('tender-radar') @UseModule('tender-radar')
async getEmailConfig(@Req() req: Request) { async getEmailConfig(@Req() req: Request) {
const { userId } = this.extractTriageContext(req); const { userId, tenantId } = this.extractTriageContext(req);
return this.tenderEmailConfig.getConfigForApi(userId); return this.tenderEmailConfig.getConfigForApi(userId, tenantId);
} }
/** /**
@@ -382,8 +382,8 @@ export class TendersController {
@Post('email-config/test') @Post('email-config/test')
@UseModule('tender-radar') @UseModule('tender-radar')
async testEmailConnection(@Body() dto: TenderEmailConfigDto, @Req() req: Request) { async testEmailConnection(@Body() dto: TenderEmailConfigDto, @Req() req: Request) {
const { userId } = this.extractTriageContext(req); const { userId, tenantId } = this.extractTriageContext(req);
return this.tenderEmailConfig.testConnection(userId, dto); return this.tenderEmailConfig.testConnection(userId, tenantId, dto);
} }
/** /**
@@ -457,14 +457,14 @@ export class TendersController {
@Get('triage') @Get('triage')
@UseModule('tender-radar') @UseModule('tender-radar')
async listTriage(@Query('ids') ids: string | undefined, @Req() req: Request) { async listTriage(@Query('ids') ids: string | undefined, @Req() req: Request) {
const { userId } = this.extractTriageContext(req); const { userId, tenantId } = this.extractTriageContext(req);
const tenderIds = (ids ?? '') const tenderIds = (ids ?? '')
.split(',') .split(',')
.map((id) => id.trim()) .map((id) => id.trim())
.filter(Boolean) .filter(Boolean)
.slice(0, MAX_TRIAGE_BATCH_IDS); .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') @Get('saved-searches')
@UseModule('tender-radar') @UseModule('tender-radar')
async listSavedSearches(@Req() req: Request) { async listSavedSearches(@Req() req: Request) {
const { userId } = this.extractTriageContext(req); const { userId, tenantId } = this.extractTriageContext(req);
return this.tenderSavedSearch.list(userId); return this.tenderSavedSearch.list(userId, tenantId);
} }
/** /**
@@ -537,8 +537,8 @@ export class TendersController {
@Body() dto: UpdateSavedSearchDto, @Body() dto: UpdateSavedSearchDto,
@Req() req: Request, @Req() req: Request,
) { ) {
const { userId } = this.extractTriageContext(req); const { userId, tenantId } = this.extractTriageContext(req);
return this.tenderSavedSearch.update(searchId, userId, dto); return this.tenderSavedSearch.update(searchId, userId, tenantId, dto);
} }
/** /**
@@ -552,8 +552,8 @@ export class TendersController {
@Param('searchId') searchId: string, @Param('searchId') searchId: string,
@Req() req: Request, @Req() req: Request,
) { ) {
const { userId } = this.extractTriageContext(req); const { userId, tenantId } = this.extractTriageContext(req);
await this.tenderSavedSearch.remove(searchId, userId); await this.tenderSavedSearch.remove(searchId, userId, tenantId);
return { success: true }; return { success: true };
} }
@@ -572,8 +572,8 @@ export class TendersController {
@Get('notification-pref') @Get('notification-pref')
@UseModule('tender-radar') @UseModule('tender-radar')
async getNotificationPref(@Req() req: Request) { async getNotificationPref(@Req() req: Request) {
const { userId } = this.extractTriageContext(req); const { userId, tenantId } = this.extractTriageContext(req);
return this.tenderNotificationPref.getForUser(userId); return this.tenderNotificationPref.getForUser(userId, tenantId);
} }
/** /**
@@ -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 | 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 | 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-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-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 | 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). | | 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 | 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 | 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-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-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 | 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-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 | ungebunden | Nutzer-CRUD für die eigenen gespeicherten Suchprofile — Mandant aus der Anfrage bekannt. | | 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-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 | 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.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. | | 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. |