Files
UHD_Media_Tool/apps/server/test/file-manager/trash.test.ts
T
rootandClaude Opus 5.5 4b49be7228 fix(files): 回收站清除先删文件再删行,顺带清过期的无登记 replaced-* 目录
- purge 先删文件、成功后再删行,删文件失败时行留着下次再清,不再留下没有登记的孤儿文件;
- 清过期项时扫各可写库根 .umt-trash/replaced-*,过期且没有任何回收站登记的整个删掉(替换旧文件登记回收站失败时留下的)。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
2026-10-03 23:37:18 +08:00

344 lines
16 KiB
TypeScript

import { access, mkdir, mkdtemp, readFile, rm, symlink, utimes, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { findCrossDeviceBase } from "@umt/testkit";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
ADMIN_KEY,
createFmHarness,
EXEC_KEY,
type FmHarness,
key,
pgAvailable,
SESSION,
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);
// 验收 07 S-2:DEV key 与 admin scope Key 同样 403,只认管理员会话
expect((await h.api("POST", "/files/trash/purge", { idempotencyKey: key() })).status).toBe(403);
expect((await h.api("POST", "/files/trash/purge", { idempotencyKey: key() }, ADMIN_KEY)).status).toBe(403);
const r = await h.api("POST", "/files/trash/purge", { idempotencyKey: key() }, SESSION);
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("purge 过期项时,过期且没有登记的 .umt-trash/replaced-* 一并清掉", async () => {
const oldBin = join(h.lib, ".umt-trash", "replaced-2020-01-01T00-00-00-000Z-aaaaaa");
const newBin = join(h.lib, ".umt-trash", "replaced-2099-01-01T00-00-00-000Z-bbbbbb");
await mkdir(oldBin, { recursive: true });
await mkdir(newBin, { recursive: true });
await writeFile(join(oldBin, "old.mkv"), "o");
await writeFile(join(newBin, "new.mkv"), "n");
const past = new Date(Date.now() - 30 * 86_400_000);
await utimes(oldBin, past, past);
const r = await h.api("POST", "/files/trash/purge", { idempotencyKey: key() }, SESSION);
expect(r.status).toBe(200);
expect(await exists(oldBin)).toBe(false);
expect(await exists(join(newBin, "new.mkv"))).toBe(true);
});
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);
// 重放:同名文件再出现,拿用过的 planId(审批已 consumed、计划已退役)再执行 → 拒绝,文件不动
await writeFile(join(h.lib, "perm.txt"), "p2");
const replay = await del({ items, dryRun: false, planId: dry.body.data.planId }, EXEC_KEY);
expect([403, 409]).toContain(replay.status);
expect(await readFile(join(h.lib, "perm.txt"), "utf8")).toBe("p2");
});
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);
});
});
// 验收 07 S-5:还原到… 的目标根可写检查;原位还原逐级建目录不跟随符号链接;跨卷还原受上限、经 fsproxy。
const xdev = findCrossDeviceBase();
describe.skipIf(skip)("files 回收站还原守卫", () => {
let h: FmHarness;
beforeAll(async () => {
h = await createFmHarness("trash5", { options: { trashCrossMaxBytes: 10 } });
});
afterAll(async () => h?.close());
const trashOne = async (rel: string, content: string) => {
await mkdir(join(h.lib, dirname(rel)), { recursive: true });
await writeFile(join(h.lib, rel), content);
const d = await h.api("POST", "/files/delete", {
idempotencyKey: key(),
items: [{ root: h.libRoot, path: rel }],
dryRun: false,
});
expect(d.status).toBe(200);
return d.body.data.trashed[0].id as string;
};
it("还原到下载根 / register_only 库根 → 403 not_writable,回收站条目不动", async () => {
const id = await trashOne("ro/a.txt", "a");
await mkdir(join(h.dl, "in"), { recursive: true });
const toDl = await h.api("POST", "/files/trash/restore", {
idempotencyKey: key(),
ids: [id],
target: { root: h.dlRoot, path: "in" },
});
expect(toDl.status).toBe(403);
expect(toDl.body.error.message).toMatch(/^not_writable:/);
const roDir = await mkdtemp(join(h.base, "ro-lib-"));
await writeFile(join(roDir, ".uhd-root"), "sentinel\n");
const ro = await h.db
.insertInto("library_root")
.values({ name: "只登记库", media_kind: "mixed", local_path: roDir, management_mode: "register_only" })
.returning("id")
.executeTakeFirstOrThrow();
const toRo = await h.api("POST", "/files/trash/restore", {
idempotencyKey: key(),
ids: [id],
target: { root: `lib:${ro.id}`, path: "" },
});
expect(toRo.status).toBe(403);
expect(await exists(join(roDir, "a.txt"))).toBe(false);
const left = await h.db.selectFrom("trash_item").select("id").where("id", "=", id).execute();
expect(left).toHaveLength(1);
});
it("原位还原:父目录被换成指向根外的符号链接 → path_escape,根外不会被建出目录", async () => {
const id = await trashOne("Esc/sub/file.txt", "f");
await rm(join(h.lib, "Esc"), { recursive: true, force: true });
await symlink(h.outside, join(h.lib, "Esc"));
const r = await h.api("POST", "/files/trash/restore", { idempotencyKey: key(), ids: [id] });
expect(r.status).toBe(200);
expect(r.body.data.restored).toEqual([]);
expect(r.body.data.failed).toEqual([expect.objectContaining({ id, code: "path_escape" })]);
expect(await exists(join(h.outside, "sub"))).toBe(false);
await rm(join(h.lib, "Esc"));
});
it("原位还原:父目录不存在时在根内逐级重建", async () => {
const id = await trashOne("Deep/er/x.txt", "x");
await rm(join(h.lib, "Deep"), { recursive: true, force: true });
const r = await h.api("POST", "/files/trash/restore", { idempotencyKey: key(), ids: [id] });
expect(r.body.data.restored).toEqual([{ id, root: h.libRoot, path: "Deep/er/x.txt" }]);
expect(await readFile(join(h.lib, "Deep", "er", "x.txt"), "utf8")).toBe("x");
});
it.skipIf(!xdev)(
"跨卷还原:超过 FILES_TRASH_CROSS_MAX_BYTES → too_large 不动;小文件经 fsproxy 复制后核验",
async () => {
const xlib = await mkdtemp(join(xdev!, "fm-xrestore-"));
try {
await writeFile(join(xlib, ".uhd-root"), "sentinel\n");
const x = await h.db
.insertInto("library_root")
.values({ name: "跨卷库", media_kind: "mixed", local_path: xlib, management_mode: "managed" })
.returning("id")
.executeTakeFirstOrThrow();
const big = await trashOne("xv/big.txt", "0123456789ABCDEF");
const small = await trashOne("xv/small.txt", "abc");
const r = await h.api("POST", "/files/trash/restore", {
idempotencyKey: key(),
ids: [big, small],
target: { root: `lib:${x.id}`, path: "" },
});
expect(r.status).toBe(200);
expect(r.body.data.failed).toEqual([expect.objectContaining({ id: big, code: "too_large" })]);
expect(r.body.data.restored).toEqual([{ id: small, root: `lib:${x.id}`, path: "small.txt" }]);
expect(await readFile(join(xlib, "small.txt"), "utf8")).toBe("abc");
expect(await exists(join(xlib, "big.txt"))).toBe(false);
const left = await h.db.selectFrom("trash_item").select("id").execute();
expect(left.map((l) => l.id)).toContain(big);
expect(left.map((l) => l.id)).not.toContain(small);
} finally {
await rm(xlib, { recursive: true, force: true });
}
},
);
});