diff --git a/app/(main)/certificates/page.tsx b/app/(main)/certificates/page.tsx index 15398e0e..a579acdc 100644 --- a/app/(main)/certificates/page.tsx +++ b/app/(main)/certificates/page.tsx @@ -5,7 +5,7 @@ import { PageHeader } from "@/components/page-header" import { FormField, FormToggle, SectionTitle } from "@/components/form-kit" import { FileImportDialog } from "@/components/file-import-dialog" import { routerCertificates, servers as mockServers } from "@/lib/data" -import type { CertStatus, Server } from "@/lib/data" +import type { Server } from "@/lib/data" import type { CertificateDto } from "@mmapp/contracts/certificates" import { OpsPanel } from "@/components/ops-panel" import { DataPageCard } from "@/components/data-page-card" @@ -28,15 +28,15 @@ import { } from "@/components/ui/sheet" import { cn } from "@/lib/utils" import { useDataSource } from "@/lib/data-source" -import { listServers } from "@/shared/api/servers" +import { can } from "@/lib/auth" +import { useCertificateCatalog } from "@/lib/use-certificate-catalog" +import type { CertificateView } from "@/lib/certificate-view" import { toFrontendServer } from "@/entities/server/model/mappers" import { createCertificateIssueJob, getAcmeSettings, getCertificateIssueJob, - listCertificates, putAcmeSettings, - refreshCertificates, testAcmeSettings, } from "@/shared/api/certificates" import { toast } from "sonner" @@ -79,7 +79,7 @@ function mockToDto(cert: (typeof routerCertificates)[number]): CertificateDto { } } -function CertPartAlertExpired({ expired }: { expired: CertificateDto[] }) { +function CertPartAlertExpired({ expired }: { expired: CertificateView[] }) { return (
@@ -95,7 +95,7 @@ function CertPartAlertExpired({ expired }: { expired: CertificateDto[] }) { ) } -function CertPartAlertExpiring({ expiring }: { expiring: CertificateDto[] }) { +function CertPartAlertExpiring({ expiring }: { expiring: CertificateView[] }) { return (
@@ -115,10 +115,12 @@ function CertPartKpi({ displayCerts, expiring, expired, + complete, }: { - displayCerts: CertificateDto[] - expiring: CertificateDto[] - expired: CertificateDto[] + displayCerts: CertificateView[] + expiring: CertificateView[] + expired: CertificateView[] + complete: boolean }) { return ( , iconClassName: "text-muted-foreground", }, { id: "valid", label: "Действующих", - value: displayCerts.filter((c) => c.status === "valid").length, + value: complete ? displayCerts.filter((c) => c.status === "valid").length : "—", icon: , iconClassName: "text-success", }, { id: "expiring", label: "Истекают", - value: expiring.length, + value: complete ? expiring.length : "—", icon: , iconClassName: expiring.length > 0 ? "text-warning" : "text-muted-foreground", variant: expiring.length > 0 ? "warning" : "default", @@ -149,7 +151,7 @@ function CertPartKpi({ { id: "expired", label: "Истёкших", - value: expired.length, + value: complete ? expired.length : "—", icon: , iconClassName: expired.length > 0 ? "text-destructive" : "text-muted-foreground", variant: expired.length > 0 ? "destructive" : "default", @@ -416,16 +418,24 @@ function CertPartIssueForm({ } export default function CertificatesPage() { + const { mode, backendUrl, prefsHydrated } = useDataSource() + if (!prefsHydrated) return

Загрузка настроек источника…

+ return +} + +function CertificatesContent() { const { mode, backendUrl, prefsHydrated, backendStatus } = useDataSource() const isLive = prefsHydrated && mode === "live" const liveReady = isLive && backendStatus === true const [search, setSearch] = useState("") - const [statusFilter, setStatusFilter] = useState("all") - const [certificates, setCertificates] = useState([]) - const [loadState, setLoadState] = useState<"idle" | "loading" | "error">("idle") - const [loadError, setLoadError] = useState(null) - const [serverList, setServerList] = useState([]) + 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) @@ -450,9 +460,9 @@ export default function CertificatesPage() { const displayCerts = useMemo(() => { if (!prefsHydrated) return [] - if (isLive) return certificates + if (isLive) return Object.values(snapshots).flatMap(snapshot => snapshot.confirmed?.certificates ?? []) return routerCertificates.map(mockToDto) - }, [prefsHydrated, isLive, certificates]) + }, [prefsHydrated, isLive, snapshots]) const displayServers = isLive ? serverList : mockServers @@ -468,12 +478,12 @@ export default function CertificatesPage() { host: s.host, site: s.site, country: s.country, - status: s.status, + status: isLive ? catalog.servers.find(server => String(server.id) === s.id)?.status ?? undefined : s.status, type: s.type, enabled: s.enabled, - meta: String(displayCerts.filter((c) => c.serverId === s.id).length), + meta: isLive && !snapshots[s.id]?.confirmed ? "—" : String(displayCerts.filter((c) => c.serverId === s.id).length), })) - ), [displayServers, displayCerts]) + ), [displayServers, displayCerts, isLive, catalog.servers, snapshots]) const serverById = useMemo(() => { const map = new Map() @@ -481,37 +491,10 @@ export default function CertificatesPage() { return map }, [isLive, serverList]) - const loadLive = useCallback( - async (silent = false) => { - if (!isLive) return - if (!silent) setLoadState("loading") - setLoadError(null) - try { - const [certRes, serversRes] = await Promise.all([ - listCertificates(backendUrl), - listServers(backendUrl), - ]) - setCertificates(certRes.certificates) - setServerList(serversRes.map(toFrontendServer)) - if (certRes.failures.length > 0 && certRes.certificates.length === 0) { - setLoadError( - certRes.failures.map((f) => `${f.serverName ?? f.serverId}: ${f.error}`).join("; "), - ) - } else if (certRes.failures.length > 0) { - toast.warning(`Часть серверов недоступна: ${certRes.failures.length}`) - } - setLoadState("idle") - } catch (e) { - setCertificates([]) - setLoadError(e instanceof Error ? e.message : "Не удалось загрузить сертификаты") - setLoadState("error") - } - }, - [backendUrl, isLive], - ) + const loadLive = catalog.reload const loadAcmeSettings = useCallback(async () => { - if (!liveReady) return + if (!liveReady || !canRead) return try { const s = await getAcmeSettings(backendUrl) setAcmeDirectoryUrl(s.directoryUrl) @@ -520,30 +503,22 @@ export default function CertificatesPage() { } catch { /* ignore */ } - }, [backendUrl, liveReady]) + }, [backendUrl, liveReady, canRead]) useEffect(() => { - if (!isLive) { - queueMicrotask(() => { - setCertificates([]) - setServerList([]) - setLoadState("idle") - setLoadError(null) - }) - return - } - queueMicrotask(() => { - void loadLive() - void loadAcmeSettings() - }) - }, [isLive, loadLive, loadAcmeSettings]) + queueMicrotask(() => { void loadAcmeSettings() }) + }, [loadAcmeSettings]) const expiring = useMemo( - () => scopedCerts.filter((c) => c.status === "valid" && c.daysLeft >= 0 && c.daysLeft <= 30), + () => scopedCerts.filter((c) => c.status === "valid" && c.daysLeft !== null && c.daysLeft >= 0 && c.daysLeft <= 30), [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 scopeKnown = !isLive || 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(() => { return scopedCerts.filter((c) => { if (statusFilter !== "all" && c.status !== statusFilter) return false @@ -559,14 +534,7 @@ export default function CertificatesPage() { }, [scopedCerts, search, statusFilter]) async function handleRefresh() { - if (!liveReady) return - try { - const res = await refreshCertificates(backendUrl) - setCertificates(res.certificates) - toast.success("Список сертификатов обновлён") - } catch (e) { - toast.error(e instanceof Error ? e.message : "Ошибка обновления") - } + await loadLive() } async function pollIssueJob(jobId: string) { @@ -576,7 +544,7 @@ export default function CertificatesPage() { if (job.status === "done") { toast.success("Сертификат выпущен и импортирован на роутер") setIssueOpen(false) - await loadLive(true) + await loadLive() return } if (job.status === "failed") { @@ -656,7 +624,8 @@ export default function CertificatesPage() { onSelect={setSelectedServerId} showAll allCount={displayServers.length} - loading={isLive && loadState === "loading" && displayServers.length === 0} + preserveSelection + loading={isLive && loading && displayServers.length === 0} header={ { void handleRefresh() }} > - + Обновить -
)} {loadError && isLive && ( -
- {loadError} +
+ {catalog.hasState ? "Последние данные сохранены. " : "Сертификаты пока неизвестны. "}{loadError}
)} + {isLive && serverError &&

{catalog.hasServers ? "Последние серверы сохранены. " : "Каталог серверов пока неизвестен. "}{serverError}

} + {isLive && !canRead &&

Нет права mm:certificates:read.

} + {isLive && !canReadServers &&

Нет права mm:servers:read. Выбор устройства для выпуска недоступен.

} + {isLive && catalog.collectedAt &&

Последнее чтение каталога: {new Date(catalog.collectedAt).toLocaleString("ru-RU")}

} + {isLive && Object.values(snapshots).filter(snapshot => selectedServerId === ALL_SERVERS_ID || snapshot.latest.serverId === selectedServerId).map(snapshot =>
+ {snapshot.latest.error &&

{snapshot.latest.serverName}: {snapshot.latest.error}. {snapshot.confirmed ? "Последние сертификаты сохранены." : "Сертификаты пока неизвестны."}

} +

{snapshot.latest.serverName}: {snapshot.confirmed ? "Подтверждённые данные от " + new Date(snapshot.confirmed.collectedAt).toLocaleString("ru-RU") : "Успешного чтения нет"}

+
)} + {(expiring.length > 0 || expired.length > 0) && (
{expired.length > 0 && } @@ -714,11 +692,12 @@ export default function CertificatesPage() {
)} - + {!classificationsKnown &&

Сводка неполная: часть устройств, статусов или сроков неизвестна. Таблица показывает доступные снимки.

} + - + - {liveReady && ( + {liveReady && canWrite && ( - {loading ? "Загрузка сертификатов…" : "Список сертификатов пока неизвестен"}

: + isLoading={isLive && loading && displayCerts.length === 0} + />} diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index cbcccb3b..ca599888 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 — подключение observed сертификатов в страницу/таблицу/детали. API и snapshot merge подготовлены в 11А.14–11А.16; таблица требований — [этап 11А](ux-ui-stage-11a.md). Сертификаты и весь 11А ещё не приняты. +Актуальная точка продолжения: 11А.17.3 — URL-контекст и поздние ответы сертификатов. State API, reader, snapshot merge и nullable представление подключены в 11А.14–11А.17.2; таблица требований — [этап 11А](ux-ui-stage-11a.md). Сертификаты и весь 11А ещё не приняты. diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index 5a42f265..95bb4bac 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А.16; пользователи/контейнеры приняты локально, сертификаты: API/reader готовы; далее 11А.17 | +| 11А | Пользователи, контейнеры, сертификаты и резервные копии | В работе: 11А.1–11А.17.2; пользователи/контейнеры приняты локально, сертификаты: state каталог подключён; далее 11А.17.3 | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | @@ -36,6 +36,15 @@ +## Блок 11А.17.2 — state каталог на странице сертификатов (2026-10-01) + +- Страница читает readCertificatesState и независимо строгий каталог серверов. Снимки сохраняются при общем/частичном отказе; подтверждённое empty очищает прежние строки. Отображаются время каталога и время последнего успешного чтения каждого устройства. +- useCertificateCatalog отменяет чтения при unmount/смене источника, использует revision и общий срок 30s. Результат одного источника не ждёт другой; источники разделены key mode/backend после prefsHydrated. +- Неизвестная/частичная сводка показывает — с объяснением, фильтр неизвестных статусов доступен. Выбор сервера сохраняется при отказе каталога; неизвестный статус сервера не превращается в offline на rail. +- 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.1 — nullable представление сертификатов (2026-10-01) - CertificatesDataGrid и раскрытые детали принимают CertificateView, совместимый с legacy/mock DTO и новым CertificateObserved. Добавлен неизвестный статус; null срок, размер ключа, дата и доверие отображаются явно, без 0/false. Известные false и 0 дней сохранены. diff --git a/docs/ux-ui-stage-11a.md b/docs/ux-ui-stage-11a.md index 6d3a45e6..442baf63 100644 --- a/docs/ux-ui-stage-11a.md +++ b/docs/ux-ui-stage-11a.md @@ -204,3 +204,12 @@ [ACME/экспорт: последующие блоки] Проверенные заготовки: shared/api/certificates.ts (readCertificatesState), lib/certificate-snapshots.ts, новый contracts CertificateObserved. Legacy API/renew/issue ещё используются, их миграцию не считать завершённой. + +## Блок 11А.17.2 — state каталог на странице сертификатов (2026-10-01) + +- Страница читает readCertificatesState и независимо строгий каталог серверов. Снимки сохраняются при общем/частичном отказе; подтверждённое empty очищает прежние строки. Отображаются время каталога и время последнего успешного чтения каждого устройства. +- useCertificateCatalog отменяет чтения при unmount/смене источника, использует revision и общий срок 30s. Результат одного источника не ждёт другой; источники разделены key mode/backend после prefsHydrated. +- Неизвестная/частичная сводка показывает — с объяснением, фильтр неизвестных статусов доступен. Выбор сервера сохраняется при отказе каталога; неизвестный статус сервера не превращается в offline на rail. +- 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 проверки остаются внешними. diff --git a/lib/use-certificate-catalog.ts b/lib/use-certificate-catalog.ts new file mode 100644 index 00000000..a72adb4f --- /dev/null +++ b/lib/use-certificate-catalog.ts @@ -0,0 +1,61 @@ +"use client" + +import { useCallback, useEffect, useRef, useState } from "react" +import { serverListSchema, type ServerRead } from "@mmapp/contracts/servers" +import { readCertificatesState } from "@/shared/api/certificates" +import { requestJson } from "@/shared/api/http-client" +import { mergeCertificateSnapshots, type CertificateSnapshots } from "@/lib/certificate-snapshots" + +export function useCertificateCatalog(backendUrl: string, live: boolean, canRead: boolean, canReadServers: boolean) { + const [snapshots, setSnapshots] = useState({}) + const [servers, setServers] = useState([]) + const [hasState, setHasState] = useState(false) + const [hasServers, setHasServers] = useState(false) + const [loading, setLoading] = useState(live) + const [error, setError] = useState(null) + const [serverError, setServerError] = useState(null) + const [collectedAt, setCollectedAt] = useState(null) + const controller = useRef(null) + const revision = useRef(0) + const alive = useRef(true) + + const reload = useCallback(async () => { + if (!live || !alive.current) return + controller.current?.abort() + const current = ++revision.current + const abort = new AbortController() + controller.current = abort + const signal = AbortSignal.any([abort.signal, AbortSignal.timeout(30_000)]) + const active = () => alive.current && !abort.signal.aborted && revision.current === current + setLoading(true) + await Promise.allSettled([ + canRead ? readCertificatesState(backendUrl, { signal }).then(result => { + if (!active()) return + setSnapshots(previous => mergeCertificateSnapshots(previous, result)) + setHasState(true) + setCollectedAt(result.collectedAt) + setError(null) + }).catch(reason => { + if (active()) setError(reason instanceof Error ? reason.message : "Ошибка каталога сертификатов") + }) : Promise.resolve(), + canReadServers ? requestJson(backendUrl, "/api/servers", { signal }).then(payload => { + const result = serverListSchema.parse(payload) + if (!active()) return + setServers(result) + setHasServers(true) + setServerError(null) + }).catch(reason => { + if (active()) setServerError(reason instanceof Error ? reason.message : "Ошибка каталога серверов") + }) : Promise.resolve(), + ]) + if (active()) setLoading(false) + }, [backendUrl, live, canRead, canReadServers]) + + useEffect(() => { + alive.current = true + queueMicrotask(() => { if (alive.current) void reload() }) + return () => { alive.current = false; controller.current?.abort() } + }, [reload]) + + return { snapshots, servers, hasState, hasServers, loading, error, serverError, collectedAt, reload } +} diff --git a/tests/e2e/certificates-data.spec.ts b/tests/e2e/certificates-data.spec.ts new file mode 100644 index 00000000..aad8ee55 --- /dev/null +++ b/tests/e2e/certificates-data.spec.ts @@ -0,0 +1,85 @@ +import { test, expect, type Page } from "@playwright/test" +import { certificate, certificateState, server } from "./fixtures/certificates" + +const refresh = (page: Page) => page.getByRole("button", { name: "Обновить", exact: true }) +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 kind of ["API", "malformed", "duplicate", "foreign"] as const) test("Сертификаты: первоначальный отказ " + kind + " и восстановление", async ({ page }) => { + let fail = true + await page.route("**/api/certificates/state", r => r.fulfill(fail ? kind === "API" ? { status: 503, json: { error: "Отказ чтения" } } : { json: kind === "malformed" ? {} : certificateState(kind === "duplicate" ? [certificate, certificate] : [{ ...certificate, serverId: "2" }]) } : { json: certificateState() })) + await page.goto("/certificates") + await expect(page.getByRole("alert", { name: "Каталог сертификатов", exact: true })).toContainText("Сертификаты пока неизвестны") + await expect(page.getByText("Нет сертификатов", { exact: true })).toHaveCount(0) + fail = false; await refresh(page).click() + await expect(page.getByText(certificate.name, { exact: true })).toBeVisible() +}) +test("Сертификаты: nullable поля и неизвестная классификация", async ({ page }) => { + await page.goto("/certificates") + await expect(page.getByText(certificate.name, { exact: true })).toBeVisible() + await expect(page.getByText("Срок неизвестен", { exact: true })).toBeVisible() + await expect(page.getByText("Неизвестен", { exact: true })).toBeVisible() + await page.getByText(certificate.name, { exact: true }).click() + await expect(page.getByText("Неизвестно", { exact: true })).toBeVisible() + await expect(page.getByText("0 бит", { exact: true })).toHaveCount(0) + await expect(page.getByRole("status").filter({ hasText: "Сводка неполная" })).toBeVisible() + await expect(page.getByRole("button", { name: "Импорт", exact: true })).toBeDisabled() +}) +test("Сертификаты: ошибка сервера не скрывает строки", async ({ page }) => { + await page.route("**/api/servers", r => r.fulfill({ status: 503, json: { error: "Серверы отказали" } })) + await page.goto("/certificates") + await expect(page.getByText(certificate.name, { exact: true })).toBeVisible() + await expect(page.getByRole("alert", { name: "Серверы сертификатов" })).toContainText("Каталог серверов пока неизвестен") + await expect(page.getByRole("button", { name: "Выпустить сертификат", exact: true })).toBeDisabled() +}) +test("Сертификаты: общий фоновый отказ сохраняет данные", async ({ page }) => { + let fail = false + await page.route("**/api/certificates/state", r => r.fulfill(fail ? { status: 503, json: { error: "Фоновый отказ" } } : { json: certificateState() })) + await page.goto("/certificates"); await expect(page.getByText(certificate.name, { exact: true })).toBeVisible() + fail = true; await refresh(page).click() + await expect(page.getByRole("alert", { name: "Каталог сертификатов", exact: true })).toContainText("Последние данные сохранены") + await expect(page.getByText(certificate.name, { exact: true })).toBeVisible() +}) +test("Сертификаты: частичный отказ и подтверждённый пустой снимок", async ({ page }) => { + let phase = 0 + await page.route("**/api/certificates/state", r => r.fulfill({ json: phase === 0 ? certificateState() : certificateState([], { error: phase === 1 ? "Устройство недоступно" : null }) })) + await page.goto("/certificates"); await expect(page.getByText(certificate.name, { exact: true })).toBeVisible() + phase = 1; await refresh(page).click() + await expect(page.getByRole("alert", { name: "Сертификаты сервера 1", exact: true })).toContainText("Последние сертификаты сохранены") + await expect(page.getByText(certificate.name, { exact: true })).toBeVisible() + phase = 2; await refresh(page).click() + await expect(page.getByText("Нет сертификатов", { exact: true })).toBeVisible() + await expect(page.getByText(certificate.name, { exact: true })).toHaveCount(0) +}) +test("Сертификаты: медленные серверы не задерживают строки", async ({ page }) => { + let release!: () => void; const gate = new Promise(r => release = r) + await page.route("**/api/servers", async r => { await gate; await r.fulfill({ json: [server] }).catch(() => {}) }) + await page.goto("/certificates"); await expect(page.getByText(certificate.name, { exact: true })).toBeVisible() + release(); await expect(refresh(page)).toBeEnabled() +}) +test("Сертификаты: до ответа нет ложного пустого каталога", async ({ page }) => { + let release!: () => void; const gate = new Promise(r => release = r) + await page.route("**/api/certificates/state", async r => { await gate; await r.fulfill({ json: certificateState() }).catch(() => {}) }) + await page.goto("/certificates"); await expect(page.getByRole("status").filter({ hasText: "Загрузка сертификатов…" })).toBeVisible() + await expect(page.getByText("Нет сертификатов", { exact: true })).toHaveCount(0) + release(); await expect(page.getByText(certificate.name, { exact: true })).toBeVisible() +}) + +for (const serverRead of [false, true]) test("Сертификаты: read-only права и server read " + serverRead, async ({ page }) => { + await page.addInitScript(({ serverRead }) => localStorage.setItem("mmapp_token", "header." + btoa(JSON.stringify({ sub: "test", apps: ["mm"], permissions: ["mm:certificates:read", ...(serverRead ? ["mm:servers:read"] : [])], exp: Math.floor(Date.now() / 1000) + 3600 })) + ".signature"), { serverRead }) + await page.route("**/api/auth/config", r => r.fulfill({ json: { required: true } })) + let serverRequests = 0 + await page.route("**/api/servers", r => { serverRequests++; return r.fulfill({ json: [server] }) }) + await page.goto("/certificates") + await expect(page.getByText(certificate.name, { exact: true })).toBeVisible() + await expect(page.getByRole("button", { name: "Выпустить сертификат", exact: true })).toBeDisabled() + await expect(page.getByRole("button", { name: "Сохранить настройки", exact: true })).toHaveCount(0) + if (!serverRead) { + await expect(page.getByRole("status").filter({ hasText: "mm:servers:read" })).toBeVisible() + expect(serverRequests).toBe(0) + } +}) diff --git a/tests/e2e/fixtures/certificates.ts b/tests/e2e/fixtures/certificates.ts new file mode 100644 index 00000000..523d463b --- /dev/null +++ b/tests/e2e/fixtures/certificates.ts @@ -0,0 +1,5 @@ +export { server } from "./users" +export const certificate = { id: "1:*A", rosId: "*A", name: "certificate-live", serverId: "1", commonName: "test.example", sans: [], issuedBy: "—", validFrom: null, validUntil: null, rawValidFrom: null, rawValidUntil: null, daysLeft: null, keySize: null, trusted: null, status: "unknown", usage: [] } +export function certificateState(certificates: unknown[] = [certificate], overrides: Record = {}) { + return { collectedAt: "2026-10-01T00:00:00Z", servers: [{ serverId: "1", serverName: "Роутер", collectedAt: "2026-10-01T00:00:00Z", certificates, error: null, ...overrides }] } +}