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 { type ServerTileItem } from "@/components/server-tile-rail"
|
||||
import { servers, greTunnels, type GreTunnel, type Server } from "@/lib/data"
|
||||
import { can } from "@/lib/auth"
|
||||
import { readProbeOutput, probeServersSchema } from "@/lib/probe-response"
|
||||
import { useDataSource } from "@/lib/data-source"
|
||||
import {
|
||||
@@ -572,6 +573,9 @@ export default function ProbesPage() {
|
||||
|
||||
function ProbesContent() {
|
||||
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 apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
|
||||
|
||||
@@ -600,7 +604,7 @@ function ProbesContent() {
|
||||
const [rosSrcV4, setRosSrcV4] = useState<string | null | undefined>(undefined)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLive) return
|
||||
if (!isLive || !canRead) return
|
||||
let cancelled = false
|
||||
const ctrl = new AbortController()
|
||||
queueMicrotask(() => { if (!cancelled) { setLiveLoad("loading"); setCatalogError(null) } })
|
||||
@@ -624,7 +628,7 @@ function ProbesContent() {
|
||||
setLiveLoad("error")
|
||||
})
|
||||
return () => { cancelled = true; ctrl.abort() }
|
||||
}, [isLive, apiFetch, catalogRevision])
|
||||
}, [isLive, canRead, apiFetch, catalogRevision])
|
||||
|
||||
const allServers = useMemo(() => {
|
||||
if (!isLive) return servers
|
||||
@@ -668,7 +672,7 @@ function ProbesContent() {
|
||||
}, [isLive, srcId, apiFetch])
|
||||
|
||||
const ensureGre = useCallback(async (serverId: string) => {
|
||||
if (!isLive) return
|
||||
if (!isLive || !canReadGre) return
|
||||
try {
|
||||
const d = await apiFetch<{ tunnels: GreTunnel[] }>(
|
||||
`/api/filters/gre-tunnels?serverId=${encodeURIComponent(serverId)}`,
|
||||
@@ -677,7 +681,7 @@ function ProbesContent() {
|
||||
} catch {
|
||||
setGreByServer(prev => ({ ...prev, [serverId]: [] }))
|
||||
}
|
||||
}, [isLive, apiFetch])
|
||||
}, [isLive, canReadGre, apiFetch])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLive || !srcId) return
|
||||
@@ -748,7 +752,7 @@ function ProbesContent() {
|
||||
// ── run test ──
|
||||
const runTest = useCallback(async () => {
|
||||
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 numericServerId = Number.parseInt(srcId, 10)
|
||||
|
||||
@@ -875,6 +879,8 @@ function ProbesContent() {
|
||||
}, [
|
||||
isLive,
|
||||
liveLoad,
|
||||
canRun,
|
||||
canReadGre,
|
||||
allServers,
|
||||
srcId,
|
||||
tool,
|
||||
@@ -907,6 +913,8 @@ function ProbesContent() {
|
||||
|
||||
const running = tests.filter(t => t.status === "running")
|
||||
|
||||
if (!canRead) return <p role="status">Для просмотра устройств требуется mm:servers:read.</p>
|
||||
|
||||
if (isLive && liveLoad === "loading" && catalogReadAt === null) {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
@@ -962,6 +970,9 @@ function ProbesContent() {
|
||||
</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 ── */}
|
||||
<OpsPanel contentClassName="pt-4 pb-4 px-4 flex flex-col gap-4">
|
||||
|
||||
@@ -1078,7 +1089,7 @@ function ProbesContent() {
|
||||
|
||||
<Button
|
||||
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"
|
||||
>
|
||||
{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)
|
||||
let lookups=0
|
||||
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)
|
||||
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 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; внешние ограничения ниже |
|
||||
| 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 |
|
||||
| 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Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
|
||||
| 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)
|
||||
|
||||
- Каталог проверяется по типам 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 эффект исправлен, остальные эффекты/расписание ещё не приняты).
|
||||
- Далее 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")) {
|
||||
return "mm:dashboard:read"
|
||||
}
|
||||
if (pathname.startsWith("/probes")) return "mm:servers:read"
|
||||
if (pathname.startsWith("/terminal")) return "mm:servers:read"
|
||||
if (pathname.startsWith("/servers")) return "mm:servers:read"
|
||||
if (pathname.startsWith("/filters") || pathname.startsWith("/gre")) {
|
||||
|
||||
@@ -51,3 +51,14 @@ test("Диагностика: пустой каталог отличается
|
||||
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)
|
||||
})
|
||||
|
||||
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