diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
index 97391fc3..418edfb5 100644
--- a/.github/pull_request_template.md
+++ b/.github/pull_request_template.md
@@ -33,5 +33,6 @@ Please, provide some information about your PR before proceeding.
- [ ] The documentation is generated using `src-docs`
- [ ] The documentation for charmhub is updated.
- [ ] The PR is tagged with appropriate label (`urgent`, `trivial`, `complex`)
+- [ ] The changelog is updated with changes that affect the users of the charm.
diff --git a/.github/workflows/integration_test.yaml b/.github/workflows/integration_test.yaml
index 058474bf..630e0bcd 100644
--- a/.github/workflows/integration_test.yaml
+++ b/.github/workflows/integration_test.yaml
@@ -2,6 +2,9 @@ name: Integration tests
on:
pull_request:
+ schedule:
+ # Trigger at 6:00 AM and 6:00 PM UTC
+ - cron: "0 6,18 * * *"
jobs:
integration-tests:
@@ -13,4 +16,9 @@ jobs:
trivy-image-config: "trivy.yaml"
juju-channel: 3.4/stable
channel: 1.28-strict/stable
- modules: '["test_charm", "test_nginx", "test_s3", "test_scaling"]'
+ modules: '["test_charm", "test_nginx", "test_s3", "test_scaling", "test_matrix_auth"]'
+ allure-report:
+ if: ${{ (success() || failure()) && github.event_name == 'schedule' }}
+ needs:
+ - integration-tests
+ uses: canonical/operator-workflows/.github/workflows/allure_report.yaml@main
diff --git a/.trivyignore b/.trivyignore
index 661c9fb2..510da387 100644
--- a/.trivyignore
+++ b/.trivyignore
@@ -1,14 +1,4 @@
# Vulnerabilites related to: Pebble, Node.JS and gosu
-CVE-2021-39293
-CVE-2021-41771
-CVE-2021-41772
-CVE-2021-44716
-CVE-2022-23772
-CVE-2022-23806
-CVE-2022-24675
-CVE-2022-24921
-CVE-2022-25883
-CVE-2022-27664
CVE-2022-28131
CVE-2022-28327
CVE-2022-2879
@@ -47,9 +37,9 @@ CVE-2024-29415
CVE-2024-34156
CVE-2024-21538
CVE-2024-24788
-# This should be removed once the following PR is merged.
-# https://github.com/element-hq/synapse/pull/17955
-CVE-2024-52804
# Fix ongoing:
# https://github.com/element-hq/synapse/pull/17985
CVE-2024-53981
+# This should be removed once pebble releases a new version.
+# https://github.com/canonical/pebble/commit/0c134f8e0d80f4bd8f42011279c8f0737b59a673
+CVE-2024-45338
diff --git a/LICENSE b/LICENSE
index c4a371b8..4b8b005b 100644
--- a/LICENSE
+++ b/LICENSE
@@ -187,7 +187,7 @@
same "printed page" as the copyright notice for easier
identification within third-party archives.
- Copyright 2024 Canonical Ltd.
+ Copyright 2025 Canonical Ltd.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
diff --git a/actions.yaml b/actions.yaml
index 950de423..c236215c 100644
--- a/actions.yaml
+++ b/actions.yaml
@@ -1,4 +1,4 @@
-# Copyright 2024 Canonical Ltd.
+# Copyright 2025 Canonical Ltd.
# See LICENSE file for licensing details.
anonymize-user:
@@ -41,15 +41,6 @@ verify-user-email:
required:
- username
- email
-promote-user-admin:
- description: |
- Promote a user as a server administrator.
- You need to supply a user name.
- properties:
- username:
- description: |
- User name to be promoted to admin.
- type: string
create-backup:
description: |
Creates a backup to s3 storage.
diff --git a/charmcraft.yaml b/charmcraft.yaml
index 5b9e8f57..10dfc919 100644
--- a/charmcraft.yaml
+++ b/charmcraft.yaml
@@ -1,4 +1,4 @@
-# Copyright 2024 Canonical Ltd.
+# Copyright 2025 Canonical Ltd.
# See LICENSE file for licensing details.
# This file configures Charmcraft.
diff --git a/config.yaml b/config.yaml
index 9e1f0879..77e532be 100644
--- a/config.yaml
+++ b/config.yaml
@@ -1,4 +1,4 @@
-# Copyright 2024 Canonical Ltd.
+# Copyright 2025 Canonical Ltd.
# See LICENSE file for licensing details.
options:
@@ -22,12 +22,6 @@ options:
default: false
description: |
Configures whether to enable e-mail notifications. Requires SMTP integration.
- enable_mjolnir:
- type: boolean
- default: false
- description: |
- Configures whether to enable Mjolnir - moderation tool for Matrix.
- Reference: https://github.com/matrix-org/mjolnir
enable_password_config:
type: boolean
default: true
diff --git a/docs/changelog.md b/docs/changelog.md
new file mode 100644
index 00000000..45dafb75
--- /dev/null
+++ b/docs/changelog.md
@@ -0,0 +1,5 @@
+# Changelog
+
+### 2025-01-09
+
+- Add changelog for tracking user-relevant changes.
diff --git a/docs/explanation/moderation-and-spam-control.md b/docs/explanation/moderation-and-spam-control.md
index 094b8663..a21f47a8 100644
--- a/docs/explanation/moderation-and-spam-control.md
+++ b/docs/explanation/moderation-and-spam-control.md
@@ -60,22 +60,4 @@ For details and implementation, visit the module’s repository: [Synapse Invite
## Mjolnir
-Synapse charm also has Mjolnir in place. Mjolnir is an all-in-one moderation
-tool designed to protect Synapse server from malicious invites, spam messages,
-and other unwanted activities.
-
-### Key features
-
-- Bans and redactions: Quickly remove malicious users and their messages from
-rooms.
-- Anti-spam: Automatically detect and mitigate spam activity.
-- Server ACLs: Manage and enforce access control lists at the server level.
-- Room directory changes and alias transfers: Adjust room visibility and manage
-aliases efficiently.
-- Account deactivation: Disable abusive or compromised accounts.
-- Room shutdown: Close problematic rooms completely.
-
-### More information
-
-For more details and implementation guidance, refer to the [Mjolnir GitHub repository](https://github.com/matrix-org/mjolnir).
-
+With the arrival of MAS mjolnir has been temporary disabled.
\ No newline at end of file
diff --git a/docs/how-to/contribute.md b/docs/how-to/contribute.md
index f9cf8839..0f64226a 100644
--- a/docs/how-to/contribute.md
+++ b/docs/how-to/contribute.md
@@ -63,6 +63,12 @@ Run the following command:
echo -e "tox -e src-docs\ngit add src-docs\n" >> .git/hooks/pre-commit
chmod +x .git/hooks/pre-commit
```
+### Changelog
+
+Please ensure that any new feature, fix, or significant change is documented by
+adding an entry to the `docs/changelog.md` file.
+
+To learn more about changelog best practices, visit [Keep a Changelog](https://keepachangelog.com/).
## Build charm
diff --git a/generate-src-docs.sh b/generate-src-docs.sh
index 23cb0070..326ae2fd 100755
--- a/generate-src-docs.sh
+++ b/generate-src-docs.sh
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
-# Copyright 2024 Canonical Ltd.
+# Copyright 2025 Canonical Ltd.
# See LICENSE file for licensing details.
rm -rf src-docs
diff --git a/lib/charms/nginx_ingress_integrator/v0/nginx_route.py b/lib/charms/nginx_ingress_integrator/v0/nginx_route.py
index a2ec38ec..0d73c75d 100644
--- a/lib/charms/nginx_ingress_integrator/v0/nginx_route.py
+++ b/lib/charms/nginx_ingress_integrator/v0/nginx_route.py
@@ -1,4 +1,4 @@
-# Copyright 2024 Canonical Ltd.
+# Copyright 2025 Canonical Ltd.
# Licensed under the Apache2.0. See LICENSE file in charm source for details.
"""Library for the nginx-route relation.
diff --git a/lib/charms/smtp_integrator/v0/smtp.py b/lib/charms/smtp_integrator/v0/smtp.py
index 2816965e..b5a7a263 100644
--- a/lib/charms/smtp_integrator/v0/smtp.py
+++ b/lib/charms/smtp_integrator/v0/smtp.py
@@ -1,4 +1,4 @@
-# Copyright 2024 Canonical Ltd.
+# Copyright 2025 Canonical Ltd.
# Licensed under the Apache2.0. See LICENSE file in charm source for details.
"""Library to manage the integration with the SMTP Integrator charm.
diff --git a/lib/charms/synapse/v0/matrix_auth.py b/lib/charms/synapse/v1/matrix_auth.py
similarity index 79%
rename from lib/charms/synapse/v0/matrix_auth.py
rename to lib/charms/synapse/v1/matrix_auth.py
index 2342b99e..e9488690 100644
--- a/lib/charms/synapse/v0/matrix_auth.py
+++ b/lib/charms/synapse/v1/matrix_auth.py
@@ -1,4 +1,4 @@
-# Copyright 2024 Canonical Ltd.
+# Copyright 2025 Canonical Ltd.
# Licensed under the Apache2.0. See LICENSE file in charm source for details.
"""Library to manage the plugin integrations with the Synapse charm.
@@ -63,29 +63,61 @@ def _on_config_changed(self, _) -> None:
LIBID = "ff6788c89b204448b3b62ba6f93e2768"
# Increment this major API version when introducing breaking changes
-LIBAPI = 0
+LIBAPI = 1
# Increment this PATCH version before using `charmcraft publish-lib` or reset
# to 0 if you are raising the major API version
-LIBPATCH = 3
+LIBPATCH = 0
# pylint: disable=wrong-import-position
import json
import logging
from typing import Dict, List, Optional, Tuple, cast
+import secrets
+import base64
+from cryptography.fernet import Fernet
import ops
from pydantic import BaseModel, Field, SecretStr
logger = logging.getLogger(__name__)
-#### Constants ####
APP_REGISTRATION_LABEL = "app-registration"
APP_REGISTRATION_CONTENT_LABEL = "app-registration-content"
DEFAULT_RELATION_NAME = "matrix-auth"
SHARED_SECRET_LABEL = "shared-secret"
SHARED_SECRET_CONTENT_LABEL = "shared-secret-content"
+ENCRYPTION_KEY_SECRET_LABEL = "encryption-key-secret"
+ENCRYPTION_KEY_SECRET_CONTENT_LABEL = "encryption-key-content"
+def encrypt_string(key: bytes, plaintext: SecretStr) -> str:
+ """Encrypt a string using Fernet.
+
+ Args:
+ key: encryption key in bytes.
+ plaintext: text to encrypt.
+
+ Returns:
+ encrypted text.
+ """
+ plaintext = cast(SecretStr, plaintext)
+ encryptor = Fernet(key)
+ ciphertext = encryptor.encrypt(plaintext.get_secret_value().encode('utf-8'))
+ return ciphertext.decode()
+
+def decrypt_string(key: bytes, ciphertext: str) -> str:
+ """Decrypt a string using Fernet.
+
+ Args:
+ key: encryption key in bytes.
+ ciphertext: encrypted text.
+
+ Returns:
+ decrypted text.
+ """
+ decryptor = Fernet(key)
+ plaintext = decryptor.decrypt(ciphertext.encode('utf-8'))
+ return plaintext.decode()
#### Data models for Provider and Requirer ####
class MatrixAuthProviderData(BaseModel):
@@ -100,6 +132,7 @@ class MatrixAuthProviderData(BaseModel):
homeserver: str
shared_secret: Optional[SecretStr] = Field(default=None, exclude=True)
shared_secret_id: Optional[SecretStr] = Field(default=None)
+ encryption_key_secret_id: Optional[SecretStr] = Field(default=None)
def set_shared_secret_id(self, model: ops.Model, relation: ops.Relation) -> None:
"""Store the Matrix shared secret as a Juju secret.
@@ -124,6 +157,27 @@ def set_shared_secret_id(self, model: ops.Model, relation: ops.Relation) -> None
secret.grant(relation)
self.shared_secret_id = cast(str, secret.id)
+ def set_encryption_key_secret_id(self, model: ops.Model, relation: ops.Relation) -> None:
+ """Store the encryption key to encrypt/decrypt appservice registrations.
+
+ Args:
+ model: the Juju model
+ relation: relation to grant access to the secrets to.
+ """
+ key = Fernet.generate_key()
+ encryption_key = key.decode('utf-8')
+ try:
+ secret = model.get_secret(label=ENCRYPTION_KEY_SECRET_LABEL)
+ secret.set_content({ENCRYPTION_KEY_SECRET_CONTENT_LABEL: encryption_key})
+ # secret.id is not None at this point
+ self.encryption_key_secret_id = cast(str, secret.id)
+ except ops.SecretNotFoundError:
+ secret = relation.app.add_secret(
+ {ENCRYPTION_KEY_SECRET_CONTENT_LABEL: encryption_key}, label=ENCRYPTION_KEY_SECRET_LABEL
+ )
+ secret.grant(relation)
+ self.encryption_key_secret_id = cast(str, secret.id)
+
@classmethod
def get_shared_secret(
cls, model: ops.Model, shared_secret_id: Optional[str]
@@ -159,6 +213,7 @@ def to_relation_data(self, model: ops.Model, relation: ops.Relation) -> Dict[str
Dict containing the representation.
"""
self.set_shared_secret_id(model, relation)
+ self.set_encryption_key_secret_id(model, relation)
return self.model_dump(exclude_unset=True)
@classmethod
@@ -196,56 +251,33 @@ class MatrixAuthRequirerData(BaseModel):
Attributes:
registration: a generated app registration file.
- registration_id: the registration Juju secret ID.
"""
registration: Optional[SecretStr] = Field(default=None, exclude=True)
- registration_secret_id: Optional[SecretStr] = Field(default=None)
-
- def set_registration_id(self, model: ops.Model, relation: ops.Relation) -> None:
- """Store the app registration as a Juju secret.
-
- Args:
- model: the Juju model
- relation: relation to grant access to the secrets to.
- """
- # password is always defined since pydantic guarantees it
- password = cast(SecretStr, self.registration)
- # pylint doesn't like get_secret_value
- secret_value = password.get_secret_value() # pylint: disable=no-member
- try:
- secret = model.get_secret(label=APP_REGISTRATION_LABEL)
- secret.set_content({APP_REGISTRATION_CONTENT_LABEL: secret_value})
- # secret.id is not None at this point
- self.registration_secret_id = cast(str, secret.id)
- except ops.SecretNotFoundError:
- secret = relation.app.add_secret(
- {APP_REGISTRATION_CONTENT_LABEL: secret_value}, label=APP_REGISTRATION_LABEL
- )
- secret.grant(relation)
- self.registration_secret_id = cast(str, secret.id)
@classmethod
- def get_registration(
- cls, model: ops.Model, registration_secret_id: Optional[str]
- ) -> Optional[SecretStr]:
- """Retrieve the registration corresponding to the registration_secret_id.
+ def get_encryption_key_secret(
+ cls, model: ops.Model, encryption_key_secret_id: Optional[str]
+ ) -> Optional[bytes]:
+ """Retrieve the encryption key secret corresponding to the encryption_key_secret_id.
Args:
model: the Juju model.
- registration_secret_id: the secret ID for the registration.
+ encryption_key_secret_id: the secret ID for the encryption key secret.
Returns:
- the registration or None if not found.
+ the encryption key secret as bytes or None if not found.
"""
- if not registration_secret_id:
- return None
try:
- secret = model.get_secret(id=registration_secret_id)
- password = secret.get_content().get(APP_REGISTRATION_CONTENT_LABEL)
- if not password:
+ if not encryption_key_secret_id:
+ # then its the provider and we can get using label
+ secret = model.get_secret(label=ENCRYPTION_KEY_SECRET_LABEL)
+ else:
+ secret = model.get_secret(id=encryption_key_secret_id)
+ encryption_key = secret.get_content().get(ENCRYPTION_KEY_SECRET_CONTENT_LABEL)
+ if not encryption_key:
return None
- return SecretStr(password)
+ return encryption_key.encode('utf-8')
except ops.SecretNotFoundError:
return None
@@ -258,11 +290,21 @@ def to_relation_data(self, model: ops.Model, relation: ops.Relation) -> Dict[str
Returns:
Dict containing the representation.
+
+ Raises:
+ ValueError if encryption key not found.
"""
- self.set_registration_id(model, relation)
- dumped_model = self.model_dump(exclude_unset=True)
+ # get encryption key
+ app = cast(ops.Application, relation.app)
+ relation_data = relation.data[app]
+ encryption_key_secret_id = relation_data.get("encryption_key_secret_id")
+ encryption_key = MatrixAuthRequirerData.get_encryption_key_secret(model, encryption_key_secret_id)
+ if not encryption_key:
+ raise ValueError("Invalid relation data: encryption_key_secret_id not found")
+ # encrypt content
+ content = encrypt_string(key=encryption_key, plaintext=self.registration)
dumped_data = {
- "registration_secret_id": dumped_model["registration_secret_id"],
+ "registration_secret": content,
}
return dumped_data
@@ -280,12 +322,20 @@ def from_relation(cls, model: ops.Model, relation: ops.Relation) -> "MatrixAuthR
Raises:
ValueError: if the value is not parseable.
"""
+ # get encryption key
app = cast(ops.Application, relation.app)
relation_data = relation.data[app]
- registration_secret_id = relation_data.get("registration_secret_id")
- registration = MatrixAuthRequirerData.get_registration(model, registration_secret_id)
+ encryption_key_secret_id = relation_data.get("encryption_key_secret_id")
+ encryption_key = MatrixAuthRequirerData.get_encryption_key_secret(model, encryption_key_secret_id)
+ if not encryption_key:
+ logger.warning("Invalid relation data: encryption_key_secret_id not found")
+ return None
+ # decrypt content
+ registration_secret = relation_data.get("registration_secret")
+ if not registration_secret:
+ return MatrixAuthRequirerData()
return MatrixAuthRequirerData(
- registration=registration,
+ registration=decrypt_string(key=encryption_key, ciphertext=registration_secret),
)
diff --git a/lib/charms/traefik_k8s/v2/ingress.py b/lib/charms/traefik_k8s/v2/ingress.py
index bb7ac5ed..5fb2cae3 100644
--- a/lib/charms/traefik_k8s/v2/ingress.py
+++ b/lib/charms/traefik_k8s/v2/ingress.py
@@ -1,4 +1,4 @@
-# Copyright 2024 Canonical Ltd.
+# Copyright 2025 Canonical Ltd.
# See LICENSE file for licensing details.
r"""# Interface Library for ingress.
diff --git a/localstack-installation.sh b/localstack-installation.sh
index 827c94b6..852f2c66 100755
--- a/localstack-installation.sh
+++ b/localstack-installation.sh
@@ -1,5 +1,5 @@
#!/bin/bash
-# Copyright 2024 Canonical Ltd.
+# Copyright 2025 Canonical Ltd.
# See LICENSE file for licensing details.
pip install pip --upgrade
diff --git a/metadata.yaml b/metadata.yaml
index 4622423a..96136fac 100644
--- a/metadata.yaml
+++ b/metadata.yaml
@@ -1,4 +1,4 @@
-# Copyright 2024 Canonical Ltd.
+# Copyright 2025 Canonical Ltd.
# See LICENSE file for licensing details.
name: synapse
diff --git a/pyproject.toml b/pyproject.toml
index 1dc34cad..c0377831 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,4 +1,4 @@
-# Copyright 2024 Canonical Ltd.
+# Copyright 2025 Canonical Ltd.
# See LICENSE file for licensing details.
[tool.bandit]
diff --git a/requirements.txt b/requirements.txt
index dac8a4de..d6586f7d 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,11 +1,11 @@
-boto3 ==1.35.80
-cosl ==0.0.47
-cryptography==44.0.0
-deepdiff ==8.0.1
-jinja2 ==3.1.4
+boto3 ==1.35.96
+cosl ==0.0.50
+cryptography ==44.0.0
+deepdiff ==8.1.1
+jinja2 ==3.1.5
jsonschema ==4.23.0
ops ==2.17.1
psycopg2-binary ==2.9.10
-pydantic ==2.10.3
+pydantic ==2.10.5
python-ulid ==3.0.0
requests ==2.32.3
diff --git a/src-docs/admin_access_token.py.md b/src-docs/admin_access_token.py.md
deleted file mode 100644
index 500f6cec..00000000
--- a/src-docs/admin_access_token.py.md
+++ /dev/null
@@ -1,67 +0,0 @@
-
-
-
-
-# module `admin_access_token.py`
-The Admin Access Token service.
-
-**Global Variables**
----------------
-- **JUJU_HAS_SECRETS**
-- **SECRET_ID**
-- **SECRET_KEY**
-
-
----
-
-## class `AdminAccessTokenService`
-The Admin Access Token Service.
-
-Attrs: _app: instance of Juju application. _model: instance of Juju model.
-
-
-
-### function `__init__`
-
-```python
-__init__(app: Application, model: Model)
-```
-
-Initialize the service.
-
-
-
-**Args:**
-
- - `app`: instance of Juju application.
- - `model`: instance of Juju model.
-
-
-
-
----
-
-
-
-### function `get`
-
-```python
-get(container: Container) → Optional[str]
-```
-
-Get an admin access token.
-
-If the admin token is not valid or it does not exist it creates one.
-
-
-
-**Args:**
-
- - `container`: Workload container.
-
-
-
-**Returns:**
- admin access token or None if fails.
-
-
diff --git a/src-docs/charm.py.md b/src-docs/charm.py.md
index aa6a0cf3..3568d8b2 100644
--- a/src-docs/charm.py.md
+++ b/src-docs/charm.py.md
@@ -20,7 +20,7 @@ Charm the service.
Attrs: on: listen to Redis events.
-
+
### function `__init__`
@@ -77,7 +77,7 @@ Unit that this execution is responsible for.
---
-
+
### function `build_charm_state`
@@ -94,7 +94,7 @@ Build charm state.
---
-
+
### function `get_main_unit`
@@ -111,7 +111,7 @@ Get main unit.
---
-
+
### function `get_main_unit_address`
@@ -128,7 +128,7 @@ Get main unit address. If main unit is None, use unit name.
---
-
+
### function `get_signing_key`
@@ -145,7 +145,7 @@ Get signing key from secret.
---
-
+
### function `get_unit_number`
@@ -169,7 +169,7 @@ Get unit number from unit name.
---
-
+
### function `instance_map`
@@ -186,7 +186,7 @@ Build instance_map config.
---
-
+
### function `is_main`
@@ -204,7 +204,7 @@ Verify if this unit is the main.
---
-
+
### function `peer_units_total`
@@ -221,7 +221,7 @@ Get peer units total.
---
-
+
### function `reconcile`
@@ -242,7 +242,7 @@ This is the main entry for changes that require a restart.
---
-
+
### function `set_main_unit`
@@ -260,7 +260,7 @@ Create/Renew an admin access token and put it in the peer relation.
---
-
+
### function `set_signing_key`
diff --git a/src-docs/matrix_auth_observer.py.md b/src-docs/matrix_auth_observer.py.md
index 2a08a1e7..de10b667 100644
--- a/src-docs/matrix_auth_observer.py.md
+++ b/src-docs/matrix_auth_observer.py.md
@@ -56,7 +56,7 @@ Return the current charm.
---
-
+
### function `get_requirer_registration_secrets`
diff --git a/src-docs/mjolnir.py.md b/src-docs/mjolnir.py.md
deleted file mode 100644
index c430ab9d..00000000
--- a/src-docs/mjolnir.py.md
+++ /dev/null
@@ -1,118 +0,0 @@
-
-
-
-
-# module `mjolnir.py`
-Provide the Mjolnir class to represent the Mjolnir plugin for Synapse.
-
-**Global Variables**
----------------
-- **MJOLNIR_SERVICE_NAME**
-- **USERNAME**
-
-
----
-
-## class `Mjolnir`
-A class representing the Mjolnir plugin for Synapse application.
-
-Mjolnir is a moderation tool for Matrix to be used to protect your server from malicious invites, spam messages etc. See https://github.com/matrix-org/mjolnir/ for more details about it.
-
-
-
-### function `__init__`
-
-```python
-__init__(charm: CharmBaseWithState, token_service: AdminAccessTokenService)
-```
-
-Initialize a new instance of the Mjolnir class.
-
-
-
-**Args:**
-
- - `charm`: The charm object that the Mjolnir instance belongs to.
- - `token_service`: Instance of Admin Access Token Service.
-
-
----
-
-#### property model
-
-Shortcut for more simple access the model.
-
-
-
----
-
-
-
-### function `enable_mjolnir`
-
-```python
-enable_mjolnir(charm_state: CharmState, admin_access_token: str) → None
-```
-
-Enable mjolnir service.
-
-The required steps to enable Mjolnir are:
- - Get an admin access token.
- - Check if the MJOLNIR_MEMBERSHIP_ROOM room is created.
- -- Only users from there will be allowed to join the management room.
- - Create Mjolnir user or get its access token if already exists.
- - Create the management room or get its room id if already exists.
- -- The management room will allow only members of MJOLNIR_MEMBERSHIP_ROOM room to join it.
- - Make the Mjolnir user admin of this room.
- - Create the Mjolnir configuration file.
- - Override Mjolnir user rate limit.
- - Finally, add Mjolnir pebble layer.
-
-
-
-**Args:**
-
- - `charm_state`: Instance of CharmState.
- - `admin_access_token`: not empty admin access token.
-
----
-
-
-
-### function `get_charm`
-
-```python
-get_charm() → CharmBaseWithState
-```
-
-Return the current charm.
-
-
-
-**Returns:**
- The current charm
-
----
-
-
-
-### function `get_membership_room_id`
-
-```python
-get_membership_room_id(admin_access_token: str) → Optional[str]
-```
-
-Check if membership room exists.
-
-
-
-**Args:**
-
- - `admin_access_token`: not empty admin access token.
-
-
-
-**Returns:**
- The room id or None if is not found.
-
-
diff --git a/src-docs/pebble.py.md b/src-docs/pebble.py.md
index 7a44b3da..746ea758 100644
--- a/src-docs/pebble.py.md
+++ b/src-docs/pebble.py.md
@@ -106,25 +106,6 @@ Return the Synapse NGINX container check.
-## function `check_mjolnir_ready`
-
-```python
-check_mjolnir_ready() → CheckDict
-```
-
-Return the Synapse Mjolnir service check.
-
-
-
-**Returns:**
-
- - `Dict`: check object converted to its dict representation.
-
-
----
-
-
-
## function `restart_nginx`
```python
@@ -143,7 +124,7 @@ Restart Synapse NGINX service and regenerate configuration.
---
-
+
## function `restart_federation_sender`
@@ -163,26 +144,7 @@ Restart Synapse federation sender service and regenerate configuration.
---
-
-
-## function `replan_mjolnir`
-
-```python
-replan_mjolnir(container: Container) → None
-```
-
-Replan Synapse Mjolnir service.
-
-
-
-**Args:**
-
- - `container`: Charm container.
-
-
----
-
-
+
## function `replan_stats_exporter`
@@ -202,7 +164,7 @@ Replan Synapse StatsExporter service.
---
-
+
## function `replan_synapse_federation_sender`
@@ -225,7 +187,7 @@ Replan Synapse Federation Sender service.
---
-
+
## function `replan_mas`
@@ -244,7 +206,7 @@ Replan Matrix Authentication Service.
---
-
+
## function `reconcile`
@@ -283,7 +245,7 @@ This is the main entry for changes that require a restart done via Pebble.
---
-
+
## function `restart_mas`
diff --git a/src/admin_access_token.py b/src/admin_access_token.py
deleted file mode 100644
index 2981dfb8..00000000
--- a/src/admin_access_token.py
+++ /dev/null
@@ -1,118 +0,0 @@
-# Copyright 2024 Canonical Ltd.
-# See LICENSE file for licensing details.
-
-# While this is a refactor,is expected to have few public methods.
-# pylint: disable=too-few-public-methods
-
-"""The Admin Access Token service."""
-import logging
-import typing
-
-import ops
-from ops.jujuversion import JujuVersion
-
-import synapse
-
-logger = logging.getLogger(__name__)
-
-JUJU_HAS_SECRETS = JujuVersion.from_environ().has_secrets
-# Disabling it since these are not hardcoded password
-SECRET_ID = "secret-id" # nosec
-SECRET_KEY = "secret-key" # nosec
-
-
-class AdminAccessTokenService: # pragma: no cover
- # TODO: Remove pragma: no cover once we get to test this class pylint: disable=fixme
- """The Admin Access Token Service.
-
- Attrs:
- _app: instance of Juju application.
- _model: instance of Juju model.
- """
-
- def __init__(self, app: ops.Application, model: ops.Model):
- """Initialize the service.
-
- Args:
- app: instance of Juju application.
- model: instance of Juju model.
- """
- self._app = app
- self._model = model
-
- def get(self, container: ops.Container) -> typing.Optional[str]:
- """Get an admin access token.
-
- If the admin token is not valid or it does not exist it creates one.
-
- Args:
- container: Workload container.
-
- Returns:
- admin access token or None if fails.
- """
- admin_access_token = self._get_from_peer_relation()
- if admin_access_token is None or not synapse.is_token_valid(admin_access_token):
- admin_access_token = self._renew_token(container)
- return admin_access_token
-
- def _get_from_peer_relation(self) -> typing.Optional[str]:
- """Get admin access token from the peer relation.
-
- Returns:
- Admin access token or None if admin token was not found or there was an error.
- """
- peer_relation = self._model.get_relation(synapse.SYNAPSE_PEER_RELATION_NAME)
- if not peer_relation:
- logger.error(
- "Failed to get admin access token: no peer relation %s found",
- synapse.SYNAPSE_PEER_RELATION_NAME,
- )
- return None
- admin_access_token = None
- if JUJU_HAS_SECRETS:
- secret_id = peer_relation.data[self._app].get(SECRET_ID)
- if secret_id:
- try:
- secret = self._model.get_secret(id=secret_id)
- admin_access_token = secret.get_content().get(SECRET_KEY)
- except ops.model.SecretNotFoundError as exc:
- logger.exception("Failed to get secret id %s: %s", secret_id, str(exc))
- del peer_relation.data[self._app][SECRET_ID]
- return None
- else:
- secret_value = peer_relation.data[self._app].get(SECRET_KEY)
- if secret_value:
- admin_access_token = secret_value
- return admin_access_token
-
- def _renew_token(self, container: ops.Container) -> typing.Optional[str]:
- """Create/Renew an admin access token and put it in the peer relation.
-
- Args:
- container: Workload container.
-
- Returns:
- admin access token or None if there was an error.
- """
- peer_relation = self._model.get_relation(synapse.SYNAPSE_PEER_RELATION_NAME)
- if not peer_relation:
- logger.error(
- "Failed to get admin access token: no peer relation %s found",
- synapse.SYNAPSE_PEER_RELATION_NAME,
- )
- return None
- admin_user = synapse.create_admin_user(container)
- if not admin_user:
- logger.error("Error creating admin user to get admin access token")
- return None
- if JUJU_HAS_SECRETS:
- logger.debug("Adding admin_access_token secret to peer relation")
- secret = self._app.add_secret({SECRET_KEY: admin_user.access_token})
- peer_relation.data[self._app].update({SECRET_ID: typing.cast(str, secret.id)})
- admin_access_token = admin_user.access_token
- else:
- logger.debug("Adding admin_access_token to peer relation")
- peer_relation.data[self._app].update({SECRET_KEY: admin_user.access_token})
- admin_access_token = admin_user.access_token
- return admin_access_token
diff --git a/src/auth/__init__.py b/src/auth/__init__.py
index 88566905..c92aa7d5 100644
--- a/src/auth/__init__.py
+++ b/src/auth/__init__.py
@@ -1,4 +1,4 @@
-# Copyright 2024 Canonical Ltd.
+# Copyright 2025 Canonical Ltd.
# See LICENSE file for licensing details.
"""Auth module."""
diff --git a/src/auth/mas.py b/src/auth/mas.py
index 1aa20e5d..79030543 100644
--- a/src/auth/mas.py
+++ b/src/auth/mas.py
@@ -1,4 +1,4 @@
-# Copyright 2024 Canonical Ltd.
+# Copyright 2025 Canonical Ltd.
# See LICENSE file for licensing details.
"""Helper module used to manage MAS-related workloads."""
@@ -10,6 +10,7 @@
from charms.hydra.v0.oauth import ClientConfig, OauthProviderConfig
from jinja2 import Environment, FileSystemLoader, select_autoescape
+from charm_types import SMTPConfiguration
from state.charm_state import SynapseConfig
from state.mas import MASConfiguration
@@ -39,8 +40,10 @@
)
MAS_AUTHORIZATION_GRANT = ["authorization_code"]
-MAS_TOKEN_ENDPOINT_AUTH_METHOD = "client_secret_basic"
MAS_OIDC_SCOPE = "openid profile email"
+# Disabling bandit checks since these are only the labels for juju secret
+MAS_TOKEN_ENDPOINT_AUTH_METHOD = "client_secret_basic" # nosec
+ADMIN_TOKEN_SECRET_LABEL = "admin.token" # nosec
class MASConfigInvalidError(Exception):
@@ -55,6 +58,10 @@ class MASVerifyUserEmailFailedError(Exception):
"""Exception raised when validation of the MAS config failed."""
+class MASGenerateAdminAccessTokenError(Exception):
+ """Exception raised when generation of admin token failed."""
+
+
def validate_mas_config(container: ops.model.Container) -> None:
"""Validate current MAS configuration.
@@ -102,7 +109,7 @@ def register_user(
Returns:
str: The generated user password
"""
- password = secrets.token_urlsafe(16)
+ password = secrets.token_hex(16)
command = [
MAS_EXECUTABLE_PATH,
"-c",
@@ -112,7 +119,7 @@ def register_user(
"--yes",
username,
"--password",
- password,
+ f"'{password}'",
]
if is_admin:
command.append("--admin")
@@ -159,10 +166,32 @@ def verify_user_email(
raise MASVerifyUserEmailFailedError("Error verifying the user email.") from exc
+def deactivate_user(container: ops.model.Container, username: str) -> None:
+ """Deactivate an user with mas-cli.
+
+ Args:
+ container: Synapse container.
+ username: Username to create the access token.
+ """
+ command = [
+ MAS_EXECUTABLE_PATH,
+ "-c",
+ MAS_CONFIGURATION_PATH,
+ "manage",
+ "lock-user",
+ "--deactivate",
+ username,
+ ]
+
+ process = container.exec(command=command, working_dir=MAS_WORKING_DIR, combine_stderr=True)
+ process.wait_output()
+
+
def generate_mas_config(
mas_configuration: MASConfiguration,
synapse_configuration: SynapseConfig,
oauth_provider_info: typing.Optional[OauthProviderConfig],
+ smtp_configuration: typing.Optional[SMTPConfiguration],
main_unit_address: str,
) -> str:
"""Render the MAS configuration file.
@@ -170,6 +199,7 @@ def generate_mas_config(
Args:
mas_configuration: Path of the template to load.
synapse_configuration: Context needed to render the template.
+ smtp_configuration: SMTP configuration.
main_unit_address: Address of synapse main unit.
oauth_provider_info: upstream provider configuration.
@@ -193,6 +223,7 @@ def generate_mas_config(
"synapse_main_unit_address": main_unit_address,
"upstream_oidc_provider_id": mas_context.upstream_oidc_provider_id,
"oauth_provider_info": oauth_provider_info,
+ "smtp_configuration": smtp_configuration,
}
env = Environment(
loader=FileSystemLoader("./templates"),
diff --git a/src/backup.py b/src/backup.py
index 9eb29a16..130abb1c 100644
--- a/src/backup.py
+++ b/src/backup.py
@@ -1,4 +1,4 @@
-# Copyright 2024 Canonical Ltd.
+# Copyright 2025 Canonical Ltd.
# See LICENSE file for licensing details.
"""Provides backup functionality for Synapse."""
diff --git a/src/backup_observer.py b/src/backup_observer.py
index 24a9871b..1c2c3e7b 100644
--- a/src/backup_observer.py
+++ b/src/backup_observer.py
@@ -1,4 +1,4 @@
-# Copyright 2024 Canonical Ltd.
+# Copyright 2025 Canonical Ltd.
# See LICENSE file for licensing details.
"""S3 Backup relation observer for Synapse."""
diff --git a/src/charm.py b/src/charm.py
index 8c172d3b..8565d259 100755
--- a/src/charm.py
+++ b/src/charm.py
@@ -1,6 +1,6 @@
#!/usr/bin/env python3
-# Copyright 2024 Canonical Ltd.
+# Copyright 2025 Canonical Ltd.
# See LICENSE file for licensing details.
"""Charm for Synapse on kubernetes."""
@@ -20,10 +20,10 @@
import pebble
import synapse
-from admin_access_token import AdminAccessTokenService
from auth.mas import (
MASRegisterUserFailedError,
MASVerifyUserEmailFailedError,
+ deactivate_user,
generate_mas_config,
generate_oauth_client_config,
generate_synapse_msc3861_config,
@@ -34,14 +34,12 @@
from database_observer import DatabaseObserver, SynapseDatabaseObserver
from matrix_auth_observer import MatrixAuthObserver
from media_observer import MediaObserver
-from mjolnir import Mjolnir
from observability import Observability
from redis_observer import RedisObserver
from smtp_observer import SMTPObserver
from state.charm_state import CharmState
from state.mas import MAS_DATABASE_INTEGRATION_NAME, MAS_DATABASE_NAME, MASConfiguration
from state.validation import CharmBaseWithState, validate_charm_state
-from user import User
logger = logging.getLogger(__name__)
@@ -79,7 +77,6 @@ def __init__(self, *args: typing.Any) -> None:
)
self._smtp = SMTPObserver(self)
self._redis = RedisObserver(self)
- self.token_service = AdminAccessTokenService(app=self.app, model=self.model)
# service-hostname is a required field so we're hardcoding to the same
# value as service-name. service-hostname should be set via Nginx
# Ingress Integrator charm config.
@@ -96,7 +93,6 @@ def __init__(self, *args: typing.Any) -> None:
)
self._oauth = OAuthRequirer(self)
self._observability = Observability(self)
- self._mjolnir = Mjolnir(self, token_service=self.token_service)
self.framework.observe(self.on.config_changed, self._on_config_changed)
self.framework.observe(self.on.leader_elected, self._on_leader_elected)
self.framework.observe(
@@ -109,10 +105,6 @@ def __init__(self, *args: typing.Any) -> None:
self.framework.observe(self.on.synapse_pebble_ready, self._on_synapse_pebble_ready)
self.framework.observe(self.on.register_user_action, self._on_register_user_action)
self.framework.observe(self.on.verify_user_email_action, self._on_verify_user_email_action)
-
- self.framework.observe(
- self.on.promote_user_admin_action, self._on_promote_user_admin_action
- )
self.framework.observe(self.on.anonymize_user_action, self._on_anonymize_user_action)
self.framework.observe(self._oauth.on.oauth_info_changed, self._on_config_changed)
self.framework.observe(self._oauth.on.oauth_info_removed, self._on_config_changed)
@@ -214,6 +206,11 @@ def reconcile(self, charm_state: CharmState, mas_configuration: MASConfiguration
charm_state: Instance of CharmState
mas_configuration: Charm state component to configure MAS
"""
+ logger.debug("Found %d peer unit(s).", self.peer_units_total())
+ if charm_state.redis_config is None and self.peer_units_total() > 1:
+ logger.debug("More than 1 peer unit found. Redis is required.")
+ self.unit.status = ops.BlockedStatus("Redis integration is required.")
+ return
if self.get_main_unit() is None and self.unit.is_leader():
logging.debug("Change_config is setting main unit.")
self.set_main_unit(self.unit.name)
@@ -226,18 +223,16 @@ def reconcile(self, charm_state: CharmState, mas_configuration: MASConfiguration
oauth_client_config = generate_oauth_client_config(
mas_configuration, charm_state.synapse_config
)
- logger.info('Generated oauth client config: %s', oauth_client_config)
self._oauth.update_client_config(oauth_client_config)
oauth_provider_info = None
if self._oauth.is_client_created():
oauth_provider_info = self._oauth.get_provider_info()
- logger.info('IS client created: %s', self._oauth.is_client_created())
-
rendered_mas_configuration = generate_mas_config(
mas_configuration,
charm_state.synapse_config,
oauth_provider_info,
+ charm_state.smtp_config,
self.get_main_unit_address(),
)
synapse_msc3861_configuration = generate_synapse_msc3861_config(
@@ -331,11 +326,6 @@ def _on_config_changed(self, _: ops.HookEvent) -> None:
charm_state = self.build_charm_state()
mas_configuration = MASConfiguration.from_charm(self)
- logger.debug("Found %d peer unit(s).", self.peer_units_total())
- if charm_state.redis_config is None and self.peer_units_total() > 1:
- logger.debug("More than 1 peer unit found. Redis is required.")
- self.unit.status = ops.BlockedStatus("Redis integration is required.")
- return
logger.debug("_on_config_changed emitting reconcile")
self.reconcile(charm_state, mas_configuration)
self._set_workload_version()
@@ -379,11 +369,6 @@ def _on_synapse_pebble_ready(self, _: ops.HookEvent) -> None:
charm_state = self.build_charm_state()
mas_configuration = MASConfiguration.from_charm(self)
- logger.debug("Found %d peer unit(s).", self.peer_units_total())
- if charm_state.redis_config is None and self.peer_units_total() > 1:
- logger.debug("More than 1 peer unit found. Redis is required.")
- self.unit.status = ops.BlockedStatus("Redis integration is required.")
- return
self.unit.status = ops.ActiveStatus()
logger.debug("_on_synapse_pebble_ready emitting reconcile")
self.reconcile(charm_state, mas_configuration)
@@ -569,73 +554,28 @@ def _on_verify_user_email_action(self, event: ActionEvent) -> None:
results = {"verify-user-email": True}
event.set_results(results)
- @validate_charm_state
- def _on_promote_user_admin_action(self, event: ActionEvent) -> None:
- """Promote user admin and report action result.
-
- Args:
- event: Event triggering the promote user admin action.
- """
- charm_state = self.build_charm_state()
- MASConfiguration.validate(self)
-
- results = {
- "promote-user-admin": False,
- }
- container = self.unit.get_container(synapse.SYNAPSE_CONTAINER_NAME)
- if not container.can_connect():
- event.fail("Failed to connect to the container")
- return
- try:
- admin_access_token = self.token_service.get(container)
- if not admin_access_token:
- event.fail("Failed to get admin access token")
- return
- username = event.params["username"]
- server = charm_state.synapse_config.server_name
- user = User(username=username, admin=True)
- synapse.promote_user_admin(
- user=user, server=server, admin_access_token=admin_access_token
- )
- results["promote-user-admin"] = True
- except synapse.APIError as exc:
- event.fail(str(exc))
- return
- event.set_results(results)
-
- @validate_charm_state
def _on_anonymize_user_action(self, event: ActionEvent) -> None:
"""Anonymize user and report action result.
Args:
event: Event triggering the anonymize user action.
"""
- charm_state = self.build_charm_state()
- MASConfiguration.validate(self)
-
- results = {
- "anonymize-user": False,
- }
container = self.unit.get_container(synapse.SYNAPSE_CONTAINER_NAME)
if not container.can_connect():
- event.fail("Container not yet ready. Try again later")
+ event.fail("Failed to connect to the container")
return
+
try:
- admin_access_token = self.token_service.get(container)
- if not admin_access_token:
- event.fail("Failed to get admin access token")
- return
- username = event.params["username"]
- server = charm_state.synapse_config.server_name
- user = User(username=username, admin=False)
- synapse.deactivate_user(
- user=user, server=server, admin_access_token=admin_access_token
- )
- results["anonymize-user"] = True
- except synapse.APIError:
- event.fail("Failed to anonymize the user. Check if the user is created and active.")
- return
- event.set_results(results)
+ deactivate_user(container=container, username=event.params["username"])
+ except ops.pebble.ExecError as exc:
+ logger.exception("Error deactivating user.")
+ event.fail(str(exc))
+
+ event.set_results(
+ {
+ "anonymize-user": True,
+ }
+ )
if __name__ == "__main__": # pragma: nocover
diff --git a/src/charm_types.py b/src/charm_types.py
index d716c99b..1405c168 100644
--- a/src/charm_types.py
+++ b/src/charm_types.py
@@ -1,6 +1,6 @@
#!/usr/bin/env python3
-# Copyright 2024 Canonical Ltd.
+# Copyright 2025 Canonical Ltd.
# See LICENSE file for licensing details.
"""Type definitions for the Synapse charm."""
diff --git a/src/database_client.py b/src/database_client.py
index 022a67e3..8f806cb7 100644
--- a/src/database_client.py
+++ b/src/database_client.py
@@ -1,4 +1,4 @@
-# Copyright 2024 Canonical Ltd.
+# Copyright 2025 Canonical Ltd.
# See LICENSE file for licensing details.
"""The DatabaseClient class."""
diff --git a/src/database_observer.py b/src/database_observer.py
index 1c221a5e..463109b4 100644
--- a/src/database_observer.py
+++ b/src/database_observer.py
@@ -1,4 +1,4 @@
-# Copyright 2024 Canonical Ltd.
+# Copyright 2025 Canonical Ltd.
# See LICENSE file for licensing details.
# Ignoring for the config change call
diff --git a/src/exceptions.py b/src/exceptions.py
index 41e88c5e..a33fe2bf 100644
--- a/src/exceptions.py
+++ b/src/exceptions.py
@@ -1,6 +1,6 @@
#!/usr/bin/env python3
-# Copyright 2024 Canonical Ltd.
+# Copyright 2025 Canonical Ltd.
# See LICENSE file for licensing details.
"""Exceptions used by the Synapse charm."""
diff --git a/src/matrix_auth_observer.py b/src/matrix_auth_observer.py
index 313f86b5..8efc3985 100644
--- a/src/matrix_auth_observer.py
+++ b/src/matrix_auth_observer.py
@@ -1,4 +1,4 @@
-# Copyright 2024 Canonical Ltd.
+# Copyright 2025 Canonical Ltd.
# See LICENSE file for licensing details.
"""The Matrix Auth relation observer."""
@@ -9,7 +9,7 @@
from typing import List, NamedTuple, Optional
import ops
-from charms.synapse.v0.matrix_auth import (
+from charms.synapse.v1.matrix_auth import (
MatrixAuthProviderData,
MatrixAuthProvides,
MatrixAuthRequirerData,
@@ -58,12 +58,13 @@ def update_matrix_auth_integration(self, charm_state: CharmState) -> None:
Args:
charm_state: The charm state.
"""
- for relation in list(self._charm.model.relations["matrix-auth"]):
- if not relation.units:
- return
+ matrix_auth_relations = list(self._charm.model.relations["matrix-auth"])
+ logger.info("%d matrix-auth relations found", len(matrix_auth_relations))
+ for relation in matrix_auth_relations:
provider_data = self._get_matrix_auth_provider_data(charm_state)
if self._matrix_auth_relation_updated(relation, provider_data):
return
+ logger.info("updating matrix-auth relation %d", relation.id)
self.matrix_auth.update_relation_data(relation, provider_data)
def get_requirer_registration_secrets(self) -> Optional[List]:
diff --git a/src/media_observer.py b/src/media_observer.py
index ed5d90a7..e53cb9a0 100644
--- a/src/media_observer.py
+++ b/src/media_observer.py
@@ -1,4 +1,4 @@
-# Copyright 2024 Canonical Ltd.
+# Copyright 2025 Canonical Ltd.
# See LICENSE file for licensing details.
"""The media integrator relation observer."""
diff --git a/src/mjolnir.py b/src/mjolnir.py
deleted file mode 100644
index bbee9fa3..00000000
--- a/src/mjolnir.py
+++ /dev/null
@@ -1,231 +0,0 @@
-# Copyright 2024 Canonical Ltd.
-# See LICENSE file for licensing details.
-
-"""Provide the Mjolnir class to represent the Mjolnir plugin for Synapse."""
-
-# disabling due the fact that collect status does many checks
-# pylint: disable=too-many-return-statements
-
-import logging
-import typing
-
-import ops
-
-import pebble
-import synapse
-from admin_access_token import AdminAccessTokenService
-from state.charm_state import CharmState
-from state.mas import MASConfiguration
-from state.validation import CharmBaseWithState, validate_charm_state
-
-logger = logging.getLogger(__name__)
-
-MJOLNIR_SERVICE_NAME = "mjolnir"
-USERNAME = "moderator"
-
-
-class Mjolnir(ops.Object): # pylint: disable=too-few-public-methods
- """A class representing the Mjolnir plugin for Synapse application.
-
- Mjolnir is a moderation tool for Matrix to be used to protect your server from malicious
- invites, spam messages etc.
- See https://github.com/matrix-org/mjolnir/ for more details about it.
- """
-
- def __init__(self, charm: CharmBaseWithState, token_service: AdminAccessTokenService):
- """Initialize a new instance of the Mjolnir class.
-
- Args:
- charm: The charm object that the Mjolnir instance belongs to.
- token_service: Instance of Admin Access Token Service.
- """
- super().__init__(charm, "mjolnir")
- self._charm = charm
- self._token_service = token_service
- self.framework.observe(charm.on.collect_unit_status, self._on_collect_status)
-
- def get_charm(self) -> CharmBaseWithState:
- """Return the current charm.
-
- Returns:
- The current charm
- """
- return self._charm
-
- @property
- def _admin_access_token(self) -> typing.Optional[str]:
- """Get admin access token.
-
- Returns:
- admin access token or None if fails.
- """
- container = self._charm.unit.get_container(synapse.SYNAPSE_CONTAINER_NAME)
- if not container.can_connect():
- logger.exception("Failed to connect to Synapse")
- return None
- access_token = self._token_service.get(container)
- if not access_token:
- logging.error("Admin Access Token was not found, please check the logs.")
- return None
- return access_token
-
- # Ignoring complexity warning for now
- @validate_charm_state
- def _on_collect_status(self, event: ops.CollectStatusEvent) -> None: # noqa: C901
- """Collect status event handler.
-
- Args:
- event: Collect status event.
- """
- charm = self.get_charm()
- charm_state = charm.build_charm_state()
- MASConfiguration.validate(charm)
-
- if not charm_state.synapse_config.enable_mjolnir:
- return
- container = self._charm.unit.get_container(synapse.SYNAPSE_CONTAINER_NAME)
- if not container.can_connect():
- self._charm.unit.status = ops.MaintenanceStatus("Waiting for Synapse pebble")
- return
- mjolnir_service = container.get_services(MJOLNIR_SERVICE_NAME)
- # This check is the same done in get_main_unit. It should be refactored
- # to a place where both Charm and Mjolnir can get it.
- peer_relation = self._charm.model.relations[synapse.SYNAPSE_PEER_RELATION_NAME]
- if peer_relation:
- logger.debug(
- "Peer relation found, checking if is main unit before configuring Mjolnir"
- )
- # The default is self._charm.unit.name to make tests that use Harness.begin() work.
- # When not using begin_with_initial_hooks, the peer relation data is not created.
- main_unit_id = (
- peer_relation[0].data[self._charm.app].get("main_unit_id", self._charm.unit.name)
- )
- if not self._charm.unit.name == main_unit_id:
- if mjolnir_service:
- logger.info("This is not the main unit, stopping Mjolnir")
- container.stop(MJOLNIR_SERVICE_NAME)
- else:
- logger.info("This is not the main unit, skipping Mjolnir configuration")
- return
- if mjolnir_service:
- mjolnir_not_active = [
- service for service in mjolnir_service.values() if not service.is_running()
- ]
- if mjolnir_not_active:
- logger.debug(
- "%s service already exists but is not running, restarting",
- MJOLNIR_SERVICE_NAME,
- )
- container.restart(MJOLNIR_SERVICE_NAME)
- logger.debug("%s service already exists and running, skipping", MJOLNIR_SERVICE_NAME)
- return
- synapse_service = container.get_services(synapse.SYNAPSE_SERVICE_NAME)
- synapse_not_active = [
- service for service in synapse_service.values() if not service.is_running()
- ]
- if not synapse_service or synapse_not_active:
- # The get_membership_room_id does a call to Synapse API in order to get the
- # membership room id. This only works if Synapse is running so that's why
- # the service status is checked here.
- self._charm.unit.status = ops.MaintenanceStatus("Waiting for Synapse")
- return
- if not self._admin_access_token:
- self._charm.unit.status = ops.MaintenanceStatus(
- "Failed to get admin access token. Please, check the logs."
- )
- return
- try:
- if self.get_membership_room_id(self._admin_access_token) is None:
- status = ops.BlockedStatus(
- f"{synapse.MJOLNIR_MEMBERSHIP_ROOM} not found and "
- "is required by Mjolnir. Please, check the logs."
- )
- interval = self._charm.model.config.get("update-status-hook-interval", "")
- logger.error(
- "The Mjolnir configuration will be done in %s after the room %s is created."
- "This interval is set in update-status-hook-interval model config.",
- interval,
- synapse.MJOLNIR_MEMBERSHIP_ROOM,
- )
- event.add_status(status)
- return
- except synapse.APIError as exc:
- logger.exception(
- "Failed to check for membership_room. Mjolnir will not be configured: %r",
- exc,
- )
- return
- self.enable_mjolnir(charm_state, self._admin_access_token)
- event.add_status(ops.ActiveStatus())
-
- def get_membership_room_id(self, admin_access_token: str) -> typing.Optional[str]:
- """Check if membership room exists.
-
- Args:
- admin_access_token: not empty admin access token.
-
- Returns:
- The room id or None if is not found.
- """
- return synapse.get_room_id(
- room_name=synapse.MJOLNIR_MEMBERSHIP_ROOM, admin_access_token=admin_access_token
- )
-
- def enable_mjolnir(self, charm_state: CharmState, admin_access_token: str) -> None:
- """Enable mjolnir service.
-
- The required steps to enable Mjolnir are:
- - Get an admin access token.
- - Check if the MJOLNIR_MEMBERSHIP_ROOM room is created.
- -- Only users from there will be allowed to join the management room.
- - Create Mjolnir user or get its access token if already exists.
- - Create the management room or get its room id if already exists.
- -- The management room will allow only members of MJOLNIR_MEMBERSHIP_ROOM room to join it.
- - Make the Mjolnir user admin of this room.
- - Create the Mjolnir configuration file.
- - Override Mjolnir user rate limit.
- - Finally, add Mjolnir pebble layer.
-
- Args:
- charm_state: Instance of CharmState.
- admin_access_token: not empty admin access token.
- """
- container = self._charm.unit.get_container(synapse.SYNAPSE_CONTAINER_NAME)
- if not container.can_connect():
- self._charm.unit.status = ops.MaintenanceStatus("Waiting for Synapse pebble")
- return
- self._charm.model.unit.status = ops.MaintenanceStatus("Configuring Mjolnir")
- mjolnir_user = synapse.create_user(
- container,
- USERNAME,
- True,
- admin_access_token,
- str(charm_state.synapse_config.server_name),
- )
- if mjolnir_user is None:
- logger.error("Failed to create Mjolnir user. Mjolnir will not be configured")
- return
- mjolnir_access_token = mjolnir_user.access_token
- room_id = synapse.get_room_id(
- room_name=synapse.MJOLNIR_MANAGEMENT_ROOM, admin_access_token=admin_access_token
- )
- if room_id is None:
- logger.info("Room %s not found, creating", synapse.MJOLNIR_MANAGEMENT_ROOM)
- room_id = synapse.create_management_room(admin_access_token=admin_access_token)
- # Add the Mjolnir user to the management room
- synapse.make_room_admin(
- user=mjolnir_user,
- server=str(charm_state.synapse_config.server_name),
- admin_access_token=admin_access_token,
- room_id=room_id,
- )
- synapse.generate_mjolnir_config(
- container=container, access_token=mjolnir_access_token, room_id=room_id
- )
- synapse.override_rate_limit(
- user=mjolnir_user,
- admin_access_token=admin_access_token,
- charm_state=charm_state,
- )
- pebble.replan_mjolnir(container)
- self._charm.model.unit.status = ops.ActiveStatus()
diff --git a/src/observability.py b/src/observability.py
index 5bcbab22..fcc83599 100644
--- a/src/observability.py
+++ b/src/observability.py
@@ -1,4 +1,4 @@
-# Copyright 2024 Canonical Ltd.
+# Copyright 2025 Canonical Ltd.
# See LICENSE file for licensing details.
"""Provide the Observability class to represent the observability stack for Synapse."""
diff --git a/src/pebble.py b/src/pebble.py
index 4e761516..6f77b1be 100644
--- a/src/pebble.py
+++ b/src/pebble.py
@@ -1,6 +1,6 @@
#!/usr/bin/env python3
-# Copyright 2024 Canonical Ltd.
+# Copyright 2025 Canonical Ltd.
# See LICENSE file for licensing details.
# Ignoring for the config change call
@@ -120,22 +120,6 @@ def check_nginx_ready() -> ops.pebble.CheckDict:
return check.to_dict()
-def check_mjolnir_ready() -> ops.pebble.CheckDict:
- """Return the Synapse Mjolnir service check.
-
- Returns:
- Dict: check object converted to its dict representation.
- """
- check = Check(synapse.CHECK_MJOLNIR_READY_NAME)
- check.override = "replace"
- check.level = "ready"
- check.http = {"url": f"http://localhost:{synapse.MJOLNIR_HEALTH_PORT}/healthz"}
- check.timeout = "10s"
- check.threshold = 5
- check.period = "1m"
- return check.to_dict()
-
-
def restart_nginx(container: ops.model.Container, main_unit_address: str) -> None:
"""Restart Synapse NGINX service and regenerate configuration.
@@ -161,16 +145,6 @@ def restart_federation_sender(container: ops.model.Container, charm_state: Charm
container.restart(synapse.SYNAPSE_FEDERATION_SENDER_SERVICE_NAME)
-def replan_mjolnir(container: ops.model.Container) -> None:
- """Replan Synapse Mjolnir service.
-
- Args:
- container: Charm container.
- """
- container.add_layer("synapse-mjolnir", _mjolnir_pebble_layer(), combine=True)
- container.replan()
-
-
def replan_stats_exporter(container: ops.model.Container, charm_state: CharmState) -> None:
"""Replan Synapse StatsExporter service.
@@ -341,6 +315,8 @@ def reconcile( # noqa: C901
PebbleServiceError: if something goes wrong while interacting with Pebble.
"""
try:
+ restart_mas(container, rendered_mas_configuration)
+
if _environment_has_changed(container=container, charm_state=charm_state, is_main=is_main):
# Configurations set via environment variables:
# synapse_report_stats, database, and proxy
@@ -413,8 +389,6 @@ def reconcile( # noqa: C901
if charm_state.datasource and is_main:
logger.info("Synapse Stats Exporter enabled.")
replan_stats_exporter(container=container, charm_state=charm_state)
- # Activate MAS on synapse
- synapse.configure_mas(current_synapse_config, synapse_msc3861_configuration)
config_has_changed = DeepDiff(
existing_synapse_config,
@@ -422,8 +396,9 @@ def reconcile( # noqa: C901
ignore_order=True,
ignore_string_case=True,
)
+ # Activate msc3861
+ synapse.configure_mas(current_synapse_config, synapse_msc3861_configuration)
- restart_mas(container, rendered_mas_configuration)
if config_has_changed:
logging.info("Configuration has changed, Synapse will be restarted.")
logging.debug("The change is: %s", config_has_changed)
@@ -483,23 +458,6 @@ def _pebble_layer(charm_state: CharmState, is_main: bool = True) -> ops.pebble.L
return typing.cast(ops.pebble.LayerDict, layer)
-def _pebble_layer_without_restart(charm_state: CharmState) -> ops.pebble.LayerDict:
- """Return a dictionary representing a Pebble layer without restart.
-
- Args:
- charm_state: Instance of CharmState
-
- Returns:
- pebble layer
- """
- new_layer = _pebble_layer(charm_state)
- new_layer["services"][synapse.SYNAPSE_SERVICE_NAME]["on-success"] = "ignore"
- new_layer["services"][synapse.SYNAPSE_SERVICE_NAME]["on-failure"] = "ignore"
- ignore = {synapse.CHECK_READY_NAME: "ignore"}
- new_layer["services"][synapse.SYNAPSE_SERVICE_NAME]["on-check-failure"] = ignore
- return new_layer
-
-
def _nginx_pebble_layer() -> ops.pebble.LayerDict:
"""Generate pebble config for the synapse-nginx container.
@@ -524,31 +482,6 @@ def _nginx_pebble_layer() -> ops.pebble.LayerDict:
return typing.cast(ops.pebble.LayerDict, layer)
-def _mjolnir_pebble_layer() -> ops.pebble.LayerDict:
- """Generate pebble config for the mjolnir service.
-
- Returns:
- The pebble configuration for the mjolnir service.
- """
- command_params = f"bot --mjolnir-config {synapse.MJOLNIR_CONFIG_PATH}"
- layer = {
- "summary": "Synapse mjolnir layer",
- "description": "Synapse mjolnir layer",
- "services": {
- synapse.MJOLNIR_SERVICE_NAME: {
- "override": "replace",
- "summary": "Mjolnir service",
- "command": f"/mjolnir-entrypoint.sh {command_params}",
- "startup": "enabled",
- },
- },
- "checks": {
- synapse.CHECK_MJOLNIR_READY_NAME: check_mjolnir_ready(),
- },
- }
- return typing.cast(ops.pebble.LayerDict, layer)
-
-
def _cron_pebble_layer(charm_state: CharmState) -> ops.pebble.LayerDict:
"""Generate pebble config for the cron service.
diff --git a/src/redis_observer.py b/src/redis_observer.py
index b47f9c5d..5e801067 100644
--- a/src/redis_observer.py
+++ b/src/redis_observer.py
@@ -1,4 +1,4 @@
-# Copyright 2024 Canonical Ltd.
+# Copyright 2025 Canonical Ltd.
# See LICENSE file for licensing details.
# Ignoring for the is_main call
diff --git a/src/s3_parameters.py b/src/s3_parameters.py
index b1f21d4a..c923c671 100644
--- a/src/s3_parameters.py
+++ b/src/s3_parameters.py
@@ -1,4 +1,4 @@
-# Copyright 2024 Canonical Ltd.
+# Copyright 2025 Canonical Ltd.
# See LICENSE file for licensing details.
"""Provides S3 Parameters configuration."""
diff --git a/src/smtp_observer.py b/src/smtp_observer.py
index 6b872ee9..496fd969 100644
--- a/src/smtp_observer.py
+++ b/src/smtp_observer.py
@@ -1,4 +1,4 @@
-# Copyright 2024 Canonical Ltd.
+# Copyright 2025 Canonical Ltd.
# See LICENSE file for licensing details.
"""The SMTP integrator relation observer."""
diff --git a/src/state/__init__.py b/src/state/__init__.py
index 90173b89..f9460355 100644
--- a/src/state/__init__.py
+++ b/src/state/__init__.py
@@ -1,4 +1,4 @@
-# Copyright 2024 Canonical Ltd.
+# Copyright 2025 Canonical Ltd.
# See LICENSE file for licensing details.
"""Charm state module."""
diff --git a/src/state/charm_state.py b/src/state/charm_state.py
index 8a10da77..17d889dd 100644
--- a/src/state/charm_state.py
+++ b/src/state/charm_state.py
@@ -1,4 +1,4 @@
-# Copyright 2024 Canonical Ltd.
+# Copyright 2025 Canonical Ltd.
# See LICENSE file for licensing details.
"""State of the Charm."""
@@ -57,7 +57,6 @@ class SynapseConfig(BaseModel): # pylint: disable=too-few-public-methods
allow_public_rooms_over_federation: allow_public_rooms_over_federation config.
block_non_admin_invites: block_non_admin_invites config.
enable_email_notifs: enable_email_notifs config.
- enable_mjolnir: enable_mjolnir config.
enable_password_config: enable_password_config config.
enable_room_list_search: enable_room_list_search config.
federation_domain_whitelist: federation_domain_whitelist config.
@@ -80,7 +79,6 @@ class SynapseConfig(BaseModel): # pylint: disable=too-few-public-methods
allow_public_rooms_over_federation: bool = False
block_non_admin_invites: bool = False
enable_email_notifs: bool = False
- enable_mjolnir: bool = False
enable_password_config: bool = True
enable_room_list_search: bool = True
experimental_alive_check: str | None = Field(None)
diff --git a/src/state/mas.py b/src/state/mas.py
index 51c824fe..b2f8f1eb 100644
--- a/src/state/mas.py
+++ b/src/state/mas.py
@@ -1,4 +1,4 @@
-# Copyright 2024 Canonical Ltd.
+# Copyright 2025 Canonical Ltd.
# See LICENSE file for licensing details.
"""State of the Charm."""
@@ -37,6 +37,10 @@ class MASContextValidationError(Exception):
"""Exception raised when validation of the MAS Context failed."""
+class MASDatasourceInvalidError(Exception):
+ """Exception raised when validation of the MAS datasource failed."""
+
+
class MASContext(BaseModel):
"""Context used to render MAS configuration file.
@@ -141,6 +145,9 @@ def from_charm(cls, charm: ops.CharmBase) -> "MASConfiguration":
cls.validate(charm)
# pylint: disable=protected-access
datasource = charm._mas_database.get_relation_as_datasource() # type: ignore
+ validate_datasource(datasource)
+
+ logger.info("Datasource validated: %s", datasource)
try:
secret = charm.model.get_secret(label=MAS_CONTEXT_LABEL)
@@ -195,3 +202,23 @@ def validate(cls, charm: ops.CharmBase) -> None:
"""
if not charm.model.relations.get(MAS_DATABASE_INTEGRATION_NAME):
raise MASDatasourceMissingError("Waiting for mas-database integration.")
+
+
+def validate_datasource(datasource: DatasourcePostgreSQL) -> None:
+ """Validate if datasource has all the expected values.
+
+ Args:
+ datasource: The fetched datasource from integration data.
+
+ Raises:
+ MASDatasourceInvalidError: When datasource is incomplete.
+ """
+ if not all(
+ [
+ datasource.get("user"),
+ datasource.get("password"),
+ datasource.get("host"),
+ datasource.get("port"),
+ ]
+ ):
+ raise MASDatasourceInvalidError("Missing values in postgresql datasource.")
diff --git a/src/state/validation.py b/src/state/validation.py
index e069bec8..3434d1e2 100644
--- a/src/state/validation.py
+++ b/src/state/validation.py
@@ -1,4 +1,4 @@
-# Copyright 2024 Canonical Ltd.
+# Copyright 2025 Canonical Ltd.
# See LICENSE file for licensing details.
"""State of the Charm."""
@@ -12,7 +12,7 @@
from state.mas import MASContextNotSetError
from .charm_state import CharmConfigInvalidError, CharmState
-from .mas import MASConfiguration, MASDatasourceMissingError
+from .mas import MASConfiguration, MASDatasourceInvalidError, MASDatasourceMissingError
logger = logging.getLogger(__name__)
@@ -94,7 +94,10 @@ def wrapper(instance: C, event: E) -> None:
try:
return method(instance, event)
- except (CharmConfigInvalidError, MASDatasourceMissingError) as exc:
+ except (
+ CharmConfigInvalidError,
+ MASDatasourceMissingError,
+ ) as exc:
logger.exception("Error initializing charm state.")
# There are two main types of events, Hooks and Actions.
# Each one of them should be treated differently.
@@ -102,8 +105,8 @@ def wrapper(instance: C, event: E) -> None:
event.fail(str(exc))
else:
charm.model.unit.status = ops.BlockedStatus(str(exc))
- except MASContextNotSetError as exc:
- logger.exception("MAS context not set by leader.")
+ except (MASContextNotSetError, MASDatasourceInvalidError) as exc:
+ logger.exception("Waiting for the charm to settle into a correct state.")
charm.model.unit.status = ops.WaitingStatus(str(exc))
return None
diff --git a/src/synapse/__init__.py b/src/synapse/__init__.py
index 34762310..7ee7fc4f 100644
--- a/src/synapse/__init__.py
+++ b/src/synapse/__init__.py
@@ -1,10 +1,9 @@
-# Copyright 2024 Canonical Ltd.
+# Copyright 2025 Canonical Ltd.
# See LICENSE file for licensing details.
"""Synapse package is used to interact with Synapse instance."""
# Exporting methods to be used for another modules
-from .admin import create_admin_user, create_user # noqa: F401
from .api import ( # noqa: F401
ADD_USER_ROOM_URL,
CREATE_ROOM_URL,
@@ -12,34 +11,23 @@
LIST_ROOMS_URL,
LIST_USERS_URL,
LOGIN_URL,
- MJOLNIR_MANAGEMENT_ROOM,
- MJOLNIR_MEMBERSHIP_ROOM,
REGISTER_URL,
SYNAPSE_PORT,
SYNAPSE_URL,
SYNAPSE_VERSION_REGEX,
VERSION_URL,
APIError,
- create_management_room,
- deactivate_user,
- get_access_token,
get_room_id,
get_version,
is_token_valid,
make_room_admin,
override_rate_limit,
- promote_user_admin,
- register_user,
)
from .workload import ( # noqa: F401
CHECK_ALIVE_NAME,
- CHECK_MJOLNIR_READY_NAME,
CHECK_NGINX_READY_NAME,
CHECK_READY_NAME,
COMMAND_MIGRATE_CONFIG,
- MJOLNIR_CONFIG_PATH,
- MJOLNIR_HEALTH_PORT,
- MJOLNIR_SERVICE_NAME,
STATS_EXPORTER_PORT,
SYNAPSE_COMMAND_PATH,
SYNAPSE_CONFIG_DIR,
@@ -61,7 +49,6 @@
WorkloadError,
create_registration_secrets_files,
execute_migrate_config,
- generate_mjolnir_config,
generate_nginx_config,
generate_worker_config,
get_environment,
diff --git a/src/synapse/admin.py b/src/synapse/admin.py
deleted file mode 100644
index e4d8784f..00000000
--- a/src/synapse/admin.py
+++ /dev/null
@@ -1,74 +0,0 @@
-#!/usr/bin/env python3
-
-# Copyright 2024 Canonical Ltd.
-# See LICENSE file for licensing details.
-
-"""Helper module used to manage admin tasks involving Synapse API and Workload."""
-
-import logging
-import typing
-from secrets import token_hex
-
-import ops
-
-from user import User
-
-from .api import register_user
-from .workload import get_registration_shared_secret
-
-logger = logging.getLogger(__name__)
-
-
-def create_admin_user(container: ops.Container) -> typing.Optional[User]:
- """Create admin user.
-
- Args:
- container: Container of the charm.
-
- Returns:
- Admin user with token to be used in Synapse API requests or None if fails.
- """
- # The username is random because if the user exists, register_user will try to get the
- # access_token.
- # But to do that it needs an admin user and we don't have one yet.
- # So, to be on the safe side, the user name is randomly generated and if for any reason
- # there is no access token on peer data/secret, another user will be created.
- #
- # Using 16 to create a random value but to be secure against brute-force attacks,
- # please check the docs:
- # https://docs.python.org/3/library/secrets.html#how-many-bytes-should-tokens-use
- username = token_hex(16)
- return create_user(container=container, username=username, admin=True)
-
-
-def create_user(
- container: ops.Container,
- username: str,
- admin: bool = False,
- admin_access_token: typing.Optional[str] = None,
- server: str = "",
-) -> typing.Optional[User]:
- """Create user by using the registration shared secret and generating token via API.
-
- Args:
- container: Container of the charm.
- username: username to be registered.
- admin: if user is admin.
- server: to be used to create the user id.
- admin_access_token: server admin access token to get user's access token if it exists.
-
- Returns:
- User or none if the creation fails.
- """
- registration_shared_secret = get_registration_shared_secret(container=container)
- if registration_shared_secret is None:
- logger.error("registration_shared_secret was not found, please check the logs")
- return None
- user = User(username=username, admin=admin)
- user.access_token = register_user(
- registration_shared_secret=registration_shared_secret,
- user=user,
- admin_access_token=admin_access_token,
- server=server,
- )
- return user
diff --git a/src/synapse/api.py b/src/synapse/api.py
index dc170dc1..bdfd497d 100644
--- a/src/synapse/api.py
+++ b/src/synapse/api.py
@@ -1,6 +1,6 @@
#!/usr/bin/env python3
-# Copyright 2024 Canonical Ltd.
+# Copyright 2025 Canonical Ltd.
# See LICENSE file for licensing details.
"""Helper module used to manage interactions with Synapse API."""
@@ -35,8 +35,6 @@
LIST_ROOMS_URL = f"{SYNAPSE_URL}/_synapse/admin/v1/rooms"
LIST_USERS_URL = f"{SYNAPSE_URL}/_synapse/admin/v2/users?from=0&limit=10&name="
LOGIN_URL = f"{SYNAPSE_URL}/_synapse/admin/v1/users"
-MJOLNIR_MANAGEMENT_ROOM = "management"
-MJOLNIR_MEMBERSHIP_ROOM = "moderators"
REGISTER_URL = f"{SYNAPSE_URL}/_synapse/admin/v1/register"
SYNAPSE_VERSION_REGEX = r"(\d+\.\d+\.\d+(?:\w+)?)\s?"
VERSION_URL = f"{SYNAPSE_URL}/_synapse/admin/v1/server_version"
@@ -177,63 +175,6 @@ def _do_request(
raise NetworkError(f"HTTP error from {url}.") from exc
-# admin_access_token is not a password
-def register_user(
- registration_shared_secret: str,
- user: User,
- server: typing.Optional[str] = None,
- admin_access_token: typing.Optional[str] = None, # nosec
-) -> str:
- """Register user.
-
- Args:
- registration_shared_secret: secret to be used to register the user.
- user: user to be registered.
- server: to be used to create the user id.
- admin_access_token: admin access token to get user's access token if it exists.
-
- Raises:
- RegisterUserError: if there was an error registering the user.
-
- Returns:
- Access token to be used by the user.
- """
- # get nonce
- nonce = _get_nonce()
- # generate mac
- hex_mac = _generate_mac(
- shared_secret=registration_shared_secret,
- nonce=nonce,
- user=user.username,
- password=user.password,
- admin=user.admin,
- )
- # build data
- data = {
- "nonce": nonce,
- "username": user.username,
- "password": user.password,
- "mac": hex_mac,
- "admin": user.admin,
- }
- # finally register user
- try:
- res = _do_request("POST", REGISTER_URL, json=data)
- return res.json()["access_token"]
- except UserExistsError as exc:
- logger.warning("User %s exists, getting the access token.", user.username)
- if not server or not admin_access_token:
- raise RegisterUserError(
- f"User {user.username} exists but there is no server/admin access token set."
- ) from exc
- return get_access_token(
- user=user, server=str(server), admin_access_token=str(admin_access_token)
- )
- except (requests.exceptions.JSONDecodeError, TypeError, KeyError) as exc:
- logger.exception("Failed to decode access_token: %r. Received: %s", exc, res.text)
- raise RegisterUserError(str(exc)) from exc
-
-
def _generate_mac( # pylint: disable=too-many-positional-arguments
shared_secret: str,
nonce: str,
@@ -331,35 +272,6 @@ def get_version(main_unit_address: str) -> str:
return version_match.group(1)
-def get_access_token(user: User, server: str, admin_access_token: str) -> str:
- """Get an access token that can be used to authenticate as that user.
-
- This is a way to do actions on behalf of a user.
-
- Args:
- user: the user on behalf of whom you want to request the access token.
- server: to be used to create the user id. User ID example: @user:server.com.
- admin_access_token: a server admin access token to be used for the request.
-
- Returns:
- Access token.
-
- Raises:
- GetAccessTokenError: if there was an error while getting access token.
- """
- # @user:server.com
- user_id = f"@{user.username}:{server}"
- res = _do_request(
- "POST", f"{LOGIN_URL}/{user_id}/login", admin_access_token=admin_access_token
- )
- try:
- res_access_token = res.json()["access_token"]
- except (requests.exceptions.JSONDecodeError, KeyError, TypeError) as exc:
- logger.exception("Failed to decode access_token: %r. Received: %s", exc, res.text)
- raise GetAccessTokenError(str(exc)) from exc
- return res_access_token
-
-
def override_rate_limit(user: User, admin_access_token: str, charm_state: CharmState) -> None:
"""Override user's rate limit.
@@ -412,78 +324,6 @@ def get_room_id(
return None
-def deactivate_user(
- user: User,
- server: str,
- admin_access_token: str,
-) -> None:
- """Deactivate user.
-
- Args:
- user: user to be deactivated.
- server: to be used to create the user id.
- admin_access_token: server admin access token to be used.
- """
- data = {
- "erase": True,
- }
- user_id = f"@{user.username}:{server}"
- url = f"{DEACTIVATE_ACCOUNT_URL}/{user_id}"
- _do_request("POST", url, admin_access_token=admin_access_token, json=data)
-
-
-def create_management_room(admin_access_token: str) -> str:
- """Create the management room to be used by Mjolnir.
-
- Args:
- admin_access_token: server admin access token to be used.
-
- Raises:
- GetRoomIDError: if there was an error while getting room id.
-
- Returns:
- Room id.
- """
- power_level_content_override = {"events_default": 0}
- moderators_room_id = get_room_id(MJOLNIR_MEMBERSHIP_ROOM, admin_access_token)
- data = {
- "name": MJOLNIR_MANAGEMENT_ROOM,
- "power_level_content_override": power_level_content_override,
- "room_alias_name": MJOLNIR_MANAGEMENT_ROOM,
- "visibility": "private",
- "initial_state": [
- # Always make history visibility shared
- {
- "type": "m.room.history_visibility",
- "state_key": "",
- "content": {"history_visibility": "shared"},
- },
- {
- "type": "m.room.guest_access",
- "state_key": "",
- "content": {"guest_access": "can_join"},
- },
- # Only retain the last 90 days of history
- {"type": "m.room.retention", "state_key": "", "content": {"max_lifetime": 604800000}},
- # Only users from MJOLNIR_MEMBERSHIP_ROOM can join
- {
- "type": "m.room.join_rules",
- "state_key": "",
- "content": {
- "join_rule": "restricted",
- "allow": [{"room_id": f"{moderators_room_id}", "type": "m.room_membership"}],
- },
- },
- ],
- }
- res = _do_request("POST", CREATE_ROOM_URL, admin_access_token=admin_access_token, json=data)
- try:
- return res.json()["room_id"]
- except (requests.exceptions.JSONDecodeError, TypeError, KeyError) as exc:
- logger.exception("Failed to decode room_id: %r. Received: %s", exc, res.text)
- raise GetRoomIDError(str(exc)) from exc
-
-
def make_room_admin(user: User, server: str, admin_access_token: str, room_id: str) -> None:
"""Make user a room's admin.
@@ -499,26 +339,6 @@ def make_room_admin(user: User, server: str, admin_access_token: str, room_id: s
_do_request("POST", url, admin_access_token=admin_access_token, json=data)
-def promote_user_admin(
- user: User,
- server: str,
- admin_access_token: str,
-) -> None:
- """Promote user to admin.
-
- Args:
- user: user to be promoted to admin.
- server: to be used to promote the user id.
- admin_access_token: server admin access token to be used.
- """
- data = {
- "admin": True,
- }
- user_id = f"@{user.username}:{server}"
- url = PROMOTE_USER_ADMIN_URL.replace("user_id", user_id)
- _do_request("PUT", url, admin_access_token=admin_access_token, json=data)
-
-
def is_token_valid(access_token: str) -> bool:
"""Check if the access token is valid making a request to whoami.
diff --git a/src/synapse/workload.py b/src/synapse/workload.py
index 4e6eb5ac..e03c094c 100644
--- a/src/synapse/workload.py
+++ b/src/synapse/workload.py
@@ -1,6 +1,6 @@
#!/usr/bin/env python3
-# Copyright 2024 Canonical Ltd.
+# Copyright 2025 Canonical Ltd.
# See LICENSE file for licensing details.
"""Helper module used to manage interactions with Synapse."""
@@ -16,18 +16,12 @@
from state.charm_state import CharmState
-from .api import SYNAPSE_URL
-
SYNAPSE_CONFIG_DIR = "/data"
CHECK_ALIVE_NAME = "synapse-alive"
-CHECK_MJOLNIR_READY_NAME = "synapse-mjolnir-ready"
CHECK_NGINX_READY_NAME = "synapse-nginx-ready"
CHECK_READY_NAME = "synapse-ready"
COMMAND_MIGRATE_CONFIG = "migrate_config"
-MJOLNIR_CONFIG_PATH = f"{SYNAPSE_CONFIG_DIR}/config/production.yaml"
-MJOLNIR_HEALTH_PORT = 7777
-MJOLNIR_SERVICE_NAME = "mjolnir"
SYNAPSE_EXPORTER_PORT = "9000"
STATS_EXPORTER_PORT = "9877"
SYNAPSE_COMMAND_PATH = "/start.py"
@@ -77,10 +71,6 @@ class EnableMetricsError(WorkloadError):
"""Exception raised when something goes wrong while enabling metrics."""
-class CreateMjolnirConfigError(WorkloadError):
- """Exception raised when something goes wrong while creating mjolnir config."""
-
-
class EnableSMTPError(WorkloadError):
"""Exception raised when something goes wrong while enabling SMTP."""
@@ -365,43 +355,6 @@ def generate_worker_config(unit_number: str, is_main: bool) -> dict:
return worker_config
-def _get_mjolnir_config(access_token: str, room_id: str) -> typing.Dict:
- """Get config as expected by mjolnir.
-
- Args:
- access_token: access token to be used by the mjolnir bot.
- room_id: management room id monitored by the Mjolnir.
-
- Returns:
- Mjolnir configuration
- """
- with open("templates/mjolnir_production.yaml", encoding="utf-8") as mjolnir_config_file:
- config = yaml.safe_load(mjolnir_config_file)
- config["homeserverUrl"] = SYNAPSE_URL
- config["rawHomeserverUrl"] = SYNAPSE_URL
- config["accessToken"] = access_token
- config["managementRoom"] = room_id
- return config
-
-
-def generate_mjolnir_config(container: ops.Container, access_token: str, room_id: str) -> None:
- """Generate mjolnir configuration.
-
- Args:
- container: Container of the charm.
- access_token: access token to be used by the Mjolnir.
- room_id: management room id monitored by the Mjolnir.
-
- Raises:
- CreateMjolnirConfigError: something went wrong creating mjolnir config.
- """
- try:
- config = _get_mjolnir_config(access_token, room_id)
- container.push(MJOLNIR_CONFIG_PATH, yaml.safe_dump(config), make_dirs=True)
- except ops.pebble.PathError as exc:
- raise CreateMjolnirConfigError(str(exc)) from exc
-
-
def create_registration_secrets_files(container: ops.Container, charm_state: CharmState) -> None:
"""Create registration secrets files.
diff --git a/src/synapse/workload_configuration.py b/src/synapse/workload_configuration.py
index 8c7c1e7a..c8ca5b47 100644
--- a/src/synapse/workload_configuration.py
+++ b/src/synapse/workload_configuration.py
@@ -1,6 +1,6 @@
#!/usr/bin/env python3
-# Copyright 2024 Canonical Ltd.
+# Copyright 2025 Canonical Ltd.
# See LICENSE file for licensing details.
"""Helper module used to manage interactions with Synapse homeserver configuration."""
diff --git a/src/user.py b/src/user.py
index 9b6d4710..bb678644 100644
--- a/src/user.py
+++ b/src/user.py
@@ -1,6 +1,6 @@
#!/usr/bin/env python3
-# Copyright 2024 Canonical Ltd.
+# Copyright 2025 Canonical Ltd.
# See LICENSE file for licensing details.
"""User class."""
diff --git a/synapse_rock/cron/cron.weekly/remote_content_empty_directory_cleanup.py b/synapse_rock/cron/cron.weekly/remote_content_empty_directory_cleanup.py
index 46097115..a3780885 100644
--- a/synapse_rock/cron/cron.weekly/remote_content_empty_directory_cleanup.py
+++ b/synapse_rock/cron/cron.weekly/remote_content_empty_directory_cleanup.py
@@ -1,5 +1,5 @@
#!/usr/bin/python3
-# Copyright 2024 Canonical Ltd.
+# Copyright 2025 Canonical Ltd.
# See LICENSE file for licensing details.
"""
diff --git a/synapse_rock/rockcraft.yaml b/synapse_rock/rockcraft.yaml
index 45713c9f..dbc6afed 100644
--- a/synapse_rock/rockcraft.yaml
+++ b/synapse_rock/rockcraft.yaml
@@ -1,4 +1,4 @@
-# Copyright 2024 Canonical Ltd.
+# Copyright 2025 Canonical Ltd.
# See LICENSE file for licensing details.
name: synapse
@@ -107,11 +107,10 @@ parts:
- xmlsec1
stage-snaps:
- aws-cli
- - mjolnir/latest/edge
plugin: nil
source: https://github.com/element-hq/synapse/
source-type: git
- source-tag: v1.120.2
+ source-tag: v1.121.1
build-environment:
- RUST_VERSION: "1.76.0"
- POETRY_VERSION: "1.7.1"
@@ -197,14 +196,13 @@ parts:
<<: *mas-source
source-subdir: frontend
build-environment:
- - NODE_URI: "https://nodejs.org/dist/v22.11.0/node-v22.11.0-linux-x64.tar.gz"
- - NODE_ENV: dev
+ - NODE_URI: "https://nodejs.org/dist/v20.18.1/node-v20.18.1-linux-x64.tar.gz"
override-build: |
curl -Ls $NODE_URI | tar xzf - -C /usr/ --skip-old-files --no-same-owner --strip-components=1
(cd $CRAFT_PART_BUILD/frontend; npm ci; npm run build)
mkdir -p $CRAFT_PART_INSTALL/mas/share/assets
cp frontend/dist/manifest.json $CRAFT_PART_INSTALL/mas/share/manifest.json
- cp -r frontend/dist/ $CRAFT_PART_INSTALL/mas/share/assets
+ cp -r frontend/dist/* $CRAFT_PART_INSTALL/mas/share/assets
stage:
- mas/share/*
mas-cli:
@@ -214,9 +212,13 @@ parts:
- "--features dist"
- "--no-default-features"
<<: *mas-source
+ build-snaps:
+ - go/1.22/stable
override-build: |
- curl -L -o opa https://openpolicyagent.org/downloads/v0.70.0/opa_linux_amd64_static
- chmod 755 ./opa; mv opa /usr/local/bin
+ # Build the open-policy-agent binary
+ # We build it here instead of in a separate part because opa is only needed during MAS build
+ git clone --depth 1 --branch v0.70.0 https://github.com/open-policy-agent/opa.git
+ (cd opa; make build; chmod +x opa_linux_amd64; mv opa_linux_amd64 /usr/local/bin/opa)
(cd policies; make)
mkdir -p $CRAFT_PART_INSTALL/mas/share
cp policies/policy.wasm $CRAFT_PART_INSTALL/mas/share/policy.wasm
diff --git a/synapse_rock/scripts/run_cron.py b/synapse_rock/scripts/run_cron.py
index a52f9c20..d4751e17 100644
--- a/synapse_rock/scripts/run_cron.py
+++ b/synapse_rock/scripts/run_cron.py
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
-# Copyright 2024 Canonical Ltd.
+# Copyright 2025 Canonical Ltd.
# See LICENSE file for licensing details.
"""
diff --git a/templates/mas_config.yaml.j2 b/templates/mas_config.yaml.j2
index a3c2c40a..16300d7a 100644
--- a/templates/mas_config.yaml.j2
+++ b/templates/mas_config.yaml.j2
@@ -70,4 +70,19 @@ upstream_oauth2:
email:
action: suggest
template: {{ '"{{ user.email }}"' }}
+{% endif %}
+{% if smtp_configuration is not none %}
+email:
+ from: '"{{ synapse_server_name_config }}" '
+ reply_to: '"No reply" '
+ transport: smtp
+{% if smtp_configuration.enable_tls %}
+ mode: tls
+{% else %}
+ mode: plain
+{% endif %}
+ hostname: {{ smtp_configuration.host }}
+ port: {{ smtp_configuration.port }}
+ username: {{ smtp_configuration.user }}
+ password: {{ smtp_configuration.password }}
{% endif %}
\ No newline at end of file
diff --git a/templates/mjolnir_production.yaml b/templates/mjolnir_production.yaml
deleted file mode 100644
index 18fb3833..00000000
--- a/templates/mjolnir_production.yaml
+++ /dev/null
@@ -1,35 +0,0 @@
-dataPath: "/data/storage"
-verboseLogging: false
-logLevel: "INFO"
-syncOnStartup: true
-verifyPermissionsOnStartup: true
-noop: false
-fasterMembershipChecks: false
-automaticallyRedactForReasons:
-- "spam"
-- "advertising"
-protectAllJoinedRooms: false
-backgroundDelayMS: 500
-health:
- healthz:
- enabled: true
- port: 7777
- address: "0.0.0.0"
- endpoint: "/healthz"
- healthyStatus: 200
- unhealthyStatus: 418
- sentry:
-pollReports: false
-displayReports: true
-web:
- enabled: true
- port: 9999
- address: "0.0.0.0"
- # A web API designed to intercept Matrix API
- # POST /_matrix/client/r0/rooms/{roomId}/report/{eventId}
- # and display readable abuse reports in the moderation room.
- #
- # If you wish to take advantage of this feature, you will need
- # to configure a reverse proxy, see e.g. test/nginx.conf
- abuseReporting:
- enabled: true
diff --git a/tests/__init__.py b/tests/__init__.py
index 289a5245..073ccfce 100644
--- a/tests/__init__.py
+++ b/tests/__init__.py
@@ -1,4 +1,4 @@
-# Copyright 2024 Canonical Ltd.
+# Copyright 2025 Canonical Ltd.
# See LICENSE file for licensing details.
"""Tests module."""
diff --git a/tests/conftest.py b/tests/conftest.py
index 4b4b9545..b9ce60be 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,4 +1,4 @@
-# Copyright 2024 Canonical Ltd.
+# Copyright 2025 Canonical Ltd.
# See LICENSE file for licensing details.
"""Fixtures for Synapse charm tests."""
diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py
index e3979c0f..dddb292a 100644
--- a/tests/integration/__init__.py
+++ b/tests/integration/__init__.py
@@ -1,2 +1,2 @@
-# Copyright 2024 Canonical Ltd.
+# Copyright 2025 Canonical Ltd.
# See LICENSE file for licensing details.
diff --git a/tests/integration/any_charm.py b/tests/integration/any_charm.py
new file mode 100644
index 00000000..b36bc2ac
--- /dev/null
+++ b/tests/integration/any_charm.py
@@ -0,0 +1,62 @@
+# Copyright 2025 Canonical Ltd.
+# See LICENSE file for licensing details.
+
+# pylint: disable=import-error,consider-using-with,no-member,too-few-public-methods
+
+"""This code should be loaded into any-charm which is used for integration tests."""
+
+import logging
+import typing
+
+from any_charm_base import AnyCharmBase
+from matrix_auth import MatrixAuthRequirerData, MatrixAuthRequires
+from ops.framework import Object
+from pydantic import SecretStr
+
+logger = logging.getLogger(__name__)
+
+
+class AnyCharm(AnyCharmBase):
+ """Execute a simple charm to test the relation."""
+
+ def __init__(self, *args, **kwargs):
+ """Initialize the charm and observe the relation events.
+
+ Args:
+ args: Arguments to pass to the parent class.
+ kwargs: Keyword arguments to pass to the parent class
+ """
+ super().__init__(*args, **kwargs)
+
+ self.plugin_auth = MatrixAuthRequires(self, relation_name="require-matrix-auth")
+ self.framework.observe(
+ self.plugin_auth.on.matrix_auth_request_processed,
+ self._on_matrix_auth_request_processed,
+ )
+
+ def _on_matrix_auth_request_processed(self, _: Object) -> None:
+ """Handle the matrix auth request processed event."""
+ logger.info("Matrix auth request processed")
+ content = """id: irc
+hs_token: 82c7a893d020b5f28eaf7ba31e1d1091b12ebafc5ceb1b6beac2b93defc1b301
+as_token: a66ae41f82b05bebfc9c259135ce1ce35c856000d542ab5d1f01e0212439d534
+namespaces:
+ users:
+ - exclusive: true
+ regex: '@irc_.*:yourhomeserverdomain'
+ aliases:
+ - exclusive: true
+ regex: '#irc_.*:yourhomeserverdomain'
+url: 'http://localhost:8090'
+sender_localpart: appservice-irc
+rate_limited: false
+protocols:
+ - irc"""
+ registration = typing.cast(SecretStr, content)
+ any_charm_data = MatrixAuthRequirerData(registration=registration)
+ relation = self.model.get_relation(self.plugin_auth.relation_name)
+ if relation:
+ logger.info("Matrix auth request setting relation data")
+ self.plugin_auth.update_relation_data(
+ relation=relation, matrix_auth_requirer_data=any_charm_data
+ )
diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py
index d743be20..314d92dd 100644
--- a/tests/integration/conftest.py
+++ b/tests/integration/conftest.py
@@ -1,10 +1,11 @@
-# Copyright 2024 Canonical Ltd.
+# Copyright 2025 Canonical Ltd.
# See LICENSE file for licensing details.
"""Fixtures for Synapse charm integration tests."""
import json
+import re
import typing
from secrets import token_hex
@@ -15,12 +16,14 @@
from juju.action import Action
from juju.application import Application
from juju.model import Model
-from ops.model import ActiveStatus
+from juju.unit import Unit
+from ops.model import ActiveStatus, BlockedStatus
from pytest import Config
from pytest_operator.plugin import OpsTest
+from auth.mas import MAS_CONFIGURATION_PATH
from tests.conftest import SYNAPSE_IMAGE_PARAM
-from tests.integration.helpers import get_access_token, register_user
+from tests.integration.helpers import register_user
# caused by pytest fixtures, mark does not work in fixtures
# pylint: disable=too-many-arguments, unused-argument
@@ -93,13 +96,11 @@ def synapse_app_charmhub_name_fixture() -> str:
async def synapse_app_fixture(
ops_test: OpsTest,
synapse_app_name: str,
- synapse_app_charmhub_name: str,
synapse_image: str,
model: Model,
server_name: str,
synapse_charm: str,
postgresql_app: Application,
- postgresql_app_name: str,
pytestconfig: Config,
):
"""Build and deploy the Synapse charm."""
@@ -113,14 +114,28 @@ async def synapse_app_fixture(
f"./{synapse_charm}",
resources=resources,
application_name=synapse_app_name,
- series="jammy",
config={"server_name": server_name},
)
+
+ await model.wait_for_idle(
+ apps=[synapse_app_name],
+ status=typing.cast(str, BlockedStatus.name),
+ idle_period=5,
+ )
+
async with ops_test.fast_forward():
- await model.relate(f"{synapse_app_name}:mas-database", f"{postgresql_app_name}")
- await model.wait_for_idle(status=ACTIVE_STATUS_NAME)
- await model.relate(f"{synapse_app_name}:database", f"{postgresql_app_name}")
- await model.wait_for_idle(status=ACTIVE_STATUS_NAME)
+ await model.relate(f"{synapse_app_name}:mas-database", f"{postgresql_app.name}")
+ await model.wait_for_idle(
+ apps=[synapse_app_name, postgresql_app.name],
+ status=ACTIVE_STATUS_NAME,
+ idle_period=5,
+ )
+ await model.relate(f"{synapse_app_name}:database", f"{postgresql_app.name}")
+ await model.wait_for_idle(
+ apps=[synapse_app_name, postgresql_app.name],
+ status=ACTIVE_STATUS_NAME,
+ idle_period=5,
+ )
return app
@@ -131,7 +146,6 @@ async def synapse_charmhub_app_fixture(
server_name: str,
synapse_app_charmhub_name: str,
postgresql_app: Application,
- postgresql_app_name: str,
synapse_charm: str,
):
"""Deploy synapse from Charmhub."""
@@ -145,14 +159,14 @@ async def synapse_charmhub_app_fixture(
config={"server_name": server_name},
)
await model.wait_for_idle(
- apps=[postgresql_app_name],
+ apps=[postgresql_app.name],
status=ACTIVE_STATUS_NAME,
idle_period=5,
)
- await model.relate(f"{synapse_app_charmhub_name}:mas-database", f"{postgresql_app_name}")
- await model.relate(f"{synapse_app_charmhub_name}:database", f"{postgresql_app_name}")
+ await model.relate(f"{synapse_app_charmhub_name}:mas-database", f"{postgresql_app.name}")
+ await model.relate(f"{synapse_app_charmhub_name}:database", f"{postgresql_app.name}")
await model.wait_for_idle(
- apps=[synapse_app_charmhub_name, postgresql_app_name],
+ apps=[synapse_app_charmhub_name, postgresql_app.name],
status=ACTIVE_STATUS_NAME,
idle_period=5,
)
@@ -250,30 +264,41 @@ def user_username_fixture() -> typing.Generator[str, None, None]:
yield token_hex(16)
-@pytest_asyncio.fixture(scope="module", name="user_password")
-async def user_password_fixture(synapse_app: Application, user_username: str) -> str:
+@pytest_asyncio.fixture(scope="module", name="user")
+async def user_fixture(synapse_app: Application, user_username: str) -> tuple[str, str]:
"""Register a user and return the new password.
Returns:
The new user password
"""
- return await register_user(synapse_app, user_username)
+ return (user_username, await register_user(synapse_app, user_username))
@pytest_asyncio.fixture(scope="module", name="access_token")
async def access_token_fixture(
- user_username: str,
- user_password: str,
+ user: tuple[str, str],
synapse_app: Application,
- get_unit_ips: typing.Callable[[str], typing.Awaitable[tuple[str, ...]]],
) -> str:
"""Return the access token after login with the username and password.
Returns:
The access token
"""
- synapse_ip = (await get_unit_ips(synapse_app.name))[0]
- return get_access_token(synapse_ip, user_username, user_password)
+ username, _ = user
+ pebble_exec_cmd = "PEBBLE_SOCKET=/charm/containers/synapse/pebble.socket pebble exec --"
+ generate_token_cmd = (
+ f"{pebble_exec_cmd} mas-cli -c {MAS_CONFIGURATION_PATH} manage issue-compatibility-token "
+ f"--yes-i-want-to-grant-synapse-admin-privileges {username}"
+ )
+ unit: Unit = synapse_app.units[0]
+ action = await unit.run(generate_token_cmd)
+ await action.wait()
+ assert action.results["return-code"] == 0
+
+ parsing_regex = r"Compatibility token issued: (?Pmct_.+) compat_access_token\.id"
+ parsed_output = re.search(parsing_regex, action.results["stderr"])
+ assert parsed_output is not None and parsed_output["token"]
+ return parsed_output["token"]
@pytest.fixture(scope="module", name="localstack_address")
diff --git a/tests/integration/helpers.py b/tests/integration/helpers.py
index 1ade47a6..e9d4969a 100644
--- a/tests/integration/helpers.py
+++ b/tests/integration/helpers.py
@@ -1,4 +1,4 @@
-# Copyright 2024 Canonical Ltd.
+# Copyright 2025 Canonical Ltd.
# See LICENSE file for licensing details.
"""Helper functions for integration tests."""
@@ -38,33 +38,6 @@ def create_moderators_room(
res.raise_for_status()
-def get_access_token(synapse_ip, user_username, user_password) -> str:
- """Get Access Token for Synapse given user and password
-
- Args:
- synapse_ip: Synapse IP
- user_username: username of the user to get the access_token
- user_password: password of the user to get the access_token
-
- Returns:
- The access token
- """
- sess = requests.session()
- res = sess.post(
- f"http://{synapse_ip}:8080/_matrix/client/r0/login",
- json={
- "identifier": {"type": "m.id.user", "user": user_username},
- "password": user_password,
- "type": "m.login.password",
- },
- timeout=5,
- )
- res.raise_for_status()
- access_token = res.json().get("access_token")
- assert access_token
- return access_token
-
-
async def register_user(synapse_app: Application, user_username: str) -> str:
"""Register a new user with admin permissions
diff --git a/tests/integration/test_charm.py b/tests/integration/test_charm.py
index e2aeb5d8..c1661bea 100644
--- a/tests/integration/test_charm.py
+++ b/tests/integration/test_charm.py
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
-# Copyright 2024 Canonical Ltd.
+# Copyright 2025 Canonical Ltd.
# See LICENSE file for licensing details.
"""Core integration tests for Synapse charm."""
@@ -11,6 +11,7 @@
import pytest
import requests
+import yaml
from juju.action import Action
from juju.application import Application
from juju.errors import JujuUnitError
@@ -20,7 +21,7 @@
from pytest_operator.plugin import OpsTest
import synapse
-from tests.integration.helpers import create_moderators_room, get_access_token, register_user
+from auth.mas import MAS_CONFIGURATION_PATH
# caused by pytest fixtures
# pylint: disable=too-many-arguments
@@ -184,8 +185,6 @@ async def test_workload_version(
async def test_synapse_enable_smtp(
model: Model,
synapse_app: Application,
- get_unit_ips: typing.Callable[[str], typing.Awaitable[tuple[str, ...]]],
- access_token: str,
relation_name: str,
):
"""
@@ -218,90 +217,17 @@ async def test_synapse_enable_smtp(
status=ACTIVE_STATUS_NAME,
)
- synapse_ip = (await get_unit_ips(synapse_app.name))[0]
- authorization_token = f"Bearer {access_token}"
- headers = {"Authorization": authorization_token}
- sample_check = {
- "client_secret": "this_is_my_secret_string",
- "email": "example@example.com",
- "id_server": "id.matrix.org",
- "send_attempt": "1",
- }
- sess = requests.session()
- res = sess.post(
- f"http://{synapse_ip}:8080/_matrix/client/r0/register/email/requestToken",
- json=sample_check,
- headers=headers,
- timeout=5,
- )
-
- assert res.status_code == 500
- # If the configuration change fails, will return something like:
- # "Email-based registration has been disabled on this server".
- # The expected error confirms that the e-mail is configured but failed since
- # is not a real SMTP server.
- assert "error was encountered when sending the email" in res.text
-
-
-async def test_promote_user_admin(
- synapse_app: Application,
- get_unit_ips: typing.Callable[[str], typing.Awaitable[tuple[str, ...]]],
-) -> None:
- """
- arrange: build and deploy the Synapse charm, create an user, get the access token and assert
- that the user is not an admin.
- act: run action to promote user to admin.
- assert: the Synapse application is active and the API request returns as expected.
- """
- operator_username = "operator"
- action_register_user: Action = await synapse_app.units[0].run_action( # type: ignore
- "register-user", username=operator_username, admin=False
- )
- await action_register_user.wait()
- assert action_register_user.status == "completed"
- password = action_register_user.results["user-password"]
- synapse_ip = (await get_unit_ips(synapse_app.name))[0]
- sess = requests.session()
- res = sess.post(
- f"http://{synapse_ip}:8080/_matrix/client/r0/login",
- # same thing is done on fixture but we are creating a non-admin user here.
- json={ # pylint: disable=duplicate-code
- "identifier": {"type": "m.id.user", "user": operator_username},
- "password": password,
- "type": "m.login.password",
- },
- timeout=5,
- )
- res.raise_for_status()
- access_token = res.json()["access_token"]
- authorization_token = f"Bearer {access_token}"
- headers = {"Authorization": authorization_token}
- # List Accounts is a request that only admins can perform.
- res = sess.get(
- f"http://{synapse_ip}:8080/_synapse/admin/v2/users?from=0&limit=10&guests=false",
- headers=headers,
- timeout=5,
- )
- assert res.status_code == 403
-
- action_promote: Action = await synapse_app.units[0].run_action( # type: ignore
- "promote-user-admin", username=operator_username
- )
- await action_promote.wait()
- assert action_promote.status == "completed"
-
- res = sess.get(
- f"http://{synapse_ip}:8080/_synapse/admin/v2/users?from=0&limit=10&guests=false",
- headers=headers,
- timeout=5,
- )
- assert res.status_code == 200
+ pebble_exec_cmd = "PEBBLE_SOCKET=/charm/containers/synapse/pebble.socket pebble exec --"
+ dump_mas_config_cmd = f"{pebble_exec_cmd} mas-cli -c {MAS_CONFIGURATION_PATH} config dump"
+ unit: Unit = synapse_app.units[0]
+ action = await unit.run(dump_mas_config_cmd)
+ await action.wait()
+ assert action.results["return-code"] == 0
+ mas_config = yaml.safe_load(action.results["stdout"])
+ assert mas_config["email"]["hostname"] == "127.0.0.1"
-async def test_anonymize_user(
- synapse_app: Application,
- get_unit_ips: typing.Callable[[str], typing.Awaitable[tuple[str, ...]]],
-) -> None:
+async def test_anonymize_user(synapse_app: Application) -> None:
"""
arrange: build and deploy the Synapse charm, create an user, get the access token and assert
that the user is not an admin.
@@ -315,20 +241,6 @@ async def test_anonymize_user(
)
await action_register_user.wait()
assert action_register_user.status == "completed"
- password = action_register_user.results["user-password"]
- synapse_ip = (await get_unit_ips(synapse_app.name))[0]
- with requests.session() as sess:
- res = sess.post(
- f"http://{synapse_ip}:8080/_matrix/client/r0/login",
- # same thing is done on fixture but we are creating a non-admin user here.
- json={ # pylint: disable=duplicate-code
- "identifier": {"type": "m.id.user", "user": operator_username},
- "password": password,
- "type": "m.login.password",
- },
- timeout=5,
- )
- res.raise_for_status()
action_anonymize: Action = await synapse_unit.run_action(
"anonymize-user", username=operator_username
@@ -336,19 +248,6 @@ async def test_anonymize_user(
await action_anonymize.wait()
assert action_anonymize.status == "completed"
- with requests.session() as sess:
- res = sess.post(
- f"http://{synapse_ip}:8080/_matrix/client/r0/login",
- # same thing is done on fixture but we are creating a non-admin user here.
- json={ # pylint: disable=duplicate-code
- "identifier": {"type": "m.id.user", "user": operator_username},
- "password": password,
- "type": "m.login.password",
- },
- timeout=5,
- )
- assert res.status_code == 403
-
@pytest.mark.usefixtures("synapse_app")
async def test_nginx_route_integration(
@@ -373,84 +272,3 @@ async def test_nginx_route_integration(
)
assert response.status_code == 200
assert "Welcome to the Matrix" in response.text
-
-
-@pytest.mark.usefixtures("postgresql_app")
-@pytest.mark.mjolnir
-async def test_synapse_enable_mjolnir(
- ops_test: OpsTest,
- synapse_app: Application,
- access_token: str,
- get_unit_ips: typing.Callable[[str], typing.Awaitable[tuple[str, ...]]],
-):
- """
- arrange: build and deploy the Synapse charm, create an user, get the access token,
- enable Mjolnir and create the management room.
- act: check Mjolnir health point.
- assert: the Synapse application is active and Mjolnir health point returns a correct response.
- """
- await synapse_app.set_config({"enable_mjolnir": "true"})
- await synapse_app.model.wait_for_idle(
- idle_period=30, timeout=120, apps=[synapse_app.name], status="blocked"
- )
- synapse_ip = (await get_unit_ips(synapse_app.name))[0]
- create_moderators_room(synapse_ip, access_token)
- async with ops_test.fast_forward():
- # using fast_forward otherwise would wait for model config update-status-hook-interval
- await synapse_app.model.wait_for_idle(
- idle_period=30, apps=[synapse_app.name], status="active"
- )
-
- res = requests.get(f"http://{synapse_ip}:{synapse.MJOLNIR_HEALTH_PORT}/healthz", timeout=5)
-
- assert res.status_code == 200
-
-
-# pylint: disable=too-many-positional-arguments
-@pytest.mark.usefixtures("postgresql_app")
-@pytest.mark.mjolnir
-async def test_synapse_with_mjolnir_from_refresh_is_up(
- ops_test: OpsTest,
- model: Model,
- synapse_charmhub_app: Application,
- get_unit_ips: typing.Callable[[str], typing.Awaitable[tuple[str, ...]]],
- synapse_charm: str,
- synapse_image: str,
-):
- """
- arrange: build and deploy the Synapse charm from charmhub and enable Mjolnir.
- act: Refresh the charm with the local one.
- assert: Synapse and Mjolnir health points should return correct responses.
- """
- await synapse_charmhub_app.set_config({"enable_mjolnir": "true"})
- await model.wait_for_idle(apps=[synapse_charmhub_app.name], status="blocked")
- synapse_ip = (await get_unit_ips(synapse_charmhub_app.name))[0]
- user_username = token_hex(16)
- user_password = await register_user(synapse_charmhub_app, user_username)
- access_token = get_access_token(synapse_ip, user_username, user_password)
- create_moderators_room(synapse_ip, access_token)
- async with ops_test.fast_forward():
- await synapse_charmhub_app.model.wait_for_idle(
- idle_period=30, apps=[synapse_charmhub_app.name], status="active"
- )
-
- resources = {
- "synapse-image": synapse_image,
- }
- await synapse_charmhub_app.refresh(path=f"./{synapse_charm}", resources=resources)
- async with ops_test.fast_forward():
- await synapse_charmhub_app.model.wait_for_idle(
- idle_period=30, apps=[synapse_charmhub_app.name], status="active"
- )
- # Unit ip could change because it is a different pod.
- synapse_ip = (await get_unit_ips(synapse_charmhub_app.name))[0]
- response = requests.get(
- f"http://{synapse_ip}:{synapse.SYNAPSE_NGINX_PORT}/_matrix/static/", timeout=5
- )
- assert response.status_code == 200
- assert "Welcome to the Matrix" in response.text
-
- mjolnir_response = requests.get(
- f"http://{synapse_ip}:{synapse.MJOLNIR_HEALTH_PORT}/healthz", timeout=5
- )
- assert mjolnir_response.status_code == 200
diff --git a/tests/integration/test_matrix_auth.py b/tests/integration/test_matrix_auth.py
new file mode 100644
index 00000000..f2cf2968
--- /dev/null
+++ b/tests/integration/test_matrix_auth.py
@@ -0,0 +1,89 @@
+#!/usr/bin/env python3
+# Copyright 2025 Canonical Ltd.
+# See LICENSE file for licensing details.
+
+"""Matrix-auth integration tests for Synapse charm."""
+import json
+import logging
+import pathlib
+import typing
+
+from juju.application import Application
+from juju.controller import Controller # type: ignore
+from juju.model import Model
+from juju.unit import Unit
+from ops.model import ActiveStatus
+from pytest_operator.plugin import OpsTest
+
+# caused by pytest fixtures, mark does not work in fixtures
+# pylint: disable=too-many-arguments, unused-argument
+
+# mypy has trouble to inferred types for variables that are initialized in subclasses.
+ACTIVE_STATUS_NAME = typing.cast(str, ActiveStatus.name) # type: ignore
+
+logger = logging.getLogger(__name__)
+
+
+async def test_synapse_cmr_matrix_auth(
+ ops_test: OpsTest,
+ model: Model,
+ synapse_app: Application,
+):
+ """
+ arrange: deploy the Synapse charm, create offer, deploy any-charm as consumer
+ in a different model and consume offer.
+ act: integrate them via matrix-auth offer.
+ assert: Synapse set the registration file received via matrix-auth.
+ """
+ await model.wait_for_idle(idle_period=10, status=ACTIVE_STATUS_NAME)
+ # This workaround was extracted from prometheus-k8s charm.
+ # Without it, the offer creation fails.
+ # https://github.com/canonical/prometheus-k8s-operator/blob/5779ecc749ee1582c6be20030a83472d024cd24f/tests/integration/test_remote_write_with_zinc.py#L103
+ controller = Controller()
+ await controller.connect()
+ await controller.create_offer(
+ model.uuid,
+ f"{synapse_app.name}:matrix-auth",
+ )
+ offers = await controller.list_offers(model.name)
+ await model.block_until(
+ lambda: all(offer.application_name == synapse_app.name for offer in offers.results)
+ )
+ await model.wait_for_idle(idle_period=10, status=ACTIVE_STATUS_NAME)
+ await ops_test.track_model(
+ "consumer",
+ )
+ with ops_test.model_context("consumer") as consumer_model:
+ any_charm_content = pathlib.Path("tests/integration/any_charm.py").read_text(
+ encoding="utf-8"
+ )
+ matrix_auth_content = pathlib.Path("lib/charms/synapse/v1/matrix_auth.py").read_text(
+ encoding="utf-8"
+ )
+ any_charm_src_overwrite = {
+ "any_charm.py": any_charm_content,
+ "matrix_auth.py": matrix_auth_content,
+ }
+ any_charm_app = await consumer_model.deploy(
+ "any-charm",
+ application_name="any-charm1",
+ channel="beta",
+ config={
+ "python-packages": "pydantic\ncryptography",
+ "src-overwrite": json.dumps(any_charm_src_overwrite),
+ },
+ )
+ await consumer_model.wait_for_idle(apps=[any_charm_app.name])
+ await consumer_model.consume(f"admin/{model.name}.{synapse_app.name}", "synapse")
+
+ await consumer_model.relate(any_charm_app.name, "synapse")
+ await consumer_model.wait_for_idle(idle_period=30, status=ACTIVE_STATUS_NAME)
+
+ unit: Unit = synapse_app.units[0]
+ ret_code, _, stderr = await ops_test.juju(
+ "exec",
+ "--unit",
+ unit.name,
+ "grep appservice-irc /data/appservice-registration-matrix-auth-*.yaml",
+ )
+ assert not ret_code, f"Failed to check for application service file, {stderr}"
diff --git a/tests/integration/test_s3.py b/tests/integration/test_s3.py
index df9b7642..93d49428 100644
--- a/tests/integration/test_s3.py
+++ b/tests/integration/test_s3.py
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
-# Copyright 2024 Canonical Ltd.
+# Copyright 2025 Canonical Ltd.
# See LICENSE file for licensing details.
"""Integration tests for Synapse charm needing the s3_backup_bucket fixture."""
diff --git a/tests/integration/test_scaling.py b/tests/integration/test_scaling.py
index 96e60cee..2b082aa0 100644
--- a/tests/integration/test_scaling.py
+++ b/tests/integration/test_scaling.py
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
-# Copyright 2024 Canonical Ltd.
+# Copyright 2025 Canonical Ltd.
# See LICENSE file for licensing details.
"""Integration tests for Synapse charm integrated with Redis."""
@@ -43,15 +43,13 @@ async def test_synapse_scaling_nginx_configured(
)
assert ops_test.model
status = await ops_test.model.get_status()
- unit = list(status.applications[synapse_app.name].units)[1]
+ application = typing.cast(Application, status.applications[synapse_app.name])
+ unit = list(application.units)[1]
address = status["applications"][synapse_app.name]["units"][unit]["address"]
- logger.info("Units: %s", list(status.applications[synapse_app.name].units))
- logger.info("Requesting %s", f"http://{address}:8008/")
response_worker = requests.get(
f"http://{address}:8008/", headers={"Host": synapse_app.name}, timeout=5
)
- logger.info("Requesting %s", f"http://{address}:8080/")
response_nginx = requests.get(
f"http://{address}:8080/", headers={"Host": synapse_app.name}, timeout=5
)
@@ -83,7 +81,8 @@ async def test_synapse_scaling_down(
)
assert ops_test.model
status = await ops_test.model.get_status()
- for unit in list(status.applications[synapse_app.name].units):
+ application = typing.cast(Application, status.applications[synapse_app.name])
+ for unit in list(application.units):
address = status["applications"][synapse_app.name]["units"][unit]["address"]
response_worker = requests.get(
f"http://{address}:8080/", headers={"Host": synapse_app.name}, timeout=5
@@ -99,7 +98,8 @@ async def test_synapse_scaling_down(
)
assert ops_test.model
status = await ops_test.model.get_status()
- for unit in list(status.applications[synapse_app.name].units):
+ application = typing.cast(Application, status.applications[synapse_app.name])
+ for unit in list(application.units):
address = status["applications"][synapse_app.name]["units"][unit]["address"]
response_worker = requests.get(
f"http://{address}:8080/", headers={"Host": synapse_app.name}, timeout=5
diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py
index e3979c0f..dddb292a 100644
--- a/tests/unit/__init__.py
+++ b/tests/unit/__init__.py
@@ -1,2 +1,2 @@
-# Copyright 2024 Canonical Ltd.
+# Copyright 2025 Canonical Ltd.
# See LICENSE file for licensing details.
diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py
index 3caae697..132bc316 100644
--- a/tests/unit/conftest.py
+++ b/tests/unit/conftest.py
@@ -1,4 +1,4 @@
-# Copyright 2024 Canonical Ltd.
+# Copyright 2025 Canonical Ltd.
# See LICENSE file for licensing details.
"""pytest fixtures for the unit test."""
@@ -118,15 +118,12 @@ def register_command_handler(
def harness_fixture(request, monkeypatch) -> typing.Generator[Harness, None, None]:
"""Ops testing framework harness fixture."""
monkeypatch.setattr(synapse, "get_version", lambda *_args, **_kwargs: "")
- monkeypatch.setattr(synapse, "create_admin_user", lambda *_args, **_kwargs: "")
monkeypatch.setattr(time, "sleep", lambda *_args, **_kwargs: "")
# Assume that MAS is working properly
monkeypatch.setattr(
"state.mas.MASConfiguration.from_charm", MagicMock(return_value=MagicMock())
)
- monkeypatch.setattr(
- "charm.generate_oauth_client_config", MagicMock(return_value=None)
- )
+ monkeypatch.setattr("charm.generate_oauth_client_config", MagicMock(return_value=None))
monkeypatch.setattr("pebble._push_mas_config", MagicMock())
monkeypatch.setattr("charm.generate_mas_config", MagicMock(return_value=""))
monkeypatch.setattr("charm.generate_synapse_msc3861_config", MagicMock(return_value={}))
diff --git a/tests/unit/test_action.py b/tests/unit/test_action.py
index 0eaff8bd..1dd5d834 100644
--- a/tests/unit/test_action.py
+++ b/tests/unit/test_action.py
@@ -1,4 +1,4 @@
-# Copyright 2024 Canonical Ltd.
+# Copyright 2025 Canonical Ltd.
# See LICENSE file for licensing details.
"""Register user action unit tests."""
@@ -14,7 +14,7 @@
from ops.charm import ActionEvent
from ops.testing import Harness
-from auth.mas import MASVerifyUserEmailFailedError, verify_user_email
+import synapse
from user import User
@@ -42,6 +42,42 @@ def test_register_user_action(harness: Harness) -> None:
assert isinstance(harness.model.unit.status, ops.ActiveStatus)
+def test_register_user_action_pebble_exec_error(
+ harness: Harness, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ """
+ arrange: Given a mocked synapse container with an exec method that raises ExecError.
+ act: run verify_user_email.
+ assert: The correct exception is raised.
+ """
+ harness.begin_with_initial_hooks()
+ event = unittest.mock.MagicMock(spec=ActionEvent)
+ event.params = {"username": "username", "admin": "no"}
+ container = harness.model.unit.get_container(synapse.SYNAPSE_CONTAINER_NAME)
+ monkeypatch.setattr(
+ container, "exec", MagicMock(side_effect=ops.pebble.ExecError([], 1, "", ""))
+ )
+ harness.charm._on_register_user_action(event)
+ assert event.fail.call_count == 1
+
+
+def test_register_user_action_action_container_not_ready(
+ harness: Harness, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ """
+ arrange: Given a mocked synapse container with an exec method that raises ExecError.
+ act: run verify_user_email.
+ assert: The correct exception is raised.
+ """
+ harness.begin_with_initial_hooks()
+ event = unittest.mock.MagicMock(spec=ActionEvent)
+ event.params = {"username": "username", "admin": "no"}
+ container = harness.model.unit.get_container(synapse.SYNAPSE_CONTAINER_NAME)
+ monkeypatch.setattr(container, "can_connect", MagicMock(return_value=False))
+ harness.charm._on_register_user_action(event)
+ assert event.fail.call_count == 1
+
+
def test_username_empty():
"""
arrange: create a user.
@@ -59,14 +95,9 @@ def test_verify_user_email_action(harness: Harness) -> None:
assert: User is created and the charm is active.
"""
harness.begin_with_initial_hooks()
- user = "username"
event = unittest.mock.MagicMock(spec=ActionEvent)
- event.params = {
- "username": user,
- "email": "user@email.com",
- }
+ event.params = {"username": "username", "email": "user@email.com"}
- # Calling to test the action since is not possible calling via harness
harness.charm._on_verify_user_email_action(event)
assert event.set_results.call_count == 1
@@ -74,13 +105,88 @@ def test_verify_user_email_action(harness: Harness) -> None:
assert isinstance(harness.model.unit.status, ops.ActiveStatus)
-def test_verify_user_email_action_pebble_exec_error() -> None:
+def test_verify_user_email_action_pebble_exec_error(
+ harness: Harness, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ """
+ arrange: Given a mocked synapse container with an exec method that raises ExecError.
+ act: run verify_user_email.
+ assert: The correct exception is raised.
+ """
+ harness.begin_with_initial_hooks()
+ event = unittest.mock.MagicMock(spec=ActionEvent)
+ event.params = {"username": "username", "email": "user@email.com"}
+ container = harness.model.unit.get_container(synapse.SYNAPSE_CONTAINER_NAME)
+ monkeypatch.setattr(
+ container, "exec", MagicMock(side_effect=ops.pebble.ExecError([], 1, "", ""))
+ )
+ harness.charm._on_verify_user_email_action(event)
+ assert event.fail.call_count == 1
+
+
+def test_verify_user_email_action_container_not_ready(
+ harness: Harness, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ """
+ arrange: Given a mocked synapse container with an exec method that raises ExecError.
+ act: run verify_user_email.
+ assert: The correct exception is raised.
+ """
+ harness.begin_with_initial_hooks()
+ event = unittest.mock.MagicMock(spec=ActionEvent)
+ event.params = {"username": "username", "email": "user@email.com"}
+ container = harness.model.unit.get_container(synapse.SYNAPSE_CONTAINER_NAME)
+ monkeypatch.setattr(container, "can_connect", MagicMock(return_value=False))
+ harness.charm._on_verify_user_email_action(event)
+ assert event.fail.call_count == 1
+
+
+def test_anonymize_user_action(harness: Harness) -> None:
+ """
+ arrange: start the Synapse charm, set Synapse container to be ready and set server_name.
+ act: run anonymize-user action.
+ assert: User is deactivated and the charm is active.
+ """
+ harness.begin_with_initial_hooks()
+ event = unittest.mock.MagicMock(spec=ActionEvent)
+ event.params = {"username": "username"}
+ harness.charm._on_anonymize_user_action(event)
+ assert event.set_results.call_count == 1
+ event.set_results.assert_called_with({"anonymize-user": True})
+ assert isinstance(harness.model.unit.status, ops.ActiveStatus)
+
+
+def test_anonymize_user_action_pebble_exec_error(
+ harness: Harness, monkeypatch: pytest.MonkeyPatch
+) -> None:
"""
arrange: Given a mocked synapse container with an exec method that raises ExecError.
act: run verify_user_email.
assert: The correct exception is raised.
"""
- container = MagicMock()
- container.exec = MagicMock(side_effect=ops.pebble.ExecError([], 1, "", ""))
- with pytest.raises(MASVerifyUserEmailFailedError):
- verify_user_email(container, "mock_user", "mock_email")
+ harness.begin_with_initial_hooks()
+ event = unittest.mock.MagicMock(spec=ActionEvent)
+ event.params = {"username": "username"}
+ container = harness.model.unit.get_container(synapse.SYNAPSE_CONTAINER_NAME)
+ monkeypatch.setattr(
+ container, "exec", MagicMock(side_effect=ops.pebble.ExecError([], 1, "", ""))
+ )
+ harness.charm._on_anonymize_user_action(event)
+ assert event.fail.call_count == 1
+
+
+def test_anonymize_user_action_container_not_ready(
+ harness: Harness, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ """
+ arrange: Given a mocked synapse container with an exec method that raises ExecError.
+ act: run verify_user_email.
+ assert: The correct exception is raised.
+ """
+ harness.begin_with_initial_hooks()
+ event = unittest.mock.MagicMock(spec=ActionEvent)
+ event.params = {"username": "username"}
+ container = harness.model.unit.get_container(synapse.SYNAPSE_CONTAINER_NAME)
+ monkeypatch.setattr(container, "can_connect", MagicMock(return_value=False))
+ harness.charm._on_anonymize_user_action(event)
+ assert event.fail.call_count == 1
diff --git a/tests/unit/test_admin_access_token.py b/tests/unit/test_admin_access_token.py
deleted file mode 100644
index ba3a3206..00000000
--- a/tests/unit/test_admin_access_token.py
+++ /dev/null
@@ -1,134 +0,0 @@
-# Copyright 2024 Canonical Ltd.
-# See LICENSE file for licensing details.
-
-"""Admin Access Token unit tests."""
-
-# pylint: disable=protected-access
-
-from secrets import token_hex
-from unittest.mock import MagicMock, patch
-
-import ops
-import pytest
-from ops.testing import Harness
-
-import admin_access_token
-import synapse
-
-
-@patch("admin_access_token.JUJU_HAS_SECRETS", True)
-@patch.object(ops.Application, "add_secret")
-def test_get_admin_access_token_with_secrets(
- mock_add_secret, harness: Harness, monkeypatch: pytest.MonkeyPatch
-) -> None:
- """
- arrange: start the Synapse charm, mock register_user and add_secret.
- act: get admin access token.
- assert: admin user is created, secret is created and the token is retrieved.
- """
- harness.begin_with_initial_hooks()
- # Mocking like the following doesn't get evaluated as expected
- # mock_juju_env.return_value = MagicMock(has_secrets=True)
- secret_mock = MagicMock
- secret_id = token_hex(16)
- secret_mock.id = secret_id
- mock_add_secret.return_value = secret_mock
- user_mock = MagicMock()
- admin_access_token_expected = token_hex(16)
- user_mock.access_token = admin_access_token_expected
- create_admin_user_mock = MagicMock(return_value=user_mock)
- monkeypatch.setattr(synapse, "create_admin_user", create_admin_user_mock)
- monkeypatch.setattr(synapse, "is_token_valid", MagicMock(return_value=True))
-
- admin_access_token_real = harness.charm.token_service.get(MagicMock)
-
- create_admin_user_mock.assert_called_once()
- mock_add_secret.assert_called_once()
- assert admin_access_token_real == admin_access_token_expected
- peer_relation = harness.model.get_relation(synapse.SYNAPSE_PEER_RELATION_NAME)
- assert peer_relation
- assert (
- harness.get_relation_data(peer_relation.id, harness.charm.app.name).get("secret-id")
- == secret_id
- )
- assert isinstance(harness.model.unit.status, ops.ActiveStatus)
-
-
-@patch("admin_access_token.JUJU_HAS_SECRETS", False)
-@patch.object(ops.Application, "add_secret")
-def test_get_admin_access_token_no_secrets(
- mock_add_secret, harness: Harness, monkeypatch: pytest.MonkeyPatch
-) -> None:
- """
- arrange: start the Synapse charm, mock register_user and add_secret.
- act: get admin access token.
- assert: admin user is created, relation is updated and the token is
- retrieved.
- """
- harness.begin_with_initial_hooks()
- # Mocking like the following doesn't get evaluated as expected
- # mock_juju_env.return_value = MagicMock(has_secrets=True)
- user_mock = MagicMock()
- admin_access_token_expected = token_hex(16)
- user_mock.access_token = admin_access_token_expected
- create_admin_user_mock = MagicMock(return_value=user_mock)
- monkeypatch.setattr(synapse, "create_admin_user", create_admin_user_mock)
- monkeypatch.setattr(synapse, "is_token_valid", MagicMock(return_value=True))
-
- admin_access_token_real = harness.charm.token_service.get(MagicMock)
-
- create_admin_user_mock.assert_called_once()
- mock_add_secret.assert_not_called()
- assert admin_access_token_real == admin_access_token_expected
- peer_relation = harness.model.get_relation(synapse.SYNAPSE_PEER_RELATION_NAME)
- assert peer_relation
- assert (
- harness.get_relation_data(peer_relation.id, harness.charm.app.name).get("secret-key")
- == admin_access_token_expected
- )
- assert isinstance(harness.model.unit.status, ops.ActiveStatus)
-
-
-@pytest.mark.parametrize(
- "juju_has_secrets,is_token_valid",
- [(juju_secret, token_valid) for juju_secret in [True, False] for token_valid in [True, False]],
-)
-def test_get_admin_access_with_refresh(
- juju_has_secrets: bool, is_token_valid: bool, harness: Harness, monkeypatch: pytest.MonkeyPatch
-) -> None:
- """
- arrange: start Synapse charm. mock create_admin_user and is_token_valid to return True/False.
- get an admin access token. is_token_valid should not be called yet, and a token
- should be returned.
- act: call get another admin access token.
- assert: is_token_valid should be called, and a new token should be returned if is_token_valid
- was False, otherwise it should be the initial token.
- """
- initial_token = token_hex(16)
- initial_user_mock = MagicMock()
- initial_user_mock.access_token = initial_token
-
- token_refreshed = token_hex(16)
- refreshed_user_mock = MagicMock()
- refreshed_user_mock.access_token = token_refreshed
-
- create_admin_user_mock = MagicMock(side_effect=[initial_user_mock, refreshed_user_mock])
- monkeypatch.setattr(synapse, "create_admin_user", create_admin_user_mock)
- is_token_valid_mock = MagicMock(return_value=is_token_valid)
- monkeypatch.setattr(synapse, "is_token_valid", is_token_valid_mock)
- monkeypatch.setattr(admin_access_token, "JUJU_HAS_SECRETS", juju_has_secrets)
-
- # Get admin access token
- harness.begin_with_initial_hooks()
- monkeypatch.setattr(synapse, "create_admin_user", create_admin_user_mock)
- initial_admin_access_token = harness.charm.token_service.get(MagicMock)
- is_token_valid_mock.assert_not_called()
- assert initial_admin_access_token == initial_token
-
- # Get admin access token. Should not be refreshed if it is valid.
- refreshed_admin_access_token = harness.charm.token_service.get(MagicMock)
-
- is_token_valid_mock.assert_called_once()
- assert refreshed_admin_access_token == (
- initial_token if is_token_valid else refreshed_admin_access_token
- )
diff --git a/tests/unit/test_admin_create_user.py b/tests/unit/test_admin_create_user.py
deleted file mode 100644
index a43f1310..00000000
--- a/tests/unit/test_admin_create_user.py
+++ /dev/null
@@ -1,75 +0,0 @@
-# Copyright 2024 Canonical Ltd.
-# See LICENSE file for licensing details.
-
-"""Tests for the create_user function in the synapse.admin module."""
-
-from secrets import token_hex
-from unittest.mock import MagicMock, patch
-
-from ops.testing import Harness
-
-import synapse
-from synapse.admin import create_user
-
-
-def test_create_user_success(harness: Harness, mocked_synapse_calls):
- """
- arrange: start the Synapse charm and set the necessary parameters.
- act: call the create_user function.
- assert: user is created successfully and the access token is generated.
- """
- # pylint: disable=unused-argument
- harness.begin_with_initial_hooks()
- container = harness.model.unit.containers["synapse"]
- username = "test_user"
- admin = True
- admin_access_token = token_hex(16)
- server = "test_server"
-
- with patch("synapse.api._do_request") as mock_request:
- mock_request.return_value.status_code = 200
- mock_request.return_value.json.return_value = {
- "access_token": "access_token",
- "nonce": "sense",
- }
- user = create_user(
- container=container,
- username=username,
- admin=admin,
- admin_access_token=admin_access_token,
- server=server,
- )
-
- assert user is not None
- assert user.username == username
- assert user.admin == admin
- assert user.access_token is not None
-
-
-def test_create_user_no_shared_secret(harness: Harness, monkeypatch):
- """
- arrange: start the Synapse charm without the registration shared secret.
- act: call the create_user function.
- assert: user creation fails and None is returned.
- """
- harness.begin_with_initial_hooks()
- container = harness.model.unit.containers["synapse"]
- username = "test_user"
- admin = True
- admin_access_token = token_hex(16)
- server = "test_server"
-
- monkeypatch.setattr(
- synapse.workload, "get_registration_shared_secret", MagicMock(return_value=None)
- )
- monkeypatch.setattr(synapse.workload, "_get_configuration_field", MagicMock(return_value=None))
-
- user = create_user(
- container=container,
- username=username,
- admin=admin,
- admin_access_token=admin_access_token,
- server=server,
- )
-
- assert user is None
diff --git a/tests/unit/test_anonymize_user_action.py b/tests/unit/test_anonymize_user_action.py
deleted file mode 100644
index 7d55a1f3..00000000
--- a/tests/unit/test_anonymize_user_action.py
+++ /dev/null
@@ -1,133 +0,0 @@
-# Copyright 2024 Canonical Ltd.
-# See LICENSE file for licensing details.
-
-"""Register user action unit tests."""
-
-# Disabled to access _on_register_user_action
-# Disabled R0801 because has similar code to test_promote_user_admin_action
-# pylint: disable=protected-access, R0801
-
-import unittest.mock
-from secrets import token_hex
-
-import pytest
-from ops.charm import ActionEvent
-from ops.testing import Harness
-
-import synapse
-
-
-def test_anonymize_user_action(harness: Harness, monkeypatch: pytest.MonkeyPatch) -> None:
- """
- arrange: start the Synapse charm, set Synapse container to be ready and set server_name.
- act: run anonymize-user action.
- assert: event results are returned as expected.
- """
- harness.begin_with_initial_hooks()
- admin_access_token = token_hex(16)
- anonymize_user_mock = unittest.mock.Mock()
- monkeypatch.setattr(
- harness.charm.token_service,
- "get",
- unittest.mock.MagicMock(return_value=admin_access_token),
- )
- monkeypatch.setattr("synapse.deactivate_user", anonymize_user_mock)
- user = "username"
- admin = True
- event = unittest.mock.MagicMock(spec=ActionEvent)
- event.params = {
- "username": user,
- "admin": admin,
- }
-
- harness.charm._on_anonymize_user_action(event)
-
- assert event.set_results.call_count == 1
- event.set_results.assert_called_with({"anonymize-user": True})
- anonymize_user_mock.assert_called_with(
- user=unittest.mock.ANY, server=unittest.mock.ANY, admin_access_token=admin_access_token
- )
-
-
-def test_anonymize_user_api_error(harness: Harness, monkeypatch: pytest.MonkeyPatch) -> None:
- """
- arrange: start the Synapse charm, set Synapse container to be ready and set server_name.
- act: run anonymize-user action.
- assert: event fails as expected.
- """
- harness.begin_with_initial_hooks()
- fail_message = "Failed to anonymize the user. Check if the user is created and active."
- synapse_api_error = synapse.APIError(fail_message)
- anonymize_user_mock = unittest.mock.MagicMock(side_effect=synapse_api_error)
- monkeypatch.setattr("synapse.deactivate_user", anonymize_user_mock)
- admin_access_token = token_hex(16)
- user = "username"
- admin = True
- monkeypatch.setattr(
- harness.charm.token_service,
- "get",
- unittest.mock.MagicMock(return_value=admin_access_token),
- )
- event = unittest.mock.MagicMock(spec=ActionEvent)
- event.params = {
- "username": user,
- "admin": admin,
- }
-
- def event_store_failure(failure_message: str) -> None:
- """Define a failure message for the event.
-
- Args:
- failure_message: failure message content to be defined.
- """
- event.fail_message = failure_message
-
- event.fail = event_store_failure
- event.params = {
- "username": user,
- "admin": admin,
- }
-
- harness.charm._on_anonymize_user_action(event)
-
- assert fail_message in event.fail_message
-
-
-def test_anonymize_user_container_down(harness: Harness) -> None:
- """
- arrange: start the Synapse charm, set Synapse container to be off.
- act: run anonymize-user action.
- assert: event fails as expected.
- """
- harness.begin_with_initial_hooks()
- harness.set_can_connect(harness.model.unit.containers[synapse.SYNAPSE_CONTAINER_NAME], False)
- event = unittest.mock.Mock()
-
- harness.charm._on_anonymize_user_action(event)
-
- assert event.set_results.call_count == 0
- assert event.fail.call_count == 1
- assert "Container not yet ready. Try again later" == event.fail.call_args[0][0]
-
-
-def test_anonymize_user_action_no_token(harness: Harness, monkeypatch: pytest.MonkeyPatch) -> None:
- """
- arrange: start the Synapse charm, set Synapse container to be ready and set server_name.
- act: run anonymize-user action.
- assert: event fails as expected.
- """
- harness.begin_with_initial_hooks()
- anonymize_user_mock = unittest.mock.Mock()
- monkeypatch.setattr(
- harness.charm.token_service,
- "get",
- unittest.mock.MagicMock(return_value=None),
- )
- monkeypatch.setattr("synapse.deactivate_user", anonymize_user_mock)
- event = unittest.mock.Mock()
-
- harness.charm._on_anonymize_user_action(event)
-
- assert event.set_results.call_count == 0
- assert event.fail.call_count == 1
- assert "Failed to get admin access token" == event.fail.call_args[0][0]
diff --git a/tests/unit/test_backup.py b/tests/unit/test_backup.py
index 95310e8a..8c0bc377 100644
--- a/tests/unit/test_backup.py
+++ b/tests/unit/test_backup.py
@@ -1,4 +1,4 @@
-# Copyright 2024 Canonical Ltd.
+# Copyright 2025 Canonical Ltd.
# See LICENSE file for licensing details.
"""Synapse backup unit tests."""
diff --git a/tests/unit/test_backup_observer.py b/tests/unit/test_backup_observer.py
index 86778af6..304bf05d 100644
--- a/tests/unit/test_backup_observer.py
+++ b/tests/unit/test_backup_observer.py
@@ -1,4 +1,4 @@
-# Copyright 2024 Canonical Ltd.
+# Copyright 2025 Canonical Ltd.
# See LICENSE file for licensing details.
"""Synapse backup observer unit tests."""
diff --git a/tests/unit/test_charm.py b/tests/unit/test_charm.py
index cfc399d1..7b63ad90 100644
--- a/tests/unit/test_charm.py
+++ b/tests/unit/test_charm.py
@@ -1,4 +1,4 @@
-# Copyright 2024 Canonical Ltd.
+# Copyright 2025 Canonical Ltd.
# See LICENSE file for licensing details.
"""Synapse charm unit tests."""
diff --git a/tests/unit/test_charm_scaling.py b/tests/unit/test_charm_scaling.py
index 452655b5..2ffbff54 100644
--- a/tests/unit/test_charm_scaling.py
+++ b/tests/unit/test_charm_scaling.py
@@ -1,4 +1,4 @@
-# Copyright 2024 Canonical Ltd.
+# Copyright 2025 Canonical Ltd.
# See LICENSE file for licensing details.
"""Synapse charm scaling unit tests."""
diff --git a/tests/unit/test_charm_state.py b/tests/unit/test_charm_state.py
index db38ea81..49082c65 100644
--- a/tests/unit/test_charm_state.py
+++ b/tests/unit/test_charm_state.py
@@ -1,4 +1,4 @@
-# Copyright 2024 Canonical Ltd.
+# Copyright 2025 Canonical Ltd.
# See LICENSE file for licensing details.
"""Synapse charm state unit tests."""
@@ -10,6 +10,7 @@
import pytest
from ops.testing import ActionFailed, Harness
+from pebble import check_synapse_alive
from state.charm_state import CharmConfigInvalidError, CharmState, SynapseConfig
from state.validation import CharmBaseWithState, validate_charm_state
@@ -192,3 +193,28 @@ def on_create_backup_action(self, _: ops.ActionEvent):
harness.run_action("create-backup")
assert "Invalid configuration" in str(err.value.message)
assert not hasattr(charm, "charm_state")
+
+
+def test_check_synapse_alive_experimental_alive_check_disabled():
+ """
+ arrange: Create a mock charmstate with experimental_alive_check = "".
+ act: Run check_synapse_alive method.
+ assert: The values are NOT added to the pebble check dictionary.
+ """
+ synapse_config = SynapseConfig(
+ server_name="example.com",
+ public_baseurl="https://example.com",
+ ) # type: ignore[call-arg]
+ charm_state = CharmState(
+ synapse_config=synapse_config,
+ datasource=None,
+ smtp_config=None,
+ media_config=None,
+ redis_config=None,
+ instance_map_config=None,
+ registration_secrets=None,
+ )
+ generated_checks = check_synapse_alive(charm_state)
+ assert generated_checks.get("period") is None
+ assert generated_checks.get("threshold") is None
+ assert generated_checks.get("timeout") is None
diff --git a/tests/unit/test_database.py b/tests/unit/test_database.py
index 8b3141a7..2dbea8bf 100644
--- a/tests/unit/test_database.py
+++ b/tests/unit/test_database.py
@@ -1,4 +1,4 @@
-# Copyright 2024 Canonical Ltd.
+# Copyright 2025 Canonical Ltd.
# See LICENSE file for licensing details.
"""Database unit tests."""
diff --git a/tests/unit/test_exceptions.py b/tests/unit/test_exceptions.py
index 3807cc97..d4a57ba4 100644
--- a/tests/unit/test_exceptions.py
+++ b/tests/unit/test_exceptions.py
@@ -1,4 +1,4 @@
-# Copyright 2024 Canonical Ltd.
+# Copyright 2025 Canonical Ltd.
# See LICENSE file for licensing details.
"""Exceptions unit tests."""
diff --git a/tests/unit/test_mas.py b/tests/unit/test_mas.py
index 1c3f18db..f5840010 100644
--- a/tests/unit/test_mas.py
+++ b/tests/unit/test_mas.py
@@ -1,4 +1,4 @@
-# Copyright 2024 Canonical Ltd.
+# Copyright 2025 Canonical Ltd.
# See LICENSE file for licensing details.
"""Synapse charm unit tests."""
@@ -42,7 +42,7 @@ def test_mas_generate_config(monkeypatch: pytest.MonkeyPatch) -> None:
}
synapse_configuration = SynapseConfig(**config) # type: ignore[arg-type]
rendered_mas_config = generate_mas_config(
- mas_configuration, synapse_configuration, None, "10.1.1.0"
+ mas_configuration, synapse_configuration, None, None, "10.1.1.0"
)
rendered_msc3861_config = generate_synapse_msc3861_config(
mas_configuration, synapse_configuration
diff --git a/tests/unit/test_matrix_auth_integration.py b/tests/unit/test_matrix_auth_integration.py
index f103c2a5..e8d13daa 100644
--- a/tests/unit/test_matrix_auth_integration.py
+++ b/tests/unit/test_matrix_auth_integration.py
@@ -1,4 +1,4 @@
-# Copyright 2024 Canonical Ltd.
+# Copyright 2025 Canonical Ltd.
# See LICENSE file for licensing details.
"""Synapse charm matrix-auth integration unit tests."""
@@ -9,8 +9,9 @@
import pytest
import yaml
-from charms.synapse.v0.matrix_auth import MatrixAuthRequirerData
+from charms.synapse.v1.matrix_auth import MatrixAuthRequirerData, encrypt_string
from ops.testing import Harness
+from pydantic import SecretStr
import synapse
@@ -83,7 +84,8 @@ def test_matrix_auth_registration_secret_success(
):
"""
arrange: start the Synapse charm with public_base url set.
- act: integrate via matrix-auth and add relation data.
+ act: integrate via matrix-auth with maubot and add registration as relation
+ data.
assert: update_relation_data is called, homeserver has same value as
public_baseurl and app_service_config_files is set.
"""
@@ -96,11 +98,12 @@ def test_matrix_auth_registration_secret_success(
monkeypatch.setattr(
harness.charm._matrix_auth.matrix_auth, "update_relation_data", update_relation_data
)
+ encryption_key = b"DXnflqjmmM8-UASxTl9oWeM7PWKQoclMFVb_bp9zLGY="
monkeypatch.setattr(
- synapse, "get_registration_shared_secret", MagicMock(return_value="shared_secret")
+ MatrixAuthRequirerData, "get_encryption_key_secret", MagicMock(return_value=encryption_key)
)
monkeypatch.setattr(
- MatrixAuthRequirerData, "get_registration", lambda *args: "test-registration"
+ synapse, "get_registration_shared_secret", MagicMock(return_value="shared_secret")
)
create_registration_secrets_files_mock = MagicMock()
monkeypatch.setattr(
@@ -109,7 +112,8 @@ def test_matrix_auth_registration_secret_success(
rel_id = harness.add_relation("matrix-auth", "maubot")
harness.add_relation_unit(rel_id, "maubot/0")
- harness.update_relation_data(rel_id, "maubot/0", {"registration_secret_id": "foo"})
+ encrypted_text = encrypt_string(key=encryption_key, plaintext=SecretStr("foo"))
+ harness.update_relation_data(rel_id, "maubot", {"registration_secret": encrypted_text})
relation = harness.charm.framework.model.get_relation("matrix-auth", rel_id)
update_relation_data.assert_called_with(relation, ANY)
@@ -123,3 +127,45 @@ def test_matrix_auth_registration_secret_success(
assert content["app_service_config_files"] == [
f"/data/appservice-registration-matrix-auth-{rel_id}.yaml"
]
+
+
+def test_matrix_auth_registration_secret_empty(harness: Harness, monkeypatch: pytest.MonkeyPatch):
+ """
+ arrange: start the Synapse charm with public_base url set.
+ act: integrate via matrix-auth with maubot and add registration as relation
+ data.
+ assert: update_relation_data is called, homeserver has same value as
+ public_baseurl and since registration is empty there are no registration
+ files.
+ """
+ base_url_value = "https://new-server"
+ harness.update_config({"server_name": TEST_SERVER_NAME, "public_baseurl": base_url_value})
+ harness.set_can_connect(synapse.SYNAPSE_CONTAINER_NAME, True)
+ harness.set_leader(True)
+ harness.begin_with_initial_hooks()
+ update_relation_data = MagicMock()
+ monkeypatch.setattr(
+ harness.charm._matrix_auth.matrix_auth, "update_relation_data", update_relation_data
+ )
+ encryption_key = b"DXnflqjmmM8-UASxTl9oWeM7PWKQoclMFVb_bp9zLGY="
+ monkeypatch.setattr(
+ MatrixAuthRequirerData, "get_encryption_key_secret", MagicMock(return_value=encryption_key)
+ )
+ monkeypatch.setattr(
+ synapse, "get_registration_shared_secret", MagicMock(return_value="shared_secret")
+ )
+ create_registration_secrets_files_mock = MagicMock()
+ monkeypatch.setattr(
+ synapse, "create_registration_secrets_files", create_registration_secrets_files_mock
+ )
+
+ rel_id = harness.add_relation("matrix-auth", "maubot")
+ harness.add_relation_unit(rel_id, "maubot/0")
+ relation = harness.charm.framework.model.get_relation("matrix-auth", rel_id)
+ harness.charm.on["matrix-auth"].relation_changed.emit(
+ relation, harness.charm.app, harness.charm.unit
+ )
+
+ update_relation_data.assert_called_with(relation, ANY)
+ assert update_relation_data.call_args[0][1].homeserver == base_url_value
+ create_registration_secrets_files_mock.assert_not_called()
diff --git a/tests/unit/test_matrix_plugins_lib.py b/tests/unit/test_matrix_plugins_lib.py
index fef07d2c..77ebb552 100644
--- a/tests/unit/test_matrix_plugins_lib.py
+++ b/tests/unit/test_matrix_plugins_lib.py
@@ -1,13 +1,14 @@
-# Copyright 2024 Canonical Ltd.
+# Copyright 2025 Canonical Ltd.
# See LICENSE file for licensing details.
"""MatrixAuth library unit tests"""
from secrets import token_hex
+from typing import cast
import ops
import pytest
-from charms.synapse.v0.matrix_auth import (
+from charms.synapse.v1.matrix_auth import (
MatrixAuthProviderData,
MatrixAuthProvides,
MatrixAuthRequestProcessed,
@@ -16,6 +17,7 @@
MatrixAuthRequires,
)
from ops.testing import Harness
+from pydantic import SecretStr
REQUIRER_METADATA = """
name: matrix-auth-consumer
@@ -36,8 +38,10 @@
"shared_secret_id": "test-secret-id",
}
+# pylint: disable=line-too-long
SAMPLE_REQUIRER_DATA = {
"registration_secret_id": "test-registration-id",
+ "registration_secret": "gAAAAABngB_H9JrNUwPDr-4E-ouqyG0V_O_1l4X-7f3wZ2A7dsZAUwUoz1lL5pmrrLdIZa5aBw_-P4iEs1le_u30RMWdtLIwAg==", # noqa: E501
}
@@ -228,7 +232,7 @@ def test_matrix_auth_provider_does_not_emit_event_when_no_data():
@pytest.mark.parametrize("is_leader", [True, False])
-def test_matrix_auth_provider_with_valid_relation_data_emits_event(is_leader, monkeypatch):
+def test_matrix_auth_provider_with_valid_relation_data_emits_event(is_leader):
"""
arrange: set up a charm.
act: add a matrix-auth relation with valid data.
@@ -238,20 +242,6 @@ def test_matrix_auth_provider_with_valid_relation_data_emits_event(is_leader, mo
harness.begin()
harness.set_leader(is_leader)
- # Mock the get_registration method to return a test registration
- def mock_get_registration(*args): # pylint: disable=unused-argument
- """Mock get_registration method.
-
- Args:
- args: Arguments passed to the method.
-
- Returns:
- str: The registration.
- """
- return "test-registration"
-
- monkeypatch.setattr(MatrixAuthRequirerData, "get_registration", mock_get_registration)
-
harness.add_relation("matrix-auth", "matrix-auth-consumer", app_data=SAMPLE_REQUIRER_DATA)
assert len(harness.charm.events) == 1
@@ -323,12 +313,13 @@ def mock_get_registration(*args): # pylint: disable=unused-argument
Returns:
str: The registration.
"""
- return "test-registration"
+ return b"9u7b67PYr7Jqx4Ot15Fg8f9PAbm8XmLsHecPIqD7VLM="
- monkeypatch.setattr(MatrixAuthRequirerData, "get_registration", mock_get_registration)
+ monkeypatch.setattr(MatrixAuthRequirerData, "get_encryption_key_secret", mock_get_registration)
harness.add_relation("matrix-auth", "matrix-auth-consumer", app_data=SAMPLE_REQUIRER_DATA)
relation_data = harness.charm.matrix_auth.get_remote_relation_data()
assert relation_data is not None
- assert relation_data.registration.get_secret_value() == "test-registration"
+ real_value = cast(SecretStr, "registration")
+ assert relation_data == MatrixAuthRequirerData(registration=real_value)
diff --git a/tests/unit/test_media_observer.py b/tests/unit/test_media_observer.py
index d8bbb161..c969c66b 100644
--- a/tests/unit/test_media_observer.py
+++ b/tests/unit/test_media_observer.py
@@ -1,4 +1,4 @@
-# Copyright 2024 Canonical Ltd.
+# Copyright 2025 Canonical Ltd.
# See LICENSE file for licensing details.
"""Synapse media unit tests."""
diff --git a/tests/unit/test_mjolnir.py b/tests/unit/test_mjolnir.py
deleted file mode 100644
index 54010483..00000000
--- a/tests/unit/test_mjolnir.py
+++ /dev/null
@@ -1,496 +0,0 @@
-# Copyright 2024 Canonical Ltd.
-# See LICENSE file for licensing details.
-
-"""Mjolnir unit tests."""
-
-# pylint: disable=protected-access
-import logging
-from dataclasses import dataclass
-from secrets import token_hex
-from unittest import mock
-from unittest.mock import ANY, MagicMock, PropertyMock, patch
-
-import ops
-import pytest
-from ops.testing import Harness
-
-import synapse
-from mjolnir import Mjolnir
-
-
-def test_get_membership_room_id(harness: Harness, monkeypatch: pytest.MonkeyPatch) -> None:
- """
- arrange: start the Synapse charm, set server_name.
- act: call get_membership_room_id.
- assert: get_membership_room_id is called once with expected args.
- """
- harness.update_config({"enable_mjolnir": True})
- harness.begin_with_initial_hooks()
- admin_access_token = token_hex(16)
- get_room_id = MagicMock()
- monkeypatch.setattr(synapse, "get_room_id", get_room_id)
-
- harness.charm._mjolnir.get_membership_room_id(admin_access_token)
-
- get_room_id.assert_called_once_with(
- room_name="moderators", admin_access_token=admin_access_token
- )
-
-
-@mock.patch("mjolnir.Mjolnir._admin_access_token", new_callable=PropertyMock)
-def test_on_collect_status_blocked(
- _admin_access_token_mock, harness: Harness, monkeypatch: pytest.MonkeyPatch
-) -> None:
- """
- arrange: start the Synapse charm, set server_name, mock container, get_membership_room_id
- and _update_peer_data.
- act: call _on_collect_status.
- assert: status is blocked.
- """
- harness.update_config({"enable_mjolnir": True})
- harness.begin_with_initial_hooks()
- _admin_access_token_mock.__get__ = mock.Mock(return_value=token_hex(16))
- monkeypatch.setattr(Mjolnir, "get_membership_room_id", MagicMock(return_value=None))
- charm_state_mock = MagicMock()
- charm_state_mock.enable_mjolnir = True
- harness.charm._mjolnir._charm_state = charm_state_mock
-
- event_mock = MagicMock()
- harness.charm._mjolnir._on_collect_status(event_mock)
-
- event_mock.add_status.assert_called_once_with(
- ops.BlockedStatus(
- "moderators not found and is required by Mjolnir. Please, check the logs."
- )
- )
-
-
-def test_on_collect_status_service_exists(
- harness: Harness, monkeypatch: pytest.MonkeyPatch
-) -> None:
- """
- arrange: start the Synapse charm, set server_name, mock get_services to return something.
- act: call _on_collect_status.
- assert: no actions is taken because the service exists.
- """
- harness.update_config({"enable_mjolnir": True})
- harness.begin_with_initial_hooks()
- container: ops.Container = harness.model.unit.get_container(synapse.SYNAPSE_CONTAINER_NAME)
- monkeypatch.setattr(container, "get_services", MagicMock(return_value=MagicMock()))
- enable_mjolnir_mock = MagicMock()
- monkeypatch.setattr(Mjolnir, "enable_mjolnir", enable_mjolnir_mock)
-
- event_mock = MagicMock()
- harness.charm._mjolnir._on_collect_status(event_mock)
-
- event_mock.add_status.assert_not_called()
- enable_mjolnir_mock.assert_not_called()
-
-
-def test_on_collect_status_no_service(harness: Harness, monkeypatch: pytest.MonkeyPatch) -> None:
- """
- arrange: start the Synapse charm, set server_name, mock get_services to return a empty dict.
- act: call _on_collect_status.
- assert: no actions is taken because Synapse service is not ready.
- """
- harness.update_config({"enable_mjolnir": True})
- harness.begin_with_initial_hooks()
- container: ops.Container = harness.model.unit.get_container(synapse.SYNAPSE_CONTAINER_NAME)
- monkeypatch.setattr(container, "get_services", MagicMock(return_value={}))
- enable_mjolnir_mock = MagicMock()
- monkeypatch.setattr(Mjolnir, "enable_mjolnir", enable_mjolnir_mock)
-
- event_mock = MagicMock()
- harness.charm._mjolnir._on_collect_status(event_mock)
-
- assert isinstance(harness.model.unit.status, ops.MaintenanceStatus)
- enable_mjolnir_mock.assert_not_called()
-
-
-def test_on_collect_status_container_off(
- harness: Harness, monkeypatch: pytest.MonkeyPatch
-) -> None:
- """
- arrange: start the Synapse charm, set server_name, mock container to not connect.
- act: call _on_collect_status.
- assert: no actions is taken because the container is off.
- """
- harness.update_config({"enable_mjolnir": True})
- harness.begin_with_initial_hooks()
- container: ops.Container = harness.model.unit.get_container(synapse.SYNAPSE_CONTAINER_NAME)
- monkeypatch.setattr(container, "can_connect", MagicMock(return_value=False))
- enable_mjolnir_mock = MagicMock()
- monkeypatch.setattr(Mjolnir, "enable_mjolnir", enable_mjolnir_mock)
-
- event_mock = MagicMock()
- harness.charm._mjolnir._on_collect_status(event_mock)
-
- event_mock.add_status.assert_not_called()
- enable_mjolnir_mock.assert_not_called()
-
-
-def test_on_collect_status_active(harness: Harness, monkeypatch: pytest.MonkeyPatch) -> None:
- """
- arrange: start the Synapse charm, set server_name, mock container, get_membership_room_id
- and _update_peer_data.
- act: call _on_collect_status.
- assert: status is active.
- """
- harness.update_config({"enable_mjolnir": True})
- harness.begin_with_initial_hooks()
- admin_access_token = token_hex(16)
- monkeypatch.setattr(Mjolnir, "_admin_access_token", admin_access_token)
- membership_room_id_mock = MagicMock(return_value="123")
- monkeypatch.setattr(Mjolnir, "get_membership_room_id", membership_room_id_mock)
- enable_mjolnir_mock = MagicMock(return_value=None)
- monkeypatch.setattr(Mjolnir, "enable_mjolnir", enable_mjolnir_mock)
- charm_state_mock = MagicMock()
- charm_state_mock.enable_mjolnir = True
- harness.charm._mjolnir._charm_state = charm_state_mock
-
- event_mock = MagicMock()
- harness.charm._mjolnir._on_collect_status(event_mock)
-
- membership_room_id_mock.assert_called_once()
- enable_mjolnir_mock.assert_called_once()
- event_mock.add_status.assert_called_once_with(ops.ActiveStatus())
-
-
-def test_on_collect_status_api_error(harness: Harness, monkeypatch: pytest.MonkeyPatch) -> None:
- """
- arrange: start the Synapse charm, set server_name, mock container, mock get_membership_room_id
- to raise an API error.
- act: call _on_collect_status.
- assert: mjolnir is not enabled.
- """
- harness.update_config({"enable_mjolnir": True})
- harness.begin_with_initial_hooks()
- admin_access_token = token_hex(16)
- monkeypatch.setattr(Mjolnir, "_admin_access_token", admin_access_token)
- membership_room_id_mock = MagicMock(side_effect=synapse.APIError("error"))
- monkeypatch.setattr(Mjolnir, "get_membership_room_id", membership_room_id_mock)
- enable_mjolnir_mock = MagicMock(return_value=None)
- monkeypatch.setattr(Mjolnir, "enable_mjolnir", enable_mjolnir_mock)
- charm_state_mock = MagicMock()
- charm_state_mock.enable_mjolnir = True
- harness.charm._mjolnir._charm_state = charm_state_mock
-
- event_mock = MagicMock()
- harness.charm._mjolnir._on_collect_status(event_mock)
-
- membership_room_id_mock.assert_called_once()
- enable_mjolnir_mock.assert_not_called()
-
-
-def test_on_collect_status_admin_none(harness: Harness, monkeypatch: pytest.MonkeyPatch) -> None:
- """
- arrange: start the Synapse charm, set server_name, mock container, mock _admin_access_token
- to be None.
- act: call _on_collect_status.
- assert: mjolnir is not enabled and the model status is Maintenance.
- """
- harness.update_config({"enable_mjolnir": True})
- harness.begin_with_initial_hooks()
- monkeypatch.setattr(Mjolnir, "_admin_access_token", None)
- enable_mjolnir_mock = MagicMock(return_value=None)
- monkeypatch.setattr(Mjolnir, "enable_mjolnir", enable_mjolnir_mock)
- charm_state_mock = MagicMock()
- charm_state_mock.enable_mjolnir = True
- harness.charm._mjolnir._charm_state = charm_state_mock
-
- event_mock = MagicMock()
- harness.charm._mjolnir._on_collect_status(event_mock)
-
- enable_mjolnir_mock.assert_not_called()
- assert isinstance(harness.model.unit.status, ops.MaintenanceStatus)
-
-
-def test_enable_mjolnir(harness: Harness, monkeypatch: pytest.MonkeyPatch) -> None:
- """
- arrange: start the Synapse charm, set server_name, mock calls to validate args.
- act: call enable_mjolnir.
- assert: all steps are taken as required.
- """
- harness.update_config({"enable_mjolnir": True})
- harness.begin_with_initial_hooks()
- admin_access_token = token_hex(16)
- monkeypatch.setattr(Mjolnir, "_admin_access_token", admin_access_token)
- mjolnir_user_mock = MagicMock()
- mjolnir_access_token = token_hex(16)
- mjolnir_user_mock.access_token = mjolnir_access_token
- create_user_mock = MagicMock(return_value=mjolnir_user_mock)
- monkeypatch.setattr(synapse, "create_user", create_user_mock)
- room_id = token_hex(16)
- get_room_id = MagicMock(return_value=room_id)
- monkeypatch.setattr(synapse, "get_room_id", get_room_id)
- make_room_admin = MagicMock()
- monkeypatch.setattr(synapse, "make_room_admin", make_room_admin)
- generate_mjolnir_config = MagicMock()
- monkeypatch.setattr(synapse, "generate_mjolnir_config", generate_mjolnir_config)
- override_rate_limit = MagicMock()
- monkeypatch.setattr(synapse, "override_rate_limit", override_rate_limit)
-
- charm_state = harness.charm.build_charm_state()
- harness.charm._mjolnir.enable_mjolnir(charm_state, admin_access_token)
-
- get_room_id.assert_called_once_with(
- room_name="management", admin_access_token=admin_access_token
- )
- make_room_admin.assert_called_once_with(
- user=ANY, server=ANY, admin_access_token=admin_access_token, room_id=room_id
- )
- generate_mjolnir_config.assert_called_once_with(
- container=ANY, access_token=mjolnir_access_token, room_id=room_id
- )
- override_rate_limit.assert_called_once_with(
- user=ANY, charm_state=ANY, admin_access_token=admin_access_token
- )
- assert harness.model.unit.status == ops.ActiveStatus()
-
-
-def test_enable_mjolnir_room_none(harness: Harness, monkeypatch: pytest.MonkeyPatch) -> None:
- """
- arrange: start the Synapse charm, set server_name, mock calls to validate args,
- get_room_id returns None.
- act: call enable_mjolnir.
- assert: all steps are taken as required.
- """
- harness.update_config({"enable_mjolnir": True})
- harness.begin_with_initial_hooks()
- admin_access_token = token_hex(16)
- monkeypatch.setattr(Mjolnir, "_admin_access_token", admin_access_token)
- mjolnir_user_mock = MagicMock()
- mjolnir_access_token = token_hex(16)
- mjolnir_user_mock.access_token = mjolnir_access_token
- create_user_mock = MagicMock(return_value=mjolnir_user_mock)
- monkeypatch.setattr(synapse, "create_user", create_user_mock)
- get_room_id = MagicMock(return_value=None)
- monkeypatch.setattr(synapse, "get_room_id", get_room_id)
- room_id = token_hex(16)
- create_management_room = MagicMock(return_value=room_id)
- monkeypatch.setattr(synapse, "create_management_room", create_management_room)
- make_room_admin = MagicMock()
- monkeypatch.setattr(synapse, "make_room_admin", make_room_admin)
- generate_mjolnir_config = MagicMock()
- monkeypatch.setattr(synapse, "generate_mjolnir_config", generate_mjolnir_config)
- override_rate_limit = MagicMock()
- monkeypatch.setattr(synapse, "override_rate_limit", override_rate_limit)
-
- charm_state = harness.charm.build_charm_state()
- harness.charm._mjolnir.enable_mjolnir(charm_state, admin_access_token)
-
- create_user_mock.assert_called_once_with(ANY, ANY, ANY, admin_access_token, ANY)
- get_room_id.assert_called_once_with(
- room_name="management", admin_access_token=admin_access_token
- )
- create_management_room.assert_called_once_with(admin_access_token=admin_access_token)
- make_room_admin.assert_called_once_with(
- user=ANY, server=ANY, admin_access_token=admin_access_token, room_id=room_id
- )
- generate_mjolnir_config.assert_called_once_with(
- container=ANY, access_token=mjolnir_access_token, room_id=room_id
- )
- override_rate_limit.assert_called_once_with(
- user=ANY, charm_state=ANY, admin_access_token=admin_access_token
- )
- assert harness.model.unit.status == ops.ActiveStatus()
-
-
-def test_enable_mjolnir_container_off(harness: Harness, monkeypatch: pytest.MonkeyPatch) -> None:
- """
- arrange: start the Synapse charm, set server_name, mock container to not connect.
- act: call enable_mjolnir.
- assert: the next step, register user, is not called.
- """
- harness.update_config({"enable_mjolnir": True})
- harness.begin_with_initial_hooks()
- container: ops.Container = harness.model.unit.get_container(synapse.SYNAPSE_CONTAINER_NAME)
- monkeypatch.setattr(container, "can_connect", MagicMock(return_value=False))
- register_user_mock = MagicMock()
- monkeypatch.setattr("auth.mas.register_user", register_user_mock)
-
- charm_state = harness.charm.build_charm_state()
- harness.charm._mjolnir.enable_mjolnir(charm_state, token_hex(16))
-
- register_user_mock.assert_not_called()
-
-
-def test_enable_mjolnir_admin_access_token(
- harness: Harness, monkeypatch: pytest.MonkeyPatch
-) -> None:
- """
- arrange: start the Synapse charm and mock token_service.
- act: get the admin_access_token.
- assert: admin_access_token is the same as the one from token_service except
- if container is down or token_service returns None.
- """
- harness.begin_with_initial_hooks()
- token_mock = token_hex(16)
- token_service = MagicMock()
- monkeypatch.setattr(token_service, "get", MagicMock(return_value=token_mock))
- monkeypatch.setattr(harness.charm._mjolnir, "_token_service", token_service)
-
- assert harness.charm._mjolnir._admin_access_token == token_mock
-
- monkeypatch.setattr(token_service, "get", MagicMock(return_value=None))
- monkeypatch.setattr(harness.charm._mjolnir, "_token_service", token_service)
-
- assert harness.charm._mjolnir._admin_access_token is None
-
- container: ops.Container = harness.model.unit.get_container(synapse.SYNAPSE_CONTAINER_NAME)
- monkeypatch.setattr(container, "can_connect", MagicMock(return_value=False))
-
- assert harness.charm._mjolnir._admin_access_token is None
-
-
-def test_admin_access_token_no_connection(
- harness: ops.testing.Harness, monkeypatch: pytest.MonkeyPatch
-) -> None:
- """
- arrange: start the Synapse charm, set server_name, mock container to not connect.
- act: call _admin_access_token.
- assert: None is returned.
- """
- harness.update_config({"enable_mjolnir": True})
- harness.begin_with_initial_hooks()
- container: ops.Container = harness.model.unit.get_container(synapse.SYNAPSE_CONTAINER_NAME)
- monkeypatch.setattr(container, "can_connect", MagicMock(return_value=False))
- charm_state = harness.charm.build_charm_state()
- harness.charm._mjolnir.enable_mjolnir(charm_state, token_hex(16))
-
- result = harness.charm._mjolnir._admin_access_token
-
- assert result is None
-
-
-def test_admin_access_token_no_token(
- harness: ops.testing.Harness, monkeypatch: pytest.MonkeyPatch
-) -> None:
- """
- arrange: start the Synapse charm, set server_name, mock container,
- mock token_service to return None.
- act: call _admin_access_token.
- assert: None is returned and an error message is logged.
- """
- harness.update_config({"enable_mjolnir": True})
- harness.begin_with_initial_hooks()
- container: ops.Container = harness.model.unit.get_container(synapse.SYNAPSE_CONTAINER_NAME)
- monkeypatch.setattr(container, "can_connect", MagicMock(return_value=True))
- token_service_mock = MagicMock(return_value=None)
-
- monkeypatch.setattr(synapse.workload, "_get_configuration_field", MagicMock(return_value=None))
-
- charm_state = harness.charm.build_charm_state()
- harness.charm._mjolnir.enable_mjolnir(charm_state, token_service_mock)
-
- with patch.object(logging, "error") as mock_error:
- result = harness.charm._mjolnir._admin_access_token
-
- assert result is None
- mock_error.assert_called_once_with(
- "Admin Access Token was not found, please check the logs."
- )
-
-
-@dataclass
-class AdminUser:
- """
- mock AdminUser dataclass.
-
- Attributes:
- access_token: str
- """
-
- access_token: str
-
-
-def test_admin_access_token_success(
- harness: ops.testing.Harness, monkeypatch: pytest.MonkeyPatch, mocked_synapse_calls
-) -> None:
- """
- arrange: start the Synapse charm, set server_name, mock container,
- mock token_service to return a token.
- act: call _admin_access_token.
- assert: the access token is returned.
- """
- # pylint: disable=unused-argument
- harness.update_config({"enable_mjolnir": True})
- harness.begin_with_initial_hooks()
- container: ops.Container = harness.model.unit.get_container(synapse.SYNAPSE_CONTAINER_NAME)
- monkeypatch.setattr(container, "can_connect", MagicMock(return_value=True))
- access_token = token_hex(16)
- token_service_mock = MagicMock(return_value=access_token)
- admin_user = AdminUser(access_token)
- monkeypatch.setattr(synapse, "create_admin_user", MagicMock(return_value=admin_user))
-
- charm_state = harness.charm.build_charm_state()
-
- with patch("synapse.api._do_request") as mock_request:
- mock_request.return_value.status_code = 200
- expected_room_id = token_hex(16)
- room_name = token_hex(16)
- expected_room_res = [{"name": room_name, "room_id": expected_room_id}]
- mock_request.return_value.json.return_value = {
- "access_token": "access_token",
- "nonce": "sense",
- "rooms": expected_room_res,
- }
- harness.charm._mjolnir.enable_mjolnir(charm_state, token_service_mock)
- result = harness.charm._mjolnir._admin_access_token
-
- assert result == access_token
-
-
-def test_on_collect_status_not_main_unit(
- harness: Harness, monkeypatch: pytest.MonkeyPatch
-) -> None:
- """
- arrange: start the Synapse charm, set server_name, unit is not the main.
- act: call _on_collect_status.
- assert: no actions is taken because Mjolnir is enabled only in main unit.
- """
- harness.add_relation(
- synapse.SYNAPSE_PEER_RELATION_NAME,
- "synapse",
- app_data={"main_unit_id": "synapse/1"},
- )
- harness.update_config({"enable_mjolnir": True})
- harness.begin_with_initial_hooks()
- enable_mjolnir_mock = MagicMock()
- monkeypatch.setattr(Mjolnir, "enable_mjolnir", enable_mjolnir_mock)
-
- event_mock = MagicMock()
- harness.charm._mjolnir._on_collect_status(event_mock)
-
- event_mock.add_status.assert_not_called()
- enable_mjolnir_mock.assert_not_called()
-
-
-def test_on_collect_status_not_main_unit_and_is_started(
- harness: Harness, monkeypatch: pytest.MonkeyPatch
-) -> None:
- """
- arrange: start the Synapse charm, set server_name, unit has Mjolnir started.
- act: call _on_collect_status.
- assert: Mjolnir is stopped since this is not the main unit.
- """
- harness.add_relation(
- synapse.SYNAPSE_PEER_RELATION_NAME,
- "synapse",
- app_data={"main_unit_id": "synapse/1"},
- )
- harness.update_config({"enable_mjolnir": True})
- harness.begin_with_initial_hooks()
- enable_mjolnir_mock = MagicMock()
- monkeypatch.setattr(Mjolnir, "enable_mjolnir", enable_mjolnir_mock)
- container_mock = MagicMock(spec=ops.Container)
- monkeypatch.setattr(
- harness.charm.unit, "get_container", MagicMock(return_value=container_mock)
- )
-
- event_mock = MagicMock()
- harness.charm._mjolnir._on_collect_status(event_mock)
-
- event_mock.add_status.assert_not_called()
- enable_mjolnir_mock.assert_not_called()
- container_mock.stop.assert_called_with("mjolnir")
diff --git a/tests/unit/test_observability.py b/tests/unit/test_observability.py
index 4e9b8a54..cc292c4a 100644
--- a/tests/unit/test_observability.py
+++ b/tests/unit/test_observability.py
@@ -1,4 +1,4 @@
-# Copyright 2024 Canonical Ltd.
+# Copyright 2025 Canonical Ltd.
# See LICENSE file for licensing details.
"""Synapse observability unit tests."""
diff --git a/tests/unit/test_promote_user_admin_action.py b/tests/unit/test_promote_user_admin_action.py
deleted file mode 100644
index 45c63dc1..00000000
--- a/tests/unit/test_promote_user_admin_action.py
+++ /dev/null
@@ -1,129 +0,0 @@
-# Copyright 2024 Canonical Ltd.
-# See LICENSE file for licensing details.
-
-"""Register user action unit tests."""
-
-# Disabled to access _on_register_user_action
-# pylint: disable=protected-access
-
-import unittest.mock
-from secrets import token_hex
-
-import pytest
-from ops.charm import ActionEvent
-from ops.testing import Harness
-
-import synapse
-
-
-def test_promote_user_admin_action(harness: Harness, monkeypatch: pytest.MonkeyPatch) -> None:
- """
- arrange: start the Synapse charm, set Synapse container to be ready and set server_name.
- act: run promote-user-admin action.
- assert: event results are returned as expected.
- """
- harness.begin_with_initial_hooks()
- admin_access_token = token_hex(16)
- promote_user_admin_mock = unittest.mock.Mock()
- monkeypatch.setattr(
- harness.charm.token_service,
- "get",
- unittest.mock.MagicMock(return_value=admin_access_token),
- )
- monkeypatch.setattr("synapse.promote_user_admin", promote_user_admin_mock)
- user = "username"
- event = unittest.mock.MagicMock(spec=ActionEvent)
- event.params = {
- "username": user,
- }
-
- harness.charm._on_promote_user_admin_action(event)
-
- assert event.set_results.call_count == 1
- event.set_results.assert_called_with({"promote-user-admin": True})
- promote_user_admin_mock.assert_called_with(
- user=unittest.mock.ANY, server=unittest.mock.ANY, admin_access_token=admin_access_token
- )
-
-
-def test_promote_user_admin_api_error(harness: Harness, monkeypatch: pytest.MonkeyPatch) -> None:
- """
- arrange: start the Synapse charm, set Synapse container to be ready and set server_name.
- act: run promote-user-admin action.
- assert: event fails as expected.
- """
- harness.begin_with_initial_hooks()
- fail_message = "Some fail message"
- synapse_api_error = synapse.APIError(fail_message)
- promote_user_admin_mock = unittest.mock.MagicMock(side_effect=synapse_api_error)
- monkeypatch.setattr("synapse.promote_user_admin", promote_user_admin_mock)
- admin_access_token = token_hex(16)
- monkeypatch.setattr(
- harness.charm.token_service,
- "get",
- unittest.mock.MagicMock(return_value=admin_access_token),
- )
- user = "username"
- event = unittest.mock.MagicMock(spec=ActionEvent)
- event.params = {
- "username": user,
- }
-
- def event_store_failure(failure_message: str) -> None:
- """Define a failure message for the event.
-
- Args:
- failure_message: failure message content to be defined.
- """
- event.fail_message = failure_message
-
- event.fail = event_store_failure
- event.params = {
- "username": user,
- }
-
- harness.charm._on_promote_user_admin_action(event)
-
- assert fail_message in event.fail_message
-
-
-def test_promote_user_admin_container_down(harness: Harness) -> None:
- """
- arrange: start the Synapse charm, set Synapse container to be off.
- act: run promote-user-admin action.
- assert: event fails as expected.
- """
- harness.begin_with_initial_hooks()
- harness.set_can_connect(harness.model.unit.containers[synapse.SYNAPSE_CONTAINER_NAME], False)
- event = unittest.mock.Mock()
-
- harness.charm._on_promote_user_admin_action(event)
-
- assert event.set_results.call_count == 0
- assert event.fail.call_count == 1
- assert "Failed to connect to the container" == event.fail.call_args[0][0]
-
-
-def test_promote_user_admin_action_no_token(
- harness: Harness, monkeypatch: pytest.MonkeyPatch
-) -> None:
- """
- arrange: start the Synapse charm, set Synapse container to be ready and set server_name.
- act: run promote-user-admin action.
- assert: event fails as expected.
- """
- harness.begin_with_initial_hooks()
- promote_user_admin_mock = unittest.mock.Mock()
- monkeypatch.setattr(
- harness.charm.token_service,
- "get",
- unittest.mock.MagicMock(return_value=None),
- )
- monkeypatch.setattr("synapse.promote_user_admin", promote_user_admin_mock)
- event = unittest.mock.Mock()
-
- harness.charm._on_promote_user_admin_action(event)
-
- assert event.set_results.call_count == 0
- assert event.fail.call_count == 1
- assert "Failed to get admin access token" == event.fail.call_args[0][0]
diff --git a/tests/unit/test_smtp_observer.py b/tests/unit/test_smtp_observer.py
index 908e147c..3590acea 100644
--- a/tests/unit/test_smtp_observer.py
+++ b/tests/unit/test_smtp_observer.py
@@ -1,4 +1,4 @@
-# Copyright 2024 Canonical Ltd.
+# Copyright 2025 Canonical Ltd.
# See LICENSE file for licensing details.
"""SMTPObserver unit tests."""
@@ -6,10 +6,12 @@
# pylint: disable=protected-access
from secrets import token_hex
+from unittest.mock import MagicMock
import pytest
from charms.smtp_integrator.v0.smtp import AuthType, TransportSecurity
from ops.testing import Harness
+from pydantic.v1 import ValidationError
from charm_types import SMTPConfiguration
from state.charm_state import CharmConfigInvalidError
@@ -144,3 +146,26 @@ def test_get_relation_as_smtp_conf_password_from_juju_secret(harness: Harness):
smtp_configuration = harness.charm._smtp.get_relation_as_smtp_conf()
assert smtp_configuration["password"] == password
+
+
+@pytest.mark.parametrize(
+ "error",
+ [
+ pytest.param(ValueError),
+ pytest.param(ValidationError, marks=[pytest.mark.requires_secrets]),
+ ],
+)
+def test_get_relation_as_smtp_conf_error(harness: Harness, monkeypatch: pytest.MonkeyPatch, error):
+ """
+ arrange: Mock charm with get_relation_data method raising errors.
+ act: get SMTPConfiguration from smtp observer.
+ assert: fetched smtp configuration is none.
+ """
+ monkeypatch.setattr(
+ "charms.smtp_integrator.v0.smtp.SmtpRequires.get_relation_data",
+ MagicMock(side_effect=error(MagicMock(), MagicMock())),
+ )
+ harness.add_relation("smtp", "smtp-integrator", app_data={})
+ harness.begin()
+
+ assert harness.charm._smtp.get_relation_as_smtp_conf() is None
diff --git a/tests/unit/test_synapse_api.py b/tests/unit/test_synapse_api.py
index c1c3b612..9a853717 100644
--- a/tests/unit/test_synapse_api.py
+++ b/tests/unit/test_synapse_api.py
@@ -1,4 +1,4 @@
-# Copyright 2024 Canonical Ltd.
+# Copyright 2025 Canonical Ltd.
# See LICENSE file for licensing details.
"""Synapse API unit tests."""
@@ -18,197 +18,6 @@
from user import User
-@mock.patch("synapse.api.requests.Session")
-def test_register_user_success(mock_session, monkeypatch: pytest.MonkeyPatch):
- """
- arrange: set User parameters.
- act: register the user.
- assert: parameters are passed correctly.
- """
- # Set user parameters
- username = "any-user"
- user = User(username=username, admin=True)
- # Prepare mock to register the user
- get_nonce_return = "nonce"
- get_nonce_mock = mock.MagicMock(return_value=get_nonce_return)
- monkeypatch.setattr("synapse.api._get_nonce", get_nonce_mock)
- generate_mac_mock = mock.MagicMock(return_value="mac")
- monkeypatch.setattr("synapse.api._generate_mac", generate_mac_mock)
- mock_response = mock.MagicMock()
- mock_response.raise_for_status.return_value = None
- mock_requests = mock.MagicMock()
- mock_requests.post.return_value = mock_response
- mock_session.return_value = mock_requests
- shared_secret = token_hex(16)
-
- synapse.register_user(shared_secret, user)
-
- # Check if parameters are correct.
- get_nonce_mock.assert_called_once()
- generate_mac_mock.assert_called_once_with(
- shared_secret=shared_secret,
- nonce=get_nonce_return,
- user=username,
- password=mock.ANY,
- admin=True,
- )
-
-
-@mock.patch("synapse.api.requests.Session")
-def test_register_user_error(mock_session, monkeypatch: pytest.MonkeyPatch):
- """
- arrange: set User parameters and mock post to return connection and http errors.
- act: register the user.
- assert: NetworkError is raised.
- """
- username = "any-user"
- user = User(username=username, admin=True)
- get_nonce_return = "nonce"
- get_nonce_mock = mock.MagicMock(return_value=get_nonce_return)
- monkeypatch.setattr("synapse.api._get_nonce", get_nonce_mock)
- generate_mac_mock = mock.MagicMock(return_value="mac")
- monkeypatch.setattr("synapse.api._generate_mac", generate_mac_mock)
- mock_response_error = requests.exceptions.ConnectionError("Connection error")
- mock_request = mock.Mock()
- mock_request.request.side_effect = mock_response_error
- mock_session.return_value = mock_request
- shared_secret = token_hex(16)
- with pytest.raises(synapse.APIError, match="Failed to connect to"):
- synapse.register_user(shared_secret, user)
-
- mock_response_exception = mock.MagicMock()
- mock_response_exception.text = "Fail"
- mock_response_http_error = requests.exceptions.HTTPError(
- request=mock.Mock(), response=mock_response_exception
- )
- mock_request = mock.Mock()
- mock_request.request.side_effect = mock_response_http_error
- mock_session.return_value = mock_request
-
- with pytest.raises(synapse.APIError, match="HTTP error from"):
- synapse.register_user(shared_secret, user)
-
-
-@mock.patch("synapse.api.requests.Session")
-def test_register_user_keyerror(mock_session, monkeypatch: pytest.MonkeyPatch):
- """
- arrange: set User parameters and mock post to return empty content.
- act: register the user.
- assert: KeyError is raised.
- """
- username = "any-user"
- user = User(username=username, admin=True)
- get_nonce_return = "nonce"
- get_nonce_mock = mock.MagicMock(return_value=get_nonce_return)
- monkeypatch.setattr("synapse.api._get_nonce", get_nonce_mock)
- generate_mac_mock = mock.MagicMock(return_value="mac")
- monkeypatch.setattr("synapse.api._generate_mac", generate_mac_mock)
- mock_response = mock.MagicMock()
- mock_response.json.return_value = {}
- mock_requests = mock.MagicMock()
- mock_requests.request.return_value = mock_response
- mock_session.return_value = mock_requests
- shared_secret = token_hex(16)
-
- with pytest.raises(synapse.APIError, match="access_token"):
- synapse.register_user(shared_secret, user)
-
-
-def test_register_user_nonce_error(monkeypatch: pytest.MonkeyPatch):
- """
- arrange: set User parameters and mock once to return error.
- act: register the user.
- assert: NetworkError is raised.
- """
- username = "any-user"
- user = User(username=username, admin=True)
- msg = "Wrong nonce"
- mock_nonce_error = synapse.api.GetNonceError(msg)
- get_nonce_mock = mock.MagicMock(side_effect=mock_nonce_error)
- monkeypatch.setattr("synapse.api._get_nonce", get_nonce_mock)
- shared_secret = token_hex(16)
-
- with pytest.raises(synapse.APIError, match=msg):
- synapse.register_user(shared_secret, user)
-
-
-@mock.patch("synapse.api.requests.Session")
-def test_register_user_exists_error(mock_session, monkeypatch: pytest.MonkeyPatch):
- """
- arrange: set User parameters and mock post to return UserExistsError.
- act: register the user.
- assert: exception is raised because there is no server/admin access token.
- """
- username = "any-user"
- user = User(username=username, admin=True)
- get_nonce_return = "nonce"
- get_nonce_mock = mock.MagicMock(return_value=get_nonce_return)
- monkeypatch.setattr("synapse.api._get_nonce", get_nonce_mock)
- generate_mac_mock = mock.MagicMock(return_value="mac")
- monkeypatch.setattr("synapse.api._generate_mac", generate_mac_mock)
- shared_secret = token_hex(16)
- mock_response_exception = mock.MagicMock()
- mock_response_exception.text = "User ID already taken"
- mock_response_http_error = requests.exceptions.HTTPError(
- request=mock.Mock(), response=mock_response_exception
- )
- mock_request = mock.Mock()
- mock_request.request.side_effect = mock_response_http_error
- mock_session.return_value = mock_request
-
- with pytest.raises(synapse.APIError, match="exists but there is no"):
- synapse.register_user(shared_secret, user)
-
-
-@mock.patch("synapse.api.requests.Session")
-def test_access_token_success(mock_session):
- """
- arrange: set User, admin_token and server parameters.
- act: get access token.
- assert: token is returned as expected.
- """
- # Set user parameters
- username = "any-user"
- user = User(username=username, admin=True)
- # Prepare mock to get the access token
- mock_response = mock.MagicMock()
- expected_token = token_hex(16)
- mock_response = mock.MagicMock()
- mock_response.json.return_value = {"access_token": expected_token}
- mock_requests = mock.MagicMock()
- mock_requests.request.return_value = mock_response
- mock_session.return_value = mock_requests
- server = token_hex(16)
- admin_access_token = token_hex(16)
-
- result = synapse.get_access_token(user, server=server, admin_access_token=admin_access_token)
-
- assert result == expected_token
-
-
-@mock.patch("synapse.api.requests.Session")
-def test_access_token_error(mock_session):
- """
- arrange: set User, admin_token and server parameters.
- act: get access token.
- assert: API error is raised.
- """
- # Set user parameters
- username = "any-user"
- user = User(username=username, admin=True)
- # Prepare mock to get the access token
- mock_response = mock.MagicMock()
- mock_response.json.return_value = {}
- mock_requests = mock.MagicMock()
- mock_requests.request.return_value = mock_response
- mock_session.return_value = mock_requests
- server = token_hex(16)
- admin_access_token = token_hex(16)
-
- with pytest.raises(synapse.APIError, match="access_token"):
- synapse.get_access_token(user, server=server, admin_access_token=admin_access_token)
-
-
def test_override_rate_limit_success(monkeypatch: pytest.MonkeyPatch):
"""
arrange: set User, admin_token and charm_state parameters.
@@ -352,133 +161,6 @@ def test_get_room_id_not_found(monkeypatch: pytest.MonkeyPatch):
)
-def test_deactivate_user_success(monkeypatch: pytest.MonkeyPatch):
- """
- arrange: set User, admin_token and server parameters.
- act: deactivate user.
- assert: request is called as expected.
- """
- username = "any-user"
- user = User(username=username, admin=True)
- admin_access_token = token_hex(16)
- server = token_hex(16)
- expected_url = f"http://localhost:8008/_synapse/admin/v1/deactivate/@{username}:{server}"
- do_request_mock = mock.MagicMock(return_value=mock.MagicMock())
- monkeypatch.setattr("synapse.api._do_request", do_request_mock)
-
- synapse.deactivate_user(user, admin_access_token=admin_access_token, server=server)
-
- do_request_mock.assert_called_once_with(
- "POST",
- expected_url,
- admin_access_token=admin_access_token,
- json={"erase": True},
- )
-
-
-def test_deactivate_user_error(monkeypatch: pytest.MonkeyPatch):
- """
- arrange: set User, admin_token and server parameters.
- act: deactivate user.
- assert: exception is raised as expected.
- """
- username = "any-user"
- user = User(username=username, admin=True)
- admin_access_token = token_hex(16)
- server = token_hex(16)
- expected_error_msg = "Failed to connect"
- do_request_mock = mock.MagicMock(side_effect=synapse.APIError(expected_error_msg))
- monkeypatch.setattr("synapse.api._do_request", do_request_mock)
-
- with pytest.raises(synapse.APIError, match=expected_error_msg):
- synapse.deactivate_user(user, admin_access_token=admin_access_token, server=server)
-
-
-def test_create_management_room_success(monkeypatch: pytest.MonkeyPatch):
- """
- arrange: set admin_token parameter and mock get_room_id.
- act: create management room.
- assert: room id is returned.
- """
- moderator_room_id = token_hex(16)
- monkeypatch.setattr("synapse.api.get_room_id", mock.MagicMock(return_value=moderator_room_id))
- do_request_mock = mock.MagicMock(return_value=mock.MagicMock())
- monkeypatch.setattr("synapse.api._do_request", do_request_mock)
- admin_access_token = token_hex(16)
-
- synapse.create_management_room(admin_access_token=admin_access_token)
-
- expected_url = "http://localhost:8008/_matrix/client/v3/createRoom"
- expected_json = {
- "name": "management",
- "power_level_content_override": {"events_default": 0},
- "room_alias_name": "management",
- "visibility": "private",
- "initial_state": [
- {
- "type": "m.room.history_visibility",
- "state_key": "",
- "content": {"history_visibility": "shared"},
- },
- {
- "type": "m.room.guest_access",
- "state_key": "",
- "content": {"guest_access": "can_join"},
- },
- {"type": "m.room.retention", "state_key": "", "content": {"max_lifetime": 604800000}},
- {
- "type": "m.room.join_rules",
- "state_key": "",
- "content": {
- "join_rule": "restricted",
- "allow": [{"room_id": moderator_room_id, "type": "m.room_membership"}],
- },
- },
- ],
- }
- do_request_mock.assert_called_once_with(
- "POST",
- expected_url,
- admin_access_token=admin_access_token,
- json=expected_json,
- )
-
-
-def test_create_management_room_error(monkeypatch: pytest.MonkeyPatch):
- """
- arrange: set admin_token parameter, mock get_room_id and mock do_requests to raise exception.
- act: create management room.
- assert: exception is raised.
- """
- moderator_room_id = token_hex(16)
- monkeypatch.setattr("synapse.api.get_room_id", mock.MagicMock(return_value=moderator_room_id))
- admin_access_token = token_hex(16)
- expected_error_msg = "Failed to connect"
- do_request_mock = mock.MagicMock(side_effect=synapse.APIError(expected_error_msg))
- monkeypatch.setattr("synapse.api._do_request", do_request_mock)
-
- with pytest.raises(synapse.APIError, match=expected_error_msg):
- synapse.create_management_room(admin_access_token=admin_access_token)
-
-
-def test_create_management_room_key_error(monkeypatch: pytest.MonkeyPatch):
- """
- arrange: set admin_token parameter, mock get_room_id and mock do_requests to raise exception.
- act: create management room.
- assert: exception is raised.
- """
- moderator_room_id = token_hex(16)
- monkeypatch.setattr("synapse.api.get_room_id", mock.MagicMock(return_value=moderator_room_id))
- mock_response = mock.MagicMock()
- mock_response.json.return_value = {}
- do_request_mock = mock.MagicMock(return_value=mock_response)
- monkeypatch.setattr("synapse.api._do_request", do_request_mock)
- admin_access_token = token_hex(16)
-
- with pytest.raises(synapse.APIError, match="'room_id'"):
- synapse.create_management_room(admin_access_token=admin_access_token)
-
-
def test_make_room_admin_success(monkeypatch: pytest.MonkeyPatch):
"""
arrange: set User, server, admin_access_token and room_id parameters.
@@ -684,49 +366,6 @@ def test_get_version_regex_error(mock_session):
synapse.api.get_version("foo")
-def test_promote_user_admin_success(monkeypatch: pytest.MonkeyPatch):
- """
- arrange: set User, server and admin_access_token.
- act: call promote_user_admin.
- assert: request is called as expected.
- """
- username = "any-user"
- user = User(username=username, admin=True)
- admin_access_token = token_hex(16)
- server = token_hex(16)
- do_request_mock = mock.MagicMock(return_value=mock.MagicMock())
- monkeypatch.setattr("synapse.api._do_request", do_request_mock)
-
- synapse.promote_user_admin(user, admin_access_token=admin_access_token, server=server)
-
- user_id = f"@{user.username}:{server}"
- expected_url = synapse.api.PROMOTE_USER_ADMIN_URL.replace("user_id", user_id)
- do_request_mock.assert_called_once_with(
- "PUT",
- expected_url,
- admin_access_token=admin_access_token,
- json={"admin": True},
- )
-
-
-def test_promote_user_admin_error(monkeypatch: pytest.MonkeyPatch):
- """
- arrange: set User, server, admin_access_token and admin_access_token.
- act: call promote_user_admin.
- assert: exception is raised as expected.
- """
- username = "any-user"
- user = User(username=username, admin=True)
- admin_access_token = token_hex(16)
- server = token_hex(16)
- expected_error_msg = "Failed to connect"
- do_request_mock = mock.MagicMock(side_effect=synapse.APIError(expected_error_msg))
- monkeypatch.setattr("synapse.api._do_request", do_request_mock)
-
- with pytest.raises(synapse.APIError, match=expected_error_msg):
- synapse.promote_user_admin(user, admin_access_token=admin_access_token, server=server)
-
-
def test_is_token_valid_correct(monkeypatch: pytest.MonkeyPatch):
"""
arrange: given an access token and mocking http requests not to fail.
diff --git a/tests/unit/test_synapse_workload.py b/tests/unit/test_synapse_workload.py
index 4a94f547..b4e5b98b 100644
--- a/tests/unit/test_synapse_workload.py
+++ b/tests/unit/test_synapse_workload.py
@@ -1,4 +1,4 @@
-# Copyright 2024 Canonical Ltd.
+# Copyright 2025 Canonical Ltd.
# See LICENSE file for licensing details.
"""Synapse workload unit tests."""
@@ -338,45 +338,6 @@ def test_enable_forgotten_room_success(config_content: dict[str, typing.Any]):
assert yaml.safe_dump(content) == yaml.safe_dump(expected_config_content)
-def test_get_mjolnir_config_success():
- """
- arrange: set access token and room id parameters.
- act: call _get_mjolnir_config.
- assert: config returns as expected.
- """
- access_token = token_hex(16)
- room_id = token_hex(16)
-
- config = synapse.workload._get_mjolnir_config(access_token=access_token, room_id=room_id)
-
- assert config["accessToken"] == access_token
- assert config["managementRoom"] == room_id
-
-
-def test_generate_mjolnir_config_success(monkeypatch: pytest.MonkeyPatch):
- """
- arrange: set container, access token and room id parameters.
- act: call generate_mjolnir_config.
- assert: file is pushed as expected.
- """
- access_token = token_hex(16)
- room_id = token_hex(16)
- push_mock = MagicMock()
- container_mock = MagicMock()
- monkeypatch.setattr(container_mock, "push", push_mock)
-
- synapse.generate_mjolnir_config(
- container=container_mock, access_token=access_token, room_id=room_id
- )
-
- expected_config = synapse.workload._get_mjolnir_config(
- access_token=access_token, room_id=room_id
- )
- push_mock.assert_called_once_with(
- synapse.MJOLNIR_CONFIG_PATH, yaml.safe_dump(expected_config), make_dirs=True
- )
-
-
SMTP_CONFIGURATION = SMTPConfiguration(
enable_tls=True,
force_tls=False,
diff --git a/tox.ini b/tox.ini
index 2e93538f..e96a983e 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,4 +1,4 @@
-# Copyright 2024 Canonical Ltd.
+# Copyright 2025 Canonical Ltd.
# See LICENSE file for licensing details.
[tox]
@@ -116,6 +116,7 @@ commands =
[testenv:integration]
description = Run integration tests
deps =
+ allure-pytest>=2.8.18
macaroonbakery==1.3.2 # fixes "TypeError: Descriptors cannot be created directly." protobuf error
juju >=3.0
pytest
@@ -125,6 +126,7 @@ deps =
boto3
# Type error problem with newer version of macaroonbakery
macaroonbakery==1.3.2
+ git+https://github.com/canonical/data-platform-workflows@v24.0.0\#subdirectory=python/pytest_plugins/allure_pytest_collection_report
-r{toxinidir}/requirements.txt
commands =
pytest -v -x --tb native --ignore={[vars]tst_path}unit --log-cli-level=INFO -s {posargs}