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) => 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("主文件只认视频:整理登记成独立资产的字幕(is_primary = true)不标主文件(FIX-7)", async () => { await mkdir(join(h.lib, "Sub (2014)")); await writeFile(join(h.lib, "Sub (2014)", "Sub (2014) - 1080p.mkv"), "video"); await writeFile(join(h.lib, "Sub (2014)", "Sub (2014) - 1080p.chi.zh-cn.srt"), "1\n"); const e = await seedEntity(h.db, { kind: "movie", title: "Sub", year: 2014 }); const v = await seedAsset(h.db, h, { entityId: e, rel: "Sub (2014)/Sub (2014) - 1080p.mkv" }); const srt = await seedAsset(h.db, h, { entityId: e, rel: "Sub (2014)/Sub (2014) - 1080p.chi.zh-cn.srt", kind: "subtitle", }); // 与 organize.ts 一致:附属文件的资产位置也是该资产的主位置,并挂在视频位置下 await h.db .updateTable("asset_location") .set({ is_primary: true, sidecar_of_location_id: v.locationId }) .where("id", "=", srt.locationId) .execute(); const r = await h.api("GET", `/files?${q({ root: h.libRoot, path: "Sub (2014)" })}`); const byName = Object.fromEntries(r.body.data.entries.map((x: { name: string }) => [x.name, x])); expect(byName["Sub (2014) - 1080p.mkv"].asset).toMatchObject({ isPrimary: true }); expect(byName["Sub (2014) - 1080p.chi.zh-cn.srt"].asset).toMatchObject({ isPrimary: false }); }); 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); }); });