import { afterEach, describe, expect, it, vi } from 'vitest'; /** * `undici` wird gemockt, damit KEIN Test tatsaechlich ins Netz geht (Vorbild * `icon-discovery.service.spec.ts`). */ 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), })); vi.mock('../prisma/prisma-tenant.extension', () => ({ forTenant: vi.fn((p: unknown) => p), forSystem: vi.fn((p: unknown) => p), })); import { validate } from 'class-validator'; import { forTenant } from '../prisma/prisma-tenant.extension'; import { CreateProxmoxServerDto } from './dto/proxmox-server.dto'; import { buildTicketCookieHeader, loginTicket } from './proxmox-auth'; import { classifyFailure, parseJsonLenient, proxmoxGet } from './proxmox-client.service'; import { ProxmoxService } from './proxmox.service'; 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(); 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; 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); return rows.map((r) => applySelect(r, select)); }), findUnique: vi.fn(async ({ where }: { where: { id: string } }) => { const row = servers.get(where.id); 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 PASSWORD_DTO = { name: 'pmg-1', productType: 'pmg' as const, baseUrl: 'https://pmg.intern:8006', authMethod: 'password' as const, username: 'admin@pmg', password: 'geheimes-passwort', }; function pveResourcesBody() { return { data: [{ type: 'node', node: 'pve1', cpu: 0.1, maxcpu: 4, mem: 1, maxmem: 2 }] }; } describe('classifyFailure (Aufgabe 2, )', () => { it('401 -> zugang, 403 -> rechte, 404 -> antwortform, 5xx -> server', () => { expect(classifyFailure(401, null)).toBe('zugang'); expect(classifyFailure(403, null)).toBe('rechte'); expect(classifyFailure(404, null)).toBe('antwortform'); expect(classifyFailure(500, null)).toBe('server'); expect(classifyFailure(503, null)).toBe('server'); }); it('ein geworfener Netzfehler ohne Antwort wird zu netz', () => { expect(classifyFailure(null, new Error('ECONNREFUSED'))).toBe('netz'); expect(classifyFailure(null, new Error('timeout'))).toBe('netz'); }); it('ein Zertifikatsfehler wird zu zertifikat, NICHT zu netz', () => { const err = new Error('self signed certificate') as Error & { code?: string }; err.code = 'DEPTH_ZERO_SELF_SIGNED_CERT'; expect(classifyFailure(null, err)).toBe('zertifikat'); }); it('ein unbekannter Statuscode wird zu unbekannt', () => { expect(classifyFailure(418, null)).toBe('unbekannt'); }); }); describe('parseJsonLenient (Aufgabe 2, )', () => { it('gueltiges JSON -> ok:true mit den Daten', () => { expect(parseJsonLenient('{"a":1}')).toEqual({ ok: true, data: { a: 1 } }); }); it('kein JSON (HTML-Anmeldeseite) -> ok:false, kein Wurf', () => { expect(() => parseJsonLenient('login')).not.toThrow(); expect(parseJsonLenient('login')).toEqual({ ok: false }); }); it('leerer Rumpf -> ok:false', () => { expect(parseJsonLenient('')).toEqual({ ok: false }); }); }); describe('proxmoxGet — Integration gegen gemockten undici-Aufruf', () => { afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); }); it('401 wird zu errorKind zugang', async () => { vi.stubGlobal('fetch', vi.fn(async () => new Response('Unauthorized', { status: 401 }))); const result = await proxmoxGet( { baseUrl: 'https://pve.intern', tlsRejectUnauthorized: true, headers: {} }, '/api2/json/cluster/resources', ); expect(result.ok).toBe(false); expect(result.errorKind).toBe('zugang'); }); it('404 wird zu errorKind antwortform', async () => { vi.stubGlobal('fetch', vi.fn(async () => new Response('not found', { status: 404 }))); const result = await proxmoxGet( { baseUrl: 'https://pve.intern', tlsRejectUnauthorized: true, headers: {} }, '/api2/json/cluster/resources', ); expect(result.errorKind).toBe('antwortform'); }); it('ein geworfener Netzfehler ohne Antwort wird zu errorKind netz', async () => { vi.stubGlobal( 'fetch', vi.fn(async () => { throw new Error('ECONNREFUSED'); }), ); const result = await proxmoxGet( { baseUrl: 'https://pve.intern', tlsRejectUnauthorized: true, headers: {} }, '/api2/json/cluster/resources', ); expect(result.errorKind).toBe('netz'); }); it('eine Antwort, die kein JSON ist, fuehrt zu antwortform — kein Wurf', async () => { vi.stubGlobal( 'fetch', vi.fn(async () => new Response('Anmeldeseite', { status: 200 })), ); await expect( proxmoxGet( { baseUrl: 'https://pve.intern', tlsRejectUnauthorized: true, headers: {} }, '/api2/json/cluster/resources', ), ).resolves.toMatchObject({ ok: false, errorKind: 'antwortform' }); }); it('errorDetail enthaelt niemals ein Geheimnis', async () => { vi.stubGlobal( 'fetch', vi.fn(async () => new Response(JSON.stringify({ errors: { password: 'invalid' } }), { status: 401 })), ); const result = await proxmoxGet( { baseUrl: 'https://pve.intern', tlsRejectUnauthorized: true, headers: { Authorization: 'PVEAPIToken=user@pam!tok=super-geheimes-secret-xyz' }, }, '/api2/json/cluster/resources', ); expect(result.errorDetail).not.toContain('super-geheimes-secret-xyz'); }); }); describe('Ticket-Anmeldung (loginTicket) und Cookie-Kopfzeile (Aufgabe 2, )', () => { afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); }); it('POST /api2/json/access/ticket mit username/password liefert data.ticket', async () => { const fetchSpy = vi.fn(async (url: string, options: RequestInit) => { expect(url).toBe('https://pmg.intern:8006/api2/json/access/ticket'); expect(options.method).toBe('POST'); expect(options.body).toBe('username=admin%40pmg&password=geheimes-passwort'); return new Response(JSON.stringify({ data: { ticket: 'PMG:admin@pmg:abc123' } }), { status: 200 }); }); vi.stubGlobal('fetch', fetchSpy); const result = await loginTicket( { baseUrl: 'https://pmg.intern:8006', tlsRejectUnauthorized: true }, 'pmg', 'admin@pmg', 'geheimes-passwort', ); expect(result).toEqual({ ok: true, ticket: 'PMG:admin@pmg:abc123' }); }); it('kein CSRFPreventionToken wird jemals mitgesendet', async () => { const fetchSpy = vi.fn(async (_url: string, options: RequestInit) => { const headerKeys = Object.keys((options.headers as Record) ?? {}); expect(headerKeys.some((k) => k.toLowerCase().includes('csrf'))).toBe(false); expect(String(options.body)).not.toContain('CSRF'); return new Response(JSON.stringify({ data: { ticket: 't' } }), { status: 200 }); }); vi.stubGlobal('fetch', fetchSpy); await loginTicket({ baseUrl: 'https://pve.intern', tlsRejectUnauthorized: true }, 'pve', 'u', 'p'); }); it('Cookie-Kopfzeile traegt den produktabhaengigen Namen (PVE/PBS/PMG)', () => { expect(buildTicketCookieHeader('pve', 'T1')).toEqual({ Cookie: 'PVEAuthCookie=T1' }); expect(buildTicketCookieHeader('pbs', 'T1')).toEqual({ Cookie: 'PBSAuthCookie=T1' }); expect(buildTicketCookieHeader('pmg', 'T1')).toEqual({ Cookie: 'PMGAuthCookie=T1' }); }); it('401 bei der Anmeldung selbst wird zu errorKind zugang', async () => { vi.stubGlobal('fetch', vi.fn(async () => new Response('nope', { status: 401 }))); const result = await loginTicket( { baseUrl: 'https://pve.intern', tlsRejectUnauthorized: true }, 'pve', 'u', 'falsch', ); expect(result).toMatchObject({ ok: false, errorKind: 'zugang' }); }); }); describe('PMG + Token wird beim Speichern abgelehnt (Aufgabe 2, )', () => { it('DTO-Validierung schlaegt fehl fuer productType pmg + authMethod token', async () => { const dto = new CreateProxmoxServerDto(); Object.assign(dto, { name: 'pmg-token', productType: 'pmg', baseUrl: 'https://pmg.intern', authMethod: 'token', tokenId: 'root@pam!x', tokenSecret: 'geheim', }); const errors = await validate(dto); expect(errors.length).toBeGreaterThan(0); }); it('PMG + password bleibt gueltig', async () => { const dto = new CreateProxmoxServerDto(); Object.assign(dto, PASSWORD_DTO); const errors = await validate(dto); expect(errors).toEqual([]); }); }); describe('Ticket-Erneuerung bei password-Auth (Aufgabe 2, — genau EIN zweiter Versuch)', () => { afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); }); it('erstes 401 loest genau eine erneute Anmeldung aus, danach gelingt die Abfrage', async () => { const prisma = makeFakePrisma(); const service = new ProxmoxService(prisma as any, crypto as any); const created = await service.createServer('tenant-a', { ...PASSWORD_DTO, productType: 'pve', baseUrl: 'https://pve.intern', }); let loginCalls = 0; let getCalls = 0; vi.stubGlobal( 'fetch', vi.fn(async (url: string) => { if (url.endsWith('/access/ticket')) { loginCalls++; return new Response(JSON.stringify({ data: { ticket: `T${loginCalls}` } }), { status: 200 }); } getCalls++; if (getCalls === 1) return new Response('abgelaufen', { status: 401 }); return new Response(JSON.stringify(pveResourcesBody()), { status: 200 }); }), ); const result = await service.pollServer('tenant-a', (created as any).id); expect(loginCalls).toBe(2); expect(getCalls).toBe(2); expect(result?.reachable).toBe(true); }); it('ein zweites 401 bleibt errorKind zugang — kein dritter Versuch', async () => { const prisma = makeFakePrisma(); const service = new ProxmoxService(prisma as any, crypto as any); const created = await service.createServer('tenant-a', { ...PASSWORD_DTO, productType: 'pve', baseUrl: 'https://pve.intern', }); let loginCalls = 0; let getCalls = 0; vi.stubGlobal( 'fetch', vi.fn(async (url: string) => { if (url.endsWith('/access/ticket')) { loginCalls++; return new Response(JSON.stringify({ data: { ticket: `T${loginCalls}` } }), { status: 200 }); } getCalls++; return new Response('abgelaufen', { status: 401 }); }), ); const result = await service.pollServer('tenant-a', (created as any).id); expect(loginCalls).toBe(2); expect(getCalls).toBe(2); expect(result?.reachable).toBe(false); expect(result?.errorKind).toBe('zugang'); }); }); describe('forTenant bleibt Konvention auch mit Passwort-Zugang (D-08)', () => { it('nutzt forTenant beim Anlegen', async () => { const prisma = makeFakePrisma(); const service = new ProxmoxService(prisma as any, crypto as any); await service.createServer('tenant-a', PASSWORD_DTO); expect(forTenant).toHaveBeenCalled(); }); });