// @vitest-environment node import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { db } from '@/lib/db' import { invalidateAll, resolvePermissions } from '@/lib/auth/effective' import { withInherited, resolveAll, highestRungPerLadder } from '@/lib/auth/ladders' import { createLadder, deleteLadder, listLadders, setLadderRoles } from '@/lib/roles/ladders' import type { EffectivePermissions } from '@/lib/auth/permissions' /** * Ranggefüge: Rollen, die aufeinander aufbauen. * * Zwei Hälften, beide hier: die reine Rechnung, und dass sie auch dort * ankommt, wo über Zugriff entschieden wird. Die zweite Hälfte ist die * wichtigere - eine Vererbung, die nur in der Vorschau stimmt, wäre schlimmer * als gar keine. */ const url = process.env.DIRECT_DATABASE_URL ?? process.env.DATABASE_URL const suite = url ? describe : describe.skip const marke = `gefuege-${Date.now()}` function rung( id: string, rang: number | null, leiter: string | null, rechte: string[], stufe = 0, tuer = false, ) { return { discordRoleId: id, clearanceLevel: stufe, permissions: rechte, grantsMembership: tuer, ladderId: leiter, ladderRank: rang, } } describe('Die Rechnung selbst', () => { it('nimmt alles von den Sprossen darunter', () => { const unten = rung('u', 0, 'L', ['case.read'], 1, true) const mitte = rung('m', 1, 'L', ['case.create'], 2) const oben = rung('o', 2, 'L', ['case.close'], 0) const ergebnis = withInherited(oben, [unten, mitte, oben]) expect([...ergebnis.permissions].sort()).toEqual([ 'case.close', 'case.create', 'case.read', ]) // Eine Beförderung darf keine Herabstufung sein: die Stufe der oberen // Sprosse steht auf 0, wirksam sind trotzdem 2. expect(ergebnis.clearanceLevel).toBe(2) expect(ergebnis.ownClearanceLevel).toBe(0) expect(ergebnis.grantsMembership).toBe(true) }) it('vererbt nur nach oben, nie nach unten', () => { const unten = rung('u', 0, 'L', ['case.read']) const oben = rung('o', 1, 'L', ['admin'], 5) const ergebnis = withInherited(unten, [unten, oben]) expect([...ergebnis.permissions]).toEqual(['case.read']) expect(ergebnis.clearanceLevel).toBe(0) expect(ergebnis.inherited.size).toBe(0) }) it('hält zwei Gefüge auseinander', () => { const eins = rung('a', 0, 'L1', ['case.read'], 3) const zwei = rung('b', 5, 'L2', ['admin'], 5) const ergebnis = withInherited(zwei, [eins, zwei]) expect(ergebnis.permissions.has('case.read')).toBe(false) expect(ergebnis.clearanceLevel).toBe(5) }) it('lässt eine Rolle ohne Gefüge unberührt', () => { const frei = rung('f', null, null, ['case.read'], 1) const andere = rung('x', 0, 'L', ['admin'], 5) const ergebnis = withInherited(frei, [frei, andere]) expect([...ergebnis.permissions]).toEqual(['case.read']) expect(ergebnis.clearanceLevel).toBe(1) }) it('zählt ein Recht nicht als geerbt, das die Rolle selbst trägt', () => { // Für die Anzeige: das Kästchen muss abwählbar bleiben, sonst kann man // seine eigene Vergabe nicht mehr zurücknehmen. const unten = rung('u', 0, 'L', ['case.read']) const oben = rung('o', 1, 'L', ['case.read', 'case.close']) const ergebnis = withInherited(oben, [unten, oben]) expect([...ergebnis.inherited]).toEqual([]) expect(ergebnis.permissions.has('case.read')).toBe(true) }) it('verwirft unbekannte Rechte, auch geerbte', () => { // Die Spalte ist ein String[]; was dort steht, ist nicht automatisch ein // gültiges Recht. Eine alte Zeile darf keinen Zugriff öffnen. const unten = rung('u', 0, 'L', ['case.read', 'erfunden', '__proto__']) const oben = rung('o', 1, 'L', []) const ergebnis = withInherited(oben, [unten, oben]) expect([...ergebnis.permissions]).toEqual(['case.read']) }) it('kommt mit gleichem Rang zurecht, ohne sich aufzuhängen', () => { // Doppelte Ränge sind erlaubt, die Datenbank verbietet sie nicht. Sie erben // dann gegenseitig - endlich, weil nicht rekursiv gerechnet wird. const a = rung('a', 1, 'L', ['case.read']) const b = rung('b', 1, 'L', ['case.close']) expect([...resolveAll([a, b]).get('a')!.permissions].sort()).toEqual([ 'case.close', 'case.read', ]) }) it('findet je Gefüge die höchste getragene Sprosse', () => { const hoechste = highestRungPerLadder([ { ladderId: 'L', ladderRank: 0 }, { ladderId: 'L', ladderRank: 3 }, { ladderId: 'L', ladderRank: 1 }, { ladderId: 'M', ladderRank: 2 }, { ladderId: null, ladderRank: null }, ]) expect(hoechste.get('L')).toBe(3) expect(hoechste.get('M')).toBe(2) expect(hoechste.size).toBe(2) }) }) suite('Ranggefüge wirken auf den Zugriff', () => { const nutzer = `${marke}-nutzer` const unten = `${marke}-rolle-unten` const oben = `${marke}-rolle-oben` const frei = `${marke}-rolle-frei` let leiter = '' const admin: EffectivePermissions = { userId: `${marke}-admin`, clearanceLevel: 5, permissions: new Set(['roles.manage']), isAdmin: true, hasMembership: true, } beforeAll(async () => { await db.user.createMany({ data: [ { id: nutzer, username: nutzer, displayName: nutzer }, { id: admin.userId, username: admin.userId, displayName: admin.userId }, ], }) await db.roleMapping.createMany({ data: [ { discordRoleId: unten, label: 'Ermittler', clearanceLevel: 3, permissions: ['case.read', 'entry.create'], grantsMembership: true, }, { discordRoleId: oben, label: 'Leitender Ermittler', clearanceLevel: 0, permissions: ['case.close'], grantsMembership: false, }, { discordRoleId: frei, label: 'Ohne Gefüge', clearanceLevel: 1, permissions: ['personnel.read'], }, ], }) leiter = await createLadder(admin, { name: `${marke}-Dienstgrade` }) await setLadderRoles(admin, { id: leiter, roleIds: [unten, oben] }) }) afterAll(async () => { await db.auditLog.deleteMany({ where: { actorId: admin.userId } }) await db.userRole.deleteMany({ where: { userId: nutzer } }) await db.roleMapping.deleteMany({ where: { discordRoleId: { in: [unten, oben, frei] } } }) await db.roleLadder.deleteMany({ where: { id: leiter } }) await db.user.deleteMany({ where: { id: { in: [nutzer, admin.userId] } } }) }) it('gibt der oberen Sprosse alles der unteren, auch ohne die untere Rolle', async () => { // Der Fall, für den das Ganze da ist: beim Befördern nimmt Discord die // alte Rangrolle ab. Der Nutzer trägt nur noch die obere. await db.userRole.deleteMany({ where: { userId: nutzer } }) await db.userRole.create({ data: { userId: nutzer, discordRoleId: oben } }) invalidateAll() const rechte = await resolvePermissions(nutzer) expect(rechte.permissions.has('case.close')).toBe(true) expect(rechte.permissions.has('case.read')).toBe(true) expect(rechte.permissions.has('entry.create')).toBe(true) // Stufe und Türsteher kommen ebenfalls von unten. expect(rechte.clearanceLevel).toBe(3) expect(rechte.hasMembership).toBe(true) }) it('gibt der unteren Sprosse nichts von oben', async () => { // Der negative Fall. Ohne ihn wäre jede Leiter eine Rechteausweitung für // alle, die darin stehen. await db.userRole.deleteMany({ where: { userId: nutzer } }) await db.userRole.create({ data: { userId: nutzer, discordRoleId: unten } }) invalidateAll() const rechte = await resolvePermissions(nutzer) expect(rechte.permissions.has('case.read')).toBe(true) expect(rechte.permissions.has('case.close')).toBe(false) }) it('lässt eine Rolle ausserhalb jedes Gefüges unberührt', async () => { await db.userRole.deleteMany({ where: { userId: nutzer } }) await db.userRole.create({ data: { userId: nutzer, discordRoleId: frei } }) invalidateAll() const rechte = await resolvePermissions(nutzer) expect([...rechte.permissions]).toEqual(['personnel.read']) expect(rechte.clearanceLevel).toBe(1) expect(rechte.hasMembership).toBe(false) }) it('nimmt das Geerbte wieder weg, sobald die Reihenfolge sich dreht', async () => { await db.userRole.deleteMany({ where: { userId: nutzer } }) await db.userRole.create({ data: { userId: nutzer, discordRoleId: oben } }) invalidateAll() expect((await resolvePermissions(nutzer)).permissions.has('case.read')).toBe(true) // Umdrehen: die vorher obere Sprosse steht jetzt unten. await setLadderRoles(admin, { id: leiter, roleIds: [oben, unten] }) invalidateAll() const rechte = await resolvePermissions(nutzer) expect(rechte.permissions.has('case.read')).toBe(false) expect(rechte.clearanceLevel).toBe(0) await setLadderRoles(admin, { id: leiter, roleIds: [unten, oben] }) invalidateAll() }) it('nimmt das Geerbte weg, wenn das Gefüge aufgelöst wird', async () => { const eigene = await createLadder(admin, { name: `${marke}-kurz` }) await setLadderRoles(admin, { id: eigene, roleIds: [unten, oben] }) await db.userRole.deleteMany({ where: { userId: nutzer } }) await db.userRole.create({ data: { userId: nutzer, discordRoleId: oben } }) invalidateAll() expect((await resolvePermissions(nutzer)).permissions.has('case.read')).toBe(true) await deleteLadder(admin, eigene) invalidateAll() const rechte = await resolvePermissions(nutzer) expect(rechte.permissions.has('case.read')).toBe(false) // Die eigenen Rechte bleiben, nur das Geerbte fällt weg. expect(rechte.permissions.has('case.close')).toBe(true) // Die Rollen selbst gibt es noch. expect(await db.roleMapping.count({ where: { discordRoleId: { in: [unten, oben] } } })).toBe(2) await setLadderRoles(admin, { id: leiter, roleIds: [unten, oben] }) }) it('lässt eine Rolle in höchstens einem Gefüge stehen', async () => { const zweites = await createLadder(admin, { name: `${marke}-zweitens` }) await setLadderRoles(admin, { id: zweites, roleIds: [oben] }) const rolle = await db.roleMapping.findUniqueOrThrow({ where: { discordRoleId: oben } }) expect(rolle.ladderId).toBe(zweites) const alt = await listLadders(admin) expect(alt.find((l) => l.id === leiter)?.roles.map((r) => r.discordRoleId)).toEqual([unten]) await deleteLadder(admin, zweites) await setLadderRoles(admin, { id: leiter, roleIds: [unten, oben] }) }) it('weist jemanden ohne roles.manage ab', async () => { const ohne: EffectivePermissions = { userId: nutzer, clearanceLevel: 5, permissions: new Set(['case.read']), isAdmin: false, hasMembership: true, } await expect(createLadder(ohne, { name: `${marke}-verboten` })).rejects.toThrow() await expect(setLadderRoles(ohne, { id: leiter, roleIds: [] })).rejects.toThrow() await expect(deleteLadder(ohne, leiter)).rejects.toThrow() await expect(listLadders(ohne)).rejects.toThrow() expect(await db.roleLadder.count({ where: { name: `${marke}-verboten` } })).toBe(0) }) it('nimmt keinen zweiten Namen doppelt und keine erfundene Rolle', async () => { await expect(createLadder(admin, { name: `${marke}-Dienstgrade` })).rejects.toThrow() await expect(createLadder(admin, { name: ' ' })).rejects.toThrow() await expect( setLadderRoles(admin, { id: leiter, roleIds: [unten, 'gibt-es-nicht'] }), ).rejects.toThrow() // Nach dem abgewiesenen Versuch steht die Leiter unverändert da. const stand = await listLadders(admin) expect(stand.find((l) => l.id === leiter)?.roles.map((r) => r.discordRoleId)).toEqual([ unten, oben, ]) }) it('schreibt jede Änderung ins Protokoll', async () => { const eintraege = await db.auditLog.findMany({ where: { actorId: admin.userId, action: 'roles.update', targetType: 'RoleLadder' }, }) expect(eintraege.length).toBeGreaterThan(0) }) it('zeigt in der Verwaltung dasselbe, was der Zugriff bekommt', async () => { const stand = await listLadders(admin) const dieses = stand.find((l) => l.id === leiter) const obere = dieses?.roles.find((r) => r.discordRoleId === oben) expect(obere?.inheritedPermissions).toEqual(['case.read', 'entry.create']) expect(obere?.effectiveClearanceLevel).toBe(3) expect(obere?.ownClearanceLevel).toBe(0) expect(obere?.effectiveGrantsMembership).toBe(true) }) })