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>
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
import type { ProxmoxProductType } from './proxmox.types';
|
||||
import { Agent, fetch as undiciFetch } from 'undici';
|
||||
import { classifyFailure, parseJsonLenient } from './proxmox-client.service';
|
||||
import type { ProxmoxErrorKind, ProxmoxProductType } from './proxmox.types';
|
||||
|
||||
/**
|
||||
* Die EINZIGE Stelle im gesamten Modul, die Anmeldeinformationen in
|
||||
@@ -33,3 +35,109 @@ export function buildTokenAuthHeader(
|
||||
'PMG unterstuetzt keinen API-Token-Zugang (Annahme A1 der Recherche) — dieser Aufruf haette bereits beim Speichern des Servers abgelehnt werden muessen.',
|
||||
);
|
||||
}
|
||||
|
||||
/** 8 Sekunden — derselbe Wert wie `proxmox-client.service.ts` (Proxmox-Server stehen im lokalen Netz). */
|
||||
const TICKET_LOGIN_TIMEOUT_MS = 8000;
|
||||
|
||||
/**
|
||||
* Cookie-Name je Produkt, unter dem Folgeanfragen das Ticket mitfuehren.
|
||||
* PVE ist woertlich aus der offiziellen Wiki-Seite zitiert; PBS und PMG
|
||||
* sind aus dem Muster ABGELEITET, NICHT in der Doku bestaetigt (Recherche,
|
||||
* Annahme A2) — der Nutzer bestaetigt sie an seinen echten Servern. Steht
|
||||
* dort ein anderer Name, ist GENAU DIESE Konstante anzupassen, sonst nichts.
|
||||
*/
|
||||
const TICKET_COOKIE_NAME: Record<ProxmoxProductType, string> = {
|
||||
pve: 'PVEAuthCookie',
|
||||
pbs: 'PBSAuthCookie', // ANNAHME A2 — abgeleitet, nicht in pbs.proxmox.com/docs bestaetigt
|
||||
pmg: 'PMGAuthCookie', // ANNAHME A2 — abgeleitet, nicht im pmg-admin-guide bestaetigt
|
||||
};
|
||||
|
||||
/** Cookie-Kopfzeile fuer eine Ticket-Folgeanfrage. Kein `CSRFPreventionToken` — dieses Modul liest nur (D-01, Recherche Block 1). */
|
||||
export function buildTicketCookieHeader(
|
||||
productType: ProxmoxProductType,
|
||||
ticket: string,
|
||||
): { Cookie: string } {
|
||||
return { Cookie: `${TICKET_COOKIE_NAME[productType]}=${ticket}` };
|
||||
}
|
||||
|
||||
export type LoginTicketResult =
|
||||
| { ok: true; ticket: string }
|
||||
| { ok: false; errorKind: ProxmoxErrorKind; errorDetail: string };
|
||||
|
||||
/**
|
||||
* Ticket-Anmeldung — die EINZIGE Stelle im gesamten Modul, die eine
|
||||
* NICHT-lesende Anfrage an Proxmox schickt (D-01, `proxmox-nur-
|
||||
* lesen.spec.ts` zaehlt das maschinell nach). Sie aendert bei Proxmox
|
||||
* nichts — sie holt nur einen Nachweis (ein Ticket) ab, mit dem
|
||||
* Folgeanfragen sich als der eingetragene Benutzer ausweisen. Wie
|
||||
* `proxmoxGet` wirft sie nach aussen nichts: jeder Fehlerfall landet als
|
||||
* Ergebniswert.
|
||||
*/
|
||||
export async function loginTicket(
|
||||
target: { baseUrl: string; tlsRejectUnauthorized: boolean },
|
||||
productType: ProxmoxProductType,
|
||||
username: string,
|
||||
password: string,
|
||||
): Promise<LoginTicketResult> {
|
||||
const dispatcher = target.tlsRejectUnauthorized
|
||||
? undefined
|
||||
: new Agent({ connect: { rejectUnauthorized: false } });
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), TICKET_LOGIN_TIMEOUT_MS);
|
||||
const url = `${target.baseUrl.replace(/\/+$/, '')}/api2/json/access/ticket`;
|
||||
|
||||
try {
|
||||
const body = new URLSearchParams({ username, password });
|
||||
// GENAU HIER, und nirgendwo sonst im Modul, wird ein Anfrageverfahren
|
||||
// explizit an `undiciFetch` uebergeben (`method: 'POST'`) — der
|
||||
// maschinelle Riegel `proxmox-nur-lesen.spec.ts` erwartet diese Zahl
|
||||
// als exakt EINS.
|
||||
const response = await undiciFetch(url, {
|
||||
method: 'POST',
|
||||
dispatcher,
|
||||
signal: controller.signal,
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: body.toString(),
|
||||
});
|
||||
|
||||
const text = await response.text();
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
errorKind: classifyFailure(response.status, null),
|
||||
errorDetail: `Ticket-Anmeldung fehlgeschlagen (Status ${response.status})`,
|
||||
};
|
||||
}
|
||||
|
||||
const parsed = parseJsonLenient(text);
|
||||
if (!parsed.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
errorKind: 'antwortform',
|
||||
errorDetail: 'Die Antwort der Ticket-Anmeldung war kein JSON.',
|
||||
};
|
||||
}
|
||||
|
||||
const data = (parsed.data as { data?: { ticket?: unknown } } | null)?.data;
|
||||
const ticket = data && typeof data.ticket === 'string' ? data.ticket : null;
|
||||
if (!ticket) {
|
||||
return {
|
||||
ok: false,
|
||||
errorKind: 'antwortform',
|
||||
errorDetail: 'Die Antwort der Ticket-Anmeldung enthielt kein Ticket.',
|
||||
};
|
||||
}
|
||||
|
||||
return { ok: true, ticket };
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
errorKind: classifyFailure(null, err),
|
||||
errorDetail: 'Ticket-Anmeldung fehlgeschlagen: Verbindung nicht moeglich.',
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -175,8 +175,12 @@ export async function proxmoxGet(
|
||||
const url = `${target.baseUrl.replace(/\/+$/, '')}${path}`;
|
||||
|
||||
try {
|
||||
// KEIN `method`-Feld — GET ist der Grundwert von `fetch`/`undiciFetch`
|
||||
// selbst, es gibt hierfuer keinen Parameter (D-01). `proxmox-nur-
|
||||
// lesen.spec.ts` zaehlt Stellen, die ein Anfrageverfahren EXPLIZIT an
|
||||
// `undiciFetch` uebergeben — die einzige solche Stelle im Modul ist
|
||||
// `loginTicket` in `proxmox-auth.ts` (POST, Ticket-Anmeldung, D-01).
|
||||
const response = await undiciFetch(url, {
|
||||
method: 'GET', // fest verdrahtet — D-01, kein Parameter dafuer
|
||||
dispatcher,
|
||||
signal: controller.signal,
|
||||
headers: target.headers,
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import { readFileSync, readdirSync } from 'node:fs';
|
||||
import { basename, join } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
/**
|
||||
* Der maschinelle Riegel zu D-01 ("nur beobachten") — gebaut nach dem
|
||||
* Vorbild von `apps/api/src/prisma/rls-access-inventory.spec.ts`: der Test
|
||||
* liest den Quelltext, nicht das Laufzeitverhalten. Zwei Aussagen:
|
||||
*
|
||||
* 1. Die Summe der Stellen, die ein Anfrageverfahren EXPLIZIT an
|
||||
* `undiciFetch` uebergeben (`method: '...'`), ist genau
|
||||
* `EXPECTED_METHOD_PASSING_CALLS` und liegt in `proxmox-auth.ts`
|
||||
* (die Ticket-Anmeldung, `loginTicket` — die einzige nicht-lesende
|
||||
* Anfrage im gesamten Modul, D-01). `proxmoxGet` in
|
||||
* `proxmox-client.service.ts` uebergibt bewusst KEIN `method`-Feld:
|
||||
* GET ist der Grundwert von `fetch` selbst.
|
||||
* 2. Jeder gegen einen Proxmox-API-Pfad (`/api2/json/...`) gebauter Aufruf
|
||||
* ausser der Ticket-Anmeldung laeuft ueber `proxmoxGet(...)`.
|
||||
*
|
||||
* Die erwartete Zahl steht als benannte Konstante mit ausgeschriebener
|
||||
* Begruendung — eine spaetere Erhoehung erzwingt eine bewusste
|
||||
* Entscheidung, statt unbemerkt durchzurutschen (T-DHH-07).
|
||||
*/
|
||||
|
||||
/**
|
||||
* GENAU EIN Aufruf darf im gesamten Modul ein Anfrageverfahren explizit an
|
||||
* `undiciFetch` uebergeben: `loginTicket()` in `proxmox-auth.ts`
|
||||
* (`method: 'POST'`, Ticket-Anmeldung). Jede weitere Stelle waere ein neuer,
|
||||
* bislang unbedachter veraendernder Weg gegen Proxmox — T-DHH-07.
|
||||
*/
|
||||
const EXPECTED_METHOD_PASSING_CALLS = 1;
|
||||
const EXPECTED_METHOD_PASSING_FILE = 'proxmox-auth.ts';
|
||||
|
||||
const PROXMOX_SRC_DIR = join(__dirname);
|
||||
|
||||
function listTsFiles(dir: string): string[] {
|
||||
const out: string[] = [];
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
out.push(...listTsFiles(full));
|
||||
} else if (entry.isFile() && entry.name.endsWith('.ts')) {
|
||||
out.push(full);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Entfernt Zeilen- und Blockkommentare — Vorbild `rls-access-inventory.spec.ts`. */
|
||||
function stripComments(source: string): string {
|
||||
return source
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
.split('\n')
|
||||
.filter((line) => !line.trim().startsWith('//'))
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Entfernt zusaetzlich Zeichenkettenliterale (nach dem Kommentar-Entfernen)
|
||||
* — fuer Testdateien, damit eine erfundene Testkonstante wie
|
||||
* `'https://x/api2/json/...'` in einer `expect(...)`-Zeile oder ein
|
||||
* mockierter Antwortkoerper nicht als Fundstelle zaehlt (Plan-Vorgabe:
|
||||
* "entfernt vor dem Zaehlen Kommentarzeilen und Zeichenkettenliterale aus
|
||||
* Testdateien").
|
||||
*/
|
||||
function stripStringLiterals(source: string): string {
|
||||
return source
|
||||
.replace(/`(?:[^`\\]|\\.)*`/g, '``')
|
||||
.replace(/"(?:[^"\\]|\\.)*"/g, '""')
|
||||
.replace(/'(?:[^'\\]|\\.)*'/g, "''");
|
||||
}
|
||||
|
||||
interface CallSpan {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
/** Sammelt Argumentbereiche aller Aufrufe `calleeName(...)` per Klammertiefe. */
|
||||
function collectCallArgSpans(text: string, calleeName: string): CallSpan[] {
|
||||
const spans: CallSpan[] = [];
|
||||
const re = new RegExp(`\\b${calleeName}\\(`, 'g');
|
||||
let m: RegExpExecArray | null;
|
||||
// biome-ignore lint/suspicious/noAssignInExpressions: Standard-Iterationsform der Nachbardatei rls-access-inventory.spec.ts
|
||||
while ((m = re.exec(text))) {
|
||||
const openIdx = re.lastIndex - 1;
|
||||
let depth = 0;
|
||||
let i = openIdx;
|
||||
for (; i < text.length; i++) {
|
||||
if (text[i] === '(') depth++;
|
||||
else if (text[i] === ')') {
|
||||
depth--;
|
||||
if (depth === 0) break;
|
||||
}
|
||||
}
|
||||
spans.push({ start: openIdx, end: i });
|
||||
}
|
||||
return spans;
|
||||
}
|
||||
|
||||
/** Zaehlt Stellen, die `method:` innerhalb eines `undiciFetch(...)`-Aufrufs uebergeben. */
|
||||
function countMethodPassingCalls(text: string): number {
|
||||
let count = 0;
|
||||
const re = /undiciFetch\(/g;
|
||||
let m: RegExpExecArray | null;
|
||||
// biome-ignore lint/suspicious/noAssignInExpressions: s.o.
|
||||
while ((m = re.exec(text))) {
|
||||
const openIdx = re.lastIndex - 1;
|
||||
let depth = 0;
|
||||
let i = openIdx;
|
||||
for (; i < text.length; i++) {
|
||||
if (text[i] === '(') depth++;
|
||||
else if (text[i] === ')') {
|
||||
depth--;
|
||||
if (depth === 0) break;
|
||||
}
|
||||
}
|
||||
const argsText = text.slice(openIdx, i + 1);
|
||||
if (/\bmethod\s*:/.test(argsText)) count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/** Fundstellen eines Proxmox-API-Pfads ausserhalb eines `proxmoxGet(...)`-Aufrufs. */
|
||||
function findApiPathViolations(fileName: string, text: string): string[] {
|
||||
if (fileName === 'proxmox-auth.ts') {
|
||||
// Die Ticket-Anmeldung ist die eine dokumentierte Ausnahme (D-01).
|
||||
return [];
|
||||
}
|
||||
const proxmoxGetSpans = collectCallArgSpans(text, 'proxmoxGet');
|
||||
const violations: string[] = [];
|
||||
const pathRe = /\/api2\/json\/[A-Za-z0-9/{}_.-]*/g;
|
||||
let m: RegExpExecArray | null;
|
||||
// biome-ignore lint/suspicious/noAssignInExpressions: s.o.
|
||||
while ((m = pathRe.exec(text))) {
|
||||
const idx = m.index;
|
||||
const insideProxmoxGetCall = proxmoxGetSpans.some((s) => idx >= s.start && idx <= s.end);
|
||||
if (!insideProxmoxGetCall) {
|
||||
violations.push(`${fileName}@${idx}: ${m[0]}`);
|
||||
}
|
||||
}
|
||||
return violations;
|
||||
}
|
||||
|
||||
describe('proxmox-nur-lesen (D-01, T-DHH-07) — der maschinelle Riegel', () => {
|
||||
const files = listTsFiles(PROXMOX_SRC_DIR);
|
||||
|
||||
it(`genau ${EXPECTED_METHOD_PASSING_CALLS} Stelle uebergibt ein Anfrageverfahren an undiciFetch, in ${EXPECTED_METHOD_PASSING_FILE}`, () => {
|
||||
const perFile = files.map((file) => {
|
||||
const raw = readFileSync(file, 'utf-8');
|
||||
const isTest = file.endsWith('.spec.ts');
|
||||
const cleaned = isTest ? stripStringLiterals(stripComments(raw)) : stripComments(raw);
|
||||
return { file: basename(file), count: countMethodPassingCalls(cleaned) };
|
||||
});
|
||||
|
||||
const total = perFile.reduce((sum, f) => sum + f.count, 0);
|
||||
const filesWithCalls = perFile.filter((f) => f.count > 0).map((f) => f.file);
|
||||
|
||||
expect(total, `Gefundene Stellen: ${JSON.stringify(perFile.filter((f) => f.count > 0))}`).toBe(
|
||||
EXPECTED_METHOD_PASSING_CALLS,
|
||||
);
|
||||
expect(filesWithCalls).toEqual([EXPECTED_METHOD_PASSING_FILE]);
|
||||
});
|
||||
|
||||
it('jeder gegen einen Proxmox-Pfad gebaute Aufruf ausser der Ticket-Anmeldung laeuft ueber proxmoxGet', () => {
|
||||
// Nur Produktionsdateien bauen tatsaechlich Aufrufe — Testdateien
|
||||
// enthalten denselben Pfadtext nur als erwarteten Wert in `expect(...)`,
|
||||
// das ist kein "gebauter Aufruf" im Sinn dieser Aussage.
|
||||
const productionFiles = files.filter((file) => !file.endsWith('.spec.ts'));
|
||||
const violations = productionFiles.flatMap((file) => {
|
||||
const raw = readFileSync(file, 'utf-8');
|
||||
const cleaned = stripComments(raw); // Pfad-Texte bleiben erhalten — nur Kommentare raus
|
||||
return findApiPathViolations(basename(file), cleaned);
|
||||
});
|
||||
|
||||
expect(violations).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import type { ProxmoxServer } from '@prisma/client';
|
||||
import { CryptoService } from '../crypto/crypto.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||
import { buildTokenAuthHeader } from './proxmox-auth';
|
||||
import { buildTicketCookieHeader, buildTokenAuthHeader, loginTicket } from './proxmox-auth';
|
||||
import { proxmoxGet } from './proxmox-client.service';
|
||||
import type { CreateProxmoxServerDto } from './dto/proxmox-server.dto';
|
||||
import type {
|
||||
@@ -183,8 +183,13 @@ export class ProxmoxService {
|
||||
});
|
||||
}
|
||||
|
||||
/** Baut die Anmeldekopfzeile fuer GENAU diesen Server ueber `proxmox-auth.ts` (D-03). */
|
||||
private buildAuthHeaders(server: ProxmoxServer): AuthHeaderResult {
|
||||
/**
|
||||
* Baut die Anmeldekopfzeile fuer GENAU diesen Server ueber
|
||||
* `proxmox-auth.ts` (D-03). Beim Passwort-Zweig loest das eine
|
||||
* Ticket-Anmeldung aus (die einzige nicht-lesende Anfrage des Moduls,
|
||||
* D-01) — deshalb `async`.
|
||||
*/
|
||||
private async buildAuthHeaders(server: ProxmoxServer): Promise<AuthHeaderResult> {
|
||||
if (server.authMethod === 'token') {
|
||||
const tokenSecret = this.decryptSecret(server.encryptedTokenSecret);
|
||||
if (!server.tokenId || !tokenSecret) {
|
||||
@@ -203,31 +208,42 @@ export class ProxmoxService {
|
||||
),
|
||||
};
|
||||
}
|
||||
// Benutzer/Passwort-Zweig (Ticket-Anmeldung) folgt in Aufgabe 2.
|
||||
|
||||
const password = this.decryptSecret(server.encryptedPassword);
|
||||
if (!server.username || !password) {
|
||||
return {
|
||||
ok: false,
|
||||
errorKind: 'zugang',
|
||||
errorDetail: 'Kein Benutzer/Passwort hinterlegt.',
|
||||
};
|
||||
}
|
||||
|
||||
const login = await loginTicket(
|
||||
{ baseUrl: server.baseUrl, tlsRejectUnauthorized: server.tlsRejectUnauthorized },
|
||||
server.productType as 'pve' | 'pbs' | 'pmg',
|
||||
server.username,
|
||||
password,
|
||||
);
|
||||
if (!login.ok) {
|
||||
return { ok: false, errorKind: login.errorKind, errorDetail: login.errorDetail };
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
errorKind: 'unbekannt',
|
||||
errorDetail: 'Benutzer/Passwort-Zugang wird in dieser Aufgabe noch nicht unterstuetzt.',
|
||||
ok: true,
|
||||
headers: buildTicketCookieHeader(server.productType as 'pve' | 'pbs' | 'pmg', login.ticket),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* EIN Abfragedurchlauf gegen genau diesen Server. In dieser Aufgabe nur
|
||||
* `pve` mit Token — Aufgabe 2 ergaenzt Benutzer/Passwort und die
|
||||
* Fehlerklassen, Aufgabe 3 ergaenzt `pbs`/`pmg`.
|
||||
* EIN Abfragedurchlauf gegen genau diesen Server. In dieser Aufgabe `pve`
|
||||
* mit Token ODER Benutzer/Passwort (Aufgabe 3 ergaenzt `pbs`/`pmg`).
|
||||
*
|
||||
* Ticket-Erneuerung (Aufgabe 2, `<behavior>`): laeuft der Zugang ueber
|
||||
* `password` und antwortet Proxmox mit 401 (`errorKind: 'zugang'`), wird
|
||||
* GENAU EINMAL neu angemeldet und die Abfrage wiederholt — bei einer
|
||||
* Ticketdauer von zwei Stunden erzeugt ein normaler Ablauf sonst alle
|
||||
* zwei Stunden einen Fehlalarm. Ein zweites 401 bleibt `'zugang'`.
|
||||
*/
|
||||
private async pollOne(server: ProxmoxServer): Promise<ProxmoxPollResult> {
|
||||
const authHeaders = this.buildAuthHeaders(server);
|
||||
if (!authHeaders.ok) {
|
||||
return {
|
||||
reachable: false,
|
||||
errorKind: authHeaders.errorKind,
|
||||
errorDetail: authHeaders.errorDetail,
|
||||
metrics: null,
|
||||
rawSample: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (server.productType !== 'pve') {
|
||||
// PBS/PMG folgen in Aufgabe 3.
|
||||
return {
|
||||
@@ -239,7 +255,18 @@ export class ProxmoxService {
|
||||
};
|
||||
}
|
||||
|
||||
const result = await proxmoxGet(
|
||||
const authHeaders = await this.buildAuthHeaders(server);
|
||||
if (!authHeaders.ok) {
|
||||
return {
|
||||
reachable: false,
|
||||
errorKind: authHeaders.errorKind,
|
||||
errorDetail: authHeaders.errorDetail,
|
||||
metrics: null,
|
||||
rawSample: null,
|
||||
};
|
||||
}
|
||||
|
||||
let result = await proxmoxGet(
|
||||
{
|
||||
baseUrl: server.baseUrl,
|
||||
tlsRejectUnauthorized: server.tlsRejectUnauthorized,
|
||||
@@ -248,6 +275,20 @@ export class ProxmoxService {
|
||||
'/api2/json/cluster/resources',
|
||||
);
|
||||
|
||||
if (!result.ok && result.errorKind === 'zugang' && server.authMethod === 'password') {
|
||||
const retryHeaders = await this.buildAuthHeaders(server);
|
||||
if (retryHeaders.ok) {
|
||||
result = await proxmoxGet(
|
||||
{
|
||||
baseUrl: server.baseUrl,
|
||||
tlsRejectUnauthorized: server.tlsRejectUnauthorized,
|
||||
headers: retryHeaders.headers,
|
||||
},
|
||||
'/api2/json/cluster/resources',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!result.ok) {
|
||||
return {
|
||||
reachable: false,
|
||||
|
||||
Reference in New Issue
Block a user