Files
alpenwerk-hr/tests/unit/proxy.test.ts
Andrei Laas a0811d75f4
Some checks failed
CI / Migrationen auf leerer Datenbank (push) Has been cancelled
CI / Lint, Typen, Tests, Build (push) Has been cancelled
Das Logo war nie kaputt — der Proxy hat es umgeleitet
`curl -sI` auf /brand/manner-logo.png antwortete mit **307**. Die Datei lag
die ganze Zeit da, ausgeliefert wurde sie nie: der Abgleich in proxy.ts
nimmt alles ausser _next/static, _next/image und favicon.ico, und public/
stand nicht darin. Eine Anfrage nach dem Logo lief also in den Gate, fand
keine Sitzung und bekam eine Umleitung nach /login. Auf ein <img> antwortet
der Server damit mit HTML, und der Browser zeigt den Ersatztext.

Das erklaert auch, warum es so sprunghaft aussah. Auffallen konnte es nur an
einer einzigen Stelle: die Anmeldeseite ist die einzige Seite, die jemand
ohne Sitzung zu sehen bekommt — und ausgerechnet dort steht das Logo.
Ueberall sonst ist man angemeldet, der Gate laesst das Bild durch. Einmal so
geladen liegt es im Zwischenspeicher und erscheint auf /login weiter, bis
jemand hart neu laedt.

Und es erklaert, warum keine der Aenderungen am Bild geholfen hat: weder das
Ausmisten des SVG noch der Wechsel auf ein PNG konnte etwas ausrichten, weil
die Datei den Browser gar nicht erreichte. Dass es „seit 8016d31" auftrat,
war eine Verwechslung von Ursache und Gelegenheit — dort bekam die
Anmeldeseite ihre rosa Flaeche und damit ueberhaupt erst ein sichtbares Logo
an einer Stelle ohne Sitzung.

Aufgenommen wird jetzt, was eine nicht angemeldete Person auf der
Anmeldeseite braucht: brand/ sowie icon.svg, apple-icon.png und
manifest.webmanifest, die der Browser von sich aus holt. Einzeln aufgezaehlt
und nicht als Regel ueber Dateiendungen — „alles mit einem Punkt darin"
haette auch Routen durchgelassen, die keine Datei sind. In public/ liegt
nichts Personenbezogenes und darf auch nie etwas liegen; alles, was Daten
fuehrt, geht durch withUser() und die Policies.

Fuenf Tests auf den Abgleich selbst. Die vorhandenen rufen proxy()
unmittelbar auf und gehen damit am matcher vorbei — ein Fehler dort war von
ihnen nicht zu sehen. Geprueft wird beides: dass die Dateien vorbeikommen,
und dass sonst nichts aufgeht, auch nicht ueber einen aehnlich aussehenden
Pfad wie /brandneu.

Lint, Typen, Schemaabgleich, 567 Tests und der Build sind sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 21:39:58 +02:00

146 lines
6.5 KiB
TypeScript

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<Response> {
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);
});
});