From 1c407b77de072d0f41ab85e5e287db8d58a756cc Mon Sep 17 00:00:00 2001 From: Denozordec Date: Thu, 1 Oct 2026 13:56:42 +0700 Subject: [PATCH] =?UTF-8?q?fix(ui):=20=D1=81=D0=BE=D1=85=D1=80=D0=B0=D0=BD?= =?UTF-8?q?=D1=8F=D1=82=D1=8C=20=D0=BF=D0=BE=D0=B4=D1=82=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=D0=B6=D0=B4=D1=91=D0=BD=D0=BD=D1=8B=D0=B5=20=D0=BD=D0=B0=D1=81?= =?UTF-8?q?=D1=82=D1=80=D0=BE=D0=B9=D0=BA=D0=B8=20=D0=B0=D0=B2=D1=82=D0=BE?= =?UTF-8?q?=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(main)/certificates/page.tsx | 2 +- .../certificate-renew-settings.tsx | 151 +++++++++++------- docs/ux-ui-plan.md | 2 +- docs/ux-ui-progress.md | 10 +- docs/ux-ui-stage-11a.md | 8 + tests/e2e/certificates-context.spec.ts | 22 ++- tests/e2e/certificates-data.spec.ts | 2 +- tests/e2e/certificates-renew.spec.ts | 50 ++++++ tests/e2e/certificates-url.spec.ts | 2 +- 9 files changed, 182 insertions(+), 67 deletions(-) create mode 100644 tests/e2e/certificates-renew.spec.ts diff --git a/app/(main)/certificates/page.tsx b/app/(main)/certificates/page.tsx index ecc63236..b7ac57eb 100644 --- a/app/(main)/certificates/page.tsx +++ b/app/(main)/certificates/page.tsx @@ -715,7 +715,7 @@ function CertificatesContent() { {!classificationsKnown &&

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

} - + {liveReady && canWrite && ( (null) - const [lastError, setLastError] = useState(null) - const [loaded, setLoaded] = useState(false) - const [toggleBusy, setToggleBusy] = useState(false) - const [saveBusy, setSaveBusy] = useState(false) + const [confirmed, setConfirmed] = useState(null) + const enabled = confirmed?.enabled ?? false + const [intervalDraft, setIntervalDraft] = useState("") + const [daysDraft, setDaysDraft] = useState("") + const [loading, setLoading] = useState(liveReady && canRead) + const [busy, setBusy] = useState(false) + const [error, setError] = useState(null) + const alive = useRef(true) + const controller = useRef(null) + const writeLock = useRef(false) + const dirty = useRef(false) const load = useCallback(async () => { - if (!liveReady) return + if (!liveReady || !canRead || writeLock.current || !alive.current) return + controller.current?.abort() + const abort = new AbortController() + controller.current = abort + setLoading(true) try { - const s = await getCertificateRenewSettings(backendUrl) - setEnabled(s.enabled) - setIntervalDraft(String(s.intervalSec)) - setDaysDraft(String(s.renewBeforeDays)) - setLastCollectedAt(s.lastCollectedAt ?? null) - setLastError(s.lastError ?? null) - setLoaded(true) - } catch (e) { - setLoaded(true) - toast.error(e instanceof Error ? e.message : "Не удалось загрузить настройки автообновления") + const saved = await getCertificateRenewSettings(backendUrl, { signal: abort.signal }) + if (!alive.current || abort.signal.aborted) return + setConfirmed(saved) + if (!dirty.current) { + setIntervalDraft(String(saved.intervalSec)) + setDaysDraft(String(saved.renewBeforeDays)) + } + setError(null) + } catch (reason) { + if (alive.current && !abort.signal.aborted) setError(reason instanceof Error ? reason.message : "Не удалось прочитать настройки") + } finally { + if (alive.current && !abort.signal.aborted) setLoading(false) } - }, [backendUrl, liveReady]) + }, [backendUrl, liveReady, canRead]) useEffect(() => { - queueMicrotask(() => { - void load() - }) + alive.current = true + const cancel = () => { alive.current = false; controller.current?.abort() } + return cancel + }, []) + + useEffect(() => { + queueMicrotask(() => { if (alive.current) void load() }) }, [load]) - async function handleEnabledChange(next: boolean) { - if (!liveReady || toggleBusy) return - const prev = enabled - setEnabled(next) - setToggleBusy(true) + async function save(patch: { enabled?: boolean; intervalSec?: number; renewBeforeDays?: number }) { + if (!liveReady || !canRead || !canWrite || !confirmed || loading || writeLock.current || !alive.current) return + writeLock.current = true + setBusy(true) + const abort = new AbortController() + controller.current = abort try { - const saved = await putCertificateRenewSettings(backendUrl, { enabled: next }) - setEnabled(saved.enabled) - toast.success(next ? "Автообновление через MikrotikManager включено" : "Автообновление через MikrotikManager выключено") - } catch (e) { - setEnabled(prev) - toast.error(e instanceof Error ? e.message : "Не удалось сохранить") + const saved = await putCertificateRenewSettings(backendUrl, patch, { signal: abort.signal }) + if (!alive.current || abort.signal.aborted) return + setConfirmed(saved) + if (patch.intervalSec !== undefined) { + setIntervalDraft(String(saved.intervalSec)) + setDaysDraft(String(saved.renewBeforeDays)) + dirty.current = false + } + setError(null) + toast.success(patch.enabled !== undefined ? (saved.enabled ? "Автообновление включено" : "Автообновление выключено") : "Расписание автообновления сохранено") + } catch (reason) { + if (alive.current && !abort.signal.aborted) setError((reason instanceof Error ? reason.message : "Не удалось сохранить") + ". Подтверждённые настройки и черновик сохранены в форме; результат операции не подтверждён.") } finally { - setToggleBusy(false) + writeLock.current = false + if (alive.current && !abort.signal.aborted) setBusy(false) } } - async function handleSaveSchedule() { - if (!liveReady || saveBusy) return - const intervalSec = Math.max(300, Number.parseInt(intervalDraft, 10) || 21600) - const renewBeforeDays = Math.max(1, Math.min(90, Number.parseInt(daysDraft, 10) || 30)) - setSaveBusy(true) - try { - const saved = await putCertificateRenewSettings(backendUrl, { intervalSec, renewBeforeDays }) - setIntervalDraft(String(saved.intervalSec)) - setDaysDraft(String(saved.renewBeforeDays)) - toast.success("Расписание автообновления сохранено") - } catch (e) { - toast.error(e instanceof Error ? e.message : "Не удалось сохранить расписание") - } finally { - setSaveBusy(false) + function handleEnabledChange(next: boolean) { void save({ enabled: next }) } + function handleSaveSchedule() { + if (!/^[0-9]+$/.test(intervalDraft) || !/^[0-9]+$/.test(daysDraft) || !Number.isSafeInteger(Number(intervalDraft)) || Number(intervalDraft) < 300 || Number(daysDraft) < 1 || Number(daysDraft) > 90) { + setError("Введите целые числа: интервал от 300 секунд, срок от 1 до 90 дней.") + return } + void save({ intervalSec: Number(intervalDraft), renewBeforeDays: Number(daysDraft) }) } - const interactionsOff = !liveReady || toggleBusy || (liveReady && !loaded) + const interactionsOff = !liveReady || !canRead || !canWrite || busy || loading || !confirmed + const lastCollectedAt = confirmed?.lastCollectedAt + const lastError = confirmed?.lastError return ( - {!liveReady ? "нет backend" : enabled ? "Включено" : "Выключено"} + {!confirmed ? "Неизвестно" : enabled ? "Включено" : "Выключено"} } contentClassName="px-5 py-4 flex flex-col gap-4" > + {error &&

{error}

} + {loading &&

Загрузка настроек автообновления…

} + {!canRead &&

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

} + {!canWrite &&

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

} + {liveReady && canRead && }

Обновлять сертификаты из MM

@@ -115,7 +140,7 @@ export function CertificateRenewSettingsPanel({ Если ACME уже крутит RouterOS — выключите, чтобы не было двойного перевыпуска.

- +
{!liveReady ? ( @@ -125,6 +150,8 @@ export function CertificateRenewSettingsPanel({ Переключатель станет активен, когда backend доступен. Планировщик читает тот же флаг, что и страница «Сбор данных». + ) : !confirmed ? ( +

Состояние автообновления пока неизвестно. Сохранение недоступно до успешного чтения.

) : enabled ? ( Не смешивайте с ACME RouterOS @@ -135,7 +162,7 @@ export function CertificateRenewSettingsPanel({ ) : ( - Обновление отдано RouterOS + Автообновление MM выключено Планировщик MM больше не проверяет срок и не перевыпускает сертификаты. Ручной выпуск и импорт остаются доступны. @@ -143,27 +170,29 @@ export function CertificateRenewSettingsPanel({ )} -
+
setIntervalDraft(e.target.value)} - disabled={!liveReady || saveBusy} + aria-label="Интервал проверки сертификатов" + onChange={(e) => { dirty.current = true; setIntervalDraft(e.target.value) }} + disabled={interactionsOff || !enabled} /> setDaysDraft(e.target.value)} - disabled={!liveReady || saveBusy} + aria-label="Дней до истечения сертификата" + onChange={(e) => { dirty.current = true; setDaysDraft(e.target.value) }} + disabled={interactionsOff || !enabled} />
- diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index dcb104ae..cc70ec16 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А.19 — достоверные renew настройки, затем ACME и polling выпуска. State API, reader, snapshot merge и nullable представление подключены в 11А.14–11А.17.3; таблица требований — [этап 11А](ux-ui-stage-11a.md). Сертификаты и весь 11А ещё не приняты. +Актуальная точка продолжения: 11А.20 — достоверная форма ACME, затем 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 8a257f28..2c55a5dc 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А.18; пользователи/контейнеры приняты локально, сертификаты: state/URL/строгий API подключены; далее 11А.19 | +| 11А | Пользователи, контейнеры, сертификаты и резервные копии | В работе: 11А.1–11А.19; пользователи/контейнеры приняты локально, сертификаты: state/URL/API/renew готовы; далее 11А.20 | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | @@ -36,6 +36,14 @@ +## Блок 11А.19 — достоверные настройки автообновления (2026-10-01) + +- Renew форма начинает с unknown, а не enabled/default расписания. Ошибка чтения оставляет операции заблокированными до подтверждённого GET; есть явный повтор и отдельные read/write права. Read-only не выдаётся за недоступный backend. +- Числа проверяются без parseInt/default/clamping: целый интервал от 300 и срок 1–90. Ошибка/204/malformed PUT сохраняет подтверждённое состояние и dirty черновик. Переключатель меняется только после подтверждения, toggle/schedule имеют общий синхронный lock. +- Фоновый 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А.18 — строгие API настроек и выпуска сертификатов (2026-10-01) - ACME/renew settings и issue request/ответы проверяются существующими contracts схемами. Malformed/204 не считаются успешным сохранением; пустой jobId и чужая задача в polling отклоняются. Legacy list/refresh тоже валидируют DTO. diff --git a/docs/ux-ui-stage-11a.md b/docs/ux-ui-stage-11a.md index e1cbb838..edf40b8f 100644 --- a/docs/ux-ui-stage-11a.md +++ b/docs/ux-ui-stage-11a.md @@ -226,3 +226,11 @@ - ACME/renew settings и issue request/ответы проверяются существующими contracts схемами. Malformed/204 не считаются успешным сохранением; пустой jobId и чужая задача в polling отклоняются. Legacy list/refresh тоже валидируют DTO. - Все certificate API принимают caller AbortSignal вместе со сроком 30s. Ошибка Cloudflare с backend message сохраняет понятную причину вместо HTTP Bad Request. Payload проверяется до mutation запроса. - 5 новых API fault тестов + 5 snapshot/reader тестов, TypeScript и профильный ESLint прошли. Следующий блок 11А.19 — достоверное чтение и сохранение renew настроек, затем ACME и issue polling. Реальные ACME/RouterOS операции пока не приняты. + +## Блок 11А.19 — достоверные настройки автообновления (2026-10-01) + +- Renew форма начинает с unknown, а не enabled/default расписания. Ошибка чтения оставляет операции заблокированными до подтверждённого GET; есть явный повтор и отдельные read/write права. Read-only не выдаётся за недоступный backend. +- Числа проверяются без parseInt/default/clamping: целый интервал от 300 и срок 1–90. Ошибка/204/malformed PUT сохраняет подтверждённое состояние и dirty черновик. Переключатель меняется только после подтверждения, toggle/schedule имеют общий синхронный lock. +- Фоновый 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 и приёмка сертификатов. diff --git a/tests/e2e/certificates-context.spec.ts b/tests/e2e/certificates-context.spec.ts index 41e6c258..48e60cfb 100644 --- a/tests/e2e/certificates-context.spec.ts +++ b/tests/e2e/certificates-context.spec.ts @@ -3,7 +3,7 @@ import {certificate as container,server,certificateState as state} from "./fixtu const headers={"access-control-allow-origin":"*","access-control-allow-methods":"GET,POST,OPTIONS","access-control-allow-headers":"Content-Type,Authorization"} for(const change of ["backend","mode"])for(const fails of [false,true])test("Сертификаты: позднее чтение/"+change+"/"+fails,async({page})=>{ let started=false,completed=false,release!:()=>void;const gate=new Promise(r=>release=r) - await page.route("**/health",r=>r.fulfill({headers,json:{ok:true}}));await page.route("**/api/**",r=>{const p=new URL(r.request().url()).pathname;return r.fulfill({headers,json:p==="/api/auth/config"?{required:false}:p==="/api/servers"?[server]:p==="/api/certificates/state"?state():{directoryUrl:"https://acme.example.test/directory",tokenConfigured:false,enabled:false,intervalSec:300,daysBeforeExpiry:30}})}) + await page.route("**/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,renewBeforeDays: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() @@ -11,3 +11,23 @@ for(const change of ["backend","mode"])for(const fails of [false,true])test("С 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) }) + +for (const fails of [false, true]) test("Сертификаты: позднее сохранение renew/" + 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/renew-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: "СТАРОЕ СОХРАНЕНИЕ ОТКАЗ" } } : { headers, json: { enabled: false, intervalSec: 21600, renewBeforeDays: 30 } }) } catch {} finally { completed = true } + }) + await page.goto("/certificates"); const toggle = page.getByRole("switch", { name: "Автообновление сертификатов", exact: true }) + await expect(toggle).toBeChecked(); await toggle.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(toggle).toBeChecked(); release(); await expect.poll(() => completed).toBe(true) + await expect(toggle).toBeChecked(); await expect(page.getByText("СТАРОЕ СОХРАНЕНИЕ ОТКАЗ", { exact: true })).toHaveCount(0) + await expect(page.getByText("Автообновление выключено", { exact: true })).toHaveCount(0) +}) diff --git a/tests/e2e/certificates-data.spec.ts b/tests/e2e/certificates-data.spec.ts index aad8ee55..061173cb 100644 --- a/tests/e2e/certificates-data.spec.ts +++ b/tests/e2e/certificates-data.spec.ts @@ -6,7 +6,7 @@ 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 } }) + 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, renewBeforeDays: 30 } }) }) }) for (const kind of ["API", "malformed", "duplicate", "foreign"] as const) test("Сертификаты: первоначальный отказ " + kind + " и восстановление", async ({ page }) => { diff --git a/tests/e2e/certificates-renew.spec.ts b/tests/e2e/certificates-renew.spec.ts new file mode 100644 index 00000000..c08eed6a --- /dev/null +++ b/tests/e2e/certificates-renew.spec.ts @@ -0,0 +1,50 @@ +import { test, expect, type Page } from "@playwright/test" +import { certificateState, server } from "./fixtures/certificates" +const renew = { enabled: true, intervalSec: 21600, renewBeforeDays: 30 } +const interval = (page: Page) => page.getByRole("textbox", { name: "Интервал проверки сертификатов", exact: true }) +const save = (page: Page) => page.getByRole("button", { name: "Сохранить расписание", exact: true }) +const toggle = (page: Page) => page.getByRole("switch", { 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() : path === "/api/certificates/renew-settings" ? renew : { directoryUrl: "https://acme.example.test/directory", tokenConfigured: false } }) + }) +}) +for (const malformed of [false, true]) test("Автообновление: первоначальный отказ " + malformed + " не разрешает defaults", async ({ page }) => { + let fail = true + await page.route("**/api/certificates/renew-settings", r => r.fulfill(fail ? malformed ? { json: {} } : { status: 503, json: { error: "Отказ расписания" } } : { json: renew })) + await page.goto("/certificates") + await expect(page.getByRole("alert", { name: "Настройки автообновления" })).toBeVisible() + await expect(toggle(page)).toBeDisabled(); await expect(save(page)).toBeDisabled(); await expect(interval(page)).toHaveValue("") + fail = false; await page.getByRole("button", { name: "Перечитать настройки автообновления" }).click() + await expect(interval(page)).toHaveValue("21600"); await expect(toggle(page)).toBeChecked() +}) +for (const malformed of [false, true]) test("Автообновление: отказ сохранения " + malformed + " сохраняет черновик", async ({ page }) => { + await page.route("**/api/certificates/renew-settings", r => r.request().method() === "PUT" ? r.fulfill(malformed ? { json: {} } : { status: 503, json: { error: "Сохранение отказало" } }) : r.fulfill({ json: renew })) + await page.goto("/certificates"); await expect(interval(page)).toHaveValue("21600") + await interval(page).fill("900"); await save(page).click() + await expect(page.getByRole("alert", { name: "Настройки автообновления" })).toContainText("результат операции не подтверждён") + await expect(interval(page)).toHaveValue("900"); await expect(toggle(page)).toBeChecked() + await expect(page.getByText("Расписание автообновления сохранено", { exact: true })).toHaveCount(0) +}) +for (const value of ["bad", "299", "300oops", "1.5"]) test("Автообновление: invalid interval " + value + " не подменяется default", async ({ page }) => { + let writes = 0; await page.route("**/api/certificates/renew-settings", r => { if (r.request().method() === "PUT") writes++; return r.fulfill({ json: renew }) }) + await page.goto("/certificates"); await expect(interval(page)).toHaveValue("21600") + await interval(page).fill(value); await save(page).click() + await expect(page.getByRole("alert", { name: "Настройки автообновления" })).toContainText("Введите целые числа") + expect(writes).toBe(0); await expect(interval(page)).toHaveValue(value) +}) +test("Автообновление: single flight переключателя и подтверждённый false", async ({ page }) => { + let writes = 0; let release!: () => void; const gate = new Promise(r => release = r) + await page.route("**/api/certificates/renew-settings", async r => { if (r.request().method() === "GET") return r.fulfill({ json: renew }); writes++; await gate; await r.fulfill({ json: { ...renew, enabled: false } }) }) + await page.goto("/certificates"); await expect(toggle(page)).toBeChecked(); await toggle(page).click() + await expect(toggle(page)).toBeDisabled(); await expect(save(page)).toBeDisabled(); expect(writes).toBe(1) + release(); await expect(toggle(page)).not.toBeChecked(); await expect(page.getByText("Автообновление выключено", { exact: true })).toBeVisible() +}) +test("Автообновление: фоновое чтение не стирает dirty черновик", async ({ page }) => { + let second = false; await page.route("**/api/certificates/renew-settings", r => r.fulfill({ json: { ...renew, intervalSec: second ? 7200 : 21600 } })) + await page.goto("/certificates"); await expect(interval(page)).toHaveValue("21600"); await interval(page).fill("900") + second = true; await page.getByRole("button", { name: "Перечитать настройки автообновления" }).click() + await expect(interval(page)).toBeEnabled(); await expect(interval(page)).toHaveValue("900") +}) diff --git a/tests/e2e/certificates-url.spec.ts b/tests/e2e/certificates-url.spec.ts index f94a6d04..7bd05cb1 100644 --- a/tests/e2e/certificates-url.spec.ts +++ b/tests/e2e/certificates-url.spec.ts @@ -4,7 +4,7 @@ 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 } }) + 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, renewBeforeDays: 30 } }) }) }) for (const query of ["server=bad", "server=1&server=2", "status=bad", "q=a&q=b"]) test("Сертификаты URL: неверный контекст " + query, async ({ page }) => {