Files
meshcore-analyzer/tests/e2e/test-user-management-e2e.js
T
efitenandClaude Opus 5.5 e149568c6c feat(observer-logins): personal MQTT logins for observers (#2306)
Observers publish to the broker with one shared MQTT login. Anyone who
has that login can publish under any observer's topic, and the ingestor
takes the observer id from the topic (`cmd/ingestor/main.go:1009`), so
the data in the analyzer DB is only as trustworthy as the shared
password. This PR gives each user a personal MQTT login, generated and
mailed by CoreScope, and lets the broker ask CoreScope whether a login
may connect and publish.

Closes #2319.

The feature is optional and off by default
(`userManagement.observerLogins.enabled`). It needs
`userManagement.enabled`. With it off, nothing changes.

## Changes in this PR

**Broker hooks.** `POST /api/mqtt-hook/emqx/authn` and `/authz`, for the
EMQX HTTP authenticator and authorizer.
- They check a shared secret header, `X-CoreScope-Hook-Secret`, in
constant time.
- A username CoreScope does not know gets `ignore`, so EMQX falls
through to its built-in database and existing users keep working.
- A personal login may only publish `meshcore/<iata>/<pubkey>/<type>`,
with a 64-hex pubkey that is unbound or bound to the same user.
- The first publish for an unbound pubkey binds that observer to the
user (first come wins), until the user owns 20 observers (admin
assignments count too).
- An admin can rebind any observer.

**Credentials.**
- Username `obs_` + 12 random `[a-z0-9]`, password 32 random
`[A-Za-z0-9]`, both from `crypto/rand`.
- The password is stored twice: SHA-256 for the auth check, and sealed
with AES-256-GCM (key from config or `CORESCOPE_OBSERVER_KEY`) so the
user can ask for the same credentials again.
- The UI and the API never show the password. It leaves the server only
in the mail.

**Mail.** One mail with four formats:
- the meshcoretomqtt TOML;
- the observer firmware CLI commands (Gessaman or Dutch-MeshCore, same
`mqttN` CLI), on slot 4: slots 1-2 (Gessaman, Let's Mesh US/EU) and 1-3
(Dutch-MeshCore, `MQTTDefaults.h:19-34`) hold default broker presets on
a fresh install, slot 4 is empty on both. `set mqtt4.server` gets the
bare host: the firmware picks the scheme from the port
(`MQTTBridge.cpp:2094-2145`), and builds before merge 32dd8ad
(2026-10-19) do not accept a scheme there (read from the source, not
tested on a device). A scheme is written only when the port would pick
the wrong one, for example TLS on 8884;
- the Home Assistant meshcore-ha broker form fields;
- the plain values.

Each block starts with a numbered title and one line on where it goes.
The account page can resend the mail, at most 3 times per 24 h.

**Account and admin UI.**
- The account page gets an "Observer MQTT login" section
(`#/account?section=observers`) with the username, the bound observers
and the resend button.
- The admin page gets an "Observer logins" tab
(`#/admin?tab=observer-logins`) that lists logins with revoke and
reissue, and every observer with its owner, "not migrated" or
"orphaned", with a reassign control.

**Notifications.** Creating a login adds `observer.offline.own` ("My
observers go offline") to the owner's notification events; mails go out
once the user has notifications switched on, and the user can untick it.

**Storage.** users.db schema v7 adds `mqtt_logins` and
`observer_bindings`. The server still writes only users.db through
`internal/users`, and the analyzer DB stays read-only.

**Docs.** `docs/observer-mqtt-logins.md` covers the EMQX setup (authn
and authz chains, the hook body templates, the closing `^obs_` deny rule
and why it is needed), how to enable it on a running EMQX through the
REST API step by step, with a check and a rollback per step, and how to
migrate observers off a shared login. `config.example.json` has the
block with placeholder values.

## Enabling it on an instance

Full steps in `docs/observer-mqtt-logins.md`. In short:

1. **CoreScope:** add `userManagement.observerLogins` to `config.json`
with `enabled: true`, a `hookSecret` (`openssl rand -hex 32`), an
`encryptionKey` (`openssl rand -base64 32`) and the `broker` host, port,
transport and TLS that observers should use. Restart. Keep the key safe:
without it, mails cannot be resent and logins must be reissued.
2. **EMQX, authorization:** add a file source with `{deny, {username,
{re, "^obs_"}}, all, ["#"]}.` and move it to the end, then add the HTTP
authorizer pointing at `/api/mqtt-hook/emqx/authz` (it lands at the
front).
3. **EMQX, authentication:** add the HTTP authenticator pointing at
`/api/mqtt-hook/emqx/authn` and move it to the front, before the
built-in database.
4. **Check:** the shared login still connects, an unknown `obs_` login
is refused, and `metrics.deny` on the authorizer stays 0 for existing
clients.
5. **Owners:** they request a login on their account page and paste the
block for their uploader. Each observer binds to them on its first
publish. An uploader that was refused before step 3 may need a restart.
6. **Later:** remove the shared login from the broker once enough
observers moved.

## Who can claim an observer

Any active account can request a login, and its first publish under an
unbound pubkey binds that observer, also when CoreScope already knew it
from the shared login (first come wins, a deliberate choice: owners
migrate without waiting for an admin). Observer pubkeys are public, and
registration cannot be closed, so with this feature on anyone with a
mail address can claim up to 20 unbound observers per account. Before,
publishing needed the shared password.

What limits it: each claim of a known observer mails the admins, an
admin can unbind or reassign it and revoke the login, and a bound
observer cannot be claimed by another login. The doc spells this out and
suggests assigning known observers to their owners before announcing the
feature. A stricter mode (admin approval of logins, or no auto-bind for
known observers) is not in this PR.

## Performance

`authorize()` runs for every publish that misses the EMQX authz cache.
It takes two short read locks, does map lookups and one topic split.
`BenchmarkAuthorizeBound` (2000 observers, 500 logins): 533.5 ns/op, 96
B/op, 2 allocs/op. The admin observers table renders once per load and
updates single rows after a change.

## Verification

- All 16 Go modules: `bash scripts/allmod.sh test ./...` green.
- Skipped `TestSaveGeoFilterPreservesFileMode` and
`TestWriteStatsAtomic_SymlinkAtDestIsReplaced`: both fail on Windows on
a clean master too.
- `sh test-all.sh` green.
- `tests/e2e/test-user-management-e2e.js` 36/36 locally. It covers:
request, mail, resend, revoke, reissue, reassign, no password in the
page or API body, and axe. The CI job for this suite gets the
`observerLogins` block with fake values in `deploy.yml`.
- Local EMQX 5.8.6 in Docker against a local CoreScope:
- a personal login connects and publishes, which binds the observer; a
second personal login is denied on that observer's topic, and subscribe
is denied;
- with CoreScope stopped, the built-in users still connect and publish,
and new personal-login connects are refused;
- the `^obs_` deny rule blocks an already-connected personal login while
CoreScope is down; without the rule, the same publish was delivered.

- Production: running on an EMQX 5.8.9 broker with about 50 clients
since 2026-10-25, set up through the REST API as in the doc. After the
hooks went in, the authorizer answered `ignore` for every existing login
(0 denies, 0 dropped messages), and 4 observers were bound to their
owners through personal logins on the same evening, with the shared
login still in use by the rest.

## Known behaviour

EMQX's HTTP client can mark the hook resource down for one health-check
interval (15 s measured) when CoreScope closes an idle keep-alive
connection just as a health check runs. During that window new
personal-login connects are refused and the uploader reconnects. I saw
it 3 times in about 25 minutes of runs with a forced 1-5 s idle timeout,
and could not trigger it on demand. EMQX 5.8.6 has no setting for it,
and 5.10.5 ships the same client code. The doc describes it and the
alarm to watch for.

## Not in this PR

- mosquitto (mosquitto-go-auth). EMQX only.
- A websocket path field: the mail does not carry one, the doc tells
operators to match the listener path.
- Disabling the shared login. That is an operator step after migration,
described in the doc.
- Tested on EMQX 5.8.6 (Docker) and 5.8.9 (production) only.

Note on cb9f4ff3: its message says the scheme problem was reported by an
operator. That report turned out to come from before the broker used the
hook, so it does not show this cause; the change rests on the firmware
source only.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 09:50:20 +01:00

632 lines
36 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* E2E: optional user management (docs/specs/2026-10-06-user-management-design.md).
* BASE_URL server with userManagement on + fake mailer (-tags e2etest build)
* BASE_URL_OFF the regular fixture server (feature off)
*
* Local run (never point the servers at the tracked fixture; migrate a copy):
* cp test-fixtures/e2e-fixture.db "$TMP/on.db"; cp test-fixtures/e2e-fixture.db "$TMP/off.db"
* corescope-migrate -db "$TMP/on.db"; corescope-migrate -db "$TMP/off.db"
* (cd cmd/server && go build -o ../../corescope-server . && go build -tags e2etest -o ../../corescope-server-e2e .)
* # config.json for the on-server (in $CFGDIR): port 13582, userManagement {enabled: true,
* # dbPath "users.db", adminEmails ["admin@e2e.test"], publicBaseUrl "http://localhost:13582",
* # mail {provider "fake", fromEmail "noreply@e2e.test"}, channelProposals {enabled: true}, notifications {enabled: true},
* # observerLogins {enabled: true, hookSecret "e2e-hook-secret-0123", encryptionKey "BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc=",
* # broker {host "mqtt.e2e.test"}}}
* corescope-server -port 13581 -db "$TMP/off.db" -public public &
* (cd "$CFGDIR" && corescope-server-e2e -config-dir . -port 13582 -db "$TMP/on.db" -public <repo>/public) &
* BASE_URL=http://localhost:13582 BASE_URL_OFF=http://localhost:13581 node tests/e2e/test-user-management-e2e.js
* Set CHROMIUM_PATH to a Chrome/Chromium binary if Playwright's own is not installed.
*/
'use strict';
const crypto = require('crypto');
const { chromium, request } = require('playwright');
const { AxeBuilder } = require('@axe-core/playwright');
const BASE = process.env.BASE_URL || 'http://localhost:13582';
const BASE_OFF = process.env.BASE_URL_OFF || 'http://localhost:13581';
const PW = 'correct horse battery';
let passed = 0, failed = 0;
async function step(name, fn) {
try { await fn(); passed++; console.log(' ✓ ' + name); }
catch (e) { failed++; console.error(' ✗ ' + name + ': ' + e.message); }
}
function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); }
// Resolves once auth.js has finished its first /api/auth/me round trip.
async function authReady(page) {
await page.waitForFunction(() => !!window.CSAuth);
await page.evaluate(() => window.CSAuth.ready());
}
// Resolves once the full node page has rendered its paths section, which
// needs a network round trip started after CSNotify.mount ran: a positive
// signal that the page settled before asserting that something is absent.
async function nodePageRendered(page) {
await page.waitForFunction(() => {
const el = document.getElementById('fullPathsContent');
return !!el && !el.querySelector('.spinner');
});
}
async function lastMailToken(page) {
const r = await page.request.get(BASE + '/__e2e/last-mail');
assert(r.ok(), 'last-mail HTTP ' + r.status());
const m = await r.json();
const sm = /token=([A-Za-z0-9_%-]+)/.exec(m.text);
assert(sm, 'no token in mail text: ' + m.text);
return { to: m.to, token: decodeURIComponent(sm[1]) };
}
async function registerAndActivate(page, email, name) {
await page.goto(BASE + '/#/account/register', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#registerForm');
await page.fill('#regEmail', email);
await page.fill('#regName', name);
await page.fill('#regPassword', PW);
await page.click('#registerForm button[type="submit"]');
await page.waitForSelector('#mailSentHeading');
const { to, token } = await lastMailToken(page);
assert(to === email, 'activation mail went to ' + to);
await page.goto(BASE + '/#/account/activate?token=' + encodeURIComponent(token));
await page.waitForSelector('#activateForm');
await page.fill('#actPassword', PW);
await page.click('#activateForm button[type="submit"]');
await page.waitForSelector('#accountToggle .nav-account-label:has-text("' + name + '")');
}
// axeClean fails on serious or critical WCAG 2 A/AA violations inside sel.
async function axeClean(pg, sel) {
// A finite animation in flight (the 150 ms .page-enter fade) blends colours
// and fails color-contrast at random; wait until those have finished.
await pg.waitForFunction(() => document.getAnimations().every((a) =>
a.playState !== 'running' || !a.effect || a.effect.getComputedTiming().endTime === Infinity));
const res = await new AxeBuilder({ page: pg }).include(sel).withTags(['wcag2a', 'wcag2aa']).analyze();
const bad = res.violations.filter((v) => v.impact === 'serious' || v.impact === 'critical');
assert(bad.length === 0, sel + ': ' + bad.map((v) => v.id + ' ' + v.nodes.map((n) => n.target.join(' ') + ' ' + ((n.any[0] || {}).message || '')).join(' | ')).join(', '));
}
// The account's synced keys, read through the page's own session.
async function accountKeys(pg) {
return pg.evaluate(() => window.CSAuth.request('GET', '/api/account/settings').then((r) => (r.data.doc && r.data.doc.keys) || {}));
}
async function until(fn, label) {
const end = Date.now() + 8000;
for (;;) {
if (await fn()) return;
if (Date.now() > end) throw new Error('timed out: ' + label);
await new Promise((r) => setTimeout(r, 200));
}
}
(async () => {
const browser = await chromium.launch({
headless: true,
executablePath: process.env.CHROMIUM_PATH || undefined,
args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],
});
console.log(`\n=== user management E2E against ${BASE} (off: ${BASE_OFF}) ===`);
const off = await (await browser.newContext()).newPage();
off.setDefaultTimeout(8000);
await step('feature off: no account control and no auth API', async () => {
await off.goto(BASE_OFF + '/', { waitUntil: 'domcontentloaded' });
await authReady(off);
assert(await off.locator('#accountToggle').count() === 0, 'account control rendered while off');
assert(await off.evaluate(() => !window.CSAuth.isEnabled()), 'CSAuth enabled while off');
// Unknown /api paths fall through to the SPA page (200 HTML): only JSON with a csrfToken would be a leak.
const r = await off.request.get(BASE_OFF + '/api/auth/me');
let body = null;
try { body = await r.json(); } catch (_) { /* HTML, expected */ }
assert(!(body && body.csrfToken), '/api/auth/me answered a session while off');
});
const admin = await (await browser.newContext()).newPage();
admin.setDefaultTimeout(8000);
admin.on('dialog', (d) => d.accept());
admin.on('pageerror', (e) => console.error('[pageerror admin]', e.message));
await step('config admin registers, activates with a password, sees the Admin entry', async () => {
await registerAndActivate(admin, 'admin@e2e.test', 'E2E Admin');
await admin.click('#accountToggle');
assert(await admin.locator('#accountMenu a[href="#/admin"]').isVisible(), 'no Admin menu entry');
});
const user = await (await browser.newContext()).newPage();
user.setDefaultTimeout(8000);
user.on('pageerror', (e) => console.error('[pageerror user]', e.message));
await step('second user registers and activates; no Admin entry for a non-admin', async () => {
await registerAndActivate(user, 'user@e2e.test', 'E2E User');
await user.click('#accountToggle');
assert(await user.locator('#accountMenu').isVisible(), 'account menu did not open');
assert(await user.locator('#accountMenu a[href="#/admin"]').count() === 0, 'non-admin sees Admin');
});
await step('user logs out from the account page (phone width) and logs in again', async () => {
// At phone width the header control is hidden: the page button is the only way out.
await user.setViewportSize({ width: 375, height: 800 });
await user.goto(BASE + '/#/account');
await user.waitForSelector('#profileForm');
await user.click('#accountPageLogout');
// Settings sync is active for a logged-in user: logout asks keep or remove.
await user.click('.cs-dialog [data-choice="keep"]');
await user.waitForSelector('#loginForm');
assert(await user.evaluate(() => location.hash) === '#/account/login', 'not on the login view after logout');
assert(await user.evaluate(() => window.CS_USER === null), 'client still holds the user');
await user.setViewportSize({ width: 1280, height: 720 });
await user.waitForSelector('#accountToggle .nav-account-label:has-text("Log in")');
await user.fill('#loginEmail', 'user@e2e.test');
await user.fill('#loginPassword', PW);
await user.click('#loginForm button[type="submit"]');
await user.waitForSelector('#profileForm');
await user.waitForSelector('#accountToggle .nav-account-label:has-text("E2E User")');
});
// Settings sync: two browser contexts are two devices on one account.
const SYNC_FAV = 'e2e5e7c0000000000000000000000000000000000000000000000000000000a1';
const d1 = await (await browser.newContext()).newPage();
const d2 = await (await browser.newContext()).newPage();
for (const [pg, tag] of [[d1, 'd1'], [d2, 'd2']]) {
pg.setDefaultTimeout(8000);
pg.on('pageerror', (e) => console.error('[pageerror ' + tag + ']', e.message));
}
await step('settings sync: device 1 saves a packet time window and a favorite to the account', async () => {
await registerAndActivate(d1, 'sync@e2e.test', 'E2E Sync');
await d1.goto(BASE + '/#/packets');
await d1.waitForSelector('#fTimeWindow');
await d1.selectOption('#fTimeWindow', '180');
// No favorite star without node rows in view: write the key as nodes.js does.
await d1.evaluate((pk) => localStorage.setItem('meshcore-favorites', JSON.stringify([pk])), SYNC_FAV);
await until(async () => {
const k = await accountKeys(d1);
return k['meshcore-time-window'] === '180' && (k['meshcore-favorites'] || '').includes(SYNC_FAV);
}, 'account holds the time window and the favorite');
});
await step('settings sync: device 2 logs in and gets both; its channel key stays local', async () => {
await d2.goto(BASE + '/#/account/login', { waitUntil: 'domcontentloaded' });
await d2.waitForSelector('#loginForm');
await d2.evaluate(() => localStorage.setItem('corescope_channel_keys', JSON.stringify({ '#e2e': '00112233445566778899aabbccddeeff' })));
await d2.fill('#loginEmail', 'sync@e2e.test');
await d2.fill('#loginPassword', PW);
await d2.click('#loginForm button[type="submit"]');
await d2.waitForSelector('#profileForm');
await d2.waitForFunction((pk) => (localStorage.getItem('meshcore-favorites') || '').includes(pk) &&
localStorage.getItem('meshcore-time-window') === '180', SYNC_FAV);
// Check after a full round trip from device 2 (pull, merge, push), not
// just after its first pull.
await d2.evaluate(() => window.CSSettingsSync.syncNow());
const k = await accountKeys(d2);
assert(!('corescope_channel_keys' in k), 'channel key reached the account');
});
await step('settings sync: a favorite removed on device 1 is gone on device 2', async () => {
await d1.evaluate(() => localStorage.setItem('meshcore-favorites', '[]'));
await until(async () => !((await accountKeys(d1))['meshcore-favorites'] || '').includes(SYNC_FAV), 'removal reached the account');
await d2.evaluate(() => window.CSSettingsSync.syncNow());
await d2.waitForFunction((pk) => !(localStorage.getItem('meshcore-favorites') || '').includes(pk), SYNC_FAV);
});
await step('settings sync: axe on the section and the logout dialog; Remove keeps the channel key', async () => {
await d2.waitForSelector('#syncStatus');
await axeClean(d2, '#syncSection');
await d2.click('#accountPageLogout');
await d2.waitForSelector('.cs-dialog');
assert(await d2.evaluate(() => document.activeElement && document.activeElement.getAttribute('data-choice') === 'keep'), 'Keep is not focused');
await axeClean(d2, '.cs-dialog');
await d2.click('.cs-dialog [data-choice="remove"]');
await d2.waitForSelector('#loginForm');
const left = await d2.evaluate(() => ({
fav: localStorage.getItem('meshcore-favorites'), tw: localStorage.getItem('meshcore-time-window'),
base: localStorage.getItem('cs-settings-sync-base'), ch: localStorage.getItem('corescope_channel_keys'),
}));
assert(left.fav === null && left.tw === null && left.base === null, 'synced keys left: ' + JSON.stringify(left));
assert(left.ch && left.ch.includes('#e2e'), 'channel key removed');
});
await step('admin disables the user; the live session is logged out without a reload', async () => {
await admin.goto(BASE + '/#/admin/users');
const row = admin.locator('tr[data-email="user@e2e.test"]');
await row.waitFor();
await row.locator('button[data-act="disable"]').click();
await admin.waitForSelector('tr[data-email="user@e2e.test"] .um-status-disabled');
// No reload: leave and re-enter the account page; its sessions call answers 401.
await user.goto(BASE + '/#/home');
await user.goto(BASE + '/#/account');
// The 60 s settings pull may log the user out first, so assert the end state only.
await user.waitForSelector('#accountToggle .nav-account-label:has-text("Log in")');
assert(await user.evaluate(() => window.CS_USER === null), 'CS_USER is not null');
});
await step('admin overview: user figures and a failed-login burst under Needs attention, followed to the audit tab', async () => {
// Five wrong passwords for an existing account (user@e2e.test: 1 earlier login + 5 stays under the 10-per-address limit).
const guess = await (await browser.newContext()).newPage();
guess.setDefaultTimeout(8000);
await guess.goto(BASE + '/#/home', { waitUntil: 'domcontentloaded' });
await authReady(guess);
const codes = await guess.evaluate(async () => {
const out = [];
for (let i = 0; i < 5; i++) {
const r = await fetch('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: 'user@e2e.test', password: 'not the password' }) });
out.push(r.status);
}
return out;
});
assert(codes.every((c) => c === 401), 'failed logins answered ' + codes.join(','));
await guess.context().close();
await admin.goto(BASE + '/#/admin');
await admin.waitForSelector('#aoUsers [data-stat="total"]');
// admin, user and sync are the three accounts at this point.
assert((await admin.textContent('#aoUsers [data-stat="total"]')) === '3', 'total accounts');
const link = admin.locator('#adminAttention a[href*="action=user.login.failed"]');
await link.waitFor();
await link.click();
await admin.waitForSelector('#auditBody tr[data-action="user.login.failed"]');
assert((await admin.evaluate(() => location.hash)).indexOf('tab=audit') !== -1, 'not on the audit tab');
});
await step('the old #/admin/users link lands on the Users tab with its filter', async () => {
await admin.goto(BASE + '/#/admin/users?status=disabled');
await admin.waitForSelector('tr[data-email="user@e2e.test"]');
assert((await admin.evaluate(() => location.hash)) === '#/admin?tab=users&status=disabled', 'hash not rewritten');
});
await step('axe: no serious or critical violations on the three admin tabs', async () => {
for (const [route, sel] of [['/#/admin', '#aoUsers [data-stat="total"]'], ['/#/admin?tab=users', '.um-table'], ['/#/admin?tab=audit', '#auditBody tr']]) {
await admin.goto(BASE + route);
await admin.waitForSelector(sel);
await admin.mouse.move(0, 0); // parked to avoid hover noise; status text on the hover tint is AA (contrast unit test)
await admin.waitForTimeout(1500);
await axeClean(admin, '#app');
}
});
await step('axe: no serious or critical violations on the new views', async () => {
for (const [pg, route, sel] of [[user, '/#/account/login', '#loginForm'], [admin, '/#/admin/users', '.um-table']]) {
await pg.goto(BASE + route);
await pg.waitForSelector(sel);
await authReady(pg);
await pg.mouse.move(0, 0);
await pg.waitForTimeout(1500);
await axeClean(pg, '#app');
}
});
const proposer = await (await browser.newContext()).newPage();
proposer.setDefaultTimeout(8000);
proposer.on('pageerror', (e) => console.error('[pageerror proposer]', e.message));
await step('proposals: no Propose button in the Add Channel dialog while logged out', async () => {
await proposer.goto(BASE + '/#/channels', { waitUntil: 'domcontentloaded' });
await authReady(proposer);
await proposer.click('#chAddChannelBtn');
await proposer.waitForSelector('#chHashtagName');
assert(await proposer.locator('#chHashtagProposeBtn').isHidden(), 'Propose button visible while logged out');
});
await step('proposals: a user proposes #e2e-test from the Add Channel dialog', async () => {
await registerAndActivate(proposer, 'proposer@e2e.test', 'E2E Proposer');
await proposer.goto(BASE + '/#/channels');
await proposer.click('#chAddChannelBtn');
await proposer.waitForSelector('#chHashtagProposeBtn:not([hidden])');
await proposer.fill('#chHashtagName', 'public');
await until(async () => (await proposer.textContent('#chHashtagProposeMsg')).indexOf('built-in') !== -1, 'live validation');
await proposer.fill('#chHashtagName', 'e2e-test');
await proposer.click('#chHashtagProposeBtn');
await until(async () => (await proposer.textContent('#chHashtagProposeMsg')) === 'Proposed #e2e-test, an admin will review it.', 'propose result');
});
await step('proposals: My proposals on the account page shows it waiting', async () => {
await proposer.goto(BASE + '/#/account');
await proposer.waitForSelector('#propList li[data-subject="#e2e-test"]');
assert((await proposer.textContent('#propList')).indexOf('Waiting for review') !== -1, 'status not shown');
});
await step('proposals: the admin approves it on the Proposals tab after the warning', async () => {
const warnings = [];
const onDialog = (d) => warnings.push(d.message());
admin.on('dialog', onDialog);
try {
await admin.goto(BASE + '/#/admin?tab=proposals&status=pending');
await admin.waitForSelector('#propAdminBody tr[data-subject="#e2e-test"]');
await admin.click('#propAdminBody tr[data-subject="#e2e-test"] button[data-act="approve"]');
await until(async () => (await admin.textContent('#propAdminMsg')) === '#e2e-test: approved', 'approve result');
} finally {
admin.off('dialog', onDialog);
}
assert(warnings.some((m) => /every visitor/.test(m)), 'no readability warning: ' + warnings.join(' | '));
});
await step('proposals: the approved channel, without traffic, is listed on the Channels page with its marker', async () => {
const body = await (await proposer.request.get(BASE + '/api/channels')).json();
assert(Array.isArray(body.approvedChannels) && body.approvedChannels.indexOf('#e2e-test') !== -1, JSON.stringify(body.approvedChannels));
assert(!(body.channels || []).some((c) => c && c.name === '#e2e-test'), '#e2e-test has traffic in the fixture');
await proposer.goto(BASE + '/#/channels');
// The client caches /api/channels for 15 s: reload to fetch the list after the approval.
await proposer.reload();
await proposer.waitForSelector('#chList .ch-item[data-hash="#e2e-test"] .ch-approved-badge');
});
await step('proposals: revoke removes it from the approved list', async () => {
await admin.goto(BASE + '/#/admin?tab=proposals&status=approved');
await admin.waitForSelector('#propAdminBody tr[data-subject="#e2e-test"]');
await admin.click('#propAdminBody tr[data-subject="#e2e-test"] button[data-act="revoke"]');
await until(async () => (await admin.textContent('#propAdminMsg')) === '#e2e-test: revoked', 'revoke result');
const body = await (await admin.request.get(BASE + '/api/channels')).json();
assert(Array.isArray(body.approvedChannels) && body.approvedChannels.indexOf('#e2e-test') === -1, JSON.stringify(body.approvedChannels));
});
await step('axe: no serious or critical violations on the Proposals tab', async () => {
await admin.goto(BASE + '/#/admin?tab=proposals&status=all');
await admin.waitForSelector('#propAdminBody tr[data-subject="#e2e-test"]');
await admin.mouse.move(0, 0);
await admin.waitForTimeout(1500);
await axeClean(admin, '#app');
});
await step('feature off: /api/channels carries no approvedChannels', async () => {
const body = await (await off.request.get(BASE_OFF + '/api/channels')).json();
assert(!('approvedChannels' in body), 'approvedChannels present while off');
});
let watchKey = null;
await step('notifications: no Notify me toggle on a node page while logged out', async () => {
const body = await (await proposer.request.get(BASE + '/api/nodes?limit=1')).json();
watchKey = body.nodes[0].public_key;
const anon = await (await browser.newContext()).newPage();
anon.setDefaultTimeout(8000);
await anon.goto(BASE + '/#/nodes/' + encodeURIComponent(watchKey));
await anon.waitForSelector('#nodeNotifySlotFull', { state: 'attached' });
await authReady(anon);
await nodePageRendered(anon);
assert((await anon.locator('[data-notify-toggle]').count()) === 0, 'toggle shown while logged out');
await anon.context().close();
});
await step('notifications: a user turns on Notify me on a node page', async () => {
await proposer.goto(BASE + '/#/nodes/' + encodeURIComponent(watchKey));
await proposer.waitForSelector('#nodeNotifySlotFull [data-notify-toggle][aria-pressed="false"]');
await proposer.click('#nodeNotifySlotFull [data-notify-toggle]');
await proposer.waitForSelector('#nodeNotifySlotFull [data-notify-toggle][aria-pressed="true"]');
});
await step('notifications: the #/account?section=notifications deep link shows the watched node', async () => {
await proposer.goto(BASE + '/#/account?section=notifications');
await proposer.waitForSelector('#notifySection li[data-pubkey="' + watchKey + '"]');
assert(await proposer.isChecked('#notifyEnabled'), 'notifications are not on by default');
await until(() => proposer.evaluate(() => {
const r = document.getElementById('notifications').getBoundingClientRect();
return r.top >= -1 && r.top < window.innerHeight;
}), 'deep link scrolls to the Notifications heading');
await axeClean(proposer, '#notifySection');
});
await step('notifications: the unsubscribe link turns notifications off', async () => {
const r = await proposer.request.get(BASE + '/__e2e/unsubscribe-link?email=' + encodeURIComponent('proposer@e2e.test'));
assert(r.ok(), 'unsubscribe-link HTTP ' + r.status());
const { link } = await r.json();
const anon = await (await browser.newContext()).newPage();
anon.setDefaultTimeout(8000);
await anon.goto(link);
await anon.waitForSelector('#unsubBtn:not([disabled])');
await axeClean(anon, '#app');
await anon.click('#unsubBtn');
await until(async () => (await anon.textContent('#accountMsg')).indexOf('Notifications are off') !== -1, 'unsubscribe result');
await anon.context().close();
await proposer.goto(BASE + '/#/account?section=notifications');
await proposer.reload();
await proposer.waitForSelector('#notifyEnabled');
assert(!(await proposer.isChecked('#notifyEnabled')), 'still on after the unsubscribe link');
});
await step('data export: the account page links to the export, which returns the user\'s own data', async () => {
await proposer.goto(BASE + '/#/account');
await proposer.waitForSelector('#accountExport');
assert(await proposer.getAttribute('#accountExport', 'href') === '/api/account/export', 'export link href');
const r = await proposer.request.get(BASE + '/api/account/export');
assert(r.ok(), 'export HTTP ' + r.status());
const cd = r.headers()['content-disposition'] || '';
assert(/^attachment; filename="corescope-account-\d{4}-\d{2}-\d{2}\.json"$/.test(cd), 'content-disposition ' + cd);
const body = await r.json();
assert(body.formatVersion === 1, 'formatVersion ' + body.formatVersion);
assert(body.profile && body.profile.email === 'proposer@e2e.test', 'export profile ' + JSON.stringify(body.profile));
});
// Companion linking (docs/specs/2026-10-08-companion-linking-design.md).
const driver = await (await browser.newContext()).newPage();
driver.setDefaultTimeout(8000);
driver.on('pageerror', (e) => console.error('[pageerror driver]', e.message));
await step('companions: a device token shows under Devices, a linked companion under Companions, and unlinks', async () => {
await registerAndActivate(driver, 'driver@e2e.test', 'E2E Driver');
// RX calls the API without the browser's cookies (credentials: 'omit'):
// with the session cookie the bearer header would not count.
const api = await request.newContext();
let r = await api.post(BASE + '/api/auth/device-token', { data: { email: 'driver@e2e.test', password: PW, deviceName: 'E2E Pixel' } });
assert(r.ok(), 'device-token HTTP ' + r.status());
const auth = { Authorization: 'Bearer ' + (await r.json()).token };
const { privateKey, publicKey } = crypto.generateKeyPairSync('ed25519');
const pk = publicKey.export({ format: 'der', type: 'spki' }).subarray(-32).toString('hex');
r = await api.post(BASE + '/api/account/companions/challenge', { headers: auth, data: { pubkey: pk } });
assert(r.ok(), 'challenge HTTP ' + r.status());
const { challenge } = await r.json();
const signed = Buffer.from('corescope-link:' + new URL(BASE).host + ':' + challenge, 'utf8');
const signature = crypto.sign(null, signed, privateKey).toString('hex');
r = await api.post(BASE + '/api/account/companions', { headers: auth, data: { pubkey: pk, challenge, signature, name: 'E2E Car' } });
assert(r.ok(), 'link HTTP ' + r.status() + ': ' + (await r.text()));
await driver.goto(BASE + '/#/account');
await driver.reload();
await driver.waitForSelector('#sessList li[data-kind="device"]');
assert((await driver.textContent('#sessList')).indexOf('CoreDrive RX – E2E Pixel') !== -1, 'device token not listed as CoreDrive RX');
const unlink = '#compList [data-unlink="' + pk + '"]';
await driver.waitForSelector(unlink);
assert((await driver.textContent('#compList')).indexOf('E2E Car') !== -1, 'companion name missing');
await axeClean(driver, '#compList');
driver.once('dialog', (d) => d.accept());
await driver.click(unlink);
await driver.waitForSelector('#compEmpty');
assert((await driver.textContent('#compMsg')).indexOf('Companion unlinked') !== -1, 'no unlink message');
r = await api.get(BASE + '/api/account/companions', { headers: auth });
assert(r.ok() && (await r.json()).length === 0, 'companion still linked after Unlink');
await api.dispose();
});
await driver.context().close();
// Observer MQTT logins (docs/superpowers/specs/2026-10-25-observer-mqtt-logins-design.md).
const OBS_EMAIL = 'observer@e2e.test';
const owner = await (await browser.newContext()).newPage();
owner.setDefaultTimeout(8000);
owner.on('pageerror', (e) => console.error('[pageerror owner]', e.message));
const obs = { username: null, password: null, pubkey: null, name: null };
// The newest fake mail, once it satisfies ok.
async function mailWhere(pg, ok, label) {
let m = null;
await until(async () => {
const r = await pg.request.get(BASE + '/__e2e/last-mail');
m = r.ok() ? await r.json() : null;
return !!m && ok(m);
}, label);
return m;
}
async function credentialsMail(pg, username) {
const m = await mailWhere(pg, (x) => x.to === OBS_EMAIL && /Password: [A-Za-z0-9]{32}/.test(x.text) &&
(!username || x.text.indexOf('Username: ' + username) !== -1), 'credentials mail');
return { username: /Username: (obs_[a-z0-9]{12})/.exec(m.text)[1], password: /Password: ([A-Za-z0-9]{32})/.exec(m.text)[1], text: m.text };
}
async function noPasswordShown(pg, password) {
const html = await pg.evaluate(() => document.documentElement.outerHTML);
assert(html.indexOf(password) === -1, 'the password is on the page');
}
await step('observer logins: the account section offers a login mailed to the account address', async () => {
await registerAndActivate(owner, OBS_EMAIL, 'E2E Observer');
await owner.goto(BASE + '/#/account?section=observers');
await owner.waitForSelector('#obsLoginRequest');
const text = await owner.textContent('#obsLoginSection');
assert(text.indexOf('Request a personal login for your observers. The credentials are sent to ' + OBS_EMAIL + '.') !== -1, text);
assert((await owner.textContent('#obsLoginRequest')) === 'Request observer login', 'button label');
await until(() => owner.evaluate(() => {
const r = document.getElementById('observers').getBoundingClientRect();
return r.top >= -1 && r.top < window.innerHeight;
}), 'section=observers scrolls to the heading');
await axeClean(owner, '#obsLoginSection');
});
await step('observer logins: request mails obs_ credentials; the page shows the username, never the password', async () => {
await owner.click('#obsLoginRequest');
await owner.waitForSelector('#obsLoginUser');
const c = await credentialsMail(owner);
obs.username = c.username;
obs.password = c.password;
assert(c.text.indexOf('obs_') !== -1, 'no obs_ in the mail');
assert(c.text.indexOf('#/account?section=observers') !== -1, 'mail does not link to the section');
assert((await owner.textContent('#obsLoginUser')) === obs.username, 'username not shown');
assert((await owner.textContent('#obsLoginMsg')).indexOf('Check your mailbox') !== -1, 'no sent message');
await noPasswordShown(owner, obs.password);
const api = await (await owner.request.get(BASE + '/api/account/observer-login')).text();
assert(api.indexOf(obs.password) === -1, 'the API returned the password');
});
await step('observer logins: an observer publishing with the login is listed as bound', async () => {
const list = await (await owner.request.get(BASE + '/api/observers')).json();
const o = list.observers.find((x) => /^[0-9A-Fa-f]{64}$/.test(x.id));
assert(o, 'no observer with a 64-hex id in the fixture');
obs.pubkey = o.id.toUpperCase();
obs.name = o.name;
const r = await owner.request.post(BASE + '/api/mqtt-hook/emqx/authz', {
headers: { 'X-CoreScope-Hook-Secret': 'e2e-hook-secret-0123' },
data: { username: obs.username, topic: 'meshcore/TST/' + obs.pubkey + '/status', action: 'publish' },
});
assert(r.ok() && (await r.json()).result === 'allow', 'authz did not allow the publish');
// The fixture already knows this observer, so the admins get a claim notice.
await mailWhere(owner, (m) => m.to === 'admin@e2e.test' && /existing observer was claimed/.test(m.subject), 'admin claim notice');
await owner.reload();
await owner.waitForSelector('#obsList li[data-pubkey="' + obs.pubkey + '"]');
const text = await owner.textContent('#obsList');
assert(text.indexOf(obs.pubkey.slice(0, 12)) !== -1 && text.indexOf(obs.name) !== -1, text);
});
await step('observer logins: Mail my credentials again sends the same login', async () => {
await owner.click('#obsLoginResend');
await until(async () => (await owner.textContent('#obsLoginMsg')).indexOf('Check your mailbox') !== -1, 'resend message');
const c = await credentialsMail(owner, obs.username);
assert(c.password === obs.password, 'resend mailed another password');
await noPasswordShown(owner, obs.password);
});
await step('observer logins: the admin tab lists the login and the observer with its owner', async () => {
await admin.goto(BASE + '/#/admin?tab=observer-logins');
const row = admin.locator('#obsAdminLogins tr[data-username="' + obs.username + '"]');
await row.waitFor();
const t = await row.textContent();
assert(t.indexOf('E2E Observer') !== -1 && t.indexOf(OBS_EMAIL) !== -1, t);
const orow = admin.locator('#obsAdminObservers tr[data-pubkey="' + obs.pubkey + '"]');
await orow.waitFor();
assert((await orow.locator('td').nth(2).textContent()) === 'E2E Observer', 'bound observer does not show its owner');
assert(await orow.locator('select').count() === 0, 'a select rendered before Reassign');
assert((await admin.textContent('#obsAdminObservers')).indexOf('not migrated') !== -1, 'unbound observers not marked');
await noPasswordShown(admin, obs.password);
await admin.mouse.move(0, 0);
await admin.waitForTimeout(1500);
await axeClean(admin, '#app');
});
await step('observer logins: Reassign unbinds the observer and binds it back, row by row', async () => {
const orow = '#obsAdminObservers tr[data-pubkey="' + obs.pubkey + '"]';
await admin.click(orow + ' button[data-reassign]');
await admin.waitForSelector(orow + ' select:focus');
await axeClean(admin, orow);
await admin.selectOption(orow + ' select', '');
await admin.waitForSelector(orow + ' .um-status-pending');
assert((await admin.textContent('#obsAdminMsg')).indexOf('unbound') !== -1, 'no unbound message');
let api = await (await admin.request.get(BASE + '/api/admin/observer-logins')).json();
assert(!api.bindings.some((b) => b.pubkey === obs.pubkey), 'binding still present after unbinding');
const uid = api.logins.find((l) => l.username === obs.username).userId;
await admin.click(orow + ' button[data-reassign]');
await admin.selectOption(orow + ' select', String(uid));
await until(async () => (await admin.textContent(orow + ' td:nth-child(3)')) === 'E2E Observer', 'owner back on the row');
api = await (await admin.request.get(BASE + '/api/admin/observer-logins')).json();
assert(api.bindings.some((b) => b.pubkey === obs.pubkey && b.userId === uid && b.boundBy === 'admin'), JSON.stringify(api.bindings));
});
await step('observer logins: revoke asks inline, then the account page says revoked', async () => {
const row = '#obsAdminLogins tr[data-username="' + obs.username + '"]';
await admin.click(row + ' button[data-act="revoke"]');
await admin.click(row + ' button[data-act="revoke-confirm"]');
await until(async () => (await admin.textContent('#obsAdminMsg')).indexOf('revoked') !== -1, 'revoke message');
await admin.waitForSelector(row + ' .um-status-disabled');
await owner.reload();
await owner.waitForSelector('#obsLoginRevoked');
assert((await owner.textContent('#obsLoginRevoked')) === 'Your observer login was revoked. Contact the administrator.', 'revoked text');
assert(await owner.locator('#obsLoginResend').count() === 0, 'resend offered while revoked');
});
await step('observer logins: reissue mails a new login and the account page shows it again', async () => {
const row = '#obsAdminLogins tr[data-username="' + obs.username + '"]';
await admin.click(row + ' button[data-act="reissue"]');
await admin.click(row + ' button[data-act="reissue-confirm"]');
const c = await mailWhere(admin, (m) => m.to === OBS_EMAIL && m.text.indexOf('Username: ' + obs.username) === -1 && /Username: obs_/.test(m.text), 'reissue mail');
const fresh = /Username: (obs_[a-z0-9]{12})/.exec(c.text)[1];
await admin.waitForSelector('#obsAdminLogins tr[data-username="' + fresh + '"]');
await owner.reload();
await owner.waitForSelector('#obsLoginUser');
assert((await owner.textContent('#obsLoginUser')) === fresh, 'new username not shown');
});
await owner.context().close();
await step('feature off: no userManagement block, so no notifications flag, and no toggle on the node page', async () => {
const body = await (await off.request.get(BASE_OFF + '/api/config/client')).json();
assert(!body.userManagement, 'userManagement present while off');
await off.goto(BASE_OFF + '/#/nodes/' + encodeURIComponent(watchKey));
await off.waitForSelector('#nodeNotifySlotFull', { state: 'attached' });
await authReady(off);
await nodePageRendered(off);
assert((await off.locator('[data-notify-toggle]').count()) === 0, 'toggle shown while the feature is off');
});
await browser.close();
console.log('\n' + passed + '/' + (passed + failed) + ' tests passed');
process.exit(failed > 0 ? 1 : 0);
})();