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>; 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"]); }); });