feat(quick-260909-mir): dkv-Testlage herstellen, Konfigurationspfade binden, Planer-Pfad benennen

- apps/api/src/dkv/dkv.service.spec.ts (neu): Zwei-Klienten-Nachweis nach
  dem Muster aus groups.service.spec.ts/tender-triage.service.spec.ts —
  dieser Bereich hatte vorher KEINE Testdatei (Befund J). 7 Testfaelle
  decken getConfigForApi, saveConfig (Zugangsdaten-Erhaltung), testConnection,
  die Verarbeitungsstrecke und den bewusst ungebundenen Planer-Startpfad ab
- dkv.service.ts: loadConfig(tenantId?) in zwei Methoden geteilt —
  loadConfig(tenantId) [Pflicht-Mandant, gebunden] und die neue, eigene
  Methode loadAnyActiveConfigForScheduler() [bewusst UNGEBUNDEN, eigener
  Kopfkommentar mit beiden Zustaenden]. getConfigForApi/saveConfig/
  testConnection/_runPipeline binden je EINEN Klienten pro Methode
  vollstaendig ueber forTenant()
- dkv-scheduler.service.ts: Kopfkommentar fortgeschrieben (beide Zustaende,
  Praezedenzfall, Unsymmetrie), Aufruf auf loadAnyActiveConfigForScheduler()
  umgestellt — an der Ablauflogik des Planers nichts geaendert
- .planning/WINDOWS.md: Eintrag #21 (deviation) fuer die benannte Altlast
  des Planer-Startpfads angelegt
- docs/mandantentrennung-zugriffsklassifikation.md: dkvModuleConfig-Zeile
  auf den jetzt gemessenen Stand "gemischt" nachgezogen (Rule 3 — noetig,
  damit rls-access-inventory.spec.ts nach der Aufteilung von loadConfig()
  gruen bleibt; die uebrigen zwei dkv-Zeilen und die Uebersichtstabelle
  bleiben Aufgabe 3 vorbehalten)
- Falsifizierungsnachweis erbracht: getConfigForApi's erster gebundener
  Client probeweise durch this.prisma ersetzt, genau Test 1 wurde rot
  (6 andere blieben gruen), Rueckbau zurueckgenommen, Dateien identisch
  zum Ausgangsstand bestaetigt

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
This commit is contained in:
2026-09-09 16:46:09 +02:00
parent 761e5e2c36
commit 222f453747
5 changed files with 392 additions and 27 deletions
+258
View File
@@ -0,0 +1,258 @@
import { describe, expect, it, vi } from 'vitest';
import { DkvService } from './dkv.service';
/**
* DkvService.spec — RED-first (TDD) Nachweis fuer die Bindung an
* forTenant() (260909-mir, Aufgabe 2/3). Dieser Bereich hatte VOR diesem
* Durchlauf KEINE einzige Testdatei (Befund J) — dieser Fake ist deshalb die
* Voraussetzung dafuer, dass irgendeine Aussage dieses Plans nachpruefbar
* ist, nicht eine Zugabe.
*
* Zwei-Klienten-Nachweis (Muster aus groups.service.spec.ts /
* tender-triage.service.spec.ts): `__makeBoundClient(tenantId)` wrappt
* DIESELBEN In-Memory-Maps mit einer protokollierenden Schicht je Modell.
* Der ungebundene Fake protokolliert NICHT, der gebundene schon — eine
* vergessene Bindung wird dadurch sichtbar, ein reiner Identitaets-Mock
* (`(p) => p`, der ldap-Fehler) wuerde das nicht leisten.
*
* Der Fake wird in Aufgabe 3 um `dkvVehicleMaster`/`dkvInvoiceHistory`
* ERWEITERT, nicht ersetzt.
*/
vi.mock('../prisma/prisma-tenant.extension', () => ({
forTenant: vi.fn((prisma: any, tenantId: string) => prisma.__makeBoundClient(tenantId)),
}));
function _applySelect(row: any, select: Record<string, boolean> | undefined) {
if (!select) return { ...row };
const out: Record<string, unknown> = {};
for (const key of Object.keys(select)) {
if (select[key]) out[key] = (row as any)[key];
}
return out;
}
function makeFakePrisma() {
const configs = new Map<string, any>(); // key: tenantId
const boundCallLog: { tenantId: string; model: string; method: string }[] = [];
const dkvModuleConfig = {
findFirst: vi.fn(async ({ select }: { select?: Record<string, boolean> } = {}) => {
// Arbitrary-but-first row — die Form, die der Planer-Startpfad heute
// benutzt (findFirst() ganz ohne Bedingung). Map bewahrt
// Einfuegereihenfolge, das genuegt fuer "irgendeine" Zeile.
const first = configs.values().next().value;
return first ? _applySelect(first, select) : null;
}),
findUnique: vi.fn(
async ({
where,
select,
}: {
where: { tenantId: string };
select?: Record<string, boolean>;
}) => {
const row = configs.get(where.tenantId);
return row ? _applySelect(row, select) : null;
},
),
upsert: vi.fn(
async ({
where,
create,
update,
select,
}: {
where: { tenantId: string };
create: Record<string, unknown>;
update: Record<string, unknown>;
select?: Record<string, boolean>;
}) => {
const existing = configs.get(where.tenantId);
const record = existing
? { ...existing, ...update }
: { id: `cfg-${configs.size + 1}`, ...create };
configs.set(where.tenantId, record);
return _applySelect(record, select);
},
),
};
const fake: any = {
dkvModuleConfig,
__boundCallLog: boundCallLog,
__seedConfig(tenantId: string, row: Record<string, unknown>) {
configs.set(tenantId, { tenantId, ...row });
},
__makeBoundClient(tenantId: string) {
const wrapped: any = {};
for (const method of ['findFirst', 'findUnique', 'upsert']) {
wrapped[method] = async (...args: any[]) => {
boundCallLog.push({ tenantId, model: 'dkvModuleConfig', method });
return (dkvModuleConfig as any)[method](...args);
};
}
return { dkvModuleConfig: wrapped };
},
};
return fake;
}
function expectBoundCall(prisma: any, tenantId: string, model: string, method: string) {
const found = prisma.__boundCallLog.some(
(c: any) => c.tenantId === tenantId && c.model === model && c.method === method,
);
expect(
found,
`erwarteter gebundener Aufruf ${model}.${method}(tenant=${tenantId}) fehlt im Protokoll: ${JSON.stringify(prisma.__boundCallLog)}`,
).toBe(true);
}
/** Schlichte Attrappen fuer die uebrigen Konstruktor-Abhaengigkeiten von DkvService. */
function makeFakeCrypto(overrides: Partial<{ encrypt: any; decrypt: any }> = {}) {
return {
encrypt: overrides.encrypt ?? vi.fn((plain: string) => `enc(${plain})`),
decrypt: overrides.decrypt ?? vi.fn((stored: string) => stored.replace(/^enc\(/, '').replace(/\)$/, '')),
};
}
function makeDkvService(prisma: any, cryptoOverrides: Partial<{ encrypt: any; decrypt: any }> = {}) {
const crypto = makeFakeCrypto(cryptoOverrides);
const parser = {} as any;
const exporter = {} as any;
const mailer = { sendExportEmail: vi.fn() } as any;
const imapProvider = { testConnection: vi.fn(async () => ({ success: true })) } as any;
const exchangeProvider = { testConnection: vi.fn(async () => ({ success: true })) } as any;
const service = new DkvService(
prisma,
crypto as any,
parser,
exporter,
mailer,
imapProvider,
exchangeProvider,
);
return { service, crypto };
}
describe('DkvService — Bindung an forTenant() (260909-mir)', () => {
it('Test 1: getConfigForApi(tenantId) — beide Lesezugriffe auf dkvModuleConfig stehen im Bindungsprotokoll unter genau diesem Mandanten', async () => {
const prisma = makeFakePrisma();
prisma.__seedConfig('t1', { id: 'cfg-1', protocol: 'imap', isActive: true, encryptedInboxCreds: null });
const { service } = makeDkvService(prisma);
await service.getConfigForApi('t1');
const configCalls = prisma.__boundCallLog.filter(
(c: any) => c.tenantId === 't1' && c.model === 'dkvModuleConfig' && c.method === 'findUnique',
);
expect(
configCalls.length,
`erwarte mindestens zwei gebundene findUnique-Aufrufe (sicherer + roher Lesezugriff) fuer t1, gefunden: ${JSON.stringify(prisma.__boundCallLog)}`,
).toBeGreaterThanOrEqual(2);
});
it('Test 2: getConfigForApi eines Mandanten liefert NICHT die Konfiguration eines zweiten Mandanten, wenn beide im Speicher liegen', async () => {
const prisma = makeFakePrisma();
prisma.__seedConfig('t1', { id: 'cfg-1', protocol: 'imap', isActive: true, host: 'mail-a.example.invalid', encryptedInboxCreds: null });
prisma.__seedConfig('t2', { id: 'cfg-2', protocol: 'imap', isActive: true, host: 'mail-b.example.invalid', encryptedInboxCreds: null });
const { service } = makeDkvService(prisma);
const configForT1 = await service.getConfigForApi('t1');
expect(configForT1?.host).toBe('mail-a.example.invalid');
expect(configForT1?.tenantId).toBe('t1');
});
it('Test 3: saveConfig(tenantId, dto) mit gesetztem Benutzernamen und LEEREM Passwort — Lese- und Schreibzugriff sind gebunden, das gespeicherte Passwort bleibt unveraendert', async () => {
const prisma = makeFakePrisma();
const crypto = makeFakeCrypto();
prisma.__seedConfig('t1', {
id: 'cfg-1',
protocol: 'imap',
isActive: true,
encryptedInboxCreds: crypto.encrypt(JSON.stringify({ username: 'alt-user', password: 'geheim-123' })),
});
const { service } = makeDkvService(prisma, { encrypt: crypto.encrypt, decrypt: crypto.decrypt });
await service.saveConfig('t1', {
protocol: 'imap',
encryption: 'ssl-tls',
username: 'neu-user',
password: '',
} as any);
expectBoundCall(prisma, 't1', 'dkvModuleConfig', 'findUnique');
expectBoundCall(prisma, 't1', 'dkvModuleConfig', 'upsert');
const raw = await prisma.dkvModuleConfig.findUnique({ where: { tenantId: 't1' } });
const rawCreds = JSON.parse(crypto.decrypt(raw.encryptedInboxCreds));
expect(rawCreds.password).toBe('geheim-123');
expect(rawCreds.username).toBe('neu-user');
});
it('Test 4: testConnection(tenantId, dto) mit leerem Passwort — der Rueckgriff auf die gespeicherten Zugangsdaten steht gebunden im Protokoll', async () => {
const prisma = makeFakePrisma();
const crypto = makeFakeCrypto();
prisma.__seedConfig('t1', {
id: 'cfg-1',
protocol: 'imap',
isActive: true,
encryptedInboxCreds: crypto.encrypt(JSON.stringify({ username: 'user-a', password: 'geheim-123' })),
});
const { service } = makeDkvService(prisma, { encrypt: crypto.encrypt, decrypt: crypto.decrypt });
await service.testConnection('t1', {
protocol: 'imap',
encryption: 'ssl-tls',
password: '',
} as any);
expectBoundCall(prisma, 't1', 'dkvModuleConfig', 'findUnique');
});
it('Test 5: die Verarbeitungsstrecke liest ihre Konfiguration gebunden; bei fehlender Konfiguration bricht sie wie bisher still ab', async () => {
const prisma = makeFakePrisma();
// Kein __seedConfig fuer t1 — die Konfiguration fehlt bewusst.
const { service } = makeDkvService(prisma);
const result = await service.checkNow('t1');
expectBoundCall(prisma, 't1', 'dkvModuleConfig', 'findUnique');
// Verhalten bleibt unveraendert: kein Fehler, checkNow meldet 'ok' —
// die Rechnungsverarbeitung stellt fuer diesen Mandanten still die
// Arbeit ein (Befund K, Stelle 2), das wird hier nur festgeschrieben.
expect(result.status).toBe('ok');
});
it('Test 6: der bewusst uebergreifende Planer-Startpfad steht NICHT im Bindungsprotokoll — Fehlen der Bindung ist hier die bestandene Erwartung, NICHT spaeter "reparieren"', async () => {
const prisma = makeFakePrisma();
prisma.__seedConfig('t1', { id: 'cfg-1', protocol: 'imap', isActive: true, encryptedInboxCreds: 'enc(egal)' });
const { service } = makeDkvService(prisma);
await service.loadAnyActiveConfigForScheduler();
expect(
prisma.__boundCallLog.length,
`der Planer-Startpfad darf KEINEN gebundenen Aufruf erzeugen, gefunden: ${JSON.stringify(prisma.__boundCallLog)}`,
).toBe(0);
});
it('Test 7: der Planer-Startpfad liefert die verschluesselten Zugangsdaten NICHT mit (Befund D — Entlastung wird festgeschrieben, nicht geglaubt)', async () => {
const prisma = makeFakePrisma();
prisma.__seedConfig('t1', {
id: 'cfg-1',
protocol: 'imap',
isActive: true,
encryptedInboxCreds: 'enc(sollte-nie-hier-auftauchen)',
});
const { service } = makeDkvService(prisma);
const result = await service.loadAnyActiveConfigForScheduler();
expect(result).not.toBeNull();
expect((result as any).encryptedInboxCreds).toBeUndefined();
});
});