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
+49 -8
View File
@@ -22,13 +22,26 @@ import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
const UMGEBUNG = { ...process.env };
/**
* Standard-Anspruechge **mit** einer Rollengruppe.
*
* Seit #035 ist „angemeldet, aber in keiner Rollengruppe“ kein stilles `LESER`
* mehr, sondern fuehrt in die Selbstauskunft — und ohne passende Person im
* Verzeichnis wird die Anmeldung abgewiesen. Die Tests hier pruefen
* JIT-Provisioning, Protokoll und Kundenbindung; sie sollen nicht nebenbei an
* dieser Weiche haengen. Wer die Weiche selbst prueft, findet das in
* `tests/selbstauskunft.test.ts`.
*/
function anspruecke(ueberschreiben: Partial<OidcAnspruecke> = {}): OidcAnspruecke {
return {
sub: "anbieter-sub-1",
email: "maria.huber@firma.example",
emailBestaetigt: true,
name: "Maria Huber",
gruppen: [],
gruppen: ["lz-techniker"],
// Der UPN haengt an der Selbstauskunft (#035); im AD ist er oft nicht die
// Mailadresse, deshalb hier bewusst ein anderer Wert.
upn: "mhuber@firma.local",
ziel: "/",
...ueberschreiben,
};
@@ -136,9 +149,23 @@ describe.runIf(datenbankVerfuegbar)("Anmeldung und Benutzerpflege", () => {
const befoerdert = await benutzerAusOidcUebernehmen(anspruecke({ gruppen: ["lz-admin"] }));
expect(befoerdert.rolle).toBe("ADMIN");
// Und wieder zurueck: eine entzogene Gruppe entzieht die Rolle.
const entzogen = await benutzerAusOidcUebernehmen(anspruecke({ gruppen: [] }));
expect(entzogen.rolle).toBe("LESER");
/*
* Und wieder zurueck. **Seit #035 ist das kein `LESER` mehr:** wem die
* Rollengruppe entzogen wird und wer zu keiner Person im Verzeichnis
* passt, kommt nicht mehr herein.
*
* Das ist strenger als vorher und Absicht. Vorher behielt ein
* ausgeschiedener Techniker Lesezugriff auf **alle** Laeufe des Hauses,
* solange niemand sein Konto deaktivierte — und genau das vergisst man.
* Der Notfallzugang dagegen bleibt: `AUTH_LOCAL_ENABLED` haengt nicht an
* den Gruppen.
*/
await expect(benutzerAusOidcUebernehmen(anspruecke({ gruppen: [] }))).rejects.toThrow(AnmeldeFehler);
// Die Rolle in der Datenbank bleibt dabei, was sie war – abgewiesen heisst
// abgewiesen, nicht herabgestuft.
const unveraendert = await testPrisma.benutzer.findUniqueOrThrow({ where: { id: erst.id } });
expect(unveraendert.rolle).toBe("ADMIN");
});
it("protokolliert jede Rollenaenderung aus den Gruppen", async () => {
@@ -195,12 +222,22 @@ describe.runIf(datenbankVerfuegbar)("Anmeldung und Benutzerpflege", () => {
});
describe("Kundenbindung", () => {
/*
* **Rollengruppe und Kundengruppe sind zwei Dinge** (#035). Diese Tests gaben
* vorher nur `lz-kunde-MUS` mit und bekamen dadurch stillschweigend `LESER`.
* Seit #035 fuehrt „keine Rollengruppe“ in die Selbstauskunft — eine
* Kundengruppe allein sagt eben nichts darueber, was jemand duerfen soll.
* Wer lesen soll, gehoert in die Leser-Gruppe; dafuer gibt es
* `OIDC_GROUP_VIEWER`.
*/
it("bindet den Benutzer an die Kunden aus seinen Gruppen", async () => {
process.env["OIDC_CUSTOMER_GROUP_PREFIX"] = "lz-kunde-";
const mus = await testPrisma.kunde.create({ data: { name: "Musterfirma", kuerzel: "MUS" } });
await testPrisma.kunde.create({ data: { name: "Baeckerei Reiter", kuerzel: "REI" } });
const benutzer = await benutzerAusOidcUebernehmen(anspruecke({ gruppen: ["lz-kunde-MUS"] }));
const benutzer = await benutzerAusOidcUebernehmen(
anspruecke({ gruppen: ["lz-leser", "lz-kunde-MUS"] }),
);
const bindungen = await testPrisma.benutzerKunde.findMany({
where: { benutzerId: benutzer.id },
});
@@ -215,8 +252,10 @@ describe.runIf(datenbankVerfuegbar)("Anmeldung und Benutzerpflege", () => {
data: { name: "Baeckerei Reiter", kuerzel: "REI" },
});
const benutzer = await benutzerAusOidcUebernehmen(anspruecke({ gruppen: ["lz-kunde-MUS"] }));
await benutzerAusOidcUebernehmen(anspruecke({ gruppen: ["lz-kunde-REI"] }));
const benutzer = await benutzerAusOidcUebernehmen(
anspruecke({ gruppen: ["lz-leser", "lz-kunde-MUS"] }),
);
await benutzerAusOidcUebernehmen(anspruecke({ gruppen: ["lz-leser", "lz-kunde-REI"] }));
const bindungen = await testPrisma.benutzerKunde.findMany({
where: { benutzerId: benutzer.id },
@@ -227,7 +266,9 @@ describe.runIf(datenbankVerfuegbar)("Anmeldung und Benutzerpflege", () => {
it("bindet ohne Praefix niemanden ein", async () => {
await testPrisma.kunde.create({ data: { name: "Musterfirma", kuerzel: "MUS" } });
const benutzer = await benutzerAusOidcUebernehmen(anspruecke({ gruppen: ["lz-kunde-MUS"] }));
const benutzer = await benutzerAusOidcUebernehmen(
anspruecke({ gruppen: ["lz-leser", "lz-kunde-MUS"] }),
);
expect(await testPrisma.benutzerKunde.count({ where: { benutzerId: benutzer.id } })).toBe(0);
});
});
+13
View File
@@ -63,6 +63,12 @@ describe.runIf(datenbankVerfuegbar)("Handgeschriebene Datenbankobjekte", () => {
).toEqual([
"BausteinSchritt_unveraenderlich",
"BausteinVersion_status_einbahn",
/*
* Zieht die Rolle mit, wenn eine Person geloescht wird (#035). Ohne ihn
* scheiterte das `ON DELETE SET NULL` des Fremdschluessels am CHECK, und
* das Loeschen eines Mitarbeiters waere unmoeglich statt folgerichtig.
*/
"Mitarbeiter_konto_mitziehen",
"VorlagenAbschnitt_baustein_veroeffentlicht",
"VorlagenAbschnitt_unveraenderlich",
"VorlagenSchritt_unveraenderlich",
@@ -116,6 +122,13 @@ describe.runIf(datenbankVerfuegbar)("Handgeschriebene Datenbankobjekte", () => {
expect(vorhanden).toEqual([
"Anhang_groesse_positiv",
"Anhang_pruefsumme_form",
/*
* Die Rolle MITARBEITER und ihre Zuordnung gehoeren zusammen, in beide
* Richtungen (#035). Ohne das waere ein MITARBEITER ohne Person moeglich —
* ein Recht, das sich auf niemanden bezieht, und eine Einschraenkung, die
* je nach Formulierung „nichts“ oder „alles“ bedeutet.
*/
"Benutzer_mitarbeiter_nur_bei_rolle",
"Einstellung_einzelne_zeile",
"Einstellung_smtp_passwort_verschluesselt",
"Einstellung_smtp_port_bereich",
+48 -15
View File
@@ -11,13 +11,20 @@ const ALLE_RECHTE = Object.keys(RECHTE) as Recht[];
* Test, der die Tabelle nur gegen sich selbst haelt, wuerde jede Aenderung mitmachen.
*/
const MATRIX: Record<Recht, Record<Rolle, boolean>> = {
lesen: { ADMIN: true, REDAKTEUR: true, TECHNIKER: true, LESER: true },
laeufeBearbeiten: { ADMIN: true, REDAKTEUR: true, TECHNIKER: true, LESER: false },
geraeteBearbeiten: { ADMIN: true, REDAKTEUR: true, TECHNIKER: true, LESER: false },
mitarbeiterBearbeiten: { ADMIN: true, REDAKTEUR: true, TECHNIKER: true, LESER: false },
vorlagenBearbeiten: { ADMIN: true, REDAKTEUR: true, TECHNIKER: false, LESER: false },
archivVerwalten: { ADMIN: true, REDAKTEUR: false, TECHNIKER: false, LESER: false },
verwaltung: { ADMIN: true, REDAKTEUR: false, TECHNIKER: false, LESER: false },
lesen: { ADMIN: true, REDAKTEUR: true, TECHNIKER: true, LESER: true, MITARBEITER: false },
selbstauskunft: { ADMIN: false, REDAKTEUR: false, TECHNIKER: false, LESER: false, MITARBEITER: true },
laeufeBearbeiten: { ADMIN: true, REDAKTEUR: true, TECHNIKER: true, LESER: false, MITARBEITER: false },
geraeteBearbeiten: { ADMIN: true, REDAKTEUR: true, TECHNIKER: true, LESER: false, MITARBEITER: false },
mitarbeiterBearbeiten: {
ADMIN: true,
REDAKTEUR: true,
TECHNIKER: true,
LESER: false,
MITARBEITER: false,
},
vorlagenBearbeiten: { ADMIN: true, REDAKTEUR: true, TECHNIKER: false, LESER: false, MITARBEITER: false },
archivVerwalten: { ADMIN: true, REDAKTEUR: false, TECHNIKER: false, LESER: false, MITARBEITER: false },
verwaltung: { ADMIN: true, REDAKTEUR: false, TECHNIKER: false, LESER: false, MITARBEITER: false },
};
describe("Rechtematrix", () => {
@@ -26,10 +33,12 @@ describe("Rechtematrix", () => {
);
it("prueft jede Rolle gegen jedes Recht", () => {
// Haelt fest, dass die Tabelle unten wirklich vollstaendig ist: vier Rollen, sieben Rechte.
expect(ALLE_ROLLEN).toHaveLength(4);
expect(ALLE_RECHTE).toHaveLength(7);
expect(faelle).toHaveLength(28);
// Haelt fest, dass die Tabelle oben wirklich vollstaendig ist: fuenf Rollen,
// acht Rechte. Die Zahlen stehen hier ausgeschrieben, damit eine neue Rolle
// oder ein neues Recht **hier** auffaellt und nicht erst im Betrieb (#035).
expect(ALLE_ROLLEN).toHaveLength(5);
expect(ALLE_RECHTE).toHaveLength(8);
expect(faelle).toHaveLength(40);
});
it.each(faelle)("%s: %s -> %s", (recht, rolle, erwartet) => {
@@ -62,10 +71,27 @@ describe("Rollen im Einzelnen", () => {
expect(darf("REDAKTEUR", "archivVerwalten")).toBe(false);
});
it("gibt ADMIN alles", () => {
for (const recht of ALLE_RECHTE) {
it("gibt ADMIN alles – ausser der Selbstauskunft", () => {
for (const recht of ALLE_RECHTE.filter((r) => r !== "selbstauskunft")) {
expect(darf("ADMIN", recht), `ADMIN darf ${recht}`).toBe(true);
}
/*
* Die eine Ausnahme, und sie ist Absicht (#035): `selbstauskunft` ist kein
* zusaetzliches Recht, sondern ein **Ersatz** fuer `lesen`. Ein ADMIN sieht
* ueber `lesen` ohnehin alles; ihm die Selbstauskunft zu geben waere ein
* zweiter Weg zu denselben Daten — und damit eine zweite Stelle, an der
* jemand die Einschraenkung auf die eigene Person vergessen kann.
*/
expect(darf("ADMIN", "selbstauskunft")).toBe(false);
});
it("laesst MITARBEITER genau eines und sonst nichts", () => {
expect(darf("MITARBEITER", "selbstauskunft")).toBe(true);
for (const recht of ALLE_RECHTE.filter((r) => r !== "selbstauskunft")) {
expect(darf("MITARBEITER", recht), `MITARBEITER darf ${recht} nicht`).toBe(false);
}
// Vor allem nicht `lesen`: das waere die Akteneinsicht, die es nicht sein soll.
expect(darf("MITARBEITER", "lesen")).toBe(false);
});
it("behaelt das Zurueckholen aus dem Archiv beim ADMIN", () => {
@@ -103,14 +129,21 @@ describe("Aufbau der Tabelle", () => {
expect(Object.keys(ROLLEN_RANG).sort()).toStrictEqual([...ALLE_ROLLEN].sort());
});
it("gibt jeder Rolle einen eigenen Rang, ADMIN oben und LESER unten", () => {
it("gibt jeder Rolle einen eigenen Rang, ADMIN oben und MITARBEITER unten", () => {
const raenge = ALLE_ROLLEN.map((rolle) => ROLLEN_RANG[rolle]);
expect(new Set(raenge).size).toBe(ALLE_ROLLEN.length);
expect(Math.max(...raenge)).toBe(ROLLEN_RANG.ADMIN);
expect(Math.min(...raenge)).toBe(ROLLEN_RANG.LESER);
expect(Math.min(...raenge)).toBe(ROLLEN_RANG.MITARBEITER);
expect(ROLLEN_RANG.ADMIN).toBeGreaterThan(ROLLEN_RANG.REDAKTEUR);
expect(ROLLEN_RANG.REDAKTEUR).toBeGreaterThan(ROLLEN_RANG.TECHNIKER);
expect(ROLLEN_RANG.TECHNIKER).toBeGreaterThan(ROLLEN_RANG.LESER);
/*
* **Unter LESER.** Der Rang entscheidet in `staerksteRolle`, wer gewinnt,
* wenn jemand in mehreren abgebildeten Gruppen steht. Stuende MITARBEITER
* darueber, verdraengte eine Selbstauskunft-Gruppe die echte Rolle — und ein
* Techniker verlöre seine Rechte, weil er auch Mitarbeiter des Hauses ist.
*/
expect(ROLLEN_RANG.LESER).toBeGreaterThan(ROLLEN_RANG.MITARBEITER);
});
});
+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);
});
});
+21
View File
@@ -176,6 +176,27 @@ describe("Rechtepruefung an den Einstiegspunkten", () => {
expect(befunde).toEqual([]);
});
it("laesst keine Server Action auf dem Recht der Selbstauskunft stehen", () => {
/*
* Die Zusage „ein MITARBEITER kann nichts schreiben“ (#035) steht nicht in
* einer einzelnen Pruefung, sondern in der **Abwesenheit** einer Aktion mit
* diesem Recht. Das ist strukturell: `selbstauskunft` ist ein reines
* Leserecht, und sobald eine Server Action es verlangte, haette diese Rolle
* einen Schreibweg — ohne dass irgendein anderer Test rot wuerde.
*
* Deshalb hier und nicht in `tests/rollen.test.ts`: dort geht es um die
* Tabelle, hier um das, was mit ihr gebaut wird.
*/
const befunde: string[] = [];
for (const name of readdirSync(verzeichnis).filter((n) => n.endsWith(".ts"))) {
const quelle = readFileSync(join(verzeichnis, name), "utf8");
if (quelle.includes('rechtErforderlich("selbstauskunft")')) {
befunde.push(`${name} verlangt "selbstauskunft" – das ist ein reines Leserecht`);
}
}
expect(befunde).toEqual([]);
});
it("erkennt eine Aktion ohne jede Pruefung", () => {
// Der Test oben ist nachsichtiger geworden; dieser hier zeigt, dass er
// trotzdem noch anschlaegt. Geprueft wird die Regel, nicht die Datei.