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:
co-authored by
Claude Opus 5
parent
9565b9a920
commit
10b49cf8df
+187
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -67,6 +67,7 @@ async function aufraeumen(): Promise<void> {
|
|||||||
})
|
})
|
||||||
await db.telegramChat.deleteMany({ where: { chatId: testChatId } })
|
await db.telegramChat.deleteMany({ where: { chatId: testChatId } })
|
||||||
await db.integrationszustand.deleteMany({ where: { quelle: 'TELEGRAM' } })
|
await db.integrationszustand.deleteMany({ where: { quelle: 'TELEGRAM' } })
|
||||||
|
await db.apiToken.deleteMany({ where: { name: { startsWith: kennung } } })
|
||||||
}
|
}
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
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 } })
|
await db.benutzer.delete({ where: { id: benutzer.id } })
|
||||||
|
|
||||||
const nachher = await db.auditEintrag.findUnique({ where: { id: audit.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',
|
'Push-Geräte verschwinden mit dem Benutzer',
|
||||||
(await db.pushGeraet.count({ where: { endpunkt: { contains: kennung } } })) === 0,
|
(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(
|
pruefe(
|
||||||
'Telegram-Chats verschwinden nicht, verlieren aber den Benutzer',
|
'Telegram-Chats verschwinden nicht, verlieren aber den Benutzer',
|
||||||
(await db.telegramChat.count({ where: { benutzerId: benutzer.id } })) === 0,
|
(await db.telegramChat.count({ where: { benutzerId: benutzer.id } })) === 0,
|
||||||
|
|||||||
Reference in New Issue
Block a user