- 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]>
283 lines
14 KiB
TypeScript
283 lines
14 KiB
TypeScript
import { createMockNexusPhpSite, type MockNexusPhpSite } from "@umt/testkit";
|
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
import { snapshotSite } from "../../src/modules/sources-identity/service/snapshot.ts";
|
|
import { liveSearch } from "../../src/modules/sources-identity/service/workbench.ts";
|
|
import { createHarness, EXEC_KEY, type Harness, json, pgAvailable, READ_KEY } from "./harness.ts";
|
|
|
|
const up = await pgAvailable();
|
|
|
|
describe.skipIf(!up)("来源管理与站点工作台 API", () => {
|
|
let h: Harness;
|
|
let mock: MockNexusPhpSite;
|
|
let siteId: string;
|
|
|
|
beforeAll(async () => {
|
|
mock = await createMockNexusPhpSite();
|
|
h = await createHarness("api");
|
|
});
|
|
afterAll(async () => {
|
|
await h?.close();
|
|
await mock?.close();
|
|
});
|
|
|
|
it("鉴权:无 key 401,只读 key 写站点 403,organize.execute 不能改站点配置", async () => {
|
|
expect((await h.app.request("/api/v1/sites")).status).toBe(401);
|
|
expect((await h.req("/sites", { key: READ_KEY })).status).toBe(200);
|
|
const body = { key: "audiences", name: "观众", adapter: "nexusphp", baseUrl: `${mock.baseUrl}/` };
|
|
expect((await h.req("/sites", { key: READ_KEY, body })).status).toBe(403);
|
|
expect((await h.req("/sites", { key: EXEC_KEY, body })).status).toBe(403);
|
|
});
|
|
|
|
it("站点注册表 + 登记站点;适配器类型与定义不符 400;重复 409;校验失败 422", async () => {
|
|
const defs = await json(await h.req("/site-definitions"));
|
|
expect(defs.status).toBe(200);
|
|
const aud = defs.body.data.items.find((d: { key: string }) => d.key === "audiences");
|
|
expect(aud).toMatchObject({ adapter: "nexusphp", siteId: null, shippedEnabled: true });
|
|
expect(defs.body.data.items.find((d: { key: string }) => d.key === "mteam")?.adapter).toBe("mteam_api");
|
|
|
|
const bad = await json(
|
|
await h.req("/sites", {
|
|
body: { key: "audiences", name: "观众", adapter: "mteam_api", baseUrl: `${mock.baseUrl}/` },
|
|
}),
|
|
);
|
|
expect(bad.status).toBe(400);
|
|
const unknown = await json(
|
|
await h.req("/sites", { body: { key: "nosuch", name: "x", adapter: "nexusphp", baseUrl: "https://x.example/" } }),
|
|
);
|
|
expect(unknown.status).toBe(400);
|
|
expect(
|
|
(await h.req("/sites", { body: { key: "BAD KEY", name: "x", adapter: "nexusphp", baseUrl: "nope" } })).status,
|
|
).toBe(422);
|
|
|
|
const ok = await json(
|
|
await h.req("/sites", {
|
|
body: {
|
|
key: "audiences",
|
|
name: "观众",
|
|
adapter: "nexusphp",
|
|
baseUrl: `${mock.baseUrl}/`,
|
|
rateLimit: { minIntervalMs: 0, concurrency: 4 },
|
|
snapshotEnabled: true,
|
|
snapshotIntervalSec: 3600,
|
|
},
|
|
}),
|
|
);
|
|
expect(ok.status).toBe(200);
|
|
siteId = ok.body.data.id;
|
|
expect(
|
|
(
|
|
await h.req("/sites", {
|
|
body: { key: "audiences", name: "观众", adapter: "nexusphp", baseUrl: `${mock.baseUrl}/` },
|
|
})
|
|
).status,
|
|
).toBe(409);
|
|
const defs2 = await json(await h.req("/site-definitions"));
|
|
expect(defs2.body.data.items.find((d: { key: string }) => d.key === "audiences").siteId).toBe(siteId);
|
|
});
|
|
|
|
it("站点账号:凭证只写(存 secret),响应里只有 secret_ref", async () => {
|
|
const r = await json(
|
|
await h.req("/site-accounts", { body: { siteId, label: "主号", authKind: "cookie", credential: mock.cookie } }),
|
|
);
|
|
expect(r.status).toBe(200);
|
|
expect(JSON.stringify(r.body)).not.toContain(mock.cookie);
|
|
expect(h.secrets.plain.get(r.body.data.secretRef)).toBe(mock.cookie);
|
|
expect(
|
|
(await h.req("/site-accounts", { body: { siteId, label: "主号", authKind: "cookie", credential: "x" } })).status,
|
|
).toBe(409);
|
|
});
|
|
|
|
it("Feed CRUD:URL 存 secret,对外只给脱敏 URL;启停;立即抓取入队", async () => {
|
|
const url = "https://audiences.example/torrentrss.php?rows=50&passkey=abcdef0123456789";
|
|
const c = await json(
|
|
await h.req("/feed-sources", { body: { siteId, name: "电影", purpose: "movie", url, pollIntervalSec: 600 } }),
|
|
);
|
|
expect(c.status).toBe(200);
|
|
expect(c.body.data.urlDisplay).toContain("passkey=%5BREDACTED%5D".slice(0, 8));
|
|
expect(JSON.stringify(c.body)).not.toContain("abcdef0123456789");
|
|
const id = c.body.data.id;
|
|
expect((await h.req("/feed-sources", { body: { siteId, name: "x", url: "not a url" } })).status).toBe(422);
|
|
const off = await json(await h.req(`/feed-sources/${id}/disable`, { body: {} }));
|
|
expect(off.body.data).toMatchObject({ enabled: false, healthStatus: "disabled" });
|
|
expect((await h.req(`/feed-sources/${id}/fetch`, { body: {} })).status).toBe(412);
|
|
await h.req(`/feed-sources/${id}/enable`, { body: {} });
|
|
const f = await json(await h.req(`/feed-sources/${id}/fetch`, { body: {} }));
|
|
expect(f.status).toBe(200);
|
|
expect(h.engine.enqueued.at(-1)).toMatchObject({ task: "ingestFeed", input: { feedSourceId: id, manual: true } });
|
|
const patched = await json(
|
|
await h.req(`/feed-sources/${id}`, {
|
|
method: "PATCH",
|
|
body: { url: "https://audiences.example/rss?passkey=NEWPASSKEY99" },
|
|
}),
|
|
);
|
|
expect(JSON.stringify(patched.body)).not.toContain("NEWPASSKEY99");
|
|
expect(h.secrets.plain.get(patched.body.data.urlSecretRef)).toContain("NEWPASSKEY99");
|
|
expect((await h.req("/feed-sources/00000000-0000-4000-8000-000000000000")).status).toBe(404);
|
|
});
|
|
|
|
it("站点内添加 Feed:只给地址即可,账号 / 解析器 / 名称按站点推断;域名不符只提示", async () => {
|
|
const acc = (await json(await h.req(`/site-accounts?siteId=${siteId}`))).body.data.items[0];
|
|
const same = await json(
|
|
await h.req("/feed-sources", { body: { siteId, url: "https://audiences.me/torrentrss.php?passkey=k1" } }),
|
|
);
|
|
expect(same.status).toBe(200);
|
|
expect(same.body.data).toMatchObject({
|
|
name: "观众 RSS",
|
|
siteAccountId: acc.id,
|
|
purpose: "generic",
|
|
pollIntervalSec: 1800,
|
|
});
|
|
expect(same.body.data.parserVersion).toMatch(/^rss@1;audiences@/);
|
|
expect(same.body.data.warnings).toBeUndefined();
|
|
const other = await json(
|
|
await h.req("/feed-sources", {
|
|
body: { siteId, purpose: "promotion", name: "促销", url: "https://elsewhere.example/rss?passkey=k2" },
|
|
}),
|
|
);
|
|
expect(other.status).toBe(200);
|
|
expect(other.body.data.name).toBe("促销");
|
|
expect(other.body.data.warnings?.[0]).toContain("elsewhere.example");
|
|
const bare = await json(await h.req("/feed-sources", { body: { url: "https://any.example/feed.xml" } }));
|
|
expect(bare.body.data).toMatchObject({
|
|
name: "any.example",
|
|
siteId: null,
|
|
siteAccountId: null,
|
|
parserVersion: "1",
|
|
});
|
|
});
|
|
|
|
it("快照:列表页 upsert 到 source_release,记录覆盖范围;站点级限流器每站一个实例", async () => {
|
|
const r = await snapshotSite(h.rt, { siteId, kind: "periodic" });
|
|
expect(r.status).toBe("ok");
|
|
expect(r.pages).toEqual([1]);
|
|
expect(r.newCount).toBe(r.itemCount);
|
|
const limiter = h.rt.limiterOf(siteId);
|
|
expect(limiter).not.toBeNull();
|
|
await snapshotSite(h.rt, { siteId, kind: "manual" });
|
|
expect(h.rt.limiterOf(siteId)).toBe(limiter);
|
|
expect(limiter!.stats.granted).toBeGreaterThanOrEqual(2);
|
|
const snaps = await json(await h.req(`/sites/${siteId}/snapshots`));
|
|
expect(snaps.body.data.items[0]).toMatchObject({ kind: "manual", status: "ok", pages: [1] });
|
|
const observed = await h.outbox("release.observed");
|
|
expect(observed.length).toBe(r.newCount);
|
|
const site = await json(await h.req(`/sites/${siteId}`));
|
|
expect(site.body.data.lastSnapshotAt).not.toBeNull();
|
|
// 限流间隔 > 0 时令牌在 PG 跨进程共享(rate_limit_bucket,key = site:<id>);0 = 不限流只走本地
|
|
await h.db
|
|
.updateTable("site")
|
|
.set({ rate_limit: JSON.stringify({ minIntervalMs: 5, concurrency: 5 }) })
|
|
.where("id", "=", siteId)
|
|
.execute();
|
|
expect((await snapshotSite(h.rt, { siteId, kind: "manual" })).status).toBe("ok");
|
|
const bucket = await h.db
|
|
.selectFrom("rate_limit_bucket")
|
|
.selectAll()
|
|
.where("key", "=", `site:${siteId}`)
|
|
.executeTakeFirst();
|
|
expect(bucket).toMatchObject({ capacity: 5, refill_per_sec: 200 });
|
|
});
|
|
|
|
it("工作台列表:快照优先,标注来源与更新时间;筛选与分页", async () => {
|
|
const l = await json(await h.req(`/sites/${siteId}/releases?limit=2`));
|
|
expect(l.status).toBe(200);
|
|
expect(l.body.data.items).toHaveLength(2);
|
|
expect(l.body.data.nextCursor).not.toBeNull();
|
|
expect(l.body.data.coverage).toMatchObject({ siteEnabled: true, stale: false, live: false });
|
|
expect(l.body.data.items[0]).toMatchObject({ origin: "snapshot", entity: null });
|
|
expect(l.body.data.items[0].dataUpdatedAt).toBeTruthy();
|
|
const p2 = await json(await h.req(`/sites/${siteId}/releases?limit=2&cursor=${l.body.data.nextCursor}`));
|
|
expect(p2.body.data.items[0].id).not.toBe(l.body.data.items[0].id);
|
|
const free = await json(await h.req(`/sites/${siteId}/releases?promotion=free`));
|
|
expect(free.body.data.items.every((i: { promotion: { kind: string } }) => i.promotion.kind === "free")).toBe(true);
|
|
const q = await json(await h.req(`/sites/${siteId}/releases?q=Dune&live=false`));
|
|
expect(q.body.data.items.map((i: { siteReleaseId: string }) => i.siteReleaseId)).toContain("812345");
|
|
});
|
|
|
|
it("实时补查:并发相同搜索合并为一次站点请求;API 标注实时来源", async () => {
|
|
const site = await h.db.selectFrom("site").selectAll().where("id", "=", siteId).executeTakeFirstOrThrow();
|
|
const searches = () => mock.requests.filter((r) => r.path.includes("search=")).length;
|
|
const before = searches();
|
|
const [a, b] = await Promise.all([liveSearch(h.rt, site, "Bear"), liveSearch(h.rt, site, "bear")]);
|
|
expect(searches() - before).toBe(1);
|
|
expect([a.coalesced, b.coalesced].sort()).toEqual([false, true]);
|
|
expect(a.error).toBeNull();
|
|
const r = await json(await h.req(`/sites/${siteId}/releases?q=Last&live=true`));
|
|
expect(r.body.data.coverage).toMatchObject({ live: true, liveError: null });
|
|
expect(r.body.data.items.map((i: { siteReleaseId: string }) => i.siteReleaseId)).toContain("812348");
|
|
const snaps = await h.db.selectFrom("site_snapshot").selectAll().where("kind", "=", "live_search").execute();
|
|
expect(snaps).toHaveLength(2);
|
|
});
|
|
|
|
it("详情:易变字段复核时间;refresh 失败保留既有数据", async () => {
|
|
const rel = await h.db
|
|
.selectFrom("source_release")
|
|
.selectAll()
|
|
.where("site_release_id", "=", "812345")
|
|
.executeTakeFirstOrThrow();
|
|
const d = await json(await h.req(`/sites/${siteId}/releases/${rel.id}?refresh=true`));
|
|
expect(d.status).toBe(200);
|
|
expect(d.body.data.refreshError).toBeNull();
|
|
expect(d.body.data.volatile.recheckBeforeDownload).toBe(true);
|
|
expect(d.body.data.volatile.detailFetchedAt).not.toBeNull();
|
|
mock.setLoggedIn(false);
|
|
const d2 = await json(await h.req(`/sites/${siteId}/releases/${rel.id}?refresh=true`));
|
|
expect(d2.status).toBe(200);
|
|
expect(d2.body.data.refreshError).toMatch(/auth/);
|
|
expect(d2.body.data.release.title).toBe(rel.title);
|
|
// 登录失效 → 账号标失效 + 待处理,周期快照不再访问
|
|
const acc = await h.db
|
|
.selectFrom("site_account")
|
|
.selectAll()
|
|
.where("site_id", "=", siteId)
|
|
.executeTakeFirstOrThrow();
|
|
expect(["invalid", "expired"]).toContain(acc.status);
|
|
expect(
|
|
(await h.db.selectFrom("decision").selectAll().where("dedupe_key", "=", `site-auth:${siteId}`).execute()).length,
|
|
).toBe(1);
|
|
const n = mock.requests.length;
|
|
expect((await snapshotSite(h.rt, { siteId, kind: "periodic" })).reason).toBe("auth_invalid");
|
|
expect(mock.requests.length).toBe(n);
|
|
// 换凭证:账号复位、待处理关闭
|
|
mock.setLoggedIn(true);
|
|
await h.req(`/site-accounts/${acc.id}`, { method: "PATCH", body: { credential: mock.cookie } });
|
|
const d3 = await h.db
|
|
.selectFrom("decision")
|
|
.select(["status"])
|
|
.where("dedupe_key", "=", `site-auth:${siteId}`)
|
|
.executeTakeFirstOrThrow();
|
|
expect(d3.status).toBe("resolved");
|
|
expect((await snapshotSite(h.rt, { siteId, kind: "periodic" })).status).toBe("ok");
|
|
});
|
|
|
|
it("关闭站点:停止网络访问,历史资源仍可查并标注 site_disabled", async () => {
|
|
const off = await json(await h.req(`/sites/${siteId}/disable`, { body: {} }));
|
|
expect(off.body.data).toMatchObject({ enabled: false, healthStatus: "disabled" });
|
|
const n = mock.requests.length;
|
|
const l = await json(await h.req(`/sites/${siteId}/releases?q=Nothing.Here&live=true`));
|
|
expect(l.body.data.coverage).toMatchObject({ siteEnabled: false, live: false });
|
|
expect(l.body.data.coverage.liveError).toMatch(/关闭/);
|
|
expect(mock.requests.length).toBe(n);
|
|
const all = await json(await h.req(`/sites/${siteId}/releases`));
|
|
expect(all.body.data.items.length).toBeGreaterThan(0);
|
|
expect(all.body.data.items.every((i: { availability: string }) => i.availability === "site_disabled")).toBe(true);
|
|
expect((await h.req(`/sites/${siteId}/snapshot`, { body: {} })).status).toBe(412);
|
|
await h.req(`/sites/${siteId}/enable`, { body: {} });
|
|
const back = await json(await h.req(`/sites/${siteId}/releases?limit=1`));
|
|
expect(back.body.data.items[0].availability).toBe("active");
|
|
});
|
|
|
|
it("来源资源 / 观察 / 作品列表与 OpenAPI 文档", async () => {
|
|
const l = await json(await h.req(`/source-releases?siteId=${siteId}&q=Dune`));
|
|
expect(l.body.data.items.length).toBeGreaterThan(0);
|
|
expect((await h.req("/source-releases/not-a-uuid")).status).toBe(422);
|
|
expect((await json(await h.req("/observations"))).status).toBe(200);
|
|
expect((await json(await h.req("/media-entities"))).status).toBe(200);
|
|
expect((await json(await h.req("/identity-pool"))).body.data.items).toEqual([]);
|
|
const doc = await json(await h.app.request("/api/openapi.json"));
|
|
const paths = Object.keys((doc.body as unknown as { paths: Record<string, unknown> }).paths);
|
|
expect(paths).toEqual(
|
|
expect.arrayContaining(["/api/v1/sites/{id}/releases", "/api/v1/identity-pool", "/api/v1/decisions/summary"]),
|
|
);
|
|
});
|
|
});
|