Introduce an outbound Python host agent, Debian installer, durable remote operations, immutable game-file libraries, host-aware deployments and ports, and occupancy-safe campaigns and draining. Native hosts do not require Komodo. Add administrator, operator and viewer accounts, protected management routes, secure sessions, password recovery, audit history and separate telemetry tokens. Document first-admin setup, reporter migration and live-host acceptance checks. Validation: 1,343 TypeScript tests and 39 agent tests passed, along with type checking, the production build, compiled SSR/API smoke tests and administrator recovery. Real Debian/Docker T5/T6 acceptance remains unrun.
62 lines
3.9 KiB
TypeScript
62 lines
3.9 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();
|
|
});
|
|
});
|