fix(ui): учитывать права на операции с серверами
This commit is contained in:
1 parent
e9e5030047
commit
61950638bc
6 files changed
+121
-18
No files matched your search
@@ -2,6 +2,7 @@
|
||||
|
||||
import { Suspense, useEffect, useMemo, useRef, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { can } from "@/lib/auth"
|
||||
import { useSearchParams } from "next/navigation"
|
||||
import { serverCreateSchema } from "@mmapp/contracts/servers"
|
||||
import { OperationGate, pollServers, serverOperationError, type PollResult } from "@/lib/server-operations"
|
||||
@@ -256,6 +257,13 @@ function ServersPageContent() {
|
||||
const formVersion = useRef(0)
|
||||
const testVersion = useRef(0)
|
||||
const contextVersion = useRef(0)
|
||||
const canWrite = can("mm:servers:write")
|
||||
const accessDenied = "Недостаточно прав: изменение и опрос серверов требуют разрешения на запись."
|
||||
function requireWrite(report: (message: string) => void = setOperationError) {
|
||||
if (can("mm:servers:write")) return true
|
||||
report(accessDenied)
|
||||
return false
|
||||
}
|
||||
const controlsBusy = saving || rowBusy || pollAllBusy || pollingIds.size > 0
|
||||
|
||||
const set = <K extends keyof FormState>(k: K, v: FormState[K]) => {
|
||||
@@ -316,6 +324,7 @@ function ServersPageContent() {
|
||||
// ── actions ──────────────────────────────────────────────────────────────
|
||||
|
||||
function openAdd() {
|
||||
if (!requireWrite()) return
|
||||
if (gate.current.has("save")) return
|
||||
formVersion.current++; testVersion.current++
|
||||
setFormError(""); setDetailsLoading(false); setDetailsReady(true)
|
||||
@@ -326,6 +335,7 @@ function ServersPageContent() {
|
||||
}
|
||||
|
||||
async function openEdit(s: Server) {
|
||||
if (!requireWrite()) return
|
||||
if (gate.current.has("save")) return
|
||||
const version = ++formVersion.current
|
||||
testVersion.current++
|
||||
@@ -370,6 +380,7 @@ function ServersPageContent() {
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!requireWrite(setFormError)) return
|
||||
if (!detailsReady || detailsLoading || !gate.current.acquire("save")) return
|
||||
const context = contextVersion.current
|
||||
setSaving(true); setFormError("")
|
||||
@@ -462,6 +473,7 @@ function ServersPageContent() {
|
||||
}
|
||||
|
||||
async function handleToggleStatus(id: string) {
|
||||
if (!requireWrite()) return
|
||||
const server = serverList.find(s => s.id === id)
|
||||
if (!server || !gate.current.acquire("rows")) return
|
||||
const context = contextVersion.current
|
||||
@@ -478,6 +490,7 @@ function ServersPageContent() {
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
if (!requireWrite(setDeleteError)) return
|
||||
if (!gate.current.acquire("rows")) return
|
||||
const context = contextVersion.current
|
||||
setDeleting(true); setRowBusy(true); setDeleteError("")
|
||||
@@ -500,12 +513,14 @@ function ServersPageContent() {
|
||||
}
|
||||
|
||||
async function runPoll(ids: string[]) {
|
||||
if (!requireWrite()) return
|
||||
if (!isLive || !gate.current.acquire("rows")) return
|
||||
const context = contextVersion.current
|
||||
setPollAllBusy(true); setPollingIds(new Set(ids)); setOperationError(""); setOperationMessage(""); setPollResults([])
|
||||
try {
|
||||
const results = await pollServers(ids, async id => {
|
||||
if (context !== contextVersion.current) throw new Error("Контекст изменился; опрос отменён.")
|
||||
if (!can("mm:servers:write")) throw new Error(accessDenied)
|
||||
await pollServer(backendUrl, id)
|
||||
})
|
||||
if (context !== contextVersion.current) return
|
||||
@@ -522,6 +537,7 @@ function ServersPageContent() {
|
||||
}
|
||||
|
||||
async function handleTest() {
|
||||
if (!requireWrite(setFormError)) return
|
||||
if (!detailsReady || gate.current.has("save") || gate.current.has("test")) return
|
||||
const version = ++testVersion.current
|
||||
if (!form.host || !form.username) {
|
||||
@@ -595,11 +611,11 @@ function ServersPageContent() {
|
||||
crumbs={[{ label: "Управление" }, { label: "Серверы" }]}
|
||||
actions={
|
||||
<>
|
||||
<Button variant="outline" size="sm" onClick={() => selectedServerId !== null ? selectedServer && void runPoll([selectedServer.id]) : void handlePollAll()} disabled={!isLive || controlsBusy || (selectedServerId !== null ? !selectedServer : serverList.length === 0)}>
|
||||
<Button variant="outline" size="sm" onClick={() => selectedServerId !== null ? selectedServer && void runPoll([selectedServer.id]) : void handlePollAll()} disabled={!canWrite || !isLive || controlsBusy || (selectedServerId !== null ? !selectedServer : serverList.length === 0)}>
|
||||
<RefreshCwIcon className={cn("size-4", pollAllBusy && "animate-spin")} />{selectedServerId !== null ? "Проверить сервер" : "Проверить все"}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm"><DownloadIcon className="size-4" />Экспорт</Button>
|
||||
<Button size="sm" onClick={openAdd} disabled={controlsBusy}><PlusIcon className="size-4" />Добавить сервер</Button>
|
||||
<Button size="sm" onClick={openAdd} disabled={!canWrite || controlsBusy}><PlusIcon className="size-4" />Добавить сервер</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
@@ -607,6 +623,7 @@ function ServersPageContent() {
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="flex flex-col gap-5">
|
||||
|
||||
{!canWrite && <Alert variant="info" role="status"><AlertTitle>Только просмотр</AlertTitle><AlertDescription>Изменение, удаление, опрос серверов и тест соединения требуют разрешения на запись. Просмотр деталей и фильтры доступны.</AlertDescription></Alert>}
|
||||
{operationError && <Alert variant="destructive" role="alert"><AlertTitle>Действие не завершено</AlertTitle><AlertDescription>{operationError}</AlertDescription></Alert>}
|
||||
{operationMessage && <Alert variant="success" role="status"><AlertDescription>{operationMessage}</AlertDescription></Alert>}
|
||||
{pollResults.length > 0 && (
|
||||
@@ -614,7 +631,7 @@ function ServersPageContent() {
|
||||
<AlertTitle>Опрос: успешно {pollResults.filter(r => !r.error).length} из {pollResults.length}</AlertTitle>
|
||||
<AlertDescription>
|
||||
<ul>{pollResults.map(r => <li key={r.id}>{serverList.find(s => s.id === r.id)?.name ?? r.id}: {r.error ?? "опрос выполнен"}</li>)}</ul>
|
||||
{pollResults.some(r => r.error) && <Button type="button" variant="outline" size="sm" disabled={controlsBusy} onClick={() => runPoll(pollResults.filter(r => r.error).map(r => r.id))}>Повторить неуспешные</Button>}
|
||||
{pollResults.some(r => r.error) && <Button type="button" variant="outline" size="sm" disabled={!canWrite || controlsBusy} onClick={() => runPoll(pollResults.filter(r => r.error).map(r => r.id))}>Повторить неуспешные</Button>}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
@@ -692,9 +709,10 @@ function ServersPageContent() {
|
||||
isLive={isLive}
|
||||
pollingIds={pollingIds}
|
||||
busy={controlsBusy}
|
||||
canWrite={canWrite}
|
||||
onPoll={handlePoll}
|
||||
onEdit={openEdit}
|
||||
onDelete={id => { setDeleteError(""); setDeleteTarget(serverList.find(s => s.id === id) ?? null) }}
|
||||
onDelete={id => { if (!requireWrite()) return; setDeleteError(""); setDeleteTarget(serverList.find(s => s.id === id) ?? null) }}
|
||||
onToggleStatus={handleToggleStatus}
|
||||
/>
|
||||
</DataPageCard>
|
||||
@@ -714,7 +732,7 @@ function ServersPageContent() {
|
||||
{!detailsReady && !detailsLoading && editingId && <Button type="button" variant="outline" onClick={() => { const server = serverList.find(s => s.id === editingId); if (server) void openEdit(server) }}>Повторить загрузку</Button>}
|
||||
</div>}
|
||||
|
||||
<fieldset disabled={saving || !detailsReady} className="flex min-h-0 flex-1 flex-col" aria-busy={saving || detailsLoading}>
|
||||
<fieldset disabled={!canWrite || saving || !detailsReady} className="flex min-h-0 flex-1 flex-col" aria-busy={saving || detailsLoading}>
|
||||
<Stepper value={sheetStep} onValueChange={setSheetStep} className="flex-1 flex flex-col min-h-0 px-6 py-5">
|
||||
<StepperNav className="mb-5">
|
||||
<StepperItem step={1}>
|
||||
@@ -875,7 +893,7 @@ function ServersPageContent() {
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button type="button" variant="outline" size="sm" className="w-fit gap-2"
|
||||
disabled={testState === "testing"} onClick={handleTest}>
|
||||
disabled={!canWrite || testState === "testing"} onClick={handleTest}>
|
||||
{testState === "testing"
|
||||
? <><LoaderCircleIcon className="size-4 animate-spin" />Проверка…</>
|
||||
: <><WifiIcon className="size-4" />Проверить подключение</>}
|
||||
@@ -936,7 +954,7 @@ function ServersPageContent() {
|
||||
{sheetStep < 4 ? (
|
||||
<Button className="ml-auto" disabled={saving || !detailsReady} onClick={() => setSheetStep((s) => s + 1)}>Далее</Button>
|
||||
) : (
|
||||
<Button className="ml-auto" disabled={saving || !detailsReady || testState === "testing"} onClick={handleSave}>
|
||||
<Button className="ml-auto" disabled={!canWrite || saving || !detailsReady || testState === "testing"} onClick={handleSave}>
|
||||
{saving ? "Сохранение…" : sheetMode === "edit" ? "Сохранить" : "Добавить сервер"}
|
||||
</Button>
|
||||
)}
|
||||
@@ -952,7 +970,7 @@ function ServersPageContent() {
|
||||
{deleteError && <p role="alert" className="text-sm text-destructive">{deleteError}</p>}
|
||||
<AlertDialogFooter>
|
||||
<Button type="button" variant="outline" disabled={deleting} onClick={() => setDeleteTarget(null)}>Отмена</Button>
|
||||
<Button type="button" variant="destructive" disabled={deleting} onClick={() => { if (deleteTarget) void handleDelete(deleteTarget.id) }}>{deleting ? "Удаление…" : "Удалить"}</Button>
|
||||
<Button type="button" variant="destructive" disabled={!canWrite || deleting} onClick={() => { if (deleteTarget) void handleDelete(deleteTarget.id) }}>{deleting ? "Удаление…" : "Удалить"}</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
@@ -22,11 +22,12 @@ const ROS_FEATURES = [
|
||||
interface ServerExpandedDetailProps {
|
||||
server: Server
|
||||
isLive: boolean
|
||||
canWrite: boolean
|
||||
isPolling: boolean
|
||||
onPoll: () => void
|
||||
}
|
||||
|
||||
function ServerExpandedDetail({ server: s, isLive, isPolling, onPoll }: ServerExpandedDetailProps) {
|
||||
function ServerExpandedDetail({ server: s, isLive, isPolling, canWrite, onPoll }: ServerExpandedDetailProps) {
|
||||
const ver = rosVer(s.os)
|
||||
|
||||
return (
|
||||
@@ -65,7 +66,7 @@ function ServerExpandedDetail({ server: s, isLive, isPolling, onPoll }: ServerEx
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 gap-1.5 text-xs shrink-0"
|
||||
disabled={isPolling}
|
||||
disabled={!canWrite || isPolling}
|
||||
onClick={(e) => { e.stopPropagation(); onPoll() }}
|
||||
>
|
||||
<RefreshCwIcon className={cn("size-3.5", isPolling && "animate-spin")} />
|
||||
|
||||
@@ -84,6 +84,7 @@ function RosBadge({ os }: { os: string }) {
|
||||
}
|
||||
|
||||
interface ServerRowActionsProps {
|
||||
canWrite: boolean
|
||||
busy: boolean
|
||||
server: Server
|
||||
isLive: boolean
|
||||
@@ -95,6 +96,7 @@ interface ServerRowActionsProps {
|
||||
}
|
||||
|
||||
function ServerRowActions({
|
||||
canWrite,
|
||||
busy,
|
||||
server,
|
||||
isLive,
|
||||
@@ -137,22 +139,22 @@ function ServerRowActions({
|
||||
<ExternalLinkIcon className="size-4" />
|
||||
Открыть WebFig
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onEdit(server)}>
|
||||
<DropdownMenuItem disabled={!canWrite} onClick={() => onEdit(server)}>
|
||||
<PencilIcon className="size-4" />
|
||||
Редактировать
|
||||
</DropdownMenuItem>
|
||||
{isLive && (
|
||||
<DropdownMenuItem onClick={() => onPoll(server.id)} disabled={isPolling}>
|
||||
<DropdownMenuItem onClick={() => onPoll(server.id)} disabled={!canWrite || isPolling}>
|
||||
<RefreshCwIcon className={cn("size-4", isPolling && "animate-spin")} />
|
||||
{isPolling ? "Опрос…" : "Опросить"}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem onClick={() => onToggleStatus(server.id)}>
|
||||
<DropdownMenuItem disabled={!canWrite} onClick={() => onToggleStatus(server.id)}>
|
||||
<PowerIcon className="size-4" />
|
||||
{server.enabled ? "Отключить" : "Включить"}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive" onClick={() => onDelete(server.id)}>
|
||||
<DropdownMenuItem disabled={!canWrite} variant="destructive" onClick={() => onDelete(server.id)}>
|
||||
<Trash2Icon className="size-4" />
|
||||
Удалить
|
||||
</DropdownMenuItem>
|
||||
@@ -164,6 +166,7 @@ function ServerRowActions({
|
||||
|
||||
interface ServersDataGridProps {
|
||||
hasUnfilteredRows?: boolean
|
||||
canWrite: boolean
|
||||
busy?: boolean
|
||||
servers: Server[]
|
||||
isLive: boolean
|
||||
@@ -175,6 +178,7 @@ interface ServersDataGridProps {
|
||||
}
|
||||
|
||||
function ServersDataGrid({
|
||||
canWrite,
|
||||
hasUnfilteredRows = false,
|
||||
busy = false,
|
||||
servers,
|
||||
@@ -224,6 +228,7 @@ function ServersDataGrid({
|
||||
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
expandedContent: (row: Server) => (
|
||||
<ServerExpandedDetail
|
||||
canWrite={canWrite}
|
||||
server={row}
|
||||
isLive={isLive}
|
||||
isPolling={pollingIds.has(row.id)}
|
||||
@@ -346,6 +351,7 @@ function ServersDataGrid({
|
||||
const s = row.original
|
||||
return (
|
||||
<ServerRowActions
|
||||
canWrite={canWrite}
|
||||
busy={busy}
|
||||
server={s}
|
||||
isLive={isLive}
|
||||
@@ -365,7 +371,7 @@ function ServersDataGrid({
|
||||
},
|
||||
},
|
||||
],
|
||||
[busy, isLive, onDelete, onEdit, onPoll, onToggleStatus, pollingIds],
|
||||
[canWrite, busy, isLive, onDelete, onEdit, onPoll, onToggleStatus, pollingIds],
|
||||
)
|
||||
|
||||
const table = useTable({
|
||||
@@ -382,7 +388,7 @@ function ServersDataGrid({
|
||||
<EmptyState
|
||||
icon={<ServerIcon className="size-4" />}
|
||||
title={hasUnfilteredRows ? "Нет совпадений" : "Нет серверов"}
|
||||
description={hasUnfilteredRows ? "Измените поиск или сбросьте фильтры" : "Добавьте первый MikroTik-сервер для мониторинга"}
|
||||
description={hasUnfilteredRows ? "Измените поиск или сбросьте фильтры" : canWrite ? "Добавьте первый MikroTik-сервер для мониторинга" : "Серверов нет. Добавление доступно пользователю с правом записи."}
|
||||
className="border-0 py-12"
|
||||
/>
|
||||
)
|
||||
|
||||
+11
-1
@@ -21,7 +21,7 @@
|
||||
| 4 | Остальные таблицы, удаление старых API, общая регрессия | Завершён 2026-09-20; границы приёмки ниже |
|
||||
| 5 | Состояния данных, свежесть, частичные сбои, гонки polling | Завершён 2026-09-20 для дашборда; границы проверок ниже |
|
||||
| 6 | Навигация и дашборд | Завершён 2026-09-20 в границах локальной приёмки; ограничения ниже |
|
||||
| 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | В работе: блоки 7.1–7.2 завершены; далее права действий 7.3 |
|
||||
| 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | В работе: 7.1–7.2 и серверная часть 7.3 завершены; далее права мониторинга |
|
||||
| 8 | Трафик и статистика | Не начат |
|
||||
| 9 | VPN, Firewall и Filters | Не начат |
|
||||
| 10 | Маршрутизация, диагностика, карта и терминал | Не начат |
|
||||
@@ -29,6 +29,16 @@
|
||||
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
|
||||
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
|
||||
|
||||
## Блок 7.3А — права действий серверов (2026-09-21)
|
||||
|
||||
- ServersPage использует существующий can("mm:servers:write") после AuthGuard. Backend относит создание/изменение/удаление, POST poll и test-connection к записи; политика API не изменена. Наследование write/admin и auth disabled сохранены, is_admin не даёт отдельного обхода.
|
||||
- Режим «Только просмотр»: отключены добавление, редактирование, включение, удаление, опрос в заголовке/меню/деталях и повтор опроса. Детали, фильтры и WebFig доступны. Пустой список не предлагает читателю добавить сервер.
|
||||
- Обработчики повторно проверяют актуальные claims перед действием; массовый опрос — перед каждым запросом. Отзыв права не закрывает форму и не отправляет запись. Проверка в UI не заменяет backend; неизвестный клиенту отзыв сервер возвращает как 403 с сохранением формы. Внешняя смена token отражается на следующем render/действии; подписка на изменения сессии не добавлена.
|
||||
- Проверки: 32 E2E servers.spec.ts прошли (27 прежних и 5 прав), TypeScript и профильный ESLint прошли. Production build прошёл: 34 статические страницы. ReUI audit: композиция, установленный Data Grid и тема сохранены; добавлены только состояние прав, props прикладных компонентов и сообщение Alert.
|
||||
- Границы: HTTP-моки и тестовые claims без реальной JWT-проверки; RouterOS не проверялся. Известная кнопка «Экспорт» серверов без обработчика остаётся задачей форм/действий 7.4.
|
||||
- Следующий шаг: 7.3Б — инвентаризация write-действий uptime (Ping/Resources/Speed), сопоставление с backend и ограничения прав, затем профильные HTTP-мок тесты. 7.3 целиком пока не завершён.
|
||||
- Лимит после основного блока: использовано 15% пятичасового, 79% недельного. Локальный коммит без push; исходные изменения навыков/AGENTS/.codex не включать.
|
||||
|
||||
## Этап 0 — исходная база
|
||||
|
||||
Исходный HEAD: f8e743721007e809b8b148bb6ddcf9e6adb3f52d.
|
||||
|
||||
@@ -61,7 +61,7 @@ URL — источник сохранённого состояния. Back/Forwa
|
||||
3. **7.4 — формы, детали и полный контекст мониторинга.** Восстановление вкладки/фильтров из URL, понятные единицы и подписи, возврат к списку после действия. Не дублировать сохранённые исправления этапа 1; проверить загрузку, отмену, 400/403/500, timeout и повторное нажатие.
|
||||
4. **7.5 — приёмка этапа.** Сценарий «проблема → сервер/проверка → действие → возврат» с сохранением контекста; профильные E2E, права в HTTP-моках, TypeScript, lint и build. Реальные JWT/RouterOS отдельно в тестовом окружении.
|
||||
|
||||
Следующий исполняемый шаг: блок 7.3 — права действий серверов и мониторинга. Codec URL серверов, hook и подключение к ServersPageContent выполнены. Полную регрессию всей системы здесь не повторять без изменений общей основы или найденной регрессии.
|
||||
Следующий исполняемый шаг: блок 7.3Б — права действий мониторинга. Серверная часть 7.3А выполнена: can(mm:servers:write), ограничения кнопок, проверки обработчиков и 5 E2E прав. Codec URL серверов, hook и подключение к ServersPageContent выполнены. Полную регрессию всей системы здесь не повторять без изменений общей основы или найденной регрессии.
|
||||
|
||||
## Решение по синхронизации URL (7.1)
|
||||
|
||||
|
||||
@@ -441,3 +441,71 @@ test("URL серверов: черновик не публикуется, быс
|
||||
await expect(search).toHaveValue("192.0.2.2")
|
||||
await expect(page.locator("tr[data-row-id]")).toHaveCount(1)
|
||||
})
|
||||
|
||||
// Claims are UI fixtures only; HTTP mocks do not verify JWT signatures.
|
||||
function permissionToken(permission: string, isAdmin = false) {
|
||||
return `fixture.${Buffer.from(JSON.stringify({ sub: "test", apps: ["mm"], permissions: [permission], is_admin: isAdmin, exp: Math.floor(Date.now() / 1000) + 3600 })).toString("base64url")}.fixture`
|
||||
}
|
||||
|
||||
for (const isAdmin of [false, true]) {
|
||||
test(`права серверов: read, is_admin=${isAdmin}, только просмотр без записи`, async ({ page }) => {
|
||||
await page.addInitScript(token => localStorage.setItem("mmapp_token", token), permissionToken("mm:servers:read", isAdmin))
|
||||
const writes: string[] = []
|
||||
await setup(page, async (route, path) => {
|
||||
if (path === "/api/auth/config") { await route.fulfill({ json: { required: true } }); return true }
|
||||
if (path.startsWith("/api/servers") && route.request().method() !== "GET") writes.push(path)
|
||||
return false
|
||||
})
|
||||
await expect(page.getByText("Только просмотр", { exact: true })).toBeVisible()
|
||||
await expect(page.getByRole("button", { name: "Добавить сервер", exact: true })).toBeDisabled()
|
||||
await expect(page.getByRole("button", { name: "Проверить все", exact: true })).toBeDisabled()
|
||||
await page.getByRole("button", { name: "Действия: Проверка 1", exact: true }).click()
|
||||
for (const name of ["Редактировать", "Опросить", "Отключить", "Удалить"]) {
|
||||
await expect(page.getByRole("menuitem", { name, exact: true })).toBeDisabled()
|
||||
}
|
||||
await expect(page.getByRole("menuitem", { name: "Открыть WebFig", exact: true })).toBeEnabled()
|
||||
await page.keyboard.press("Escape")
|
||||
await page.getByRole("button", { name: "Раскрыть: Проверка 1", exact: true }).click()
|
||||
await expect(page.getByText("Модель: Test router", { exact: true })).toBeVisible()
|
||||
await expect(page.getByRole("button", { name: "Опросить сейчас", exact: true })).toBeDisabled()
|
||||
expect(writes).toEqual([])
|
||||
})
|
||||
}
|
||||
|
||||
for (const permission of ["mm:servers:write", "mm:servers:admin"]) {
|
||||
test(`права серверов: ${permission}, серверный 403 сохраняет форму`, async ({ page }) => {
|
||||
await page.addInitScript(token => localStorage.setItem("mmapp_token", token), permissionToken(permission))
|
||||
await setup(page, async (route, path) => {
|
||||
if (path === "/api/auth/config") { await route.fulfill({ json: { required: true } }); return true }
|
||||
if (path === "/api/servers/1" && route.request().method() === "PUT") {
|
||||
await route.fulfill({ status: 403, json: { error: "Forbidden" } }); return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
await expect(page.getByRole("button", { name: "Добавить сервер", exact: true })).toBeEnabled()
|
||||
await expect(page.getByRole("button", { name: "Проверить все", exact: true })).toBeEnabled()
|
||||
await edit(page)
|
||||
await finalStep(page)
|
||||
await page.getByRole("button", { name: "Сохранить", exact: true }).click()
|
||||
await expect(page.getByRole("dialog")).toBeVisible()
|
||||
await expect(page.getByRole("alert")).toContainText("Недостаточно прав")
|
||||
})
|
||||
}
|
||||
|
||||
test("права серверов: отзыв перед сохранением блокирует запрос и сохраняет форму", async ({ page }) => {
|
||||
await page.addInitScript(token => localStorage.setItem("mmapp_token", token), permissionToken("mm:servers:write"))
|
||||
let writes = 0
|
||||
await setup(page, async (route, path) => {
|
||||
if (path === "/api/auth/config") { await route.fulfill({ json: { required: true } }); return true }
|
||||
if (path.startsWith("/api/servers") && route.request().method() !== "GET") writes++
|
||||
return false
|
||||
})
|
||||
await edit(page)
|
||||
await finalStep(page)
|
||||
await page.evaluate(token => localStorage.setItem("mmapp_token", token), permissionToken("mm:servers:read"))
|
||||
await page.getByRole("button", { name: "Сохранить", exact: true }).click()
|
||||
await expect(page.getByRole("dialog")).toBeVisible()
|
||||
await expect(page.getByRole("alert")).toContainText("Недостаточно прав")
|
||||
await expect(page.getByRole("button", { name: "Сохранить", exact: true })).toBeDisabled()
|
||||
expect(writes).toBe(0)
|
||||
})
|
||||
Reference in new issue
Block a user