Files
ihasmail-inbuxa/web/src/views/admin/__tests__/account-sheet.test.tsx
T
jcoffey-dev f7712b1c1e 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.
2026-09-15 22:44:53 -07:00

137 lines
5.5 KiB
TypeScript

import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { Router } from "wouter";
import { memoryLocation } from "wouter/memory-location";
import { useSession } from "@/store/session";
import type { JmapSession } from "@/jmap/types";
import type { DirectoryAccount } from "@/lib/admin/adminDirectory";
import { AccountSheet } from "../AccountSheet";
import type { DirectoryContext } from "../directoryContext";
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const HELPDESK = ["sysAccountGet", "sysAccountQuery", "sysAccountUpdate"];
function signIn(permissions: string[], username = "[email protected]") {
useSession.setState({
session: { capabilities: {}, accounts: {}, primaryAccounts: { "urn:stalwart:jmap": "self" }, username, ihasmail: { permissions } } as unknown as JmapSession,
});
}
const account = (over: Partial<DirectoryAccount>): DirectoryAccount => ({
id: "u1",
"@type": "User",
name: "ada",
domainId: "d1",
emailAddress: "[email protected]",
description: "Ada Lovelace",
roles: { "@type": "User" },
credentials: { "0": { "@type": "Password", secret: "[********]" } },
...over,
});
const ctx: DirectoryContext = { domains: [{ id: "d1", name: "example.com" }], roles: null, groups: new Map(), self: { ids: new Set(["self"]), address: "[email protected]" } };
const button = (host: HTMLElement, text: string) => [...host.querySelectorAll("button")].find((b) => b.textContent?.includes(text));
/**
* The guards that stand in for checks Stalwart does not make. A store test
* cannot see these: they are what the sheet renders, and what it leaves out.
*/
describe("the account sheet", () => {
let host: HTMLDivElement;
let root: Root;
const render = async (a: DirectoryAccount) => {
const { hook } = memoryLocation({ path: `/admin/accounts/${a.id}` });
await act(async () => {
root.render(
<Router hook={hook}>
<AccountSheet account={a} ctx={ctx} onClose={() => {}} onChanged={() => {}} onCreated={() => {}} onDeleted={() => {}} />
</Router>,
);
});
};
beforeEach(() => {
host = document.createElement("div");
document.body.appendChild(host);
root = createRoot(host);
});
afterEach(async () => {
await act(async () => root.unmount());
host.remove();
});
it("shows an account that outranks the viewer read-only, password included", async () => {
signIn(HELPDESK);
await render(account({ roles: { "@type": "Admin" } }));
expect(host.textContent).toContain("permissions yours doesn't");
expect(button(host, "Set a new password")?.disabled).toBe(true);
expect((host.querySelector("#admin-description") as HTMLInputElement).disabled).toBe(true);
expect(host.textContent).not.toContain("Save changes");
});
it("lets the same viewer edit an ordinary account, but not delete it", async () => {
signIn(HELPDESK);
await render(account({}));
expect(button(host, "Set a new password")?.disabled).toBe(false);
expect(host.textContent).toContain("Save changes");
expect(host.textContent).not.toContain("Delete account");
});
it("sends your own password to Settings, and keeps your role and account out of reach", async () => {
signIn([...HELPDESK, "sysAccountDestroy"]);
await render(account({ id: "self", emailAddress: "[email protected]" }));
expect(host.textContent).toContain("Change your own password in");
expect(host.querySelector('a[href="/settings/security"]')).not.toBeNull();
expect(button(host, "Set a new password")).toBeUndefined();
expect((host.querySelector('select[aria-label="Role"]') as HTMLSelectElement).disabled).toBe(true);
expect(button(host, "Delete account")?.disabled).toBe(true);
});
});
/**
* An account in a tenant has to be on a domain in that tenant -- the live
* server refuses anything else -- so the only tenant offered is the domain's.
*/
describe("an account's tenant", () => {
let host: HTMLDivElement;
let root: Root;
const tenantCtx: DirectoryContext = {
...ctx,
domains: [{ id: "d1", name: "example.com", memberTenantId: null }, { id: "d3", name: "acme.example", memberTenantId: "t1" }],
tenants: [{ id: "t1", name: "Acme Corp" }, { id: "t2", name: "Globex" }],
};
const render = async (a: DirectoryAccount) => {
const { hook } = memoryLocation({ path: `/admin/accounts/${a.id}` });
await act(async () => {
root.render(<Router hook={hook}><AccountSheet account={a} ctx={tenantCtx} onClose={() => {}} onChanged={() => {}} onCreated={() => {}} onDeleted={() => {}} /></Router>);
});
};
beforeEach(() => {
host = document.createElement("div");
document.body.appendChild(host);
root = createRoot(host);
});
afterEach(async () => {
await act(async () => root.unmount());
host.remove();
});
it("offers only the tenant its domain is in", async () => {
signIn([...HELPDESK, "sysTenantGet", "sysTenantQuery"]);
await render(account({ domainId: "d3", memberTenantId: "t1", emailAddress: "[email protected]" }));
const options = [...host.querySelectorAll<HTMLOptionElement>('select[aria-label="Tenant"] option')].map((o) => o.textContent);
expect(options).toEqual(["No tenant", "Acme Corp"]);
});
it("offers no choice at all on a domain in no tenant", async () => {
signIn([...HELPDESK, "sysTenantGet", "sysTenantQuery"]);
await render(account({ domainId: "d1" }));
expect(host.querySelector('select[aria-label="Tenant"]')).toBeNull();
});
});