Files
tessera-ctl/apps/api/src/proxmox/proxmox-client.service.spec.ts
T
schalli 4f8a368c9e test(260923-dhh): Proxmox-Modul Aufgabe 2 - Benutzer/Passwort, Fehlerklassen, Nur-Lesen-Riegel
- proxmox-auth.ts: loginTicket (die einzige nicht-lesende Anfrage im
  Modul, POST /access/ticket) und buildTicketCookieHeader je Produkt
  (Cookie-Namen als benannte Konstante, Annahme A2 kommentiert)
- proxmox-client.service.ts: classifyFailure (401->zugang, 403->rechte,
  404->antwortform, 5xx->server, Netzfehler->netz, Zertifikatsfehler->
  zertifikat) und parseJsonLenient (kein Wurf bei Nicht-JSON); kein
  explizites method-Feld mehr an proxmoxGet (GET ist Grundwert)
- proxmox.service.ts: Passwort-Zweig via Ticket-Anmeldung, genau ein
  zweiter Versuch nach 401 (Ticket-Ablauf alle zwei Stunden kein
  Fehlalarm)
- proxmox-nur-lesen.spec.ts: maschinischer Riegel zu D-01 — genau eine
  Stelle (proxmox-auth.ts) uebergibt ein Anfrageverfahren an
  undiciFetch, jeder Proxmox-Pfad ausserhalb laeuft ueber proxmoxGet

Tore: api 1270/1270 (>=1240), type-check 4/4.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 10:23:22 +02:00

376 lines
13 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`).
*/
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),
}));
vi.mock('../prisma/prisma-tenant.extension', () => ({
forTenant: vi.fn((p: unknown) => p),
forSystem: vi.fn((p: unknown) => p),
}));
import { validate } from 'class-validator';
import { forTenant } from '../prisma/prisma-tenant.extension';
import { CreateProxmoxServerDto } from './dto/proxmox-server.dto';
import { buildTicketCookieHeader, loginTicket } from './proxmox-auth';
import { classifyFailure, parseJsonLenient, proxmoxGet } from './proxmox-client.service';
import { ProxmoxService } from './proxmox.service';
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>();
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;
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 PASSWORD_DTO = {
name: 'pmg-1',
productType: 'pmg' as const,
baseUrl: 'https://pmg.intern:8006',
authMethod: 'password' as const,
username: 'admin@pmg',
password: 'geheimes-passwort',
};
function pveResourcesBody() {
return { data: [{ type: 'node', node: 'pve1', cpu: 0.1, maxcpu: 4, mem: 1, maxmem: 2 }] };
}
describe('classifyFailure (Aufgabe 2, <behavior>)', () => {
it('401 -> zugang, 403 -> rechte, 404 -> antwortform, 5xx -> server', () => {
expect(classifyFailure(401, null)).toBe('zugang');
expect(classifyFailure(403, null)).toBe('rechte');
expect(classifyFailure(404, null)).toBe('antwortform');
expect(classifyFailure(500, null)).toBe('server');
expect(classifyFailure(503, null)).toBe('server');
});
it('ein geworfener Netzfehler ohne Antwort wird zu netz', () => {
expect(classifyFailure(null, new Error('ECONNREFUSED'))).toBe('netz');
expect(classifyFailure(null, new Error('timeout'))).toBe('netz');
});
it('ein Zertifikatsfehler wird zu zertifikat, NICHT zu netz', () => {
const err = new Error('self signed certificate') as Error & { code?: string };
err.code = 'DEPTH_ZERO_SELF_SIGNED_CERT';
expect(classifyFailure(null, err)).toBe('zertifikat');
});
it('ein unbekannter Statuscode wird zu unbekannt', () => {
expect(classifyFailure(418, null)).toBe('unbekannt');
});
});
describe('parseJsonLenient (Aufgabe 2, <behavior>)', () => {
it('gueltiges JSON -> ok:true mit den Daten', () => {
expect(parseJsonLenient('{"a":1}')).toEqual({ ok: true, data: { a: 1 } });
});
it('kein JSON (HTML-Anmeldeseite) -> ok:false, kein Wurf', () => {
expect(() => parseJsonLenient('<html>login</html>')).not.toThrow();
expect(parseJsonLenient('<html>login</html>')).toEqual({ ok: false });
});
it('leerer Rumpf -> ok:false', () => {
expect(parseJsonLenient('')).toEqual({ ok: false });
});
});
describe('proxmoxGet — Integration gegen gemockten undici-Aufruf', () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it('401 wird zu errorKind zugang', async () => {
vi.stubGlobal('fetch', vi.fn(async () => new Response('Unauthorized', { status: 401 })));
const result = await proxmoxGet(
{ baseUrl: 'https://pve.intern', tlsRejectUnauthorized: true, headers: {} },
'/api2/json/cluster/resources',
);
expect(result.ok).toBe(false);
expect(result.errorKind).toBe('zugang');
});
it('404 wird zu errorKind antwortform', async () => {
vi.stubGlobal('fetch', vi.fn(async () => new Response('not found', { status: 404 })));
const result = await proxmoxGet(
{ baseUrl: 'https://pve.intern', tlsRejectUnauthorized: true, headers: {} },
'/api2/json/cluster/resources',
);
expect(result.errorKind).toBe('antwortform');
});
it('ein geworfener Netzfehler ohne Antwort wird zu errorKind netz', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => {
throw new Error('ECONNREFUSED');
}),
);
const result = await proxmoxGet(
{ baseUrl: 'https://pve.intern', tlsRejectUnauthorized: true, headers: {} },
'/api2/json/cluster/resources',
);
expect(result.errorKind).toBe('netz');
});
it('eine Antwort, die kein JSON ist, fuehrt zu antwortform — kein Wurf', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response('<html>Anmeldeseite</html>', { status: 200 })),
);
await expect(
proxmoxGet(
{ baseUrl: 'https://pve.intern', tlsRejectUnauthorized: true, headers: {} },
'/api2/json/cluster/resources',
),
).resolves.toMatchObject({ ok: false, errorKind: 'antwortform' });
});
it('errorDetail enthaelt niemals ein Geheimnis', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response(JSON.stringify({ errors: { password: 'invalid' } }), { status: 401 })),
);
const result = await proxmoxGet(
{
baseUrl: 'https://pve.intern',
tlsRejectUnauthorized: true,
headers: { Authorization: 'PVEAPIToken=user@pam!tok=super-geheimes-secret-xyz' },
},
'/api2/json/cluster/resources',
);
expect(result.errorDetail).not.toContain('super-geheimes-secret-xyz');
});
});
describe('Ticket-Anmeldung (loginTicket) und Cookie-Kopfzeile (Aufgabe 2, <behavior>)', () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it('POST /api2/json/access/ticket mit username/password liefert data.ticket', async () => {
const fetchSpy = vi.fn(async (url: string, options: RequestInit) => {
expect(url).toBe('https://pmg.intern:8006/api2/json/access/ticket');
expect(options.method).toBe('POST');
expect(options.body).toBe('username=admin%40pmg&password=geheimes-passwort');
return new Response(JSON.stringify({ data: { ticket: 'PMG:admin@pmg:abc123' } }), { status: 200 });
});
vi.stubGlobal('fetch', fetchSpy);
const result = await loginTicket(
{ baseUrl: 'https://pmg.intern:8006', tlsRejectUnauthorized: true },
'pmg',
'admin@pmg',
'geheimes-passwort',
);
expect(result).toEqual({ ok: true, ticket: 'PMG:admin@pmg:abc123' });
});
it('kein CSRFPreventionToken wird jemals mitgesendet', async () => {
const fetchSpy = vi.fn(async (_url: string, options: RequestInit) => {
const headerKeys = Object.keys((options.headers as Record<string, string>) ?? {});
expect(headerKeys.some((k) => k.toLowerCase().includes('csrf'))).toBe(false);
expect(String(options.body)).not.toContain('CSRF');
return new Response(JSON.stringify({ data: { ticket: 't' } }), { status: 200 });
});
vi.stubGlobal('fetch', fetchSpy);
await loginTicket({ baseUrl: 'https://pve.intern', tlsRejectUnauthorized: true }, 'pve', 'u', 'p');
});
it('Cookie-Kopfzeile traegt den produktabhaengigen Namen (PVE/PBS/PMG)', () => {
expect(buildTicketCookieHeader('pve', 'T1')).toEqual({ Cookie: 'PVEAuthCookie=T1' });
expect(buildTicketCookieHeader('pbs', 'T1')).toEqual({ Cookie: 'PBSAuthCookie=T1' });
expect(buildTicketCookieHeader('pmg', 'T1')).toEqual({ Cookie: 'PMGAuthCookie=T1' });
});
it('401 bei der Anmeldung selbst wird zu errorKind zugang', async () => {
vi.stubGlobal('fetch', vi.fn(async () => new Response('nope', { status: 401 })));
const result = await loginTicket(
{ baseUrl: 'https://pve.intern', tlsRejectUnauthorized: true },
'pve',
'u',
'falsch',
);
expect(result).toMatchObject({ ok: false, errorKind: 'zugang' });
});
});
describe('PMG + Token wird beim Speichern abgelehnt (Aufgabe 2, <behavior>)', () => {
it('DTO-Validierung schlaegt fehl fuer productType pmg + authMethod token', async () => {
const dto = new CreateProxmoxServerDto();
Object.assign(dto, {
name: 'pmg-token',
productType: 'pmg',
baseUrl: 'https://pmg.intern',
authMethod: 'token',
tokenId: 'root@pam!x',
tokenSecret: 'geheim',
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
});
it('PMG + password bleibt gueltig', async () => {
const dto = new CreateProxmoxServerDto();
Object.assign(dto, PASSWORD_DTO);
const errors = await validate(dto);
expect(errors).toEqual([]);
});
});
describe('Ticket-Erneuerung bei password-Auth (Aufgabe 2, <behavior> — genau EIN zweiter Versuch)', () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it('erstes 401 loest genau eine erneute Anmeldung aus, danach gelingt die Abfrage', async () => {
const prisma = makeFakePrisma();
const service = new ProxmoxService(prisma as any, crypto as any);
const created = await service.createServer('tenant-a', {
...PASSWORD_DTO,
productType: 'pve',
baseUrl: 'https://pve.intern',
});
let loginCalls = 0;
let getCalls = 0;
vi.stubGlobal(
'fetch',
vi.fn(async (url: string) => {
if (url.endsWith('/access/ticket')) {
loginCalls++;
return new Response(JSON.stringify({ data: { ticket: `T${loginCalls}` } }), { status: 200 });
}
getCalls++;
if (getCalls === 1) return new Response('abgelaufen', { status: 401 });
return new Response(JSON.stringify(pveResourcesBody()), { status: 200 });
}),
);
const result = await service.pollServer('tenant-a', (created as any).id);
expect(loginCalls).toBe(2);
expect(getCalls).toBe(2);
expect(result?.reachable).toBe(true);
});
it('ein zweites 401 bleibt errorKind zugang — kein dritter Versuch', async () => {
const prisma = makeFakePrisma();
const service = new ProxmoxService(prisma as any, crypto as any);
const created = await service.createServer('tenant-a', {
...PASSWORD_DTO,
productType: 'pve',
baseUrl: 'https://pve.intern',
});
let loginCalls = 0;
let getCalls = 0;
vi.stubGlobal(
'fetch',
vi.fn(async (url: string) => {
if (url.endsWith('/access/ticket')) {
loginCalls++;
return new Response(JSON.stringify({ data: { ticket: `T${loginCalls}` } }), { status: 200 });
}
getCalls++;
return new Response('abgelaufen', { status: 401 });
}),
);
const result = await service.pollServer('tenant-a', (created as any).id);
expect(loginCalls).toBe(2);
expect(getCalls).toBe(2);
expect(result?.reachable).toBe(false);
expect(result?.errorKind).toBe('zugang');
});
});
describe('forTenant bleibt Konvention auch mit Passwort-Zugang (D-08)', () => {
it('nutzt forTenant beim Anlegen', async () => {
const prisma = makeFakePrisma();
const service = new ProxmoxService(prisma as any, crypto as any);
await service.createServer('tenant-a', PASSWORD_DTO);
expect(forTenant).toHaveBeenCalled();
});
});