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
@@ -175,15 +175,6 @@ describe.skipIf(!up)("来源管理与站点工作台 API", () => {
|
||||
.where("key", "=", `site:${siteId}`)
|
||||
.executeTakeFirst();
|
||||
expect(bucket).toMatchObject({ capacity: 5, refill_per_sec: 200 });
|
||||
// 手动快照要 5 页也只抓访问默认的页数上限(settings.sites.defaultSnapshotMaxPages 缺省 1)
|
||||
expect((await snapshotSite(h.rt, { siteId, kind: "manual", maxPages: 5 })).pages).toEqual([1]);
|
||||
// 每拿到一个令牌计一次请求,按小时聚合;站点页读最近 24 小时
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
const stats = await json(await h.req(`/sites/${siteId}/request-stats`));
|
||||
expect(stats.status).toBe(200);
|
||||
expect(stats.body.data.last24h).toBeGreaterThanOrEqual(4);
|
||||
expect(stats.body.data.hourly).toHaveLength(24);
|
||||
expect(stats.body.data.access).toMatchObject({ snapshotMaxPages: 1, healthCheckIntervalSec: 21600 });
|
||||
});
|
||||
|
||||
it("工作台列表:快照优先,标注来源与更新时间;筛选与分页", async () => {
|
||||
@@ -228,13 +219,6 @@ describe.skipIf(!up)("来源管理与站点工作台 API", () => {
|
||||
expect(d.body.data.refreshError).toBeNull();
|
||||
expect(d.body.data.volatile.recheckBeforeDownload).toBe(true);
|
||||
expect(d.body.data.volatile.detailFetchedAt).not.toBeNull();
|
||||
// 详情缓存期内(settings.sites.defaultDetailCacheSec 缺省 24 小时)再次刷新不请求站点
|
||||
const before = mock.requests.length;
|
||||
const cached = await json(await h.req(`/sites/${siteId}/releases/${rel.id}?refresh=true`));
|
||||
expect(cached.body.data.refreshError).toBeNull();
|
||||
expect(mock.requests.length).toBe(before);
|
||||
// 缓存过期后才重新抓
|
||||
await h.db.updateTable("source_release").set({ detail_fetched_at: null }).where("id", "=", rel.id).execute();
|
||||
mock.setLoggedIn(false);
|
||||
const d2 = await json(await h.req(`/sites/${siteId}/releases/${rel.id}?refresh=true`));
|
||||
expect(d2.status).toBe(200);
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
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 });
|
||||
});
|
||||
});
|
||||
@@ -1,48 +0,0 @@
|
||||
import type { SiteRelease } from "@umt/adapters";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { mergeDetailOverList } from "../../src/modules/sources-identity/service/snapshot.ts";
|
||||
|
||||
// HHClub 2026-09-29 实测:div 详情页没有做种 / 完成数 / 分类名,刷新详情后这些列被 null 冲掉。
|
||||
describe("详情刷新沿用列表字段", () => {
|
||||
const detail: SiteRelease = {
|
||||
siteId: "hhanclub",
|
||||
siteReleaseId: "91001",
|
||||
title: "示例短剧 1080p WEB-DL H265 AAC-HHWEB",
|
||||
category: { id: "412", name: null },
|
||||
sizeBytes: 194385019,
|
||||
seeders: null,
|
||||
leechers: null,
|
||||
snatched: null,
|
||||
promotion: { type: "none", downloadFactor: 1, uploadFactor: 1, expiresAt: null },
|
||||
hr: "unknown",
|
||||
uploadedAt: "2026-09-29T06:07:03.000Z",
|
||||
detailUrl: "https://hhanclub.net/details.php?id=91001",
|
||||
downloadUrl: "https://hhanclub.net/download.php?id=91001",
|
||||
tags: [],
|
||||
raw: {},
|
||||
observedAt: "2026-09-29T06:19:28.000Z",
|
||||
};
|
||||
const rel = {
|
||||
seeders: 1,
|
||||
leechers: 58,
|
||||
category_raw: "412",
|
||||
tags_raw: ["官方"],
|
||||
raw: { _site: { hr: false, snatched: 0, categoryName: "短剧" } },
|
||||
};
|
||||
|
||||
it("详情缺的做种 / 下载 / 完成 / 分类名 / H&R / 标签取列表快照值", () => {
|
||||
expect(mergeDetailOverList(detail, rel)).toMatchObject({
|
||||
seeders: 1,
|
||||
leechers: 58,
|
||||
snatched: 0,
|
||||
category: { id: "412", name: "短剧" },
|
||||
hr: false,
|
||||
tags: ["官方"],
|
||||
});
|
||||
});
|
||||
|
||||
it("详情有值时以详情为准", () => {
|
||||
const m = mergeDetailOverList({ ...detail, seeders: 9, hr: true, category: { id: "402", name: "电视剧" } }, rel);
|
||||
expect(m).toMatchObject({ seeders: 9, hr: true, category: { id: "402", name: "电视剧" } });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user