feat: add TallyNote local reimbursement ledger
TallyNote release / linux-x64 (push) Failing after 2m41s
TallyNote release / linux-x64 (push) Failing after 2m41s
This commit is contained in:
@@ -0,0 +1,294 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { mkdir, readlink, symlink, writeFile, readFile, stat, readdir } from "node:fs/promises";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { createHash, generateKeyPairSync, sign } from "node:crypto";
|
||||
import {
|
||||
atomicSwitchRelease,
|
||||
createSafeArchive,
|
||||
detectPlatform,
|
||||
downloadReleaseAsset,
|
||||
fetchReleaseMetadata,
|
||||
fetchReleaseText,
|
||||
extractSafeArchive,
|
||||
isNewerVersion,
|
||||
normalizeReleasePermissions,
|
||||
sanitizeAssetName,
|
||||
selectReleaseAsset,
|
||||
validateHttpsUrl,
|
||||
} from "../server/update.js";
|
||||
import { runUpdate } from "../server/cli/update.js";
|
||||
import { validateUpdateRequest } from "../server/cli/update.js";
|
||||
import { checkForUpdate, verifyReleaseSignature } from "../server/update-service.js";
|
||||
import { loadConfig, prepareDataDirectories } from "../server/config.js";
|
||||
import { openDatabase } from "../server/db/index.js";
|
||||
|
||||
const envKeys = ["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"];
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
for (const key of envKeys) delete process.env[key];
|
||||
});
|
||||
|
||||
describe("更新安全工具", () => {
|
||||
it("严格比较 SemVer、平台和 HTTPS 白名单", () => {
|
||||
expect(isNewerVersion("1.0.0", "1.1.0")).toBe(true);
|
||||
expect(isNewerVersion("1.0.0", "1.0.0-beta.1")).toBe(false);
|
||||
expect(detectPlatform("linux", "x86_64").target).toBe("linux-x64");
|
||||
const release = {
|
||||
version: "1.2.0",
|
||||
assets: [
|
||||
{ name: "tallynote-1.2.0-linux-arm64-glibc.tar.gz", url: "https://updates.example/arm" },
|
||||
{ name: "tallynote-1.2.0-linux-x64-glibc.tar.gz", url: "https://updates.example/x64" },
|
||||
],
|
||||
};
|
||||
expect(selectReleaseAsset(release, detectPlatform("linux", "x86_64"))?.name).toContain("linux-x64");
|
||||
expect(selectReleaseAsset({ version: "1.2.0", assets: [{ name: "tallynote-1.2.0-linux-arm64-glibc.tar.gz", url: "https://updates.example/arm" }] }, detectPlatform("linux", "x86_64"))).toBeUndefined();
|
||||
expect(() => validateHttpsUrl("http://updates.example/x64", { allowedHosts: ["updates.example"] })).toThrow();
|
||||
expect(() => sanitizeAssetName("../release.tar.gz")).toThrow();
|
||||
});
|
||||
|
||||
it("验证 SHA256SUMS 的 Ed25519 detached signature", () => {
|
||||
const { publicKey, privateKey } = generateKeyPairSync("ed25519");
|
||||
const payload = "a".repeat(64) + " tallynote.tar.gz\n";
|
||||
const signature = sign(null, Buffer.from(payload), privateKey).toString("base64");
|
||||
const pem = publicKey.export({ type: "spki", format: "pem" }).toString();
|
||||
expect(verifyReleaseSignature(payload, signature, pem)).toBe(true);
|
||||
expect(verifyReleaseSignature(payload, sign(null, Buffer.from(payload), privateKey), pem)).toBe(true);
|
||||
expect(verifyReleaseSignature(payload + "tampered", signature, pem)).toBe(false);
|
||||
});
|
||||
|
||||
it("拒绝把队列文件重定向到另一更新源", () => {
|
||||
process.env.TALLYNOTE_DATA_DIR = "/tmp/tallynote-request-test";
|
||||
process.env.TALLYNOTE_PUBLIC_ORIGIN = "http://127.0.0.1:3997";
|
||||
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";
|
||||
process.env.TALLYNOTE_UPDATE_REQUIRE_SIGNATURE = "true";
|
||||
const config = loadConfig();
|
||||
const base = {
|
||||
jobId: "00000000-0000-4000-8000-000000000001",
|
||||
version: "1.1.0",
|
||||
assetUrl: "https://updates.example/app.tar.gz",
|
||||
assetName: "app.tar.gz",
|
||||
expectedSha256: "a".repeat(64),
|
||||
requestedAt: Date.now(),
|
||||
currentLink: config.currentLink,
|
||||
releasesDir: config.releasesDir,
|
||||
dataDir: config.dataDir,
|
||||
};
|
||||
expect(() => validateUpdateRequest({ ...base, metadataUrl: "https://evil.example/latest" }, config)).toThrow(/请求源|主机/);
|
||||
expect(() => validateUpdateRequest({ ...base, metadataUrl: "https://updates.example/latest", requestedAt: Date.now() - 2 * 24 * 60 * 60 * 1000 }, config)).toThrow(/过期/);
|
||||
});
|
||||
|
||||
it("读取 metadata 和 SHA256 sidecar 时限制重定向主机", async () => {
|
||||
const digest = "a".repeat(64);
|
||||
globalThis.fetch = (async (input: string | URL) => {
|
||||
const url = input.toString();
|
||||
if (url.endsWith("/latest")) {
|
||||
return new Response(JSON.stringify({ tag_name: "v1.2.0", assets: [{ name: "SHA256SUMS", browser_download_url: "https://updates.example/SHA256SUMS" }, { name: "app-linux-x64.tar.gz", browser_download_url: "https://updates.example/app-linux-x64.tar.gz" }] }), { status: 200, headers: { "content-type": "application/json" } });
|
||||
}
|
||||
return new Response(`${digest} app-linux-x64.tar.gz\n`, { status: 200 });
|
||||
}) as typeof fetch;
|
||||
const metadata = await fetchReleaseMetadata("https://updates.example/latest", { allowedHosts: ["updates.example"] });
|
||||
expect(metadata.version).toBe("1.2.0");
|
||||
expect((await fetchReleaseText("https://updates.example/SHA256SUMS", { allowedHosts: ["updates.example"] })).trim()).toContain(digest);
|
||||
});
|
||||
|
||||
it("对没有 Content-Length 的 metadata 和 sidecar 响应执行流式大小限制", async () => {
|
||||
const oversized = "x".repeat(2 * 1024 * 1024 + 1);
|
||||
globalThis.fetch = (async (input: string | URL) => {
|
||||
const url = input.toString();
|
||||
return url.endsWith("/latest")
|
||||
? new Response(oversized, { status: 200 })
|
||||
: new Response(oversized, { status: 200 });
|
||||
}) as typeof fetch;
|
||||
await expect(fetchReleaseMetadata("https://updates.example/latest", { allowedHosts: ["updates.example"] })).rejects.toThrow("更新发布信息不可用");
|
||||
await expect(fetchReleaseText("https://updates.example/SHA256SUMS", { allowedHosts: ["updates.example"], maxBytes: 1024 })).rejects.toThrow("更新校验文件过大");
|
||||
});
|
||||
|
||||
it("不会把 SHA256SUMS.sig 误当成摘要清单", async () => {
|
||||
const dataDir = await mkdtemp(path.join(tmpdir(), "tallynote-update-sidecar-order-"));
|
||||
process.env.TALLYNOTE_DATA_DIR = dataDir;
|
||||
process.env.TALLYNOTE_PUBLIC_ORIGIN = "http://127.0.0.1:3998";
|
||||
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";
|
||||
process.env.TALLYNOTE_UPDATE_REQUIRE_SIGNATURE = "false";
|
||||
const config = loadConfig();
|
||||
prepareDataDirectories(config);
|
||||
const database = openDatabase(config);
|
||||
const digest = "e".repeat(64);
|
||||
const assetName = `tallynote-1.2.1-${detectPlatform().target}-glibc.tar.gz`;
|
||||
globalThis.fetch = (async (input: string | URL) => input.toString().endsWith("SHA256SUMS.sig")
|
||||
? new Response("not-a-digest")
|
||||
: input.toString().endsWith("SHA256SUMS")
|
||||
? new Response(`${digest} ${assetName}\n`)
|
||||
: new Response(JSON.stringify({ tag_name: "v1.2.1", assets: [{ name: "SHA256SUMS.sig", browser_download_url: "https://updates.example/SHA256SUMS.sig" }, { name: "SHA256SUMS", browser_download_url: "https://updates.example/SHA256SUMS" }, { name: assetName, browser_download_url: `https://updates.example/${assetName}` }] })));
|
||||
try {
|
||||
const result = await checkForUpdate(database.sqlite, config);
|
||||
expect(result.latest).toMatchObject({ compatible: true, integrityReady: true });
|
||||
} finally {
|
||||
database.sqlite.close();
|
||||
await rm(dataDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("下载流限制大小并返回摘要", async () => {
|
||||
const bytes = Buffer.from("release-bytes");
|
||||
const destinationRoot = await mkdtemp(path.join(tmpdir(), "tallynote-update-download-"));
|
||||
try {
|
||||
globalThis.fetch = (async () => new Response(bytes, { status: 200, headers: { "content-length": String(bytes.length) } })) as typeof fetch;
|
||||
const result = await downloadReleaseAsset("https://updates.example/release.tar.gz", path.join(destinationRoot, "release.tar.gz"), { allowedHosts: ["updates.example"], maxBytes: 1024 });
|
||||
expect(result.size).toBe(bytes.length);
|
||||
expect(result.sha256).toBe(createHash("sha256").update(bytes).digest("hex"));
|
||||
} finally {
|
||||
await rm(destinationRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("原子切换 current 符号链接并保留旧版本", async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "tallynote-update-switch-"));
|
||||
try {
|
||||
const releases = path.join(root, "releases");
|
||||
const current = path.join(root, "current");
|
||||
const old = path.join(releases, "1.0.0");
|
||||
const staged = path.join(root, "staged");
|
||||
await mkdir(path.join(old, "dist"), { recursive: true });
|
||||
await writeFile(path.join(old, "dist", "marker"), "old");
|
||||
await mkdir(path.join(staged, "dist"), { recursive: true });
|
||||
await writeFile(path.join(staged, "dist", "marker"), "new");
|
||||
await symlink(old, current);
|
||||
const result = await atomicSwitchRelease(staged, current, releases, "1.1.0");
|
||||
expect(await readlink(current)).toBe(path.join(releases, "1.1.0"));
|
||||
expect(result.previousTarget).toBe(path.relative(root, old));
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("runUpdate 校验摘要、解包并原子替换目录", async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "tallynote-update-run-"));
|
||||
try {
|
||||
const source = path.join(root, "source");
|
||||
const current = path.join(root, "current");
|
||||
const staging = path.join(root, "staging");
|
||||
const backup = path.join(root, "backups", "old.tar.gz");
|
||||
await mkdir(path.join(source, "dist"), { recursive: true });
|
||||
await writeFile(path.join(source, "dist", "marker"), "new");
|
||||
await mkdir(path.join(current, "dist"), { recursive: true });
|
||||
await writeFile(path.join(current, "dist", "marker"), "old");
|
||||
const archive = path.join(root, "release.tar.gz");
|
||||
await createSafeArchive(source, archive);
|
||||
const bytes = await readFile(archive);
|
||||
const digest = createHash("sha256").update(bytes).digest("hex");
|
||||
const fetchImpl = (async () => new Response(bytes, { status: 200, headers: { "content-length": String(bytes.length) } })) as typeof fetch;
|
||||
const result = await runUpdate({
|
||||
assetUrl: "https://updates.example/release.tar.gz",
|
||||
assetName: "release.tar.gz",
|
||||
version: "1.1.0",
|
||||
expectedSha256: digest,
|
||||
currentVersion: "1.0.0",
|
||||
currentDir: current,
|
||||
stagingDir: staging,
|
||||
backupArchivePath: backup,
|
||||
allowedHosts: ["updates.example"],
|
||||
fetchImpl,
|
||||
});
|
||||
expect(result.version).toBe("1.1.0");
|
||||
expect(await readFile(path.join(current, "dist", "marker"), "utf8")).toBe("new");
|
||||
expect((await stat(backup)).size).toBeGreaterThan(0);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("流式解包在展开大小上限前拒绝高压缩比归档,并修正发布树权限", async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "tallynote-update-stream-"));
|
||||
try {
|
||||
const source = path.join(root, "source");
|
||||
const destination = path.join(root, "destination");
|
||||
await mkdir(path.join(source, "dist", "server"), { recursive: true });
|
||||
await mkdir(path.join(source, "bin"), { recursive: true });
|
||||
await mkdir(path.join(source, "scripts"), { recursive: true });
|
||||
await mkdir(path.join(source, "runtime", "bin"), { recursive: true });
|
||||
await writeFile(path.join(source, "dist", "server", "large.js"), Buffer.alloc(2 * 1024 * 1024, 0x41));
|
||||
await writeFile(path.join(source, "bin", "tallynote"), "#!/bin/sh\n");
|
||||
await writeFile(path.join(source, "scripts", "runner.sh"), "#!/bin/sh\n");
|
||||
await writeFile(path.join(source, "runtime", "bin", "node"), "node");
|
||||
const archive = path.join(root, "release.tar.gz");
|
||||
await createSafeArchive(source, archive);
|
||||
expect((await stat(archive)).size).toBeLessThan(64 * 1024);
|
||||
await expect(extractSafeArchive(archive, destination, { maxBytes: 1024 * 1024 })).rejects.toThrow(/大小限制/);
|
||||
expect(await stat(destination).catch(() => null)).toBeNull();
|
||||
|
||||
await extractSafeArchive(archive, destination, { maxBytes: 4 * 1024 * 1024 });
|
||||
await normalizeReleasePermissions(destination);
|
||||
expect((await stat(path.join(destination, "dist"))).mode & 0o777).toBe(0o755);
|
||||
expect((await stat(path.join(destination, "dist", "server", "large.js"))).mode & 0o777).toBe(0o644);
|
||||
expect((await stat(path.join(destination, "bin", "tallynote"))).mode & 0o777).toBe(0o755);
|
||||
expect((await stat(path.join(destination, "scripts", "runner.sh"))).mode & 0o777).toBe(0o755);
|
||||
expect((await stat(path.join(destination, "runtime", "bin", "node"))).mode & 0o777).toBe(0o755);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("流式创建备份遵守大小上限并清理失败的临时文件", async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "tallynote-update-archive-"));
|
||||
try {
|
||||
const source = path.join(root, "source");
|
||||
const archive = path.join(root, "backup.tar.gz");
|
||||
await mkdir(source, { recursive: true });
|
||||
await writeFile(path.join(source, "large.bin"), Buffer.alloc(128 * 1024, 0x42));
|
||||
await expect(createSafeArchive(source, archive, { maxBytes: 1024 })).rejects.toThrow(/大小限制/);
|
||||
expect(await stat(archive).catch(() => null)).toBeNull();
|
||||
expect((await readdir(root)).filter((name) => name.includes(".part-")).length).toBe(0);
|
||||
await createSafeArchive(source, archive, { maxBytes: 256 * 1024 });
|
||||
expect((await stat(archive)).size).toBeGreaterThan(0);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("更新元数据缓存", () => {
|
||||
it("选择当前平台资产并要求 SHA256 sidecar", async () => {
|
||||
const dataDir = await mkdtemp(path.join(tmpdir(), "tallynote-update-cache-"));
|
||||
process.env.TALLYNOTE_DATA_DIR = dataDir;
|
||||
process.env.TALLYNOTE_PUBLIC_ORIGIN = "http://127.0.0.1:3996";
|
||||
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";
|
||||
process.env.TALLYNOTE_UPDATE_REQUIRE_SIGNATURE = "true";
|
||||
const { publicKey, privateKey } = generateKeyPairSync("ed25519");
|
||||
const publicPem = publicKey.export({ type: "spki", format: "pem" }).toString();
|
||||
process.env.TALLYNOTE_UPDATE_PUBLIC_KEY = publicPem;
|
||||
const config = loadConfig();
|
||||
prepareDataDirectories(config);
|
||||
const database = openDatabase(config);
|
||||
const digest = "b".repeat(64);
|
||||
const platformAsset = `tallynote-1.1.0-${detectPlatform().target}-glibc.tar.gz`;
|
||||
const sums = `${digest} ${platformAsset}\n`;
|
||||
const signature = sign(null, Buffer.from(sums), privateKey);
|
||||
globalThis.fetch = (async (input: string | URL) => input.toString().endsWith("SHA256SUMS.sig")
|
||||
? new Response(signature)
|
||||
: input.toString().endsWith("SHA256SUMS")
|
||||
? new Response(sums)
|
||||
: new Response(JSON.stringify({ tag_name: "v1.1.0", published_at: "2026-08-28T00:00:00Z", assets: [{ name: "SHA256SUMS", browser_download_url: "https://updates.example/SHA256SUMS" }, { name: "SHA256SUMS.sig", browser_download_url: "https://updates.example/SHA256SUMS.sig" }, { name: platformAsset, browser_download_url: `https://updates.example/${platformAsset}` }] }), { status: 200 })) as typeof fetch;
|
||||
try {
|
||||
const result = await checkForUpdate(database.sqlite, config);
|
||||
expect(result.latest).toMatchObject({ version: "1.1.0", compatible: true, integrityReady: true, signatureReady: true, isNewer: true });
|
||||
const cached = database.sqlite.prepare("SELECT value FROM system_settings WHERE key='update.release.v1'").get() as { value: string };
|
||||
expect(JSON.parse(cached.value).asset.sha256).toBe(digest);
|
||||
} finally {
|
||||
database.sqlite.close();
|
||||
await rm(dataDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user