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,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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user