fix(ui): сохранять подтверждённые настройки автообновления
This commit is contained in:
1 parent
df89e88f4e
commit
1c407b77de
9 files changed
+182
-67
No files matched your search
@@ -715,7 +715,7 @@ function CertificatesContent() {
|
||||
{!classificationsKnown && <p role="status" className="text-xs text-muted-foreground">Сводка неполная: часть устройств, статусов или сроков неизвестна. Таблица показывает доступные снимки.</p>}
|
||||
<CertPartKpi displayCerts={scopedCerts} expiring={expiring} expired={expired} complete={classificationsKnown} />
|
||||
|
||||
<CertificateRenewSettingsPanel backendUrl={backendUrl} liveReady={liveReady && canWrite} />
|
||||
<CertificateRenewSettingsPanel backendUrl={backendUrl} liveReady={liveReady} canRead={canRead} canWrite={canWrite} />
|
||||
|
||||
{liveReady && canWrite && (
|
||||
<CertPartAcmeSettings
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { OpsPanel } from "@/components/ops-panel"
|
||||
import { FormField, FormToggle } from "@/components/form-kit"
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
getCertificateRenewSettings,
|
||||
putCertificateRenewSettings,
|
||||
} from "@/shared/api/certificates"
|
||||
import type { CertificateRenewSettingsDto } from "@mmapp/contracts/certificates"
|
||||
import { toast } from "sonner"
|
||||
|
||||
/**
|
||||
@@ -23,76 +24,95 @@ import { toast } from "sonner"
|
||||
export function CertificateRenewSettingsPanel({
|
||||
backendUrl,
|
||||
liveReady,
|
||||
canRead = true,
|
||||
canWrite = true,
|
||||
}: {
|
||||
backendUrl: string
|
||||
liveReady: boolean
|
||||
canRead?: boolean
|
||||
canWrite?: boolean
|
||||
}) {
|
||||
const [enabled, setEnabled] = useState(true)
|
||||
const [intervalDraft, setIntervalDraft] = useState("21600")
|
||||
const [daysDraft, setDaysDraft] = useState("30")
|
||||
const [lastCollectedAt, setLastCollectedAt] = useState<string | null>(null)
|
||||
const [lastError, setLastError] = useState<string | null>(null)
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
const [toggleBusy, setToggleBusy] = useState(false)
|
||||
const [saveBusy, setSaveBusy] = useState(false)
|
||||
const [confirmed, setConfirmed] = useState<CertificateRenewSettingsDto | null>(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<string | null>(null)
|
||||
const alive = useRef(true)
|
||||
const controller = useRef<AbortController | null>(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 (
|
||||
<OpsPanel
|
||||
@@ -101,13 +121,18 @@ export function CertificateRenewSettingsPanel({
|
||||
headerRight={
|
||||
<Badge
|
||||
size="sm"
|
||||
variant={!liveReady ? "warning-light" : enabled ? "success-light" : "secondary"}
|
||||
variant={!liveReady || !confirmed ? "warning-light" : enabled ? "success-light" : "secondary"}
|
||||
>
|
||||
{!liveReady ? "нет backend" : enabled ? "Включено" : "Выключено"}
|
||||
{!confirmed ? "Неизвестно" : enabled ? "Включено" : "Выключено"}
|
||||
</Badge>
|
||||
}
|
||||
contentClassName="px-5 py-4 flex flex-col gap-4"
|
||||
>
|
||||
{error && <p role="alert" aria-label="Настройки автообновления">{error}</p>}
|
||||
{loading && <p role="status">Загрузка настроек автообновления…</p>}
|
||||
{!canRead && <p role="status">Нет права mm:certificates:read.</p>}
|
||||
{!canWrite && <p role="status">Настройки доступны только для чтения.</p>}
|
||||
{liveReady && canRead && <Button variant="outline" size="sm" disabled={busy || loading} onClick={() => void load()}>Перечитать настройки автообновления</Button>}
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Обновлять сертификаты из MM</p>
|
||||
@@ -115,7 +140,7 @@ export function CertificateRenewSettingsPanel({
|
||||
Если ACME уже крутит RouterOS — выключите, чтобы не было двойного перевыпуска.
|
||||
</p>
|
||||
</div>
|
||||
<FormToggle checked={enabled} onChange={handleEnabledChange} disabled={interactionsOff} />
|
||||
<FormToggle ariaLabel="Автообновление сертификатов" checked={enabled} onChange={handleEnabledChange} disabled={interactionsOff} />
|
||||
</div>
|
||||
|
||||
{!liveReady ? (
|
||||
@@ -125,6 +150,8 @@ export function CertificateRenewSettingsPanel({
|
||||
Переключатель станет активен, когда backend доступен. Планировщик читает тот же флаг, что и страница «Сбор данных».
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : !confirmed ? (
|
||||
<p role="status">Состояние автообновления пока неизвестно. Сохранение недоступно до успешного чтения.</p>
|
||||
) : enabled ? (
|
||||
<Alert variant="warning">
|
||||
<AlertTitle>Не смешивайте с ACME RouterOS</AlertTitle>
|
||||
@@ -135,7 +162,7 @@ export function CertificateRenewSettingsPanel({
|
||||
</Alert>
|
||||
) : (
|
||||
<Alert variant="info">
|
||||
<AlertTitle>Обновление отдано RouterOS</AlertTitle>
|
||||
<AlertTitle>Автообновление MM выключено</AlertTitle>
|
||||
<AlertDescription>
|
||||
Планировщик MM больше не проверяет срок и не перевыпускает сертификаты. Ручной выпуск и импорт
|
||||
остаются доступны.
|
||||
@@ -143,27 +170,29 @@ export function CertificateRenewSettingsPanel({
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className={cn("flex flex-col gap-4", !enabled && "pointer-events-none opacity-40")}>
|
||||
<div className={cn("flex flex-col gap-4", confirmed && !enabled && "opacity-60")}>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<FormField label="Интервал проверки" hint="Секунды, минимум 300">
|
||||
<Input
|
||||
inputMode="numeric"
|
||||
value={intervalDraft}
|
||||
onChange={(e) => setIntervalDraft(e.target.value)}
|
||||
disabled={!liveReady || saveBusy}
|
||||
aria-label="Интервал проверки сертификатов"
|
||||
onChange={(e) => { dirty.current = true; setIntervalDraft(e.target.value) }}
|
||||
disabled={interactionsOff || !enabled}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Обновлять за" hint="Дней до истечения, 1–90">
|
||||
<Input
|
||||
inputMode="numeric"
|
||||
value={daysDraft}
|
||||
onChange={(e) => setDaysDraft(e.target.value)}
|
||||
disabled={!liveReady || saveBusy}
|
||||
aria-label="Дней до истечения сертификата"
|
||||
onChange={(e) => { dirty.current = true; setDaysDraft(e.target.value) }}
|
||||
disabled={interactionsOff || !enabled}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button variant="outline" size="sm" disabled={!liveReady || saveBusy || !enabled} onClick={() => void handleSaveSchedule()}>
|
||||
<Button variant="outline" size="sm" disabled={interactionsOff || !enabled} onClick={() => void handleSaveSchedule()}>
|
||||
Сохранить расписание
|
||||
</Button>
|
||||
<Link href="/data-collection" className={cn(buttonVariants({ variant: "ghost", size: "sm" }), "h-8 text-xs")}>
|
||||
|
||||
+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/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А ещё не приняты.
|
||||
@@ -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.
|
||||
|
||||
@@ -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 и приёмка сертификатов.
|
||||
@@ -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<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("**/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<void>(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)
|
||||
})
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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<void>(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")
|
||||
})
|
||||
@@ -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 }) => {
|
||||
|
||||
Reference in new issue
Block a user