web/src/lib had grown to 85 flat modules -- 42% of the web source, about
12,800 lines -- with one subdirectory (smime/) to its name. The tell was
that a naming prefix had taken over a directory's job: eight adminX.ts
files sat adjacent because alphabetical order put them there, not because
anything said they belonged together.
lib/admin/ adminAccess, adminDashboard, adminDirectory, adminDomains,
adminGroups, adminLists, adminRoles, adminTenants
lib/calendar/ appointment, availabilityWindow, eventDrag, ics, recurrence
Tests move with their modules into lib/admin/__tests__ and
lib/calendar/__tests__, which is what views/ already does. describeRules
stays in lib/__tests__: it checks that sieve's describeRule and
recurrence's agree, so it belongs to neither.
recurrence.ts joins the calendar group and archiveDate.ts does not, which
is the opposite of the first guess from the filenames. archiveDate picks
the Archive/2026/09 mailbox for a message -- mail, not calendar --
while recurrence reads JSCalendarRecurrenceRule. schedule.ts is scheduled
*send*, so it stays put too. birthdays.ts is left alone deliberately: it
is read off the contact cards and only rendered by the calendar, so it
belongs to whichever of the two you ask.
docs/ held no documentation. It held ten JPEGs and the two scripts that
capture them, while the actual documentation is a separate site in the
ihasmail.org repository -- so anyone opening docs/ expecting prose found
a headless-Chrome driver. The images are now screenshots/, and the two
capture scripts join the other .mjs tooling in scripts/, which is where a
generator belongs. Renaming docs/ to screenshots/ wholesale would have
produced screenshots/screenshots/inbox-dark.jpg.
No behavior changes: every import was already on the @/ alias, so this is
path rewrites and nothing else.
107 lines
5.7 KiB
TypeScript
107 lines
5.7 KiB
TypeScript
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<string, RoleDef>([
|
|
["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);
|
|
});
|
|
});
|