Restore the previous slack engine and deprecate it, rename replace the slack engine to slack_bolt until deprecation

This commit is contained in:
Gareth J. Greenaway 2022-12-05 11:31:29 -08:00 committed by Megan Wilhite
parent 21463a2f46
commit 4212c320e6
24 changed files with 1765 additions and 568 deletions

1
changelog/63095.added Normal file
View file

@ -0,0 +1 @@
Restore the previous slack engine and deprecate it, rename replace the slack engine to slack_bolt until deprecation

View file

@ -0,0 +1 @@
Deprecating the Salt Slack engine in favor of the Salt Slack Bolt Engine.

View file

@ -23,6 +23,7 @@ engine modules
redis_sentinel redis_sentinel
script script
slack slack
slack_bolt_engine
sqs_events sqs_events
stalekey stalekey
test test

View file

@ -0,0 +1,5 @@
salt.engines.slack_bolt_engine
==============================
.. automodule:: salt.engines.slack_bolt_engine
:members:

View file

@ -14,3 +14,4 @@ mercurial
hglib hglib
redis-py-cluster redis-py-cluster
python-consul python-consul
slack_bolt

View file

@ -798,6 +798,10 @@ six==1.16.0
# vcert # vcert
# virtualenv # virtualenv
# websocket-client # websocket-client
slack-bolt==1.15.5
# via -r requirements/static/ci/linux.in
slack-sdk==3.19.5
# via slack-bolt
smmap==4.0.0 smmap==4.0.0
# via gitdb # via gitdb
snowballstemmer==2.1.0 snowballstemmer==2.1.0

View file

@ -797,6 +797,10 @@ six==1.16.0
# vcert # vcert
# virtualenv # virtualenv
# websocket-client # websocket-client
slack-bolt==1.15.5
# via -r requirements/static/ci/linux.in
slack-sdk==3.19.5
# via slack-bolt
smmap==4.0.0 smmap==4.0.0
# via gitdb # via gitdb
sqlparse==0.4.2 sqlparse==0.4.2

View file

@ -856,6 +856,10 @@ six==1.16.0
# vcert # vcert
# virtualenv # virtualenv
# websocket-client # websocket-client
slack-bolt==1.15.5
# via -r requirements/static/ci/linux.in
slack-sdk==3.19.5
# via slack-bolt
smmap==3.0.4 smmap==3.0.4
# via gitdb # via gitdb
sqlparse==0.4.2 sqlparse==0.4.2

View file

@ -820,6 +820,10 @@ six==1.16.0
# vcert # vcert
# virtualenv # virtualenv
# websocket-client # websocket-client
slack-bolt==1.15.5
# via -r requirements/static/ci/linux.in
slack-sdk==3.19.5
# via slack-bolt
smmap==4.0.0 smmap==4.0.0
# via gitdb # via gitdb
snowballstemmer==2.1.0 snowballstemmer==2.1.0

View file

@ -822,6 +822,10 @@ six==1.16.0
# vcert # vcert
# virtualenv # virtualenv
# websocket-client # websocket-client
slack-bolt==1.15.5
# via -r requirements/static/ci/linux.in
slack-sdk==3.19.5
# via slack-bolt
smmap==4.0.0 smmap==4.0.0
# via gitdb # via gitdb
sqlparse==0.4.2 sqlparse==0.4.2

View file

@ -879,6 +879,10 @@ six==1.16.0
# vcert # vcert
# virtualenv # virtualenv
# websocket-client # websocket-client
slack-bolt==1.15.5
# via -r requirements/static/ci/linux.in
slack-sdk==3.19.5
# via slack-bolt
smmap==3.0.4 smmap==3.0.4
# via gitdb # via gitdb
sqlparse==0.4.2 sqlparse==0.4.2

View file

@ -846,6 +846,10 @@ six==1.16.0
# vcert # vcert
# virtualenv # virtualenv
# websocket-client # websocket-client
slack-bolt==1.15.5
# via -r requirements/static/ci/linux.in
slack-sdk==3.19.5
# via slack-bolt
smmap==4.0.0 smmap==4.0.0
# via gitdb # via gitdb
snowballstemmer==2.1.0 snowballstemmer==2.1.0

View file

@ -848,6 +848,10 @@ six==1.16.0
# vcert # vcert
# virtualenv # virtualenv
# websocket-client # websocket-client
slack-bolt==1.15.5
# via -r requirements/static/ci/linux.in
slack-sdk==3.19.5
# via slack-bolt
smmap==4.0.0 smmap==4.0.0
# via gitdb # via gitdb
sqlparse==0.4.2 sqlparse==0.4.2

View file

@ -900,6 +900,10 @@ six==1.16.0
# vcert # vcert
# virtualenv # virtualenv
# websocket-client # websocket-client
slack-bolt==1.15.5
# via -r requirements/static/ci/linux.in
slack-sdk==3.19.5
# via slack-bolt
smmap==3.0.4 smmap==3.0.4
# via gitdb # via gitdb
sqlparse==0.4.2 sqlparse==0.4.2

View file

@ -838,6 +838,10 @@ six==1.16.0
# vcert # vcert
# virtualenv # virtualenv
# websocket-client # websocket-client
slack-bolt==1.15.5
# via -r requirements/static/ci/linux.in
slack-sdk==3.19.5
# via slack-bolt
smmap==4.0.0 smmap==4.0.0
# via gitdb # via gitdb
snowballstemmer==2.1.0 snowballstemmer==2.1.0

View file

@ -839,6 +839,10 @@ six==1.16.0
# vcert # vcert
# virtualenv # virtualenv
# websocket-client # websocket-client
slack-bolt==1.15.5
# via -r requirements/static/ci/linux.in
slack-sdk==3.19.5
# via slack-bolt
smmap==4.0.0 smmap==4.0.0
# via gitdb # via gitdb
sqlparse==0.4.2 sqlparse==0.4.2

View file

@ -890,6 +890,10 @@ six==1.16.0
# vcert # vcert
# virtualenv # virtualenv
# websocket-client # websocket-client
slack-bolt==1.15.5
# via -r requirements/static/ci/linux.in
slack-sdk==3.19.5
# via slack-bolt
smmap==3.0.4 smmap==3.0.4
# via gitdb # via gitdb
sqlparse==0.4.2 sqlparse==0.4.2

View file

@ -349,6 +349,8 @@ botocore==1.21.27
# boto3 # boto3
# moto # moto
# s3transfer # s3transfer
cached-property==1.5.2
# via pygit2
cachetools==4.2.2 cachetools==4.2.2
# via # via
# google-auth # google-auth
@ -839,6 +841,10 @@ six==1.16.0
# vcert # vcert
# virtualenv # virtualenv
# websocket-client # websocket-client
slack-bolt==1.15.5
# via -r requirements/static/ci/linux.in
slack-sdk==3.19.5
# via slack-bolt
smmap==4.0.0 smmap==4.0.0
# via gitdb # via gitdb
snowballstemmer==2.1.0 snowballstemmer==2.1.0

View file

@ -347,6 +347,8 @@ botocore==1.21.27
# boto3 # boto3
# moto # moto
# s3transfer # s3transfer
cached-property==1.5.2
# via pygit2
cachetools==4.2.2 cachetools==4.2.2
# via # via
# google-auth # google-auth
@ -840,6 +842,10 @@ six==1.16.0
# vcert # vcert
# virtualenv # virtualenv
# websocket-client # websocket-client
slack-bolt==1.15.5
# via -r requirements/static/ci/linux.in
slack-sdk==3.19.5
# via slack-bolt
smmap==4.0.0 smmap==4.0.0
# via gitdb # via gitdb
sqlparse==0.4.2 sqlparse==0.4.2

View file

@ -895,6 +895,10 @@ six==1.16.0
# vcert # vcert
# virtualenv # virtualenv
# websocket-client # websocket-client
slack-bolt==1.15.5
# via -r requirements/static/ci/linux.in
slack-sdk==3.19.5
# via slack-bolt
smmap==3.0.4 smmap==3.0.4
# via gitdb # via gitdb
sqlparse==0.4.2 sqlparse==0.4.2

View file

@ -3,47 +3,21 @@ An engine that reads messages from Slack and can act on them
.. versionadded:: 2016.3.0 .. versionadded:: 2016.3.0
:depends: `slack_bolt <https://pypi.org/project/slack_bolt/>`_ Python module :depends: `slackclient <https://pypi.org/project/slackclient/>`_ Python module
.. important:: .. important::
This engine requires a Slack app and a Slack Bot user. To create a This engine requires a bot user. To create a bot user, first go to the
bot user, first go to the **Custom Integrations** page in your **Custom Integrations** page in your Slack Workspace. Copy and paste the
Slack Workspace. Copy and paste the following URL, and log in with following URL, and replace ``myworkspace`` with the proper value for your
account credentials with administrative privileges: workspace:
``https://api.slack.com/apps/new`` ``https://myworkspace.slack.com/apps/manage/custom-integrations``
Next, click on the ``From scratch`` option from the ``Create an app`` popup. Next, click on the ``Bots`` integration and request installation. Once
Give your new app a unique name, eg. ``SaltSlackEngine``, select the workspace approved by an admin, you will be able to proceed with adding the bot user.
where your app will be running, and click ``Create App``. Once the bot user has been added, you can configure it by adding an avatar,
setting the display name, etc. You will also at this time have access to
Next, click on ``Socket Mode`` and then click on the toggle button for your API token, which will be needed to configure this engine.
``Enable Socket Mode``. In the dialog give your Socket Mode Token a unique
name and then copy and save the app level token. This will be used
as the ``app_token`` parameter in the Slack engine configuration.
Next, click on ``Event Subscriptions`` and ensure that ``Enable Events`` is in
the on position. Then add the following bot events, ``message.channel``
and ``message.im`` to the ``Subcribe to bot events`` list.
Next, click on ``OAuth & Permissions`` and then under ``Bot Token Scope``, click
on ``Add an OAuth Scope``. Ensure the following scopes are included:
- ``channels:history``
- ``channels:read``
- ``chat:write``
- ``commands``
- ``files:read``
- ``files:write``
- ``im:history``
- ``mpim:history``
- ``usergroups:read``
- ``users:read``
Once all the scopes have been added, click the ``Install to Workspace`` button
under ``OAuth Tokens for Your Workspace``, then click ``Allow``. Copy and save
the ``Bot User OAuth Token``, this will be used as the ``bot_token`` parameter
in the Slack engine configuration.
Finally, add this bot user to a channel by switching to the channel and Finally, add this bot user to a channel by switching to the channel and
using ``/invite @mybotuser``. Keep in mind that this engine will process using ``/invite @mybotuser``. Keep in mind that this engine will process
@ -100,9 +74,6 @@ Configuration Examples
.. versionchanged:: 2017.7.0 .. versionchanged:: 2017.7.0
Access control group support added Access control group support added
.. versionchanged:: 3006.0
Updated to use slack_bolt Python library.
This example uses a single group called ``default``. In addition, other groups This example uses a single group called ``default``. In addition, other groups
are being loaded from pillar data. The group names do not have any are being loaded from pillar data. The group names do not have any
significance, it is the users and commands defined within them that are used to significance, it is the users and commands defined within them that are used to
@ -112,8 +83,7 @@ determine whether the Slack user has permission to run the desired command.
engines: engines:
- slack: - slack:
app_token: "xapp-x-xxxxxxxxxxx-xxxxxxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" token: 'xoxb-xxxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxxxx'
bot_token: 'xoxb-xxxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxxxx'
control: True control: True
fire_all: False fire_all: False
groups_pillar_name: 'slack_engine:groups_pillar' groups_pillar_name: 'slack_engine:groups_pillar'
@ -151,8 +121,7 @@ must be quoted, or else PyYAML will fail to load the configuration.
engines: engines:
- slack: - slack:
groups_pillar: slack_engine_pillar groups_pillar: slack_engine_pillar
app_token: "xapp-x-xxxxxxxxxxx-xxxxxxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" token: 'xoxb-xxxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxxxx'
bot_token: 'xoxb-xxxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxxxx'
control: True control: True
fire_all: True fire_all: True
tag: salt/engines/slack tag: salt/engines/slack
@ -177,7 +146,6 @@ must be quoted, or else PyYAML will fail to load the configuration.
""" """
import ast import ast
import collections
import datetime import datetime
import itertools import itertools
import logging import logging
@ -198,12 +166,11 @@ import salt.utils.slack
import salt.utils.yaml import salt.utils.yaml
try: try:
import slack_bolt import slackclient
import slack_bolt.adapter.socket_mode
HAS_SLACKBOLT = True HAS_SLACKCLIENT = True
except ImportError: except ImportError:
HAS_SLACKBOLT = False HAS_SLACKCLIENT = False
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@ -211,38 +178,17 @@ __virtualname__ = "slack"
def __virtual__(): def __virtual__():
if not HAS_SLACKBOLT: if not HAS_SLACKCLIENT:
return (False, "The 'slack_bolt' Python module could not be loaded") return (False, "The 'slackclient' Python module could not be loaded")
return __virtualname__ return __virtualname__
class SlackClient: class SlackClient:
def __init__(self, app_token, bot_token, trigger_string): def __init__(self, token):
self.master_minion = salt.minion.MasterMinion(__opts__) self.master_minion = salt.minion.MasterMinion(__opts__)
self.app = slack_bolt.App(token=bot_token) self.sc = slackclient.SlackClient(token)
self.handler = slack_bolt.adapter.socket_mode.SocketModeHandler( self.slack_connect = self.sc.rtm_connect()
self.app, app_token
)
self.handler.connect()
self.app_token = app_token
self.bot_token = bot_token
self.msg_queue = collections.deque()
trigger_pattern = "(^{}.*)".format(trigger_string)
# Register message_trigger when we see messages that start
# with the trigger string
self.app.message(re.compile(trigger_pattern))(self.message_trigger)
def _run_until(self):
return True
def message_trigger(self, message):
# Add the received message to the queue
self.msg_queue.append(message)
def get_slack_users(self, token): def get_slack_users(self, token):
""" """
@ -597,12 +543,13 @@ class SlackClient:
return data return data
for sleeps in (5, 10, 30, 60): for sleeps in (5, 10, 30, 60):
if self.handler: if self.slack_connect:
break break
else: else:
# see https://api.slack.com/docs/rate-limits # see https://api.slack.com/docs/rate-limits
log.warning( log.warning(
"Slack connection is invalid, sleeping %s", "Slack connection is invalid. Server: %s, sleeping %s",
self.sc.server,
sleeps, sleeps,
) )
time.sleep( time.sleep(
@ -611,51 +558,51 @@ class SlackClient:
else: else:
raise UserWarning( raise UserWarning(
"Connection to slack is still invalid, giving up: {}".format( "Connection to slack is still invalid, giving up: {}".format(
self.handler self.slack_connect
) )
) # Boom! ) # Boom!
while self._run_until(): while True:
while self.msg_queue: msg = self.sc.rtm_read()
msg = self.msg_queue.popleft() for m_data in msg:
try: try:
msg_text = self.message_text(msg) msg_text = self.message_text(m_data)
except (ValueError, TypeError) as msg_err: except (ValueError, TypeError) as msg_err:
log.debug( log.debug(
"Got an error from trying to get the message text %s", msg_err "Got an error from trying to get the message text %s", msg_err
) )
yield {"message_data": msg} # Not a message type from the API? yield {"message_data": m_data} # Not a message type from the API?
continue continue
# Find the channel object from the channel name # Find the channel object from the channel name
channel = msg["channel"] channel = self.sc.server.channels.find(m_data["channel"])
data = just_data(msg) data = just_data(m_data)
if msg_text.startswith(trigger_string): if msg_text.startswith(trigger_string):
loaded_groups = self.get_config_groups(groups, groups_pillar_name) loaded_groups = self.get_config_groups(groups, groups_pillar_name)
if not data.get("user_name"): if not data.get("user_name"):
log.error( log.error(
"The user %s can not be looked up via slack. What has" "The user %s can not be looked up via slack. What has"
" happened here?", " happened here?",
msg.get("user"), m_data.get("user"),
) )
channel.send_message( channel.send_message(
"The user {} can not be looked up via slack. Not" "The user {} can not be looked up via slack. Not"
" running {}".format(data["user_id"], msg_text) " running {}".format(data["user_id"], msg_text)
) )
yield {"message_data": msg} yield {"message_data": m_data}
continue continue
(allowed, target, cmdline) = self.control_message_target( (allowed, target, cmdline) = self.control_message_target(
data["user_name"], msg_text, loaded_groups, trigger_string data["user_name"], msg_text, loaded_groups, trigger_string
) )
log.debug("Got target: %s, cmdline: %s", target, cmdline)
if allowed: if allowed:
ret = { yield {
"message_data": msg, "message_data": m_data,
"channel": msg["channel"], "channel": m_data["channel"],
"user": data["user_id"], "user": data["user_id"],
"user_name": data["user_name"], "user_name": data["user_name"],
"cmdline": cmdline, "cmdline": cmdline,
"target": target, "target": target,
} }
yield ret
continue continue
else: else:
channel.send_message( channel.send_message(
@ -823,48 +770,45 @@ class SlackClient:
outstanding = {} # set of job_id that we need to check for outstanding = {} # set of job_id that we need to check for
while self._run_until(): while True:
log.trace("Sleeping for interval of %s", interval) log.trace("Sleeping for interval of %s", interval)
time.sleep(interval) time.sleep(interval)
# Drain the slack messages, up to 10 messages at a clip # Drain the slack messages, up to 10 messages at a clip
count = 0 count = 0
for msg in message_generator: for msg in message_generator:
if msg: # The message_generator yields dicts. Leave this loop
# The message_generator yields dicts. Leave this loop # on a dict that looks like {'done': True} or when we've done it
# on a dict that looks like {'done': True} or when we've done it # 10 times without taking a break.
# 10 times without taking a break. log.trace("Got a message from the generator: %s", msg.keys())
log.trace("Got a message from the generator: %s", msg.keys()) if count > 10:
if count > 10: log.warning(
log.warning( "Breaking in getting messages because count is exceeded"
"Breaking in getting messages because count is exceeded" )
) break
break if not msg:
if not msg: count += 1
count += 1 log.warning("Skipping an empty message.")
log.warning("Skipping an empty message.") continue # This one is a dud, get the next message
continue # This one is a dud, get the next message if msg.get("done"):
if msg.get("done"): log.trace("msg is done")
log.trace("msg is done") break
break if fire_all:
if fire_all: log.debug("Firing message to the bus with tag: %s", tag)
log.debug("Firing message to the bus with tag: %s", tag) log.debug("%s %s", tag, msg)
log.debug("%s %s", tag, msg) self.fire("{}/{}".format(tag, msg["message_data"].get("type")), msg)
self.fire( if control and (len(msg) > 1) and msg.get("cmdline"):
"{}/{}".format(tag, msg["message_data"].get("type")), msg channel = self.sc.server.channels.find(msg["channel"])
) jid = self.run_command_async(msg)
if control and (len(msg) > 1) and msg.get("cmdline"): log.debug("Submitted a job and got jid: %s", jid)
jid = self.run_command_async(msg) outstanding[
log.debug("Submitted a job and got jid: %s", jid) jid
outstanding[ ] = msg # record so we can return messages to the caller
jid channel.send_message(
] = msg # record so we can return messages to the caller "@{}'s job is submitted as salt jid {}".format(
text_msg = "@{}'s job is submitted as salt jid {}".format(
msg["user_name"], jid msg["user_name"], jid
) )
self.app.client.chat_postMessage( )
channel=msg["channel"], text=text_msg count += 1
)
count += 1
start_time = time.time() start_time = time.time()
job_status = self.get_jobs_from_runner( job_status = self.get_jobs_from_runner(
outstanding.keys() outstanding.keys()
@ -881,7 +825,7 @@ class SlackClient:
log.debug("ret to send back is %s", result) log.debug("ret to send back is %s", result)
# formatting function? # formatting function?
this_job = outstanding[jid] this_job = outstanding[jid]
channel = this_job["channel"] channel = self.sc.server.channels.find(this_job["channel"])
return_text = self.format_return_text(result, function) return_text = self.format_return_text(result, function)
return_prefix = ( return_prefix = (
"@{}'s job `{}` (id: {}) (target: {}) returned".format( "@{}'s job `{}` (id: {}) (target: {}) returned".format(
@ -891,19 +835,19 @@ class SlackClient:
this_job["target"], this_job["target"],
) )
) )
self.app.client.chat_postMessage( channel.send_message(return_prefix)
channel=channel, text=return_prefix
)
ts = time.time() ts = time.time()
st = datetime.datetime.fromtimestamp(ts).strftime("%Y%m%d%H%M%S%f") st = datetime.datetime.fromtimestamp(ts).strftime("%Y%m%d%H%M%S%f")
filename = "salt-results-{}.yaml".format(st) filename = "salt-results-{}.yaml".format(st)
resp = self.app.client.files_upload( r = self.sc.api_call(
channels=channel, "files.upload",
channels=channel.id,
filename=filename, filename=filename,
content=return_text, content=return_text,
) )
# Handle unicode return # Handle unicode return
log.debug("Got back %s via the slack client", resp) log.debug("Got back %s via the slack client", r)
resp = salt.utils.yaml.safe_load(salt.utils.json.dumps(r))
if "ok" in resp and resp["ok"] is False: if "ok" in resp and resp["ok"] is False:
this_job["channel"].send_message( this_job["channel"].send_message(
"Error: {}".format(resp["error"]) "Error: {}".format(resp["error"])
@ -971,8 +915,7 @@ class SlackClient:
def start( def start(
app_token, token,
bot_token,
control=False, control=False,
trigger="!", trigger="!",
groups=None, groups=None,
@ -984,17 +927,23 @@ def start(
Listen to slack events and forward them to salt, new version Listen to slack events and forward them to salt, new version
""" """
if (not bot_token) or (not bot_token.startswith("xoxb")): salt.utils.versions.warn_until(
"Argon",
"This 'slack' engine will be deprecated and "
"will be replace by the slack_bolt engine. This new "
"engine will use the new Bolt library from Slack and requires "
"a Slack app and a Slack bot account.",
)
if (not token) or (not token.startswith("xoxb")):
time.sleep(2) # don't respawn too quickly time.sleep(2) # don't respawn too quickly
log.error("Slack bot token not found, bailing...") log.error("Slack bot token not found, bailing...")
raise UserWarning("Slack Engine bot token not configured") raise UserWarning("Slack Engine bot token not configured")
try: try:
client = SlackClient( client = SlackClient(token=token)
app_token=app_token, bot_token=bot_token, trigger_string=trigger
)
message_generator = client.generate_triggered_messages( message_generator = client.generate_triggered_messages(
bot_token, trigger, groups, groups_pillar_name token, trigger, groups, groups_pillar_name
) )
client.run_commands_from_slack_async(message_generator, fire_all, tag, control) client.run_commands_from_slack_async(message_generator, fire_all, tag, control)
except Exception: # pylint: disable=broad-except except Exception: # pylint: disable=broad-except

File diff suppressed because it is too large Load diff

View file

@ -4,110 +4,30 @@ unit tests for the slack engine
import pytest import pytest
import salt.config import salt.config
import salt.engines.slack as slack_engine import salt.engines.slack as slack
from tests.support.mock import MagicMock, call, patch from tests.support.mock import MagicMock, patch
pytestmark = [ pytestmark = [
pytest.mark.skipif( pytest.mark.skipif(
slack_engine.HAS_SLACKBOLT is False, reason="The slack_bolt is not installed" slack.HAS_SLACKCLIENT is False, reason="The SlackClient is not installed"
) )
] ]
class MockRunnerClient:
"""
Mock RunnerClient class
"""
def __init__(self, *args, **kwargs):
self.args = args
self.kwargs = kwargs
def asynchronous(self, *args, **kwargs):
"""
Mock asynchronous method
"""
return True
class MockLocalClient:
"""
Mock RunnerClient class
"""
def __init__(self, *args, **kwargs):
self.args = args
self.kwargs = kwargs
def __enter__(self, *args, **kwargs):
return self
def __exit__(self, *args, **kwargs):
pass
def cmd_async(self, *args, **kwargs):
"""
Mock cmd_async method
"""
return True
class MockSlackBoltSocketMode:
def __init__(self, *args, **kwargs):
self.args = args
self.kwargs = kwargs
def connect(self, *args, **kwargs):
return True
class MockSlackBoltApp:
def __init__(self, *args, **kwargs):
self.args = args
self.kwargs = kwargs
self.client = MockSlackBoltAppClient()
self.logger = None
self.proxy = None
def message(self, *args, **kwargs):
return MagicMock(return_value=True)
class MockSlackBoltAppClient:
def __init__(self, *args, **kwargs):
self.args = args
self.kwargs = kwargs
def chat_postMessage(self, *args, **kwargs):
return MagicMock(return_value=True)
def files_upload(self, *args, **kwargs):
return MagicMock(return_value=True)
@pytest.fixture @pytest.fixture
def configure_loader_modules(): def configure_loader_modules():
return {slack_engine: {}} return {slack: {}}
@pytest.fixture @pytest.fixture
def slack_client(minion_opts): def slack_client():
mock_opts = salt.config.DEFAULT_MINION_OPTS.copy() mock_opts = salt.config.DEFAULT_MINION_OPTS.copy()
app_token = "xapp-x-xxxxxxxxxxx-xxxxxxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" token = "xoxb-xxxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxxxx"
bot_token = "xoxb-xxxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxxxx"
trigger = "!"
with patch.dict(slack_engine.__opts__, minion_opts): with patch.dict(slack.__opts__, mock_opts):
with patch( with patch("slackclient.SlackClient.rtm_connect", MagicMock(return_value=True)):
"slack_bolt.App", MagicMock(autospec=True, return_value=MockSlackBoltApp()) slack_client = slack.SlackClient(token)
): yield slack_client
with patch(
"slack_bolt.adapter.socket_mode.SocketModeHandler",
MagicMock(autospec=True, return_value=MockSlackBoltSocketMode()),
):
slack_client = slack_engine.SlackClient(app_token, bot_token, trigger)
yield slack_client
def test_control_message_target(slack_client): def test_control_message_target(slack_client):
@ -173,341 +93,3 @@ def test_control_message_target(slack_client):
) )
assert target_commandline == _expected assert target_commandline == _expected
def test_run_commands_from_slack_async(slack_client):
"""
Test slack engine: test_run_commands_from_slack_async
"""
mock_job_status = {
"20221027001127600438": {
"data": {"minion": {"return": True, "retcode": 0, "success": True}},
"function": "test.ping",
}
}
message_generator = [
{
"message_data": {
"client_msg_id": "c1d0c13d-5e78-431e-9921-4786a7d27543",
"type": "message",
"text": '!test.ping target="minion"',
"user": "U02QY11UJ",
"ts": "1666829486.542159",
"blocks": [
{
"type": "rich_text",
"block_id": "2vdy",
"elements": [
{
"type": "rich_text_section",
"elements": [
{
"type": "text",
"text": '!test.ping target="minion"',
}
],
}
],
}
],
"team": "T02QY11UG",
"channel": "C02QY11UQ",
"event_ts": "1666829486.542159",
"channel_type": "channel",
},
"channel": "C02QY11UQ",
"user": "U02QY11UJ",
"user_name": "garethgreenaway",
"cmdline": ["test.ping"],
"target": {"target": "minion", "tgt_type": "glob"},
}
]
mock_files_upload_resp = {
"ok": True,
"file": {
"id": "F047YTDGJF9",
"created": 1666883749,
"timestamp": 1666883749,
"name": "salt-results-20221027081549173603.yaml",
"title": "salt-results-20221027081549173603",
"mimetype": "text/plain",
"filetype": "yaml",
"pretty_type": "YAML",
"user": "U0485K894PN",
"user_team": "T02QY11UG",
"editable": True,
"size": 18,
"mode": "snippet",
"is_external": False,
"external_type": "",
"is_public": True,
"public_url_shared": False,
"display_as_bot": False,
"username": "",
"url_private": "",
"url_private_download": "",
"permalink": "",
"permalink_public": "",
"edit_link": "",
"preview": "minion:\n True",
"preview_highlight": "",
"lines": 2,
"lines_more": 0,
"preview_is_truncated": False,
"comments_count": 0,
"is_starred": False,
"shares": {
"public": {
"C02QY11UQ": [
{
"reply_users": [],
"reply_users_count": 0,
"reply_count": 0,
"ts": "1666883749.485979",
"channel_name": "general",
"team_id": "T02QY11UG",
"share_user_id": "U0485K894PN",
}
]
}
},
"channels": ["C02QY11UQ"],
"groups": [],
"ims": [],
"has_rich_preview": False,
"file_access": "visible",
},
}
patch_app_client_files_upload = patch.object(
MockSlackBoltAppClient,
"files_upload",
MagicMock(autospec=True, return_value=mock_files_upload_resp),
)
patch_app_client_chat_postMessage = patch.object(
MockSlackBoltAppClient,
"chat_postMessage",
MagicMock(autospec=True, return_value=True),
)
patch_slack_client_run_until = patch.object(
slack_client, "_run_until", MagicMock(autospec=True, side_effect=[True, False])
)
patch_slack_client_run_command_async = patch.object(
slack_client,
"run_command_async",
MagicMock(autospec=True, return_value="20221027001127600438"),
)
patch_slack_client_get_jobs_from_runner = patch.object(
slack_client,
"get_jobs_from_runner",
MagicMock(autospec=True, return_value=mock_job_status),
)
upload_calls = call(
channels="C02QY11UQ",
content="minion:\n True",
filename="salt-results-20221027090136014442.yaml",
)
chat_postMessage_calls = [
call(
channel="C02QY11UQ",
text="@garethgreenaway's job is submitted as salt jid 20221027001127600438",
),
call(
channel="C02QY11UQ",
text="@garethgreenaway's job `['test.ping']` (id: 20221027001127600438) (target: {'target': 'minion', 'tgt_type': 'glob'}) returned",
),
]
#
# test with control as True and fire_all as False
#
with patch_slack_client_run_until, patch_slack_client_run_command_async, patch_slack_client_get_jobs_from_runner, patch_app_client_files_upload as app_client_files_upload, patch_app_client_chat_postMessage as app_client_chat_postMessage:
slack_client.run_commands_from_slack_async(
message_generator=message_generator,
fire_all=False,
tag="salt/engines/slack",
control=True,
)
app_client_files_upload.asser_has_calls(upload_calls)
app_client_chat_postMessage.asser_has_calls(chat_postMessage_calls)
#
# test with control and fire_all as True
#
patch_slack_client_run_until = patch.object(
slack_client, "_run_until", MagicMock(autospec=True, side_effect=[True, False])
)
mock_event_send = MagicMock(return_value=True)
patch_event_send = patch.dict(
slack_engine.__salt__, {"event.send": mock_event_send}
)
event_send_calls = [
call(
"salt/engines/slack/message",
{
"message_data": {
"client_msg_id": "c1d0c13d-5e78-431e-9921-4786a7d27543",
"type": "message",
"text": '!test.ping target="minion"',
"user": "U02QY11UJ",
"ts": "1666829486.542159",
"blocks": [
{
"type": "rich_text",
"block_id": "2vdy",
"elements": [
{
"type": "rich_text_section",
"elements": [
{
"type": "text",
"text": '!test.ping target="minion"',
}
],
}
],
}
],
"team": "T02QY11UG",
"channel": "C02QY11UQ",
"event_ts": "1666829486.542159",
"channel_type": "channel",
},
"channel": "C02QY11UQ",
"user": "U02QY11UJ",
"user_name": "garethgreenaway",
"cmdline": ["test.ping"],
"target": {"target": "minion", "tgt_type": "glob"},
},
)
]
with patch_slack_client_run_until, patch_slack_client_run_command_async, patch_slack_client_get_jobs_from_runner, patch_event_send, patch_app_client_files_upload as app_client_files_upload, patch_app_client_chat_postMessage as app_client_chat_postMessage:
slack_client.run_commands_from_slack_async(
message_generator=message_generator,
fire_all=True,
tag="salt/engines/slack",
control=True,
)
app_client_files_upload.asser_has_calls(upload_calls)
app_client_chat_postMessage.asser_has_calls(chat_postMessage_calls)
mock_event_send.asser_has_calls(event_send_calls)
def test_run_command_async(slack_client):
"""
Test slack engine: test_run_command_async
"""
msg = {
"message_data": {
"client_msg_id": "6c71d7f9-a44d-402f-8f9f-d1bb5b650853",
"type": "message",
"text": '!test.ping target="minion"',
"user": "U02QY11UJ",
"ts": "1667427929.764169",
"blocks": [
{
"type": "rich_text",
"block_id": "AjL",
"elements": [
{
"type": "rich_text_section",
"elements": [
{"type": "text", "text": '!test.ping target="minion"'}
],
}
],
}
],
"team": "T02QY11UG",
"channel": "C02QY11UQ",
"event_ts": "1667427929.764169",
"channel_type": "channel",
},
"channel": "C02QY11UQ",
"user": "U02QY11UJ",
"user_name": "garethgreenaway",
"cmdline": ["test.ping"],
"target": {"target": "minion", "tgt_type": "glob"},
}
local_client_mock = MagicMock(autospec=True, return_value=MockLocalClient())
patch_local_client = patch("salt.client.LocalClient", local_client_mock)
local_client_cmd_async_mock = MagicMock(
autospec=True, return_value={"jid": "20221027001127600438"}
)
patch_local_client_cmd_async = patch.object(
MockLocalClient, "cmd_async", local_client_cmd_async_mock
)
expected_calls = [call("minion", "test.ping", arg=[], kwarg={}, tgt_type="glob")]
with patch_local_client, patch_local_client_cmd_async as local_client_cmd_async:
ret = slack_client.run_command_async(msg)
local_client_cmd_async.assert_has_calls(expected_calls)
msg = {
"message_data": {
"client_msg_id": "35f4783f-8913-4687-8f04-21182bcacd5a",
"type": "message",
"text": "!test.arg arg1 arg2 arg3 key1=value1 key2=value2",
"user": "U02QY11UJ",
"ts": "1667429460.576889",
"blocks": [
{
"type": "rich_text",
"block_id": "EAzTy",
"elements": [
{
"type": "rich_text_section",
"elements": [
{
"type": "text",
"text": "!test.arg arg1 arg2 arg3 key1=value1 key2=value2",
}
],
}
],
}
],
"team": "T02QY11UG",
"channel": "C02QY11UQ",
"event_ts": "1667429460.576889",
"channel_type": "channel",
},
"channel": "C02QY11UQ",
"user": "U02QY11UJ",
"user_name": "garethgreenaway",
"cmdline": ["test.arg", "arg1", "arg2", "arg3", "key1=value1", "key2=value2"],
"target": {"target": "*", "tgt_type": "glob"},
}
runner_client_mock = MagicMock(autospec=True, return_value=MockRunnerClient())
patch_runner_client = patch("salt.runner.RunnerClient", runner_client_mock)
runner_client_asynchronous_mock = MagicMock(
autospec=True, return_value={"jid": "20221027001127600438"}
)
patch_runner_client_asynchronous = patch.object(
MockRunnerClient, "asynchronous", runner_client_asynchronous_mock
)
expected_calls = [
call(
"test.arg",
{
"arg": ["arg1", "arg2", "arg3"],
"kwarg": {"key1": "value1", "key2": "value2"},
},
)
]
with patch_runner_client, patch_runner_client_asynchronous as runner_client_asynchronous:
ret = slack_client.run_command_async(msg)
runner_client_asynchronous.assert_has_calls(expected_calls)

View file

@ -0,0 +1,516 @@
"""
unit tests for the slack engine
"""
import pytest
import salt.config
import salt.engines.slack_bolt_engine as slack_bolt_engine
from tests.support.mock import MagicMock, call, patch
pytestmark = [
pytest.mark.skipif(
slack_bolt_engine.HAS_SLACKBOLT is False,
reason="The slack_bolt is not installed",
)
]
class MockRunnerClient:
"""
Mock RunnerClient class
"""
def __init__(self, *args, **kwargs):
self.args = args
self.kwargs = kwargs
def asynchronous(self, *args, **kwargs):
"""
Mock asynchronous method
"""
return True
class MockLocalClient:
"""
Mock RunnerClient class
"""
def __init__(self, *args, **kwargs):
self.args = args
self.kwargs = kwargs
def __enter__(self, *args, **kwargs):
return self
def __exit__(self, *args, **kwargs):
pass
def cmd_async(self, *args, **kwargs):
"""
Mock cmd_async method
"""
return True
class MockSlackBoltSocketMode:
def __init__(self, *args, **kwargs):
self.args = args
self.kwargs = kwargs
def connect(self, *args, **kwargs):
return True
class MockSlackBoltApp:
def __init__(self, *args, **kwargs):
self.args = args
self.kwargs = kwargs
self.client = MockSlackBoltAppClient()
self.logger = None
self.proxy = None
def message(self, *args, **kwargs):
return MagicMock(return_value=True)
class MockSlackBoltAppClient:
def __init__(self, *args, **kwargs):
self.args = args
self.kwargs = kwargs
def chat_postMessage(self, *args, **kwargs):
return MagicMock(return_value=True)
def files_upload(self, *args, **kwargs):
return MagicMock(return_value=True)
@pytest.fixture
def configure_loader_modules():
return {slack_bolt_engine: {}}
@pytest.fixture
def slack_client(minion_opts):
mock_opts = salt.config.DEFAULT_MINION_OPTS.copy()
app_token = "xapp-x-xxxxxxxxxxx-xxxxxxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
bot_token = "xoxb-xxxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxxxx"
trigger = "!"
with patch.dict(slack_bolt_engine.__opts__, minion_opts):
with patch(
"slack_bolt.App", MagicMock(autospec=True, return_value=MockSlackBoltApp())
):
with patch(
"slack_bolt.adapter.socket_mode.SocketModeHandler",
MagicMock(autospec=True, return_value=MockSlackBoltSocketMode()),
):
slack_client = slack_bolt_engine.SlackClient(
app_token, bot_token, trigger
)
yield slack_client
def test_control_message_target(slack_client):
"""
Test slack engine: control_message_target
"""
trigger_string = "!"
loaded_groups = {
"default": {
"targets": {},
"commands": {"cmd.run", "test.ping"},
"default_target": {"tgt_type": "glob", "target": "*"},
"users": {"gareth"},
"aliases": {
"whoami": {"cmd": "cmd.run whoami"},
"list_pillar": {"cmd": "pillar.items"},
},
}
}
slack_user_name = "gareth"
# Check for correct cmdline
_expected = (True, {"tgt_type": "glob", "target": "*"}, ["cmd.run", "whoami"])
text = "!cmd.run whoami"
target_commandline = slack_client.control_message_target(
slack_user_name, text, loaded_groups, trigger_string
)
assert target_commandline == _expected
# Check aliases result in correct cmdline
text = "!whoami"
target_commandline = slack_client.control_message_target(
slack_user_name, text, loaded_groups, trigger_string
)
assert target_commandline == _expected
# Check pillar is overridden
_expected = (
True,
{"tgt_type": "glob", "target": "*"},
["pillar.items", 'pillar={"hello": "world"}'],
)
text = r"""!list_pillar pillar='{"hello": "world"}'"""
target_commandline = slack_client.control_message_target(
slack_user_name, text, loaded_groups, trigger_string
)
assert target_commandline == _expected
# Check target is overridden
_expected = (
True,
{"tgt_type": "glob", "target": "localhost"},
["cmd.run", "whoami"],
)
text = "!cmd.run whoami target='localhost'"
target_commandline = slack_client.control_message_target(
slack_user_name, text, loaded_groups, trigger_string
)
assert target_commandline == _expected
def test_run_commands_from_slack_async(slack_client):
"""
Test slack engine: test_run_commands_from_slack_async
"""
mock_job_status = {
"20221027001127600438": {
"data": {"minion": {"return": True, "retcode": 0, "success": True}},
"function": "test.ping",
}
}
message_generator = [
{
"message_data": {
"client_msg_id": "c1d0c13d-5e78-431e-9921-4786a7d27543",
"type": "message",
"text": '!test.ping target="minion"',
"user": "U02QY11UJ",
"ts": "1666829486.542159",
"blocks": [
{
"type": "rich_text",
"block_id": "2vdy",
"elements": [
{
"type": "rich_text_section",
"elements": [
{
"type": "text",
"text": '!test.ping target="minion"',
}
],
}
],
}
],
"team": "T02QY11UG",
"channel": "C02QY11UQ",
"event_ts": "1666829486.542159",
"channel_type": "channel",
},
"channel": "C02QY11UQ",
"user": "U02QY11UJ",
"user_name": "garethgreenaway",
"cmdline": ["test.ping"],
"target": {"target": "minion", "tgt_type": "glob"},
}
]
mock_files_upload_resp = {
"ok": True,
"file": {
"id": "F047YTDGJF9",
"created": 1666883749,
"timestamp": 1666883749,
"name": "salt-results-20221027081549173603.yaml",
"title": "salt-results-20221027081549173603",
"mimetype": "text/plain",
"filetype": "yaml",
"pretty_type": "YAML",
"user": "U0485K894PN",
"user_team": "T02QY11UG",
"editable": True,
"size": 18,
"mode": "snippet",
"is_external": False,
"external_type": "",
"is_public": True,
"public_url_shared": False,
"display_as_bot": False,
"username": "",
"url_private": "",
"url_private_download": "",
"permalink": "",
"permalink_public": "",
"edit_link": "",
"preview": "minion:\n True",
"preview_highlight": "",
"lines": 2,
"lines_more": 0,
"preview_is_truncated": False,
"comments_count": 0,
"is_starred": False,
"shares": {
"public": {
"C02QY11UQ": [
{
"reply_users": [],
"reply_users_count": 0,
"reply_count": 0,
"ts": "1666883749.485979",
"channel_name": "general",
"team_id": "T02QY11UG",
"share_user_id": "U0485K894PN",
}
]
}
},
"channels": ["C02QY11UQ"],
"groups": [],
"ims": [],
"has_rich_preview": False,
"file_access": "visible",
},
}
patch_app_client_files_upload = patch.object(
MockSlackBoltAppClient,
"files_upload",
MagicMock(autospec=True, return_value=mock_files_upload_resp),
)
patch_app_client_chat_postMessage = patch.object(
MockSlackBoltAppClient,
"chat_postMessage",
MagicMock(autospec=True, return_value=True),
)
patch_slack_client_run_until = patch.object(
slack_client, "_run_until", MagicMock(autospec=True, side_effect=[True, False])
)
patch_slack_client_run_command_async = patch.object(
slack_client,
"run_command_async",
MagicMock(autospec=True, return_value="20221027001127600438"),
)
patch_slack_client_get_jobs_from_runner = patch.object(
slack_client,
"get_jobs_from_runner",
MagicMock(autospec=True, return_value=mock_job_status),
)
upload_calls = call(
channels="C02QY11UQ",
content="minion:\n True",
filename="salt-results-20221027090136014442.yaml",
)
chat_postMessage_calls = [
call(
channel="C02QY11UQ",
text="@garethgreenaway's job is submitted as salt jid 20221027001127600438",
),
call(
channel="C02QY11UQ",
text="@garethgreenaway's job `['test.ping']` (id: 20221027001127600438) (target: {'target': 'minion', 'tgt_type': 'glob'}) returned",
),
]
#
# test with control as True and fire_all as False
#
with patch_slack_client_run_until, patch_slack_client_run_command_async, patch_slack_client_get_jobs_from_runner, patch_app_client_files_upload as app_client_files_upload, patch_app_client_chat_postMessage as app_client_chat_postMessage:
slack_client.run_commands_from_slack_async(
message_generator=message_generator,
fire_all=False,
tag="salt/engines/slack",
control=True,
)
app_client_files_upload.asser_has_calls(upload_calls)
app_client_chat_postMessage.asser_has_calls(chat_postMessage_calls)
#
# test with control and fire_all as True
#
patch_slack_client_run_until = patch.object(
slack_client, "_run_until", MagicMock(autospec=True, side_effect=[True, False])
)
mock_event_send = MagicMock(return_value=True)
patch_event_send = patch.dict(
slack_bolt_engine.__salt__, {"event.send": mock_event_send}
)
event_send_calls = [
call(
"salt/engines/slack/message",
{
"message_data": {
"client_msg_id": "c1d0c13d-5e78-431e-9921-4786a7d27543",
"type": "message",
"text": '!test.ping target="minion"',
"user": "U02QY11UJ",
"ts": "1666829486.542159",
"blocks": [
{
"type": "rich_text",
"block_id": "2vdy",
"elements": [
{
"type": "rich_text_section",
"elements": [
{
"type": "text",
"text": '!test.ping target="minion"',
}
],
}
],
}
],
"team": "T02QY11UG",
"channel": "C02QY11UQ",
"event_ts": "1666829486.542159",
"channel_type": "channel",
},
"channel": "C02QY11UQ",
"user": "U02QY11UJ",
"user_name": "garethgreenaway",
"cmdline": ["test.ping"],
"target": {"target": "minion", "tgt_type": "glob"},
},
)
]
with patch_slack_client_run_until, patch_slack_client_run_command_async, patch_slack_client_get_jobs_from_runner, patch_event_send, patch_app_client_files_upload as app_client_files_upload, patch_app_client_chat_postMessage as app_client_chat_postMessage:
slack_client.run_commands_from_slack_async(
message_generator=message_generator,
fire_all=True,
tag="salt/engines/slack",
control=True,
)
app_client_files_upload.asser_has_calls(upload_calls)
app_client_chat_postMessage.asser_has_calls(chat_postMessage_calls)
mock_event_send.asser_has_calls(event_send_calls)
def test_run_command_async(slack_client):
"""
Test slack engine: test_run_command_async
"""
msg = {
"message_data": {
"client_msg_id": "6c71d7f9-a44d-402f-8f9f-d1bb5b650853",
"type": "message",
"text": '!test.ping target="minion"',
"user": "U02QY11UJ",
"ts": "1667427929.764169",
"blocks": [
{
"type": "rich_text",
"block_id": "AjL",
"elements": [
{
"type": "rich_text_section",
"elements": [
{"type": "text", "text": '!test.ping target="minion"'}
],
}
],
}
],
"team": "T02QY11UG",
"channel": "C02QY11UQ",
"event_ts": "1667427929.764169",
"channel_type": "channel",
},
"channel": "C02QY11UQ",
"user": "U02QY11UJ",
"user_name": "garethgreenaway",
"cmdline": ["test.ping"],
"target": {"target": "minion", "tgt_type": "glob"},
}
local_client_mock = MagicMock(autospec=True, return_value=MockLocalClient())
patch_local_client = patch("salt.client.LocalClient", local_client_mock)
local_client_cmd_async_mock = MagicMock(
autospec=True, return_value={"jid": "20221027001127600438"}
)
patch_local_client_cmd_async = patch.object(
MockLocalClient, "cmd_async", local_client_cmd_async_mock
)
expected_calls = [call("minion", "test.ping", arg=[], kwarg={}, tgt_type="glob")]
with patch_local_client, patch_local_client_cmd_async as local_client_cmd_async:
ret = slack_client.run_command_async(msg)
local_client_cmd_async.assert_has_calls(expected_calls)
msg = {
"message_data": {
"client_msg_id": "35f4783f-8913-4687-8f04-21182bcacd5a",
"type": "message",
"text": "!test.arg arg1 arg2 arg3 key1=value1 key2=value2",
"user": "U02QY11UJ",
"ts": "1667429460.576889",
"blocks": [
{
"type": "rich_text",
"block_id": "EAzTy",
"elements": [
{
"type": "rich_text_section",
"elements": [
{
"type": "text",
"text": "!test.arg arg1 arg2 arg3 key1=value1 key2=value2",
}
],
}
],
}
],
"team": "T02QY11UG",
"channel": "C02QY11UQ",
"event_ts": "1667429460.576889",
"channel_type": "channel",
},
"channel": "C02QY11UQ",
"user": "U02QY11UJ",
"user_name": "garethgreenaway",
"cmdline": ["test.arg", "arg1", "arg2", "arg3", "key1=value1", "key2=value2"],
"target": {"target": "*", "tgt_type": "glob"},
}
runner_client_mock = MagicMock(autospec=True, return_value=MockRunnerClient())
patch_runner_client = patch("salt.runner.RunnerClient", runner_client_mock)
runner_client_asynchronous_mock = MagicMock(
autospec=True, return_value={"jid": "20221027001127600438"}
)
patch_runner_client_asynchronous = patch.object(
MockRunnerClient, "asynchronous", runner_client_asynchronous_mock
)
expected_calls = [
call(
"test.arg",
{
"arg": ["arg1", "arg2", "arg3"],
"kwarg": {"key1": "value1", "key2": "value2"},
},
)
]
with patch_runner_client, patch_runner_client_asynchronous as runner_client_asynchronous:
ret = slack_client.run_command_async(msg)
runner_client_asynchronous.assert_has_calls(expected_calls)