aps-agent/scripts/bindings.test.mjs

228 lines
11 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.

/**
* 账号绑定与登录态用例(任务 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 });
}
});