From 4f8a368c9e2f69d1fcf2a2b7f1103d4a7a695861 Mon Sep 17 00:00:00 2001 From: Schalli Date: Wed, 23 Sep 2026 10:23:22 +0200 Subject: [PATCH] test(260923-dhh): Proxmox-Modul Aufgabe 2 - Benutzer/Passwort, Fehlerklassen, Nur-Lesen-Riegel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - proxmox-auth.ts: loginTicket (die einzige nicht-lesende Anfrage im Modul, POST /access/ticket) und buildTicketCookieHeader je Produkt (Cookie-Namen als benannte Konstante, Annahme A2 kommentiert) - proxmox-client.service.ts: classifyFailure (401->zugang, 403->rechte, 404->antwortform, 5xx->server, Netzfehler->netz, Zertifikatsfehler-> zertifikat) und parseJsonLenient (kein Wurf bei Nicht-JSON); kein explizites method-Feld mehr an proxmoxGet (GET ist Grundwert) - proxmox.service.ts: Passwort-Zweig via Ticket-Anmeldung, genau ein zweiter Versuch nach 401 (Ticket-Ablauf alle zwei Stunden kein Fehlalarm) - proxmox-nur-lesen.spec.ts: maschinischer Riegel zu D-01 — genau eine Stelle (proxmox-auth.ts) uebergibt ein Anfrageverfahren an undiciFetch, jeder Proxmox-Pfad ausserhalb laeuft ueber proxmoxGet Tore: api 1270/1270 (>=1240), type-check 4/4. Co-Authored-By: Claude Sonnet 5 --- apps/api/src/proxmox/proxmox-auth.ts | 110 ++++- .../proxmox/proxmox-client.service.spec.ts | 375 ++++++++++++++++++ .../api/src/proxmox/proxmox-client.service.ts | 6 +- .../api/src/proxmox/proxmox-nur-lesen.spec.ts | 177 +++++++++ apps/api/src/proxmox/proxmox.service.ts | 85 +++- 5 files changed, 729 insertions(+), 24 deletions(-) create mode 100644 apps/api/src/proxmox/proxmox-client.service.spec.ts create mode 100644 apps/api/src/proxmox/proxmox-nur-lesen.spec.ts diff --git a/apps/api/src/proxmox/proxmox-auth.ts b/apps/api/src/proxmox/proxmox-auth.ts index 431bed9..504568e 100644 --- a/apps/api/src/proxmox/proxmox-auth.ts +++ b/apps/api/src/proxmox/proxmox-auth.ts @@ -1,4 +1,6 @@ -import type { ProxmoxProductType } from './proxmox.types'; +import { Agent, fetch as undiciFetch } from 'undici'; +import { classifyFailure, parseJsonLenient } from './proxmox-client.service'; +import type { ProxmoxErrorKind, ProxmoxProductType } from './proxmox.types'; /** * Die EINZIGE Stelle im gesamten Modul, die Anmeldeinformationen in @@ -33,3 +35,109 @@ export function buildTokenAuthHeader( 'PMG unterstuetzt keinen API-Token-Zugang (Annahme A1 der Recherche) — dieser Aufruf haette bereits beim Speichern des Servers abgelehnt werden muessen.', ); } + +/** 8 Sekunden — derselbe Wert wie `proxmox-client.service.ts` (Proxmox-Server stehen im lokalen Netz). */ +const TICKET_LOGIN_TIMEOUT_MS = 8000; + +/** + * Cookie-Name je Produkt, unter dem Folgeanfragen das Ticket mitfuehren. + * PVE ist woertlich aus der offiziellen Wiki-Seite zitiert; PBS und PMG + * sind aus dem Muster ABGELEITET, NICHT in der Doku bestaetigt (Recherche, + * Annahme A2) — der Nutzer bestaetigt sie an seinen echten Servern. Steht + * dort ein anderer Name, ist GENAU DIESE Konstante anzupassen, sonst nichts. + */ +const TICKET_COOKIE_NAME: Record = { + pve: 'PVEAuthCookie', + pbs: 'PBSAuthCookie', // ANNAHME A2 — abgeleitet, nicht in pbs.proxmox.com/docs bestaetigt + pmg: 'PMGAuthCookie', // ANNAHME A2 — abgeleitet, nicht im pmg-admin-guide bestaetigt +}; + +/** Cookie-Kopfzeile fuer eine Ticket-Folgeanfrage. Kein `CSRFPreventionToken` — dieses Modul liest nur (D-01, Recherche Block 1). */ +export function buildTicketCookieHeader( + productType: ProxmoxProductType, + ticket: string, +): { Cookie: string } { + return { Cookie: `${TICKET_COOKIE_NAME[productType]}=${ticket}` }; +} + +export type LoginTicketResult = + | { ok: true; ticket: string } + | { ok: false; errorKind: ProxmoxErrorKind; errorDetail: string }; + +/** + * Ticket-Anmeldung — die EINZIGE Stelle im gesamten Modul, die eine + * NICHT-lesende Anfrage an Proxmox schickt (D-01, `proxmox-nur- + * lesen.spec.ts` zaehlt das maschinell nach). Sie aendert bei Proxmox + * nichts — sie holt nur einen Nachweis (ein Ticket) ab, mit dem + * Folgeanfragen sich als der eingetragene Benutzer ausweisen. Wie + * `proxmoxGet` wirft sie nach aussen nichts: jeder Fehlerfall landet als + * Ergebniswert. + */ +export async function loginTicket( + target: { baseUrl: string; tlsRejectUnauthorized: boolean }, + productType: ProxmoxProductType, + username: string, + password: string, +): Promise { + const dispatcher = target.tlsRejectUnauthorized + ? undefined + : new Agent({ connect: { rejectUnauthorized: false } }); + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), TICKET_LOGIN_TIMEOUT_MS); + const url = `${target.baseUrl.replace(/\/+$/, '')}/api2/json/access/ticket`; + + try { + const body = new URLSearchParams({ username, password }); + // GENAU HIER, und nirgendwo sonst im Modul, wird ein Anfrageverfahren + // explizit an `undiciFetch` uebergeben (`method: 'POST'`) — der + // maschinelle Riegel `proxmox-nur-lesen.spec.ts` erwartet diese Zahl + // als exakt EINS. + const response = await undiciFetch(url, { + method: 'POST', + dispatcher, + signal: controller.signal, + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: body.toString(), + }); + + const text = await response.text(); + + if (!response.ok) { + return { + ok: false, + errorKind: classifyFailure(response.status, null), + errorDetail: `Ticket-Anmeldung fehlgeschlagen (Status ${response.status})`, + }; + } + + const parsed = parseJsonLenient(text); + if (!parsed.ok) { + return { + ok: false, + errorKind: 'antwortform', + errorDetail: 'Die Antwort der Ticket-Anmeldung war kein JSON.', + }; + } + + const data = (parsed.data as { data?: { ticket?: unknown } } | null)?.data; + const ticket = data && typeof data.ticket === 'string' ? data.ticket : null; + if (!ticket) { + return { + ok: false, + errorKind: 'antwortform', + errorDetail: 'Die Antwort der Ticket-Anmeldung enthielt kein Ticket.', + }; + } + + return { ok: true, ticket }; + } catch (err) { + return { + ok: false, + errorKind: classifyFailure(null, err), + errorDetail: 'Ticket-Anmeldung fehlgeschlagen: Verbindung nicht moeglich.', + }; + } finally { + clearTimeout(timeout); + } +} diff --git a/apps/api/src/proxmox/proxmox-client.service.spec.ts b/apps/api/src/proxmox/proxmox-client.service.spec.ts new file mode 100644 index 0000000..0a71587 --- /dev/null +++ b/apps/api/src/proxmox/proxmox-client.service.spec.ts @@ -0,0 +1,375 @@ +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(); + }); +}); diff --git a/apps/api/src/proxmox/proxmox-client.service.ts b/apps/api/src/proxmox/proxmox-client.service.ts index c4c5a6a..39fe02c 100644 --- a/apps/api/src/proxmox/proxmox-client.service.ts +++ b/apps/api/src/proxmox/proxmox-client.service.ts @@ -175,8 +175,12 @@ export async function proxmoxGet( const url = `${target.baseUrl.replace(/\/+$/, '')}${path}`; try { + // KEIN `method`-Feld — GET ist der Grundwert von `fetch`/`undiciFetch` + // selbst, es gibt hierfuer keinen Parameter (D-01). `proxmox-nur- + // lesen.spec.ts` zaehlt Stellen, die ein Anfrageverfahren EXPLIZIT an + // `undiciFetch` uebergeben — die einzige solche Stelle im Modul ist + // `loginTicket` in `proxmox-auth.ts` (POST, Ticket-Anmeldung, D-01). const response = await undiciFetch(url, { - method: 'GET', // fest verdrahtet — D-01, kein Parameter dafuer dispatcher, signal: controller.signal, headers: target.headers, diff --git a/apps/api/src/proxmox/proxmox-nur-lesen.spec.ts b/apps/api/src/proxmox/proxmox-nur-lesen.spec.ts new file mode 100644 index 0000000..9c227c1 --- /dev/null +++ b/apps/api/src/proxmox/proxmox-nur-lesen.spec.ts @@ -0,0 +1,177 @@ +import { readFileSync, readdirSync } from 'node:fs'; +import { basename, join } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +/** + * Der maschinelle Riegel zu D-01 ("nur beobachten") — gebaut nach dem + * Vorbild von `apps/api/src/prisma/rls-access-inventory.spec.ts`: der Test + * liest den Quelltext, nicht das Laufzeitverhalten. Zwei Aussagen: + * + * 1. Die Summe der Stellen, die ein Anfrageverfahren EXPLIZIT an + * `undiciFetch` uebergeben (`method: '...'`), ist genau + * `EXPECTED_METHOD_PASSING_CALLS` und liegt in `proxmox-auth.ts` + * (die Ticket-Anmeldung, `loginTicket` — die einzige nicht-lesende + * Anfrage im gesamten Modul, D-01). `proxmoxGet` in + * `proxmox-client.service.ts` uebergibt bewusst KEIN `method`-Feld: + * GET ist der Grundwert von `fetch` selbst. + * 2. Jeder gegen einen Proxmox-API-Pfad (`/api2/json/...`) gebauter Aufruf + * ausser der Ticket-Anmeldung laeuft ueber `proxmoxGet(...)`. + * + * Die erwartete Zahl steht als benannte Konstante mit ausgeschriebener + * Begruendung — eine spaetere Erhoehung erzwingt eine bewusste + * Entscheidung, statt unbemerkt durchzurutschen (T-DHH-07). + */ + +/** + * GENAU EIN Aufruf darf im gesamten Modul ein Anfrageverfahren explizit an + * `undiciFetch` uebergeben: `loginTicket()` in `proxmox-auth.ts` + * (`method: 'POST'`, Ticket-Anmeldung). Jede weitere Stelle waere ein neuer, + * bislang unbedachter veraendernder Weg gegen Proxmox — T-DHH-07. + */ +const EXPECTED_METHOD_PASSING_CALLS = 1; +const EXPECTED_METHOD_PASSING_FILE = 'proxmox-auth.ts'; + +const PROXMOX_SRC_DIR = join(__dirname); + +function listTsFiles(dir: string): string[] { + const out: string[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) { + out.push(...listTsFiles(full)); + } else if (entry.isFile() && entry.name.endsWith('.ts')) { + out.push(full); + } + } + return out; +} + +/** Entfernt Zeilen- und Blockkommentare — Vorbild `rls-access-inventory.spec.ts`. */ +function stripComments(source: string): string { + return source + .replace(/\/\*[\s\S]*?\*\//g, '') + .split('\n') + .filter((line) => !line.trim().startsWith('//')) + .join('\n'); +} + +/** + * Entfernt zusaetzlich Zeichenkettenliterale (nach dem Kommentar-Entfernen) + * — fuer Testdateien, damit eine erfundene Testkonstante wie + * `'https://x/api2/json/...'` in einer `expect(...)`-Zeile oder ein + * mockierter Antwortkoerper nicht als Fundstelle zaehlt (Plan-Vorgabe: + * "entfernt vor dem Zaehlen Kommentarzeilen und Zeichenkettenliterale aus + * Testdateien"). + */ +function stripStringLiterals(source: string): string { + return source + .replace(/`(?:[^`\\]|\\.)*`/g, '``') + .replace(/"(?:[^"\\]|\\.)*"/g, '""') + .replace(/'(?:[^'\\]|\\.)*'/g, "''"); +} + +interface CallSpan { + start: number; + end: number; +} + +/** Sammelt Argumentbereiche aller Aufrufe `calleeName(...)` per Klammertiefe. */ +function collectCallArgSpans(text: string, calleeName: string): CallSpan[] { + const spans: CallSpan[] = []; + const re = new RegExp(`\\b${calleeName}\\(`, 'g'); + let m: RegExpExecArray | null; + // biome-ignore lint/suspicious/noAssignInExpressions: Standard-Iterationsform der Nachbardatei rls-access-inventory.spec.ts + while ((m = re.exec(text))) { + const openIdx = re.lastIndex - 1; + let depth = 0; + let i = openIdx; + for (; i < text.length; i++) { + if (text[i] === '(') depth++; + else if (text[i] === ')') { + depth--; + if (depth === 0) break; + } + } + spans.push({ start: openIdx, end: i }); + } + return spans; +} + +/** Zaehlt Stellen, die `method:` innerhalb eines `undiciFetch(...)`-Aufrufs uebergeben. */ +function countMethodPassingCalls(text: string): number { + let count = 0; + const re = /undiciFetch\(/g; + let m: RegExpExecArray | null; + // biome-ignore lint/suspicious/noAssignInExpressions: s.o. + while ((m = re.exec(text))) { + const openIdx = re.lastIndex - 1; + let depth = 0; + let i = openIdx; + for (; i < text.length; i++) { + if (text[i] === '(') depth++; + else if (text[i] === ')') { + depth--; + if (depth === 0) break; + } + } + const argsText = text.slice(openIdx, i + 1); + if (/\bmethod\s*:/.test(argsText)) count++; + } + return count; +} + +/** Fundstellen eines Proxmox-API-Pfads ausserhalb eines `proxmoxGet(...)`-Aufrufs. */ +function findApiPathViolations(fileName: string, text: string): string[] { + if (fileName === 'proxmox-auth.ts') { + // Die Ticket-Anmeldung ist die eine dokumentierte Ausnahme (D-01). + return []; + } + const proxmoxGetSpans = collectCallArgSpans(text, 'proxmoxGet'); + const violations: string[] = []; + const pathRe = /\/api2\/json\/[A-Za-z0-9/{}_.-]*/g; + let m: RegExpExecArray | null; + // biome-ignore lint/suspicious/noAssignInExpressions: s.o. + while ((m = pathRe.exec(text))) { + const idx = m.index; + const insideProxmoxGetCall = proxmoxGetSpans.some((s) => idx >= s.start && idx <= s.end); + if (!insideProxmoxGetCall) { + violations.push(`${fileName}@${idx}: ${m[0]}`); + } + } + return violations; +} + +describe('proxmox-nur-lesen (D-01, T-DHH-07) — der maschinelle Riegel', () => { + const files = listTsFiles(PROXMOX_SRC_DIR); + + it(`genau ${EXPECTED_METHOD_PASSING_CALLS} Stelle uebergibt ein Anfrageverfahren an undiciFetch, in ${EXPECTED_METHOD_PASSING_FILE}`, () => { + const perFile = files.map((file) => { + const raw = readFileSync(file, 'utf-8'); + const isTest = file.endsWith('.spec.ts'); + const cleaned = isTest ? stripStringLiterals(stripComments(raw)) : stripComments(raw); + return { file: basename(file), count: countMethodPassingCalls(cleaned) }; + }); + + const total = perFile.reduce((sum, f) => sum + f.count, 0); + const filesWithCalls = perFile.filter((f) => f.count > 0).map((f) => f.file); + + expect(total, `Gefundene Stellen: ${JSON.stringify(perFile.filter((f) => f.count > 0))}`).toBe( + EXPECTED_METHOD_PASSING_CALLS, + ); + expect(filesWithCalls).toEqual([EXPECTED_METHOD_PASSING_FILE]); + }); + + it('jeder gegen einen Proxmox-Pfad gebaute Aufruf ausser der Ticket-Anmeldung laeuft ueber proxmoxGet', () => { + // Nur Produktionsdateien bauen tatsaechlich Aufrufe — Testdateien + // enthalten denselben Pfadtext nur als erwarteten Wert in `expect(...)`, + // das ist kein "gebauter Aufruf" im Sinn dieser Aussage. + const productionFiles = files.filter((file) => !file.endsWith('.spec.ts')); + const violations = productionFiles.flatMap((file) => { + const raw = readFileSync(file, 'utf-8'); + const cleaned = stripComments(raw); // Pfad-Texte bleiben erhalten — nur Kommentare raus + return findApiPathViolations(basename(file), cleaned); + }); + + expect(violations).toEqual([]); + }); +}); diff --git a/apps/api/src/proxmox/proxmox.service.ts b/apps/api/src/proxmox/proxmox.service.ts index 7cf6ae4..fbd749c 100644 --- a/apps/api/src/proxmox/proxmox.service.ts +++ b/apps/api/src/proxmox/proxmox.service.ts @@ -3,7 +3,7 @@ import type { ProxmoxServer } from '@prisma/client'; import { CryptoService } from '../crypto/crypto.service'; import { PrismaService } from '../prisma/prisma.service'; import { forTenant } from '../prisma/prisma-tenant.extension'; -import { buildTokenAuthHeader } from './proxmox-auth'; +import { buildTicketCookieHeader, buildTokenAuthHeader, loginTicket } from './proxmox-auth'; import { proxmoxGet } from './proxmox-client.service'; import type { CreateProxmoxServerDto } from './dto/proxmox-server.dto'; import type { @@ -183,8 +183,13 @@ export class ProxmoxService { }); } - /** Baut die Anmeldekopfzeile fuer GENAU diesen Server ueber `proxmox-auth.ts` (D-03). */ - private buildAuthHeaders(server: ProxmoxServer): AuthHeaderResult { + /** + * Baut die Anmeldekopfzeile fuer GENAU diesen Server ueber + * `proxmox-auth.ts` (D-03). Beim Passwort-Zweig loest das eine + * Ticket-Anmeldung aus (die einzige nicht-lesende Anfrage des Moduls, + * D-01) — deshalb `async`. + */ + private async buildAuthHeaders(server: ProxmoxServer): Promise { if (server.authMethod === 'token') { const tokenSecret = this.decryptSecret(server.encryptedTokenSecret); if (!server.tokenId || !tokenSecret) { @@ -203,31 +208,42 @@ export class ProxmoxService { ), }; } - // Benutzer/Passwort-Zweig (Ticket-Anmeldung) folgt in Aufgabe 2. + + const password = this.decryptSecret(server.encryptedPassword); + if (!server.username || !password) { + return { + ok: false, + errorKind: 'zugang', + errorDetail: 'Kein Benutzer/Passwort hinterlegt.', + }; + } + + const login = await loginTicket( + { baseUrl: server.baseUrl, tlsRejectUnauthorized: server.tlsRejectUnauthorized }, + server.productType as 'pve' | 'pbs' | 'pmg', + server.username, + password, + ); + if (!login.ok) { + return { ok: false, errorKind: login.errorKind, errorDetail: login.errorDetail }; + } return { - ok: false, - errorKind: 'unbekannt', - errorDetail: 'Benutzer/Passwort-Zugang wird in dieser Aufgabe noch nicht unterstuetzt.', + ok: true, + headers: buildTicketCookieHeader(server.productType as 'pve' | 'pbs' | 'pmg', login.ticket), }; } /** - * EIN Abfragedurchlauf gegen genau diesen Server. In dieser Aufgabe nur - * `pve` mit Token — Aufgabe 2 ergaenzt Benutzer/Passwort und die - * Fehlerklassen, Aufgabe 3 ergaenzt `pbs`/`pmg`. + * EIN Abfragedurchlauf gegen genau diesen Server. In dieser Aufgabe `pve` + * mit Token ODER Benutzer/Passwort (Aufgabe 3 ergaenzt `pbs`/`pmg`). + * + * Ticket-Erneuerung (Aufgabe 2, ``): laeuft der Zugang ueber + * `password` und antwortet Proxmox mit 401 (`errorKind: 'zugang'`), wird + * GENAU EINMAL neu angemeldet und die Abfrage wiederholt — bei einer + * Ticketdauer von zwei Stunden erzeugt ein normaler Ablauf sonst alle + * zwei Stunden einen Fehlalarm. Ein zweites 401 bleibt `'zugang'`. */ private async pollOne(server: ProxmoxServer): Promise { - const authHeaders = this.buildAuthHeaders(server); - if (!authHeaders.ok) { - return { - reachable: false, - errorKind: authHeaders.errorKind, - errorDetail: authHeaders.errorDetail, - metrics: null, - rawSample: null, - }; - } - if (server.productType !== 'pve') { // PBS/PMG folgen in Aufgabe 3. return { @@ -239,7 +255,18 @@ export class ProxmoxService { }; } - const result = await proxmoxGet( + const authHeaders = await this.buildAuthHeaders(server); + if (!authHeaders.ok) { + return { + reachable: false, + errorKind: authHeaders.errorKind, + errorDetail: authHeaders.errorDetail, + metrics: null, + rawSample: null, + }; + } + + let result = await proxmoxGet( { baseUrl: server.baseUrl, tlsRejectUnauthorized: server.tlsRejectUnauthorized, @@ -248,6 +275,20 @@ export class ProxmoxService { '/api2/json/cluster/resources', ); + if (!result.ok && result.errorKind === 'zugang' && server.authMethod === 'password') { + const retryHeaders = await this.buildAuthHeaders(server); + if (retryHeaders.ok) { + result = await proxmoxGet( + { + baseUrl: server.baseUrl, + tlsRejectUnauthorized: server.tlsRejectUnauthorized, + headers: retryHeaders.headers, + }, + '/api2/json/cluster/resources', + ); + } + } + if (!result.ok) { return { reachable: false,