Files
TallyNote/tests/update-apply-staging.test.ts
T
Qiufeng ae8966baf6 fix: 修复在线更新暂存链路并增加全局 API 限流备底
- 新增 server/rate-limit.ts:进程内固定窗口限流器,无数据库写入
- server/app.ts 注册全局 preHandler,仅作用于 /api/*,超限返回 429 与 Retry-After
- 提取 isApiPath 统一 onSend、preHandler 与 404 的路径判断
- 更新任务冲突判定改用 ACTIVE_UPDATE_CONFLICT_SQL,staged/download 产物不再阻塞新任务
- cancelUpdateJob 调用补上 await,避免结果恒为 pending Promise
- server/cli/update.ts 增加特权工作区所有权校验与暂存路径重建逻辑
- 新增 tests/rate-limit.test.ts 与 tests/update-apply-staging.test.ts
2026-09-17 13:12:20 +08:00

320 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { createHash, randomUUID } from "node:crypto";
import { lstat, mkdir, mkdtemp, readFile, readlink, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { loadConfig, prepareDataDirectories, type AppConfig } from "../server/config.js";
import { openDatabase } from "../server/db/index.js";
import { main } from "../server/cli/update.js";
import { createSafeArchive, detectPlatform } from "../server/update.js";
/**
* Link-level coverage for the two-process update hand-off:
* the unprivileged web process stages a verified payload into
* `<dataDir>/staging/update-<jobId>`, then the privileged CLI (`main`) picks it
* up from a staged/apply DB row and switches the release.
*
* Ownership expectations are injected through `UpdateMainOverrides` because the
* suite runs as a non-root developer on macOS. Production defaults stay
* untouched: they never consult `process.getuid()`.
*/
const CURRENT_UID = process.getuid?.() ?? 0;
const NEW_VERSION = "9.9.9";
const METADATA_URL = "https://updates.example/latest";
const TRACKED_ENV = [
"TALLYNOTE_DATA_DIR",
"TALLYNOTE_INSTALL_PREFIX",
"TALLYNOTE_PUBLIC_ORIGIN",
"TALLYNOTE_COOKIE_SECURE",
"TALLYNOTE_UPDATE_STRATEGY",
"TALLYNOTE_UPDATE_METADATA_URL",
"TALLYNOTE_UPDATE_ALLOWED_HOSTS",
"TALLYNOTE_UPDATE_REQUIRE_SIGNATURE",
] as const;
const baselineEnv = new Map<string, string | undefined>(TRACKED_ENV.map((key) => [key, process.env[key]]));
const baselineArgv = [...process.argv];
afterEach(() => {
for (const key of TRACKED_ENV) {
const value = baselineEnv.get(key);
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
process.argv.splice(0, process.argv.length, ...baselineArgv);
});
type ApplyFixture = {
root: string;
config: AppConfig;
jobId: string;
stagedDir: string;
digest: string;
assetName: string;
};
type FixtureOptions = {
/** Shape of `<stagingDir>/update-<jobId>`: a real staged tree, a symlink
* masquerading as one, or nothing at all. */
stagedWorkspace?: "directory" | "symlink" | "absent";
/** Whether the staged archive that `assertStagedArchiveIntegrity` hashes. */
withArchive?: boolean;
/** Value written to `update_jobs.download_path`. The runner NULLs this column
* when it releases a workspace, so `null` is the post-runner production state. */
downloadPath?: "null" | "stale" | "outside-staging-root";
/** Whether the staged/apply row exists at all. */
withDatabaseRow?: boolean;
};
/** Build the exact on-disk state the web download step leaves behind before a
* privileged apply runs: release layout, staged workspace, staged/apply row and
* the request file the CLI is invoked with. */
async function setupApplyFixture(options: FixtureOptions = {}): Promise<ApplyFixture> {
const root = await mkdtemp(path.join(tmpdir(), "tallynote-apply-staging-"));
const dataDir = path.join(root, "data");
const installPrefix = path.join(root, "install");
process.env.TALLYNOTE_DATA_DIR = dataDir;
process.env.TALLYNOTE_INSTALL_PREFIX = installPrefix;
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 = METADATA_URL;
process.env.TALLYNOTE_UPDATE_ALLOWED_HOSTS = "updates.example";
process.env.TALLYNOTE_UPDATE_REQUIRE_SIGNATURE = "false";
const config = loadConfig();
prepareDataDirectories(config);
// Installer layout with a live current release so `atomicSwitchRelease` has a
// real previous target to report.
await mkdir(config.releasesDir, { recursive: true, mode: 0o755 });
const previousRelease = path.join(config.releasesDir, config.appVersion);
await mkdir(path.join(previousRelease, "dist"), { recursive: true, mode: 0o755 });
await writeFile(path.join(previousRelease, "dist", "marker"), "old");
await symlink(previousRelease, config.currentLink);
const assetName = `tallynote-${NEW_VERSION}-${detectPlatform().target}.tar.gz`;
const source = path.join(root, "release-source");
await mkdir(path.join(source, "dist"), { recursive: true, mode: 0o700 });
await writeFile(path.join(source, "dist", "marker"), "new");
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 jobId = randomUUID();
const stagedDir = path.join(config.stagingDir, `update-${jobId}`);
const stagedWorkspace = options.stagedWorkspace ?? "directory";
if (stagedWorkspace === "directory") {
await mkdir(path.join(stagedDir, "payload", "dist"), { recursive: true, mode: 0o700 });
await writeFile(path.join(stagedDir, "payload", "dist", "marker"), "new");
if (options.withArchive !== false) await writeFile(path.join(stagedDir, "release.tar.gz"), bytes, { mode: 0o600 });
} else if (stagedWorkspace === "symlink") {
// A symlinked workspace is the classic "swap the staged tree after the web
// process verified it" attack, and must never be followed by root.
const decoy = path.join(root, "decoy-workspace");
await mkdir(path.join(decoy, "payload", "dist"), { recursive: true, mode: 0o700 });
await writeFile(path.join(decoy, "payload", "dist", "marker"), "attacker");
await symlink(decoy, stagedDir);
}
let recordedDownloadPath: string | null = null;
if (options.downloadPath === "stale") recordedDownloadPath = path.join(root, "stale-workspace");
if (options.downloadPath === "outside-staging-root") {
recordedDownloadPath = path.join(root, "outside-workspace");
await mkdir(path.join(recordedDownloadPath, "payload", "dist"), { recursive: true, mode: 0o700 });
}
if (options.withDatabaseRow !== false) {
const database = openDatabase(config);
try {
const now = Date.now();
database.sqlite.prepare(`
INSERT INTO update_jobs(id, operation, status, version, platform, asset_name, asset_url,
expected_sha256, download_path, created_at, updated_at, requested_at)
VALUES (?, 'apply', 'staged', ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(jobId, NEW_VERSION, detectPlatform().target, assetName, `https://updates.example/${assetName}`, digest, recordedDownloadPath, now, now, now);
} finally {
database.sqlite.close();
}
}
await writeFile(config.updateRequestPath, JSON.stringify({
jobId,
operation: "apply",
version: NEW_VERSION,
metadataUrl: config.updateMetadataUrl,
assetUrl: `https://updates.example/${assetName}`,
assetName,
expectedSha256: digest,
requestedAt: Date.now(),
currentLink: config.currentLink,
releasesDir: config.releasesDir,
dataDir: config.dataDir,
}), { mode: 0o600 });
return { root, config, jobId, stagedDir, digest, assetName };
}
/** Invoke the privileged entry point the way the runner does: through the
* request file, which is the only path that reaches the staged apply branch. */
async function runMain(fixture: ApplyFixture, overrides: { stagingOwnerUid?: number; workspaceOwnerUid?: number } = {}): Promise<void> {
process.argv.push("--request-file", fixture.config.updateRequestPath);
await main(fixture.config, {
stagingOwnerUid: overrides.stagingOwnerUid ?? CURRENT_UID,
workspaceOwnerUid: overrides.workspaceOwnerUid ?? CURRENT_UID,
});
}
function readJob(config: AppConfig, jobId: string): { status: string; operation: string; errorMessage: string | null; downloadPath: string | null } | undefined {
const database = openDatabase(config);
try {
return database.sqlite.prepare("SELECT status, operation, error_message AS errorMessage, download_path AS downloadPath FROM update_jobs WHERE id=?").get(jobId) as
{ status: string; operation: string; errorMessage: string | null; downloadPath: string | null } | undefined;
} finally {
database.sqlite.close();
}
}
/** The audit row written by `failUpdateJobWithReason`, which carries the real
* machine-readable reason the UI renders instead of the runner's health text. */
function readFailureAudit(config: AppConfig, jobId: string): { action: string; outcome: string; afterJson: string } | undefined {
const database = openDatabase(config);
try {
return database.sqlite.prepare("SELECT action, outcome, after_json AS afterJson FROM audit_events WHERE target_id=? ORDER BY id DESC LIMIT 1").get(jobId) as
{ action: string; outcome: string; afterJson: string } | undefined;
} finally {
database.sqlite.close();
}
}
describe("web 暂存 → CLI apply 链路", () => {
it("场景 1:staged 行 + 暂存工作区存在时切换 current 到新 release", async () => {
const fixture = await setupApplyFixture();
try {
await runMain(fixture);
const link = await lstat(fixture.config.currentLink);
expect(link.isSymbolicLink()).toBe(true);
expect(await readlink(fixture.config.currentLink)).toBe(path.join(fixture.config.releasesDir, NEW_VERSION));
expect((await lstat(path.join(fixture.config.releasesDir, NEW_VERSION))).isDirectory()).toBe(true);
expect(await readFile(path.join(fixture.config.releasesDir, NEW_VERSION, "dist", "marker"), "utf8")).toBe("new");
// The web-owned staging tree is consumed and the row leaves the staged state.
expect(await lstat(fixture.stagedDir).catch(() => null)).toBeNull();
expect(readJob(fixture.config, fixture.jobId)).toMatchObject({ status: "applying", operation: "apply" });
} finally {
await rm(fixture.root, { recursive: true, force: true });
}
});
it("场景 2:download_path 为 NULL 时仍按 jobId 重建暂存工作区", async () => {
const fixture = await setupApplyFixture({ downloadPath: "null" });
try {
// Precondition: the runner already cleared the transient column.
expect(readJob(fixture.config, fixture.jobId)?.downloadPath).toBeNull();
await runMain(fixture);
expect(await readlink(fixture.config.currentLink)).toBe(path.join(fixture.config.releasesDir, NEW_VERSION));
expect(await readFile(path.join(fixture.config.releasesDir, NEW_VERSION, "dist", "marker"), "utf8")).toBe("new");
} finally {
await rm(fixture.root, { recursive: true, force: true });
}
});
it("场景 2b:download_path 指向已消失的陈旧路径时仍回退到 jobId 候选", async () => {
const fixture = await setupApplyFixture({ downloadPath: "stale" });
try {
expect(readJob(fixture.config, fixture.jobId)?.downloadPath).toBe(path.join(fixture.root, "stale-workspace"));
await runMain(fixture);
expect(await readlink(fixture.config.currentLink)).toBe(path.join(fixture.config.releasesDir, NEW_VERSION));
} finally {
await rm(fixture.root, { recursive: true, force: true });
}
});
it("场景 3:候选暂存目录不存在时拒绝并把行置为 failed", async () => {
const fixture = await setupApplyFixture({ stagedWorkspace: "absent" });
try {
await expect(runMain(fixture)).rejects.toThrow(/暂存目录已不存在/);
// No release may be published from a workspace that was never staged.
expect(await readlink(fixture.config.currentLink)).toBe(path.join(fixture.config.releasesDir, fixture.config.appVersion));
expect(await lstat(path.join(fixture.config.releasesDir, NEW_VERSION)).catch(() => null)).toBeNull();
const job = readJob(fixture.config, fixture.jobId);
expect(job?.status).toBe("failed");
expect(job?.errorMessage).toBe("暂存目录已不存在,请重新下载");
expect(readFailureAudit(fixture.config, fixture.jobId)).toMatchObject({ action: "update.failed", outcome: "failure" });
expect(JSON.parse(readFailureAudit(fixture.config, fixture.jobId)!.afterJson)).toMatchObject({ reason: "staged_workspace_missing" });
} finally {
await rm(fixture.root, { recursive: true, force: true });
}
});
it("场景 4a:暂存工作区是符号链接时拒绝执行", async () => {
const fixture = await setupApplyFixture({ stagedWorkspace: "symlink" });
try {
await expect(runMain(fixture)).rejects.toThrow(/更新暂存目录权限无效/);
// The decoy payload must never be promoted to a release.
expect(await readlink(fixture.config.currentLink)).toBe(path.join(fixture.config.releasesDir, fixture.config.appVersion));
expect(await lstat(path.join(fixture.config.releasesDir, NEW_VERSION)).catch(() => null)).toBeNull();
expect(readJob(fixture.config, fixture.jobId)?.status).toBe("failed");
expect(readJob(fixture.config, fixture.jobId)?.errorMessage).toBe("更新暂存目录权限无效");
expect(JSON.parse(readFailureAudit(fixture.config, fixture.jobId)!.afterJson)).toMatchObject({ reason: "staged_workspace_insecure" });
} finally {
await rm(fixture.root, { recursive: true, force: true });
}
});
it("场景 4b:记录路径位于 stagingDir 之外时拒绝,即使暂存目录缺失", async () => {
const fixture = await setupApplyFixture({ stagedWorkspace: "absent", downloadPath: "outside-staging-root" });
try {
await expect(runMain(fixture)).rejects.toThrow(/更新暂存路径无效/);
expect(await lstat(path.join(fixture.config.releasesDir, NEW_VERSION)).catch(() => null)).toBeNull();
expect(readJob(fixture.config, fixture.jobId)?.status).toBe("failed");
expect(JSON.parse(readFailureAudit(fixture.config, fixture.jobId)!.afterJson)).toMatchObject({ reason: "staged_workspace_invalid" });
} finally {
await rm(fixture.root, { recursive: true, force: true });
}
});
it("场景 5:暂存区属主与期望 uid 不符时拒绝", async () => {
const fixture = await setupApplyFixture();
try {
await expect(runMain(fixture, { stagingOwnerUid: CURRENT_UID + 1 })).rejects.toThrow(/更新暂存根目录权限无效/);
expect(await readlink(fixture.config.currentLink)).toBe(path.join(fixture.config.releasesDir, fixture.config.appVersion));
const job = readJob(fixture.config, fixture.jobId);
expect(job?.status).toBe("failed");
expect(job?.errorMessage).toBe("更新暂存根目录权限无效");
expect(JSON.parse(readFailureAudit(fixture.config, fixture.jobId)!.afterJson)).toMatchObject({ reason: "apply_precheck_failed" });
} finally {
await rm(fixture.root, { recursive: true, force: true });
}
});
it("场景 5b:工作区属主与期望 uid 不符时在 preflight 阶段拒绝", async () => {
const fixture = await setupApplyFixture();
try {
await expect(runMain(fixture, { workspaceOwnerUid: CURRENT_UID + 1 })).rejects.toThrow(/更新工作目录必须是 root 拥有且权限为 0700/);
expect(await lstat(path.join(fixture.config.releasesDir, NEW_VERSION)).catch(() => null)).toBeNull();
expect(await readlink(fixture.config.currentLink)).toBe(path.join(fixture.config.releasesDir, fixture.config.appVersion));
// The preflight rejection happens before the database is opened, so the
// row is left staged for the runner to finalize. Recorded as observed
// behavior, not asserted as a requirement.
expect(readJob(fixture.config, fixture.jobId)?.status).toBe("staged");
} finally {
await rm(fixture.root, { recursive: true, force: true });
}
});
});