Files
tessera-ctl/apps/api/src/settings/settings.service.spec.ts
T
schalli 6e2a641d76 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
2026-09-14 11:46:08 +02:00

487 lines
16 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest';
import * as nodemailer from 'nodemailer';
import { SettingsService } from './settings.service';
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` — 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`).
*/
vi.mock('../prisma/prisma-tenant.extension', () => ({
forTenant: vi.fn((unboundClient: any, tenantId: string) => unboundClient.__makeBoundClient(tenantId)),
}));
let mockVerify = vi.fn(async () => true);
let mockSendMail = vi.fn(async () => ({}));
vi.mock('nodemailer', () => ({
createTransport: vi.fn(() => ({
verify: (...args: unknown[]) => (mockVerify as any)(...args),
sendMail: (...args: unknown[]) => (mockSendMail as any)(...args),
})),
}));
interface FakeSmtpRow {
id: string;
tenantId: string;
host: string;
port: number;
encryption: string;
username: string | null;
encryptedPassword: string | null;
fromAddress: string;
createdAt?: Date;
updatedAt?: Date;
}
interface BoundCall {
tenantId: string;
model: 'smtpConfig';
method: string;
}
function applySelect(row: Record<string, unknown>, select?: Record<string, boolean>) {
if (!select) return { ...row };
const out: Record<string, unknown> = {};
for (const key of Object.keys(select)) {
if (select[key]) out[key] = row[key];
}
return out;
}
function makeFakePrisma(rows: FakeSmtpRow[] = []) {
const configs = new Map(rows.map((r) => [r.tenantId, { ...r }]));
const boundCallLog: BoundCall[] = [];
let autoId = rows.length;
const unboundSmtpConfig = {
findFirst: vi.fn(async () => {
const first = configs.values().next().value;
return first ? { ...first } : null;
}),
};
function makeScopedSmtpConfig(tenantId: string) {
return {
findUnique: async ({ where, select }: any) => {
boundCallLog.push({ tenantId, model: 'smtpConfig', method: 'findUnique' });
const row = configs.get(where.tenantId);
if (!row || row.tenantId !== tenantId) return null;
return applySelect(row, select);
},
upsert: async ({ where, create, update, select }: any) => {
boundCallLog.push({ tenantId, model: 'smtpConfig', method: 'upsert' });
if (where.tenantId !== tenantId) return null;
const existing = configs.get(tenantId);
const record = existing
? { ...existing, ...update }
: { id: `smtp-${++autoId}`, createdAt: new Date(), ...create };
record.updatedAt = new Date();
configs.set(tenantId, record);
return applySelect(record, select);
},
};
}
const fake: any = {
smtpConfig: unboundSmtpConfig,
__configs: configs,
__boundCallLog: boundCallLog,
__makeBoundClient(tenantId: string) {
return { smtpConfig: makeScopedSmtpConfig(tenantId) };
},
};
return fake;
}
function expectBoundCall(prisma: any, tenantId: string, method: string) {
const found = prisma.__boundCallLog.some(
(c: BoundCall) => c.tenantId === tenantId && c.model === 'smtpConfig' && c.method === method,
);
expect(
found,
`erwarteter gebundener Aufruf smtpConfig.${method}(tenant=${tenantId}) fehlt im Protokoll: ${JSON.stringify(prisma.__boundCallLog)}`,
).toBe(true);
}
function makeFakeCrypto() {
return {
encrypt: vi.fn((s: string) => `enc(${s})`),
decrypt: vi.fn((s: string) => s.slice(4, -1)),
};
}
beforeEach(() => {
vi.clearAllMocks();
mockVerify = vi.fn(async () => true);
mockSendMail = vi.fn(async () => ({}));
});
describe('SettingsService — Bindung an forTenant() (260911-gwh)', () => {
it('scheitert an "X is not a function", wenn getSmtpConfig versehentlich ungebunden auf dem Basisclient laeuft (Falsifizierungsform)', () => {
const prisma = makeFakePrisma();
expect((prisma.smtpConfig as any).findUnique).toBeUndefined();
expect((prisma.smtpConfig as any).upsert).toBeUndefined();
});
describe('getSmtpConfig', () => {
it('liefert die Zeile mit encryptedPassword (fuer hasPassword), ohne Felder ausserhalb des select', async () => {
const prisma = makeFakePrisma([
{
id: 'smtp-a',
tenantId: 't1',
host: 'smtp-a.example.invalid',
port: 587,
encryption: 'starttls',
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.getSmtpConfig('t1');
expect(result?.encryptedPassword).toBe('enc(geheim)');
expect(result?.host).toBe('smtp-a.example.invalid');
expectBoundCall(prisma, 't1', 'findUnique');
});
it('fremder Mandant: null', 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.getSmtpConfig('t2');
expect(result).toBeNull();
});
});
describe('saveSmtpConfig', () => {
it('mit Kennwort: encrypt wird GENAU mit dem Klartext aufgerufen, upsert traegt encryptedPassword, Rueckgabe OHNE encryptedPassword', async () => {
const prisma = makeFakePrisma();
const crypto = makeFakeCrypto();
const service = new SettingsService(prisma as any, crypto as any);
const result = await service.saveSmtpConfig('t1', {
host: 'smtp-a.example.invalid',
port: 587,
encryption: 'starttls',
username: 'user-a',
password: 'klartext-geheim',
fromAddress: 'a@example.invalid',
} as any);
expect(crypto.encrypt).toHaveBeenCalledWith('klartext-geheim');
expect((result as any).encryptedPassword).toBeUndefined();
const stored = prisma.__configs.get('t1');
expect(stored.encryptedPassword).toBe('enc(klartext-geheim)');
expectBoundCall(prisma, 't1', 'upsert');
const logged = JSON.stringify(prisma.__boundCallLog);
expect(logged).not.toContain('klartext-geheim');
});
it('ohne Kennwort (leer oder fehlend): encrypt NICHT aufgerufen, update traegt KEINEN Schluessel encryptedPassword, username fehlend -> null', async () => {
const prisma = makeFakePrisma([
{
id: 'smtp-a',
tenantId: 't1',
host: 'alt.example.invalid',
port: 587,
encryption: 'starttls',
username: 'alt-user',
encryptedPassword: 'enc(alt-geheim)',
fromAddress: 'a@example.invalid',
},
]);
const crypto = makeFakeCrypto();
const service = new SettingsService(prisma as any, crypto as any);
await service.saveSmtpConfig('t1', {
host: 'neu.example.invalid',
port: 587,
encryption: 'starttls',
password: '',
fromAddress: 'a@example.invalid',
} as any);
expect(crypto.encrypt).not.toHaveBeenCalled();
const stored = prisma.__configs.get('t1');
expect(stored.encryptedPassword).toBe('enc(alt-geheim)'); // bestehendes bleibt
expect(stored.username).toBeNull();
});
it('unter t2, wenn nur t1 eine Zeile hat: legt fuer t2 an, t1 bleibt unveraendert (Semantik nach der Bindung)', async () => {
const prisma = makeFakePrisma([
{
id: 'smtp-a',
tenantId: 't1',
host: '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);
await service.saveSmtpConfig('t2', {
host: 'b.example.invalid',
port: 587,
encryption: 'starttls',
fromAddress: 'b@example.invalid',
} as any);
expect(prisma.__configs.get('t1').host).toBe('a.example.invalid');
expect(prisma.__configs.get('t2').host).toBe('b.example.invalid');
});
});
describe('getDecryptedSmtpConfig', () => {
it('entschluesselt encryptedPassword, liefert die Form die tender-mail.service.ts erwartet', async () => {
const prisma = makeFakePrisma([
{
id: 'smtp-a',
tenantId: 't1',
host: 'smtp-a.example.invalid',
port: 587,
encryption: 'starttls',
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.getDecryptedSmtpConfig('t1');
expect(crypto.decrypt).toHaveBeenCalledWith('enc(geheim)');
expect(result).toEqual({
host: 'smtp-a.example.invalid',
port: 587,
encryption: 'starttls',
username: 'user-a',
fromAddress: 'a@example.invalid',
decryptedPassword: 'geheim',
});
});
it('ohne encryptedPassword: decryptedPassword null, decrypt NICHT aufgerufen', 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 crypto = makeFakeCrypto();
const service = new SettingsService(prisma as any, crypto as any);
const result = await service.getDecryptedSmtpConfig('t1');
expect(result?.decryptedPassword).toBeNull();
expect(crypto.decrypt).not.toHaveBeenCalled();
});
it('T-GWH-01: fremder Mandant -> null, decrypt NICHT aufgerufen', async () => {
const prisma = makeFakePrisma([
{
id: 'smtp-a',
tenantId: 't1',
host: 'smtp-a.example.invalid',
port: 587,
encryption: 'starttls',
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.getDecryptedSmtpConfig('t2');
expect(result).toBeNull();
expect(crypto.decrypt).not.toHaveBeenCalled();
});
});
describe('testSmtpConfig', () => {
const storedRow: FakeSmtpRow = {
id: 'smtp-a',
tenantId: 't1',
host: 'smtp-a.example.invalid',
port: 587,
encryption: 'starttls',
username: 'user-a',
encryptedPassword: 'enc(geheim)',
fromAddress: 'a@example.invalid',
};
it('ohne Kennwort/Benutzername im DTO: greift auf die gespeicherten Werte zurueck, ruft verify auf, { success: true }', async () => {
const prisma = makeFakePrisma([storedRow]);
const service = new SettingsService(prisma as any, makeFakeCrypto() as any);
const result = await service.testSmtpConfig('t1', {
host: 'smtp-a.example.invalid',
port: 587,
encryption: 'starttls',
fromAddress: 'a@example.invalid',
} as any);
expect(result).toEqual({ success: true });
expect(mockVerify).toHaveBeenCalled();
expect(mockSendMail).not.toHaveBeenCalled();
expect(nodemailer.createTransport).toHaveBeenCalledWith(
expect.objectContaining({ auth: { user: 'user-a', pass: 'geheim' } }),
);
});
it('mit testTo: sendMail statt verify, from/to aus dto', async () => {
const prisma = makeFakePrisma([storedRow]);
const service = new SettingsService(prisma as any, makeFakeCrypto() as any);
await service.testSmtpConfig('t1', {
host: 'smtp-a.example.invalid',
port: 587,
encryption: 'starttls',
fromAddress: 'a@example.invalid',
testTo: 'ziel@example.invalid',
} as any);
expect(mockSendMail).toHaveBeenCalledWith(
expect.objectContaining({ from: 'a@example.invalid', to: 'ziel@example.invalid' }),
);
expect(mockVerify).not.toHaveBeenCalled();
});
it('verify wirft -> { success: false }, kein Wurf nach aussen', async () => {
mockVerify = vi.fn(async () => {
throw new Error('ECONNREFUSED');
});
const prisma = makeFakePrisma([storedRow]);
const service = new SettingsService(prisma as any, makeFakeCrypto() as any);
const result = await service.testSmtpConfig('t1', {
host: 'smtp-a.example.invalid',
port: 587,
encryption: 'starttls',
fromAddress: 'a@example.invalid',
} as any);
expect(result.success).toBe(false);
});
it('fremder Mandant ohne Kennwort im DTO: auth ohne Benutzer, kein Fehler', async () => {
const prisma = makeFakePrisma([storedRow]);
const service = new SettingsService(prisma as any, makeFakeCrypto() as any);
await service.testSmtpConfig('t2', {
host: 'x.example.invalid',
port: 587,
encryption: 'starttls',
fromAddress: 'x@example.invalid',
} as any);
expect(nodemailer.createTransport).toHaveBeenCalledWith(
expect.objectContaining({ auth: undefined }),
);
});
});
describe('Wachhund je Anfrageweg', () => {
const storedRow: FakeSmtpRow = {
id: 'smtp-a',
tenantId: 't1',
host: 'smtp-a.example.invalid',
port: 587,
encryption: 'starttls',
username: 'user-a',
encryptedPassword: 'enc(geheim)',
fromAddress: 'a@example.invalid',
};
it('genau EIN gebundener Klient je Aufruf von getSmtpConfig/saveSmtpConfig/getDecryptedSmtpConfig', async () => {
const prisma = makeFakePrisma([storedRow]);
const service = new SettingsService(prisma as any, makeFakeCrypto() as any);
for (const call of [
() => service.getSmtpConfig('t1'),
() =>
service.saveSmtpConfig('t1', {
host: 'x.invalid',
port: 587,
encryption: 'starttls',
fromAddress: 'x@invalid.de',
} as any),
() => service.getDecryptedSmtpConfig('t1'),
]) {
vi.mocked(forTenant).mockClear();
await call();
expect(vi.mocked(forTenant).mock.calls.length).toBe(1);
}
});
it('testSmtpConfig erzeugt genau einen gebundenen Klienten, wenn es auf gespeicherte Zugangsdaten zurueckgreift', async () => {
const prisma = makeFakePrisma([storedRow]);
const service = new SettingsService(prisma as any, makeFakeCrypto() as any);
vi.mocked(forTenant).mockClear();
await service.testSmtpConfig('t1', {
host: 'smtp-a.example.invalid',
port: 587,
encryption: 'starttls',
fromAddress: 'a@example.invalid',
} as any);
expect(vi.mocked(forTenant).mock.calls.length).toBe(1);
});
it('testSmtpConfig erzeugt KEINEN gebundenen Klienten, wenn Kennwort und Benutzername im DTO stehen', async () => {
const prisma = makeFakePrisma([storedRow]);
const service = new SettingsService(prisma as any, makeFakeCrypto() as any);
vi.mocked(forTenant).mockClear();
await service.testSmtpConfig('t1', {
host: 'smtp-a.example.invalid',
port: 587,
encryption: 'starttls',
username: 'anderer-user',
password: 'anderes-kennwort',
fromAddress: 'a@example.invalid',
} as any);
expect(vi.mocked(forTenant).mock.calls.length).toBe(0);
});
});
});