From 39570656c9766398a24fcc3a087e357304bf2bbe Mon Sep 17 00:00:00 2001 From: Denozordec Date: Thu, 1 Oct 2026 16:22:06 +0700 Subject: [PATCH] =?UTF-8?q?fix(backups):=20=D0=BD=D0=B5=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=B2=D1=82=D0=BE=D1=80=D1=8F=D1=82=D1=8C=20=D0=B8=D0=BC=D0=BF?= =?UTF-8?q?=D0=BE=D1=80=D1=82=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=20=D0=BF?= =?UTF-8?q?=D0=BE=D1=82=D0=B5=D1=80=D0=B8=20=D0=BE=D1=82=D0=B2=D0=B5=D1=82?= =?UTF-8?q?=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/package.json | 2 +- backend/src/routes/backups-operations.test.ts | 6 ++++- backend/src/routes/backups.ts | 6 +++-- backend/src/services/backup-restore.test.ts | 24 +++++++++++++++++++ backend/src/services/backup-restore.ts | 6 +++++ backend/src/services/backup-service.ts | 3 ++- backend/src/services/mikrotik.ts | 3 ++- docs/ux-ui-plan.md | 2 +- docs/ux-ui-progress.md | 10 +++++++- docs/ux-ui-stage-11a.md | 9 +++++++ 10 files changed, 63 insertions(+), 8 deletions(-) create mode 100644 backend/src/services/backup-restore.test.ts create mode 100644 backend/src/services/backup-restore.ts diff --git a/backend/package.json b/backend/package.json index 041c0e96..37b765cb 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-deletion.test.ts && 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:backups": "tsx src/services/backup-restore.test.ts && tsx src/services/backup-deletion.test.ts && 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/routes/backups-operations.test.ts b/backend/src/routes/backups-operations.test.ts index e57c5567..f9455ef8 100644 --- a/backend/src/routes/backups-operations.test.ts +++ b/backend/src/routes/backups-operations.test.ts @@ -2,12 +2,14 @@ import assert from "node:assert/strict" import Fastify from "fastify" import { validatorCompiler, serializerCompiler } from "@fastify/type-provider-zod" import { backupJobResponseSchema } from "@mmapp/contracts/backups" +import { BackupRestoreUnknownError } from "../services/backup-restore.js" import { createBackupsRoutes } from "./backups.js" import { permissionForRequest } from "../lib/permissions.js" const app = Fastify(); app.setValidatorCompiler(validatorCompiler); app.setSerializerCompiler(serializerCompiler) +let unknownRestore = false let deleteOutcome = "success", deleted = 0 let created = 0, restored = 0, eventCalls = 0, restoreFail = false -await app.register(createBackupsRoutes({ delete: async () => { deleted++; if (deleteOutcome === "error") throw new Error("S3 unavailable"); if (deleteOutcome === "missing") return null; return { id: "backup-1", serverId: 1, serverName: "router", filename: "config.rsc", sizeBytes: 0, createdAt: "2026-10-01T00:00:00Z", kind: "manual", storage: "both" } }, listServers: async () => [{ id: 1 }, { id: 2 }], create: async id => { created++; if (id === "2") throw new Error("RouterOS unavailable"); return { id: "backup-1", serverId: 1, serverName: "router", filename: "config.rsc", sizeBytes: 0, createdAt: "2026-10-01T00:00:00Z", kind: "manual", storage: "local" } }, restore: async () => { restored++; if (restoreFail) throw new Error("import failed"); return { backupId: "backup-1", serverId: 1, filename: "config.rsc", serverName: "router" } }, appendEvent: async () => { eventCalls++; throw new Error("event DB unavailable") } }), { prefix: "/api" }) +await app.register(createBackupsRoutes({ delete: async () => { deleted++; if (deleteOutcome === "error") throw new Error("S3 unavailable"); if (deleteOutcome === "missing") return null; return { id: "backup-1", serverId: 1, serverName: "router", filename: "config.rsc", sizeBytes: 0, createdAt: "2026-10-01T00:00:00Z", kind: "manual", storage: "both" } }, listServers: async () => [{ id: 1 }, { id: 2 }], create: async id => { created++; if (id === "2") throw new Error("RouterOS unavailable"); return { id: "backup-1", serverId: 1, serverName: "router", filename: "config.rsc", sizeBytes: 0, createdAt: "2026-10-01T00:00:00Z", kind: "manual", storage: "local" } }, restore: async () => { restored++; if (unknownRestore) throw new BackupRestoreUnknownError(); if (restoreFail) throw new Error("import failed"); return { backupId: "backup-1", serverId: 1, filename: "config.rsc", serverName: "router" } }, appendEvent: async () => { eventCalls++; throw new Error("event DB unavailable") } }), { prefix: "/api" }) const post = (payload: unknown) => app.inject({ method: "POST", url: "/api/backups/create", payload: payload as Record }) for (const serverIds of [[], ["1", "missing"], ["1", "3"], ["1", "1"], ["0"], ["1junk"], [9007199254740992]]) assert.equal((await post({ serverIds })).statusCode, 400) assert.equal(created, 0) @@ -29,6 +31,8 @@ assert.equal((await post({ requestId, serverIds: ["2"], notes: "manual" })).stat assert.equal((await post({ requestId, serverIds: ["1"], notes: "other" })).statusCode, 409) const success = await app.inject({ method: "POST", url: "/api/backups/backup-1/restore" }); assert.equal(success.statusCode, 200); assert.deepEqual(success.json(), { backupId: "backup-1", serverId: 1, filename: "config.rsc", serverName: "router" }); assert.equal(restored, 1) restoreFail = true; const failure = await app.inject({ method: "POST", url: "/api/backups/backup-1/restore" }); assert.equal(failure.statusCode, 400); assert.equal(failure.json().error, "import failed") +unknownRestore = true; const unknown = await app.inject({ method: "POST", url: "/api/backups/backup-1/restore" }); assert.equal(unknown.statusCode, 502); assert.equal(unknown.json().outcome, "unknown") +assert.equal(failure.json().outcome, "not-applied") assert.equal(permissionForRequest("POST", "/api/backups/create"), "mm:backups:write"); assert.equal(permissionForRequest("POST", "/api/backups/backup-1/restore"), "mm:backups:write"); assert.equal(permissionForRequest("GET", "/api/backups/jobs/job"), "mm:backups:read") assert.equal((await app.inject({ method: "DELETE", url: "/api/backups/backup-1" })).statusCode, 204) deleteOutcome = "error"; assert.equal((await app.inject({ method: "DELETE", url: "/api/backups/backup-1" })).statusCode, 500) diff --git a/backend/src/routes/backups.ts b/backend/src/routes/backups.ts index 4183e9f8..76569eee 100644 --- a/backend/src/routes/backups.ts +++ b/backend/src/routes/backups.ts @@ -25,6 +25,7 @@ import { updateBackupStorageSettings, } from "../services/backup-service.js" +import { BackupRestoreUnknownError } from "../services/backup-restore.js" import { processBackupJob, recordBackupEvent } from "../services/backup-job-execution.js" type BackupJob = BackupJobStatusResponse @@ -189,7 +190,7 @@ export function createBackupsRoutes(deps: { listServers?: () => Promise { try { const result = await (deps.restore ?? restoreBackupToDevice)(req.params.id) - await event({ + void event({ level: "warning", eventType: "backups.restore", sourceModule: "backups", @@ -200,7 +201,8 @@ export function createBackupsRoutes(deps: { listServers?: () => Promise new MikrotikClient({ host: "router.invalid", port: 80, useSsl: false, verifySsl: false, username: "test", password: "test" }) +test("import does not fallback after ambiguous failure or command rejection", async () => { + for (const error of [new Error("connection lost after apply"), new Error("Invalid JSON"), new MikrotikError(500, "/import", "error"), new MikrotikError(400, "/import", "script failed"), new MikrotikError(401, "/import", "unauthorized")]) { + const client = makeClient(), calls: string[] = [] + client.post = async (endpoint: string): Promise => { calls.push(endpoint); throw error } + await assert.rejects(client.importUploadedFile("config.rsc"), error); assert.deepEqual(calls, ["/import"]) + } +}) +test("unsupported endpoint retains one fallback and successful import is not repeated", async () => { + for (const status of [200, 404, 405]) { + const client = makeClient(), calls: string[] = [] + client.post = async (endpoint: string): Promise => { calls.push(endpoint); if (endpoint === "/import" && status !== 200) throw new MikrotikError(status, endpoint, "unsupported"); return { ok: true } as T } + await client.importUploadedFile("config.rsc"); assert.deepEqual(calls, status === 200 ? ["/import"] : ["/import", "/execute"]) + } +}) +test("failed import has unknown outcome and no application retry", async () => { + let calls = 0 + await assert.rejects(importBackupOnce("config.rsc", async () => { calls++; throw new Error("lost response") }), BackupRestoreUnknownError) + assert.equal(calls, 1); await importBackupOnce("config.rsc", async () => ({})) +}) diff --git a/backend/src/services/backup-restore.ts b/backend/src/services/backup-restore.ts new file mode 100644 index 00000000..ca0c8951 --- /dev/null +++ b/backend/src/services/backup-restore.ts @@ -0,0 +1,6 @@ +export class BackupRestoreUnknownError extends Error { + constructor(options?: ErrorOptions) { super("Результат импорта неизвестен. Проверьте конфигурацию роутера перед повтором.", options); this.name = "BackupRestoreUnknownError" } +} +export async function importBackupOnce(file: string, apply: (file: string) => Promise): Promise { + try { await apply(file) } catch (cause) { throw new BackupRestoreUnknownError({ cause }) } +} diff --git a/backend/src/services/backup-service.ts b/backend/src/services/backup-service.ts index 45dbf826..1807b9ef 100644 --- a/backend/src/services/backup-service.ts +++ b/backend/src/services/backup-service.ts @@ -8,6 +8,7 @@ import type { PutBackupStorageSettings, } from "@mmapp/contracts/backups" import { mapBackupEntry } from "./backup-dto.js" +import { importBackupOnce } from "./backup-restore.js" import { deleteStoredBackup } from "./backup-deletion.js" import { planBackupStorageUpdate } from "./backup-storage-update.js" import { db } from "../db/index.js" @@ -477,7 +478,7 @@ export async function restoreBackupToDevice(id: string): Promise<{ backupId: str const content = await readBackupContent(meta) const client = MikrotikClient.fromServer(row) const uploaded = await client.uploadTextFile(meta.filename, content.toString("utf8"), 60_000) - await client.importUploadedFile(uploaded) + await importBackupOnce(uploaded, file => client.importUploadedFile(file)) return { backupId: meta.id, serverId: row.id, filename: meta.filename, serverName: row.name } } diff --git a/backend/src/services/mikrotik.ts b/backend/src/services/mikrotik.ts index 4b54caa4..0d0fa1eb 100644 --- a/backend/src/services/mikrotik.ts +++ b/backend/src/services/mikrotik.ts @@ -784,7 +784,8 @@ export class MikrotikClient { async importUploadedFile(fileName: string): Promise { try { return await this.post("/import", { "file-name": fileName }, 120_000) - } catch { + } catch (error) { + if (!(error instanceof MikrotikError) || ![404, 405].includes(error.statusCode)) throw error return await this.post("/execute", { script: `/import file-name="${fileName}"` }, 120_000) } } diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index 8af6da61..c4b24389 100644 --- a/docs/ux-ui-plan.md +++ b/docs/ux-ui-plan.md @@ -226,4 +226,4 @@ REST запрос → ожидание → подтверждённый output / | Пользователи | [Профиль](ux-ui/screens/users-profile-desktop.png), [ошибка](ux-ui/screens/users-error-desktop.png) | [390](ux-ui/screens/users-profile-mobile.png), [320](ux-ui/screens/users-profile-narrow.png) | | Контейнеры | [Обзор](ux-ui/screens/containers-overview-desktop.png), [снимок](ux-ui/screens/containers-export-desktop.png) | [390](ux-ui/screens/containers-export-mobile.png), [320](ux-ui/screens/containers-export-narrow.png) | -Актуальная точка продолжения: 11А.29.4 — неизвестный результат restore и повтор. Идентичность 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А.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А ещё не приняты. diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index e46ae121..41367c5d 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -27,7 +27,7 @@ | 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 | | 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 | | 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 | -| 11А | Пользователи, контейнеры, сертификаты и резервные копии | В работе: 11А.1–11А.29.3; пользователи/контейнеры/сертификаты приняты локально; резервные копии: идентичность restore, далее 11А.29.4 | +| 11А | Пользователи, контейнеры, сертификаты и резервные копии | В работе: 11А.1–11А.29.4; пользователи/контейнеры/сертификаты приняты локально; резервные копии: неизвестный outcome backend, далее 11А.29.5 | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | @@ -36,6 +36,14 @@ +## Блок 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. +- Событие успешного импорта пишется независимо от HTTP receipt: медленный/отказавший журнал не удерживает подтверждённый ответ. Успешный импорт не повторяется helper-функцией. +- 3 restore fault unit (сети в них нет), весь backend test:backups с HTTP outcome 502/400, backend production build прошли. Профильный lint новых/helper/service/routes файлов прошёл. В mikrotik.ts остались две исходные prefer-const ошибки (req/entry); наличие обеих подтверждено lint версии HEAD до изменения, новых lint ошибок нет. +- Реальный RouterOS не проверен; /execute receipt подтверждает ответ команды, не завершение всех изменений конфигурации. Backend durable restore/idempotency repository пока отсутствует; клиентский запрет слепого повтора нужен следующим блоком. +- **Следующий блок 11А.29.5:** checkpoint restore до POST, неизвестный результат/после reload и явная проверка роутера перед новым импортом; затем URL/Frame/адаптивность, scheduler и приёмка backups. + ## Блок 11А.29.3 — идентичность подтверждения импорта (2026-10-01) - Restore receipt содержит backupId и numeric serverId наряду с filename/актуальным serverName. API проверяет обе цели, страница проверяет filename. UI serverId преобразуется из string в number согласно контракту. Ответ чужого сервера/бэкапа и старый неполный receipt не закрывают диалог и не подтверждают импорт. Переименование сервера не считается ошибкой. diff --git a/docs/ux-ui-stage-11a.md b/docs/ux-ui-stage-11a.md index 2f296967..dba9f304 100644 --- a/docs/ux-ui-stage-11a.md +++ b/docs/ux-ui-stage-11a.md @@ -390,3 +390,12 @@ - API receipt подтверждает ответ импорта, не работоспособность конечной конфигурации. Внешние RouterOS/PostgreSQL/S3 не проверены. Потеря ответа и blind fallback после ошибки импорта остаются следующим блоком. - **Следующий блок 11А.29.4:** неизвестный результат restore, ограничение повторного импорта и его HTTP семантика; далее URL/Frame/адаптивность и scheduler/приёмка 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. +- Событие успешного импорта пишется независимо от HTTP receipt: медленный/отказавший журнал не удерживает подтверждённый ответ. Успешный импорт не повторяется helper-функцией. +- 3 restore fault unit (сети в них нет), весь backend test:backups с HTTP outcome 502/400, backend production build прошли. Профильный lint новых/helper/service/routes файлов прошёл. В mikrotik.ts остались две исходные prefer-const ошибки (req/entry); наличие обеих подтверждено lint версии HEAD до изменения, новых lint ошибок нет. +- Реальный RouterOS не проверен; /execute receipt подтверждает ответ команды, не завершение всех изменений конфигурации. Backend durable restore/idempotency repository пока отсутствует; клиентский запрет слепого повтора нужен следующим блоком. +- **Следующий блок 11А.29.5:** checkpoint restore до POST, неизвестный результат/после reload и явная проверка роутера перед новым импортом; затем URL/Frame/адаптивность, scheduler и приёмка backups. +