From 10b49cf8df375c5c39c1f8cde837fd56ba28cfaa Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Sat, 12 Sep 2026 11:22:48 +0200 Subject: [PATCH] test: Die Tuer der Schnittstelle scharf pruefen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- e2e/api.spec.ts | 187 ++++++++++++++++++++++++++++++++++++ scripts/integrationstest.ts | 42 ++++++++ 2 files changed, 229 insertions(+) create mode 100644 e2e/api.spec.ts diff --git a/e2e/api.spec.ts b/e2e/api.spec.ts new file mode 100644 index 0000000..aece00f --- /dev/null +++ b/e2e/api.spec.ts @@ -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 { + 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() + }) +}) diff --git a/scripts/integrationstest.ts b/scripts/integrationstest.ts index 889f400..b02d55a 100644 --- a/scripts/integrationstest.ts +++ b/scripts/integrationstest.ts @@ -67,6 +67,7 @@ async function aufraeumen(): Promise { }) 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 { @@ -395,6 +396,43 @@ async function main(): Promise { }), ) + // --- 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 { '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,