4f8a368c9e
- 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>
376 lines
13 KiB
TypeScript
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();
|
|
});
|
|
});
|