Files
proxdeploy/tests/tokens.test.ts
T
florian.wildeandClaude Opus 5.5 9fdced62db 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>
2026-10-12 15:08:27 +02:00

225 lines
8.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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"]);
});
});