feat(browser): unify site credentials with browser profiles (BR-2)
- Inject the site account Cookie (manual + auto-login session, session wins)
into the camofox profile before the first tab opens; password accounts
without a session run refresh-login first; API Key sites are skipped.
- Cookie expired after injection -> credential.status=expired; cookie
accounts go to waiting/login_required ("log in once, applies site-wide").
- Write back site-domain cookies from the browser on handover, session close,
every 10 min of activity, and from refresh-login when the stored cookie is
invalid (verified before saving). user_info.cookieOrigin records
source=browser; source shown as manual/login/browser in the origin view.
- New camofox plugin umt-cookies (GET/POST /umt/sessions/:userId/cookies
under the access key); profile dir 0700/files 0600 in the desktop image.
- Single entry: sources-identity/service/credentials.ts.
- Real check: HHClub cookie injected into a fresh profile -> home page
logged in (logout link present), 5 cookies read back.
Requires rebuilding umt-browser-desktop (docker compose build camofox &&
docker compose up -d camofox) to take effect.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
901b01d1a1
commit
28974d1e1a
58 files changed
+2843
-1112
No files matched your search
@@ -0,0 +1,139 @@
|
||||
import { createMockCamofox, type MockCamofox } from "@umt/testkit";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
BrowserError,
|
||||
type BrowserLogger,
|
||||
cookieDomainMatches,
|
||||
cookiesFromHeader,
|
||||
createCamofoxBrowserProvider,
|
||||
headerFromCookies,
|
||||
mergeCookieHeaders,
|
||||
parseCookieHeader,
|
||||
profileKeyFor,
|
||||
sameCookieHeader,
|
||||
staticBrowserSecrets,
|
||||
} from "./index.ts";
|
||||
|
||||
// BR-2:Cookie 头串 ⇄ 浏览器 Cookie 互转,与 camofox Cookie 接口(umt-cookies 插件 / 上游导入端点 / 都没有)。
|
||||
|
||||
const ACCESS_KEY = "camofox-access-key-br2";
|
||||
const PROVIDER = "11111111-1111-4111-8111-111111111111";
|
||||
const SITE = "22222222-2222-4222-8222-222222222222";
|
||||
const ACCOUNT = "33333333-3333-4333-8333-333333333333";
|
||||
const SECRET = "c0ffee-session-value-br2";
|
||||
|
||||
describe("Cookie 头串互转", () => {
|
||||
it("解析 / 合并(后者优先)/ 等价比较", () => {
|
||||
expect([...parseCookieHeader(" a=1; b = 2 ;;bad; c=x=y ")]).toEqual([
|
||||
["a", "1"],
|
||||
["b", "2"],
|
||||
["c", "x=y"],
|
||||
]);
|
||||
expect(mergeCookieHeaders("a=1; b=2", "b=3; c=4")).toBe("a=1; b=3; c=4");
|
||||
expect(mergeCookieHeaders(null, "")).toBeNull();
|
||||
expect(sameCookieHeader("a=1; b=2", "b=2;a=1")).toBe(true);
|
||||
expect(sameCookieHeader("a=1", "a=2")).toBe(false);
|
||||
});
|
||||
|
||||
it("注入对象:站点主机 host-only、https → secure、30 天期限", () => {
|
||||
const list = cookiesFromHeader("uid=1; pass=x", "https://hhanclub.net/", 1000);
|
||||
expect(list).toEqual([
|
||||
expect.objectContaining({
|
||||
name: "uid",
|
||||
domain: "hhanclub.net",
|
||||
path: "/",
|
||||
secure: true,
|
||||
expires: 1000 + 30 * 86400,
|
||||
}),
|
||||
expect.objectContaining({ name: "pass", value: "x" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("读回只取站点域名、未过期的 Cookie;同名取更具体的", () => {
|
||||
expect(cookieDomainMatches(".hhanclub.net", "www.hhanclub.net")).toBe(true);
|
||||
expect(cookieDomainMatches("hhanclub.net", "evilhhanclub.net")).toBe(false);
|
||||
const h = headerFromCookies(
|
||||
[
|
||||
{ name: "pass", value: "root", domain: ".hhanclub.net", path: "/", expires: -1, httpOnly: true, secure: true },
|
||||
{ name: "pass", value: "host", domain: "hhanclub.net", path: "/", expires: -1, httpOnly: true, secure: true },
|
||||
{ name: "old", value: "1", domain: "hhanclub.net", path: "/", expires: 10, httpOnly: false, secure: false },
|
||||
{ name: "_ga", value: "t", domain: ".google.com", path: "/", expires: -1, httpOnly: false, secure: false },
|
||||
],
|
||||
"https://hhanclub.net/",
|
||||
100,
|
||||
);
|
||||
expect(h).toBe("pass=host");
|
||||
expect(headerFromCookies([], "https://hhanclub.net/")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("camofox Cookie 接口", () => {
|
||||
let mock: MockCamofox | undefined;
|
||||
afterEach(async () => {
|
||||
await mock?.close();
|
||||
mock = undefined;
|
||||
});
|
||||
const provider = (m: MockCamofox, logs?: Parameters<BrowserLogger>[0][]) =>
|
||||
createCamofoxBrowserProvider({
|
||||
providerId: PROVIDER,
|
||||
baseUrl: m.url,
|
||||
secrets: staticBrowserSecrets({ [`provider:${PROVIDER}`]: ACCESS_KEY }),
|
||||
...(logs ? { logger: (e) => void logs.push(e) } : {}),
|
||||
});
|
||||
const key = profileKeyFor(SITE, ACCOUNT);
|
||||
const cookies = cookiesFromHeader(`c_secure_uid=1001; c_secure_pass=${SECRET}`, "https://audiences.example/");
|
||||
|
||||
it("umt-cookies 插件:写入(带访问凭据)与读回;日志不含 Cookie 值", async () => {
|
||||
mock = await createMockCamofox({ accessKey: ACCESS_KEY });
|
||||
const logs: Parameters<BrowserLogger>[0][] = [];
|
||||
const p = provider(mock, logs);
|
||||
expect(await p.setCookies(key, cookies)).toEqual({ count: 2 });
|
||||
const call = mock.callsTo("/umt/sessions/")[0]!;
|
||||
expect(call.headers.authorization).toBe(`Bearer ${ACCESS_KEY}`);
|
||||
const back = await p.getCookies(key, ["https://audiences.example/"]);
|
||||
expect(headerFromCookies(back, "https://audiences.example/")).toBe(`c_secure_pass=${SECRET}; c_secure_uid=1001`);
|
||||
expect(await p.getCookies(key, ["https://other.example/"])).toEqual([]);
|
||||
expect(JSON.stringify(logs)).not.toContain(SECRET);
|
||||
});
|
||||
|
||||
it("openSession 带 cookies:先注入再开标签页", async () => {
|
||||
mock = await createMockCamofox({ accessKey: ACCESS_KEY });
|
||||
const p = provider(mock);
|
||||
const r = await p.openSession({
|
||||
providerId: PROVIDER,
|
||||
siteId: SITE,
|
||||
accountId: ACCOUNT,
|
||||
initialUrl: "https://audiences.example/",
|
||||
budget: { domainAllowlist: ["audiences.example"], timeoutMs: 500, maxRetries: 0, maxBytes: 1_000_000 },
|
||||
cookies,
|
||||
});
|
||||
expect(r.cookies).toEqual({ status: "injected", count: 2 });
|
||||
const paths = mock.calls.map((c) => c.path);
|
||||
expect(paths.findIndex((x) => x.startsWith("/umt/"))).toBeLessThan(paths.indexOf("/tabs"));
|
||||
});
|
||||
|
||||
it("没有插件:回退上游导入端点(CAMOFOX_API_KEY 与访问凭据相同时);读回报不支持", async () => {
|
||||
mock = await createMockCamofox({ accessKey: ACCESS_KEY, cookieApi: "upstream", cookieImportKey: ACCESS_KEY });
|
||||
const p = provider(mock);
|
||||
expect(await p.setCookies(key, cookies)).toEqual({ count: 2 });
|
||||
expect(mock.cookieJars.get(key)).toHaveLength(2);
|
||||
await expect(p.getCookies(key, ["https://audiences.example/"])).rejects.toMatchObject({ kind: "not_implemented" });
|
||||
});
|
||||
|
||||
it("插件与上游导入都不可用:not_implemented;openSession 照常打开并标 unsupported", async () => {
|
||||
mock = await createMockCamofox({ accessKey: ACCESS_KEY, cookieApi: "upstream" });
|
||||
const p = provider(mock);
|
||||
const e = await p.setCookies(key, cookies).catch((x: unknown) => x);
|
||||
expect(e).toBeInstanceOf(BrowserError);
|
||||
expect((e as BrowserError).kind).toBe("not_implemented");
|
||||
expect((e as BrowserError).message).not.toContain(SECRET);
|
||||
const r = await p.openSession({
|
||||
providerId: PROVIDER,
|
||||
siteId: SITE,
|
||||
accountId: ACCOUNT,
|
||||
budget: { domainAllowlist: ["audiences.example"], timeoutMs: 500, maxRetries: 0, maxBytes: 1_000_000 },
|
||||
cookies,
|
||||
});
|
||||
expect(r.cookies).toEqual({ status: "unsupported", count: 0 });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user