This commit is contained in:
2026-10-18 04:32:58 +07:00
parent e85d899fd7
commit c36d3fd314
14 changed files with 446 additions and 50 deletions
+9 -1
View File
@@ -19,13 +19,21 @@ test('журнал сохраняет только разрешённые пол
log.record('output-close', { reason: 'renderer', password: 'SECRET', error: new Error('SECRET') })
log.record('output-close', { reason: 'https://secret.example/SECRET' })
log.record('package', { action: 'download', status: 'failed', response: 'SECRET' })
log.record('request-error', {
path: '/api/games/{id}/music',
reason: 'ENOTFOUND',
token: 'SECRET',
error: new Error('SECRET'),
})
log.record('request-error', { path: '/api/games/7?token=SECRET', reason: 'ENOTFOUND' })
log.record('request-error', { path: '/api/games/{id}', reason: 'SECRET' })
const content = await readFile(join(dir, 'diagnostic.jsonl'), 'utf8')
assert.equal(content.includes('SECRET'), false)
const lines = content
.trim()
.split('\n')
.map((s) => JSON.parse(s))
assert.equal(lines.length, 3)
assert.equal(lines.length, 4)
assert.equal(lines[0].version, '0.2.0-rc.1')
assert.deepEqual(Object.keys(lines[1]).sort(), ['event', 'reason', 'time'])
})
+7
View File
@@ -1,3 +1,4 @@
import { NETWORK_ERROR_CODES } from './request-error.js'
import { appendFileSync, mkdirSync, renameSync, rmSync, statSync } from 'node:fs'
import { join } from 'node:path'
@@ -12,6 +13,7 @@ type Event =
| 'media-error'
| 'connection'
| 'package'
| 'request-error'
const fields: Partial<Record<Event, Record<string, readonly string[]>>> = {
'output-close': {
reason: [
@@ -25,6 +27,10 @@ const fields: Partial<Record<Event, Record<string, readonly string[]>>> = {
'unresponsive',
],
},
'request-error': {
path: ['/api/games/{id}', '/api/games/{id}/screen-plan', '/api/games/{id}/music'],
reason: [...NETWORK_ERROR_CODES, 'AUTH', 'HTTP', 'INVALID_JSON', 'SESSION_CHANGED'],
},
'media-error': { channel: ['main', 'music', 'scene'] },
connection: { status: ['disabled', 'waiting', 'connected', 'offline', 'expired'] },
package: { action: ['download', 'check', 'remove'], status: ['success', 'failed'] },
@@ -40,6 +46,7 @@ const events: readonly string[] = [
'media-error',
'connection',
'package',
'request-error',
]
export class DiagnosticLog {
private disabled = false
+15 -8
View File
@@ -290,14 +290,21 @@ app
}
const packages = new PackageStore(join(app.getPath('userData'), 'packages'))
await packages.initialize()
online = new Online(join(app.getPath('userData'), 'connection'), packages, controller, {
available: () =>
safeStorage.isEncryptionAvailable() &&
(process.platform !== 'linux' ||
!['basic_text', 'unknown'].includes(safeStorage.getSelectedStorageBackend())),
encrypt: (value) => safeStorage.encryptString(value),
decrypt: (value) => safeStorage.decryptString(value),
})
online = new Online(
join(app.getPath('userData'), 'connection'),
packages,
controller,
{
available: () =>
safeStorage.isEncryptionAvailable() &&
(process.platform !== 'linux' ||
!['basic_text', 'unknown'].includes(safeStorage.getSelectedStorageBackend())),
encrypt: (value) => safeStorage.encryptString(value),
decrypt: (value) => safeStorage.decryptString(value),
},
undefined,
(failure) => diagnostics?.record('request-error', failure),
)
await online.initialize()
let previousConnection = online.state.connection
const logConnection = () => {
+49 -1
View File
@@ -35,6 +35,7 @@ async function fixture(t: TestContext, secure = true) {
}
const model = {
offline: false,
server: 'https://server.test',
introURL: 'https://media.test/intro.wav',
musicOverride: null as Record<string, unknown> | null,
mediaHandler: null as ((url: string) => Promise<Response>) | null,
@@ -82,7 +83,7 @@ async function fixture(t: TestContext, secure = true) {
headers: { 'Content-Type': 'audio/wav' },
})
}
assert.equal(url.origin, 'https://server.test')
assert.equal(url.origin, model.server)
if (path === '/api/auth/login')
return json({ accessToken: 'ACCESS-SECRET', refreshToken: 'REFRESH-SECRET' })
if (path === '/api/auth/refresh') {
@@ -264,6 +265,44 @@ async function fixture(t: TestContext, secure = true) {
prepare,
}
}
test('чистый профиль предлагает адрес сайта по умолчанию', async (t) => {
const f = await fixture(t)
assert.equal(f.online.snapshot().server, 'https://evening-detective.crabs-games.art')
})
for (const [input, expected] of [
['https://evening-detective.crabs-games.art/', 'https://evening-detective-api.crabs-games.art'],
['http://127.0.0.1:8090', 'http://127.0.0.1:8090'],
]) {
test(`адрес входа ${input}: API и сохранённый адрес ${expected}`, async (t) => {
const f = await fixture(t, false)
f.model.server = expected!
await f.online.act({
action: 'login',
server: input!,
email: 'test@example.test',
password: 'PASSWORD-SECRET',
})
assert.equal(f.online.snapshot().authenticated, true)
assert.equal(f.online.snapshot().server, expected)
assert.ok(f.requests.some((r) => r.path === '/api/auth/login'))
await f.online.close()
const restored = new Online(
join(f.directory, 'connection'),
f.store,
new Controller(() => {}),
f.secrets,
f.fetchAPI,
)
try {
await restored.initialize()
assert.equal(restored.snapshot().server, expected)
} finally {
await restored.close()
}
})
}
function objectMusic(version: number, index = 0) {
return {
stateVersion: String(version),
@@ -1189,3 +1228,12 @@ test('публикация текста после смены игры не от
assert.equal(f.controller.state.catalog.key, null)
assert.equal(f.controller.state.scene.stepIndex, null)
})
test('истёкшая авторизация при подключении игры не маскируется сетевой ошибкой', async (t) => {
const f = await fixture(t)
await f.login()
f.model.accessExpired = true
f.model.refreshExpired = true
await assert.rejects(f.online.act({ action: 'connect', gameId: 7 }), /Сессия истекла/)
assert.equal(f.online.snapshot().authenticated, false)
})
+22 -27
View File
@@ -1,6 +1,7 @@
import { ApiError, NetworkError, SessionChangedError, networkFailure } from './request-error.js'
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import type { OnlineIntent, OnlineState } from '../src/types/online.js'
import { DEFAULT_SERVER, type OnlineIntent, type OnlineState } from '../src/types/online.js'
import type {
Intent,
PlanStatus,
@@ -39,20 +40,6 @@ interface Credentials {
refresh: string
screen: ScreenSession | null
}
class NetworkError extends Error {}
class ApiError extends Error {
constructor(public status: number) {
super(
status === 401
? 'Сессия истекла. Войдите снова'
: status === 403
? 'Нет доступа к действию'
: status === 400
? 'Действие пока недоступно для этой игры'
: `Ошибка сервера: HTTP ${status}`,
)
}
}
function serverURL(value: unknown): string {
const url = new URL(text(value))
if (
@@ -64,7 +51,9 @@ function serverURL(value: unknown): string {
(url.pathname !== '/' && url.pathname !== '')
)
throw new Error('Укажите адрес HTTP(S) сервера без пути и параметров')
return url.origin
return url.origin === DEFAULT_SERVER
? 'https://evening-detective-api.crabs-games.art'
: url.origin
}
function token(value: unknown): string {
const s = text(value)
@@ -74,7 +63,7 @@ function token(value: unknown): string {
}
export class Online {
readonly state: OnlineState = {
server: '',
server: DEFAULT_SERVER,
authenticated: false,
remembered: false,
busy: false,
@@ -119,6 +108,7 @@ export class Online {
gameId,
token: this.credentials.access,
signal: this.materialAbort.signal,
onRequestFailure: this.onRequestFailure,
},
(url, init) => {
const parsed = new URL(String(url))
@@ -489,7 +479,7 @@ export class Online {
body: JSON.stringify(plan),
}),
)
if (generation !== this.generation) throw new Error('Сессия изменилась')
if (generation !== this.generation) throw new SessionChangedError()
await this.acceptPlan(parsePlan(result.plan), pkg.key, generation, version)
} finally {
this.planBusy--
@@ -839,6 +829,9 @@ export class Online {
private controller: Controller,
private secrets: SecretStorage,
private fetchAPI: typeof fetch = fetch,
private onRequestFailure: NonNullable<
import('./screen-package.js').PackageRequest['onRequestFailure']
> = () => {},
) {
controller.onMusicEnded = (report) => {
if (!this.screen) return false
@@ -1000,18 +993,19 @@ export class Online {
this.state.connection = 'disabled'
}
private async raw(server: string, path: string, init: RequestInit = {}): Promise<Response> {
const signal = AbortSignal.any([
AbortSignal.timeout(30_000),
...(init.signal ? [init.signal] : []),
])
try {
return await this.fetchAPI(`${server}${path}`, {
...init,
redirect: 'error',
credentials: 'omit',
signal: AbortSignal.any([
AbortSignal.timeout(30_000),
...(init.signal ? [init.signal] : []),
]),
signal,
})
} catch {
throw new NetworkError('Сервер недоступен или превышено время ожидания')
} catch (error) {
throw networkFailure(error, signal)
}
}
private async json(response: Response): Promise<Record<string, unknown>> {
@@ -1032,7 +1026,7 @@ export class Online {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken: c.refresh }),
})
if (generation !== this.generation) throw new Error('Сессия изменилась')
if (generation !== this.generation) throw new SessionChangedError()
if (response.status === 401) {
this.credentials = null
this.state.authenticated = false
@@ -1040,7 +1034,7 @@ export class Online {
throw new ApiError(401)
}
const result = await this.json(response)
if (generation !== this.generation) throw new Error('Сессия изменилась')
if (generation !== this.generation) throw new SessionChangedError()
this.credentials = {
...c,
access: token(result.accessToken),
@@ -1062,7 +1056,7 @@ export class Online {
return this.raw(c.server, path, { ...init, headers })
}
let response = await request(c.access)
if (generation !== this.generation) throw new Error('Сессия изменилась')
if (generation !== this.generation) throw new SessionChangedError()
if (response.status === 401) {
await response.body?.cancel()
await this.refresh(c.access, generation)
@@ -1195,6 +1189,7 @@ export class Online {
gameId,
token: this.credentials.access,
signal: this.cancellation.signal,
onRequestFailure: this.onRequestFailure,
},
(url, init) => {
const parsed = new URL(String(url))
+21
View File
@@ -0,0 +1,21 @@
import assert from 'node:assert/strict'
import { test } from 'node:test'
import { networkFailure } from './request-error.js'
test('безопасная причина соединения извлекается из вложенных Node-ошибок', () => {
for (const [code, message] of [
['ECONNREFUSED', 'Сервер отклонил соединение'],
['ECONNRESET', 'Соединение с сервером прервано'],
['UND_ERR_CONNECT_TIMEOUT', 'Превышено время ожидания соединения с сервером'],
['CERT_HAS_EXPIRED', 'Ошибка защищённого соединения с сервером'],
['SECRET', 'Сервер недоступен'],
]) {
const error = new TypeError('SECRET', {
cause: new AggregateError([Object.assign(new Error('SECRET'), { code })]),
})
const result = networkFailure(error, new AbortController().signal)
assert.equal(result.message, message)
assert.equal(result.code, code === 'SECRET' ? 'NETWORK' : code)
assert.ok(!result.message.includes('SECRET'))
}
})
+77
View File
@@ -0,0 +1,77 @@
export const NETWORK_ERROR_CODES = [
'NETWORK',
'TIMEOUT',
'ABORTED',
'ENOTFOUND',
'EAI_AGAIN',
'ECONNREFUSED',
'ECONNRESET',
'ETIMEDOUT',
'UND_ERR_CONNECT_TIMEOUT',
'CERT_HAS_EXPIRED',
'DEPTH_ZERO_SELF_SIGNED_CERT',
'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
'ERR_TLS_CERT_ALTNAME_INVALID',
] as const
export type NetworkErrorCode = (typeof NETWORK_ERROR_CODES)[number]
export class NetworkError extends Error {
constructor(
message: string,
public code: NetworkErrorCode = 'NETWORK',
) {
super(message)
}
}
export class SessionChangedError extends Error {
constructor() {
super('Сессия изменилась. Повторите подключение игры')
}
}
export class ApiError extends Error {
constructor(public status: number) {
super(
status === 401
? 'Сессия истекла. Войдите снова'
: status === 403
? 'Нет доступа к действию'
: status === 400
? 'Действие пока недоступно для этой игры'
: `Ошибка сервера: HTTP ${status}`,
)
}
}
function causeCode(error: unknown, depth = 0): NetworkErrorCode | undefined {
if (!error || typeof error !== 'object' || depth > 3) return
const value = error as { code?: unknown; cause?: unknown; errors?: unknown[] }
if (NETWORK_ERROR_CODES.includes(value.code as NetworkErrorCode))
return value.code as NetworkErrorCode
return (
causeCode(value.cause, depth + 1) ??
(Array.isArray(value.errors)
? value.errors.map((e) => causeCode(e, depth + 1)).find(Boolean)
: undefined)
)
}
export function networkFailure(error: unknown, signal: AbortSignal): NetworkError {
if (signal.aborted) {
return signal.reason?.name === 'TimeoutError'
? new NetworkError('Превышено время ожидания ответа сервера', 'TIMEOUT')
: new NetworkError('Подготовка отменена', 'ABORTED')
}
if (error instanceof NetworkError) return error
const code = causeCode(error) ?? 'NETWORK'
const message =
code === 'ENOTFOUND' || code === 'EAI_AGAIN'
? 'Не удалось определить адрес сервера'
: code.includes('CERT') || code === 'UNABLE_TO_VERIFY_LEAF_SIGNATURE'
? 'Ошибка защищённого соединения с сервером'
: code === 'ETIMEDOUT' || code === 'UND_ERR_CONNECT_TIMEOUT'
? 'Превышено время ожидания соединения с сервером'
: code === 'ECONNREFUSED'
? 'Сервер отклонил соединение'
: code === 'ECONNRESET'
? 'Соединение с сервером прервано'
: 'Сервер недоступен'
return new NetworkError(message, code)
}
+68 -1
View File
@@ -1,5 +1,6 @@
import assert from 'node:assert/strict'
import { test } from 'node:test'
import { ApiError, SessionChangedError } from './request-error.js'
import { buildScreenPackage, prepareScreenPackage } from './screen-package.js'
const server = 'https://example.test'
@@ -200,7 +201,7 @@ test('отказы API и некорректный JSON не выдают час
prepareScreenPackage({ server, gameId: 7, token: 'SECRET' }, async () => {
throw new Error('SECRET')
}),
/^Error: Сервер недоступен$/,
/^Error: Описание игры: Сервер недоступен$/,
)
})
@@ -236,3 +237,69 @@ test('отмена и недопустимые адреса обрабатыва
)
await assert.rejects(prepareScreenPackage({ server, gameId: 7, token: '' }), /токен/)
})
test('сетевая ошибка называет запрос и журналирует только безопасную причину', async () => {
for (const suffix of ['', '/screen-plan', '/music']) {
const failures: { path: string; reason: string }[] = []
const data = fixture()
await assert.rejects(
prepareScreenPackage(
{ server, gameId: 7, token: 'SECRET', onRequestFailure: (f) => failures.push(f) },
async (input) => {
const url = String(input)
if (url === `${server}/api/games/7${suffix}`)
throw new TypeError('SECRET', {
cause: Object.assign(new Error('SECRET'), { code: 'ENOTFOUND' }),
})
return Response.json(
url.endsWith('/music')
? data.music
: url.endsWith('/screen-plan')
? data.plan
: data.game,
)
},
),
new RegExp(
`^Error: ${suffix === '/music' ? 'Музыка игры' : suffix === '/screen-plan' ? 'План экранов' : 'Описание игры'}: Не удалось определить адрес сервера$`,
),
)
assert.deepEqual(failures, [{ path: `/api/games/{id}${suffix}`, reason: 'ENOTFOUND' }])
assert.ok(!JSON.stringify(failures).includes('SECRET'))
}
})
test('подготовка сохраняет ошибки авторизации и смены сессии', async () => {
for (const error of [new ApiError(401), new ApiError(403), new SessionChangedError()]) {
const data = fixture()
await assert.rejects(
prepareScreenPackage({ server, gameId: 7, token: 'SECRET' }, async (input) => {
const url = String(input)
if (url.endsWith('/screen-plan')) throw error
return Response.json(url.endsWith('/music') ? data.music : data.game)
}),
(e) => e === error,
)
}
})
test('таймаут и отмена подготовки различаются без утечки причины', async () => {
for (const [name, message, reason] of [
['TimeoutError', 'Превышено время ожидания ответа сервера', 'TIMEOUT'],
['AbortError', 'Подготовка отменена', 'ABORTED'],
]) {
const signal = AbortSignal.abort(new DOMException('SECRET', name))
const failures: { reason: string }[] = []
await assert.rejects(
prepareScreenPackage(
{ server, gameId: 7, token: 'SECRET', signal, onRequestFailure: (f) => failures.push(f) },
async (_input, init) => {
init?.signal?.throwIfAborted()
return Response.json({})
},
),
new RegExp(`^Error: Описание игры: ${message}$`),
)
assert.ok(failures.every((f) => f.reason === reason))
}
})
+41 -5
View File
@@ -1,3 +1,10 @@
import {
ApiError,
NetworkError,
SessionChangedError,
networkFailure,
type NetworkErrorCode,
} from './request-error.js'
import { createHash } from 'node:crypto'
export interface ScreenPackage {
@@ -30,6 +37,10 @@ export interface PackageRequest {
gameId: number
token: string
signal?: AbortSignal
onRequestFailure?: (failure: {
path: string
reason: NetworkErrorCode | 'SESSION_CHANGED' | 'AUTH' | 'HTTP' | 'INVALID_JSON'
}) => void
}
function object(value: unknown): Record<string, unknown> {
@@ -238,12 +249,35 @@ export async function prepareScreenPackage(
redirect: 'error',
signal,
})
} catch {
throw new Error(
signal.aborted ? 'Подготовка отменена или превышено время ожидания' : 'Сервер недоступен',
)
} catch (error) {
const path = `/api/games/{id}${suffix}`
if (error instanceof ApiError || error instanceof SessionChangedError) {
request.onRequestFailure?.({
path,
reason:
error instanceof ApiError
? [401, 403].includes(error.status)
? 'AUTH'
: 'HTTP'
: 'SESSION_CHANGED',
})
throw error
}
const failure = networkFailure(error, signal)
request.onRequestFailure?.({ path, reason: failure.code })
const name =
suffix === '/music'
? 'Музыка игры'
: suffix === '/screen-plan'
? 'План экранов'
: 'Описание игры'
throw new NetworkError(`${name}: ${failure.message}`, failure.code)
}
if (!response.ok)
if (!response.ok) {
request.onRequestFailure?.({
path: `/api/games/{id}${suffix}`,
reason: [401, 403].includes(response.status) ? 'AUTH' : 'HTTP',
})
throw new Error(
response.status === 401
? 'Требуется авторизация'
@@ -251,9 +285,11 @@ export async function prepareScreenPackage(
? 'Нет доступа к игре'
: `Ошибка API: HTTP ${response.status}`,
)
}
try {
return await response.json()
} catch {
request.onRequestFailure?.({ path: `/api/games/{id}${suffix}`, reason: 'INVALID_JSON' })
throw new Error('Некорректный JSON в ответе API')
}
}