forked from EduCraft/curriculum-project-hub
54837717fd
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.
188 lines
5.9 KiB
TypeScript
188 lines
5.9 KiB
TypeScript
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();
|
|
});
|
|
});
|