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); }); });