forked from EduCraft/curriculum-project-hub
afaf5bee09
RoleEntry 扩成 (model, systemPrompt, tools) bundle,id 兼任 slash command 名。 ToolRegistry.subset() 支持按白名单构造 per-run 视图,模型永远看不到 role 外的工具。 trigger 解析 /<role> 命令,查 RoleEntry 组装 systemPrompt + 子集 registry 传给 runner。 新增 RoleTriggerGrant 表 + canTriggerRole gate,与 ADR-0004 canTriggerAgent 串联: 先问'能不能触发 agent',再问'能触发哪个 role'。未配置 role 放行(back-compat)。 - models.ts: RoleEntry 加 systemPrompt + tools 字段 - tools.ts: ToolRegistry.subset(names) 返回共享 handler 的子集视图 - trigger.ts: extractRole 解析 slash; 传 systemPrompt + runTools; catch 链容错 P2025 - runner.ts: RunRequest.systemPrompt 改 string | undefined (exactOptionalPropertyTypes) - schema.prisma + migration: RoleTriggerGrant(projectId, roleId, principal, revokedAt) - permission.ts: canTriggerRole gate (有 grant 记录即白名单模式,含 revoked) - server.ts: draft/review 两个 role 加 systemPrompt + tools 白名单 - 测试: role-permission.test.ts (5) + trigger.test.ts (+3), 53 全绿
137 lines
4.4 KiB
TypeScript
137 lines
4.4 KiB
TypeScript
/**
|
|
* Test helpers for integration tests.
|
|
*
|
|
* Each test gets a clean DB (tables truncated before the test), a mock
|
|
* FeishuRuntime (sendText/sendCard are no-ops that record calls), and a mock
|
|
* AgentProvider (chat() returns a fixed "stop" response — no real network).
|
|
*/
|
|
import { PrismaClient } from "@prisma/client";
|
|
import type { FastifyBaseLogger } from "fastify";
|
|
import type { FeishuRuntime } from "../src/feishu/client.js";
|
|
import type { AgentProvider, ChatRequest, ChatResponse, Message } from "../src/agent/provider.js";
|
|
|
|
export const TEST_DATABASE_URL = "postgresql://paradigm:paradigm@127.0.0.1:5432/cph_hub_test";
|
|
|
|
export const prisma = new PrismaClient({
|
|
datasources: { db: { url: TEST_DATABASE_URL } },
|
|
});
|
|
|
|
/** Truncate all tables before each test for isolation. */
|
|
export async function resetDb(): Promise<void> {
|
|
const tables = [
|
|
"AuditEntry",
|
|
"PermissionSettings",
|
|
"PermissionGrant",
|
|
"RoleTriggerGrant",
|
|
"ProjectAgentLock",
|
|
"AgentRun",
|
|
"AgentSession",
|
|
"ProjectGroupBinding",
|
|
"PlatformRoleAssignment",
|
|
"User",
|
|
"Project",
|
|
];
|
|
// Truncate with CASCADE to wipe dependent rows in one shot.
|
|
await prisma.$executeRawUnsafe(`TRUNCATE TABLE ${tables.map((t) => `"${t}"`).join(", ")} RESTART IDENTITY CASCADE`);
|
|
}
|
|
|
|
/** A logger that discards everything (tests don't need fastify's pino). */
|
|
export const silentLogger: FastifyBaseLogger = {
|
|
info() {}, warn() {}, error() {}, debug() {}, fatal() {},
|
|
child() { return this; },
|
|
level: "silent",
|
|
} as unknown as FastifyBaseLogger;
|
|
|
|
/** Records sendText/sendCard calls so tests can assert on them. */
|
|
export interface MockFeishuRuntime extends FeishuRuntime {
|
|
readonly sentTexts: string[];
|
|
readonly sentCards: unknown[];
|
|
}
|
|
|
|
export function mockFeishuRuntime(): MockFeishuRuntime {
|
|
const sentTexts: string[] = [];
|
|
const sentCards: unknown[] = [];
|
|
// Mock the client — sendText/sendCard are in client.ts, not on the runtime
|
|
// object directly. We patch by providing a runtime whose client is a stub;
|
|
// the actual send functions cast through shape, so a minimal stub works.
|
|
const rt: MockFeishuRuntime = {
|
|
client: {
|
|
im: {
|
|
v1: {
|
|
message: {
|
|
create: async (p: unknown) => {
|
|
const payload = p as { data?: { msg_type?: string; content?: string } };
|
|
if (payload.data?.msg_type === "interactive") {
|
|
sentCards.push(payload.data.content ? JSON.parse(payload.data.content) : null);
|
|
} else {
|
|
try {
|
|
const c = JSON.parse(payload.data?.content ?? "{}") as { text?: string };
|
|
sentTexts.push(c.text ?? payload.data?.content ?? "");
|
|
} catch {
|
|
sentTexts.push(payload.data?.content ?? "");
|
|
}
|
|
}
|
|
return { data: { message_id: "mock-msg-id" } };
|
|
},
|
|
},
|
|
},
|
|
},
|
|
} as unknown as FeishuRuntime["client"],
|
|
logger: silentLogger,
|
|
sentTexts,
|
|
sentCards,
|
|
};
|
|
return rt;
|
|
}
|
|
|
|
/** A provider that always returns a fixed "stop" response — no network. */
|
|
export class MockProvider implements AgentProvider {
|
|
readonly id = "mock";
|
|
readonly calls: ChatRequest[] = [];
|
|
|
|
async chat(req: ChatRequest): Promise<ChatResponse> {
|
|
this.calls.push(req);
|
|
const message: Message = {
|
|
role: "assistant",
|
|
parts: [{ type: "text", text: "mock response" }],
|
|
};
|
|
return { finishReason: "stop", message, usage: { inputTokens: 10, outputTokens: 5 } };
|
|
}
|
|
}
|
|
|
|
/** Create a project + binding + user + grant for a test. */
|
|
export async function seedProject(
|
|
projectId: string,
|
|
chatId: string,
|
|
options: { principal?: string; role?: "READ" | "EDIT" | "MANAGE" } = {},
|
|
): Promise<void> {
|
|
const principal = options.principal ?? "ou_test_user";
|
|
const role = options.role ?? "EDIT";
|
|
await prisma.project.create({
|
|
data: {
|
|
id: projectId,
|
|
name: `Test ${projectId}`,
|
|
workspaceDir: `/tmp/test-${projectId}`,
|
|
},
|
|
});
|
|
await prisma.user.create({
|
|
data: {
|
|
id: "u_" + projectId,
|
|
feishuOpenId: principal,
|
|
displayName: "Test User",
|
|
platformRoles: { create: { role: "TEACHER" } },
|
|
permissionGrants: {
|
|
create: {
|
|
resourceType: "PROJECT",
|
|
resourceId: projectId,
|
|
principal,
|
|
role,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
await prisma.projectGroupBinding.create({
|
|
data: { projectId, chatId, createdByUserId: "u_" + projectId },
|
|
});
|
|
}
|