feat(260923-dhh): Proxmox-Modul Aufgabe 3 - PBS und PMG auswerten

- 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>
This commit is contained in:
2026-09-23 10:28:25 +02:00
parent 4f8a368c9e
commit 998aba9ef3
6 changed files with 750 additions and 97 deletions
@@ -264,3 +264,86 @@ describe('ProxmoxService — Aufgabe 1 (PVE per Token, durchgehender Weg)', () =
expect(forTenant).toHaveBeenCalled();
});
});
describe('ProxmoxService — Aufgabe 3 (PBS und PMG)', () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it('fragt PBS ab: Belegung plus je Datenspeicher hoechstens 10 Folgeabfragen', async () => {
const prisma = makeFakePrisma();
const service = new ProxmoxService(prisma as any, crypto as any);
const created = await service.createServer('tenant-a', {
...TOKEN_DTO,
name: 'pbs-1',
productType: 'pbs',
baseUrl: 'https://pbs.intern:8007',
});
const usageBody = {
data: Array.from({ length: 15 }, (_, i) => ({ store: `store-${i}`, total: 100, used: 10, avail: 90 })),
};
let snapshotCalls = 0;
const fetchSpy = vi.fn(async (url: string) => {
if (url.includes('/status/datastore-usage')) {
return new Response(JSON.stringify(usageBody), { status: 200 });
}
snapshotCalls++;
return new Response(JSON.stringify({ data: [] }), { status: 200 });
});
vi.stubGlobal('fetch', fetchSpy);
const result = await service.pollServer('tenant-a', (created as any).id);
expect(result?.reachable).toBe(true);
expect(snapshotCalls).toBe(10);
expect((result?.metrics as { datastores: unknown[] }).datastores).toHaveLength(15);
});
it('fragt PMG ab und normalisiert die Tageszahlen', async () => {
const prisma = makeFakePrisma();
const service = new ProxmoxService(prisma as any, crypto as any);
const created = await service.createServer('tenant-a', {
name: 'pmg-1',
productType: 'pmg',
baseUrl: 'https://pmg.intern:8006',
authMethod: 'password',
username: 'admin@pmg',
password: 'geheim',
});
const fetchSpy = vi.fn(async (url: string) => {
if (url.endsWith('/access/ticket')) {
return new Response(JSON.stringify({ data: { ticket: 'PMG:admin@pmg:xyz' } }), { status: 200 });
}
return new Response(
JSON.stringify({ data: { count_in: 10, count_out: 5, spamcount_in: 1, spamcount_out: 0, viruscount_in: 0, viruscount_out: 0 } }),
{ status: 200 },
);
});
vi.stubGlobal('fetch', fetchSpy);
const result = await service.pollServer('tenant-a', (created as any).id);
expect(result?.reachable).toBe(true);
expect(result?.metrics).toMatchObject({ productType: 'pmg', countIn: 10, countOut: 5, spamCount: 1 });
});
it('401/403/404/500 bleiben fuer PBS/PMG dieselben Fehlerschluessel wie fuer PVE', async () => {
const prisma = makeFakePrisma();
const service = new ProxmoxService(prisma as any, crypto as any);
const created = await service.createServer('tenant-a', {
...TOKEN_DTO,
name: 'pbs-403',
productType: 'pbs',
baseUrl: 'https://pbs.intern:8007',
});
vi.stubGlobal('fetch', vi.fn(async () => new Response('forbidden', { status: 403 })));
const result = await service.pollServer('tenant-a', (created as any).id);
expect(result?.reachable).toBe(false);
expect(result?.errorKind).toBe('rechte');
});
});