forked from EduCraft/curriculum-project-hub
feat(hub): built-in PBank 题库 capability + role tools (v0.0.42)
Register pbank as an ADR-0027 external capability with org-scoped username/password envelopes, readiness via /login, and in-process cph_hub MCP tools (search/get/get_many) that materialize sources under the run workspace. Extend the capability secret payload for docmind vs pbank kinds, admin capabilities UI, role tool umbrella `pbank`, and the pbank-problem-report skill. Credentials never reach the Agent process.
This commit is contained in:
@@ -0,0 +1,187 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { prisma, resetDb, seedTestOrganization, testSecretEnvelope, DEFAULT_ORG_ID } from "./helpers.js";
|
||||
import { createPbankService } from "../../src/capability/pbank.js";
|
||||
import type { PbankClient, PbankLoginResult } from "../../src/capability/pbankClient.js";
|
||||
import type { PbankCapabilitySecretPayload } from "../../src/capability/types.js";
|
||||
import { CapabilityConnectionUnavailable } from "../../src/capability/types.js";
|
||||
|
||||
const CAPABILITY_ID = "pbank";
|
||||
const PROBLEM_ID = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee";
|
||||
|
||||
describe("pbank capability service (ADR-0027)", () => {
|
||||
let workspaceRoot: string;
|
||||
let runId: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetDb();
|
||||
await seedTestOrganization();
|
||||
workspaceRoot = await mkdtemp(join(tmpdir(), "cph-pbank-"));
|
||||
runId = "run-pbank-test";
|
||||
await prisma.project.create({
|
||||
data: {
|
||||
id: "proj-pbank",
|
||||
organizationId: DEFAULT_ORG_ID,
|
||||
name: "PBank Test",
|
||||
workspaceDir: workspaceRoot,
|
||||
},
|
||||
});
|
||||
await prisma.agentRun.create({
|
||||
data: {
|
||||
id: runId,
|
||||
projectId: "proj-pbank",
|
||||
entrypoint: "FEISHU",
|
||||
provider: "openrouter",
|
||||
model: "mock-model",
|
||||
status: "ACTIVE",
|
||||
prompt: "search pbank",
|
||||
metadata: {},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(workspaceRoot, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
async function seedActiveConnection(): Promise<void> {
|
||||
const payload: PbankCapabilitySecretPayload = {
|
||||
schemaVersion: 1,
|
||||
kind: "pbank",
|
||||
baseUrl: "https://pbank.example/api",
|
||||
username: "teacher",
|
||||
password: "secret-never-log",
|
||||
rightsStatus: "owned",
|
||||
rightsHolder: "Paradigm Education",
|
||||
};
|
||||
const connection = await prisma.organizationCapabilityConnection.create({
|
||||
data: {
|
||||
id: "pbank-conn-1",
|
||||
organizationId: DEFAULT_ORG_ID,
|
||||
capabilityId: CAPABILITY_ID,
|
||||
status: "ACTIVE",
|
||||
activatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
const envelope = testSecretEnvelope.encryptJson(
|
||||
{
|
||||
purpose: "capability",
|
||||
organizationId: DEFAULT_ORG_ID,
|
||||
connectionId: connection.id,
|
||||
secretVersionId: "pbank-sv-1",
|
||||
},
|
||||
payload,
|
||||
);
|
||||
await prisma.capabilityCredentialVersion.create({
|
||||
data: {
|
||||
id: "pbank-sv-1",
|
||||
connectionId: connection.id,
|
||||
version: 1,
|
||||
envelope: envelope as object,
|
||||
keyId: envelope.keyId,
|
||||
},
|
||||
});
|
||||
await prisma.organizationCapabilityConnection.update({
|
||||
where: { id: connection.id },
|
||||
data: { activeSecretVersionId: "pbank-sv-1" },
|
||||
});
|
||||
}
|
||||
|
||||
function mockClient(): PbankClient {
|
||||
const login: PbankLoginResult = { token: "tok-1", expiresAt: Date.now() + 60_000 };
|
||||
return {
|
||||
login: vi.fn(async () => login),
|
||||
searchProblems: vi.fn(async () => ({
|
||||
pageNum: 1,
|
||||
pageSize: 10,
|
||||
total: 1,
|
||||
items: [{ id: PROBLEM_ID, title: "示例题" }],
|
||||
})),
|
||||
getProblem: vi.fn(async () => ({ id: PROBLEM_ID, title: "示例题" })),
|
||||
getOccurrences: vi.fn(async () => ({ items: [] })),
|
||||
downloadProject: vi.fn(async () => ({
|
||||
buffer: Buffer.from("# problem\n"),
|
||||
contentType: "text/plain",
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
it("fails closed when no ACTIVE connection exists", async () => {
|
||||
const service = createPbankService({
|
||||
prisma,
|
||||
secrets: testSecretEnvelope,
|
||||
client: mockClient(),
|
||||
});
|
||||
await expect(
|
||||
service.searchProblems(
|
||||
{ organizationId: DEFAULT_ORG_ID, runId, workspaceDir: workspaceRoot },
|
||||
{ q: "函数" },
|
||||
),
|
||||
).rejects.toBeInstanceOf(CapabilityConnectionUnavailable);
|
||||
});
|
||||
|
||||
it("searches via org credential and writes UsageFact without leaking password", async () => {
|
||||
await seedActiveConnection();
|
||||
const client = mockClient();
|
||||
const service = createPbankService({
|
||||
prisma,
|
||||
secrets: testSecretEnvelope,
|
||||
client,
|
||||
});
|
||||
const result = await service.searchProblems(
|
||||
{ organizationId: DEFAULT_ORG_ID, runId, workspaceDir: workspaceRoot },
|
||||
{ q: "函数", pageNum: 1, pageSize: 10 },
|
||||
);
|
||||
|
||||
expect(client.login).toHaveBeenCalledTimes(1);
|
||||
const loginArg = (client.login as ReturnType<typeof vi.fn>).mock.calls[0]?.[0] as PbankCapabilitySecretPayload;
|
||||
expect(loginArg.username).toBe("teacher");
|
||||
expect(loginArg.password).toBe("secret-never-log");
|
||||
expect(loginArg.kind).toBe("pbank");
|
||||
|
||||
expect(result.data).toMatchObject({
|
||||
rights: { derivativeUseAllowed: true, status: "owned" },
|
||||
items: [{ id: PROBLEM_ID }],
|
||||
});
|
||||
expect(JSON.stringify(result.data)).not.toContain("secret-never-log");
|
||||
|
||||
const facts = await prisma.usageFact.findMany({ where: { runId } });
|
||||
expect(facts).toHaveLength(1);
|
||||
expect(facts[0]).toMatchObject({
|
||||
kind: "external_capability",
|
||||
capabilityId: CAPABILITY_ID,
|
||||
provider: "paradigm_pbank",
|
||||
unit: "requests",
|
||||
quantity: expect.anything(),
|
||||
costUsd: null,
|
||||
costSource: "unknown",
|
||||
});
|
||||
});
|
||||
|
||||
it("fetches one problem and attaches rights", async () => {
|
||||
await seedActiveConnection();
|
||||
const client = mockClient();
|
||||
const service = createPbankService({
|
||||
prisma,
|
||||
secrets: testSecretEnvelope,
|
||||
client,
|
||||
});
|
||||
const result = await service.getProblem(
|
||||
{ organizationId: DEFAULT_ORG_ID, runId, workspaceDir: workspaceRoot },
|
||||
{ urlOrId: PROBLEM_ID, includeProjects: true, materializeProjects: false },
|
||||
);
|
||||
expect(result.data).toMatchObject({
|
||||
id: PROBLEM_ID,
|
||||
rights: { derivativeUseAllowed: true },
|
||||
problem: { id: PROBLEM_ID },
|
||||
projects: {
|
||||
problem: { status: "text" },
|
||||
answer: { status: "text" },
|
||||
},
|
||||
});
|
||||
expect(client.getProblem).toHaveBeenCalled();
|
||||
expect(client.downloadProject).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -58,6 +58,7 @@ describe("pdf_to_md_bundle capability adapter (ADR-0027)", () => {
|
||||
async function seedActiveCapabilityConnection(): Promise<void> {
|
||||
const payload: CapabilitySecretPayload = {
|
||||
schemaVersion: 1,
|
||||
kind: "docmind",
|
||||
accessKeyId: "LTAI-test-key-id",
|
||||
accessKeySecret: "test-secret-never-log",
|
||||
endpoint: "docmind-api.cn-hangzhou.aliyuncs.com",
|
||||
|
||||
Reference in New Issue
Block a user