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