feat(adapters,testkit): guarded qB data deletion (B7) and mock qB file materialization (B8)

adapters: assertSafeToDeleteData({contentPath, libraryRoots, importVerified}) rejects
paths inside/equal to/above any library root, relative or root paths, empty roots and
unverified imports; QbClient.delete({deleteFiles:true}) requires a per-hash
SafeDeleteCheck and throws DownloaderError('unsafe_delete') before sending anything.

testkit: mock qB complete(hash, {files, writeFiles}) writes random-content files under
save_path (opt-in, size-capped, traversal-safe); deleteFiles=true removes them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-27 19:22:56 +08:00
1 parent 42ecc93fa5
commit fa9221086b
9 files changed
+371 -9

No files matched your search

+14
View File
@@ -1,3 +1,17 @@
# @umt/testkit
测试夹具与 mock 服务:mock qB WebUI、mock NexusPHP 站点、mock M-Team API、mock RSS、mock TMDB 与临时目录夹具。只供测试依赖,不进入生产构建。
## mock qB 落盘
`createMockQb()` 默认只改内存状态。e2e 需要"下载完成 → Copy 入库"时:
```ts
const qb = await createMockQb();
// files 的 path 相对任务 save_path(多文件种子含根目录);写随机内容,大小与清单一致,返回绝对路径
const paths = await qb.complete(hash, { files: [{ path: "Show.S01/Show.S01E01.mkv", size: 4 * 1024 * 1024 }] });
```
- `createMockQb({ materializeFiles: true })` 让所有 `complete()` 按现有文件清单落盘;单次总量上限 `maxMaterializeBytes`(默认 64 MiB)。
- `torrents/delete` 带 `deleteFiles=true` 时同步删除落盘文件和因此变空的目录(不越过 save_path)。
- save_path 请用临时目录(`mkdtemp`),不要用 `/downloads` 这类真实路径。
+102 -3
View File
@@ -1,6 +1,9 @@
import { randomBytes } from "node:crypto";
import { rmdirSync, rmSync } from "node:fs";
import { mkdir, open } from "node:fs/promises";
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
import type { AddressInfo } from "node:net";
import { dirname, isAbsolute, join, normalize, relative, sep } from "node:path";
import { inspectTorrent } from "./bencode.ts";
import { parseCookies, parseMultipart, readBody } from "./http-util.ts";
@@ -77,9 +80,21 @@ export interface MockQbOptions {
preferences?: Record<string, unknown>;
/** 磁力无元数据时的默认大小 */
magnetDefaultSize?: number;
/**
* complete() 时是否默认在 save_path 下真的写出文件(随机内容,大小与文件清单一致),供"下载完成 → Copy 入库" e2e 使用。
* 默认 false:现有用例的 save_path 多是 /downloads 之类的假路径,不能落盘。
*/
materializeFiles?: boolean;
/** 单次 complete() 最多写出的总字节数,默认 64 MiB;超出抛错(请用 files 配小文件) */
maxMaterializeBytes?: number;
port?: number;
}
export interface MockQbCompleteOptions {
writeFiles?: boolean;
files?: { path: string; size: number }[];
}
export interface MockQb {
url: string;
calls: MockQbCall[];
@@ -89,7 +104,11 @@ export interface MockQb {
tagDefinitions(): string[];
seed(t: Partial<MockQbTorrent> & { hash: string; name: string }): MockQbTorrent;
setProgress(hash: string, progress: number): void;
complete(hash: string): void;
/**
* 置为完成。writeFiles(或给了 files、或 materializeFiles=true)时在 save_path 下写出随机内容文件,
* 返回写出的绝对路径;files 同时替换任务的文件清单(path 相对 save_path,多文件种子含根目录)。
*/
complete(hash: string, opts?: MockQbCompleteOptions): Promise<string[]>;
setState(hash: string, state: string): void;
setError(hash: string): void;
setMissingFiles(hash: string): void;
@@ -172,6 +191,77 @@ export async function createMockQb(opts: MockQbOptions = {}): Promise<MockQb> {
}
}
/** hash → 已落盘的文件(绝对路径);deleteFiles=true 时同步删除 */
const materialized = new Map<string, string[]>();
function safeJoin(root: string, rel: string): string {
const n = normalize(rel);
if (!rel || isAbsolute(rel) || n === ".." || n.startsWith(`..${sep}`)) {
throw new Error(`mock qb: 非法文件路径 ${rel}`);
}
return join(root, n);
}
function applyFiles(t: MockQbTorrent, files: { path: string; size: number }[]) {
t.files = files.map((f) => ({ name: f.path, size: f.size, progress: t.progress, priority: 1 }));
t.size = t.total_size = files.reduce((a, f) => a + f.size, 0);
const heads = new Set(files.map((f) => f.path.split("/")[0]));
const single = files.length === 1 && !files[0]!.path.includes("/");
const root = t.save_path.replace(/\/$/, "");
t.content_path = single
? `${root}/${files[0]!.path}`
: heads.size === 1 && files.every((f) => f.path.includes("/"))
? `${root}/${[...heads][0]}`
: root;
}
async function materialize(t: MockQbTorrent): Promise<string[]> {
const total = t.files.reduce((a, f) => a + (f.priority === 0 ? 0 : f.size), 0);
const limit = opts.maxMaterializeBytes ?? 64 * 1024 * 1024;
if (total > limit) {
throw new Error(`mock qb: 要写出 ${total} 字节,超过上限 ${limit};用 complete(hash, { files }) 配小文件`);
}
const written: string[] = [];
for (const f of t.files) {
if (f.priority === 0) continue;
const abs = safeJoin(t.save_path, f.name);
await mkdir(dirname(abs), { recursive: true });
const fh = await open(abs, "w");
try {
for (let left = f.size; left > 0; ) {
const n = Math.min(left, 1024 * 1024);
await fh.write(randomBytes(n));
left -= n;
}
} finally {
await fh.close();
}
written.push(abs);
}
materialized.set(t.hash, [...(materialized.get(t.hash) ?? []), ...written]);
return written;
}
/** 删除落盘文件,并自下而上清掉因此变空的目录(不越过 save_path) */
function removeMaterialized(t: MockQbTorrent) {
const files = materialized.get(t.hash) ?? [];
materialized.delete(t.hash);
const dirs = new Set<string>();
for (const f of files) {
rmSync(f, { force: true });
for (let d = dirname(f); relative(t.save_path, d) && !relative(t.save_path, d).startsWith(".."); d = dirname(d)) {
dirs.add(d);
}
}
for (const d of [...dirs].sort((a, b) => b.length - a.length)) {
try {
rmdirSync(d);
} catch {
// 非空或已不存在:保留
}
}
}
function toInfo(t: MockQbTorrent) {
const { files: _f, hiddenPolls: _h, tags, ...rest } = t;
return { ...rest, tags: tags.join(", ") };
@@ -401,7 +491,10 @@ export async function createMockQb(opts: MockQbOptions = {}): Promise<MockQb> {
"torrents/tags": () => ({ body: JSON.stringify([...tagDefs]), json: true }),
"torrents/delete": (_q, form) => {
if (form.deleteFiles !== "true" && form.deleteFiles !== "false") return { status: 400, body: "deleteFiles" };
for (const t of select(form.hashes)) torrents.delete(t.hash);
for (const t of select(form.hashes)) {
if (form.deleteFiles === "true") removeMaterialized(t);
torrents.delete(t.hash);
}
return { body: "" };
},
"sync/maindata": () => {
@@ -508,7 +601,13 @@ export async function createMockQb(opts: MockQbOptions = {}): Promise<MockQb> {
return m;
},
setProgress: (h, p) => setProgress(need(h), p),
complete: (h) => setProgress(need(h), 1),
complete: async (h, o = {}) => {
const t = need(h);
if (o.files) applyFiles(t, o.files);
setProgress(t, 1);
const write = o.writeFiles ?? (o.files !== undefined || opts.materializeFiles === true);
return write ? materialize(t) : [];
},
setState: (h, s) => {
need(h).state = s;
},
+90
View File
@@ -0,0 +1,90 @@
import { existsSync, readdirSync, statSync } from "node:fs";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { createMockQb, type MockQb } from "../src/index.ts";
let mock: MockQb | null = null;
let dir: string | null = null;
afterEach(async () => {
await mock?.close();
mock = null;
if (dir) await rm(dir, { recursive: true, force: true });
dir = null;
});
async function login(m: MockQb): Promise<string> {
const res = await fetch(`${m.url}/api/v2/auth/login`, {
method: "POST",
body: new URLSearchParams({ username: "admin", password: "adminadmin" }),
});
return /SID=([^;]+)/.exec(res.headers.get("set-cookie") ?? "")![1]!;
}
async function del(m: MockQb, hash: string, deleteFiles: boolean) {
const sid = await login(m);
const res = await fetch(`${m.url}/api/v2/torrents/delete`, {
method: "POST",
headers: { cookie: `SID=${sid}` },
body: new URLSearchParams({ hashes: hash, deleteFiles: String(deleteFiles) }),
});
expect(res.status).toBe(200);
}
const H = "c".repeat(40);
describe("mock qB 落盘(B8)", () => {
it("complete({ files }) 在 save_path 下写出随机内容文件,并更新文件清单与 content_path", async () => {
dir = await mkdtemp(join(tmpdir(), "umt-mockqb-"));
mock = await createMockQb();
mock.seed({ hash: H, name: "Show.S01", save_path: dir });
const written = await mock.complete(H, {
files: [
{ path: "Show.S01/Show.S01E01.mkv", size: 3 * 1024 * 1024 + 7 },
{ path: "Show.S01/Subs/Show.S01E01.chs.srt", size: 100 },
],
});
expect(written).toEqual([join(dir, "Show.S01/Show.S01E01.mkv"), join(dir, "Show.S01/Subs/Show.S01E01.chs.srt")]);
expect(statSync(written[0]!).size).toBe(3 * 1024 * 1024 + 7);
expect(statSync(written[1]!).size).toBe(100);
// 随机内容:不是全零
expect((await readFile(written[1]!)).some((b) => b !== 0)).toBe(true);
const t = mock.get(H)!;
expect(t).toMatchObject({ progress: 1, size: 3 * 1024 * 1024 + 107, content_path: `${dir}/Show.S01` });
expect(t.files.map((f) => f.name)).toEqual(["Show.S01/Show.S01E01.mkv", "Show.S01/Subs/Show.S01E01.chs.srt"]);
// deleteFiles=true 同步删除落盘文件和因此变空的目录,save_path 本身保留
await del(mock, H, true);
expect(written.some((p) => existsSync(p))).toBe(false);
expect(readdirSync(dir)).toEqual([]);
});
it("deleteFiles=false 只删任务不删文件;默认不落盘,materializeFiles 打开后按现有清单写", async () => {
dir = await mkdtemp(join(tmpdir(), "umt-mockqb-"));
mock = await createMockQb({ materializeFiles: true });
mock.seed({ hash: H, name: "Movie.2024.mkv", save_path: dir, size: 2048 });
const written = await mock.complete(H);
expect(written).toEqual([join(dir, "Movie.2024.mkv")]);
await del(mock, H, false);
expect(existsSync(written[0]!)).toBe(true);
const plain = await createMockQb();
try {
plain.seed({ hash: H, name: "x.mkv", save_path: "/downloads" });
expect(await plain.complete(H)).toEqual([]);
expect(plain.get(H)!.progress).toBe(1);
} finally {
await plain.close();
}
});
it("超出落盘上限、路径越界时抛错", async () => {
dir = await mkdtemp(join(tmpdir(), "umt-mockqb-"));
mock = await createMockQb({ maxMaterializeBytes: 1024 });
mock.seed({ hash: H, name: "big.mkv", save_path: dir, size: 4096 });
await expect(mock.complete(H, { writeFiles: true })).rejects.toThrow(/超过上限/);
await expect(mock.complete(H, { files: [{ path: "../escape.mkv", size: 1 }] })).rejects.toThrow(/非法文件路径/);
expect(readdirSync(dir)).toEqual([]);
});
});