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
+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));
}
}