aps-agent/scripts/control-plane.test.mjs

480 lines
23 KiB
JavaScript
Raw Permalink 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.

/**
* A4 控制面用例:身份、策略、凭据、确认、审计五块一起验收(A4.1–A4.6)。
*
* 这些用例刻意走 HTTP 与 SSE 的公开接口,而不是直接调内部函数——控制面的价值就在于
* 「谁都绕不过去」,只有从外面打进来才算数。
*/
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { test } from 'node:test';
import { createFakeProvider } from '@aps/provider-sdk/testing';
const subject = { userId: 'u-a4', tenantId: 't-a4' };
const otherSubject = { userId: 'u-other', tenantId: 't-a4' };
/** 真实系统都要先绑账号(A4.1):包一层把 requiresIdentity 打开 */
function identityProvider(id) {
const base = createFakeProvider(id);
return {
stats: base.stats,
describe: async () => ({ ...(await base.describe()), requiresIdentity: true }),
invoke: (context, call) => base.invoke(context, call),
health: () => base.health(),
preview: (context, call) => base.preview(context, call),
};
}
function tmpHome() {
const dir = mkdtempSync(join(tmpdir(), 'aps-a4-'));
process.env.APS_CREDENTIAL_KEY_FILE = join(dir, 'secret.key');
return dir;
}
async function startPlatform(options = {}) {
delete process.env.APS_DEV_PROVIDER;
const { startServer } = await import('../server/dist/index.js');
return startServer({
host: '127.0.0.1',
port: 0,
version: 'test',
engine: 'fake',
databasePath: options.databasePath ?? ':memory:',
...(options.capabilityVisibility === undefined ? {} : { capabilityVisibility: options.capabilityVisibility }),
...(options.approvalTtlMs === undefined ? {} : { approvalTtlMs: options.approvalTtlMs }),
});
}
const headersFor = (who) => ({ 'content-type': 'application/json', 'x-aps-user': who.userId, 'x-aps-tenant': who.tenantId });
async function createSession(server, who = subject) {
const response = await fetch(`${server.url}/v1/sessions`, {
method: 'POST',
headers: headersFor(who),
body: JSON.stringify({ scope: 'project', projectId: 'p-default', title: 'A4 用例' }),
});
assert.equal(response.status, 200, '建会话应成功');
return (await response.json()).session.sessionId;
}
/** 打开 SSE 并收集事件(用完 close()) */
async function openStream(server, sessionId, who = subject) {
const controller = new AbortController();
const events = [];
const stream = await fetch(`${server.url}/v1/sessions/${sessionId}/events`, { headers: { accept: 'text/event-stream', 'x-aps-user': who.userId }, signal: controller.signal });
assert.equal(stream.status, 200, '事件流要能连上(会话归属要对)');
void (async () => {
const decoder = new TextDecoder();
let buffer = '';
try {
for await (const chunk of stream.body) {
buffer += decoder.decode(chunk, { stream: true });
let index = buffer.indexOf('\n\n');
while (index >= 0) {
const frame = buffer.slice(0, index);
buffer = buffer.slice(index + 2);
const line = frame.split('\n').find((item) => item.startsWith('data: '));
if (line) events.push(JSON.parse(line.slice(6)));
index = buffer.indexOf('\n\n');
}
}
} catch {
/* aborted */
}
})();
return {
events,
async waitFor(predicate, timeoutMs = 3000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (events.some(predicate)) return true;
await new Promise((resolve) => setTimeout(resolve, 15));
}
return false;
},
close() {
controller.abort();
},
};
}
test('A4.1 身份:一个用户绑多个系统账号,停用一个不影响另一个', async () => {
const dir = tmpHome();
const server = await startPlatform();
try {
const aps = identityProvider('aps');
await server.service.registerProvider(aps);
await server.service.registerProvider(identityProvider('erp'));
const identity = server.service.controlPlane.identity;
identity.bind({ userId: subject.userId, tenantId: subject.tenantId, providerId: 'aps', accountId: 'E1001' });
identity.bind({ userId: subject.userId, tenantId: subject.tenantId, providerId: 'erp', accountId: 'R2002' });
assert.deepEqual(identity.list(subject).map((binding) => binding.providerId), ['aps', 'erp']);
// 一次只读调用:Provider 拿到的是映射后的账号,不是平台用户
const apsCall = await server.service.invoke({ subject, sessionId: 's-a4', capabilityId: 'aps.echo', params: { text: 'hi' } });
assert.equal(apsCall.status, 'succeeded');
assert.equal(aps.stats.contexts.at(-1).accountId, 'E1001');
// 停用 aps:aps 被拒,erp 照常
identity.disable(subject, 'aps');
const denied = await server.service.invoke({ subject, sessionId: 's-a4', capabilityId: 'aps.echo', params: { text: 'hi' } });
assert.equal(denied.status, 'failed');
assert.equal(denied.errorKind, 'forbidden');
const erpCall = await server.service.invoke({ subject, sessionId: 's-a4', capabilityId: 'erp.echo', params: { text: 'hi' } });
assert.equal(erpCall.status, 'succeeded', '停用 aps 不该影响 erp 的登录态');
} finally {
await server.close();
rmSync(dir, { recursive: true, force: true });
delete process.env.APS_CREDENTIAL_KEY_FILE;
}
});
test('A4.1/A4.2 未绑定身份默认拒绝,并且留下 policy.denied 审计', async () => {
const dir = tmpHome();
const server = await startPlatform();
try {
await server.service.registerProvider(identityProvider('aps'));
const receipt = await server.service.invoke({ subject, sessionId: 's-a4', capabilityId: 'aps.echo', params: { text: 'hi' } });
assert.equal(receipt.status, 'failed');
assert.equal(receipt.errorKind, 'forbidden');
assert.match(String(receipt.result.message), /绑定/);
const trace = server.service.controlPlane.audit.trace(receipt.executionId);
assert.equal(trace.length, 1);
assert.equal(trace[0].event, 'policy.denied');
assert.equal(trace[0].decision, 'policy.identity_unbound');
assert.equal(trace[0].actorUserId, subject.userId);
} finally {
await server.close();
rmSync(dir, { recursive: true, force: true });
delete process.env.APS_CREDENTIAL_KEY_FILE;
}
});
test('A4.2 策略:可见的读放行、写要确认、个人会话写直接拒', async () => {
const dir = tmpHome();
const server = await startPlatform();
try {
await server.service.registerProvider(identityProvider('aps'));
server.service.controlPlane.identity.bind({ userId: subject.userId, tenantId: subject.tenantId, providerId: 'aps', accountId: 'E1001' });
const read = await server.service.invoke({ subject, sessionId: 's-a4', projectId: 'p-default', capabilityId: 'aps.echo', params: { text: 'hi' } });
assert.equal(read.status, 'succeeded');
const write = await server.service.invoke({ subject, sessionId: 's-a4', projectId: 'p-default', capabilityId: 'aps.write', params: { value: 1 } });
assert.equal(write.status, 'pending-confirmation', '写操作先开确认卡');
// ADR-0003:个人会话只读,写操作必须在项目会话里
const personal = await server.service.invoke({ subject, sessionId: 's-a4', capabilityId: 'aps.write', params: { value: 1 } });
assert.equal(personal.status, 'failed');
assert.match(String(personal.result.message), /项目会话/);
} finally {
await server.close();
rmSync(dir, { recursive: true, force: true });
delete process.env.APS_CREDENTIAL_KEY_FILE;
}
});
test('A4.2 平台的允许不覆盖业务系统的拒绝:forbidden 原样落地、不重试', async () => {
const dir = tmpHome();
const server = await startPlatform();
try {
const aps = identityProvider('aps');
await server.service.registerProvider(aps);
server.service.controlPlane.identity.bind({ userId: subject.userId, tenantId: subject.tenantId, providerId: 'aps', accountId: 'E1001' });
const receipt = await server.service.invoke({
subject,
sessionId: 's-a4',
projectId: 'p-default',
capabilityId: 'aps.declare-error',
params: { kind: 'forbidden' },
});
assert.equal(receipt.status, 'failed');
assert.equal(receipt.errorKind, 'forbidden');
assert.equal(aps.stats.calls['aps.declare-error'], 1, '业务系统的拒绝不该被平台重试');
const trace = server.service.controlPlane.audit.trace(receipt.executionId);
assert.ok(trace.some((entry) => entry.event === 'provider.denied'), '审计里要记下是业务系统拒绝的');
assert.ok(!trace.some((entry) => entry.event === 'execution.finished' && entry.decision === 'succeeded'));
} finally {
await server.close();
rmSync(dir, { recursive: true, force: true });
delete process.env.APS_CREDENTIAL_KEY_FILE;
}
});
test('A4.3 凭据:库里只有密文,执行期经句柄取用,参数与日志里都没有明文', async () => {
const dir = tmpHome();
const server = await startPlatform();
const secret = 'aps-token-9f2a1c';
try {
const aps = identityProvider('aps');
await server.service.registerProvider(aps);
const control = server.service.controlPlane;
control.identity.bind({ userId: subject.userId, tenantId: subject.tenantId, providerId: 'aps', accountId: 'E1001' });
control.credentials.put(subject, 'aps', { token: secret });
const sealed = control.credentials.sealedFor(subject, 'aps');
assert.ok(sealed && sealed.ciphertext.length > 0, '凭据要加密后落库');
assert.ok(!JSON.stringify(sealed).includes(secret), '密文里不能出现明文');
assert.equal(control.credentials.redact(`token=${secret} 已加载`), 'token=*** 已加载');
// Provider 能通过句柄拿到;参数里没有凭据(handle 是上下文里的对象,不是参数)
const receipt = await server.service.invoke({ subject, sessionId: 's-a4', projectId: 'p-default', capabilityId: 'aps.echo', params: { text: 'hi' } });
assert.equal(receipt.status, 'succeeded');
const lastContext = aps.stats.contexts.at(-1);
assert.equal(lastContext.hasCredentials, true, '有配置凭据时要签发一次性句柄');
assert.ok(!JSON.stringify(lastContext.params).includes(secret), '凭据不能混进工具参数');
const record = server.service.core.executions.get(receipt.executionId);
assert.ok(!JSON.stringify(record).includes(secret), '执行记录里不能有明文');
// 授权用完即废
assert.equal(control.credentials.activeGrants(), 0, '执行结束后一次性授权应被撤销');
} finally {
await server.close();
rmSync(dir, { recursive: true, force: true });
delete process.env.APS_CREDENTIAL_KEY_FILE;
}
});
test('A4.4 确认卡:写操作等确认、确认后执行、事件齐全、重放不重复执行', async () => {
const dir = tmpHome();
const server = await startPlatform();
try {
const aps = identityProvider('aps');
await server.service.registerProvider(aps);
server.service.controlPlane.identity.bind({ userId: subject.userId, tenantId: subject.tenantId, providerId: 'aps', accountId: 'E1001' });
const sessionId = await createSession(server);
const stream = await openStream(server, sessionId);
const receipt = await server.service.invokeFromSession(sessionId, { capabilityId: 'aps.write', params: { value: 3 }, idempotencyKey: 'idem-a4-write-1' });
assert.equal(receipt.status, 'pending-confirmation');
assert.ok(receipt.approvalId);
assert.ok(await stream.waitFor((event) => event.type === 'approval.requested'), '要推确认卡给界面');
assert.equal(aps.stats.calls['aps.write'], undefined, '没确认前不能动业务系统');
// 待确认中的执行也要能按 executionId 查到,且只有本人能查
const pendingView = await fetch(`${server.url}/v1/executions/${receipt.executionId}`, { headers: headersFor(subject) });
assert.equal((await pendingView.json()).status, 'pending-confirmation');
const stolenView = await fetch(`${server.url}/v1/executions/${receipt.executionId}`, { headers: headersFor(otherSubject) });
assert.equal(stolenView.status, 403, '执行记录只有本人能查');
const decision = await fetch(`${server.url}/v1/approvals/${receipt.approvalId}`, {
method: 'POST',
headers: headersFor(subject),
body: JSON.stringify({ decision: 'approve', revision: 7, idempotencyKey: 'idem-a4-approve-1' }),
});
assert.equal(decision.status, 200);
const decided = await decision.json();
assert.equal(decided.decision, 'approved');
assert.equal(decided.receipt.status, 'succeeded');
assert.equal(aps.stats.calls['aps.write'], 1, '确认后只执行一次');
assert.ok(await stream.waitFor((event) => event.type === 'approval.resolved'), '要推确认结果');
assert.ok(await stream.waitFor((event) => event.type === 'execution.updated'), '要推执行状态');
// 换一个幂等键再确认:仍不重复执行
const again = await fetch(`${server.url}/v1/approvals/${receipt.approvalId}`, {
method: 'POST',
headers: headersFor(subject),
body: JSON.stringify({ decision: 'approve', revision: 7, idempotencyKey: 'idem-a4-approve-2' }),
});
assert.equal(again.status, 200);
assert.equal((await again.json()).receipt.executionId, decided.receipt.executionId);
assert.equal(aps.stats.calls['aps.write'], 1, '同一写请求重试不重复生效');
stream.close();
} finally {
await server.close();
rmSync(dir, { recursive: true, force: true });
delete process.env.APS_CREDENTIAL_KEY_FILE;
}
});
test('A4.4 版本变了确认即失效;过期确认不再执行', async () => {
const dir = tmpHome();
const server = await startPlatform({ approvalTtlMs: 30 });
try {
const aps = identityProvider('aps');
await server.service.registerProvider(aps);
server.service.controlPlane.identity.bind({ userId: subject.userId, tenantId: subject.tenantId, providerId: 'aps', accountId: 'E1001' });
const sessionId = await createSession(server);
const first = await server.service.invokeFromSession(sessionId, { capabilityId: 'aps.write', params: { value: 1 }, idempotencyKey: 'idem-a4-version-1' });
const mismatch = await fetch(`${server.url}/v1/approvals/${first.approvalId}`, {
method: 'POST',
headers: headersFor(subject),
body: JSON.stringify({ decision: 'approve', revision: 999, idempotencyKey: 'idem-a4-version-2' }),
});
assert.equal((await mismatch.json()).decision, 'expired', '目标版本变了确认就作废');
assert.equal(aps.stats.calls['aps.write'], undefined);
const second = await server.service.invokeFromSession(sessionId, { capabilityId: 'aps.write', params: { value: 2 }, idempotencyKey: 'idem-a4-version-3' });
await new Promise((resolve) => setTimeout(resolve, 60));
const expired = await fetch(`${server.url}/v1/approvals/${second.approvalId}`, {
method: 'POST',
headers: headersFor(subject),
body: JSON.stringify({ decision: 'approve', revision: 7, idempotencyKey: 'idem-a4-version-4' }),
});
assert.equal((await expired.json()).decision, 'expired', '过期确认要提示重新确认');
assert.equal(aps.stats.calls['aps.write'], undefined);
} finally {
await server.close();
rmSync(dir, { recursive: true, force: true });
delete process.env.APS_CREDENTIAL_KEY_FILE;
}
});
test('A4.4 待确认动作跨进程重启存活', async () => {
const dir = tmpHome();
const databasePath = join(dir, 'aps.sqlite');
const first = await startPlatform({ databasePath });
let approvalId;
let sessionId;
let executionId;
try {
await first.service.registerProvider(identityProvider('aps'));
first.service.controlPlane.identity.bind({ userId: subject.userId, tenantId: subject.tenantId, providerId: 'aps', accountId: 'E1001' });
sessionId = await createSession(first);
const receipt = await first.service.invokeFromSession(sessionId, { capabilityId: 'aps.write', params: { value: 5 }, idempotencyKey: 'idem-a4-restart-1' });
approvalId = receipt.approvalId;
executionId = receipt.executionId;
} finally {
await first.close();
}
const second = await startPlatform({ databasePath });
try {
await second.service.registerProvider(identityProvider('aps'));
const pending = second.service.core.approvals.get(approvalId);
assert.ok(pending, '重启后待确认动作还在(落库,不是内存)');
assert.equal(pending.status, 'pending-confirmation');
const decision = await fetch(`${second.url}/v1/approvals/${approvalId}`, {
method: 'POST',
headers: headersFor(subject),
body: JSON.stringify({ decision: 'approve', revision: 7, idempotencyKey: 'idem-a4-restart-2' }),
});
assert.equal(decision.status, 200);
assert.equal((await decision.json()).decision, 'approved');
const execution = await (await fetch(`${second.url}/v1/executions/${executionId}`, { headers: headersFor(subject) })).json();
assert.equal(execution.status, 'succeeded', '重启后仍能按原 executionId 执行并落回执');
} finally {
await second.close();
rmSync(dir, { recursive: true, force: true });
delete process.env.APS_CREDENTIAL_KEY_FILE;
}
});
test('A4.5 审计:按 executionId 追到人、参数、审批与回执;改一条就断链', async () => {
const dir = tmpHome();
const databasePath = join(dir, 'aps.sqlite');
const server = await startPlatform({ databasePath });
try {
await server.service.registerProvider(identityProvider('aps'));
server.service.controlPlane.identity.bind({ userId: subject.userId, tenantId: subject.tenantId, providerId: 'aps', accountId: 'E1001' });
const sessionId = await createSession(server);
const pending = await server.service.invokeFromSession(sessionId, { capabilityId: 'aps.write', params: { value: 8 }, idempotencyKey: 'idem-a4-audit-1' });
await fetch(`${server.url}/v1/approvals/${pending.approvalId}`, {
method: 'POST',
headers: headersFor(subject),
body: JSON.stringify({ decision: 'approve', revision: 7, idempotencyKey: 'idem-a4-audit-2' }),
});
const trace = await (await fetch(`${server.url}/v1/executions/${pending.executionId}/trace`, { headers: headersFor(subject) })).json();
assert.equal(trace.executionId, pending.executionId);
assert.equal(trace.chainOk, true);
assert.equal(trace.approval.decidedBy, subject.userId, '要能追到是谁批准的');
assert.equal(trace.receipt.status, 'succeeded');
const events = trace.entries.map((entry) => entry.event);
assert.ok(events.includes('policy.requires_confirmation'));
assert.ok(events.includes('approval.requested'));
assert.ok(events.includes('approval.decided'));
assert.ok(events.includes('execution.finished'));
assert.ok(trace.entries.every((entry) => entry.actorUserId === subject.userId));
assert.ok(trace.entries.some((entry) => typeof entry.paramsDigest === 'string' && entry.paramsDigest.startsWith('sha256:')));
// 别人看不到这条执行
const forbidden = await fetch(`${server.url}/v1/executions/${pending.executionId}/trace`, { headers: headersFor(otherSubject) });
assert.equal(forbidden.status, 403);
// 直接改库里的审计记录:链校验必须报错
const { DatabaseSync } = await import('node:sqlite');
const raw = new DatabaseSync(databasePath);
raw.prepare('update audit_log set decision = ? where seq = (select min(seq) from audit_log)').run('被改过');
raw.close();
const verified = server.service.controlPlane.audit.verify();
assert.equal(verified.ok, false, '审计记录被改过要能被发现');
assert.equal(verified.brokenAt, 1);
} finally {
await server.close();
rmSync(dir, { recursive: true, force: true });
delete process.env.APS_CREDENTIAL_KEY_FILE;
}
});
test('A4.6 越权与绕过:不能替别人确认、看不见的能力调不了、停用后一律拒', async () => {
const dir = tmpHome();
const server = await startPlatform({ capabilityVisibility: { defaultAllow: false, denyWrite: false, grants: [{ userId: subject.userId, allow: ['aps.*'] }] } });
try {
await server.service.registerProvider(identityProvider('aps'));
server.service.controlPlane.identity.bind({ userId: subject.userId, tenantId: subject.tenantId, providerId: 'aps', accountId: 'E1001' });
const sessionId = await createSession(server);
const pending = await server.service.invokeFromSession(sessionId, { capabilityId: 'aps.write', params: { value: 1 }, idempotencyKey: 'idem-a4-sec-1' });
// 别人的确认卡:不是发起人就不给确认
const stolen = await fetch(`${server.url}/v1/approvals/${pending.approvalId}`, {
method: 'POST',
headers: headersFor(otherSubject),
body: JSON.stringify({ decision: 'approve', revision: 7, idempotencyKey: 'idem-a4-sec-2' }),
});
assert.equal(stolen.status, 403, '确认人只能是会话发起人');
// 白名单外的身份:能力不可见,调用直接拒
const outsider = await fetch(`${server.url}/v1/capabilities`, { headers: headersFor(otherSubject) });
assert.deepEqual((await outsider.json()).capabilities, [], '别人的能力清单是空的');
const outsiderCall = await server.service.invoke({ subject: otherSubject, sessionId: 's-x', projectId: 'p-default', capabilityId: 'aps.echo', params: { text: 'hi' } });
assert.equal(outsiderCall.status, 'failed');
assert.equal(outsiderCall.errorKind, 'forbidden');
// 越权尝试要留痕
const trace = server.service.controlPlane.audit.trace(outsiderCall.executionId);
assert.equal(trace[0].decision, 'policy.capability_not_visible');
// 绕过尝试:没有内部令牌打不进工具回调
const bypass = await fetch(`${server.url}/internal/tool-call`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ sessionId, capabilityId: 'aps.write', params: { value: 1 } }),
});
assert.equal(bypass.status, 401, '工具回调只认平台内部令牌');
} finally {
await server.close();
rmSync(dir, { recursive: true, force: true });
delete process.env.APS_CREDENTIAL_KEY_FILE;
}
});
test('A4.6 会话归属:别人的会话发消息与取能力清单都被拒', async () => {
const dir = tmpHome();
const server = await startPlatform();
try {
await server.service.registerProvider(identityProvider('aps'));
const sessionId = await createSession(server);
const send = await fetch(`${server.url}/v1/sessions/${sessionId}/messages`, {
method: 'POST',
headers: headersFor(otherSubject),
body: JSON.stringify({ text: '插一句', idempotencyKey: 'idem-a4-owner-1' }),
});
assert.equal(send.status, 403);
const capabilities = await fetch(`${server.url}/v1/capabilities?sessionId=${sessionId}`, { headers: headersFor(otherSubject) });
assert.equal(capabilities.status, 403);
} finally {
await server.close();
rmSync(dir, { recursive: true, force: true });
delete process.env.APS_CREDENTIAL_KEY_FILE;
}
});