From adb6589816435c64f52727078791075c70d19d5a Mon Sep 17 00:00:00 2001
From: Denozordec
Date: Thu, 1 Oct 2026 13:46:12 +0700
Subject: [PATCH] =?UTF-8?q?fix(ui):=20=D1=81=D0=BE=D1=85=D1=80=D0=B0=D0=BD?=
=?UTF-8?q?=D1=8F=D1=82=D1=8C=20=D0=BA=D0=BE=D0=BD=D1=82=D0=B5=D0=BA=D1=81?=
=?UTF-8?q?=D1=82=20=D0=B8=20=D0=B8=D1=81=D1=82=D0=BE=D1=87=D0=BD=D0=B8?=
=?UTF-8?q?=D0=BA=20=D1=81=D0=B5=D1=80=D1=82=D0=B8=D1=84=D0=B8=D0=BA=D0=B0?=
=?UTF-8?q?=D1=82=D0=BE=D0=B2?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
app/(main)/certificates/page.tsx | 36 +++++++++++++++----
.../data-grids/certificates-data-grid.tsx | 8 +++--
docs/ux-ui-plan.md | 2 +-
docs/ux-ui-progress.md | 9 ++++-
docs/ux-ui-stage-11a.md | 7 ++++
lib/certificates-url-state.test.ts | 14 ++++++++
lib/certificates-url-state.ts | 16 +++++++++
playwright.certificates-context.config.ts | 8 +++++
playwright.config.ts | 2 +-
tests/e2e/certificates-context.spec.ts | 13 +++++++
tests/e2e/certificates-url.spec.ts | 36 +++++++++++++++++++
11 files changed, 138 insertions(+), 13 deletions(-)
create mode 100644 lib/certificates-url-state.test.ts
create mode 100644 lib/certificates-url-state.ts
create mode 100644 playwright.certificates-context.config.ts
create mode 100644 tests/e2e/certificates-context.spec.ts
create mode 100644 tests/e2e/certificates-url.spec.ts
diff --git a/app/(main)/certificates/page.tsx b/app/(main)/certificates/page.tsx
index a579acdc..ecc63236 100644
--- a/app/(main)/certificates/page.tsx
+++ b/app/(main)/certificates/page.tsx
@@ -1,6 +1,8 @@
"use client"
-import { useCallback, useEffect, useMemo, useState } from "react"
+import { Suspense, useCallback, useEffect, useMemo, useState } from "react"
+import { useSearchParams } from "next/navigation"
+import { readCertificatesUrl, patchCertificatesUrl, type CertificateStatusFilter } from "@/lib/certificates-url-state"
import { PageHeader } from "@/components/page-header"
import { FormField, FormToggle, SectionTitle } from "@/components/form-kit"
import { FileImportDialog } from "@/components/file-import-dialog"
@@ -418,6 +420,10 @@ function CertPartIssueForm({
}
export default function CertificatesPage() {
+ return Загрузка контекста сертификатов…
}>
+}
+
+function CertificatesSource() {
const { mode, backendUrl, prefsHydrated } = useDataSource()
if (!prefsHydrated) return Загрузка настроек источника…
return
@@ -428,14 +434,25 @@ function CertificatesContent() {
const isLive = prefsHydrated && mode === "live"
const liveReady = isLive && backendStatus === true
- const [search, setSearch] = useState("")
+ const params = useSearchParams()
+ const context = readCertificatesUrl(params)
+ const search = context.search
+ const statusFilter = context.status
+ const selectedServerId = context.server
+ const urlError = context.error
+ function writeContext(changes: Record, replace = false) {
+ const href = patchCertificatesUrl(new URLSearchParams(window.location.search), changes) + window.location.hash
+ if (replace) window.history.replaceState(null, "", href); else window.history.pushState(null, "", href)
+ }
+ const setSearch = (value: string) => writeContext({ q: value }, true)
+ const setStatusFilter = (value: CertificateStatusFilter) => writeContext({ status: value === "all" ? null : value })
+ const setSelectedServerId = (value: string) => writeContext({ server: value === ALL_SERVERS_ID ? null : value })
const canRead = can("mm:certificates:read")
const canWrite = can("mm:certificates:write")
const canReadServers = can("mm:servers:read")
const catalog = useCertificateCatalog(backendUrl, isLive, canRead, canReadServers)
const { snapshots, loading, error: loadError, serverError } = catalog
const serverList = useMemo(() => catalog.servers.map(toFrontendServer), [catalog.servers])
- const [statusFilter, setStatusFilter] = useState("all")
const [issueOpen, setIssueOpen] = useState(false)
const [issueStep, setIssueStep] = useState(1)
@@ -448,7 +465,6 @@ function CertificatesContent() {
const [issueTrustWww, setIssueTrustWww] = useState(true)
const [issueTrustApi, setIssueTrustApi] = useState(true)
- const [selectedServerId, setSelectedServerId] = useState(ALL_SERVERS_ID)
const [acmeDirectoryUrl, setAcmeDirectoryUrl] = useState(
"https://acme-v02.api.letsencrypt.org/directory",
@@ -467,9 +483,10 @@ function CertificatesContent() {
const displayServers = isLive ? serverList : mockServers
const scopedCerts = useMemo(() => {
+ if (urlError) return []
if (selectedServerId === ALL_SERVERS_ID) return displayCerts
return displayCerts.filter((c) => c.serverId === selectedServerId)
- }, [displayCerts, selectedServerId])
+ }, [displayCerts, selectedServerId, urlError])
const certRailItems = useMemo(() => (
displayServers.map((s) => ({
@@ -516,7 +533,7 @@ function CertificatesContent() {
const expired = useMemo(() => scopedCerts.filter((c) => c.status === "expired"), [scopedCerts])
const scopeSnapshots = Object.values(snapshots).filter(snapshot => selectedServerId === ALL_SERVERS_ID || snapshot.latest.serverId === selectedServerId)
- const scopeKnown = !isLive || canRead && catalog.hasState && !loadError && (selectedServerId === ALL_SERVERS_ID || !!snapshots[selectedServerId]) && scopeSnapshots.every(snapshot => snapshot.latest.error === null)
+ const scopeKnown = !urlError && (!isLive && (selectedServerId === ALL_SERVERS_ID || mockServers.some(server => server.id === selectedServerId)) || canRead && catalog.hasState && !loadError && (selectedServerId === ALL_SERVERS_ID || !!snapshots[selectedServerId]) && scopeSnapshots.every(snapshot => snapshot.latest.error === null))
const classificationsKnown = scopeKnown && scopedCerts.every(cert => cert.status !== "unknown" && cert.daysLeft !== null)
const filtered = useMemo(() => {
@@ -665,6 +682,9 @@ function CertificatesContent() {
}
>
+ {!isLive &&
Моковые данные
}
+ {urlError &&
{urlError}
}
+ {!urlError && selectedServerId !== ALL_SERVERS_ID && (isLive ? catalog.hasState && !snapshots[selectedServerId] : !mockServers.some(server => server.id === selectedServerId)) &&
Выбранный сервер недоступен в каталоге сертификатов. Выберите другой сервер.
}
{isLive && backendStatus === false && (
Backend недоступен — live-операции отключены.
@@ -734,9 +754,11 @@ function CertificatesContent() {
}}
countLabel={isLive && !catalog.hasState ? "Количество неизвестно" : `${filtered.length} строк в снимках`}
/>
- {isLive && scopedCerts.length === 0 && !scopeKnown ?
{loading ? "Загрузка сертификатов…" : "Список сертификатов пока неизвестен"}
:
Исправьте контекст ссылки : isLive && scopedCerts.length === 0 && !scopeKnown ? {loading ? "Загрузка сертификатов…" : "Список сертификатов пока неизвестен"}
: }
diff --git a/components/data-grids/certificates-data-grid.tsx b/components/data-grids/certificates-data-grid.tsx
index c7e27aff..a87cda5b 100644
--- a/components/data-grids/certificates-data-grid.tsx
+++ b/components/data-grids/certificates-data-grid.tsx
@@ -68,9 +68,11 @@ interface CertificatesDataGridProps {
certificates: CertificateView[]
serverMap: Map
isLoading?: boolean
+ emptyTitle?: string
+ emptyDescription?: string
}
-function CertificatesDataGrid({ certificates, serverMap, isLoading }: CertificatesDataGridProps) {
+function CertificatesDataGrid({ certificates, serverMap, isLoading, emptyTitle = "Нет сертификатов", emptyDescription = "Выпустите или импортируйте сертификат" }: CertificatesDataGridProps) {
const columns = useMemo[]>(
() => [
{
@@ -229,8 +231,8 @@ function CertificatesDataGrid({ certificates, serverMap, isLoading }: Certificat
return (
}
- title="Нет сертификатов"
- description="Выпустите или импортируйте сертификат"
+ title={emptyTitle}
+ description={emptyDescription}
className="border-0 py-12"
/>
)
diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md
index ca599888..6b47a01e 100644
--- a/docs/ux-ui-plan.md
+++ b/docs/ux-ui-plan.md
@@ -226,4 +226,4 @@ REST запрос → ожидание → подтверждённый output /
| Пользователи | [Профиль](ux-ui/screens/users-profile-desktop.png), [ошибка](ux-ui/screens/users-error-desktop.png) | [390](ux-ui/screens/users-profile-mobile.png), [320](ux-ui/screens/users-profile-narrow.png) |
| Контейнеры | [Обзор](ux-ui/screens/containers-overview-desktop.png), [снимок](ux-ui/screens/containers-export-desktop.png) | [390](ux-ui/screens/containers-export-mobile.png), [320](ux-ui/screens/containers-export-narrow.png) |
-Актуальная точка продолжения: 11А.17.3 — URL-контекст и поздние ответы сертификатов. State API, reader, snapshot merge и nullable представление подключены в 11А.14–11А.17.2; таблица требований — [этап 11А](ux-ui-stage-11a.md). Сертификаты и весь 11А ещё не приняты.
+Актуальная точка продолжения: 11А.18 — строгие API ACME/renew/issue, затем формы настроек и polling выпуска. State API, reader, snapshot merge и nullable представление подключены в 11А.14–11А.17.3; таблица требований — [этап 11А](ux-ui-stage-11a.md). Сертификаты и весь 11А ещё не приняты.
diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md
index 95bb4bac..1c01a0fc 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А.17.2; пользователи/контейнеры приняты локально, сертификаты: state каталог подключён; далее 11А.17.3 |
+| 11А | Пользователи, контейнеры, сертификаты и резервные копии | В работе: 11А.1–11А.17.3; пользователи/контейнеры приняты локально, сертификаты: state/URL подключены; далее 11А.18 |
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
@@ -36,6 +36,13 @@
+## Блок 11А.17.3 — URL и изоляция источника сертификатов (2026-10-01)
+
+- server/status/q читаются из URL: reload/Back сохраняют контекст, чужие параметры и hash не теряются. Дубликаты, неизвестные статусы, некорректные ID и слишком длинный поиск явно отклоняются; сброс не удаляет чужой контекст. Mock srv ID поддержан отдельно.
+- Недоступный сервер не заменяется всеми устройствами; ошибка ссылки скрывает строки. Нет совпадений отличается от подтверждённого пустого каталога. Моковые данные подписаны явно.
+- 3 URL unit, 6 URL E2E и 4 E2E смены backend/mode с поздним успехом/ошибкой прошли. ESLint/TypeScript прошли; production сборка URL/Suspense прошла до финального добавления подписи моков, после него повторены TypeScript и E2E.
+- Далее 11А.18: строгие ответы/отмена API ACME/renew/issue, затем достоверные формы настроек и issue checkpoint/polling. Настройки, выпуск и общая приёмка сертификатов пока не приняты.
+
## Блок 11А.17.2 — state каталог на странице сертификатов (2026-10-01)
- Страница читает readCertificatesState и независимо строгий каталог серверов. Снимки сохраняются при общем/частичном отказе; подтверждённое empty очищает прежние строки. Отображаются время каталога и время последнего успешного чтения каждого устройства.
diff --git a/docs/ux-ui-stage-11a.md b/docs/ux-ui-stage-11a.md
index 442baf63..2df56968 100644
--- a/docs/ux-ui-stage-11a.md
+++ b/docs/ux-ui-stage-11a.md
@@ -213,3 +213,10 @@
- Refresh теперь повторяет строгий GET state, не подмешивает legacy DTO и не объявляет частичный результат полным успехом. Write-действия требуют права сертификатов, выпуск также требует доступного каталога серверов. Неподключённый импорт отключён вместо ложного сообщения об успехе.
- 12 профильных HTTP E2E, TypeScript, ESLint и production frontend прошли. API контракт/сетевые правила не менялись.
- Следующий блок 11А.17.3: URL server/status/q и проверки позднего ответа при смене mode/backend. Затем отдельные блоки ACME/renew, issue polling, UI/экспорт и приёмка. Сертификаты в целом, резервные копии, 11Б/12 не приняты; реальные RouterOS/ACME/Portal проверки остаются внешними.
+
+## Блок 11А.17.3 — URL и изоляция источника сертификатов (2026-10-01)
+
+- server/status/q читаются из URL: reload/Back сохраняют контекст, чужие параметры и hash не теряются. Дубликаты, неизвестные статусы, некорректные ID и слишком длинный поиск явно отклоняются; сброс не удаляет чужой контекст. Mock srv ID поддержан отдельно.
+- Недоступный сервер не заменяется всеми устройствами; ошибка ссылки скрывает строки. Нет совпадений отличается от подтверждённого пустого каталога. Моковые данные подписаны явно.
+- 3 URL unit, 6 URL E2E и 4 E2E смены backend/mode с поздним успехом/ошибкой прошли. ESLint/TypeScript прошли; production сборка URL/Suspense прошла до финального добавления подписи моков, после него повторены TypeScript и E2E.
+- Далее 11А.18: строгие ответы/отмена API ACME/renew/issue, затем достоверные формы настроек и issue checkpoint/polling. Настройки, выпуск и общая приёмка сертификатов пока не приняты.
diff --git a/lib/certificates-url-state.test.ts b/lib/certificates-url-state.test.ts
new file mode 100644
index 00000000..b3bd0b80
--- /dev/null
+++ b/lib/certificates-url-state.test.ts
@@ -0,0 +1,14 @@
+import assert from "node:assert/strict"
+import { test } from "node:test"
+import { readCertificatesUrl, patchCertificatesUrl } from "./certificates-url-state"
+test("invalid and duplicate context is rejected", () => {
+ for (const query of ["server=bad", "server=0", "server=-1", "server=1.5", "server=9007199254740992", "server=1&server=2", "status=bad", "status=valid&status=unknown", "q=a&q=b", "q=" + "x".repeat(201)]) assert.ok(readCertificatesUrl(new URLSearchParams(query)).error, query)
+})
+test("known context, mock identifier and empty defaults", () => {
+ assert.deepEqual(readCertificatesUrl(new URLSearchParams("server=2&status=unknown&q=test")), { server: "2", status: "unknown", search: "test", error: null })
+ assert.equal(readCertificatesUrl(new URLSearchParams("server=srv1")).error, null)
+ assert.deepEqual(readCertificatesUrl(new URLSearchParams()), { server: "all", status: "all", search: "", error: null })
+})
+test("patch preserves unrelated parameters and removes filters", () => {
+ assert.equal(patchCertificatesUrl(new URLSearchParams("server=1&status=valid&ref=map"), { server: null, status: null }), "/certificates?ref=map")
+})
diff --git a/lib/certificates-url-state.ts b/lib/certificates-url-state.ts
new file mode 100644
index 00000000..ab66aed0
--- /dev/null
+++ b/lib/certificates-url-state.ts
@@ -0,0 +1,16 @@
+export type CertificateStatusFilter = "all" | "valid" | "expired" | "revoked" | "unknown"
+const statuses = ["valid", "expired", "revoked", "unknown"]
+export function readCertificatesUrl(params: URLSearchParams) {
+ const server = params.get("server"), status = params.get("status"), search = params.get("q") ?? ""
+ let error: string | null = null
+ if (["server", "status", "q"].some(key => params.getAll(key).length > 1)) error = "Параметры контекста сертификатов повторяются."
+ if (server !== null && !(/^[1-9][0-9]*$/.test(server) && Number.isSafeInteger(Number(server))) && !/^srv[1-9][0-9]*$/.test(server)) error = "В ссылке указан неверный сервер."
+ if (status !== null && !statuses.includes(status)) error = "В ссылке указан неизвестный статус."
+ if (search.length > 200) error = "Поиск в ссылке слишком длинный."
+ return { server: server ?? "all", status: (status ?? "all") as CertificateStatusFilter, search, error }
+}
+export function patchCertificatesUrl(params: URLSearchParams, changes: Record) {
+ const next = new URLSearchParams(params)
+ for (const [key, value] of Object.entries(changes)) if (value === null || value === "") next.delete(key); else next.set(key, value)
+ return "/certificates" + (next.size ? "?" + next.toString() : "")
+}
diff --git a/playwright.certificates-context.config.ts b/playwright.certificates-context.config.ts
new file mode 100644
index 00000000..a5ac49ef
--- /dev/null
+++ b/playwright.certificates-context.config.ts
@@ -0,0 +1,8 @@
+import { defineConfig } from "@playwright/test"
+import base from "./playwright.config"
+const server=base.webServer
+if(!server||Array.isArray(server))throw new Error("Ожидается один локальный сервер")
+export default defineConfig({
+ ...base, use:{...base.use,baseURL:"http://127.0.0.1:3101"},testIgnore:[],testMatch:"certificates-context.spec.ts",
+ webServer:{...server,command:"node node_modules/next/dist/bin/next dev --hostname 127.0.0.1 --port 3101",url:"http://127.0.0.1:3101",env:{...server.env,NEXT_PUBLIC_BACKEND_URL:"local"}},
+})
diff --git a/playwright.config.ts b/playwright.config.ts
index b79ce9a0..b920558e 100644
--- a/playwright.config.ts
+++ b/playwright.config.ts
@@ -2,7 +2,7 @@ import { defineConfig } from "@playwright/test"
export default defineConfig({
testDir: "./tests/e2e",
- testIgnore: ["**/containers-context.spec.ts", "**/users-context.spec.ts", "**/probes-context.spec.ts", "**/terminal-context.spec.ts", "**/network-map-context.spec.ts", "**/route-optimizer-context.spec.ts", "**/recursive-routes-context.spec.ts", "**/ospf-context.spec.ts", "**/bgp-context.spec.ts", "**/firewall-context.spec.ts", "**/filters-context.spec.ts", "**/ipsec-context.spec.ts", "**/gre-context.spec.ts", "**/vxlan-context.spec.ts"],
+ testIgnore: ["**/certificates-context.spec.ts", "**/containers-context.spec.ts", "**/users-context.spec.ts", "**/probes-context.spec.ts", "**/terminal-context.spec.ts", "**/network-map-context.spec.ts", "**/route-optimizer-context.spec.ts", "**/recursive-routes-context.spec.ts", "**/ospf-context.spec.ts", "**/bgp-context.spec.ts", "**/firewall-context.spec.ts", "**/filters-context.spec.ts", "**/ipsec-context.spec.ts", "**/gre-context.spec.ts", "**/vxlan-context.spec.ts"],
fullyParallel: false,
workers: 1,
retries: 0,
diff --git a/tests/e2e/certificates-context.spec.ts b/tests/e2e/certificates-context.spec.ts
new file mode 100644
index 00000000..41e6c258
--- /dev/null
+++ b/tests/e2e/certificates-context.spec.ts
@@ -0,0 +1,13 @@
+import {test,expect} from "@playwright/test"
+import {certificate as container,server,certificateState as state} from "./fixtures/certificates"
+const headers={"access-control-allow-origin":"*","access-control-allow-methods":"GET,POST,OPTIONS","access-control-allow-headers":"Content-Type,Authorization"}
+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/servers"?[server]:p==="/api/certificates/state"?state():{directoryUrl:"https://acme.example.test/directory",tokenConfigured:false,enabled:false,intervalSec:300,daysBeforeExpiry:30}})})
+ await page.route("**/api/certificates/state",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:state([{...container,name:"СТАРЫЙ СЕРТИФИКАТ"}])})}catch{}finally{completed=true}})
+ await page.goto("/certificates");await expect.poll(()=>started).toBe(true);await page.goto("/settings")
+ 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()
+ await page.goto("/certificates");release();await expect.poll(()=>completed).toBe(true)
+ if(change==="backend")await expect(page.getByText(container.name,{exact:true})).toBeVisible();else await expect(page.getByText("Моковые данные",{exact:true})).toBeVisible()
+ await expect(page.getByText("СТАРЫЙ СЕРТИФИКАТ",{exact:true})).toHaveCount(0);await expect(page.getByText(/СТАРАЯ ОШИБКА сертификатов/)).toHaveCount(0)
+})
diff --git a/tests/e2e/certificates-url.spec.ts b/tests/e2e/certificates-url.spec.ts
new file mode 100644
index 00000000..f94a6d04
--- /dev/null
+++ b/tests/e2e/certificates-url.spec.ts
@@ -0,0 +1,36 @@
+import { test, expect } from "@playwright/test"
+import { certificate, certificateState, server } from "./fixtures/certificates"
+test.beforeEach(async ({ 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/auth/config" ? { required: false } : path === "/api/servers" ? [server] : path === "/api/certificates/state" ? certificateState() : { directoryUrl: "https://acme.example.test/directory", tokenConfigured: false, enabled: false, intervalSec: 300, daysBeforeExpiry: 30 } })
+ })
+})
+for (const query of ["server=bad", "server=1&server=2", "status=bad", "q=a&q=b"]) test("Сертификаты URL: неверный контекст " + query, async ({ page }) => {
+ await page.goto("/certificates?" + query + "&keep=map#detail")
+ await expect(page.getByRole("alert", { name: "Контекст сертификатов", exact: true })).toBeVisible()
+ await expect(page.getByText(certificate.name, { exact: true })).toHaveCount(0)
+ await page.getByRole("button", { name: "Сбросить контекст", exact: true }).click()
+ expect(new URL(page.url()).search).toBe("?keep=map")
+ expect(new URL(page.url()).hash).toBe("#detail")
+ await expect(page.getByText(certificate.name, { exact: true })).toBeVisible()
+})
+test("Сертификаты URL: фильтр, reload и Back", async ({ page }) => {
+ await page.goto("/certificates?server=1&status=unknown&q=certificate&keep=map#detail")
+ await expect(page.getByText(certificate.name, { exact: true })).toBeVisible()
+ await page.getByRole("button", { name: "Действующие", exact: true }).click()
+ await expect(page.getByText("Нет совпадений", { exact: true })).toBeVisible()
+ expect(new URL(page.url()).searchParams.get("status")).toBe("valid")
+ await page.goBack(); await expect(page.getByText(certificate.name, { exact: true })).toBeVisible()
+ await page.reload(); await expect(page.getByRole("textbox", { name: "Поиск по имени, CN, эмитенту…" })).toHaveValue("certificate")
+ await expect(page.getByRole("button", { name: "Неизвестные", exact: true })).toHaveAttribute("aria-pressed", "true")
+ expect(new URL(page.url()).hash).toBe("#detail")
+})
+test("Сертификаты URL: недоступный сервер не заменяется всеми", async ({ page }) => {
+ await page.goto("/certificates?server=2&keep=map")
+ await expect(page.getByRole("alert").filter({ hasText: "Выбранный сервер недоступен" })).toBeVisible()
+ await expect(page.getByText(certificate.name, { exact: true })).toHaveCount(0)
+ await expect(page.getByText("Нет сертификатов", { exact: true })).toHaveCount(0)
+ expect(new URL(page.url()).searchParams.get("server")).toBe("2")
+})