Files
UHD_Media_Tool/apps/server/test/file-manager/write.test.ts
T
rootandClaude Opus 5.5 36aa99b8aa feat(files): W5-A2 move/copy via planImport, organize overrides, delete records, download
- /files/move|copy go through planImport's new `transfer` variant (keep names, directory
  expanded to per-file plan) → organization_record + verifyAndImport/executeRecord: per-file
  ledger resumable, fsproxy, cross-volume directory move = per-file copy→verify→delete then
  remove empty source dirs; shared plan store + plan_stale; asset_location follows per file,
  path_binding revised when a whole directory moved (finalizeTransfer); request waits up to
  FILES_TRANSFER_WAIT_SEC, else executed.pending
- /files/organize per-item overrides take effect: season/episodes (domain planOrganize
  overrides), contentUnitId coverage, targetDir (no binding), operation, slotId (satisfied
  after verify); warnings only for inapplicable overrides
- delete/trash/restore write organization_record (action trash|delete|restore, file-level
  results; migration 0026)
- GET /files/download (admin session, nfo/subtitle/image, size cap, streamed, audited)
- GET /files/records?ids|planIds for the organize page
- fs race errors map to 503/404/403/400 instead of 500
- runbook updated

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-27 23:53:50 +08:00

352 lines
14 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);
});
});