up
This commit is contained in:
@@ -0,0 +1,543 @@
|
||||
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<string, unknown>,
|
||||
stateResponse: null as (() => Promise<Response>) | 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<void>((resolve) => {
|
||||
started = resolve
|
||||
})
|
||||
f.store.download = async (description, options) => {
|
||||
started()
|
||||
await new Promise<void>((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',
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user