feat(260923-dhh): Proxmox-Modul Aufgabe 1 - PVE per Token, Ende-zu-Ende
- ProxmoxServer/ProxmoxServerStatus mit RLS (tenant_isolation_policy +
system_read_policy auf ProxmoxServer fuer den kommenden Planer)
- proxmox-auth.ts (Token-Kopfzeilen PVE/PBS), proxmox-client.service.ts
(proxmoxGet, ausschliesslich lesend, Dispatcher je Aufruf aus
tlsRejectUnauthorized, nie global)
- proxmox.service.ts: Server anlegen (Geheimnis verschluesselt,
select ohne Geheimnisfelder), Serverliste, PVE-Abfrage mit
nachsichtiger Grundauswertung (Knoten/Gaeste)
- Controller/Modul/Seed nach Domaincheck-Vorbild, Kategorie
"infrastructure", @UseModule('proxmox') + @Roles auf Schreibwegen
- Modulseite (duenne Liste) + proxmox-api.ts + Registrierung in
MODULE_REGISTRY
- Zugriffsklassifikation nachgezogen (rls-access-inventory.spec.ts gruen)
Tore: api 1247/1247 (>=1240), web 693/693, type-check 4/4, lint 5/5,
Biome apps/web 53 Warnungen (unveraendert).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,266 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
/**
|
||||
* `undici` wird gemockt, damit KEIN Test tatsaechlich ins Netz geht (Vorbild
|
||||
* `icon-discovery.service.spec.ts`) — die Mock-Klasse zeichnet nur die
|
||||
* uebergebenen `options` auf, `fetch` delegiert zur Laufzeit an
|
||||
* `globalThis.fetch`, damit `vi.stubGlobal('fetch', …)` je Test greift.
|
||||
*/
|
||||
vi.mock('undici', () => ({
|
||||
Agent: class Agent {
|
||||
constructor(public readonly options: unknown) {}
|
||||
},
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Test-Attrappe, Signatur folgt dem Original
|
||||
fetch: (...args: unknown[]) => (globalThis.fetch as any)(...args),
|
||||
}));
|
||||
|
||||
// `forTenant` gibt in diesem Test denselben Client zurueck — Mandantenbindung
|
||||
// selbst ist nicht Gegenstand dieser Datei (siehe rls-access-inventory.spec.ts).
|
||||
vi.mock('../prisma/prisma-tenant.extension', () => ({
|
||||
forTenant: vi.fn((p: unknown) => p),
|
||||
forSystem: vi.fn((p: unknown) => p),
|
||||
}));
|
||||
|
||||
import { Agent } from 'undici';
|
||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||
import { ProxmoxService } from './proxmox.service';
|
||||
import type { CreateProxmoxServerDto } from './dto/proxmox-server.dto';
|
||||
|
||||
/** Durchschaubarer Ersatz fuer AES-256-GCM — Zusammenspiel unter Test, nicht die Bibliothek. */
|
||||
const crypto = {
|
||||
encrypt: vi.fn((plaintext: string) =>
|
||||
['aa11', 'bb22', Buffer.from(plaintext, 'utf8').toString('hex')].join(':'),
|
||||
),
|
||||
decrypt: vi.fn((stored: string) => {
|
||||
const [, , ciphertext] = stored.split(':');
|
||||
return Buffer.from(ciphertext, 'hex').toString('utf8');
|
||||
}),
|
||||
};
|
||||
|
||||
function makeFakePrisma() {
|
||||
const servers = new Map<string, any>();
|
||||
const statuses = new Map<string, any>(); // key: serverId
|
||||
|
||||
function applySelect(row: any, select: Record<string, boolean> | undefined) {
|
||||
if (!select) return { ...row };
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const key of Object.keys(select)) {
|
||||
if (key === 'status') {
|
||||
out.status = statuses.get(row.id) ?? null;
|
||||
continue;
|
||||
}
|
||||
if (select[key]) out[key] = row[key];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const proxmoxServer = {
|
||||
create: vi.fn(async ({ data, select }: { data: any; select?: any }) => {
|
||||
const id = `srv-${servers.size + 1}`;
|
||||
const row = { id, createdAt: new Date(), updatedAt: new Date(), ...data };
|
||||
delete row.status; // nested create handled below
|
||||
servers.set(id, row);
|
||||
if (data.status?.create) {
|
||||
statuses.set(id, { id: `status-${id}`, serverId: id, updatedAt: new Date(), ...data.status.create });
|
||||
}
|
||||
return applySelect(row, select);
|
||||
}),
|
||||
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);
|
||||
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;
|
||||
}),
|
||||
};
|
||||
|
||||
const proxmoxServerStatus = {
|
||||
upsert: vi.fn(
|
||||
async ({
|
||||
where,
|
||||
create,
|
||||
update,
|
||||
}: {
|
||||
where: { serverId: string };
|
||||
create: Record<string, unknown>;
|
||||
update: Record<string, unknown>;
|
||||
}) => {
|
||||
const existing = statuses.get(where.serverId);
|
||||
const record = existing
|
||||
? { ...existing, ...update }
|
||||
: { id: `status-${where.serverId}`, updatedAt: new Date(), ...create };
|
||||
statuses.set(where.serverId, record);
|
||||
return { ...record };
|
||||
},
|
||||
),
|
||||
};
|
||||
|
||||
return { proxmoxServer, proxmoxServerStatus, __servers: servers, __statuses: statuses };
|
||||
}
|
||||
|
||||
const TOKEN_DTO: CreateProxmoxServerDto = {
|
||||
name: 'pve-1',
|
||||
productType: 'pve',
|
||||
baseUrl: 'https://pve.intern:8006',
|
||||
authMethod: 'token',
|
||||
tokenId: 'root@pam!tessera',
|
||||
tokenSecret: 'geheimes-token-secret',
|
||||
};
|
||||
|
||||
function pveResourcesBody(overrides: Partial<Record<string, unknown>> = {}) {
|
||||
return {
|
||||
data: [
|
||||
{ type: 'node', node: 'pve1', cpu: 0.12, maxcpu: 8, mem: 4_000_000_000, maxmem: 16_000_000_000 },
|
||||
{ type: 'qemu', node: 'pve1', vmid: 100, status: 'running' },
|
||||
{ type: 'qemu', node: 'pve1', vmid: 101, status: 'stopped' },
|
||||
{ type: 'lxc', node: 'pve1', vmid: 200, status: 'running' },
|
||||
],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('ProxmoxService — Aufgabe 1 (PVE per Token, durchgehender Weg)', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('legt einen Server verschluesselt an und liefert nie das Geheimnis zurueck', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new ProxmoxService(prisma as any, crypto as any);
|
||||
|
||||
const created = await service.createServer('tenant-a', TOKEN_DTO);
|
||||
|
||||
expect((created as any).encryptedTokenSecret).toBeUndefined();
|
||||
expect((created as any).encryptedPassword).toBeUndefined();
|
||||
|
||||
const storedRow = [...prisma.__servers.values()][0];
|
||||
expect(storedRow.encryptedTokenSecret).not.toBe(TOKEN_DTO.tokenSecret);
|
||||
expect(storedRow.encryptedTokenSecret).toMatch(/^[0-9a-f]+:[0-9a-f]+:[0-9a-f]*$/i);
|
||||
});
|
||||
|
||||
it('listWithStatus liefert weder encryptedTokenSecret noch encryptedPassword', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new ProxmoxService(prisma as any, crypto as any);
|
||||
await service.createServer('tenant-a', TOKEN_DTO);
|
||||
|
||||
const list = await service.listWithStatus('tenant-a');
|
||||
|
||||
expect(list).toHaveLength(1);
|
||||
expect(JSON.stringify(list)).not.toContain(TOKEN_DTO.tokenSecret);
|
||||
expect('encryptedTokenSecret' in (list[0] as object)).toBe(false);
|
||||
expect('encryptedPassword' in (list[0] as object)).toBe(false);
|
||||
});
|
||||
|
||||
it('pollServer fragt PVE ab, normalisiert nachsichtig und schreibt das Zwischenlager', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new ProxmoxService(prisma as any, crypto as any);
|
||||
const created = await service.createServer('tenant-a', TOKEN_DTO);
|
||||
|
||||
const fetchSpy = vi.fn(async (url: string) => {
|
||||
expect(url).toBe('https://pve.intern:8006/api2/json/cluster/resources');
|
||||
return new Response(JSON.stringify(pveResourcesBody()), { 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: 'pve',
|
||||
nodeCount: 1,
|
||||
guestsRunning: 2,
|
||||
guestsStopped: 1,
|
||||
});
|
||||
|
||||
const status = prisma.__statuses.get((created as any).id);
|
||||
expect(status.reachable).toBe(true);
|
||||
expect(status.metrics).toMatchObject({ nodeCount: 1 });
|
||||
expect(status.rawSample).toContain('"node":"pve1"');
|
||||
});
|
||||
|
||||
it('sendet die Token-Kopfzeile im PVE-Schema (Gleichheitszeichen vor dem Geheimnis)', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new ProxmoxService(prisma as any, crypto as any);
|
||||
const created = await service.createServer('tenant-a', TOKEN_DTO);
|
||||
|
||||
let capturedAuth: string | null = null;
|
||||
const fetchSpy = vi.fn(async (_url: string, options: RequestInit) => {
|
||||
capturedAuth = (options.headers as Record<string, string>).Authorization;
|
||||
return new Response(JSON.stringify(pveResourcesBody()), { status: 200 });
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchSpy);
|
||||
|
||||
await service.pollServer('tenant-a', (created as any).id);
|
||||
|
||||
expect(capturedAuth).toBe(
|
||||
`PVEAPIToken=${TOKEN_DTO.tokenId}=${TOKEN_DTO.tokenSecret}`,
|
||||
);
|
||||
});
|
||||
|
||||
it('uebergibt bei tlsRejectUnauthorized=true KEINEN Dispatcher, bei false genau einen mit abgeschalteter Pruefung', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new ProxmoxService(prisma as any, crypto as any);
|
||||
|
||||
const strictServer = await service.createServer('tenant-a', TOKEN_DTO);
|
||||
const lenientServer = await service.createServer('tenant-a', {
|
||||
...TOKEN_DTO,
|
||||
name: 'pve-2',
|
||||
tlsRejectUnauthorized: false,
|
||||
});
|
||||
|
||||
const dispatchers: unknown[] = [];
|
||||
const fetchSpy = vi.fn(async (_url: string, options: RequestInit & { dispatcher?: unknown }) => {
|
||||
dispatchers.push(options.dispatcher);
|
||||
return new Response(JSON.stringify(pveResourcesBody()), { status: 200 });
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchSpy);
|
||||
|
||||
await service.pollServer('tenant-a', (strictServer as any).id);
|
||||
await service.pollServer('tenant-a', (lenientServer as any).id);
|
||||
|
||||
expect(dispatchers[0]).toBeUndefined();
|
||||
expect(dispatchers[1]).toBeInstanceOf(Agent);
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Test-Attrappe traegt `options` nicht im echten undici-Typ
|
||||
expect((dispatchers[1] as any).options).toEqual({
|
||||
connect: { rejectUnauthorized: false },
|
||||
});
|
||||
});
|
||||
|
||||
it('ein fehlendes Feld der Antwort fuehrt zu null, nicht zu einem Wurf', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new ProxmoxService(prisma as any, crypto as any);
|
||||
const created = await service.createServer('tenant-a', TOKEN_DTO);
|
||||
|
||||
const bodyWithMissingFields = {
|
||||
data: [{ type: 'node', node: 'pve1' /* cpu/maxcpu/mem/maxmem fehlen */ }],
|
||||
};
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => new Response(JSON.stringify(bodyWithMissingFields), { status: 200 })),
|
||||
);
|
||||
|
||||
const result = await service.pollServer('tenant-a', (created as any).id);
|
||||
|
||||
expect(result?.reachable).toBe(true);
|
||||
const metrics = result?.metrics as { nodes: { cpu: unknown; maxcpu: unknown; mem: unknown; maxmem: unknown }[] };
|
||||
expect(metrics.nodes[0]).toEqual({
|
||||
node: 'pve1',
|
||||
cpu: null,
|
||||
maxcpu: null,
|
||||
mem: null,
|
||||
maxmem: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('nutzt forTenant fuer jeden Datenbankzugriff (D-08)', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new ProxmoxService(prisma as any, crypto as any);
|
||||
await service.createServer('tenant-a', TOKEN_DTO);
|
||||
await service.listWithStatus('tenant-a');
|
||||
|
||||
expect(forTenant).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user