feat(sources,organize): site agent capabilities and organize.minSizeBytes setting (W10-pre2)
- site.list / site.health_check / site.refresh_login / site.user_data capabilities over the W9-A site center services; outputs whitelisted (no secret refs, cookies, passwords, login names); execute kinds need automation grant. IDs use snake_case (CapabilityId regex); registry count 17 -> 21. - settings.organize.minSizeBytes (default 50 MiB); file-manager identify / organize and planImport take request > setting > port default. - tests and runbook notes (sites, files). Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
c08d8ce62a
commit
81bf010e3e
18 files changed
+563
-15
No files matched your search
@@ -172,7 +172,12 @@ export async function createHarness(name: string, overrides: AoOverrides = {}):
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const k of Object.keys(DEFAULT_SETTINGS) as SettingsKey[]) {
|
||||
const v = stored.get(k);
|
||||
out[k] = GlobalSettings.shape[k].parse({ ...DEFAULT_SETTINGS[k], ...((v as object) ?? {}) });
|
||||
// 整理最小体积缺省跟随端口注入的 minVideoBytes(测试用小文件)
|
||||
const base =
|
||||
k === "organize"
|
||||
? { ...DEFAULT_SETTINGS.organize, minSizeBytes: overrides.options?.minVideoBytes ?? 100_000 }
|
||||
: DEFAULT_SETTINGS[k];
|
||||
out[k] = GlobalSettings.shape[k].parse({ ...base, ...((v as object) ?? {}) });
|
||||
}
|
||||
return out as GlobalSettings;
|
||||
},
|
||||
|
||||
@@ -524,7 +524,7 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise<PlanRes
|
||||
templates: fileTemplates,
|
||||
target: { ...target, ...(health.kind === "online" ? { device: health.dev } : {}) },
|
||||
retention: { hnrSatisfied: hnr },
|
||||
minVideoBytes: req.minSizeBytes ?? d.ports.options.minVideoBytes,
|
||||
minVideoBytes: req.minSizeBytes ?? orgSettings.minSizeBytes ?? d.ports.options.minVideoBytes,
|
||||
...(req.fileOverrides ? { overrides: req.fileOverrides } : {}),
|
||||
...(d.ctx.config?.ORGANIZE_DEFAULT_SUBTITLE_LANG
|
||||
? { defaultSubtitleLang: d.ctx.config.ORGANIZE_DEFAULT_SUBTITLE_LANG }
|
||||
|
||||
@@ -86,7 +86,7 @@ export async function identify(
|
||||
): Promise<FilesIdentifyResult> {
|
||||
const db = d.ctx.db;
|
||||
const { videos, truncated } = await collectVideos(d, body);
|
||||
// G2:最小体积(请求 > settings.organize.minSizeBytes > 不过滤)
|
||||
// G2:最小体积(请求/向导 > settings.organize.minSizeBytes,缺省 50 MiB;设置读不到时不过滤)
|
||||
const orgSettings = (await d.ctx.settings.get("organize").catch(() => null)) as { minSizeBytes?: unknown } | null;
|
||||
const minSize = body.minSizeBytes ?? (typeof orgSettings?.minSizeBytes === "number" ? orgSettings.minSizeBytes : 0);
|
||||
let excludedBySize = 0;
|
||||
|
||||
@@ -118,7 +118,7 @@ function segmentsOf(plan: FrozenPlan | undefined, overrideDir: boolean): FilesOr
|
||||
);
|
||||
}
|
||||
|
||||
/** 设置里的最小体积(settings.organize.minSizeBytes;契约未加该字段前恒为 undefined → planImport 用端口默认) */
|
||||
/** 设置里的最小体积(settings.organize.minSizeBytes,缺省 50 MiB);读不到时 undefined → planImport 再读设置 / 端口默认 */
|
||||
async function settingsMinSize(d: FmDeps): Promise<number | undefined> {
|
||||
const org = (await d.ctx.settings.get("organize").catch(() => null)) as { minSizeBytes?: unknown } | null;
|
||||
return typeof org?.minSizeBytes === "number" && org.minSizeBytes >= 0 ? org.minSizeBytes : undefined;
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import type { CapabilityRegistry } from "@umt/agent";
|
||||
import { capabilitySpec, type SiteAccountUserData, type SiteLastHealth } from "@umt/contracts";
|
||||
import type { SiteAccountRow, SiteRow } from "@umt/db";
|
||||
import type { SourcesRuntime } from "./runtime.ts";
|
||||
import { checkSiteHealth, overallAccountStatus, refreshSiteAccount, userDataHistory } from "./service/account.ts";
|
||||
|
||||
// W10-pre2 站点能力:site.list / site.health_check / site.refresh_login / site.user_data,处理器调 W9-A 站点中心的 service。
|
||||
// 脱敏:输出按白名单逐字段拼装——不含 secret 引用、Cookie、密码、登录名、UA 与站点 config。
|
||||
|
||||
const iso = (d: Date | null) => (d ? d.toISOString() : null);
|
||||
|
||||
function accountBrief(a: SiteAccountRow) {
|
||||
return {
|
||||
id: a.id,
|
||||
label: a.label,
|
||||
authKind: a.auth_kind,
|
||||
enabled: a.enabled,
|
||||
status: a.status,
|
||||
lastLoginAt: iso(a.last_login_at),
|
||||
lastVerifiedAt: iso(a.last_verified_at),
|
||||
lastError: (a.last_error ?? null) as never,
|
||||
userDataAt: iso(a.user_data_at),
|
||||
hasSession: !!a.session_secret_ref,
|
||||
hasPasskey: !!a.passkey_secret_ref || (a.auth_kind === "passkey" && !!a.secret_ref),
|
||||
};
|
||||
}
|
||||
|
||||
async function liveSite(r: SourcesRuntime, siteId: string): Promise<SiteRow> {
|
||||
const s = await r.ctx.db
|
||||
.selectFrom("site")
|
||||
.selectAll()
|
||||
.where("id", "=", siteId)
|
||||
.where("deleted_at", "is", null)
|
||||
.executeTakeFirst();
|
||||
if (!s) throw new Error("站点不存在");
|
||||
return s;
|
||||
}
|
||||
|
||||
export function registerSiteCapabilities(registry: CapabilityRegistry, rt: () => SourcesRuntime): void {
|
||||
registry.register(capabilitySpec("site.list"), async (input) => {
|
||||
const { db } = rt().ctx;
|
||||
let q = db.selectFrom("site").selectAll().where("deleted_at", "is", null);
|
||||
if (!input.includeDisabled) q = q.where("enabled", "=", true);
|
||||
if (input.keys?.length) q = q.where("key", "in", input.keys);
|
||||
const sites = await q.orderBy("key").execute();
|
||||
const accounts = sites.length
|
||||
? await db
|
||||
.selectFrom("site_account")
|
||||
.selectAll()
|
||||
.where(
|
||||
"site_id",
|
||||
"in",
|
||||
sites.map((s) => s.id),
|
||||
)
|
||||
.orderBy("created_at")
|
||||
.execute()
|
||||
: [];
|
||||
return {
|
||||
items: sites.map((s) => {
|
||||
const mine = accounts.filter((a) => a.site_id === s.id);
|
||||
return {
|
||||
id: s.id,
|
||||
key: s.key,
|
||||
name: s.name,
|
||||
adapter: s.adapter,
|
||||
baseUrl: s.base_url,
|
||||
enabled: s.enabled,
|
||||
healthStatus: s.health_status,
|
||||
lastHealth: (s.last_health ?? null) as SiteLastHealth | null,
|
||||
lastHealthAt: iso(s.last_health_at),
|
||||
accountStatus: overallAccountStatus(mine),
|
||||
accounts: mine.map(accountBrief),
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
registry.register(capabilitySpec("site.health_check"), async (input, ctx) => {
|
||||
const r = rt();
|
||||
return checkSiteHealth(r, await liveSite(r, input.siteId), { type: "agent", id: ctx.actorId });
|
||||
});
|
||||
|
||||
registry.register(capabilitySpec("site.refresh_login"), async (input, ctx) => {
|
||||
const r = rt();
|
||||
const s = await liveSite(r, input.siteId);
|
||||
let q = r.ctx.db.selectFrom("site_account").select(["id"]).where("site_id", "=", s.id);
|
||||
q = input.accountId
|
||||
? q.where("id", "=", input.accountId)
|
||||
: q.where("enabled", "=", true).where("auth_kind", "!=", "passkey");
|
||||
const acc = await q.orderBy("created_at").executeTakeFirst();
|
||||
if (!acc) throw new Error(input.accountId ? "站点账号不存在" : "站点还没有凭证");
|
||||
return refreshSiteAccount(r, acc.id, {
|
||||
mode: "login",
|
||||
actor: { type: "agent", id: ctx.actorId },
|
||||
signal: AbortSignal.any([ctx.signal, AbortSignal.timeout(30_000)]),
|
||||
});
|
||||
});
|
||||
|
||||
registry.register(capabilitySpec("site.user_data"), async (input) => {
|
||||
const r = rt();
|
||||
const s = await liveSite(r, input.siteId);
|
||||
let q = r.ctx.db
|
||||
.selectFrom("site_account")
|
||||
.select(["id", "label", "status", "user_data", "user_data_at"])
|
||||
.where("site_id", "=", s.id);
|
||||
if (input.accountId) q = q.where("id", "=", input.accountId);
|
||||
const accounts = await q.orderBy("created_at").execute();
|
||||
return {
|
||||
siteId: s.id,
|
||||
accounts: accounts.map((a) => ({
|
||||
accountId: a.id,
|
||||
label: a.label,
|
||||
status: a.status,
|
||||
userData: (a.user_data ?? null) as SiteAccountUserData | null,
|
||||
userDataAt: iso(a.user_data_at),
|
||||
})),
|
||||
history: await userDataHistory(r, s.id, {
|
||||
...(input.accountId ? { accountId: input.accountId } : {}),
|
||||
days: input.days,
|
||||
}),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import type { CapabilityRegistry } from "@umt/agent";
|
||||
import { capabilitySpec, type ExternalEntityType, type SourceRelease } from "@umt/contracts";
|
||||
import { sql } from "@umt/db";
|
||||
import { normalizeTitle } from "@umt/domain";
|
||||
import { registerSiteCapabilities } from "./capabilities-sites.ts";
|
||||
import { toApi, toApiList } from "./rows.ts";
|
||||
import type { SourcesRuntime } from "./runtime.ts";
|
||||
import { assignReleaseIdentity } from "./service/identity-admin.ts";
|
||||
@@ -9,10 +10,12 @@ import { resolveRelease } from "./service/resolve.ts";
|
||||
import { refreshReleaseDetail } from "./service/snapshot.ts";
|
||||
import { liveSearch } from "./service/workbench.ts";
|
||||
|
||||
// 本模块领域的能力处理器(规格一律取 contracts):catalog.lookup / release.search / release.inspect / identity.resolve。
|
||||
// 本模块领域的能力处理器(规格一律取 contracts):catalog.lookup / release.search / release.inspect / identity.resolve;
|
||||
// 站点能力 site.* 见 capabilities-sites.ts。
|
||||
// identity.resolve 属 propose 类,授权回调由 W2-D 接入前会被注册中心拒绝(安全默认)。
|
||||
|
||||
export function registerSourcesCapabilities(registry: CapabilityRegistry, rt: () => SourcesRuntime): void {
|
||||
registerSiteCapabilities(registry, rt);
|
||||
registry.register(capabilitySpec("catalog.lookup"), async (input) => {
|
||||
const { db } = rt().ctx;
|
||||
const local: { entity: unknown; score: number; matchedAlias: string | null }[] = [];
|
||||
|
||||
@@ -42,8 +42,8 @@ describe.skipIf(!hasPg)("E2 Agent 委托闭环(fake 模型 + 真实能力/授权
|
||||
return r.body.data.id as string;
|
||||
};
|
||||
|
||||
it("启动断言:17 个能力全部注册,并在能力目录里可见", async () => {
|
||||
expect(e.booted.ctx.capabilities.list()).toHaveLength(17);
|
||||
it("启动断言:21 个能力全部注册,并在能力目录里可见", async () => {
|
||||
expect(e.booted.ctx.capabilities.list()).toHaveLength(21);
|
||||
const r = await e.api("GET", "/capabilities");
|
||||
expect(r.body.data.items.map((c: { id: string }) => c.id).sort()).toEqual(
|
||||
e.booted.ctx.capabilities
|
||||
|
||||
@@ -235,6 +235,8 @@ export async function createE2E(name: string, opts: E2EOptions = {}): Promise<E2
|
||||
await e.settings({
|
||||
metadata: { tmdbApiKey: TMDB_KEY, tmdbBaseUrl: `${tmdb.url}/3` },
|
||||
retention: { seedRetentionDays: 0 },
|
||||
// 整理最小体积与模块端口的 minVideoBytes 一致(测试文件 ≥ 100 KB)
|
||||
organize: { minSizeBytes: 100_000 },
|
||||
});
|
||||
if (opts.worker !== false) await e.startWorker();
|
||||
return e;
|
||||
|
||||
@@ -144,7 +144,12 @@ export async function createFmHarness(
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const k of Object.keys(DEFAULT_SETTINGS) as SettingsKey[]) {
|
||||
const v = stored.get(k);
|
||||
out[k] = GlobalSettings.shape[k].parse({ ...DEFAULT_SETTINGS[k], ...((v as object) ?? {}) });
|
||||
// 整理最小体积缺省跟随端口注入的 minVideoBytes(测试用小文件)
|
||||
const base =
|
||||
k === "organize"
|
||||
? { ...DEFAULT_SETTINGS.organize, minSizeBytes: overrides.ao?.options?.minVideoBytes ?? 10 }
|
||||
: DEFAULT_SETTINGS[k];
|
||||
out[k] = GlobalSettings.shape[k].parse({ ...base, ...((v as object) ?? {}) });
|
||||
}
|
||||
return out as GlobalSettings;
|
||||
},
|
||||
|
||||
@@ -53,7 +53,7 @@ describe.skipIf(skip)("files 识别 / 整理 / 探测 / 刮削", () => {
|
||||
const r = await h.api(
|
||||
"POST",
|
||||
"/files/identify",
|
||||
{ items: [{ root: h.dlRoot, path: "Some.Show.S01.1080p.WEB-DL" }] },
|
||||
{ items: [{ root: h.dlRoot, path: "Some.Show.S01.1080p.WEB-DL" }], minSizeBytes: 0 },
|
||||
READ_KEY,
|
||||
);
|
||||
expect(r.status).toBe(200);
|
||||
@@ -72,6 +72,7 @@ describe.skipIf(skip)("files 识别 / 整理 / 探测 / 刮削", () => {
|
||||
it("identify:远端候选(catalog.lookup)", async () => {
|
||||
const r = await h.api("POST", "/files/identify", {
|
||||
items: [{ root: h.dlRoot, path: "Remote.Film.2019.1080p.mkv" }],
|
||||
minSizeBytes: 0,
|
||||
});
|
||||
expect(r.status).toBe(200);
|
||||
const c = r.body.data.items[0].candidates;
|
||||
@@ -82,7 +83,12 @@ describe.skipIf(skip)("files 识别 / 整理 / 探测 / 刮削", () => {
|
||||
await mkdir(join(h.dl, "many"), { recursive: true });
|
||||
const names = Array.from({ length: 23 }, (_, i) => `Distinct Title ${String.fromCharCode(65 + i)}.2019.1080p.mkv`);
|
||||
for (const n of names) await writeFile(join(h.dl, "many", n), "x");
|
||||
const r = await h.api("POST", "/files/identify", { items: [{ root: h.dlRoot, path: "many" }] }, READ_KEY);
|
||||
const r = await h.api(
|
||||
"POST",
|
||||
"/files/identify",
|
||||
{ items: [{ root: h.dlRoot, path: "many" }], minSizeBytes: 0 },
|
||||
READ_KEY,
|
||||
);
|
||||
expect(r.status).toBe(200);
|
||||
expect(r.body.data.items).toHaveLength(23);
|
||||
const capped = r.body.data.items.filter((i: { warnings: string[] }) => i.warnings.some((w) => w.includes("上限")));
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { basename, join } from "node:path";
|
||||
import { DEFAULT_SETTINGS, GlobalSettings } from "@umt/contracts";
|
||||
import { sql } from "@umt/db";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { createFmHarness, EXEC_KEY, type FmHarness, key, pgAvailable, READ_KEY, seedEntity } from "./harness.ts";
|
||||
|
||||
// W10-pre2:settings.organize.minSizeBytes(缺省 50 MiB)是识别与整理(planImport)的默认最小体积;请求 / 向导给的值优先。
|
||||
|
||||
const skip = !(await pgAvailable());
|
||||
|
||||
describe("settings.organize.minSizeBytes 契约", () => {
|
||||
it("缺省 50 MiB;非负整数;旧的 organize 设置补缺省后仍合法", () => {
|
||||
expect(DEFAULT_SETTINGS.organize.minSizeBytes).toBe(50 * 1024 * 1024);
|
||||
expect(GlobalSettings.shape.organize.safeParse({ ...DEFAULT_SETTINGS.organize, minSizeBytes: -1 }).success).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
GlobalSettings.shape.organize.parse({ ...DEFAULT_SETTINGS.organize, defaultMode: "move" }).minSizeBytes,
|
||||
).toBe(50 * 1024 * 1024);
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(skip)("最小体积读设置(W10-pre2)", () => {
|
||||
let h: FmHarness;
|
||||
let film: string;
|
||||
const setMin = (n: number) =>
|
||||
sql`insert into settings (key, value) values ('organize', ${JSON.stringify({ minSizeBytes: n })}::jsonb)
|
||||
on conflict (key) do update set value = excluded.value`.execute(h.db);
|
||||
|
||||
beforeAll(async () => {
|
||||
h = await createFmHarness("minsize");
|
||||
film = await seedEntity(h.db, { kind: "movie", title: "Size Film", year: 2022 });
|
||||
await writeFile(join(h.dl, "Size.Film.2022.2160p.BluRay.x265-GRP.mkv"), "x".repeat(64));
|
||||
await writeFile(join(h.dl, "Size.Film.2022.1080p.small.mkv"), "0123456789ab");
|
||||
});
|
||||
afterAll(async () => {
|
||||
await h?.close();
|
||||
});
|
||||
|
||||
const items = () => [
|
||||
{ root: h.dlRoot, path: "Size.Film.2022.2160p.BluRay.x265-GRP.mkv", mediaEntityId: film },
|
||||
{ root: h.dlRoot, path: "Size.Film.2022.1080p.small.mkv", mediaEntityId: film },
|
||||
];
|
||||
const organize = (body: Record<string, unknown>) =>
|
||||
h.api("POST", "/files/organize", { idempotencyKey: key(), action: "copy", dryRun: true, ...body }, EXEC_KEY);
|
||||
|
||||
it("整理:不给 minSizeBytes 时用设置值;请求给的值优先", async () => {
|
||||
await setMin(32);
|
||||
const r = await organize({ items: items() });
|
||||
expect(r.status).toBe(200);
|
||||
const g = r.body.data.groups[0];
|
||||
expect(g.minSizeBytes).toBe(32);
|
||||
expect(g.files.map((f: { source: string }) => basename(f.source))).toEqual([
|
||||
"Size.Film.2022.2160p.BluRay.x265-GRP.mkv",
|
||||
]);
|
||||
expect(g.excluded).toContainEqual(expect.objectContaining({ size: 12, reason: "below_min_size" }));
|
||||
|
||||
const over = await organize({ items: items(), minSizeBytes: 0 });
|
||||
expect(over.body.data.groups[0].excluded).toEqual([]);
|
||||
expect(over.body.data.groups[0].files).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("识别:不给 minSizeBytes 时按设置跳过小文件;请求 0 不过滤", async () => {
|
||||
await setMin(32);
|
||||
const body = { items: items().map(({ root, path }) => ({ root, path })) };
|
||||
const r = await h.api("POST", "/files/identify", body, READ_KEY);
|
||||
expect(r.status).toBe(200);
|
||||
expect(r.body.data.excludedBySize).toBe(1);
|
||||
const r0 = await h.api("POST", "/files/identify", { ...body, minSizeBytes: 0 }, READ_KEY);
|
||||
expect(r0.body.data.excludedBySize).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
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("进入首批能力目录(17 + 4 = 21);类别 / 风险 / 重放按约定", () => {
|
||||
expect(CAPABILITY_IDS).toHaveLength(21);
|
||||
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(/站点不存在/);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user