Let guests try a demo project without an account (#428)
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:
1 parent
ce1f25f2eb
commit
925a39a276
30 files changed
+1397
-128
No files matched your search
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
// A command-line script: under a web server it does nothing (#427).
|
||||
if (PHP_SAPI !== 'cli') {
|
||||
exit(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Demo copies and demo material (#428): the list of offered files, the copies a guest picks from,
|
||||
* and that a demo copy counts as a use of a file. Plain PHP like releases.test.php; CI runs it with
|
||||
* `php tests/php/demos.test.php` and reads the last line.
|
||||
*/
|
||||
|
||||
require __DIR__ . '/../../api/lib/AtomicFile.php';
|
||||
require __DIR__ . '/../../api/lib/DemoFiles.php';
|
||||
require __DIR__ . '/../../api/lib/FileUsage.php';
|
||||
require __DIR__ . '/../../api/lib/ReleaseStore.php';
|
||||
require __DIR__ . '/../../api/lib/Store.php';
|
||||
|
||||
$failed = 0;
|
||||
$passed = 0;
|
||||
|
||||
function check(string $name, bool $ok, mixed $got = null): void
|
||||
{
|
||||
global $failed, $passed;
|
||||
if ($ok) {
|
||||
$passed++;
|
||||
echo "PASS {$name}\n";
|
||||
return;
|
||||
}
|
||||
$failed++;
|
||||
echo "FAIL {$name}", $got === null ? '' : ' — got ' . json_encode($got, JSON_UNESCAPED_UNICODE), "\n";
|
||||
}
|
||||
|
||||
$data = sys_get_temp_dir() . '/feuerwerk-php-demos-' . bin2hex(random_bytes(4));
|
||||
mkdir("{$data}/projects", 0775, true);
|
||||
|
||||
// --- DemoFiles -------------------------------------------------------------------------------------
|
||||
|
||||
$files = new DemoFiles("{$data}/demo-files.json");
|
||||
$tree = 'a590cab0f8b7/' . str_repeat('1', 64) . '.png';
|
||||
$song = 'a590cab0f8b7/' . str_repeat('2', 64) . '.ogg';
|
||||
check('nothing is offered before an administrator offers it', $files->of('textures') === [] && !$files->has('textures', $tree));
|
||||
check('offering a file says it changed something', $files->set('textures', $tree, true) === true);
|
||||
check('and it is offered', $files->has('textures', $tree) && $files->of('textures') === [$tree]);
|
||||
check('offering it again changes nothing', $files->set('textures', $tree, true) === false && $files->of('textures') === [$tree]);
|
||||
$files->set('sounds', $song, true);
|
||||
check('each kind has its own list', $files->of('sounds') === [$song] && !$files->has('sounds', $tree));
|
||||
check('a new reader finds the same', (new DemoFiles("{$data}/demo-files.json"))->of('textures') === [$tree]);
|
||||
check('the list is not for the web server', (fileperms("{$data}/demo-files.json") & 0o007) === 0);
|
||||
check('taking the offer back', $files->set('textures', $tree, false) === true && $files->of('textures') === []);
|
||||
check('leaves the other kinds', $files->of('sounds') === [$song]);
|
||||
$threw = false;
|
||||
try {
|
||||
$files->set('pixels', $tree, true);
|
||||
} catch (InvalidArgumentException) {
|
||||
$threw = true;
|
||||
}
|
||||
check('a kind that cannot be offered is refused', $threw);
|
||||
|
||||
// --- Demo copies -----------------------------------------------------------------------------------
|
||||
|
||||
$demos = new ReleaseStore("{$data}/demos");
|
||||
check('without demo copies the list is empty', $demos->list() === []);
|
||||
$store = new Store("{$data}/projects", 'admin');
|
||||
$platformer = $store->create('Platformer');
|
||||
$empty = $store->create('Empty project');
|
||||
$saved = $store->save($platformer['id'], 1, ['id' => $platformer['id'], 'name' => 'Platformer', 'assets' => [['id' => 'a', 'file' => $song]]]);
|
||||
$first = $demos->publish($saved);
|
||||
$demos->publish($empty);
|
||||
$list = $demos->list();
|
||||
check('the list names every copy, by name', array_column($list, 'name') === ['Empty project', 'Platformer'], $list);
|
||||
check('with its token and when it was made', ReleaseStore::isValidToken($list[1]['token']) && $list[1]['publishedAt'] !== '', $list[1]);
|
||||
check('and nothing of the project itself', !array_key_exists('project', $list[0]) && !array_key_exists('projectId', $list[0]));
|
||||
check('the token loads the frozen copy', $demos->load($first['token'])['project']['assets'][0]['file'] === $song);
|
||||
|
||||
$store->save($platformer['id'], 2, ['id' => $platformer['id'], 'name' => 'Platformer', 'assets' => []]);
|
||||
check('changing the project leaves the copy as it was', $demos->load($first['token'])['project']['assets'] !== []);
|
||||
$again = $demos->publish($store->load($platformer['id']));
|
||||
check('updating it keeps the token and takes the new stand', $again['token'] === $first['token'] && $demos->load($first['token'])['project']['assets'] === []);
|
||||
|
||||
// --- A demo copy uses its files --------------------------------------------------------------------
|
||||
|
||||
$store->save($platformer['id'], 3, ['id' => $platformer['id'], 'name' => 'Platformer', 'assets' => [['id' => 'a', 'file' => $song]]]);
|
||||
$demos->publish($store->load($platformer['id']));
|
||||
$store->save($platformer['id'], 4, ['id' => $platformer['id'], 'name' => 'Platformer', 'assets' => []]);
|
||||
$uses = (new FileUsage($data))->of($song);
|
||||
check('a file only the demo copy still names counts as used', count($uses) === 1 && $uses[0]['id'] === $platformer['id'], $uses);
|
||||
check('and the refusal says so', str_contains(FileUsage::refusal($uses, 'admin', true), 'demo copies'));
|
||||
$demos->takeDown($platformer['id']);
|
||||
check('once the copy is gone, so is the use', (new FileUsage($data))->of($song) === []);
|
||||
|
||||
exec('rm -rf ' . escapeshellarg($data));
|
||||
|
||||
echo $failed === 0 ? "ALL PASSED – {$passed} checks\n" : "{$failed} FAILED, {$passed} passed\n";
|
||||
exit($failed === 0 ? 0 : 1);
|
||||
@@ -127,17 +127,17 @@ $uses = [
|
||||
];
|
||||
check(
|
||||
'the refusal names the asker\'s projects only',
|
||||
FileUsage::refusal($uses, 'anna', false) === 'Still used by “Garden” (2 projects) and a project of another account, counting snapshots and releases',
|
||||
FileUsage::refusal($uses, 'anna', false) === 'Still used by “Garden” (2 projects) and a project of another account, counting snapshots, releases and demo copies',
|
||||
FileUsage::refusal($uses, 'anna', false),
|
||||
);
|
||||
check(
|
||||
'an administrator learns whose the others are',
|
||||
FileUsage::refusal($uses, 'anna', true) === 'Still used by “Garden” (2 projects) and “Mine” of bob, counting snapshots and releases',
|
||||
FileUsage::refusal($uses, 'anna', true) === 'Still used by “Garden” (2 projects) and “Mine” of bob, counting snapshots, releases and demo copies',
|
||||
FileUsage::refusal($uses, 'anna', true),
|
||||
);
|
||||
check(
|
||||
'somebody with none of them learns only how many',
|
||||
FileUsage::refusal($uses, 'carl', false) === 'Still used by 3 projects of other accounts, counting snapshots and releases',
|
||||
FileUsage::refusal($uses, 'carl', false) === 'Still used by 3 projects of other accounts, counting snapshots, releases and demo copies',
|
||||
FileUsage::refusal($uses, 'carl', false),
|
||||
);
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in new issue
Block a user