Files
meshcore-analyzer/tests/unit/test-admin-dashboard-ui.js
T
efitenandClaude Opus 5.5 de364a8bb6 feat: mail notifications for watched nodes (user management part E) (#2140)
Part E of #2128: a logged-in user watches nodes and gets one mail when a
watched node goes offline, comes back, or reports a low battery. Admins
can add instance events: a new foreign node, and an observer going
offline or back. This covers the per-user mail part of #775.

Stacked on #2139 (part D, which builds on #2138). Review the commits
after that branch.

## The situation

A sysop learns that a repeater went silent, or that its battery is
running down, only by opening CoreScope. #775 asks for notifications.
Accounts (part A) now give a verified address to mail and a mailer with
delivery status.

## What this PR adds

**Events.** They use the instance thresholds, so a mail does not
disagree with the node page:
- `node.offline`: silent for the role's `healthThresholds` window. Last
heard comes from the packet store, and repeaters and rooms count relayed
traffic (#1598).
- `node.battery`: advert telemetry below `batteryThresholds.lowMv`,
recovered at `lowMv + 100`.
- Admins only: `foreign.new` (once per node) and `observer.offline`.

**Store.** Schema v5 with `notification_prefs`, `notification_watches`
and `notification_state`.

**Server** (only with `userManagement.notifications.enabled`)
- A notifier next to the janitor, every `intervalMinutes` (5). The
evaluator is a pure function of watches, prefs, stored states and node
snapshots.
- No burst after a restart or a new watch: a subject's first evaluation
stores its state without mailing. The loop waits for the startup load.
While ingest is stale (newest packet older than 30 minutes) the offline
checks pause, and after recovery they wait one silent window.
- One mail per user per check. Limits: 20 per user and 100 per instance
per rolling 24 hours, 50 watches per user. A change over a limit is
recorded and never mailed later.
- Every mail has a one-click unsubscribe (`List-Unsubscribe` and
`List-Unsubscribe-Post`). The GET only redirects to a confirm page, so
link scanners cannot unsubscribe anyone. Node names are cleaned of
control and bidi characters before they go into a mail.
- Routes: `GET`/`PUT /api/account/notifications`, `PUT`/`DELETE
/api/account/notifications/watches/{pubkey}`, `POST
/api/account/notifications/watch-my-nodes` (copies the synced "my nodes"
list), `GET`/`POST /api/notifications/unsubscribe`. `mailer.Message`
gains `Headers`.

**Frontend.** A "Notify me" toggle on the node pages, a Notifications
section on the account page (`#/account?section=notifications`), an
unsubscribe page, and the notification mail count on the admin overview.

Spec:
[`docs/specs/2026-10-07-node-notifications-design.md`](https://github.com/efiten/CoreScope/blob/feat/notifications/docs/specs/2026-10-07-node-notifications-design.md).

## Performance

Each check reads watches, prefs and states from `users.db` (one query
each), the watched nodes from the analyzer DB in chunks of 500, and
last-heard times from the packet store.

| Measurement | Result |
|---|---|
| One check on our staging instance, 130,000 packets in memory, 1
watcher | 6.9 ms, of which 0.93 ms holding the store read lock |
| Evaluator benchmark, 100 users with 50 watches each, 2,000 nodes |
1.75 ms |

No work is added to ingest, broadcast or any request path.

## Verification

- `internal/users` and `internal/mailer` with `-race`; the `cmd/server`
suite plus `-race` on the notifier tests; 74 new Go tests. The same
Windows-only failure as noted in #2138 applies.
- `sh test-all.sh` exits 0; the XSS gate in diff mode passes.
- User-management E2E: 26 of 26 steps locally. With notifications off
the new steps fail, so they do test the flag.
- On our staging and production instance since 7 October 2026.

## Not in this PR

- Other channels (Discord, Telegram, webhooks). Detection is separate
from delivery, so one can be added.
- The topology, RF and anomaly alerts of #775.
- If the whole server starts after a feed outage that already ended, the
grace window is unknown and a watcher can get one wrong offline mail.
The user guide says so.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 22:02:39 +02:00

690 lines
36 KiB
JavaScript

/* Unit tests for the admin area (docs/specs/2026-10-07-admin-dashboard-design.md):
* public/admin-audit.js, public/admin-overview.js and public/admin.js, each
* loaded from disk into a vm sandbox (same pattern as
* test-user-management-ui.js). */
'use strict';
const vm = require('vm');
const fs = require('fs');
const path = require('path');
const assert = require('assert');
const ROOT = path.resolve(__dirname, '..', '..');
let passed = 0, failed = 0;
const pending = [];
function test(name, fn) {
const p = Promise.resolve().then(fn).then(
() => { passed++; console.log(' ok ' + name); },
(e) => { failed++; console.log(' FAIL ' + name + ': ' + e.message); });
pending.push(p);
return p;
}
const tick = () => new Promise((r) => setTimeout(r, 5));
const src = (f) => fs.readFileSync(path.join(ROOT, f), 'utf8');
const XSS = '<img src=x onerror=alert(1)>';
// escapeHtml comes from the real app.js, not a copy.
function loadEscapeHtml() {
const m = src('public/app.js').match(/function escapeHtml\(s\) \{[\s\S]*?\n\}/);
assert(m, 'escapeHtml not found in app.js');
return vm.runInNewContext('(' + m[0].replace(/^function escapeHtml/, 'function') + ')');
}
// Elements are created on first lookup and remember handlers and content.
function makeDom() {
const els = {};
const mk = (id) => {
const el = { id, value: '', checked: false, textContent: '', innerHTML: '', hidden: false, handlers: {} };
el.addEventListener = (t, fn) => { el.handlers[t] = fn; };
el.insertAdjacentHTML = (pos, html) => { el.innerHTML += html; };
el.querySelector = (sel) => els[id + ' ' + sel] || (els[id + ' ' + sel] = mk(id + ' ' + sel));
return el;
};
const document = {
visibilityState: 'visible',
getElementById(id) { return els[id] || (els[id] = mk(id)); },
querySelector(sel) { return els[sel] || (els[sel] = mk(sel)); },
};
return { els, mk, document };
}
// loadTab runs the given public files with window === the sandbox global,
// a fake CSAuth whose request() answers from routes(path), a recording
// history.replaceState and recordable intervals.
function loadTab(files, hash, routes, extra) {
const dom = makeDom();
const loc = { hash };
const replaced = [];
const calls = [];
const bodies = [];
const timers = [];
const CSAuth = {
request(method, p, body) { calls.push(p); bodies.push(body); return Promise.resolve().then(() => routes(p, body)); },
say(id, text, ok) { const el = dom.document.getElementById(id); el.textContent = text; el.ok = !!ok; },
errText(r) { return (r.data && r.data.error) || ('Request failed (HTTP ' + r.status + ')'); },
};
const ctx = Object.assign({
document: dom.document, location: loc, URLSearchParams, Promise, String, Number, Object, Math, Date, JSON, console, CSAuth,
history: { replaceState(a, b, h) { replaced.push(h); loc.hash = h; } },
escapeHtml: loadEscapeHtml(),
setInterval(fn, ms) { timers.push({ fn, ms, cleared: false }); return timers.length; },
clearInterval(id) { if (timers[id - 1]) timers[id - 1].cleared = true; },
}, extra || {});
ctx.window = ctx;
vm.createContext(ctx);
[].concat(files).forEach((f) => vm.runInContext(src(f), ctx));
return { ctx, dom, els: dom.els, loc, replaced, calls, bodies, timers };
}
const NOW = Date.UTC(2026, 9, 7, 12, 0, 0);
const OK = (data) => ({ ok: true, status: 200, data });
console.log('admin-audit.js');
const E = (o) => Object.assign({ id: 10, at: '2026-10-07T10:00:00Z', action: 'user.login.failed', actor: null,
target: { id: 7, displayName: 'Eve', email: 'eve@example.org' }, detail: { reason: 'wrong_password' } }, o);
const auditEnv = (hash, routes) => loadTab('public/admin-audit.js', hash, routes);
const auditT = () => auditEnv('', () => OK({ entries: [], next: null })).ctx.CSAdminAudit._test;
const rows = (html) => (html.match(/<tr /g) || []).length;
test('readHash keeps only known actions, numeric users and known periods', () => {
const t = auditT();
const rh = (h) => JSON.parse(JSON.stringify(t.readHash(h)));
assert.deepStrictEqual(rh('#/admin?tab=audit&action=user.login.failed&user=12&period=7d'),
{ action: 'user.login.failed', user: '12', period: '7d' });
assert.deepStrictEqual(rh('#/admin?tab=audit&action=drop&user=1x&period=1y'), { action: '', user: '', period: '' });
assert.strictEqual(rh('#/admin?tab=audit&user=0').user, '', 'user=0 is not an account id');
assert.strictEqual(rh('#/admin?tab=audit&user=007').user, '', 'leading zeros are rejected');
assert.strictEqual(rh('#/admin?tab=audit&action=user.login.*').action, 'user.login.*');
});
test('the audit action filter offers channel proposals', () => {
assert.strictEqual(auditT().readHash('#/admin?tab=audit&action=proposal.*').action, 'proposal.*');
});
test('hashFor writes tab=audit and only the set filters', () => {
const t = auditT();
assert.strictEqual(t.hashFor({ action: 'user.login.*', user: '12', period: '' }), '#/admin?tab=audit&action=user.login.*&user=12');
assert.strictEqual(t.hashFor({ action: '', user: '', period: '30d' }), '#/admin?tab=audit&period=30d');
});
test('apiPath turns the period into from, adds before and the page size', () => {
const t = auditT();
const from = encodeURIComponent(new Date(NOW - 864e5).toISOString());
assert.strictEqual(t.apiPath({ action: 'user.login.failed', user: '12', period: '24h' }, null, NOW),
'/api/admin/audit?action=user.login.failed&user=12&from=' + from + '&limit=100');
assert.strictEqual(t.apiPath({ action: '', user: '', period: '' }, 55, NOW), '/api/admin/audit?before=55&limit=100');
});
test('rows escape actions, names, emails and details; deleted and system refs', () => {
const t = auditT();
const html = t.rowHtml(E({ action: XSS, actor: { id: 3, displayName: XSS, email: XSS }, detail: { [XSS]: XSS } }));
assert(html.indexOf('<img') === -1, 'raw markup: ' + html);
assert(html.indexOf('&lt;img src=x onerror=alert(1)&gt;') !== -1);
assert.strictEqual(t.refHtml({ id: 5, deleted: true }), '#5 <span class="account-hint">(deleted)</span>');
assert.strictEqual(t.refHtml(null), '<span class="account-hint">system</span>');
assert(t.refHtml({ id: 7, displayName: 'Eve', email: 'eve@example.org' }).indexOf('href="#/admin?tab=users&amp;id=7"') !== -1);
assert.strictEqual(t.detailText({ b: '2', a: '1' }), 'a=1, b=2');
});
test('mount reads the hash, renders rows, and Load more appends with before', async () => {
const env = auditEnv('#/admin?tab=audit&action=user.login.failed&user=7', (p) =>
p.indexOf('before=9') !== -1 ? OK({ entries: [E({ id: 8 })], next: null }) : OK({ entries: [E({ id: 10 }), E({ id: 9 })], next: 9 }));
env.ctx.CSAdminAudit.mount(env.dom.mk('c'));
await tick();
assert(env.calls[0].indexOf('action=user.login.failed&user=7') !== -1, env.calls[0]);
assert.strictEqual(env.els.auditAction.value, 'user.login.failed');
assert.strictEqual(rows(env.els.auditBody.innerHTML), 2);
assert(env.els.auditBody.innerHTML.indexOf('data-action="user.login.failed"') !== -1);
assert.strictEqual(env.els.auditMore.hidden, false);
env.els.auditMore.handlers.click();
await tick();
assert(env.calls[1].indexOf('before=9') !== -1, env.calls[1]);
assert.strictEqual(rows(env.els.auditBody.innerHTML), 3);
assert.strictEqual(env.els.auditMore.hidden, true);
});
test('an empty last page keeps the rows and hides Load more', async () => {
const env = auditEnv('#/admin?tab=audit', (p) =>
p.indexOf('before=9') !== -1 ? OK({ entries: [], next: null }) : OK({ entries: [E({ id: 10 }), E({ id: 9 })], next: 9 }));
env.ctx.CSAdminAudit.mount(env.dom.mk('c'));
await tick();
env.els.auditMore.handlers.click();
await tick();
assert.strictEqual(rows(env.els.auditBody.innerHTML), 2);
assert(env.els.auditBody.innerHTML.indexOf('No entries match') === -1);
assert.strictEqual(env.els.auditMore.hidden, true);
});
test('no entries shows a message row', async () => {
const env = auditEnv('#/admin?tab=audit', () => OK({ entries: [], next: null }));
env.ctx.CSAdminAudit.mount(env.dom.mk('c'));
await tick();
assert(env.els.auditBody.innerHTML.indexOf('No entries match.') !== -1);
});
test('filter changes rewrite the hash with replaceState and reload; the user id input is validated', async () => {
const env = auditEnv('#/admin?tab=audit&action=user.login.failed&user=7', () => OK({ entries: [E()], next: null }));
env.ctx.CSAdminAudit.mount(env.dom.mk('c'));
await tick();
assert.strictEqual(env.els.auditUser.value, '7');
env.els.auditPeriod.handlers.change({ target: { value: '7d' } });
await tick();
assert.strictEqual(env.loc.hash, '#/admin?tab=audit&action=user.login.failed&user=7&period=7d');
assert.strictEqual(env.replaced.length, 1);
assert(env.calls[1].indexOf('from=') !== -1, env.calls[1]);
env.els.auditUser.handlers.change({ target: { value: '12' } });
await tick();
assert.strictEqual(env.loc.hash, '#/admin?tab=audit&action=user.login.failed&user=12&period=7d');
assert(env.calls[2].indexOf('user=12') !== -1, env.calls[2]);
const n = env.calls.length;
const ev = { target: env.els.auditUser };
env.els.auditUser.value = '1x';
env.els.auditUser.handlers.change(ev);
await tick();
assert.strictEqual(env.calls.length, n, 'invalid input must not reload');
assert.strictEqual(env.els.auditUser.value, '12');
for (const bad of ['0', '007']) {
env.els.auditUser.value = bad;
env.els.auditUser.handlers.change(ev);
await tick();
assert.strictEqual(env.calls.length, n, bad + ' must not reload');
assert.strictEqual(env.els.auditUser.value, '12', bad + ' must be reset');
}
env.els.auditUser.handlers.change({ target: { value: '' } });
await tick();
assert.strictEqual(env.loc.hash, '#/admin?tab=audit&action=user.login.failed&period=7d');
assert(env.calls[env.calls.length - 1].indexOf('user=') === -1);
});
test('a refused or failed request shows its error in auditMsg', async () => {
const refused = auditEnv('#/admin?tab=audit', () => ({ ok: false, status: 500, data: { error: 'boom' } }));
refused.ctx.CSAdminAudit.mount(refused.dom.mk('c'));
await tick();
assert.strictEqual(refused.els.auditMsg.textContent, 'boom');
const broken = auditEnv('#/admin?tab=audit', () => Promise.reject(new Error('net')));
broken.ctx.CSAdminAudit.mount(broken.dom.mk('c'));
await tick();
assert.strictEqual(broken.els.auditMsg.textContent, 'Network error, try again.');
});
test('a successful load clears an earlier error', async () => {
let fail = true;
const env = auditEnv('#/admin?tab=audit', () => fail ? { ok: false, status: 500, data: { error: 'boom' } } : OK({ entries: [E()], next: null }));
env.ctx.CSAdminAudit.mount(env.dom.mk('c'));
await tick();
assert.strictEqual(env.els.auditMsg.textContent, 'boom');
fail = false;
env.els.auditPeriod.handlers.change({ target: { value: '7d' } });
await tick();
assert.strictEqual(env.els.auditMsg.textContent, '');
});
test('a filter change clears stale rows and the cursor; the old response is dropped', async () => {
const held = [];
const env = auditEnv('#/admin?tab=audit', (p) => p.indexOf('from=') !== -1
? OK({ entries: [E({ id: 50 })], next: null })
: (held.length ? Promise.resolve(OK({ entries: [E({ id: 10 })], next: 9 })) : new Promise((r) => { held.push(r); })));
env.ctx.CSAdminAudit.mount(env.dom.mk('c'));
await tick();
env.els.auditPeriod.handlers.change({ target: { value: '7d' } });
assert.strictEqual(env.els.auditMore.hidden, true);
assert.strictEqual(env.els.auditBody.innerHTML, '');
await tick();
assert.strictEqual(rows(env.els.auditBody.innerHTML), 1);
held[0](OK({ entries: [E({ id: 10 }), E({ id: 9 })], next: 9 }));
await tick();
assert.strictEqual(rows(env.els.auditBody.innerHTML), 1, 'stale response must be dropped');
assert.strictEqual(env.els.auditMore.hidden, true);
});
test('a failed filter request leaves no stale rows', async () => {
let fail = false;
const env = auditEnv('#/admin?tab=audit', () => fail ? { ok: false, status: 500, data: { error: 'boom' } } : OK({ entries: [E()], next: 9 }));
env.ctx.CSAdminAudit.mount(env.dom.mk('c'));
await tick();
fail = true;
env.els.auditPeriod.handlers.change({ target: { value: '7d' } });
await tick();
assert.strictEqual(env.els.auditBody.innerHTML, '');
assert.strictEqual(env.els.auditMore.hidden, true);
});
test('unmount drops a response that arrives later', async () => {
let release;
const env = auditEnv('#/admin?tab=audit', () => new Promise((r) => { release = r; }));
env.ctx.CSAdminAudit.mount(env.dom.mk('c'));
await tick();
env.ctx.CSAdminAudit.unmount();
release(OK({ entries: [E()], next: null }));
await tick();
assert.strictEqual(env.els.auditBody.innerHTML, '');
});
console.log('admin-overview.js');
const STATS = { total: 3, active: 2, pending: 1, disabled: 0, admins: 1, stuckPending: 1, bouncing: 2, new7d: 3, new30d: 3,
newPerDay: [{ day: '2026-10-06', count: 1 }, { day: '2026-10-07', count: 2 }], active7d: 2, active30d: 2, logins24h: 4,
failedLogins24h: 6, mail7d: { delivered: 1, bounced: 0, blocked: 0, spam: 0, pending: 2, other: 0 },
guessing: [{ userId: 7, displayName: 'Eve', failed: 6 }] };
const MQTT = { sources: [
{ name: 'ok', connected: true, lastPacketUnix: NOW / 1000 - 60 },
{ name: 'off', connected: false, lastPacketUnix: NOW / 1000 - 60 },
{ name: 'quiet', connected: true, lastPacketUnix: NOW / 1000 - 11 * 60 },
{ name: 'never', connected: true, lastPacketUnix: 0 }] };
const HEALTH = { version: 'v9.9.9', commit: 'abc1234', uptimeHuman: '1h 2m' };
const OBS = { observers: [{ online: true }, { online: false }] };
const observersStub = { ObserversSummary: { computeCounts: (list) => ({ online: list.filter((o) => o.online).length, total: list.length }) } };
const overviewEnv = (routes) => loadTab(['public/mqtt-status-panel.js', 'public/admin-overview.js'], '#/admin', routes, observersStub);
const allOk = (p) => OK({ '/api/admin/stats': STATS, '/api/health': HEALTH, '/api/healthz': { ready: true },
'/api/mqtt/status': MQTT, '/api/observers': OBS }[p]);
const ovT = () => overviewEnv(allOk).ctx.CSAdminOverview._test;
const resOf = (o) => Object.assign({ stats: { data: STATS }, health: { data: HEALTH }, healthz: { data: { ready: true } },
mqtt: { data: MQTT }, observers: { data: OBS } }, o);
test('attention items from fixed data: stuck, bouncing, guessing, three MQTT sources down', () => {
const items = ovT().attentionItems(resOf({}), NOW);
assert.deepStrictEqual([...items.map((i) => i.href)], ['#/admin?tab=users&status=pending', '#/admin?tab=users&bouncing=1',
'#/admin?tab=audit&action=user.login.failed&user=7', '#/observers', '#/observers', '#/observers']);
const mqttText = items.slice(3).map((i) => i.text).join(' | ');
assert(mqttText.indexOf('off') !== -1 && mqttText.indexOf('quiet') !== -1 && mqttText.indexOf('never') !== -1, mqttText);
assert(mqttText.indexOf(' ok ') === -1, mqttText);
assert(items[2].text.indexOf('6 failed logins') !== -1 && items[2].text.indexOf('Eve') !== -1, items[2].text);
});
test('mqttDown: not connected, never a message, or older than 10 minutes', () => {
const t = ovT();
assert.strictEqual(t.mqttDown({ connected: true, lastPacketUnix: NOW / 1000 - 9 * 60 }, NOW), false);
assert.strictEqual(t.mqttDown({ connected: true, lastPacketUnix: NOW / 1000 - 11 * 60 }, NOW), true);
assert.strictEqual(t.mqttDown({ connected: true, lastPacketUnix: 0 }, NOW), true);
assert.strictEqual(t.mqttDown({ connected: false, lastPacketUnix: NOW / 1000 }, NOW), true);
});
test('nothing to report renders no attention section; failed sources add no items', () => {
const t = ovT();
const calm = Object.assign({}, STATS, { stuckPending: 0, bouncing: 0, guessing: [] });
assert.strictEqual(t.attentionItems(resOf({ stats: { data: calm }, mqtt: { data: { sources: [MQTT.sources[0]] } } }), NOW).length, 0);
assert.strictEqual(t.attentionItems(resOf({ stats: { error: true }, mqtt: { error: true } }), NOW).length, 0);
assert.strictEqual(t.attentionHtml([]), '');
});
test('names, source names and versions are escaped', () => {
const t = ovT();
const res = resOf({ stats: { data: Object.assign({}, STATS, { guessing: [{ userId: 7, displayName: XSS, failed: 5 }] }) },
mqtt: { data: { sources: [{ name: XSS, connected: false, lastPacketUnix: 0 }] } },
health: { data: { version: XSS, commit: XSS, uptimeHuman: XSS } } });
const html = t.render(res, NOW);
assert(html.indexOf('<img') === -1, 'raw markup: ' + html);
assert(html.indexOf('&lt;img src=x onerror=alert(1)&gt;') !== -1);
});
test('an empty instance renders zeros and no attention section', () => {
const t = ovT();
const zero = { total: 0, active: 0, pending: 0, disabled: 0, admins: 0, stuckPending: 0, bouncing: 0, new7d: 0, new30d: 0,
newPerDay: [{ day: '2026-10-07', count: 0 }], active7d: 0, active30d: 0, logins24h: 0, failedLogins24h: 0,
mail7d: { delivered: 0, bounced: 0, blocked: 0, spam: 0, pending: 0, other: 0 }, guessing: [] };
const html = t.render(resOf({ stats: { data: zero }, mqtt: { data: { sources: [] } }, observers: { data: { observers: [] } } }), NOW);
assert(html.indexOf('adminAttention') === -1, html);
assert(html.indexOf('data-stat="total">0<') !== -1);
assert(html.indexOf('No MQTT sources reported.') !== -1);
});
test('fetchAll keeps each source on its own; healthz 503 warming up is data, not an error', async () => {
const t = ovT();
// Functions, so the rejected promise only exists for the stats request.
const answers = {
'/api/admin/stats': () => Promise.reject(new Error('net')),
'/api/health': () => OK(HEALTH),
'/api/healthz': () => ({ ok: false, status: 503, data: { ready: false, reason: 'loading' } }),
'/api/mqtt/status': () => ({ ok: false, status: 500, data: {} }),
'/api/observers': () => OK(OBS),
};
const res = await t.fetchAll((m, p) => Promise.resolve().then(answers[p]));
assert.strictEqual(res.stats.error, true);
assert.strictEqual(res.healthz.data.ready, false);
assert.strictEqual(res.mqtt.error, true);
const html = t.render(res, NOW);
assert(html.indexOf('Could not load user figures') !== -1);
assert(html.indexOf('Could not load MQTT status') !== -1);
assert(html.indexOf('warming up') !== -1);
assert(html.indexOf('v9.9.9') !== -1);
assert(html.indexOf('Could not load server health') === -1 && html.indexOf('Could not load observers') === -1);
});
test('mount fetches five sources, a timer tick (visible only) skips healthz, Refresh and Retry read it, unmount stops the timer', async () => {
const env = overviewEnv(allOk);
const ov = env.ctx.CSAdminOverview;
ov.mount(env.dom.mk('c'));
await tick();
assert.strictEqual(env.calls.length, 5);
assert(env.els.aoBody.innerHTML.indexOf('data-stat="total">3<') !== -1, env.els.aoBody.innerHTML);
assert(env.els.aoBody.innerHTML.indexOf('data-stat="observersOnline">1<') !== -1);
const timer = env.timers[env.timers.length - 1];
assert.strictEqual(timer.ms, 60000);
env.dom.document.visibilityState = 'hidden';
timer.fn();
await tick();
assert.strictEqual(env.calls.length, 5);
env.dom.document.visibilityState = 'visible';
timer.fn();
await tick();
assert.strictEqual(env.calls.length, 9); // the timer skips /api/healthz
assert.strictEqual(env.calls.filter((p) => p === '/api/healthz').length, 1);
assert(env.els.aoBody.innerHTML.indexOf('>ready<') !== -1, 'last healthz result kept');
env.els.aoBody.handlers.click({ target: { closest: () => ({}) } }); // Retry
await tick();
assert.strictEqual(env.calls.length, 14);
assert.strictEqual(env.calls.filter((p) => p === '/api/healthz').length, 2);
env.els.aoRefresh.handlers.click();
await tick();
assert.strictEqual(env.calls.length, 19);
ov.unmount();
assert.strictEqual(timer.cleared, true);
});
test('Refresh and Retry start no second full refresh while one is in flight; the button is disabled until it settles', async () => {
const held = [];
const env = overviewEnv((p) => (p === '/api/healthz' ? new Promise((r) => { held.push(r); }) : allOk(p)));
const ov = env.ctx.CSAdminOverview;
ov.mount(env.dom.mk('c'));
await tick();
const hz = () => env.calls.filter((p) => p === '/api/healthz').length;
assert.strictEqual(hz(), 1);
assert.strictEqual(env.els.aoRefresh.disabled, true, 'disabled while the mount refresh runs');
env.els.aoRefresh.handlers.click();
env.els.aoRefresh.handlers.click();
env.els.aoBody.handlers.click({ target: { closest: () => ({}) } }); // Retry
await tick();
assert.strictEqual(hz(), 1, 'clicks during a full refresh start no other');
held.shift()(OK({ ready: true }));
await tick();
assert.strictEqual(env.els.aoRefresh.disabled, false, 'enabled once it settles');
env.els.aoRefresh.handlers.click();
env.els.aoRefresh.handlers.click();
await tick();
assert.strictEqual(hz(), 2, 'two rapid clicks make one /api/healthz request');
ov.unmount();
});
console.log('admin.js');
function loadShell(hash, opts) {
opts = opts || {};
const dom = makeDom();
const loc = { hash };
const replaced = [];
const pages = {};
const listeners = {};
const me = { current: opts.me === undefined ? { id: 1, role: 'admin' } : opts.me };
const mods = {};
['CSAdminOverview', 'CSAdminUsers', 'CSAdminAudit', 'CSAdminProposals'].forEach((n) => {
mods[n] = { mounted: 0, unmounted: 0, el: null, mount(el) { this.mounted++; this.el = el; }, unmount() { this.unmounted++; } };
});
const CSAuth = { ready: () => Promise.resolve(), isEnabled: () => opts.enabled !== false,
isAdmin: () => !!me.current && me.current.role === 'admin' };
const ctx = Object.assign({ document: dom.document, location: loc, URLSearchParams, Promise, String, console, CSAuth,
MC_USER_MGMT: opts.proposals ? { enabled: true, channelProposals: true } : null,
history: { replaceState(a, b, h) { replaced.push(h); loc.hash = h; } }, escapeHtml: loadEscapeHtml(),
registerPage(n, m) { pages[n] = m; }, addEventListener(t, fn) { listeners[t] = fn; } }, mods);
ctx.window = ctx;
vm.createContext(ctx);
vm.runInContext(src('public/admin.js'), ctx);
return { t: ctx.CSAdmin._test, page: pages.admin, app: { innerHTML: '' }, loc, replaced, mods, els: dom.els,
fire(detail) { me.current = detail; if (listeners['cs-auth-changed']) listeners['cs-auth-changed']({ detail }); } };
}
test('readTab defaults to overview; legacyRewrite maps #/admin/users only', () => {
const t = loadShell('#/admin').t;
assert.strictEqual(t.readTab('#/admin').id, 'overview');
assert.strictEqual(t.readTab('#/admin?tab=bogus').id, 'overview');
assert.strictEqual(t.readTab('#/admin?tab=audit&user=3').id, 'audit');
assert.strictEqual(t.legacyRewrite('#/admin/users?status=pending&id=7'), '#/admin?tab=users&status=pending&id=7');
assert.strictEqual(t.legacyRewrite('#/admin/users'), '#/admin?tab=users');
assert.strictEqual(t.legacyRewrite('#/admin?tab=users'), null);
});
test('tab links: one active link with aria-current', () => {
const html = loadShell('#/admin').t.tabsHtml('audit');
assert.strictEqual((html.match(/aria-current="page"/g) || []).length, 1);
assert(html.indexOf('class="tab-btn active" href="#/admin?tab=audit" aria-current="page"') !== -1, html);
assert(html.indexOf('href="#/admin?tab=overview"') !== -1 && html.indexOf('href="#/admin?tab=users"') !== -1);
});
test('#/admin mounts the overview tab into #adminTab', async () => {
const env = loadShell('#/admin');
await env.page.init(env.app, null);
assert.strictEqual(env.mods.CSAdminOverview.mounted, 1);
assert.strictEqual(env.mods.CSAdminOverview.el, env.els.adminTab);
assert(env.app.innerHTML.indexOf('<h2>Admin</h2>') !== -1);
});
test('the old #/admin/users link is rewritten with replaceState and opens the Users tab with its detail id', async () => {
const env = loadShell('#/admin/users?status=pending&id=7');
await env.page.init(env.app, 'users');
assert.deepStrictEqual(env.replaced, ['#/admin?tab=users&status=pending&id=7']);
assert.strictEqual(env.mods.CSAdminUsers.mounted, 1);
assert.strictEqual(env.mods.CSAdminOverview.mounted, 0);
});
test('feature off, or an unknown sub-route, is Not found', async () => {
const off = loadShell('#/admin', { enabled: false });
await off.page.init(off.app, null);
assert(off.app.innerHTML.indexOf('Not found') !== -1);
const sub = loadShell('#/admin/other');
await sub.page.init(sub.app, 'other');
assert(sub.app.innerHTML.indexOf('Not found') !== -1);
assert.strictEqual(off.mods.CSAdminOverview.mounted + sub.mods.CSAdminOverview.mounted, 0);
});
test('a non-admin sees Admins only and no tab is mounted', async () => {
const env = loadShell('#/admin?tab=audit', { me: { id: 2, role: 'user' } });
await env.page.init(env.app, null);
assert(env.app.innerHTML.indexOf('Admins only') !== -1);
assert.strictEqual(env.mods.CSAdminAudit.mounted, 0);
});
test('auth changes: losing admin unmounts the tab and shows Admins only; logout goes to the login view', async () => {
const env = loadShell('#/admin?tab=audit');
await env.page.init(env.app, null);
env.fire({ id: 1, role: 'user' });
await tick();
assert.strictEqual(env.mods.CSAdminAudit.unmounted, 1);
assert(env.app.innerHTML.indexOf('Admins only') !== -1, env.app.innerHTML);
env.fire(null);
assert.strictEqual(env.loc.hash, '#/account/login');
});
test('destroy unmounts the tab; later auth changes are ignored', async () => {
const env = loadShell('#/admin?tab=users');
await env.page.init(env.app, null);
env.page.destroy();
assert.strictEqual(env.mods.CSAdminUsers.unmounted, 1);
env.loc.hash = '#/home';
env.fire(null);
assert.strictEqual(env.loc.hash, '#/home');
});
test('a tab switch (router destroy, then init on the new hash) unmounts the old tab', async () => {
const env = loadShell('#/admin?tab=overview');
await env.page.init(env.app, null);
env.page.destroy();
env.loc.hash = '#/admin?tab=audit';
await env.page.init(env.app, null);
assert.strictEqual(env.mods.CSAdminOverview.unmounted, 1);
assert.strictEqual(env.mods.CSAdminAudit.mounted, 1);
assert.strictEqual(env.mods.CSAdminAudit.unmounted, 0);
});
test('init without a destroy in between still unmounts the mounted tab', async () => {
const env = loadShell('#/admin?tab=users');
await env.page.init(env.app, null);
env.loc.hash = '#/admin?tab=overview';
await env.page.init(env.app, null);
assert.strictEqual(env.mods.CSAdminUsers.unmounted, 1);
assert.strictEqual(env.mods.CSAdminOverview.mounted, 1);
});
test('destroy before CSAuth.ready resolves: no tab is mounted', async () => {
const env = loadShell('#/admin');
const p = env.page.init(env.app, null);
env.page.destroy();
await p;
assert.strictEqual(env.mods.CSAdminOverview.mounted, 0);
});
test('the Proposals tab exists only when channel proposals are on', async () => {
const off = loadShell('#/admin?tab=proposals');
assert.strictEqual(off.t.readTab('#/admin?tab=proposals').id, 'overview');
assert.strictEqual(off.t.tabsHtml('overview').indexOf('tab=proposals'), -1);
const on = loadShell('#/admin?tab=proposals&status=pending', { proposals: true });
assert.strictEqual(on.t.readTab(on.loc.hash).id, 'proposals');
assert(on.t.tabsHtml('proposals').indexOf('class="tab-btn active" href="#/admin?tab=proposals" aria-current="page"') !== -1);
await on.page.init(on.app, null);
assert.strictEqual(on.mods.CSAdminProposals.mounted, 1);
assert.strictEqual(on.mods.CSAdminProposals.el, on.els.adminTab);
});
console.log('admin-proposals.js');
const PR = (o) => Object.assign({ id: 5, kind: 'hashtag_channel', subject: '#mycity', status: 'pending', note: '',
createdAt: '2026-10-07T10:00:00Z', decidedAt: null, proposer: { id: 2, displayName: 'Pat', email: 'pat@example.org' }, reviewer: null }, o);
const propEnv = (hash, routes) => loadTab(['public/channel-proposals.js', 'public/admin-proposals.js'], hash, routes);
const propT = () => propEnv('', () => OK([])).ctx.CSAdminProposals._test;
test('readHash defaults to pending and keeps known statuses; paths follow the status', () => {
const t = propT();
assert.strictEqual(t.readHash('#/admin?tab=proposals'), 'pending');
assert.strictEqual(t.readHash('#/admin?tab=proposals&status=revoked'), 'revoked');
assert.strictEqual(t.readHash('#/admin?tab=proposals&status=all'), 'all');
assert.strictEqual(t.readHash('#/admin?tab=proposals&status=bogus'), 'pending');
assert.strictEqual(t.hashFor('approved'), '#/admin?tab=proposals&status=approved');
assert.strictEqual(t.apiPath('all'), '/api/admin/proposals');
assert.strictEqual(t.apiPath('pending'), '/api/admin/proposals?status=pending');
});
test('rows escape subject, names, emails and notes; actions follow the status', () => {
const t = propT();
const html = t.rowHtml(PR({ subject: '#' + XSS, note: XSS, proposer: { id: 2, displayName: XSS, email: XSS } }));
assert(html.indexOf('<img') === -1, html);
assert(html.indexOf('data-subject="#&lt;') !== -1, html);
assert(html.indexOf('data-act="approve"') !== -1 && html.indexOf('data-act="reject"') !== -1 && html.indexOf('data-act="revoke"') === -1);
assert(html.indexOf('data-note="5"') !== -1);
const appr = t.rowHtml(PR({ status: 'approved', decidedAt: '2026-10-07T11:00:00Z', reviewer: { id: 1, displayName: 'Ada', email: 'ada@example.org' } }));
assert(appr.indexOf('data-act="revoke"') !== -1 && appr.indexOf('data-act="approve"') === -1 && appr.indexOf('Ada') !== -1);
const done = t.rowHtml(PR({ status: 'rejected' }));
assert(done.indexOf('data-act=') === -1 && done.indexOf('data-note=') === -1);
assert(t.rowHtml(PR({ proposer: null })).indexOf('deleted account') !== -1);
assert(t.refHtml({ id: 9, deleted: true }).indexOf('deleted account') !== -1);
});
test('Proposed by and Decision are optional columns, so Actions fits a phone', async () => {
const optional = (html) => (html.match(/class="um-col-optional"/g) || []).length;
const appr = propT().rowHtml(PR({ status: 'approved', decidedAt: '2026-10-07T11:00:00Z', reviewer: { id: 1, displayName: 'Ada', email: 'ada@example.org' } }));
assert.strictEqual(optional(appr), 2, appr);
assert(/<td class="um-col-optional">.*Pat/.test(propT().rowHtml(PR())));
const env = propEnv('#/admin?tab=proposals', () => OK([]));
const c = env.dom.mk('c');
await env.ctx.CSAdminProposals.mount(c);
assert(c.innerHTML.indexOf('<th scope="col" class="um-col-optional">Proposed by</th><th scope="col" class="um-col-optional">Decision</th><th scope="col">Actions</th>') !== -1, c.innerHTML);
});
test('mount loads the status from the hash and renders the rows', async () => {
const env = propEnv('#/admin?tab=proposals&status=approved', () => OK([PR({ status: 'approved' })]));
await env.ctx.CSAdminProposals.mount(env.dom.mk('c'));
assert.deepStrictEqual(env.calls, ['/api/admin/proposals?status=approved']);
assert.strictEqual(env.els.propAdminStatus.value, 'approved');
assert(env.els.propAdminBody.innerHTML.indexOf('data-subject="#mycity"') !== -1, env.els.propAdminBody.innerHTML);
const empty = propEnv('#/admin?tab=proposals', () => OK([]));
await empty.ctx.CSAdminProposals.mount(empty.dom.mk('c'));
assert(empty.els.propAdminBody.innerHTML.indexOf('No proposals.') !== -1);
});
test('a status change rewrites the hash and reloads', async () => {
const env = propEnv('#/admin?tab=proposals', () => OK([]));
await env.ctx.CSAdminProposals.mount(env.dom.mk('c'));
await env.els.propAdminStatus.handlers.change({ target: { value: 'all' } });
assert.deepStrictEqual(env.replaced, ['#/admin?tab=proposals&status=all']);
assert.strictEqual(env.calls[env.calls.length - 1], '/api/admin/proposals');
});
test('approve asks for confirmation with the readability warning; cancel sends nothing', async () => {
const env = propEnv('#/admin?tab=proposals', () => OK([PR()]));
await env.ctx.CSAdminProposals.mount(env.dom.mk('c'));
const asked = [];
await env.ctx.CSAdminProposals._test.act('5', 'approve', (m) => { asked.push(m); return false; });
assert.strictEqual(asked.length, 1);
assert(asked[0].indexOf('#mycity') !== -1 && /every visitor/.test(asked[0]), asked[0]);
assert.strictEqual(env.calls.filter((p) => p.indexOf('/approve') !== -1).length, 0);
});
test('approve with a note posts it, says so and reloads the list', async () => {
const env = propEnv('#/admin?tab=proposals', (p) => (p.endsWith('/approve') ? OK(PR({ status: 'approved' })) : OK([PR()])));
await env.ctx.CSAdminProposals.mount(env.dom.mk('c'));
env.dom.document.querySelector('[data-note="5"]').value = ' fine ';
await env.ctx.CSAdminProposals._test.act('5', 'approve', () => true);
assert.deepStrictEqual(env.calls, ['/api/admin/proposals?status=pending', '/api/admin/proposals/5/approve', '/api/admin/proposals?status=pending']);
assert.deepStrictEqual(JSON.parse(JSON.stringify(env.bodies[1])), { note: 'fine' });
assert.strictEqual(env.els.propAdminMsg.textContent, '#mycity: approved');
assert.strictEqual(env.els.propAdminMsg.ok, true);
});
test('revoke needs no confirmation; a refused action shows the server error', async () => {
const env = propEnv('#/admin?tab=proposals&status=approved', (p) => (p.endsWith('/revoke')
? { ok: false, status: 409, data: { error: "not possible in the proposal's current state" } } : OK([PR({ status: 'approved' })])));
await env.ctx.CSAdminProposals.mount(env.dom.mk('c'));
await env.ctx.CSAdminProposals._test.act('5', 'revoke', () => { throw new Error('asked to confirm a revoke'); });
assert.strictEqual(env.els.propAdminMsg.textContent, "not possible in the proposal's current state");
assert.strictEqual(env.els.propAdminMsg.ok, false);
});
test('a double click sends one POST', async () => {
const env = propEnv('#/admin?tab=proposals', (p) => (p.endsWith('/reject') ? OK(PR({ status: 'rejected' })) : OK([PR()])));
await env.ctx.CSAdminProposals.mount(env.dom.mk('c'));
const a = env.ctx.CSAdminProposals._test.act('5', 'reject', () => true);
const b = env.ctx.CSAdminProposals._test.act('5', 'reject', () => true);
await Promise.all([a, b]);
assert.strictEqual(env.calls.filter((p) => p.endsWith('/reject')).length, 1);
await env.ctx.CSAdminProposals._test.act('5', 'reject', () => true);
assert.strictEqual(env.calls.filter((p) => p.endsWith('/reject')).length, 2, 'guard released after settling');
});
test('a refused decision reloads the list and keeps the error message', async () => {
const env = propEnv('#/admin?tab=proposals', (p) => (p.endsWith('/reject')
? { ok: false, status: 409, data: { error: 'already decided' } } : OK([PR()])));
await env.ctx.CSAdminProposals.mount(env.dom.mk('c'));
await env.ctx.CSAdminProposals._test.act('5', 'reject', () => true);
assert.deepStrictEqual(env.calls, ['/api/admin/proposals?status=pending', '/api/admin/proposals/5/reject', '/api/admin/proposals?status=pending']);
assert.strictEqual(env.els.propAdminMsg.textContent, 'already decided');
assert.strictEqual(env.els.propAdminMsg.ok, false);
});
test('an unknown id does nothing; unmount drops a late answer', async () => {
let release;
const env = propEnv('#/admin?tab=proposals', () => new Promise((r) => { release = () => r(OK([PR()])); }));
const p = env.ctx.CSAdminProposals.mount(env.dom.mk('c'));
await tick();
env.ctx.CSAdminProposals.unmount();
release();
await p;
assert.strictEqual(env.els.propAdminBody.innerHTML, '', 'late answer rendered after unmount');
await env.ctx.CSAdminProposals._test.act('77', 'reject', () => true);
assert.strictEqual(env.calls.length, 1);
});
test('Users card shows notification figures only when the server sends them', () => {
const t = ovT();
assert.strictEqual(t.usersCardHtml({ data: STATS }).indexOf('Notifications'), -1);
const h = t.usersCardHtml({ data: Object.assign({}, STATS, { notify: { mailsLast24h: 7, maxMailsPerDay: 300, watches: 12, watchingUsers: 3 } }) });
assert(h.indexOf('<h4>Notifications</h4>') !== -1, h);
assert(h.indexOf('data-stat="notifyMails">7 of 300<') !== -1 && h.indexOf('data-stat="notifyWatches">12<') !== -1 &&
h.indexOf('data-stat="notifyUsers">3<') !== -1, h);
});
test('the audit action filter offers notification settings', () => {
assert.strictEqual(auditT().readHash('#/admin?tab=audit&action=notify.*').action, 'notify.*');
});
Promise.all(pending).then(() => {
console.log('\n' + passed + ' passed, ' + failed + ' failed');
process.exit(failed ? 1 : 0);
});