dsh-im-ops/test/access-policy-host.test.mjs
2026-09-01 10:46:41 +08:00

219 lines
10 KiB
JavaScript

import assert from 'node:assert/strict';
import test from 'node:test';
import {
accessPolicyProvider,
initialAccessPolicyFor,
privilegedSenderIdsFor,
} from '../plugin-src/host/channels/shared/access-policy-production.mjs';
import {
SET_ACCESS_POLICY_ENDPOINT,
validAccessPolicyPayload,
} from '../plugin-src/host/channels/shared/access-policy-rpc.mjs';
import { createWeixinRpcHandler, WEIXIN_ENDPOINTS } from '../plugin-src/host/channels/weixin/rpc.mjs';
import { createFeishuRpcHandler, FEISHU_ENDPOINTS } from '../plugin-src/host/channels/feishu/rpc.mjs';
import { createDingtalkRpcHandler, DINGTALK_ENDPOINTS } from '../plugin-src/host/channels/dingtalk/rpc.mjs';
import { createWecomRpcHandler, WECOM_ENDPOINTS } from '../plugin-src/host/channels/wecom/rpc.mjs';
import { createQqRpcHandler, QQ_ENDPOINTS } from '../plugin-src/host/channels/qq/rpc.mjs';
import { createSlackRpcHandler, SLACK_ENDPOINTS } from '../plugin-src/host/channels/slack/rpc.mjs';
import { createTelegramRpcHandler, TELEGRAM_ENDPOINTS } from '../plugin-src/host/channels/telegram/rpc.mjs';
import { createDiscordRpcHandler, DISCORD_ENDPOINTS } from '../plugin-src/host/channels/discord/rpc.mjs';
import { createWhatsappRpcHandler, WHATSAPP_ENDPOINTS } from '../plugin-src/host/channels/whatsapp/rpc.mjs';
const users = (values = []) => values.map((value) => (
value && typeof value === 'object'
? value
: { id: value, canExecuteCommands: true }
));
const scope = ({
mode,
defaultCanExecuteCommands,
commandPermissionOverrides = [],
allowlistUsers = [],
}) => ({
mode,
open: {
defaultCanExecuteCommands,
commandPermissionOverrides: users(commandPermissionOverrides),
},
allowlist: { users: users(allowlistUsers) },
});
const open = (allowlistUsers = [], defaultCanExecuteCommands = true) => scope({
mode: 'open', defaultCanExecuteCommands, allowlistUsers,
});
const allowlist = (allowlistUsers = []) => scope({
mode: 'allowlist', defaultCanExecuteCommands: false, allowlistUsers,
});
const policy = (direct = open(), group = open()) => ({ direct, group });
test('Host initialization preserves the nine channel access baselines and legacy migrations', () => {
assert.deepEqual(initialAccessPolicyFor('weixin', { ownerUserId: 'wx-owner' }),
policy(allowlist(), allowlist()));
assert.deepEqual(initialAccessPolicyFor('feishu', { ownerOpenIds: ['ou_owner'] }),
policy(allowlist(), allowlist()));
assert.deepEqual(initialAccessPolicyFor('feishu', { ownerOpenIds: ['*'] }), policy());
for (const channel of ['dingtalk', 'wecom', 'slack', 'discord']) {
assert.deepEqual(initialAccessPolicyFor(channel), policy());
}
assert.deepEqual(initialAccessPolicyFor('qq', { ownerUserOpenid: 'qq-owner' }),
policy(allowlist(), open()));
assert.deepEqual(initialAccessPolicyFor('qq', { ownerUserOpenid: '*' }), policy());
assert.deepEqual(initialAccessPolicyFor('telegram', {
accessMode: 'compatible', allowedUsers: ['101'],
}), policy(open(['101']), open()));
assert.deepEqual(initialAccessPolicyFor('telegram', {
accessMode: 'private-allowlist', allowedUsers: ['101'],
}), policy(allowlist(['101']), allowlist()));
assert.deepEqual(initialAccessPolicyFor('whatsapp', {
accessMode: 'self-only', accountJid: '886900000000@s.whatsapp.net',
}), policy(allowlist(), allowlist()));
assert.deepEqual(initialAccessPolicyFor('whatsapp', {
accessMode: 'private-allowlist',
accountJid: '886900000000@lid',
allowedNumbers: ['16505550999'],
}), policy(allowlist(['16505550999@s.whatsapp.net']), allowlist()));
assert.deepEqual(initialAccessPolicyFor('whatsapp', {
accessMode: 'open', allowedNumbers: ['16505550999'],
}), policy(open(['16505550999@s.whatsapp.net']), open()));
});
test('Host-only owner identities are not copied into public access-policy rows', () => {
const cases = [
['weixin', { ownerUserId: 'wx-private-owner' }, 'wx-private-owner'],
['feishu', { ownerOpenIds: ['ou_private_owner'] }, 'ou_private_owner'],
['qq', { ownerUserOpenid: 'qq-private-owner' }, 'qq-private-owner'],
['whatsapp', {
accessMode: 'private-allowlist',
accountJid: '886900000000@lid',
allowedNumbers: ['16505550999'],
}, '886900000000@lid'],
];
for (const [channel, config, ownerId] of cases) {
assert.equal(JSON.stringify(initialAccessPolicyFor(channel, config)).includes(ownerId), false, channel);
}
});
test('Host access provider reads the latest committed workspace policy', () => {
let current = policy(allowlist(), allowlist());
const provider = accessPolicyProvider({ accessPolicyFor: () => current }, 'bot_one', {
channel: 'feishu', config: { ownerOpenIds: ['ou_owner', '*'] },
});
assert.equal(provider.botId, 'bot_one');
assert.equal(provider.getSettings(), current);
current = policy();
assert.equal(provider.getSettings(), current);
assert.equal(provider.isPrivileged(['ou_owner'], 'direct'), true);
assert.equal(provider.isPrivileged(['*'], 'group'), false);
assert.equal(provider.isPrivileged(['ou_other'], 'direct'), false);
assert.equal(provider.isPrivileged(['ou_owner'], 'unknown'), false);
const whatsapp = accessPolicyProvider({ accessPolicyFor: () => current }, 'bot_wa', {
channel: 'whatsapp',
config: { accountJid: '16505550100@s.whatsapp.net' },
equals: (left, right) => left.split('@')[0] === right.split('@')[0],
});
assert.equal(whatsapp.isPrivileged(['16505550100@lid'], 'group'), true);
});
test('Host privileged identities come only from durable owner or legacy authorization fields', () => {
assert.deepEqual(privilegedSenderIdsFor('weixin', { ownerUserId: 'wx-owner' }), ['wx-owner']);
assert.deepEqual(privilegedSenderIdsFor('feishu', { ownerOpenIds: ['*', 'ou_owner'] }), ['ou_owner']);
assert.deepEqual(privilegedSenderIdsFor('dingtalk', {
approvedSenders: [{ staffId: 'ding-owner' }, { staffId: 'ding-owner' }],
}), ['ding-owner']);
assert.deepEqual(privilegedSenderIdsFor('qq', { ownerUserOpenid: '*' }), []);
assert.deepEqual(privilegedSenderIdsFor('qq', { ownerUserOpenid: 'qq-owner' }), ['qq-owner']);
assert.deepEqual(privilegedSenderIdsFor('whatsapp', {
accountJid: '886900000000@s.whatsapp.net',
}), ['886900000000@s.whatsapp.net']);
for (const channel of ['wecom', 'slack', 'telegram', 'discord']) {
assert.deepEqual(privilegedSenderIdsFor(channel, { allowedUsers: ['legacy'] }), []);
}
});
test('shared access-policy RPC payload is exact and validates the full atomic policy', () => {
const value = policy(open([], false), allowlist());
assert.equal(validAccessPolicyPayload({ botId: 'bot_one', policy: value }), true);
assert.equal(validAccessPolicyPayload({ botId: 'bot_one', policy: value, extra: true }), false);
assert.equal(validAccessPolicyPayload({ botId: '../bad', policy: value }), false);
assert.equal(validAccessPolicyPayload({ botId: 'bot_one', accessMode: 'open' }), false);
assert.equal(validAccessPolicyPayload({
botId: 'bot_one',
policy: {
direct: { mode: 'open', defaultCanExecuteCommands: true, users: [] },
group: { mode: 'open', defaultCanExecuteCommands: true, users: [] },
},
}), false, 'RPC accepts canonical scopes only');
assert.equal(validAccessPolicyPayload({
botId: 'bot_one',
policy: { ...value, direct: { ...value.direct, mode: 'legacy' } },
}), false);
});
function controllerFixture() {
const calls = [];
const snapshot = (accessPolicy = policy()) => ({
schemaVersion: 2,
revision: 1,
bots: [{
botId: 'bot_one',
configured: true,
connected: true,
state: 'connected',
accessPolicy,
}],
});
const controller = {
status: async () => snapshot(),
bindCredentials: async () => snapshot(),
reconnectBot: async () => snapshot(),
deleteBot: async () => snapshot(),
startProvisioning: async () => ({}),
registrationStatus: async () => ({}),
cancelProvisioning: async () => ({}),
submitVerification: async () => ({}),
approveSender: async () => snapshot(),
revokeSender: async () => snapshot(),
startRegistration: async () => ({}),
cancelRegistration: async () => ({}),
disconnect: async () => snapshot(),
async updateAccessPolicy(botId, accessPolicy, projectStatus) {
calls.push({ botId, policy: accessPolicy });
const value = snapshot(accessPolicy);
return projectStatus ? projectStatus(value) : value;
},
};
return { controller, calls };
}
test('all nine Host RPCs accept only the unified bot.access-policy.set contract', async () => {
const factories = [
['weixin', createWeixinRpcHandler, WEIXIN_ENDPOINTS],
['feishu', createFeishuRpcHandler, FEISHU_ENDPOINTS],
['dingtalk', createDingtalkRpcHandler, DINGTALK_ENDPOINTS],
['wecom', createWecomRpcHandler, WECOM_ENDPOINTS],
['qq', createQqRpcHandler, QQ_ENDPOINTS],
['slack', createSlackRpcHandler, SLACK_ENDPOINTS],
['telegram', createTelegramRpcHandler, TELEGRAM_ENDPOINTS],
['discord', createDiscordRpcHandler, DISCORD_ENDPOINTS],
['whatsapp', createWhatsappRpcHandler, WHATSAPP_ENDPOINTS],
];
const next = policy(open([], false), allowlist(['operator']));
for (const [channel, createHandler, endpoints] of factories) {
const { controller, calls } = controllerFixture();
const handler = createHandler(controller);
assert.equal(endpoints.setAccessPolicy, SET_ACCESS_POLICY_ENDPOINT, channel);
const result = await handler(endpoints.setAccessPolicy, { botId: 'bot_one', policy: next });
assert.equal(result.ok, true, `${channel}: ${JSON.stringify(result)}`);
assert.deepEqual(calls, [{ botId: 'bot_one', policy: next }], channel);
assert.deepEqual(result.value?.bots?.[0]?.accessPolicy, next, `${channel} update projection`);
const status = await handler(endpoints.status, {});
assert.equal(status.ok, true, `${channel} status: ${JSON.stringify(status)}`);
assert.deepEqual(status.value?.bots?.[0]?.accessPolicy, policy(), `${channel} status projection`);
const legacy = await handler(endpoints.setAccessPolicy, {
botId: 'bot_one', accessMode: 'open', allowedUsers: [],
});
assert.equal(legacy.ok, false, channel);
assert.equal(legacy.error.code, 'bad-request', channel);
assert.equal(calls.length, 1, channel);
}
});