fix(ui): сохранять контекст каталога и выбирать клиента IPsec
This commit is contained in:
1 parent
4896d8ef6f
commit
ecd72d9750
5 files changed
+113
-40
No files matched your search
@@ -31,7 +31,8 @@ import {
|
||||
import { Flag } from "@/components/flag"
|
||||
import { StatusDot } from "@/components/status-dot"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { listInterfaceCatalog } from "@/shared/api/users"
|
||||
import { can } from "@/lib/auth"
|
||||
import { listInterfaceCatalog, userApiError } from "@/shared/api/users"
|
||||
import { TYPE_VARIANT } from "@/components/data-grids/users-expanded-detail"
|
||||
import {
|
||||
bindingDiffKey,
|
||||
@@ -157,32 +158,33 @@ function UserSheet({
|
||||
const [form, setForm] = useState<AppUserForm>(() => (user ? toForm(user) : emptyForm(servers)))
|
||||
const [errors, setErrors] = useState<Partial<Record<keyof AppUserForm, string>>>({})
|
||||
const [catalogServerId, setCatalogServerId] = useState(servers[0]?.id ?? "")
|
||||
const [catalog, setCatalog] = useState<CatalogIface[]>([])
|
||||
const [selectedPicks, setSelectedPicks] = useState<CatalogPick[]>([])
|
||||
const [expandedWg, setExpandedWg] = useState<string | null>(null)
|
||||
const [newComment, setNewComment] = useState("")
|
||||
|
||||
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 [catalogRevision, setCatalogRevision] = useState(0)
|
||||
const demoCatalog = useMemo(() => {
|
||||
if (isLive || !catalogSrv) return []
|
||||
const occupancy = users.map(u => u.id === user?.id ? { ...u, bindings: form.bindings } : u)
|
||||
const withDraft = user ? occupancy : [...occupancy, { id: "__draft__", name: "", login: "", email: "", role: "viewer" as const, last: "", avatar: "", active: true, sections: [], servers: [], bindings: form.bindings }]
|
||||
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 catalogReady = !!catalogSrv && (!isLive || !!currentCatalog?.readAt && !currentCatalog.loading && !currentCatalog.error)
|
||||
useEffect(() => {
|
||||
if (!open || !catalogServerId) {
|
||||
setCatalog([])
|
||||
return
|
||||
}
|
||||
if (!isLive) {
|
||||
const occupancy = users.map((u) =>
|
||||
u.id === user?.id ? { ...u, bindings: form.bindings } : u,
|
||||
)
|
||||
const withDraft = user
|
||||
? occupancy
|
||||
: [...occupancy, { id: "__draft__", name: "", login: "", email: "", role: "viewer" as const, last: "", avatar: "", active: true, sections: [], servers: [], bindings: form.bindings }]
|
||||
setCatalog(catalogForServer(catalogServerId, withDraft))
|
||||
return
|
||||
}
|
||||
if (!open || !isLive || !catalogSrv || !can("mm:users:read")) return
|
||||
let cancelled = false
|
||||
void listInterfaceCatalog(backendUrl, catalogServerId)
|
||||
.then((ifaces) => { if (!cancelled) setCatalog(ifaces) })
|
||||
.catch(() => { if (!cancelled) setCatalog([]) })
|
||||
return () => { cancelled = true }
|
||||
}, [open, catalogServerId, isLive, backendUrl, users, user, form.bindings])
|
||||
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) })) })
|
||||
return () => { cancelled = true; controller.abort() }
|
||||
}, [open, isLive, catalogSrv, catalogServerId, catalogKey, backendUrl, catalogRevision])
|
||||
|
||||
const setField = <K extends keyof AppUserForm>(k: K, v: AppUserForm[K]) => {
|
||||
setForm((f) => ({ ...f, [k]: v }))
|
||||
@@ -234,13 +236,22 @@ function UserSheet({
|
||||
}
|
||||
|
||||
const isAdmin = form.role === "admin"
|
||||
const catalogSrv = servers.find((s) => s.id === catalogServerId)
|
||||
|
||||
const validSelectedPicks = catalogReady ? selectedPicks.filter(pick => {
|
||||
const iface = catalog.find(item => item.name === pick.interfaceName && item.type === pick.type)
|
||||
if (!iface || iface.peersError || iface.boundUserId && iface.boundUserId !== user?.id) return false
|
||||
if (iface.type === "wg" || iface.type === "ipsec") {
|
||||
const peer = iface.peers?.find(item => item.publicKey === pick.peerPublicKey)
|
||||
return !!peer && (!peer.boundUserId || peer.boundUserId === user?.id)
|
||||
}
|
||||
return !pick.peerPublicKey
|
||||
}) : []
|
||||
|
||||
const addSelectedBindings = () => {
|
||||
if (!catalogSrv || selectedPicks.length === 0) return
|
||||
if (!catalogReady || !catalogSrv || validSelectedPicks.length === 0 || saving) return
|
||||
const existing = new Set(form.bindings.map(bindingDiffKey))
|
||||
const next: InterfaceBinding[] = [...form.bindings]
|
||||
for (const pick of selectedPicks) {
|
||||
for (const pick of validSelectedPicks) {
|
||||
const key = bindingDiffKey({
|
||||
serverId: catalogSrv.id,
|
||||
interfaceName: pick.interfaceName,
|
||||
@@ -452,30 +463,36 @@ function UserSheet({
|
||||
|
||||
<StepperContent value={4} className="flex flex-col">
|
||||
<p className="text-[11px] text-muted-foreground py-2.5 border-b">
|
||||
Ethernet и GRE — целиком. WireGuard — только пир (public-key).
|
||||
Ethernet и GRE — целиком. WireGuard — пир (public-key), IPsec — клиент (CN).
|
||||
</p>
|
||||
|
||||
<div className="py-3 flex flex-col gap-3 border-b">
|
||||
<label className="text-[10px] text-muted-foreground">Сервер</label>
|
||||
<select
|
||||
className="h-8 rounded-md border bg-background px-2 text-xs font-mono"
|
||||
value={catalogServerId}
|
||||
aria-label="Сервер каталога интерфейсов"
|
||||
value={catalogSrv ? catalogServerId : ""}
|
||||
onChange={(e) => { setCatalogServerId(e.target.value); setSelectedPicks([]); setExpandedWg(null) }}
|
||||
>
|
||||
{!catalogSrv && <option value="" disabled>Выберите сервер</option>}
|
||||
{servers.map((s) => (
|
||||
<option key={s.id} value={s.id}>{s.name} · {s.site}</option>
|
||||
))}
|
||||
</select>
|
||||
<Frame dense spacing="sm">
|
||||
<FramePanel className="max-h-64 overflow-y-auto p-1.5">
|
||||
{catalog.length === 0 && (
|
||||
{!catalogSrv && <p role="status">Выберите сервер каталога интерфейсов.</p>}
|
||||
{isLive && currentCatalog?.loading && <p role="status">Чтение каталога интерфейсов…</p>}
|
||||
{isLive && currentCatalog?.readAt && <p role="status" className="text-xs text-muted-foreground">Каталог получен: {new Date(currentCatalog.readAt).toLocaleTimeString("ru-RU")}</p>}
|
||||
{isLive && currentCatalog?.error && <div role="alert" aria-label="Каталог интерфейсов"><p>{currentCatalog.error} Прежние данные доступны для просмотра; выбор приостановлен.</p><Button type="button" variant="outline" onClick={() => { setSelectedPicks([]); setCatalogRevision(n => n + 1) }}>Повторить каталог интерфейсов</Button></div>}
|
||||
<Frame dense spacing="sm" className="h-64 min-h-0 flex flex-col">
|
||||
<FramePanel className="flex-1 min-h-0 overflow-y-auto p-1.5">
|
||||
{catalogReady && catalog.length === 0 && (
|
||||
<p className="px-2 py-3 text-center text-[11px] text-muted-foreground">Нет интерфейсов в каталоге</p>
|
||||
)}
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{catalog.map((iface) => {
|
||||
const tile = IFACE_TILE[iface.type]
|
||||
const Icon = tile.icon
|
||||
if (iface.type === "wg") {
|
||||
if (iface.type === "wg" || iface.type === "ipsec") {
|
||||
const open = expandedWg === iface.name
|
||||
const legacyTaken = Boolean(iface.boundUserId && iface.boundUserId !== user?.id)
|
||||
const legacyMine = alreadyBoundHere.has(`${iface.name}\0`)
|
||||
@@ -495,7 +512,7 @@ function UserSheet({
|
||||
<ItemContent className="min-w-0">
|
||||
<ItemTitle className="max-w-full min-w-0 gap-1.5 font-mono text-[11px]">
|
||||
<span className="min-w-0 truncate">{iface.name}</span>
|
||||
<Badge variant={TYPE_VARIANT.wg} size="sm">WireGuard</Badge>
|
||||
<Badge variant={TYPE_VARIANT[iface.type]} size="sm">{IFACE_TYPE_LABEL[iface.type]}</Badge>
|
||||
</ItemTitle>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
@@ -508,21 +525,21 @@ function UserSheet({
|
||||
{open ? (
|
||||
<div className="ml-4 flex flex-col gap-0.5 border-l pl-2">
|
||||
{iface.peersError ? (
|
||||
<p className="px-2 py-1.5 text-[11px] text-muted-foreground">Не удалось загрузить пиры</p>
|
||||
<p role="alert" className="px-2 py-1.5 text-xs text-destructive">Не удалось загрузить {iface.type === "ipsec" ? "клиентов IPsec" : "пиры WireGuard"}: {iface.peersError}<Button type="button" variant="outline" onClick={() => { setSelectedPicks([]); setCatalogRevision(n => n + 1) }}>Повторить каталог интерфейсов</Button></p>
|
||||
) : null}
|
||||
{(iface.peers ?? []).length === 0 && !iface.peersError ? (
|
||||
<p className="px-2 py-1.5 text-[11px] text-muted-foreground">Нет пиров на интерфейсе</p>
|
||||
<p className="px-2 py-1.5 text-[11px] text-muted-foreground">{iface.type === "ipsec" ? "Нет клиентов IPsec" : "Нет пиров на интерфейсе"}</p>
|
||||
) : null}
|
||||
{(iface.peers ?? []).map((peer) => {
|
||||
const pick: CatalogPick = {
|
||||
interfaceName: iface.name,
|
||||
peerPublicKey: peer.publicKey,
|
||||
peerName: peer.name,
|
||||
type: "wg",
|
||||
type: iface.type,
|
||||
}
|
||||
const taken = Boolean(peer.boundUserId && peer.boundUserId !== user?.id)
|
||||
const mine = alreadyBoundHere.has(`${iface.name}\0${peer.publicKey}`)
|
||||
const disabled = taken || mine || legacyTaken
|
||||
const disabled = !catalogReady || !!iface.peersError || taken || mine || legacyTaken
|
||||
const selected = selectedPickKeys.has(pickKey(pick))
|
||||
return (
|
||||
<Item
|
||||
@@ -551,7 +568,7 @@ function UserSheet({
|
||||
<ItemTitle className="max-w-full min-w-0 gap-1.5 font-mono text-[11px]">
|
||||
<span className="min-w-0 truncate">{peer.name || peer.publicKey}</span>
|
||||
{peer.latestHandshake ? (
|
||||
<Badge variant="success-light" size="xs">handshake</Badge>
|
||||
<Badge variant="outline" size="xs" title={peer.latestHandshake}>{iface.type === "ipsec" ? "время активности" : "время handshake"}</Badge>
|
||||
) : null}
|
||||
</ItemTitle>
|
||||
</ItemContent>
|
||||
@@ -576,7 +593,7 @@ function UserSheet({
|
||||
const pick: CatalogPick = { interfaceName: iface.name, peerPublicKey: "", type: iface.type }
|
||||
const taken = Boolean(iface.boundUserId && iface.boundUserId !== user?.id)
|
||||
const mine = alreadyBoundHere.has(`${iface.name}\0`)
|
||||
const disabled = taken || mine
|
||||
const disabled = !catalogReady || taken || mine
|
||||
const selected = selectedPickKeys.has(pickKey(pick))
|
||||
return (
|
||||
<Item
|
||||
@@ -631,7 +648,7 @@ function UserSheet({
|
||||
value={newComment}
|
||||
onChange={(e) => setNewComment(e.target.value)}
|
||||
/>
|
||||
<Button size="sm" disabled={selectedPicks.length === 0} onClick={addSelectedBindings}>
|
||||
<Button size="sm" disabled={!catalogReady || validSelectedPicks.length === 0} onClick={addSelectedBindings}>
|
||||
<PlusIcon className="size-3.5" />Привязать
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
+10
-1
@@ -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А.4; загрузка, удаление и сохранение пользователей приняты локально, каталог/UX далее |
|
||||
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | В работе: 11А.1–11А.5; frontend пользователей/каталога принят по API фикстурам; backend каталог/UX далее |
|
||||
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
|
||||
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
|
||||
|
||||
@@ -36,6 +36,15 @@
|
||||
|
||||
|
||||
|
||||
## Блок 11А.5 — каталог интерфейсов формы (2026-10-01)
|
||||
|
||||
- Каталог привязан к serverId/host; ожидание, error и подтверждённое empty различаются. Поздний ответ другого устройства отменяется. При повторе сохраняются прошлые строки/время получения, выбор блокируется до успешного чтения; черновик формы не сбрасывается.
|
||||
- API схемы отклоняют дубли интерфейсов/пиров, отсутствующий peer snapshot и пустые ключи. Ошибка peers показана явно, не выдаётся за пустой список; выбор неподтверждённого пира блокируется. Перед привязкой выбранные объекты сверяются с текущим каталогом.
|
||||
- IPsec теперь выбирает конкретного клиента/CN как требует backend; раньше весь ipsec-vpn отправлялся с пустым ключом. WireGuard остаётся выбором public-key. Время handshake/активности не называется текущим подключением.
|
||||
- Frame scroll имеет высоту родителя и min-h-0 flex цепочку. Демо каталог вычисляется при рендере; последняя исходная UserSheet set-state-in-effect ошибка устранена без suppressions.
|
||||
- 25 catalog/operations E2E, TypeScript и профильный lint прошли. Проверены API/malformed/дубли/неполные peer данные, empty/loading, поздний success/error другого сервера, WG failure/retry, IPsec CN payload и прежние операции.
|
||||
- Далее 11А.6: backend достоверность interface catalog (сейчас snapshot/история без метаданных, неизвестные флаги и скрытый IPsec failure); затем UI/доступность и локальная приёмка пользователей. Реальные RouterOS/DB/Portal не проверены.
|
||||
|
||||
## Блок 11А.4 — многошаговое сохранение профиля и привязок (2026-10-01)
|
||||
|
||||
- Общий mutation lock, отмена/30s срок каждого запроса, защита прежнего источника; Sheet не сбрасывает черновик при изменении каталога/подтверждённом промежуточном шаге. Во время отправки поля/кнопки и закрытие блокируются.
|
||||
|
||||
@@ -67,3 +67,13 @@
|
||||
- Ответы проверяют userId/login, отправленные поля/права и контекст привязки. Wrong role/sections/servers не подтверждают сохранение. IDs в URL кодируются. Техническая многострочная ошибка схемы заменена понятной краткой ошибкой — иначе footer выходил за экран; обнаружено E2E, исправлено и повторено без ослабления теста.
|
||||
- 5 fault-injection unit, 27 различных data/operations E2E и 8 context E2E прошли; TypeScript, профильный lint и production frontend прошли. В UserSheet остаётся одна исходная catalog set-state-in-effect ошибка; она входит в следующий блок, suppressions нет.
|
||||
- Далее 11А.5: строгий interface catalog, loading/error/empty, точный сервер и поздние ответы/peer сбои; затем форма/таблица, клавиатура и ПК/телефон. Реальная БД/Portal не проверены.
|
||||
|
||||
|
||||
## Блок 11А.5 — каталог интерфейсов формы (2026-10-01)
|
||||
|
||||
- Каталог привязан к serverId/host; ожидание, error и подтверждённое empty различаются. Поздний ответ другого устройства отменяется. При повторе сохраняются прошлые строки/время получения, выбор блокируется до успешного чтения; черновик формы не сбрасывается.
|
||||
- API схемы отклоняют дубли интерфейсов/пиров, отсутствующий peer snapshot и пустые ключи. Ошибка peers показана явно, не выдаётся за пустой список; выбор неподтверждённого пира блокируется. Перед привязкой выбранные объекты сверяются с текущим каталогом.
|
||||
- IPsec теперь выбирает конкретного клиента/CN как требует backend; раньше весь ipsec-vpn отправлялся с пустым ключом. WireGuard остаётся выбором public-key. Время handshake/активности не называется текущим подключением.
|
||||
- Frame scroll имеет высоту родителя и min-h-0 flex цепочку. Демо каталог вычисляется при рендере; последняя исходная UserSheet set-state-in-effect ошибка устранена без suppressions.
|
||||
- 25 catalog/operations E2E, TypeScript и профильный lint прошли. Проверены API/malformed/дубли/неполные peer данные, empty/loading, поздний success/error другого сервера, WG failure/retry, IPsec CN payload и прежние операции.
|
||||
- Далее 11А.6: backend достоверность interface catalog (сейчас snapshot/история без метаданных, неизвестные флаги и скрытый IPsec failure); затем UI/доступность и локальная приёмка пользователей. Реальные RouterOS/DB/Portal не проверены.
|
||||
+4
-1
@@ -18,7 +18,8 @@ const checkedUserSchema = appUserReadSchema.refine(user => user.bindings.every(b
|
||||
const usersListPayload = z.object({ users: z.array(checkedUserSchema).refine(rows => new Set(rows.map(row => row.id)).size === rows.length, "Повторяющиеся пользователи в ответе API") })
|
||||
const userPayload = z.object({ user: checkedUserSchema })
|
||||
const bindingPayload = z.object({ binding: userBindingSchema })
|
||||
const catalogPayload = z.object({ interfaces: z.array(catalogInterfaceSchema) })
|
||||
const checkedCatalogInterface = catalogInterfaceSchema.refine(iface => iface.name.trim().length > 0 && (!(iface.type === "wg" || iface.type === "ipsec") || iface.peers !== undefined || !!iface.peersError) && (!iface.peers || iface.peers.every(peer => peer.publicKey.trim().length > 0) && new Set(iface.peers.map(peer => peer.publicKey)).size === iface.peers.length), "Неполный или неоднозначный каталог интерфейсов")
|
||||
const catalogPayload = z.object({ interfaces: z.array(checkedCatalogInterface).refine(rows => new Set(rows.map(row => row.name)).size === rows.length, "Повторяющиеся интерфейсы") })
|
||||
|
||||
export function toFrontendBinding(b: UserBinding): InterfaceBinding {
|
||||
return {
|
||||
@@ -132,10 +133,12 @@ export async function deleteUserBinding(baseUrl: string, userId: string, binding
|
||||
export async function listInterfaceCatalog(
|
||||
baseUrl: string,
|
||||
serverId: string,
|
||||
init?: RequestInit,
|
||||
): Promise<CatalogIface[]> {
|
||||
const payload = await requestJson<unknown>(
|
||||
baseUrl,
|
||||
`/api/users/interface-catalog?serverId=${encodeURIComponent(serverId)}`,
|
||||
init,
|
||||
)
|
||||
return catalogPayload.parse(payload).interfaces as CatalogInterface[]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import {test,expect,type Page} from "@playwright/test"
|
||||
import {user,server} 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:[]}})})
|
||||
})
|
||||
async function open(page:Page){await page.goto("/users");await page.getByRole("button",{name:"Добавить пользователя",exact:true}).click();await page.getByPlaceholder("Иван Иванов").fill("Каталог тест");await page.getByPlaceholder("[email protected]").fill("[email protected]");for(let i=0;i<3;i++)await page.getByRole("dialog").getByRole("button",{name:"Далее",exact:true}).click()}
|
||||
for(const kind of ["API","malformed","duplicate","missing-peers","duplicate-peers"])test("Каталог пользователя: отказ и повтор "+kind,async({page})=>{
|
||||
let fail=true;await page.route("**/api/users/interface-catalog?*",r=>r.fulfill(fail?(kind==="API"?{status:503,json:{error:"КАТАЛОГ НЕДОСТУПЕН"}}:{json:{interfaces:kind==="malformed"?[{}]:kind==="duplicate"?[iface,iface]:kind==="missing-peers"?[{...iface,type:"wg"}]:[{...iface,type:"wg",peers:[peer,peer]}]}}):{json:{interfaces:[iface]}}))
|
||||
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:[]}})})
|
||||
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}})
|
||||
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 ОТКАЗ"}:{})}]}}))
|
||||
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"})
|
||||
})
|
||||
Reference in new issue
Block a user