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>
98 lines
4.9 KiB
PHP
98 lines
4.9 KiB
PHP
<?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);
|