Files
electric-cherry/tests/unit/game-file-storage.test.ts

100 lines
4.1 KiB
TypeScript

import { createHash } from 'node:crypto';
import { mkdtemp, mkdir, readFile, readdir, rm, symlink } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import {
assertRegularPath,
manifestChecksum,
parseByteRange,
receiveGameFile,
validateManifest,
validateGamePath,
verifyGameFile
} from '../../src/lib/server/game-files/storage.js';
const sha = (data: string) => createHash('sha256').update(data).digest('hex');
const entry = (path = 'zone/file.ff', data = 'game data') => ({
path,
size: Buffer.byteLength(data),
sha256: sha(data)
});
const stream = (value: string) => new Response(value).body!;
let directory: string;
afterEach(async () => {
if (directory) await rm(directory, { recursive: true, force: true });
});
describe('game-file manifests', () => {
it.each([
'../escape',
'/absolute',
'a/../b',
'a//b',
'a\\b',
'C:/file',
'./file',
'a\0b',
'a/file.',
'a/file '
])('rejects unsafe or ambiguous path %s', (path) => {
expect(() => validateGamePath(path)).toThrow();
});
it('canonicalizes file order, rejects duplicates, and hashes the canonical manifest', () => {
const files = validateManifest([entry('z.ff'), entry('a.ff')]);
expect(files.map((file) => file.path)).toEqual(['a.ff', 'z.ff']);
expect(manifestChecksum(files)).toBe(sha(JSON.stringify(files)));
expect(() => validateManifest([entry('A.ff'), entry('a.ff')])).toThrow(/Duplicate/);
expect(() => validateManifest([entry('zone'), entry('zone/file.ff')])).toThrow(
/file and a directory/
);
expect(() => validateManifest([{ ...entry(), size: -1 }])).toThrow();
expect(() => validateManifest([{ ...entry(), sha256: 'bad' }])).toThrow();
});
});
describe('streaming storage integrity', () => {
it('atomically accepts only complete verified streams and cleans partial uploads', async () => {
directory = await mkdtemp(join(tmpdir(), 'ec-game-storage-'));
const file = entry();
await expect(receiveGameFile(directory, file, stream('bad'))).rejects.toThrow(/mismatch/);
expect(await readdir(join(directory, 'zone'))).toEqual([]);
await expect(
receiveGameFile(directory, file, stream('game data too long'))
).rejects.toThrow(/exceeds/);
await receiveGameFile(directory, file, stream('game data'));
expect(await readFile(join(directory, file.path), 'utf8')).toBe('game data');
await verifyGameFile(directory, file);
await expect(
verifyGameFile(directory, { ...file, sha256: sha('something') })
).rejects.toThrow(/Checksum/);
});
it('refuses symlinks before writing or reading shared files', async () => {
directory = await mkdtemp(join(tmpdir(), 'ec-game-symlink-'));
const root = join(directory, 'root');
await mkdir(root);
const outside = join(directory, 'outside');
await mkdir(outside);
await symlink(outside, join(root, 'zone'));
await expect(receiveGameFile(root, entry(), stream('game data'))).rejects.toThrow(
/non-regular/
);
await expect(assertRegularPath(root, 'zone/file.ff', true)).rejects.toThrow(/non-regular/);
expect(await readdir(outside)).toEqual([]);
});
});
describe('HTTP download resume ranges', () => {
it('supports open, bounded, suffix, and clipped ranges', () => {
expect(parseByteRange(null, 10)).toBeNull();
expect(parseByteRange('bytes=3-', 10)).toEqual({ start: 3, end: 9 });
expect(parseByteRange('bytes=3-5', 10)).toEqual({ start: 3, end: 5 });
expect(parseByteRange('bytes=-3', 10)).toEqual({ start: 7, end: 9 });
expect(parseByteRange('bytes=3-99', 10)).toEqual({ start: 3, end: 9 });
});
it.each(['bytes=10-', 'bytes=-0', 'bytes=5-3', 'bytes=0-1,3-4', 'bytes=-', 'not-a-range'])(
'rejects unsatisfiable %s',
(range) => {
expect(() => parseByteRange(range, 10)).toThrow();
}
);
});