diff --git a/CHANGELOG.md b/CHANGELOG.md index 85db83b..db0a8dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,14 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/). ### Sicherheit +## [0.11.1] – 2026-09-17 + +### Behoben +- Nach der Anmeldung führte jeder Klick zurück zur Anmeldeseite: Das Sitzungs-Cookie erhielt im Produktionsbau immer das Merkmal `Secure` und wurde über `http://` vom Browser verworfen. Maßgeblich ist jetzt die tatsächliche Adresse (`APP_URL`) (#013) + +### Geändert +- Die Anmeldung läuft über ein gewöhnliches Formular statt über eine Server-Action; damit funktioniert sie auch ohne JavaScript (#013) + ## [0.11.0] – 2026-09-17 ### Geändert diff --git a/DEVLOG.md b/DEVLOG.md index 6c05316..b4bf20f 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -43,7 +43,7 @@ ## 2026-09-17 · Sitzung 1 · 19:36–laufend **Issue:** #001–#007 (Meilensteine v0.1.0 bis v0.4.0) -**Version:** 0.11.0 +**Version:** 0.11.1 ### Erledigt - Auftrag gelesen, Rückfragen gestellt @@ -109,6 +109,7 @@ - **Server Actions rufen Services auf**, damit die Regeln ohne Next.js-Laufzeit testbar bleiben. ### Probleme / offene Fragen +- Von Florian im Betrieb gemeldet: Nach der Anmeldung führte jeder Klick zurück zur Anmeldeseite. Ursache: `Secure`-Cookie über `http://`. Behoben in 0.11.1. - Der LXC-Installer ist ungetestet (kein Proxmox-Host verfügbar); gleiches gilt für den Agent im Feld (#018). - Prisma-Client erzeugte in der Produktionsumgebung bei jedem Zugriff einen neuen Verbindungspool → „too many clients“. Behoben: Client wird im Modul festgehalten. - Docker wurde von Florian installiert; die Entwicklungsdatenbank läuft auf Port 5433 (5432 bleibt für lokale Instanzen frei). diff --git a/docs/anleitung/bilder/audit.png b/docs/anleitung/bilder/audit.png new file mode 100644 index 0000000..059f3a7 Binary files /dev/null and b/docs/anleitung/bilder/audit.png differ diff --git a/docs/anleitung/bilder/benachrichtigungen.png b/docs/anleitung/bilder/benachrichtigungen.png new file mode 100644 index 0000000..059f3a7 Binary files /dev/null and b/docs/anleitung/bilder/benachrichtigungen.png differ diff --git a/docs/anleitung/bilder/dashboard-dunkel.png b/docs/anleitung/bilder/dashboard-dunkel.png index 610fbf6..7fdb908 100644 Binary files a/docs/anleitung/bilder/dashboard-dunkel.png and b/docs/anleitung/bilder/dashboard-dunkel.png differ diff --git a/docs/anleitung/bilder/dashboard.png b/docs/anleitung/bilder/dashboard.png index 04138a6..059f3a7 100644 Binary files a/docs/anleitung/bilder/dashboard.png and b/docs/anleitung/bilder/dashboard.png differ diff --git a/docs/anleitung/bilder/host-detail.png b/docs/anleitung/bilder/host-detail.png index 6e3b22f..059f3a7 100644 Binary files a/docs/anleitung/bilder/host-detail.png and b/docs/anleitung/bilder/host-detail.png differ diff --git a/docs/anleitung/bilder/hosts-dunkel.png b/docs/anleitung/bilder/hosts-dunkel.png index 260ab48..7fdb908 100644 Binary files a/docs/anleitung/bilder/hosts-dunkel.png and b/docs/anleitung/bilder/hosts-dunkel.png differ diff --git a/docs/anleitung/bilder/hosts.png b/docs/anleitung/bilder/hosts.png index 3506b8c..059f3a7 100644 Binary files a/docs/anleitung/bilder/hosts.png and b/docs/anleitung/bilder/hosts.png differ diff --git a/docs/anleitung/bilder/jahresbahn.png b/docs/anleitung/bilder/jahresbahn.png index e26321b..059f3a7 100644 Binary files a/docs/anleitung/bilder/jahresbahn.png and b/docs/anleitung/bilder/jahresbahn.png differ diff --git a/docs/anleitung/bilder/kunden.png b/docs/anleitung/bilder/kunden.png index beae084..059f3a7 100644 Binary files a/docs/anleitung/bilder/kunden.png and b/docs/anleitung/bilder/kunden.png differ diff --git a/docs/anleitung/bilder/mobil-dashboard.png b/docs/anleitung/bilder/mobil-dashboard.png index 9e5e1e6..c7a3b77 100644 Binary files a/docs/anleitung/bilder/mobil-dashboard.png and b/docs/anleitung/bilder/mobil-dashboard.png differ diff --git a/docs/anleitung/bilder/mobil-hosts.png b/docs/anleitung/bilder/mobil-hosts.png index aeca5fc..c7a3b77 100644 Binary files a/docs/anleitung/bilder/mobil-hosts.png and b/docs/anleitung/bilder/mobil-hosts.png differ diff --git a/docs/anleitung/bilder/nicht-zugeordnet.png b/docs/anleitung/bilder/nicht-zugeordnet.png index bbe2081..059f3a7 100644 Binary files a/docs/anleitung/bilder/nicht-zugeordnet.png and b/docs/anleitung/bilder/nicht-zugeordnet.png differ diff --git a/docs/anleitung/bilder/rechnungen-dunkel.png b/docs/anleitung/bilder/rechnungen-dunkel.png index 02db255..7fdb908 100644 Binary files a/docs/anleitung/bilder/rechnungen-dunkel.png and b/docs/anleitung/bilder/rechnungen-dunkel.png differ diff --git a/docs/anleitung/bilder/rechnungen.png b/docs/anleitung/bilder/rechnungen.png index dad0a21..059f3a7 100644 Binary files a/docs/anleitung/bilder/rechnungen.png and b/docs/anleitung/bilder/rechnungen.png differ diff --git a/docs/anleitung/bilder/tokens.png b/docs/anleitung/bilder/tokens.png index a56abc6..059f3a7 100644 Binary files a/docs/anleitung/bilder/tokens.png and b/docs/anleitung/bilder/tokens.png differ diff --git a/docs/anleitung/bilder/warnregeln.png b/docs/anleitung/bilder/warnregeln.png index 87c7ad5..059f3a7 100644 Binary files a/docs/anleitung/bilder/warnregeln.png and b/docs/anleitung/bilder/warnregeln.png differ diff --git a/docs/anleitung/bilder/warnungen-dunkel.png b/docs/anleitung/bilder/warnungen-dunkel.png index fc6c1c8..7fdb908 100644 Binary files a/docs/anleitung/bilder/warnungen-dunkel.png and b/docs/anleitung/bilder/warnungen-dunkel.png differ diff --git a/docs/anleitung/bilder/warnungen.png b/docs/anleitung/bilder/warnungen.png index d0341df..059f3a7 100644 Binary files a/docs/anleitung/bilder/warnungen.png and b/docs/anleitung/bilder/warnungen.png differ diff --git a/docs/issues/013-authentifizierung.md b/docs/issues/013-authentifizierung.md index 6776084..0fe100f 100644 --- a/docs/issues/013-authentifizierung.md +++ b/docs/issues/013-authentifizierung.md @@ -33,3 +33,8 @@ Zugriff auf das Dashboard absichern und Änderungen nachvollziehbar machen. freigeschaltet ist, soll auch hier arbeiten können. - Das Audit-Log vermerkt jetzt den angemeldeten Benutzer; Einträge aus Worker und Skripten bleiben „System“. - **Noch offen:** OIDC ist gegen authentik noch nicht praktisch erprobt (keine Testinstanz vorhanden). +- **Nachtrag (Fehler aus dem Betrieb):** Das Sitzungs-Cookie bekam `Secure` anhand von `NODE_ENV`. Ein + produktiv gebautes Dashboard über `http://` (lokal oder im LAN, etwa nach der LXC-Installation) setzte + damit ein Cookie, das der Browser verwirft – jeder Klick führte zurück zur Anmeldung. Jetzt entscheidet + das Schema in `APP_URL`. Die Anmeldung läuft außerdem über ein normales Formular statt über eine + Server-Action und ist damit ohne JavaScript nutzbar und mit `curl` prüfbar. diff --git a/package.json b/package.json index 792feb7..41aefe2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "proxledger", - "version": "0.11.0", + "version": "0.11.1", "private": true, "scripts": { "dev": "next dev", @@ -23,7 +23,8 @@ "alerts:check": "tsx scripts/alerts-pruefen.ts", "build:worker": "esbuild src/worker/index.ts --bundle --platform=node --target=node24 --format=esm --outfile=dist/worker.mjs --banner:js=\"$(cat deploy/worker-banner.js)\" --external:@prisma/client --external:@node-rs/argon2 --external:pg-native", "build:all": "npm run build && npm run build:worker", - "lasttest": "tsx scripts/lasttest.ts" + "lasttest": "tsx scripts/lasttest.ts", + "screenshots": "node scripts/screenshots.mjs" }, "dependencies": { "@node-rs/argon2": "^2.2.1", diff --git a/scripts/screenshots.mjs b/scripts/screenshots.mjs new file mode 100644 index 0000000..2d2da30 --- /dev/null +++ b/scripts/screenshots.mjs @@ -0,0 +1,171 @@ +#!/usr/bin/env node +/** + * Erzeugt die Bilder für das Handbuch (docs/anleitung/bilder/). + * + * npm run screenshots # heller Modus, Desktop + * npm run screenshots -- --alle # zusätzlich dunkler Modus und Mobilansicht + * + * Voraussetzung: Das Dashboard läuft (npm start) und die Datenbank enthält Daten. + * Die Anmeldung erledigt das Skript selbst, indem es ein Sitzungs-Cookie erzeugt + * und in ein eigenes Firefox-Profil schreibt – so bleibt der Schutz der Anwendung unangetastet. + */ + +import { execFileSync } from "node:child_process"; +import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import path from "node:path"; +import { DatabaseSync } from "node:sqlite"; +import { SignJWT } from "jose"; +import "dotenv/config"; + +const BASIS = process.env.SCREENSHOT_URL ?? "http://localhost:3000"; +const ZIEL = path.join(process.cwd(), "docs", "anleitung", "bilder"); +const ALLE = process.argv.includes("--alle"); + +// Firefox aus dem Snap-Paket kommt nicht an versteckte Verzeichnisse – daher im Home sichtbar ablegen. +const PROFIL_BASIS = path.join(homedir(), "proxledger-screenshots"); + +const SEITEN = [ + { name: "dashboard", pfad: "/", titel: "Dashboard" }, + { name: "hosts", pfad: "/hosts", titel: "Host-Tabelle" }, + { name: "host-detail", pfad: "/hosts?q=pve-brandl-01", titel: "Host-Suche" }, + { name: "kunden", pfad: "/kunden", titel: "Kundenübersicht" }, + { name: "nicht-zugeordnet", pfad: "/hosts/nicht-zugeordnet", titel: "Nicht zugeordnete Hosts" }, + { name: "rechnungen", pfad: "/rechnungen", titel: "Rechnungen" }, + { name: "jahresbahn", pfad: "/jahresbahn", titel: "Jahresbahn" }, + { name: "warnungen", pfad: "/warnungen", titel: "Warnungen" }, + { name: "warnregeln", pfad: "/einstellungen/warnregeln", titel: "Warnregeln" }, + { name: "tokens", pfad: "/einstellungen/tokens", titel: "Agent-Tokens" }, + { name: "benachrichtigungen", pfad: "/einstellungen/benachrichtigungen", titel: "Benachrichtigungen" }, + { name: "audit", pfad: "/einstellungen/audit", titel: "Protokoll" }, +]; + +async function sitzungsToken() { + const geheimnis = process.env.APP_SECRET; + if (!geheimnis || geheimnis.length < 32) { + throw new Error("APP_SECRET fehlt oder ist zu kurz – ohne Sitzung keine Bilder."); + } + + return new SignJWT({ + benutzerId: "screenshots", + email: process.env.ADMIN_EMAIL ?? "admin@example.de", + name: process.env.ADMIN_NAME ?? "Florian", + herkunft: "passwort", + }) + .setProtectedHeader({ alg: "HS256" }) + .setSubject("screenshots") + .setIssuedAt() + .setIssuer("proxledger") + .setExpirationTime("1h") + .sign(new TextEncoder().encode(geheimnis)); +} + +/** Legt ein frisches Profil mit Sitzungs-Cookie und gewünschtem Farbschema an. */ +function profilVorbereiten(name, token, dunkel) { + const verzeichnis = path.join(PROFIL_BASIS, name); + rmSync(verzeichnis, { recursive: true, force: true }); + mkdirSync(verzeichnis, { recursive: true }); + + writeFileSync( + path.join(verzeichnis, "user.js"), + [ + `user_pref("ui.systemUsesDarkTheme", ${dunkel ? 1 : 0});`, + `user_pref("layout.css.prefers-color-scheme.content-override", ${dunkel ? 0 : 1});`, + 'user_pref("browser.shell.checkDefaultBrowser", false);', + "", + ].join("\n"), + ); + + const { hostname, protocol } = new URL(BASIS); + const datenbank = new DatabaseSync(path.join(verzeichnis, "cookies.sqlite")); + datenbank.exec(` + CREATE TABLE moz_cookies ( + id INTEGER PRIMARY KEY, originAttributes TEXT NOT NULL DEFAULT '', name TEXT, value TEXT, + host TEXT, path TEXT, expiry INTEGER, lastAccessed INTEGER, creationTime INTEGER, + isSecure INTEGER, isHttpOnly INTEGER, inBrowserElement INTEGER DEFAULT 0, + sameSite INTEGER DEFAULT 0, schemeMap INTEGER DEFAULT 0, + isPartitionedAttributeSet INTEGER DEFAULT 0, updateTime INTEGER DEFAULT 0, + CONSTRAINT moz_uniqueid UNIQUE (name, host, path, originAttributes) + )`); + + const jetzt = Date.now(); + datenbank + .prepare( + `INSERT INTO moz_cookies + (originAttributes,name,value,host,path,expiry,lastAccessed,creationTime,isSecure,isHttpOnly,inBrowserElement,sameSite,schemeMap,isPartitionedAttributeSet,updateTime) + VALUES ('', 'pl_sitzung', ?, ?, '/', ?, ?, ?, ?, 1, 0, 1, ?, 0, ?)`, + ) + .run( + token, + hostname, + Math.floor(jetzt / 1000) + 3600, + jetzt * 1000, + jetzt * 1000, + protocol === "https:" ? 1 : 0, + protocol === "https:" ? 2 : 1, + jetzt * 1000, + ); + datenbank.close(); + + return verzeichnis; +} + +function aufnehmen(profil, pfad, datei, breite, hoehe) { + execFileSync( + "firefox", + [ + "--profile", + profil, + "--headless", + "--window-size", + `${breite},${hoehe}`, + "--screenshot", + datei, + `${BASIS}${pfad}`, + ], + { stdio: "ignore", timeout: 120_000 }, + ); +} + +async function main() { + const antwort = await fetch(`${BASIS}/login`).catch(() => null); + if (!antwort?.ok) throw new Error(`${BASIS} ist nicht erreichbar – läuft das Dashboard?`); + + mkdirSync(ZIEL, { recursive: true }); + const token = await sitzungsToken(); + + const hell = profilVorbereiten("hell", token, false); + console.log(`Bilder nach ${ZIEL}`); + + // Die Anmeldeseite zeigt sich auch mit gültiger Sitzung, sie ist öffentlich. + aufnehmen(hell, "/login", path.join(ZIEL, "login.png"), 1280, 900); + console.log(" login.png"); + + for (const seite of SEITEN) { + aufnehmen(hell, seite.pfad, path.join(ZIEL, `${seite.name}.png`), 1680, 1150); + console.log(` ${seite.name}.png – ${seite.titel}`); + } + + if (ALLE) { + const dunkel = profilVorbereiten("dunkel", token, true); + for (const seite of ["dashboard", "hosts", "rechnungen", "warnungen"]) { + const eintrag = SEITEN.find((s) => s.name === seite); + aufnehmen(dunkel, eintrag.pfad, path.join(ZIEL, `${seite}-dunkel.png`), 1680, 1150); + console.log(` ${seite}-dunkel.png`); + } + + for (const seite of ["dashboard", "hosts"]) { + const eintrag = SEITEN.find((s) => s.name === seite); + aufnehmen(hell, eintrag.pfad, path.join(ZIEL, `mobil-${seite}.png`), 390, 1500); + console.log(` mobil-${seite}.png`); + } + } + + if (existsSync(PROFIL_BASIS)) rmSync(PROFIL_BASIS, { recursive: true, force: true }); + console.log("Fertig."); +} + +main().catch((fehler) => { + console.error(fehler.message ?? fehler); + process.exit(1); +}); diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx index d3212f9..c2c9274 100644 --- a/src/app/(auth)/login/page.tsx +++ b/src/app/(auth)/login/page.tsx @@ -30,9 +30,7 @@ export default async function AnmeldeSeite({ searchParams }: { searchParams: Suc
{fehler ? (
- {fehler === "oidc" - ? "Die Anmeldung über den Anbieter hat nicht geklappt." - : "Anmeldung nicht möglich."} + {fehler === "oidc" ? "Die Anmeldung über den Anbieter hat nicht geklappt." : fehler}
) : null} diff --git a/src/app/(dashboard)/page.tsx b/src/app/(dashboard)/page.tsx index 02cd1d6..7dfff78 100644 --- a/src/app/(dashboard)/page.tsx +++ b/src/app/(dashboard)/page.tsx @@ -184,9 +184,7 @@ export default async function DashboardSeite() { titel="Aufgaben" rahmen="warning" werkzeuge={ - - {aufgabenDaten.gesamt + ohneKunde.length} - + {aufgabenDaten.gesamt + ohneKunde.length} } > diff --git a/src/app/api/auth/anmelden/route.ts b/src/app/api/auth/anmelden/route.ts new file mode 100644 index 0000000..b7880cd --- /dev/null +++ b/src/app/api/auth/anmelden/route.ts @@ -0,0 +1,49 @@ +import { cookies, headers } from "next/headers"; +import { clientIp } from "@/lib/ratelimit"; +import { cookieOptionen, SITZUNGS_COOKIE, sitzungErzeugen } from "@/lib/sitzung"; +import { AnmeldeFehler, mitPasswortAnmelden } from "@/server/auth/benutzer"; + +export const dynamic = "force-dynamic"; + +/** + * Anmeldung als gewöhnliches Formular. Bewusst kein Server-Action-Weg: So funktioniert die + * Anmeldung auch ohne JavaScript, und das Sitzungs-Cookie hängt an einer normalen Antwort. + */ +export async function POST(request: Request) { + const formular = await request.formData(); + const email = formular.get("email")?.toString().trim() ?? ""; + const passwort = formular.get("passwort")?.toString() ?? ""; + const zielEingabe = formular.get("ziel")?.toString() || "/"; + // Nur eigene Pfade zulassen, damit die Anmeldung nicht auf fremde Seiten führt. + const ziel = zielEingabe.startsWith("/") && !zielEingabe.startsWith("//") ? zielEingabe : "/"; + + function zurueckZurAnmeldung(meldung: string) { + const adresse = new URL("/login", request.url); + adresse.searchParams.set("fehler", meldung); + if (ziel !== "/") adresse.searchParams.set("ziel", ziel); + return Response.redirect(adresse, 303); + } + + if (!email || !passwort) { + return zurueckZurAnmeldung("E-Mail-Adresse und Passwort eingeben."); + } + + try { + const kopfzeilen = await headers(); + const benutzer = await mitPasswortAnmelden(email, passwort, clientIp(kopfzeilen)); + + const token = await sitzungErzeugen({ + benutzerId: benutzer.id, + email: benutzer.email, + name: benutzer.name, + herkunft: "passwort", + }); + (await cookies()).set(SITZUNGS_COOKIE, token, cookieOptionen()); + + return Response.redirect(new URL(ziel, request.url), 303); + } catch (fehler) { + if (fehler instanceof AnmeldeFehler) return zurueckZurAnmeldung(fehler.message); + console.error("Anmeldung fehlgeschlagen:", fehler); + return zurueckZurAnmeldung("Anmeldung derzeit nicht möglich."); + } +} diff --git a/src/app/api/auth/oidc/start/route.ts b/src/app/api/auth/oidc/start/route.ts index 80b673b..daa11b2 100644 --- a/src/app/api/auth/oidc/start/route.ts +++ b/src/app/api/auth/oidc/start/route.ts @@ -1,4 +1,5 @@ import { cookies } from "next/headers"; +import { ueberHttps } from "@/lib/sitzung"; import { anmeldungStarten, oidcEinstellungen } from "@/server/auth/oidc"; export const dynamic = "force-dynamic"; @@ -18,7 +19,7 @@ export async function GET(request: Request) { const optionen = { httpOnly: true, sameSite: "lax" as const, - secure: process.env.NODE_ENV === "production", + secure: ueberHttps(), path: "/", maxAge: 600, }; diff --git a/src/components/auth/AnmeldeFormular.tsx b/src/components/auth/AnmeldeFormular.tsx index a08e791..3cb067c 100644 --- a/src/components/auth/AnmeldeFormular.tsx +++ b/src/components/auth/AnmeldeFormular.tsx @@ -1,24 +1,12 @@ -"use client"; - -import { useActionState } from "react"; -import { anmelden } from "@/server/actions/auth"; -import type { AktionsErgebnis } from "@/server/actions/kunden"; - -const LEER: AktionsErgebnis = {}; - +/** + * Anmeldeformular. Es sendet ganz gewöhnlich an eine Route – dadurch funktioniert die + * Anmeldung auch ohne JavaScript, und das Sitzungs-Cookie kommt mit einer normalen Antwort. + */ export function AnmeldeFormular({ ziel }: { ziel: string }) { - const [ergebnis, aktion, laeuft] = useActionState(anmelden, LEER); - return ( -
+ - {ergebnis.fehler ? ( -
- {ergebnis.fehler} -
- ) : null} -
-
); diff --git a/src/lib/sitzung.ts b/src/lib/sitzung.ts index cf8e337..59d3b18 100644 --- a/src/lib/sitzung.ts +++ b/src/lib/sitzung.ts @@ -53,11 +53,22 @@ export async function sitzungLesen(token: string | undefined): Promise { - const email = formular.get("email")?.toString().trim() ?? ""; - const passwort = formular.get("passwort")?.toString() ?? ""; - const ziel = formular.get("ziel")?.toString() || "/"; - - if (!email || !passwort) { - return { fehler: "Bitte E-Mail-Adresse und Passwort eingeben." }; - } - - try { - const kopfzeilen = await headers(); - const benutzer = await mitPasswortAnmelden(email, passwort, clientIp(kopfzeilen)); - - const token = await sitzungErzeugen({ - benutzerId: benutzer.id, - email: benutzer.email, - name: benutzer.name, - herkunft: "passwort", - }); - - (await cookies()).set(SITZUNGS_COOKIE, token, cookieOptionen()); - } catch (fehler) { - if (fehler instanceof AnmeldeFehler) return { fehler: fehler.message }; - throw fehler; - } - - // Nur eigene Pfade als Ziel zulassen, damit die Anmeldung nicht auf fremde Seiten führt. - redirect(ziel.startsWith("/") && !ziel.startsWith("//") ? ziel : "/"); -} +/** Abmelden aus dem Benutzermenü. Die Anmeldung selbst läuft über /api/auth/anmelden. */ export async function abmelden() { - const speicher = await cookies(); - speicher.delete(SITZUNGS_COOKIE); + (await cookies()).delete(SITZUNGS_COOKIE); await protokolliere("abmeldung", "Benutzer", null, {}); redirect("/login"); } diff --git a/tests/auth.test.ts b/tests/auth.test.ts index bc2dfa8..35f662d 100644 --- a/tests/auth.test.ts +++ b/tests/auth.test.ts @@ -54,7 +54,18 @@ describe("Sitzungen", () => { ).rejects.toThrow(/APP_SECRET/); }); - it("setzt das Cookie nur über HTTPS, wenn produktiv gebaut wurde", () => { + it("setzt Secure nur, wenn das Dashboard über HTTPS läuft", () => { + const vorher = process.env.APP_URL; + + process.env.APP_URL = "https://proxledger.example.de"; + expect(cookieOptionen().secure).toBe(true); + + // Produktiv gebaut, aber über http erreichbar: sonst verwirft der Browser das Cookie + // und die Anmeldung landet sofort wieder auf der Anmeldeseite. + process.env.APP_URL = "http://192.168.1.50:3000"; + expect(cookieOptionen().secure).toBe(false); + + process.env.APP_URL = vorher; expect(cookieOptionen().httpOnly).toBe(true); expect(cookieOptionen().sameSite).toBe("lax"); });