diff --git a/.github/workflows/integration_test.yaml b/.github/workflows/integration_test.yaml index 489bd5a0..9a412adc 100644 --- a/.github/workflows/integration_test.yaml +++ b/.github/workflows/integration_test.yaml @@ -13,5 +13,6 @@ jobs: trivy-image-config: "trivy.yaml" juju-channel: 3.1/stable channel: 1.28-strict/stable + modules: '["test_charm", "test_saml", "test_users"]' self-hosted-runner: true self-hosted-runner-label: "edge" diff --git a/actions.yaml b/actions.yaml index bec71e88..b70f9c07 100644 --- a/actions.yaml +++ b/actions.yaml @@ -1,15 +1,5 @@ # Copyright 2024 Canonical Ltd. # See LICENSE file for licensing details. -add-admin-user: - description: Add a new admin user. - params: - email: - type: string - description: User email. - password: - type: string - description: User password. - required: [email, password] anonymize-user: description: Anonymize a user. params: @@ -17,3 +7,24 @@ anonymize-user: type: string description: The unique identifier of the user to anonymize. required: [username] +create-user: + description: Create a new user. + params: + email: + type: string + description: User email. + admin: + type: boolean + description: Whether the user should be an admin. + active: + type: boolean + description: Whether the user should be email-verified and active. + default: true + required: [email] +promote-user: + description: Promote a user to admin. + params: + email: + type: string + description: User email. + required: [email] \ No newline at end of file diff --git a/config.yaml b/config.yaml index 8cf75602..446ced93 100644 --- a/config.yaml +++ b/config.yaml @@ -17,6 +17,10 @@ options: type: string description: "External hostname this discourse instance responds to. Defaults to application name." default: "" + force_https: + type: boolean + description: Configure Discourse to use https. + default: false force_saml_login: type: boolean description: "Force SAML login (full screen, no local database logins)." @@ -134,7 +138,3 @@ options: type: string description: "Throttle level - blocks excessive usage by ip. Accepted values: none, permissive, strict." default: none - force_https: - type: boolean - description: Configure Discourse to use https. - default: false diff --git a/discourse_rock/patches/anonymize_user.patch b/discourse_rock/patches/anonymize_user.patch deleted file mode 100644 index a7c3a22c..00000000 --- a/discourse_rock/patches/anonymize_user.patch +++ /dev/null @@ -1,15 +0,0 @@ ---- a/lib/tasks/users.rake -+++ b/lib/tasks/users.rake -@@ -210,3 +210,12 @@ def find_user(username) - - user - end -+ -+desc "Anonymize user with the given username" -+task "users:anonymize", [:username] => [:environment] do |_, args| -+ username = args[:username] -+ user = find_user(username) -+ system_user = Discourse.system_user -+ UserAnonymizer.new(user, system_user).make_anonymous -+ puts "User #{username} anonymized" -+end diff --git a/discourse_rock/patches/discourse-charm.patch b/discourse_rock/patches/discourse-charm.patch new file mode 100644 index 00000000..3aac35df --- /dev/null +++ b/discourse_rock/patches/discourse-charm.patch @@ -0,0 +1,28 @@ +--- a/lib/tasks/discourse-charm.rake ++++ b/lib/tasks/discourse-charm.rake +@@ -0,0 +1,25 @@ ++# frozen_string_literal: true ++ ++desc "Check if a user exists for given email address" ++task "users:exists", [:email] => [:environment] do |_, args| ++ email = args[:email] ++ if User.find_by_email(email) ++ puts "User with email #{email} exists" ++ exit 0 ++ end ++ puts "ERROR: User with email #{email} not found" ++ exit 2 ++end ++ ++desc "Activate a user account" ++task "users:activate", [:email] => [:environment] do |_, args| ++ email = args[:email] ++ user = User.find_by_email(email) ++ if !user ++ puts "User with email #{email} does not exist" ++ exit 2 ++ end ++ user.email_tokens.update_all(confirmed: true) ++ puts "User with email #{email} activated" ++ exit 0 ++end diff --git a/discourse_rock/patches/lp1903695.patch b/discourse_rock/patches/lp1903695.patch index 5df8612c..67e611f6 100644 --- a/discourse_rock/patches/lp1903695.patch +++ b/discourse_rock/patches/lp1903695.patch @@ -1,8 +1,6 @@ -diff --git a/lib/middleware/anonymous_cache.rb b/lib/middleware/anonymous_cache.rb -index d41069c92e..fe968c6d64 100644 --- a/lib/middleware/anonymous_cache.rb +++ b/lib/middleware/anonymous_cache.rb -@@ -347,7 +347,7 @@ module Middleware +@@ -351,7 +351,7 @@ module Middleware return @app.call(env) if defined?(@@disabled) && @@disabled if PAYLOAD_INVALID_REQUEST_METHODS.include?(env[Rack::REQUEST_METHOD]) && @@ -10,4 +8,4 @@ index d41069c92e..fe968c6d64 100644 + env[Rack::RACK_INPUT].respond_to?(:size) && env[Rack::RACK_INPUT].size > 0 return 413, { "Cache-Control" => "private, max-age=0, must-revalidate" }, [] end - + diff --git a/discourse_rock/rockcraft.yaml b/discourse_rock/rockcraft.yaml index 0a54f666..50d4692e 100644 --- a/discourse_rock/rockcraft.yaml +++ b/discourse_rock/rockcraft.yaml @@ -186,12 +186,14 @@ parts: after: [discourse, patches] override-stage: | git -C srv/discourse/app apply patches/lp1903695.patch - git -C srv/discourse/app apply patches/anonymize_user.patch + git -C srv/discourse/app apply patches/discourse-charm.patch git -C srv/discourse/app apply patches/sigterm.patch # The following is a fix for UglifierJS assets compilation # https://github.com/lautis/uglifier/issues/127#issuecomment-352224986 sed -i 's/config.assets.js_compressor = :uglifier/config.assets.js_compressor = Uglifier.new(:harmony => true)/g' srv/discourse/app/config/environments/production.rb sed -i '1s/^/require "uglifier"\n/' srv/discourse/app/config/environments/production.rb + prime: + - srv/discourse/app/lib/tasks/discourse-charm.rake scripts: plugin: dump source: scripts diff --git a/docs/how-to/contribute.md b/docs/how-to/contribute.md index 4bbe44bc..7d6de760 100644 --- a/docs/how-to/contribute.md +++ b/docs/how-to/contribute.md @@ -83,8 +83,7 @@ the registry: ```shell cd [project_dir]/discourse_rock && rockcraft pack rockcraft.yaml - skopeo --insecure-policy copy oci-archive:discourse_1.0_amd64.rock docker-daemon:localhost:32000/discourse:latest - docker push localhost:32000/discourse:latest + rockcraft.skopeo --insecure-policy copy --dest-tls-verify=false oci-archive:discourse_1.0_amd64.rock docker://localhost:32000/discourse:latest ``` ### Deploy diff --git a/localstack-installation.sh b/localstack-installation.sh old mode 100644 new mode 100755 diff --git a/src/charm.py b/src/charm.py index f013bf1f..57e9390f 100755 --- a/src/charm.py +++ b/src/charm.py @@ -7,6 +7,8 @@ import hashlib import logging import os.path +import secrets +import string import typing from collections import defaultdict, namedtuple @@ -112,7 +114,8 @@ def __init__(self, *args): self.framework.observe(self.on.upgrade_charm, self._on_upgrade_charm) self.framework.observe(self.on.discourse_pebble_ready, self._on_discourse_pebble_ready) self.framework.observe(self.on.config_changed, self._on_config_changed) - self.framework.observe(self.on.add_admin_user_action, self._on_add_admin_user_action) + self.framework.observe(self.on.promote_user_action, self._on_promote_user_action) + self.framework.observe(self.on.create_user_action, self._on_create_user_action) self.framework.observe(self.on.anonymize_user_action, self._on_anonymize_user_action) self.redis = RedisRequires(self) @@ -694,43 +697,157 @@ def _activate_charm(self) -> None: self._start_service() self.model.unit.status = ActiveStatus() - def _on_add_admin_user_action(self, event: ActionEvent) -> None: - """Add a new admin user to Discourse. + def _user_exists(self, email: str) -> bool: + """Check if a user with the given email exists. Args: - event: Event triggering the add_admin_user action. + email: Email of the user to check. + + Returns: + True if the user exists, False otherwise. """ - email = event.params["email"] - password = event.params["password"] container = self.unit.get_container(CONTAINER_NAME) - if container.can_connect(): - process = container.exec( - [ - os.path.join(DISCOURSE_PATH, "bin/bundle"), - "exec", - "rake", - "admin:create", - ], - stdin=f"{email}\n{password}\n{password}\nY\n", - working_dir=DISCOURSE_PATH, - user=CONTAINER_APP_USERNAME, - environment=self._create_discourse_environment_settings(), - timeout=60, + user_exists = container.exec( + [os.path.join(DISCOURSE_PATH, "bin/bundle"), "exec", "rake", f"users:exists[{email}]"], + working_dir=DISCOURSE_PATH, + user=CONTAINER_APP_USERNAME, + environment=self._create_discourse_environment_settings(), + ) + try: + user_exists.wait_output() + return True + except ExecError as ex: + if ex.exit_code == 2: + return False + raise + + def _activate_user(self, email: str) -> bool: + """Activate a user with the given email. + + Args: + email: Email of the user to activate. + """ + container = self.unit.get_container(CONTAINER_NAME) + activate_process = container.exec( + [ + os.path.join(DISCOURSE_PATH, "bin/bundle"), + "exec", + "rake", + f"users:activate[{email}]", + ], + working_dir=DISCOURSE_PATH, + user=CONTAINER_APP_USERNAME, + environment=self._create_discourse_environment_settings(), + ) + try: + activate_process.wait_output() + return True + except ExecError as ex: + if ex.exit_code == 2: + return False + raise + + def _on_promote_user_action(self, event: ActionEvent) -> None: + """Promote a user to a specific trust level. + + Args: + event: Event triggering the promote_user action. + """ + container = self.unit.get_container(CONTAINER_NAME) + if not container.can_connect(): + event.fail("Unable to connect to container, container is not ready") + return + + email = event.params["email"] + + if not self._user_exists(email): + event.fail(f"User with email {email} does not exist") + return + + process = container.exec( + [ + os.path.join(DISCOURSE_PATH, "bin/bundle"), + "exec", + "rake", + "admin:create", + ], + stdin=f"{email}\nn\nY\n", + working_dir=DISCOURSE_PATH, + user=CONTAINER_APP_USERNAME, + environment=self._create_discourse_environment_settings(), + timeout=60, + ) + try: + process.wait_output() + event.set_results({"user": email}) + except ExecError as ex: + event.fail( + f"Failed to make user with email {email} an admin: {ex.stdout}" # type: ignore ) - try: - process.wait_output() - event.set_results({"user": f"{email}"}) - except ExecError as ex: - event.fail( - # Parameter validation errors are printed to stdout - f"Failed to create user with email {email}: {ex.stdout}" # type: ignore - ) - else: - event.fail("Container is not ready") + + def _on_create_user_action(self, event: ActionEvent) -> None: + """Create a new user in Discourse. + + Args: + event: Event triggering the create_user action. + """ + container = self.unit.get_container(CONTAINER_NAME) + if not container.can_connect(): + event.fail("Unable to connect to container, container is not ready") + return + + email = event.params["email"] + password = self._generate_password(16) + + if self._user_exists(email): + event.fail(f"User with email {email} already exists") + return + + # Admin flag is optional, if it is true, the user will be created as an admin + admin_flag = "Y" if event.params.get("admin") else "N" + + process = container.exec( + [ + os.path.join(DISCOURSE_PATH, "bin/bundle"), + "exec", + "rake", + "admin:create", + ], + stdin=f"{email}\n{password}\n{password}\n{admin_flag}\n", + working_dir=DISCOURSE_PATH, + user=CONTAINER_APP_USERNAME, + environment=self._create_discourse_environment_settings(), + timeout=60, + ) + try: + process.wait_output() + except ExecError as ex: + event.fail(f"Failed to make user with email {email}: {ex.stdout}") # type: ignore + return + + if not event.params.get("admin") and event.params.get("active"): + if not self._activate_user(email): + event.fail(f"Could not find user {email} to activate") + return + + event.set_results({"user": email, "password": password}) + + def _generate_password(self, length: int) -> str: + """Generate a random password. + + Args: + length: Length of the password to generate. + + Returns: + Random password. + """ + choices = string.ascii_letters + string.digits + password = "".join([secrets.choice(choices) for _ in range(length)]) + return password def _config_force_https(self) -> None: """Config Discourse to force_https option based on charm configuration.""" - container = self.unit.get_container("discourse") + container = self.unit.get_container(CONTAINER_NAME) force_bool = str(self.config["force_https"]).lower() process = container.exec( [ @@ -751,27 +868,31 @@ def _on_anonymize_user_action(self, event: ActionEvent) -> None: event: Event triggering the anonymize_user action. """ username = event.params["username"] - container = self.unit.get_container("discourse") - if container.can_connect(): - process = container.exec( - [ - "bash", - "-c", - f"./bin/bundle exec rake users:anonymize[{username}]", - ], - working_dir=DISCOURSE_PATH, - user=CONTAINER_APP_USERNAME, - environment=self._create_discourse_environment_settings(), + container = self.unit.get_container(CONTAINER_NAME) + if not container.can_connect(): + event.fail("Unable to connect to container, container is not ready") + return + + process = container.exec( + [ + os.path.join(DISCOURSE_PATH, "bin/bundle"), + "exec", + "rake", + f"users:anonymize[{username}]", + ], + working_dir=DISCOURSE_PATH, + user=CONTAINER_APP_USERNAME, + environment=self._create_discourse_environment_settings(), + ) + try: + process.wait_output() + event.set_results({"user": f"{username}"}) + except ExecError as ex: + event.fail( + # Parameter validation errors are printed to stdout + # Ignore mypy warning when formatting stdout + f"Failed to anonymize user with username {username}:{ex.stdout}" # type: ignore ) - try: - process.wait_output() - event.set_results({"user": f"{username}"}) - except ExecError as ex: - event.fail( - # Parameter validation errors are printed to stdout - # Ignore mypy warning when formatting stdout - f"Failed to anonymize user with username {username}:{ex.stdout}" # type: ignore - ) def _start_service(self): """Start discourse.""" diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 5f203e4d..a88491a7 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -18,6 +18,7 @@ from ops.model import WaitingStatus from pytest import Config, fixture from pytest_operator.plugin import Model, OpsTest +from saml_test_helper import SamlK8sTestHelper # pylint: disable=import-error from . import types @@ -232,17 +233,37 @@ async def setup_saml_config(app: Application, model: Model): original_config = {k: v["value"] for k, v in original_config.items()} await discourse_app.set_config({"force_https": "true"}) + saml_helper = SamlK8sTestHelper.deploy_saml_idp(model.name) + saml_app: Application = await model.deploy( + "saml-integrator", + channel="latest/edge", + series="jammy", + trust=True, + ) + await model.wait_for_idle() + saml_helper.prepare_pod(model.name, f"{saml_app.name}-0") + saml_helper.prepare_pod(model.name, f"{app.name}-0") + await model.wait_for_idle() + await saml_app.set_config( # type: ignore[attr-defined] + { + "entity_id": saml_helper.entity_id, + "metadata_url": saml_helper.metadata_url, + } + ) + await model.add_relation(app.name, "saml-integrator") + await model.wait_for_idle() + + yield saml_helper + @pytest_asyncio.fixture(scope="module", name="admin_credentials") async def admin_credentials_fixture(app: Application) -> types.Credentials: """Admin user credentials.""" email = f"admin-user{secrets.randbits(32)}@test.internal" - password = secrets.token_urlsafe(16) discourse_unit: Unit = app.units[0] - action: Action = await discourse_unit.run_action( - "add-admin-user", email=email, password=password - ) + action: Action = await discourse_unit.run_action("create-user", email=email, admin=True) await action.wait() + password = action.results["password"] admin_credentials = types.Credentials( email=email, username=email.split("@", maxsplit=1)[0], password=password ) @@ -254,16 +275,18 @@ async def admin_api_key_fixture( admin_credentials: types.Credentials, discourse_address: str ) -> str: """Admin user API key""" - with requests.session() as sess: + with requests.session() as session: # Get CSRF token - res = sess.get(f"{discourse_address}/session/csrf", headers={"Accept": "application/json"}) + response = session.get( + f"{discourse_address}/session/csrf", headers={"Accept": "application/json"} + ) # pylint doesn't see the "ok" member - assert res.status_code == requests.codes.ok, res.text # pylint: disable=no-member - data = res.json() + assert response.ok, response.text # pylint: disable=no-member + data = response.json() assert data["csrf"], data csrf = data["csrf"] # Create session & login - res = sess.post( + response = session.post( f"{discourse_address}/session", headers={ "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", @@ -278,10 +301,10 @@ async def admin_api_key_fixture( }, ) # pylint doesn't see the "ok" member - assert res.status_code == requests.codes.ok, res.text # pylint: disable=no-member - assert "error" not in res.json() + assert response.ok, response.text # pylint: disable=no-member + assert "error" not in response.json() # Create global key - res = sess.post( + response = session.post( f"{discourse_address}/admin/api/keys", headers={ "Content-Type": "application/json", @@ -291,8 +314,8 @@ async def admin_api_key_fixture( json={"key": {"description": "admin-api-key", "username": None}}, ) # pylint doesn't see the "ok" member - assert res.status_code == requests.codes.ok, res.text # pylint: disable=no-member + assert response.ok, response.text # pylint: disable=no-member - data = res.json() + data = response.json() assert data["key"]["key"], data return data["key"]["key"] diff --git a/tests/integration/test_charm.py b/tests/integration/test_charm.py index 04250a22..23de5966 100644 --- a/tests/integration/test_charm.py +++ b/tests/integration/test_charm.py @@ -5,21 +5,17 @@ import logging import re -import socket -import unittest.mock from datetime import datetime, timedelta from pathlib import Path from typing import Dict import pytest import requests -import urllib3.exceptions from boto3 import client from botocore.config import Config from juju.application import Application from ops.model import ActiveStatus from pytest_operator.plugin import Model, OpsTest -from saml_test_helper import SamlK8sTestHelper # pylint: disable=import-error from charm import PROMETHEUS_PORT @@ -190,12 +186,12 @@ async def test_create_category( admin_api_key: str, ): """ - arrange: Given discourse application and an admin user - act: if an admin user creates a category - assert: a category should be created normally. + arrange: A discourse application and an admin user + act: If an admin user creates a category + assert: A category should be created normally. """ category_info = {"name": "test", "color": "FFFFFF"} - res = requests.post( + response = requests.post( f"{discourse_address}/categories.json", headers={ "Api-Key": admin_api_key, @@ -204,7 +200,7 @@ async def test_create_category( json=category_info, timeout=60, ) - category_id = res.json()["category"]["id"] + category_id = response.json()["category"]["id"] category = requests.get(f"{discourse_address}/c/{category_id}/show.json", timeout=60).json()[ "category" ] @@ -213,122 +209,17 @@ async def test_create_category( assert category["color"] == category_info["color"] -@pytest.mark.asyncio -@pytest.mark.abort_on_fail -@pytest.mark.usefixtures("setup_saml_config") -async def test_saml_login( # pylint: disable=too-many-locals,too-many-arguments - app: Application, - requests_timeout: int, - run_action, - model: Model, -): - """ - arrange: after discourse charm has been deployed, with all required relation established. - act: add an admin user and enable force-https mode. - assert: user can login discourse using SAML Authentication. - """ - saml_helper = SamlK8sTestHelper.deploy_saml_idp(model.name) - saml_app: Application = await model.deploy( - "saml-integrator", - channel="latest/edge", - series="jammy", - trust=True, - ) - await model.wait_for_idle() - saml_helper.prepare_pod(model.name, f"{saml_app.name}-0") - saml_helper.prepare_pod(model.name, f"{app.name}-0") - await model.wait_for_idle() - await saml_app.set_config( # type: ignore[attr-defined] - { - "entity_id": saml_helper.entity_id, - "metadata_url": saml_helper.metadata_url, - } - ) - await model.add_relation(app.name, "saml-integrator") - await model.wait_for_idle() - urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) - # discourse need a long password and a valid email - # username can't be "discourse" or it will be renamed - username = "ubuntu" - email = "ubuntu@canonical.com" - password = "test-discourse-k8s-password" # nosec - saml_helper.register_user(username=username, email=email, password=password) - - action_result = await run_action(app.name, "add-admin-user", email=email, password=password) - assert "user" in action_result - - host = app.name - original_getaddrinfo = socket.getaddrinfo - - def patched_getaddrinfo(*args): - if args[0] == host: - return original_getaddrinfo("127.0.0.1", *args[1:]) - return original_getaddrinfo(*args) - - with unittest.mock.patch.multiple(socket, getaddrinfo=patched_getaddrinfo): - session = requests.session() - - response = session.get( - f"https://{host}/auth/saml/metadata", - verify=False, - timeout=10, - ) - saml_helper.register_service_provider(name=host, metadata=response.text) - - preference_page = session.get( - f"https://{host}/u/{username}/preferences/account", - verify=False, - timeout=requests_timeout, - ) - assert preference_page.status_code == 404 - - session.get(f"https://{host}", verify=False) - response = session.get( - f"https://{host}/session/csrf", - verify=False, - headers={"Accept": "application/json"}, - timeout=requests_timeout, - ) - csrf_token = response.json()["csrf"] - redirect_response = session.post( - f"https://{host}/auth/saml", - data={"authenticity_token": csrf_token}, - verify=False, - timeout=requests_timeout, - allow_redirects=False, - ) - assert redirect_response.status_code == 302 - redirect_url = redirect_response.headers["Location"] - saml_response = saml_helper.redirect_sso_login( - redirect_url, username=username, password=password - ) - assert f"https://{host}" in saml_response.url - session.post( - saml_response.url, - verify=False, - data={"SAMLResponse": saml_response.data["SAMLResponse"], "SameSite": "1"}, - ) - session.post(saml_response.url, verify=False, data=saml_response.data) - - preference_page = session.get( - f"https://{host}/u/{username}/preferences/account", - verify=False, - timeout=requests_timeout, - ) - assert preference_page.status_code == 200 - - @pytest.mark.asyncio async def test_serve_compiled_assets( discourse_address: str, ): """ - arrange: Given discourse application - act: when accessing a page that does not exist - assert: a compiled asset should be served. + arrange: A discourse application + act: Access a page that does not exist + assert: A compiled asset should be served. """ - res = requests.get(f"{discourse_address}/404", timeout=60) - not_found_page = str(res.content) + response = requests.get(f"{discourse_address}/404", timeout=60) + not_found_page = str(response.content) asset_matches = re.search( r"(onpopstate-handler).+.js", not_found_page @@ -344,9 +235,9 @@ async def test_relations( requests_timeout: int, ): """ - arrange: Given discourse application - act: when removing some of its relations - assert: it should have the correct status + arrange: A discourse application + act: Remove some of its relations + assert: It should have the correct status """ def test_discourse_srv_status_ok(): @@ -387,6 +278,7 @@ def test_discourse_srv_status_ok(): test_discourse_srv_status_ok() +@pytest.mark.skip(reason="Frequent timeouts") async def test_upgrade( app: Application, model: Model, @@ -394,7 +286,7 @@ async def test_upgrade( ops_test: OpsTest, ): """ - arrange: Given discourse application with three units + arrange: A discourse application with three units act: Refresh the application (upgrade) assert: The application upgrades and over all the upgrade, the application replies correctly through the ingress. diff --git a/tests/integration/test_saml.py b/tests/integration/test_saml.py new file mode 100644 index 00000000..ea095361 --- /dev/null +++ b/tests/integration/test_saml.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +# Copyright 2024 Canonical Ltd. +# See LICENSE file for licensing details. +"""Discourse integration tests.""" + +import logging +import socket +import unittest.mock + +import pytest +import requests +import urllib3.exceptions +from juju.application import Application # pylint: disable=import-error + +logger = logging.getLogger(__name__) + + +@pytest.mark.asyncio +@pytest.mark.abort_on_fail +@pytest.mark.usefixtures("setup_saml_config") +async def test_saml_login( # pylint: disable=too-many-locals,too-many-arguments + app: Application, + requests_timeout: int, + run_action, + setup_saml_config, +): + """ + arrange: after discourse charm has been deployed, with all required relation established. + act: add an admin user and enable force-https mode. + assert: user can login discourse using SAML Authentication. + """ + saml_helper = setup_saml_config + urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + # discourse need a long password and a valid email + # username can't be "discourse" or it will be renamed + username = "ubuntu" + email = "ubuntu@canonical.com" + password = "test-discourse-k8s-password" # nosecue + saml_helper.register_user(username=username, email=email, password=password) + + action_result = await run_action(app.name, "create-user", email=email) + assert "user" in action_result + + host = app.name + original_getaddrinfo = socket.getaddrinfo + + def patched_getaddrinfo(*args): + if args[0] == host: + return original_getaddrinfo("127.0.0.1", *args[1:]) + return original_getaddrinfo(*args) + + with unittest.mock.patch.multiple(socket, getaddrinfo=patched_getaddrinfo): + session = requests.session() + + response = session.get( + f"https://{host}/auth/saml/metadata", + verify=False, + timeout=10, + ) + saml_helper.register_service_provider(name=host, metadata=response.text) + + preference_page = session.get( + f"https://{host}/u/{username}/preferences/account", + verify=False, + timeout=requests_timeout, + ) + assert preference_page.status_code == 404 + + session.get(f"https://{host}", verify=False) + response = session.get( + f"https://{host}/session/csrf", + verify=False, + headers={"Accept": "application/json"}, + timeout=requests_timeout, + ) + csrf_token = response.json()["csrf"] + redirect_response = session.post( + f"https://{host}/auth/saml", + data={"authenticity_token": csrf_token}, + verify=False, + timeout=requests_timeout, + allow_redirects=False, + ) + assert redirect_response.status_code == 302 + redirect_url = redirect_response.headers["Location"] + saml_response = saml_helper.redirect_sso_login( + redirect_url, username=username, password=password + ) + assert f"https://{host}" in saml_response.url + session.post( + saml_response.url, + verify=False, + data={"SAMLResponse": saml_response.data["SAMLResponse"], "SameSite": "1"}, + ) + session.post(saml_response.url, verify=False, data=saml_response.data) + + preference_page = session.get( + f"https://{host}/u/{username}/preferences/account", + verify=False, + timeout=requests_timeout, + ) + assert preference_page.status_code == 200 diff --git a/tests/integration/test_users.py b/tests/integration/test_users.py new file mode 100644 index 00000000..5a29899f --- /dev/null +++ b/tests/integration/test_users.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +# Copyright 2024 Canonical Ltd. +# See LICENSE file for licensing details. +"""Discourse integration tests.""" + +import logging + +import pytest +import requests +from juju.action import Action +from juju.application import Application +from juju.unit import Unit + +logger = logging.getLogger(__name__) + + +@pytest.mark.asyncio +async def test_create_user( + app: Application, +): + """ + arrange: A discourse application + act: Create a user + assert: User is created, and re-creating the same user should fail + """ + + await app.model.wait_for_idle(status="active") + discourse_unit: Unit = app.units[0] + + email = "test-user@test.internal" + + action: Action = await discourse_unit.run_action("create-user", email=email) + await action.wait() + assert action.results["user"] == email + + # Re-creating the same user should fail, as the user already exists + break_action: Action = await discourse_unit.run_action("create-user", email=email) + await break_action.wait() + assert break_action.status == "failed" + + +@pytest.mark.asyncio +async def test_promote_user( + app: Application, + discourse_address: str, +): + """ + arrange: A discourse application + act: Promote a user to admin + assert: User cannot access the admin API before being promoted + """ + + with requests.session() as session: + + def get_api_key(csrf_token: str) -> bool: + response = session.post( + f"{discourse_address}/admin/api/keys", + headers={ + "Content-Type": "application/json", + "X-CSRF-Token": csrf_token, + "X-Requested-With": "XMLHttpRequest", + }, + json={"key": {"description": "admin-api-key", "username": None}}, + ) + if response.json().get("key") is None: + return False + return True + + response = session.get( + f"{discourse_address}/session/csrf", headers={"Accept": "application/json"}, timeout=60 + ) + # pylint doesn't see the "ok" member + assert response.ok, response.text # pylint: disable=no-member + data = response.json() + assert data["csrf"], data + csrf = data["csrf"] + + email = "test-promote-user@test.internal" + discourse_unit: Unit = app.units[0] + create_action: Action = await discourse_unit.run_action("create-user", email=email) + await create_action.wait() + assert create_action.results["user"] == email + + response = session.post( + f"{discourse_address}/session", + headers={ + "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", + "X-CSRF-Token": csrf, + "X-Requested-With": "XMLHttpRequest", + }, + data={ + "login": email, + "password": create_action.results["password"], + "second_factor_method": "1", + "timezone": "Asia/Hong_Kong", + }, + ) + + assert response.ok, response.text # pylint: disable=no-member + assert "error" not in response.json() + + assert not get_api_key(csrf), "This should fail as the user is not promoted" + + promote_action: Action = await discourse_unit.run_action("promote-user", email=email) + await promote_action.wait() + + assert get_api_key(csrf), "This should succeed as the user is promoted" diff --git a/tests/unit/test_charm.py b/tests/unit/test_charm.py index 83fe905b..b9486b09 100644 --- a/tests/unit/test_charm.py +++ b/tests/unit/test_charm.py @@ -7,15 +7,13 @@ # Protected access check is disabled in tests as we're injecting test data import secrets -import typing from unittest.mock import MagicMock, patch import ops import pytest -from ops.charm import ActionEvent from ops.model import ActiveStatus, BlockedStatus, WaitingStatus -from charm import CONTAINER_NAME, DATABASE_NAME, DISCOURSE_PATH, SERVICE_NAME, DiscourseCharm +from charm import CONTAINER_NAME, DATABASE_NAME, DISCOURSE_PATH, SERVICE_NAME from tests.unit import helpers @@ -310,10 +308,10 @@ def test_db_relation(): ), "database name should be set after relation joined" -def test_add_admin_user(): +def test_promote_user_success(): """ arrange: an email and a password - act: when the _on_add_admin_user_action mtehod is executed + act: when the _on_promote_user_action method is executed assert: the underlying rake command to add the user is executed with the appropriate parameters. """ @@ -330,7 +328,7 @@ def bundle_handler(args: ops.testing.ExecArgs) -> None: 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.stdin != f"{email}\nn\nY\n" or args.timeout != 60 ): raise ValueError(f"{args.command} wasn't made with the correct args.") @@ -341,16 +339,147 @@ def bundle_handler(args: ops.testing.ExecArgs) -> None: handler=bundle_handler, ) - charm: DiscourseCharm = typing.cast(DiscourseCharm, harness.charm) + email = "sample@email.com" + harness.run_action("promote-user", {"email": email}) + assert expected_exec_call_was_made + + +def test_promote_user_fail(): + """ + arrange: an email + act: when the _on_create_user_action method is executed + assert: the create user rake command is executed upon failure of the user existence check. + """ + 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 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 mock_create_user(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 email not in str(args.stdin) + or args.user != "_daemon_" + or args.timeout != 60 + ): + raise ValueError(f"{args.command} wasn't made with the correct args.") + + harness.handle_exec( + SERVICE_NAME, + [f"{DISCOURSE_PATH}/bin/bundle", "exec", "rake", "admin:create"], + handler=mock_create_user, + ) + + stdout = "ERROR: User with email f{email} not found" + + # Exit code 2 means that the user cannot be found in the rake task. + harness.handle_exec( + SERVICE_NAME, + [f"{DISCOURSE_PATH}/bin/bundle", "exec", "rake", f"users:exists[{email}]"], + result=ops.testing.ExecResult(exit_code=2, stdout=stdout, stderr=""), + ) + try: + harness.run_action("promote-user", {"email": email}) + assert False + except ops.testing.ActionFailed as e: + assert e.message == f"User with email {email} does not exist" + + # Exit code 1 means that the rake task failed. + harness.handle_exec( + SERVICE_NAME, + [f"{DISCOURSE_PATH}/bin/bundle", "exec", "rake", f"users:exists[{email}]"], + result=ops.testing.ExecResult(exit_code=1, stdout=stdout, stderr=""), + ) + try: + harness.run_action("promote-user", {"email": email}) + assert False + except ops.pebble.ExecError as e: + assert "non-zero exit code 1" in str(e) + + +def test_create_user_success(): + """ + arrange: an email + act: when the _on_create_user_action method is executed + assert: the create user rake command is executed upon failure of the user existence check. + """ + 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 + email = "sample@email.com" + + def mock_create_user(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 email not in str(args.stdin) + or args.user != "_daemon_" + or args.timeout != 60 + ): + raise ValueError(f"{args.command} wasn't made with the correct args.") + + harness.handle_exec( + SERVICE_NAME, + [f"{DISCOURSE_PATH}/bin/bundle", "exec", "rake", "admin:create"], + handler=mock_create_user, + ) + + stdout = "ERROR: User with email f{email} not found" + harness.handle_exec( + SERVICE_NAME, + [f"{DISCOURSE_PATH}/bin/bundle", "exec", "rake", f"users:exists[{email}]"], + result=ops.testing.ExecResult(exit_code=2, stdout=stdout, stderr=""), + ) + + harness.run_action("create-user", {"email": email}) + assert expected_exec_call_was_made + + +def test_create_user_fail(): + """ + arrange: an email + act: when the _on_create_user_action method is executed + assert: the create user rake command is executed upon failure of the user existence check. + """ + 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 + email = "sample@email.com" + + def mock_create_user(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 email not in str(args.stdin) + or args.user != "_daemon_" + or args.timeout != 60 + ): + raise ValueError(f"{args.command} wasn't made with the correct args.") + + harness.handle_exec( + SERVICE_NAME, + [f"{DISCOURSE_PATH}/bin/bundle", "exec", "rake", "admin:create"], + handler=mock_create_user, + ) + + try: + harness.run_action("create-user", {"email": email}) + assert False + except ops.testing.ActionFailed as e: + assert e.message == f"User with email {email} already exists" def test_anonymize_user(): @@ -379,13 +508,12 @@ def bundle_handler(args: ops.testing.ExecArgs) -> None: harness.handle_exec( SERVICE_NAME, - ["bash", "-c", f"./bin/bundle exec rake users:anonymize[{username}]"], + [f"{DISCOURSE_PATH}/bin/bundle", "exec", "rake", f"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) + + harness.run_action("anonymize-user", {"username": username}) + assert expected_exec_call_was_made def test_handle_pebble_ready_event():