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}
/>
- void handleSaveSchedule()}>
+ void handleSaveSchedule()}>
Сохранить расписание
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 }) => {