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]>
175 lines
9.0 KiB
TypeScript
175 lines
9.0 KiB
TypeScript
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);
|
|
});
|
|
});
|