Let only the owner delete a project, not an administrator (#272)
game-maker / gate (push) Successful in 2m36s
game-maker / suite (push) Successful in 13m2s

#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>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-09-30 21:13:20 +02:00
1 parent f14c4ecb77
commit cbda6d64a7
5 files changed
+69 -17

No files matched your search

+16 -4
View File
@@ -49,11 +49,15 @@ export async function users(t) {
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).
// 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)) {
for (const project of account.projects) await asAdmin('DELETE', `projects/${project.id}`);
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}`);
}
};
@@ -283,8 +287,16 @@ export async function users(t) {
JSON.stringify({ name: stored.name, owner: stored.owner }),
);
// 49i. With the project gone, the account goes too.
await asAdmin('DELETE', `projects/${made.id}`);
// 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');