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 -5
View File
@@ -27,12 +27,15 @@ function check(string $name, bool $ok, mixed $got = null): void
echo "FAIL {$name}", $got === null ? '' : ' — got ' . json_encode($got), "\n";
}
function throws(callable $work): bool
function throws(callable $work, string $class = InvalidArgumentException::class): bool
{
try {
$work();
} catch (InvalidArgumentException) {
return true;
} catch (Throwable $e) {
if ($e instanceof $class) {
return true;
}
throw $e;
}
return false;
}
@@ -110,8 +113,16 @@ check('the new name owns it', $moved !== null && $moved['owner'] === 'annabel');
check('the old name does not', $mine->load($id) === null);
check('transfer keeps the revision', $moved['revision'] === 2, $moved['revision'] ?? null);
$other = (new Store($projects, 'carl'))->create('Carl\'s game');
check('an administrator deletes somebody else\'s project', $admin->delete($other['id']) && $admin->listOf('carl') === []);
$carl = new Store($projects, 'carl');
$other = $carl->create('Carl\'s game');
check('an administrator may not delete somebody else\'s project', throws(fn() => $admin->delete($other['id']), NotOwnerException::class));
check('the refused delete leaves the project', $carl->load($other['id']) !== null);
check('its owner deletes it', $carl->delete($other['id']) && $admin->listOf('carl') === []);
$ownerless = "{$projects}/nobody01.json";
file_put_contents($ownerless, json_encode(['id' => 'nobody01', 'revision' => 1, 'project' => []]));
check('an administrator may not delete a project without an owner', throws(fn() => $admin->delete('nobody01'), NotOwnerException::class) && is_file($ownerless));
$own = $admin->create('Boss\'s game');
check('an administrator deletes their own project', $admin->delete($own['id']) && $admin->list() === []);
// The web server serves `data/` as static files, so a readable project would skip the sign-in.
check('a project file is not readable for the web server', (fileperms("{$projects}/{$id}.json") & 0o007) === 0);