From fd019023dc937026b9eaa86f18c6e0406d7373ec Mon Sep 17 00:00:00 2001 From: Anders Lindh Olsson Date: Tue, 5 May 2026 14:47:21 +0200 Subject: [PATCH 1/5] fix(processors): add missing getint to ConfigAdapter CFConfig.loads() called conf.getint() to parse pushMaxRetries, but ConfigAdapter only implemented get() and getboolean(). This caused an AttributeError on startup in any deployment using the cfstore processor. --- server/recceiver/processors.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/server/recceiver/processors.py b/server/recceiver/processors.py index 352f67df..f984237d 100644 --- a/server/recceiver/processors.py +++ b/server/recceiver/processors.py @@ -50,6 +50,12 @@ def getboolean(self, key, D=None): except (ConfigParser.NoOptionError, ValueError): return D + def getint(self, key, D=None): + try: + return self._C.getint(self._S, key, vars=self.env_vars) + except (ConfigParser.NoOptionError, ValueError): + return D + def __getitem__(self, key): result = self.get(key) if result is None: From db79a89f6bda17c50f834c26bda2beda7327b752 Mon Sep 17 00:00:00 2001 From: Anders Lindh Olsson Date: Tue, 5 May 2026 14:47:27 +0200 Subject: [PATCH 2/5] test(processors): add unit tests for ConfigAdapter and CFConfig.loads The existing suite is entirely Docker integration tests, leaving the configuration parsing path untested. Cover ConfigAdapter.get, getboolean, and getint (including missing-key and invalid-value fallback) and CFConfig.loads defaults and overrides so regressions like the missing getint method are caught without needing Docker. --- server/tests/test_config.py | 136 ++++++++++++++++++++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 server/tests/test_config.py diff --git a/server/tests/test_config.py b/server/tests/test_config.py new file mode 100644 index 00000000..b79b0937 --- /dev/null +++ b/server/tests/test_config.py @@ -0,0 +1,136 @@ +import textwrap +from configparser import ConfigParser +from pathlib import Path + +from recceiver.cfstore import CFConfig, CFProcessor +from recceiver.processors import ConfigAdapter, ProcessorController + + +def make_adapter(section: str = "cf", values: dict = None, env: dict = None) -> ConfigAdapter: + parser = ConfigParser() + parser.add_section(section) + for key, value in (values or {}).items(): + parser.set(section, key, str(value)) + adapter = ConfigAdapter(parser, section) + if env: + adapter.env_vars = env + return adapter + + +class TestConfigAdapterGet: + def test_returns_value(self): + adapter = make_adapter(values={"baseurl": "https://cf:8080"}) + assert adapter.get("baseurl") == "https://cf:8080" + + def test_returns_default_when_missing(self): + adapter = make_adapter() + assert adapter.get("baseurl", "fallback") == "fallback" + + def test_returns_none_when_missing_and_no_default(self): + adapter = make_adapter() + assert adapter.get("baseurl") is None + + def test_env_var_overrides_config(self): + adapter = make_adapter(values={"baseurl": "https://original"}, env={"baseurl": "https://override"}) + assert adapter.get("baseurl") == "https://override" + + def test_env_var_provides_value_not_in_config(self): + adapter = make_adapter(env={"baseurl": "https://from-env"}) + assert adapter.get("baseurl") == "https://from-env" + + +class TestConfigAdapterGetBoolean: + def test_returns_true(self): + adapter = make_adapter(values={"alias": "true"}) + assert adapter.getboolean("alias") is True + + def test_returns_false(self): + adapter = make_adapter(values={"alias": "false"}) + assert adapter.getboolean("alias") is False + + def test_returns_default_when_missing(self): + adapter = make_adapter() + assert adapter.getboolean("alias", False) is False + + def test_returns_default_on_invalid_value(self): + adapter = make_adapter(values={"alias": "notabool"}) + assert adapter.getboolean("alias", True) is True + + +class TestConfigAdapterGetInt: + def test_returns_int(self): + adapter = make_adapter(values={"pushmaxretries": "5"}) + assert adapter.getint("pushmaxretries") == 5 + + def test_returns_default_when_missing(self): + adapter = make_adapter() + assert adapter.getint("pushmaxretries", 10) == 10 + + def test_returns_none_when_missing_and_no_default(self): + adapter = make_adapter() + assert adapter.getint("pushmaxretries") is None + + def test_returns_default_on_invalid_value(self): + adapter = make_adapter(values={"pushmaxretries": "notanint"}) + assert adapter.getint("pushmaxretries", 10) == 10 + + def test_env_var_overrides_config(self): + adapter = make_adapter(values={"pushmaxretries": "5"}, env={"pushmaxretries": "99"}) + assert adapter.getint("pushmaxretries", 10) == 99 + + +class TestCFConfigLoads: + def test_loads_defaults_without_error(self): + adapter = make_adapter() + config = CFConfig.loads(adapter) + assert isinstance(config, CFConfig) + + def test_default_push_max_retries(self): + adapter = make_adapter() + config = CFConfig.loads(adapter) + assert config.push_max_retries == 10 + + def test_push_max_retries_from_config(self): + adapter = make_adapter(values={"pushmaxretries": "3"}) + config = CFConfig.loads(adapter) + assert config.push_max_retries == 3 + + def test_push_max_retries_from_env(self): + adapter = make_adapter(env={"pushmaxretries": "7"}) + config = CFConfig.loads(adapter) + assert config.push_max_retries == 7 + + def test_default_push_always_retry(self): + adapter = make_adapter() + config = CFConfig.loads(adapter) + assert config.push_always_retry is True + + def test_alias_disabled_by_default(self): + adapter = make_adapter() + config = CFConfig.loads(adapter) + assert config.alias_enabled is False + + def test_alias_enabled_from_config(self): + adapter = make_adapter(values={"alias": "true"}) + config = CFConfig.loads(adapter) + assert config.alias_enabled is True + + +class TestProcessorControllerFromConfig: + def test_cf_processor_initialized_from_config_file(self, tmp_path: Path): + config_file = tmp_path / "recceiver.conf" + config_file.write_text( + textwrap.dedent( + """\ + [recceiver] + procs = cf + + [cf] + pushMaxRetries = 5 + """ + ) + ) + ctrl = ProcessorController(cfile=str(config_file)) + assert len(ctrl.procs) == 1 + assert isinstance(ctrl.procs[0], CFProcessor) + assert ctrl.procs[0].cf_config.push_max_retries == 5 From e05b2411120990b07458a422cac0d552ac4dc4be Mon Sep 17 00:00:00 2001 From: Anders Lindh Olsson Date: Tue, 5 May 2026 14:59:00 +0200 Subject: [PATCH 3/5] test(server): smoke-test container startup against real image Verifies the container reaches the CF connection attempt (CF_START in logs) rather than crashing during config parsing. A missing method on ConfigAdapter would cause an AttributeError in makeService, before the log system is even configured, so CF_START would never appear. --- server/tests/test_container_smoke.py | 46 ++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 server/tests/test_container_smoke.py diff --git a/server/tests/test_container_smoke.py b/server/tests/test_container_smoke.py new file mode 100644 index 00000000..595241fc --- /dev/null +++ b/server/tests/test_container_smoke.py @@ -0,0 +1,46 @@ +import time +from pathlib import Path + +import pytest + +from docker import DockerClient + +SERVER_DIR = Path(__file__).parent.parent + + +@pytest.fixture(scope="session") +def recceiver_image(): + client = DockerClient() + image, _ = client.images.build(path=str(SERVER_DIR), tag="recceiver-test:smoke", rm=True) + yield image.id + client.images.remove(image.id, force=True) + + +class TestContainerSmoke: + def test_container_starts_and_parses_config(self, recceiver_image): + """Verify the container reaches the CF connection attempt rather than crashing during config parsing. + + A config parsing error (e.g. AttributeError on a missing ConfigAdapter method) causes the + container to exit before CF_START is logged. A healthy startup logs CF_START first and only + then fails to connect to CF. + """ + client = DockerClient() + container = client.containers.run( + recceiver_image, + environment={ + "RECCEIVER_RECCEIVER_PROCS": "cf", + "RECCEIVER_RECCEIVER_LOGLEVEL": "INFO", + "RECCEIVER_CF_BASEURL": "https://nonexistent-cf:8080/ChannelFinder", + "RECCEIVER_CF_CFUSERNAME": "admin", + "RECCEIVER_CF_CFPASSWORD": "password", + }, + detach=True, + ) + try: + time.sleep(10) + logs = container.logs().decode() + assert "AttributeError" not in logs, f"Container crashed with AttributeError:\n{logs}" + assert "CF_START" in logs, f"CF_START not found in logs — service may not have started:\n{logs}" + finally: + container.stop(timeout=5) + container.remove() From 6f792116b8e17940e9184a993f12d379ae6e8dd3 Mon Sep 17 00:00:00 2001 From: Anders Lindh Olsson Date: Tue, 5 May 2026 15:20:48 +0200 Subject: [PATCH 4/5] refactor(tests): split test_config into test_processors and test_cfstore --- server/tests/test_cfstore.py | 52 +++++++++++++++++++ .../{test_config.py => test_processors.py} | 39 +------------- 2 files changed, 53 insertions(+), 38 deletions(-) create mode 100644 server/tests/test_cfstore.py rename server/tests/{test_config.py => test_processors.py} (72%) diff --git a/server/tests/test_cfstore.py b/server/tests/test_cfstore.py new file mode 100644 index 00000000..da909ee7 --- /dev/null +++ b/server/tests/test_cfstore.py @@ -0,0 +1,52 @@ +from configparser import ConfigParser + +from recceiver.cfstore import CFConfig +from recceiver.processors import ConfigAdapter + + +def make_adapter(section: str = "cf", values: dict = None, env: dict = None) -> ConfigAdapter: + parser = ConfigParser() + parser.add_section(section) + for key, value in (values or {}).items(): + parser.set(section, key, str(value)) + adapter = ConfigAdapter(parser, section) + if env: + adapter.env_vars = env + return adapter + + +class TestCFConfigLoads: + def test_loads_defaults_without_error(self): + adapter = make_adapter() + config = CFConfig.loads(adapter) + assert isinstance(config, CFConfig) + + def test_default_push_max_retries(self): + adapter = make_adapter() + config = CFConfig.loads(adapter) + assert config.push_max_retries == 10 + + def test_push_max_retries_from_config(self): + adapter = make_adapter(values={"pushmaxretries": "3"}) + config = CFConfig.loads(adapter) + assert config.push_max_retries == 3 + + def test_push_max_retries_from_env(self): + adapter = make_adapter(env={"pushmaxretries": "7"}) + config = CFConfig.loads(adapter) + assert config.push_max_retries == 7 + + def test_default_push_always_retry(self): + adapter = make_adapter() + config = CFConfig.loads(adapter) + assert config.push_always_retry is True + + def test_alias_disabled_by_default(self): + adapter = make_adapter() + config = CFConfig.loads(adapter) + assert config.alias_enabled is False + + def test_alias_enabled_from_config(self): + adapter = make_adapter(values={"alias": "true"}) + config = CFConfig.loads(adapter) + assert config.alias_enabled is True diff --git a/server/tests/test_config.py b/server/tests/test_processors.py similarity index 72% rename from server/tests/test_config.py rename to server/tests/test_processors.py index b79b0937..16bc2a64 100644 --- a/server/tests/test_config.py +++ b/server/tests/test_processors.py @@ -2,7 +2,7 @@ from configparser import ConfigParser from pathlib import Path -from recceiver.cfstore import CFConfig, CFProcessor +from recceiver.cfstore import CFProcessor from recceiver.processors import ConfigAdapter, ProcessorController @@ -79,43 +79,6 @@ def test_env_var_overrides_config(self): assert adapter.getint("pushmaxretries", 10) == 99 -class TestCFConfigLoads: - def test_loads_defaults_without_error(self): - adapter = make_adapter() - config = CFConfig.loads(adapter) - assert isinstance(config, CFConfig) - - def test_default_push_max_retries(self): - adapter = make_adapter() - config = CFConfig.loads(adapter) - assert config.push_max_retries == 10 - - def test_push_max_retries_from_config(self): - adapter = make_adapter(values={"pushmaxretries": "3"}) - config = CFConfig.loads(adapter) - assert config.push_max_retries == 3 - - def test_push_max_retries_from_env(self): - adapter = make_adapter(env={"pushmaxretries": "7"}) - config = CFConfig.loads(adapter) - assert config.push_max_retries == 7 - - def test_default_push_always_retry(self): - adapter = make_adapter() - config = CFConfig.loads(adapter) - assert config.push_always_retry is True - - def test_alias_disabled_by_default(self): - adapter = make_adapter() - config = CFConfig.loads(adapter) - assert config.alias_enabled is False - - def test_alias_enabled_from_config(self): - adapter = make_adapter(values={"alias": "true"}) - config = CFConfig.loads(adapter) - assert config.alias_enabled is True - - class TestProcessorControllerFromConfig: def test_cf_processor_initialized_from_config_file(self, tmp_path: Path): config_file = tmp_path / "recceiver.conf" From 04d2e64d70afbd8a947bae029d8a555fc51e63d2 Mon Sep 17 00:00:00 2001 From: Anders Lindh Olsson Date: Tue, 5 May 2026 15:20:57 +0200 Subject: [PATCH 5/5] chore(ci): add sonar-project.properties to fix Python version warning --- server/sonar-project.properties | 1 + 1 file changed, 1 insertion(+) create mode 100644 server/sonar-project.properties diff --git a/server/sonar-project.properties b/server/sonar-project.properties new file mode 100644 index 00000000..f1bc3af5 --- /dev/null +++ b/server/sonar-project.properties @@ -0,0 +1 @@ +sonar.python.version=3.9