aps-agent/scripts/bindings.test.mjs

228 lines
11 KiB
JavaScript
Raw Normal View History

/**
* 账号绑定与登录态用例(任务 A5.7 / ADR-0012)。
*
* 用一个「会过期的业务系统」当靶子:登录换 token、token 能被作废、作废后必须 401。
* 要证明的四件事:密码不落盘、token 只以密文存在、失效后调用被拒且不再拿旧凭据去撞、重新绑定能恢复。
*/
import assert from 'node:assert/strict';
import { createServer } from 'node:http';
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { test } from 'node:test';
const subject = { userId: 'u-bind', tenantId: 't-bind', displayName: '张工', clientKind: 'browser' };
const headers = (who = subject) => ({ 'content-type': 'application/json', 'x-aps-user': who.userId, 'x-aps-tenant': who.tenantId });
/** 业务系统桩:账号密码换 token,token 可被作废,能力调用校验 Bearer */
async function startStubSystem() {
const validTokens = new Set();
const calls = [];
const server = createServer((req, res) => {
const url = new URL(req.url ?? '/', 'http://localhost');
const json = (status, body) => res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify(body));
const bearer = (req.headers.authorization ?? '').replace(/^Bearer\s+/i, '');
if (url.pathname === '/__invalidate') {
validTokens.clear();
json(200, { invalidated: true });
return;
}
if (url.pathname === '/api/health') return json(200, { status: 'ok' });
if (url.pathname === '/api/manifest') {
return json(200, {
id: 'aps',
displayName: '桩 APS',
version: '1.0.0',
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', 'forbidden'],
},
],
});
}
if (url.pathname === '/api/auth/login') {
const chunks = [];
req.on('data', (chunk) => chunks.push(chunk));
req.on('end', () => {
const body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
if (body.accountId !== 'E1001' || body.password !== 'demo-pass') return json(401, { detail: '账号或密码不正确' });
const token = `tok-${validTokens.size + 1}`;
validTokens.add(token);
json(200, { token, accountId: 'E1001', displayName: '张工', expiresAt: Date.now() + 60_000 });
});
return;
}
if (url.pathname === '/api/auth/whoami') {
return validTokens.has(bearer) ? json(200, { accountId: 'E1001' }) : json(401, { detail: '登录态已失效' });
}
const match = url.pathname.match(/^\/api\/capabilities\/(.+)$/);
if (match) {
calls.push({ capabilityId: decodeURIComponent(match[1]), token: bearer });
if (!validTokens.has(bearer)) return json(401, { detail: '登录态已失效,请重新绑定' });
return json(200, { executionId: 'exec-stub', status: 'succeeded', result: { total: 7 } });
}
json(404, { detail: 'not found' });
});
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
return {
baseUrl: `http://127.0.0.1:${server.address().port}`,
calls,
invalidate: async () => {
await fetch(`http://127.0.0.1:${server.address().port}/__invalidate`, { method: 'POST' });
},
close: () => new Promise((resolve) => server.close(resolve)),
};
}
async function startPlatform(apsUrl, databasePath = ':memory:') {
delete process.env.APS_DEV_PROVIDER;
process.env.APS_PROVIDER_APS_URL = apsUrl;
const { startServer } = await import('../server/dist/index.js');
return startServer({ host: '127.0.0.1', port: 0, version: 'test', engine: 'fake', databasePath });
}
test('A5.7 绑定→调用→登录态失效→重绑:全程密码不落盘、凭据只走一次性句柄', async () => {
const dir = mkdtempSync(join(tmpdir(), 'aps-bind-'));
process.env.APS_CREDENTIAL_KEY_FILE = join(dir, 'secret.key');
const stub = await startStubSystem();
const dbPath = join(dir, 'aps.sqlite');
const server = await startPlatform(stub.baseUrl, dbPath);
try {
// 1) 未绑定:列表里是 unbound,调用被策略层拒
const before = await (await fetch(`${server.url}/v1/bindings`, { headers: headers() })).json();
assert.equal(before.bindings.find((b) => b.providerId === 'aps').status, 'unbound');
const denied = await server.service.invoke({ subject, sessionId: 's', projectId: 'p-default', capabilityId: 'aps.orders.query', params: {} });
assert.equal(denied.errorKind, 'forbidden');
assert.equal(stub.calls.length, 0, '没绑定就不该碰业务系统');
// 2) 发起绑定 → 3) 完成绑定(密码只出现在这一条请求里)
const start = await (await fetch(`${server.url}/v1/bindings/aps/start`, { method: 'POST', headers: headers(), body: '{}' })).json();
assert.equal(start.authKind, 'password-exchange');
const completeResponse = await fetch(`${server.url}/v1/bindings/aps/complete`, {
method: 'POST',
headers: headers(),
body: JSON.stringify({ accountId: 'E1001', password: 'demo-pass' }),
});
assert.equal(completeResponse.status, 200);
const bound = (await completeResponse.json()).binding;
assert.equal(bound.status, 'active');
assert.equal(bound.accountId, 'E1001');
assert.equal(bound.displayName, '张工');
// 密码与 token 都不能落库
const raw = readFileSync(dbPath, 'utf8');
assert.ok(!raw.includes('demo-pass'), '密码绝不能落库');
assert.ok(!raw.includes('tok-1'), 'token 只能以密文存在');
// 4) 绑定后调用成功,而且带上了 token
const ok = await server.service.invoke({ subject, sessionId: 's', projectId: 'p-default', capabilityId: 'aps.orders.query', params: {} });
assert.equal(ok.status, 'succeeded');
assert.equal(ok.result.total, 7);
assert.equal(stub.calls.at(-1).token.startsWith('tok-'), true, '业务系统收到的应该是绑定时换来的 token');
// 5) 业务系统作废登录态:第一次调用被业务系统拒,之后绑定变 expired,不再拿旧凭据去撞
await stub.invalidate();
const firstAfterInvalidate = await server.service.invoke({ subject, sessionId: 's', projectId: 'p-default', capabilityId: 'aps.orders.query', params: {} });
assert.equal(firstAfterInvalidate.status, 'failed');
assert.equal(firstAfterInvalidate.errorKind, 'forbidden');
const bindingAfter = await (await fetch(`${server.url}/v1/bindings`, { headers: headers() })).json();
assert.equal(bindingAfter.bindings.find((b) => b.providerId === 'aps').status, 'expired');
const callsBefore = stub.calls.length;
const blocked = await server.service.invoke({ subject, sessionId: 's', projectId: 'p-default', capabilityId: 'aps.orders.query', params: {} });
assert.equal(blocked.status, 'failed');
assert.match(String(blocked.result.message), /重新绑定/);
assert.equal(stub.calls.length, callsBefore, '过期后不该再打业务系统');
const trace = server.service.controlPlane.audit.trace(blocked.executionId);
assert.equal(trace[0].decision, 'policy.identity_expired');
// 6) 重新绑定后恢复
await fetch(`${server.url}/v1/bindings/aps/complete`, {
method: 'POST',
headers: headers(),
body: JSON.stringify({ accountId: 'E1001', password: 'demo-pass' }),
});
const recovered = await server.service.invoke({ subject, sessionId: 's', projectId: 'p-default', capabilityId: 'aps.orders.query', params: {} });
assert.equal(recovered.status, 'succeeded', '重新绑定后应恢复');
// 7) 解绑:凭据与映射都删掉,调用回到未绑定状态
const removed = await (await fetch(`${server.url}/v1/bindings/aps`, { method: 'DELETE', headers: headers() })).json();
assert.equal(removed.removed, true);
const afterUnbind = await server.service.invoke({ subject, sessionId: 's', projectId: 'p-default', capabilityId: 'aps.orders.query', params: {} });
assert.equal(afterUnbind.status, 'failed');
assert.match(String(afterUnbind.result.message), /绑定/);
} finally {
await server.close();
await stub.close();
delete process.env.APS_PROVIDER_APS_URL;
delete process.env.APS_CREDENTIAL_KEY_FILE;
rmSync(dir, { recursive: true, force: true });
}
});
test('A5.7 解绑 APS 不影响另一个系统', async () => {
const dir = mkdtempSync(join(tmpdir(), 'aps-bind2-'));
process.env.APS_CREDENTIAL_KEY_FILE = join(dir, 'secret.key');
const stub = await startStubSystem();
const server = await startPlatform(stub.baseUrl);
try {
const { createFakeProvider } = await import('@aps/provider-sdk/testing');
const other = createFakeProvider('erp');
await server.service.registerProvider(other);
server.service.controlPlane.identity.bind({ userId: subject.userId, tenantId: subject.tenantId, providerId: 'erp', accountId: 'R-1' });
await fetch(`${server.url}/v1/bindings/aps/complete`, {
method: 'POST',
headers: headers(),
body: JSON.stringify({ accountId: 'E1001', password: 'demo-pass' }),
});
await fetch(`${server.url}/v1/bindings/aps`, { method: 'DELETE', headers: headers() });
const otherCall = await server.service.invoke({ subject, sessionId: 's', projectId: 'p-default', capabilityId: 'erp.echo', params: { text: 'hi' } });
assert.equal(otherCall.status, 'succeeded', '解绑 APS 不该影响 ERP 的登录态');
} finally {
await server.close();
await stub.close();
delete process.env.APS_PROVIDER_APS_URL;
delete process.env.APS_CREDENTIAL_KEY_FILE;
rmSync(dir, { recursive: true, force: true });
}
});
test('A5.7 绑定失败不建绑定:账号密码不对就是拒绝', async () => {
const dir = mkdtempSync(join(tmpdir(), 'aps-bind3-'));
process.env.APS_CREDENTIAL_KEY_FILE = join(dir, 'secret.key');
const stub = await startStubSystem();
const server = await startPlatform(stub.baseUrl);
try {
const response = await fetch(`${server.url}/v1/bindings/aps/complete`, {
method: 'POST',
headers: headers(),
body: JSON.stringify({ accountId: 'E1001', password: 'wrong' }),
});
assert.equal(response.status, 403);
const list = await (await fetch(`${server.url}/v1/bindings`, { headers: headers() })).json();
assert.equal(list.bindings.find((b) => b.providerId === 'aps').status, 'unbound');
} finally {
await server.close();
await stub.close();
delete process.env.APS_PROVIDER_APS_URL;
delete process.env.APS_CREDENTIAL_KEY_FILE;
rmSync(dir, { recursive: true, force: true });
}
});