Files
betreuungs-cockpit/e2e/anmeldung.spec.ts
T
Florian Wilde 1a91f62ced 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
2026-08-22 06:23:04 +02:00

154 lines
6.0 KiB
TypeScript

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/)
})
})