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:
@@ -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"]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user