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) }) })