474 lines
22 KiB
JavaScript
474 lines
22 KiB
JavaScript
/**
|
||
* 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, '没确认前不能动业务系统');
|
||
|
||
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;
|
||
}
|
||
}); |