diff --git a/app/(main)/backups/page.tsx b/app/(main)/backups/page.tsx index 0ad58271..e4e696c1 100644 --- a/app/(main)/backups/page.tsx +++ b/app/(main)/backups/page.tsx @@ -295,7 +295,7 @@ function BackupsContent() { if (!restoreTarget || !restoreTarget.serverId) return const target = restoreTarget, abort = startOperation(); if (!abort) return try { - const result = await restoreBackup(backendUrl, target.id, { signal: abort.signal }) + const result = await restoreBackup(backendUrl, target.id, { signal: abort.signal }, Number(target.serverId)) if (!alive.current || abort.signal.aborted) return if (result.filename !== target.filename) throw new Error("Ответ восстановления относится к другому файлу") toast.success("API подтвердил импорт " + result.filename + " на " + result.serverName) diff --git a/backend/src/routes/backups-operations.test.ts b/backend/src/routes/backups-operations.test.ts index a6bc462a..e57c5567 100644 --- a/backend/src/routes/backups-operations.test.ts +++ b/backend/src/routes/backups-operations.test.ts @@ -7,7 +7,7 @@ import { permissionForRequest } from "../lib/permissions.js" const app = Fastify(); app.setValidatorCompiler(validatorCompiler); app.setSerializerCompiler(serializerCompiler) 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 { 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 (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) @@ -27,7 +27,7 @@ await new Promise(resolve => setImmediate(resolve)); assert.equal(created, befor assert.equal((await post({ requestId, serverIds: ["1"], notes: "manual" })).statusCode, 202); assert.equal(created, before + 1) assert.equal((await post({ requestId, serverIds: ["2"], notes: "manual" })).statusCode, 409) 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(), { filename: "config.rsc", serverName: "router" }); assert.equal(restored, 1) +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") 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) diff --git a/backend/src/services/backup-service.ts b/backend/src/services/backup-service.ts index 34bfdc69..45dbf826 100644 --- a/backend/src/services/backup-service.ts +++ b/backend/src/services/backup-service.ts @@ -468,7 +468,7 @@ export async function readBackupContent(meta: BackupMeta): Promise { return await readFile(path.join(BACKUPS_DIR, meta.filename)) } -export async function restoreBackupToDevice(id: string): Promise<{ filename: string; serverName: string }> { +export async function restoreBackupToDevice(id: string): Promise<{ backupId: string; serverId: number; filename: string; serverName: string }> { const meta = await getBackupById(id) if (!meta) throw new Error("Бэкап не найден") if (!meta.serverId) throw new Error("Сервер бэкапа удалён — восстановить нельзя") @@ -478,7 +478,7 @@ export async function restoreBackupToDevice(id: string): Promise<{ filename: str const client = MikrotikClient.fromServer(row) const uploaded = await client.uploadTextFile(meta.filename, content.toString("utf8"), 60_000) await client.importUploadedFile(uploaded) - return { filename: meta.filename, serverName: row.name } + return { backupId: meta.id, serverId: row.id, filename: meta.filename, serverName: row.name } } export async function syncBackupsFromS3(): Promise<{ imported: number; skipped: number }> { diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index 78704ba2..8af6da61 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.3 — restore identity/неизвестный результат. 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.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А ещё не приняты. diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index 29e807cf..e46ae121 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.2; пользователи/контейнеры/сертификаты приняты локально; резервные копии: подтверждённая очистка, далее 11А.29.3 | +| 11А | Пользователи, контейнеры, сертификаты и резервные копии | В работе: 11А.1–11А.29.3; пользователи/контейнеры/сертификаты приняты локально; резервные копии: идентичность restore, далее 11А.29.4 | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | @@ -36,6 +36,13 @@ +## Блок 11А.29.3 — идентичность подтверждения импорта (2026-10-01) + +- Restore receipt содержит backupId и numeric serverId наряду с filename/актуальным serverName. API проверяет обе цели, страница проверяет filename. UI serverId преобразуется из string в number согласно контракту. Ответ чужого сервера/бэкапа и старый неполный receipt не закрывают диалог и не подтверждают импорт. Переименование сервера не считается ошибкой. +- Backend/helper и тестовые HTTP ответы мигрированы вместе. 10 frontend API unit, backend test:backups, 7 operations E2E и 4 restore context E2E, frontend/backend production build, TypeScript и профильный ESLint прошли. Первый прогон выявил string/number ID mismatch; исправлено и проверки повторены. +- API receipt подтверждает ответ импорта, не работоспособность конечной конфигурации. Внешние RouterOS/PostgreSQL/S3 не проверены. Потеря ответа и blind fallback после ошибки импорта остаются следующим блоком. +- **Следующий блок 11А.29.4:** неизвестный результат restore, ограничение повторного импорта и его HTTP семантика; далее URL/Frame/адаптивность и scheduler/приёмка backups. + ## Блок 11А.29.2 — подтверждённая очистка перед удалением записи (2026-10-01) - Backend удаляет S3 объект и локальный файл до записи каталога. Ошибка S3/диска/DB выходит наружу, не превращается в 204. Отсутствие рабочего S3 подключения при s3Key отклоняет удаление. Сохранившаяся запись позволяет повторить частичную очистку; local rm force и S3 DeleteObject допускают уже отсутствующий объект. diff --git a/docs/ux-ui-stage-11a.md b/docs/ux-ui-stage-11a.md index 5482c399..2f296967 100644 --- a/docs/ux-ui-stage-11a.md +++ b/docs/ux-ui-stage-11a.md @@ -382,3 +382,11 @@ - 3 fault/order/path unit, полный backend test:backups (DTO/storage/job/HTTP/S3), HTTP DELETE 204/500/404 и RBAC, backend production build и профильный ESLint прошли. Frontend изменён только текстом пояснения. Реальные PostgreSQL/RouterOS/S3 не проверены. - **Следующий блок 11А.29.3:** restore receipt backupId/serverId, неизвестный результат импорта и повтор; затем URL/Frame/адаптивность, scheduler cleanup и локальная приёмка резервных копий. + +## Блок 11А.29.3 — идентичность подтверждения импорта (2026-10-01) + +- Restore receipt содержит backupId и numeric serverId наряду с filename/актуальным serverName. API проверяет обе цели, страница проверяет filename. UI serverId преобразуется из string в number согласно контракту. Ответ чужого сервера/бэкапа и старый неполный receipt не закрывают диалог и не подтверждают импорт. Переименование сервера не считается ошибкой. +- Backend/helper и тестовые HTTP ответы мигрированы вместе. 10 frontend API unit, backend test:backups, 7 operations E2E и 4 restore context E2E, frontend/backend production build, TypeScript и профильный ESLint прошли. Первый прогон выявил string/number ID mismatch; исправлено и проверки повторены. +- API receipt подтверждает ответ импорта, не работоспособность конечной конфигурации. Внешние RouterOS/PostgreSQL/S3 не проверены. Потеря ответа и blind fallback после ошибки импорта остаются следующим блоком. +- **Следующий блок 11А.29.4:** неизвестный результат restore, ограничение повторного импорта и его HTTP семантика; далее URL/Frame/адаптивность и scheduler/приёмка backups. + diff --git a/packages/contracts/src/backups.ts b/packages/contracts/src/backups.ts index fd9b1f28..62641bed 100644 --- a/packages/contracts/src/backups.ts +++ b/packages/contracts/src/backups.ts @@ -117,7 +117,7 @@ export const backupJobResponseSchema = backupProgressSchema.extend({ if (new Set(job.created.map(item => item.id)).size !== job.created.length) ctx.addIssue({ code: "custom", message: "Дубли резервных копий" }) }) export const backupsListResponseSchema = z.array(backupItemDtoSchema).refine(items => new Set(items.map(item => item.id)).size === items.length, "Дубли резервных копий") -export const restoreBackupResponseSchema = z.object({ filename: z.string().min(1), serverName: z.string().min(1) }) +export const restoreBackupResponseSchema = z.object({ backupId: z.string().min(1), serverId: z.number().int().positive().safe(), filename: z.string().min(1), serverName: z.string().min(1) }) export const syncBackupsResponseSchema = z.object({ imported: z.number().int().nonnegative().safe(), skipped: z.number().int().nonnegative().safe() }) export type CreateBackupJobResponse = z.infer export type BackupJobStatusResponse = z.infer diff --git a/shared/api/backups.test.ts b/shared/api/backups.test.ts index 91ea6283..af36146b 100644 --- a/shared/api/backups.test.ts +++ b/shared/api/backups.test.ts @@ -75,7 +75,7 @@ test("restore, S3 test and sync do not accept empty or malformed success", async const original = globalThis.fetch try { globalThis.fetch = async () => json({}); await assert.rejects(() => restoreBackup(base, "backup-1")); await assert.rejects(() => testBackupStorage(base)); await assert.rejects(() => syncBackupsFromStorage(base)) - globalThis.fetch = async () => json({ filename: "config.rsc", serverName: "router" }); assert.equal((await restoreBackup(base, "backup-1")).filename, "config.rsc") + globalThis.fetch = async () => json({ backupId: "backup-1", serverId: 1, filename: "config.rsc", serverName: "router" }); assert.equal((await restoreBackup(base, "backup-1")).filename, "config.rsc") globalThis.fetch = async () => json(storage); assert.equal((await testBackupStorage(base)).secretConfigured, false) globalThis.fetch = async () => json({ imported: 0, skipped: 0 }); assert.equal((await syncBackupsFromStorage(base)).imported, 0) globalThis.fetch = async () => json({ imported: -1, skipped: 0 }); await assert.rejects(() => syncBackupsFromStorage(base)) @@ -105,3 +105,14 @@ test("download encodes identity and refuses HTML/JSON success bodies", async () globalThis.fetch = async () => new Response("config", { headers: { "Content-Type": "text/plain; charset=utf-8" } }); assert.equal(await (await downloadBackup(base, "backup-1")).text(), "config") } finally { globalThis.fetch = original } }) + + test("restore receipt matches backup and device identity", async () => { + const original = globalThis.fetch + try { + const receipt = { backupId: "backup-1", serverId: 1, filename: "config.rsc", serverName: "renamed router" } + for (const patch of [{ backupId: "other" }, { serverId: 2 }, { backupId: undefined }, { serverId: undefined }, { serverId: "1" }]) { + globalThis.fetch = async () => json({ ...receipt, ...patch }); await assert.rejects(() => restoreBackup(base, "backup-1", undefined, 1)) + } + globalThis.fetch = async () => json(receipt); assert.equal((await restoreBackup(base, "backup-1", undefined, 1)).serverName, "renamed router") + } finally { globalThis.fetch = original } +}) diff --git a/shared/api/backups.ts b/shared/api/backups.ts index cd6bd837..d6476277 100644 --- a/shared/api/backups.ts +++ b/shared/api/backups.ts @@ -42,8 +42,10 @@ export async function deleteBackup(baseUrl: string, id: string, options?: Reques const response = await requestBlob(baseUrl, "/api/backups/" + encodeURIComponent(id), backupOptions({ ...options, method: "DELETE" })) if (response.status !== 204) throw new Error("Удаление резервной копии не подтверждено: ожидается HTTP 204") } -export async function restoreBackup(baseUrl: string, id: string, options?: RequestInit) { - return restoreBackupResponseSchema.parse(await backupJson(baseUrl, "/api/backups/" + encodeURIComponent(id) + "/restore", { ...options, method: "POST" })) +export async function restoreBackup(baseUrl: string, id: string, options?: RequestInit, expectedServerId?: number) { + const result = restoreBackupResponseSchema.parse(await backupJson(baseUrl, "/api/backups/" + encodeURIComponent(id) + "/restore", { ...options, method: "POST" })) + if (result.backupId !== id || (expectedServerId !== undefined && result.serverId !== expectedServerId)) throw new Error("Ответ восстановления относится к другой резервной копии или серверу") + return result } export async function getBackupScheduleSettings(baseUrl: string, options?: RequestInit) { return backupScheduleSettingsDtoSchema.parse(await backupJson(baseUrl, "/api/backups/schedule", { ...options, method: "GET" })) diff --git a/tests/e2e/backups-context.spec.ts b/tests/e2e/backups-context.spec.ts index 8ac2ac7a..a8ad48ed 100644 --- a/tests/e2e/backups-context.spec.ts +++ b/tests/e2e/backups-context.spec.ts @@ -74,7 +74,7 @@ for (const operation of ["delete", "restore", "download"]) for (const change of try { if (fails) await r.fulfill({ headers, status: 503, json: { error: "СТАРАЯ ОПЕРАЦИЯ ОТКАЗАЛА" } }) else if (operation === "delete") await r.fulfill({ headers, status: 204 }) - else if (operation === "restore") await r.fulfill({ headers, json: { filename: backup.filename, serverName: "СТАРЫЙ РОУТЕР" } }) + else if (operation === "restore") await r.fulfill({ headers, json: { backupId: backup.id, serverId: 1, filename: backup.filename, serverName: "СТАРЫЙ РОУТЕР" } }) else await r.fulfill({ headers, contentType: "text/plain", body: "old config" }) } catch {} finally { completed = true } }) diff --git a/tests/e2e/backups-operations.spec.ts b/tests/e2e/backups-operations.spec.ts index 42035166..5b604589 100644 --- a/tests/e2e/backups-operations.spec.ts +++ b/tests/e2e/backups-operations.spec.ts @@ -22,7 +22,7 @@ for (const status of [403, 503, 200]) test("Бэкапы: удаление " + s }) test("Бэкапы: ошибка restore и чужой filename сохраняют диалог", async ({ page }) => { await setupBackups(page); let failure = true - await page.route("**/api/backups/backup-1/restore", r => r.fulfill(failure ? { status: 503, json: { error: "Импорт отказал" } } : { json: { filename: "other.rsc", serverName: "router" } })) + await page.route("**/api/backups/backup-1/restore", r => r.fulfill(failure ? { status: 503, json: { error: "Импорт отказал" } } : { json: { backupId: "backup-1", serverId: 1, filename: "other.rsc", serverName: "router" } })) await page.goto("/backups"); await page.getByRole("button", { name: "Восстановить " + backup.filename, exact: true }).click() const dialog = page.getByRole("alertdialog"); await dialog.getByRole("button", { name: "Восстановить", exact: true }).click() await expect(dialog.getByRole("alert").filter({ hasText: "Импорт отказал" })).toBeVisible() @@ -37,3 +37,13 @@ test("Бэкапы: download получает файл, HTML 200 показыв const download = page.waitForEvent("download"); await button.click(); expect((await download).suggestedFilename()).toBe(backup.filename) html = true; await button.click(); await expect(page.getByRole("alert", { name: "Операция резервных копий" })).toContainText("не подтверждён API") }) + +test("Бэкапы: restore требует правильный serverId и принимает новое имя сервера", async ({ page }) => { + await setupBackups(page); let serverId = 2, calls = 0 + await page.route("**/api/backups/backup-1/restore", async r => { calls++; await r.fulfill({ json: { backupId: backup.id, serverId, filename: backup.filename, serverName: "Новое имя роутера" } }) }) + await page.goto("/backups"); await page.getByRole("button", { name: "Восстановить " + backup.filename, exact: true }).click() + const dialog = page.getByRole("alertdialog"); await dialog.getByRole("button", { name: "Восстановить", exact: true }).click() + await expect(dialog.getByRole("alert").filter({ hasText: "другой резервной копии или серверу" })).toBeVisible() + serverId = 1; await dialog.getByRole("button", { name: "Восстановить", exact: true }).dblclick() + await expect(dialog).toBeHidden(); await expect(page.getByText("API подтвердил импорт " + backup.filename + " на Новое имя роутера", { exact: true })).toBeVisible(); expect(calls).toBe(2) +})