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,214 @@
|
||||
import { access, 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,
|
||||
seedAsset,
|
||||
seedBinding,
|
||||
seedDownload,
|
||||
seedEntity,
|
||||
} from "./harness.ts";
|
||||
|
||||
// 删除保护与回收站:引用检查(做种 / 资产 / 绑定 / 根本身)、退役联动、回收站往返、原位被占 409、purge 只清过期、
|
||||
// 永久删除开关与 admin 会话、API Key 需一次性审批。
|
||||
|
||||
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("trash");
|
||||
entity = await seedEntity(h.db, { kind: "movie", title: "Gone", year: 2001 });
|
||||
});
|
||||
afterAll(async () => h?.close());
|
||||
|
||||
const del = (body: Record<string, unknown>, k?: string) =>
|
||||
h.api("POST", "/files/delete", { idempotencyKey: key(), ...body }, k);
|
||||
|
||||
it("纳管做种文件 409;根本身拒绝;dryRun 列出引用", async () => {
|
||||
await writeFile(join(h.dl, "Seed.2020.mkv"), "seed");
|
||||
await seedDownload(h.db, h, { name: "Seed.2020.mkv", status: "completed" });
|
||||
const dry = await del({ items: [{ root: h.dlRoot, path: "Seed.2020.mkv" }], dryRun: true });
|
||||
expect(dry.status).toBe(200);
|
||||
expect(dry.body.data.checks[0]).toMatchObject({ blocked: "download_active" });
|
||||
expect(dry.body.data.checks[0].downloads[0].hnr).toBe("unknown");
|
||||
const exec = await del({ items: [{ root: h.dlRoot, path: "Seed.2020.mkv" }], dryRun: false });
|
||||
expect(exec.status).toBe(409);
|
||||
expect(await exists(join(h.dl, "Seed.2020.mkv"))).toBe(true);
|
||||
const root = await del({ items: [{ root: h.libRoot, path: "" }], dryRun: false });
|
||||
expect(root.status).toBe(400);
|
||||
});
|
||||
|
||||
it("已登记资产:未勾选退役 409;勾选后进回收站、location=removed、asset=retired、Slot→attention + 待处理", async () => {
|
||||
await mkdir(join(h.lib, "Gone (2001)"));
|
||||
await writeFile(join(h.lib, "Gone (2001)", "Gone.2001.mkv"), "gone");
|
||||
const a = await seedAsset(h.db, h, { entityId: entity, rel: "Gone (2001)/Gone.2001.mkv" });
|
||||
const sub = await h.db
|
||||
.insertInto("subscription")
|
||||
.values({ media_entity_id: entity, media_kind: "movie", mode: "continuous" })
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
const slot = await h.db
|
||||
.insertInto("acquisition_slot")
|
||||
.values({
|
||||
media_entity_id: entity,
|
||||
subscription_id: sub.id,
|
||||
name: "main",
|
||||
status: "satisfied",
|
||||
current_asset_id: a.assetId,
|
||||
})
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
const items = [{ root: h.libRoot, path: "Gone (2001)/Gone.2001.mkv" }];
|
||||
const no = await del({ items, dryRun: false });
|
||||
expect(no.status).toBe(409);
|
||||
expect(no.body.error.message).toMatch(/^needs_retire:/);
|
||||
|
||||
const ok = await del({ items, dryRun: false, retireAssets: true });
|
||||
expect(ok.status).toBe(200);
|
||||
expect(ok.body.data.trashed).toHaveLength(1);
|
||||
const t = ok.body.data.trashed[0];
|
||||
expect(t.trashPath).toContain(join(h.lib, ".umt-trash"));
|
||||
expect(await exists(join(h.lib, "Gone (2001)", "Gone.2001.mkv"))).toBe(false);
|
||||
expect(await readFile(t.trashPath, "utf8")).toBe("gone");
|
||||
const loc = await h.db
|
||||
.selectFrom("asset_location")
|
||||
.select(["status"])
|
||||
.where("id", "=", a.locationId)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(loc.status).toBe("removed");
|
||||
const asset = await h.db
|
||||
.selectFrom("media_asset")
|
||||
.select(["status"])
|
||||
.where("id", "=", a.assetId)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(asset.status).toBe("retired");
|
||||
const s = await h.db
|
||||
.selectFrom("acquisition_slot")
|
||||
.select(["status"])
|
||||
.where("id", "=", slot.id)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(s.status).toBe("attention");
|
||||
const dec = await h.db.selectFrom("decision").select(["subject_id"]).where("subject_id", "=", slot.id).execute();
|
||||
expect(dec).toHaveLength(1);
|
||||
|
||||
// 回收站列表 → 还原到原位
|
||||
const list = await h.api("GET", "/files/trash");
|
||||
expect(list.body.data.items.map((x: { id: string }) => x.id)).toContain(t.id);
|
||||
const restore = await h.api("POST", "/files/trash/restore", { idempotencyKey: key(), ids: [t.id] });
|
||||
expect(restore.status).toBe(200);
|
||||
expect(restore.body.data.restored).toEqual([{ id: t.id, root: h.libRoot, path: "Gone (2001)/Gone.2001.mkv" }]);
|
||||
expect(await readFile(join(h.lib, "Gone (2001)", "Gone.2001.mkv"), "utf8")).toBe("gone");
|
||||
});
|
||||
|
||||
it("还原时原位被占 → 该项 conflict;可还原到其他目录", async () => {
|
||||
await writeFile(join(h.lib, "dup.txt"), "v1");
|
||||
const d1 = await del({ items: [{ root: h.libRoot, path: "dup.txt" }], dryRun: false });
|
||||
const id = d1.body.data.trashed[0].id;
|
||||
await writeFile(join(h.lib, "dup.txt"), "v2");
|
||||
const r = await h.api("POST", "/files/trash/restore", { idempotencyKey: key(), ids: [id] });
|
||||
expect(r.body.data.failed).toEqual([expect.objectContaining({ id, code: "exists" })]);
|
||||
expect(await readFile(join(h.lib, "dup.txt"), "utf8")).toBe("v2");
|
||||
await mkdir(join(h.lib, "restored"));
|
||||
const r2 = await h.api("POST", "/files/trash/restore", {
|
||||
idempotencyKey: key(),
|
||||
ids: [id],
|
||||
target: { root: h.libRoot, path: "restored" },
|
||||
});
|
||||
expect(r2.body.data.restored[0].path).toBe("restored/dup.txt");
|
||||
expect(await readFile(join(h.lib, "restored", "dup.txt"), "utf8")).toBe("v1");
|
||||
});
|
||||
|
||||
it("绑定目录需勾选解除绑定", async () => {
|
||||
await mkdir(join(h.lib, "Bound"));
|
||||
const b = await seedBinding(h.db, h, { entityId: entity, rel: "Bound" });
|
||||
const no = await del({ items: [{ root: h.libRoot, path: "Bound" }], dryRun: false });
|
||||
expect(no.status).toBe(409);
|
||||
expect(no.body.error.message).toMatch(/^needs_unbind:/);
|
||||
const ok = await del({ items: [{ root: h.libRoot, path: "Bound" }], dryRun: false, unbind: true });
|
||||
expect(ok.status).toBe(200);
|
||||
const row = await h.db.selectFrom("path_binding").select(["status"]).where("id", "=", b).executeTakeFirstOrThrow();
|
||||
expect(row.status).toBe("superseded");
|
||||
});
|
||||
|
||||
it("purge 只清过期项;API Key 不能 purge", async () => {
|
||||
await writeFile(join(h.lib, "old.txt"), "o");
|
||||
await writeFile(join(h.lib, "new.txt"), "n");
|
||||
const a = await del({ items: [{ root: h.libRoot, path: "old.txt" }], dryRun: false });
|
||||
const b = await del({ items: [{ root: h.libRoot, path: "new.txt" }], dryRun: false });
|
||||
const oldId = a.body.data.trashed[0].id;
|
||||
const newId = b.body.data.trashed[0].id;
|
||||
await h.db
|
||||
.updateTable("trash_item")
|
||||
.set({ expires_at: new Date(Date.now() - 1000) })
|
||||
.where("id", "=", oldId)
|
||||
.execute();
|
||||
const denied = await h.api("POST", "/files/trash/purge", { idempotencyKey: key() }, EXEC_KEY);
|
||||
expect(denied.status).toBe(403);
|
||||
const r = await h.api("POST", "/files/trash/purge", { idempotencyKey: key() });
|
||||
expect(r.status).toBe(200);
|
||||
expect(r.body.data.purged).toEqual([oldId]);
|
||||
expect(await exists(a.body.data.trashed[0].trashPath)).toBe(false);
|
||||
expect(await exists(b.body.data.trashed[0].trashPath)).toBe(true);
|
||||
const left = await h.db.selectFrom("trash_item").select(["id"]).execute();
|
||||
expect(left.map((x) => x.id)).toContain(newId);
|
||||
expect(left.map((x) => x.id)).not.toContain(oldId);
|
||||
});
|
||||
|
||||
it("永久删除默认关闭;API Key 删除需一次性审批", async () => {
|
||||
await writeFile(join(h.lib, "perm.txt"), "p");
|
||||
const p = await del({ items: [{ root: h.libRoot, path: "perm.txt" }], mode: "permanent", dryRun: false });
|
||||
expect(p.status).toBe(403);
|
||||
expect(p.body.error.message).toMatch(/^permanent_disabled:/);
|
||||
|
||||
const items = [{ root: h.libRoot, path: "perm.txt" }];
|
||||
const dry = await del({ items, dryRun: true }, EXEC_KEY);
|
||||
expect(dry.status).toBe(200);
|
||||
const noApproval = await del({ items, dryRun: false, planId: dry.body.data.planId }, EXEC_KEY);
|
||||
expect(noApproval.status).toBe(403);
|
||||
expect(noApproval.body.error.message).toMatch(/^approval_required:/);
|
||||
// 管理员批准该计划(绑定内容哈希)后可执行一次
|
||||
const art = await h.db
|
||||
.selectFrom("artifact")
|
||||
.select(["content_hash"])
|
||||
.where("id", "=", dry.body.data.planId)
|
||||
.executeTakeFirstOrThrow();
|
||||
await h.db
|
||||
.insertInto("approval")
|
||||
.values({
|
||||
plan_type: "files_plan",
|
||||
plan_id: dry.body.data.planId,
|
||||
payload_hash: art.content_hash,
|
||||
expected_revision: 0,
|
||||
status: "approved",
|
||||
requested_by: "api_key:x",
|
||||
})
|
||||
.execute();
|
||||
const ok = await del({ items, dryRun: false, planId: dry.body.data.planId }, EXEC_KEY);
|
||||
expect(ok.status).toBe(200);
|
||||
expect(await exists(join(h.lib, "perm.txt"))).toBe(false);
|
||||
});
|
||||
|
||||
it("离线根:删除检查返回 root_unavailable,不当作已删除", async () => {
|
||||
const { rename } = await import("node:fs/promises");
|
||||
await writeFile(join(h.lib, "keep.txt"), "k");
|
||||
await rename(join(h.lib, ".uhd-root"), join(h.lib, ".uhd-root.bak"));
|
||||
try {
|
||||
const r = await del({ items: [{ root: h.libRoot, path: "keep.txt" }], dryRun: false });
|
||||
expect(r.status).toBe(503);
|
||||
} finally {
|
||||
await rename(join(h.lib, ".uhd-root.bak"), join(h.lib, ".uhd-root"));
|
||||
}
|
||||
expect(await exists(join(h.lib, "keep.txt"))).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user