import assert from 'node:assert/strict' import { test, type TestContext } from 'node:test' import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto' import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises' import { join } from 'node:path' import { tmpdir } from 'node:os' import { Controller } from './controller.js' import { Online, type SecretStorage } from './online.js' import { PackageStore } from './package-store.js' import { applyRemote, parseRemote, send } from './remote-state.js' async function fixture(t: TestContext, secure = true) { const directory = await mkdtemp(join(tmpdir(), 'screen-online-')) t.after(() => rm(directory, { recursive: true, force: true })) const key = randomBytes(32) const secrets: SecretStorage = { available: () => secure, encrypt(value) { assert.ok(secure) const iv = randomBytes(12) const cipher = createCipheriv('aes-256-gcm', key, iv) const bytes = Buffer.concat([cipher.update(value), cipher.final()]) return Buffer.concat([iv, cipher.getAuthTag(), bytes]) }, decrypt(value) { const cipher = createDecipheriv('aes-256-gcm', key, value.subarray(0, 12)) cipher.setAuthTag(value.subarray(12, 28)) return Buffer.concat([cipher.update(value.subarray(28)), cipher.final()]).toString() }, } const model = { offline: false, failMedia: false, expired: false, refreshExpired: false, accessExpired: false, refreshed: false, refreshCalls: 0, advanceCalls: 0, resultError: false, title: 'Дело', roles: ['organizer'], remote: { gameID: 7, mode: 2, command: 1, stateVersion: '9007199254740993', soundEnabled: true, positionMs: '0', volume: 80, } as Record, stateResponse: null as (() => Promise) | null, } const json = (value: unknown, status = 200) => Response.json(value, { status }) const requests: { path: string; auth: string | null }[] = [] const fetchAPI: typeof fetch = async (input, init) => { if (model.offline) throw new Error('network secret-detail') const url = new URL(String(input)) const auth = new Headers(init?.headers).get('Authorization') const path = url.pathname requests.push({ path, auth }) assert.equal(init?.credentials, 'omit') if (url.origin === 'https://media.test') { assert.equal(auth, null) if (model.failMedia) return new Response(null, { status: 404 }) return new Response(Buffer.from('RIFFtest-WAVE'), { headers: { 'Content-Type': 'audio/wav' }, }) } assert.equal(url.origin, 'https://server.test') if (path === '/api/auth/login') return json({ accessToken: 'ACCESS-SECRET', refreshToken: 'REFRESH-SECRET' }) if (path === '/api/auth/refresh') { model.refreshCalls++ await new Promise((resolve) => setTimeout(resolve, 5)) if (model.refreshExpired) return json({}, 401) model.refreshed = true return json({ accessToken: 'ACCESS-ROTATED', refreshToken: 'REFRESH-ROTATED' }) } if (path === '/api/screens') return json({ screenID: '12', accessToken: 'SCREEN-SECRET' }) if (path === '/api/screens/state') { assert.equal(auth, null) if (model.stateResponse) return model.stateResponse() return model.expired ? json({}, 401) : json({ state: model.remote }) } if (path === '/api/screens/music/advance') { model.advanceCalls++ return json({ state: objectMusic(2, 1) }) } if (path === '/api/screens/results') { if (model.resultError) return json({}, 400) return json({ questions: [{ code: 'A', text: 'Вопрос' }], teams: [ { team: { ID: 'team', name: 'Команда', password: 'TEAM-SECRET', captainEmail: 'private@example.test', applications: [{ name: 'PRIVATE' }], }, place: 1, totalScore: 5, actionsCount: 2, answers: [{ questionCode: 'A', score: 5, answer: 'ANSWER-SECRET' }], }, ], }) } if (auth === 'Bearer ACCESS-SECRET' && model.accessExpired && !model.refreshed) return json({}, 401) if (model.refreshExpired) return json({}, 401) if (path === '/api/users/me') return json({ user: { roles: model.roles } }) if (path === '/api/games') return json({ games: [ { ID: 7, name: model.title, status: 'finish', teams: [{ password: 'TEAM-SECRET' }] }, ], }) if (path === '/api/games/7') return json({ game: { ID: 7, name: model.title, scenario: { story: { introduction: { audio: 'https://media.test/intro.wav' }, conclusion: { video: '' }, }, }, }, }) if (path === '/api/games/7/screen-plan') return json({ plan: { steps: [{ mode: 2 }] } }) if (path === '/api/games/7/music') return json({ gamePlaylistId: 1, playlists: [ { ID: 1, name: 'Музыка', tracks: [0, 1].map((i) => ({ ID: i + 1, name: `Трек ${i}`, filename: `https://media.test/${i}.wav`, position: i, })), }, ], }) if (path.endsWith('/bind') || path.endsWith('/sound')) return json({}) throw new Error(`Unexpected request ${path}`) } const store = new PackageStore(join(directory, 'packages'), { fetch: fetchAPI, freeBytes: async () => 1n << 40n, wait: async () => {}, }) await store.initialize() const controller = new Controller(() => {}) const online = new Online(join(directory, 'connection'), store, controller, secrets, fetchAPI) await online.initialize() const login = () => online.act({ action: 'login', server: 'https://server.test/', email: 'test@example.test', password: 'PASSWORD-SECRET', }) const prepare = async () => { await login() await online.act({ action: 'download', gameId: 7 }) await online.select(store.list()[0]!.key) await online.act({ action: 'connect' }) } return { directory, store, controller, online, secrets, fetchAPI, model, requests, login, prepare, } } function objectMusic(version: number, index = 0) { return { stateVersion: String(version), playlist: { ID: 1, tracks: [{ ID: 1 }, { ID: 2 }] }, trackIndex: index, playback: 'MUSIC_PLAYBACK_PLAYING', volume: 40, loop: true, } } test('вход, подготовка и зашифрованные секреты; после перезапуска игра и результаты работают офлайн', async (t) => { const f = await fixture(t) await f.prepare() await f.online.act({ action: 'results' }) const snapshot = f.online.snapshot() assert.equal(snapshot.authenticated, true) assert.equal(snapshot.remembered, true) const files = await readdir(join(f.directory, 'connection')) for (const file of files) { const bytes = await readFile(join(f.directory, 'connection', file)) for (const secret of [ 'ACCESS-SECRET', 'REFRESH-SECRET', 'SCREEN-SECRET', 'PASSWORD-SECRET', 'TEAM-SECRET', 'ANSWER-SECRET', 'private@example.test', ]) assert.equal(bytes.includes(secret), false) } assert.equal(JSON.stringify(snapshot).includes('SECRET'), false) f.model.offline = true const store = new PackageStore(join(f.directory, 'packages')) await store.initialize() const c = new Controller(() => {}) const online = new Online(join(f.directory, 'connection'), store, c, f.secrets, f.fetchAPI) await online.initialize() assert.equal(c.state.catalog.key, f.controller.state.catalog.key) assert.equal(c.state.results?.teams[0]?.totalScore, 5) assert.ok(c.state.results?.capturedAt) assert.equal(c.state.outputOpen, false) assert.equal(c.state.channels.main.desired, 'stopped') c.output(true) send(c, { action: 'play', channel: 'main' }) await online.poll() assert.equal(online.state.connection, 'offline') assert.equal(c.state.channels.main.desired, 'playing') }) test('без хранилища ОС вход только в памяти; выход не останавливает показ', async (t) => { const f = await fixture(t, false) await f.prepare() assert.equal(f.online.state.remembered, false) await assert.rejects(readFile(join(f.directory, 'connection', 'credentials.bin')), { code: 'ENOENT', }) f.controller.output(true) send(f.controller, { action: 'play', channel: 'main' }) await f.online.act({ action: 'logout' }) assert.equal(f.controller.state.channels.main.desired, 'playing') assert.equal(f.online.state.screenId, null) }) test('три параллельных запроса описания используют одно обновление токена', async (t) => { const f = await fixture(t) await f.login() f.model.accessExpired = true await f.online.act({ action: 'download', gameId: 7 }) assert.equal(f.model.refreshCalls, 1) assert.equal(f.store.list().length, 1) }) test('обновление запрещено при показе, ошибка сохраняет старый пакет, новая версия включается остановленной', async (t) => { const f = await fixture(t) await f.prepare() const old = f.store.list()[0]! f.controller.output(true) await assert.rejects(f.online.act({ action: 'download', gameId: 7 }), /Закройте показ/) f.controller.output(false) f.model.title = 'Новое дело' await f.online.act({ action: 'check', gameId: 7 }) assert.match(f.online.state.notice, /есть изменения/) f.model.failMedia = true await assert.rejects(f.online.act({ action: 'download', gameId: 7 })) assert.equal(f.store.list()[0]?.version, old.version) f.model.failMedia = false await f.online.act({ action: 'download', gameId: 7 }) assert.notEqual(f.store.list()[0]?.version, old.version) assert.equal(f.controller.state.catalog.name, 'Новое дело') assert.equal(f.controller.state.channels.main.desired, 'stopped') await assert.rejects(f.online.act({ action: 'remove', key: old.key }), /другой пакет/) }) test('пропускает исходную и накопленную версии; повторная версия не отменяет локальную паузу', async (t) => { const f = await fixture(t) await f.prepare() const c = f.controller c.output(true) await f.online.poll() assert.equal(c.state.channels.main.desired, 'stopped') f.model.remote.stateVersion = '9007199254740994' await f.online.poll() assert.equal(c.state.channels.main.desired, 'playing') send(c, { action: 'pause', channel: 'main' }) await f.online.poll() assert.equal(c.state.channels.main.desired, 'paused') f.model.offline = true await f.online.poll() f.model.offline = false f.model.remote.stateVersion = '9007199254740995' await f.online.poll() assert.equal(c.state.channels.main.desired, 'paused') f.model.remote.stateVersion = '9007199254740996' await f.online.poll() assert.equal(c.state.channels.main.desired, 'playing') c.output(false) c.output(true) await f.online.poll() assert.equal(c.state.channels.main.desired, 'stopped') }) test('недействительная сессия и смена серверной привязки сохраняют локальный показ', async (t) => { const f = await fixture(t) await f.prepare() f.controller.output(true) send(f.controller, { action: 'play', channel: 'main' }) f.model.expired = true await f.online.poll() assert.equal(f.online.state.connection, 'expired') assert.equal(f.controller.state.channels.main.desired, 'playing') f.model.expired = false await f.online.act({ action: 'connect' }) f.model.remote.gameID = 8 await f.online.poll() assert.equal(f.online.state.screenId, null) assert.equal(f.controller.state.channels.main.desired, 'playing') }) test('не применяет ответ от предыдущего окна и не допускает параллельный опрос', async (t) => { const f = await fixture(t) await f.prepare() f.controller.output(true) await f.online.poll() let release!: (response: Response) => void f.model.stateResponse = () => new Promise((resolve) => { release = resolve }) const pending = f.online.poll() await f.online.poll() f.controller.output(false) f.controller.output(true) release(Response.json({ state: { ...f.model.remote, stateVersion: '9007199254740999' } })) await pending assert.equal(f.controller.state.channels.main.desired, 'stopped') }) test('результаты сохраняются при ошибке API и не содержат секретных полей', async (t) => { const f = await fixture(t) await f.prepare() await f.online.act({ action: 'results' }) const before = structuredClone(f.controller.state.results) f.model.resultError = true await assert.rejects(f.online.act({ action: 'results' }), /пока недоступно/) assert.deepEqual(f.controller.state.results, before) assert.equal(JSON.stringify(before).includes('SECRET'), false) }) test('таймер синхронизируется один раз на версию; звук меняется без увеличения версии', () => { let now = 0 const c = new Controller( () => {}, () => now, ) const previous = parseRemote({ gameID: 7, mode: 7, stateVersion: '1', timerDurationSeconds: '60', }) const next = parseRemote({ gameID: 7, mode: 7, stateVersion: '2', soundEnabled: true, timerDurationSeconds: '60', timerRemainingSeconds: '40', timerState: 2, command: 8, }) applyRemote(c, previous, next) assert.equal(c.state.timer.remainingMs, 40000) now += 3000 c.tick() applyRemote(c, next, next) assert.equal(c.state.timer.remainingMs, 37000) send(c, { action: 'mute', enabled: true }) applyRemote(c, { ...next, sound: false }, next) assert.equal(c.state.muted, false) assert.throws(() => parseRemote({ stateVersion: '9223372036854775808' })) assert.throws(() => parseRemote({ mode: 99 })) }) test('музыкальная громкость не возвращает старый трек; новый серверный трек использует локальные файлы', async (t) => { const f = await fixture(t) await f.prepare() f.controller.output(true) const previous = parseRemote({ music: objectMusic(1) }) send(f.controller, { action: 'next', channel: 'music' }) const next = parseRemote({ music: { ...objectMusic(2), volume: 70 } }) applyRemote(f.controller, previous, next) assert.equal(f.controller.state.channels.music.trackIndex, 1) assert.equal(f.controller.state.channels.music.volume, 70) applyRemote(f.controller, next, parseRemote({ music: objectMusic(3, 1) })) assert.equal(f.controller.state.channels.music.desired, 'playing') assert.match(f.controller.state.channels.music.source!.url, /^screen-app:/) assert.throws( () => applyRemote( f.controller, next, parseRemote({ music: { ...objectMusic(4), playlist: { ID: 99, tracks: [{ ID: 1 }] } } }), ), /Музыка изменилась/, ) }) test('истечение JWT не обрывает показ и оставляет доступным повторный вход', async (t) => { const f = await fixture(t) await f.prepare() f.controller.output(true) send(f.controller, { action: 'play', channel: 'main' }) f.model.accessExpired = true f.model.refreshExpired = true await assert.rejects(f.online.act({ action: 'games' }), /Сессия истекла/) assert.equal(f.online.state.authenticated, false) assert.equal(f.controller.state.channels.main.desired, 'playing') assert.equal(f.controller.state.catalog.key, f.store.list()[0]?.key) }) test('отмена подготовки не меняет готовую версию и снимает запрет открытия', async (t) => { const f = await fixture(t) await f.prepare() const old = f.store.list()[0]!.version const original = f.store.download.bind(f.store) let started!: () => void const ready = new Promise((resolve) => { started = resolve }) f.store.download = async (description, options) => { started() await new Promise((resolve) => options!.signal!.addEventListener('abort', () => resolve(), { once: true }), ) return original(description, options) } const pending = f.online.act({ action: 'download', gameId: 7 }) const failure = assert.rejects(pending) await ready assert.equal(f.online.blocksOutput, true) await assert.rejects(f.online.select(null), /Подождите/) await f.online.act({ action: 'cancel' }) await failure assert.equal(f.online.blocksOutput, false) assert.equal(f.store.list()[0]?.version, old) }) test('одинаковая серверная сцена с новой версией отменяет локальное переключение', () => { const c = new Controller(() => {}) const previous = parseRemote({ mode: 1, stateVersion: '1' }) send(c, { action: 'mode', mode: 8 }) applyRemote(c, previous, parseRemote({ mode: 1, stateVersion: '2' })) assert.equal(c.state.scene.mode, 1) send(c, { action: 'mode', mode: 8 }) applyRemote( c, parseRemote({ mode: 7, stateVersion: '2' }), parseRemote({ mode: 7, stateVersion: '3', command: 8, timerState: 2 }), ) assert.equal(c.state.scene.mode, 7) assert.equal(c.state.timer.status, 'running') }) test('откат версии не позволяет повторно выполнить уже полученную команду', async (t) => { const f = await fixture(t) await f.prepare() f.controller.output(true) await f.online.poll() f.model.remote.stateVersion = '9007199254740995' await f.online.poll() send(f.controller, { action: 'pause', channel: 'main' }) f.model.remote.stateVersion = '9007199254740994' await f.online.poll() f.model.remote.stateVersion = '9007199254740995' await f.online.poll() assert.equal(f.controller.state.channels.main.desired, 'paused') }) test('окончание серверного трека обновляет сервер один раз и не перематывает локальный следующий', async (t) => { const f = await fixture(t) await f.prepare() f.controller.output(true) f.model.remote.music = objectMusic(1) await f.online.poll() f.model.remote.music = objectMusic(2) f.model.remote.stateVersion = '9007199254740994' await f.online.poll() send(f.controller, { action: 'play', channel: 'music' }) const report = { session: f.controller.state.session, channel: 'music' as const, operation: f.controller.state.channels.music.operation, status: 'ended' as const, position: 4, duration: 4, } f.controller.report(report) f.controller.report(report) await new Promise((resolve) => setTimeout(resolve, 10)) assert.equal(f.model.advanceCalls, 1) assert.equal(f.controller.state.channels.music.trackIndex, 1) send(f.controller, { action: 'seek', channel: 'music', position: 2 }) f.model.remote.music = objectMusic(2, 1) await f.online.poll() assert.equal(f.controller.state.channels.music.position, 2) }) test('protobuf JSON с опущенными нулями сохраняет тишину и завершённый таймер', () => { const state = parseRemote({ mode: 7, timerDurationSeconds: '60', timerState: 'SCREEN_TIMER_STATE_FINISHED', music: { stateVersion: '1' }, }) assert.equal(state.volume, 0) assert.equal(state.music?.volume, 0) assert.equal(state.timer.remainingMs, 0) assert.equal(state.timer.status, 'finished') assert.throws(() => parseRemote({ stateVersion: 9007199254740992 })) }) test('вход без прав управления не сохраняет токены даже после их обновления', async (t) => { const f = await fixture(t) f.model.accessExpired = true f.model.roles = ['player'] await assert.rejects(f.login(), /нужны права/) assert.equal(f.online.state.authenticated, false) assert.equal(f.online.state.remembered, false) assert.equal(f.model.refreshCalls, 1) await assert.rejects(readFile(join(f.directory, 'connection', 'credentials.bin')), { code: 'ENOENT', }) })