fix(ui): сохранять черновики и результат настроек ACME
This commit is contained in:
1 parent
1c407b77de
commit
594e60a1f4
7 files changed
+223
-161
No files matched your search
@@ -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 (
|
||||
<OpsPanel
|
||||
title="ACME · Cloudflare DNS-01"
|
||||
description="Публичные Let's Encrypt для зон в Cloudflare выпускаются на backend и импортируются на RouterOS 7.22+."
|
||||
contentClassName="px-5 py-4 flex flex-col gap-3"
|
||||
>
|
||||
<form
|
||||
className="grid gap-3 md:grid-cols-2"
|
||||
autoComplete="off"
|
||||
onSubmit={(event) => event.preventDefault()}
|
||||
>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium">ACME directory URL</label>
|
||||
<Input
|
||||
autoComplete="off"
|
||||
name="acme-directory-url"
|
||||
value={acmeDirectoryUrl}
|
||||
onChange={(e) => setAcmeDirectoryUrl(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium">Cloudflare zone id (опционально)</label>
|
||||
<Input
|
||||
autoComplete="off"
|
||||
name="cloudflare-zone-id"
|
||||
value={acmeZoneId}
|
||||
onChange={(e) => setAcmeZoneId(e.target.value)}
|
||||
placeholder="Авто по домену"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5 md:col-span-2">
|
||||
<label className="text-sm font-medium">Cloudflare API token</label>
|
||||
<Input
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
name="cloudflare-api-token"
|
||||
value={acmeTokenDraft}
|
||||
onChange={(e) => setAcmeTokenDraft(e.target.value)}
|
||||
placeholder={
|
||||
acmeTokenConfigured
|
||||
? "Токен сохранён — введите новый для замены"
|
||||
: "API token с правом DNS"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" disabled={acmeSaveBusy} onClick={onTest}>
|
||||
Проверить Cloudflare
|
||||
</Button>
|
||||
<Button size="sm" disabled={acmeSaveBusy} onClick={onSave}>
|
||||
Сохранить настройки
|
||||
</Button>
|
||||
</div>
|
||||
</OpsPanel>
|
||||
)
|
||||
}
|
||||
|
||||
function CertPartReference() {
|
||||
return (
|
||||
<OpsPanel
|
||||
@@ -466,14 +383,6 @@ function CertificatesContent() {
|
||||
const [issueTrustApi, setIssueTrustApi] = useState(true)
|
||||
|
||||
|
||||
const [acmeDirectoryUrl, setAcmeDirectoryUrl] = useState(
|
||||
"https://acme-v02.api.letsencrypt.org/directory",
|
||||
)
|
||||
const [acmeZoneId, setAcmeZoneId] = useState("")
|
||||
const [acmeTokenDraft, setAcmeTokenDraft] = useState("")
|
||||
const [acmeTokenConfigured, setAcmeTokenConfigured] = useState(false)
|
||||
const [acmeSaveBusy, setAcmeSaveBusy] = useState(false)
|
||||
|
||||
const displayCerts = useMemo(() => {
|
||||
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 (
|
||||
<>
|
||||
<ServerRailLayout
|
||||
@@ -717,24 +578,7 @@ function CertificatesContent() {
|
||||
|
||||
<CertificateRenewSettingsPanel backendUrl={backendUrl} liveReady={liveReady} canRead={canRead} canWrite={canWrite} />
|
||||
|
||||
{liveReady && canWrite && (
|
||||
<CertPartAcmeSettings
|
||||
acmeDirectoryUrl={acmeDirectoryUrl}
|
||||
setAcmeDirectoryUrl={setAcmeDirectoryUrl}
|
||||
acmeZoneId={acmeZoneId}
|
||||
setAcmeZoneId={setAcmeZoneId}
|
||||
acmeTokenDraft={acmeTokenDraft}
|
||||
setAcmeTokenDraft={setAcmeTokenDraft}
|
||||
acmeTokenConfigured={acmeTokenConfigured}
|
||||
acmeSaveBusy={acmeSaveBusy}
|
||||
onTest={() => {
|
||||
void handleTestAcme()
|
||||
}}
|
||||
onSave={() => {
|
||||
void handleSaveAcmeSettings()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<CertificateAcmeSettings backendUrl={backendUrl} liveReady={liveReady} canRead={canRead} canWrite={canWrite} />
|
||||
|
||||
<DataPageCard>
|
||||
<DataPageToolbar
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { putAcmeCloudflareSettingsSchema, type AcmeCloudflareSettingsDto } from "@mmapp/contracts/certificates"
|
||||
import { getAcmeSettings, putAcmeSettings, testAcmeSettings } from "@/shared/api/certificates"
|
||||
import { OpsPanel } from "@/components/ops-panel"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { toast } from "sonner"
|
||||
|
||||
/** Reuses Settings 16 composition: https://reui.io/preview/base/settings-16?ref=mcp */
|
||||
export function CertificateAcmeSettings({ backendUrl, liveReady, canRead, canWrite }: { backendUrl: string; liveReady: boolean; canRead: boolean; canWrite: boolean }) {
|
||||
const [confirmed, setConfirmed] = useState<AcmeCloudflareSettingsDto | null>(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<string | null>(null)
|
||||
const [testResult, setTestResult] = useState<string | null>(null)
|
||||
const dirty = useRef(false)
|
||||
const lock = useRef(false)
|
||||
const alive = useRef(true)
|
||||
const controller = useRef<AbortController | null>(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 (
|
||||
<OpsPanel title="ACME · Cloudflare DNS-01" description="Публичные Let's Encrypt выпускаются на backend и импортируются на выбранный RouterOS 7.22+." contentClassName="px-5 py-4 flex flex-col gap-3">
|
||||
{error && <p role="alert" aria-label="Настройки ACME">{error}</p>}
|
||||
{loading && <p role="status">Загрузка настроек ACME…</p>}
|
||||
{!confirmed && <p role="status">Настройки ACME пока неизвестны. Сохранение и проверка недоступны до успешного чтения.</p>}
|
||||
{!liveReady && <p role="status">Нет подключения к API.</p>}
|
||||
{!canRead && <p role="status">Нет права mm:certificates:read.</p>}
|
||||
{!canWrite && <p role="status">Настройки ACME доступны только для чтения.</p>}
|
||||
<p className="text-xs text-muted-foreground">{confirmed ? confirmed.tokenConfigured ? "Токен сохранён на backend." : "Сохранённого токена нет." : "Состояние токена неизвестно."}</p>
|
||||
{testResult && <p role="status">{testResult}. Настройки не изменены.</p>}
|
||||
{liveReady && canRead && <Button variant="outline" size="sm" disabled={busy || loading} onClick={() => void load()}>Перечитать настройки ACME</Button>}
|
||||
<form className="grid gap-3 md:grid-cols-2" autoComplete="off" onSubmit={event => event.preventDefault()}>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="certificate-acme-directory" className="text-sm font-medium">ACME directory URL</label>
|
||||
<Input id="certificate-acme-directory" name="acme-directory-url" autoComplete="off" disabled={disabled} value={directory} onChange={event => edit(setDirectory, event.target.value)} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="certificate-acme-zone" className="text-sm font-medium">Cloudflare zone id (опционально)</label>
|
||||
<Input id="certificate-acme-zone" name="cloudflare-zone-id" autoComplete="off" disabled={disabled} value={zone} onChange={event => edit(setZone, event.target.value)} placeholder="Авто по домену" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5 md:col-span-2">
|
||||
<label htmlFor="certificate-acme-token" className="text-sm font-medium">Cloudflare API token</label>
|
||||
<Input id="certificate-acme-token" name="cloudflare-api-token" type="password" autoComplete="new-password" disabled={disabled} value={token} onChange={event => edit(setToken, event.target.value)} placeholder={confirmed?.tokenConfigured ? "Введите новый для замены; пустое поле сохранит прежний" : "API token с правом DNS"} />
|
||||
<p className="text-xs text-muted-foreground">Проверка использует введённый токен или сохранённый на backend. Сохранение выполняется отдельно.</p>
|
||||
</div>
|
||||
</form>
|
||||
{canWrite && <div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" disabled={disabled} onClick={() => void operate("test")}>Проверить Cloudflare</Button>
|
||||
<Button size="sm" disabled={disabled} onClick={() => void operate("save")}>Сохранить настройки</Button>
|
||||
</div>}
|
||||
</OpsPanel>
|
||||
)
|
||||
}
|
||||
+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А.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А ещё не приняты.
|
||||
@@ -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.
|
||||
|
||||
@@ -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 остаются незавершёнными.
|
||||
@@ -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<string, unknown> = {}
|
||||
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<void>(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)
|
||||
})
|
||||
@@ -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<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/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)
|
||||
})
|
||||
Reference in new issue
Block a user