fix(ui): различать снимок историю и неизвестный каталог пользователей
This commit is contained in:
1 parent
c37498512f
commit
9320c11d58
10 files changed
+111
-52
No files matched your search
@@ -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<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)}:{})})
|
||||
}
|
||||
@@ -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<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)
|
||||
})
|
||||
@@ -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<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("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)
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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("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<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:"new@example.test",email:"new@example.test"};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)
|
||||
})
|
||||
Reference in new issue
Block a user