diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 97391fc3..418edfb5 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -33,5 +33,6 @@ Please, provide some information about your PR before proceeding. - [ ] The documentation is generated using `src-docs` - [ ] The documentation for charmhub is updated. - [ ] The PR is tagged with appropriate label (`urgent`, `trivial`, `complex`) +- [ ] The changelog is updated with changes that affect the users of the charm. diff --git a/.github/workflows/integration_test.yaml b/.github/workflows/integration_test.yaml index 058474bf..630e0bcd 100644 --- a/.github/workflows/integration_test.yaml +++ b/.github/workflows/integration_test.yaml @@ -2,6 +2,9 @@ name: Integration tests on: pull_request: + schedule: + # Trigger at 6:00 AM and 6:00 PM UTC + - cron: "0 6,18 * * *" jobs: integration-tests: @@ -13,4 +16,9 @@ jobs: trivy-image-config: "trivy.yaml" juju-channel: 3.4/stable channel: 1.28-strict/stable - modules: '["test_charm", "test_nginx", "test_s3", "test_scaling"]' + modules: '["test_charm", "test_nginx", "test_s3", "test_scaling", "test_matrix_auth"]' + allure-report: + if: ${{ (success() || failure()) && github.event_name == 'schedule' }} + needs: + - integration-tests + uses: canonical/operator-workflows/.github/workflows/allure_report.yaml@main diff --git a/.trivyignore b/.trivyignore index 661c9fb2..510da387 100644 --- a/.trivyignore +++ b/.trivyignore @@ -1,14 +1,4 @@ # Vulnerabilites related to: Pebble, Node.JS and gosu -CVE-2021-39293 -CVE-2021-41771 -CVE-2021-41772 -CVE-2021-44716 -CVE-2022-23772 -CVE-2022-23806 -CVE-2022-24675 -CVE-2022-24921 -CVE-2022-25883 -CVE-2022-27664 CVE-2022-28131 CVE-2022-28327 CVE-2022-2879 @@ -47,9 +37,9 @@ CVE-2024-29415 CVE-2024-34156 CVE-2024-21538 CVE-2024-24788 -# This should be removed once the following PR is merged. -# https://github.com/element-hq/synapse/pull/17955 -CVE-2024-52804 # Fix ongoing: # https://github.com/element-hq/synapse/pull/17985 CVE-2024-53981 +# This should be removed once pebble releases a new version. +# https://github.com/canonical/pebble/commit/0c134f8e0d80f4bd8f42011279c8f0737b59a673 +CVE-2024-45338 diff --git a/LICENSE b/LICENSE index c4a371b8..4b8b005b 100644 --- a/LICENSE +++ b/LICENSE @@ -187,7 +187,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright 2024 Canonical Ltd. + Copyright 2025 Canonical Ltd. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/actions.yaml b/actions.yaml index 950de423..c236215c 100644 --- a/actions.yaml +++ b/actions.yaml @@ -1,4 +1,4 @@ -# Copyright 2024 Canonical Ltd. +# Copyright 2025 Canonical Ltd. # See LICENSE file for licensing details. anonymize-user: @@ -41,15 +41,6 @@ verify-user-email: required: - username - email -promote-user-admin: - description: | - Promote a user as a server administrator. - You need to supply a user name. - properties: - username: - description: | - User name to be promoted to admin. - type: string create-backup: description: | Creates a backup to s3 storage. diff --git a/charmcraft.yaml b/charmcraft.yaml index 5b9e8f57..10dfc919 100644 --- a/charmcraft.yaml +++ b/charmcraft.yaml @@ -1,4 +1,4 @@ -# Copyright 2024 Canonical Ltd. +# Copyright 2025 Canonical Ltd. # See LICENSE file for licensing details. # This file configures Charmcraft. diff --git a/config.yaml b/config.yaml index 9e1f0879..77e532be 100644 --- a/config.yaml +++ b/config.yaml @@ -1,4 +1,4 @@ -# Copyright 2024 Canonical Ltd. +# Copyright 2025 Canonical Ltd. # See LICENSE file for licensing details. options: @@ -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/changelog.md b/docs/changelog.md new file mode 100644 index 00000000..45dafb75 --- /dev/null +++ b/docs/changelog.md @@ -0,0 +1,5 @@ +# Changelog + +### 2025-01-09 + +- Add changelog for tracking user-relevant changes. 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/docs/how-to/contribute.md b/docs/how-to/contribute.md index f9cf8839..0f64226a 100644 --- a/docs/how-to/contribute.md +++ b/docs/how-to/contribute.md @@ -63,6 +63,12 @@ Run the following command: echo -e "tox -e src-docs\ngit add src-docs\n" >> .git/hooks/pre-commit chmod +x .git/hooks/pre-commit ``` +### Changelog + +Please ensure that any new feature, fix, or significant change is documented by +adding an entry to the `docs/changelog.md` file. + +To learn more about changelog best practices, visit [Keep a Changelog](https://keepachangelog.com/). ## Build charm diff --git a/generate-src-docs.sh b/generate-src-docs.sh index 23cb0070..326ae2fd 100755 --- a/generate-src-docs.sh +++ b/generate-src-docs.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash -# Copyright 2024 Canonical Ltd. +# Copyright 2025 Canonical Ltd. # See LICENSE file for licensing details. rm -rf src-docs diff --git a/lib/charms/nginx_ingress_integrator/v0/nginx_route.py b/lib/charms/nginx_ingress_integrator/v0/nginx_route.py index a2ec38ec..0d73c75d 100644 --- a/lib/charms/nginx_ingress_integrator/v0/nginx_route.py +++ b/lib/charms/nginx_ingress_integrator/v0/nginx_route.py @@ -1,4 +1,4 @@ -# Copyright 2024 Canonical Ltd. +# Copyright 2025 Canonical Ltd. # Licensed under the Apache2.0. See LICENSE file in charm source for details. """Library for the nginx-route relation. diff --git a/lib/charms/smtp_integrator/v0/smtp.py b/lib/charms/smtp_integrator/v0/smtp.py index 2816965e..b5a7a263 100644 --- a/lib/charms/smtp_integrator/v0/smtp.py +++ b/lib/charms/smtp_integrator/v0/smtp.py @@ -1,4 +1,4 @@ -# Copyright 2024 Canonical Ltd. +# Copyright 2025 Canonical Ltd. # Licensed under the Apache2.0. See LICENSE file in charm source for details. """Library to manage the integration with the SMTP Integrator charm. diff --git a/lib/charms/synapse/v0/matrix_auth.py b/lib/charms/synapse/v1/matrix_auth.py similarity index 79% rename from lib/charms/synapse/v0/matrix_auth.py rename to lib/charms/synapse/v1/matrix_auth.py index 2342b99e..e9488690 100644 --- a/lib/charms/synapse/v0/matrix_auth.py +++ b/lib/charms/synapse/v1/matrix_auth.py @@ -1,4 +1,4 @@ -# Copyright 2024 Canonical Ltd. +# Copyright 2025 Canonical Ltd. # Licensed under the Apache2.0. See LICENSE file in charm source for details. """Library to manage the plugin integrations with the Synapse charm. @@ -63,29 +63,61 @@ def _on_config_changed(self, _) -> None: LIBID = "ff6788c89b204448b3b62ba6f93e2768" # Increment this major API version when introducing breaking changes -LIBAPI = 0 +LIBAPI = 1 # Increment this PATCH version before using `charmcraft publish-lib` or reset # to 0 if you are raising the major API version -LIBPATCH = 3 +LIBPATCH = 0 # pylint: disable=wrong-import-position import json import logging from typing import Dict, List, Optional, Tuple, cast +import secrets +import base64 +from cryptography.fernet import Fernet import ops from pydantic import BaseModel, Field, SecretStr logger = logging.getLogger(__name__) -#### Constants #### APP_REGISTRATION_LABEL = "app-registration" APP_REGISTRATION_CONTENT_LABEL = "app-registration-content" DEFAULT_RELATION_NAME = "matrix-auth" SHARED_SECRET_LABEL = "shared-secret" SHARED_SECRET_CONTENT_LABEL = "shared-secret-content" +ENCRYPTION_KEY_SECRET_LABEL = "encryption-key-secret" +ENCRYPTION_KEY_SECRET_CONTENT_LABEL = "encryption-key-content" +def encrypt_string(key: bytes, plaintext: SecretStr) -> str: + """Encrypt a string using Fernet. + + Args: + key: encryption key in bytes. + plaintext: text to encrypt. + + Returns: + encrypted text. + """ + plaintext = cast(SecretStr, plaintext) + encryptor = Fernet(key) + ciphertext = encryptor.encrypt(plaintext.get_secret_value().encode('utf-8')) + return ciphertext.decode() + +def decrypt_string(key: bytes, ciphertext: str) -> str: + """Decrypt a string using Fernet. + + Args: + key: encryption key in bytes. + ciphertext: encrypted text. + + Returns: + decrypted text. + """ + decryptor = Fernet(key) + plaintext = decryptor.decrypt(ciphertext.encode('utf-8')) + return plaintext.decode() #### Data models for Provider and Requirer #### class MatrixAuthProviderData(BaseModel): @@ -100,6 +132,7 @@ class MatrixAuthProviderData(BaseModel): homeserver: str shared_secret: Optional[SecretStr] = Field(default=None, exclude=True) shared_secret_id: Optional[SecretStr] = Field(default=None) + encryption_key_secret_id: Optional[SecretStr] = Field(default=None) def set_shared_secret_id(self, model: ops.Model, relation: ops.Relation) -> None: """Store the Matrix shared secret as a Juju secret. @@ -124,6 +157,27 @@ def set_shared_secret_id(self, model: ops.Model, relation: ops.Relation) -> None secret.grant(relation) self.shared_secret_id = cast(str, secret.id) + def set_encryption_key_secret_id(self, model: ops.Model, relation: ops.Relation) -> None: + """Store the encryption key to encrypt/decrypt appservice registrations. + + Args: + model: the Juju model + relation: relation to grant access to the secrets to. + """ + key = Fernet.generate_key() + encryption_key = key.decode('utf-8') + try: + secret = model.get_secret(label=ENCRYPTION_KEY_SECRET_LABEL) + secret.set_content({ENCRYPTION_KEY_SECRET_CONTENT_LABEL: encryption_key}) + # secret.id is not None at this point + self.encryption_key_secret_id = cast(str, secret.id) + except ops.SecretNotFoundError: + secret = relation.app.add_secret( + {ENCRYPTION_KEY_SECRET_CONTENT_LABEL: encryption_key}, label=ENCRYPTION_KEY_SECRET_LABEL + ) + secret.grant(relation) + self.encryption_key_secret_id = cast(str, secret.id) + @classmethod def get_shared_secret( cls, model: ops.Model, shared_secret_id: Optional[str] @@ -159,6 +213,7 @@ def to_relation_data(self, model: ops.Model, relation: ops.Relation) -> Dict[str Dict containing the representation. """ self.set_shared_secret_id(model, relation) + self.set_encryption_key_secret_id(model, relation) return self.model_dump(exclude_unset=True) @classmethod @@ -196,56 +251,33 @@ class MatrixAuthRequirerData(BaseModel): Attributes: registration: a generated app registration file. - registration_id: the registration Juju secret ID. """ registration: Optional[SecretStr] = Field(default=None, exclude=True) - registration_secret_id: Optional[SecretStr] = Field(default=None) - - def set_registration_id(self, model: ops.Model, relation: ops.Relation) -> None: - """Store the app registration as a Juju secret. - - Args: - model: the Juju model - relation: relation to grant access to the secrets to. - """ - # password is always defined since pydantic guarantees it - password = cast(SecretStr, self.registration) - # pylint doesn't like get_secret_value - secret_value = password.get_secret_value() # pylint: disable=no-member - try: - secret = model.get_secret(label=APP_REGISTRATION_LABEL) - secret.set_content({APP_REGISTRATION_CONTENT_LABEL: secret_value}) - # secret.id is not None at this point - self.registration_secret_id = cast(str, secret.id) - except ops.SecretNotFoundError: - secret = relation.app.add_secret( - {APP_REGISTRATION_CONTENT_LABEL: secret_value}, label=APP_REGISTRATION_LABEL - ) - secret.grant(relation) - self.registration_secret_id = cast(str, secret.id) @classmethod - def get_registration( - cls, model: ops.Model, registration_secret_id: Optional[str] - ) -> Optional[SecretStr]: - """Retrieve the registration corresponding to the registration_secret_id. + def get_encryption_key_secret( + cls, model: ops.Model, encryption_key_secret_id: Optional[str] + ) -> Optional[bytes]: + """Retrieve the encryption key secret corresponding to the encryption_key_secret_id. Args: model: the Juju model. - registration_secret_id: the secret ID for the registration. + encryption_key_secret_id: the secret ID for the encryption key secret. Returns: - the registration or None if not found. + the encryption key secret as bytes or None if not found. """ - if not registration_secret_id: - return None try: - secret = model.get_secret(id=registration_secret_id) - password = secret.get_content().get(APP_REGISTRATION_CONTENT_LABEL) - if not password: + if not encryption_key_secret_id: + # then its the provider and we can get using label + secret = model.get_secret(label=ENCRYPTION_KEY_SECRET_LABEL) + else: + secret = model.get_secret(id=encryption_key_secret_id) + encryption_key = secret.get_content().get(ENCRYPTION_KEY_SECRET_CONTENT_LABEL) + if not encryption_key: return None - return SecretStr(password) + return encryption_key.encode('utf-8') except ops.SecretNotFoundError: return None @@ -258,11 +290,21 @@ def to_relation_data(self, model: ops.Model, relation: ops.Relation) -> Dict[str Returns: Dict containing the representation. + + Raises: + ValueError if encryption key not found. """ - self.set_registration_id(model, relation) - dumped_model = self.model_dump(exclude_unset=True) + # get encryption key + app = cast(ops.Application, relation.app) + relation_data = relation.data[app] + encryption_key_secret_id = relation_data.get("encryption_key_secret_id") + encryption_key = MatrixAuthRequirerData.get_encryption_key_secret(model, encryption_key_secret_id) + if not encryption_key: + raise ValueError("Invalid relation data: encryption_key_secret_id not found") + # encrypt content + content = encrypt_string(key=encryption_key, plaintext=self.registration) dumped_data = { - "registration_secret_id": dumped_model["registration_secret_id"], + "registration_secret": content, } return dumped_data @@ -280,12 +322,20 @@ def from_relation(cls, model: ops.Model, relation: ops.Relation) -> "MatrixAuthR Raises: ValueError: if the value is not parseable. """ + # get encryption key app = cast(ops.Application, relation.app) relation_data = relation.data[app] - registration_secret_id = relation_data.get("registration_secret_id") - registration = MatrixAuthRequirerData.get_registration(model, registration_secret_id) + encryption_key_secret_id = relation_data.get("encryption_key_secret_id") + encryption_key = MatrixAuthRequirerData.get_encryption_key_secret(model, encryption_key_secret_id) + if not encryption_key: + logger.warning("Invalid relation data: encryption_key_secret_id not found") + return None + # decrypt content + registration_secret = relation_data.get("registration_secret") + if not registration_secret: + return MatrixAuthRequirerData() return MatrixAuthRequirerData( - registration=registration, + registration=decrypt_string(key=encryption_key, ciphertext=registration_secret), ) diff --git a/lib/charms/traefik_k8s/v2/ingress.py b/lib/charms/traefik_k8s/v2/ingress.py index bb7ac5ed..5fb2cae3 100644 --- a/lib/charms/traefik_k8s/v2/ingress.py +++ b/lib/charms/traefik_k8s/v2/ingress.py @@ -1,4 +1,4 @@ -# Copyright 2024 Canonical Ltd. +# Copyright 2025 Canonical Ltd. # See LICENSE file for licensing details. r"""# Interface Library for ingress. diff --git a/localstack-installation.sh b/localstack-installation.sh index 827c94b6..852f2c66 100755 --- a/localstack-installation.sh +++ b/localstack-installation.sh @@ -1,5 +1,5 @@ #!/bin/bash -# Copyright 2024 Canonical Ltd. +# Copyright 2025 Canonical Ltd. # See LICENSE file for licensing details. pip install pip --upgrade diff --git a/metadata.yaml b/metadata.yaml index 4622423a..96136fac 100644 --- a/metadata.yaml +++ b/metadata.yaml @@ -1,4 +1,4 @@ -# Copyright 2024 Canonical Ltd. +# Copyright 2025 Canonical Ltd. # See LICENSE file for licensing details. name: synapse diff --git a/pyproject.toml b/pyproject.toml index 1dc34cad..c0377831 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,4 +1,4 @@ -# Copyright 2024 Canonical Ltd. +# Copyright 2025 Canonical Ltd. # See LICENSE file for licensing details. [tool.bandit] diff --git a/requirements.txt b/requirements.txt index dac8a4de..d6586f7d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,11 +1,11 @@ -boto3 ==1.35.80 -cosl ==0.0.47 -cryptography==44.0.0 -deepdiff ==8.0.1 -jinja2 ==3.1.4 +boto3 ==1.35.96 +cosl ==0.0.50 +cryptography ==44.0.0 +deepdiff ==8.1.1 +jinja2 ==3.1.5 jsonschema ==4.23.0 ops ==2.17.1 psycopg2-binary ==2.9.10 -pydantic ==2.10.3 +pydantic ==2.10.5 python-ulid ==3.0.0 requests ==2.32.3 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 aa6a0cf3..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__` @@ -77,7 +77,7 @@ Unit that this execution is responsible for. --- - + ### function `build_charm_state` @@ -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` @@ -145,7 +145,7 @@ Get signing key from secret. --- - + ### function `get_unit_number` @@ -169,7 +169,7 @@ Get unit number from unit name. --- - + ### function `instance_map` @@ -186,7 +186,7 @@ Build instance_map config. --- - + ### function `is_main` @@ -204,7 +204,7 @@ Verify if this unit is the main. --- - + ### function `peer_units_total` @@ -221,7 +221,7 @@ Get peer units total. --- - + ### function `reconcile` @@ -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/matrix_auth_observer.py.md b/src-docs/matrix_auth_observer.py.md index 2a08a1e7..de10b667 100644 --- a/src-docs/matrix_auth_observer.py.md +++ b/src-docs/matrix_auth_observer.py.md @@ -56,7 +56,7 @@ Return the current charm. --- - + ### function `get_requirer_registration_secrets` 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 7a44b3da..746ea758 100644 --- a/src-docs/pebble.py.md +++ b/src-docs/pebble.py.md @@ -106,25 +106,6 @@ 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` ```python @@ -143,7 +124,7 @@ Restart Synapse NGINX service and regenerate configuration. --- - + ## function `restart_federation_sender` @@ -163,26 +144,7 @@ Restart Synapse federation sender service and regenerate configuration. --- - - -## function `replan_mjolnir` - -```python -replan_mjolnir(container: Container) → None -``` - -Replan Synapse Mjolnir service. - - - -**Args:** - - - `container`: Charm container. - - ---- - - + ## function `replan_stats_exporter` @@ -202,7 +164,7 @@ Replan Synapse StatsExporter service. --- - + ## function `replan_synapse_federation_sender` @@ -225,7 +187,7 @@ Replan Synapse Federation Sender service. --- - + ## function `replan_mas` @@ -244,7 +206,7 @@ Replan Matrix Authentication Service. --- - + ## function `reconcile` @@ -283,7 +245,7 @@ This is the main entry for changes that require a restart done via Pebble. --- - + ## function `restart_mas` diff --git a/src/admin_access_token.py b/src/admin_access_token.py deleted file mode 100644 index 2981dfb8..00000000 --- a/src/admin_access_token.py +++ /dev/null @@ -1,118 +0,0 @@ -# Copyright 2024 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/__init__.py b/src/auth/__init__.py index 88566905..c92aa7d5 100644 --- a/src/auth/__init__.py +++ b/src/auth/__init__.py @@ -1,4 +1,4 @@ -# Copyright 2024 Canonical Ltd. +# Copyright 2025 Canonical Ltd. # See LICENSE file for licensing details. """Auth module.""" diff --git a/src/auth/mas.py b/src/auth/mas.py index 1aa20e5d..79030543 100644 --- a/src/auth/mas.py +++ b/src/auth/mas.py @@ -1,4 +1,4 @@ -# Copyright 2024 Canonical Ltd. +# Copyright 2025 Canonical Ltd. # See LICENSE file for licensing details. """Helper module used to manage MAS-related workloads.""" @@ -10,6 +10,7 @@ from charms.hydra.v0.oauth import ClientConfig, OauthProviderConfig from jinja2 import Environment, FileSystemLoader, select_autoescape +from charm_types import SMTPConfiguration from state.charm_state import SynapseConfig from state.mas import MASConfiguration @@ -39,8 +40,10 @@ ) MAS_AUTHORIZATION_GRANT = ["authorization_code"] -MAS_TOKEN_ENDPOINT_AUTH_METHOD = "client_secret_basic" MAS_OIDC_SCOPE = "openid profile email" +# Disabling bandit checks since these are only the labels for juju secret +MAS_TOKEN_ENDPOINT_AUTH_METHOD = "client_secret_basic" # nosec +ADMIN_TOKEN_SECRET_LABEL = "admin.token" # nosec class MASConfigInvalidError(Exception): @@ -55,6 +58,10 @@ class MASVerifyUserEmailFailedError(Exception): """Exception raised when validation of the MAS config failed.""" +class MASGenerateAdminAccessTokenError(Exception): + """Exception raised when generation of admin token failed.""" + + def validate_mas_config(container: ops.model.Container) -> None: """Validate current MAS configuration. @@ -102,7 +109,7 @@ def register_user( Returns: str: The generated user password """ - password = secrets.token_urlsafe(16) + password = secrets.token_hex(16) command = [ MAS_EXECUTABLE_PATH, "-c", @@ -112,7 +119,7 @@ def register_user( "--yes", username, "--password", - password, + f"'{password}'", ] if is_admin: command.append("--admin") @@ -159,10 +166,32 @@ def verify_user_email( 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, oauth_provider_info: typing.Optional[OauthProviderConfig], + smtp_configuration: typing.Optional[SMTPConfiguration], main_unit_address: str, ) -> str: """Render the MAS configuration file. @@ -170,6 +199,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. oauth_provider_info: upstream provider configuration. @@ -193,6 +223,7 @@ def generate_mas_config( "synapse_main_unit_address": main_unit_address, "upstream_oidc_provider_id": mas_context.upstream_oidc_provider_id, "oauth_provider_info": oauth_provider_info, + "smtp_configuration": smtp_configuration, } env = Environment( loader=FileSystemLoader("./templates"), diff --git a/src/backup.py b/src/backup.py index 9eb29a16..130abb1c 100644 --- a/src/backup.py +++ b/src/backup.py @@ -1,4 +1,4 @@ -# Copyright 2024 Canonical Ltd. +# Copyright 2025 Canonical Ltd. # See LICENSE file for licensing details. """Provides backup functionality for Synapse.""" diff --git a/src/backup_observer.py b/src/backup_observer.py index 24a9871b..1c2c3e7b 100644 --- a/src/backup_observer.py +++ b/src/backup_observer.py @@ -1,4 +1,4 @@ -# Copyright 2024 Canonical Ltd. +# Copyright 2025 Canonical Ltd. # See LICENSE file for licensing details. """S3 Backup relation observer for Synapse.""" diff --git a/src/charm.py b/src/charm.py index 8c172d3b..8565d259 100755 --- a/src/charm.py +++ b/src/charm.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 -# Copyright 2024 Canonical Ltd. +# Copyright 2025 Canonical Ltd. # See LICENSE file for licensing details. """Charm for Synapse on kubernetes.""" @@ -20,10 +20,10 @@ import pebble import synapse -from admin_access_token import AdminAccessTokenService from auth.mas import ( MASRegisterUserFailedError, MASVerifyUserEmailFailedError, + deactivate_user, generate_mas_config, generate_oauth_client_config, generate_synapse_msc3861_config, @@ -34,14 +34,12 @@ 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__) @@ -79,7 +77,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. @@ -96,7 +93,6 @@ def __init__(self, *args: typing.Any) -> None: ) self._oauth = OAuthRequirer(self) 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( @@ -109,10 +105,6 @@ 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.verify_user_email_action, self._on_verify_user_email_action) - - self.framework.observe( - self.on.promote_user_admin_action, self._on_promote_user_admin_action - ) self.framework.observe(self.on.anonymize_user_action, self._on_anonymize_user_action) self.framework.observe(self._oauth.on.oauth_info_changed, self._on_config_changed) self.framework.observe(self._oauth.on.oauth_info_removed, self._on_config_changed) @@ -214,6 +206,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) @@ -226,18 +223,16 @@ def reconcile(self, charm_state: CharmState, mas_configuration: MASConfiguration oauth_client_config = generate_oauth_client_config( mas_configuration, charm_state.synapse_config ) - logger.info('Generated oauth client config: %s', oauth_client_config) self._oauth.update_client_config(oauth_client_config) oauth_provider_info = None if self._oauth.is_client_created(): oauth_provider_info = self._oauth.get_provider_info() - logger.info('IS client created: %s', self._oauth.is_client_created()) - rendered_mas_configuration = generate_mas_config( mas_configuration, charm_state.synapse_config, oauth_provider_info, + charm_state.smtp_config, self.get_main_unit_address(), ) synapse_msc3861_configuration = generate_synapse_msc3861_config( @@ -331,11 +326,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() @@ -379,11 +369,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) @@ -569,73 +554,28 @@ def _on_verify_user_email_action(self, event: ActionEvent) -> None: results = {"verify-user-email": True} event.set_results(results) - @validate_charm_state - def _on_promote_user_admin_action(self, event: ActionEvent) -> None: - """Promote user admin and report action result. - - Args: - event: Event triggering the promote user admin 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 - ) - results["promote-user-admin"] = True - except synapse.APIError as exc: - event.fail(str(exc)) - return - event.set_results(results) - - @validate_charm_state def _on_anonymize_user_action(self, event: ActionEvent) -> None: """Anonymize user and report action result. 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/charm_types.py b/src/charm_types.py index d716c99b..1405c168 100644 --- a/src/charm_types.py +++ b/src/charm_types.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 -# Copyright 2024 Canonical Ltd. +# Copyright 2025 Canonical Ltd. # See LICENSE file for licensing details. """Type definitions for the Synapse charm.""" diff --git a/src/database_client.py b/src/database_client.py index 022a67e3..8f806cb7 100644 --- a/src/database_client.py +++ b/src/database_client.py @@ -1,4 +1,4 @@ -# Copyright 2024 Canonical Ltd. +# Copyright 2025 Canonical Ltd. # See LICENSE file for licensing details. """The DatabaseClient class.""" diff --git a/src/database_observer.py b/src/database_observer.py index 1c221a5e..463109b4 100644 --- a/src/database_observer.py +++ b/src/database_observer.py @@ -1,4 +1,4 @@ -# Copyright 2024 Canonical Ltd. +# Copyright 2025 Canonical Ltd. # See LICENSE file for licensing details. # Ignoring for the config change call diff --git a/src/exceptions.py b/src/exceptions.py index 41e88c5e..a33fe2bf 100644 --- a/src/exceptions.py +++ b/src/exceptions.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 -# Copyright 2024 Canonical Ltd. +# Copyright 2025 Canonical Ltd. # See LICENSE file for licensing details. """Exceptions used by the Synapse charm.""" diff --git a/src/matrix_auth_observer.py b/src/matrix_auth_observer.py index 313f86b5..8efc3985 100644 --- a/src/matrix_auth_observer.py +++ b/src/matrix_auth_observer.py @@ -1,4 +1,4 @@ -# Copyright 2024 Canonical Ltd. +# Copyright 2025 Canonical Ltd. # See LICENSE file for licensing details. """The Matrix Auth relation observer.""" @@ -9,7 +9,7 @@ from typing import List, NamedTuple, Optional import ops -from charms.synapse.v0.matrix_auth import ( +from charms.synapse.v1.matrix_auth import ( MatrixAuthProviderData, MatrixAuthProvides, MatrixAuthRequirerData, @@ -58,12 +58,13 @@ def update_matrix_auth_integration(self, charm_state: CharmState) -> None: Args: charm_state: The charm state. """ - for relation in list(self._charm.model.relations["matrix-auth"]): - if not relation.units: - return + matrix_auth_relations = list(self._charm.model.relations["matrix-auth"]) + logger.info("%d matrix-auth relations found", len(matrix_auth_relations)) + for relation in matrix_auth_relations: provider_data = self._get_matrix_auth_provider_data(charm_state) if self._matrix_auth_relation_updated(relation, provider_data): return + logger.info("updating matrix-auth relation %d", relation.id) self.matrix_auth.update_relation_data(relation, provider_data) def get_requirer_registration_secrets(self) -> Optional[List]: diff --git a/src/media_observer.py b/src/media_observer.py index ed5d90a7..e53cb9a0 100644 --- a/src/media_observer.py +++ b/src/media_observer.py @@ -1,4 +1,4 @@ -# Copyright 2024 Canonical Ltd. +# Copyright 2025 Canonical Ltd. # See LICENSE file for licensing details. """The media integrator relation observer.""" diff --git a/src/mjolnir.py b/src/mjolnir.py deleted file mode 100644 index bbee9fa3..00000000 --- a/src/mjolnir.py +++ /dev/null @@ -1,231 +0,0 @@ -# Copyright 2024 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/observability.py b/src/observability.py index 5bcbab22..fcc83599 100644 --- a/src/observability.py +++ b/src/observability.py @@ -1,4 +1,4 @@ -# Copyright 2024 Canonical Ltd. +# Copyright 2025 Canonical Ltd. # See LICENSE file for licensing details. """Provide the Observability class to represent the observability stack for Synapse.""" diff --git a/src/pebble.py b/src/pebble.py index 4e761516..6f77b1be 100644 --- a/src/pebble.py +++ b/src/pebble.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 -# Copyright 2024 Canonical Ltd. +# Copyright 2025 Canonical Ltd. # See LICENSE file for licensing details. # Ignoring for the config change call @@ -120,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. @@ -161,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. @@ -341,6 +315,8 @@ def reconcile( # noqa: C901 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 @@ -413,8 +389,6 @@ def reconcile( # noqa: C901 if charm_state.datasource and is_main: logger.info("Synapse Stats Exporter enabled.") replan_stats_exporter(container=container, charm_state=charm_state) - # Activate MAS on synapse - synapse.configure_mas(current_synapse_config, synapse_msc3861_configuration) config_has_changed = DeepDiff( existing_synapse_config, @@ -422,8 +396,9 @@ def reconcile( # noqa: C901 ignore_order=True, ignore_string_case=True, ) + # Activate msc3861 + synapse.configure_mas(current_synapse_config, synapse_msc3861_configuration) - restart_mas(container, rendered_mas_configuration) if config_has_changed: logging.info("Configuration has changed, Synapse will be restarted.") logging.debug("The change is: %s", config_has_changed) @@ -483,23 +458,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. @@ -524,31 +482,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. diff --git a/src/redis_observer.py b/src/redis_observer.py index b47f9c5d..5e801067 100644 --- a/src/redis_observer.py +++ b/src/redis_observer.py @@ -1,4 +1,4 @@ -# Copyright 2024 Canonical Ltd. +# Copyright 2025 Canonical Ltd. # See LICENSE file for licensing details. # Ignoring for the is_main call diff --git a/src/s3_parameters.py b/src/s3_parameters.py index b1f21d4a..c923c671 100644 --- a/src/s3_parameters.py +++ b/src/s3_parameters.py @@ -1,4 +1,4 @@ -# Copyright 2024 Canonical Ltd. +# Copyright 2025 Canonical Ltd. # See LICENSE file for licensing details. """Provides S3 Parameters configuration.""" diff --git a/src/smtp_observer.py b/src/smtp_observer.py index 6b872ee9..496fd969 100644 --- a/src/smtp_observer.py +++ b/src/smtp_observer.py @@ -1,4 +1,4 @@ -# Copyright 2024 Canonical Ltd. +# Copyright 2025 Canonical Ltd. # See LICENSE file for licensing details. """The SMTP integrator relation observer.""" diff --git a/src/state/__init__.py b/src/state/__init__.py index 90173b89..f9460355 100644 --- a/src/state/__init__.py +++ b/src/state/__init__.py @@ -1,4 +1,4 @@ -# Copyright 2024 Canonical Ltd. +# Copyright 2025 Canonical Ltd. # See LICENSE file for licensing details. """Charm state module.""" diff --git a/src/state/charm_state.py b/src/state/charm_state.py index 8a10da77..17d889dd 100644 --- a/src/state/charm_state.py +++ b/src/state/charm_state.py @@ -1,4 +1,4 @@ -# Copyright 2024 Canonical Ltd. +# Copyright 2025 Canonical Ltd. # See LICENSE file for licensing details. """State of the Charm.""" @@ -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 51c824fe..b2f8f1eb 100644 --- a/src/state/mas.py +++ b/src/state/mas.py @@ -1,4 +1,4 @@ -# Copyright 2024 Canonical Ltd. +# Copyright 2025 Canonical Ltd. # See LICENSE file for licensing details. """State of the Charm.""" @@ -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. @@ -141,6 +145,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) @@ -195,3 +202,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 e069bec8..3434d1e2 100644 --- a/src/state/validation.py +++ b/src/state/validation.py @@ -1,4 +1,4 @@ -# Copyright 2024 Canonical Ltd. +# Copyright 2025 Canonical Ltd. # See LICENSE file for licensing details. """State of the Charm.""" @@ -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 34762310..7ee7fc4f 100644 --- a/src/synapse/__init__.py +++ b/src/synapse/__init__.py @@ -1,10 +1,9 @@ -# Copyright 2024 Canonical Ltd. +# Copyright 2025 Canonical Ltd. # See LICENSE file for licensing details. """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, diff --git a/src/synapse/admin.py b/src/synapse/admin.py deleted file mode 100644 index e4d8784f..00000000 --- a/src/synapse/admin.py +++ /dev/null @@ -1,74 +0,0 @@ -#!/usr/bin/env python3 - -# Copyright 2024 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 dc170dc1..bdfd497d 100644 --- a/src/synapse/api.py +++ b/src/synapse/api.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 -# Copyright 2024 Canonical Ltd. +# Copyright 2025 Canonical Ltd. # See LICENSE file for licensing details. """Helper module used to manage interactions with Synapse API.""" @@ -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 4e6eb5ac..e03c094c 100644 --- a/src/synapse/workload.py +++ b/src/synapse/workload.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 -# Copyright 2024 Canonical Ltd. +# Copyright 2025 Canonical Ltd. # See LICENSE file for licensing details. """Helper module used to manage interactions with Synapse.""" @@ -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 8c7c1e7a..c8ca5b47 100644 --- a/src/synapse/workload_configuration.py +++ b/src/synapse/workload_configuration.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 -# Copyright 2024 Canonical Ltd. +# Copyright 2025 Canonical Ltd. # See LICENSE file for licensing details. """Helper module used to manage interactions with Synapse homeserver configuration.""" diff --git a/src/user.py b/src/user.py index 9b6d4710..bb678644 100644 --- a/src/user.py +++ b/src/user.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 -# Copyright 2024 Canonical Ltd. +# Copyright 2025 Canonical Ltd. # See LICENSE file for licensing details. """User class.""" diff --git a/synapse_rock/cron/cron.weekly/remote_content_empty_directory_cleanup.py b/synapse_rock/cron/cron.weekly/remote_content_empty_directory_cleanup.py index 46097115..a3780885 100644 --- a/synapse_rock/cron/cron.weekly/remote_content_empty_directory_cleanup.py +++ b/synapse_rock/cron/cron.weekly/remote_content_empty_directory_cleanup.py @@ -1,5 +1,5 @@ #!/usr/bin/python3 -# Copyright 2024 Canonical Ltd. +# Copyright 2025 Canonical Ltd. # See LICENSE file for licensing details. """ diff --git a/synapse_rock/rockcraft.yaml b/synapse_rock/rockcraft.yaml index 45713c9f..dbc6afed 100644 --- a/synapse_rock/rockcraft.yaml +++ b/synapse_rock/rockcraft.yaml @@ -1,4 +1,4 @@ -# Copyright 2024 Canonical Ltd. +# Copyright 2025 Canonical Ltd. # See LICENSE file for licensing details. name: synapse @@ -107,11 +107,10 @@ parts: - xmlsec1 stage-snaps: - aws-cli - - mjolnir/latest/edge plugin: nil source: https://github.com/element-hq/synapse/ source-type: git - source-tag: v1.120.2 + source-tag: v1.121.1 build-environment: - RUST_VERSION: "1.76.0" - POETRY_VERSION: "1.7.1" @@ -197,14 +196,13 @@ parts: <<: *mas-source source-subdir: frontend build-environment: - - NODE_URI: "https://nodejs.org/dist/v22.11.0/node-v22.11.0-linux-x64.tar.gz" - - NODE_ENV: dev + - NODE_URI: "https://nodejs.org/dist/v20.18.1/node-v20.18.1-linux-x64.tar.gz" override-build: | curl -Ls $NODE_URI | tar xzf - -C /usr/ --skip-old-files --no-same-owner --strip-components=1 (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: @@ -214,9 +212,13 @@ parts: - "--features dist" - "--no-default-features" <<: *mas-source + build-snaps: + - go/1.22/stable override-build: | - curl -L -o opa https://openpolicyagent.org/downloads/v0.70.0/opa_linux_amd64_static - chmod 755 ./opa; mv opa /usr/local/bin + # Build the open-policy-agent binary + # We build it here instead of in a separate part because opa is only needed during MAS build + git clone --depth 1 --branch v0.70.0 https://github.com/open-policy-agent/opa.git + (cd opa; make build; chmod +x opa_linux_amd64; mv opa_linux_amd64 /usr/local/bin/opa) (cd policies; make) mkdir -p $CRAFT_PART_INSTALL/mas/share cp policies/policy.wasm $CRAFT_PART_INSTALL/mas/share/policy.wasm diff --git a/synapse_rock/scripts/run_cron.py b/synapse_rock/scripts/run_cron.py index a52f9c20..d4751e17 100644 --- a/synapse_rock/scripts/run_cron.py +++ b/synapse_rock/scripts/run_cron.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# Copyright 2024 Canonical Ltd. +# Copyright 2025 Canonical Ltd. # See LICENSE file for licensing details. """ diff --git a/templates/mas_config.yaml.j2 b/templates/mas_config.yaml.j2 index a3c2c40a..16300d7a 100644 --- a/templates/mas_config.yaml.j2 +++ b/templates/mas_config.yaml.j2 @@ -70,4 +70,19 @@ upstream_oauth2: email: action: suggest template: {{ '"{{ user.email }}"' }} +{% endif %} +{% 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/__init__.py b/tests/__init__.py index 289a5245..073ccfce 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,4 +1,4 @@ -# Copyright 2024 Canonical Ltd. +# Copyright 2025 Canonical Ltd. # See LICENSE file for licensing details. """Tests module.""" diff --git a/tests/conftest.py b/tests/conftest.py index 4b4b9545..b9ce60be 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,4 +1,4 @@ -# Copyright 2024 Canonical Ltd. +# Copyright 2025 Canonical Ltd. # See LICENSE file for licensing details. """Fixtures for Synapse charm tests.""" diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py index e3979c0f..dddb292a 100644 --- a/tests/integration/__init__.py +++ b/tests/integration/__init__.py @@ -1,2 +1,2 @@ -# Copyright 2024 Canonical Ltd. +# Copyright 2025 Canonical Ltd. # See LICENSE file for licensing details. diff --git a/tests/integration/any_charm.py b/tests/integration/any_charm.py new file mode 100644 index 00000000..b36bc2ac --- /dev/null +++ b/tests/integration/any_charm.py @@ -0,0 +1,62 @@ +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +# pylint: disable=import-error,consider-using-with,no-member,too-few-public-methods + +"""This code should be loaded into any-charm which is used for integration tests.""" + +import logging +import typing + +from any_charm_base import AnyCharmBase +from matrix_auth import MatrixAuthRequirerData, MatrixAuthRequires +from ops.framework import Object +from pydantic import SecretStr + +logger = logging.getLogger(__name__) + + +class AnyCharm(AnyCharmBase): + """Execute a simple charm to test the relation.""" + + def __init__(self, *args, **kwargs): + """Initialize the charm and observe the relation events. + + Args: + args: Arguments to pass to the parent class. + kwargs: Keyword arguments to pass to the parent class + """ + super().__init__(*args, **kwargs) + + self.plugin_auth = MatrixAuthRequires(self, relation_name="require-matrix-auth") + self.framework.observe( + self.plugin_auth.on.matrix_auth_request_processed, + self._on_matrix_auth_request_processed, + ) + + def _on_matrix_auth_request_processed(self, _: Object) -> None: + """Handle the matrix auth request processed event.""" + logger.info("Matrix auth request processed") + content = """id: irc +hs_token: 82c7a893d020b5f28eaf7ba31e1d1091b12ebafc5ceb1b6beac2b93defc1b301 +as_token: a66ae41f82b05bebfc9c259135ce1ce35c856000d542ab5d1f01e0212439d534 +namespaces: + users: + - exclusive: true + regex: '@irc_.*:yourhomeserverdomain' + aliases: + - exclusive: true + regex: '#irc_.*:yourhomeserverdomain' +url: 'http://localhost:8090' +sender_localpart: appservice-irc +rate_limited: false +protocols: + - irc""" + registration = typing.cast(SecretStr, content) + any_charm_data = MatrixAuthRequirerData(registration=registration) + relation = self.model.get_relation(self.plugin_auth.relation_name) + if relation: + logger.info("Matrix auth request setting relation data") + self.plugin_auth.update_relation_data( + relation=relation, matrix_auth_requirer_data=any_charm_data + ) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index d743be20..314d92dd 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -1,10 +1,11 @@ -# Copyright 2024 Canonical Ltd. +# Copyright 2025 Canonical Ltd. # See LICENSE file for licensing details. """Fixtures for Synapse charm integration tests.""" 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 1ade47a6..e9d4969a 100644 --- a/tests/integration/helpers.py +++ b/tests/integration/helpers.py @@ -1,4 +1,4 @@ -# Copyright 2024 Canonical Ltd. +# Copyright 2025 Canonical Ltd. # See LICENSE file for licensing details. """Helper functions for integration tests.""" @@ -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 e2aeb5d8..c1661bea 100644 --- a/tests/integration/test_charm.py +++ b/tests/integration/test_charm.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# Copyright 2024 Canonical Ltd. +# Copyright 2025 Canonical Ltd. # See LICENSE file for licensing details. """Core integration tests for Synapse charm.""" @@ -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 @@ -184,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, ): """ @@ -218,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. @@ -315,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 @@ -336,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( @@ -373,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/integration/test_matrix_auth.py b/tests/integration/test_matrix_auth.py new file mode 100644 index 00000000..f2cf2968 --- /dev/null +++ b/tests/integration/test_matrix_auth.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Matrix-auth integration tests for Synapse charm.""" +import json +import logging +import pathlib +import typing + +from juju.application import Application +from juju.controller import Controller # type: ignore +from juju.model import Model +from juju.unit import Unit +from ops.model import ActiveStatus +from pytest_operator.plugin import OpsTest + +# caused by pytest fixtures, mark does not work in fixtures +# pylint: disable=too-many-arguments, unused-argument + +# mypy has trouble to inferred types for variables that are initialized in subclasses. +ACTIVE_STATUS_NAME = typing.cast(str, ActiveStatus.name) # type: ignore + +logger = logging.getLogger(__name__) + + +async def test_synapse_cmr_matrix_auth( + ops_test: OpsTest, + model: Model, + synapse_app: Application, +): + """ + arrange: deploy the Synapse charm, create offer, deploy any-charm as consumer + in a different model and consume offer. + act: integrate them via matrix-auth offer. + assert: Synapse set the registration file received via matrix-auth. + """ + await model.wait_for_idle(idle_period=10, status=ACTIVE_STATUS_NAME) + # This workaround was extracted from prometheus-k8s charm. + # Without it, the offer creation fails. + # https://github.com/canonical/prometheus-k8s-operator/blob/5779ecc749ee1582c6be20030a83472d024cd24f/tests/integration/test_remote_write_with_zinc.py#L103 + controller = Controller() + await controller.connect() + await controller.create_offer( + model.uuid, + f"{synapse_app.name}:matrix-auth", + ) + offers = await controller.list_offers(model.name) + await model.block_until( + lambda: all(offer.application_name == synapse_app.name for offer in offers.results) + ) + await model.wait_for_idle(idle_period=10, status=ACTIVE_STATUS_NAME) + await ops_test.track_model( + "consumer", + ) + with ops_test.model_context("consumer") as consumer_model: + any_charm_content = pathlib.Path("tests/integration/any_charm.py").read_text( + encoding="utf-8" + ) + matrix_auth_content = pathlib.Path("lib/charms/synapse/v1/matrix_auth.py").read_text( + encoding="utf-8" + ) + any_charm_src_overwrite = { + "any_charm.py": any_charm_content, + "matrix_auth.py": matrix_auth_content, + } + any_charm_app = await consumer_model.deploy( + "any-charm", + application_name="any-charm1", + channel="beta", + config={ + "python-packages": "pydantic\ncryptography", + "src-overwrite": json.dumps(any_charm_src_overwrite), + }, + ) + await consumer_model.wait_for_idle(apps=[any_charm_app.name]) + await consumer_model.consume(f"admin/{model.name}.{synapse_app.name}", "synapse") + + await consumer_model.relate(any_charm_app.name, "synapse") + await consumer_model.wait_for_idle(idle_period=30, status=ACTIVE_STATUS_NAME) + + unit: Unit = synapse_app.units[0] + ret_code, _, stderr = await ops_test.juju( + "exec", + "--unit", + unit.name, + "grep appservice-irc /data/appservice-registration-matrix-auth-*.yaml", + ) + assert not ret_code, f"Failed to check for application service file, {stderr}" diff --git a/tests/integration/test_s3.py b/tests/integration/test_s3.py index df9b7642..93d49428 100644 --- a/tests/integration/test_s3.py +++ b/tests/integration/test_s3.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# Copyright 2024 Canonical Ltd. +# Copyright 2025 Canonical Ltd. # See LICENSE file for licensing details. """Integration tests for Synapse charm needing the s3_backup_bucket fixture.""" diff --git a/tests/integration/test_scaling.py b/tests/integration/test_scaling.py index 96e60cee..2b082aa0 100644 --- a/tests/integration/test_scaling.py +++ b/tests/integration/test_scaling.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# Copyright 2024 Canonical Ltd. +# Copyright 2025 Canonical Ltd. # See LICENSE file for licensing details. """Integration tests for Synapse charm integrated with Redis.""" @@ -43,15 +43,13 @@ async def test_synapse_scaling_nginx_configured( ) assert ops_test.model status = await ops_test.model.get_status() - unit = list(status.applications[synapse_app.name].units)[1] + application = typing.cast(Application, status.applications[synapse_app.name]) + unit = list(application.units)[1] address = status["applications"][synapse_app.name]["units"][unit]["address"] - logger.info("Units: %s", list(status.applications[synapse_app.name].units)) - logger.info("Requesting %s", f"http://{address}:8008/") response_worker = requests.get( f"http://{address}:8008/", headers={"Host": synapse_app.name}, timeout=5 ) - logger.info("Requesting %s", f"http://{address}:8080/") response_nginx = requests.get( f"http://{address}:8080/", headers={"Host": synapse_app.name}, timeout=5 ) @@ -83,7 +81,8 @@ async def test_synapse_scaling_down( ) assert ops_test.model status = await ops_test.model.get_status() - for unit in list(status.applications[synapse_app.name].units): + application = typing.cast(Application, status.applications[synapse_app.name]) + for unit in list(application.units): address = status["applications"][synapse_app.name]["units"][unit]["address"] response_worker = requests.get( f"http://{address}:8080/", headers={"Host": synapse_app.name}, timeout=5 @@ -99,7 +98,8 @@ async def test_synapse_scaling_down( ) assert ops_test.model status = await ops_test.model.get_status() - for unit in list(status.applications[synapse_app.name].units): + application = typing.cast(Application, status.applications[synapse_app.name]) + for unit in list(application.units): address = status["applications"][synapse_app.name]["units"][unit]["address"] response_worker = requests.get( f"http://{address}:8080/", headers={"Host": synapse_app.name}, timeout=5 diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py index e3979c0f..dddb292a 100644 --- a/tests/unit/__init__.py +++ b/tests/unit/__init__.py @@ -1,2 +1,2 @@ -# Copyright 2024 Canonical Ltd. +# Copyright 2025 Canonical Ltd. # See LICENSE file for licensing details. diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index 3caae697..132bc316 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -1,4 +1,4 @@ -# Copyright 2024 Canonical Ltd. +# Copyright 2025 Canonical Ltd. # See LICENSE file for licensing details. """pytest fixtures for the unit test.""" @@ -118,15 +118,12 @@ 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( - "charm.generate_oauth_client_config", MagicMock(return_value=None) - ) + monkeypatch.setattr("charm.generate_oauth_client_config", MagicMock(return_value=None)) 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={})) diff --git a/tests/unit/test_action.py b/tests/unit/test_action.py index 0eaff8bd..1dd5d834 100644 --- a/tests/unit/test_action.py +++ b/tests/unit/test_action.py @@ -1,4 +1,4 @@ -# Copyright 2024 Canonical Ltd. +# Copyright 2025 Canonical Ltd. # See LICENSE file for licensing details. """Register user action unit tests.""" @@ -14,7 +14,7 @@ from ops.charm import ActionEvent from ops.testing import Harness -from auth.mas import MASVerifyUserEmailFailedError, verify_user_email +import synapse from user import User @@ -42,6 +42,42 @@ def test_register_user_action(harness: Harness) -> None: 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. @@ -59,14 +95,9 @@ def test_verify_user_email_action(harness: Harness) -> None: 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, - "email": "user@email.com", - } + event.params = {"username": "username", "email": "user@email.com"} - # Calling to test the action since is not possible calling via harness harness.charm._on_verify_user_email_action(event) assert event.set_results.call_count == 1 @@ -74,13 +105,88 @@ def test_verify_user_email_action(harness: Harness) -> None: assert isinstance(harness.model.unit.status, ops.ActiveStatus) -def test_verify_user_email_action_pebble_exec_error() -> None: +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. """ - container = MagicMock() - container.exec = MagicMock(side_effect=ops.pebble.ExecError([], 1, "", "")) - with pytest.raises(MASVerifyUserEmailFailedError): - verify_user_email(container, "mock_user", "mock_email") + 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 ba3a3206..00000000 --- a/tests/unit/test_admin_access_token.py +++ /dev/null @@ -1,134 +0,0 @@ -# Copyright 2024 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 a43f1310..00000000 --- a/tests/unit/test_admin_create_user.py +++ /dev/null @@ -1,75 +0,0 @@ -# Copyright 2024 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 7d55a1f3..00000000 --- a/tests/unit/test_anonymize_user_action.py +++ /dev/null @@ -1,133 +0,0 @@ -# Copyright 2024 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_backup.py b/tests/unit/test_backup.py index 95310e8a..8c0bc377 100644 --- a/tests/unit/test_backup.py +++ b/tests/unit/test_backup.py @@ -1,4 +1,4 @@ -# Copyright 2024 Canonical Ltd. +# Copyright 2025 Canonical Ltd. # See LICENSE file for licensing details. """Synapse backup unit tests.""" diff --git a/tests/unit/test_backup_observer.py b/tests/unit/test_backup_observer.py index 86778af6..304bf05d 100644 --- a/tests/unit/test_backup_observer.py +++ b/tests/unit/test_backup_observer.py @@ -1,4 +1,4 @@ -# Copyright 2024 Canonical Ltd. +# Copyright 2025 Canonical Ltd. # See LICENSE file for licensing details. """Synapse backup observer unit tests.""" diff --git a/tests/unit/test_charm.py b/tests/unit/test_charm.py index cfc399d1..7b63ad90 100644 --- a/tests/unit/test_charm.py +++ b/tests/unit/test_charm.py @@ -1,4 +1,4 @@ -# Copyright 2024 Canonical Ltd. +# Copyright 2025 Canonical Ltd. # See LICENSE file for licensing details. """Synapse charm unit tests.""" diff --git a/tests/unit/test_charm_scaling.py b/tests/unit/test_charm_scaling.py index 452655b5..2ffbff54 100644 --- a/tests/unit/test_charm_scaling.py +++ b/tests/unit/test_charm_scaling.py @@ -1,4 +1,4 @@ -# Copyright 2024 Canonical Ltd. +# Copyright 2025 Canonical Ltd. # See LICENSE file for licensing details. """Synapse charm scaling unit tests.""" diff --git a/tests/unit/test_charm_state.py b/tests/unit/test_charm_state.py index db38ea81..49082c65 100644 --- a/tests/unit/test_charm_state.py +++ b/tests/unit/test_charm_state.py @@ -1,4 +1,4 @@ -# Copyright 2024 Canonical Ltd. +# Copyright 2025 Canonical Ltd. # See LICENSE file for licensing details. """Synapse charm state unit tests.""" @@ -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_database.py b/tests/unit/test_database.py index 8b3141a7..2dbea8bf 100644 --- a/tests/unit/test_database.py +++ b/tests/unit/test_database.py @@ -1,4 +1,4 @@ -# Copyright 2024 Canonical Ltd. +# Copyright 2025 Canonical Ltd. # See LICENSE file for licensing details. """Database unit tests.""" diff --git a/tests/unit/test_exceptions.py b/tests/unit/test_exceptions.py index 3807cc97..d4a57ba4 100644 --- a/tests/unit/test_exceptions.py +++ b/tests/unit/test_exceptions.py @@ -1,4 +1,4 @@ -# Copyright 2024 Canonical Ltd. +# Copyright 2025 Canonical Ltd. # See LICENSE file for licensing details. """Exceptions unit tests.""" diff --git a/tests/unit/test_mas.py b/tests/unit/test_mas.py index 1c3f18db..f5840010 100644 --- a/tests/unit/test_mas.py +++ b/tests/unit/test_mas.py @@ -1,4 +1,4 @@ -# Copyright 2024 Canonical Ltd. +# Copyright 2025 Canonical Ltd. # See LICENSE file for licensing details. """Synapse charm unit tests.""" @@ -42,7 +42,7 @@ def test_mas_generate_config(monkeypatch: pytest.MonkeyPatch) -> None: } synapse_configuration = SynapseConfig(**config) # type: ignore[arg-type] rendered_mas_config = generate_mas_config( - mas_configuration, synapse_configuration, None, "10.1.1.0" + mas_configuration, synapse_configuration, None, None, "10.1.1.0" ) rendered_msc3861_config = generate_synapse_msc3861_config( mas_configuration, synapse_configuration diff --git a/tests/unit/test_matrix_auth_integration.py b/tests/unit/test_matrix_auth_integration.py index f103c2a5..e8d13daa 100644 --- a/tests/unit/test_matrix_auth_integration.py +++ b/tests/unit/test_matrix_auth_integration.py @@ -1,4 +1,4 @@ -# Copyright 2024 Canonical Ltd. +# Copyright 2025 Canonical Ltd. # See LICENSE file for licensing details. """Synapse charm matrix-auth integration unit tests.""" @@ -9,8 +9,9 @@ import pytest import yaml -from charms.synapse.v0.matrix_auth import MatrixAuthRequirerData +from charms.synapse.v1.matrix_auth import MatrixAuthRequirerData, encrypt_string from ops.testing import Harness +from pydantic import SecretStr import synapse @@ -83,7 +84,8 @@ def test_matrix_auth_registration_secret_success( ): """ arrange: start the Synapse charm with public_base url set. - act: integrate via matrix-auth and add relation data. + act: integrate via matrix-auth with maubot and add registration as relation + data. assert: update_relation_data is called, homeserver has same value as public_baseurl and app_service_config_files is set. """ @@ -96,11 +98,12 @@ def test_matrix_auth_registration_secret_success( monkeypatch.setattr( harness.charm._matrix_auth.matrix_auth, "update_relation_data", update_relation_data ) + encryption_key = b"DXnflqjmmM8-UASxTl9oWeM7PWKQoclMFVb_bp9zLGY=" monkeypatch.setattr( - synapse, "get_registration_shared_secret", MagicMock(return_value="shared_secret") + MatrixAuthRequirerData, "get_encryption_key_secret", MagicMock(return_value=encryption_key) ) monkeypatch.setattr( - MatrixAuthRequirerData, "get_registration", lambda *args: "test-registration" + synapse, "get_registration_shared_secret", MagicMock(return_value="shared_secret") ) create_registration_secrets_files_mock = MagicMock() monkeypatch.setattr( @@ -109,7 +112,8 @@ def test_matrix_auth_registration_secret_success( rel_id = harness.add_relation("matrix-auth", "maubot") harness.add_relation_unit(rel_id, "maubot/0") - harness.update_relation_data(rel_id, "maubot/0", {"registration_secret_id": "foo"}) + encrypted_text = encrypt_string(key=encryption_key, plaintext=SecretStr("foo")) + harness.update_relation_data(rel_id, "maubot", {"registration_secret": encrypted_text}) relation = harness.charm.framework.model.get_relation("matrix-auth", rel_id) update_relation_data.assert_called_with(relation, ANY) @@ -123,3 +127,45 @@ def test_matrix_auth_registration_secret_success( assert content["app_service_config_files"] == [ f"/data/appservice-registration-matrix-auth-{rel_id}.yaml" ] + + +def test_matrix_auth_registration_secret_empty(harness: Harness, monkeypatch: pytest.MonkeyPatch): + """ + arrange: start the Synapse charm with public_base url set. + act: integrate via matrix-auth with maubot and add registration as relation + data. + assert: update_relation_data is called, homeserver has same value as + public_baseurl and since registration is empty there are no registration + files. + """ + base_url_value = "https://new-server" + harness.update_config({"server_name": TEST_SERVER_NAME, "public_baseurl": base_url_value}) + harness.set_can_connect(synapse.SYNAPSE_CONTAINER_NAME, True) + harness.set_leader(True) + harness.begin_with_initial_hooks() + update_relation_data = MagicMock() + monkeypatch.setattr( + harness.charm._matrix_auth.matrix_auth, "update_relation_data", update_relation_data + ) + encryption_key = b"DXnflqjmmM8-UASxTl9oWeM7PWKQoclMFVb_bp9zLGY=" + monkeypatch.setattr( + MatrixAuthRequirerData, "get_encryption_key_secret", MagicMock(return_value=encryption_key) + ) + monkeypatch.setattr( + synapse, "get_registration_shared_secret", MagicMock(return_value="shared_secret") + ) + create_registration_secrets_files_mock = MagicMock() + monkeypatch.setattr( + synapse, "create_registration_secrets_files", create_registration_secrets_files_mock + ) + + rel_id = harness.add_relation("matrix-auth", "maubot") + harness.add_relation_unit(rel_id, "maubot/0") + relation = harness.charm.framework.model.get_relation("matrix-auth", rel_id) + harness.charm.on["matrix-auth"].relation_changed.emit( + relation, harness.charm.app, harness.charm.unit + ) + + update_relation_data.assert_called_with(relation, ANY) + assert update_relation_data.call_args[0][1].homeserver == base_url_value + create_registration_secrets_files_mock.assert_not_called() diff --git a/tests/unit/test_matrix_plugins_lib.py b/tests/unit/test_matrix_plugins_lib.py index fef07d2c..77ebb552 100644 --- a/tests/unit/test_matrix_plugins_lib.py +++ b/tests/unit/test_matrix_plugins_lib.py @@ -1,13 +1,14 @@ -# Copyright 2024 Canonical Ltd. +# Copyright 2025 Canonical Ltd. # See LICENSE file for licensing details. """MatrixAuth library unit tests""" from secrets import token_hex +from typing import cast import ops import pytest -from charms.synapse.v0.matrix_auth import ( +from charms.synapse.v1.matrix_auth import ( MatrixAuthProviderData, MatrixAuthProvides, MatrixAuthRequestProcessed, @@ -16,6 +17,7 @@ MatrixAuthRequires, ) from ops.testing import Harness +from pydantic import SecretStr REQUIRER_METADATA = """ name: matrix-auth-consumer @@ -36,8 +38,10 @@ "shared_secret_id": "test-secret-id", } +# pylint: disable=line-too-long SAMPLE_REQUIRER_DATA = { "registration_secret_id": "test-registration-id", + "registration_secret": "gAAAAABngB_H9JrNUwPDr-4E-ouqyG0V_O_1l4X-7f3wZ2A7dsZAUwUoz1lL5pmrrLdIZa5aBw_-P4iEs1le_u30RMWdtLIwAg==", # noqa: E501 } @@ -228,7 +232,7 @@ def test_matrix_auth_provider_does_not_emit_event_when_no_data(): @pytest.mark.parametrize("is_leader", [True, False]) -def test_matrix_auth_provider_with_valid_relation_data_emits_event(is_leader, monkeypatch): +def test_matrix_auth_provider_with_valid_relation_data_emits_event(is_leader): """ arrange: set up a charm. act: add a matrix-auth relation with valid data. @@ -238,20 +242,6 @@ def test_matrix_auth_provider_with_valid_relation_data_emits_event(is_leader, mo harness.begin() harness.set_leader(is_leader) - # Mock the get_registration method to return a test registration - def mock_get_registration(*args): # pylint: disable=unused-argument - """Mock get_registration method. - - Args: - args: Arguments passed to the method. - - Returns: - str: The registration. - """ - return "test-registration" - - monkeypatch.setattr(MatrixAuthRequirerData, "get_registration", mock_get_registration) - harness.add_relation("matrix-auth", "matrix-auth-consumer", app_data=SAMPLE_REQUIRER_DATA) assert len(harness.charm.events) == 1 @@ -323,12 +313,13 @@ def mock_get_registration(*args): # pylint: disable=unused-argument Returns: str: The registration. """ - return "test-registration" + return b"9u7b67PYr7Jqx4Ot15Fg8f9PAbm8XmLsHecPIqD7VLM=" - monkeypatch.setattr(MatrixAuthRequirerData, "get_registration", mock_get_registration) + monkeypatch.setattr(MatrixAuthRequirerData, "get_encryption_key_secret", mock_get_registration) harness.add_relation("matrix-auth", "matrix-auth-consumer", app_data=SAMPLE_REQUIRER_DATA) relation_data = harness.charm.matrix_auth.get_remote_relation_data() assert relation_data is not None - assert relation_data.registration.get_secret_value() == "test-registration" + real_value = cast(SecretStr, "registration") + assert relation_data == MatrixAuthRequirerData(registration=real_value) diff --git a/tests/unit/test_media_observer.py b/tests/unit/test_media_observer.py index d8bbb161..c969c66b 100644 --- a/tests/unit/test_media_observer.py +++ b/tests/unit/test_media_observer.py @@ -1,4 +1,4 @@ -# Copyright 2024 Canonical Ltd. +# Copyright 2025 Canonical Ltd. # See LICENSE file for licensing details. """Synapse media unit tests.""" diff --git a/tests/unit/test_mjolnir.py b/tests/unit/test_mjolnir.py deleted file mode 100644 index 54010483..00000000 --- a/tests/unit/test_mjolnir.py +++ /dev/null @@ -1,496 +0,0 @@ -# Copyright 2024 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 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("auth.mas.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_observability.py b/tests/unit/test_observability.py index 4e9b8a54..cc292c4a 100644 --- a/tests/unit/test_observability.py +++ b/tests/unit/test_observability.py @@ -1,4 +1,4 @@ -# Copyright 2024 Canonical Ltd. +# Copyright 2025 Canonical Ltd. # See LICENSE file for licensing details. """Synapse observability unit tests.""" 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 45c63dc1..00000000 --- a/tests/unit/test_promote_user_admin_action.py +++ /dev/null @@ -1,129 +0,0 @@ -# Copyright 2024 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_smtp_observer.py b/tests/unit/test_smtp_observer.py index 908e147c..3590acea 100644 --- a/tests/unit/test_smtp_observer.py +++ b/tests/unit/test_smtp_observer.py @@ -1,4 +1,4 @@ -# Copyright 2024 Canonical Ltd. +# Copyright 2025 Canonical Ltd. # See LICENSE file for licensing details. """SMTPObserver unit tests.""" @@ -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 c1c3b612..9a853717 100644 --- a/tests/unit/test_synapse_api.py +++ b/tests/unit/test_synapse_api.py @@ -1,4 +1,4 @@ -# Copyright 2024 Canonical Ltd. +# Copyright 2025 Canonical Ltd. # See LICENSE file for licensing details. """Synapse API unit tests.""" @@ -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 4a94f547..b4e5b98b 100644 --- a/tests/unit/test_synapse_workload.py +++ b/tests/unit/test_synapse_workload.py @@ -1,4 +1,4 @@ -# Copyright 2024 Canonical Ltd. +# Copyright 2025 Canonical Ltd. # See LICENSE file for licensing details. """Synapse workload unit tests.""" @@ -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, diff --git a/tox.ini b/tox.ini index 2e93538f..e96a983e 100644 --- a/tox.ini +++ b/tox.ini @@ -1,4 +1,4 @@ -# Copyright 2024 Canonical Ltd. +# Copyright 2025 Canonical Ltd. # See LICENSE file for licensing details. [tox] @@ -116,6 +116,7 @@ commands = [testenv:integration] description = Run integration tests deps = + allure-pytest>=2.8.18 macaroonbakery==1.3.2 # fixes "TypeError: Descriptors cannot be created directly." protobuf error juju >=3.0 pytest @@ -125,6 +126,7 @@ deps = boto3 # Type error problem with newer version of macaroonbakery macaroonbakery==1.3.2 + git+https://github.com/canonical/data-platform-workflows@v24.0.0\#subdirectory=python/pytest_plugins/allure_pytest_collection_report -r{toxinidir}/requirements.txt commands = pytest -v -x --tb native --ignore={[vars]tst_path}unit --log-cli-level=INFO -s {posargs}