Files
rootandClaude Opus 5.5 adc853aa64 feat(sites): site requests follow browser UA, smooth UA switch, CF cookies browser-only (BR-4)
- every outbound site request (adapter list/search/detail/download/health,
  RSS, login, user data) carries the account UA via SourcesRuntime.siteFetch;
  no more Deno default UA
- priority: account manual > pending > global setting > browser identity
  (fallback: same-shape Windows Firefox UA); settings UA default is empty
- migration 0056: existing cookie/password accounts keep their old UA as
  ua_pending until re-login or browser cookie write-back switches cookie+UA
  in one transaction; browser_provider.identity caches the browser identity
- cf_clearance/__cf_bm stripped on browser write-back (names kept only)
- browser sessions get per-site egress (same proxy as HTTP, or direct)
- site account page shows UA source; raw UA in tech info

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-30 03:17:13 +08:00

274 lines
12 KiB
TypeScript

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<any> => {
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);
});
});