From 22b327b676cdd4b0b9ef705da536d6a02c1c9487 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominique=20J=C3=A4ggi?= Date: Thu, 1 Feb 2024 07:48:35 +0100 Subject: [PATCH] feat: elevated slack client --- .../spacecat-shared-slack-client/README.md | 91 ++++++- .../src/clients/elevated-slack-client.js | 22 +- .../src/models/index.d.ts | 29 -- .../src/models/slack-channel.js | 12 - .../src/models/slack-team.js | 17 -- .../src/models/slack-user.js | 33 --- .../test/clients/base-slack-client.test.js | 73 ++--- .../clients/elevated-slack-client.test.js | 251 ++++++++++++++++++ .../test/index.test.js | 13 +- 9 files changed, 366 insertions(+), 175 deletions(-) create mode 100644 packages/spacecat-shared-slack-client/test/clients/elevated-slack-client.test.js diff --git a/packages/spacecat-shared-slack-client/README.md b/packages/spacecat-shared-slack-client/README.md index 72c8ff01..3c68e812 100644 --- a/packages/spacecat-shared-slack-client/README.md +++ b/packages/spacecat-shared-slack-client/README.md @@ -22,13 +22,38 @@ To configure tokens for your application, follow these steps: 4. For information on the scopes needed for each Slack API method, refer to the [documentation](https://api.slack.com/methods). The required scopes for each API method are listed in the "Bot tokens" row. As an example, to use the `postMessage` API method, the required scope is `chat:write`, as documented in [https://api.slack.com/methods/chat.postMessage](https://api.slack.com/methods/chat.postMessage). +### Scopes required for the current implementation +All Bot Tokens: +``` +chat:write +files:read +files:write +team:read +``` + +Scopes needed for elevated Bot: +``` +channels:manage (for public channels) +channels:read (check if user is in a channel or a channel exists) +channels:write.invites +channels:write.topic +groups:read (check if user is in a channel or a channel exists) +groups:write (for private channels) +groups:write.invites +groups:write.topic +users:read (to lookup users, required by users:read.email) +users:read.email (to lookup users by their emails) +``` ### Creating and instance from Helix UniversalContext ```js +import createFrom from '@adobe/spacecat-shared-slack-client'; + const context = {}; // Your Helix UniversalContext object const target = 'ADOBE_INTERNAL'; -const slackClient = SlackClient.createFrom(context, target); +const isElevated = false; // optional, defaults to false +const slackClient = createFrom(context, target, isElevated); ``` **Required env variables in Helix UniversalContext** @@ -40,15 +65,65 @@ SLACK_TOKEN_ADOBE_INTERNAL="slack bot token for the adobe internal org" SLACK_TOKEN_ADOBE_EXTERNAL="slack bot token for the adobe external org" ``` +Additionally, when using the elevated slack client, the following environment variables are required: + +``` +SLACK_TOKEN_ADOBE_INTERNAL_ELEVATED="slack bot token for the adobe internal org" +SLACK_TOKEN_ADOBE_EXTERNAL_ELEVATED="slack bot token for the adobe external org" +SLACK_OPS_CHANNEL_ADOBE_INTERNAL="slack channel id for the ops channel to which status and action required messages are sent" +SLACK_OPS_CHANNEL_ADOBE_EXTERNAL="slack channel id for the ops channel to which status and action required messages are sent" +SLACK_OPS_ADMINS_ADOBE_INTERNAL="comma separated list of slack user ids who are invited to created channels" +SLACK_OPS_ADMINS_ADOBE_EXTERNAL="comma separated list of slack user ids who are invited to created channels" +``` + **Note**: if Helix UniversalContext object already contains a `slackClients` field, then `createFrom` factory method returns the previously created instance instead of creating a new one. ### Constructor -`SlackClient` class needs a slack bot token and a logger object: +`ElevatedSlackClient` or `BaseSlackClient` need a slack bot token, an ops config and a logger object: ```js const token = 'slack bot token'; -const slackClient = new SlackClient(token, console); +const opsConfig = { + channel: 'mandatory slack channel id for the ops channel to which status and action required messages are sent', + admins: 'optional comma separated list of slack user ids who are invited to created channels', +}; +const slackClient = new SlackClient(token, opsConfig, console); +``` + +### Channel Creation && Invitation + +#### Creating a channel + +```js +import createFrom, { SLACK_TARGETS } from '@adobe/spacecat-shared-slack-client'; + +const elevatedClient = createFrom(context, SLACK_TARGETS.ADOBE_EXTERNAL, true); +const channel = await elevatedClient.createChannel( + channelName, + 'This is a test topic', + 'This is a test description', + false, // public vs private channel +); +``` + +#### Inviting a user to a channel + +```js +import createFrom, { SLACK_TARGETS } from '@adobe/spacecat-shared-slack-client'; + +const elevatedClient = createFrom(context, SLACK_TARGETS.ADOBE_EXTERNAL, true); + +const result = await elevatedClient.inviteUsersByEmail(channel.getId(), [ + { + email: 'user1@email.com', + realName: 'User 1', + }, + { + email: 'user3@acme.com', + realName: 'User 2', + }, +]); ``` ### Posting a message @@ -56,12 +131,12 @@ const slackClient = new SlackClient(token, console); #### Posting a text message ```js -import { SlackClient, SLACK_TARGETS } from '@adobe/spacecat-shared-slack-client'; +import createFrom, { SLACK_TARGETS } from '@adobe/spacecat-shared-slack-client'; const channelId = 'channel-id'; // channel to send the message to const threadId = 'thread-id'; // thread id to send the message under (optional) -const internalSlackClient = SlackClient.createFrom(context, SLACK_TARGETS.ADOBE_INTERNAL); +const internalSlackClient = createFrom(context, SLACK_TARGETS.ADOBE_INTERNAL); await internalSlackClient.postMessage({ text: 'HELLO WORLD!', @@ -73,7 +148,7 @@ await internalSlackClient.postMessage({ #### Posting a simple text message using Slack Block Builder (recommended) ```js -import { SlackClient, SLACK_TARGETS } from '@adobe/spacecat-shared-slack-client'; +import createFrom, { SLACK_TARGETS } from '@adobe/spacecat-shared-slack-client'; import { Message, Blocks, Elements } from 'slack-block-builder'; const channelId = 'channel-id'; // channel to send the message to @@ -96,7 +171,7 @@ await internalSlackClient.postMessage(message); #### Posting a non-trivial message using Slack Block Builder (recommended) ```js -import { SlackClient, SLACK_TARGETS } from '@adobe/spacecat-shared-slack-client'; +import createFrom, { SLACK_TARGETS } from '@adobe/spacecat-shared-slack-client'; import { Message, Blocks, Elements } from 'slack-block-builder'; const channelId = 'channel-id'; // channel to send the message to @@ -136,7 +211,7 @@ await internalSlackClient.postMessage(message); ### Uploading a file ```js -import { SlackClient, SLACK_TARGETS } from '@adobe/spacecat-shared-slack-client'; +import createFrom, { SLACK_TARGETS } from '@adobe/spacecat-shared-slack-client'; const channelId = 'channel-id'; // channel to send the message to const threadId = 'thread-id'; // thread id to send the message under (optional) diff --git a/packages/spacecat-shared-slack-client/src/clients/elevated-slack-client.js b/packages/spacecat-shared-slack-client/src/clients/elevated-slack-client.js index 0be66eb4..077be336 100644 --- a/packages/spacecat-shared-slack-client/src/clients/elevated-slack-client.js +++ b/packages/spacecat-shared-slack-client/src/clients/elevated-slack-client.js @@ -120,7 +120,7 @@ export default class ElevatedSlackClient extends BaseSlackClient { const response = await this._apiCall('team.info'); return SlackTeam.create(response.team); } catch (e) { - this.log.error('Failed to retrieve workspace information', e); + this.log.error('Failed to retrieve team information', e); throw e; } } @@ -133,7 +133,6 @@ export default class ElevatedSlackClient extends BaseSlackClient { */ async #initialize() { if (this.isInitialized) { - this.log.debug('Slack client already initialized'); return; } @@ -183,12 +182,6 @@ export default class ElevatedSlackClient extends BaseSlackClient { * or null if no user with the specified email address was found. */ async #findUserByEmail(email) { - if (!hasText(email)) { - throw new Error('Email is required'); - } - - await this.#initialize(); - try { const response = await this._apiCall('users.lookupByEmail', { email }); return SlackUser.create(response.user); @@ -210,12 +203,6 @@ export default class ElevatedSlackClient extends BaseSlackClient { * @returns {Promise>} A promise resolving to an array of Channel objects. */ async #getUserChannels(userId) { - if (!hasText(userId)) { - throw new Error('User ID is required'); - } - - await this.#initialize(); - let channels = []; let cursor = ''; do { @@ -253,7 +240,7 @@ export default class ElevatedSlackClient extends BaseSlackClient { .then((status) => ({ email: user.email, status })) .catch((error) => ({ email: user.email, - status: 'Failed to invite', + status: SLACK_STATUSES.GENERAL_ERROR, error, }))); @@ -316,11 +303,6 @@ export default class ElevatedSlackClient extends BaseSlackClient { * @return {Promise} A promise that resolves when the message is posted. */ async #postMessageToOpsChannel(message) { - if (!hasText(this.opsConfig.opsChannelId)) { - this.log.warn('No ops channel configured, cannot post message'); - return; - } - try { const result = await this.postMessage({ channel: this.opsConfig.opsChannelId, diff --git a/packages/spacecat-shared-slack-client/src/models/index.d.ts b/packages/spacecat-shared-slack-client/src/models/index.d.ts index e7b2be30..f0fb4e84 100644 --- a/packages/spacecat-shared-slack-client/src/models/index.d.ts +++ b/packages/spacecat-shared-slack-client/src/models/index.d.ts @@ -27,18 +27,6 @@ export interface SlackChannel { * @returns {string} The channel's name. */ getName(): string, - - /** - * Checks if the channel is public. - * @returns {boolean} True if the channel is public, false otherwise. - */ - isPublic(): boolean, - - /** - * Checks if the channel is archived. - * @returns {boolean} True if the channel is archived, false otherwise. - */ - isArchived(): boolean, } export interface SlackTeam { @@ -53,23 +41,6 @@ export interface SlackTeam { * @returns {string} The team's name. */ getName(): string, - - /** - * Retrieves the URL of the team. - * @returns {string} The team's URL. - */ - getURL(): string, - - /** - * Retrieves the domain of the team. - * @returns {string} The team's domain. - */ - getDomain(): string, - - /** - * Indicates whether the team is an enterprise grid. - */ - isEnterprise(): boolean, } /** diff --git a/packages/spacecat-shared-slack-client/src/models/slack-channel.js b/packages/spacecat-shared-slack-client/src/models/slack-channel.js index 53b1113e..67db87e0 100644 --- a/packages/spacecat-shared-slack-client/src/models/slack-channel.js +++ b/packages/spacecat-shared-slack-client/src/models/slack-channel.js @@ -20,15 +20,11 @@ export default class SlackChannel { * @param {object} channelData - channel data * @param {string} channelData.id - channel id * @param {string} channelData.name - channel name - * @param {boolean} channelData.is_private - is channel private - * @param {boolean} channelData.is_archived - is channel archived * @constructor */ constructor(channelData) { this.id = channelData.id; this.name = channelData.name; - this.is_private = channelData.is_private; - this.is_archived = channelData.is_archived; } static create(channelData) { @@ -42,12 +38,4 @@ export default class SlackChannel { getName() { return this.name; } - - isPublic() { - return !this.is_private; - } - - isArchived() { - return this.is_archived; - } } diff --git a/packages/spacecat-shared-slack-client/src/models/slack-team.js b/packages/spacecat-shared-slack-client/src/models/slack-team.js index 4f03623d..26c28340 100644 --- a/packages/spacecat-shared-slack-client/src/models/slack-team.js +++ b/packages/spacecat-shared-slack-client/src/models/slack-team.js @@ -10,8 +10,6 @@ * governing permissions and limitations under the License. */ -import { hasText } from '@adobe/spacecat-shared-utils'; - /** * Represents a Slack team */ @@ -22,9 +20,6 @@ export default class SlackTeam { * @param {object} teamData - team data * @param {string} teamData.id - team id * @param {string} teamData.name - team name - * @param {string} teamData.url - team url - * @param {string} teamData.domain - team domain - * @param {string} teamData.enterprise_id - team enterprise id * @constructor */ constructor(teamData) { @@ -46,16 +41,4 @@ export default class SlackTeam { getName() { return this.name; } - - getURL() { - return this.url; - } - - getDomain() { - return this.domain; - } - - isEnterprise() { - return hasText(this.enterpriseId); - } } diff --git a/packages/spacecat-shared-slack-client/src/models/slack-user.js b/packages/spacecat-shared-slack-client/src/models/slack-user.js index 6d1dd259..65b2c4fe 100644 --- a/packages/spacecat-shared-slack-client/src/models/slack-user.js +++ b/packages/spacecat-shared-slack-client/src/models/slack-user.js @@ -34,13 +34,8 @@ export default class SlackUser { */ constructor(userData) { this.id = userData.id; - this.teamId = userData.team_id; this.name = userData.name; - this.realName = userData.profile?.real_name; this.email = userData.profile?.email; - this.isAdmin = userData.is_admin; - this.isOwner = userData.is_owner; - this.isBot = userData.is_bot; this.isRestricted = userData.is_restricted; this.isUltraRestricted = userData.is_ultra_restricted; } @@ -53,38 +48,10 @@ export default class SlackUser { return this.id; } - getTeamId() { - return this.teamId; - } - - getHandle() { - return this.name; - } - - getRealName() { - return this.realName; - } - getEmail() { return this.email; } - isAdminUser() { - return this.isAdmin; - } - - isOwnerUser() { - return this.isOwner; - } - - isBotUser() { - return this.isBot; - } - - isMultiChannelGuestUser() { - return this.isRestricted && !this.isUltraRestricted; - } - isSingleChannelGuestUser() { return this.isRestricted && this.isUltraRestricted; } diff --git a/packages/spacecat-shared-slack-client/test/clients/base-slack-client.test.js b/packages/spacecat-shared-slack-client/test/clients/base-slack-client.test.js index 067afdd6..47cd214d 100644 --- a/packages/spacecat-shared-slack-client/test/clients/base-slack-client.test.js +++ b/packages/spacecat-shared-slack-client/test/clients/base-slack-client.test.js @@ -11,12 +11,11 @@ */ /* eslint-env mocha */ -/* eslint-disable no-underscore-dangle */ import chai from 'chai'; import chaiAsPromised from 'chai-as-promised'; -import sinon from 'sinon'; import nock from 'nock'; +import sinon from 'sinon'; import BaseSlackClient from '../../src/clients/base-slack-client.js'; @@ -26,13 +25,20 @@ const { expect } = chai; describe('BaseSlackClient', () => { const mockToken = 'mock-token'; - const mockLog = { error: sinon.spy() }; + const mockOpsConfig = { + opsChannelId: 'ops123', + admins: ['admin1', 'admin2'], + }; + const mockLog = { + debug: sinon.spy(), + error: sinon.spy(), + }; let client; - let nockScope; + let mockApi; - before(() => { - client = new BaseSlackClient(mockToken, mockLog); - nockScope = nock('https://slack.com'); + beforeEach(() => { + client = new BaseSlackClient(mockToken, mockOpsConfig, mockLog); + mockApi = nock('https://slack.com'); }); afterEach(() => { @@ -45,27 +51,17 @@ describe('BaseSlackClient', () => { const response = { ok: true, channel: 'C123456', ts: '1234567890.12345' }; it('sends a message successfully', async () => { - nockScope.post('/api/chat.postMessage').reply(200, response); + mockApi.post('/api/chat.postMessage').reply(200, response); const result = await client.postMessage(message); expect(result).to.deep.equal({ channelId: response.channel, threadId: response.ts }); }); it('throws an error on Slack API failure', () => { - nockScope.post('/api/chat.postMessage').reply(500); + mockApi.post('/api/chat.postMessage').reply(500); expect(client.postMessage(message)).to.eventually.be.rejectedWith(Error); }); - - it('logs an error on Slack API failure', async () => { - nockScope.post('/api/chat.postMessage').reply(200, { ok: false }); - - try { - await client.postMessage(message); - } catch (e) { - expect(mockLog.error.called).to.be.true; - } - }); }); describe('fileUpload', () => { @@ -101,21 +97,21 @@ describe('BaseSlackClient', () => { files: [], }), }; - nockScope.post('/api/files.uploadV2').reply(200, { ok: true, files: [] }); + mockApi.post('/api/files.uploadV2').reply(200, { ok: true, files: [] }); expect(client.fileUpload(file)).to.eventually.be.rejectedWith(Error); }); it('throws an error on Slack API failure', () => { client.client = webApiClient; - nockScope.post('/api/files.uploadV2').reply(500); + mockApi.post('/api/files.uploadV2').reply(500); expect(client.fileUpload(file)).to.eventually.be.rejectedWith(Error); }); it('logs an error on Slack API failure', async () => { client.client = webApiClient; - nockScope.post('/api/files.uploadV2').reply(500); + mockApi.post('/api/files.uploadV2').reply(500); try { await client.fileUpload(file); @@ -124,37 +120,4 @@ describe('BaseSlackClient', () => { } }); }); - - describe('BaseSlackClient with Enterprise Features', () => { - let enterpriseClient; - - before(() => { - enterpriseClient = new BaseSlackClient(mockToken, mockLog, true); - nockScope = nock('https://slack.com'); - }); - - describe('Enterprise-aware API Calls', () => { - it('uses the enterprise version of the method when isEnterprise is true', async () => { - const enterpriseMethod = 'conversations.create'; - const expectedMethod = `admin.${enterpriseMethod}`; - nockScope.post(`/api/${expectedMethod}`).reply(200, { ok: true }); - - await enterpriseClient._apiCall(enterpriseMethod, {}); - expect(nockScope.isDone()).to.be.true; - }); - - it('uses the standard method when isEnterprise is false', async () => { - const standardClient = new BaseSlackClient(mockToken, mockLog, false); - const standardMethod = 'conversations.create'; - nockScope.post(`/api/${standardMethod}`).reply(200, { ok: true }); - - await standardClient._apiCall(standardMethod, {}); - expect(nockScope.isDone()).to.be.true; - }); - }); - }); - - after(() => { - nock.restore(); - }); }); diff --git a/packages/spacecat-shared-slack-client/test/clients/elevated-slack-client.test.js b/packages/spacecat-shared-slack-client/test/clients/elevated-slack-client.test.js new file mode 100644 index 00000000..e3a1560a --- /dev/null +++ b/packages/spacecat-shared-slack-client/test/clients/elevated-slack-client.test.js @@ -0,0 +1,251 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* eslint-env mocha */ + +import chai from 'chai'; +import chaiAsPromised from 'chai-as-promised'; +import nock from 'nock'; +import sinon from 'sinon'; + +import ElevatedSlackClient from '../../src/clients/elevated-slack-client.js'; + +chai.use(chaiAsPromised); + +const { expect } = chai; + +describe('ElevatedSlackClient', () => { + const mockToken = 'mock-token'; + const mockOpsConfig = { + opsChannelId: 'ops123', + admins: ['admin1', 'admin2'], + }; + let mockLog; + + let client; + let mockApi; + + beforeEach(() => { + mockLog = { + info: sinon.spy(), + error: sinon.spy(), + warn: sinon.spy(), + debug: sinon.spy(), + }; + + client = new ElevatedSlackClient(mockToken, mockOpsConfig, mockLog); + mockApi = nock('https://slack.com/api'); + }); + + afterEach(() => { + nock.cleanAll(); + sinon.restore(); + }); + + describe('general', () => { + it('throws error if getting self fails', async () => { + mockApi.post('/auth.test').reply(200, { ok: false, error: 'some_error' }); + + await expect(client.createChannel('new-channel', false)).to.eventually.be.rejectedWith(Error); + expect(mockLog.error.calledWith('Failed to retrieve self information')).to.be.true; + }); + + it('throws error if getting team info fails', async () => { + mockApi.post('/auth.test').reply(200, { ok: true, user_id: 'U123456' }); + mockApi.post('/team.info').reply(200, { ok: false, error: 'some_error' }); + + await expect(client.createChannel('new-channel', false)).to.eventually.be.rejectedWith(Error); + expect(mockLog.error.calledWith('Failed to retrieve team information')).to.be.true; + }); + + it('logs error if posting to ops channel fails', async () => { + mockApi.post('/auth.test').reply(200, { ok: true, user_id: 'U123456' }); + mockApi.post('/team.info').reply(200, { ok: true, team: { id: 'T123456', name: 'test-team' } }); + mockApi.post('/conversations.create').reply(200, { + ok: true, + channel: { id: 'C123456', name: 'new-channel' }, + }); + mockApi.post('/conversations.invite').reply(200, { + ok: true, + }); + mockApi.post('/conversations.invite').reply(200, { + ok: true, + }); + mockApi.post('/chat.postMessage').reply(200, { + ok: false, + error: 'some_error', + }); + + await client.createChannel('new-channel', false); + expect(mockLog.error.calledWithMatch('Failed to post message to ops channel')).to.be.true; + }); + }); + + describe('Functional', () => { + beforeEach(() => { + mockApi.post('/auth.test').reply(200, { ok: true, user_id: 'U123456' }); + mockApi.post('/team.info').reply(200, { ok: true, team: { id: 'T123456', name: 'test-team' } }); + mockApi.post('/chat.postMessage').reply(200, { ok: true, channel: 'C123456', ts: '1234567890.12345' }); + }); + + describe('createChannel', () => { + it('creates a public channel successfully', async () => { + mockApi.post('/conversations.create').reply(200, { + ok: true, + channel: { id: 'C123456', name: 'new-channel' }, + }); + + mockApi.post('/conversations.setTopic').reply(200, { ok: true }); + mockApi.post('/conversations.setPurpose').reply(200, { ok: true }); + + // ops admin 1 invited to the channel + mockApi.post('/conversations.invite').reply(200, { + ok: true, + }); + // ops admin 1 invited to the channel + mockApi.post('/conversations.invite').reply(200, { + ok: true, + }); + + const channel = await client.createChannel('new-channel', 'topic', 'description', false); + expect(channel).to.have.property('id', 'C123456'); + expect(channel).to.have.property('name', 'new-channel'); + expect(mockLog.info.calledWith('Created channel C123456 with name new-channel in workspace T123456')).to.be.true; + }); + + it('throws an error if channel name is missing', async () => { + await expect(client.createChannel()).to.eventually.be.rejectedWith(Error, 'Channel name is required'); + }); + + it('throws an error if the channel name is taken', async () => { + mockApi.post('/conversations.create').reply(200, { ok: false, error: 'name_taken' }); + + await expect(client.createChannel('existing-channel', false)).to.eventually.be.rejectedWith(Error); + expect(mockLog.warn.calledWith('Channel with name existing-channel already exists')).to.be.true; + }); + + it('throws an error if the channel creation fails', async () => { + mockApi.post('/conversations.create').reply(200, { ok: false, error: 'some_error' }); + + await expect(client.createChannel('some-channel', false)).to.eventually.be.rejectedWith(Error); + expect(mockLog.error.calledWithMatch('Failed to create channel some-channel')).to.be.true; + }); + + it('does not initialize more than once', async () => { + mockApi.post('/conversations.create').reply(200, { ok: false, error: 'some_error' }); + mockApi.post('/conversations.create').reply(200, { ok: false, error: 'some_error' }); + + await expect(client.createChannel('some-channel', false)).to.eventually.be.rejectedWith(Error); + await expect(client.createChannel('some-channel', false)).to.eventually.be.rejectedWith(Error); + + expect(mockLog.debug.callCount).to.equal(3); + expect(mockLog.debug.firstCall.calledWithMatch('API call auth.test')).to.be.true; + expect(mockLog.debug.secondCall.calledWithMatch('API call team.info')).to.be.true; + expect(mockLog.debug.thirdCall.calledWithMatch('Slack client initialized')).to.be.true; + }); + + it('logs errors if adding admins to new channel fails', async () => { + mockApi.post('/conversations.create').reply(200, { + ok: true, + channel: { id: 'C123456', name: 'new-channel' }, + }); + mockApi.post('/conversations.invite').reply(200, { + ok: false, + error: 'some_error', + }); + mockApi.post('/conversations.invite').reply(200, { + ok: false, + error: 'some_error', + }); + + await client.createChannel('new-channel', false); + expect(mockLog.error.calledWithMatch('Failed to invite admin to channel')).to.be.true; + }); + }); + + describe('inviteUsersByEmail', () => { + it('invites a user to a channel', async () => { + mockApi.post('/users.lookupByEmail').reply(200, { ok: true, user: { id: 'U123456', profile: {} } }); + mockApi.post('/users.conversations').reply(200, { ok: true, channels: [{ id: 'C823823' }] }); + mockApi.post('/conversations.invite').reply(200, { ok: true }); + + const results = await client.inviteUsersByEmail('C123456', [{ email: 'test@example.com' }]); + expect(results).to.be.an('array').that.is.not.empty; + expect(results[0]).to.deep.equal({ email: 'test@example.com', status: 'user_invited_to_channel' }); + }); + + it('handles user not found error', async () => { + mockApi.post('/users.lookupByEmail').reply(200, { ok: false, error: 'users_not_found' }); + + const results = await client.inviteUsersByEmail('C123456', [{ email: 'nonexistent@example.com' }]); + expect(results).to.be.an('array').that.is.not.empty; + expect(results[0]).to.deep.equal({ email: 'nonexistent@example.com', status: 'user_needs_invitation_to_workspace' }); + }); + + it('throws error when channel id is missing', async () => { + await expect(client.inviteUsersByEmail()).to.eventually.be.rejectedWith(Error, 'Channel ID is required'); + }); + + it('throws error when users are missing', async () => { + await expect(client.inviteUsersByEmail('some-channel')).to.eventually.be.rejectedWith(Error, 'Users must be an array'); + }); + + it('throws error without at least one valid user', async () => { + await expect(client.inviteUsersByEmail('some-channel', [])).to.eventually.be.rejectedWith(Error, 'At least one valid user is required'); + await expect(client.inviteUsersByEmail('some-channel', [{ email: '' }])).to.eventually.be.rejectedWith(Error, 'At least one valid user is required'); + }); + + it('sets users status to general error if lookup by email fails', async () => { + mockApi.post('/users.lookupByEmail').reply(200, { ok: false, error: 'some_error' }); + + const results = await client.inviteUsersByEmail('C123456', [{ email: 'some-user@example.com' }]); + + expect(results).to.be.an('array').with.length(1); + expect(results[0].status).to.equal('general_error'); + }); + + it('throws error if user channels lookup fails', async () => { + mockApi.post('/users.lookupByEmail').reply(200, { ok: true, user: { id: 'U123456' } }); + mockApi.post('/users.conversations').reply(200, { ok: false, error: 'some_error' }); + + const results = await client.inviteUsersByEmail('C123456', [{ email: 'test@example.com' }]); + expect(results).to.be.an('array').that.is.not.empty; + expect(results[0].status).to.equal('general_error'); + }); + + it('sets user status if user already in same channel', async () => { + mockApi.post('/users.lookupByEmail').reply(200, { ok: true, user: { id: 'U123456' } }); + mockApi.post('/users.conversations').reply(200, { ok: true, channels: [{ id: 'C123456' }] }); + + const results = await client.inviteUsersByEmail('C123456', [{ email: 'test@example.com' }]); + expect(results).to.be.an('array').that.is.not.empty; + expect(results[0]).to.deep.equal({ email: 'test@example.com', status: 'user_already_in_channel' }); + }); + + it('sets user status if user needs upgrade from single-channel to multichannel guest', async () => { + mockApi.post('/users.lookupByEmail').reply(200, { + ok: true, + user: { + id: 'U123456', + is_ultra_restricted: true, + is_restricted: true, + }, + }); + mockApi.post('/users.conversations').reply(200, { ok: true, channels: [{ id: 'C239848397' }] }); + + const results = await client.inviteUsersByEmail('C123456', [{ email: 'test@example.com' }]); + expect(results).to.be.an('array').that.is.not.empty; + expect(results[0]).to.deep.equal({ email: 'test@example.com', status: 'user_already_in_another_channel' }); + }); + }); + }); +}); diff --git a/packages/spacecat-shared-slack-client/test/index.test.js b/packages/spacecat-shared-slack-client/test/index.test.js index 4acce7db..813e5e24 100644 --- a/packages/spacecat-shared-slack-client/test/index.test.js +++ b/packages/spacecat-shared-slack-client/test/index.test.js @@ -17,7 +17,7 @@ import createFrom, { SLACK_TARGETS } from '../src/index.js'; import BaseSlackClient from '../src/clients/base-slack-client.js'; import ElevatedSlackClient from '../src/clients/elevated-slack-client.js'; -describe('createFrom', () => { +describe('Factory', () => { let context; const mockToken = 'mock-token'; const mockLog = { info: () => {} }; @@ -29,6 +29,10 @@ describe('createFrom', () => { SLACK_TOKEN_ADOBE_EXTERNAL: mockToken, SLACK_TOKEN_ADOBE_INTERNAL_ELEVATED: mockToken, SLACK_TOKEN_ADOBE_EXTERNAL_ELEVATED: mockToken, + SLACK_OPS_CHANNEL_ADOBE_INTERNAL: 'mock-channel', + SLACK_OPS_CHANNEL_ADOBE_EXTERNAL: 'mock-channel', + SLACK_OPS_ADMINS_ADOBE_INTERNAL: 'mock-admin', + SLACK_OPS_ADMINS_ADOBE_EXTERNAL: 'mock-admin', }, slackClients: {}, log: mockLog, @@ -61,6 +65,13 @@ describe('createFrom', () => { expect(() => createFrom(context, SLACK_TARGETS.ADOBE_INTERNAL, true)).to.throw('No Slack token set for ADOBE_INTERNAL with elevated privileges'); }); + it('throws an error if Ops Channel ID is not set', () => { + context.env.SLACK_OPS_CHANNEL_ADOBE_INTERNAL = ''; + context.env.SLACK_OPS_ADMINS_ADOBE_INTERNAL = undefined; + expect(() => createFrom(context, SLACK_TARGETS.ADOBE_INTERNAL, false)).to.throw('No Ops Channel ID set for ADOBE_INTERNAL'); + expect(() => createFrom(context, SLACK_TARGETS.ADOBE_INTERNAL, true)).to.throw('No Ops Channel ID set for ADOBE_INTERNAL'); + }); + it('reuses existing client instances for the same target', () => { const client1 = createFrom(context, SLACK_TARGETS.ADOBE_INTERNAL, false); const client2 = createFrom(context, SLACK_TARGETS.ADOBE_INTERNAL, false);