Files
proxdeploy/tests/engine/erkundung.test.ts
T
florian.wildeandClaude Opus 5.5 86b650e20a feat(server): Verbindungstest mit Fähigkeitserkennung
Der Test liest Version, Cluster, Nodes, Storages, Bridges, VNets und die
effektiven Rechte des Tokens und leitet daraus ab, welcher Vorgang per
API, per SSH oder gar nicht geht – je PVE-Version: Template-Bau per API
erst ab 8.4 mit import-Storage, Snippets und LXC-Exec nur per SSH.

Fehlende Rechte erscheinen einzeln mit Pfad und Zweck; die Rollenliste
in docs/proxmox-rechte.md prüft ein Test gegen den Code. SSH-Host-Keys
werden wie Zertifikate erst nach Bestätigung genutzt, und bestätigt
werden kann nur ein Schlüssel, den der letzte Test gesehen hat.

Statussymbole in der GUI sind jetzt gezeichnet: ◐ und ○ fehlen im
Web-Subset von JetBrains Mono.

Refs #003

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 09:44:27 +02:00

187 lines
7.0 KiB
TypeScript

import { utils } from "ssh2";
import { afterEach, describe, expect, it } from "vitest";
import { ProxmoxClient } from "@/engine/proxmox/client";
import { erkunden } from "@/engine/proxmox/erkundung";
import { sshAusfuehren, SshFehler } from "@/engine/ssh/verbindung";
import { MOCK_TOKEN_GEHEIMNIS, MOCK_TOKEN_ID, ProxmoxMock } from "../proxmox-mock/mock";
import { szenarioEinrichten, type Szenario } from "../proxmox-mock/szenario";
import { SshMock } from "../ssh-mock";
const aufraeumen: Array<() => Promise<void>> = [];
afterEach(async () => {
while (aufraeumen.length) await aufraeumen.pop()!();
});
async function mitSzenario(szenario: Szenario) {
const mock = await new ProxmoxMock().starten();
szenarioEinrichten(mock, szenario);
const client = new ProxmoxClient({
url: mock.url,
tokenId: MOCK_TOKEN_ID,
tokenGeheimnis: MOCK_TOKEN_GEHEIMNIS,
tlsPruefen: false,
fingerabdruck: mock.fingerabdruck,
});
aufraeumen.push(async () => {
await client.schliessen();
await mock.stoppen();
});
return { mock, client };
}
describe("Verbindungstest (Erkundung)", () => {
it("PVE 9.2 Einzelhost: Nodes, Storages, Bridges, keine fehlenden Rechte, Template-Bau per API", async () => {
const { client } = await mitSzenario({ version: "9.2.1", importStorage: true });
const bericht = await erkunden(client, { serverHost: "127.0.0.1", ssh: null });
expect(bericht.version).toBe("9.2.1");
expect(bericht.cluster).toBeNull();
expect(bericht.knoten).toHaveLength(1);
expect(bericht.knoten[0]!.storages.map((s) => s.storage)).toEqual(["local", "local-zfs"]);
expect(bericht.knoten[0]!.bridges).toEqual([
expect.objectContaining({ name: "vmbr0", vlanBewusst: true, cidr: "10.0.10.2/24", kommentar: "LAN" }),
expect.objectContaining({ name: "vmbr1", vlanBewusst: false }),
]);
expect(bericht.vnets).toEqual([{ name: "dmz", zone: "zone1", tag: 30, alias: "DMZ" }]);
expect(bericht.fehlendeRechte.filter((r) => r.pflicht)).toEqual([]);
expect(bericht.faehigkeiten.templateBau.weg).toBe("api");
expect(bericht.warnungen).toEqual([]);
});
it("Cluster: Name, Quorum und Node-IPs", async () => {
const { client } = await mitSzenario({
version: "9.2.1",
cluster: {
name: "rz1",
nodes: [
{ name: "pve01", ip: "10.0.0.1" },
{ name: "pve02", ip: "10.0.0.2" },
{ name: "pve03", ip: "10.0.0.3", online: false },
],
},
});
const bericht = await erkunden(client, { serverHost: "10.0.0.1", ssh: null });
expect(bericht.cluster).toEqual({ name: "rz1", quorate: true });
expect(bericht.knoten.map((k) => `${k.name}:${k.online}:${k.ip}`)).toEqual([
"pve01:true:10.0.0.1",
"pve02:true:10.0.0.2",
"pve03:false:10.0.0.3",
]);
expect(bericht.knoten[2]!.storages).toEqual([]);
});
it("PVE 8.3: Template-Bau braucht SSH, VM.Monitor wird verlangt", async () => {
const { client } = await mitSzenario({
version: "8.3.5",
importStorage: true,
ohneRechte: ["VM.Monitor"],
});
const bericht = await erkunden(client, { serverHost: "127.0.0.1", ssh: null });
expect(bericht.faehigkeiten.templateBau.weg).toBe("nicht_moeglich");
expect(bericht.fehlendeRechte).toContainEqual(
expect.objectContaining({ recht: "VM.Monitor", pfad: "/vms" }),
);
});
it("fehlende Leserechte werden zu Warnungen statt zum Abbruch", async () => {
const { client } = await mitSzenario({
version: "9.2.1",
ohneClusterStatus: true,
ohneRechte: ["SDN.Audit"],
});
const bericht = await erkunden(client, { serverHost: "127.0.0.1", ssh: null });
expect(bericht.knoten).toHaveLength(1);
expect(bericht.vnets).toBeNull();
expect(bericht.warnungen.join("\n")).toMatch(/Cluster-Status.*Sys\.Audit/);
});
it("warnt vor PVE 7", async () => {
const { client } = await mitSzenario({ version: "7.4.17" });
const bericht = await erkunden(client, { serverHost: "127.0.0.1", ssh: null });
expect(bericht.warnungen.join()).toMatch(/nicht unterstützt/);
});
});
describe("SSH", () => {
const paar = utils.generateKeyPairSync("ed25519");
async function sshServer() {
const ssh = await new SshMock(paar.public).starten();
aufraeumen.push(() => ssh.stoppen());
return ssh;
}
it("Erkunden liefert den Host-Key; danach nur noch mit Pin", async () => {
const ssh = await sshServer();
const ziel = {
host: "127.0.0.1",
port: ssh.port,
benutzer: "root",
privatSchluessel: paar.private,
hostSchluessel: null,
};
const erkundet = await sshAusfuehren(ziel, "pveversion", { erkunden: true });
expect(erkundet.hostSchluessel).toBe(ssh.hostSchluessel);
expect(erkundet.stdout).toContain("pve-manager/9.2.1");
await expect(sshAusfuehren(ziel, "pveversion")).rejects.toMatchObject({ art: "host_schluessel" });
const mitPin = await sshAusfuehren({ ...ziel, hostSchluessel: ssh.hostSchluessel }, "echo hallo");
expect(mitPin.code).toBe(0);
});
it("trennt bei geändertem Host-Key", async () => {
const ssh = await sshServer();
const fehler = await sshAusfuehren(
{
host: "127.0.0.1",
port: ssh.port,
benutzer: "root",
privatSchluessel: paar.private,
hostSchluessel: "SHA256:falsch",
},
"pveversion",
).catch((f: unknown) => f);
expect(fehler).toBeInstanceOf(SshFehler);
expect((fehler as SshFehler).art).toBe("host_schluessel");
expect(ssh.befehle).toEqual([]);
});
it("meldet einen nicht hinterlegten Schlüssel als Anmeldefehler mit Hinweis", async () => {
const ssh = await sshServer();
const fremd = utils.generateKeyPairSync("ed25519");
const fehler = (await sshAusfuehren(
{
host: "127.0.0.1",
port: ssh.port,
benutzer: "root",
privatSchluessel: fremd.private,
hostSchluessel: ssh.hostSchluessel,
},
"pveversion",
).catch((f: unknown) => f)) as SshFehler;
expect(fehler.art).toBe("anmeldung");
expect(fehler.hinweis).toMatch(/authorized_keys/);
});
it("Erkundung mit SSH: unbestätigt, dann bestätigt → Snippets und pct exec möglich", async () => {
const ssh = await sshServer();
const { client } = await mitSzenario({ version: "8.3.5", importStorage: true });
const zugang = { benutzer: "root", port: ssh.port, privatSchluessel: paar.private, hostSchluessel: {} };
const erst = await erkunden(client, { serverHost: "127.0.0.1", ssh: zugang });
expect(erst.ssh).toBe("nicht_bestaetigt");
expect(erst.knoten[0]!.ssh).toMatchObject({
zustand: "nicht_bestaetigt",
hostSchluessel: ssh.hostSchluessel,
});
expect(erst.faehigkeiten.templateBau.weg).toBe("nicht_moeglich");
const dann = await erkunden(client, {
serverHost: "127.0.0.1",
ssh: { ...zugang, hostSchluessel: { pve01: ssh.hostSchluessel } },
});
expect(dann.ssh).toBe("ok");
expect(dann.faehigkeiten.templateBau.weg).toBe("ssh");
expect(dann.faehigkeiten.lxcProvisionierung).toMatchObject({ ueber: "node" });
});
});