Files
electric-cherry/tests/unit/game-file-library.test.ts
T
josh e5c0fda1ed Add native host management and role-based operator authentication
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.
2026-10-03 11:42:36 -04:00

105 lines
8.0 KiB
TypeScript

import Database from 'better-sqlite3';
import { createHash } from 'node:crypto';
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { 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 { createEnrollment, createHost, enrollHost, getHostOperation, reportHostOperation, revokeCredentials } from '../../src/lib/server/hosts/index.js';
import { createLibraryImport, getHostLibraryCache, getLibraryVersion, importPublicationStatus, listLibraries, publishImport, queueLibraryDownload, requestImportPublication, requireReadyLibrary, setImportManifest, updateHostLibraryCaches, uploadImportFile } from '../../src/lib/server/game-files/index.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; let hostId: string; let credential: string;
const data = 'real game bytes';
const file = { path: 'zone/game.ff', size: Buffer.byteLength(data), sha256: createHash('sha256').update(data).digest('hex') };
beforeEach(async () => {
state.handle = new Database(':memory:'); state.handle.pragma('foreign_keys=ON');
directory = await mkdtemp(join(tmpdir(), 'ec-libraries-')); vi.stubEnv('GAME_FILES_ROOT', directory);
const host = createHost({ name: 'Debian host' }); hostId = host.id;
const enrollment = createEnrollment(host.id);
credential = enrollHost(enrollment.token, { protocolVersion: 1, dockerRunning: true, directoriesWritable: true, storageAvailable: 1_000_000_000_000 }).credential;
});
afterEach(async () => { state.handle?.close(); await rm(directory, { recursive: true, force: true }); vi.unstubAllEnvs(); });
function begin(version = '1') { return createLibraryImport({ hostId, game: 't6', name: 'Black Ops II', version, description: 'Complete files', dlc: ['Origins'] }); }
async function published() {
const version = begin(); setImportManifest(hostId, version.id, [file]);
await uploadImportFile(hostId, version.id, file.path, new Response(data).body);
return publishImport(hostId, version.id);
}
describe('game-file import lifecycle', () => {
it('publishes verified filesystem content and permanently pins manifest metadata', async () => {
const initial = begin();
expect(getHostOperation(initial.operationId!)?.payload.sourceVolume).toBe('t6_game_files');
setImportManifest(hostId, initial.id, [file]);
await expect(publishImport(hostId, initial.id)).rejects.toThrow();
expect(getLibraryVersion(initial.id)?.state).toBe('importing');
await uploadImportFile(hostId, initial.id, file.path, new Response(data).body);
const ready = await publishImport(hostId, initial.id);
expect(ready.state).toBe('ready'); expect(ready.sizeBytes).toBe(data.length);
expect(await readFile(join(ready.storageLocation, file.path), 'utf8')).toBe(data);
expect((await publishImport(hostId, ready.id)).id).toBe(ready.id);
await expect(uploadImportFile(hostId, ready.id, file.path, new Response(data).body)).rejects.toThrow(/not writable/);
expect(() => state.handle!.prepare('UPDATE game_file_versions SET description=? WHERE id=?').run('changed', ready.id)).toThrow(/immutable/);
expect(() => begin()).toThrow(/already exists/);
expect(begin('2').id).not.toBe(ready.id);
});
it('restricts an import to its source host and rejects manifest mutation', () => {
const version = begin();
expect(() => setImportManifest('another-host', version.id, [file])).toThrow(/not found/);
const first = setImportManifest(hostId, version.id, [file]);
expect(setImportManifest(hostId, version.id, [file]).manifestSha256).toBe(first.manifestSha256);
expect(() => setImportManifest(hostId, version.id, [{ ...file, path: 'changed.ff' }])).toThrow(/cannot change/);
});
it('surfaces durable agent failures while preserving historical versions', () => {
const version = begin();
reportHostOperation(hostId, version.operationId!, { state: 'failed', error: 'Source volume not found' });
expect(listLibraries()[0].versions[0]).toMatchObject({ state: 'failed', error: 'Source volume not found' });
});
it('returns a pollable publication status while large files are verified asynchronously', async () => {
const version = begin(); setImportManifest(hostId, version.id, [file]);
await uploadImportFile(hostId, version.id, file.path, new Response(data).body);
expect(importPublicationStatus(hostId, version.id).status).toBe('pending');
expect(requestImportPublication(hostId, version.id).status).toBe('verifying');
expect(requestImportPublication(hostId, version.id).status).toBe('verifying');
await publishImport(hostId, version.id);
expect(importPublicationStatus(hostId, version.id)).toMatchObject({ status: 'published', version: { id: version.id, manifestSha256: expect.any(String) } });
});
});
describe('verified host distribution', () => {
it('deduplicates active downloads and blocks readiness without exact manifest verification', async () => {
const version = await published();
const operation = queueLibraryDownload(hostId, version.id);
expect(queueLibraryDownload(hostId, version.id).id).toBe(operation.id);
expect(() => requireReadyLibrary(hostId, version.id)).toThrow(/finish downloading/);
updateHostLibraryCaches(hostId, [{ libraryVersionId: version.id, status: 'ready', manifestSha256: 'wrong' }]);
expect(getHostLibraryCache(hostId, version.id)?.status).toBe('failed');
updateHostLibraryCaches(hostId, [{ libraryVersionId: version.id, status: 'ready', manifestSha256: version.manifestSha256 }]);
expect(requireReadyLibrary(hostId, version.id).id).toBe(version.id);
updateHostLibraryCaches(hostId, [{ libraryVersionId: version.id, status: 'outdated', manifestSha256: version.manifestSha256 }]);
expect(requireReadyLibrary(hostId, version.id).id).toBe(version.id);
updateHostLibraryCaches(hostId, [{ libraryVersionId: version.id, status: 'ready', bytesDownloaded: 1, manifestSha256: version.manifestSha256 }]);
expect(getHostLibraryCache(hostId, version.id)?.status).toBe('failed');
updateHostLibraryCaches(hostId, [], { snapshot: true });
expect(getHostLibraryCache(hostId, version.id)?.status).toBe('missing');
expect(() => requireReadyLibrary(hostId, version.id)).toThrow(/finish downloading/);
});
it('streams resumable byte ranges and guards the immutable manifest endpoint with revocable host credentials', async () => {
const version = await published();
const request = (headers: HeadersInit = {}) => new Request('https://controller/api/agent/libraries/' + version.id + '/manifest', { headers });
expect(manifestRoute({ request: request(), params: { id: version.id } }).status).toBe(401);
const authorized = request({ Authorization: 'Bearer ' + credential });
expect(manifestRoute({ request: authorized, params: { id: version.id } }).status).toBe(200);
const partial = await serveGameFile(request({ Range: 'bytes=5-' }), version.id, file.path);
expect(partial.status).toBe(206); expect(partial.headers.get('content-range')).toBe(`bytes 5-${data.length - 1}/${data.length}`);
expect(await partial.text()).toBe(data.slice(5));
const invalid = await serveGameFile(request({ Range: 'bytes=999-' }), version.id, file.path);
expect(invalid.status).toBe(416);
const resumed = await serveGameFile(request({ Range: 'bytes=5-', 'If-Range': '"different"' }), version.id, file.path);
expect(resumed.status).toBe(200); expect(await resumed.text()).toBe(data);
revokeCredentials(hostId);
expect(manifestRoute({ request: authorized, params: { id: version.id } }).status).toBe(401);
});
});