#179 decided that nobody deletes a project on someone else's behalf. The API still let an administrator do it, because the admin store treated every project as its own. Option A from the issue is now in place: opening, editing, snapshots and release stay open to the administrator, deleting stays with the owner. - Store::delete checks the owner under the save lock and throws NotOwnerException for anyone else. It follows the pattern of StaleRevisionException. - The route answers that with 403 "Only its owner may delete a project". A file without an owner is covered the same way. Non-admins still get 404 for a project that is not theirs. - The editor has no way to delete a foreign project, the project picker lists only one's own. Nothing changes there. - An account that owns projects can no longer be deleted by an administrator at all. Its owner has to delete them first. - e2e 49 deleted the throwaway projects as administrator, both in its sweep and in 49i. It now signs in as the owner for that. 49i checks both answers. Tests: - tests/php/accounts.test.php: 56 checks passed (was 52, one check turned around, five new) - e2e 49: 22 checks passed (was 21), deleteByAdmin 403, deleteByOwner 204 - Gate: 197 files, 2855 tests passed - Counter-checks: without the owner check 4 PHP checks and 49i fail - The e2e total of 1603 is computed (1602 + 1), not measured Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
316 lines
14 KiB
JavaScript
316 lines
14 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), and only the owner may
|
||
// delete them (#272). The step never changes the throwaway password, so it still signs in.
|
||
const sweep = async () => {
|
||
const accounts = await (await asAdmin('GET', 'users')).json();
|
||
for (const account of accounts.filter((a) => a.username === TEMP || a.username === RENAMED)) {
|
||
if (account.projects.length > 0) {
|
||
const ownerCookie = await logIn(baseUrl, account.username, TEMP_PASSWORD);
|
||
for (const project of account.projects) await call(ownerCookie, '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. Opening and editing are not deleting: that stays with the owner (#272). With the
|
||
// project gone, the account goes too.
|
||
const deleteByAdmin = (await asAdmin('DELETE', `projects/${made.id}`)).status;
|
||
const ownerCookie = await logIn(baseUrl, RENAMED, TEMP_PASSWORD);
|
||
const deleteByOwner = (await call(ownerCookie, 'DELETE', `projects/${made.id}`)).status;
|
||
check(
|
||
'an administrator may not delete somebody else’s project, its owner may',
|
||
deleteByAdmin === 403 && deleteByOwner === 204,
|
||
JSON.stringify({ deleteByAdmin, deleteByOwner }),
|
||
);
|
||
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();
|
||
}
|
||
}
|