diff --git a/app/(main)/backups/page.tsx b/app/(main)/backups/page.tsx index e4e696c1..887f98fa 100644 --- a/app/(main)/backups/page.tsx +++ b/app/(main)/backups/page.tsx @@ -37,6 +37,9 @@ import { } from "@/shared/api/backups" import { ApiClientError, requestJson } from "@/shared/api/http-client" import { toast } from "sonner" +import { useBackupRestoreGuard } from "@/lib/use-backup-restore-guard" +import { restoreWasNotApplied } from "@/lib/backup-restore-checkpoint" +import { Checkbox } from "@/components/ui/checkbox" import { useBackupJob } from "@/lib/use-backup-job" import { backupJobOutcome } from "@/lib/backup-job-poll" import { backupSchedulePayload, backupStoragePayload } from "@/lib/backup-settings" @@ -91,6 +94,8 @@ function BackupsContent() { const [liveServers, setLiveServers] = useState(isLive ? [] : mockServers) const baseCanCreate = canWrite && canReadServers && liveServers.length > 0 && current("servers") && current("storage") const [loading, setLoading] = useState(false) + const restoreGuard = useBackupRestoreGuard(backendUrl, isLive) + const [restoreReviewed, setRestoreReviewed] = useState(false) const [opBusy, setOpBusy] = useState(false) const [opError, setOpError] = useState(null) const [scheduleSaveBusy, setScheduleSaveBusy] = useState(false) @@ -292,15 +297,23 @@ function BackupsContent() { finally { finishOperation(abort) } } async function handleRestore() { - if (!restoreTarget || !restoreTarget.serverId) return + if (!restoreTarget || !restoreTarget.serverId || !restoreGuard.canStart) return const target = restoreTarget, abort = startOperation(); if (!abort) return + let sent = false try { + if (!restoreGuard.begin({ backupId: target.id, serverId: Number(target.serverId), filename: target.filename, serverName: target.server })) return + sent = true const result = await restoreBackup(backendUrl, target.id, { signal: abort.signal }, Number(target.serverId)) if (!alive.current || abort.signal.aborted) return if (result.filename !== target.filename) throw new Error("Ответ восстановления относится к другому файлу") toast.success("API подтвердил импорт " + result.filename + " на " + result.serverName) - setRestoreTarget(null) - } catch (error) { if (alive.current && !abort.signal.aborted) setOpError(userApiError(error)) } + restoreGuard.clear(); setRestoreTarget(null) + } catch (error) { + if (alive.current && !abort.signal.aborted) { + if (sent && restoreWasNotApplied(error)) restoreGuard.clear() + setOpError(userApiError(error) + (sent && !restoreWasNotApplied(error) ? ". Результат импорта неизвестен; проверьте роутер перед повтором" : "")) + } + } finally { finishOperation(abort) } } async function handleDownload(id: string, filename: string) { @@ -399,6 +412,14 @@ function BackupsContent() { {({ backups: "История", servers: "Серверы", schedule: "Расписание", storage: "Хранилище" })[resource]}: {resources[resource].error} )} + {isLive && (restoreGuard.checkpoint || restoreGuard.error) && + {opBusy && restoreTarget ? "Импорт ожидает подтверждения" : "Проверьте результат восстановления"} + + {restoreGuard.error || (restoreGuard.checkpoint ? restoreGuard.checkpoint.filename + " → " + restoreGuard.checkpoint.serverName + ". Ответ ещё не подтверждён. Закрытие страницы не отменяет импорт." : "")} + + + + } {opError && Операция не подтверждена{opError}} {(job.error || job.storageError) && {jobTerminal ? "Задача завершена с ограничениями" : "Результат задачи пока неизвестен"}{job.error || job.storageError}} openCreate(false)} canCreate={canCreate} canWrite={canWrite && !opBusy && !scheduleSaveBusy && !backupJobId} + canRestore={restoreGuard.canStart} canDownload={isLive && canRead} />} @@ -562,6 +584,7 @@ function BackupsContent() { void handleRestore()} onCancel={() => setRestoreTarget(null)} diff --git a/components/backups/backup-confirm-dialogs.tsx b/components/backups/backup-confirm-dialogs.tsx index aced8424..93fb52e7 100644 --- a/components/backups/backup-confirm-dialogs.tsx +++ b/components/backups/backup-confirm-dialogs.tsx @@ -57,12 +57,14 @@ export function BackupDeleteDialog({ export function BackupRestoreDialog({ backup, busy, + blocked = false, error, onConfirm, onCancel, }: { backup: Backup | null busy: boolean + blocked?: boolean error?: string | null onConfirm: () => void onCancel: () => void @@ -91,8 +93,8 @@ export function BackupRestoreDialog({ {error &&

{error}

} - Отмена - + {blocked && !busy ? "Закрыть" : "Отмена"} + {busy ? : null} Восстановить diff --git a/components/backups/backups-history.tsx b/components/backups/backups-history.tsx index d8965cbb..ec79a834 100644 --- a/components/backups/backups-history.tsx +++ b/components/backups/backups-history.tsx @@ -29,6 +29,7 @@ export function BackupsHistory({ onDelete, onCreate, canCreate = true, + canRestore = true, canWrite = true, canDownload = true, }: { @@ -43,6 +44,7 @@ export function BackupsHistory({ onRestore: (backup: Backup) => void onDelete: (backup: Backup) => void canCreate?: boolean + canRestore?: boolean canWrite?: boolean canDownload?: boolean onCreate: () => void @@ -82,6 +84,7 @@ export function BackupsHistory({ onDownload={onDownload} onRestore={onRestore} onDelete={onDelete} + canRestore={canRestore} canWrite={canWrite} canDownload={canDownload} emptyTitle={isEmptyAll ? "Нет бэкапов" : "Ничего не найдено"} diff --git a/components/data-grids/backups-data-grid.tsx b/components/data-grids/backups-data-grid.tsx index 3e6c2311..2131e1cc 100644 --- a/components/data-grids/backups-data-grid.tsx +++ b/components/data-grids/backups-data-grid.tsx @@ -32,6 +32,7 @@ interface BackupsDataGridProps { onDownload: (id: string, filename: string) => void onRestore: (backup: Backup) => void onDelete: (backup: Backup) => void + canRestore?: boolean canWrite?: boolean canDownload?: boolean emptyAction?: ReactNode @@ -50,6 +51,7 @@ function BackupsDataGrid({ onDownload, onRestore, onDelete, + canRestore = true, canWrite = true, canDownload = true, emptyAction, @@ -207,7 +209,7 @@ function BackupsDataGrid({ className="size-7" title="Восстановить" aria-label={`Восстановить ${b.filename}`} - disabled={!canWrite || !b.serverId} + disabled={!canWrite || !canRestore || !b.serverId} onClick={() => onRestore(b)} > @@ -235,7 +237,7 @@ function BackupsDataGrid({ }, }, ], - [onDelete, onDownload, onRestore, canWrite, canDownload], + [onDelete, onDownload, onRestore, canWrite, canRestore, canDownload], ) const table = useTable({ diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index c4b24389..251d9f8d 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А.29.5 — клиентский checkpoint restore и повтор после проверки роутера. Backend outcome неизвестного импорта принят в 11А.29.4. Идентичность receipt принята в 11А.29.3. Backend cleanup удаления принят в 11А.29.2. Frontend delete/restore/download принят в 11А.29.1, затем URL/Frame/визуальная приёмка и scheduler cleanup. Frontend checkpoint/создание/serial polling приняты локально в 11А.28.2. API/основа наблюдения реализованы в 11А.28.1. Настройки расписания/S3 реализованы в 11А.27.1–11А.27.2. Каталоги/source/права реализованы в 11А.25, backend результат ручных операций исправлен в 11А.26. Сертификаты приняты локально в 11А.24, backups API стал строгим. State API, reader, snapshot merge и nullable представление подключены в 11А.14–11А.17.3; таблица требований — [этап 11А](ux-ui-stage-11a.md). Резервные копии и весь 11А ещё не приняты. +Актуальная точка продолжения: 11А.30 — URL/Filters контекст резервных копий. Клиентский checkpoint restore принят в 11А.29.5. Backend outcome неизвестного импорта принят в 11А.29.4. Идентичность receipt принята в 11А.29.3. Backend cleanup удаления принят в 11А.29.2. Frontend delete/restore/download принят в 11А.29.1, затем URL/Frame/визуальная приёмка и scheduler cleanup. Frontend checkpoint/создание/serial polling приняты локально в 11А.28.2. API/основа наблюдения реализованы в 11А.28.1. Настройки расписания/S3 реализованы в 11А.27.1–11А.27.2. Каталоги/source/права реализованы в 11А.25, backend результат ручных операций исправлен в 11А.26. Сертификаты приняты локально в 11А.24, backups API стал строгим. 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 41367c5d..b76d8e11 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А.29.4; пользователи/контейнеры/сертификаты приняты локально; резервные копии: неизвестный outcome backend, далее 11А.29.5 | +| 11А | Пользователи, контейнеры, сертификаты и резервные копии | В работе: 11А.1–11А.29.5; пользователи/контейнеры/сертификаты приняты локально; резервные копии: checkpoint restore, далее 11А.30 | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | @@ -36,6 +36,15 @@ +## Блок 11А.29.5 — отметка неизвестного restore до POST (2026-10-01) + +- До POST записывается immutable backupId/serverId/filename/имя/время в sessionStorage по backend URL. Отказ записи не отправляет импорт. Синхронный guard и общий operation lock защищают повтор; hydration/corrupt checkpoint не допускают начало. +- Обрыв, deadline, чужой receipt, backend unknown и legacy 400 без outcome сохраняют отметку. Reload/возврат не запускает POST и блокирует restore. Известный 400 not-applied/403/404/405 или подтверждённый receipt очищает отметку. Отказ очистки сохраняет блокировку, но не отменяет toast подтверждённого импорта. +- Новый импорт требует явного checkbox «Я проверил состояние роутера…» и отдельного действия «Разрешить новое восстановление». Оно только снимает отметку, не отправляет запрос и не объявляет конфигурацию проверенной приложением. Read-only/занятость запрещают снятие; после unknown диалог закрывается кнопкой «Закрыть», это не отмена server operation. +- 2 checkpoint/classification unit, 12 operations E2E, все 28 context E2E, TypeScript/профильный ESLint и production frontend прошли. Тесты уточнены: review выполняется после закрытия modal, checkpoint same-origin привязан к location.origin. +- Durable backend restore repository отсутствует: отметка действует в текущей вкладке/sessionStorage, не синхронизирует разные браузеры/вкладки и не доказывает результат RouterOS. Реальные RouterOS/PostgreSQL/S3 не проверены; ручная проверка устройства остаётся внешним действием. +- **Следующий блок 11А.30:** URL контекст истории/табов/Filters, затем Frame и визуальная адаптивная приёмка, unknown статус устройств, scheduler cleanup и локальная приёмка backups. + ## Блок 11А.29.4 — неизвестный результат импорта на backend (2026-10-01) - Import fallback /execute разрешён только при RouterOS HTTP 404/405 для /import. Таймаут/обрыв, невалидный JSON, 400/401/500 не запускают второй импорт. После отправки команды любая ошибка маркируется BackupRestoreUnknownError; HTTP 502/outcome unknown не объявляет конфигурацию неизменённой. Ошибка подготовки до импорта остаётся 400/outcome not-applied. diff --git a/docs/ux-ui-stage-11a.md b/docs/ux-ui-stage-11a.md index dba9f304..bb96d3f5 100644 --- a/docs/ux-ui-stage-11a.md +++ b/docs/ux-ui-stage-11a.md @@ -399,3 +399,13 @@ - Реальный RouterOS не проверен; /execute receipt подтверждает ответ команды, не завершение всех изменений конфигурации. Backend durable restore/idempotency repository пока отсутствует; клиентский запрет слепого повтора нужен следующим блоком. - **Следующий блок 11А.29.5:** checkpoint restore до POST, неизвестный результат/после reload и явная проверка роутера перед новым импортом; затем URL/Frame/адаптивность, scheduler и приёмка backups. + +## Блок 11А.29.5 — отметка неизвестного restore до POST (2026-10-01) + +- До POST записывается immutable backupId/serverId/filename/имя/время в sessionStorage по backend URL. Отказ записи не отправляет импорт. Синхронный guard и общий operation lock защищают повтор; hydration/corrupt checkpoint не допускают начало. +- Обрыв, deadline, чужой receipt, backend unknown и legacy 400 без outcome сохраняют отметку. Reload/возврат не запускает POST и блокирует restore. Известный 400 not-applied/403/404/405 или подтверждённый receipt очищает отметку. Отказ очистки сохраняет блокировку, но не отменяет toast подтверждённого импорта. +- Новый импорт требует явного checkbox «Я проверил состояние роутера…» и отдельного действия «Разрешить новое восстановление». Оно только снимает отметку, не отправляет запрос и не объявляет конфигурацию проверенной приложением. Read-only/занятость запрещают снятие; после unknown диалог закрывается кнопкой «Закрыть», это не отмена server operation. +- 2 checkpoint/classification unit, 12 operations E2E, все 28 context E2E, TypeScript/профильный ESLint и production frontend прошли. Тесты уточнены: review выполняется после закрытия modal, checkpoint same-origin привязан к location.origin. +- Durable backend restore repository отсутствует: отметка действует в текущей вкладке/sessionStorage, не синхронизирует разные браузеры/вкладки и не доказывает результат RouterOS. Реальные RouterOS/PostgreSQL/S3 не проверены; ручная проверка устройства остаётся внешним действием. +- **Следующий блок 11А.30:** URL контекст истории/табов/Filters, затем Frame и визуальная адаптивная приёмка, unknown статус устройств, scheduler cleanup и локальная приёмка backups. + diff --git a/lib/backup-restore-checkpoint.test.ts b/lib/backup-restore-checkpoint.test.ts new file mode 100644 index 00000000..4d0c2790 --- /dev/null +++ b/lib/backup-restore-checkpoint.test.ts @@ -0,0 +1,15 @@ +import assert from "node:assert/strict" +import { test } from "node:test" +import { ApiClientError } from "@/shared/api/http-client" +import { backupRestoreStorageKey, readBackupRestoreCheckpoint, restoreWasNotApplied } from "./backup-restore-checkpoint" +test("restore checkpoint preserves target and rejects corrupt or incomplete state", () => { + const target = { backupId: "backup-1", serverId: 1, filename: "config.rsc", serverName: "router", requestedAt: "2026-10-01T00:00:00Z" } + assert.deepEqual(readBackupRestoreCheckpoint(JSON.stringify(target)), target); assert.equal(readBackupRestoreCheckpoint(null), null) + for (const payload of ["invalid JSON", JSON.stringify({}), JSON.stringify({ ...target, serverId: "1" }), JSON.stringify({ ...target, requestedAt: "bad" })]) assert.throws(() => readBackupRestoreCheckpoint(payload)) + assert.notEqual(backupRestoreStorageKey("first"), backupRestoreStorageKey("second")) +}) +test("unknown response is not proof that import was not applied", () => { + for (const error of [new Error("timeout"), new ApiClientError("old backend error", 400), new ApiClientError("lost after apply", 502, { outcome: "unknown" }), new ApiClientError("server error", 500)]) assert.equal(restoreWasNotApplied(error), false) + assert.equal(restoreWasNotApplied(new ApiClientError("prepare failed", 400, { outcome: "not-applied" })), true) + assert.equal(restoreWasNotApplied(new ApiClientError("forbidden", 403)), true) +}) diff --git a/lib/backup-restore-checkpoint.ts b/lib/backup-restore-checkpoint.ts new file mode 100644 index 00000000..e3a15ffe --- /dev/null +++ b/lib/backup-restore-checkpoint.ts @@ -0,0 +1,11 @@ +import { z } from "zod" +import { ApiClientError } from "@/shared/api/http-client" +export const backupRestoreCheckpointSchema = z.object({ backupId: z.string().min(1), serverId: z.number().int().positive().safe(), filename: z.string().min(1), serverName: z.string().min(1), requestedAt: z.string().datetime({ offset: true }) }) +export type BackupRestoreCheckpoint = z.infer +export function readBackupRestoreCheckpoint(value: string | null) { return value ? backupRestoreCheckpointSchema.parse(JSON.parse(value)) : null } +export function backupRestoreStorageKey(backendUrl: string) { return "mmapp:backup-restore:" + backendUrl } +export function restoreWasNotApplied(error: unknown): boolean { + if (!(error instanceof ApiClientError)) return false + if ([403, 404, 405].includes(error.status)) return true + return error.status === 400 && !!error.payload && typeof error.payload === "object" && "outcome" in error.payload && error.payload.outcome === "not-applied" +} diff --git a/lib/use-backup-restore-guard.ts b/lib/use-backup-restore-guard.ts new file mode 100644 index 00000000..6f2b899e --- /dev/null +++ b/lib/use-backup-restore-guard.ts @@ -0,0 +1,32 @@ +"use client" +import { useEffect, useRef, useState } from "react" +import { backupRestoreCheckpointSchema, backupRestoreStorageKey, readBackupRestoreCheckpoint, type BackupRestoreCheckpoint } from "./backup-restore-checkpoint" +export function useBackupRestoreGuard(backendUrl: string, live: boolean) { + const [checkpoint, setCheckpoint] = useState(null), current = useRef(null) + const [hydrated, setHydrated] = useState(false), [error, setError] = useState(null), blocked = useRef(false), alive = useRef(true) + const key = backupRestoreStorageKey(backendUrl) + useEffect(() => { + alive.current = true + queueMicrotask(() => { + if (!alive.current) return + try { const saved = live ? readBackupRestoreCheckpoint(sessionStorage.getItem(key)) : null; current.current = saved; setCheckpoint(saved) } + catch { blocked.current = true; setError("Не удалось прочитать отметку импорта. Проверьте состояние роутера перед новым восстановлением") } + setHydrated(true) + }) + return () => { alive.current = false } + }, [key, live]) + function begin(target: Omit) { + if (!live || !hydrated || !alive.current || blocked.current || current.current) return false + const value = backupRestoreCheckpointSchema.parse({ ...target, requestedAt: new Date().toISOString() }) + try { sessionStorage.setItem(key, JSON.stringify(value)) } + catch { blocked.current = true; setError("Не удалось сохранить отметку импорта. Запрос не отправлен"); return false } + current.current = value; setCheckpoint(value); return true + } + function clear() { + if (!live || !hydrated || !alive.current) return false + try { sessionStorage.removeItem(key) } + catch { blocked.current = true; setError("Не удалось очистить отметку импорта. Новый импорт заблокирован"); return false } + current.current = null; blocked.current = false; setCheckpoint(null); setError(null); return true + } + return { checkpoint, hydrated, error, begin, clear, canStart: hydrated && !checkpoint && !error } +} diff --git a/tests/e2e/backups-operations.spec.ts b/tests/e2e/backups-operations.spec.ts index 5b604589..25e7c23b 100644 --- a/tests/e2e/backups-operations.spec.ts +++ b/tests/e2e/backups-operations.spec.ts @@ -22,7 +22,7 @@ for (const status of [403, 503, 200]) test("Бэкапы: удаление " + s }) test("Бэкапы: ошибка restore и чужой filename сохраняют диалог", async ({ page }) => { await setupBackups(page); let failure = true - await page.route("**/api/backups/backup-1/restore", r => r.fulfill(failure ? { status: 503, json: { error: "Импорт отказал" } } : { json: { backupId: "backup-1", serverId: 1, filename: "other.rsc", serverName: "router" } })) + await page.route("**/api/backups/backup-1/restore", r => r.fulfill(failure ? { status: 400, json: { error: "Импорт отказал", outcome: "not-applied" } } : { json: { backupId: "backup-1", serverId: 1, filename: "other.rsc", serverName: "router" } })) await page.goto("/backups"); await page.getByRole("button", { name: "Восстановить " + backup.filename, exact: true }).click() const dialog = page.getByRole("alertdialog"); await dialog.getByRole("button", { name: "Восстановить", exact: true }).click() await expect(dialog.getByRole("alert").filter({ hasText: "Импорт отказал" })).toBeVisible() @@ -44,6 +44,47 @@ test("Бэкапы: restore требует правильный serverId и пр await page.goto("/backups"); await page.getByRole("button", { name: "Восстановить " + backup.filename, exact: true }).click() const dialog = page.getByRole("alertdialog"); await dialog.getByRole("button", { name: "Восстановить", exact: true }).click() await expect(dialog.getByRole("alert").filter({ hasText: "другой резервной копии или серверу" })).toBeVisible() - serverId = 1; await dialog.getByRole("button", { name: "Восстановить", exact: true }).dblclick() + await expect(dialog.getByRole("button", { name: "Восстановить", exact: true })).toBeDisabled() + await dialog.getByRole("button", { name: "Закрыть", exact: true }).click() + await page.getByRole("checkbox", { name: /Я проверил состояние роутера/ }).check(); await page.getByRole("button", { name: "Разрешить новое восстановление", exact: true }).click() + serverId = 1; await page.getByRole("button", { name: "Восстановить " + backup.filename, exact: true }).click(); await dialog.getByRole("button", { name: "Восстановить", exact: true }).dblclick() await expect(dialog).toBeHidden(); await expect(page.getByText("API подтвердил импорт " + backup.filename + " на Новое имя роутера", { exact: true })).toBeVisible(); expect(calls).toBe(2) }) + +for (const outcome of ["unknown", "legacy-error"]) test("Бэкапы: неизвестный restore переживает reload/" + outcome, async ({ page }) => { + await setupBackups(page); let calls = 0 + await page.route("**/api/backups/backup-1/restore", async r => { calls++; await r.fulfill({ status: outcome === "unknown" ? 502 : 400, json: { error: "Ответ импорта потерян", ...(outcome === "unknown" ? { outcome: "unknown" } : {}) } }) }) + await page.goto("/backups"); await page.getByRole("button", { name: "Восстановить " + backup.filename, exact: true }).click() + const dialog = page.getByRole("alertdialog"); await dialog.getByRole("button", { name: "Восстановить", exact: true }).click() + await expect(dialog.getByRole("button", { name: "Восстановить", exact: true })).toBeDisabled(); await expect(dialog).toContainText("Результат импорта неизвестен") + await page.reload(); const alert = page.getByRole("alert", { name: "Подтверждение восстановления", exact: true }) + await expect(alert).toContainText(backup.filename); await expect(page.getByRole("button", { name: "Восстановить " + backup.filename, exact: true })).toBeDisabled() + await expect(alert.getByRole("button", { name: "Разрешить новое восстановление", exact: true })).toBeDisabled(); expect(calls).toBe(1) + await alert.getByRole("checkbox", { name: /Я проверил состояние роутера/ }).check(); await alert.getByRole("button", { name: "Разрешить новое восстановление", exact: true }).click() + await expect(alert).toBeHidden(); await expect(page.getByRole("button", { name: "Восстановить " + backup.filename, exact: true })).toBeEnabled(); expect(calls).toBe(1) +}) +test("Бэкапы: запрет записи checkpoint не отправляет restore", async ({ page }) => { + await setupBackups(page); let calls = 0 + await page.addInitScript(() => { const original = Storage.prototype.setItem; Storage.prototype.setItem = function(key, value) { if (key.startsWith("mmapp:backup-restore:")) throw new Error("storage denied"); return original.call(this, key, value) } }) + await page.route("**/api/backups/backup-1/restore", async r => { calls++; await r.fulfill({ json: {} }) }) + await page.goto("/backups"); await page.getByRole("button", { name: "Восстановить " + backup.filename, exact: true }).click() + await page.getByRole("alertdialog").getByRole("button", { name: "Восстановить", exact: true }).click() + await expect(page.getByRole("alert", { name: "Подтверждение восстановления", exact: true })).toContainText("Запрос не отправлен"); expect(calls).toBe(0) +}) +test("Бэкапы: повреждённый checkpoint запрещает новый restore", async ({ page }) => { + await setupBackups(page); await page.addInitScript(() => sessionStorage.setItem("mmapp:backup-restore:" + location.origin, "broken")) + await page.goto("/backups"); await expect(page.getByRole("alert", { name: "Подтверждение восстановления", exact: true })).toContainText("Не удалось прочитать отметку") + await expect(page.getByRole("button", { name: "Восстановить " + backup.filename, exact: true })).toBeDisabled() +}) + +test("Бэкапы: подтверждённый restore с отказом очистки checkpoint не допускает новый импорт", async ({ page }) => { + await setupBackups(page); let calls = 0 + await page.addInitScript(() => { const original = Storage.prototype.removeItem; Storage.prototype.removeItem = function(key) { if (key.startsWith("mmapp:backup-restore:")) throw new Error("storage denied"); return original.call(this, key) } }) + await page.route("**/api/backups/backup-1/restore", async r => { calls++; await r.fulfill({ json: { backupId: backup.id, serverId: backup.serverId, filename: backup.filename, serverName: "router" } }) }) + await page.goto("/backups"); await page.getByRole("button", { name: "Восстановить " + backup.filename, exact: true }).click() + await page.getByRole("alertdialog").getByRole("button", { name: "Восстановить", exact: true }).click() + await expect(page.getByRole("alertdialog")).toBeHidden(); await expect(page.getByText("API подтвердил импорт " + backup.filename + " на router", { exact: true })).toBeVisible() + const alert = page.getByRole("alert", { name: "Подтверждение восстановления", exact: true }); await expect(alert).toContainText("Не удалось очистить отметку") + await alert.getByRole("checkbox", { name: /Я проверил состояние роутера/ }).check(); await alert.getByRole("button", { name: "Разрешить новое восстановление", exact: true }).click() + await expect(page.getByRole("button", { name: "Восстановить " + backup.filename, exact: true })).toBeDisabled(); expect(calls).toBe(1) +})