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
+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')
}