174 lines
6.9 KiB
JavaScript
174 lines
6.9 KiB
JavaScript
|
|
/**
|
|||
|
|
* 能力可见性用例(任务 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;
|
|||
|
|
}
|
|||
|
|
});
|