feat: Grundgerüst mit Anmeldung, Layout und Design-Tokens
Next.js 16, Prisma 7, Tailwind 4 und Vitest/Playwright, Konfiguration aus Laufzettel übernommen, weil dort derselbe Stack erprobt ist. Die Farben der Branding-Vorgabe stehen als Tokens in globals.css, das helle Theme setzt dieselben Namen neu. Amber und Rot sind dort abgedunkelt, sonst reicht der Kontrast nicht – ein Test prüft beide Themes gegen 4,5:1. Anmeldung mit lokalem Benutzer, Rolle je Anfrage aus der Datenbank, Audit-Log per Trigger nur anhängbar. Geprüft im gebauten Stand auf 1440 px und 390 px. Closes #001 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { POST as anmeldenPost } from "@/app/api/v1/auth/anmelden/route";
|
||||
import { GET as ichGet } from "@/app/api/v1/ich/route";
|
||||
import { passwortHashen } from "@/lib/passwort";
|
||||
import { ersterAdminFallsLeer, sperrenZuruecksetzen } from "@/server/services/anmeldung";
|
||||
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
|
||||
|
||||
function anmeldung(benutzername: string, passwort: string): Request {
|
||||
return new Request("http://localhost/api/v1/auth/anmelden", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ benutzername, passwort }),
|
||||
});
|
||||
}
|
||||
|
||||
describe.runIf(datenbankVerfuegbar)("Anmeldung", () => {
|
||||
beforeEach(async () => {
|
||||
await datenbankLeeren();
|
||||
sperrenZuruecksetzen();
|
||||
await testPrisma.benutzer.create({
|
||||
data: {
|
||||
benutzername: "florian",
|
||||
anzeigename: "Florian",
|
||||
rolle: "OPERATOR",
|
||||
passwortHash: await passwortHashen("richtiges-passwort"),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("setzt bei richtigem Passwort ein HttpOnly-Cookie und /ich kennt den Benutzer", async () => {
|
||||
const antwort = await anmeldenPost(anmeldung("Florian", "richtiges-passwort"));
|
||||
expect(antwort.status).toBe(200);
|
||||
const cookie = antwort.headers.get("set-cookie") ?? "";
|
||||
expect(cookie).toMatch(/pd_sitzung=/);
|
||||
expect(cookie).toMatch(/HttpOnly/i);
|
||||
// Über http (ohne Proxy) darf das Cookie nicht Secure sein, sonst schickt der Browser es nie zurück.
|
||||
expect(cookie).not.toMatch(/Secure/i);
|
||||
|
||||
const ich = await ichGet(
|
||||
new Request("http://localhost/api/v1/ich", { headers: { cookie: cookie.split(";")[0]! } }),
|
||||
);
|
||||
expect(ich.status).toBe(200);
|
||||
expect(await ich.json()).toMatchObject({ benutzername: "florian", rolle: "OPERATOR" });
|
||||
});
|
||||
|
||||
it("meldet falsches Passwort und unbekannten Benutzer gleich", async () => {
|
||||
const falsch = await anmeldenPost(anmeldung("florian", "falsch"));
|
||||
const unbekannt = await anmeldenPost(anmeldung("niemand", "falsch"));
|
||||
expect(falsch.status).toBe(401);
|
||||
expect(await falsch.json()).toEqual(await unbekannt.json());
|
||||
});
|
||||
|
||||
it("weist deaktivierte Benutzer ab", async () => {
|
||||
await testPrisma.benutzer.update({ where: { benutzername: "florian" }, data: { aktiv: false } });
|
||||
expect((await anmeldenPost(anmeldung("florian", "richtiges-passwort"))).status).toBe(401);
|
||||
});
|
||||
|
||||
it("sperrt nach zehn Fehlversuchen, auch für das richtige Passwort", async () => {
|
||||
for (let i = 0; i < 10; i += 1) await anmeldenPost(anmeldung("florian", "falsch"));
|
||||
const antwort = await anmeldenPost(anmeldung("florian", "richtiges-passwort"));
|
||||
expect(antwort.status).toBe(429);
|
||||
expect((await antwort.json()).fehler.hinweis).toMatch(/15 Minuten/);
|
||||
});
|
||||
|
||||
it("protokolliert Erfolg und Fehlschlag", async () => {
|
||||
await anmeldenPost(anmeldung("florian", "falsch"));
|
||||
await anmeldenPost(anmeldung("florian", "richtiges-passwort"));
|
||||
const aktionen = (await testPrisma.protokoll.findMany({ orderBy: { id: "asc" } })).map((e) => e.aktion);
|
||||
expect(aktionen).toEqual(["Anmeldung fehlgeschlagen", "Angemeldet"]);
|
||||
});
|
||||
|
||||
it("antwortet auf kaputtes JSON mit 400 statt 500", async () => {
|
||||
const antwort = await anmeldenPost(
|
||||
new Request("http://localhost/api/v1/auth/anmelden", { method: "POST", body: "{kaputt" }),
|
||||
);
|
||||
expect(antwort.status).toBe(400);
|
||||
});
|
||||
|
||||
it("/ich ohne Sitzung ist 401", async () => {
|
||||
expect((await ichGet(new Request("http://localhost/api/v1/ich"))).status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe.runIf(datenbankVerfuegbar)("Erster Administrator", () => {
|
||||
beforeEach(datenbankLeeren);
|
||||
|
||||
it("wird nur angelegt, solange es keinen Benutzer gibt", async () => {
|
||||
process.env["ADMIN_BENUTZER"] = "Admin";
|
||||
process.env["ADMIN_PASSWORT"] = "ein-langes-startpasswort";
|
||||
await ersterAdminFallsLeer();
|
||||
await ersterAdminFallsLeer();
|
||||
const alle = await testPrisma.benutzer.findMany();
|
||||
expect(alle).toHaveLength(1);
|
||||
expect(alle[0]).toMatchObject({ benutzername: "admin", rolle: "ADMIN" });
|
||||
});
|
||||
|
||||
it("legt bei zu kurzem Passwort niemanden an", async () => {
|
||||
process.env["ADMIN_PASSWORT"] = "kurz";
|
||||
await ersterAdminFallsLeer();
|
||||
expect(await testPrisma.benutzer.count()).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
|
||||
|
||||
/**
|
||||
* Regeln, die in der Datenbank selbst stehen – sie gelten auch, wenn jemand an der
|
||||
* Service-Schicht vorbei schreibt. Jede ist hier namentlich aufgeführt.
|
||||
*/
|
||||
describe.runIf(datenbankVerfuegbar)("Datenbankregeln", () => {
|
||||
beforeEach(datenbankLeeren);
|
||||
|
||||
it("protokoll_nur_anhaengen: Einträge lassen sich weder ändern noch löschen", async () => {
|
||||
const eintrag = await testPrisma.protokoll.create({ data: { quelle: "SYSTEM", aktion: "Test" } });
|
||||
await expect(
|
||||
testPrisma.protokoll.update({ where: { id: eintrag.id }, data: { aktion: "Gefälscht" } }),
|
||||
).rejects.toThrow(/nur anhängbar/);
|
||||
await expect(testPrisma.protokoll.delete({ where: { id: eintrag.id } })).rejects.toThrow(/nur anhängbar/);
|
||||
});
|
||||
|
||||
it("protokoll_nur_anhaengen: das Löschen eines Benutzers lässt seine Einträge stehen", async () => {
|
||||
const benutzer = await testPrisma.benutzer.create({ data: { benutzername: "weg", anzeigename: "Weg" } });
|
||||
await testPrisma.protokoll.create({
|
||||
data: { quelle: "WEB", aktion: "Angemeldet", benutzerId: benutzer.id },
|
||||
});
|
||||
await testPrisma.benutzer.delete({ where: { id: benutzer.id } });
|
||||
const eintraege = await testPrisma.protokoll.findMany();
|
||||
expect(eintraege).toHaveLength(1);
|
||||
expect(eintraege[0]?.benutzerId).toBeNull();
|
||||
});
|
||||
|
||||
it("benutzer_benutzername_format: nur Kleinbuchstaben, Ziffern, . _ -", async () => {
|
||||
await expect(
|
||||
testPrisma.benutzer.create({ data: { benutzername: "Florian Wilde", anzeigename: "x" } }),
|
||||
).rejects.toThrow(/benutzer_benutzername_format/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
const BENUTZER = process.env["E2E_BENUTZER"] ?? "admin";
|
||||
const PASSWORT = process.env["E2E_PASSWORT"] ?? "proxdeploy-dev-admin";
|
||||
|
||||
async function anmelden(page: Page) {
|
||||
await page.goto("/");
|
||||
await expect(page).toHaveURL(/\/anmelden/);
|
||||
await page.getByLabel("Benutzername").fill(BENUTZER);
|
||||
await page.getByLabel("Passwort").fill(PASSWORT);
|
||||
await page.getByRole("button", { name: "Anmelden" }).click();
|
||||
await expect(page.getByRole("heading", { name: "Übersicht" })).toBeVisible();
|
||||
}
|
||||
|
||||
async function keinQuerscrollen(page: Page) {
|
||||
const breiten = await page.evaluate(() => ({
|
||||
inhalt: document.documentElement.scrollWidth,
|
||||
fenster: window.innerWidth,
|
||||
}));
|
||||
expect(breiten.inhalt).toBeLessThanOrEqual(breiten.fenster);
|
||||
}
|
||||
|
||||
test("falsches Passwort zeigt eine Meldung mit Symbol und Text", async ({ page }) => {
|
||||
await page.goto("/anmelden");
|
||||
// Eigener Name: Fehlversuche zählen je Benutzer, der echte Admin soll nie gesperrt werden.
|
||||
await page.getByLabel("Benutzername").fill("e2e-unbekannt");
|
||||
await page.getByLabel("Passwort").fill("falsch-falsch");
|
||||
await page.getByRole("button", { name: "Anmelden" }).click();
|
||||
// Wenn die Seite nicht hydriert, passiert hier nichts – das ist die eigentliche Prüfung.
|
||||
await expect(page.getByText("Benutzername oder Passwort ist falsch.")).toBeVisible();
|
||||
await expect(page.getByText("✗")).toBeVisible();
|
||||
});
|
||||
|
||||
test("Anmelden, Übersicht, Abmelden", async ({ page }) => {
|
||||
await anmelden(page);
|
||||
await keinQuerscrollen(page);
|
||||
await page.screenshot({ path: `test-results/uebersicht-${test.info().project.name}.png`, fullPage: true });
|
||||
|
||||
if (test.info().project.name === "handy") {
|
||||
await page.getByRole("button", { name: "Navigation öffnen" }).click();
|
||||
}
|
||||
await page.getByRole("button", { name: "Abmelden" }).click();
|
||||
await expect(page).toHaveURL(/\/anmelden/);
|
||||
});
|
||||
|
||||
test("Theme-Wechsel bleibt nach dem Neuladen erhalten", async ({ page }) => {
|
||||
await anmelden(page);
|
||||
await expect(page.locator("html")).toHaveAttribute("data-theme", "dunkel");
|
||||
if (test.info().project.name === "handy") {
|
||||
await page.getByRole("button", { name: "Navigation öffnen" }).click();
|
||||
}
|
||||
await page.getByRole("button", { name: "Helles Theme einschalten" }).click();
|
||||
await expect(page.locator("html")).toHaveAttribute("data-theme", "hell");
|
||||
await page.reload();
|
||||
await expect(page.locator("html")).toHaveAttribute("data-theme", "hell");
|
||||
await page.screenshot({
|
||||
path: `test-results/uebersicht-hell-${test.info().project.name}.png`,
|
||||
fullPage: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("Anmeldeseite ohne Querscrollen", async ({ page }) => {
|
||||
await page.goto("/anmelden");
|
||||
await keinQuerscrollen(page);
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { entschluesseln, GeheimnisFehler, verschluesseln } from "@/lib/geheimnisse";
|
||||
|
||||
const ursprung = process.env["PROXDEPLOY_SCHLUESSEL"];
|
||||
afterEach(() => {
|
||||
process.env["PROXDEPLOY_SCHLUESSEL"] = ursprung;
|
||||
});
|
||||
|
||||
describe("Geheimnisse (AES-256-GCM)", () => {
|
||||
it("verschlüsselt und entschlüsselt verlustfrei, auch Umlaute und Zeilenumbrüche", () => {
|
||||
const klar = "-----BEGIN OPENSSH PRIVATE KEY-----\nÄÖÜ ß\n-----END-----\n";
|
||||
expect(entschluesseln(verschluesseln(klar))).toBe(klar);
|
||||
});
|
||||
|
||||
it("erzeugt bei gleichem Klartext jedes Mal einen anderen Geheimtext (neuer IV)", () => {
|
||||
expect(verschluesseln("geheim")).not.toBe(verschluesseln("geheim"));
|
||||
});
|
||||
|
||||
it("trägt das Versionspräfix v1", () => {
|
||||
expect(verschluesseln("x").startsWith("v1.")).toBe(true);
|
||||
});
|
||||
|
||||
it("wirft bei verändertem Geheimtext", () => {
|
||||
const teile = verschluesseln("geheim").split(".");
|
||||
const daten = Buffer.from(teile[3]!, "base64");
|
||||
daten[0] = daten[0]! ^ 0xff;
|
||||
teile[3] = daten.toString("base64");
|
||||
expect(() => entschluesseln(teile.join("."))).toThrow(GeheimnisFehler);
|
||||
});
|
||||
|
||||
it("wirft mit anderem Schlüssel", () => {
|
||||
const geheim = verschluesseln("geheim");
|
||||
process.env["PROXDEPLOY_SCHLUESSEL"] = Buffer.alloc(32, 9).toString("base64");
|
||||
expect(() => entschluesseln(geheim)).toThrow(/nicht mehr derselbe/);
|
||||
});
|
||||
|
||||
it("verlangt genau 32 Byte Schlüssel und nennt den Befehl zum Erzeugen", () => {
|
||||
process.env["PROXDEPLOY_SCHLUESSEL"] = Buffer.alloc(16).toString("base64");
|
||||
expect(() => verschluesseln("x")).toThrow(/openssl rand -base64 32/);
|
||||
});
|
||||
|
||||
it("lehnt unbekannte Formate und Versionen ab", () => {
|
||||
expect(() => entschluesseln("klartext")).toThrow(/unbekanntes Format/);
|
||||
expect(() => entschluesseln("v9.a.b.c")).toThrow(/unbekannte Version/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import "dotenv/config";
|
||||
|
||||
/**
|
||||
* Spielt die Migrationen in die Testdatenbank ein. Ohne TEST_DATABASE_URL überspringen sich die
|
||||
* Datenbanktests – und sagen das laut, damit ein grüner Lauf nicht mehr verspricht, als er prüft.
|
||||
*/
|
||||
export default async function aufbau(): Promise<void> {
|
||||
const url = process.env["TEST_DATABASE_URL"];
|
||||
if (!url) {
|
||||
console.warn("\n TEST_DATABASE_URL fehlt – Datenbanktests werden ÜBERSPRUNGEN. Start: pnpm db:up\n");
|
||||
return;
|
||||
}
|
||||
const { execFileSync } = await import("node:child_process");
|
||||
execFileSync("pnpm", ["exec", "prisma", "migrate", "deploy"], {
|
||||
stdio: "inherit",
|
||||
env: { ...process.env, DATABASE_URL: url },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { PrismaPg } from "@prisma/adapter-pg";
|
||||
import { PrismaClient } from "@/generated/prisma/client";
|
||||
|
||||
const url = process.env["TEST_DATABASE_URL"];
|
||||
|
||||
// In CI darf nichts still übersprungen werden – dort lieber hart abbrechen.
|
||||
if (!url && process.env["CI"]) {
|
||||
throw new Error("TEST_DATABASE_URL fehlt. In CI dürfen die Datenbanktests nicht übersprungen werden.");
|
||||
}
|
||||
|
||||
export const datenbankVerfuegbar = Boolean(url);
|
||||
|
||||
export const testPrisma = new PrismaClient({
|
||||
adapter: new PrismaPg({ connectionString: url ?? "postgresql://localhost:5437/proxdeploy_test" }),
|
||||
});
|
||||
|
||||
/**
|
||||
* Leert alle Tabellen. Das Protokoll ist per Trigger nur anhängbar – TRUNCATE löst
|
||||
* Zeilen-Trigger nicht aus und ist deshalb der einzige Weg, es zu leeren.
|
||||
*/
|
||||
export async function datenbankLeeren(): Promise<void> {
|
||||
const tabellen = await testPrisma.$queryRaw<{ tablename: string }[]>`
|
||||
SELECT tablename FROM pg_tables WHERE schemaname = 'public' AND tablename NOT LIKE '_prisma%'
|
||||
`;
|
||||
if (tabellen.length === 0) return;
|
||||
const liste = tabellen.map((t) => `"public"."${t.tablename}"`).join(", ");
|
||||
await testPrisma.$executeRawUnsafe(`TRUNCATE ${liste} RESTART IDENTITY CASCADE`);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
/**
|
||||
* Kontrast mindestens 4,5:1 (WCAG AA) für Text auf Grund und Panel – in beiden Themes.
|
||||
* Liest die Tokens direkt aus globals.css, damit eine Farbänderung hier auffällt.
|
||||
*/
|
||||
const css = readFileSync("src/app/globals.css", "utf8");
|
||||
|
||||
function block(selektor: string): Record<string, string> {
|
||||
const anfang = css.indexOf(selektor);
|
||||
const inhalt = css.slice(css.indexOf("{", anfang) + 1, css.indexOf("}", anfang));
|
||||
return Object.fromEntries(
|
||||
[...inhalt.matchAll(/--farbe-([a-z-]+):\s*(#[0-9a-f]{6})/gi)].map((t) => [t[1]!, t[2]!]),
|
||||
);
|
||||
}
|
||||
|
||||
function leuchtdichte(hex: string): number {
|
||||
const kanal = (i: number) => {
|
||||
const c = parseInt(hex.slice(i, i + 2), 16) / 255;
|
||||
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
||||
};
|
||||
return 0.2126 * kanal(1) + 0.7152 * kanal(3) + 0.0722 * kanal(5);
|
||||
}
|
||||
|
||||
export function kontrast(a: string, b: string): number {
|
||||
const [hell, dunkel] = [leuchtdichte(a), leuchtdichte(b)].sort((x, y) => y - x) as [number, number];
|
||||
return (hell + 0.05) / (dunkel + 0.05);
|
||||
}
|
||||
|
||||
const THEMES = { dunkel: block('[data-theme="dunkel"]'), hell: block('[data-theme="hell"]') };
|
||||
const TEXTFARBEN = ["text", "muted", "akzent", "laeuft", "fehler"];
|
||||
|
||||
describe("Kontrast der Theme-Farben", () => {
|
||||
for (const [name, farben] of Object.entries(THEMES)) {
|
||||
for (const flaeche of ["grund", "panel", "panel-hoch"]) {
|
||||
it.each(TEXTFARBEN)(`${name}: %s auf ${flaeche} ≥ 4,5:1`, (textfarbe) => {
|
||||
expect(kontrast(farben[textfarbe]!, farben[flaeche]!)).toBeGreaterThanOrEqual(4.5);
|
||||
});
|
||||
}
|
||||
it(`${name}: Text auf Akzentfläche ≥ 4,5:1`, () => {
|
||||
expect(kontrast(farben["akzent-text"]!, farben["akzent"]!)).toBeGreaterThanOrEqual(4.5);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import "dotenv/config";
|
||||
|
||||
// Tests laufen immer gegen die Testdatenbank, nie gegen die Entwicklungsdatenbank.
|
||||
if (process.env["TEST_DATABASE_URL"]) {
|
||||
process.env["DATABASE_URL"] = process.env["TEST_DATABASE_URL"];
|
||||
}
|
||||
|
||||
// Feste Testgeheimnisse, damit kein Test eine .env verlangt.
|
||||
process.env["PROXDEPLOY_SCHLUESSEL"] = Buffer.alloc(32, 7).toString("base64");
|
||||
process.env["SITZUNG_GEHEIMNIS"] = "test-sitzungsgeheimnis-mit-mindestens-32-zeichen";
|
||||
process.env["LOG_LEVEL"] = "silent";
|
||||
process.env["TRUST_PROXY"] = "false";
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { sicherheitsrichtlinie } from "@/proxy";
|
||||
|
||||
describe("Content-Security-Policy", () => {
|
||||
it("ist im gebauten Stand streng", () => {
|
||||
const csp = sicherheitsrichtlinie("abc", false);
|
||||
expect(csp).toContain("script-src 'self' 'nonce-abc' 'strict-dynamic'");
|
||||
expect(csp).not.toContain("unsafe-eval");
|
||||
expect(csp).not.toContain("ws:");
|
||||
expect(csp).toContain("frame-ancestors 'none'");
|
||||
});
|
||||
|
||||
it("lockert nur im Entwicklungsmodus", () => {
|
||||
expect(sicherheitsrichtlinie("abc", true)).toContain("'unsafe-eval'");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { SignJWT } from "jose";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { sitzungErzeugen, sitzungLesen } from "@/lib/sitzung";
|
||||
|
||||
describe("Sitzungscookie", () => {
|
||||
it("liest ein selbst ausgestelltes Token zurück", async () => {
|
||||
const jwt = await sitzungErzeugen({ benutzerId: "b1", herkunft: "passwort" });
|
||||
expect(await sitzungLesen(jwt)).toMatchObject({ benutzerId: "b1", herkunft: "passwort" });
|
||||
});
|
||||
|
||||
it("verwirft fehlende, verfälschte und fremd signierte Tokens", async () => {
|
||||
expect(await sitzungLesen(undefined)).toBeUndefined();
|
||||
const jwt = await sitzungErzeugen({ benutzerId: "b1", herkunft: "passwort" });
|
||||
expect(await sitzungLesen(jwt.slice(0, -2) + "xx")).toBeUndefined();
|
||||
const fremd = await new SignJWT({ herkunft: "passwort" })
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
.setSubject("b1")
|
||||
.setIssuer("proxdeploy")
|
||||
.setAudience("proxdeploy-web")
|
||||
.setIssuedAt()
|
||||
.setExpirationTime("1h")
|
||||
.sign(new TextEncoder().encode("ein-ganz-anderes-geheimnis-mit-genug-zeichen"));
|
||||
expect(await sitzungLesen(fremd)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("enthält keine Rolle – die kommt je Anfrage aus der Datenbank", async () => {
|
||||
const jwt = await sitzungErzeugen({ benutzerId: "b1", herkunft: "oidc" });
|
||||
const inhalt = JSON.parse(Buffer.from(jwt.split(".")[1]!, "base64url").toString());
|
||||
expect(inhalt).not.toHaveProperty("rolle");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join, relative } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { istOeffentlich, OEFFENTLICH } from "@/proxy";
|
||||
|
||||
/**
|
||||
* Strukturelle Prüfung: jede Seite und jeder Route Handler prüft sein Recht selbst.
|
||||
* Ausnahmen stehen hier namentlich mit Begründung – ein neuer Handler ohne Prüfung soll
|
||||
* jemanden zwingen, sie aufzuschreiben.
|
||||
*/
|
||||
const AUSNAHMEN: Record<string, string> = {
|
||||
"src/app/api/v1/auth/anmelden/route.ts": "Anmeldung selbst",
|
||||
"src/app/api/v1/auth/abmelden/route.ts": "Abmelden muss ohne gültige Sitzung gehen",
|
||||
"src/app/api/v1/gesundheit/route.ts": "Healthcheck, verrät nur die Version",
|
||||
"src/app/anmelden/page.tsx": "Anmeldeseite",
|
||||
"src/app/kein-recht/page.tsx": "zeigt nur einen Hinweis",
|
||||
};
|
||||
|
||||
function dateien(verzeichnis: string): string[] {
|
||||
return readdirSync(verzeichnis).flatMap((name) => {
|
||||
const pfad = join(verzeichnis, name);
|
||||
return statSync(pfad).isDirectory() ? dateien(pfad) : [pfad];
|
||||
});
|
||||
}
|
||||
|
||||
const wurzel = process.cwd();
|
||||
const kandidaten = dateien(join(wurzel, "src/app"))
|
||||
.map((pfad) => relative(wurzel, pfad))
|
||||
.filter((pfad) => pfad.endsWith("/page.tsx") || pfad.endsWith("/route.ts"));
|
||||
|
||||
describe("Zugangsprüfung", () => {
|
||||
it.each(kandidaten.filter((pfad) => !(pfad in AUSNAHMEN)))("%s prüft sein Recht", (pfad) => {
|
||||
const quelltext = readFileSync(join(wurzel, pfad), "utf8");
|
||||
expect(quelltext).toMatch(/seiteSchuetzen\(|rechtErforderlich\(/);
|
||||
});
|
||||
|
||||
it("jede Ausnahme gibt es noch", () => {
|
||||
for (const pfad of Object.keys(AUSNAHMEN)) expect(kandidaten).toContain(pfad);
|
||||
});
|
||||
|
||||
it("öffentliche Pfade sind genau die bekannten", () => {
|
||||
expect(OEFFENTLICH).toEqual([
|
||||
"/anmelden",
|
||||
"/api/v1/auth",
|
||||
"/api/v1/gesundheit",
|
||||
"/brand",
|
||||
"/manifest.webmanifest",
|
||||
]);
|
||||
expect(istOeffentlich("/api/v1/auth/anmelden")).toBe(true);
|
||||
expect(istOeffentlich("/api/v1/authentifizieren")).toBe(false);
|
||||
expect(istOeffentlich("/server")).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user