diff --git a/.github/workflows/integration_test.yaml b/.github/workflows/integration_test.yaml index 02c84541..05667f2a 100644 --- a/.github/workflows/integration_test.yaml +++ b/.github/workflows/integration_test.yaml @@ -12,5 +12,5 @@ jobs: chaos-enabled: false chaos-experiments: pod-delete trivy-image-config: "trivy.yaml" - self-hosted-runner: true - self-hosted-runner-label: "edge" + juju-channel: 3.1/stable + channel: 1.28-strict/stable diff --git a/config.yaml b/config.yaml index 75571f4b..aad862bc 100644 --- a/config.yaml +++ b/config.yaml @@ -36,6 +36,10 @@ options: Comma separated list of IP address CIDR ranges that should be allowed for federation, identity servers, push servers, and for checking key validity for third-party invite events. + notif_from: + type: string + description: defines the "From" address to use when sending emails. + It must be set if the SMTP integration is enabled. Defaults to server_name. public_baseurl: type: string description: | @@ -53,31 +57,6 @@ options: Synapse server name. Must be set to deploy the charm. Corresponds to the server_name option on Synapse configuration file and sets the public-facing domain of the server. - smtp_enable_tls: - type: boolean - description: If enabled, STARTTLS will be used to use an encrypted SMTP - connection. - default: true - smtp_host: - type: string - description: The hostname of the SMTP host used for sending emails. - default: '' - smtp_notif_from: - type: string - description: defines the "From" address to use when sending emails. - It must be set if email sending is enabled. Defaults to server_name. - smtp_pass: - type: string - description: The password if the SMTP server requires authentication. - default: '' - smtp_port: - type: int - description: The port of the SMTP server used for sending emails. - default: 25 - smtp_user: - type: string - description: The username if the SMTP server requires authentication. - default: '' trusted_key_servers: type: string description: Comma separated list of trusted servers to download signing diff --git a/docs/how-to/configure-smtp.md b/docs/how-to/configure-smtp.md new file mode 100644 index 00000000..26939547 --- /dev/null +++ b/docs/how-to/configure-smtp.md @@ -0,0 +1,36 @@ +# How to integrate with SMTP for sending notifications + +This document shows how to integrate Synapse with SMTP for sending +emails. Synapse should be deployed beforehand. + +## Deploy smtp-integrator charm + +For synapse to use SMTP, it uses the smtp-integrator charm. Replace the configuration options with your specific configuration. +Configuring SMTP without tls or starttls or without authentication is not supported. + +``` +juju deploy smtp-integrator --channel edge +juju config smtp-integrator host= port= user= password= auth_type=plain transport_security=tls +``` + +## Configure email to use in `From` + +Configure the "From" mail for Synapse with: +``` +juju config synapse notif_from= +``` + +## Integrate with Synapse + +You can run it with the legacy integration `smtp-legacy` or with +the new integration using secrets `smtp`. A Juju version +with secrets is required for the `smtp` integration. + +With the old integration without using secrets, run: +``` +juju integrate smtp-integrator:smtp-legacy synapse:smtp +``` +For the new integration with secrets, run: +``` +juju integrate smtp-integrator:smtp synapse:smtp +``` \ No newline at end of file diff --git a/docs/how-to/contribute.md b/docs/how-to/contribute.md index 15069740..b6779a4b 100644 --- a/docs/how-to/contribute.md +++ b/docs/how-to/contribute.md @@ -84,7 +84,7 @@ the registry: skopeo --insecure-policy copy oci-archive:synapse_1.0_amd64.rock docker-daemon:localhost:32000/synapse:latest docker push localhost:32000/synapse:latest cd [project_dir]/nginx_rock && rockcraft pack rockcraft.yaml - skopeo --insecure-policy copy oci-archive:synapse_nginx_1.0_amd64.rock docker-daemon:localhost:32000/synapse-nginx:latest + skopeo --insecure-policy copy oci-archive:synapse-nginx_1.0_amd64.rock docker-daemon:localhost:32000/synapse-nginx:latest docker push localhost:32000/synapse-nginx:latest ### Deploy diff --git a/docs/index.md b/docs/index.md index 7aaaa03f..6b1808eb 100644 --- a/docs/index.md +++ b/docs/index.md @@ -28,7 +28,8 @@ If there’s a particular area of documentation that you’d like to see that’ 1. [Tutorial](tutorial) 1. [Getting Started](tutorial/getting-started.md) 1. [How to](how-to) - 1. [Contribute](how-to/contribute.md) + 1. [Configure SMTP](how-to/configure-smtp.md) + 2. [Contribute](how-to/contribute.md) 1. [Reference](reference) 1. [Actions](reference/actions.md) 1. [Integrations](reference/integrations.md) diff --git a/docs/reference/integrations.md b/docs/reference/integrations.md index b983d6cd..ef9583cc 100644 --- a/docs/reference/integrations.md +++ b/docs/reference/integrations.md @@ -73,4 +73,24 @@ Note that `public_baseurl` configuration set the public-facing base URL that clients use to access this Homeserver. It's used as `entity_id` if set instead of https://server_name. -See more information in [Charm Architecture](https://charmhub.io/synapse/docs/explanation-charm-architecture). \ No newline at end of file +See more information in [Charm Architecture](https://charmhub.io/synapse/docs/explanation-charm-architecture). + + +### smtp + +_Interface_: smtp +_Supported charms_: [smtp-integrator](https://charmhub.io/smtp-integrator/) + +Integrating Synapse with SMTP Integrator provides SMTP configuration details so +a smtp server can be used in Synapse. + +Example smtp integrate command: `juju integrate synapse smtp-integrator:smtp` + +Note that the smtp-integrator provides two interfaces, `smtp` and `smtp-legacy`. +Only use the first one if the Juju version used supports secrets. The "From" email +is set with the Synapse configuration option `notif_from`. + +For the smtp-integrator, insecure configurations with `transport_security=none` or not +authenticated connections with `auth_type=none` are not supported. + +See more information in [Charm Architecture](https://charmhub.io/synapse/docs/explanation-charm-architecture). diff --git a/lib/charms/smtp_integrator/v0/smtp.py b/lib/charms/smtp_integrator/v0/smtp.py new file mode 100644 index 00000000..adf095cd --- /dev/null +++ b/lib/charms/smtp_integrator/v0/smtp.py @@ -0,0 +1,352 @@ +# Copyright 2024 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. + +This library contains the Requires and Provides classes for handling the integration +between an application and a charm providing the `smtp` and `smtp-legacy` integrations. +If the requirer charm supports secrets, the preferred approach is to use the `smtp` +relation to leverage them. +This library also contains a `SmtpRelationData` class to wrap the SMTP data that will +be shared via the integration. + +### Requirer Charm + +```python + +from charms.smtp_integrator.v0.smtp import SmtpDataAvailableEvent, SmtpRequires + +class SmtpRequirerCharm(ops.CharmBase): + def __init__(self, *args): + super().__init__(*args) + self.smtp = smtp.SmtpRequires(self) + self.framework.observe(self.smtp.on.smtp_data_available, self._handler) + ... + + def _handler(self, events: SmtpDataAvailableEvent) -> None: + ... + +``` + +As shown above, the library provides a custom event to handle the scenario in +which new SMTP data has been added or updated. + +### Provider Charm + +Following the previous example, this is an example of the provider charm. + +```python +from charms.smtp_integrator.v0.smtp import SmtpProvides + +class SmtpProviderCharm(ops.CharmBase): + def __init__(self, *args): + super().__init__(*args) + self.smtp = SmtpProvides(self) + ... + +``` +The SmtpProvides object wraps the list of relations into a `relations` property +and provides an `update_relation_data` method to update the relation data by passing +a `SmtpRelationData` data object. + +```python +class SmtpProviderCharm(ops.CharmBase): + ... + + def _on_config_changed(self, _) -> None: + for relation in self.model.relations[self.smtp.relation_name]: + self.smtp.update_relation_data(relation, self._get_smtp_data()) + +``` +""" + +# The unique Charmhub library identifier, never change it +LIBID = "09583c2f9c1d4c0f9a40244cfc20b0c2" + +# Increment this major API version when introducing breaking changes +LIBAPI = 0 + +# Increment this PATCH version before using `charmcraft publish-lib` or reset +# to 0 if you are raising the major API version +LIBPATCH = 7 + +# pylint: disable=wrong-import-position +import itertools +import logging +from enum import Enum +from typing import Dict, Optional + +import ops +from pydantic import BaseModel, Field, ValidationError + +logger = logging.getLogger(__name__) + +DEFAULT_RELATION_NAME = "smtp" +LEGACY_RELATION_NAME = "smtp-legacy" + + +class TransportSecurity(str, Enum): + """Represent the transport security values. + + Attributes: + NONE: none + STARTTLS: starttls + TLS: tls + """ + + NONE = "none" + STARTTLS = "starttls" + TLS = "tls" + + +class AuthType(str, Enum): + """Represent the auth type values. + + Attributes: + NONE: none + NOT_PROVIDED: not_provided + PLAIN: plain + """ + + NONE = "none" + NOT_PROVIDED = "not_provided" + PLAIN = "plain" + + +class SmtpRelationData(BaseModel): + """Represent the relation data. + + Attributes: + host: The hostname or IP address of the outgoing SMTP relay. + port: The port of the outgoing SMTP relay. + user: The SMTP AUTH user to use for the outgoing SMTP relay. + password: The SMTP AUTH password to use for the outgoing SMTP relay. + password_id: The secret ID where the SMTP AUTH password for the SMTP relay is stored. + auth_type: The type used to authenticate with the SMTP relay. + transport_security: The security protocol to use for the outgoing SMTP relay. + domain: The domain used by the sent emails from SMTP relay. + """ + + host: str = Field(..., min_length=1) + port: int = Field(None, ge=1, le=65536) + user: Optional[str] + password: Optional[str] + password_id: Optional[str] + auth_type: AuthType + transport_security: TransportSecurity + domain: Optional[str] + + def to_relation_data(self) -> Dict[str, str]: + """Convert an instance of SmtpRelationData to the relation representation. + + Returns: + Dict containing the representation. + """ + result = { + "host": str(self.host), + "port": str(self.port), + "auth_type": self.auth_type.value, + "transport_security": self.transport_security.value, + } + if self.domain: + result["domain"] = self.domain + if self.user: + result["user"] = self.user + if self.password: + result["password"] = self.password + if self.password_id: + result["password_id"] = self.password_id + return result + + +class SmtpDataAvailableEvent(ops.RelationEvent): + """Smtp event emitted when relation data has changed. + + Attributes: + host: The hostname or IP address of the outgoing SMTP relay. + port: The port of the outgoing SMTP relay. + user: The SMTP AUTH user to use for the outgoing SMTP relay. + password: The SMTP AUTH password to use for the outgoing SMTP relay. + password_id: The secret ID where the SMTP AUTH password for the SMTP relay is stored. + auth_type: The type used to authenticate with the SMTP relay. + transport_security: The security protocol to use for the outgoing SMTP relay. + domain: The domain used by the sent emails from SMTP relay. + """ + + @property + def host(self) -> str: + """Fetch the SMTP host from the relation.""" + assert self.relation.app + return self.relation.data[self.relation.app].get("host") + + @property + def port(self) -> int: + """Fetch the SMTP port from the relation.""" + assert self.relation.app + return int(self.relation.data[self.relation.app].get("port")) + + @property + def user(self) -> str: + """Fetch the SMTP user from the relation.""" + assert self.relation.app + return self.relation.data[self.relation.app].get("user") + + @property + def password(self) -> str: + """Fetch the SMTP password from the relation.""" + assert self.relation.app + return self.relation.data[self.relation.app].get("password") + + @property + def password_id(self) -> str: + """Fetch the SMTP password from the relation.""" + assert self.relation.app + return self.relation.data[self.relation.app].get("password_id") + + @property + def auth_type(self) -> AuthType: + """Fetch the SMTP auth type from the relation.""" + assert self.relation.app + return AuthType(self.relation.data[self.relation.app].get("auth_type")) + + @property + def transport_security(self) -> TransportSecurity: + """Fetch the SMTP transport security protocol from the relation.""" + assert self.relation.app + return TransportSecurity(self.relation.data[self.relation.app].get("transport_security")) + + @property + def domain(self) -> str: + """Fetch the SMTP domain from the relation.""" + assert self.relation.app + return self.relation.data[self.relation.app].get("domain") + + +class SmtpRequiresEvents(ops.CharmEvents): + """SMTP events. + + This class defines the events that a SMTP requirer can emit. + + Attributes: + smtp_data_available: the SmtpDataAvailableEvent. + """ + + smtp_data_available = ops.EventSource(SmtpDataAvailableEvent) + + +class SmtpRequires(ops.Object): + """Requirer side of the SMTP relation. + + Attributes: + on: events the provider can emit. + """ + + on = SmtpRequiresEvents() + + def __init__(self, charm: ops.CharmBase, relation_name: str = DEFAULT_RELATION_NAME) -> None: + """Construct. + + Args: + charm: the provider charm. + relation_name: the relation name. + """ + super().__init__(charm, relation_name) + self.charm = charm + self.relation_name = relation_name + self.framework.observe(charm.on[relation_name].relation_changed, self._on_relation_changed) + + def get_relation_data(self) -> Optional[SmtpRelationData]: + """Retrieve the relation data. + + Returns: + SmtpRelationData: the relation data. + """ + relation = self.model.get_relation(self.relation_name) + return self._get_relation_data_from_relation(relation) if relation else None + + def _get_relation_data_from_relation(self, relation: ops.Relation) -> SmtpRelationData: + """Retrieve the relation data. + + Args: + relation: the relation to retrieve the data from. + + Returns: + SmtpRelationData: the relation data. + """ + assert relation.app + relation_data = relation.data[relation.app] + return SmtpRelationData( + host=relation_data.get("host"), + port=relation_data.get("port"), + user=relation_data.get("user"), + password=relation_data.get("password"), + password_id=relation_data.get("password_id"), + auth_type=relation_data.get("auth_type"), + transport_security=relation_data.get("transport_security"), + domain=relation_data.get("domain"), + ) + + def _is_relation_data_valid(self, relation: ops.Relation) -> bool: + """Validate the relation data. + + Args: + relation: the relation to validate. + + Returns: + true: if the relation data is valid. + """ + try: + _ = self._get_relation_data_from_relation(relation) + return True + except ValidationError as ex: + error_fields = set( + itertools.chain.from_iterable(error["loc"] for error in ex.errors()) + ) + error_field_str = " ".join(f"{f}" for f in error_fields) + logger.warning("Error validation the relation data %s", error_field_str) + return False + + def _on_relation_changed(self, event: ops.RelationChangedEvent) -> None: + """Event emitted when the relation has changed. + + Args: + event: event triggering this handler. + """ + assert event.relation.app + relation_data = event.relation.data[event.relation.app] + if relation_data: + if relation_data["auth_type"] == AuthType.NONE.value: + logger.warning('Insecure setting: auth_type has a value "none"') + if relation_data["transport_security"] == TransportSecurity.NONE.value: + logger.warning('Insecure setting: transport_security has value "none"') + if self._is_relation_data_valid(event.relation): + self.on.smtp_data_available.emit(event.relation, app=event.app, unit=event.unit) + + +class SmtpProvides(ops.Object): + """Provider side of the SMTP relation.""" + + def __init__(self, charm: ops.CharmBase, relation_name: str = DEFAULT_RELATION_NAME) -> None: + """Construct. + + Args: + charm: the provider charm. + relation_name: the relation name. + """ + super().__init__(charm, relation_name) + self.charm = charm + self.relation_name = relation_name + + def update_relation_data(self, relation: ops.Relation, smtp_data: SmtpRelationData) -> None: + """Update the relation data. + + Args: + relation: the relation for which to update the data. + smtp_data: a SmtpRelationData instance wrapping the data to be updated. + """ + relation_data = smtp_data.to_relation_data() + if relation_data["auth_type"] == AuthType.NONE.value: + logger.warning('Insecure setting: auth_type has a value "none"') + if relation_data["transport_security"] == TransportSecurity.NONE.value: + logger.warning('Insecure setting: transport_security has value "none"') + relation.data[self.charm.model.app].update(relation_data) diff --git a/metadata.yaml b/metadata.yaml index 59a2c69a..539ca3f6 100644 --- a/metadata.yaml +++ b/metadata.yaml @@ -61,6 +61,10 @@ requires: interface: saml limit: 1 optional: true + smtp: + interface: smtp + limit: 1 + optional: true peers: synapse-peers: diff --git a/pyproject.toml b/pyproject.toml index 038117f0..18224ee5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,9 +18,7 @@ show_missing = true [tool.pytest.ini_options] minversion = "6.0" log_cli_level = "INFO" -markers = [ - "requires_secrets: mark tests that require external secrets" -] +markers = ["requires_secrets: mark tests that require external secrets"] [tool.pylint.'MESSAGES CONTROL'] extension-pkg-whitelist = "pydantic" diff --git a/src-docs/charm.py.md b/src-docs/charm.py.md index e73766d5..85e5f1ec 100644 --- a/src-docs/charm.py.md +++ b/src-docs/charm.py.md @@ -18,7 +18,7 @@ Charm for Synapse on kubernetes. ## class `SynapseCharm` Charm the service. - + ### function `__init__` @@ -75,7 +75,7 @@ Unit that this execution is responsible for. --- - + ### function `change_config` @@ -87,7 +87,7 @@ Change configuration. --- - + ### function `get_admin_access_token` diff --git a/src-docs/charm_state.py.md b/src-docs/charm_state.py.md index b1801a31..c92fd211 100644 --- a/src-docs/charm_state.py.md +++ b/src-docs/charm_state.py.md @@ -14,7 +14,7 @@ Exception raised when a charm configuration is found to be invalid. Attrs: msg (str): Explanation of the error. - + ### function `__init__` @@ -46,6 +46,7 @@ State of the Charm. - `synapse_config`: synapse configuration. - `datasource`: datasource information. - `saml_config`: saml configuration. + - `smtp_config`: smtp configuration. - `proxy`: proxy information. @@ -64,7 +65,7 @@ Get charm proxy information from juju charm environment. --- - + ### classmethod `from_charm` @@ -72,7 +73,8 @@ Get charm proxy information from juju charm environment. from_charm( charm: CharmBase, datasource: Optional[DatasourcePostgreSQL], - saml_config: Optional[SAMLConfiguration] + saml_config: Optional[SAMLConfiguration], + smtp_config: Optional[SMTPConfiguration] ) → CharmState ``` @@ -85,6 +87,7 @@ Initialize a new instance of the CharmState class from the associated charm. - `charm`: The charm instance associated with this state. - `datasource`: datasource information to be used by Synapse. - `saml_config`: saml configuration to be used by Synapse. + - `smtp_config`: SMTP configuration to be used by Synapse. Return: The CharmState instance created by the provided charm. @@ -127,15 +130,10 @@ Represent Synapse builtin configuration values. - `enable_room_list_search`: enable_room_list_search config. - `federation_domain_whitelist`: federation_domain_whitelist config. - `ip_range_whitelist`: ip_range_whitelist config. + - `notif_from`: defines the "From" address to use when sending emails. - `public_baseurl`: public_baseurl config. - `report_stats`: report_stats config. - `server_name`: server_name config. - - `smtp_enable_tls`: enable tls while connecting to SMTP server. - - `smtp_host`: SMTP host. - - `smtp_notif_from`: defines the "From" address to use when sending emails. - - `smtp_pass`: password to authenticate to SMTP host. - - `smtp_port`: SMTP port. - - `smtp_user`: username to authenticate to SMTP host. - `trusted_key_servers`: trusted_key_servers config. @@ -143,34 +141,31 @@ Represent Synapse builtin configuration values. --- - + -### classmethod `set_default_smtp_notif_from` +### classmethod `get_default_notif_from` ```python -set_default_smtp_notif_from( - smtp_notif_from: Optional[str], - values: dict -) → Optional[str] +get_default_notif_from(notif_from: Optional[str], values: dict) → Optional[str] ``` -Set server_name as default value to smtp_notif_from. +Set server_name as default value to notif_from. **Args:** - - `smtp_notif_from`: the smtp_notif_from current value. + - `notif_from`: the notif_from current value. - `values`: values already defined. **Returns:** - The default value for smtp_notif_from if not defined. + The default value for notif_from if not defined. --- - + ### classmethod `to_yes_or_no` diff --git a/src-docs/charm_types.py.md b/src-docs/charm_types.py.md index 2071d21e..f2557d5b 100644 --- a/src-docs/charm_types.py.md +++ b/src-docs/charm_types.py.md @@ -42,3 +42,24 @@ A named tuple representing a SAML configuration. +--- + +## class `SMTPConfiguration` +A named tuple representing SMTP configuration. + + + +**Attributes:** + + - `host`: The hostname of the outgoing SMTP server. + - `port`: The port on the mail server for outgoing SMTP. + - `user`: Optional username for authentication. + - `password`: Optional password for authentication. + - `enable_tls`: If enabled, if the server supports TLS, it will be used. + - `force_tls`: If this option is set to true, TLS is used from the start (Implicit TLS) and the option require_transport_security is ignored. + - `require_transport_security`: Set to true to require TLS transport security for SMTP. + + + + + diff --git a/src-docs/pebble.py.md b/src-docs/pebble.py.md index 684868cc..7608a81a 100644 --- a/src-docs/pebble.py.md +++ b/src-docs/pebble.py.md @@ -57,7 +57,7 @@ Change the configuration. --- - + ### function `enable_saml` @@ -69,6 +69,30 @@ Enable SAML while receiving on_saml_data_available event. +**Args:** + + - `container`: Charm container. + + + +**Raises:** + + - `PebbleServiceError`: if something goes wrong while interacting with Pebble. + +--- + + + +### function `enable_smtp` + +```python +enable_smtp(container: Container) → None +``` + +Enable SMTP while receiving on_smtp_data_available event. + + + **Args:** - `container`: Charm container. @@ -117,7 +141,7 @@ Replan Synapse NGINX service. --- - + ### function `reset_instance` diff --git a/src-docs/smtp_observer.py.md b/src-docs/smtp_observer.py.md new file mode 100644 index 00000000..2f46b2c8 --- /dev/null +++ b/src-docs/smtp_observer.py.md @@ -0,0 +1,66 @@ + + + + +# module `smtp_observer.py` +The SMTP integrator relation observer. + + + +--- + +## class `SMTPObserver` +The SMTP relation observer. + +Attrs: _pebble_service: instance of pebble service. + + + +### function `__init__` + +```python +__init__(charm: CharmBase) +``` + +Initialize the observer and register event handlers. + + + +**Args:** + + - `charm`: The parent charm to attach the observer to. + + +--- + +#### property model + +Shortcut for more simple access the model. + + + +--- + + + +### function `get_relation_as_smtp_conf` + +```python +get_relation_as_smtp_conf() → Optional[SMTPConfiguration] +``` + +Get SMTP data from relation. + + + +**Returns:** + + - `Dict`: Information needed for setting environment variables. + + + +**Raises:** + + - `CharmConfigInvalidError`: If the SMTP configurations is not supported. + + diff --git a/src/charm.py b/src/charm.py index cf2ca00c..a17903b5 100755 --- a/src/charm.py +++ b/src/charm.py @@ -24,6 +24,7 @@ from observability import Observability from pebble import PebbleService, PebbleServiceError from saml_observer import SAMLObserver +from smtp_observer import SMTPObserver from user import User JUJU_HAS_SECRETS = JujuVersion.from_environ().has_secrets @@ -38,6 +39,10 @@ class SynapseCharm(ops.CharmBase): """Charm the service.""" + # This class has several instance attributes like observers, libraries and state. + # Consider refactoring if more attributes are added. + # pylint: disable=too-many-instance-attributes + def __init__(self, *args: typing.Any) -> None: """Construct. @@ -47,11 +52,13 @@ def __init__(self, *args: typing.Any) -> None: super().__init__(*args) self._database = DatabaseObserver(self) self._saml = SAMLObserver(self) + self._smtp = SMTPObserver(self) try: self._charm_state = CharmState.from_charm( charm=self, datasource=self._database.get_relation_as_datasource(), saml_config=self._saml.get_relation_as_saml_conf(), + smtp_config=self._smtp.get_relation_as_smtp_conf(), ) except CharmConfigInvalidError as exc: self.model.unit.status = ops.BlockedStatus(exc.msg) diff --git a/src/charm_state.py b/src/charm_state.py index 2464c8d3..7dd4bd08 100644 --- a/src/charm_state.py +++ b/src/charm_state.py @@ -1,5 +1,3 @@ -#!/usr/bin/env python3 - # Copyright 2024 Canonical Ltd. # See LICENSE file for licensing details. @@ -22,7 +20,7 @@ validator, ) -from charm_types import DatasourcePostgreSQL, SAMLConfiguration +from charm_types import DatasourcePostgreSQL, SAMLConfiguration, SMTPConfiguration class CharmConfigInvalidError(Exception): @@ -65,15 +63,10 @@ class SynapseConfig(BaseModel): # pylint: disable=too-few-public-methods enable_room_list_search: enable_room_list_search config. federation_domain_whitelist: federation_domain_whitelist config. ip_range_whitelist: ip_range_whitelist config. + notif_from: defines the "From" address to use when sending emails. public_baseurl: public_baseurl config. report_stats: report_stats config. server_name: server_name config. - smtp_enable_tls: enable tls while connecting to SMTP server. - smtp_host: SMTP host. - smtp_notif_from: defines the "From" address to use when sending emails. - smtp_pass: password to authenticate to SMTP host. - smtp_port: SMTP port. - smtp_user: username to authenticate to SMTP host. trusted_key_servers: trusted_key_servers config. """ @@ -86,12 +79,7 @@ class SynapseConfig(BaseModel): # pylint: disable=too-few-public-methods public_baseurl: str | None = Field(None) report_stats: str | None = Field(None) server_name: str = Field(..., min_length=2) - smtp_enable_tls: bool = True - smtp_host: str | None = Field(None) - smtp_notif_from: str | None = Field(None) - smtp_pass: str | None = Field(None) - smtp_port: int | None = Field(None) - smtp_user: str | None = Field(None) + notif_from: str | None = Field(None) trusted_key_servers: str | None = Field( None, regex=r"^[A-Za-z0-9][A-Za-z0-9-.]*(?:,[A-Za-z0-9][A-Za-z0-9-.]*)*\.\D{2,4}$" ) @@ -105,24 +93,24 @@ class Config: # pylint: disable=too-few-public-methods extra = Extra.allow - @validator("smtp_notif_from", pre=True, always=True) + @validator("notif_from", pre=True, always=True) @classmethod - def set_default_smtp_notif_from( - cls, smtp_notif_from: typing.Optional[str], values: dict + def get_default_notif_from( + cls, notif_from: typing.Optional[str], values: dict ) -> typing.Optional[str]: - """Set server_name as default value to smtp_notif_from. + """Set server_name as default value to notif_from. Args: - smtp_notif_from: the smtp_notif_from current value. + notif_from: the notif_from current value. values: values already defined. Returns: - The default value for smtp_notif_from if not defined. + The default value for notif_from if not defined. """ server_name = values.get("server_name") - if smtp_notif_from is None and server_name: + if notif_from is None and server_name: return server_name - return smtp_notif_from + return notif_from @validator("report_stats") @classmethod @@ -148,12 +136,14 @@ class CharmState: synapse_config: synapse configuration. datasource: datasource information. saml_config: saml configuration. + smtp_config: smtp configuration. proxy: proxy information. """ synapse_config: SynapseConfig datasource: typing.Optional[DatasourcePostgreSQL] saml_config: typing.Optional[SAMLConfiguration] + smtp_config: typing.Optional[SMTPConfiguration] @property def proxy(self) -> "ProxyConfig": @@ -177,6 +167,7 @@ def from_charm( charm: ops.CharmBase, datasource: typing.Optional[DatasourcePostgreSQL], saml_config: typing.Optional[SAMLConfiguration], + smtp_config: typing.Optional[SMTPConfiguration], ) -> "CharmState": """Initialize a new instance of the CharmState class from the associated charm. @@ -184,6 +175,7 @@ def from_charm( charm: The charm instance associated with this state. datasource: datasource information to be used by Synapse. saml_config: saml configuration to be used by Synapse. + smtp_config: SMTP configuration to be used by Synapse. Return: The CharmState instance created by the provided charm. @@ -205,4 +197,5 @@ def from_charm( synapse_config=valid_synapse_config, datasource=datasource, saml_config=saml_config, + smtp_config=smtp_config, ) diff --git a/src/charm_types.py b/src/charm_types.py index ca265b8e..8f2a84e1 100644 --- a/src/charm_types.py +++ b/src/charm_types.py @@ -6,6 +6,7 @@ """Type definitions for the Synapse charm.""" import typing +from dataclasses import dataclass class DatasourcePostgreSQL(typing.TypedDict): @@ -36,3 +37,27 @@ class SAMLConfiguration(typing.TypedDict): entity_id: str metadata_url: str + + +@dataclass(frozen=True) +class SMTPConfiguration(typing.TypedDict): + """A named tuple representing SMTP configuration. + + Attributes: + host: The hostname of the outgoing SMTP server. + port: The port on the mail server for outgoing SMTP. + user: Optional username for authentication. + password: Optional password for authentication. + enable_tls: If enabled, if the server supports TLS, it will be used. + force_tls: If this option is set to true, TLS is used from the start (Implicit TLS) + and the option require_transport_security is ignored. + require_transport_security: Set to true to require TLS transport security for SMTP. + """ + + host: str + port: int + user: typing.Optional[str] + password: typing.Optional[str] + enable_tls: bool + force_tls: bool + require_transport_security: bool diff --git a/src/pebble.py b/src/pebble.py index 0961915f..c8b89778 100644 --- a/src/pebble.py +++ b/src/pebble.py @@ -90,7 +90,8 @@ def change_config(self, container: ops.model.Container) -> None: # noqa: C901 if self._charm_state.saml_config is not None: logger.debug("pebble.change_config: Enabling SAML") synapse.enable_saml(container=container, charm_state=self._charm_state) - if self._charm_state.synapse_config.smtp_host: + if self._charm_state.smtp_config is not None: + logger.debug("pebble.change_config: Enabling SMTP") synapse.enable_smtp(container=container, charm_state=self._charm_state) if not self._charm_state.synapse_config.enable_password_config: synapse.disable_password_config(container=container) @@ -131,6 +132,22 @@ def enable_saml(self, container: ops.model.Container) -> None: except (synapse.WorkloadError, ops.pebble.PathError) as exc: raise PebbleServiceError(str(exc)) from exc + def enable_smtp(self, container: ops.model.Container) -> None: + """Enable SMTP while receiving on_smtp_data_available event. + + Args: + container: Charm container. + + Raises: + PebbleServiceError: if something goes wrong while interacting with Pebble. + """ + try: + logger.debug("pebble.enable_smtp: Enabling SMTP") + synapse.enable_smtp(container=container, charm_state=self._charm_state) + self.restart_synapse(container) + except (synapse.WorkloadError, ops.pebble.PathError) as exc: + raise PebbleServiceError(str(exc)) from exc + def reset_instance(self, container: ops.model.Container) -> None: """Reset instance. diff --git a/src/smtp_observer.py b/src/smtp_observer.py new file mode 100644 index 00000000..8ce9f1d2 --- /dev/null +++ b/src/smtp_observer.py @@ -0,0 +1,155 @@ +# Copyright 2024 Canonical Ltd. +# See LICENSE file for licensing details. + +"""The SMTP integrator relation observer.""" + +# ignoring duplicate-code with container connect check in the saml observer. +# pylint: disable=R0801 + +import logging +import typing +from typing import Optional + +import ops +from charms.smtp_integrator.v0.smtp import ( + AuthType, + SmtpDataAvailableEvent, + SmtpRelationData, + SmtpRequires, + TransportSecurity, +) +from ops.charm import CharmBase +from ops.framework import Object +from pydantic import ValidationError + +import synapse +from charm_state import CharmConfigInvalidError +from charm_types import SMTPConfiguration +from pebble import PebbleServiceError + +logger = logging.getLogger(__name__) + + +class SMTPObserver(Object): + """The SMTP relation observer. + + Attrs: + _pebble_service: instance of pebble service. + """ + + _RELATION_NAME = "smtp" + + def __init__(self, charm: CharmBase): + """Initialize the observer and register event handlers. + + Args: + charm: The parent charm to attach the observer to. + """ + super().__init__(charm, "smtp-observer") + self._charm = charm + self.smtp = SmtpRequires( + self._charm, + relation_name=self._RELATION_NAME, + ) + self.framework.observe( + self.smtp.on.smtp_data_available, + self._on_smtp_relation_data_available, + ) + + def get_relation_as_smtp_conf(self) -> Optional[SMTPConfiguration]: + """Get SMTP data from relation. + + Returns: + Dict: Information needed for setting environment variables. + + Raises: + CharmConfigInvalidError: If the SMTP configurations is not supported. + """ + try: + relation_data: Optional[SmtpRelationData] = self.smtp.get_relation_data() + except ValidationError: + # ValidationError happens in the smtp(_legacy)relation_created event, as + # the relation databag is empty at that point. + logger.info("SMTP databag is empty. SMTP information will be set in the next event.") + return None + + if relation_data is None: + return None + + if relation_data.transport_security == TransportSecurity.NONE: + raise CharmConfigInvalidError("Transport security NONE is not supported for SMTP") + + if relation_data.auth_type != AuthType.PLAIN: + raise CharmConfigInvalidError("Only PLAIN auth type is supported for SMTP") + + user = relation_data.user + password = self._get_password_from_relation_data(relation_data) + + # Not all combinations for the next variables are correct. See: + # https://github.com/matrix-org/synapse/blob/develop/synapse/config/emailconfig.py + force_tls = False + enable_tls = False + require_transport_security = False + if relation_data.transport_security == TransportSecurity.STARTTLS: + enable_tls = True + require_transport_security = True + elif relation_data.transport_security == TransportSecurity.TLS: + force_tls = True + enable_tls = True + require_transport_security = True + + return SMTPConfiguration( + enable_tls=enable_tls, + force_tls=force_tls, + require_transport_security=require_transport_security, + host=relation_data.host, + port=relation_data.port, + user=user, + password=password, + ) + + def _get_password_from_relation_data(self, relation_data: SmtpRelationData) -> Optional[str]: + """Get smtp password from relation data. + + Arguments: + relation_data: The relation data from where to extract the password + + Returns: + the password or None if no password found + """ + # If the relation data password_id exists, that means that + # Juju version is >= 3.0 and secrets are used for the password. + # Otherwise, use the field password as a fallback + if relation_data.password_id: + secret = self.model.get_secret(id=relation_data.password_id) + content = secret.get_content() + return content["password"] + return relation_data.password + + def _on_smtp_relation_data_available(self, _: SmtpDataAvailableEvent) -> None: + """Handle SMTP data available.""" + self.model.unit.status = ops.MaintenanceStatus("Preparing the SMTP integration") + logger.debug("_on_smtp_data_available: Enabling SMTP") + self._enable_smtp() + + def _enable_smtp(self) -> None: + """Enable SMTP.""" + container = self._charm.unit.get_container(synapse.SYNAPSE_CONTAINER_NAME) + if not container.can_connect() or self._pebble_service is None: + self._charm.unit.status = ops.MaintenanceStatus("Waiting for Synapse pebble") + return + try: + self._pebble_service.enable_smtp(container) + except PebbleServiceError as exc: + self._charm.model.unit.status = ops.BlockedStatus(f"SMTP integration failed: {exc}") + return + self._charm.unit.status = ops.ActiveStatus() + + @property + def _pebble_service(self) -> typing.Any: + """Return instance of pebble service. + + Returns: + instance of pebble service or none. + """ + return getattr(self._charm, "pebble_service", None) diff --git a/src/synapse/workload.py b/src/synapse/workload.py index 7f45f3e9..fb762745 100644 --- a/src/synapse/workload.py +++ b/src/synapse/workload.py @@ -73,6 +73,10 @@ class EnableSAMLError(WorkloadError): """Exception raised when something goes wrong while enabling SAML.""" +class EnableSMTPError(WorkloadError): + """Exception raised when something goes wrong while enabling SMTP.""" + + class ExecResult(typing.NamedTuple): """A named tuple representing the result of executing a command. @@ -602,28 +606,35 @@ def enable_smtp(container: ops.Container, charm_state: CharmState) -> None: charm_state: Instance of CharmState. Raises: - WorkloadError: something went wrong enabling SMTP. + EnableSMTPError: something went wrong enabling SMTP. """ try: config = container.pull(SYNAPSE_CONFIG_PATH).read() current_yaml = yaml.safe_load(config) current_yaml["email"] = {} - # The following three configurations are mandatory for SMTP. - current_yaml["email"]["smtp_host"] = charm_state.synapse_config.smtp_host - current_yaml["email"]["smtp_port"] = charm_state.synapse_config.smtp_port - current_yaml["email"]["notif_from"] = charm_state.synapse_config.smtp_notif_from - if charm_state.synapse_config.smtp_user: - current_yaml["email"]["smtp_user"] = charm_state.synapse_config.smtp_user - if charm_state.synapse_config.smtp_pass: - current_yaml["email"]["smtp_pass"] = charm_state.synapse_config.smtp_pass - if not charm_state.synapse_config.smtp_enable_tls: - # Only set if the user set as false. - # By default, if the server supports TLS, it will be used, - # and the server must present a certificate that is valid for 'smtp_host'. - current_yaml["email"]["enable_tls"] = charm_state.synapse_config.smtp_enable_tls + current_yaml["email"]["notif_from"] = charm_state.synapse_config.notif_from + + if charm_state.smtp_config is None: + raise EnableSMTPError( + "SMTP Configuration not found. " + "Please verify the integration between SMTP Integrator and Synapse." + ) + + smtp_config = charm_state.smtp_config + current_yaml["email"]["smtp_host"] = smtp_config["host"] + current_yaml["email"]["smtp_port"] = smtp_config["port"] + if charm_state.smtp_config["user"] is not None: + current_yaml["email"]["smtp_user"] = smtp_config["user"] + if charm_state.smtp_config["password"] is not None: + current_yaml["email"]["smtp_pass"] = smtp_config["password"] + current_yaml["email"]["enable_tls"] = smtp_config["enable_tls"] + current_yaml["email"]["force_tls"] = smtp_config["force_tls"] + current_yaml["email"]["require_transport_security"] = smtp_config[ + "require_transport_security" + ] container.push(SYNAPSE_CONFIG_PATH, yaml.safe_dump(current_yaml)) except ops.pebble.PathError as exc: - raise WorkloadError(str(exc)) from exc + raise EnableSMTPError(str(exc)) from exc def reset_instance(container: ops.Container) -> None: diff --git a/tests/integration/test_charm.py b/tests/integration/test_charm.py index 6990422d..82b8a491 100644 --- a/tests/integration/test_charm.py +++ b/tests/integration/test_charm.py @@ -7,6 +7,7 @@ import logging import re import typing +from secrets import token_hex import pytest import requests @@ -352,29 +353,57 @@ async def test_saml_auth( # pylint: disable=too-many-locals assert "Continue to your account" in logged_in_page.text +@pytest.mark.parametrize( + "relation_name", + [ + pytest.param("smtp-legacy"), + pytest.param("smtp", marks=[pytest.mark.requires_secrets]), + ], +) 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, ): """ - arrange: build and deploy the Synapse charm, create an user, get the access token - and enable SMTP. + arrange: build and deploy the Synapse charm. Create an user and get the access token + Deploy, configure and integrate with Synapse the smtp-integrator charm. act: try to check if a given email address is not already associated. assert: the Synapse application is active and the error returned is the one expected. """ - await synapse_app.set_config({"smtp_host": "127.0.0.1"}) - await synapse_app.model.wait_for_idle( - idle_period=30, apps=[synapse_app.name], status=ACTIVE_STATUS_NAME + if "smtp-integrator" in model.applications: + await model.remove_application("smtp-integrator") + await model.block_until(lambda: "smtp-integrator" not in model.applications, timeout=60) + await model.wait_for_idle(status=ACTIVE_STATUS_NAME, idle_period=5) + + smtp_integrator_app = await model.deploy( + "smtp-integrator", + channel="latest/edge", + config={ + "auth_type": "plain", + "host": "127.0.0.1", + "password": token_hex(16), + "transport_security": "tls", + "user": "username", + }, + ) + await model.wait_for_idle(status=ACTIVE_STATUS_NAME) + await model.add_relation(f"{smtp_integrator_app.name}:{relation_name}", synapse_app.name) + await model.wait_for_idle( + idle_period=30, + apps=[synapse_app.name, smtp_integrator_app.name], + 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 = { - "id_server": "id.matrix.org", "client_secret": "this_is_my_secret_string", "email": "example@example.com", + "id_server": "id.matrix.org", "send_attempt": "1", } sess = requests.session() diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index 5b5d5f5b..df22d484 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -7,10 +7,12 @@ import typing import unittest.mock +from secrets import token_hex import ops import pytest import yaml +from charms.smtp_integrator.v0.smtp import AuthType, TransportSecurity from ops.pebble import ExecError from ops.testing import Harness @@ -182,6 +184,26 @@ def saml_configured_fixture(harness: Harness) -> Harness: return harness +@pytest.fixture(name="smtp_configured") +def smtp_configured_fixture(harness: Harness) -> Harness: + """Harness fixture with smtp relation configured""" + harness.update_config({"server_name": TEST_SERVER_NAME, "public_baseurl": TEST_SERVER_NAME}) + password_id = harness.add_model_secret("smtp-integrator", {"password": token_hex(16)}) + smtp_relation_data = { + "auth_type": AuthType.PLAIN, + "host": "127.0.0.1", + "password_id": password_id, + "port": "25", + "transport_security": TransportSecurity.TLS, + "user": "username", + } + harness.add_relation("smtp", "smtp-integrator", app_data=smtp_relation_data) + harness.grant_secret(password_id, "synapse") + harness.set_can_connect(synapse.SYNAPSE_CONTAINER_NAME, True) + harness.set_leader(True) + return harness + + @pytest.fixture(name="container_mocked") def container_mocked_fixture(monkeypatch: pytest.MonkeyPatch) -> unittest.mock.MagicMock: """Mock container base to others fixtures.""" diff --git a/tests/unit/test_charm.py b/tests/unit/test_charm.py index 77f5afc1..05c20644 100644 --- a/tests/unit/test_charm.py +++ b/tests/unit/test_charm.py @@ -194,6 +194,7 @@ def test_saml_integration_pebble_error( """ harness = saml_configured harness.begin() + relation = harness.charm.framework.model.get_relation("saml", 0) enable_saml_mock = MagicMock(side_effect=PebbleServiceError("fail")) monkeypatch.setattr(harness.charm._saml._pebble_service, "enable_saml", enable_saml_mock) @@ -205,6 +206,64 @@ def test_saml_integration_pebble_error( harness.cleanup() +def test_smtp_integration_container_down(smtp_configured: Harness) -> None: + """ + arrange: start the Synapse charm, set server_name, set Synapse container to be down. + act: emit smtp_data_available. + assert: Synapse charm should report maintenance status and waiting for pebble. + """ + harness = smtp_configured + harness.begin() + harness.set_can_connect(harness.model.unit.containers[synapse.SYNAPSE_CONTAINER_NAME], False) + relation = harness.charm.framework.model.get_relation("smtp", 0) + + harness.charm._smtp.smtp.on.smtp_data_available.emit(relation) + + assert isinstance(harness.model.unit.status, ops.MaintenanceStatus) + assert "Waiting for" in str(harness.model.unit.status) + + +def test_smtp_relation_pebble_success(smtp_configured: Harness, monkeypatch: pytest.MonkeyPatch): + """ + arrange: start the Synapse charm, set server_name, mock synapse.enable_smtp. + act: emit smtp_data_available + assert: synapse.enable_smtp is called once and unit is active. + """ + harness = smtp_configured + enable_smtp_mock = MagicMock() + container = harness.model.unit.containers[synapse.SYNAPSE_CONTAINER_NAME] + monkeypatch.setattr(synapse, "enable_smtp", enable_smtp_mock) + + harness.begin() + + relation = harness.charm.framework.model.get_relation("smtp", 0) + harness.charm._smtp.smtp.on.smtp_data_available.emit(relation) + + enable_smtp_mock.assert_called_once_with( + container=container, charm_state=harness.charm._charm_state + ) + assert isinstance(harness.model.unit.status, ops.ActiveStatus) + + +def test_smtp_relation_pebble_error(smtp_configured: Harness, monkeypatch: pytest.MonkeyPatch): + """ + arrange: start the Synapse charm, set server_name, mock pebble to give an error. + act: emit smtp_data_available. + assert: Synapse charm should submit the correct status (blocked). + """ + harness = smtp_configured + harness.begin() + + enable_smtp_mock = MagicMock(side_effect=PebbleServiceError("fail")) + monkeypatch.setattr(harness.charm._smtp._pebble_service, "enable_smtp", enable_smtp_mock) + + relation = harness.charm.framework.model.get_relation("smtp", 0) + harness.charm._smtp.smtp.on.smtp_data_available.emit(relation) + + assert isinstance(harness.model.unit.status, ops.BlockedStatus) + assert "SMTP integration failed" in str(harness.model.unit.status) + + def test_server_name_change(harness: Harness, monkeypatch: pytest.MonkeyPatch) -> None: """ arrange: start the Synapse charm, set Synapse container to be ready and set server_name. diff --git a/tests/unit/test_smtp_observer.py b/tests/unit/test_smtp_observer.py new file mode 100644 index 00000000..2eefad8e --- /dev/null +++ b/tests/unit/test_smtp_observer.py @@ -0,0 +1,146 @@ +# Copyright 2024 Canonical Ltd. +# See LICENSE file for licensing details. + +"""SMTPObserver unit tests.""" + +# pylint: disable=protected-access + +from secrets import token_hex + +import pytest +from charms.smtp_integrator.v0.smtp import AuthType, TransportSecurity +from ops.testing import Harness + +from charm_state import CharmConfigInvalidError +from charm_types import SMTPConfiguration + + +def _test_get_relation_data_to_smtp_conf_parameters(): + """Generate parameters for the test_get_relation_as_smtp_conf. + + Returns: + The tests. + """ + password = token_hex(16) + return [ + pytest.param( + { + "host": "127.0.0.1", + "port": "25", + "auth_type": AuthType.PLAIN, + "transport_security": TransportSecurity.STARTTLS, + "user": "username", + "password": password, + }, + SMTPConfiguration( + enable_tls=True, + force_tls=False, + require_transport_security=True, + host="127.0.0.1", + port=25, + user="username", + password=password, + ), + id="plain auth type with starttls", + ), + pytest.param( + { + "host": "127.0.0.1", + "port": "587", + "auth_type": AuthType.PLAIN, + "transport_security": TransportSecurity.TLS, + "user": "username", + "password": password, + }, + SMTPConfiguration( + enable_tls=True, + force_tls=True, + require_transport_security=True, + host="127.0.0.1", + port=587, + user="username", + password=password, + ), + id="plain auth type with tls", + ), + ] + + +@pytest.mark.parametrize( + "relation_data, expected_config", _test_get_relation_data_to_smtp_conf_parameters() +) +def test_get_relation_as_smtp_conf(harness: Harness, relation_data, expected_config): + """ + arrange: add relation_data from parameter. + act: get SMTPConfiguration from smtp observer. + assert: expected smtp configuration matches returned one. + """ + harness.add_relation("smtp", "smtp-integrator", app_data=relation_data) + harness.begin() + + smtp_configuration = harness.charm._smtp.get_relation_as_smtp_conf() + + assert smtp_configuration == expected_config + + +@pytest.mark.parametrize( + "relation_data", + [ + pytest.param( + { + "host": "127.0.0.1", + "port": "25", + "auth_type": AuthType.PLAIN, + "username": "username", + "password": token_hex(16), + "transport_security": TransportSecurity.NONE, + }, + id="auth type plan with transport security none", + ), + pytest.param( + { + "host": "127.0.0.1", + "port": "25", + "auth_type": AuthType.NONE, + "transport_security": TransportSecurity.TLS, + }, + id="auth type none with TLS", + ), + ], +) +def test_get_relation_fails_invalid_config(harness: Harness, relation_data): + """ + arrange: add not supported invalid relation_data from parameter. + act: get SMTPConfiguration from smtp observer. + assert: raises exception CharmConfigInvalidError + """ + harness.add_relation("smtp", "smtp-integrator", app_data=relation_data) + harness.begin() + + with pytest.raises(CharmConfigInvalidError): + harness.charm._smtp.get_relation_as_smtp_conf() + + +def test_get_relation_as_smtp_conf_password_from_juju_secret(harness: Harness): + """ + arrange: add smtp relation to smtp-integration with secret. + act: get smtp configuration from smtp observer. + assert: password in smtp configuration is the same as the original secret. + """ + password = token_hex(16) + password_id = harness.add_model_secret("smtp-integrator", {"password": password}) + smtp_relation_data = { + "auth_type": AuthType.PLAIN, + "host": "127.0.0.1", + "password_id": password_id, + "port": "587", + "transport_security": TransportSecurity.TLS, + "user": "alice", + } + harness.add_relation("smtp", "smtp-integrator", app_data=smtp_relation_data) + harness.grant_secret(password_id, "synapse") + harness.begin() + + smtp_configuration = harness.charm._smtp.get_relation_as_smtp_conf() + + assert smtp_configuration["password"] == password diff --git a/tests/unit/test_synapse_api.py b/tests/unit/test_synapse_api.py index b15cce15..d07dffbd 100644 --- a/tests/unit/test_synapse_api.py +++ b/tests/unit/test_synapse_api.py @@ -224,6 +224,7 @@ def test_override_rate_limit_success(monkeypatch: pytest.MonkeyPatch): synapse_config=synapse_config, datasource=None, saml_config=None, + smtp_config=None, ) expected_url = ( f"http://localhost:8008/_synapse/admin/v1/users/@any-user:{server}/override_ratelimit" @@ -256,6 +257,7 @@ def test_override_rate_limit_error(monkeypatch: pytest.MonkeyPatch): synapse_config=synapse_config, datasource=None, saml_config=None, + smtp_config=None, ) expected_error_msg = "Failed to connect" do_request_mock = mock.MagicMock(side_effect=synapse.APIError(expected_error_msg)) diff --git a/tests/unit/test_synapse_workload.py b/tests/unit/test_synapse_workload.py index fce57e8a..3f0f27de 100644 --- a/tests/unit/test_synapse_workload.py +++ b/tests/unit/test_synapse_workload.py @@ -19,6 +19,7 @@ import synapse from charm import SynapseCharm from charm_state import CharmState, SynapseConfig +from charm_types import SMTPConfiguration from .conftest import TEST_SERVER_NAME @@ -260,7 +261,9 @@ def test_enable_ip_range_whitelist_no_action(harness: Harness, monkeypatch: pyte synapse_config = SynapseConfig(**config) # type: ignore[arg-type] synapse.enable_ip_range_whitelist( container_mock, - CharmState(datasource=None, saml_config=None, synapse_config=synapse_config), + CharmState( + datasource=None, saml_config=None, smtp_config=None, synapse_config=synapse_config + ), ) container_mock.pull.assert_called_once() @@ -313,7 +316,7 @@ def test_enable_federation_domain_whitelist_error( ): """ arrange: set mock container with file. - act: update federation_domain_whitelist config and call enable_smtp. + act: update federation_domain_whitelist config and call enable_federation_domain_whitelist. assert: raise WorkloadError in case of error. """ error_message = "Error pulling file" @@ -356,7 +359,10 @@ def test_enable_trusted_key_servers_no_action(harness: Harness): synapse_config = SynapseConfig(**config) # type: ignore[arg-type] synapse.enable_trusted_key_servers( - container, CharmState(datasource=None, saml_config=None, synapse_config=synapse_config) + container, + CharmState( + datasource=None, saml_config=None, smtp_config=None, synapse_config=synapse_config + ), ) with open(config_path, encoding="utf-8") as config_file: @@ -679,7 +685,18 @@ def test_create_mjolnir_config_success(monkeypatch: pytest.MonkeyPatch): ) -def test_enable_smtp_success(harness: Harness, monkeypatch: pytest.MonkeyPatch): +SMTP_CONFIGURATION = SMTPConfiguration( + enable_tls=True, + force_tls=False, + require_transport_security=True, + host="smtp.example.com", + port=25, + user="username", + password=token_hex(16), +) + + +def test_enable_smtp_success(monkeypatch: pytest.MonkeyPatch): """ arrange: set mock container with file. act: update smtp_host config and call enable_smtp. @@ -699,27 +716,46 @@ def test_enable_smtp_success(harness: Harness, monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr(container_mock, "pull", pull_mock) monkeypatch.setattr(container_mock, "push", push_mock) - expected_smtp_host = "127.0.0.1" - harness.update_config({"smtp_host": expected_smtp_host}) - harness.begin() - synapse.enable_smtp(container_mock, harness.charm._charm_state) + charm_state = CharmState( + datasource=None, + saml_config=None, + smtp_config=SMTP_CONFIGURATION, + synapse_config=SynapseConfig( + federation_domain_whitelist=None, + ip_range_whitelist=None, + notif_from="noreply@example.com", + public_baseurl=None, + report_stats=None, + server_name="example.com", + trusted_key_servers=None, + ), + ) + synapse.enable_smtp(container_mock, charm_state) assert pull_mock.call_args[0][0] == synapse.SYNAPSE_CONFIG_PATH assert push_mock.call_args[0][0] == synapse.SYNAPSE_CONFIG_PATH - server_name = harness.charm._charm_state.synapse_config.server_name expected_config_content = { "listeners": [ {"type": "http", "port": 8080, "bind_addresses": ["::"]}, ], - "email": {"notif_from": server_name, "smtp_host": expected_smtp_host, "smtp_port": 25}, + "email": { + "enable_tls": True, + "force_tls": False, + "require_transport_security": True, + "notif_from": "noreply@example.com", + "smtp_host": "smtp.example.com", + "smtp_port": 25, + "smtp_user": "username", + "smtp_pass": SMTP_CONFIGURATION["password"], + }, } assert push_mock.call_args[0][1] == yaml.safe_dump(expected_config_content) -def test_enable_smtp_error(harness: Harness, monkeypatch: pytest.MonkeyPatch): +def test_enable_smtp_error(monkeypatch: pytest.MonkeyPatch): """ arrange: set mock container with file. - act: update smtp_host config and call enable_smtp. + act: add smtp integration call enable_smtp. assert: raise WorkloadError in case of error. """ error_message = "Error pulling file" @@ -728,17 +764,28 @@ def test_enable_smtp_error(harness: Harness, monkeypatch: pytest.MonkeyPatch): container_mock = MagicMock() monkeypatch.setattr(container_mock, "pull", pull_mock) + charm_state = CharmState( + datasource=None, + saml_config=None, + smtp_config=SMTP_CONFIGURATION, + synapse_config=SynapseConfig( + federation_domain_whitelist=None, + ip_range_whitelist=None, + notif_from="noreply@example.com", + public_baseurl=None, + report_stats=None, + server_name="example.com", + trusted_key_servers=None, + ), + ) with pytest.raises(synapse.WorkloadError, match=error_message): - expected_smtp_host = "127.0.0.1" - harness.update_config({"smtp_host": expected_smtp_host}) - harness.begin() - synapse.enable_smtp(container_mock, harness.charm._charm_state) + synapse.enable_smtp(container_mock, charm_state) def test_enable_serve_server_wellknown_success(monkeypatch: pytest.MonkeyPatch): """ arrange: set mock container with file. - act: update smtp_host config and call enable_serve_server_wellknown. + act: call enable_serve_server_wellknown. assert: new configuration file is pushed and serve_server_wellknown is enabled. """ config_content = """ diff --git a/tox.ini b/tox.ini index e73d8569..4242c410 100644 --- a/tox.ini +++ b/tox.ini @@ -40,7 +40,7 @@ deps = lazydocs -r{toxinidir}/requirements.txt commands = - ; can't run lazydocs directly due to needing to run it on src/* which produces an invocation error in tox + ; cannot run lazydocs directly due to needing to run it on src/* which produces an invocation error in tox sh generate-src-docs.sh [testenv:lint] @@ -116,7 +116,7 @@ commands = description = Run integration tests deps = macaroonbakery==1.3.2 # fixes "TypeError: Descriptors cannot be created directly." protobuf error - juju==2.9.45.0 + juju >=3.0 pytest pytest-asyncio pytest-operator