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