v2 platform foundation (docs 01/02/03): Action Runtime + Registry + unified API
- 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)
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* # 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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user