Files
rootandClaude Opus 5.5 28974d1e1a 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]>
2026-09-29 15:25:07 +08:00

126 lines
5.0 KiB
TypeScript

import { sameCookieHeader } from "@umt/adapters";
import { createMockNexusPhpSite, type MockNexusPhpSite } from "@umt/testkit";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
readAccountCookie,
registerBrowserCookieReader,
writeBrowserCookie,
} from "../../src/modules/sources-identity/service/credentials.ts";
import { createHarness, type Harness, pgAvailable } from "./harness.ts";
// BR-2 凭据单一入口(mock NexusPHP 登录态判定):来源判定(手填 / 自动登录 / 浏览器回写)、回写只在有变化时写,
// refresh-login 发现 Cookie 失效时从浏览器 profile 读回试一次,有效才回写。
const up = await pgAvailable();
// 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;
};
describe.skipIf(!up)("BR-2 站点账号 Cookie 单一入口", () => {
let h: Harness;
let mock: MockNexusPhpSite;
let siteId: string;
let accountId: string;
let browserCookie: string | null = null;
beforeAll(async () => {
mock = await createMockNexusPhpSite({ username: "alice", password: "s3cret-pw" });
h = await createHarness("br2cred");
// 测试用 secret 替身不记 rotated_at:补上(来源判定依赖它)
const replace = h.secrets.replace.bind(h.secrets);
h.secrets.replace = async (ref, plaintext) => {
await replace(ref, plaintext);
await h.db.updateTable("secret").set({ rotated_at: new Date() }).where("id", "=", ref).execute();
};
const site = await json(
await h.req("/sites", {
body: {
key: "audiences",
name: "观众",
adapter: "nexusphp",
baseUrl: `${mock.baseUrl}/`,
rateLimit: { minIntervalMs: 0, concurrency: 10 },
},
}),
);
siteId = site.id;
registerBrowserCookieReader(h.ctx, async () => browserCookie);
});
afterAll(async () => {
await h?.close();
await mock?.close();
});
const refresh = async () =>
(await json(await h.req(`/sites/${siteId}/refresh-login`, { body: { wait: true, accountId } }))).result;
it("手填 Cookie → 来源 manual;浏览器回写 → browser;再手填 → manual", async () => {
const r = await json(
await h.req(`/sites/${siteId}/credentials`, {
method: "PUT",
body: { kind: "cookie", cookie: "c_secure_uid=1; c_secure_pass=stale" },
}),
);
accountId = r.siteAccountId;
expect(await readAccountCookie(h.ctx, accountId)).toMatchObject({ source: "manual", applicable: true });
expect(await writeBrowserCookie(h.ctx, accountId, "c_secure_pass=stale; c_secure_uid=1")).toMatchObject({
changed: false,
});
const w = await writeBrowserCookie(h.ctx, accountId, "c_secure_uid=1; c_secure_pass=from-browser");
expect(w).toMatchObject({ changed: true, count: 2 });
expect(await readAccountCookie(h.ctx, accountId)).toMatchObject({
source: "browser",
header: "c_secure_pass=from-browser; c_secure_uid=1",
});
await new Promise((r) => setTimeout(r, 10));
await json(
await h.req(`/sites/${siteId}/credentials`, {
method: "PUT",
body: { kind: "cookie", cookie: "c_secure_uid=1; c_secure_pass=stale" },
}),
);
// cookieOrigin 记的时间比 secret 最近写入早 5 秒以上才判手填;测试里直接把回写时间拨早
await h.db
.updateTable("site_account")
.set({
user_info: JSON.stringify({ cookieOrigin: { source: "browser", by: "browser", at: "2020-01-01T00:00:00Z" } }),
})
.where("id", "=", accountId)
.execute();
expect(await readAccountCookie(h.ctx, accountId)).toMatchObject({ source: "manual" });
});
it("refresh-login:存的 Cookie 失效,浏览器里有有效登录态 → 验证通过后回写,账号 active", async () => {
browserCookie = "c_secure_uid=1; c_secure_pass=also-stale";
const bad = await refresh();
expect(bad).toMatchObject({ ok: false, status: "expired" });
browserCookie = mock.cookie;
const ok = await refresh();
expect(ok).toMatchObject({ ok: true, status: "active" });
const acc = await readAccountCookie(h.ctx, accountId);
expect(acc).toMatchObject({ source: "browser" });
expect(acc!.header).toContain("mock-session-secret");
// 之后 HTTP 抓取取凭证走同一存储
const used = await h.rt["siteSecrets"](siteId).resolve({ siteId, kind: "cookie" });
expect(sameCookieHeader(used, acc!.header)).toBe(true);
});
it("API Key 账号不适用", async () => {
const r = await json(
await h.req(`/sites/${siteId}/credentials`, {
method: "PUT",
body: { kind: "api_key", label: "k", apiKey: "k-123" },
}),
);
expect(await readAccountCookie(h.ctx, r.siteAccountId)).toMatchObject({ applicable: false, header: null });
expect(await writeBrowserCookie(h.ctx, r.siteAccountId, "a=1")).toMatchObject({ changed: false });
});
});