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

+148
View File
@@ -0,0 +1,148 @@
/**
* Trying the editor without an account (#428): what a session may do, the guest's project held in
* the tab, the stores that refuse before they ask, and the routes of demo copies and demo material.
*/
import { afterEach, describe, expect, it, vi } from 'vitest';
import { HttpDemoStore } from '../../src/core/store/DemoStore';
import { HttpFileStore } from '../../src/core/store/HttpFileStore';
import { MemoryProjectStore } from '../../src/core/store/MemoryProjectStore';
import { StaleRevisionError } from '../../src/core/store/ProjectStore';
import { serializeProject } from '../../src/core/store/serialize';
import { guestFiles, guestReleases, guestSnapshots, guestVideos } from '../../src/editor/guestStores';
import { GUEST_REFUSAL, type SessionAction, sessionAllows } from '../../src/editor/session';
import { snapshotBlocker } from '../../src/editor/snapshots';
import { makeProject } from '../helpers';
const TOKEN = '0123456789abcdef0123456789abcdef';
const FILE = `a590cab0f8b7/${'b'.repeat(64)}.png`;
function stubFetch(body: BodyInit | null, init: ResponseInit) {
const mock = vi.fn<typeof fetch>(() => Promise.resolve(new Response(body, init)));
vi.stubGlobal('fetch', mock);
return mock;
}
afterEach(() => {
vi.unstubAllGlobals();
});
describe('what a session may do', () => {
const actions: SessionAction[] = [
'save',
'upload',
'deleteFile',
'projects',
'snapshots',
'release',
'password',
'users',
'demoCopy',
'offerDemo',
];
it('a guest writes nothing to the server and manages nothing', () => {
for (const action of actions)
expect(sessionAllows({ guest: true, role: 'user' }, action), action).toBe(false);
// Not even a guest who somehow carried an administrator's role.
for (const action of actions)
expect(sessionAllows({ guest: true, role: 'admin' }, action), action).toBe(false);
});
it('an account does all of it but the administrators’ own', () => {
const user = actions.filter((action) => sessionAllows({ guest: false, role: 'user' }, action));
expect(user).toEqual(['save', 'upload', 'deleteFile', 'projects', 'snapshots', 'release', 'password']);
expect(actions.every((action) => sessionAllows({ guest: false, role: 'admin' }, action))).toBe(true);
});
});
describe("a guest's project, held in the tab", () => {
const raw = () => serializeProject(makeProject().project);
it('is the one project there is, under the token, and loads after `prepare` saw it', async () => {
const store = new MemoryProjectStore(TOKEN, 'Platformer', raw(), GUEST_REFUSAL);
expect(await store.list()).toEqual([
expect.objectContaining({ id: TOKEN, name: 'Platformer', revision: 1 }),
]);
const seen: unknown[] = [];
const loaded = await store.load(TOKEN, (json) => {
seen.push(json);
return Promise.resolve();
});
expect(seen).toHaveLength(1);
expect(loaded.project.id).toBe(TOKEN);
expect(loaded.revision).toBe(1);
await expect(store.load('another')).rejects.toThrow(/not found/);
});
it('keeps a save in the tab, with the revision moving on as on the server', async () => {
const store = new MemoryProjectStore(TOKEN, 'Platformer', raw(), GUEST_REFUSAL);
const { project } = await store.load(TOKEN);
project.name = 'Changed';
expect(await store.save(project, 1)).toBe(2);
expect((await store.load(TOKEN)).project.name).toBe('Changed');
await expect(store.save(project, 1)).rejects.toBeInstanceOf(StaleRevisionError);
});
it('refuses to make or delete a project, with what a guest is told', async () => {
const store = new MemoryProjectStore(TOKEN, 'Platformer', raw(), GUEST_REFUSAL);
await expect(store.create('New')).rejects.toThrow(GUEST_REFUSAL);
await expect(store.delete(TOKEN)).rejects.toThrow(GUEST_REFUSAL);
});
});
describe("a guest's stores", () => {
it('list what the server offers, and refuse every write without asking it', async () => {
const mock = stubFetch(JSON.stringify([{ file: FILE, demo: true }]), { status: 200 });
const textures = guestFiles(new HttpFileStore('/api', 'textures'));
expect(await textures.list()).toEqual([{ file: FILE, demo: true }]);
expect(mock).toHaveBeenCalledTimes(1);
await expect(textures.upload(new Blob(['x']), 'image/png', 'x.png')).rejects.toThrow(GUEST_REFUSAL);
await expect(textures.delete(FILE)).rejects.toThrow(GUEST_REFUSAL);
const videos = guestVideos({
...new HttpFileStore('/api', 'videos'),
convert: () => Promise.reject(new Error('unreached')),
} as never);
await expect(videos.convert('x.mp4')).rejects.toThrow(GUEST_REFUSAL);
await expect(guestSnapshots.create(TOKEN, 'one')).rejects.toThrow(GUEST_REFUSAL);
await expect(guestReleases.publish(TOKEN)).rejects.toThrow(GUEST_REFUSAL);
expect(mock).toHaveBeenCalledTimes(1);
});
});
describe('demo copies and demo material', () => {
it('are listed and loaded without a project id, and made per project', async () => {
const mock = stubFetch(JSON.stringify([]), { status: 200 });
const demos = new HttpDemoStore('/api');
await demos.list();
await demos.load(TOKEN);
await demos.publish('p1');
await demos.takeDown('p1');
const calls = mock.mock.calls.map(
([url, init]) => `${init?.method} ${decodeURIComponent(url as string)}`,
);
expect(calls).toEqual([
'GET /api?r=demos',
`GET /api?r=demos/${TOKEN}`,
'POST /api?r=projects/p1/demo',
'DELETE /api?r=projects/p1/demo',
]);
});
it('offering a file is only there for an administrator, and sends the switch', async () => {
expect(new HttpFileStore('/api', 'textures').setDemo).toBeUndefined();
const mock = stubFetch(null, { status: 204 });
await new HttpFileStore('/api', 'textures', true).setDemo!(FILE, true);
const [url, init] = mock.mock.calls[0]!;
expect(decodeURIComponent(url as string)).toBe(`/api?r=textures/${FILE}`);
expect(init?.method).toBe('PATCH');
expect(JSON.parse(init?.body as string)).toEqual({ demo: true });
});
it('is not made of a stand that was not saved', () => {
expect(snapshotBlocker('error', 'demo')).toBe(
'The project could not be saved, so there is nothing current to make a demo copy of',
);
expect(snapshotBlocker('saved', 'demo')).toBeNull();
});
});
+97
View File
@@ -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);
+3 -3
View File
@@ -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),
);
+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');