feat(selbstauskunft): Mitarbeiter sehen ihre eigenen Geraete (#55)
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s

„Was ist eigentlich mit meinem Notebook passiert?“ musste bisher ein
Techniker nachsehen. Ein Mitarbeiter kann die Arbeiten an **seinen**
Geraeten jetzt selbst ansehen — mit der fuenften Rolle MITARBEITER,
zugeordnet beim Anmelden ueber den UPN.

Was er sieht, sind Titel, Zeitraum und Status. Keine Schritte, keine
Notizen, keine Kommentare, keine Anhaenge: in einem Lauf stehen
Arbeitsnotizen ueber Menschen und Befehle mit Platzhaltern fuer
Zugangsdaten. Eine Selbstauskunft ist keine Akteneinsicht, und das haelt
nicht die Seite fest, sondern das Lesemodell — eine Seite, die filtert,
ist eine Seite, die es vergessen kann.

`selbstauskunft` ist ein **Ersatz** fuer `lesen` und kein Zusatz; auch
ein ADMIN hat es nicht. Ein zweiter Weg zu denselben Daten waere eine
zweite Stelle, an der jemand die Einschraenkung auf die eigene Person
vergisst. Die Datenbank verlangt das sogar: ein CHECK haelt fest, dass
Rolle und Zuordnung zusammengehoeren, in beide Richtungen.

Ein mehrdeutiger UPN wird abgewiesen und protokolliert. Zwei Kunden
koennen dieselbe Person fuehren; ein findFirst entschiede nach
Sortierung darueber, wessen Geraete jemand sieht.

**Verhaltensaenderung:** wer sich anmeldet und in keiner Rollengruppe
steht, bekommt nicht mehr automatisch LESER — also nicht mehr
Lesezugriff auf alle Laeufe des Hauses. Das greift nur mit
konfigurierten OIDC_GROUP_*; ohne sie bleibt alles wie vorher, denn ohne
abgebildete Gruppen laesst sich Techniker nicht von Mitarbeiter
unterscheiden.

Nebenbei behoben: `staerksteRolle` benutzte LESER als Startwert und
damit als Untergrenze — mit einer Rolle darunter lieferte sie mehr
Rechte als uebergeben. Gefunden von einem Test, der seit #007
unveraendert dasteht.

Closes #55

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-10-06 07:32:54 +02:00
co-authored by Claude Opus 5
parent a417426f43
commit 0158d0292f
28 changed files with 1806 additions and 76 deletions
+533
View File
@@ -0,0 +1,533 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { beforeEach, describe, expect, it } from "vitest";
import { Betriebsmodus, LaufStatus, VersionStatus } from "@/generated/prisma/enums";
import { AnmeldeFehler, benutzerAusOidcUebernehmen } from "@/server/auth/benutzer";
import type { OidcAnspruecke } from "@/server/auth/oidc";
import { darf, startseite } from "@/server/auth/rollen";
import { navigationFuer } from "@/lib/navigation";
import { meineDaten, meineGeraete, meineLaeufeOhneGeraet } from "@/server/queries/selbstauskunft";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
/**
* Mitarbeiter-Selbstauskunft (#035).
*
* Die wichtigste Zusage dieses Meilensteins ist eine **Verneinung**: eine
* Selbstauskunft ist keine Akteneinsicht. Geprueft wird sie hier nicht an der
* Seite, sondern an der **Antwort** des Lesemodells — eine Seite laesst sich
* umgehen, ein Lesemodell nicht.
*/
const UMGEBUNG = { ...process.env };
function anspruecke(ueberschreiben: Partial<OidcAnspruecke> = {}): OidcAnspruecke {
return {
sub: "anbieter-sub-mitarbeiter",
email: "maria.huber@firma.example",
emailBestaetigt: true,
name: "Maria Huber",
gruppen: [],
upn: "mhuber@firma.local",
ziel: "/",
...ueberschreiben,
};
}
async function aufbau() {
const kunde = await testPrisma.kunde.create({
data: { name: "Eigene Organisation", kuerzel: "EIGEN", istEigeneOrganisation: true },
});
await testPrisma.einstellung.create({ data: { id: "global", modus: Betriebsmodus.INTERN } });
const maria = await testPrisma.mitarbeiter.create({
data: {
kundeId: kunde.id,
vorname: "Maria",
nachname: "Huber",
anzeigename: "Maria Huber",
upn: "mhuber@firma.local",
mail: "maria.huber@firma.example",
abteilung: "Buchhaltung",
titel: "Sachbearbeiterin",
},
});
const kollege = await testPrisma.mitarbeiter.create({
data: {
kundeId: kunde.id,
vorname: "Ali",
nachname: "Demir",
anzeigename: "Ali Demir",
upn: "ademir@firma.local",
},
});
const meinNotebook = await testPrisma.geraet.create({
data: { kundeId: kunde.id, mitarbeiterId: maria.id, tagCode: "LZ-MEANBC", hostname: "NB-042" },
});
const fremdesNotebook = await testPrisma.geraet.create({
data: { kundeId: kunde.id, mitarbeiterId: kollege.id, tagCode: "LZ-FREMDA", hostname: "NB-099" },
});
const vorlage = await testPrisma.vorlage.create({ data: { name: "PC Neuinstallation" } });
const version = await testPrisma.vorlagenVersion.create({
data: { vorlageId: vorlage.id, version: 1, status: VersionStatus.ENTWURF },
});
const abschnitt = await testPrisma.vorlagenAbschnitt.create({
data: { versionId: version.id, titel: "Konto", position: 0 },
});
const schritt = await testPrisma.vorlagenSchritt.create({
data: {
abschnittId: abschnitt.id,
titel: "Konto anlegen",
typ: "BEFEHL",
position: 0,
konfig: {
shell: "powershell",
befehl: 'New-ADUser -AccountPassword "{{generiertes_passwort}}"',
},
},
});
await testPrisma.vorlagenVersion.update({
where: { id: version.id },
data: { status: VersionStatus.VEROEFFENTLICHT, veroeffentlichtAm: new Date() },
});
const admin = await testPrisma.benutzer.create({
data: { email: "admin@firma.example", name: "Admin", rolle: "ADMIN" },
});
/* Ein Lauf an **meinem** Geraet, mit Notiz, Kommentar und Anhang daran. */
const meinLauf = await testPrisma.lauf.create({
data: {
vorlagenVersionId: version.id,
kundeId: kunde.id,
titel: "PC Neuinstallation – NB-042",
geraetId: meinNotebook.id,
mitarbeiterId: maria.id,
status: LaufStatus.ABGESCHLOSSEN,
gestartetAm: new Date("2026-09-01T08:00:00Z"),
abgeschlossenAm: new Date("2026-09-02T15:00:00Z"),
},
});
const ergebnis = await testPrisma.schrittergebnis.create({
data: {
laufId: meinLauf.id,
vorlagenSchrittId: schritt.id,
zustand: "ERLEDIGT",
notiz: "Nutzerin war unkooperativ, Gerät kam beschädigt zurück",
wert: "irgendein Wert",
aktualisiertVonId: admin.id,
},
});
await testPrisma.kommentar.create({
data: { laufId: meinLauf.id, benutzerId: admin.id, rumpf: "Rückfrage ans Team wegen der Lizenz" },
});
await testPrisma.anhang.create({
data: {
schrittergebnisId: ergebnis.id,
laufId: meinLauf.id,
kundeId: kunde.id,
dateiname: "schreibtisch.png",
medientyp: "image/png",
groesse: 10,
pruefsumme: "d".repeat(64),
},
});
/* Ein Lauf am Geraet des Kollegen – darf nie auftauchen. */
const fremderLauf = await testPrisma.lauf.create({
data: {
vorlagenVersionId: version.id,
kundeId: kunde.id,
titel: "PC Neuinstallation – NB-099",
geraetId: fremdesNotebook.id,
mitarbeiterId: kollege.id,
status: LaufStatus.IN_ARBEIT,
gestartetAm: new Date(),
},
});
/* Ein Lauf auf meine Person, ohne Geraet: das Onboarding. */
const persoenlich = await testPrisma.lauf.create({
data: {
vorlagenVersionId: version.id,
kundeId: kunde.id,
titel: "Onboarding Maria Huber",
mitarbeiterId: maria.id,
status: LaufStatus.ABGESCHLOSSEN,
gestartetAm: new Date("2026-08-01T08:00:00Z"),
abgeschlossenAm: new Date("2026-08-01T12:00:00Z"),
},
});
return { kunde, maria, kollege, meinNotebook, fremdesNotebook, meinLauf, fremderLauf, persoenlich };
}
describe("Die Rolle MITARBEITER", () => {
it("darf genau die Selbstauskunft und sonst nichts", () => {
expect(darf("MITARBEITER", "selbstauskunft")).toBe(true);
expect(darf("MITARBEITER", "lesen")).toBe(false);
expect(darf("MITARBEITER", "laeufeBearbeiten")).toBe(false);
expect(darf("MITARBEITER", "geraeteBearbeiten")).toBe(false);
expect(darf("MITARBEITER", "mitarbeiterBearbeiten")).toBe(false);
expect(darf("MITARBEITER", "vorlagenBearbeiten")).toBe(false);
expect(darf("MITARBEITER", "archivVerwalten")).toBe(false);
expect(darf("MITARBEITER", "verwaltung")).toBe(false);
});
it("landet nach der Anmeldung auf der eigenen Seite und nicht auf „kein Zugriff“", () => {
/*
* Beim Durchklicken gefunden: das Standardziel nach der Anmeldung ist `/`,
* und die Uebersicht verlangt `lesen`. Ein MITARBEITER sah deshalb als
* Erstes „Dafuer fehlt dir die Berechtigung“ — genau die leere Seite, die
* dieses Issue vermeiden will. Dieselbe Funktion richtet auch den Knopf auf
* `/kein-zugriff`, der vorher im Kreis fuehrte.
*/
expect(startseite("MITARBEITER")).toBe("/meine-geraete");
for (const rolle of ["ADMIN", "REDAKTEUR", "TECHNIKER", "LESER"] as const) {
expect(startseite(rolle), `${rolle} faengt auf der Uebersicht an`).toBe("/");
}
});
it("sieht in der Navigation nur die eine Seite", () => {
/*
* „Alles andere ist weg, nicht grau“ — ein Eintrag, der auf
* `/kein-zugriff` fuehrt, ist schlimmer als keiner.
*/
const eintraege = navigationFuer("INTERN", (recht) => darf("MITARBEITER", recht));
expect(eintraege.map((e) => e.pfad)).toEqual(["/meine-geraete"]);
});
it("nimmt den anderen Rollen die Selbstauskunft nicht weg – sie brauchen sie nicht", () => {
for (const rolle of ["ADMIN", "REDAKTEUR", "TECHNIKER", "LESER"] as const) {
const pfade = navigationFuer("INTERN", (recht) => darf(rolle, recht)).map((e) => e.pfad);
expect(pfade, `${rolle} sieht die Selbstauskunft nicht`).not.toContain("/meine-geraete");
expect(pfade.length, `${rolle} sieht die gewoehnliche Navigation`).toBeGreaterThan(1);
}
});
});
describe.runIf(datenbankVerfuegbar)("Was ein Mitarbeiter sieht", () => {
beforeEach(async () => {
await datenbankLeeren();
});
it("sieht die Läufe seiner Geräte und keine anderen", async () => {
const { maria, meinLauf, fremderLauf } = await aufbau();
const geraete = await meineGeraete(maria.id);
expect(geraete).toHaveLength(1);
expect(geraete[0]?.bezeichnung).toBe("NB-042");
const laufIds = geraete.flatMap((g) => g.laeufe.map((l) => l.id));
expect(laufIds).toEqual([meinLauf.id]);
expect(laufIds).not.toContain(fremderLauf.id);
});
it("sieht die Läufe zu seiner Person ohne Gerät", async () => {
const { maria, persoenlich, meinLauf } = await aufbau();
const ohne = await meineLaeufeOhneGeraet(maria.id);
expect(ohne.map((l) => l.id)).toEqual([persoenlich.id]);
// Der Lauf am Geraet steht schon unter dem Geraet – hier waere er doppelt.
expect(ohne.map((l) => l.id)).not.toContain(meinLauf.id);
});
it("sieht keine Schritte, Notizen, Kommentare oder Anhänge – geprüft über die Antwort", async () => {
/*
* Das Kernkriterium. Geprueft wird die **Antwort** und nicht die Seite: im
* Aufbau haengen an dem Lauf eine Notiz („Nutzerin war unkooperativ“), ein
* Kommentar, ein Anhang und ein Befehlsschritt mit
* `{{generiertes_passwort}}`. Nichts davon darf durchkommen.
*/
const { maria } = await aufbau();
const antwort = JSON.stringify([
await meineGeraete(maria.id),
await meineLaeufeOhneGeraet(maria.id),
await meineDaten(maria.id),
]);
expect(antwort).not.toContain("unkooperativ");
expect(antwort).not.toContain("Rückfrage ans Team");
expect(antwort).not.toContain("schreibtisch.png");
expect(antwort).not.toContain("New-ADUser");
expect(antwort).not.toContain("{{");
expect(antwort).not.toContain("irgendein Wert");
expect(antwort).not.toContain("ERLEDIGT");
// Und was durchkommen **soll**, kommt durch.
expect(antwort).toContain("PC Neuinstallation – NB-042");
expect(antwort).toContain("ABGESCHLOSSEN");
});
it("holt die verbotenen Felder nicht einmal aus der Datenbank", () => {
/*
* **Gefunden durch eine Gegenprobe, die gruen blieb.** Der Test unten prueft
* die Antwort, und die ist sicher: `laufBauen` setzt sie aus den erlaubten
* Feldern zusammen, also kommt auch bei einem zu weiten `select` nichts
* durch. Genau deshalb blieb er gruen, als im `select` versuchsweise
* `ergebnisse: { notiz, wert }` stand — waehrend der Kopfkommentar der Datei
* behauptet, das `select` hole es „gar nicht erst".
*
* Diese Behauptung braucht ihre eigene Deckung. Sie ist nicht bloss Zierde:
* ein `select`, das Notizen mitliest, zieht sie durch Protokolle und
* Fehlerberichte, und der naechste, der `laufBauen` erweitert, hat sie schon
* zur Hand.
*/
const quelle = readFileSync(join(process.cwd(), "src/server/queries/selbstauskunft.ts"), "utf8");
for (const verboten of ["notiz", "wert", "ergebnisse", "kommentare", "anhaenge", "konfig"]) {
expect(quelle, `das Lesemodell darf "${verboten}" nicht abfragen`).not.toContain(`${verboten}: true`);
expect(quelle, `das Lesemodell darf "${verboten}" nicht einbinden`).not.toContain(`${verboten}: {`);
}
});
it("gibt je Lauf genau die erlaubten Felder heraus", async () => {
// Namentlich aufgezaehlt: ein zusaetzliches Feld im Lesemodell faellt hier
// auf und nicht erst, wenn es auf der Seite steht.
const { maria } = await aufbau();
const geraete = await meineGeraete(maria.id);
const lauf = geraete[0]?.laeufe[0];
expect(lauf).toBeDefined();
expect(Object.keys(lauf ?? {}).sort()).toEqual([
"abgeschlossenAm",
"begonnenAm",
"erstelltAm",
"id",
"status",
"titel",
"vorlagenName",
]);
});
it("zeigt archivierte Geräte weiter – „mein altes Notebook“ ist die Frage", async () => {
const { maria, meinNotebook } = await aufbau();
await testPrisma.geraet.update({ where: { id: meinNotebook.id }, data: { archiviert: true } });
const geraete = await meineGeraete(maria.id);
expect(geraete).toHaveLength(1);
expect(geraete[0]?.archiviert).toBe(true);
});
it("liefert die eigenen Stammdaten, aber keine fremden", async () => {
const { maria, kollege } = await aufbau();
const daten = await meineDaten(maria.id);
expect(daten).toMatchObject({ anzeigename: "Maria Huber", abteilung: "Buchhaltung" });
// Die Funktion nimmt eine Kennung – mit der des Kollegen bekaeme man dessen
// Daten. Genau deshalb kommt sie aus der Sitzung und nie aus einer Eingabe.
const fremd = await meineDaten(kollege.id);
expect(fremd?.anzeigename).toBe("Ali Demir");
});
it("gibt für jemanden ohne Geräte eine leere Liste und keinen Fehler", async () => {
const { kollege, fremdesNotebook } = await aufbau();
await testPrisma.lauf.deleteMany({ where: { geraetId: fremdesNotebook.id } });
await testPrisma.geraet.delete({ where: { id: fremdesNotebook.id } });
expect(await meineGeraete(kollege.id)).toEqual([]);
expect(await meineLaeufeOhneGeraet(kollege.id)).toEqual([]);
});
});
describe.runIf(datenbankVerfuegbar)("Zuordnung bei der Anmeldung", () => {
beforeEach(async () => {
await datenbankLeeren();
process.env = { ...UMGEBUNG };
// Zuordnung aktiv – sonst greift die Selbstauskunft absichtlich gar nicht.
process.env["OIDC_GROUP_ADMIN"] = "lz-admin";
process.env["OIDC_GROUP_TECHNICIAN"] = "lz-technik";
});
it("macht aus einer Anmeldung ohne Rollengruppe einen MITARBEITER", async () => {
const { maria } = await aufbau();
const benutzer = await benutzerAusOidcUebernehmen(anspruecke());
expect(benutzer.rolle).toBe("MITARBEITER");
expect(benutzer.mitarbeiterId).toBe(maria.id);
});
it("ordnet unabhängig von Groß- und Kleinschreibung zu", async () => {
const { maria } = await aufbau();
const benutzer = await benutzerAusOidcUebernehmen(anspruecke({ upn: "MHuber@Firma.Local" }));
expect(benutzer.mitarbeiterId).toBe(maria.id);
});
it("lässt eine echte Rollengruppe vorgehen und ordnet dann keine Person zu", async () => {
await aufbau();
const benutzer = await benutzerAusOidcUebernehmen(anspruecke({ gruppen: ["lz-technik"] }));
expect(benutzer.rolle).toBe("TECHNIKER");
// Wer eine echte Rolle hat, sieht ueber `lesen` ohnehin alles – eine
// Zuordnung daneben waere ein zweiter Weg zu denselben Daten.
expect(benutzer.mitarbeiterId).toBeNull();
});
it("weist ab, wenn es zu dem UPN keine Person gibt", async () => {
await aufbau();
await expect(benutzerAusOidcUebernehmen(anspruecke({ upn: "niemand@firma.local" }))).rejects.toThrow(
AnmeldeFehler,
);
// Und kein halb angelegtes Konto hinterlassen.
expect(await testPrisma.benutzer.count({ where: { email: "maria.huber@firma.example" } })).toBe(0);
});
it("nennt den Grund, damit die Anmeldeseite etwas Hilfreiches sagen kann", async () => {
await aufbau();
try {
await benutzerAusOidcUebernehmen(anspruecke({ upn: "niemand@firma.local" }));
throw new Error("haette werfen muessen");
} catch (fehler) {
expect(fehler).toBeInstanceOf(AnmeldeFehler);
expect((fehler as AnmeldeFehler).grund).toBe("keine-person");
}
});
it("weist einen mehrdeutigen UPN ab und protokolliert ihn", async () => {
const { kunde } = await aufbau();
// Zwei Personen mit demselben UPN – etwa zwei Mandanten derselben Firma.
const zweiter = await testPrisma.kunde.create({ data: { name: "Zweite GmbH", kuerzel: "ZWEI" } });
await testPrisma.mitarbeiter.create({
data: {
kundeId: zweiter.id,
vorname: "Maria",
nachname: "Huber",
anzeigename: "Maria Huber (zweite)",
upn: "mhuber@firma.local",
},
});
expect(kunde.id).toBeTruthy();
try {
await benutzerAusOidcUebernehmen(anspruecke());
throw new Error("haette werfen muessen");
} catch (fehler) {
expect((fehler as AnmeldeFehler).grund).toBe("upn-mehrdeutig");
}
const protokoll = await testPrisma.protokoll.findMany({
where: { aktion: { contains: "mehrdeutig" } },
});
expect(protokoll).toHaveLength(1);
// Ohne Urheber: das Konto gibt es an der Stelle vielleicht noch nicht.
expect(protokoll[0]?.benutzerId).toBeNull();
expect(JSON.stringify(protokoll[0]?.nachher)).toContain("mhuber@firma.local");
});
it("ordnet keine deaktivierte Person zu", async () => {
const { maria } = await aufbau();
await testPrisma.mitarbeiter.update({ where: { id: maria.id }, data: { aktiv: false } });
await expect(benutzerAusOidcUebernehmen(anspruecke())).rejects.toThrow(AnmeldeFehler);
});
it("greift gar nicht, solange keine Gruppen-Zuordnung eingerichtet ist", async () => {
/*
* Die Wache gegen ein Update, das Leute aussperrt. Ohne abgebildete Gruppen
* kann die Anwendung nicht unterscheiden, ob jemand Techniker oder
* Mitarbeiter ist — dann bleibt alles wie vor #035.
*/
delete process.env["OIDC_GROUP_ADMIN"];
delete process.env["OIDC_GROUP_TECHNICIAN"];
await aufbau();
const benutzer = await benutzerAusOidcUebernehmen(anspruecke());
expect(benutzer.rolle).toBe("LESER");
expect(benutzer.mitarbeiterId).toBeNull();
});
it("stuft bei der nächsten Anmeldung um, wenn jemand eine Rollengruppe bekommt", async () => {
const { maria } = await aufbau();
const erst = await benutzerAusOidcUebernehmen(anspruecke());
expect(erst.rolle).toBe("MITARBEITER");
expect(erst.mitarbeiterId).toBe(maria.id);
const dann = await benutzerAusOidcUebernehmen(anspruecke({ gruppen: ["lz-admin"] }));
expect(dann.rolle).toBe("ADMIN");
// Die Zuordnung muss **mit** verschwinden, sonst verletzt sie den CHECK.
expect(dann.mitarbeiterId).toBeNull();
});
});
describe.runIf(datenbankVerfuegbar)("Was die Datenbank selbst festhält", () => {
beforeEach(async () => {
await datenbankLeeren();
});
it("lässt die Rolle MITARBEITER nicht ohne Zuordnung zu", async () => {
await expect(
testPrisma.benutzer.create({
data: { email: "ohne@firma.example", name: "Ohne Person", rolle: "MITARBEITER" },
}),
).rejects.toThrow();
});
it("lässt eine Zuordnung nicht an einer anderen Rolle zu", async () => {
const { maria } = await aufbau();
await expect(
testPrisma.benutzer.create({
data: {
email: "admin2@firma.example",
name: "Admin mit Person",
rolle: "ADMIN",
mitarbeiterId: maria.id,
},
}),
).rejects.toThrow();
});
it("gibt eine Person an höchstens ein Konto", async () => {
const { maria } = await aufbau();
await testPrisma.benutzer.create({
data: {
email: "maria@firma.example",
name: "Maria Huber",
rolle: "MITARBEITER",
mitarbeiterId: maria.id,
},
});
await expect(
testPrisma.benutzer.create({
data: {
email: "maria.zweitkonto@firma.example",
name: "Maria Huber (zweites Konto)",
rolle: "MITARBEITER",
mitarbeiterId: maria.id,
},
}),
).rejects.toThrow();
});
it("zieht die Rolle mit, wenn die Person gelöscht wird", async () => {
/*
* `ON DELETE SET NULL` allein wuerde am CHECK scheitern: ein MITARBEITER
* ohne Zuordnung ist verboten. Der Trigger setzt deshalb beides zugleich —
* sonst waere das Loeschen eines Mitarbeiters unmoeglich statt folgerichtig.
*/
const { kunde } = await aufbau();
const person = await testPrisma.mitarbeiter.create({
data: {
kundeId: kunde.id,
vorname: "Weg",
nachname: "Damit",
anzeigename: "Weg Damit",
upn: "wdamit@firma.local",
},
});
const konto = await testPrisma.benutzer.create({
data: {
email: "weg@firma.example",
name: "Weg Damit",
rolle: "MITARBEITER",
mitarbeiterId: person.id,
},
});
await testPrisma.mitarbeiter.delete({ where: { id: person.id } });
const nachher = await testPrisma.benutzer.findUniqueOrThrow({ where: { id: konto.id } });
expect(nachher.rolle).toBe("LESER");
expect(nachher.mitarbeiterId).toBeNull();
// Das Konto bleibt – nur ohne Person.
expect(nachher.aktiv).toBe(true);
});
});