diff --git a/components/users/user-sheet.tsx b/components/users/user-sheet.tsx index de0fba0a..c8a2561b 100644 --- a/components/users/user-sheet.tsx +++ b/components/users/user-sheet.tsx @@ -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 &&

Выберите сервер каталога интерфейсов.

} {isLive && currentCatalog?.loading &&

Чтение каталога интерфейсов…

} {isLive && currentCatalog?.readAt &&

Каталог получен: {new Date(currentCatalog.readAt).toLocaleTimeString("ru-RU")}

} + {isLive && currentCatalog?.result &&
+

{currentCatalog.result.source === "snapshot" ? "Интерфейсы из сохранённого снимка." : currentCatalog.result.source === "traffic-history" ? "Частичный каталог из истории трафика; типы определены по именам." : "Снимок интерфейсов отсутствует."}

+

{currentCatalog.result.sampledAt ? "Время среза: " + new Date(currentCatalog.result.sampledAt).toLocaleString("ru-RU") : "Время среза неизвестно."} Состояния относятся к этому срезу.

+
} + {isLive && !!currentCatalog?.result?.failures.length &&
+ {currentCatalog.result.failures.map(failure =>

{failure.resource === "interfaces" ? "Интерфейсы" : failure.resource === "wireguard" ? "WireGuard" : "IPsec"}: {failure.error}

)} + +
} {isLive && currentCatalog?.error &&

{currentCatalog.error} Прежние данные доступны для просмотра; выбор приостановлен.

} {catalogReady && catalog.length === 0 && ( -

Нет интерфейсов в каталоге

+

{!isLive || currentCatalog?.result?.inventoryComplete ? "Нет интерфейсов в каталоге" : currentCatalog?.result?.source === "traffic-history" ? "В этом срезе нет интерфейсов" : "Список интерфейсов неизвестен"}

)}
{catalog.map((iface) => { @@ -511,7 +519,7 @@ function UserSheet({ - {iface.name} + {iface.type === "ipsec" && iface.name === "ipsec-vpn" ? "Клиенты IPsec" : iface.name} {IFACE_TYPE_LABEL[iface.type]} @@ -620,7 +628,7 @@ function UserSheet({ - {iface.running ? : } + {iface.name} {IFACE_TYPE_LABEL[iface.type]} diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index f9457265..db9d380d 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -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 будет подключён в следующем блоке. diff --git a/docs/ux-ui-stage-11a.md b/docs/ux-ui-stage-11a.md index 3e8c5eac..05cd680a 100644 --- a/docs/ux-ui-stage-11a.md +++ b/docs/ux-ui-stage-11a.md @@ -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 остаётся внешней приёмкой. diff --git a/lib/users.ts b/lib/users.ts index f9385824..eb1ebfcb 100644 --- a/lib/users.ts +++ b/lib/users.ts @@ -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[] diff --git a/shared/api/users.ts b/shared/api/users.ts index 6c736621..55a6ec6c 100644 --- a/shared/api/users.ts +++ b/shared/api/users.ts @@ -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 { + const raw = await requestJson(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 +} diff --git a/tests/e2e/fixtures/users.ts b/tests/e2e/fixtures/users.ts index 910a8770..742db626 100644 --- a/tests/e2e/fixtures/users.ts +++ b/tests/e2e/fixtures/users.ts @@ -1,2 +1,11 @@ export const user={id:"u-test",name:"Настоящий пользователь",login:"test@example.test",email:"test@example.test",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={}) { + return {serverId,source:"snapshot",sampledAt:"2026-10-01T00:00:00Z",inventoryComplete:true,interfaces,failures:[],...overrides} +} +export function catalogResponse(route:Route,options:NonNullable[0]>){ + const json=options.json as Record|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)}:{})}) +} diff --git a/tests/e2e/users-catalog.spec.ts b/tests/e2e/users-catalog.spec.ts index 1cb0904a..a441efa0 100644 --- a/tests/e2e/users-catalog.spec.ts +++ b/tests/e2e/users-catalog.spec.ts @@ -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("i.ivanov@company.io").fill("catalog@example.test");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(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(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(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(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|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) }) diff --git a/tests/e2e/users-context.spec.ts b/tests/e2e/users-context.spec.ts index d2f22893..53cd296a 100644 --- a/tests/e2e/users-context.spec.ts +++ b/tests/e2e/users-context.spec.ts @@ -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:"test@example.test",email:"test@example.test",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(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(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(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("i.ivanov@company.io").fill("old@example.test");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) diff --git a/tests/e2e/users-data.spec.ts b/tests/e2e/users-data.spec.ts index 5c262cbe..f32a0c2d 100644 --- a/tests/e2e/users-data.spec.ts +++ b/tests/e2e/users-data.spec.ts @@ -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(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(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(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(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() }) diff --git a/tests/e2e/users-operations.spec.ts b/tests/e2e/users-operations.spec.ts index f692da0c..06620284 100644 --- a/tests/e2e/users-operations.spec.ts +++ b/tests/e2e/users-operations.spec.ts @@ -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(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("i.ivanov@company.io").fill("new@example.test");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:"new@example.test",email:"new@example.test"}]:[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:"new@example.test",email:"new@example.test"}]:[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("i.ivanov@company.io")).toHaveValue("new@example.test") 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(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:"new@example.test",email:"new@example.test"};const bindings:Array>=[] - 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) })