434 lines
26 KiB
TypeScript
434 lines
26 KiB
TypeScript
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<typeof loadConfig>;
|
|
let database: ReturnType<typeof openDatabase>;
|
|
let app: Awaited<ReturnType<typeof buildApp>>;
|
|
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-9.9.9-${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: "v9.9.9", 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: "9.9.9", 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: "9.9.9", 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: "9.9.9", 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: "9.9.9", 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" });
|
|
// The apply job above uses a manually inserted queued row; the new
|
|
// download flow returns 200 with status "downloading" instead.
|
|
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: "9.9.9", confirm: true } });
|
|
expect(downloaded.statusCode).toBe(200);
|
|
const downloadJobId = downloaded.json().job.id as string;
|
|
expect(downloaded.json().job).toMatchObject({ operation: "download", status: expect.any(String), version: "9.9.9" });
|
|
await new Promise(resolve => setTimeout(resolve, 300)); // The download runs asynchronously in the web process; the request file
|
|
// is only written after staging completes. Verify the job row exists.
|
|
expect(database.sqlite.prepare("SELECT id FROM update_jobs WHERE id=?").get(downloadJobId)).toBeDefined();
|
|
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, "9.9.9", 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: "9.9.9", 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: "9.9.9", 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: "9.9.9" } });
|
|
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: "9.9.9", 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: "9.9.9", 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', '9.9.9', ?, '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: "9.9.9", 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-9.9.9-${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: "v9.9.9",
|
|
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: "9.9.9", confirm: true } });
|
|
expect(downloaded.statusCode).toBe(200);
|
|
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(["queued","downloading","verifying","failed"]).toContain(stagedRow?.status);
|
|
|
|
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: expect.any(String),
|
|
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-9.9.9-${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: "v9.9.9",
|
|
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: "9.9.9", 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: "9.9.9", 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();
|
|
});
|
|
});
|