Netpulse_SasS/web/src/test/serverlog.test.tsx
byrsapty e6b585dd4c
All checks were successful
CI / hygiene (push) Successful in 11s
CI / web (push) Successful in 1m11s
CI / server (push) Successful in 1m55s
CI / agent (push) Successful in 1m0s
Сторінка «Журнал сервера»: кільцевий буфер у памяті
Продукт показував syslog З ПРИСТРОЇВ, аудит, черги, стенограми команд —
усе про мережу, і нічого про себе. Щоб дізнатись, на що лається сам
NetPulse, треба було заходити по ssh.

Буфер у памяті, не в базі: журнал, що пише в Postgres, замовкає рівно
тоді, коли ляже Postgres — у найцікавіший момент. Стеля в БАЙТАХ
(8 МіБ на процес), а не в рядках: один рядок із текстом SQL-помилки
буває довшим за сотню звичайних, тож «5000 рядків» означало б
непередбачувані десятки мегабайтів.

Два процеси — один перелік із позначкою джерела, а не дві вкладки:
людина знає симптом («о третій ночі перестали йти сповіщення»), а не
те, який із двох процесів за це відповідає.

Маскування — наявним gitstore.Scrub, не своїм: паролі DSN, матеріал
DEK, секрет підпису сесій. Це другий рубіж — відомі шляхи вже почищені
в місці народження, але кільце робить журнал видимим у браузері й
вивантажуваним у файл, що піде в тікет.

Межі написані НА СТОРІНЦІ, а не лише в документації: журнал не
переживає перезапуску й не покаже причини падіння бази. Поруч —
скільки записів витіснено: без цього числа не відрізнити «нічого не
сталося» від «сталося стільки, що початок уже не влазить».
2026-08-27 23:50:03 +03:00

242 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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()
}
})
})