From cae4ea1fd6f354887a60f513178cded73a80f605 Mon Sep 17 00:00:00 2001 From: Niels Robin-Aubertin Date: Fri, 22 Sep 2023 10:07:19 -0400 Subject: [PATCH] Use pytest for the unit tests (#105) * Use pytest for the unit tests * Address PR comments * Rework status check for some tests * Add param ids for test_relations * Simplify unit tests * Fixes * Use handle_exec in unit tests * Remove left overs * Fix --------- Co-authored-by: arturo-seijas <102022572+arturo-seijas@users.noreply.github.com> --- src/charm.py | 3 +- tests/unit/__init__.py | 2 - tests/unit/_patched_charm.py | 61 -- tests/unit/helpers.py | 132 ++++ tests/unit/test_charm.py | 1280 ++++++++++++++++------------------ tox.ini | 3 +- 6 files changed, 733 insertions(+), 748 deletions(-) delete mode 100644 tests/unit/_patched_charm.py create mode 100644 tests/unit/helpers.py diff --git a/src/charm.py b/src/charm.py index afa2c868..439d00ae 100755 --- a/src/charm.py +++ b/src/charm.py @@ -8,7 +8,7 @@ import typing from collections import defaultdict, namedtuple -import ops.lib +import ops from charms.data_platform_libs.v0.data_interfaces import ( DatabaseCreatedEvent, DatabaseEndpointsChangedEvent, @@ -27,7 +27,6 @@ from database import DatabaseHandler logger = logging.getLogger(__name__) -pgsql = ops.lib.use("pgsql", 1, "postgresql-charmers@lists.launchpad.net") S3Info = namedtuple("S3Info", ["enabled", "region", "bucket", "endpoint"]) diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py index 257f97c3..db3bfe1a 100644 --- a/tests/unit/__init__.py +++ b/tests/unit/__init__.py @@ -1,4 +1,2 @@ # Copyright 2023 Canonical Ltd. # See LICENSE file for licensing details. - -"""Ops testing settings.""" diff --git a/tests/unit/_patched_charm.py b/tests/unit/_patched_charm.py deleted file mode 100644 index 0844758b..00000000 --- a/tests/unit/_patched_charm.py +++ /dev/null @@ -1,61 +0,0 @@ -# Copyright 2023 Canonical Ltd. -# See LICENSE file for licensing details. - -"""Patch the ``ops-lib-pgsql`` library for unit testing. - -This script is used to monkey patch necessary code to allow running unit tests with -``ops-lib-pgsql``. This patch needs to be run prior to the importing of the main module since -the main module uses ``ops.lib.use`` which runs ``exec_module`` internally and the -``ops-lib-pgsql`` just happens to use ``from .client import *``. Combined, that makes patching -any private variables inside ``pgsql.client`` afterwards impossible. -""" - -from unittest.mock import MagicMock, patch - -import ops.lib -import pgsql.client - -__all__ = ["DiscourseCharm", "pgsql_patch"] - -_og_use = ops.lib.use - - -def _use(*args, **kwargs): - print("use: ", args) - if args == ("pgsql", 1, "postgresql-charmers@lists.launchpad.net"): - return pgsql - return _og_use(*args, **kwargs) - - -ops.lib.use = _use - - -class _PGSQLPatch: - def __init__(self): - # borrow some code from - # https://github.com/canonical/ops-lib-pgsql/blob/master/tests/test_client.py - self._leadership_data = {} # type: ignore - self._patch = patch.multiple( - pgsql.client, - _is_ready=MagicMock(return_value=True), - _get_pgsql_leader_data=self._leadership_data.copy, - _set_pgsql_leader_data=self._leadership_data.update, - ) - - def _reset_leadership_data(self): - self._leadership_data.clear() - - def start(self): - """start PostgreSQL.""" - self._reset_leadership_data() - self._patch.start() - - def stop(self): - """stop PostgreSQL.""" - self._reset_leadership_data() - self._patch.stop() - - -pgsql_patch = _PGSQLPatch() -DiscourseCharm = __import__("charm").DiscourseCharm -DISCOURSE_PATH = __import__("charm").DISCOURSE_PATH diff --git a/tests/unit/helpers.py b/tests/unit/helpers.py new file mode 100644 index 00000000..2a44ea51 --- /dev/null +++ b/tests/unit/helpers.py @@ -0,0 +1,132 @@ +# Copyright 2023 Canonical Ltd. +# See LICENSE file for licensing details. + +"""helpers for the unit test.""" + +import contextlib +import typing +import unittest.mock + +from ops.model import Container +from ops.testing import Harness + +from charm import DiscourseCharm + +DATABASE_NAME = "discourse" + + +def start_harness( + *, + with_postgres: bool = True, + with_redis: bool = True, + with_ingress: bool = False, + with_config: typing.Optional[typing.Dict[str, typing.Any]] = None, +): + """Start a harness discourse charm. + + This is also a workaround for the fact that Harness + doesn't reinitialize the charm as expected. + Ref: https://github.com/canonical/operator/issues/736 + + Args: + - with_postgres: should a postgres relation be added + - with_redis: should a redis relation be added + - with_ingress: should a ingress relation be added + - with_config: apply some configuration to the charm + + Returns: a ready to use harness instance + """ + harness = Harness(DiscourseCharm) + harness.begin_with_initial_hooks() + + # We catch all exec calls to the container by default + harness.handle_exec("discourse", [], result=0) + + if with_postgres: + _add_postgres_relation(harness) + + if with_redis: + _add_redis_relation(harness) + harness.framework.reemit() + + if with_ingress: + _add_ingress_relation(harness) + + if with_config is not None: + harness.update_config(with_config) + harness.container_pebble_ready("discourse") + + return harness + + +@contextlib.contextmanager +def _patch_setup_completed(): + """Patch filesystem calls in the _is_setup_completed and _set_setup_completed functions.""" + setup_completed = False + + def exists(*_args, **_kwargs): + return setup_completed + + def push(*_args, **_kwargs): + nonlocal setup_completed + setup_completed = True + + with unittest.mock.patch.multiple(Container, exists=exists, push=push): + yield + + +def _add_postgres_relation(harness): + """Add postgresql relation and relation data to the charm. + + Args: + - A harness instance + + Returns: the same harness instance with an added relation + """ + + relation_data = { + "database": DATABASE_NAME, + "endpoints": "dbhost:5432,dbhost-2:5432", + "password": "somepasswd", # nosec + "username": "someuser", + } + + # get a relation ID for the test outside of __init__ + harness.db_relation_id = ( # pylint: disable=attribute-defined-outside-init + harness.add_relation("database", "postgresql") + ) + harness.add_relation_unit(harness.db_relation_id, "postgresql/0") + harness.update_relation_data( + harness.db_relation_id, + "postgresql", + relation_data, + ) + + +def _add_redis_relation(harness): + """Add redis relation and relation data to the charm. + + Args: + - A harness instance + + Returns: the same harness instance with an added relation + """ + redis_relation_id = harness.add_relation("redis", "redis") + harness.add_relation_unit(redis_relation_id, "redis/0") + # We need to bypass protected access to inject the relation data + # pylint: disable=protected-access + harness.charm._stored.redis_relation = { + redis_relation_id: {"hostname": "redis-host", "port": 1010} + } + + +def _add_ingress_relation(harness): + """Add ingress relation and relation data to the charm. + + Args: + - A harness instance + + Returns: the same harness instance with an added relation + """ + nginx_route_relation_id = harness.add_relation("nginx-route", "ingress") + harness.add_relation_unit(nginx_route_relation_id, "ingress/0") diff --git a/tests/unit/test_charm.py b/tests/unit/test_charm.py index 0e2f8560..1b92b60d 100644 --- a/tests/unit/test_charm.py +++ b/tests/unit/test_charm.py @@ -6,697 +6,613 @@ # pylint: disable=protected-access # Protected access check is disabled in tests as we're injecting test data -import contextlib import secrets import typing -import unittest -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock -import ops.model -import ops.pebble +import ops +import pytest from ops.charm import ActionEvent -from ops.model import ActiveStatus, BlockedStatus, Container, WaitingStatus -from ops.testing import Harness +from ops.model import ActiveStatus, BlockedStatus, WaitingStatus -from tests.unit._patched_charm import DISCOURSE_PATH, DiscourseCharm, pgsql_patch - -BLOCKED_STATUS = BlockedStatus.name # type: ignore +from charm import DISCOURSE_PATH, DiscourseCharm +from tests.unit import helpers DATABASE_NAME = "discourse" -# pylint: disable=too-many-public-methods -class TestDiscourseK8sCharm(unittest.TestCase): - """Unit tests for Discourse charm.""" - - def start_harness(self, with_postgres: bool, with_redis: bool): - """Start a harness discourse charm. - - Args: - - with_postgres: should a postgres relation be added - - with_redis: should a redis relation be added - """ - self.harness = Harness(DiscourseCharm) - self.harness.disable_hooks() - self.harness._framework = ops.framework.Framework( - self.harness._storage, self.harness._charm_dir, self.harness._meta, self.harness._model - ) - if with_postgres: - self._add_postgres_relation() - self.harness.enable_hooks() - self.harness.begin_with_initial_hooks() - if with_redis: - self._add_redis_relation() - with self._patch_exec(): - self.harness.framework.reemit() - - with self._patch_exec(), self._patch_setup_completed(): - charm: DiscourseCharm = typing.cast(DiscourseCharm, self.harness.charm) - charm._set_setup_completed() - - def setUp(self): - pgsql_patch.start() - self.start_harness(with_postgres=True, with_redis=True) - self.addCleanup(self.harness.cleanup) - - def tearDown(self): - pgsql_patch.stop() - - @contextlib.contextmanager - def _patch_exec(self, fail: bool = False) -> typing.Generator[unittest.mock.Mock, None, None]: - """Patch the ops.model.Container.exec method. - - When fail argument is true, the execution will fail. - - Yields: - Mock for the exec method. - """ - exec_process_mock = unittest.mock.MagicMock() - if not fail: - exec_process_mock.wait_output = unittest.mock.MagicMock(return_value=("", "")) - else: - exec_process_mock.wait_output = unittest.mock.Mock() - exec_process_mock.wait_output.side_effect = ops.pebble.ExecError([], 1, "", "") - exec_function_mock = unittest.mock.MagicMock(return_value=exec_process_mock) - with unittest.mock.patch.multiple(ops.model.Container, exec=exec_function_mock): - yield exec_function_mock - - @contextlib.contextmanager - def _patch_setup_completed(self): - """Patch filesystem calls in the _is_setup_completed and _set_setup_completed functions.""" - setup_completed = False - - def exists(*_args, **_kwargs): - return setup_completed - - def push(*_args, **_kwargs): - nonlocal setup_completed - setup_completed = True - - with unittest.mock.patch.multiple(Container, exists=exists, push=push): - yield - - def test_relations_not_ready(self): - """ - arrange: given a deployed discourse charm - act: when pebble ready event is triggered - assert: it will wait for the db relation. - """ - self.start_harness(with_postgres=False, with_redis=False) - self.assertEqual( - self.harness.model.unit.status, - WaitingStatus("Waiting for database relation"), - ) - - def test_db_relation_not_ready(self): - """ - arrange: given a deployed discourse charm with the redis relation stablished - act: when pebble ready event is triggered - assert: it will wait for the db relation. - """ - self.start_harness(with_postgres=False, with_redis=True) - self.assertEqual( - self.harness.model.unit.status, - WaitingStatus("Waiting for database relation"), - ) - - def test_redis_relation_not_ready(self): - """ - arrange: given a deployed discourse charm with the redis db stablished - act: when pebble ready event is triggered - assert: it will wait for the redis relation. - """ - self.start_harness(with_postgres=True, with_redis=False) - - self.assertEqual( - self.harness.model.unit.status, - WaitingStatus("Waiting for redis relation"), - ) - - def test_ingress_relation_not_ready(self): - """ - arrange: given a deployed discourse charm with the ingress established - act: when pebble ready event is triggered - assert: it will wait for the ingress relation. - """ - self.start_harness(with_postgres=False, with_redis=False) - self._add_ingress_relation() - - self.assertEqual( - self.harness.model.unit.status, - WaitingStatus("Waiting for database relation"), - ) - - def test_config_changed_when_no_saml_target(self): - """ - arrange: given a deployed discourse charm with all the required relations - act: when force_saml_login configuration is True and there's no saml_target_url - assert: it will get to blocked status waiting for the latter. - """ - self.harness.update_config({"force_saml_login": True, "saml_target_url": ""}) - with self._patch_exec(): - self.harness.container_pebble_ready("discourse") - - self.assertEqual( - self.harness.model.unit.status, - BlockedStatus("force_saml_login can not be true without a saml_target_url"), - ) - - def test_config_changed_when_saml_sync_groups_and_no_url_invalid(self): - """ - arrange: given a deployed discourse charm with all the required relations - act: when saml_sync_groups configuration is provided and there's no saml_target_url - assert: it will get to blocked status waiting for the latter. - """ - self.harness.update_config({"saml_sync_groups": "group1", "saml_target_url": ""}) - with self._patch_exec(): - self.harness.container_pebble_ready("discourse") - - self.assertEqual( - self.harness.model.unit.status, - BlockedStatus("'saml_sync_groups' cannot be specified without a 'saml_target_url'"), - ) - - def test_config_changed_when_saml_target_url_and_force_https_disabled(self): - """ - arrange: given a deployed discourse charm with all the required relations - act: when saml_target_url configuration is provided and force_https is False - assert: it will get to blocked status waiting for the latter. - """ - self.harness.update_config({"saml_target_url": "group1", "force_https": False}) - with self._patch_exec(): - self.harness.container_pebble_ready("discourse") - - self.assertEqual( - self.harness.model.unit.status, - BlockedStatus( - "'saml_target_url' cannot be specified without 'force_https' being true" - ), - ) - - def test_config_changed_when_no_cors(self): - """ - arrange: given a deployed discourse charm with all the required relations - act: when cors_origin configuration is empty - assert: it will get to blocked status waiting for it. - """ - self.harness.update_config({"cors_origin": ""}) - with self._patch_exec(): - self.harness.container_pebble_ready("discourse") - - self.assertNotEqual( - self.harness.charm._database.get_relation_data(), - None, - "database name should be set after relation joined", - ) - - self.assertEqual( - self.harness.charm._database.get_relation_data().get("POSTGRES_DB"), - "discourse", - "database name should be set after relation joined", - ) - - def test_config_changed_when_throttle_mode_invalid(self): - """ - arrange: given a deployed discourse charm with all the required relations - act: when throttle_level configuration is invalid - assert: it will get to blocked status waiting for a valid value to be provided. - """ - self.harness.update_config({"throttle_level": "Scream"}) - - self.assertEqual(self.harness.model.unit.status.name, BLOCKED_STATUS) - self.assertTrue("none permissive strict" in self.harness.model.unit.status.message) - - def test_config_changed_when_s3_and_no_bucket_invalid(self): - """ - arrange: given a deployed discourse charm with all the required relations - act: when s3_enabled configuration is True and there's no s3_bucket - assert: it will get to blocked status waiting for the latter. - """ - self.harness.update_config( - { - "s3_access_key_id": "3|33+", - "s3_enabled": True, - "s3_endpoint": "s3.endpoint", - "s3_region": "the-infinite-and-beyond", - "s3_secret_access_key": "s|kI0ure_k3Y", - } - ) - - self.assertEqual( - self.harness.model.unit.status, - BlockedStatus("'s3_enabled' requires 's3_bucket'"), - ) - - def test_config_changed_when_valid_no_s3_backup_nor_cdn(self): - """ - arrange: given a deployed discourse charm with all the required relations - act: when a valid configuration is provided - assert: the appropriate configuration values are passed to the pod and the unit - reaches Active status. - """ - with self._patch_exec() as mock_exec: - self.harness.set_leader(True) - self.harness.update_config( - { - "s3_access_key_id": "3|33+", - "s3_bucket": "who-s-a-good-bucket?", - "s3_enabled": True, - "s3_endpoint": "s3.endpoint", - "s3_region": "the-infinite-and-beyond", - "s3_secret_access_key": "s|kI0ure_k3Y", - } - ) - self.harness.container_pebble_ready("discourse") - self.harness.framework.reemit() - - assert self.harness._charm - mock_exec.assert_any_call( - [f"{DISCOURSE_PATH}/bin/bundle", "exec", "rake", "s3:upload_assets"], - environment=self.harness._charm._create_discourse_environment_settings(), - working_dir=DISCOURSE_PATH, - user="_daemon_", - ) - updated_plan = self.harness.get_container_pebble_plan("discourse").to_dict() - updated_plan_env = updated_plan["services"]["discourse"]["environment"] - self.assertNotIn("DISCOURSE_BACKUP_LOCATION", updated_plan_env) - self.assertEqual("*", updated_plan_env["DISCOURSE_CORS_ORIGIN"]) - self.assertEqual("dbhost", updated_plan_env["DISCOURSE_DB_HOST"]) - self.assertEqual(DATABASE_NAME, updated_plan_env["DISCOURSE_DB_NAME"]) - self.assertEqual("somepasswd", updated_plan_env["DISCOURSE_DB_PASSWORD"]) - self.assertEqual("someuser", updated_plan_env["DISCOURSE_DB_USERNAME"]) - self.assertTrue(updated_plan_env["DISCOURSE_ENABLE_CORS"]) - self.assertEqual("discourse-k8s", updated_plan_env["DISCOURSE_HOSTNAME"]) - self.assertEqual("redis-host", updated_plan_env["DISCOURSE_REDIS_HOST"]) - self.assertEqual("1010", updated_plan_env["DISCOURSE_REDIS_PORT"]) - self.assertTrue(updated_plan_env["DISCOURSE_SERVE_STATIC_ASSETS"]) - self.assertEqual("3|33+", updated_plan_env["DISCOURSE_S3_ACCESS_KEY_ID"]) - self.assertNotIn("DISCOURSE_S3_BACKUP_BUCKET", updated_plan_env) - self.assertNotIn("DISCOURSE_S3_CDN_URL", updated_plan_env) - self.assertEqual("who-s-a-good-bucket?", updated_plan_env["DISCOURSE_S3_BUCKET"]) - self.assertEqual("s3.endpoint", updated_plan_env["DISCOURSE_S3_ENDPOINT"]) - self.assertEqual("the-infinite-and-beyond", updated_plan_env["DISCOURSE_S3_REGION"]) - self.assertEqual("s|kI0ure_k3Y", updated_plan_env["DISCOURSE_S3_SECRET_ACCESS_KEY"]) - self.assertTrue(updated_plan_env["DISCOURSE_USE_S3"]) - self.assertEqual(self.harness.model.unit.status, ActiveStatus()) - - def test_config_changed_when_valid_no_fingerprint(self): - """ - arrange: given a deployed discourse charm with all the required relations - act: when a valid configuration is provided - assert: the appropriate configuration values are passed to the pod and the unit - reaches Active status. - """ - with self._patch_exec(): - self.harness.update_config( - { - "force_saml_login": True, - "saml_target_url": "https://login.sample.com/+saml", - "saml_sync_groups": "group1", - "s3_enabled": False, - "force_https": True, - } - ) - self.harness.container_pebble_ready("discourse") - self.harness.framework.reemit() - - updated_plan = self.harness.get_container_pebble_plan("discourse").to_dict() - updated_plan_env = updated_plan["services"]["discourse"]["environment"] - self.assertEqual("*", updated_plan_env["DISCOURSE_CORS_ORIGIN"]) - self.assertEqual("dbhost", updated_plan_env["DISCOURSE_DB_HOST"]) - self.assertEqual(DATABASE_NAME, updated_plan_env["DISCOURSE_DB_NAME"]) - self.assertEqual("somepasswd", updated_plan_env["DISCOURSE_DB_PASSWORD"]) - self.assertEqual("someuser", updated_plan_env["DISCOURSE_DB_USERNAME"]) - self.assertTrue(updated_plan_env["DISCOURSE_ENABLE_CORS"]) - self.assertEqual("discourse-k8s", updated_plan_env["DISCOURSE_HOSTNAME"]) - self.assertEqual("redis-host", updated_plan_env["DISCOURSE_REDIS_HOST"]) - self.assertEqual("1010", updated_plan_env["DISCOURSE_REDIS_PORT"]) - self.assertNotIn("DISCOURSE_SAML_CERT_FINGERPRINT", updated_plan_env) - self.assertEqual("true", updated_plan_env["DISCOURSE_SAML_FULL_SCREEN_LOGIN"]) - self.assertEqual( - "https://login.sample.com/+saml", updated_plan_env["DISCOURSE_SAML_TARGET_URL"] - ) - self.assertEqual("false", updated_plan_env["DISCOURSE_SAML_GROUPS_FULLSYNC"]) - self.assertEqual("true", updated_plan_env["DISCOURSE_SAML_SYNC_GROUPS"]) - self.assertEqual("group1", updated_plan_env["DISCOURSE_SAML_SYNC_GROUPS_LIST"]) - self.assertTrue(updated_plan_env["DISCOURSE_SERVE_STATIC_ASSETS"]) - self.assertEqual("none", updated_plan_env["DISCOURSE_SMTP_AUTHENTICATION"]) - self.assertEqual("none", updated_plan_env["DISCOURSE_SMTP_OPENSSL_VERIFY_MODE"]) - self.assertNotIn("DISCOURSE_USE_S3", updated_plan_env) - self.assertEqual(self.harness.model.unit.status, ActiveStatus()) - - def test_config_changed_when_valid(self): - """ - arrange: given a deployed discourse charm with all the required relations - act: when a valid configuration is provided - assert: the appropriate configuration values are passed to the pod and the unit - reaches Active status. - """ - with self._patch_exec(): - self.harness.update_config( - { - "developer_emails": "user@foo.internal", - "enable_cors": True, - "external_hostname": "discourse.local", - "force_saml_login": True, - "saml_target_url": "https://login.ubuntu.com/+saml", - "saml_sync_groups": "group1", - "smtp_address": "smtp.internal", - "smtp_domain": "foo.internal", - "smtp_password": "OBV10USLYF4K3", - "smtp_username": "apikey", - "s3_access_key_id": "3|33+", - "s3_backup_bucket": "back-bucket", - "s3_bucket": "who-s-a-good-bucket?", - "s3_cdn_url": "s3.cdn", - "s3_enabled": True, - "s3_endpoint": "s3.endpoint", - "s3_region": "the-infinite-and-beyond", - "s3_secret_access_key": "s|kI0ure_k3Y", - "force_https": True, - } - ) - self.harness.container_pebble_ready("discourse") - self.harness.framework.reemit() - - updated_plan = self.harness.get_container_pebble_plan("discourse").to_dict() - updated_plan_env = updated_plan["services"]["discourse"]["environment"] - self.assertEqual("s3", updated_plan_env["DISCOURSE_BACKUP_LOCATION"]) - self.assertEqual("*", updated_plan_env["DISCOURSE_CORS_ORIGIN"]) - self.assertEqual("dbhost", updated_plan_env["DISCOURSE_DB_HOST"]) - self.assertEqual(DATABASE_NAME, updated_plan_env["DISCOURSE_DB_NAME"]) - self.assertEqual("somepasswd", updated_plan_env["DISCOURSE_DB_PASSWORD"]) - self.assertEqual("someuser", updated_plan_env["DISCOURSE_DB_USERNAME"]) - self.assertEqual("user@foo.internal", updated_plan_env["DISCOURSE_DEVELOPER_EMAILS"]) - self.assertTrue(updated_plan_env["DISCOURSE_ENABLE_CORS"]) - self.assertEqual("discourse.local", updated_plan_env["DISCOURSE_HOSTNAME"]) - self.assertEqual("redis-host", updated_plan_env["DISCOURSE_REDIS_HOST"]) - self.assertEqual("1010", updated_plan_env["DISCOURSE_REDIS_PORT"]) - self.assertIsNotNone(updated_plan_env["DISCOURSE_SAML_CERT_FINGERPRINT"]) - self.assertEqual("true", updated_plan_env["DISCOURSE_SAML_FULL_SCREEN_LOGIN"]) - self.assertEqual( - "https://login.ubuntu.com/+saml", updated_plan_env["DISCOURSE_SAML_TARGET_URL"] - ) - self.assertEqual("false", updated_plan_env["DISCOURSE_SAML_GROUPS_FULLSYNC"]) - self.assertEqual("true", updated_plan_env["DISCOURSE_SAML_SYNC_GROUPS"]) - self.assertEqual("group1", updated_plan_env["DISCOURSE_SAML_SYNC_GROUPS_LIST"]) - self.assertTrue(updated_plan_env["DISCOURSE_SERVE_STATIC_ASSETS"]) - self.assertEqual("3|33+", updated_plan_env["DISCOURSE_S3_ACCESS_KEY_ID"]) - self.assertEqual("back-bucket", updated_plan_env["DISCOURSE_S3_BACKUP_BUCKET"]) - self.assertEqual("s3.cdn", updated_plan_env["DISCOURSE_S3_CDN_URL"]) - self.assertEqual("who-s-a-good-bucket?", updated_plan_env["DISCOURSE_S3_BUCKET"]) - self.assertEqual("s3.endpoint", updated_plan_env["DISCOURSE_S3_ENDPOINT"]) - self.assertEqual("the-infinite-and-beyond", updated_plan_env["DISCOURSE_S3_REGION"]) - self.assertEqual("s|kI0ure_k3Y", updated_plan_env["DISCOURSE_S3_SECRET_ACCESS_KEY"]) - self.assertEqual("smtp.internal", updated_plan_env["DISCOURSE_SMTP_ADDRESS"]) - self.assertEqual("none", updated_plan_env["DISCOURSE_SMTP_AUTHENTICATION"]) - self.assertEqual("foo.internal", updated_plan_env["DISCOURSE_SMTP_DOMAIN"]) - self.assertEqual("none", updated_plan_env["DISCOURSE_SMTP_OPENSSL_VERIFY_MODE"]) - self.assertEqual("OBV10USLYF4K3", updated_plan_env["DISCOURSE_SMTP_PASSWORD"]) - self.assertEqual("587", updated_plan_env["DISCOURSE_SMTP_PORT"]) - self.assertEqual("apikey", updated_plan_env["DISCOURSE_SMTP_USER_NAME"]) - self.assertTrue(updated_plan_env["DISCOURSE_USE_S3"]) - self.assertEqual(self.harness.model.unit.status, ActiveStatus()) - - def test_db_relation(self): - """ - arrange: given a deployed discourse charm - act: when the database relation is added - assert: the appropriate database name is set. - """ - self.harness.set_leader(True) - - db_relation_data = self.harness.get_relation_data( - self.db_relation_id, - "postgresql", - ) - - self.assertEqual( - db_relation_data.get("database"), - "discourse", - "database name should be set after relation joined", - ) - - self.assertEqual( - self.harness.charm._database.get_relation_data().get("POSTGRES_DB"), - "discourse", - "database name should be set after relation joined", - ) - - @patch.object(Container, "exec") - def test_add_admin_user(self, mock_exec): - """ - arrange: an email and a password - act: when the _on_add_admin_user_action mtehod is executed - assert: the underlying rake command to add the user is executed - with the appropriate parameters. - """ - charm: DiscourseCharm = typing.cast(DiscourseCharm, self.harness.charm) - - email = "sample@email.com" - password = "somepassword" # nosec - event = MagicMock(spec=ActionEvent) - event.params = { - "email": email, - "password": password, +@pytest.mark.parametrize( + "with_postgres, with_redis, with_ingress, status", + [ + (False, False, False, WaitingStatus("Waiting for database relation")), + (False, True, False, WaitingStatus("Waiting for database relation")), + (True, False, False, WaitingStatus("Waiting for redis relation")), + (True, True, False, ActiveStatus("")), + (False, False, True, WaitingStatus("Waiting for database relation")), + (False, True, True, WaitingStatus("Waiting for database relation")), + (True, False, True, WaitingStatus("Waiting for redis relation")), + (True, True, True, ActiveStatus("")), + ], + ids=[ + "No relation", + "Only redis", + "Only postgres", + "Postgres+redis", + "Only ingress", + "Redis+ingress", + "Postgres+ingress", + "All relations", + ], +) +def test_relations(with_postgres, with_redis, with_ingress, status): + """ + arrange: given a deployed discourse charm + act: when pebble ready event is triggered + assert: it will have the correct status depending on the relations + """ + harness = helpers.start_harness( + with_postgres=with_postgres, with_redis=with_redis, with_ingress=with_ingress + ) + assert harness.model.unit.status == status + + +def test_ingress_relation_not_ready(): + """ + arrange: given a deployed discourse charm with the ingress established + act: when pebble ready event is triggered + assert: it will wait for the ingress relation. + """ + harness = helpers.start_harness(with_postgres=False, with_redis=False, with_ingress=True) + assert harness.model.unit.status == WaitingStatus("Waiting for database relation") + + +def test_config_changed_when_no_saml_target(): + """ + arrange: given a deployed discourse charm with all the required relations + act: when force_saml_login configuration is True and there's no saml_target_url + assert: it will get to blocked status waiting for the latter. + """ + harness = helpers.start_harness(with_config={"force_saml_login": True, "saml_target_url": ""}) + assert harness.model.unit.status == BlockedStatus( + "force_saml_login can not be true without a saml_target_url" + ) + + +def test_config_changed_when_saml_sync_groups_and_no_url_invalid(): + """ + arrange: given a deployed discourse charm with all the required relations + act: when saml_sync_groups configuration is provided and there's no saml_target_url + assert: it will get to blocked status waiting for the latter. + """ + harness = helpers.start_harness( + with_config={"saml_sync_groups": "group1", "saml_target_url": ""} + ) + assert harness.model.unit.status == BlockedStatus( + "'saml_sync_groups' cannot be specified without a 'saml_target_url'" + ) + + +def test_config_changed_when_saml_target_url_and_force_https_disabled(): + """ + arrange: given a deployed discourse charm with all the required relations + act: when saml_target_url configuration is provided and force_https is False + assert: it will get to blocked status waiting for the latter. + """ + harness = helpers.start_harness( + with_config={"saml_target_url": "group1", "force_https": False} + ) + assert harness.model.unit.status == BlockedStatus( + "'saml_target_url' cannot be specified without 'force_https' being true" + ) + + +def test_config_changed_when_no_cors(): + """ + arrange: given a deployed discourse charm with all the required relations + act: when cors_origin configuration is empty + assert: it will get to blocked status waiting for it. + """ + harness = helpers.start_harness(with_config={"cors_origin": ""}) + assert ( + harness.charm._database.get_relation_data() is not None + ), "database name should be set after relation joined" + assert ( + harness.charm._database.get_relation_data().get("POSTGRES_DB") == "discourse" + ), "database name should be set after relation joined" + + +def test_config_changed_when_throttle_mode_invalid(): + """ + arrange: given a deployed discourse charm with all the required relations + act: when throttle_level configuration is invalid + assert: it will get to blocked status waiting for a valid value to be provided. + """ + harness = helpers.start_harness(with_config={"throttle_level": "Scream"}) + assert isinstance(harness.model.unit.status, BlockedStatus) + assert "none permissive strict" in harness.model.unit.status.message + + +def test_config_changed_when_s3_and_no_bucket_invalid(): + """ + arrange: given a deployed discourse charm with all the required relations + act: when s3_enabled configuration is True and there's no s3_bucket + assert: it will get to blocked status waiting for the latter. + """ + harness = helpers.start_harness( + with_config={ + "s3_access_key_id": "3|33+", + "s3_enabled": True, + "s3_endpoint": "s3.endpoint", + "s3_region": "the-infinite-and-beyond", + "s3_secret_access_key": "s|kI0ure_k3Y", } - charm._on_add_admin_user_action(event) - - mock_exec.assert_any_call( - [ - f"{DISCOURSE_PATH}/bin/bundle", - "exec", - "rake", - "admin:create", - ], - working_dir=DISCOURSE_PATH, - user="_daemon_", - environment=charm._create_discourse_environment_settings(), - stdin=f"{email}\n{password}\n{password}\nY\n", - timeout=60, - ) - - @patch.object(Container, "exec") - def test_anonymize_user(self, mock_exec): - """ - arrange: set up discourse - act: execute the _on_anonymize_user_action method - assert: the underlying rake command to anonymize the user is executed - with the appropriate parameters. - """ - charm: DiscourseCharm = typing.cast(DiscourseCharm, self.harness.charm) - username = "someusername" - event = MagicMock(spec=ActionEvent) - event.params = {"username": username} - charm._on_anonymize_user_action(event) - - mock_exec.assert_any_call( - [ - "bash", - "-c", - f"./bin/bundle exec rake users:anonymize[{username}]", - ], - working_dir=DISCOURSE_PATH, - user="_daemon_", - environment=charm._create_discourse_environment_settings(), - ) - - def test_install_when_leader(self): - """ - arrange: given a deployed discourse charm with all the required relations - act: trigger the install event on a leader unit - assert: migrations are executed and assets are precompiled. - """ - self.harness.set_leader(True) - with self._patch_exec() as mock_exec: - self.harness.container_pebble_ready("discourse") - self.harness.charm.on.install.emit() - self.harness.framework.reemit() - - updated_plan = self.harness.get_container_pebble_plan("discourse").to_dict() - updated_plan_env = updated_plan["services"]["discourse"]["environment"] - mock_exec.assert_any_call( - [f"{DISCOURSE_PATH}/bin/bundle", "exec", "rake", "--trace", "db:migrate"], - environment=updated_plan_env, - working_dir=DISCOURSE_PATH, - user="_daemon_", - ) - mock_exec.assert_any_call( - [f"{DISCOURSE_PATH}/bin/bundle", "exec", "rake", "assets:precompile"], - environment=updated_plan_env, - working_dir=DISCOURSE_PATH, - user="_daemon_", - ) - mock_exec.assert_any_call( - [f"{DISCOURSE_PATH}/bin/rails", "runner", "puts Discourse::VERSION::STRING"], - environment=updated_plan_env, - working_dir=DISCOURSE_PATH, - user="_daemon_", - ) - - def test_install_when_not_leader(self): - """ - arrange: given a deployed discourse charm with all the required relations - act: trigger the install event on a leader unit - assert: migrations are executed and assets are precompiled. - """ - self.harness.set_leader(False) - with self._patch_exec() as mock_exec: - self.harness.container_pebble_ready("discourse") - self.harness.charm.on.install.emit() - self.harness.framework.reemit() - - updated_plan = self.harness.get_container_pebble_plan("discourse").to_dict() - updated_plan_env = updated_plan["services"]["discourse"]["environment"] - mock_exec.assert_any_call( - [f"{DISCOURSE_PATH}/bin/bundle", "exec", "rake", "assets:precompile"], - environment=updated_plan_env, - working_dir=DISCOURSE_PATH, - user="_daemon_", - ) - mock_exec.assert_any_call( - [f"{DISCOURSE_PATH}/bin/rails", "runner", "puts Discourse::VERSION::STRING"], - environment=updated_plan_env, - working_dir=DISCOURSE_PATH, - user="_daemon_", - ) - - def _add_postgres_relation(self): - "Add postgresql relation and relation data to the charm." - - relation_data = { - "database": DATABASE_NAME, - "endpoints": "dbhost:5432,dbhost-2:5432", - "password": "somepasswd", # nosec - "username": "someuser", + ) + assert harness.model.unit.status == BlockedStatus("'s3_enabled' requires 's3_bucket'") + + +def test_config_changed_when_valid_no_s3_backup_nor_cdn(): + """ + arrange: given a deployed discourse charm with all the required relations + act: when a valid configuration is provided + assert: the appropriate configuration values are passed to the pod and the unit + reaches Active status. + """ + harness = helpers.start_harness() + + # We catch the exec call that we expect to register it and make sure that the + # args passed to it are correct. + expected_exec_call_was_made = False + + def bundle_handler(args: ops.testing.ExecArgs) -> None: + nonlocal expected_exec_call_was_made + expected_exec_call_was_made = True + if ( + args.environment != harness._charm._create_discourse_environment_settings() + or args.working_dir != DISCOURSE_PATH + or args.user != "_daemon_" + ): + raise ValueError("Exec rake s3:upload_assets wasn't made with the correct args.") + + harness.handle_exec( + "discourse", + [f"{DISCOURSE_PATH}/bin/bundle", "exec", "rake", "s3:upload_assets"], + handler=bundle_handler, + ) + + harness.set_leader(True) + harness.update_config( + { + "s3_access_key_id": "3|33+", + "s3_bucket": "who-s-a-good-bucket?", + "s3_enabled": True, + "s3_endpoint": "s3.endpoint", + "s3_region": "the-infinite-and-beyond", + "s3_secret_access_key": "s|kI0ure_k3Y", } - - # get a relation ID for the test outside of __init__ (note pylint disable) - self.db_relation_id = ( # pylint: disable=attribute-defined-outside-init - self.harness.add_relation("database", "postgresql") - ) - self.harness.add_relation_unit(self.db_relation_id, "postgresql/0") - self.harness.update_relation_data( - self.db_relation_id, - "postgresql", - relation_data, - ) - - def _add_redis_relation(self): - "Add redis relation and relation data to the charm." - redis_relation_id = self.harness.add_relation("redis", "redis") - self.harness.add_relation_unit(redis_relation_id, "redis/0") - self.harness.charm._stored.redis_relation = { - redis_relation_id: {"hostname": "redis-host", "port": 1010} + ) + harness.container_pebble_ready("discourse") + harness.framework.reemit() + + assert harness._charm + assert expected_exec_call_was_made + + updated_plan = harness.get_container_pebble_plan("discourse").to_dict() + updated_plan_env = updated_plan["services"]["discourse"]["environment"] + assert "DISCOURSE_BACKUP_LOCATION" not in updated_plan_env + assert "*" == updated_plan_env["DISCOURSE_CORS_ORIGIN"] + assert "dbhost" == updated_plan_env["DISCOURSE_DB_HOST"] + assert DATABASE_NAME == updated_plan_env["DISCOURSE_DB_NAME"] + assert "somepasswd" == updated_plan_env["DISCOURSE_DB_PASSWORD"] + assert "someuser" == updated_plan_env["DISCOURSE_DB_USERNAME"] + assert updated_plan_env["DISCOURSE_ENABLE_CORS"] + assert "discourse-k8s" == updated_plan_env["DISCOURSE_HOSTNAME"] + assert "redis-host" == updated_plan_env["DISCOURSE_REDIS_HOST"] + assert "1010" == updated_plan_env["DISCOURSE_REDIS_PORT"] + assert updated_plan_env["DISCOURSE_SERVE_STATIC_ASSETS"] + assert "3|33+" == updated_plan_env["DISCOURSE_S3_ACCESS_KEY_ID"] + assert "DISCOURSE_S3_BACKUP_BUCKET" not in updated_plan_env + assert "DISCOURSE_S3_CDN_URL" not in updated_plan_env + assert "who-s-a-good-bucket?" == updated_plan_env["DISCOURSE_S3_BUCKET"] + assert "s3.endpoint" == updated_plan_env["DISCOURSE_S3_ENDPOINT"] + assert "the-infinite-and-beyond" == updated_plan_env["DISCOURSE_S3_REGION"] + assert "s|kI0ure_k3Y" == updated_plan_env["DISCOURSE_S3_SECRET_ACCESS_KEY"] + assert updated_plan_env["DISCOURSE_USE_S3"] + assert isinstance(harness.model.unit.status, ActiveStatus) + + +def test_config_changed_when_valid_no_fingerprint(): + """ + arrange: given a deployed discourse charm with all the required relations + act: when a valid configuration is provided + assert: the appropriate configuration values are passed to the pod and the unit + reaches Active status. + """ + harness = helpers.start_harness( + with_config={ + "force_saml_login": True, + "saml_target_url": "https://login.sample.com/+saml", + "saml_sync_groups": "group1", + "s3_enabled": False, + "force_https": True, } - - def _add_ingress_relation(self): - "Add ingress relation and relation data to the charm." - nginx_route_relation_id = self.harness.add_relation("nginx-route", "ingress") - self.harness.add_relation_unit(nginx_route_relation_id, "ingress/0") - - def test_postgres_relation_data(self): - """ - arrange: given a deployed discourse charm and some relation data - act: add the postgresql relation to the charm - assert: the charm should wait for some correct relation data - """ - test_cases = [ - ( - { - "database": DATABASE_NAME, - "endpoints": "dbhost:5432,dbhost-2:5432", - "password": secrets.token_hex(16), - "username": "someuser", - }, - True, - ), - ( - { - "database": DATABASE_NAME, - "endpoints": "foo", - "password": secrets.token_hex(16), - "username": "someuser", - }, - False, - ), - ( - { - "database": DATABASE_NAME, - "endpoints": "dbhost:5432,dbhost-2:5432", - "password": "", - "username": "someuser", - }, - False, - ), - ] - - for relation_data, should_be_ready in test_cases: - with self.subTest(relation_data=relation_data, should_be_ready=should_be_ready): - self.start_harness(with_postgres=False, with_redis=False) - db_relation_id = self.harness.add_relation("database", "postgresql") - self.harness.add_relation_unit(db_relation_id, "postgresql/0") - self.harness.update_relation_data( - db_relation_id, - "postgresql", - relation_data, - ) - if should_be_ready: - self.assertEqual( - self.harness.model.unit.status, - WaitingStatus("Waiting for redis relation"), - ) - else: - self.assertEqual( - self.harness.model.unit.status, - WaitingStatus("Waiting for database relation"), - ) - - def test_redis_relation_data(self): - """ - arrange: given a deployed discourse charm and some relation data - act: add the redis relation to the charm - assert: the charm should wait for some correct relation data - """ - test_cases = [ - ( - {"hostname": "redis-host", "port": 1010}, - True, - ), - ( - {"hostname": "redis-host", "port": 0}, - False, - ), - ( - {"hostname": "", "port": 1010}, - False, - ), - ( - {"hostname": "redis-host", "port": None}, - False, - ), - ( - {"hostname": None, "port": None}, - False, - ), - ( - {}, - False, - ), - ( - {"port": 6379}, - False, - ), - ( - {"hostname": "redis-port"}, - False, - ), - ] - - for relation_data, should_be_ready in test_cases: - with self.subTest(relation_data=relation_data, should_be_ready=should_be_ready): - self.start_harness(with_postgres=True, with_redis=False) - redis_relation_id = self.harness.add_relation("redis", "redis") - self.harness.add_relation_unit(redis_relation_id, "redis/0") - self.harness.charm._stored.redis_relation = {redis_relation_id: relation_data} - self.assertEqual(should_be_ready, self.harness.charm._are_db_relations_ready()) + ) + + updated_plan = harness.get_container_pebble_plan("discourse").to_dict() + updated_plan_env = updated_plan["services"]["discourse"]["environment"] + assert "*" == updated_plan_env["DISCOURSE_CORS_ORIGIN"] + assert "dbhost" == updated_plan_env["DISCOURSE_DB_HOST"] + assert DATABASE_NAME == updated_plan_env["DISCOURSE_DB_NAME"] + assert "somepasswd" == updated_plan_env["DISCOURSE_DB_PASSWORD"] + assert "someuser" == updated_plan_env["DISCOURSE_DB_USERNAME"] + assert updated_plan_env["DISCOURSE_ENABLE_CORS"] + assert "discourse-k8s" == updated_plan_env["DISCOURSE_HOSTNAME"] + assert "redis-host" == updated_plan_env["DISCOURSE_REDIS_HOST"] + assert "1010" == updated_plan_env["DISCOURSE_REDIS_PORT"] + assert "DISCOURSE_SAML_CERT_FINGERPRINT" not in updated_plan_env + assert "true" == updated_plan_env["DISCOURSE_SAML_FULL_SCREEN_LOGIN"] + assert "https://login.sample.com/+saml" == updated_plan_env["DISCOURSE_SAML_TARGET_URL"] + assert "false" == updated_plan_env["DISCOURSE_SAML_GROUPS_FULLSYNC"] + assert "true" == updated_plan_env["DISCOURSE_SAML_SYNC_GROUPS"] + assert "group1" == updated_plan_env["DISCOURSE_SAML_SYNC_GROUPS_LIST"] + assert updated_plan_env["DISCOURSE_SERVE_STATIC_ASSETS"] + assert "none" == updated_plan_env["DISCOURSE_SMTP_AUTHENTICATION"] + assert "none" == updated_plan_env["DISCOURSE_SMTP_OPENSSL_VERIFY_MODE"] + assert "DISCOURSE_USE_S3" not in updated_plan_env + assert isinstance(harness.model.unit.status, ActiveStatus) + + +def test_config_changed_when_valid(): + """ + arrange: given a deployed discourse charm with all the required relations + act: when a valid configuration is provided + assert: the appropriate configuration values are passed to the pod and the unit + reaches Active status. + """ + harness = helpers.start_harness( + with_config={ + "developer_emails": "user@foo.internal", + "enable_cors": True, + "external_hostname": "discourse.local", + "force_saml_login": True, + "saml_target_url": "https://login.ubuntu.com/+saml", + "saml_sync_groups": "group1", + "smtp_address": "smtp.internal", + "smtp_domain": "foo.internal", + "smtp_password": "OBV10USLYF4K3", + "smtp_username": "apikey", + "s3_access_key_id": "3|33+", + "s3_backup_bucket": "back-bucket", + "s3_bucket": "who-s-a-good-bucket?", + "s3_cdn_url": "s3.cdn", + "s3_enabled": True, + "s3_endpoint": "s3.endpoint", + "s3_region": "the-infinite-and-beyond", + "s3_secret_access_key": "s|kI0ure_k3Y", + "force_https": True, + } + ) + + updated_plan = harness.get_container_pebble_plan("discourse").to_dict() + updated_plan_env = updated_plan["services"]["discourse"]["environment"] + assert "s3" == updated_plan_env["DISCOURSE_BACKUP_LOCATION"] + assert "*" == updated_plan_env["DISCOURSE_CORS_ORIGIN"] + assert "dbhost" == updated_plan_env["DISCOURSE_DB_HOST"] + assert DATABASE_NAME == updated_plan_env["DISCOURSE_DB_NAME"] + assert "somepasswd" == updated_plan_env["DISCOURSE_DB_PASSWORD"] + assert "someuser" == updated_plan_env["DISCOURSE_DB_USERNAME"] + assert "user@foo.internal" == updated_plan_env["DISCOURSE_DEVELOPER_EMAILS"] + assert updated_plan_env["DISCOURSE_ENABLE_CORS"] + assert "discourse.local" == updated_plan_env["DISCOURSE_HOSTNAME"] + assert "redis-host" == updated_plan_env["DISCOURSE_REDIS_HOST"] + assert "1010" == updated_plan_env["DISCOURSE_REDIS_PORT"] + assert updated_plan_env["DISCOURSE_SAML_CERT_FINGERPRINT"] is not None + assert "true" == updated_plan_env["DISCOURSE_SAML_FULL_SCREEN_LOGIN"] + assert "https://login.ubuntu.com/+saml" == updated_plan_env["DISCOURSE_SAML_TARGET_URL"] + assert "false" == updated_plan_env["DISCOURSE_SAML_GROUPS_FULLSYNC"] + assert "true" == updated_plan_env["DISCOURSE_SAML_SYNC_GROUPS"] + assert "group1" == updated_plan_env["DISCOURSE_SAML_SYNC_GROUPS_LIST"] + assert updated_plan_env["DISCOURSE_SERVE_STATIC_ASSETS"] + assert "3|33+" == updated_plan_env["DISCOURSE_S3_ACCESS_KEY_ID"] + assert "back-bucket" == updated_plan_env["DISCOURSE_S3_BACKUP_BUCKET"] + assert "s3.cdn" == updated_plan_env["DISCOURSE_S3_CDN_URL"] + assert "who-s-a-good-bucket?" == updated_plan_env["DISCOURSE_S3_BUCKET"] + assert "s3.endpoint" == updated_plan_env["DISCOURSE_S3_ENDPOINT"] + assert "the-infinite-and-beyond" == updated_plan_env["DISCOURSE_S3_REGION"] + assert "s|kI0ure_k3Y" == updated_plan_env["DISCOURSE_S3_SECRET_ACCESS_KEY"] + assert "smtp.internal" == updated_plan_env["DISCOURSE_SMTP_ADDRESS"] + assert "none" == updated_plan_env["DISCOURSE_SMTP_AUTHENTICATION"] + assert "foo.internal" == updated_plan_env["DISCOURSE_SMTP_DOMAIN"] + assert "none" == updated_plan_env["DISCOURSE_SMTP_OPENSSL_VERIFY_MODE"] + assert "OBV10USLYF4K3" == updated_plan_env["DISCOURSE_SMTP_PASSWORD"] + assert "587" == updated_plan_env["DISCOURSE_SMTP_PORT"] + assert "apikey" == updated_plan_env["DISCOURSE_SMTP_USER_NAME"] + assert updated_plan_env["DISCOURSE_USE_S3"] + assert isinstance(harness.model.unit.status, ActiveStatus) + + +def test_db_relation(): + """ + arrange: given a deployed discourse charm + act: when the database relation is added + assert: the appropriate database name is set. + """ + harness = helpers.start_harness() + harness.set_leader(True) + + db_relation_data = harness.get_relation_data( + # This attribute was defined in the helpers + harness.db_relation_id, # pylint: disable=no-member + "postgresql", + ) + + assert ( + db_relation_data.get("database") == "discourse" + ), "database name should be set after relation joined" + assert ( + harness.charm._database.get_relation_data().get("POSTGRES_DB") == "discourse" + ), "database name should be set after relation joined" + + +def test_add_admin_user(): + """ + arrange: an email and a password + act: when the _on_add_admin_user_action mtehod is executed + assert: the underlying rake command to add the user is executed + with the appropriate parameters. + """ + harness = helpers.start_harness() + + # We catch the exec call that we expect to register it and make sure that the + # args passed to it are correct. + expected_exec_call_was_made = False + + def bundle_handler(args: ops.testing.ExecArgs) -> None: + nonlocal expected_exec_call_was_made + expected_exec_call_was_made = True + if ( + args.environment != harness._charm._create_discourse_environment_settings() + or args.working_dir != DISCOURSE_PATH + or args.user != "_daemon_" + or args.stdin != f"{email}\n{password}\n{password}\nY\n" + or args.timeout != 60 + ): + raise ValueError(f"{args.command} wasn't made with the correct args.") + + harness.handle_exec( + "discourse", + [f"{DISCOURSE_PATH}/bin/bundle", "exec", "rake", "admin:create"], + handler=bundle_handler, + ) + + charm: DiscourseCharm = typing.cast(DiscourseCharm, harness.charm) + + email = "sample@email.com" + password = "somepassword" # nosec + event = MagicMock(spec=ActionEvent) + event.params = { + "email": email, + "password": password, + } + charm._on_add_admin_user_action(event) + + +def test_anonymize_user(): + """ + arrange: set up discourse + act: execute the _on_anonymize_user_action method + assert: the underlying rake command to anonymize the user is executed + with the appropriate parameters. + """ + harness = helpers.start_harness() + username = "someusername" + + # We catch the exec call that we expect to register it and make sure that the + # args passed to it are correct. + expected_exec_call_was_made = False + + def bundle_handler(args: ops.testing.ExecArgs) -> None: + nonlocal expected_exec_call_was_made + expected_exec_call_was_made = True + if ( + args.environment != harness._charm._create_discourse_environment_settings() + or args.working_dir != DISCOURSE_PATH + or args.user != "_daemon_" + ): + raise ValueError(f"{args.command} wasn't made with the correct args.") + + harness.handle_exec( + "discourse", + ["bash", "-c", f"./bin/bundle exec rake users:anonymize[{username}]"], + handler=bundle_handler, + ) + charm: DiscourseCharm = typing.cast(DiscourseCharm, harness.charm) + event = MagicMock(spec=ActionEvent) + event.params = {"username": username} + charm._on_anonymize_user_action(event) + + +def test_install_when_leader(): + """ + arrange: given a deployed discourse charm with all the required relations + act: trigger the install event on a leader unit + assert: migrations are executed and assets are precompiled. + """ + harness = helpers.start_harness() + + # exec calls that we want to monitor + exec_calls = [ + [f"{DISCOURSE_PATH}/bin/bundle", "exec", "rake", "--trace", "db:migrate"], + [f"{DISCOURSE_PATH}/bin/bundle", "exec", "rake", "assets:precompile"], + [f"{DISCOURSE_PATH}/bin/rails", "runner", "puts Discourse::VERSION::STRING"], + ] + + # construct the dict to store if those calls were executed + expected_exec_call_was_made = {" ".join(call): False for call in exec_calls} + + # We catch the exec calls that we expect to register + # it and make sure that the args passed to it are correct. + def exec_handler(args: ops.testing.ExecArgs) -> None: + nonlocal expected_exec_call_was_made + + # set the call as executed + expected_exec_call_was_made[" ".join(args.command)] = True + + if ( + args.environment != harness._charm._create_discourse_environment_settings() + or args.working_dir != DISCOURSE_PATH + or args.user != "_daemon_" + ): + raise ValueError(f"{args.command} wasn't made with the correct args.") + + for call in exec_calls: + harness.handle_exec("discourse", call, handler=exec_handler) + + harness.set_leader(True) + harness.container_pebble_ready("discourse") + harness.charm.on.install.emit() + harness.framework.reemit() + + assert all(expected_exec_call_was_made.values()) + + +def test_install_when_not_leader(): + """ + arrange: given a deployed discourse charm with all the required relations + act: trigger the install event on a leader unit + assert: migrations are executed and assets are precompiled. + """ + harness = helpers.start_harness() + + # exec calls that we want to monitor + exec_calls = [ + [f"{DISCOURSE_PATH}/bin/bundle", "exec", "rake", "assets:precompile"], + [f"{DISCOURSE_PATH}/bin/rails", "runner", "puts Discourse::VERSION::STRING"], + ] + + # construct the dict to store if those calls were executed + expected_exec_call_was_made = {" ".join(call): False for call in exec_calls} + + # We catch the exec calls that we expect to register + # it and make sure that the args passed to it are correct. + def exec_handler(args: ops.testing.ExecArgs) -> None: + nonlocal expected_exec_call_was_made + + # set the call as executed + expected_exec_call_was_made[" ".join(args.command)] = True + + if ( + args.environment != harness._charm._create_discourse_environment_settings() + or args.working_dir != DISCOURSE_PATH + or args.user != "_daemon_" + ): + raise ValueError(f"{args.command} wasn't made with the correct args.") + + for call in exec_calls: + harness.handle_exec("discourse", call, handler=exec_handler) + + harness.set_leader(False) + harness.container_pebble_ready("discourse") + harness.charm.on.install.emit() + harness.framework.reemit() + + +@pytest.mark.parametrize( + "relation_data, should_be_ready", + [ + ( + { + "database": DATABASE_NAME, + "endpoints": "dbhost:5432,dbhost-2:5432", + "password": secrets.token_hex(16), + "username": "someuser", + }, + True, + ), + ( + { + "database": DATABASE_NAME, + "endpoints": "foo", + "password": secrets.token_hex(16), + "username": "someuser", + }, + False, + ), + ( + { + "database": DATABASE_NAME, + "endpoints": "dbhost:5432,dbhost-2:5432", + "password": "", + "username": "someuser", + }, + False, + ), + ], +) +def test_postgres_relation_data(relation_data, should_be_ready): + """ + arrange: given a deployed discourse charm and some relation data + act: add the postgresql relation to the charm + assert: the charm should wait for some correct relation data + """ + harness = helpers.start_harness(with_postgres=False, with_redis=False) + db_relation_id = harness.add_relation("database", "postgresql") + harness.add_relation_unit(db_relation_id, "postgresql/0") + harness.update_relation_data( + db_relation_id, + "postgresql", + relation_data, + ) + if should_be_ready: + assert harness.model.unit.status == WaitingStatus("Waiting for redis relation") + else: + assert harness.model.unit.status == WaitingStatus("Waiting for database relation") + + +@pytest.mark.parametrize( + "relation_data, should_be_ready", + [ + ( + {"hostname": "redis-host", "port": 1010}, + True, + ), + ( + {"hostname": "redis-host", "port": 0}, + False, + ), + ( + {"hostname": "", "port": 1010}, + False, + ), + ( + {"hostname": "redis-host", "port": None}, + False, + ), + ( + {"hostname": None, "port": None}, + False, + ), + ( + {}, + False, + ), + ( + {"port": 6379}, + False, + ), + ( + {"hostname": "redis-port"}, + False, + ), + ], +) +def test_redis_relation_data(relation_data, should_be_ready): + """ + arrange: given a deployed discourse charm and some relation data + act: add the redis relation to the charm + assert: the charm should wait for some correct relation data + """ + harness = helpers.start_harness(with_postgres=True, with_redis=False) + redis_relation_id = harness.add_relation("redis", "redis") + harness.add_relation_unit(redis_relation_id, "redis/0") + harness.charm._stored.redis_relation = {redis_relation_id: relation_data} + assert should_be_ready == harness.charm._are_db_relations_ready() diff --git a/tox.ini b/tox.ini index 03258c48..58b15907 100644 --- a/tox.ini +++ b/tox.ini @@ -46,7 +46,7 @@ deps = flake8-test-docs isort mypy - ops + ops>=2.6.0 ops-lib-pgsql psycopg2-binary pydocstyle>=2.10 @@ -81,6 +81,7 @@ deps = cosl pytest coverage[toml] + ops>=2.6.0 -r{toxinidir}/requirements.txt commands = coverage run --source={[vars]src_path} \