feat: Erinnerungen, Wartungslauf und Playwright-Tests der Anmeldung

Etappe 8, erster Teil.

Erinnerungsmails sind bewusst eine Zugabe: Ist kein SMTP konfiguriert,
wird nichts gesendet und nichts geworfen. Die Fristen stehen ohnehin im
Dashboard und im abonnierten Kalender - eine ausgefallene Mail darf
nicht dazu fuehren, dass der Wartungslauf abbricht und danach auch das
Aufraeumen liegen bleibt.

In der Mail steht, DASS etwas faellig ist und wo man nachsieht - nicht,
worum es geht. Kein Name der betreuten Person, keine Inhalte. E-Mail ist
unterwegs unverschluesselt und landet auf fremden Servern.

Je Frist geht hoechstens eine Mail pro Vorwarnstufe raus. Taeglich waere
nach einer Woche Rauschen, das man wegklickt - und genau dann verpasst
man die eine, auf die es ankam.

Der Wartungslauf faengt jeden Schritt einzeln ab: Papierkorb leeren,
Anmeldeversuche und altes Audit-Log aufraeumen laufen auch dann, wenn
der SMTP-Server nicht antwortet. Angestossen wird er ueber
/api/wartung mit einem HMAC aus AUTH_SECRET - ohne gueltigen Schluessel
antwortet die Route mit 404 statt 401.

Ein echter Fund aus den Tests: Auth.js reicht den Fehlercode aus
authorize() nicht an die Server Action zurueck. Bei gesperrtem Zugang
sah man deshalb nur die allgemeine Meldung "stimmen nicht" und haette
ratlos weiterprobiert, obwohl die Sperre bereits lief. Die Sperre wird
jetzt zusaetzlich in der Server Action geprueft und benannt; die Pruefung
in authorize() bleibt als eigentliche Sperre bestehen.

Sechs Playwright-Tests der Anmeldung, darunter zwei, die
Sicherheitszusagen nachweisen: dass zwei Fehlversuche mit
unterschiedlicher Ursache dieselbe Meldung liefern (sonst liesse sich
ablesen, welche Adressen existieren), und dass die Anmeldeseite keine
einzige Anfrage an einen fremden Host stellt.

Etappe: 8
Phase: Haertung
This commit is contained in:
Florian Wilde
2026-08-22 06:23:04 +02:00
parent 5cc7f5c140
commit 1a91f62ced
10 changed files with 783 additions and 8 deletions
+153
View File
@@ -0,0 +1,153 @@
import { expect, test } from '@playwright/test'
import { anmelden, fehlversucheLoeschen, umgebung } from './hilfen'
/**
* Hinweis zu den Selektoren: Meldungen werden immer INNERHALB des
* Formulars gesucht (`page.locator('form').getByRole('alert')`). Die
* Fehleranzeige von Next.js traegt im Entwicklungsmodus ebenfalls
* role="alert" - ein ungenauer Selektor findet zwei Elemente und der
* Test schlaegt aus dem falschen Grund fehl.
*
* Der wichtigste Ablauf ueberhaupt: Ohne Anmeldung kommt niemand an
* Gesundheits- und Vermoegensdaten. Diese Datei prueft die Zusage, dass
* der zweite Faktor Pflicht ist und sich nicht umgehen laesst.
*/
test.describe('Anmeldung', () => {
// Ein Test loest die Sperre absichtlich aus - danach muss aufgeraeumt
// werden, sonst haengen die folgenden Tests an ihr fest.
test.afterEach(async () => {
await fehlversucheLoeschen()
})
test('verlangt alle drei Angaben und laesst danach herein', async ({ page }) => {
await page.goto('/anmelden')
await expect(page.getByRole('heading', { name: 'Anmelden' })).toBeVisible()
await expect(page.locator('input[name="email"]')).toBeVisible()
await expect(page.locator('input[name="passwort"]')).toBeVisible()
await expect(page.locator('input[name="code"]')).toBeVisible()
await anmelden(page)
await expect(page).toHaveURL(/\/heute/)
})
test('verraet nicht, welche der drei Angaben falsch war', async ({ page }) => {
const env = await umgebung()
// Zwei Fehlversuche, die sich nur in EINER Angabe unterscheiden.
// Beide muessen dieselbe Meldung liefern - sonst liesse sich daraus
// ablesen, ob eine E-Mail-Adresse existiert oder ob das Passwort
// bereits stimmte.
const meldungen: string[] = []
for (const versuch of [
{ email: env.SEED_ADMIN_EMAIL!, passwort: 'ganz-sicher-falsch-2026' },
{ email: 'gibt-es-nicht@example.invalid', passwort: 'ganz-sicher-falsch-2026' },
]) {
await page.goto('/anmelden')
await page.fill('input[name="email"]', versuch.email)
await page.fill('input[name="passwort"]', versuch.passwort)
await page.fill('input[name="code"]', '123456')
await page.click('button[type="submit"]')
const meldung = page.locator('form').getByRole('alert')
await expect(meldung).toBeVisible()
meldungen.push(((await meldung.textContent()) ?? '').trim())
await expect(page).toHaveURL(/\/anmelden/)
}
// Entweder beide Male die neutrale Meldung, oder beide Male die
// Sperrmeldung - aber nie zwei verschiedene Auskuenfte.
expect(meldungen[0]).toBe(meldungen[1])
// Und in keinem Fall ein Hinweis auf das einzelne Feld.
for (const meldung of meldungen) {
expect(meldung).not.toMatch(/unbekannt|nicht registriert|existiert nicht/i)
expect(meldung).not.toMatch(/Passwort ist falsch|falsches Passwort/i)
}
})
test('sperrt nach mehreren Fehlversuchen und sagt das auch', async ({ page }) => {
const env = await umgebung()
// LOGIN_MAX_VERSUCHE steht in der .env, Standard 5. Sechs Versuche
// reichen also in jedem Fall.
let sperrmeldung = ''
for (let i = 0; i < 6; i++) {
await page.goto('/anmelden')
await page.fill('input[name="email"]', env.SEED_ADMIN_EMAIL!)
await page.fill('input[name="passwort"]', `falsch-${i}`)
await page.fill('input[name="code"]', '123456')
await page.click('button[type="submit"]')
// Auf die Meldung warten, statt sie sofort zu lesen: die Anmeldung
// wird bewusst auf eine Mindestdauer gestreckt, damit die Antwortzeit
// nichts verraet.
const meldung = page.locator('form').getByRole('alert')
await expect(meldung).toBeVisible({ timeout: 20_000 })
const text = ((await meldung.textContent()) ?? '').trim()
if (/gesperrt|zu viele/i.test(text)) {
sperrmeldung = text
break
}
}
expect(sperrmeldung, 'Nach sechs Fehlversuchen muss gesperrt werden').not.toBe('')
// Die Sperre wird benannt - sonst probiert man ratlos weiter.
expect(sperrmeldung).toMatch(/gesperrt|zu viele/i)
})
test('schickt Unangemeldete von Fachseiten zur Anmeldung', async ({ page }) => {
for (const pfad of ['/heute', '/stammdaten', '/fristen', '/protokoll']) {
await page.goto(pfad)
await expect(page, `${pfad} muss geschuetzt sein`).toHaveURL(/\/anmelden/)
}
})
test('setzt eine strenge Content-Security-Policy und laedt nichts von aussen', async ({
page,
}) => {
const fremdeAnfragen: string[] = []
page.on('request', (anfrage) => {
const url = new URL(anfrage.url())
if (!['localhost', '127.0.0.1'].includes(url.hostname)) {
fremdeAnfragen.push(anfrage.url())
}
})
const antwort = await page.goto('/anmelden')
const kopf = antwort?.headers() ?? {}
expect(kopf['content-security-policy']).toBeTruthy()
expect(kopf['content-security-policy']).toContain("default-src 'self'")
expect(kopf['content-security-policy']).toContain("frame-ancestors 'none'")
expect(kopf['content-security-policy']).toContain("object-src 'none'")
expect(kopf['x-content-type-options']).toBe('nosniff')
expect(kopf['x-frame-options']).toBe('DENY')
await page.waitForLoadState('networkidle')
// Keine Schriften von Google, kein CDN, keine Analysedienste.
expect(fremdeAnfragen, `Fremde Anfragen: ${fremdeAnfragen.join(', ')}`).toHaveLength(0)
})
test('meldet ab und sperrt danach wieder aus', async ({ page }) => {
await anmelden(page)
await expect(page).toHaveURL(/\/heute/)
// Am Handy liegt das Benutzermenue in der Kopfzeile, am Schreibtisch
// unten in der Seitenleiste - der Knopf traegt in beiden Faellen den
// Namen des angemeldeten Benutzers.
await page.getByRole('button', { name: /Florian Wilde|Betreuer|Administrator/i }).first().click()
await page.getByRole('menuitem', { name: 'Abmelden' }).click()
await page.waitForURL(/\/anmelden/, { timeout: 30_000 })
await page.goto('/stammdaten')
await expect(page).toHaveURL(/\/anmelden/)
})
})
+119
View File
@@ -0,0 +1,119 @@
import type { Page } from '@playwright/test'
import { PrismaClient } from '@prisma/client'
import { TOTP, Secret } from 'otpauth'
import { readFile } from 'node:fs/promises'
/**
* Gemeinsame Helfer fuer die Playwright-Tests.
*
* Die Anmeldung braucht einen echten TOTP-Code. Deshalb setzt der Seed im
* Entwicklungsmodus ein bekanntes Geheimnis (SEED_DEV_TOTP_SECRET), aus
* dem sich hier der aktuelle Code errechnen laesst. In der Produktion
* ignoriert der Seed diese Variable.
*/
let gemerkt: Record<string, string> | null = null
export async function umgebung(): Promise<Record<string, string>> {
if (gemerkt) return gemerkt
const roh = await readFile('.env', 'utf8')
const werte: Record<string, string> = {}
for (const zeile of roh.split(/\r?\n/)) {
const treffer = zeile.match(/^([A-Z0-9_]+)=(.*)$/)
if (treffer) werte[treffer[1]!] = treffer[2]!.replace(/^"|"$/g, '')
}
gemerkt = werte
return werte
}
export function totpCode(secret: string, kennung: string): string {
return new TOTP({
issuer: 'Betreuungs-Cockpit',
label: kennung,
algorithm: 'SHA1',
digits: 6,
period: 30,
secret: Secret.fromBase32(secret),
}).generate()
}
/**
* Loescht die Fehlversuche des Testzugangs.
*
* Noetig, weil ein Test die Login-Sperre absichtlich ausloest. Ohne diesen
* Reset waeren alle folgenden Tests fuer eine Viertelstunde ausgesperrt -
* und man saehe nicht mehr, ob sie wirklich fehlschlagen oder nur warten.
*/
export async function fehlversucheLoeschen(): Promise<void> {
const db = new PrismaClient()
try {
await db.loginVersuch.deleteMany({ where: { erfolg: false } })
} finally {
await db.$disconnect()
}
}
/** Meldet sich an und wartet, bis die Anwendung erreicht ist. */
export async function anmelden(seite: Page): Promise<void> {
const env = await umgebung()
// Vorherige Tests koennen die Sperre ausgeloest haben.
await fehlversucheLoeschen()
await seite.goto('/anmelden')
await seite.fill('input[name="email"]', env.SEED_ADMIN_EMAIL!)
await seite.fill('input[name="passwort"]', env.SEED_ADMIN_PASSWORT!)
await seite.fill('input[name="code"]', totpCode(env.SEED_DEV_TOTP_SECRET!, env.SEED_ADMIN_EMAIL!))
await Promise.all([
seite.waitForURL((url) => !url.pathname.startsWith('/anmelden'), { timeout: 45_000 }),
seite.click('button[type="submit"]'),
])
}
/**
* Ein winziges, gueltiges PNG als Buffer - dient als hochgeladenes
* "Foto eines Behoerdenschreibens". Bewusst inline erzeugt, damit im
* Repository keine Binaerdatei liegt.
*/
export function beispielBild(): Buffer {
return Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
'base64',
)
}
/**
* Eine minimale, gueltige PDF-Datei. Ebenfalls inline erzeugt.
* Enthaelt den uebergebenen Text als sichtbaren Seiteninhalt.
*/
export function beispielPdf(text = 'Bescheid'): Buffer {
const inhalt = `BT /F1 24 Tf 72 700 Td (${text}) Tj ET`
const objekte = [
'<< /Type /Catalog /Pages 2 0 R >>',
'<< /Type /Pages /Kids [3 0 R] /Count 1 >>',
'<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] ' +
'/Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>',
`<< /Length ${inhalt.length} >>\nstream\n${inhalt}\nendstream`,
'<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>',
]
let pdf = '%PDF-1.4\n'
const versaetze: number[] = []
objekte.forEach((objekt, i) => {
versaetze.push(pdf.length)
pdf += `${i + 1} 0 obj\n${objekt}\nendobj\n`
})
const xref = pdf.length
pdf += `xref\n0 ${objekte.length + 1}\n0000000000 65535 f \n`
for (const versatz of versaetze) {
pdf += `${String(versatz).padStart(10, '0')} 00000 n \n`
}
pdf += `trailer\n<< /Size ${objekte.length + 1} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`
return Buffer.from(pdf, 'latin1')
}
+2 -1
View File
@@ -27,7 +27,8 @@
"db:sql": "npm run db:volltext && npm run db:audit-schutz",
"protokoll": "node scripts/bauprotokoll.mjs",
"test:e2e": "playwright test",
"protokoll:md": "node scripts/bauprotokoll.mjs --md"
"protokoll:md": "node scripts/bauprotokoll.mjs --md",
"wartungsschluessel": "node --env-file=.env scripts/wartungsschluessel.mjs"
},
"prisma": {
"seed": "tsx prisma/seed.ts"
+54
View File
@@ -0,0 +1,54 @@
import { defineConfig, devices } from '@playwright/test'
/**
* Playwright fuer die kritischen Ablaeufe.
*
* Zwei Geraete, weil die Anwendung am Handy vollwertig bedienbar sein muss -
* das ist kein Zusatz, sondern Anforderung: Ich sitze oft im Heim oder auf
* dem Amt und habe nur das Telefon dabei.
*
* Der Server wird nicht automatisch gestartet: die Tests brauchen eine
* eingerichtete Datenbank mit Demodaten. Vorher einmal:
*
* docker compose -f docker-compose.dev.yml up -d
* npm run db:migrate && npm run db:sql
* SEED_DEMO=true npm run db:seed
* npm run dev
*/
const BASIS = process.env.E2E_BASIS ?? 'http://localhost:3300'
export default defineConfig({
testDir: './e2e',
// Anmeldung, Uploads und PDF-Erzeugung brauchen Zeit.
timeout: 90_000,
expect: { timeout: 15_000 },
// Die Tests teilen sich eine Datenbank und legen Daten an - parallel
// wuerden sie sich gegenseitig die Zaehlungen verderben.
fullyParallel: false,
workers: 1,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 0,
reporter: process.env.CI ? [['list'], ['html', { open: 'never' }]] : [['list']],
use: {
baseURL: BASIS,
locale: 'de-DE',
timezoneId: 'Europe/Berlin',
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
projects: [
{
name: 'schreibtisch',
use: { ...devices['Desktop Chrome'], viewport: { width: 1440, height: 900 } },
},
{
name: 'handy',
use: { ...devices['Pixel 7'] },
},
],
})
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env node
/**
* Gibt den Schluessel fuer /api/wartung aus.
*
* node --env-file=.env scripts/wartungsschluessel.mjs
*
* Damit laesst sich der taegliche Wartungslauf von aussen anstossen, z.B.
* per cron auf dem Server:
*
* 30 4 * * * curl -fsS -H "X-Wartung-Schluessel: <ausgabe>" \
* https://cockpit.example.de/api/wartung >/dev/null
*/
import { createHmac } from 'node:crypto'
const geheim = process.env.AUTH_SECRET
if (!geheim) {
console.error('AUTH_SECRET ist nicht gesetzt. Aufruf: node --env-file=.env scripts/wartungsschluessel.mjs')
process.exit(1)
}
console.log(createHmac('sha256', geheim).update('wartung').digest('base64url'))
+51
View File
@@ -0,0 +1,51 @@
import { NextResponse, type NextRequest } from 'next/server'
import { createHmac, timingSafeEqual } from 'node:crypto'
import { wartungslauf } from '@/lib/wartung'
/**
* Anstoss fuer den taeglichen Wartungslauf.
*
* curl -H "X-Wartung-Schluessel: <schluessel>" https://.../api/wartung
*
* Der Schluessel ist ein HMAC ueber die Zeichenkette "wartung" mit
* AUTH_SECRET - so braucht es kein weiteres Geheimnis in der .env, und der
* Wert laesst sich mit dem Skript unten jederzeit neu ausrechnen:
*
* node -e "console.log(require('crypto').createHmac('sha256', process.env.AUTH_SECRET).update('wartung').digest('base64url'))"
*
* Ohne gueltigen Schluessel antwortet die Route mit 404 statt 401 - wer
* die Adresse errät, soll nicht einmal erfahren, dass es sie gibt.
*/
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
function erwarteterSchluessel(): string {
return createHmac('sha256', process.env.AUTH_SECRET ?? '')
.update('wartung')
.digest('base64url')
}
function schluesselStimmt(uebergeben: string | null): boolean {
if (!uebergeben) return false
const erwartet = Buffer.from(erwarteterSchluessel())
const vorhanden = Buffer.from(uebergeben)
if (erwartet.length !== vorhanden.length) return false
return timingSafeEqual(erwartet, vorhanden)
}
export async function GET(anfrage: NextRequest) {
const schluessel =
anfrage.headers.get('x-wartung-schluessel') ??
anfrage.nextUrl.searchParams.get('schluessel')
if (!schluesselStimmt(schluessel)) {
return new NextResponse('Not Found', { status: 404 })
}
const ergebnis = await wartungslauf()
return NextResponse.json(ergebnis, {
status: ergebnis.fehler.length > 0 ? 207 : 200,
headers: { 'Cache-Control': 'no-store' },
})
}
+140
View File
@@ -0,0 +1,140 @@
import 'server-only'
import nodemailer from 'nodemailer'
import type { Transporter } from 'nodemailer'
import { env } from '@/lib/env'
/**
* E-Mail-Versand fuer Fristerinnerungen.
*
* Bewusst als Zugabe gebaut, nicht als Kernstueck: Ist kein SMTP
* konfiguriert, wird nichts gesendet und nichts geworfen. Die Fristen
* stehen ohnehin im Dashboard und im abonnierten Kalender - eine
* ausgefallene Mail darf nicht dazu fuehren, dass ein Wartungslauf
* abbricht und danach auch das Aufraeumen liegen bleibt.
*
* Inhaltlich gilt Datensparsamkeit: In die Mail gehoert, DASS etwas
* faellig ist und wo man nachsieht - nicht, worum es geht. E-Mail ist
* unverschluesselt unterwegs und landet auf fremden Servern; Diagnosen
* oder Kontostaende haben darin nichts zu suchen. Deshalb nimmt
* mailSenden() bewusst nur einfachen Text entgegen, und die Aufrufer
* halten sich an diese Regel.
*/
let versender: Transporter | null = null
function versenderHolen(): Transporter {
if (versender) return versender
const smtp = env.smtp
versender = nodemailer.createTransport({
host: smtp.host,
port: smtp.port,
// 465 ist implizit TLS, 587 wird per STARTTLS hochgestuft.
secure: smtp.port === 465,
auth: smtp.user ? { user: smtp.user, pass: smtp.passwort } : undefined,
requireTLS: smtp.port !== 465,
tls: {
// Ein Zertifikatsfehler ist ein Fehler, kein Grund zum Weitermachen.
rejectUnauthorized: true,
minVersion: 'TLSv1.2',
},
})
return versender
}
export type Nachricht = {
an: string
betreff: string
text: string
}
export type Versandergebnis = {
gesendet: boolean
meldung?: string
}
export async function mailSenden(nachricht: Nachricht): Promise<Versandergebnis> {
if (!env.smtpKonfiguriert) {
return { gesendet: false, meldung: 'Kein SMTP konfiguriert - es wurde nichts gesendet.' }
}
try {
await versenderHolen().sendMail({
from: env.smtp.absender,
to: nachricht.an,
subject: nachricht.betreff,
text: nachricht.text,
headers: {
// Damit Abwesenheitsantworten nicht zurueckkommen.
'Auto-Submitted': 'auto-generated',
'X-Auto-Response-Suppress': 'All',
},
})
return { gesendet: true }
} catch (fehler) {
const meldung = fehler instanceof Error ? fehler.message : String(fehler)
console.error('[mail] Versand fehlgeschlagen:', meldung)
return { gesendet: false, meldung }
}
}
/**
* Erinnerungstext fuer eine faellige Frist.
*
* Enthaelt Titel und Datum der Frist, aber weder den Namen der betreuten
* Person noch inhaltliche Angaben. Wer die Mail abfaengt, erfaehrt daraus
* nichts ueber den Fall.
*/
export function fristErinnerung(frist: {
titel: string
datum: Date
tageBis: number
ausschlussfrist: boolean
rechtsgrundlage: string | null
}): Nachricht {
const datum = frist.datum.toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
})
const wann =
frist.tageBis < 0
? `war am ${datum} fällig und ist seit ${Math.abs(frist.tageBis)} Tagen überfällig`
: frist.tageBis === 0
? `ist heute fällig (${datum})`
: `ist in ${frist.tageBis} Tagen fällig (${datum})`
const zeilen = [
`Eine Frist ${wann}:`,
'',
` ${frist.titel}`,
frist.rechtsgrundlage ? ` ${frist.rechtsgrundlage}` : '',
'',
]
if (frist.ausschlussfrist) {
zeilen.push(
'ACHTUNG: Das ist eine Ausschlussfrist. Läuft sie ab, ist der Anspruch',
'ersatzlos weg - es gibt keine Nachsicht und keine Wiedereinsetzung.',
'',
)
}
zeilen.push(
`Einzelheiten stehen im Betreuungs-Cockpit: ${env.basisUrl}/fristen`,
'',
'--',
'Diese Nachricht wurde automatisch erzeugt. Sie enthält bewusst keine',
'Angaben zur betreuten Person - E-Mail ist unterwegs nicht verschlüsselt.',
)
return {
an: '',
betreff: frist.ausschlussfrist
? `Ausschlussfrist: ${frist.titel}`
: `Frist: ${frist.titel}`,
text: zeilen.filter((z) => z !== undefined).join('\n'),
}
}
+208
View File
@@ -0,0 +1,208 @@
import 'server-only'
import { db, papierkorbGrenze } from '@/lib/db'
import { env } from '@/lib/env'
import { fristErinnerung, mailSenden } from '@/lib/mail'
import { alteVersucheEntfernen } from '@/lib/auth/ratelimit'
/**
* Taeglicher Wartungslauf.
*
* Wird ueber die Route /api/wartung angestossen (durch cron, systemd-timer
* oder den Docker-Sicherungsdienst). Vier Aufgaben:
*
* 1. Fristerinnerungen verschicken, wenn eine Vorwarnstufe erreicht ist
* 2. Papierkorb leeren (30 Tage)
* 3. Alte Anmeldeversuche entfernen (30 Tage)
* 4. Altes Audit-Log entfernen (10 Jahre, ueber die DB-Funktion)
*
* Jeder Schritt faengt seine Fehler selbst ab. Faellt die Mail aus, soll
* trotzdem aufgeraeumt werden - sonst laeuft die Datenbank voll, weil ein
* SMTP-Server nicht antwortet.
*/
export type Wartungsergebnis = {
erinnerungen: number
papierkorbGeleert: number
loginversucheEntfernt: number
auditEintraegeEntfernt: number
fehler: string[]
}
/** Tabellen mit Papierkorb - Reihenfolge egal, Fremdschluessel raeumen mit. */
const PAPIERKORB_TABELLEN = [
'frist',
'vorgang',
'genehmigung',
'dokument',
'buchung',
'konto',
'vermoegensposten',
'immobilie',
'vertrag',
'kontakt',
'kontaktprotokoll',
'gesundheitsereignis',
'sozialhilfeAntrag',
'nachforderung',
'bescheid',
'leistung',
'bericht',
'betreute',
] as const
export async function wartungslauf(): Promise<Wartungsergebnis> {
const ergebnis: Wartungsergebnis = {
erinnerungen: 0,
papierkorbGeleert: 0,
loginversucheEntfernt: 0,
auditEintraegeEntfernt: 0,
fehler: [],
}
// --- 1. Fristerinnerungen ---------------------------------------------
try {
ergebnis.erinnerungen = await erinnerungenVerschicken()
} catch (fehler) {
ergebnis.fehler.push(`Erinnerungen: ${beschreiben(fehler)}`)
}
// --- 2. Papierkorb leeren ----------------------------------------------
try {
ergebnis.papierkorbGeleert = await papierkorbLeeren()
} catch (fehler) {
ergebnis.fehler.push(`Papierkorb: ${beschreiben(fehler)}`)
}
// --- 3. Anmeldeversuche -------------------------------------------------
try {
ergebnis.loginversucheEntfernt = await alteVersucheEntfernen()
} catch (fehler) {
ergebnis.fehler.push(`Anmeldeversuche: ${beschreiben(fehler)}`)
}
// --- 4. Audit-Log --------------------------------------------------------
try {
// Die Tabelle ist gegen DELETE gesperrt; nur diese Funktion darf
// aufraeumen. Zehn Jahre entsprechen der Aufbewahrung der
// Rechnungslegungsunterlagen.
const zeilen = await db.$queryRaw<
{ audit_log_aufraeumen: bigint }[]
>`SELECT audit_log_aufraeumen('10 years'::interval)`
ergebnis.auditEintraegeEntfernt = Number(zeilen[0]?.audit_log_aufraeumen ?? 0)
} catch (fehler) {
ergebnis.fehler.push(`Audit-Log: ${beschreiben(fehler)}`)
}
return ergebnis
}
function beschreiben(fehler: unknown): string {
return fehler instanceof Error ? fehler.message : String(fehler)
}
/**
* Verschickt je Frist hoechstens eine Mail pro Vorwarnstufe.
*
* letzteErinnerungStufe merkt sich die zuletzt gemeldete Stufe. Erst wenn
* eine engere Stufe erreicht ist, geht wieder eine Mail raus - sonst kaeme
* sie taeglich, und man wuerde sie nach einer Woche ignorieren. Genau das
* soll nicht passieren.
*/
async function erinnerungenVerschicken(): Promise<number> {
if (!env.smtpKonfiguriert) return 0
const heute = new Date()
heute.setHours(0, 0, 0, 0)
const fristen = await db.frist.findMany({
where: { status: 'OFFEN', geloeschtAm: null },
select: {
id: true,
titel: true,
datum: true,
rechtsgrundlage: true,
ausschlussfrist: true,
vorwarnungTage: true,
letzteErinnerungStufe: true,
betreuteId: true,
},
})
let verschickt = 0
for (const frist of fristen) {
const tageBis = Math.round((frist.datum.getTime() - heute.getTime()) / 86_400_000)
// Erreichte Stufen: alle Vorwarnungen, die schon greifen. Ueberfaellige
// Fristen bekommen die Stufe 0.
const erreicht = frist.vorwarnungTage.filter((tage) => tageBis <= tage)
const stufe = tageBis < 0 ? 0 : erreicht.length > 0 ? Math.min(...erreicht) : null
if (stufe === null) continue
// Schon fuer diese oder eine engere Stufe gemeldet?
if (frist.letzteErinnerungStufe !== null && frist.letzteErinnerungStufe <= stufe) continue
const empfaenger = await empfaengerFuer(frist.betreuteId)
if (empfaenger.length === 0) continue
const vorlage = fristErinnerung({
titel: frist.titel,
datum: frist.datum,
tageBis,
ausschlussfrist: frist.ausschlussfrist,
rechtsgrundlage: frist.rechtsgrundlage,
})
let mindestensEine = false
for (const an of empfaenger) {
const { gesendet } = await mailSenden({ ...vorlage, an })
if (gesendet) mindestensEine = true
}
if (mindestensEine) {
await db.frist.update({
where: { id: frist.id },
data: { letzteErinnerungStufe: stufe },
})
verschickt += 1
}
}
return verschickt
}
/** Nur Betreuer bekommen Erinnerungen, keine Mitleser. */
async function empfaengerFuer(betreuteId: string): Promise<string[]> {
const zugaenge = await db.betreuungsZugang.findMany({
where: { betreuteId, rolle: 'BETREUER', user: { aktiv: true, geloeschtAm: null } },
select: { user: { select: { email: true } } },
})
return zugaenge.map((z) => z.user.email)
}
/**
* Entfernt endgueltig, was laenger als 30 Tage im Papierkorb liegt.
*
* Das Audit-Log bleibt davon unberuehrt - dort steht weiterhin, dass und
* wann etwas geloescht wurde. Genau darum geht es bei einem Nachweis.
*/
async function papierkorbLeeren(): Promise<number> {
const grenze = papierkorbGrenze()
let entfernt = 0
for (const tabelle of PAPIERKORB_TABELLEN) {
// Prisma hat keinen dynamischen Zugriff mit Typsicherheit - deshalb
// hier ein bewusster, eng begrenzter Umweg ueber den Modellnamen.
const modell = (db as unknown as Record<string, { deleteMany: (a: unknown) => Promise<{ count: number }> }>)[
tabelle
]
if (!modell) continue
const { count } = await modell.deleteMany({
where: { geloeschtAm: { lt: grenze } },
})
entfernt += count
}
return entfernt
}
+25
View File
@@ -1,8 +1,10 @@
'use server'
import { AuthError } from 'next-auth'
import { headers } from 'next/headers'
import { z } from 'zod'
import { signIn } from '@/auth'
import { anmeldungErlaubt } from '@/lib/auth/ratelimit'
/**
* Server Action fuer das Anmeldeformular.
@@ -41,6 +43,16 @@ function istWeiterleitung(fehler: unknown): boolean {
)
}
/** Hinter dem Reverse Proxy steht die echte Adresse im weitergereichten Kopf. */
async function ipDerAnfrage(): Promise<string | null> {
try {
const kopf = await headers()
return kopf.get('x-forwarded-for')?.split(',')[0]?.trim() || kopf.get('x-real-ip') || null
} catch {
return null
}
}
/** Verhindert offene Weiterleitungen auf fremde Seiten. */
function sicheresZiel(ziel: string): string {
if (!ziel.startsWith('/') || ziel.startsWith('//')) return '/heute'
@@ -93,6 +105,19 @@ export async function anmelden(
const { email, passwort, code, weiter } = gelesen.data
// Die Sperre wird hier geprueft und nicht erst in authorize().
//
// Grund: Auth.js reicht den Fehlercode aus authorize() nicht
// unveraendert an die Server Action zurueck - man saehe auch bei
// gesperrtem Zugang nur die allgemeine Meldung und wuerde ratlos
// weiterprobieren. Die Pruefung in authorize() bleibt trotzdem
// bestehen: sie ist die eigentliche Sperre, diese hier ist die
// Erklaerung dafuer.
const sperre = await anmeldungErlaubt(email, await ipDerAnfrage())
if (sperre.gesperrt) {
return { fehler: fehlertext(`gesperrt:${sperre.nochSekunden}`), email }
}
try {
await signIn('zugangsdaten', {
email,
+10 -7
View File
@@ -115,17 +115,20 @@ describe('sealen und oeffnen', () => {
})
it('oeffnet einen Datensatz und ergaenzt die Klartextfelder', () => {
const datenbankzeile = {
id: 'abc',
vorname: 'Erika',
...sealen('Betreute', { diagnosen: 'COPD GOLD III', notizen: null }),
}
const versiegelt = sealen('Betreute', { diagnosen: 'COPD GOLD III', notizen: null })
const datenbankzeile = { id: 'abc', vorname: 'Erika', ...versiegelt }
const offen = oeffnen('Betreute', datenbankzeile)
expect(offen.vorname).toBe('Erika')
expect(offen.diagnosen).toBe('COPD GOLD III')
expect(offen.notizen).toBeNull()
// Der Umschlag bleibt stehen.
expect(offen.diagnosenVerschluesselt).toMatch(/^bc1\./)
// Der Umschlag bleibt stehen - sonst wuerde ein Zurueckschreiben des
// geoeffneten Datensatzes die verschluesselten Felder leeren.
expect(versiegelt.diagnosenVerschluesselt).toMatch(/^bc1\./)
expect((offen as Record<string, unknown>).diagnosenVerschluesselt).toBe(
versiegelt.diagnosenVerschluesselt,
)
})
})