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