import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; /** * Bindung an forTenant() — dasselbe Muster wie dashboard.service.spec.ts * (260910-krx): der gebundene Klient ist ein ZWEITES, von `prisma` * unterscheidbares Objekt ueber DEMSELBEN Speicher, das protokolliert, * welche Aufrufe ueber ihn liefen. Ein vergessener Bindungsaufruf faellt * damit auf (`prisma.dashboardImage` waere dann ohne Protokoll-Eintrag). * `forSystem` steht als Spion daneben: seit Stufe 2 (quick-260924-m4n, * Bootstrap-Umzug entfernt) darf der Dienst ihn nie mehr rufen. */ vi.mock('../prisma/prisma-tenant.extension', () => ({ forTenant: vi.fn((prisma: FakePrisma, tenantId: string, userId?: string) => prisma.__makeBoundClient(tenantId, userId), ), forSystem: vi.fn(() => { throw new Error('forSystem darf der Bilderdienst seit Stufe 2 nicht mehr rufen'); }), })); import { BadRequestException, InternalServerErrorException, NotFoundException } from '@nestjs/common'; import type { AuthUser, UploadedFileLike } from '../auth/types/auth-user'; import { forSystem, forTenant } from '../prisma/prisma-tenant.extension'; import { DashboardImagesService } from './dashboard-images.service'; /** * dashboard-images.service.spec — quick-260921-pi9 (Bilderrahmen), * erweitert in quick-260922-hk4 (Bilder auf der Festplatte statt in der * Datenbank). * * Faelle an der Grenze Dienst -> Datenbank: Liste ohne `data`, Upload ohne * Datei, Magic Bytes schlagen den behaupteten MIME-Typ in BEIDE Richtungen * (T-PI9-01), Zaehler 30 (T-PI9-03), fremder Benutzer UND fremder Mandant * -> 404 (T-PI9-04, nie 403), eigenes Bild liefert Bytes, Loeschen * eigen/fremd, und der Nachweis, dass jede Methode * `forTenant(prisma, tenantId, userId)` mit dem Benutzer als drittem * Argument aufruft. * * Dazu die Grenze Dienst -> Dateibereich (hk4, Tests 13-20): KEIN * `fs`-Mock, sondern ein echtes Verzeichnis unter `os.tmpdir()` (Muster * desktop.service.spec.ts) ueber den Testschalter * `DASHBOARD_IMAGES_DIR` — der Dienst schreibt und liest wirklich. * Geprueft werden Ablageort und Dateiname (IMMER die UUID der Zeile plus * die Endung aus dem ERKANNTEN Typ, NIE `originalName`, T-HK4-01), das * Zuruecknehmen der Zeile bei fehlgeschlagenem Schreiben (T-HK4-04), 404 * bei fehlender Datei und das Mitloeschen der Datei. * * Stufe 2 (quick-260924-m4n): die Spalte `data` ist weg, `storagePath` ist * Pflicht. Mit ihr entfallen die Faelle 10b/10c (Selbstheilung aus `data`), * 18 (Zeile ohne `storagePath`) und 21-23 (Bootstrap-Umzug). */ interface ImageRow { id: string; userId: string; tenantId: string; originalName: string; mimeType: string; size: number; storagePath: string; createdAt: Date; } interface BoundCall { tenantId: string; userId: string | undefined; model: string; method: string; } type ModelMethods = Record Promise>; interface FakePrisma { dashboardImage: ModelMethods; __rows: ImageRow[]; __boundCallLog: BoundCall[]; __makeBoundClient(tenantId: string, userId?: string): { dashboardImage: ModelMethods }; } const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13]); const JPEG = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0, 0x10, 0x4a, 0x46]); const TEXT = Buffer.from('nur Text, kein Bild'); /** Ablageort der Testdateien — echtes Verzeichnis, kein fs-Mock. */ let imagesDir: string; const ORIGINAL_DIR_ENV = process.env.DASHBOARD_IMAGES_DIR; beforeAll(() => { imagesDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tessera-dashboard-images-')); process.env.DASHBOARD_IMAGES_DIR = imagesDir; }); afterAll(() => { fs.rmSync(imagesDir, { recursive: true, force: true }); if (ORIGINAL_DIR_ENV === undefined) { delete process.env.DASHBOARD_IMAGES_DIR; } else { process.env.DASHBOARD_IMAGES_DIR = ORIGINAL_DIR_ENV; } }); /** * Eine Zeile OHNE Datei auf der Platte; der Pfad folgt der Form, die der * Dienst selbst vergibt (`storagePath` ist seit Stufe 2 Pflicht). */ function makeRow(overrides: Partial = {}): ImageRow { const id = overrides.id ?? 'img-1'; const userId = overrides.userId ?? 'user-1'; return { id, userId, tenantId: overrides.tenantId ?? 'tenant-1', originalName: overrides.originalName ?? 'foto.png', mimeType: overrides.mimeType ?? 'image/png', size: overrides.size ?? PNG.length, storagePath: overrides.storagePath ?? `user-files/dashboard-images/${userId}/${id}.png`, createdAt: overrides.createdAt ?? new Date('2026-01-01'), }; } /** * Legt eine Zeile MIT passender Datei auf der Platte an — der Normalfall * nach dem Upload (die Tests 8-12 pruefen Besitz und Bindung, nicht die * Ablage). */ function makeStoredRow(overrides: Partial = {}, bytes: Buffer = PNG): ImageRow { const row = makeRow(overrides); const absolute = path.join(imagesDir, row.userId, `${row.id}.png`); fs.mkdirSync(path.dirname(absolute), { recursive: true }); fs.writeFileSync(absolute, bytes); return row; } function storedFile(userId: string, id: string, ext = 'png'): string { return path.join(imagesDir, userId, `${id}.${ext}`); } function pick(row: ImageRow, select: Record | undefined) { if (!select) return row; const out: Record = {}; for (const key of Object.keys(select)) { if (select[key]) out[key] = row[key as keyof ImageRow]; } return out; } function makeFakePrisma(rows: ImageRow[] = []): FakePrisma { const boundCallLog: BoundCall[] = []; const dashboardImage: ModelMethods = { findMany: vi.fn(async (raw: unknown) => { const args = raw as { where: { tenantId?: string; userId?: string }; select?: Record; }; const where = args.where ?? {}; return rows .filter((r) => { if (where.tenantId !== undefined && r.tenantId !== where.tenantId) return false; if (where.userId !== undefined && r.userId !== where.userId) return false; return true; }) .slice() .sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()) .map((r) => pick(r, args.select)); }), count: vi.fn(async (raw: unknown) => { const args = raw as { where: { tenantId: string; userId: string } }; return rows.filter((r) => r.tenantId === args.where.tenantId && r.userId === args.where.userId).length; }), create: vi.fn(async (raw: unknown) => { const args = raw as { data: Partial; select?: Record }; const created = makeRow({ id: `new-${rows.length + 1}`, ...args.data, createdAt: new Date('2026-02-02') }); rows.push(created); return pick(created, args.select); }), update: vi.fn(async (raw: unknown) => { const args = raw as { where: { id: string }; data: Partial }; const row = rows.find((r) => r.id === args.where.id); if (!row) throw new Error(`update: Zeile '${args.where.id}' gibt es nicht`); Object.assign(row, args.data); return row; }), findUnique: vi.fn(async (raw: unknown) => { const args = raw as { where: { id: string } }; return rows.find((r) => r.id === args.where.id) ?? null; }), delete: vi.fn(async (raw: unknown) => { const args = raw as { where: { id: string } }; const idx = rows.findIndex((r) => r.id === args.where.id); if (idx === -1) return null; const [removed] = rows.splice(idx, 1); return removed; }), }; function wrap(tenantId: string, userId?: string) { const wrapped: ModelMethods = {}; for (const method of Object.keys(dashboardImage)) { wrapped[method] = async (...args: unknown[]) => { boundCallLog.push({ tenantId, userId, model: 'dashboardImage', method }); return dashboardImage[method](...args); }; } return { dashboardImage: wrapped }; } const fake: FakePrisma = { dashboardImage, __rows: rows, __boundCallLog: boundCallLog, __makeBoundClient(tenantId: string, userId?: string) { return wrap(tenantId, userId); }, }; return fake; } function makeService(prisma: FakePrisma) { // Der Dienst verlangt einen PrismaService; die Attrappe deckt genau das // Modell ab, das der Dienst anfasst (nur ueber den gebundenen Klienten). // `as never` statt einer Doppelzusicherung ueber unknown (Muster // tender-mail.service.spec.ts; der Zusicherungs-Zaehler bleibt bei 27). return new DashboardImagesService(prisma as never); } const user: AuthUser = { id: 'user-1', username: 'anna', role: 'USER', tenantId: 'tenant-1', mustChangePassword: false, }; function file(buffer: Buffer, mimetype: string, originalname = 'foto.png'): UploadedFileLike { return { buffer, mimetype, originalname, size: buffer.length }; } beforeEach(() => { vi.mocked(forTenant).mockClear(); vi.mocked(forSystem).mockClear(); }); describe('DashboardImagesService (quick-260921-pi9)', () => { it('Test 1: list liefert nur eigene Zeilen, nur Metadaten (nie data), aelteste zuerst', async () => { const prisma = makeFakePrisma([ makeRow({ id: 'b', createdAt: new Date('2026-03-01') }), makeRow({ id: 'a', createdAt: new Date('2026-01-01') }), makeRow({ id: 'fremd', userId: 'user-2' }), ]); const result = await makeService(prisma).list('user-1', 'tenant-1'); expect(result.map((r) => r.id)).toEqual(['a', 'b']); for (const r of result) { expect(Object.keys(r).sort()).toEqual(['createdAt', 'id', 'mimeType', 'originalName', 'size']); } const call = vi.mocked(prisma.dashboardImage.findMany).mock.calls[0][0] as { select: Record }; expect(call.select.data).toBeUndefined(); expect(call.select.storagePath).toBeUndefined(); }); it('Test 2: upload ohne Datei -> BadRequestException mit deutscher Meldung', async () => { const prisma = makeFakePrisma(); await expect(makeService(prisma).upload(user, undefined)).rejects.toThrow( new BadRequestException('Bitte wählen Sie eine Bilddatei aus.'), ); expect(prisma.dashboardImage.create).not.toHaveBeenCalled(); }); it('Test 3: PNG-Bytes mit behauptetem text/plain gelingen und speichern image/png (Magic Bytes, nicht mimetype)', async () => { const prisma = makeFakePrisma(); const result = await makeService(prisma).upload(user, file(PNG, 'text/plain', 'irgendwas.txt')); expect(result.mimeType).toBe('image/png'); expect(result.originalName).toBe('irgendwas.txt'); expect(result.size).toBe(PNG.length); expect(Object.keys(result).sort()).toEqual(['createdAt', 'id', 'mimeType', 'originalName', 'size']); expect(prisma.__rows[0].userId).toBe('user-1'); expect(prisma.__rows[0].tenantId).toBe('tenant-1'); }); it('Test 4: Textdatei mit behauptetem image/png scheitert mit deutscher Meldung, nichts wird angelegt', async () => { const prisma = makeFakePrisma(); await expect(makeService(prisma).upload(user, file(TEXT, 'image/png', 'bild.png'))).rejects.toThrow( new BadRequestException('Nur Bilder im Format PNG, JPEG, GIF oder WebP sind erlaubt.'), ); expect(prisma.dashboardImage.create).not.toHaveBeenCalled(); }); it('Test 5: Zaehler — 30 eigene Bilder blocken das 31., 29 lassen das 30. durch', async () => { const thirty = Array.from({ length: 30 }, (_, i) => makeRow({ id: `i${i}` })); const full = makeFakePrisma(thirty); await expect(makeService(full).upload(user, file(PNG, 'image/png'))).rejects.toThrow( new BadRequestException( 'Sie haben die Höchstzahl von 30 Bildern erreicht. Bitte löschen Sie zuerst ein Bild.', ), ); expect(full.dashboardImage.create).not.toHaveBeenCalled(); const almost = makeFakePrisma(thirty.slice(0, 29)); await expect(makeService(almost).upload(user, file(PNG, 'image/png'))).resolves.toMatchObject({ mimeType: 'image/png', }); }); it('Test 6: Zaehler zaehlt nur den eigenen Benutzer im eigenen Mandanten (fremde Zeilen zaehlen nicht)', async () => { const foreign = Array.from({ length: 30 }, (_, i) => makeRow({ id: `f${i}`, userId: 'user-2' })); const prisma = makeFakePrisma(foreign); await expect(makeService(prisma).upload(user, file(PNG, 'image/png'))).resolves.toMatchObject({ mimeType: 'image/png', }); const countArgs = vi.mocked(prisma.dashboardImage.count).mock.calls[0][0] as { where: unknown }; expect(countArgs.where).toEqual({ tenantId: 'tenant-1', userId: 'user-1' }); }); it('Test 7: originalName wird auf 255 Zeichen gekuerzt', async () => { const prisma = makeFakePrisma(); const result = await makeService(prisma).upload(user, file(PNG, 'image/png', 'x'.repeat(400))); expect(result.originalName).toHaveLength(255); }); it('Test 8: getBytes — fremder Benutzer (gleicher Mandant) -> NotFoundException, nie Forbidden', async () => { const prisma = makeFakePrisma([makeStoredRow({ id: 'img-1', userId: 'user-2' })]); await expect(makeService(prisma).getBytes('img-1', 'user-1', 'tenant-1')).rejects.toThrow(NotFoundException); }); it('Test 9: getBytes — fremder Mandant (gleicher Benutzer) -> NotFoundException; unbekannte Kennung ebenso', async () => { const prisma = makeFakePrisma([makeStoredRow({ id: 'img-1', tenantId: 'tenant-2' })]); const service = makeService(prisma); await expect(service.getBytes('img-1', 'user-1', 'tenant-1')).rejects.toThrow(NotFoundException); await expect(service.getBytes('gibt-es-nicht', 'user-1', 'tenant-1')).rejects.toThrow(NotFoundException); }); it('Test 10: getBytes — eigenes Bild liefert mimeType und die gespeicherten Bytes', async () => { const prisma = makeFakePrisma([makeStoredRow({ id: 'img-1' })]); const result = await makeService(prisma).getBytes('img-1', 'user-1', 'tenant-1'); expect(result.mimeType).toBe('image/png'); expect(Buffer.from(result.data).equals(PNG)).toBe(true); }); it('Test 11: remove — eigenes Bild wird geloescht und { id } geliefert; fremdes (Benutzer ODER Mandant) -> 404 ohne Loeschung', async () => { const prisma = makeFakePrisma([ makeStoredRow({ id: 'eigen' }), makeStoredRow({ id: 'fremd-user', userId: 'user-2' }), makeStoredRow({ id: 'fremd-tenant', tenantId: 'tenant-2' }), ]); const service = makeService(prisma); await expect(service.remove('eigen', 'user-1', 'tenant-1')).resolves.toEqual({ id: 'eigen' }); expect(prisma.__rows.map((r) => r.id)).toEqual(['fremd-user', 'fremd-tenant']); await expect(service.remove('fremd-user', 'user-1', 'tenant-1')).rejects.toThrow(NotFoundException); await expect(service.remove('fremd-tenant', 'user-1', 'tenant-1')).rejects.toThrow(NotFoundException); expect(prisma.__rows).toHaveLength(2); }); it('Test 12: jede Methode bindet mit (prisma, tenantId, userId) und laeuft NUR ueber den gebundenen Klienten', async () => { const prisma = makeFakePrisma([]); const service = makeService(prisma); await service.list('user-1', 'tenant-1'); const created = await service.upload(user, file(PNG, 'image/png')); await service.getBytes(created.id, 'user-1', 'tenant-1'); await service.remove(created.id, 'user-1', 'tenant-1'); expect(vi.mocked(forTenant)).toHaveBeenCalledTimes(4); for (const call of vi.mocked(forTenant).mock.calls) { expect(call[0]).toBe(prisma); expect(call[1]).toBe('tenant-1'); expect(call[2]).toBe('user-1'); } // Jeder Modellaufruf steht im Protokoll des gebundenen Klienten. Seit // Stufe 2 vergibt der Dienst die UUID selbst und legt die Zeile gleich // MIT Pfad an — kein nachtraegliches `update` mehr (m4n). const methods = prisma.__boundCallLog.map((c) => c.method); expect(methods).toEqual(['findMany', 'count', 'create', 'findUnique', 'findUnique', 'delete']); expect(vi.mocked(forSystem)).not.toHaveBeenCalled(); for (const c of prisma.__boundCallLog) { expect(c.tenantId).toBe('tenant-1'); expect(c.userId).toBe('user-1'); } }); }); describe('DashboardImagesService — Ablage im Dateibereich (quick-260922-hk4)', () => { it('Test 13: upload schreibt die Datei unter //.png und speichert den relativen Pfad in der Zeile', async () => { const prisma = makeFakePrisma(); const result = await makeService(prisma).upload(user, file(PNG, 'image/png')); const onDisk = storedFile('user-1', result.id); expect(fs.existsSync(onDisk)).toBe(true); expect(fs.readFileSync(onDisk).equals(PNG)).toBe(true); expect(prisma.__rows[0].storagePath).toBe(`user-files/dashboard-images/user-1/${result.id}.png`); // Die Zeile traegt den Pfad schon beim Anlegen (Pflichtfeld seit Stufe 2), // die Kennung ist eine vom Dienst vergebene UUID, und Bytes gehen nie in // die Zeile. const createArgs = vi.mocked(prisma.dashboardImage.create).mock.calls[0][0] as { data: Record }; expect(createArgs.data.storagePath).toBe(`user-files/dashboard-images/user-1/${result.id}.png`); expect(createArgs.data.id).toBe(result.id); expect(result.id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/); expect(createArgs.data).not.toHaveProperty('data'); expect(prisma.dashboardImage.update).not.toHaveBeenCalled(); }); it('Test 14: der Dateiname ist IMMER die UUID plus die Endung des ERKANNTEN Typs — originalName kommt nie im Pfad vor (T-HK4-01)', async () => { const prisma = makeFakePrisma(); const service = makeService(prisma); const boeserName = '../../../etc/passwd.png'; const result = await service.upload(user, file(JPEG, 'image/png', boeserName)); // Erkannt wurde JPEG (Magic Bytes), also .jpg — nicht .png aus dem Namen. expect(result.mimeType).toBe('image/jpeg'); const stored = prisma.__rows[0].storagePath; expect(stored).toBe(`user-files/dashboard-images/user-1/${result.id}.jpg`); expect(stored).not.toContain('passwd'); expect(stored).not.toContain('..'); expect(fs.existsSync(storedFile('user-1', result.id, 'jpg'))).toBe(true); // Der Anzeigename bleibt in der Zeile erhalten, nur eben als Text. expect(result.originalName).toBe(boeserName); }); it('Test 15: scheitert das Schreiben, wird die eben angelegte Zeile wieder geloescht und 500 geworfen (T-HK4-04)', async () => { const blocker = path.join(imagesDir, 'blockade'); fs.writeFileSync(blocker, 'ich bin eine Datei, kein Verzeichnis'); const vorher = process.env.DASHBOARD_IMAGES_DIR; process.env.DASHBOARD_IMAGES_DIR = path.join(blocker, 'unmoeglich'); try { const prisma = makeFakePrisma(); await expect(makeService(prisma).upload(user, file(PNG, 'image/png'))).rejects.toThrow( InternalServerErrorException, ); expect(prisma.dashboardImage.create).toHaveBeenCalledTimes(1); expect(prisma.dashboardImage.delete).toHaveBeenCalledTimes(1); expect(prisma.__rows).toHaveLength(0); } finally { process.env.DASHBOARD_IMAGES_DIR = vorher; } }); it('Test 16: getBytes liest den Dateiinhalt unter dem Pfad aus der Zeile', async () => { const prisma = makeFakePrisma([makeStoredRow({ id: 'img-16' }, JPEG)]); const result = await makeService(prisma).getBytes('img-16', 'user-1', 'tenant-1'); expect(Buffer.from(result.data).equals(JPEG)).toBe(true); }); it('Test 17: Zeile vorhanden, Datei fehlt -> NotFoundException (die Kachel zeigt „Bild nicht verfügbar")', async () => { // Eigene Kennung: das Verzeichnis ist ueber alle Tests dieser Datei // dasselbe, eine von Test 8/10 angelegte `img-1.png` waere sonst da. const prisma = makeFakePrisma([ makeRow({ id: 'datei-fehlt', storagePath: 'user-files/dashboard-images/user-1/datei-fehlt.png' }), ]); await expect(makeService(prisma).getBytes('datei-fehlt', 'user-1', 'tenant-1')).rejects.toThrow( NotFoundException, ); }); it('Test 19: remove loescht Zeile UND Datei', async () => { const prisma = makeFakePrisma([makeStoredRow({ id: 'weg' })]); const onDisk = storedFile('user-1', 'weg'); expect(fs.existsSync(onDisk)).toBe(true); await expect(makeService(prisma).remove('weg', 'user-1', 'tenant-1')).resolves.toEqual({ id: 'weg' }); expect(prisma.__rows).toHaveLength(0); expect(fs.existsSync(onDisk)).toBe(false); }); it('Test 20: fehlt die Datei beim Loeschen, gelingt das Loeschen trotzdem (eine Dateileiche ist harmloser als eine haengende Loeschung)', async () => { const prisma = makeFakePrisma([ makeRow({ id: 'nur-zeile', storagePath: 'user-files/dashboard-images/user-1/nur-zeile.png' }), ]); await expect(makeService(prisma).remove('nur-zeile', 'user-1', 'tenant-1')).resolves.toEqual({ id: 'nur-zeile', }); expect(prisma.__rows).toHaveLength(0); }); });