- 新增 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
320 lines
15 KiB
TypeScript
320 lines
15 KiB
TypeScript
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 });
|
||
}
|
||
});
|
||
});
|