Продукт показував syslog З ПРИСТРОЇВ, аудит, черги, стенограми команд — усе про мережу, і нічого про себе. Щоб дізнатись, на що лається сам NetPulse, треба було заходити по ssh. Буфер у памяті, не в базі: журнал, що пише в Postgres, замовкає рівно тоді, коли ляже Postgres — у найцікавіший момент. Стеля в БАЙТАХ (8 МіБ на процес), а не в рядках: один рядок із текстом SQL-помилки буває довшим за сотню звичайних, тож «5000 рядків» означало б непередбачувані десятки мегабайтів. Два процеси — один перелік із позначкою джерела, а не дві вкладки: людина знає симптом («о третій ночі перестали йти сповіщення»), а не те, який із двох процесів за це відповідає. Маскування — наявним gitstore.Scrub, не своїм: паролі DSN, матеріал DEK, секрет підпису сесій. Це другий рубіж — відомі шляхи вже почищені в місці народження, але кільце робить журнал видимим у браузері й вивантажуваним у файл, що піде в тікет. Межі написані НА СТОРІНЦІ, а не лише в документації: журнал не переживає перезапуску й не покаже причини падіння бази. Поруч — скільки записів витіснено: без цього числа не відрізнити «нічого не сталося» від «сталося стільки, що початок уже не влазить».
242 lines
12 KiB
TypeScript
242 lines
12 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||
import { MemoryRouter, Route, Routes } from 'react-router-dom'
|
||
import { ServerLogPage } from '../pages/ServerLogPage'
|
||
import { AppShell } from '../components/AppShell'
|
||
import { session } from '../api/session'
|
||
import { FakeWebSocket, fetchRouter } from './support'
|
||
import type { Permission } from '../types'
|
||
|
||
/**
|
||
* Сторінка «Журнал сервера»: `pages/ServerLogPage.tsx`.
|
||
*
|
||
* Тут перевіряється не «сторінка малюється», а три речі, кожна з яких
|
||
* ламається тихо й веде до ХИБНОГО ВИСНОВКУ, а не до видимої помилки.
|
||
*
|
||
* 1. **Рядок про межі журналу.** Кільце живе в пам'яті й не переживає
|
||
* перезапуску процесу, а причини падіння бази в ньому не буде
|
||
* ніколи. Якщо цей текст зникне, людина після нічної аварії побачить
|
||
* порожній перелік і зробить висновок «нічого не сталося». Тому текст
|
||
* перевіряється як поведінка, а не як оформлення.
|
||
*
|
||
* 2. **Недоступне джерело проти тихого.** Процесів два, і «колектор
|
||
* мовчить» та «колектор недоступний» на екрані виглядають однаково.
|
||
* Якщо помилка джерела перестане показуватись, сторінка буде
|
||
* показувати половину системи як цілу — і жодного знаку про це.
|
||
*
|
||
* 3. **Фільтр їде на сервер.** Стеля відповіді накладається ПІСЛЯ
|
||
* фільтра, тож фільтрувати на клієнті означає показувати «останні 500
|
||
* рядків, серед яких три помилки» там, де просили «останні 500
|
||
* помилок». Обидві сторінки виглядають робочими.
|
||
*/
|
||
|
||
const t0 = '2026-08-27T03:00:00.000Z'
|
||
|
||
function page(over: Record<string, unknown> = {}) {
|
||
return {
|
||
records: [
|
||
{ seq: 3, time: t0, level: 'ERROR', msg: 'дзеркало', attrs: 'url=https://forgejo.example/np.git', source: 'collector' },
|
||
{ seq: 2, time: t0, level: 'INFO', msg: 'бекап', attrs: 'хостів=12', source: 'collector' },
|
||
{ seq: 1, time: t0, level: 'WARN', msg: 'ліцензія', attrs: 'діб=5', source: 'api' },
|
||
],
|
||
sources: [
|
||
{ key: 'api', label: 'API', ok: true, records: 1, bytes: 1024, max_bytes: 8388608, dropped: 0, since: t0 },
|
||
{ key: 'collector', label: 'Колектор', ok: true, records: 2, bytes: 2048, max_bytes: 8388608, dropped: 0, since: t0 },
|
||
],
|
||
limit: 500,
|
||
...over,
|
||
}
|
||
}
|
||
|
||
function show(body: unknown = page()) {
|
||
const server = fetchRouter({ 'GET /api/v1/server-log': body })
|
||
render(
|
||
<MemoryRouter>
|
||
<ServerLogPage />
|
||
</MemoryRouter>,
|
||
)
|
||
return server
|
||
}
|
||
|
||
beforeEach(() => {
|
||
session.set('tok', {
|
||
userID: 'u-me',
|
||
username: 'me',
|
||
tenantID: 't-1',
|
||
permissions: ['settings:write'],
|
||
})
|
||
vi.stubGlobal('WebSocket', class {})
|
||
})
|
||
|
||
afterEach(() => {
|
||
vi.unstubAllGlobals()
|
||
})
|
||
|
||
describe('межі журналу написані на самій сторінці', () => {
|
||
it('поруч із переліком стоїть, що журнал не переживає перезапуску', async () => {
|
||
show()
|
||
await screen.findByText('дзеркало')
|
||
|
||
// Саме ці два твердження й породжують хибний висновок, якщо їх
|
||
// не написати: людина шукає в журналі те, чого там не буває.
|
||
expect(screen.getByText(/не переживає їхнього перезапуску/)).toBeInTheDocument()
|
||
expect(screen.getByText(/не покаже причини падіння самої бази/)).toBeInTheDocument()
|
||
})
|
||
|
||
it('текст стоїть на сторінці, а не з’являється лише в порожньому журналі', async () => {
|
||
// Друга половина. Без неї зеленим був би й варіант, де пояснення
|
||
// показують тільки тоді, коли рядків немає, — тобто рівно не тоді,
|
||
// коли воно потрібне: людина, яка бачить сотню рядків і НЕ бачить
|
||
// серед них падіння, теж має знати чому.
|
||
show(page({ records: [] }))
|
||
await screen.findByText(/Журнал порожній/)
|
||
expect(screen.getByText(/не переживає їхнього перезапуску/)).toBeInTheDocument()
|
||
})
|
||
})
|
||
|
||
describe('стан двох джерел', () => {
|
||
it('недоступний колектор названо словами, а не сховано в порожнечу', async () => {
|
||
show(
|
||
page({
|
||
records: [{ seq: 1, time: t0, level: 'WARN', msg: 'ліцензія', attrs: '', source: 'api' }],
|
||
sources: [
|
||
{ key: 'api', label: 'API', ok: true, records: 1, bytes: 1024, max_bytes: 8388608, dropped: 0, since: t0 },
|
||
{ key: 'collector', label: 'Колектор', ok: false, error: 'dial tcp: connection refused', records: 0, bytes: 0, max_bytes: 0, dropped: 0 },
|
||
],
|
||
}),
|
||
)
|
||
|
||
await screen.findByText('ліцензія')
|
||
expect(screen.getByText('Колектор — недоступний')).toBeInTheDocument()
|
||
// Причина текстом: без неї «недоступний» лишає людину з здогадками,
|
||
// а лікується це щоразу різним.
|
||
expect(screen.getByText(/connection refused/)).toBeInTheDocument()
|
||
})
|
||
|
||
it('живий колектор недоступним не називається', async () => {
|
||
// Друга половина: перша була б зелена й на сторінці, яка вважає
|
||
// недоступними обидва джерела завжди.
|
||
show()
|
||
await screen.findByText('дзеркало')
|
||
expect(screen.queryByText(/недоступний/)).not.toBeInTheDocument()
|
||
// І про витіснення теж не пишеться, поки нічого не витіснено:
|
||
// попередження, яке горить завжди, перестають читати.
|
||
expect(screen.queryByText(/витіснено/)).not.toBeInTheDocument()
|
||
})
|
||
|
||
it('витіснені рядки названо: тиша й переповнення — різні речі', async () => {
|
||
show(
|
||
page({
|
||
sources: [
|
||
{ key: 'api', label: 'API', ok: true, records: 1, bytes: 8388608, max_bytes: 8388608, dropped: 4321, since: t0 },
|
||
{ key: 'collector', label: 'Колектор', ok: true, records: 2, bytes: 2048, max_bytes: 8388608, dropped: 0, since: t0 },
|
||
],
|
||
}),
|
||
)
|
||
await screen.findByText('дзеркало')
|
||
expect(screen.getByText(/витіснено 4321 старіших/)).toBeInTheDocument()
|
||
})
|
||
})
|
||
|
||
describe('фільтри йдуть на сервер', () => {
|
||
it('вибір рівня перезапитує журнал із параметром level', async () => {
|
||
const server = show()
|
||
await screen.findByText('дзеркало')
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: 'лише error' }))
|
||
|
||
await waitFor(() => {
|
||
expect(server.calls.some((c) => c.url.includes('level=error'))).toBe(true)
|
||
})
|
||
})
|
||
|
||
it('пошук їде на сервер, а не фільтрує вже завантажене', async () => {
|
||
const server = show()
|
||
await screen.findByText('дзеркало')
|
||
|
||
fireEvent.change(screen.getByLabelText('пошук у журналі'), {
|
||
target: { value: 'forgejo' },
|
||
})
|
||
|
||
await waitFor(
|
||
() => {
|
||
expect(server.calls.some((c) => c.url.includes('q=forgejo'))).toBe(true)
|
||
},
|
||
{ timeout: 2000 },
|
||
)
|
||
})
|
||
|
||
it('джерело звужує перелік, але обидва лишаються доступними', async () => {
|
||
show()
|
||
await screen.findByText('дзеркало')
|
||
// До звуження видно рядки обох процесів.
|
||
expect(screen.getByText('ліцензія')).toBeInTheDocument()
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: 'Колектор' }))
|
||
await waitFor(() => expect(screen.queryByText('ліцензія')).not.toBeInTheDocument())
|
||
expect(screen.getByText('дзеркало')).toBeInTheDocument()
|
||
|
||
// І назад: фільтр, а не вкладка — «обидва» має лишатись досяжним.
|
||
fireEvent.click(screen.getByRole('button', { name: 'обидва' }))
|
||
await waitFor(() => expect(screen.getByText('ліцензія')).toBeInTheDocument())
|
||
})
|
||
})
|
||
|
||
describe('пункт меню й право на нього', () => {
|
||
/** Оболонка з довільним набором прав. */
|
||
function shell(permissions: Permission[]) {
|
||
session.set('tok', { userID: 'u-1', username: 'me', tenantID: 't-1', permissions })
|
||
FakeWebSocket.reset()
|
||
vi.stubGlobal('WebSocket', FakeWebSocket)
|
||
fetchRouter({
|
||
'GET /api/v1/alerts': {
|
||
alerts: [],
|
||
counts: { firing: 0, acknowledged: 0, suppressed: 0, disaster: 0, high: 0 },
|
||
},
|
||
})
|
||
return render(
|
||
<MemoryRouter initialEntries={['/devices']}>
|
||
<Routes>
|
||
<Route element={<AppShell />}>
|
||
<Route path="*" element={<div>сторінка</div>} />
|
||
</Route>
|
||
</Routes>
|
||
</MemoryRouter>,
|
||
)
|
||
}
|
||
|
||
it('із settings:write пункт є', async () => {
|
||
shell(['settings:write'])
|
||
expect(await screen.findByRole('link', { name: /Журнал сервера/ })).toBeInTheDocument()
|
||
})
|
||
|
||
it('самого audit:read замало — і це навмисно', async () => {
|
||
// Обидві половини потрібні. Перша половина сама по собі була б
|
||
// зелена й тоді, коли пункт зник для всіх; ця — сама по собі
|
||
// зелена й тоді, коли право взагалі не перевіряється правильно.
|
||
//
|
||
// Зміст перевірки: аудит («хто що зробив») дають внутрішньому
|
||
// контролеру, і системний журнал з адресами, іменами хостів і
|
||
// текстами помилок SQL до нього не додається мовчки.
|
||
shell(['audit:read'])
|
||
expect(await screen.findByRole('link', { name: /Журнал аудиту/ })).toBeInTheDocument()
|
||
expect(screen.queryByRole('link', { name: /Журнал сервера/ })).not.toBeInTheDocument()
|
||
})
|
||
})
|
||
|
||
describe('автооновлення', () => {
|
||
it('вимкнене автооновлення справді спиняє опитування', async () => {
|
||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||
try {
|
||
const server = show()
|
||
await screen.findByText('дзеркало')
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: /Автооновлення/ }))
|
||
const before = server.calls.length
|
||
await vi.advanceTimersByTimeAsync(20_000)
|
||
expect(server.calls.length).toBe(before)
|
||
} finally {
|
||
vi.useRealTimers()
|
||
}
|
||
})
|
||
})
|