From fce8bfbb8572764729082262e2853a01a3c03252 Mon Sep 17 00:00:00 2001 From: "Amanda H. L. de Andrade Katz" Date: Wed, 4 Oct 2023 13:39:58 -0300 Subject: [PATCH] Add SMTP configuration (#56) --- config.yaml | 51 +++-- src-docs/charm.py.md | 4 +- src-docs/charm_state.py.md | 127 ++++--------- src-docs/pebble.py.md | 6 +- src/charm.py | 14 +- src/charm_state.py | 145 ++++++--------- src/mjolnir.py | 6 +- src/pebble.py | 4 +- src/synapse/__init__.py | 1 + src/synapse/api.py | 2 +- src/synapse/workload.py | 226 +++++++++++++---------- tests/conftest.py | 6 + tests/integration/conftest.py | 64 ++++++- tests/integration/test_charm.py | 140 +++++++------- tests/unit/conftest.py | 11 -- tests/unit/test_charm.py | 26 +-- tests/unit/test_database.py | 26 +-- tests/unit/test_mjolnir.py | 21 +++ tests/unit/test_reset_instance_action.py | 6 +- tests/unit/test_synapse_api.py | 6 +- tests/unit/test_synapse_workload.py | 142 ++++++++++++++ 21 files changed, 604 insertions(+), 430 deletions(-) diff --git a/config.yaml b/config.yaml index 4f280d2c..32cbf1a9 100644 --- a/config.yaml +++ b/config.yaml @@ -2,26 +2,51 @@ # See LICENSE file for licensing details. options: - server_name: + enable_mjolnir: + type: boolean + default: false + description: | + Configures whether to enable Mjolnir - moderation tool for Matrix. + Reference: https://github.com/matrix-org/mjolnir + public_baseurl: type: string description: | - 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. + The public-facing base URL that clients use to access this Homeserver. + Defaults to https:///. Only used if there is integration with + SAML integrator charm. report_stats: description: | Configures whether to report statistics. default: false type: boolean - public_baseurl: + server_name: type: string description: | - The public-facing base URL that clients use to access this Homeserver. - Defaults to https:///. Only used if there is integration with - SAML integrator charm. - enable_mjolnir: + 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 - default: false - description: | - Configures whether to enable Mjolnir - moderation tool for Matrix. - Reference: https://github.com/matrix-org/mjolnir + 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: '' diff --git a/src-docs/charm.py.md b/src-docs/charm.py.md index e49dd0ea..b65ba120 100644 --- a/src-docs/charm.py.md +++ b/src-docs/charm.py.md @@ -69,7 +69,7 @@ Unit that this execution is responsible for. --- - + ### function `change_config` @@ -81,7 +81,7 @@ Change configuration. --- - + ### function `replan_nginx` diff --git a/src-docs/charm_state.py.md b/src-docs/charm_state.py.md index a3de6b7c..10f6fd0b 100644 --- a/src-docs/charm_state.py.md +++ b/src-docs/charm_state.py.md @@ -17,7 +17,7 @@ Exception raised when a charm configuration is found to be invalid. Attrs: msg (str): Explanation of the error. - + ### function `__init__` @@ -42,143 +42,90 @@ Initialize a new instance of the CharmConfigInvalidError exception. ## class `CharmState` State of the Charm. -Attrs: server_name: server_name config. report_stats: report_stats config. public_baseurl: public_baseurl config. enable_mjolnir: enable_mjolnir config. datasource: datasource information. saml_config: saml configuration. - -### function `__init__` - -```python -__init__( - synapse_config: SynapseConfig, - datasource: Optional[DatasourcePostgreSQL], - saml_config: Optional[SAMLConfiguration] -) → None -``` - -Construct. - - - -**Args:** +**Attributes:** - - `synapse_config`: The value of the synapse_config charm configuration. - - `datasource`: Datasource information. - - `saml_config`: SAML configuration. - - ---- + - `synapse_config`: synapse configuration. + - `datasource`: datasource information. + - `saml_config`: saml configuration. -#### property datasource -Return datasource. - -**Returns:** - datasource or None. - --- -#### property enable_mjolnir - -Return enable_mjolnir config. + +### classmethod `from_charm` +```python +from_charm( + charm: CharmBase, + datasource: Optional[DatasourcePostgreSQL], + saml_config: Optional[SAMLConfiguration] +) → CharmState +``` -**Returns:** - - - `bool`: enable_mjolnir config. - ---- - -#### property public_baseurl - -Return public_baseurl config. +Initialize a new instance of the CharmState class from the associated charm. -**Returns:** +**Args:** - - `str`: public_baseurl config. - ---- - -#### property report_stats + - `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. -Return report_stats config. +Return: The CharmState instance created by the provided charm. -**Returns:** +**Raises:** - - `str`: report_stats config as yes or no. - ---- - -#### property saml_config - -Return SAML configuration. - - + - `CharmConfigInvalidError`: if the charm configuration is invalid. -**Returns:** - SAMLConfiguration or None. --- -#### property server_name - -Return server_name config. - +## class `SynapseConfig` +Represent Synapse builtin configuration values. +Attrs: server_name: server_name config. report_stats: report_stats config. public_baseurl: public_baseurl config. enable_mjolnir: enable_mjolnir 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 autehtncate to SMTP host. -**Returns:** - - - `str`: server_name config. --- - + -### classmethod `from_charm` +### classmethod `set_default_smtp_notif_from` ```python -from_charm(charm: 'SynapseCharm') → CharmState +set_default_smtp_notif_from( + smtp_notif_from: Optional[str], + values: dict +) → Optional[str] ``` -Initialize a new instance of the CharmState class from the associated charm. +Set server_name as default value to smtp_notif_from. **Args:** - - `charm`: The charm instance associated with this state. - -Return: The CharmState instance created by the provided charm. - - - -**Raises:** - - - `CharmConfigInvalidError`: if the charm configuration is invalid. - - ---- - -## class `SynapseConfig` -Represent Synapse builtin configuration values. - -Attrs: server_name: server_name config. report_stats: report_stats config. public_baseurl: public_baseurl config. enable_mjolnir: enable_mjolnir config. + - `smtp_notif_from`: the smtp_notif_from current value. + - `values`: values already defined. +**Returns:** + The default value for smtp_notif_from if not defined. --- - + ### classmethod `to_yes_or_no` diff --git a/src-docs/pebble.py.md b/src-docs/pebble.py.md index 81dda338..2e685c33 100644 --- a/src-docs/pebble.py.md +++ b/src-docs/pebble.py.md @@ -57,7 +57,7 @@ Change the configuration. --- - + ### function `enable_saml` @@ -65,7 +65,7 @@ Change the configuration. enable_saml(container: Container) → None ``` -Enable SAML. +Enable SAML while receiving on_saml_data_available event. @@ -117,7 +117,7 @@ Replan Synapse NGINX service. --- - + ### function `reset_instance` diff --git a/src/charm.py b/src/charm.py index 5ae792b5..9513e835 100755 --- a/src/charm.py +++ b/src/charm.py @@ -36,10 +36,14 @@ def __init__(self, *args: typing.Any) -> None: args: class arguments. """ super().__init__(*args) - self.database = DatabaseObserver(self) - self.saml = SAMLObserver(self) + self._database = DatabaseObserver(self) + self._saml = SAMLObserver(self) try: - self._charm_state = CharmState.from_charm(charm=self) + self._charm_state = CharmState.from_charm( + charm=self, + datasource=self._database.get_relation_as_datasource(), + saml_config=self._saml.get_relation_as_saml_conf(), + ) except CharmConfigInvalidError as exc: self.model.unit.status = ops.BlockedStatus(exc.msg) return @@ -65,7 +69,7 @@ def __init__(self, *args: typing.Any) -> None: self._observability = Observability(self) # Mjolnir is a moderation tool for Matrix. # See https://github.com/matrix-org/mjolnir/ for more details about it. - if self._charm_state.enable_mjolnir: + if self._charm_state.synapse_config.enable_mjolnir: self._mjolnir = Mjolnir(self, charm_state=self._charm_state) self.framework.observe(self.on.config_changed, self._on_config_changed) self.framework.observe(self.on.reset_instance_action, self._on_reset_instance_action) @@ -136,7 +140,7 @@ def _on_reset_instance_action(self, event: ActionEvent) -> None: try: self.model.unit.status = ops.MaintenanceStatus("Resetting Synapse instance") self.pebble_service.reset_instance(container) - datasource = self.database.get_relation_as_datasource() + datasource = self._database.get_relation_as_datasource() actions.reset_instance( container=container, charm_state=self._charm_state, datasource=datasource ) diff --git a/src/charm_state.py b/src/charm_state.py index 8aaedeca..ac83a930 100644 --- a/src/charm_state.py +++ b/src/charm_state.py @@ -4,9 +4,12 @@ # See LICENSE file for licensing details. """State of the Charm.""" +import dataclasses import itertools import typing +import ops + # pydantic is causing this no-name-in-module problem from pydantic import ( # pylint: disable=no-name-in-module,import-error BaseModel, @@ -18,15 +21,17 @@ from charm_types import DatasourcePostgreSQL, SAMLConfiguration -if typing.TYPE_CHECKING: - from charm import SynapseCharm - - KNOWN_CHARM_CONFIG = ( - "server_name", - "report_stats", - "public_baseurl", "enable_mjolnir", + "public_baseurl", + "report_stats", + "server_name", + "smtp_enable_tls", + "smtp_host", + "smtp_notif_from", + "smtp_pass", + "smtp_port", + "smtp_user", ) @@ -54,12 +59,24 @@ class SynapseConfig(BaseModel): # pylint: disable=too-few-public-methods report_stats: report_stats config. public_baseurl: public_baseurl config. enable_mjolnir: enable_mjolnir 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 autehtncate to SMTP host. """ server_name: str | None = Field(..., min_length=2) report_stats: str | None = Field(None) public_baseurl: str | None = Field(None) enable_mjolnir: bool = False + 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) class Config: # pylint: disable=too-few-public-methods """Config class. @@ -70,6 +87,25 @@ class Config: # pylint: disable=too-few-public-methods extra = Extra.allow + @validator("smtp_notif_from", pre=True, always=True) + @classmethod + def set_default_smtp_notif_from( + cls, smtp_notif_from: typing.Optional[str], values: dict + ) -> typing.Optional[str]: + """Set server_name as default value to smtp_notif_from. + + Args: + smtp_notif_from: the smtp_notif_from current value. + values: values already defined. + + Returns: + The default value for smtp_notif_from if not defined. + """ + server_name = values.get("server_name") + if smtp_notif_from is None and server_name: + return server_name + return smtp_notif_from + @validator("report_stats") @classmethod def to_yes_or_no(cls, value: str) -> str: @@ -86,96 +122,33 @@ def to_yes_or_no(cls, value: str) -> str: return "no" +@dataclasses.dataclass(frozen=True) class CharmState: """State of the Charm. - Attrs: - server_name: server_name config. - report_stats: report_stats config. - public_baseurl: public_baseurl config. - enable_mjolnir: enable_mjolnir config. + Attributes: + synapse_config: synapse configuration. datasource: datasource information. saml_config: saml configuration. """ - def __init__( - self, - *, - synapse_config: SynapseConfig, - datasource: typing.Optional[DatasourcePostgreSQL], - saml_config: typing.Optional[SAMLConfiguration], - ) -> None: - """Construct. - - Args: - synapse_config: The value of the synapse_config charm configuration. - datasource: Datasource information. - saml_config: SAML configuration. - """ - self._synapse_config = synapse_config - self._datasource = datasource - self._saml_config = saml_config - - @property - def server_name(self) -> typing.Optional[str]: - """Return server_name config. - - Returns: - str: server_name config. - """ - return self._synapse_config.server_name - - @property - def report_stats(self) -> typing.Union[str, bool, None]: - """Return report_stats config. - - Returns: - str: report_stats config as yes or no. - """ - return self._synapse_config.report_stats - - @property - def public_baseurl(self) -> typing.Optional[str]: - """Return public_baseurl config. - - Returns: - str: public_baseurl config. - """ - return self._synapse_config.public_baseurl - - @property - def enable_mjolnir(self) -> bool: - """Return enable_mjolnir config. - - Returns: - bool: enable_mjolnir config. - """ - return self._synapse_config.enable_mjolnir - - @property - def datasource(self) -> typing.Union[DatasourcePostgreSQL, None]: - """Return datasource. - - Returns: - datasource or None. - """ - return self._datasource - - @property - def saml_config(self) -> typing.Union[SAMLConfiguration, None]: - """Return SAML configuration. - - Returns: - SAMLConfiguration or None. - """ - return self._saml_config + synapse_config: SynapseConfig + datasource: typing.Optional[DatasourcePostgreSQL] + saml_config: typing.Optional[SAMLConfiguration] @classmethod - def from_charm(cls, charm: "SynapseCharm") -> "CharmState": + def from_charm( + cls, + charm: ops.CharmBase, + datasource: typing.Optional[DatasourcePostgreSQL], + saml_config: typing.Optional[SAMLConfiguration], + ) -> "CharmState": """Initialize a new instance of the CharmState class from the associated charm. Args: 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. Return: The CharmState instance created by the provided charm. @@ -194,6 +167,6 @@ def from_charm(cls, charm: "SynapseCharm") -> "CharmState": raise CharmConfigInvalidError(f"invalid configuration: {error_field_str}") from exc return cls( synapse_config=valid_synapse_config, - datasource=charm.database.get_relation_as_datasource(), - saml_config=charm.saml.get_relation_as_saml_conf(), + datasource=datasource, + saml_config=saml_config, ) diff --git a/src/mjolnir.py b/src/mjolnir.py index 7f45ba31..1b2b1fc7 100644 --- a/src/mjolnir.py +++ b/src/mjolnir.py @@ -112,7 +112,7 @@ def _on_collect_status(self, event: ops.CollectStatusEvent) -> None: Args: event: Collect status event. """ - if not self._charm_state.enable_mjolnir: + if not self._charm_state.synapse_config.enable_mjolnir: return container = self._charm.unit.get_container(synapse.SYNAPSE_CONTAINER_NAME) if not container.can_connect(): @@ -211,7 +211,7 @@ def enable_mjolnir(self) -> None: container, USERNAME, True, - str(self._charm_state.server_name), + str(self._charm_state.synapse_config.server_name), admin_access_token, ) mjolnir_access_token = mjolnir_user.access_token @@ -224,7 +224,7 @@ def enable_mjolnir(self) -> None: # Add the Mjolnir user to the management room synapse.make_room_admin( user=mjolnir_user, - server=str(self._charm_state.server_name), + server=str(self._charm_state.synapse_config.server_name), admin_access_token=admin_access_token, room_id=room_id, ) diff --git a/src/pebble.py b/src/pebble.py index 97467667..661f2dcc 100644 --- a/src/pebble.py +++ b/src/pebble.py @@ -89,12 +89,14 @@ def change_config(self, container: ops.model.Container) -> None: 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: + 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 enable_saml(self, container: ops.model.Container) -> None: - """Enable SAML. + """Enable SAML while receiving on_saml_data_available event. Args: container: Charm container. diff --git a/src/synapse/__init__.py b/src/synapse/__init__.py index afaa7c8d..128cac8e 100644 --- a/src/synapse/__init__.py +++ b/src/synapse/__init__.py @@ -55,6 +55,7 @@ enable_metrics, enable_saml, enable_serve_server_wellknown, + enable_smtp, execute_migrate_config, get_environment, get_registration_shared_secret, diff --git a/src/synapse/api.py b/src/synapse/api.py index 77623c66..55e3d9ac 100644 --- a/src/synapse/api.py +++ b/src/synapse/api.py @@ -279,7 +279,7 @@ def override_rate_limit(user: User, admin_access_token: str, charm_state: CharmS admin_access_token: server admin access token to be used. charm_state: Instance of CharmState. """ - server_name = charm_state.server_name + server_name = charm_state.synapse_config.server_name rate_limit_url = ( f"{SYNAPSE_URL}/_synapse/admin/v1/users/" f"@{user.username}:{server_name}/override_ratelimit" diff --git a/src/synapse/workload.py b/src/synapse/workload.py index dc08ef6b..5ee4f0c2 100644 --- a/src/synapse/workload.py +++ b/src/synapse/workload.py @@ -138,6 +138,104 @@ def check_mjolnir_ready() -> ops.pebble.CheckDict: return check.to_dict() +def _get_configuration_field(container: ops.Container, fieldname: str) -> typing.Optional[str]: + """Get configuration field. + + Args: + container: Container of the charm. + fieldname: field to get. + + Raises: + PathError: if somethings goes wrong while reading the configuration file. + + Returns: + configuration field value. + """ + try: + configuration_content = str(container.pull(SYNAPSE_CONFIG_PATH, encoding="utf-8").read()) + return yaml.safe_load(configuration_content)[fieldname] + except PathError as path_error: + if path_error.kind == "not-found": + logger.debug( + "configuration file %s not found, will be created by config-changed", + SYNAPSE_CONFIG_PATH, + ) + return None + logger.exception( + "exception while reading configuration file %s: %r", + SYNAPSE_CONFIG_PATH, + path_error, + ) + raise + + +def get_registration_shared_secret(container: ops.Container) -> typing.Optional[str]: + """Get registration_shared_secret from configuration file. + + Args: + container: Container of the charm. + + Returns: + registration_shared_secret value. + """ + return _get_configuration_field(container=container, fieldname="registration_shared_secret") + + +def _check_server_name(container: ops.Container, charm_state: CharmState) -> None: + """Check server_name. + + Check if server_name of the state has been modified in relation to the configuration file. + + Args: + container: Container of the charm. + charm_state: Instance of CharmState. + + Raises: + ServerNameModifiedError: if server_name from state is different than the one in the + configuration file. + """ + configured_server_name = _get_configuration_field(container=container, fieldname="server_name") + if ( + configured_server_name is not None + and configured_server_name != charm_state.synapse_config.server_name + ): + msg = ( + f"server_name {charm_state.synapse_config.server_name} is different from the existing " + f"one {configured_server_name}. Please revert the config or run the action " + "reset-instance if you want to erase the existing instance and start a new " + "one." + ) + logger.error(msg) + raise ServerNameModifiedError( + "The server_name modification is not allowed, please check the logs" + ) + + +def _exec( + container: ops.Container, + command: list[str], + environment: dict[str, str] | None = None, +) -> ExecResult: + """Execute a command inside the Synapse workload container. + + Args: + container: Container of the charm. + command: A list of strings representing the command to be executed. + environment: Environment variables for the command to be executed. + + Returns: + ExecResult: An `ExecResult` object representing the result of the command execution. + """ + exec_process = container.exec(command, environment=environment, working_dir=SYNAPSE_CONFIG_DIR) + try: + stdout, stderr = exec_process.wait_output() + return ExecResult(0, typing.cast(str, stdout), typing.cast(str, stderr)) + except ExecError as exc: + return ExecResult( + exc.exit_code, typing.cast(str, exc.stdout), typing.cast(str, exc.stderr) + ) + + def execute_migrate_config(container: ops.Container, charm_state: CharmState) -> None: """Run the Synapse command migrate_config. @@ -266,9 +364,9 @@ def _create_pysaml2_config(charm_state: CharmState) -> typing.Dict: saml_config = charm_state.saml_config entity_id = ( - charm_state.public_baseurl - if charm_state.public_baseurl is not None - else f"https://{charm_state.server_name}" + charm_state.synapse_config.public_baseurl + if charm_state.synapse_config.public_baseurl is not None + else f"https://{charm_state.synapse_config.server_name}" ) sp_config = { "metadata": { @@ -308,8 +406,8 @@ def enable_saml(container: ops.Container, charm_state: CharmState) -> None: try: config = container.pull(SYNAPSE_CONFIG_PATH).read() current_yaml = yaml.safe_load(config) - if charm_state.public_baseurl is not None: - current_yaml["public_baseurl"] = charm_state.public_baseurl + if charm_state.synapse_config.public_baseurl is not None: + current_yaml["public_baseurl"] = charm_state.synapse_config.public_baseurl # enable x_forwarded to pass expected headers current_listeners = current_yaml["listeners"] updated_listeners = [ @@ -338,16 +436,36 @@ def enable_saml(container: ops.Container, charm_state: CharmState) -> None: raise EnableSAMLError(str(exc)) from exc -def get_registration_shared_secret(container: ops.Container) -> typing.Optional[str]: - """Get registration_shared_secret from configuration file. +def enable_smtp(container: ops.Container, charm_state: CharmState) -> None: + """Change the Synapse configuration to enable SMTP. Args: container: Container of the charm. + charm_state: Instance of CharmState. - Returns: - registration_shared_secret value. + Raises: + WorkloadError: something went wrong enabling SMTP. """ - return _get_configuration_field(container=container, fieldname="registration_shared_secret") + 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 + container.push(SYNAPSE_CONFIG_PATH, yaml.safe_dump(current_yaml)) + except ops.pebble.PathError as exc: + raise WorkloadError(str(exc)) from exc def reset_instance(container: ops.Container) -> None: @@ -386,8 +504,8 @@ def get_environment(charm_state: CharmState) -> typing.Dict[str, str]: A dictionary representing the Synapse environment variables. """ environment = { - "SYNAPSE_SERVER_NAME": f"{charm_state.server_name}", - "SYNAPSE_REPORT_STATS": f"{charm_state.report_stats}", + "SYNAPSE_SERVER_NAME": f"{charm_state.synapse_config.server_name}", + "SYNAPSE_REPORT_STATS": f"{charm_state.synapse_config.report_stats}", # TLS disabled so the listener is HTTP. HTTPS will be handled by Traefik. # TODO verify support to HTTPS backend before changing this # pylint: disable=fixme "SYNAPSE_NO_TLS": str(True), @@ -400,87 +518,3 @@ def get_environment(charm_state: CharmState) -> typing.Dict[str, str]: environment["POSTGRES_USER"] = datasource["user"] environment["POSTGRES_PASSWORD"] = datasource["password"] return environment - - -def _check_server_name(container: ops.Container, charm_state: CharmState) -> None: - """Check server_name. - - Check if server_name of the state has been modified in relation to the configuration file. - - Args: - container: Container of the charm. - charm_state: Instance of CharmState. - - Raises: - ServerNameModifiedError: if server_name from state is different than the one in the - configuration file. - """ - configured_server_name = _get_configuration_field(container=container, fieldname="server_name") - if configured_server_name is not None and configured_server_name != charm_state.server_name: - msg = ( - f"server_name {charm_state.server_name} is different from the existing " - f"one {configured_server_name}. Please revert the config or run the action " - "reset-instance if you want to erase the existing instance and start a new " - "one." - ) - logger.error(msg) - raise ServerNameModifiedError( - "The server_name modification is not allowed, please check the logs" - ) - - -def _exec( - container: ops.Container, - command: list[str], - environment: dict[str, str] | None = None, -) -> ExecResult: - """Execute a command inside the Synapse workload container. - - Args: - container: Container of the charm. - command: A list of strings representing the command to be executed. - environment: Environment variables for the command to be executed. - - Returns: - ExecResult: An `ExecResult` object representing the result of the command execution. - """ - exec_process = container.exec(command, environment=environment, working_dir=SYNAPSE_CONFIG_DIR) - try: - stdout, stderr = exec_process.wait_output() - return ExecResult(0, typing.cast(str, stdout), typing.cast(str, stderr)) - except ExecError as exc: - return ExecResult( - exc.exit_code, typing.cast(str, exc.stdout), typing.cast(str, exc.stderr) - ) - - -def _get_configuration_field(container: ops.Container, fieldname: str) -> typing.Optional[str]: - """Get configuration field. - - Args: - container: Container of the charm. - fieldname: field to get. - - Raises: - PathError: if somethings goes wrong while reading the configuration file. - - Returns: - configuration field value. - """ - try: - configuration_content = str(container.pull(SYNAPSE_CONFIG_PATH, encoding="utf-8").read()) - value = yaml.safe_load(configuration_content)[fieldname] - return value - except PathError as path_error: - if path_error.kind == "not-found": - logger.debug( - "configuration file %s not found, will be created by config-changed", - SYNAPSE_CONFIG_PATH, - ) - return None - logger.exception( - "exception while reading configuration file %s: %r", - SYNAPSE_CONFIG_PATH, - path_error, - ) - raise diff --git a/tests/conftest.py b/tests/conftest.py index 5a41c5dc..054f3288 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -20,3 +20,9 @@ def pytest_addoption(parser: Parser) -> None: SYNAPSE_NGINX_IMAGE_PARAM, action="store", help="Synapse NGINX image to be deployed" ) parser.addoption("--charm-file", action="store", help="Charm file to be deployed") + parser.addoption( + "--use-existing", + action="store_true", + default=False, + help="This parameter will skip deploy of Synapse and PostgreSQL", + ) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 91fafc52..40de4690 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -6,9 +6,12 @@ import json import typing +from secrets import token_hex import pytest import pytest_asyncio +import requests +from juju.action import Action from juju.application import Application from juju.model import Model from ops.model import ActiveStatus @@ -98,8 +101,12 @@ async def synapse_app_fixture( synapse_charm: str, postgresql_app: Application, postgresql_app_name: str, + pytestconfig: Config, ): - """Build and deploy the Synapse charm so the install can be tested.""" + """Build and deploy the Synapse charm.""" + use_existing = pytestconfig.getoption("--use-existing", default=False) + if use_existing: + return model.applications[synapse_app_name] if synapse_app_refresh_name in model.applications: await model.remove_application(synapse_app_refresh_name, block_until_done=True) await model.wait_for_idle(status=ACTIVE_STATUS_NAME, idle_period=5) @@ -237,11 +244,12 @@ def postgresql_app_name_app_name_fixture() -> str: @pytest_asyncio.fixture(scope="module", name="postgresql_app") async def postgresql_app_fixture( - ops_test: OpsTest, - model: Model, - postgresql_app_name: str, + ops_test: OpsTest, model: Model, postgresql_app_name: str, pytestconfig: Config ): """Deploy postgresql.""" + use_existing = pytestconfig.getoption("--use-existing", default=False) + if use_existing: + return model.applications[postgresql_app_name] async with ops_test.fast_forward(): await model.deploy(postgresql_app_name, channel="14/stable", trust=True) await model.wait_for_idle(status=ACTIVE_STATUS_NAME) @@ -295,3 +303,51 @@ async def deploy_prometheus_fixture( await model.wait_for_idle(raise_on_blocked=True, status=ACTIVE_STATUS_NAME) return app + + +@pytest.fixture(scope="module", name="user_username") +def user_username_fixture() -> typing.Generator[str, None, None]: + """Return the a username to be created for tests.""" + yield token_hex(16) + + +@pytest_asyncio.fixture(scope="module", name="user_password") +async def user_password_fixture( + synapse_app: Application, user_username: str +) -> typing.AsyncGenerator[str, None]: + """Return the a username to be created for tests.""" + action_register_user: Action = await synapse_app.units[0].run_action( # type: ignore + "register-user", username=user_username, admin=True + ) + await action_register_user.wait() + assert action_register_user.status == "completed" + assert action_register_user.results["register-user"] + password = action_register_user.results["user-password"] + assert password + yield password + + +@pytest_asyncio.fixture(scope="module", name="access_token") +async def access_token_fixture( + user_username: str, + user_password: str, + synapse_app: Application, + get_unit_ips: typing.Callable[[str], typing.Awaitable[tuple[str, ...]]], +) -> typing.AsyncGenerator[str, None]: + """Return the access token after login with the username and password.""" + synapse_ip = (await get_unit_ips(synapse_app.name))[0] + # login + 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()["access_token"] + assert access_token + yield access_token diff --git a/tests/integration/test_charm.py b/tests/integration/test_charm.py index 36c579cf..8dc92c1a 100644 --- a/tests/integration/test_charm.py +++ b/tests/integration/test_charm.py @@ -7,7 +7,6 @@ import logging import re import typing -from secrets import token_hex import pytest import requests @@ -64,6 +63,7 @@ async def test_synapse_is_up( assert "Welcome to the Matrix" in response.text +@pytest.mark.cos @pytest.mark.usefixtures("synapse_app", "prometheus_app") async def test_prometheus_integration( model: Model, @@ -79,7 +79,7 @@ async def test_prometheus_integration( """ await model.add_relation(prometheus_app_name, synapse_app_name) await model.wait_for_idle( - apps=[synapse_app_name, prometheus_app_name], status=ACTIVE_STATUS_NAME + idle_period=30, apps=[synapse_app_name, prometheus_app_name], status=ACTIVE_STATUS_NAME ) for unit_ip in await get_unit_ips(prometheus_app_name): @@ -87,6 +87,7 @@ async def test_prometheus_integration( assert len(query_targets["data"]["activeTargets"]) +@pytest.mark.cos @pytest.mark.usefixtures("synapse_app", "prometheus_app", "grafana_app") async def test_grafana_integration( model: Model, @@ -134,6 +135,7 @@ async def test_grafana_integration( assert len(dashboards) +@pytest.mark.nginx @pytest.mark.usefixtures("synapse_app") async def test_nginx_route_integration( model: Model, @@ -148,7 +150,7 @@ async def test_nginx_route_integration( """ await model.add_relation(f"{synapse_app_name}", f"{nginx_integrator_app_name}") await nginx_integrator_app.set_config({"service-hostname": synapse_app_name}) - await model.wait_for_idle(status=ACTIVE_STATUS_NAME) + await model.wait_for_idle(idle_period=30, status=ACTIVE_STATUS_NAME) response = requests.get( "http://127.0.0.1/_matrix/static/", headers={"Host": synapse_app_name}, timeout=5 @@ -182,39 +184,6 @@ async def test_reset_instance_action( assert current_server_name == another_server_name -async def test_register_user_action( - model: Model, - synapse_app: Application, - get_unit_ips: typing.Callable[[str], typing.Awaitable[tuple[str, ...]]], -) -> None: - """ - arrange: a deployed Synapse charm. - act: call the register user action. - assert: the user is registered and the login is successful. - """ - await model.wait_for_idle(status=ACTIVE_STATUS_NAME) - username = "operator" - unit = model.applications[synapse_app.name].units[0] - action_register_user: Action = await synapse_app.units[0].run_action( # type: ignore - "register-user", username=username, admin=True - ) - await action_register_user.wait() - assert action_register_user.status == "completed" - assert action_register_user.results["register-user"] - password = action_register_user.results["user-password"] - assert password - assert unit.workload_status == "active" - data = {"type": "m.login.password", "user": username, "password": password} - for unit_ip in await get_unit_ips(synapse_app.name): - response = requests.post( - f"http://{unit_ip}:{synapse.SYNAPSE_NGINX_PORT}/_matrix/client/r0/login", - json=data, - timeout=5, - ) - assert response.status_code == 200 - assert response.json()["access_token"] - - @pytest.mark.asyncio async def test_workload_version( ops_test: OpsTest, @@ -226,6 +195,7 @@ async def test_workload_version( act: get status from Juju. assert: the workload version is set and match the one given by Synapse API request. """ + await synapse_app.model.wait_for_idle(idle_period=30, apps=[synapse_app.name], status="active") _, status, _ = await ops_test.juju("status", "--format", "json") status = json.loads(status) juju_workload_version = status["applications"][synapse_app.name].get("version", "") @@ -243,46 +213,20 @@ async def test_workload_version( 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. - act: enable Mjolnir and create the management room. + 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. """ - synapse_ip = (await get_unit_ips(synapse_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 - username = token_hex(16) - action_register_user: Action = await synapse_app.units[0].run_action( # type: ignore - "register-user", username=username, admin=True - ) - await action_register_user.wait() - assert action_register_user.status == "completed" - assert action_register_user.results["register-user"] - password = action_register_user.results["user-password"] - await synapse_app.set_config({"enable_mjolnir": "true"}) - await synapse_app.model.wait_for_idle(apps=[synapse_app.name], status="blocked") - synapse_ip = (await get_unit_ips(synapse_app.name))[0] - # login - sess = requests.session() - res = sess.post( - f"http://{synapse_ip}:8080/_matrix/client/r0/login", - json={ - "identifier": {"type": "m.id.user", "user": username}, - "password": password, - "type": "m.login.password", - }, - timeout=5, + await synapse_app.model.wait_for_idle( + idle_period=30, timeout=120, apps=[synapse_app.name], status="blocked" ) - res.raise_for_status() - access_token = res.json()["access_token"] - assert access_token - # create the room + synapse_ip = (await get_unit_ips(synapse_app.name))[0] authorization_token = f"Bearer {access_token}" headers = {"Authorization": authorization_token} room_body = { @@ -293,6 +237,7 @@ async def test_synapse_enable_mjolnir( "room_version": "1", "topic": "moderators", } + sess = requests.session() res = sess.post( f"http://{synapse_ip}:8080/_matrix/client/v3/createRoom", json=room_body, @@ -300,16 +245,18 @@ async def test_synapse_enable_mjolnir( timeout=5, ) res.raise_for_status() - - # wait for idle 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(apps=[synapse_app.name], status="active") - # check healthz endpoint + await synapse_app.model.wait_for_idle( + idle_period=30, apps=[synapse_app.name], status="active" + ) + res = sess.get(f"http://{synapse_ip}:{synapse.MJOLNIR_HEALTH_PORT}/healthz", timeout=5) + assert res.status_code == 200 +@pytest.mark.nginx @pytest.mark.asyncio @pytest.mark.usefixtures("nginx_integrator_app") async def test_saml_auth( # pylint: disable=too-many-locals @@ -344,10 +291,12 @@ async def test_saml_auth( # pylint: disable=too-many-locals "metadata_url": f"https://{saml_helper.SAML_HOST}/metadata", } ) - await model.wait_for_idle() + await model.wait_for_idle(idle_period=30) await model.add_relation(saml_integrator_app.name, synapse_app.name) await model.wait_for_idle( - apps=[synapse_app.name, saml_integrator_app.name], status=ACTIVE_STATUS_NAME + idle_period=30, + apps=[synapse_app.name, saml_integrator_app.name], + status=ACTIVE_STATUS_NAME, ) session = requests.session() @@ -381,3 +330,44 @@ async def test_saml_auth( # pylint: disable=too-many-locals assert logged_in_page.status_code == 200 assert "Continue to your account" in logged_in_page.text + + +async def test_synapse_enable_smtp( + synapse_app: Application, + get_unit_ips: typing.Callable[[str], typing.Awaitable[tuple[str, ...]]], + access_token: str, +): + """ + arrange: build and deploy the Synapse charm, create an user, get the access token + and enable SMTP. + 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 + ) + + 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", + "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 diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index 4a8f375a..1ecaf9cd 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -211,14 +211,3 @@ def container_with_path_error_pass_fixture( remove_path_mock = unittest.mock.MagicMock(side_effect=path_error) monkeypatch.setattr(container_mocked, "remove_path", remove_path_mock) return container_mocked - - -@pytest.fixture(name="erase_database_mocked") -def erase_database_mocked_fixture(monkeypatch: pytest.MonkeyPatch) -> unittest.mock.MagicMock: - """Mock erase_database.""" - database_mocked = unittest.mock.MagicMock() - erase_database_mock = unittest.mock.MagicMock(side_effect=None) - monkeypatch.setattr(database_mocked, "erase_database", erase_database_mock) - monkeypatch.setattr(database_mocked, "get_conn", unittest.mock.MagicMock()) - monkeypatch.setattr(database_mocked, "get_relation_data", unittest.mock.MagicMock()) - return database_mocked diff --git a/tests/unit/test_charm.py b/tests/unit/test_charm.py index 9ec052f0..f70b6cf5 100644 --- a/tests/unit/test_charm.py +++ b/tests/unit/test_charm.py @@ -143,26 +143,6 @@ def test_traefik_integration(harness: Harness) -> None: } -def test_saml_integration_container_restart(monkeypatch: pytest.MonkeyPatch) -> None: - """ - arrange: start the Synapse charm, set server_name, mock container and enable_saml. - act: enable saml via pebble_service as the observer does. - assert: The container is restarted. - """ - harness = Harness(SynapseCharm) - harness.update_config({"server_name": TEST_SERVER_NAME}) - harness.begin() - monkeypatch.setattr("synapse.enable_saml", MagicMock) - container = MagicMock() - container_restart = MagicMock() - monkeypatch.setattr(container, "restart", container_restart) - - harness.charm.pebble_service.enable_saml(container) - - container_restart.assert_called_once() - harness.cleanup() - - def test_saml_integration_container_down(saml_configured: Harness) -> None: """ arrange: start the Synapse charm, set server_name, set Synapse container to be down. @@ -174,7 +154,7 @@ def test_saml_integration_container_down(saml_configured: Harness) -> None: harness.set_can_connect(harness.model.unit.containers[synapse.SYNAPSE_CONTAINER_NAME], False) relation = harness.charm.framework.model.get_relation("saml", 0) - harness.charm.saml.saml.on.saml_data_available.emit(relation) + harness.charm._saml.saml.on.saml_data_available.emit(relation) assert isinstance(harness.model.unit.status, ops.MaintenanceStatus) assert "Waiting for" in str(harness.model.unit.status) @@ -193,9 +173,9 @@ def test_saml_integration_pebble_error( 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) + monkeypatch.setattr(harness.charm._saml._pebble_service, "enable_saml", enable_saml_mock) - harness.charm.saml.saml.on.saml_data_available.emit(relation) + harness.charm._saml.saml.on.saml_data_available.emit(relation) assert isinstance(harness.model.unit.status, ops.BlockedStatus) assert "SAML integration failed" in str(harness.model.unit.status) diff --git a/tests/unit/test_database.py b/tests/unit/test_database.py index cdb2d414..14ac6597 100644 --- a/tests/unit/test_database.py +++ b/tests/unit/test_database.py @@ -38,7 +38,7 @@ def test_erase_database(harness: Harness, monkeypatch: pytest.MonkeyPatch) -> No assert: erase query is executed. """ harness.begin() - datasource = harness.charm.database.get_relation_as_datasource() + datasource = harness.charm._database.get_relation_as_datasource() db_client = DatabaseClient(datasource=datasource) conn_mock = unittest.mock.MagicMock() cursor_mock = conn_mock.cursor.return_value.__enter__.return_value @@ -71,7 +71,7 @@ def test_erase_database_error(harness: Harness, monkeypatch: pytest.MonkeyPatch) assert: exception is raised. """ harness.begin() - datasource = harness.charm.database.get_relation_as_datasource() + datasource = harness.charm._database.get_relation_as_datasource() db_client = DatabaseClient(datasource=datasource) conn_mock = unittest.mock.MagicMock() cursor_mock = conn_mock.cursor.return_value.__enter__.return_value @@ -93,7 +93,7 @@ def test_connect(harness: Harness, monkeypatch: pytest.MonkeyPatch): postgresql_relation = harness.model.relations["database"][0] harness.update_relation_data(postgresql_relation.id, "postgresql", {"password": token_hex(16)}) harness.begin() - datasource = harness.charm.database.get_relation_as_datasource() + datasource = harness.charm._database.get_relation_as_datasource() db_client = DatabaseClient(datasource=datasource) mock_connection = unittest.mock.MagicMock() mock_connection.autocommit = True @@ -118,7 +118,7 @@ def test_connect_error(harness: Harness, monkeypatch: pytest.MonkeyPatch) -> Non assert: exception is raised. """ harness.begin() - datasource = harness.charm.database.get_relation_as_datasource() + datasource = harness.charm._database.get_relation_as_datasource() db_client = DatabaseClient(datasource=datasource) error_msg = "Invalid query" connect_mock = unittest.mock.MagicMock(side_effect=psycopg2.Error(error_msg)) @@ -134,7 +134,7 @@ def test_prepare_database(harness: Harness, monkeypatch: pytest.MonkeyPatch) -> assert: update query is executed. """ harness.begin() - datasource = harness.charm.database.get_relation_as_datasource() + datasource = harness.charm._database.get_relation_as_datasource() db_client = DatabaseClient(datasource=datasource) conn_mock = unittest.mock.MagicMock() cursor_mock = conn_mock.cursor.return_value.__enter__.return_value @@ -160,7 +160,7 @@ def test_prepare_database_error(harness: Harness, monkeypatch: pytest.MonkeyPatc assert: exception is raised. """ harness.begin() - datasource = harness.charm.database.get_relation_as_datasource() + datasource = harness.charm._database.get_relation_as_datasource() db_client = DatabaseClient(datasource=datasource) conn_mock = unittest.mock.MagicMock() cursor_mock = conn_mock.cursor.return_value.__enter__.return_value @@ -195,8 +195,8 @@ def test_relation_as_datasource( port="5432", user="user", ) - assert expected == harness.charm.database.get_relation_as_datasource() - assert harness.charm.app.name == harness.charm.database.get_database_name() + assert expected == harness.charm._database.get_relation_as_datasource() + assert harness.charm.app.name == harness.charm._database.get_database_name() synapse_env = synapse.get_environment(harness.charm._charm_state) assert synapse_env["POSTGRES_DB"] == expected["db"] assert synapse_env["POSTGRES_HOST"] == expected["host"] @@ -215,10 +215,10 @@ def test_relation_as_datasource_error(harness: Harness, monkeypatch: pytest.Monk get_relation_as_datasource_mock = unittest.mock.MagicMock(return_value=None) monkeypatch.setattr( - harness.charm.database, "get_relation_as_datasource", get_relation_as_datasource_mock + harness.charm._database, "get_relation_as_datasource", get_relation_as_datasource_mock ) with pytest.raises(CharmDatabaseRelationNotFoundError): - harness.charm.database.get_database_name() + harness.charm._database.get_database_name() def test_change_config(harness: Harness): @@ -229,7 +229,7 @@ def test_change_config(harness: Harness): """ harness.begin() - harness.charm.database._change_config() + harness.charm._database._change_config() assert isinstance(harness.model.unit.status, ops.ActiveStatus) @@ -245,7 +245,7 @@ def test_change_config_error( harness.begin() harness.set_can_connect(harness.model.unit.containers[synapse.SYNAPSE_CONTAINER_NAME], False) - harness.charm.database._change_config() + harness.charm._database._change_config() assert isinstance(harness.model.unit.status, ops.MaintenanceStatus) @@ -267,6 +267,6 @@ def test_on_database_created(harness: Harness, monkeypatch: pytest.MonkeyPatch): database_observer, "DatabaseClient", unittest.mock.MagicMock(return_value=db_client_mock) ) - harness.charm.database._on_database_created(unittest.mock.MagicMock()) + harness.charm._database._on_database_created(unittest.mock.MagicMock()) db_client_mock.prepare.assert_called_once() diff --git a/tests/unit/test_mjolnir.py b/tests/unit/test_mjolnir.py index 055e21f0..c9fb83c8 100644 --- a/tests/unit/test_mjolnir.py +++ b/tests/unit/test_mjolnir.py @@ -175,6 +175,27 @@ def test_on_collect_status_service_exists( peer_data_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() + harness.set_leader(True) + container: ops.Container = harness.model.unit.get_container(synapse.SYNAPSE_CONTAINER_NAME) + monkeypatch.setattr(container, "get_services", MagicMock(return_value={})) + peer_data_mock = MagicMock() + monkeypatch.setattr(Mjolnir, "_update_peer_data", peer_data_mock) + + event_mock = MagicMock() + harness.charm._mjolnir._on_collect_status(event_mock) + + peer_data_mock.assert_not_called() + assert isinstance(harness.model.unit.status, ops.MaintenanceStatus) + + def test_on_collect_status_container_off( harness: Harness, monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/tests/unit/test_reset_instance_action.py b/tests/unit/test_reset_instance_action.py index 1d0f95d9..83bcd4ee 100644 --- a/tests/unit/test_reset_instance_action.py +++ b/tests/unit/test_reset_instance_action.py @@ -13,6 +13,7 @@ from ops.testing import Harness import synapse +from database_client import DatabaseClient from .conftest import TEST_SERVER_NAME @@ -84,6 +85,7 @@ def test_reset_instance_action_failed(harness: Harness) -> None: def test_reset_instance_action_path_error_blocked( container_with_path_error_blocked: unittest.mock.MagicMock, harness: Harness, + monkeypatch: pytest.MonkeyPatch, ) -> None: """ arrange: start the Synapse charm, set Synapse container to be ready and set server_name. @@ -96,6 +98,7 @@ def test_reset_instance_action_path_error_blocked( return_value=container_with_path_error_blocked ) event = unittest.mock.MagicMock() + monkeypatch.setattr(DatabaseClient, "erase", unittest.mock.MagicMock()) # Calling to test the action since is not possible calling via harness harness.charm._on_reset_instance_action(event) @@ -109,7 +112,6 @@ def test_reset_instance_action_path_error_pass( container_with_path_error_pass: unittest.mock.MagicMock, harness: Harness, monkeypatch: pytest.MonkeyPatch, - erase_database_mocked: unittest.mock.MagicMock, ) -> None: """ arrange: start the Synapse charm, set Synapse container to be ready and set server_name. @@ -118,7 +120,6 @@ def test_reset_instance_action_path_error_pass( """ harness.begin() harness.set_leader(True) - harness.charm._database = erase_database_mocked content = io.StringIO(f'server_name: "{TEST_SERVER_NAME}"') pull_mock = unittest.mock.MagicMock(return_value=content) monkeypatch.setattr(container_with_path_error_pass, "pull", pull_mock) @@ -126,6 +127,7 @@ def test_reset_instance_action_path_error_pass( return_value=container_with_path_error_pass ) event = unittest.mock.MagicMock() + monkeypatch.setattr(DatabaseClient, "erase", unittest.mock.MagicMock()) # Calling to test the action since is not possible calling via harness harness.charm._on_reset_instance_action(event) diff --git a/tests/unit/test_synapse_api.py b/tests/unit/test_synapse_api.py index f900a8cc..a646ec41 100644 --- a/tests/unit/test_synapse_api.py +++ b/tests/unit/test_synapse_api.py @@ -218,7 +218,8 @@ def test_override_rate_limit_success(monkeypatch: pytest.MonkeyPatch): user = User(username=username, admin=True) admin_access_token = token_hex(16) server = token_hex(16) - synapse_config = SynapseConfig(server_name=server, report_stats="False", public_baseurl="") + # while using Pydantic, mypy ignores default values + synapse_config = SynapseConfig(server_name=server) # type: ignore[call-arg] charm_state = CharmState(synapse_config=synapse_config, datasource=None, saml_config=None) expected_url = ( f"http://localhost:8008/_synapse/admin/v1/users/@any-user:{server}/override_ratelimit" @@ -246,7 +247,8 @@ def test_override_rate_limit_error(monkeypatch: pytest.MonkeyPatch): user = User(username=username, admin=True) admin_access_token = token_hex(16) server = token_hex(16) - synapse_config = SynapseConfig(server_name=server, report_stats="False", public_baseurl="") + # while using Pydantic, mypy ignores default values + synapse_config = SynapseConfig(server_name=server) # type: ignore[call-arg] charm_state = CharmState(synapse_config=synapse_config, datasource=None, saml_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 61047a5c..9394a330 100644 --- a/tests/unit/test_synapse_workload.py +++ b/tests/unit/test_synapse_workload.py @@ -198,3 +198,145 @@ def test_create_mjolnir_config_success(monkeypatch: pytest.MonkeyPatch): push_mock.assert_called_once_with( synapse.MJOLNIR_CONFIG_PATH, yaml.safe_dump(expected_config), make_dirs=True ) + + +def test_enable_smtp_success(harness: Harness, monkeypatch: pytest.MonkeyPatch): + """ + arrange: set mock container with file. + act: update smtp_host config and call enable_smtp. + assert: new configuration file is pushed and SMTP is enabled. + """ + config_content = """ + listeners: + - type: http + port: 8080 + bind_addresses: + - "::" + """ + text_io_mock = io.StringIO(config_content) + pull_mock = Mock(return_value=text_io_mock) + push_mock = MagicMock() + container_mock = MagicMock() + 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) + + 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}, + } + assert push_mock.call_args[0][1] == yaml.safe_dump(expected_config_content) + + +def test_enable_smtp_error(harness: Harness, monkeypatch: pytest.MonkeyPatch): + """ + arrange: set mock container with file. + act: update smtp_host config and call enable_smtp. + assert: raise WorkloadError in case of error. + """ + error_message = "Error pulling file" + path_error = ops.pebble.PathError(kind="fake", message=error_message) + pull_mock = MagicMock(side_effect=path_error) + container_mock = MagicMock() + monkeypatch.setattr(container_mock, "pull", pull_mock) + + 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) + + +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. + assert: new configuration file is pushed and serve_server_wellknown is enabled. + """ + config_content = """ + listeners: + - type: http + port: 8080 + bind_addresses: + - "::" + """ + text_io_mock = io.StringIO(config_content) + pull_mock = Mock(return_value=text_io_mock) + push_mock = MagicMock() + container_mock = MagicMock() + monkeypatch.setattr(container_mock, "pull", pull_mock) + monkeypatch.setattr(container_mock, "push", push_mock) + + synapse.enable_serve_server_wellknown(container_mock) + + assert pull_mock.call_args[0][0] == synapse.SYNAPSE_CONFIG_PATH + assert push_mock.call_args[0][0] == synapse.SYNAPSE_CONFIG_PATH + expected_config_content = { + "listeners": [ + {"type": "http", "port": 8080, "bind_addresses": ["::"]}, + ], + "serve_server_wellknown": True, + } + assert push_mock.call_args[0][1] == yaml.safe_dump(expected_config_content) + + +def test_enable_serve_server_wellknown_error(monkeypatch: pytest.MonkeyPatch): + """ + arrange: set mock container with file. + act: call enable_serve_server_wellknown. + assert: raise WorkloadError. + """ + error_message = "Error pulling file" + path_error = ops.pebble.PathError(kind="fake", message=error_message) + pull_mock = MagicMock(side_effect=path_error) + container_mock = MagicMock() + monkeypatch.setattr(container_mock, "pull", pull_mock) + + with pytest.raises(synapse.WorkloadError, match=error_message): + synapse.enable_serve_server_wellknown(container_mock) + + +def test_get_registration_shared_secret_success(monkeypatch: pytest.MonkeyPatch): + """ + arrange: set mock container with file. + act: call get_registration_shared_secret. + assert: registration_shared_secret is returned. + """ + expected_secret = token_hex(16) + config_content = f"registration_shared_secret: {expected_secret}" + text_io_mock = io.StringIO(config_content) + pull_mock = Mock(return_value=text_io_mock) + push_mock = MagicMock() + container_mock = MagicMock() + monkeypatch.setattr(container_mock, "pull", pull_mock) + monkeypatch.setattr(container_mock, "push", push_mock) + + received_secret = synapse.get_registration_shared_secret(container_mock) + + assert pull_mock.call_args[0][0] == synapse.SYNAPSE_CONFIG_PATH + assert received_secret == expected_secret + + +def test_get_registration_shared_secret_error(monkeypatch: pytest.MonkeyPatch): + """ + arrange: set mock container with file. + act: call get_registration_shared_secret. + assert: raise WorkloadError. + """ + error_message = "Error pulling file" + path_error = ops.pebble.PathError(kind="fake", message=error_message) + pull_mock = MagicMock(side_effect=path_error) + container_mock = MagicMock() + monkeypatch.setattr(container_mock, "pull", pull_mock) + + with pytest.raises(ops.pebble.PathError, match=error_message): + synapse.get_registration_shared_secret(container_mock)