Files
UHD_Media_Tool/apps/server/test/file-manager/write.test.ts
T
rootandClaude Opus 5.5 17cf3cbf97 fix(files,security): S-1 per-source Move authorization, S-2 session-only annotation, S-5 restore guards
S-1 (/files/move, /files/organize action=move):
- organize-guard: authorizeMoveEntities checks every source's media entities
  (declared entity plus entities of registered assets under the source);
  one-time approval still covers the whole request; any source that cannot
  be attributed to an entity only passes with an approval; denial lists every
  unauthorized item; optional non-consuming pre-check
- transfer: entities per source (directory = registered assets beneath it)
- organize: all move groups pre-checked before any group executes; any
  denial -> whole request 403 approval_required
S-2:
- api.ts: GET /files/download and POST /files/trash/purge marked
  auth:"session"; files routes attach requireAdminSession from the registry
  annotation; handler-side purge check removed, download keeps a same-rule
  defense check; permanent delete uses isAdminSession
- DEV key rule written down: equals an admin-scope key for session-only
  endpoints, still admin for Move/delete approval bypass (as authorizeMove)
- api-registry.test: session list updated; new reverse assertion that no
  non-session endpoint answers 403 to an admin-scope bearer
S-5 (trash restore):
- restore-to requires a writable managed library root (isWritableTarget)
- in-place restore rebuilds missing parents level by level without
  following symlinks (mkdirWithin), realpath-checked per level
- cross-volume restore capped by FILES_TRASH_CROSS_MAX_BYTES; cross-volume
  copies (trash in / restore out) go through fsproxy
Tests: multi-entity move 403 + listing, unattributed source, organize foreign
asset 403, download/purge 403 for DEV and admin-scope keys, restore target
not writable, symlinked parent, rebuild parents, cross-volume cap.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-28 00:45:56 +08:00

428 lines
17 KiB
TypeScript

import { access, appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
createFmHarness,
EXEC_KEY,
type FmHarness,
key,
pgAvailable,
READ_KEY,
seedAsset,
seedBinding,
seedDownload,
seedEntity,
} from "./harness.ts";
// 写接口:mkdir / rename(不覆盖、同步 asset_location + sidecar、目录改名写 path_binding 修订、做种 409)、
// move/copy 的 dryRun→execute 一致性与源变更拒绝、planId 与请求不符、幂等重放。
const skip = !(await pgAvailable());
const exists = (p: string) =>
access(p).then(
() => true,
() => false,
);
describe.skipIf(skip)("files 写操作", () => {
let h: FmHarness;
let entity: string;
beforeAll(async () => {
h = await createFmHarness("write");
entity = await seedEntity(h.db, { kind: "movie", title: "Movie", year: 2020 });
});
afterAll(async () => h?.close());
it("mkdir:非递归、已存在 409、非法名 400、只读 scope 403、下载根不可写", async () => {
const ok = await h.api("POST", "/files/mkdir", {
idempotencyKey: key(),
root: h.libRoot,
path: "",
name: "新目录",
});
expect(ok.status).toBe(200);
expect(await exists(join(h.lib, "新目录"))).toBe(true);
const dup = await h.api("POST", "/files/mkdir", {
idempotencyKey: key(),
root: h.libRoot,
path: "",
name: "新目录",
});
expect(dup.status).toBe(409);
const bad = await h.api("POST", "/files/mkdir", { idempotencyKey: key(), root: h.libRoot, path: "", name: "a/b" });
expect(bad.status).toBeGreaterThanOrEqual(400);
const trailing = await h.api("POST", "/files/mkdir", {
idempotencyKey: key(),
root: h.libRoot,
path: "",
name: "x.",
});
expect(trailing.status).toBe(400);
const ro = await h.api(
"POST",
"/files/mkdir",
{ idempotencyKey: key(), root: h.libRoot, path: "", name: "y" },
READ_KEY,
);
expect(ro.status).toBe(403);
const dl = await h.api("POST", "/files/mkdir", { idempotencyKey: key(), root: h.dlRoot, path: "", name: "y" });
expect(dl.status).toBe(403);
const audit = await h.db.selectFrom("audit_event").select(["action"]).where("action", "=", "files.mkdir").execute();
expect(audit).toHaveLength(1);
});
it("rename 文件:sidecar 一起改,asset_location 同步,同名存在 409 且原文件不动", async () => {
const dir = join(h.lib, "Movie (2020)");
await mkdir(dir);
await writeFile(join(dir, "Movie.2020.mkv"), "video");
await writeFile(join(dir, "Movie.2020.chs.srt"), "sub");
await writeFile(join(dir, "Taken.mkv"), "other");
const main = await seedAsset(h.db, h, { entityId: entity, rel: "Movie (2020)/Movie.2020.mkv" });
const sub = await seedAsset(h.db, h, {
entityId: entity,
rel: "Movie (2020)/Movie.2020.chs.srt",
kind: "subtitle",
sidecarOf: main.locationId,
});
const clash = await h.api("POST", "/files/rename", {
idempotencyKey: key(),
root: h.libRoot,
path: "Movie (2020)/Movie.2020.mkv",
newName: "Taken.mkv",
});
expect(clash.status).toBe(409);
expect(await readFile(join(dir, "Movie.2020.mkv"), "utf8")).toBe("video");
expect(await readFile(join(dir, "Taken.mkv"), "utf8")).toBe("other");
const preview = await h.api("POST", "/files/rename", {
idempotencyKey: key(),
root: h.libRoot,
path: "Movie (2020)/Movie.2020.mkv",
newName: "Movie (2020) 1080p.mkv",
dryRun: true,
});
expect(preview.status).toBe(200);
expect(preview.body.data.renames).toEqual([
{ from: "Movie (2020)/Movie.2020.mkv", to: "Movie (2020)/Movie (2020) 1080p.mkv", role: "main" },
{ from: "Movie (2020)/Movie.2020.chs.srt", to: "Movie (2020)/Movie (2020) 1080p.chs.srt", role: "sidecar" },
]);
expect(preview.body.data.assetLocations).toBe(2);
expect(await exists(join(dir, "Movie.2020.mkv"))).toBe(true);
const r = await h.api("POST", "/files/rename", {
idempotencyKey: key(),
root: h.libRoot,
path: "Movie (2020)/Movie.2020.mkv",
newName: "Movie (2020) 1080p.mkv",
});
expect(r.status).toBe(200);
expect(r.body.data.assetLocations).toBe(2);
expect(r.body.data.recordId).toBeTruthy();
expect(await exists(join(dir, "Movie (2020) 1080p.mkv"))).toBe(true);
expect(await exists(join(dir, "Movie (2020) 1080p.chs.srt"))).toBe(true);
const locs = await h.db
.selectFrom("asset_location")
.select(["id", "relative_path"])
.where("id", "in", [main.locationId, sub.locationId])
.execute();
expect(Object.fromEntries(locs.map((l) => [l.id, l.relative_path]))).toEqual({
[main.locationId]: "Movie (2020)/Movie (2020) 1080p.mkv",
[sub.locationId]: "Movie (2020)/Movie (2020) 1080p.chs.srt",
});
const rec = await h.db
.selectFrom("organization_record")
.select(["action", "status", "media_entity_id"])
.where("id", "=", r.body.data.recordId)
.executeTakeFirstOrThrow();
expect(rec).toMatchObject({ action: "rename", status: "succeeded", media_entity_id: entity });
const ops = await h.db.selectFrom("asset_operation").select(["kind"]).where("kind", "=", "rename").execute();
expect(ops.length).toBe(2);
expect(h.events.some((e) => e.type === "files.changed")).toBe(true);
});
it("rename 目录命中绑定:旧绑定 superseded 且链接到新绑定,目录下位置前缀替换", async () => {
const bindingId = await seedBinding(h.db, h, { entityId: entity, rel: "Movie (2020)" });
const r = await h.api("POST", "/files/rename", {
idempotencyKey: key(),
root: h.libRoot,
path: "Movie (2020)",
newName: "Movie 2020 Collection",
});
expect(r.status).toBe(200);
expect(r.body.data.bindings).toHaveLength(1);
const old = await h.db.selectFrom("path_binding").selectAll().where("id", "=", bindingId).executeTakeFirstOrThrow();
expect(old.status).toBe("superseded");
const nb = await h.db
.selectFrom("path_binding")
.selectAll()
.where("id", "=", old.superseded_by_id!)
.executeTakeFirstOrThrow();
expect(nb).toMatchObject({ status: "active", relative_path: "Movie 2020 Collection", media_entity_id: entity });
expect(nb.bound_by).toContain("dev_admin");
const locs = await h.db
.selectFrom("asset_location")
.select(["relative_path"])
.where("library_root_id", "=", h.libraryRootId)
.execute();
expect(locs.map((l) => l.relative_path).sort()).toEqual([
"Movie 2020 Collection/Movie (2020) 1080p.chs.srt",
"Movie 2020 Collection/Movie (2020) 1080p.mkv",
]);
});
it("rename:纳管做种目录 409;根本身 400", async () => {
await mkdir(join(h.dl, "Seeding.Pack"));
await writeFile(join(h.dl, "Seeding.Pack", "a.mkv"), "a");
await seedDownload(h.db, h, { name: "Seeding.Pack", status: "completed" });
const r = await h.api("POST", "/files/rename", {
idempotencyKey: key(),
root: h.dlRoot,
path: "Seeding.Pack",
newName: "Renamed.Pack",
});
expect(r.status).toBe(409);
expect(r.body.error.message).toMatch(/^download_active:/);
expect(await exists(join(h.dl, "Seeding.Pack"))).toBe(true);
const root = await h.api("POST", "/files/rename", {
idempotencyKey: key(),
root: h.libRoot,
path: "",
newName: "x",
});
expect(root.status).toBe(400);
});
it("move:dryRun → execute 一致;源变更 → plan_stale;请求与 planId 不符 → plan_stale;幂等重放不重复执行", async () => {
await mkdir(join(h.lib, "inbox"));
await writeFile(join(h.dl, "Loose.File.2019.mkv"), "loose-v1");
const body = {
items: [{ root: h.dlRoot, path: "Loose.File.2019.mkv" }],
target: { root: h.libRoot, path: "inbox" },
mode: "move",
};
const dry = await h.api("POST", "/files/move", { ...body, idempotencyKey: key(), dryRun: true });
expect(dry.status).toBe(200);
expect(dry.body.data.pairs).toEqual([
{
source: { root: h.dlRoot, path: "Loose.File.2019.mkv" },
target: { root: h.libRoot, path: "inbox/Loose.File.2019.mkv" },
size: 8,
conflict: false,
registered: false,
files: 1,
crossDevice: false,
},
]);
const planId = dry.body.data.planId;
// 执行必须带 planId
const noPlan = await h.api("POST", "/files/move", { ...body, idempotencyKey: key(), dryRun: false });
expect(noPlan.status).toBe(400);
// planId 与请求不符(换了目标)
await mkdir(join(h.lib, "other"));
const mismatch = await h.api("POST", "/files/move", {
...body,
target: { root: h.libRoot, path: "other" },
idempotencyKey: key(),
dryRun: false,
planId,
});
expect(mismatch.status).toBe(409);
expect(mismatch.body.error.message).toMatch(/^plan_stale:/);
// 源在预览后变化
await appendFile(join(h.dl, "Loose.File.2019.mkv"), "-changed");
const stale = await h.api("POST", "/files/move", { ...body, idempotencyKey: key(), dryRun: false, planId });
expect(stale.status).toBe(409);
expect(stale.body.error.message).toMatch(/^plan_stale:/);
expect(await exists(join(h.dl, "Loose.File.2019.mkv"))).toBe(true);
// 重新预览后执行;同一幂等键重放返回首次结果
const dry2 = await h.api("POST", "/files/move", { ...body, idempotencyKey: key(), dryRun: true });
const k = key();
const exec = await h.api("POST", "/files/move", {
...body,
idempotencyKey: k,
dryRun: false,
planId: dry2.body.data.planId,
});
expect(exec.status).toBe(200);
expect(exec.body.data.executed.succeeded).toEqual(["Loose.File.2019.mkv"]);
expect(exec.body.data.pairs).toEqual(dry2.body.data.pairs);
expect(await readFile(join(h.lib, "inbox", "Loose.File.2019.mkv"), "utf8")).toBe("loose-v1-changed");
expect(await exists(join(h.dl, "Loose.File.2019.mkv"))).toBe(false);
const replay = await h.api("POST", "/files/move", {
...body,
idempotencyKey: k,
dryRun: false,
planId: dry2.body.data.planId,
});
expect(replay.status).toBe(200);
expect(replay.body.data).toEqual(exec.body.data);
// 用过的 planId 不能再执行
const again = await h.api("POST", "/files/move", {
...body,
idempotencyKey: key(),
dryRun: false,
planId: dry2.body.data.planId,
});
expect(again.status).toBeGreaterThanOrEqual(400);
const rec = await h.db
.selectFrom("organization_record")
.select(["action", "status"])
.where("id", "=", exec.body.data.executed.recordId)
.executeTakeFirstOrThrow();
// W5-A2:搬运走 planImport → verifyAndImport,记录 action 与整理一致(move/copy)
expect(rec).toEqual({ action: "move", status: "succeeded" });
});
it("TOCTOU:预览后把源换成指向根外的符号链接,执行被拒绝且根外文件不动", async () => {
const { rm, symlink } = await import("node:fs/promises");
await writeFile(join(h.dl, "Swap.mkv"), "swap");
const body = {
items: [{ root: h.dlRoot, path: "Swap.mkv" }],
target: { root: h.libRoot, path: "inbox" },
mode: "move",
};
const dry = await h.api("POST", "/files/move", { ...body, idempotencyKey: key(), dryRun: true });
await rm(join(h.dl, "Swap.mkv"));
await symlink(join(h.outside, "secret.txt"), join(h.dl, "Swap.mkv"));
const r = await h.api("POST", "/files/move", {
...body,
idempotencyKey: key(),
dryRun: false,
planId: dry.body.data.planId,
});
expect(r.status).toBe(400);
expect(r.body.error.message).toMatch(/^path_escape:/);
expect(await readFile(join(h.outside, "secret.txt"), "utf8")).toBe("top secret\n");
expect(await exists(join(h.lib, "inbox", "Swap.mkv"))).toBe(false);
});
it("move 已登记资产:asset_location 跟随;API Key 无授权 → 403;copy 不删源", async () => {
await mkdir(join(h.lib, "A"));
await mkdir(join(h.lib, "B"));
await writeFile(join(h.lib, "A", "Reg.2018.mkv"), "reg");
const a = await seedAsset(h.db, h, { entityId: entity, rel: "A/Reg.2018.mkv" });
const body = {
items: [{ root: h.libRoot, path: "A/Reg.2018.mkv" }],
target: { root: h.libRoot, path: "B" },
mode: "move",
};
const dry = await h.api("POST", "/files/move", { ...body, idempotencyKey: key(), dryRun: true }, EXEC_KEY);
expect(dry.body.data.pairs[0].registered).toBe(true);
const denied = await h.api(
"POST",
"/files/move",
{ ...body, idempotencyKey: key(), dryRun: false, planId: dry.body.data.planId },
EXEC_KEY,
);
expect(denied.status).toBe(403);
const dry2 = await h.api("POST", "/files/move", { ...body, idempotencyKey: key(), dryRun: true });
const ok = await h.api("POST", "/files/move", {
...body,
idempotencyKey: key(),
dryRun: false,
planId: dry2.body.data.planId,
});
expect(ok.status).toBe(200);
const loc = await h.db
.selectFrom("asset_location")
.select(["relative_path"])
.where("id", "=", a.locationId)
.executeTakeFirstOrThrow();
expect(loc.relative_path).toBe("B/Reg.2018.mkv");
await writeFile(join(h.dl, "CopyMe.mkv"), "copy");
const cbody = { items: [{ root: h.dlRoot, path: "CopyMe.mkv" }], target: { root: h.libRoot, path: "A" } };
const cdry = await h.api("POST", "/files/copy", { ...cbody, idempotencyKey: key(), dryRun: true }, EXEC_KEY);
expect(cdry.status).toBe(200);
const cexec = await h.api(
"POST",
"/files/copy",
{ ...cbody, idempotencyKey: key(), dryRun: false, planId: cdry.body.data.planId },
EXEC_KEY,
);
expect(cexec.status).toBe(200);
expect(await readFile(join(h.lib, "A", "CopyMe.mkv"), "utf8")).toBe("copy");
expect(await exists(join(h.dl, "CopyMe.mkv"))).toBe(true);
});
it("多源 Move(验收 07 S-1):两个作品只授权一个 → 整体 403 并列出未授权项;都授权后放行;未归属作品的源只认审批", async () => {
const e1 = await seedEntity(h.db, { kind: "movie", title: "授权作品", year: 2011 });
const e2 = await seedEntity(h.db, { kind: "movie", title: "未授权作品", year: 2012 });
await mkdir(join(h.lib, "S1"));
await mkdir(join(h.lib, "S1dst"));
await mkdir(join(h.lib, "S1", "Two"));
await writeFile(join(h.lib, "S1", "One.2011.mkv"), "one");
await writeFile(join(h.lib, "S1", "Two", "Two.2012.mkv"), "two");
await seedAsset(h.db, h, { entityId: e1, rel: "S1/One.2011.mkv" });
await seedAsset(h.db, h, { entityId: e2, rel: "S1/Two/Two.2012.mkv" });
const g1 = await h.db
.insertInto("automation_grant")
.values({ action: "organize.execute", scope: JSON.stringify({ mediaEntityIds: [e1] }), granted_by: "test" })
.returning("id")
.executeTakeFirstOrThrow();
const body = {
items: [
{ root: h.libRoot, path: "S1/One.2011.mkv" },
{ root: h.libRoot, path: "S1/Two" },
],
target: { root: h.libRoot, path: "S1dst" },
mode: "move",
};
const run = async () => {
const dry = await h.api("POST", "/files/move", { ...body, idempotencyKey: key(), dryRun: true }, EXEC_KEY);
expect(dry.status).toBe(200);
return h.api(
"POST",
"/files/move",
{ ...body, idempotencyKey: key(), dryRun: false, planId: dry.body.data.planId },
EXEC_KEY,
);
};
const denied = await run();
expect(denied.status).toBe(403);
expect(denied.body.error.message).toMatch(/^approval_required:/);
expect(denied.body.error.message).toContain("S1/Two");
expect(denied.body.error.message).toContain(e2);
expect(denied.body.error.message).not.toContain("S1/One.2011.mkv(");
// 整体拒绝:两个源都没动
expect(await exists(join(h.lib, "S1", "One.2011.mkv"))).toBe(true);
expect(await exists(join(h.lib, "S1", "Two", "Two.2012.mkv"))).toBe(true);
const audit = await h.db
.selectFrom("audit_event")
.select(["payload"])
.where("action", "=", "files.move_denied")
.orderBy("at", "desc")
.executeTakeFirst();
expect(JSON.stringify(audit?.payload)).toContain(e2);
// 未登记文件(无法归属作品)混入:即使其余都有授权也拒绝
await writeFile(join(h.lib, "S1", "Loose.mkv"), "loose");
const g2 = await h.db
.insertInto("automation_grant")
.values({ action: "organize.execute", scope: JSON.stringify({ mediaEntityIds: [e2] }), granted_by: "test" })
.returning("id")
.executeTakeFirstOrThrow();
const loose = { ...body, items: [...body.items, { root: h.libRoot, path: "S1/Loose.mkv" }] };
const ldry = await h.api("POST", "/files/move", { ...loose, idempotencyKey: key(), dryRun: true }, EXEC_KEY);
const ldenied = await h.api(
"POST",
"/files/move",
{ ...loose, idempotencyKey: key(), dryRun: false, planId: ldry.body.data.planId },
EXEC_KEY,
);
expect(ldenied.status).toBe(403);
expect(ldenied.body.error.message).toContain("S1/Loose.mkv");
expect(ldenied.body.error.message).toContain("一次性审批");
// 两个作品都被覆盖 → 放行
const ok = await run();
expect(ok.status).toBe(200);
expect(await exists(join(h.lib, "S1dst", "One.2011.mkv"))).toBe(true);
expect(await exists(join(h.lib, "S1dst", "Two", "Two.2012.mkv"))).toBe(true);
await h.db.updateTable("automation_grant").set({ status: "revoked" }).where("id", "in", [g1.id, g2.id]).execute();
});
});