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:
rootandClaude Opus 5.5 committed 2026-09-27 23:02:20 +08:00
1 parent 09f6e3a3e5
commit 29cd10b27e
20 files changed
+4380 -1

No files matched your search

@@ -0,0 +1,174 @@
import { mkdir, rename, symlink, utimes, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
createFmHarness,
type FmHarness,
pgAvailable,
READ_KEY,
seedAsset,
seedDownload,
seedEntity,
} from "./harness.ts";
// 读接口 + 路径守卫:只列登记根、路径逃逸(.. / URL 编码 / 符号链接 / 根外绝对路径)、离线 503、截断与分页、行内状态。
const skip = !(await pgAvailable());
const q = (o: Record<string, string>) => new URLSearchParams(o).toString();
describe.skipIf(skip)("files 浏览与守卫", () => {
let h: FmHarness;
beforeAll(async () => {
h = await createFmHarness("browse", { options: { listMaxEntries: 300 } });
await mkdir(join(h.lib, "Movie (2020)"));
await writeFile(join(h.lib, "Movie (2020)", "Movie.2020.1080p.mkv"), "video");
await writeFile(join(h.lib, "Movie (2020)", "Other.2021.mkv"), "video2");
await writeFile(join(h.lib, "Movie (2020)", ".hidden"), "x");
await writeFile(join(h.lib, "Movie (2020)", "Movie.2020.1080p.mkv.uhd-partial"), "x");
await symlink(h.outside, join(h.lib, "escape-dir"));
await symlink(join(h.outside, "secret.txt"), join(h.lib, "escape-file.txt"));
await symlink(join(h.lib, "Movie (2020)"), join(h.lib, "inner-link"));
await mkdir(join(h.dl, "Show.S01.1080p"));
await writeFile(join(h.dl, "Show.S01.1080p", "Show.S01E01.mkv"), "ep1");
const e = await seedEntity(h.db, { kind: "movie", title: "Movie", year: 2020 });
await seedAsset(h.db, h, { entityId: e, rel: "Movie (2020)/Movie.2020.1080p.mkv" });
await seedDownload(h.db, h, { name: "Show.S01.1080p", status: "downloading" });
// 根为 "/" 的配置永不出现
await h.db
.insertInto("library_root")
.values({ name: "危险", media_kind: "mixed", local_path: "/", management_mode: "managed" })
.execute();
});
afterAll(async () => h?.close());
it("GET /files/roots 只列登记根,带健康与可写", async () => {
const r = await h.api("GET", "/files/roots");
expect(r.status).toBe(200);
const items = r.body.data.items;
expect(items.map((x: { id: string }) => x.id).sort()).toEqual([h.dlRoot, h.libRoot].sort());
const lib = items.find((x: { id: string }) => x.id === h.libRoot);
expect(lib).toMatchObject({ kind: "library", health: "healthy", writable: true });
const dl = items.find((x: { id: string }) => x.id === h.dlRoot);
expect(dl).toMatchObject({ kind: "download", writable: false, mediaKind: null });
// API Key 只读 scope 可读
expect((await h.api("GET", "/files/roots", undefined, READ_KEY)).status).toBe(200);
});
it("列目录:行内业务状态、隐藏文件、符号链接逃逸标记", async () => {
const r = await h.api("GET", `/files?${q({ root: h.libRoot, path: "Movie (2020)" })}`);
expect(r.status).toBe(200);
const byName = Object.fromEntries(r.body.data.entries.map((e: { name: string }) => [e.name, e]));
expect(Object.keys(byName).sort()).toEqual(["Movie.2020.1080p.mkv", "Other.2021.mkv"]);
expect(byName["Movie.2020.1080p.mkv"]).toMatchObject({ status: "registered", fileKind: "video" });
expect(byName["Movie.2020.1080p.mkv"].asset).toMatchObject({ title: "Movie", year: 2020 });
expect(byName["Other.2021.mkv"].status).toBe("unidentified");
const all = await h.api("GET", `/files?${q({ root: h.libRoot, path: "Movie (2020)", hidden: "true" })}`);
const names = all.body.data.entries.map((e: { name: string }) => e.name);
expect(names).toContain(".hidden");
const partial = all.body.data.entries.find((e: { name: string }) => e.name.endsWith(".uhd-partial"));
expect(partial.status).toBe("temp");
const root = await h.api("GET", `/files?${q({ root: h.libRoot })}`);
const esc = root.body.data.entries.find((e: { name: string }) => e.name === "escape-dir");
expect(esc).toMatchObject({ type: "symlink", escape: true });
const inner = root.body.data.entries.find((e: { name: string }) => e.name === "inner-link");
expect(inner.escape).toBe(false);
const dir = root.body.data.entries.find((e: { name: string }) => e.name === "Movie (2020)");
expect(dir.dirSummary).toMatchObject({ videoCount: 2, registeredCount: 1 });
});
it("下载根:纳管下载中的内容标 downloading", async () => {
const r = await h.api("GET", `/files?${q({ root: h.dlRoot })}`);
const e = r.body.data.entries.find((x: { name: string }) => x.name === "Show.S01.1080p");
expect(e.status).toBe("downloading");
expect(e.download.status).toBe("downloading");
const inner = await h.api("GET", `/files?${q({ root: h.dlRoot, path: "Show.S01.1080p" })}`);
expect(inner.body.data.entries[0].status).toBe("downloading");
});
it("路径逃逸一律拒绝:..、URL 编码、根外绝对路径、符号链接(目录与文件)、未登记根", async () => {
const bad = [
`/files?${q({ root: h.libRoot, path: ".." })}`,
`/files?${q({ root: h.libRoot, path: "Movie (2020)/../.." })}`,
`/files?root=${h.libRoot}&path=..%2F..%2Fetc`,
`/files?${q({ root: h.libRoot, path: "/etc" })}`,
`/files?${q({ root: h.libRoot, path: "escape-dir" })}`,
`/files/stat?${q({ root: h.libRoot, path: "escape-dir/secret.txt" })}`,
`/files/stat?${q({ root: h.libRoot, path: "escape-file.txt" })}`,
];
for (const p of bad) {
const r = await h.api("GET", p);
expect([400, 422], p).toContain(r.status);
expect(JSON.stringify(r.body)).not.toContain("top secret");
}
const unknown = await h.api("GET", `/files?${q({ root: "lib:00000000-0000-4000-8000-000000000000" })}`);
expect(unknown.status).toBe(404);
// 根内符号链接(目标仍在根内)可以浏览
const ok = await h.api("GET", `/files?${q({ root: h.libRoot, path: "inner-link" })}`);
expect(ok.status).toBe(200);
// 写操作同样受守卫
const mk = await h.api("POST", "/files/mkdir", {
idempotencyKey: "k-escape-0001",
root: h.libRoot,
path: "escape-dir",
name: "x",
});
expect(mk.status).toBe(400);
});
it("根离线(哨兵缺失)→ 503 root_unavailable,不返回空列表", async () => {
await rename(join(h.lib, ".uhd-root"), join(h.lib, ".uhd-root.bak"));
try {
const r = await h.api("GET", `/files?${q({ root: h.libRoot })}`);
expect(r.status).toBe(503);
expect(r.body.error.message).toMatch(/^root_unavailable:/);
const roots = await h.api("GET", "/files/roots");
expect(roots.body.data.items.find((x: { id: string }) => x.id === h.libRoot).health).toBe("offline");
} finally {
await rename(join(h.lib, ".uhd-root.bak"), join(h.lib, ".uhd-root"));
}
});
it("大目录截断 + 分页稳定 + 目录变化 stale + 过滤", async () => {
const big = join(h.lib, "big");
await mkdir(big);
for (let i = 0; i < 400; i++) await writeFile(join(big, `f${String(i).padStart(4, "0")}.txt`), "");
const r1 = await h.api("GET", `/files?${q({ root: h.libRoot, path: "big", limit: "100" })}`);
expect(r1.body.data).toMatchObject({ truncated: true, total: 300, stale: false });
expect(r1.body.data.entries).toHaveLength(100);
const seen = new Set(r1.body.data.entries.map((e: { name: string }) => e.name));
let cursor = r1.body.data.nextCursor;
while (cursor) {
const r = await h.api("GET", `/files?${q({ root: h.libRoot, path: "big", limit: "100", cursor })}`);
for (const e of r.body.data.entries) {
expect(seen.has(e.name)).toBe(false);
seen.add(e.name);
}
cursor = r.body.data.nextCursor;
}
expect(seen.size).toBe(300);
// 自然排序(截断取的是目录读取顺序的前 N 项,排序在其内进行)
const page1 = r1.body.data.entries.map((e: { name: string }) => e.name);
expect(page1).toEqual([...page1].sort());
// 目录 mtime 变化 → stale
await writeFile(join(big, "zz-new.txt"), "");
await utimes(big, new Date(), new Date(Date.now() + 5000));
const r2 = await h.api("GET", `/files?${q({ root: h.libRoot, path: "big", cursor: r1.body.data.nextCursor })}`);
expect(r2.body.data.stale).toBe(true);
// 过滤 + glob
const g = await h.api("GET", `/files?${q({ root: h.libRoot, path: "Movie (2020)", q: "movie*.mkv" })}`);
expect(g.body.data.entries.map((e: { name: string }) => e.name)).toEqual(["Movie.2020.1080p.mkv"]);
const f = await h.api("GET", `/files?${q({ root: h.libRoot, path: "Movie (2020)", filter: "unidentified" })}`);
expect(f.body.data.entries.map((e: { name: string }) => e.name)).toEqual(["Other.2021.mkv"]);
});
it("GET /files/stat:inode/nlink/sidecars", async () => {
await writeFile(join(h.lib, "Movie (2020)", "Movie.2020.1080p.chs.srt"), "sub");
const r = await h.api("GET", `/files/stat?${q({ root: h.libRoot, path: "Movie (2020)/Movie.2020.1080p.mkv" })}`);
expect(r.status).toBe(200);
expect(r.body.data.sidecars).toEqual(["Movie (2020)/Movie.2020.1080p.chs.srt"]);
expect(r.body.data.nlink).toBe(1);
expect(r.body.data.sharedInode).toBe(false);
});
});
+298
View File
@@ -0,0 +1,298 @@
import { randomUUID } from "node:crypto";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { capabilitySpec, DEFAULT_SETTINGS, GlobalSettings, type SettingsKey } from "@umt/contracts";
import { type DB, migrateToLatest } from "@umt/db";
import { normalizeTitle } from "@umt/domain";
import { Kysely, PostgresDialect, sql } from "kysely";
import pg from "pg";
import { ConfigSchema } from "../../src/config.ts";
import { createOutboxPort } from "../../src/context.ts";
import { createApp } from "../../src/http/app.ts";
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 { 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";
// 文件管理测试装配:真实 PG(独立 schema)+ 临时目录(库根 / 下载根 / 根外目录)+ 记录入队的假引擎。
export const DB_URL = process.env.DATABASE_URL ?? "postgres://umt:umt@localhost:55433/umt";
export const DEV_KEY = "dev-admin-fm-test-key";
export const READ_KEY = "fm-organize-read-key";
export const EXEC_KEY = "fm-organize-exec-key";
const KEYS = [
{ key: READ_KEY, scopes: ["organize.read"] },
{ key: EXEC_KEY, scopes: ["organize.execute"] },
];
export async function pgAvailable() {
if (process.env.UMT_SKIP_DB_TESTS === "1") return false;
const c = new pg.Client({ connectionString: DB_URL, connectionTimeoutMillis: 2000 });
try {
await c.connect();
await c.end();
return true;
} catch {
return false;
}
}
// biome-ignore lint/suspicious/noExplicitAny: 测试断言直接读响应 JSON
type Loose = any;
export interface FmHarness {
db: Kysely<DB>;
engine: ReturnType<typeof fakeEngine>;
events: { type: string; payload: Loose }[];
base: string;
lib: string;
dl: string;
outside: string;
libRoot: string;
dlRoot: string;
libraryRootId: string;
instanceId: string;
api(method: string, path: string, body?: unknown, key?: string): Promise<{ status: number; body: Loose }>;
close(): Promise<void>;
}
export async function createFmHarness(
name: string,
overrides: FmOverrides = {},
opts: { catalog?: 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 });
await admin.connect();
await admin.query(`create schema ${schema}`);
await admin.end();
const db = new Kysely<DB>({
dialect: new PostgresDialect({
pool: new pg.Pool({ connectionString: DB_URL, max: 6, options: `-c search_path=${schema},public` }),
}),
});
await migrateToLatest(db, { migrationTableSchema: schema });
const base = await mkdtemp(join(tmpdir(), `fm-${name}-`));
const lib = join(base, "lib");
const dl = join(base, "dl");
const outside = join(base, "outside");
for (const p of [lib, dl, outside]) await mkdir(p, { recursive: true });
await writeFile(join(lib, ".uhd-root"), "sentinel\n");
await writeFile(join(outside, "secret.txt"), "top secret\n");
const logger = createLogger({ level: "silent" });
const engine = fakeEngine();
const config = ConfigSchema.parse({
DATABASE_URL: DB_URL,
SECRET_MASTER_KEY: "test-master-key-0123456789",
DEV_ADMIN_KEY: DEV_KEY,
DATA_DIR: join(base, "data"),
});
const bus = new EventBus(50);
const events: { type: string; payload: Loose }[] = [];
const busProxy = {
publish: async <P>(type: string, payload: P) => {
events.push({ type, payload });
return bus.publish(type, payload);
},
since: bus.since.bind(bus),
subscribe: bus.subscribe.bind(bus),
};
const capabilities = createCapabilityRegistry({ logger });
if (opts.catalog)
capabilities.register(capabilitySpec("catalog.lookup"), async (input) => ({
local: [],
remote: String(input.query).includes("Remote")
? [{ provider: "tmdb", entityType: "movie" as const, externalId: "999", title: "Remote Film", year: 2019 }]
: [],
}));
const ctx: ServerContext = {
role: "all",
db,
config,
logger,
events: busProxy,
engine,
outbox: createOutboxPort(),
secrets: { resolve: async () => "x", create: async () => "x", replace: async () => {}, remove: async () => {} },
settings: {
async all() {
const rows = await db.selectFrom("settings").select(["key", "value"]).execute();
const stored = new Map(rows.map((r) => [r.key, r.value]));
const out: Record<string, unknown> = {};
for (const k of Object.keys(DEFAULT_SETTINGS) as SettingsKey[]) {
const v = stored.get(k);
out[k] = GlobalSettings.shape[k].parse({ ...DEFAULT_SETTINGS[k], ...((v as object) ?? {}) });
}
return out as GlobalSettings;
},
async get(k) {
return (await this.all())[k];
},
},
capabilities,
};
const module = createFileManagerModule({
...overrides,
ao: {
probe: async () => ({ status: "unknown", reason: "ffprobe_unavailable" }),
...overrides.ao,
options: { backgroundLoops: false, minVideoBytes: 10, verifyHash: "sha256", ...(overrides.ao?.options ?? {}) },
},
options: { backgroundLoops: false, dataDir: join(base, "data"), ...overrides.options },
});
const app = createApp({
config: { NODE_ENV: "development", DEV_ADMIN_KEY: DEV_KEY },
logger,
bus,
role: "all",
version: "test",
db,
modules: [module],
ctx,
apiKeyStore: {
async findByHash(hash) {
const k = KEYS.find((x) => hashApiKey(x.key) === hash);
return k ? { id: k.key, name: k.key, scopes: k.scopes, revokedAt: null } : null;
},
async touch() {},
},
});
const root = await db
.insertInto("library_root")
.values({ name: "电影库", media_kind: "mixed", local_path: lib, management_mode: "managed" })
.returning("id")
.executeTakeFirstOrThrow();
const inst = await db
.insertInto("downloader_instance")
.values({ name: "qb", base_url: "http://127.0.0.1:1", settings: JSON.stringify({ savePath: dl }) })
.returning("id")
.executeTakeFirstOrThrow();
return {
db,
engine,
events,
base,
lib,
dl,
outside,
libRoot: `lib:${root.id}`,
dlRoot: `dl:${inst.id}:0`,
libraryRootId: root.id,
instanceId: inst.id,
async api(method, path, body, key = DEV_KEY) {
const res = await app.request(`/api/v1${path}`, {
method,
headers: {
authorization: `Bearer ${key}`,
...(body !== undefined ? { "content-type": "application/json" } : {}),
},
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
});
return { status: res.status, body: await res.json() };
},
async close() {
await db.destroy();
const c = new pg.Client({ connectionString: DB_URL });
await c.connect();
await c.query(`drop schema if exists ${schema} cascade`);
await c.end();
await rm(base, { recursive: true, force: true });
},
};
}
export const key = () => randomUUID();
export async function seedEntity(db: Kysely<DB>, e: { kind: "movie" | "series"; title: string; year: number }) {
const id = (
await db
.insertInto("media_entity")
.values({ kind: e.kind, title: e.title, year: e.year, status: "active" })
.returning("id")
.executeTakeFirstOrThrow()
).id;
await db
.insertInto("title_alias")
.values({ media_entity_id: id, alias: e.title, normalized: normalizeTitle(e.title), source: "user" })
.execute();
return id;
}
/** 已登记资产 + 库内位置(relative_path 相对库根) */
export async function seedAsset(
db: Kysely<DB>,
h: Pick<FmHarness, "libraryRootId">,
a: { entityId: string; rel: string; kind?: "video" | "subtitle"; sidecarOf?: string },
) {
const asset = await db
.insertInto("media_asset")
.values({ media_entity_id: a.entityId, kind: a.kind ?? "video", size_bytes: 10 })
.returning("id")
.executeTakeFirstOrThrow();
const loc = await db
.insertInto("asset_location")
.values({
media_asset_id: asset.id,
location_kind: "library",
library_root_id: h.libraryRootId,
relative_path: a.rel,
status: "available",
is_primary: !a.sidecarOf,
sidecar_of_location_id: a.sidecarOf ?? null,
})
.returning("id")
.executeTakeFirstOrThrow();
return { assetId: asset.id, locationId: loc.id };
}
export async function seedBinding(
db: Kysely<DB>,
h: Pick<FmHarness, "libraryRootId">,
b: { entityId: string; rel: string },
) {
return (
await db
.insertInto("path_binding")
.values({
media_entity_id: b.entityId,
binding_kind: "entity",
library_root_id: h.libraryRootId,
relative_path: b.rel,
})
.returning("id")
.executeTakeFirstOrThrow()
).id;
}
/** 纳管下载任务(内容 = 下载根下的 name) */
export async function seedDownload(
db: Kysely<DB>,
h: Pick<FmHarness, "instanceId" | "dl">,
t: { name: string; status: "downloading" | "completed" },
) {
return (
await db
.insertInto("download_task")
.values({
instance_id: h.instanceId,
torrent_hash: randomUUID().replace(/-/g, "").padEnd(40, "0").slice(0, 40),
name: t.name,
save_path: h.dl,
content_path: join(h.dl, t.name),
managed: true,
status: t.status,
})
.returning("id")
.executeTakeFirstOrThrow()
).id;
}
export { sql };
@@ -0,0 +1,160 @@
import { appendFile, mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { createFmHarness, type FmHarness, key, pgAvailable, READ_KEY, seedAsset, seedEntity } from "./harness.ts";
// identify(只读,本地 + 远端召回前 5,不落库)、organize(按作品分组走 planImport,dryRun→execute 一致与源变更)、
// probe(已登记写回 media_info)、scrape(仅已登记作品,入队 refreshMetadata;NFO 待 W5)。
const skip = !(await pgAvailable());
describe.skipIf(skip)("files 识别 / 整理 / 探测 / 刮削", () => {
let h: FmHarness;
let show: string;
let film: string;
beforeAll(async () => {
h = await createFmHarness(
"ident",
{
ao: {
probe: async () => ({
status: "ok",
container: "matroska",
durationSec: 60,
video: [{ codec: "hevc", width: 3840, height: 2160, hdr: "HDR10" }],
audio: [{ codec: "truehd" }],
subtitles: [],
}),
},
},
{ catalog: true },
);
show = await seedEntity(h.db, { kind: "series", title: "Some Show", year: 2022 });
film = await seedEntity(h.db, { kind: "movie", title: "Blue Film", year: 2021 });
await mkdir(join(h.dl, "Some.Show.S01.1080p.WEB-DL"));
await writeFile(join(h.dl, "Some.Show.S01.1080p.WEB-DL", "Some.Show.S01E02.1080p.WEB-DL-GRP.mkv"), "ep2");
await writeFile(join(h.dl, "Some.Show.S01.1080p.WEB-DL", "Some.Show.S01E03.1080p.WEB-DL-GRP.mkv"), "ep3");
await writeFile(join(h.dl, "Blue.Film.2021.2160p.BluRay.x265-GRP.mkv"), "film-bytes");
await writeFile(join(h.dl, "Remote.Film.2019.1080p.mkv"), "remote");
});
afterAll(async () => h?.close());
it("identify:目录展开为视频,本地候选命中,季集解析,不写库", async () => {
const before = await h.db.selectFrom("media_entity").select(["id"]).execute();
const r = await h.api(
"POST",
"/files/identify",
{ items: [{ root: h.dlRoot, path: "Some.Show.S01.1080p.WEB-DL" }] },
READ_KEY,
);
expect(r.status).toBe(200);
expect(r.body.data.items).toHaveLength(2);
const it0 = r.body.data.items[0];
expect(it0.parsed).toMatchObject({ season: 1, episodes: [2], resolution: "1080p" });
expect(it0.candidates[0]).toMatchObject({ mediaEntityId: show, provider: "local", title: "Some Show" });
expect(it0.candidates.length).toBeLessThanOrEqual(5);
expect(it0.suggestion).toMatchObject({ mediaEntityId: show, contentUnit: "S01E02" });
const after = await h.db.selectFrom("media_entity").select(["id"]).execute();
expect(after).toHaveLength(before.length);
const pool = await h.db.selectFrom("identity_candidate").select(["id"]).execute();
expect(pool).toHaveLength(0);
});
it("identify:远端候选(catalog.lookup)", async () => {
const r = await h.api("POST", "/files/identify", {
items: [{ root: h.dlRoot, path: "Remote.Film.2019.1080p.mkv" }],
});
expect(r.status).toBe(200);
const c = r.body.data.items[0].candidates;
expect(c[0]).toMatchObject({ provider: "tmdb", externalId: "999", mediaEntityId: null, kind: "movie" });
});
it("organize:dryRun 返回 planId 与前后路径;源变更 → 该组 plan_stale;重新预览后执行入队 verifyAndImport", async () => {
const items = [{ root: h.dlRoot, path: "Blue.Film.2021.2160p.BluRay.x265-GRP.mkv", mediaEntityId: film }];
const dry = await h.api("POST", "/files/organize", { idempotencyKey: key(), items, action: "copy", dryRun: true });
expect(dry.status).toBe(200);
const g = dry.body.data.groups[0];
expect(g.error).toBeNull();
expect(g.planId).toBeTruthy();
expect(g.files[0].source).toBe(join(h.dl, "Blue.Film.2021.2160p.BluRay.x265-GRP.mkv"));
expect(g.files[0].target).toContain(join(h.lib, "Blue Film (2021)"));
await appendFile(join(h.dl, "Blue.Film.2021.2160p.BluRay.x265-GRP.mkv"), "+");
const stale = await h.api("POST", "/files/organize", {
idempotencyKey: key(),
items,
action: "copy",
dryRun: false,
planIds: [g.planId],
});
expect(stale.status).toBe(200);
expect(stale.body.data.groups[0].error.code).toBe("plan_stale");
expect(stale.body.data.recordIds).toEqual([]);
const dry2 = await h.api("POST", "/files/organize", { idempotencyKey: key(), items, action: "copy", dryRun: true });
const exec = await h.api("POST", "/files/organize", {
idempotencyKey: key(),
items,
action: "copy",
dryRun: false,
planIds: [dry2.body.data.groups[0].planId],
});
expect(exec.status).toBe(200);
expect(exec.body.data.groups[0].error).toBeNull();
expect(exec.body.data.recordIds).toHaveLength(1);
expect(exec.body.data.groups[0].files.map((f: { target: string }) => f.target)).toEqual(
dry2.body.data.groups[0].files.map((f: { target: string }) => f.target),
);
expect(h.engine.enqueued.some((e) => e.task === "verifyAndImport")).toBe(true);
const rec = await h.db
.selectFrom("organization_record")
.select(["trigger", "action", "media_entity_id"])
.where("id", "=", exec.body.data.recordIds[0])
.executeTakeFirstOrThrow();
expect(rec).toEqual({ trigger: "manual", action: "copy", media_entity_id: film });
});
it("organize:路径逃逸同样拒绝", async () => {
const r = await h.api("POST", "/files/organize", {
idempotencyKey: key(),
items: [{ root: h.dlRoot, path: "../outside/secret.txt", mediaEntityId: film }],
action: "copy",
dryRun: true,
});
expect(r.status).toBeGreaterThanOrEqual(400);
expect(r.status).toBeLessThan(500);
});
it("probe:已登记资产写回 media_info;scrape 仅已登记作品", async () => {
await mkdir(join(h.lib, "Blue Film probe"));
await writeFile(join(h.lib, "Blue Film probe", "Blue.Film.2021.mkv"), "x");
const a = await seedAsset(h.db, h, { entityId: film, rel: "Blue Film probe/Blue.Film.2021.mkv" });
const r = await h.api("POST", "/files/probe", {
items: [{ root: h.libRoot, path: "Blue Film probe/Blue.Film.2021.mkv" }],
});
expect(r.status).toBe(200);
expect(r.body.data.items[0]).toMatchObject({
status: "ok",
savedToAsset: true,
summary: { resolution: "3840x2160", hdr: "HDR10", videoCodec: "hevc", audioTracks: 1 },
});
const row = await h.db
.selectFrom("media_asset")
.select(["media_info"])
.where("id", "=", a.assetId)
.executeTakeFirstOrThrow();
expect((row.media_info as { probe?: { status: string } }).probe?.status).toBe("ok");
// 未登记:只缓存
const u = await h.api("POST", "/files/probe", { items: [{ root: h.dlRoot, path: "Remote.Film.2019.1080p.mkv" }] });
expect(u.body.data.items[0]).toMatchObject({ status: "ok", savedToAsset: false, cached: false });
const u2 = await h.api("POST", "/files/probe", { items: [{ root: h.dlRoot, path: "Remote.Film.2019.1080p.mkv" }] });
expect(u2.body.data.items[0].cached).toBe(true);
const s = await h.api("POST", "/files/scrape", { idempotencyKey: key(), mediaEntityId: film, writeNfo: true });
expect(s.status).toBe(200);
expect(s.body.data).toMatchObject({ mediaEntityId: film, nfo: "not_implemented", images: "not_requested" });
expect(h.engine.enqueued.some((e) => e.task === "refreshMetadata")).toBe(true);
const none = await h.api("POST", "/files/scrape", { idempotencyKey: key(), mediaEntityId: show });
expect(none.status).toBe(400);
});
});
+214
View File
@@ -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);
});
});
+348
View File
@@ -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);
});
});
+2 -1
View File
@@ -43,12 +43,13 @@ const pingModule: ServerModule = {
};
describe("ServerModule 骨架", () => {
it("清单里是四个 W2 模块,名字唯一", () => {
it("清单里是四个 W2 模块 + 文件管理,名字唯一", () => {
expect(SERVER_MODULES.map((m) => m.name)).toEqual([
"auth-settings",
"sources-identity",
"acquisition-organize",
"agent",
"file-manager",
]);
});