mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-10-16 00:12:06 +00:00
Part 3 of 3 for #2173 (companion linking): the optional linked-only ingest filter, the account page and the "My coverage" toggle, plus the docs. Builds on #2174 and #2175 (both merged); `upstream/master` is merged in, so the diff shows only this part. ## Changes in this PR **Ingestor: linked-only ingest** (`cmd/ingestor/linked_companions.go`) - With `clientRxCoverage.requireLinkedCompanion` on (default off) and user management enabled, every `meshcore/client/<pubkey>/...` message whose pubkey is not in `companion_links` is dropped before any client handler runs, and counted (`client_unlinked_dropped` in the stats file). - The ingestor opens `users.db` read-only (`mode=ro`, raw SQL, no `internal/users` import), like the approved-channels reader, so the #1283 write invariant holds. `TestLinkedCompanionSetIsReadOnly` pins it. - The set refreshes every 60 s. A miss re-reads at most once per 5 s, so a companion linked a moment ago is accepted within seconds while a flood of unknown pubkeys costs one read per 5 s. - It passes everything until its first successful read, and when `users.db` has no `companion_links` table yet (a server older than this). After the first read it keeps the last set on a read error. It is a filter, not a security boundary: anyone with the shared broker credential can still publish under a linked pubkey. **Frontend** - Account page: device tokens show under Devices as "CoreDrive RX" with their label; a new Companions section lists linked companions (name, short key, linked since, last seen) with an Unlink button. - Coverage page (`#/rx-coverage`): a "My coverage" toggle for logged-in users (`mine=1`). Only the newest coverage reply is drawn, and a refused `mine=1` turns the toggle off. Under "My coverage" the page does not ask for the "nothing received" cells from #2172, and their legend entry is hidden. **Docs**: user guide (accounts: devices, companions), `docs/client-rx-coverage.md` (linking, linked-only ingest, the CORS exception), `config.example.json`, and the spec status. ## Performance The filter runs on every client message. Benchmarks with 1000 linked companions (12-core dev box, `go test -bench`, 3 runs each): | Path | Time | Allocations | |---|---|---| | linked pubkey (lock-free map lookup) | 54 ns/op | 0 | | linked pubkey, parallel | 11-14 ns/op | 0 | | unlinked pubkey inside the 5 s miss gap | 85-95 ns/op | 0 | | one `users.db` re-read | 0.38 ms | 3030 | The unlinked benchmark asserts that no extra `users.db` read happens. A re-read happens at most once per 5 s on a miss and once a minute from the refresh loop. ## Verification - `cmd/ingestor`: 15 new tests (filter off changes nothing, linked passes, unlinked dropped before every handler, miss refresh accepts a new link, the 5 s cap holds under a flood, unlink honoured after the periodic refresh, missing table and missing file, fail-closed only after the first read, read-only open, startup, stats counter) and 4 benchmarks. Full ingestor suite green. - Frontend unit tests for the account page (device tokens, companions list escaping, empty state, Unlink confirm and cancel, load errors) and `tests/unit/test-rx-coverage-mine.js`. - E2E `tests/e2e/test-user-management-e2e.js` gains a companion step (device token under Devices, a companion linked with a real Ed25519 signature under Companions, Unlink). Run locally against the e2etest build: 28/28. The coverage suites (`test-rx-coverage-gaps-e2e.js`, `-noise-`, `-viewport-`) still pass. - Frontend unit suite (`test-all.sh`) green. ## Not in this PR - Narrowing the "nothing received" cells to the caller's companions under "My coverage" (the track query takes one companion today). - Per-user broker credentials or ACLs, which would turn the filter into an access control (non-goal in the spec). --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1079 lines
55 KiB
JavaScript
1079 lines
55 KiB
JavaScript
/* Unit tests for the optional user-management UI (public/auth.js). Real file
|
||
* loaded in a vm sandbox, same pattern as test-frontend-helpers.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;
|
||
}
|
||
|
||
// escapeHtml comes from the real app.js, not a copy.
|
||
function loadEscapeHtml() {
|
||
const src = fs.readFileSync(path.join(ROOT, 'public/app.js'), 'utf8');
|
||
const m = src.match(/function escapeHtml\(s\) \{[\s\S]*?\n\}/);
|
||
assert(m, 'escapeHtml not found in app.js');
|
||
return vm.runInNewContext('(' + m[0].replace(/^function escapeHtml/, 'function') + ')');
|
||
}
|
||
|
||
// CSAuth.say and CSAuth.errText come from the real auth.js, bound to ctx
|
||
// (say reads ctx.document).
|
||
function loadAuthHelpers(ctx) {
|
||
const src = fs.readFileSync(path.join(ROOT, 'public/auth.js'), 'utf8');
|
||
const say = src.match(/function say\(id, text, ok\) \{[\s\S]*?\n \}/);
|
||
const err = src.match(/function errText\(r\) \{.*\}/);
|
||
assert(say && err, 'say/errText not found in auth.js');
|
||
return { say: vm.runInContext('(' + say[0] + ')', ctx), errText: vm.runInContext('(' + err[0] + ')', ctx) };
|
||
}
|
||
|
||
function makeEnv(routes) {
|
||
const els = {};
|
||
const events = [];
|
||
const mkEl = (id) => ({
|
||
id: id || '', className: '', innerHTML: '', textContent: '', hidden: false,
|
||
classList: { add() {}, remove() {} },
|
||
style: {}, listeners: {}, setAttribute() {}, appendChild() {},
|
||
addEventListener(t, f) { this.listeners[t] = f; },
|
||
});
|
||
const right = { insertBefore(el) { els[el.id] = el; } };
|
||
const doc = {
|
||
getElementById(id) {
|
||
if (id === 'hamburger') return null;
|
||
if (id === 'accountWrap') return els.accountWrap || null;
|
||
if (id === 'csAuthToast') return els.csAuthToast || null;
|
||
return els[id] || mkEl(id);
|
||
},
|
||
querySelector(sel) { return sel === '.top-nav .nav-right' ? right : null; },
|
||
createElement() { return mkEl(); },
|
||
addEventListener() {},
|
||
body: { appendChild(el) { els[el.id] = el; } },
|
||
};
|
||
const calls = [];
|
||
const win = {
|
||
addEventListener(t, f) { (this.listeners[t] = this.listeners[t] || []).push(f); },
|
||
innerWidth: 1000, listeners: {},
|
||
dispatchEvent(e) { events.push(e); },
|
||
MC_USER_MGMT: { enabled: true },
|
||
MeshConfigReady: Promise.resolve(),
|
||
};
|
||
win.window = win;
|
||
const loc = { hash: '#/account' };
|
||
const ctx = {
|
||
window: win, document: doc, console, Promise, JSON, String, Object, location: loc,
|
||
setTimeout, clearTimeout,
|
||
CustomEvent: function (type, init) { this.type = type; this.detail = init && init.detail; },
|
||
escapeHtml: loadEscapeHtml(),
|
||
fetch(url, opts) {
|
||
calls.push({ url, opts });
|
||
const r = routes(url, opts) || { status: 404, body: {} };
|
||
return Promise.resolve({ ok: r.status < 400, status: r.status, json: () => Promise.resolve(r.body) });
|
||
},
|
||
};
|
||
ctx.globalThis = ctx;
|
||
vm.createContext(ctx);
|
||
vm.runInContext(fs.readFileSync(path.join(ROOT, 'public/auth.js'), 'utf8'), ctx);
|
||
return { win, els, events, calls, loc };
|
||
}
|
||
|
||
const ME = { id: 1, email: 'a@b.c', displayName: 'Ann', role: 'user', csrfToken: 'tok123' };
|
||
|
||
console.log('auth.js');
|
||
|
||
test('inert when the server does not advertise userManagement', async () => {
|
||
const calls = [];
|
||
const win = { addEventListener() {}, dispatchEvent() {}, MC_USER_MGMT: null, MeshConfigReady: Promise.resolve() };
|
||
win.window = win;
|
||
const ctx = { window: win, document: { addEventListener() {}, getElementById() { return null; }, querySelector() { return null; } },
|
||
console, Promise, JSON, String, Object, setTimeout, clearTimeout, CustomEvent: function () {},
|
||
escapeHtml: loadEscapeHtml(), fetch() { calls.push(1); return Promise.reject(new Error('no')); } };
|
||
vm.createContext(ctx);
|
||
vm.runInContext(fs.readFileSync(path.join(ROOT, 'public/auth.js'), 'utf8'), ctx);
|
||
await win.CSAuth.ready();
|
||
assert.strictEqual(win.CSAuth.isEnabled(), false);
|
||
assert.strictEqual(calls.length, 0);
|
||
});
|
||
|
||
test('account menu opens as fixed, positioned from the toggle rect, and follows a resize', async () => {
|
||
const env = makeEnv((u) => u === '/api/auth/me' ? { status: 200, body: ME } : null);
|
||
await env.win.CSAuth.ready();
|
||
const toggle = { id: 'accountToggle', listeners: {}, setAttribute() {}, addEventListener(t, f) { this.listeners[t] = f; },
|
||
getBoundingClientRect: () => ({ bottom: 50, right: 900 }) };
|
||
const menu = { id: 'accountMenu', hidden: true, style: {}, listeners: {}, addEventListener() {} };
|
||
env.els.accountToggle = toggle; env.els.accountMenu = menu; env.els.accountLogout = { addEventListener() {} };
|
||
env.win.CSAuth._test.renderControl();
|
||
toggle.listeners.click({ stopPropagation() {} });
|
||
assert.strictEqual(menu.hidden, false);
|
||
assert.strictEqual(menu.style.top, '54px');
|
||
assert.strictEqual(menu.style.right, '100px');
|
||
assert.strictEqual(menu.style.left, 'auto');
|
||
toggle.getBoundingClientRect = () => ({ bottom: 60, right: 800 });
|
||
env.win.listeners.resize.forEach((f) => f());
|
||
assert.strictEqual(menu.style.top, '64px');
|
||
assert.strictEqual(menu.style.right, '200px');
|
||
});
|
||
|
||
test('login state is loaded from /api/auth/me and announced', async () => {
|
||
const env = makeEnv((u) => u === '/api/auth/me' ? { status: 200, body: ME } : null);
|
||
await env.win.CSAuth.ready();
|
||
assert.strictEqual(env.win.CS_USER.displayName, 'Ann');
|
||
assert(env.events.some((e) => e.type === 'cs-auth-changed'));
|
||
});
|
||
|
||
test('CSRF header only on non-GET requests', async () => {
|
||
const env = makeEnv((u) => u === '/api/auth/me' ? { status: 200, body: ME } : { status: 200, body: {} });
|
||
await env.win.CSAuth.ready();
|
||
env.calls.length = 0;
|
||
await env.win.CSAuth.request('GET', '/api/x');
|
||
await env.win.CSAuth.request('POST', '/api/x', { a: 1 });
|
||
assert.strictEqual(env.calls[0].opts.headers['X-CS-CSRF'], undefined);
|
||
assert.strictEqual(env.calls[1].opts.headers['X-CS-CSRF'], 'tok123');
|
||
assert.strictEqual(env.calls[1].opts.body, '{"a":1}');
|
||
});
|
||
|
||
test('401 on an authenticated call clears the user and toasts', async () => {
|
||
const env = makeEnv((u) => u === '/api/auth/me' ? { status: 200, body: ME } : { status: 401, body: { error: 'x' } });
|
||
await env.win.CSAuth.ready();
|
||
const r = await env.win.CSAuth.request('GET', '/api/auth/sessions');
|
||
assert.strictEqual(r.status, 401);
|
||
assert.strictEqual(env.win.CS_USER, null);
|
||
assert.strictEqual(env.els.csAuthToast.textContent, 'You were logged out.');
|
||
});
|
||
|
||
test('401 from the login call itself keeps state and shows no toast', async () => {
|
||
const env = makeEnv((u) => u === '/api/auth/me' ? { status: 200, body: ME } : { status: 401, body: {} });
|
||
await env.win.CSAuth.ready();
|
||
await env.win.CSAuth.request('POST', '/api/auth/login', { email: 'a', password: 'b' });
|
||
assert.strictEqual(env.win.CS_USER.displayName, 'Ann');
|
||
assert.strictEqual(env.els.csAuthToast, undefined);
|
||
});
|
||
|
||
test('401 without a logged-in user shows no toast', async () => {
|
||
const env = makeEnv((u) => u === '/api/auth/me' ? { status: 401, body: {} } : { status: 401, body: {} });
|
||
await env.win.CSAuth.ready();
|
||
await env.win.CSAuth.request('GET', '/api/x');
|
||
assert.strictEqual(env.els.csAuthToast, undefined);
|
||
});
|
||
|
||
test('display name is escaped in the header label', async () => {
|
||
const payload = '<img src=x onerror=alert(1)>';
|
||
const env = makeEnv((u) => u === '/api/auth/me' ? { status: 200, body: Object.assign({}, ME, { displayName: payload }) } : null);
|
||
await env.win.CSAuth.ready();
|
||
const html = env.els.accountWrap.innerHTML;
|
||
assert(html.indexOf('<img') === -1, 'raw tag in header HTML: ' + html);
|
||
assert(html.indexOf('<img src=x onerror=alert(1)>') !== -1);
|
||
});
|
||
|
||
test('Admin menu entry is shown to admins only', async () => {
|
||
const user = makeEnv((u) => u === '/api/auth/me' ? { status: 200, body: ME } : null);
|
||
await user.win.CSAuth.ready();
|
||
assert(user.els.accountWrap.innerHTML.indexOf('#/admin') === -1);
|
||
const admin = makeEnv((u) => u === '/api/auth/me' ? { status: 200, body: Object.assign({}, ME, { role: 'admin' }) } : null);
|
||
await admin.win.CSAuth.ready();
|
||
assert(admin.els.accountWrap.innerHTML.indexOf('<a role="menuitem" href="#/admin">Admin</a>') !== -1, admin.els.accountWrap.innerHTML);
|
||
});
|
||
|
||
test('logged-out header is a login link', async () => {
|
||
const env = makeEnv((u) => u === '/api/auth/me' ? { status: 401, body: {} } : null);
|
||
await env.win.CSAuth.ready();
|
||
assert(env.els.accountWrap.innerHTML.indexOf('href="#/account/login"') !== -1);
|
||
});
|
||
|
||
test('401 from the activate call (wrong password) keeps the session and shows no toast', async () => {
|
||
const env = makeEnv((u) => u === '/api/auth/me' ? { status: 200, body: ME } : { status: 401, body: { error: 'wrong password for this account' } });
|
||
await env.win.CSAuth.ready();
|
||
await env.win.CSAuth.request('POST', '/api/auth/activate', { token: 't', password: 'p' });
|
||
assert.strictEqual(env.win.CS_USER.displayName, 'Ann');
|
||
assert.strictEqual(env.els.csAuthToast, undefined);
|
||
});
|
||
|
||
test('logout posts with CSRF, moves to the next view, then clears the user', async () => {
|
||
const env = makeEnv((u) => u === '/api/auth/me' ? { status: 200, body: ME } : { status: 200, body: { ok: true } });
|
||
await env.win.CSAuth.ready();
|
||
env.calls.length = 0;
|
||
let hashAtClear = null;
|
||
env.win.dispatchEvent = (e) => { if (e.type === 'cs-auth-changed') hashAtClear = env.loc.hash; };
|
||
const r = await env.win.CSAuth.logout('#/account/login');
|
||
assert.strictEqual(r.ok, true);
|
||
assert.strictEqual(env.calls[0].url, '/api/auth/logout');
|
||
assert.strictEqual(env.calls[0].opts.method, 'POST');
|
||
assert.strictEqual(env.calls[0].opts.headers['X-CS-CSRF'], 'tok123');
|
||
assert.strictEqual(env.win.CS_USER, null);
|
||
assert.strictEqual(hashAtClear, '#/account/login', 'hash must change before cs-auth-changed fires');
|
||
});
|
||
|
||
test('a refused logout keeps the user and the view', async () => {
|
||
const env = makeEnv((u) => u === '/api/auth/me' ? { status: 200, body: ME } : { status: 403, body: { error: 'request origin not allowed' } });
|
||
await env.win.CSAuth.ready();
|
||
const r = await env.win.CSAuth.logout('#/account/login');
|
||
assert.strictEqual(r.status, 403);
|
||
assert.strictEqual(env.win.CS_USER.displayName, 'Ann');
|
||
assert.strictEqual(env.loc.hash, '#/account');
|
||
});
|
||
|
||
test('logout handler: cancel keeps the session and posts nothing', async () => {
|
||
const env = makeEnv((u) => u === '/api/auth/me' ? { status: 200, body: ME } : { status: 200, body: { ok: true } });
|
||
await env.win.CSAuth.ready();
|
||
env.calls.length = 0;
|
||
env.win.CSAuth.setLogoutHandler(() => Promise.resolve({ cancel: true }));
|
||
const r = await env.win.CSAuth.logout('#/account/login');
|
||
assert.strictEqual(r.cancelled, true);
|
||
assert.strictEqual(r.ok, false);
|
||
assert.strictEqual(env.calls.length, 0);
|
||
assert.strictEqual(env.win.CS_USER.displayName, 'Ann');
|
||
assert.strictEqual(env.loc.hash, '#/account');
|
||
});
|
||
|
||
test('logout handler: afterLogout runs after the POST, before the user is cleared', async () => {
|
||
const env = makeEnv((u) => u === '/api/auth/me' ? { status: 200, body: ME } : { status: 200, body: { ok: true } });
|
||
await env.win.CSAuth.ready();
|
||
env.calls.length = 0;
|
||
let userAtAfter = 'not called', postedBefore = false;
|
||
env.win.CSAuth.setLogoutHandler(() => Promise.resolve({ afterLogout() { userAtAfter = env.win.CS_USER; postedBefore = env.calls.length === 1; } }));
|
||
const r = await env.win.CSAuth.logout('#/account/login');
|
||
assert.strictEqual(r.ok, true);
|
||
assert.strictEqual(postedBefore, true);
|
||
assert.strictEqual(userAtAfter.displayName, 'Ann');
|
||
assert.strictEqual(env.win.CS_USER, null);
|
||
});
|
||
|
||
// M4 (final review): a second Log out click while the dialog is open must
|
||
// not stack a second dialog or send a second POST.
|
||
test('logout: a second call while one is running opens no second dialog', async () => {
|
||
const env = makeEnv((u) => u === '/api/auth/me' ? { status: 200, body: ME } : { status: 200, body: { ok: true } });
|
||
await env.win.CSAuth.ready();
|
||
env.calls.length = 0;
|
||
let dialogs = 0, choose;
|
||
env.win.CSAuth.setLogoutHandler(() => { dialogs++; return new Promise((r) => { choose = r; }); });
|
||
const first = env.win.CSAuth.logout('#/account/login');
|
||
const second = env.win.CSAuth.logout('#/account/login');
|
||
await new Promise((r) => setTimeout(r, 0));
|
||
assert.strictEqual(dialogs, 1);
|
||
assert.strictEqual((await second).cancelled, true);
|
||
choose({});
|
||
assert.strictEqual((await first).ok, true);
|
||
assert.strictEqual(env.calls.filter((c) => c.url === '/api/auth/logout').length, 1);
|
||
// Finished: the next logout runs again.
|
||
const third = env.win.CSAuth.logout('#/account/login');
|
||
assert.strictEqual(dialogs, 2);
|
||
choose({ cancel: true });
|
||
assert.strictEqual((await third).cancelled, true);
|
||
});
|
||
|
||
test('logout handler: a refused POST does not run afterLogout', async () => {
|
||
const env = makeEnv((u) => u === '/api/auth/me' ? { status: 200, body: ME } : { status: 403, body: { error: 'no' } });
|
||
await env.win.CSAuth.ready();
|
||
let ran = false;
|
||
env.win.CSAuth.setLogoutHandler(() => Promise.resolve({ afterLogout() { ran = true; } }));
|
||
await env.win.CSAuth.logout('#/account/login');
|
||
assert.strictEqual(ran, false);
|
||
});
|
||
|
||
console.log('mobile nav account entry');
|
||
|
||
// Slice the real route tables and builders out of the two files; markers
|
||
// failing to match throws instead of silently testing nothing.
|
||
function sliceBetween(file, from, to) {
|
||
const src = fs.readFileSync(path.join(ROOT, 'public', file), 'utf8');
|
||
const a = src.indexOf(from), b = src.indexOf(to);
|
||
assert(a !== -1 && b > a, 'markers moved in ' + file);
|
||
return src.slice(a, b);
|
||
}
|
||
function loadNav(file, from, to, fn, win) {
|
||
const sb = { window: Object.assign({ addEventListener() {} }, win), console };
|
||
vm.createContext(sb);
|
||
vm.runInContext(sliceBetween(file, from, to) + '\nthis.fn = ' + fn + ';', sb);
|
||
return sb.fn;
|
||
}
|
||
const NAVS = [
|
||
{ name: 'bottom-nav moreRoutes', file: 'bottom-nav.js', from: 'var MORE_ROUTES = [', to: 'var SHEET_ID', fn: 'moreRoutes' },
|
||
{ name: 'nav-drawer routes', file: 'nav-drawer.js', from: 'var ROUTES = [', to: 'function phIconHTML', fn: 'routes' },
|
||
];
|
||
NAVS.forEach((n) => {
|
||
const get = (win) => loadNav(n.file, n.from, n.to, n.fn, win)();
|
||
const acct = (list) => list.filter((r) => r.route === 'account');
|
||
test(n.name + ': no account entry when user management is off', () => {
|
||
assert.strictEqual(acct(get({})).length, 0);
|
||
});
|
||
test(n.name + ': Log in entry when logged out', () => {
|
||
const a = acct(get({ MC_USER_MGMT: { enabled: true } }));
|
||
assert.strictEqual(a.length, 1);
|
||
assert.strictEqual(a[0].hash, '#/account/login');
|
||
assert.strictEqual(a[0].label, 'Log in');
|
||
});
|
||
test(n.name + ': My account entry when logged in', () => {
|
||
const a = acct(get({ MC_USER_MGMT: { enabled: true }, CS_USER: { id: 1 } }));
|
||
assert.strictEqual(a.length, 1);
|
||
assert.strictEqual(a[0].hash, '#/account');
|
||
assert.strictEqual(a[0].label, 'My account');
|
||
});
|
||
test(n.name + ': coverage insert still present after analytics', () => {
|
||
const list = get({ MC_CLIENT_RX_COVERAGE: true, MC_USER_MGMT: { enabled: true } });
|
||
const i = list.findIndex((r) => r.route === 'analytics');
|
||
assert.strictEqual(list[i + 1].route, 'rx-coverage');
|
||
assert.strictEqual(list.length, get({}).length + 2);
|
||
});
|
||
});
|
||
|
||
console.log('account.js');
|
||
|
||
function loadAccount(hash, routes, extraWin) {
|
||
const els = {};
|
||
const mk = () => {
|
||
const el = { value: '', textContent: '', handlers: {}, cls: {}, html: '' };
|
||
el.classList = { toggle(c, on) { el.cls[c] = !!on; } };
|
||
el.addEventListener = (t, fn) => { el.handlers[t] = fn; };
|
||
el.querySelector = () => null;
|
||
el.insertAdjacentHTML = (pos, html) => { els.renewLink = { html }; };
|
||
return el;
|
||
};
|
||
const doc = { getElementById(id) { return els[id] || (id === 'renewLink' ? null : (els[id] = mk())); } };
|
||
let pages = {};
|
||
const calls = [];
|
||
const user = { current: null };
|
||
const logouts = [];
|
||
let logoutResult = { ok: true, status: 200, data: { ok: true } };
|
||
let override = null;
|
||
const loc = { hash, replace(h) { loc.hash = h; } };
|
||
const replaced = [];
|
||
const listeners = {};
|
||
const CSAuth = {
|
||
request(method, p, body) { calls.push({ method, p, body }); return override ? override(method, p, body) : Promise.resolve(routes(p, body)); },
|
||
setUser(u) { user.current = u; },
|
||
user() { return user.current; },
|
||
logout(next) { logouts.push(next); return logoutResult instanceof Error ? Promise.reject(logoutResult) : Promise.resolve(logoutResult); },
|
||
ready() { return Promise.resolve(); }, isEnabled() { return true; },
|
||
notify() {}, refreshMe() { return Promise.resolve(); },
|
||
};
|
||
const win = Object.assign({ CSAuth, addEventListener(t, fn) { listeners[t] = fn; } }, extraWin || {});
|
||
const ctx = { window: win, document: doc, CSAuth, location: loc, URLSearchParams, Promise, String, Object,
|
||
history: { replaceState(a, b, h) { replaced.push(h); loc.hash = h; } },
|
||
escapeHtml: loadEscapeHtml(), registerPage(n, m) { pages[n] = m; }, console };
|
||
vm.createContext(ctx);
|
||
Object.assign(CSAuth, loadAuthHelpers(ctx));
|
||
vm.runInContext(fs.readFileSync(path.join(ROOT, 'public/account.js'), 'utf8'), ctx);
|
||
return { setRequest(f) { override = f; }, doc, t: ctx.window.CSAccount._test, els, calls, loc, user, pages, logouts, replaced,
|
||
setLogout(r) { logoutResult = r; },
|
||
fire(detail) { user.current = detail; if (listeners['cs-auth-changed']) listeners['cs-auth-changed']({ detail }); } };
|
||
}
|
||
const submitForm = async (env, formId) => {
|
||
await env.els[formId].handlers.submit({ preventDefault() {} });
|
||
};
|
||
|
||
test('profile view escapes email and session fields', () => {
|
||
const env = loadAccount('#/account', () => ({}));
|
||
const payload = '<img src=x onerror=alert(1)>';
|
||
const html = env.t.profileHtml({ email: payload, role: 'admin', displayName: payload });
|
||
assert(html.indexOf('<img') === -1, 'raw tag in profile HTML');
|
||
assert(html.indexOf('<img src=x onerror=alert(1)>') !== -1);
|
||
const sess = env.t.sessionsHtml([{ id: '1"><b>', userAgent: payload, lastSeenAt: 'x', current: false }]);
|
||
assert(sess.indexOf('<img') === -1 && sess.indexOf('<b>') === -1, 'raw tag in sessions HTML: ' + sess);
|
||
});
|
||
|
||
test('sessions list marks the current device without a logout button', () => {
|
||
const env = loadAccount('#/account', () => ({}));
|
||
const h = env.t.sessionsHtml([{ id: 1, userAgent: 'A', lastSeenAt: '2026-01-01T00:00:00Z', current: true },
|
||
{ id: 2, userAgent: '', lastSeenAt: '2026-01-01T00:00:00Z', current: false }]);
|
||
assert.strictEqual((h.match(/data-sess=/g) || []).length, 1);
|
||
assert(h.indexOf('data-sess="2"') !== -1 && h.indexOf('Unknown device') !== -1);
|
||
});
|
||
|
||
test('device tokens render as CoreDrive RX with a marker and keep their logout button', () => {
|
||
const env = loadAccount('#/account', () => ({}));
|
||
const h = env.t.sessionsHtml([
|
||
{ id: 1, kind: 'web', label: '', userAgent: 'Firefox', lastSeenAt: '2026-01-01T00:00:00Z', current: true },
|
||
{ id: 2, kind: 'device', label: 'Pixel <8>', userAgent: 'CoreDriveRX/1.17', lastSeenAt: '2026-01-01T00:00:00Z', current: false },
|
||
{ id: 3, kind: 'device', label: '', userAgent: '', lastSeenAt: '2026-01-01T00:00:00Z', current: false }]);
|
||
assert(h.indexOf('<li><span>Firefox') !== -1, 'web session changed: ' + h);
|
||
assert(h.indexOf('CoreDrive RX – Pixel <8>') !== -1, 'no labelled device row: ' + h);
|
||
assert(h.indexOf('<span>CoreDrive RX <span class="um-chip um-chip-device">') !== -1, 'no unlabelled device row: ' + h);
|
||
assert(h.indexOf('CoreDriveRX/1.17') === -1, 'a device row shows its user agent instead of its label');
|
||
assert.strictEqual((h.match(/data-kind="device"/g) || []).length, 2);
|
||
assert.strictEqual((h.match(/um-chip-device/g) || []).length, 2);
|
||
assert(h.indexOf('data-sess="2"') !== -1 && h.indexOf('data-sess="3"') !== -1, 'device rows lost their logout button');
|
||
});
|
||
|
||
test('companions list escapes fields, shortens the pubkey and says never without a reception', () => {
|
||
const env = loadAccount('#/account', () => ({}));
|
||
const pk = 'ab'.repeat(32);
|
||
const h = env.t.companionsHtml([
|
||
{ pubkey: pk, name: '<img src=x>', linkedAt: '2026-10-01T10:00:00Z', lastSeenAt: null },
|
||
{ pubkey: '"><b>', name: '', linkedAt: 'x', lastSeenAt: '2026-10-02T09:00:00Z' }]);
|
||
assert(h.indexOf('<img') === -1 && h.indexOf('<b>') === -1, 'raw tag in companions HTML: ' + h);
|
||
assert(h.indexOf('<img src=x>') !== -1, 'name missing: ' + h);
|
||
assert(h.indexOf('<code>' + pk.slice(0, 12) + '…</code>') !== -1, 'no short pubkey: ' + h);
|
||
assert(h.indexOf('data-unlink="' + pk + '"') !== -1, 'Unlink does not carry the full pubkey');
|
||
assert(h.indexOf('last seen never') !== -1, 'no "never" for a null lastSeenAt');
|
||
assert.strictEqual((h.match(/data-unlink=/g) || []).length, 2);
|
||
});
|
||
|
||
test('companions empty state explains linking from CoreDrive RX', () => {
|
||
const env = loadAccount('#/account', () => ({}));
|
||
[[], null].forEach((list) => {
|
||
const h = env.t.companionsHtml(list);
|
||
assert(h.indexOf('id="compEmpty"') !== -1 && h.indexOf('CoreDrive RX') !== -1 && h.indexOf('data-unlink') === -1, h);
|
||
assert(h.indexOf('My nodes') === -1, 'linking no longer adds to My nodes: ' + h);
|
||
});
|
||
});
|
||
|
||
test('profile view has a Companions section below Devices', () => {
|
||
const env = loadAccount('#/account', () => ({}));
|
||
const html = env.t.profileHtml({ email: 'a@b.c', role: 'user', displayName: 'Ann' });
|
||
const dev = html.indexOf('<h3>Devices</h3>'), comp = html.indexOf('<h3>Companions</h3>');
|
||
assert(dev !== -1 && comp > dev, 'Companions heading missing or above Devices');
|
||
assert(html.indexOf('id="compList"') !== -1 && html.indexOf('id="compMsg"') !== -1);
|
||
});
|
||
|
||
test('profile view lists companions; Unlink confirms, sends DELETE and reloads the list', async () => {
|
||
const pk = 'cd'.repeat(32);
|
||
let linked = [{ pubkey: pk, name: 'Car', linkedAt: '2026-10-01T10:00:00Z', lastSeenAt: null }];
|
||
const env = loadAccount('#/account', (p) => {
|
||
if (p === '/api/account/companions') return { ok: true, status: 200, data: linked };
|
||
if (p === '/api/account/companions/' + pk) { linked = []; return { ok: true, status: 204, data: {} }; }
|
||
return { ok: true, status: 200, data: [] };
|
||
}, { confirm: () => true });
|
||
env.user.current = { id: 1, email: 'a@b.c', displayName: 'Ann', role: 'user' };
|
||
env.t.views.profile({ set innerHTML(v) {} });
|
||
await new Promise((r) => setTimeout(r, 5));
|
||
assert(env.els.compList.innerHTML.indexOf('data-unlink="' + pk + '"') !== -1, env.els.compList.innerHTML);
|
||
await env.els.compList.handlers.click({ target: { getAttribute: (a) => (a === 'data-unlink' ? pk : null) } });
|
||
await new Promise((r) => setTimeout(r, 5));
|
||
assert(env.calls.some((c) => c.method === 'DELETE' && c.p === '/api/account/companions/' + pk), 'no DELETE');
|
||
assert.strictEqual(env.els.compMsg.textContent, 'Companion unlinked.');
|
||
assert(env.els.compList.innerHTML.indexOf('id="compEmpty"') !== -1, 'list not reloaded');
|
||
});
|
||
|
||
test('a cancelled Unlink sends nothing', async () => {
|
||
const pk = 'cd'.repeat(32);
|
||
const env = loadAccount('#/account', (p) => (p === '/api/account/companions'
|
||
? { ok: true, status: 200, data: [{ pubkey: pk, name: '', linkedAt: 'x', lastSeenAt: null }] }
|
||
: { ok: true, status: 200, data: [] }), { confirm: () => false });
|
||
env.user.current = { id: 1, email: 'a@b.c', displayName: 'Ann', role: 'user' };
|
||
env.t.views.profile({ set innerHTML(v) {} });
|
||
await new Promise((r) => setTimeout(r, 5));
|
||
await env.els.compList.handlers.click({ target: { getAttribute: () => pk } });
|
||
assert(!env.calls.some((c) => c.method === 'DELETE'), 'DELETE sent after cancel');
|
||
});
|
||
|
||
test('a rejected companions load shows the error in compMsg', async () => {
|
||
const env = loadAccount('#/account', () => ({}));
|
||
env.user.current = { id: 1, email: 'a@b.c', displayName: 'Ann', role: 'user' };
|
||
env.setRequest(() => Promise.reject(new Error('net')));
|
||
env.t.views.profile({ set innerHTML(v) {} });
|
||
await new Promise((r) => setTimeout(r, 5));
|
||
assert.strictEqual(env.els.compMsg.textContent, 'Network error, try again.');
|
||
});
|
||
|
||
test('activate posts token and password, logs in on success', async () => {
|
||
const env = loadAccount('#/account/activate?token=T0K', () => ({ ok: true, status: 200, data: { id: 1, displayName: 'Ann' } }));
|
||
env.t.views.activate({});
|
||
env.doc.getElementById('actPassword').value = 'hunter2hunter2';
|
||
await submitForm(env, 'activateForm');
|
||
assert.strictEqual(JSON.stringify(env.calls[0]), JSON.stringify({ method: 'POST', p: '/api/auth/activate', body: { token: 'T0K', password: 'hunter2hunter2' } }));
|
||
assert.strictEqual(env.user.current.displayName, 'Ann');
|
||
assert.strictEqual(env.loc.hash, '#/account');
|
||
});
|
||
|
||
test('activate 401 shows the wrong-password message and stays on the form', async () => {
|
||
const env = loadAccount('#/account/activate?token=T', () => ({ ok: false, status: 401, data: { error: 'wrong password for this account' } }));
|
||
env.t.views.activate({});
|
||
await submitForm(env, 'activateForm');
|
||
assert.strictEqual(env.els.accountMsg.textContent, 'Wrong password for this account');
|
||
assert.strictEqual(env.user.current, null);
|
||
assert.strictEqual(env.loc.hash, '#/account/activate'); // token stripped, form kept
|
||
});
|
||
|
||
test('activate 410 offers a new registration link', async () => {
|
||
const env = loadAccount('#/account/activate?token=T', () => ({ ok: false, status: 410, data: { error: 'this link has expired' } }));
|
||
env.t.views.activate({});
|
||
await submitForm(env, 'activateForm');
|
||
assert(env.els.renewLink.html.indexOf('href="#/account/register"') !== -1);
|
||
assert(env.els.renewLink.html.indexOf('Register again to get a new link') !== -1);
|
||
});
|
||
|
||
test('reset 410 offers the forgot-password link', async () => {
|
||
const env = loadAccount('#/account/reset?token=T', () => ({ ok: false, status: 410, data: { error: 'this link has expired' } }));
|
||
env.t.views.reset({});
|
||
env.doc.getElementById('resetPassword').value = 'abcdefghijkl';
|
||
env.doc.getElementById('resetPassword2').value = 'abcdefghijkl';
|
||
await submitForm(env, 'resetForm');
|
||
assert.strictEqual(JSON.stringify(env.calls[0].body), JSON.stringify({ token: 'T', password: 'abcdefghijkl' }));
|
||
assert(env.els.renewLink.html.indexOf('href="#/account/forgot"') !== -1);
|
||
});
|
||
|
||
test('reset refuses mismatching passwords without a request', async () => {
|
||
const env = loadAccount('#/account/reset?token=T', () => ({ ok: true, status: 200, data: {} }));
|
||
env.t.views.reset({});
|
||
env.doc.getElementById('resetPassword').value = 'abcdefghijkl';
|
||
env.doc.getElementById('resetPassword2').value = 'different-pass';
|
||
await submitForm(env, 'resetForm');
|
||
assert.strictEqual(env.calls.length, 0);
|
||
assert.strictEqual(env.els.accountMsg.textContent, 'The passwords do not match.');
|
||
});
|
||
|
||
test('activate without a token renders no form and no password input', () => {
|
||
const env = loadAccount('#/account/activate', () => ({}));
|
||
let html = '';
|
||
env.t.views.activate({ set innerHTML(v) { html = v; } });
|
||
assert(html.indexOf('<form') === -1 && html.indexOf('type="password"') === -1, html);
|
||
assert(html.indexOf('href="#/account/login"') !== -1);
|
||
});
|
||
|
||
test('no account input carries a name attribute (no native submit leak)', () => {
|
||
const env = loadAccount('#/account', () => ({}));
|
||
assert(!/<input[^>]* name=/.test(env.t.profileHtml({ email: 'a', role: 'user' })));
|
||
let html = '';
|
||
env.t.views.register({ set innerHTML(v) { html = v; } });
|
||
assert(!/<input[^>]* name=/.test(html));
|
||
});
|
||
|
||
const flush = () => new Promise((r) => setTimeout(r, 5));
|
||
|
||
const render = (env, name) => { let html = ''; env.t.views[name]({ set innerHTML(v) { html = v; } }); return html; };
|
||
|
||
test('register success goes to check-mail, which shows the escaped email and no form', async () => {
|
||
const payload = '<img src=x onerror=alert(1)>@e.c';
|
||
const env = loadAccount('#/account/register', () => ({ ok: true, status: 200, data: { message: 'x' } }));
|
||
env.t.views.register({ set innerHTML(v) {} });
|
||
env.doc.getElementById('regEmail').value = payload;
|
||
await submitForm(env, 'registerForm');
|
||
await flush();
|
||
assert.strictEqual(env.loc.hash, '#/account/check-mail');
|
||
const html = render(env, 'check-mail');
|
||
assert(html.indexOf('Check your mailbox') !== -1, html);
|
||
assert(html.indexOf('<form') === -1, 'form present');
|
||
assert(html.indexOf('<img') === -1 && html.indexOf('<img') !== -1, html);
|
||
assert(html.indexOf('expires in 48 hours') !== -1);
|
||
assert(html.indexOf('href="#/account/login"') !== -1 && html.indexOf('href="#/account/register"') !== -1);
|
||
assert(html.indexOf('tabindex="-1"') !== -1 && html.indexOf('ph-envelope-simple') !== -1);
|
||
assert(env.els.mailSentHeading, 'heading not looked up for focus');
|
||
});
|
||
|
||
test('check-mail without state (refresh) shows generic text and no address', () => {
|
||
const env = loadAccount('#/account/check-mail', () => ({ ok: true, status: 200, data: {} }));
|
||
const html = render(env, 'check-mail');
|
||
assert(html.indexOf('If the address can be used, we sent you a link. Check your mailbox.') !== -1, html);
|
||
assert(html.indexOf('<strong>') === -1 && html.indexOf('<form') === -1);
|
||
});
|
||
|
||
test('forgot success goes to check-mail with the escaped email and 1 hour text', async () => {
|
||
const payload = '<img src=x onerror=alert(1)>@e.c';
|
||
const env = loadAccount('#/account/forgot', () => ({ ok: true, status: 200, data: { message: 'x' } }));
|
||
env.t.views.forgot({ set innerHTML(v) {} });
|
||
env.doc.getElementById('forgotEmail').value = payload;
|
||
await submitForm(env, 'forgotForm');
|
||
await flush();
|
||
assert.strictEqual(env.loc.hash, '#/account/check-mail');
|
||
const html = render(env, 'check-mail');
|
||
assert(html.indexOf('Check your mailbox') !== -1 && html.indexOf('<form') === -1, html);
|
||
assert(html.indexOf('<img') === -1 && html.indexOf('<img') !== -1, html);
|
||
assert(html.indexOf('expires in 1 hour') !== -1 && html.indexOf('Back to log in') !== -1);
|
||
});
|
||
|
||
test('register 400 keeps the form and shows the error', async () => {
|
||
const env = loadAccount('#/account/register', () => ({ ok: false, status: 400, data: { error: 'Bad password' } }));
|
||
let html = '';
|
||
env.t.views.register({ set innerHTML(v) { html = v; } });
|
||
await submitForm(env, 'registerForm');
|
||
await flush();
|
||
assert(html.indexOf('<form') !== -1 && html.indexOf('Check your mailbox') === -1);
|
||
assert.strictEqual(env.loc.hash, '#/account/register');
|
||
assert.strictEqual(env.els.accountMsg.textContent, 'Bad password');
|
||
});
|
||
|
||
|
||
test('a rejected fetch on a profile form shows the error in its own box', async () => {
|
||
const env = loadAccount('#/account', () => ({}));
|
||
env.user.current = { email: 'a@b.c', displayName: 'Ann', role: 'user' };
|
||
let n = 0;
|
||
env.calls.length = 0;
|
||
const req = () => (++n === 1 ? Promise.resolve({ ok: true, status: 200, data: [] }) : Promise.reject(new Error('net')));
|
||
env.setRequest(req);
|
||
env.t.views.profile({ set innerHTML(v) {} });
|
||
await new Promise((r) => setTimeout(r, 5));
|
||
await submitForm(env, 'pwForm');
|
||
assert.strictEqual(env.els.pwMsg.textContent, 'Network error, try again.');
|
||
});
|
||
|
||
test('a rejected sessions load shows the error in sessMsg', async () => {
|
||
const env = loadAccount('#/account', () => ({}));
|
||
env.user.current = { email: 'a@b.c', displayName: 'Ann', role: 'user' };
|
||
env.setRequest(() => Promise.reject(new Error('net')));
|
||
env.t.views.profile({ set innerHTML(v) {} });
|
||
await new Promise((r) => setTimeout(r, 5));
|
||
assert.strictEqual(env.els.sessMsg.textContent, 'Network error, try again.');
|
||
});
|
||
|
||
test('profile view without a user redirects to login', () => {
|
||
const env = loadAccount('#/account', () => ({}));
|
||
env.t.views.profile({});
|
||
assert.strictEqual(env.loc.hash, '#/account/login');
|
||
});
|
||
|
||
test('profile view: Log out button logs out to the login view', async () => {
|
||
const env = loadAccount('#/account', () => ({ ok: true, status: 200, data: [] }));
|
||
env.user.current = { id: 1, email: 'a@b.c', displayName: 'Ann', role: 'user' };
|
||
env.t.views.profile({ set innerHTML(v) {} });
|
||
await env.els.accountPageLogout.handlers.click();
|
||
assert.deepStrictEqual(env.logouts, ['#/account/login']);
|
||
});
|
||
|
||
test('profile view: a refused logout shows the error next to the button', async () => {
|
||
const env = loadAccount('#/account', () => ({ ok: true, status: 200, data: [] }));
|
||
env.user.current = { id: 1, email: 'a@b.c', displayName: 'Ann', role: 'user' };
|
||
env.setLogout({ ok: false, status: 403, data: { error: 'request origin not allowed' } });
|
||
env.t.views.profile({ set innerHTML(v) {} });
|
||
await env.els.accountPageLogout.handlers.click();
|
||
assert.strictEqual(env.els.logoutMsg.textContent, 'request origin not allowed');
|
||
env.setLogout(new Error('net'));
|
||
await env.els.accountPageLogout.handlers.click();
|
||
assert.strictEqual(env.els.logoutMsg.textContent, 'Network error, try again.');
|
||
});
|
||
|
||
test('profile view: a cancelled logout shows no message', async () => {
|
||
const env = loadAccount('#/account', () => ({ ok: true, status: 200, data: [] }));
|
||
env.user.current = { id: 1, email: 'a@b.c', displayName: 'Ann', role: 'user' };
|
||
env.setLogout({ ok: false, cancelled: true, status: 0, data: {} });
|
||
env.t.views.profile({ set innerHTML(v) {} });
|
||
await env.els.accountPageLogout.handlers.click();
|
||
assert.strictEqual(env.doc.getElementById('logoutMsg').textContent, '');
|
||
});
|
||
|
||
test('profile view mounts the settings sync section only when the module is loaded', () => {
|
||
const mounted = [];
|
||
const env = loadAccount('#/account', () => ({ ok: true, status: 200, data: [] }), { CSSettingsSync: { mountSection(el) { mounted.push(el); } } });
|
||
env.user.current = { id: 1, email: 'a@b.c', displayName: 'Ann', role: 'user' };
|
||
const app = { innerHTML: '' };
|
||
env.t.views.profile(app);
|
||
assert(app.innerHTML.indexOf('<h3>Settings sync</h3><div id="syncSection"></div>') !== -1);
|
||
assert.strictEqual(mounted.length, 1);
|
||
assert.strictEqual(mounted[0], env.els.syncSection);
|
||
const without = loadAccount('#/account', () => ({}));
|
||
assert.strictEqual(without.t.profileHtml({ email: 'a', role: 'user', displayName: 'A' }).indexOf('syncSection'), -1);
|
||
});
|
||
|
||
test('profile view shows My proposals only when channel proposals are on', () => {
|
||
const loaded = [];
|
||
const env = loadAccount('#/account', () => ({ ok: true, status: 200, data: [] }),
|
||
{ CSProposals: { enabled: () => true, loadMine(el, msgId) { loaded.push([el, msgId]); } } });
|
||
env.user.current = { id: 1, email: 'a@b.c', displayName: 'Ann', role: 'user' };
|
||
const app = { innerHTML: '' };
|
||
env.t.views.profile(app);
|
||
assert(app.innerHTML.indexOf('<h3>My proposals</h3><div id="propList"></div>') !== -1, app.innerHTML);
|
||
assert.strictEqual(loaded.length, 1);
|
||
assert.strictEqual(loaded[0][0], env.els.propList);
|
||
assert.strictEqual(loaded[0][1], 'propMsg');
|
||
const off = loadAccount('#/account', () => ({}), { CSProposals: { enabled: () => false, loadMine() { throw new Error('loaded while off'); } } });
|
||
assert.strictEqual(off.t.profileHtml({ email: 'a', role: 'user', displayName: 'A' }).indexOf('propList'), -1);
|
||
const absent = loadAccount('#/account', () => ({}));
|
||
assert.strictEqual(absent.t.profileHtml({ email: 'a', role: 'user', displayName: 'A' }).indexOf('propList'), -1);
|
||
});
|
||
|
||
test('profile view: Log out button for everyone, Admin link for admins only', () => {
|
||
const env = loadAccount('#/account', () => ({}));
|
||
const user = env.t.profileHtml({ email: 'a', role: 'user', displayName: 'A' });
|
||
const admin = env.t.profileHtml({ email: 'a', role: 'admin', displayName: 'A' });
|
||
assert(user.indexOf('id="accountPageLogout"') !== -1 && admin.indexOf('id="accountPageLogout"') !== -1);
|
||
assert(user.indexOf('#/admin') === -1);
|
||
assert(admin.indexOf('href="#/admin">Admin</a>') !== -1, admin);
|
||
});
|
||
|
||
test('profile view: Download my data links to the export route, before Delete account', () => {
|
||
const env = loadAccount('#/account', () => ({}));
|
||
const html = env.t.profileHtml({ email: 'a', role: 'user', displayName: 'A' });
|
||
const link = '<a class="account-btn account-btn-secondary" id="accountExport" href="/api/account/export" download>Download my data</a>';
|
||
assert(html.indexOf(link) !== -1, html);
|
||
assert(html.indexOf('<h3>My data</h3>') !== -1, 'no My data heading');
|
||
assert(html.indexOf('id="accountExport"') < html.indexOf('id="delForm"'), 'export link after the delete form');
|
||
});
|
||
|
||
test('profile view follows auth changes: logout redirects, another user re-renders', () => {
|
||
const env = loadAccount('#/account', () => ({ ok: true, status: 200, data: [] }));
|
||
env.user.current = { id: 1, email: 'a@b.c', displayName: 'Ann', role: 'user' };
|
||
let renders = 0;
|
||
env.t.views.profile({ set innerHTML(v) { renders++; } });
|
||
env.fire({ id: 1, email: 'a@b.c', displayName: 'Ann B', role: 'user' }); // same user (name saved): no re-render
|
||
assert.strictEqual(renders, 1);
|
||
env.fire({ id: 2, email: 'b@b.c', displayName: 'Bob', role: 'user' });
|
||
assert.strictEqual(renders, 2);
|
||
assert.strictEqual(env.els.profName.value, 'Bob');
|
||
env.fire(null);
|
||
assert.strictEqual(env.loc.hash, '#/account/login');
|
||
});
|
||
|
||
test('auth changes on other views are ignored', () => {
|
||
const env = loadAccount('#/account', () => ({ ok: true, status: 200, data: [] }));
|
||
env.user.current = { id: 1, email: 'a@b.c', displayName: 'Ann', role: 'user' };
|
||
env.t.views.profile({ set innerHTML(v) {} });
|
||
env.loc.hash = '#/home';
|
||
env.fire(null);
|
||
assert.strictEqual(env.loc.hash, '#/home');
|
||
});
|
||
|
||
test('unknown and inherited view names fall back to the profile view', async () => {
|
||
for (const name of ['constructor', '__proto__', 'toString', 'nope']) {
|
||
const env = loadAccount('#/account/' + name, () => ({}));
|
||
env.pages.account.init({ set innerHTML(v) {} }, name);
|
||
await tick();
|
||
assert.strictEqual(env.loc.hash, '#/account/login', name + ' did not reach the profile view');
|
||
}
|
||
});
|
||
|
||
test('tokens are stripped from the hash once read', async () => {
|
||
const act = loadAccount('#/account/activate?token=T0K', () => ({ ok: true, status: 200, data: { id: 1 } }));
|
||
act.t.views.activate({ set innerHTML(v) {} });
|
||
assert.strictEqual(act.loc.hash, '#/account/activate');
|
||
await submitForm(act, 'activateForm');
|
||
assert.strictEqual(act.calls[0].body.token, 'T0K');
|
||
const reset = loadAccount('#/account/reset?token=R1', () => ({ ok: true, status: 200, data: { message: 'ok' } }));
|
||
reset.t.views.reset({ set innerHTML(v) {} });
|
||
assert.strictEqual(reset.loc.hash, '#/account/reset');
|
||
const conf = loadAccount('#/account/confirm-email?token=C1', () => ({ ok: true, status: 200, data: { message: 'ok' } }));
|
||
conf.t.views['confirm-email']({ set innerHTML(v) {} });
|
||
assert.strictEqual(conf.loc.hash, '#/account/confirm-email');
|
||
assert.strictEqual(conf.calls[0].body.token, 'C1');
|
||
});
|
||
|
||
test('reset success clears the client user (the reset ended every session)', async () => {
|
||
const env = loadAccount('#/account/reset?token=T', () => ({ ok: true, status: 200, data: { message: 'Password changed.' } }));
|
||
env.user.current = { id: 1 };
|
||
env.t.views.reset({ set innerHTML(v) {} });
|
||
env.doc.getElementById('resetPassword').value = 'abcdefghijkl';
|
||
env.doc.getElementById('resetPassword2').value = 'abcdefghijkl';
|
||
await submitForm(env, 'resetForm');
|
||
assert.strictEqual(env.user.current, null);
|
||
assert.strictEqual(env.els.accountMsg.textContent, 'Password changed.');
|
||
});
|
||
|
||
test('activate 410 for an already active account offers no registration link', async () => {
|
||
const env = loadAccount('#/account/activate?token=T', () => ({ ok: false, status: 410, data: { error: 'this account is already activated, log in instead' } }));
|
||
env.t.views.activate({ set innerHTML(v) {} });
|
||
await submitForm(env, 'activateForm');
|
||
assert.strictEqual(env.els.renewLink, undefined);
|
||
assert.strictEqual(env.els.accountMsg.textContent, 'this account is already activated, log in instead');
|
||
});
|
||
|
||
console.log('admin-users.js');
|
||
|
||
function loadAdmin(hash, routes, opts) {
|
||
const els = {};
|
||
const mk = (id) => {
|
||
const el = { id, value: '', checked: false, textContent: '', innerHTML: '', hidden: false, handlers: {}, cls: {}, focused: 0 };
|
||
el.classList = { toggle(c, on) { el.cls[c] = !!on; } };
|
||
el.addEventListener = (t, fn) => { el.handlers[t] = fn; };
|
||
el.focus = () => { el.focused++; };
|
||
el.querySelector = (sel) => els[id + ' ' + sel] || (els[id + ' ' + sel] = mk(id + ' ' + sel));
|
||
return el;
|
||
};
|
||
const doc = {
|
||
getElementById(id) { return els[id] || (els[id] = mk(id)); },
|
||
querySelector(sel) { return els[sel] || (els[sel] = mk(sel)); },
|
||
};
|
||
const calls = [];
|
||
const replaced = [];
|
||
const loc = { hash };
|
||
let me = { id: 1, role: 'admin' };
|
||
let refreshed = 0;
|
||
const CSAuth = {
|
||
isAdmin() { return !!me && me.role === 'admin'; },
|
||
user() { return me; },
|
||
request(method, p, body) { calls.push({ method, p, body }); return Promise.resolve(routes(method, p, body)); },
|
||
refreshMe() { refreshed++; me = { id: 1, role: 'user' }; return Promise.resolve(); },
|
||
};
|
||
const ctx = { window: { CSAuth }, document: doc, CSAuth, location: loc, URLSearchParams, Promise, String,
|
||
history: { replaceState(a, b, h) { replaced.push(h); loc.hash = h; } },
|
||
confirm() { return true; }, debounce: (opts && opts.debounce) || function (fn) { return fn; },
|
||
escapeHtml: loadEscapeHtml(), console };
|
||
vm.createContext(ctx);
|
||
Object.assign(CSAuth, loadAuthHelpers(ctx));
|
||
vm.runInContext(fs.readFileSync(path.join(ROOT, 'public/admin-users.js'), 'utf8'), ctx);
|
||
const app = { innerHTML: '', querySelector() { return els.umPage || (els.umPage = mk('umPage')); } };
|
||
const um = ctx.window.CSAdminUsers;
|
||
return { t: um._test, um, els, doc, calls, replaced, loc, app, refreshed: () => refreshed };
|
||
}
|
||
const tick = () => new Promise((r) => setTimeout(r, 5));
|
||
const U = (o) => Object.assign({ id: 2, email: 'u@x.y', displayName: 'U', role: 'user', status: 'active', configAdmin: false }, o);
|
||
const OK = (data) => ({ ok: true, status: 200, data: data });
|
||
const acts = (env, u, me) => (env.t.actionsFor(u, me).match(/data-act="(\w+)"/g) || []).map((x) => x.slice(10, -1));
|
||
const adminEnv = () => loadAdmin('#/admin?tab=users', () => OK([]));
|
||
|
||
test('actionsFor: pending offers activate/resend, no disable and no role buttons', () => {
|
||
assert.deepStrictEqual(acts(adminEnv(), U({ status: 'pending' }), { id: 1 }), ['detail', 'activate', 'resend', 'delete']);
|
||
});
|
||
test('actionsFor: active user offers disable, promote, delete', () => {
|
||
assert.deepStrictEqual(acts(adminEnv(), U(), { id: 1 }), ['detail', 'disable', 'promote', 'delete']);
|
||
});
|
||
test('actionsFor: active admin offers demote', () => {
|
||
assert.deepStrictEqual(acts(adminEnv(), U({ role: 'admin' }), { id: 1 }), ['detail', 'disable', 'demote', 'delete']);
|
||
});
|
||
test('actionsFor: disabled offers enable, never disable', () => {
|
||
assert.deepStrictEqual(acts(adminEnv(), U({ status: 'disabled' }), { id: 1 }), ['detail', 'enable', 'promote', 'delete']);
|
||
});
|
||
test('actionsFor: own row has no disable or delete but can change role', () => {
|
||
assert.deepStrictEqual(acts(adminEnv(), U({ id: 1, role: 'admin' }), { id: 1 }), ['detail', 'demote']);
|
||
});
|
||
test('actionsFor: config admin has no disable, role or delete', () => {
|
||
assert.deepStrictEqual(acts(adminEnv(), U({ role: 'admin', configAdmin: true }), { id: 1 }), ['detail']);
|
||
});
|
||
test('actionsFor: buttons use account-btn classes', () => {
|
||
const h = adminEnv().t.actionsFor(U(), { id: 1 });
|
||
assert(h.indexOf('class="account-btn account-btn-secondary"') !== -1 && h.indexOf('btn-primary') === -1);
|
||
});
|
||
|
||
test('row and detail rendering escape every dynamic field', () => {
|
||
const env = adminEnv();
|
||
const p = '<img src=x onerror=alert(1)>';
|
||
const row = env.t.rowHtml(U({ id: '1"><b>', email: p, displayName: p, role: p, status: p, lastMail: { lastEvent: p, lastReason: p }, createdAt: p }), { id: 1 });
|
||
assert(row.indexOf('<img') === -1 && row.indexOf('<b>') === -1, 'raw markup in row: ' + row);
|
||
const det = env.t.detailHtml({ user: { id: 1, displayName: p, email: p }, sessions: [],
|
||
mail: [{ id: '3"><i>', purpose: p, to: p, sentAt: p, lastEvent: p, lastReason: p, events: [{ event: p, at: p, reason: p }] }],
|
||
audit: [{ at: p, action: p, actorUserId: p }] });
|
||
assert(det.indexOf('<img') === -1 && det.indexOf('<i>') === -1, 'raw markup in detail: ' + det);
|
||
assert(det.indexOf('<img src=x onerror=alert(1)>') !== -1);
|
||
});
|
||
|
||
test('deep link: filters, bouncing and detail id round-trip through the hash', () => {
|
||
const env = adminEnv();
|
||
const rh = (h) => JSON.parse(JSON.stringify(env.t.readHash(h)));
|
||
assert.deepStrictEqual(rh('#/admin?tab=users&status=pending&role=admin&q=a%20b&id=7'),
|
||
{ status: 'pending', role: 'admin', q: 'a b', bouncing: false, id: '7' });
|
||
assert.strictEqual(rh('#/admin?tab=users&bouncing=1').bouncing, true);
|
||
assert.strictEqual(rh('#/admin?tab=users&bouncing=yes').bouncing, false);
|
||
assert.strictEqual(env.t.hashFor({ status: 'pending', role: '', q: 'a b', bouncing: false }, '7'), '#/admin?tab=users&status=pending&q=a+b&id=7');
|
||
assert.strictEqual(env.t.hashFor({ status: '', role: '', q: '', bouncing: true }, null), '#/admin?tab=users&bouncing=1');
|
||
assert.strictEqual(env.t.hashFor({ status: '', role: '', q: '', bouncing: false }, null), '#/admin?tab=users');
|
||
});
|
||
|
||
test('mount reads the hash into the request and detail; filter changes use replaceState', async () => {
|
||
const env = loadAdmin('#/admin?tab=users&status=pending&id=7', (m, p) =>
|
||
OK(p.indexOf('/api/admin/users/7') === 0 ? { user: U({ id: 7 }), sessions: [], mail: [], audit: [] } : []));
|
||
env.um.mount(env.app);
|
||
await tick();
|
||
assert(env.calls.some((c) => c.p === '/api/admin/users?status=pending'), JSON.stringify(env.calls));
|
||
assert(env.calls.some((c) => c.p === '/api/admin/users/7'));
|
||
assert.strictEqual(env.els.umStatus.value, 'pending');
|
||
env.els.umRole.handlers.change({ target: { value: 'admin' } });
|
||
assert.strictEqual(env.loc.hash, '#/admin?tab=users&status=pending&role=admin&id=7');
|
||
assert(env.replaced.length >= 1);
|
||
});
|
||
|
||
test('bouncing filter is sent to the server as bouncing=1 and kept in the hash', async () => {
|
||
const env = loadAdmin('#/admin?tab=users&bouncing=1', (m, p) =>
|
||
OK(p.indexOf('bouncing=1') !== -1 ? [U({ id: 2, email: 'b@x.y', emailBouncing: true })] : [U({ id: 2, email: 'b@x.y', emailBouncing: true }), U({ id: 3, email: 'c@x.y' })]));
|
||
env.um.mount(env.app);
|
||
await tick();
|
||
assert(env.calls.some((c) => c.p === '/api/admin/users?bouncing=1'), JSON.stringify(env.calls));
|
||
assert.strictEqual(env.els.umBouncing.checked, true);
|
||
assert(env.els.umBody.innerHTML.indexOf('b@x.y') !== -1 && env.els.umBody.innerHTML.indexOf('c@x.y') === -1, env.els.umBody.innerHTML);
|
||
env.els.umBouncing.handlers.change({ target: { checked: false } });
|
||
await tick();
|
||
assert.strictEqual(env.loc.hash, '#/admin?tab=users');
|
||
assert.strictEqual(env.calls[env.calls.length - 1].p, '/api/admin/users?');
|
||
assert(env.els.umBody.innerHTML.indexOf('c@x.y') !== -1);
|
||
});
|
||
|
||
test('the browser does not drop rows the server returned for the bouncing filter', async () => {
|
||
const env = loadAdmin('#/admin?tab=users&bouncing=1', () => OK([U({ id: 4, email: 'd@x.y', emailBouncing: false })]));
|
||
env.um.mount(env.app);
|
||
await tick();
|
||
assert(env.els.umBody.innerHTML.indexOf('d@x.y') !== -1, 'the server is the filter: ' + env.els.umBody.innerHTML);
|
||
});
|
||
|
||
test('a rejected list fetch shows an error in umMsg', async () => {
|
||
const env = loadAdmin('#/admin?tab=users', () => Promise.reject(new Error('net')));
|
||
env.um.mount(env.app);
|
||
await tick();
|
||
assert.strictEqual(env.els.umMsg.textContent, 'Network error, try again.');
|
||
});
|
||
|
||
test('a rejected action shows an error in umMsg', async () => {
|
||
let n = 0;
|
||
const env = loadAdmin('#/admin?tab=users', () => (++n === 1 ? OK([]) : Promise.reject(new Error('net'))));
|
||
env.um.mount(env.app);
|
||
await tick();
|
||
env.els.umPage.handlers.click({ target: { closest: () => ({ getAttribute: (a) => ({ 'data-act': 'disable', 'data-id': '2' })[a] }) } });
|
||
await tick();
|
||
assert.strictEqual(env.els.umMsg.textContent, 'Network error, try again.');
|
||
});
|
||
|
||
test('demoting yourself refreshes the session and leaves the page', async () => {
|
||
const env = loadAdmin('#/admin?tab=users', () => OK([]));
|
||
env.um.mount(env.app);
|
||
await tick();
|
||
env.els.umPage.handlers.click({ target: { closest: () => ({ getAttribute: (a) => ({ 'data-act': 'demote', 'data-id': '1' })[a] }) } });
|
||
await tick();
|
||
assert.strictEqual(env.refreshed(), 1);
|
||
assert.strictEqual(env.loc.hash, '#/account');
|
||
});
|
||
|
||
test('readHash accepts only numeric ids and known status and role values', () => {
|
||
const env = adminEnv();
|
||
const h = (s) => JSON.parse(JSON.stringify(env.t.readHash('#/admin?tab=users&' + s)));
|
||
assert.deepStrictEqual(h('status=bogus&role=root&id=7%3Bx&q=x'), { status: '', role: '', q: 'x', bouncing: false, id: '' });
|
||
assert.deepStrictEqual(h('id=abc'), { status: '', role: '', q: '', bouncing: false, id: '' });
|
||
assert.deepStrictEqual(h('status=disabled&role=user&id=12'), { status: 'disabled', role: 'user', q: '', bouncing: false, id: '12' });
|
||
});
|
||
|
||
const clickAct = (env, act, id) => env.els.umPage.handlers.click({ target: { closest: () => ({ getAttribute: (a) => ({ 'data-act': act, 'data-id': id })[a] }) } });
|
||
|
||
test('detail panel has a Close button; opening focuses the panel, closing returns focus', async () => {
|
||
const env = loadAdmin('#/admin?tab=users', (m, p) =>
|
||
OK(p.indexOf('/api/admin/users/7') === 0 ? { user: U({ id: 7 }), sessions: [], mail: [], audit: [] } : []));
|
||
assert(env.t.detailHtml({ user: U({ id: 7 }), sessions: [], mail: [], audit: [] }).indexOf('data-act="close"') !== -1);
|
||
env.um.mount(env.app);
|
||
await tick();
|
||
clickAct(env, 'detail', '7');
|
||
await tick();
|
||
assert.strictEqual(env.els.umDetail.hidden, false);
|
||
assert.strictEqual(env.els['umDetail h3'].focused, 1);
|
||
assert.strictEqual(env.loc.hash, '#/admin?tab=users&id=7');
|
||
clickAct(env, 'close', null);
|
||
assert.strictEqual(env.els.umDetail.hidden, true);
|
||
assert.strictEqual(env.loc.hash, '#/admin?tab=users');
|
||
assert.strictEqual(env.els['button[data-act="detail"][data-id="7"]'].focused, 1);
|
||
});
|
||
|
||
test('a deep-linked detail does not steal focus on load', async () => {
|
||
const env = loadAdmin('#/admin?tab=users&id=7', (m, p) =>
|
||
OK(p.indexOf('/api/admin/users/7') === 0 ? { user: U({ id: 7 }), sessions: [], mail: [], audit: [] } : []));
|
||
env.um.mount(env.app);
|
||
await tick();
|
||
assert.strictEqual(env.els.umDetail.hidden, false);
|
||
assert.strictEqual((env.els['umDetail h3'] || { focused: 0 }).focused, 0);
|
||
});
|
||
|
||
test('unmount drops a list response that arrives later', async () => {
|
||
let release;
|
||
const env = loadAdmin('#/admin?tab=users', () => new Promise((r) => { release = r; }));
|
||
env.um.mount(env.app);
|
||
env.um.unmount();
|
||
release(OK([U({ email: 'late@x.y' })]));
|
||
await tick();
|
||
assert.strictEqual(env.els.umBody.innerHTML, '');
|
||
});
|
||
|
||
test('a search typed just before unmount does nothing when its debounce fires', async () => {
|
||
const timers = [];
|
||
const debounce = (fn) => function () { timers.push(fn); };
|
||
const env = loadAdmin('#/admin?tab=users', () => OK([]), { debounce });
|
||
env.um.mount(env.app);
|
||
await tick();
|
||
const n = env.calls.length;
|
||
env.els.umQ.value = 'late';
|
||
env.els.umQ.handlers.input();
|
||
env.um.unmount();
|
||
const getEl = env.doc.getElementById;
|
||
env.doc.getElementById = (id) => (id === 'umQ' ? null : getEl(id));
|
||
assert.doesNotThrow(() => timers.forEach((fn) => fn()));
|
||
await tick();
|
||
assert.strictEqual(env.calls.length, n, 'no request after unmount');
|
||
assert.strictEqual(env.loc.hash, '#/admin?tab=users', 'hash untouched after unmount');
|
||
});
|
||
|
||
test('a search from a previous mount does nothing after a remount', async () => {
|
||
const timers = [];
|
||
const debounce = (fn) => function () { timers.push(fn); };
|
||
const env = loadAdmin('#/admin?tab=users', () => OK([]), { debounce });
|
||
env.um.mount(env.app);
|
||
await tick();
|
||
env.els.umQ.handlers.input();
|
||
const stale = timers.pop();
|
||
env.um.unmount();
|
||
env.um.mount(env.app);
|
||
await tick();
|
||
const n = env.calls.length;
|
||
env.els.umQ.value = 'old';
|
||
stale();
|
||
await tick();
|
||
assert.strictEqual(env.calls.length, n, 'stale debounce must not load');
|
||
});
|
||
|
||
console.log('perf.js reset');
|
||
|
||
function loadPerf() {
|
||
const ctx = { window: { addEventListener() {} }, document: { getElementById() { return null; }, addEventListener() {} },
|
||
console, Date, Math, Array, Object, String, Number, JSON, RegExp, Error, Promise, Map, Set,
|
||
parseInt, parseFloat, isNaN, isFinite, setTimeout() {}, clearTimeout() {}, setInterval() { return 0; }, clearInterval() {},
|
||
performance: { now: () => 0 }, registerPage() {} };
|
||
ctx.globalThis = ctx;
|
||
vm.createContext(ctx);
|
||
vm.runInContext(fs.readFileSync(path.join(ROOT, 'public/perf.js'), 'utf8'), ctx);
|
||
return ctx;
|
||
}
|
||
function fakeFetch(status) {
|
||
const calls = [];
|
||
const fn = (url, opts) => { calls.push({ url, opts }); return Promise.resolve({ ok: status < 400, status }); };
|
||
fn.calls = calls;
|
||
return fn;
|
||
}
|
||
|
||
test('feature off: reset is the old plain POST, no alert, local stats cleared', async () => {
|
||
const ctx = loadPerf();
|
||
const f = fakeFetch(401);
|
||
const alerts = [];
|
||
const ok = await ctx.resetPerfStats({ MC_USER_MGMT: null, CSAuth: { adminHeaders: () => ({}) } }, f, (m) => alerts.push(m));
|
||
assert.strictEqual(ok, true);
|
||
assert.strictEqual(alerts.length, 0);
|
||
assert.strictEqual(JSON.stringify(f.calls[0]), JSON.stringify({ url: '/api/perf/reset', opts: { method: 'POST' } }));
|
||
});
|
||
|
||
test('feature on: an admin session authorises the reset', async () => {
|
||
const ctx = loadPerf();
|
||
const f = fakeFetch(200);
|
||
const ok = await ctx.resetPerfStats({ MC_USER_MGMT: { enabled: true }, CSAuth: { adminHeaders: () => ({ 'X-CS-CSRF': 'c' }) } }, f, () => {});
|
||
assert.strictEqual(ok, true);
|
||
assert.strictEqual(f.calls[0].opts.headers['X-CS-CSRF'], 'c');
|
||
});
|
||
|
||
test('feature on: a refused reset alerts and keeps the stats', async () => {
|
||
const ctx = loadPerf();
|
||
const alerts = [];
|
||
const ok = await ctx.resetPerfStats({ MC_USER_MGMT: { enabled: true }, CSAuth: { adminHeaders: () => ({}) } }, fakeFetch(403), (m) => alerts.push(m));
|
||
assert.strictEqual(ok, false);
|
||
assert.deepStrictEqual(alerts, ['Reset failed: HTTP 403']);
|
||
});
|
||
|
||
test('profile has the Notifications section only with notifications on', () => {
|
||
const u = { email: 'a@example.org', role: 'user', displayName: 'A' };
|
||
assert.strictEqual(loadAccount('#/account', () => ({})).t.profileHtml(u).indexOf('notifySection'), -1);
|
||
const on = loadAccount('#/account', () => ({}), { CSNotify: { enabled: () => true } });
|
||
assert(on.t.profileHtml(u).indexOf('<h3 id="notifications">Notifications</h3><div id="notifySection"></div>') !== -1);
|
||
});
|
||
|
||
test('unsubscribe view posts the token only after the confirm click', async () => {
|
||
const msg = 'Notifications are off. Turn them back on from your account page.';
|
||
const env = loadAccount('#/account/unsubscribe?token=T%2B1', () => ({ ok: true, status: 200, data: { ok: true, message: msg } }));
|
||
env.t.views.unsubscribe({});
|
||
assert.strictEqual(env.loc.hash, '#/account/unsubscribe', 'token left in the address bar');
|
||
assert.strictEqual(env.calls.length, 0, 'posted before the click');
|
||
await env.els.unsubBtn.handlers.click();
|
||
assert.strictEqual(env.calls[0].method, 'POST');
|
||
assert.strictEqual(env.calls[0].p, '/api/notifications/unsubscribe?token=T%2B1');
|
||
assert.strictEqual(env.els.accountMsg.textContent, msg);
|
||
});
|
||
|
||
test('unsubscribe view: no token, and a dead token', async () => {
|
||
const none = loadAccount('#/account/unsubscribe', () => ({}));
|
||
none.t.views.unsubscribe({});
|
||
assert.strictEqual(none.els.unsubBtn.disabled, true);
|
||
assert(none.els.accountMsg.textContent.indexOf('incomplete') !== -1);
|
||
const dead = loadAccount('#/account/unsubscribe?token=x', () => ({ ok: false, status: 410, data: { error: 'this link is invalid or was already used' } }));
|
||
dead.t.views.unsubscribe({});
|
||
await dead.els.unsubBtn.handlers.click();
|
||
assert.strictEqual(dead.els.accountMsg.textContent, 'this link is invalid or was already used');
|
||
assert.strictEqual(dead.els.unsubBtn.disabled, false);
|
||
});
|
||
|
||
Promise.all(pending).then(() => {
|
||
console.log('\n' + passed + ' passed, ' + failed + ' failed');
|
||
process.exit(failed ? 1 : 0);
|
||
});
|