fix(backups): сохранять неизвестный результат восстановления

This commit is contained in:
Denozordec committed 2026-10-01 16:32:06 +07:00
1 parent 39570656c9
commit 6f152c8dfe
11 files changed
+159 -11

No files matched your search

+26 -3
View File
@@ -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<Server[]>(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<string | null>(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() {
<AlertDescription>{({ backups: "История", servers: "Серверы", schedule: "Расписание", storage: "Хранилище" })[resource]}: {resources[resource].error}</AlertDescription>
</Alert>)}
{isLive && (restoreGuard.checkpoint || restoreGuard.error) && <Alert variant="warning" aria-label="Подтверждение восстановления">
<AlertTitle>{opBusy && restoreTarget ? "Импорт ожидает подтверждения" : "Проверьте результат восстановления"}</AlertTitle>
<AlertDescription className="flex flex-col gap-3">
<span>{restoreGuard.error || (restoreGuard.checkpoint ? restoreGuard.checkpoint.filename + " → " + restoreGuard.checkpoint.serverName + ". Ответ ещё не подтверждён. Закрытие страницы не отменяет импорт." : "")}</span>
<label className="flex items-start gap-2"><Checkbox checked={restoreReviewed} disabled={opBusy || !canWrite} onCheckedChange={value => setRestoreReviewed(value === true)} />Я проверил состояние роутера и понимаю последствия повторного импорта</label>
<Button type="button" variant="outline" disabled={!restoreReviewed || opBusy || !canWrite} onClick={() => { if (restoreGuard.clear()) { setRestoreReviewed(false); setOpError(null) } }}>Разрешить новое восстановление</Button>
</AlertDescription>
</Alert>}
{opError && <Alert variant="warning" aria-label="Операция резервных копий"><AlertTitle>Операция не подтверждена</AlertTitle><AlertDescription>{opError}</AlertDescription></Alert>}
{(job.error || job.storageError) && <Alert variant="warning" aria-label="Задача резервного копирования"><AlertTitle>{jobTerminal ? "Задача завершена с ограничениями" : "Результат задачи пока неизвестен"}</AlertTitle><AlertDescription>{job.error || job.storageError}</AlertDescription></Alert>}
<KpiStatGrid
@@ -476,6 +497,7 @@ function BackupsContent() {
onCreate={() => openCreate(false)}
canCreate={canCreate}
canWrite={canWrite && !opBusy && !scheduleSaveBusy && !backupJobId}
canRestore={restoreGuard.canStart}
canDownload={isLive && canRead}
/>}
</TabsContent>
@@ -562,6 +584,7 @@ function BackupsContent() {
<BackupRestoreDialog
backup={restoreTarget}
busy={opBusy}
blocked={!restoreGuard.canStart}
error={opError}
onConfirm={() => void handleRestore()}
onCancel={() => setRestoreTarget(null)}
@@ -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({
</AlertDialogHeader>
{error && <p role="alert" className="text-sm text-destructive break-words">{error}</p>}
<AlertDialogFooter>
<AlertDialogCancel disabled={busy} onClick={onCancel}>Отмена</AlertDialogCancel>
<AlertDialogAction disabled={busy} onClick={onConfirm}>
<AlertDialogCancel disabled={busy} onClick={onCancel}>{blocked && !busy ? "Закрыть" : "Отмена"}</AlertDialogCancel>
<AlertDialogAction disabled={busy || blocked} onClick={onConfirm}>
{busy ? <LoaderCircleIcon className="size-4 animate-spin" /> : null}
Восстановить
</AlertDialogAction>
+3
View File
@@ -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 ? "Нет бэкапов" : "Ничего не найдено"}
+4 -2
View File
@@ -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)}
>
<RefreshCwIcon className="size-3.5" />
@@ -235,7 +237,7 @@ function BackupsDataGrid({
},
},
],
[onDelete, onDownload, onRestore, canWrite, canDownload],
[onDelete, onDownload, onRestore, canWrite, canRestore, canDownload],
)
const table = useTable({
+1 -1
View File
@@ -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А ещё не приняты.
+10 -1
View File
@@ -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.
+10
View File
@@ -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.
+15
View File
@@ -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)
})
+11
View File
@@ -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<typeof backupRestoreCheckpointSchema>
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"
}
+32
View File
@@ -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<BackupRestoreCheckpoint | null>(null), current = useRef<BackupRestoreCheckpoint | null>(null)
const [hydrated, setHydrated] = useState(false), [error, setError] = useState<string | null>(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<BackupRestoreCheckpoint, "requestedAt">) {
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 }
}
+43 -2
View File
@@ -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)
})