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:
co-authored by
Claude Opus 5
parent
0d0f8503cc
commit
4da2f3d9c6
@@ -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.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
Reference in New Issue
Block a user