users: admin CRUD API and admin shell UI
Login, forced password change, admin layout with every 05 §2 page in the sidebar (unbuilt ones name their milestone), Users table/cards + drawer (General tab), one-time temporary passwords, reset password. The last active admin can't be demoted, deactivated or deleted. Audit diffs only mask secret strings. scripts/e2e has a dependency-free CDP browser check for this task's acceptance flow. Task: M1-T4
This commit is contained in:
1 parent
13733ac262
commit
9a81e37725
25 files changed
+1774
-33
No files matched your search
@@ -0,0 +1,15 @@
|
||||
# Browser checks
|
||||
|
||||
Dependency-free scripts that drive a headless Chromium (Edge/Chrome) over the DevTools
|
||||
protocol. They need an api serving the built web UI and a fresh database.
|
||||
|
||||
```sh
|
||||
pnpm --filter @selfphoneng/web build && mkdir -p /tmp/sp && cp -r apps/web/dist /tmp/sp/public
|
||||
# fresh DB, then from /tmp/sp:
|
||||
DATABASE_URL=postgres://… INITIAL_ADMIN_EMAIL=admin@example.com INITIAL_ADMIN_PASSWORD=initial-admin-pw \
|
||||
node --env-file=<repo>/.env --conditions=source <repo>/apps/api/src/index.ts
|
||||
BROWSER=/usr/bin/chromium node scripts/e2e/m1-t4.mjs http://127.0.0.1:8080 admin@example.com initial-admin-pw /tmp/shots
|
||||
node scripts/e2e/mobile.mjs http://127.0.0.1:8080 admin@example.com correct-horse-battery-staple /tmp/shots
|
||||
```
|
||||
|
||||
`m1-t4.mjs` is the M1-T4 acceptance flow: forced password change, then create/edit/delete a user.
|
||||
@@ -0,0 +1,117 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Minimal headless-Edge driver over the Chrome DevTools Protocol (no dependencies).
|
||||
import { spawn } from 'node:child_process';
|
||||
import { mkdtempSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
// Any Chromium: set BROWSER, e.g. /usr/bin/chromium or the Edge/Chrome .exe on Windows.
|
||||
const EDGE = process.env.BROWSER ?? 'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe';
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
export async function launch({ width = 1280, height = 800, shotsDir }) {
|
||||
const profile = mkdtempSync(join(tmpdir(), 'sp-edge-'));
|
||||
const port = 9300 + Math.floor(Math.random() * 500);
|
||||
const proc = spawn(EDGE, [
|
||||
'--headless=new',
|
||||
`--remote-debugging-port=${port}`,
|
||||
`--user-data-dir=${profile}`,
|
||||
'--no-first-run',
|
||||
`--window-size=${width},${height}`,
|
||||
'about:blank',
|
||||
]);
|
||||
let info;
|
||||
for (let i = 0; i < 50 && !info; i++) {
|
||||
await sleep(200);
|
||||
info = await fetch(`http://127.0.0.1:${port}/json/list`)
|
||||
.then((r) => r.json())
|
||||
.then((l) => l.find((t) => t.type === 'page'))
|
||||
.catch(() => undefined);
|
||||
}
|
||||
const ws = new WebSocket(info.webSocketDebuggerUrl);
|
||||
await new Promise((r) => ws.addEventListener('open', r));
|
||||
let id = 0;
|
||||
const pending = new Map();
|
||||
const consoleErrors = [];
|
||||
ws.addEventListener('message', (ev) => {
|
||||
const m = JSON.parse(ev.data);
|
||||
if (m.id && pending.has(m.id)) {
|
||||
const { resolve, reject } = pending.get(m.id);
|
||||
pending.delete(m.id);
|
||||
if (m.error) reject(new Error(m.error.message));
|
||||
else resolve(m.result);
|
||||
}
|
||||
if (m.method === 'Runtime.exceptionThrown') consoleErrors.push(m.params.exceptionDetails.text);
|
||||
if (m.method === 'Runtime.consoleAPICalled' && m.params.type === 'error')
|
||||
consoleErrors.push(m.params.args.map((a) => a.value ?? a.description).join(' '));
|
||||
});
|
||||
const send = (method, params = {}) =>
|
||||
new Promise((resolve, reject) => {
|
||||
pending.set(++id, { resolve, reject });
|
||||
ws.send(JSON.stringify({ id, method, params }));
|
||||
});
|
||||
await send('Page.enable');
|
||||
await send('Runtime.enable');
|
||||
await send('Emulation.setDeviceMetricsOverride', {
|
||||
width,
|
||||
height,
|
||||
deviceScaleFactor: 1,
|
||||
mobile: width < 500,
|
||||
});
|
||||
|
||||
const evaluate = async (expr) => {
|
||||
const r = await send('Runtime.evaluate', {
|
||||
expression: expr,
|
||||
awaitPromise: true,
|
||||
returnByValue: true,
|
||||
});
|
||||
if (r.exceptionDetails)
|
||||
throw new Error(r.exceptionDetails.exception?.description ?? 'eval failed');
|
||||
return r.result.value;
|
||||
};
|
||||
const waitFor = async (expr, what, timeout = 8000) => {
|
||||
const end = Date.now() + timeout;
|
||||
while (Date.now() < end) {
|
||||
if (await evaluate(expr).catch(() => false)) return;
|
||||
await sleep(100);
|
||||
}
|
||||
throw new Error(`timeout waiting for: ${what ?? expr}`);
|
||||
};
|
||||
const page = {
|
||||
evaluate,
|
||||
waitFor,
|
||||
consoleErrors,
|
||||
goto: (url) => send('Page.navigate', { url }),
|
||||
path: () => evaluate('location.pathname'),
|
||||
/** Sets an input/select found by its <label> text, the way a user typing would (React sees it). */
|
||||
fill: (label, value) =>
|
||||
evaluate(`(() => {
|
||||
const l = [...document.querySelectorAll('label')].find(l => l.textContent.trim().startsWith(${JSON.stringify(label)}) && l.htmlFor && document.getElementById(l.htmlFor)?.offsetParent !== null);
|
||||
if (!l) throw new Error('no visible field ' + ${JSON.stringify(label)});
|
||||
const el = document.getElementById(l.htmlFor);
|
||||
const proto = el.tagName === 'SELECT' ? HTMLSelectElement.prototype : HTMLInputElement.prototype;
|
||||
Object.getOwnPropertyDescriptor(proto, 'value').set.call(el, ${JSON.stringify(value)});
|
||||
el.dispatchEvent(new Event(el.tagName === 'SELECT' ? 'change' : 'input', { bubbles: true }));
|
||||
return true;
|
||||
})()`),
|
||||
/** Clicks the first visible button/link whose text or aria-label matches. */
|
||||
click: (text) =>
|
||||
evaluate(`(() => {
|
||||
const t = ${JSON.stringify(text)};
|
||||
const el = [...document.querySelectorAll('button, a')].reverse().find(e => (e.textContent.trim() === t || e.getAttribute('aria-label') === t) && e.getClientRects().length);
|
||||
if (!el) throw new Error('no visible button ' + t);
|
||||
el.click();
|
||||
return true;
|
||||
})()`),
|
||||
text: () => evaluate('document.body.innerText'),
|
||||
shot: async (name) => {
|
||||
const r = await send('Page.captureScreenshot', { format: 'png' });
|
||||
writeFileSync(join(shotsDir, `${name}.png`), Buffer.from(r.data, 'base64'));
|
||||
},
|
||||
close: async () => {
|
||||
ws.close();
|
||||
proc.kill();
|
||||
},
|
||||
};
|
||||
return page;
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// M1-T4 acceptance: admin logs in, is forced to change the password, then creates/edits/deletes a user.
|
||||
// Usage: node m1-t4.mjs <baseUrl> <adminEmail> <adminInitialPassword> <shotsDir>
|
||||
import assert from 'node:assert/strict';
|
||||
import { launch } from './cdp.mjs';
|
||||
|
||||
const [base, email, initialPw, shotsDir] = process.argv.slice(2);
|
||||
const NEW_PW = 'correct-horse-battery-staple';
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
const p = await launch({ shotsDir });
|
||||
try {
|
||||
await p.goto(`${base}/admin/users`);
|
||||
await p.waitFor(`location.pathname === '/login'`, 'redirect to /login');
|
||||
await p.shot('01-login');
|
||||
|
||||
await p.fill('Email', email);
|
||||
await p.fill('Password', initialPw);
|
||||
await p.click('Sign in');
|
||||
await p.waitFor(`location.pathname === '/change-password'`, 'forced password change');
|
||||
await p.shot('02-change-password');
|
||||
|
||||
await p.fill('Current password', initialPw);
|
||||
await p.fill('New password', 'short');
|
||||
await p.fill('Repeat new password', 'short');
|
||||
await p.click('Change password');
|
||||
await p.waitFor(
|
||||
`document.body.innerText.includes('at least 10 characters')`,
|
||||
'min length error shown',
|
||||
);
|
||||
|
||||
await p.fill('New password', NEW_PW);
|
||||
await p.fill('Repeat new password', NEW_PW);
|
||||
await p.click('Change password');
|
||||
await p.waitFor(`location.pathname === '/admin'`, 'admin dashboard after change');
|
||||
assert.match(await p.text(), /Coming in M2/);
|
||||
|
||||
await p.click('Users & Extensions');
|
||||
await p.waitFor(`document.body.innerText.includes('Administrator')`, 'users table');
|
||||
await p.shot('03-users');
|
||||
|
||||
// create
|
||||
await p.click('New user');
|
||||
await p.waitFor(`!!document.querySelector('dialog[open]')`, 'drawer open');
|
||||
await p.fill('Name', 'Ann Lee');
|
||||
await p.fill('Email', 'ann@example.com');
|
||||
await p.shot('04-new-user');
|
||||
await p.click('Create user');
|
||||
await p.waitFor(`document.body.innerText.includes('Temporary password')`, 'temp password dialog');
|
||||
const temp = await p.evaluate(`document.querySelector('dialog[open] code').textContent`);
|
||||
assert.match(temp, /^[A-Za-z0-9]{16}$/);
|
||||
await p.shot('05-temp-password');
|
||||
await p.click('Done');
|
||||
await p.waitFor(`document.body.innerText.includes('ann@example.com')`, 'new user in table');
|
||||
|
||||
// edit
|
||||
await p.click('Edit Ann Lee');
|
||||
await p.waitFor(`!!document.querySelector('dialog[open]')`, 'edit drawer');
|
||||
await p.fill('Name', 'Ann B. Lee');
|
||||
await p.fill('Role', 'admin');
|
||||
await p.click('Save');
|
||||
await p.waitFor(`document.body.innerText.includes('Ann B. Lee')`, 'edited name in table');
|
||||
|
||||
// delete
|
||||
await p.click('Delete Ann B. Lee');
|
||||
await p.waitFor(
|
||||
`document.body.innerText.includes('Delete Ann B. Lee?') || !!document.querySelector('dialog[open]')`,
|
||||
'confirm',
|
||||
);
|
||||
await p.shot('06-confirm-delete');
|
||||
await p.click('Delete');
|
||||
await p.waitFor(`!document.body.innerText.includes('ann@example.com')`, 'user removed');
|
||||
await p.shot('07-after-delete');
|
||||
|
||||
// log out and back in with the new password
|
||||
await p.click('Sign out');
|
||||
await p.waitFor(`location.pathname === '/login'`, 'signed out');
|
||||
await p.fill('Email', email);
|
||||
await p.fill('Password', NEW_PW);
|
||||
await p.click('Sign in');
|
||||
await p.waitFor(`location.pathname === '/admin'`, 'signed back in');
|
||||
await sleep(200);
|
||||
|
||||
assert.deepEqual(p.consoleErrors, [], 'no console errors');
|
||||
console.log('M1-T4 browser flow: PASS');
|
||||
} catch (e) {
|
||||
await p.shot('FAILED').catch(() => {});
|
||||
console.error(
|
||||
'FAIL:',
|
||||
e.message,
|
||||
'\npath:',
|
||||
await p.path().catch(() => '?'),
|
||||
'\nconsole:',
|
||||
p.consoleErrors,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
await p.close();
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Screenshots of the admin shell at 360 px (05 §1: works at 360 px; tables become cards).
|
||||
import { launch } from './cdp.mjs';
|
||||
const [base, email, pw, shotsDir] = process.argv.slice(2);
|
||||
const p = await launch({ width: 360, height: 740, shotsDir });
|
||||
try {
|
||||
await p.goto(`${base}/login`);
|
||||
await p.waitFor(`!!document.querySelector('input[type=email]')`);
|
||||
await p.fill('Email', email);
|
||||
await p.fill('Password', pw);
|
||||
await p.click('Sign in');
|
||||
await p.waitFor(`location.pathname === '/admin'`);
|
||||
await p.goto(`${base}/admin/users`);
|
||||
await p.waitFor(`document.body.innerText.includes('admin@example.com')`);
|
||||
const overflow = await p.evaluate('document.documentElement.scrollWidth > innerWidth');
|
||||
await p.shot('m-users');
|
||||
await p.click('Menu');
|
||||
await p.waitFor(`document.body.innerText.includes('Trunks')`);
|
||||
await p.shot('m-menu');
|
||||
console.log('horizontal overflow:', overflow, '| console errors:', p.consoleErrors);
|
||||
} finally {
|
||||
await p.close();
|
||||
}
|
||||
Reference in new issue
Block a user