fix(ui): сохранять контекст и источник сертификатов
This commit is contained in:
1 parent
b184922f61
commit
adb6589816
11 files changed
+138
-13
No files matched your search
@@ -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
@@ -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А ещё не приняты.
|
||||||
@@ -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 очищает прежние строки. Отображаются время каталога и время последнего успешного чтения каждого устройства.
|
||||||
|
|||||||
@@ -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. Настройки, выпуск и общая приёмка сертификатов пока не приняты.
|
||||||
@@ -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")
|
||||||
|
})
|
||||||
@@ -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"}},
|
||||||
|
})
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
@@ -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")
|
||||||
|
})
|
||||||
Reference in new issue
Block a user