From 1845a0951bdcb2ba0e6d736ca5fd260a042766e5 Mon Sep 17 00:00:00 2001 From: Phan Trung Thanh Date: Tue, 14 Jan 2025 02:23:21 +0100 Subject: [PATCH] ISD-2817 Add mas to pebble plan (#620) --- .trivyignore | 6 +- actions.yaml | 15 +- config.yaml | 6 - .../moderation-and-spam-control.md | 20 +- pyproject.toml | 2 +- src-docs/admin_access_token.py.md | 67 --- src-docs/charm.py.md | 14 +- src-docs/mjolnir.py.md | 118 ----- src-docs/pebble.py.md | 93 ++-- src/actions/__init__.py | 7 - src/actions/register_user.py | 77 --- src/admin_access_token.py | 118 ----- src/auth/mas.py | 208 +++++++- src/charm.py | 118 ++--- src/mjolnir.py | 231 -------- src/pebble.py | 114 ++-- src/state/charm_state.py | 2 - src/state/mas.py | 29 +- src/state/validation.py | 11 +- src/synapse/__init__.py | 15 +- src/synapse/admin.py | 74 --- src/synapse/api.py | 180 ------- src/synapse/workload.py | 47 -- src/synapse/workload_configuration.py | 6 +- synapse_rock/etc/nginx.conf | 20 + synapse_rock/rockcraft.yaml | 3 +- templates/mas_config.yaml.j2 | 16 + templates/mjolnir_production.yaml | 35 -- tests/integration/conftest.py | 69 ++- tests/integration/helpers.py | 27 - tests/integration/test_charm.py | 237 +-------- tests/unit/conftest.py | 8 +- tests/unit/test_action.py | 192 +++++++ tests/unit/test_admin_access_token.py | 134 ----- tests/unit/test_admin_create_user.py | 75 --- tests/unit/test_anonymize_user_action.py | 133 ----- tests/unit/test_charm.py | 13 +- tests/unit/test_charm_state.py | 26 + tests/unit/test_mas.py | 15 +- tests/unit/test_mjolnir.py | 497 ------------------ tests/unit/test_promote_user_admin_action.py | 129 ----- tests/unit/test_register_user_action.py | 129 ----- tests/unit/test_smtp_observer.py | 25 + tests/unit/test_synapse_api.py | 361 ------------- tests/unit/test_synapse_workload.py | 61 --- 45 files changed, 783 insertions(+), 3000 deletions(-) delete mode 100644 src-docs/admin_access_token.py.md delete mode 100644 src-docs/mjolnir.py.md delete mode 100644 src/actions/__init__.py delete mode 100644 src/actions/register_user.py delete mode 100644 src/admin_access_token.py delete mode 100644 src/mjolnir.py delete mode 100644 src/synapse/admin.py delete mode 100644 templates/mjolnir_production.yaml create mode 100644 tests/unit/test_action.py delete mode 100644 tests/unit/test_admin_access_token.py delete mode 100644 tests/unit/test_admin_create_user.py delete mode 100644 tests/unit/test_anonymize_user_action.py delete mode 100644 tests/unit/test_mjolnir.py delete mode 100644 tests/unit/test_promote_user_admin_action.py delete mode 100644 tests/unit/test_register_user_action.py diff --git a/.trivyignore b/.trivyignore index b670ff9f..a2827e4a 100644 --- a/.trivyignore +++ b/.trivyignore @@ -53,10 +53,6 @@ CVE-2024-52804 # Fix ongoing: # https://github.com/element-hq/synapse/pull/17985 CVE-2024-53981 -# The 3 following CVEs will be fixed by Synapse 1.120.2 -CVE-2024-52805 -CVE-2024-52815 -CVE-2024-53863 # This should be removed once pebble releases a new version. # https://github.com/canonical/pebble/commit/0c134f8e0d80f4bd8f42011279c8f0737b59a673 -CVE-2024-45338 \ No newline at end of file +CVE-2024-45338 diff --git a/actions.yaml b/actions.yaml index f9fe0d2e..c236215c 100644 --- a/actions.yaml +++ b/actions.yaml @@ -27,15 +27,20 @@ register-user: default: false required: - username -promote-user-admin: +verify-user-email: description: | - Promote a user as a server administrator. - You need to supply a user name. + Verify an user's email. + You need to supply an username and the email to verify. properties: username: - description: | - User name to be promoted to admin. + description: The username. + type: string + email: + description: The email to verify. type: string + required: + - username + - email create-backup: description: | Creates a backup to s3 storage. diff --git a/config.yaml b/config.yaml index 7a5692e7..77e532be 100644 --- a/config.yaml +++ b/config.yaml @@ -22,12 +22,6 @@ options: default: false description: | Configures whether to enable e-mail notifications. Requires SMTP integration. - enable_mjolnir: - type: boolean - default: false - description: | - Configures whether to enable Mjolnir - moderation tool for Matrix. - Reference: https://github.com/matrix-org/mjolnir enable_password_config: type: boolean default: true diff --git a/docs/explanation/moderation-and-spam-control.md b/docs/explanation/moderation-and-spam-control.md index 094b8663..a21f47a8 100644 --- a/docs/explanation/moderation-and-spam-control.md +++ b/docs/explanation/moderation-and-spam-control.md @@ -60,22 +60,4 @@ For details and implementation, visit the module’s repository: [Synapse Invite ## Mjolnir -Synapse charm also has Mjolnir in place. Mjolnir is an all-in-one moderation -tool designed to protect Synapse server from malicious invites, spam messages, -and other unwanted activities. - -### Key features - -- Bans and redactions: Quickly remove malicious users and their messages from -rooms. -- Anti-spam: Automatically detect and mitigate spam activity. -- Server ACLs: Manage and enforce access control lists at the server level. -- Room directory changes and alias transfers: Adjust room visibility and manage -aliases efficiently. -- Account deactivation: Disable abusive or compromised accounts. -- Room shutdown: Close problematic rooms completely. - -### More information - -For more details and implementation guidance, refer to the [Mjolnir GitHub repository](https://github.com/matrix-org/mjolnir). - +With the arrival of MAS mjolnir has been temporary disabled. \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 101acc8f..c0377831 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,7 @@ extension-pkg-whitelist = "pydantic" # Formatting tools configuration [tool.black] line-length = 99 -target-version = ["py38"] +target-version = ["py310"] [tool.isort] line_length = 99 diff --git a/src-docs/admin_access_token.py.md b/src-docs/admin_access_token.py.md deleted file mode 100644 index 500f6cec..00000000 --- a/src-docs/admin_access_token.py.md +++ /dev/null @@ -1,67 +0,0 @@ - - - - -# module `admin_access_token.py` -The Admin Access Token service. - -**Global Variables** ---------------- -- **JUJU_HAS_SECRETS** -- **SECRET_ID** -- **SECRET_KEY** - - ---- - -## class `AdminAccessTokenService` -The Admin Access Token Service. - -Attrs: _app: instance of Juju application. _model: instance of Juju model. - - - -### function `__init__` - -```python -__init__(app: Application, model: Model) -``` - -Initialize the service. - - - -**Args:** - - - `app`: instance of Juju application. - - `model`: instance of Juju model. - - - - ---- - - - -### function `get` - -```python -get(container: Container) → Optional[str] -``` - -Get an admin access token. - -If the admin token is not valid or it does not exist it creates one. - - - -**Args:** - - - `container`: Workload container. - - - -**Returns:** - admin access token or None if fails. - - diff --git a/src-docs/charm.py.md b/src-docs/charm.py.md index 84619597..3568d8b2 100644 --- a/src-docs/charm.py.md +++ b/src-docs/charm.py.md @@ -20,7 +20,7 @@ Charm the service. Attrs: on: listen to Redis events. - + ### function `__init__` @@ -94,7 +94,7 @@ Build charm state. --- - + ### function `get_main_unit` @@ -111,7 +111,7 @@ Get main unit. --- - + ### function `get_main_unit_address` @@ -128,7 +128,7 @@ Get main unit address. If main unit is None, use unit name. --- - + ### function `get_signing_key` @@ -204,7 +204,7 @@ Verify if this unit is the main. --- - + ### function `peer_units_total` @@ -242,7 +242,7 @@ This is the main entry for changes that require a restart. --- - + ### function `set_main_unit` @@ -260,7 +260,7 @@ Create/Renew an admin access token and put it in the peer relation. --- - + ### function `set_signing_key` diff --git a/src-docs/mjolnir.py.md b/src-docs/mjolnir.py.md deleted file mode 100644 index c430ab9d..00000000 --- a/src-docs/mjolnir.py.md +++ /dev/null @@ -1,118 +0,0 @@ - - - - -# module `mjolnir.py` -Provide the Mjolnir class to represent the Mjolnir plugin for Synapse. - -**Global Variables** ---------------- -- **MJOLNIR_SERVICE_NAME** -- **USERNAME** - - ---- - -## class `Mjolnir` -A class representing the Mjolnir plugin for Synapse application. - -Mjolnir is a moderation tool for Matrix to be used to protect your server from malicious invites, spam messages etc. See https://github.com/matrix-org/mjolnir/ for more details about it. - - - -### function `__init__` - -```python -__init__(charm: CharmBaseWithState, token_service: AdminAccessTokenService) -``` - -Initialize a new instance of the Mjolnir class. - - - -**Args:** - - - `charm`: The charm object that the Mjolnir instance belongs to. - - `token_service`: Instance of Admin Access Token Service. - - ---- - -#### property model - -Shortcut for more simple access the model. - - - ---- - - - -### function `enable_mjolnir` - -```python -enable_mjolnir(charm_state: CharmState, admin_access_token: str) → None -``` - -Enable mjolnir service. - -The required steps to enable Mjolnir are: - - Get an admin access token. - - Check if the MJOLNIR_MEMBERSHIP_ROOM room is created. - -- Only users from there will be allowed to join the management room. - - Create Mjolnir user or get its access token if already exists. - - Create the management room or get its room id if already exists. - -- The management room will allow only members of MJOLNIR_MEMBERSHIP_ROOM room to join it. - - Make the Mjolnir user admin of this room. - - Create the Mjolnir configuration file. - - Override Mjolnir user rate limit. - - Finally, add Mjolnir pebble layer. - - - -**Args:** - - - `charm_state`: Instance of CharmState. - - `admin_access_token`: not empty admin access token. - ---- - - - -### function `get_charm` - -```python -get_charm() → CharmBaseWithState -``` - -Return the current charm. - - - -**Returns:** - The current charm - ---- - - - -### function `get_membership_room_id` - -```python -get_membership_room_id(admin_access_token: str) → Optional[str] -``` - -Check if membership room exists. - - - -**Args:** - - - `admin_access_token`: not empty admin access token. - - - -**Returns:** - The room id or None if is not found. - - diff --git a/src-docs/pebble.py.md b/src-docs/pebble.py.md index afa27bfc..746ea758 100644 --- a/src-docs/pebble.py.md +++ b/src-docs/pebble.py.md @@ -7,12 +7,14 @@ Class to interact with pebble. **Global Variables** --------------- -- **STATS_EXPORTER_SERVICE_NAME** - **MAS_CONFIGURATION_PATH** +- **MAS_PEBBLE_LAYER** +- **MAS_SERVICE_NAME** +- **STATS_EXPORTER_SERVICE_NAME** --- - + ## function `check_synapse_alive` @@ -37,7 +39,7 @@ Return the Synapse container alive check. --- - + ## function `check_synapse_ready` @@ -56,7 +58,7 @@ Return the Synapse container ready check. --- - + ## function `restart_synapse` @@ -83,7 +85,7 @@ This will force a restart even if its plan hasn't changed. --- - + ## function `check_nginx_ready` @@ -102,26 +104,7 @@ Return the Synapse NGINX container check. --- - - -## function `check_mjolnir_ready` - -```python -check_mjolnir_ready() → CheckDict -``` - -Return the Synapse Mjolnir service check. - - - -**Returns:** - - - `Dict`: check object converted to its dict representation. - - ---- - - + ## function `restart_nginx` @@ -141,7 +124,7 @@ Restart Synapse NGINX service and regenerate configuration. --- - + ## function `restart_federation_sender` @@ -161,34 +144,38 @@ Restart Synapse federation sender service and regenerate configuration. --- - + -## function `replan_mjolnir` +## function `replan_stats_exporter` ```python -replan_mjolnir(container: Container) → None +replan_stats_exporter(container: Container, charm_state: CharmState) → None ``` -Replan Synapse Mjolnir service. +Replan Synapse StatsExporter service. **Args:** - `container`: Charm container. + - `charm_state`: Instance of CharmState. --- - + -## function `replan_stats_exporter` +## function `replan_synapse_federation_sender` ```python -replan_stats_exporter(container: Container, charm_state: CharmState) → None +replan_synapse_federation_sender( + container: Container, + charm_state: CharmState +) → None ``` -Replan Synapse StatsExporter service. +Replan Synapse Federation Sender service. @@ -200,30 +187,26 @@ Replan Synapse StatsExporter service. --- - + -## function `replan_synapse_federation_sender` +## function `replan_mas` ```python -replan_synapse_federation_sender( - container: Container, - charm_state: CharmState -) → None +replan_mas(container: Container) → None ``` -Replan Synapse Federation Sender service. +Replan Matrix Authentication Service. **Args:** - `container`: Charm container. - - `charm_state`: Instance of CharmState. --- - + ## function `reconcile` @@ -231,6 +214,7 @@ Replan Synapse Federation Sender service. reconcile( charm_state: CharmState, rendered_mas_configuration: str, + synapse_msc3861_configuration: dict, container: Container, is_main: bool = True, unit_number: str = '' @@ -247,6 +231,7 @@ This is the main entry for changes that require a restart done via Pebble. - `charm_state`: Instance of CharmState - `rendered_mas_configuration`: Rendered MAS yaml configuration. + - `synapse_msc3861_configuration`: Synapse's msc3861 configuration - `container`: Charm container. - `is_main`: if unit is main. - `unit_number`: unit number id to set the worker name. @@ -258,6 +243,26 @@ This is the main entry for changes that require a restart done via Pebble. - `PebbleServiceError`: if something goes wrong while interacting with Pebble. +--- + + + +## function `restart_mas` + +```python +restart_mas(container: Container, rendered_mas_configuration: str) → None +``` + +Update MAS configuration and restart MAS. + + + +**Args:** + + - `container`: The synapse container. + - `rendered_mas_configuration`: YAML configuration for MAS. + + --- ## class `PebbleServiceError` @@ -265,7 +270,7 @@ Exception raised when something fails while interacting with Pebble. Attrs: msg (str): Explanation of the error. - + ### function `__init__` diff --git a/src/actions/__init__.py b/src/actions/__init__.py deleted file mode 100644 index 389830fa..00000000 --- a/src/actions/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -# Copyright 2025 Canonical Ltd. -# See LICENSE file for licensing details. - -"""Actions package is used to run actions provided by the charm.""" - -# Exporting methods to be used for another modules -from .register_user import RegisterUserError, register_user # noqa: F401 diff --git a/src/actions/register_user.py b/src/actions/register_user.py deleted file mode 100644 index bd391069..00000000 --- a/src/actions/register_user.py +++ /dev/null @@ -1,77 +0,0 @@ -#!/usr/bin/env python3 - -# Copyright 2025 Canonical Ltd. -# See LICENSE file for licensing details. - -"""Module to interact with Register User action.""" - -import logging -import typing - -import ops - -# pydantic is causing this no-name-in-module problem -from pydantic.v1 import ValidationError # pylint: disable=no-name-in-module,import-error - -import synapse -from user import User - -logger = logging.getLogger(__name__) - - -class RegisterUserError(Exception): - """Exception raised when something fails while running register-user. - - Attrs: - msg (str): Explanation of the error. - """ - - def __init__(self, msg: str): - """Initialize a new instance of the RegisterUserError exception. - - Args: - msg (str): Explanation of the error. - """ - self.msg = msg - - -# access_token is not a password -def register_user( - container: ops.Container, - username: str, - admin: bool, - admin_access_token: typing.Optional[str] = None, - server: str = "", -) -> User: - """Run register user action. - - Args: - container: Container of the charm. - username: username to be registered. - admin: if user is admin. - server: to be used to create the user id. - admin_access_token: server admin access token to get user's access token if it exists. - - Raises: - RegisterUserError: if something goes wrong while registering the user. - - Returns: - User with password registered. - """ - try: - registration_shared_secret = synapse.get_registration_shared_secret(container=container) - if registration_shared_secret is None: - raise RegisterUserError( - "registration_shared_secret was not found, please check the logs" - ) - user = User(username=username, admin=admin) - access_token = synapse.register_user( - registration_shared_secret=registration_shared_secret, - user=user, - admin_access_token=admin_access_token, - server=server, - ) - user.access_token = access_token - return user - except (ValidationError, synapse.APIError) as exc: - raise RegisterUserError(str(exc)) from exc diff --git a/src/admin_access_token.py b/src/admin_access_token.py deleted file mode 100644 index 3de1fe23..00000000 --- a/src/admin_access_token.py +++ /dev/null @@ -1,118 +0,0 @@ -# Copyright 2025 Canonical Ltd. -# See LICENSE file for licensing details. - -# While this is a refactor,is expected to have few public methods. -# pylint: disable=too-few-public-methods - -"""The Admin Access Token service.""" -import logging -import typing - -import ops -from ops.jujuversion import JujuVersion - -import synapse - -logger = logging.getLogger(__name__) - -JUJU_HAS_SECRETS = JujuVersion.from_environ().has_secrets -# Disabling it since these are not hardcoded password -SECRET_ID = "secret-id" # nosec -SECRET_KEY = "secret-key" # nosec - - -class AdminAccessTokenService: # pragma: no cover - # TODO: Remove pragma: no cover once we get to test this class pylint: disable=fixme - """The Admin Access Token Service. - - Attrs: - _app: instance of Juju application. - _model: instance of Juju model. - """ - - def __init__(self, app: ops.Application, model: ops.Model): - """Initialize the service. - - Args: - app: instance of Juju application. - model: instance of Juju model. - """ - self._app = app - self._model = model - - def get(self, container: ops.Container) -> typing.Optional[str]: - """Get an admin access token. - - If the admin token is not valid or it does not exist it creates one. - - Args: - container: Workload container. - - Returns: - admin access token or None if fails. - """ - admin_access_token = self._get_from_peer_relation() - if admin_access_token is None or not synapse.is_token_valid(admin_access_token): - admin_access_token = self._renew_token(container) - return admin_access_token - - def _get_from_peer_relation(self) -> typing.Optional[str]: - """Get admin access token from the peer relation. - - Returns: - Admin access token or None if admin token was not found or there was an error. - """ - peer_relation = self._model.get_relation(synapse.SYNAPSE_PEER_RELATION_NAME) - if not peer_relation: - logger.error( - "Failed to get admin access token: no peer relation %s found", - synapse.SYNAPSE_PEER_RELATION_NAME, - ) - return None - admin_access_token = None - if JUJU_HAS_SECRETS: - secret_id = peer_relation.data[self._app].get(SECRET_ID) - if secret_id: - try: - secret = self._model.get_secret(id=secret_id) - admin_access_token = secret.get_content().get(SECRET_KEY) - except ops.model.SecretNotFoundError as exc: - logger.exception("Failed to get secret id %s: %s", secret_id, str(exc)) - del peer_relation.data[self._app][SECRET_ID] - return None - else: - secret_value = peer_relation.data[self._app].get(SECRET_KEY) - if secret_value: - admin_access_token = secret_value - return admin_access_token - - def _renew_token(self, container: ops.Container) -> typing.Optional[str]: - """Create/Renew an admin access token and put it in the peer relation. - - Args: - container: Workload container. - - Returns: - admin access token or None if there was an error. - """ - peer_relation = self._model.get_relation(synapse.SYNAPSE_PEER_RELATION_NAME) - if not peer_relation: - logger.error( - "Failed to get admin access token: no peer relation %s found", - synapse.SYNAPSE_PEER_RELATION_NAME, - ) - return None - admin_user = synapse.create_admin_user(container) - if not admin_user: - logger.error("Error creating admin user to get admin access token") - return None - if JUJU_HAS_SECRETS: - logger.debug("Adding admin_access_token secret to peer relation") - secret = self._app.add_secret({SECRET_KEY: admin_user.access_token}) - peer_relation.data[self._app].update({SECRET_ID: typing.cast(str, secret.id)}) - admin_access_token = admin_user.access_token - else: - logger.debug("Adding admin_access_token to peer relation") - peer_relation.data[self._app].update({SECRET_KEY: admin_user.access_token}) - admin_access_token = admin_user.access_token - return admin_access_token diff --git a/src/auth/mas.py b/src/auth/mas.py index 4582a23a..9e3d3755 100644 --- a/src/auth/mas.py +++ b/src/auth/mas.py @@ -2,25 +2,184 @@ # See LICENSE file for licensing details. """Helper module used to manage MAS-related workloads.""" - import logging +import secrets +import typing +import ops from jinja2 import Environment, FileSystemLoader, select_autoescape +from charm_types import SMTPConfiguration from state.charm_state import SynapseConfig from state.mas import MASConfiguration +logger = logging.getLogger() + MAS_TEMPLATE_FILE_NAME = "mas_config.yaml.j2" -logger = logging.getLogger() +MAS_SERVICE_NAME = "synapse-mas" +MAS_EXECUTABLE_PATH = "/usr/bin/mas-cli" +MAS_WORKING_DIR = "/mas" +MAS_CONFIGURATION_PATH = f"{MAS_WORKING_DIR}/config.yaml" + +MAS_PEBBLE_LAYER = ops.pebble.LayerDict( + { + "summary": "Matrix Authentication Service layer", + "description": "pebble config layer for MAS", + "services": { + MAS_SERVICE_NAME: { + "override": "replace", + "summary": "Matrix Authentication Service", + "startup": "enabled", + "command": f"{MAS_EXECUTABLE_PATH} server -c {MAS_CONFIGURATION_PATH}", + "working-dir": MAS_WORKING_DIR, + } + }, + } +) + +# Disabling it since this is only the label for juju secret +ADMIN_TOKEN_SECRET_LABEL = "admin.token" # nosec + + +class MASConfigInvalidError(Exception): + """Exception raised when validation of the MAS config failed.""" + + +class MASRegisterUserFailedError(Exception): + """Exception raised when validation of the MAS config failed.""" + + +class MASVerifyUserEmailFailedError(Exception): + """Exception raised when validation of the MAS config failed.""" + + +class MASGenerateAdminAccessTokenError(Exception): + """Exception raised when generation of admin token failed.""" -# pylint: disable=too-few-public-methods +def validate_mas_config(container: ops.model.Container) -> None: + """Validate current MAS configuration. + + Args: + container: Synapse container. + """ + command = [MAS_EXECUTABLE_PATH, "config", "check", "-c", MAS_CONFIGURATION_PATH] + process = container.exec(command=command, working_dir=MAS_WORKING_DIR) + process.wait_output() + + +def sync_mas_config(container: ops.model.Container) -> None: + """Sync the MAS configuration with the database. + + Args: + container: Synapse container. + """ + command = [MAS_EXECUTABLE_PATH, "config", "sync", "--prune", "-c", MAS_CONFIGURATION_PATH] + process = container.exec(command=command, working_dir=MAS_WORKING_DIR) + process.wait() + + +def register_user( + container: ops.model.Container, + username: str, + is_admin: bool = False, +) -> str: + """Register a new user with MAS. + + Args: + container: Synapse container. + username: The username. + is_admin: Whether the user is an admin. Defaults to False. + + Raises: + MASRegisterUserFailedError: when user registration fails + + Returns: + str: The generated user password + """ + password = secrets.token_hex(16) + command = [ + MAS_EXECUTABLE_PATH, + "-c", + MAS_CONFIGURATION_PATH, + "manage", + "register-user", + "--yes", + username, + "--password", + f"'{password}'", + ] + if is_admin: + command.append("--admin") + try: + process = container.exec(command=command, working_dir=MAS_WORKING_DIR) + process.wait_output() + except ops.pebble.ExecError as exc: + logger.error("Error registering new user: %s", exc.stderr) + raise MASRegisterUserFailedError("Error validating MAS configuration.") from exc + + return password + + +def verify_user_email( + container: ops.model.Container, + username: str, + email: str, +) -> None: + """Verify a user email with mas-cli. + + Args: + container: Synapse container. + username: The username. + email: The user's email. + + Raises: + MASVerifyUserEmailFailedError: when user registration fails + """ + command = [ + MAS_EXECUTABLE_PATH, + "-c", + MAS_CONFIGURATION_PATH, + "manage", + "verify-email", + username, + email, + ] + + try: + process = container.exec(command=command, working_dir=MAS_WORKING_DIR) + process.wait_output() + except ops.pebble.ExecError as exc: + logger.error("Error verifying the user email: %s", exc.stderr) + raise MASVerifyUserEmailFailedError("Error verifying the user email.") from exc + + +def deactivate_user(container: ops.model.Container, username: str) -> None: + """Deactivate an user with mas-cli. + + Args: + container: Synapse container. + username: Username to create the access token. + """ + command = [ + MAS_EXECUTABLE_PATH, + "-c", + MAS_CONFIGURATION_PATH, + "manage", + "lock-user", + "--deactivate", + username, + ] + + process = container.exec(command=command, working_dir=MAS_WORKING_DIR, combine_stderr=True) + process.wait_output() def generate_mas_config( mas_configuration: MASConfiguration, synapse_configuration: SynapseConfig, + smtp_configuration: typing.Optional[SMTPConfiguration], main_unit_address: str, ) -> str: """Render the MAS configuration file. @@ -28,6 +187,7 @@ def generate_mas_config( Args: mas_configuration: Path of the template to load. synapse_configuration: Context needed to render the template. + smtp_configuration: SMTP configuration. main_unit_address: Address of synapse main unit. Returns: @@ -48,6 +208,7 @@ def generate_mas_config( "enable_password_config": synapse_configuration.enable_password_config, "synapse_server_name_config": synapse_configuration.server_name, "synapse_main_unit_address": main_unit_address, + "smtp_configuration": smtp_configuration, } env = Environment( loader=FileSystemLoader("./templates"), @@ -58,3 +219,44 @@ def generate_mas_config( ) template = env.get_template(MAS_TEMPLATE_FILE_NAME) return template.render(context) + + +def generate_synapse_msc3861_config( + mas_configuration: MASConfiguration, synapse_configuration: SynapseConfig +) -> dict: + """Render synapse's msc3861 configuration. + + msc3861 delegates authentication to the Matrix Authentication Service (MAS). + + Args: + mas_configuration: Path of the template to load. + synapse_configuration: Context needed to render the template. + + Returns: + str: The rendered msc3861 configuration. + """ + mas_context = mas_configuration.mas_context + + mas_prefix = mas_configuration.mas_prefix + # We explicitly set the oauth2 endpoints using MAS local address + # This is to avoid problems with TLS self-signed certificates + # when the charm is behind an https ingress + mas_local_address = f"http://localhost:8081{mas_prefix}" + # MAS public address is used when redirecting the client to MAS for login + mas_public_address = f"{synapse_configuration.public_baseurl}{mas_prefix}" + return { + "enabled": True, + "issuer": mas_public_address, + "client_id": mas_context.synapse_oidc_client_id, + "client_auth_method": "client_secret_basic", + "client_secret": mas_context.synapse_oidc_client_secret, + "admin_token": mas_context.synapse_shared_secret, + "account_management_url": f"{mas_public_address}account", + "issuer_metadata": { + "authorization_endpoint": f"{mas_local_address}authorize", + "token_endpoint": f"{mas_local_address}oauth2/token", + "jwks_uri": f"{mas_local_address}oauth2/keys.json", + "registration_endpoint": f"{mas_local_address}oauth2/registration", + "introspection_endpoint": f"{mas_local_address}oauth2/introspect", + }, + } diff --git a/src/charm.py b/src/charm.py index a4b702bb..8ecf50c2 100755 --- a/src/charm.py +++ b/src/charm.py @@ -17,23 +17,27 @@ from ops import main from ops.charm import ActionEvent, RelationDepartedEvent -import actions import pebble import synapse -from admin_access_token import AdminAccessTokenService -from auth.mas import generate_mas_config +from auth.mas import ( + MASRegisterUserFailedError, + MASVerifyUserEmailFailedError, + deactivate_user, + generate_mas_config, + generate_synapse_msc3861_config, + register_user, + verify_user_email, +) from backup_observer import BackupObserver from database_observer import DatabaseObserver, SynapseDatabaseObserver from matrix_auth_observer import MatrixAuthObserver from media_observer import MediaObserver -from mjolnir import Mjolnir from observability import Observability from redis_observer import RedisObserver from smtp_observer import SMTPObserver from state.charm_state import CharmState from state.mas import MAS_DATABASE_INTEGRATION_NAME, MAS_DATABASE_NAME, MASConfiguration from state.validation import CharmBaseWithState, validate_charm_state -from user import User logger = logging.getLogger(__name__) @@ -71,7 +75,6 @@ def __init__(self, *args: typing.Any) -> None: ) self._smtp = SMTPObserver(self) self._redis = RedisObserver(self) - self.token_service = AdminAccessTokenService(app=self.app, model=self.model) # service-hostname is a required field so we're hardcoding to the same # value as service-name. service-hostname should be set via Nginx # Ingress Integrator charm config. @@ -87,7 +90,6 @@ def __init__(self, *args: typing.Any) -> None: port=synapse.SYNAPSE_NGINX_PORT, ) self._observability = Observability(self) - self._mjolnir = Mjolnir(self, token_service=self.token_service) self.framework.observe(self.on.config_changed, self._on_config_changed) self.framework.observe(self.on.leader_elected, self._on_leader_elected) self.framework.observe( @@ -99,9 +101,7 @@ def __init__(self, *args: typing.Any) -> None: ) self.framework.observe(self.on.synapse_pebble_ready, self._on_synapse_pebble_ready) self.framework.observe(self.on.register_user_action, self._on_register_user_action) - self.framework.observe( - self.on.promote_user_admin_action, self._on_promote_user_admin_action - ) + self.framework.observe(self.on.verify_user_email_action, self._on_verify_user_email_action) self.framework.observe(self.on.anonymize_user_action, self._on_anonymize_user_action) def build_charm_state(self) -> CharmState: @@ -200,6 +200,11 @@ def reconcile(self, charm_state: CharmState, mas_configuration: MASConfiguration charm_state: Instance of CharmState mas_configuration: Charm state component to configure MAS """ + logger.debug("Found %d peer unit(s).", self.peer_units_total()) + if charm_state.redis_config is None and self.peer_units_total() > 1: + logger.debug("More than 1 peer unit found. Redis is required.") + self.unit.status = ops.BlockedStatus("Redis integration is required.") + return if self.get_main_unit() is None and self.unit.is_leader(): logging.debug("Change_config is setting main unit.") self.set_main_unit(self.unit.name) @@ -218,12 +223,19 @@ def reconcile(self, charm_state: CharmState, mas_configuration: MASConfiguration signing_key_path, signing_key_from_secret, make_dirs=True, encoding="utf-8" ) rendered_mas_configuration = generate_mas_config( - mas_configuration, charm_state.synapse_config, self.get_main_unit_address() + mas_configuration, + charm_state.synapse_config, + charm_state.smtp_config, + self.get_main_unit_address(), + ) + synapse_msc3861_configuration = generate_synapse_msc3861_config( + mas_configuration, charm_state.synapse_config ) # reconcile configuration pebble.reconcile( charm_state, rendered_mas_configuration, + synapse_msc3861_configuration, container, is_main=self.is_main(), unit_number=self.get_unit_number(), @@ -297,11 +309,6 @@ def _on_config_changed(self, _: ops.HookEvent) -> None: charm_state = self.build_charm_state() mas_configuration = MASConfiguration.from_charm(self) - logger.debug("Found %d peer unit(s).", self.peer_units_total()) - if charm_state.redis_config is None and self.peer_units_total() > 1: - logger.debug("More than 1 peer unit found. Redis is required.") - self.unit.status = ops.BlockedStatus("Redis integration is required.") - return logger.debug("_on_config_changed emitting reconcile") self.reconcile(charm_state, mas_configuration) self._set_workload_version() @@ -345,11 +352,6 @@ def _on_synapse_pebble_ready(self, _: ops.HookEvent) -> None: charm_state = self.build_charm_state() mas_configuration = MASConfiguration.from_charm(self) - logger.debug("Found %d peer unit(s).", self.peer_units_total()) - if charm_state.redis_config is None and self.peer_units_total() > 1: - logger.debug("More than 1 peer unit found. Redis is required.") - self.unit.status = ops.BlockedStatus("Redis integration is required.") - return self.unit.status = ops.ActiveStatus() logger.debug("_on_synapse_pebble_ready emitting reconcile") self.reconcile(charm_state, mas_configuration) @@ -502,47 +504,37 @@ def _on_register_user_action(self, event: ActionEvent) -> None: event.fail("Failed to connect to the container") return try: - user = actions.register_user( - container=container, username=event.params["username"], admin=event.params["admin"] + password = register_user( + container=container, + username=event.params["username"], + is_admin=event.params["admin"], ) - except actions.RegisterUserError as exc: + except MASRegisterUserFailedError as exc: event.fail(str(exc)) return - results = {"register-user": True, "user-password": user.password} + results = {"register-user": True, "user-password": password} event.set_results(results) - @validate_charm_state - def _on_promote_user_admin_action(self, event: ActionEvent) -> None: - """Promote user admin and report action result. + def _on_verify_user_email_action(self, event: ActionEvent) -> None: + """Register user and report action result. Args: - event: Event triggering the promote user admin action. + event: Event triggering the register user instance action. """ - charm_state = self.build_charm_state() - MASConfiguration.validate(self) - - results = { - "promote-user-admin": False, - } container = self.unit.get_container(synapse.SYNAPSE_CONTAINER_NAME) if not container.can_connect(): event.fail("Failed to connect to the container") return try: - admin_access_token = self.token_service.get(container) - if not admin_access_token: - event.fail("Failed to get admin access token") - return - username = event.params["username"] - server = charm_state.synapse_config.server_name - user = User(username=username, admin=True) - synapse.promote_user_admin( - user=user, server=server, admin_access_token=admin_access_token + verify_user_email( + container=container, + username=event.params["username"], + email=event.params["email"], ) - results["promote-user-admin"] = True - except synapse.APIError as exc: + except MASVerifyUserEmailFailedError as exc: event.fail(str(exc)) return + results = {"verify-user-email": True} event.set_results(results) @validate_charm_state @@ -552,32 +544,22 @@ def _on_anonymize_user_action(self, event: ActionEvent) -> None: Args: event: Event triggering the anonymize user action. """ - charm_state = self.build_charm_state() - MASConfiguration.validate(self) - - results = { - "anonymize-user": False, - } container = self.unit.get_container(synapse.SYNAPSE_CONTAINER_NAME) if not container.can_connect(): - event.fail("Container not yet ready. Try again later") + event.fail("Failed to connect to the container") return + try: - admin_access_token = self.token_service.get(container) - if not admin_access_token: - event.fail("Failed to get admin access token") - return - username = event.params["username"] - server = charm_state.synapse_config.server_name - user = User(username=username, admin=False) - synapse.deactivate_user( - user=user, server=server, admin_access_token=admin_access_token - ) - results["anonymize-user"] = True - except synapse.APIError: - event.fail("Failed to anonymize the user. Check if the user is created and active.") - return - event.set_results(results) + deactivate_user(container=container, username=event.params["username"]) + except ops.pebble.ExecError as exc: + logger.exception("Error deactivating user.") + event.fail(str(exc)) + + event.set_results( + { + "anonymize-user": True, + } + ) if __name__ == "__main__": # pragma: nocover diff --git a/src/mjolnir.py b/src/mjolnir.py deleted file mode 100644 index e21cfe91..00000000 --- a/src/mjolnir.py +++ /dev/null @@ -1,231 +0,0 @@ -# Copyright 2025 Canonical Ltd. -# See LICENSE file for licensing details. - -"""Provide the Mjolnir class to represent the Mjolnir plugin for Synapse.""" - -# disabling due the fact that collect status does many checks -# pylint: disable=too-many-return-statements - -import logging -import typing - -import ops - -import pebble -import synapse -from admin_access_token import AdminAccessTokenService -from state.charm_state import CharmState -from state.mas import MASConfiguration -from state.validation import CharmBaseWithState, validate_charm_state - -logger = logging.getLogger(__name__) - -MJOLNIR_SERVICE_NAME = "mjolnir" -USERNAME = "moderator" - - -class Mjolnir(ops.Object): # pylint: disable=too-few-public-methods - """A class representing the Mjolnir plugin for Synapse application. - - Mjolnir is a moderation tool for Matrix to be used to protect your server from malicious - invites, spam messages etc. - See https://github.com/matrix-org/mjolnir/ for more details about it. - """ - - def __init__(self, charm: CharmBaseWithState, token_service: AdminAccessTokenService): - """Initialize a new instance of the Mjolnir class. - - Args: - charm: The charm object that the Mjolnir instance belongs to. - token_service: Instance of Admin Access Token Service. - """ - super().__init__(charm, "mjolnir") - self._charm = charm - self._token_service = token_service - self.framework.observe(charm.on.collect_unit_status, self._on_collect_status) - - def get_charm(self) -> CharmBaseWithState: - """Return the current charm. - - Returns: - The current charm - """ - return self._charm - - @property - def _admin_access_token(self) -> typing.Optional[str]: - """Get admin access token. - - Returns: - admin access token or None if fails. - """ - container = self._charm.unit.get_container(synapse.SYNAPSE_CONTAINER_NAME) - if not container.can_connect(): - logger.exception("Failed to connect to Synapse") - return None - access_token = self._token_service.get(container) - if not access_token: - logging.error("Admin Access Token was not found, please check the logs.") - return None - return access_token - - # Ignoring complexity warning for now - @validate_charm_state - def _on_collect_status(self, event: ops.CollectStatusEvent) -> None: # noqa: C901 - """Collect status event handler. - - Args: - event: Collect status event. - """ - charm = self.get_charm() - charm_state = charm.build_charm_state() - MASConfiguration.validate(charm) - - if not charm_state.synapse_config.enable_mjolnir: - return - container = self._charm.unit.get_container(synapse.SYNAPSE_CONTAINER_NAME) - if not container.can_connect(): - self._charm.unit.status = ops.MaintenanceStatus("Waiting for Synapse pebble") - return - mjolnir_service = container.get_services(MJOLNIR_SERVICE_NAME) - # This check is the same done in get_main_unit. It should be refactored - # to a place where both Charm and Mjolnir can get it. - peer_relation = self._charm.model.relations[synapse.SYNAPSE_PEER_RELATION_NAME] - if peer_relation: - logger.debug( - "Peer relation found, checking if is main unit before configuring Mjolnir" - ) - # The default is self._charm.unit.name to make tests that use Harness.begin() work. - # When not using begin_with_initial_hooks, the peer relation data is not created. - main_unit_id = ( - peer_relation[0].data[self._charm.app].get("main_unit_id", self._charm.unit.name) - ) - if not self._charm.unit.name == main_unit_id: - if mjolnir_service: - logger.info("This is not the main unit, stopping Mjolnir") - container.stop(MJOLNIR_SERVICE_NAME) - else: - logger.info("This is not the main unit, skipping Mjolnir configuration") - return - if mjolnir_service: - mjolnir_not_active = [ - service for service in mjolnir_service.values() if not service.is_running() - ] - if mjolnir_not_active: - logger.debug( - "%s service already exists but is not running, restarting", - MJOLNIR_SERVICE_NAME, - ) - container.restart(MJOLNIR_SERVICE_NAME) - logger.debug("%s service already exists and running, skipping", MJOLNIR_SERVICE_NAME) - return - synapse_service = container.get_services(synapse.SYNAPSE_SERVICE_NAME) - synapse_not_active = [ - service for service in synapse_service.values() if not service.is_running() - ] - if not synapse_service or synapse_not_active: - # The get_membership_room_id does a call to Synapse API in order to get the - # membership room id. This only works if Synapse is running so that's why - # the service status is checked here. - self._charm.unit.status = ops.MaintenanceStatus("Waiting for Synapse") - return - if not self._admin_access_token: - self._charm.unit.status = ops.MaintenanceStatus( - "Failed to get admin access token. Please, check the logs." - ) - return - try: - if self.get_membership_room_id(self._admin_access_token) is None: - status = ops.BlockedStatus( - f"{synapse.MJOLNIR_MEMBERSHIP_ROOM} not found and " - "is required by Mjolnir. Please, check the logs." - ) - interval = self._charm.model.config.get("update-status-hook-interval", "") - logger.error( - "The Mjolnir configuration will be done in %s after the room %s is created." - "This interval is set in update-status-hook-interval model config.", - interval, - synapse.MJOLNIR_MEMBERSHIP_ROOM, - ) - event.add_status(status) - return - except synapse.APIError as exc: - logger.exception( - "Failed to check for membership_room. Mjolnir will not be configured: %r", - exc, - ) - return - self.enable_mjolnir(charm_state, self._admin_access_token) - event.add_status(ops.ActiveStatus()) - - def get_membership_room_id(self, admin_access_token: str) -> typing.Optional[str]: - """Check if membership room exists. - - Args: - admin_access_token: not empty admin access token. - - Returns: - The room id or None if is not found. - """ - return synapse.get_room_id( - room_name=synapse.MJOLNIR_MEMBERSHIP_ROOM, admin_access_token=admin_access_token - ) - - def enable_mjolnir(self, charm_state: CharmState, admin_access_token: str) -> None: - """Enable mjolnir service. - - The required steps to enable Mjolnir are: - - Get an admin access token. - - Check if the MJOLNIR_MEMBERSHIP_ROOM room is created. - -- Only users from there will be allowed to join the management room. - - Create Mjolnir user or get its access token if already exists. - - Create the management room or get its room id if already exists. - -- The management room will allow only members of MJOLNIR_MEMBERSHIP_ROOM room to join it. - - Make the Mjolnir user admin of this room. - - Create the Mjolnir configuration file. - - Override Mjolnir user rate limit. - - Finally, add Mjolnir pebble layer. - - Args: - charm_state: Instance of CharmState. - admin_access_token: not empty admin access token. - """ - container = self._charm.unit.get_container(synapse.SYNAPSE_CONTAINER_NAME) - if not container.can_connect(): - self._charm.unit.status = ops.MaintenanceStatus("Waiting for Synapse pebble") - return - self._charm.model.unit.status = ops.MaintenanceStatus("Configuring Mjolnir") - mjolnir_user = synapse.create_user( - container, - USERNAME, - True, - admin_access_token, - str(charm_state.synapse_config.server_name), - ) - if mjolnir_user is None: - logger.error("Failed to create Mjolnir user. Mjolnir will not be configured") - return - mjolnir_access_token = mjolnir_user.access_token - room_id = synapse.get_room_id( - room_name=synapse.MJOLNIR_MANAGEMENT_ROOM, admin_access_token=admin_access_token - ) - if room_id is None: - logger.info("Room %s not found, creating", synapse.MJOLNIR_MANAGEMENT_ROOM) - room_id = synapse.create_management_room(admin_access_token=admin_access_token) - # Add the Mjolnir user to the management room - synapse.make_room_admin( - user=mjolnir_user, - server=str(charm_state.synapse_config.server_name), - admin_access_token=admin_access_token, - room_id=room_id, - ) - synapse.generate_mjolnir_config( - container=container, access_token=mjolnir_access_token, room_id=room_id - ) - synapse.override_rate_limit( - user=mjolnir_user, - admin_access_token=admin_access_token, - charm_state=charm_state, - ) - pebble.replan_mjolnir(container) - self._charm.model.unit.status = ops.ActiveStatus() diff --git a/src/pebble.py b/src/pebble.py index ff2cd786..668d658e 100644 --- a/src/pebble.py +++ b/src/pebble.py @@ -17,12 +17,18 @@ from ops.pebble import Check import synapse +from auth.mas import ( + MAS_CONFIGURATION_PATH, + MAS_PEBBLE_LAYER, + MAS_SERVICE_NAME, + sync_mas_config, + validate_mas_config, +) from state.charm_state import CharmState logger = logging.getLogger(__name__) STATS_EXPORTER_SERVICE_NAME = "stats-exporter" -MAS_CONFIGURATION_PATH = "/mas/config.yaml" class PebbleServiceError(Exception): @@ -114,22 +120,6 @@ def check_nginx_ready() -> ops.pebble.CheckDict: return check.to_dict() -def check_mjolnir_ready() -> ops.pebble.CheckDict: - """Return the Synapse Mjolnir service check. - - Returns: - Dict: check object converted to its dict representation. - """ - check = Check(synapse.CHECK_MJOLNIR_READY_NAME) - check.override = "replace" - check.level = "ready" - check.http = {"url": f"http://localhost:{synapse.MJOLNIR_HEALTH_PORT}/healthz"} - check.timeout = "10s" - check.threshold = 5 - check.period = "1m" - return check.to_dict() - - def restart_nginx(container: ops.model.Container, main_unit_address: str) -> None: """Restart Synapse NGINX service and regenerate configuration. @@ -155,16 +145,6 @@ def restart_federation_sender(container: ops.model.Container, charm_state: Charm container.restart(synapse.SYNAPSE_FEDERATION_SENDER_SERVICE_NAME) -def replan_mjolnir(container: ops.model.Container) -> None: - """Replan Synapse Mjolnir service. - - Args: - container: Charm container. - """ - container.add_layer("synapse-mjolnir", _mjolnir_pebble_layer(), combine=True) - container.replan() - - def replan_stats_exporter(container: ops.model.Container, charm_state: CharmState) -> None: """Replan Synapse StatsExporter service. @@ -207,6 +187,16 @@ def replan_synapse_federation_sender( container.replan() +def replan_mas(container: ops.model.Container) -> None: + """Replan Matrix Authentication Service. + + Args: + container: Charm container. + """ + container.add_layer(MAS_SERVICE_NAME, MAS_PEBBLE_LAYER, combine=True) + container.replan() + + def _get_synapse_config(container: ops.model.Container) -> dict: """Get the current Synapse configuration. @@ -299,9 +289,12 @@ def _environment_has_changed( # The complexity of this method will be reviewed. -def reconcile( # noqa: C901 pylint: disable=too-many-branches,too-many-statements +# pylint: disable=too-many-branches,too-many-statements +# pylint: disable=too-many-arguments,too-many-positional-arguments +def reconcile( # noqa: C901 charm_state: CharmState, rendered_mas_configuration: str, + synapse_msc3861_configuration: dict, container: ops.model.Container, is_main: bool = True, unit_number: str = "", @@ -313,6 +306,7 @@ def reconcile( # noqa: C901 pylint: disable=too-many-branches,too-many-statemen Args: charm_state: Instance of CharmState rendered_mas_configuration: Rendered MAS yaml configuration. + synapse_msc3861_configuration: Synapse's msc3861 configuration container: Charm container. is_main: if unit is main. unit_number: unit number id to set the worker name. @@ -321,6 +315,8 @@ def reconcile( # noqa: C901 pylint: disable=too-many-branches,too-many-statemen PebbleServiceError: if something goes wrong while interacting with Pebble. """ try: + restart_mas(container, rendered_mas_configuration) + if _environment_has_changed(container=container, charm_state=charm_state, is_main=is_main): # Configurations set via environment variables: # synapse_report_stats, database, and proxy @@ -374,8 +370,6 @@ def reconcile( # noqa: C901 pylint: disable=too-many-branches,too-many-statemen if charm_state.redis_config is not None: logger.debug("pebble.change_config: Enabling Redis") synapse.enable_redis(current_synapse_config, charm_state=charm_state) - if not charm_state.synapse_config.enable_password_config: - synapse.disable_password_config(current_synapse_config) if charm_state.synapse_config.federation_domain_whitelist: synapse.enable_federation_domain_whitelist( current_synapse_config, charm_state=charm_state @@ -401,6 +395,9 @@ def reconcile( # noqa: C901 pylint: disable=too-many-branches,too-many-statemen ignore_order=True, ignore_string_case=True, ) + # Activate msc3861 + synapse.configure_mas(current_synapse_config, synapse_msc3861_configuration) + if config_has_changed: logging.info("Configuration has changed, Synapse will be restarted.") logging.debug("The change is: %s", config_has_changed) @@ -418,8 +415,6 @@ def reconcile( # noqa: C901 pylint: disable=too-many-branches,too-many-statemen restart_federation_sender(container=container, charm_state=charm_state) else: logging.info("Configuration has not changed, no action.") - - _push_mas_config(container, rendered_mas_configuration, MAS_CONFIGURATION_PATH) except (synapse.WorkloadError, ops.pebble.PathError) as exc: raise PebbleServiceError(str(exc)) from exc @@ -462,23 +457,6 @@ def _pebble_layer(charm_state: CharmState, is_main: bool = True) -> ops.pebble.L return typing.cast(ops.pebble.LayerDict, layer) -def _pebble_layer_without_restart(charm_state: CharmState) -> ops.pebble.LayerDict: - """Return a dictionary representing a Pebble layer without restart. - - Args: - charm_state: Instance of CharmState - - Returns: - pebble layer - """ - new_layer = _pebble_layer(charm_state) - new_layer["services"][synapse.SYNAPSE_SERVICE_NAME]["on-success"] = "ignore" - new_layer["services"][synapse.SYNAPSE_SERVICE_NAME]["on-failure"] = "ignore" - ignore = {synapse.CHECK_READY_NAME: "ignore"} - new_layer["services"][synapse.SYNAPSE_SERVICE_NAME]["on-check-failure"] = ignore - return new_layer - - def _nginx_pebble_layer() -> ops.pebble.LayerDict: """Generate pebble config for the synapse-nginx container. @@ -503,31 +481,6 @@ def _nginx_pebble_layer() -> ops.pebble.LayerDict: return typing.cast(ops.pebble.LayerDict, layer) -def _mjolnir_pebble_layer() -> ops.pebble.LayerDict: - """Generate pebble config for the mjolnir service. - - Returns: - The pebble configuration for the mjolnir service. - """ - command_params = f"bot --mjolnir-config {synapse.MJOLNIR_CONFIG_PATH}" - layer = { - "summary": "Synapse mjolnir layer", - "description": "Synapse mjolnir layer", - "services": { - synapse.MJOLNIR_SERVICE_NAME: { - "override": "replace", - "summary": "Mjolnir service", - "command": f"/mjolnir-entrypoint.sh {command_params}", - "startup": "enabled", - }, - }, - "checks": { - synapse.CHECK_MJOLNIR_READY_NAME: check_mjolnir_ready(), - }, - } - return typing.cast(ops.pebble.LayerDict, layer) - - def _cron_pebble_layer(charm_state: CharmState) -> ops.pebble.LayerDict: """Generate pebble config for the cron service. @@ -604,3 +557,16 @@ def _pebble_layer_federation_sender(charm_state: CharmState) -> ops.pebble.Layer }, } return typing.cast(ops.pebble.LayerDict, layer) + + +def restart_mas(container: ops.model.Container, rendered_mas_configuration: str) -> None: + """Update MAS configuration and restart MAS. + + Args: + container: The synapse container. + rendered_mas_configuration: YAML configuration for MAS. + """ + _push_mas_config(container, rendered_mas_configuration, MAS_CONFIGURATION_PATH) + validate_mas_config(container) + sync_mas_config(container) + replan_mas(container) diff --git a/src/state/charm_state.py b/src/state/charm_state.py index 6fb134c4..17d889dd 100644 --- a/src/state/charm_state.py +++ b/src/state/charm_state.py @@ -57,7 +57,6 @@ class SynapseConfig(BaseModel): # pylint: disable=too-few-public-methods allow_public_rooms_over_federation: allow_public_rooms_over_federation config. block_non_admin_invites: block_non_admin_invites config. enable_email_notifs: enable_email_notifs config. - enable_mjolnir: enable_mjolnir config. enable_password_config: enable_password_config config. enable_room_list_search: enable_room_list_search config. federation_domain_whitelist: federation_domain_whitelist config. @@ -80,7 +79,6 @@ class SynapseConfig(BaseModel): # pylint: disable=too-few-public-methods allow_public_rooms_over_federation: bool = False block_non_admin_invites: bool = False enable_email_notifs: bool = False - enable_mjolnir: bool = False enable_password_config: bool = True enable_room_list_search: bool = True experimental_alive_check: str | None = Field(None) diff --git a/src/state/mas.py b/src/state/mas.py index 3a19ff39..8537055e 100644 --- a/src/state/mas.py +++ b/src/state/mas.py @@ -37,6 +37,10 @@ class MASContextValidationError(Exception): """Exception raised when validation of the MAS Context failed.""" +class MASDatasourceInvalidError(Exception): + """Exception raised when validation of the MAS datasource failed.""" + + class MASContext(BaseModel): """Context used to render MAS configuration file. @@ -120,7 +124,7 @@ def mas_prefix(self) -> str: Returns: str: The MAS listening prefix """ - return "/auth" + return "/auth/" @classmethod def from_charm(cls, charm: ops.CharmBase) -> "MASConfiguration": @@ -139,6 +143,9 @@ def from_charm(cls, charm: ops.CharmBase) -> "MASConfiguration": cls.validate(charm) # pylint: disable=protected-access datasource = charm._mas_database.get_relation_as_datasource() # type: ignore + validate_datasource(datasource) + + logger.info("Datasource validated: %s", datasource) try: secret = charm.model.get_secret(label=MAS_CONTEXT_LABEL) @@ -191,3 +198,23 @@ def validate(cls, charm: ops.CharmBase) -> None: """ if not charm.model.relations.get(MAS_DATABASE_INTEGRATION_NAME): raise MASDatasourceMissingError("Waiting for mas-database integration.") + + +def validate_datasource(datasource: DatasourcePostgreSQL) -> None: + """Validate if datasource has all the expected values. + + Args: + datasource: The fetched datasource from integration data. + + Raises: + MASDatasourceInvalidError: When datasource is incomplete. + """ + if not all( + [ + datasource.get("user"), + datasource.get("password"), + datasource.get("host"), + datasource.get("port"), + ] + ): + raise MASDatasourceInvalidError("Missing values in postgresql datasource.") diff --git a/src/state/validation.py b/src/state/validation.py index d306fbd6..3434d1e2 100644 --- a/src/state/validation.py +++ b/src/state/validation.py @@ -12,7 +12,7 @@ from state.mas import MASContextNotSetError from .charm_state import CharmConfigInvalidError, CharmState -from .mas import MASConfiguration, MASDatasourceMissingError +from .mas import MASConfiguration, MASDatasourceInvalidError, MASDatasourceMissingError logger = logging.getLogger(__name__) @@ -94,7 +94,10 @@ def wrapper(instance: C, event: E) -> None: try: return method(instance, event) - except (CharmConfigInvalidError, MASDatasourceMissingError) as exc: + except ( + CharmConfigInvalidError, + MASDatasourceMissingError, + ) as exc: logger.exception("Error initializing charm state.") # There are two main types of events, Hooks and Actions. # Each one of them should be treated differently. @@ -102,8 +105,8 @@ def wrapper(instance: C, event: E) -> None: event.fail(str(exc)) else: charm.model.unit.status = ops.BlockedStatus(str(exc)) - except MASContextNotSetError as exc: - logger.exception("MAS context not set by leader.") + except (MASContextNotSetError, MASDatasourceInvalidError) as exc: + logger.exception("Waiting for the charm to settle into a correct state.") charm.model.unit.status = ops.WaitingStatus(str(exc)) return None diff --git a/src/synapse/__init__.py b/src/synapse/__init__.py index fa91fd2f..7ee7fc4f 100644 --- a/src/synapse/__init__.py +++ b/src/synapse/__init__.py @@ -4,7 +4,6 @@ """Synapse package is used to interact with Synapse instance.""" # Exporting methods to be used for another modules -from .admin import create_admin_user, create_user # noqa: F401 from .api import ( # noqa: F401 ADD_USER_ROOM_URL, CREATE_ROOM_URL, @@ -12,34 +11,23 @@ LIST_ROOMS_URL, LIST_USERS_URL, LOGIN_URL, - MJOLNIR_MANAGEMENT_ROOM, - MJOLNIR_MEMBERSHIP_ROOM, REGISTER_URL, SYNAPSE_PORT, SYNAPSE_URL, SYNAPSE_VERSION_REGEX, VERSION_URL, APIError, - create_management_room, - deactivate_user, - get_access_token, get_room_id, get_version, is_token_valid, make_room_admin, override_rate_limit, - promote_user_admin, - register_user, ) from .workload import ( # noqa: F401 CHECK_ALIVE_NAME, - CHECK_MJOLNIR_READY_NAME, CHECK_NGINX_READY_NAME, CHECK_READY_NAME, COMMAND_MIGRATE_CONFIG, - MJOLNIR_CONFIG_PATH, - MJOLNIR_HEALTH_PORT, - MJOLNIR_SERVICE_NAME, STATS_EXPORTER_PORT, SYNAPSE_COMMAND_PATH, SYNAPSE_CONFIG_DIR, @@ -61,7 +49,6 @@ WorkloadError, create_registration_secrets_files, execute_migrate_config, - generate_mjolnir_config, generate_nginx_config, generate_worker_config, get_environment, @@ -71,7 +58,7 @@ ) from .workload_configuration import ( # noqa: F401 block_non_admin_invites, - disable_password_config, + configure_mas, disable_room_list_search, enable_allow_public_rooms_over_federation, enable_federation_domain_whitelist, diff --git a/src/synapse/admin.py b/src/synapse/admin.py deleted file mode 100644 index b67b3949..00000000 --- a/src/synapse/admin.py +++ /dev/null @@ -1,74 +0,0 @@ -#!/usr/bin/env python3 - -# Copyright 2025 Canonical Ltd. -# See LICENSE file for licensing details. - -"""Helper module used to manage admin tasks involving Synapse API and Workload.""" - -import logging -import typing -from secrets import token_hex - -import ops - -from user import User - -from .api import register_user -from .workload import get_registration_shared_secret - -logger = logging.getLogger(__name__) - - -def create_admin_user(container: ops.Container) -> typing.Optional[User]: - """Create admin user. - - Args: - container: Container of the charm. - - Returns: - Admin user with token to be used in Synapse API requests or None if fails. - """ - # The username is random because if the user exists, register_user will try to get the - # access_token. - # But to do that it needs an admin user and we don't have one yet. - # So, to be on the safe side, the user name is randomly generated and if for any reason - # there is no access token on peer data/secret, another user will be created. - # - # Using 16 to create a random value but to be secure against brute-force attacks, - # please check the docs: - # https://docs.python.org/3/library/secrets.html#how-many-bytes-should-tokens-use - username = token_hex(16) - return create_user(container=container, username=username, admin=True) - - -def create_user( - container: ops.Container, - username: str, - admin: bool = False, - admin_access_token: typing.Optional[str] = None, - server: str = "", -) -> typing.Optional[User]: - """Create user by using the registration shared secret and generating token via API. - - Args: - container: Container of the charm. - username: username to be registered. - admin: if user is admin. - server: to be used to create the user id. - admin_access_token: server admin access token to get user's access token if it exists. - - Returns: - User or none if the creation fails. - """ - registration_shared_secret = get_registration_shared_secret(container=container) - if registration_shared_secret is None: - logger.error("registration_shared_secret was not found, please check the logs") - return None - user = User(username=username, admin=admin) - user.access_token = register_user( - registration_shared_secret=registration_shared_secret, - user=user, - admin_access_token=admin_access_token, - server=server, - ) - return user diff --git a/src/synapse/api.py b/src/synapse/api.py index a1a56c95..bdfd497d 100644 --- a/src/synapse/api.py +++ b/src/synapse/api.py @@ -35,8 +35,6 @@ LIST_ROOMS_URL = f"{SYNAPSE_URL}/_synapse/admin/v1/rooms" LIST_USERS_URL = f"{SYNAPSE_URL}/_synapse/admin/v2/users?from=0&limit=10&name=" LOGIN_URL = f"{SYNAPSE_URL}/_synapse/admin/v1/users" -MJOLNIR_MANAGEMENT_ROOM = "management" -MJOLNIR_MEMBERSHIP_ROOM = "moderators" REGISTER_URL = f"{SYNAPSE_URL}/_synapse/admin/v1/register" SYNAPSE_VERSION_REGEX = r"(\d+\.\d+\.\d+(?:\w+)?)\s?" VERSION_URL = f"{SYNAPSE_URL}/_synapse/admin/v1/server_version" @@ -177,63 +175,6 @@ def _do_request( raise NetworkError(f"HTTP error from {url}.") from exc -# admin_access_token is not a password -def register_user( - registration_shared_secret: str, - user: User, - server: typing.Optional[str] = None, - admin_access_token: typing.Optional[str] = None, # nosec -) -> str: - """Register user. - - Args: - registration_shared_secret: secret to be used to register the user. - user: user to be registered. - server: to be used to create the user id. - admin_access_token: admin access token to get user's access token if it exists. - - Raises: - RegisterUserError: if there was an error registering the user. - - Returns: - Access token to be used by the user. - """ - # get nonce - nonce = _get_nonce() - # generate mac - hex_mac = _generate_mac( - shared_secret=registration_shared_secret, - nonce=nonce, - user=user.username, - password=user.password, - admin=user.admin, - ) - # build data - data = { - "nonce": nonce, - "username": user.username, - "password": user.password, - "mac": hex_mac, - "admin": user.admin, - } - # finally register user - try: - res = _do_request("POST", REGISTER_URL, json=data) - return res.json()["access_token"] - except UserExistsError as exc: - logger.warning("User %s exists, getting the access token.", user.username) - if not server or not admin_access_token: - raise RegisterUserError( - f"User {user.username} exists but there is no server/admin access token set." - ) from exc - return get_access_token( - user=user, server=str(server), admin_access_token=str(admin_access_token) - ) - except (requests.exceptions.JSONDecodeError, TypeError, KeyError) as exc: - logger.exception("Failed to decode access_token: %r. Received: %s", exc, res.text) - raise RegisterUserError(str(exc)) from exc - - def _generate_mac( # pylint: disable=too-many-positional-arguments shared_secret: str, nonce: str, @@ -331,35 +272,6 @@ def get_version(main_unit_address: str) -> str: return version_match.group(1) -def get_access_token(user: User, server: str, admin_access_token: str) -> str: - """Get an access token that can be used to authenticate as that user. - - This is a way to do actions on behalf of a user. - - Args: - user: the user on behalf of whom you want to request the access token. - server: to be used to create the user id. User ID example: @user:server.com. - admin_access_token: a server admin access token to be used for the request. - - Returns: - Access token. - - Raises: - GetAccessTokenError: if there was an error while getting access token. - """ - # @user:server.com - user_id = f"@{user.username}:{server}" - res = _do_request( - "POST", f"{LOGIN_URL}/{user_id}/login", admin_access_token=admin_access_token - ) - try: - res_access_token = res.json()["access_token"] - except (requests.exceptions.JSONDecodeError, KeyError, TypeError) as exc: - logger.exception("Failed to decode access_token: %r. Received: %s", exc, res.text) - raise GetAccessTokenError(str(exc)) from exc - return res_access_token - - def override_rate_limit(user: User, admin_access_token: str, charm_state: CharmState) -> None: """Override user's rate limit. @@ -412,78 +324,6 @@ def get_room_id( return None -def deactivate_user( - user: User, - server: str, - admin_access_token: str, -) -> None: - """Deactivate user. - - Args: - user: user to be deactivated. - server: to be used to create the user id. - admin_access_token: server admin access token to be used. - """ - data = { - "erase": True, - } - user_id = f"@{user.username}:{server}" - url = f"{DEACTIVATE_ACCOUNT_URL}/{user_id}" - _do_request("POST", url, admin_access_token=admin_access_token, json=data) - - -def create_management_room(admin_access_token: str) -> str: - """Create the management room to be used by Mjolnir. - - Args: - admin_access_token: server admin access token to be used. - - Raises: - GetRoomIDError: if there was an error while getting room id. - - Returns: - Room id. - """ - power_level_content_override = {"events_default": 0} - moderators_room_id = get_room_id(MJOLNIR_MEMBERSHIP_ROOM, admin_access_token) - data = { - "name": MJOLNIR_MANAGEMENT_ROOM, - "power_level_content_override": power_level_content_override, - "room_alias_name": MJOLNIR_MANAGEMENT_ROOM, - "visibility": "private", - "initial_state": [ - # Always make history visibility shared - { - "type": "m.room.history_visibility", - "state_key": "", - "content": {"history_visibility": "shared"}, - }, - { - "type": "m.room.guest_access", - "state_key": "", - "content": {"guest_access": "can_join"}, - }, - # Only retain the last 90 days of history - {"type": "m.room.retention", "state_key": "", "content": {"max_lifetime": 604800000}}, - # Only users from MJOLNIR_MEMBERSHIP_ROOM can join - { - "type": "m.room.join_rules", - "state_key": "", - "content": { - "join_rule": "restricted", - "allow": [{"room_id": f"{moderators_room_id}", "type": "m.room_membership"}], - }, - }, - ], - } - res = _do_request("POST", CREATE_ROOM_URL, admin_access_token=admin_access_token, json=data) - try: - return res.json()["room_id"] - except (requests.exceptions.JSONDecodeError, TypeError, KeyError) as exc: - logger.exception("Failed to decode room_id: %r. Received: %s", exc, res.text) - raise GetRoomIDError(str(exc)) from exc - - def make_room_admin(user: User, server: str, admin_access_token: str, room_id: str) -> None: """Make user a room's admin. @@ -499,26 +339,6 @@ def make_room_admin(user: User, server: str, admin_access_token: str, room_id: s _do_request("POST", url, admin_access_token=admin_access_token, json=data) -def promote_user_admin( - user: User, - server: str, - admin_access_token: str, -) -> None: - """Promote user to admin. - - Args: - user: user to be promoted to admin. - server: to be used to promote the user id. - admin_access_token: server admin access token to be used. - """ - data = { - "admin": True, - } - user_id = f"@{user.username}:{server}" - url = PROMOTE_USER_ADMIN_URL.replace("user_id", user_id) - _do_request("PUT", url, admin_access_token=admin_access_token, json=data) - - def is_token_valid(access_token: str) -> bool: """Check if the access token is valid making a request to whoami. diff --git a/src/synapse/workload.py b/src/synapse/workload.py index c29917d7..e03c094c 100644 --- a/src/synapse/workload.py +++ b/src/synapse/workload.py @@ -16,18 +16,12 @@ from state.charm_state import CharmState -from .api import SYNAPSE_URL - SYNAPSE_CONFIG_DIR = "/data" CHECK_ALIVE_NAME = "synapse-alive" -CHECK_MJOLNIR_READY_NAME = "synapse-mjolnir-ready" CHECK_NGINX_READY_NAME = "synapse-nginx-ready" CHECK_READY_NAME = "synapse-ready" COMMAND_MIGRATE_CONFIG = "migrate_config" -MJOLNIR_CONFIG_PATH = f"{SYNAPSE_CONFIG_DIR}/config/production.yaml" -MJOLNIR_HEALTH_PORT = 7777 -MJOLNIR_SERVICE_NAME = "mjolnir" SYNAPSE_EXPORTER_PORT = "9000" STATS_EXPORTER_PORT = "9877" SYNAPSE_COMMAND_PATH = "/start.py" @@ -77,10 +71,6 @@ class EnableMetricsError(WorkloadError): """Exception raised when something goes wrong while enabling metrics.""" -class CreateMjolnirConfigError(WorkloadError): - """Exception raised when something goes wrong while creating mjolnir config.""" - - class EnableSMTPError(WorkloadError): """Exception raised when something goes wrong while enabling SMTP.""" @@ -365,43 +355,6 @@ def generate_worker_config(unit_number: str, is_main: bool) -> dict: return worker_config -def _get_mjolnir_config(access_token: str, room_id: str) -> typing.Dict: - """Get config as expected by mjolnir. - - Args: - access_token: access token to be used by the mjolnir bot. - room_id: management room id monitored by the Mjolnir. - - Returns: - Mjolnir configuration - """ - with open("templates/mjolnir_production.yaml", encoding="utf-8") as mjolnir_config_file: - config = yaml.safe_load(mjolnir_config_file) - config["homeserverUrl"] = SYNAPSE_URL - config["rawHomeserverUrl"] = SYNAPSE_URL - config["accessToken"] = access_token - config["managementRoom"] = room_id - return config - - -def generate_mjolnir_config(container: ops.Container, access_token: str, room_id: str) -> None: - """Generate mjolnir configuration. - - Args: - container: Container of the charm. - access_token: access token to be used by the Mjolnir. - room_id: management room id monitored by the Mjolnir. - - Raises: - CreateMjolnirConfigError: something went wrong creating mjolnir config. - """ - try: - config = _get_mjolnir_config(access_token, room_id) - container.push(MJOLNIR_CONFIG_PATH, yaml.safe_dump(config), make_dirs=True) - except ops.pebble.PathError as exc: - raise CreateMjolnirConfigError(str(exc)) from exc - - def create_registration_secrets_files(container: ops.Container, charm_state: CharmState) -> None: """Create registration secrets files. diff --git a/src/synapse/workload_configuration.py b/src/synapse/workload_configuration.py index ee378b52..c8ca5b47 100644 --- a/src/synapse/workload_configuration.py +++ b/src/synapse/workload_configuration.py @@ -36,13 +36,15 @@ def set_public_baseurl(current_yaml: dict, charm_state: CharmState) -> None: current_yaml["public_baseurl"] = charm_state.synapse_config.public_baseurl -def disable_password_config(current_yaml: dict) -> None: - """Change the Synapse configuration to disable password config. +def configure_mas(current_yaml: dict, synapse_msc3861_configuration: dict) -> None: + """Change the Synapse configuration to disable password config and enable MAS. Args: current_yaml: current configuration. + synapse_msc3861_configuration: Synapse msc3861 configuration. """ current_yaml["password_config"] = {"enabled": False} + current_yaml["experimental_features"] = {"msc3861": synapse_msc3861_configuration} def disable_room_list_search(current_yaml: dict) -> None: diff --git a/synapse_rock/etc/nginx.conf b/synapse_rock/etc/nginx.conf index 6371ff38..153192d1 100644 --- a/synapse_rock/etc/nginx.conf +++ b/synapse_rock/etc/nginx.conf @@ -54,6 +54,26 @@ http { return 204; } + # --------------------- MAS configuration block -------------------------- + location ~ ^/_matrix/client/(.*)/(login|logout|refresh) { + proxy_http_version 1.1; + rewrite ^/(_matrix/client/.*) /auth/$1 break; + proxy_pass http://localhost:8081; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Host $host; + } + + location /auth { + proxy_http_version 1.1; + proxy_pass http://localhost:8081; + # Forward the client IP address + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Host $host; + } + # --------------------- end MAS configuration block ----------------------- + # The worker endpoints were extracted from the following documentation: # https://matrix-org.github.io/synapse/latest/workers.html#synapseappgeneric_worker location ~ ^/_matrix/client/(r0|v3)/rooms/([^/]*)/report/(.*)$ { diff --git a/synapse_rock/rockcraft.yaml b/synapse_rock/rockcraft.yaml index 924368b9..dbc6afed 100644 --- a/synapse_rock/rockcraft.yaml +++ b/synapse_rock/rockcraft.yaml @@ -107,7 +107,6 @@ parts: - xmlsec1 stage-snaps: - aws-cli - - mjolnir/latest/edge plugin: nil source: https://github.com/element-hq/synapse/ source-type: git @@ -203,7 +202,7 @@ parts: (cd $CRAFT_PART_BUILD/frontend; npm ci; npm run build) mkdir -p $CRAFT_PART_INSTALL/mas/share/assets cp frontend/dist/manifest.json $CRAFT_PART_INSTALL/mas/share/manifest.json - cp -r frontend/dist/ $CRAFT_PART_INSTALL/mas/share/assets + cp -r frontend/dist/* $CRAFT_PART_INSTALL/mas/share/assets stage: - mas/share/* mas-cli: diff --git a/templates/mas_config.yaml.j2 b/templates/mas_config.yaml.j2 index f15ee413..7f25255e 100644 --- a/templates/mas_config.yaml.j2 +++ b/templates/mas_config.yaml.j2 @@ -8,6 +8,7 @@ http: - name: compat - name: graphql - name: assets + path: /mas/share/assets binds: - address: '[::]:8081' prefix: {{ mas_prefix }} @@ -46,3 +47,18 @@ templates: translations_path: /mas/share/translations policy: wasm_module: /mas/share/policy.wasm +{% if smtp_configuration is not none %} +email: + from: '"{{ synapse_server_name_config }}" ' + reply_to: '"No reply" ' + transport: smtp +{% if smtp_configuration.enable_tls %} + mode: tls +{% else %} + mode: plain +{% endif %} + hostname: {{ smtp_configuration.host }} + port: {{ smtp_configuration.port }} + username: {{ smtp_configuration.user }} + password: {{ smtp_configuration.password }} +{% endif %} \ No newline at end of file diff --git a/templates/mjolnir_production.yaml b/templates/mjolnir_production.yaml deleted file mode 100644 index 18fb3833..00000000 --- a/templates/mjolnir_production.yaml +++ /dev/null @@ -1,35 +0,0 @@ -dataPath: "/data/storage" -verboseLogging: false -logLevel: "INFO" -syncOnStartup: true -verifyPermissionsOnStartup: true -noop: false -fasterMembershipChecks: false -automaticallyRedactForReasons: -- "spam" -- "advertising" -protectAllJoinedRooms: false -backgroundDelayMS: 500 -health: - healthz: - enabled: true - port: 7777 - address: "0.0.0.0" - endpoint: "/healthz" - healthyStatus: 200 - unhealthyStatus: 418 - sentry: -pollReports: false -displayReports: true -web: - enabled: true - port: 9999 - address: "0.0.0.0" - # A web API designed to intercept Matrix API - # POST /_matrix/client/r0/rooms/{roomId}/report/{eventId} - # and display readable abuse reports in the moderation room. - # - # If you wish to take advantage of this feature, you will need - # to configure a reverse proxy, see e.g. test/nginx.conf - abuseReporting: - enabled: true diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index ba04d737..314d92dd 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -5,6 +5,7 @@ import json +import re import typing from secrets import token_hex @@ -15,12 +16,14 @@ from juju.action import Action from juju.application import Application from juju.model import Model -from ops.model import ActiveStatus +from juju.unit import Unit +from ops.model import ActiveStatus, BlockedStatus from pytest import Config from pytest_operator.plugin import OpsTest +from auth.mas import MAS_CONFIGURATION_PATH from tests.conftest import SYNAPSE_IMAGE_PARAM -from tests.integration.helpers import get_access_token, register_user +from tests.integration.helpers import register_user # caused by pytest fixtures, mark does not work in fixtures # pylint: disable=too-many-arguments, unused-argument @@ -93,13 +96,11 @@ def synapse_app_charmhub_name_fixture() -> str: async def synapse_app_fixture( ops_test: OpsTest, synapse_app_name: str, - synapse_app_charmhub_name: str, synapse_image: str, model: Model, server_name: str, synapse_charm: str, postgresql_app: Application, - postgresql_app_name: str, pytestconfig: Config, ): """Build and deploy the Synapse charm.""" @@ -113,14 +114,28 @@ async def synapse_app_fixture( f"./{synapse_charm}", resources=resources, application_name=synapse_app_name, - series="jammy", config={"server_name": server_name}, ) + + await model.wait_for_idle( + apps=[synapse_app_name], + status=typing.cast(str, BlockedStatus.name), + idle_period=5, + ) + async with ops_test.fast_forward(): - await model.relate(f"{synapse_app_name}:mas-database", f"{postgresql_app_name}") - await model.wait_for_idle(status=ACTIVE_STATUS_NAME) - await model.relate(f"{synapse_app_name}:database", f"{postgresql_app_name}") - await model.wait_for_idle(status=ACTIVE_STATUS_NAME) + await model.relate(f"{synapse_app_name}:mas-database", f"{postgresql_app.name}") + await model.wait_for_idle( + apps=[synapse_app_name, postgresql_app.name], + status=ACTIVE_STATUS_NAME, + idle_period=5, + ) + await model.relate(f"{synapse_app_name}:database", f"{postgresql_app.name}") + await model.wait_for_idle( + apps=[synapse_app_name, postgresql_app.name], + status=ACTIVE_STATUS_NAME, + idle_period=5, + ) return app @@ -131,7 +146,6 @@ async def synapse_charmhub_app_fixture( server_name: str, synapse_app_charmhub_name: str, postgresql_app: Application, - postgresql_app_name: str, synapse_charm: str, ): """Deploy synapse from Charmhub.""" @@ -145,14 +159,14 @@ async def synapse_charmhub_app_fixture( config={"server_name": server_name}, ) await model.wait_for_idle( - apps=[postgresql_app_name], + apps=[postgresql_app.name], status=ACTIVE_STATUS_NAME, idle_period=5, ) - await model.relate(f"{synapse_app_charmhub_name}:mas-database", f"{postgresql_app_name}") - await model.relate(f"{synapse_app_charmhub_name}:database", f"{postgresql_app_name}") + await model.relate(f"{synapse_app_charmhub_name}:mas-database", f"{postgresql_app.name}") + await model.relate(f"{synapse_app_charmhub_name}:database", f"{postgresql_app.name}") await model.wait_for_idle( - apps=[synapse_app_charmhub_name, postgresql_app_name], + apps=[synapse_app_charmhub_name, postgresql_app.name], status=ACTIVE_STATUS_NAME, idle_period=5, ) @@ -250,30 +264,41 @@ def user_username_fixture() -> typing.Generator[str, None, None]: yield token_hex(16) -@pytest_asyncio.fixture(scope="module", name="user_password") -async def user_password_fixture(synapse_app: Application, user_username: str) -> str: +@pytest_asyncio.fixture(scope="module", name="user") +async def user_fixture(synapse_app: Application, user_username: str) -> tuple[str, str]: """Register a user and return the new password. Returns: The new user password """ - return await register_user(synapse_app, user_username) + return (user_username, await register_user(synapse_app, user_username)) @pytest_asyncio.fixture(scope="module", name="access_token") async def access_token_fixture( - user_username: str, - user_password: str, + user: tuple[str, str], synapse_app: Application, - get_unit_ips: typing.Callable[[str], typing.Awaitable[tuple[str, ...]]], ) -> str: """Return the access token after login with the username and password. Returns: The access token """ - synapse_ip = (await get_unit_ips(synapse_app.name))[0] - return get_access_token(synapse_ip, user_username, user_password) + username, _ = user + pebble_exec_cmd = "PEBBLE_SOCKET=/charm/containers/synapse/pebble.socket pebble exec --" + generate_token_cmd = ( + f"{pebble_exec_cmd} mas-cli -c {MAS_CONFIGURATION_PATH} manage issue-compatibility-token " + f"--yes-i-want-to-grant-synapse-admin-privileges {username}" + ) + unit: Unit = synapse_app.units[0] + action = await unit.run(generate_token_cmd) + await action.wait() + assert action.results["return-code"] == 0 + + parsing_regex = r"Compatibility token issued: (?Pmct_.+) compat_access_token\.id" + parsed_output = re.search(parsing_regex, action.results["stderr"]) + assert parsed_output is not None and parsed_output["token"] + return parsed_output["token"] @pytest.fixture(scope="module", name="localstack_address") diff --git a/tests/integration/helpers.py b/tests/integration/helpers.py index 38195bb6..e9d4969a 100644 --- a/tests/integration/helpers.py +++ b/tests/integration/helpers.py @@ -38,33 +38,6 @@ def create_moderators_room( res.raise_for_status() -def get_access_token(synapse_ip, user_username, user_password) -> str: - """Get Access Token for Synapse given user and password - - Args: - synapse_ip: Synapse IP - user_username: username of the user to get the access_token - user_password: password of the user to get the access_token - - Returns: - The access token - """ - sess = requests.session() - res = sess.post( - f"http://{synapse_ip}:8080/_matrix/client/r0/login", - json={ - "identifier": {"type": "m.id.user", "user": user_username}, - "password": user_password, - "type": "m.login.password", - }, - timeout=5, - ) - res.raise_for_status() - access_token = res.json().get("access_token") - assert access_token - return access_token - - async def register_user(synapse_app: Application, user_username: str) -> str: """Register a new user with admin permissions diff --git a/tests/integration/test_charm.py b/tests/integration/test_charm.py index 90dbf588..c1661bea 100644 --- a/tests/integration/test_charm.py +++ b/tests/integration/test_charm.py @@ -11,6 +11,7 @@ import pytest import requests +import yaml from juju.action import Action from juju.application import Application from juju.errors import JujuUnitError @@ -20,7 +21,7 @@ from pytest_operator.plugin import OpsTest import synapse -from tests.integration.helpers import create_moderators_room, get_access_token, register_user +from auth.mas import MAS_CONFIGURATION_PATH # caused by pytest fixtures # pylint: disable=too-many-arguments @@ -33,6 +34,7 @@ async def test_synapse_is_up( synapse_app: Application, + server_name: str, get_unit_ips: typing.Callable[[str], typing.Awaitable[tuple[str, ...]]], ): """ @@ -47,24 +49,20 @@ async def test_synapse_is_up( assert response.status_code == 200 assert "Welcome to the Matrix" in response.text - pebble_exec_cmd = "PEBBLE_SOCKET=/charm/containers/synapse/pebble.socket pebble exec --" - mas_cli_check_cmd = f"{pebble_exec_cmd} mas-cli help" - unit: Unit = synapse_app.units[0] - action = await unit.run(mas_cli_check_cmd) - await action.wait() - assert action.results["return-code"] == 0, "Error running mas-cli." - - check_assets_cmd = ( - "[ -d /mas/share/assets -a" - " -f /mas/share/policy.wasm -a" - " -f /mas/share/manifest.json -a" - " -d /mas/share/templates -a" - " -d /mas/share/translations ] && echo ok" - ) - action = await unit.run("/bin/bash -c " f"'{pebble_exec_cmd} {check_assets_cmd}'") - await action.wait() - assert action.results["return-code"] == 0, "mas assets folder not found." - assert "ok" in action.results["stdout"] + response = requests.get(f"http://{unit_ip}:{synapse.SYNAPSE_NGINX_PORT}/auth/", timeout=5) + assert response.status_code == 200 + assert "Matrix Authentication Service" in response.text + + response = requests.get( + ( + f"http://{unit_ip}:{synapse.SYNAPSE_NGINX_PORT}" + "/auth/.well-known/openid-configuration" + ), + timeout=5, + ) + assert response.status_code == 200 + openid_configuration = response.json() + assert openid_configuration.get("issuer") == f"https://{server_name}/auth/" async def test_synapse_validate_configuration(synapse_app: Application): @@ -187,8 +185,6 @@ async def test_workload_version( async def test_synapse_enable_smtp( model: Model, synapse_app: Application, - get_unit_ips: typing.Callable[[str], typing.Awaitable[tuple[str, ...]]], - access_token: str, relation_name: str, ): """ @@ -221,90 +217,17 @@ async def test_synapse_enable_smtp( status=ACTIVE_STATUS_NAME, ) - synapse_ip = (await get_unit_ips(synapse_app.name))[0] - authorization_token = f"Bearer {access_token}" - headers = {"Authorization": authorization_token} - sample_check = { - "client_secret": "this_is_my_secret_string", - "email": "example@example.com", - "id_server": "id.matrix.org", - "send_attempt": "1", - } - sess = requests.session() - res = sess.post( - f"http://{synapse_ip}:8080/_matrix/client/r0/register/email/requestToken", - json=sample_check, - headers=headers, - timeout=5, - ) - - assert res.status_code == 500 - # If the configuration change fails, will return something like: - # "Email-based registration has been disabled on this server". - # The expected error confirms that the e-mail is configured but failed since - # is not a real SMTP server. - assert "error was encountered when sending the email" in res.text - - -async def test_promote_user_admin( - synapse_app: Application, - get_unit_ips: typing.Callable[[str], typing.Awaitable[tuple[str, ...]]], -) -> None: - """ - arrange: build and deploy the Synapse charm, create an user, get the access token and assert - that the user is not an admin. - act: run action to promote user to admin. - assert: the Synapse application is active and the API request returns as expected. - """ - operator_username = "operator" - action_register_user: Action = await synapse_app.units[0].run_action( # type: ignore - "register-user", username=operator_username, admin=False - ) - await action_register_user.wait() - assert action_register_user.status == "completed" - password = action_register_user.results["user-password"] - synapse_ip = (await get_unit_ips(synapse_app.name))[0] - sess = requests.session() - res = sess.post( - f"http://{synapse_ip}:8080/_matrix/client/r0/login", - # same thing is done on fixture but we are creating a non-admin user here. - json={ # pylint: disable=duplicate-code - "identifier": {"type": "m.id.user", "user": operator_username}, - "password": password, - "type": "m.login.password", - }, - timeout=5, - ) - res.raise_for_status() - access_token = res.json()["access_token"] - authorization_token = f"Bearer {access_token}" - headers = {"Authorization": authorization_token} - # List Accounts is a request that only admins can perform. - res = sess.get( - f"http://{synapse_ip}:8080/_synapse/admin/v2/users?from=0&limit=10&guests=false", - headers=headers, - timeout=5, - ) - assert res.status_code == 403 - - action_promote: Action = await synapse_app.units[0].run_action( # type: ignore - "promote-user-admin", username=operator_username - ) - await action_promote.wait() - assert action_promote.status == "completed" - - res = sess.get( - f"http://{synapse_ip}:8080/_synapse/admin/v2/users?from=0&limit=10&guests=false", - headers=headers, - timeout=5, - ) - assert res.status_code == 200 + pebble_exec_cmd = "PEBBLE_SOCKET=/charm/containers/synapse/pebble.socket pebble exec --" + dump_mas_config_cmd = f"{pebble_exec_cmd} mas-cli -c {MAS_CONFIGURATION_PATH} config dump" + unit: Unit = synapse_app.units[0] + action = await unit.run(dump_mas_config_cmd) + await action.wait() + assert action.results["return-code"] == 0 + mas_config = yaml.safe_load(action.results["stdout"]) + assert mas_config["email"]["hostname"] == "127.0.0.1" -async def test_anonymize_user( - synapse_app: Application, - get_unit_ips: typing.Callable[[str], typing.Awaitable[tuple[str, ...]]], -) -> None: +async def test_anonymize_user(synapse_app: Application) -> None: """ arrange: build and deploy the Synapse charm, create an user, get the access token and assert that the user is not an admin. @@ -318,20 +241,6 @@ async def test_anonymize_user( ) await action_register_user.wait() assert action_register_user.status == "completed" - password = action_register_user.results["user-password"] - synapse_ip = (await get_unit_ips(synapse_app.name))[0] - with requests.session() as sess: - res = sess.post( - f"http://{synapse_ip}:8080/_matrix/client/r0/login", - # same thing is done on fixture but we are creating a non-admin user here. - json={ # pylint: disable=duplicate-code - "identifier": {"type": "m.id.user", "user": operator_username}, - "password": password, - "type": "m.login.password", - }, - timeout=5, - ) - res.raise_for_status() action_anonymize: Action = await synapse_unit.run_action( "anonymize-user", username=operator_username @@ -339,19 +248,6 @@ async def test_anonymize_user( await action_anonymize.wait() assert action_anonymize.status == "completed" - with requests.session() as sess: - res = sess.post( - f"http://{synapse_ip}:8080/_matrix/client/r0/login", - # same thing is done on fixture but we are creating a non-admin user here. - json={ # pylint: disable=duplicate-code - "identifier": {"type": "m.id.user", "user": operator_username}, - "password": password, - "type": "m.login.password", - }, - timeout=5, - ) - assert res.status_code == 403 - @pytest.mark.usefixtures("synapse_app") async def test_nginx_route_integration( @@ -376,84 +272,3 @@ async def test_nginx_route_integration( ) assert response.status_code == 200 assert "Welcome to the Matrix" in response.text - - -@pytest.mark.usefixtures("postgresql_app") -@pytest.mark.mjolnir -async def test_synapse_enable_mjolnir( - ops_test: OpsTest, - synapse_app: Application, - access_token: str, - get_unit_ips: typing.Callable[[str], typing.Awaitable[tuple[str, ...]]], -): - """ - arrange: build and deploy the Synapse charm, create an user, get the access token, - enable Mjolnir and create the management room. - act: check Mjolnir health point. - assert: the Synapse application is active and Mjolnir health point returns a correct response. - """ - await synapse_app.set_config({"enable_mjolnir": "true"}) - await synapse_app.model.wait_for_idle( - idle_period=30, timeout=120, apps=[synapse_app.name], status="blocked" - ) - synapse_ip = (await get_unit_ips(synapse_app.name))[0] - create_moderators_room(synapse_ip, access_token) - async with ops_test.fast_forward(): - # using fast_forward otherwise would wait for model config update-status-hook-interval - await synapse_app.model.wait_for_idle( - idle_period=30, apps=[synapse_app.name], status="active" - ) - - res = requests.get(f"http://{synapse_ip}:{synapse.MJOLNIR_HEALTH_PORT}/healthz", timeout=5) - - assert res.status_code == 200 - - -# pylint: disable=too-many-positional-arguments -@pytest.mark.usefixtures("postgresql_app") -@pytest.mark.mjolnir -async def test_synapse_with_mjolnir_from_refresh_is_up( - ops_test: OpsTest, - model: Model, - synapse_charmhub_app: Application, - get_unit_ips: typing.Callable[[str], typing.Awaitable[tuple[str, ...]]], - synapse_charm: str, - synapse_image: str, -): - """ - arrange: build and deploy the Synapse charm from charmhub and enable Mjolnir. - act: Refresh the charm with the local one. - assert: Synapse and Mjolnir health points should return correct responses. - """ - await synapse_charmhub_app.set_config({"enable_mjolnir": "true"}) - await model.wait_for_idle(apps=[synapse_charmhub_app.name], status="blocked") - synapse_ip = (await get_unit_ips(synapse_charmhub_app.name))[0] - user_username = token_hex(16) - user_password = await register_user(synapse_charmhub_app, user_username) - access_token = get_access_token(synapse_ip, user_username, user_password) - create_moderators_room(synapse_ip, access_token) - async with ops_test.fast_forward(): - await synapse_charmhub_app.model.wait_for_idle( - idle_period=30, apps=[synapse_charmhub_app.name], status="active" - ) - - resources = { - "synapse-image": synapse_image, - } - await synapse_charmhub_app.refresh(path=f"./{synapse_charm}", resources=resources) - async with ops_test.fast_forward(): - await synapse_charmhub_app.model.wait_for_idle( - idle_period=30, apps=[synapse_charmhub_app.name], status="active" - ) - # Unit ip could change because it is a different pod. - synapse_ip = (await get_unit_ips(synapse_charmhub_app.name))[0] - response = requests.get( - f"http://{synapse_ip}:{synapse.SYNAPSE_NGINX_PORT}/_matrix/static/", timeout=5 - ) - assert response.status_code == 200 - assert "Welcome to the Matrix" in response.text - - mjolnir_response = requests.get( - f"http://{synapse_ip}:{synapse.MJOLNIR_HEALTH_PORT}/healthz", timeout=5 - ) - assert mjolnir_response.status_code == 200 diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index ca42e57a..9a866a7b 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -118,13 +118,14 @@ def register_command_handler( def harness_fixture(request, monkeypatch) -> typing.Generator[Harness, None, None]: """Ops testing framework harness fixture.""" monkeypatch.setattr(synapse, "get_version", lambda *_args, **_kwargs: "") - monkeypatch.setattr(synapse, "create_admin_user", lambda *_args, **_kwargs: "") monkeypatch.setattr(time, "sleep", lambda *_args, **_kwargs: "") # Assume that MAS is working properly monkeypatch.setattr( "state.mas.MASConfiguration.from_charm", MagicMock(return_value=MagicMock()) ) monkeypatch.setattr("pebble._push_mas_config", MagicMock()) + monkeypatch.setattr("charm.generate_mas_config", MagicMock(return_value="")) + monkeypatch.setattr("charm.generate_synapse_msc3861_config", MagicMock(return_value={})) harness = Harness(SynapseCharm) # Necessary for traefik-k8s.v2.ingress library as it calls binding.network.bind_address @@ -199,6 +200,11 @@ def start_cmd_handler(argv: list[str]) -> synapse.ExecResult: executable="rm", handler=lambda _: synapse.ExecResult(0, "", ""), ) + harness.register_command_handler( # type: ignore # pylint: disable=no-member + container=synapse_container, + executable="/usr/bin/mas-cli", + handler=lambda _: synapse.ExecResult(0, "", ""), + ) yield harness harness.cleanup() diff --git a/tests/unit/test_action.py b/tests/unit/test_action.py new file mode 100644 index 00000000..1dd5d834 --- /dev/null +++ b/tests/unit/test_action.py @@ -0,0 +1,192 @@ +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Register user action unit tests.""" + +# Disabled to access _on_register_user_action +# pylint: disable=protected-access + +import unittest.mock +from unittest.mock import MagicMock + +import ops +import pytest +from ops.charm import ActionEvent +from ops.testing import Harness + +import synapse +from user import User + + +def test_register_user_action(harness: Harness) -> None: + """ + arrange: start the Synapse charm, set Synapse container to be ready and set server_name. + act: run register-user action. + assert: User is created and the charm is active. + """ + harness.begin_with_initial_hooks() + user = "username" + event = unittest.mock.MagicMock(spec=ActionEvent) + event.params = { + "username": user, + "admin": "no", + } + + # Calling to test the action since is not possible calling via harness + harness.charm._on_register_user_action(event) + + assert event.set_results.call_count == 1 + event.set_results.assert_called_with( + {"register-user": True, "user-password": unittest.mock.ANY} + ) + assert isinstance(harness.model.unit.status, ops.ActiveStatus) + + +def test_register_user_action_pebble_exec_error( + harness: Harness, monkeypatch: pytest.MonkeyPatch +) -> None: + """ + arrange: Given a mocked synapse container with an exec method that raises ExecError. + act: run verify_user_email. + assert: The correct exception is raised. + """ + harness.begin_with_initial_hooks() + event = unittest.mock.MagicMock(spec=ActionEvent) + event.params = {"username": "username", "admin": "no"} + container = harness.model.unit.get_container(synapse.SYNAPSE_CONTAINER_NAME) + monkeypatch.setattr( + container, "exec", MagicMock(side_effect=ops.pebble.ExecError([], 1, "", "")) + ) + harness.charm._on_register_user_action(event) + assert event.fail.call_count == 1 + + +def test_register_user_action_action_container_not_ready( + harness: Harness, monkeypatch: pytest.MonkeyPatch +) -> None: + """ + arrange: Given a mocked synapse container with an exec method that raises ExecError. + act: run verify_user_email. + assert: The correct exception is raised. + """ + harness.begin_with_initial_hooks() + event = unittest.mock.MagicMock(spec=ActionEvent) + event.params = {"username": "username", "admin": "no"} + container = harness.model.unit.get_container(synapse.SYNAPSE_CONTAINER_NAME) + monkeypatch.setattr(container, "can_connect", MagicMock(return_value=False)) + harness.charm._on_register_user_action(event) + assert event.fail.call_count == 1 + + +def test_username_empty(): + """ + arrange: create a user. + act: set username as empty. + assert: ValueError is raised. + """ + with pytest.raises(ValueError, match="Username must not be empty"): + User(username="", admin=True) + + +def test_verify_user_email_action(harness: Harness) -> None: + """ + arrange: start the Synapse charm, set Synapse container to be ready and set server_name. + act: run register-user action. + assert: User is created and the charm is active. + """ + harness.begin_with_initial_hooks() + event = unittest.mock.MagicMock(spec=ActionEvent) + event.params = {"username": "username", "email": "user@email.com"} + + harness.charm._on_verify_user_email_action(event) + + assert event.set_results.call_count == 1 + event.set_results.assert_called_with({"verify-user-email": True}) + assert isinstance(harness.model.unit.status, ops.ActiveStatus) + + +def test_verify_user_email_action_pebble_exec_error( + harness: Harness, monkeypatch: pytest.MonkeyPatch +) -> None: + """ + arrange: Given a mocked synapse container with an exec method that raises ExecError. + act: run verify_user_email. + assert: The correct exception is raised. + """ + harness.begin_with_initial_hooks() + event = unittest.mock.MagicMock(spec=ActionEvent) + event.params = {"username": "username", "email": "user@email.com"} + container = harness.model.unit.get_container(synapse.SYNAPSE_CONTAINER_NAME) + monkeypatch.setattr( + container, "exec", MagicMock(side_effect=ops.pebble.ExecError([], 1, "", "")) + ) + harness.charm._on_verify_user_email_action(event) + assert event.fail.call_count == 1 + + +def test_verify_user_email_action_container_not_ready( + harness: Harness, monkeypatch: pytest.MonkeyPatch +) -> None: + """ + arrange: Given a mocked synapse container with an exec method that raises ExecError. + act: run verify_user_email. + assert: The correct exception is raised. + """ + harness.begin_with_initial_hooks() + event = unittest.mock.MagicMock(spec=ActionEvent) + event.params = {"username": "username", "email": "user@email.com"} + container = harness.model.unit.get_container(synapse.SYNAPSE_CONTAINER_NAME) + monkeypatch.setattr(container, "can_connect", MagicMock(return_value=False)) + harness.charm._on_verify_user_email_action(event) + assert event.fail.call_count == 1 + + +def test_anonymize_user_action(harness: Harness) -> None: + """ + arrange: start the Synapse charm, set Synapse container to be ready and set server_name. + act: run anonymize-user action. + assert: User is deactivated and the charm is active. + """ + harness.begin_with_initial_hooks() + event = unittest.mock.MagicMock(spec=ActionEvent) + event.params = {"username": "username"} + harness.charm._on_anonymize_user_action(event) + assert event.set_results.call_count == 1 + event.set_results.assert_called_with({"anonymize-user": True}) + assert isinstance(harness.model.unit.status, ops.ActiveStatus) + + +def test_anonymize_user_action_pebble_exec_error( + harness: Harness, monkeypatch: pytest.MonkeyPatch +) -> None: + """ + arrange: Given a mocked synapse container with an exec method that raises ExecError. + act: run verify_user_email. + assert: The correct exception is raised. + """ + harness.begin_with_initial_hooks() + event = unittest.mock.MagicMock(spec=ActionEvent) + event.params = {"username": "username"} + container = harness.model.unit.get_container(synapse.SYNAPSE_CONTAINER_NAME) + monkeypatch.setattr( + container, "exec", MagicMock(side_effect=ops.pebble.ExecError([], 1, "", "")) + ) + harness.charm._on_anonymize_user_action(event) + assert event.fail.call_count == 1 + + +def test_anonymize_user_action_container_not_ready( + harness: Harness, monkeypatch: pytest.MonkeyPatch +) -> None: + """ + arrange: Given a mocked synapse container with an exec method that raises ExecError. + act: run verify_user_email. + assert: The correct exception is raised. + """ + harness.begin_with_initial_hooks() + event = unittest.mock.MagicMock(spec=ActionEvent) + event.params = {"username": "username"} + container = harness.model.unit.get_container(synapse.SYNAPSE_CONTAINER_NAME) + monkeypatch.setattr(container, "can_connect", MagicMock(return_value=False)) + harness.charm._on_anonymize_user_action(event) + assert event.fail.call_count == 1 diff --git a/tests/unit/test_admin_access_token.py b/tests/unit/test_admin_access_token.py deleted file mode 100644 index f498fdb6..00000000 --- a/tests/unit/test_admin_access_token.py +++ /dev/null @@ -1,134 +0,0 @@ -# Copyright 2025 Canonical Ltd. -# See LICENSE file for licensing details. - -"""Admin Access Token unit tests.""" - -# pylint: disable=protected-access - -from secrets import token_hex -from unittest.mock import MagicMock, patch - -import ops -import pytest -from ops.testing import Harness - -import admin_access_token -import synapse - - -@patch("admin_access_token.JUJU_HAS_SECRETS", True) -@patch.object(ops.Application, "add_secret") -def test_get_admin_access_token_with_secrets( - mock_add_secret, harness: Harness, monkeypatch: pytest.MonkeyPatch -) -> None: - """ - arrange: start the Synapse charm, mock register_user and add_secret. - act: get admin access token. - assert: admin user is created, secret is created and the token is retrieved. - """ - harness.begin_with_initial_hooks() - # Mocking like the following doesn't get evaluated as expected - # mock_juju_env.return_value = MagicMock(has_secrets=True) - secret_mock = MagicMock - secret_id = token_hex(16) - secret_mock.id = secret_id - mock_add_secret.return_value = secret_mock - user_mock = MagicMock() - admin_access_token_expected = token_hex(16) - user_mock.access_token = admin_access_token_expected - create_admin_user_mock = MagicMock(return_value=user_mock) - monkeypatch.setattr(synapse, "create_admin_user", create_admin_user_mock) - monkeypatch.setattr(synapse, "is_token_valid", MagicMock(return_value=True)) - - admin_access_token_real = harness.charm.token_service.get(MagicMock) - - create_admin_user_mock.assert_called_once() - mock_add_secret.assert_called_once() - assert admin_access_token_real == admin_access_token_expected - peer_relation = harness.model.get_relation(synapse.SYNAPSE_PEER_RELATION_NAME) - assert peer_relation - assert ( - harness.get_relation_data(peer_relation.id, harness.charm.app.name).get("secret-id") - == secret_id - ) - assert isinstance(harness.model.unit.status, ops.ActiveStatus) - - -@patch("admin_access_token.JUJU_HAS_SECRETS", False) -@patch.object(ops.Application, "add_secret") -def test_get_admin_access_token_no_secrets( - mock_add_secret, harness: Harness, monkeypatch: pytest.MonkeyPatch -) -> None: - """ - arrange: start the Synapse charm, mock register_user and add_secret. - act: get admin access token. - assert: admin user is created, relation is updated and the token is - retrieved. - """ - harness.begin_with_initial_hooks() - # Mocking like the following doesn't get evaluated as expected - # mock_juju_env.return_value = MagicMock(has_secrets=True) - user_mock = MagicMock() - admin_access_token_expected = token_hex(16) - user_mock.access_token = admin_access_token_expected - create_admin_user_mock = MagicMock(return_value=user_mock) - monkeypatch.setattr(synapse, "create_admin_user", create_admin_user_mock) - monkeypatch.setattr(synapse, "is_token_valid", MagicMock(return_value=True)) - - admin_access_token_real = harness.charm.token_service.get(MagicMock) - - create_admin_user_mock.assert_called_once() - mock_add_secret.assert_not_called() - assert admin_access_token_real == admin_access_token_expected - peer_relation = harness.model.get_relation(synapse.SYNAPSE_PEER_RELATION_NAME) - assert peer_relation - assert ( - harness.get_relation_data(peer_relation.id, harness.charm.app.name).get("secret-key") - == admin_access_token_expected - ) - assert isinstance(harness.model.unit.status, ops.ActiveStatus) - - -@pytest.mark.parametrize( - "juju_has_secrets,is_token_valid", - [(juju_secret, token_valid) for juju_secret in [True, False] for token_valid in [True, False]], -) -def test_get_admin_access_with_refresh( - juju_has_secrets: bool, is_token_valid: bool, harness: Harness, monkeypatch: pytest.MonkeyPatch -) -> None: - """ - arrange: start Synapse charm. mock create_admin_user and is_token_valid to return True/False. - get an admin access token. is_token_valid should not be called yet, and a token - should be returned. - act: call get another admin access token. - assert: is_token_valid should be called, and a new token should be returned if is_token_valid - was False, otherwise it should be the initial token. - """ - initial_token = token_hex(16) - initial_user_mock = MagicMock() - initial_user_mock.access_token = initial_token - - token_refreshed = token_hex(16) - refreshed_user_mock = MagicMock() - refreshed_user_mock.access_token = token_refreshed - - create_admin_user_mock = MagicMock(side_effect=[initial_user_mock, refreshed_user_mock]) - monkeypatch.setattr(synapse, "create_admin_user", create_admin_user_mock) - is_token_valid_mock = MagicMock(return_value=is_token_valid) - monkeypatch.setattr(synapse, "is_token_valid", is_token_valid_mock) - monkeypatch.setattr(admin_access_token, "JUJU_HAS_SECRETS", juju_has_secrets) - - # Get admin access token - harness.begin_with_initial_hooks() - monkeypatch.setattr(synapse, "create_admin_user", create_admin_user_mock) - initial_admin_access_token = harness.charm.token_service.get(MagicMock) - is_token_valid_mock.assert_not_called() - assert initial_admin_access_token == initial_token - - # Get admin access token. Should not be refreshed if it is valid. - refreshed_admin_access_token = harness.charm.token_service.get(MagicMock) - - is_token_valid_mock.assert_called_once() - assert refreshed_admin_access_token == ( - initial_token if is_token_valid else refreshed_admin_access_token - ) diff --git a/tests/unit/test_admin_create_user.py b/tests/unit/test_admin_create_user.py deleted file mode 100644 index 0f6810bd..00000000 --- a/tests/unit/test_admin_create_user.py +++ /dev/null @@ -1,75 +0,0 @@ -# Copyright 2025 Canonical Ltd. -# See LICENSE file for licensing details. - -"""Tests for the create_user function in the synapse.admin module.""" - -from secrets import token_hex -from unittest.mock import MagicMock, patch - -from ops.testing import Harness - -import synapse -from synapse.admin import create_user - - -def test_create_user_success(harness: Harness, mocked_synapse_calls): - """ - arrange: start the Synapse charm and set the necessary parameters. - act: call the create_user function. - assert: user is created successfully and the access token is generated. - """ - # pylint: disable=unused-argument - harness.begin_with_initial_hooks() - container = harness.model.unit.containers["synapse"] - username = "test_user" - admin = True - admin_access_token = token_hex(16) - server = "test_server" - - with patch("synapse.api._do_request") as mock_request: - mock_request.return_value.status_code = 200 - mock_request.return_value.json.return_value = { - "access_token": "access_token", - "nonce": "sense", - } - user = create_user( - container=container, - username=username, - admin=admin, - admin_access_token=admin_access_token, - server=server, - ) - - assert user is not None - assert user.username == username - assert user.admin == admin - assert user.access_token is not None - - -def test_create_user_no_shared_secret(harness: Harness, monkeypatch): - """ - arrange: start the Synapse charm without the registration shared secret. - act: call the create_user function. - assert: user creation fails and None is returned. - """ - harness.begin_with_initial_hooks() - container = harness.model.unit.containers["synapse"] - username = "test_user" - admin = True - admin_access_token = token_hex(16) - server = "test_server" - - monkeypatch.setattr( - synapse.workload, "get_registration_shared_secret", MagicMock(return_value=None) - ) - monkeypatch.setattr(synapse.workload, "_get_configuration_field", MagicMock(return_value=None)) - - user = create_user( - container=container, - username=username, - admin=admin, - admin_access_token=admin_access_token, - server=server, - ) - - assert user is None diff --git a/tests/unit/test_anonymize_user_action.py b/tests/unit/test_anonymize_user_action.py deleted file mode 100644 index 6d9b9c67..00000000 --- a/tests/unit/test_anonymize_user_action.py +++ /dev/null @@ -1,133 +0,0 @@ -# Copyright 2025 Canonical Ltd. -# See LICENSE file for licensing details. - -"""Register user action unit tests.""" - -# Disabled to access _on_register_user_action -# Disabled R0801 because has similar code to test_promote_user_admin_action -# pylint: disable=protected-access, R0801 - -import unittest.mock -from secrets import token_hex - -import pytest -from ops.charm import ActionEvent -from ops.testing import Harness - -import synapse - - -def test_anonymize_user_action(harness: Harness, monkeypatch: pytest.MonkeyPatch) -> None: - """ - arrange: start the Synapse charm, set Synapse container to be ready and set server_name. - act: run anonymize-user action. - assert: event results are returned as expected. - """ - harness.begin_with_initial_hooks() - admin_access_token = token_hex(16) - anonymize_user_mock = unittest.mock.Mock() - monkeypatch.setattr( - harness.charm.token_service, - "get", - unittest.mock.MagicMock(return_value=admin_access_token), - ) - monkeypatch.setattr("synapse.deactivate_user", anonymize_user_mock) - user = "username" - admin = True - event = unittest.mock.MagicMock(spec=ActionEvent) - event.params = { - "username": user, - "admin": admin, - } - - harness.charm._on_anonymize_user_action(event) - - assert event.set_results.call_count == 1 - event.set_results.assert_called_with({"anonymize-user": True}) - anonymize_user_mock.assert_called_with( - user=unittest.mock.ANY, server=unittest.mock.ANY, admin_access_token=admin_access_token - ) - - -def test_anonymize_user_api_error(harness: Harness, monkeypatch: pytest.MonkeyPatch) -> None: - """ - arrange: start the Synapse charm, set Synapse container to be ready and set server_name. - act: run anonymize-user action. - assert: event fails as expected. - """ - harness.begin_with_initial_hooks() - fail_message = "Failed to anonymize the user. Check if the user is created and active." - synapse_api_error = synapse.APIError(fail_message) - anonymize_user_mock = unittest.mock.MagicMock(side_effect=synapse_api_error) - monkeypatch.setattr("synapse.deactivate_user", anonymize_user_mock) - admin_access_token = token_hex(16) - user = "username" - admin = True - monkeypatch.setattr( - harness.charm.token_service, - "get", - unittest.mock.MagicMock(return_value=admin_access_token), - ) - event = unittest.mock.MagicMock(spec=ActionEvent) - event.params = { - "username": user, - "admin": admin, - } - - def event_store_failure(failure_message: str) -> None: - """Define a failure message for the event. - - Args: - failure_message: failure message content to be defined. - """ - event.fail_message = failure_message - - event.fail = event_store_failure - event.params = { - "username": user, - "admin": admin, - } - - harness.charm._on_anonymize_user_action(event) - - assert fail_message in event.fail_message - - -def test_anonymize_user_container_down(harness: Harness) -> None: - """ - arrange: start the Synapse charm, set Synapse container to be off. - act: run anonymize-user action. - assert: event fails as expected. - """ - harness.begin_with_initial_hooks() - harness.set_can_connect(harness.model.unit.containers[synapse.SYNAPSE_CONTAINER_NAME], False) - event = unittest.mock.Mock() - - harness.charm._on_anonymize_user_action(event) - - assert event.set_results.call_count == 0 - assert event.fail.call_count == 1 - assert "Container not yet ready. Try again later" == event.fail.call_args[0][0] - - -def test_anonymize_user_action_no_token(harness: Harness, monkeypatch: pytest.MonkeyPatch) -> None: - """ - arrange: start the Synapse charm, set Synapse container to be ready and set server_name. - act: run anonymize-user action. - assert: event fails as expected. - """ - harness.begin_with_initial_hooks() - anonymize_user_mock = unittest.mock.Mock() - monkeypatch.setattr( - harness.charm.token_service, - "get", - unittest.mock.MagicMock(return_value=None), - ) - monkeypatch.setattr("synapse.deactivate_user", anonymize_user_mock) - event = unittest.mock.Mock() - - harness.charm._on_anonymize_user_action(event) - - assert event.set_results.call_count == 0 - assert event.fail.call_count == 1 - assert "Failed to get admin access token" == event.fail.call_args[0][0] diff --git a/tests/unit/test_charm.py b/tests/unit/test_charm.py index 07d13b05..7b63ad90 100644 --- a/tests/unit/test_charm.py +++ b/tests/unit/test_charm.py @@ -275,6 +275,7 @@ def test_enable_federation_domain_whitelist_is_called( monkeypatch.setattr(synapse, "enable_media_retention", MagicMock()) monkeypatch.setattr(synapse, "enable_stale_devices_deletion", MagicMock()) monkeypatch.setattr(synapse, "validate_config", MagicMock()) + monkeypatch.setattr(synapse, "configure_mas", MagicMock()) enable_federation_mock = MagicMock() monkeypatch.setattr(synapse, "enable_federation_domain_whitelist", enable_federation_mock) @@ -282,12 +283,12 @@ def test_enable_federation_domain_whitelist_is_called( container = MagicMock() monkeypatch.setattr(container, "push", MagicMock()) monkeypatch.setattr(container, "pull", MagicMock(return_value=config)) - pebble.reconcile(charm_state, "", container=container) + pebble.reconcile(charm_state, "", {}, container=container) enable_federation_mock.assert_called_once() -def test_disable_password_config_is_called( +def test_configure_mas_is_called( harness: Harness, monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -310,16 +311,16 @@ def test_disable_password_config_is_called( monkeypatch.setattr(synapse, "enable_media_retention", MagicMock()) monkeypatch.setattr(synapse, "enable_stale_devices_deletion", MagicMock()) monkeypatch.setattr(synapse, "validate_config", MagicMock()) - disable_password_config_mock = MagicMock() - monkeypatch.setattr(synapse, "disable_password_config", disable_password_config_mock) + configure_mas_mock = MagicMock() + monkeypatch.setattr(synapse, "configure_mas", configure_mas_mock) charm_state = harness.charm.build_charm_state() container = MagicMock() monkeypatch.setattr(container, "push", MagicMock()) monkeypatch.setattr(container, "pull", MagicMock(return_value=io.StringIO("{}"))) - pebble.reconcile(charm_state, "", container=container) + pebble.reconcile(charm_state, "", {}, container=container) - disable_password_config_mock.assert_called_once() + configure_mas_mock.assert_called_once() def test_nginx_replan(harness: Harness, monkeypatch: pytest.MonkeyPatch) -> None: diff --git a/tests/unit/test_charm_state.py b/tests/unit/test_charm_state.py index 6cbbdebd..49082c65 100644 --- a/tests/unit/test_charm_state.py +++ b/tests/unit/test_charm_state.py @@ -10,6 +10,7 @@ import pytest from ops.testing import ActionFailed, Harness +from pebble import check_synapse_alive from state.charm_state import CharmConfigInvalidError, CharmState, SynapseConfig from state.validation import CharmBaseWithState, validate_charm_state @@ -192,3 +193,28 @@ def on_create_backup_action(self, _: ops.ActionEvent): harness.run_action("create-backup") assert "Invalid configuration" in str(err.value.message) assert not hasattr(charm, "charm_state") + + +def test_check_synapse_alive_experimental_alive_check_disabled(): + """ + arrange: Create a mock charmstate with experimental_alive_check = "". + act: Run check_synapse_alive method. + assert: The values are NOT added to the pebble check dictionary. + """ + synapse_config = SynapseConfig( + server_name="example.com", + public_baseurl="https://example.com", + ) # type: ignore[call-arg] + charm_state = CharmState( + synapse_config=synapse_config, + datasource=None, + smtp_config=None, + media_config=None, + redis_config=None, + instance_map_config=None, + registration_secrets=None, + ) + generated_checks = check_synapse_alive(charm_state) + assert generated_checks.get("period") is None + assert generated_checks.get("threshold") is None + assert generated_checks.get("timeout") is None diff --git a/tests/unit/test_mas.py b/tests/unit/test_mas.py index 1ee8f58c..db5c36da 100644 --- a/tests/unit/test_mas.py +++ b/tests/unit/test_mas.py @@ -10,13 +10,12 @@ from ops.model import SecretNotFoundError from ops.testing import Harness -from auth.mas import generate_mas_config +from auth.mas import generate_mas_config, generate_synapse_msc3861_config from charm import SynapseCharm from state.charm_state import SynapseConfig from state.mas import MAS_DATABASE_INTEGRATION_NAME, MAS_DATABASE_NAME, MASConfiguration -# pylint: disable=protected-access def test_mas_generate_config(monkeypatch: pytest.MonkeyPatch) -> None: """ arrange: Given a synapse charm related to postgresql. @@ -42,7 +41,12 @@ def test_mas_generate_config(monkeypatch: pytest.MonkeyPatch) -> None: "public_baseurl": "https://foo", } synapse_configuration = SynapseConfig(**config) # type: ignore[arg-type] - rendered_mas_config = generate_mas_config(mas_configuration, synapse_configuration, "10.1.1.0") + rendered_mas_config = generate_mas_config( + mas_configuration, synapse_configuration, None, "10.1.1.0" + ) + rendered_msc3861_config = generate_synapse_msc3861_config( + mas_configuration, synapse_configuration + ) parsed_mas_config = yaml.safe_load(rendered_mas_config) assert ( parsed_mas_config["http"]["public_base"] @@ -56,3 +60,8 @@ def test_mas_generate_config(monkeypatch: pytest.MonkeyPatch) -> None: parsed_mas_config["database"]["uri"] == f"postgresql://{db_user}:{db_password}@{db_endpoint}/{MAS_DATABASE_NAME}" ) + + assert ( + rendered_msc3861_config["issuer"] + == f"{synapse_configuration.public_baseurl}{mas_configuration.mas_prefix}" + ) diff --git a/tests/unit/test_mjolnir.py b/tests/unit/test_mjolnir.py deleted file mode 100644 index 05f1dbfe..00000000 --- a/tests/unit/test_mjolnir.py +++ /dev/null @@ -1,497 +0,0 @@ -# Copyright 2025 Canonical Ltd. -# See LICENSE file for licensing details. - -"""Mjolnir unit tests.""" - -# pylint: disable=protected-access -import logging -from dataclasses import dataclass -from secrets import token_hex -from unittest import mock -from unittest.mock import ANY, MagicMock, PropertyMock, patch - -import ops -import pytest -from ops.testing import Harness - -import actions -import synapse -from mjolnir import Mjolnir - - -def test_get_membership_room_id(harness: Harness, monkeypatch: pytest.MonkeyPatch) -> None: - """ - arrange: start the Synapse charm, set server_name. - act: call get_membership_room_id. - assert: get_membership_room_id is called once with expected args. - """ - harness.update_config({"enable_mjolnir": True}) - harness.begin_with_initial_hooks() - admin_access_token = token_hex(16) - get_room_id = MagicMock() - monkeypatch.setattr(synapse, "get_room_id", get_room_id) - - harness.charm._mjolnir.get_membership_room_id(admin_access_token) - - get_room_id.assert_called_once_with( - room_name="moderators", admin_access_token=admin_access_token - ) - - -@mock.patch("mjolnir.Mjolnir._admin_access_token", new_callable=PropertyMock) -def test_on_collect_status_blocked( - _admin_access_token_mock, harness: Harness, monkeypatch: pytest.MonkeyPatch -) -> None: - """ - arrange: start the Synapse charm, set server_name, mock container, get_membership_room_id - and _update_peer_data. - act: call _on_collect_status. - assert: status is blocked. - """ - harness.update_config({"enable_mjolnir": True}) - harness.begin_with_initial_hooks() - _admin_access_token_mock.__get__ = mock.Mock(return_value=token_hex(16)) - monkeypatch.setattr(Mjolnir, "get_membership_room_id", MagicMock(return_value=None)) - charm_state_mock = MagicMock() - charm_state_mock.enable_mjolnir = True - harness.charm._mjolnir._charm_state = charm_state_mock - - event_mock = MagicMock() - harness.charm._mjolnir._on_collect_status(event_mock) - - event_mock.add_status.assert_called_once_with( - ops.BlockedStatus( - "moderators not found and is required by Mjolnir. Please, check the logs." - ) - ) - - -def test_on_collect_status_service_exists( - harness: Harness, monkeypatch: pytest.MonkeyPatch -) -> None: - """ - arrange: start the Synapse charm, set server_name, mock get_services to return something. - act: call _on_collect_status. - assert: no actions is taken because the service exists. - """ - harness.update_config({"enable_mjolnir": True}) - harness.begin_with_initial_hooks() - container: ops.Container = harness.model.unit.get_container(synapse.SYNAPSE_CONTAINER_NAME) - monkeypatch.setattr(container, "get_services", MagicMock(return_value=MagicMock())) - enable_mjolnir_mock = MagicMock() - monkeypatch.setattr(Mjolnir, "enable_mjolnir", enable_mjolnir_mock) - - event_mock = MagicMock() - harness.charm._mjolnir._on_collect_status(event_mock) - - event_mock.add_status.assert_not_called() - enable_mjolnir_mock.assert_not_called() - - -def test_on_collect_status_no_service(harness: Harness, monkeypatch: pytest.MonkeyPatch) -> None: - """ - arrange: start the Synapse charm, set server_name, mock get_services to return a empty dict. - act: call _on_collect_status. - assert: no actions is taken because Synapse service is not ready. - """ - harness.update_config({"enable_mjolnir": True}) - harness.begin_with_initial_hooks() - container: ops.Container = harness.model.unit.get_container(synapse.SYNAPSE_CONTAINER_NAME) - monkeypatch.setattr(container, "get_services", MagicMock(return_value={})) - enable_mjolnir_mock = MagicMock() - monkeypatch.setattr(Mjolnir, "enable_mjolnir", enable_mjolnir_mock) - - event_mock = MagicMock() - harness.charm._mjolnir._on_collect_status(event_mock) - - assert isinstance(harness.model.unit.status, ops.MaintenanceStatus) - enable_mjolnir_mock.assert_not_called() - - -def test_on_collect_status_container_off( - harness: Harness, monkeypatch: pytest.MonkeyPatch -) -> None: - """ - arrange: start the Synapse charm, set server_name, mock container to not connect. - act: call _on_collect_status. - assert: no actions is taken because the container is off. - """ - harness.update_config({"enable_mjolnir": True}) - harness.begin_with_initial_hooks() - container: ops.Container = harness.model.unit.get_container(synapse.SYNAPSE_CONTAINER_NAME) - monkeypatch.setattr(container, "can_connect", MagicMock(return_value=False)) - enable_mjolnir_mock = MagicMock() - monkeypatch.setattr(Mjolnir, "enable_mjolnir", enable_mjolnir_mock) - - event_mock = MagicMock() - harness.charm._mjolnir._on_collect_status(event_mock) - - event_mock.add_status.assert_not_called() - enable_mjolnir_mock.assert_not_called() - - -def test_on_collect_status_active(harness: Harness, monkeypatch: pytest.MonkeyPatch) -> None: - """ - arrange: start the Synapse charm, set server_name, mock container, get_membership_room_id - and _update_peer_data. - act: call _on_collect_status. - assert: status is active. - """ - harness.update_config({"enable_mjolnir": True}) - harness.begin_with_initial_hooks() - admin_access_token = token_hex(16) - monkeypatch.setattr(Mjolnir, "_admin_access_token", admin_access_token) - membership_room_id_mock = MagicMock(return_value="123") - monkeypatch.setattr(Mjolnir, "get_membership_room_id", membership_room_id_mock) - enable_mjolnir_mock = MagicMock(return_value=None) - monkeypatch.setattr(Mjolnir, "enable_mjolnir", enable_mjolnir_mock) - charm_state_mock = MagicMock() - charm_state_mock.enable_mjolnir = True - harness.charm._mjolnir._charm_state = charm_state_mock - - event_mock = MagicMock() - harness.charm._mjolnir._on_collect_status(event_mock) - - membership_room_id_mock.assert_called_once() - enable_mjolnir_mock.assert_called_once() - event_mock.add_status.assert_called_once_with(ops.ActiveStatus()) - - -def test_on_collect_status_api_error(harness: Harness, monkeypatch: pytest.MonkeyPatch) -> None: - """ - arrange: start the Synapse charm, set server_name, mock container, mock get_membership_room_id - to raise an API error. - act: call _on_collect_status. - assert: mjolnir is not enabled. - """ - harness.update_config({"enable_mjolnir": True}) - harness.begin_with_initial_hooks() - admin_access_token = token_hex(16) - monkeypatch.setattr(Mjolnir, "_admin_access_token", admin_access_token) - membership_room_id_mock = MagicMock(side_effect=synapse.APIError("error")) - monkeypatch.setattr(Mjolnir, "get_membership_room_id", membership_room_id_mock) - enable_mjolnir_mock = MagicMock(return_value=None) - monkeypatch.setattr(Mjolnir, "enable_mjolnir", enable_mjolnir_mock) - charm_state_mock = MagicMock() - charm_state_mock.enable_mjolnir = True - harness.charm._mjolnir._charm_state = charm_state_mock - - event_mock = MagicMock() - harness.charm._mjolnir._on_collect_status(event_mock) - - membership_room_id_mock.assert_called_once() - enable_mjolnir_mock.assert_not_called() - - -def test_on_collect_status_admin_none(harness: Harness, monkeypatch: pytest.MonkeyPatch) -> None: - """ - arrange: start the Synapse charm, set server_name, mock container, mock _admin_access_token - to be None. - act: call _on_collect_status. - assert: mjolnir is not enabled and the model status is Maintenance. - """ - harness.update_config({"enable_mjolnir": True}) - harness.begin_with_initial_hooks() - monkeypatch.setattr(Mjolnir, "_admin_access_token", None) - enable_mjolnir_mock = MagicMock(return_value=None) - monkeypatch.setattr(Mjolnir, "enable_mjolnir", enable_mjolnir_mock) - charm_state_mock = MagicMock() - charm_state_mock.enable_mjolnir = True - harness.charm._mjolnir._charm_state = charm_state_mock - - event_mock = MagicMock() - harness.charm._mjolnir._on_collect_status(event_mock) - - enable_mjolnir_mock.assert_not_called() - assert isinstance(harness.model.unit.status, ops.MaintenanceStatus) - - -def test_enable_mjolnir(harness: Harness, monkeypatch: pytest.MonkeyPatch) -> None: - """ - arrange: start the Synapse charm, set server_name, mock calls to validate args. - act: call enable_mjolnir. - assert: all steps are taken as required. - """ - harness.update_config({"enable_mjolnir": True}) - harness.begin_with_initial_hooks() - admin_access_token = token_hex(16) - monkeypatch.setattr(Mjolnir, "_admin_access_token", admin_access_token) - mjolnir_user_mock = MagicMock() - mjolnir_access_token = token_hex(16) - mjolnir_user_mock.access_token = mjolnir_access_token - create_user_mock = MagicMock(return_value=mjolnir_user_mock) - monkeypatch.setattr(synapse, "create_user", create_user_mock) - room_id = token_hex(16) - get_room_id = MagicMock(return_value=room_id) - monkeypatch.setattr(synapse, "get_room_id", get_room_id) - make_room_admin = MagicMock() - monkeypatch.setattr(synapse, "make_room_admin", make_room_admin) - generate_mjolnir_config = MagicMock() - monkeypatch.setattr(synapse, "generate_mjolnir_config", generate_mjolnir_config) - override_rate_limit = MagicMock() - monkeypatch.setattr(synapse, "override_rate_limit", override_rate_limit) - - charm_state = harness.charm.build_charm_state() - harness.charm._mjolnir.enable_mjolnir(charm_state, admin_access_token) - - get_room_id.assert_called_once_with( - room_name="management", admin_access_token=admin_access_token - ) - make_room_admin.assert_called_once_with( - user=ANY, server=ANY, admin_access_token=admin_access_token, room_id=room_id - ) - generate_mjolnir_config.assert_called_once_with( - container=ANY, access_token=mjolnir_access_token, room_id=room_id - ) - override_rate_limit.assert_called_once_with( - user=ANY, charm_state=ANY, admin_access_token=admin_access_token - ) - assert harness.model.unit.status == ops.ActiveStatus() - - -def test_enable_mjolnir_room_none(harness: Harness, monkeypatch: pytest.MonkeyPatch) -> None: - """ - arrange: start the Synapse charm, set server_name, mock calls to validate args, - get_room_id returns None. - act: call enable_mjolnir. - assert: all steps are taken as required. - """ - harness.update_config({"enable_mjolnir": True}) - harness.begin_with_initial_hooks() - admin_access_token = token_hex(16) - monkeypatch.setattr(Mjolnir, "_admin_access_token", admin_access_token) - mjolnir_user_mock = MagicMock() - mjolnir_access_token = token_hex(16) - mjolnir_user_mock.access_token = mjolnir_access_token - create_user_mock = MagicMock(return_value=mjolnir_user_mock) - monkeypatch.setattr(synapse, "create_user", create_user_mock) - get_room_id = MagicMock(return_value=None) - monkeypatch.setattr(synapse, "get_room_id", get_room_id) - room_id = token_hex(16) - create_management_room = MagicMock(return_value=room_id) - monkeypatch.setattr(synapse, "create_management_room", create_management_room) - make_room_admin = MagicMock() - monkeypatch.setattr(synapse, "make_room_admin", make_room_admin) - generate_mjolnir_config = MagicMock() - monkeypatch.setattr(synapse, "generate_mjolnir_config", generate_mjolnir_config) - override_rate_limit = MagicMock() - monkeypatch.setattr(synapse, "override_rate_limit", override_rate_limit) - - charm_state = harness.charm.build_charm_state() - harness.charm._mjolnir.enable_mjolnir(charm_state, admin_access_token) - - create_user_mock.assert_called_once_with(ANY, ANY, ANY, admin_access_token, ANY) - get_room_id.assert_called_once_with( - room_name="management", admin_access_token=admin_access_token - ) - create_management_room.assert_called_once_with(admin_access_token=admin_access_token) - make_room_admin.assert_called_once_with( - user=ANY, server=ANY, admin_access_token=admin_access_token, room_id=room_id - ) - generate_mjolnir_config.assert_called_once_with( - container=ANY, access_token=mjolnir_access_token, room_id=room_id - ) - override_rate_limit.assert_called_once_with( - user=ANY, charm_state=ANY, admin_access_token=admin_access_token - ) - assert harness.model.unit.status == ops.ActiveStatus() - - -def test_enable_mjolnir_container_off(harness: Harness, monkeypatch: pytest.MonkeyPatch) -> None: - """ - arrange: start the Synapse charm, set server_name, mock container to not connect. - act: call enable_mjolnir. - assert: the next step, register user, is not called. - """ - harness.update_config({"enable_mjolnir": True}) - harness.begin_with_initial_hooks() - container: ops.Container = harness.model.unit.get_container(synapse.SYNAPSE_CONTAINER_NAME) - monkeypatch.setattr(container, "can_connect", MagicMock(return_value=False)) - register_user_mock = MagicMock() - monkeypatch.setattr(actions, "register_user", register_user_mock) - - charm_state = harness.charm.build_charm_state() - harness.charm._mjolnir.enable_mjolnir(charm_state, token_hex(16)) - - register_user_mock.assert_not_called() - - -def test_enable_mjolnir_admin_access_token( - harness: Harness, monkeypatch: pytest.MonkeyPatch -) -> None: - """ - arrange: start the Synapse charm and mock token_service. - act: get the admin_access_token. - assert: admin_access_token is the same as the one from token_service except - if container is down or token_service returns None. - """ - harness.begin_with_initial_hooks() - token_mock = token_hex(16) - token_service = MagicMock() - monkeypatch.setattr(token_service, "get", MagicMock(return_value=token_mock)) - monkeypatch.setattr(harness.charm._mjolnir, "_token_service", token_service) - - assert harness.charm._mjolnir._admin_access_token == token_mock - - monkeypatch.setattr(token_service, "get", MagicMock(return_value=None)) - monkeypatch.setattr(harness.charm._mjolnir, "_token_service", token_service) - - assert harness.charm._mjolnir._admin_access_token is None - - container: ops.Container = harness.model.unit.get_container(synapse.SYNAPSE_CONTAINER_NAME) - monkeypatch.setattr(container, "can_connect", MagicMock(return_value=False)) - - assert harness.charm._mjolnir._admin_access_token is None - - -def test_admin_access_token_no_connection( - harness: ops.testing.Harness, monkeypatch: pytest.MonkeyPatch -) -> None: - """ - arrange: start the Synapse charm, set server_name, mock container to not connect. - act: call _admin_access_token. - assert: None is returned. - """ - harness.update_config({"enable_mjolnir": True}) - harness.begin_with_initial_hooks() - container: ops.Container = harness.model.unit.get_container(synapse.SYNAPSE_CONTAINER_NAME) - monkeypatch.setattr(container, "can_connect", MagicMock(return_value=False)) - charm_state = harness.charm.build_charm_state() - harness.charm._mjolnir.enable_mjolnir(charm_state, token_hex(16)) - - result = harness.charm._mjolnir._admin_access_token - - assert result is None - - -def test_admin_access_token_no_token( - harness: ops.testing.Harness, monkeypatch: pytest.MonkeyPatch -) -> None: - """ - arrange: start the Synapse charm, set server_name, mock container, - mock token_service to return None. - act: call _admin_access_token. - assert: None is returned and an error message is logged. - """ - harness.update_config({"enable_mjolnir": True}) - harness.begin_with_initial_hooks() - container: ops.Container = harness.model.unit.get_container(synapse.SYNAPSE_CONTAINER_NAME) - monkeypatch.setattr(container, "can_connect", MagicMock(return_value=True)) - token_service_mock = MagicMock(return_value=None) - - monkeypatch.setattr(synapse.workload, "_get_configuration_field", MagicMock(return_value=None)) - - charm_state = harness.charm.build_charm_state() - harness.charm._mjolnir.enable_mjolnir(charm_state, token_service_mock) - - with patch.object(logging, "error") as mock_error: - result = harness.charm._mjolnir._admin_access_token - - assert result is None - mock_error.assert_called_once_with( - "Admin Access Token was not found, please check the logs." - ) - - -@dataclass -class AdminUser: - """ - mock AdminUser dataclass. - - Attributes: - access_token: str - """ - - access_token: str - - -def test_admin_access_token_success( - harness: ops.testing.Harness, monkeypatch: pytest.MonkeyPatch, mocked_synapse_calls -) -> None: - """ - arrange: start the Synapse charm, set server_name, mock container, - mock token_service to return a token. - act: call _admin_access_token. - assert: the access token is returned. - """ - # pylint: disable=unused-argument - harness.update_config({"enable_mjolnir": True}) - harness.begin_with_initial_hooks() - container: ops.Container = harness.model.unit.get_container(synapse.SYNAPSE_CONTAINER_NAME) - monkeypatch.setattr(container, "can_connect", MagicMock(return_value=True)) - access_token = token_hex(16) - token_service_mock = MagicMock(return_value=access_token) - admin_user = AdminUser(access_token) - monkeypatch.setattr(synapse, "create_admin_user", MagicMock(return_value=admin_user)) - - charm_state = harness.charm.build_charm_state() - - with patch("synapse.api._do_request") as mock_request: - mock_request.return_value.status_code = 200 - expected_room_id = token_hex(16) - room_name = token_hex(16) - expected_room_res = [{"name": room_name, "room_id": expected_room_id}] - mock_request.return_value.json.return_value = { - "access_token": "access_token", - "nonce": "sense", - "rooms": expected_room_res, - } - harness.charm._mjolnir.enable_mjolnir(charm_state, token_service_mock) - result = harness.charm._mjolnir._admin_access_token - - assert result == access_token - - -def test_on_collect_status_not_main_unit( - harness: Harness, monkeypatch: pytest.MonkeyPatch -) -> None: - """ - arrange: start the Synapse charm, set server_name, unit is not the main. - act: call _on_collect_status. - assert: no actions is taken because Mjolnir is enabled only in main unit. - """ - harness.add_relation( - synapse.SYNAPSE_PEER_RELATION_NAME, - "synapse", - app_data={"main_unit_id": "synapse/1"}, - ) - harness.update_config({"enable_mjolnir": True}) - harness.begin_with_initial_hooks() - enable_mjolnir_mock = MagicMock() - monkeypatch.setattr(Mjolnir, "enable_mjolnir", enable_mjolnir_mock) - - event_mock = MagicMock() - harness.charm._mjolnir._on_collect_status(event_mock) - - event_mock.add_status.assert_not_called() - enable_mjolnir_mock.assert_not_called() - - -def test_on_collect_status_not_main_unit_and_is_started( - harness: Harness, monkeypatch: pytest.MonkeyPatch -) -> None: - """ - arrange: start the Synapse charm, set server_name, unit has Mjolnir started. - act: call _on_collect_status. - assert: Mjolnir is stopped since this is not the main unit. - """ - harness.add_relation( - synapse.SYNAPSE_PEER_RELATION_NAME, - "synapse", - app_data={"main_unit_id": "synapse/1"}, - ) - harness.update_config({"enable_mjolnir": True}) - harness.begin_with_initial_hooks() - enable_mjolnir_mock = MagicMock() - monkeypatch.setattr(Mjolnir, "enable_mjolnir", enable_mjolnir_mock) - container_mock = MagicMock(spec=ops.Container) - monkeypatch.setattr( - harness.charm.unit, "get_container", MagicMock(return_value=container_mock) - ) - - event_mock = MagicMock() - harness.charm._mjolnir._on_collect_status(event_mock) - - event_mock.add_status.assert_not_called() - enable_mjolnir_mock.assert_not_called() - container_mock.stop.assert_called_with("mjolnir") diff --git a/tests/unit/test_promote_user_admin_action.py b/tests/unit/test_promote_user_admin_action.py deleted file mode 100644 index c4315e69..00000000 --- a/tests/unit/test_promote_user_admin_action.py +++ /dev/null @@ -1,129 +0,0 @@ -# Copyright 2025 Canonical Ltd. -# See LICENSE file for licensing details. - -"""Register user action unit tests.""" - -# Disabled to access _on_register_user_action -# pylint: disable=protected-access - -import unittest.mock -from secrets import token_hex - -import pytest -from ops.charm import ActionEvent -from ops.testing import Harness - -import synapse - - -def test_promote_user_admin_action(harness: Harness, monkeypatch: pytest.MonkeyPatch) -> None: - """ - arrange: start the Synapse charm, set Synapse container to be ready and set server_name. - act: run promote-user-admin action. - assert: event results are returned as expected. - """ - harness.begin_with_initial_hooks() - admin_access_token = token_hex(16) - promote_user_admin_mock = unittest.mock.Mock() - monkeypatch.setattr( - harness.charm.token_service, - "get", - unittest.mock.MagicMock(return_value=admin_access_token), - ) - monkeypatch.setattr("synapse.promote_user_admin", promote_user_admin_mock) - user = "username" - event = unittest.mock.MagicMock(spec=ActionEvent) - event.params = { - "username": user, - } - - harness.charm._on_promote_user_admin_action(event) - - assert event.set_results.call_count == 1 - event.set_results.assert_called_with({"promote-user-admin": True}) - promote_user_admin_mock.assert_called_with( - user=unittest.mock.ANY, server=unittest.mock.ANY, admin_access_token=admin_access_token - ) - - -def test_promote_user_admin_api_error(harness: Harness, monkeypatch: pytest.MonkeyPatch) -> None: - """ - arrange: start the Synapse charm, set Synapse container to be ready and set server_name. - act: run promote-user-admin action. - assert: event fails as expected. - """ - harness.begin_with_initial_hooks() - fail_message = "Some fail message" - synapse_api_error = synapse.APIError(fail_message) - promote_user_admin_mock = unittest.mock.MagicMock(side_effect=synapse_api_error) - monkeypatch.setattr("synapse.promote_user_admin", promote_user_admin_mock) - admin_access_token = token_hex(16) - monkeypatch.setattr( - harness.charm.token_service, - "get", - unittest.mock.MagicMock(return_value=admin_access_token), - ) - user = "username" - event = unittest.mock.MagicMock(spec=ActionEvent) - event.params = { - "username": user, - } - - def event_store_failure(failure_message: str) -> None: - """Define a failure message for the event. - - Args: - failure_message: failure message content to be defined. - """ - event.fail_message = failure_message - - event.fail = event_store_failure - event.params = { - "username": user, - } - - harness.charm._on_promote_user_admin_action(event) - - assert fail_message in event.fail_message - - -def test_promote_user_admin_container_down(harness: Harness) -> None: - """ - arrange: start the Synapse charm, set Synapse container to be off. - act: run promote-user-admin action. - assert: event fails as expected. - """ - harness.begin_with_initial_hooks() - harness.set_can_connect(harness.model.unit.containers[synapse.SYNAPSE_CONTAINER_NAME], False) - event = unittest.mock.Mock() - - harness.charm._on_promote_user_admin_action(event) - - assert event.set_results.call_count == 0 - assert event.fail.call_count == 1 - assert "Failed to connect to the container" == event.fail.call_args[0][0] - - -def test_promote_user_admin_action_no_token( - harness: Harness, monkeypatch: pytest.MonkeyPatch -) -> None: - """ - arrange: start the Synapse charm, set Synapse container to be ready and set server_name. - act: run promote-user-admin action. - assert: event fails as expected. - """ - harness.begin_with_initial_hooks() - promote_user_admin_mock = unittest.mock.Mock() - monkeypatch.setattr( - harness.charm.token_service, - "get", - unittest.mock.MagicMock(return_value=None), - ) - monkeypatch.setattr("synapse.promote_user_admin", promote_user_admin_mock) - event = unittest.mock.Mock() - - harness.charm._on_promote_user_admin_action(event) - - assert event.set_results.call_count == 0 - assert event.fail.call_count == 1 - assert "Failed to get admin access token" == event.fail.call_args[0][0] diff --git a/tests/unit/test_register_user_action.py b/tests/unit/test_register_user_action.py deleted file mode 100644 index 1f37bbdb..00000000 --- a/tests/unit/test_register_user_action.py +++ /dev/null @@ -1,129 +0,0 @@ -# Copyright 2025 Canonical Ltd. -# See LICENSE file for licensing details. - -"""Register user action unit tests.""" - -# Disabled to access _on_register_user_action -# pylint: disable=protected-access - -import unittest.mock - -import ops -import pytest -from ops.charm import ActionEvent -from ops.testing import Harness - -import synapse -from user import User - - -def test_register_user_action(harness: Harness, monkeypatch: pytest.MonkeyPatch) -> None: - """ - arrange: start the Synapse charm, set Synapse container to be ready and set server_name. - act: run register-user action. - assert: User is created and the charm is active. - """ - harness.begin_with_initial_hooks() - get_registration_mock = unittest.mock.Mock(return_value="shared_secret") - monkeypatch.setattr("synapse.get_registration_shared_secret", get_registration_mock) - register_user_mock = unittest.mock.MagicMock() - monkeypatch.setattr("synapse.register_user", register_user_mock) - user = "username" - event = unittest.mock.MagicMock(spec=ActionEvent) - event.params = { - "username": user, - "admin": "no", - } - - # Calling to test the action since is not possible calling via harness - harness.charm._on_register_user_action(event) - - assert event.set_results.call_count == 1 - event.set_results.assert_called_with( - {"register-user": True, "user-password": unittest.mock.ANY} - ) - assert isinstance(harness.model.unit.status, ops.ActiveStatus) - - -def test_register_user_registration_none( - harness: Harness, monkeypatch: pytest.MonkeyPatch -) -> None: - """ - arrange: start the Synapse charm, set Synapse container to be ready and set server_name. - act: run register-user action. - assert: event fails if registration shared secret is not found. - """ - harness.begin_with_initial_hooks() - get_registration_mock = unittest.mock.Mock(return_value=None) - monkeypatch.setattr("synapse.get_registration_shared_secret", get_registration_mock) - register_user_mock = unittest.mock.MagicMock() - monkeypatch.setattr("synapse.register_user", register_user_mock) - user = "username" - event = unittest.mock.MagicMock(spec=ActionEvent) - - def event_store_failure(failure_message: str) -> None: - """Define a failure message for the event. - - Args: - failure_message: failure message content to be defined. - """ - event.fail_message = failure_message - - event.fail = event_store_failure - event.params = { - "username": user, - "admin": "no", - } - - # Calling to test the action since is not possible calling via harness - harness.charm._on_register_user_action(event) - - assert "registration_shared_secret was not found" in event.fail_message - assert isinstance(harness.model.unit.status, ops.ActiveStatus) - - -def test_register_user_action_api_error(harness: Harness, monkeypatch: pytest.MonkeyPatch) -> None: - """ - arrange: start the Synapse charm, set Synapse container to be ready and set server_name. - act: run register-user action. - assert: Synapse API fails. - """ - harness.begin_with_initial_hooks() - get_registration_mock = unittest.mock.Mock(return_value="shared_secret") - monkeypatch.setattr("synapse.get_registration_shared_secret", get_registration_mock) - fail_message = "Some fail message" - synapse_api_error = synapse.APIError(fail_message) - register_user_mock = unittest.mock.MagicMock(side_effect=synapse_api_error) - monkeypatch.setattr("synapse.register_user", register_user_mock) - user = "username" - event = unittest.mock.MagicMock(spec=ActionEvent) - - def event_store_failure(failure_message: str) -> None: - """Define a failure message for the event. - - Args: - failure_message: failure message content to be defined. - """ - event.fail_message = failure_message - - event.fail = event_store_failure - event.params = { - "username": user, - "admin": "no", - } - - # Calling to test the action since is not possible calling via harness - harness.charm._on_register_user_action(event) - - assert fail_message in event.fail_message - assert isinstance(harness.model.unit.status, ops.ActiveStatus) - - -def test_username_empty(): - """ - arrange: create a user. - act: set username as empty. - assert: ValueError is raised. - """ - with pytest.raises(ValueError, match="Username must not be empty"): - User(username="", admin=True) diff --git a/tests/unit/test_smtp_observer.py b/tests/unit/test_smtp_observer.py index eda7183f..3590acea 100644 --- a/tests/unit/test_smtp_observer.py +++ b/tests/unit/test_smtp_observer.py @@ -6,10 +6,12 @@ # pylint: disable=protected-access from secrets import token_hex +from unittest.mock import MagicMock import pytest from charms.smtp_integrator.v0.smtp import AuthType, TransportSecurity from ops.testing import Harness +from pydantic.v1 import ValidationError from charm_types import SMTPConfiguration from state.charm_state import CharmConfigInvalidError @@ -144,3 +146,26 @@ def test_get_relation_as_smtp_conf_password_from_juju_secret(harness: Harness): smtp_configuration = harness.charm._smtp.get_relation_as_smtp_conf() assert smtp_configuration["password"] == password + + +@pytest.mark.parametrize( + "error", + [ + pytest.param(ValueError), + pytest.param(ValidationError, marks=[pytest.mark.requires_secrets]), + ], +) +def test_get_relation_as_smtp_conf_error(harness: Harness, monkeypatch: pytest.MonkeyPatch, error): + """ + arrange: Mock charm with get_relation_data method raising errors. + act: get SMTPConfiguration from smtp observer. + assert: fetched smtp configuration is none. + """ + monkeypatch.setattr( + "charms.smtp_integrator.v0.smtp.SmtpRequires.get_relation_data", + MagicMock(side_effect=error(MagicMock(), MagicMock())), + ) + harness.add_relation("smtp", "smtp-integrator", app_data={}) + harness.begin() + + assert harness.charm._smtp.get_relation_as_smtp_conf() is None diff --git a/tests/unit/test_synapse_api.py b/tests/unit/test_synapse_api.py index e83e9a23..9a853717 100644 --- a/tests/unit/test_synapse_api.py +++ b/tests/unit/test_synapse_api.py @@ -18,197 +18,6 @@ from user import User -@mock.patch("synapse.api.requests.Session") -def test_register_user_success(mock_session, monkeypatch: pytest.MonkeyPatch): - """ - arrange: set User parameters. - act: register the user. - assert: parameters are passed correctly. - """ - # Set user parameters - username = "any-user" - user = User(username=username, admin=True) - # Prepare mock to register the user - get_nonce_return = "nonce" - get_nonce_mock = mock.MagicMock(return_value=get_nonce_return) - monkeypatch.setattr("synapse.api._get_nonce", get_nonce_mock) - generate_mac_mock = mock.MagicMock(return_value="mac") - monkeypatch.setattr("synapse.api._generate_mac", generate_mac_mock) - mock_response = mock.MagicMock() - mock_response.raise_for_status.return_value = None - mock_requests = mock.MagicMock() - mock_requests.post.return_value = mock_response - mock_session.return_value = mock_requests - shared_secret = token_hex(16) - - synapse.register_user(shared_secret, user) - - # Check if parameters are correct. - get_nonce_mock.assert_called_once() - generate_mac_mock.assert_called_once_with( - shared_secret=shared_secret, - nonce=get_nonce_return, - user=username, - password=mock.ANY, - admin=True, - ) - - -@mock.patch("synapse.api.requests.Session") -def test_register_user_error(mock_session, monkeypatch: pytest.MonkeyPatch): - """ - arrange: set User parameters and mock post to return connection and http errors. - act: register the user. - assert: NetworkError is raised. - """ - username = "any-user" - user = User(username=username, admin=True) - get_nonce_return = "nonce" - get_nonce_mock = mock.MagicMock(return_value=get_nonce_return) - monkeypatch.setattr("synapse.api._get_nonce", get_nonce_mock) - generate_mac_mock = mock.MagicMock(return_value="mac") - monkeypatch.setattr("synapse.api._generate_mac", generate_mac_mock) - mock_response_error = requests.exceptions.ConnectionError("Connection error") - mock_request = mock.Mock() - mock_request.request.side_effect = mock_response_error - mock_session.return_value = mock_request - shared_secret = token_hex(16) - with pytest.raises(synapse.APIError, match="Failed to connect to"): - synapse.register_user(shared_secret, user) - - mock_response_exception = mock.MagicMock() - mock_response_exception.text = "Fail" - mock_response_http_error = requests.exceptions.HTTPError( - request=mock.Mock(), response=mock_response_exception - ) - mock_request = mock.Mock() - mock_request.request.side_effect = mock_response_http_error - mock_session.return_value = mock_request - - with pytest.raises(synapse.APIError, match="HTTP error from"): - synapse.register_user(shared_secret, user) - - -@mock.patch("synapse.api.requests.Session") -def test_register_user_keyerror(mock_session, monkeypatch: pytest.MonkeyPatch): - """ - arrange: set User parameters and mock post to return empty content. - act: register the user. - assert: KeyError is raised. - """ - username = "any-user" - user = User(username=username, admin=True) - get_nonce_return = "nonce" - get_nonce_mock = mock.MagicMock(return_value=get_nonce_return) - monkeypatch.setattr("synapse.api._get_nonce", get_nonce_mock) - generate_mac_mock = mock.MagicMock(return_value="mac") - monkeypatch.setattr("synapse.api._generate_mac", generate_mac_mock) - mock_response = mock.MagicMock() - mock_response.json.return_value = {} - mock_requests = mock.MagicMock() - mock_requests.request.return_value = mock_response - mock_session.return_value = mock_requests - shared_secret = token_hex(16) - - with pytest.raises(synapse.APIError, match="access_token"): - synapse.register_user(shared_secret, user) - - -def test_register_user_nonce_error(monkeypatch: pytest.MonkeyPatch): - """ - arrange: set User parameters and mock once to return error. - act: register the user. - assert: NetworkError is raised. - """ - username = "any-user" - user = User(username=username, admin=True) - msg = "Wrong nonce" - mock_nonce_error = synapse.api.GetNonceError(msg) - get_nonce_mock = mock.MagicMock(side_effect=mock_nonce_error) - monkeypatch.setattr("synapse.api._get_nonce", get_nonce_mock) - shared_secret = token_hex(16) - - with pytest.raises(synapse.APIError, match=msg): - synapse.register_user(shared_secret, user) - - -@mock.patch("synapse.api.requests.Session") -def test_register_user_exists_error(mock_session, monkeypatch: pytest.MonkeyPatch): - """ - arrange: set User parameters and mock post to return UserExistsError. - act: register the user. - assert: exception is raised because there is no server/admin access token. - """ - username = "any-user" - user = User(username=username, admin=True) - get_nonce_return = "nonce" - get_nonce_mock = mock.MagicMock(return_value=get_nonce_return) - monkeypatch.setattr("synapse.api._get_nonce", get_nonce_mock) - generate_mac_mock = mock.MagicMock(return_value="mac") - monkeypatch.setattr("synapse.api._generate_mac", generate_mac_mock) - shared_secret = token_hex(16) - mock_response_exception = mock.MagicMock() - mock_response_exception.text = "User ID already taken" - mock_response_http_error = requests.exceptions.HTTPError( - request=mock.Mock(), response=mock_response_exception - ) - mock_request = mock.Mock() - mock_request.request.side_effect = mock_response_http_error - mock_session.return_value = mock_request - - with pytest.raises(synapse.APIError, match="exists but there is no"): - synapse.register_user(shared_secret, user) - - -@mock.patch("synapse.api.requests.Session") -def test_access_token_success(mock_session): - """ - arrange: set User, admin_token and server parameters. - act: get access token. - assert: token is returned as expected. - """ - # Set user parameters - username = "any-user" - user = User(username=username, admin=True) - # Prepare mock to get the access token - mock_response = mock.MagicMock() - expected_token = token_hex(16) - mock_response = mock.MagicMock() - mock_response.json.return_value = {"access_token": expected_token} - mock_requests = mock.MagicMock() - mock_requests.request.return_value = mock_response - mock_session.return_value = mock_requests - server = token_hex(16) - admin_access_token = token_hex(16) - - result = synapse.get_access_token(user, server=server, admin_access_token=admin_access_token) - - assert result == expected_token - - -@mock.patch("synapse.api.requests.Session") -def test_access_token_error(mock_session): - """ - arrange: set User, admin_token and server parameters. - act: get access token. - assert: API error is raised. - """ - # Set user parameters - username = "any-user" - user = User(username=username, admin=True) - # Prepare mock to get the access token - mock_response = mock.MagicMock() - mock_response.json.return_value = {} - mock_requests = mock.MagicMock() - mock_requests.request.return_value = mock_response - mock_session.return_value = mock_requests - server = token_hex(16) - admin_access_token = token_hex(16) - - with pytest.raises(synapse.APIError, match="access_token"): - synapse.get_access_token(user, server=server, admin_access_token=admin_access_token) - - def test_override_rate_limit_success(monkeypatch: pytest.MonkeyPatch): """ arrange: set User, admin_token and charm_state parameters. @@ -352,133 +161,6 @@ def test_get_room_id_not_found(monkeypatch: pytest.MonkeyPatch): ) -def test_deactivate_user_success(monkeypatch: pytest.MonkeyPatch): - """ - arrange: set User, admin_token and server parameters. - act: deactivate user. - assert: request is called as expected. - """ - username = "any-user" - user = User(username=username, admin=True) - admin_access_token = token_hex(16) - server = token_hex(16) - expected_url = f"http://localhost:8008/_synapse/admin/v1/deactivate/@{username}:{server}" - do_request_mock = mock.MagicMock(return_value=mock.MagicMock()) - monkeypatch.setattr("synapse.api._do_request", do_request_mock) - - synapse.deactivate_user(user, admin_access_token=admin_access_token, server=server) - - do_request_mock.assert_called_once_with( - "POST", - expected_url, - admin_access_token=admin_access_token, - json={"erase": True}, - ) - - -def test_deactivate_user_error(monkeypatch: pytest.MonkeyPatch): - """ - arrange: set User, admin_token and server parameters. - act: deactivate user. - assert: exception is raised as expected. - """ - username = "any-user" - user = User(username=username, admin=True) - admin_access_token = token_hex(16) - server = token_hex(16) - expected_error_msg = "Failed to connect" - do_request_mock = mock.MagicMock(side_effect=synapse.APIError(expected_error_msg)) - monkeypatch.setattr("synapse.api._do_request", do_request_mock) - - with pytest.raises(synapse.APIError, match=expected_error_msg): - synapse.deactivate_user(user, admin_access_token=admin_access_token, server=server) - - -def test_create_management_room_success(monkeypatch: pytest.MonkeyPatch): - """ - arrange: set admin_token parameter and mock get_room_id. - act: create management room. - assert: room id is returned. - """ - moderator_room_id = token_hex(16) - monkeypatch.setattr("synapse.api.get_room_id", mock.MagicMock(return_value=moderator_room_id)) - do_request_mock = mock.MagicMock(return_value=mock.MagicMock()) - monkeypatch.setattr("synapse.api._do_request", do_request_mock) - admin_access_token = token_hex(16) - - synapse.create_management_room(admin_access_token=admin_access_token) - - expected_url = "http://localhost:8008/_matrix/client/v3/createRoom" - expected_json = { - "name": "management", - "power_level_content_override": {"events_default": 0}, - "room_alias_name": "management", - "visibility": "private", - "initial_state": [ - { - "type": "m.room.history_visibility", - "state_key": "", - "content": {"history_visibility": "shared"}, - }, - { - "type": "m.room.guest_access", - "state_key": "", - "content": {"guest_access": "can_join"}, - }, - {"type": "m.room.retention", "state_key": "", "content": {"max_lifetime": 604800000}}, - { - "type": "m.room.join_rules", - "state_key": "", - "content": { - "join_rule": "restricted", - "allow": [{"room_id": moderator_room_id, "type": "m.room_membership"}], - }, - }, - ], - } - do_request_mock.assert_called_once_with( - "POST", - expected_url, - admin_access_token=admin_access_token, - json=expected_json, - ) - - -def test_create_management_room_error(monkeypatch: pytest.MonkeyPatch): - """ - arrange: set admin_token parameter, mock get_room_id and mock do_requests to raise exception. - act: create management room. - assert: exception is raised. - """ - moderator_room_id = token_hex(16) - monkeypatch.setattr("synapse.api.get_room_id", mock.MagicMock(return_value=moderator_room_id)) - admin_access_token = token_hex(16) - expected_error_msg = "Failed to connect" - do_request_mock = mock.MagicMock(side_effect=synapse.APIError(expected_error_msg)) - monkeypatch.setattr("synapse.api._do_request", do_request_mock) - - with pytest.raises(synapse.APIError, match=expected_error_msg): - synapse.create_management_room(admin_access_token=admin_access_token) - - -def test_create_management_room_key_error(monkeypatch: pytest.MonkeyPatch): - """ - arrange: set admin_token parameter, mock get_room_id and mock do_requests to raise exception. - act: create management room. - assert: exception is raised. - """ - moderator_room_id = token_hex(16) - monkeypatch.setattr("synapse.api.get_room_id", mock.MagicMock(return_value=moderator_room_id)) - mock_response = mock.MagicMock() - mock_response.json.return_value = {} - do_request_mock = mock.MagicMock(return_value=mock_response) - monkeypatch.setattr("synapse.api._do_request", do_request_mock) - admin_access_token = token_hex(16) - - with pytest.raises(synapse.APIError, match="'room_id'"): - synapse.create_management_room(admin_access_token=admin_access_token) - - def test_make_room_admin_success(monkeypatch: pytest.MonkeyPatch): """ arrange: set User, server, admin_access_token and room_id parameters. @@ -684,49 +366,6 @@ def test_get_version_regex_error(mock_session): synapse.api.get_version("foo") -def test_promote_user_admin_success(monkeypatch: pytest.MonkeyPatch): - """ - arrange: set User, server and admin_access_token. - act: call promote_user_admin. - assert: request is called as expected. - """ - username = "any-user" - user = User(username=username, admin=True) - admin_access_token = token_hex(16) - server = token_hex(16) - do_request_mock = mock.MagicMock(return_value=mock.MagicMock()) - monkeypatch.setattr("synapse.api._do_request", do_request_mock) - - synapse.promote_user_admin(user, admin_access_token=admin_access_token, server=server) - - user_id = f"@{user.username}:{server}" - expected_url = synapse.api.PROMOTE_USER_ADMIN_URL.replace("user_id", user_id) - do_request_mock.assert_called_once_with( - "PUT", - expected_url, - admin_access_token=admin_access_token, - json={"admin": True}, - ) - - -def test_promote_user_admin_error(monkeypatch: pytest.MonkeyPatch): - """ - arrange: set User, server, admin_access_token and admin_access_token. - act: call promote_user_admin. - assert: exception is raised as expected. - """ - username = "any-user" - user = User(username=username, admin=True) - admin_access_token = token_hex(16) - server = token_hex(16) - expected_error_msg = "Failed to connect" - do_request_mock = mock.MagicMock(side_effect=synapse.APIError(expected_error_msg)) - monkeypatch.setattr("synapse.api._do_request", do_request_mock) - - with pytest.raises(synapse.APIError, match=expected_error_msg): - synapse.promote_user_admin(user, admin_access_token=admin_access_token, server=server) - - def test_is_token_valid_correct(monkeypatch: pytest.MonkeyPatch): """ arrange: given an access token and mocking http requests not to fail. diff --git a/tests/unit/test_synapse_workload.py b/tests/unit/test_synapse_workload.py index fc36703f..b4e5b98b 100644 --- a/tests/unit/test_synapse_workload.py +++ b/tests/unit/test_synapse_workload.py @@ -338,45 +338,6 @@ def test_enable_forgotten_room_success(config_content: dict[str, typing.Any]): assert yaml.safe_dump(content) == yaml.safe_dump(expected_config_content) -def test_get_mjolnir_config_success(): - """ - arrange: set access token and room id parameters. - act: call _get_mjolnir_config. - assert: config returns as expected. - """ - access_token = token_hex(16) - room_id = token_hex(16) - - config = synapse.workload._get_mjolnir_config(access_token=access_token, room_id=room_id) - - assert config["accessToken"] == access_token - assert config["managementRoom"] == room_id - - -def test_generate_mjolnir_config_success(monkeypatch: pytest.MonkeyPatch): - """ - arrange: set container, access token and room id parameters. - act: call generate_mjolnir_config. - assert: file is pushed as expected. - """ - access_token = token_hex(16) - room_id = token_hex(16) - push_mock = MagicMock() - container_mock = MagicMock() - monkeypatch.setattr(container_mock, "push", push_mock) - - synapse.generate_mjolnir_config( - container=container_mock, access_token=access_token, room_id=room_id - ) - - expected_config = synapse.workload._get_mjolnir_config( - access_token=access_token, room_id=room_id - ) - push_mock.assert_called_once_with( - synapse.MJOLNIR_CONFIG_PATH, yaml.safe_dump(expected_config), make_dirs=True - ) - - SMTP_CONFIGURATION = SMTPConfiguration( enable_tls=True, force_tls=False, @@ -450,28 +411,6 @@ def test_enable_serve_server_wellknown_success(config_content: dict[str, typing. assert yaml.safe_dump(content) == yaml.safe_dump(expected_config_content) -def test_disable_password_config_success(): - """ - arrange: set mock container with file. - act: call disable_password_config. - assert: new configuration file is pushed and password_config is disabled. - """ - config_content = """ - password_config: - enabled: true - """ - config = yaml.safe_load(config_content) - - synapse.disable_password_config(config) - - expected_config_content = { - "password_config": { - "enabled": False, - }, - } - assert yaml.safe_dump(config) == yaml.safe_dump(expected_config_content) - - def test_get_registration_shared_secret_success(monkeypatch: pytest.MonkeyPatch): """ arrange: set mock container with file.