998aba9ef3
- proxmox-normalize.ts: nachsichtige Leser (readNumber/readText/readBool/ readList) und normalizePve/normalizePbs/normalizePmg als reine Funktionen, nie ein Wurf bei unerwarteter Form - PVE ergaenzt um je Speicherort Belegung (storages) - PBS: Belegung je Datenspeicher plus letzte Sicherung/Pruefergebnis aus bis zu 10 Folgeabfragen je Durchlauf (Deckel in proxmox.service.ts) - PMG: Tageszahlen eingehend/ausgehend/Spam/Viren - Feldnamen je Produkt als benannte Konstante (Annahmen A3/A5 der Recherche), mehrere plausible Namen je Feld moeglich - proxmox.service.ts: produktabhaengige Abfragefolge, Ticket-Erneuerung jetzt je Durchlauf statt je Aufruf (PBS-Mehrfachabfragen loggen nicht mehrfach neu ein) - proxmox-nur-lesen.spec.ts: Riegel erkennt jetzt auch den Umschlag getWithRetry als zulaessige Aufrufform Tore: api 1293/1293 (>=1240), type-check 4/4. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
188 lines
7.1 KiB
TypeScript
188 lines
7.1 KiB
TypeScript
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([]);
|
|
});
|
|
});
|