710034c80a
Nachbesserung aus dem Browser-Rundgang zu 260923-dhh (Befund 1): "Verbindung testen" pruefte bislang immer den in der Datenbank gespeicherten Server, nicht das ungespeicherte Formular. Eine im Formular abgeschaltete Zertifikatspruefung oder ein neu eingetipptes Token-Geheimnis wurden dadurch beim Test ignoriert und erst nach "Speichern" wirksam — eine Falle fuer genau den Ablauf, den Nutzer instinktiv waehlen (eintippen, testen, dann erst speichern). Neues `TestProxmoxServerDto` plus Merge-Baustein `resolveEffectiveTestServer` in `ProxmoxService`: normale Felder folgen dem Formular (auch wenn absichtlich geleert), Geheimnisfelder folgen der bestehenden "leer -> gespeicherten Wert behalten"-Regel, weil `ServerForm` sie beim Laden nie aus der Datenbank vorbefuellt. Neue Route `POST servers/test` (ohne `:id`) deckt die Neuanlage ab, wo es noch keinen gespeicherten Server gibt. Der Testen-Knopf steht jetzt immer zur Verfuegung, nicht mehr nur nach dem ersten Speichern. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
636 lines
24 KiB
TypeScript
636 lines
24 KiB
TypeScript
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, UpdateProxmoxServerDto } 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);
|
|
if (where?.isActive !== undefined) rows = rows.filter((r) => r.isActive === where.isActive);
|
|
return rows.map((r) => applySelect(r, select));
|
|
}),
|
|
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 };
|
|
},
|
|
),
|
|
update: vi.fn(
|
|
async ({
|
|
where,
|
|
data,
|
|
select,
|
|
}: {
|
|
where: { id: string };
|
|
data: Record<string, unknown>;
|
|
select?: any;
|
|
}) => {
|
|
const existing = servers.get(where.id);
|
|
const updated = { ...existing, ...data, updatedAt: new Date() };
|
|
servers.set(where.id, updated);
|
|
return applySelect(updated, select);
|
|
},
|
|
),
|
|
delete: vi.fn(async ({ where }: { where: { id: string } }) => {
|
|
const row = servers.get(where.id);
|
|
servers.delete(where.id);
|
|
statuses.delete(where.id); // Fremdschluessel mit Loeschweitergabe (onDelete: Cascade)
|
|
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();
|
|
});
|
|
});
|
|
|
|
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');
|
|
});
|
|
});
|
|
|
|
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('Nachbesserung Befund 1: testConnection prueft die im Formular abgeschaltete Zertifikatspruefung, nicht den gespeicherten Stand (Server wurde MIT tlsRejectUnauthorized:true angelegt)', async () => {
|
|
const prisma = makeFakePrisma();
|
|
const service = new ProxmoxService(prisma as any, crypto as any);
|
|
// Gespeichert: Zertifikatspruefung AN (Vorgabe).
|
|
const created = await service.createServer('tenant-a', TOKEN_DTO);
|
|
|
|
const dispatchers: unknown[] = [];
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn(async (_url: string, options: RequestInit & { dispatcher?: unknown }) => {
|
|
dispatchers.push(options.dispatcher);
|
|
return new Response(JSON.stringify(pveResourcesBody()), { status: 200 });
|
|
}),
|
|
);
|
|
|
|
// Formular: Zertifikatspruefung wurde vom Nutzer AUSGESCHALTET, aber noch nicht gespeichert.
|
|
await service.testConnection('tenant-a', (created as any).id, {
|
|
tlsRejectUnauthorized: false,
|
|
});
|
|
|
|
// Vor der Korrektur wurde ausschliesslich der gespeicherte Server (Zertifikatspruefung AN)
|
|
// getestet — dieser Test waere ohne die Korrektur rot, weil dispatchers[0] dann `undefined` waere.
|
|
expect(dispatchers[0]).toBeInstanceOf(Agent);
|
|
// biome-ignore lint/suspicious/noExplicitAny: Test-Attrappe traegt `options` nicht im echten undici-Typ
|
|
expect((dispatchers[0] as any).options).toEqual({ connect: { rejectUnauthorized: false } });
|
|
});
|
|
|
|
it('Nachbesserung Befund 1: ein im Formular NEU eingetipptes Token-Geheimnis wird getestet, nicht das gespeicherte', 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;
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn(async (_url: string, options: RequestInit) => {
|
|
capturedAuth = (options.headers as Record<string, string>).Authorization;
|
|
return new Response(JSON.stringify(pveResourcesBody()), { status: 200 });
|
|
}),
|
|
);
|
|
|
|
await service.testConnection('tenant-a', (created as any).id, {
|
|
tokenSecret: 'ein-anderes-geheimnis',
|
|
});
|
|
|
|
// Ohne die Korrektur wuerde hier weiterhin TOKEN_DTO.tokenSecret gesendet — roter Test.
|
|
expect(capturedAuth).toBe(`PVEAPIToken=${TOKEN_DTO.tokenId}=ein-anderes-geheimnis`);
|
|
});
|
|
|
|
it('Nachbesserung Befund 1: leer gelassenes Geheimnisfeld im Formular nutzt weiterhin das gespeicherte Token-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;
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn(async (_url: string, options: RequestInit) => {
|
|
capturedAuth = (options.headers as Record<string, string>).Authorization;
|
|
return new Response(JSON.stringify(pveResourcesBody()), { status: 200 });
|
|
}),
|
|
);
|
|
|
|
// Formular sendet kein tokenSecret (Feld leer gelassen) — wie `ServerForm.buildPayload()`.
|
|
await service.testConnection('tenant-a', (created as any).id, {});
|
|
|
|
expect(capturedAuth).toBe(`PVEAPIToken=${TOKEN_DTO.tokenId}=${TOKEN_DTO.tokenSecret}`);
|
|
});
|
|
|
|
it('Nachbesserung Befund 1: testDraftConnection testet einen noch nicht gespeicherten Server ausschliesslich mit den Formularwerten', async () => {
|
|
const prisma = makeFakePrisma();
|
|
const service = new ProxmoxService(prisma as any, crypto as any);
|
|
|
|
let capturedUrl: string | null = null;
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn(async (url: string) => {
|
|
capturedUrl = url;
|
|
return new Response(JSON.stringify(pveResourcesBody()), { status: 200 });
|
|
}),
|
|
);
|
|
|
|
const result = await service.testDraftConnection({
|
|
productType: 'pve',
|
|
baseUrl: 'https://neu.intern:8006',
|
|
authMethod: 'token',
|
|
tokenId: 'root@pam!neu',
|
|
tokenSecret: 'frisches-geheimnis',
|
|
});
|
|
|
|
expect(result.reachable).toBe(true);
|
|
expect(capturedUrl).toBe('https://neu.intern:8006/api2/json/cluster/resources');
|
|
});
|
|
|
|
it('Nachbesserung Befund 1: testDraftConnection ohne Geheimnis liefert den Fehlerschluessel "zugang", statt zu werfen', async () => {
|
|
const prisma = makeFakePrisma();
|
|
const service = new ProxmoxService(prisma as any, crypto as any);
|
|
|
|
const result = await service.testDraftConnection({
|
|
productType: 'pve',
|
|
baseUrl: 'https://neu.intern:8006',
|
|
authMethod: 'token',
|
|
tokenId: 'root@pam!neu',
|
|
});
|
|
|
|
expect(result.reachable).toBe(false);
|
|
expect(result.errorKind).toBe('zugang');
|
|
});
|
|
|
|
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 });
|
|
});
|
|
});
|
|
|
|
describe('ProxmoxService — Aufgabe 5 (Bearbeiten, Loeschen)', () => {
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
it('ein NICHT gesendetes Geheimnisfeld laesst den gespeicherten Wert unveraendert', async () => {
|
|
const prisma = makeFakePrisma();
|
|
const service = new ProxmoxService(prisma as any, crypto as any);
|
|
const created = await service.createServer('tenant-a', TOKEN_DTO);
|
|
const storedBefore = prisma.__servers.get((created as any).id).encryptedTokenSecret;
|
|
|
|
await service.updateServer('tenant-a', (created as any).id, { name: 'neuer-name' });
|
|
|
|
expect(prisma.__servers.get((created as any).id).encryptedTokenSecret).toBe(storedBefore);
|
|
expect(prisma.__servers.get((created as any).id).name).toBe('neuer-name');
|
|
});
|
|
|
|
it('eine LEERE Zeichenkette loescht das Geheimnis, ein gefuellter Wert verschluesselt neu', async () => {
|
|
const prisma = makeFakePrisma();
|
|
const service = new ProxmoxService(prisma as any, crypto as any);
|
|
const created = await service.createServer('tenant-a', TOKEN_DTO);
|
|
|
|
await service.updateServer('tenant-a', (created as any).id, { tokenSecret: '' });
|
|
expect(prisma.__servers.get((created as any).id).encryptedTokenSecret).toBeNull();
|
|
|
|
await service.updateServer('tenant-a', (created as any).id, { tokenSecret: 'neues-geheimnis' });
|
|
const stored = prisma.__servers.get((created as any).id).encryptedTokenSecret;
|
|
expect(stored).not.toBe('neues-geheimnis');
|
|
expect(stored).toMatch(/^[0-9a-f]+:[0-9a-f]+:[0-9a-f]*$/i);
|
|
});
|
|
|
|
it('PMG plus Token wird auch beim Bearbeiten abgelehnt — auch wenn nur authMethod gesendet wird', 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',
|
|
authMethod: 'password',
|
|
username: 'admin@pmg',
|
|
password: 'geheim',
|
|
});
|
|
|
|
const dto: UpdateProxmoxServerDto = { authMethod: 'token', tokenId: 'x', tokenSecret: 'y' };
|
|
await expect(service.updateServer('tenant-a', (created as any).id, dto)).rejects.toThrow();
|
|
});
|
|
|
|
it('loescht einen Server samt Zwischenlagerzeile', async () => {
|
|
const prisma = makeFakePrisma();
|
|
const service = new ProxmoxService(prisma as any, crypto as any);
|
|
const created = await service.createServer('tenant-a', TOKEN_DTO);
|
|
expect(prisma.__statuses.has((created as any).id)).toBe(true);
|
|
|
|
const deleted = await service.deleteServer('tenant-a', (created as any).id);
|
|
|
|
expect(deleted).toBe(true);
|
|
expect(prisma.__servers.has((created as any).id)).toBe(false);
|
|
expect(prisma.__statuses.has((created as any).id)).toBe(false);
|
|
});
|
|
|
|
it('deleteServer liefert false fuer einen unbekannten Server', async () => {
|
|
const prisma = makeFakePrisma();
|
|
const service = new ProxmoxService(prisma as any, crypto as any);
|
|
expect(await service.deleteServer('tenant-a', 'unbekannt')).toBe(false);
|
|
});
|
|
});
|