- 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]>
274 lines
12 KiB
TypeScript
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);
|
|
});
|
|
});
|