import { afterEach, describe, expect, it, vi } from 'vitest'; /** * `undici` wird gemockt, damit KEIN Test tatsaechlich ins Netz geht (Vorbild * `icon-discovery.service.spec.ts`) — die Mock-Klasse zeichnet nur die * uebergebenen `options` auf, `fetch` delegiert zur Laufzeit an * `globalThis.fetch`, damit `vi.stubGlobal('fetch', …)` je Test greift. */ vi.mock('undici', () => ({ Agent: class Agent { constructor(public readonly options: unknown) {} }, // biome-ignore lint/suspicious/noExplicitAny: Test-Attrappe, Signatur folgt dem Original fetch: (...args: unknown[]) => (globalThis.fetch as any)(...args), })); // `forTenant` gibt in diesem Test denselben Client zurueck — Mandantenbindung // selbst ist nicht Gegenstand dieser Datei (siehe rls-access-inventory.spec.ts). vi.mock('../prisma/prisma-tenant.extension', () => ({ forTenant: vi.fn((p: unknown) => p), forSystem: vi.fn((p: unknown) => p), })); import { Agent } from 'undici'; import { forTenant } from '../prisma/prisma-tenant.extension'; import { ProxmoxService } from './proxmox.service'; import type { CreateProxmoxServerDto, UpdateProxmoxServerDto } from './dto/proxmox-server.dto'; /** Durchschaubarer Ersatz fuer AES-256-GCM — Zusammenspiel unter Test, nicht die Bibliothek. */ const crypto = { encrypt: vi.fn((plaintext: string) => ['aa11', 'bb22', Buffer.from(plaintext, 'utf8').toString('hex')].join(':'), ), decrypt: vi.fn((stored: string) => { const [, , ciphertext] = stored.split(':'); return Buffer.from(ciphertext, 'hex').toString('utf8'); }), }; function makeFakePrisma() { const servers = new Map(); const statuses = new Map(); // key: serverId function applySelect(row: any, select: Record | undefined) { if (!select) return { ...row }; const out: Record = {}; for (const key of Object.keys(select)) { if (key === 'status') { out.status = statuses.get(row.id) ?? null; continue; } if (select[key]) out[key] = row[key]; } return out; } const proxmoxServer = { create: vi.fn(async ({ data, select }: { data: any; select?: any }) => { const id = `srv-${servers.size + 1}`; const row = { id, createdAt: new Date(), updatedAt: new Date(), ...data }; delete row.status; // nested create handled below servers.set(id, row); if (data.status?.create) { statuses.set(id, { id: `status-${id}`, serverId: id, updatedAt: new Date(), ...data.status.create }); } return applySelect(row, select); }), findMany: vi.fn(async ({ where, select }: { where?: any; select?: any } = {}) => { let rows = [...servers.values()]; if (where?.tenantId) rows = rows.filter((r) => r.tenantId === where.tenantId); if (where?.isActive !== undefined) rows = rows.filter((r) => r.isActive === where.isActive); return rows.map((r) => applySelect(r, select)); }), findUnique: vi.fn( async ({ where, include }: { where: { id: string }; include?: { status?: boolean } }) => { const row = servers.get(where.id); if (!row) return null; if (include?.status) { return { ...row, status: statuses.get(row.id) ?? null }; } return { ...row }; }, ), update: vi.fn( async ({ where, data, select, }: { where: { id: string }; data: Record; select?: any; }) => { const existing = servers.get(where.id); const updated = { ...existing, ...data, updatedAt: new Date() }; servers.set(where.id, updated); return applySelect(updated, select); }, ), delete: vi.fn(async ({ where }: { where: { id: string } }) => { const row = servers.get(where.id); servers.delete(where.id); statuses.delete(where.id); // Fremdschluessel mit Loeschweitergabe (onDelete: Cascade) return row ? { ...row } : null; }), }; const proxmoxServerStatus = { upsert: vi.fn( async ({ where, create, update, }: { where: { serverId: string }; create: Record; update: Record; }) => { const existing = statuses.get(where.serverId); const record = existing ? { ...existing, ...update } : { id: `status-${where.serverId}`, updatedAt: new Date(), ...create }; statuses.set(where.serverId, record); return { ...record }; }, ), }; return { proxmoxServer, proxmoxServerStatus, __servers: servers, __statuses: statuses }; } const TOKEN_DTO: CreateProxmoxServerDto = { name: 'pve-1', productType: 'pve', baseUrl: 'https://pve.intern:8006', authMethod: 'token', tokenId: 'root@pam!tessera', tokenSecret: 'geheimes-token-secret', }; function pveResourcesBody(overrides: Partial> = {}) { return { data: [ { type: 'node', node: 'pve1', cpu: 0.12, maxcpu: 8, mem: 4_000_000_000, maxmem: 16_000_000_000 }, { type: 'qemu', node: 'pve1', vmid: 100, status: 'running' }, { type: 'qemu', node: 'pve1', vmid: 101, status: 'stopped' }, { type: 'lxc', node: 'pve1', vmid: 200, status: 'running' }, ], ...overrides, }; } describe('ProxmoxService — Aufgabe 1 (PVE per Token, durchgehender Weg)', () => { afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); }); it('legt einen Server verschluesselt an und liefert nie das Geheimnis zurueck', async () => { const prisma = makeFakePrisma(); const service = new ProxmoxService(prisma as any, crypto as any); const created = await service.createServer('tenant-a', TOKEN_DTO); expect((created as any).encryptedTokenSecret).toBeUndefined(); expect((created as any).encryptedPassword).toBeUndefined(); const storedRow = [...prisma.__servers.values()][0]; expect(storedRow.encryptedTokenSecret).not.toBe(TOKEN_DTO.tokenSecret); expect(storedRow.encryptedTokenSecret).toMatch(/^[0-9a-f]+:[0-9a-f]+:[0-9a-f]*$/i); }); it('listWithStatus liefert weder encryptedTokenSecret noch encryptedPassword', async () => { const prisma = makeFakePrisma(); const service = new ProxmoxService(prisma as any, crypto as any); await service.createServer('tenant-a', TOKEN_DTO); const list = await service.listWithStatus('tenant-a'); expect(list).toHaveLength(1); expect(JSON.stringify(list)).not.toContain(TOKEN_DTO.tokenSecret); expect('encryptedTokenSecret' in (list[0] as object)).toBe(false); expect('encryptedPassword' in (list[0] as object)).toBe(false); }); it('pollServer fragt PVE ab, normalisiert nachsichtig und schreibt das Zwischenlager', async () => { const prisma = makeFakePrisma(); const service = new ProxmoxService(prisma as any, crypto as any); const created = await service.createServer('tenant-a', TOKEN_DTO); const fetchSpy = vi.fn(async (url: string) => { expect(url).toBe('https://pve.intern:8006/api2/json/cluster/resources'); return new Response(JSON.stringify(pveResourcesBody()), { status: 200 }); }); vi.stubGlobal('fetch', fetchSpy); const result = await service.pollServer('tenant-a', (created as any).id); expect(result?.reachable).toBe(true); expect(result?.metrics).toMatchObject({ productType: 'pve', nodeCount: 1, guestsRunning: 2, guestsStopped: 1, }); const status = prisma.__statuses.get((created as any).id); expect(status.reachable).toBe(true); expect(status.metrics).toMatchObject({ nodeCount: 1 }); expect(status.rawSample).toContain('"node":"pve1"'); }); it('sendet die Token-Kopfzeile im PVE-Schema (Gleichheitszeichen vor dem Geheimnis)', async () => { const prisma = makeFakePrisma(); const service = new ProxmoxService(prisma as any, crypto as any); const created = await service.createServer('tenant-a', TOKEN_DTO); let capturedAuth: string | null = null; const fetchSpy = vi.fn(async (_url: string, options: RequestInit) => { capturedAuth = (options.headers as Record).Authorization; return new Response(JSON.stringify(pveResourcesBody()), { status: 200 }); }); vi.stubGlobal('fetch', fetchSpy); await service.pollServer('tenant-a', (created as any).id); expect(capturedAuth).toBe( `PVEAPIToken=${TOKEN_DTO.tokenId}=${TOKEN_DTO.tokenSecret}`, ); }); it('uebergibt bei tlsRejectUnauthorized=true KEINEN Dispatcher, bei false genau einen mit abgeschalteter Pruefung', async () => { const prisma = makeFakePrisma(); const service = new ProxmoxService(prisma as any, crypto as any); const strictServer = await service.createServer('tenant-a', TOKEN_DTO); const lenientServer = await service.createServer('tenant-a', { ...TOKEN_DTO, name: 'pve-2', tlsRejectUnauthorized: false, }); const dispatchers: unknown[] = []; const fetchSpy = vi.fn(async (_url: string, options: RequestInit & { dispatcher?: unknown }) => { dispatchers.push(options.dispatcher); return new Response(JSON.stringify(pveResourcesBody()), { status: 200 }); }); vi.stubGlobal('fetch', fetchSpy); await service.pollServer('tenant-a', (strictServer as any).id); await service.pollServer('tenant-a', (lenientServer as any).id); expect(dispatchers[0]).toBeUndefined(); expect(dispatchers[1]).toBeInstanceOf(Agent); // biome-ignore lint/suspicious/noExplicitAny: Test-Attrappe traegt `options` nicht im echten undici-Typ expect((dispatchers[1] as any).options).toEqual({ connect: { rejectUnauthorized: false }, }); }); it('ein fehlendes Feld der Antwort fuehrt zu null, nicht zu einem Wurf', async () => { const prisma = makeFakePrisma(); const service = new ProxmoxService(prisma as any, crypto as any); const created = await service.createServer('tenant-a', TOKEN_DTO); const bodyWithMissingFields = { data: [{ type: 'node', node: 'pve1' /* cpu/maxcpu/mem/maxmem fehlen */ }], }; vi.stubGlobal( 'fetch', vi.fn(async () => new Response(JSON.stringify(bodyWithMissingFields), { status: 200 })), ); const result = await service.pollServer('tenant-a', (created as any).id); expect(result?.reachable).toBe(true); const metrics = result?.metrics as { nodes: { cpu: unknown; maxcpu: unknown; mem: unknown; maxmem: unknown }[] }; expect(metrics.nodes[0]).toEqual({ node: 'pve1', cpu: null, maxcpu: null, mem: null, maxmem: null, }); }); it('nutzt forTenant fuer jeden Datenbankzugriff (D-08)', async () => { const prisma = makeFakePrisma(); const service = new ProxmoxService(prisma as any, crypto as any); await service.createServer('tenant-a', TOKEN_DTO); await service.listWithStatus('tenant-a'); expect(forTenant).toHaveBeenCalled(); }); }); describe('ProxmoxService — Aufgabe 3 (PBS und PMG)', () => { afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); }); it('fragt PBS ab: Belegung plus je Datenspeicher hoechstens 10 Folgeabfragen', async () => { const prisma = makeFakePrisma(); const service = new ProxmoxService(prisma as any, crypto as any); const created = await service.createServer('tenant-a', { ...TOKEN_DTO, name: 'pbs-1', productType: 'pbs', baseUrl: 'https://pbs.intern:8007', }); const usageBody = { data: Array.from({ length: 15 }, (_, i) => ({ store: `store-${i}`, total: 100, used: 10, avail: 90 })), }; let snapshotCalls = 0; const fetchSpy = vi.fn(async (url: string) => { if (url.includes('/status/datastore-usage')) { return new Response(JSON.stringify(usageBody), { status: 200 }); } snapshotCalls++; return new Response(JSON.stringify({ data: [] }), { status: 200 }); }); vi.stubGlobal('fetch', fetchSpy); const result = await service.pollServer('tenant-a', (created as any).id); expect(result?.reachable).toBe(true); expect(snapshotCalls).toBe(10); expect((result?.metrics as { datastores: unknown[] }).datastores).toHaveLength(15); }); it('fragt PMG ab und normalisiert die Tageszahlen', async () => { const prisma = makeFakePrisma(); const service = new ProxmoxService(prisma as any, crypto as any); const created = await service.createServer('tenant-a', { name: 'pmg-1', productType: 'pmg', baseUrl: 'https://pmg.intern:8006', authMethod: 'password', username: 'admin@pmg', password: 'geheim', }); const fetchSpy = vi.fn(async (url: string) => { if (url.endsWith('/access/ticket')) { return new Response(JSON.stringify({ data: { ticket: 'PMG:admin@pmg:xyz' } }), { status: 200 }); } return new Response( JSON.stringify({ data: { count_in: 10, count_out: 5, spamcount_in: 1, spamcount_out: 0, viruscount_in: 0, viruscount_out: 0 } }), { status: 200 }, ); }); vi.stubGlobal('fetch', fetchSpy); const result = await service.pollServer('tenant-a', (created as any).id); expect(result?.reachable).toBe(true); expect(result?.metrics).toMatchObject({ productType: 'pmg', countIn: 10, countOut: 5, spamCount: 1 }); }); it('401/403/404/500 bleiben fuer PBS/PMG dieselben Fehlerschluessel wie fuer PVE', async () => { const prisma = makeFakePrisma(); const service = new ProxmoxService(prisma as any, crypto as any); const created = await service.createServer('tenant-a', { ...TOKEN_DTO, name: 'pbs-403', productType: 'pbs', baseUrl: 'https://pbs.intern:8007', }); vi.stubGlobal('fetch', vi.fn(async () => new Response('forbidden', { status: 403 }))); const result = await service.pollServer('tenant-a', (created as any).id); expect(result?.reachable).toBe(false); expect(result?.errorKind).toBe('rechte'); }); }); describe('ProxmoxService — Aufgabe 4 (Verbindungstest, Zehn-Sekunden-Sperre)', () => { afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); }); it('testConnection liefert das Ergebnis, schreibt aber NICHT ins Zwischenlager', async () => { const prisma = makeFakePrisma(); const service = new ProxmoxService(prisma as any, crypto as any); const created = await service.createServer('tenant-a', TOKEN_DTO); vi.stubGlobal( 'fetch', vi.fn(async () => new Response(JSON.stringify(pveResourcesBody()), { status: 200 })), ); const result = await service.testConnection('tenant-a', (created as any).id); expect(result?.reachable).toBe(true); const status = prisma.__statuses.get((created as any).id); // Die leere Zwischenlagerzeile aus createServer bleibt unveraendert. expect(status.lastPolledAt).toBeUndefined(); expect(status.reachable).toBe(false); }); it('Nachbesserung Befund 1: testConnection prueft die im Formular abgeschaltete Zertifikatspruefung, nicht den gespeicherten Stand (Server wurde MIT tlsRejectUnauthorized:true angelegt)', async () => { const prisma = makeFakePrisma(); const service = new ProxmoxService(prisma as any, crypto as any); // Gespeichert: Zertifikatspruefung AN (Vorgabe). const created = await service.createServer('tenant-a', TOKEN_DTO); const dispatchers: unknown[] = []; vi.stubGlobal( 'fetch', vi.fn(async (_url: string, options: RequestInit & { dispatcher?: unknown }) => { dispatchers.push(options.dispatcher); return new Response(JSON.stringify(pveResourcesBody()), { status: 200 }); }), ); // Formular: Zertifikatspruefung wurde vom Nutzer AUSGESCHALTET, aber noch nicht gespeichert. await service.testConnection('tenant-a', (created as any).id, { tlsRejectUnauthorized: false, }); // Vor der Korrektur wurde ausschliesslich der gespeicherte Server (Zertifikatspruefung AN) // getestet — dieser Test waere ohne die Korrektur rot, weil dispatchers[0] dann `undefined` waere. expect(dispatchers[0]).toBeInstanceOf(Agent); // biome-ignore lint/suspicious/noExplicitAny: Test-Attrappe traegt `options` nicht im echten undici-Typ expect((dispatchers[0] as any).options).toEqual({ connect: { rejectUnauthorized: false } }); }); it('Nachbesserung Befund 1: ein im Formular NEU eingetipptes Token-Geheimnis wird getestet, nicht das gespeicherte', async () => { const prisma = makeFakePrisma(); const service = new ProxmoxService(prisma as any, crypto as any); const created = await service.createServer('tenant-a', TOKEN_DTO); let capturedAuth: string | null = null; vi.stubGlobal( 'fetch', vi.fn(async (_url: string, options: RequestInit) => { capturedAuth = (options.headers as Record).Authorization; return new Response(JSON.stringify(pveResourcesBody()), { status: 200 }); }), ); await service.testConnection('tenant-a', (created as any).id, { tokenSecret: 'ein-anderes-geheimnis', }); // Ohne die Korrektur wuerde hier weiterhin TOKEN_DTO.tokenSecret gesendet — roter Test. expect(capturedAuth).toBe(`PVEAPIToken=${TOKEN_DTO.tokenId}=ein-anderes-geheimnis`); }); it('Nachbesserung Befund 1: leer gelassenes Geheimnisfeld im Formular nutzt weiterhin das gespeicherte Token-Geheimnis', async () => { const prisma = makeFakePrisma(); const service = new ProxmoxService(prisma as any, crypto as any); const created = await service.createServer('tenant-a', TOKEN_DTO); let capturedAuth: string | null = null; vi.stubGlobal( 'fetch', vi.fn(async (_url: string, options: RequestInit) => { capturedAuth = (options.headers as Record).Authorization; return new Response(JSON.stringify(pveResourcesBody()), { status: 200 }); }), ); // Formular sendet kein tokenSecret (Feld leer gelassen) — wie `ServerForm.buildPayload()`. await service.testConnection('tenant-a', (created as any).id, {}); expect(capturedAuth).toBe(`PVEAPIToken=${TOKEN_DTO.tokenId}=${TOKEN_DTO.tokenSecret}`); }); it('Nachbesserung Befund 1: testDraftConnection testet einen noch nicht gespeicherten Server ausschliesslich mit den Formularwerten', async () => { const prisma = makeFakePrisma(); const service = new ProxmoxService(prisma as any, crypto as any); let capturedUrl: string | null = null; vi.stubGlobal( 'fetch', vi.fn(async (url: string) => { capturedUrl = url; return new Response(JSON.stringify(pveResourcesBody()), { status: 200 }); }), ); const result = await service.testDraftConnection({ productType: 'pve', baseUrl: 'https://neu.intern:8006', authMethod: 'token', tokenId: 'root@pam!neu', tokenSecret: 'frisches-geheimnis', }); expect(result.reachable).toBe(true); expect(capturedUrl).toBe('https://neu.intern:8006/api2/json/cluster/resources'); }); it('Nachbesserung Befund 1: testDraftConnection ohne Geheimnis liefert den Fehlerschluessel "zugang", statt zu werfen', async () => { const prisma = makeFakePrisma(); const service = new ProxmoxService(prisma as any, crypto as any); const result = await service.testDraftConnection({ productType: 'pve', baseUrl: 'https://neu.intern:8006', authMethod: 'token', tokenId: 'root@pam!neu', }); expect(result.reachable).toBe(false); expect(result.errorKind).toBe('zugang'); }); it('POST servers/:id/poll verweigert einen zweiten Durchlauf innerhalb von zehn Sekunden und liefert den vorhandenen Stand', async () => { const prisma = makeFakePrisma(); const service = new ProxmoxService(prisma as any, crypto as any); const created = await service.createServer('tenant-a', TOKEN_DTO); let fetchCalls = 0; vi.stubGlobal( 'fetch', vi.fn(async () => { fetchCalls++; return new Response(JSON.stringify(pveResourcesBody()), { status: 200 }); }), ); const first = await service.pollServer('tenant-a', (created as any).id); const second = await service.pollServer('tenant-a', (created as any).id); expect(fetchCalls).toBe(1); expect(second).toEqual(first); }); it('nach zehn Sekunden ist ein erneuter Durchlauf wieder erlaubt', async () => { const prisma = makeFakePrisma(); const service = new ProxmoxService(prisma as any, crypto as any); const created = await service.createServer('tenant-a', TOKEN_DTO); let fetchCalls = 0; vi.stubGlobal( 'fetch', vi.fn(async () => { fetchCalls++; return new Response(JSON.stringify(pveResourcesBody()), { status: 200 }); }), ); await service.pollServer('tenant-a', (created as any).id); const status = prisma.__statuses.get((created as any).id); status.lastPolledAt = new Date(Date.now() - 11_000); // Sperre kuenstlich veraltern await service.pollServer('tenant-a', (created as any).id); expect(fetchCalls).toBe(2); }); it('loadActiveServersForScheduler nutzt forSystem (D-08, der einzige Systemkontext-Aufruf des Moduls)', async () => { const prisma = makeFakePrisma(); const service = new ProxmoxService(prisma as any, crypto as any); await service.createServer('tenant-a', TOKEN_DTO); const servers = await service.loadActiveServersForScheduler(); expect(servers).toHaveLength(1); expect(servers[0]).toMatchObject({ tenantId: 'tenant-a', pollIntervalMin: 5 }); }); }); describe('ProxmoxService — Aufgabe 5 (Bearbeiten, Loeschen)', () => { afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); }); it('ein NICHT gesendetes Geheimnisfeld laesst den gespeicherten Wert unveraendert', async () => { const prisma = makeFakePrisma(); const service = new ProxmoxService(prisma as any, crypto as any); const created = await service.createServer('tenant-a', TOKEN_DTO); const storedBefore = prisma.__servers.get((created as any).id).encryptedTokenSecret; await service.updateServer('tenant-a', (created as any).id, { name: 'neuer-name' }); expect(prisma.__servers.get((created as any).id).encryptedTokenSecret).toBe(storedBefore); expect(prisma.__servers.get((created as any).id).name).toBe('neuer-name'); }); it('eine LEERE Zeichenkette loescht das Geheimnis, ein gefuellter Wert verschluesselt neu', async () => { const prisma = makeFakePrisma(); const service = new ProxmoxService(prisma as any, crypto as any); const created = await service.createServer('tenant-a', TOKEN_DTO); await service.updateServer('tenant-a', (created as any).id, { tokenSecret: '' }); expect(prisma.__servers.get((created as any).id).encryptedTokenSecret).toBeNull(); await service.updateServer('tenant-a', (created as any).id, { tokenSecret: 'neues-geheimnis' }); const stored = prisma.__servers.get((created as any).id).encryptedTokenSecret; expect(stored).not.toBe('neues-geheimnis'); expect(stored).toMatch(/^[0-9a-f]+:[0-9a-f]+:[0-9a-f]*$/i); }); it('PMG plus Token wird auch beim Bearbeiten abgelehnt — auch wenn nur authMethod gesendet wird', async () => { const prisma = makeFakePrisma(); const service = new ProxmoxService(prisma as any, crypto as any); const created = await service.createServer('tenant-a', { name: 'pmg-1', productType: 'pmg', baseUrl: 'https://pmg.intern', authMethod: 'password', username: 'admin@pmg', password: 'geheim', }); const dto: UpdateProxmoxServerDto = { authMethod: 'token', tokenId: 'x', tokenSecret: 'y' }; await expect(service.updateServer('tenant-a', (created as any).id, dto)).rejects.toThrow(); }); it('loescht einen Server samt Zwischenlagerzeile', async () => { const prisma = makeFakePrisma(); const service = new ProxmoxService(prisma as any, crypto as any); const created = await service.createServer('tenant-a', TOKEN_DTO); expect(prisma.__statuses.has((created as any).id)).toBe(true); const deleted = await service.deleteServer('tenant-a', (created as any).id); expect(deleted).toBe(true); expect(prisma.__servers.has((created as any).id)).toBe(false); expect(prisma.__statuses.has((created as any).id)).toBe(false); }); it('deleteServer liefert false fuer einen unbekannten Server', async () => { const prisma = makeFakePrisma(); const service = new ProxmoxService(prisma as any, crypto as any); expect(await service.deleteServer('tenant-a', 'unbekannt')).toBe(false); }); });