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
This commit is contained in:
Qiufeng
2026-09-17 13:12:20 +08:00
parent 5afcd98ebd
commit ae8966baf6
12 changed files with 1145 additions and 71 deletions
+6 -2
View File
@@ -1,8 +1,12 @@
import { expect, test } from "@playwright/test";
// Keep the expected copy in one place so a product-wide copy refresh cannot
// silently desynchronise this suite from web-next/src/pages/auth/LoginPage.tsx.
const LOGIN_HEADING = "登录 TallyNote 工作台";
test("未登录时显示中文登录入口", async ({ page }) => {
await page.goto("/");
await expect(page.getByRole("heading", { name: "登录到 TallyNote", exact: true })).toBeVisible();
await expect(page.getByRole("heading", { name: LOGIN_HEADING, exact: true })).toBeVisible();
await expect(page.locator(".tn-login-header")).toHaveCount(0);
await expect(page.getByLabel("用户名", { exact: true })).toBeVisible();
await expect(page.getByLabel("密码", { exact: true })).toBeVisible();
@@ -17,7 +21,7 @@ for (const viewport of [
test(`未登录入口适配 ${viewport.width}px`, async ({ page }) => {
await page.setViewportSize(viewport);
await page.goto("/");
await expect(page.getByRole("heading", { name: "登录到 TallyNote", exact: true })).toBeVisible();
await expect(page.getByRole("heading", { name: LOGIN_HEADING, exact: true })).toBeVisible();
await expect(page.getByLabel("用户名", { exact: true })).toBeVisible();
await expect(page.getByLabel("密码", { exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "登录" })).toBeVisible();
+208
View File
@@ -0,0 +1,208 @@
import { afterEach, describe, expect, it } from "vitest";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { buildApp } from "../server/app.js";
import { loadConfig, prepareDataDirectories } from "../server/config.js";
import { openDatabase } from "../server/db/index.js";
import { createRateLimiter } from "../server/rate-limit.js";
const configKeys = [
"TALLYNOTE_DATA_DIR",
"TALLYNOTE_PUBLIC_ORIGIN",
"TALLYNOTE_COOKIE_SECURE",
"TALLYNOTE_ALLOW_INSECURE_HTTP",
"TALLYNOTE_RATE_LIMIT_PER_MINUTE",
"TALLYNOTE_TRUST_PROXY",
"NODE_ENV",
"TALLYNOTE_ENV",
];
afterEach(() => { for (const key of configKeys) delete process.env[key]; });
describe("内存滑动窗口限流器", () => {
it("窗口内未超限时放行", () => {
let clock = 1_000;
const limiter = createRateLimiter({ limit: 3, windowMs: 60_000, now: () => clock });
expect(limiter.check("a")).toEqual({ allowed: true, retryAfterSeconds: 0 });
clock += 1_000;
expect(limiter.check("a")).toEqual({ allowed: true, retryAfterSeconds: 0 });
clock += 1_000;
expect(limiter.check("a")).toEqual({ allowed: true, retryAfterSeconds: 0 });
});
it("达到上限后拒绝并给出 Retry-After 秒数", () => {
let clock = 10_000;
const limiter = createRateLimiter({ limit: 2, windowMs: 30_000, now: () => clock });
expect(limiter.check("a").allowed).toBe(true);
expect(limiter.check("a").allowed).toBe(true);
clock += 5_000;
const denied = limiter.check("a");
expect(denied.allowed).toBe(false);
expect(denied.retryAfterSeconds).toBeGreaterThan(0);
// The window started at t=10000 and lasts 30s, so at t=15000 the caller
// must wait the remaining 25 seconds.
expect(denied.retryAfterSeconds).toBe(25);
});
it("窗口过期后计数重置并重新放行", () => {
let clock = 0;
const limiter = createRateLimiter({ limit: 1, windowMs: 1_000, now: () => clock });
expect(limiter.check("a").allowed).toBe(true);
expect(limiter.check("a").allowed).toBe(false);
// One millisecond before the window closes the key is still limited.
clock = 999;
expect(limiter.check("a").allowed).toBe(false);
clock = 1_000;
expect(limiter.check("a")).toEqual({ allowed: true, retryAfterSeconds: 0 });
// The reset key starts a brand-new window from the reset moment, so the
// same key is limited again until that new window also elapses.
clock = 1_500;
expect(limiter.check("a").allowed).toBe(false);
clock = 2_000;
expect(limiter.check("a")).toEqual({ allowed: true, retryAfterSeconds: 0 });
});
it("不同键互不影响", () => {
const limiter = createRateLimiter({ limit: 1, windowMs: 60_000, now: () => 0 });
expect(limiter.check("1.2.3.4").allowed).toBe(true);
expect(limiter.check("1.2.3.4").allowed).toBe(false);
expect(limiter.check("5.6.7.8").allowed).toBe(true);
expect(limiter.check("5.6.7.8").allowed).toBe(false);
expect(limiter.size()).toBe(2);
});
it("惰性清理过期桶,避免长期运行内存增长", () => {
let clock = 0;
const limiter = createRateLimiter({ limit: 10, windowMs: 1_000, now: () => clock });
for (let index = 0; index < 999; index += 1) limiter.check(`stale-${index}`);
expect(limiter.size()).toBe(999);
clock = 5_000;
// The sweep is amortized: only a periodic full pass removes dead keys, so
// the count must drop back to just the key currently receiving traffic.
for (let index = 0; index < 1_000; index += 1) limiter.check("noisy");
expect(limiter.size()).toBe(1);
});
it("拒绝无效的限流参数", () => {
expect(() => createRateLimiter({ limit: 0, windowMs: 1_000 })).toThrow(/limit/);
expect(() => createRateLimiter({ limit: 1.5, windowMs: 1_000 })).toThrow(/limit/);
expect(() => createRateLimiter({ limit: 1, windowMs: 0 })).toThrow(/窗口/);
});
});
describe("全局限流配置", () => {
function validConfigEnv() {
process.env.TALLYNOTE_PUBLIC_ORIGIN = "http://127.0.0.1:3996";
process.env.TALLYNOTE_COOKIE_SECURE = "false";
}
it("默认每分钟 600 次,并支持显式覆盖", () => {
validConfigEnv();
expect(loadConfig().apiRateLimitPerMinute).toBe(600);
process.env.TALLYNOTE_RATE_LIMIT_PER_MINUTE = "120";
expect(loadConfig().apiRateLimitPerMinute).toBe(120);
process.env.TALLYNOTE_RATE_LIMIT_PER_MINUTE = "1";
expect(loadConfig().apiRateLimitPerMinute).toBe(1);
});
it("拒绝非整数或小于 1 的限流值", () => {
validConfigEnv();
process.env.TALLYNOTE_RATE_LIMIT_PER_MINUTE = "0";
expect(() => loadConfig()).toThrow(/TALLYNOTE_RATE_LIMIT_PER_MINUTE/);
process.env.TALLYNOTE_RATE_LIMIT_PER_MINUTE = "-10";
expect(() => loadConfig()).toThrow(/TALLYNOTE_RATE_LIMIT_PER_MINUTE/);
process.env.TALLYNOTE_RATE_LIMIT_PER_MINUTE = "abc";
expect(() => loadConfig()).toThrow(/TALLYNOTE_RATE_LIMIT_PER_MINUTE/);
process.env.TALLYNOTE_RATE_LIMIT_PER_MINUTE = "12.5";
expect(() => loadConfig()).toThrow(/TALLYNOTE_RATE_LIMIT_PER_MINUTE/);
});
});
describe("全局限流接入 HTTP 层", () => {
const dataDirs: string[] = [];
afterEach(() => {
while (dataDirs.length > 0) rmSync(dataDirs.pop()!, { recursive: true, force: true });
});
async function buildLimitedApp(limit: string, withWeb = false) {
const dataDir = mkdtempSync(path.join(tmpdir(), "tallynote-rate-limit-"));
dataDirs.push(dataDir);
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_RATE_LIMIT_PER_MINUTE = limit;
const config = loadConfig();
// By default keep the test independent from the locally generated dist/web
// tree. When a real asset graph is requested the exemption must still hold,
// which proves it is path-based rather than an artefact of a missing webDir.
config.webDir = withWeb ? path.join(dataDir, "web") : path.join(dataDir, "missing-web");
prepareDataDirectories(config);
if (withWeb) {
mkdirSync(path.join(config.webDir, "assets"), { recursive: true });
writeFileSync(path.join(config.webDir, "index.html"), "<!doctype html><title>tallynote-test-index</title>");
writeFileSync(path.join(config.webDir, "assets", "probe.js"), "console.log('tallynote-test-asset');");
}
const database = openDatabase(config);
const app = await buildApp(database, config);
return { app, database };
}
it("超过配置的 /api/* 配额后返回 429 与 Retry-After,非 API 路径不受影响", async () => {
const { app, database } = await buildLimitedApp("2");
try {
// Static/health traffic is exempt: the limiter only owns /api/*.
for (let index = 0; index < 5; index += 1) {
const health = await app.inject({ method: "GET", url: "/health" });
expect(health.statusCode).toBe(200);
}
const first = await app.inject({ method: "GET", url: "/api/auth/status" });
expect(first.statusCode).toBe(200);
const second = await app.inject({ method: "GET", url: "/api/auth/status" });
expect(second.statusCode).toBe(200);
const limited = await app.inject({ method: "GET", url: "/api/auth/status" });
expect(limited.statusCode).toBe(429);
expect(limited.json().error.code).toBe("RATE_LIMITED");
expect(limited.json().error.message).toBe("请求过于频繁,请稍后再试");
expect(limited.json().error.requestId).toBeTruthy();
expect(Number(limited.headers["retry-after"])).toBeGreaterThan(0);
// The limiter must not touch the database: no new table, no writes to
// the login lockout table used by the stricter login protection.
const attempts = database.sqlite.prepare("SELECT COUNT(*) AS count FROM login_attempts").get() as { count: number };
expect(attempts.count).toBe(0);
} finally {
await app.close();
database.sqlite.close();
}
});
it("配额耗尽后静态资源与 SPA 回退仍可访问", async () => {
const { app, database } = await buildLimitedApp("1", true);
try {
// Spend the whole /api/* quota for this client.
const first = await app.inject({ method: "GET", url: "/api/auth/status" });
expect(first.statusCode).toBe(200);
const limited = await app.inject({ method: "GET", url: "/api/auth/status" });
expect(limited.statusCode).toBe(429);
// A limited client must still be able to load the page and its assets,
// otherwise recovery from the limit is impossible without a cache purge.
const index = await app.inject({ method: "GET", url: "/" });
expect(index.statusCode).toBe(200);
expect(index.body).toContain("tallynote-test-index");
const asset = await app.inject({ method: "GET", url: "/assets/probe.js" });
expect(asset.statusCode).toBe(200);
expect(asset.body).toContain("tallynote-test-asset");
// An unknown non-API path falls back to the SPA entry and stays exempt.
const fallback = await app.inject({ method: "GET", url: "/expenses" });
expect(fallback.statusCode).toBe(200);
expect(fallback.body).toContain("tallynote-test-index");
} finally {
await app.close();
database.sqlite.close();
}
});
});
+2 -1
View File
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { chmodSync, existsSync, mkdtempSync, readFileSync, statSync, rmSync } from "node:fs";
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, statSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { randomUUID } from "node:crypto";
@@ -8,6 +8,7 @@ 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";
import { reconcileOrphanedUpdateJobs } from "../server/update-service.js";
describe("更新 API", () => {
let dataDir: string;
+319
View File
@@ -0,0 +1,319 @@
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 });
}
});
});
+6
View File
@@ -346,6 +346,12 @@ describe("更新安全工具", () => {
insert.run(applyingId, "apply", "applying", config.appVersion, "linux-x64", "https://updates.example/applying.tar.gz", staleAt, staleAt);
insert.run(stagedId, "download", "staged", "9.9.9", "linux-x64", "https://updates.example/staged.tar.gz", staleAt, staleAt);
insert.run(stagedApplyId, "apply", "staged", "9.9.9", "linux-x64", "https://updates.example/staged-apply.tar.gz", staleAt, staleAt);
// A staged row is only actionable while its staged payload exists. The
// real download path always creates `update-<id>` before flipping a job
// to `staged`, so create the workspace here too; otherwise the fixture
// tests an impossible state where the row claims an artifact it never had.
await mkdir(path.join(config.stagingDir, `update-${stagedId}`), { recursive: true });
await mkdir(path.join(config.stagingDir, `update-${stagedApplyId}`), { recursive: true });
const now = Date.now();
expect(reconcileOrphanedUpdateJobs(database.sqlite, config, now)).toBe(3);
expect(database.sqlite.prepare("SELECT status FROM update_jobs WHERE id=?").get(queuedId)).toEqual({ status: "failed" });