Let guests try a demo project without an account (#428)
feuerwerk / gate (push) Successful in 4m51s
feuerwerk / suite (push) Successful in 19m23s

The editor was only open to somebody with an account. A guest now picks
a demo project on the sign-in screen and gets the whole editor on a copy
held in the tab. The server writes nothing for a guest, so there is no
guest session, no guest folder and no quota to keep.

- Demo copies are frozen copies like a release, in data/demos/. An
  administrator makes, updates and removes one with "Demo copy..." in
  the project's main menu. GET demos and GET demos/{token} need no
  session. An empty project is just a demo that is empty.
- Demo material: an administrator offers an uploaded file with "Offer
  as demo" in the pickers (PATCH {demo}, data/demo-files.json). Every
  list carries it with demo: true, for accounts and guests alike.
  Signed out, a list holds the demo material alone, without the owner.
- An offered file is only an administrator's to delete. A file a demo
  copy names counts as used, like one a release names.
- The guest's project lives in MemoryProjectStore. Every store that
  would write refuses in the tab before a request is made, and the
  autosave is not attached at all.
- The main menu drops projects, snapshots and release for a guest. The
  pickers drop Upload and Delete, a dropped or pasted file is refused,
  and a recording is saved as a download.
- The toolbar says the work is not saved. The Account menu holds
  "Sign in..." instead of Logout and the password.
- A guest's demo never goes into the address: a reload brings the
  sign-in screen back, not the copy. Found by e2e 70.
- GET plugins needs no session any more, since a demo may switch one
  on. It only reads the server's own modules.

Tests:
- e2e 70 (atx): ALL PASSED, 13 checks. Counter-test with the guest
  autosave sent to the server: 70f and 70g red on the PUT.
- tests/core/guest.test.ts: 9 tests. PHP: demos 20, files 46,
  rights 80 checks (the matrix asks every new route).
- Gate (atx): CHECK EXIT 0, 314 files, 4852 tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-16 06:58:30 +02:00
1 parent ce1f25f2eb
commit 925a39a276
30 files changed
+1397 -128

No files matched your search

+61 -9
View File
@@ -149,10 +149,13 @@ $saved = call('PUT', "projects/{$project['id']}", $alice, [
]);
$snapshot = call('POST', "projects/{$project['id']}/snapshots", $alice, ['name' => 'One'])['body'];
$release = call('POST', "projects/{$project['id']}/release", $alice)['body'];
// An administrator makes a demo copy of it and offers her image to everybody (#428).
$demo = call('POST', "projects/{$project['id']}/demo", $admin)['body'];
$offered = call('PATCH', "textures/{$texture}", $admin, ['demo' => true])['status'];
check(
'alice has her project, image, sound, snapshot and release',
$saved['status'] === 200 && $texture !== '' && $sound !== '' && isset($snapshot['id'], $release['token']),
[$saved['status'], $texture, $sound, $snapshot, $release],
'alice has her project, image, sound, snapshot, release and a demo copy, her image is offered',
$saved['status'] === 200 && $texture !== '' && $sound !== '' && isset($snapshot['id'], $release['token'], $demo['token']) && $offered === 204,
[$saved['status'], $texture, $sound, $snapshot, $release, $demo, $offered],
);
$folder = explode('/', $texture)[0];
$p = $project['id'];
@@ -190,23 +193,32 @@ $matrix = [
['POST', "projects/{$p}/release", null, 'POST projects/{}/release', [401, 200, 404, 200]],
['DELETE', "projects/{$p}/release", null, 'DELETE projects/{}/release', [401, null, 404, null]],
['GET', "releases/{$release['token']}", null, 'GET releases/{}', [200, 200, 200, 200]],
['GET', 'demos', null, 'GET demos', [200, 200, 200, 200]],
['GET', "demos/{$demo['token']}", null, 'GET demos/{}', [200, 200, 200, 200]],
['GET', "projects/{$p}/demo", null, 'GET projects/{}/demo', [401, 403, 403, 200]],
['POST', "projects/{$p}/demo", null, 'POST projects/{}/demo', [401, 403, 403, 200]],
['DELETE', "projects/{$p}/demo", null, 'DELETE projects/{}/demo', [401, 403, 403, null]],
['GET', 'textures', null, 'GET textures', [401, 200, 200, 200]],
['GET', 'textures', null, 'GET textures', [200, 200, 200, 200]],
['POST', 'textures&name=b.png', $png, 'POST textures', [401, 201, 201, 201]],
['DELETE', "textures/{$texture}", null, 'DELETE textures/{}', [401, 409, 403, 409]],
['GET', 'fonts', null, 'GET fonts', [401, 200, 200, 200]],
['PATCH', "textures/{$texture}", ['demo' => true], 'PATCH textures/{}', [401, 403, 403, 204]],
['GET', 'fonts', null, 'GET fonts', [200, 200, 200, 200]],
['POST', 'fonts&name=x.ttf', 'not a font at all', 'POST fonts', [401, 422, 422, 422]],
['DELETE', "fonts/{$missing}.ttf", null, 'DELETE fonts/{}', [401, 404, 403, 404]],
['GET', 'sounds', null, 'GET sounds', [401, 200, 200, 200]],
['PATCH', "fonts/{$missing}.ttf", ['demo' => true], 'PATCH fonts/{}', [401, 403, 403, 404]],
['GET', 'sounds', null, 'GET sounds', [200, 200, 200, 200]],
['POST', 'sounds&name=b.wav', $wav(32, 'b'), 'POST sounds', [401, 201, 201, 201]],
['DELETE', "sounds/{$sound}", null, 'DELETE sounds/{}', [401, null, 403, null]],
['GET', 'videos', null, 'GET videos', [401, 200, 200, 200]],
['PATCH', "sounds/{$sound}", ['demo' => 'yes'], 'PATCH sounds/{}', [401, 403, 403, 422]],
['GET', 'videos', null, 'GET videos', [200, 200, 200, 200]],
['POST', 'videos&name=x.mov&convert=0', 'not a video at all', 'POST videos', [401, 422, 422, 422]],
['POST', "videos/{$missing}.mp4/convert", null, 'POST videos/{}/convert', [401, 404, 403, 404]],
['DELETE', "videos/{$missing}.mp4", null, 'DELETE videos/{}', [401, 404, 403, 404]],
['PATCH', "videos/{$missing}.mp4", ['demo' => true], 'PATCH videos/{}', [401, 403, 403, 404]],
['POST', 'pixels', 'not a cel', 'POST pixels', [401, 422, 422, 422]],
['GET', 'plugins', null, 'GET plugins', [401, 200, 200, 200]],
['GET', 'plugins/nothing.js', null, 'GET plugins/{}', [401, 404, 404, 404]],
['GET', 'plugins', null, 'GET plugins', [200, 200, 200, 200]],
['GET', 'plugins/nothing.js', null, 'GET plugins/{}', [404, 404, 404, 404]],
];
$actors = ['signed out' => null, 'alice' => $alice, 'bob' => $bob, 'an administrator' => $admin];
@@ -238,6 +250,41 @@ $unasked = array_values(array_diff($documentedRoutes, $asked));
check('every route of the API is in the matrix', count($documentedRoutes) > 30 && $unasked === [], $unasked ?: count($documentedRoutes));
check('and the matrix asks about no route the API does not name', array_values(array_diff($asked, $documentedRoutes)) === [], array_values(array_diff($asked, $documentedRoutes)));
// --- Demo copies and demo material (#428) ---------------------------------------------------------
$signedOut = call('GET', 'textures')['body'];
check(
'signed out, the image list is the offered image alone, without who uploaded it',
count($signedOut) === 1 && $signedOut[0]['file'] === $texture && $signedOut[0]['demo'] === true
&& $signedOut[0]['owner'] === null && $signedOut[0]['deletable'] === false,
$signedOut,
);
$bobs = call('GET', 'textures', $bob)['body'];
$hers = array_values(array_filter($bobs, static fn(array $item): bool => $item['file'] === $texture));
check(
'another account lists it beside its own, offered and not for it to delete',
count($hers) === 1 && $hers[0]['demo'] === true && $hers[0]['deletable'] === false && $hers[0]['owner'] === 'alice',
$hers,
);
check(
'its owner sees it marked as well',
in_array(true, array_column(call('GET', 'textures', $alice)['body'], 'demo'), true),
);
check('the signed-out sound list is empty while nothing is offered', call('GET', 'sounds')['body'] === []);
call('PATCH', "sounds/{$sound}", $admin, ['demo' => true]);
$kept = call('DELETE', "sounds/{$sound}", $alice);
check(
'its owner cannot delete a file while everybody is offered it',
$kept['status'] === 409 && str_contains((string) ($kept['body']['error'] ?? ''), 'demo material'),
$kept,
);
$demos = call('GET', 'demos')['body'];
check('the demo list names the copy, signed out', array_column($demos, 'name') === ['Alice'] && $demos[0]['token'] === $demo['token'], $demos);
$opened = call('GET', "demos/{$demo['token']}")['body'];
check('a demo copy goes out under its token, not the project id', ($opened['project']['id'] ?? null) === $demo['token'], $opened);
check('an unknown demo is not found', call('GET', 'demos/' . str_repeat('0', 32))['status'] === 404);
check('a demo cannot be changed through its open route', call('POST', "demos/{$demo['token']}", $admin)['status'] === 404);
// --- Paths, depth and unknown routes --------------------------------------------------------------
check('an unknown route is 401 signed out', call('GET', 'nothing')['status'] === 401);
@@ -278,6 +325,11 @@ $big = ['id' => $p, 'name' => 'Alice', 'assets' => [], 'pad' => str_repeat('x',
check('so is a save that would grow a project past it', call('PUT', "projects/{$p}", $alice, ['revision' => 2, 'project' => $big])['status'] === 413);
check('a save that keeps it small goes through', call('PUT', "projects/{$p}", $alice, ['revision' => 2, 'project' => ['name' => 'Alice']])['status'] === 200);
// --- Deleting the project takes its copies along ---------------------------------------------------
check('deleting the project', call('DELETE', "projects/{$p}", $alice)['status'] === 204);
check('takes its demo copy along', call('GET', "demos/{$demo['token']}")['status'] === 404 && call('GET', 'demos')['body'] === []);
// --- Sign-ins: the brake -------------------------------------------------------------------------
$gone = signIn('carol', 'password-carol');