feat: harden firewall policies telemetry and operations
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
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
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.
This commit is contained in:
1 parent
aa475848fd
commit
9fa6269174
242 files changed
+19872
-3057
No files matched your search
@@ -0,0 +1 @@
|
||||
<!doctype html><html lang="en"><head><meta charset="UTF-8"><title>EvoFirewall F1 interaction checks</title></head><body><h1>Isolated F1 interaction checks</h1><pre id="results">Running…</pre><pre id="metrics"></pre><div id="fixture"></div><script type="module" src="/test/f1.tsx"></script></body></html>
|
||||
@@ -0,0 +1,112 @@
|
||||
import { act, Profiler, useState, type ReactNode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import type { ExplainResponse } from '@evofw/shared'
|
||||
import { AgentPortRuleForm, AgentPortImportForm } from '@/components/agents/agent-port-acl-forms'
|
||||
import { portAclDraftSchema } from '@/components/agents/port-acl-validation'
|
||||
import { AgentExplainSheet } from '@/components/agents/agent-explain-sheet'
|
||||
import { AgentOverrideSheet } from '@/components/agents/agent-settings-sheets'
|
||||
import { StatsAgentSelect } from '@/components/stats/stats-agent-select'
|
||||
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 metrics = document.querySelector<HTMLPreElement>('#metrics')!
|
||||
const host = document.querySelector<HTMLDivElement>('#fixture')!
|
||||
const root = createRoot(host)
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } })
|
||||
const originalFetch = window.fetch
|
||||
const checks: string[] = []
|
||||
const measured = { scope: 'Current fixture harness only; no baseline comparison', commits: 0, actualDurationMs: 0, requestCounts: {} as Record<string, number> }
|
||||
function reportMetrics() { metrics.textContent = JSON.stringify(measured, null, 2) }
|
||||
function check(condition: unknown, label: string) { if (!condition) throw new Error(label); checks.push(`PASS ${label}`); results.textContent = checks.join('\n') }
|
||||
const flush = () => new Promise((resolve) => setTimeout(resolve, 50))
|
||||
async function render(node: ReactNode) { await act(async () => { root.render(<Profiler id="f1-fixtures" onRender={(_id, _phase, actualDuration) => { measured.commits++; measured.actualDurationMs += actualDuration; reportMetrics() }}><QueryClientProvider client={qc}>{node}</QueryClientProvider></Profiler>); await flush() }) }
|
||||
async function input(id: string, value: string) { await act(async () => { const field = document.getElementById(id) as HTMLInputElement; Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(field, value); field.dispatchEvent(new Event('input', { bubbles: true })); await flush() }) }
|
||||
async function submit() { await act(async () => { document.querySelector<HTMLFormElement>('[data-slot="sheet-content"] form')!.requestSubmit(); await flush() }) }
|
||||
async function choose(id: string, label: string) { await act(async () => { document.getElementById(id)!.click(); await flush() }); await act(async () => { const option = [...document.querySelectorAll<HTMLElement>('[role="option"]')].find((item) => item.textContent?.trim() === label); if (!option) throw new Error(`Option not found: ${label}`); option.click(); await flush() }) }
|
||||
const explain: ExplainResponse = { agent_id: 'fixture', query: { ip: '203.0.113.10', port: 443, protocol: 'tcp', chain: 'input', dnat: false, connection_state: 'new' }, desired: { revision: 8, hash: 'desired', decision: null, stage: 'external', match: null }, applied: { revision: null, hash: null, same_as_desired: null, status: 'unsupported' }, matches: [], matches_total: 0, truncated: false, limits: ['desired_policy_not_kernel_observation', 'external_firewall_may_override_accept'], external_firewall: { observed_at: null, rule_count: 0, complete: false } }
|
||||
let explainGets = 0
|
||||
let overridePosts: Record<string, unknown>[] = []
|
||||
let failOverride = true
|
||||
window.fetch = async (request, init) => {
|
||||
const url = String(request)
|
||||
const path = url.split('?')[0]
|
||||
const key = `${init?.method ?? 'GET'} ${path}`
|
||||
measured.requestCounts[key] = (measured.requestCounts[key] ?? 0) + 1
|
||||
reportMetrics()
|
||||
if (path.endsWith('/api/v1/agents')) return Response.json({ items: [{ id: 'fixture-office', name: 'Office network', hostname: 'office.example' }, { id: 'fixture-edge', name: 'Edge gateway', hostname: 'edge.example' }] })
|
||||
if (path.endsWith('/explain')) { explainGets++; return Response.json(explain) }
|
||||
if (path.endsWith('/overrides')) {
|
||||
if (init?.method === 'POST') { overridePosts.push(JSON.parse(String(init.body))); return failOverride ? Response.json({ error: { message: 'Fixture write rejected' } }, { status: 503 }) : Response.json({ ok: true }) }
|
||||
return Response.json({ items: [{ id: 'expired', agent_id: 'fixture', cidr: '198.51.100.0/24', action: 'deny', created_at: '2026-10-23T00:00:00Z', created_by_name: 'Fixture author', reason: 'Fixture incident', expires_at: '2026-10-23T01:00:00Z', expired: true, expiration_enforcement: 'next_policy_sync' }] })
|
||||
}
|
||||
throw new Error(`Unexpected fixture request: ${key}`)
|
||||
}
|
||||
|
||||
try {
|
||||
const catalog = { items: [{ value: 'fixture-list', label: 'Office ranges' }], loading: false, ready: true, error: null, retry: () => {} }
|
||||
const initial = { action: 'open' as const, protocol: 'tcp' as const, port_start: '', port_end: '', src_kind: 'all' as const, src_cidr: '', list_id: '', enabled: true, comment: '' }
|
||||
let saves = 0
|
||||
let savedPort: number | null = null
|
||||
const rule = (list = catalog) => <AgentPortRuleForm open onOpenChange={() => {}} title="Fixture rule" description="Fixture only" canWrite pending={false} lists={list} initialValues={initial} onSubmit={(values) => { saves++; savedPort = portAclDraftSchema.parse(values).port_start }} />
|
||||
await render(rule())
|
||||
await input('acl-port_start', '1e3'); await submit()
|
||||
check(saves === 0 && document.getElementById('acl-port_start')?.getAttribute('aria-invalid') === 'true', 'RHF Port ACL blocks exponent notation before save')
|
||||
await input('acl-port_start', '443'); await input('acl-port_end', '80'); await submit()
|
||||
check(saves === 0 && document.getElementById('acl-port_end')?.getAttribute('aria-invalid') === 'true', 'RHF Port ACL rejects reversed range with an inline error')
|
||||
await input('acl-port_end', '')
|
||||
const portNode = document.getElementById('acl-port_start')
|
||||
await render(rule({ ...catalog, items: [...catalog.items] }))
|
||||
check(document.getElementById('acl-port_start') === portNode && (portNode as HTMLInputElement).value === '443', 'catalog refresh preserves the mounted dirty Port ACL draft')
|
||||
await submit()
|
||||
check(saves === 1 && savedPort === 443, 'form submit delivers one validated Port ACL save')
|
||||
|
||||
let imports = 0
|
||||
let imported = ''
|
||||
await render(<AgentPortImportForm open onOpenChange={() => {}} canWrite pending={false} lists={catalog} sets={catalog} onSubmit={(values) => { imports++; imported = `${values.listId}:${values.ports}` }} />)
|
||||
await input('acl-import-ports', '80-'); await submit()
|
||||
check(imports === 0 && document.getElementById('acl-import-ports')?.getAttribute('aria-invalid') === 'true', 'RHF import rejects malformed ports and missing source')
|
||||
await choose('acl-import-source', 'Office ranges'); await input('acl-import-ports', '22,8000-8001'); await submit()
|
||||
check(imports === 1 && imported === 'fixture-list:22,8000-8001', 'RHF import submits its selected source and valid ranges once')
|
||||
|
||||
let selection: string[] = []
|
||||
function Selection() { const [value, setValue] = useState<string[]>([]); return <StatsAgentSelect value={value} onChange={(next) => { selection = next; setValue(next) }} /> }
|
||||
await render(<Selection />)
|
||||
await act(async () => { const field = document.getElementById('stats-agents')!; field.focus(); field.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); await flush(); await flush() })
|
||||
check(document.getElementById('stats-agents')?.getAttribute('aria-expanded') === 'true', 'agent combobox opens through keyboard interaction')
|
||||
await act(async () => { const option = [...document.querySelectorAll<HTMLElement>('[role="option"]')].find((item) => item.textContent?.includes('Office network')); if (!option) throw new Error('Office network option not found'); option.click(); await flush() })
|
||||
check(selection[0] === 'fixture-office' && host.textContent?.includes('Office network') && !host.textContent?.includes('fixture-office'), 'agent filtering selects names without exposing raw identifiers')
|
||||
|
||||
await render(<AgentExplainSheet agentId="fixture" open onOpenChange={() => {}} />)
|
||||
await input('explain-ip', '203.0.113.10'); await input('explain-port', '443'); await submit()
|
||||
check(explainGets === 0 && document.getElementById('explain-port')?.getAttribute('aria-invalid') === 'true', 'Explain blocks an unpaired transport input without a request')
|
||||
await choose('explain-protocol', 'TCP'); await submit(); await act(async () => { await flush() })
|
||||
check(explainGets === 1 && document.body.textContent?.includes('Решение не определено') && document.body.textContent?.includes('Совпадение неизвестно'), 'Explain preserves external and unconfirmed decisions after a valid read')
|
||||
await input('explain-ip', '203.0.113.11')
|
||||
check(explainGets === 1 && document.body.textContent?.includes('Форма изменена — запустите проверку снова'), 'editing Explain retains a labeled previous result without issuing another query')
|
||||
|
||||
await render(<AgentOverrideSheet agentId="fixture" open onOpenChange={() => {}} />)
|
||||
await act(async () => { await flush() })
|
||||
check(document.body.textContent?.includes('Fixture author') && document.body.textContent?.includes('Истёк'), 'override history shows server-owned author and expired state')
|
||||
await input('ov-cidr', '203.0.113.10'); await choose('ov-expiry-mode', 'На время'); await input('ov-ttl', '0'); await submit()
|
||||
check(overridePosts.length === 0 && document.getElementById('ov-ttl')?.getAttribute('aria-invalid') === 'true', 'override expiry validation blocks zero TTL')
|
||||
await input('ov-ttl', '15')
|
||||
await act(async () => { const reason = document.getElementById('ov-reason') as HTMLTextAreaElement; Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')!.set!.call(reason, 'Fixture reason'); reason.dispatchEvent(new Event('input', { bubbles: true })); await flush() })
|
||||
await submit(); await act(async () => { await flush() })
|
||||
check(overridePosts.length === 1 && (document.getElementById('ov-cidr') as HTMLInputElement).value === '203.0.113.10', 'failed override write preserves the dirty draft')
|
||||
failOverride = false
|
||||
await submit(); await act(async () => { await flush() })
|
||||
check(overridePosts.length === 2 && overridePosts[1].ttl_seconds === 900 && overridePosts[1].reason === 'Fixture reason' && !('expires_at' in overridePosts[1]) && !('created_by_name' in overridePosts[1]), 'temporary override sends validated TTL and reason without author spoofing')
|
||||
check((document.getElementById('ov-cidr') as HTMLInputElement).value === '' && document.body.textContent?.includes('следующей связи'), 'successful override resets the draft and retains next-sync expiry limits')
|
||||
results.dataset.status = 'passed'
|
||||
} catch (error) {
|
||||
results.textContent = `${checks.join('\n')}\nFAIL ${error instanceof Error ? error.stack : String(error)}`
|
||||
results.dataset.status = 'failed'
|
||||
} finally {
|
||||
await act(async () => root.unmount())
|
||||
qc.clear()
|
||||
window.fetch = originalFetch
|
||||
reportMetrics()
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<!doctype html><html lang="en"><head><meta charset="UTF-8"><title>EvoFirewall F2/F3 interaction checks</title></head><body><h1>Isolated F2/F3 interaction checks</h1><pre id="results">Running…</pre><pre id="metrics"></pre><div id="fixture"></div><script type="module" src="/test/f2.tsx"></script></body></html>
|
||||
@@ -0,0 +1,515 @@
|
||||
import { act, type ReactNode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import type { PolicyRollout } from '@evofw/shared'
|
||||
import { WebhooksSheet } from '@/components/webhooks-sheet'
|
||||
import { AgentLabelsSheet } from '@/components/agents/agent-labels-sheet'
|
||||
import { PolicyRolloutsSheet } from '@/components/rules/policy-rollouts-sheet'
|
||||
import { AgentCredentialsSheet } from '@/components/agents/agent-credentials-sheet'
|
||||
import { ensureAuthConfig, setToken, getToken } from '@/lib/auth'
|
||||
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 metrics = document.querySelector<HTMLPreElement>('#metrics')!
|
||||
const root = createRoot(document.querySelector<HTMLDivElement>('#fixture')!)
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
})
|
||||
const originalFetch = window.fetch
|
||||
const originalToken = getToken()
|
||||
const checks: string[] = []
|
||||
const calls: { method: string; path: string; body: any }[] = []
|
||||
const flush = () => new Promise((resolve) => setTimeout(resolve, 75))
|
||||
function check(value: unknown, label: string) {
|
||||
if (!value) throw new Error(label)
|
||||
checks.push(`PASS ${label}`)
|
||||
results.textContent = checks.join('\n')
|
||||
}
|
||||
async function render(node: ReactNode) {
|
||||
await act(async () => {
|
||||
root.render(<QueryClientProvider client={qc}>{node}</QueryClientProvider>)
|
||||
await flush()
|
||||
})
|
||||
await act(async () => {
|
||||
await flush()
|
||||
})
|
||||
}
|
||||
async function click(text: string) {
|
||||
await act(async () => {
|
||||
const button = [
|
||||
...document.querySelectorAll<HTMLButtonElement>('button'),
|
||||
].find((item) => item.textContent?.trim() === text && !item.disabled)
|
||||
if (!button) throw new Error(`Button missing or disabled: ${text}`)
|
||||
button.click()
|
||||
await flush()
|
||||
})
|
||||
}
|
||||
function button(text: string) {
|
||||
return [...document.querySelectorAll<HTMLButtonElement>('button')].find(
|
||||
(item) => item.textContent?.trim() === text,
|
||||
)
|
||||
}
|
||||
async function input(id: string, value: string) {
|
||||
await act(async () => {
|
||||
const field = document.getElementById(id) as HTMLInputElement
|
||||
Object.getOwnPropertyDescriptor(
|
||||
HTMLInputElement.prototype,
|
||||
'value',
|
||||
)!.set!.call(field, value)
|
||||
field.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
await flush()
|
||||
})
|
||||
}
|
||||
async function submit() {
|
||||
await act(async () => {
|
||||
document
|
||||
.querySelector<HTMLFormElement>('[data-slot="sheet-content"] form')!
|
||||
.requestSubmit()
|
||||
await flush()
|
||||
})
|
||||
}
|
||||
async function choose(id: string, label: string) {
|
||||
await act(async () => {
|
||||
const field = document.getElementById(id)!
|
||||
field.focus()
|
||||
field.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }))
|
||||
await flush()
|
||||
await flush()
|
||||
})
|
||||
await act(async () => {
|
||||
const option = [
|
||||
...document.querySelectorAll<HTMLElement>('[role="option"]'),
|
||||
].find((item) => item.textContent?.trim() === label)
|
||||
if (!option) throw new Error(`Option missing: ${label}`)
|
||||
option.click()
|
||||
await flush()
|
||||
})
|
||||
await act(async () => {
|
||||
document
|
||||
.getElementById(id)
|
||||
?.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }),
|
||||
)
|
||||
await flush()
|
||||
})
|
||||
}
|
||||
async function chooseSelect(index: number, label: string) {
|
||||
await act(async () => {
|
||||
document
|
||||
.querySelectorAll<HTMLElement>('[data-slot="select-trigger"]')
|
||||
[index].click()
|
||||
await flush()
|
||||
})
|
||||
await act(async () => {
|
||||
const option = [
|
||||
...document.querySelectorAll<HTMLElement>('[role="option"]'),
|
||||
].find((item) => item.textContent?.trim() === label)
|
||||
if (!option) throw new Error(`Select option missing: ${label}`)
|
||||
option.click()
|
||||
await flush()
|
||||
})
|
||||
}
|
||||
const writes = (path: string) =>
|
||||
calls.filter((call) => call.method !== 'GET' && call.path === path)
|
||||
const fixtureToken = (permissions: string[]) =>
|
||||
`fixture.${btoa(JSON.stringify({ sub: 'fixture', permissions, apps: ['fw'] }))}.unsigned`
|
||||
let configured = true,
|
||||
failHook = true,
|
||||
failLabels = true,
|
||||
pendingRotation = false
|
||||
const endpoint = {
|
||||
id: 'hook',
|
||||
name: 'Existing endpoint',
|
||||
url: 'https://fixture.invalid/hook',
|
||||
events: ['agent.offline'],
|
||||
enabled: false,
|
||||
secret_configured: true,
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
}
|
||||
let rollout: PolicyRollout = {
|
||||
id: 'rollout',
|
||||
snapshot_id: 'snapshot',
|
||||
state: 'canary',
|
||||
version: 9,
|
||||
created_at: '2026-10-24',
|
||||
updated_at: '',
|
||||
targets: [
|
||||
{
|
||||
agent_id: 'target',
|
||||
is_canary: true,
|
||||
phase: 'candidate',
|
||||
desired_revision: 8,
|
||||
desired_hash: 'new',
|
||||
applied_revision: null,
|
||||
applied_hash: null,
|
||||
acknowledged: false,
|
||||
previous_desired_snapshot_id: 'previous',
|
||||
},
|
||||
],
|
||||
}
|
||||
window.fetch = async (request, init) => {
|
||||
const url = String(request),
|
||||
path = new URL(url, location.origin).pathname,
|
||||
method = init?.method ?? 'GET'
|
||||
const body = init?.body ? JSON.parse(String(init.body)) : null
|
||||
calls.push({ method, path, body })
|
||||
metrics.textContent = JSON.stringify(
|
||||
calls.map(({ method, path }) => ({ method, path })),
|
||||
null,
|
||||
2,
|
||||
)
|
||||
if (path === '/api/auth/config') return Response.json({ required: true })
|
||||
if (path === '/api/v1/agents')
|
||||
return Response.json({
|
||||
items: [
|
||||
{ id: 'target', name: 'Fixture target', hostname: 'fixture.invalid' },
|
||||
],
|
||||
})
|
||||
if (path === '/api/v1/webhooks') {
|
||||
if (method === 'POST')
|
||||
return failHook
|
||||
? Response.json(
|
||||
{ error: { message: 'Fixture webhook failure' } },
|
||||
{ status: 503 },
|
||||
)
|
||||
: Response.json(endpoint)
|
||||
return Response.json({
|
||||
items: [endpoint],
|
||||
configured,
|
||||
limits: { endpoints: 10, pending: 100 },
|
||||
queue: { pending: 1, dead: 2, dropped: 3 },
|
||||
})
|
||||
}
|
||||
if (path === '/api/v1/webhooks/hook') return Response.json(endpoint)
|
||||
if (path.endsWith('/deliveries'))
|
||||
return Response.json({ items: [], total: 0 })
|
||||
if (path === '/api/v1/agent-labels')
|
||||
return Response.json({
|
||||
items: [
|
||||
{ id: 'label-a', name: 'Fixture label A', color: null, created_at: '' },
|
||||
{ id: 'label-b', name: 'Fixture label B', color: null, created_at: '' },
|
||||
],
|
||||
total: 2,
|
||||
})
|
||||
if (path.endsWith('/labels')) {
|
||||
if (method === 'PUT' && failLabels)
|
||||
return Response.json(
|
||||
{ error: { message: 'Fixture label write rejected' } },
|
||||
{ status: 409 },
|
||||
)
|
||||
return Response.json({ agent_id: 'target', label_ids: ['label-a'] })
|
||||
}
|
||||
if (path === '/api/v1/policy-sets')
|
||||
return Response.json({ items: [{ id: 'set', name: 'Fixture set' }] })
|
||||
if (path.endsWith('/preview-assignment'))
|
||||
return Response.json({
|
||||
mode: body.mode,
|
||||
items: [
|
||||
{
|
||||
agent_id: 'target',
|
||||
generation: 7,
|
||||
previous_set_ids: [],
|
||||
set_ids: ['set'],
|
||||
changed: true,
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
})
|
||||
if (path.endsWith('/assign-policy-sets'))
|
||||
return Response.json({ items: [], affected_agent_ids: ['target'] })
|
||||
if (path === '/api/v1/policies/snapshots')
|
||||
return Response.json({
|
||||
items: [{ id: 'snapshot', name: 'Fixture snapshot' }],
|
||||
})
|
||||
if (path.endsWith('/diff'))
|
||||
return Response.json({
|
||||
snapshot_id: 'snapshot',
|
||||
items: [
|
||||
{
|
||||
agent_id: 'target',
|
||||
expected_generation: 7,
|
||||
before_hash: 'old',
|
||||
candidate_hash: 'new',
|
||||
added: 1,
|
||||
removed: 0,
|
||||
examples: [],
|
||||
truncated: false,
|
||||
default_action_changed: false,
|
||||
port_rules_changed: false,
|
||||
conflict: null,
|
||||
},
|
||||
],
|
||||
})
|
||||
if (path === '/api/v1/policies/rollouts')
|
||||
return method === 'POST'
|
||||
? Response.json(rollout)
|
||||
: Response.json({ items: [rollout] })
|
||||
if (path === '/api/v1/policies/rollouts/rollout')
|
||||
return Response.json(rollout)
|
||||
if (
|
||||
path.endsWith('/confirm') ||
|
||||
path.endsWith('/rollback') ||
|
||||
path.endsWith('/release')
|
||||
)
|
||||
return Response.json({ ok: true })
|
||||
if (path.endsWith('/token-rotation')) {
|
||||
if (method === 'POST') {
|
||||
pendingRotation = true
|
||||
return Response.json(
|
||||
{
|
||||
request_id: '00000000-0000-4000-8000-000000000001',
|
||||
expires_at: '2099-01-01T00:00:00Z',
|
||||
},
|
||||
{ status: 202 },
|
||||
)
|
||||
}
|
||||
return Response.json({
|
||||
agent_id: 'target',
|
||||
supported: true,
|
||||
client_version: '1.0.0',
|
||||
agent_status: 'approved',
|
||||
rotation: pendingRotation
|
||||
? {
|
||||
request_id: '00000000-0000-4000-8000-000000000001',
|
||||
expires_at: '2099-01-01T00:00:00Z',
|
||||
}
|
||||
: null,
|
||||
completed_rotation: null,
|
||||
})
|
||||
}
|
||||
if (path === '/api/v1/install-links')
|
||||
return Response.json({
|
||||
items: [
|
||||
{
|
||||
id: 'install',
|
||||
slug: 'not-rendered',
|
||||
client_name: 'Fixture target',
|
||||
platform: 'linux',
|
||||
agent_id: 'target',
|
||||
created_at: '2026-10-24',
|
||||
expires_at: '2026-10-25',
|
||||
consumed_at: '2026-10-24',
|
||||
revoked_at: null,
|
||||
use_count: 1,
|
||||
urls: { by_id: 'not-rendered', by_slug: 'not-rendered' },
|
||||
curl: {
|
||||
by_id: 'credential-must-not-render',
|
||||
by_slug: 'credential-must-not-render',
|
||||
},
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
})
|
||||
throw new Error(`Unexpected fixture request ${method} ${path}`)
|
||||
}
|
||||
try {
|
||||
await ensureAuthConfig()
|
||||
setToken(
|
||||
fixtureToken(['fw:agents:write', 'fw:policies:write', 'fw:settings:admin']),
|
||||
)
|
||||
await render(<WebhooksSheet open onOpenChange={() => {}} />)
|
||||
check(
|
||||
document.body.textContent?.includes('1 ожидают') &&
|
||||
document.body.textContent.includes('2 исчерпали'),
|
||||
'webhook health keeps pending/dead/dropped distinct',
|
||||
)
|
||||
await input('webhook-name', 'Draft hook')
|
||||
await input('webhook-url', 'https://fixture.invalid/new')
|
||||
await input('webhook-secret', 'short')
|
||||
await submit()
|
||||
check(
|
||||
writes('/api/v1/webhooks').length === 0 &&
|
||||
document
|
||||
.getElementById('webhook-secret')
|
||||
?.getAttribute('aria-invalid') === 'true',
|
||||
'short webhook secret blocked inline before POST',
|
||||
)
|
||||
await input('webhook-secret', 'fixture-only-never-delivered-0000000000')
|
||||
await submit()
|
||||
check(
|
||||
writes('/api/v1/webhooks').length === 1 &&
|
||||
(document.getElementById('webhook-name') as HTMLInputElement).value ===
|
||||
'Draft hook',
|
||||
'failed webhook write preserves dirty draft',
|
||||
)
|
||||
failHook = false
|
||||
await submit()
|
||||
check(
|
||||
(document.getElementById('webhook-secret') as HTMLInputElement).value ===
|
||||
'' && writes('/api/v1/webhooks')[1].body.enabled === false,
|
||||
'successful create clears write-only secret and defaults disabled',
|
||||
)
|
||||
await click('Изменить')
|
||||
await input('webhook-name', 'Renamed hook')
|
||||
await submit()
|
||||
check(
|
||||
writes('/api/v1/webhooks/hook')[0]?.body.name === 'Renamed hook' &&
|
||||
!('secret' in writes('/api/v1/webhooks/hook')[0].body),
|
||||
'edit omits blank secret instead of clearing existing secret',
|
||||
)
|
||||
configured = false
|
||||
await act(async () => {
|
||||
await qc.invalidateQueries({ queryKey: ['webhooks'] })
|
||||
await flush()
|
||||
})
|
||||
check(
|
||||
button('Добавить webhook')?.disabled &&
|
||||
document.body.textContent?.includes('Шифрование webhook не настроено'),
|
||||
'missing encryption key disables writes with explanation',
|
||||
)
|
||||
await render(null)
|
||||
qc.clear()
|
||||
setToken(fixtureToken(['fw:agents:read']))
|
||||
const previousCalls = calls.length
|
||||
await render(<WebhooksSheet key="denied" open onOpenChange={() => {}} />)
|
||||
check(
|
||||
calls.length === previousCalls &&
|
||||
document.body.textContent?.includes('Требуются права администратора'),
|
||||
'non-admin webhook panel fetches and writes nothing',
|
||||
)
|
||||
|
||||
await render(null)
|
||||
qc.clear()
|
||||
setToken(fixtureToken(['fw:agents:write', 'fw:policies:write']))
|
||||
await render(
|
||||
<AgentLabelsSheet agentId="target" open onOpenChange={() => {}} />,
|
||||
)
|
||||
await choose('agent-label-ids', 'Fixture label B')
|
||||
await act(async () => {
|
||||
await qc.invalidateQueries({ queryKey: ['agents', 'target', 'labels'] })
|
||||
await flush()
|
||||
})
|
||||
check(
|
||||
document.body.textContent?.includes('Fixture label B'),
|
||||
'agent labels retain dirty selection across refetch',
|
||||
)
|
||||
await click('Сохранить метки агента')
|
||||
check(
|
||||
writes('/api/v1/agents/target/labels')[0]?.body.label_ids.includes(
|
||||
'label-b',
|
||||
) && !button('Сохранить метки агента')?.disabled,
|
||||
'rejected labels write preserves editable selection',
|
||||
)
|
||||
await render(null)
|
||||
qc.clear()
|
||||
await render(<AgentLabelsSheet open onOpenChange={() => {}} />)
|
||||
await choose('group-label-selector', 'Fixture label A')
|
||||
await choose('group-sets', 'Fixture set')
|
||||
await click('Предпросмотр назначения')
|
||||
check(
|
||||
writes('/api/v1/agent-labels/assign-policy-sets').length === 0 &&
|
||||
document.body.textContent?.includes('Fixture target'),
|
||||
'group preview resolves named agents without auto-assignment',
|
||||
)
|
||||
await click('Применить показанное назначение')
|
||||
check(
|
||||
writes('/api/v1/agent-labels/assign-policy-sets').length === 0,
|
||||
'group write requires explicit confirmation',
|
||||
)
|
||||
await click('Подтвердить')
|
||||
check(
|
||||
JSON.stringify(
|
||||
writes('/api/v1/agent-labels/assign-policy-sets')[0]?.body.expected,
|
||||
) === JSON.stringify([{ agent_id: 'target', generation: 7 }]),
|
||||
'group assignment sends exact preview CAS generations',
|
||||
)
|
||||
|
||||
await render(null)
|
||||
qc.clear()
|
||||
await render(<PolicyRolloutsSheet open onOpenChange={() => {}} />)
|
||||
await chooseSelect(0, 'Fixture snapshot')
|
||||
await choose('rollout-targets', 'Fixture targetfixture.invalid')
|
||||
check(
|
||||
button('Запустить canary')?.disabled,
|
||||
'canary start gated on prior diff',
|
||||
)
|
||||
await click('Предпросмотр diff')
|
||||
await choose('rollout-canaries', 'Fixture targetfixture.invalid')
|
||||
await click('Запустить canary')
|
||||
check(
|
||||
writes('/api/v1/policies/rollouts').length === 0,
|
||||
'canary publication requires separate confirmation',
|
||||
)
|
||||
await click('Подтвердить')
|
||||
check(
|
||||
writes('/api/v1/policies/rollouts')[0]?.body.targets[0]
|
||||
.expected_generation === 7 &&
|
||||
button('Подтвердить после ACK')?.disabled &&
|
||||
document.body.textContent?.includes('Ожидается ACK'),
|
||||
'canary uses diff generation and pending ACK never enables confirmation',
|
||||
)
|
||||
rollout = {
|
||||
...rollout,
|
||||
targets: [
|
||||
{
|
||||
...rollout.targets[0],
|
||||
acknowledged: true,
|
||||
applied_revision: 8,
|
||||
applied_hash: 'new',
|
||||
},
|
||||
],
|
||||
}
|
||||
await act(async () => {
|
||||
await qc.invalidateQueries({ queryKey: ['policy-rollouts'] })
|
||||
await flush()
|
||||
})
|
||||
check(
|
||||
!button('Подтвердить после ACK')?.disabled &&
|
||||
writes('/api/v1/policies/rollouts/rollout/confirm').length === 0,
|
||||
'fresh canary ACK enables but does not auto-confirm',
|
||||
)
|
||||
await click('Подтвердить после ACK')
|
||||
await click('Подтвердить')
|
||||
check(
|
||||
writes('/api/v1/policies/rollouts/rollout/confirm')[0]?.body
|
||||
.expected_version === 9,
|
||||
'rollout transition sends exact displayed version',
|
||||
)
|
||||
await click('Снять pin')
|
||||
check(
|
||||
document.body.textContent?.includes('живая политика будет проверена') &&
|
||||
writes('/api/v1/agents/target/policy-pin/release').length === 0,
|
||||
'pin release warns about validated live-policy resume before write',
|
||||
)
|
||||
await click('Отмена')
|
||||
|
||||
await render(null)
|
||||
qc.clear()
|
||||
await render(
|
||||
<AgentCredentialsSheet agentId="target" open onOpenChange={() => {}} />,
|
||||
)
|
||||
check(
|
||||
document.body.textContent?.includes('Использована') &&
|
||||
!document.body.textContent.includes('credential-must-not-render'),
|
||||
'credential panel displays link lifecycle without installation credentials',
|
||||
)
|
||||
await click('Запросить ротацию')
|
||||
check(
|
||||
writes('/api/v1/agents/target/token-rotation').length === 0,
|
||||
'rotation requires explicit request confirmation',
|
||||
)
|
||||
await click('Запросить')
|
||||
check(
|
||||
pendingRotation &&
|
||||
document.body.textContent?.includes('Ожидается обмен ключа агентом') &&
|
||||
button('Запросить ротацию')?.disabled,
|
||||
'rotation request remains pending until agent exchange',
|
||||
)
|
||||
results.dataset.status = 'passed'
|
||||
} catch (error) {
|
||||
results.textContent = `${checks.join('\n')}\nFAIL ${error instanceof Error ? error.stack : String(error)}`
|
||||
results.dataset.status = 'failed'
|
||||
} finally {
|
||||
if (results.dataset.status === 'passed') {
|
||||
await act(async () => root.unmount())
|
||||
qc.clear()
|
||||
window.fetch = originalFetch
|
||||
}
|
||||
if (originalToken) setToken(originalToken)
|
||||
else localStorage.removeItem('fw_auth_token')
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head><meta charset="UTF-8"><title>EvoFirewall isolated interaction checks</title></head>
|
||||
<body>
|
||||
<h1>Isolated interaction checks</h1>
|
||||
<pre id="results">Running…</pre>
|
||||
<div id="fixture"></div>
|
||||
<script type="module" src="/test/interaction.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,192 @@
|
||||
// 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'
|
||||
}
|
||||
Reference in new issue
Block a user