Files
EvoFirewall/apps/web/test/interaction.tsx
T
Denozordec 9fa6269174
quality / changes (push) Successful in 7s
quality / commitlint (push) Skipped
quality / openapi (push) Successful in 30s
quality / web (push) Successful in 1m3s
quality / api (push) Successful in 1m15s
quality / linux-agent-smoke (push) Failing after 27s
quality / docker-check (push) Failing after 2m28s
CD / quality (push) Failing after 5m52s
CD / publish (push) Skipped
feat: harden firewall policies telemetry and operations
Implement reliable agent apply, telemetry v2, analytics retention, guarded rollouts, enrollment, labels and webhooks. Improve ReUI frontend data safety and bounded list loading, and add Docker smoke, recovery and resource checks. Document local verification and pending native acceptance.
2026-10-26 13:10:58 +07:00

193 lines
12 KiB
TypeScript

// Browser-only mounted regression checks. This page is outside the production
// entry graph and replaces fetch with fixtures before mounting application UI.
import { act, useEffect, useState } from 'react'
import { createRoot } from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { QueryState } from '@/components/query-state'
import { AgentPolicySetsSortable } from '@/components/agents/agent-policy-sets-sortable'
import { AgentDiagnosticsPanel } from '@/components/agents/agent-diagnostics-panel'
import { TrafficTimeseriesChart } from '@/components/stats/traffic-timeseries-chart'
import { PortalShell } from '@/components/layout/portal-shell'
import { createRootRoute, createRouter, createMemoryHistory, RouterProvider } from '@tanstack/react-router'
import { ensureAuthConfig } from '@/lib/auth'
import { DEFAULT_APP_SWITCHER_CONFIG } from '@/lib/app-switcher-config'
import type { AgentDiagnostics, TimeseriesPoint } from '@evofw/shared'
import '@evofw/ui/globals.css'
if (!import.meta.env.DEV) throw new Error('Development checks only')
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
const results = document.querySelector<HTMLPreElement>('#results')!
const host = document.querySelector<HTMLDivElement>('#fixture')!
const root = createRoot(host)
const checks: string[] = []
function check(condition: unknown, label: string) {
if (!condition) throw new Error(label)
checks.push(`PASS ${label}`)
results.textContent = checks.join('\n')
}
let mounts = 0
let unmounts = 0
function Editor() {
const [value, setValue] = useState('initial')
useEffect(() => { mounts++; return () => { unmounts++ } }, [])
return <input aria-label="Operator edit" value={value} onChange={(e) => setValue(e.target.value)} />
}
try {
const view = (failed: boolean) => (
<QueryState hasData isError={failed} error={new Error('Fixture offline')}>
<Editor />
</QueryState>
)
await act(async () => root.render(view(false)))
const input = host.querySelector<HTMLInputElement>('input')!
input.focus()
await act(async () => {
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(input, 'unsaved')
input.dispatchEvent(new Event('input', { bubbles: true }))
})
await act(async () => root.render(view(true)))
check(host.querySelector('input') === input, 'background failure preserves the input DOM node')
check(document.activeElement === input, 'background failure preserves focus')
check(input.value === 'unsaved', 'background failure preserves local edit state')
check(mounts === 1 && unmounts === 0, 'background failure does not remount the editor')
await act(async () => root.render(view(false)))
check(host.querySelector('input') === input && input.value === 'unsaved', 'refresh recovery preserves the edit')
let assignmentPuts = 0
const originalFetch = window.fetch
window.fetch = async (request, init) => {
const path = String(request)
if (init?.method === 'PUT') assignmentPuts++
if (path.endsWith('/agents/fixture/policy-sets')) {
return Response.json({ error: { message: 'Assignment fixture unavailable' } }, { status: 503 })
}
if (path.endsWith('/policy-sets')) {
return Response.json({ items: [{ id: 'catalog-set', name: 'Fixture set', enabled: true }] })
}
throw new Error(`Unexpected fixture request: ${path}`)
}
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
await act(async () => {
root.render(<QueryClientProvider client={qc}><AgentPolicySetsSortable agentId="fixture" /></QueryClientProvider>)
await new Promise((resolve) => setTimeout(resolve, 100))
})
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 100)) })
const add = [...host.querySelectorAll<HTMLButtonElement>('button')].find((button) => button.textContent?.trim() === 'Добавить')
check(host.textContent?.includes('Assignment fixture unavailable'), 'assignment GET failure displays its error')
check(add?.disabled, 'assignment GET failure disables assignment writes')
add?.click()
check(assignmentPuts === 0, 'assignment GET failure cannot produce a replacement PUT')
await act(async () => root.unmount())
qc.clear()
// Import the router plugin's development split component so the test mounts
// the real settings page without adding a production export or route.
const splitUrl = '/src/routes/_auth/settings.tsx?tsr-split=component'
const { component: SettingsPage } = await import(/* @vite-ignore */ splitUrl)
let storedSettings = { enroll_seed: 'fixture-seed', evobgp_api_url: '', evobgp_api_token: '', agent_sync_interval_sec: '60', show_quick_actions: 'true' }
let settingsOffline = false
let saves = 0
window.fetch = async (request, init) => {
if (!String(request).endsWith('/api/v1/settings')) throw new Error('Unexpected settings fixture request')
if (init?.method === 'PUT') {
storedSettings = JSON.parse(String(init.body))
saves++
return Response.json({ ok: true })
}
return settingsOffline
? Response.json({ error: { message: 'Settings fixture offline' } }, { status: 503 })
: Response.json(storedSettings)
}
const settingsClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
const settingsRoot = createRoot(host)
await act(async () => {
settingsRoot.render(<QueryClientProvider client={settingsClient}><SettingsPage /></QueryClientProvider>)
await new Promise((resolve) => setTimeout(resolve, 100))
})
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 100)) })
const seed = host.querySelector<HTMLInputElement>('#enroll_seed')!
check(seed?.value === 'fixture-seed', 'real settings page hydrates the fixture')
seed.focus()
await act(async () => {
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(seed, 'operator-unsaved-seed')
seed.dispatchEvent(new Event('input', { bubbles: true }))
})
check(host.textContent?.includes('Есть несохранённые изменения'), 'editing settings records dirty state')
await act(async () => { settingsClient.setQueryData(['settings'], { ...storedSettings, enroll_seed: 'remote-refresh' }) })
check(seed.value === 'operator-unsaved-seed', 'settings background data cannot reset dirty fields')
settingsOffline = true
await act(async () => { await settingsClient.refetchQueries({ queryKey: ['settings'] }); await new Promise((resolve) => setTimeout(resolve, 30)) })
check(host.querySelector('#enroll_seed') === seed && seed.value === 'operator-unsaved-seed', 'settings refresh error preserves mounted dirty fields')
check(document.activeElement === seed, 'settings refresh error preserves input focus')
settingsOffline = false
await act(async () => { await settingsClient.refetchQueries({ queryKey: ['settings'] }); await new Promise((resolve) => setTimeout(resolve, 30)) })
await act(async () => {
host.querySelector<HTMLFormElement>('form')!.requestSubmit()
await new Promise((resolve) => setTimeout(resolve, 100))
})
check(saves === 1 && storedSettings.enroll_seed === 'operator-unsaved-seed', 'settings save sends the operator edit once')
check(seed.value === 'operator-unsaved-seed' && !host.textContent?.includes('Есть несохранённые изменения'), 'accepted settings become clean without reverting to stale cache')
await act(async () => settingsRoot.unmount())
settingsClient.clear()
const diagnostics: AgentDiagnostics = {
desired_revision: 8, desired_hash: 'sha256:desired-fixture', applied_revision: 7, applied_hash: 'sha256:applied-fixture',
agent_version: 'fixture-v2', capabilities: ['traffic-v2'], last_contact_at: '2026-10-24T00:05:00Z',
last_telemetry_at: null, last_apply_at: null, apply_error: null, status: 'drift', telemetry_version: 2,
quality: { partial: true, truncated: true, unsupported: ['accepted'], lost_reports: 2 },
}
const diagnosticRoot = createRoot(host)
await act(async () => diagnosticRoot.render(<AgentDiagnosticsPanel diagnostics={diagnostics} />))
check(host.textContent?.includes('Расхождение политики') && !host.textContent?.includes('Синхронизирован'), 'diagnostics displays authoritative drift instead of inferring synchronization from contact')
check(host.textContent?.includes('Не получена') && host.textContent?.includes('Не подтверждено'), 'diagnostics distinguishes missing telemetry from missing apply acknowledgment')
check(host.textContent?.includes('Неполный') && host.textContent?.includes('Обрезан') && host.textContent?.includes('Потеряно отчётов: 2'), 'diagnostics exposes partial, truncated and lost-report quality')
const nullPoint: TimeseriesPoint = { timestamp: '2026-10-24T00:00:00.000Z', packets_dropped: null, packets_accepted: null, pps: null, coverage: 0, estimated: false, quality: { partial: false, truncated: false, unsupported: ['packets'], lost_reports: 0 } }
await act(async () => diagnosticRoot.render(<TrafficTimeseriesChart items={[nullPoint]} />))
check(host.textContent?.includes('Пропуски не означают нулевой трафик') && !host.querySelector('.recharts-surface'), 'missing packet measurements render an explicit gap state instead of a zero traffic chart')
check(nullPoint.packets_dropped === null && nullPoint.packets_accepted === null, 'chart rendering leaves missing packet values null')
await act(async () => diagnosticRoot.unmount())
let fullCatalogRequests = 0
let summaryRequests = 0
const previousToken = localStorage.getItem('fw_auth_token')
localStorage.setItem('fw_auth_token', `fixture.${btoa(JSON.stringify({ sub: 'fixture', name: 'Fixture reader', apps: ['fw'], permissions: ['fw:agents:read'] }))}.fixture`)
window.fetch = async (request) => {
const path = String(request)
if (path.endsWith('/api/auth/config')) return Response.json({ required: true, portal_url: 'https://fixture.invalid' })
if (path.endsWith('/api/v1/app-switcher')) return Response.json(DEFAULT_APP_SWITCHER_CONFIG)
if (path.endsWith('/api/v1/ui/summary')) {
summaryRequests++
return Response.json({ updated_at: '2026-10-24T00:00:00.000Z', agents: { approved: 0, online: 0, pending: 0, offline: 0 } })
}
if (/\/api\/v1\/(agents|lists|policy-sets)$/.test(path)) fullCatalogRequests++
throw new Error(`Unexpected shell fixture request: ${path}`)
}
await ensureAuthConfig()
const shellClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
const fixtureRoute = createRootRoute({ component: () => <QueryClientProvider client={shellClient}><PortalShell><p>Read-only summary fixture</p></PortalShell></QueryClientProvider> })
const fixtureRouter = createRouter({ routeTree: fixtureRoute, history: createMemoryHistory({ initialEntries: ['/'] }) })
const shellRoot = createRoot(host)
await act(async () => {
shellRoot.render(<RouterProvider router={fixtureRouter} />)
await new Promise((resolve) => setTimeout(resolve, 100))
})
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 100)) })
check(summaryRequests === 1 && fullCatalogRequests === 0, 'closed shell reads one summary without fetching full agent/list/policy catalogs')
const monitor = [...host.querySelectorAll<HTMLButtonElement>('button')].find((button) => button.getAttribute('aria-label')?.startsWith('Состояние системы:'))!
await act(async () => { monitor.click(); await new Promise((resolve) => setTimeout(resolve, 50)) })
check(document.body.textContent?.includes('Нет одобренных') && !document.body.textContent?.includes('Списки IP'), 'permission-omitted summary sections stay hidden instead of showing fabricated zero metrics')
check(!host.textContent?.includes('Списки') && !host.textContent?.includes('Наборы правил'), 'read-only agent permission hides unrelated navigation sections')
await act(async () => shellRoot.unmount())
shellClient.clear()
if (previousToken === null) localStorage.removeItem('fw_auth_token')
else localStorage.setItem('fw_auth_token', previousToken)
window.fetch = originalFetch
results.dataset.status = 'passed'
} catch (error) {
results.textContent = `${checks.join('\n')}\nFAIL ${error instanceof Error ? error.stack : String(error)}`
results.dataset.status = 'failed'
}