Die Übersicht beantwortet vier Fragen, die man morgens hat: woran arbeite ich,
was ist fällig, wo hat jemand geschrieben, was hat sich getan. Jede Liste
läuft durch visibleCaseWhere — das ist der Ort, an dem eine unachtsame
Abfrage am ehesten eine Akte verrät. Ein Test prüft jede einzeln gegen eine zu
hoch eingestufte Akte. Der bisherige Inhalt ("Dein Zugang") steht jetzt unter
/zugang.
Beobachten ist eine Merkliste, kein Recht: nur was sichtbar ist, lässt sich
merken, und steigt eine Akte später über die eigene Stufe, verschwindet sie
aus der Liste, ohne dass jemand aufräumt.
Das helle Thema tauscht nur die semantische Ebene; die Grundpalette bleibt.
Die Klassifizierungsfarben sind für hellen Grund eine Spur dunkler, im Farbton
aber dieselben — eine Stufe muss in beiden Themen wiedererkennbar sein. Drei
Zustände, "wie das System" ist der Auslieferungszustand, und ein Skript im
Kopf setzt die Wahl, bevor das erste Mal gemalt wird.
Die Messung misst nicht den Nutzer. web-vitals schickt Zahlen und das
Routenmuster, keine Kennung und keinen konkreten Pfad: wer wann welche Akte
geöffnet hat, steht im Zugriffsprotokoll, und eine zweite Spur daneben wäre
eine Überwachungsfunktion ohne Auftrag. Ausgewertet wird Median und 75.
Perzentil, nicht der Mittelwert.
Mobil: die breiten Tabellen scrollen in sich, statt die Seite breiter zu
machen. Ein e2e-Test hält fest, dass keine Seite waagerecht überläuft.
scripts/restore-test.sh spielt eine Sicherung in eine Wegwerf-Datenbank und
prüft ausdrücklich, dass Regeln und Trigger mitkommen: eine Wiederherstellung
ohne entry_no_delete wäre eine Datenbank ohne die Zusicherung, um derentwillen
das System gebaut wird. Einmal durchgespielt, alles angekommen.
186 Tests im Web-Paket, 17 in der Datenbank, 7 im Worker, 54 e2e.
Grösste Route 116,2 von 120 kB.
142 lines
5.5 KiB
TypeScript
142 lines
5.5 KiB
TypeScript
import { expect, test } from '@playwright/test'
|
|
|
|
test('Gesundheitsendpunkt antwortet mit allen drei Abhaengigkeiten', async ({ request }) => {
|
|
const response = await request.get('/api/health')
|
|
expect([200, 503]).toContain(response.status())
|
|
const body = await response.json()
|
|
expect(Object.keys(body.components).sort()).toEqual(['clamav', 'database', 'discordGateway'])
|
|
})
|
|
|
|
test('Antwort traegt eine strikte Inhaltsrichtlinie', async ({ page }) => {
|
|
const response = await page.goto('/anmelden')
|
|
const csp = response?.headers()['content-security-policy'] ?? ''
|
|
expect(csp).toContain("object-src 'none'")
|
|
expect(csp).toContain("frame-ancestors 'none'")
|
|
expect(csp).toMatch(/script-src [^;]*'nonce-/)
|
|
expect(csp).not.toMatch(/script-src [^;]*'unsafe-inline'/)
|
|
})
|
|
|
|
test('Anmeldeseite bietet den Discord-Weg an', async ({ page }) => {
|
|
await page.goto('/anmelden')
|
|
await expect(page.getByRole('button', { name: 'Mit Discord anmelden' })).toBeVisible()
|
|
})
|
|
|
|
test('Startseite fuehrt Nichtangemeldete zur Anmeldung', async ({ page }) => {
|
|
await page.goto('/')
|
|
await expect(page).toHaveURL(/\/anmelden$/)
|
|
})
|
|
|
|
test.describe('Geschuetzte Bereiche', () => {
|
|
const geschuetzt = [
|
|
'/uebersicht',
|
|
'/faelle',
|
|
'/faelle/neu',
|
|
'/personalakten',
|
|
'/personalakten/neu',
|
|
'/kanaele',
|
|
'/suche',
|
|
'/faelle/AfiS-2026-0001/export',
|
|
'/faelle/AfiS-2026-0001/beteiligte',
|
|
'/faelle/AfiS-2026-0001/dokumente',
|
|
'/einstellungen',
|
|
'/dokumente/AfiS-2026-0001',
|
|
'/admin/rollen',
|
|
'/admin/system',
|
|
]
|
|
for (const pfad of geschuetzt) {
|
|
test(`${pfad} ist ohne Anmeldung nicht erreichbar`, async ({ page }) => {
|
|
await page.goto(pfad)
|
|
await expect(page).toHaveURL(/\/anmelden$/)
|
|
})
|
|
}
|
|
})
|
|
|
|
test.describe('Druckansichten', () => {
|
|
// Sie liegen ausserhalb des angemeldeten Bereichs, weil der PDF-Dienst sie
|
|
// mit einem kurzlebigen Zeichen aufruft und keine Sitzung hat. Ohne
|
|
// gueltiges Zeichen gibt es sie nicht — 404 statt Weiterleitung, denn die
|
|
// Existenz ist selbst eine Information.
|
|
// Geprueft wird der Inhalt, nicht der Statuscode: Next streamt die Antwort,
|
|
// der Kopf ist also schon raus, wenn notFound() greift. Entscheidend ist,
|
|
// dass nichts aus der Akte auf der Seite landet.
|
|
for (const pfad of ['/faelle/AfiS-2026-0001/druck', '/dokumente/AfiS-2026-0001/druck']) {
|
|
for (const [name, ziel] of [
|
|
['ohne Zeichen', pfad],
|
|
['mit erfundenem Zeichen', `${pfad}?token=abc.def`],
|
|
] as const) {
|
|
test(`${pfad} zeigt ${name} nichts`, async ({ page }) => {
|
|
await page.goto(ziel)
|
|
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Nicht gefunden')
|
|
await expect(page.locator('.print-cover')).toHaveCount(0)
|
|
await expect(page.locator('.sheet')).toHaveCount(0)
|
|
})
|
|
}
|
|
}
|
|
})
|
|
|
|
test.describe('Schmale Schirme', () => {
|
|
// Die Seite darf auf keinem Gerät waagerecht scrollen. Ein Formular, das
|
|
// seitlich hinausragt, ist auf dem Telefon unbedienbar — und genau dort
|
|
// wird eine Akte unterwegs gelesen. Siehe docs/09-performance-und-ux.md.
|
|
for (const pfad of ['/anmelden', '/anmelden/fehler?grund=keine-afis-rolle']) {
|
|
test(`${pfad} laeuft nicht seitlich ueber`, async ({ page }) => {
|
|
await page.goto(pfad)
|
|
const ueberlauf = await page.evaluate(
|
|
() => document.documentElement.scrollWidth - document.documentElement.clientWidth,
|
|
)
|
|
expect(ueberlauf).toBeLessThanOrEqual(1)
|
|
})
|
|
}
|
|
|
|
test('Druckansicht laeuft nicht seitlich ueber', async ({ page }) => {
|
|
await page.goto('/faelle/AfiS-2026-0001/druck')
|
|
const ueberlauf = await page.evaluate(
|
|
() => document.documentElement.scrollWidth - document.documentElement.clientWidth,
|
|
)
|
|
expect(ueberlauf).toBeLessThanOrEqual(1)
|
|
})
|
|
})
|
|
|
|
test('Fehlerseite benennt den Grund der Ablehnung', async ({ page }) => {
|
|
await page.goto('/anmelden/fehler?grund=keine-afis-rolle')
|
|
await expect(page.getByRole('heading', { level: 1 })).toHaveText(
|
|
'Keine Berechtigung für das Aktensystem',
|
|
)
|
|
|
|
await page.goto('/anmelden/fehler?grund=keine-mitgliedschaft')
|
|
await expect(page.getByRole('heading', { level: 1 })).toHaveText(
|
|
'Du bist nicht auf dem AfiS-Discord',
|
|
)
|
|
})
|
|
|
|
test('OAuth-Start nutzt Scope identify, state und PKCE', async ({ request, baseURL }) => {
|
|
// Sichert die Anforderungen aus docs/03-auth-und-rechte.md ab.
|
|
//
|
|
// Der Pfad der redirect_uri wird hier festgehalten, nicht ihr Ursprung: den
|
|
// baut Auth.js aus AUTH_URL, das in web/auth.ts aus APP_URL abgeleitet wird.
|
|
// Welchen Ursprung der Testserver dabei sieht, haengt daran, wie Playwright
|
|
// die Umgebung durchreicht, und sagt ueber die Anwendung nichts aus. Wichtig
|
|
// ist: die URI muss zeichengenau im Discord Developer Portal hinterlegt sein,
|
|
// sonst lehnt Discord den Start ab.
|
|
const csrfResponse = await request.get('/api/auth/csrf')
|
|
const { csrfToken } = await csrfResponse.json()
|
|
|
|
const response = await request.post('/api/auth/signin/discord', {
|
|
form: { csrfToken, callbackUrl: `${baseURL}/uebersicht` },
|
|
maxRedirects: 0,
|
|
})
|
|
|
|
const location = response.headers()['location'] ?? ''
|
|
expect(location).toContain('discord.com')
|
|
|
|
const params = new URL(location).searchParams
|
|
expect(params.get('scope')).toBe('identify')
|
|
expect(params.get('state')).toBeTruthy()
|
|
expect(params.get('code_challenge')).toBeTruthy()
|
|
expect(params.get('code_challenge_method')).toBe('S256')
|
|
|
|
const redirectUri = new URL(params.get('redirect_uri') ?? '')
|
|
expect(redirectUri.pathname).toBe('/api/auth/callback/discord')
|
|
expect(redirectUri.protocol).toMatch(/^https?:$/)
|
|
})
|