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.
This commit is contained in:
2026-09-15 09:37:00 -07:00
parent ce5eb04c2d
commit 40df0f658b
21 changed files with 211 additions and 48 deletions
@@ -92,3 +92,45 @@ describe("the account sheet", () => {
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();
});
});