Support resumable game folder uploads and remove DLC declarations

This commit is contained in:
2026-10-04 09:51:45 -04:00
parent 95cf7dd36f
commit 840f02b53e
22 changed files with 1121 additions and 102 deletions
+2 -1
View File
@@ -49,7 +49,8 @@ describe('management route permissions', () => {
['/api/hosts', 'POST'], ['/api/hosts/[id]', 'PATCH'], ['/api/hosts/[id]/enrollment', 'POST'],
['/api/hosts/[id]/credentials', 'DELETE'], ['/api/hosts/[id]/credentials', 'POST'],
['/api/hosts/[id]/operations', 'POST'], ['/api/settings', 'PUT'], ['/api/game-files', 'POST'],
['/api/game-files/[id]/download', 'POST'], ['/api/maintenance', 'POST'], ['/api/watchdog', 'POST'], ['/api/plutonium', 'POST']
['/api/game-files/[id]/download', 'POST'], ['/api/game-files/[id]/upload/files/[...path]', 'PUT'],
['/api/game-files/[id]/upload/complete', 'POST'], ['/api/maintenance', 'POST'], ['/api/watchdog', 'POST'], ['/api/plutonium', 'POST']
])('protects host, game-file, and global policy mutations at %s', (route, method) => {
expect(routePermission(route, method)).toBe('admin');
expect(allows('operator', route, method)).toBe(false);
+227
View File
@@ -0,0 +1,227 @@
import Database from 'better-sqlite3';
import { createHash } from 'node:crypto';
import { mkdir, mkdtemp, readFile, rename, rm, stat } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const state = vi.hoisted(() => ({ handle: null as Database.Database | null }));
vi.mock('../../src/lib/server/db/index.js', () => ({ db: () => state.handle }));
import {
browserUploadStatus, createLibraryImport, createLibraryUpload, getHostLibraryCache,
getLibraryVersion, listLibraries, publishBrowserUpload, queueLibraryDownload,
requestBrowserPublication, requireReadyLibrary, updateHostLibraryCaches, uploadBrowserFile
} from '../../src/lib/server/game-files/index.js';
import { createEnrollment, createHost, enrollHost, listHosts } from '../../src/lib/server/hosts/index.js';
import { manifestChecksum } from '../../src/lib/server/game-files/storage.js';
import { serveGameFile } from '../../src/lib/server/game-files/http.js';
import { GET as manifestRoute } from '../../src/routes/api/agent/libraries/[id]/manifest/+server.js';
let directory: string;
const modified = 1_700_000_000_000;
const bytes = Buffer.from('verified Plutonium game content');
const sha = (value: Uint8Array) => createHash('sha256').update(value).digest('hex');
const file = (path = 'zone/base.ff', content = bytes) => ({ path, size: content.length, lastModified: modified });
const upload = (id: string, path: string, content: Buffer, offset = 0, checksum = sha(content)) =>
uploadBrowserFile(id, path, { offset, size: content.length, sha256: checksum }, new Response(new Uint8Array(content)).body);
const begin = (game: 't5' | 't6' = 't6', files = [file()], version = '1') =>
createLibraryUpload({ game, name: `Plutonium ${game.toUpperCase()}`, version, description: 'Uploaded game folder', files });
beforeEach(async () => {
directory = await mkdtemp(join(tmpdir(), 'ec-browser-library-'));
state.handle = new Database(join(directory, 'controller.sqlite'));
state.handle.pragma('foreign_keys=ON');
vi.stubEnv('GAME_FILES_ROOT', join(directory, 'libraries'));
});
afterEach(async () => {
state.handle?.close(); state.handle = null;
await rm(directory, { recursive: true, force: true });
vi.unstubAllEnvs();
});
function host() {
const created = createHost({ name: 'Fresh download target' });
const { credential } = enrollHost(createEnrollment(created.id).token, {
protocolVersion: 1, dockerRunning: true, directoriesWritable: true, storageAvailable: 100 * 1024 ** 3
});
return { id: created.id, credential };
}
describe('browser folder uploads', () => {
it.each(['t5', 't6'] as const)('publishes %s from a browser without an enrolled host or DLC declarations', async game => {
expect(listHosts()).toEqual([]);
const initial = begin(game);
expect(initial).toMatchObject({ state: 'importing', operationId: null, sourceHostId: null, sourceVolume: null });
expect(initial).not.toHaveProperty('dlc');
expect(await browserUploadStatus(initial.id)).toMatchObject({
status: 'pending', files: [{ ...file(), receivedBytes: 0, complete: false }]
});
await upload(initial.id, file().path, bytes);
const accepted = await requestBrowserPublication(initial.id);
expect(['verifying', 'published']).toContain(accepted.status);
const ready = await publishBrowserUpload(initial.id);
expect(ready).toMatchObject({ state: 'ready', sizeBytes: bytes.length, fileCount: 1 });
expect(ready.files).toEqual([{ path: file().path, size: bytes.length, sha256: sha(bytes) }]);
expect(ready.manifestSha256).toBe(manifestChecksum(ready.files));
expect(await readFile(join(ready.storageLocation, file().path))).toEqual(bytes);
expect(await browserUploadStatus(initial.id)).toMatchObject({ status: 'published' });
expect(listLibraries()[0].versions[0]).not.toHaveProperty('dlc');
expect(listHosts()).toEqual([]);
});
it('preserves nested relative contents and derives a canonical manifest in path order', async () => {
const selected = [file('zone/english/a.ff'), file('main/iw_00.iwd'), file('empty.txt', Buffer.alloc(0))];
const initial = begin('t6', selected);
for (const item of selected) await upload(initial.id, item.path, item.size ? bytes : Buffer.alloc(0));
const ready = await publishBrowserUpload(initial.id);
expect(ready.files.map(item => item.path)).toEqual(['empty.txt', 'main/iw_00.iwd', 'zone/english/a.ff']);
expect(ready.files[0].sha256).toBe(sha(Buffer.alloc(0)));
expect(ready.manifestSha256).toBe(manifestChecksum(ready.files));
expect(await readFile(join(ready.storageLocation, 'zone/english/a.ff'))).toEqual(bytes);
});
it('rejects incomplete publication and allows the remaining files to finish the same version', async () => {
const initial = begin('t5', [file('zone/first.ff'), file('zone/second.ff')]);
await upload(initial.id, 'zone/first.ff', bytes);
await expect(publishBrowserUpload(initial.id)).rejects.toThrow();
expect(getLibraryVersion(initial.id)?.state).not.toBe('ready');
const hidden = await serveGameFile(new Request('https://ec.test/files'), initial.id, 'zone/first.ff');
expect(hidden.status).toBe(404);
await upload(initial.id, 'zone/second.ff', bytes);
const ready = await publishBrowserUpload(initial.id);
expect(ready.state).toBe('ready');
expect(ready.id).toBe(initial.id);
});
it('does not acknowledge corrupt chunks and permits a verified retry', async () => {
const initial = begin();
await expect(upload(initial.id, file().path, bytes, 0, '0'.repeat(64))).rejects.toThrow();
expect((await browserUploadStatus(initial.id)).files[0]).toMatchObject({ receivedBytes: 0, complete: false });
await expect(uploadBrowserFile(initial.id, file().path, {
offset: 0, size: bytes.length, sha256: sha(bytes)
}, new Response(bytes.subarray(0, -1)).body)).rejects.toThrow();
expect((await browserUploadStatus(initial.id)).files[0].receivedBytes).toBe(0);
await upload(initial.id, file().path, bytes);
expect((await browserUploadStatus(initial.id)).files[0]).toMatchObject({ receivedBytes: bytes.length, complete: true });
expect((await publishBrowserUpload(initial.id)).state).toBe('ready');
});
it('surfaces asynchronous incomplete publication as retryable failure', async () => {
const initial = begin();
await requestBrowserPublication(initial.id);
await vi.waitFor(async () => expect((await browserUploadStatus(initial.id)).status).toBe('failed'));
expect((await browserUploadStatus(initial.id)).error).toBeTruthy();
await upload(initial.id, file().path, bytes);
expect(await browserUploadStatus(initial.id)).toMatchObject({ status: 'pending', error: null });
await requestBrowserPublication(initial.id);
expect((await publishBrowserUpload(initial.id)).state).toBe('ready');
});
it('resumes from the verified chunk boundary and safely retries a lost acknowledgement', async () => {
const sizeProbe = begin();
const chunkSize = (await browserUploadStatus(sizeProbe.id)).chunkSize;
expect(chunkSize).toBeGreaterThan(0);
const large = Buffer.alloc(chunkSize + 17, 0x41);
const initial = begin('t6', [file('main/archive.iwd', large)], 'large');
const first = large.subarray(0, chunkSize);
await upload(initial.id, 'main/archive.iwd', first);
expect((await browserUploadStatus(initial.id)).files[0]).toMatchObject({ receivedBytes: chunkSize, complete: false });
state.handle!.close();
state.handle = new Database(join(directory, 'controller.sqlite'));
state.handle.pragma('foreign_keys=ON');
await expect(upload(initial.id, 'main/archive.iwd', first)).rejects.toMatchObject({ receivedBytes: chunkSize });
expect((await browserUploadStatus(initial.id)).files[0].receivedBytes).toBe(chunkSize);
await upload(initial.id, 'main/archive.iwd', large.subarray(chunkSize), chunkSize);
const ready = await publishBrowserUpload(initial.id);
expect(ready.files[0].sha256).toBe(sha(large));
expect((await readFile(join(ready.storageLocation, 'main/archive.iwd'))).equals(large)).toBe(true);
});
it('rejects unknown file paths, traversal, and chunks outside the declared file', async () => {
const initial = begin();
await expect(upload(initial.id, 'zone/unselected.ff', bytes)).rejects.toThrow();
await expect(upload(initial.id, '../escape.ff', bytes)).rejects.toThrow();
await expect(upload(initial.id, file().path, bytes, bytes.length + 1)).rejects.toThrow();
await expect(upload(initial.id, file().path, Buffer.concat([bytes, bytes]))).rejects.toThrow();
expect((await browserUploadStatus(initial.id)).files[0].receivedBytes).toBe(0);
});
it('keeps ready bytes and version metadata immutable and requires a new version label', async () => {
const initial = begin(); await upload(initial.id, file().path, bytes);
const ready = await publishBrowserUpload(initial.id);
await expect(upload(initial.id, file().path, bytes)).rejects.toThrow();
expect(() => state.handle!.prepare('UPDATE game_file_versions SET description=? WHERE id=?').run('changed', ready.id)).toThrow(/immutable/);
expect(() => begin()).toThrow(/already exists/);
expect((await publishBrowserUpload(initial.id)).manifestSha256).toBe(ready.manifestSha256);
expect(begin('t6', [file()], '2').id).not.toBe(ready.id);
expect(await readFile(join(ready.storageLocation, file().path))).toEqual(bytes);
});
it('recovers publication after the verified directory was renamed but the ready commit was interrupted', async () => {
const initial = begin(); await upload(initial.id, file().path, bytes);
const files = [{ path: file().path, size: bytes.length, sha256: sha(bytes) }];
const checksum = manifestChecksum(files);
state.handle!.transaction(() => {
state.handle!.prepare('UPDATE game_file_versions SET manifest=?,manifest_sha256=? WHERE id=?')
.run(JSON.stringify(files), checksum, initial.id);
state.handle!.prepare('INSERT INTO game_file_entries(version_id,path,size,sha256) VALUES(?,?,?,?)')
.run(initial.id, file().path, bytes.length, sha(bytes));
})();
await mkdir(dirname(initial.storageLocation), { recursive: true });
await rename(join(directory, 'libraries', '.imports', initial.id), initial.storageLocation);
state.handle!.close(); state.handle = new Database(join(directory, 'controller.sqlite'));
state.handle.pragma('foreign_keys=ON');
expect(await browserUploadStatus(initial.id)).toMatchObject({ status: 'pending', files: [{ complete: true }] });
const ready = await publishBrowserUpload(initial.id);
expect(ready).toMatchObject({ id: initial.id, state: 'ready', manifestSha256: checksum, files });
expect(await readFile(join(ready.storageLocation, file().path))).toEqual(bytes);
});
it('rejects chunks racing with final publication without recreating staging files', async () => {
const initial = begin(); await upload(initial.id, file().path, bytes);
const publishing = publishBrowserUpload(initial.id);
await expect(upload(initial.id, file().path, bytes)).rejects.toMatchObject({ status: 409 });
const ready = await publishing;
expect(ready.state).toBe('ready');
expect(await readFile(join(ready.storageLocation, file().path))).toEqual(bytes);
await expect(stat(join(directory, 'libraries', '.imports', initial.id))).rejects.toMatchObject({ code: 'ENOENT' });
});
it('rejects browser operations against an existing host-sourced import', async () => {
const source = host();
const legacy = createLibraryImport({ hostId: source.id, game: 't6', name: 'Legacy import', version: '1' });
expect(legacy.sourceHostId).toBe(source.id);
await expect(Promise.resolve().then(() => browserUploadStatus(legacy.id))).rejects.toThrow();
await expect(upload(legacy.id, file().path, bytes)).rejects.toThrow();
await expect(publishBrowserUpload(legacy.id)).rejects.toThrow();
await expect(Promise.resolve().then(() => requestBrowserPublication(legacy.id))).rejects.toThrow();
expect(getLibraryVersion(legacy.id)?.state).toBe('importing');
});
it('distributes uploaded versions through the unchanged agent manifest, ranges, and verified cache contract', async () => {
const initial = begin('t5'); await upload(initial.id, file().path, bytes);
const ready = await publishBrowserUpload(initial.id);
const target = host();
const operation = queueLibraryDownload(target.id, ready.id);
expect(operation).toMatchObject({ kind: 'library.download', payload: {
libraryVersionId: ready.id, game: 't5', manifestSha256: ready.manifestSha256,
manifestUrl: `/api/agent/libraries/${ready.id}/manifest`, filesBaseUrl: `/api/agent/libraries/${ready.id}/files`
} });
const manifest = manifestRoute({ request: new Request(`https://ec.test/api/agent/libraries/${ready.id}/manifest`, {
headers: { Authorization: `Bearer ${target.credential}` }
}), params: { id: ready.id } });
expect(manifest.status).toBe(200);
expect(await manifest.json()).toEqual({ id: ready.id, game: 't5', files: ready.files, sizeBytes: bytes.length, manifestSha256: ready.manifestSha256 });
const range = await serveGameFile(new Request('https://ec.test/file', { headers: { Range: 'bytes=9-' } }), ready.id, file().path);
expect(range.status).toBe(206);
expect(Buffer.from(await range.arrayBuffer())).toEqual(bytes.subarray(9));
expect(() => requireReadyLibrary(target.id, ready.id)).toThrow(/finish downloading/);
updateHostLibraryCaches(target.id, [{ libraryVersionId: ready.id, status: 'ready', manifestSha256: '0'.repeat(64) }]);
expect(getHostLibraryCache(target.id, ready.id)?.status).toBe('failed');
updateHostLibraryCaches(target.id, [{ libraryVersionId: ready.id, status: 'ready', manifestSha256: ready.manifestSha256 }]);
expect(requireReadyLibrary(target.id, ready.id).id).toBe(ready.id);
const replacement = begin('t5', [file()], '2'); await upload(replacement.id, file().path, bytes);
await publishBrowserUpload(replacement.id);
expect(requireReadyLibrary(target.id, ready.id).manifestSha256).toBe(ready.manifestSha256);
});
});
+180
View File
@@ -0,0 +1,180 @@
import { createHash } from 'node:crypto';
import { mkdir, mkdtemp, readFile, readdir, rm, symlink, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
getUploadFileProgress, hashUploadedFiles, MAX_UPLOAD_CHUNK_BYTES,
receiveUploadChunk, UploadOffsetError, validateUploadPlan, withUploadLock
} from '../../src/lib/server/game-files/uploads.js';
import { manifestChecksum, verifyGameFile } from '../../src/lib/server/game-files/storage.js';
const sha = (value: string | Uint8Array) => createHash('sha256').update(value).digest('hex');
const stream = (value: string) => new Response(value).body!;
const file = { path: 'zone/english/map.ff', size: 10, lastModified: 1234 };
const chunk = (value: string, offset = 0) => ({ offset, size: Buffer.byteLength(value), sha256: sha(value) });
let directory: string;
let root: string;
beforeEach(async () => {
directory = await mkdtemp(join(tmpdir(), 'ec-folder-upload-'));
root = join(directory, 'import');
});
afterEach(async () => { await rm(directory, { recursive: true, force: true }); });
describe('browser folder upload plans', () => {
it('preserves folder structure and source identity while sorting canonical paths', () => {
expect(validateUploadPlan([{ path: 'zone/map.ff', size: 10, lastModified: 100 }, { path: 'main/data.iwd', size: 0 }])).toEqual([
{ path: 'main/data.iwd', size: 0, lastModified: 0 },
{ path: 'zone/map.ff', size: 10, lastModified: 100 }
]);
});
it.each([
[], [{ path: '../zone.ff', size: 1 }], [{ path: 'zone\\map.ff', size: 1 }],
[{ path: 'Zone/a', size: 1 }, { path: 'zone/A', size: 1 }],
[{ path: 'ZONE', size: 1 }, { path: 'zone/a', size: 1 }],
[{ path: 'zone/a', size: -1 }], [{ path: 'zone/a', size: 1.5 }],
[{ path: 'zone/a', size: Number.MAX_SAFE_INTEGER }, { path: 'zone/b', size: 1 }],
[{ path: 'zone/a', size: 1, lastModified: -1 }], [{ path: 'zone/a', size: 1, lastModified: 1.5 }],
[{ path: 'zone/a', size: 1, lastModified: null }]
].map(plan => ({ plan })))('rejects ambiguous or unsafe plan $plan', ({ plan }) => {
expect(() => validateUploadPlan(plan)).toThrow();
});
});
describe('bounded resumable file transfer', () => {
it('resumes arbitrary chunks by filesystem offset and produces a distribution-compatible manifest', async () => {
expect(await getUploadFileProgress(root, file)).toEqual({ receivedBytes: 0, complete: false });
expect(await receiveUploadChunk(root, file, chunk('0123'), stream('0123'))).toEqual({ receivedBytes: 4, complete: false });
// A new request discovers its checkpoint from disk; no process-local receipt is required.
expect(await getUploadFileProgress(root, file)).toEqual({ receivedBytes: 4, complete: false });
expect(await receiveUploadChunk(root, file, chunk('456789', 4), stream('456789'))).toEqual({ receivedBytes: 10, complete: true });
expect(await readFile(join(root, file.path), 'utf8')).toBe('0123456789');
const manifest = await withUploadLock(root, () => hashUploadedFiles(root, [file]));
expect(manifest).toEqual([{ path: file.path, size: 10, sha256: sha('0123456789') }]);
expect(manifestChecksum(manifest)).toBe(sha(JSON.stringify(manifest)));
await verifyGameFile(root, manifest[0]);
expect(await readdir(root)).toEqual(['zone']);
expect(await readdir(`${root}.chunks`)).toEqual([]);
});
it('reports committed progress after reply loss instead of appending duplicate bytes', async () => {
await receiveUploadChunk(root, file, chunk('0123'), stream('0123'));
await expect(receiveUploadChunk(root, file, chunk('0123'), stream('0123'))).rejects.toMatchObject({
name: 'UploadOffsetError', receivedBytes: 4, status: 409
});
await expect(receiveUploadChunk(root, file, chunk('89', 8), stream('89'))).rejects.toBeInstanceOf(UploadOffsetError);
expect(await readFile(join(root, file.path), 'utf8')).toBe('0123');
await receiveUploadChunk(root, file, chunk('456789', 4), stream('456789'));
});
it('can resume a verified append interrupted by process termination at an unaligned byte', async () => {
await mkdir(join(root, 'zone/english'), { recursive: true });
await writeFile(join(root, file.path), '0123456');
expect(await getUploadFileProgress(root, file)).toEqual({ receivedBytes: 7, complete: false });
await receiveUploadChunk(root, file, chunk('789', 7), stream('789'));
expect(await readFile(join(root, file.path), 'utf8')).toBe('0123456789');
});
it('distinguishes an absent empty file and creates it using the empty digest', async () => {
const empty = { path: 'main/empty.dat', size: 0 };
expect(await getUploadFileProgress(root, empty)).toEqual({ receivedBytes: 0, complete: false });
await expect(hashUploadedFiles(root, [empty])).rejects.toThrow();
expect(await receiveUploadChunk(root, empty, chunk(''), null)).toEqual({ receivedBytes: 0, complete: true });
expect(await getUploadFileProgress(root, empty)).toEqual({ receivedBytes: 0, complete: true });
expect(await hashUploadedFiles(root, [empty])).toEqual([{ ...empty, sha256: sha('') }]);
});
it('does not advance progress for wrong checksums, truncated requests, oversized streams or disconnects', async () => {
await receiveUploadChunk(root, file, chunk('0123'), stream('0123'));
await expect(receiveUploadChunk(root, file, chunk('456789', 4), stream('xxxxxx'))).rejects.toThrow(/checksum/);
await expect(receiveUploadChunk(root, file, chunk('456789', 4), stream('45'))).rejects.toThrow(/size mismatch/);
await expect(receiveUploadChunk(root, file, chunk('456789', 4), stream('4567890'))).rejects.toThrow(/exceeds/);
let reads = 0;
const disconnect = new ReadableStream<Uint8Array>({ pull(controller) {
if (reads++ === 0) controller.enqueue(new TextEncoder().encode('45'));
else controller.error(new Error('Browser connection ended.'));
} });
await expect(receiveUploadChunk(root, file, chunk('456789', 4), disconnect)).rejects.toThrow(/connection ended/);
expect(await getUploadFileProgress(root, file)).toEqual({ receivedBytes: 4, complete: false });
expect(await readFile(join(root, file.path), 'utf8')).toBe('0123');
expect(await readdir(`${root}.chunks`)).toEqual([]);
await receiveUploadChunk(root, file, chunk('456789', 4), stream('456789'));
});
it('bounds request size before reading any body and rejects invalid metadata', async () => {
const tooLarge = MAX_UPLOAD_CHUNK_BYTES + 1;
await expect(receiveUploadChunk(root, { path: 'large.iwd', size: tooLarge }, {
offset: 0, size: tooLarge, sha256: sha('')
}, null)).rejects.toThrow(/Invalid upload chunk/);
for (const metadata of [
{ ...chunk('a'), offset: -1 }, { ...chunk('a'), offset: 0.5 }, { ...chunk('a'), size: 1.5 },
{ ...chunk('a'), sha256: 'wrong' }, chunk('', 0), chunk('01234567890', 0)
]) await expect(receiveUploadChunk(root, file, metadata, stream('a'))).rejects.toThrow(/Invalid upload chunk/);
await expect(receiveUploadChunk(root, file, chunk('a'), null)).rejects.toThrow(/body is required/);
});
it('streams a maximum-size chunk supplied in small pieces', async () => {
const piece = new Uint8Array(64 * 1024).fill(97);
let remaining = MAX_UPLOAD_CHUNK_BYTES;
const digest = createHash('sha256');
for (let i = 0; i < MAX_UPLOAD_CHUNK_BYTES / piece.length; i++) digest.update(piece);
const body = new ReadableStream<Uint8Array>({ pull(controller) {
if (remaining === 0) controller.close();
else { remaining -= piece.length; controller.enqueue(piece); }
} });
const large = { path: 'main/large.iwd', size: MAX_UPLOAD_CHUNK_BYTES };
const sha256 = digest.digest('hex');
await receiveUploadChunk(root, large, { offset: 0, size: large.size, sha256 }, body);
expect(await hashUploadedFiles(root, [large])).toEqual([{ ...large, sha256 }]);
});
it('rejects missing or incorrectly sized files before publication', async () => {
await receiveUploadChunk(root, file, chunk('0123'), stream('0123'));
await expect(hashUploadedFiles(root, [file])).rejects.toThrow(/Size verification/);
await writeFile(join(root, file.path), '01234567890');
await expect(getUploadFileProgress(root, file)).rejects.toThrow(/exceeds/);
});
it('refuses traversal and symlinks in destination and temporary storage', async () => {
await mkdir(root);
const outside = join(directory, 'outside');
await mkdir(outside);
await expect(receiveUploadChunk(root, { path: '../escape', size: 1 }, chunk('a'), stream('a'))).rejects.toThrow(/relative/);
await symlink(outside, join(root, 'zone'));
await expect(receiveUploadChunk(root, file, chunk('0123'), stream('0123'))).rejects.toThrow(/non-regular/);
await rm(join(root, 'zone'));
await symlink(outside, `${root}.chunks`);
await expect(receiveUploadChunk(root, file, chunk('0123'), stream('0123'))).rejects.toThrow(/non-regular/);
expect(await readdir(outside)).toEqual([]);
});
});
describe('upload and publication coordination', () => {
it('serializes simultaneous writes with the same offset', async () => {
const responses = await Promise.allSettled([
receiveUploadChunk(root, file, chunk('0123'), stream('0123')),
receiveUploadChunk(root, file, chunk('0123'), stream('0123'))
]);
expect(responses.map(response => response.status)).toEqual(['fulfilled', 'rejected']);
expect((responses[1] as PromiseRejectedResult).reason).toMatchObject({ name: 'UploadOffsetError', receivedBytes: 4 });
expect(await readFile(join(root, file.path), 'utf8')).toBe('0123');
});
it('rechecks writable state inside the lock after queued publication and releases failed locks', async () => {
let finishPublication!: () => void;
const gate = new Promise<void>(resolve => { finishPublication = resolve; });
let published = false;
const publication = withUploadLock(root, async () => { await gate; published = true; });
const transfer = receiveUploadChunk(root, file, chunk('0123'), stream('0123'), () => {
if (published) throw new Error('Library is immutable.');
});
finishPublication();
await publication;
await expect(transfer).rejects.toThrow(/immutable/);
expect(await getUploadFileProgress(root, file)).toEqual({ receivedBytes: 0, complete: false });
await expect(withUploadLock(root, () => { throw new Error('Failed work'); })).rejects.toThrow(/Failed work/);
expect(await withUploadLock(root, () => 'next operation')).toBe('next operation');
});
});
+75
View File
@@ -0,0 +1,75 @@
import { describe, expect, it, vi } from 'vitest';
import { selectGameFolder } from '../../src/lib/ui/game-folder.js';
function selectedFile(path: string, size = 10) {
const read = vi.fn(() => { throw new Error('Folder selection must not read file bodies.'); });
const file = {
name: path.split('/').at(-1), webkitRelativePath: path, size, lastModified: 1_700_000_000_000,
arrayBuffer: read, text: read, stream: read, slice: read
} as unknown as File;
return { file, read };
}
describe('game folder selection', () => {
it('removes the selected root exactly once while preserving nested game paths', () => {
const first = selectedFile('T6/T6/zone/english/base.ff', 20);
const second = selectedFile('T6/main/iw_00.iwd', 30);
const result = selectGameFolder([first.file, second.file]);
expect(result.name).toBe('T6');
expect(result.size).toBe(50);
expect(result.files).toEqual([
{ path: 'T6/zone/english/base.ff', file: first.file },
{ path: 'main/iw_00.iwd', file: second.file }
]);
expect(first.read).not.toHaveBeenCalled();
expect(second.read).not.toHaveBeenCalled();
});
it('prepares multi-gigabyte and empty files using metadata alone', () => {
const large = selectedFile('Game/zone/base.ff', 7 * 1024 ** 3);
const empty = selectedFile('Game/empty.txt', 0);
const result = selectGameFolder([large.file, empty.file]);
expect(result.size).toBe(7 * 1024 ** 3);
expect(result.files.map(item => item.path)).toEqual(['empty.txt', 'zone/base.ff']);
expect(result.files[1].file).toBe(large.file);
expect(large.read).not.toHaveBeenCalled();
expect(empty.read).not.toHaveBeenCalled();
});
it('rejects case-insensitive duplicate file names', () => {
expect(() => selectGameFolder([
selectedFile('Game/Zone/base.ff').file,
selectedFile('Game/zone/BASE.ff').file
])).toThrow('duplicate game paths');
});
it('rejects a path used as both a file and a directory regardless of case', () => {
expect(() => selectGameFolder([
selectedFile('Game/zone/english/base.ff').file,
selectedFile('Game/ZONE').file
])).toThrow('same game path for a file and a directory');
});
it.each(['../base.ff', './base.ff', '/Game/base.ff', 'base.ff'])(
'rejects an absent or parent root in %s', path => {
expect(() => selectGameFolder([selectedFile(path).file])).toThrow('one game folder');
}
);
it.each(['Game/../base.ff', 'Game/zone/./base.ff', 'Game/zone//base.ff', 'Game/zone\\base.ff'])(
'rejects traversal and non-normalized content paths in %s', path => {
expect(() => selectGameFolder([selectedFile(path).file])).toThrow('unsupported game path');
}
);
it('rejects files from multiple selected roots', () => {
expect(() => selectGameFolder([
selectedFile('T5/main/base.ff').file,
selectedFile('T6/main/base.ff').file
])).toThrow('one game folder');
});
it('rejects empty selections', () => {
expect(() => selectGameFolder([])).toThrow('contains no files');
});
});