import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { readAccountCookie } from "../../src/modules/sources-identity/service/credentials.ts"; import { type BrowserHarness, createBrowserHarness, type Loose, ORIGIN, pgAvailable } from "./harness.ts"; // BR-2 凭据统一(对 testkit mock camofox + mock NexusPHP 登录态判定): // 打开会话注入站点账号 Cookie → 首页 / 受保护页是登录态;Cookie 过期 → 需要人工登录; // 人工在画面里登录后交还 → 读回站点域名 Cookie 写进账号(来源 browser);关闭时也回写;没有插件时降级说明。 const up = await pgAvailable(); const GOOD = "good-session-value-7f3a"; const STALE = "stale-session-value-11"; const FRESH = "fresh-browser-login-9c2"; describe.skipIf(!up)("BR-2 站点账号 Cookie ⇄ 浏览器 profile", () => { let h: BrowserHarness; let cookieAcc: string; const setCookieAccount = async (cookie: string) => { const ref = await h.secrets.create({ purpose: "site_cookie", plaintext: cookie }); await h.db .updateTable("site_account") .set({ auth_kind: "cookie", secret_ref: ref, session_secret_ref: null, status: "active", user_info: "{}" }) .where("id", "=", cookieAcc) .execute(); }; const open = async (path = "/torrents.php") => (await h.req("/browser/sessions", { body: { siteId: h.siteId, accountId: cookieAcc, url: `${ORIGIN}${path}` } })) .body.data as Loose; const closeAll = async () => { for (const s of (await h.req("/browser/sessions")).body.data.items as Loose[]) await h.req(`/browser/sessions/${s.id}`, { method: "DELETE" }); }; beforeAll(async () => { h = await createBrowserHarness("cred", { sessionCookie: { name: "c_secure_pass", valid: [GOOD], fresh: FRESH }, }); cookieAcc = h.accountId; }, 120_000); afterAll(async () => { await h?.close(); }); it("注入:Cookie 账号打开会话前写进该 profile,受保护页直接是登录态;响应里没有 Cookie 值", async () => { await setCookieAccount(`c_secure_uid=1001; c_secure_pass=${GOOD}`); const s = await open(); expect(s.controller).toBe("automation"); expect(s.currentUrl).toBe(`${ORIGIN}/torrents.php`); expect(s.credential).toMatchObject({ status: "injected", source: "manual", count: 2 }); expect(JSON.stringify(s)).not.toContain(GOOD); // 注入走 umt-cookies 插件,早于打开标签页;Cookie 按站点主机 host-only、https → secure const calls = h.mock.calls.map((c) => `${c.method} ${c.path}`); const inj = calls.indexOf(`POST /umt/sessions/${encodeURIComponent(s.profileKey)}/cookies`); expect(inj).toBeGreaterThanOrEqual(0); expect(inj).toBeLessThan(calls.lastIndexOf("POST /tabs")); const jar = h.mock.cookieJars.get(s.profileKey)!; expect(jar.find((c) => c.name === "c_secure_pass")).toMatchObject({ value: GOOD, domain: "audiences.example", secure: true, }); await closeAll(); }); it("过期:注入后仍落在登录页 → 需要人工登录;人工登录后交还 → 回写账号 Cookie(来源浏览器),下次抓取用新值", async () => { h.mock.loggedIn.clear(); h.mock.cookieJars.clear(); await setCookieAccount(`c_secure_uid=1001; c_secure_pass=${STALE}`); const s = await open(); expect(s.controller).toBe("waiting"); expect(s.waitingReason).toBe("login_required"); expect(s.credential).toMatchObject({ status: "expired" }); expect(s.credential.message).toContain("登录一次"); const d = await h.db.selectFrom("decision").selectAll().where("subject_id", "=", s.id).executeTakeFirstOrThrow(); expect(d.summary).toContain("交还后全站生效"); // 人工接管,在画面里登录(桌面模式里的导航不经 API:直接改 mock 的登录态与当前地址) expect((await h.req(`/browser/sessions/${s.id}/control/acquire`, { body: { reason: "登录" } })).status).toBe(200); const tab = [...h.mock.tabs.values()].find((t) => t.userId === s.profileKey)!; h.mock.loggedIn.add(s.profileKey); h.mock.cookieJars.get(s.profileKey)!.push( { name: "c_secure_pass", value: FRESH, domain: "audiences.example", path: "/", expires: -1, httpOnly: true, secure: true, }, // 别的域名的 Cookie 不回写 { name: "_ga", value: "tracker", domain: ".analytics.example", path: "/", expires: -1, httpOnly: false, secure: true, }, ); tab.url = `${ORIGIN}/torrents.php`; const r = await h.req(`/browser/sessions/${s.id}/control/release`, { body: {} }); expect(r.status).toBe(200); const acc = await readAccountCookie(h.ctx, cookieAcc); expect(acc).toMatchObject({ source: "browser", applicable: true }); expect(acc!.header).toContain(`c_secure_pass=${FRESH}`); expect(acc!.header).toContain("c_secure_uid=1001"); expect(acc!.header).not.toContain("_ga"); const row = await h.db.selectFrom("site_account").selectAll().where("id", "=", cookieAcc).executeTakeFirstOrThrow(); expect((row.user_info as Loose).cookieOrigin).toMatchObject({ source: "browser", by: "browser" }); const view = (await h.req(`/browser/sessions/${s.id}`)).body.data; expect(view.credential).toMatchObject({ status: "injected", source: "browser" }); expect(view.credential.syncedAt).toBeTruthy(); expect(JSON.stringify(view)).not.toContain(FRESH); await closeAll(); }); it("关闭会话时回写;停在登录页不回写(未登录的 Cookie 不覆盖存储)", async () => { await setCookieAccount(`c_secure_uid=1001; c_secure_pass=${GOOD}`); h.mock.cookieJars.clear(); const s = await open(); expect(s.credential.status).toBe("injected"); // 站点在浏览器里轮换了会话 Cookie const jar = h.mock.cookieJars.get(s.profileKey)!; jar.find((c) => c.name === "c_secure_pass")!.value = "rotated-by-site-44"; await h.req(`/browser/sessions/${s.id}`, { method: "DELETE" }); expect((await readAccountCookie(h.ctx, cookieAcc))!.header).toContain("c_secure_pass=rotated-by-site-44"); // 登录页:不回写 await setCookieAccount(`c_secure_uid=1001; c_secure_pass=${GOOD}`); const s2 = await open("/login.php"); h.mock.cookieJars.get(s2.profileKey)!.find((c) => c.name === "c_secure_pass")!.value = "anonymous-junk"; await h.req(`/browser/sessions/${s2.id}`, { method: "DELETE" }); expect((await readAccountCookie(h.ctx, cookieAcc))!.header).toContain(`c_secure_pass=${GOOD}`); }); it("有活动时每 10 分钟读回一次", async () => { await setCookieAccount(`c_secure_uid=1001; c_secure_pass=${GOOD}`); h.mock.cookieJars.clear(); const s = await open(); h.mock.cookieJars.get(s.profileKey)!.find((c) => c.name === "c_secure_pass")!.value = "periodic-rotated-5"; // 刚打开:不读回 await h.req(`/browser/sessions/${s.id}/snapshot`); expect((await readAccountCookie(h.ctx, cookieAcc))!.header).toContain(`c_secure_pass=${GOOD}`); // 会话创建时间拨到 11 分钟前:下一次活动触发后台读回 await h.db .updateTable("browser_session") .set({ created_at: new Date(Date.now() - 11 * 60_000) }) .where("id", "=", s.id) .execute(); await h.req(`/browser/sessions/${s.id}/snapshot`); await expect .poll(async () => (await readAccountCookie(h.ctx, cookieAcc))!.header, { timeout: 3000 }) .toContain("c_secure_pass=periodic-rotated-5"); await closeAll(); }); it("匿名会话与 API Key 账号不注入", async () => { const r = await h.req("/browser/sessions", { body: { siteId: h.siteId, accountId: null, url: `${ORIGIN}/` } }); expect(r.body.data.credential).toMatchObject({ status: "not_applicable" }); await closeAll(); const ref = await h.secrets.create({ purpose: "site_api_key", plaintext: "api-key-xyz" }); await h.db .updateTable("site_account") .set({ auth_kind: "api_key", secret_ref: ref }) .where("id", "=", cookieAcc) .execute(); const before = h.mock.callsTo(/\/cookies$/).length; const s = await open("/"); expect(s.credential).toMatchObject({ status: "not_applicable" }); expect(h.mock.callsTo(/\/cookies$/).length).toBe(before); await closeAll(); }); }); describe.skipIf(!up)("BR-2 浏览器服务没有 umt-cookies 插件", () => { let h: BrowserHarness; beforeAll(async () => { h = await createBrowserHarness("cred_noplugin", { sessionCookie: { name: "c_secure_pass", valid: [GOOD], fresh: FRESH }, cookieApi: "none", }); }, 120_000); afterAll(async () => { await h?.close(); }); it("注入报不支持,会话照常打开并说明原因", async () => { const ref = await h.secrets.create({ purpose: "site_cookie", plaintext: `c_secure_pass=${GOOD}` }); await h.db .updateTable("site_account") .set({ auth_kind: "cookie", secret_ref: ref }) .where("id", "=", h.accountId) .execute(); const r = await h.req("/browser/sessions", { body: { siteId: h.siteId, url: `${ORIGIN}/` } }); expect(r.status).toBe(200); expect(r.body.data.credential).toMatchObject({ status: "unsupported", count: 0 }); expect(r.body.data.credential.message).toContain("umt-cookies"); }); });