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

278 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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}`);
// 按人绑定后策略才放行(A4.1 + ADR-0012)
server.service.controlPlane.identity.bind({ userId: subject.userId, tenantId: subject.tenantId, providerId: 'aps', accountId: 'E1001' });
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();
}
});