Files
Laufzettel/tests/anmeldung.test.ts
florian.wildeandClaude Opus 5 40e95dbe5f
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
feat(auth): Anmeldung, Rollen und Benutzerverwaltung (#21)
OIDC mit Discovery, PKCE, state und nonce; lokaler Login mit argon2 als
Notfallzugang; Rechte in einer Tabelle, Prüfung ausschließlich serverseitig,
Rolle und Aktiv-Status pro Anfrage aus der Datenbank.

Ein unabhängiger Review fand eine offene Weiterleitung: die Präfix-Prüfung des
Rücksprungziels ließ /<TAB>/fremd.example durch, woraus der Browser
//fremd.example macht. Das Ziel wird jetzt aufgelöst und die Herkunft
verglichen. Dazu acht weitere Befunde, alle behoben.

274 Tests.

Closes #7

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-25 01:35:02 +00:00

223 lines
7.8 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { oeffentlicheAdresse } from "@/lib/adressen";
import { sicheresZiel } from "@/server/auth/anmeldung";
import { sitzungBewerten } from "@/server/auth/sitzungspflicht";
import type { Sitzung } from "@/lib/sitzung";
import {
kundenkuerzelAusGruppen,
oidcAktiv,
oidcName,
rolleAusGruppen,
rueckrufAdresse,
} from "@/server/auth/oidc";
/**
* Die Entscheidungen rund um die Anmeldung, die ohne Datenbank und ohne Next
* pruefbar sind (#007).
*/
const UMGEBUNG = { ...process.env };
afterEach(() => {
process.env = { ...UMGEBUNG };
});
describe("Ruecksprungziel", () => {
it("nimmt einen Pfad innerhalb der Anwendung an", () => {
expect(sicheresZiel("/t/LZ-7K3F9Q")).toBe("/t/LZ-7K3F9Q");
expect(sicheresZiel("/admin/benutzer?seite=2")).toBe("/admin/benutzer?seite=2");
});
it("weist eine Weiterleitung nach draussen ab", () => {
// Ohne diese Pruefung waere ein Anmeldelink verschickbar, der nach
// erfolgreicher Anmeldung auf einer fremden Seite landet.
for (const boesartig of [
"https://fremd.example/phishing",
"//fremd.example",
"/\\fremd.example",
"http://fremd.example",
"javascript:alert(1)",
]) {
expect(sicheresZiel(boesartig)).toBe("/");
}
});
it("faellt ohne Angabe auf die Startseite zurueck", () => {
expect(sicheresZiel(undefined)).toBe("/");
expect(sicheresZiel(null)).toBe("/");
expect(sicheresZiel("")).toBe("/");
});
});
describe("Rollen aus OIDC-Gruppen", () => {
beforeEach(() => {
process.env["OIDC_GROUP_ADMIN"] = "lz-admin";
process.env["OIDC_GROUP_EDITOR"] = "lz-redakteur";
process.env["OIDC_GROUP_TECHNICIAN"] = "lz-techniker";
process.env["OIDC_GROUP_VIEWER"] = "lz-leser";
});
it("bildet jede Gruppe auf ihre Rolle ab", () => {
expect(rolleAusGruppen(["lz-admin"])).toBe("ADMIN");
expect(rolleAusGruppen(["lz-redakteur"])).toBe("REDAKTEUR");
expect(rolleAusGruppen(["lz-techniker"])).toBe("TECHNIKER");
expect(rolleAusGruppen(["lz-leser"])).toBe("LESER");
});
it("gibt bei mehreren Gruppen die staerkste Rolle", () => {
expect(rolleAusGruppen(["lz-leser", "lz-admin", "lz-techniker"])).toBe("ADMIN");
expect(rolleAusGruppen(["lz-techniker", "lz-redakteur"])).toBe("REDAKTEUR");
});
it("gibt ohne Treffer LESER, nie ADMIN und nie einen Fehler", () => {
expect(rolleAusGruppen([])).toBe("LESER");
expect(rolleAusGruppen(["irgendwas", "ganz-anderes"])).toBe("LESER");
});
it("achtet auf Gross- und Kleinschreibung der Gruppennamen", () => {
// Verzeichnisse unterscheiden hier, also darf die Zuordnung es auch.
expect(rolleAusGruppen(["LZ-ADMIN"])).toBe("LESER");
});
it("ordnet nichts zu, wenn die Variablen fehlen", () => {
process.env = { ...UMGEBUNG };
delete process.env["OIDC_GROUP_ADMIN"];
expect(rolleAusGruppen(["lz-admin"])).toBe("LESER");
});
});
describe("Kundenbindung aus OIDC-Gruppen", () => {
it("liefert ohne Praefix nichts, damit keine Einschraenkung entsteht", () => {
delete process.env["OIDC_CUSTOMER_GROUP_PREFIX"];
expect(kundenkuerzelAusGruppen(["lz-kunde-MUS"])).toEqual([]);
});
it("liest die Kuerzel hinter dem Praefix", () => {
process.env["OIDC_CUSTOMER_GROUP_PREFIX"] = "lz-kunde-";
expect(kundenkuerzelAusGruppen(["lz-kunde-MUS", "lz-kunde-rei", "lz-admin"])).toEqual(["MUS", "REI"]);
});
it("verwirft Kuerzel, die nicht zur Form passen", () => {
process.env["OIDC_CUSTOMER_GROUP_PREFIX"] = "lz-kunde-";
// Dieselbe Form wie die Bedingung Kunde_kuerzel_form in der Datenbank.
expect(kundenkuerzelAusGruppen(["lz-kunde-X", "lz-kunde-ZULANG7", "lz-kunde-MU_S"])).toEqual([]);
});
});
describe("OIDC-Konfiguration", () => {
it("gilt erst als eingerichtet, wenn alle drei Angaben da sind", () => {
process.env["OIDC_ISSUER"] = "https://authentik.example/application/o/laufzettel/";
process.env["OIDC_CLIENT_ID"] = "kennung";
delete process.env["OIDC_CLIENT_SECRET"];
expect(oidcAktiv()).toBe(false);
process.env["OIDC_CLIENT_SECRET"] = "geheim";
expect(oidcAktiv()).toBe(true);
});
it("leitet die Rueckruf-Adresse aus APP_URL ab, nicht aus der Anfrage", () => {
// Hinter einem Reverse Proxy traegt die Anfrage den internen Host; die beim
// Anbieter hinterlegte Adresse ist aber die oeffentliche.
process.env["APP_URL"] = "https://laufzettel.example.de";
expect(rueckrufAdresse()).toBe("https://laufzettel.example.de/api/auth/oidc/rueckruf");
});
it("wirft mit klarer Meldung, wenn APP_URL fehlt", () => {
delete process.env["APP_URL"];
expect(() => rueckrufAdresse()).toThrow(/APP_URL/);
});
it("nennt den Anbieter beim Namen, sonst neutral", () => {
process.env["OIDC_NAME"] = "authentik";
expect(oidcName()).toBe("authentik");
delete process.env["OIDC_NAME"];
expect(oidcName()).toBe("Single Sign-on");
});
});
describe("Gueltigkeit einer Sitzung", () => {
const jetzt = Math.floor(Date.now() / 1000);
const sitzung: Sitzung = {
benutzerId: "benutzer-1",
email: "techniker@firma.example",
name: "Techniker",
herkunft: "passwort",
ausgestelltAm: jetzt,
};
it("traegt bei aktivem Benutzer", () => {
expect(sitzungBewerten(sitzung, { id: "benutzer-1", aktiv: true, sitzungenUngueltigAb: null })).toEqual({
gueltig: true,
});
});
it("faellt, wenn es den Benutzer nicht mehr gibt", () => {
expect(sitzungBewerten(sitzung, null)).toEqual({ gueltig: false, grund: "unbekannt" });
});
it("faellt, wenn das Cookie auf einen anderen Benutzer zeigt", () => {
expect(sitzungBewerten(sitzung, { id: "benutzer-2", aktiv: true, sitzungenUngueltigAb: null })).toEqual({
gueltig: false,
grund: "unbekannt",
});
});
it("faellt sofort bei einem deaktivierten Konto", () => {
// Genau dafuer wird pro Anfrage aus der Datenbank gelesen: sonst liefe das
// Cookie noch bis zu zwoelf Stunden weiter.
expect(sitzungBewerten(sitzung, { id: "benutzer-1", aktiv: false, sitzungenUngueltigAb: null })).toEqual({
gueltig: false,
grund: "deaktiviert",
});
});
it("faellt, wenn die Sitzung vor dem Widerruf ausgestellt wurde", () => {
expect(
sitzungBewerten(sitzung, {
id: "benutzer-1",
aktiv: true,
sitzungenUngueltigAb: new Date((jetzt + 60) * 1000),
}),
).toEqual({ gueltig: false, grund: "widerrufen" });
});
it("traegt, wenn die Sitzung nach dem Widerruf ausgestellt wurde", () => {
expect(
sitzungBewerten(sitzung, {
id: "benutzer-1",
aktiv: true,
sitzungenUngueltigAb: new Date((jetzt - 60) * 1000),
}),
).toEqual({ gueltig: true });
});
});
describe("Oeffentliche Adressen", () => {
it("baut Umleitungen aus APP_URL, nicht aus der Anfrage", () => {
// Hinter einem Reverse Proxy traegt die Anfrage den internen Host. Eine
// daraus gebaute Umleitung schickt den Benutzer an eine Adresse, die er gar
// nicht erreicht.
process.env["APP_URL"] = "https://laufzettel.example.de";
expect(oeffentlicheAdresse("/anmelden", "http://10.0.0.7:3000").toString()).toBe(
"https://laufzettel.example.de/anmelden",
);
});
it("behaelt Abfrageparameter", () => {
process.env["APP_URL"] = "https://laufzettel.example.de";
expect(oeffentlicheAdresse("/anmelden?fehler=oidc-aus", "http://intern").search).toBe("?fehler=oidc-aus");
});
it("faellt ohne APP_URL auf die Herkunft der Anfrage zurueck", () => {
delete process.env["APP_URL"];
expect(oeffentlicheAdresse("/anmelden", "http://127.0.0.1:3100").toString()).toBe(
"http://127.0.0.1:3100/anmelden",
);
});
it("faellt auch bei unbrauchbarer APP_URL zurueck, statt stehen zu bleiben", () => {
process.env["APP_URL"] = "keine-adresse";
expect(oeffentlicheAdresse("/anmelden", "http://127.0.0.1:3100").host).toBe("127.0.0.1:3100");
});
});