- /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]>
352 lines
14 KiB
TypeScript
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);
|
|
});
|
|
});
|