diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 000000000..e5fa359cc --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,43 @@ +# Copyright 2023 Canonical Ltd. +# See LICENSE file for licensing details. +from importlib.metadata import version +from unittest.mock import PropertyMock + +import pytest +from ops import JujuVersion + + +@pytest.fixture(autouse=True) +def juju_has_secrets(mocker): + """This fixture will force the usage of secrets whenever run on Juju 3.x. + + NOTE: This is needed, as normally JujuVersion is set to 0.0.0 in tests + (i.e. not the real juju version) + """ + if version("juju") < "3": + mocker.patch.object( + JujuVersion, "has_secrets", new_callable=PropertyMock + ).return_value = False + return False + else: + mocker.patch.object( + JujuVersion, "has_secrets", new_callable=PropertyMock + ).return_value = True + return True + + +@pytest.fixture +def only_with_juju_secrets(juju_has_secrets): + """Pretty way to skip Juju 3 tests.""" + if not juju_has_secrets: + pytest.skip("Secrets test only applies on Juju 3.x") + + +@pytest.fixture +def only_without_juju_secrets(juju_has_secrets): + """Pretty way to skip Juju 2-specific tests. + + Typically: to save CI time, when the same check were executed in a Juju 3-specific way already + """ + if juju_has_secrets: + pytest.skip("Skipping legacy secrets tests") diff --git a/tests/integration/helpers.py b/tests/integration/helpers.py index f64b67a91..159c5fde4 100644 --- a/tests/integration/helpers.py +++ b/tests/integration/helpers.py @@ -72,6 +72,21 @@ async def get_password(ops_test: OpsTest, unit_id: int, username="operator") -> return action.results["password"] +async def set_password( + ops_test: OpsTest, unit_id: int, username: str = "operator", password: str = "secret" +) -> str: + """Use the charm action to retrieve the password from provided unit. + + Returns: + String with the password stored on the peer relation databag. + """ + action = await ops_test.model.units.get(f"{APP_NAME}/{unit_id}").run_action( + "set-password", **{"username": username, "password": password} + ) + action = await action.wait() + return action.results + + async def get_mongo_cmd(ops_test: OpsTest, unit_name: str): ls_code, _, _ = await ops_test.juju(f"ssh --container {unit_name} ls /usr/bin/mongosh") diff --git a/tests/integration/test_charm.py b/tests/integration/test_charm.py index 300daa3d2..484f8eb68 100644 --- a/tests/integration/test_charm.py +++ b/tests/integration/test_charm.py @@ -2,13 +2,17 @@ # Copyright 2023 Canonical Ltd. # See LICENSE file for licensing details. +import json import logging import time +from unittest.mock import PropertyMock import pytest from lightkube import AsyncClient from lightkube.resources.core_v1 import Pod +from ops import JujuVersion from pymongo import MongoClient +from pytest_mock import MockerFixture from pytest_operator.plugin import OpsTest from .helpers import ( @@ -25,6 +29,7 @@ primary_host, run_mongo_op, secondary_mongo_uris_with_sync_delay, + set_password, ) logger = logging.getLogger(__name__) @@ -113,6 +118,39 @@ async def test_monitor_user(ops_test: OpsTest) -> None: assert return_code == 0, f"command rs.conf() on monitor user does not work, error: {stderr}" +@pytest.mark.usefixtures("only_with_juju_secrets") +async def test_reset_and_get_password_secret(ops_test: OpsTest, mocker: MockerFixture) -> None: + """Test verifies that we can set and retrieve the correct password using Juju 3.x secrets.""" + mocker.patch.object(JujuVersion, "has_secrets", new_callable=PropertyMock).return_value = True + # Setting existing password + leader_id = await get_leader_id(ops_test) + result = await set_password(ops_test, unit_id=leader_id, username="operator", password="bla") + + # Chopping off initial 'secret:' from the ID + secret_id = result["secret-id"].split(":")[1] + + # Getting back the pw programmatically + password = await get_password(ops_test, unit_id=leader_id, username="operator") + + # Getting back the pw from juju CLI + complete_command = f"show-secret {secret_id} --reveal --format=json" + _, stdout, _ = await ops_test.juju(*complete_command.split()) + data = json.loads(stdout) + + assert data[secret_id]["label"] == "app:operator-password" + assert data[secret_id]["content"]["Data"]["operator-password"] == password + + +@pytest.mark.usefixtures("only_without_juju_secrets") +async def test_reset_and_get_password_no_secret(ops_test: OpsTest, mocker) -> None: + """Test verifies that we can set and retrieve the correct password using Juju 2.x.""" + mocker.patch.object(JujuVersion, "has_secrets", new_callable=PropertyMock).return_value = False + leader_id = await get_leader_id(ops_test) + await set_password(ops_test, unit_id=leader_id, username="operator", password="bla") + password = await get_password(ops_test, unit_id=leader_id, username="operator") + assert password == "bla" + + async def test_scale_up(ops_test: OpsTest): """Tests juju add-unit functionality. diff --git a/tests/unit/test_charm.py b/tests/unit/test_charm.py index 84789e51b..3c60c0e6f 100644 --- a/tests/unit/test_charm.py +++ b/tests/unit/test_charm.py @@ -3,7 +3,7 @@ import logging import unittest from unittest import mock -from unittest.mock import patch +from unittest.mock import MagicMock, patch from charms.mongodb.v0.helpers import CONF_DIR, DATA_DIR, KEY_FILE from ops.model import ModelError @@ -33,6 +33,7 @@ class TestCharm(unittest.TestCase): @patch_network_get(private_address="1.1.1.1") def setUp(self): + self.maxDiff = None self.harness = Harness(MongoDBCharm) mongo_resource = { "registrypath": "mongo:4.4", @@ -44,6 +45,11 @@ def setUp(self): self.charm = self.harness.charm self.addCleanup(self.harness.cleanup) + def _setup_secrets(self): + self.harness.set_leader(True) + self.harness.charm._generate_secrets() + self.harness.set_leader(False) + @patch("charm.MongoDBCharm._pull_licenses") @patch("ops.framework.EventBase.defer") @patch("charm.MongoDBCharm._set_data_dir_permissions") @@ -618,6 +624,28 @@ def test_start_init_operator_user_after_second_call(self, connection, oversee_us defer.assert_not_called() + def test_get_password(self): + self._setup_secrets() + + assert isinstance(self.harness.charm.get_secret("app", "monitor-password"), str) + assert self.harness.charm.get_secret("app", "non-existing-secret") is None + + def test_set_password(self): + """NOTE: currently ops.testing seems to allow for non-leader to set secrets too!""" + self._setup_secrets() + + # Getting current password + self.harness.charm.set_secret("app", "monitor-password", "bla") + assert self.harness.charm.get_secret("app", "monitor-password") == "bla" + + def test_delete_password(self): + """NOTE: currently ops.testing seems to allow for non-leader to remove secrets too!""" + self._setup_secrets() + + assert self.harness.charm.get_secret("app", "monitor-password") + self.harness.charm.remove_secret("app", "monitor-password") + assert self.harness.charm.get_secret("app", "monitor-password") is None + @patch("charm.MongoDBConnection") @patch("charm.MongoDBCharm._connect_mongodb_exporter") def test_connect_to_mongo_exporter_on_set_password(self, connect_exporter, connection): @@ -632,6 +660,89 @@ def test_connect_to_mongo_exporter_on_set_password(self, connect_exporter, conne self.harness.charm._on_set_password(action_event) connect_exporter.assert_called() + @patch("charm.MongoDBConnection") + @patch("charm.MongoDBCharm._connect_mongodb_exporter") + def test_event_set_password_secrets(self, connect_exporter, connection): + """Test _connect_mongodb_exporter is called when the password is set for 'montior' user. + + Furthermore: in Juju 3.x we want to use secrets + """ + pw = "bla" + + self.harness.set_leader(True) + + action_event = mock.Mock() + action_event.set_results = MagicMock() + action_event.params = {"username": "monitor", "password": pw} + self.harness.charm._on_set_password(action_event) + connect_exporter.assert_called() + + action_event.set_results.assert_called() + args_pw_set = action_event.set_results.call_args.args[0] + assert "secret-id" in args_pw_set + + action_event.params = {"username": "monitor"} + self.harness.charm._on_get_password(action_event) + args_pw = action_event.set_results.call_args.args[0] + assert "password" in args_pw + assert args_pw["password"] == pw + + @patch("charm.MongoDBConnection") + @patch("charm.MongoDBCharm._connect_mongodb_exporter") + def test_event_auto_reset_password_secrets_when_no_pw_value_shipped( + self, connect_exporter, connection + ): + """Test _connect_mongodb_exporter is called when the password is set for 'montior' user. + + Furthermore: in Juju 3.x we want to use secrets + """ + self._setup_secrets() + self.harness.set_leader(True) + + action_event = mock.Mock() + action_event.set_results = MagicMock() + + # Getting current password + action_event.params = {"username": "monitor"} + self.harness.charm._on_get_password(action_event) + args_pw = action_event.set_results.call_args.args[0] + assert "password" in args_pw + pw1 = args_pw["password"] + + # No password value was shipped + action_event.params = {"username": "monitor"} + self.harness.charm._on_set_password(action_event) + connect_exporter.assert_called() + + # New password was generated + action_event.params = {"username": "monitor"} + self.harness.charm._on_get_password(action_event) + args_pw = action_event.set_results.call_args.args[0] + assert "password" in args_pw + pw2 = args_pw["password"] + + # a new password was created + assert pw1 != pw2 + + @patch("charm.MongoDBConnection") + @patch("charm.MongoDBCharm._connect_mongodb_exporter") + def test_event_any_unit_can_get_password_secrets(self, connect_exporter, connection): + """Test _connect_mongodb_exporter is called when the password is set for 'montior' user. + + Furthermore: in Juju 3.x we want to use secrets + """ + self._setup_secrets() + + action_event = mock.Mock() + action_event.set_results = MagicMock() + + # Getting current password + action_event.params = {"username": "monitor"} + self.harness.charm._on_get_password(action_event) + args_pw = action_event.set_results.call_args.args[0] + assert "password" in args_pw + assert args_pw["password"] + @patch("charm.MongoDBCharm._pull_licenses") @patch("ops.framework.EventBase.defer") @patch("charm.MongoDBCharm._set_data_dir_permissions") @@ -643,7 +754,7 @@ def test__connect_mongodb_exporter_success( container = self.harness.model.unit.get_container("mongod") self.harness.set_can_connect(container, True) self.harness.charm.on.mongod_pebble_ready.emit(container) - password = self.harness.charm.app_peer_data["monitor-password"] + password = self.harness.charm.get_secret("app", "monitor-password") uri_template = "mongodb://monitor:{password}@mongodb-k8s-0.mongodb-k8s-endpoints/?replicaSet=mongodb-k8s&authSource=admin"