Merge branch 'phase/012-014' (Schluessel, Personen, Private Key)
# Conflicts: # src/components/vorgang/useVorgang.ts # src/worker/handler.ts
This commit is contained in:
@@ -73,7 +73,13 @@ describe("Rechte", () => {
|
||||
|
||||
it("verlangt fuer kritische Aktionen eine Bestaetigung, und die nur fuer Admin-Rechte", () => {
|
||||
expect([...BESTAETIGUNG_NOETIG].sort()).toEqual(
|
||||
["deploymentKeyVerwalten", "massenwiderruf", "privateKeyAnzeigen", "zielkontenVerwalten"].sort(),
|
||||
[
|
||||
"deploymentKeyVerwalten",
|
||||
"massenwiderruf",
|
||||
"privateKeyAnzeigen",
|
||||
"privateKeyLoeschen",
|
||||
"zielkontenVerwalten",
|
||||
].sort(),
|
||||
);
|
||||
for (const recht of BESTAETIGUNG_NOETIG) {
|
||||
expect(RECHTE[recht]).toEqual(["ADMIN"]);
|
||||
|
||||
@@ -20,6 +20,8 @@ const REGELN = [
|
||||
"privateverwendung_pfad_gueltig",
|
||||
"publicverwendung_host_oder_gruppe",
|
||||
"benutzer_notfall_vollstaendig",
|
||||
"schluessel_slug_gueltig",
|
||||
"schluessel_geloescht_ohne_private_key",
|
||||
].sort();
|
||||
|
||||
const INDIZES = [
|
||||
@@ -90,6 +92,25 @@ describe("Datenbankregeln", () => {
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("laesst nur Slugs zu, die in Dateinamen passen (#012)", async () => {
|
||||
for (const slug of ["Gross", "mit leer", "../x", "a_b", "", "x".repeat(65)]) {
|
||||
await expect(schluesselAnlegen({ slug })).rejects.toThrow();
|
||||
}
|
||||
await expect(schluesselAnlegen({ slug: "backup-srv-pve01" })).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it("kennt „Private Key geloescht“ nur ohne Chiffrat (#014)", async () => {
|
||||
await expect(
|
||||
schluesselAnlegen({
|
||||
privatGeloeschtAm: new Date(),
|
||||
privatVerschluesselt: Buffer.from("x"),
|
||||
dekVerschluesselt: Buffer.from("y"),
|
||||
masterKeyVersion: 1,
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
await expect(schluesselAnlegen({ privatGeloeschtAm: new Date() })).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it("laesst nur einen aktiven Instanz-Deployment-Key zu", async () => {
|
||||
await expect(schluesselAnlegen({ kategorie: "DIENST", instanzStandard: true })).rejects.toThrow();
|
||||
await schluesselAnlegen({ kategorie: "DEPLOYMENT", instanzStandard: true });
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import pg from "pg";
|
||||
import "dotenv/config";
|
||||
import { schluesselpaarErzeugen } from "../../src/lib/ssh/openssh";
|
||||
|
||||
/**
|
||||
* Schluessel im gebauten Stand mit laufendem Worker (#012–#014). Testdaten heissen zz-e2e-…
|
||||
* und bleiben liegen (Schluessel werden nicht geloescht, nur spaeter widerrufen).
|
||||
*/
|
||||
test.setTimeout(90_000);
|
||||
|
||||
async function oidcAnmelden(page: Page, rolle: "admin" | "operator" | "reader") {
|
||||
await page.goto("/anmelden");
|
||||
await page.getByRole("link", { name: /Mit Mock-OIDC anmelden/ }).click();
|
||||
await page.locator(`button[data-rolle="${rolle}"]`).click();
|
||||
await page.getByRole("button", { name: "Anmelden", exact: true }).click();
|
||||
await expect(page.getByRole("heading", { name: "Übersicht" })).toBeVisible();
|
||||
}
|
||||
|
||||
async function datenbank<T>(arbeit: (client: pg.Client) => Promise<T>): Promise<T> {
|
||||
const client = new pg.Client({ connectionString: process.env["DATABASE_URL"] });
|
||||
await client.connect();
|
||||
try {
|
||||
return await arbeit(client);
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
}
|
||||
|
||||
function eindeutig(): string {
|
||||
return `zz-e2e-${Date.now().toString(36)}`;
|
||||
}
|
||||
|
||||
test("Operator erzeugt einen Schlüssel und landet im Detail", async ({ page }) => {
|
||||
const name = `${eindeutig()} Backup`;
|
||||
await oidcAnmelden(page, "operator");
|
||||
await page.goto("/schluessel/neu");
|
||||
await page.getByLabel("Zweck").fill(name);
|
||||
await page.getByLabel("Tags").fill("e2e");
|
||||
await page.getByRole("button", { name: "Schlüssel erzeugen" }).click();
|
||||
|
||||
await expect(page.getByRole("heading", { name })).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page).toHaveURL(/\/schluessel\/[^/]+$/);
|
||||
await expect(page.getByText("verschlüsselt gespeichert")).toBeVisible();
|
||||
await expect(page.getByText("ed25519", { exact: true })).toBeVisible();
|
||||
await expect(page.getByLabel("Public Key", { exact: true })).toContainText("ssh-ed25519 AAAA");
|
||||
await expect(page.getByLabel("Public Key", { exact: true })).toContainText(
|
||||
`sshkv:${name.toLowerCase().replace(/ /g, "-")}`,
|
||||
);
|
||||
|
||||
await page.goto(`/schluessel?q=${encodeURIComponent(name)}`);
|
||||
await expect(page.getByRole("link", { name })).toBeVisible();
|
||||
});
|
||||
|
||||
test("Public Key importieren, Duplikat wird erkannt", async ({ page }) => {
|
||||
const name = `${eindeutig()} Laptop`;
|
||||
const paar = await schluesselpaarErzeugen("ED25519", { kommentar: "e2e@laptop" });
|
||||
await oidcAnmelden(page, "operator");
|
||||
await page.goto("/schluessel/neu");
|
||||
await page.getByRole("radio", { name: "Importieren" }).click();
|
||||
await page.getByLabel("Zweck").fill(name);
|
||||
await page.getByLabel("Public Key").fill(paar.publicKeyZeile);
|
||||
await page.locator(`input[name="kategorie"][value="PERSON"]`).check();
|
||||
await page.getByRole("button", { name: "Schlüssel importieren" }).click();
|
||||
|
||||
await expect(page.getByRole("heading", { name })).toBeVisible();
|
||||
await expect(page.getByText("nicht gespeichert (Personen-Key)")).toBeVisible();
|
||||
await expect(page.getByText(paar.fingerprint)).toBeVisible();
|
||||
|
||||
await page.goto("/schluessel/neu");
|
||||
await page.getByRole("radio", { name: "Importieren" }).click();
|
||||
await page.getByLabel("Zweck").fill(`${name} nochmal`);
|
||||
await page.getByLabel("Public Key").fill(paar.publicKeyZeile);
|
||||
await page.getByRole("button", { name: "Schlüssel importieren" }).click();
|
||||
await expect(page.getByRole("alert").first()).toContainText(`bereits verwaltet: „${name}“`);
|
||||
});
|
||||
|
||||
test("Leser sieht Schlüssel, darf aber keine anlegen", async ({ page }) => {
|
||||
await oidcAnmelden(page, "reader");
|
||||
await page.goto("/schluessel");
|
||||
await expect(page.getByRole("heading", { name: "Schlüssel", exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "Schlüssel anlegen" })).toHaveCount(0);
|
||||
await page.goto("/schluessel/neu");
|
||||
await expect(page).toHaveURL(/\/kein-zugriff/);
|
||||
});
|
||||
|
||||
test("Leser hinterlegt einen eigenen Public Key und entfernt ihn wieder", async ({ page }) => {
|
||||
const name = `${eindeutig()} Laptop`;
|
||||
const paar = await schluesselpaarErzeugen("ED25519", { kommentar: "reader@laptop" });
|
||||
await oidcAnmelden(page, "reader");
|
||||
await page.getByRole("link", { name: "Meine Schlüssel" }).first().click();
|
||||
await expect(page.getByRole("heading", { name: "Meine Schlüssel" })).toBeVisible();
|
||||
await expect(page.getByText(/Wirksam erst, wenn ein Operator oder Admin/).first()).toBeVisible();
|
||||
|
||||
await page.getByLabel("Bezeichnung").fill(name);
|
||||
await page.getByLabel("Public Key").fill(paar.publicKeyZeile);
|
||||
await page.getByRole("button", { name: "Hinterlegen" }).click();
|
||||
await expect(page.getByText(`Hinterlegt: ${paar.fingerprint}`)).toBeVisible();
|
||||
const eintrag = page.locator("li", { hasText: name });
|
||||
await expect(eintrag.getByText("noch nicht zugewiesen")).toBeVisible();
|
||||
|
||||
await eintrag.getByRole("button", { name: `${name} entfernen` }).click();
|
||||
await page.getByRole("dialog").getByRole("button", { name: "Entfernen", exact: true }).click();
|
||||
await expect(page.locator("li", { hasText: name })).toHaveCount(0);
|
||||
});
|
||||
|
||||
/**
|
||||
* Erneute Anmeldung beim Mock-OIDC (#014). Der Mock uebernimmt nur die Claims aus dem
|
||||
* Formular; `auth_time` muss deshalb mitgeschickt werden, sonst lehnt sshkv die Bestaetigung ab.
|
||||
*/
|
||||
async function oidcBestaetigen(page: Page) {
|
||||
await expect(page).toHaveURL(/\/bestaetigen\?ziel=/);
|
||||
await page.getByRole("link", { name: /erneut anmelden/ }).click();
|
||||
await page.locator('button[data-rolle="admin"]').click();
|
||||
const claims = JSON.parse(await page.locator("#claims").inputValue()) as Record<string, unknown>;
|
||||
await page.locator("#claims").fill(JSON.stringify({ ...claims, auth_time: Math.floor(Date.now() / 1000) }));
|
||||
await page.getByRole("button", { name: "Anmelden", exact: true }).click();
|
||||
}
|
||||
|
||||
test("Admin zeigt den Private Key nach Bestätigung an, lädt ihn herunter und löscht ihn", async ({
|
||||
page,
|
||||
}) => {
|
||||
const name = `${eindeutig()} Deploy`;
|
||||
await oidcAnmelden(page, "admin");
|
||||
// Eine Bestaetigung aus einem frueheren Lauf soll nicht mitzaehlen.
|
||||
await datenbank((client) =>
|
||||
client.query(`UPDATE "Benutzer" SET "bestaetigtAm" = NULL WHERE "email" = 'admin@sshkv.test'`),
|
||||
);
|
||||
await page.goto("/schluessel/neu");
|
||||
await page.getByLabel("Zweck").fill(name);
|
||||
await page.getByRole("button", { name: "Schlüssel erzeugen" }).click();
|
||||
await expect(page.getByRole("heading", { name })).toBeVisible({ timeout: 30_000 });
|
||||
const detail = page.url();
|
||||
const fingerprint =
|
||||
(
|
||||
await page
|
||||
.getByText(/^SHA256:/)
|
||||
.first()
|
||||
.textContent()
|
||||
)?.trim() ?? "";
|
||||
|
||||
// Ohne frische Bestaetigung geht es erst nach /bestaetigen und von dort zurueck in den Dialog.
|
||||
await page.getByRole("button", { name: "anzeigen (Re-Auth)" }).click();
|
||||
const dialog = page.getByRole("dialog");
|
||||
await dialog.getByRole("button", { name: "Anzeigen" }).click();
|
||||
await oidcBestaetigen(page);
|
||||
await expect(page).toHaveURL(/privatekey=1/);
|
||||
await dialog.getByRole("button", { name: "Anzeigen" }).click();
|
||||
const block = dialog.getByLabel("Private Key", { exact: true });
|
||||
await expect(block).toContainText("-----BEGIN OPENSSH PRIVATE KEY-----", { timeout: 30_000 });
|
||||
await expect(dialog.getByText("Wird nicht erneut angezeigt.")).toBeVisible();
|
||||
await dialog.getByRole("button", { name: "Schließen" }).click();
|
||||
await expect(block).toHaveCount(0);
|
||||
|
||||
// Download: eigene Abholung, Datei <slug>, ohne Caching.
|
||||
await page.getByRole("button", { name: "anzeigen (Re-Auth)" }).click();
|
||||
const antwort = page.waitForResponse((r) => r.url().includes("/api/private-key/"));
|
||||
const datei = page.waitForEvent("download");
|
||||
await dialog.getByRole("button", { name: "Herunterladen" }).click();
|
||||
const kopf = (await antwort).headers();
|
||||
expect(kopf["cache-control"]).toContain("no-store");
|
||||
expect(kopf["content-disposition"]).toMatch(/^attachment; filename="zz-e2e-[a-z0-9]+-deploy"$/);
|
||||
const download = await datei;
|
||||
expect(download.suggestedFilename()).toMatch(/^zz-e2e-[a-z0-9]+-deploy$/);
|
||||
await expect(dialog.getByText(/Download gestartet/)).toBeVisible();
|
||||
await dialog.getByRole("button", { name: "Schließen" }).click();
|
||||
|
||||
// Operator sieht keinen Anzeigen-Knopf und kein Loeschen.
|
||||
const operator = await page.context().browser()!.newContext();
|
||||
const operatorSeite = await operator.newPage();
|
||||
await oidcAnmelden(operatorSeite, "operator");
|
||||
await operatorSeite.goto(detail);
|
||||
await expect(operatorSeite.getByText("verschlüsselt gespeichert")).toBeVisible();
|
||||
await expect(operatorSeite.getByRole("button", { name: "anzeigen (Re-Auth)" })).toHaveCount(0);
|
||||
await expect(operatorSeite.getByRole("button", { name: "Private Key löschen" })).toHaveCount(0);
|
||||
await operator.close();
|
||||
|
||||
// Loeschen (Bestaetigung ist noch frisch).
|
||||
await page.getByRole("button", { name: "Private Key löschen" }).click();
|
||||
await dialog.getByText("Ich habe verstanden").click();
|
||||
await dialog.getByRole("button", { name: "Endgültig löschen" }).click();
|
||||
await expect(page.getByText(/gelöscht am/)).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByRole("button", { name: "anzeigen (Re-Auth)" })).toHaveCount(0);
|
||||
|
||||
// Im Audit stehen Anzeige, Download und Loeschen mit Fingerprint – ohne den Key.
|
||||
expect(fingerprint).toMatch(/^SHA256:/);
|
||||
const { rows } = await datenbank((client) =>
|
||||
client.query<{ aktion: string; zeile: string }>(
|
||||
`SELECT "aktion", row_to_json(p)::text AS zeile FROM "Protokoll" p WHERE "fingerprint" = $1 ORDER BY "id"`,
|
||||
[fingerprint],
|
||||
),
|
||||
);
|
||||
expect(rows.map((r) => r.aktion)).toEqual(
|
||||
expect.arrayContaining(["privatekey.angezeigt", "privatekey.heruntergeladen", "privatekey.geloescht"]),
|
||||
);
|
||||
expect(rows.some((r) => r.zeile.includes("PRIVATE KEY"))).toBe(false);
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { WARTESCHLANGE, warteschlangeHolen, type VorgangsJob } from "@/server/vorgaenge/warteschlange";
|
||||
import { vorgangAusfuehren } from "@/worker/ausfuehren";
|
||||
import { HANDLER } from "@/worker/handler";
|
||||
|
||||
/**
|
||||
* Fuehrt einen eingereihten Vorgang so aus, wie es der Worker tut (#012): Job aus pg-boss
|
||||
* holen, mit seinem `geheim` ausfuehren, danach loeschen (src/worker/index.ts).
|
||||
* Liefert zusaetzlich die rohen Job-Daten, damit Tests pruefen koennen, was darin stand.
|
||||
*/
|
||||
export async function vorgangImWorker(vorgangId: string): Promise<{ jobDaten: VorgangsJob }> {
|
||||
const boss = await warteschlangeHolen();
|
||||
// Die Warteschlange ueberlebt das Leeren der Tabellen; deshalb gezielt den eigenen Job suchen.
|
||||
const jobs = await boss.fetch<VorgangsJob>(WARTESCHLANGE, { batchSize: 100 });
|
||||
const job = jobs.find((kandidat) => kandidat.data.vorgangId === vorgangId);
|
||||
if (!job) throw new Error(`Kein Job für Vorgang ${vorgangId}`);
|
||||
await vorgangAusfuehren(job.data.vorgangId, HANDLER, job.data.geheim);
|
||||
await boss.complete(WARTESCHLANGE, job.id);
|
||||
if (job.data.geheim) await boss.deleteJob(WARTESCHLANGE, job.id);
|
||||
return { jobDaten: job.data };
|
||||
}
|
||||
|
||||
/** Alle Zeilen aller Tabellen als ein Text – fuer „steht das Geheimnis irgendwo?“. */
|
||||
export async function datenbankAlsText(): Promise<string> {
|
||||
const teile = await Promise.all([
|
||||
prisma.vorgang.findMany(),
|
||||
prisma.protokoll.findMany(),
|
||||
prisma.$queryRaw<unknown[]>`SELECT data FROM pgboss.job`,
|
||||
]);
|
||||
return JSON.stringify(teile, (_k, wert: unknown) => (typeof wert === "bigint" ? wert.toString() : wert));
|
||||
}
|
||||
|
||||
export async function warteschlangeStoppen(): Promise<void> {
|
||||
const boss = await warteschlangeHolen();
|
||||
await boss.stop({ graceful: false });
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { afterAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import type { Benutzer } from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { darf } from "@/server/auth/rechte";
|
||||
import { kontenAuswahl, personDetail, personenListe } from "@/server/queries/personen";
|
||||
import { FachFehler } from "@/server/services/fachfehler";
|
||||
import {
|
||||
eigenePersonSicherstellen,
|
||||
eigenenPublicKeyHinterlegen,
|
||||
eigenenSchluesselEntfernen,
|
||||
eigeneSchluessel,
|
||||
} from "@/server/services/meine-schluessel";
|
||||
import { personAnlegen, personArchivieren, personBearbeiten } from "@/server/services/personen";
|
||||
import { publicKeyImportieren } from "@/server/services/schluessel";
|
||||
import { datenbankLeeren } from "./hilfen/db";
|
||||
import { warteschlangeStoppen } from "./hilfen/vorgang";
|
||||
import { ed25519Blob, zeile } from "./ssh/hilfen";
|
||||
|
||||
/** Personen und Self-Service „Meine Schluessel“ (#013). */
|
||||
|
||||
let operator: Benutzer;
|
||||
let leser: Benutzer;
|
||||
let andererLeser: Benutzer;
|
||||
|
||||
beforeEach(async () => {
|
||||
await datenbankLeeren();
|
||||
operator = await prisma.benutzer.create({ data: { name: "Olga Operator", rolle: "OPERATOR" } });
|
||||
leser = await prisma.benutzer.create({
|
||||
data: { name: "Lea Leser", rolle: "LESER", email: "lea@example.org", emailBestaetigt: true },
|
||||
});
|
||||
andererLeser = await prisma.benutzer.create({
|
||||
data: { name: "Max Mitleser", rolle: "LESER", email: "max@example.org", emailBestaetigt: true },
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await warteschlangeStoppen();
|
||||
});
|
||||
|
||||
async function fachfehlerVon(versuch: Promise<unknown>): Promise<FachFehler> {
|
||||
const fehler = await versuch.then(
|
||||
() => undefined,
|
||||
(f: unknown) => f,
|
||||
);
|
||||
expect(fehler).toBeInstanceOf(FachFehler);
|
||||
return fehler as FachFehler;
|
||||
}
|
||||
|
||||
const akteur = () => ({ id: operator.id, name: operator.name });
|
||||
const neueZeile = (kommentar = "test") => zeile("ssh-ed25519", ed25519Blob(), kommentar);
|
||||
|
||||
describe("Personen verwalten", () => {
|
||||
it("legt an, bearbeitet, archiviert und protokolliert", async () => {
|
||||
const person = await personAnlegen(
|
||||
{ name: "Anna Berger", email: "A.Berger@Example.org", organisation: "Netzwerk GmbH" },
|
||||
akteur(),
|
||||
);
|
||||
const detail = await personDetail(person.id);
|
||||
expect(detail).toMatchObject({ email: "a.berger@example.org", organisation: "Netzwerk GmbH" });
|
||||
|
||||
await personBearbeiten(
|
||||
person.id,
|
||||
{ name: "Anna Berger", email: "a.berger@example.org", notiz: "privat" },
|
||||
akteur(),
|
||||
);
|
||||
const geaendert = await prisma.protokoll.findFirstOrThrow({ where: { aktion: "person.geaendert" } });
|
||||
// Die Notiz steht nicht im Protokoll, nur dass sie sich aenderte.
|
||||
expect(JSON.stringify(geaendert.details)).not.toContain("privat");
|
||||
expect(geaendert.details).toMatchObject({ notiz: "geändert", organisation: { vorher: "Netzwerk GmbH" } });
|
||||
|
||||
await personArchivieren(person.id, akteur());
|
||||
expect((await personenListe()).map((p) => p.name)).toEqual([]);
|
||||
expect((await personenListe({ archivierte: true })).map((p) => p.name)).toEqual(["Anna Berger"]);
|
||||
await fachfehlerVon(personBearbeiten(person.id, { name: "X" }, akteur()));
|
||||
expect(await prisma.protokoll.count({ where: { aktion: { startsWith: "person." } } })).toBe(3);
|
||||
});
|
||||
|
||||
it("prueft E-Mail und verknuepft ein Konto nur einmal", async () => {
|
||||
const fehler = await fachfehlerVon(personAnlegen({ name: "X", email: "keine-mail" }, akteur()));
|
||||
expect(fehler.feldfehler["email"]).toBeDefined();
|
||||
|
||||
await personAnlegen({ name: "Lea", benutzerId: leser.id }, akteur());
|
||||
const doppelt = await fachfehlerVon(personAnlegen({ name: "Lea 2", benutzerId: leser.id }, akteur()));
|
||||
expect(doppelt.feldfehler["benutzerId"]).toBeDefined();
|
||||
expect((await kontenAuswahl()).map((k) => k.name)).toEqual(["Max Mitleser", "Olga Operator"]);
|
||||
});
|
||||
|
||||
it("loest beim Archivieren die Kontoverknuepfung", async () => {
|
||||
const person = await personAnlegen({ name: "Lea", benutzerId: leser.id }, akteur());
|
||||
await personArchivieren(person.id, akteur());
|
||||
expect((await personDetail(person.id))?.benutzerId).toBeNull();
|
||||
// Der Self-Service landet danach bei einer neuen Person, nicht bei der archivierten.
|
||||
expect((await eigenePersonSicherstellen(leser)).id).not.toBe(person.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Eigene Person", () => {
|
||||
it("wird beim ersten Aufruf angelegt und danach wiederverwendet", async () => {
|
||||
const [a, b] = await Promise.all([eigenePersonSicherstellen(leser), eigenePersonSicherstellen(leser)]);
|
||||
expect(a.id).toBe(b.id);
|
||||
expect(await prisma.person.count()).toBe(1);
|
||||
expect((await personDetail(a.id))?.benutzerId).toBe(leser.id);
|
||||
});
|
||||
|
||||
it("verknuepft per bestaetigter E-Mail mit einer vorhandenen Person", async () => {
|
||||
const person = await personAnlegen({ name: "Lea Leser (IT)", email: "LEA@example.org" }, akteur());
|
||||
expect((await eigenePersonSicherstellen(leser)).id).toBe(person.id);
|
||||
expect(await prisma.protokoll.count({ where: { aktion: "person.verknuepft" } })).toBe(1);
|
||||
});
|
||||
|
||||
it("verknuepft nicht ueber eine unbestaetigte Adresse", async () => {
|
||||
const person = await personAnlegen({ name: "Chefin", email: "chefin@example.org" }, akteur());
|
||||
const angreifer = await prisma.benutzer.create({
|
||||
data: { name: "Mallory", rolle: "LESER", email: "chefin@example.org", emailBestaetigt: false },
|
||||
});
|
||||
expect((await eigenePersonSicherstellen(angreifer)).id).not.toBe(person.id);
|
||||
expect((await personDetail(person.id))?.benutzerId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Meine Schluessel", () => {
|
||||
it("hinterlegt eigene Public Keys als Personen-Keys", async () => {
|
||||
const neu = await eigenenPublicKeyHinterlegen(leser, {
|
||||
name: "Laptop",
|
||||
publicKey: neueZeile("lea@laptop"),
|
||||
});
|
||||
const schluessel = await prisma.schluessel.findUniqueOrThrow({
|
||||
where: { id: neu.id },
|
||||
select: {
|
||||
kategorie: true,
|
||||
selbstHinterlegt: true,
|
||||
personId: true,
|
||||
erstelltVonId: true,
|
||||
masterKeyVersion: true,
|
||||
},
|
||||
});
|
||||
const person = await eigenePersonSicherstellen(leser);
|
||||
expect(schluessel).toEqual({
|
||||
kategorie: "PERSON",
|
||||
selbstHinterlegt: true,
|
||||
personId: person.id,
|
||||
erstelltVonId: leser.id,
|
||||
masterKeyVersion: null,
|
||||
});
|
||||
const eintrag = await prisma.protokoll.findFirstOrThrow({
|
||||
where: { aktion: "schluessel.selbst_hinterlegt" },
|
||||
});
|
||||
expect(eintrag.akteurId).toBe(leser.id);
|
||||
});
|
||||
|
||||
it("zeigt nur eigene Keys und laesst fremde nicht entfernen", async () => {
|
||||
const eigener = await eigenenPublicKeyHinterlegen(leser, { name: "Meiner", publicKey: neueZeile() });
|
||||
const fremder = await eigenenPublicKeyHinterlegen(andererLeser, {
|
||||
name: "Fremd",
|
||||
publicKey: neueZeile(),
|
||||
});
|
||||
|
||||
expect((await eigeneSchluessel(leser)).schluessel.map((s) => s.name)).toEqual(["Meiner"]);
|
||||
expect((await eigeneSchluessel(andererLeser)).schluessel.map((s) => s.name)).toEqual(["Fremd"]);
|
||||
|
||||
const fehler = await fachfehlerVon(eigenenSchluesselEntfernen(leser, fremder.id));
|
||||
expect(fehler.message).toMatch(/unter Ihren Schlüsseln nicht/);
|
||||
expect(await prisma.schluessel.count({ where: { id: fremder.id } })).toBe(1);
|
||||
|
||||
await eigenenSchluesselEntfernen(leser, eigener.id);
|
||||
expect(await prisma.schluessel.count({ where: { id: eigener.id } })).toBe(0);
|
||||
expect(await prisma.protokoll.count({ where: { aktion: "schluessel.selbst_entfernt" } })).toBe(1);
|
||||
});
|
||||
|
||||
it("entfernt keine zugewiesenen und keine von Operatoren angelegten Keys", async () => {
|
||||
const zugewiesen = await eigenenPublicKeyHinterlegen(leser, {
|
||||
name: "Zugewiesen",
|
||||
publicKey: neueZeile(),
|
||||
});
|
||||
const host = await prisma.host.create({ data: { name: "srv-test01", adresse: "10.0.0.1" } });
|
||||
await prisma.publicVerwendung.create({
|
||||
data: { schluesselId: zugewiesen.id, hostId: host.id, zielbenutzer: "lea" },
|
||||
});
|
||||
await fachfehlerVon(eigenenSchluesselEntfernen(leser, zugewiesen.id));
|
||||
|
||||
const person = await eigenePersonSicherstellen(leser);
|
||||
const vomOperator = await publicKeyImportieren(
|
||||
{ name: "Vom Operator", kategorie: "PERSON", publicKey: neueZeile(), personId: person.id },
|
||||
akteur(),
|
||||
);
|
||||
await fachfehlerVon(eigenenSchluesselEntfernen(leser, vomOperator.id));
|
||||
expect(await prisma.schluessel.count()).toBe(2);
|
||||
});
|
||||
|
||||
it("meldet Duplikate auch im Self-Service", async () => {
|
||||
const z = neueZeile();
|
||||
await eigenenPublicKeyHinterlegen(leser, { name: "Einmal", publicKey: z });
|
||||
const fehler = await fachfehlerVon(
|
||||
eigenenPublicKeyHinterlegen(andererLeser, { name: "Nochmal", publicKey: z }),
|
||||
);
|
||||
expect(fehler.message).toMatch(/bereits verwaltet/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Rechte (Freigabe Frage 4)", () => {
|
||||
it("Leser darf eigene Keys hinterlegen, aber weder verwalten noch zuweisen", () => {
|
||||
expect(darf("LESER", "eigeneSchluesselHinterlegen")).toBe(true);
|
||||
expect(darf("LESER", "schluesselVerwalten")).toBe(false);
|
||||
expect(darf("LESER", "publicKeyAusrollen")).toBe(false);
|
||||
});
|
||||
|
||||
it("die Self-Service-Aktionen verlangen das Self-Service-Recht und reichen nur den eigenen Benutzer weiter", () => {
|
||||
const text = readFileSync("src/server/actions/meine-schluessel.ts", "utf8");
|
||||
expect(text.match(/rechtErforderlich\("eigeneSchluesselHinterlegen"\)/g)).toHaveLength(2);
|
||||
expect(text).not.toMatch(/personId/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,249 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { ABHOLFRIST_MS } from "@/lib/schluessel";
|
||||
import { privateKeyLesen } from "@/lib/ssh/openssh";
|
||||
import { BESTAETIGUNG_NOETIG, RECHTE } from "@/server/auth/rechte";
|
||||
import { schluesselDetail } from "@/server/queries/schluessel";
|
||||
import { FachFehler } from "@/server/services/fachfehler";
|
||||
import {
|
||||
abholungAnfordern,
|
||||
privateKeyAbholen,
|
||||
privateKeyLoeschenAnfordern,
|
||||
} from "@/server/services/privatekey";
|
||||
import { erzeugenAnfordern, publicKeyImportieren } from "@/server/services/schluessel";
|
||||
import { vorgangLesen } from "@/server/vorgaenge/vorgaenge";
|
||||
import { abholungenAufraeumen } from "@/worker/ablaeufe/privatekey";
|
||||
import { deploymentKeySicherstellen, masterKeyVergessen, privateKeyLaden } from "@/worker/geheimnisse";
|
||||
import { datenbankLeeren } from "./hilfen/db";
|
||||
import { datenbankAlsText, vorgangImWorker, warteschlangeStoppen } from "./hilfen/vorgang";
|
||||
import { ed25519Blob, zeile } from "./ssh/hilfen";
|
||||
|
||||
/** Private Key anzeigen, herunterladen, loeschen (#014). */
|
||||
|
||||
let verzeichnis: string;
|
||||
let admin: { id: string; name: string };
|
||||
let zweiterAdmin: { id: string; name: string };
|
||||
|
||||
beforeAll(() => {
|
||||
verzeichnis = mkdtempSync(path.join(tmpdir(), "sshkv-master-"));
|
||||
const datei = path.join(verzeichnis, "master.key");
|
||||
writeFileSync(datei, randomBytes(32), { mode: 0o600 });
|
||||
process.env["SSHKV_MASTER_KEY_FILE"] = datei;
|
||||
masterKeyVergessen();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
rmSync(verzeichnis, { recursive: true, force: true });
|
||||
await warteschlangeStoppen();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await datenbankLeeren();
|
||||
admin = await prisma.benutzer.create({
|
||||
data: { name: "Ada Admin", rolle: "ADMIN" },
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
zweiterAdmin = await prisma.benutzer.create({
|
||||
data: { name: "Zora Zweitadmin", rolle: "ADMIN" },
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
});
|
||||
|
||||
async function fachfehlerVon(versuch: Promise<unknown>): Promise<FachFehler> {
|
||||
const fehler = await versuch.then(
|
||||
() => undefined,
|
||||
(f: unknown) => f,
|
||||
);
|
||||
expect(fehler).toBeInstanceOf(FachFehler);
|
||||
return fehler as FachFehler;
|
||||
}
|
||||
|
||||
/** Ein Dienst-Key mit gespeichertem Private Key, erzeugt wie in der Oberflaeche. */
|
||||
async function dienstKey(): Promise<string> {
|
||||
const vorgangId = await erzeugenAnfordern({ name: "Backup" }, undefined, admin);
|
||||
await vorgangImWorker(vorgangId);
|
||||
const { ergebnis } = await prisma.vorgang.findUniqueOrThrow({ where: { id: vorgangId } });
|
||||
return (ergebnis as { schluesselId: string }).schluesselId;
|
||||
}
|
||||
|
||||
async function bereitgelegt(
|
||||
schluesselId: string,
|
||||
akteur = admin,
|
||||
zweck: "anzeigen" | "herunterladen" = "anzeigen",
|
||||
) {
|
||||
const vorgangId = await abholungAnfordern(schluesselId, zweck, akteur);
|
||||
await vorgangImWorker(vorgangId);
|
||||
return vorgangId;
|
||||
}
|
||||
|
||||
describe("Abholung", () => {
|
||||
it("liefert den Key genau einmal und protokolliert ohne ihn", async () => {
|
||||
const schluesselId = await dienstKey();
|
||||
const vorgangId = await bereitgelegt(schluesselId);
|
||||
|
||||
const roh = await prisma.vorgang.findUniqueOrThrow({ where: { id: vorgangId } });
|
||||
expect(roh.status).toBe("ERLEDIGT");
|
||||
const abholung = (roh.ergebnis as { abholung: string }).abholung;
|
||||
expect(abholung).toMatch(/^v1\./);
|
||||
expect(abholung).not.toContain("PRIVATE KEY");
|
||||
// Die Status-Schnittstelle zeigt nur „bereit“, nie das Chiffrat.
|
||||
expect((await vorgangLesen(vorgangId))?.ergebnis).toEqual({ bereit: true });
|
||||
|
||||
const erwartet = (await privateKeyLaden(schluesselId)).toString("utf8");
|
||||
const geliefert = await privateKeyAbholen(vorgangId, "anzeigen", admin);
|
||||
expect(geliefert.privateKey).toBe(erwartet);
|
||||
expect(privateKeyLesen(geliefert.privateKey).fingerprint).toBe(geliefert.fingerprint);
|
||||
|
||||
expect((await prisma.vorgang.findUniqueOrThrow({ where: { id: vorgangId } })).ergebnis).toEqual({
|
||||
abgeholt: true,
|
||||
});
|
||||
await fachfehlerVon(privateKeyAbholen(vorgangId, "anzeigen", admin));
|
||||
|
||||
const eintraege = await prisma.protokoll.findMany({
|
||||
where: { aktion: { startsWith: "privatekey." } },
|
||||
orderBy: { id: "asc" },
|
||||
});
|
||||
expect(eintraege.map((e) => e.aktion)).toEqual(["privatekey.angefordert", "privatekey.angezeigt"]);
|
||||
expect(eintraege.every((e) => e.fingerprint === geliefert.fingerprint)).toBe(true);
|
||||
|
||||
const alles = await datenbankAlsText();
|
||||
expect(alles).not.toContain("PRIVATE KEY");
|
||||
expect(alles).not.toContain(erwartet.split("\n")[2]);
|
||||
});
|
||||
|
||||
it("gibt bei gleichzeitigen Abholungen genau eine heraus", async () => {
|
||||
const vorgangId = await bereitgelegt(await dienstKey(), admin, "herunterladen");
|
||||
const ergebnisse = await Promise.allSettled(
|
||||
Array.from({ length: 5 }, () => privateKeyAbholen(vorgangId, "herunterladen", admin)),
|
||||
);
|
||||
expect(ergebnisse.filter((e) => e.status === "fulfilled")).toHaveLength(1);
|
||||
expect(await prisma.protokoll.count({ where: { aktion: "privatekey.heruntergeladen" } })).toBe(1);
|
||||
});
|
||||
|
||||
it("gibt nur dem Anfordernden heraus", async () => {
|
||||
const vorgangId = await bereitgelegt(await dienstKey());
|
||||
await fachfehlerVon(privateKeyAbholen(vorgangId, "anzeigen", zweiterAdmin));
|
||||
await expect(privateKeyAbholen(vorgangId, "anzeigen", admin)).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it("verweigert nach Ablauf der Frist und ueberschreibt die Abholung trotzdem", async () => {
|
||||
const vorgangId = await bereitgelegt(await dienstKey());
|
||||
const { beendetAm } = await prisma.vorgang.findUniqueOrThrow({ where: { id: vorgangId } });
|
||||
const spaeter = new Date(beendetAm!.getTime() + ABHOLFRIST_MS + 1000);
|
||||
const fehler = await fachfehlerVon(privateKeyAbholen(vorgangId, "anzeigen", admin, spaeter));
|
||||
expect(fehler.message).toMatch(/verfallen/);
|
||||
expect(
|
||||
JSON.stringify((await prisma.vorgang.findUniqueOrThrow({ where: { id: vorgangId } })).ergebnis),
|
||||
).not.toContain("abholung");
|
||||
});
|
||||
|
||||
it("verlangt einen gespeicherten Private Key und gibt keinen Deployment-Key heraus", async () => {
|
||||
const nurPublic = await publicKeyImportieren(
|
||||
{ name: "Nur Public", publicKey: zeile("ssh-ed25519", ed25519Blob()) },
|
||||
admin,
|
||||
);
|
||||
expect((await fachfehlerVon(abholungAnfordern(nurPublic.id, "anzeigen", admin))).message).toMatch(
|
||||
/kein Private Key/,
|
||||
);
|
||||
const deployment = await deploymentKeySicherstellen();
|
||||
expect((await fachfehlerVon(abholungAnfordern(deployment, "anzeigen", admin))).message).toMatch(
|
||||
/Deployment/,
|
||||
);
|
||||
await fachfehlerVon(privateKeyLoeschenAnfordern(deployment, admin));
|
||||
expect(await prisma.vorgang.count({ where: { art: "PRIVATE_KEY_ABHOLEN" } })).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Aufraeumen", () => {
|
||||
it("ueberschreibt nur Abholungen, die laenger als die Frist liegen", async () => {
|
||||
const jetzt = new Date();
|
||||
const alt = await prisma.vorgang.create({
|
||||
data: {
|
||||
art: "PRIVATE_KEY_ABHOLEN",
|
||||
status: "ERLEDIGT",
|
||||
ergebnis: { abholung: "v1.a.b.c" },
|
||||
beendetAm: new Date(jetzt.getTime() - ABHOLFRIST_MS - 5000),
|
||||
},
|
||||
});
|
||||
const frisch = await prisma.vorgang.create({
|
||||
data: {
|
||||
art: "PRIVATE_KEY_ABHOLEN",
|
||||
status: "ERLEDIGT",
|
||||
ergebnis: { abholung: "v1.d.e.f" },
|
||||
beendetAm: new Date(jetzt.getTime() - 30_000),
|
||||
},
|
||||
});
|
||||
const fremd = await prisma.vorgang.create({
|
||||
data: {
|
||||
art: "ERREICHBARKEIT",
|
||||
status: "ERLEDIGT",
|
||||
ergebnis: { abholung: "kein Key" },
|
||||
beendetAm: alt.beendetAm,
|
||||
},
|
||||
});
|
||||
|
||||
expect(await abholungenAufraeumen(jetzt)).toBe(1);
|
||||
const lesen = async (id: string) => (await prisma.vorgang.findUniqueOrThrow({ where: { id } })).ergebnis;
|
||||
expect(await lesen(alt.id)).toEqual({ verfallen: true });
|
||||
expect(await lesen(frisch.id)).toEqual({ abholung: "v1.d.e.f" });
|
||||
expect(await lesen(fremd.id)).toEqual({ abholung: "kein Key" });
|
||||
// Spaeter verfaellt auch die zweite.
|
||||
expect(await abholungenAufraeumen(new Date(jetzt.getTime() + ABHOLFRIST_MS))).toBe(1);
|
||||
});
|
||||
|
||||
it("laeuft vor jeder neuen Abholung im Worker", async () => {
|
||||
const schluesselId = await dienstKey();
|
||||
const liegengeblieben = await bereitgelegt(schluesselId);
|
||||
await prisma.vorgang.update({
|
||||
where: { id: liegengeblieben },
|
||||
data: { beendetAm: new Date(Date.now() - ABHOLFRIST_MS - 1000) },
|
||||
});
|
||||
await bereitgelegt(schluesselId);
|
||||
expect((await prisma.vorgang.findUniqueOrThrow({ where: { id: liegengeblieben } })).ergebnis).toEqual({
|
||||
verfallen: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Private Key loeschen", () => {
|
||||
it("verwirft Chiffrat, Data-Key und Version im Worker und protokolliert", async () => {
|
||||
const schluesselId = await dienstKey();
|
||||
const vorgangId = await privateKeyLoeschenAnfordern(schluesselId, admin);
|
||||
await vorgangImWorker(vorgangId);
|
||||
expect((await prisma.vorgang.findUniqueOrThrow({ where: { id: vorgangId } })).status).toBe("ERLEDIGT");
|
||||
|
||||
const detail = await schluesselDetail(schluesselId);
|
||||
expect(detail?.privatGespeichert).toBe(false);
|
||||
expect(detail?.privatGeloeschtAm).toBeInstanceOf(Date);
|
||||
await expect(privateKeyLaden(schluesselId)).rejects.toThrow(/kein Private Key/);
|
||||
|
||||
const eintrag = await prisma.protokoll.findFirstOrThrow({ where: { aktion: "privatekey.geloescht" } });
|
||||
expect(eintrag).toMatchObject({ akteurId: admin.id, fingerprint: detail?.fingerprint });
|
||||
await fachfehlerVon(privateKeyLoeschenAnfordern(schluesselId, admin));
|
||||
await fachfehlerVon(abholungAnfordern(schluesselId, "anzeigen", admin));
|
||||
});
|
||||
});
|
||||
|
||||
describe("Rechte", () => {
|
||||
it("nur Admin, immer mit frischer Bestaetigung", () => {
|
||||
for (const recht of ["privateKeyAnzeigen", "privateKeyLoeschen"] as const) {
|
||||
expect(RECHTE[recht]).toEqual(["ADMIN"]);
|
||||
expect(BESTAETIGUNG_NOETIG.has(recht)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("Action und Route Handler pruefen selbst", () => {
|
||||
const aktionen = readFileSync("src/server/actions/privatekey.ts", "utf8");
|
||||
expect(aktionen.match(/rechtErforderlich\("privateKeyAnzeigen"\)/g)).toHaveLength(2);
|
||||
expect(aktionen).toMatch(/rechtErforderlich\("privateKeyLoeschen"\)/);
|
||||
const route = readFileSync("src/app/api/private-key/[vorgangId]/route.ts", "utf8");
|
||||
expect(route).toMatch(/rechtErforderlich\("privateKeyAnzeigen"\)/);
|
||||
expect(route).toMatch(/no-store/);
|
||||
expect(route).toMatch(/attachment/);
|
||||
expect(route).not.toMatch(/export async function GET/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { readFileSync, readdirSync, statSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { SCHLUESSEL_DETAIL_SELECT, SCHLUESSEL_LISTE_SELECT } from "@/server/queries/schluessel";
|
||||
|
||||
/**
|
||||
* Strukturell (#012): Jede Prisma-Abfrage auf `Schluessel` ausserhalb des Workers hat ein
|
||||
* festes `select`. Ohne `select` liefert Prisma die ganze Zeile – samt Chiffrat –, ohne dass
|
||||
* der Feldname im Code steht; tests/geheimnisse-grenze.test.ts saehe das nicht.
|
||||
*/
|
||||
function dateien(verzeichnis: string): string[] {
|
||||
return readdirSync(verzeichnis).flatMap((name) => {
|
||||
const pfad = path.join(verzeichnis, name);
|
||||
if (statSync(pfad).isDirectory()) return name === "generated" ? [] : dateien(pfad);
|
||||
return /\.(ts|tsx)$/.test(name) ? [pfad.split(path.sep).join("/")] : [];
|
||||
});
|
||||
}
|
||||
|
||||
/** Der Worker darf (und muss) die ganze Zeile sehen; die Grenze dort zieht geheimnisse.ts. */
|
||||
const WEB = dateien("src").filter((d) => !d.startsWith("src/worker/"));
|
||||
|
||||
/** Methoden, die Zeilen zurueckgeben – nur die brauchen ein `select`. */
|
||||
const ZEILEN =
|
||||
/\.schluessel\.(findMany|findFirst|findFirstOrThrow|findUnique|findUniqueOrThrow|create|update|upsert|delete)\(/g;
|
||||
|
||||
/** Argumenttext eines Aufrufs ab der oeffnenden Klammer, per Klammerzaehlung. */
|
||||
function argumente(text: string, start: number): string {
|
||||
let tiefe = 0;
|
||||
for (let i = start; i < text.length; i++) {
|
||||
if (text[i] === "(") tiefe++;
|
||||
else if (text[i] === ")" && --tiefe === 0) return text.slice(start, i + 1);
|
||||
}
|
||||
return text.slice(start);
|
||||
}
|
||||
|
||||
describe("Abfragen auf Schluessel im Web", () => {
|
||||
it("findet Aufrufe", () => {
|
||||
const anzahl = WEB.reduce((summe, d) => summe + [...readFileSync(d, "utf8").matchAll(ZEILEN)].length, 0);
|
||||
expect(anzahl).toBeGreaterThan(5);
|
||||
});
|
||||
|
||||
it("haben alle ein select", () => {
|
||||
const verstoesse: string[] = [];
|
||||
for (const datei of WEB) {
|
||||
const text = readFileSync(datei, "utf8");
|
||||
for (const treffer of text.matchAll(ZEILEN)) {
|
||||
const start = treffer.index + treffer[0].length - 1;
|
||||
if (!/\bselect\s*:/.test(argumente(text, start))) {
|
||||
verstoesse.push(`${datei}: ${treffer[0]}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(verstoesse).toEqual([]);
|
||||
});
|
||||
|
||||
it("holen Schluessel nie als ganze Relation (include/select: { schluessel: true })", () => {
|
||||
// Zeilen mit `_count` zaehlen nur und sind erlaubt.
|
||||
const verstoesse = WEB.filter((d) =>
|
||||
readFileSync(d, "utf8")
|
||||
.split("\n")
|
||||
.some(
|
||||
(zeile) =>
|
||||
!zeile.includes("_count") &&
|
||||
/\b(schluessel|vorgaenger|nachfolger|deploymentSchluessel|erstellteSchluessel)\s*:\s*true/.test(
|
||||
zeile,
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(verstoesse).toEqual([]);
|
||||
});
|
||||
|
||||
it("die festen select-Listen nennen kein Chiffrat", () => {
|
||||
for (const auswahl of [SCHLUESSEL_LISTE_SELECT, SCHLUESSEL_DETAIL_SELECT]) {
|
||||
expect(Object.keys(auswahl)).not.toContain("privatVerschluesselt");
|
||||
expect(Object.keys(auswahl)).not.toContain("dekVerschluesselt");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,431 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { entschluesseln } from "@/lib/geheimnisse";
|
||||
import {
|
||||
ablaufZustand,
|
||||
fingerprintKurz,
|
||||
freierSlug,
|
||||
slugAus,
|
||||
typAnzeige,
|
||||
erzeugenSchema,
|
||||
} from "@/lib/schluessel";
|
||||
import { privateKeyLesen, schluesselpaarErzeugen } from "@/lib/ssh/openssh";
|
||||
import { schluesselDetail, schluesselListe } from "@/server/queries/schluessel";
|
||||
import { FachFehler } from "@/server/services/fachfehler";
|
||||
import {
|
||||
erzeugenAnfordern,
|
||||
paarImportAnfordern,
|
||||
publicKeyImportieren,
|
||||
schluesselBearbeiten,
|
||||
} from "@/server/services/schluessel";
|
||||
import { masterKeyVergessen, privateKeyLaden } from "@/worker/geheimnisse";
|
||||
import { datenbankLeeren } from "./hilfen/db";
|
||||
import { datenbankAlsText, vorgangImWorker, warteschlangeStoppen } from "./hilfen/vorgang";
|
||||
import { ed25519Blob, rsaBlob, zeile } from "./ssh/hilfen";
|
||||
|
||||
/** Schluessel erzeugen, importieren, bearbeiten (#012) – Service, Worker-Ablauf, Lesemodell. */
|
||||
|
||||
let verzeichnis: string;
|
||||
let akteur: { id: string; name: string };
|
||||
|
||||
beforeAll(() => {
|
||||
verzeichnis = mkdtempSync(path.join(tmpdir(), "sshkv-master-"));
|
||||
const datei = path.join(verzeichnis, "master.key");
|
||||
writeFileSync(datei, randomBytes(32), { mode: 0o600 });
|
||||
process.env["SSHKV_MASTER_KEY_FILE"] = datei;
|
||||
masterKeyVergessen();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
rmSync(verzeichnis, { recursive: true, force: true });
|
||||
await warteschlangeStoppen();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await datenbankLeeren();
|
||||
const benutzer = await prisma.benutzer.create({ data: { name: "Olga Operator", rolle: "OPERATOR" } });
|
||||
akteur = { id: benutzer.id, name: benutzer.name };
|
||||
});
|
||||
|
||||
async function fachfehlerVon(versuch: Promise<unknown>): Promise<FachFehler> {
|
||||
const fehler = await versuch.then(
|
||||
() => undefined,
|
||||
(f: unknown) => f,
|
||||
);
|
||||
expect(fehler).toBeInstanceOf(FachFehler);
|
||||
return fehler as FachFehler;
|
||||
}
|
||||
|
||||
/** Kern eines Private Keys (ohne Kopf- und Fusszeile) – das eigentliche Geheimnis. */
|
||||
function kern(privateKey: string): string {
|
||||
return privateKey.split("\n").slice(1, -2).join("").slice(0, 60);
|
||||
}
|
||||
|
||||
describe("Regeln (rein)", () => {
|
||||
it.each([
|
||||
["Backup srv-backup01 → srv-pve01", "backup-srv-backup01-srv-pve01"],
|
||||
["Größter Schlüssel für Ärzte", "groesster-schluessel-fuer-aerzte"],
|
||||
[" --- ", "schluessel"],
|
||||
["Café", "cafe"],
|
||||
["x".repeat(80), "x".repeat(64)],
|
||||
])("slugAus(%j) = %j", (name, slug) => {
|
||||
expect(slugAus(name)).toBe(slug);
|
||||
});
|
||||
|
||||
it("haengt Nummern an, bis der Slug frei ist", async () => {
|
||||
const belegt = new Set(["backup", "backup-2"]);
|
||||
expect(await freierSlug("backup", async (s) => !belegt.has(s))).toBe("backup-3");
|
||||
const lang = "a".repeat(64);
|
||||
const ergebnis = await freierSlug(lang, async (s) => s !== lang);
|
||||
expect(ergebnis).toHaveLength(64);
|
||||
expect(ergebnis.endsWith("-2")).toBe(true);
|
||||
});
|
||||
|
||||
it("kuerzt Fingerprints und zeigt Typen", () => {
|
||||
expect(fingerprintKurz("SHA256:q3vN8xP0b4Tz7KfL2mWcR9yHdE1sJ6uAoGi5VnBQe0k")).toBe(
|
||||
"SHA256:q3vN8xP0…Qe0k",
|
||||
);
|
||||
expect(typAnzeige("ED25519", "ssh-ed25519 AAAA")).toBe("ed25519");
|
||||
expect(typAnzeige("RSA4096", "ssh-rsa AAAA")).toBe("rsa-4096");
|
||||
expect(typAnzeige("ANDERER", "ecdsa-sha2-nistp256 AAAA")).toBe("ecdsa-sha2-nistp256");
|
||||
expect(typAnzeige("ANDERER", "sk-ssh-ed25519@openssh.com AAAA")).toBe("sk-ssh-ed25519");
|
||||
});
|
||||
|
||||
it("bewertet das Ablaufdatum", () => {
|
||||
const jetzt = new Date("2026-10-12T08:00:00Z");
|
||||
expect(ablaufZustand(null, jetzt).art).toBe("keiner");
|
||||
expect(ablaufZustand(new Date("2026-10-30T00:00:00Z"), jetzt)).toEqual({ art: "bald", tage: 18 });
|
||||
expect(ablaufZustand(new Date("2026-11-12T00:00:00Z"), jetzt).art).toBe("spaeter");
|
||||
expect(ablaufZustand(new Date("2026-10-11T00:00:00Z"), jetzt).art).toBe("abgelaufen");
|
||||
});
|
||||
|
||||
it("prueft Eingaben", () => {
|
||||
expect(erzeugenSchema.safeParse({ name: "" }).success).toBe(false);
|
||||
expect(erzeugenSchema.safeParse({ name: "x", slug: "Mit Leer" }).success).toBe(false);
|
||||
expect(erzeugenSchema.safeParse({ name: "x", kommentar: "a\nb" }).success).toBe(false);
|
||||
expect(erzeugenSchema.safeParse({ name: "x", ablaufAm: "2026-02-30" }).success).toBe(false);
|
||||
expect(erzeugenSchema.safeParse({ name: "x", typ: "DSA" }).success).toBe(false);
|
||||
const ok = erzeugenSchema.parse({ name: " Backup ", tags: "PVE, pve backup", ablaufAm: "2030-01-31" });
|
||||
expect(ok).toMatchObject({ name: "Backup", tags: ["pve", "backup"], typ: "ED25519", slug: null });
|
||||
expect(ok.ablaufAm?.toISOString()).toBe("2030-01-31T00:00:00.000Z");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Public Key importieren", () => {
|
||||
it("legt an, setzt Slug und Kommentar und protokolliert", async () => {
|
||||
const blob = ed25519Blob();
|
||||
const neu = await publicKeyImportieren(
|
||||
{
|
||||
name: "Laptop Florian",
|
||||
kategorie: "PERSON",
|
||||
publicKey: zeile("ssh-ed25519", blob, "florian@laptop"),
|
||||
},
|
||||
akteur,
|
||||
);
|
||||
const s = await schluesselDetail(neu.id);
|
||||
expect(s).toMatchObject({
|
||||
slug: "laptop-florian",
|
||||
typ: "ED25519",
|
||||
kategorie: "PERSON",
|
||||
kommentar: "florian@laptop",
|
||||
privatGespeichert: false,
|
||||
selbstHinterlegt: false,
|
||||
});
|
||||
expect(s?.publicKey).toBe(zeile("ssh-ed25519", blob, "florian@laptop"));
|
||||
const eintrag = await prisma.protokoll.findFirstOrThrow({ where: { aktion: "schluessel.importiert" } });
|
||||
expect(eintrag.fingerprint).toBe(s?.fingerprint);
|
||||
expect(eintrag.akteurName).toBe("Olga Operator");
|
||||
});
|
||||
|
||||
it("meldet einen schon verwalteten Key mit Verweis", async () => {
|
||||
const z = zeile("ssh-ed25519", ed25519Blob());
|
||||
const erster = await publicKeyImportieren({ name: "Erster", publicKey: z }, akteur);
|
||||
const fehler = await fachfehlerVon(publicKeyImportieren({ name: "Zweiter", publicKey: z }, akteur));
|
||||
expect(fehler.message).toContain("„Erster“");
|
||||
expect(fehler.message).toContain(`/schluessel/${erster.id}`);
|
||||
expect(fehler.feldfehler["publicKey"]).toBeDefined();
|
||||
});
|
||||
|
||||
it("nummeriert abgeleitete Slugs und lehnt vergebene Wunsch-Slugs ab", async () => {
|
||||
const a = await publicKeyImportieren(
|
||||
{ name: "Backup", publicKey: zeile("ssh-ed25519", ed25519Blob()) },
|
||||
akteur,
|
||||
);
|
||||
const b = await publicKeyImportieren(
|
||||
{ name: "Backup", publicKey: zeile("ssh-ed25519", ed25519Blob()) },
|
||||
akteur,
|
||||
);
|
||||
expect([a.slug, b.slug]).toEqual(["backup", "backup-2"]);
|
||||
const fehler = await fachfehlerVon(
|
||||
publicKeyImportieren(
|
||||
{ name: "X", slug: "backup", publicKey: zeile("ssh-ed25519", ed25519Blob()) },
|
||||
akteur,
|
||||
),
|
||||
);
|
||||
expect(fehler.feldfehler["slug"]).toMatch(/vergeben/);
|
||||
});
|
||||
|
||||
it("verlangt RSA mit mindestens 2048 Bit und erkennt RSA-4096", async () => {
|
||||
const fehler = await fachfehlerVon(
|
||||
publicKeyImportieren({ name: "Alt", publicKey: zeile("ssh-rsa", rsaBlob(1024)) }, akteur),
|
||||
);
|
||||
expect(fehler.feldfehler["publicKey"]).toMatch(/2048/);
|
||||
const neu = await publicKeyImportieren(
|
||||
{ name: "Neu", publicKey: zeile("ssh-rsa", rsaBlob(4096)) },
|
||||
akteur,
|
||||
);
|
||||
expect((await schluesselDetail(neu.id))?.typ).toBe("RSA4096");
|
||||
const mittel = await publicKeyImportieren(
|
||||
{ name: "Mittel", publicKey: zeile("ssh-rsa", rsaBlob(3072)) },
|
||||
akteur,
|
||||
);
|
||||
expect((await schluesselDetail(mittel.id))?.typ).toBe("ANDERER");
|
||||
});
|
||||
|
||||
it("lehnt Unsinn, Vergangenheit und unbekannte Bezuege ab", async () => {
|
||||
await fachfehlerVon(publicKeyImportieren({ name: "X", publicKey: "kein key" }, akteur));
|
||||
const vergangen = await fachfehlerVon(
|
||||
publicKeyImportieren(
|
||||
{ name: "X", publicKey: zeile("ssh-ed25519", ed25519Blob()), ablaufAm: "2020-01-01" },
|
||||
akteur,
|
||||
),
|
||||
);
|
||||
expect(vergangen.feldfehler["ablaufAm"]).toBeDefined();
|
||||
const person = await fachfehlerVon(
|
||||
publicKeyImportieren(
|
||||
{ name: "X", publicKey: zeile("ssh-ed25519", ed25519Blob()), personId: "gibtsnicht" },
|
||||
akteur,
|
||||
),
|
||||
);
|
||||
expect(person.feldfehler["personId"]).toBeDefined();
|
||||
expect(await prisma.schluessel.count()).toBe(0);
|
||||
});
|
||||
|
||||
it("setzt das Ablaufdatum aus der Laufzeit der Vorlage", async () => {
|
||||
const vorlage = await prisma.vorlage.create({
|
||||
data: { name: "Kurz", zielbenutzer: "deploy", standardLaufzeitTage: 10 },
|
||||
});
|
||||
const neu = await publicKeyImportieren(
|
||||
{ name: "Mit Vorlage", publicKey: zeile("ssh-ed25519", ed25519Blob()), vorlageId: vorlage.id },
|
||||
akteur,
|
||||
);
|
||||
const s = await schluesselDetail(neu.id);
|
||||
expect(ablaufZustand(s!.ablaufAm)).toEqual({ art: "bald", tage: 10 });
|
||||
expect(s?.vorlage?.name).toBe("Kurz");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Erzeugen im Worker", () => {
|
||||
it("erzeugt ed25519, versiegelt und verweist den Vorgang auf den Schluessel", async () => {
|
||||
const vorgangId = await erzeugenAnfordern(
|
||||
{ name: "Backup srv-backup01", tags: "backup" },
|
||||
undefined,
|
||||
akteur,
|
||||
);
|
||||
const { jobDaten } = await vorgangImWorker(vorgangId);
|
||||
expect(jobDaten.geheim).toBeUndefined();
|
||||
|
||||
const vorgang = await prisma.vorgang.findUniqueOrThrow({ where: { id: vorgangId } });
|
||||
expect(vorgang.status).toBe("ERLEDIGT");
|
||||
const { schluesselId, fingerprint } = vorgang.ergebnis as { schluesselId: string; fingerprint: string };
|
||||
expect(vorgang.schluesselId).toBe(schluesselId);
|
||||
|
||||
const s = await schluesselDetail(schluesselId);
|
||||
expect(s).toMatchObject({
|
||||
slug: "backup-srv-backup01",
|
||||
kommentar: "sshkv:backup-srv-backup01",
|
||||
kategorie: "DIENST",
|
||||
typ: "ED25519",
|
||||
privatGespeichert: true,
|
||||
passphraseGeschuetzt: false,
|
||||
fingerprint,
|
||||
tags: ["backup"],
|
||||
});
|
||||
expect(s?.erstelltVon?.name).toBe("Olga Operator");
|
||||
|
||||
const privat = await privateKeyLaden(schluesselId);
|
||||
expect(privateKeyLesen(privat.toString("utf8")).fingerprint).toBe(fingerprint);
|
||||
privat.fill(0);
|
||||
|
||||
const eintrag = await prisma.protokoll.findFirstOrThrow({ where: { aktion: "schluessel.erzeugt" } });
|
||||
expect(eintrag).toMatchObject({ fingerprint, akteurId: akteur.id, ergebnis: "ERFOLG" });
|
||||
});
|
||||
|
||||
it("speichert mit Passphrase den geschuetzten Key – die Passphrase nirgends", async () => {
|
||||
const passphrase = "korrekt-pferd-batterie-heftklammer";
|
||||
const vorgangId = await erzeugenAnfordern({ name: "Geschuetzt", typ: "ED25519" }, passphrase, akteur);
|
||||
const vorgang = await prisma.vorgang.findUniqueOrThrow({ where: { id: vorgangId } });
|
||||
expect(JSON.stringify(vorgang.eingabe)).not.toContain(passphrase);
|
||||
expect(vorgang.eingabe).toMatchObject({ mitPassphrase: true });
|
||||
|
||||
const { jobDaten } = await vorgangImWorker(vorgangId);
|
||||
expect(jobDaten.geheim).toBeDefined();
|
||||
expect(jobDaten.geheim).not.toContain(passphrase);
|
||||
expect(JSON.parse(entschluesseln(jobDaten.geheim!))).toEqual({ passphrase });
|
||||
|
||||
const { schluesselId } = (await prisma.vorgang.findUniqueOrThrow({ where: { id: vorgangId } }))
|
||||
.ergebnis as { schluesselId: string };
|
||||
expect((await schluesselDetail(schluesselId))?.passphraseGeschuetzt).toBe(true);
|
||||
const privat = (await privateKeyLaden(schluesselId)).toString("utf8");
|
||||
expect(() => privateKeyLesen(privat)).toThrow(/Passphrase/);
|
||||
expect(privateKeyLesen(privat, passphrase).verschluesselt).toBe(true);
|
||||
|
||||
const alles = await datenbankAlsText();
|
||||
expect(alles).not.toContain(passphrase);
|
||||
expect(alles).not.toContain("PRIVATE KEY");
|
||||
});
|
||||
|
||||
it("erzeugt RSA-4096", { timeout: 30_000 }, async () => {
|
||||
const vorgangId = await erzeugenAnfordern({ name: "Alt-System", typ: "RSA4096" }, undefined, akteur);
|
||||
await vorgangImWorker(vorgangId);
|
||||
const { schluesselId } = (await prisma.vorgang.findUniqueOrThrow({ where: { id: vorgangId } }))
|
||||
.ergebnis as { schluesselId: string };
|
||||
const s = await schluesselDetail(schluesselId);
|
||||
expect(s?.typ).toBe("RSA4096");
|
||||
expect(s?.publicKey.startsWith("ssh-rsa ")).toBe(true);
|
||||
});
|
||||
|
||||
it("scheitert verstaendlich, wenn der Slug inzwischen vergeben ist", async () => {
|
||||
const vorgangId = await erzeugenAnfordern({ name: "Doppelt" }, undefined, akteur);
|
||||
await publicKeyImportieren(
|
||||
{ name: "Doppelt", slug: "doppelt", publicKey: zeile("ssh-ed25519", ed25519Blob()) },
|
||||
akteur,
|
||||
);
|
||||
await vorgangImWorker(vorgangId);
|
||||
const vorgang = await prisma.vorgang.findUniqueOrThrow({ where: { id: vorgangId } });
|
||||
expect(vorgang.status).toBe("FEHLGESCHLAGEN");
|
||||
expect(vorgang.fehler).toMatch(/inzwischen vergeben/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Paar importieren im Worker", () => {
|
||||
it("prueft, versiegelt und laesst weder Vorgang noch Protokoll den Key sehen", async () => {
|
||||
const paar = await schluesselpaarErzeugen("ED25519", { kommentar: "alt@server" });
|
||||
const vorgangId = await paarImportAnfordern(
|
||||
{ name: "Altlast" },
|
||||
paar.privateKeyOpenssh,
|
||||
undefined,
|
||||
akteur,
|
||||
);
|
||||
const vorgang = await prisma.vorgang.findUniqueOrThrow({ where: { id: vorgangId } });
|
||||
expect(JSON.stringify(vorgang)).not.toContain("PRIVATE KEY");
|
||||
|
||||
const { jobDaten } = await vorgangImWorker(vorgangId);
|
||||
expect(jobDaten.geheim).not.toContain(kern(paar.privateKeyOpenssh));
|
||||
const fertig = await prisma.vorgang.findUniqueOrThrow({ where: { id: vorgangId } });
|
||||
expect(fertig.status).toBe("ERLEDIGT");
|
||||
const { schluesselId } = fertig.ergebnis as { schluesselId: string };
|
||||
const s = await schluesselDetail(schluesselId);
|
||||
expect(s).toMatchObject({
|
||||
fingerprint: paar.fingerprint,
|
||||
kommentar: "alt@server",
|
||||
privatGespeichert: true,
|
||||
});
|
||||
expect((await privateKeyLaden(schluesselId)).toString("utf8")).toBe(paar.privateKeyOpenssh);
|
||||
|
||||
const alles = await datenbankAlsText();
|
||||
expect(alles).not.toContain("PRIVATE KEY");
|
||||
expect(alles).not.toContain(kern(paar.privateKeyOpenssh));
|
||||
expect(await prisma.protokoll.count({ where: { aktion: "schluessel.importiert" } })).toBe(1);
|
||||
});
|
||||
|
||||
it("behaelt einen passphrasengeschuetzten Key so, wie er kam", async () => {
|
||||
const passphrase = "nur-zur-pruefung-1234";
|
||||
const paar = await schluesselpaarErzeugen("ED25519", { kommentar: "x", passphrase });
|
||||
const ohne = await paarImportAnfordern({ name: "Ohne" }, paar.privateKeyOpenssh, undefined, akteur);
|
||||
await vorgangImWorker(ohne);
|
||||
expect((await prisma.vorgang.findUniqueOrThrow({ where: { id: ohne } })).fehler).toMatch(/Passphrase/);
|
||||
|
||||
const falsch = await paarImportAnfordern(
|
||||
{ name: "Falsch" },
|
||||
paar.privateKeyOpenssh,
|
||||
"Zz9-unpassend",
|
||||
akteur,
|
||||
);
|
||||
await vorgangImWorker(falsch);
|
||||
const fehler = (await prisma.vorgang.findUniqueOrThrow({ where: { id: falsch } })).fehler ?? "";
|
||||
expect(fehler).toMatch(/Passphrase/);
|
||||
expect(fehler).not.toContain("Zz9-unpassend");
|
||||
|
||||
const richtig = await paarImportAnfordern(
|
||||
{ name: "Richtig" },
|
||||
paar.privateKeyOpenssh,
|
||||
passphrase,
|
||||
akteur,
|
||||
);
|
||||
await vorgangImWorker(richtig);
|
||||
const { schluesselId } = (await prisma.vorgang.findUniqueOrThrow({ where: { id: richtig } }))
|
||||
.ergebnis as {
|
||||
schluesselId: string;
|
||||
};
|
||||
expect((await schluesselDetail(schluesselId))?.passphraseGeschuetzt).toBe(true);
|
||||
expect((await privateKeyLaden(schluesselId)).toString("utf8")).toBe(paar.privateKeyOpenssh);
|
||||
expect(await datenbankAlsText()).not.toContain(passphrase);
|
||||
});
|
||||
|
||||
it("lehnt Fremdformate im Web und Duplikate im Worker ab", async () => {
|
||||
const pem = "-----BEGIN RSA PRIVATE KEY-----\nMIIE\n-----END RSA PRIVATE KEY-----";
|
||||
const fehler = await fachfehlerVon(paarImportAnfordern({ name: "PEM" }, pem, undefined, akteur));
|
||||
expect(fehler.message).not.toContain("MIIE");
|
||||
|
||||
const paar = await schluesselpaarErzeugen("ED25519", { kommentar: "x" });
|
||||
await publicKeyImportieren({ name: "Schon da", publicKey: paar.publicKeyZeile }, akteur);
|
||||
const id = await paarImportAnfordern({ name: "Nochmal" }, paar.privateKeyOpenssh, undefined, akteur);
|
||||
await vorgangImWorker(id);
|
||||
expect((await prisma.vorgang.findUniqueOrThrow({ where: { id } })).fehler).toMatch(/„Schon da“/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Bearbeiten und Lesemodell", () => {
|
||||
it("aendert Metadaten, protokolliert nur Geaendertes und schuetzt den Status", async () => {
|
||||
const neu = await publicKeyImportieren(
|
||||
{ name: "Alt", publicKey: zeile("ssh-ed25519", ed25519Blob()) },
|
||||
akteur,
|
||||
);
|
||||
await schluesselBearbeiten(neu.id, { name: "Neu", tags: "a b", status: "ABGELAUFEN" }, akteur);
|
||||
const s = await schluesselDetail(neu.id);
|
||||
expect(s).toMatchObject({ name: "Neu", tags: ["a", "b"], status: "ABGELAUFEN", slug: "alt" });
|
||||
const eintrag = await prisma.protokoll.findFirstOrThrow({ where: { aktion: "schluessel.geaendert" } });
|
||||
expect(Object.keys(eintrag.details as object).sort()).toEqual(["name", "status", "tags"]);
|
||||
|
||||
await prisma.schluessel.update({
|
||||
where: { id: neu.id },
|
||||
data: { status: "WIDERRUFEN" },
|
||||
select: { id: true },
|
||||
});
|
||||
await fachfehlerVon(schluesselBearbeiten(neu.id, { name: "Neu", status: "AKTIV" }, akteur));
|
||||
});
|
||||
|
||||
it("filtert die Liste und liefert keine Chiffrat-Felder", async () => {
|
||||
await publicKeyImportieren(
|
||||
{
|
||||
name: "Person A",
|
||||
kategorie: "PERSON",
|
||||
publicKey: zeile("ssh-ed25519", ed25519Blob()),
|
||||
tags: "laptop",
|
||||
},
|
||||
akteur,
|
||||
);
|
||||
const vorgangId = await erzeugenAnfordern({ name: "Dienst B" }, undefined, akteur);
|
||||
await vorgangImWorker(vorgangId);
|
||||
|
||||
expect((await schluesselListe()).map((s) => s.name)).toEqual(["Dienst B", "Person A"]);
|
||||
expect((await schluesselListe({ kategorie: "PERSON" })).map((s) => s.name)).toEqual(["Person A"]);
|
||||
expect((await schluesselListe({ q: "laptop" })).map((s) => s.name)).toEqual(["Person A"]);
|
||||
expect((await schluesselListe({ status: "WIDERRUFEN" })).length).toBe(0);
|
||||
expect((await schluesselListe({ kategorie: "QUATSCH" })).length).toBe(2);
|
||||
|
||||
const liste = await schluesselListe();
|
||||
const dienst = liste.find((s) => s.name === "Dienst B")!;
|
||||
expect(dienst.privatGespeichert).toBe(true);
|
||||
const detail = await schluesselDetail(dienst.id);
|
||||
for (const antwort of [liste, detail]) {
|
||||
const text = JSON.stringify(antwort);
|
||||
expect(text).not.toMatch(/privatVerschluesselt|dekVerschluesselt|masterKeyVersion/);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user