From 594e60a1f43041bdec0a2df154127d24c47ac06f Mon Sep 17 00:00:00 2001 From: Denozordec Date: Thu, 1 Oct 2026 14:03:30 +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=D1=87=D0=B5=D1=80=D0=BD=D0=BE=D0=B2=D0=B8?= =?UTF-8?q?=D0=BA=D0=B8=20=D0=B8=20=D1=80=D0=B5=D0=B7=D1=83=D0=BB=D1=8C?= =?UTF-8?q?=D1=82=D0=B0=D1=82=20=D0=BD=D0=B0=D1=81=D1=82=D1=80=D0=BE=D0=B5?= =?UTF-8?q?=D0=BA=20ACME?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(main)/certificates/page.tsx | 162 +----------------- .../certificate-acme-settings.tsx | 121 +++++++++++++ docs/ux-ui-plan.md | 2 +- docs/ux-ui-progress.md | 10 +- docs/ux-ui-stage-11a.md | 8 + tests/e2e/certificates-acme.spec.ts | 61 +++++++ tests/e2e/certificates-context.spec.ts | 20 +++ 7 files changed, 223 insertions(+), 161 deletions(-) create mode 100644 components/certificates/certificate-acme-settings.tsx create mode 100644 tests/e2e/certificates-acme.spec.ts diff --git a/app/(main)/certificates/page.tsx b/app/(main)/certificates/page.tsx index b7ac57eb..92019bab 100644 --- a/app/(main)/certificates/page.tsx +++ b/app/(main)/certificates/page.tsx @@ -1,6 +1,6 @@ "use client" -import { Suspense, useCallback, useEffect, useMemo, useState } from "react" +import { Suspense, 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" @@ -13,6 +13,7 @@ import { OpsPanel } from "@/components/ops-panel" import { DataPageCard } from "@/components/data-page-card" import { DataPageToolbar } from "@/components/data-page-toolbar" import { CertificatesDataGrid } from "@/components/data-grids/certificates-data-grid" +import { CertificateAcmeSettings } from "@/components/certificates/certificate-acme-settings" import { CertificateRenewSettingsPanel } from "@/components/certificates/certificate-renew-settings" import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid" import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout" @@ -36,10 +37,7 @@ import type { CertificateView } from "@/lib/certificate-view" import { toFrontendServer } from "@/entities/server/model/mappers" import { createCertificateIssueJob, - getAcmeSettings, getCertificateIssueJob, - putAcmeSettings, - testAcmeSettings, } from "@/shared/api/certificates" import { toast } from "sonner" import { @@ -163,87 +161,6 @@ function CertPartKpi({ ) } -function CertPartAcmeSettings({ - acmeDirectoryUrl, - setAcmeDirectoryUrl, - acmeZoneId, - setAcmeZoneId, - acmeTokenDraft, - setAcmeTokenDraft, - acmeTokenConfigured, - acmeSaveBusy, - onTest, - onSave, -}: { - acmeDirectoryUrl: string - setAcmeDirectoryUrl: (v: string) => void - acmeZoneId: string - setAcmeZoneId: (v: string) => void - acmeTokenDraft: string - setAcmeTokenDraft: (v: string) => void - acmeTokenConfigured: boolean - acmeSaveBusy: boolean - onTest: () => void - onSave: () => void -}) { - return ( - -
event.preventDefault()} - > -
- - setAcmeDirectoryUrl(e.target.value)} - /> -
-
- - setAcmeZoneId(e.target.value)} - placeholder="Авто по домену" - /> -
-
- - setAcmeTokenDraft(e.target.value)} - placeholder={ - acmeTokenConfigured - ? "Токен сохранён — введите новый для замены" - : "API token с правом DNS" - } - /> -
-
-
- - -
-
- ) -} - function CertPartReference() { return ( { if (!prefsHydrated) return [] if (isLive) return Object.values(snapshots).flatMap(snapshot => snapshot.confirmed?.certificates ?? []) @@ -510,22 +419,6 @@ function CertificatesContent() { const loadLive = catalog.reload - const loadAcmeSettings = useCallback(async () => { - if (!liveReady || !canRead) return - try { - const s = await getAcmeSettings(backendUrl) - setAcmeDirectoryUrl(s.directoryUrl) - setAcmeZoneId(s.defaultZoneId ?? "") - setAcmeTokenConfigured(s.tokenConfigured) - } catch { - /* ignore */ - } - }, [backendUrl, liveReady, canRead]) - - useEffect(() => { - queueMicrotask(() => { void loadAcmeSettings() }) - }, [loadAcmeSettings]) - const expiring = useMemo( () => scopedCerts.filter((c) => c.status === "valid" && c.daysLeft !== null && c.daysLeft >= 0 && c.daysLeft <= 30), [scopedCerts], @@ -601,38 +494,6 @@ function CertificatesContent() { } } - async function handleSaveAcmeSettings() { - if (!liveReady) return - setAcmeSaveBusy(true) - try { - const saved = await putAcmeSettings(backendUrl, { - directoryUrl: acmeDirectoryUrl.trim(), - defaultZoneId: acmeZoneId.trim() || null, - cloudflareApiToken: acmeTokenDraft.trim() ? acmeTokenDraft.trim() : undefined, - }) - setAcmeTokenConfigured(saved.tokenConfigured) - setAcmeTokenDraft("") - toast.success("Настройки ACME сохранены") - } catch (e) { - toast.error(e instanceof Error ? e.message : "Не удалось сохранить настройки") - } finally { - setAcmeSaveBusy(false) - } - } - - async function handleTestAcme() { - if (!liveReady) return - try { - const res = await testAcmeSettings(backendUrl, { - cloudflareApiToken: acmeTokenDraft.trim() || undefined, - }) - if (res.ok) toast.success(res.message ?? "Cloudflare API доступен") - else toast.error(res.message ?? "Проверка не прошла") - } catch (e) { - toast.error(e instanceof Error ? e.message : "Проверка Cloudflare не удалась") - } - } - return ( <> - {liveReady && canWrite && ( - { - void handleTestAcme() - }} - onSave={() => { - void handleSaveAcmeSettings() - }} - /> - )} + (null) + const [directory, setDirectory] = useState("") + const [zone, setZone] = useState("") + const [token, setToken] = useState("") + const [loading, setLoading] = useState(liveReady && canRead) + const [busy, setBusy] = useState(false) + const [error, setError] = useState(null) + const [testResult, setTestResult] = useState(null) + const dirty = useRef(false) + const lock = useRef(false) + const alive = useRef(true) + const controller = useRef(null) + + const load = useCallback(async () => { + if (!liveReady || !canRead || lock.current || !alive.current) return + controller.current?.abort() + const abort = new AbortController() + controller.current = abort + setLoading(true) + try { + const saved = await getAcmeSettings(backendUrl, { signal: abort.signal }) + if (!alive.current || abort.signal.aborted) return + setConfirmed(saved) + if (!dirty.current) { setDirectory(saved.directoryUrl); setZone(saved.defaultZoneId ?? "") } + setError(null) + } catch (reason) { + if (alive.current && !abort.signal.aborted) setError(reason instanceof Error ? reason.message : "Не удалось прочитать ACME настройки") + } finally { + if (alive.current && !abort.signal.aborted) setLoading(false) + } + }, [backendUrl, liveReady, canRead]) + + useEffect(() => { + alive.current = true + const cancel = () => { alive.current = false; controller.current?.abort() } + return cancel + }, []) + useEffect(() => { queueMicrotask(() => { if (alive.current) void load() }) }, [load]) + + async function operate(action: "save" | "test") { + if (!liveReady || !canRead || !canWrite || !confirmed || loading || lock.current || !alive.current) return + const payload = { directoryUrl: directory.trim(), defaultZoneId: zone.trim() || null, cloudflareApiToken: token.trim() || undefined } + if (action === "save" && !putAcmeCloudflareSettingsSchema.safeParse(payload).success) { setError("Укажите корректный URL ACME directory."); return } + if (action === "test" && !token.trim() && !confirmed.tokenConfigured) { setError("Введите токен для проверки. Сохранённого токена нет."); return } + lock.current = true + setBusy(true) + setTestResult(null) + const abort = new AbortController() + controller.current = abort + try { + if (action === "save") { + const saved = await putAcmeSettings(backendUrl, payload, { signal: abort.signal }) + if (!alive.current || abort.signal.aborted) return + setConfirmed(saved) + if (payload.cloudflareApiToken && !saved.tokenConfigured) throw new Error("Сохранение токена не подтверждено") + setDirectory(saved.directoryUrl); setZone(saved.defaultZoneId ?? ""); setToken("") + dirty.current = false + setError(null) + toast.success("Настройки ACME сохранены") + } else { + const result = await testAcmeSettings(backendUrl, { cloudflareApiToken: token.trim() || undefined }, { signal: abort.signal }) + if (!alive.current || abort.signal.aborted) return + if (!result.ok) throw new Error(result.message ?? "Проверка Cloudflare не прошла") + setError(null) + setTestResult(result.message ?? "Cloudflare API доступен") + } + } catch (reason) { + if (alive.current && !abort.signal.aborted) setError((reason instanceof Error ? reason.message : "Операция не выполнена") + (action === "save" ? ". Результат сохранения не подтверждён; черновик сохранён в форме." : ". Проверка не сохраняет настройки.")) + } finally { + lock.current = false + if (alive.current && !abort.signal.aborted) setBusy(false) + } + } + + const disabled = !liveReady || !canRead || !canWrite || !confirmed || loading || busy + function edit(set: (value: string) => void, value: string) { dirty.current = true; setTestResult(null); set(value) } + return ( + + {error &&

{error}

} + {loading &&

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

} + {!confirmed &&

Настройки ACME пока неизвестны. Сохранение и проверка недоступны до успешного чтения.

} + {!liveReady &&

Нет подключения к API.

} + {!canRead &&

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

} + {!canWrite &&

Настройки ACME доступны только для чтения.

} +

{confirmed ? confirmed.tokenConfigured ? "Токен сохранён на backend." : "Сохранённого токена нет." : "Состояние токена неизвестно."}

+ {testResult &&

{testResult}. Настройки не изменены.

} + {liveReady && canRead && } +
event.preventDefault()}> +
+ + edit(setDirectory, event.target.value)} /> +
+
+ + edit(setZone, event.target.value)} placeholder="Авто по домену" /> +
+
+ + edit(setToken, event.target.value)} placeholder={confirmed?.tokenConfigured ? "Введите новый для замены; пустое поле сохранит прежний" : "API token с правом DNS"} /> +

Проверка использует введённый токен или сохранённый на backend. Сохранение выполняется отдельно.

+
+
+ {canWrite &&
+ + +
} +
+ ) +} diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index cc70ec16..0d7eaa62 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А.20 — достоверная форма ACME, затем checkpoint/polling выпуска. State API, reader, snapshot merge и nullable представление подключены в 11А.14–11А.17.3; таблица требований — [этап 11А](ux-ui-stage-11a.md). Сертификаты и весь 11А ещё не приняты. +Актуальная точка продолжения: 11А.21 — совместимость issue DTO, затем checkpoint/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 2c55a5dc..aa43f0c0 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А.19; пользователи/контейнеры приняты локально, сертификаты: state/URL/API/renew готовы; далее 11А.20 | +| 11А | Пользователи, контейнеры, сертификаты и резервные копии | В работе: 11А.1–11А.20; пользователи/контейнеры приняты локально, сертификаты: state/URL/API/renew/ACME готовы; далее 11А.21 | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | @@ -36,6 +36,14 @@ +## Блок 11А.20 — достоверная форма ACME (2026-10-01) + +- ACME форма выделена в собственный компонент с прежней OpsPanel/ReUI композицией. До подтверждённого GET настройки и состояние токена неизвестны; read-only читает фактические значения, сохранение/тест требуют write права. +- Dirty URL/zone/token сохраняются при фоновом чтении и отказе PUT. Токен очищается только после валидного подтверждения; tokenConfigured=false при отправленном токене не считается успехом. Пустое поле сохраняет прежний секрет на backend. +- Save/test имеют общий синхронный lock и блокируют поля на время операции; test не объявляется сохранением. Ошибка Cloudflare видна с причиной. При unmount запросы отменяются, старые ответы/toast не попадают в другой источник. +- 10 ACME E2E, 2 поздних PUT context E2E, 2 read-only E2E, TypeScript/ESLint и production frontend прошли. +- Следующий блок 11А.21: совместимость реального issue DTO (backend serverId сейчас числом, failed step не включён в контракт), затем сохранение job checkpoint и отмена polling без повторного POST. Выпуск/общая приёмка сертификатов, backups/11Б/12 остаются незавершёнными. + ## Блок 11А.19 — достоверные настройки автообновления (2026-10-01) - Renew форма начинает с unknown, а не enabled/default расписания. Ошибка чтения оставляет операции заблокированными до подтверждённого GET; есть явный повтор и отдельные read/write права. Read-only не выдаётся за недоступный backend. diff --git a/docs/ux-ui-stage-11a.md b/docs/ux-ui-stage-11a.md index edf40b8f..63f3f4bc 100644 --- a/docs/ux-ui-stage-11a.md +++ b/docs/ux-ui-stage-11a.md @@ -234,3 +234,11 @@ - Фоновый GET не стирает dirty черновик; запросы отменяются при unmount, поздний ответ не меняет другой источник и не показывает старый toast. Disabled MM не объявляется доказательством включённого RouterOS ACME. - 10 renew E2E, повторные 12 data E2E, 2 context E2E позднего PUT, TypeScript/ESLint и production frontend прошли. HTTP fixtures приведены к реальному renewBeforeDays контракту. - Следующий блок 11А.20: выделить достоверную форму ACME, неизвестное чтение, сохранение черновика/токена, общий lock save/test и отмена; затем issue checkpoint/polling и приёмка сертификатов. + +## Блок 11А.20 — достоверная форма ACME (2026-10-01) + +- ACME форма выделена в собственный компонент с прежней OpsPanel/ReUI композицией. До подтверждённого GET настройки и состояние токена неизвестны; read-only читает фактические значения, сохранение/тест требуют write права. +- Dirty URL/zone/token сохраняются при фоновом чтении и отказе PUT. Токен очищается только после валидного подтверждения; tokenConfigured=false при отправленном токене не считается успехом. Пустое поле сохраняет прежний секрет на backend. +- Save/test имеют общий синхронный lock и блокируют поля на время операции; test не объявляется сохранением. Ошибка Cloudflare видна с причиной. При unmount запросы отменяются, старые ответы/toast не попадают в другой источник. +- 10 ACME E2E, 2 поздних PUT context E2E, 2 read-only E2E, TypeScript/ESLint и production frontend прошли. +- Следующий блок 11А.21: совместимость реального issue DTO (backend serverId сейчас числом, failed step не включён в контракт), затем сохранение job checkpoint и отмена polling без повторного POST. Выпуск/общая приёмка сертификатов, backups/11Б/12 остаются незавершёнными. diff --git a/tests/e2e/certificates-acme.spec.ts b/tests/e2e/certificates-acme.spec.ts new file mode 100644 index 00000000..1cba7abd --- /dev/null +++ b/tests/e2e/certificates-acme.spec.ts @@ -0,0 +1,61 @@ +import { test, expect, type Page } from "@playwright/test" +import { certificateState, server } from "./fixtures/certificates" +const acme = { directoryUrl: "https://acme.example.test/directory", defaultZoneId: "old-zone", tokenConfigured: true } +const directory = (page: Page) => page.getByRole("textbox", { name: "ACME directory URL", exact: true }) +const token = (page: Page) => page.getByLabel("Cloudflare API token", { exact: true }) +const save = (page: Page) => page.getByRole("button", { name: "Сохранить настройки", exact: true }) +const check = (page: Page) => page.getByRole("button", { name: "Проверить Cloudflare", 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() : path === "/api/certificates/renew-settings" ? { enabled: false, intervalSec: 300, renewBeforeDays: 30 } : acme }) + }) +}) +for (const malformed of [false, true]) test("ACME: неизвестные настройки после отказа " + malformed, async ({ page }) => { + let fail = true; await page.route("**/api/certificates/acme-settings", r => r.fulfill(fail ? malformed ? { json: {} } : { status: 503, json: { error: "ACME чтение отказало" } } : { json: acme })) + await page.goto("/certificates"); await expect(page.getByRole("alert", { name: "Настройки ACME", exact: true })).toBeVisible() + await expect(save(page)).toBeDisabled(); await expect(check(page)).toBeDisabled(); await expect(directory(page)).toHaveValue("") + await expect(page.getByText("Состояние токена неизвестно.", { exact: true })).toBeVisible() + fail = false; await page.getByRole("button", { name: "Перечитать настройки ACME", exact: true }).click(); await expect(directory(page)).toHaveValue(acme.directoryUrl) +}) +for (const kind of ["API", "malformed", "token-unconfirmed"]) test("ACME: отказ сохранения " + kind + " оставляет черновик и токен", async ({ page }) => { + await page.route("**/api/certificates/acme-settings", r => r.request().method() === "GET" ? r.fulfill({ json: acme }) : r.fulfill(kind === "API" ? { status: 503, json: { error: "Сохранение ACME отказало" } } : { json: kind === "malformed" ? {} : { ...acme, tokenConfigured: false } })) + await page.goto("/certificates"); await expect(directory(page)).toHaveValue(acme.directoryUrl) + await directory(page).fill("https://new.example.test/directory"); await token(page).fill("test-draft-token"); await save(page).click() + await expect(page.getByRole("alert", { name: "Настройки ACME", exact: true })).toContainText("Результат сохранения не подтверждён") + await expect(token(page)).toHaveValue("test-draft-token"); await expect(directory(page)).toHaveValue("https://new.example.test/directory") + await expect(page.getByText("Настройки ACME сохранены", { exact: true })).toHaveCount(0) +}) +test("ACME: подтверждённое сохранение очищает только принятый токен", async ({ page }) => { + let body: Record = {} + await page.route("**/api/certificates/acme-settings", r => { if (r.request().method() === "GET") return r.fulfill({ json: acme }); body = r.request().postDataJSON(); return r.fulfill({ json: { ...acme, directoryUrl: body.directoryUrl } }) }) + await page.goto("/certificates"); await expect(directory(page)).toHaveValue(acme.directoryUrl); await token(page).fill("test-draft-token"); await save(page).click() + await expect(page.getByText("Настройки ACME сохранены", { exact: true })).toBeVisible(); await expect(token(page)).toHaveValue("") + expect(body.cloudflareApiToken).toBe("test-draft-token") +}) +test("ACME: Cloudflare отказ не меняет настройки или токен", async ({ page }) => { + await page.route("**/api/certificates/acme-settings/test", r => r.fulfill({ status: 400, json: { ok: false, message: "Cloudflare denied" } })) + await page.goto("/certificates"); await expect(directory(page)).toHaveValue(acme.directoryUrl); await token(page).fill("test-draft-token"); await check(page).click() + await expect(page.getByRole("alert", { name: "Настройки ACME", exact: true })).toContainText("Cloudflare denied") + await expect(token(page)).toHaveValue("test-draft-token"); await expect(page.getByText("Настройки ACME сохранены", { exact: true })).toHaveCount(0) +}) +test("ACME: save и test разделяют single flight", async ({ page }) => { + let writes = 0, tests = 0; let release!: () => void; const gate = new Promise(r => release = r) + await page.route("**/api/certificates/acme-settings", r => { if (r.request().method() === "PUT") writes++; return r.fulfill({ json: acme }) }) + await page.route("**/api/certificates/acme-settings/test", async r => { tests++; await gate; await r.fulfill({ json: { ok: true, message: "Cloudflare доступен" } }) }) + await page.goto("/certificates"); await expect(directory(page)).toHaveValue(acme.directoryUrl); await check(page).click() + await expect(save(page)).toBeDisabled(); await expect(check(page)).toBeDisabled(); await expect(token(page)).toBeDisabled(); expect(writes).toBe(0); expect(tests).toBe(1) + release(); await expect(page.getByRole("status").filter({ hasText: "Cloudflare доступен" })).toBeVisible() +}) +test("ACME: background read не стирает черновик", async ({ page }) => { + let second = false; await page.route("**/api/certificates/acme-settings", r => r.fulfill({ json: { ...acme, directoryUrl: second ? "https://server-new.example.test/directory" : acme.directoryUrl } })) + await page.goto("/certificates"); await expect(directory(page)).toHaveValue(acme.directoryUrl); await directory(page).fill("https://draft.example.test/directory"); await token(page).fill("test-draft-token") + second = true; await page.getByRole("button", { name: "Перечитать настройки ACME", exact: true }).click(); await expect(directory(page)).toBeEnabled() + await expect(directory(page)).toHaveValue("https://draft.example.test/directory"); await expect(token(page)).toHaveValue("test-draft-token") +}) +test("ACME: неверный URL не отправляет mutation", async ({ page }) => { + let writes = 0; await page.route("**/api/certificates/acme-settings", r => { if (r.request().method() === "PUT") writes++; return r.fulfill({ json: acme }) }) + await page.goto("/certificates"); await expect(directory(page)).toHaveValue(acme.directoryUrl); await directory(page).fill("bad"); await save(page).click() + await expect(page.getByRole("alert", { name: "Настройки ACME", exact: true })).toContainText("корректный URL"); expect(writes).toBe(0) +}) diff --git a/tests/e2e/certificates-context.spec.ts b/tests/e2e/certificates-context.spec.ts index 48e60cfb..02df3654 100644 --- a/tests/e2e/certificates-context.spec.ts +++ b/tests/e2e/certificates-context.spec.ts @@ -31,3 +31,23 @@ for (const fails of [false, true]) test("Сертификаты: позднее await expect(toggle).toBeChecked(); await expect(page.getByText("СТАРОЕ СОХРАНЕНИЕ ОТКАЗ", { exact: true })).toHaveCount(0) await expect(page.getByText("Автообновление выключено", { exact: true })).toHaveCount(0) }) + +for (const fails of [false, true]) test("Сертификаты: позднее сохранение ACME/" + 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 path = new URL(r.request().url()).pathname + return r.fulfill({ headers, json: path === "/api/auth/config" ? { required: false } : path === "/api/servers" ? [server] : path === "/api/certificates/state" ? state() : path === "/api/certificates/renew-settings" ? { enabled: true, intervalSec: 21600, renewBeforeDays: 30 } : { directoryUrl: "https://acme.example.test/directory", tokenConfigured: false } }) + }) + await page.route("**/api/certificates/acme-settings", async r => { + if (r.request().method() !== "PUT") return r.fallback() + started = true; await gate + try { await r.fulfill(fails ? { headers, status: 503, json: { error: "СТАРОЕ СОХРАНЕНИЕ ACME ОТКАЗ" } } : { headers, json: { directoryUrl: "https://old.example.test/directory", tokenConfigured: true } }) } catch {} finally { completed = true } + }) + await page.goto("/certificates"); const directory = page.getByRole("textbox", { name: "ACME directory URL", exact: true }) + await expect(directory).toHaveValue("https://acme.example.test/directory"); await directory.fill("https://old.example.test/directory"); await page.getByRole("button", { name: "Сохранить настройки", exact: true }).click(); await expect.poll(() => started).toBe(true) + await page.goto("/settings"); const input = page.getByPlaceholder("http://localhost:8000"); await input.fill("http://localhost:8101"); await input.press("Enter") + await page.goto("/certificates"); await expect(directory).toHaveValue("https://acme.example.test/directory"); release(); await expect.poll(() => completed).toBe(true) + await expect(directory).toHaveValue("https://acme.example.test/directory"); await expect(page.getByText("СТАРОЕ СОХРАНЕНИЕ ACME ОТКАЗ", { exact: true })).toHaveCount(0) + await expect(page.getByText("Настройки ACME сохранены", { exact: true })).toHaveCount(0) +})