/** * APS Provider 用例(任务 A5.1–A5.6)。 * * 两层: * 1. 打桩:用本地 HTTP 桩替身验证适配器的契约行为(manifest 映射、参数透传、错误归一、健康探测), * 并跑平台的 Provider 契约测试套件(A2.4) * 2. 端到端:真起 APS 服务(Python),让平台经 Provider 读到真实数据——这一步就是 A5 的验收 * * 端到端那几条在没有 APS 运行环境时跳过(CI 的 platform job 不装 Python)。 */ import assert from 'node:assert/strict'; import { spawn } from 'node:child_process'; import { existsSync, readFileSync, readdirSync } from 'node:fs'; import { createServer } from 'node:http'; import { join } from 'node:path'; import { test } from 'node:test'; import { runProviderContract } from '@aps/provider-sdk/contract-suite'; import { createApsProvider } from '@aps/provider-aps'; const ROOT = process.cwd(); const subject = { userId: 'u-a5', tenantId: 't-a5' }; /** 打桩的 APS:够用来验适配器的每一种映射 */ async function startStubAps(options = {}) { const seen = []; const server = createServer((req, res) => { const url = new URL(req.url ?? '/', 'http://localhost'); if (url.pathname === '/api/health') { if (options.healthFails === true) { res.writeHead(503).end('{"status":"down"}'); return; } res.writeHead(200, { 'content-type': 'application/json' }).end('{"status":"ok","detail":"桩"}'); return; } if (url.pathname === '/api/manifest') { res.writeHead(200, { 'content-type': 'application/json' }).end( JSON.stringify({ id: 'aps', displayName: '桩 APS', version: '9.9.9', platformVersion: 1, requiresIdentity: true, auth: { kind: 'password-exchange' }, capabilities: [ { id: 'aps.orders.query', title: '订单查询', description: '查销售订单与交期状态', effect: 'read', mode: 'sync', whenNotToUse: '要排产用 aps.schedule.preview', synonyms: ['订单'], inputSchema: { type: 'object' }, outputSchema: { type: 'object' }, idempotency: 'none', errors: ['invalid_argument', 'unavailable'], }, ], }), ); return; } const match = url.pathname.match(/^\/api\/capabilities\/(.+)$/); if (match) { const chunks = []; req.on('data', (chunk) => chunks.push(chunk)); req.on('end', () => { const body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}'); seen.push({ capabilityId: decodeURIComponent(match[1]), body }); res.writeHead(200, { 'content-type': 'application/json' }).end( JSON.stringify({ executionId: body.executionId, status: options.receiptStatus ?? 'succeeded', result: { total: 3, echoParams: body.params }, ...(options.errorKind === undefined ? {} : { errorKind: options.errorKind }), }), ); }); return; } res.writeHead(404).end(); }); await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); const { port } = server.address(); return { baseUrl: `http://127.0.0.1:${port}`, seen, close: () => new Promise((resolve) => server.close(resolve)), }; } const context = (overrides = {}) => ({ executionId: 'exec-a5-1', sessionId: 's-a5', projectId: 'p-default', subject, identity: { accountId: 'E1001', displayName: '张工' }, idempotencyKey: 'idem-a5-1', deadlineAt: Date.now() + 10_000, ...overrides, }); test('describe() 把 APS manifest 映射成平台可用的 ProviderManifest', async () => { const stub = await startStubAps(); try { const provider = createApsProvider({ baseUrl: stub.baseUrl }); const manifest = await provider.describe(); assert.equal(manifest.id, 'aps'); assert.equal(manifest.platformVersion, 1); assert.equal(manifest.requiresIdentity, true); assert.equal(manifest.capabilities.length, 1); assert.equal(manifest.capabilities[0].id, 'aps.orders.query'); assert.equal(manifest.capabilities[0].effect, 'read'); assert.ok(manifest.capabilities[0].description.length > 0, '描述要原样带过来给模型看'); } finally { await stub.close(); } }); test('invoke() 透传 executionId / 幂等键 / 账号映射,并原样带回回执', async () => { const stub = await startStubAps(); try { const provider = createApsProvider({ baseUrl: stub.baseUrl }); const receipt = await provider.invoke(context(), { capabilityId: 'aps.orders.query', params: { limit: 5 } }); assert.equal(receipt.status, 'succeeded'); assert.equal(receipt.executionId, 'exec-a5-1'); assert.deepEqual(receipt.result.echoParams, { limit: 5 }); const call = stub.seen.at(-1); assert.equal(call.capabilityId, 'aps.orders.query'); assert.equal(call.body.executionId, 'exec-a5-1', 'executionId 要带给 APS,回执才能对应'); assert.equal(call.body.idempotencyKey, 'idem-a5-1'); assert.equal(call.body.identity.accountId, 'E1001', '按人绑定后的账号要透传给 APS'); assert.ok(!JSON.stringify(call.body).includes('token'), '参数里不该出现任何凭据'); } finally { await stub.close(); } }); test('APS 不可达 / 5xx → unavailable;4xx → invalid_argument', async () => { const provider = createApsProvider({ baseUrl: 'http://127.0.0.1:1' }); await assert.rejects( () => provider.invoke(context(), { capabilityId: 'aps.orders.query', params: {} }), (error) => error.kind === 'unavailable', ); const stub = await startStubAps({ healthFails: true }); try { const health = await createApsProvider({ baseUrl: stub.baseUrl }).health(); assert.equal(health.status, 'down'); } finally { await stub.close(); } }); test('APS 的错误分类原样透传,由 Core 决定归一', async () => { const stub = await startStubAps({ receiptStatus: 'failed', errorKind: 'forbidden' }); try { const receipt = await createApsProvider({ baseUrl: stub.baseUrl }).invoke(context(), { capabilityId: 'aps.orders.query', params: {}, }); assert.equal(receipt.status, 'failed'); assert.equal(receipt.errorKind, 'forbidden'); } finally { await stub.close(); } }); test('通过平台的 Provider 契约测试套件', async () => { const stub = await startStubAps(); try { const report = await runProviderContract(createApsProvider({ baseUrl: stub.baseUrl })); assert.equal(report.ok, true, `契约测试未通过:${report.checks.filter((c) => !c.ok).map((c) => `${c.name}(${c.detail ?? ''})`).join(';')}`); } finally { await stub.close(); } }); /* ------------------------------- 端到端 ------------------------------- */ function apsPython() { const candidates = [join(ROOT, 'aps', '.venv', 'Scripts', 'python.exe'), join(ROOT, 'aps', '.venv', 'bin', 'python')]; return candidates.find((candidate) => existsSync(candidate)); } async function startRealAps() { const python = apsPython(); if (python === undefined) return undefined; const port = 8300 + Math.floor(Math.random() * 500); const child = spawn(python, ['-m', 'uvicorn', 'aps.api.app:app', '--host', '127.0.0.1', '--port', String(port), '--log-level', 'warning'], { cwd: join(ROOT, 'aps'), stdio: 'ignore', }); const baseUrl = `http://127.0.0.1:${port}`; const deadline = Date.now() + 25_000; while (Date.now() < deadline) { try { const response = await fetch(`${baseUrl}/api/health`); if (response.ok) return { baseUrl, stop: () => child.kill() }; } catch { /* 还没起来 */ } await new Promise((resolve) => setTimeout(resolve, 300)); } child.kill(); return undefined; } test('A5 验收:平台经 Provider 读到 APS 的真实数据', async (t) => { const aps = await startRealAps(); if (aps === undefined) { t.skip('本机没有 aps/.venv(先 cd aps && uv sync),跳过端到端验收'); return; } delete process.env.APS_DEV_PROVIDER; const { startServer } = await import('../server/dist/index.js'); const server = await startServer({ host: '127.0.0.1', port: 0, version: 'test', engine: 'fake', databasePath: ':memory:' }); try { const provider = createApsProvider({ baseUrl: aps.baseUrl }); const manifest = await server.service.registerProvider(provider); assert.ok(manifest.capabilities.length >= 9, `APS 应暴露只读能力,实际 ${manifest.capabilities.length}`); // 真实走一遍绑定(A5.7):向 APS 换 token → 加密存 → 策略放行 const bound = await server.service.bindingComplete(subject, 'aps', { accountId: 'E1001', password: 'aps-demo-pass' }); assert.equal(bound.binding.status, 'active'); const receipt = await server.service.invoke({ subject, sessionId: 's-a5', projectId: 'p-default', capabilityId: 'aps.orders.query', params: { limit: 5 }, }); assert.equal(receipt.status, 'succeeded', `调用应成功:${JSON.stringify(receipt)}`); assert.equal(receipt.result.total, 7, 'APS 演示数据里有 7 张销售订单'); // 未绑定的身份仍然被拒:说明策略在 Provider 之前生效 const outsider = await server.service.invoke({ subject: { userId: 'nobody', tenantId: 't-a5' }, sessionId: 's-a5', projectId: 'p-default', capabilityId: 'aps.orders.query', params: {} }); assert.equal(outsider.status, 'failed'); assert.equal(outsider.errorKind, 'forbidden'); } finally { await server.close(); aps.stop(); } }); test('A5.1 能力清单不漂移:平台侧描述与 APS 的 manifest 一致', async (t) => { const aps = await startRealAps(); if (aps === undefined) { t.skip('本机没有 aps/.venv,跳过漂移检查'); return; } try { const manifest = await createApsProvider({ baseUrl: aps.baseUrl }).describe(); const files = readdirSync(join(ROOT, 'providers', 'aps', 'capabilities')).filter((name) => name.endsWith('.capability.json')); const described = new Map( files.map((name) => { const body = JSON.parse(readFileSync(join(ROOT, 'providers', 'aps', 'capabilities', name), 'utf8')); return [body.id, body]; }), ); for (const capability of manifest.capabilities) { const describedCapability = described.get(capability.id); assert.ok(describedCapability, `平台侧缺 ${capability.id} 的能力描述文件`); assert.equal(describedCapability.purpose, capability.description, `${capability.id} 的用途与 APS 不一致`); assert.equal(describedCapability.effect, capability.effect); } } finally { aps.stop(); } });