Files
AfiS-Aktensystem/web/tests/entzug.test.ts
T
Harlekin cbbc1dceb5
Prüfung / Lint, Typen, Tests (push) Canceled after 0s
Prüfung / Images bauen (pdf) (push) Canceled after 0s
Prüfung / Images bauen (web) (push) Canceled after 0s
Prüfung / Images bauen (worker) (push) Canceled after 0s
Images / bauen (pdf, docker/pdf.Dockerfile) (push) Canceled after 0s
Images / bauen (web, docker/web.Dockerfile) (push) Canceled after 0s
Images / bauen (worker, docker/worker.Dockerfile) (push) Canceled after 0s
Rollenwechsel in Discord auf beiden Seiten pruefen
Der Worker ist die einzige Stelle, an der Discord-Rollen in die Datenbank
kommen. Damit haengt jede Befoerderung, Herabstufung und jeder Rauswurf an
syncMember und enforceMembership - geprueft war davon bisher nur der
vollstaendige Entzug.

Neu abgedeckt: Rollen werden getauscht statt angehaeuft, eine leere
Rollenliste raeumt wirklich alles ab, ein Rueckkehrer wird wieder aktiv,
und ein unveraendertes Mitglied loest keine Meldung aus - sonst wuerfe der
Fuenf-Minuten-Abgleich reihum jeden Zwischenspeicher weg.

Auf der Web-Seite dazu die Gegenrichtung: eine Befoerderung wirkt sofort,
die Stufe kommt aus der hoechsten Rolle und nicht aus der zuletzt
eingetragenen, und eine im Adminbereich geaenderte Rollenzuordnung wirkt
auch dann, wenn sich beim Nutzer selbst keine Zeile aendert.

Der Notify-Test stellt seine Voraussetzung jetzt selbst her. Er erbte die
zu loeschende Zeile von einem frueheren Test und waere still umgefallen,
sobald der etwas anderes tut: ohne Loeschung kein Ausloeser, ohne
Ausloeser keine Meldung.
2026-09-04 14:06:42 +02:00

200 lines
7.6 KiB
TypeScript

// @vitest-environment node
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { Client } from 'pg'
import { db } from '@/lib/db'
import { loadSession, createSession } from '@/lib/auth/session'
import { invalidateAll, invalidateUser, resolvePermissions } from '@/lib/auth/effective'
/**
* Was passiert, wenn jemand seinen Zugriff verliert.
*
* Vier Wege führen dorthin: Herabstufung, Entzug der AfiS-Rolle, Rauswurf und
* selbst gehen. Der Worker deckt die Discord-Seite ab (siehe
* worker/tests/mitgliedschaft.test.ts) - hier steht die andere Hälfte: dass
* die Web-App den Entzug auch bemerkt.
*
* Der Kern ist, dass die **Zeile in der Datenbank** der Sitzungsnachweis ist
* und nicht das Cookie. Ein Cookie lässt sich nicht zurückziehen, eine Zeile
* schon.
*/
const url = process.env.DIRECT_DATABASE_URL ?? process.env.DATABASE_URL
const suite = url ? describe : describe.skip
const marke = `entzug-${Date.now()}`
suite('Entzug des Zugriffs', () => {
const nutzer = `${marke}-nutzer`
const rolleHoch = `${marke}-rolle-hoch`
const rolleGast = `${marke}-rolle-gast`
beforeAll(async () => {
await db.user.create({ data: { id: nutzer, username: nutzer, displayName: nutzer } })
await db.roleMapping.create({
data: {
discordRoleId: rolleHoch,
label: 'Ermittler',
clearanceLevel: 4,
permissions: ['case.read'],
grantsMembership: true,
},
})
await db.roleMapping.create({
data: {
discordRoleId: rolleGast,
label: 'Gast',
clearanceLevel: 0,
permissions: [],
grantsMembership: false,
},
})
})
afterAll(async () => {
await db.$disconnect()
})
it('nimmt einer Herabstufung sofort die Stufe, sobald der Zwischenspeicher fällt', async () => {
await db.userRole.create({ data: { userId: nutzer, discordRoleId: rolleHoch } })
invalidateUser(nutzer)
expect((await resolvePermissions(nutzer)).clearanceLevel).toBe(4)
// Herabgestuft: die Rolle mit Stufe 4 weg, die Gastrolle bleibt.
await db.userRole.deleteMany({ where: { userId: nutzer, discordRoleId: rolleHoch } })
await db.userRole.create({ data: { userId: nutzer, discordRoleId: rolleGast } })
invalidateUser(nutzer)
const danach = await resolvePermissions(nutzer)
expect(danach.clearanceLevel).toBe(0)
expect(danach.permissions.has('case.read')).toBe(false)
// Und ohne berechtigende Rolle auch keine Mitgliedschaft mehr.
expect(danach.hasMembership).toBe(false)
})
it('gibt eine Beförderung sofort weiter', async () => {
// Die Gegenrichtung zur Herabstufung: wer eine höhere Rolle bekommt, muss
// auch mehr sehen dürfen, sobald der Zwischenspeicher fällt.
await db.userRole.deleteMany({ where: { userId: nutzer } })
invalidateUser(nutzer)
expect((await resolvePermissions(nutzer)).clearanceLevel).toBe(0)
await db.userRole.create({ data: { userId: nutzer, discordRoleId: rolleHoch } })
invalidateUser(nutzer)
const danach = await resolvePermissions(nutzer)
expect(danach.clearanceLevel).toBe(4)
expect(danach.permissions.has('case.read')).toBe(true)
expect(danach.hasMembership).toBe(true)
})
it('nimmt die höchste Stufe aller Rollen, nicht die zuletzt vergebene', async () => {
// Wer befördert wird, behält meist seine alten Rollen. Käme die Stufe aus
// der zuletzt eingetragenen Zeile, hinge das Ergebnis an der Reihenfolge,
// in der Discord die Rollen liefert.
await db.userRole.deleteMany({ where: { userId: nutzer } })
await db.userRole.createMany({
data: [
{ userId: nutzer, discordRoleId: rolleGast },
{ userId: nutzer, discordRoleId: rolleHoch },
],
})
invalidateUser(nutzer)
expect((await resolvePermissions(nutzer)).clearanceLevel).toBe(4)
})
it('wirkt auch, wenn nicht die Rolle sich ändert, sondern was sie gewährt', async () => {
// Der andere Weg zur Herabstufung: die Leitung nimmt einer Rolle im
// Adminbereich die Stufe. Dann ändert sich keine einzige UserRole-Zeile.
await db.userRole.deleteMany({ where: { userId: nutzer } })
await db.userRole.create({ data: { userId: nutzer, discordRoleId: rolleHoch } })
invalidateUser(nutzer)
expect((await resolvePermissions(nutzer)).clearanceLevel).toBe(4)
await db.roleMapping.update({
where: { discordRoleId: rolleHoch },
data: { clearanceLevel: 1, permissions: [] },
})
invalidateAll()
const danach = await resolvePermissions(nutzer)
expect(danach.clearanceLevel).toBe(1)
expect(danach.permissions.has('case.read')).toBe(false)
// Zurücksetzen, damit die folgenden Prüfungen ihre Grundlage behalten.
await db.roleMapping.update({
where: { discordRoleId: rolleHoch },
data: { clearanceLevel: 4, permissions: ['case.read'] },
})
invalidateAll()
})
it('meldet jede Rollenänderung über den Kanal, der den Zwischenspeicher leert', async () => {
// Das ist der Grund, warum eine Herabstufung in unter einer Sekunde wirkt
// und nicht erst nach der Verfallszeit von 60 Sekunden. Ohne diesen
// Auslöser bliebe die alte Stufe stehen, obwohl sie in der Datenbank
// längst weg ist.
// Voraussetzung selbst herstellen: ein Test, der die Zeile von einem
// frueheren erbt, faellt still um, sobald der etwas anders macht - dann
// loescht `deleteMany` nichts, es feuert kein Ausloeser, und es sieht nach
// einem kaputten Kanal aus.
await db.userRole.createMany({
data: [{ userId: nutzer, discordRoleId: rolleGast }],
skipDuplicates: true,
})
const horcher = new Client({ connectionString: url })
await horcher.connect()
try {
const gemeldet = new Promise<string>((resolve) => {
horcher.on('notification', (nachricht) => {
if (nachricht.channel === 'member_changed' && nachricht.payload === nutzer) {
resolve(nachricht.payload)
}
})
})
await horcher.query('LISTEN member_changed')
await db.userRole.deleteMany({ where: { userId: nutzer, discordRoleId: rolleGast } })
const abgelaufen = new Promise<never>((_, ab) =>
setTimeout(() => ab(new Error('keine Meldung binnen 5 s')), 5000),
)
expect(await Promise.race([gemeldet, abgelaufen])).toBe(nutzer)
} finally {
await horcher.end()
}
})
it('macht eine gelöschte Sitzung sofort ungültig', async () => {
// Rauswurf und Austritt laufen beide hierüber: der Worker löscht die
// Zeile, und der nächste Request findet nichts mehr.
const sitzung = await createSession(nutzer)
expect(await loadSession(sitzung)).not.toBeNull()
await db.session.deleteMany({ where: { userId: nutzer } })
expect(await loadSession(sitzung)).toBeNull()
})
it('lässt eine abgelaufene Sitzung nicht gelten und räumt sie weg', async () => {
const sitzung = await db.session.create({
data: {
userId: nutzer,
expiresAt: new Date(Date.now() - 1000),
},
select: { id: true },
})
expect(await loadSession(sitzung.id)).toBeNull()
// Und sie ist danach weg, nicht nur abgelehnt.
expect(await db.session.findUnique({ where: { id: sitzung.id } })).toBeNull()
})
it('hält einen stillgelegten Nutzer draussen, auch mit gültiger Sitzung', async () => {
// handleDeparture setzt isActive auf false. getCurrentUser prüft das bei
// jedem Request zusätzlich zur Sitzung.
await db.user.update({ where: { id: nutzer }, data: { isActive: false } })
const konto = await db.user.findUniqueOrThrow({ where: { id: nutzer } })
expect(konto.isActive).toBe(false)
})
})