import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { chmodSync, existsSync, mkdtempSync, readFileSync, statSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { randomUUID } from "node:crypto"; import { buildApp } from "../server/app.js"; import { loadConfig, prepareDataDirectories } from "../server/config.js"; import { openDatabase } from "../server/db/index.js"; import { hashPassword } from "../server/security.js"; import { detectPlatform } from "../server/update.js"; describe("更新 API", () => { let dataDir: string; let config: ReturnType; let database: ReturnType; let app: Awaited>; const originalFetch = globalThis.fetch; beforeEach(async () => { dataDir = mkdtempSync(path.join(tmpdir(), "tallynote-update-api-")); process.env.TALLYNOTE_DATA_DIR = dataDir; process.env.TALLYNOTE_PUBLIC_ORIGIN = "http://127.0.0.1:3995"; process.env.TALLYNOTE_COOKIE_SECURE = "false"; process.env.TALLYNOTE_UPDATE_STRATEGY = "systemd"; process.env.TALLYNOTE_UPDATE_METADATA_URL = "https://updates.example/latest"; process.env.TALLYNOTE_UPDATE_ALLOWED_HOSTS = "updates.example"; // This API fixture focuses on queue ownership; the signature path is // covered by update.test.ts with a generated Ed25519 key. process.env.TALLYNOTE_UPDATE_REQUIRE_SIGNATURE = "false"; config = loadConfig(); prepareDataDirectories(config); database = openDatabase(config); app = await buildApp(database, config); }); afterEach(async () => { globalThis.fetch = originalFetch; await app.close(); database.sqlite.close(); rmSync(dataDir, { recursive: true, force: true }); for (const key of ["TALLYNOTE_DATA_DIR", "TALLYNOTE_PUBLIC_ORIGIN", "TALLYNOTE_COOKIE_SECURE", "TALLYNOTE_UPDATE_STRATEGY", "TALLYNOTE_UPDATE_METADATA_URL", "TALLYNOTE_UPDATE_ALLOWED_HOSTS", "TALLYNOTE_UPDATE_REQUIRE_SIGNATURE", "TALLYNOTE_UPDATE_PUBLIC_KEY"]) delete process.env[key]; }); async function login(username = "update-admin") { const adminId = randomUUID(); const password = "UpdateApiPassword!2026"; const passwordHash = await hashPassword(password); database.sqlite.prepare(` INSERT INTO admins(id, username, username_norm, display_name, password_hash, status, must_change_password, auth_version, version, created_at) VALUES (?, ?, ?, ?, ?, 'active', 0, 1, 1, ?) `).run(adminId, username, username, `更新测试管理员-${username}`, passwordHash, Date.now()); const response = await app.inject({ method: "POST", url: "/api/auth/login", headers: { origin: config.publicOrigin }, payload: { username, password } }); const raw = response.headers["set-cookie"]; const cookies = (Array.isArray(raw) ? raw : [raw ?? ""]).map((value) => value.split(";", 1)[0]).join("; "); const csrf = /(?:^|; )tally_csrf=([^;]+)/.exec(cookies)?.[1] ?? ""; return { cookies, csrf }; } function mockRelease() { const digest = "c".repeat(64); const asset = `tallynote-1.3.0-${detectPlatform().target}-glibc.tar.gz`; globalThis.fetch = (async (input: string | URL) => input.toString().endsWith("SHA256SUMS") ? new Response(`${digest} ${asset}\n`, { status: 200 }) : new Response(JSON.stringify({ tag_name: "v1.3.0", assets: [{ name: "SHA256SUMS", browser_download_url: "https://updates.example/SHA256SUMS" }, { name: asset, browser_download_url: `https://updates.example/${asset}` }] }), { status: 200 })) as typeof fetch; } it("检查 release、创建受保护请求文件并拒绝重复任务", async () => { const session = await login(); mockRelease(); const checked = await app.inject({ method: "POST", url: "/api/update/check", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: {} }); expect(checked.statusCode).toBe(200); expect(checked.json().latest).toMatchObject({ version: "1.3.0", compatible: true, integrityReady: true, isNewer: true }); expect(checked.headers["cache-control"]).toBe("no-store"); const tooSoon = await app.inject({ method: "POST", url: "/api/update/check", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: {} }); expect(tooSoon.statusCode).toBe(429); expect(tooSoon.headers["retry-after"]).toBeDefined(); // Cooldown is scoped to the authenticated administrator, not the whole // database or release endpoint. const otherSession = await login("update-admin-other"); const otherChecked = await app.inject({ method: "POST", url: "/api/update/check", headers: { origin: config.publicOrigin, cookie: otherSession.cookies, "x-csrf-token": otherSession.csrf }, payload: {} }); expect(otherChecked.statusCode).toBe(200); const applied = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { version: "1.3.0", confirm: true } }); expect(applied.statusCode).toBe(202); const jobId = applied.json().job.id as string; const request = JSON.parse(readFileSync(config.updateRequestPath, "utf8")) as { jobId: string; expectedSha256: string; currentLink: string }; expect(request).toMatchObject({ jobId, version: "1.3.0", expectedSha256: "c".repeat(64), currentLink: config.currentLink }); expect(statSync(config.updateRequestPath).mode & 0o777).toBe(0o600); mockRelease(); const duplicate = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { version: "1.3.0", confirm: true } }); expect(duplicate.statusCode).toBe(409); expect(duplicate.json().error.code).toBe("UPDATE_IN_PROGRESS"); const status = await app.inject({ method: "GET", url: "/api/update/status", headers: { cookie: session.cookies } }); expect(status.json().job).toMatchObject({ id: jobId, status: "queued" }); const audit = database.sqlite.prepare("SELECT action FROM audit_events WHERE action LIKE 'update.%' ORDER BY id").all() as Array<{ action: string }>; expect(audit.map((row) => row.action)).toEqual(expect.arrayContaining(["update.checked", "update.apply_requested"])); }); it("先下载并暂存更新包,再由同一管理员认领应用", async () => { const session = await login("update-staged"); mockRelease(); const checked = await app.inject({ method: "POST", url: "/api/update/check", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: {} }); expect(checked.statusCode).toBe(200); const downloaded = await app.inject({ method: "POST", url: "/api/update/download", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { version: "1.3.0", confirm: true } }); expect(downloaded.statusCode).toBe(202); const downloadJobId = downloaded.json().job.id as string; expect(downloaded.json().job).toMatchObject({ operation: "download", status: "queued", version: "1.3.0" }); const downloadRequest = JSON.parse(readFileSync(config.updateRequestPath, "utf8")) as { jobId: string; operation: string }; expect(downloadRequest).toMatchObject({ jobId: downloadJobId, operation: "download" }); expect(database.sqlite.prepare("SELECT operation, status FROM update_jobs WHERE id=?").get(downloadJobId)).toEqual({ operation: "download", status: "queued" }); database.sqlite.prepare("UPDATE update_jobs SET status='failed', error_message='test', updated_at=? WHERE id=?").run(Date.now(), downloadJobId); const stagedId = randomUUID(); const now = Date.now(); database.sqlite.prepare(`INSERT INTO update_jobs(id, admin_id, session_hash, request_id, requested_at, operation, status, version, platform, release_url, asset_name, asset_url, expected_sha256, actual_sha256, download_path, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 'download', 'staged', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`) .run(stagedId, (await database.sqlite.prepare("SELECT id FROM admins WHERE username=?").get("update-staged") as { id: string }).id, "session", "staged-request", now, "1.3.0", detectPlatform().target, config.updateMetadataUrl, "release.tar.gz", "https://updates.example/release.tar.gz", "c".repeat(64), "c".repeat(64), path.join(config.dataDir, "staged-workspace"), now, now); const applied = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { jobId: stagedId, version: "1.3.0", confirm: true } }); expect(applied.statusCode).toBe(202); expect(applied.json().job).toMatchObject({ id: stagedId, operation: "apply", status: "staged" }); expect(database.sqlite.prepare("SELECT operation, status FROM update_jobs WHERE id=?").get(stagedId)).toEqual({ operation: "apply", status: "staged" }); const applyRequest = JSON.parse(readFileSync(config.updateRequestPath, "utf8")) as { jobId: string; operation: string; assetUrl: string; expectedSha256: string }; expect(applyRequest).toMatchObject({ jobId: stagedId, operation: "apply", assetUrl: "https://updates.example/release.tar.gz", expectedSha256: "c".repeat(64) }); const duplicate = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { jobId: stagedId, version: "1.3.0", confirm: true } }); expect(duplicate.statusCode).toBe(409); expect(duplicate.json().error.code).toBe("UPDATE_IN_PROGRESS"); }); it("缺少确认或未启用 systemd 时不接受更新", async () => { const session = await login(); const invalid = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { version: "1.3.0" } }); expect(invalid.statusCode).toBe(400); process.env.TALLYNOTE_UPDATE_STRATEGY = "disabled"; const disabledConfig = loadConfig(); expect(disabledConfig.updateStrategy).toBe("disabled"); }); it("首次进入状态页不会展示历史失败任务,也不会阻断新的检查", async () => { const session = await login("update-history"); const admin = database.sqlite.prepare("SELECT id FROM admins WHERE username=?").get("update-history") as { id: string }; const now = Date.now(); database.sqlite.prepare(` INSERT INTO update_jobs(id, admin_id, operation, status, version, platform, asset_url, error_message, created_at, updated_at) VALUES (?, ?, 'download', 'failed', '1.1.0', ?, 'https://updates.example/old.tar.gz', 'old failure', ?, ?) `).run(randomUUID(), admin.id, detectPlatform().target, now - 60_000, now - 60_000); const initial = await app.inject({ method: "GET", url: "/api/update/status", headers: { cookie: session.cookies } }); expect(initial.statusCode).toBe(200); expect(initial.json().job).toBeNull(); mockRelease(); const checked = await app.inject({ method: "POST", url: "/api/update/check", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: {} }); expect(checked.statusCode).toBe(200); expect(checked.json().latest).toMatchObject({ version: "1.3.0", isNewer: true }); }); it("不会应用已经等于当前版本的暂存更新", async () => { const session = await login("update-staged-current"); const admin = database.sqlite.prepare("SELECT id FROM admins WHERE username=?").get("update-staged-current") as { id: string }; const now = Date.now(); const stagedId = randomUUID(); database.sqlite.prepare(` INSERT INTO update_jobs( id, admin_id, operation, status, version, platform, release_url, asset_name, asset_url, expected_sha256, actual_sha256, download_path, created_at, updated_at ) VALUES (?, ?, 'download', 'staged', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( stagedId, admin.id, config.appVersion, detectPlatform().target, config.updateMetadataUrl, "current.tar.gz", "https://updates.example/current.tar.gz", "c".repeat(64), "c".repeat(64), path.join(config.dataDir, "staged-current"), now, now, ); const apply = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { jobId: stagedId, version: config.appVersion, confirm: true }, }); expect(apply.statusCode).toBe(409); // Reconciliation expires same-version staged jobs before the apply route // can consume them, so the public response is the generic not-staged // conflict while the database records the precise expiry reason. expect(apply.json().error.code).toBe("UPDATE_NOT_STAGED"); expect(database.sqlite.prepare("SELECT status, error_message AS errorMessage FROM update_jobs WHERE id=?").get(stagedId)).toEqual({ status: "failed", errorMessage: "暂存更新已过期,当前版本无需再次升级", }); const status = await app.inject({ method: "GET", url: "/api/update/status", headers: { cookie: session.cookies } }); expect(status.statusCode).toBe(200); expect(status.json().job).toBeNull(); }); it("更新任务只对发起管理员可见,并隐藏内部错误详情", async () => { const owner = await login("update-owner"); const other = await login("update-other"); mockRelease(); const checked = await app.inject({ method: "POST", url: "/api/update/check", headers: { origin: config.publicOrigin, cookie: owner.cookies, "x-csrf-token": owner.csrf }, payload: {} }); expect(checked.statusCode).toBe(200); const applied = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: owner.cookies, "x-csrf-token": owner.csrf }, payload: { version: "1.3.0", confirm: true } }); expect(applied.statusCode).toBe(202); const jobId = applied.json().job.id as string; database.sqlite.prepare("UPDATE update_jobs SET error_message=? WHERE id=?").run("/var/lib/tallynote/secret-command-output", jobId); const hiddenStatus = await app.inject({ method: "GET", url: "/api/update/status", headers: { cookie: other.cookies } }); expect(hiddenStatus.statusCode).toBe(200); expect(hiddenStatus.json().job).toBeNull(); const hiddenDetail = await app.inject({ method: "GET", url: `/api/update/jobs/${jobId}`, headers: { cookie: other.cookies } }); expect(hiddenDetail.statusCode).toBe(404); const ownDetail = await app.inject({ method: "GET", url: `/api/update/jobs/${jobId}`, headers: { cookie: owner.cookies } }); expect(ownDetail.statusCode).toBe(200); expect(ownDetail.json().job.errorMessage).toBe("更新失败,请查看服务器日志或重试"); }); it("取消任务按管理员隔离,并只删除匹配任务的请求文件", async () => { const owner = await login("cancel-owner"); const other = await login("cancel-other"); const ownerId = (database.sqlite.prepare("SELECT id FROM admins WHERE username=?").get("cancel-owner") as { id: string }).id; const otherId = (database.sqlite.prepare("SELECT id FROM admins WHERE username=?").get("cancel-other") as { id: string }).id; const now = Date.now(); const ownerJobId = randomUUID(); const otherJobId = randomUUID(); const insert = database.sqlite.prepare(` INSERT INTO update_jobs(id, admin_id, operation, status, version, platform, asset_url, created_at, updated_at) VALUES (?, ?, 'download', 'queued', '1.3.0', ?, 'https://updates.example/update.tar.gz', ?, ?) `); insert.run(ownerJobId, ownerId, detectPlatform().target, now, now); insert.run(otherJobId, otherId, detectPlatform().target, now + 1, now + 1); await import("node:fs/promises").then(({ writeFile }) => writeFile(config.updateRequestPath, JSON.stringify({ jobId: otherJobId }), { encoding: "utf8", mode: 0o600 })); const ownerCancel = await app.inject({ method: "POST", url: "/api/update/cancel", headers: { origin: config.publicOrigin, cookie: owner.cookies, "x-csrf-token": owner.csrf }, payload: { jobId: ownerJobId }, }); expect(ownerCancel.statusCode).toBe(200); expect((database.sqlite.prepare("SELECT status FROM update_jobs WHERE id=?").get(ownerJobId) as { status: string }).status).toBe("cancelled"); expect((database.sqlite.prepare("SELECT status FROM update_jobs WHERE id=?").get(otherJobId) as { status: string }).status).toBe("queued"); expect(existsSync(config.updateRequestPath)).toBe(true); const otherCancel = await app.inject({ method: "POST", url: "/api/update/cancel", headers: { origin: config.publicOrigin, cookie: other.cookies, "x-csrf-token": other.csrf }, payload: {}, }); expect(otherCancel.statusCode).toBe(200); expect((database.sqlite.prepare("SELECT status FROM update_jobs WHERE id=?").get(otherJobId) as { status: string }).status).toBe("cancelled"); expect(existsSync(config.updateRequestPath)).toBe(false); }); it("应用前重新校验失败时写入失败审计", async () => { const session = await login("update-audit"); globalThis.fetch = (async () => new Response("upstream unavailable", { status: 503 })) as typeof fetch; const response = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { version: "1.3.0", confirm: true } }); expect(response.statusCode).toBe(502); // A failed upstream check must not reserve the per-admin cooldown; an // operator can retry immediately after fixing the release endpoint. const check = await app.inject({ method: "POST", url: "/api/update/check", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: {} }); expect(check.statusCode).toBe(502); const retry = await app.inject({ method: "POST", url: "/api/update/check", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: {} }); expect(retry.statusCode).toBe(502); const audit = database.sqlite.prepare("SELECT outcome FROM audit_events WHERE action='update.apply_requested' ORDER BY id DESC LIMIT 1").get() as { outcome: string } | undefined; expect(audit?.outcome).toBe("failure"); }); it("下载请求交由 systemd runner 接管,并保留可查询的排队状态", async () => { const { createSafeArchive } = await import("../server/update.js"); const { createHash } = await import("node:crypto"); const { mkdirSync, writeFileSync } = await import("node:fs"); const payloadSource = mkdtempSync(path.join(tmpdir(), "tallynote-test-payload-")); mkdirSync(path.join(payloadSource, "dist"), { recursive: true }); writeFileSync(path.join(payloadSource, "dist", "server.js"), "console.log(1);"); const archivePath = path.join(tmpdir(), `tallynote-archive-${randomUUID()}.tar.gz`); await createSafeArchive(payloadSource, archivePath); const archiveBytes = readFileSync(archivePath); const digest = createHash("sha256").update(archiveBytes).digest("hex"); const assetName = `tallynote-1.3.0-${detectPlatform().target}-glibc.tar.gz`; globalThis.fetch = (async (input: string | URL) => { const url = input.toString(); if (url.endsWith("SHA256SUMS")) { return new Response(`${digest} ${assetName}\n`, { status: 200 }); } if (url.endsWith(assetName)) { return new Response(archiveBytes, { status: 200, headers: { "content-length": String(archiveBytes.length) } }); } return new Response(JSON.stringify({ tag_name: "v1.3.0", assets: [ { name: "SHA256SUMS", browser_download_url: "https://updates.example/SHA256SUMS" }, { name: assetName, browser_download_url: `https://updates.example/${assetName}` } ] }), { status: 200 }); }) as typeof fetch; const session = await login("update-inprocess"); const checked = await app.inject({ method: "POST", url: "/api/update/check", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: {} }); expect(checked.statusCode).toBe(200); const downloaded = await app.inject({ method: "POST", url: "/api/update/download", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { version: "1.3.0", confirm: true } }); expect(downloaded.statusCode).toBe(202); const downloadJobId = downloaded.json().job.id as string; const stagedRow = database.sqlite.prepare("SELECT status, actual_sha256, download_path FROM update_jobs WHERE id=?").get(downloadJobId) as any; expect(stagedRow?.status).toBe("queued"); expect(stagedRow?.actual_sha256).toBeNull(); expect(stagedRow?.download_path).toBeNull(); const statusRes = await app.inject({ method: "GET", url: "/api/update/status", headers: { cookie: session.cookies } }); expect(statusRes.statusCode).toBe(200); expect(statusRes.json().job).toMatchObject({ id: downloadJobId, status: "queued", operation: "download", assetName, assetUrl: `https://updates.example/${assetName}`, }); rmSync(payloadSource, { recursive: true, force: true }); rmSync(archivePath, { force: true }); }); it("管理员可取消 systemd 下载任务并清理请求文件", async () => { const { createSafeArchive } = await import("../server/update.js"); const { createHash } = await import("node:crypto"); const { mkdirSync, writeFileSync } = await import("node:fs"); const payloadSource = mkdtempSync(path.join(tmpdir(), "tallynote-cancel-payload-")); mkdirSync(path.join(payloadSource, "dist"), { recursive: true }); writeFileSync(path.join(payloadSource, "dist", "server.js"), "console.log(1);"); const archivePath = path.join(tmpdir(), `tallynote-cancel-${randomUUID()}.tar.gz`); await createSafeArchive(payloadSource, archivePath); const archiveBytes = readFileSync(archivePath); const digest = createHash("sha256").update(archiveBytes).digest("hex"); const assetName = `tallynote-1.3.0-${detectPlatform().target}-glibc.tar.gz`; // Mock a slow stream let fetchAborted = false; globalThis.fetch = (async (input: string | URL, init?: any) => { const url = input.toString(); if (url.endsWith("SHA256SUMS")) { return new Response(`${digest} ${assetName}\n`, { status: 200 }); } if (url.endsWith(assetName)) { init?.signal?.addEventListener("abort", () => { fetchAborted = true; }); const stream = new ReadableStream({ async start(controller) { controller.enqueue(archiveBytes.slice(0, 50)); // Simulate hanging network until aborted await new Promise((resolve) => { if (init?.signal?.aborted) return resolve(undefined); init?.signal?.addEventListener("abort", () => resolve(undefined)); }); controller.close(); } }); return new Response(stream, { status: 200, headers: { "content-length": String(archiveBytes.length) } }); } return new Response(JSON.stringify({ tag_name: "v1.3.0", assets: [ { name: "SHA256SUMS", browser_download_url: "https://updates.example/SHA256SUMS" }, { name: assetName, browser_download_url: `https://updates.example/${assetName}` } ] }), { status: 200 }); }) as typeof fetch; const session = await login("update-cancel-inprocess"); await app.inject({ method: "POST", url: "/api/update/check", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: {} }); const downloaded = await app.inject({ method: "POST", url: "/api/update/download", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { version: "1.3.0", confirm: true } }); const downloadJobId = downloaded.json().job.id as string; // Wait until status becomes downloading for (let i = 0; i < 30; i++) { await new Promise((r) => setTimeout(r, 30)); const row = database.sqlite.prepare("SELECT status FROM update_jobs WHERE id=?").get(downloadJobId) as any; if (row?.status === "downloading") break; } const cancelRes = await app.inject({ method: "POST", url: "/api/update/cancel", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { jobId: downloadJobId } }); expect(cancelRes.statusCode).toBe(200); expect(cancelRes.json().success).toBe(true); const cancelledRow = database.sqlite.prepare("SELECT status FROM update_jobs WHERE id=?").get(downloadJobId) as any; expect(cancelledRow?.status).toBe("cancelled"); expect(fetchAborted).toBe(false); rmSync(payloadSource, { recursive: true, force: true }); rmSync(archivePath, { force: true }); }); it("管理员可主动取消排队中的更新任务并清理请求文件", async () => { const session = await login("update-cancel"); mockRelease(); await app.inject({ method: "POST", url: "/api/update/check", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: {} }); const applied = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { version: "1.3.0", confirm: true } }); expect(applied.statusCode).toBe(202); expect(existsSync(config.updateRequestPath)).toBe(true); const cancelRes = await app.inject({ method: "POST", url: "/api/update/cancel", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: {} }); expect(cancelRes.statusCode).toBe(200); expect(cancelRes.json().success).toBe(true); expect(existsSync(config.updateRequestPath)).toBe(false); const statusRes = await app.inject({ method: "GET", url: "/api/update/status", headers: { cookie: session.cookies } }); expect(statusRes.statusCode).toBe(200); expect(statusRes.json().job).toBeNull(); }); });