diff --git a/auth.ts b/auth.ts index e59762c..2d62ff6 100644 --- a/auth.ts +++ b/auth.ts @@ -31,33 +31,35 @@ async function upsertAppUser(externalId: string, email: string, fullName: string return row.id; } -export const { handlers, auth, signIn, signOut } = NextAuth(() => ({ - ...authConfig(), - callbacks: { - async jwt({ token, profile }) { - // `profile` liegt nur beim ersten Durchlauf nach der Rückkehr von Entra - // vor. Danach wird das Token nur noch weitergereicht — die Datenbank - // wird also einmal pro Anmeldung befragt, nicht einmal pro Aufruf. - if (!profile) return token; +export const { handlers, auth, signIn, signOut } = NextAuth(() => { + const base = authConfig(); + return { + ...base, + callbacks: { + // Die Rückrufe aus der Basis **behalten**, nicht ersetzen: dort liegt + // session(), das die Kennung aus dem Token auf die Sitzung legt. Ein + // schlichtes `callbacks: { jwt }` hätte es stillschweigend entfernt. + ...base.callbacks, + async jwt({ token, profile }) { + // `profile` liegt nur beim ersten Durchlauf nach der Rückkehr von Entra + // vor. Danach wird das Token nur noch weitergereicht — die Datenbank + // wird also einmal pro Anmeldung befragt, nicht einmal pro Aufruf. + if (!profile) return token; - const externalId = typeof profile.oid === "string" ? profile.oid : null; - const email = [profile.email, profile.preferred_username, profile.upn].find( - (v): v is string => typeof v === "string" && v.length > 0 - ); + const externalId = typeof profile.oid === "string" ? profile.oid : null; + const email = [profile.email, profile.preferred_username, profile.upn].find( + (v): v is string => typeof v === "string" && v.length > 0 + ); - // Lieber abbrechen als eine Sitzung ohne Kennung ausstellen: die käme - // als `null` bei withUser() an, und die Policies gäben dann konsequent - // nichts zurück — was sich als „die Anwendung ist leer" zeigt statt als - // Anmeldefehler. - if (!externalId || !email) throw new Error("Entra lieferte weder oid noch E-Mail-Adresse."); + // Lieber abbrechen als eine Sitzung ohne Kennung ausstellen: die käme + // als `null` bei withUser() an, und die Policies gäben dann konsequent + // nichts zurück — was sich als „die Anwendung ist leer" zeigt statt als + // Anmeldefehler. + if (!externalId || !email) throw new Error("Entra lieferte weder oid noch E-Mail-Adresse."); - token.uid = await upsertAppUser(externalId, email, typeof profile.name === "string" ? profile.name : null); - return token; + token.uid = await upsertAppUser(externalId, email, typeof profile.name === "string" ? profile.name : null); + return token; + }, }, - - async session({ session, token }) { - if (token.uid) session.user.id = token.uid; - return session; - }, - }, -})); + }; +}); diff --git a/eslint.config.mjs b/eslint.config.mjs index 3a67424..3517ff0 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -26,7 +26,9 @@ const eslintConfig = defineConfig([ // Vereinbarung überlebt den nächsten Termindruck nicht. { files: ["**/*.ts", "**/*.tsx"], - ignores: ["lib/db/**", "tests/integration/**", "supabase/**", "scripts/**"], + // Tests dürfen: sie werden nicht ausgeliefert, und einige prüfen gerade + // die Einstellungen des Pools — das geht nicht, ohne ihn anzusehen. + ignores: ["lib/db/**", "tests/**", "supabase/**", "scripts/**"], rules: { "no-restricted-imports": [ "error", diff --git a/lib/auth/config.ts b/lib/auth/config.ts index 92fac77..97920b6 100644 --- a/lib/auth/config.ts +++ b/lib/auth/config.ts @@ -91,9 +91,49 @@ export function authConfig(): NextAuthConfig { session: { strategy: "jwt", maxAge: SESSION_MAX_AGE_SECONDS }, + callbacks: { + // Muss **hier** stehen und nicht in auth.ts, obwohl es nur eine + // Zuweisung ist. + // + // proxy.ts baut eine eigene Auth.js-Instanz aus genau dieser Datei. Lag + // die Zuordnung in auth.ts, bekäme der Proxy die Standard-Sitzung ohne + // `id`, hielte jede angemeldete Person für nicht angemeldet und + // schickte sie zurück auf /login — obwohl das Cookie längst gesetzt + // ist. Genau so ist die erste echte Anmeldung in einer Schleife + // gelandet: Konto angelegt, Sitzung gültig, und trotzdem kam man nicht + // hinein. + // + // Datenbank braucht das nicht, es liest nur aus dem entschlüsselten + // Token — deshalb darf es am Rand laufen. + session({ session, token }) { + if (token.uid) session.user.id = token.uid; + return session; + }, + }, + // Hinter Reverse Proxy und Container-Netzwerk kommt der Host aus dem // Header. Ohne das verweigert Auth.js in der Produktion den Dienst, weil // es die Herkunft nicht bestätigen kann. trustHost: true, + + // Auf der Anmeldeseite steht bewusst nur eine allgemeine Meldung — der + // Grund ist fremdbestimmt und gehört nicht auf eine Seite, die echt + // aussieht. Im Serverprotokoll gehört er dagegen hin, und zwar + // vollständig: ohne das scheitert die Anmeldung lautlos, der Browser + // springt auf /login zurück, und es gibt nichts zu lesen ausser einer + // 302. Genau so ist die erste Anmeldung hier fehlgeschlagen. + logger: { + error(err) { + console.error("[auth] Fehler:", err); + const cause = (err as { cause?: unknown }).cause; + // Der eigentliche Grund steckt oft eine Ebene tiefer — bei einem + // Fehler in einem Rückruf ist die äussere Meldung nur „Read more at + // …/errors#callback-route-error". + if (cause) console.error("[auth] Ursache:", cause); + }, + warn(code) { + console.warn("[auth] Warnung:", code); + }, + }, }; } diff --git a/lib/db/pool.ts b/lib/db/pool.ts index c71aeb9..317e991 100644 --- a/lib/db/pool.ts +++ b/lib/db/pool.ts @@ -1,5 +1,40 @@ import "server-only"; -import { Pool } from "pg"; +import { Pool, types } from "pg"; + +// ═══ Wie Werte aus der Datenbank ankommen ════════════════════════ +// +// Der Wechsel des Zugriffswegs hat hier eine Falle hinterlassen, die kein +// Typprüfer und keiner der Tests fangen konnte. +// +// Die alte API-Schicht lieferte JSON: ein `date` kam als "2026-08-03" an, +// ein `numeric` als Zahl. Genau so steht es in lib/supabase/types.ts, und +// darauf baut die gesamte Anwendung — Sortierungen mit localeCompare, +// Vergleiche wie `entry_date <= stichtag`, das Ableiten des Status. +// +// Der `pg`-Treiber macht es anders herum: aus `date` wird ein Date-Objekt, +// aus `numeric` eine Zeichenkette. Die Deklarationen blieben dabei +// unverändert gültig — sie beschreiben ja nur, was der Code *glaubt*. Der +// Fehler zeigt sich erst zur Laufzeit, und im günstigen Fall als Absturz +// („a.date.localeCompare is not a function"). Im ungünstigen Fall gar +// nicht: ein Datumsvergleich zwischen Date und Zeichenkette wirft nicht, er +// liefert bloss das falsche Ergebnis. +// +// Deshalb wird der Treiber hier auf die Form zurückgestellt, die die Typen +// beschreiben. Das ist die kleinere und ehrlichere Änderung, als 49 +// Abfragestellen umzuschreiben. +// +// Nebenbei löst es ein zweites Problem: `date` ist ein Kalendertag ohne +// Zeitzone. Als Date-Objekt bekäme er eine — Mitternacht in der Zone des +// Servers —, und ein Geburtsdatum verschöbe sich beim Formatieren um einen +// Tag. Dieselbe Klasse von Fehler wie im Seed. +types.setTypeParser(1082, (v) => v); // date → "YYYY-MM-DD", unverändert +types.setTypeParser(1184, (v) => new Date(v).toISOString()); // timestamptz → ISO-8601 mit Z +types.setTypeParser(1114, (v) => new Date(v + "Z").toISOString()); // timestamp ohne Zone +types.setTypeParser(1700, (v) => Number(v)); // numeric → Zahl + +// Bewusst *nicht* umgestellt: int8 (bigint). Es kommt nur aus count() und +// wird überall mit Number() gelesen; als Zahl geparst verlöre es jenseits +// von 2^53 stillschweigend an Genauigkeit. // Die einzige Stelle im Projekt, die `pg` importieren darf. // diff --git a/tests/unit/db-type-parsers.test.ts b/tests/unit/db-type-parsers.test.ts new file mode 100644 index 0000000..9d9f096 --- /dev/null +++ b/tests/unit/db-type-parsers.test.ts @@ -0,0 +1,68 @@ +import { types } from "pg"; +import { beforeAll, describe, expect, it, vi } from "vitest"; + +// Warum es diesen Test gibt. +// +// lib/supabase/types.ts sagt für 16 Spalten `string` und für zwei `number`. +// Der `pg`-Treiber liefert von Haus aus das Gegenteil: aus `date` wird ein +// Date-Objekt, aus `numeric` eine Zeichenkette. +// +// Diese Abweichung ist für Typprüfer und Tests unsichtbar — die +// Deklarationen beschreiben, was der Code glaubt, nicht was ankommt. tsc war +// sauber, 187 Tests waren grün, und die Anwendung stürzte auf der ersten +// Seite ab („a.date.localeCompare is not a function"). Der Absturz war noch +// der freundliche Fall; ein Vergleich zwischen Date und Zeichenkette wirft +// nicht, er liefert bloss das falsche Ergebnis. +// +// Geprüft wird deshalb die einzige Stelle, an der beides zusammenkommt: die +// Parser, die lib/db/pool.ts beim Import registriert. + +vi.mock("server-only", () => ({})); + +beforeAll(async () => { + // Der Import allein registriert die Parser — deshalb reicht er als + // Vorbereitung, und deshalb würde ein Entfernen der Zeilen hier auffallen. + await import("@/lib/db/pool"); +}); + +const parse = (oid: number, raw: string) => types.getTypeParser(oid)(raw); + +describe("Typumwandlung des Treibers", () => { + it("gibt `date` unverändert als Kalendertag zurück", () => { + // Nicht `new Date(...)`: ein Kalendertag hat keine Zeitzone. Als + // Date-Objekt bekäme er Mitternacht in der Zone des Servers, und ein + // Geburtsdatum verschöbe sich beim Formatieren um einen Tag — in + // Österreich immer, weil MEZ östlich von UTC liegt. + expect(parse(1082, "1968-08-15")).toBe("1968-08-15"); + }); + + it("gibt `numeric` als Zahl zurück", () => { + // Die FTE-Kachel rechnet damit. Als Zeichenkette ergäbe die Summe + // "744.4" + "0.5" = "744.40.5" statt 744.9 — ohne jede Fehlermeldung. + expect(parse(1700, "744.4")).toBe(744.4); + expect(parse(1700, "38.5")).toBe(38.5); + }); + + it("gibt `timestamptz` als ISO-8601 mit Z zurück", () => { + const v = parse(1184, "2026-08-03 07:55:46.659+00"); + expect(v).toBe("2026-08-03T07:55:46.659Z"); + // Muss für Date verwertbar bleiben — die Protokollansicht formatiert + // damit. Die Rohform des Treibers („… +00") ist kein Format, das der + // Standard kennt. + expect(Number.isNaN(new Date(v as string).getTime())).toBe(false); + }); + + it("lässt `int8` als Zeichenkette", () => { + // Kommt nur aus count() und wird überall mit Number() gelesen. Als Zahl + // geparst verlöre es jenseits von 2^53 stillschweigend an Genauigkeit. + expect(parse(20, "852")).toBe("852"); + }); + + it("sortiert ISO-Zeitstempel als Text chronologisch", () => { + // Darauf beruht jedes orderBy im Anwendungscode, das auf Zeichenketten + // arbeitet. Gilt nur, weil das Format feste Breite hat und in UTC steht. + const frueher = parse(1184, "2026-08-03 07:55:46.659+00") as string; + const spaeter = parse(1184, "2026-08-03 09:12:01.000+00") as string; + expect(frueher.localeCompare(spaeter)).toBeLessThan(0); + }); +});