Files
ihasmail/web/src/views/admin/__tests__/tenant-sheet.test.tsx
T
jcoffey-dev 40df0f658b Follow what the live server does with tenants and domains
A run on the production server with throwaway tenants, a role, lists and a
domain, all removed, found three things the source reading had not:

- Something in a tenant has to be on a domain in that tenant (a list in a
  tenant on an unassigned domain is invalidForeignKey), while something in
  no tenant may be on a tenant's domain. The account panel's tenant choice
  offered every tenant; it offers only the domain's now, and a new account
  starts in the tenant of the domain it is made on. The domain list reads
  memberTenantId for it.
- A domain created in a tenant puts its DKIM keys there too, and they keep
  the tenant from being deleted. They are counted with the rest, so Delete
  is not offered while any remain.
- Stalwart lets a domain leave a tenant while the tenant still has accounts
  on it, stranding them. The panel asks first and refuses while any are
  there.

The refusal to delete a tenant that holds anything was confirmed, as were
tenant create, quota pointers, logo and rename. The mock follows the domain
rule, filters DKIM keys by tenant, and KNOWN-ISSUES records the run.

The non-Enterprise notice is now just "Tenants are a Stalwart Enterprise
feature." Two sentences were reworded and one plural added, in all nine
catalogues, and the old sentences are gone.
2026-09-15 09:37:00 -07:00

125 lines
5.4 KiB
TypeScript

import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useSession } from "@/store/session";
import type { JmapSession } from "@/jmap/types";
import type { DirectoryTenant } from "@/lib/adminTenants";
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const api = vi.hoisted(() => ({
counts: { accounts: 1, groups: 0, lists: 0, domains: 1, roles: 0, dkimKeys: 2 } as Record<string, number>,
onDomain: 0,
updateTenant: vi.fn(async () => {}),
setDomainTenant: vi.fn(async () => {}),
}));
vi.mock("@/lib/adminTenants", async (original) => ({
...(await original<typeof import("@/lib/adminTenants")>()),
countTenantMembers: vi.fn(async () => api.counts),
tenantDomains: vi.fn(async () => ({ inTenant: [{ id: "d3", name: "old-brand.example" }], unassigned: [{ id: "d4", name: "spare.example" }] })),
updateTenant: api.updateTenant,
setDomainTenant: api.setDomainTenant,
tenantAccountsOnDomain: vi.fn(async () => api.onDomain),
}));
const { TenantSheet } = await import("../TenantSheet");
const tenant: DirectoryTenant = { id: "t1", name: "Acme Corp", logo: null, roles: { "@type": "Default" }, quotas: { maxAccounts: 25, maxDomains: 2, maxOauthClients: 3 }, usedDiskQuota: 0 };
const ALL = ["sysTenantGet", "sysTenantQuery", "sysTenantUpdate", "sysTenantDestroy", "sysDomainUpdate"];
const signIn = (permissions: string[]) =>
useSession.setState({ session: { capabilities: {}, accounts: {}, primaryAccounts: {}, username: "[email protected]", ihasmail: { permissions } } as unknown as JmapSession });
const button = (host: HTMLElement, label: string) => [...host.querySelectorAll("button")].find((b) => b.getAttribute("aria-label") === label || b.textContent?.trim() === label || b.textContent?.includes(label));
const type = async (el: HTMLInputElement, value: string) => {
await act(async () => {
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(el, value);
el.dispatchEvent(new Event("input", { bubbles: true }));
});
};
describe("the tenant sheet", () => {
let host: HTMLDivElement;
let root: Root;
const render = async () => {
await act(async () => {
root.render(<TenantSheet tenant={tenant} roles={new Map()} onClose={() => {}} onChanged={() => {}} onCreated={() => {}} onDeleted={() => {}} />);
});
await act(async () => {});
};
beforeEach(() => {
host = document.createElement("div");
document.body.appendChild(host);
root = createRoot(host);
api.updateTenant.mockClear();
api.setDomainTenant.mockClear();
api.counts = { accounts: 1, groups: 0, lists: 0, domains: 1, roles: 0, dkimKeys: 2 };
api.onDomain = 0;
});
afterEach(async () => {
await act(async () => root.unmount());
host.remove();
});
it("shows what it holds against its limits, and will not delete while it holds anything", async () => {
signIn(ALL);
await render();
expect(host.querySelector(".admin-kv")?.textContent).toContain("1 of 25");
expect(button(host, "Delete tenant…")?.disabled).toBe(true);
});
it("offers the delete once it is empty", async () => {
api.counts = { accounts: 0, groups: 0, lists: 0, domains: 0, roles: 0, dkimKeys: 0 };
signIn(ALL);
await render();
expect(button(host, "Delete tenant…")?.disabled).toBe(false);
});
it("saves a changed limit as one pointer, and an emptied one as no limit", async () => {
signIn(ALL);
await render();
await type(host.querySelector<HTMLInputElement>("#admin-tenant-maxAccounts")!, "30");
await type(host.querySelector<HTMLInputElement>("#admin-tenant-maxDomains")!, "");
await act(async () => button(host, "Save changes")!.click());
expect(api.updateTenant).toHaveBeenCalledWith("t1", { "quotas/maxAccounts": 30, "quotas/maxDomains": null });
});
it("moves a domain in, and offers no domain moves without the permission to change domains", async () => {
signIn(ALL);
await render();
await act(async () => button(host, "Add")!.click());
expect(api.setDomainTenant).toHaveBeenCalledWith("d4", "t1");
await act(async () => root.unmount());
root = createRoot(host);
signIn(["sysTenantGet", "sysTenantQuery"]);
await render();
expect(host.querySelector('select[aria-label="Domain to add"]')).toBeNull();
expect(button(host, "Take old-brand.example out of the tenant")).toBeUndefined();
});
});
describe("taking a domain out of a tenant", () => {
let host: HTMLDivElement;
let root: Root;
beforeEach(() => {
host = document.createElement("div");
document.body.appendChild(host);
root = createRoot(host);
api.setDomainTenant.mockClear();
});
afterEach(async () => {
await act(async () => root.unmount());
host.remove();
});
it("is refused while the tenant still has accounts on it, which Stalwart would strand", async () => {
api.onDomain = 2;
signIn(ALL);
await act(async () => {
root.render(<TenantSheet tenant={tenant} roles={new Map()} onClose={() => {}} onChanged={() => {}} onCreated={() => {}} onDeleted={() => {}} />);
});
await act(async () => {});
await act(async () => button(host, "Take old-brand.example out of the tenant")!.click());
expect(api.setDomainTenant).not.toHaveBeenCalled();
expect(host.querySelector(".admin-notice.error")?.textContent).toContain("2 accounts in this tenant are still on old-brand.example");
});
});