feat(quick-260914-eym): Mail-Transport je Versand nach Mandant (WINDOWS #30), ldap/digest/matching ueber Systemkontext, vier Tabellen im Werkzeug, Erlaubnisliste vollstaendig

- mail: MailerModule-Fabrik und DB-Startpfad (findFirst beim Boot) ersatzlos
  entfernt; MailService baut je Versand einen nodemailer-Transport aus
  getDecryptedSmtpConfig(tenantId) des Empfaenger-Mandanten, Umgebungs-Kette
  (MAIL_* -> TESSERA_SMTP_* -> localhost:1025) nur als Rueckfall; Fehler
  weiter verschluckt (T-02-12), close() im finally; neue mail.service.spec.ts
  (4 Tests, T-GWH-03 geschlossen)
- settings: Startpfad-Methode samt vier Spec-Tests geloescht;
  auth: requestPasswordReset reicht user.tenantId durch (Spec-Zusicherung)
- ldap: getAllActiveConfigs und Nachverschluesselung lesen ueber forSystem
  (zwei Zuweisungen), Schreibzeile je Altzeile ueber forTenant(config.tenantId);
  Tests 301/306 umgedreht, neuer Altzeilen-Test
- tender-digest: Kandidatenabfrage ueber forSystem, Schleife gebunden (+1 Test)
- tender-matching: Profilabfrage ueber forSystem, Katalog (D-03) ungebunden (+1 Test)
- tender-notifications.integration.spec: Mock um forSystem
- Werkzeug: LdapConfig (15 Spalten), LdapFieldMapping (6), TenderMatch (8),
  TenderSavedSearch (8) je neun Kennungen plus Relations-Kennung
  ldapconfig-systemkontext-include-fieldmappings-beider-mandanten
  -> Alle 253 Pruefungen bestanden
- Detektor: FORSYSTEM_ALLOWED_CALL_SITES auf 4 Dateien / 5 Aufrufe;
  Proben-Empfaenger sysPrisma (Gate-Zaehlung, Name nicht hartkodiert)
- Klassifikation: 6 Zeilen system-gebunden, settings/smtpConfig gebunden
- Falsifizierung durch Rueckbau ausgefuehrt und zurueckgenommen:
  (a) FOR SELECT bei TenderMatch entfernt -> 5 von 253 rot (Insert gelingt,
  cmd ALL); (b) Regel TenderSavedSearch aus der Datei entfernt -> 1 von 245
  rot (Extraktion), lebende DB bleibt bei 34; (c) local=false -> gruen, plus
  Reset entfernt -> 5 rot (Erben sichtbar); (d) Zahl 0 -> 2 rot, Fremddatei
  admin-seed -> 3 rot
- Baseline: 64 Dateien / 1054 Tests, tsc 0, Werkzeug 253

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018N9CD3ebPKm1b32bPpBknY
This commit is contained in:
2026-09-14 11:46:08 +02:00
parent 3d645674f0
commit 6e2a641d76
18 changed files with 828 additions and 336 deletions
+3
View File
@@ -387,9 +387,12 @@ describe('AuthService.requestPasswordReset', () => {
expiresAt: expect.any(Date),
},
});
// Drittes Argument (260914-eym): die tenantId des Empfaengers (emailUser
// liegt unter 't1') — MailService baut daraus den Transport je Versand.
expect(mailService.sendPasswordResetEmail).toHaveBeenCalledWith(
'bob@example.com',
expect.any(String),
't1',
);
});
+4 -2
View File
@@ -242,8 +242,10 @@ export class AuthService {
},
});
// Send the reset email (fire-and-forget, errors logged by MailService)
await this.mailService.sendPasswordResetEmail(email, token);
// Send the reset email (fire-and-forget, errors logged by MailService).
// Der Mandant des Empfaengers entscheidet ueber den SMTP-Transport
// (260914-eym, WINDOWS #30) — er ist hier bereits bekannt.
await this.mailService.sendPasswordResetEmail(email, token, user.tenantId);
}
/**
+2 -2
View File
@@ -24,8 +24,8 @@ const CronJobClass: new (cronTime: string, onTick: () => void) => { start(): voi
* AUFTRAG JE MANDANT (Etappe 3c, 260914-eym, WINDOWS #21 GESCHLOSSEN):
*
* Einmal-abfragen-viele-bedienen. Beim Start laedt der Planer ueber
* `DkvService.loadActiveConfigsForScheduler()` (systemgebunden, `forSystem()`,
* nur lesend) ALLE aktiven Konfigurationen und registriert je aktivem
* `DkvService.loadActiveConfigsForScheduler()` (systemgebunden ueber den
* Systemkontext-Helfer, nur lesend) ALLE aktiven Konfigurationen und registriert je aktivem
* Mandanten einen EIGENEN Cron-Auftrag unter dem Registry-Namen
* `dkv-inbox-poll:<tenantId>`. Der Tick eines Auftrags ruft
* `processInbox(tenantId)` fuer GENAU diesen Mandanten — der Tick selbst
+59 -5
View File
@@ -8,9 +8,12 @@ import { LdapConfigService } from './ldap-config.service';
// Implementierung auf ein zweites, unterscheidbares Client-Objekt um.
vi.mock('../prisma/prisma-tenant.extension', () => ({
forTenant: vi.fn((p: unknown) => p),
// Systemkontext (260914-eym): liefert den in `__systemClient` hinterlegten
// Klienten, sonst denselben Client (Bestandstests).
forSystem: vi.fn((p: any) => p.__systemClient ?? p),
}));
import { forTenant } from '../prisma/prisma-tenant.extension';
import { forSystem, forTenant } from '../prisma/prisma-tenant.extension';
/**
* Das Bind-Passwort ist das einzige Zugangsdatum, das nicht gehasht werden
@@ -298,14 +301,65 @@ describe('LdapConfigService — Bindung an forTenant() (260909-ipc)', () => {
expect(prisma.ldapFieldMapping.delete).not.toHaveBeenCalled();
});
it('getAllActiveConfigs() bleibt bewusst uebergreifend — kein Mandantenkontext', async () => {
await service.getAllActiveConfigs();
it('getAllActiveConfigs() liest ueber den Systemkontext: forSystem genau einmal, forTenant nie (260914-eym)', async () => {
const systemClient = {
ldapConfig: { findMany: vi.fn().mockResolvedValue([{ ...CONFIG_ROW }]) },
};
prisma.__systemClient = systemClient;
const result = await service.getAllActiveConfigs();
expect(forSystem).toHaveBeenCalledTimes(1);
expect(forSystem).toHaveBeenCalledWith(prisma);
expect(forTenant).not.toHaveBeenCalled();
expect(systemClient.ldapConfig.findMany).toHaveBeenCalledWith({
where: { isActive: true },
include: { tenant: true, fieldMappings: true },
});
expect(prisma.ldapConfig.findMany).not.toHaveBeenCalled();
expect(result).toHaveLength(1);
});
it('onApplicationBootstrap() bleibt bewusst uebergreifend — kein Mandantenkontext', async () => {
prisma.ldapConfig.findMany.mockResolvedValue([]);
it('onApplicationBootstrap() mit leerer Liste: forSystem einmal, forTenant nie, kein Update (Leere ist Nichtstun, 260914-eym)', async () => {
const systemClient = { ldapConfig: { findMany: vi.fn().mockResolvedValue([]) } };
prisma.__systemClient = systemClient;
await service.onApplicationBootstrap();
expect(forSystem).toHaveBeenCalledTimes(1);
expect(forTenant).not.toHaveBeenCalled();
expect(prisma.ldapConfig.update).not.toHaveBeenCalled();
});
it('onApplicationBootstrap() mit einer Altzeile (Klartext, t1): liest system, schreibt GEBUNDEN — forTenant genau einmal mit t1, update traegt das verschluesselte Kennwort (260914-eym)', async () => {
const systemClient = {
ldapConfig: {
findMany: vi.fn().mockResolvedValue([
{ id: 'alt', tenantId: 't1', encryptedBindPassword: 'klartext' },
]),
},
};
prisma.__systemClient = systemClient;
const boundClient = {
ldapConfig: { update: vi.fn((args: any) => Promise.resolve({ ...CONFIG_ROW, ...args.data })) },
};
vi.mocked(forTenant).mockImplementation(() => boundClient as any);
await service.onApplicationBootstrap();
expect(forSystem).toHaveBeenCalledTimes(1);
expect(forTenant).toHaveBeenCalledTimes(1);
expect(forTenant).toHaveBeenCalledWith(prisma, 't1');
expect(boundClient.ldapConfig.update).toHaveBeenCalledTimes(1);
const call = boundClient.ldapConfig.update.mock.calls[0][0];
expect(call.where).toEqual({ id: 'alt' });
expect(call.data.encryptedBindPassword).toBe(
'aa11:bb22:' + Buffer.from('klartext').toString('hex'),
);
// Der rohe Client schreibt NICHT.
expect(prisma.ldapConfig.update).not.toHaveBeenCalled();
// Implementierung zuruecksetzen (vi.clearAllMocks loescht nur Aufrufe).
vi.mocked(forTenant).mockImplementation((p: unknown) => p as any);
});
});
+37 -23
View File
@@ -2,7 +2,7 @@ import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
import { CryptoService } from '../crypto/crypto.service';
import { PrismaService } from '../prisma/prisma.service';
import { forTenant } from '../prisma/prisma-tenant.extension';
import { forSystem, forTenant } from '../prisma/prisma-tenant.extension';
import {
CreateFieldMappingDto,
CreateLdapConfigDto,
@@ -53,19 +53,26 @@ export class LdapConfigService implements OnApplicationBootstrap {
* re-encrypted still authenticates, because the read path below tolerates a
* legacy plaintext value.
*
* BLEIBT bewusst UNGEBUNDEN (WINDOWS #20 Etappe 2, 260909-ipc, Befund B):
* dieser Durchlauf muss ALLE Konfigurationen ALLER Mandanten nachziehen,
* bevor je ein einzelner Mandantenkontext feststeht — beim Boot existiert
* strukturell noch keiner. Nach dem Scharfschalten (Etappe 4) sieht dieser
* Zugriff 0 Zeilen; die Nachverschluesselung wird dann stillschweigend zum
* Nichtstun statt zu einem Fehler. Die Loesung gehoert nach Etappe 3
* (Systemkontext), diese Umstellung entscheidet sie nicht.
* SYSTEMGEBUNDEN LESEN, JE ZEILE GEBUNDEN SCHREIBEN (Etappe 3c,
* 260914-eym; vorher bewusst ungebunden, 260909-ipc Befund B): dieser
* Durchlauf muss ALLE Konfigurationen ALLER Mandanten sehen, bevor je ein
* einzelner Mandantenkontext feststeht — beim Boot existiert strukturell
* noch keiner. Das Lesen laeuft deshalb ueber `forSystem()`
* (`system_read_policy ... FOR SELECT` auf "LdapConfig", Migration
* 20260914120000): das Verstummen nach dem Scharfschalten ist strukturell
* ausgeschlossen. Die Schreibzeile je Altzeile laeuft ueber
* `forTenant(this.prisma, config.tenantId)` — unter Systemkontext ist
* Schreiben abgewiesen (gemessen: `update` per id -> P2025, INSERT ->
* 42501), und der Mandant steht in der gelesenen Zeile. Eine LEERE Liste
* ist Nichtstun (kein Loeschen, kein Deaktivieren).
*/
async onApplicationBootstrap(): Promise<void> {
try {
const configs = await this.prisma.ldapConfig.findMany({
select: { id: true, tenantId: true, encryptedBindPassword: true },
});
const systemPrisma = forSystem(this.prisma) as any;
const configs: { id: string; tenantId: string; encryptedBindPassword: string | null }[] =
await systemPrisma.ldapConfig.findMany({
select: { id: true, tenantId: true, encryptedBindPassword: true },
});
const legacy = configs.filter(
(config) =>
@@ -75,7 +82,10 @@ export class LdapConfigService implements OnApplicationBootstrap {
if (legacy.length === 0) return;
for (const config of legacy) {
await this.prisma.ldapConfig.update({
// Schreiben je Altzeile GEBUNDEN an den Mandanten der Zeile — unter
// Systemkontext wuerde die Datenbank das Update abweisen (P2025).
const tenantPrisma = forTenant(this.prisma, config.tenantId) as any;
await tenantPrisma.ldapConfig.update({
where: { id: config.id },
data: {
encryptedBindPassword: this.crypto.encrypt(
@@ -295,21 +305,25 @@ export class LdapConfigService implements OnApplicationBootstrap {
* Get all active LDAP configs. Used by the scheduler to determine which
* tenants need auto-sync.
*
* BLEIBT bewusst UNGEBUNDEN (WINDOWS #20 Etappe 2, 260909-ipc, Befund B):
* der Planer braucht die Liste ALLER aktiven Konfigurationen ALLER
* Mandanten, um daraus je Mandant einen Sync-Lauf anzustossen — das ist
* die Aufgabe dieser Methode, nicht ein vergessener `forTenant()`-Aufruf.
* Nach dem Scharfschalten (Etappe 4) sieht dieser Zugriff 0 Zeilen: der
* LDAP-Abgleich stellt dann fuer JEDEN Mandanten ohne Fehlermeldung, ohne
* Protokolleintrag und ohne sichtbare Aenderung die Arbeit ein (Befund E,
* docs/mandantentrennung-etappe2-fehlerrichtung.md). Die Loesung
* (Systemkontext) gehoert nach Etappe 3.
* SYSTEMGEBUNDEN (Etappe 3c, 260914-eym; vorher bewusst ungebunden,
* 260909-ipc Befund B): der Planer braucht die Liste ALLER aktiven
* Konfigurationen ALLER Mandanten, um daraus je Mandant einen gebundenen
* Sync-Lauf anzustossen — `forSystem()` liest sie ueber
* `system_read_policy ... FOR SELECT` (Migration 20260914120000).
* `LdapFieldMapping` wird ueber `include: { fieldMappings }` mitgelesen
* (WINDOWS-#27-Form) und traegt deshalb dieselbe Regel; `Tenant` traegt
* in keiner Migration eine Regel und braucht keine Oeffnung. Das
* Verstummen nach dem Scharfschalten (Befund E) ist damit strukturell
* ausgeschlossen; eine LEERE Liste startet keinen Sync-Lauf — der
* Loeschzweig in ldap.service.ts liegt INNERHALB eines gebundenen Laufs,
* den es dann nicht gibt.
*/
async getAllActiveConfigs() {
const configs = await this.prisma.ldapConfig.findMany({
const systemPrisma = forSystem(this.prisma) as any;
const configs = await systemPrisma.ldapConfig.findMany({
where: { isActive: true },
include: { tenant: true, fieldMappings: true },
});
return configs.map((config) => this.withDecryptedPassword(config));
return configs.map((config: any) => this.withDecryptedPassword(config));
}
}
+14 -84
View File
@@ -1,102 +1,32 @@
import { Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { MailerModule } from '@nestjs-modules/mailer';
import { SettingsModule } from '../settings/settings.module';
import { SettingsService } from '../settings/settings.service';
import { MailService } from './mail.service';
/**
* MailModule — system email delivery (password reset, welcome emails).
*
* D-06: SMTP transport is now sourced from the DB SmtpConfig row (priority 1)
* with an env-var fallback (priority 2) when no DB row exists.
* KEIN STARTPFAD MEHR (Etappe 3c, 260914-eym, WINDOWS #30 GESCHLOSSEN):
* die Mailer-Fabrik (`MailerModule.forRootAsync`) und ihr Lesezugriff
* `findFirst()` auf SmtpConfig beim Boot sind ersatzlos entfernt.
* `MailService` baut je Versand einen nodemailer-Transport nach dem
* Mandanten des Empfaengers (siehe dessen Kopfkommentar). Der sechste Fall
* der Hintergrunddienst-Falle (docs/mandantentrennung-zugriffsklassifikation.md)
* EXISTIERT damit NICHT MEHR — deshalb traegt SmtpConfig keine
* `system_read_policy` (Migration 20260914120000).
*
* Transport priority:
* 1. DB SmtpConfig (loadAnySmtpConfigForStartupTransport — bewusst
* UNGEBUNDEN, sechster Fall der Hintergrunddienst-Falle, 260911-gwh;
* siehe deren Kopfkommentar in settings.service.ts fuer beide
* Zustaende: HEUTE zieht sie den Server EINES beliebigen Mandanten fuer
* alle Systemmails [T-GWH-03], NACH DEM SCHARFSCHALTEN liefert sie
* `null` und diese Rueckfallkette greift — WINDOWS #30)
* Transport-Prioritaet JE VERSAND:
* 1. SmtpConfig des Empfaenger-Mandanten (gebunden, `getDecryptedSmtpConfig(tenantId)`)
* 2. Env vars: MAIL_HOST / MAIL_PORT / MAIL_USER / MAIL_PASS
* 3. Legacy env vars: TESSERA_SMTP_HOST / TESSERA_SMTP_PORT / TESSERA_SMTP_USER / TESSERA_SMTP_PASSWORD
* 4. Final hardcoded fallback: localhost:1025 (Mailhog / dev default)
*
* The factory is async because loadAnySmtpConfigForStartupTransport() reads
* from the DB. No circular import risk: MailModule → SettingsModule →
* CalendarModule (no reverse edges).
* No circular import risk: MailModule -> SettingsModule -> CalendarModule
* (no reverse edges). `@nestjs-modules/mailer` bleibt als Paket installiert,
* wird aber von keinem Modul mehr benutzt.
*/
@Module({
imports: [
SettingsModule,
MailerModule.forRootAsync({
imports: [SettingsModule],
useFactory: async (settingsService: SettingsService, configService: ConfigService) => {
// Priority 1: DB SmtpConfig — loadAnySmtpConfigForStartupTransport()
// stays bewusst UNGEBUNDEN (findFirst, no tenant context at boot).
const db = await settingsService.loadAnySmtpConfigForStartupTransport();
if (db) {
// T-07-11: DB password used only to build transport; never logged
return {
transport: {
host: db.host,
port: db.port,
secure: db.secure,
requireTLS: db.requireTLS,
auth: db.username
? { user: db.username, pass: db.password ?? '' }
: undefined,
},
defaults: {
from: db.fromAddress,
},
};
}
// Priority 2: Env vars (new names first, legacy TESSERA_SMTP_* as secondary fallback)
const host =
configService.get<string>('MAIL_HOST') ??
configService.get<string>('TESSERA_SMTP_HOST') ??
'localhost';
const port =
configService.get<number>('MAIL_PORT') ??
configService.get<number>('TESSERA_SMTP_PORT') ??
1025;
const user =
configService.get<string>('MAIL_USER') ??
configService.get<string>('TESSERA_SMTP_USER') ??
'';
const pass =
configService.get<string>('MAIL_PASS') ??
configService.get<string>('TESSERA_SMTP_PASSWORD') ??
'';
const from =
configService.get<string>('TESSERA_SMTP_FROM') ??
'Tessera <tessera@tessera.local>';
const secure =
configService.get<string>('TESSERA_SMTP_SECURE', 'false') === 'true';
return {
transport: {
host,
port,
secure,
auth: { user, pass },
},
defaults: { from },
};
},
inject: [SettingsService, ConfigService],
}),
],
imports: [SettingsModule],
providers: [MailService],
exports: [MailService],
})
export class MailModule {}
+199
View File
@@ -0,0 +1,199 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import * as nodemailer from 'nodemailer';
import { MailService } from './mail.service';
/**
* MailService.spec — NEU (260914-eym, Etappe 3c, WINDOWS #30). Der Bereich
* `mail` hatte VOR diesem Durchlauf KEINE Testdatei. Festgenagelt wird die
* Bauform "Transport je Versand nach Mandant des Empfaengers":
*
* 1. IDENTITAET FUER EINEN MANDANTEN MIT SmtpConfig: Transport aus GENAU
* dieser Config, `from` = deren fromAddress, `close()` gerufen.
* 2. Mandant OHNE SmtpConfig: die bisherige Umgebungs-Kette (MAIL_* vor
* TESSERA_SMTP_* vor localhost:1025), `from` aus TESSERA_SMTP_FROM bzw.
* Vorgabe.
* 3. ZWEI Mandanten nacheinander -> zwei verschiedene Transporte, keiner
* sieht die Zugangsdaten des anderen (T-GWH-03 geschlossen).
* 4. `sendMail` wirft -> kein Throw nach aussen (T-02-12), Fehler
* protokolliert, `close()` trotzdem gerufen.
*
* `nodemailer` wird per `vi.mock` ersetzt (wie in settings.service.spec.ts)
* — kein echter Transport, lokal gibt es keinen `mailhog`.
*/
let mockSendMail = vi.fn(async (_mail: unknown) => ({}));
const mockClose = vi.fn();
vi.mock('nodemailer', () => ({
createTransport: vi.fn(() => ({
sendMail: (...args: unknown[]) => (mockSendMail as any)(...args),
close: (...args: unknown[]) => (mockClose as any)(...args),
})),
}));
interface FakeDecrypted {
host: string;
port: number;
encryption: string;
username: string | null;
fromAddress: string;
decryptedPassword: string | null;
}
function makeFakeSettings(configsByTenant: Record<string, FakeDecrypted>) {
return {
getDecryptedSmtpConfig: vi.fn(async (tenantId: string) => configsByTenant[tenantId] ?? null),
};
}
function makeFakeConfig(values: Record<string, string | number | undefined>) {
return {
get: vi.fn((key: string, fallback?: unknown) => (values[key] !== undefined ? values[key] : fallback)),
};
}
const configA: FakeDecrypted = {
host: 'smtp-a.example.invalid',
port: 465,
encryption: 'ssl-tls',
username: 'user-a',
fromAddress: 'noreply@a.example.invalid',
decryptedPassword: 'geheim-a',
};
const configB: FakeDecrypted = {
host: 'smtp-b.example.invalid',
port: 587,
encryption: 'starttls',
username: 'user-b',
fromAddress: 'noreply@b.example.invalid',
decryptedPassword: 'geheim-b',
};
beforeEach(() => {
vi.clearAllMocks();
mockSendMail = vi.fn(async (_mail: unknown) => ({}));
});
describe('MailService — Transport je Versand nach Mandant des Empfaengers (260914-eym, WINDOWS #30)', () => {
it('Test 1: Mandant MIT SmtpConfig -> getDecryptedSmtpConfig genau einmal mit dieser tenantId, createTransport mit deren host/port/secure/requireTLS/auth, from = deren fromAddress, close() gerufen (Identitaet zu heute)', async () => {
const settings = makeFakeSettings({ t1: configA });
const config = makeFakeConfig({ MAIL_HOST: 'env-darf-nicht-greifen' });
const service = new MailService(settings as any, config as any);
await service.sendPasswordResetEmail('alice@a.example.invalid', 'tok-1', 't1');
expect(settings.getDecryptedSmtpConfig).toHaveBeenCalledTimes(1);
expect(settings.getDecryptedSmtpConfig).toHaveBeenCalledWith('t1');
expect(vi.mocked(nodemailer.createTransport)).toHaveBeenCalledTimes(1);
expect(vi.mocked(nodemailer.createTransport)).toHaveBeenCalledWith({
host: 'smtp-a.example.invalid',
port: 465,
secure: true,
requireTLS: false,
auth: { user: 'user-a', pass: 'geheim-a' },
});
expect(mockSendMail).toHaveBeenCalledTimes(1);
const sent = mockSendMail.mock.calls[0][0] as any;
expect(sent.from).toBe('noreply@a.example.invalid');
expect(sent.to).toBe('alice@a.example.invalid');
expect(sent.text).toContain('/reset-password/tok-1');
expect(mockClose).toHaveBeenCalledTimes(1);
});
it('Test 2: Mandant OHNE SmtpConfig -> Umgebungs-Kette: MAIL_* vor TESSERA_SMTP_* vor localhost:1025, from aus TESSERA_SMTP_FROM bzw. Vorgabe', async () => {
// (a) MAIL_* gesetzt -> gewinnt vor TESSERA_SMTP_*
const svcA = new MailService(
makeFakeSettings({}) as any,
makeFakeConfig({
MAIL_HOST: 'mail.example.invalid',
MAIL_PORT: 2525,
MAIL_USER: 'mail-user',
MAIL_PASS: 'mail-pass',
TESSERA_SMTP_HOST: 'legacy.example.invalid',
TESSERA_SMTP_FROM: 'Tessera <from@example.invalid>',
}) as any,
);
await svcA.sendPasswordResetEmail('x@example.invalid', 'tok', 't-ohne');
expect(vi.mocked(nodemailer.createTransport)).toHaveBeenLastCalledWith({
host: 'mail.example.invalid',
port: 2525,
secure: false,
auth: { user: 'mail-user', pass: 'mail-pass' },
});
expect((mockSendMail.mock.calls.at(-1)![0] as any).from).toBe('Tessera <from@example.invalid>');
// (b) nur TESSERA_SMTP_* gesetzt -> zweite Stufe
const svcB = new MailService(
makeFakeSettings({}) as any,
makeFakeConfig({
TESSERA_SMTP_HOST: 'legacy.example.invalid',
TESSERA_SMTP_PORT: 587,
TESSERA_SMTP_USER: 'legacy-user',
TESSERA_SMTP_PASSWORD: 'legacy-pass',
TESSERA_SMTP_SECURE: 'true',
}) as any,
);
await svcB.sendPasswordResetEmail('x@example.invalid', 'tok', 't-ohne');
expect(vi.mocked(nodemailer.createTransport)).toHaveBeenLastCalledWith({
host: 'legacy.example.invalid',
port: 587,
secure: true,
auth: { user: 'legacy-user', pass: 'legacy-pass' },
});
expect((mockSendMail.mock.calls.at(-1)![0] as any).from).toBe('Tessera <tessera@tessera.local>');
// (c) nichts gesetzt -> localhost:1025
const svcC = new MailService(makeFakeSettings({}) as any, makeFakeConfig({}) as any);
await svcC.sendPasswordResetEmail('x@example.invalid', 'tok', 't-ohne');
expect(vi.mocked(nodemailer.createTransport)).toHaveBeenLastCalledWith({
host: 'localhost',
port: 1025,
secure: false,
auth: { user: '', pass: '' },
});
expect(mockClose).toHaveBeenCalledTimes(3);
});
it('Test 3: zwei Mandanten nacheinander -> zwei verschiedene Transporte, keiner sieht die Zugangsdaten des anderen (T-GWH-03 geschlossen)', async () => {
const settings = makeFakeSettings({ t1: configA, t2: configB });
const service = new MailService(settings as any, makeFakeConfig({}) as any);
await service.sendPasswordResetEmail('alice@a.example.invalid', 'tok-a', 't1');
await service.sendWelcomeEmail('bob@b.example.invalid', 'bob', 't2');
expect(settings.getDecryptedSmtpConfig.mock.calls.map((c) => c[0])).toEqual(['t1', 't2']);
const transports = vi.mocked(nodemailer.createTransport).mock.calls.map((c) => c[0] as any);
expect(transports).toHaveLength(2);
expect(transports[0].host).toBe('smtp-a.example.invalid');
expect(transports[0].auth).toEqual({ user: 'user-a', pass: 'geheim-a' });
expect(transports[1].host).toBe('smtp-b.example.invalid');
expect(transports[1].requireTLS).toBe(true);
expect(transports[1].auth).toEqual({ user: 'user-b', pass: 'geheim-b' });
expect(JSON.stringify(transports[0])).not.toContain('geheim-b');
expect(JSON.stringify(transports[1])).not.toContain('geheim-a');
const sentMails = mockSendMail.mock.calls.map((c) => c[0] as any);
expect(sentMails[0].from).toBe('noreply@a.example.invalid');
expect(sentMails[1].from).toBe('noreply@b.example.invalid');
expect(sentMails[1].text).toContain('bob');
expect(mockClose).toHaveBeenCalledTimes(2);
});
it('Test 4: sendMail wirft -> kein Throw nach aussen (T-02-12), Fehler protokolliert ohne Kennwort, close() trotzdem gerufen', async () => {
mockSendMail = vi.fn(async () => {
throw new Error('ECONNREFUSED smtp-a.example.invalid');
});
const settings = makeFakeSettings({ t1: configA });
const service = new MailService(settings as any, makeFakeConfig({}) as any);
const errorSpy = vi.spyOn((service as any).logger, 'error').mockImplementation(() => undefined);
await expect(
service.sendPasswordResetEmail('alice@a.example.invalid', 'tok-1', 't1'),
).resolves.toBeUndefined();
expect(errorSpy).toHaveBeenCalledTimes(1);
expect(String(errorSpy.mock.calls[0][0])).toContain('Failed to send Password reset email to alice@a.example.invalid');
expect(JSON.stringify(errorSpy.mock.calls[0])).not.toContain('geheim-a');
expect(mockClose).toHaveBeenCalledTimes(1);
});
});
+156 -31
View File
@@ -1,6 +1,52 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { MailerService } from '@nestjs-modules/mailer';
import * as nodemailer from 'nodemailer';
import { SettingsService } from '../settings/settings.service';
/**
* MailService — Systemmails (Kennwort-Zuruecksetzung, Willkommensmail).
*
* TRANSPORT JE VERSAND NACH MANDANT DES EMPFAENGERS (Etappe 3c, 260914-eym,
* WINDOWS #30 GESCHLOSSEN):
*
* Vorher baute `mail.module.ts` beim Start EINEN Transport aus einer
* beliebigen SmtpConfig (`findFirst()` ohne Bedingung) und alle
* Systemmails aller Mandanten liefen ueber den SMTP-Server und die
* Absenderadresse DIESES einen Mandanten (T-GWH-03). Zwei Gruende, warum
* der Transport jetzt JE VERSAND entsteht:
*
* 1. Pitfall 3 (Research): ein Start-Transport kann nicht wechseln — eine
* Aenderung der SMTP-Einstellungen im UI griff erst nach einem Neustart.
* 2. Mandantentrennung: der Mandant des EMPFAENGERS entscheidet, welche
* Zugangsdaten benutzt werden — nie ein beliebiger. Der Mandant ist an
* der einzigen produktiven Versandstelle bekannt
* (`AuthService.requestPasswordReset`: `user.tenantId` steht eine Zeile
* vor dem Versand). Vorlage: `DkvMailService`/`TenderMailService`
* (`getDecryptedSmtpConfig(tenantId)`, gebunden, `nodemailer.createTransport`,
* `transport.close()` im `finally`).
*
* Die Umgebungs-Kette (MAIL_* -> TESSERA_SMTP_* -> localhost:1025) ist NUR
* noch der Rueckfall fuer Mandanten OHNE eigene SmtpConfig — nicht mehr
* der Ersatz fuer einen verstummten Startpfad. Es gibt keinen Startpfad
* mehr, deshalb braucht `SmtpConfig` auch keine `system_read_policy`.
*
* Was mit EINEM Mandanten identisch bleibt (mail.service.spec.ts): Mandant
* MIT SmtpConfig -> Transport aus GENAU dieser Config, `from` = deren
* fromAddress; Mandant OHNE -> dieselbe Umgebungs-Kette wie bisher;
* Transportfehler werden weiter verschluckt und protokolliert (T-02-12 —
* der Anmeldeweg antwortet weiter 200, keine E-Mail-Enumeration).
*
* Sicherheit: das entschluesselte Kennwort existiert nur im Rumpf von
* `resolveTransport`/`sendViaTenantTransport` und wird nie protokolliert
* (T-07-10/T-07-11); Protokollzeilen nennen nur Quelle (tenant/env) und
* Empfaenger.
*/
interface ResolvedTransport {
source: 'tenant' | 'env';
options: nodemailer.TransportOptions & Record<string, unknown>;
from: string;
}
@Injectable()
export class MailService {
@@ -8,8 +54,8 @@ export class MailService {
private readonly appUrl: string;
constructor(
private mailerService: MailerService,
private configService: ConfigService,
private readonly settingsService: SettingsService,
private readonly configService: ConfigService,
) {
this.appUrl = this.configService.get<string>(
'TESSERA_APP_URL',
@@ -17,14 +63,113 @@ export class MailService {
);
}
/**
* Transport-Optionen fuer den Mandanten des Empfaengers: die SmtpConfig
* des Mandanten (gebunden ueber `getDecryptedSmtpConfig(tenantId)`),
* sonst die bisherige Umgebungs-Kette aus `mail.module.ts` unveraendert.
*/
private async resolveTransport(tenantId: string): Promise<ResolvedTransport> {
const smtpConfig = await this.settingsService.getDecryptedSmtpConfig(tenantId);
if (smtpConfig) {
return {
source: 'tenant',
options: {
host: smtpConfig.host,
port: smtpConfig.port,
secure: smtpConfig.encryption === 'ssl-tls',
requireTLS: smtpConfig.encryption === 'starttls',
auth: smtpConfig.username
? {
user: smtpConfig.username,
// T-07-10/T-07-11: entschluesseltes Kennwort nur hier, nie protokolliert
pass: smtpConfig.decryptedPassword ?? '',
}
: undefined,
},
from: smtpConfig.fromAddress,
};
}
// Rueckfall: Umgebungsvariablen (neue Namen zuerst, TESSERA_SMTP_* als
// zweite Stufe, zuletzt localhost:1025 — Mailhog / dev default).
const host =
this.configService.get<string>('MAIL_HOST') ??
this.configService.get<string>('TESSERA_SMTP_HOST') ??
'localhost';
const port =
this.configService.get<number>('MAIL_PORT') ??
this.configService.get<number>('TESSERA_SMTP_PORT') ??
1025;
const user =
this.configService.get<string>('MAIL_USER') ??
this.configService.get<string>('TESSERA_SMTP_USER') ??
'';
const pass =
this.configService.get<string>('MAIL_PASS') ??
this.configService.get<string>('TESSERA_SMTP_PASSWORD') ??
'';
const from =
this.configService.get<string>('TESSERA_SMTP_FROM') ??
'Tessera <tessera@tessera.local>';
const secure =
this.configService.get<string>('TESSERA_SMTP_SECURE', 'false') === 'true';
return {
source: 'env',
options: { host, port, secure, auth: { user, pass } },
from,
};
}
/**
* Der eine Versandpfad: Transport je Versand aus `resolveTransport`,
* Fehler verschluckt und protokolliert (T-02-12), `close()` im `finally`
* (WR-01 — keine offenen Verbindungen).
*/
private async sendViaTenantTransport(
tenantId: string,
mail: { to: string; subject: string; text: string },
kind: string,
): Promise<void> {
let transport: nodemailer.Transporter | null = null;
try {
const resolved = await this.resolveTransport(tenantId);
transport = nodemailer.createTransport(resolved.options as any);
await transport.sendMail({
from: resolved.from,
to: mail.to,
subject: mail.subject,
text: mail.text,
});
this.logger.log(`${kind} email sent to ${mail.to} (transport: ${resolved.source})`);
} catch (error) {
// Log but don't throw -- caller returns 200 regardless (T-02-12)
this.logger.error(
`Failed to send ${kind} email to ${mail.to}`,
error instanceof Error ? error.stack : String(error),
);
} finally {
transport?.close();
}
}
/**
* Send a password reset email with a time-limited token link.
* T-02-12: The caller always returns 200 regardless of whether this succeeds
* (no email enumeration).
*
* @param tenantId - Mandant des Empfaengers (entscheidet ueber den SMTP-Transport)
*/
async sendPasswordResetEmail(
email: string,
token: string,
tenantId: string,
locale: string = 'de',
): Promise<void> {
const resetLink = `${this.appUrl}/reset-password/${token}`;
@@ -66,28 +211,20 @@ export class MailService {
'The Tessera Team',
].join('\n');
try {
await this.mailerService.sendMail({
to: email,
subject,
text,
});
this.logger.log(`Password reset email sent to ${email}`);
} catch (error) {
// Log but don't throw -- caller returns 200 regardless (T-02-12)
this.logger.error(
`Failed to send password reset email to ${email}`,
error instanceof Error ? error.stack : String(error),
);
}
await this.sendViaTenantTransport(tenantId, { to: email, subject, text }, 'Password reset');
}
/**
* Send a welcome email to a newly created user (optional).
* Send a welcome email to a newly created user (optional — derzeit ohne
* Aufrufer, gemessen 260914-eym; bleibt als Pfad ueber denselben
* Transport je Versand erhalten).
*
* @param tenantId - Mandant des Empfaengers (entscheidet ueber den SMTP-Transport)
*/
async sendWelcomeEmail(
email: string,
username: string,
tenantId: string,
locale: string = 'de',
): Promise<void> {
const isGerman = locale === 'de';
@@ -117,18 +254,6 @@ export class MailService {
'The Tessera Team',
].join('\n');
try {
await this.mailerService.sendMail({
to: email,
subject,
text,
});
this.logger.log(`Welcome email sent to ${email}`);
} catch (error) {
this.logger.error(
`Failed to send welcome email to ${email}`,
error instanceof Error ? error.stack : String(error),
);
}
await this.sendViaTenantTransport(tenantId, { to: email, subject, text }, 'Welcome');
}
}
@@ -133,12 +133,29 @@ const RELATION_SPEC_EXCEPTIONS = new Set<string>(['apps/api/src/tenders/backfill
* ist die Liste kein "mindestens", sondern ein "genau": jede Datei mit
* `forSystem(` ausserhalb der Liste, jede Abweichung der Zahl (auch ein
* ZWEITER Aufruf in einer erlaubten Datei) und jeder veraltete Eintrag
* (Datei weg oder Zahl gesunken) machen die Spec rot. Aufgabe 1 traegt den
* ersten Aufrufer (DKV-Planer-Startpfad); Aufgabe 2 erweitert auf die vier
* Dateien der sechs Hintergrunddienst-Faelle.
* (Datei weg oder Zahl gesunken) machen die Spec rot.
*
* Die sechs Faelle der Hintergrunddienst-Falle
* (docs/mandantentrennung-zugriffsklassifikation.md) und wo sie stehen:
* (1) DKV-Planer-Startpfad -> dkv.service.ts (1 Aufruf,
* `loadActiveConfigsForScheduler`); (2) Mailmodul-Startpfad -> NICHT in der
* Liste: der Startpfad ist ENTFERNT, `MailService` baut je Versand einen
* Transport gebunden ueber `getDecryptedSmtpConfig(tenantId)`
* (settings.service.ts/mail.service.ts rufen `forSystem` nie); (3) ldap ->
* ldap-config.service.ts (2 Aufrufe: `getAllActiveConfigs` und die
* Nachverschluesselung in `onApplicationBootstrap`, je eigene Methode);
* (4) tender-digest -> tender-digest.scheduler.ts (1, Kandidatenabfrage);
* (5) tender-matching -> tender-matching.service.ts (1, Profilabfrage);
* (6) admin-seed -> NICHT in der Liste: der einzige Lesezugriff ausserhalb
* der Schleife ist `tenant.findMany` auf `Tenant`, das in keiner Migration
* eine Regel traegt — kein Systemkontext noetig, Datei unveraendert.
* Summe: 4 Dateien, 5 Aufrufe.
*/
const FORSYSTEM_ALLOWED_CALL_SITES = new Map<string, number>([
['apps/api/src/dkv/dkv.service.ts', 1],
['apps/api/src/ldap/ldap-config.service.ts', 2],
['apps/api/src/tenders/tender-digest.scheduler.ts', 1],
['apps/api/src/tenders/tender-matching.service.ts', 1],
]);
const STAND_TOKENS = ['gebunden', 'ungebunden', 'gemischt', 'system-gebunden'] as const;
@@ -1023,13 +1040,13 @@ class ProbeService {
expect(unresolvedResult.unresolvedRelationSpecValues).toHaveLength(1);
expect(unresolvedResult.unresolvedRelationSpecValues[0]).toContain('IMPORTED_SELECT');
});
it('Probe C (260914-eym, Systemkontext): `include: { fieldMappings: true }` auf einem forSystem(-Klienten liefert systemModels mit ldapConfig UND ldapFieldMapping, beide weder in bound noch unbound, Stand system-gebunden', () => {
it('Probe C (260914-eym, Systemkontext, Empfaengername absichtlich nicht systemPrisma): `include: { fieldMappings: true }` auf einem forSystem(-Klienten liefert systemModels mit ldapConfig UND ldapFieldMapping, beide weder in bound noch unbound, Stand system-gebunden', () => {
const probe = `
class ProbeService {
constructor(private readonly prisma: any) {}
async getAllActiveConfigs() {
const systemPrisma = forSystem(this.prisma) as any;
return systemPrisma.ldapConfig.findMany({
const sysPrisma = forSystem(this.prisma) as any;
return sysPrisma.ldapConfig.findMany({
where: { isActive: true },
include: { fieldMappings: true },
});
@@ -1052,8 +1069,8 @@ class ProbeService {
class ProbeService {
constructor(private readonly prisma: any) {}
async readAll() {
const systemPrisma = forSystem(this.prisma) as any;
return systemPrisma.ldapConfig.findMany();
const sysPrisma = forSystem(this.prisma) as any;
return sysPrisma.ldapConfig.findMany();
}
async writeOne(tenantId: string) {
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
@@ -1070,8 +1087,8 @@ class ProbeService {
class ProbeService {
constructor(private readonly prisma: any) {}
async readAll() {
const systemPrisma = forSystem(this.prisma) as any;
return systemPrisma.ldapConfig.findMany();
const sysPrisma = forSystem(this.prisma) as any;
return sysPrisma.ldapConfig.findMany();
}
async readRaw() {
return this.prisma.ldapConfig.findMany();
+7 -87
View File
@@ -7,11 +7,13 @@ import { forTenant } from '../prisma/prisma-tenant.extension';
* SettingsService.spec — NEU (260911-gwh). Der Bereich `settings` hatte VOR
* diesem Lauf KEINE Testdatei (Befund J). Zwei-Klienten-Nachbau, aber mit
* einer GRENZE als Bauform (anders als `favorites`): der UNGEBUNDENE Nachbau
* bietet fuer `smtpConfig` AUSSCHLIESSLICH `findFirst` (der Startpfad) —
* KEIN `findUnique`, KEIN `upsert`; der GEBUNDENE Klient bietet
* AUSSCHLIESSLICH `findUnique`/`upsert` — KEIN `findFirst`. Ein gebundener
* Startpfad scheitert damit ebenso hart wie ein ungebundener Anfrageweg
* ("X is not a function" statt eines stillen Fallbacks).
* bietet fuer `smtpConfig` AUSSCHLIESSLICH `findFirst` — KEIN `findUnique`,
* KEIN `upsert`; der GEBUNDENE Klient bietet AUSSCHLIESSLICH
* `findUnique`/`upsert` — KEIN `findFirst`. Ein ungebundener Anfrageweg
* scheitert damit hart ("X is not a function" statt eines stillen
* Fallbacks). Der ungebundene Startpfad des Mailmoduls (findFirst beim
* Boot) und sein describe-Block sind seit 260914-eym (WINDOWS #30)
* GELOESCHT — der ungebundene Nachbau bleibt als Falsifizierungsform stehen.
*
* `nodemailer` wird per `vi.mock` ersetzt — kein echter Transport (lokal
* gibt es keinen `mailhog`).
@@ -416,88 +418,6 @@ describe('SettingsService — Bindung an forTenant() (260911-gwh)', () => {
});
});
describe('loadAnySmtpConfigForStartupTransport (Startpfad, bewusst ungebunden)', () => {
it('laeuft ueber den UNGEBUNDENEN Nachbau (findFirst), liefert secure/requireTLS/entschluesseltes Kennwort', async () => {
const prisma = makeFakePrisma([
{
id: 'smtp-a',
tenantId: 't1',
host: 'smtp-a.example.invalid',
port: 465,
encryption: 'ssl-tls',
username: 'user-a',
encryptedPassword: 'enc(geheim)',
fromAddress: 'a@example.invalid',
},
]);
const crypto = makeFakeCrypto();
const service = new SettingsService(prisma as any, crypto as any);
const result = await service.loadAnySmtpConfigForStartupTransport();
expect(result).toEqual({
host: 'smtp-a.example.invalid',
port: 465,
secure: true,
requireTLS: false,
username: 'user-a',
password: 'geheim',
fromAddress: 'a@example.invalid',
});
});
it('requireTLS bei starttls', async () => {
const prisma = makeFakePrisma([
{
id: 'smtp-a',
tenantId: 't1',
host: 'smtp-a.example.invalid',
port: 587,
encryption: 'starttls',
username: null,
encryptedPassword: null,
fromAddress: 'a@example.invalid',
},
]);
const service = new SettingsService(prisma as any, makeFakeCrypto() as any);
const result = await service.loadAnySmtpConfigForStartupTransport();
expect(result?.secure).toBe(false);
expect(result?.requireTLS).toBe(true);
});
it('leerer Nachbau -> null', async () => {
const prisma = makeFakePrisma([]);
const service = new SettingsService(prisma as any, makeFakeCrypto() as any);
const result = await service.loadAnySmtpConfigForStartupTransport();
expect(result).toBeNull();
});
it('Null-Klienten-Nachweis: der Startpfad erzeugt KEINEN gebundenen Klienten (gemessen, nicht behauptet)', async () => {
const prisma = makeFakePrisma([
{
id: 'smtp-a',
tenantId: 't1',
host: 'smtp-a.example.invalid',
port: 587,
encryption: 'starttls',
username: null,
encryptedPassword: null,
fromAddress: 'a@example.invalid',
},
]);
const service = new SettingsService(prisma as any, makeFakeCrypto() as any);
vi.mocked(forTenant).mockClear();
await service.loadAnySmtpConfigForStartupTransport();
expect(vi.mocked(forTenant).mock.calls.length).toBe(0);
});
});
describe('Wachhund je Anfrageweg', () => {
const storedRow: FakeSmtpRow = {
id: 'smtp-a',
+4 -73
View File
@@ -91,8 +91,10 @@ export class SettingsService {
/**
* Internal: Get the decrypted SMTP config for a tenant.
* Used by DkvMailService/TenderMailService to build a nodemailer transport
* at send time — the ONLY send path (Befund K, 260909-laa/260909-mir).
* Used by DkvMailService/TenderMailService — and seit 260914-eym auch von
* MailService (Systemmails, Transport je Versand nach Mandant des
* Empfaengers, WINDOWS #30) — to build a nodemailer transport at send
* time — the ONLY send path (Befund K, 260909-laa/260909-mir).
* NEVER log the decrypted password (T-07-10 / T-05-13).
*
* Mandantengebunden seit 260911-gwh (Aufgabe 2): EIN Klient
@@ -190,75 +192,4 @@ export class SettingsService {
return { success: false };
}
}
/**
* Tenant-agnostic startup accessor for the MailModule factory.
*
* BLEIBT bewusst UNGEBUNDEN (260911-gwh, sechster Fall der
* Hintergrunddienst-Falle — gleicher Bauart wie
* `DkvService.loadAnyActiveConfigForScheduler()`, WINDOWS #21, siehe
* dessen Kopfkommentar als Vorlage). Zwei Zustaende, beide gehoeren
* genannt:
*
* - HEUTE bereits falsch, nicht nur ungenau: `findFirst()` ohne jede
* Bedingung zieht bei mehreren Mandanten den SMTP-Server und die
* Absenderadresse EINES beliebigen Mandanten fuer ALLE
* Kennwort-Zuruecksetzungs- und Willkommensmails ALLER Mandanten
* (T-GWH-03 — Nutzung fremder Zugangsdaten, nicht nur Sichtbarkeit).
* - NACH DEM SCHARFSCHALTEN (WINDOWS #18) liefert dieselbe Abfrage
* `null`, `mail.module.ts` faellt auf Umgebungsvariablen und zuletzt
* `localhost:1025` zurueck — ein FALSCHER, aber vorhandener Transport
* statt einer Meldung; `MailService` faengt jeden Transportfehler
* (T-02-12) und der Controller antwortet `200`. Das Verstummen ist
* damit DOPPELT verdeckt: erst durch die Rueckfallkette, dann durch
* das Verschlucken im Versand. Das ist die Unsymmetrie zu `ldap`
* (`getAllActiveConfigs`, heute korrekt, verstummt erst spaeter) UND zu
* `dkv` (WINDOWS #21, heute bereits falsch, verstummt spaeter MIT
* Protokollzeile) — hier: heute bereits falsch, verstummt spaeter OHNE
* Protokollzeile.
*
* Binden wuerde diesen Pfad garantiert leer laufen lassen (beim Start
* gibt es strukturell keinen Mandantenkontext). Der Umbau auf Transport
* je Versand aus `getDecryptedSmtpConfig(tenantId)` — die Form, die
* `DkvMailService`/`TenderMailService` bereits haben, `MailService`
* muesste den Mandanten nur von `requestPasswordReset` entgegennehmen —
* ist eine Funktionsaenderung (Umbau des Mailmoduls), KEIN Bindungsumbau,
* NICHT dieser Auftrag. Entscheidung: EIGENER Ledger-Eintrag statt
* Anschluss an #21 (andere Datei, andere Reparatur, andere
* Verdeckungsform) — siehe WINDOWS #30 und
* `docs/mandantentrennung-etappe2-fehlerrichtung.md`, Abschnitt
* "## Bereich settings", (s4)(a).
*
* D-06: MailModule reads this at startup (priority 1) and falls back to env vars (priority 2).
* T-07-11: Decrypted password is used only to build the transport — never logged.
*/
async loadAnySmtpConfigForStartupTransport(): Promise<{
host: string;
port: number;
secure: boolean;
requireTLS: boolean;
username: string | null;
password: string | null;
fromAddress: string;
} | null> {
const config = await this.prisma.smtpConfig.findFirst();
if (!config) return null;
let password: string | null = null;
if (config.encryptedPassword) {
// T-07-11: Used only to build transport at startup; never logged
password = this.crypto.decrypt(config.encryptedPassword);
}
return {
host: config.host,
port: config.port,
secure: config.encryption === 'ssl-tls',
requireTLS: config.encryption === 'starttls',
username: config.username,
password,
fromAddress: config.fromAddress,
};
}
}
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { forTenant } from '../prisma/prisma-tenant.extension';
import { forSystem, forTenant } from '../prisma/prisma-tenant.extension';
import { TenderDigestScheduler } from './tender-digest.scheduler';
/**
@@ -29,6 +29,8 @@ import { TenderDigestScheduler } from './tender-digest.scheduler';
*/
vi.mock('../prisma/prisma-tenant.extension', () => ({
forTenant: vi.fn((prisma: any, tenantId: string) => prisma.__makeBoundClient(tenantId)),
// Systemkontext (260914-eym): die Kandidatenabfrage laeuft ueber forSystem().
forSystem: vi.fn((prisma: any) => prisma.__makeSystemClient()),
}));
const MONDAY = new Date('2026-07-27T10:00:00Z');
@@ -84,6 +86,7 @@ function makeFakePrisma(opts: {
};
const modelsByName: Record<string, any> = { tenderMatch, tenderNotificationPref, user };
const systemCallLog: { model: string; method: string }[] = [];
return {
tenderMatch,
@@ -91,6 +94,19 @@ function makeFakePrisma(opts: {
user,
__store: { matches, prefs, users },
__boundCallLog: boundCallLog,
__systemCallLog: systemCallLog,
// Systemkontext-Klient (260914-eym): protokolliert in __systemCallLog,
// tenderMatch.findMany unveraendert (dieselbe Fake-Implementierung).
__makeSystemClient() {
return {
tenderMatch: {
findMany: async (args: any) => {
systemCallLog.push({ model: 'tenderMatch', method: 'findMany' });
return tenderMatch.findMany(args);
},
},
};
},
__makeBoundClient(tenantId: string) {
const bound: any = {};
for (const [modelName, model] of Object.entries(modelsByName)) {
@@ -380,6 +396,26 @@ describe('TenderDigestScheduler — Bindung an forTenant() (260909-laa)', () =>
expect(forTenant).toHaveBeenCalledTimes(1);
});
it('die Kandidatenabfrage laeuft ueber den System-Klienten: __systemCallLog enthaelt genau tenderMatch.findMany, nichts aus der Schleife (260914-eym)', async () => {
vi.mocked(forSystem).mockClear();
const users = new Map([['user-1', { id: 'user-1', email: 'a@tenant.de', tenantId: 'tenant-1' }]]);
const prefs = new Map([['user-1', { userId: 'user-1', digestInterval: 'daily' }]]);
const matches = [makeMatch({ userId: 'user-1', tenantId: 'tenant-1' })];
const prisma = makeFakePrisma({ matches, prefs, users });
const mail = { sendDigest: vi.fn().mockResolvedValue(true) };
const scheduler = new TenderDigestScheduler(makeSchedulerRegistry() as any, prisma as any, mail as any);
await scheduler.runDigest(TUESDAY);
expect(forSystem).toHaveBeenCalledTimes(1);
expect(forSystem).toHaveBeenCalledWith(prisma);
expect(prisma.__systemCallLog).toEqual([{ model: 'tenderMatch', method: 'findMany' }]);
// Die Schleife (Praeferenz, Treffer, Benutzer, Stempelung) lief gebunden,
// nicht ueber den System-Klienten.
expect(prisma.__boundCallLog.length).toBeGreaterThan(0);
expect(mail.sendDigest).toHaveBeenCalledTimes(1);
});
it('die Zugriffe je Kandidatenzeile binden an den Mandanten DIESER Zeile — Zugriffe innerhalb der Schleife laufen auf dem gebundenen Client', async () => {
const users = new Map([['user-1', { id: 'user-1', email: 'a@tenant.de', tenantId: 'tenant-1' }]]);
const prefs = new Map([['user-1', { userId: 'user-1', digestInterval: 'daily' }]]);
@@ -1,7 +1,7 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { SchedulerRegistry } from '@nestjs/schedule';
import { PrismaService } from '../prisma/prisma.service';
import { forTenant } from '../prisma/prisma-tenant.extension';
import { forSystem, forTenant } from '../prisma/prisma-tenant.extension';
import { TenderMailItem, TenderMailService } from './tender-mail.service';
/**
@@ -105,9 +105,13 @@ export class TenderDigestScheduler implements OnModuleInit {
async runDigest(now: Date = new Date()): Promise<void> {
// Candidate users: distinct userId with at least one un-notified match,
// across ALL tenants — a single findMany, never a per-tenant iteration.
// BEWUSST UNGEBUNDEN (260909-laa, Aufgabe 3) — der bewusste Fan-out
// über alle Mandanten dieses Bereichs; Etappe-3-Uebergabe (Systemkontext
// für Hintergrundläufe wird dort entschieden, hier NICHT vorweggenommen).
// SYSTEMGEBUNDEN (Etappe 3c, 260914-eym; die Etappe-3-Uebergabe aus
// 260909-laa ist damit eingeloest): `forSystem()` liest TenderMatch
// ALLER Mandanten NUR lesend (`system_read_policy ... FOR SELECT`,
// Migration 20260914120000) — ohne diese Regel saehe der Digest nach dem
// Scharfschalten 0 Kandidaten und wuerde stumm. Die Schleife unten
// bleibt je Kandidatenzeile GEBUNDEN (bewusst ohne Benutzer, wie in 3b).
// Eine LEERE Kandidatenliste ist Nichtstun: `notifiedAt` bleibt NULL.
//
// Zusaetzlich das denormalisierte tenantId der Treffer-Zeile mit
// ausgewaehlt (nicht Teil von `distinct`), damit die Schleife unten
@@ -117,7 +121,8 @@ export class TenderDigestScheduler implements OnModuleInit {
// `distinct(['userId'])` liefert dann nur EINE der moeglichen
// tenantId-Werte je Nutzer, welche ist von der internen Zeilenreihenfolge
// abhaengig. Siehe docs/mandantentrennung-etappe2-fehlerrichtung.md.
const candidates = await this.prisma.tenderMatch.findMany({
const systemPrisma = forSystem(this.prisma) as any;
const candidates: { userId: string; tenantId: string }[] = await systemPrisma.tenderMatch.findMany({
where: { notifiedAt: null },
select: { userId: true, tenantId: true },
distinct: ['userId'],
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest';
import { forTenant } from '../prisma/prisma-tenant.extension';
import { forSystem, forTenant } from '../prisma/prisma-tenant.extension';
import { TenderMatchingService } from './tender-matching.service';
/**
@@ -24,6 +24,8 @@ import { TenderMatchingService } from './tender-matching.service';
*/
vi.mock('../prisma/prisma-tenant.extension', () => ({
forTenant: vi.fn((prisma: any, tenantId: string) => prisma.__makeBoundClient(tenantId)),
// Systemkontext (260914-eym): die Profilabfrage laeuft ueber forSystem().
forSystem: vi.fn((prisma: any) => prisma.__makeSystemClient()),
}));
const PROFILE_A = {
@@ -125,6 +127,7 @@ function makeFakePrisma(opts: {
};
const modelsByName: Record<string, any> = { tenderMatch, user };
const systemCallLog: { model: string; method: string }[] = [];
const prisma = {
tenderSavedSearch,
@@ -133,6 +136,20 @@ function makeFakePrisma(opts: {
user,
__store: { matches, savedSearches, tenders, users },
__boundCallLog: boundCallLog,
__systemCallLog: systemCallLog,
// Systemkontext-Klient (260914-eym): protokolliert in __systemCallLog;
// nur tenderSavedSearch — ein Katalogzugriff ueber diesen Klienten
// wuerde hart scheitern ("tender is undefined").
__makeSystemClient() {
return {
tenderSavedSearch: {
findMany: async () => {
systemCallLog.push({ model: 'tenderSavedSearch', method: 'findMany' });
return tenderSavedSearch.findMany();
},
},
};
},
__makeBoundClient(tenantId: string) {
const bound: any = {};
for (const [modelName, model] of Object.entries(modelsByName)) {
@@ -445,6 +462,23 @@ describe('TenderMatchingService.matchDelta — Bindung an forTenant() (260909-la
expect(forTenant).toHaveBeenCalledWith(prisma, PROFILE_A.tenantId);
});
it('die Profilabfrage laeuft ueber den System-Klienten, der Katalog-Lesezugriff NICHT (weiter roher Client) (260914-eym)', async () => {
vi.mocked(forSystem).mockClear();
const matchingTenderIds = new Set(['new-1']);
const prisma = makeFakePrisma({ savedSearches: [PROFILE_A], matchingTenderIds });
const service = new TenderMatchingService(prisma as any, makeFakeMail() as any);
await service.matchDelta(['new-1']);
expect(forSystem).toHaveBeenCalledTimes(1);
expect(forSystem).toHaveBeenCalledWith(prisma);
expect(prisma.__systemCallLog).toEqual([{ model: 'tenderSavedSearch', method: 'findMany' }]);
// Katalog: roher Client, genau einmal (ein Profil).
expect(prisma.tender.findMany).toHaveBeenCalledTimes(1);
// Treffer-Anlage gebunden.
expect(prisma.__boundCallLog.some((c: any) => c.model === 'tenderMatch' && c.method === 'upsert')).toBe(true);
});
it('die Treffer-Anlage bindet an den Mandanten DES PROFILS — EIN gebundener Client je Profil, nicht je Treffer', async () => {
vi.mocked(forTenant).mockClear();
const matchingTenderIds = new Set(['new-1', 'new-2', 'new-3']);
@@ -1,7 +1,7 @@
import { Injectable, Logger } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { forTenant } from '../prisma/prisma-tenant.extension';
import { forSystem, forTenant } from '../prisma/prisma-tenant.extension';
import { TenderMailService } from './tender-mail.service';
import { buildTenderWhere } from './tender-query.builder';
import type { TenderQueryDto } from './dto/tender-query.dto';
@@ -64,11 +64,17 @@ export class TenderMatchingService {
async matchDelta(newTenderIds: string[]): Promise<void> {
if (!newTenderIds.length) return;
// BEWUSST UNGEBUNDEN (260909-laa, Aufgabe 3) — Profile aller Mandanten
// werden gegen neue Treffer geprueft, der bewusste Fan-out dieses
// Bereichs; Etappe-3-Uebergabe (Systemkontext fuer Hintergrundlaeufe
// wird dort entschieden, hier NICHT vorweggenommen).
const savedSearches = await this.prisma.tenderSavedSearch.findMany();
// SYSTEMGEBUNDEN (Etappe 3c, 260914-eym; die Etappe-3-Uebergabe aus
// 260909-laa ist damit eingeloest): Profile ALLER Mandanten werden gegen
// neue Treffer geprueft — `forSystem()` liest TenderSavedSearch NUR
// lesend (`system_read_policy ... FOR SELECT`, Migration 20260914120000);
// ohne diese Regel saehe der Abgleich nach dem Scharfschalten 0 Profile
// und wuerde stumm. Treffer-Anlage und Sofortmeldung bleiben je Profil
// GEBUNDEN (unten); der Katalog-Lesezugriff (`tender`, D-03) bleibt
// ungebunden. Eine LEERE Profilliste ist Nichtstun (keine Treffer).
const systemPrisma = forSystem(this.prisma) as any;
const savedSearches: Prisma.TenderSavedSearchGetPayload<Record<string, never>>[] =
await systemPrisma.tenderSavedSearch.findMany();
for (const search of savedSearches) {
try {
@@ -11,6 +11,8 @@ import { TenderDigestScheduler } from './tender-digest.scheduler';
*/
vi.mock('../prisma/prisma-tenant.extension', () => ({
forTenant: vi.fn((prisma: any, tenantId: string) => prisma.__makeBoundClient(tenantId)),
// Systemkontext (260914-eym): Profil- und Kandidatenabfrage laufen ueber forSystem().
forSystem: vi.fn((prisma: any) => prisma.__makeSystemClient()),
}));
/**
@@ -136,6 +138,14 @@ function makeSharedFakePrisma(opts: {
user: userModel,
__store: { matches },
__boundCallLog: boundCallLog,
// Systemkontext-Klient (260914-eym): dieselben Fake-Implementierungen,
// ohne Bindungsprotokoll.
__makeSystemClient() {
return {
tenderSavedSearch: { findMany: async () => [savedSearch] },
tenderMatch: { findMany: async (args: any) => tenderMatch.findMany(args) },
};
},
__makeBoundClient(tenantId: string) {
const bound: any = {};
for (const [modelName, model] of Object.entries(modelsByName)) {