Files
florian.wildeandClaude Opus 5.5 b5b43f41f6 feat(personen): Personen und Self-Service Meine Schluessel (#013)
Personen lassen sich anlegen, bearbeiten und archivieren; ihr Detail
zeigt ihre Schluessel. Unter "Meine Schluessel" hinterlegt jede Rolle
eigene Public Keys (Freigabe Frage 4), wirksam erst nach Zuweisung
durch einen Operator oder Admin.

Warum so:
- Die eigene Person entsteht beim ersten Aufruf oder wird verknuepft:
  per benutzerId, sonst per E-Mail - aber nur, wenn der OIDC-Anbieter
  die Adresse bestaetigt hat. Dafuer merkt sich Benutzer jetzt
  emailBestaetigt (Migration); sonst koennte jemand mit selbst
  gewaehlter Adresse die Keys einer fremden Person sehen.
- Die Self-Service-Services nehmen nur den angemeldeten Benutzer und
  leiten die Person selbst ab; fremde Schluessel-IDs gelten als nicht
  vorhanden.
- Entfernen nur fuer eigene, selbst hinterlegte Keys ohne Verwendung,
  bedingt geloescht, damit eine gleichzeitige Zuweisung nicht per
  Cascade verschwindet.
- Archivieren statt Loeschen: wer bis wann welchen Key hatte, bleibt
  nachvollziehbar.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:38:55 +02:00

214 lines
9.2 KiB
TypeScript

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/);
});
});