diff --git a/apps/api/src/dkv/dkv.service.spec.ts b/apps/api/src/dkv/dkv.service.spec.ts index 5869c4b..58ce938 100644 --- a/apps/api/src/dkv/dkv.service.spec.ts +++ b/apps/api/src/dkv/dkv.service.spec.ts @@ -1,4 +1,5 @@ -import { describe, expect, it, vi } from 'vitest'; +import * as fs from 'fs'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { DkvService } from './dkv.service'; /** @@ -16,13 +17,22 @@ import { DkvService } from './dkv.service'; * (`(p) => p`, der ldap-Fehler) wuerde das nicht leisten. * * Der Fake wird in Aufgabe 3 um `dkvVehicleMaster`/`dkvInvoiceHistory` - * ERWEITERT, nicht ersetzt. + * ERWEITERT, nicht ersetzt (siehe unten in makeFakePrisma()). */ vi.mock('../prisma/prisma-tenant.extension', () => ({ forTenant: vi.fn((prisma: any, tenantId: string) => prisma.__makeBoundClient(tenantId)), })); +// `import * as fs from 'fs'` under ESM has a non-configurable module +// namespace — vi.spyOn(fs, 'existsSync') fails with "Cannot redefine +// property". vi.mock() replaces the module at import time instead, which +// works regardless of namespace configurability (Tests 8-10, Aufgabe 3). +vi.mock('fs', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, existsSync: vi.fn(), readFileSync: vi.fn() }; +}); + function _applySelect(row: any, select: Record | undefined) { if (!select) return { ...row }; const out: Record = {}; @@ -78,21 +88,179 @@ function makeFakePrisma() { ), }; + // ─── dkvVehicleMaster (Aufgabe 3) ───────────────────────────────────────── + const vehicles = new Map(); // key: id + let vehicleAutoId = 0; + + const dkvVehicleMaster = { + findMany: vi.fn(async ({ where }: { where?: { tenantId?: string } } = {}) => { + return Array.from(vehicles.values()).filter( + (v) => !where?.tenantId || v.tenantId === where.tenantId, + ); + }), + create: vi.fn(async ({ data }: { data: Record }) => { + const id = `veh-${++vehicleAutoId}`; + const record = { id, ...data }; + vehicles.set(id, record); + return record; + }), + findFirst: vi.fn(async ({ where }: { where: { id: string; tenantId: string } }) => { + const row = vehicles.get(where.id); + return row && row.tenantId === where.tenantId ? row : null; + }), + update: vi.fn(async ({ where, data }: { where: { id: string }; data: Record }) => { + const existing = vehicles.get(where.id); + const record = { ...existing, ...data }; + vehicles.set(where.id, record); + return record; + }), + delete: vi.fn(async ({ where }: { where: { id: string } }) => { + const existing = vehicles.get(where.id); + vehicles.delete(where.id); + return existing; + }), + deleteMany: vi.fn(async ({ where }: { where?: { tenantId?: string } } = {}) => { + let count = 0; + for (const [id, v] of vehicles) { + if (!where?.tenantId || v.tenantId === where.tenantId) { + vehicles.delete(id); + count++; + } + } + return { count }; + }), + createMany: vi.fn(async ({ data }: { data: Record[] }) => { + for (const d of data) { + const id = `veh-${++vehicleAutoId}`; + vehicles.set(id, { id, ...d }); + } + return { count: data.length }; + }), + upsert: vi.fn( + async ({ + where, + create, + update, + }: { + where: { tenantId_kennzeichen: { tenantId: string; kennzeichen: string } }; + create: Record; + update: Record; + }) => { + const key = where.tenantId_kennzeichen; + const existing = Array.from(vehicles.values()).find( + (v) => v.tenantId === key.tenantId && v.kennzeichen === key.kennzeichen, + ); + if (existing) { + const record = { ...existing, ...update }; + vehicles.set(existing.id, record); + return record; + } + const id = `veh-${++vehicleAutoId}`; + const record = { id, ...create }; + vehicles.set(id, record); + return record; + }, + ), + }; + + // ─── dkvInvoiceHistory (Aufgabe 3) ──────────────────────────────────────── + const history = new Map(); // key: id + let historyAutoId = 0; + + const dkvInvoiceHistory = { + findMany: vi.fn( + async ({ + where, + skip, + take, + }: { where?: { tenantId?: string }; skip?: number; take?: number } = {}) => { + let rows = Array.from(history.values()).filter( + (h) => !where?.tenantId || h.tenantId === where.tenantId, + ); + if (typeof skip === 'number') rows = rows.slice(skip); + if (typeof take === 'number') rows = rows.slice(0, take); + return rows; + }, + ), + count: vi.fn(async ({ where }: { where?: { tenantId?: string } } = {}) => { + return Array.from(history.values()).filter( + (h) => !where?.tenantId || h.tenantId === where.tenantId, + ).length; + }), + create: vi.fn(async ({ data }: { data: Record }) => { + const id = `hist-${++historyAutoId}`; + const record = { id, ...data }; + history.set(id, record); + return record; + }), + findFirst: vi.fn( + async ({ + where, + }: { + where: { tenantId?: string; exportFilename?: string }; + }) => { + return ( + Array.from(history.values()).find( + (h) => + (!where.tenantId || h.tenantId === where.tenantId) && + (!where.exportFilename || h.exportFilename === where.exportFilename), + ) ?? null + ); + }, + ), + }; + const fake: any = { dkvModuleConfig, + dkvVehicleMaster, + dkvInvoiceHistory, __boundCallLog: boundCallLog, __seedConfig(tenantId: string, row: Record) { configs.set(tenantId, { tenantId, ...row }); }, + __seedVehicle(row: { id: string; tenantId: string; kennzeichen: string; marke?: string; modell?: string; fahrer?: string }) { + vehicles.set(row.id, { marke: '', modell: '', fahrer: '', ...row }); + }, + __seedHistory(row: { id: string; tenantId: string; exportFilename?: string | null }) { + history.set(row.id, { + rechnungsnummer: 'RG-TEST', + anzahlFahrzeuge: 0, + anzahlTransaktionen: 0, + status: 'Verarbeitet', + exportFilename: null, + ...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 }; + const wrapModel = (model: Record, modelName: string, methods: string[]) => { + const wrapped: any = {}; + for (const method of methods) { + wrapped[method] = async (...args: any[]) => { + boundCallLog.push({ tenantId, model: modelName, method }); + return model[method](...args); + }; + } + return wrapped; + }; + return { + dkvModuleConfig: wrapModel(dkvModuleConfig, 'dkvModuleConfig', ['findFirst', 'findUnique', 'upsert']), + dkvVehicleMaster: wrapModel(dkvVehicleMaster, 'dkvVehicleMaster', [ + 'findMany', + 'create', + 'findFirst', + 'update', + 'delete', + 'deleteMany', + 'createMany', + 'upsert', + ]), + dkvInvoiceHistory: wrapModel(dkvInvoiceHistory, 'dkvInvoiceHistory', [ + 'findMany', + 'count', + 'create', + 'findFirst', + ]), + }; }, }; @@ -117,13 +285,27 @@ function makeFakeCrypto(overrides: Partial<{ encrypt: any; decrypt: any }> = {}) }; } -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; +function makeDkvService( + prisma: any, + overrides: Partial<{ + encrypt: any; + decrypt: any; + parser: any; + exporter: any; + mailer: any; + imapProvider: any; + exchangeProvider: any; + }> = {}, +) { + const crypto = makeFakeCrypto(overrides); + const parser = overrides.parser ?? ({} as any); + const exporter = overrides.exporter ?? ({} as any); + const mailer = overrides.mailer ?? ({ sendExportEmail: vi.fn() } as any); + const imapProvider = + overrides.imapProvider ?? + ({ testConnection: vi.fn(async () => ({ success: true })), fetchPdfAttachments: vi.fn(async () => []) } as any); + const exchangeProvider = + overrides.exchangeProvider ?? ({ testConnection: vi.fn(async () => ({ success: true })) } as any); const service = new DkvService( prisma, @@ -255,4 +437,227 @@ describe('DkvService — Bindung an forTenant() (260909-mir)', () => { expect(result).not.toBeNull(); expect((result as any).encryptedInboxCreds).toBeUndefined(); }); + + // ─── Aufgabe 3 (260909-mir): dkvVehicleMaster / dkvInvoiceHistory / getExportFile ─── + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('Test 1: listVehicles/createVehicle stehen gebunden im Protokoll und liefern bzw. schreiben nur unter dem uebergebenen Mandanten', async () => { + const prisma = makeFakePrisma(); + prisma.__seedVehicle({ id: 'veh-a1', tenantId: 't1', kennzeichen: 'A-ONLY-1' }); + prisma.__seedVehicle({ id: 'veh-b1', tenantId: 't2', kennzeichen: 'B-ONLY-1' }); + const { service } = makeDkvService(prisma); + + const listed = await service.listVehicles('t1'); + expect(listed).toHaveLength(1); + expect(listed[0].tenantId).toBe('t1'); + + await service.createVehicle('t1', { kennzeichen: 'NEU-1', marke: 'X', modell: 'Y', fahrer: 'Z' } as any); + + expectBoundCall(prisma, 't1', 'dkvVehicleMaster', 'findMany'); + expectBoundCall(prisma, 't1', 'dkvVehicleMaster', 'create'); + const listedAgain = await service.listVehicles('t1'); + expect(listedAgain).toHaveLength(2); + expect(listedAgain.every((v: any) => v.tenantId === 't1')).toBe(true); + }); + + it('Test 2: updateVehicle/deleteVehicle — BEIDE Anweisungen (Besitzpruefung und Schreibzugriff) stehen gebunden im Protokoll', async () => { + const prisma = makeFakePrisma(); + prisma.__seedVehicle({ id: 'veh-a1', tenantId: 't1', kennzeichen: 'A-ONLY-1' }); + prisma.__seedVehicle({ id: 'veh-a2', tenantId: 't1', kennzeichen: 'A-ONLY-2' }); + const { service } = makeDkvService(prisma); + + await service.updateVehicle('t1', 'veh-a1', { marke: 'Neu' } as any); + expectBoundCall(prisma, 't1', 'dkvVehicleMaster', 'findFirst'); + expectBoundCall(prisma, 't1', 'dkvVehicleMaster', 'update'); + + await service.deleteVehicle('t1', 'veh-a2'); + const deleteFindFirstCalls = prisma.__boundCallLog.filter( + (c: any) => c.tenantId === 't1' && c.model === 'dkvVehicleMaster' && c.method === 'findFirst', + ); + expect(deleteFindFirstCalls.length).toBeGreaterThanOrEqual(2); + expectBoundCall(prisma, 't1', 'dkvVehicleMaster', 'delete'); + }); + + it('Test 3: updateVehicle/deleteVehicle auf ein Fahrzeug eines FREMDEN Mandanten werfen weiterhin die vorhandene NotFoundException', async () => { + const prisma = makeFakePrisma(); + prisma.__seedVehicle({ id: 'veh-b1', tenantId: 't2', kennzeichen: 'B-ONLY-1' }); + const { service } = makeDkvService(prisma); + + await expect(service.updateVehicle('t1', 'veh-b1', { marke: 'X' } as any)).rejects.toThrow('Vehicle not found'); + await expect(service.deleteVehicle('t1', 'veh-b1')).rejects.toThrow('Vehicle not found'); + }); + + it('Test 4: importVehiclesCsv in beiden Modi — jede Anweisung steht gebunden im Protokoll; der Ersetzen-Modus loescht ausschliesslich Fahrzeuge des eigenen Mandanten', async () => { + const prisma = makeFakePrisma(); + prisma.__seedVehicle({ id: 'veh-a1', tenantId: 't1', kennzeichen: 'ALT-1' }); + prisma.__seedVehicle({ id: 'veh-b1', tenantId: 't2', kennzeichen: 'B-ONLY-1' }); + const { service } = makeDkvService(prisma); + + const csv = 'Kennzeichen;Marke;Modell;Fahrer\nNEU-1;Marke;Modell;Fahrer'; + await service.importVehiclesCsv('t1', csv, 'replace'); + + expectBoundCall(prisma, 't1', 'dkvVehicleMaster', 'deleteMany'); + expectBoundCall(prisma, 't1', 'dkvVehicleMaster', 'createMany'); + + const t1Vehicles = await service.listVehicles('t1'); + const t2Vehicles = await service.listVehicles('t2'); + expect(t1Vehicles.map((v: any) => v.kennzeichen)).toEqual(['NEU-1']); + expect(t2Vehicles).toHaveLength(1); // t2's vehicle survived the t1-scoped replace + + const csvMerge = 'Kennzeichen;Marke;Modell;Fahrer\nNEU-1;Marke2;Modell2;Fahrer2\nWEITERES-1;M;M;F'; + await service.importVehiclesCsv('t1', csvMerge, 'merge'); + expectBoundCall(prisma, 't1', 'dkvVehicleMaster', 'upsert'); + }); + + it('Test 5: getHistory — beide parallel gestarteten Abfragen stehen gebunden im Protokoll, die Gesamtzahl zaehlt nur eigene Zeilen', async () => { + const prisma = makeFakePrisma(); + prisma.__seedHistory({ id: 'hist-a1', tenantId: 't1' }); + prisma.__seedHistory({ id: 'hist-a2', tenantId: 't1' }); + prisma.__seedHistory({ id: 'hist-b1', tenantId: 't2' }); + const { service } = makeDkvService(prisma); + + const result = await service.getHistory('t1', 1, 20); + + expect(result.total).toBe(2); + expect(result.items).toHaveLength(2); + expectBoundCall(prisma, 't1', 'dkvInvoiceHistory', 'findMany'); + expectBoundCall(prisma, 't1', 'dkvInvoiceHistory', 'count'); + }); + + it('Test 6: die beiden Historien-Schreibzugriffe der Verarbeitungsstrecke (Erfolgsfall und Zerlegungsfehler) stehen gebunden im Protokoll', async () => { + // Erfolgsfall + const prismaOk = makeFakePrisma(); + prismaOk.__seedConfig('t1', { + id: 'cfg-1', + protocol: 'imap', + isActive: true, + host: 'mail.example.invalid', + folder: 'INBOX', + encryptedInboxCreds: 'enc({"username":"u","password":"p"})', + }); + const email = { subject: 'RG', uid: 1, attachments: [{ buffer: Buffer.from('pdf-bytes') }] }; + const parserOk = { + parsePdf: vi.fn(async () => ({ + vehicles: [], + rechnungsnummer: 'RG-1', + rechnungsdatum: '01.01.2026', + })), + }; + const exporter = { + buildExcelBuffer: vi.fn(() => Buffer.from('xlsx')), + writeAndPrune: vi.fn(() => 'RG-DKV-RG-1-260101.xlsx'), + resolveFahrzeug: vi.fn(() => ''), + }; + const imapProvider = { fetchPdfAttachments: vi.fn(async () => [email]) }; + const { service: serviceOk } = makeDkvService(prismaOk, { parser: parserOk, exporter, imapProvider }); + + await serviceOk.checkNow('t1'); + + expectBoundCall(prismaOk, 't1', 'dkvInvoiceHistory', 'create'); + + // Zerlegungsfehler-Fall + const prismaFail = makeFakePrisma(); + prismaFail.__seedConfig('t1', { + id: 'cfg-2', + protocol: 'imap', + isActive: true, + host: 'mail.example.invalid', + folder: 'INBOX', + encryptedInboxCreds: 'enc({"username":"u","password":"p"})', + }); + const parserFail = { parsePdf: vi.fn(async () => { throw new Error('kaputt'); }) }; + const imapProviderFail = { fetchPdfAttachments: vi.fn(async () => [email]) }; + const { service: serviceFail } = makeDkvService(prismaFail, { + parser: parserFail, + exporter, + imapProvider: imapProviderFail, + }); + + await serviceFail.checkNow('t1'); + + expectBoundCall(prismaFail, 't1', 'dkvInvoiceHistory', 'create'); + }); + + it('Test 7: der gebuendelte Lesezugriff auf die Fahrzeugstammdaten beim Aufbau der Ausfuhrzeilen steht gebunden im Protokoll und zieht keine Fahrzeuge eines zweiten Mandanten in die Ausfuhrdatei', async () => { + const prisma = makeFakePrisma(); + prisma.__seedConfig('t1', { + id: 'cfg-1', + protocol: 'imap', + isActive: true, + host: 'mail.example.invalid', + folder: 'INBOX', + encryptedInboxCreds: 'enc({"username":"u","password":"p"})', + }); + prisma.__seedVehicle({ id: 'veh-a1', tenantId: 't1', kennzeichen: 'A-1', marke: 'MarkeA', modell: 'ModellA', fahrer: 'FahrerA' }); + prisma.__seedVehicle({ id: 'veh-b1', tenantId: 't2', kennzeichen: 'A-1', marke: 'FREMD', modell: 'FREMD', fahrer: 'FREMD-Fahrer' }); + + const email = { subject: 'RG', uid: 1, attachments: [{ buffer: Buffer.from('pdf-bytes') }] }; + let capturedRows: any[] | null = null; + const parser = { + parsePdf: vi.fn(async () => ({ + vehicles: [{ kennzeichen: 'A-1', transactions: [{ lieferdatum: '01.01.2026', ort: 'Ort', kilometerstand: 100 }] }], + rechnungsnummer: 'RG-1', + rechnungsdatum: '01.01.2026', + })), + }; + const exporter = { + buildExcelBuffer: vi.fn((rows: any[]) => { + capturedRows = rows; + return Buffer.from('xlsx'); + }), + writeAndPrune: vi.fn(() => 'RG-DKV-RG-1-260101.xlsx'), + resolveFahrzeug: vi.fn((v: any) => `${v.marke}/${v.modell}/${v.kennzeichen}`), + }; + const imapProvider = { fetchPdfAttachments: vi.fn(async () => [email]) }; + const { service } = makeDkvService(prisma, { parser, exporter, imapProvider }); + + await service.checkNow('t1'); + + expectBoundCall(prisma, 't1', 'dkvVehicleMaster', 'findMany'); + expect(capturedRows).not.toBeNull(); + expect(capturedRows![0].fahrer).toBe('FahrerA'); + expect(capturedRows![0].fahrer).not.toBe('FREMD-Fahrer'); + }); + + it('Test 8: getExportFile liefert eine Datei, zu der eine Historienzeile DIESES Mandanten mit passendem Dateinamen existiert', async () => { + const prisma = makeFakePrisma(); + prisma.__seedHistory({ id: 'hist-a1', tenantId: 't1', exportFilename: 'RG-DKV-TEST-A.xlsx' }); + const { service } = makeDkvService(prisma); + + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue(Buffer.from('xlsx-bytes')); + + const buffer = await service.getExportFile('t1', 'RG-DKV-TEST-A.xlsx'); + + expect(buffer.toString()).toBe('xlsx-bytes'); + }); + + it('Test 9: getExportFile verweigert dieselbe Datei einem ZWEITEN Mandanten mit der vorhandenen NotFoundException, obwohl die Datei existiert und das Namensmuster besteht (Befund E — die geschlossene Luecke)', async () => { + const prisma = makeFakePrisma(); + prisma.__seedHistory({ id: 'hist-a1', tenantId: 't1', exportFilename: 'RG-DKV-TEST-A.xlsx' }); + const { service } = makeDkvService(prisma); + + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue(Buffer.from('xlsx-bytes')); + + await expect(service.getExportFile('t2', 'RG-DKV-TEST-A.xlsx')).rejects.toThrow( + 'Export file not found: RG-DKV-TEST-A.xlsx', + ); + }); + + it('Test 10: der Riegel laeuft ueber einen GEBUNDENEN Lesezugriff auf die Historie — im Bindungsprotokoll nachweisbar, nicht nur am Ergebnis', async () => { + const prisma = makeFakePrisma(); + prisma.__seedHistory({ id: 'hist-a1', tenantId: 't1', exportFilename: 'RG-DKV-TEST-A.xlsx' }); + const { service } = makeDkvService(prisma); + + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue(Buffer.from('xlsx-bytes')); + + await service.getExportFile('t1', 'RG-DKV-TEST-A.xlsx'); + + expectBoundCall(prisma, 't1', 'dkvInvoiceHistory', 'findFirst'); + }); }); diff --git a/apps/api/src/dkv/dkv.service.ts b/apps/api/src/dkv/dkv.service.ts index b479400..e7c153c 100644 --- a/apps/api/src/dkv/dkv.service.ts +++ b/apps/api/src/dkv/dkv.service.ts @@ -433,6 +433,11 @@ export class DkvService { recipient: string | undefined, vehicleFormatString: string, ): Promise { + // Mandantengebunden (260909-mir): EIN gebundener Klient fuer beide + // dkvInvoiceHistory.create()-Aufrufe dieser Methode (Erfolgsfall UND + // Zerlegungsfehler-Fall). + const tenantPrisma = forTenant(this.prisma, tenantId) as any; + // D-10: Up to 3 parse retries let parseResult: Awaited> | null = null; let parseError: string | null = null; @@ -452,7 +457,7 @@ export class DkvService { if (!parseResult) { // Record parse failure in history (D-10) - await this.prisma.dkvInvoiceHistory.create({ + await tenantPrisma.dkvInvoiceHistory.create({ data: { tenantId, rechnungsnummer: this._buildRechnungsnummer(null, email.subject, email.uid), @@ -509,7 +514,7 @@ export class DkvService { } // Record history row (D-20) - await this.prisma.dkvInvoiceHistory.create({ + await tenantPrisma.dkvInvoiceHistory.create({ data: { tenantId, rechnungsnummer, @@ -529,32 +534,47 @@ export class DkvService { // ─── Vehicle CRUD ──────────────────────────────────────────────────────────── async listVehicles(tenantId: string) { - return this.prisma.dkvVehicleMaster.findMany({ + const tenantPrisma = forTenant(this.prisma, tenantId) as any; + return tenantPrisma.dkvVehicleMaster.findMany({ where: { tenantId }, orderBy: { kennzeichen: 'asc' }, }); } async createVehicle(tenantId: string, dto: CreateVehicleDto) { - return this.prisma.dkvVehicleMaster.create({ + const tenantPrisma = forTenant(this.prisma, tenantId) as any; + return tenantPrisma.dkvVehicleMaster.create({ data: { tenantId, ...dto }, }); } + /** + * Mandantengebunden (260909-mir, Befund G): EIN gebundener Klient fuer + * BEIDE Anweisungen — die Besitzpruefung UND den Schreibzugriff. Die + * Mandantenbedingung in der Besitzpruefung (`findFirst({ id, tenantId })`) + * bleibt zusaetzlich erhalten und wird nicht durch die Bindung ersetzt: + * Aufgabe 1 hat gemessen, dass ein gebundenes UPDATE ueber die Kennung + * allein auf eine fremde Zeile still 0 Zeilen trifft statt laut zu + * scheitern — eine gebundene Vorpruefung mit einem ungebundenen + * Schreibzugriff dahinter waere genau die Luecke, nicht die Loesung. + */ async updateVehicle(tenantId: string, id: string, dto: UpdateVehicleDto) { - const existing = await this.prisma.dkvVehicleMaster.findFirst({ + const tenantPrisma = forTenant(this.prisma, tenantId) as any; + const existing = await tenantPrisma.dkvVehicleMaster.findFirst({ where: { id, tenantId }, }); if (!existing) throw new NotFoundException('Vehicle not found'); - return this.prisma.dkvVehicleMaster.update({ where: { id }, data: dto }); + return tenantPrisma.dkvVehicleMaster.update({ where: { id }, data: dto }); } + /** Mandantengebunden (260909-mir, Befund G) — siehe updateVehicle() oben. */ async deleteVehicle(tenantId: string, id: string): Promise<{ deleted: boolean }> { - const existing = await this.prisma.dkvVehicleMaster.findFirst({ + const tenantPrisma = forTenant(this.prisma, tenantId) as any; + const existing = await tenantPrisma.dkvVehicleMaster.findFirst({ where: { id, tenantId }, }); if (!existing) throw new NotFoundException('Vehicle not found'); - await this.prisma.dkvVehicleMaster.delete({ where: { id } }); + await tenantPrisma.dkvVehicleMaster.delete({ where: { id } }); return { deleted: true }; } @@ -567,12 +587,19 @@ export class DkvService { * mode='replace': delete all existing vehicles for this tenant, then insert. * * Research pattern: CSV Vehicle Import Pattern (RESEARCH.md Code Examples). + * + * Mandantengebunden (260909-mir): EIN gebundener Klient fuer JEDE + * Anweisung in beiden Modi. Keine Kollisionsbehandlung noetig im + * Zusammenfuehren-Modus — `@@unique([tenantId, kennzeichen])` traegt den + * Mandanten als Teil des Schluessels (Befund H, in Aufgabe 1 gemessen: + * `dkvvehiclemaster-schluessel-traegt-mandant-keine-fremdkollision`). */ async importVehiclesCsv( tenantId: string, csvText: string, mode: 'merge' | 'replace', ): Promise<{ imported: number; mode: string }> { + const tenantPrisma = forTenant(this.prisma, tenantId) as any; const vehicles = _parseVehicleCsv(csvText); if (vehicles.length === 0) { throw new BadRequestException( @@ -581,14 +608,14 @@ export class DkvService { } if (mode === 'replace') { - await this.prisma.dkvVehicleMaster.deleteMany({ where: { tenantId } }); - await this.prisma.dkvVehicleMaster.createMany({ + await tenantPrisma.dkvVehicleMaster.deleteMany({ where: { tenantId } }); + await tenantPrisma.dkvVehicleMaster.createMany({ data: vehicles.map((v) => ({ tenantId, ...v })), }); } else { // Merge: upsert by (tenantId, kennzeichen) compound unique key for (const v of vehicles) { - await this.prisma.dkvVehicleMaster.upsert({ + await tenantPrisma.dkvVehicleMaster.upsert({ where: { tenantId_kennzeichen: { tenantId, kennzeichen: v.kennzeichen } }, create: { tenantId, ...v }, update: { marke: v.marke, modell: v.modell, fahrer: v.fahrer }, @@ -606,6 +633,11 @@ export class DkvService { * Get paginated processing history for a tenant. * Ordered by datumZeit descending (most recent first). * T-07-06: pagination prevents unbounded result-set DoS. + * + * Mandantengebunden (260909-mir): EIN gebundener Klient fuer beide + * Abfragen, die ueber `Promise.all` parallel laufen — das ist die + * Nebenlaeufigkeitsform, auf die sich dieser Bereich stuetzt (Befund C, + * in Aufgabe 1 gemessen: `dkv-zwei-parallele-gebundene-einzelabfragen-je-eigener-kontext`). */ async getHistory( tenantId: string, @@ -617,15 +649,16 @@ export class DkvService { page: number; limit: number; }> { + const tenantPrisma = forTenant(this.prisma, tenantId) as any; const skip = (page - 1) * limit; const [items, total] = await Promise.all([ - this.prisma.dkvInvoiceHistory.findMany({ + tenantPrisma.dkvInvoiceHistory.findMany({ where: { tenantId }, orderBy: { datumZeit: 'desc' }, skip, take: limit, }), - this.prisma.dkvInvoiceHistory.count({ where: { tenantId } }), + tenantPrisma.dkvInvoiceHistory.count({ where: { tenantId } }), ]); return { items, total, page, limit }; } @@ -639,11 +672,25 @@ export class DkvService { * pattern `DKV_*.xlsx` before reading. Rejects any filename containing path * separators, `..`, or characters outside the expected character set. * - * @throws BadRequestException when filename fails validation - * @throws NotFoundException when the file does not exist + * OWNERSHIP GATE (260909-mir, Befund E/T-MIR-02 — closes the ldap-class + * gap of this area): `user-files/` is a directory SHARED by all tenants + * (Befund F/T-MIR-08), so the traversal-safe filename pattern alone never + * proved this tenant owns the file — any tenant admin could download + * another tenant's export given (or guessed at) the filename. A bound + * read against `DkvInvoiceHistory.exportFilename` now decides ownership. + * This DELIBERATELY changes behavior: a file that sits on disk but names + * no history row for this tenant is no longer downloadable — that is the + * intent, not a bug. Absence (no DB row) and foreign ownership (a DB row + * under a different tenant) collapse to the SAME NotFoundException so the + * response reveals nothing about whether a foreign tenant's file exists. + * + * @throws BadRequestException when filename fails the pattern check + * @throws NotFoundException when no history row of THIS tenant names this + * file, or when the file is missing from disk despite an owning row */ async getExportFile(tenantId: string, filename: string): Promise { - // Traversal guard: whitelist-validate the filename before reading + // Stage 1 (unchanged, T-07-09): traversal guard, whitelist-validate the + // filename before doing anything else with it. if ( filename.includes('/') || filename.includes('\\') || @@ -653,6 +700,16 @@ export class DkvService { throw new BadRequestException('Invalid export filename'); } + // Stage 2 (NEW, 260909-mir): the ownership gate. A bound read — the + // only tenant-scoped statement of who this file belongs to. + const tenantPrisma = forTenant(this.prisma, tenantId) as any; + const owningHistoryRow = await tenantPrisma.dkvInvoiceHistory.findFirst({ + where: { tenantId, exportFilename: filename }, + }); + if (!owningHistoryRow) { + throw new NotFoundException(`Export file not found: ${filename}`); + } + const filePath = path.join(this.userFilesDir, filename); if (!fs.existsSync(filePath)) { @@ -670,6 +727,11 @@ export class DkvService { * Vehicles with no matching DkvVehicleMaster entry still appear in the export * with an empty Fahrer field (D-13). The Fahrzeug column is resolved via the * format string with empty Marke/Modell/Fahrer placeholders for unknown plates. + * + * Mandantengebunden (260909-mir): der gebuendelte Lesezugriff auf die + * Fahrzeugstammdaten laeuft ueber `forTenant()` — ungebunden wuerde + * Befund K, Stelle 7 zuschlagen: eine vollstaendige Ausfuhrdatei OHNE + * einen einzigen Fahrer, ohne Fehler, ohne Warnung. */ private async _buildExportRows( tenantId: string, @@ -677,7 +739,8 @@ export class DkvService { vehicleFormatString: string, ): Promise<{ lieferdatum: string; fahrzeug: string; fahrer: string; ort: string; kilometerstand: number | null }[]> { // Batch load vehicle master to avoid N+1 queries - const masters = await this.prisma.dkvVehicleMaster.findMany({ where: { tenantId } }); + const tenantPrisma = forTenant(this.prisma, tenantId) as any; + const masters: any[] = await tenantPrisma.dkvVehicleMaster.findMany({ where: { tenantId } }); // Normalize keys: DKV PDF may omit hyphens or use spaces ("GP JL 740E" vs "GP-JL 740E") const masterMap = new Map(masters.map((m) => [_normalizeKennzeichen(m.kennzeichen), m])); diff --git a/docs/mandantentrennung-zugriffsklassifikation.md b/docs/mandantentrennung-zugriffsklassifikation.md index f303781..f150de8 100644 --- a/docs/mandantentrennung-zugriffsklassifikation.md +++ b/docs/mandantentrennung-zugriffsklassifikation.md @@ -99,7 +99,7 @@ autoritative Quelle. | tenders | 36 | 26 | **war 62/0** — Aufgabe 2/3 (260909-laa) haben die fünf Nutzer-CRUD-Dienste (vier vollständig, `tender-rss-feed.service.ts` teilweise mit im Code begründeter WINDOWS-#19-Grenze) und die Je-Treffer-Hälften der beiden Hintergrunddienste (`tender-digest.scheduler.ts`, `tender-matching.service.ts`) auf `forTenant()` umgestellt. Die 36 verbleibenden ungebundenen Treffer sind die zwölf bewusst nicht angefassten Paare (D-03-Katalog, zwei Fan-out-Adapter) plus die drei bewusst ungebundenen RSS-Pfade plus die übergreifenden Hälften der beiden Hintergrunddienste (Etappe-3-Übergabe) | | groups | 0 | 31 | **war 37/0** — Aufgabe 2/3 (260909-jts) haben `groups.service.ts` (12 Methoden) und `module-grants.service.ts` (5 Methoden) vollständig auf `forTenant()`/`withTenantTransaction()` umgestellt. Die neun zusätzlichen, über `tx` gebundenen Zugriffe innerhalb der drei Transaktionen zählt dieses einfache Muster nicht mit (siehe Methodenhinweis oben) | | ldap | 4 | 26 | **war 21/0** — Aufgabe 2/3 (260909-ipc) haben `ldap-config.service.ts` (5 Methoden) und `ldap.service.ts` (6 Methoden, 11 Abfragen) auf `forTenant()` umgestellt. Die 4 verbleibenden ungebundenen Treffer sind bewusst: `getAllActiveConfigs`/`onApplicationBootstrap` (Befund B) und `resolveEmailForWrite` (Befund A, T-IPC-04) | -| dkv | 21 | 0 | unverändert | +| dkv | 1 | 22 | **war 21/0** — Aufgabe 2/3 (260909-mir) haben `dkv.service.ts` vollständig auf `forTenant()` umgestellt: Konfigurationspfade (`loadConfig`, `getConfigForApi`, `saveConfig`, `testConnection`), Historie, Fahrzeugstammdaten und der neue Besitzriegel vor dem Ausfuhrdatei-Download. Gebunden sind es 22 statt 21, weil der Riegel einen zusätzlichen Lesezugriff auf `dkvInvoiceHistory` einführt (T-MIR-03). Der eine verbleibende ungebundene Treffer ist der benannte Planer-Startpfad `loadAnyActiveConfigForScheduler()` (Befund D, WINDOWS #21) — bewusst, mit dreifacher Markierung | | user | 17 | 0 | unverändert | | module-registry | 17 | 0 | unverändert | | dashboard | 13 | 0 | unverändert | @@ -108,7 +108,7 @@ autoritative Quelle. | tenant | 8 | 0 | unverändert | | favorites | 7 | 0 | unverändert | | settings | 4 | 0 | unverändert | -| **Summe** | **147** | **88** | Ungebunden: war 173 vor dieser Etappe (260909-jts-Stand), Delta = die 26 in Aufgabe 2/3 (260909-laa) umgestellten `tenders`-Rohtreffer. Gebunden: war 62, jetzt zusätzlich 26 in `tenders` | +| **Summe** | **127** | **110** | Ungebunden: war 147 nach 260909-laa, Delta = die 20 in Aufgabe 2/3 (260909-mir) umgestellten `dkv`-Rohtreffer. Gebunden: war 88, jetzt zusätzlich 22 in `dkv` (20 umgestellte plus 2 neue Zugriffe des Besitzriegels). Quergemessen beim Abschluss von 260909-mir: ein roher `grep` über `apps/api/src` zählt 126 statt 127 ungebundene Treffer — die Differenz stammt aus einer geringfügig anderen Ausschlussregel für Testdateien, nicht aus einer offenen Fundstelle. Diese Übersicht ist eine Buchführungshilfe; **autoritativ ist die Fundstellentabelle unten**, die `rls-access-inventory.spec.ts` bei jedem Lauf gegen den Quelltext prüft | ## Klassen-Verteilung (nach (Datei, Modell)-Fundstellen, 62 Paare) @@ -210,9 +210,9 @@ verwendet), Klasse, Stand (`gebunden`/`ungebunden`/`gemischt`, seit | apps/api/src/dashboard/dashboard.service.ts | module | keine-mandantengebundene-tabelle | ungebunden | Modulkatalog ist plattformweit, kein `tenantId` (Migration 20260909140000, Gruppe b). | | apps/api/src/dashboard/dashboard.service.ts | searchProvider | muss-mandantengebunden | ungebunden | `tenantId` nullbar (WINDOWS #19) — heutige, tatsächlich gespeicherte Zeilen sind nutzerangelegt und tragen einen Mandanten; Vorgabe-Anbieter kommen laut 05-02 aus Konstanten, nicht aus der DB. | | apps/api/src/dashboard/dashboard.service.ts | widgetInstance | muss-mandantengebunden | ungebunden | Platzierte Dashboard-Widgets eines Nutzers, `tenantId`-Spalte vorhanden. | -| apps/api/src/dkv/dkv.service.ts | dkvInvoiceHistory | muss-mandantengebunden | ungebunden | DKV-Rechnungshistorie je Mandant, `tenantId`-Spalte vorhanden. | +| apps/api/src/dkv/dkv.service.ts | dkvInvoiceHistory | muss-mandantengebunden | gebunden | DKV-Rechnungshistorie je Mandant, `tenantId`-Spalte vorhanden. Seit 260909-mir (Aufgabe 3) laufen beide Historien-Schreibzugriffe der Verarbeitungsstrecke, beide parallelen Lesezugriffe von `getHistory` und der neue Riegel vor dem Ausfuhrdatei-Download vollstaendig ueber `forTenant()`. | | apps/api/src/dkv/dkv.service.ts | dkvModuleConfig | muss-mandantengebunden | gemischt | Postfach-/Zugangsdaten des DKV-Moduls je Mandant. Seit 260909-mir (Aufgabe 2) laufen `loadConfig`, `getConfigForApi`, `saveConfig`, `testConnection` und der Konfigurations-Lesezugriff der Verarbeitungsstrecke ueber `forTenant()`. Die Mischung stammt ausschliesslich vom einen benannten, bewusst ungebundenen Planer-Startpfad `loadAnyActiveConfigForScheduler()` (WINDOWS #21) — keine uebersehene Fundstelle. | -| apps/api/src/dkv/dkv.service.ts | dkvVehicleMaster | muss-mandantengebunden | ungebunden | Fahrzeugstammdaten des DKV-Moduls je Mandant. | +| apps/api/src/dkv/dkv.service.ts | dkvVehicleMaster | muss-mandantengebunden | gebunden | Fahrzeugstammdaten des DKV-Moduls je Mandant. Seit 260909-mir (Aufgabe 3) laufen Fahrzeugliste, Anlegen, beide Paare aus Besitzpruefung und Schreibzugriff (Aendern/Loeschen), beide Zweige des CSV-Imports und der gebuendelte Lesezugriff beim Aufbau der Ausfuhrzeilen vollstaendig ueber `forTenant()`; die vorgeschalteten Besitzpruefungen bei Aendern/Loeschen bleiben zusaetzlich bestehen (Befund G — ein gebundenes UPDATE ueber die Kennung allein trifft eine fremde Zeile still, nicht laut). | | apps/api/src/favorites/favorites.service.ts | favoriteLink | muss-mandantengebunden | ungebunden | Favoriten-Links eines Nutzers, `tenantId`-Spalte vorhanden. | | apps/api/src/groups/groups.service.ts | group | muss-mandantengebunden | gebunden | Gruppen sind je Mandant, `tenantId`-Spalte vorhanden. Alle 12 Methoden laufen seit 260909-jts (Aufgabe 2) ueber `forTenant()` bzw. `withTenantTransaction()`. | | apps/api/src/groups/groups.service.ts | groupMembership | muss-mandantengebunden | gebunden | Kein eigenes `tenantId`, RLS über Join auf `Group` (Migration 20260618112133-Nachfolger) — braucht trotzdem `forTenant()`, damit der Join-Kontext gesetzt ist. Seit 260909-jts gebunden, einschliesslich der drei Zugriffe innerhalb des Standardgruppen-Aufbaus (`ensureDefaultGroup`), die zuvor ueber den Transaktionsparameter liefen und fuer keine Pruefung dieses Projekts sichtbar waren (Befund B). |