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