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.
125 lines
5.4 KiB
TypeScript
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");
|
|
});
|
|
});
|