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
@@ -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<string, any>();
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');
});
});
});