From 8e01666e0e918901058a5f263a6f928b5a77e286 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Thu, 1 Oct 2026 15:25:48 +0700 Subject: [PATCH] =?UTF-8?q?fix(backups):=20=D1=81=D0=BE=D1=85=D1=80=D0=B0?= =?UTF-8?q?=D0=BD=D1=8F=D1=82=D1=8C=20=D1=80=D0=B0=D1=81=D0=BF=D0=B8=D1=81?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D0=B5=20=D0=B8=20=D1=85=D1=80=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D0=BB=D0=B8=D1=89=D0=B5=20=D0=BD=D0=B5=D0=B7=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D1=81=D0=B8=D0=BC=D0=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(main)/backups/page.tsx | 150 ++++++++++++------------ components/backups/backups-settings.tsx | 73 ++++++++---- docs/ux-ui-plan.md | 2 +- docs/ux-ui-progress.md | 11 +- docs/ux-ui-stage-11a.md | 9 ++ lib/backup-settings.test.ts | 18 +++ lib/backup-settings.ts | 17 +++ shared/api/backups.test.ts | 9 ++ shared/api/backups.ts | 16 ++- tests/e2e/backups-context.spec.ts | 18 +++ tests/e2e/backups-data.spec.ts | 2 +- tests/e2e/backups-settings.spec.ts | 65 ++++++++++ 12 files changed, 286 insertions(+), 104 deletions(-) create mode 100644 lib/backup-settings.test.ts create mode 100644 lib/backup-settings.ts create mode 100644 tests/e2e/backups-settings.spec.ts diff --git a/app/(main)/backups/page.tsx b/app/(main)/backups/page.tsx index 970954a1..d75f85c2 100644 --- a/app/(main)/backups/page.tsx +++ b/app/(main)/backups/page.tsx @@ -38,6 +38,7 @@ import { } from "@/shared/api/backups" import { requestJson, requestBlob } from "@/shared/api/http-client" import { toast } from "sonner" +import { backupSchedulePayload, backupStoragePayload } from "@/lib/backup-settings" import { BackupsHistory } from "@/components/backups/backups-history" import { BackupsSettings, @@ -74,6 +75,8 @@ function BackupsContent() { const alive = useRef(true) const scheduleDirty = useRef(false) const storageDirty = useRef(false) + const settingsLock = useRef(false) + const settingsController = useRef(null) const known = (resource: Resource) => !isLive || resources[resource].known const current = (resource: Resource) => known(resource) && !resources[resource].error const [tab, setTab] = useState("history") @@ -95,6 +98,7 @@ function BackupsContent() { const [savedSchedule, setSavedSchedule] = useState(null) const [savedStorage, setSavedStorage] = useState(null) const [scheduleLastError, setScheduleLastError] = useState(null) + const [allEnabled, setAllEnabled] = useState(true) const [selectedServers, setSelectedServers] = useState>(new Set()) const [manualOpen, setManualOpen] = useState(false) const [manualStep, setManualStep] = useState(1) @@ -142,7 +146,7 @@ function BackupsContent() { }).catch(error => failure("servers", error)) : Promise.resolve(), getBackupScheduleSettings(backendUrl, { signal }).then(row => { if (!active()) return - if (!scheduleDirty.current) { setSchedule(row); setSelectedServers(new Set(row.serverIds)) } + if (!scheduleDirty.current) { setSchedule(row); setSelectedServers(new Set(row.serverIds)); setAllEnabled(row.serverIds.length === 0) } setSavedSchedule(row); setScheduleLastError(row.lastError ?? null); success("schedule") }).catch(error => failure("schedule", error)), getBackupStorageSettings(backendUrl, { signal }).then(row => { @@ -157,89 +161,79 @@ function BackupsContent() { useEffect(() => { alive.current = true queueMicrotask(() => { if (alive.current) void loadLive() }) - return () => { alive.current = false; reader.current?.abort() } + return () => { alive.current = false; reader.current?.abort(); settingsController.current?.abort() } }, [loadLive]) + function startSettingsMutation() { + if (!alive.current || settingsLock.current || opBusy || backupJobId) return null + settingsLock.current = true + const abort = new AbortController() + settingsController.current = abort + reader.current?.abort(); revision.current += 1; setLoading(false) + setScheduleSaveBusy(true); setOpError(null) + return abort + } + function finishSettingsMutation(abort: AbortController) { + if (settingsController.current !== abort) return + settingsLock.current = false; settingsController.current = null + if (alive.current) { setScheduleSaveBusy(false); setTestBusy(false); setSyncBusy(false) } + } + function settingsConfirmed(resource: Resource) { + setResources(prev => ({ ...prev, [resource]: { known: true, error: null, receivedAt: new Date().toISOString() } })) + } async function handleSave() { - if (!canWrite || !current("schedule") || !current("storage") || scheduleSaveBusy) return - setScheduleSaveBusy(true) - setOpError(null) + if (!canWrite || !current("schedule") || !current("servers") || !canReadServers) return + const abort = startSettingsMutation(); if (!abort) return try { - const confirmedSchedule = await putBackupScheduleSettings(backendUrl, { - enabled: schedule.enabled, - frequency: schedule.frequency, - hour: schedule.hour, - minute: schedule.minute, - weekDay: schedule.weekDay, - monthDay: schedule.monthDay, - keepCount: schedule.keepCount, - format: "rsc", - serverIds: [...selectedServers], - }) - setSavedSchedule(confirmedSchedule) - const saved = await putBackupStorageSettings(backendUrl, { - provider: storage.provider, - s3Endpoint: storage.s3Endpoint, - s3Region: storage.s3Region, - s3Bucket: storage.s3Bucket, - s3Prefix: storage.s3Prefix, - s3AccessKeyId: storage.s3AccessKeyId, - s3SecretAccessKey: storage.s3SecretAccessKey || undefined, - s3ForcePathStyle: storage.s3ForcePathStyle, - keepLocalCopy: storage.keepLocalCopy, - }) - setSavedStorage(saved) - setStorage((prev) => ({ ...prev, s3SecretAccessKey: "" })) - toast.success("Настройки сохранены") - } catch (e) { - setOpError(e instanceof Error ? e.message : "Ошибка сохранения настроек") - } finally { - setScheduleSaveBusy(false) - } + const payload = backupSchedulePayload(schedule, [...selectedServers], allEnabled) + if (payload.serverIds?.some(id => !liveServers.some(server => server.id === id))) throw new Error("Выбранный сервер отсутствует; обновите или измените выбор") + const result = await putBackupScheduleSettings(backendUrl, payload, { signal: abort.signal }) + if (!alive.current || abort.signal.aborted) return + setSavedSchedule(result); setSchedule(result); setScheduleLastError(result.lastError ?? null); scheduleDirty.current = false + setSelectedServers(new Set(result.serverIds)); setAllEnabled(result.serverIds.length === 0); settingsConfirmed("schedule") + toast.success("Расписание сохранено") + } catch (error) { if (alive.current && !abort.signal.aborted) setOpError(userApiError(error)) } + finally { finishSettingsMutation(abort) } + } + async function handleSaveStorage() { + if (!canWrite || !current("storage")) return + const abort = startSettingsMutation(); if (!abort) return + try { + const result = await putBackupStorageSettings(backendUrl, backupStoragePayload(storage), { signal: abort.signal }) + if (!alive.current || abort.signal.aborted) return + setSavedStorage(result); setStorage(prev => ({ ...prev, ...result, s3SecretAccessKey: "" })); storageDirty.current = false; settingsConfirmed("storage") + toast.success("Хранилище сохранено") + } catch (error) { if (alive.current && !abort.signal.aborted) setOpError(userApiError(error)) } + finally { finishSettingsMutation(abort) } } - async function handleTestStorage() { if (!canWrite || !current("storage")) return + if (storageDirty.current) { setOpError("Сначала сохраните хранилище; проверка использует подтверждённые настройки"); return } + if (savedStorage?.provider !== "s3") { setOpError("Проверка доступна для сохранённого хранилища S3"); return } + const abort = startSettingsMutation(); if (!abort) return setTestBusy(true) try { - await putBackupStorageSettings(backendUrl, { - provider: storage.provider, - s3Endpoint: storage.s3Endpoint, - s3Region: storage.s3Region, - s3Bucket: storage.s3Bucket, - s3Prefix: storage.s3Prefix, - s3AccessKeyId: storage.s3AccessKeyId, - s3SecretAccessKey: storage.s3SecretAccessKey || undefined, - s3ForcePathStyle: storage.s3ForcePathStyle, - keepLocalCopy: storage.keepLocalCopy, - }) - const result = await testBackupStorage(backendUrl) - setSavedStorage(result) - toast.success("S3 доступен") - } catch (e) { - toast.error(e instanceof Error ? e.message : "Ошибка проверки S3") - try { - setSavedStorage(await getBackupStorageSettings(backendUrl)) - } catch { - /* ignore */ - } - } finally { - setTestBusy(false) - } + const result = await testBackupStorage(backendUrl, { signal: abort.signal }) + if (!alive.current || abort.signal.aborted) return + setSavedStorage(result); settingsConfirmed("storage") + if (result.lastTestError || !result.lastTestAt || result.provider !== "s3") throw new Error(result.lastTestError || "Проверка S3 не подтверждена") + toast.success("Доступ к S3 подтверждён") + } catch (error) { if (alive.current && !abort.signal.aborted) setOpError(userApiError(error)) } + finally { finishSettingsMutation(abort) } } - async function handleSyncStorage() { if (!canWrite || !current("storage")) return + if (storageDirty.current) { setOpError("Сначала сохраните хранилище; синхронизация использует подтверждённые настройки"); return } + if (savedStorage?.provider !== "s3") return + const abort = startSettingsMutation(); if (!abort) return setSyncBusy(true) try { - const result = await syncBackupsFromStorage(backendUrl) - toast.success(`Импортировано: ${result.imported}, пропущено: ${result.skipped}`) + const result = await syncBackupsFromStorage(backendUrl, { signal: abort.signal }) + if (!alive.current || abort.signal.aborted) return + toast.success("Импортировано: " + result.imported + ", пропущено: " + result.skipped) await loadLive() - } catch (e) { - toast.error(e instanceof Error ? e.message : "Ошибка синхронизации") - } finally { - setSyncBusy(false) - } + } catch (error) { if (alive.current && !abort.signal.aborted) setOpError(userApiError(error)) } + finally { finishSettingsMutation(abort) } } async function handleManualBackup() { @@ -353,7 +347,7 @@ function BackupsContent() { if (!confirmed.enabled) return "Расписание отключено" const time = String(confirmed.hour).padStart(2, "0") + ":" + String(confirmed.minute).padStart(2, "0") if (confirmed.frequency === "daily") return "Каждый день в " + time - if (confirmed.frequency === "weekly") return "Каждую неделю (" + WEEK_DAYS[confirmed.weekDay] + ") в " + time + if (confirmed.frequency === "weekly") return "Каждую неделю (" + WEEK_DAYS[Number(confirmed.weekDay)] + ") в " + time return confirmed.monthDay + "-го числа каждого месяца в " + time })() const confirmedStorage = isLive ? savedStorage : defaultStorageForm @@ -416,7 +410,7 @@ function BackupsContent() { {!canRead &&

Нет права mm:backups:read

} {isLive && !canReadServers &&

Каталог устройств недоступен без mm:servers:read.

} {isLive && canRead &&
- + {loading ? "Обновление данных…" : resources.backups.receivedAt ? "Получено: " + new Date(resources.backups.receivedAt).toLocaleString("ru-RU") : "Резервные копии пока неизвестны"}
} {isLive && (Object.keys(resources) as Resource[]).filter(resource => resources[resource].error).map(resource => @@ -424,6 +418,7 @@ function BackupsContent() { {({ backups: "История", servers: "Серверы", schedule: "Расписание", storage: "Хранилище" })[resource]}: {resources[resource].error} )} + {opError && Операция не подтверждена{opError}} } - {(!known("schedule") || !known("storage")) ?

Настройки резервных копий пока неизвестны

:
+ { scheduleDirty.current = true; setSelectedServers(new Set(liveServers.map(s => s.id))) }} + onSelectAll={() => { scheduleDirty.current = true; setAllEnabled(false); setSelectedServers(new Set(liveServers.map(s => s.id))) }} onClearServers={() => { scheduleDirty.current = true; setSelectedServers(new Set()) }} onSave={handleSave} + onSaveStorage={handleSaveStorage} + scheduleKnown={known("schedule")} + storageKnown={known("storage")} + scheduleWritable={canWrite && canReadServers && current("schedule") && current("servers")} + storageWritable={canWrite && current("storage")} + allEnabled={allEnabled} + onAllEnabledChange={value => { scheduleDirty.current = true; setAllEnabled(value) }} onTest={handleTestStorage} onSync={handleSyncStorage} saveBusy={scheduleSaveBusy} testBusy={testBusy} syncBusy={syncBusy} /> -
} +
diff --git a/components/backups/backups-settings.tsx b/components/backups/backups-settings.tsx index 11391de3..ae3c7444 100644 --- a/components/backups/backups-settings.tsx +++ b/components/backups/backups-settings.tsx @@ -20,11 +20,11 @@ export type StorageProvider = "local" | "s3" export type BackupScheduleForm = { enabled: boolean frequency: BackupFreq - hour: number - minute: number - weekDay: number - monthDay: number - keepCount: number + hour: number | string + minute: number | string + weekDay: number | string + monthDay: number | string + keepCount: number | string format: "rsc" | "backup" } @@ -93,6 +93,13 @@ export function BackupsSettings({ onSelectAll, onClearServers, onSave, + onSaveStorage, + scheduleKnown = true, + storageKnown = true, + scheduleWritable = true, + storageWritable = true, + allEnabled = false, + onAllEnabledChange, onTest, onSync, saveBusy, @@ -109,6 +116,13 @@ export function BackupsSettings({ onToggleServer: (id: string) => void onSelectAll: () => void onClearServers: () => void + onSaveStorage: () => void + scheduleKnown?: boolean + storageKnown?: boolean + scheduleWritable?: boolean + storageWritable?: boolean + allEnabled?: boolean + onAllEnabledChange: (value: boolean) => void onSave: () => void onTest: () => void onSync: () => void @@ -121,16 +135,17 @@ export function BackupsSettings({ return (
+ {!scheduleKnown ?

Расписание пока неизвестно

:

Автоматический бэкап

Создавать бэкапы по расписанию

- onScheduleChange("enabled", v)} /> + onScheduleChange("enabled", v)} />
-
+
onScheduleChange("monthDay", Math.min(28, Math.max(1, Number(e.target.value))))} + onChange={(e) => onScheduleChange("monthDay", e.target.value)} /> )} @@ -185,10 +201,12 @@ export function BackupsSettings({ min={0} max={23} className="font-mono w-20 text-center" - value={String(schedule.hour).padStart(2, "0")} - onChange={(e) => onScheduleChange("hour", Math.min(23, Math.max(0, Number(e.target.value))))} + aria-label="Час запуска" + value={schedule.hour} + onChange={(e) => onScheduleChange("hour", e.target.value)} /> : + onScheduleChange("minute", e.target.value)} />
{[0, 15, 30, 45].map((m) => (
+
} + {!storageKnown ?

Хранилище пока неизвестно

:
)} +
} +
+
onAllEnabledChange(value === true)} />Все включённые серверы, включая добавленные позже
{servers.map((s) => { const checked = selectedServers.has(s.id) @@ -388,6 +408,7 @@ export function BackupsSettings({ )} > onToggleServer(s.id)} aria-label={`Выбрать ${s.name}`} @@ -407,12 +428,14 @@ export function BackupsSettings({ Выбрано {selectedServers.size} из {servers.length} серверов

+
-
- +
) diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index 406877b3..afd1539d 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А.27 — достоверные формы расписания/хранилища резервных копий, затем создание/checkpoint/polling и остальные операции. Каталоги/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А.27.2 — валидация и подтверждение S3 настроек/теста, затем создание/checkpoint/polling и остальные операции. Расписание и independent save реализованы в 11А.27.1. Каталоги/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 3e5a5288..02bb655a 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А.26; пользователи/контейнеры/сертификаты приняты локально, каталоги/API/ручной runner резервных копий исправлены; далее 11А.27 | +| 11А | Пользователи, контейнеры, сертификаты и резервные копии | В работе: 11А.1–11А.27.1; пользователи/контейнеры/сертификаты приняты локально; резервные копии: каталоги/API/runner/расписание; далее 11А.27.2 | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | @@ -36,6 +36,15 @@ +## Блок 11А.27.1 — независимое сохранение настроек и расписания (2026-10-01) + +- Расписание и хранилище имеют отдельные кнопки/PUT/подтверждения. Отказ одного DTO не скрывает известную соседнюю форму. Общий settings lock защищает save/test/sync от повторов; поля заблокированы на время отправки, старый GET отменяется. Unmount отменяет мутацию и подавляет поздние toast/state. +- Числовой черновик сохраняет blank/дробный/выходящий за диапазон ввод: значения не clamp/parseInt. Валидация 0–23/0–59/1–28/1–365 до HTTP. Доступны произвольные минуты. Точный формат сохраняется; enabled binary backup требует явного выбора поддерживаемого .rsc. Недоступность .backup не заменяется обещанием «скоро». +- Пустой список serverIds обозначает явный режим всех включённых устройств, включая будущие; пустой ручной выбор не отправляется. Устаревшие выбранные IDs требуют изменения/обновления. Backend семантика расписания не изменена. +- API подтверждает отправленные поля и наличие нового секретного ключа. Ошибка остаётся в постоянном Alert/черновике; только подтверждённый save очищает secret. Test/sync S3 используют сохранённую конфигурацию и не делают скрытый PUT; dirty форма просит явно сохранить её. +- 10 helper/API unit, 12 data + 9 settings E2E (повтор после исправления a11y имени checkbox), 8 context E2E, полный TypeScript/профильный ESLint и production frontend прошли. Реальные PostgreSQL/RouterOS/S3 не проверены. +- **Следующий блок 11А.27.2:** валидация S3/доступные имена полей, корректная свежесть последнего теста при изменении конфигурации, секрет/невалидный ack/API error/test без PUT и профильная проверка. Затем создание/checkpoint/polling, остальные операции и визуальная приёмка. + ## Блок 11А.26 — результат операций отделён от событий (2026-10-01) - Ручной job и restore используют безопасную запись событий: отказ журнала не прерывает запуск, не превращает созданные файлы в failed и не возвращает ошибку после подтверждённого импорта RouterOS. Process job вынесен в тестируемую функцию; map задач локален экземпляру routes. diff --git a/docs/ux-ui-stage-11a.md b/docs/ux-ui-stage-11a.md index 650db026..aacb5a67 100644 --- a/docs/ux-ui-stage-11a.md +++ b/docs/ux-ui-stage-11a.md @@ -328,3 +328,12 @@ - Персональные ошибки остаются partial done с created/failures. Ошибка S3 загрузки сохраняет локальную копию и uploadError; итоговое событие имеет warning и число ошибок загрузки. Frontend polling пока требует отдельной доработки, чтобы не называть такой done полным успехом. - 3 fault unit job, HTTP handler проверки create/restore при отказах событий, invalid target/no side effect/partial/RBAC, test:backups (включая S3 client), backend production build, 6 frontend API unit, TypeScript/профильный lint прошли. Реальный PostgreSQL/RouterOS/S3 не проверен; jobs in-memory теряются после рестарта, requestId/idempotency ещё не реализованы. - **Следующий блок 11А.27:** достоверные формы расписания/хранилища, independent known состояния, dirty/секреты/partial save/общий mutation lock и S3 test без скрытого сохранения. Далее 11А.28 создание/checkpoint/polling; scheduler event/setup cleanup дополнительно проверить в 11А.26.1 или 11Б. + +## Блок 11А.27.1 — независимое сохранение настроек и расписания (2026-10-01) + +- Расписание и хранилище имеют отдельные кнопки/PUT/подтверждения. Отказ одного DTO не скрывает известную соседнюю форму. Общий settings lock защищает save/test/sync от повторов; поля заблокированы на время отправки, старый GET отменяется. Unmount отменяет мутацию и подавляет поздние toast/state. +- Числовой черновик сохраняет blank/дробный/выходящий за диапазон ввод: значения не clamp/parseInt. Валидация 0–23/0–59/1–28/1–365 до HTTP. Доступны произвольные минуты. Точный формат сохраняется; enabled binary backup требует явного выбора поддерживаемого .rsc. Недоступность .backup не заменяется обещанием «скоро». +- Пустой список serverIds обозначает явный режим всех включённых устройств, включая будущие; пустой ручной выбор не отправляется. Устаревшие выбранные IDs требуют изменения/обновления. Backend семантика расписания не изменена. +- API подтверждает отправленные поля и наличие нового секретного ключа. Ошибка остаётся в постоянном Alert/черновике; только подтверждённый save очищает secret. Test/sync S3 используют сохранённую конфигурацию и не делают скрытый PUT; dirty форма просит явно сохранить её. +- 10 helper/API unit, 12 data + 9 settings E2E (повтор после исправления a11y имени checkbox), 8 context E2E, полный TypeScript/профильный ESLint и production frontend прошли. Реальные PostgreSQL/RouterOS/S3 не проверены. +- **Следующий блок 11А.27.2:** валидация S3/доступные имена полей, корректная свежесть последнего теста при изменении конфигурации, секрет/невалидный ack/API error/test без PUT и профильная проверка. Затем создание/checkpoint/polling, остальные операции и визуальная приёмка. diff --git a/lib/backup-settings.test.ts b/lib/backup-settings.test.ts new file mode 100644 index 00000000..f48b82db --- /dev/null +++ b/lib/backup-settings.test.ts @@ -0,0 +1,18 @@ +import assert from "node:assert/strict" +import { test } from "node:test" +import { backupSchedulePayload } from "./backup-settings" +const form = { enabled: false, frequency: "daily" as const, hour: "0", minute: "7", weekDay: 0, monthDay: 1, keepCount: "365", format: "rsc" as const } +test("schedule preserves zero, arbitrary minute and explicit all-enabled semantics", () => { + const result = backupSchedulePayload(form, ["1"], true) + assert.equal(result.hour, 0); assert.equal(result.minute, 7); assert.equal(result.keepCount, 365); assert.deepEqual(result.serverIds, []) + assert.deepEqual(backupSchedulePayload(form, ["1"], false).serverIds, ["1"]) +}) +test("blank, fractional, negative and out-of-range numbers are rejected without normalization", () => { + for (const hour of ["", "1.5", "-1", "24", "1e1"]) assert.throws(() => backupSchedulePayload({ ...form, hour }, ["1"], false)) + assert.throws(() => backupSchedulePayload({ ...form, keepCount: "366" }, ["1"], false)) + assert.throws(() => backupSchedulePayload(form, [], false)) +}) +test("unsupported binary schedule requires an explicit format choice", () => { + assert.throws(() => backupSchedulePayload({ ...form, enabled: true, format: "backup" }, ["1"], false)) + assert.equal(backupSchedulePayload({ ...form, format: "backup" }, ["1"], false).format, "backup") +}) diff --git a/lib/backup-settings.ts b/lib/backup-settings.ts new file mode 100644 index 00000000..5dfb64c2 --- /dev/null +++ b/lib/backup-settings.ts @@ -0,0 +1,17 @@ +import { putBackupScheduleSettingsSchema, putBackupStorageSettingsSchema } from "@mmapp/contracts/backups" +import type { BackupScheduleForm, BackupStorageForm } from "@/components/backups/backups-settings" + +function integer(value: number | string, label: string, min: number, max: number): number { + if (!/^[0-9]+$/.test(String(value))) throw new Error(label + ": введите целое число") + const number = Number(value) + if (!Number.isSafeInteger(number) || number < min || number > max) throw new Error(label + ": допустимо от " + min + " до " + max) + return number +} +export function backupSchedulePayload(form: BackupScheduleForm, serverIds: string[], allEnabled: boolean) { + if (!allEnabled && !serverIds.length) throw new Error("Выберите серверы или режим всех включённых устройств") + if (form.enabled && form.format !== "rsc") throw new Error("Плановый binary backup пока не поддерживается; выберите .rsc явно") + return putBackupScheduleSettingsSchema.parse({ ...form, hour: integer(form.hour, "Час", 0, 23), minute: integer(form.minute, "Минута", 0, 59), weekDay: integer(form.weekDay, "День недели", 0, 6), monthDay: integer(form.monthDay, "День месяца", 1, 28), keepCount: integer(form.keepCount, "Число копий", 1, 365), serverIds: allEnabled ? [] : serverIds }) +} +export function backupStoragePayload(form: BackupStorageForm) { + return putBackupStorageSettingsSchema.parse({ provider: form.provider, s3Endpoint: form.s3Endpoint, s3Region: form.s3Region, s3Bucket: form.s3Bucket, s3Prefix: form.s3Prefix, s3AccessKeyId: form.s3AccessKeyId, s3SecretAccessKey: form.s3SecretAccessKey || undefined, s3ForcePathStyle: form.s3ForcePathStyle, keepLocalCopy: form.keepLocalCopy }) +} diff --git a/shared/api/backups.test.ts b/shared/api/backups.test.ts index f00d8883..f0169697 100644 --- a/shared/api/backups.test.ts +++ b/shared/api/backups.test.ts @@ -81,3 +81,12 @@ test("restore, S3 test and sync do not accept empty or malformed success", async globalThis.fetch = async () => json({ imported: -1, skipped: 0 }); await assert.rejects(() => syncBackupsFromStorage(base)) } finally { globalThis.fetch = original } }) + +test("settings acknowledgment must match submitted fields and confirm a new secret", async () => { + const original = globalThis.fetch + try { + globalThis.fetch = async () => json(schedule); await assert.rejects(() => putBackupScheduleSettings(base, { hour: 7 }), /не подтверждено/) + globalThis.fetch = async () => json(storage); await assert.rejects(() => putBackupStorageSettings(base, { provider: "s3" }), /не подтверждено/) + await assert.rejects(() => putBackupStorageSettings(base, { s3SecretAccessKey: "new-test-secret" }), /ключа/) + } finally { globalThis.fetch = original } +}) diff --git a/shared/api/backups.ts b/shared/api/backups.ts index 281a940f..1873ca28 100644 --- a/shared/api/backups.ts +++ b/shared/api/backups.ts @@ -46,16 +46,28 @@ export async function restoreBackup(baseUrl: string, id: string, options?: Reque export async function getBackupScheduleSettings(baseUrl: string, options?: RequestInit) { return backupScheduleSettingsDtoSchema.parse(await backupJson(baseUrl, "/api/backups/schedule", { ...options, method: "GET" })) } +function confirmSettings(result: Record, payload: Record) { + for (const [key, value] of Object.entries(payload)) { + if (value === undefined) continue + if (key === "s3SecretAccessKey") { + if (value && result.secretConfigured !== true) throw new Error("Сохранение ключа хранилища не подтверждено") + } else if (JSON.stringify(result[key]) !== JSON.stringify(value)) throw new Error("Сохранение настроек не подтверждено: ответ отличается от отправленных данных") + } +} export async function putBackupScheduleSettings(baseUrl: string, payload: Partial, options?: RequestInit) { const body = putBackupScheduleSettingsSchema.parse(payload) - return backupScheduleSettingsDtoSchema.parse(await backupJson(baseUrl, "/api/backups/schedule", { ...options, method: "PUT", body: JSON.stringify(body) })) + const result = backupScheduleSettingsDtoSchema.parse(await backupJson(baseUrl, "/api/backups/schedule", { ...options, method: "PUT", body: JSON.stringify(body) })) + confirmSettings(result, body) + return result } export async function getBackupStorageSettings(baseUrl: string, options?: RequestInit) { return backupStorageSettingsDtoSchema.parse(await backupJson(baseUrl, "/api/backups/storage", { ...options, method: "GET" })) } export async function putBackupStorageSettings(baseUrl: string, payload: PutBackupStorageSettings, options?: RequestInit) { const body = putBackupStorageSettingsSchema.parse(payload) - return backupStorageSettingsDtoSchema.parse(await backupJson(baseUrl, "/api/backups/storage", { ...options, method: "PUT", body: JSON.stringify(body) })) + const result = backupStorageSettingsDtoSchema.parse(await backupJson(baseUrl, "/api/backups/storage", { ...options, method: "PUT", body: JSON.stringify(body) })) + confirmSettings(result, body) + return result } export async function testBackupStorage(baseUrl: string, options?: RequestInit) { return backupStorageSettingsDtoSchema.parse(await backupJson(baseUrl, "/api/backups/storage/test", { ...options, method: "POST" })) diff --git a/tests/e2e/backups-context.spec.ts b/tests/e2e/backups-context.spec.ts index be272d33..e069b835 100644 --- a/tests/e2e/backups-context.spec.ts +++ b/tests/e2e/backups-context.spec.ts @@ -18,3 +18,21 @@ for (const change of ["backend", "mode"]) for (const fails of [false, true]) tes await expect(page.getByText("СТАРЫЙ БЭКАП.rsc", { exact: true })).toHaveCount(0) await expect(page.getByText(/СТАРАЯ ОШИБКА БЭКАПА/)).toHaveCount(0) }) + +for (const kind of ["schedule", "storage"]) for (const fails of [false, true]) test("Бэкапы: позднее сохранение/" + kind + "/" + fails, async ({ page }) => { + await setupBackups(page, headers) + let started = false, completed = false, release!: () => void; const gate = new Promise(r => release = r) + await page.route("**/api/backups/" + kind, 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: { ...r.request().postDataJSON(), secretConfigured: false, serverIds: ["1"] } }) } catch {} finally { completed = true } + }) + await page.goto("/backups"); await page.getByRole("tab", { name: "Настройки", exact: true }).click() + const label = kind === "schedule" ? "Сохранить расписание" : "Сохранить хранилище" + await page.getByRole("button", { name: label, 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("/backups"); release(); await expect.poll(() => completed).toBe(true) + await expect(page.getByText(backup.filename, { exact: true })).toBeVisible() + await expect(page.getByText(/СТАРОЕ СОХРАНЕНИЕ БЭКАПА/)).toHaveCount(0) + await expect(page.getByText(kind === "schedule" ? "Расписание сохранено" : "Хранилище сохранено", { exact: true })).toHaveCount(0) +}) diff --git a/tests/e2e/backups-data.spec.ts b/tests/e2e/backups-data.spec.ts index 04bebb18..8989e661 100644 --- a/tests/e2e/backups-data.spec.ts +++ b/tests/e2e/backups-data.spec.ts @@ -45,7 +45,7 @@ for (const serverRead of [false, true]) test("Бэкапы: read-only и server await expect(page.getByRole("button", { name: "Новый бэкап", exact: true })).toBeDisabled() await expect(page.getByRole("button", { name: "Удалить " + backup.filename, exact: true })).toBeDisabled() await page.getByRole("tab", { name: "Настройки", exact: true }).click() - await expect(page.getByRole("button", { name: "Сохранить настройки", exact: true })).toBeDisabled() + await expect(page.getByRole("button", { name: "Сохранить расписание", exact: true })).toBeDisabled() if (!serverRead) expect(serverRequests).toBe(0) }) diff --git a/tests/e2e/backups-settings.spec.ts b/tests/e2e/backups-settings.spec.ts new file mode 100644 index 00000000..eed0245f --- /dev/null +++ b/tests/e2e/backups-settings.spec.ts @@ -0,0 +1,65 @@ +import { test, expect, type Page } from "@playwright/test" +import { setupBackups, schedule, storage } from "./fixtures/backups" +async function open(page: Page) { await page.goto("/backups"); await page.getByRole("tab", { name: "Настройки", exact: true }).click(); await expect(page.getByRole("spinbutton", { name: "Час запуска" })).toBeVisible() } +const save = (page: Page) => page.getByRole("button", { name: "Сохранить расписание", exact: true }) +test("Бэкапы: save расписания не сохраняет хранилище", async ({ page }) => { + await setupBackups(page); let puts = 0, storagePuts = 0, body: Record = {} + await page.route("**/api/backups/storage", r => { if (r.request().method() === "PUT") storagePuts++; return r.fallback() }) + await page.route("**/api/backups/schedule", r => { if (r.request().method() !== "PUT") return r.fallback(); puts++; body = r.request().postDataJSON(); return r.fulfill({ json: { ...schedule, ...body } }) }) + await open(page); await page.getByRole("spinbutton", { name: "Час запуска" }).fill("7"); await page.getByRole("spinbutton", { name: "Минута запуска" }).fill("7") + await save(page).click(); await expect(page.getByText("Расписание сохранено", { exact: true })).toBeVisible() + expect(puts).toBe(1); expect(storagePuts).toBe(0); expect(body.hour).toBe(7); expect(body.minute).toBe(7); expect(body.format).toBe("rsc") +}) +for (const failure of ["API", "mismatch"]) test("Бэкапы: ошибка " + failure + " сохраняет расписание", async ({ page }) => { + await setupBackups(page) + await page.route("**/api/backups/schedule", r => r.request().method() !== "PUT" ? r.fallback() : r.fulfill(failure === "API" ? { status: 503, json: { error: "Расписание отказало" } } : { json: schedule })) + await open(page); await page.getByRole("spinbutton", { name: "Час запуска" }).fill("7"); await save(page).click() + await expect(page.getByText("Расписание сохранено", { exact: true })).toHaveCount(0) + await expect(page.getByRole("spinbutton", { name: "Час запуска" })).toHaveValue("7") + await expect(save(page)).toBeEnabled() +}) +test("Бэкапы: неверные числа и пустой явный выбор не отправляются", async ({ page }) => { + await setupBackups(page); let puts = 0 + await page.route("**/api/backups/schedule", r => { if (r.request().method() === "PUT") puts++; return r.fallback() }) + await open(page) + for (const value of ["", "1.5", "24"]) { await page.getByRole("spinbutton", { name: "Час запуска" }).fill(value); await save(page).click(); await expect(page.getByRole("spinbutton", { name: "Час запуска" })).toHaveValue(value) } + await page.getByRole("spinbutton", { name: "Час запуска" }).fill("0") + await page.getByRole("button", { name: "Сбросить", exact: true }).click(); await save(page).click(); expect(puts).toBe(0) +}) +test("Бэкапы: все включённые устройства передаются явно как пустой список", async ({ page }) => { + await setupBackups(page); let body: Record = {} + await page.route("**/api/backups/schedule", r => { if (r.request().method() !== "PUT") return r.fallback(); body = r.request().postDataJSON(); return r.fulfill({ json: { ...schedule, ...body } }) }) + await open(page); await page.getByRole("checkbox", { name: "Все включённые серверы", exact: true }).check(); await save(page).click() + await expect(page.getByText("Расписание сохранено", { exact: true })).toBeVisible(); expect(body.serverIds).toEqual([]) +}) +test("Бэкапы: общий lock не допускает второй PUT и блокирует поля", async ({ page }) => { + await setupBackups(page); let puts = 0, release!: () => void; const gate = new Promise(r => release = r) + await page.route("**/api/backups/schedule", async r => { if (r.request().method() !== "PUT") return r.fallback(); puts++; await gate; await r.fulfill({ json: { ...schedule, ...r.request().postDataJSON() } }).catch(() => {}) }) + await open(page); await save(page).dblclick() + await expect(page.getByRole("spinbutton", { name: "Час запуска" })).toBeDisabled() + await expect(page.getByRole("button", { name: "Сохранить хранилище", exact: true })).toBeDisabled() + release(); await expect(save(page)).toBeEnabled(); expect(puts).toBe(1) +}) +test("Бэкапы: отказ расписания не скрывает форму хранилища", async ({ page }) => { + await setupBackups(page); await page.route("**/api/backups/schedule", r => r.fulfill({ status: 503, json: { error: "Schedule unavailable" } })) + await page.goto("/backups"); await page.getByRole("tab", { name: "Настройки", exact: true }).click() + await expect(page.getByRole("button", { name: "Сохранить хранилище", exact: true })).toBeEnabled() + await expect(save(page)).toBeDisabled(); await expect(page.getByRole("spinbutton", { name: "Час запуска" })).toHaveCount(0) +}) +test("Бэкапы: проверка S3 не сохраняет черновик незаметно", async ({ page }) => { + await setupBackups(page); let mutations = 0 + await page.route("**/api/backups/storage**", r => { if (r.request().method() !== "GET") mutations++; return r.fallback() }) + await open(page); await page.getByRole("button", { name: "S3", exact: true }).click() + await page.getByPlaceholder("mikrotik-backups", { exact: true }).fill("draft-bucket") + await page.getByRole("button", { name: "Проверить", exact: true }).click() + await expect(page.getByRole("alert", { name: "Операция резервных копий" })).toContainText("Сначала сохраните хранилище; проверка использует подтверждённые настройки") + expect(mutations).toBe(0) + await expect(page.getByPlaceholder("mikrotik-backups", { exact: true })).toHaveValue("draft-bucket") +}) +test("Бэкапы: независимое подтверждение хранилища не отправляет расписание", async ({ page }) => { + await setupBackups(page); let schedulePuts = 0, storagePuts = 0 + await page.route("**/api/backups/schedule", r => { if (r.request().method() === "PUT") schedulePuts++; return r.fallback() }) + await page.route("**/api/backups/storage", r => { if (r.request().method() !== "PUT") return r.fallback(); storagePuts++; return r.fulfill({ json: { ...storage, ...r.request().postDataJSON() } }) }) + await open(page); await page.getByRole("button", { name: "Сохранить хранилище", exact: true }).click() + await expect(page.getByText("Хранилище сохранено", { exact: true })).toBeVisible(); expect(storagePuts).toBe(1); expect(schedulePuts).toBe(0) +})