aps-agent/scripts/aps-provider.test.mjs

279 lines
11 KiB
JavaScript
Raw Permalink Normal View History

/**
* 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();
}
});