Files
evening_detective_screen/electron/package-store.test.ts
T
2026-10-18 14:15:43 +07:00

550 lines
23 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,
type VerificationProgress,
} 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('запуск без сети проверяет отдельный кеш всех игр и сообщает прогресс', async (t) => {
const f = await fixture(t)
const other = description('https://other.test')
const otherKey = packageKey(other.server, other.game.id)
for (const pkg of [f.pkg, other]) {
await f.store.remember(pkg)
await f.store.ensureAsset(pkg, pkg.assets[0]!.id, new AbortController().signal)
}
const calls = f.calls.length
f.state.handler = async () => {
throw new Error('Нет сети')
}
const restored = new PackageStore(f.root, f.dependencies)
const progress: VerificationProgress[] = []
await restored.initialize((value) => progress.push(value))
assert.equal(f.calls.length, calls)
for (const [pkg, key] of [
[f.pkg, f.key],
[other, otherKey],
] as const) {
assert.equal(restored.assetStatus(key, pkg.assets[0]!.id).status, 'ready')
assert.equal(restored.assetStatus(key, pkg.assets[1]!.id).status, 'missing')
}
assert.equal(progress[0]!.total, null)
assert.deepEqual(progress.at(-1), { completed: 4, total: 4 })
assert.deepEqual(
progress.slice(1).map((p) => p.completed),
[0, 1, 2, 3, 4],
)
})
test('перезапуск выявляет подмену кеша того же размера, удаление и симлинк', async (t) => {
const f = await fixture(t)
await f.store.remember(f.pkg)
const id = f.pkg.assets[0]!.id
await f.store.ensureAsset(f.pkg, id, new AbortController().signal)
const file = await f.store.localFile(f.key, id)
await writeFile(file.path, Buffer.alloc(file.size, 42))
await f.store.initialize()
assert.equal(f.store.assetStatus(f.key, id).status, 'missing')
await f.store.ensureAsset(f.pkg, id, new AbortController().signal)
await rm(file.path)
await symlink(join(f.dir, 'outside'), file.path)
await f.store.initialize()
assert.equal(f.store.assetStatus(f.key, id).status, 'missing')
await rm(file.path)
await f.store.initialize()
assert.equal(f.store.assetStatus(f.key, id).status, 'missing')
})
test('пустое хранилище завершает стартовую проверку с нулевым счётчиком', async (t) => {
const f = await fixture(t)
const progress: VerificationProgress[] = []
await f.store.initialize((p) => progress.push(p))
assert.deepEqual(progress, [
{ completed: 0, total: null },
{ completed: 0, total: 0 },
])
})
test('открытие папки выбирает реальные файлы полного пакета и восстановленного кеша', async (t) => {
const f = await fixture(t)
await f.store.remember(f.pkg)
await assert.rejects(f.store.folders(f.key), /ещё не загружены/)
const local = await f.store.download(f.pkg)
const assets = join(f.root, f.key, `v-${local.version}`, 'assets')
assert.deepEqual(await f.store.folders(f.key), [assets])
const id = f.pkg.assets[0]!.id
await rm((await f.store.localFile(f.key, id)).path)
await f.store.ensureAsset(f.pkg, id, new AbortController().signal)
assert.deepEqual(
new Set(await f.store.folders(f.key)),
new Set([assets, join(f.root, f.key, 'cache')]),
)
})
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))
})