feat(api): API-Tokens, kurze Anfragen und OpenAPI für die CLI

- ApiToken (nur SHA-256), Bearer-Anmeldung, POST /auth/token für proxdeploy login, Verwaltung in den Einstellungen
- anfrageErgaenzen: Storage, Bridge, Node, Benutzer und Schlüssel aus Server-Standards, Verbindungstest und Anmeldung – auch beim Template-Bau
- Abschlussbox serverseitig (abschluss), Web und CLI zeigen dieselben Zeilen
- Antwortschemas als zod, daraus /api/v1/openapi.json und cli/api/openapi.json (pnpm openapi)

Refs #013

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 15:08:27 +02:00
co-authored by Claude Opus 5.5
parent 1e195f9bca
commit 9fdced62db
27 changed files with 1386 additions and 58 deletions
+224
View File
@@ -0,0 +1,224 @@
import { readFileSync } from "node:fs";
import { beforeEach, describe, expect, it } from "vitest";
import * as anmeldenToken from "@/app/api/v1/auth/token/route";
import * as ich from "@/app/api/v1/ich/route";
import * as liste from "@/app/api/v1/tokens/route";
import * as einzeln from "@/app/api/v1/tokens/[id]/route";
import { verschluesseln } from "@/lib/geheimnisse";
import { passwortHashen } from "@/lib/passwort";
import * as A from "@/lib/schemas/api";
import { openApi } from "@/server/openapi";
import { sperrenZuruecksetzen } from "@/server/services/anmeldung";
import { anfrageErgaenzen } from "@/server/services/ausrollen";
import { katalogSynchronisieren } from "@/server/services/katalog";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
import { angemeldet, anfrage, kontext } from "./helpers/anfragen";
const mitToken = (methode: string, pfad: string, token: string, rumpf?: unknown) => {
const a = anfrage(methode, pfad, null, rumpf);
a.headers.set("authorization", `Bearer ${token}`);
return a;
};
describe("OpenAPI", () => {
it("cli/api/openapi.json passt zum Code (sonst `pnpm openapi` und `go generate`)", () => {
const datei = JSON.parse(readFileSync("cli/api/openapi.json", "utf8"));
expect(datei).toEqual(JSON.parse(JSON.stringify(openApi())));
});
it("jede $ref zeigt auf ein vorhandenes Schema", () => {
const text = JSON.stringify(openApi());
const schemas = Object.keys((openApi() as { components: { schemas: object } }).components.schemas);
for (const [, name] of text.matchAll(/"#\/components\/schemas\/([^"]+)"/g))
expect(schemas).toContain(name);
});
});
describe.runIf(datenbankVerfuegbar)("API-Tokens", () => {
let operator: Awaited<ReturnType<typeof angemeldet>>;
beforeEach(async () => {
await datenbankLeeren();
sperrenZuruecksetzen();
operator = await angemeldet("OPERATOR", "florian");
});
const tokenAnlegen = async (rumpf: unknown = { name: "laptop" }) =>
await liste.POST(anfrage("POST", "/api/v1/tokens", operator.cookie, rumpf));
it("Token einmal zeigen, nur den Hash speichern, damit anmelden", async () => {
const antwort = await tokenAnlegen();
expect(antwort.status).toBe(201);
const neu = A.NeuesToken.parse(await antwort.json());
expect(neu.token).toMatch(/^pd_[A-Za-z0-9_-]{43}$/);
expect(neu.token.startsWith(neu.praefix)).toBe(true);
const zeile = await testPrisma.apiToken.findFirstOrThrow();
expect(zeile.hash).not.toContain(neu.token);
expect(
JSON.stringify(await (await liste.GET(anfrage("GET", "/api/v1/tokens", operator.cookie))).json()),
).not.toContain(neu.token);
const wer = await ich.GET(mitToken("GET", "/api/v1/ich", neu.token));
expect(wer.status).toBe(200);
expect(A.Ich.parse(await wer.json()).benutzername).toBe("florian");
expect((await testPrisma.apiToken.findFirstOrThrow()).zuletztGenutzt).not.toBeNull();
const protokoll = await testPrisma.protokoll.findMany({ where: { aktion: "API-Token angelegt" } });
expect(protokoll).toHaveLength(1);
});
it("unbekannte, abgelaufene und widerrufene Tokens gelten nicht – auch nicht mit Cookie dabei", async () => {
expect((await ich.GET(mitToken("GET", "/api/v1/ich", "pd_falsch"))).status).toBe(401);
const falschMitCookie = mitToken("GET", "/api/v1/ich", "pd_falsch");
falschMitCookie.headers.set("cookie", operator.cookie);
expect((await ich.GET(falschMitCookie)).status).toBe(401);
const neu = A.NeuesToken.parse(await (await tokenAnlegen({ name: "kurz", tage: 1 })).json());
await testPrisma.apiToken.update({
where: { id: neu.id },
data: { ablaufAm: new Date(Date.now() - 1000) },
});
expect((await ich.GET(mitToken("GET", "/api/v1/ich", neu.token))).status).toBe(401);
const zweites = A.NeuesToken.parse(await (await tokenAnlegen({ name: "weg" })).json());
const loeschen = await einzeln.DELETE(
anfrage("DELETE", `/api/v1/tokens/${zweites.id}`, operator.cookie),
kontext({ id: zweites.id }),
);
expect(loeschen.status).toBe(204);
expect((await ich.GET(mitToken("GET", "/api/v1/ich", zweites.token))).status).toBe(401);
});
it("gesperrter Benutzer: Token wirkt nicht mehr; fremde Tokens löscht niemand", async () => {
const neu = A.NeuesToken.parse(await (await tokenAnlegen()).json());
const anderer = await angemeldet("ADMIN", "admin");
const fremd = await einzeln.DELETE(
anfrage("DELETE", `/api/v1/tokens/${neu.id}`, anderer.cookie),
kontext({ id: neu.id }),
);
expect(fremd.status).toBe(403);
await testPrisma.benutzer.update({ where: { id: operator.benutzer.id }, data: { aktiv: false } });
expect((await ich.GET(mitToken("GET", "/api/v1/ich", neu.token))).status).toBe(401);
});
it("doppelter Name ist ein Feldfehler", async () => {
await tokenAnlegen();
const doppelt = await tokenAnlegen();
expect(doppelt.status).toBe(409);
expect((await doppelt.json()).fehler.felder.name).toBeTruthy();
});
it("proxdeploy login: Passwort gegen Token, mit Sperre und Protokoll als CLI", async () => {
await testPrisma.benutzer.update({
where: { id: operator.benutzer.id },
data: { passwortHash: await passwortHashen("geheim-genug-123") },
});
const falsch = await anmeldenToken.POST(
anfrage("POST", "/api/v1/auth/token", null, { benutzername: "florian", passwort: "nein", name: "cli" }),
);
expect(falsch.status).toBe(401);
const richtig = await anmeldenToken.POST(
anfrage("POST", "/api/v1/auth/token", null, {
benutzername: "Florian",
passwort: "geheim-genug-123",
name: "cli@laptop",
}),
);
expect(richtig.status).toBe(201);
const neu = A.NeuesToken.parse(await richtig.json());
expect((await ich.GET(mitToken("GET", "/api/v1/ich", neu.token))).status).toBe(200);
const eintraege = await testPrisma.protokoll.findMany({
where: { quelle: "CLI" },
orderBy: { id: "asc" },
});
expect(eintraege.map((e) => e.aktion)).toEqual([
"Anmeldung fehlgeschlagen",
"Angemeldet",
"API-Token angelegt",
]);
});
});
type Erg = { storage?: string; netz: { bridge?: string; vlan?: number }; zugang: { benutzer?: string } };
describe.runIf(datenbankVerfuegbar)("Kurze Anfrage ergänzen (CLI)", () => {
beforeEach(async () => {
await datenbankLeeren();
await katalogSynchronisieren();
});
const bericht = {
knoten: [
{ name: "pve00", online: false, storages: [], bridges: [] },
{
name: "pve01",
online: true,
storages: [
{ storage: "local", aktiv: true, inhalte: ["iso", "vztmpl", "import"] },
{ storage: "aus", aktiv: false, inhalte: ["images"] },
{ storage: "local-zfs", aktiv: true, inhalte: ["images", "rootdir"] },
{ storage: "ct-only", aktiv: true, inhalte: ["rootdir"] },
],
bridges: [{ name: "vmbr1" }, { name: "vmbr0" }],
},
],
};
async function server(standards: object | null = null) {
await testPrisma.server.create({
data: {
name: "rz1",
titel: "RZ 1",
url: "https://127.0.0.1:8006",
tokenId: "a@pve!b",
tokenGeheimnis: verschluesseln("x"),
fingerabdruck: "AA",
letzterTest: bericht,
...(standards ? { standards } : {}),
},
});
}
it("füllt Storage, Bridge und Benutzer aus Verbindungstest und Anmeldung", async () => {
await server();
const { benutzer } = await angemeldet("OPERATOR", "florian");
const voll = (await anfrageErgaenzen(
{ typ: "vm", server: "rz1", distribution: "debian-13", name: "web01" },
{ benutzer, quelle: "CLI" },
)) as Erg;
expect(voll.storage).toBe("local-zfs");
expect(voll.netz.bridge).toBe("vmbr0");
expect(voll.zugang.benutzer).toBe("florian");
const ct = (await anfrageErgaenzen(
{ typ: "lxc", server: "rz1", distribution: "debian-13", name: "ct01", storage: "", netz: { vlan: 20 } },
{ benutzer, quelle: "CLI" },
)) as Erg;
expect(ct.storage).toBe("local-zfs");
expect(ct.netz).toEqual({ vlan: 20, bridge: "vmbr0" });
});
it("Standards des Servers gehen vor; Angaben der Anfrage bleiben; root wird kein Gastbenutzer", async () => {
await server({ storage: "ct-only", bridge: "vmbr1" });
const { benutzer } = await angemeldet("ADMIN", "root");
const ct = (await anfrageErgaenzen(
{ typ: "lxc", server: "rz1", distribution: "debian-13", name: "ct01" },
{ benutzer, quelle: "CLI" },
)) as Erg;
expect(ct.storage).toBe("ct-only");
expect(ct.netz.bridge).toBe("vmbr1");
expect(ct.zugang.benutzer).toBeUndefined();
const vm = (await anfrageErgaenzen(
{
typ: "vm",
server: "rz1",
distribution: "debian-13",
name: "web01",
storage: "x",
netz: { bridge: "y" },
},
{ benutzer, quelle: "CLI" },
)) as Erg;
expect([vm.storage, vm.netz.bridge]).toEqual(["x", "y"]);
});
});