diff --git a/web/tests/entzug.test.ts b/web/tests/entzug.test.ts index 98e8d81..e149c4f 100644 --- a/web/tests/entzug.test.ts +++ b/web/tests/entzug.test.ts @@ -3,7 +3,7 @@ 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 { invalidateUser, resolvePermissions } from '@/lib/auth/effective' +import { invalidateAll, invalidateUser, resolvePermissions } from '@/lib/auth/effective' /** * Was passiert, wenn jemand seinen Zugriff verliert. @@ -70,11 +70,78 @@ suite('Entzug des Zugriffs', () => { 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 { diff --git a/worker/tests/rollenwechsel.test.ts b/worker/tests/rollenwechsel.test.ts new file mode 100644 index 0000000..90ffbce --- /dev/null +++ b/worker/tests/rollenwechsel.test.ts @@ -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 { + 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) + }) +})