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:
@@ -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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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. |
|
||||||
|
|||||||
Reference in New Issue
Block a user