fix(ui): сохранять контекст и источник сертификатов

This commit is contained in:
Denozordec committed 2026-10-01 13:46:12 +07:00
1 parent b184922f61
commit adb6589816
11 files changed
+138 -13

No files matched your search

+29 -7
View File
@@ -1,6 +1,8 @@
"use client" "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 { PageHeader } from "@/components/page-header"
import { FormField, FormToggle, SectionTitle } from "@/components/form-kit" import { FormField, FormToggle, SectionTitle } from "@/components/form-kit"
import { FileImportDialog } from "@/components/file-import-dialog" import { FileImportDialog } from "@/components/file-import-dialog"
@@ -418,6 +420,10 @@ function CertPartIssueForm({
} }
export default function CertificatesPage() { export default function CertificatesPage() {
return <Suspense fallback={<p role="status">Загрузка контекста сертификатов…</p>}><CertificatesSource /></Suspense>
}
function CertificatesSource() {
const { mode, backendUrl, prefsHydrated } = useDataSource() const { mode, backendUrl, prefsHydrated } = useDataSource()
if (!prefsHydrated) return <p role="status" className="p-5">Загрузка настроек источника…</p> if (!prefsHydrated) return <p role="status" className="p-5">Загрузка настроек источника…</p>
return <CertificatesContent key={mode + ":" + backendUrl} /> return <CertificatesContent key={mode + ":" + backendUrl} />
@@ -428,14 +434,25 @@ function CertificatesContent() {
const isLive = prefsHydrated && mode === "live" const isLive = prefsHydrated && mode === "live"
const liveReady = isLive && backendStatus === true 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<string, string | null>, 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 canRead = can("mm:certificates:read")
const canWrite = can("mm:certificates:write") const canWrite = can("mm:certificates:write")
const canReadServers = can("mm:servers:read") const canReadServers = can("mm:servers:read")
const catalog = useCertificateCatalog(backendUrl, isLive, canRead, canReadServers) const catalog = useCertificateCatalog(backendUrl, isLive, canRead, canReadServers)
const { snapshots, loading, error: loadError, serverError } = catalog const { snapshots, loading, error: loadError, serverError } = catalog
const serverList = useMemo(() => catalog.servers.map(toFrontendServer), [catalog.servers]) const serverList = useMemo(() => catalog.servers.map(toFrontendServer), [catalog.servers])
const [statusFilter, setStatusFilter] = useState<CertificateView["status"] | "all">("all")
const [issueOpen, setIssueOpen] = useState(false) const [issueOpen, setIssueOpen] = useState(false)
const [issueStep, setIssueStep] = useState(1) const [issueStep, setIssueStep] = useState(1)
@@ -448,7 +465,6 @@ function CertificatesContent() {
const [issueTrustWww, setIssueTrustWww] = useState(true) const [issueTrustWww, setIssueTrustWww] = useState(true)
const [issueTrustApi, setIssueTrustApi] = useState(true) const [issueTrustApi, setIssueTrustApi] = useState(true)
const [selectedServerId, setSelectedServerId] = useState(ALL_SERVERS_ID)
const [acmeDirectoryUrl, setAcmeDirectoryUrl] = useState( const [acmeDirectoryUrl, setAcmeDirectoryUrl] = useState(
"https://acme-v02.api.letsencrypt.org/directory", "https://acme-v02.api.letsencrypt.org/directory",
@@ -467,9 +483,10 @@ function CertificatesContent() {
const displayServers = isLive ? serverList : mockServers const displayServers = isLive ? serverList : mockServers
const scopedCerts = useMemo(() => { const scopedCerts = useMemo(() => {
if (urlError) return []
if (selectedServerId === ALL_SERVERS_ID) return displayCerts if (selectedServerId === ALL_SERVERS_ID) return displayCerts
return displayCerts.filter((c) => c.serverId === selectedServerId) return displayCerts.filter((c) => c.serverId === selectedServerId)
}, [displayCerts, selectedServerId]) }, [displayCerts, selectedServerId, urlError])
const certRailItems = useMemo<ServerTileItem[]>(() => ( const certRailItems = useMemo<ServerTileItem[]>(() => (
displayServers.map((s) => ({ displayServers.map((s) => ({
@@ -516,7 +533,7 @@ function CertificatesContent() {
const expired = useMemo(() => scopedCerts.filter((c) => c.status === "expired"), [scopedCerts]) 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 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 classificationsKnown = scopeKnown && scopedCerts.every(cert => cert.status !== "unknown" && cert.daysLeft !== null)
const filtered = useMemo(() => { const filtered = useMemo(() => {
@@ -665,6 +682,9 @@ function CertificatesContent() {
} }
> >
<div className="flex flex-col gap-5"> <div className="flex flex-col gap-5">
{!isLive && <p role="status" className="text-xs text-muted-foreground">Моковые данные</p>}
{urlError && <div role="alert" aria-label="Контекст сертификатов">{urlError} <Button variant="outline" size="sm" onClick={() => writeContext({ server: null, status: null, q: null }, true)}>Сбросить контекст</Button></div>}
{!urlError && selectedServerId !== ALL_SERVERS_ID && (isLive ? catalog.hasState && !snapshots[selectedServerId] : !mockServers.some(server => server.id === selectedServerId)) && <p role="alert">Выбранный сервер недоступен в каталоге сертификатов. Выберите другой сервер.</p>}
{isLive && backendStatus === false && ( {isLive && backendStatus === false && (
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 px-4 py-3 text-sm text-amber-700 dark:text-amber-300"> <div className="rounded-lg border border-amber-500/30 bg-amber-500/5 px-4 py-3 text-sm text-amber-700 dark:text-amber-300">
Backend недоступен — live-операции отключены. Backend недоступен — live-операции отключены.
@@ -734,9 +754,11 @@ function CertificatesContent() {
}} }}
countLabel={isLive && !catalog.hasState ? "Количество неизвестно" : `${filtered.length} строк в снимках`} countLabel={isLive && !catalog.hasState ? "Количество неизвестно" : `${filtered.length} строк в снимках`}
/> />
{isLive && scopedCerts.length === 0 && !scopeKnown ? <p role="status" className="p-5">{loading ? "Загрузка сертификатов…" : "Список сертификатов пока неизвестен"}</p> : <CertificatesDataGrid {urlError ? <p role="status" className="p-5">Исправьте контекст ссылки</p> : isLive && scopedCerts.length === 0 && !scopeKnown ? <p role="status" className="p-5">{loading ? "Загрузка сертификатов…" : "Список сертификатов пока неизвестен"}</p> : <CertificatesDataGrid
certificates={filtered} certificates={filtered}
serverMap={serverById} serverMap={serverById}
emptyTitle={search || statusFilter !== "all" ? "Нет совпадений" : "Нет сертификатов"}
emptyDescription={search || statusFilter !== "all" ? "Измените фильтр или поиск" : "Подтверждённый каталог пуст"}
isLoading={isLive && loading && displayCerts.length === 0} isLoading={isLive && loading && displayCerts.length === 0}
/>} />}
</DataPageCard> </DataPageCard>
@@ -68,9 +68,11 @@ interface CertificatesDataGridProps {
certificates: CertificateView[] certificates: CertificateView[]
serverMap: Map<string, Server> serverMap: Map<string, Server>
isLoading?: boolean isLoading?: boolean
emptyTitle?: string
emptyDescription?: string
} }
function CertificatesDataGrid({ certificates, serverMap, isLoading }: CertificatesDataGridProps) { function CertificatesDataGrid({ certificates, serverMap, isLoading, emptyTitle = "Нет сертификатов", emptyDescription = "Выпустите или импортируйте сертификат" }: CertificatesDataGridProps) {
const columns = useMemo<ColumnDef<DataGridFeatures, CertificateView>[]>( const columns = useMemo<ColumnDef<DataGridFeatures, CertificateView>[]>(
() => [ () => [
{ {
@@ -229,8 +231,8 @@ function CertificatesDataGrid({ certificates, serverMap, isLoading }: Certificat
return ( return (
<EmptyState <EmptyState
icon={<ShieldCheckIcon className="size-4" />} icon={<ShieldCheckIcon className="size-4" />}
title="Нет сертификатов" title={emptyTitle}
description="Выпустите или импортируйте сертификат" description={emptyDescription}
className="border-0 py-12" className="border-0 py-12"
/> />
) )
+1 -1
View File
@@ -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/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) | | Контейнеры | [Обзор](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А ещё не приняты.
+8 -1
View File
@@ -27,7 +27,7 @@
| 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 | | 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 |
| 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 | | 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 |
| 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 | | 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Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | | 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) ## Блок 11А.17.2 — state каталог на странице сертификатов (2026-10-01)
- Страница читает readCertificatesState и независимо строгий каталог серверов. Снимки сохраняются при общем/частичном отказе; подтверждённое empty очищает прежние строки. Отображаются время каталога и время последнего успешного чтения каждого устройства. - Страница читает readCertificatesState и независимо строгий каталог серверов. Снимки сохраняются при общем/частичном отказе; подтверждённое empty очищает прежние строки. Отображаются время каталога и время последнего успешного чтения каждого устройства.
+7
View File
@@ -213,3 +213,10 @@
- Refresh теперь повторяет строгий GET state, не подмешивает legacy DTO и не объявляет частичный результат полным успехом. Write-действия требуют права сертификатов, выпуск также требует доступного каталога серверов. Неподключённый импорт отключён вместо ложного сообщения об успехе. - Refresh теперь повторяет строгий GET state, не подмешивает legacy DTO и не объявляет частичный результат полным успехом. Write-действия требуют права сертификатов, выпуск также требует доступного каталога серверов. Неподключённый импорт отключён вместо ложного сообщения об успехе.
- 12 профильных HTTP E2E, TypeScript, ESLint и production frontend прошли. API контракт/сетевые правила не менялись. - 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 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. Настройки, выпуск и общая приёмка сертификатов пока не приняты.
+14
View File
@@ -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")
})
+16
View File
@@ -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<string, string | null>) {
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() : "")
}
@@ -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"}},
})
+1 -1
View File
@@ -2,7 +2,7 @@ import { defineConfig } from "@playwright/test"
export default defineConfig({ export default defineConfig({
testDir: "./tests/e2e", 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, fullyParallel: false,
workers: 1, workers: 1,
retries: 0, retries: 0,
+13
View File
@@ -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<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/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)
})
+36
View File
@@ -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")
})