Administration used to open on its first section. It opens on a grid of cards now: users, domains, messages waiting in the delivery queue, server memory, and the last 24 hours' received and sent. Each card is there only when the role holds what its number needs -- a count is a query, the metric history a query and a get -- so a helpdesk role that reads accounts and domains sees those two cards and nothing about the server. What the cards count is whatever Stalwart answers for the signed-in account, which scopes a tenant administrator's accounts, domains and queue to the tenancy. The metric history has no tenant in it, and Stalwart's Tenant Administrator role does not hold it, so a tenant's dashboard is users, domains and pending. The history is Enterprise-only and switched off by default. A server that refuses it leaves those cards off; one that records nothing says so rather than showing zeroes. Received and sent add up the queue counters Stalwart's own dashboard uses, filtered with the comparison names the live server accepts (a bare timestamp is unsupportedFilter). The column count follows the number of cards so rows stay even, and falls back by the grid's own width rather than the window's. The server's test for whether an account is offered Administration matches the client's again, now that a count is enough. The mock answers the queue and an hourly history ending in the current hour; MOCK_METRICS=off refuses the history as Community does, a tenant administrator gets the queue, and helpdesk reads domains, as the demo's does. ROADMAP and FEATURES said reporting and queues were out of scope; they say the dashboard reads a handful of numbers and that managing queues, logs and settings stays out. KNOWN-ISSUES records what was settled on the live server and what was only read from source. Fourteen new strings, in all nine catalogues.
77 lines
4.1 KiB
TypeScript
77 lines
4.1 KiB
TypeScript
import { test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { administrationAllowed, gateAdministration, grantsAdministration, mayNameRegistryMethod } from "./adminGate.js";
|
|
|
|
const req = (...methods: string[]) => JSON.stringify({ using: ["urn:ietf:params:jmap:core"], methodCalls: methods.map((m, i) => [m, {}, `c${i}`]) });
|
|
|
|
/**
|
|
* With ADMINISTRATION=0 an administrator's browser must not be a way round the
|
|
* operator's decision. Hiding the menu would leave the proxy forwarding the
|
|
* very calls the menu made.
|
|
*/
|
|
test("mail, calendars and the rest pass untouched", () => {
|
|
const r = gateAdministration(req("Email/query", "Mailbox/get", "CalendarEvent/set", "FileNode/get", "Principal/getAvailability"));
|
|
assert.equal(r.ok, true);
|
|
});
|
|
|
|
test("the account's own registry objects pass", () => {
|
|
assert.equal(gateAdministration(req("x:AccountSettings/get", "x:AppPassword/set", "x:PublicKey/get", "x:MaskedEmail/set")).ok, true);
|
|
});
|
|
|
|
test("directory and server objects are refused, and named", () => {
|
|
for (const m of ["x:Account/get", "x:Domain/set", "x:Role/query", "x:Tenant/get", "x:SystemSettings/set", "x:DkimSignature/get"]) {
|
|
assert.deepEqual(gateAdministration(req("Email/get", m)), { ok: false, method: m });
|
|
}
|
|
});
|
|
|
|
test("a body that could name a registry method and cannot be read is refused rather than forwarded", () => {
|
|
assert.deepEqual(gateAdministration('{"methodCalls": [["x:Account/get"'), { ok: false, method: null });
|
|
assert.deepEqual(gateAdministration(JSON.stringify({ methodCalls: "x:Account/get" })), { ok: false, method: null });
|
|
assert.deepEqual(gateAdministration(JSON.stringify({ methodCalls: [[{}, {}, "c"]], note: "x:" })), { ok: false, method: null });
|
|
});
|
|
|
|
test("a body that cannot name a registry method is forwarded exactly as it came", () => {
|
|
// Most traffic from a session that may not administer: no parse, no rewrite.
|
|
const raw = '{"using":["urn:ietf:params:jmap:core"],"methodCalls":[["Email/get",{"ids":["a"]},"c"]]}';
|
|
assert.equal(mayNameRegistryMethod(raw), false);
|
|
assert.deepEqual(gateAdministration(raw), { ok: true, body: raw });
|
|
});
|
|
|
|
test("a method name hidden behind a unicode escape is still found", () => {
|
|
// JSON.parse and the server both read \u0078 as "x"; a substring check alone would not.
|
|
const raw = '{"methodCalls":[["\\u0078:Account/get",{},"c"]]}';
|
|
assert.equal(mayNameRegistryMethod(raw), true);
|
|
assert.deepEqual(gateAdministration(raw), { ok: false, method: "x:Account/get" });
|
|
});
|
|
|
|
/**
|
|
* The operator's rule: administration only from a session signed in with
|
|
* "This is my own device" ticked, and never when the installation turned it off.
|
|
*/
|
|
test("administration needs both the installation and a device marked as the person's own", () => {
|
|
assert.equal(administrationAllowed(true, true), true);
|
|
assert.equal(administrationAllowed(true, false), false);
|
|
assert.equal(administrationAllowed(false, true), false);
|
|
});
|
|
|
|
test("an account counts as an administrator by the same test the menu makes", () => {
|
|
assert.equal(grantsAdministration(["sysAccountQuery", "sysAccountGet"]), true);
|
|
assert.equal(grantsAdministration(["sysDomainQuery", "sysDomainGet"]), true);
|
|
// The dashboard opens on less than a list: a count is only a query.
|
|
assert.equal(grantsAdministration(["sysAccountQuery"]), true);
|
|
assert.equal(grantsAdministration(["sysQueuedMessageQuery"]), true);
|
|
assert.equal(grantsAdministration(["sysMetricQuery", "sysMetricGet"]), true);
|
|
assert.equal(grantsAdministration(["sysMetricQuery"]), false);
|
|
assert.equal(grantsAdministration(["sysAccountGet", "sysDomainGet"]), false);
|
|
assert.equal(grantsAdministration(["jmapEmailGet", "sysAccountSettingsGet"]), false);
|
|
});
|
|
|
|
test("what is forwarded is what was checked", () => {
|
|
// A duplicate key is read one way by JSON.parse; forwarding the parsed form
|
|
// means the server cannot read it the other way.
|
|
const raw = '{"methodCalls":[["x:Account/get",{},"a"]],"methodCalls":[["Email/get",{},"b"]]}';
|
|
const r = gateAdministration(raw);
|
|
assert.equal(r.ok, true);
|
|
if (r.ok) assert.equal(r.body, JSON.stringify({ methodCalls: [["Email/get", {}, "b"]] }));
|
|
});
|