import { FALLBACK_SITE_USER_AGENT, LEGACY_SITE_USER_AGENT } from "@umt/contracts"; import { createMockNexusPhpSite, createMockRssServer, type MockNexusPhpSite, type MockRssServer } from "@umt/testkit"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { readAccountCookie, registerBrowserCookieReader, writeBrowserCookie, } from "../../src/modules/sources-identity/service/credentials.ts"; import { ingestFeed } from "../../src/modules/sources-identity/service/feeds.ts"; import { registerBrowserIdentityReader, resolveUserAgent, } from "../../src/modules/sources-identity/service/user-agent.ts"; import { createHarness, type Harness, pgAvailable, seedFeed } from "./harness.ts"; // BR-4 站点请求 UA:每条出站请求都带账号 UA(不再是 Deno 缺省 UA)、优先级(手动 > 待切换 > 全局 > 跟随浏览器)、 // 平滑迁移(待切换账号沿用旧 UA,重新登录 / 浏览器回写 Cookie 时 Cookie + UA 一起切换)、Cloudflare Cookie 仅浏览器。 const up = await pgAvailable(); const BROWSER_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:152.0) Gecko/20100101 Firefox/152.0"; // biome-ignore lint/suspicious/noExplicitAny: 响应体断言 const json = async (r: Response): Promise => { const body = await r.json(); if (!r.ok) throw new Error(`${r.status} ${JSON.stringify(body)}`); return body.data; }; type RecordedRequest = MockNexusPhpSite["requests"][number]; const uaOf = (r: RecordedRequest) => String(r.headers["user-agent"] ?? ""); describe("resolveUserAgent 优先级", () => { const d = { global: "", browser: "B" }; it("手动 > 待切换 > 全局 > 跟随浏览器 > 常量", () => { expect(resolveUserAgent({ user_agent: "M", ua_pending: "P" }, { global: "G", browser: "B" })).toEqual({ ua: "M", source: "manual", }); expect(resolveUserAgent({ user_agent: null, ua_pending: "P" }, { global: "G", browser: "B" })).toEqual({ ua: "P", source: "pending", }); expect( resolveUserAgent({ user_agent: null, ua_pending: "P" }, { global: "G", browser: "B" }, { ignorePending: true }), ).toEqual({ ua: "G", source: "global" }); expect(resolveUserAgent({ user_agent: " ", ua_pending: null }, d)).toEqual({ ua: "B", source: "browser" }); expect(resolveUserAgent(null, { global: "", browser: "" })).toEqual({ ua: FALLBACK_SITE_USER_AGENT, source: "browser", }); }); }); describe.skipIf(!up)("BR-4 站点请求 UA 与 Cookie 切换", () => { let h: Harness; let mock: MockNexusPhpSite; let rss: MockRssServer; let siteId: string; let accountId: string; let browserUa: string | null = BROWSER_UA; let browserCookie: string | null = null; const account = () => h.db.selectFrom("site_account").selectAll().where("id", "=", accountId).executeTakeFirstOrThrow(); const site = () => h.db.selectFrom("site").selectAll().where("id", "=", siteId).executeTakeFirstOrThrow(); const apiAccount = async () => json(await h.req(`/site-accounts/${accountId}`)); const refresh = async () => (await json(await h.req(`/sites/${siteId}/refresh-login`, { body: { wait: true, accountId } }))).result; const since = (n: number) => mock.requests.slice(n); beforeAll(async () => { mock = await createMockNexusPhpSite({ username: "alice", password: "s3cret-pw" }); rss = await createMockRssServer({ passkey: "PK", items: [ { guid: "br4-1", title: "Some.Movie.2024.1080p.BluRay-GRP", link: `${mock.baseUrl}/details.php?id=900001`, enclosureUrl: `${mock.baseUrl}/download.php?id=900001&passkey=PK`, size: 1024 ** 3, pubDate: new Date("2026-09-20T08:00:00Z"), categories: ["电影"], }, ], }); h = await createHarness("br4ua"); registerBrowserIdentityReader(h.ctx, async () => browserUa); registerBrowserCookieReader(h.ctx, async () => browserCookie); const s = await json( await h.req("/sites", { body: { key: "audiences", name: "观众", adapter: "nexusphp", baseUrl: `${mock.baseUrl}/`, rateLimit: { minIntervalMs: 0, concurrency: 10 }, }, }), ); siteId = s.id; const r = await json( await h.req(`/sites/${siteId}/credentials`, { method: "PUT", body: { kind: "password", username: "alice", password: "s3cret-pw" }, }), ); accountId = r.siteAccountId; }); afterAll(async () => { await h?.close(); await mock?.close(); await rss?.close(); }); it("登录、用户数据、列表 / 搜索 / 详情 / 下载 / 健康检测、RSS:每条请求都带跟随浏览器的 UA", async () => { const n0 = mock.requests.length; expect(await refresh()).toMatchObject({ ok: true, status: "active" }); const adapter = h.rt.siteAdapter(await site()); const page = await adapter.list(0); const id = page.items[0]!.siteReleaseId; await adapter.search("Movie"); await adapter.detail(id); const target = await adapter.downloadTarget(id); expect(target.kind).toBe("torrent"); expect((await adapter.healthCheck()).ok).toBe(true); const f = await seedFeed(h, { siteId, url: rss.feedUrl, name: "br4", accountId }); expect((await ingestFeed(h.rt, f.id)).status).toBe("ok"); const reqs = [...since(n0), ...rss.requests]; const paths = new Set(since(n0).map((r) => r.path.split("?")[0])); for (const p of ["/takelogin.php", "/torrents.php", "/details.php", "/download.php"]) expect(paths).toContain(p); expect(rss.requests.length).toBeGreaterThan(0); for (const r of reqs) { expect(uaOf(r), `${r.method} ${r.path}`).toBe(BROWSER_UA); expect(uaOf(r)).not.toMatch(/Deno/i); } expect(await apiAccount()).toMatchObject({ userAgentSource: "browser", effectiveUserAgent: BROWSER_UA, userAgent: null, }); }); it("浏览器身份读不到:退回同型 Windows Firefox 常量;设置里的全局 UA 优先于跟随浏览器,账号手动再优先", async () => { browserUa = null; // runtime 缓存浏览器 UA 60 秒:换一个 runtime 读 (h.rt as unknown as { browserUa: { at: number } }).browserUa.at = 0; let n = mock.requests.length; await h.rt.siteAdapter(await site()).healthCheck(); expect(since(n).map(uaOf)).toEqual(since(n).map(() => FALLBACK_SITE_USER_AGENT)); await h.db .insertInto("settings") .values({ key: "sites", value: JSON.stringify({ userAgent: "Global-UA/1.0" }) }) .execute(); n = mock.requests.length; await h.rt.siteDefaults(); await h.rt.siteAdapter(await site()).healthCheck(); expect(new Set(since(n).map(uaOf))).toEqual(new Set(["Global-UA/1.0"])); expect(await apiAccount()).toMatchObject({ userAgentSource: "global", effectiveUserAgent: "Global-UA/1.0" }); await h.db.updateTable("site_account").set({ user_agent: "Manual-UA/2.0" }).where("id", "=", accountId).execute(); n = mock.requests.length; await h.rt.siteAdapter(await site()).healthCheck(); expect(new Set(since(n).map(uaOf))).toEqual(new Set(["Manual-UA/2.0"])); expect(await apiAccount()).toMatchObject({ userAgentSource: "manual", userAgent: "Manual-UA/2.0" }); await h.db.updateTable("site_account").set({ user_agent: null }).where("id", "=", accountId).execute(); await h.db.deleteFrom("settings").where("key", "=", "sites").execute(); await h.rt.siteDefaults(); browserUa = BROWSER_UA; (h.rt as unknown as { browserUa: { at: number } }).browserUa.at = 0; }); it("待切换:沿用旧 UA;重新登录用浏览器 UA,会话 Cookie 与 UA 一起切换", async () => { await h.db .updateTable("site_account") .set({ ua_pending: LEGACY_SITE_USER_AGENT }) .where("id", "=", accountId) .execute(); expect(await apiAccount()).toMatchObject({ userAgentSource: "pending", effectiveUserAgent: LEGACY_SITE_USER_AGENT, }); let n = mock.requests.length; await h.rt.siteAdapter(await site()).healthCheck(); expect(new Set(since(n).map(uaOf))).toEqual(new Set([LEGACY_SITE_USER_AGENT])); n = mock.requests.length; expect(await refresh()).toMatchObject({ ok: true }); const login = since(n).find((r) => r.path.startsWith("/takelogin.php")); expect(login && uaOf(login)).toBe(BROWSER_UA); expect((await account()).ua_pending).toBeNull(); n = mock.requests.length; await h.rt.siteAdapter(await site()).healthCheck(); expect(new Set(since(n).map(uaOf))).toEqual(new Set([BROWSER_UA])); expect(await apiAccount()).toMatchObject({ userAgentSource: "browser" }); }); it("浏览器回写 Cookie:原子切换 UA;cf_clearance / __cf_bm 只留在浏览器,站点请求不用", async () => { const r = await json( await h.req(`/sites/${siteId}/credentials`, { method: "PUT", body: { kind: "cookie", label: "c", cookie: "c_secure_uid=1; c_secure_pass=old" }, }), ); const cid = r.siteAccountId as string; await h.db.updateTable("site_account").set({ ua_pending: LEGACY_SITE_USER_AGENT }).where("id", "=", cid).execute(); const w = await writeBrowserCookie( h.ctx, cid, "c_secure_uid=1; c_secure_pass=from-browser; cf_clearance=CFSECRET; __cf_bm=BMSECRET", ); expect(w).toMatchObject({ changed: true, count: 2, uaSwitched: true, browserOnly: ["__cf_bm", "cf_clearance"] }); const acc = await readAccountCookie(h.ctx, cid); expect(acc!.header).toBe("c_secure_pass=from-browser; c_secure_uid=1"); expect(acc!.header).not.toContain("CFSECRET"); const row = await h.db.selectFrom("site_account").selectAll().where("id", "=", cid).executeTakeFirstOrThrow(); expect(row.ua_pending).toBeNull(); expect(row.user_info).toMatchObject({ browserOnlyCookies: ["__cf_bm", "cf_clearance"] }); const view = await json(await h.req(`/site-accounts/${cid}`)); expect(view).toMatchObject({ userAgentSource: "browser", browserOnlyCookies: ["__cf_bm", "cf_clearance"] }); expect(JSON.stringify(view)).not.toContain("CFSECRET"); // 只多了 CF Cookie、其余不变:不算变化 expect( await writeBrowserCookie(h.ctx, cid, "c_secure_uid=1; c_secure_pass=from-browser; cf_clearance=OTHER; __cf_bm=X"), ).toMatchObject({ changed: false }); // Cookie 不变但仍待切换:只切 UA await h.db.updateTable("site_account").set({ ua_pending: LEGACY_SITE_USER_AGENT }).where("id", "=", cid).execute(); expect(await writeBrowserCookie(h.ctx, cid, "c_secure_uid=1; c_secure_pass=from-browser")).toMatchObject({ changed: false, uaSwitched: true, }); expect( (await h.db.selectFrom("site_account").select("ua_pending").where("id", "=", cid).executeTakeFirstOrThrow()) .ua_pending, ).toBeNull(); await h.req(`/site-accounts/${cid}`, { method: "DELETE" }); }); it("refresh-login 从浏览器读回的 Cookie 也剔除 CF Cookie;换主凭证结束待切换", async () => { await h.db .updateTable("site_account") .set({ ua_pending: LEGACY_SITE_USER_AGENT }) .where("id", "=", accountId) .execute(); await json( await h.req(`/sites/${siteId}/credentials`, { method: "PUT", body: { kind: "cookie", cookie: "c_secure_uid=1; c_secure_pass=stale" }, }), ); expect((await account()).ua_pending).toBeNull(); await h.db .updateTable("site_account") .set({ ua_pending: LEGACY_SITE_USER_AGENT }) .where("id", "=", accountId) .execute(); browserCookie = `${mock.cookie}; cf_clearance=CFSECRET`; const n = mock.requests.length; expect(await refresh()).toMatchObject({ ok: true, status: "active" }); const acc = await readAccountCookie(h.ctx, accountId); expect(acc!.header).not.toContain("cf_clearance"); expect((await account()).ua_pending).toBeNull(); // 验证浏览器 Cookie 的请求用浏览器 UA,且没有带 cf_clearance const withBrowserCookie = since(n).filter((r) => String(r.headers.cookie ?? "").includes("mock")); for (const r of since(n)) expect(String(r.headers.cookie ?? "")).not.toContain("CFSECRET"); expect(withBrowserCookie.every((r) => uaOf(r) === BROWSER_UA)).toBe(true); }); });