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
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',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue