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>
149 lines
6.2 KiB
TypeScript
149 lines
6.2 KiB
TypeScript
/**
|
||
* 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();
|
||
});
|
||
});
|