Rollenwechsel in Discord auf beiden Seiten pruefen
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
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
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.
This commit is contained in:
@@ -3,7 +3,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
|||||||
import { Client } from 'pg'
|
import { Client } from 'pg'
|
||||||
import { db } from '@/lib/db'
|
import { db } from '@/lib/db'
|
||||||
import { loadSession, createSession } from '@/lib/auth/session'
|
import { loadSession, createSession } from '@/lib/auth/session'
|
||||||
import { invalidateUser, resolvePermissions } from '@/lib/auth/effective'
|
import { invalidateAll, invalidateUser, resolvePermissions } from '@/lib/auth/effective'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Was passiert, wenn jemand seinen Zugriff verliert.
|
* Was passiert, wenn jemand seinen Zugriff verliert.
|
||||||
@@ -70,11 +70,78 @@ suite('Entzug des Zugriffs', () => {
|
|||||||
expect(danach.hasMembership).toBe(false)
|
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 () => {
|
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
|
// Das ist der Grund, warum eine Herabstufung in unter einer Sekunde wirkt
|
||||||
// und nicht erst nach der Verfallszeit von 60 Sekunden. Ohne diesen
|
// und nicht erst nach der Verfallszeit von 60 Sekunden. Ohne diesen
|
||||||
// Auslöser bliebe die alte Stufe stehen, obwohl sie in der Datenbank
|
// Auslöser bliebe die alte Stufe stehen, obwohl sie in der Datenbank
|
||||||
// längst weg ist.
|
// 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 })
|
const horcher = new Client({ connectionString: url })
|
||||||
await horcher.connect()
|
await horcher.connect()
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -0,0 +1,224 @@
|
|||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||||
|
import { createPrismaClient, type PrismaClient } from '@afis/db'
|
||||||
|
import type { GuildMember } from 'discord.js'
|
||||||
|
import { enforceMembership, handleDeparture, syncMember } from '../src/sync.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Was passiert, wenn sich die Discord-Rollen eines Nutzers aendern.
|
||||||
|
*
|
||||||
|
* Der Worker ist die einzige Stelle, an der Rollen in die Datenbank kommen -
|
||||||
|
* die Web-Anwendung fragt nie live bei Discord an. Damit haengt jede
|
||||||
|
* Rechteaenderung an diesen beiden Funktionen, und zwar in beide Richtungen:
|
||||||
|
* eine Befoerderung muss ankommen, eine Herabstufung muss ankommen, und ein
|
||||||
|
* Rauswurf muss die Sitzungen beenden.
|
||||||
|
*
|
||||||
|
* Geprueft wird mit einem nachgebauten `GuildMember`, weil nur gelesen wird,
|
||||||
|
* was hier drinsteht. Der Umweg ueber ein echtes Gateway wuerde nichts
|
||||||
|
* beweisen, was diese Aufrufe nicht auch zeigen.
|
||||||
|
*/
|
||||||
|
const url = process.env.DIRECT_DATABASE_URL ?? process.env.DATABASE_URL
|
||||||
|
const suite = url ? describe : describe.skip
|
||||||
|
|
||||||
|
let db: PrismaClient
|
||||||
|
const marke = `wechsel-${Date.now()}`
|
||||||
|
|
||||||
|
const GUILD = `${marke}-guild`
|
||||||
|
const rolleAfis = `${marke}-afis`
|
||||||
|
const rolleGast = `${marke}-gast`
|
||||||
|
const rolleLeitung = `${marke}-leitung`
|
||||||
|
|
||||||
|
/** Das Wenige, das `syncMember` von einem GuildMember liest. */
|
||||||
|
function mitglied(id: string, rollen: string[], name = id): GuildMember {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
guild: { id: GUILD },
|
||||||
|
nickname: name,
|
||||||
|
roles: { cache: new Map(rollen.map((r) => [r, {}])) },
|
||||||
|
user: { username: id, globalName: name, avatar: null, bot: false },
|
||||||
|
} as unknown as GuildMember
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rollenVon(userId: string): Promise<string[]> {
|
||||||
|
const zeilen = await db.userRole.findMany({
|
||||||
|
where: { userId },
|
||||||
|
select: { discordRoleId: true },
|
||||||
|
})
|
||||||
|
return zeilen.map((z) => z.discordRoleId).sort()
|
||||||
|
}
|
||||||
|
|
||||||
|
suite('Rollenwechsel in Discord', () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
db = createPrismaClient(url)
|
||||||
|
await db.roleMapping.createMany({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
discordRoleId: rolleAfis,
|
||||||
|
label: 'AfiS',
|
||||||
|
category: 'MEMBERSHIP',
|
||||||
|
grantsMembership: true,
|
||||||
|
clearanceLevel: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
discordRoleId: rolleLeitung,
|
||||||
|
label: 'Leitung',
|
||||||
|
category: 'CLEARANCE',
|
||||||
|
grantsMembership: true,
|
||||||
|
clearanceLevel: 4,
|
||||||
|
permissions: ['admin'],
|
||||||
|
},
|
||||||
|
{ discordRoleId: rolleGast, label: 'Gast', category: 'UNSORTED' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await db.$disconnect()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('legt einen neuen Nutzer mit seinen Rollen an', async () => {
|
||||||
|
const id = `${marke}-neu`
|
||||||
|
expect(await syncMember(db, mitglied(id, [rolleAfis]))).toBe(true)
|
||||||
|
|
||||||
|
expect(await rollenVon(id)).toEqual([rolleAfis])
|
||||||
|
const nutzer = await db.user.findUniqueOrThrow({ where: { id } })
|
||||||
|
expect(nutzer.isActive).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('traegt eine Befoerderung nach', async () => {
|
||||||
|
const id = `${marke}-befoerdert`
|
||||||
|
await syncMember(db, mitglied(id, [rolleAfis]))
|
||||||
|
|
||||||
|
// Leitungsrolle kommt dazu.
|
||||||
|
expect(await syncMember(db, mitglied(id, [rolleAfis, rolleLeitung]))).toBe(true)
|
||||||
|
expect(await rollenVon(id)).toEqual([rolleAfis, rolleLeitung].sort())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('traegt eine Herabstufung nach und laesst die Sitzung stehen', async () => {
|
||||||
|
const id = `${marke}-herab`
|
||||||
|
await syncMember(db, mitglied(id, [rolleAfis, rolleLeitung]))
|
||||||
|
await db.session.create({
|
||||||
|
data: { userId: id, expiresAt: new Date(Date.now() + 86_400_000) },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Leitungsrolle weg, AfiS bleibt: er darf weiter herein, nur weniger sehen.
|
||||||
|
await syncMember(db, mitglied(id, [rolleAfis]))
|
||||||
|
await enforceMembership(db, id)
|
||||||
|
|
||||||
|
expect(await rollenVon(id)).toEqual([rolleAfis])
|
||||||
|
expect(await db.session.count({ where: { userId: id } })).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('raeumt alle Rollen ab, wenn jemand gar keine mehr traegt', async () => {
|
||||||
|
// Der Fall, der bei einer Loeschbedingung ueber eine leere Liste leicht
|
||||||
|
// danebengeht: bleibt dann alles stehen, behielte ein Ausgeschlossener
|
||||||
|
// seine Rechte.
|
||||||
|
const id = `${marke}-leer`
|
||||||
|
await syncMember(db, mitglied(id, [rolleAfis, rolleLeitung, rolleGast]))
|
||||||
|
expect(await rollenVon(id)).toHaveLength(3)
|
||||||
|
|
||||||
|
await syncMember(db, mitglied(id, []))
|
||||||
|
expect(await rollenVon(id)).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('tauscht Rollen aus, statt sie anzuhaeufen', async () => {
|
||||||
|
const id = `${marke}-tausch`
|
||||||
|
await syncMember(db, mitglied(id, [rolleAfis]))
|
||||||
|
await syncMember(db, mitglied(id, [rolleLeitung]))
|
||||||
|
|
||||||
|
// Die alte Rolle ist weg, nicht bloss die neue dazugekommen.
|
||||||
|
expect(await rollenVon(id)).toEqual([rolleLeitung])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('beendet die Sitzungen, sobald die letzte Zugangsrolle faellt', async () => {
|
||||||
|
const id = `${marke}-raus`
|
||||||
|
await syncMember(db, mitglied(id, [rolleAfis]))
|
||||||
|
await db.session.createMany({
|
||||||
|
data: [
|
||||||
|
{ userId: id, expiresAt: new Date(Date.now() + 86_400_000) },
|
||||||
|
{ userId: id, expiresAt: new Date(Date.now() + 86_400_000) },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
// Nur noch Gast: keine Rolle mehr, die zur Anmeldung berechtigt.
|
||||||
|
await syncMember(db, mitglied(id, [rolleGast]))
|
||||||
|
expect(await enforceMembership(db, id)).toBe(true)
|
||||||
|
|
||||||
|
expect(await db.session.count({ where: { userId: id } })).toBe(0)
|
||||||
|
const nutzer = await db.user.findUniqueOrThrow({ where: { id } })
|
||||||
|
expect(nutzer.isActive).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('nimmt einen wieder auf, der seine Rolle zurueckbekommt', async () => {
|
||||||
|
const id = `${marke}-zurueck`
|
||||||
|
await syncMember(db, mitglied(id, [rolleAfis]))
|
||||||
|
await syncMember(db, mitglied(id, [rolleGast]))
|
||||||
|
await enforceMembership(db, id)
|
||||||
|
expect((await db.user.findUniqueOrThrow({ where: { id } })).isActive).toBe(false)
|
||||||
|
|
||||||
|
// Rolle zurueck: der Datensatz muss wieder aktiv werden, sonst kaeme er
|
||||||
|
// nie mehr herein, obwohl Discord ihn laengst wieder fuehrt.
|
||||||
|
await syncMember(db, mitglied(id, [rolleAfis]))
|
||||||
|
await enforceMembership(db, id)
|
||||||
|
|
||||||
|
expect((await db.user.findUniqueOrThrow({ where: { id } })).isActive).toBe(true)
|
||||||
|
expect(await rollenVon(id)).toEqual([rolleAfis])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('beendet beim Serveraustritt die Sitzungen und raeumt die Rollen ab', async () => {
|
||||||
|
const id = `${marke}-austritt`
|
||||||
|
await syncMember(db, mitglied(id, [rolleAfis, rolleLeitung]))
|
||||||
|
await db.session.create({
|
||||||
|
data: { userId: id, expiresAt: new Date(Date.now() + 86_400_000) },
|
||||||
|
})
|
||||||
|
|
||||||
|
await handleDeparture(db, id)
|
||||||
|
|
||||||
|
expect(await db.session.count({ where: { userId: id } })).toBe(0)
|
||||||
|
expect(await rollenVon(id)).toEqual([])
|
||||||
|
// Der Datensatz bleibt: Autorenangaben in alten Akten muessen gueltig bleiben.
|
||||||
|
const nutzer = await db.user.findUniqueOrThrow({ where: { id } })
|
||||||
|
expect(nutzer.isActive).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('nimmt einen Rueckkehrer nach dem Rauswurf wieder auf', async () => {
|
||||||
|
const id = `${marke}-rueckkehr`
|
||||||
|
await syncMember(db, mitglied(id, [rolleAfis]))
|
||||||
|
await handleDeparture(db, id)
|
||||||
|
|
||||||
|
await syncMember(db, mitglied(id, [rolleAfis]))
|
||||||
|
await enforceMembership(db, id)
|
||||||
|
|
||||||
|
expect((await db.user.findUniqueOrThrow({ where: { id } })).isActive).toBe(true)
|
||||||
|
expect(await rollenVon(id)).toEqual([rolleAfis])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('schreibt nur, wenn sich wirklich etwas geaendert hat', async () => {
|
||||||
|
// Der Voll-Abgleich laeuft alle fuenf Minuten ueber jedes Mitglied. Wuerde
|
||||||
|
// syncMember dabei unbesehen schreiben, loeste jede Runde fuer jeden
|
||||||
|
// Nutzer eine Meldung aus und die Web-Anwendung wuerfe ihre
|
||||||
|
// zwischengespeicherten Rechte alle fuenf Minuten weg.
|
||||||
|
const id = `${marke}-unveraendert`
|
||||||
|
expect(await syncMember(db, mitglied(id, [rolleAfis]))).toBe(true)
|
||||||
|
expect(await syncMember(db, mitglied(id, [rolleAfis]))).toBe(false)
|
||||||
|
|
||||||
|
// Eine echte Aenderung meldet sich weiterhin.
|
||||||
|
expect(await syncMember(db, mitglied(id, [rolleAfis, rolleGast]))).toBe(true)
|
||||||
|
|
||||||
|
// Auch ein neuer Anzeigename ist eine Aenderung - er steht in Akten.
|
||||||
|
expect(await syncMember(db, mitglied(id, [rolleAfis, rolleGast], 'Neuer Name'))).toBe(true)
|
||||||
|
const nutzer = await db.user.findUniqueOrThrow({ where: { id } })
|
||||||
|
expect(nutzer.displayName).toBe('Neuer Name')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('schont den Notfallzugang bei jedem dieser Wege', async () => {
|
||||||
|
const id = `${marke}-notfall`
|
||||||
|
await syncMember(db, mitglied(id, [rolleGast]))
|
||||||
|
await db.session.create({
|
||||||
|
data: { userId: id, expiresAt: new Date(Date.now() + 86_400_000) },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Ohne Zugangsrolle - aber als Notfallzugang eingetragen.
|
||||||
|
expect(await enforceMembership(db, id, [id])).toBe(false)
|
||||||
|
expect(await db.session.count({ where: { userId: id } })).toBe(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user