From a6fc5511e4edf92ea2dfd2ae461cb29e4c969b1b Mon Sep 17 00:00:00 2001 From: Florian Strzelecki Date: Mon, 22 Aug 2022 00:28:19 +0200 Subject: [PATCH] bot, coretasks, irc: use the new capability negotiation system This is the last part of the rework: actually use all the right tools for the job, and deprecate/remove the old wonky ones. Namely: * move `bot.cap_req` from `AbstractBot` to `Sopel`, and deprecate it * replace various attributes by properties from the `irc.capabilities` manager * add the cap request manager (`bot.cap_requests`), and use it to know more about the capability requests, and to register requests * deprecate `irc.utils.CapReq` * completely rework `sopel.coretasks`'s management of capability negotiation, and also SASL authentication * add so **many** tests for coretasks (CAP & SASL related) --- sopel/bot.py | 136 +++++++- sopel/coretasks.py | 442 ++++++++++++++---------- sopel/irc/__init__.py | 148 +++----- sopel/irc/utils.py | 30 +- test/coretasks/test_coretasks_cap.py | 474 ++++++++++++++++++++++++++ test/coretasks/test_coretasks_sasl.py | 195 +++++++++++ test/test_coretasks.py | 9 +- 7 files changed, 1121 insertions(+), 313 deletions(-) create mode 100644 test/coretasks/test_coretasks_cap.py create mode 100644 test/coretasks/test_coretasks_sasl.py diff --git a/sopel/bot.py b/sopel/bot.py index 009640597d..684a7e0fc5 100644 --- a/sopel/bot.py +++ b/sopel/bot.py @@ -18,6 +18,7 @@ from types import MappingProxyType from typing import ( Any, + Callable, Dict, Iterable, Mapping, @@ -30,7 +31,11 @@ from sopel import db, irc, logger, plugin, plugins, tools from sopel.irc import modes from sopel.lifecycle import deprecated -from sopel.plugins import jobs as plugin_jobs, rules as plugin_rules +from sopel.plugins import ( + capabilities as plugin_capabilities, + jobs as plugin_jobs, + rules as plugin_rules, +) from sopel.tools import jobs as tools_jobs from sopel.trigger import Trigger @@ -51,6 +56,7 @@ def __init__(self, config, daemon=False): self._running_triggers_lock = threading.Lock() self._plugins: Dict[str, Any] = {} self._rules_manager = plugin_rules.Manager() + self._cap_requests_manager = plugin_capabilities.Manager() self._scheduler = plugin_jobs.Scheduler(self) self._url_callbacks = tools.SopelMemory() @@ -68,16 +74,6 @@ def __init__(self, config, daemon=False): function names to the time which they were last used by that nick. """ - self.server_capabilities = {} - """A dict mapping supported IRCv3 capabilities to their options. - - For example, if the server specifies the capability ``sasl=EXTERNAL``, - it will be here as ``{"sasl": "EXTERNAL"}``. Capabilities specified - without any options will have ``None`` as the value. - - For servers that do not support IRCv3, this will be an empty set. - """ - self.modeparser = modes.ModeParser() """A mode parser used to parse ``MODE`` messages and modestrings.""" @@ -114,6 +110,11 @@ def __init__(self, config, daemon=False): self.shutdown_methods = [] """List of methods to call on shutdown.""" + @property + def cap_requests(self) -> plugin_capabilities.Manager: + """Capability Requests manager.""" + return self._cap_requests_manager + @property def rules(self) -> plugin_rules.Manager: """Rules manager.""" @@ -877,6 +878,119 @@ def _update_running_triggers(self, running_triggers: list) -> None: self._running_triggers = [ t for t in running_triggers if t.is_alive()] + # capability negotiation + def request_capabilities(self) -> bool: + """Request available capabilities and return if negotiation is on. + + :return: tell if the negotiation is active or not + + This takes the available capabilities and ask the request manager to + request only these who are available. + + If none is available or if none is requested, the negotiation is not + active and this returns ``False``. It is the responsibility of the + caller to make sure it signals the IRC server to end the negotiation + with a ``CAP END`` command. + """ + available_capabilities = self._capabilities_manager.available.keys() + + if not available_capabilities: + LOGGER.debug('No client capability to negotiate.') + return False + + LOGGER.info( + "Client capability negotiation list: %s", + ', '.join(available_capabilities), + ) + + self._cap_requests_manager.request_available( + self, available_capabilities) + + return bool(self._cap_requests_manager.requested) + + def resume_capability_negotiation( + self, cap_req: Tuple[str, ...], + plugin_name: str, + ) -> None: + """Resume capability negotiation and close when necessary. + + :param cap_req: a capability request + :param plugin_name: plugin that requested the capability and want to + resume capability negotiation + + This will resume a capability request through the bot's + :attr:`capability requests manager`, and if the + negotiation wasn't completed before and is now complete, it will send + a ``CAP END`` command. + + This method must be used by plugin that declare a capability request + with a handler that returns + :attr:`sopel.plugin.CapabilityNegotiation.CONTINUE` on acknowledgement + in order for the bot to resume and eventually close negotiation. + + For example, this is usefull for SASL auth which happens while + negotiating capabilities. + """ + was_completed, is_complete = self._cap_requests_manager.resume( + cap_req, plugin_name, + ) + if not was_completed and is_complete: + LOGGER.info("End of client capability negotiation requests.") + self.write(('CAP', 'END')) + + @deprecated( + 'cap_req is replaced by sopel.plugin.capability decorator', + version='8.0', + removed_in='9.0', + ) + def cap_req( + self, + plugin_name: str, + capability: str, + arg: Optional[str] = None, + failure_callback: Optional[Callable] = None, + success_callback: Optional[Callable] = None, + ) -> None: + """Obsolete capability request method. + + .. deprecated:: 8.0 + + This will be removed in Sopel 9.0. See the + :class:`sopel.plugin.capability` for a replacement. + + .. warning:: + + This method must not be used. This will emulate the old behavior + by adding a :class:`sopel.plugin.capability` with a wrapper around + ``success_callback`` and ``failure_callback``, however the behavior + is not the same as before. The callback won't be called if the + request is never made, i.e. never REQ, ACK, or NAK, as the new + system doesn't try to emulate the server's response. + + """ + if capability.startswith('~'): + capability = '-%s' % capability.strip('~') + elif capability.startswith('='): + capability = capability.strip('=') + + @plugin.capability(capability) + def cap_req_wrapper( + cap_req: Tuple[str, ...], + bot: SopelWrapper, + acknowledged: bool, + ) -> plugin.CapabilityNegotiation: + LOGGER.warning( + 'Emulation of callback for "%s" request.', ' '.join(cap_req)) + if acknowledged and success_callback: + success_callback(bot) + elif not acknowledged and failure_callback: + failure_callback(bot) + + # always consider the request to be DONE + return plugin.CapabilityNegotiation.DONE + + self._cap_requests_manager.register(plugin_name, cap_req_wrapper) + # event handlers def on_scheduler_error( diff --git a/sopel/coretasks.py b/sopel/coretasks.py index 42e06b439a..6ef1aaf6fd 100644 --- a/sopel/coretasks.py +++ b/sopel/coretasks.py @@ -30,11 +30,16 @@ import logging import re import time +from typing import Callable, Dict, List, Optional, Tuple, TYPE_CHECKING from sopel import config, plugin from sopel.irc import isupport, utils from sopel.tools import events, jobs, SopelMemory, target +if TYPE_CHECKING: + from sopel.bot import Sopel, SopelWrapper + from sopel.trigger import Trigger + LOGGER = logging.getLogger(__name__) @@ -55,10 +60,105 @@ } -batched_caps = {} +def _auth_capability( + cap_req: Tuple[str, ...], bot: SopelWrapper, acknowledged: bool, +) -> plugin.CapabilityNegotiation: + if acknowledged: + return plugin.CapabilityNegotiation.DONE + + name = ', '.join(cap_req) + owner_account = bot.settings.core.owner_account + admin_accounts = bot.settings.core.admin_accounts + + LOGGER.info( + 'Server does not support "%s". ' + 'User account validation unavailable or limited.', + name, + ) + if owner_account or admin_accounts: + LOGGER.warning( + 'Owner or admin accounts are configured, but "%s" is not ' + 'supported by the server. This may cause unexpected behavior.', + name, + ) + + return plugin.CapabilityNegotiation.DONE + + +def _sasl_capability( + cap_req: Tuple[str, ...], bot: SopelWrapper, acknowledged: bool, +) -> plugin.CapabilityNegotiation: + # Manage CAP REQ :sasl + auth_method = bot.settings.core.auth_method + server_auth_method = bot.settings.core.server_auth_method + is_required = 'sasl' in (auth_method, server_auth_method) + + if not is_required: + # not required: we are fine, available or not + return plugin.CapabilityNegotiation.DONE + elif not acknowledged: + # required but not available: error, we must stop here + LOGGER.error( + 'SASL capability is not enabled; ' + 'cannot authenticate with SASL.', + ) + return plugin.CapabilityNegotiation.ERROR + + # Check SASL configuration (password is required) + password, mech = _get_sasl_pass_and_mech(bot) + if not password: + raise config.ConfigurationError( + 'SASL authentication required but no password available; ' + 'please check your configuration file.', + ) + + cap_info = bot.capabilities.get_capability_info('sasl') + cap_params = cap_info.params + + available_mechs = cap_params.split(',') if cap_params else [] + + if available_mechs and mech not in available_mechs: + # Raise an error if configured to use an unsupported SASL mechanism, + # but only if the server actually advertised supported mechanisms, + # i.e. this network supports SASL 3.2 + # SASL 3.1 failure is handled (when possible) + # by the sasl_mechs() function -def setup(bot): + # See https://github.com/sopel-irc/sopel/issues/1780 for background + raise config.ConfigurationError( + 'SASL mechanism "{mech}" is not advertised by this server; ' + 'available mechanisms are: {available}.'.format( + mech=mech, + available=', '.join(available_mechs), + ) + ) + + bot.write(('AUTHENTICATE', mech)) + + # If we want to do SASL, we have to wait before we can send CAP END. So if + # we are, wait on 903 (SASL successful) to send it. + return plugin.CapabilityNegotiation.CONTINUE + + +CAP_ECHO_MESSAGE = plugin.capability('echo-message') +CAP_MULTI_PREFIX = plugin.capability('multi-prefix') +CAP_AWAY_NOTIFY = plugin.capability('away-notify') +CAP_CHGHOST = plugin.capability('chghost') +CAP_CAP_NOTIFY = plugin.capability('cap-notify') +CAP_SERVER_TIME = plugin.capability('server-time') +CAP_USERHOST_IN_NAMES = plugin.capability('userhost-in-names') +CAP_MESSAGE_TAGS = plugin.capability('message-tags') +CAP_ACCOUNT_NOTIFY = plugin.capability( + 'account-notify', handler=_auth_capability) +CAP_EXTENDED_JOIN = plugin.capability( + 'extended-join', handler=_auth_capability) +CAP_ACCOUNT_TAG = plugin.capability( + 'account-tag', handler=_auth_capability) +CAP_SASL = plugin.capability('sasl', handler=_sasl_capability) + + +def setup(bot: Sopel): """Set up the coretasks plugin. The setup phase is used to activate the throttle feature to prevent a flood @@ -251,6 +351,11 @@ def startup(bot, trigger): if bot.connection_registered: return + LOGGER.info( + 'Enabled client capabilities: %s', + ', '.join(bot.capabilities.enabled), + ) + # nick shenanigans are serious business, but fortunately RPL_WELCOME # includes the actual nick used by the server after truncation, removal # of invalid characters, etc. so we can check for such shenanigans @@ -289,9 +394,11 @@ def startup(bot, trigger): bot.write(('MODE', bot.nick, modes)) # warn for insecure auth method if necessary - if (not bot.config.core.owner_account and - 'account-tag' in bot.enabled_capabilities and - '@' not in bot.config.core.owner): + if ( + not bot.config.core.owner_account and + bot.capabilities.is_enabled('account-tag') and + '@' not in bot.config.core.owner + ): msg = ( "This network supports using network services to identify you as " "my owner, rather than just matching your nickname. This is much " @@ -358,14 +465,14 @@ def handle_isupport(bot, trigger): # was NAMESX support status updated? if not namesx_support and 'NAMESX' in bot.isupport: # yes it was! - if 'multi-prefix' not in bot.server_capabilities: - # and the server doesn't have the multi-prefix capability + if not bot.capabilities.is_enabled('multi-prefix'): + # and the multi-prefix capability is not enabled # so we can ask the server to use the NAMESX feature bot.write(('PROTOCTL', 'NAMESX')) # was UHNAMES support status updated? if not uhnames_support and 'UHNAMES' in bot.isupport: # yes it was! - if 'userhost-in-names' not in bot.server_capabilities: + if not bot.capabilities.is_enabled('userhost-in-names'): # and the server doesn't have the userhost-in-names capability # so we should ask for UHNAMES instead bot.write(('PROTOCTL', 'UHNAMES')) @@ -441,7 +548,7 @@ def enable_service_auth(bot, trigger): """ if bot.config.core.owner_account: return - if 'account-tag' not in bot.enabled_capabilities: + if not bot.capabilities.is_enabled('account-tag'): bot.say('This server does not fully support services auth, so this ' 'command is not available.') return @@ -523,7 +630,7 @@ def handle_names(bot, trigger): } uhnames = 'UHNAMES' in bot.isupport - userhost_in_names = 'userhost-in-names' in bot.enabled_capabilities + userhost_in_names = bot.capabilities.is_enabled('userhost-in-names') names = trigger.split() for name in names: @@ -801,7 +908,7 @@ def _send_who(bot, channel): @plugin.interval(30) def _periodic_send_who(bot): """Periodically send a WHO request to keep user information up-to-date.""" - if 'away-notify' in bot.enabled_capabilities: + if bot.capabilities.is_enabled('away-notify'): # WHO not needed to update 'away' status return @@ -869,8 +976,9 @@ def track_join(bot, trigger): bot.channels[channel].add_user(user) if len(trigger.args) > 1 and trigger.args[1] != '*' and ( - 'account-notify' in bot.enabled_capabilities and - 'extended-join' in bot.enabled_capabilities): + bot.capabilities.is_enabled('account-notify') and + bot.capabilities.is_enabled('extended-join') + ): user.account = trigger.args[1] @@ -893,175 +1001,144 @@ def track_quit(bot, trigger): auth_after_register(bot) -@plugin.event('CAP') -@plugin.thread(False) -@plugin.unblockable -@plugin.priority('medium') -def receive_cap_list(bot, trigger): - """Handle client capability negotiation.""" - cap = trigger.strip('-=~') - # Server is listing capabilities - if trigger.args[1] == 'LS': - receive_cap_ls_reply(bot, trigger) - # Server denied CAP REQ - elif trigger.args[1] == 'NAK': - entry = bot._cap_reqs.get(cap, None) - # If it was requested with bot.cap_req - if entry: - for req in entry: - # And that request was mandatory/prohibit, and a callback was - # provided - if req.prefix and req.failure: - # Call it. - req.failure(bot, req.prefix + cap) - # Server is removing a capability - elif trigger.args[1] == 'DEL': - entry = bot._cap_reqs.get(cap, None) - # If it was requested with bot.cap_req - if entry: - for req in entry: - # And that request wasn't prohibit, and a callback was - # provided - if req.prefix != '-' and req.failure: - # Call it. - req.failure(bot, req.prefix + cap) - # Server is adding new capability - elif trigger.args[1] == 'NEW': - entry = bot._cap_reqs.get(cap, None) - # If it was requested with bot.cap_req - if entry: - for req in entry: - # And that request wasn't prohibit - if req.prefix != '-': - # Request it - bot.write(('CAP', 'REQ', req.prefix + cap)) - # Server is acknowledging a capability - elif trigger.args[1] == 'ACK': - caps = trigger.args[2].split() - for cap in caps: - cap.strip('-~= ') - bot.enabled_capabilities.add(cap) - entry = bot._cap_reqs.get(cap, []) - for req in entry: - if req.success: - req.success(bot, req.prefix + trigger) - if cap == 'sasl': # TODO why is this not done with bot.cap_req? - try: - receive_cap_ack_sasl(bot) - except config.ConfigurationError as error: - LOGGER.error(str(error)) - bot.quit('Wrong SASL configuration.') - - -def receive_cap_ls_reply(bot, trigger): - if bot.server_capabilities: - # We've already seen the results, so someone sent CAP LS from a plugin. - # We're too late to do SASL, and we don't want to send CAP END before - # the plugin has done what it needs to, so just return +def _receive_cap_ls_reply(bot: SopelWrapper, trigger: Trigger) -> None: + if not bot.capabilities.handle_ls(bot, trigger): + # multi-line, we must wait for more return - for cap in trigger.split(): - c = cap.split('=') - if len(c) == 2: - batched_caps[c[0]] = c[1] - else: - batched_caps[c[0]] = None + if not bot.request_capabilities(): + # Negotiation end because there is nothing to request + LOGGER.info('No capability negotiation.') + bot.write(('CAP', 'END')) - # Not the last in a multi-line reply. First two args are * and LS. - if trigger.args[2] == '*': - return - LOGGER.info( - "Client capability negotiation list: %s", - ', '.join(batched_caps.keys()), - ) - bot.server_capabilities = batched_caps - - # If some other plugin requests it, we don't need to add another request. - # If some other plugin prohibits it, we shouldn't request it. - core_caps = [ - 'echo-message', - 'multi-prefix', - 'away-notify', - 'chghost', - 'cap-notify', - 'server-time', - 'userhost-in-names', - 'message-tags', - ] - for cap in core_caps: - if cap not in bot._cap_reqs: - bot._cap_reqs[cap] = [utils.CapReq('', 'coretasks')] - - def acct_warn(bot, cap): - LOGGER.info("Server does not support %s, or it conflicts with a custom " - "plugin. User account validation unavailable or limited.", - cap[1:]) - if bot.config.core.owner_account or bot.config.core.admin_accounts: - LOGGER.warning( - "Owner or admin accounts are configured, but %s is not " - "supported by the server. This may cause unexpected behavior.", - cap[1:]) - auth_caps = ['account-notify', 'extended-join', 'account-tag'] - for cap in auth_caps: - if cap not in bot._cap_reqs: - bot._cap_reqs[cap] = [utils.CapReq('', 'coretasks', acct_warn)] - - for cap, reqs in bot._cap_reqs.items(): - # At this point, we know mandatory and prohibited don't co-exist, but - # we need to call back for optionals if they're also prohibited - prefix = '' - for entry in reqs: - if prefix == '-' and entry.prefix != '-': - entry.failure(bot, entry.prefix + cap) - continue - if entry.prefix: - prefix = entry.prefix - - # It's not required, or it's supported, so we can request it - if prefix != '=' or cap in bot.server_capabilities: - # REQs fail as a whole, so we send them one capability at a time - bot.write(('CAP', 'REQ', entry.prefix + cap)) - # If it's required but not in server caps, we need to call all the - # callbacks - else: - for entry in reqs: - if entry.failure and entry.prefix == '=': - entry.failure(bot, entry.prefix + cap) +def _handle_cap_acknowledgement( + bot: SopelWrapper, + cap_req: Tuple[str, ...], + results: List[Tuple[bool, Optional[plugin.CapabilityNegotiation]]], + was_completed: bool, +) -> None: + if any( + callback_result[1] == plugin.CapabilityNegotiation.ERROR + for callback_result in results + ): + # error: a plugin needs something and the bot cannot function properly + LOGGER.error( + 'Capability negotiation failed for request: "%s"', + ' '.join(cap_req), + ) + bot.write(('CAP', 'END')) # close negotiation now + bot.quit('Error negotiating capabilities.') - # If we want to do SASL, we have to wait before we can send CAP END. So if - # we are, wait on 903 (SASL successful) to send it. - if bot.config.core.auth_method == 'sasl' or bot.config.core.server_auth_method == 'sasl': - bot.write(('CAP', 'REQ', 'sasl')) - else: - bot.write(('CAP', 'END')) - LOGGER.info("End of client capability negotiation requests.") + if not was_completed and bot.cap_requests.is_complete: + # success: negotiation is complete and wasn't already + LOGGER.info('Capability negotiation ended successfuly.') + bot.write(('CAP', 'END')) # close negotiation now -def receive_cap_ack_sasl(bot): - # Presumably we're only here if we said we actually *want* sasl, but still - # check anyway in case the server glitched. - password, mech = _get_sasl_pass_and_mech(bot) - if not password: - return +def _receive_cap_ack(bot: SopelWrapper, trigger: Trigger) -> None: + was_completed = bot.cap_requests.is_complete + cap_ack: Tuple[str, ...] = bot.capabilities.handle_ack(bot, trigger) - available_mechs = bot.server_capabilities.get('sasl', '') - available_mechs = available_mechs.split(',') if available_mechs else [] + try: + result: Optional[ + List[Tuple[bool, Optional[plugin.CapabilityNegotiation]]] + ] = bot.cap_requests.acknowledge(bot, cap_ack) + except config.ConfigurationError as error: + LOGGER.error( + 'Configuration error on ACK capability "%s": %s', + ', '.join(cap_ack), + error, + ) + bot.write(('CAP', 'END')) # close negotiation now + bot.quit('Wrong configuration.') + return None + except Exception as error: + LOGGER.exception( + 'Error on ACK capability "%s": %s', + ', '.join(cap_ack), + error, + ) + bot.write(('CAP', 'END')) # close negotiation now + bot.quit('Error negotiating capabilities.') + return None - if available_mechs and mech not in available_mechs: - """ - Raise an error if configured to use an unsupported SASL mechanism, - but only if the server actually advertised supported mechanisms, - i.e. this network supports SASL 3.2 + if result is None: + # a plugin may have request the capability without using the proper + # interface: ignore + return None - SASL 3.1 failure is handled (when possible) by the sasl_mechs() function + _handle_cap_acknowledgement(bot, cap_ack, result, was_completed) - See https://github.com/sopel-irc/sopel/issues/1780 for background - """ - raise config.ConfigurationError( - "SASL mechanism '{}' is not advertised by this server.".format(mech)) - bot.write(('AUTHENTICATE', mech)) +def _receive_cap_nak(bot: SopelWrapper, trigger: Trigger) -> None: + was_completed = bot.cap_requests.is_complete + cap_ack = bot.capabilities.handle_nak(bot, trigger) + + try: + result: Optional[ + List[Tuple[bool, Optional[plugin.CapabilityNegotiation]]] + ] = bot.cap_requests.deny(bot, cap_ack) + except config.ConfigurationError as error: + LOGGER.error( + 'Configuration error on NAK capability "%s": %s', + ', '.join(cap_ack), + error, + ) + bot.write(('CAP', 'END')) # close negotiation now + bot.quit('Wrong configuration.') + return None + except Exception as error: + LOGGER.exception( + 'Error on NAK capability "%s": %s', + ', '.join(cap_ack), + error, + ) + bot.write(('CAP', 'END')) # close negotiation now + bot.quit('Error negotiating capabilities.') + return None + + if result is None: + # a plugin may have request the capability without using the proper + # interface: ignore + return None + + _handle_cap_acknowledgement(bot, cap_ack, result, was_completed) + + +def _receive_cap_new(bot: SopelWrapper, trigger: Trigger) -> None: + cap_new = bot.capabilities.handle_new(bot, trigger) + LOGGER.info('Capability is now available: %s', ', '.join(cap_new)) + # TODO: try to request what wasn't requested before + + +def _receive_cap_del(bot: SopelWrapper, trigger: Trigger) -> None: + cap_del = bot.capabilities.handle_del(bot, trigger) + LOGGER.info('Capability is now unavailable: %s', ', '.join(cap_del)) + # TODO: what to do when a CAP is removed? NAK callbacks? + + +CAP_HANDLERS: Dict[str, Callable[[SopelWrapper, Trigger], None]] = { + 'LS': _receive_cap_ls_reply, # Server is listing capabilities + 'ACK': _receive_cap_ack, # Server is acknowledging a capability + 'NAK': _receive_cap_nak, # Server is denying a capability + 'NEW': _receive_cap_new, # Server is adding new capability + 'DEL': _receive_cap_del, # Server is removing a capability +} + + +@plugin.event('CAP') +@plugin.thread(False) +@plugin.unblockable +@plugin.priority('medium') +def receive_cap_list(bot: SopelWrapper, trigger: Trigger) -> None: + """Handle client capability negotiation.""" + subcommand = trigger.args[1] + if subcommand in CAP_HANDLERS: + handler = CAP_HANDLERS[subcommand] + handler(bot, trigger) + else: + LOGGER.info('Unknown CAP subcommand received: %s', subcommand) def send_authenticate(bot, token): @@ -1149,7 +1226,9 @@ def auth_proceed(bot, trigger): return else: # Not an expected response from the server - LOGGER.warning("Aborting SASL: unexpected server reply '%s'" % trigger) + LOGGER.warning( + 'Aborting SASL: unexpected server reply "%s"', trigger, + ) # Send `authenticate-abort` command # See https://ircv3.net/specs/extensions/sasl-3.1#the-authenticate-command bot.write(('AUTHENTICATE', '*')) @@ -1166,17 +1245,10 @@ def _make_sasl_plain_token(account, password): @plugin.thread(False) @plugin.unblockable @plugin.priority('medium') -def sasl_success(bot, trigger): - """End CAP request on successful SASL auth. - - If SASL is configured, then the bot won't send ``CAP END`` once it gets - all the capability responses; it will wait for SASL auth result. - - In this case, the SASL auth is a success, so we can close the negotiation. - """ +def sasl_success(bot: SopelWrapper, trigger: Trigger): + """Resume capability negotiation on successful SASL auth.""" LOGGER.info("Successful SASL Auth.") - bot.write(('CAP', 'END')) - LOGGER.info("End of client capability negotiation requests.") + bot.resume_capability_negotiation(CAP_SASL.cap_req, 'coretasks') @plugin.event(events.ERR_SASLFAIL) @@ -1191,6 +1263,9 @@ def sasl_fail(bot, trigger): LOGGER.error( "SASL Auth Failed; check your configuration: %s", str(trigger)) + # negotiation done + bot.resume_capability_negotiation(CAP_SASL.cap_req, 'coretasks') + # quit bot.quit('SASL Auth Failed') @@ -1203,6 +1278,8 @@ def sasl_mechs(bot, trigger): # check anyway in case the server glitched. password, mech = _get_sasl_pass_and_mech(bot) if not password: + # negotiation done + bot.resume_capability_negotiation(CAP_SASL.cap_req, 'coretasks') return supported_mechs = trigger.args[1].split(',') @@ -1220,7 +1297,7 @@ def sasl_mechs(bot, trigger): to an IRC server NOT implementing the optional 908 reply. A network with SASL 3.2 should theoretically never get this far because - Sopel should catch the unadvertised mechanism in receive_cap_ack_sasl(). + Sopel should catch the unadvertised mechanism in CAP_SASL. See https://github.com/sopel-irc/sopel/issues/1780 for background """ @@ -1230,6 +1307,7 @@ def sasl_mechs(bot, trigger): mech, ', '.join(supported_mechs), ) + bot.resume_capability_negotiation(CAP_SASL.cap_req, 'coretasks') bot.quit('Wrong SASL configuration.') else: LOGGER.info( diff --git a/sopel/irc/__init__.py b/sopel/irc/__init__.py index 0951c74608..f0e924ea18 100644 --- a/sopel/irc/__init__.py +++ b/sopel/irc/__init__.py @@ -33,10 +33,8 @@ import time from typing import ( Any, - Callable, Dict, Iterable, - List, Optional, Set, Tuple, @@ -46,8 +44,9 @@ from sopel import tools, trigger from sopel.tools import identifiers from .backends import AsyncioBackend +from .capabilities import Capabilities from .isupport import ISupport -from .utils import CapReq, safe +from .utils import safe if TYPE_CHECKING: from sopel.config import Config @@ -68,6 +67,7 @@ def __init__(self, settings: Config): self._user: str = settings.core.user self._name: str = settings.core.name self._isupport = ISupport() + self._capabilities_manager = Capabilities() self._myinfo: Optional[MyInfo] = None self._nick: identifiers.Identifier = self.make_identifier( settings.core.nick) @@ -78,10 +78,6 @@ def __init__(self, settings: Config): """Flag stating whether the IRC Connection is registered yet.""" self.settings = settings """Bot settings.""" - self.enabled_capabilities: Set[str] = set() - """A set containing the IRCv3 capabilities that the bot has enabled.""" - self._cap_reqs: Dict[str, List[CapReq]] = dict() - """A dictionary of capability names to a list of requests.""" # internal machinery self.sending = threading.RLock() @@ -117,6 +113,57 @@ def config(self) -> Config: # TODO: Deprecate config, replaced by settings return self.settings + @property + def capabilities(self) -> Capabilities: + """Capabilities negotiated with the server.""" + return self._capabilities_manager + + @property + def enabled_capabilities(self) -> Set[str]: + """A set containing the IRCv3 capabilities that the bot has enabled. + + .. deprecated:: 8.0 + + Enabled server capabilities are now managed by + :attr:`bot.capabilities ` and its various methods and + attributes: + + * use :meth:`bot.capabilities.is_enabled() ` + to check if a capability is enabled + * use :attr:`bot.capabilities.enabled ` + for a list of enabled capabilities + + Will be removed in Sopel 9. + + """ + return set(self._capabilities_manager.enabled) + + @property + def server_capabilities(self) -> Dict[str, Optional[str]]: + """A dict mapping supported IRCv3 capabilities to their options. + + For example, if the server specifies the capability ``sasl=EXTERNAL``, + it will be here as ``{"sasl": "EXTERNAL"}``. Capabilities specified + without any options will have ``None`` as the value. + + For servers that do not support IRCv3, this will be an empty set. + + .. deprecated:: 8.0 + + Enabled server capabilities are now managed by + :attr:`bot.capabilities ` and its various methods and + attributes: + + * use :meth:`bot.capabilities.is_available() ` + to check if a capability is available + * use :attr:`bot.capabilities.available ` + for a list of available capabilities and their parameters + + Will be removed in Sopel 9. + + """ + return self._capabilities_manager.available + @property def isupport(self) -> ISupport: """Features advertised by the server. @@ -306,7 +353,7 @@ def on_message(self, message: str) -> None: identifier_factory=self.make_identifier, ) if all( - cap not in self.enabled_capabilities + not self.capabilities.is_enabled(cap) for cap in ['account-tag', 'extended-join'] ): pretrigger.tags.pop('account', None) @@ -334,7 +381,7 @@ def on_message_sent(self, raw: str) -> None: self.log_raw(raw, '>>') # Simulate echo-message - no_echo = 'echo-message' not in self.enabled_capabilities + no_echo = not self.capabilities.is_enabled('echo-message') echoed = ['PRIVMSG', 'NOTICE'] if no_echo and any(raw.upper().startswith(cmd) for cmd in echoed): # Use the hostmask we think the IRC server is using for us, @@ -443,89 +490,6 @@ def log_raw(self, line: str, prefix: str) -> None: logger = logging.getLogger('sopel.raw') logger.info("%s\t%r", prefix, line) - def cap_req( - self, - plugin_name: str, - capability: str, - arg: Optional[str] = None, - failure_callback: Optional[Callable] = None, - success_callback: Optional[Callable] = None, - ) -> None: - """Tell Sopel to request a capability when it starts. - - :param plugin_name: the plugin requesting the capability - :param capability: the capability requested, optionally prefixed with - ``-`` or ``=`` - :param arg: arguments for the capability request - :param failure_callback: a function that will be called if the - capability request fails - :param success_callback: a function that will be called if the - capability is successfully requested - - By prefixing the capability with ``-``, it will be ensured that the - capability is not enabled. Similarly, by prefixing the capability with - ``=``, it will be ensured that the capability is enabled. Requiring and - disabling is "first come, first served"; if one plugin requires a - capability, and another prohibits it, this function will raise an - exception in whichever plugin loads second. An exception will also be - raised if the plugin is being loaded after the bot has already started, - and the request would change the set of enabled capabilities. - - If the capability is not prefixed, and no other plugin prohibits it, it - will be requested. Otherwise, it will not be requested. Since - capability requests that are not mandatory may be rejected by the - server, as well as by other plugins, a plugin which makes such a - request should account for that possibility. - - The actual capability request to the server is handled after the - completion of this function. In the event that the server denies a - request, the ``failure_callback`` function will be called, if provided. - The arguments will be a :class:`~sopel.bot.Sopel` object, and the - capability which was rejected. This can be used to disable callables - which rely on the capability. It will be be called either if the server - NAKs the request, or if the server enabled it and later DELs it. - - The ``success_callback`` function will be called upon acknowledgment - of the capability from the server, whether during the initial - capability negotiation, or later. - - If ``arg`` is given, and does not exactly match what the server - provides or what other plugins have requested for that capability, it - is considered a conflict. - """ - # TODO raise better exceptions - cap = capability[1:] - prefix = capability[0] - - entry = self._cap_reqs.get(cap, []) - if any((ent.arg != arg for ent in entry)): - raise Exception('Capability conflict') - - if prefix == '-': - if self.connection_registered and cap in self.enabled_capabilities: - raise Exception('Can not change capabilities after server ' - 'connection has been completed.') - if any((ent.prefix != '-' for ent in entry)): - raise Exception('Capability conflict') - entry.append(CapReq(prefix, plugin_name, failure_callback, arg, - success_callback)) - self._cap_reqs[cap] = entry - else: - if prefix != '=': - cap = capability - prefix = '' - if self.connection_registered and (cap not in - self.enabled_capabilities): - raise Exception('Can not change capabilities after server ' - 'connection has been completed.') - # Non-mandatory will callback at the same time as if the server - # rejected it. - if any((ent.prefix == '-' for ent in entry)) and prefix == '=': - raise Exception('Capability conflict') - entry.append(CapReq(prefix, plugin_name, failure_callback, arg, - success_callback)) - self._cap_reqs[cap] = entry - def write(self, args: Iterable[str], text: Optional[str] = None) -> None: """Send a command to the server. diff --git a/sopel/irc/utils.py b/sopel/irc/utils.py index 56212f5069..8700d78199 100644 --- a/sopel/irc/utils.py +++ b/sopel/irc/utils.py @@ -5,6 +5,8 @@ from typing import NamedTuple +from sopel.lifecycle import deprecated + def safe(string): """Remove newlines from a string. @@ -37,32 +39,20 @@ def safe(string): return string +@deprecated('CapReq is obsolete.', version='8.0', removed_in='9.0') class CapReq: - """Represents a pending CAP REQ request. - - :param str prefix: either ``=`` (must be enabled), - ``-`` (must **not** be enabled), - or empty string (desired but optional) - :param str plugin: the requesting plugin's name - :param failure: function to call if this capability request fails - :type failure: :term:`function` - :param str arg: optional capability value; the request will fail if - the server's value is different - :param success: function to call if this capability request succeeds - :type success: :term:`function` - - The ``success`` and ``failure`` callbacks must accept two arguments: - ``bot`` (a :class:`~sopel.bot.Sopel` instance) and ``cap`` (the name of - the requested capability, as a string). + """Obsolete representation of a CAP REQ. + + .. deprecated:: 8.0 + + This class is deprecated. See :class:`sopel.plugin.capability` instead. + + This will be removed in Sopel 9. - .. seealso:: - For more information on how capability requests work, see the - documentation for :meth:`sopel.irc.AbstractBot.cap_req`. """ def __init__(self, prefix, plugin, failure=None, arg=None, success=None): def nop(bot, cap): pass - # TODO at some point, reorder those args to be sane self.prefix = prefix self.plugin = plugin self.arg = arg diff --git a/test/coretasks/test_coretasks_cap.py b/test/coretasks/test_coretasks_cap.py new file mode 100644 index 0000000000..1f596c0dbc --- /dev/null +++ b/test/coretasks/test_coretasks_cap.py @@ -0,0 +1,474 @@ +"""Test behavior of SASL by ``sopel.coretasks``""" +from __future__ import annotations + +from typing import Tuple, TYPE_CHECKING +from unittest import mock + +import pytest + +from sopel import config, plugin +from sopel.tests import rawlist + +if TYPE_CHECKING: + from sopel.bot import Sopel, SopelWrapper + from sopel.config import Config + from sopel.tests.factories import BotFactory, ConfigFactory + + +TMP_CONFIG = """ +[core] +owner = Uowner +nick = TestBot +enable = coretasks +""" + + +@pytest.fixture +def tmpconfig(configfactory: ConfigFactory) -> Config: + return configfactory('conf.ini', TMP_CONFIG) + + +@pytest.fixture +def mockbot(tmpconfig: Config, botfactory: BotFactory) -> Sopel: + mockbot = botfactory.preloaded(tmpconfig) + mockbot.backend.connected = True + return mockbot + + +def test_cap_ls_ack(mockbot: Sopel): + mockbot.on_message( + ':irc.example.com CAP * LS :echo-message multi-prefix example/cap') + assert mockbot.backend.message_sent == rawlist( + 'CAP REQ :echo-message', + 'CAP REQ :multi-prefix', + ) + + mockbot.on_message(':irc.example.com CAP * ACK :echo-message') + assert mockbot.backend.message_sent[2:] == [] + + mockbot.on_message(':irc.example.com CAP * ACK :example/cap') + assert mockbot.backend.message_sent[2:] == [], ( + 'Unknown cap request must not count for completion of anything.') + + mockbot.on_message(':irc.example.com CAP * ACK :multi-prefix') + assert mockbot.backend.message_sent[2:] == rawlist( + 'CAP END', + ) + + assert mockbot.capabilities.is_enabled('echo-message') + assert mockbot.capabilities.is_enabled('example/cap') + assert mockbot.capabilities.is_enabled('multi-prefix') + + +def test_cap_ls_nak(mockbot: Sopel): + mockbot.on_message(':irc.example.com CAP * LS :echo-message multi-prefix') + assert mockbot.backend.message_sent == rawlist( + 'CAP REQ :echo-message', + 'CAP REQ :multi-prefix', + ) + + mockbot.on_message(':irc.example.com CAP * NAK :echo-message') + assert mockbot.backend.message_sent[2:] == [] + + mockbot.on_message(':irc.example.com CAP * NAK :example/cap') + assert mockbot.backend.message_sent[2:] == [], ( + 'Unknown cap request must not count for completion of anything.') + + mockbot.on_message(':irc.example.com CAP * NAK :multi-prefix') + assert mockbot.backend.message_sent[2:] == rawlist( + 'CAP END', + ) + + assert not mockbot.capabilities.is_enabled('echo-message') + assert not mockbot.capabilities.is_enabled('example/cap') + assert not mockbot.capabilities.is_enabled('multi-prefix') + + +def test_cap_ls_empty(mockbot: Sopel): + mockbot.on_message(':irc.example.com CAP * LS :') + assert mockbot.backend.message_sent == rawlist( + 'CAP END', + ) + + +def test_cap_ls_unknown(mockbot: Sopel): + mockbot.on_message(':irc.example.com CAP * LS :example/cap') + assert mockbot.backend.message_sent == rawlist( + 'CAP END', + ) + + +def test_cap_ls_multiline(mockbot: Sopel): + mockbot.on_message(':irc.example.com CAP * LS * :echo-message') + assert mockbot.backend.message_sent == [], ( + 'LS is not done, we must not send request yet.') + + mockbot.on_message(':irc.example.com CAP * LS :multi-prefix') + assert mockbot.backend.message_sent == rawlist( + 'CAP REQ :echo-message', + 'CAP REQ :multi-prefix', + ) + + mockbot.on_message(':irc.example.com CAP * ACK :echo-message') + assert mockbot.backend.message_sent[2:] == [] + + mockbot.on_message(':irc.example.com CAP * NAK :multi-prefix') + assert mockbot.backend.message_sent[2:] == rawlist( + 'CAP END', + ) + + assert mockbot.capabilities.is_enabled('echo-message') + assert not mockbot.capabilities.is_enabled('multi-prefix') + + +def test_cap_ls_all(mockbot: Sopel): + capabilities = ( + 'account-notify', + 'account-tag', + 'away-notify', + 'cap-notify', + 'chghost', + 'echo-message', + 'extended-join', + 'message-tags', + 'multi-prefix', + 'sasl=PLAIN,EXTERNAL', + 'server-time', + 'userhost-in-names', + ) + part1 = ' '.join(capabilities[:4]) + part2 = ' '.join(capabilities[4:]) + unrequested_part = ' '.join(('chathistory', 'metadata')) + mockbot.on_message(':irc.example.com CAP * LS * :%s' % part1) + mockbot.on_message(':irc.example.com CAP * LS * :%s' % unrequested_part) + mockbot.on_message(':irc.example.com CAP * LS :%s' % part2) + + assert mockbot.backend.message_sent == rawlist( + 'CAP REQ :echo-message', + 'CAP REQ :multi-prefix', + 'CAP REQ :away-notify', + 'CAP REQ :chghost', + 'CAP REQ :cap-notify', + 'CAP REQ :server-time', + 'CAP REQ :userhost-in-names', + 'CAP REQ :message-tags', + 'CAP REQ :account-notify', + 'CAP REQ :extended-join', + 'CAP REQ :account-tag', + 'CAP REQ :sasl', + ) + n = len(mockbot.backend.message_sent) + + # now we have to ACK or NAK capabilities for the negotiation to end + mockbot.on_message(':irc.example.com CAP * ACK :echo-message') + mockbot.on_message(':irc.example.com CAP * ACK :multi-prefix') + mockbot.on_message(':irc.example.com CAP * ACK :away-notify') + mockbot.on_message(':irc.example.com CAP * ACK :chghost') + mockbot.on_message(':irc.example.com CAP * ACK :cap-notify') + mockbot.on_message(':irc.example.com CAP * ACK :server-time') + mockbot.on_message(':irc.example.com CAP * ACK :userhost-in-names') + mockbot.on_message(':irc.example.com CAP * ACK :message-tags') + mockbot.on_message(':irc.example.com CAP * ACK :account-notify') + mockbot.on_message(':irc.example.com CAP * ACK :extended-join') + mockbot.on_message(':irc.example.com CAP * ACK :account-tag') + assert mockbot.backend.message_sent[n:] == [], 'No CAP END yet!' + + # final capability to ACK + mockbot.on_message(':irc.example.com CAP * ACK :sasl') + assert mockbot.backend.message_sent[n:] == rawlist( + 'CAP END' + ) + + +def test_cap_ack_auth_related_cap(mockbot: Sopel): + mockbot.on_message( + ':irc.example.com CAP * LS :account-notify extended-join account-tag') + + assert mockbot.backend.message_sent == rawlist( + 'CAP REQ :account-notify', + 'CAP REQ :extended-join', + 'CAP REQ :account-tag', + ) + + mockbot.on_message(':irc.example.com CAP * ACK :account-notify') + assert mockbot.backend.message_sent[3:] == [] + mockbot.on_message(':irc.example.com CAP * ACK :extended-join') + assert mockbot.backend.message_sent[3:] == [] + mockbot.on_message(':irc.example.com CAP * ACK :account-tag') + + assert mockbot.capabilities.is_enabled('account-notify') + assert mockbot.capabilities.is_enabled('extended-join') + assert mockbot.capabilities.is_enabled('account-tag') + assert mockbot.cap_requests.is_complete + assert mockbot.backend.message_sent[3:] == rawlist( + 'CAP END', + ) + + +def test_cap_nak_auth_related_cap(mockbot: Sopel): + mockbot.on_message( + ':irc.example.com CAP * LS :account-notify extended-join account-tag') + + assert mockbot.backend.message_sent == rawlist( + 'CAP REQ :account-notify', + 'CAP REQ :extended-join', + 'CAP REQ :account-tag', + ) + + mockbot.on_message(':irc.example.com CAP * NAK :account-notify') + assert mockbot.backend.message_sent[3:] == [] + mockbot.on_message(':irc.example.com CAP * NAK :extended-join') + assert mockbot.backend.message_sent[3:] == [] + mockbot.on_message(':irc.example.com CAP * NAK :account-tag') + + assert not mockbot.capabilities.is_enabled('account-notify') + assert not mockbot.capabilities.is_enabled('extended-join') + assert not mockbot.capabilities.is_enabled('account-tag') + assert mockbot.cap_requests.is_complete + assert mockbot.backend.message_sent[3:] == rawlist( + 'CAP END', + ) + + +def test_cap_ack_config_error(mockbot: Sopel): + @plugin.capability('example/cap') + def cap_req( + cap_req: Tuple[str, ...], + bot: SopelWrapper, + acknowledged: bool, + ) -> None: + raise config.ConfigurationError('Improperly configured.') + + mockbot.cap_requests.register('test', cap_req) + mockbot.on_message(':irc.example.com CAP * LS :example/cap') + assert mockbot.backend.message_sent == rawlist( + 'CAP REQ :example/cap', + ) + + mockbot.on_message(':irc.example.com CAP * ACK :example/cap') + assert mockbot.backend.message_sent[1:] == rawlist( + 'CAP END', + 'QUIT :Wrong configuration.', + ) + + +def test_cap_ack_error(mockbot: Sopel): + @plugin.capability('example/cap') + def cap_req( + cap_req: Tuple[str, ...], + bot: SopelWrapper, + acknowledged: bool, + ) -> None: + raise Exception('Random error.') + + mockbot.cap_requests.register('test', cap_req) + mockbot.on_message(':irc.example.com CAP * LS :example/cap') + assert mockbot.backend.message_sent == rawlist( + 'CAP REQ :example/cap', + ) + + mockbot.on_message(':irc.example.com CAP * ACK :example/cap') + assert mockbot.backend.message_sent[1:] == rawlist( + 'CAP END', + 'QUIT :Error negotiating capabilities.', + ) + + +def test_cap_nak_config_error(mockbot: Sopel): + @plugin.capability('example/cap') + def cap_req( + cap_req: Tuple[str, ...], + bot: SopelWrapper, + acknowledged: bool, + ) -> None: + raise config.ConfigurationError('Improperly configured.') + + mockbot.cap_requests.register('test', cap_req) + mockbot.on_message(':irc.example.com CAP * LS :example/cap') + assert mockbot.backend.message_sent == rawlist( + 'CAP REQ :example/cap', + ) + + mockbot.on_message(':irc.example.com CAP * NAK :example/cap') + assert mockbot.backend.message_sent[1:] == rawlist( + 'CAP END', + 'QUIT :Wrong configuration.', + ) + + +def test_cap_nak_error(mockbot: Sopel): + @plugin.capability('example/cap') + def cap_req( + cap_req: Tuple[str, ...], + bot: SopelWrapper, + acknowledged: bool, + ) -> None: + raise Exception('Random error.') + + mockbot.cap_requests.register('test', cap_req) + mockbot.on_message(':irc.example.com CAP * LS :example/cap') + assert mockbot.backend.message_sent == rawlist( + 'CAP REQ :example/cap', + ) + + mockbot.on_message(':irc.example.com CAP * NAK :example/cap') + assert mockbot.backend.message_sent[1:] == rawlist( + 'CAP END', + 'QUIT :Error negotiating capabilities.', + ) + + +def test_cap_list(mockbot: Sopel): + mockbot.on_message(':irc.example.com CAP * LIST :example/cap') + assert mockbot.backend.message_sent == [], 'Nothing sent after a LIST' + assert not mockbot.capabilities.is_enabled('example/cap'), ( + 'LIST is not supported yet.') + + +def test_cap_new(mockbot: Sopel): + assert not mockbot.capabilities.is_available('example/cap') + mockbot.on_message(':irc.example.com CAP * NEW :example/cap') + assert mockbot.backend.message_sent == [], 'Nothing sent after a NEW' + assert mockbot.capabilities.is_available('example/cap') + assert not mockbot.capabilities.is_enabled('example/cap') + + +def test_cap_del(mockbot: Sopel): + mockbot.on_message(':irc.example.com CAP * LS :example/cap') + mockbot.on_message(':irc.example.com CAP * ACK :example/cap') + assert mockbot.backend.message_sent == rawlist( + 'CAP END' + ), 'Capability negotiation must have ended.' + assert mockbot.capabilities.is_available('example/cap') + assert mockbot.capabilities.is_enabled('example/cap') + + mockbot.on_message(':irc.example.com CAP * DEL :example/cap') + assert mockbot.backend.message_sent[1:] == [], 'Nothing sent after a DEL' + assert not mockbot.capabilities.is_available('example/cap') + assert not mockbot.capabilities.is_enabled('example/cap') + + +def test_cap_deprecated_cap_req_ack(mockbot: Sopel): + mockfn = mock.Mock() + mockbot.cap_req( + 'example', + 'example/cap', + failure_callback=mockfn.cap_failure, + success_callback=mockfn.cap_success, + ) + mockbot.cap_req( + 'example', + '=example/req', + failure_callback=mockfn.req_failure, + success_callback=mockfn.req_success, + ) + mockbot.cap_req( + 'example', + '-example/cap2', + failure_callback=mockfn.cap2_failure, + success_callback=mockfn.cap2_success, + ) + mockbot.cap_req( + 'example', + '~example/req2', + failure_callback=mockfn.req2_failure, + success_callback=mockfn.req2_success, + ) + assert mockbot.cap_requests.is_registered(('example/cap',)) + assert mockbot.cap_requests.is_registered(('example/req',)) + assert mockbot.cap_requests.is_registered(('-example/cap2',)) + assert mockbot.cap_requests.is_registered(('-example/req2',)) + + mockbot.on_message( + ':irc.example.com CAP * LS ' + ':example/cap example/cap2 example/req example/req2', + ) + assert mockbot.backend.message_sent == rawlist( + 'CAP REQ :example/cap', + 'CAP REQ :example/req', + 'CAP REQ :-example/cap2', + 'CAP REQ :-example/req2', + ) + + mockbot.on_message(':irc.example.com CAP * ACK :example/cap') + mockfn.cap_success.assert_called_once() + mockfn.cap_failure.assert_not_called() + + mockbot.on_message(':irc.example.com CAP * ACK :-example/cap2') + mockfn.cap2_success.assert_called_once() + mockfn.cap2_failure.assert_not_called() + + mockbot.on_message(':irc.example.com CAP * ACK :example/req') + mockfn.req_success.assert_called_once() + mockfn.req_failure.assert_not_called() + + mockbot.on_message(':irc.example.com CAP * ACK :-example/req2') + mockfn.req2_success.assert_called_once() + mockfn.req2_failure.assert_not_called() + + assert mockbot.backend.message_sent[4:] == rawlist( + 'CAP END', + ) + + +def test_cap_deprecated_cap_req_nak(mockbot: Sopel): + mockfn = mock.Mock() + mockbot.cap_req( + 'example', + 'example/cap', + failure_callback=mockfn.cap_failure, + success_callback=mockfn.cap_success, + ) + mockbot.cap_req( + 'example', + '=example/req', + failure_callback=mockfn.req_failure, + success_callback=mockfn.req_success, + ) + mockbot.cap_req( + 'example', + '-example/cap2', + failure_callback=mockfn.cap2_failure, + success_callback=mockfn.cap2_success, + ) + mockbot.cap_req( + 'example', + '~example/req2', + failure_callback=mockfn.req2_failure, + success_callback=mockfn.req2_success, + ) + assert mockbot.cap_requests.is_registered(('example/cap',)) + assert mockbot.cap_requests.is_registered(('example/req',)) + assert mockbot.cap_requests.is_registered(('-example/cap2',)) + assert mockbot.cap_requests.is_registered(('-example/req2',)) + + mockbot.on_message( + ':irc.example.com CAP * LS ' + ':example/cap example/cap2 example/req example/req2', + ) + assert mockbot.backend.message_sent == rawlist( + 'CAP REQ :example/cap', + 'CAP REQ :example/req', + 'CAP REQ :-example/cap2', + 'CAP REQ :-example/req2', + ) + + mockbot.on_message(':irc.example.com CAP * NAK :example/cap') + mockfn.cap_success.assert_not_called() + mockfn.cap_failure.assert_called_once() + + mockbot.on_message(':irc.example.com CAP * NAK :-example/cap2') + mockfn.cap2_success.assert_not_called() + mockfn.cap2_failure.assert_called_once() + + mockbot.on_message(':irc.example.com CAP * NAK :example/req') + mockfn.req_success.assert_not_called() + mockfn.req_failure.assert_called_once() + + mockbot.on_message(':irc.example.com CAP * NAK :-example/req2') + mockfn.req2_success.assert_not_called() + mockfn.req2_failure.assert_called_once() + + assert mockbot.backend.message_sent[4:] == rawlist( + 'CAP END', + ) diff --git a/test/coretasks/test_coretasks_sasl.py b/test/coretasks/test_coretasks_sasl.py new file mode 100644 index 0000000000..7ad532583b --- /dev/null +++ b/test/coretasks/test_coretasks_sasl.py @@ -0,0 +1,195 @@ +"""Test behavior of SASL by ``sopel.coretasks``""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +from sopel import coretasks +from sopel.tests import rawlist + +if TYPE_CHECKING: + from sopel.config import Config + from sopel.tests.factories import BotFactory, ConfigFactory + + +TMP_CONFIG_NO_SASL = """ +[core] +owner = Uowner +nick = TestBot +enable = coretasks +""" + + +TMP_CONFIG_SASL_DEFAULT = """ +[core] +owner = Uowner +nick = TestBot +enable = coretasks +auth_method = sasl +auth_password = secret +""" + + +TMP_CONFIG_SASL_NO_PASSWORD = """ +[core] +owner = Uowner +nick = TestBot +enable = coretasks +auth_method = sasl +""" + + +@pytest.fixture +def tmpconfig(configfactory: ConfigFactory) -> Config: + return configfactory('conf.ini', TMP_CONFIG_SASL_DEFAULT) + + +def test_sasl_plain_token_generation() -> None: + """Make sure SASL PLAIN tokens match the expected format.""" + assert ( + coretasks._make_sasl_plain_token('sopel', 'sasliscool') == + 'sopel\x00sopel\x00sasliscool') + + +def test_sasl_not_configured( + configfactory: ConfigFactory, + botfactory: BotFactory, +) -> None: + settings = configfactory('conf.ini', TMP_CONFIG_NO_SASL) + mockbot = botfactory.preloaded(settings) + mockbot.backend.connected = True + + # connect + mockbot.on_connect() + expected = 3 + assert len(mockbot.backend.message_sent) == expected, 'Sanity check failed' + + # list capabilities + mockbot.on_message(':irc.example.com CAP * LS :sasl=PLAIN,EXTERNAL') + assert mockbot.backend.message_sent[expected:] == rawlist( + 'CAP REQ :sasl' + ), 'Only SASL was listed, only SASL must be requested.' + n = len(mockbot.backend.message_sent) + + # ACK sasl capability + mockbot.on_message(':irc.example.com CAP * ACK :sasl') + assert mockbot.backend.message_sent[n:] == rawlist( + 'CAP END', + ), 'SASL is requested but not configured, so negotiation must end.' + + +def test_sasl_plain(botfactory: BotFactory, tmpconfig) -> None: + mockbot = botfactory.preloaded(tmpconfig, preloads=['coretasks']) + mockbot.backend.connected = True + + # connect + mockbot.on_connect() + expected = 3 + assert len(mockbot.backend.message_sent) == expected, 'Sanity check failed' + + # list capabilities + mockbot.on_message(':irc.example.com CAP * LS :sasl=PLAIN,EXTERNAL') + assert mockbot.backend.message_sent[expected:] == rawlist( + 'CAP REQ :sasl' + ), 'Only SASL was listed, only SASL must be requested.' + n = len(mockbot.backend.message_sent) + + # ACK sasl capability + mockbot.on_message(':irc.example.com CAP * ACK :sasl') + assert mockbot.backend.message_sent[n:] == rawlist( + 'AUTHENTICATE PLAIN', + ) + n = len(mockbot.backend.message_sent) + + # Server waiting for authentication + mockbot.on_message(':irc.example.com AUTHENTICATE +') + assert mockbot.backend.message_sent[n:] == rawlist( + 'AUTHENTICATE VGVzdEJvdABUZXN0Qm90AHNlY3JldA==', + ) + n = len(mockbot.backend.message_sent) + + # Server accept authentication + mockbot.on_message( + ':irc.example.com 900 TestBot TestBot!sopel@example.com sopel ' + ':You are now logged in as TestBot') + mockbot.on_message( + ':irc.example.com 903 TestBot :SASL authentication successful') + assert mockbot.backend.message_sent[n:] == rawlist( + 'CAP END', + ) + + +def test_sasl_plain_no_password( + botfactory: BotFactory, + configfactory: ConfigFactory, +) -> None: + tmpconfig = configfactory('conf.ini', TMP_CONFIG_SASL_NO_PASSWORD) + mockbot = botfactory.preloaded(tmpconfig, preloads=['coretasks']) + mockbot.backend.connected = True + + # connect and capability negotiation + mockbot.on_connect() + mockbot.on_message(':irc.example.com CAP * LS :sasl=PLAIN,EXTERNAL') + n = len(mockbot.backend.message_sent) + mockbot.on_message(':irc.example.com CAP * ACK :sasl') + assert mockbot.backend.message_sent[n:] == rawlist( + 'CAP END', + 'QUIT :Wrong configuration.' + ), 'No password is a configuration error and must the bot must quit.' + + +def test_sasl_plain_bad_password(botfactory: BotFactory, tmpconfig) -> None: + mockbot = botfactory.preloaded(tmpconfig, preloads=['coretasks']) + mockbot.backend.connected = True + + # connect and capability negotiation + mockbot.on_connect() + mockbot.on_message(':irc.example.com CAP * LS :sasl=PLAIN,EXTERNAL') + mockbot.on_message(':irc.example.com CAP * ACK :sasl') + mockbot.on_message(':irc.example.com AUTHENTICATE +') + n = len(mockbot.backend.message_sent) + + # upon receiving the password, let's say it's invalid + mockbot.on_message( + ':irc.example.com 904 TestBot :SASL authentication failed') + assert mockbot.backend.message_sent[n:] == rawlist( + 'CAP END', + 'QUIT :SASL Auth Failed' + ), 'Upon failure, corestaks must end capability negotiation.' + + +def test_sasl_plain_not_supported(botfactory: BotFactory, tmpconfig) -> None: + mockbot = botfactory.preloaded(tmpconfig, preloads=['coretasks']) + mockbot.backend.connected = True + + # connect + mockbot.on_connect() + + # capability negotiation + mockbot.on_message(':irc.example.com CAP * LS :sasl=EXTERNAL') + n = len(mockbot.backend.message_sent) + + # ACK sasl + mockbot.on_message(':irc.example.com CAP * ACK :sasl') + + assert mockbot.backend.message_sent[n:] == rawlist( + 'CAP END', + 'QUIT :Wrong configuration.', + ), 'SASL mech is not available so we must stop here.' + + +def test_sasl_nak(botfactory: BotFactory, tmpconfig) -> None: + mockbot = botfactory.preloaded(tmpconfig, preloads=['coretasks']) + mockbot.backend.connected = True + + # connect and capability negotiation + mockbot.on_connect() + mockbot.on_message(':irc.example.com CAP * LS :sasl=PLAIN,EXTERNAL') + n = len(mockbot.backend.message_sent) + mockbot.on_message(':irc.example.com CAP * NAK :sasl') + + assert mockbot.backend.message_sent[n:] == rawlist( + 'CAP END', + 'QUIT :Error negotiating capabilities.', + ) diff --git a/test/test_coretasks.py b/test/test_coretasks.py index 5b8d145a8f..d7968a1114 100644 --- a/test/test_coretasks.py +++ b/test/test_coretasks.py @@ -456,7 +456,7 @@ def test_handle_isupport_uhnames(mockbot): def test_handle_isupport_namesx_with_multi_prefix(mockbot): # set multi-prefix - mockbot.server_capabilities['multi-prefix'] = None + mockbot.on_message(':irc.example.com CAP Sopel ACK :multi-prefix') # send NAMESX in ISUPPORT mockbot.on_message( @@ -498,13 +498,6 @@ def test_handle_rpl_myinfo(mockbot): assert mockbot.myinfo.version == 'example-1.2.3' -def test_sasl_plain_token_generation(): - """Make sure SASL PLAIN tokens match the expected format.""" - assert ( - coretasks._make_sasl_plain_token('sopel', 'sasliscool') == - 'sopel\x00sopel\x00sasliscool') - - def test_recv_chghost(mockbot, ircfactory): """Ensure that CHGHOST messages are correctly handled.""" irc = ircfactory(mockbot)