fix(backups): проверять ответы и прогресс резервного копирования

This commit is contained in:
Denozordec committed 2026-10-01 14:55:03 +07:00
1 parent 46fe38321a
commit 4ae2954f97
6 files changed
+189 -113

No files matched your search

+82
View File
@@ -0,0 +1,82 @@
import assert from "node:assert/strict"
import { test } from "node:test"
import { listBackups, createBackupsAsync, getBackupJob, deleteBackup, restoreBackup, getBackupScheduleSettings, putBackupScheduleSettings, getBackupStorageSettings, putBackupStorageSettings, testBackupStorage, syncBackupsFromStorage } from "./backups"
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" }] }
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 () => {
const original = globalThis.fetch
try {
globalThis.fetch = async () => json([]); assert.deepEqual(await listBackups(base), [])
globalThis.fetch = async () => json([item]); assert.equal((await listBackups(base))[0].sizeBytes, 0)
for (const payload of [{}, [item, item], [{ ...item, sizeBytes: -1 }], [{ ...item, serverId: "1" }], [{ ...item, createdAt: "bad" }], [{ ...item, storage: undefined }]]) {
globalThis.fetch = async () => json(payload); await assert.rejects(() => listBackups(base))
}
} finally { globalThis.fetch = original }
})
test("settings require a confirmed response and preserve false rather than defaults", async () => {
const original = globalThis.fetch
try {
for (const read of [getBackupScheduleSettings, getBackupStorageSettings]) {
globalThis.fetch = async () => json({}); await assert.rejects(() => read(base))
globalThis.fetch = async () => new Response(null, { status: 204 }); await assert.rejects(() => read(base))
}
globalThis.fetch = async url => json(String(url).endsWith("schedule") ? schedule : storage)
assert.equal((await getBackupScheduleSettings(base)).enabled, false)
assert.equal((await getBackupStorageSettings(base)).secretConfigured, false)
assert.equal((await putBackupScheduleSettings(base, { enabled: false })).enabled, false)
assert.equal((await putBackupStorageSettings(base, { keepLocalCopy: false })).keepLocalCopy, false)
} finally { globalThis.fetch = original }
})
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) }
try {
await assert.rejects(() => createBackupsAsync(base, { serverIds: [] }))
await assert.rejects(() => createBackupsAsync(base, { serverIds: ["1", "1"] }))
await assert.rejects(() => createBackupsAsync(base, { serverIds: ["0"] }))
await assert.rejects(() => putBackupScheduleSettings(base, { hour: 24 }))
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({ created: [item], failures: [] }); await assert.rejects(() => createBackupsAsync(base, { serverIds: ["1"] }))
} finally { globalThis.fetch = original }
})
test("partial completion is valid, foreign and inconsistent job observations rejected", async () => {
const original = globalThis.fetch
try {
globalThis.fetch = async () => json(job)
assert.equal((await getBackupJob(base, "job-1")).failures[0].error, "RouterOS unavailable")
await assert.rejects(() => getBackupJob(base, "job-other"), /другой задаче/)
for (const patch of [{ completed: 3 }, { completed: 1 }, { finishedAt: undefined }, { created: [item, item] }, { failures: [] }]) {
globalThis.fetch = async () => json({ ...job, ...patch }); await assert.rejects(() => getBackupJob(base, "job-1"))
}
globalThis.fetch = async () => json({ ...job, status: "failed", completed: 1, failures: [{ serverId: "job", error: "interrupted" }] })
assert.equal((await getBackupJob(base, "job-1")).status, "failed")
} finally { globalThis.fetch = original }
})
test("delete requires 204, encodes ID and carries caller cancellation", async () => {
const original = globalThis.fetch; let url = "", signal: AbortSignal | null | undefined
try {
globalThis.fetch = async (input, init) => { url = String(input); signal = init?.signal; return json({ ok: true }) }
await assert.rejects(() => deleteBackup(base, "a/b"), /204/); assert.ok(url.endsWith("a%2Fb"))
globalThis.fetch = async (_input, init) => { signal = init?.signal; return new Response(null, { status: 204 }) }
const abort = new AbortController(); await deleteBackup(base, "backup-1", { signal: abort.signal }); abort.abort(); assert.equal(signal?.aborted, true)
globalThis.fetch = async () => json({ error: "Нет доступа" }, 403); await assert.rejects(() => deleteBackup(base, "backup-1"), /Нет доступа/)
} finally { globalThis.fetch = original }
})
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(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))
} finally { globalThis.fetch = original }
})
+47 -106
View File
@@ -1,122 +1,63 @@
import { requestJson } from "@/shared/api/http-client"
import type {
BackupScheduleSettingsDto,
BackupStorageSettingsDto,
PutBackupStorageSettings,
import {
backupJobResponseSchema,
backupScheduleSettingsDtoSchema,
backupStorageSettingsDtoSchema,
backupsListResponseSchema,
createBackupJobResponseSchema,
createBackupRequestSchema,
putBackupScheduleSettingsSchema,
putBackupStorageSettingsSchema,
restoreBackupResponseSchema,
syncBackupsResponseSchema,
} from "@mmapp/contracts/backups"
import type { BackupItemDto, BackupScheduleSettingsDto, PutBackupStorageSettings } from "@mmapp/contracts/backups"
import { requestBlob, requestJson } from "@/shared/api/http-client"
export type BackupItem = {
id: string
serverId: number | string | null
serverName: string
filename: string
sizeBytes: number
createdAt: string
kind: "manual" | "auto"
notes?: string
storage?: "local" | "s3" | "both"
s3Key?: string | null
uploadError?: string | null
}
export type BackupItem = BackupItemDto
export type { CreateBackupJobResponse, BackupJobStatusResponse } from "@mmapp/contracts/backups"
type CreateBackupResponse = {
created: BackupItem[]
failures: Array<{ serverId: string; error: string }>
function backupOptions(options?: RequestInit): RequestInit {
return { ...options, signal: AbortSignal.any([...(options?.signal ? [options.signal] : []), AbortSignal.timeout(30_000)]) }
}
export type CreateBackupJobResponse = {
jobId: string
status: "queued" | "running" | "done" | "failed"
total: number
completed: number
function backupJson(baseUrl: string, path: string, options?: RequestInit) {
return requestJson<unknown>(baseUrl, path, backupOptions(options))
}
export type BackupJobStatusResponse = {
id: string
status: "queued" | "running" | "done" | "failed"
requestedAt: string
startedAt?: string
finishedAt?: string
total: number
completed: number
created: BackupItem[]
failures: Array<{ serverId: string; error: string }>
export async function listBackups(baseUrl: string, options?: RequestInit) {
return backupsListResponseSchema.parse(await backupJson(baseUrl, "/api/backups", { ...options, method: "GET" }))
}
export async function listBackups(baseUrl: string): Promise<BackupItem[]> {
return requestJson<BackupItem[]>(baseUrl, "/api/backups")
export async function createBackupsAsync(baseUrl: string, payload: { serverIds: string[]; notes?: string }, options?: RequestInit) {
const body = createBackupRequestSchema.parse(payload)
return createBackupJobResponseSchema.parse(await backupJson(baseUrl, "/api/backups/create", { ...options, method: "POST", body: JSON.stringify(body) }))
}
export async function createBackups(
baseUrl: string,
payload: { serverIds: string[]; notes?: string },
): Promise<CreateBackupResponse> {
return requestJson<CreateBackupResponse>(baseUrl, "/api/backups/create", {
method: "POST",
body: JSON.stringify(payload),
})
export async function getBackupJob(baseUrl: string, jobId: string, options?: RequestInit) {
const result = backupJobResponseSchema.parse(await backupJson(baseUrl, "/api/backups/jobs/" + encodeURIComponent(jobId), { ...options, method: "GET" }))
if (result.id !== jobId) throw new Error("Ответ относится к другой задаче резервного копирования")
return result
}
export async function createBackupsAsync(
baseUrl: string,
payload: { serverIds: string[]; notes?: string },
): Promise<CreateBackupJobResponse> {
return requestJson<CreateBackupJobResponse>(baseUrl, "/api/backups/create", {
method: "POST",
body: JSON.stringify(payload),
})
export async function deleteBackup(baseUrl: string, id: string, options?: RequestInit): Promise<void> {
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 getBackupJob(baseUrl: string, jobId: string): Promise<BackupJobStatusResponse> {
return requestJson<BackupJobStatusResponse>(baseUrl, `/api/backups/jobs/${jobId}`)
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 deleteBackup(baseUrl: string, id: string): Promise<void> {
await requestJson<void>(baseUrl, `/api/backups/${id}`, { method: "DELETE" })
export async function getBackupScheduleSettings(baseUrl: string, options?: RequestInit) {
return backupScheduleSettingsDtoSchema.parse(await backupJson(baseUrl, "/api/backups/schedule", { ...options, method: "GET" }))
}
export async function restoreBackup(baseUrl: string, id: string): Promise<{ filename: string; serverName: string }> {
return requestJson<{ filename: string; serverName: string }>(baseUrl, `/api/backups/${id}/restore`, {
method: "POST",
})
export async function putBackupScheduleSettings(baseUrl: string, payload: Partial<BackupScheduleSettingsDto>, options?: RequestInit) {
const body = putBackupScheduleSettingsSchema.parse(payload)
return backupScheduleSettingsDtoSchema.parse(await backupJson(baseUrl, "/api/backups/schedule", { ...options, method: "PUT", body: JSON.stringify(body) }))
}
export async function getBackupScheduleSettings(baseUrl: string): Promise<BackupScheduleSettingsDto> {
return requestJson<BackupScheduleSettingsDto>(baseUrl, "/api/backups/schedule")
export async function getBackupStorageSettings(baseUrl: string, options?: RequestInit) {
return backupStorageSettingsDtoSchema.parse(await backupJson(baseUrl, "/api/backups/storage", { ...options, method: "GET" }))
}
export async function putBackupScheduleSettings(
baseUrl: string,
payload: Partial<BackupScheduleSettingsDto>,
): Promise<BackupScheduleSettingsDto> {
return requestJson<BackupScheduleSettingsDto>(baseUrl, "/api/backups/schedule", {
method: "PUT",
body: JSON.stringify(payload),
})
export async function putBackupStorageSettings(baseUrl: string, payload: PutBackupStorageSettings, options?: RequestInit) {
const body = putBackupStorageSettingsSchema.parse(payload)
return backupStorageSettingsDtoSchema.parse(await backupJson(baseUrl, "/api/backups/storage", { ...options, method: "PUT", body: JSON.stringify(body) }))
}
export async function getBackupStorageSettings(baseUrl: string): Promise<BackupStorageSettingsDto> {
return requestJson<BackupStorageSettingsDto>(baseUrl, "/api/backups/storage")
export async function testBackupStorage(baseUrl: string, options?: RequestInit) {
return backupStorageSettingsDtoSchema.parse(await backupJson(baseUrl, "/api/backups/storage/test", { ...options, method: "POST" }))
}
export async function putBackupStorageSettings(
baseUrl: string,
payload: PutBackupStorageSettings,
): Promise<BackupStorageSettingsDto> {
return requestJson<BackupStorageSettingsDto>(baseUrl, "/api/backups/storage", {
method: "PUT",
body: JSON.stringify(payload),
})
}
export async function testBackupStorage(baseUrl: string): Promise<BackupStorageSettingsDto> {
return requestJson<BackupStorageSettingsDto>(baseUrl, "/api/backups/storage/test", { method: "POST" })
}
export async function syncBackupsFromStorage(
baseUrl: string,
): Promise<{ imported: number; skipped: number }> {
return requestJson<{ imported: number; skipped: number }>(baseUrl, "/api/backups/storage/sync", {
method: "POST",
})
export async function syncBackupsFromStorage(baseUrl: string, options?: RequestInit) {
return syncBackupsResponseSchema.parse(await backupJson(baseUrl, "/api/backups/storage/sync", { ...options, method: "POST" }))
}