Files
feuerwerk/tests/core/http-store.test.ts
T
adminandClaude Opus 5.5 6b49a0bea7 Give each account its own folder of uploaded files to delete (#344)
Uploaded images, fonts and sounds belonged to nobody. Every account
saw every file in the pickers, and only an administrator could
delete one, through the API alone. Now every account uploads into a
folder of its own, sees its own files and nobody's, and can delete a
file of its own that no project uses.

- Files are stored as data/<kind>/<folder>/<sha256>.<ext>, and the
  folder is part of asset.file. A name without a folder belongs to
  nobody.
- The folder is a random 12-character name kept in users.json and
  given with the first upload. Renaming an account moves no file, and
  a released game's URLs carry no account name.
- The list shows the caller's own files and nobody's. An
  administrator sees all, each with owner and deletable.
- DELETE takes the name with its folder. It answers 403 unless the
  file is the caller's or the caller is an administrator, and 409
  while a project, snapshot or release of any account names it
  (FileUsage). The 409 names the caller's own projects only.
- The pickers have a Delete button. The first press asks, the second
  deletes, and a refusal shows the server's reason.
- A deleted account's folder stays and only administrators see it.
- tools/migrate-file-owners.php moved every used file into the folder
  of the account using it (a copy each for two accounts) and rewrote
  projects, snapshots and releases. A project's revision goes up, so
  an open editor gets a conflict instead of saving old names back.
- Font families are named after the hash alone, so a folder never
  ends up in a CSS family name.

Run on stein2 on 2026-10-07, backup in
/root/config-backup-20261007/data-before-344: 25 files used,
27 copies, 8 projects, 53 snapshots and 1 release rewritten, 30
unused files stay nobody's. Afterwards all 600 references resolve to
a file in their owner's folder.

Found while building: the Delete button cut its new label to "Del…"
because a button does not measure itself again on a new text. The
picker now lays its button row out again.

Tests:
- PHP files.test.php (new): 46 checks, counter-check 3 red
- Unit core + editor: 220 files, 3403 tests
- e2e 34 (melektaus-atx): ALL PASSED - 67 checks (2 new)
- Gate (melektaus-atx): CHECK EXIT 0, 256 files, 3977 tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 08:25:13 +02:00

131 lines
6.1 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.
import { afterEach, describe, expect, it, vi } from 'vitest';
import { HttpProjectStore } from '../../src/core/store/HttpProjectStore';
import { HttpFileStore } from '../../src/core/store/HttpFileStore';
import { pluginsOf } from '../../src/core/store/projectPlugins';
import { StaleRevisionError } from '../../src/core/store/ProjectStore';
import { makeProject } from '../helpers';
const FILE = `${'a'.repeat(64)}.png`;
/** What nginx answers when it refuses a request body larger than its 1 MB limit. */
const NGINX_413 = '<html><head><title>413 Request Entity Too Large</title></head><body></body></html>';
/** Replaces `fetch` with one that always answers the same way; the mock records the request. */
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('HttpProjectStore', () => {
it('reads a JSON body and keeps the server message on failure', async () => {
stubFetch(JSON.stringify([{ id: 'p', name: 'P', revision: 1, updatedAt: 'now' }]), { status: 200 });
await expect(new HttpProjectStore('/api').list()).resolves.toHaveLength(1);
stubFetch(JSON.stringify({ error: 'Project not found' }), { status: 404 });
await expect(new HttpProjectStore('/api').load('p')).rejects.toThrow(/Project not found \(404\)/);
});
it('explains a non-JSON error body by its status instead of calling it invalid JSON', async () => {
stubFetch(NGINX_413, { status: 413, statusText: 'Request Entity Too Large' });
const { project } = makeProject();
await expect(new HttpProjectStore('/api').save(project, 1)).rejects.toThrow(/too large \(413\)/i);
});
it('still reports a successful response that is not JSON as a defect', async () => {
stubFetch('<html>hello</html>', { status: 200 });
await expect(new HttpProjectStore('/api').list()).rejects.toThrow(/invalid JSON \(200\)/);
});
/**
* The editor loads the project's plugins in `prepare` (#119). What they register – node types,
* asset kinds – has to be there before the JSON is read, or the deserializer drops it as unknown.
*/
it('runs prepare on the raw JSON and waits for it before deserializing', async () => {
stubFetch(JSON.stringify({ revision: 4, project: { id: 'p', name: 'P', plugins: ['demo.js'] } }), {
status: 200,
});
let seen: unknown;
const loaded = await new HttpProjectStore('/api').load('p', async (raw) => {
seen = raw;
await Promise.resolve();
// Whatever prepare does has to be part of what is read: this stands for the asset kinds and
// node types a plugin registers while it is being activated.
(raw as { assets: unknown[] }).assets = [{ id: 's', type: 'scene', name: 'Level' }];
});
expect(pluginsOf(seen)).toEqual(['demo.js']);
expect(loaded.project.plugins).toEqual(['demo.js']);
expect(loaded.project.assets.map((a) => a.name)).toEqual(['Level']);
});
it('turns a 409 into a StaleRevisionError', async () => {
stubFetch(JSON.stringify({ error: 'Project was modified elsewhere', revision: 7 }), { status: 409 });
const { project } = makeProject();
await expect(new HttpProjectStore('/api').save(project, 3)).rejects.toBeInstanceOf(StaleRevisionError);
});
});
describe('HttpFileStore', () => {
it('uploads the raw bytes and returns what the server stored', async () => {
const mock = stubFetch(JSON.stringify({ file: FILE, width: 2, height: 3, bytes: 4 }), { status: 201 });
const bytes = new Blob([new Uint8Array([1, 2, 3, 4])]);
const stored = await new HttpFileStore('/api', 'textures').upload(bytes, 'image/png');
expect(stored).toEqual({ file: FILE, width: 2, height: 3, bytes: 4 });
const [url, init] = mock.mock.calls[0]!;
expect(url).toBe('/api?r=textures');
expect(init?.method).toBe('POST');
// The body *is* the image: no multipart wrapper, no JSON.
expect(init?.body).toBe(bytes);
expect(init?.headers).toEqual({ 'Content-Type': 'image/png' });
});
it('sends the file name beside the route, and omits it when there is none', async () => {
const answer = JSON.stringify({ file: FILE, name: 'hero idle.png', width: 2, height: 3, bytes: 4 });
const bytes = new Blob([new Uint8Array(4)]);
let mock = stubFetch(answer, { status: 201 });
await new HttpFileStore('/api', 'textures').upload(bytes, 'image/png', 'hero idle.png');
// The body stays the raw image, so the name travels in the query – encoded, not as a path.
expect(mock.mock.calls[0]![0]).toBe('/api?r=textures&name=hero%20idle.png');
mock = stubFetch(answer, { status: 201 });
await new HttpFileStore('/api', 'textures').upload(bytes, 'image/png');
expect(mock.mock.calls[0]![0]).toBe('/api?r=textures');
});
it('reports the nginx size limit as a size problem', async () => {
stubFetch(NGINX_413, { status: 413, statusText: 'Request Entity Too Large' });
const bytes = new Blob([new Uint8Array(8)]);
await expect(new HttpFileStore('/api', 'textures').upload(bytes, 'image/png')).rejects.toThrow(
/too large \(413\)/i,
);
});
it('deletes a file of a folder under its route, the slash encoded', async () => {
const mock = stubFetch(null, { status: 204 });
await new HttpFileStore('/api', 'sounds').delete(`0123456789ab/${FILE}`);
expect(mock.mock.calls[0]![0]).toBe(`/api?r=sounds%2F0123456789ab%2F${FILE}`);
expect(mock.mock.calls[0]![1]?.method).toBe('DELETE');
});
it('hands on the reason a refused delete gives, as it is', async () => {
stubFetch(JSON.stringify({ error: 'Still used by “Garden”' }), { status: 409 });
await expect(new HttpFileStore('/api', 'sounds').delete(FILE)).rejects.toThrow(
/^Still used by “Garden”$/,
);
});
it('lists', async () => {
stubFetch(JSON.stringify([{ file: FILE, width: 8, height: 4, bytes: 12, updatedAt: 'now' }]), {
status: 200,
});
await expect(new HttpFileStore('/api', 'textures').list()).resolves.toEqual([
{ file: FILE, width: 8, height: 4, bytes: 12, updatedAt: 'now' },
]);
});
});