feat(files): W5-A2 move/copy via planImport, organize overrides, delete records, download
- /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]>
This commit is contained in:
1 parent
3641ab9573
commit
36aa99b8aa
25 files changed
+1766
-271
No files matched your search
@@ -14,6 +14,7 @@ import { hashApiKey } from "../../src/http/auth.ts";
|
||||
import { EventBus } from "../../src/http/sse.ts";
|
||||
import { createLogger } from "../../src/logger.ts";
|
||||
import { fakeEngine } from "../../src/modules/acquisition-organize/__tests__/harness.ts";
|
||||
import { executeRecord, type FrozenPlan } from "../../src/modules/acquisition-organize/services/organize.ts";
|
||||
import { createCapabilityRegistry } from "../../src/modules/agent/capability-registry.ts";
|
||||
import { createFileManagerModule, type FmOverrides } from "../../src/modules/file-manager/index.ts";
|
||||
import type { ServerContext } from "../../src/modules/types.ts";
|
||||
@@ -47,6 +48,7 @@ type Loose = any;
|
||||
export interface FmHarness {
|
||||
db: Kysely<DB>;
|
||||
engine: ReturnType<typeof fakeEngine>;
|
||||
module: ReturnType<typeof createFileManagerModule>;
|
||||
events: { type: string; payload: Loose }[];
|
||||
base: string;
|
||||
lib: string;
|
||||
@@ -57,13 +59,16 @@ export interface FmHarness {
|
||||
libraryRootId: string;
|
||||
instanceId: string;
|
||||
api(method: string, path: string, body?: unknown, key?: string): Promise<{ status: number; body: Loose }>;
|
||||
/** 原始响应(非 JSON 接口,如 /files/download) */
|
||||
raw(method: string, path: string, key?: string): Promise<Response>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export async function createFmHarness(
|
||||
name: string,
|
||||
overrides: FmOverrides = {},
|
||||
opts: { catalog?: boolean } = {},
|
||||
/** runTransfers(默认 true):假引擎收到 /files 移动/复制的 verifyAndImport 时就地执行(模拟 worker) */
|
||||
opts: { catalog?: boolean; runTransfers?: boolean } = {},
|
||||
): Promise<FmHarness> {
|
||||
const schema = `fm_test_${name}_${process.pid.toString(36)}${Math.random().toString(36).slice(2, 7)}`;
|
||||
const admin = new pg.Client({ connectionString: DB_URL });
|
||||
@@ -144,8 +149,30 @@ export async function createFmHarness(
|
||||
...overrides.ao,
|
||||
options: { backgroundLoops: false, minVideoBytes: 10, verifyHash: "sha256", ...(overrides.ao?.options ?? {}) },
|
||||
},
|
||||
options: { backgroundLoops: false, dataDir: join(base, "data"), ...overrides.options },
|
||||
options: { backgroundLoops: false, dataDir: join(base, "data"), transferWaitMs: 10_000, ...overrides.options },
|
||||
});
|
||||
if (opts.runTransfers !== false) {
|
||||
const enqueue = engine.enqueue.bind(engine);
|
||||
engine.enqueue = async (task, input, o) => {
|
||||
const r = await enqueue(task, input, o);
|
||||
const recordId = (input as { recordId?: string }).recordId;
|
||||
if (task === "verifyAndImport" && recordId)
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
const rec = await db
|
||||
.selectFrom("organization_record")
|
||||
.select("plan")
|
||||
.where("id", "=", recordId)
|
||||
.executeTakeFirst();
|
||||
if ((rec?.plan as unknown as FrozenPlan | undefined)?.transfer)
|
||||
await executeRecord(module.deps().ao, recordId);
|
||||
} catch {
|
||||
// 模拟 worker:失败留给测试断言(记录保持 running)
|
||||
}
|
||||
}, 0);
|
||||
return r;
|
||||
};
|
||||
}
|
||||
const app = createApp({
|
||||
config: { NODE_ENV: "development", DEV_ADMIN_KEY: DEV_KEY },
|
||||
logger,
|
||||
@@ -178,6 +205,7 @@ export async function createFmHarness(
|
||||
return {
|
||||
db,
|
||||
engine,
|
||||
module,
|
||||
events,
|
||||
base,
|
||||
lib,
|
||||
@@ -198,6 +226,9 @@ export async function createFmHarness(
|
||||
});
|
||||
return { status: res.status, body: await res.json() };
|
||||
},
|
||||
raw(method, path, key = DEV_KEY) {
|
||||
return Promise.resolve(app.request(`/api/v1${path}`, { method, headers: { authorization: `Bearer ${key}` } }));
|
||||
},
|
||||
async close() {
|
||||
await db.destroy();
|
||||
const c = new pg.Client({ connectionString: DB_URL });
|
||||
|
||||
@@ -0,0 +1,414 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { access, copyFile, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { findCrossDeviceBase } from "@umt/testkit";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { executeRecord } from "../../src/modules/acquisition-organize/services/organize.ts";
|
||||
import { fsErrorToFiles } from "../../src/modules/file-manager/common.ts";
|
||||
import {
|
||||
createFmHarness,
|
||||
EXEC_KEY,
|
||||
type FmHarness,
|
||||
key,
|
||||
pgAvailable,
|
||||
READ_KEY,
|
||||
seedAsset,
|
||||
seedBinding,
|
||||
seedEntity,
|
||||
sql,
|
||||
} from "./harness.ts";
|
||||
|
||||
// W5-A2:移动/复制走 planImport(目录跨卷逐文件可恢复)、整理逐项覆写生效、删除/还原写整理记录、
|
||||
// 下载守卫、记录回查、文件系统竞态不 500。
|
||||
|
||||
const skip = !(await pgAvailable());
|
||||
const xdev = findCrossDeviceBase();
|
||||
const exists = (p: string) =>
|
||||
access(p).then(
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
|
||||
describe.skipIf(skip)("files W5-A2", () => {
|
||||
let h: FmHarness;
|
||||
beforeAll(async () => {
|
||||
h = await createFmHarness(
|
||||
"w5a2",
|
||||
{ options: { allowPermanentDelete: true, downloadMaxBytes: 100, transferWaitMs: 200 } },
|
||||
{ runTransfers: false },
|
||||
);
|
||||
});
|
||||
afterAll(async () => h?.close());
|
||||
|
||||
it.skipIf(!xdev)(
|
||||
"目录跨卷移动:逐文件 Copy→核验→删源;中途 kill 后续跑 resumed 对账;位置跟随、绑定修订、删空目录",
|
||||
async () => {
|
||||
// 第二个库根放在另一设备上
|
||||
const xlib = await mkdtemp(join(xdev!, "fm-xlib-"));
|
||||
try {
|
||||
await writeFile(join(xlib, ".uhd-root"), "sentinel\n");
|
||||
await mkdir(join(xlib, "tv"));
|
||||
const x = await h.db
|
||||
.insertInto("library_root")
|
||||
.values({ name: "跨卷库", media_kind: "mixed", local_path: xlib, management_mode: "managed" })
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
const show = await seedEntity(h.db, { kind: "series", title: "Show", year: 2020 });
|
||||
await mkdir(join(h.lib, "Show", "extras"), { recursive: true });
|
||||
await writeFile(join(h.lib, "Show", "Show.S01E01.mkv"), "episode-one");
|
||||
await writeFile(join(h.lib, "Show", "Show.S01E02.mkv"), "episode-two");
|
||||
await writeFile(join(h.lib, "Show", "extras", "bonus.nfo"), "<nfo/>");
|
||||
const a = await seedAsset(h.db, h, { entityId: show, rel: "Show/Show.S01E01.mkv" });
|
||||
const bind = await seedBinding(h.db, h, { entityId: show, rel: "Show" });
|
||||
|
||||
const body = {
|
||||
items: [{ root: h.libRoot, path: "Show" }],
|
||||
target: { root: `lib:${x.id}`, path: "tv" },
|
||||
mode: "move",
|
||||
};
|
||||
// 跨卷 Move 未开启:预览给 warning,执行 400(不产生记录)
|
||||
await sql`insert into settings (key, value) values ('organize', ${JSON.stringify({ allowCrossVolumeMove: false })}::jsonb)
|
||||
on conflict (key) do update set value = excluded.value`.execute(h.db);
|
||||
const off = await h.api("POST", "/files/move", { ...body, idempotencyKey: key(), dryRun: true });
|
||||
expect(off.body.data.warnings.join()).toMatch(/跨卷移动未开启/);
|
||||
const offExec = await h.api("POST", "/files/move", {
|
||||
...body,
|
||||
idempotencyKey: key(),
|
||||
dryRun: false,
|
||||
planId: off.body.data.planId,
|
||||
});
|
||||
expect(offExec.status).toBe(400);
|
||||
await sql`insert into settings (key, value) values ('organize', ${JSON.stringify({ allowCrossVolumeMove: true })}::jsonb)
|
||||
on conflict (key) do update set value = excluded.value`.execute(h.db);
|
||||
|
||||
const dry = await h.api("POST", "/files/move", { ...body, idempotencyKey: key(), dryRun: true });
|
||||
expect(dry.status).toBe(200);
|
||||
expect(dry.body.data.pairs[0]).toMatchObject({
|
||||
files: 3,
|
||||
crossDevice: true,
|
||||
registered: true,
|
||||
conflict: false,
|
||||
});
|
||||
expect(dry.body.data.warnings).toEqual([]);
|
||||
|
||||
// 执行:假引擎不跑 worker → 等待窗口内未完成,返回 pending + recordId/taskId
|
||||
const exec = await h.api("POST", "/files/move", {
|
||||
...body,
|
||||
idempotencyKey: key(),
|
||||
dryRun: false,
|
||||
planId: dry.body.data.planId,
|
||||
});
|
||||
expect(exec.status).toBe(200);
|
||||
const ex = exec.body.data.executed;
|
||||
expect(ex).toMatchObject({ pending: true, succeeded: [], failed: [], status: "running" });
|
||||
expect(ex.taskId).toBeTruthy();
|
||||
const recordId = ex.recordId as string;
|
||||
expect(h.engine.enqueued.at(-1)).toMatchObject({ task: "verifyAndImport", input: { recordId } });
|
||||
|
||||
// 模拟上一次 attempt:第一个文件已复制到目标,删源前 worker 被 kill(账本停在 started)
|
||||
const first = await h.db
|
||||
.selectFrom("organization_file_result")
|
||||
.select(["ordinal", "source_path", "target_path"])
|
||||
.where("organization_record_id", "=", recordId)
|
||||
.where("source_path", "=", join(h.lib, "Show", "Show.S01E01.mkv"))
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(first.target_path).toBe("tv/Show/Show.S01E01.mkv");
|
||||
await mkdir(join(xlib, "tv", "Show"), { recursive: true });
|
||||
await copyFile(first.source_path, join(xlib, first.target_path!));
|
||||
await sql`insert into tasks_op_ledger (op_key, attempt_token, attempts, status)
|
||||
values (${`org:${recordId}:${first.ordinal}`}, ${randomUUID()}, 1, 'started')`.execute(h.db);
|
||||
|
||||
const r = await executeRecord(h.module.deps().ao, recordId);
|
||||
expect(r.status).toBe("succeeded");
|
||||
expect(await readFile(join(xlib, "tv", "Show", "Show.S01E01.mkv"), "utf8")).toBe("episode-one");
|
||||
expect(await readFile(join(xlib, "tv", "Show", "Show.S01E02.mkv"), "utf8")).toBe("episode-two");
|
||||
expect(await readFile(join(xlib, "tv", "Show", "extras", "bonus.nfo"), "utf8")).toBe("<nfo/>");
|
||||
// 源目录(含空子目录)已删
|
||||
expect(await exists(join(h.lib, "Show"))).toBe(false);
|
||||
const op = await h.db
|
||||
.selectFrom("asset_operation")
|
||||
.select("result")
|
||||
.where("operation_id", "=", `org:${recordId}:${first.ordinal}`)
|
||||
.executeTakeFirstOrThrow();
|
||||
const res = op.result as { resumed?: boolean; steps?: { step: string; status: string }[] };
|
||||
expect(res.resumed).toBe(true);
|
||||
expect(res.steps?.find((s) => s.step === "copy")?.status).toBe("skipped");
|
||||
// 位置跟随到新库根;绑定写修订
|
||||
const loc = await h.db
|
||||
.selectFrom("asset_location")
|
||||
.select(["library_root_id", "relative_path"])
|
||||
.where("id", "=", a.locationId)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(loc).toEqual({ library_root_id: x.id, relative_path: "tv/Show/Show.S01E01.mkv" });
|
||||
const old = await h.db
|
||||
.selectFrom("path_binding")
|
||||
.select(["status", "superseded_by_id"])
|
||||
.where("id", "=", bind)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(old.status).toBe("superseded");
|
||||
const nb = await h.db
|
||||
.selectFrom("path_binding")
|
||||
.select(["library_root_id", "relative_path", "status"])
|
||||
.where("id", "=", old.superseded_by_id!)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(nb).toEqual({ library_root_id: x.id, relative_path: "tv/Show", status: "active" });
|
||||
const rec = await h.db
|
||||
.selectFrom("organization_record")
|
||||
.select(["action", "status", "file_succeeded", "media_entity_id"])
|
||||
.where("id", "=", recordId)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(rec).toEqual({ action: "move", status: "succeeded", file_succeeded: 3, media_entity_id: show });
|
||||
// 重复 finalize 幂等
|
||||
expect((await executeRecord(h.module.deps().ao, recordId)).status).toBe("succeeded");
|
||||
|
||||
// planId 回查整理记录(整理页预填)
|
||||
const q = await h.api(
|
||||
"GET",
|
||||
`/files/records?planIds=${dry.body.data.planId}&includeFiles=true`,
|
||||
undefined,
|
||||
READ_KEY,
|
||||
);
|
||||
expect(q.status).toBe(200);
|
||||
expect(q.body.data.planRecords[dry.body.data.planId]).toBe(recordId);
|
||||
expect(q.body.data.items[0].files).toHaveLength(3);
|
||||
} finally {
|
||||
await sql`delete from settings where key = 'organize'`.execute(h.db);
|
||||
await rm(xlib, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
60_000,
|
||||
);
|
||||
|
||||
it("复制目录走同一计划:目标已存在的文件不覆盖、记失败(partial),其余照常", async () => {
|
||||
await mkdir(join(h.dl, "Pack", "sub"), { recursive: true });
|
||||
await writeFile(join(h.dl, "Pack", "a.mkv"), "aaa");
|
||||
await writeFile(join(h.dl, "Pack", "sub", "b.srt"), "bbb");
|
||||
await mkdir(join(h.lib, "dest", "Pack"), { recursive: true });
|
||||
await writeFile(join(h.lib, "dest", "Pack", "a.mkv"), "old");
|
||||
const body = { items: [{ root: h.dlRoot, path: "Pack" }], target: { root: h.libRoot, path: "dest" } };
|
||||
const dry = await h.api("POST", "/files/copy", { ...body, idempotencyKey: key(), dryRun: true });
|
||||
expect(dry.status).toBe(200);
|
||||
expect(dry.body.data.pairs[0]).toMatchObject({ files: 2, conflict: true });
|
||||
expect(dry.body.data.warnings.join()).toMatch(/已存在/);
|
||||
const exec = await h.api("POST", "/files/copy", {
|
||||
...body,
|
||||
idempotencyKey: key(),
|
||||
dryRun: false,
|
||||
planId: dry.body.data.planId,
|
||||
});
|
||||
const recordId = exec.body.data.executed.recordId;
|
||||
const r = await executeRecord(h.module.deps().ao, recordId);
|
||||
expect(r.status).toBe("partial");
|
||||
expect(await readFile(join(h.lib, "dest", "Pack", "a.mkv"), "utf8")).toBe("old");
|
||||
expect(await readFile(join(h.lib, "dest", "Pack", "sub", "b.srt"), "utf8")).toBe("bbb");
|
||||
expect(await exists(join(h.dl, "Pack", "a.mkv"))).toBe(true);
|
||||
});
|
||||
|
||||
it("整理逐项覆写:季/集改变计划路径;目标目录与操作覆写分组;无法应用的才进 warnings;Slot 覆写在核验后生效", async () => {
|
||||
const series = await seedEntity(h.db, { kind: "series", title: "剧", year: 2020 });
|
||||
const film = await seedEntity(h.db, { kind: "movie", title: "Film", year: 2019 });
|
||||
await writeFile(join(h.dl, "weird-a.mkv"), "x".repeat(64));
|
||||
await writeFile(join(h.dl, "weird-b.mkv"), "y".repeat(64));
|
||||
await writeFile(join(h.dl, "Film.2019.1080p.mkv"), "z".repeat(64));
|
||||
await mkdir(join(h.lib, "picked"), { recursive: true });
|
||||
const slot = await h.db
|
||||
.insertInto("acquisition_slot")
|
||||
.values({ media_entity_id: film, name: "Film 主版本", status: "empty" } as never)
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
const dry = await h.api("POST", "/files/organize", {
|
||||
idempotencyKey: key(),
|
||||
action: "copy",
|
||||
dryRun: true,
|
||||
items: [
|
||||
{ root: h.dlRoot, path: "weird-a.mkv", mediaEntityId: series, season: 2, episodes: [3, 4] },
|
||||
{ root: h.dlRoot, path: "weird-b.mkv", mediaEntityId: series, season: 2, episodes: [5, 7] },
|
||||
{
|
||||
root: h.dlRoot,
|
||||
path: "Film.2019.1080p.mkv",
|
||||
mediaEntityId: film,
|
||||
season: 1,
|
||||
slotId: slot.id,
|
||||
targetDir: { root: h.libRoot, path: "picked" },
|
||||
operation: "copy",
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(dry.status).toBe(200);
|
||||
const groups = dry.body.data.groups;
|
||||
expect(groups).toHaveLength(2);
|
||||
const tv = groups.find((g: { mediaEntityId: string }) => g.mediaEntityId === series);
|
||||
const mv = groups.find((g: { mediaEntityId: string }) => g.mediaEntityId === film);
|
||||
const tvTargets = tv.files.map((f: { target: string | null }) => f.target);
|
||||
expect(tvTargets.some((t: string | null) => t?.endsWith("Season 2/剧 - S02E03-E04.mkv"))).toBe(true);
|
||||
// 非连续集号不应用(只按季覆写,文件名无集号 → 计划排除),其余覆写不进 warnings
|
||||
expect(tv.warnings).toEqual([expect.stringMatching(/weird-b\.mkv: 集号 5,7 不连续/)]);
|
||||
expect(mv.warnings).toEqual([expect.stringMatching(/电影不适用季\/集覆写/)]);
|
||||
expect(mv).toMatchObject({ action: "copy", slotId: slot.id, targetDir: join(h.lib, "picked") });
|
||||
expect(mv.files[0].target).toBe(join(h.lib, "picked", "Film (2019) - 1080p.mkv"));
|
||||
|
||||
const exec = await h.api("POST", "/files/organize", {
|
||||
idempotencyKey: key(),
|
||||
action: "copy",
|
||||
dryRun: false,
|
||||
planIds: groups.map((g: { planId: string }) => g.planId),
|
||||
items: [
|
||||
{
|
||||
root: h.dlRoot,
|
||||
path: "Film.2019.1080p.mkv",
|
||||
mediaEntityId: film,
|
||||
season: 1,
|
||||
slotId: slot.id,
|
||||
targetDir: { root: h.libRoot, path: "picked" },
|
||||
operation: "copy",
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(exec.status).toBe(200);
|
||||
const g = exec.body.data.groups[0];
|
||||
expect(g.error).toBeNull();
|
||||
await executeRecord(h.module.deps().ao, g.recordId);
|
||||
expect(await readFile(join(h.lib, "picked", "Film (2019) - 1080p.mkv"), "utf8")).toBe("z".repeat(64));
|
||||
const s = await h.db
|
||||
.selectFrom("acquisition_slot")
|
||||
.select(["status", "current_asset_id"])
|
||||
.where("id", "=", slot.id)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(s.current_asset_id).not.toBeNull();
|
||||
expect(s.status).toBe("satisfied");
|
||||
// 目标目录覆写不新建路径绑定
|
||||
const binds = await h.db.selectFrom("path_binding").select("id").where("media_entity_id", "=", film).execute();
|
||||
expect(binds).toEqual([]);
|
||||
});
|
||||
|
||||
it("删除/永久删除/还原都写整理记录(文件级结果)", async () => {
|
||||
await mkdir(join(h.lib, "gone", "in"), { recursive: true });
|
||||
await writeFile(join(h.lib, "gone", "x.srt"), "1");
|
||||
await writeFile(join(h.lib, "gone", "in", "y.srt"), "2");
|
||||
const del = await h.api("POST", "/files/delete", {
|
||||
idempotencyKey: key(),
|
||||
items: [{ root: h.libRoot, path: "gone" }],
|
||||
dryRun: false,
|
||||
});
|
||||
expect(del.status).toBe(200);
|
||||
const recId = del.body.data.recordId;
|
||||
expect(recId).toBeTruthy();
|
||||
const rec = await h.db
|
||||
.selectFrom("organization_record")
|
||||
.select(["action", "status", "file_total", "file_succeeded"])
|
||||
.where("id", "=", recId)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(rec).toEqual({ action: "trash", status: "succeeded", file_total: 2, file_succeeded: 2 });
|
||||
const files = await h.db
|
||||
.selectFrom("organization_file_result")
|
||||
.select(["source_path", "target_path", "mode"])
|
||||
.where("organization_record_id", "=", recId)
|
||||
.orderBy("ordinal")
|
||||
.execute();
|
||||
expect(files.map((f) => f.source_path).sort()).toEqual(
|
||||
[join(h.lib, "gone", "in", "y.srt"), join(h.lib, "gone", "x.srt")].sort(),
|
||||
);
|
||||
expect(files.every((f) => f.mode === "trash" && f.target_path?.includes(".umt-trash"))).toBe(true);
|
||||
|
||||
const rs = await h.api("POST", "/files/trash/restore", {
|
||||
idempotencyKey: key(),
|
||||
ids: [del.body.data.trashed[0].id],
|
||||
});
|
||||
expect(rs.status).toBe(200);
|
||||
expect(rs.body.data.restored).toHaveLength(1);
|
||||
const rrec = await h.db
|
||||
.selectFrom("organization_record")
|
||||
.select(["action", "status", "target_library_root_id", "target_path"])
|
||||
.where("id", "=", rs.body.data.recordId)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(rrec).toEqual({
|
||||
action: "restore",
|
||||
status: "succeeded",
|
||||
target_library_root_id: h.libraryRootId,
|
||||
target_path: "gone",
|
||||
});
|
||||
|
||||
const perm = await h.api("POST", "/files/delete", {
|
||||
idempotencyKey: key(),
|
||||
items: [{ root: h.libRoot, path: "gone/x.srt" }],
|
||||
mode: "permanent",
|
||||
confirmName: "x.srt",
|
||||
dryRun: false,
|
||||
});
|
||||
expect(perm.status).toBe(200);
|
||||
const prec = await h.db
|
||||
.selectFrom("organization_record")
|
||||
.select(["action", "file_total"])
|
||||
.where("id", "=", perm.body.data.recordId)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(prec).toEqual({ action: "delete", file_total: 1 });
|
||||
// 按 planId/记录 id 回查
|
||||
const q = await h.api("GET", `/files/records?ids=${recId},${perm.body.data.recordId}`);
|
||||
expect(q.body.data.items.map((i: { action: string }) => i.action).sort()).toEqual(["delete", "trash"]);
|
||||
});
|
||||
|
||||
it("下载:仅管理员会话;只允许 nfo/字幕/图片且不超上限;路径守卫;流式 + 审计", async () => {
|
||||
await mkdir(join(h.lib, "dl-test"), { recursive: true });
|
||||
await writeFile(join(h.lib, "dl-test", "海报.jpg"), "JPEGDATA");
|
||||
await writeFile(join(h.lib, "dl-test", "movie.nfo"), "<movie/>");
|
||||
await writeFile(join(h.lib, "dl-test", "big.srt"), "x".repeat(200));
|
||||
await writeFile(join(h.lib, "dl-test", "v.mkv"), "video");
|
||||
await symlink(join(h.outside, "secret.txt"), join(h.lib, "dl-test", "leak.nfo"));
|
||||
const url = (path: string, extra = "") =>
|
||||
`/files/download?root=${encodeURIComponent(h.libRoot)}&path=${encodeURIComponent(path)}${extra}`;
|
||||
|
||||
const ok = await h.raw("GET", url("dl-test/海报.jpg", "&inline=true"));
|
||||
expect(ok.status).toBe(200);
|
||||
expect(await ok.text()).toBe("JPEGDATA");
|
||||
expect(ok.headers.get("content-type")).toBe("image/jpeg");
|
||||
expect(ok.headers.get("content-disposition")).toMatch(/^inline; .*filename\*=UTF-8''%E6%B5%B7%E6%8A%A5\.jpg/);
|
||||
expect(ok.headers.get("x-content-type-options")).toBe("nosniff");
|
||||
const nfo = await h.raw("GET", url("dl-test/movie.nfo", "&inline=true"));
|
||||
expect(nfo.headers.get("content-disposition")).toMatch(/^attachment;/);
|
||||
expect(await nfo.text()).toBe("<movie/>");
|
||||
|
||||
expect((await h.api("GET", url("dl-test/movie.nfo"), undefined, READ_KEY)).status).toBe(403);
|
||||
expect((await h.api("GET", url("dl-test/movie.nfo"), undefined, EXEC_KEY)).status).toBe(403);
|
||||
const video = await h.api("GET", url("dl-test/v.mkv"));
|
||||
expect(video.status).toBe(403);
|
||||
const big = await h.api("GET", url("dl-test/big.srt"));
|
||||
expect(big.status).toBe(413);
|
||||
expect(big.body.error.message).toMatch(/^too_large:/);
|
||||
const leak = await h.api("GET", url("dl-test/leak.nfo"));
|
||||
expect(leak.status).toBe(400);
|
||||
expect(leak.body.error.message).toMatch(/^path_escape:/);
|
||||
expect((await h.api("GET", url("dl-test"))).status).toBe(400);
|
||||
expect((await h.api("GET", url("dl-test/none.nfo"))).status).toBe(404);
|
||||
expect((await h.api("GET", url("../outside/secret.txt"))).status).toBeGreaterThanOrEqual(400);
|
||||
|
||||
const audits = await h.db
|
||||
.selectFrom("audit_event")
|
||||
.select(["action"])
|
||||
.where("action", "=", "files.download")
|
||||
.execute();
|
||||
expect(audits.length).toBe(2);
|
||||
});
|
||||
|
||||
it("文件系统竞态/离线不 500:errno 映射;下载根目录整个不见 → 503", async () => {
|
||||
const e = (code: string) => Object.assign(new Error(code), { code });
|
||||
expect(fsErrorToFiles(e("ENOTCONN"))?.status).toBe(503);
|
||||
expect(fsErrorToFiles(e("EIO"))?.reason).toBe("root_unavailable");
|
||||
expect(fsErrorToFiles(e("ENOENT"))?.status).toBe(404);
|
||||
expect(fsErrorToFiles(e("EACCES"))?.status).toBe(403);
|
||||
expect(fsErrorToFiles(e("ELOOP"))?.status).toBe(400);
|
||||
expect(fsErrorToFiles(new Error("x"))).toBeNull();
|
||||
|
||||
const hidden = `${h.dl}.away`;
|
||||
await rm(hidden, { recursive: true, force: true });
|
||||
await import("node:fs/promises").then((fs) => fs.rename(h.dl, hidden));
|
||||
try {
|
||||
const list = await h.api("GET", `/files?root=${encodeURIComponent(h.dlRoot)}&path=`);
|
||||
expect(list.status).toBe(503);
|
||||
expect(list.body.error.message).toMatch(/^root_unavailable:/);
|
||||
const st = await h.api("GET", `/files/stat?root=${encodeURIComponent(h.dlRoot)}&path=x`);
|
||||
expect(st.status).toBe(503);
|
||||
} finally {
|
||||
await import("node:fs/promises").then((fs) => fs.rename(hidden, h.dl));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -210,6 +210,8 @@ describe.skipIf(skip)("files 写操作", () => {
|
||||
size: 8,
|
||||
conflict: false,
|
||||
registered: false,
|
||||
files: 1,
|
||||
crossDevice: false,
|
||||
},
|
||||
]);
|
||||
const planId = dry.body.data.planId;
|
||||
@@ -271,7 +273,8 @@ describe.skipIf(skip)("files 写操作", () => {
|
||||
.select(["action", "status"])
|
||||
.where("id", "=", exec.body.data.executed.recordId)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(rec).toEqual({ action: "relocate", status: "succeeded" });
|
||||
// W5-A2:搬运走 planImport → verifyAndImport,记录 action 与整理一致(move/copy)
|
||||
expect(rec).toEqual({ action: "move", status: "succeeded" });
|
||||
});
|
||||
|
||||
it("TOCTOU:预览后把源换成指向根外的符号链接,执行被拒绝且根外文件不动", async () => {
|
||||
|
||||
Reference in new issue
Block a user