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 { 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() }) })