Files
evening_detective_screen/electron/package-store.test.ts
T
2026-10-18 03:11:10 +07:00

469 lines
19 KiB
TypeScript

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<Response>
} = { 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<void>((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<void>((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<void>((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<void>((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<void>((resolve) => {
started = resolve
})
t.mock.timers.tick(30_001)
}
await rejected
assert.equal(f.calls.length, 3)
assert.deepEqual(f.store.list(), [])
})
test('инкрементальный кеш: завершённый файл доступен до полного пакета, повторно не скачивается и переживает перезапуск', async (t) => {
const f = await fixture(t)
await f.store.remember(f.pkg)
const first = f.pkg.assets[0]!
const second = f.pkg.assets[1]!
await f.store.ensureAsset(f.pkg, first.id, new AbortController().signal)
assert.equal(f.calls.length, 1)
await assert.rejects(f.store.localFile(f.key, second.id))
const range = await packageMediaResponse(
f.store,
new Request(`screen-app://app/packages/${f.key}/${first.id}`, {
headers: { Range: 'bytes=2-5' },
}),
)
assert.equal(range.status, 206)
assert.deepEqual(Buffer.from(await range.arrayBuffer()), body.subarray(2, 6))
await f.store.ensureAsset(f.pkg, first.id, new AbortController().signal)
assert.equal(f.calls.length, 1)
const restored = new PackageStore(f.root, f.dependencies)
await restored.initialize()
assert.equal(restored.description(f.key)?.package.metadataVersion, f.pkg.metadataVersion)
await restored.ensureAsset(f.pkg, first.id, new AbortController().signal)
assert.equal(f.calls.length, 1)
assert.deepEqual(await readFile((await restored.localFile(f.key, first.id)).path), body)
})
test('инкрементальный кеш: повреждённый файл восстанавливается независимо от остальных, параллельные запросы объединяются', async (t) => {
const f = await fixture(t)
await f.store.download(f.pkg)
const first = f.pkg.assets[0]!
const second = f.pkg.assets[1]!
const path = (await f.store.localFile(f.key, first.id)).path
await writeFile(path, Buffer.alloc(body.length))
const before = f.calls.length
await Promise.all(
[0, 1].map(() => f.store.ensureAsset(f.pkg, first.id, new AbortController().signal)),
)
assert.equal(f.calls.length, before + 1)
assert.deepEqual(await readFile((await f.store.localFile(f.key, first.id)).path), body)
assert.deepEqual(await readFile((await f.store.localFile(f.key, second.id)).path), body)
const restored = new PackageStore(f.root, f.dependencies)
await restored.initialize()
assert.ok(restored.description(f.key), 'описание восстановленного пакета переживает перезапуск')
assert.deepEqual(await readFile((await restored.localFile(f.key, first.id)).path), body)
})
test('инкрементальный кеш: ошибка и отмена не публикуют незавершённый файл и сохраняют готовый', async (t) => {
const f = await fixture(t)
await f.store.remember(f.pkg)
const [first, second] = f.pkg.assets
await f.store.ensureAsset(f.pkg, first!.id, new AbortController().signal)
f.state.free = 0n
await assert.rejects(
f.store.ensureAsset(f.pkg, second!.id, new AbortController().signal),
/места/,
)
await assert.rejects(f.store.localFile(f.key, second!.id))
assert.deepEqual(await readFile((await f.store.localFile(f.key, first!.id)).path), body)
f.state.free = highSpace
const abort = new AbortController()
f.state.handler = async () => {
abort.abort()
return mediaResponse()
}
await assert.rejects(f.store.ensureAsset(f.pkg, second!.id, abort.signal))
await assert.rejects(f.store.localFile(f.key, second!.id))
})