diff --git a/apps/api/src/tenders/adapters/email-alert.adapter.spec.ts b/apps/api/src/tenders/adapters/email-alert.adapter.spec.ts index 67f076e..d00ca51 100644 --- a/apps/api/src/tenders/adapters/email-alert.adapter.spec.ts +++ b/apps/api/src/tenders/adapters/email-alert.adapter.spec.ts @@ -1,5 +1,6 @@ import { readFileSync } from 'fs'; import { join } from 'path'; +import { Logger } from '@nestjs/common'; import { describe, expect, it, vi } from 'vitest'; import { EmailAlertAdapter, @@ -196,6 +197,8 @@ describe('EmailAlertAdapter', () => { const { adapter } = makeAdapter({ configs: [ { + id: 'cfg-a', + userId: 'user-a', tenantId: 'tenant-a', protocol: 'imap', host: 'imap.example.test', @@ -225,6 +228,8 @@ describe('EmailAlertAdapter', () => { const { adapter } = makeAdapter({ configs: [ { + id: 'cfg-ews', + userId: 'user-ews', tenantId: 'tenant-ews', protocol: 'exchange', host: 'https://mail.example.test/EWS/Exchange.asmx', @@ -258,6 +263,8 @@ describe('EmailAlertAdapter', () => { tenderEmailConfig: { findMany: vi.fn(async () => [ { + id: 'cfg-a', + userId: 'user-a', tenantId: 'tenant-a', protocol: 'imap', host: 'imap.example.test', @@ -285,7 +292,7 @@ describe('EmailAlertAdapter', () => { ); }); - it('catch-per-tenant: one mocked mailbox throws, others still return records tagged with their own ownerTenantId', async () => { + it('catch-per-mailbox: one of two mailboxes throws, the other still returns its records, and a warning is logged naming only the row id + owner (T-17-03)', async () => { const throwingFetch = vi.fn(async () => { throw new Error('connection refused'); }); @@ -295,6 +302,8 @@ describe('EmailAlertAdapter', () => { tenderEmailConfig: { findMany: vi.fn(async () => [ { + id: 'cfg-broken', + userId: 'user-broken', tenantId: 'tenant-broken', protocol: 'imap', host: 'imap.broken.test', @@ -306,6 +315,8 @@ describe('EmailAlertAdapter', () => { encryptedInboxCreds: null, }, { + id: 'cfg-ok', + userId: 'user-ok', tenantId: 'tenant-ok', protocol: 'imap', host: 'imap.ok.test', @@ -320,13 +331,14 @@ describe('EmailAlertAdapter', () => { }, }; // First mailbox throws, second succeeds — same imapProvider instance, - // called twice (once per tenant), mockImplementationOnce per call. + // called twice (once per mailbox row), mockImplementationOnce per call. const imapProvider = { fetchMessages: vi .fn() .mockImplementationOnce(throwingFetch) .mockImplementationOnce(workingFetch), }; + const warnSpy = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined); const adapter = new EmailAlertAdapter( prisma as any, crypto as any, @@ -339,6 +351,145 @@ describe('EmailAlertAdapter', () => { expect(imapProvider.fetchMessages).toHaveBeenCalledTimes(2); expect(records).toHaveLength(1); expect(records[0]?.ownerTenantId).toBe('tenant-ok'); + // Hand-written expected message (T-17-03) — not built via the same + // template string the production code uses, so this actually proves + // the message shape rather than restating the implementation. + expect(warnSpy).toHaveBeenCalledWith( + 'Email-alert mailbox cfg-broken (owner user-broken) failed, skipping: connection refused', + ); + // Never leaks credentials/mailbox address into the log line. + const loggedMessages = warnSpy.mock.calls.map((call) => String(call[0])); + expect(loggedMessages.join(' ')).not.toMatch(/imap\.broken\.test|password|username/i); + warnSpy.mockRestore(); + }); + + it('two active rows of the SAME tenant, different owners and different mailbox data: the provider is called twice with each owner\'s own credentials, and the result contains candidates from both (Phase 17, D-01)', async () => { + const msgFromA = { ...MSG, subject: 'Ausschreibung aus Postfach A' }; + const msgFromB = { ...MSG, subject: 'Ausschreibung aus Postfach B' }; + const cryptoReal = makeFakeCrypto(); + const credsA = cryptoReal.encrypt( + JSON.stringify({ username: 'user-a@example.test', password: 'secret-a' }), + ); + const credsB = cryptoReal.encrypt( + JSON.stringify({ username: 'user-b@example.test', password: 'secret-b' }), + ); + const imapFetchMessages = vi + .fn() + .mockImplementationOnce(async () => [msgFromA]) + .mockImplementationOnce(async () => [msgFromB]); + const prisma = { + tenderEmailConfig: { + findMany: vi.fn(async () => [ + { + id: 'cfg-user-a', + userId: 'user-a', + tenantId: 'tenant-shared', + protocol: 'imap', + host: 'imap.user-a.test', + port: 993, + encryption: 'ssl-tls', + folder: 'INBOX', + senderFilter: null, + domain: null, + encryptedInboxCreds: credsA, + }, + { + id: 'cfg-user-b', + userId: 'user-b', + tenantId: 'tenant-shared', + protocol: 'imap', + host: 'imap.user-b.test', + port: 993, + encryption: 'ssl-tls', + folder: 'INBOX', + senderFilter: null, + domain: null, + encryptedInboxCreds: credsB, + }, + ]), + }, + }; + const adapter = new EmailAlertAdapter( + prisma as any, + cryptoReal as any, + { fetchMessages: imapFetchMessages } as any, + { fetchMessages: vi.fn(async () => []) } as any, + ); + + const records = await adapter.fetchTenders('2026-07-23'); + + expect(imapFetchMessages).toHaveBeenCalledTimes(2); + expect(imapFetchMessages).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + host: 'imap.user-a.test', + username: 'user-a@example.test', + password: 'secret-a', + }), + ); + expect(imapFetchMessages).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + host: 'imap.user-b.test', + username: 'user-b@example.test', + password: 'secret-b', + }), + ); + const titles = records.map((r) => (r.ocdsPayload as { title: string }).title); + expect(titles).toContain('Ausschreibung aus Postfach A'); + expect(titles).toContain('Ausschreibung aus Postfach B'); + expect(records.every((r) => r.ownerTenantId === 'tenant-shared')).toBe(true); + }); + + it('ownerTenantId on each extracted record equals that row\'s tenantId — two rows of different tenants yield two different values (D-13)', async () => { + const msgFromTenant1 = { ...MSG, subject: 'Von Mandant 1' }; + const msgFromTenant2 = { ...MSG, subject: 'Von Mandant 2' }; + const imapFetchMessages = vi + .fn() + .mockImplementationOnce(async () => [msgFromTenant1]) + .mockImplementationOnce(async () => [msgFromTenant2]); + const { adapter } = makeAdapter({ + configs: [ + { + id: 'cfg-t1', + userId: 'user-t1', + tenantId: 'tenant-1', + protocol: 'imap', + host: 'imap.tenant1.test', + port: 993, + encryption: 'ssl-tls', + folder: 'INBOX', + senderFilter: null, + domain: null, + encryptedInboxCreds: null, + }, + { + id: 'cfg-t2', + userId: 'user-t2', + tenantId: 'tenant-2', + protocol: 'imap', + host: 'imap.tenant2.test', + port: 993, + encryption: 'ssl-tls', + folder: 'INBOX', + senderFilter: null, + domain: null, + encryptedInboxCreds: null, + }, + ], + imapFetchMessages, + }); + + const records = await adapter.fetchTenders('2026-07-23'); + + const byTitle = (title: string) => + records.find((r) => (r.ocdsPayload as { title: string }).title === title); + + expect(byTitle('Von Mandant 1')?.ownerTenantId).toBe('tenant-1'); + expect(byTitle('Von Mandant 2')?.ownerTenantId).toBe('tenant-2'); + expect(byTitle('Von Mandant 1')?.ownerTenantId).not.toBe( + byTitle('Von Mandant 2')?.ownerTenantId, + ); }); it('returns [] when there are no active configs', async () => { @@ -356,6 +507,8 @@ describe('EmailAlertAdapter', () => { const { adapter } = makeAdapter({ configs: [ { + id: 'cfg-a', + userId: 'user-a', tenantId: 'tenant-a', protocol: 'imap', host: 'imap.example.test', diff --git a/apps/api/src/tenders/adapters/email-alert.adapter.ts b/apps/api/src/tenders/adapters/email-alert.adapter.ts index 390e0b9..62ad7e7 100644 --- a/apps/api/src/tenders/adapters/email-alert.adapter.ts +++ b/apps/api/src/tenders/adapters/email-alert.adapter.ts @@ -12,25 +12,31 @@ import type { TenderSourceAdapter } from './tender-source-adapter.interface'; /** * EmailAlertAdapter — INGEST-05. Extracts candidate tender detail links and - * a display title from a tenant's portal-alert mailbox, generically (D-04): - * no per-portal parser, since no concrete alert-sending portals are known + * a display title from a portal-alert mailbox, generically (D-04): no + * per-portal parser, since no concrete alert-sending portals are known * ahead of time (14-CONTEXT.md). Feld-Armut (no CPV/buyer/deadline) is a * deliberate, documented consequence (D-05) — the same deferral already * accepted for the NetServer/cosinex/RSS scraper adapters. * - * `fetchTenders()` (Plan 14-03 Task 2) is an internal per-tenant fan-out - * (RESEARCH.md Pattern 1, same shape as NetServerAdapter's per-portal loop - * and RssAdapter's per-feed loop): reads every ACTIVE `TenderEmailConfig` - * row across ALL tenants in ONE query — this is a DELIBERATE, audited + * `fetchTenders()` (Plan 14-03 Task 2) is an internal fan-out (RESEARCH.md + * Pattern 1, same shape as NetServerAdapter's per-portal loop and + * RssAdapter's per-feed loop): reads every ACTIVE `TenderEmailConfig` row + * across ALL tenants in ONE query — this is a DELIBERATE, audited * cross-tenant read at the platform-scheduler level (mirrors the same * documented exception for RssAdapter/TenderIngestionService itself) and - * must NEVER be wrapped in `forTenant()`/RLS. Each tenant's mailbox is - * fetched independently via the shared `inbox/` module's - * `InboxProvider.fetchMessages` (Plan 14-01) — catch-per-tenant, so one - * tenant's broken/unreachable mailbox never blocks the others in the same - * tick. Every extracted candidate is tagged with `ownerTenantId` = the - * configuring tenant's id (D-13) so the resulting Tender rows stay private - * to that tenant on the read side (tender-query.builder.ts). + * must NEVER be wrapped in `forTenant()`/RLS. Since Phase 17 (Plan 01, + * D-01) `TenderEmailConfig` ownership moved from tenant to user — a tenant + * can now have MULTIPLE active mailboxes (one per user) — so the fan-out + * runs per MAILBOX ROW, not per tenant; the mechanics are unchanged, this + * is still the platform scheduler resolving every active row in one tick, + * not a per-tenant/per-user request. Each mailbox is fetched independently + * via the shared `inbox/` module's `InboxProvider.fetchMessages` (Plan + * 14-01) — catch-per-mailbox, so one broken/unreachable mailbox never + * blocks the others in the same tick, even when two of them belong to the + * same tenant. Every extracted candidate is tagged with `ownerTenantId` = + * that mailbox row's `tenantId` (D-13, still denormalized per-row after + * D-01) so the resulting Tender rows stay private to that tenant on the + * read side (tender-query.builder.ts). * * Security (T-14-03-03, stored-XSS guard): only plain-text `title` and raw * `href` URL strings are ever extracted from an alert email body — cheerio @@ -38,6 +44,8 @@ import type { TenderSourceAdapter } from './tender-source-adapter.interface'; * markup ever crosses into a RawTenderRecord/Tender row. * Security (T-05-13): decrypted mailbox credentials only ever exist within * `resolveDecryptedConfig`'s return value scope — never logged. + * Security (T-17-03): the per-mailbox failure warning below names only the + * row id and owning userId — never username, password, or mailbox address. */ /** @@ -139,7 +147,8 @@ export class EmailAlertAdapter implements TenderSourceAdapter { async fetchTenders(_dayCursor: string): Promise { // Deliberate cross-tenant read (see class docstring) — NEVER wrap in // forTenant()/RLS. This is the platform scheduler resolving every - // tenant's active mailbox in one tick, not a per-tenant request. + // active mailbox row in one tick (potentially several per tenant since + // Phase 17, D-01), not a per-tenant/per-user request. const configs = await this.prisma.tenderEmailConfig.findMany({ where: { isActive: true }, }); @@ -155,10 +164,13 @@ export class EmailAlertAdapter implements TenderSourceAdapter { const messages = await provider.fetchMessages(inboxConfig); records.push(...this.extractCandidates(messages, cfg.tenantId, fetchedAt)); } catch (error) { - // Catch-per-tenant (D-01 discipline, mirrors NetServer/RssAdapter): - // one tenant's broken/unreachable mailbox never blocks the others. + // Catch-per-mailbox (D-01 discipline, mirrors NetServer/RssAdapter): + // one broken/unreachable mailbox never blocks the others — even + // two mailboxes belonging to the same tenant (Phase 17, D-01). + // T-17-03: names only the row id and owning userId, never + // username/password/mailbox address. this.logger.warn( - `Email-alert mailbox for tenant ${cfg.tenantId} failed, skipping: ${(error as Error).message}`, + `Email-alert mailbox ${cfg.id} (owner ${cfg.userId}) failed, skipping: ${(error as Error).message}`, ); } } @@ -171,7 +183,7 @@ export class EmailAlertAdapter implements TenderSourceAdapter { * the shared inbox providers. T-05-13: the decrypted password only ever * exists within this method's return value — never logged. A decrypt * failure resolves to no credentials (the provider's own auth failure is - * then caught by fetchTenders' per-tenant try/catch above), matching the + * then caught by fetchTenders' per-mailbox try/catch above), matching the * "ignore decrypt errors" convention already used by * DkvService/TenderEmailConfigService. */ @@ -206,10 +218,13 @@ export class EmailAlertAdapter implements TenderSourceAdapter { } /** - * Maps one tenant's fetched InboxMessages into RawTenderRecord[] — one - * record per extracted candidate link (D-04), every record tagged with - * `ownerTenantId` (D-13) so the resulting Tender rows stay private to - * this tenant on the read side. + * Maps one mailbox row's fetched InboxMessages into RawTenderRecord[] — + * one record per extracted candidate link (D-04), every record tagged + * with `ownerTenantId` = that row's `tenantId` (D-13) so the resulting + * Tender rows stay private to that tenant on the read side. Since Phase + * 17 (D-01) a tenant can own several mailbox rows (one per user); each + * still tags its records with the same `ownerTenantId` — visibility is + * unchanged, only who configures the source differs. */ private extractCandidates( messages: InboxMessage[], diff --git a/apps/api/src/tenders/email-config-migration-sql.spec.ts b/apps/api/src/tenders/email-config-migration-sql.spec.ts new file mode 100644 index 0000000..878a9d7 --- /dev/null +++ b/apps/api/src/tenders/email-config-migration-sql.spec.ts @@ -0,0 +1,88 @@ +import { readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +/** + * Prüft das hand-geschriebene Migrations-SQL für den Wechsel von + * TenderEmailConfig.tenantId auf userId (Phase 17, Plan 01, D-01) — reiner + * Textabgleich ohne Datenbank, gleiches Muster wie + * doe-url-migration-sql.spec.ts / groups/migration-sql.spec.ts. + * + * Die Reihenfolge ist die zentrale Korrektheitsbedingung dieser Migration: + * eine bereits vorhandene Postfach-Zeile muss ihren Besitzer bekommen, + * BEVOR unbesetzte Zeilen entfernt werden, und die Pflichtsetzung der + * neuen Spalte darf erst NACH der Befüllung stehen — sonst schlägt die + * Migration bei jeder Bestandszeile fehl. + */ + +const MIGRATIONS_DIR = join(__dirname, '../../prisma/migrations'); + +function readMigrationSql(suffix: string): string { + const dirs = readdirSync(MIGRATIONS_DIR, { withFileTypes: true }) + .filter((entry) => entry.isDirectory() && entry.name.endsWith(suffix)) + .map((entry) => entry.name); + + if (dirs.length !== 1) { + throw new Error( + `Expected exactly one migration directory ending in "${suffix}", found ${dirs.length}: ${dirs.join(', ')}`, + ); + } + + return readFileSync(join(MIGRATIONS_DIR, dirs[0], 'migration.sql'), 'utf-8'); +} + +describe('tender_email_config_per_user migration.sql', () => { + const sql = readMigrationSql('_tender_email_config_per_user'); + + it('ordnet Bestandszeilen einem Administrator zu, BEVOR unbesetzte Zeilen geloescht werden', () => { + const updateIdx = sql.indexOf('UPDATE "TenderEmailConfig"'); + const deleteIdx = sql.indexOf('DELETE FROM "TenderEmailConfig"'); + + expect(updateIdx).toBeGreaterThanOrEqual(0); + expect(deleteIdx).toBeGreaterThan(updateIdx); + }); + + it('die Zuordnung beschraenkt sich auf aktive Nutzer mit Administratorrolle desselben Mandanten und nimmt genau einen', () => { + const updateStmt = sql.slice( + sql.indexOf('UPDATE "TenderEmailConfig"'), + sql.indexOf('DELETE FROM "TenderEmailConfig"'), + ); + + expect(updateStmt).toContain('u."tenantId" = tec."tenantId"'); + expect(updateStmt).toContain('u."isActive" = true'); + expect(updateStmt).toMatch(/u\."role"\s+IN\s+\('ADMIN',\s*'SUPER_ADMIN'\)/); + expect(updateStmt).toMatch(/ORDER BY\s+u\."createdAt"\s+ASC,\s*u\."id"\s+ASC/); + expect(updateStmt).toContain('LIMIT 1'); + }); + + it('entfernt nur Zeilen, die nach der Zuordnung noch keinen Besitzer haben (kein Administrator im Mandanten)', () => { + expect(sql).toMatch(/DELETE FROM "TenderEmailConfig" WHERE "userId" IS NULL/); + }); + + it('hebt die alte Eindeutigkeitsregel auf dem Mandantenfeld auf und legt eine neue auf dem Besitzerfeld an', () => { + expect(sql).toContain('DROP INDEX "TenderEmailConfig_tenantId_key"'); + expect(sql).toContain( + 'CREATE UNIQUE INDEX "TenderEmailConfig_userId_key" ON "TenderEmailConfig"("userId")', + ); + // Der gewoehnliche Index auf tenantId bleibt bestehen — tenantId wird + // weiterhin gefiltert (SMTP-Aufloesung), nur die Eindeutigkeit faellt weg. + expect(sql).not.toContain('DROP INDEX "TenderEmailConfig_tenantId_idx"'); + }); + + it('setzt die Spalte erst NACH der Befuellung auf Pflicht', () => { + const backfillIdx = sql.indexOf('UPDATE "TenderEmailConfig"'); + const notNullIdx = sql.indexOf('ALTER COLUMN "userId" SET NOT NULL'); + + expect(backfillIdx).toBeGreaterThanOrEqual(0); + expect(notNullIdx).toBeGreaterThan(backfillIdx); + }); + + it('legt die Spalte zunaechst ohne Pflicht an, bevor irgendetwas befuellt wird', () => { + const addColumnIdx = sql.indexOf('ADD COLUMN "userId" TEXT'); + const backfillIdx = sql.indexOf('UPDATE "TenderEmailConfig"'); + + expect(addColumnIdx).toBeGreaterThanOrEqual(0); + expect(sql.slice(addColumnIdx, addColumnIdx + 40)).not.toMatch(/NOT NULL/); + expect(backfillIdx).toBeGreaterThan(addColumnIdx); + }); +});