2026-09-30 14:12:24 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 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}`);
|
2026-09-30 14:26:35 +08:00
|
|
|
|
// 真实走一遍绑定(A5.7):向 APS 换 token → 加密存 → 策略放行
|
|
|
|
|
|
const bound = await server.service.bindingComplete(subject, 'aps', { accountId: 'E1001', password: 'aps-demo-pass' });
|
|
|
|
|
|
assert.equal(bound.binding.status, 'active');
|
2026-09-30 14:12:24 +08:00
|
|
|
|
|
|
|
|
|
|
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();
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|