test(17-01): prove multi-mailbox fan-out and the userId migration's SQL order

Der mandantenuebergreifende Sammelabruf in email-alert.adapter.ts bleibt
mechanisch unveraendert (findMany({isActive:true}) in einem Zug,
Fehlerbehandlung je Zeile) — geaendert wird nur die Warnmeldung (Zeilen-id
+ Besitzer statt Mandant, T-17-03) und die Klassendoku.

- Neue Tests: zwei aktive Postfaecher DESSELBEN Mandanten werden beide mit
  ihren jeweils eigenen Zugangsdaten abgeholt; ein kaputtes Postfach
  blockiert das andere nicht und protokolliert eine Warnung ohne
  Zugangsdaten/Adresse; die Herkunftsmarkierung folgt dem Mandantenfeld
  der jeweiligen Zeile (zwei Mandanten -> zwei Werte). Erwartungswerte von
  Hand geschrieben, nicht ueber die Produktivfunktion erzeugt.
- tender-email-config.service.spec.ts (bereits in der Task-2-Migration
  mitgeliefert) deckt zusaetzlich: tenantId wird beim Anlegen mitgeschrieben,
  zwei Nutzer desselben Mandanten erzeugen zwei Zeilen statt eine zu
  ueberschreiben.
- email-config-migration-sql.spec.ts (neu, Vorbild
  doe-url-migration-sql.spec.ts): prueft die Reihenfolge der
  Hand-Migration textuell — Zuordnung vor Loeschung, Pflicht erst nach
  Befuellung, alte Eindeutigkeit runter/neue rauf, gewoehnlicher
  tenantId-Index bleibt stehen.

src/tenders: 335/335 gruen. API gesamt: 603/603. Web gesamt: 192/192.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-12 11:24:12 +02:00
parent 05b1d293d8
commit 55ceb24ee9
3 changed files with 280 additions and 24 deletions
@@ -1,5 +1,6 @@
import { readFileSync } from 'fs'; import { readFileSync } from 'fs';
import { join } from 'path'; import { join } from 'path';
import { Logger } from '@nestjs/common';
import { describe, expect, it, vi } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import { import {
EmailAlertAdapter, EmailAlertAdapter,
@@ -196,6 +197,8 @@ describe('EmailAlertAdapter', () => {
const { adapter } = makeAdapter({ const { adapter } = makeAdapter({
configs: [ configs: [
{ {
id: 'cfg-a',
userId: 'user-a',
tenantId: 'tenant-a', tenantId: 'tenant-a',
protocol: 'imap', protocol: 'imap',
host: 'imap.example.test', host: 'imap.example.test',
@@ -225,6 +228,8 @@ describe('EmailAlertAdapter', () => {
const { adapter } = makeAdapter({ const { adapter } = makeAdapter({
configs: [ configs: [
{ {
id: 'cfg-ews',
userId: 'user-ews',
tenantId: 'tenant-ews', tenantId: 'tenant-ews',
protocol: 'exchange', protocol: 'exchange',
host: 'https://mail.example.test/EWS/Exchange.asmx', host: 'https://mail.example.test/EWS/Exchange.asmx',
@@ -258,6 +263,8 @@ describe('EmailAlertAdapter', () => {
tenderEmailConfig: { tenderEmailConfig: {
findMany: vi.fn(async () => [ findMany: vi.fn(async () => [
{ {
id: 'cfg-a',
userId: 'user-a',
tenantId: 'tenant-a', tenantId: 'tenant-a',
protocol: 'imap', protocol: 'imap',
host: 'imap.example.test', 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 () => { const throwingFetch = vi.fn(async () => {
throw new Error('connection refused'); throw new Error('connection refused');
}); });
@@ -295,6 +302,8 @@ describe('EmailAlertAdapter', () => {
tenderEmailConfig: { tenderEmailConfig: {
findMany: vi.fn(async () => [ findMany: vi.fn(async () => [
{ {
id: 'cfg-broken',
userId: 'user-broken',
tenantId: 'tenant-broken', tenantId: 'tenant-broken',
protocol: 'imap', protocol: 'imap',
host: 'imap.broken.test', host: 'imap.broken.test',
@@ -306,6 +315,8 @@ describe('EmailAlertAdapter', () => {
encryptedInboxCreds: null, encryptedInboxCreds: null,
}, },
{ {
id: 'cfg-ok',
userId: 'user-ok',
tenantId: 'tenant-ok', tenantId: 'tenant-ok',
protocol: 'imap', protocol: 'imap',
host: 'imap.ok.test', host: 'imap.ok.test',
@@ -320,13 +331,14 @@ describe('EmailAlertAdapter', () => {
}, },
}; };
// First mailbox throws, second succeeds — same imapProvider instance, // 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 = { const imapProvider = {
fetchMessages: vi fetchMessages: vi
.fn() .fn()
.mockImplementationOnce(throwingFetch) .mockImplementationOnce(throwingFetch)
.mockImplementationOnce(workingFetch), .mockImplementationOnce(workingFetch),
}; };
const warnSpy = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
const adapter = new EmailAlertAdapter( const adapter = new EmailAlertAdapter(
prisma as any, prisma as any,
crypto as any, crypto as any,
@@ -339,6 +351,145 @@ describe('EmailAlertAdapter', () => {
expect(imapProvider.fetchMessages).toHaveBeenCalledTimes(2); expect(imapProvider.fetchMessages).toHaveBeenCalledTimes(2);
expect(records).toHaveLength(1); expect(records).toHaveLength(1);
expect(records[0]?.ownerTenantId).toBe('tenant-ok'); 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 () => { it('returns [] when there are no active configs', async () => {
@@ -356,6 +507,8 @@ describe('EmailAlertAdapter', () => {
const { adapter } = makeAdapter({ const { adapter } = makeAdapter({
configs: [ configs: [
{ {
id: 'cfg-a',
userId: 'user-a',
tenantId: 'tenant-a', tenantId: 'tenant-a',
protocol: 'imap', protocol: 'imap',
host: 'imap.example.test', host: 'imap.example.test',
@@ -12,25 +12,31 @@ import type { TenderSourceAdapter } from './tender-source-adapter.interface';
/** /**
* EmailAlertAdapter — INGEST-05. Extracts candidate tender detail links and * EmailAlertAdapter — INGEST-05. Extracts candidate tender detail links and
* a display title from a tenant's portal-alert mailbox, generically (D-04): * a display title from a portal-alert mailbox, generically (D-04): no
* no per-portal parser, since no concrete alert-sending portals are known * 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 * ahead of time (14-CONTEXT.md). Feld-Armut (no CPV/buyer/deadline) is a
* deliberate, documented consequence (D-05) — the same deferral already * deliberate, documented consequence (D-05) — the same deferral already
* accepted for the NetServer/cosinex/RSS scraper adapters. * accepted for the NetServer/cosinex/RSS scraper adapters.
* *
* `fetchTenders()` (Plan 14-03 Task 2) is an internal per-tenant fan-out * `fetchTenders()` (Plan 14-03 Task 2) is an internal fan-out (RESEARCH.md
* (RESEARCH.md Pattern 1, same shape as NetServerAdapter's per-portal loop * Pattern 1, same shape as NetServerAdapter's per-portal loop and
* and RssAdapter's per-feed loop): reads every ACTIVE `TenderEmailConfig` * RssAdapter's per-feed loop): reads every ACTIVE `TenderEmailConfig` row
* row across ALL tenants in ONE query — this is a DELIBERATE, audited * across ALL tenants in ONE query — this is a DELIBERATE, audited
* cross-tenant read at the platform-scheduler level (mirrors the same * cross-tenant read at the platform-scheduler level (mirrors the same
* documented exception for RssAdapter/TenderIngestionService itself) and * documented exception for RssAdapter/TenderIngestionService itself) and
* must NEVER be wrapped in `forTenant()`/RLS. Each tenant's mailbox is * must NEVER be wrapped in `forTenant()`/RLS. Since Phase 17 (Plan 01,
* fetched independently via the shared `inbox/` module's * D-01) `TenderEmailConfig` ownership moved from tenant to user — a tenant
* `InboxProvider.fetchMessages` (Plan 14-01) — catch-per-tenant, so one * can now have MULTIPLE active mailboxes (one per user) — so the fan-out
* tenant's broken/unreachable mailbox never blocks the others in the same * runs per MAILBOX ROW, not per tenant; the mechanics are unchanged, this
* tick. Every extracted candidate is tagged with `ownerTenantId` = the * is still the platform scheduler resolving every active row in one tick,
* configuring tenant's id (D-13) so the resulting Tender rows stay private * not a per-tenant/per-user request. Each mailbox is fetched independently
* to that tenant on the read side (tender-query.builder.ts). * 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 * 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 * `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. * markup ever crosses into a RawTenderRecord/Tender row.
* Security (T-05-13): decrypted mailbox credentials only ever exist within * Security (T-05-13): decrypted mailbox credentials only ever exist within
* `resolveDecryptedConfig`'s return value scope — never logged. * `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<RawTenderRecord[]> { async fetchTenders(_dayCursor: string): Promise<RawTenderRecord[]> {
// Deliberate cross-tenant read (see class docstring) — NEVER wrap in // Deliberate cross-tenant read (see class docstring) — NEVER wrap in
// forTenant()/RLS. This is the platform scheduler resolving every // 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({ const configs = await this.prisma.tenderEmailConfig.findMany({
where: { isActive: true }, where: { isActive: true },
}); });
@@ -155,10 +164,13 @@ export class EmailAlertAdapter implements TenderSourceAdapter {
const messages = await provider.fetchMessages(inboxConfig); const messages = await provider.fetchMessages(inboxConfig);
records.push(...this.extractCandidates(messages, cfg.tenantId, fetchedAt)); records.push(...this.extractCandidates(messages, cfg.tenantId, fetchedAt));
} catch (error) { } catch (error) {
// Catch-per-tenant (D-01 discipline, mirrors NetServer/RssAdapter): // Catch-per-mailbox (D-01 discipline, mirrors NetServer/RssAdapter):
// one tenant's broken/unreachable mailbox never blocks the others. // 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( 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 * the shared inbox providers. T-05-13: the decrypted password only ever
* exists within this method's return value — never logged. A decrypt * exists within this method's return value — never logged. A decrypt
* failure resolves to no credentials (the provider's own auth failure is * 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 * "ignore decrypt errors" convention already used by
* DkvService/TenderEmailConfigService. * DkvService/TenderEmailConfigService.
*/ */
@@ -206,10 +218,13 @@ export class EmailAlertAdapter implements TenderSourceAdapter {
} }
/** /**
* Maps one tenant's fetched InboxMessages into RawTenderRecord[] — one * Maps one mailbox row's fetched InboxMessages into RawTenderRecord[] —
* record per extracted candidate link (D-04), every record tagged with * one record per extracted candidate link (D-04), every record tagged
* `ownerTenantId` (D-13) so the resulting Tender rows stay private to * with `ownerTenantId` = that row's `tenantId` (D-13) so the resulting
* this tenant on the read side. * 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( private extractCandidates(
messages: InboxMessage[], messages: InboxMessage[],
@@ -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);
});
});