Files
inbuxa-webmail/web/src/views/admin/__tests__/admin-dashboard.test.tsx
T
jcoffey-dev 4787e8bf12 Point from the dashboard to Stalwart's own administration
A line under the cards says where the rest is: detailed metrics, the
delivery queue, logs and server settings are in Stalwart's own
administration. It links there when the operator sets STALWART_ADMIN_URL,
and stays plain text otherwise, because STALWART_URL is how this server
reaches Stalwart and is often an address no browser can open.

Several servers: a servers file entry may now be an object,
{"url": ..., "adminUrl": ...}, and a session routed to that server gets its
adminUrl. A routed domain without one gets no link rather than the default
server's, for the same reason routing never falls back. The URL is sent
only to a session that may administer.

The shipped example file stopped the server at startup: its "_comment"
key was read as a domain and refused as not a URL, while the test that
checks the example skipped it. Keys starting with an underscore are notes
now -- no mail domain starts with one -- and the example is also loaded
through the real parser in a test, so the two cannot disagree again.

Two new strings, in all nine catalogues.
2026-09-15 08:16:52 -07:00

151 lines
6.1 KiB
TypeScript

import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Router } from "wouter";
import { memoryLocation } from "wouter/memory-location";
import { JmapMethodError } from "@/jmap/client";
import { useSession } from "@/store/session";
import type { JmapSession } from "@/jmap/types";
import type { MetricRecord } from "@/lib/adminDashboard";
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const feeds = vi.hoisted(() => ({
counts: { Account: 35, Domain: 3, QueuedMessage: 9 } as Record<string, number>,
metrics: (): Promise<MetricRecord[]> => Promise.resolve([]),
}));
vi.mock("@/lib/adminDashboard", async (original) => ({
...(await original<typeof import("@/lib/adminDashboard")>()),
countObjects: vi.fn(async (object: string) => feeds.counts[object]),
loadMetrics: vi.fn(() => feeds.metrics()),
}));
const { AdminDashboard } = await import("../AdminDashboard");
const signIn = (permissions: string[]) =>
useSession.setState({ session: { capabilities: {}, accounts: {}, primaryAccounts: {}, username: "[email protected]", ihasmail: { permissions } } as unknown as JmapSession });
const HELPDESK = ["sysAccountGet", "sysAccountQuery", "sysAccountUpdate", "sysDomainGet", "sysDomainQuery"];
const TENANT = [...HELPDESK, "sysAccountCreate", "sysDomainCreate", "sysQueuedMessageGet", "sysQueuedMessageQuery"];
const ADMIN = [...TENANT, "sysMetricGet", "sysMetricQuery"];
/** The dashboard shows what the role may read, and nothing a server refuses. */
describe("the Administration dashboard", () => {
let host: HTMLDivElement;
let root: Root;
const render = async () => {
const { hook } = memoryLocation({ path: "/admin" });
await act(async () => {
root.render(<Router hook={hook}><AdminDashboard /></Router>);
});
await act(async () => {});
};
const cards = () => [...host.querySelectorAll(".admin-card")].map((c) => [c.querySelector(".admin-card-label")?.textContent, c.querySelector(".admin-card-value")?.textContent, c.querySelector(".hint")?.textContent]);
beforeEach(() => {
host = document.createElement("div");
document.body.appendChild(host);
root = createRoot(host);
feeds.metrics = () => Promise.resolve([]);
});
afterEach(async () => {
await act(async () => root.unmount());
host.remove();
});
it("gives a helpdesk role its two counts and nothing about the server", async () => {
signIn(HELPDESK);
await render();
expect(cards()).toEqual([
["Users", "35", "User accounts"],
["Domains", "3", "Mail domains"],
]);
});
it("gives a tenant administrator the queue as well, but no history", async () => {
signIn(TENANT);
await render();
expect(cards().map((c) => c[0])).toEqual(["Users", "Domains", "Pending"]);
});
it("adds the history for a role that reads metrics", async () => {
feeds.metrics = () =>
Promise.resolve([
{ "@type": "Gauge", metric: "server.memory", count: 360_000_000, timestamp: "2026-09-15T15:00:00Z" },
{ "@type": "Counter", metric: "queue.message-queued", count: 93, timestamp: "2026-09-15T15:00:00Z" },
{ "@type": "Counter", metric: "queue.report-queued", count: 39, timestamp: "2026-09-15T15:00:00Z" },
]);
signIn(ADMIN);
await render();
expect(cards().map((c) => [c[0], c[1]])).toEqual([
["Users", "35"],
["Domains", "3"],
["Pending", "9"],
["Server memory", "343 MB"],
["Received", "93"],
["Sent", "39"],
]);
});
it("leaves the history off where the server refuses it, as Community does", async () => {
feeds.metrics = () => Promise.reject(new JmapMethodError("x:Metric/query", { type: "forbidden", description: "This feature is only available in the Enterprise edition" }));
signIn(ADMIN);
await render();
expect(cards().map((c) => c[0])).toEqual(["Users", "Domains", "Pending"]);
});
it("says the history is not recorded rather than showing a quiet day", async () => {
signIn(ADMIN);
await render();
expect(cards().slice(3)).toEqual([
["Server memory", "—", "Not recorded on this server"],
["Received", "—", "Not recorded on this server"],
["Sent", "—", "Not recorded on this server"],
]);
});
it("keeps a card that failed for another reason, and says so", async () => {
feeds.metrics = () => Promise.reject(new Error("offline"));
signIn(ADMIN);
await render();
expect(cards()[4]).toEqual(["Received", "—", "Could not be loaded"]);
});
});
describe("the pointer to Stalwart's own administration", () => {
let host: HTMLDivElement;
let root: Root;
beforeEach(() => {
host = document.createElement("div");
document.body.appendChild(host);
root = createRoot(host);
});
afterEach(async () => {
await act(async () => root.unmount());
host.remove();
});
const renderWith = async (adminUrl: string | null) => {
useSession.setState({ session: { capabilities: {}, accounts: {}, primaryAccounts: {}, username: "[email protected]", ihasmail: { permissions: HELPDESK, server: { edition: "enterprise", adminUrl } } } as unknown as JmapSession });
const { hook } = memoryLocation({ path: "/admin" });
await act(async () => {
root.render(<Router hook={hook}><AdminDashboard /></Router>);
});
await act(async () => {});
};
it("names it, and links it where the operator has said where it is", async () => {
await renderWith("https://admin.example.com");
const note = host.querySelector(".admin-dashboard-note")!;
expect(note.textContent).toContain("Stalwart's own administration");
const link = note.querySelector("a")!;
expect(link.getAttribute("href")).toBe("https://admin.example.com");
expect(link.getAttribute("rel")).toBe("noopener noreferrer");
});
it("names it without a link where nobody has", async () => {
await renderWith(null);
expect(host.querySelector(".admin-dashboard-note")?.textContent).toContain("Stalwart's own administration");
expect(host.querySelector(".admin-dashboard-note a")).toBeNull();
});
});