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:
@@ -21,10 +21,33 @@ const CronJobClass: new (cronTime: string, onTick: () => void) => { start(): voi
|
||||
* module config. (Research Pattern 7: Dynamic Cron Job; Pitfall 4: ScheduleModule
|
||||
* must be registered in AppModule — done in Plan 01.)
|
||||
*
|
||||
* Multi-tenant note (v1): On init, the scheduler loads the first active
|
||||
* DkvModuleConfig row via findFirst(). For single-tenant deployments this
|
||||
* is always the correct config. Multi-tenant scheduling (one cron job per
|
||||
* active tenant) is deferred to a future plan.
|
||||
* Multi-tenant note (v1): On init, the scheduler loads config via
|
||||
* `DkvService.loadAnyActiveConfigForScheduler()`, which pulls the first
|
||||
* active DkvModuleConfig row via findFirst() — same underlying query as
|
||||
* before, now split into its own named method (260909-mir).
|
||||
*
|
||||
* BLEIBT bewusst UNGEBUNDEN (WINDOWS #21 Etappe 2, 260909-mir, Befund D —
|
||||
* volle Begruendung im Kopfkommentar von
|
||||
* `DkvService.loadAnyActiveConfigForScheduler()` und im Abschnitt
|
||||
* "Bereich dkv" von docs/mandantentrennung-etappe2-fehlerrichtung.md).
|
||||
* Zwei Zustaende, beide gehoeren genannt:
|
||||
*
|
||||
* - HEUTE bereits falsch, nicht nur ungenau: bei mehreren Mandanten wird
|
||||
* EIN beliebiger bedient, die uebrigen NIE — und ist ausgerechnet die
|
||||
* gezogene Zeile inaktiv, registriert der Planer gar nichts, obwohl ein
|
||||
* zweiter Mandant aktiv waere.
|
||||
* - NACH DEM SCHARFSCHALTEN (Etappe 4, WINDOWS #18) verstummt dieselbe
|
||||
* Abfrage zusaetzlich: sie liefert dann `null`, und die Protokollzeile
|
||||
* unten ("no active config found") ist auf einer frischen Installation
|
||||
* der Normalfall — sie alarmiert deshalb niemanden, obwohl ein
|
||||
* tatsaechlich eingerichteter Mandant nicht bedient wird.
|
||||
*
|
||||
* Fuer single-tenant deployments (heute der einzige produktive Fall) ist
|
||||
* dieselbe Abfrage stets die korrekte Config. Multi-tenant scheduling
|
||||
* (poll-once-fan-out-many, ein Cron-Auftrag je aktivem Mandanten) ist die
|
||||
* in 07-04 zurueckgestellte Mehrmandanten-Planung und bleibt eine
|
||||
* Funktionsaenderung fuer eine kuenftige Phase, kein Bindungsumbau dieses
|
||||
* Plans.
|
||||
*
|
||||
* The DkvController calls `setInterval()` after saving config so the cron job
|
||||
* reflects any admin change immediately — without a service restart.
|
||||
@@ -56,8 +79,10 @@ export class DkvSchedulerService implements OnModuleInit {
|
||||
*/
|
||||
async onModuleInit(): Promise<void> {
|
||||
try {
|
||||
// loadConfig without tenantId → findFirst (v1 single-tenant)
|
||||
const config = await this.dkvService.loadConfig();
|
||||
// Bewusst uebergreifender Planer-Startpfad (WINDOWS #21) — siehe
|
||||
// Kopfkommentar dieser Klasse und von
|
||||
// DkvService.loadAnyActiveConfigForScheduler().
|
||||
const config = await this.dkvService.loadAnyActiveConfigForScheduler();
|
||||
if (config?.isActive && config.tenantId) {
|
||||
this.activeTenantId = config.tenantId;
|
||||
this.setInterval(config.pollIntervalMin, config.tenantId);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||
import { DkvExportService } from './dkv-export.service';
|
||||
import { DkvMailService } from './dkv-mail.service';
|
||||
import { DkvParserService } from './dkv-parser.service';
|
||||
@@ -55,10 +56,13 @@ const CONFIG_SAFE_SELECT = {
|
||||
* - T-07-09: Export filename validated against safe pattern before reading (traversal guard)
|
||||
* - Single-flight guard: prevents concurrent inbox processing (Pitfall 7)
|
||||
*
|
||||
* Multi-tenant note (v1): The scheduler loads config via findFirst().
|
||||
* Each processInbox(tenantId) call is per-tenant. The Controller scopes all
|
||||
* operations to req.tenantId. Full per-tenant scheduling (one cron per active
|
||||
* tenant) is deferred to a future plan — v1 covers single-tenant deployments.
|
||||
* Multi-tenant note (v1): The scheduler loads its startup config via
|
||||
* loadAnyActiveConfigForScheduler(), which stays bewusst UNGEBUNDEN
|
||||
* (WINDOWS #21, see that method's own doc comment). Each processInbox(tenantId)
|
||||
* call is per-tenant and fully forTenant()-bound (260909-mir). The Controller
|
||||
* scopes all operations to req.tenantId. Full per-tenant scheduling (one cron
|
||||
* per active tenant) is deferred to a future plan — v1 covers single-tenant
|
||||
* deployments.
|
||||
*/
|
||||
@Injectable()
|
||||
export class DkvService {
|
||||
@@ -91,30 +95,81 @@ export class DkvService {
|
||||
|
||||
/**
|
||||
* Load DKV module config for a tenant (safe — no encrypted creds).
|
||||
* When tenantId is omitted, returns the first row (used by scheduler on init).
|
||||
*
|
||||
* Mandantengebunden (WINDOWS #20 Etappe 2, 260909-mir): der Mandant kommt
|
||||
* hier als PFLICHT-Parameter herein, ist also vor dem Zugriff bereits
|
||||
* bekannt. Diese Methode war frueher `loadConfig(tenantId?)` mit einem
|
||||
* optionalen Parameter, hinter dem der eine Zweig gebunden werden MUSSTE
|
||||
* und der andere gebunden werden DURFTE NICHT — genau die Form, die
|
||||
* dieser Umbau aufloest. Der uebergreifende Zweig ist jetzt eine eigene,
|
||||
* benannte Methode: `loadAnyActiveConfigForScheduler()` unten.
|
||||
*/
|
||||
async loadConfig(tenantId?: string) {
|
||||
if (!tenantId) {
|
||||
return this.prisma.dkvModuleConfig.findFirst({ select: CONFIG_SAFE_SELECT });
|
||||
}
|
||||
return this.prisma.dkvModuleConfig.findUnique({
|
||||
async loadConfig(tenantId: string) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
return tenantPrisma.dkvModuleConfig.findUnique({
|
||||
where: { tenantId },
|
||||
select: CONFIG_SAFE_SELECT,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull the DKV module config for a single, ARBITRARY tenant that has one
|
||||
* configured — used EXCLUSIVELY by DkvSchedulerService.onModuleInit() to
|
||||
* seed the one (v1, single-tenant) cron job at boot time.
|
||||
*
|
||||
* BLEIBT bewusst UNGEBUNDEN (WINDOWS #21 Etappe 2, 260909-mir, Befund D
|
||||
* — siehe .planning/WINDOWS.md und den Abschnitt "Bereich dkv" in
|
||||
* docs/mandantentrennung-etappe2-fehlerrichtung.md fuer die vollstaendige
|
||||
* Begruendung, hier nur die Kurzfassung):
|
||||
*
|
||||
* - HEUTE bereits falsch, nicht nur ungenau: `findFirst()` ohne jede
|
||||
* Bedingung zieht bei mehreren Mandanten EINEN beliebigen und bedient
|
||||
* die uebrigen NIE. Ist ausgerechnet die gezogene Zeile inaktiv,
|
||||
* registriert der Planer gar nichts, obwohl ein zweiter Mandant aktiv
|
||||
* waere.
|
||||
* - NACH DEM SCHARFSCHALTEN (Etappe 4, WINDOWS #18) verstummt dieselbe
|
||||
* Abfrage zusaetzlich: sie liefert dann `null` statt einer beliebigen
|
||||
* Zeile, der Planer protokolliert das als Normalfall und richtet fuer
|
||||
* JEDEN Mandanten nichts ein — ohne Fehler, ohne Alarm.
|
||||
* - Binden wuerde diesen Pfad garantiert leer laufen lassen (es gibt beim
|
||||
* Boot strukturell keinen Mandantenkontext). Umbau auf
|
||||
* einmal-abfragen-viele-bedienen ist die in 07-04 zurueckgestellte
|
||||
* Mehrmandanten-Planung — eine Funktionsaenderung, kein Bindungsumbau,
|
||||
* und deshalb hier NICHT vorgenommen.
|
||||
* - Praezedenzfall: `LdapConfigService.getAllActiveConfigs()`
|
||||
* (260909-ipc, Befund B) — mit der einen Unsymmetrie, die dieser
|
||||
* Praezedenzfall NICHT deckt: `getAllActiveConfigs` ist heute korrekt
|
||||
* und verstummt erst spaeter, dieser Pfad ist HEUTE bereits falsch UND
|
||||
* verstummt zusaetzlich spaeter.
|
||||
*
|
||||
* Das Signal fuer das Verstummen gehoert in die Vorabpruefung von Etappe 4
|
||||
* (`apps/api/scripts/rls-preflight.mjs`), NICHT in diesen Durchlauf.
|
||||
*/
|
||||
async loadAnyActiveConfigForScheduler() {
|
||||
return this.prisma.dkvModuleConfig.findFirst({ select: CONFIG_SAFE_SELECT });
|
||||
}
|
||||
|
||||
/**
|
||||
* Load config for API response: safe fields + decrypted username + hasPassword flag.
|
||||
* T-07-12: password is NEVER returned — only hasPassword boolean.
|
||||
*
|
||||
* Mandantengebunden (260909-mir): EIN gebundener Klient fuer BEIDE
|
||||
* Lesezugriffe dieser Methode (nicht `this.loadConfig(tenantId)` plus ein
|
||||
* zweiter Aufruf — das waere ein Klient je Modellzugriff statt je
|
||||
* Methode).
|
||||
*/
|
||||
async getConfigForApi(tenantId: string) {
|
||||
const safe = await this.loadConfig(tenantId);
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const safe = await tenantPrisma.dkvModuleConfig.findUnique({
|
||||
where: { tenantId },
|
||||
select: CONFIG_SAFE_SELECT,
|
||||
});
|
||||
if (!safe) return null;
|
||||
|
||||
let username: string | null = null;
|
||||
let hasPassword = false;
|
||||
try {
|
||||
const raw = await this.prisma.dkvModuleConfig.findUnique({ where: { tenantId } });
|
||||
const raw = await tenantPrisma.dkvModuleConfig.findUnique({ where: { tenantId } });
|
||||
if (raw?.encryptedInboxCreds) {
|
||||
const creds = JSON.parse(this.crypto.decrypt(raw.encryptedInboxCreds)) as { username?: string; password?: string };
|
||||
username = creds.username ?? null;
|
||||
@@ -137,8 +192,17 @@ export class DkvService {
|
||||
*
|
||||
* T-07-12: Returns safe select (no encryptedInboxCreds).
|
||||
* T-05-13: Never logs decrypted credentials.
|
||||
*
|
||||
* Mandantengebunden (260909-mir): EIN gebundener Klient fuer den
|
||||
* erhaltenden Lesezugriff UND den Schreibzugriff dieser Methode — beide
|
||||
* sehen dadurch denselben Mandanten, ein leerer Lesezugriff kann nicht
|
||||
* mit einem erfolgreichen Schreibzugriff unter einem anderen Kontext
|
||||
* kombiniert werden (T-MIR-07, die zerstoerende Stelle aus Befund K).
|
||||
* Kein `where`-Filter entfaellt — die Mandantenbedingung bleibt neben der
|
||||
* Bindung als zweite Schicht bestehen.
|
||||
*/
|
||||
async saveConfig(tenantId: string, dto: DkvConfigDto) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
let encryptedInboxCreds: string | undefined;
|
||||
|
||||
const credChanged = (dto.password && dto.password.length > 0) ||
|
||||
@@ -151,7 +215,7 @@ export class DkvService {
|
||||
// Preserve the field that was left empty from the existing stored value
|
||||
if (!dto.password || !dto.username) {
|
||||
try {
|
||||
const existing = await this.prisma.dkvModuleConfig.findUnique({ where: { tenantId } });
|
||||
const existing = await tenantPrisma.dkvModuleConfig.findUnique({ where: { tenantId } });
|
||||
if (existing?.encryptedInboxCreds) {
|
||||
// T-05-13: decrypt only to preserve — never log the result
|
||||
const stored = JSON.parse(this.crypto.decrypt(existing.encryptedInboxCreds)) as {
|
||||
@@ -184,7 +248,7 @@ export class DkvService {
|
||||
...(encryptedInboxCreds !== undefined && { encryptedInboxCreds }),
|
||||
};
|
||||
|
||||
return this.prisma.dkvModuleConfig.upsert({
|
||||
return tenantPrisma.dkvModuleConfig.upsert({
|
||||
where: { tenantId },
|
||||
create: { tenantId, ...data },
|
||||
update: data,
|
||||
@@ -197,14 +261,17 @@ export class DkvService {
|
||||
* When dto.password is empty, falls back to the stored encrypted password.
|
||||
*
|
||||
* T-05-13: Decrypted password used only within this method scope — never logged.
|
||||
* Mandantengebunden (260909-mir): EIN gebundener Klient fuer den
|
||||
* Rueckgriff auf die gespeicherten Zugangsdaten.
|
||||
*/
|
||||
async testConnection(tenantId: string, dto: DkvConfigDto): Promise<{ success: boolean; message?: string }> {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
let password: string | undefined = dto.password;
|
||||
|
||||
// If no password in DTO, fall back to the stored one
|
||||
if (!password) {
|
||||
try {
|
||||
const existing = await this.prisma.dkvModuleConfig.findUnique({ where: { tenantId } });
|
||||
const existing = await tenantPrisma.dkvModuleConfig.findUnique({ where: { tenantId } });
|
||||
if (existing?.encryptedInboxCreds) {
|
||||
const stored = JSON.parse(this.crypto.decrypt(existing.encryptedInboxCreds)) as {
|
||||
password?: string;
|
||||
@@ -281,8 +348,10 @@ export class DkvService {
|
||||
}
|
||||
|
||||
private async _runPipeline(tenantId: string): Promise<void> {
|
||||
// Load raw config (need encryptedInboxCreds for decryption)
|
||||
const config = await this.prisma.dkvModuleConfig.findUnique({ where: { tenantId } });
|
||||
// Load raw config (need encryptedInboxCreds for decryption).
|
||||
// Mandantengebunden (260909-mir).
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const config = await tenantPrisma.dkvModuleConfig.findUnique({ where: { tenantId } });
|
||||
if (!config) {
|
||||
this.logger.warn(`DKV processInbox: no config for tenant ${tenantId}`);
|
||||
return;
|
||||
|
||||
Reference in New Issue
Block a user