test(260923-dhh): Proxmox-Modul Aufgabe 2 - Benutzer/Passwort, Fehlerklassen, Nur-Lesen-Riegel

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-09-23 10:23:22 +02:00
parent 3a1bfd943e
commit 4f8a368c9e
5 changed files with 729 additions and 24 deletions
@@ -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([]);
});
});