import assert from "node:assert/strict" import { test } from "node:test" import { listBackups, downloadBackup, 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", 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 () => { 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", serverIds: ["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({ 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 } }) 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({ 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)) } finally { globalThis.fetch = original } }) test("settings acknowledgment must match submitted fields and confirm a new secret", async () => { const original = globalThis.fetch try { globalThis.fetch = async () => json(schedule); await assert.rejects(() => putBackupScheduleSettings(base, { hour: 7 }), /не подтверждено/) globalThis.fetch = async () => json(storage); await assert.rejects(() => putBackupStorageSettings(base, { provider: "s3" }), /не подтверждено/) 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 } }) test("download encodes identity and refuses HTML/JSON success bodies", async () => { const original = globalThis.fetch; let url = "" try { for (const type of ["text/html", "application/json", ""]) { globalThis.fetch = async input => { url = String(input); return new Response("wrong body", { headers: { "Content-Type": type } }) }; await assert.rejects(() => downloadBackup(base, "a/b"), /не подтверждён/) } assert.ok(url.endsWith("a%2Fb/download")) 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 } })