fix(backups): сверять сервер и файл подтверждения импорта

This commit is contained in:
Denozordec committed 2026-10-01 16:18:37 +07:00
1 parent af86d2c3cd
commit 99ff5aebaf
11 files changed
+51 -13

No files matched your search

+1 -1
View File
@@ -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)
@@ -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<string, unknown> })
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)
+2 -2
View File
@@ -468,7 +468,7 @@ export async function readBackupContent(meta: BackupMeta): Promise<Buffer> {
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 }> {
+1 -1
View File
@@ -226,4 +226,4 @@ REST запрос → ожидание → подтверждённый output /
| Пользователи | [Профиль](ux-ui/screens/users-profile-desktop.png), [ошибка](ux-ui/screens/users-error-desktop.png) | [390](ux-ui/screens/users-profile-mobile.png), [320](ux-ui/screens/users-profile-narrow.png) |
| Контейнеры | [Обзор](ux-ui/screens/containers-overview-desktop.png), [снимок](ux-ui/screens/containers-export-desktop.png) | [390](ux-ui/screens/containers-export-mobile.png), [320](ux-ui/screens/containers-export-narrow.png) |
Актуальная точка продолжения: 11А.29.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А ещё не приняты.
+8 -1
View File
@@ -27,7 +27,7 @@
| 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 |
| 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 |
| 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 |
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | В работе: 11А.1–11А.29.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 допускают уже отсутствующий объект.
+8
View File
@@ -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.
+1 -1
View File
@@ -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<typeof createBackupJobResponseSchema>
export type BackupJobStatusResponse = z.infer<typeof backupJobResponseSchema>
+12 -1
View File
@@ -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 }
})
+4 -2
View File
@@ -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" }))
+1 -1
View File
@@ -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 }
})
+11 -1
View File
@@ -22,7 +22,7 @@ for (const status of [403, 503, 200]) test("Бэкапы: удаление " + s
})
test("Бэкапы: ошибка restore и чужой filename сохраняют диалог", async ({ page }) => {
await setupBackups(page); let failure = true
await page.route("**/api/backups/backup-1/restore", r => r.fulfill(failure ? { status: 503, json: { error: "Импорт отказал" } } : { json: { 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)
})