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 userCookie = await logIn(baseUrl, E2E_USER, await e2ePassword()); const refused = await call(userCookie, 'GET', 'users'); check( `the API answers ${E2E_USER} 403 on the accounts`, refused.status === 403 && (await refused.json()).error === 'Administrators only', String(refused.status), ); // An image, font or sound is shared, and a project of another account may wear it. The name is // valid but nobody's, so nothing is deleted either way. const nobodys = `textures/${'0'.repeat(64)}.png`; const byUser = (await call(userCookie, 'DELETE', nobodys)).status; const byAdmin = (await asAdmin('DELETE', nobodys)).status; check( 'only an administrator may delete a shared file', byUser === 403 && byAdmin === 404, JSON.stringify({ byUser, byAdmin }), ); // 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(); } }