fix(backups): сверять сервер и файл подтверждения импорта
This commit is contained in:
1 parent
af86d2c3cd
commit
99ff5aebaf
11 files changed
+51
-13
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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
@@ -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А ещё не приняты.
|
||||
@@ -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 допускают уже отсутствующий объект.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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" }))
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
Reference in new issue
Block a user