import assert from 'node:assert/strict' import { createHash, randomUUID } from 'node:crypto' import { createServer } from 'node:http' import { mkdtemp, readFile, readdir, rm, mkdir, symlink, writeFile } from 'node:fs/promises' import { readdirSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { test, type TestContext } from 'node:test' import { buildScreenPackage, parseScreenPackage } from './screen-package.js' import { PackageStore, packageKey, type DownloadProgress } from './package-store.js' import { packageMediaResponse } from './package-media.js' const highSpace = 1n << 40n const body = Buffer.from('RIFFtest-data-WAVE') function description(server = 'https://media.test') { return buildScreenPackage(server, 7, { game: { game: { ID: 7, name: 'Test', scenario: { image: `${server}/cover.png`, story: { introduction: { audio: `${server}/intro.wav` } }, }, }, }, plan: {}, music: {}, }) } function mediaResponse(bytes: Uint8Array = body, mime = 'audio/wav') { return new Response(Buffer.from(bytes), { headers: { 'Content-Type': mime, 'Content-Length': String(bytes.length) }, }) } async function fixture(t: TestContext) { const dir = await mkdtemp(join(tmpdir(), 'screen-store-')) t.after(() => rm(dir, { recursive: true, force: true })) const root = join(dir, 'packages') const state: { bytes: Buffer free: bigint handler?: (url: string, init?: RequestInit) => Promise } = { bytes: body, free: highSpace } const calls: string[] = [] const delays: number[] = [] const dependencies = { fetch: (async (input, init) => { calls.push(String(input)) assert.equal(new Headers(init?.headers).get('Authorization'), null) assert.equal(init?.credentials, 'omit') assert.equal(init?.redirect, 'manual') return state.handler ? state.handler(String(input), init) : mediaResponse(state.bytes) }) as typeof fetch, freeBytes: async () => state.free, wait: async (milliseconds: number, signal: AbortSignal) => { signal.throwIfAborted() delays.push(milliseconds) }, } const store = new PackageStore(root, dependencies) await store.initialize() const pkg = description() const key = packageKey(pkg.server, pkg.game.id) return { root, dir, state, calls, delays, dependencies, store, pkg, key } } test('загружает, проверяет с диска, восстанавливает после перезапуска и выдаёт офлайн Range', async (t) => { const f = await fixture(t) const progress: DownloadProgress[] = [] const downloaded = await f.store.download(f.pkg, { onProgress: (value) => progress.push(value) }) assert.equal(downloaded.files.length, 2) assert.equal(downloaded.files[0]!.sha256, createHash('sha256').update(body).digest('hex')) assert.deepEqual(progress.at(-1), { stage: 'ready', assetId: null, downloadedBytes: body.length * 2, completedFiles: 2, totalFiles: 2, totalBytes: body.length * 2, }) assert.ok(progress.slice(0, -1).every((p) => p.totalBytes === null)) f.state.handler = async () => { throw new Error('Network disabled') } const restored = new PackageStore(f.root, f.dependencies) await restored.initialize() assert.deepEqual(restored.list(), [downloaded]) for (const asset of f.pkg.assets) { const url = `screen-app://app/packages/${f.key}/${asset.id}` const response = await packageMediaResponse(restored, new Request(url)) assert.equal(response.status, 200) assert.deepEqual(Buffer.from(await response.arrayBuffer()), body) const range = await packageMediaResponse( restored, new Request(url, { headers: { Range: 'bytes=2-5' } }), ) assert.equal(range.status, 206) assert.equal(range.headers.get('content-range'), `bytes 2-5/${body.length}`) assert.deepEqual(Buffer.from(await range.arrayBuffer()), body.subarray(2, 6)) assert.equal( (await packageMediaResponse(restored, new Request(url, { headers: { Range: 'bytes=999-' } }))) .status, 416, ) } assert.equal(f.calls.length, 2) const copy = restored.list() copy[0]!.files.length = 0 assert.equal(restored.list()[0]!.files.length, 2) }) test('новые байты по тому же URL меняют версию; старая версия удаляется после активации', async (t) => { const f = await fixture(t) const first = await f.store.download(f.pkg) f.state.bytes = Buffer.from('different media bytes') const second = await f.store.download(f.pkg) assert.equal(first.package.metadataVersion, second.package.metadataVersion) assert.notEqual(first.version, second.version) assert.deepEqual(await readdir(join(f.root, f.key)), ['current.json', `v-${second.version}`]) assert.equal( await readFile(join(f.root, f.key, 'current.json'), 'utf8'), JSON.stringify(second.version), ) assert.equal((await f.store.download(f.pkg)).version, second.version) }) test('после обрыва повторяет только текущий файл целиком', async (t) => { const f = await fixture(t) const broken = f.pkg.assets[1]!.url let attempts = 0 f.state.handler = async (url) => { if (url !== broken || attempts++ > 0) return mediaResponse() let reads = 0 return new Response( new ReadableStream({ pull(controller) { if (reads++ === 0) controller.enqueue(body.subarray(0, 3)) else controller.error(new Error('Connection reset')) }, }), { headers: { 'Content-Type': 'audio/wav', 'Content-Length': String(body.length) } }, ) } await f.store.download(f.pkg) assert.equal(f.calls.filter((url) => url === f.pkg.assets[0]!.url).length, 1) assert.equal(f.calls.filter((url) => url === broken).length, 2) assert.deepEqual(f.delays, [1000]) assert.deepEqual(await readFile((await f.store.localFile(f.key, f.pkg.assets[1]!.id)).path), body) }) test('ошибка обновления, отмена и нехватка места сохраняют прежний пакет', async (t) => { const f = await fixture(t) const first = await f.store.download(f.pkg) f.state.handler = async () => { throw new Error('Offline') } await assert.rejects(f.store.download(f.pkg), /Обрыв/) assert.deepEqual(f.delays, [1000, 3000]) assert.deepEqual(f.store.list(), [first]) f.state.handler = undefined await assert.rejects( f.store.download(f.pkg, { onProgress: (p) => { if (p.stage === 'downloading') f.store.cancel() }, }), /отменена/, ) const signal = new AbortController() await assert.rejects( f.store.download(f.pkg, { signal: signal.signal, onProgress: (p) => { if (p.stage === 'activating') signal.abort() }, }), /отменена/, ) f.state.free = 0n await assert.rejects(f.store.download(f.pkg), /Недостаточно места/) f.state.free = highSpace await assert.rejects( f.store.download(f.pkg, { onProgress: (p) => { if (p.stage === 'downloading') f.state.free = 0n }, }), /Недостаточно места/, ) assert.deepEqual(f.store.list(), [first]) assert.deepEqual(await readdir(join(f.root, f.key)), ['current.json', `v-${first.version}`]) }) test('не активирует файл, повреждённый между записью и повторной проверкой', async (t) => { const f = await fixture(t) await assert.rejects( f.store.download(f.pkg, { onProgress: (p) => { if (p.stage !== 'verifying') return const stage = readdirSync(join(f.root, f.key)).find((name) => name.startsWith('staging-'))! writeFileSync(join(f.root, f.key, stage, 'assets', p.assetId!), Buffer.alloc(body.length)) }, }), /Ошибка проверки/, ) assert.deepEqual(f.store.list(), []) assert.deepEqual(await readdir(join(f.root, f.key)), []) }) test('повреждение после перезапуска и некорректный манифест исключают готовность', async (t) => { const f = await fixture(t) const first = await f.store.download(f.pkg) const path = (await f.store.localFile(f.key, first.files[0]!.id)).path await writeFile(path, Buffer.alloc(body.length)) await assert.rejects(f.store.verify(f.key), /Повреждён файл/) assert.deepEqual(f.store.list(), []) const restored = new PackageStore(f.root, f.dependencies) await restored.initialize() assert.deepEqual(restored.list(), []) await writeFile(join(f.root, f.key, 'current.json'), JSON.stringify('../../outside')) await restored.initialize() assert.deepEqual(restored.list(), []) const bad = structuredClone(f.pkg) bad.assets[0]!.id = '../escape' assert.throws(() => parseScreenPackage(bad), /ресурс/) await assert.rejects(f.store.download(bad), /ресурс/) }) test('очищает следы сбоя до/после переключения указателя', async (t) => { const f = await fixture(t) const ready = await f.store.download(f.pkg) const stage = join(f.root, f.key, `staging-${randomUUID()}`) const orphan = join(f.root, f.key, `v-${'a'.repeat(64)}`) await mkdir(stage) await mkdir(orphan) await writeFile(join(f.root, f.key, 'current.tmp'), JSON.stringify('b'.repeat(64))) const restored = new PackageStore(f.root, f.dependencies) await restored.initialize() assert.deepEqual(restored.list(), [ready]) assert.deepEqual(await readdir(join(f.root, f.key)), ['current.json', `v-${ready.version}`]) }) test('симлинки и произвольные пути не позволяют читать или удалять чужие файлы', async (t) => { const f = await fixture(t) await writeFile(join(f.dir, 'outside.wav'), body) const ready = await f.store.download(f.pkg) const path = (await f.store.localFile(f.key, ready.files[0]!.id)).path await rm(path) await symlink(join(f.dir, 'outside.wav'), path) await assert.rejects(f.store.verify(f.key), /Симлинк/) await f.store.remove(f.key) assert.deepEqual(await readFile(join(f.dir, 'outside.wav')), body) await symlink(f.dir, join(f.root, f.key), 'dir') await assert.rejects(f.store.remove(f.key), /Симлинк/) await assert.rejects(f.store.remove('../'), /идентификатор/) assert.deepEqual(await readFile(join(f.dir, 'outside.wav')), body) assert.equal( (await packageMediaResponse(f.store, new Request(`screen-app://app/packages/${f.key}/bad`))) .status, 404, ) }) test('редиректы не отправляют JWT; опасные адреса и ошибочные ответы отклоняются', async (t) => { const f = await fixture(t) f.state.handler = async (url) => url.startsWith('https://cdn.test') ? mediaResponse() : new Response(null, { status: 302, headers: { Location: 'https://cdn.test/video.wav' } }) await f.store.download(f.pkg) assert.ok(f.calls.some((url) => url.startsWith('https://cdn.test'))) for (const location of [ 'http://cdn.test/file.wav', 'file:///etc/passwd', 'https://user:password@cdn.test/file.wav', 'https://cdn.test/file.wav?token=SECRET', ]) { f.state.handler = async () => new Response(null, { status: 302, headers: { Location: location } }) await assert.rejects(f.store.download(f.pkg), /редирект|адрес/) } for (const status of [401, 403, 404, 206]) { f.state.handler = async () => new Response(null, { status }) const before = f.calls.length await assert.rejects(f.store.download(f.pkg), /HTTP/) assert.equal(f.calls.length - before, 1) } f.state.handler = async () => new Response('HTML', { headers: { 'Content-Type': 'text/html' } }) await assert.rejects(f.store.download(f.pkg), /тип медиа/) f.state.handler = async () => new Response('short', { headers: { 'Content-Type': 'audio/wav', 'Content-Length': '100' } }) await assert.rejects(f.store.download(f.pkg), /не полностью/) f.state.handler = async () => new Response(null, { status: 302, headers: { Location: '/loop' } }) await assert.rejects(f.store.download(f.pkg), /редирект/) }) test('изменения хранилища сериализованы, отмена доступна во время загрузки', async (t) => { const f = await fixture(t) let started!: () => void const loading = new Promise((resolve) => { started = resolve }) f.state.handler = async (_url, init) => { started() return new Promise((_resolve, reject) => { init?.signal?.addEventListener('abort', () => reject(new Error('aborted')), { once: true }) }) } const job = f.store.download(f.pkg) await loading await assert.rejects(f.store.remove(f.key), /занято/) await assert.rejects(f.store.download(f.pkg), /занято/) f.store.cancel() await assert.rejects(job, /отменена/) }) test('реальный HTTP: отключение сервера не мешает восстановить и читать пакет', async (t) => { const f = await fixture(t) const server = createServer((_req, response) => { response.writeHead(200, { 'Content-Type': 'audio/wav', 'Content-Length': body.length }) response.end(body) }) await new Promise((resolve, reject) => { server.once('error', reject) server.listen(0, '127.0.0.1', resolve) }) t.after(() => server.close()) const address = server.address() assert.ok(address && typeof address !== 'string') const pkg = description(`http://127.0.0.1:${address.port}`) const store = new PackageStore(f.root) await store.initialize() const downloaded = await store.download(pkg) await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())), ) const restored = new PackageStore(f.root) await restored.initialize() for (const file of downloaded.files) { const response = await packageMediaResponse( restored, new Request(`screen-app://app/packages/${downloaded.key}/${file.id}`), ) assert.equal(response.status, 200) assert.deepEqual(Buffer.from(await response.arrayBuffer()), body) } }) test('принимает MIME WAV/OGG существующего бэкенда и неизвестный общий размер', async (t) => { const f = await fixture(t) for (const mime of ['audio/wave', 'audio/x-wav', 'application/ogg']) { f.state.handler = async () => new Response(body, { headers: { 'Content-Type': mime } }) const pkg = await f.store.download(f.pkg) assert.ok( pkg.files.every( (file) => file.mime === (mime === 'application/ogg' ? 'audio/ogg' : 'audio/wav'), ), ) } }) test('повторная загрузка восстанавливает повреждённую копию той же версии', async (t) => { const f = await fixture(t) const original = await f.store.download(f.pkg) await writeFile( (await f.store.localFile(f.key, original.files[0]!.id)).path, Buffer.alloc(body.length), ) const repaired = await f.store.download(f.pkg) assert.equal(repaired.version, original.version) assert.deepEqual(await f.store.verify(f.key), original) }) test('превышение времени бездействия повторяет запрос трижды и оставляет пакет неготовым', async (t) => { const f = await fixture(t) t.mock.timers.enable({ apis: ['setTimeout'] }) let started!: () => void let start = new Promise((resolve) => { started = resolve }) f.state.handler = async (_url, init) => { started() return new Promise((_resolve, reject) => { init?.signal?.addEventListener('abort', () => reject(new Error('timeout')), { once: true }) }) } const job = f.store.download(f.pkg) const rejected = assert.rejects(job, /таймаут/) for (let i = 0; i < 3; i++) { await start start = new Promise((resolve) => { started = resolve }) t.mock.timers.tick(30_001) } await rejected assert.equal(f.calls.length, 3) assert.deepEqual(f.store.list(), []) })