feat(260923-dhh): Proxmox-Modul Aufgabe 4 - Hintergrundabfrage je Mandant, Verbindungstest

- proxmox-scheduler.service.ts: ein Cron-Auftrag je aktivem Mandant
  (proxmox-poll:<tenantId>), onApplicationBootstrap (nicht onModuleInit,
  Tender-Muster), Abfrageintervall = kleinstes pollIntervalMin der
  aktiven Server, ein fehlgeschlagener Server bricht die Tick-Schleife
  nicht ab, refreshTenant() zieht nach jedem Speichern sofort nach
- proxmox.service.ts: loadActiveServersForScheduler() als einziger
  forSystem()-Aufruf des Moduls (Erlaubnisliste in
  rls-access-inventory.spec.ts), testConnection() schreibt nicht ins
  Zwischenlager, pollServer() bekommt eine Zehn-Sekunden-Sperre (T-DHH-06)
- proxmox.controller.ts: POST servers/:id/test, create() zieht den
  Planer nach dem Anlegen sofort nach
- Zugriffsklassifikation: proxmoxServer wechselt auf system-gebunden
  (Startpfad des Planers), proxmoxServerStatus bleibt gebunden

Tore: api 1306/1306 (>=1240), type-check 4/4, rls-access-inventory
und rls-coverage gruen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-23 14:34:11 +02:00
parent 998aba9ef3
commit fccaf8db0f
8 changed files with 621 additions and 15 deletions
+91 -4
View File
@@ -68,12 +68,19 @@ function makeFakePrisma() {
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);
if (where?.isActive !== undefined) rows = rows.filter((r) => r.isActive === where.isActive);
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;
}),
findUnique: vi.fn(
async ({ where, include }: { where: { id: string }; include?: { status?: boolean } }) => {
const row = servers.get(where.id);
if (!row) return null;
if (include?.status) {
return { ...row, status: statuses.get(row.id) ?? null };
}
return { ...row };
},
),
};
const proxmoxServerStatus = {
@@ -347,3 +354,83 @@ describe('ProxmoxService — Aufgabe 3 (PBS und PMG)', () => {
expect(result?.errorKind).toBe('rechte');
});
});
describe('ProxmoxService — Aufgabe 4 (Verbindungstest, Zehn-Sekunden-Sperre)', () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it('testConnection liefert das Ergebnis, schreibt aber NICHT ins Zwischenlager', async () => {
const prisma = makeFakePrisma();
const service = new ProxmoxService(prisma as any, crypto as any);
const created = await service.createServer('tenant-a', TOKEN_DTO);
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response(JSON.stringify(pveResourcesBody()), { status: 200 })),
);
const result = await service.testConnection('tenant-a', (created as any).id);
expect(result?.reachable).toBe(true);
const status = prisma.__statuses.get((created as any).id);
// Die leere Zwischenlagerzeile aus createServer bleibt unveraendert.
expect(status.lastPolledAt).toBeUndefined();
expect(status.reachable).toBe(false);
});
it('POST servers/:id/poll verweigert einen zweiten Durchlauf innerhalb von zehn Sekunden und liefert den vorhandenen Stand', async () => {
const prisma = makeFakePrisma();
const service = new ProxmoxService(prisma as any, crypto as any);
const created = await service.createServer('tenant-a', TOKEN_DTO);
let fetchCalls = 0;
vi.stubGlobal(
'fetch',
vi.fn(async () => {
fetchCalls++;
return new Response(JSON.stringify(pveResourcesBody()), { status: 200 });
}),
);
const first = await service.pollServer('tenant-a', (created as any).id);
const second = await service.pollServer('tenant-a', (created as any).id);
expect(fetchCalls).toBe(1);
expect(second).toEqual(first);
});
it('nach zehn Sekunden ist ein erneuter Durchlauf wieder erlaubt', async () => {
const prisma = makeFakePrisma();
const service = new ProxmoxService(prisma as any, crypto as any);
const created = await service.createServer('tenant-a', TOKEN_DTO);
let fetchCalls = 0;
vi.stubGlobal(
'fetch',
vi.fn(async () => {
fetchCalls++;
return new Response(JSON.stringify(pveResourcesBody()), { status: 200 });
}),
);
await service.pollServer('tenant-a', (created as any).id);
const status = prisma.__statuses.get((created as any).id);
status.lastPolledAt = new Date(Date.now() - 11_000); // Sperre kuenstlich veraltern
await service.pollServer('tenant-a', (created as any).id);
expect(fetchCalls).toBe(2);
});
it('loadActiveServersForScheduler nutzt forSystem (D-08, der einzige Systemkontext-Aufruf des Moduls)', async () => {
const prisma = makeFakePrisma();
const service = new ProxmoxService(prisma as any, crypto as any);
await service.createServer('tenant-a', TOKEN_DTO);
const servers = await service.loadActiveServersForScheduler();
expect(servers).toHaveLength(1);
expect(servers[0]).toMatchObject({ tenantId: 'tenant-a', pollIntervalMin: 5 });
});
});