Files
meshcore-analyzer/tests/unit/test-admin-dashboard-ui.js
T
efitenandClaude Opus 5.5 d29957fbdc feat(observer-logins): lock the shared login out of migrated observers (#2348)
Follow-up to #2306 / #2319. With personal observer logins, a migrated
observer still is not safe from the shared login: the hook answers
`ignore` for the shared login, so EMQX's built-in database lets it
publish under any observer's topic, including one whose owner has moved
to a personal login. This PR lets an operator close that per migrated
observer, before the shared login is removed altogether.

## Changes

**users.db v8.** `observer_bindings.confirmed_at`: the moment the
owner's personal login first published under that pubkey.
- An auto-bind (the owner's first publish) is confirmed at once.
- An admin assignment stays unconfirmed until the owner's login
publishes under that pubkey; that publish sets it.
- Reassigning to another user or unbinding clears it; reassigning to the
same owner keeps it.
- Migration: existing `auto` rows get `confirmed_at = bound_at`, `admin`
rows stay NULL.

**Config.** `userManagement.observerLogins.sharedUsernames` (default
empty): the broker's shared login names. A name starting with `obs_` is
refused at startup.

**Authz hook.** For a login in `sharedUsernames`, a publish under
`meshcore/<x>/<pubkey>/...` is denied when that pubkey's binding is
confirmed, whatever the number of topic segments (the ingestor
attributes `meshcore/<x>/<pubkey>` and longer topics to that pubkey).
Logged as `publish denied: user=<name> observer=<8 hex>: confirmed
owner`. Every other request from a shared login keeps `ignore`, so the
built-in database decides as before. Personal logins are unchanged.

An orphaned binding (owner account deleted) does not count as confirmed.

**Admin.** The observers table of the Observer logins tab gets a
Confirmed column; the bindings JSON gets `confirmedAt`.

**Docs.** `docs/observer-mqtt-logins.md`: "Locking the shared login out
of migrated observers" (what it denies, how to see `confirmedAt`, the
risk below). `config.example.json` has the key.

## Risk for operators

An observer that is confirmed but still runs a second uploader on the
shared login loses that second uploader once its name is in
`sharedUsernames`. The deny log line names the observer.

## Tests

- `internal/users`: v7 to v8 migration on a real v7 database, auto
confirm, admin assignment then owner confirm, non-owner and repeated
confirm, reassign clears, orphan reassign.
- `cmd/server`: the decision table (confirmed, unconfirmed, unbound,
non-publish), topics with 3, 4 and 5+ segments, trailing slash, empty
and `client` iata, lowercase key; owner publish confirms, confirm
failure still allows and retries; reassign, unbind and user removal
clear the flag; the index load against a real store; config validation;
the hook and admin endpoints end to end.
- `tests/e2e/test-user-management-e2e.js`: the Confirmed column for an
auto-bound observer.
- Local: `internal/users`, `cmd/server` (two Windows-only tests skipped,
as on master) and `cmd/ingestor` green. The E2E assertion was not run
locally; CI runs it.

## Not in this PR

- Using `confirmed_at` for verified `/neighbors` reports: a separate PR,
built on #2300.
- Removing the shared login: still an operator step after migration.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 19:10:41 +01:00

936 lines
52 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('the audit action filter offers data export and users.db backup', () => {
const t = auditT();
assert.strictEqual(t.readHash('#/admin?tab=audit&action=user.export').action, 'user.export');
assert.strictEqual(t.readHash('#/admin?tab=audit&action=user.backup').action, 'user.backup');
});
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', 'CSAdminObserverLogins'].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 || opts.observerLogins) ? { enabled: true, channelProposals: !!opts.proposals, observerLogins: !!opts.observerLogins } : 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);
});
test('the Observer logins tab exists only when observer logins are on', async () => {
const off = loadShell('#/admin?tab=observer-logins', { proposals: true });
assert.strictEqual(off.t.readTab('#/admin?tab=observer-logins').id, 'overview');
assert.strictEqual(off.t.tabsHtml('overview').indexOf('tab=observer-logins'), -1);
const on = loadShell('#/admin?tab=observer-logins', { observerLogins: true });
assert.strictEqual(on.t.readTab(on.loc.hash).id, 'observer-logins');
assert(on.t.tabsHtml('observer-logins').indexOf('href="#/admin?tab=observer-logins" aria-current="page">Observer logins</a>') !== -1);
assert.strictEqual(on.t.tabsHtml('overview').indexOf('tab=proposals'), -1, 'proposals tab shown with proposals off');
await on.page.init(on.app, null);
assert.strictEqual(on.mods.CSAdminObserverLogins.mounted, 1);
assert.strictEqual(on.mods.CSAdminObserverLogins.el, on.els.adminTab);
});
test('index.html loads admin-observer-logins.js before admin.js, with the cache buster', () => {
const html = src('public/index.html');
const tab = html.indexOf('<script src="admin-observer-logins.js?v=__BUST__"');
assert(tab !== -1, 'script tag missing');
assert(tab < html.indexOf('<script src="admin.js?v=__BUST__"'), 'loaded after admin.js');
});
console.log('admin-observer-logins.js');
const PKA = 'ab'.repeat(32), PKB = 'CD'.repeat(32), PKC = 'EF'.repeat(32);
const LOGIN = (o) => Object.assign({ userId: 7, email: 'ann@example.org', displayName: 'Ann', username: 'obs_abc123def456',
createdAt: '2026-10-25T10:00:00Z', revoked: false, lastAuthAt: null, observers: [PKA.toUpperCase()] }, o);
const OBS_ROUTES = (logins, bindings, observers) => (p) => {
if (p === '/api/admin/observer-logins') return OK({ logins, bindings });
if (p === '/api/observers') return OK({ observers, server_time: 'x' });
return OK({ ok: true });
};
const obsEnv = (routes) => loadTab('public/admin-observer-logins.js', '#/admin?tab=observer-logins', routes);
const obsT = () => obsEnv(() => OK({})).ctx.CSAdminObserverLogins._test;
const clickAct = (act, id) => ({ target: { closest: () => ({ getAttribute: (a) => (a === 'data-act' ? act : String(id)) }) } });
test('joinObservers matches bindings case-insensitively and keeps bindings without an analyzer row', () => {
const rows = obsT().joinObservers(
[{ id: PKA, name: 'Roof', last_seen: '2026-10-25T09:00:00Z' }, { id: PKB, name: 'Shed' }, { id: PKA, name: 'dup' }],
[{ pubkey: PKA.toUpperCase(), userId: 7 }, { pubkey: PKC, userId: null }]);
assert.strictEqual(rows.length, 3, JSON.stringify(rows));
assert.deepStrictEqual(JSON.parse(JSON.stringify(rows.map((r) => [r.pubkey, r.bound, r.userId]))),
[[PKA.toUpperCase(), true, 7], [PKB, false, null], [PKC, true, null]]);
assert.strictEqual(rows[0].name, 'Roof');
});
test('joinObservers carries confirmedAt from the binding, null otherwise', () => {
const rows = obsT().joinObservers([{ id: PKB, name: 'Shed' }],
[{ pubkey: PKA, userId: 7, confirmedAt: '2026-10-28T09:00:00Z' }, { pubkey: PKC, userId: 7, confirmedAt: null }]);
assert.deepStrictEqual(JSON.parse(JSON.stringify(rows.map((r) => [r.pubkey, r.confirmedAt]))),
[[PKB, null], [PKA.toUpperCase(), '2026-10-28T09:00:00Z'], [PKC, null]]);
});
test('observer rows: the Confirmed cell shows the date, "not yet" while unconfirmed, a dash without a binding', () => {
const t = obsT();
const by = { 7: LOGIN() };
const when = '2026-10-28T09:00:00Z';
const yes = t.observerRowHtml({ pubkey: PKB, name: 'Shed', lastSeen: null, bound: true, userId: 7, confirmedAt: when }, by);
assert(yes.indexOf('<td class="um-col-optional obs-confirmed">' + new Date(when).toLocaleString() + '</td>') !== -1, yes);
const notYet = t.observerRowHtml({ pubkey: PKB, name: 'Shed', lastSeen: null, bound: true, userId: 7, confirmedAt: null }, by);
assert(notYet.indexOf('<td class="um-col-optional obs-confirmed">not yet</td>') !== -1, notYet);
const open = t.observerRowHtml({ pubkey: PKB, name: 'Shed', lastSeen: null, bound: false, userId: null, confirmedAt: null }, by);
assert(open.indexOf('<td class="um-col-optional obs-confirmed">—</td>') !== -1, open);
});
test('ownerLabel: owner name, user #id for an unknown login, orphaned, not migrated', () => {
const t = obsT();
const by = { 7: LOGIN() };
assert.strictEqual(t.ownerLabel({ bound: true, userId: 7 }, by), 'Ann');
assert.strictEqual(t.ownerLabel({ bound: true, userId: 9 }, by), 'user #9');
assert.strictEqual(t.ownerLabel({ bound: true, userId: null }, by), 'orphaned');
assert.strictEqual(t.ownerLabel({ bound: false, userId: null }, by), 'not migrated');
});
test('login rows escape every field; a revoked login offers Reissue only', () => {
const t = obsT();
const h = t.loginRowHtml(LOGIN({ displayName: XSS, email: XSS, username: XSS }), null);
assert(h.indexOf('<img') === -1, 'raw tag: ' + h);
assert(h.indexOf('data-act="revoke"') !== -1 && h.indexOf('data-act="reissue"') !== -1 && h.indexOf('never') !== -1);
const r = t.loginRowHtml(LOGIN({ revoked: true }), null);
assert(r.indexOf('data-act="revoke"') === -1 && r.indexOf('data-act="reissue"') !== -1 && r.indexOf('um-status-disabled">revoked') !== -1, r);
});
test('observer rows: owner status, escaped names, a Reassign button (no select) only for a 64-hex key', () => {
const t = obsT();
const by = { 7: LOGIN() };
const bound = t.observerRowHtml({ pubkey: PKB, name: XSS, lastSeen: null, bound: true, userId: 7 }, by);
assert(bound.indexOf('<img') === -1, bound);
assert(bound.indexOf('<td>Ann</td>') !== -1, bound);
assert(bound.indexOf('data-reassign="' + PKB + '"') !== -1 && bound.indexOf('aria-label="Reassign &lt;img') !== -1, bound);
assert(bound.indexOf('<select') === -1 && bound.indexOf('<option') === -1, 'options repeated per row');
const open = t.observerRowHtml({ pubkey: PKB, name: 'Shed', lastSeen: null, bound: false, userId: null }, by);
assert(open.indexOf('um-status-pending">not migrated') !== -1, open);
const orphan = t.observerRowHtml({ pubkey: PKB, name: 'Shed', lastSeen: null, bound: true, userId: null }, by);
assert(orphan.indexOf('um-status-disabled">orphaned') !== -1, orphan);
const odd = t.observerRowHtml({ pubkey: 'NOT-A-KEY', name: 'x', lastSeen: null, bound: false, userId: null }, by);
assert(odd.indexOf('data-reassign') === -1, 'Reassign for a key the PUT would refuse');
assert.strictEqual(t.summaryText([{ bound: false }, { bound: true }, { bound: false }]), '2 of 3 observers not migrated (no personal login yet).');
});
test('owner options are built once from the logins; the editing row gets the select, orphaned adds its own entry', () => {
const t = obsT();
const opts = t.ownerOptionsHtml([LOGIN(), LOGIN({ userId: 8, displayName: XSS, username: 'obs_b' })]);
assert(opts.indexOf('<option value="">not bound</option><option value="7">Ann (obs_abc123def456)</option>') === 0, opts);
assert(opts.indexOf('<img') === -1, opts);
const edit = t.observerCellsHtml({ pubkey: PKB, name: 'Shed', lastSeen: null, bound: true, userId: null }, {}, true, opts);
assert(edit.indexOf('<select data-pubkey="' + PKB + '" aria-label="Owner of Shed"><option value="orphaned" disabled>orphaned</option>') !== -1, edit);
assert(edit.indexOf('data-cancel="' + PKB + '"') !== -1, edit);
assert.strictEqual(t.ownerValue({ bound: false, userId: null }), '');
assert.strictEqual(t.ownerValue({ bound: true, userId: null }), 'orphaned');
assert.strictEqual(t.ownerValue({ bound: true, userId: 7 }), '7');
});
test('mount makes one logins and one observers request and renders both tables', async () => {
const env = obsEnv(OBS_ROUTES([LOGIN()], [{ pubkey: PKA.toUpperCase(), userId: 7 }], [{ id: PKA, name: 'Roof' }, { id: PKB, name: 'Shed' }]));
await env.ctx.CSAdminObserverLogins.mount({ innerHTML: '' });
assert.deepStrictEqual(env.calls.slice().sort(), ['/api/admin/observer-logins', '/api/observers']);
assert(env.els.obsAdminLogins.innerHTML.indexOf('data-username="obs_abc123def456"') !== -1);
assert(env.els.obsAdminObservers.innerHTML.indexOf('data-pubkey="' + PKB + '"') !== -1);
assert.strictEqual(env.els.obsAdminSummary.textContent, '1 of 2 observers not migrated (no personal login yet).');
});
test('Revoke asks inline first and touches only the logins table; Cancel sends nothing; confirming posts and reloads', async () => {
const env = obsEnv(OBS_ROUTES([LOGIN()], [], []));
await env.ctx.CSAdminObserverLogins.mount({ innerHTML: '' });
const t = env.ctx.CSAdminObserverLogins._test;
env.els.obsAdminObservers.innerHTML = 'UNTOUCHED';
t.onLoginClick(clickAct('revoke', 7));
assert(env.els.obsAdminLogins.innerHTML.indexOf('data-act="revoke-confirm"') !== -1, 'no inline confirm');
t.onLoginClick(clickAct('cancel', 7));
assert(env.els.obsAdminLogins.innerHTML.indexOf('revoke-confirm') === -1, 'confirm not cleared');
assert.strictEqual(env.els.obsAdminObservers.innerHTML, 'UNTOUCHED', 'confirm/cancel rebuilt the observers table');
assert.strictEqual(env.calls.filter((c) => /revoke/.test(c)).length, 0, 'POST before confirming');
t.onLoginClick(clickAct('revoke', 7));
await t.onLoginClick(clickAct('revoke-confirm', 7));
assert(env.calls.indexOf('/api/admin/observer-logins/7/revoke') !== -1, env.calls.join());
assert.strictEqual(env.els.obsAdminMsg.textContent, 'obs_abc123def456: revoked');
assert.strictEqual(env.calls.filter((c) => c === '/api/admin/observer-logins').length, 2, 'not reloaded');
});
test('Reissue posts after its confirm; a refused action shows the server error', async () => {
const env = obsEnv((p) => (/reissue$/.test(p) ? { ok: false, status: 503, data: { error: 'could not send the mail, try again later' } }
: OBS_ROUTES([LOGIN()], [], [])(p)));
await env.ctx.CSAdminObserverLogins.mount({ innerHTML: '' });
const t = env.ctx.CSAdminObserverLogins._test;
t.onLoginClick(clickAct('reissue', 7));
await t.onLoginClick(clickAct('reissue-confirm', 7));
assert(env.calls.indexOf('/api/admin/observer-logins/7/reissue') !== -1);
assert.strictEqual(env.els.obsAdminMsg.textContent, 'could not send the mail, try again later');
assert.strictEqual(env.els.obsAdminMsg.ok, false);
});
const rowKey = (pk) => 'obsAdminObservers tr[data-pubkey="' + pk + '"]';
const clickRow = (attr, pk) => ({ target: { closest: () => ({ getAttribute: (a) => (a === attr ? pk : null) }) } });
test('Reassign swaps the shared select into that row only; another Reassign or Cancel restores the button', async () => {
const env = obsEnv(OBS_ROUTES([LOGIN()], [{ pubkey: PKA.toUpperCase(), userId: 7 }], [{ id: PKA, name: 'Roof' }, { id: PKB, name: 'Shed' }]));
await env.ctx.CSAdminObserverLogins.mount({ innerHTML: '' });
const t = env.ctx.CSAdminObserverLogins._test;
const A = PKA.toUpperCase();
env.els.obsAdminObservers.innerHTML = 'UNTOUCHED';
t.onObserverClick(clickRow('data-reassign', A));
const trA = env.els[rowKey(A)];
assert(trA.innerHTML.indexOf('<select data-pubkey="' + A + '"') !== -1, trA.innerHTML);
assert.strictEqual(env.els[rowKey(A) + ' select'].value, '7', 'current owner not selected');
t.onObserverClick(clickRow('data-reassign', PKB));
assert(trA.innerHTML.indexOf('<select') === -1 && trA.innerHTML.indexOf('data-reassign') !== -1, 'previous row kept its select');
assert(env.els[rowKey(PKB)].innerHTML.indexOf('<select') !== -1);
t.onObserverClick(clickRow('data-cancel', PKB));
assert(env.els[rowKey(PKB)].innerHTML.indexOf('<select') === -1, 'Cancel left the select');
t.onObserverClick(clickRow('data-reassign', A));
t.onObserverKey({ key: 'Escape' });
assert(trA.innerHTML.indexOf('<select') === -1, 'Escape left the select');
assert.strictEqual(env.els.obsAdminObservers.innerHTML, 'UNTOUCHED', 'the whole table was rebuilt');
assert.strictEqual(env.calls.length, 2, 'Reassign made a request');
});
test('a saved owner PUTs the user id or null and updates only that row and the summary; the select is disabled meanwhile', async () => {
const env = obsEnv(OBS_ROUTES([LOGIN()], [], [{ id: PKB, name: 'Shed' }, { id: PKC, name: 'Mast' }]));
await env.ctx.CSAdminObserverLogins.mount({ innerHTML: '' });
const t = env.ctx.CSAdminObserverLogins._test;
env.els.obsAdminObservers.innerHTML = 'UNTOUCHED';
t.onObserverClick(clickRow('data-reassign', PKB));
const sel = { value: '7', disabled: false, getAttribute: () => PKB };
const p = t.onChange({ target: sel });
assert.strictEqual(sel.disabled, true, 'select enabled while saving');
await p;
assert.strictEqual(sel.disabled, false);
const tr = env.els[rowKey(PKB)];
assert(tr.innerHTML.indexOf('<td>Ann</td>') !== -1 && tr.innerHTML.indexOf('<select') === -1, tr.innerHTML);
assert.strictEqual(env.els.obsAdminSummary.textContent, '1 of 2 observers not migrated (no personal login yet).');
assert.strictEqual(env.els.obsAdminObservers.innerHTML, 'UNTOUCHED', 'the whole table was rebuilt');
assert.strictEqual(env.calls.filter((c) => c === '/api/observers').length, 1, 'reloaded after a reassign');
await t.onChange({ target: { value: '', disabled: false, getAttribute: () => PKB } });
const puts = env.calls.map((c, i) => [c, env.bodies[i]]).filter((x) => x[0].indexOf('/api/admin/observer-bindings/') === 0);
assert.deepStrictEqual(JSON.parse(JSON.stringify(puts)),
[['/api/admin/observer-bindings/' + PKB, { userId: 7 }], ['/api/admin/observer-bindings/' + PKB, { userId: null }]]);
assert.strictEqual(env.els.obsAdminMsg.textContent, PKB.slice(0, 12) + '…: unbound');
assert(tr.innerHTML.indexOf('not migrated') !== -1, tr.innerHTML);
});
test('a reassign keeps confirmedAt for the same owner and clears it for another owner or an unbind', async () => {
const when = '2026-10-28T09:00:00Z';
const env = obsEnv(OBS_ROUTES([LOGIN(), LOGIN({ userId: 8, username: 'obs_b' })],
[{ pubkey: PKB, userId: 7, confirmedAt: when }], [{ id: PKB, name: 'Shed' }]));
await env.ctx.CSAdminObserverLogins.mount({ innerHTML: '' });
const t = env.ctx.CSAdminObserverLogins._test;
await t.onChange({ target: { value: '7', disabled: false, getAttribute: () => PKB } });
assert.strictEqual(t.state.byKey[PKB].confirmedAt, when, 'same owner cleared it');
await t.onChange({ target: { value: '8', disabled: false, getAttribute: () => PKB } });
assert.strictEqual(t.state.byKey[PKB].confirmedAt, null, 'new owner kept it');
assert(env.els[rowKey(PKB)].innerHTML.indexOf('obs-confirmed">not yet</td>') !== -1, env.els[rowKey(PKB)].innerHTML);
t.state.byKey[PKB].confirmedAt = when;
await t.onChange({ target: { value: '', disabled: false, getAttribute: () => PKB } });
assert.strictEqual(t.state.byKey[PKB].confirmedAt, null, 'unbind kept it');
});
test('a refused owner change shows the error and puts the saved value back', async () => {
const env = obsEnv((p) => (p.indexOf('/api/admin/observer-bindings/') === 0 ? { ok: false, status: 404, data: { error: 'user not found' } }
: OBS_ROUTES([LOGIN()], [], [{ id: PKB, name: 'Shed' }])(p)));
await env.ctx.CSAdminObserverLogins.mount({ innerHTML: '' });
const t = env.ctx.CSAdminObserverLogins._test;
t.onObserverClick(clickRow('data-reassign', PKB));
const sel = { value: '7', disabled: false, getAttribute: () => PKB };
await t.onChange({ target: sel });
assert.strictEqual(env.els.obsAdminMsg.textContent, 'user not found');
assert.strictEqual(sel.value, '', 'unsaved choice still selected');
assert.strictEqual(sel.disabled, false);
assert.strictEqual(t.state.byKey[PKB].bound, false);
});
test('unmount drops a load that answers later', async () => {
let release;
const gate = new Promise((r) => { release = r; });
const env = obsEnv((p) => gate.then(() => OBS_ROUTES([LOGIN()], [], [])(p)));
const m = env.ctx.CSAdminObserverLogins.mount({ innerHTML: '' });
env.ctx.CSAdminObserverLogins.unmount();
release();
await m;
assert.strictEqual(env.els.obsAdminLogins.innerHTML, '', 'late answer rendered');
});
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);
});