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();
});
});