Group the admin and calendar modules, and stop calling screenshots docs

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.
This commit is contained in:
2026-09-15 22:44:53 -07:00
parent 0bde2df69d
commit f7712b1c1e
72 changed files with 108 additions and 108 deletions
@@ -0,0 +1,106 @@
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);
});
});
@@ -0,0 +1,87 @@
import { describe, expect, it, vi } from "vitest";
import { client, JmapMethodError } from "@/jmap/client";
import { balancedColumns, countObjects, isRefused, loadMetrics, summarizeMetrics, type MetricRecord } from "@/lib/admin/adminDashboard";
const counter = (metric: string, count: number, timestamp = "2026-09-15T14:00:00Z"): MetricRecord => ({ "@type": "Counter", metric, count, timestamp });
describe("the dashboard's message numbers", () => {
it("adds received and sent up over the metric names Stalwart's own dashboard uses", () => {
const stats = summarizeMetrics([
counter("queue.message-queued", 6),
counter("queue.message-queued", 4, "2026-09-15T13:00:00Z"),
counter("queue.authenticated-message-queued", 2),
counter("queue.dsn-queued", 1),
counter("queue.report-queued", 3),
// Recorded, but not either number.
counter("message-ingest.ham", 50),
]);
expect(stats.received).toBe(10);
expect(stats.sent).toBe(6);
});
it("reads memory from the newest gauge, not the first one listed", () => {
const stats = summarizeMetrics([
{ "@type": "Gauge", metric: "server.memory", count: 100, timestamp: "2026-09-15T12:00:00Z" },
{ "@type": "Gauge", metric: "server.memory", count: 300, timestamp: "2026-09-15T14:00:00Z" },
{ "@type": "Gauge", metric: "queue.count", count: 7, timestamp: "2026-09-15T15:00:00Z" },
]);
expect(stats.memory).toEqual({ bytes: 300, at: "2026-09-15T14:00:00Z" });
});
it("tells a history that records nothing from a quiet day", () => {
expect(summarizeMetrics([]).recorded).toBe(false);
const quiet = summarizeMetrics([{ "@type": "Gauge", metric: "server.memory", count: 1, timestamp: "2026-09-15T14:00:00Z" }]);
expect(quiet).toMatchObject({ recorded: true, received: 0, sent: 0 });
});
});
describe("the dashboard's queries", () => {
it("counts users rather than accounts, and asks for no ids", async () => {
const call = vi.spyOn(client, "call").mockResolvedValue({ ids: [], total: 5 });
expect(await countObjects("Account")).toBe(5);
expect(call).toHaveBeenCalledWith("x:Account/query", { filter: { "@type": "User" }, limit: 0, calculateTotal: true });
await countObjects("QueuedMessage");
expect(call).toHaveBeenLastCalledWith("x:QueuedMessage/query", { limit: 0, calculateTotal: true });
call.mockRestore();
});
it("filters the history with Stalwart's comparison names, and pages the gets", async () => {
// A bare `timestamp` or `after` is unsupportedFilter on a live server.
vi.spyOn(client, "maxObjectsInGet", "get").mockReturnValue(2);
const call = vi.spyOn(client, "call").mockImplementation(async (method, args) => {
if (method === "x:Metric/query") return (args as { position: number }).position === 0 ? { ids: ["a", "b"] } : { ids: ["c"] };
return { list: ((args as { ids: string[] }).ids).map((id) => counter("queue.message-queued", 1, id)) };
});
const records = await loadMetrics(new Date("2026-09-14T15:30:00.123Z"));
expect(records).toHaveLength(3);
expect(call.mock.calls[0]).toEqual([
"x:Metric/query",
{
filter: { timestampIsGreaterThanOrEqual: "2026-09-14T15:30:00Z", metric: ["queue.message-queued", "queue.authenticated-message-queued", "queue.dsn-queued", "queue.report-queued", "server.memory"] },
sort: [{ property: "timestamp", isAscending: false }],
position: 0,
limit: 2,
},
]);
vi.restoreAllMocks();
});
it("treats only a forbidden answer as the server refusing", () => {
expect(isRefused(new JmapMethodError("x:Metric/query", { type: "forbidden" }))).toBe(true);
expect(isRefused(new JmapMethodError("x:Metric/query", { type: "serverFail" }))).toBe(false);
expect(isRefused(new Error("offline"))).toBe(false);
});
});
describe("the card grid", () => {
it("never leaves a row short when the cards can be divided evenly", () => {
for (const n of [1, 2, 3, 4, 6]) {
const { wide, mid } = balancedColumns(n);
expect(n % wide, `${n} cards across ${wide}`).toBe(0);
expect(n % mid, `${n} cards across ${mid}`).toBe(0);
expect(wide).toBeLessThanOrEqual(4);
}
expect(balancedColumns(6)).toEqual({ wide: 3, mid: 2 });
expect(balancedColumns(3)).toEqual({ wide: 3, mid: 1 });
});
});
@@ -0,0 +1,99 @@
import { describe, expect, it, vi } from "vitest";
import { client } from "@/jmap/client";
import { aliasList, describeDirectoryError, DirectoryError, hasPassword, passwordPatch, queryAccounts, quotasWithDisk } from "@/lib/admin/adminDirectory";
describe("setting a password", () => {
it("writes into the existing password credential, keeping its place", () => {
const account = { credentials: { "0": { "@type": "AppPassword" as const }, "2": { "@type": "Password" as const, secret: "[********]" } } };
expect(passwordPatch(account, "new secret")).toEqual({ "credentials/2/secret": "new secret" });
});
it("adds one after the last index when the account has none", () => {
const account = { credentials: { "0": { "@type": "AppPassword" as const }, "3": { "@type": "ApiKey" as const } } };
expect(passwordPatch(account, "s")).toEqual({ "credentials/4": { "@type": "Password", secret: "s" } });
expect(passwordPatch({}, "s")).toEqual({ "credentials/0": { "@type": "Password", secret: "s" } });
expect(hasPassword(account)).toBe(false);
});
});
describe("lists written back", () => {
it("re-index aliases the way the server stores a list", () => {
expect(aliasList([{ name: "b", domainId: "d1" }, { name: "c", domainId: "d2", enabled: false }])).toEqual({
"0": { enabled: true, name: "b", domainId: "d1", description: null },
"1": { enabled: false, name: "c", domainId: "d2", description: null },
});
});
it("change the disk limit without touching the other quotas", () => {
expect(quotasWithDisk({ maxEmails: 10, maxDiskQuota: 5 }, 7)).toEqual({ maxEmails: 10, maxDiskQuota: 7 });
expect(quotasWithDisk({ maxEmails: 10, maxDiskQuota: 5 }, null)).toEqual({ maxEmails: 10 });
expect(quotasWithDisk(undefined, 0)).toEqual({});
});
});
describe("explaining a refusal", () => {
it("says what a taken address means", () => {
expect(describeDirectoryError(new DirectoryError("primaryKeyViolation", "exists"))).toMatch(/already in use/);
});
it("keeps the server's own words for a password policy", () => {
expect(describeDirectoryError(new DirectoryError("invalidProperties", "Password must be at least 8 characters long.", ["secret"]))).toContain("at least 8 characters");
});
it("handles a method-level refusal as well as a set error", () => {
expect(describeDirectoryError({ type: "forbidden", message: "x:Account/set: forbidden" })).toMatch(/refused/);
});
});
describe("the account query", () => {
it("filters on @type, the property's name on the object", async () => {
// A live 0.16 server answers a plain `type` with "unsupportedFilter - type"
// and fails the whole list, which is how this was found.
const call = vi.spyOn(client, "call").mockResolvedValue({ ids: [], total: 0 });
await queryAccounts({ type: "User", text: " ada ", position: 50, limit: 50 });
expect(call).toHaveBeenCalledWith("x:Account/query", { filter: { "@type": "User", text: "ada" }, position: 50, limit: 50, calculateTotal: true });
call.mockRestore();
});
});
/**
* Stalwart explains a refusal in English, and none of it should reach an
* interface in another language as it is. Each case below is a refusal a
* live server gave, or one its source says it gives.
*/
describe("refusals in the reader's language", () => {
it("recognizes the registry's validators and says it again, without the server's words", () => {
// Live, 2026-09-13: a reserved TLD, and a catch-all without a domain.
const domain = describeDirectoryError(new DirectoryError("invalidPatch", "Invalid domain name", ["name"]), "domain");
expect(domain).toMatch(/isn't a valid domain name/);
expect(domain).not.toContain("Invalid domain name");
expect(describeDirectoryError(new DirectoryError("invalidPatch", "Invalid email address", ["catchAllAddress"]), "domain")).toMatch(/full address/);
expect(describeDirectoryError(new DirectoryError("invalidProperties", "Invalid email local part", ["name"]))).toMatch(/before the @/);
});
it("never echoes a description it does not know", () => {
const text = describeDirectoryError(new DirectoryError("invalidPatch", "Something only the server would say", ["whatever"]));
expect(text).not.toContain("Something only the server would say");
expect(describeDirectoryError(new DirectoryError("forbidden", "You are not allowed to do that thing"))).not.toContain("not allowed to do that thing");
expect(describeDirectoryError(new DirectoryError("someNewType", "Brand new English"))).not.toContain("Brand new English");
});
it("tells a grant refusal and a directory-backed account apart from a plain no", () => {
expect(describeDirectoryError(new DirectoryError("forbidden", "You are not authorized to grant permissions: sysDomainDestroy."))).toMatch(/permissions your own role/);
expect(describeDirectoryError(new DirectoryError("forbidden", "Cannot set credentials for accounts in an external directory."))).toMatch(/external directory/);
});
it("words a clash and a missing object for what it was about", () => {
expect(describeDirectoryError(new DirectoryError("primaryKeyViolation", undefined, ["name"]), "domain")).toMatch(/domain name is already in use/);
expect(describeDirectoryError(new DirectoryError("primaryKeyViolation", undefined))).toMatch(/address is already in use/);
expect(describeDirectoryError(new DirectoryError("notFound", undefined), "domain")).toMatch(/domain no longer exists/);
});
it("explains ihasmail's own refusals by their code, not their English message", () => {
const own = { status: 403, code: "administration_needs_own_device", message: "Administration is only available when signed in on a device marked as your own (x:Account/query)." };
expect(describeDirectoryError(own)).toMatch(/marked as your own/);
expect(describeDirectoryError(own)).not.toContain("x:Account/query");
expect(describeDirectoryError({ status: 403, code: "administration_disabled", message: "…" })).toMatch(/turned off/);
expect(describeDirectoryError({ method: "x:Account/query", type: "unsupportedFilter", message: "x:Account/query: unsupportedFilter - type" })).toBe("The mail server could not carry out the request (unsupportedFilter).");
});
});
@@ -0,0 +1,69 @@
import { describe, expect, it } from "vitest";
import { describeLinked, dkimAlgorithm, looksLikeDomain, normalizeDomain, parseZoneFile } from "@/lib/admin/adminDomains";
/**
* Written the way Stalwart's BIND serializer writes it (dns-update's
* `BindSerializer`): `name IN TYPE value`, and a TXT over 255 bytes as a
* parenthesized run of quoted chunks.
*/
const long = "v=DKIM1; k=rsa; h=sha256; p=" + "A".repeat(400);
const zone = [
"example.com. IN MX 10 mail.example.com.",
'example.com. IN TXT "v=spf1 mx ra=postmaster -all"',
"v1-rsa-20260601._domainkey.example.com. IN TXT (",
...(long.match(/.{1,255}/g) ?? []).map((c) => ` "${c}"`),
")",
'_dmarc.example.com. IN TXT "v=DMARC1; p=reject; rua=mailto:\\"postmaster\\"@example.com"',
"_jmap._tcp.example.com. IN SRV 0 1 443 mail.example.com.",
'example.com. IN CAA 0 issue "letsencrypt.org"',
"",
].join("\n");
describe("reading the zone file", () => {
const records = parseZoneFile(zone);
it("gives one row per record, without the root dot", () => {
expect(records.map((r) => r.type)).toEqual(["MX", "TXT", "TXT", "TXT", "SRV", "CAA"]);
expect(records[0]).toMatchObject({ name: "example.com", value: "10 mail.example.com." });
});
it("joins a split TXT record back into the value a DNS form wants", () => {
expect(records[2]!.name).toBe("v1-rsa-20260601._domainkey.example.com");
expect(records[2]!.value).toBe(long);
expect(records[2]!.line).toContain("(");
});
it("unquotes and unescapes TXT values, and leaves other types as written", () => {
expect(records[1]!.value).toBe("v=spf1 mx ra=postmaster -all");
expect(records[3]!.value).toBe('v=DMARC1; p=reject; rua=mailto:"postmaster"@example.com');
expect(records[5]!.value).toBe('0 issue "letsencrypt.org"');
});
it("keeps a line it cannot read rather than dropping it", () => {
expect(parseZoneFile("something unexpected")).toEqual([{ name: "", type: "", value: "something unexpected", line: "something unexpected" }]);
});
});
describe("domain names", () => {
it("are written back lower-case without the root dot", () => {
expect(normalizeDomain(" Example.COM. ")).toBe("example.com");
});
it("are checked loosely before the server decides", () => {
expect(looksLikeDomain("mail.example.co.uk")).toBe(true);
expect(looksLikeDomain("example")).toBe(false);
expect(looksLikeDomain("exa mple.com")).toBe(false);
expect(looksLikeDomain("-bad.example.com")).toBe(false);
});
});
describe("explaining what still uses a domain", () => {
it("counts by kind", () => {
expect(describeLinked(["Account", "Account", "DkimSignature", "MailingList", "Whatever"])).toBe("2 accounts, 1 DKIM key, 1 mailing list, 1 other item");
});
it("names a key's algorithm from its type", () => {
expect(dkimAlgorithm("Dkim1Ed25519Sha256")).toBe("Ed25519 · DKIM1");
expect(dkimAlgorithm("Dkim2RsaSha256")).toBe("RSA · DKIM2");
});
});
@@ -0,0 +1,66 @@
import { describe, expect, it, vi } from "vitest";
import { client } from "@/jmap/client";
import { countMembers, createGroup, destroyGroup, groupRoleKey, groupRolesFromKey, membershipPatch } from "@/lib/admin/adminGroups";
describe("group membership", () => {
it("is a patch to each member, one pointer each, so no other membership moves", () => {
// Stalwart's set patch adds a key on `true` and removes it on `null`, and
// leaves every other key in the set as it was.
expect(membershipPatch(["u1", "u2"], "g1", true)).toEqual({ u1: { "memberGroupIds/g1": true }, u2: { "memberGroupIds/g1": true } });
expect(membershipPatch(["u1"], "g1", false)).toEqual({ u1: { "memberGroupIds/g1": null } });
});
it("counts members as users whose memberships name the group, asking for no ids", async () => {
const call = vi.spyOn(client, "call").mockResolvedValue({ ids: [], total: 4 });
expect(await countMembers(["g1"])).toEqual(new Map([["g1", 4]]));
expect(call).toHaveBeenCalledWith("x:Account/query", { filter: { "@type": "User", memberGroupIds: "g1" }, limit: 0, calculateTotal: true });
call.mockRestore();
});
it("leaves a count out rather than showing a failed one as none", async () => {
const call = vi.spyOn(client, "call").mockRejectedValue(new Error("offline"));
expect(await countMembers(["g1"])).toEqual(new Map());
call.mockRestore();
});
});
describe("creating and deleting a group", () => {
it("creates an account of type Group, with nothing a person needs to sign in", async () => {
const call = vi.spyOn(client, "call").mockResolvedValue({ created: { n: { id: "g9" } } });
expect(await createGroup({ name: " sales ", domainId: "d1", description: "", roles: { "@type": "Default" }, diskQuotaBytes: null })).toBe("g9");
const create = (call.mock.calls[0]![1] as { create: { n: Record<string, unknown> } }).create.n;
expect(create).toMatchObject({ "@type": "Group", name: "sales", domainId: "d1", description: null, roles: { "@type": "Default" }, permissions: { "@type": "Inherit" }, quotas: {} });
expect(create).not.toHaveProperty("credentials");
expect(create).not.toHaveProperty("encryptionAtRest");
expect(create).not.toHaveProperty("memberGroupIds");
call.mockRestore();
});
it("takes the members out before deleting, and deletes nothing if that fails", async () => {
const call = vi.spyOn(client, "call").mockResolvedValueOnce({ updated: { u1: null } }).mockResolvedValueOnce({ destroyed: ["g1"] });
await destroyGroup("g1", ["u1"]);
expect(call.mock.calls.map((c) => [c[0], Object.keys(c[1] as object)])).toEqual([
["x:Account/set", ["update"]],
["x:Account/set", ["destroy"]],
]);
call.mockReset();
call.mockResolvedValueOnce({ notUpdated: { u1: { type: "forbidden" } } });
await expect(destroyGroup("g1", ["u1"])).rejects.toMatchObject({ type: "forbidden" });
expect(call).toHaveBeenCalledTimes(1);
call.mockRestore();
});
it("goes straight to the delete for a group with no members", async () => {
const call = vi.spyOn(client, "call").mockResolvedValue({ destroyed: ["g1"] });
await destroyGroup("g1", []);
expect(call).toHaveBeenCalledTimes(1);
expect(call).toHaveBeenCalledWith("x:Account/set", { destroy: ["g1"] });
call.mockRestore();
});
it("round-trips a group's roles, which are Default or Custom", () => {
for (const roles of [{ "@type": "Default" } as const, { "@type": "Custom", roleIds: { r1: true, r2: true } } as const]) {
expect(groupRolesFromKey(groupRoleKey(roles))).toEqual(roles);
}
});
});
@@ -0,0 +1,48 @@
import { describe, expect, it, vi } from "vitest";
import { client } from "@/jmap/client";
import { createList, parseAddresses, queryLists, recipientsPatch } from "@/lib/admin/adminLists";
describe("a mailing list's recipients", () => {
it("are saved as what was added and removed, one pointer each", () => {
// The live server adds a set key on `true`, removes it on `null`, and
// leaves the rest -- so a recipient added elsewhere meanwhile survives.
expect(recipientsPatch(["[email protected]", "[email protected]"], ["[email protected]", "[email protected]"])).toEqual({
"recipients/[email protected]": null,
"recipients/[email protected]": true,
});
expect(recipientsPatch(["[email protected]"], ["[email protected]"])).toEqual({});
});
it("compare without regard to case, and escape what a pointer cannot hold", () => {
expect(recipientsPatch(["[email protected]"], ["[email protected]"])).toEqual({});
expect(recipientsPatch([], ["odd/[email protected]"])).toEqual({ "recipients/[email protected]": true });
});
it("come out of a paste of names, commas and angle brackets, and keep what isn't an address", () => {
expect(parseAddresses('Ada Lovelace <[email protected]>, [email protected]; "Alan" [email protected]\[email protected] mailto:[email protected]')).toEqual({
addresses: ["[email protected]", "[email protected]", "[email protected]", "[email protected]"],
rejected: [],
});
expect(parseAddresses("ada@, @example.org, someone@nowhere")).toEqual({ addresses: [], rejected: ["ada@", "@example.org", "someone@nowhere"] });
});
});
describe("the list calls", () => {
it("search on text, and leave the filter out when there is none", async () => {
const call = vi.spyOn(client, "call").mockResolvedValue({ ids: [], total: 0 });
await queryLists({ text: " board ", position: 50, limit: 50 });
expect(call).toHaveBeenLastCalledWith("x:MailingList/query", { filter: { text: "board" }, position: 50, limit: 50, calculateTotal: true });
await queryLists({});
expect(call).toHaveBeenLastCalledWith("x:MailingList/query", { position: 0, calculateTotal: true });
call.mockRestore();
});
it("create one with its recipients as a set", async () => {
const call = vi.spyOn(client, "call").mockResolvedValue({ created: { n: { id: "l9" } } });
expect(await createList({ name: "team", domainId: "d1", description: " ", recipients: ["[email protected]", "[email protected]"] })).toBe("l9");
expect(call).toHaveBeenCalledWith("x:MailingList/set", {
create: { n: { name: "team", domainId: "d1", description: null, recipients: { "[email protected]": true, "[email protected]": true }, aliases: {} } },
});
call.mockRestore();
});
});
@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import { permissionSet } from "@/lib/admin/adminAccess";
import { canBuildOn, effectivePermissions, inherited, roleOutranks, setPatch, type DirectoryRole } from "@/lib/admin/adminRoles";
const flags = (...n: string[]) => Object.fromEntries(n.map((x) => [x, true]));
const roles = new Map<string, DirectoryRole>([
["user", { id: "user", description: "User", enabledPermissions: flags("jmapEmailGet", "jmapEmailUpdate") }],
["help", { id: "help", description: "Helpdesk", enabledPermissions: flags("sysAccountGet"), disabledPermissions: flags("jmapEmailUpdate"), roleIds: flags("user") }],
["lead", { id: "lead", description: "Lead", enabledPermissions: flags("sysAccountUpdate"), roleIds: flags("help") }],
]);
describe("what a role holds", () => {
it("follows every base, and a denial anywhere in the tree wins", () => {
// Stalwart unions enabled with enabled and disabled with disabled across
// the tree, then takes the disabled away (permissions.rs).
expect([...effectivePermissions(roles.get("lead")!, roles, "lead")].sort()).toEqual(["jmapEmailGet", "sysAccountGet", "sysAccountUpdate"]);
const { granted, denied } = inherited(["help"], roles, "lead");
expect(granted.get("jmapEmailGet")).toBe("help");
expect(denied.get("jmapEmailUpdate")).toBe("help");
});
it("changes a set one pointer at a time", () => {
expect(setPatch("enabledPermissions", ["a", "b"], new Set(["b", "c"]))).toEqual({ "enabledPermissions/a": null, "enabledPermissions/c": true });
expect(setPatch("roleIds", [], [])).toEqual({});
});
it("will not build on itself, or on a role already built on it", () => {
expect(canBuildOn("help", "help", roles)).toBe(false);
expect(canBuildOn("help", "lead", roles)).toBe(false);
expect(canBuildOn("lead", "user", roles)).toBe(true);
expect(canBuildOn(null, "lead", roles)).toBe(true);
});
it("is read-only to a viewer missing anything enabled in its tree, denied or not", () => {
const viewer = permissionSet(["jmapEmailGet", "sysAccountGet", "sysAccountUpdate"]);
// jmapEmailUpdate is denied on Helpdesk but enabled on User beneath it: a
// grant Stalwart would check, and a delete it would not.
expect(roleOutranks(viewer, roles.get("lead")!, roles)).toBe(true);
expect(roleOutranks(permissionSet([...viewer, "jmapEmailUpdate"]), roles.get("lead")!, roles)).toBe(false);
});
});
@@ -0,0 +1,49 @@
import { describe, expect, it, vi } from "vitest";
import { client } from "@/jmap/client";
import { countTenantMembers, drawableLogo, quotasPatch, setDomainTenant } from "@/lib/admin/adminTenants";
describe("a tenant's limits", () => {
it("change one pointer each, leaving the quotas ihasmail does not offer alone", () => {
const before = { maxAccounts: 25, maxDomains: 2, maxOauthClients: 7 };
expect(quotasPatch(before, { maxAccounts: 30, maxDomains: null, maxGroups: 5, maxRoles: null })).toEqual({
"quotas/maxAccounts": 30,
"quotas/maxDomains": null,
"quotas/maxGroups": 5,
});
expect(quotasPatch(before, { maxAccounts: 25 })).toEqual({});
});
});
describe("what a tenant holds", () => {
it("is counted with a memberTenantId filter per kind, users and groups apart", async () => {
const call = vi.spyOn(client, "call").mockImplementation(async (method, args) => {
const f = (args as { filter: Record<string, unknown> }).filter;
if (method === "x:Role/query") throw new Error("forbidden");
return { total: method === "x:Account/query" && f["@type"] === "Group" ? 2 : 1 };
});
expect(await countTenantMembers("t1")).toEqual({ accounts: 1, groups: 2, lists: 1, domains: 1, dkimKeys: 1 });
expect(call).toHaveBeenCalledWith("x:Account/query", { filter: { "@type": "User", memberTenantId: "t1" }, limit: 0, calculateTotal: true });
expect(call).toHaveBeenCalledWith("x:Domain/query", { filter: { memberTenantId: "t1" }, limit: 0, calculateTotal: true });
call.mockRestore();
});
it("moves a domain in and out by its memberTenantId", async () => {
const call = vi.spyOn(client, "call").mockResolvedValue({ updated: { d4: null } });
await setDomainTenant("d4", "t1");
expect(call).toHaveBeenLastCalledWith("x:Domain/set", { update: { d4: { memberTenantId: "t1" } } });
await setDomainTenant("d4", null);
expect(call).toHaveBeenLastCalledWith("x:Domain/set", { update: { d4: { memberTenantId: null } } });
call.mockRestore();
});
});
describe("a tenant's logo", () => {
it("is drawn only from https or an image data URL", () => {
expect(drawableLogo("https://example.com/logo.png")).toBe("https://example.com/logo.png");
expect(drawableLogo("data:image/png;base64,AAAA")).toBe("data:image/png;base64,AAAA");
expect(drawableLogo("http://example.com/logo.png")).toBeNull();
expect(drawableLogo("javascript:alert(1)")).toBeNull();
expect(drawableLogo("data:text/html;base64,AAAA")).toBeNull();
expect(drawableLogo(null)).toBeNull();
});
});