From 63cd1591517611a7d25ad97093d397c8bb7dfb4a Mon Sep 17 00:00:00 2001 From: Brad Stein Date: Wed, 22 Apr 2026 02:53:00 -0300 Subject: [PATCH] test(titan-iac): cover mailu sync scripts --- scripts/tests/test_mailu_sync.py | 175 ++++++++++++++++++ scripts/tests/test_mailu_sync_listener.py | 134 ++++++++++++++ services/mailu/scripts/mailu_sync_listener.py | 2 + testing/quality_contract.json | 8 +- 4 files changed, 318 insertions(+), 1 deletion(-) create mode 100644 scripts/tests/test_mailu_sync_listener.py diff --git a/scripts/tests/test_mailu_sync.py b/scripts/tests/test_mailu_sync.py index 1ab6dda7..4d6207fe 100644 --- a/scripts/tests/test_mailu_sync.py +++ b/scripts/tests/test_mailu_sync.py @@ -138,6 +138,100 @@ def test_kc_get_users_paginates(monkeypatch): assert sync.SESSION.calls == 1 +def test_kc_get_users_fetches_second_page_after_full_batch(monkeypatch): + sync = load_sync_module(monkeypatch) + + class _PagedSession: + def __init__(self): + self.calls = 0 + self.first_params = [] + + def get(self, *_, **kwargs): + self.calls += 1 + self.first_params.append(kwargs["params"]["first"]) + if self.calls == 1: + return _FakeResponse([{"id": f"u{i}"} for i in range(200)]) + return _FakeResponse([{"id": "last"}]) + + sync.SESSION = _PagedSession() + + users = sync.kc_get_users("tok") + + assert len(users) == 201 + assert sync.SESSION.first_params == [0, 200] + + +def test_get_kc_token_posts_client_credentials(monkeypatch): + sync = load_sync_module(monkeypatch) + calls = [] + + class _TokenSession: + def post(self, url, data, timeout): + calls.append((url, data, timeout)) + return _FakeResponse({"access_token": "tok"}) + + sync.SESSION = _TokenSession() + + assert sync.get_kc_token() == "tok" + assert calls[0][1]["grant_type"] == "client_credentials" + + +def test_retry_request_retries_then_succeeds(monkeypatch): + sync = load_sync_module(monkeypatch) + attempts = [] + sleeps = [] + + def _flaky(): + attempts.append(1) + if len(attempts) == 1: + raise sync.requests.RequestException("temporary") + return "ok" + + monkeypatch.setattr(sync.time, "sleep", lambda seconds: sleeps.append(seconds)) + + assert sync.retry_request("request", _flaky, attempts=2) == "ok" + assert sleeps == [2] + + +def test_retry_request_reraises_final_error(monkeypatch): + sync = load_sync_module(monkeypatch) + monkeypatch.setattr(sync.time, "sleep", lambda seconds: None) + + with pytest.raises(sync.requests.RequestException): + sync.retry_request( + "request", + lambda: (_ for _ in ()).throw(sync.requests.RequestException("nope")), + attempts=1, + ) + + +def test_retry_db_connect_retries_then_succeeds(monkeypatch): + sync = load_sync_module(monkeypatch) + attempts = [] + sleeps = [] + + def _connect(**kwargs): + attempts.append(kwargs) + if len(attempts) == 1: + raise sync.psycopg2.Error("not yet") + return "conn" + + monkeypatch.setattr(sync.psycopg2, "connect", _connect) + monkeypatch.setattr(sync.time, "sleep", lambda seconds: sleeps.append(seconds)) + + assert sync.retry_db_connect(attempts=2) == "conn" + assert sleeps == [2] + + +def test_retry_db_connect_reraises_final_error(monkeypatch): + sync = load_sync_module(monkeypatch) + monkeypatch.setattr(sync.psycopg2, "connect", lambda **kwargs: (_ for _ in ()).throw(sync.psycopg2.Error("down"))) + monkeypatch.setattr(sync.time, "sleep", lambda seconds: None) + + with pytest.raises(sync.psycopg2.Error): + sync.retry_db_connect(attempts=1) + + def test_ensure_mailu_user_skips_foreign_domain(monkeypatch): sync = load_sync_module(monkeypatch) executed = [] @@ -166,6 +260,87 @@ def test_ensure_mailu_user_upserts(monkeypatch): assert captured["password"] != "pw" +def test_attribute_and_email_helpers(monkeypatch): + sync = load_sync_module(monkeypatch) + + assert sync.get_attribute_value({"x": ["first", "second"]}, "x") == "first" + assert sync.get_attribute_value({"x": []}, "x") is None + assert sync.get_attribute_value({"x": "value"}, "x") == "value" + assert sync.mailu_enabled({"mailu_email": ["legacy@example.com"]}) is True + assert sync.mailu_enabled({"mailu_enabled": ["off"]}) is False + assert sync.resolve_mailu_email({"username": "fallback", "email": "user@example.com"}, {}) == "user@example.com" + assert sync.resolve_mailu_email({"username": "fallback", "email": "user@other.com"}, {}) == "fallback@example.com" + + +def test_safe_update_payload_filters_fields(monkeypatch): + sync = load_sync_module(monkeypatch) + + payload = sync._safe_update_payload( + { + "username": "user", + "enabled": True, + "email": "user@example.com", + "emailVerified": False, + "firstName": "User", + "lastName": "Example", + "requiredActions": ["UPDATE_PASSWORD", 7], + "attributes": "not-a-dict", + "ignored": "value", + } + ) + + assert payload == { + "username": "user", + "enabled": True, + "email": "user@example.com", + "emailVerified": False, + "firstName": "User", + "lastName": "Example", + "requiredActions": ["UPDATE_PASSWORD"], + "attributes": {}, + } + + +def test_ensure_system_mailboxes_handles_configurations(monkeypatch, capsys): + sync = load_sync_module(monkeypatch) + ensured = [] + monkeypatch.setattr(sync, "MAILU_SYSTEM_USERS", ["postmaster@example.com", "abuse"]) + monkeypatch.setattr(sync, "MAILU_SYSTEM_PASSWORD", "") + + sync.ensure_system_mailboxes(object()) + + assert "MAILU_SYSTEM_PASSWORD is missing" in capsys.readouterr().out + + def _ensure(cursor, email, password, display_name): + ensured.append((email, password, display_name)) + if email == "abuse": + raise RuntimeError("boom") + + monkeypatch.setattr(sync, "MAILU_SYSTEM_PASSWORD", "pw") + monkeypatch.setattr(sync, "ensure_mailu_user", _ensure) + + sync.ensure_system_mailboxes(object()) + + out = capsys.readouterr().out + assert ensured == [ + ("postmaster@example.com", "pw", "postmaster"), + ("abuse", "pw", "abuse"), + ] + assert "Ensured system mailbox for postmaster@example.com" in out + assert "Failed to ensure system mailbox abuse" in out + + +def test_main_exits_without_users_or_system_mailboxes(monkeypatch, capsys): + sync = load_sync_module(monkeypatch) + monkeypatch.setattr(sync, "MAILU_SYSTEM_USERS", []) + monkeypatch.setattr(sync, "get_kc_token", lambda: "tok") + monkeypatch.setattr(sync, "kc_get_users", lambda token: []) + + sync.main() + + assert "No users found; exiting." in capsys.readouterr().out + + def test_main_generates_password_and_upserts(monkeypatch): sync = load_sync_module(monkeypatch) monkeypatch.setattr(sync.bcrypt_sha256, "hash", lambda password: f"hash:{password}") diff --git a/scripts/tests/test_mailu_sync_listener.py b/scripts/tests/test_mailu_sync_listener.py new file mode 100644 index 00000000..eff7ff34 --- /dev/null +++ b/scripts/tests/test_mailu_sync_listener.py @@ -0,0 +1,134 @@ +import importlib.util +import io +import pathlib +import types + + +def load_listener_module(monkeypatch): + monkeypatch.setenv("MAILU_SYNC_WAIT_TIMEOUT_SEC", "0") + module_path = ( + pathlib.Path(__file__).resolve().parents[2] + / "services" + / "mailu" + / "scripts" + / "mailu_sync_listener.py" + ) + spec = importlib.util.spec_from_file_location("mailu_sync_listener_testmod", module_path) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module + + +def _handler_for(listener, body): + handler = listener.Handler.__new__(listener.Handler) + raw = body if isinstance(body, bytes) else body.encode() + handler.headers = {"Content-Length": str(len(raw))} + handler.rfile = io.BytesIO(raw) + handler.responses = [] + handler.headers_ended = 0 + handler.send_response = lambda code: handler.responses.append(code) + handler.end_headers = lambda: setattr(handler, "headers_ended", handler.headers_ended + 1) + return handler + + +def test_listener_run_sync_blocking_updates_state(monkeypatch): + listener = load_listener_module(monkeypatch) + monkeypatch.setattr(listener, "time", lambda: 42.0) + monkeypatch.setattr( + listener.subprocess, + "run", + lambda command, check: types.SimpleNamespace(returncode=3), + ) + + assert listener._run_sync_blocking() == 3 + assert listener.last_rc == 3 + assert listener.last_run == 42.0 + assert listener.sync_done.is_set() + + listener.sync_running = True + assert listener._run_sync_blocking() == 0 + + +def test_listener_trigger_sync_async_honors_running_and_debounce(monkeypatch): + listener = load_listener_module(monkeypatch) + starts = [] + + class _Thread: + def __init__(self, target, daemon): + self.target = target + self.daemon = daemon + + def start(self): + starts.append((self.target, self.daemon)) + + monkeypatch.setattr(listener.threading, "Thread", _Thread) + monkeypatch.setattr(listener, "time", lambda: 100.0) + + listener.sync_running = True + assert listener._trigger_sync_async() is False + + listener.sync_running = False + listener.last_run = 95.0 + assert listener._trigger_sync_async() is False + + assert listener._trigger_sync_async(force=True) is True + assert starts and starts[0][1] is True + + +def test_listener_post_rejects_invalid_json(monkeypatch): + listener = load_listener_module(monkeypatch) + handler = _handler_for(listener, b"{not-json") + + handler.do_POST() + + assert handler.responses == [400] + assert handler.headers_ended == 1 + + +def test_listener_post_triggers_async_without_wait(monkeypatch): + listener = load_listener_module(monkeypatch) + called = [] + monkeypatch.setattr(listener, "_trigger_sync_async", lambda force=False: called.append(force) or True) + handler = _handler_for(listener, '{"force": true}') + + handler.do_POST() + + assert called == [True] + assert handler.responses == [202] + + +def test_listener_post_wait_returns_success_or_failure(monkeypatch): + listener = load_listener_module(monkeypatch) + called = [] + monkeypatch.setattr(listener, "_trigger_sync_async", lambda force=False: called.append(force) or True) + listener.sync_running = False + listener.last_rc = 0 + handler = _handler_for(listener, '{"wait": true, "force": true}') + + handler.do_POST() + + assert called == [True] + assert handler.responses == [200] + + listener.last_rc = 2 + handler = _handler_for(listener, '{"wait": true}') + handler.do_POST() + assert handler.responses == [500] + + +def test_listener_post_wait_keeps_running_request_successful(monkeypatch): + listener = load_listener_module(monkeypatch) + listener.sync_running = True + handler = _handler_for(listener, '{"wait": true}') + + handler.do_POST() + + assert handler.responses == [200] + + +def test_listener_log_message_is_quiet(monkeypatch): + listener = load_listener_module(monkeypatch) + handler = listener.Handler.__new__(listener.Handler) + + assert handler.log_message("ignored %s", "value") is None diff --git a/services/mailu/scripts/mailu_sync_listener.py b/services/mailu/scripts/mailu_sync_listener.py index 4e31c811..48f3d4fe 100644 --- a/services/mailu/scripts/mailu_sync_listener.py +++ b/services/mailu/scripts/mailu_sync_listener.py @@ -1,3 +1,5 @@ +"""HTTP debounce wrapper for triggering the Mailu Keycloak sync job.""" + import http.server import json import os diff --git a/testing/quality_contract.json b/testing/quality_contract.json index 3f4d3e25..bb0d85dd 100644 --- a/testing/quality_contract.json +++ b/testing/quality_contract.json @@ -17,6 +17,8 @@ "ci/scripts/publish_test_metrics.py", "ci/scripts/publish_test_metrics_quality.py", "ci/scripts/supply_chain_report.py", + "services/mailu/scripts/mailu_sync.py", + "services/mailu/scripts/mailu_sync_listener.py", "testing/__init__.py", "testing/quality_contract.py", "testing/quality_docs.py", @@ -37,6 +39,7 @@ "scripts/tests", "services/comms/scripts/tests", "services/mailu/scripts/mailu_sync.py", + "services/mailu/scripts/mailu_sync_listener.py", "testing/tests", "testing" ], @@ -117,7 +120,8 @@ "ci/tests/**/*.py", "scripts/tests/**/*.py", "services/*/scripts/tests/**/*.py", - "services/mailu/scripts/mailu_sync.py" + "services/mailu/scripts/mailu_sync.py", + "services/mailu/scripts/mailu_sync_listener.py" ], "naming_rules": [ { @@ -163,6 +167,8 @@ "ci/scripts/publish_test_metrics.py", "ci/scripts/publish_test_metrics_quality.py", "ci/scripts/supply_chain_report.py", + "services/mailu/scripts/mailu_sync.py", + "services/mailu/scripts/mailu_sync_listener.py", "testing/quality_contract.py", "testing/quality_docs.py", "testing/quality_hygiene.py",