test: Spiegel-Eingang und Integrationstabellen absichern

Der Eingang des Spiegels ist die einzige Tuer, die ohne Anmeldung
aufgeht. Vier Faelle: ohne Signatur, falsch signiert, zu alt, und der
gute Fall — bei dem eine nicht mehr geschickte Belegung auch hier
verschwinden muss.

Der Testserver bekommt dafuer dasselbe Geheimnis wie der Test. Eine
Pruefung, die immer uebersprungen wird, prueft nichts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-11 23:58:43 +02:00
co-authored by Claude Opus 5
parent 0d0f8503cc
commit 4da2f3d9c6
4 changed files with 234 additions and 0 deletions
+10
View File
@@ -6,6 +6,16 @@ export const EMAIL = process.env.ADMIN_EMAIL ?? ''
export const PASSWORT = process.env.ADMIN_PASSWORT ?? '' export const PASSWORT = process.env.ADMIN_PASSWORT ?? ''
export const VERBINDUNG = process.env.DATABASE_URL ?? '' export const VERBINDUNG = process.env.DATABASE_URL ?? ''
/**
* Geheimnis fuer den Spiegel im Test.
*
* Der Testserver bekommt denselben Wert ueber playwright.config.ts, sonst
* liesse sich die Tuer gar nicht pruefen — und eine Pruefung, die immer
* uebersprungen wird, prueft nichts.
*/
export const SPIEGEL_GEHEIMNIS =
process.env.SPIEGEL_GEHEIMNIS ?? 'e2e-spiegel-geheimnis-nur-lokal'
/** /**
* Meldet das Testkonto mit Passwort an. * Meldet das Testkonto mit Passwort an.
* *
+138
View File
@@ -0,0 +1,138 @@
import { createHmac } from 'node:crypto'
import { PrismaPg } from '@prisma/adapter-pg'
import { expect, test } from '@playwright/test'
import { PrismaClient } from '../src/generated/prisma/client.js'
import { SPIEGEL_GEHEIMNIS, VERBINDUNG } from './hilfe'
/**
* Der Eingang des Spiegels.
*
* Hier meldet sich kein Mensch an — die Gegenstelle weist sich allein über
* eine Signatur aus. Deshalb wird hier nicht die Oberfläche geprüft, sondern
* genau diese Tür: Sie muss zu sein, wenn irgendetwas nicht stimmt.
*/
const GEHEIMNIS = SPIEGEL_GEHEIMNIS
const db = new PrismaClient({ adapter: new PrismaPg({ connectionString: VERBINDUNG }) })
const PRAEFIX = `E2E-S-${Date.now()}`
function signieren(koerper: string, geheimnis = GEHEIMNIS): string {
return createHmac('sha256', geheimnis).update(koerper).digest('hex')
}
function sendung(teile: Record<string, unknown> = {}): string {
return JSON.stringify({
gesendetAm: new Date().toISOString(),
belegungen: [],
aufgaben: [],
...teile,
})
}
test.describe('Spiegel', () => {
test.afterAll(async () => {
await db.spiegelbelegung.deleteMany({ where: { quelleRef: { startsWith: PRAEFIX } } })
await db.aufgabe.deleteMany({ where: { quelleRef: { startsWith: PRAEFIX } } })
await db.auditEintrag.deleteMany({ where: { aktion: 'spiegel.empfangen' } })
await db.$disconnect()
})
test('weist eine Sendung ohne Signatur ab', async ({ request }) => {
const antwort = await request.post('/api/spiegel', {
headers: { 'Content-Type': 'application/json' },
data: sendung(),
})
expect(antwort.status()).toBe(401)
})
test('weist eine falsch signierte Sendung ab', async ({ request }) => {
const koerper = sendung()
const antwort = await request.post('/api/spiegel', {
headers: {
'Content-Type': 'application/json',
'X-Planer-Signatur': signieren(koerper, 'das-ist-nicht-das-geheimnis'),
},
data: koerper,
})
expect(antwort.status()).toBe(401)
})
test('weist eine Sendung ab, die zu lange unterwegs war', async ({ request }) => {
test.skip(!GEHEIMNIS, 'Ohne SPIEGEL_GEHEIMNIS ist der Eingang ohnehin zu.')
// Eine Stunde alt: richtig signiert, trotzdem wertlos. Sonst ließe sich
// ein abgefangenes Paket beliebig oft erneut einspielen.
const koerper = sendung({
gesendetAm: new Date(Date.now() - 3_600_000).toISOString(),
})
const antwort = await request.post('/api/spiegel', {
headers: {
'Content-Type': 'application/json',
'X-Planer-Signatur': signieren(koerper),
},
data: koerper,
})
expect(antwort.status()).toBe(401)
})
test('nimmt eine gültige Sendung an und räumt Veraltetes weg', async ({ request }) => {
test.skip(
!GEHEIMNIS,
'Ohne SPIEGEL_GEHEIMNIS auf dem Testserver lässt sich das nicht prüfen.',
)
const beginn = new Date(Date.now() + 86_400_000)
const ende = new Date(beginn.getTime() + 3_600_000)
const erste = sendung({
belegungen: [
{
ref: `${PRAEFIX}-a`,
beginn: beginn.toISOString(),
ende: ende.toISOString(),
},
],
aufgaben: [{ ref: `${PRAEFIX}-1`, titel: `${PRAEFIX} Angebot schreiben` }],
})
const antwort = await request.post('/api/spiegel', {
headers: {
'Content-Type': 'application/json',
'X-Planer-Signatur': signieren(erste),
},
data: erste,
})
expect(antwort.ok()).toBe(true)
expect(await db.spiegelbelegung.count({ where: { quelleRef: `${PRAEFIX}-a` } })).toBe(
1,
)
const aufgabe = await db.aufgabe.findFirst({
where: { quelle: 'SPIEGEL', quelleRef: `${PRAEFIX}-1` },
select: { titel: true },
})
expect(aufgabe?.titel).toBe(`${PRAEFIX} Angebot schreiben`)
// Zweite Runde ohne die Belegung: Sie wurde drüben ausgeplant und muss
// auch hier verschwinden — sonst stünde für immer eine Sperre, die es
// längst nicht mehr gibt.
const zweite = sendung({ aufgaben: [] })
const nachher = await request.post('/api/spiegel', {
headers: {
'Content-Type': 'application/json',
'X-Planer-Signatur': signieren(zweite),
},
data: zweite,
})
expect(nachher.ok()).toBe(true)
expect(await db.spiegelbelegung.count({ where: { quelleRef: `${PRAEFIX}-a` } })).toBe(
0,
)
// Die Aufgabe bleibt: Sie ist Planung, keine Sperre.
expect(
await db.aufgabe.count({ where: { quelle: 'SPIEGEL', quelleRef: `${PRAEFIX}-1` } }),
).toBe(1)
})
})
+3
View File
@@ -1,5 +1,6 @@
import { defineConfig, devices } from '@playwright/test' import { defineConfig, devices } from '@playwright/test'
import 'dotenv/config' import 'dotenv/config'
import { SPIEGEL_GEHEIMNIS } from './e2e/hilfe'
const PORT = Number(process.env.E2E_PORT ?? 3100) const PORT = Number(process.env.E2E_PORT ?? 3100)
const BASIS = `http://127.0.0.1:${PORT}` const BASIS = `http://127.0.0.1:${PORT}`
@@ -41,6 +42,8 @@ export default defineConfig({
APP_URL: BASIS, APP_URL: BASIS,
WEBAUTHN_ORIGIN: BASIS, WEBAUTHN_ORIGIN: BASIS,
WEBAUTHN_RP_ID: '127.0.0.1', WEBAUTHN_RP_ID: '127.0.0.1',
// Ohne Geheimnis waere der Spiegel-Eingang zu und sein Test sinnlos.
SPIEGEL_GEHEIMNIS,
}, },
}, },
}) })
+83
View File
@@ -41,6 +41,9 @@ async function scheitert(name: string, tun: () => Promise<unknown>): Promise<voi
} }
const kennung = `zzz-test-${Date.now()}` const kennung = `zzz-test-${Date.now()}`
// Eigene Chatkennung je Lauf, damit das Aufraeumen genau diese Zeile trifft
// und nicht irgendeine, die zufaellig aehnlich aussieht.
const testChatId = BigInt(Date.now())
const testEmail = `${kennung}@example.invalid` const testEmail = `${kennung}@example.invalid`
async function aufraeumen(): Promise<void> { async function aufraeumen(): Promise<void> {
@@ -58,6 +61,12 @@ async function aufraeumen(): Promise<void> {
await db.webauthnChallenge.deleteMany({ where: { kennung } }) await db.webauthnChallenge.deleteMany({ where: { kennung } })
await db.pushGeraet.deleteMany({ where: { endpunkt: { contains: kennung } } }) await db.pushGeraet.deleteMany({ where: { endpunkt: { contains: kennung } } })
await db.meldungsnachweis.deleteMany({ where: { schluessel: { contains: kennung } } }) await db.meldungsnachweis.deleteMany({ where: { schluessel: { contains: kennung } } })
await db.spiegelbelegung.deleteMany({ where: { quelleRef: { contains: kennung } } })
await db.telegramKopplung.deleteMany({
where: { code: { contains: kennung.slice(-8) } },
})
await db.telegramChat.deleteMany({ where: { chatId: testChatId } })
await db.integrationszustand.deleteMany({ where: { quelle: 'TELEGRAM' } })
} }
async function main(): Promise<void> { async function main(): Promise<void> {
@@ -316,6 +325,76 @@ async function main(): Promise<void> {
).id.length > 0, ).id.length > 0,
) )
// --- Integrationen -------------------------------------------------------
// Der Zustand haengt an der Quelle: eine Zeile je Integration, nicht mehr.
await db.integrationszustand.create({
data: { quelle: 'TELEGRAM', merker: '42' },
})
await scheitert('Je Quelle nur ein Integrationszustand', () =>
db.integrationszustand.create({ data: { quelle: 'TELEGRAM' } }),
)
await db.integrationszustand.deleteMany({ where: { quelle: 'TELEGRAM' } })
// Zwei Aufgaben aus derselben Quelle mit derselben Kennung darf es nicht
// geben – sonst legte jeder Lauf dieselbe Sache erneut an.
const ausZammad = await db.aufgabe.create({
data: { titel: `${kennung}-ticket`, quelle: 'ZAMMAD', quelleRef: `${kennung}-1` },
})
pruefe('Aufgabe aus einer Integration anlegen', ausZammad.id.length > 0)
pruefe('Ohne Freigabe wird nicht gespiegelt', ausZammad.spiegeln === false)
await scheitert('Dieselbe Quelle mit derselben Kennung nur einmal (Integration)', () =>
db.aufgabe.create({
data: {
titel: `${kennung}-ticket-doppelt`,
quelle: 'ZAMMAD',
quelleRef: `${kennung}-1`,
},
}),
)
await db.spiegelbelegung.create({
data: {
quelleRef: `${kennung}-zb`,
beginn: new Date(Date.UTC(2026, 2, 17, 8, 0)),
ende: new Date(Date.UTC(2026, 2, 17, 9, 0)),
},
})
await scheitert('Dieselbe gespiegelte Belegung nur einmal', () =>
db.spiegelbelegung.create({
data: {
quelleRef: `${kennung}-zb`,
beginn: new Date(Date.UTC(2026, 2, 18, 8, 0)),
ende: new Date(Date.UTC(2026, 2, 18, 9, 0)),
},
}),
)
const chat = await db.telegramChat.create({
data: { chatId: testChatId, benutzerId: benutzer.id },
})
pruefe('Telegram-Chat empfaengt im Zweifel Meldungen', chat.meldungen === true)
await scheitert('Dieselbe Chatkennung nur einmal', () =>
db.telegramChat.create({ data: { chatId: chat.chatId } }),
)
await db.telegramKopplung.create({
data: {
code: `${kennung.slice(-8)}`,
benutzerId: benutzer.id,
gueltigBis: new Date(Date.now() + 600_000),
},
})
await scheitert('Denselben Kopplungscode nur einmal', () =>
db.telegramKopplung.create({
data: {
code: `${kennung.slice(-8)}`,
benutzerId: benutzer.id,
gueltigBis: new Date(Date.now() + 600_000),
},
}),
)
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 } })
@@ -341,6 +420,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(
'Telegram-Chats verschwinden nicht, verlieren aber den Benutzer',
(await db.telegramChat.count({ where: { benutzerId: benutzer.id } })) === 0,
)
pruefe( pruefe(
'Meldungsnachweise verschwinden mit dem Benutzer', 'Meldungsnachweise verschwinden mit dem Benutzer',
(await db.meldungsnachweis.count({ (await db.meldungsnachweis.count({