fix(ui): различать снимок историю и неизвестный каталог пользователей

This commit is contained in:
Denozordec committed 2026-10-01 10:18:42 +07:00
1 parent c37498512f
commit 9320c11d58
10 files changed
+111 -52

No files matched your search

+19 -11
View File
@@ -31,8 +31,9 @@ import {
import { Flag } from "@/components/flag"
import { StatusDot } from "@/components/status-dot"
import { cn } from "@/lib/utils"
import type { InterfaceCatalogState } from "@mmapp/contracts/users"
import { can } from "@/lib/auth"
import { listInterfaceCatalog, userApiError } from "@/shared/api/users"
import { readInterfaceCatalogState, userApiError } from "@/shared/api/users"
import { TYPE_VARIANT } from "@/components/data-grids/users-expanded-detail"
import {
bindingDiffKey,
@@ -47,7 +48,6 @@ import {
SECTION_GROUP_DEFS,
type AppUser,
type AppUserForm,
type CatalogIface,
type InterfaceBinding,
type InterfaceType,
type PermLevel,
@@ -164,7 +164,7 @@ function UserSheet({
const catalogSrv = servers.find(s => s.id === catalogServerId)
const catalogKey = JSON.stringify([catalogServerId, catalogSrv?.host])
const [catalogState, setCatalogState] = useState<{ key: string; serverId: string; rows: CatalogIface[]; loading: boolean; error?: string; readAt?: number } | null>(null)
const [catalogState, setCatalogState] = useState<{ key: string; serverId: string; result?: InterfaceCatalogState; loading: boolean; error?: string; readAt?: number } | null>(null)
const [catalogRevision, setCatalogRevision] = useState(0)
const demoCatalog = useMemo(() => {
if (isLive || !catalogSrv) return []
@@ -173,16 +173,16 @@ function UserSheet({
return catalogForServer(catalogServerId, withDraft)
}, [isLive, catalogSrv, catalogServerId, users, user, form.bindings])
const currentCatalog = catalogState?.key === catalogKey ? catalogState : null
const catalog = isLive ? currentCatalog?.rows ?? [] : demoCatalog
const catalog = isLive ? currentCatalog?.result?.interfaces ?? [] : demoCatalog
const catalogReady = !!catalogSrv && (!isLive || !!currentCatalog?.readAt && !currentCatalog.loading && !currentCatalog.error)
useEffect(() => {
if (!open || !isLive || !catalogSrv || !can("mm:users:read")) return
let cancelled = false
const controller = new AbortController()
queueMicrotask(() => { if (!cancelled) setCatalogState(prev => ({ key: catalogKey, serverId: catalogServerId, rows: prev?.key === catalogKey ? prev.rows : [], readAt: prev?.key === catalogKey ? prev.readAt : undefined, loading: true })) })
void listInterfaceCatalog(backendUrl, catalogServerId, { signal: AbortSignal.any([controller.signal, AbortSignal.timeout(30_000)]) })
.then(rows => { if (!cancelled) setCatalogState({ key: catalogKey, serverId: catalogServerId, rows, loading: false, readAt: Date.now() }) })
.catch(error => { if (!cancelled) setCatalogState(prev => ({ key: catalogKey, serverId: catalogServerId, rows: prev?.key === catalogKey ? prev.rows : [], readAt: prev?.key === catalogKey ? prev.readAt : undefined, loading: false, error: userApiError(error) })) })
queueMicrotask(() => { if (!cancelled) setCatalogState(prev => ({ key: catalogKey, serverId: catalogServerId, result: prev?.key === catalogKey ? prev.result : undefined, readAt: prev?.key === catalogKey ? prev.readAt : undefined, loading: true })) })
void readInterfaceCatalogState(backendUrl, catalogServerId, { signal: AbortSignal.any([controller.signal, AbortSignal.timeout(30_000)]) })
.then(result => { if (!cancelled) setCatalogState({ key: catalogKey, serverId: catalogServerId, result, loading: false, readAt: Date.now() }) })
.catch(error => { if (!cancelled) setCatalogState(prev => ({ key: catalogKey, serverId: catalogServerId, result: prev?.key === catalogKey ? prev.result : undefined, readAt: prev?.key === catalogKey ? prev.readAt : undefined, loading: false, error: userApiError(error) })) })
return () => { cancelled = true; controller.abort() }
}, [open, isLive, catalogSrv, catalogServerId, catalogKey, backendUrl, catalogRevision])
@@ -482,11 +482,19 @@ function UserSheet({
{!catalogSrv && <p role="status">Выберите сервер каталога интерфейсов.</p>}
{isLive && currentCatalog?.loading && <p role="status">Чтение каталога интерфейсов…</p>}
{isLive && currentCatalog?.readAt && <p role="status" className="text-xs text-muted-foreground">Каталог получен: {new Date(currentCatalog.readAt).toLocaleTimeString("ru-RU")}</p>}
{isLive && currentCatalog?.result && <div className="text-xs text-muted-foreground" role="status">
<p>{currentCatalog.result.source === "snapshot" ? "Интерфейсы из сохранённого снимка." : currentCatalog.result.source === "traffic-history" ? "Частичный каталог из истории трафика; типы определены по именам." : "Снимок интерфейсов отсутствует."}</p>
<p>{currentCatalog.result.sampledAt ? "Время среза: " + new Date(currentCatalog.result.sampledAt).toLocaleString("ru-RU") : "Время среза неизвестно."} Состояния относятся к этому срезу.</p>
</div>}
{isLive && !!currentCatalog?.result?.failures.length && <div role="alert" aria-label="Частичные сбои каталога" className="text-xs text-destructive">
{currentCatalog.result.failures.map(failure => <p key={failure.resource}>{failure.resource === "interfaces" ? "Интерфейсы" : failure.resource === "wireguard" ? "WireGuard" : "IPsec"}: {failure.error}</p>)}
<Button type="button" variant="outline" onClick={() => { setSelectedPicks([]); setCatalogRevision(n => n + 1) }}>Повторить частичный каталог</Button>
</div>}
{isLive && currentCatalog?.error && <div role="alert" aria-label="Каталог интерфейсов"><p>{currentCatalog.error} Прежние данные доступны для просмотра; выбор приостановлен.</p><Button type="button" variant="outline" onClick={() => { setSelectedPicks([]); setCatalogRevision(n => n + 1) }}>Повторить каталог интерфейсов</Button></div>}
<Frame dense spacing="sm" className="h-64 min-h-0 flex flex-col">
<FramePanel className="flex-1 min-h-0 overflow-y-auto p-1.5">
{catalogReady && catalog.length === 0 && (
<p className="px-2 py-3 text-center text-[11px] text-muted-foreground">Нет интерфейсов в каталоге</p>
<p className="px-2 py-3 text-center text-[11px] text-muted-foreground">{!isLive || currentCatalog?.result?.inventoryComplete ? "Нет интерфейсов в каталоге" : currentCatalog?.result?.source === "traffic-history" ? "В этом срезе нет интерфейсов" : "Список интерфейсов неизвестен"}</p>
)}
<div className="flex flex-col gap-0.5">
{catalog.map((iface) => {
@@ -511,7 +519,7 @@ function UserSheet({
</ItemMedia>
<ItemContent className="min-w-0">
<ItemTitle className="max-w-full min-w-0 gap-1.5 font-mono text-[11px]">
<span className="min-w-0 truncate">{iface.name}</span>
<span className="min-w-0 truncate">{iface.type === "ipsec" && iface.name === "ipsec-vpn" ? "Клиенты IPsec" : iface.name}</span>
<Badge variant={TYPE_VARIANT[iface.type]} size="sm">{IFACE_TYPE_LABEL[iface.type]}</Badge>
</ItemTitle>
</ItemContent>
@@ -620,7 +628,7 @@ function UserSheet({
</ItemMedia>
<ItemContent className="min-w-0">
<ItemTitle className="max-w-full min-w-0 gap-1.5 font-mono text-[11px]">
{iface.running ? <StatusDot status="online" /> : <StatusDot status="offline" />}
<span title={iface.running === null ? "Running в срезе неизвестен" : iface.running ? "Running в срезе" : "Не running в срезе"}><StatusDot status={iface.running === null ? "unknown" : iface.running ? "online" : "offline"} /></span>
<span className="min-w-0 truncate">{iface.name}</span>
<Badge variant={TYPE_VARIANT[iface.type]} size="sm">
{IFACE_TYPE_LABEL[iface.type]}
+9 -1
View File
@@ -27,7 +27,7 @@
| 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 |
| 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 |
| 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 |
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | В работе: 11А.1–11А.6; backend state каталог проверен локально; подключение frontend/UX далее |
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | В работе: 11А.1–11А.7; state каталог подключён; UX/приёмка пользователей далее |
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
@@ -36,6 +36,14 @@
## Блок 11А.7 — frontend state каталог (2026-10-01)
- UserSheet использует /interface-catalog-state, строго проверяет новый контракт и точный serverId. Legacy reader экспортирован для совместимости, новый экран к неоднозначному envelope не возвращается.
- UI отдельно показывает браузерное получение и время/источник среза. Snapshot и частичная traffic history подписаны; unknown не становится пустым каталогом. Nullable running отображается неизвестным состоянием, а исторические флаги не называются текущими.
- Частичные failures интерфейсов/WG/IPsec видны сразу с повтором, включая пустой IPsec failure. IPsec-vpn подписан как каталог клиентов, а не физический интерфейс. Демо и live состояния сохраняют прежнюю композицию Frame/Stepper.
- 44 различных data/operations/catalog E2E + 8 context E2E, TypeScript, профильный lint и production frontend прошли. Фикстуры перенесены на новый state envelope; отдельные negative тесты не дополняют отсутствующие метаданные автоматически.
- Далее 11А.8: семантические токены таблицы/формы, доступные имена, права/клавиатура, ПК/390/320 и снимки. После приёмки пользователей — контейнеры. Реальная БД/RouterOS/Portal остаётся внешней приёмкой.
## Блок 11А.6 — backend метаданные каталога интерфейсов (2026-10-01)
- Добавлен /users/interface-catalog-state с serverId/source/sampledAt/inventoryComplete/failures и nullable running/disabled. Старый /interface-catalog и форма interfaces/boolean сохранены для совместимости; новый frontend будет подключён в следующем блоке.
+9
View File
@@ -87,3 +87,12 @@
- Строгий новый контракт и factory DI проверяют serverId, источник/полноту, время, peer keys/дубли и точный контекст ответа. GET остаётся в существующем mm:users:read; правила сетевых действий/авторизации не изменены.
- Backend build, профильный lint, inventory parser и Fastify HTTP тесты прошли. test:users включён в общий test; прежние binding unique/cascade тесты явно skipped из-за отсутствия PostgreSQL. SQL/реальный RouterOS ещё требуют внешней приёмки.
- Далее 11А.7: подключить frontend к state контракту, показать источник/дату/частичные отказы и неизвестное состояние; обновить HTTP-фикстуры и проверить регрессию. Затем визуальная приёмка пользователей.
## Блок 11А.7 — frontend state каталог (2026-10-01)
- UserSheet использует /interface-catalog-state, строго проверяет новый контракт и точный serverId. Legacy reader экспортирован для совместимости, новый экран к неоднозначному envelope не возвращается.
- UI отдельно показывает браузерное получение и время/источник среза. Snapshot и частичная traffic history подписаны; unknown не становится пустым каталогом. Nullable running отображается неизвестным состоянием, а исторические флаги не называются текущими.
- Частичные failures интерфейсов/WG/IPsec видны сразу с повтором, включая пустой IPsec failure. IPsec-vpn подписан как каталог клиентов, а не физический интерфейс. Демо и live состояния сохраняют прежнюю композицию Frame/Stepper.
- 44 различных data/operations/catalog E2E + 8 context E2E, TypeScript, профильный lint и production frontend прошли. Фикстуры перенесены на новый state envelope; отдельные negative тесты не дополняют отсутствующие метаданные автоматически.
- Далее 11А.8: семантические токены таблицы/формы, доступные имена, права/клавиатура, ПК/390/320 и снимки. После приёмки пользователей — контейнеры. Реальная БД/RouterOS/Portal остаётся внешней приёмкой.
+2 -2
View File
@@ -33,8 +33,8 @@ export interface CatalogPeer {
export interface CatalogIface {
name: string
type: InterfaceType
running: boolean
disabled: boolean
running: boolean | null
disabled: boolean | null
boundUserId: string | null
boundUserLogin: string | null
peers?: CatalogPeer[]
+9
View File
@@ -1,4 +1,6 @@
import {
interfaceCatalogStateSchema,
type InterfaceCatalogState,
appUserReadSchema,
catalogInterfaceSchema,
userBindingSchema,
@@ -148,3 +150,10 @@ export function userApiError(error: unknown): string {
if (error instanceof Error) return error.message.length > 500 ? error.message.slice(0, 500) + "…" : error.message
return "Запрос не подтверждён."
}
export async function readInterfaceCatalogState(baseUrl: string, serverId: string, init?: RequestInit): Promise<InterfaceCatalogState> {
const raw = await requestJson<unknown>(baseUrl, "/api/users/interface-catalog-state?serverId=" + encodeURIComponent(serverId), init)
const state = interfaceCatalogStateSchema.parse(raw)
if (String(state.serverId) !== serverId) throw new Error("Не подтверждён сервер каталога интерфейсов.")
return state
}
+9
View File
@@ -1,2 +1,11 @@
export const user={id:"u-test",name:"Настоящий пользователь",login:"[email protected]",email:"[email protected]",role:"viewer",active:true,avatar:"НП",lastSeen:null,sections:[],servers:[],bindings:[],createdAt:"2026-10-01T00:00:00Z",updatedAt:"2026-10-01T00:00:00Z"}
export const server={id:1,name:"Роутер",host:"192.0.2.1",port:443,useSsl:true,verifySsl:false,username:"admin",password:"",type:"exit-node",site:"",country:"RU",asn:"",comment:"",enabled:true,lanSubnet:"",wanUplinks:[],createdAt:"",updatedAt:"",status:null,latency:null,os:null,model:null,uptime:null,cpuLoad:null,freeMemory:null,totalMemory:null,identityName:null,sessions:0,polledAt:null}
import type {Route} from "@playwright/test"
export function catalogState(interfaces:unknown[]=[],serverId=1,overrides:Record<string,unknown>={}) {
return {serverId,source:"snapshot",sampledAt:"2026-10-01T00:00:00Z",inventoryComplete:true,interfaces,failures:[],...overrides}
}
export function catalogResponse(route:Route,options:NonNullable<Parameters<Route["fulfill"]>[0]>){
const json=options.json as Record<string,unknown>|undefined
return route.fulfill({...options,...(json&&typeof json==="object"&&"interfaces" in json?{json:catalogState(json.interfaces as unknown[],Number(new URL(route.request().url()).searchParams.get("serverId")??1),json)}:{})})
}
+26 -11
View File
@@ -1,34 +1,49 @@
import {test,expect,type Page} from "@playwright/test"
import {user,server} from "./fixtures/users"
import {user,server,catalogResponse,catalogState} from "./fixtures/users"
const iface={name:"ether1",type:"ether",running:true,disabled:false,boundUserId:null,boundUserLogin:null}
const peer={publicKey:"client-cn",name:"Клиент VPN",comment:"",allowedIps:[],boundUserId:null,boundUserLogin:null}
test.beforeEach(async({page})=>{
await page.route("**/health",r=>r.fulfill({json:{ok:true}}));await page.route("**/api/**",r=>{const p=new URL(r.request().url()).pathname;return r.fulfill({json:p==="/api/auth/config"?{required:false}:p==="/api/users"?{users:[user]}:p==="/api/servers"?[server]:{interfaces:[]}})})
await page.route("**/health",r=>catalogResponse(r, {json:{ok:true}}));await page.route("**/api/**",r=>{const p=new URL(r.request().url()).pathname;return catalogResponse(r, {json:p==="/api/auth/config"?{required:false}:p==="/api/users"?{users:[user]}:p==="/api/servers"?[server]:{interfaces:[]}})})
})
async function open(page:Page){await page.goto("/users");await page.getByRole("button",{name:"Добавить пользователя",exact:true}).click();await page.getByPlaceholder("Иван Иванов").fill("Каталог тест");await page.getByPlaceholder("[email protected]").fill("[email protected]");for(let i=0;i<3;i++)await page.getByRole("dialog").getByRole("button",{name:"Далее",exact:true}).click()}
for(const kind of ["API","malformed","duplicate","missing-peers","duplicate-peers"])test("Каталог пользователя: отказ и повтор "+kind,async({page})=>{
let fail=true;await page.route("**/api/users/interface-catalog?*",r=>r.fulfill(fail?(kind==="API"?{status:503,json:{error:"КАТАЛОГ НЕДОСТУПЕН"}}:{json:{interfaces:kind==="malformed"?[{}]:kind==="duplicate"?[iface,iface]:kind==="missing-peers"?[{...iface,type:"wg"}]:[{...iface,type:"wg",peers:[peer,peer]}]}}):{json:{interfaces:[iface]}}))
let fail=true;await page.route("**/api/users/interface-catalog-state?*",r=>catalogResponse(r, fail?(kind==="API"?{status:503,json:{error:"КАТАЛОГ НЕДОСТУПЕН"}}:{json:{interfaces:kind==="malformed"?[{}]:kind==="duplicate"?[iface,iface]:kind==="missing-peers"?[{...iface,type:"wg"}]:[{...iface,type:"wg",peers:[peer,peer]}]}}):{json:{interfaces:[iface]}}))
await open(page);const dialog=page.getByRole("dialog");await expect(dialog.getByRole("alert",{name:"Каталог интерфейсов"})).toBeVisible();await expect(dialog.getByText("Нет интерфейсов в каталоге",{exact:true})).toHaveCount(0);await expect(dialog.getByRole("button",{name:"Привязать",exact:true})).toBeDisabled()
fail=false;await dialog.getByRole("button",{name:"Повторить каталог интерфейсов"}).click();await dialog.getByRole("button",{name:/ether1.*Ethernet/}).click();await expect(dialog.getByRole("button",{name:"Привязать",exact:true})).toBeEnabled()
})
test("Каталог пользователя: подтверждённое empty отличается от ожидания",async({page})=>{
let release!:()=>void;const gate=new Promise<void>(r=>release=r);await page.route("**/api/users/interface-catalog?*",async r=>{await gate;await r.fulfill({json:{interfaces:[]}})})
let release!:()=>void;const gate=new Promise<void>(r=>release=r);await page.route("**/api/users/interface-catalog-state?*",async r=>{await gate;await catalogResponse(r, {json:{interfaces:[]}})})
await open(page);await expect(page.getByRole("status").filter({hasText:"Чтение каталога интерфейсов"})).toBeVisible();await expect(page.getByText("Нет интерфейсов в каталоге",{exact:true})).toHaveCount(0);release();await expect(page.getByText("Нет интерфейсов в каталоге",{exact:true})).toBeVisible()
})
for(const fails of [false,true])test("Каталог пользователя: поздний ответ другого устройства "+fails,async({page})=>{
let started=false,completed=false,release!:()=>void;const gate=new Promise<void>(r=>release=r);await page.route("**/api/servers",r=>r.fulfill({json:[server,{...server,id:2,name:"Второй",host:"192.0.2.2"}]}))
await page.route("**/api/users/interface-catalog?*",async r=>{if(new URL(r.request().url()).searchParams.get("serverId")==="2")return r.fulfill({json:{interfaces:[{...iface,name:"ether2"}]}});started=true;await gate;try{await r.fulfill(fails?{status:503,json:{error:"СТАРЫЙ КАТАЛОГ"}}:{json:{interfaces:[iface]}})}catch{}finally{completed=true}})
let started=false,completed=false,release!:()=>void;const gate=new Promise<void>(r=>release=r);await page.route("**/api/servers",r=>catalogResponse(r, {json:[server,{...server,id:2,name:"Второй",host:"192.0.2.2"}]}))
await page.route("**/api/users/interface-catalog-state?*",async r=>{if(new URL(r.request().url()).searchParams.get("serverId")==="2")return catalogResponse(r, {json:{interfaces:[{...iface,name:"ether2"}]}});started=true;await gate;try{await catalogResponse(r, fails?{status:503,json:{error:"СТАРЫЙ КАТАЛОГ"}}:{json:{interfaces:[iface]}})}catch{}finally{completed=true}})
await open(page);await expect.poll(()=>started).toBe(true);await page.getByRole("combobox",{name:"Сервер каталога интерфейсов"}).selectOption("2");await expect(page.getByRole("button",{name:/ether2.*Ethernet/})).toBeEnabled();release();await expect.poll(()=>completed).toBe(true);await expect(page.getByRole("button",{name:/ether1.*Ethernet/})).toHaveCount(0);await expect(page.getByText("СТАРЫЙ КАТАЛОГ",{exact:true})).toHaveCount(0)
})
test("Каталог пользователя: ошибка пиров блокирует выбор, повтор восстанавливает",async({page})=>{
let fail=true;await page.route("**/api/users/interface-catalog?*",r=>r.fulfill({json:{interfaces:[{...iface,name:"wg1",type:"wg",peers:[peer],...(fail?{peersError:"WG ОТКАЗ"}:{})}]}}))
let fail=true;await page.route("**/api/users/interface-catalog-state?*",r=>catalogResponse(r, {json:{interfaces:[{...iface,name:"wg1",type:"wg",peers:[peer],...(fail?{peersError:"WG ОТКАЗ"}:{})}]}}))
await open(page);await page.getByRole("button",{name:/wg1.*WireGuard/}).click();await expect(page.getByRole("alert").filter({hasText:"WG ОТКАЗ"})).toBeVisible();await expect(page.getByRole("button",{name:"Клиент VPN",exact:true})).toBeDisabled();await expect(page.getByRole("button",{name:"Привязать",exact:true})).toBeDisabled()
fail=false;await page.getByRole("button",{name:"Повторить каталог интерфейсов"}).click();await page.getByRole("button",{name:"Клиент VPN",exact:true}).click();await expect(page.getByRole("button",{name:"Привязать",exact:true})).toBeEnabled()
})
test("Каталог пользователя: IPsec привязывает клиента по CN",async({page})=>{
let data:Record<string,unknown>|null=null
await page.route("**/api/users/interface-catalog?*",r=>r.fulfill({json:{interfaces:[{...iface,name:"ipsec-vpn",type:"ipsec",peers:[peer]}]}}))
await page.route("**/api/users",r=>r.fulfill({json:r.request().method()==="POST"?{user:{...user,...r.request().postDataJSON(),id:"created"}}:{users:[user]}}))
await page.route("**/api/users/created/bindings",r=>{data=r.request().postDataJSON();return r.fulfill({status:201,json:{binding:{...data,id:"binding",userId:"created",serverName:"Роутер",serverSite:"",serverCountry:"RU",comment:"",createdAt:"",updatedAt:""}}})})
await open(page);await page.getByRole("button",{name:/ipsec-vpn.*IPsec/}).click();await page.getByRole("button",{name:"Клиент VPN",exact:true}).click();await page.getByRole("button",{name:"Привязать",exact:true}).click();await page.getByRole("dialog").getByRole("button",{name:"Создать",exact:true}).click();await expect(page.getByRole("dialog")).toHaveCount(0);expect(data).toMatchObject({serverId:1,interfaceName:"ipsec-vpn",interfaceType:"ipsec",peerPublicKey:"client-cn"})
await page.route("**/api/users/interface-catalog-state?*",r=>catalogResponse(r, {json:{interfaces:[{...iface,name:"ipsec-vpn",type:"ipsec",peers:[peer]}]}}))
await page.route("**/api/users",r=>catalogResponse(r, {json:r.request().method()==="POST"?{user:{...user,...r.request().postDataJSON(),id:"created"}}:{users:[user]}}))
await page.route("**/api/users/created/bindings",r=>{data=r.request().postDataJSON();return catalogResponse(r, {status:201,json:{binding:{...data,id:"binding",userId:"created",serverName:"Роутер",serverSite:"",serverCountry:"RU",comment:"",createdAt:"",updatedAt:""}}})})
await open(page);await page.getByRole("button",{name:/Клиенты IPsec.*IPsec/}).click();await page.getByRole("button",{name:"Клиент VPN",exact:true}).click();await page.getByRole("button",{name:"Привязать",exact:true}).click();await page.getByRole("dialog").getByRole("button",{name:"Создать",exact:true}).click();await expect(page.getByRole("dialog")).toHaveCount(0);expect(data).toMatchObject({serverId:1,interfaceName:"ipsec-vpn",interfaceType:"ipsec",peerPublicKey:"client-cn"})
})
for(const kind of ["missing","foreign","timestamp","inconsistent"])test("Каталог state: отклоняет неверные метаданные "+kind,async({page})=>{
const payload=kind==="missing"?{interfaces:[iface]}:catalogState([iface],kind==="foreign"?2:1,kind==="timestamp"?{sampledAt:"bad-time"}:kind==="inconsistent"?{source:"traffic-history",inventoryComplete:true}:{})
await page.route("**/api/users/interface-catalog-state?*",route=>route.fulfill({json:payload}));await open(page);await expect(page.getByRole("alert",{name:"Каталог интерфейсов"})).toBeVisible();await expect(page.getByRole("button",{name:/ether1.*Ethernet/})).toHaveCount(0);await expect(page.getByRole("button",{name:"Привязать",exact:true})).toBeDisabled()
})
test("Каталог state: история и неизвестные флаги не называются текущим состоянием",async({page})=>{
await page.route("**/api/users/interface-catalog-state?*",route=>route.fulfill({json:catalogState([{...iface,running:null,disabled:null}],1,{source:"traffic-history",inventoryComplete:false})}));await open(page)
await expect(page.getByRole("status").filter({hasText:"Частичный каталог из истории трафика"})).toBeVisible();await expect(page.getByRole("status").filter({hasText:"Состояния относятся к этому срезу"})).toBeVisible();await expect(page.getByTitle("Running в срезе неизвестен",{exact:true})).toBeVisible()
})
test("Каталог state: unknown и IPsec failure видны без раскрытия группы",async({page})=>{
await page.route("**/api/users/interface-catalog-state?*",route=>route.fulfill({json:catalogState([],1,{source:"unknown",sampledAt:null,inventoryComplete:false,failures:[{resource:"interfaces",error:"Нет снимка"},{resource:"ipsec",error:"IPsec отказ"}]})}));await open(page);await expect(page.getByRole("alert",{name:"Частичные сбои каталога"})).toContainText("IPsec отказ");await expect(page.getByText("Список интерфейсов неизвестен",{exact:true})).toBeVisible();await expect(page.getByText("Нет интерфейсов в каталоге",{exact:true})).toHaveCount(0)
})
test("Каталог state: пустой срез истории не означает отсутствие интерфейсов",async({page})=>{
await page.route("**/api/users/interface-catalog-state?*",route=>route.fulfill({json:catalogState([],1,{source:"traffic-history",inventoryComplete:false})}));await open(page);await expect(page.getByText("В этом срезе нет интерфейсов",{exact:true})).toBeVisible();await expect(page.getByText("Нет интерфейсов в каталоге",{exact:true})).toHaveCount(0)
})
+4 -3
View File
@@ -1,9 +1,10 @@
import {catalogState} from "./fixtures/users"
import {test,expect} from "@playwright/test"
const headers={"access-control-allow-origin":"*","access-control-allow-methods":"GET,POST,OPTIONS","access-control-allow-headers":"Content-Type,Authorization"}
const user={id:"u-test",name:"Новый источник пользователей",login:"[email protected]",email:"[email protected]",role:"viewer",active:true,avatar:"НП",lastSeen:null,sections:[],servers:[],bindings:[],createdAt:"",updatedAt:""}
for(const change of ["backend","mode"])for(const fails of [false,true])test("Пользователи: позднее чтение/"+change+"/"+fails,async({page})=>{
let started=false,completed=false,release!:()=>void;const gate=new Promise<void>(r=>release=r)
await page.route("**/health",r=>r.fulfill({headers,json:{ok:true}}));await page.route("**/api/**",r=>{const p=new URL(r.request().url()).pathname;return r.fulfill({headers,json:p==="/api/auth/config"?{required:false}:p==="/api/users"?{users:[user]}:[]})})
await page.route("**/health",r=>r.fulfill({headers,json:{ok:true}}));await page.route("**/api/**",r=>{const p=new URL(r.request().url()).pathname;return r.fulfill({headers,json:p==="/api/auth/config"?{required:false}:p==="/api/users"?{users:[user]}:p==="/api/users/interface-catalog-state"?catalogState():[]})})
await page.route("**/api/users",async r=>{if(new URL(r.request().url()).port!=="8000")return r.fallback();started=true;await gate;try{await r.fulfill(fails?{headers,status:503,json:{error:"СТАРАЯ ОШИБКА пользователей"}}:{headers,json:{users:[{...user,name:"СТАРЫЙ ПОЛЬЗОВАТЕЛЬ"}]}})}catch{}finally{completed=true}})
await page.goto("/users");await expect.poll(()=>started).toBe(true);await page.getByRole("link",{name:/^Настройки/}).click()
if(change==="backend"){const input=page.getByPlaceholder("http://localhost:8000");await input.fill("http://localhost:8101");await input.press("Enter")}else await page.getByRole("button",{name:"Моковые",exact:true}).click()
@@ -14,7 +15,7 @@ for(const change of ["backend","mode"])for(const fails of [false,true])test("П
for(const fails of [false,true])test("Пользователи: позднее удаление не меняет новый backend/"+fails,async({page})=>{
let started=false,completed=false,release!:()=>void;const gate=new Promise<void>(r=>release=r)
await page.route("**/health",r=>r.fulfill({headers,json:{ok:true}}));await page.route("**/api/**",r=>{const p=new URL(r.request().url()).pathname;return r.fulfill({headers,json:p==="/api/auth/config"?{required:false}:p==="/api/users"?{users:[user]}:[]})})
await page.route("**/health",r=>r.fulfill({headers,json:{ok:true}}));await page.route("**/api/**",r=>{const p=new URL(r.request().url()).pathname;return r.fulfill({headers,json:p==="/api/auth/config"?{required:false}:p==="/api/users"?{users:[user]}:p==="/api/users/interface-catalog-state"?catalogState():[]})})
const catalog={id:1,name:"Сервер",host:"192.0.2.1",port:443,useSsl:true,verifySsl:false,username:"admin",password:"",type:"exit-node",site:"",country:"RU",asn:"",comment:"",enabled:true,lanSubnet:"",wanUplinks:[],createdAt:"",updatedAt:"",status:null,latency:null,os:null,model:null,uptime:null,cpuLoad:null,freeMemory:null,totalMemory:null,identityName:null,sessions:0,polledAt:null}
await page.route("**/api/servers",r=>r.fulfill({headers,json:[catalog]}));await page.route("**/api/users/u-test",async r=>{started=true;await gate;try{await r.fulfill(fails?{headers,status:503,json:{error:"СТАРОЕ УДАЛЕНИЕ ОТКЛОНЕНО"}}:{headers,status:204,body:""})}catch{}finally{completed=true}})
await page.goto("/users");await page.getByRole("button",{name:"Удалить пользователя "+user.name}).click();await page.getByRole("alertdialog").getByRole("button",{name:"Удалить",exact:true}).click();await expect.poll(()=>started).toBe(true)
@@ -24,7 +25,7 @@ for(const fails of [false,true])test("Пользователи: позднее
for(const fails of [false,true])test("Пользователи: позднее создание не меняет новый backend/"+fails,async({page})=>{
let started=false,completed=false,release!:()=>void;const gate=new Promise<void>(r=>release=r)
await page.route("**/health",r=>r.fulfill({headers,json:{ok:true}}));await page.route("**/api/**",r=>{const p=new URL(r.request().url()).pathname;return r.fulfill({headers,json:p==="/api/auth/config"?{required:false}:p==="/api/users"?{users:[user]}:[]})})
await page.route("**/health",r=>r.fulfill({headers,json:{ok:true}}));await page.route("**/api/**",r=>{const p=new URL(r.request().url()).pathname;return r.fulfill({headers,json:p==="/api/auth/config"?{required:false}:p==="/api/users"?{users:[user]}:p==="/api/users/interface-catalog-state"?catalogState():[]})})
const catalog={id:1,name:"Сервер",host:"192.0.2.1",port:443,useSsl:true,verifySsl:false,username:"admin",password:"",type:"exit-node",site:"",country:"RU",asn:"",comment:"",enabled:true,lanSubnet:"",wanUplinks:[],createdAt:"",updatedAt:"",status:null,latency:null,os:null,model:null,uptime:null,cpuLoad:null,freeMemory:null,totalMemory:null,identityName:null,sessions:0,polledAt:null}
await page.route("**/api/servers",r=>r.fulfill({headers,json:[catalog]}));await page.route("**/api/users",async r=>{if(r.request().method()!=="POST")return r.fallback();started=true;await gate;try{await r.fulfill(fails?{headers,status:503,json:{error:"СТАРОЕ СОЗДАНИЕ ОТКЛОНЕНО"}}:{headers,status:201,json:{user:{...user,...r.request().postDataJSON(),id:"old-created",name:"СТАРЫЙ СОЗДАННЫЙ"}}})}catch{}finally{completed=true}})
await page.goto("/users");await page.getByRole("button",{name:"Добавить пользователя",exact:true}).click();await page.getByPlaceholder("Иван Иванов").fill("СТАРЫЙ СОЗДАННЫЙ");await page.getByPlaceholder("[email protected]").fill("[email protected]");const dialog=page.getByRole("dialog");for(let i=0;i<3;i++)await dialog.getByRole("button",{name:"Далее",exact:true}).click();await dialog.getByRole("button",{name:"Создать",exact:true}).click();await expect.poll(()=>started).toBe(true)
+10 -10
View File
@@ -1,42 +1,42 @@
import { test, expect, type Page } from "@playwright/test"
import {user,server} from "./fixtures/users"
import {user,server,catalogResponse} from "./fixtures/users"
async function routes(page:Page){
await page.route("**/health",r=>r.fulfill({json:{ok:true}}))
await page.route("**/api/**",r=>{const path=new URL(r.request().url()).pathname;return r.fulfill({json:path==="/api/users"?{users:[user]}:path==="/api/servers"?[server]:path==="/api/auth/config"?{required:false}:{interfaces:[]}})})
await page.route("**/health",r=>catalogResponse(r, {json:{ok:true}}))
await page.route("**/api/**",r=>{const path=new URL(r.request().url()).pathname;return catalogResponse(r, {json:path==="/api/users"?{users:[user]}:path==="/api/servers"?[server]:path==="/api/auth/config"?{required:false}:{interfaces:[]}})})
}
test.beforeEach(async({page})=>routes(page))
const add=(page:Page)=>page.getByRole("button",{name:"Добавить пользователя",exact:true})
const refresh=(page:Page)=>page.getByRole("button",{name:"Обновить пользователей",exact:true})
for(const kind of ["API","malformed","duplicate"] as const)test("Пользователи: первоначальный отказ "+kind,async({page})=>{
let fail=true;await page.route("**/api/users",r=>r.fulfill(fail?(kind==="API"?{status:503,json:{error:"Отказ пользователей"}}:{json:{users:kind==="duplicate"?[user,user]:[{}]}}):{json:{users:[user]}}))
let fail=true;await page.route("**/api/users",r=>catalogResponse(r, fail?(kind==="API"?{status:503,json:{error:"Отказ пользователей"}}:{json:{users:kind==="duplicate"?[user,user]:[{}]}}):{json:{users:[user]}}))
await page.goto("/users");await expect(page.getByRole("alert",{name:"Пользователи",exact:true})).toContainText("Пользователи пока неизвестны");await expect(page.getByText("Нет пользователей",{exact:true})).toHaveCount(0);await expect(add(page)).toBeDisabled();await expect(page.getByText("Алексей Коротaев",{exact:true})).toHaveCount(0)
fail=false;await refresh(page).click();await expect(page.getByText(user.name,{exact:true})).toBeVisible();await expect(add(page)).toBeEnabled();await expect(page.getByRole("alert",{name:"Пользователи",exact:true})).toHaveCount(0)
})
for(const kind of ["API","malformed","duplicate"] as const)test("Пользователи: независимый отказ серверов "+kind,async({page})=>{
let fail=true;await page.route("**/api/servers",r=>r.fulfill(fail?(kind==="API"?{status:403,json:{error:"Серверы запрещены"}}:{json:kind==="duplicate"?[server,server]:[{}]}):{json:[server]}))
let fail=true;await page.route("**/api/servers",r=>catalogResponse(r, fail?(kind==="API"?{status:403,json:{error:"Серверы запрещены"}}:{json:kind==="duplicate"?[server,server]:[{}]}):{json:[server]}))
await page.goto("/users");await expect(page.getByText(user.name,{exact:true})).toBeVisible();await expect(page.getByRole("alert",{name:"Серверы пользователей"})).toContainText("Каталог серверов пока неизвестен");await expect(add(page)).toBeDisabled()
await page.getByText(user.name,{exact:true}).click();await expect(page.getByText("каталог неизвестен",{exact:true})).toBeVisible()
fail=false;await refresh(page).click();await expect(add(page)).toBeEnabled();await expect(page.getByRole("alert",{name:"Серверы пользователей"})).toHaveCount(0)
})
test("Пользователи: подтверждённый пустой список и нули",async({page})=>{
await page.route("**/api/users",r=>r.fulfill({json:{users:[]}}));await page.goto("/users");await expect(page.getByText("Нет пользователей",{exact:true})).toBeVisible();await expect(add(page)).toBeEnabled();await expect(page.getByRole("alert",{name:"Пользователи",exact:true})).toHaveCount(0)
await page.route("**/api/users",r=>catalogResponse(r, {json:{users:[]}}));await page.goto("/users");await expect(page.getByText("Нет пользователей",{exact:true})).toBeVisible();await expect(add(page)).toBeEnabled();await expect(page.getByRole("alert",{name:"Пользователи",exact:true})).toHaveCount(0)
})
for(const part of ["users","servers"])test("Пользователи: фоновый отказ сохраняет снимок "+part,async({page})=>{
let fail=false;await page.route("**/api/"+part,r=>r.fulfill(fail?{status:503,json:{error:"ФОНОВЫЙ ОТКАЗ"}}:{json:part==="users"?{users:[user]}:[server]}))
let fail=false;await page.route("**/api/"+part,r=>catalogResponse(r, fail?{status:503,json:{error:"ФОНОВЫЙ ОТКАЗ"}}:{json:part==="users"?{users:[user]}:[server]}))
await page.goto("/users");await expect(add(page)).toBeEnabled();fail=true;await refresh(page).click();await expect(page.getByText("ФОНОВЫЙ ОТКАЗ",{exact:true})).toBeVisible();await expect(page.getByText(user.name,{exact:true})).toBeVisible();await expect(add(page)).toBeDisabled();await expect(page.getByRole("button",{name:"Редактировать пользователя "+user.name})).toBeDisabled()
fail=false;await refresh(page).click();await expect(add(page)).toBeEnabled();await expect(page.getByText("ФОНОВЫЙ ОТКАЗ",{exact:true})).toHaveCount(0)
})
test("Пользователи: read права и отсутствие server read",async({page})=>{
await page.addInitScript(()=>localStorage.setItem("mmapp_token","header."+btoa(JSON.stringify({sub:"test",apps:["mm"],permissions:["mm:users:read"],exp:Math.floor(Date.now()/1000)+3600}))+".signature"))
await page.route("**/api/auth/config",r=>r.fulfill({json:{required:true}}));let servers=0;await page.route("**/api/servers",r=>{servers++;return r.fulfill({json:[server]})})
await page.route("**/api/auth/config",r=>catalogResponse(r, {json:{required:true}}));let servers=0;await page.route("**/api/servers",r=>{servers++;return catalogResponse(r, {json:[server]})})
await page.goto("/users");await expect(page.getByText(user.name,{exact:true})).toBeVisible();await expect(add(page)).toBeDisabled();await expect(page.getByRole("button",{name:"Удалить пользователя "+user.name})).toBeDisabled();await expect(page.getByRole("status").filter({hasText:"mm:servers:read"})).toBeVisible();expect(servers).toBe(0)
})
test("Пользователи: до ответа live нет демо-профилей",async({page})=>{
let release!:()=>void;const gate=new Promise<void>(r=>release=r);await page.route("**/api/users",async r=>{await gate;await r.fulfill({json:{users:[user]}}).catch(()=>{})})
let release!:()=>void;const gate=new Promise<void>(r=>release=r);await page.route("**/api/users",async r=>{await gate;await catalogResponse(r, {json:{users:[user]}}).catch(()=>{})})
await page.goto("/users");await expect(page.getByRole("status").filter({hasText:"Загрузка пользователей…"})).toBeVisible();await expect(page.getByText(user.name,{exact:true})).toHaveCount(0);await expect(add(page)).toBeDisabled();await expect(page.getByText("Нет пользователей",{exact:true})).toHaveCount(0);release();await expect(add(page)).toBeEnabled()
})
test("Пользователи: медленный серверный каталог не задерживает список",async({page})=>{
let release!:()=>void;const gate=new Promise<void>(r=>release=r);await page.route("**/api/servers",async r=>{await gate;await r.fulfill({json:[server]}).catch(()=>{})})
let release!:()=>void;const gate=new Promise<void>(r=>release=r);await page.route("**/api/servers",async r=>{await gate;await catalogResponse(r, {json:[server]}).catch(()=>{})})
await page.goto("/users");await expect(page.getByText(user.name,{exact:true})).toBeVisible();await expect(add(page)).toBeDisabled();release();await expect(add(page)).toBeEnabled()
})
+14 -14
View File
@@ -1,21 +1,21 @@
import {test,expect} from "@playwright/test"
import {user,server} from "./fixtures/users"
import {user,server,catalogResponse} from "./fixtures/users"
test.beforeEach(async({page})=>{
await page.route("**/health",r=>r.fulfill({json:{ok:true}}));await page.route("**/api/**",r=>{const p=new URL(r.request().url()).pathname;return r.fulfill({json:p==="/api/auth/config"?{required:false}:p==="/api/users"?{users:[user]}:p==="/api/servers"?[server]:{interfaces:[]}})})
await page.route("**/health",r=>catalogResponse(r, {json:{ok:true}}));await page.route("**/api/**",r=>{const p=new URL(r.request().url()).pathname;return catalogResponse(r, {json:p==="/api/auth/config"?{required:false}:p==="/api/users"?{users:[user]}:p==="/api/servers"?[server]:{interfaces:[]}})})
})
for(const kind of ["403","503","malformed"] as const)test("Удаление пользователя: ошибка сохраняет диалог "+kind,async({page})=>{
let fail=true,deleted=false;await page.route("**/api/users",r=>r.fulfill({json:{users:deleted?[]:[user]}}))
await page.route("**/api/users/u-test",r=>{if(fail)return r.fulfill(kind==="malformed"?{status:200,json:{success:true}}:{status:Number(kind),json:{error:"УДАЛЕНИЕ ОТКЛОНЕНО"}});deleted=true;return r.fulfill({status:204,body:""})})
let fail=true,deleted=false;await page.route("**/api/users",r=>catalogResponse(r, {json:{users:deleted?[]:[user]}}))
await page.route("**/api/users/u-test",r=>{if(fail)return catalogResponse(r, kind==="malformed"?{status:200,json:{success:true}}:{status:Number(kind),json:{error:"УДАЛЕНИЕ ОТКЛОНЕНО"}});deleted=true;return catalogResponse(r, {status:204,body:""})})
await page.goto("/users");await page.getByRole("button",{name:"Удалить пользователя "+user.name}).click();const dialog=page.getByRole("alertdialog");await dialog.getByRole("button",{name:"Удалить",exact:true}).click();await expect(dialog.getByRole("alert")).toBeVisible();await expect(dialog).toBeVisible();await expect(page.getByText(user.name,{exact:true})).toBeVisible()
fail=false;await dialog.getByRole("button",{name:"Удалить",exact:true}).click();await expect(dialog).toHaveCount(0);await expect(page.getByText("Нет пользователей",{exact:true})).toBeVisible();await expect(page.getByRole("status").filter({hasText:"Удаление пользователя подтверждено API."})).toBeVisible()
})
test("Удаление пользователя: lock, Escape и один запрос",async({page})=>{
let calls=0,deleted=false,release!:()=>void;const gate=new Promise<void>(r=>release=r)
await page.route("**/api/users",r=>r.fulfill({json:{users:deleted?[]:[user]}}));await page.route("**/api/users/u-test",async r=>{calls++;await gate;deleted=true;await r.fulfill({status:204,body:""})})
await page.route("**/api/users",r=>catalogResponse(r, {json:{users:deleted?[]:[user]}}));await page.route("**/api/users/u-test",async r=>{calls++;await gate;deleted=true;await catalogResponse(r, {status:204,body:""})})
await page.goto("/users");await page.getByRole("button",{name:"Удалить пользователя "+user.name}).click();const dialog=page.getByRole("alertdialog");await dialog.getByRole("button",{name:"Удалить",exact:true}).evaluate(el=>{(el as HTMLButtonElement).click();(el as HTMLButtonElement).click()});await expect.poll(()=>calls).toBe(1);await expect(dialog.getByRole("button",{name:"Удаление…"})).toBeDisabled();await expect(dialog.getByRole("button",{name:"Отмена",exact:true})).toBeDisabled();await page.keyboard.press("Escape");await expect(dialog).toBeVisible();release();await expect(dialog).toHaveCount(0);expect(calls).toBe(1)
})
test("Удаление пользователя: подтверждение отдельно от отказа refresh",async({page})=>{
let deleted=false,fail=true;await page.route("**/api/users",r=>r.fulfill(deleted&&fail?{status:503,json:{error:"Список не обновлён"}}:{json:{users:deleted?[]:[user]}}));await page.route("**/api/users/u-test",r=>{deleted=true;return r.fulfill({status:204,body:""})})
let deleted=false,fail=true;await page.route("**/api/users",r=>catalogResponse(r, deleted&&fail?{status:503,json:{error:"Список не обновлён"}}:{json:{users:deleted?[]:[user]}}));await page.route("**/api/users/u-test",r=>{deleted=true;return catalogResponse(r, {status:204,body:""})})
await page.goto("/users");await page.getByRole("button",{name:"Удалить пользователя "+user.name}).click();await page.getByRole("alertdialog").getByRole("button",{name:"Удалить",exact:true}).click();await expect(page.getByRole("status").filter({hasText:"Удаление пользователя подтверждено API. Обновление каталогов не удалось"})).toBeVisible();await expect(page.getByRole("alert",{name:"Пользователи",exact:true})).toContainText("Список не обновлён");await expect(page.getByText(user.name,{exact:true})).toHaveCount(0)
fail=false;await page.getByRole("button",{name:"Обновить пользователей",exact:true}).click();await expect(page.getByRole("button",{name:"Добавить пользователя",exact:true})).toBeEnabled()
})
@@ -24,32 +24,32 @@ async function openCreate(page:import("@playwright/test").Page){
await page.getByRole("button",{name:"Добавить пользователя",exact:true}).click();await page.getByPlaceholder("Иван Иванов").fill("Новый профиль");await page.getByPlaceholder("[email protected]").fill("[email protected]");for(let i=0;i<3;i++)await page.getByRole("dialog").getByRole("button",{name:"Далее",exact:true}).click()
}
for(const kind of ["403","malformed"] as const)test("Создание пользователя: сохранение черновика "+kind,async({page})=>{
let fail=true,created=false,posts=0;await page.route("**/api/users",r=>{if(r.request().method()==="POST"){posts++;if(fail)return r.fulfill(kind==="403"?{status:403,json:{error:"Создание запрещено"}}:{json:{user:{}}});created=true;return r.fulfill({status:201,json:{user:{...user,...r.request().postDataJSON(),id:"new"}}})}return r.fulfill({json:{users:created?[{...user,id:"new",name:"Новый профиль",login:"[email protected]",email:"[email protected]"}]:[user]}})})
let fail=true,created=false,posts=0;await page.route("**/api/users",r=>{if(r.request().method()==="POST"){posts++;if(fail)return catalogResponse(r, kind==="403"?{status:403,json:{error:"Создание запрещено"}}:{json:{user:{}}});created=true;return catalogResponse(r, {status:201,json:{user:{...user,...r.request().postDataJSON(),id:"new"}}})}return catalogResponse(r, {json:{users:created?[{...user,id:"new",name:"Новый профиль",login:"[email protected]",email:"[email protected]"}]:[user]}})})
await page.goto("/users");await openCreate(page);const dialog=page.getByRole("dialog");await dialog.getByRole("button",{name:"Создать",exact:true}).click();await expect(dialog.getByRole("alert",{name:"Сохранение пользователя"})).toBeVisible();await dialog.getByRole("button",{name:"Назад",exact:true}).click();await dialog.getByRole("button",{name:"Назад",exact:true}).click();await dialog.getByRole("button",{name:"Назад",exact:true}).click();await expect(page.getByPlaceholder("Иван Иванов")).toHaveValue("Новый профиль");await expect(page.getByPlaceholder("[email protected]")).toHaveValue("[email protected]")
if(kind==="403"){fail=false;for(let i=0;i<3;i++)await dialog.getByRole("button",{name:"Далее",exact:true}).click();await dialog.getByRole("button",{name:"Создать",exact:true}).click();await expect(dialog).toHaveCount(0);await expect(page.getByRole("status").filter({hasText:"Профиль и привязки подтверждены API."})).toBeVisible();expect(posts).toBe(2)}
else{for(let i=0;i<3;i++)await dialog.getByRole("button",{name:"Далее",exact:true}).click();await expect(dialog.getByRole("button",{name:"Создать",exact:true})).toBeDisabled();await dialog.getByRole("button",{name:"Проверить список пользователей"}).click();await expect(dialog).toBeVisible();expect(posts).toBe(1)}
})
test("Создание пользователя: lock формы и успех отдельно от refresh",async({page})=>{
let posts=0,created=false,release!:()=>void;const gate=new Promise<void>(r=>release=r)
await page.route("**/api/users",async r=>{if(r.request().method()==="POST"){posts++;await gate;created=true;return r.fulfill({status:201,json:{user:{...user,...r.request().postDataJSON(),id:"new"}}})}return r.fulfill(created?{status:503,json:{error:"REFRESH профиля недоступен"}}:{json:{users:[user]}})})
await page.route("**/api/users",async r=>{if(r.request().method()==="POST"){posts++;await gate;created=true;return catalogResponse(r, {status:201,json:{user:{...user,...r.request().postDataJSON(),id:"new"}}})}return catalogResponse(r, created?{status:503,json:{error:"REFRESH профиля недоступен"}}:{json:{users:[user]}})})
await page.goto("/users");await openCreate(page);const dialog=page.getByRole("dialog");await dialog.getByRole("button",{name:"Создать",exact:true}).evaluate(el=>{(el as HTMLButtonElement).click();(el as HTMLButtonElement).click()});await expect.poll(()=>posts).toBe(1);await expect(dialog.getByRole("button",{name:"Отмена",exact:true})).toBeDisabled();await page.keyboard.press("Escape");await expect(dialog).toBeVisible();release();await expect(dialog).toHaveCount(0);await expect(page.getByText("Новый профиль",{exact:true})).toBeVisible();await expect(page.getByRole("status").filter({hasText:"Профиль и привязки подтверждены API. Обновление каталогов не удалось"})).toBeVisible()
})
for(const bad of [true,false])test("Редактирование пользователя: неверный id и ошибка API "+bad,async({page})=>{
let fail=true;await page.route("**/api/users/u-test",r=>r.fulfill(fail?(bad?{json:{user:{...user,id:"foreign"}}}:{status:503,json:{error:"PATCH отклонён"}}):{json:{user:{...user,...r.request().postDataJSON()}}}))
let fail=true;await page.route("**/api/users/u-test",r=>catalogResponse(r, fail?(bad?{json:{user:{...user,id:"foreign"}}}:{status:503,json:{error:"PATCH отклонён"}}):{json:{user:{...user,...r.request().postDataJSON()}}}))
await page.goto("/users");await page.getByRole("button",{name:"Редактировать пользователя "+user.name}).click();await page.getByPlaceholder("Иван Иванов").fill("Исправленный профиль");const dialog=page.getByRole("dialog");for(let i=0;i<3;i++)await dialog.getByRole("button",{name:"Далее",exact:true}).click();await dialog.getByRole("button",{name:"Сохранить",exact:true}).click();await expect(dialog.getByRole("alert",{name:"Сохранение пользователя"})).toBeVisible();fail=false;await dialog.getByRole("button",{name:"Сохранить",exact:true}).click();await expect(dialog).toHaveCount(0)
})
for(const lost of [false,true])test("Создание пользователя: частичные привязки и повтор без дублей "+lost,async({page})=>{
let fail=true,profiles=0,patches=0;const adds:string[]=[];let profile={...user,id:"new",name:"Новый профиль",login:"[email protected]",email:"[email protected]"};const bindings:Array<Record<string,unknown>>=[]
await page.route("**/api/users/interface-catalog?*",r=>r.fulfill({json:{interfaces:["ether1","ether2"].map(name=>({name,type:"ether",running:true,disabled:false,boundUserId:null,boundUserLogin:null}))}}))
await page.route("**/api/users",r=>{if(r.request().method()==="POST"){profiles++;profile={...profile,...r.request().postDataJSON()};return r.fulfill({status:201,json:{user:{...profile,bindings}}})}return r.fulfill({json:{users:[user,{...profile,bindings}]}})})
await page.route("**/api/users/new",r=>{if(r.request().method()==="PATCH"){patches++;profile={...profile,...r.request().postDataJSON()}}return r.fulfill({json:{user:{...profile,bindings}}})})
await page.route("**/api/users/new/bindings",r=>{const data=r.request().postDataJSON();adds.push(data.interfaceName);const binding={...data,id:"saved-"+data.interfaceName,userId:"new",serverName:"Роутер",serverSite:"",serverCountry:"RU",createdAt:"",updatedAt:"",peerPublicKey:"",peerName:"",comment:""};if(fail&&((lost&&data.interfaceName==="ether1")||(!lost&&data.interfaceName==="ether2"))){if(lost)bindings.push(binding);return r.fulfill({status:lost?500:409,json:{error:"ПРИВЯЗКА НЕ ПОДТВЕРЖДЕНА"}})}bindings.push(binding);return r.fulfill({status:201,json:{binding}})})
await page.route("**/api/users/interface-catalog-state?*",r=>catalogResponse(r, {json:{interfaces:["ether1","ether2"].map(name=>({name,type:"ether",running:true,disabled:false,boundUserId:null,boundUserLogin:null}))}}))
await page.route("**/api/users",r=>{if(r.request().method()==="POST"){profiles++;profile={...profile,...r.request().postDataJSON()};return catalogResponse(r, {status:201,json:{user:{...profile,bindings}}})}return catalogResponse(r, {json:{users:[user,{...profile,bindings}]}})})
await page.route("**/api/users/new",r=>{if(r.request().method()==="PATCH"){patches++;profile={...profile,...r.request().postDataJSON()}}return catalogResponse(r, {json:{user:{...profile,bindings}}})})
await page.route("**/api/users/new/bindings",r=>{const data=r.request().postDataJSON();adds.push(data.interfaceName);const binding={...data,id:"saved-"+data.interfaceName,userId:"new",serverName:"Роутер",serverSite:"",serverCountry:"RU",createdAt:"",updatedAt:"",peerPublicKey:"",peerName:"",comment:""};if(fail&&((lost&&data.interfaceName==="ether1")||(!lost&&data.interfaceName==="ether2"))){if(lost)bindings.push(binding);return catalogResponse(r, {status:lost?500:409,json:{error:"ПРИВЯЗКА НЕ ПОДТВЕРЖДЕНА"}})}bindings.push(binding);return catalogResponse(r, {status:201,json:{binding}})})
await page.goto("/users");await openCreate(page);const dialog=page.getByRole("dialog");await dialog.getByRole("button",{name:/ether1.*Ethernet/}).click();await dialog.getByRole("button",{name:/ether2.*Ethernet/}).click();await dialog.getByRole("button",{name:"Привязать",exact:true}).click();await dialog.getByRole("button",{name:"Создать",exact:true}).click();await expect(dialog.getByRole("alert",{name:"Сохранение пользователя"})).toContainText("привязки сохранены частично");await expect(dialog.getByRole("button",{name:"Продолжить сохранение",exact:true})).toBeEnabled()
fail=false;await dialog.getByRole("button",{name:"Продолжить сохранение",exact:true}).click();await expect(dialog).toHaveCount(0);expect(profiles).toBe(1);expect(patches).toBe(1);expect(adds.filter(name=>name==="ether1")).toHaveLength(1);expect(bindings).toHaveLength(2);expect(adds.filter(name=>name==="ether2")).toHaveLength(lost?1:2)
})
for(const field of ["role","sections","servers"] as const)test("Сохранение профиля: ответ с другими разрешениями "+field,async({page})=>{
let fail=true;await page.route("**/api/users/u-test",r=>{const data=r.request().postDataJSON();return r.fulfill({json:{user:{...user,...data,...(fail?(field==="role"?{role:"admin"}:field==="sections"?{sections:[]}:{servers:[]}):{})}}})})
let fail=true;await page.route("**/api/users/u-test",r=>{const data=r.request().postDataJSON();return catalogResponse(r, {json:{user:{...user,...data,...(fail?(field==="role"?{role:"admin"}:field==="sections"?{sections:[]}:{servers:[]}):{})}}})})
await page.goto("/users");await page.getByRole("button",{name:"Редактировать пользователя "+user.name}).click();const dialog=page.getByRole("dialog");await dialog.getByRole("button",{name:"Оператор",exact:true}).click();for(let i=0;i<3;i++)await dialog.getByRole("button",{name:"Далее",exact:true}).click();await dialog.getByRole("button",{name:"Сохранить",exact:true}).click();await expect(dialog.getByRole("alert",{name:"Сохранение пользователя"})).toContainText("не подтверждён");fail=false;await dialog.getByRole("button",{name:"Сохранить",exact:true}).click();await expect(dialog).toHaveCount(0)
})