aps-agent/scripts/capability-visibility.test.mjs

174 lines
6.9 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.

/**
* 能力可见性用例(任务 A3.5)。
*
* 验收:不同身份看到的工具集不同,切换身份重建会话。
* 另外锁住两条容易漏的边界:能力清单与引擎工具集必须同源;白名单外的能力对模型也不可见。
*/
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { createFakeRuntime } from '@aps/server/agent/fake-runtime';
const A = { user: 'user-a', tenant: 't-1' };
const B = { user: 'user-b', tenant: 't-1' };
/** 记录每次 createSession 收到的工具集,用来断言「引擎看到的工具」而不是只看 HTTP 清单 */
function recordingRuntime() {
const calls = [];
const runtime = createFakeRuntime({ name: 'recording', turns: [{ text: ['收到。'] }] });
const original = runtime.createSession.bind(runtime);
runtime.createSession = async (input) => {
calls.push({ userId: input.subject.userId, tools: input.tools.map((tool) => tool.capabilityId).sort() });
return original(input);
};
return { runtime, calls };
}
async function startPlatform(visibility, runtime) {
process.env.APS_DEV_PROVIDER = '1';
const { startServer } = await import('../server/dist/index.js');
return startServer({
host: '127.0.0.1',
port: 0,
version: 'test',
databasePath: ':memory:',
engine: 'fake',
...(runtime === undefined ? {} : { runtime }),
capabilityVisibility: visibility,
});
}
const headersFor = (who) => ({ 'content-type': 'application/json', 'x-aps-user': who.user, 'x-aps-tenant': who.tenant });
async function createSession(server, who) {
const response = await fetch(`${server.url}/v1/sessions`, {
method: 'POST',
headers: headersFor(who),
body: JSON.stringify({ scope: 'project', projectId: 'p-default', title: `${who.user} 的会话` }),
});
assert.equal(response.status, 200);
return (await response.json()).session.sessionId;
}
const capabilitiesOf = async (server, who) => {
const response = await fetch(`${server.url}/v1/capabilities`, { headers: headersFor(who) });
assert.equal(response.status, 200);
return (await response.json()).capabilities.map((item) => item.id).sort();
};
test('白名单决定可见能力:清单与引擎工具集同源', async () => {
const { runtime, calls } = recordingRuntime();
const server = await startPlatform(
{
defaultAllow: false,
denyWrite: false,
grants: [
{ userId: A.user, allow: ['fake.echo'] },
{ userId: B.user, allow: ['fake.write'] },
],
},
runtime,
);
try {
assert.deepEqual(await capabilitiesOf(server, A), ['fake.echo']);
assert.deepEqual(await capabilitiesOf(server, B), ['fake.write']);
// 各发一轮,看引擎实际拿到的工具
for (const who of [A, B]) {
const sessionId = await createSession(server, who);
const response = await fetch(`${server.url}/v1/sessions/${sessionId}/messages`, {
method: 'POST',
headers: headersFor(who),
body: JSON.stringify({ text: '你好', idempotencyKey: `idem-${who.user}-0001` }),
});
assert.equal(response.status, 202);
}
await new Promise((resolve) => setTimeout(resolve, 300));
const forA = calls.find((call) => call.userId === A.user);
const forB = calls.find((call) => call.userId === B.user);
assert.deepEqual(forA?.tools, ['fake.echo'], '引擎给 A 的工具应与 A 的能力清单一致');
assert.deepEqual(forB?.tools, ['fake.write'], '引擎给 B 的工具应与 B 的能力清单一致');
assert.notDeepEqual(forA?.tools, forB?.tools, '不同身份的工具集必须不同');
} finally {
await server.close();
delete process.env.APS_DEV_PROVIDER;
}
});
test('别人的会话不能访问,更不能复用它的运行时会话', async () => {
const { runtime, calls } = recordingRuntime();
const server = await startPlatform({ defaultAllow: true, denyWrite: false, grants: [] }, runtime);
try {
const sessionId = await createSession(server, A);
const read = await fetch(`${server.url}/v1/sessions/${sessionId}`, { headers: headersFor(B) });
assert.equal(read.status, 403, 'B 读 A 的会话应被拒');
assert.equal((await read.json()).error.code, 'forbidden');
const send = await fetch(`${server.url}/v1/sessions/${sessionId}/messages`, {
method: 'POST',
headers: headersFor(B),
body: JSON.stringify({ text: '插一句', idempotencyKey: 'idem-b-0001' }),
});
assert.equal(send.status, 403, 'B 不能在 A 的会话里发消息');
assert.equal(calls.length, 0, '被拒的请求不应创建任何运行时会话(工具集不能跨身份复用)');
const caps = await fetch(`${server.url}/v1/capabilities?sessionId=${sessionId}`, { headers: headersFor(B) });
assert.equal(caps.status, 403, '按会话取能力清单也要校验归属');
} finally {
await server.close();
delete process.env.APS_DEV_PROVIDER;
}
});
test('可见能力变化后重建运行时会话(工具集不会被沿用)', async () => {
const { runtime, calls } = recordingRuntime();
const server = await startPlatform({ defaultAllow: true, denyWrite: false, grants: [] }, runtime);
try {
const sessionId = await createSession(server, A);
const send = () =>
fetch(`${server.url}/v1/sessions/${sessionId}/messages`, {
method: 'POST',
headers: headersFor(A),
body: JSON.stringify({ text: '你好', idempotencyKey: `idem-${Math.random().toString(36).slice(2)}` }),
});
assert.equal((await send()).status, 202);
await new Promise((resolve) => setTimeout(resolve, 200));
assert.equal(calls.length, 1, '第一次调用创建运行时会话');
// 注册一个新 Provider:可见工具集变了
const { createFakeProvider } = await import('@aps/provider-sdk/testing');
await server.service.registerProvider(createFakeProvider('extra'));
assert.equal((await send()).status, 202);
await new Promise((resolve) => setTimeout(resolve, 200));
assert.equal(calls.length, 2, '工具集变化后必须重建运行时会话');
assert.ok(calls[1].tools.length > calls[0].tools.length, `第二次应拿到更多工具:${calls[0].tools.length} → ${calls[1].tools.length}`);
} finally {
await server.close();
delete process.env.APS_DEV_PROVIDER;
}
});
test('只读模式屏蔽写能力;白名单里不存在的能力不会出现', async () => {
const server = await startPlatform(
{
defaultAllow: true,
denyWrite: true,
grants: [{ userId: A.user, allow: ['fake.nonexistent'] }],
},
createFakeRuntime({ name: 'noop' }),
);
try {
const visible = await capabilitiesOf(server, A);
assert.ok(!visible.includes('fake.write'), `只读模式不应出现写能力:${visible.join(',')}`);
assert.ok(!visible.includes('fake.nonexistent'), '未登记的能力不该被授权表凭空造出来');
assert.ok(visible.includes('fake.echo'), '读能力应保留');
} finally {
await server.close();
delete process.env.APS_DEV_PROVIDER;
}
});