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
+206
View File
@@ -5549,11 +5549,25 @@ async function runSystemContextChecks(adminUrl, scratchRoleUrl, results) {
report(results, 'system-context-migration-gefunden', false, 'Migration "_rls_system_context_read" nicht gefunden');
return;
}
// Mandantenregeln je Tabelle aus IHRER Quellmigration (Aufgabe 2, 260914-eym):
// DkvModuleConfig/TenderMatch aus _rls_remaining_tenant_tables, LdapConfig/
// LdapFieldMapping aus _rls_policies, TenderSavedSearch aus
// _rls_user_dimension_personal_tables.
const remainingTablesMigrationSql = readRemainingTenantTablesMigrationSql();
if (!remainingTablesMigrationSql) {
report(results, 'system-context-remaining-tables-migration-gefunden', false, 'Migration "_rls_remaining_tenant_tables" nicht gefunden');
return;
}
const rlsPoliciesMigrationSql = readRlsPoliciesMigrationSql();
if (!rlsPoliciesMigrationSql) {
report(results, 'system-context-rls-policies-migration-gefunden', false, 'Migration "_rls_policies" nicht gefunden');
return;
}
const userDimensionMigrationSql = readRlsUserDimensionMigrationSql();
if (!userDimensionMigrationSql) {
report(results, 'system-context-user-dimension-migration-gefunden', false, 'Migration "_rls_user_dimension_personal_tables" nicht gefunden');
return;
}
// Vier Funktionsfaelle, je in einer eigenen Transaktion.
const prisma = new PrismaClient({ datasourceUrl: scratchRoleUrl });
@@ -5623,6 +5637,198 @@ async function runSystemContextChecks(adminUrl, scratchRoleUrl, results) {
createAttemptData: { id: 'cfg-system-schreibversuch', tenantId: 'TENANT-A', isActive: true },
updateManyData: { folder: 'SYSTEM-SCHREIBVERSUCH' },
});
// Aufgabe 2 (260914-eym): die vier weiteren Tabellen ueber dieselbe Routine.
// LdapConfig — Mandantenregel aus 20260618112133_rls_policies, alle 15
// skalaren Spalten (text[]-Spalten mit DEFAULT '{}'). MUSS vor
// LdapFieldMapping laufen (deren Regel joint auf "LdapConfig").
await runSystemContextTableCheck({
adminUrl,
scratchRoleUrl,
results,
slug: 'ldapconfig',
tableName: 'LdapConfig',
modelName: 'LdapConfig',
tenantPolicySql: extractPolicySql(rlsPoliciesMigrationSql, 'LdapConfig'),
systemContextMigrationSql,
createTableSql: `
CREATE TABLE "LdapConfig" (
id text PRIMARY KEY,
"tenantId" text NOT NULL UNIQUE,
"serverUrl" text NOT NULL,
"baseDn" text NOT NULL,
"bindDn" text,
"encryptedBindPassword" text,
"searchFilter" text NOT NULL DEFAULT '(objectClass=person)',
"syncIntervalMin" integer NOT NULL DEFAULT 0,
"isActive" boolean NOT NULL DEFAULT true,
"tlsRejectUnauthorized" boolean NOT NULL DEFAULT true,
"groupFilterDns" text[] NOT NULL DEFAULT '{}',
"userExcludeList" text[] NOT NULL DEFAULT '{}',
"lastSyncAt" timestamp(3),
"createdAt" timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
);
`,
seedSql: `
INSERT INTO "LdapConfig" (id, "tenantId", "serverUrl", "baseDn", "isActive") VALUES
('cfg-a', 'TENANT-A', 'ldap://a.example.invalid', 'dc=a', true),
('cfg-b', 'TENANT-B', 'ldap://b.example.invalid', 'dc=b', true);
`,
tenantOfRow: (row) => row.tenantId,
createAttemptData: {
id: 'cfg-system-schreibversuch',
tenantId: 'TENANT-A',
serverUrl: 'ldap://x.example.invalid',
baseDn: 'dc=x',
},
updateManyData: { searchFilter: '(cn=SYSTEM-SCHREIBVERSUCH)' },
});
// LdapFieldMapping — Regel aus derselben Datei (Join auf LdapConfig), 6
// Spalten, ohne DROP der Elternzeilen (cfg-a/cfg-b bleiben stehen); der
// Mandant einer Zeile ergibt sich ueber ldapConfigId.
const tenantOfMapping = (row) => (row.ldapConfigId === 'cfg-a' ? 'TENANT-A' : row.ldapConfigId === 'cfg-b' ? 'TENANT-B' : row.ldapConfigId);
await runSystemContextTableCheck({
adminUrl,
scratchRoleUrl,
results,
slug: 'ldapfieldmapping',
tableName: 'LdapFieldMapping',
modelName: 'LdapFieldMapping',
tenantPolicySql: extractPolicySql(rlsPoliciesMigrationSql, 'LdapFieldMapping'),
systemContextMigrationSql,
createTableSql: `
CREATE TABLE "LdapFieldMapping" (
id text PRIMARY KEY,
"ldapConfigId" text NOT NULL REFERENCES "LdapConfig"(id) ON DELETE CASCADE,
"ldapField" text NOT NULL,
"tesseraField" text NOT NULL,
"isDefault" boolean NOT NULL DEFAULT false,
"createdAt" timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE ("ldapConfigId", "ldapField")
);
`,
seedSql: `
INSERT INTO "LdapFieldMapping" (id, "ldapConfigId", "ldapField", "tesseraField") VALUES
('fm-a', 'cfg-a', 'mail', 'email'),
('fm-b', 'cfg-b', 'mail', 'email');
`,
tenantOfRow: tenantOfMapping,
createAttemptData: {
id: 'fm-system-schreibversuch',
ldapConfigId: 'cfg-a',
ldapField: 'sn',
tesseraField: 'lastName',
},
updateManyData: { tesseraField: 'SYSTEM-SCHREIBVERSUCH' },
});
// Relations-Kennung: die #27-Form unter Systemkontext — ldapConfig.findMany
// mit include: { fieldMappings } liefert beide Mandanten und je genau eine
// Zuordnung (der Pfad von LdapConfigService.getAllActiveConfigs()).
{
const prisma = new PrismaClient({ datasourceUrl: scratchRoleUrl });
try {
const rows = await buildInlineSystemClient(prisma).ldapConfig.findMany({
where: { isActive: true },
include: { fieldMappings: true },
orderBy: { tenantId: 'asc' },
});
const shape = rows.map((r) => `${r.tenantId}:${r.fieldMappings.length}`);
report(
results,
'ldapconfig-systemkontext-include-fieldmappings-beider-mandanten',
rows.length === 2 && shape.join(',') === 'TENANT-A:1,TENANT-B:1',
`system.ldapConfig.findMany({ where: { isActive: true }, include: { fieldMappings: true } }) liefert ${rows.length} Zeile(n): ${JSON.stringify(shape)} (Mandant:Anzahl Zuordnungen)`,
);
} finally {
await prisma.$disconnect();
}
}
// TenderMatch — Regel aus 20260909140000_rls_remaining_tenant_tables, 8
// Spalten, je Mandant eine Zeile mit notifiedAt NULL (die Kandidatenform
// des Digest). Keine Fremdschluessel in der Wegwerf-Tabelle — gemessen
// wird die Regel, nicht die Referenz.
await runSystemContextTableCheck({
adminUrl,
scratchRoleUrl,
results,
slug: 'tendermatch',
tableName: 'TenderMatch',
modelName: 'TenderMatch',
tenantPolicySql: extractPolicySql(remainingTablesMigrationSql, 'TenderMatch'),
systemContextMigrationSql,
createTableSql: `
CREATE TABLE "TenderMatch" (
id text PRIMARY KEY,
"tenderId" text NOT NULL,
"savedSearchId" text NOT NULL,
"userId" text NOT NULL,
"tenantId" text NOT NULL,
"matchedAt" timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"notifiedAt" timestamp(3),
"notifiedChannel" text,
UNIQUE ("tenderId", "savedSearchId")
);
`,
seedSql: `
INSERT INTO "TenderMatch" (id, "tenderId", "savedSearchId", "userId", "tenantId", "notifiedAt") VALUES
('tm-a', 'tender-1', 'ss-a', 'user-a', 'TENANT-A', NULL),
('tm-b', 'tender-1', 'ss-b', 'user-b', 'TENANT-B', NULL);
`,
tenantOfRow: (row) => row.tenantId,
createAttemptData: {
id: 'tm-system-schreibversuch',
tenderId: 'tender-2',
savedSearchId: 'ss-a',
userId: 'user-a',
tenantId: 'TENANT-A',
},
updateManyData: { notifiedChannel: 'SYSTEM-SCHREIBVERSUCH' },
});
// TenderSavedSearch — Regel aus 20260911120000_rls_user_dimension_personal_tables
// (IS-NULL-OR-Form), 8 Spalten.
await runSystemContextTableCheck({
adminUrl,
scratchRoleUrl,
results,
slug: 'tendersavedsearch',
tableName: 'TenderSavedSearch',
modelName: 'TenderSavedSearch',
tenantPolicySql: extractPolicySql(userDimensionMigrationSql, 'TenderSavedSearch'),
systemContextMigrationSql,
createTableSql: `
CREATE TABLE "TenderSavedSearch" (
id text PRIMARY KEY,
"userId" text NOT NULL,
"tenantId" text NOT NULL,
name text NOT NULL,
filters jsonb NOT NULL DEFAULT '{}',
"instantAlert" boolean NOT NULL DEFAULT false,
"createdAt" timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE ("userId", name)
);
`,
seedSql: `
INSERT INTO "TenderSavedSearch" (id, "userId", "tenantId", name, filters) VALUES
('ss-a', 'user-a', 'TENANT-A', 'Profil A', '{}'),
('ss-b', 'user-b', 'TENANT-B', 'Profil B', '{}');
`,
tenantOfRow: (row) => row.tenantId,
createAttemptData: {
id: 'ss-system-schreibversuch',
userId: 'user-a',
tenantId: 'TENANT-A',
name: 'Schreibversuch',
filters: {},
},
updateManyData: { name: 'SYSTEM-SCHREIBVERSUCH' },
});
}
/**