diff --git a/backend/src/routes/backups-operations.test.ts b/backend/src/routes/backups-operations.test.ts index 0d1b8caf..3a2b912d 100644 --- a/backend/src/routes/backups-operations.test.ts +++ b/backend/src/routes/backups-operations.test.ts @@ -19,6 +19,13 @@ for (let attempt = 0; attempt < 20; attempt++) { await new Promise(resolve => setImmediate(resolve)); assert.ok(attempt < 19) } assert.equal(created, 2); assert.ok(eventCalls >= 2) +const requestId = "11111111-1111-4111-8111-111111111111", before = created +const repeated = await Promise.all([post({ requestId, serverIds: ["1"], notes: " manual " }), post({ requestId, serverIds: [1], notes: "manual" })]) +assert.ok(repeated.every(response => response.statusCode === 202 && response.json().jobId === requestId)) +await new Promise(resolve => setImmediate(resolve)); assert.equal(created, before + 1) +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) 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") diff --git a/backend/src/routes/backups.ts b/backend/src/routes/backups.ts index fe3a00a5..1411d70c 100644 --- a/backend/src/routes/backups.ts +++ b/backend/src/routes/backups.ts @@ -94,13 +94,20 @@ export function createBackupsRoutes(deps: { listServers?: () => Promise { const inputIds = req.body.serverIds.map((x) => String(x)) const notes = req.body.notes?.trim() || undefined + const previous = req.body.requestId ? backupJobs.get(req.body.requestId) : undefined + if (previous) { + if (JSON.stringify(previous.serverIds) !== JSON.stringify(inputIds) || previous.notes !== notes) return reply.status(409).send({ error: "Request ID уже относится к другой задаче" }) + return reply.status(202).send({ jobId: previous.id, status: previous.status, total: previous.total, completed: previous.completed, serverIds: previous.serverIds }) + } const existingServers = new Set((await (deps.listServers ?? listServersRead)()).map((s) => String(s.id))) const ids = [...new Set(inputIds)] if (ids.some(id => !existingServers.has(id))) return reply.status(400).send({ error: "Выбранный сервер отсутствует в каталоге; обновите выбор" }) - const jobId = randomUUID() + const jobId = req.body.requestId ?? randomUUID() const job: BackupJob = { id: jobId, + serverIds: ids, + notes, status: "queued", requestedAt: new Date().toISOString(), total: ids.length, @@ -108,8 +115,13 @@ export function createBackupsRoutes(deps: { listServers?: () => Promise Promise ({ id: "job", status: "queued", requestedAt: "2026-10-01T00:00:00Z", total: 2, completed: 0, created: [], failures: [] }) +const job = (): BackupJobStatusResponse => ({ id: "job", serverIds: ["1", "2"], status: "queued", requestedAt: "2026-10-01T00:00:00Z", total: 2, completed: 0, created: [], failures: [] }) test("event failure preserves done and actual created files", async () => { const row = job(), warnings: unknown[] = [] await processBackupJob(row, ["1", "2"], undefined, { create: async id => ({ ...item, id, serverId: Number(id) }), event: input => recordBackupEvent(input, async () => { throw new Error("event unavailable") }, error => { warnings.push(error) }) }) @@ -16,7 +16,7 @@ test("one failed device keeps partial files and completes every target", async ( assert.equal(row.status, "done"); assert.equal(row.completed, 2); assert.equal(row.created.length, 1); assert.deepEqual(row.failures, [{ serverId: "2", error: "device unavailable" }]); backupJobResponseSchema.parse(row) }) test("S3 upload error remains a local copy and a warning rather than full success", async () => { - const row = job(); row.total = 1 + const row = job(); row.total = 1; row.serverIds = ["1"] await processBackupJob(row, ["1"], undefined, { create: async () => ({ ...item, uploadError: "S3 unavailable" }), event: async input => { assert.equal(input.level, "warning"); assert.match(input.message, /ошибок загрузки: 1/) } }) assert.equal(row.status, "done"); assert.equal(row.created[0].storage, "local"); assert.equal(row.created[0].uploadError, "S3 unavailable"); backupJobResponseSchema.parse(row) }) diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index 5e27dffa..0c9f682f 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А.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А ещё не приняты. +Актуальная точка продолжения: 11А.28.2 — frontend checkpoint/создание/serial polling резервных копий и подтверждение partial результата, затем остальные операции/URL/приёмка. 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 716b2ad6..817638d9 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.2; пользователи/контейнеры/сертификаты приняты локально; резервные копии: каталоги/API/runner/настройки; далее 11А.28 | +| 11А | Пользователи, контейнеры, сертификаты и резервные копии | В работе: 11А.1–11А.28.1; пользователи/контейнеры/сертификаты приняты локально; резервные копии: настройки/идентичность job, далее 11А.28.2 | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | @@ -36,6 +36,15 @@ +## Блок 11А.28.1 — идентичность задачи и основа наблюдения (2026-10-01) + +- Create request принимает необязательный UUID requestId; job/ack содержат canonical serverIds, job содержит заметку. Повтор той же цели/заметки в том же backend процессе возвращает прежний job; конфликт ID возвращает 409. Конкурентный повтор после async catalog lookup резервируется одним Map insert, без второй очереди. Старые клиенты без ID получают отдельные UUID. +- Событие запуска записывается независимо от очереди/ack, чтобы медленный журнал не держал запуск. GET job контракт проверяет цель, число устройств и принадлежащие ей created/failures. API ack проверяет request ID/все serverIds, неизвестное тело не подтверждает операцию. +- Добавлены строгий checkpoint по backend URL и serial polling helper: immutable request до POST, cross-check наблюдения, AbortSignal/срок чтения/наблюдения, read failure/timeout не становятся failed server job. Outcome отличает полный успех от device failures и uploadError. +- 16 frontend API/checkpoint/poll unit, backend test:backups с HTTP concurrent retry/conflict/RBAC, backend production build, полный TypeScript/профильный ESLint прошли. UI пока использует старое создание/setInterval; подключение в следующем блоке. +- Jobs/idempotency Map существуют только в текущем процессе. После рестарта API может вернуть 404 для ранее принятой операции; автоматический повтор неизвестного POST не реализован и недопустим без durable repository. Реальные DB/RouterOS/S3/рестарт не проверены. +- **Следующий блок 11А.28.2:** frontend checkpoint до POST, общий lock формы, GET-only resume/после reload/неизвестного ответа, точный partial результат, поздние ответы источника и профильные E2E. Затем остальные операции/URL/визуальная приёмка. + ## Блок 11А.27.2 — достоверные настройки и последняя проверка S3 (2026-10-01) - S3 форма валидирует обязательные Bucket/Access key/Secret key или наличие сохранённого секрета, HTTP(S) Endpoint. Пустое secret поле оставляет существующий ключ; пробельный новый ключ отклоняется. Поля и toggles имеют доступные имена. Dirty настройка имеет статус «Не сохранено», показ/скрытие секрета не считается изменением конфигурации. diff --git a/docs/ux-ui-stage-11a.md b/docs/ux-ui-stage-11a.md index a9b966fc..75e0ce13 100644 --- a/docs/ux-ui-stage-11a.md +++ b/docs/ux-ui-stage-11a.md @@ -345,3 +345,12 @@ - Совместимость строгого 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А.28.1 — идентичность задачи и основа наблюдения (2026-10-01) + +- Create request принимает необязательный UUID requestId; job/ack содержат canonical serverIds, job содержит заметку. Повтор той же цели/заметки в том же backend процессе возвращает прежний job; конфликт ID возвращает 409. Конкурентный повтор после async catalog lookup резервируется одним Map insert, без второй очереди. Старые клиенты без ID получают отдельные UUID. +- Событие запуска записывается независимо от очереди/ack, чтобы медленный журнал не держал запуск. GET job контракт проверяет цель, число устройств и принадлежащие ей created/failures. API ack проверяет request ID/все serverIds, неизвестное тело не подтверждает операцию. +- Добавлены строгий checkpoint по backend URL и serial polling helper: immutable request до POST, cross-check наблюдения, AbortSignal/срок чтения/наблюдения, read failure/timeout не становятся failed server job. Outcome отличает полный успех от device failures и uploadError. +- 16 frontend API/checkpoint/poll unit, backend test:backups с HTTP concurrent retry/conflict/RBAC, backend production build, полный TypeScript/профильный ESLint прошли. UI пока использует старое создание/setInterval; подключение в следующем блоке. +- Jobs/idempotency Map существуют только в текущем процессе. После рестарта API может вернуть 404 для ранее принятой операции; автоматический повтор неизвестного POST не реализован и недопустим без durable repository. Реальные DB/RouterOS/S3/рестарт не проверены. +- **Следующий блок 11А.28.2:** frontend checkpoint до POST, общий lock формы, GET-only resume/после reload/неизвестного ответа, точный partial результат, поздние ответы источника и профильные E2E. Затем остальные операции/URL/визуальная приёмка. diff --git a/lib/backup-job-checkpoint.test.ts b/lib/backup-job-checkpoint.test.ts new file mode 100644 index 00000000..7c11ef0d --- /dev/null +++ b/lib/backup-job-checkpoint.test.ts @@ -0,0 +1,17 @@ +import assert from "node:assert/strict" +import { test } from "node:test" +import { backupJobCheckpointSchema, createBackupJobCheckpoint, readBackupJobCheckpoint, backupJobStorageKey } from "./backup-job-checkpoint" +const id = "11111111-1111-4111-8111-111111111111" +test("checkpoint freezes a canonical request before acknowledgment", () => { + const ids = ["1", "2"], saved = createBackupJobCheckpoint({ serverIds: ids, notes: " manual " }, id); ids.push("3") + assert.deepEqual(saved.request.serverIds, ["1", "2"]); assert.equal(saved.request.notes, "manual"); assert.equal(saved.accepted, false); assert.deepEqual(readBackupJobCheckpoint(JSON.stringify(saved)), saved) +}) +test("corrupt or foreign checkpoints cannot be restored", () => { + assert.throws(() => readBackupJobCheckpoint("bad")); assert.throws(() => createBackupJobCheckpoint({ serverIds: ["1", "1"] }, id)) + const saved = createBackupJobCheckpoint({ serverIds: ["1"] }, id) + const job = { id: "other", status: "queued", serverIds: ["1"], total: 1, completed: 0, requestedAt: "2026-10-01T00:00:00Z", created: [], failures: [] } + assert.equal(backupJobCheckpointSchema.safeParse({ ...saved, accepted: true, job }).success, false) +}) +test("storage keys preserve backend scope and absent storage stays absent", () => { + assert.notEqual(backupJobStorageKey("one"), backupJobStorageKey("two")); assert.equal(readBackupJobCheckpoint(null), null) +}) diff --git a/lib/backup-job-checkpoint.ts b/lib/backup-job-checkpoint.ts new file mode 100644 index 00000000..cf81f90d --- /dev/null +++ b/lib/backup-job-checkpoint.ts @@ -0,0 +1,14 @@ +import { z } from "zod" +import { backupJobResponseSchema, createBackupRequestSchema } from "@mmapp/contracts/backups" +import type { CreateBackupRequest } from "@mmapp/contracts/backups" +const requestSchema = createBackupRequestSchema.extend({ requestId: z.string().uuid() }) +export const backupJobCheckpointSchema = z.object({ request: requestSchema, accepted: z.boolean(), job: backupJobResponseSchema.nullable() }).superRefine((value, ctx) => { + const job = value.job + if (job && (!value.accepted || job.id !== value.request.requestId || JSON.stringify(job.serverIds) !== JSON.stringify(value.request.serverIds.map(String)) || (job.notes ?? "") !== (value.request.notes ?? ""))) ctx.addIssue({ code: "custom", message: "Наблюдение относится к другой задаче" }) +}) +export type BackupJobCheckpoint = z.infer +export function createBackupJobCheckpoint(request: CreateBackupRequest, requestId: string): BackupJobCheckpoint { + return backupJobCheckpointSchema.parse({ request: { ...request, requestId, serverIds: request.serverIds.map(String), notes: request.notes?.trim() || undefined }, accepted: false, job: null }) +} +export function readBackupJobCheckpoint(value: string | null): BackupJobCheckpoint | null { return value ? backupJobCheckpointSchema.parse(JSON.parse(value)) : null } +export function backupJobStorageKey(backendUrl: string) { return "mmapp:backup-job:" + backendUrl } diff --git a/lib/backup-job-poll.test.ts b/lib/backup-job-poll.test.ts new file mode 100644 index 00000000..b4cb3f7d --- /dev/null +++ b/lib/backup-job-poll.test.ts @@ -0,0 +1,32 @@ +import assert from "node:assert/strict" +import { test } from "node:test" +import { pollBackupJob, backupJobOutcome, waitForBackupPoll } from "./backup-job-poll" +import type { BackupJobStatusResponse } from "@mmapp/contracts/backups" +const target = { serverIds: ["1"], notes: "manual" } +const baseJob: BackupJobStatusResponse = { id: "job", status: "queued", serverIds: ["1"], notes: "manual", total: 1, completed: 0, requestedAt: "2026-10-01T00:00:00Z", created: [], failures: [] } +const file = { id: "backup", serverId: 1, serverName: "router", filename: "config.rsc", sizeBytes: 0, createdAt: "2026-10-01T00:00:00Z", kind: "manual" as const, storage: "local" as const } +test("serial queued/running reads complete only on terminal observation", async () => { + let reads = 0, inFlight = 0, maximum = 0 + const job = await pollBackupJob("base", "job", target, { signal: new AbortController().signal, wait: async () => {}, readJob: async () => { inFlight++; maximum = Math.max(maximum, inFlight); await Promise.resolve(); const status = ++reads < 3 ? "running" : "done"; inFlight--; return { ...baseJob, status, ...(status === "done" ? { completed: 1, created: [file], finishedAt: "2026-10-01T00:01:00Z" } : {}) } } }) + assert.equal(reads, 3); assert.equal(maximum, 1); assert.equal(backupJobOutcome(job).fullSuccess, true) +}) +test("partial device and upload errors never become full success", () => { + for (const patch of [{ created: [], failures: [{ serverId: "1", error: "offline" }] }, { created: [{ ...file, uploadError: "S3 unavailable" }], failures: [] }]) { + assert.equal(backupJobOutcome({ ...baseJob, status: "done", completed: 1, ...patch }).fullSuccess, false) + } +}) +test("foreign target is rejected before onJob", async () => { + let observations = 0 + await assert.rejects(() => pollBackupJob("base", "job", target, { signal: new AbortController().signal, onJob: () => { observations++ }, readJob: async () => ({ ...baseJob, serverIds: ["2"] }) }), /не соответствует/) + assert.equal(observations, 0) +}) +test("read failure or exhausted observation budget is not a failed job", async () => { + await assert.rejects(() => pollBackupJob("base", "job", target, { signal: new AbortController().signal, readJob: async () => { throw new Error("GET unavailable") } }), /GET unavailable/) + await assert.rejects(() => pollBackupJob("base", "job", target, { signal: new AbortController().signal, attempts: 1, readJob: async () => baseJob }), /Время наблюдения/) +}) +test("cancellation interrupts delay and suppresses late observation", async () => { + const abort = new AbortController(), wait = waitForBackupPoll(10_000, abort.signal); abort.abort(); await assert.rejects(wait) + const late = new AbortController(); let calls = 0 + await assert.rejects(() => pollBackupJob("base", "job", target, { signal: late.signal, onJob: () => { calls++ }, readJob: async () => { late.abort(); return baseJob } })) + assert.equal(calls, 0) +}) diff --git a/lib/backup-job-poll.ts b/lib/backup-job-poll.ts new file mode 100644 index 00000000..8a247497 --- /dev/null +++ b/lib/backup-job-poll.ts @@ -0,0 +1,29 @@ +import type { BackupJobStatusResponse, CreateBackupRequest } from "@mmapp/contracts/backups" +import { getBackupJob } from "@/shared/api/backups" +export function waitForBackupPoll(ms: number, signal: AbortSignal): Promise { + return new Promise((resolve, reject) => { + if (signal.aborted) { reject(signal.reason); return } + const abort = () => { clearTimeout(timer); signal.removeEventListener("abort", abort); reject(signal.reason) } + const timer = setTimeout(() => { signal.removeEventListener("abort", abort); resolve() }, ms) + signal.addEventListener("abort", abort, { once: true }) + }) +} +export async function pollBackupJob(baseUrl: string, jobId: string, target: CreateBackupRequest, options: { + signal: AbortSignal; onJob?: (job: BackupJobStatusResponse) => void; readJob?: typeof getBackupJob; wait?: typeof waitForBackupPoll; attempts?: number; intervalMs?: number +}) { + const signal = AbortSignal.any([options.signal, AbortSignal.timeout(300_000)]) + const readJob = options.readJob ?? getBackupJob, wait = options.wait ?? waitForBackupPoll, attempts = options.attempts ?? 150 + for (let index = 0; index < attempts; index++) { + signal.throwIfAborted() + const job = await readJob(baseUrl, jobId, { signal }); signal.throwIfAborted() + if (job.id !== jobId || JSON.stringify(job.serverIds) !== JSON.stringify(target.serverIds.map(String)) || (job.notes ?? "") !== (target.notes?.trim() ?? "")) throw new Error("Ответ задачи не соответствует выбранным серверам или заметке") + options.onJob?.(job) + if (job.status === "done" || job.status === "failed") return job + if (index + 1 < attempts) await wait(options.intervalMs ?? 1200, signal) + } + throw new Error("Время наблюдения истекло. Задача может продолжаться; повторите проверку той же задачи") +} +export function backupJobOutcome(job: BackupJobStatusResponse) { + const uploadFailures = job.created.filter(item => item.uploadError) + return { fullSuccess: job.status === "done" && job.created.length === job.total && !job.failures.length && !uploadFailures.length, created: job.created.length, deviceFailures: job.failures, uploadFailures } +} diff --git a/packages/contracts/src/backups.ts b/packages/contracts/src/backups.ts index 26a0c9a8..fd9b1f28 100644 --- a/packages/contracts/src/backups.ts +++ b/packages/contracts/src/backups.ts @@ -85,8 +85,9 @@ const backupServerIdSchema = z.union([ z.number().int().positive().safe(), ]) export const createBackupRequestSchema = z.object({ + requestId: z.string().uuid().optional(), serverIds: z.array(backupServerIdSchema).min(1).refine(ids => new Set(ids.map(String)).size === ids.length, "Дубли серверов"), - notes: z.string().max(500).optional(), + notes: z.string().trim().max(500).optional(), }) export const backupJobStatusSchema = z.enum(["queued", "running", "done", "failed"]) const backupProgressSchema = z.object({ @@ -94,11 +95,14 @@ const backupProgressSchema = z.object({ total: z.number().int().positive().safe(), completed: z.number().int().nonnegative().safe(), }) -export const createBackupJobResponseSchema = backupProgressSchema.extend({ jobId: z.string().min(1) }).superRefine((job, ctx) => { +export const createBackupJobResponseSchema = backupProgressSchema.extend({ jobId: z.string().min(1), serverIds: z.array(z.string().min(1)).min(1) }).superRefine((job, ctx) => { + if (job.serverIds.length !== job.total || new Set(job.serverIds).size !== job.total) ctx.addIssue({ code: "custom", message: "Цели не соответствуют числу устройств" }) if (job.completed > job.total || (job.status === "done" && job.completed !== job.total)) ctx.addIssue({ code: "custom", message: "Некорректный прогресс задачи" }) }) export const backupJobResponseSchema = backupProgressSchema.extend({ id: z.string().min(1), + serverIds: z.array(z.string().regex(/^[1-9][0-9]*$/)).min(1), + notes: z.string().optional(), requestedAt: z.string().datetime({ offset: true }), startedAt: z.string().datetime({ offset: true }).optional(), finishedAt: z.string().datetime({ offset: true }).optional(), @@ -107,6 +111,7 @@ export const backupJobResponseSchema = backupProgressSchema.extend({ }).superRefine((job, ctx) => { if (job.completed > job.total || (job.status === "done" && job.completed !== job.total)) ctx.addIssue({ code: "custom", message: "Некорректный прогресс задачи" }) if (["done", "failed"].includes(job.status) && !job.finishedAt) ctx.addIssue({ code: "custom", message: "Завершение не подтверждено временем" }) + if (job.created.some(item => item.serverId === null || !job.serverIds.includes(String(item.serverId))) || job.failures.some(failure => failure.serverId !== "job" && !job.serverIds.includes(failure.serverId))) ctx.addIssue({ code: "custom", message: "Результат относится к другому устройству" }) const deviceFailures = job.failures.filter(failure => failure.serverId !== "job") if (job.created.length + deviceFailures.length !== job.completed) ctx.addIssue({ code: "custom", message: "Результаты не соответствуют прогрессу" }) if (new Set(job.created.map(item => item.id)).size !== job.created.length) ctx.addIssue({ code: "custom", message: "Дубли резервных копий" }) @@ -116,3 +121,5 @@ export const restoreBackupResponseSchema = z.object({ filename: 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 + +export type CreateBackupRequest = z.infer diff --git a/shared/api/backups.test.ts b/shared/api/backups.test.ts index f0169697..0b076229 100644 --- a/shared/api/backups.test.ts +++ b/shared/api/backups.test.ts @@ -6,7 +6,7 @@ const base = "https://api.example.test" const item = { id: "backup-1", serverId: 1, serverName: "router", filename: "config.rsc", sizeBytes: 0, createdAt: "2026-10-01T00:00:00Z", kind: "manual", storage: "local" } const schedule = { enabled: false, frequency: "daily", hour: 0, minute: 0, weekDay: 0, monthDay: 1, keepCount: 7, format: "rsc", serverIds: [] } const storage = { provider: "local", s3Endpoint: "", s3Region: "", s3Bucket: "", s3Prefix: "", s3AccessKeyId: "", secretConfigured: false, s3ForcePathStyle: false, keepLocalCopy: false } -const job = { id: "job-1", status: "done", total: 2, completed: 2, requestedAt: "2026-10-01T00:00:00Z", finishedAt: "2026-10-01T00:01:00Z", created: [item], failures: [{ serverId: "2", error: "RouterOS unavailable" }] } +const job = { id: "job-1", serverIds: ["1", "2"], status: "done", total: 2, completed: 2, requestedAt: "2026-10-01T00:00:00Z", finishedAt: "2026-10-01T00:01:00Z", created: [item], failures: [{ serverId: "2", error: "RouterOS unavailable" }] } function json(payload: unknown, status = 200) { return new Response(JSON.stringify(payload), { status }) } test("known empty and zero are preserved; malformed catalogue and duplicates rejected", async () => { @@ -35,7 +35,7 @@ test("settings require a confirmed response and preserve false rather than defau }) test("invalid mutations fail before HTTP; create receives job acknowledgment", async () => { const original = globalThis.fetch; let calls = 0, body: unknown - globalThis.fetch = async (_url, init) => { calls++; body = JSON.parse(String(init?.body)); return json({ jobId: "job-1", status: "queued", total: 1, completed: 0 }, 202) } + globalThis.fetch = async (_url, init) => { calls++; body = JSON.parse(String(init?.body)); return json({ jobId: "job-1", serverIds: ["1"], status: "queued", total: 1, completed: 0 }, 202) } try { await assert.rejects(() => createBackupsAsync(base, { serverIds: [] })) await assert.rejects(() => createBackupsAsync(base, { serverIds: ["1", "1"] })) @@ -44,7 +44,7 @@ test("invalid mutations fail before HTTP; create receives job acknowledgment", a assert.equal(calls, 0) assert.equal((await createBackupsAsync(base, { serverIds: ["1"], notes: "manual" })).jobId, "job-1") assert.deepEqual(body, { serverIds: ["1"], notes: "manual" }) - globalThis.fetch = async () => json({ jobId: "job-1", status: "queued", total: 2, completed: 0 }, 202); await assert.rejects(() => createBackupsAsync(base, { serverIds: ["1"] }), /всех выбранных/); + globalThis.fetch = async () => json({ jobId: "job-1", serverIds: ["1", "2"], status: "queued", total: 2, completed: 0 }, 202); await assert.rejects(() => createBackupsAsync(base, { serverIds: ["1"] }), /выбранные серверы/); globalThis.fetch = async () => json({ created: [item], failures: [] }); await assert.rejects(() => createBackupsAsync(base, { serverIds: ["1"] })) } finally { globalThis.fetch = original } }) @@ -90,3 +90,9 @@ test("settings acknowledgment must match submitted fields and confirm a new secr await assert.rejects(() => putBackupStorageSettings(base, { s3SecretAccessKey: "new-test-secret" }), /ключа/) } finally { globalThis.fetch = original } }) + +test("backup request ID is sent and a different acknowledgment is rejected", async () => { + const original = globalThis.fetch, requestId = "11111111-1111-4111-8111-111111111111"; let body: Record = {} + globalThis.fetch = async (_url, init) => { body = JSON.parse(String(init?.body)); return json({ jobId: "other", serverIds: ["1"], status: "queued", total: 1, completed: 0 }, 202) } + try { await assert.rejects(() => createBackupsAsync(base, { requestId, serverIds: ["1"] }), /другому запросу/); assert.equal(body.requestId, requestId) } finally { globalThis.fetch = original } +}) diff --git a/shared/api/backups.ts b/shared/api/backups.ts index 1873ca28..d06f9570 100644 --- a/shared/api/backups.ts +++ b/shared/api/backups.ts @@ -25,9 +25,11 @@ function backupJson(baseUrl: string, path: string, options?: RequestInit) { export async function listBackups(baseUrl: string, options?: RequestInit) { return backupsListResponseSchema.parse(await backupJson(baseUrl, "/api/backups", { ...options, method: "GET" })) } -export async function createBackupsAsync(baseUrl: string, payload: { serverIds: string[]; notes?: string }, options?: RequestInit) { +export async function createBackupsAsync(baseUrl: string, payload: { requestId?: string; serverIds: string[]; notes?: string }, options?: RequestInit) { const body = createBackupRequestSchema.parse(payload) const result = createBackupJobResponseSchema.parse(await backupJson(baseUrl, "/api/backups/create", { ...options, method: "POST", body: JSON.stringify(body) })) + if (body.requestId && result.jobId !== body.requestId) throw new Error("Подтверждение относится к другому запросу резервного копирования") + if (JSON.stringify(result.serverIds) !== JSON.stringify(body.serverIds.map(String))) throw new Error("Не подтверждены выбранные серверы резервного копирования") if (result.total !== body.serverIds.length) throw new Error("Не подтверждено резервное копирование всех выбранных серверов") return result }