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>
188 lines
6.8 KiB
TypeScript
188 lines
6.8 KiB
TypeScript
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()
|
|
})
|
|
})
|