- 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>
225 lines
8.9 KiB
TypeScript
225 lines
8.9 KiB
TypeScript
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"]);
|
||
});
|
||
});
|