mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-10-15 02:59:55 +00:00
A small follow-up to parts A to E of #2128: users can download all data the instance holds about them, and the server backs up `users.db`. Stacked on #2140. Review the commits after that branch. ## The situation - An instance with accounts holds personal data, but a user has no way to get a copy (GDPR articles 15 and 20). Self-delete already exists. - `users.db` is the only file the server writes, and nothing backs it up: `GET /api/backup` snapshots the analyzer database only. ## What this PR adds **Data export.** `GET /api/account/export` and a "Download my data" button on the account page give one JSON file with: - the profile, including activation and a pending email change; - sessions (times and user agent); - synced settings, own proposals, notification prefs and watches; - audit rows where the user is actor or target, with other accounts as an id only; - the mail log. Password hash, session and link tokens and the unsubscribe token are left out: they are credentials. Each export is audited as `user.export`. **users.db backup** - Daily `VACUUM INTO` snapshots through `internal/users`, on by default when user management is on. They go to `backups/` next to `users.db`, the 7 newest are kept, the directory is 0700 and the files 0600. Rotation only touches `users-<timestamp>.db` files and never the snapshot it just wrote. Config: `userManagement.backup {enabled, dir, keep}`. - `GET /api/admin/users-backup`: an admin downloads a fresh snapshot to keep off the server; audited as `user.backup`. - `docs/user-guide/accounts.md` gains a restore procedure. It covers what a restore brings back (accounts deleted after the snapshot, old passwords, revoked sessions) and how to clean that up afterwards. Spec: [`docs/specs/2026-10-08-account-export-and-users-backup-design.md`](https://github.com/efiten/CoreScope/blob/feat/account-export-backup/docs/specs/2026-10-08-account-export-and-users-backup-design.md). ## Verification - `internal/users` with `-race` and the `cmd/server` suite pass locally, 30 new Go tests. The file-mode tests skip on Windows. - `sh test-all.sh` exits 0; the XSS gate in diff mode passes. - User-management E2E: 27 of 27 steps locally. - On our production instance since 8 October 2026; the first snapshot there was 143 kB. ## Not in this PR - Importing an export into another instance. - Off-site upload of the snapshots. The admin download covers that by hand. --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
446 lines
24 KiB
JavaScript
446 lines
24 KiB
JavaScript
/**
|
|
* 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}}
|
|
* 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 { chromium } = 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));
|
|
});
|
|
|
|
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);
|
|
})();
|