import { describe, expect, it } from "vitest"; import { ADMIN_BASELINE, adminSections, can, dashboardCards, canGrantRole, generatePassword, hasAdministration, outranks, permissionSet, resolveRoles, type RoleDef } from "@/lib/admin/adminAccess"; const set = (...p: string[]) => permissionSet(p); const everything = set(...ADMIN_BASELINE, "sysTenantGet", "jmapEmailGet", "impersonate"); const helpdesk = set("sysAccountGet", "sysAccountQuery", "sysAccountUpdate", "jmapEmailGet"); const roles = new Map([ ["user", { id: "user", enabledPermissions: { jmapEmailGet: true } }], ["helpdesk", { id: "helpdesk", enabledPermissions: { sysAccountGet: true, sysAccountQuery: true, sysAccountUpdate: true }, roleIds: { user: true } }], ["dns", { id: "dns", enabledPermissions: { sysDnsServerUpdate: true }, roleIds: { user: true } }], ["loop", { id: "loop", enabledPermissions: {}, roleIds: { loop: true } }], ]); describe("who is offered administration", () => { it("needs both halves of reading the account list to list accounts", () => { // Groups are accounts to the server, so they come with the same two permissions. expect(adminSections(set("sysAccountQuery", "sysAccountGet"))).toEqual(["dashboard", "accounts", "groups"]); // A query alone is a count on the dashboard, not a list. expect(adminSections(set("sysAccountQuery"))).toEqual(["dashboard"]); expect(hasAdministration(set("sysAccountGet"))).toBe(false); expect(hasAdministration(permissionSet(undefined))).toBe(false); }); it("offers each section only with both halves of reading it", () => { expect(adminSections(set("sysDomainQuery", "sysDomainGet"))).toEqual(["dashboard", "domains"]); expect(hasAdministration(set("sysDomainQuery", "sysDomainGet"))).toBe(true); expect(adminSections(set("sysAccountQuery", "sysAccountGet", "sysDomainQuery"))).toEqual(["dashboard", "accounts", "groups"]); }); it("gives the dashboard a card for each number the role can read", () => { expect(dashboardCards(set("sysAccountQuery", "sysAccountGet", "sysDomainQuery", "sysDomainGet"))).toEqual(["users", "domains"]); expect(dashboardCards(set("sysQueuedMessageQuery"))).toEqual(["pending"]); // The history takes its get as well: the query only finds the records. expect(dashboardCards(set("sysMetricQuery"))).toEqual([]); expect(dashboardCards(set("sysMetricQuery", "sysMetricGet"))).toEqual(["memory", "received", "sent"]); expect(adminSections(set("jmapEmailGet"))).toEqual([]); }); it("reads one permission per object and operation", () => { expect(can(helpdesk, "Account", "Update")).toBe(true); expect(can(helpdesk, "Account", "Destroy")).toBe(false); expect(can(helpdesk, "Domain", "Get")).toBe(false); }); }); /** * Stalwart checks a grant, but not a password change or a delete. Without this, * anyone allowed to edit accounts could take over one that can do more. */ describe("an account that outranks the viewer", () => { it("an ordinary user never does", () => { expect(outranks(helpdesk, { roles: { "@type": "User" } }, null)).toBe(false); expect(outranks(helpdesk, {}, null)).toBe(false); }); it("an administrator does, unless the viewer is one too", () => { expect(outranks(helpdesk, { roles: { "@type": "Admin" } }, roles)).toBe(true); expect(outranks(everything, { roles: { "@type": "Admin" } }, roles)).toBe(false); }); it("a custom role does when it carries something the viewer lacks", () => { expect(outranks(helpdesk, { roles: { "@type": "Custom", roleIds: { helpdesk: true } } }, roles)).toBe(false); expect(outranks(helpdesk, { roles: { "@type": "Custom", roleIds: { dns: true } } }, roles)).toBe(true); }); it("a role that cannot be read counts against the target, not for it", () => { expect(outranks(helpdesk, { roles: { "@type": "Custom", roleIds: { helpdesk: true } } }, null)).toBe(true); expect(outranks(everything, { roles: { "@type": "Custom", roleIds: { gone: true } } }, roles)).toBe(true); }); it("extra permissions on the account itself are counted", () => { expect(outranks(helpdesk, { roles: { "@type": "User" }, permissions: { "@type": "Merge", enabledPermissions: { sysDomainDestroy: true } } }, roles)).toBe(true); // Replace ignores the roles entirely, so only what it lists matters. expect(outranks(helpdesk, { roles: { "@type": "Custom", roleIds: { dns: true } }, permissions: { "@type": "Replace", enabledPermissions: { jmapEmailGet: true } } }, roles)).toBe(false); }); it("survives a role that names itself", () => { expect(resolveRoles(["loop"], roles)).toEqual(new Set()); }); }); describe("granting a role", () => { it("is offered only for roles whose every permission the viewer holds", () => { expect(canGrantRole(helpdesk, "helpdesk", roles)).toBe(true); expect(canGrantRole(helpdesk, "dns", roles)).toBe(false); expect(canGrantRole(everything, "missing", roles)).toBe(false); }); }); describe("generated passwords", () => { it("are four groups of five unambiguous characters", () => { const p = generatePassword(); expect(p).toMatch(/^[a-zA-Z2-9]{5}(-[a-zA-Z2-9]{5}){3}$/); expect(p).not.toMatch(/[01lIO]/); }); it("skip bytes that would favor the start of the alphabet", () => { // 256 % 55 leaves 36 byte values over; a plain modulo would hand those to // the first 36 characters twice as often. Bytes of 220 and up are dropped // and more are drawn, so a batch of nothing but those costs a draw. let call = 0; const source = (n: number) => (call++ === 0 ? new Uint8Array(n).fill(250) : Uint8Array.from({ length: n }, (_, i) => i)); expect(generatePassword(source)).toBe("abcde-fghjk-mnpqr-stuvw"); expect(call).toBe(2); }); });