import { NextRequest } from "next/server"; import { beforeAll, describe, expect, it, vi } from "vitest"; // Geprüft wird die Wegeführung im Proxy, nicht Auth.js. // // Der Wrapper `auth(handler)` gehört Auth.js und hängt die entschlüsselte // Sitzung als `request.auth` an; das ist fremder Code und hier durch eine // Attrappe ersetzt. Was hier zur Debatte steht, ist die Entscheidung, die // proxy.ts daraus ableitet — und die hat eine Schleife als Fehlerfall, die // sich im Betrieb als „die Anmeldeseite lädt endlos neu" zeigt und beim // Lesen des Codes leicht übersehen wird. // // Die Attrappe gibt den umschlossenen Handler bewusst als **Zusage** zurück, // weil Auth.js das in der Funktionsform ebenfalls tut. Eine Attrappe, die // ihn direkt liefert, wäre freundlicher als die Wirklichkeit und liesse ein // fehlendes `await` in proxy.ts durchgehen — genau der Fehler, der beim // ersten Ausprobieren jede Anfrage in einen 404 laufen liess. vi.mock("next-auth", () => ({ default: () => ({ auth: async (handler: (req: NextRequest) => unknown) => (req: NextRequest) => handler(req), }), })); type Session = { user: { id: string } } | null; // Bewusst locker getippt: Auth.js gibt dem umschlossenen Handler die // Signatur eines Route Handlers (Anfrage plus Kontext), und der zweite // Parameter spielt hier keine Rolle. let proxy: (req: NextRequest) => unknown; let matcher: string; beforeAll(async () => { const mod = await import("@/proxy"); proxy = mod.proxy as unknown as typeof proxy; matcher = (mod.config as { matcher: string[] }).matcher[0]; }); async function visit(path: string, session: Session): Promise { const request = new NextRequest(`http://localhost${path}`); Object.assign(request, { auth: session }); return (await proxy(request)) as Response; } const SIGNED_IN: Session = { user: { id: "11111111-1111-1111-1111-111111111111" } }; /** NextResponse.next() setzt diesen Header; eine Weiterleitung tut es nicht. */ function passedThrough(res: Response): boolean { return res.headers.get("x-middleware-next") === "1"; } describe("proxy", () => { it("schickt eine Anfrage ohne Sitzung auf die Anmeldeseite", async () => { const res = await visit("/employees", null); expect(res.status).toBe(307); expect(new URL(res.headers.get("location")!).pathname).toBe("/login"); }); it("lässt die Anmeldeseite ohne Sitzung durch", async () => { expect(passedThrough(await visit("/login", null))).toBe(true); }); it("lässt Auth.js' eigene Endpunkte ohne Sitzung durch", async () => { // Ohne diese Ausnahme landet der Rückweg von Entra auf /login und die // Anmeldung kommt nie zustande — der Code steht in der URL, aber es // entsteht nie ein Sitzungscookie. expect(passedThrough(await visit("/api/auth/callback/microsoft-entra-id", null))).toBe(true); expect(passedThrough(await visit("/api/auth/signin", null))).toBe(true); }); it("lässt eine angemeldete Anfrage durch", async () => { expect(passedThrough(await visit("/employees", SIGNED_IN))).toBe(true); }); it("holt eine angemeldete Person von der Anmeldeseite zurück in die Anwendung", async () => { const res = await visit("/login", SIGNED_IN); expect(res.status).toBe(307); expect(new URL(res.headers.get("location")!).pathname).toBe("/"); }); it("belässt eine angemeldete Person auf /login, solange eine Meldung ansteht", async () => { // Der Fall: angemeldet, aber ohne HR-Freischaltung. Das Layout leitet // nach /login?error=no_hr_access. Würde der Proxy auch das nach / // zurückschicken, liefen beide gegeneinander und der Grund wäre nie zu // lesen. expect(passedThrough(await visit("/login?error=no_hr_access", SIGNED_IN))).toBe(true); }); it("nimmt eine Sitzung ohne Kennung nicht als angemeldet", async () => { // Kommt vor, wenn das Token aus einer älteren Fassung stammt: gültig // signiert, aber ohne `uid`. Ohne diese Prüfung käme die Person durch // und liefe in Abfragen mit userId = null — die geben nichts zurück, und // die Anwendung sähe aus, als wären alle Daten weg. const res = await visit("/employees", { user: {} } as unknown as Session); expect(res.status).toBe(307); expect(new URL(res.headers.get("location")!).pathname).toBe("/login"); }); }); // ═══ Der Abgleich selbst ═══ // // Die Tests darueber rufen proxy() unmittelbar auf und gehen damit am // `matcher` vorbei — der entscheidet aber, ob der Gate ueberhaupt laeuft. Ein // Fehler dort ist deshalb von den Tests oben nicht zu sehen, und genau einer // ist passiert: die Dateien aus public/ standen nicht in der Ausnahmeliste. // Eine Anfrage nach /brand/manner-logo.png lief in den Gate, fand keine // Sitzung und wurde nach /login umgeleitet; auf der Anmeldeseite — der // einzigen Seite ohne Sitzung — blieb das Logo deshalb leer. describe("config.matcher", () => { // Erst im Test gebaut, nicht beim Einsammeln: `matcher` steht erst nach dem // beforeAll oben zur Verfuegung. const laeuftDurchDenGate = (pfad: string) => new RegExp(`^${matcher}$`).test(pfad); it("laesst die statischen Dateien aus public/ am Gate vorbei", () => { // Ohne diese Ausnahme ist das Logo auf der Anmeldeseite eine Umleitung. expect(laeuftDurchDenGate("/brand/manner-logo.png")).toBe(false); expect(laeuftDurchDenGate("/brand/icon-512.png")).toBe(false); }); it("laesst die Symbole vorbei, die der Browser von sich aus holt", () => { for (const pfad of ["/favicon.ico", "/icon.svg", "/apple-icon.png", "/manifest.webmanifest"]) { expect(laeuftDurchDenGate(pfad), pfad).toBe(false); } }); it("laesst die Ausgabe des Bauwerkzeugs vorbei", () => { expect(laeuftDurchDenGate("/_next/static/chunks/main.js")).toBe(false); expect(laeuftDurchDenGate("/_next/image?url=x")).toBe(false); }); // Die andere Haelfte, und die wichtigere: die Ausnahmen duerfen nichts // aufmachen, was Daten fuehrt. it("schickt alles Uebrige weiterhin durch den Gate", () => { for (const pfad of ["/", "/employees", "/employees/123", "/audit", "/reports", "/import", "/api/export/employees", "/api/import", "/passwort-aendern", "/login"]) { expect(laeuftDurchDenGate(pfad), pfad).toBe(true); } }); it("laesst sich nicht mit einem aehnlich aussehenden Pfad umgehen", () => { // Kein Prefix-Treffer auf halbem Weg: /brandneu ist keine Datei aus // public/brand, und /icon.svg.php ist kein Symbol. expect(laeuftDurchDenGate("/brandneu")).toBe(true); expect(laeuftDurchDenGate("/employees/brand/1")).toBe(true); }); });