Until now every account could do the same, and managing accounts meant running make-user.php on the server. Accounts now carry a role, and admins get a tab in the editor to create, rename, re-role, reset and delete accounts. Both issues share the same server files, so they land in one commit. #180 Roles: - Every account is admin or user. A missing or unknown role counts as user, on the server and in the client. - auth/session and auth/login return {user, role}. - Auth::requireAdmin() answers everyone else with 403. - make-user.php takes the role as a third argument. Without it an existing account keeps its role, so a password reset from the CLI never demotes an admin. - Admins see and save other users' projects like their own. Saving keeps the original owner. #179 User management: - The account menu gets "User management" (admins only) and "Plugins" (everyone). Both open the same closable tab, accounts on top and plugins below. The fixed Plugins tab is gone. - Per account: Rename, Role, Password (no old password needed) and Delete with a confirmation. "New user..." creates one. - Each account lists its projects with an Open button. - Delete is refused while the account still owns projects. - Renaming moves the projects along and ends every session of the account. transfer takes the same lock as save. - On their own row an admin can only set the password. The last admin cannot be demoted or deleted. The server enforces both. - The account rules live in AccountRules.php and accountRules.ts as pure functions, so they are testable without a server. Findings: - At 1280 px the Delete button reached past the column edge and was not clickable. The name column is flexible now, and module 49 checks the layout. - The server lets an admin delete other users' projects, but the editor has no button for it yet. Left open in the issue. Tests: - ./check.sh: 148 files, 2214 tests, exit 0 (was 147 / 2204). - New tests/php/accounts.test.php, 51 checks, plain PHP. CI runs it as its own step in the gate job, the host has no PHP. - New e2e module 49 with 18 checks. CI creates the e2e-admin account in serve.sh. Modules 14, 38, 46 and the scaffold follow the new tab. - e2e 1-16,37,38,45,46,49: all passed, 581 checks. - Counter-checks: owner on save, admin view, default role and self delete in PHP fail 7, 5, 5 and 1 checks. Unchecked requireAdmin, a wider column and the caller as owner each fail exactly their e2e check. - Check sum 1314 -> 1333 (+1 in 38, +18 in 49), computed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
293 lines
13 KiB
JavaScript
293 lines
13 KiB
JavaScript
import {
|
||
E2E_ADMIN,
|
||
E2E_USER,
|
||
e2eAdminPassword,
|
||
e2ePassword,
|
||
forgetSession,
|
||
logIn,
|
||
SESSION_COOKIE,
|
||
} from '../lib/auth.mjs';
|
||
|
||
/** The accounts this step makes and takes away again; a run that died halfway leaves them for the next. */
|
||
const TEMP = 'e2e-tmp';
|
||
const RENAMED = 'e2e-tmp2';
|
||
const TEMP_PASSWORD = 'temporary-pass-49';
|
||
|
||
/**
|
||
* Roles and the user management (#180, #179). The suite's own account is a user: it sees the
|
||
* plugins in the menu and nothing else, and the API tells it no. Then the browser is signed in as
|
||
* `e2e-admin`, which manages a throwaway account through the tab – create, rename, role, refused
|
||
* delete – and opens and edits that account's project as if it were its own.
|
||
*
|
||
* Runs last: it signs the browser in as somebody else and opens a project that is not the
|
||
* fixture. The last thing it does is hand the browser back to `e2e` with the fixture open, and the
|
||
* accounts and the project it made are gone again, also when a check on the way throws.
|
||
*/
|
||
export async function users(t) {
|
||
const {
|
||
check,
|
||
cdp,
|
||
json,
|
||
evaluate,
|
||
settle,
|
||
click,
|
||
doubleClick,
|
||
press,
|
||
typeText,
|
||
locate,
|
||
sleep,
|
||
waitUntil,
|
||
} = t;
|
||
const { clickToolbar, clickText, shot, saved, baseUrl, projectId, navigate } = t;
|
||
|
||
const adminCookie = await logIn(baseUrl, E2E_ADMIN, await e2eAdminPassword());
|
||
const call = (cookie, method, route, body) =>
|
||
fetch(`${baseUrl}api/index.php?r=${route}`, {
|
||
method,
|
||
headers: { Cookie: cookie, 'Content-Type': 'application/json' },
|
||
body: body === undefined ? undefined : JSON.stringify(body),
|
||
signal: AbortSignal.timeout(20_000),
|
||
});
|
||
const asAdmin = (method, route, body) => call(adminCookie, method, route, body);
|
||
// Projects first: an account that still owns one is not deleted (#179).
|
||
const sweep = async () => {
|
||
const accounts = await (await asAdmin('GET', 'users')).json();
|
||
for (const account of accounts.filter((a) => a.username === TEMP || a.username === RENAMED)) {
|
||
for (const project of account.projects) await asAdmin('DELETE', `projects/${project.id}`);
|
||
await asAdmin('DELETE', `users/${account.username}`);
|
||
}
|
||
};
|
||
const pane = () => json('__gm.users.pane()');
|
||
const rowOf = async (username) => (await pane())?.rows.find((r) => r.username === username) ?? null;
|
||
const pressButton = async (username, action) => {
|
||
// A list read again is new rows, laid out on the next frame – asked before, they sit at 0,0.
|
||
await settle();
|
||
const at = await json(`__gm.users.buttonAt(${JSON.stringify(username)}, ${JSON.stringify(action)})`);
|
||
await click(at.x, at.y);
|
||
};
|
||
/** A form of the tab, filled field by field; Enter moves on and, on the last field, sends. */
|
||
const fillForm = async (values) => {
|
||
for (const value of values) {
|
||
await press('a', { ctrl: true });
|
||
await typeText(value);
|
||
await press('Enter');
|
||
}
|
||
};
|
||
/** The last-drawn `text` – the open menu's or dialog's, over a row that says the same. */
|
||
const clickLast = async (text) => {
|
||
const at = await locate(text, true);
|
||
await click(at.x, at.y);
|
||
};
|
||
const openManagement = async () => {
|
||
await clickToolbar('menu');
|
||
await clickLast('User management');
|
||
await waitUntil('__gm.users.pane()?.rows.length > 0', { label: 'the accounts were listed' });
|
||
};
|
||
|
||
await sweep();
|
||
try {
|
||
// 49a. The suite's own account is a user (#180): the menu offers the plugins and no user
|
||
// management, and the API turns it away at the door – the check that the decision is not
|
||
// the editor's.
|
||
await press('Escape');
|
||
await clickToolbar('menu');
|
||
const userMenu = await json('__gm.menuRows()');
|
||
await press('Escape');
|
||
check(
|
||
'a user finds the plugins in the menu, but no user management',
|
||
userMenu.at(-1) === 'Plugins' && !userMenu.includes('User management') && (await pane()) === null,
|
||
JSON.stringify(userMenu),
|
||
);
|
||
// A session of its own: step 45 changes the password of `e2e`, which ends the tools' cached one.
|
||
const refused = await call(await logIn(baseUrl, E2E_USER, await e2ePassword()), 'GET', 'users');
|
||
check(
|
||
`the API answers ${E2E_USER} 403 on the accounts`,
|
||
refused.status === 403 && (await refused.json()).error === 'Administrators only',
|
||
String(refused.status),
|
||
);
|
||
|
||
// 49b. Signed in as the administrator, the fixture – which belongs to `e2e` – opens all the
|
||
// same, and the menu has the user management before the plugins.
|
||
// Cleared first: the sign-in screen of step 45 left a cookie of its own, on a path more specific
|
||
// than the one CDP gives, and the browser would send that one first.
|
||
await cdp.send('Network.clearBrowserCookies');
|
||
await cdp.send('Network.setCookie', {
|
||
name: SESSION_COOKIE,
|
||
value: adminCookie.slice(SESSION_COOKIE.length + 1),
|
||
url: baseUrl,
|
||
});
|
||
await cdp.send('Page.navigate', { url: `${baseUrl}?project=${projectId}` });
|
||
await sleep(4000);
|
||
await waitUntil(`window.__gm?.state?.project?.id === ${JSON.stringify(projectId)}`, {
|
||
timeout: 15000,
|
||
label: 'the administrator has the fixture open',
|
||
});
|
||
const signedIn = { title: await json('document.title'), id: await json('__gm.state.project.id') };
|
||
check(
|
||
`${E2E_ADMIN} opens a project of ${E2E_USER} through its URL`,
|
||
signedIn.title.endsWith(E2E_ADMIN) &&
|
||
signedIn.id === projectId &&
|
||
(await json('__gm.state.notice')) === '',
|
||
JSON.stringify({ ...signedIn, notice: await json('__gm.state.notice') }),
|
||
);
|
||
await clickToolbar('menu');
|
||
const adminMenu = await json('__gm.menuRows()');
|
||
await press('Escape');
|
||
check(
|
||
'an administrator finds the user management before the plugins',
|
||
JSON.stringify(adminMenu.slice(-3)) === JSON.stringify([null, 'User management', 'Plugins']),
|
||
JSON.stringify(adminMenu),
|
||
);
|
||
|
||
// 49c. The tab lists every account; on their own row an administrator may set a password
|
||
// and nothing else.
|
||
await openManagement();
|
||
const shown = await pane();
|
||
const own = shown.rows.find((r) => r.username === E2E_ADMIN);
|
||
check(
|
||
'User management opens its tab with every account, the plugins below',
|
||
(await json('__gm.tab()')) === 'management' &&
|
||
shown.visible === true &&
|
||
[E2E_USER, E2E_ADMIN].every((name) => shown.rows.some((r) => r.username === name)) &&
|
||
shown.rows.find((r) => r.username === E2E_USER)?.projects.length > 0 &&
|
||
(await json('__gm.plugins.pane()')).rows.length > 0,
|
||
JSON.stringify(shown),
|
||
);
|
||
check(
|
||
'the own row offers a new password only',
|
||
JSON.stringify(own?.enabled) ===
|
||
JSON.stringify({ rename: false, role: false, password: true, delete: false }),
|
||
JSON.stringify(own),
|
||
);
|
||
// Every row's last button ends inside the list: in a tab the width of the editor's centre, the
|
||
// columns once pushed Delete under the edge, where no click reached it.
|
||
const listRect = await json('__gm.users.rect()');
|
||
const ends = [];
|
||
for (const row of shown.rows)
|
||
ends.push((await json(`__gm.users.buttonAt(${JSON.stringify(row.username)}, 'delete')`)).right);
|
||
check(
|
||
'every row fits into the list, its Delete button included',
|
||
ends.every((right) => right <= listRect.x + listRect.w),
|
||
JSON.stringify({ ends, listRight: listRect.x + listRect.w }),
|
||
);
|
||
await shot('49-user-management');
|
||
|
||
// 49d. A new account through the form. A short password is said before anything is sent; a
|
||
// name that is taken comes back from the server, in its words.
|
||
await clickText('New user…');
|
||
await fillForm([TEMP, 'short', 'short']);
|
||
check(
|
||
'a short password is refused in the form, which stays open',
|
||
(await json('__gm.users.dialog()'))?.message === 'The password needs at least 8 characters',
|
||
JSON.stringify(await json('__gm.users.dialog()')),
|
||
);
|
||
await press('Escape');
|
||
await clickText('New user…');
|
||
await fillForm([E2E_USER, TEMP_PASSWORD, TEMP_PASSWORD]);
|
||
await waitUntil('__gm.users.dialog()?.busy === false && __gm.users.dialog()?.message !== ""', {
|
||
label: 'the server answered',
|
||
});
|
||
check(
|
||
'a taken name comes back from the server and is shown as it says it',
|
||
(await json('__gm.users.dialog()'))?.message === `The name ${E2E_USER} is taken`,
|
||
JSON.stringify(await json('__gm.users.dialog()')),
|
||
);
|
||
await press('Escape');
|
||
await clickText('New user…');
|
||
await fillForm([TEMP, TEMP_PASSWORD, TEMP_PASSWORD]);
|
||
await waitUntil(`__gm.users.pane()?.rows.some((r) => r.username === ${JSON.stringify(TEMP)})`, {
|
||
label: 'the new account is listed',
|
||
});
|
||
check(
|
||
'Create closes the form and lists the account as a user without projects',
|
||
(await json('__gm.users.dialog()')) === null &&
|
||
JSON.stringify(await rowOf(TEMP)) ===
|
||
JSON.stringify({
|
||
username: TEMP,
|
||
role: 'user',
|
||
projects: [],
|
||
enabled: { rename: true, role: true, password: true, delete: true },
|
||
}),
|
||
JSON.stringify(await rowOf(TEMP)),
|
||
);
|
||
|
||
// The new account signs in and makes a project of its own, the way its owner would.
|
||
const tempCookie = await logIn(baseUrl, TEMP, TEMP_PASSWORD);
|
||
const made = await (await call(tempCookie, 'POST', 'projects', { name: 'Temp game' })).json();
|
||
await evaluate('__gm.users.reload()');
|
||
|
||
// 49e. Renaming takes the project along and signs the account out.
|
||
await pressButton(TEMP, 'rename');
|
||
await fillForm([RENAMED]);
|
||
await waitUntil(`__gm.users.pane()?.rows.some((r) => r.username === ${JSON.stringify(RENAMED)})`, {
|
||
label: 'the renamed account is listed',
|
||
});
|
||
const session = await (await call(tempCookie, 'GET', 'auth/session')).json();
|
||
check(
|
||
'Rename moves the account and its project to the new name',
|
||
(await rowOf(TEMP)) === null && JSON.stringify((await rowOf(RENAMED))?.projects) === '["Temp game"]',
|
||
JSON.stringify(await pane()),
|
||
);
|
||
check('and the session under the old name is over', session.user === null, JSON.stringify(session));
|
||
|
||
// 49f. The role, picked from the row's menu.
|
||
await pressButton(RENAMED, 'role');
|
||
await clickLast('admin');
|
||
await waitUntil(
|
||
`__gm.users.pane()?.rows.find((r) => r.username === ${JSON.stringify(RENAMED)})?.role === 'admin'`,
|
||
{
|
||
label: 'the role changed',
|
||
},
|
||
);
|
||
check('Role → admin makes the account an administrator', (await rowOf(RENAMED))?.role === 'admin');
|
||
|
||
// 49g. An account that still owns a project is not deleted.
|
||
await pressButton(RENAMED, 'delete');
|
||
await clickLast('Delete');
|
||
await waitUntil('__gm.state.notice !== ""', { label: 'the refusal was said' });
|
||
check(
|
||
'Delete is refused while the account owns a project, and says why',
|
||
(await json('__gm.state.notice')) === `${RENAMED} still owns 1 project` &&
|
||
(await rowOf(RENAMED)) !== null,
|
||
JSON.stringify({ notice: await json('__gm.state.notice'), row: await rowOf(RENAMED) }),
|
||
);
|
||
|
||
// 49h. The administrator opens that project from the list and edits it like their own; the
|
||
// save keeps its owner.
|
||
await pressButton(RENAMED, `open:${made.id}`);
|
||
await waitUntil(`__gm.state.project.id === ${JSON.stringify(made.id)}`, { label: 'the project opened' });
|
||
await clickToolbar('menu');
|
||
await clickText('Project settings…');
|
||
const nameField = await locate('Temp game');
|
||
await doubleClick(nameField.x, nameField.y);
|
||
await press('a', { ctrl: true });
|
||
await typeText('Edited by admin');
|
||
await press('Enter');
|
||
await clickText('Apply');
|
||
await saved();
|
||
const stored = await (await asAdmin('GET', `projects/${made.id}`)).json();
|
||
check(
|
||
'Open loads somebody else’s project, and an edit to it is saved under its owner',
|
||
stored.name === 'Edited by admin' && stored.owner === RENAMED,
|
||
JSON.stringify({ name: stored.name, owner: stored.owner }),
|
||
);
|
||
|
||
// 49i. With the project gone, the account goes too.
|
||
await asAdmin('DELETE', `projects/${made.id}`);
|
||
await openManagement();
|
||
await pressButton(RENAMED, 'delete');
|
||
await clickLast('Delete');
|
||
await waitUntil(`!__gm.users.pane()?.rows.some((r) => r.username === ${JSON.stringify(RENAMED)})`, {
|
||
label: 'the account is gone',
|
||
});
|
||
check('Delete removes an account without projects', (await rowOf(RENAMED)) === null);
|
||
await settle();
|
||
} finally {
|
||
await sweep();
|
||
await cdp.send('Network.clearBrowserCookies');
|
||
// Step 45 changed the password of `e2e`, which ended the session `navigate` would hand over.
|
||
forgetSession(baseUrl);
|
||
await navigate();
|
||
}
|
||
}
|