Files
feuerwerk/tests/core/guest.test.ts
T
adminandClaude Opus 5.5 925a39a276
feuerwerk / gate (push) Successful in 4m51s
feuerwerk / suite (push) Successful in 19m23s
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>
2026-10-16 06:58:30 +02:00

149 lines
6.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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();
});
});