From 4da2f3d9c6a92ed620948f0e3eac1fcd32aa7b68 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Fri, 11 Sep 2026 23:58:43 +0200 Subject: [PATCH] test: Spiegel-Eingang und Integrationstabellen absichern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- e2e/hilfe.ts | 10 +++ e2e/spiegel.spec.ts | 138 ++++++++++++++++++++++++++++++++++++ playwright.config.ts | 3 + scripts/integrationstest.ts | 83 ++++++++++++++++++++++ 4 files changed, 234 insertions(+) create mode 100644 e2e/spiegel.spec.ts diff --git a/e2e/hilfe.ts b/e2e/hilfe.ts index d5b601b..965a681 100644 --- a/e2e/hilfe.ts +++ b/e2e/hilfe.ts @@ -6,6 +6,16 @@ export const EMAIL = process.env.ADMIN_EMAIL ?? '' export const PASSWORT = process.env.ADMIN_PASSWORT ?? '' 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. * diff --git a/e2e/spiegel.spec.ts b/e2e/spiegel.spec.ts new file mode 100644 index 0000000..c000580 --- /dev/null +++ b/e2e/spiegel.spec.ts @@ -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 { + 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) + }) +}) diff --git a/playwright.config.ts b/playwright.config.ts index e7de6a3..6cf733a 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,5 +1,6 @@ import { defineConfig, devices } from '@playwright/test' import 'dotenv/config' +import { SPIEGEL_GEHEIMNIS } from './e2e/hilfe' const PORT = Number(process.env.E2E_PORT ?? 3100) const BASIS = `http://127.0.0.1:${PORT}` @@ -41,6 +42,8 @@ export default defineConfig({ APP_URL: BASIS, WEBAUTHN_ORIGIN: BASIS, WEBAUTHN_RP_ID: '127.0.0.1', + // Ohne Geheimnis waere der Spiegel-Eingang zu und sein Test sinnlos. + SPIEGEL_GEHEIMNIS, }, }, }) diff --git a/scripts/integrationstest.ts b/scripts/integrationstest.ts index 58b4cd6..889f400 100644 --- a/scripts/integrationstest.ts +++ b/scripts/integrationstest.ts @@ -41,6 +41,9 @@ async function scheitert(name: string, tun: () => Promise): Promise { @@ -58,6 +61,12 @@ async function aufraeumen(): Promise { await db.webauthnChallenge.deleteMany({ where: { kennung } }) await db.pushGeraet.deleteMany({ where: { endpunkt: { 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 { @@ -316,6 +325,76 @@ async function main(): Promise { ).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 } }) const nachher = await db.auditEintrag.findUnique({ where: { id: audit.id } }) @@ -341,6 +420,10 @@ async function main(): Promise { 'Push-Geräte verschwinden mit dem Benutzer', (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( 'Meldungsnachweise verschwinden mit dem Benutzer', (await db.meldungsnachweis.count({