feat(server): W5-A file manager module (/files)
Roots/list/stat with inline business status, path guard (realpath within registered roots, symlink escape, offline root 503), mkdir/rename syncing asset_location and path_binding revisions, two-phase move/copy/organize (planId + source facts), trash with reference checks and purgeTrash task, identify/probe/scrape, audit + idempotency keys. Runbook docs/runbook/files.md. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
09f6e3a3e5
commit
29cd10b27e
20 files changed
+4380
-1
No files matched your search
@@ -0,0 +1,348 @@
|
||||
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,
|
||||
},
|
||||
]);
|
||||
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();
|
||||
expect(rec).toEqual({ action: "relocate", 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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user