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

480 lines
23 KiB
JavaScript
Raw Permalink Normal View History

/**
* 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;
}
});