test: Die Tuer der Schnittstelle scharf pruefen

Zehn Faelle, davon sieben, die scheitern muessen: ohne Token, mit
erfundenem Token, mit zu wenig Rechten in beide Richtungen, abgelaufen,
zurueckgezogen, ohne Titel — und einer, der belegt, dass Status und
Bereich aus der Anfrage nicht uebernommen werden.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-12 11:22:48 +02:00
co-authored by Claude Opus 5
parent 9565b9a920
commit 10b49cf8df
2 changed files with 229 additions and 0 deletions
+187
View File
@@ -0,0 +1,187 @@
import { createHash, randomBytes } from 'node:crypto'
import { PrismaPg } from '@prisma/adapter-pg'
import { expect, test } from '@playwright/test'
import { PrismaClient } from '../src/generated/prisma/client.js'
import { EMAIL, VERBINDUNG } from './hilfe'
/**
* Die öffentliche Schnittstelle.
*
* Geprüft wird vor allem, was **nicht** geht: ohne Token, mit falschem
* Token, mit zu wenig Rechten, mit abgelaufenem oder zurückgezogenem Token.
* Der gute Fall ist schnell erzählt — die Tür ist das Interessante.
*/
const db = new PrismaClient({ adapter: new PrismaPg({ connectionString: VERBINDUNG }) })
const PRAEFIX = `E2E-API-${Date.now()}`
/** Legt einen Token direkt in der Datenbank an und gibt den Klartext zurück. */
async function tokenBauen(
benutzerId: string,
rechte: string,
zusatz: { gueltigBis?: Date; widerrufenAm?: Date } = {},
): Promise<string> {
const praefix = randomBytes(4).toString('hex')
const klartext = `planer_${praefix}_${randomBytes(32).toString('base64url')}`
await db.apiToken.create({
data: {
benutzerId,
name: `${PRAEFIX} ${rechte}`,
praefix,
hash: createHash('sha256').update(klartext, 'utf8').digest('hex'),
rechte,
...zusatz,
},
})
return klartext
}
test.describe('API v1', () => {
test.skip(!EMAIL || !VERBINDUNG, 'Ohne ADMIN_EMAIL und DATABASE_URL kein Testkonto.')
let benutzerId = ''
test.beforeAll(async () => {
const benutzer = await db.benutzer.findUniqueOrThrow({ where: { email: EMAIL } })
benutzerId = benutzer.id
})
test.afterAll(async () => {
await db.aufgabe.deleteMany({ where: { titel: { startsWith: PRAEFIX } } })
await db.aufgabe.deleteMany({ where: { quelleRef: { startsWith: PRAEFIX } } })
await db.apiToken.deleteMany({ where: { name: { startsWith: PRAEFIX } } })
await db.auditEintrag.deleteMany({ where: { aktion: { startsWith: 'api.' } } })
await db.$disconnect()
})
test('weist eine Anfrage ohne Token ab', async ({ request }) => {
const antwort = await request.get('/api/v1/aufgaben')
expect(antwort.status()).toBe(401)
// Sagt Werkzeugen, wie sie sich ausweisen sollen.
expect(antwort.headers()['www-authenticate']).toBe('Bearer')
})
test('weist einen erfundenen Token ab', async ({ request }) => {
const antwort = await request.get('/api/v1/aufgaben', {
headers: { Authorization: `Bearer planer_abcdefgh_${'x'.repeat(43)}` },
})
expect(antwort.status()).toBe(401)
})
test('sagt einem gültigen Token, was er darf', async ({ request }) => {
const token = await tokenBauen(benutzerId, 'lesen,erfassen')
const antwort = await request.get('/api/v1/selbst', {
headers: { Authorization: `Bearer ${token}` },
})
expect(antwort.ok()).toBe(true)
expect(await antwort.json()).toMatchObject({ rechte: ['lesen', 'erfassen'] })
})
test('lässt einen Nur-Lesen-Token nicht erfassen', async ({ request }) => {
const token = await tokenBauen(benutzerId, 'lesen')
const antwort = await request.post('/api/v1/aufgaben', {
headers: { Authorization: `Bearer ${token}` },
data: { titel: `${PRAEFIX} darf nicht` },
})
expect(antwort.status()).toBe(403)
expect(await db.aufgabe.count({ where: { titel: `${PRAEFIX} darf nicht` } })).toBe(0)
})
test('lässt einen Nur-Erfassen-Token nicht lesen', async ({ request }) => {
// Erfassen schließt Lesen ausdrücklich nicht ein.
const token = await tokenBauen(benutzerId, 'erfassen')
const antwort = await request.get('/api/v1/aufgaben', {
headers: { Authorization: `Bearer ${token}` },
})
expect(antwort.status()).toBe(403)
})
test('weist einen abgelaufenen Token ab', async ({ request }) => {
const token = await tokenBauen(benutzerId, 'lesen', {
gueltigBis: new Date(Date.now() - 60_000),
})
const antwort = await request.get('/api/v1/aufgaben', {
headers: { Authorization: `Bearer ${token}` },
})
expect(antwort.status()).toBe(401)
})
test('weist einen zurückgezogenen Token ab', async ({ request }) => {
const token = await tokenBauen(benutzerId, 'lesen', { widerrufenAm: new Date() })
const antwort = await request.get('/api/v1/aufgaben', {
headers: { Authorization: `Bearer ${token}` },
})
expect(antwort.status()).toBe(401)
})
test('erfasst, wiederholt sich nicht und ändert', async ({ request }) => {
const token = await tokenBauen(benutzerId, 'lesen,erfassen,schreiben')
const kopf = { Authorization: `Bearer ${token}` }
const ref = `${PRAEFIX}-ref`
const erste = await request.post('/api/v1/aufgaben', {
headers: kopf,
data: { titel: `${PRAEFIX} Angebot schreiben`, ref },
})
expect(erste.status()).toBe(201)
const angelegt = (await erste.json()) as { id: string; titel: string }
// Dasselbe noch einmal: eine Aufgabe, nicht zwei.
const zweite = await request.post('/api/v1/aufgaben', {
headers: kopf,
data: { titel: `${PRAEFIX} Angebot schreiben`, ref },
})
expect(zweite.ok()).toBe(true)
expect(await db.aufgabe.count({ where: { quelle: 'API', quelleRef: ref } })).toBe(1)
// Abhaken über die Schnittstelle.
const geaendert = await request.patch(`/api/v1/aufgaben/${angelegt.id}`, {
headers: kopf,
data: { erledigt: true },
})
expect(geaendert.ok()).toBe(true)
expect((await geaendert.json()) as { status: string }).toMatchObject({
status: 'ERLEDIGT',
})
// Und im Protokoll steht, dass es über die Schnittstelle kam.
const protokoll = await db.auditEintrag.findFirst({
where: { aktion: 'api.geaendert', objektId: angelegt.id },
select: { details: true },
})
expect(protokoll).not.toBeNull()
expect(JSON.stringify(protokoll?.details)).toContain('api')
})
test('lehnt eine Aufgabe ohne Titel ab', async ({ request }) => {
const token = await tokenBauen(benutzerId, 'erfassen')
const antwort = await request.post('/api/v1/aufgaben', {
headers: { Authorization: `Bearer ${token}` },
data: { titel: ' ' },
})
expect(antwort.status()).toBe(400)
})
test('setzt weder Status noch Bereich aus der Anfrage', async ({ request }) => {
const token = await tokenBauen(benutzerId, 'erfassen')
const bereich = await db.bereich.findFirst({ select: { id: true } })
const antwort = await request.post('/api/v1/aufgaben', {
headers: { Authorization: `Bearer ${token}` },
data: {
titel: `${PRAEFIX} Einordnung bleibt hier`,
status: 'ERLEDIGT',
bereichId: bereich?.id ?? null,
},
})
expect(antwort.status()).toBe(201)
const angelegt = await db.aufgabe.findFirstOrThrow({
where: { titel: `${PRAEFIX} Einordnung bleibt hier` },
select: { status: true, bereichId: true },
})
// Was von außen kommt, landet offen in der Inbox.
expect(angelegt.status).toBe('OFFEN')
expect(angelegt.bereichId).toBeNull()
})
})
+42
View File
@@ -67,6 +67,7 @@ async function aufraeumen(): Promise<void> {
})
await db.telegramChat.deleteMany({ where: { chatId: testChatId } })
await db.integrationszustand.deleteMany({ where: { quelle: 'TELEGRAM' } })
await db.apiToken.deleteMany({ where: { name: { startsWith: kennung } } })
}
async function main(): Promise<void> {
@@ -395,6 +396,43 @@ async function main(): Promise<void> {
}),
)
// --- API-Tokens ----------------------------------------------------------
const token = await db.apiToken.create({
data: {
benutzerId: benutzer.id,
name: `${kennung}-token`,
praefix: kennung.slice(-8),
hash: `hash-${kennung}`,
rechte: 'lesen',
},
})
pruefe('API-Token anlegen', token.id.length > 0)
pruefe('Ein frischer Token gilt unbegrenzt', token.gueltigBis === null)
pruefe('Ein frischer Token ist nicht widerrufen', token.widerrufenAm === null)
await scheitert('Dasselbe Praefix nur einmal', () =>
db.apiToken.create({
data: {
benutzerId: benutzer.id,
name: `${kennung}-zweiter`,
praefix: kennung.slice(-8),
hash: `anderer-hash-${kennung}`,
rechte: 'lesen',
},
}),
)
await scheitert('Denselben Hash nur einmal', () =>
db.apiToken.create({
data: {
benutzerId: benutzer.id,
name: `${kennung}-dritter`,
praefix: 'anders12',
hash: `hash-${kennung}`,
rechte: 'lesen',
},
}),
)
await db.benutzer.delete({ where: { id: benutzer.id } })
const nachher = await db.auditEintrag.findUnique({ where: { id: audit.id } })
@@ -420,6 +458,10 @@ async function main(): Promise<void> {
'Push-Geräte verschwinden mit dem Benutzer',
(await db.pushGeraet.count({ where: { endpunkt: { contains: kennung } } })) === 0,
)
pruefe(
'API-Tokens verschwinden mit dem Benutzer',
(await db.apiToken.count({ where: { name: { startsWith: kennung } } })) === 0,
)
pruefe(
'Telegram-Chats verschwinden nicht, verlieren aber den Benutzer',
(await db.telegramChat.count({ where: { benutzerId: benutzer.id } })) === 0,