import { randomUUID } from "node:crypto"; import type { CapabilityRegistry, InvokeContext } from "@umt/agent"; import { CAPABILITY_IDS, capabilitySpec } from "@umt/contracts"; import { createMockNexusPhpSite, type MockNexusPhpSite } from "@umt/testkit"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { createCapabilityRegistry } from "../../src/modules/agent/capability-registry.ts"; import { registerSourcesCapabilities } from "../../src/modules/sources-identity/capabilities.ts"; import { createHarness, type Harness, pgAvailable } from "./harness.ts"; // W10-pre2 站点能力:site.list / site.health_check / site.refresh_login / site.user_data。 // 规格(类别 / 风险 / 重放)、execute 类要持续授权、输出不含凭证与 Cookie。 const up = await pgAvailable(); const TiB = 1024 ** 4; const SITE_IDS = ["site.list", "site.health_check", "site.refresh_login", "site.user_data"] as const; describe("站点能力规格", () => { it("进入能力目录(41 个已有能力 + 整理元数据补全);类别 / 风险 / 重放按约定", () => { expect(CAPABILITY_IDS).toHaveLength(42); expect(capabilitySpec("metadata.resolve_organize")).toMatchObject({ kind: "execute", replay: "never" }); for (const id of SITE_IDS) expect(CAPABILITY_IDS).toContain(id); expect(capabilitySpec("site.list")).toMatchObject({ kind: "read", riskLevel: "low", replay: "safe" }); expect(capabilitySpec("site.user_data")).toMatchObject({ kind: "read", riskLevel: "low", replay: "safe" }); expect(capabilitySpec("site.health_check")).toMatchObject({ kind: "execute", riskLevel: "low", replay: "safe", idempotent: true, }); expect(capabilitySpec("site.refresh_login")).toMatchObject({ kind: "execute", riskLevel: "medium", replay: "never", }); }); }); describe.skipIf(!up)("站点能力(mock NexusPHP)", () => { let h: Harness; let mock: MockNexusPhpSite; let registry: CapabilityRegistry; let siteId: string; const PASSWORD = "cap-s3cret-pw"; const agentCtx = (): InvokeContext => ({ actorId: "agent:site-test", taskId: randomUUID(), scopeIds: [], signal: new AbortController().signal, source: "agent", }); /** 输出里不许出现的东西:密码、会话 Cookie、secret 引用 */ const assertRedacted = async (out: unknown) => { const text = JSON.stringify(out); expect(text).not.toContain(PASSWORD); expect(text).not.toMatch(/secretRef|secret_ref|cookie|loginName|userAgent/i); for (const v of h.secrets.plain.values()) expect(text).not.toContain(v); for (const ref of h.secrets.plain.keys()) expect(text).not.toContain(ref); }; beforeAll(async () => { mock = await createMockNexusPhpSite({ username: "alice", password: PASSWORD }); h = await createHarness("sitecaps"); registry = createCapabilityRegistry({ logger: h.ctx.logger, db: h.db, settings: h.ctx.settings }); registerSourcesCapabilities(registry, () => h.rt); const r = await h.req("/sites", { body: { key: "audiences", name: "观众", adapter: "nexusphp", baseUrl: `${mock.baseUrl}/`, rateLimit: { minIntervalMs: 0, concurrency: 10 }, }, }); siteId = ((await r.json()) as { data: { id: string } }).data.id; const c = await h.req(`/sites/${siteId}/credentials`, { method: "PUT", body: { kind: "password", username: "alice", password: PASSWORD }, }); expect(c.status).toBe(200); }); afterAll(async () => { await h?.close(); await mock?.close(); }); it("四个站点能力都挂上处理器", () => { const ids = registry.list().map((c) => c.descriptor.id); for (const id of SITE_IDS) expect(ids).toContain(id); }); it("site.refresh_login:没有持续授权 → 拒绝,账号不动", async () => { await expect(registry.invoke("site.refresh_login", { siteId }, agentCtx())).rejects.toThrow(); const acc = await h.db.selectFrom("site_account").select(["status"]).where("site_id", "=", siteId).execute(); expect(acc.map((a) => a.status)).toEqual(["unknown"]); }); it("site.refresh_login:有持续授权 → 登录成功并拉用户数据;输出脱敏", async () => { await h.db.insertInto("automation_grant").values({ action: "site.refresh_login", granted_by: "test" }).execute(); const out = (await registry.invoke("site.refresh_login", { siteId }, agentCtx())) as { ok: boolean; status: string; userData: { upload: number } | null; }; expect(out).toMatchObject({ ok: true, status: "active", reason: null }); expect(out.userData?.upload).toBe(12.5 * TiB); await assertRedacted(out); const audits = await h.db .selectFrom("audit_event") .select(["action"]) .where("target_id", "=", "site.refresh_login") .execute(); expect(audits.map((a) => a.action)).toEqual(expect.arrayContaining(["capability.denied", "capability.invoke"])); }); it("site.list:健康、账号状态与凭证有无;不含凭证、Cookie、secret 引用", async () => { const out = (await registry.invoke("site.list", {}, agentCtx())) as { items: { id: string; accountStatus: string; accounts: { hasSession: boolean; status: string }[] }[]; }; const item = out.items.find((s) => s.id === siteId); expect(item).toMatchObject({ key: "audiences", enabled: true, accountStatus: "active" }); expect(item?.accounts[0]).toMatchObject({ status: "active", hasSession: true, authKind: "password" }); await assertRedacted(out); // 停用站点缺省不列 await h.db.updateTable("site").set({ enabled: false }).where("id", "=", siteId).execute(); const hidden = (await registry.invoke("site.list", {}, agentCtx())) as { items: unknown[] }; expect(hidden.items).toHaveLength(0); const all = (await registry.invoke("site.list", { includeDisabled: true }, agentCtx())) as { items: unknown[] }; expect(all.items).toHaveLength(1); await h.db.updateTable("site").set({ enabled: true }).where("id", "=", siteId).execute(); }); it("site.user_data:最新用户数据 + 按天历史", async () => { const out = (await registry.invoke("site.user_data", { siteId }, agentCtx())) as { accounts: { userData: { seeding: number } | null }[]; history: { upload: number }[]; }; expect(out.accounts).toHaveLength(1); expect(out.accounts[0]?.userData?.seeding).toBe(31); expect(out.history).toHaveLength(1); expect(out.history[0]?.upload).toBe(12.5 * TiB); await assertRedacted(out); }); it("site.health_check:execute 同样要授权;授权后写回健康列", async () => { await expect(registry.invoke("site.health_check", { siteId }, agentCtx())).rejects.toThrow(); await h.db.insertInto("automation_grant").values({ action: "site.health_check", granted_by: "test" }).execute(); const out = (await registry.invoke("site.health_check", { siteId }, agentCtx())) as { ok: boolean; healthStatus: string; }; expect(out).toMatchObject({ siteId, ok: true, loggedIn: true, healthStatus: "healthy" }); await assertRedacted(out); const s = await h.db .selectFrom("site") .select(["health_status", "last_health_at"]) .where("id", "=", siteId) .executeTakeFirstOrThrow(); expect(s.health_status).toBe("healthy"); expect(s.last_health_at).toBeTruthy(); }); it("不存在的站点:明确报错", async () => { await expect(registry.invoke("site.user_data", { siteId: randomUUID() }, agentCtx())).rejects.toThrow(/站点不存在/); }); });