Auth.js replaces GoTrue. The sign-in still goes to the same Entra tenant,
but nothing sits between the app and the identity provider any more — the
code exchange, state, nonce and the session cookie are ours.
lib/auth/session.ts stays the only place that knows where a user id comes
from, which is why this was one file and not fifty. What it returns is now
app_users.id. app_upsert_user() maps the Entra `oid` onto it, and for an
address that already has a profiles row it adopts that id instead of
minting a new one — otherwise everyone would have been signed in and cut
off from their own notes, drafts and audit trail at the same time.
That upsert is the one write that cannot have a session context yet: the
id is what it produces. It runs as a SECURITY DEFINER function that may
touch app_users and nothing else, which is a far smaller lever than the
service key that used to answer this class of problem.
The proxy no longer checks HR rights. It has no database connection, and
putting role/is_active in the token would have frozen the claim until the
next sign-in. The check moved to where it can read the current truth: the
app layout on every render, requireHrUser() for the export routes, and
underneath both, RLS.
Two things only came out by running it:
- `export const proxy = auth(…)` is not a function declaration, so
Next.js never found it and every request 404'd. `next build` reported
success and listed the proxy. In the function config form auth() also
returns the handler as a promise, so it needs an await. The proxy test
now mocks it as a promise for that reason — a friendlier mock would
let the same bug back in.
- A missing AUTH_MICROSOFT_ENTRA_ID_ISSUER silently falls back to
/common/, and the redirect really did go there. That would let any
Microsoft account sign in, including a private one, and it would never
look broken. It now refuses to start in production.
Neither build nor image needs credentials any more: the pool is created on
first use, the auth config is evaluated per request, and there are no
NEXT_PUBLIC_* values left to bake in. One image now runs in every
environment.
Verified: typecheck, lint, 187 tests, build, and by hand in the browser —
/employees redirects to /login, and the sign-in button reaches the Entra
page with PKCE and the callback URL that goes into the app registration.
Not verified against a real database; there is still no DATABASE_URL.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
153 lines
6.6 KiB
TypeScript
153 lines
6.6 KiB
TypeScript
import { NextRequest } from "next/server";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { exportFilename, sanitizeForSpreadsheetCell, toCsv } from "@/lib/export";
|
|
import {
|
|
parseEventDateParam,
|
|
parseEventType,
|
|
parseGroupDimension,
|
|
parseIsoDateParam,
|
|
parseMeasure,
|
|
parseSplitDimension,
|
|
} from "@/lib/reports";
|
|
|
|
// Die Cron-Route lädt lib/db, das über `import "server-only"` abgesichert
|
|
// ist und beim Laden eine DATABASE_URL verlangt — beides gibt es unter
|
|
// reinem Vitest nicht. Weggemockt: geprüft wird hier nur der
|
|
// Geheimnis-Abgleich, der zurückkehrt, bevor irgendeine Verbindung
|
|
// entsteht.
|
|
// `import "server-only"` wirft ausserhalb der Server-Übersetzung von Next —
|
|
// also auch hier. Der Riegel ist im Betrieb richtig; für den Test wird das
|
|
// Modul zu einer leeren Hülle.
|
|
vi.mock("server-only", () => ({}));
|
|
vi.mock("@/lib/db", () => ({ asSystem: vi.fn(), withUser: vi.fn() }));
|
|
vi.mock("@/lib/db/rpc", () => ({ callFunction: vi.fn() }));
|
|
|
|
describe("sanitizeForSpreadsheetCell", () => {
|
|
it("prefixes values that would be read as a formula by Excel/Sheets", () => {
|
|
expect(sanitizeForSpreadsheetCell("=SUM(A1:A2)")).toBe("'=SUM(A1:A2)");
|
|
expect(sanitizeForSpreadsheetCell("+1234")).toBe("'+1234");
|
|
expect(sanitizeForSpreadsheetCell("-1234")).toBe("'-1234");
|
|
expect(sanitizeForSpreadsheetCell("@cmd")).toBe("'@cmd");
|
|
});
|
|
|
|
it("leaves ordinary text untouched", () => {
|
|
expect(sanitizeForSpreadsheetCell("Gruber")).toBe("Gruber");
|
|
expect(sanitizeForSpreadsheetCell("Hauptstraße 1, 1200 Wien")).toBe("Hauptstraße 1, 1200 Wien");
|
|
});
|
|
|
|
it("toCsv escapes a formula-injection payload in a row value", () => {
|
|
const csv = toCsv([{ name: "=cmd|'/c calc'!A1" }], [{ header: "Name", get: (r: { name: string }) => r.name }]);
|
|
expect(csv).toContain("'=cmd");
|
|
});
|
|
});
|
|
|
|
// Der Test zu sanitizeIlikeTerm ist entfallen, weil die Funktion es ist.
|
|
// Sie entschärfte Zeichen, die in der Filtersyntax der alten API-Schicht
|
|
// strukturelle Bedeutung hatten. Am direkten Datenbankzugang wird der
|
|
// Suchbegriff als Parameter gebunden — ein Komma oder eine Klammer darin
|
|
// ist schlicht ein Zeichen. Die Lücke ist nicht abgesichert, sondern weg.
|
|
|
|
describe("report query-string parsing", () => {
|
|
it("falls back to a known dimension instead of passing an unknown one through", () => {
|
|
expect(parseMeasure("bogus")).toBe("headcount");
|
|
expect(parseGroupDimension("bogus")).toBe("division");
|
|
expect(parseMeasure("fte")).toBe("fte");
|
|
expect(parseGroupDimension("weekday")).toBe("weekday");
|
|
});
|
|
|
|
it("treats an unknown split or event type as 'none' rather than a value", () => {
|
|
expect(parseSplitDimension("bogus")).toBeNull();
|
|
expect(parseSplitDimension("")).toBeNull();
|
|
expect(parseSplitDimension("location")).toBe("location");
|
|
expect(parseEventType("bogus")).toBeNull();
|
|
expect(parseEventType("Eintritt")).toBe("Eintritt");
|
|
});
|
|
|
|
// Object.hasOwn, not `in`: a plain `value in LABELS` check would accept
|
|
// "constructor" or "toString" off the prototype chain as a valid dimension.
|
|
it("does not accept inherited Object properties as dimensions", () => {
|
|
expect(parseGroupDimension("constructor")).toBe("division");
|
|
expect(parseSplitDimension("toString")).toBeNull();
|
|
expect(parseEventType("hasOwnProperty")).toBeNull();
|
|
});
|
|
|
|
it("rejects a Stichtag that is not a real calendar date", () => {
|
|
expect(parseIsoDateParam("2026-03-05")).toBe("2026-03-05");
|
|
expect(parseIsoDateParam("2026-02-30")).toBeUndefined();
|
|
expect(parseIsoDateParam("heute")).toBeUndefined();
|
|
expect(parseIsoDateParam('2026-03-05"; rm -rf /')).toBeUndefined();
|
|
});
|
|
|
|
it("keeps the open-interval sentinel but still rejects free text", () => {
|
|
expect(parseEventDateParam("open")).toBe("open");
|
|
expect(parseEventDateParam("2026-01-01")).toBe("2026-01-01");
|
|
expect(parseEventDateParam("whenever")).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("exportFilename", () => {
|
|
// The base is assembled from query-string values and lands in a
|
|
// Content-Disposition header, so a quote in it would otherwise break out
|
|
// of the quoted filename.
|
|
it("strips characters that would escape the Content-Disposition filename", () => {
|
|
const name = exportFilename('bericht"; attachment; filename="evil', "csv");
|
|
expect(name).not.toContain('"');
|
|
expect(name).not.toContain(";");
|
|
expect(name.endsWith(".csv")).toBe(true);
|
|
});
|
|
|
|
it("keeps an ordinary base readable", () => {
|
|
expect(exportFilename("bericht-headcount-division", "xlsx")).toMatch(/^bericht-headcount-division-\d{4}-\d{2}-\d{2}\.xlsx$/);
|
|
});
|
|
});
|
|
|
|
describe("cron auth guard (/api/cron/apply-pending-changes)", () => {
|
|
afterEach(() => {
|
|
vi.unstubAllEnvs();
|
|
});
|
|
|
|
async function callCronRoute(authorization?: string) {
|
|
// The route reads process.env.CRON_SECRET inside the handler on every
|
|
// request (not at module load time), so importing it once and reusing
|
|
// it across the stubbed-env cases below is safe.
|
|
const { GET } = await import("@/app/api/cron/apply-pending-changes/route");
|
|
const headers = new Headers();
|
|
if (authorization !== undefined) headers.set("authorization", authorization);
|
|
const request = new NextRequest("http://localhost/api/cron/apply-pending-changes", { headers });
|
|
return GET(request);
|
|
}
|
|
|
|
it("rejects the request when CRON_SECRET is not configured, even with a matching-looking header", async () => {
|
|
vi.stubEnv("CRON_SECRET", "");
|
|
const res = await callCronRoute("Bearer ");
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it("rejects a request with no Authorization header", async () => {
|
|
vi.stubEnv("CRON_SECRET", "test-secret");
|
|
const res = await callCronRoute(undefined);
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it("rejects a request with the wrong bearer token", async () => {
|
|
vi.stubEnv("CRON_SECRET", "test-secret");
|
|
const res = await callCronRoute("Bearer wrong-value");
|
|
expect(res.status).toBe(401);
|
|
});
|
|
});
|
|
|
|
describe("protected export route without a session (/api/export/employees)", () => {
|
|
it("returns 401 instead of running the query when there is no authenticated user", async () => {
|
|
// Gestellt wird die Sitzung, nicht die Datenbank: die Route muss mit 401
|
|
// antworten, *bevor* sie eine Verbindung aufbaut. Käme sie bis zur
|
|
// Abfrage, liefe der Test ohne DATABASE_URL in einen Verbindungsfehler
|
|
// statt in die Zusicherung — und bewiese nichts.
|
|
vi.doMock("@/auth", () => ({ auth: async () => null }));
|
|
const { GET } = await import("@/app/api/export/employees/route");
|
|
const request = new NextRequest("http://localhost/api/export/employees");
|
|
const res = await GET(request);
|
|
expect(res.status).toBe(401);
|
|
vi.doUnmock("@/auth");
|
|
});
|
|
});
|