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 { 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).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(); }); });