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; } /** * Aufrufformen, deren Argumentbereich einen Proxmox-Pfad tragen darf: * `proxmoxGet` selbst, UND `getWithRetry` — der private Umschlag in * `proxmox.service.ts` (Aufgabe 2/3, Ticket-Erneuerung), der seinerseits * ausschliesslich `proxmoxGet` ruft (durch dieselbe erste Aussage dieses * Riegels abgesichert: keine zweite `undiciFetch`-Methodenstelle in dieser * Datei). */ const ALLOWED_PATH_CALLEES = ['proxmoxGet', 'getWithRetry'] as const; /** Fundstellen eines Proxmox-API-Pfads ausserhalb einer erlaubten Aufrufform. */ function findApiPathViolations(fileName: string, text: string): string[] { if (fileName === 'proxmox-auth.ts') { // Die Ticket-Anmeldung ist die eine dokumentierte Ausnahme (D-01). return []; } const allowedSpans = ALLOWED_PATH_CALLEES.flatMap((callee) => collectCallArgSpans(text, callee)); 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 insideAllowedCall = allowedSpans.some((s) => idx >= s.start && idx <= s.end); if (!insideAllowedCall) { 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([]); }); });