- v2 foundation: ActionResult/DevError/ActorContext/ResourceRef/Execution types - ActionRegistry + ResourceRegistry (namespaced types, FAIL FAST on duplicates) - ActionRuntime pipeline: validate -> authorize -> middleware -> handler -> output validation -> audit -> event -> execution persisted (devos_action_executions) - Legacy bridge: v1 services registered as devops.*/core.* resources & actions (47 actions, 9 resources) - Unified Action API: POST /api/v2/actions/:actionId, GET discovery (actions/resources) - 10 v2 unit tests (registry fail-fast, validation, authz, failure audit, events)
186 lines
7.1 KiB
TypeScript
186 lines
7.1 KiB
TypeScript
/**
|
||
* # DevOS v2 Action Runtime 单元测试(docs 03 §98)
|
||
* 覆盖:注册 FAIL FAST / 输入验证 / 授权 / 执行成功 / 失败审计 / Discovery。
|
||
*/
|
||
import { describe, test, expect, beforeEach } from "bun:test";
|
||
import { z } from "zod";
|
||
import { ActionRegistry, defineAction, type ActionDefinition } from "../apps/api/src/v2/registry/action-registry";
|
||
import { ResourceRegistry } from "../apps/api/src/v2/registry/resource-registry";
|
||
import { ActionRuntime, type ActionRuntimePorts } from "../apps/api/src/v2/runtime/action-runtime";
|
||
import { actionRegistry as globalRegistry } from "../apps/api/src/v2/registry/action-registry";
|
||
import type { ActorContext, ActionExecutionRecord } from "../apps/api/src/v2/foundation/types";
|
||
|
||
/** # 测试用 runtime 工厂(采集端口调用) **/
|
||
function makeRuntime(opts: { allowed?: boolean } = {}) {
|
||
globalRegistry.clear();
|
||
const registry = globalRegistry;
|
||
const executed: string[] = [];
|
||
const audits: Record<string, unknown>[] = [];
|
||
const events: { eventType: string; payload: Record<string, unknown> }[] = [];
|
||
const executions: ActionExecutionRecord[] = [];
|
||
|
||
const ports: ActionRuntimePorts = {
|
||
checkPermission: async () => opts.allowed ?? true,
|
||
audit: async (r) => {
|
||
audits.push(r as unknown as Record<string, unknown>);
|
||
},
|
||
publishEvent: async (e) => {
|
||
events.push({ eventType: e.eventType, payload: e.payload });
|
||
},
|
||
saveExecution: async (r) => {
|
||
executions.push(r);
|
||
},
|
||
};
|
||
const runtime = new ActionRuntime(ports);
|
||
const actor: ActorContext = { type: "user", id: "user_1", organizationId: "ws_1" };
|
||
return { registry, runtime, actor, executed, audits, events, executions, ports };
|
||
}
|
||
|
||
/** # 示例动作:devops.website.scan(对齐 docs 03 §76) **/
|
||
function scanAction(impl?: ActionDefinition<never, never>["execute"]) {
|
||
return defineAction({
|
||
id: "devops.website.scan",
|
||
resource: "devops.website",
|
||
name: "Scan website",
|
||
description: "Scan a website for issues",
|
||
mode: "async",
|
||
permission: "devops.website.scan",
|
||
inputSchema: z.object({ websiteId: z.string(), depth: z.number().int().min(1).max(10).default(3) }),
|
||
outputSchema: z.object({ executionId: z.string(), pagesScanned: z.number() }),
|
||
execute: impl ?? (async () => ({ executionId: "exec_x", pagesScanned: 42 })),
|
||
}) as unknown as ActionDefinition<never, never>;
|
||
}
|
||
|
||
const actor: ActorContext = { type: "user", id: "user_1", organizationId: "ws_1" };
|
||
|
||
describe("v2 ActionRegistry", () => {
|
||
test("重复注册 FAIL FAST", () => {
|
||
const registry = new ActionRegistry();
|
||
const action = scanAction();
|
||
registry.register(action, "test");
|
||
expect(() => registry.register(action, "test")).toThrow(/duplicate action id/);
|
||
});
|
||
|
||
test("非命名空间 id 拒绝注册", () => {
|
||
const registry = new ActionRegistry();
|
||
const bad = scanAction();
|
||
(bad as { id: string }).id = "scan";
|
||
expect(() => registry.register(bad, "test")).toThrow(/namespaced/);
|
||
});
|
||
|
||
test("list 返回已注册动作", () => {
|
||
const registry = new ActionRegistry();
|
||
registry.register(scanAction(), "test");
|
||
expect(registry.list().map((a) => a.id)).toContain("devops.website.scan");
|
||
});
|
||
});
|
||
|
||
describe("v2 ResourceRegistry", () => {
|
||
test("注册 + 发现", () => {
|
||
const registry = new ResourceRegistry();
|
||
registry.register({
|
||
type: "devops.website",
|
||
label: "Website",
|
||
actionIds: ["devops.website.scan"],
|
||
ownership: { organization: true },
|
||
});
|
||
expect(registry.has("devops.website")).toBe(true);
|
||
expect(registry.get("devops.website")?.actionIds).toContain("devops.website.scan");
|
||
});
|
||
|
||
test("重复类型 FAIL FAST", () => {
|
||
const registry = new ResourceRegistry();
|
||
const def = { type: "devops.website", label: "W", actionIds: [] };
|
||
registry.register(def as never);
|
||
expect(() => registry.register(def as never)).toThrow(/duplicate resource type/);
|
||
});
|
||
});
|
||
|
||
describe("v2 ActionRuntime", () => {
|
||
let env: ReturnType<typeof makeRuntime>;
|
||
|
||
beforeEach(() => {
|
||
env = makeRuntime({ allowed: true });
|
||
env.registry.register(scanAction(), "test");
|
||
});
|
||
|
||
test("执行成功:验证 → 授权 → handler → execution 落库 → 审计 → 事件", async () => {
|
||
const result = await env.runtime.execute(
|
||
"devops.website.scan",
|
||
{ websiteId: "website_123" },
|
||
actor,
|
||
{ source: "user", organizationId: "ws_1" },
|
||
);
|
||
expect(result.success).toBe(true);
|
||
expect(result.data).toEqual({ executionId: "exec_x", pagesScanned: 42 });
|
||
expect(result.executionId.startsWith("exec_")).toBe(true);
|
||
// depth 默认值 3 由 zod schema 应用
|
||
expect(env.executions).toHaveLength(1);
|
||
expect(env.executions[0].status).toBe("completed");
|
||
expect(env.executions[0].resourceType).toBe("devops.website");
|
||
// 审计
|
||
expect(env.audits).toHaveLength(1);
|
||
expect(env.audits[0].action).toBe("devops.website.scan");
|
||
// 事件 started + completed
|
||
expect(env.events.map((e) => e.eventType)).toEqual(["action.started", "action.completed"]);
|
||
});
|
||
|
||
test("输入验证失败:ACTION 不执行、execution failed、不抛异常", async () => {
|
||
const result = await env.runtime.execute(
|
||
"devops.website.scan",
|
||
{ websiteId: "website_123", depth: 99 },
|
||
actor,
|
||
{ organizationId: "ws_1" },
|
||
);
|
||
expect(result.success).toBe(false);
|
||
expect(result.error?.code).toBe("VALIDATION_FAILED");
|
||
expect(env.executions[0].status).toBe("failed");
|
||
expect(env.audits[0].status).toBe("FAILURE");
|
||
expect(env.events.some((e) => e.eventType === "action.failed")).toBe(true);
|
||
});
|
||
|
||
test("授权失败:ACTION_FORBIDDEN,handler 未执行", async () => {
|
||
const denied = makeRuntime({ allowed: false });
|
||
denied.registry.register(scanAction(), "test");
|
||
const result = await denied.runtime.execute(
|
||
"devops.website.scan",
|
||
{ websiteId: "w1" },
|
||
actor,
|
||
{ organizationId: "ws_1" },
|
||
);
|
||
expect(result.success).toBe(false);
|
||
expect(result.error?.code).toBe("ACTION_FORBIDDEN");
|
||
});
|
||
|
||
test("未知 Action:ACTION_NOT_FOUND", async () => {
|
||
const result = await env.runtime.execute("no.such.action", {}, actor, {});
|
||
expect(result.success).toBe(false);
|
||
expect(result.error?.code).toBe("ACTION_NOT_FOUND");
|
||
});
|
||
|
||
test("handler 抛错:映射 ACTION_FAILED + 失败审计", async () => {
|
||
const registry = globalRegistry;
|
||
registry.clear();
|
||
registry.register(
|
||
defineAction({
|
||
id: "devops.website.scan",
|
||
resource: "devops.website",
|
||
name: "scan",
|
||
description: "scan",
|
||
inputSchema: z.object({ websiteId: z.string() }),
|
||
execute: async () => {
|
||
throw new Error("boom");
|
||
},
|
||
}) as unknown as ActionDefinition<never, never>,
|
||
"test",
|
||
);
|
||
const result = await env.runtime.execute("devops.website.scan", { websiteId: "w1" }, actor, {
|
||
organizationId: "ws_1",
|
||
});
|
||
console.log("DEBUG registry ids:", env.registry.list().map((a) => a.id), "| result:", JSON.stringify(result).slice(0, 200));
|
||
expect(result.success).toBe(false);
|
||
expect(result.error?.message).toBe("boom");
|
||
expect(env.executions[0].error?.message).toBe("boom");
|
||
});
|
||
});
|