mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 03:03:24 +08:00
feat: add unified IM access policies
This commit is contained in:
parent
075d2713c1
commit
840e5aa857
89 changed files with 5573 additions and 1676 deletions
219
test/access-policy-host.test.mjs
Normal file
219
test/access-policy-host.test.mjs
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
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);
|
||||
}
|
||||
});
|
||||
584
test/access-policy.test.mjs
Normal file
584
test/access-policy.test.mjs
Normal file
|
|
@ -0,0 +1,584 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
mkdtemp,
|
||||
mkdir,
|
||||
readFile,
|
||||
realpath,
|
||||
rename,
|
||||
rm,
|
||||
writeFile,
|
||||
} from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
DEFAULT_ACCESS_POLICY,
|
||||
createAccessPolicy,
|
||||
createAccessPolicyScope,
|
||||
evaluateAccessPolicy,
|
||||
normalizeAccessPolicy,
|
||||
validateAccessPolicy,
|
||||
} from '../src/channels/shared/access-policy.mjs';
|
||||
import {
|
||||
BotWorkspaceStore,
|
||||
createWorkspaceAwareController,
|
||||
} from '../src/channels/shared/bot-workspace-store.mjs';
|
||||
|
||||
function user(id, canExecuteCommands = true) {
|
||||
return { id, canExecuteCommands };
|
||||
}
|
||||
|
||||
function scope({
|
||||
mode = 'open',
|
||||
defaultCanExecuteCommands = true,
|
||||
commandPermissionOverrides = [],
|
||||
users = [],
|
||||
} = {}) {
|
||||
return {
|
||||
mode,
|
||||
open: { defaultCanExecuteCommands, commandPermissionOverrides },
|
||||
allowlist: { users },
|
||||
};
|
||||
}
|
||||
|
||||
function openScope({
|
||||
defaultCanExecuteCommands = true,
|
||||
commandPermissionOverrides = [],
|
||||
allowlistUsers = [],
|
||||
} = {}) {
|
||||
return scope({
|
||||
mode: 'open',
|
||||
defaultCanExecuteCommands,
|
||||
commandPermissionOverrides,
|
||||
users: allowlistUsers,
|
||||
});
|
||||
}
|
||||
|
||||
function allowlistScope(users = [], {
|
||||
defaultCanExecuteCommands = false,
|
||||
commandPermissionOverrides = [],
|
||||
} = {}) {
|
||||
return scope({
|
||||
mode: 'allowlist',
|
||||
defaultCanExecuteCommands,
|
||||
commandPermissionOverrides,
|
||||
users,
|
||||
});
|
||||
}
|
||||
|
||||
function policy({ direct = openScope(), group = openScope() } = {}) {
|
||||
return { direct, group };
|
||||
}
|
||||
|
||||
async function fixture(t) {
|
||||
const root = await realpath(await mkdtemp(join(tmpdir(), 'dsh-im-access-policy-')));
|
||||
t.after(() => rm(root, { recursive: true, force: true }));
|
||||
const defaultWorkspace = join(root, 'workspace');
|
||||
await mkdir(defaultWorkspace);
|
||||
return {
|
||||
root,
|
||||
defaultWorkspace,
|
||||
path: join(root, 'workspaces.json'),
|
||||
};
|
||||
}
|
||||
|
||||
test('access policy validates and normalizes one complete atomic config', () => {
|
||||
const normalized = validateAccessPolicy(policy({
|
||||
direct: openScope({
|
||||
defaultCanExecuteCommands: false,
|
||||
commandPermissionOverrides: [user(' user-one ')],
|
||||
allowlistUsers: [user('inactive-member', false)],
|
||||
}),
|
||||
group: allowlistScope([user(8672352515, false)], {
|
||||
commandPermissionOverrides: [user('inactive-admin')],
|
||||
}),
|
||||
}));
|
||||
|
||||
assert.deepEqual(normalized, {
|
||||
direct: {
|
||||
mode: 'open',
|
||||
open: {
|
||||
defaultCanExecuteCommands: false,
|
||||
commandPermissionOverrides: [user('user-one')],
|
||||
},
|
||||
allowlist: { users: [user('inactive-member', false)] },
|
||||
},
|
||||
group: {
|
||||
mode: 'allowlist',
|
||||
open: {
|
||||
defaultCanExecuteCommands: false,
|
||||
commandPermissionOverrides: [user('inactive-admin')],
|
||||
},
|
||||
allowlist: { users: [user('8672352515', false)] },
|
||||
},
|
||||
});
|
||||
assert.equal(Object.isFrozen(normalized), true);
|
||||
assert.equal(Object.isFrozen(normalized.direct.open.commandPermissionOverrides), true);
|
||||
assert.equal(Object.isFrozen(normalized.direct.allowlist.users), true);
|
||||
assert.equal(normalizeAccessPolicy({ damaged: true }), null);
|
||||
|
||||
const invalid = [
|
||||
{},
|
||||
{ ...policy(), extra: true },
|
||||
policy({ direct: { ...openScope(), extra: true } }),
|
||||
policy({ direct: { ...openScope(), mode: 'private' } }),
|
||||
policy({ direct: { ...openScope(), open: { defaultCanExecuteCommands: true } } }),
|
||||
policy({ direct: openScope({ defaultCanExecuteCommands: 'yes' }) }),
|
||||
policy({ direct: openScope({ commandPermissionOverrides: 'user-one' }) }),
|
||||
policy({ direct: { ...openScope(), allowlist: { users: 'user-one' } } }),
|
||||
policy({ direct: openScope({ commandPermissionOverrides: [user('')] }) }),
|
||||
policy({ direct: openScope({ commandPermissionOverrides: [user('bad\u0000id')] }) }),
|
||||
policy({ direct: openScope({ commandPermissionOverrides: [user('x'.repeat(257))] }) }),
|
||||
policy({ direct: openScope({ commandPermissionOverrides: [user('user', 'yes')] }) }),
|
||||
policy({ direct: openScope({
|
||||
commandPermissionOverrides: [{ ...user('user'), extra: true }],
|
||||
}) }),
|
||||
policy({ direct: openScope({
|
||||
commandPermissionOverrides: [user(' duplicate '), user('duplicate', false)],
|
||||
}) }),
|
||||
policy({ direct: allowlistScope([user(' duplicate '), user('duplicate', false)]) }),
|
||||
{
|
||||
direct: { mode: 'open', defaultCanExecuteCommands: true, users: [] },
|
||||
group: { mode: 'open', defaultCanExecuteCommands: true, users: [] },
|
||||
},
|
||||
];
|
||||
for (const input of invalid) {
|
||||
assert.throws(() => validateAccessPolicy(input), { code: 'access-policy-invalid' });
|
||||
}
|
||||
});
|
||||
|
||||
test('access policy constructors default closed and accept only canonical scopes', () => {
|
||||
assert.deepEqual(DEFAULT_ACCESS_POLICY, policy({
|
||||
direct: allowlistScope(),
|
||||
group: allowlistScope(),
|
||||
}));
|
||||
assert.deepEqual(createAccessPolicy({
|
||||
direct: createAccessPolicyScope(allowlistScope([
|
||||
user('owner'),
|
||||
user(1234n),
|
||||
])),
|
||||
group: createAccessPolicyScope(openScope()),
|
||||
}), policy({
|
||||
direct: allowlistScope([
|
||||
user('owner'),
|
||||
user('1234'),
|
||||
]),
|
||||
group: openScope(),
|
||||
}));
|
||||
assert.deepEqual(createAccessPolicyScope({
|
||||
mode: 'open',
|
||||
open: {
|
||||
defaultCanExecuteCommands: false,
|
||||
commandPermissionOverrides: [user('admin')],
|
||||
},
|
||||
allowlist: { users: [user('member')] },
|
||||
}), openScope({
|
||||
defaultCanExecuteCommands: false,
|
||||
commandPermissionOverrides: [user('admin')],
|
||||
allowlistUsers: [user('member')],
|
||||
}));
|
||||
assert.throws(() => createAccessPolicyScope({
|
||||
mode: 'allowlist',
|
||||
defaultCanExecuteCommands: false,
|
||||
users: [],
|
||||
}), { code: 'access-policy-invalid' });
|
||||
});
|
||||
|
||||
test('access decisions keep direct, group, ordinary-message and command permissions separate', () => {
|
||||
const settings = policy({
|
||||
direct: openScope({
|
||||
defaultCanExecuteCommands: false,
|
||||
commandPermissionOverrides: [user('admin')],
|
||||
allowlistUsers: [user('guest-deny', false)],
|
||||
}),
|
||||
group: allowlistScope([
|
||||
user('member', false),
|
||||
user('operator'),
|
||||
], { commandPermissionOverrides: [user('unknown')] }),
|
||||
});
|
||||
|
||||
assert.deepEqual(evaluateAccessPolicy(settings, {
|
||||
conversationType: 'direct', senderIds: ['guest'],
|
||||
}), { allowed: true, reason: 'allowed' });
|
||||
assert.deepEqual(evaluateAccessPolicy(settings, {
|
||||
conversationType: 'direct', senderIds: ['guest'], isCommand: true,
|
||||
}), { allowed: false, reason: 'command-not-allowed' });
|
||||
assert.deepEqual(evaluateAccessPolicy(settings, {
|
||||
conversationType: 'direct', senderIds: ['admin'], isCommand: true,
|
||||
}), { allowed: true, reason: 'allowed' });
|
||||
assert.deepEqual(evaluateAccessPolicy(settings, {
|
||||
conversationType: 'direct', senderIds: ['guest-deny'], isCommand: true,
|
||||
}), { allowed: false, reason: 'command-not-allowed' },
|
||||
'the inactive allowlist does not override open-mode command defaults');
|
||||
assert.deepEqual(evaluateAccessPolicy(settings, {
|
||||
conversationType: 'group', senderIds: ['unknown'],
|
||||
}), { allowed: false, reason: 'sender-not-allowed' });
|
||||
assert.deepEqual(evaluateAccessPolicy(settings, {
|
||||
conversationType: 'group', senderIds: ['member'],
|
||||
}), { allowed: true, reason: 'allowed' });
|
||||
assert.deepEqual(evaluateAccessPolicy(settings, {
|
||||
conversationType: 'group', senderIds: ['member'], isCommand: true,
|
||||
}), { allowed: false, reason: 'command-not-allowed' });
|
||||
assert.deepEqual(evaluateAccessPolicy(settings, {
|
||||
conversationType: 'group',
|
||||
senderIds: ['alternate', 'OPERATOR'],
|
||||
isCommand: true,
|
||||
equals: (left, right) => left.toLowerCase() === right.toLowerCase(),
|
||||
}), { allowed: true, reason: 'allowed' });
|
||||
const nonCanonical = {
|
||||
direct: {
|
||||
mode: 'allowlist',
|
||||
defaultCanExecuteCommands: false,
|
||||
users: [user('old-member', false)],
|
||||
},
|
||||
group: { mode: 'open', defaultCanExecuteCommands: true, users: [] },
|
||||
};
|
||||
assert.equal(normalizeAccessPolicy(nonCanonical), null);
|
||||
assert.deepEqual(evaluateAccessPolicy(nonCanonical, {
|
||||
conversationType: 'direct', senderIds: ['old-member'],
|
||||
}), { allowed: false, reason: 'policy-unavailable' });
|
||||
assert.deepEqual(evaluateAccessPolicy(null, {
|
||||
conversationType: 'direct', senderIds: ['admin'],
|
||||
}), { allowed: false, reason: 'policy-unavailable' });
|
||||
assert.deepEqual(evaluateAccessPolicy(settings, {
|
||||
conversationType: 'channel', senderIds: ['admin'],
|
||||
}), { allowed: false, reason: 'invalid-context' });
|
||||
assert.deepEqual(evaluateAccessPolicy(settings, {
|
||||
conversationType: 'direct', senderIds: [null, 'bad\u0000id'],
|
||||
}), { allowed: false, reason: 'sender-unavailable' });
|
||||
});
|
||||
|
||||
test('access decisions read only the active scenario when one id exists in both lists', () => {
|
||||
const sameId = policy({
|
||||
direct: openScope({
|
||||
defaultCanExecuteCommands: false,
|
||||
commandPermissionOverrides: [user('same-user', true)],
|
||||
allowlistUsers: [user('same-user', false)],
|
||||
}),
|
||||
group: allowlistScope([user('same-user', false)], {
|
||||
defaultCanExecuteCommands: false,
|
||||
commandPermissionOverrides: [user('same-user', true)],
|
||||
}),
|
||||
});
|
||||
|
||||
assert.doesNotThrow(() => validateAccessPolicy(sameId),
|
||||
'the same id may appear once in each independent scenario');
|
||||
assert.deepEqual(evaluateAccessPolicy(sameId, {
|
||||
conversationType: 'direct', senderIds: ['same-user'], isCommand: true,
|
||||
}), { allowed: true, reason: 'allowed' },
|
||||
'open mode reads its override and ignores the conflicting allowlist row');
|
||||
assert.deepEqual(evaluateAccessPolicy(sameId, {
|
||||
conversationType: 'group', senderIds: ['same-user'], isCommand: true,
|
||||
}), { allowed: false, reason: 'command-not-allowed' },
|
||||
'allowlist mode reads its row and ignores the conflicting open override');
|
||||
});
|
||||
|
||||
test('access decisions deny commands when equivalent sender aliases match conflicting rows', () => {
|
||||
const settings = policy({
|
||||
direct: openScope({
|
||||
defaultCanExecuteCommands: true,
|
||||
commandPermissionOverrides: [
|
||||
user('configured-pn', true),
|
||||
user('configured-lid', false),
|
||||
],
|
||||
}),
|
||||
group: allowlistScope([
|
||||
user('configured-pn', true),
|
||||
user('configured-lid', false),
|
||||
]),
|
||||
});
|
||||
const aliases = new Set([
|
||||
'sender-pn', 'sender-lid', 'configured-pn', 'configured-lid',
|
||||
]);
|
||||
const equals = (left, right) => aliases.has(left) && aliases.has(right);
|
||||
const senderIds = ['sender-pn', 'sender-lid'];
|
||||
|
||||
assert.deepEqual(evaluateAccessPolicy(settings, {
|
||||
conversationType: 'direct', senderIds, isCommand: true, equals,
|
||||
}), { allowed: false, reason: 'command-not-allowed' });
|
||||
assert.deepEqual(evaluateAccessPolicy(settings, {
|
||||
conversationType: 'group', senderIds, equals,
|
||||
}), { allowed: true, reason: 'allowed' },
|
||||
'any matching allowlist alias permits an ordinary message');
|
||||
assert.deepEqual(evaluateAccessPolicy(settings, {
|
||||
conversationType: 'group', senderIds, isCommand: true, equals,
|
||||
}), { allowed: false, reason: 'command-not-allowed' });
|
||||
|
||||
const allAllowed = policy({
|
||||
direct: openScope({
|
||||
defaultCanExecuteCommands: false,
|
||||
commandPermissionOverrides: [
|
||||
user('configured-pn', true),
|
||||
user('configured-lid', true),
|
||||
],
|
||||
}),
|
||||
});
|
||||
assert.deepEqual(evaluateAccessPolicy(allAllowed, {
|
||||
conversationType: 'direct', senderIds, isCommand: true, equals,
|
||||
}), { allowed: true, reason: 'allowed' },
|
||||
'all equivalent matching rows must explicitly allow commands');
|
||||
});
|
||||
|
||||
test('BotWorkspaceStore initializes a missing policy once and upgrades v1 to v2', async (t) => {
|
||||
const { path, defaultWorkspace } = await fixture(t);
|
||||
await writeFile(path, `${JSON.stringify({
|
||||
version: 1,
|
||||
workspaces: { bot_one: defaultWorkspace },
|
||||
agentPresets: { bot_one: 'router-standard' },
|
||||
})}\n`);
|
||||
const store = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
|
||||
const initial = policy({ direct: allowlistScope([
|
||||
{ id: 'owner', canExecuteCommands: true },
|
||||
]) });
|
||||
|
||||
await store.ensure('bot_one', { initialAccessPolicy: initial });
|
||||
assert.deepEqual(store.accessPolicyFor('bot_one'), initial);
|
||||
const saved = JSON.parse(await readFile(path, 'utf8'));
|
||||
assert.equal(saved.version, 2);
|
||||
assert.equal(saved.workspaces.bot_one, defaultWorkspace);
|
||||
assert.equal(saved.agentPresets.bot_one, 'router-standard');
|
||||
assert.deepEqual(saved.accessPolicies.bot_one, initial);
|
||||
|
||||
const replacementSeed = policy({ direct: openScope() });
|
||||
await store.ensure('bot_one', { initialAccessPolicy: replacementSeed });
|
||||
assert.deepEqual(store.accessPolicyFor('bot_one'), initial, 'initialization is idempotent');
|
||||
|
||||
const reloaded = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
|
||||
assert.deepEqual(reloaded.accessPolicyFor('bot_one'), initial);
|
||||
assert.deepEqual(reloaded.decorateStatus({ bots: [{ botId: 'bot_one' }] }).bots[0].accessPolicy, initial);
|
||||
});
|
||||
|
||||
test('BotWorkspaceStore fail-closes non-canonical v2 policies without rewriting on load', async (t) => {
|
||||
const { path, defaultWorkspace } = await fixture(t);
|
||||
const valid = policy({
|
||||
direct: allowlistScope([user('member')]),
|
||||
group: openScope({ defaultCanExecuteCommands: false }),
|
||||
});
|
||||
const nonCanonical = {
|
||||
direct: {
|
||||
mode: 'open',
|
||||
defaultCanExecuteCommands: false,
|
||||
users: [user('old-override')],
|
||||
},
|
||||
group: {
|
||||
mode: 'allowlist',
|
||||
defaultCanExecuteCommands: true,
|
||||
users: [user('old-member', false)],
|
||||
},
|
||||
};
|
||||
const original = JSON.stringify({
|
||||
version: 2,
|
||||
workspaces: {
|
||||
bot_good: defaultWorkspace,
|
||||
bot_noncanonical: defaultWorkspace,
|
||||
},
|
||||
agentPresets: { bot_good: 'router-standard' },
|
||||
contextEnhancement: {
|
||||
bot_good: {
|
||||
group: { enabled: false, fields: ['senderId'], guidance: '' },
|
||||
direct: { enabled: true, fields: ['senderId', 'senderName'], guidance: 'direct' },
|
||||
},
|
||||
},
|
||||
deliveryTargets: {
|
||||
bot_good: { target: { kind: 'user', route: { userId: 'one' } } },
|
||||
},
|
||||
accessPolicies: {
|
||||
bot_good: valid,
|
||||
bot_noncanonical: nonCanonical,
|
||||
},
|
||||
});
|
||||
await writeFile(path, original);
|
||||
|
||||
const store = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
|
||||
assert.deepEqual(store.accessPolicyFor('bot_good'), valid);
|
||||
assert.equal(store.accessPolicyFor('bot_noncanonical'), null);
|
||||
assert.equal(store.agentPresetFor('bot_good'), 'router-standard');
|
||||
assert.equal(store.deliveryTargetFor('bot_good', 'target').route.userId, 'one');
|
||||
assert.equal(await readFile(path, 'utf8'), original,
|
||||
'loading invalid policy data must be read-only');
|
||||
await store.ensure('bot_noncanonical', { initialAccessPolicy: policy() });
|
||||
assert.equal(store.accessPolicyFor('bot_noncanonical'), null,
|
||||
'invalid policy data is not treated as a missing seed');
|
||||
assert.equal(await readFile(path, 'utf8'), original);
|
||||
});
|
||||
|
||||
test('BotWorkspaceStore isolates damaged policies and does not initialize over them', async (t) => {
|
||||
const { path, defaultWorkspace } = await fixture(t);
|
||||
const valid = policy({ group: allowlistScope([
|
||||
{ id: 'member', canExecuteCommands: true },
|
||||
]) });
|
||||
await writeFile(path, `${JSON.stringify({
|
||||
version: 2,
|
||||
workspaces: {
|
||||
bot_good: defaultWorkspace,
|
||||
bot_damaged: defaultWorkspace,
|
||||
bot_missing: defaultWorkspace,
|
||||
},
|
||||
deliveryTargets: {
|
||||
bot_good: { target: { kind: 'user', route: { userId: 'one' } } },
|
||||
},
|
||||
accessPolicies: {
|
||||
bot_good: valid,
|
||||
bot_damaged: { direct: { mode: 'open' } },
|
||||
},
|
||||
})}\n`);
|
||||
const store = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
|
||||
|
||||
assert.deepEqual(store.accessPolicyFor('bot_good'), valid);
|
||||
assert.equal(store.accessPolicyFor('bot_damaged'), null);
|
||||
assert.equal(store.accessPolicyFor('bot_missing'), null);
|
||||
await store.ensure('bot_damaged', { initialAccessPolicy: policy() });
|
||||
await store.ensure('bot_missing', { initialAccessPolicy: policy() });
|
||||
|
||||
assert.equal(store.accessPolicyFor('bot_damaged'), null, 'damaged is not treated as missing');
|
||||
assert.deepEqual(store.accessPolicyFor('bot_missing'), policy());
|
||||
assert.equal(store.deliveryTargetFor('bot_good', 'target').route.userId, 'one');
|
||||
const saved = JSON.parse(await readFile(path, 'utf8'));
|
||||
assert.equal(saved.accessPolicies.bot_damaged, null);
|
||||
assert.deepEqual(saved.accessPolicies.bot_good, valid);
|
||||
assert.deepEqual(saved.accessPolicies.bot_missing, policy());
|
||||
});
|
||||
|
||||
test('BotWorkspaceStore fail-closes a damaged policy section without poisoning other bot data', async (t) => {
|
||||
const { path, defaultWorkspace } = await fixture(t);
|
||||
await writeFile(path, `${JSON.stringify({
|
||||
version: 2,
|
||||
workspaces: { bot_one: defaultWorkspace },
|
||||
agentPresets: { bot_one: 'router-standard' },
|
||||
deliveryTargets: {
|
||||
bot_one: { target: { kind: 'user', route: { userId: 'one' } } },
|
||||
},
|
||||
accessPolicies: 'damaged-section',
|
||||
})}\n`);
|
||||
const store = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
|
||||
|
||||
assert.equal(store.workspaceFor('bot_one'), defaultWorkspace);
|
||||
assert.equal(store.agentPresetFor('bot_one'), 'router-standard');
|
||||
assert.equal(store.deliveryTargetFor('bot_one', 'target').route.userId, 'one');
|
||||
assert.equal(store.accessPolicyFor('bot_one'), null);
|
||||
await store.ensure('bot_one', { initialAccessPolicy: policy() });
|
||||
assert.equal(store.accessPolicyFor('bot_one'), null,
|
||||
'startup initialization must not overwrite a damaged policy section');
|
||||
});
|
||||
|
||||
test('BotWorkspaceStore publishes policy snapshots only after atomic persistence', async (t) => {
|
||||
const { root, defaultWorkspace } = await fixture(t);
|
||||
const storeDirectory = join(root, 'store');
|
||||
const storePath = join(storeDirectory, 'workspaces.json');
|
||||
await mkdir(storeDirectory);
|
||||
const store = await new BotWorkspaceStore(storePath, { defaultWorkspace }).load();
|
||||
const initial = policy({ direct: allowlistScope([
|
||||
{ id: 'owner', canExecuteCommands: true },
|
||||
]) });
|
||||
await store.ensure('bot_io', { initialAccessPolicy: initial });
|
||||
|
||||
await rename(storeDirectory, `${storeDirectory}-saved`);
|
||||
await writeFile(storeDirectory, 'blocks policy persistence');
|
||||
await assert.rejects(store.setAccessPolicy('bot_io', policy()));
|
||||
assert.deepEqual(store.accessPolicyFor('bot_io'), initial);
|
||||
|
||||
await rm(storeDirectory, { force: true });
|
||||
await rename(`${storeDirectory}-saved`, storeDirectory);
|
||||
assert.deepEqual(JSON.parse(await readFile(storePath, 'utf8')).accessPolicies.bot_io, initial);
|
||||
});
|
||||
|
||||
test('BotWorkspaceStore cleans policies on reconcile and fences same-id stale updates', async (t) => {
|
||||
const { path, defaultWorkspace } = await fixture(t);
|
||||
const store = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
|
||||
await store.ensure('bot_one', { initialAccessPolicy: policy() });
|
||||
await store.ensure('bot_two', { initialAccessPolicy: policy({ group: allowlistScope() }) });
|
||||
const staleIncarnation = store.incarnationFor('bot_one');
|
||||
|
||||
await store.remove('bot_one');
|
||||
await store.ensure('bot_one', {
|
||||
initialAccessPolicy: policy({ direct: allowlistScope([
|
||||
{ id: 'new-owner', canExecuteCommands: true },
|
||||
]) }),
|
||||
});
|
||||
await assert.rejects(store.setAccessPolicy('bot_one', policy(), {
|
||||
incarnation: staleIncarnation,
|
||||
}), { code: 'workspace-bot-not-found' });
|
||||
assert.equal(store.accessPolicyFor('bot_one').direct.allowlist.users[0].id, 'new-owner');
|
||||
|
||||
await store.reconcile(['bot_one']);
|
||||
const saved = JSON.parse(await readFile(path, 'utf8'));
|
||||
assert.equal(saved.workspaces.bot_two, undefined);
|
||||
assert.equal(saved.accessPolicies.bot_two, undefined);
|
||||
assert.equal(saved.accessPolicies.bot_one.direct.allowlist.users[0].id, 'new-owner');
|
||||
});
|
||||
|
||||
test('workspace-aware controller preprojects a full policy status before commit', async (t) => {
|
||||
const { path, defaultWorkspace } = await fixture(t);
|
||||
const store = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
|
||||
const initial = policy({ direct: allowlistScope() });
|
||||
const updated = policy({ direct: openScope({ defaultCanExecuteCommands: false }) });
|
||||
await store.ensure('bot_one', { initialAccessPolicy: initial });
|
||||
const base = {
|
||||
status() { return { bots: [{ botId: 'bot_one', connected: true }] }; },
|
||||
};
|
||||
const controller = createWorkspaceAwareController(base, {
|
||||
workspaces: store,
|
||||
stateFor: async () => ({ async clearSessions() {} }),
|
||||
});
|
||||
|
||||
const result = await controller.updateAccessPolicy('bot_one', updated, async (projected) => {
|
||||
assert.deepEqual(projected.bots[0].accessPolicy, updated);
|
||||
assert.deepEqual(store.accessPolicyFor('bot_one'), initial, 'projection happens before commit');
|
||||
return { ...projected, projected: true };
|
||||
});
|
||||
assert.equal(result.projected, true);
|
||||
assert.deepEqual(result.bots[0].accessPolicy, updated);
|
||||
assert.deepEqual(store.accessPolicyFor('bot_one'), updated);
|
||||
|
||||
await assert.rejects(controller.updateAccessPolicy('bot_one', initial, async () => {
|
||||
throw new Error('projection failed');
|
||||
}), /projection failed/);
|
||||
assert.deepEqual(store.accessPolicyFor('bot_one'), updated);
|
||||
assert.throws(() => controller.updateAccessPolicy('bot_one', {
|
||||
direct: { mode: 'open', defaultCanExecuteCommands: true, users: [] },
|
||||
group: { mode: 'open', defaultCanExecuteCommands: true, users: [] },
|
||||
}), {
|
||||
code: 'access-policy-invalid',
|
||||
});
|
||||
assert.deepEqual(store.accessPolicyFor('bot_one'), updated,
|
||||
'strict writes reject non-canonical payloads without changing the committed policy');
|
||||
await assert.rejects(controller.updateAccessPolicy('missing', initial), {
|
||||
code: 'workspace-bot-not-found',
|
||||
});
|
||||
});
|
||||
|
||||
test('workspace-aware controller cannot write a policy into a same-id rebound bot', async (t) => {
|
||||
const { path, defaultWorkspace } = await fixture(t);
|
||||
const store = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
|
||||
const oldPolicy = policy({ direct: allowlistScope([
|
||||
{ id: 'old-owner', canExecuteCommands: true },
|
||||
]) });
|
||||
const reboundPolicy = policy({ direct: allowlistScope([
|
||||
{ id: 'new-owner', canExecuteCommands: true },
|
||||
]) });
|
||||
await store.ensure('bot_rebound', { initialAccessPolicy: oldPolicy });
|
||||
let markStatusStarted;
|
||||
let releaseStatus;
|
||||
const statusStarted = new Promise((resolveStarted) => { markStatusStarted = resolveStarted; });
|
||||
const statusGate = new Promise((resolveStatus) => { releaseStatus = resolveStatus; });
|
||||
const controller = createWorkspaceAwareController({
|
||||
async status() {
|
||||
markStatusStarted();
|
||||
await statusGate;
|
||||
return { bots: [{ botId: 'bot_rebound' }] };
|
||||
},
|
||||
}, {
|
||||
workspaces: store,
|
||||
stateFor: async () => ({ async clearSessions() {} }),
|
||||
});
|
||||
|
||||
const updating = controller.updateAccessPolicy('bot_rebound', policy());
|
||||
await statusStarted;
|
||||
await store.remove('bot_rebound');
|
||||
await store.ensure('bot_rebound', { initialAccessPolicy: reboundPolicy });
|
||||
releaseStatus();
|
||||
|
||||
await assert.rejects(updating, { code: 'workspace-bot-not-found' });
|
||||
assert.deepEqual(store.accessPolicyFor('bot_rebound'), reboundPolicy);
|
||||
});
|
||||
35
test/channels/access-policy-fixture.mjs
Normal file
35
test/channels/access-policy-fixture.mjs
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
export { COMMAND_PERMISSION_DENIED_MESSAGE } from '../../src/channels/shared/inbound-access.mjs';
|
||||
|
||||
function scope(users = []) {
|
||||
return {
|
||||
mode: 'allowlist',
|
||||
open: {
|
||||
defaultCanExecuteCommands: false,
|
||||
commandPermissionOverrides: [],
|
||||
},
|
||||
allowlist: {
|
||||
users: users.map(({ id, canExecuteCommands = false }) => ({
|
||||
id,
|
||||
canExecuteCommands,
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function directAccessPolicy({
|
||||
users = [],
|
||||
privilegedIds = [],
|
||||
} = {}) {
|
||||
const privileged = new Set(privilegedIds);
|
||||
const settings = {
|
||||
direct: scope(users),
|
||||
group: scope(),
|
||||
};
|
||||
return {
|
||||
getSettings: () => settings,
|
||||
isPrivileged: (senderIds) => (
|
||||
(Array.isArray(senderIds) ? senderIds : [senderIds])
|
||||
.some((senderId) => privileged.has(senderId))
|
||||
),
|
||||
};
|
||||
}
|
||||
|
|
@ -26,6 +26,7 @@ test('client exposes the fixed DingTalk RPC channel and endpoint names', () => {
|
|||
setWorkspace: 'bot.workspace.set',
|
||||
setAgentPreset: 'bot.preset.set',
|
||||
setContextEnhancement: 'bot.context-enhancement.set',
|
||||
setAccessPolicy: 'bot.access-policy.set',
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -21,6 +21,10 @@ import {
|
|||
createOutboundArtifactTool,
|
||||
releaseOutboundArtifact,
|
||||
} from '../../../src/channels/shared/semantic/artifact.mjs';
|
||||
import {
|
||||
COMMAND_PERMISSION_DENIED_MESSAGE,
|
||||
directAccessPolicy,
|
||||
} from '../access-policy-fixture.mjs';
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
|
|
@ -681,6 +685,75 @@ test('DingTalk checks the group mention before downloading a picture', async ()
|
|||
assert.equal(asks, 0);
|
||||
});
|
||||
|
||||
test('DingTalk applies the unified access policy before attachments or Harness work', async () => {
|
||||
const fixture = stateFixture();
|
||||
fixture.sessions.set('p2p:staff-member', 'session-member');
|
||||
let downloads = 0;
|
||||
const harnessCalls = [];
|
||||
const sent = [];
|
||||
const accessPolicy = directAccessPolicy({
|
||||
users: [{ id: 'staff-member', canExecuteCommands: false }],
|
||||
privilegedIds: ['staff-owner'],
|
||||
});
|
||||
const bridge = new DingtalkHarnessBridge({
|
||||
api: {
|
||||
downloadImage: async () => {
|
||||
downloads += 1;
|
||||
return PNG_BYTES;
|
||||
},
|
||||
sendText: async ({ text }) => {
|
||||
sent.push(text);
|
||||
return { messageId: `dingtalk-policy-${sent.length}` };
|
||||
},
|
||||
},
|
||||
clientId: 'ding-client',
|
||||
clientSecret: 'host-secret',
|
||||
accessPolicy,
|
||||
harness: {
|
||||
sessionExists: async (sessionId) => {
|
||||
harnessCalls.push(['sessionExists', sessionId]);
|
||||
return true;
|
||||
},
|
||||
ask: async (sessionId, prompt) => {
|
||||
harnessCalls.push(['ask', sessionId, prompt]);
|
||||
return '白名单消息已处理';
|
||||
},
|
||||
},
|
||||
state: fixture.state,
|
||||
});
|
||||
|
||||
await bridge.accept(message('policy-blocked-picture', '', {
|
||||
senderStaffId: 'staff-blocked',
|
||||
msgtype: 'picture',
|
||||
text: undefined,
|
||||
content: { downloadCode: 'blocked-picture' },
|
||||
robotCode: 'robot-code',
|
||||
}));
|
||||
assert.equal(downloads, 0);
|
||||
assert.deepEqual(harnessCalls, []);
|
||||
assert.deepEqual(sent, []);
|
||||
|
||||
await bridge.accept(message('policy-member-text', '普通消息', {
|
||||
senderStaffId: 'staff-member',
|
||||
}));
|
||||
assert.equal(harnessCalls.some(([operation]) => operation === 'ask'), true);
|
||||
assert.deepEqual(sent, ['白名单消息已处理']);
|
||||
|
||||
const callsBeforeDeniedCommand = harnessCalls.length;
|
||||
const repliesBeforeDeniedCommand = sent.length;
|
||||
await bridge.accept(message('policy-member-command', '/help', {
|
||||
senderStaffId: 'staff-member',
|
||||
}));
|
||||
assert.equal(harnessCalls.length, callsBeforeDeniedCommand);
|
||||
assert.deepEqual(sent.slice(repliesBeforeDeniedCommand), [COMMAND_PERMISSION_DENIED_MESSAGE]);
|
||||
|
||||
accessPolicy.getSettings().direct.allowlist.users = [];
|
||||
await bridge.accept(message('policy-owner-command', '/help', {
|
||||
senderStaffId: 'staff-owner',
|
||||
}));
|
||||
assert.match(sent.at(-1), /\/help/);
|
||||
});
|
||||
|
||||
test('DingTalk returns a specific retry message when picture download fails', async () => {
|
||||
const fixture = stateFixture();
|
||||
fixture.sessions.set('p2p:staff-approved', 'session-image');
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import {
|
|||
resolveDiscordMessageRoute,
|
||||
} from '../../../src/channels/discord/discord-runtime.mjs';
|
||||
import { setImHostLanguage } from '../../../src/channels/shared/i18n.mjs';
|
||||
import { COMMAND_PERMISSION_DENIED_MESSAGE } from '../../../src/channels/shared/inbound-access.mjs';
|
||||
import {
|
||||
DISCORD_ENDPOINTS,
|
||||
createDiscordRpcHandler,
|
||||
|
|
@ -1442,9 +1443,12 @@ test('Discord captures context settings before asynchronous Thread routing and u
|
|||
const routingStarted = deferred();
|
||||
const releaseRouting = deferred();
|
||||
const seen = new Set();
|
||||
const deliveries = [];
|
||||
const prompts = [];
|
||||
let socket;
|
||||
let reads = 0;
|
||||
let accessReads = 0;
|
||||
let threadStarts = 0;
|
||||
let config = {
|
||||
group: {
|
||||
enabled: true,
|
||||
|
|
@ -1453,6 +1457,24 @@ test('Discord captures context settings before asynchronous Thread routing and u
|
|||
},
|
||||
direct: { enabled: false, fields: [], guidance: 'direct must not leak' },
|
||||
};
|
||||
const allowedAccessSettings = {
|
||||
direct: {
|
||||
mode: 'open',
|
||||
open: { defaultCanExecuteCommands: true, commandPermissionOverrides: [] },
|
||||
allowlist: { users: [] },
|
||||
},
|
||||
group: {
|
||||
mode: 'allowlist',
|
||||
open: { defaultCanExecuteCommands: false, commandPermissionOverrides: [] },
|
||||
allowlist: {
|
||||
users: [
|
||||
{ id: '333333333333333333', canExecuteCommands: true },
|
||||
{ id: '333333333333333334', canExecuteCommands: false },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
let accessSettings = allowedAccessSettings;
|
||||
const runtime = new DiscordRuntime({
|
||||
config: { botId: 'discord_internal', platformId: botId, name: 'Harness Discord' },
|
||||
token: TOKEN,
|
||||
|
|
@ -1460,6 +1482,12 @@ test('Discord captures context settings before asynchronous Thread routing and u
|
|||
botId: 'discord_internal',
|
||||
getSettings: () => { reads += 1; return config; },
|
||||
},
|
||||
accessPolicy: {
|
||||
getSettings: () => {
|
||||
accessReads += 1;
|
||||
return accessSettings;
|
||||
},
|
||||
},
|
||||
harness: {
|
||||
ensureRunning: async () => true,
|
||||
sessionExists: async () => true,
|
||||
|
|
@ -1475,12 +1503,16 @@ test('Discord captures context settings before asynchronous Thread routing and u
|
|||
getGatewayBot: async () => ({ url: 'wss://gateway.discord.gg' }),
|
||||
getChannel: async () => assert.fail('The channel is already in the gateway cache'),
|
||||
startThreadFromMessage: async () => {
|
||||
threadStarts += 1;
|
||||
routingStarted.resolve();
|
||||
await releaseRouting.promise;
|
||||
return { id: threadId, type: 11, parent_id: parentId, owner_id: botId };
|
||||
},
|
||||
sendTyping: async () => {},
|
||||
createMessage: async () => ({ id: '888888888888888890' }),
|
||||
createMessage: async (request) => {
|
||||
deliveries.push(request);
|
||||
return { id: '888888888888888890' };
|
||||
},
|
||||
editMessage: async ({ messageId }) => ({ id: messageId }),
|
||||
}),
|
||||
createWebSocket: () => {
|
||||
|
|
@ -1498,13 +1530,37 @@ test('Discord captures context settings before asynchronous Thread routing and u
|
|||
socket.emit('message', { data: JSON.stringify({ op: 0, t: 'GUILD_CREATE', s: 2,
|
||||
d: { id: '444444444444444444', channels: [{ id: parentId, type: 0 }], threads: [] },
|
||||
}) });
|
||||
socket.emit('message', { data: JSON.stringify({ op: 0, t: 'MESSAGE_CREATE', s: 3, d: {
|
||||
id: '111111111111111189', channel_id: parentId, guild_id: '444444444444444444',
|
||||
author: { id: '333333333333333332', bot: false },
|
||||
mentions: [{ id: botId }], content: `<@${botId}> denied before Thread`,
|
||||
} }) });
|
||||
await eventually(() => runtime.status.messagesRejected === 1);
|
||||
assert.equal(threadStarts, 0, 'a denied member must not create a Discord Thread');
|
||||
socket.emit('message', { data: JSON.stringify({ op: 0, t: 'MESSAGE_CREATE', s: 4, d: {
|
||||
id: '111111111111111188', channel_id: parentId, guild_id: '444444444444444444',
|
||||
author: { id: '333333333333333334', bot: false },
|
||||
mentions: [{ id: botId }], content: `<@${botId}> /new`,
|
||||
} }) });
|
||||
await eventually(() => runtime.status.messagesRejected === 2);
|
||||
assert.equal(threadStarts, 0, 'a command-denied member must not create a Discord Thread');
|
||||
assert.equal(deliveries.at(-1)?.channelId, parentId);
|
||||
assert.equal(deliveries.at(-1)?.content, COMMAND_PERMISSION_DENIED_MESSAGE);
|
||||
socket.emit('message', { data: JSON.stringify({ op: 0, t: 'MESSAGE_CREATE', s: 3, d: {
|
||||
id: threadId, channel_id: parentId, guild_id: '444444444444444444',
|
||||
author: { id: '333333333333333333', bot: false, global_name: 'Global Name', username: 'username' },
|
||||
member: { nick: 'Group Nick' }, mentions: [{ id: botId }], content: `<@${botId}> first`,
|
||||
} }) });
|
||||
await routingStarted.promise;
|
||||
assert.equal(threadStarts, 1);
|
||||
config = { ...config, group: { ...config.group, enabled: false } };
|
||||
accessSettings = {
|
||||
...allowedAccessSettings,
|
||||
group: {
|
||||
...allowedAccessSettings.group,
|
||||
allowlist: { users: [] },
|
||||
},
|
||||
};
|
||||
releaseRouting.resolve();
|
||||
await eventually(() => runtime.status.messagesReplied === 1);
|
||||
assert.match(prompts[0], /accepted before routing/);
|
||||
|
|
@ -1513,7 +1569,10 @@ test('Discord captures context settings before asynchronous Thread routing and u
|
|||
senderName: 'Group Nick', botId: 'discord_internal',
|
||||
});
|
||||
assert.equal(reads, 1, 'routing and Bridge share one accepted configuration read');
|
||||
assert.equal(accessReads, 3,
|
||||
'each source event reads access once and the Thread keeps its arrival decision');
|
||||
|
||||
accessSettings = allowedAccessSettings;
|
||||
socket.emit('message', { data: JSON.stringify({ op: 0, t: 'MESSAGE_CREATE', s: 4, d: {
|
||||
id: '111111111111111191', channel_id: threadId, guild_id: '444444444444444444',
|
||||
author: { id: '333333333333333333', bot: false }, content: 'second without enhancement',
|
||||
|
|
@ -1521,6 +1580,7 @@ test('Discord captures context settings before asynchronous Thread routing and u
|
|||
await eventually(() => runtime.status.messagesReplied === 2);
|
||||
assert.equal(prompts[1], 'second without enhancement');
|
||||
assert.equal(reads, 2);
|
||||
assert.equal(accessReads, 4, 'the next managed-Thread event reads the latest policy once');
|
||||
});
|
||||
|
||||
test('Discord runtime records one uncertain Thread result and suppresses Gateway replays', async () => {
|
||||
|
|
|
|||
|
|
@ -15,6 +15,10 @@ import {
|
|||
OutboundArtifactRegistry,
|
||||
createOutboundArtifactTool,
|
||||
} from '../../../src/channels/shared/semantic/artifact.mjs';
|
||||
import {
|
||||
COMMAND_PERMISSION_DENIED_MESSAGE,
|
||||
directAccessPolicy,
|
||||
} from '../access-policy-fixture.mjs';
|
||||
|
||||
const PNG_1X1 = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
|
||||
|
|
@ -894,6 +898,79 @@ test('bridge downloads an inbound Feishu image once and submits structured Harne
|
|||
assert.deepEqual(sent, ['看到了一张图片']);
|
||||
});
|
||||
|
||||
test('Feishu applies the unified access policy before attachments or Harness work', async () => {
|
||||
const fixture = stateFixture([['p2p:ou_member', 'session-member']]);
|
||||
let downloads = 0;
|
||||
const harnessCalls = [];
|
||||
const sent = [];
|
||||
const accessPolicy = directAccessPolicy({
|
||||
users: [{ id: 'ou_member', canExecuteCommands: false }],
|
||||
privilegedIds: ['ou_owner'],
|
||||
});
|
||||
const client = {
|
||||
im: { v1: {
|
||||
messageResource: { get: async () => {
|
||||
downloads += 1;
|
||||
return { getReadableStream: () => Readable.from([PNG_1X1]) };
|
||||
} },
|
||||
message: { create: async (request) => {
|
||||
sent.push(JSON.parse(request.data.content).text);
|
||||
return { code: 0, data: { message_id: `om_policy_${sent.length}` } };
|
||||
} },
|
||||
} },
|
||||
};
|
||||
const bridge = new FeishuHarnessBridge({
|
||||
client,
|
||||
channel: {},
|
||||
accessPolicy,
|
||||
harness: {
|
||||
sessionExists: async (sessionId) => {
|
||||
harnessCalls.push(['sessionExists', sessionId]);
|
||||
return true;
|
||||
},
|
||||
ask: async (sessionId, prompt) => {
|
||||
harnessCalls.push(['ask', sessionId, prompt]);
|
||||
return '白名单消息已处理';
|
||||
},
|
||||
},
|
||||
state: fixture.state,
|
||||
status: bridgeStatus(),
|
||||
});
|
||||
|
||||
await bridge.accept(event('policy-blocked-image', '', {
|
||||
senderOpenId: 'ou_blocked',
|
||||
message_type: 'image',
|
||||
content: JSON.stringify({ image_key: 'img_blocked' }),
|
||||
}));
|
||||
await bridge.waitForIdle();
|
||||
assert.equal(downloads, 0);
|
||||
assert.deepEqual(harnessCalls, []);
|
||||
assert.deepEqual(sent, []);
|
||||
|
||||
await bridge.accept(event('policy-member-text', '普通消息', {
|
||||
senderOpenId: 'ou_member',
|
||||
}));
|
||||
await bridge.waitForIdle();
|
||||
assert.equal(harnessCalls.some(([operation]) => operation === 'ask'), true);
|
||||
assert.deepEqual(sent, ['白名单消息已处理']);
|
||||
|
||||
const callsBeforeDeniedCommand = harnessCalls.length;
|
||||
const repliesBeforeDeniedCommand = sent.length;
|
||||
await bridge.accept(event('policy-member-command', '/help', {
|
||||
senderOpenId: 'ou_member',
|
||||
}));
|
||||
await bridge.waitForIdle();
|
||||
assert.equal(harnessCalls.length, callsBeforeDeniedCommand);
|
||||
assert.deepEqual(sent.slice(repliesBeforeDeniedCommand), [COMMAND_PERMISSION_DENIED_MESSAGE]);
|
||||
|
||||
accessPolicy.getSettings().direct.allowlist.users = [];
|
||||
await bridge.accept(event('policy-owner-command', '/help', {
|
||||
senderOpenId: 'ou_owner',
|
||||
}));
|
||||
await bridge.waitForIdle();
|
||||
assert.match(sent.at(-1), /\/status/);
|
||||
});
|
||||
|
||||
test('bridge hands a native Feishu file source to the current Harness turn', async () => {
|
||||
const fixture = stateFixture([['p2p:ou_user', 'session-file']]);
|
||||
const bytes = Buffer.from('feishu-native-file');
|
||||
|
|
@ -3533,6 +3610,32 @@ test('card buttons from an unallowed sender are ignored', async () => {
|
|||
assert.equal(sent.length, 1, 'a card action without an operator must fail closed');
|
||||
});
|
||||
|
||||
test('a card callback without a trusted route stays silent before access evaluation', async () => {
|
||||
const sent = [];
|
||||
const bridge = new FeishuHarnessBridge({
|
||||
client: cardClient(async (outgoing) => sent.push(outgoing)),
|
||||
channel: {},
|
||||
accessPolicy: directAccessPolicy({
|
||||
users: [{ id: 'ou_member', canExecuteCommands: true }],
|
||||
privilegedIds: ['ou_owner'],
|
||||
}),
|
||||
harness: sessionsHarness(1),
|
||||
state: stateFixture().state,
|
||||
status: bridgeStatus(),
|
||||
});
|
||||
|
||||
await bridge.onCardAction({
|
||||
...cardActionEvent('om_stale_after_restart', 'new', 'ou_member'),
|
||||
context: {
|
||||
open_message_id: 'om_stale_after_restart',
|
||||
open_chat_id: 'oc_untrusted_scope',
|
||||
},
|
||||
});
|
||||
await bridge.waitForIdle();
|
||||
|
||||
assert.deepEqual(sent, [], 'missing direct/group scope must fail closed without a reply');
|
||||
});
|
||||
|
||||
test('card buttons from an allowed sender work', async () => {
|
||||
const fixture = stateFixture();
|
||||
const sent = [];
|
||||
|
|
|
|||
|
|
@ -17,6 +17,10 @@ import {
|
|||
OutboundArtifactRegistry,
|
||||
createOutboundArtifactTool,
|
||||
} from '../../../src/channels/shared/semantic/artifact.mjs';
|
||||
import {
|
||||
COMMAND_PERMISSION_DENIED_MESSAGE,
|
||||
directAccessPolicy,
|
||||
} from '../access-policy-fixture.mjs';
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
|
|
@ -475,6 +479,74 @@ test('QQ checks sender and group mention before downloading image attachments',
|
|||
assert.equal(asks, 0);
|
||||
});
|
||||
|
||||
test('QQ applies the unified access policy before attachments or Harness work', async () => {
|
||||
const fixture = stateFixture([['c2c:member-openid', 'session-member']]);
|
||||
let downloads = 0;
|
||||
const harnessCalls = [];
|
||||
const sent = [];
|
||||
const accessPolicy = directAccessPolicy({
|
||||
users: [{ id: 'member-openid', canExecuteCommands: false }],
|
||||
privilegedIds: ['owner-openid'],
|
||||
});
|
||||
const bridge = new QqHarnessBridge({
|
||||
bot: {
|
||||
sendText: async (_target, text) => {
|
||||
sent.push(text);
|
||||
return { id: `qq-policy-${sent.length}` };
|
||||
},
|
||||
},
|
||||
ownerUserOpenid: 'owner-openid',
|
||||
accessPolicy,
|
||||
harness: {
|
||||
sessionExists: async (sessionId) => {
|
||||
harnessCalls.push(['sessionExists', sessionId]);
|
||||
return true;
|
||||
},
|
||||
ask: async (sessionId, prompt) => {
|
||||
harnessCalls.push(['ask', sessionId, prompt]);
|
||||
return '白名单消息已处理';
|
||||
},
|
||||
},
|
||||
state: fixture.state,
|
||||
fetchImpl: async () => {
|
||||
downloads += 1;
|
||||
return new Response(PNG_BYTES, { headers: { 'content-type': 'image/png' } });
|
||||
},
|
||||
});
|
||||
const directMessage = (messageId, senderId, content, overrides = {}) => message({
|
||||
messageId,
|
||||
senderId,
|
||||
content,
|
||||
replyTarget: { scope: 'c2c', targetId: senderId, msgId: messageId },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
await bridge.accept(directMessage('policy-blocked-image', 'blocked-openid', '', {
|
||||
attachments: [{
|
||||
content_type: 'image/png',
|
||||
filename: 'blocked.png',
|
||||
url: 'https://multimedia.nt.qq.com.cn/download/blocked',
|
||||
}],
|
||||
}));
|
||||
assert.equal(downloads, 0);
|
||||
assert.deepEqual(harnessCalls, []);
|
||||
assert.deepEqual(sent, []);
|
||||
|
||||
await bridge.accept(directMessage('policy-member-text', 'member-openid', '普通消息'));
|
||||
assert.equal(harnessCalls.some(([operation]) => operation === 'ask'), true);
|
||||
assert.deepEqual(sent, ['白名单消息已处理']);
|
||||
|
||||
const callsBeforeDeniedCommand = harnessCalls.length;
|
||||
const repliesBeforeDeniedCommand = sent.length;
|
||||
await bridge.accept(directMessage('policy-member-command', 'member-openid', '/help'));
|
||||
assert.equal(harnessCalls.length, callsBeforeDeniedCommand);
|
||||
assert.deepEqual(sent.slice(repliesBeforeDeniedCommand), [COMMAND_PERMISSION_DENIED_MESSAGE]);
|
||||
|
||||
accessPolicy.getSettings().direct.allowlist.users = [];
|
||||
await bridge.accept(directMessage('policy-owner-command', 'owner-openid', '/help'));
|
||||
assert.match(sent.at(-1), /\/help/);
|
||||
});
|
||||
|
||||
test('QQ rejects non-platform image URLs without fetching and returns a retryable image error', async () => {
|
||||
const fixture = stateFixture([['c2c:owner-openid', 'session-image']]);
|
||||
const sent = [];
|
||||
|
|
|
|||
65
test/channels/shared/command-permission.test.mjs
Normal file
65
test/channels/shared/command-permission.test.mjs
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { isSharedLocalCommand } from '../../../src/channels/shared/command-permission.mjs';
|
||||
import { evaluateInboundAccess } from '../../../src/channels/shared/inbound-access.mjs';
|
||||
|
||||
test('isSharedLocalCommand matches existing local command families', () => {
|
||||
for (const command of [
|
||||
'/help', '/status', '/new', '/version', '/stop', '/steer more',
|
||||
'/batch', '/send', '/cancel', '/history 3', '/workspace /tmp',
|
||||
'/workspacelist', '/sessionlist', '/sessions /tmp', '/session 2',
|
||||
'/compact', '/models', '/model 2', '/reasonings', '/reasoning high',
|
||||
'/presetlist', '/preset default',
|
||||
]) {
|
||||
assert.equal(isSharedLocalCommand(command), true, command);
|
||||
}
|
||||
});
|
||||
|
||||
test('isSharedLocalCommand leaves unknown and channel-specific slash text as ordinary prompts', () => {
|
||||
for (const text of [
|
||||
'/foo', '/help me', 'hello', '/', '',
|
||||
'/menu', '/repair verify', '/watch session-id', '/unwatch session-id',
|
||||
'/watchlist', '/archived off',
|
||||
]) {
|
||||
assert.equal(isSharedLocalCommand(text), false, text);
|
||||
}
|
||||
});
|
||||
|
||||
test('isSharedLocalCommand follows current media command routing', () => {
|
||||
assert.equal(isSharedLocalCommand('/history', { hasFiles: true }), true);
|
||||
assert.equal(isSharedLocalCommand('/batch', { hasFiles: true }), true);
|
||||
assert.equal(isSharedLocalCommand('/status', { hasImages: true }), false);
|
||||
assert.equal(isSharedLocalCommand('/workspace /tmp', { hasFiles: true }), false);
|
||||
assert.equal(isSharedLocalCommand('/stop', { hasImages: true }), true);
|
||||
assert.equal(isSharedLocalCommand('/stop', { hasFiles: true }), false);
|
||||
});
|
||||
|
||||
test('evaluateInboundAccess always preserves an original owner privilege', () => {
|
||||
const deniedPolicy = {
|
||||
direct: {
|
||||
mode: 'allowlist',
|
||||
open: { defaultCanExecuteCommands: false, commandPermissionOverrides: [] },
|
||||
allowlist: { users: [] },
|
||||
},
|
||||
group: {
|
||||
mode: 'allowlist',
|
||||
open: { defaultCanExecuteCommands: false, commandPermissionOverrides: [] },
|
||||
allowlist: { users: [] },
|
||||
},
|
||||
};
|
||||
const accessPolicy = {
|
||||
getSettings: () => deniedPolicy,
|
||||
isPrivileged: (senderIds) => senderIds === 'owner-id',
|
||||
};
|
||||
assert.deepEqual(evaluateInboundAccess(accessPolicy, {
|
||||
conversationType: 'group',
|
||||
senderIds: 'owner-id',
|
||||
text: '/status',
|
||||
}), { allowed: true, reason: 'privileged-sender' });
|
||||
assert.equal(evaluateInboundAccess(accessPolicy, {
|
||||
conversationType: 'group',
|
||||
senderIds: 'another-user',
|
||||
text: '/status',
|
||||
}).allowed, false);
|
||||
});
|
||||
|
|
@ -7,6 +7,7 @@ import manifest from '../../../package.json' with { type: 'json' };
|
|||
|
||||
import { DiscordHarnessBridge } from '../../../src/channels/discord/discord-bridge.mjs';
|
||||
import { connectionTestTarget } from '../../../src/channels/shared/connection-test.mjs';
|
||||
import { COMMAND_PERMISSION_DENIED_MESSAGE } from '../../../src/channels/shared/inbound-access.mjs';
|
||||
import { InboundFileError } from '../../../src/channels/shared/inbound-file.mjs';
|
||||
import {
|
||||
OUTBOUND_ARTIFACT_TOOL,
|
||||
|
|
@ -88,6 +89,21 @@ function message(messageId, content, overrides = {}) {
|
|||
};
|
||||
}
|
||||
|
||||
function accessPolicy({ canExecuteCommands = false } = {}) {
|
||||
return {
|
||||
direct: {
|
||||
mode: 'allowlist',
|
||||
open: { defaultCanExecuteCommands: false, commandPermissionOverrides: [] },
|
||||
allowlist: { users: [{ id: 'actor-a', canExecuteCommands }] },
|
||||
},
|
||||
group: {
|
||||
mode: 'allowlist',
|
||||
open: { defaultCanExecuteCommands: false, commandPermissionOverrides: [] },
|
||||
allowlist: { users: [] },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function questionInteraction({
|
||||
id = 'question-one',
|
||||
sessionId = 'session-one',
|
||||
|
|
@ -277,6 +293,84 @@ test('shared status reactions replace processing with success without joining th
|
|||
]);
|
||||
});
|
||||
|
||||
test('all four shared text channels enforce fail-closed live access before side effects', async () => {
|
||||
for (const [name, Bridge] of [
|
||||
['slack', SlackHarnessBridge],
|
||||
['telegram', TelegramHarnessBridge],
|
||||
['discord', DiscordHarnessBridge],
|
||||
['whatsapp', WhatsappHarnessBridge],
|
||||
]) {
|
||||
const fixture = stateFixture();
|
||||
const sent = [];
|
||||
const asks = [];
|
||||
let imageLoads = 0;
|
||||
let sessionClears = 0;
|
||||
let policyReadFails = true;
|
||||
let settings = null;
|
||||
const originalClearSession = fixture.state.clearSession.bind(fixture.state);
|
||||
fixture.state.clearSession = async (...args) => {
|
||||
sessionClears += 1;
|
||||
return originalClearSession(...args);
|
||||
};
|
||||
const bridge = new Bridge({
|
||||
accessPolicy: {
|
||||
getSettings() {
|
||||
if (policyReadFails) throw new Error('private policy read detail');
|
||||
return settings;
|
||||
},
|
||||
isPrivileged: (senderIds) => senderIds.includes('owner-a'),
|
||||
},
|
||||
bot: { sendText: async (_target, text) => sent.push(text) },
|
||||
state: fixture.state,
|
||||
harness: {
|
||||
createSession: async () => `session-access-${name}`,
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, content) => {
|
||||
asks.push(content);
|
||||
return `${name} allowed reply`;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await bridge.accept(message(`access-blocked-${name}`, 'blocked attachment', {
|
||||
images: [{
|
||||
mediaType: 'image/png',
|
||||
load: async () => {
|
||||
imageLoads += 1;
|
||||
return Buffer.from('must not load');
|
||||
},
|
||||
}],
|
||||
}));
|
||||
assert.equal(imageLoads, 0, `${name} authorizes before downloading attachments`);
|
||||
assert.deepEqual(asks, [], `${name} fail-closed denial never reaches Harness`);
|
||||
assert.equal(fixture.seen.has(`access-blocked-${name}`), true,
|
||||
`${name} records a denial for replay suppression`);
|
||||
|
||||
await bridge.accept(message(`access-owner-${name}`, '/help', { senderId: 'owner-a' }));
|
||||
assert.match(sent.at(-1), /\/help/, `${name} owner bypasses a failed policy read`);
|
||||
assert.deepEqual(asks, [], `${name} owner command remains local`);
|
||||
|
||||
policyReadFails = false;
|
||||
settings = accessPolicy();
|
||||
await bridge.accept(message(`access-blocked-${name}`, 'replayed after policy update'));
|
||||
assert.deepEqual(asks, [], `${name} a denied replay cannot bypass the new policy`);
|
||||
|
||||
await bridge.accept(message(`access-ordinary-${name}`, 'allowed ordinary message'));
|
||||
assert.equal(asks.length, 1, `${name} applies the live policy to a new event`);
|
||||
assert.equal(sent.at(-1), `${name} allowed reply`, `${name} keeps the normal reply path`);
|
||||
|
||||
await bridge.accept(message(`access-command-denied-${name}`, '/new'));
|
||||
assert.equal(asks.length, 1, `${name} denied command never reaches Harness`);
|
||||
assert.equal(sessionClears, 0, `${name} denied command has no command side effect`);
|
||||
assert.equal(sent.at(-1), COMMAND_PERMISSION_DENIED_MESSAGE, `${name} explains command denial`);
|
||||
|
||||
settings = accessPolicy({ canExecuteCommands: true });
|
||||
await bridge.accept(message(`access-command-allowed-${name}`, '/new'));
|
||||
assert.equal(sessionClears, 1, `${name} policy hot-update applies without rebuilding the bridge`);
|
||||
assert.equal(asks.length, 1, `${name} allowed local command is not a model prompt`);
|
||||
}
|
||||
});
|
||||
|
||||
test('runtime abort clears a queued interaction reply reaction instead of marking success', async () => {
|
||||
const fixture = stateFixture();
|
||||
const controller = new AbortController();
|
||||
|
|
|
|||
|
|
@ -1053,6 +1053,78 @@ class FakeSocket {
|
|||
}
|
||||
}
|
||||
|
||||
test('Slack runtime forwards the live access policy provider into its shared bridge', async () => {
|
||||
let socket;
|
||||
let stateWrites = 0;
|
||||
const runtime = new SlackRuntime({
|
||||
config: {
|
||||
botId: 'slack_access',
|
||||
platformId: 'T12345678:U12345678',
|
||||
name: 'DeepSeek Harness',
|
||||
},
|
||||
botToken: BOT_TOKEN,
|
||||
appToken: APP_TOKEN,
|
||||
harness: { ensureRunning: async () => true },
|
||||
state: {
|
||||
hasSeen: () => false,
|
||||
markSeen: async () => { stateWrites += 1; },
|
||||
},
|
||||
accessPolicy: {
|
||||
getSettings: () => ({
|
||||
direct: {
|
||||
mode: 'allowlist',
|
||||
open: { defaultCanExecuteCommands: false, commandPermissionOverrides: [] },
|
||||
allowlist: { users: [] },
|
||||
},
|
||||
group: {
|
||||
mode: 'open',
|
||||
open: { defaultCanExecuteCommands: true, commandPermissionOverrides: [] },
|
||||
allowlist: { users: [] },
|
||||
},
|
||||
}),
|
||||
},
|
||||
createApi: () => ({
|
||||
authTest: async () => ({ team_id: 'T12345678', user_id: 'U12345678' }),
|
||||
openConnection: async () => ({ url: 'wss://wss-primary.slack.com/link/?ticket=test' }),
|
||||
}),
|
||||
createWebSocket: () => {
|
||||
socket = new FakeSocket();
|
||||
queueMicrotask(() => socket.emit('message', {
|
||||
data: JSON.stringify({
|
||||
type: 'hello',
|
||||
connection_info: { app_id: 'A12345678' },
|
||||
}),
|
||||
}));
|
||||
return socket;
|
||||
},
|
||||
logger: { warn() {}, error(...args) { assert.fail(args.join(' ')); } },
|
||||
});
|
||||
|
||||
try {
|
||||
await runtime.start();
|
||||
socket.emit('message', {
|
||||
data: JSON.stringify({
|
||||
envelope_id: 'env-denied',
|
||||
type: 'events_api',
|
||||
payload: {
|
||||
type: 'event_callback',
|
||||
api_app_id: 'A12345678',
|
||||
event_id: 'Ev-denied',
|
||||
event: {
|
||||
type: 'message', channel_type: 'im', channel: 'D12345678',
|
||||
user: 'U00000000', ts: '1700000000.009', text: 'must stay local',
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
await eventually(() => runtime.status.messagesRejected === 1);
|
||||
assert.equal(stateWrites, 1, 'the denial is recorded only for replay suppression');
|
||||
assert.deepEqual(socket.sent.at(-1), { envelope_id: 'env-denied' });
|
||||
} finally {
|
||||
await runtime.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test('Slack runtime opens Socket Mode, acknowledges envelopes, and becomes ready', async () => {
|
||||
let socket;
|
||||
const abortMark = deferred();
|
||||
|
|
|
|||
|
|
@ -4,16 +4,12 @@ import test from 'node:test';
|
|||
|
||||
import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import TestRenderer from 'react-test-renderer';
|
||||
|
||||
import {
|
||||
TelegramAccessSettings,
|
||||
TelegramAccountCard,
|
||||
TelegramSettingsTab,
|
||||
} from '../../../plugin-src/client/channels/telegram/index.js';
|
||||
|
||||
const { act } = TestRenderer;
|
||||
|
||||
test('Telegram settings exposes a Bot Token action without a fake QR action', () => {
|
||||
const markup = renderToStaticMarkup(React.createElement(TelegramSettingsTab, {
|
||||
rpcCall: async () => ({ ok: true, value: { bots: [] } }),
|
||||
|
|
@ -44,125 +40,16 @@ test('Telegram account card matches the unified compact card layout', () => {
|
|||
assert.doesNotMatch(markup, /Bot API 长轮询|消息通道|dim-botMetric/);
|
||||
assert.match(markup, />检查连接</);
|
||||
assert.match(markup, />移除接入</);
|
||||
assert.match(markup, />访问设置</);
|
||||
assert.match(markup, /aria-label="Telegram 访问模式"/);
|
||||
assert.match(markup, />兼容模式(默认)</);
|
||||
assert.match(markup, /aria-label="更多机器人设置"/);
|
||||
assert.doesNotMatch(markup, /Telegram 访问模式|兼容模式(默认)|安全模式(私聊白名单)/);
|
||||
assert.doesNotMatch(markup, /dim-cardSummary/);
|
||||
});
|
||||
|
||||
test('Telegram access settings edits and saves one bot policy', async () => {
|
||||
const saved = [];
|
||||
let renderer;
|
||||
await act(async () => {
|
||||
renderer = TestRenderer.create(React.createElement(TelegramAccessSettings, {
|
||||
account: {
|
||||
botId: 'telegram_test',
|
||||
accessPolicy: { accessMode: 'compatible', allowedUsers: ['111111111'] },
|
||||
},
|
||||
onSave: async (policy) => saved.push(policy),
|
||||
}));
|
||||
});
|
||||
|
||||
const select = renderer.root.findByProps({ 'aria-label': 'Telegram 访问模式' });
|
||||
let textarea = renderer.root.findByProps({
|
||||
'aria-label': '允许私聊的 Telegram User ID',
|
||||
});
|
||||
assert.equal(textarea.props.disabled, true);
|
||||
await act(async () => {
|
||||
select.props.onChange({ target: { value: 'private-allowlist' } });
|
||||
});
|
||||
textarea = renderer.root.findByProps({
|
||||
'aria-label': '允许私聊的 Telegram User ID',
|
||||
});
|
||||
assert.equal(textarea.props.disabled, false);
|
||||
await act(async () => {
|
||||
textarea.props.onChange({ target: { value: '6087707998\n1202499116\n6087707998' } });
|
||||
});
|
||||
await act(async () => {
|
||||
select.props.onChange({ target: { value: 'compatible' } });
|
||||
});
|
||||
textarea = renderer.root.findByProps({
|
||||
'aria-label': '允许私聊的 Telegram User ID',
|
||||
});
|
||||
assert.equal(textarea.props.disabled, true);
|
||||
assert.equal(textarea.props.value, '6087707998\n1202499116\n6087707998');
|
||||
await act(async () => {
|
||||
select.props.onChange({ target: { value: 'private-allowlist' } });
|
||||
});
|
||||
textarea = renderer.root.findByProps({
|
||||
'aria-label': '允许私聊的 Telegram User ID',
|
||||
});
|
||||
assert.equal(textarea.props.disabled, false);
|
||||
assert.equal(textarea.props.value, '6087707998\n1202499116\n6087707998');
|
||||
assert.deepEqual(
|
||||
renderer.root.findByProps({ className: 'dtg-accessBadge' }).children,
|
||||
['已生效:兼容模式'],
|
||||
);
|
||||
await act(async () => {
|
||||
await renderer.root.findByType('form').props.onSubmit({ preventDefault() {} });
|
||||
});
|
||||
assert.deepEqual(saved, [{
|
||||
accessMode: 'private-allowlist',
|
||||
allowedUsers: ['6087707998', '1202499116'],
|
||||
}]);
|
||||
await act(async () => renderer.unmount());
|
||||
});
|
||||
|
||||
test('Telegram access settings keeps both mode descriptions in an accessible help tooltip', async () => {
|
||||
let renderer;
|
||||
await act(async () => {
|
||||
renderer = TestRenderer.create(React.createElement(TelegramAccessSettings, {
|
||||
account: {
|
||||
botId: 'telegram_test',
|
||||
accessPolicy: { accessMode: 'compatible', allowedUsers: ['111111111'] },
|
||||
},
|
||||
onSave() {},
|
||||
}));
|
||||
});
|
||||
const helpButton = renderer.root.findByProps({
|
||||
'aria-label': '查看 Telegram 访问模式说明',
|
||||
});
|
||||
const tooltip = renderer.root.findByProps({ role: 'tooltip' });
|
||||
const heading = renderer.root.findByProps({ className: 'dtg-accessHeading' });
|
||||
assert.equal(helpButton.props.type, 'button');
|
||||
assert.ok(tooltip.props.id);
|
||||
assert.equal(helpButton.props['aria-describedby'], tooltip.props.id);
|
||||
assert.equal(heading.findAllByType('p').length, 0);
|
||||
|
||||
const markup = renderToStaticMarkup(React.createElement(TelegramAccessSettings, {
|
||||
account: {
|
||||
botId: 'telegram_test',
|
||||
accessPolicy: { accessMode: 'compatible', allowedUsers: ['111111111'] },
|
||||
},
|
||||
onSave() {},
|
||||
}));
|
||||
assert.match(markup, />兼容模式<\/strong>/);
|
||||
assert.match(markup, />安全模式<\/strong>/);
|
||||
assert.match(markup, /保持原有行为:私聊直接响应,群聊在被提及或回复时响应。/);
|
||||
assert.match(markup, /群聊全部忽略,私聊仅允许白名单用户。/);
|
||||
await act(async () => renderer.unmount());
|
||||
});
|
||||
|
||||
test('Telegram access mode help opens for pointer hover and keyboard focus', async () => {
|
||||
const styles = await readFile(
|
||||
new URL('../../../plugin-src/client/channels/telegram/styles.js', import.meta.url),
|
||||
test('Telegram cards shrink to a narrow English panel without horizontal scrolling', async () => {
|
||||
const sharedStyles = await readFile(
|
||||
new URL('../../../plugin-src/client/styles.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(styles, /\.dtg-accessHeading \{[^}]*position: relative;/);
|
||||
assert.match(styles, /\.dtg-accessHelp \{[^}]*position: static;/);
|
||||
assert.match(styles, /\.dtg-accessTooltip \{[^}]*right: 0;[^}]*width: min\(300px, 100%\);[^}]*max-width: 100%;/);
|
||||
assert.match(styles, /\.dtg-accessHelpButton:focus-visible \{/);
|
||||
assert.match(styles, /\.dtg-accessHelp:hover \.dtg-accessTooltip, \.dtg-accessHelp:focus-within \.dtg-accessTooltip \{[^}]*opacity: 1;[^}]*visibility: visible;/);
|
||||
});
|
||||
|
||||
test('Telegram cards shrink to a narrow English panel without horizontal scrolling', async () => {
|
||||
const [sharedStyles, telegramStyles] = await Promise.all([
|
||||
readFile(new URL('../../../plugin-src/client/styles.js', import.meta.url), 'utf8'),
|
||||
readFile(
|
||||
new URL('../../../plugin-src/client/channels/telegram/styles.js', import.meta.url),
|
||||
'utf8',
|
||||
),
|
||||
]);
|
||||
|
||||
assert.match(sharedStyles, /\.dim-panel \.dim-botList \{[^}]*grid-template-columns: minmax\(0, 1fr\);/);
|
||||
assert.match(sharedStyles, /\.dim-panel \.dim-botCard \{[^}]*min-width: 0;[^}]*width: 100%;[^}]*max-width: 100%;[^}]*overflow: hidden;/);
|
||||
|
|
@ -170,19 +57,4 @@ test('Telegram cards shrink to a narrow English panel without horizontal scrolli
|
|||
assert.doesNotMatch(sharedStyles, /\.dim-panel \.dim-botCardTop \{ flex-direction: column;/);
|
||||
assert.match(sharedStyles, /\.dim-panel \.dim-workspacePath \{[^}]*overflow: hidden;[^}]*overflow-wrap: anywhere;[^}]*white-space: normal;/);
|
||||
assert.doesNotMatch(sharedStyles, /\.dim-panel \.dim-workspacePath \{[^}]*overflow-x: auto;/);
|
||||
assert.match(telegramStyles, /\.dtg-access \{[^}]*min-width: 0;[^}]*width: 100%;[^}]*max-width: 100%;/);
|
||||
assert.match(telegramStyles, /\.dtg-accessHeading \{[^}]*flex-wrap: wrap;/);
|
||||
assert.match(telegramStyles, /\.dtg-accessStatus \{[^}]*max-width: 100%;[^}]*flex-wrap: wrap;/);
|
||||
assert.match(telegramStyles, /\.dtg-accessField select, \.dtg-accessField textarea \{[^}]*min-width: 0;[^}]*max-width: 100%;/);
|
||||
});
|
||||
|
||||
test('Telegram access settings warns when safe mode has an empty allowlist', () => {
|
||||
const markup = renderToStaticMarkup(React.createElement(TelegramAccessSettings, {
|
||||
account: {
|
||||
botId: 'telegram_test',
|
||||
accessPolicy: { accessMode: 'private-allowlist', allowedUsers: [] },
|
||||
},
|
||||
onSave() {},
|
||||
}));
|
||||
assert.match(markup, /白名单为空;保存后该机器人会拒绝所有入站消息。/);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -902,7 +902,7 @@ test('Telegram queued policy update cannot persist after controller close begins
|
|||
assert.equal(configStore.get(botId).allowedUsers, undefined);
|
||||
});
|
||||
|
||||
test('Telegram RPC accepts only token binding and strips credential internals', async () => {
|
||||
test('Telegram RPC accepts the unified access policy and strips credential internals', async () => {
|
||||
const calls = [];
|
||||
const connectionTests = [];
|
||||
const controller = {
|
||||
|
|
@ -925,7 +925,7 @@ test('Telegram RPC accepts only token binding and strips credential internals',
|
|||
}),
|
||||
sendConnectionTest: async (botId) => { connectionTests.push(botId); },
|
||||
deleteBot: async () => ({ bots: [], totals: { configured: 0, connected: 0 } }),
|
||||
setAccessPolicy: async (botId, policy) => {
|
||||
updateAccessPolicy: async (botId, policy) => {
|
||||
calls.push({ botId, policy });
|
||||
return {
|
||||
bots: [{ botId, accessPolicy: policy }],
|
||||
|
|
@ -973,28 +973,35 @@ test('Telegram RPC accepts only token binding and strips credential internals',
|
|||
code: 'test-target-unavailable',
|
||||
});
|
||||
|
||||
const unifiedPolicy = {
|
||||
direct: {
|
||||
mode: 'allowlist',
|
||||
open: { defaultCanExecuteCommands: false, commandPermissionOverrides: [] },
|
||||
allowlist: { users: [{ id: '6087707998', canExecuteCommands: true }] },
|
||||
},
|
||||
group: {
|
||||
mode: 'open',
|
||||
open: { defaultCanExecuteCommands: true, commandPermissionOverrides: [] },
|
||||
allowlist: { users: [] },
|
||||
},
|
||||
};
|
||||
const access = await handler(TELEGRAM_ENDPOINTS.setAccessPolicy, {
|
||||
botId: 'telegram_123',
|
||||
accessMode: TELEGRAM_ACCESS_MODES.privateAllowlist,
|
||||
allowedUsers: ['6087707998', '6087707998'],
|
||||
policy: unifiedPolicy,
|
||||
});
|
||||
assert.equal(access.ok, true);
|
||||
assert.deepEqual(calls.at(-1), {
|
||||
botId: 'telegram_123',
|
||||
policy: {
|
||||
accessMode: TELEGRAM_ACCESS_MODES.privateAllowlist,
|
||||
allowedUsers: ['6087707998'],
|
||||
},
|
||||
policy: unifiedPolicy,
|
||||
});
|
||||
assert.equal((await handler(TELEGRAM_ENDPOINTS.setAccessPolicy, {
|
||||
botId: 'telegram_123',
|
||||
accessMode: TELEGRAM_ACCESS_MODES.privateAllowlist,
|
||||
allowedUsers: ['@username'],
|
||||
allowedUsers: ['6087707998'],
|
||||
})).error.code, 'bad-request');
|
||||
assert.equal((await handler(TELEGRAM_ENDPOINTS.setAccessPolicy, {
|
||||
botId: 'telegram_123',
|
||||
accessMode: TELEGRAM_ACCESS_MODES.compatible,
|
||||
allowedUsers: [],
|
||||
policy: unifiedPolicy,
|
||||
extra: true,
|
||||
})).error.code, 'bad-request');
|
||||
});
|
||||
|
|
@ -1009,7 +1016,7 @@ test('shared token RPC never sends a connection test after reconnect is cancelle
|
|||
reconnectBot: async () => reconnect,
|
||||
sendConnectionTest: async () => { sendCalls += 1; },
|
||||
deleteBot: async () => ({ bots: [] }),
|
||||
setAccessPolicy: async () => ({ bots: [] }),
|
||||
updateAccessPolicy: async () => ({ bots: [] }),
|
||||
};
|
||||
const abort = new AbortController();
|
||||
const result = createTelegramRpcHandler(controller)(TELEGRAM_ENDPOINTS.reconnectBot, {
|
||||
|
|
@ -1446,7 +1453,7 @@ test('Telegram runtime still starts when the command menu setup fails', async ()
|
|||
}
|
||||
});
|
||||
|
||||
test('Telegram runtime enforces the selected bot private allowlist', async () => {
|
||||
test('Telegram runtime enforces the unified direct and group access policy', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'dsh-im-telegram-allowlist-runtime-'));
|
||||
const state = await new TelegramStateStore(join(directory, 'state.json')).load();
|
||||
const asked = [];
|
||||
|
|
@ -1508,8 +1515,10 @@ test('Telegram runtime enforces the selected bot private allowlist', async () =>
|
|||
botId: 'telegram_allowlist',
|
||||
platformId: '123456789',
|
||||
username: 'HarnessBot',
|
||||
// Kept deliberately contradictory: legacy fields are migration input,
|
||||
// not a second active Runtime gate after unified policy injection.
|
||||
accessMode: TELEGRAM_ACCESS_MODES.privateAllowlist,
|
||||
allowedUsers: ['7'],
|
||||
allowedUsers: ['999'],
|
||||
},
|
||||
token: TOKEN,
|
||||
harness: {
|
||||
|
|
@ -1521,6 +1530,20 @@ test('Telegram runtime enforces the selected bot private allowlist', async () =>
|
|||
},
|
||||
},
|
||||
state,
|
||||
accessPolicy: {
|
||||
getSettings: () => ({
|
||||
direct: {
|
||||
mode: 'allowlist',
|
||||
open: { defaultCanExecuteCommands: false, commandPermissionOverrides: [] },
|
||||
allowlist: { users: [{ id: '7', canExecuteCommands: true }] },
|
||||
},
|
||||
group: {
|
||||
mode: 'allowlist',
|
||||
open: { defaultCanExecuteCommands: false, commandPermissionOverrides: [] },
|
||||
allowlist: { users: [] },
|
||||
},
|
||||
}),
|
||||
},
|
||||
createApi: () => fakeApi,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,10 @@ import {
|
|||
OutboundArtifactRegistry,
|
||||
createOutboundArtifactTool,
|
||||
} from '../../../src/channels/shared/semantic/artifact.mjs';
|
||||
import {
|
||||
COMMAND_PERMISSION_DENIED_MESSAGE,
|
||||
directAccessPolicy,
|
||||
} from '../access-policy-fixture.mjs';
|
||||
|
||||
const PNG_1X1 = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
|
||||
|
|
@ -707,6 +711,78 @@ test('Enterprise WeChat exposes native file callbacks through the SDK downloader
|
|||
}]);
|
||||
});
|
||||
|
||||
test('Enterprise WeChat applies the unified access policy before attachments or Harness work', async () => {
|
||||
const transport = testClient();
|
||||
let downloads = 0;
|
||||
const harnessCalls = [];
|
||||
const accessPolicy = directAccessPolicy({
|
||||
users: [{ id: 'member-1', canExecuteCommands: false }],
|
||||
privilegedIds: ['owner-1'],
|
||||
});
|
||||
transport.client.downloadFile = async () => {
|
||||
downloads += 1;
|
||||
return { buffer: PNG_1X1, filename: 'blocked.png' };
|
||||
};
|
||||
const bridge = new WecomHarnessBridge({
|
||||
client: transport.client,
|
||||
generateStreamId: (() => {
|
||||
let sequence = 0;
|
||||
return () => `policy-stream-${++sequence}`;
|
||||
})(),
|
||||
accessPolicy,
|
||||
harness: {
|
||||
sessionExists: async (sessionId) => {
|
||||
harnessCalls.push(['sessionExists', sessionId]);
|
||||
return true;
|
||||
},
|
||||
ask: async (sessionId, prompt) => {
|
||||
harnessCalls.push(['ask', sessionId, prompt]);
|
||||
return '白名单消息已处理';
|
||||
},
|
||||
},
|
||||
state: state(),
|
||||
});
|
||||
|
||||
await bridge.accept(frame({
|
||||
msgid: 'policy-blocked-image',
|
||||
from: { userid: 'blocked-1' },
|
||||
msgtype: 'image',
|
||||
text: undefined,
|
||||
image: { url: 'https://wecom.example/blocked', aeskey: 'blocked-key' },
|
||||
}));
|
||||
assert.equal(downloads, 0);
|
||||
assert.deepEqual(harnessCalls, []);
|
||||
assert.deepEqual(transport.streamed, []);
|
||||
assert.deepEqual(transport.active, []);
|
||||
|
||||
await bridge.accept(frame({
|
||||
msgid: 'policy-member-text',
|
||||
text: { content: '普通消息' },
|
||||
}));
|
||||
assert.equal(harnessCalls.some(([operation]) => operation === 'ask'), true);
|
||||
assert.equal(transport.streamed.at(-1).content, streamedAnswer('白名单消息已处理'));
|
||||
|
||||
const callsBeforeDeniedCommand = harnessCalls.length;
|
||||
const repliesBeforeDeniedCommand = transport.streamed.length;
|
||||
await bridge.accept(frame({
|
||||
msgid: 'policy-member-command',
|
||||
text: { content: '/help' },
|
||||
}));
|
||||
assert.equal(harnessCalls.length, callsBeforeDeniedCommand);
|
||||
assert.deepEqual(transport.streamed.slice(repliesBeforeDeniedCommand).map(({ content, finish }) => ({
|
||||
content,
|
||||
finish,
|
||||
})), [{ content: COMMAND_PERMISSION_DENIED_MESSAGE, finish: true }]);
|
||||
|
||||
accessPolicy.getSettings().direct.allowlist.users = [];
|
||||
await bridge.accept(frame({
|
||||
msgid: 'policy-owner-command',
|
||||
from: { userid: 'owner-1' },
|
||||
text: { content: '/help' },
|
||||
}));
|
||||
assert.match(transport.streamed.at(-1).content, /\/help/);
|
||||
});
|
||||
|
||||
test('Enterprise WeChat bridge hands its prefetched native file to the current Harness turn', async () => {
|
||||
const transport = testClient();
|
||||
const bytes = Buffer.from('wecom-bridge-file');
|
||||
|
|
|
|||
|
|
@ -16,6 +16,10 @@ import {
|
|||
createOutboundArtifactTool,
|
||||
releaseOutboundArtifact,
|
||||
} from '../../../src/channels/shared/semantic/artifact.mjs';
|
||||
import {
|
||||
COMMAND_PERMISSION_DENIED_MESSAGE,
|
||||
directAccessPolicy,
|
||||
} from '../access-policy-fixture.mjs';
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
|
|
@ -651,6 +655,68 @@ test('Weixin authorizes the sender before resolving encrypted image references',
|
|||
assert.equal(asks, 0);
|
||||
});
|
||||
|
||||
test('Weixin applies the unified access policy before attachments or Harness work', async () => {
|
||||
const fixture = stateFixture();
|
||||
fixture.sessions.set('p2p:member-user', 'session-member');
|
||||
let imageExtractions = 0;
|
||||
const harnessCalls = [];
|
||||
const sent = [];
|
||||
const accessPolicy = directAccessPolicy({
|
||||
users: [{ id: 'member-user', canExecuteCommands: false }],
|
||||
privilegedIds: ['owner-user'],
|
||||
});
|
||||
const bridge = new WeixinHarnessBridge({
|
||||
api: {
|
||||
inboundImages: (value) => {
|
||||
imageExtractions += 1;
|
||||
return value?.item_list?.some((item) => item?.image_item) ? [{ data: PNG_BYTES }] : [];
|
||||
},
|
||||
sendText: async (request) => sent.push(request.text),
|
||||
},
|
||||
baseUrl: 'https://ilinkai.weixin.qq.com/',
|
||||
token: 'host-token',
|
||||
ownerUserId: 'owner-user',
|
||||
accessPolicy,
|
||||
harness: {
|
||||
sessionExists: async (sessionId) => {
|
||||
harnessCalls.push(['sessionExists', sessionId]);
|
||||
return true;
|
||||
},
|
||||
ask: async (sessionId, prompt) => {
|
||||
harnessCalls.push(['ask', sessionId, prompt]);
|
||||
return '白名单消息已处理';
|
||||
},
|
||||
},
|
||||
state: fixture.state,
|
||||
});
|
||||
|
||||
await bridge.accept(message('policy-blocked-image', '', {
|
||||
from_user_id: 'blocked-user',
|
||||
item_list: [{ type: 2, image_item: { media: {} } }],
|
||||
}));
|
||||
assert.equal(imageExtractions, 0);
|
||||
assert.deepEqual(harnessCalls, []);
|
||||
assert.deepEqual(sent, []);
|
||||
|
||||
await bridge.accept(message('policy-member-text', '普通消息', {
|
||||
from_user_id: 'member-user',
|
||||
}));
|
||||
assert.equal(harnessCalls.some(([operation]) => operation === 'ask'), true);
|
||||
assert.deepEqual(sent, ['白名单消息已处理']);
|
||||
|
||||
const callsBeforeDeniedCommand = harnessCalls.length;
|
||||
const repliesBeforeDeniedCommand = sent.length;
|
||||
await bridge.accept(message('policy-member-command', '/help', {
|
||||
from_user_id: 'member-user',
|
||||
}));
|
||||
assert.equal(harnessCalls.length, callsBeforeDeniedCommand);
|
||||
assert.deepEqual(sent.slice(repliesBeforeDeniedCommand), [COMMAND_PERMISSION_DENIED_MESSAGE]);
|
||||
|
||||
accessPolicy.getSettings().direct.allowlist.users = [];
|
||||
await bridge.accept(message('policy-owner-command', '/help'));
|
||||
assert.match(sent.at(-1), /\/help/);
|
||||
});
|
||||
|
||||
test('Weixin returns a specific retry message when encrypted image loading fails', async () => {
|
||||
const fixture = stateFixture();
|
||||
fixture.sessions.set('p2p:owner-user', 'session-image');
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ import {
|
|||
EmptyView,
|
||||
ProvisionView,
|
||||
QrPanel,
|
||||
WhatsappAccessSettings,
|
||||
WhatsappAccountCard,
|
||||
WhatsappSettingsTab,
|
||||
} from '../../../plugin-src/client/channels/whatsapp/index.js';
|
||||
|
|
@ -72,79 +71,18 @@ test('WhatsApp account card uses the unified compact channel layout', () => {
|
|||
assert.match(markup, /检查连接/);
|
||||
assert.match(markup, /移除接入/);
|
||||
assert.match(markup, /class="dim-presetSelect"/);
|
||||
assert.match(markup, /仅自己模式(默认)/);
|
||||
assert.match(markup, /指定联系人模式/);
|
||||
assert.match(markup, /开放响应模式/);
|
||||
assert.match(markup, /已绑定账号自己发出的群聊消息/);
|
||||
assert.match(markup, /aria-label="更多机器人设置"/);
|
||||
assert.doesNotMatch(markup, /仅自己模式(默认)|指定联系人模式|开放响应模式/);
|
||||
assert.match(markup, /role="status"[^>]*>测试消息已发送/);
|
||||
});
|
||||
|
||||
test('WhatsApp access settings save a normalized selected-contact allowlist', async () => {
|
||||
const saved = [];
|
||||
let renderer;
|
||||
await act(async () => {
|
||||
renderer = create(React.createElement(WhatsappAccessSettings, {
|
||||
account: {
|
||||
accessPolicy: { accessMode: 'self-only', allowedNumbers: [] },
|
||||
},
|
||||
onSave: async (value) => saved.push(value),
|
||||
}));
|
||||
});
|
||||
const select = renderer.root.findByProps({ 'aria-label': 'WhatsApp 访问模式' });
|
||||
await act(async () => {
|
||||
select.props.onChange({ target: { value: 'private-allowlist' } });
|
||||
});
|
||||
const textarea = renderer.root.findByProps({
|
||||
'aria-label': '允许私聊的 WhatsApp 电话号码',
|
||||
});
|
||||
await act(async () => {
|
||||
textarea.props.onChange({ target: { value: '+16505550999\n16505550999' } });
|
||||
});
|
||||
await act(async () => {
|
||||
renderer.root.findByType('form').props.onSubmit({ preventDefault() {} });
|
||||
await flushMicrotasks();
|
||||
});
|
||||
assert.deepEqual(saved, [{
|
||||
accessMode: 'private-allowlist',
|
||||
allowedNumbers: ['16505550999'],
|
||||
}]);
|
||||
await act(async () => { renderer.unmount(); });
|
||||
});
|
||||
|
||||
test('WhatsApp access settings only show the allowlist for selected contacts', async () => {
|
||||
let renderer;
|
||||
await act(async () => {
|
||||
renderer = create(React.createElement(WhatsappAccessSettings, {
|
||||
account: {
|
||||
accessPolicy: { accessMode: 'self-only', allowedNumbers: ['16505550999'] },
|
||||
},
|
||||
onSave: async () => {},
|
||||
}));
|
||||
});
|
||||
const select = renderer.root.findByProps({ 'aria-label': 'WhatsApp 访问模式' });
|
||||
const allowlistFields = () => renderer.root.findAllByProps({
|
||||
'aria-label': '允许私聊的 WhatsApp 电话号码',
|
||||
});
|
||||
|
||||
assert.equal(allowlistFields().length, 0);
|
||||
await act(async () => {
|
||||
select.props.onChange({ target: { value: 'private-allowlist' } });
|
||||
});
|
||||
assert.equal(allowlistFields().length, 1);
|
||||
await act(async () => {
|
||||
select.props.onChange({ target: { value: 'open' } });
|
||||
});
|
||||
assert.equal(allowlistFields().length, 0);
|
||||
await act(async () => { renderer.unmount(); });
|
||||
});
|
||||
|
||||
test('WhatsApp connection check requests a test message from the existing reconnect endpoint', async () => {
|
||||
const source = await readFile(new URL(
|
||||
'../../../plugin-src/client/channels/whatsapp/index.js',
|
||||
import.meta.url,
|
||||
), 'utf8');
|
||||
assert.match(source, /WHATSAPP_ENDPOINTS\.reconnectBot,[\s\S]*\{ botId: account\.botId, sendTest: true \}/);
|
||||
assert.match(source, /WHATSAPP_ENDPOINTS\.setAccessPolicy/);
|
||||
assert.doesNotMatch(source, /WHATSAPP_ENDPOINTS\.setAccessPolicy/);
|
||||
assert.match(source, /\[account\.botId\]: '连接检查失败,请稍后重试。'/);
|
||||
assert.doesNotMatch(source, /连接检查失败:\$\{presentError\(error\)\.message\}/);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -51,7 +51,10 @@ test('WhatsApp production has no per-bot result-file Gate', async (t) => {
|
|||
const production = await createProductionController(ctx, { dataDir }, internals);
|
||||
await controllerOptions.createRuntime({
|
||||
botId: 'whatsapp_enabled',
|
||||
config: { botId: 'whatsapp_enabled' },
|
||||
config: {
|
||||
botId: 'whatsapp_enabled',
|
||||
accountJid: '16505550123@s.whatsapp.net',
|
||||
},
|
||||
authDir: '00000000-0000-4000-8000-000000000001',
|
||||
});
|
||||
await controllerOptions.createRuntime({
|
||||
|
|
@ -62,6 +65,9 @@ test('WhatsApp production has no per-bot result-file Gate', async (t) => {
|
|||
|
||||
assert.equal(Object.hasOwn(runtimes[0], 'outboundArtifactsEnabled'), false);
|
||||
assert.equal(Object.hasOwn(runtimes[1], 'outboundArtifactsEnabled'), false);
|
||||
assert.equal(runtimes[0].accessPolicy.isPrivileged(['+16505550123'], 'direct'), true,
|
||||
'production privileged matching uses the same bare-number normalization');
|
||||
assert.equal(runtimes[0].accessPolicy.isPrivileged(['not-a-jid'], 'direct'), false);
|
||||
await production.close();
|
||||
|
||||
const productionWithDefault = await createProductionController(ctx, { dataDir }, internals);
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import {
|
|||
WhatsappRuntime,
|
||||
createWhatsappMediaDownloader,
|
||||
normalizeWhatsappMessage,
|
||||
whatsappAccessPolicyIdsEqual,
|
||||
whatsappInboundAllowed,
|
||||
} from '../../../src/channels/whatsapp/whatsapp-runtime.mjs';
|
||||
import { createWhatsappWebSession } from '../../../src/channels/whatsapp/whatsapp-web-session.mjs';
|
||||
|
|
@ -146,6 +147,29 @@ function linkedConfig(overrides = {}) {
|
|||
};
|
||||
}
|
||||
|
||||
test('WhatsApp access-policy equality accepts phone and user-JID aliases and rejects invalid ids', () => {
|
||||
assert.equal(whatsappAccessPolicyIdsEqual(
|
||||
'16505550999', '16505550999@s.whatsapp.net',
|
||||
), true, 'a bare phone number matches its PN JID');
|
||||
assert.equal(whatsappAccessPolicyIdsEqual(
|
||||
'+16505550999', '16505550999@s.whatsapp.net',
|
||||
), true, 'a +number matches its PN JID');
|
||||
assert.equal(whatsappAccessPolicyIdsEqual(
|
||||
'16505550999@s.whatsapp.net', '16505550999:4@s.whatsapp.net',
|
||||
), true, 'full and device-qualified PN JIDs retain Baileys alias matching');
|
||||
assert.equal(whatsappAccessPolicyIdsEqual(
|
||||
'987654321098765@lid', '987654321098765@s.whatsapp.net',
|
||||
), true, 'PN and LID aliases retain Baileys user matching');
|
||||
assert.equal(whatsappAccessPolicyIdsEqual(
|
||||
'16505550999', '16505550888@s.whatsapp.net',
|
||||
), false);
|
||||
for (const invalid of [undefined, null, '', 'not-a-jid', 'bad@', '@lid', '+']) {
|
||||
assert.equal(whatsappAccessPolicyIdsEqual(invalid, invalid), false,
|
||||
`invalid id must fail closed: ${String(invalid)}`);
|
||||
assert.equal(whatsappAccessPolicyIdsEqual(invalid, ACCOUNT_JID), false);
|
||||
}
|
||||
});
|
||||
|
||||
test('WhatsApp config stores only linked-device metadata with restrictive permissions', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-im-whatsapp-config-'));
|
||||
const path = join(root, 'config.json');
|
||||
|
|
@ -616,8 +640,20 @@ test('WhatsApp keeps native and document images as images and exposes ordinary d
|
|||
});
|
||||
});
|
||||
|
||||
test('WhatsApp runtime filters messages before the bridge and applies policy updates live', async () => {
|
||||
test('WhatsApp runtime uses live unified policy settings and existing JID alias matching', async () => {
|
||||
let callbacks;
|
||||
let accessSettings = {
|
||||
direct: {
|
||||
mode: 'allowlist',
|
||||
open: { defaultCanExecuteCommands: false, commandPermissionOverrides: [] },
|
||||
allowlist: { users: [] },
|
||||
},
|
||||
group: {
|
||||
mode: 'allowlist',
|
||||
open: { defaultCanExecuteCommands: false, commandPermissionOverrides: [] },
|
||||
allowlist: { users: [] },
|
||||
},
|
||||
};
|
||||
const calls = [];
|
||||
const socket = {
|
||||
sendPresenceUpdate: async (...args) => calls.push(['presence', ...args]),
|
||||
|
|
@ -643,6 +679,10 @@ test('WhatsApp runtime filters messages before the bridge and applies policy upd
|
|||
authDir: '/tmp/test-whatsapp-auth',
|
||||
harness,
|
||||
state,
|
||||
accessPolicy: {
|
||||
getSettings: () => accessSettings,
|
||||
isPrivileged: (senderIds) => senderIds.includes(ACCOUNT_JID),
|
||||
},
|
||||
createSession: async (options) => {
|
||||
callbacks = options;
|
||||
return {
|
||||
|
|
@ -661,13 +701,38 @@ test('WhatsApp runtime filters messages before the bridge and applies policy upd
|
|||
assert.equal(runtime.status.ready, true);
|
||||
assert.equal(runtime.status.messagesRejected, 1);
|
||||
assert.equal(calls.length, 0);
|
||||
runtime.setAccessPolicy({ accessMode: WHATSAPP_ACCESS_MODES.open, allowedNumbers: [] });
|
||||
await callbacks.onMessage({
|
||||
key: { remoteJid: '16505550999@s.whatsapp.net', id: 'direct-3', fromMe: false },
|
||||
key: { remoteJid: ACCOUNT_JID, id: 'owner-1', fromMe: true },
|
||||
message: { conversation: 'owner bypass' },
|
||||
});
|
||||
assert.ok(calls.some((call) => call[0] === 'message'
|
||||
&& call[2].text === 'Harness answer'), 'linked owner bypasses an empty allowlist');
|
||||
accessSettings = {
|
||||
...accessSettings,
|
||||
direct: {
|
||||
mode: 'allowlist',
|
||||
open: { defaultCanExecuteCommands: false, commandPermissionOverrides: [] },
|
||||
allowlist: {
|
||||
users: [{ id: '16505550999', canExecuteCommands: true }],
|
||||
},
|
||||
},
|
||||
};
|
||||
const answerCountBeforeAlternate = calls.filter((call) => (
|
||||
call[0] === 'message' && call[2].text === 'Harness answer'
|
||||
)).length;
|
||||
await callbacks.onMessage({
|
||||
key: {
|
||||
remoteJid: '987654321098765@lid',
|
||||
remoteJidAlt: '16505550999@s.whatsapp.net',
|
||||
id: 'direct-3',
|
||||
fromMe: false,
|
||||
},
|
||||
message: { conversation: 'hello again' },
|
||||
});
|
||||
assert.ok(calls.some((call) => call[0] === 'presence' && call[1] === 'composing'));
|
||||
assert.ok(calls.some((call) => call[0] === 'message' && call[2].text === 'Harness answer'));
|
||||
assert.equal(calls.filter((call) => (
|
||||
call[0] === 'message' && call[2].text === 'Harness answer'
|
||||
)).length, answerCountBeforeAlternate + 1,
|
||||
'a bare allowlist number matches the PN alternate for an inbound LID');
|
||||
await runtime.stop();
|
||||
});
|
||||
|
||||
|
|
@ -1424,7 +1489,7 @@ test('WhatsApp reconnect RPC sends tests only for the connected target and keeps
|
|||
cancelProvisioning: async () => null,
|
||||
reconnectBot: async () => snapshot(),
|
||||
deleteBot: async () => snapshot(),
|
||||
setAccessPolicy: async () => snapshot(),
|
||||
updateAccessPolicy: async () => snapshot(),
|
||||
sendConnectionTest: async () => {
|
||||
sendCalls += 1;
|
||||
if (sendFailure) throw new Error('private provider failure');
|
||||
|
|
@ -1490,7 +1555,7 @@ test('WhatsApp RPC never sends a connection test after reconnect is cancelled',
|
|||
reconnectBot: async () => reconnect,
|
||||
sendConnectionTest: async () => { sendCalls += 1; },
|
||||
deleteBot: async () => ({ bots: [] }),
|
||||
setAccessPolicy: async () => ({ bots: [] }),
|
||||
updateAccessPolicy: async () => ({ bots: [] }),
|
||||
};
|
||||
const abort = new AbortController();
|
||||
const result = createWhatsappRpcHandler(controller)(WHATSAPP_ENDPOINTS.reconnectBot, {
|
||||
|
|
@ -1533,10 +1598,6 @@ test('WhatsApp QR controller and RPC keep the raw QR and linked identity host-on
|
|||
},
|
||||
start: async () => {},
|
||||
stop: async () => {},
|
||||
setAccessPolicy: (value) => appliedPolicies.push({
|
||||
accessMode: value.accessMode,
|
||||
allowedNumbers: value.allowedNumbers,
|
||||
}),
|
||||
}),
|
||||
deleteAuth: async (name) => deletedAuth.push(name),
|
||||
});
|
||||
|
|
@ -1544,6 +1605,17 @@ test('WhatsApp QR controller and RPC keep the raw QR and linked identity host-on
|
|||
const handler = createWhatsappRpcHandler(controller, {
|
||||
encodeQr: async () => 'data:image/png;base64,QUJDRA==',
|
||||
});
|
||||
controller.updateAccessPolicy = async (botId, policy, projectStatus) => {
|
||||
appliedPolicies.push(policy);
|
||||
const current = await controller.status();
|
||||
const updated = {
|
||||
...current,
|
||||
bots: current.bots.map((bot) => bot.botId === botId
|
||||
? { ...bot, accessPolicy: policy }
|
||||
: bot),
|
||||
};
|
||||
return projectStatus ? projectStatus(updated) : updated;
|
||||
};
|
||||
const started = await handler(WHATSAPP_ENDPOINTS.beginProvisioning, {});
|
||||
assert.equal(started.ok, true);
|
||||
assert.match(started.value.qrCodeDataUrl, /^data:image\/png/);
|
||||
|
|
@ -1562,20 +1634,30 @@ test('WhatsApp QR controller and RPC keep the raw QR and linked identity host-on
|
|||
allowedNumbers: [],
|
||||
});
|
||||
assert.doesNotMatch(JSON.stringify(status.value), /16505550123@s\.whatsapp\.net|authDirectory/);
|
||||
const unifiedPolicy = {
|
||||
direct: {
|
||||
mode: 'allowlist',
|
||||
open: { defaultCanExecuteCommands: false, commandPermissionOverrides: [] },
|
||||
allowlist: {
|
||||
users: [{
|
||||
id: '16505550999@s.whatsapp.net',
|
||||
canExecuteCommands: true,
|
||||
}],
|
||||
},
|
||||
},
|
||||
group: {
|
||||
mode: 'allowlist',
|
||||
open: { defaultCanExecuteCommands: false, commandPermissionOverrides: [] },
|
||||
allowlist: { users: [] },
|
||||
},
|
||||
};
|
||||
const updated = await handler(WHATSAPP_ENDPOINTS.setAccessPolicy, {
|
||||
botId: status.value.bots[0].botId,
|
||||
accessMode: WHATSAPP_ACCESS_MODES.privateAllowlist,
|
||||
allowedNumbers: ['+16505550999'],
|
||||
policy: unifiedPolicy,
|
||||
});
|
||||
assert.equal(updated.ok, true);
|
||||
assert.deepEqual(updated.value.bots[0].accessPolicy, {
|
||||
accessMode: WHATSAPP_ACCESS_MODES.privateAllowlist,
|
||||
allowedNumbers: ['16505550999'],
|
||||
});
|
||||
assert.deepEqual(appliedPolicies, [{
|
||||
accessMode: WHATSAPP_ACCESS_MODES.privateAllowlist,
|
||||
allowedNumbers: ['16505550999'],
|
||||
}]);
|
||||
assert.deepEqual(updated.value.bots[0].accessPolicy, unifiedPolicy);
|
||||
assert.deepEqual(appliedPolicies, [unifiedPolicy]);
|
||||
const invalidPolicy = await handler(WHATSAPP_ENDPOINTS.setAccessPolicy, {
|
||||
botId: status.value.bots[0].botId,
|
||||
accessMode: 'compatible',
|
||||
|
|
|
|||
|
|
@ -5,6 +5,10 @@ import React from 'react';
|
|||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import TestRenderer from 'react-test-renderer';
|
||||
|
||||
import {
|
||||
ACCESS_CHANNEL_DEFINITIONS,
|
||||
ACCESS_POLICY_ENDPOINT,
|
||||
} from '../plugin-src/client/access-policy-settings.js';
|
||||
import {
|
||||
BOT_SETTINGS_TABS,
|
||||
DELIVERY_CHANNEL_DEFINITIONS,
|
||||
|
|
@ -40,6 +44,11 @@ function button(root, label) {
|
|||
return root.findAllByType('button').find((entry) => textOf(entry) === label);
|
||||
}
|
||||
|
||||
function accessHelpButtons(root) {
|
||||
return root.findAll((entry) => entry.type === 'button'
|
||||
&& String(entry.props['aria-label'] ?? '').endsWith('查看访问权限说明'));
|
||||
}
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
let reject;
|
||||
|
|
@ -69,10 +78,31 @@ const connectedAccount = Object.freeze({
|
|||
botId: 'bot_feishu_01',
|
||||
botName: '通知机器人',
|
||||
connected: true,
|
||||
accessPolicy: Object.freeze({
|
||||
direct: Object.freeze({
|
||||
mode: 'open',
|
||||
open: Object.freeze({
|
||||
defaultCanExecuteCommands: true,
|
||||
commandPermissionOverrides: Object.freeze([]),
|
||||
}),
|
||||
allowlist: Object.freeze({ users: Object.freeze([]) }),
|
||||
}),
|
||||
group: Object.freeze({
|
||||
mode: 'open',
|
||||
open: Object.freeze({
|
||||
defaultCanExecuteCommands: false,
|
||||
commandPermissionOverrides: Object.freeze([]),
|
||||
}),
|
||||
allowlist: Object.freeze({ users: Object.freeze([]) }),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
test('delivery settings define only the nine supported IM channel routes', () => {
|
||||
assert.deepEqual(BOT_SETTINGS_TABS, [{ id: 'delivery', label: '投递设置' }]);
|
||||
assert.deepEqual(BOT_SETTINGS_TABS, [
|
||||
{ id: 'delivery', label: '投递设置' },
|
||||
{ id: 'access', label: '访问设置' },
|
||||
]);
|
||||
assert.equal(DELIVERY_RPC_CHANNEL, '/dsh-im-delivery');
|
||||
assert.deepEqual(Object.keys(DELIVERY_CHANNEL_DEFINITIONS), [
|
||||
'weixin', 'feishu', 'dingtalk', 'wecom', 'qq',
|
||||
|
|
@ -91,6 +121,8 @@ test('delivery settings define only the nine supported IM channel routes', () =>
|
|||
['chatId', 'messageThreadId'],
|
||||
);
|
||||
assert.equal('office' in DELIVERY_CHANNEL_DEFINITIONS, false);
|
||||
assert.deepEqual(Object.keys(ACCESS_CHANNEL_DEFINITIONS), Object.keys(DELIVERY_CHANNEL_DEFINITIONS));
|
||||
assert.equal(ACCESS_CHANNEL_DEFINITIONS.weixin.groupSupported, false);
|
||||
});
|
||||
|
||||
test('all nine robot cards add one accessible settings gear beside existing content', () => {
|
||||
|
|
@ -147,6 +179,7 @@ test('the card gear opens a bot-scoped page in the current channel panel and ret
|
|||
state: 'connected',
|
||||
bot: { name: '微信通知助手', accountIdMasked: 'wx••01' },
|
||||
health: { status: 'healthy', summary: '微信连接正常', lastCheckedAt: Date.now() },
|
||||
accessPolicy: connectedAccount.accessPolicy,
|
||||
};
|
||||
const deliveryCalls = [];
|
||||
const renderer = await (async () => {
|
||||
|
|
@ -189,7 +222,9 @@ test('the card gear opens a bot-scoped page in the current channel panel and ret
|
|||
page.findByProps({ className: 'dim-deliveryHeader' }).findAllByType('h2').length,
|
||||
0,
|
||||
);
|
||||
const settingsTab = page.findByProps({ role: 'tab' });
|
||||
const settingsTabs = page.findAllByProps({ role: 'tab' });
|
||||
assert.deepEqual(settingsTabs.map(textOf), ['投递设置', '访问设置']);
|
||||
const settingsTab = settingsTabs[0];
|
||||
const settingsPanel = page.findByProps({ role: 'tabpanel' });
|
||||
assert.equal(textOf(settingsTab), '投递设置');
|
||||
assert.equal(settingsTab.props['aria-selected'], true);
|
||||
|
|
@ -226,6 +261,266 @@ test('the card gear opens a bot-scoped page in the current channel panel and ret
|
|||
assert.equal(renderer.root.findByProps({ id: 'dim-tab-weixin' }).props['aria-selected'], true);
|
||||
});
|
||||
|
||||
test('access settings preserve independent mode drafts and save direct and group atomically', async (t) => {
|
||||
const calls = [];
|
||||
const renderer = await mount(t, {
|
||||
channel: 'feishu',
|
||||
account: connectedAccount,
|
||||
rpcCall: async (endpoint) => {
|
||||
assert.equal(endpoint, DELIVERY_ENDPOINTS.list);
|
||||
return { ok: true, value: { targets: [] } };
|
||||
},
|
||||
accessRpcCall: async (endpoint, payload) => {
|
||||
calls.push({ endpoint, payload });
|
||||
return {
|
||||
ok: true,
|
||||
value: { bots: [{ botId: connectedAccount.botId, accessPolicy: payload.policy }] },
|
||||
};
|
||||
},
|
||||
onBack() {},
|
||||
});
|
||||
|
||||
assert.equal(accessHelpButtons(renderer.root).length, 0);
|
||||
await act(async () => {
|
||||
button(renderer.root, '访问设置').props.onClick();
|
||||
await flush();
|
||||
});
|
||||
|
||||
assert.equal(renderer.root.findAllByProps({ role: 'tab' }).length, 2);
|
||||
assert.equal(renderer.root.findAllByProps({ className: 'dim-accessScene' }).length, 2);
|
||||
assert.equal(renderer.root.findAllByProps({ className: 'dim-accessOwnerNotice' }).length, 0);
|
||||
assert.equal(accessHelpButtons(renderer.root).length, 2);
|
||||
for (const [scene, title] of [['direct', '私聊'], ['group', '群聊']]) {
|
||||
const sceneEditor = renderer.root.findByProps({ 'data-scene': scene });
|
||||
assert.equal(sceneEditor.props['aria-label'], title);
|
||||
const accessHelpButton = sceneEditor.findByProps({
|
||||
'aria-label': `${title} 查看访问权限说明`,
|
||||
});
|
||||
const accessHelpTooltip = sceneEditor.findByProps({
|
||||
className: 'dim-channelTooltip dim-accessHelpTooltip',
|
||||
});
|
||||
assert.equal(accessHelpTooltip.props.role, 'tooltip');
|
||||
assert.equal(accessHelpButton.props['aria-describedby'], accessHelpTooltip.props.id);
|
||||
assert.match(textOf(accessHelpTooltip), /原所有者或扫码接入者始终可以访问并执行命令/);
|
||||
}
|
||||
assert.equal(accessHelpButtons(renderer.root.findByProps({ className: 'dim-accessActions' })).length, 0);
|
||||
assert.equal(accessHelpButtons(renderer.root.findByProps({ role: 'tablist' })).length, 0);
|
||||
assert.match(
|
||||
textOf(renderer.root.findByProps({ 'data-scene': 'direct' })),
|
||||
/命令权限例外/,
|
||||
);
|
||||
await act(async () => {
|
||||
const direct = renderer.root.findByProps({ 'data-scene': 'direct' });
|
||||
const addUser = direct.findByProps({ 'aria-label': '私聊 新增用户' });
|
||||
assert.equal(addUser.props.title, '新增用户');
|
||||
assert.match(addUser.props.className, /dim-accessAddUser/);
|
||||
assert.equal(textOf(addUser), '+');
|
||||
addUser.props.onClick();
|
||||
await flush();
|
||||
});
|
||||
await act(async () => {
|
||||
renderer.root.findByProps({ 'aria-label': '私聊 飞书 Open ID 1' }).props.onChange({
|
||||
target: { value: ' ou_override ' },
|
||||
});
|
||||
renderer.root.findByProps({ 'aria-label': '群聊 默认命令权限' }).props.onChange({
|
||||
target: { value: 'allow' },
|
||||
});
|
||||
await flush();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
renderer.root.findByProps({ 'aria-label': '私聊 访问模式' }).props.onChange({
|
||||
target: { value: 'allowlist' },
|
||||
});
|
||||
await flush();
|
||||
});
|
||||
const directAllowlistHelp = renderer.root.findByProps({
|
||||
'aria-label': '私聊 查看白名单说明',
|
||||
});
|
||||
const directAllowlistTooltip = renderer.root.findByProps({
|
||||
className: 'dim-channelTooltip dim-accessEmptyAllowlistTooltip',
|
||||
});
|
||||
assert.equal(directAllowlistTooltip.props.role, 'tooltip');
|
||||
assert.equal(directAllowlistHelp.props['aria-describedby'], directAllowlistTooltip.props.id);
|
||||
assert.equal(
|
||||
textOf(directAllowlistTooltip),
|
||||
'当前没有白名单用户,保存后普通用户将无法使用机器人。',
|
||||
);
|
||||
assert.equal(renderer.root.findAllByProps({ className: 'dim-accessWarning' }).length, 0);
|
||||
assert.match(
|
||||
textOf(renderer.root.findByProps({ 'data-scene': 'direct' })),
|
||||
/白名单用户/,
|
||||
);
|
||||
assert.equal(
|
||||
renderer.root.findAllByProps({ 'aria-label': '私聊 默认命令权限' }).length,
|
||||
0,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
const direct = renderer.root.findByProps({ 'data-scene': 'direct' });
|
||||
direct.findByProps({ 'aria-label': '私聊 新增用户' }).props.onClick();
|
||||
await flush();
|
||||
});
|
||||
assert.equal(
|
||||
renderer.root.findAllByProps({ 'aria-label': '私聊 查看白名单说明' }).length,
|
||||
0,
|
||||
);
|
||||
await act(async () => {
|
||||
renderer.root.findByProps({ 'aria-label': '群聊 访问模式' }).props.onChange({
|
||||
target: { value: 'allowlist' },
|
||||
});
|
||||
await flush();
|
||||
});
|
||||
assert.ok(renderer.root.findByProps({ 'aria-label': '群聊 查看白名单说明' }));
|
||||
await act(async () => {
|
||||
renderer.root.findByProps({ 'aria-label': '群聊 访问模式' }).props.onChange({
|
||||
target: { value: 'open' },
|
||||
});
|
||||
await flush();
|
||||
});
|
||||
await act(async () => {
|
||||
renderer.root.findByProps({ 'aria-label': '私聊 飞书 Open ID 1' }).props.onChange({
|
||||
target: { value: ' ou_allowed ' },
|
||||
});
|
||||
await flush();
|
||||
});
|
||||
await act(async () => {
|
||||
renderer.root.findByProps({ 'aria-label': '私聊 用户 1 命令权限' }).props.onChange({
|
||||
target: { value: 'allow' },
|
||||
});
|
||||
await flush();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
renderer.root.findByProps({ 'aria-label': '私聊 访问模式' }).props.onChange({
|
||||
target: { value: 'open' },
|
||||
});
|
||||
await flush();
|
||||
});
|
||||
assert.equal(
|
||||
renderer.root.findByProps({ 'aria-label': '私聊 飞书 Open ID 1' }).props.value,
|
||||
' ou_override ',
|
||||
);
|
||||
assert.equal(
|
||||
renderer.root.findByProps({ 'aria-label': '私聊 用户 1 命令权限' }).props.value,
|
||||
'deny',
|
||||
);
|
||||
assert.equal(
|
||||
renderer.root.findByProps({ 'aria-label': '私聊 默认命令权限' }).props.value,
|
||||
'allow',
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
renderer.root.findByProps({ 'aria-label': '私聊 访问模式' }).props.onChange({
|
||||
target: { value: 'allowlist' },
|
||||
});
|
||||
await flush();
|
||||
});
|
||||
assert.equal(
|
||||
renderer.root.findByProps({ 'aria-label': '私聊 飞书 Open ID 1' }).props.value,
|
||||
' ou_allowed ',
|
||||
);
|
||||
assert.equal(
|
||||
renderer.root.findByProps({ 'aria-label': '私聊 用户 1 命令权限' }).props.value,
|
||||
'allow',
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
renderer.root.findByProps({ className: 'dim-accessPage' }).props.onSubmit({
|
||||
preventDefault() {},
|
||||
});
|
||||
await flush();
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, [{
|
||||
endpoint: ACCESS_POLICY_ENDPOINT,
|
||||
payload: {
|
||||
botId: connectedAccount.botId,
|
||||
policy: {
|
||||
direct: {
|
||||
mode: 'allowlist',
|
||||
open: {
|
||||
defaultCanExecuteCommands: true,
|
||||
commandPermissionOverrides: [{ id: 'ou_override', canExecuteCommands: false }],
|
||||
},
|
||||
allowlist: {
|
||||
users: [{ id: 'ou_allowed', canExecuteCommands: true }],
|
||||
},
|
||||
},
|
||||
group: {
|
||||
mode: 'open',
|
||||
open: {
|
||||
defaultCanExecuteCommands: true,
|
||||
commandPermissionOverrides: [],
|
||||
},
|
||||
allowlist: { users: [] },
|
||||
},
|
||||
},
|
||||
},
|
||||
}]);
|
||||
const feedback = renderer.root.findByProps({ className: 'dim-accessFeedback' });
|
||||
assert.match(textOf(feedback), /访问设置已保存/);
|
||||
assert.equal(feedback.props['data-tone'], 'success');
|
||||
assert.equal(feedback.props.role, 'status');
|
||||
|
||||
await act(async () => {
|
||||
button(renderer.root, '投递设置').props.onClick();
|
||||
await flush();
|
||||
});
|
||||
assert.equal(accessHelpButtons(renderer.root).length, 0);
|
||||
});
|
||||
|
||||
test('access settings keep a failed atomic save visible as an error', async (t) => {
|
||||
const renderer = await mount(t, {
|
||||
channel: 'feishu',
|
||||
account: connectedAccount,
|
||||
rpcCall: async () => ({ ok: true, value: { targets: [] } }),
|
||||
accessRpcCall: async () => ({
|
||||
ok: false,
|
||||
error: { code: 'access-policy-invalid', message: '访问策略未保存。' },
|
||||
}),
|
||||
onBack() {},
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
button(renderer.root, '访问设置').props.onClick();
|
||||
await flush();
|
||||
});
|
||||
await act(async () => {
|
||||
renderer.root.findByProps({ className: 'dim-accessPage' }).props.onSubmit({
|
||||
preventDefault() {},
|
||||
});
|
||||
await flush();
|
||||
});
|
||||
|
||||
const feedback = renderer.root.findByProps({ className: 'dim-accessFeedback' });
|
||||
assert.equal(textOf(feedback), '访问策略未保存。');
|
||||
assert.equal(feedback.props['data-tone'], 'error');
|
||||
assert.equal(feedback.props.role, 'alert');
|
||||
});
|
||||
|
||||
test('WeChat keeps the shared access page but disables its unsupported group section', async (t) => {
|
||||
const renderer = await mount(t, {
|
||||
channel: 'weixin',
|
||||
account: { ...connectedAccount, botId: 'wx_access_01' },
|
||||
rpcCall: async () => ({ ok: true, value: { targets: [] } }),
|
||||
accessRpcCall: async () => {
|
||||
throw new Error('save should not run in this rendering test');
|
||||
},
|
||||
onBack() {},
|
||||
});
|
||||
await act(async () => {
|
||||
button(renderer.root, '访问设置').props.onClick();
|
||||
await flush();
|
||||
});
|
||||
|
||||
const group = renderer.root.findByProps({ 'data-scene': 'group' });
|
||||
assert.equal(group.props.disabled, true);
|
||||
assert.match(textOf(group), /当前渠道不支持群聊/);
|
||||
assert.equal(group.findAllByType('select').length, 0);
|
||||
assert.ok(renderer.root.findByProps({ 'data-scene': 'direct' }));
|
||||
});
|
||||
|
||||
test('new target waits for saved targets before generating aliases or checking duplicates', async (t) => {
|
||||
const pending = deferred();
|
||||
const renderer = await mount(t, {
|
||||
|
|
@ -480,7 +775,10 @@ test('recent conversation names remain platform data in the English UI', async (
|
|||
docsLink.props.href,
|
||||
'https://github.com/xmanrui/dsh-im/blob/main/PROACTIVE_DELIVERY.en.md',
|
||||
);
|
||||
assert.equal(textOf(renderer.root.findByProps({ role: 'tab' })), 'Delivery settings');
|
||||
assert.deepEqual(
|
||||
renderer.root.findAllByProps({ role: 'tab' }).map(textOf),
|
||||
['Delivery settings', 'Access settings'],
|
||||
);
|
||||
});
|
||||
|
||||
test('target create, edit, copy, and delete use the minimal RPC payloads', async (t) => {
|
||||
|
|
|
|||
|
|
@ -32,6 +32,27 @@ const publicFailure = Object.freeze({
|
|||
at: Date.UTC(2026, 7, 25, 7, 30),
|
||||
});
|
||||
|
||||
const publicAccessPolicy = Object.freeze({
|
||||
direct: Object.freeze({
|
||||
mode: 'allowlist',
|
||||
open: Object.freeze({
|
||||
defaultCanExecuteCommands: false,
|
||||
commandPermissionOverrides: Object.freeze([]),
|
||||
}),
|
||||
allowlist: Object.freeze({
|
||||
users: Object.freeze([{ id: 'user_safe', canExecuteCommands: true }]),
|
||||
}),
|
||||
}),
|
||||
group: Object.freeze({
|
||||
mode: 'open',
|
||||
open: Object.freeze({
|
||||
defaultCanExecuteCommands: false,
|
||||
commandPermissionOverrides: Object.freeze([]),
|
||||
}),
|
||||
allowlist: Object.freeze({ users: Object.freeze([]) }),
|
||||
}),
|
||||
});
|
||||
|
||||
function rawBot() {
|
||||
return {
|
||||
botId: 'bot_safe',
|
||||
|
|
@ -40,6 +61,7 @@ function rawBot() {
|
|||
state: 'connected',
|
||||
workspace: '/workspace/current',
|
||||
groupResponseMode: 'mention',
|
||||
accessPolicy: publicAccessPolicy,
|
||||
bot: {
|
||||
name: 'Harness Bot',
|
||||
username: 'harness_bot',
|
||||
|
|
@ -107,6 +129,7 @@ test('all channel snapshot normalizers retain the same safe message failure', ()
|
|||
for (const [channel, normalize] of normalizers) {
|
||||
const snapshot = normalize({ bots: [rawBot()] });
|
||||
assert.deepEqual(snapshot.bots[0].lastMessageError, publicFailure, channel);
|
||||
assert.deepEqual(snapshot.bots[0].accessPolicy, publicAccessPolicy, channel);
|
||||
assert.doesNotMatch(
|
||||
JSON.stringify(snapshot.bots[0].lastMessageError),
|
||||
/providerDetail|private|token/i,
|
||||
|
|
|
|||
|
|
@ -286,9 +286,11 @@ test('all nine production channels use channel presets only as bot creation defa
|
|||
for (const path of PRODUCTION_FILES) {
|
||||
const source = await readFile(new URL(`../${path}`, import.meta.url), 'utf8');
|
||||
assert.doesNotMatch(source, /\bagentPreset:\s*config\.agentPreset/, path);
|
||||
const creationDefaults = source.match(
|
||||
/workspaces\.ensure\([^;]*\{\s*defaultAgentPreset:\s*config\.agentPreset,?\s*\}\)/g,
|
||||
) ?? [];
|
||||
const creationDefaults = (source.match(
|
||||
/workspaces\.ensure\((?:(?!workspaces\.ensure)[\s\S])*?\n\s*\}\)/g,
|
||||
) ?? []).filter((call) => (
|
||||
/\bdefaultAgentPreset:\s*config\.agentPreset\b/.test(call)
|
||||
));
|
||||
assert.equal(
|
||||
creationDefaults.length,
|
||||
2,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue