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); }); });