fix(ui): согласовать права диагностики с API
This commit is contained in:
1 parent
cba3db1508
commit
b856215c03
6 files changed
+50
-11
No files matched your search
@@ -20,6 +20,7 @@ import { Input } from "@/components/ui/input"
|
|||||||
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
|
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
|
||||||
import { type ServerTileItem } from "@/components/server-tile-rail"
|
import { type ServerTileItem } from "@/components/server-tile-rail"
|
||||||
import { servers, greTunnels, type GreTunnel, type Server } from "@/lib/data"
|
import { servers, greTunnels, type GreTunnel, type Server } from "@/lib/data"
|
||||||
|
import { can } from "@/lib/auth"
|
||||||
import { readProbeOutput, probeServersSchema } from "@/lib/probe-response"
|
import { readProbeOutput, probeServersSchema } from "@/lib/probe-response"
|
||||||
import { useDataSource } from "@/lib/data-source"
|
import { useDataSource } from "@/lib/data-source"
|
||||||
import {
|
import {
|
||||||
@@ -572,6 +573,9 @@ export default function ProbesPage() {
|
|||||||
|
|
||||||
function ProbesContent() {
|
function ProbesContent() {
|
||||||
const { mode, backendUrl } = useDataSource()
|
const { mode, backendUrl } = useDataSource()
|
||||||
|
const canRead = can("mm:servers:read")
|
||||||
|
const canRun = can("mm:servers:write")
|
||||||
|
const canReadGre = can("mm:filters:read")
|
||||||
const isLive = mode === "live"
|
const isLive = mode === "live"
|
||||||
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
|
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
|
||||||
|
|
||||||
@@ -600,7 +604,7 @@ function ProbesContent() {
|
|||||||
const [rosSrcV4, setRosSrcV4] = useState<string | null | undefined>(undefined)
|
const [rosSrcV4, setRosSrcV4] = useState<string | null | undefined>(undefined)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLive) return
|
if (!isLive || !canRead) return
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
const ctrl = new AbortController()
|
const ctrl = new AbortController()
|
||||||
queueMicrotask(() => { if (!cancelled) { setLiveLoad("loading"); setCatalogError(null) } })
|
queueMicrotask(() => { if (!cancelled) { setLiveLoad("loading"); setCatalogError(null) } })
|
||||||
@@ -624,7 +628,7 @@ function ProbesContent() {
|
|||||||
setLiveLoad("error")
|
setLiveLoad("error")
|
||||||
})
|
})
|
||||||
return () => { cancelled = true; ctrl.abort() }
|
return () => { cancelled = true; ctrl.abort() }
|
||||||
}, [isLive, apiFetch, catalogRevision])
|
}, [isLive, canRead, apiFetch, catalogRevision])
|
||||||
|
|
||||||
const allServers = useMemo(() => {
|
const allServers = useMemo(() => {
|
||||||
if (!isLive) return servers
|
if (!isLive) return servers
|
||||||
@@ -668,7 +672,7 @@ function ProbesContent() {
|
|||||||
}, [isLive, srcId, apiFetch])
|
}, [isLive, srcId, apiFetch])
|
||||||
|
|
||||||
const ensureGre = useCallback(async (serverId: string) => {
|
const ensureGre = useCallback(async (serverId: string) => {
|
||||||
if (!isLive) return
|
if (!isLive || !canReadGre) return
|
||||||
try {
|
try {
|
||||||
const d = await apiFetch<{ tunnels: GreTunnel[] }>(
|
const d = await apiFetch<{ tunnels: GreTunnel[] }>(
|
||||||
`/api/filters/gre-tunnels?serverId=${encodeURIComponent(serverId)}`,
|
`/api/filters/gre-tunnels?serverId=${encodeURIComponent(serverId)}`,
|
||||||
@@ -677,7 +681,7 @@ function ProbesContent() {
|
|||||||
} catch {
|
} catch {
|
||||||
setGreByServer(prev => ({ ...prev, [serverId]: [] }))
|
setGreByServer(prev => ({ ...prev, [serverId]: [] }))
|
||||||
}
|
}
|
||||||
}, [isLive, apiFetch])
|
}, [isLive, canReadGre, apiFetch])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLive || !srcId) return
|
if (!isLive || !srcId) return
|
||||||
@@ -748,7 +752,7 @@ function ProbesContent() {
|
|||||||
// ── run test ──
|
// ── run test ──
|
||||||
const runTest = useCallback(async () => {
|
const runTest = useCallback(async () => {
|
||||||
const srv = allServers.find(s => s.id === srcId)
|
const srv = allServers.find(s => s.id === srcId)
|
||||||
if (!srv || !srv.enabled || runLock.current || (isLive && liveLoad !== "idle")) return
|
if (!canRun || (tool === "bandwidth" && isLive && !canReadGre) || !srv || !srv.enabled || runLock.current || (isLive && liveLoad !== "idle")) return
|
||||||
const id = String(nextId.current++)
|
const id = String(nextId.current++)
|
||||||
const numericServerId = Number.parseInt(srcId, 10)
|
const numericServerId = Number.parseInt(srcId, 10)
|
||||||
|
|
||||||
@@ -875,6 +879,8 @@ function ProbesContent() {
|
|||||||
}, [
|
}, [
|
||||||
isLive,
|
isLive,
|
||||||
liveLoad,
|
liveLoad,
|
||||||
|
canRun,
|
||||||
|
canReadGre,
|
||||||
allServers,
|
allServers,
|
||||||
srcId,
|
srcId,
|
||||||
tool,
|
tool,
|
||||||
@@ -907,6 +913,8 @@ function ProbesContent() {
|
|||||||
|
|
||||||
const running = tests.filter(t => t.status === "running")
|
const running = tests.filter(t => t.status === "running")
|
||||||
|
|
||||||
|
if (!canRead) return <p role="status">Для просмотра устройств требуется mm:servers:read.</p>
|
||||||
|
|
||||||
if (isLive && liveLoad === "loading" && catalogReadAt === null) {
|
if (isLive && liveLoad === "loading" && catalogReadAt === null) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
@@ -962,6 +970,9 @@ function ProbesContent() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!canRun && <p role="status" className="text-sm text-muted-foreground">Для запуска диагностики требуется mm:servers:write.</p>}
|
||||||
|
{isLive && tool === "bandwidth" && !canReadGre && <p role="status" className="text-sm text-muted-foreground">Для выбора GRE требуется mm:filters:read.</p>}
|
||||||
|
|
||||||
{/* ── tool selector + config ── */}
|
{/* ── tool selector + config ── */}
|
||||||
<OpsPanel contentClassName="pt-4 pb-4 px-4 flex flex-col gap-4">
|
<OpsPanel contentClassName="pt-4 pb-4 px-4 flex flex-col gap-4">
|
||||||
|
|
||||||
@@ -1078,7 +1089,7 @@ function ProbesContent() {
|
|||||||
|
|
||||||
<Button
|
<Button
|
||||||
onClick={() => void runTest()}
|
onClick={() => void runTest()}
|
||||||
disabled={runBusy || !srcServer?.enabled || (isLive && liveLoad !== "idle") || (tool === "bandwidth" && !bwTunnels.length)}
|
disabled={!canRun || (tool === "bandwidth" && isLive && !canReadGre) || runBusy || !srcServer?.enabled || (isLive && liveLoad !== "idle") || (tool === "bandwidth" && !bwTunnels.length)}
|
||||||
className="h-8 shrink-0 gap-1.5 self-end"
|
className="h-8 shrink-0 gap-1.5 self-end"
|
||||||
>
|
>
|
||||||
{runBusy ? <LoaderCircleIcon className="size-3.5 animate-spin" /> : <PlayIcon className="size-3.5" />}
|
{runBusy ? <LoaderCircleIcon className="size-3.5 animate-spin" /> : <PlayIcon className="size-3.5" />}
|
||||||
|
|||||||
@@ -7,10 +7,11 @@ const {default:authPlugin,requireAuth}=await import("./auth.js"),{createExecRout
|
|||||||
const app=Fastify();app.setValidatorCompiler(validatorCompiler);app.setSerializerCompiler(serializerCompiler);await app.register(authPlugin);app.addHook("preHandler",requireAuth)
|
const app=Fastify();app.setValidatorCompiler(validatorCompiler);app.setSerializerCompiler(serializerCompiler);await app.register(authPlugin);app.addHook("preHandler",requireAuth)
|
||||||
let lookups=0
|
let lookups=0
|
||||||
await app.register(createExecRoutes({getServer:async()=>{lookups++;return undefined},createClient:()=>{throw new Error("unused")}}),{prefix:"/api"})
|
await app.register(createExecRoutes({getServer:async()=>{lookups++;return undefined},createClient:()=>{throw new Error("unused")}}),{prefix:"/api"})
|
||||||
const send=(token?:string)=>app.inject({method:"POST",url:"/api/servers/1/exec",payload:{command:"interface print"},headers:token?{authorization:"Bearer "+token}:{}})
|
app.post("/api/servers/:id/probes/run",async(_req,reply)=>{lookups++;return reply.status(404).send({error:"DI: no device"})})
|
||||||
|
const send=(token?:string,url="/api/servers/1/exec")=>app.inject({method:"POST",url,payload:{command:"interface print"},headers:token?{authorization:"Bearer "+token}:{}})
|
||||||
assert.equal((await send()).statusCode,401);assert.equal(lookups,0)
|
assert.equal((await send()).statusCode,401);assert.equal(lookups,0)
|
||||||
for(const permission of ["mm:servers:read","mm:network:write","mm:servers:write","mm:servers:admin"]){
|
for(const url of ["/api/servers/1/exec","/api/servers/1/probes/run"])for(const permission of ["mm:servers:read","mm:network:write","mm:servers:write","mm:servers:admin"]){
|
||||||
const token=await new SignJWT({sub:"test",apps:["mm"],permissions:[permission]}).setProtectedHeader({alg:"HS256"}).setIssuer("https://auth.test.local").setExpirationTime("1h").sign(new TextEncoder().encode("terminal-test-secret"))
|
const token=await new SignJWT({sub:"test",apps:["mm"],permissions:[permission]}).setProtectedHeader({alg:"HS256"}).setIssuer("https://auth.test.local").setExpirationTime("1h").sign(new TextEncoder().encode("terminal-test-secret"))
|
||||||
const before=lookups,response=await send(token);const allowed=permission==="mm:servers:write"||permission==="mm:servers:admin";assert.equal(response.statusCode,allowed?404:403);assert.equal(lookups,before+(allowed?1:0))
|
const before=lookups,response=await send(token,url);const allowed=permission==="mm:servers:write"||permission==="mm:servers:admin";assert.equal(response.statusCode,allowed?404:403);assert.equal(lookups,before+(allowed?1:0))
|
||||||
}
|
}
|
||||||
await app.close();console.log("Exec RBAC: signed JWT requires existing servers:write/admin before device lookup; no DB/RouterOS")
|
await app.close();console.log("Exec/Probes RBAC: signed JWT requires existing servers:write/admin before device lookup; no DB/RouterOS")
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
| 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | Завершён в границах локальной приёмки 2026-09-23; внешние ограничения ниже |
|
| 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | Завершён в границах локальной приёмки 2026-09-23; внешние ограничения ниже |
|
||||||
| 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 |
|
| 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 |
|
||||||
| 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 |
|
| 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 |
|
||||||
| 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.59; оптимизатор, карта и терминал приняты локально; диагностика /probes в работе |
|
| 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.60; оптимизатор, карта и терминал приняты локально; диагностика /probes в работе |
|
||||||
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат |
|
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат |
|
||||||
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
|
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
|
||||||
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
|
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
|
||||||
@@ -36,6 +36,13 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## Блок 10.60 — права диагностики (2026-10-01)
|
||||||
|
|
||||||
|
- permissionForPath(/probes) соответствует каталогу servers:read, вместо прежнего fallback dashboard:read. Запуск и Enter требуют servers:write, как текущий POST /servers/:id/probes/run; серверный RBAC не расширен. Нет servers:read — каталог не запрашивается.
|
||||||
|
- GRE каталог требует filters:read; без него не выполняется GET, BW запуск недоступен с понятной причиной. Остальная разрешённая диагностика остаётся доступной. Права расписания будут приняты отдельно в 11Б.
|
||||||
|
- 14 E2E, TypeScript, production frontend, профильный lint и backend test:exec/test:auth прошли. Auth exec тест теперь проверяет также probes HTTP путь подписанными JWT на настоящем requireAuth/permissionForRequest со stub handler: read/network write запрещены, servers write/admin допущены. Это не проверка production probes handler/RouterOS/портала.
|
||||||
|
- Далее 10.61: GRE каталог — ошибка не равна пустому списку, строгий контракт/cleanup/повтор. Затем URL, backend probes и адаптивная приёмка. Исходные 7 эффектных lint ошибок страницы ещё открыты.
|
||||||
|
|
||||||
## Блок 10.59 — каталог устройств диагностики (2026-10-01)
|
## Блок 10.59 — каталог устройств диагностики (2026-10-01)
|
||||||
|
|
||||||
- Каталог проверяется по типам id/host/status/enabled/type, уникальности id и конечной неотрицательной latency. Неизвестный status отображается как unknown, не offline. Abort/cleanup запрещают прежний результат после ухода/смены источника.
|
- Каталог проверяется по типам id/host/status/enabled/type, уникальности id и конечной неотрицательной latency. Неизвестный status отображается как unknown, не offline. Abort/cleanup запрещают прежний результат после ухода/смены источника.
|
||||||
|
|||||||
@@ -355,3 +355,11 @@ Shared контракт и backend observed исходные значения/д
|
|||||||
- 12 основных + 4 source E2E, 3 unit, TypeScript и production frontend прошли. Первый прогон новых тестов остановился на пересечении role=alert с Next route announcer; alert каталога именован, повтор 12/12 чистый. Helper/tests проходят lint; исходные ошибки страницы остаются (catalog эффект исправлен, остальные эффекты/расписание ещё не приняты).
|
- 12 основных + 4 source E2E, 3 unit, TypeScript и production frontend прошли. Первый прогон новых тестов остановился на пересечении role=alert с Next route announcer; alert каталога именован, повтор 12/12 чистый. Helper/tests проходят lint; исходные ошибки страницы остаются (catalog эффект исправлен, остальные эффекты/расписание ещё не приняты).
|
||||||
- Далее 10.60: права страницы/запуска должны соответствовать фактическим /api/servers read/write. Затем GRE каталог, URL/device context, backend и адаптивная приёмка. Внутреннее sessions=0 не отображается в диагностике и не является измерением; заменить при общей типизации. Реальная сеть не проверена.
|
- Далее 10.60: права страницы/запуска должны соответствовать фактическим /api/servers read/write. Затем GRE каталог, URL/device context, backend и адаптивная приёмка. Внутреннее sessions=0 не отображается в диагностике и не является измерением; заменить при общей типизации. Реальная сеть не проверена.
|
||||||
|
|
||||||
|
|
||||||
|
## Блок 10.60 — права диагностики (2026-10-01)
|
||||||
|
|
||||||
|
- permissionForPath(/probes) соответствует каталогу servers:read, вместо прежнего fallback dashboard:read. Запуск и Enter требуют servers:write, как текущий POST /servers/:id/probes/run; серверный RBAC не расширен. Нет servers:read — каталог не запрашивается.
|
||||||
|
- GRE каталог требует filters:read; без него не выполняется GET, BW запуск недоступен с понятной причиной. Остальная разрешённая диагностика остаётся доступной. Права расписания будут приняты отдельно в 11Б.
|
||||||
|
- 14 E2E, TypeScript, production frontend, профильный lint и backend test:exec/test:auth прошли. Auth exec тест теперь проверяет также probes HTTP путь подписанными JWT на настоящем requireAuth/permissionForRequest со stub handler: read/network write запрещены, servers write/admin допущены. Это не проверка production probes handler/RouterOS/портала.
|
||||||
|
- Далее 10.61: GRE каталог — ошибка не равна пустому списку, строгий контракт/cleanup/повтор. Затем URL, backend probes и адаптивная приёмка. Исходные 7 эффектных lint ошибок страницы ещё открыты.
|
||||||
|
|
||||||
@@ -227,6 +227,7 @@ export function permissionForPath(pathname: string): string | null {
|
|||||||
if (pathname === "/" || pathname.startsWith("/dashboard")) {
|
if (pathname === "/" || pathname.startsWith("/dashboard")) {
|
||||||
return "mm:dashboard:read"
|
return "mm:dashboard:read"
|
||||||
}
|
}
|
||||||
|
if (pathname.startsWith("/probes")) return "mm:servers:read"
|
||||||
if (pathname.startsWith("/terminal")) return "mm:servers:read"
|
if (pathname.startsWith("/terminal")) return "mm:servers:read"
|
||||||
if (pathname.startsWith("/servers")) return "mm:servers:read"
|
if (pathname.startsWith("/servers")) return "mm:servers:read"
|
||||||
if (pathname.startsWith("/filters") || pathname.startsWith("/gre")) {
|
if (pathname.startsWith("/filters") || pathname.startsWith("/gre")) {
|
||||||
|
|||||||
@@ -51,3 +51,14 @@ test("Диагностика: пустой каталог отличается
|
|||||||
test("Диагностика: неизвестный статус не становится offline",async({page})=>{
|
test("Диагностика: неизвестный статус не становится offline",async({page})=>{
|
||||||
await page.route("**/api/servers",r=>r.fulfill({json:[{...devices[0],status:null}]}));await page.goto("/probes");await expect(run(page)).toBeEnabled();await expect(page.getByRole("option",{name:/Первый/})).not.toHaveAccessibleName(/Не в сети|offline/i)
|
await page.route("**/api/servers",r=>r.fulfill({json:[{...devices[0],status:null}]}));await page.goto("/probes");await expect(run(page)).toBeEnabled();await expect(page.getByRole("option",{name:/Первый/})).not.toHaveAccessibleName(/Не в сети|offline/i)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
for(const permission of ["mm:servers:read","mm:servers:write"])test("Диагностика: права запуска "+permission,async({page})=>{
|
||||||
|
await page.addInitScript(p=>localStorage.setItem("mmapp_token","header."+btoa(JSON.stringify({sub:"test",apps:["mm"],permissions:[p],exp:Math.floor(Date.now()/1000)+3600}))+".signature"),permission)
|
||||||
|
await page.route("**/api/auth/config",r=>r.fulfill({json:{required:true}}));let posts=0,greReads=0
|
||||||
|
await page.route("**/api/filters/gre-tunnels?*",r=>{greReads++;return r.fulfill({json:{tunnels:[]}})})
|
||||||
|
await page.route("**/api/servers/1/probes/run",r=>{posts++;return r.fulfill({json:{output:"РАЗРЕШЕНО"}})})
|
||||||
|
await page.goto("/probes")
|
||||||
|
if(permission==="mm:servers:read"){await expect(page.getByRole("status").filter({hasText:"Для запуска диагностики требуется mm:servers:write"})).toBeVisible();await expect(run(page)).toBeDisabled();await page.getByRole("textbox",{name:"Цель диагностики"}).press("Enter");expect(posts).toBe(0)}
|
||||||
|
else{await expect(run(page)).toBeEnabled();await run(page).click();await expect(page.getByText("РАЗРЕШЕНО",{exact:true})).toBeVisible();expect(posts).toBe(1)}
|
||||||
|
expect(greReads).toBe(0);await page.getByRole("button",{name:/BW-тест/}).click();await expect(page.getByRole("status").filter({hasText:"Для выбора GRE требуется mm:filters:read"})).toBeVisible();await expect(run(page)).toBeDisabled()
|
||||||
|
})
|
||||||
Reference in new issue
Block a user