From 30bd479a6f19c82c544ab22263379894237db89b Mon Sep 17 00:00:00 2001
From: Denozordec
Date: Thu, 1 Oct 2026 15:37:00 +0700
Subject: [PATCH] =?UTF-8?q?fix(backups):=20=D0=BF=D0=BE=D0=B4=D1=82=D0=B2?=
=?UTF-8?q?=D0=B5=D1=80=D0=B6=D0=B4=D0=B0=D1=82=D1=8C=20=D0=BD=D0=B0=D1=81?=
=?UTF-8?q?=D1=82=D1=80=D0=BE=D0=B9=D0=BA=D0=B8=20=D0=B8=20=D0=BF=D0=BE?=
=?UTF-8?q?=D1=81=D0=BB=D0=B5=D0=B4=D0=BD=D1=8E=D1=8E=20=D0=BF=D1=80=D0=BE?=
=?UTF-8?q?=D0=B2=D0=B5=D1=80=D0=BA=D1=83=20S3?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
app/(main)/backups/page.tsx | 22 +++++--
backend/package.json | 2 +-
backend/src/services/backup-dto.test.ts | 14 +++++
backend/src/services/backup-dto.ts | 9 +++
backend/src/services/backup-service.ts | 59 ++++++-------------
.../services/backup-storage-update.test.ts | 20 +++++++
backend/src/services/backup-storage-update.ts | 15 +++++
components/backups/backups-settings.tsx | 33 +++++++----
docs/ux-ui-plan.md | 2 +-
docs/ux-ui-progress.md | 10 +++-
docs/ux-ui-stage-11a.md | 8 +++
lib/backup-settings.test.ts | 10 +++-
lib/backup-settings.ts | 12 +++-
tests/e2e/backups-storage.spec.ts | 56 ++++++++++++++++++
14 files changed, 208 insertions(+), 64 deletions(-)
create mode 100644 backend/src/services/backup-dto.test.ts
create mode 100644 backend/src/services/backup-dto.ts
create mode 100644 backend/src/services/backup-storage-update.test.ts
create mode 100644 backend/src/services/backup-storage-update.ts
create mode 100644 tests/e2e/backups-storage.spec.ts
diff --git a/app/(main)/backups/page.tsx b/app/(main)/backups/page.tsx
index d75f85c2..09be9267 100644
--- a/app/(main)/backups/page.tsx
+++ b/app/(main)/backups/page.tsx
@@ -5,7 +5,7 @@ import { PageHeader } from "@/components/page-header"
import { backups as mockBackups, servers as mockServers, type Backup, type Server } from "@/lib/data"
import { createFilterQuery } from "@/components/reui/filters/filters-query"
import type { FilterQuery } from "@/components/reui/filters/filters-types"
-import type { BackupScheduleSettingsDto, BackupStorageSettingsDto } from "@mmapp/contracts/backups"
+import { backupStorageSettingsDtoSchema, type BackupScheduleSettingsDto, type BackupStorageSettingsDto } from "@mmapp/contracts/backups"
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
import { Alert, AlertAction, AlertDescription, AlertTitle } from "@/components/reui/alert"
import { Button } from "@/components/ui/button"
@@ -36,7 +36,7 @@ import {
testBackupStorage,
type BackupItem,
} from "@/shared/api/backups"
-import { requestJson, requestBlob } from "@/shared/api/http-client"
+import { ApiClientError, 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"
@@ -95,6 +95,7 @@ function BackupsContent() {
const [syncBusy, setSyncBusy] = useState(false)
const [schedule, setSchedule] = useState(defaultSchedule)
const [storage, setStorage] = useState(defaultStorageForm)
+ const [storageIsDirty, setStorageIsDirty] = useState(false)
const [savedSchedule, setSavedSchedule] = useState(null)
const [savedStorage, setSavedStorage] = useState(null)
const [scheduleLastError, setScheduleLastError] = useState(null)
@@ -108,7 +109,7 @@ function BackupsContent() {
const [restoreTarget, setRestoreTarget] = useState(null)
const setSched = (k: K, v: BackupScheduleForm[K]) => { scheduleDirty.current = true; setSchedule(s => ({ ...s, [k]: v })) }
- const setStore = (k: K, v: BackupStorageForm[K]) => { storageDirty.current = true; setStorage(s => ({ ...s, [k]: v })) }
+ const setStore = (k: K, v: BackupStorageForm[K]) => { if (k !== "showPassword") { storageDirty.current = true; setStorageIsDirty(true) } setStorage(s => ({ ...s, [k]: v })) }
const mapApiBackupToUi = useCallback((b: BackupItem): Backup => {
const kb = Math.round(b.sizeBytes / 1024)
@@ -199,9 +200,9 @@ function BackupsContent() {
if (!canWrite || !current("storage")) return
const abort = startSettingsMutation(); if (!abort) return
try {
- const result = await putBackupStorageSettings(backendUrl, backupStoragePayload(storage), { signal: abort.signal })
+ const result = await putBackupStorageSettings(backendUrl, backupStoragePayload(storage, savedStorage?.secretConfigured), { signal: abort.signal })
if (!alive.current || abort.signal.aborted) return
- setSavedStorage(result); setStorage(prev => ({ ...prev, ...result, s3SecretAccessKey: "" })); storageDirty.current = false; settingsConfirmed("storage")
+ setSavedStorage(result); setStorage(prev => ({ ...prev, ...result, s3SecretAccessKey: "" })); storageDirty.current = false; setStorageIsDirty(false); settingsConfirmed("storage")
toast.success("Хранилище сохранено")
} catch (error) { if (alive.current && !abort.signal.aborted) setOpError(userApiError(error)) }
finally { finishSettingsMutation(abort) }
@@ -218,7 +219,15 @@ function BackupsContent() {
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)) }
+ } catch (error) {
+ if (alive.current && !abort.signal.aborted) {
+ if (error instanceof ApiClientError && error.payload && typeof error.payload === "object" && "settings" in error.payload) {
+ const parsed = backupStorageSettingsDtoSchema.safeParse(error.payload.settings)
+ if (parsed.success) setSavedStorage(parsed.data)
+ }
+ setOpError(userApiError(error))
+ }
+ }
finally { finishSettingsMutation(abort) }
}
async function handleSyncStorage() {
@@ -522,6 +531,7 @@ function BackupsContent() {
onSaveStorage={handleSaveStorage}
scheduleKnown={known("schedule")}
storageKnown={known("storage")}
+ storageDirty={storageIsDirty}
scheduleWritable={canWrite && canReadServers && current("schedule") && current("servers")}
storageWritable={canWrite && current("storage")}
allEnabled={allEnabled}
diff --git a/backend/package.json b/backend/package.json
index 616bd691..63dbe5a5 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -24,7 +24,7 @@
"test:recursive-routes": "tsx src/routes/recursive-routes.test.ts && tsx src/services/recursive-route-catalog.test.ts",
"test:config-sync": "tsx src/services/config-apply-plan.test.ts && tsx src/services/entity-snapshots.test.ts",
"test:ros-file": "tsx src/services/ros-file-contents.test.ts",
- "test:backups": "tsx src/services/backup-job-execution.test.ts && tsx src/routes/backups-operations.test.ts && tsx src/services/s3-backup-client.test.ts",
+ "test:backups": "tsx src/services/backup-dto.test.ts && tsx src/services/backup-storage-update.test.ts && tsx src/services/backup-job-execution.test.ts && tsx src/routes/backups-operations.test.ts && tsx src/services/s3-backup-client.test.ts",
"test:live-maps": "tsx src/services/ospf-route-parse.test.ts && tsx src/services/vxlan-live.test.ts && tsx src/services/containers-live.test.ts && tsx src/services/firewall-poll.test.ts && tsx src/services/gre-catalog-poll.test.ts && tsx src/routes/filters-gre-catalog.test.ts && tsx src/routes/bgp-catalog.test.ts && tsx src/services/bgp-catalog-parse.test.ts && tsx src/routes/ospf-catalog.test.ts && tsx src/services/ospf-observed-values.test.ts",
"test": "npm run test:probes && npm run test:exec && npm run test:alert-engine && npm run test:auth && npm run test:wireguard && npm run test:traffic-rate && npm run test:traffic-flow && npm run test:users && npm run test:pg && npm run test:backups && npm run test:live-maps && npm run test:ospf-optimizer && npm run test:recursive-routes && npm run test:config-sync && npm run test:ros-file && npm run test:containers && npm run test:certificates",
"test:geoip": "tsx src/services/traffic-flow-geoip.test.ts",
diff --git a/backend/src/services/backup-dto.test.ts b/backend/src/services/backup-dto.test.ts
new file mode 100644
index 00000000..aec25de8
--- /dev/null
+++ b/backend/src/services/backup-dto.test.ts
@@ -0,0 +1,14 @@
+import assert from "node:assert/strict"
+import { test } from "node:test"
+import { mapBackupEntry } from "./backup-dto.js"
+const row = { id: "backup", serverId: 1, serverName: "router", filename: "config.rsc", sizeBytes: 0, createdAt: "2026-10-01 07:00:00.123+07", kind: "manual" as const, notes: null, storage: "local" as const, s3Key: null, s3Etag: null, uploadError: null }
+test("PostgreSQL timestamp format is normalized to ISO with the same instant", () => {
+ const result = mapBackupEntry(row); assert.equal(result.createdAt, "2026-10-01T00:00:00.123Z"); assert.equal(result.sizeBytes, 0); assert.equal(result.serverId, 1)
+})
+test("removed server and partial S3 failure remain explicit", () => {
+ const result = mapBackupEntry({ ...row, serverId: null, uploadError: "S3 unavailable" })
+ assert.equal(result.serverId, null); assert.equal(result.storage, "local"); assert.equal(result.uploadError, "S3 unavailable")
+})
+test("invalid timestamps and sizes do not turn into truthful backup metadata", () => {
+ assert.throws(() => mapBackupEntry({ ...row, createdAt: "bad" })); assert.throws(() => mapBackupEntry({ ...row, sizeBytes: -1 }))
+})
diff --git a/backend/src/services/backup-dto.ts b/backend/src/services/backup-dto.ts
new file mode 100644
index 00000000..485f7b6c
--- /dev/null
+++ b/backend/src/services/backup-dto.ts
@@ -0,0 +1,9 @@
+import { backupItemDtoSchema } from "@mmapp/contracts/backups"
+import type { backupEntries } from "../db/schema.js"
+export function mapBackupEntry(row: typeof backupEntries.$inferSelect) {
+ return backupItemDtoSchema.parse({
+ id: row.id, serverId: row.serverId, serverName: row.serverName, filename: row.filename,
+ sizeBytes: row.sizeBytes, createdAt: new Date(row.createdAt).toISOString(), kind: row.kind,
+ notes: row.notes ?? undefined, storage: row.storage, s3Key: row.s3Key, uploadError: row.uploadError,
+ })
+}
diff --git a/backend/src/services/backup-service.ts b/backend/src/services/backup-service.ts
index 52ba82a9..a2a9e4b9 100644
--- a/backend/src/services/backup-service.ts
+++ b/backend/src/services/backup-service.ts
@@ -1,12 +1,14 @@
import { randomUUID } from "node:crypto"
import { mkdir, rm, stat, writeFile, readFile } from "node:fs/promises"
import path from "node:path"
-import { desc, eq } from "drizzle-orm"
+import { and, desc, eq } from "drizzle-orm"
import type {
BackupScheduleSettingsDto,
BackupStorageSettingsDto,
PutBackupStorageSettings,
} from "@mmapp/contracts/backups"
+import { mapBackupEntry } from "./backup-dto.js"
+import { planBackupStorageUpdate } from "./backup-storage-update.js"
import { db } from "../db/index.js"
import { parseJsonArray } from "../db/json.js"
import { backupEntries, backupScheduleSettings, backupStorageSettings } from "../db/schema.js"
@@ -45,21 +47,7 @@ export type BackupMeta = {
type BackupRow = typeof backupEntries.$inferSelect
-function rowToMeta(row: BackupRow): BackupMeta {
- return {
- id: row.id,
- serverId: row.serverId,
- serverName: row.serverName,
- filename: row.filename,
- sizeBytes: row.sizeBytes,
- createdAt: row.createdAt,
- kind: row.kind,
- notes: row.notes ?? undefined,
- storage: row.storage ?? "local",
- s3Key: row.s3Key,
- uploadError: row.uploadError,
- }
-}
+function rowToMeta(row: BackupRow): BackupMeta { return mapBackupEntry(row) }
export async function ensureBackupStorage(): Promise {
await mkdir(BACKUPS_DIR, { recursive: true })
@@ -260,19 +248,7 @@ export async function updateBackupStorageSettings(
): Promise {
const prev = await getBackupStorageSettingsRow()
const now = new Date().toISOString()
- const secret = patch.s3SecretAccessKey
- const next = {
- provider: patch.provider ?? prev.provider,
- s3Endpoint: patch.s3Endpoint ?? prev.s3Endpoint,
- s3Region: patch.s3Region ?? prev.s3Region,
- s3Bucket: patch.s3Bucket ?? prev.s3Bucket,
- s3Prefix: patch.s3Prefix ?? prev.s3Prefix,
- s3AccessKeyId: patch.s3AccessKeyId ?? prev.s3AccessKeyId,
- s3SecretAccessKey: secret && secret.length > 0 ? secret : prev.s3SecretAccessKey,
- s3ForcePathStyle: patch.s3ForcePathStyle ?? prev.s3ForcePathStyle,
- keepLocalCopy: patch.keepLocalCopy ?? prev.keepLocalCopy,
- updatedAt: now,
- }
+ const next = planBackupStorageUpdate(prev, patch, now)
if ((await db.select().from(backupStorageSettings).where(eq(backupStorageSettings.id, SETTINGS_ID)).limit(1))[0]) {
await db.update(backupStorageSettings).set(next).where(eq(backupStorageSettings.id, SETTINGS_ID))
} else {
@@ -307,26 +283,25 @@ export async function testBackupStorageConnection(): Promise {
+ const result = planBackupStorageUpdate(row, { s3SecretAccessKey: "", s3Bucket: row.s3Bucket }, now)
+ assert.equal(result.s3SecretAccessKey, row.s3SecretAccessKey); assert.equal(result.lastTestAt, row.lastTestAt)
+ assert.equal(result.updatedAt, now)
+})
+test("every effective S3 configuration change invalidates previous test metadata", () => {
+ for (const patch of [{ provider: "local" as const }, { s3Endpoint: "https://other.example.test" }, { s3Region: "new" }, { s3Bucket: "new" }, { s3Prefix: "new" }, { s3AccessKeyId: "new" }, { s3SecretAccessKey: "new-test-secret" }, { s3ForcePathStyle: true }]) {
+ const result = planBackupStorageUpdate({ ...row, lastTestError: "old failure" }, patch, now)
+ assert.equal(result.lastTestAt, null); assert.equal(result.lastTestError, null)
+ }
+})
+test("local retention does not invent a connection change or discard a failed test", () => {
+ const result = planBackupStorageUpdate({ ...row, lastTestError: "failed" }, { keepLocalCopy: false }, now)
+ assert.equal(result.keepLocalCopy, false); assert.equal(result.lastTestAt, row.lastTestAt); assert.equal(result.lastTestError, "failed")
+})
diff --git a/backend/src/services/backup-storage-update.ts b/backend/src/services/backup-storage-update.ts
new file mode 100644
index 00000000..535a2365
--- /dev/null
+++ b/backend/src/services/backup-storage-update.ts
@@ -0,0 +1,15 @@
+import type { BackupStorageSettingsDto, PutBackupStorageSettings } from "@mmapp/contracts/backups"
+type StorageRecord = Omit & { s3SecretAccessKey: string }
+/** A test of previous credentials cannot confirm the new configuration. */
+export function planBackupStorageUpdate(prev: StorageRecord, patch: PutBackupStorageSettings, updatedAt: string) {
+ const next = {
+ provider: patch.provider ?? prev.provider, s3Endpoint: patch.s3Endpoint ?? prev.s3Endpoint, s3Region: patch.s3Region ?? prev.s3Region,
+ s3Bucket: patch.s3Bucket ?? prev.s3Bucket, s3Prefix: patch.s3Prefix ?? prev.s3Prefix, s3AccessKeyId: patch.s3AccessKeyId ?? prev.s3AccessKeyId,
+ s3SecretAccessKey: patch.s3SecretAccessKey && patch.s3SecretAccessKey.length > 0 ? patch.s3SecretAccessKey : prev.s3SecretAccessKey,
+ s3ForcePathStyle: patch.s3ForcePathStyle ?? prev.s3ForcePathStyle, keepLocalCopy: patch.keepLocalCopy ?? prev.keepLocalCopy,
+ lastTestAt: prev.lastTestAt ?? null, lastTestError: prev.lastTestError ?? null, updatedAt,
+ }
+ const connectionFields = ["provider", "s3Endpoint", "s3Region", "s3Bucket", "s3Prefix", "s3AccessKeyId", "s3SecretAccessKey", "s3ForcePathStyle"] as const
+ if (connectionFields.some(key => next[key] !== prev[key])) { next.lastTestAt = null; next.lastTestError = null }
+ return next
+}
diff --git a/components/backups/backups-settings.tsx b/components/backups/backups-settings.tsx
index ae3c7444..9522a200 100644
--- a/components/backups/backups-settings.tsx
+++ b/components/backups/backups-settings.tsx
@@ -65,15 +65,17 @@ export const defaultStorageForm: BackupStorageForm = {
showPassword: false,
}
-function storageStatus(saved: BackupStorageSettingsDto | null, form: BackupStorageForm) {
- if (form.provider === "local") {
+function storageStatus(saved: BackupStorageSettingsDto | null, dirty: boolean) {
+ if (dirty) return { label: "Не сохранено", variant: "warning-light" as const }
+ if (!saved) return { label: "Демонстрация", variant: "secondary" as const }
+ if (saved.provider === "local") {
return { label: "Локально", variant: "secondary" as const }
}
if (saved?.lastTestError) {
return { label: "Ошибка", variant: "destructive-light" as const }
}
if (saved?.lastTestAt && !saved.lastTestError) {
- return { label: "Connected", variant: "success-light" as const }
+ return { label: "Проверено ранее", variant: "success-light" as const }
}
if (saved?.secretConfigured && saved.s3Bucket) {
return { label: "Не проверено", variant: "warning-light" as const }
@@ -96,6 +98,7 @@ export function BackupsSettings({
onSaveStorage,
scheduleKnown = true,
storageKnown = true,
+ storageDirty = false,
scheduleWritable = true,
storageWritable = true,
allEnabled = false,
@@ -119,6 +122,7 @@ export function BackupsSettings({
onSaveStorage: () => void
scheduleKnown?: boolean
storageKnown?: boolean
+ storageDirty?: boolean
scheduleWritable?: boolean
storageWritable?: boolean
allEnabled?: boolean
@@ -130,7 +134,7 @@ export function BackupsSettings({
testBusy: boolean
syncBusy: boolean
}) {
- const status = storageStatus(savedStorage, storage)
+ const status = storageStatus(savedStorage, storageDirty)
const secretPlaceholder = savedStorage?.secretConfigured ? "•••••••• (сохранён)" : "••••••••"
return (
@@ -260,6 +264,7 @@ export function BackupsSettings({
}
contentClassName="px-5 py-5 flex flex-col gap-5"
>
+ {savedStorage?.lastTestAt && Последняя проверка: {new Date(savedStorage.lastTestAt).toLocaleString("ru-RU")}. Текущая доступность может измениться.
}
onStorageChange("s3Endpoint", e.target.value)}
/>
@@ -290,7 +296,8 @@ export function BackupsSettings({
onStorageChange("s3Region", e.target.value)}
/>
@@ -298,7 +305,8 @@ export function BackupsSettings({
onStorageChange("s3Bucket", e.target.value)}
/>
@@ -307,6 +315,7 @@ export function BackupsSettings({
onStorageChange("s3Prefix", e.target.value)}
/>
@@ -316,7 +325,8 @@ export function BackupsSettings({
onStorageChange("s3AccessKeyId", e.target.value)}
/>
@@ -327,7 +337,8 @@ export function BackupsSettings({
className="font-mono pr-14"
autoComplete="new-password"
placeholder={secretPlaceholder}
- value={storage.s3SecretAccessKey}
+ aria-label="Secret key"
+ value={storage.s3SecretAccessKey}
onChange={(e) => onStorageChange("s3SecretAccessKey", e.target.value)}
/>
Нужен для MinIO и части совместимых API
- onStorageChange("s3ForcePathStyle", v)} />
+ onStorageChange("s3ForcePathStyle", v)} />
Оставлять локальную копию
После успешной загрузки в S3
-
onStorageChange("keepLocalCopy", v)} />
+ onStorageChange("keepLocalCopy", v)} />
{savedStorage?.lastTestError ? (
{savedStorage.lastTestError}
@@ -425,7 +436,7 @@ export function BackupsSettings({
})}
- Выбрано {selectedServers.size} из {servers.length} серверов
+ {allEnabled ? "Все включённые устройства, включая добавленные позже" : "Выбрано " + selectedServers.size + " из " + servers.length + " серверов"}
diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md
index afd1539d..5e27dffa 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.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А ещё не приняты.
+Актуальная точка продолжения: 11А.28 — создание/checkpoint/polling резервных копий и подтверждение partial результата, затем остальные операции/URL/приёмка. Настройки расписания/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 02bb655a..716b2ad6 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А.27.1; пользователи/контейнеры/сертификаты приняты локально; резервные копии: каталоги/API/runner/расписание; далее 11А.27.2 |
+| 11А | Пользователи, контейнеры, сертификаты и резервные копии | В работе: 11А.1–11А.27.2; пользователи/контейнеры/сертификаты приняты локально; резервные копии: каталоги/API/runner/настройки; далее 11А.28 |
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
@@ -36,6 +36,14 @@
+## Блок 11А.27.2 — достоверные настройки и последняя проверка S3 (2026-10-01)
+
+- S3 форма валидирует обязательные Bucket/Access key/Secret key или наличие сохранённого секрета, HTTP(S) Endpoint. Пустое secret поле оставляет существующий ключ; пробельный новый ключ отклоняется. Поля и toggles имеют доступные имена. Dirty настройка имеет статус «Не сохранено», показ/скрытие секрета не считается изменением конфигурации.
+- Backend сбрасывает lastTestAt/error при фактическом изменении S3-конфигурации/ключей. Test запись проверяет updatedAt через условный UPDATE; изменившаяся конфигурация требует повторной проверки. UI показывает «Проверено ранее» и дату, не Connected; API error с подтверждённым settings обновляет последний receipt. Реальный PostgreSQL optimistic UPDATE ещё требует внешней приёмки.
+- Совместимость строгого backup API с PostgreSQL подтверждена mapper unit: raw timestamp преобразуется в ISO того же момента. Null server/0 byte/uploadError сохраняются; invalid дата/размер отклоняются. Сравнение timestamp receipt учитывает представление PostgreSQL, а не только строковое равенство.
+- 28 data/settings/storage E2E, 11 frontend helper/API unit, 3 DTO + 3 storage update + 3 job fault unit и HTTP/S3 профиль, frontend/backend production build, TypeScript/профильный ESLint прошли. Реальные PostgreSQL/RouterOS/S3 и конкурентные внешние клиенты не проверены.
+- **Следующий блок 11А.28:** single-flight создание, immutable цель/checkpoint, наблюдение без setInterval гонок и ложного полного успеха при failures/uploadError. Jobs сейчас in-memory: рестарт backend означает потерю наблюдения; автоматический повтор неизвестного POST недопустим. Затем delete/restore/download, URL, scheduler cleanup и визуальная приёмка.
+
## Блок 11А.27.1 — независимое сохранение настроек и расписания (2026-10-01)
- Расписание и хранилище имеют отдельные кнопки/PUT/подтверждения. Отказ одного DTO не скрывает известную соседнюю форму. Общий settings lock защищает save/test/sync от повторов; поля заблокированы на время отправки, старый GET отменяется. Unmount отменяет мутацию и подавляет поздние toast/state.
diff --git a/docs/ux-ui-stage-11a.md b/docs/ux-ui-stage-11a.md
index aacb5a67..a9b966fc 100644
--- a/docs/ux-ui-stage-11a.md
+++ b/docs/ux-ui-stage-11a.md
@@ -337,3 +337,11 @@
- 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А.27.2 — достоверные настройки и последняя проверка S3 (2026-10-01)
+
+- S3 форма валидирует обязательные Bucket/Access key/Secret key или наличие сохранённого секрета, HTTP(S) Endpoint. Пустое secret поле оставляет существующий ключ; пробельный новый ключ отклоняется. Поля и toggles имеют доступные имена. Dirty настройка имеет статус «Не сохранено», показ/скрытие секрета не считается изменением конфигурации.
+- Backend сбрасывает lastTestAt/error при фактическом изменении S3-конфигурации/ключей. Test запись проверяет updatedAt через условный UPDATE; изменившаяся конфигурация требует повторной проверки. UI показывает «Проверено ранее» и дату, не Connected; API error с подтверждённым settings обновляет последний receipt. Реальный PostgreSQL optimistic UPDATE ещё требует внешней приёмки.
+- Совместимость строгого backup API с PostgreSQL подтверждена mapper unit: raw timestamp преобразуется в ISO того же момента. Null server/0 byte/uploadError сохраняются; invalid дата/размер отклоняются. Сравнение timestamp receipt учитывает представление PostgreSQL, а не только строковое равенство.
+- 28 data/settings/storage E2E, 11 frontend helper/API unit, 3 DTO + 3 storage update + 3 job fault unit и HTTP/S3 профиль, frontend/backend production build, TypeScript/профильный ESLint прошли. Реальные PostgreSQL/RouterOS/S3 и конкурентные внешние клиенты не проверены.
+- **Следующий блок 11А.28:** single-flight создание, immutable цель/checkpoint, наблюдение без setInterval гонок и ложного полного успеха при failures/uploadError. Jobs сейчас in-memory: рестарт backend означает потерю наблюдения; автоматический повтор неизвестного POST недопустим. Затем delete/restore/download, URL, scheduler cleanup и визуальная приёмка.
diff --git a/lib/backup-settings.test.ts b/lib/backup-settings.test.ts
index f48b82db..2ca8ec26 100644
--- a/lib/backup-settings.test.ts
+++ b/lib/backup-settings.test.ts
@@ -1,6 +1,6 @@
import assert from "node:assert/strict"
import { test } from "node:test"
-import { backupSchedulePayload } from "./backup-settings"
+import { backupSchedulePayload, backupStoragePayload } 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)
@@ -16,3 +16,11 @@ 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")
})
+
+const storageForm = { provider: "s3" as const, s3Endpoint: "", s3Region: "", s3Bucket: "bucket", s3Prefix: "", s3AccessKeyId: "key", s3SecretAccessKey: "", s3ForcePathStyle: false, keepLocalCopy: false, showPassword: false }
+test("S3 requires complete fields, endpoint protocol and a new or existing secret", () => {
+ assert.throws(() => backupStoragePayload(storageForm))
+ assert.equal(backupStoragePayload(storageForm, true).s3SecretAccessKey, undefined)
+ assert.equal(backupStoragePayload({ ...storageForm, s3SecretAccessKey: "new-test-secret" }).s3SecretAccessKey, "new-test-secret")
+ for (const patch of [{ s3Bucket: " " }, { s3AccessKeyId: " " }, { s3Endpoint: "invalid" }, { s3Endpoint: "ftp://example.test" }, { s3SecretAccessKey: " " }]) assert.throws(() => backupStoragePayload({ ...storageForm, ...patch }, true))
+})
diff --git a/lib/backup-settings.ts b/lib/backup-settings.ts
index 5dfb64c2..36718fcd 100644
--- a/lib/backup-settings.ts
+++ b/lib/backup-settings.ts
@@ -12,6 +12,16 @@ export function backupSchedulePayload(form: BackupScheduleForm, serverIds: strin
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) {
+export function backupStoragePayload(form: BackupStorageForm, secretConfigured = false) {
+ if (form.provider === "s3") {
+ if (!form.s3Bucket.trim() || !form.s3AccessKeyId.trim()) throw new Error("Для S3 заполните Bucket и Access key")
+ if (!form.s3SecretAccessKey.trim() && !secretConfigured) throw new Error("Для S3 требуется Secret key")
+ if (form.s3SecretAccessKey && !form.s3SecretAccessKey.trim()) throw new Error("Secret key не может состоять только из пробелов")
+ if (form.s3Endpoint) {
+ let endpoint: URL
+ try { endpoint = new URL(form.s3Endpoint) } catch { throw new Error("Endpoint должен быть полным URL http/https") }
+ if (!["http:", "https:"].includes(endpoint.protocol)) throw new Error("Endpoint должен использовать http/https")
+ }
+ }
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/tests/e2e/backups-storage.spec.ts b/tests/e2e/backups-storage.spec.ts
new file mode 100644
index 00000000..e567742f
--- /dev/null
+++ b/tests/e2e/backups-storage.spec.ts
@@ -0,0 +1,56 @@
+import { test, expect, type Page } from "@playwright/test"
+import { setupBackups, storage } from "./fixtures/backups"
+const s3 = { ...storage, provider: "s3", s3Bucket: "bucket", s3AccessKeyId: "test-access", secretConfigured: true, lastTestAt: "2026-10-01T00:00:00Z", lastTestError: null }
+async function open(page: Page) { await page.goto("/backups"); await page.getByRole("tab", { name: "Настройки", exact: true }).click(); await expect(page.getByRole("button", { name: "Сохранить хранилище", exact: true })).toBeEnabled() }
+const save = (page: Page) => page.getByRole("button", { name: "Сохранить хранилище", exact: true })
+for (const failure of ["API", "malformed", "unconfirmed secret"]) test("Бэкапы S3: " + failure + " сохраняет секрет и черновик", async ({ page }) => {
+ await setupBackups(page); let puts = 0
+ await page.route("**/api/backups/storage", r => {
+ if (r.request().method() !== "PUT") return r.fulfill({ json: s3 })
+ puts++; const body = r.request().postDataJSON()
+ return r.fulfill(failure === "API" ? { status: 503, json: { error: "S3 save failed" } } : { json: failure === "malformed" ? {} : { ...s3, ...body, secretConfigured: false } })
+ })
+ await open(page); await page.getByRole("textbox", { name: "Bucket", exact: true }).fill("draft-bucket")
+ await page.getByLabel("Secret key", { exact: true }).fill("new-test-secret")
+ await save(page).click(); await expect(save(page)).toBeEnabled(); expect(puts).toBe(1)
+ await expect(page.getByLabel("Secret key", { exact: true })).toHaveValue("new-test-secret")
+ await expect(page.getByRole("textbox", { name: "Bucket", exact: true })).toHaveValue("draft-bucket")
+ await expect(page.getByRole("alert", { name: "Операция резервных копий" })).toBeVisible()
+ await expect(page.getByText("Хранилище сохранено", { exact: true })).toHaveCount(0)
+})
+test("Бэкапы S3: подтверждённый secret save очищает только новый секрет", async ({ page }) => {
+ await setupBackups(page); let body: Record = {}
+ await page.route("**/api/backups/storage", r => {
+ if (r.request().method() !== "PUT") return r.fulfill({ json: s3 })
+ body = r.request().postDataJSON(); return r.fulfill({ json: { ...s3, ...body, secretConfigured: true, lastTestAt: null, lastTestError: null } })
+ })
+ await open(page); await page.getByLabel("Secret key", { exact: true }).fill("new-test-secret"); await save(page).click()
+ await expect(page.getByText("Хранилище сохранено", { exact: true })).toBeVisible(); await expect(page.getByLabel("Secret key", { exact: true })).toHaveValue("")
+ expect(body.s3SecretAccessKey).toBe("new-test-secret"); await expect(page.getByText("Проверено ранее", { exact: true })).toHaveCount(0)
+})
+test("Бэкапы S3: незаполненный S3 и неверный Endpoint не отправляются", async ({ page }) => {
+ await setupBackups(page); let puts = 0
+ await page.route("**/api/backups/storage", r => { if (r.request().method() === "PUT") puts++; return r.fallback() })
+ await open(page); await page.getByRole("button", { name: "S3", exact: true }).click(); await save(page).click()
+ await expect(page.getByRole("alert", { name: "Операция резервных копий" })).toContainText("Bucket")
+ await page.getByLabel("Bucket", { exact: true }).fill("bucket"); await page.getByLabel("Access key", { exact: true }).fill("test-access"); await page.getByLabel("Secret key", { exact: true }).fill("test-secret")
+ await page.getByLabel("Endpoint", { exact: true }).fill("ftp://example.test"); await save(page).click()
+ await expect(page.getByRole("alert", { name: "Операция резервных копий" })).toContainText("http/https"); expect(puts).toBe(0)
+})
+test("Бэкапы S3: проверка подтверждённого хранилища делает только POST test", async ({ page }) => {
+ await setupBackups(page); let puts = 0, tests = 0
+ await page.route("**/api/backups/storage", r => { if (r.request().method() === "PUT") puts++; return r.fulfill({ json: s3 }) })
+ await page.route("**/api/backups/storage/test", r => { tests++; return r.fulfill({ json: { ...s3, lastTestAt: "2026-10-01T01:00:00Z" } }) })
+ await open(page); await expect(page.getByText("Проверено ранее", { exact: true })).toBeVisible()
+ await expect(page.getByText("Connected", { exact: true })).toHaveCount(0)
+ await page.getByRole("button", { name: "Проверить", exact: true }).click()
+ await expect(page.getByText("Доступ к S3 подтверждён", { exact: true })).toBeVisible(); expect(tests).toBe(1); expect(puts).toBe(0)
+})
+test("Бэкапы S3: отказ теста показывает backend receipt, не старый успех", async ({ page }) => {
+ await setupBackups(page); await page.route("**/api/backups/storage", r => r.fulfill({ json: s3 }))
+ await page.route("**/api/backups/storage/test", r => r.fulfill({ status: 400, json: { error: "S3 denied", settings: { ...s3, lastTestAt: "2026-10-01T01:00:00Z", lastTestError: "S3 denied" } } }))
+ await open(page); await page.getByRole("button", { name: "Проверить", exact: true }).click()
+ await expect(page.getByRole("alert", { name: "Операция резервных копий" })).toContainText("S3 denied")
+ await expect(page.getByText("Проверено ранее", { exact: true })).toHaveCount(0)
+ await expect(page.getByText("Доступ к S3 подтверждён", { exact: true })).toHaveCount(0)
+})