From 3a7c199332a52a712aa25b8ccfba2ce4b90c8627 Mon Sep 17 00:00:00 2001 From: Dariusz Jarosz Date: Thu, 26 Mar 2026 13:38:51 -0500 Subject: [PATCH 1/3] Add a new api_url and bely_url seperation functioanlity. --- .../config.example.yaml | 10 +- .../docs/configuration.md | 11 +- .../test/test_handler.py | 117 ++++++++++++++++++ .../src/bely_mqtt/cli.py | 7 +- .../src/bely_mqtt/config.py | 41 +++++- 5 files changed, 177 insertions(+), 9 deletions(-) diff --git a/tools/developer_tools/bely-mqtt-message-broker/config.example.yaml b/tools/developer_tools/bely-mqtt-message-broker/config.example.yaml index 10f929cc2..df62903a2 100644 --- a/tools/developer_tools/bely-mqtt-message-broker/config.example.yaml +++ b/tools/developer_tools/bely-mqtt-message-broker/config.example.yaml @@ -3,9 +3,17 @@ # Global configuration shared across all handlers global: - # BELY API URL for querying additional information + # Public-facing URL that end-users access in their browser. + # Used to generate clickable links in notifications (log entry permalinks, + # unsubscribe URLs). Must be reachable by notification recipients. bely_url: https://bely.gov/bely + # (Optional) Internal API URL for broker-to-server API calls. + # Set this when the broker runs on the same machine as BELY and should + # use localhost for API calls, while bely_url remains the public URL. + # Defaults to bely_url if not specified. + # api_url: http://localhost:8080/bely + # Handler-specific configurations handlers: # Advanced logging handler with custom directory diff --git a/tools/developer_tools/bely-mqtt-message-broker/docs/configuration.md b/tools/developer_tools/bely-mqtt-message-broker/docs/configuration.md index 5d9b0a545..c7d4b297b 100644 --- a/tools/developer_tools/bely-mqtt-message-broker/docs/configuration.md +++ b/tools/developer_tools/bely-mqtt-message-broker/docs/configuration.md @@ -23,7 +23,10 @@ bely-mqtt start [OPTIONS] ### API Options -- `--api-url TEXT` - BELY API base URL +- `--api-url TEXT` - Internal BELY API URL for server-to-server API calls. + Takes precedence over `api_url` in the config file, which in turn falls back + to `bely_url`. Set when the broker runs on the same host as BELY and internal + access is preferred over going through the public URL. - `--api-key TEXT` - BELY API authentication key ### Logging Options @@ -80,8 +83,12 @@ Handlers can be configured via a YAML file to provide both global and handler-sp ```yaml # Global configuration shared across all handlers global: - # BELY API URL for querying additional information + # Public-facing URL for notification links (log entry permalinks, unsubscribe URLs). + # Must be reachable by end-users who receive notifications. bely_url: https://bely.example.com/bely + # (Optional) Internal API URL for broker-to-BELY server API calls. + # Defaults to bely_url. Set when broker runs on the same host as BELY. + # api_url: http://localhost:8080/bely # Add any other global parameters here shared_param: value diff --git a/tools/developer_tools/bely-mqtt-message-broker/examples/handlers/apprise_smart_notification/test/test_handler.py b/tools/developer_tools/bely-mqtt-message-broker/examples/handlers/apprise_smart_notification/test/test_handler.py index a5b792bcf..81192d844 100644 --- a/tools/developer_tools/bely-mqtt-message-broker/examples/handlers/apprise_smart_notification/test/test_handler.py +++ b/tools/developer_tools/bely-mqtt-message-broker/examples/handlers/apprise_smart_notification/test/test_handler.py @@ -783,6 +783,123 @@ async def test_no_config_handler(self): # (it just won't send notifications) +class TestUrlSeparation: + """ + Acceptance tests: notification links use bely_url (public URL), not api_url (internal URL). + + When bely_url and api_url are configured separately, all user-facing links in + notifications (log entry permalinks, unsubscribe URLs) must use bely_url. + The api_url is only for server-to-server API calls and must never appear in + notification content sent to users. + """ + + PUBLIC_BELY_URL = "https://public.bely.example.com" + INTERNAL_API_URL = "http://localhost:8080/bely" + + @pytest.fixture + def mock_factory(self): + """Provide a mock event factory.""" + return MockEventFactory() + + @pytest.fixture + def config_file_email_with_id(self, tmp_path): + """ + Config with an email endpoint and config_id. + + Using the API format (configs list with config_id) so that the + unsubscribe link is generated in addition to the permalink. + """ + config = { + "global": {}, + "users": { + "alice": { + "configs": [ + { + "apprise_url": "mailto://alice@example.com", + "config_id": 42, + "notifications": { + "entry_updates": True, + "own_entry_edits": True, + "entry_replies": True, + "new_entries": True, + "reactions": True, + "document_replies": True, + }, + } + ] + } + }, + } + config_path = tmp_path / "url_sep_config.yaml" + with open(config_path, "w") as f: + yaml.dump(config, f) + return config_path + + @pytest.fixture + def handler_dual_url(self, config_file_email_with_id): + """ + Handler configured with distinct bely_url and api_url. + + The mock apprise instance is stored in the endpoint config during + __init__, so notify() calls after fixture setup are still captured. + """ + global_config = GlobalConfig( + { + "bely_url": self.PUBLIC_BELY_URL, + "api_url": self.INTERNAL_API_URL, + } + ) + + mock_apprise_instance = MagicMock() + mock_apprise_instance.add = MagicMock(return_value=True) + mock_apprise_instance.notify = MagicMock(return_value=True) + mock_apprise_class = MagicMock(return_value=mock_apprise_instance) + + with patch("notification_processor.AppriseWithEmailHeaders", mock_apprise_class): + with patch("notification_processor.is_email_notification", return_value=True): + handler = AppriseSmartNotificationHandler( + config_path=str(config_file_email_with_id), + global_config=global_config, + ) + + return handler + + @pytest.mark.asyncio + async def test_notification_links_use_bely_url_not_api_url( + self, handler_dual_url, mock_factory + ): + """ + Notification body must use the public bely_url for all links, not api_url. + + Verifies both the permalink (generated by the formatter from bely_url) and + the unsubscribe link (generated by the processor from bely_url) contain the + public URL. The internal api_url must never appear in user-facing content. + """ + event = mock_factory.create_entry_add_by_bob() + + await handler_dual_url.handle_log_entry_add(event) + + alice_endpoints = handler_dual_url.processor.user_endpoint_configs.get("alice", []) + assert alice_endpoints, "alice should have at least one endpoint configured" + + notify_mock = alice_endpoints[0]["apprise"].notify + assert notify_mock.called, "notify() should have been called for alice" + + # body is always passed as a keyword argument in send_notification + body = notify_mock.call_args[1].get("body", "") + + # Both the permalink and unsubscribe link must use the public bely_url + assert self.PUBLIC_BELY_URL in body, ( + f"Expected public bely_url ({self.PUBLIC_BELY_URL!r}) in notification body,\n" + f"got: {body!r}" + ) + + # The internal api_url must never appear in user-facing notification content + assert "localhost:8080" not in body, ( + f"Internal api_url leaked into notification body:\n{body!r}" + ) + + class TestNotificationContent: """Test the content and formatting of notifications.""" diff --git a/tools/developer_tools/bely-mqtt-message-broker/src/bely_mqtt/cli.py b/tools/developer_tools/bely-mqtt-message-broker/src/bely_mqtt/cli.py index bf6e85cc8..d8597a7dc 100644 --- a/tools/developer_tools/bely-mqtt-message-broker/src/bely_mqtt/cli.py +++ b/tools/developer_tools/bely-mqtt-message-broker/src/bely_mqtt/cli.py @@ -169,11 +169,12 @@ def start( logger.error(f"Failed to load configuration file: {e}") sys.exit(1) - # Fall back to bely_url from config if --api-url not provided + # Fall back to api_url from config if --api-url not provided + # (global_config.api_url falls back to bely_url if api_url is not explicitly set) if not api_url and config_manager and config_manager.global_config: - api_url = config_manager.global_config.bely_url + api_url = config_manager.global_config.api_url if api_url: - logger.info(f"Using bely_url from config file: {api_url}") + logger.info(f"Using api_url from config file: {api_url}") # Initialize API factory if URL is provided api_factory = None diff --git a/tools/developer_tools/bely-mqtt-message-broker/src/bely_mqtt/config.py b/tools/developer_tools/bely-mqtt-message-broker/src/bely_mqtt/config.py index 0f56a2c12..78cc92fff 100644 --- a/tools/developer_tools/bely-mqtt-message-broker/src/bely_mqtt/config.py +++ b/tools/developer_tools/bely-mqtt-message-broker/src/bely_mqtt/config.py @@ -19,6 +19,17 @@ class GlobalConfig: Global configuration shared across all handlers. Stores configuration parameters that will be available to all handlers. + + URL configuration fields: + bely_url: The public-facing URL that end-users access in their browser. + Used to generate clickable links in notifications (log entry + permalinks, unsubscribe URLs). Must be reachable by notification + recipients. + api_url: The internal URL used by the broker for server-to-server API + calls to the BELY server. Defaults to bely_url if not set. + Set this to localhost or an internal hostname when the broker + runs on the same machine as BELY, so API calls bypass the + public network. Never used in user-facing notification content. """ def __init__(self, config: Optional[Dict[str, Any]] = None): @@ -46,13 +57,36 @@ def get(self, key: str, default: Any = None) -> Any: @property def bely_url(self) -> Optional[str]: """ - Get the BELY URL from configuration. + Get the public-facing BELY URL from configuration. + + This URL is used to generate user-facing links in notifications, + such as log entry permalinks and unsubscribe URLs. It must be + accessible by the end-users who receive those notifications. Returns: - BELY URL if configured, None otherwise. + Public BELY URL if configured, None otherwise. """ return self.config.get("bely_url") + @property + def api_url(self) -> Optional[str]: + """ + Get the BELY API URL for internal server-to-server API calls. + + Used by BelyApiFactory for making API requests to the BELY server. + Defaults to bely_url if not explicitly configured, which is correct + when the broker accesses BELY through the same public URL. + + Set api_url explicitly when the broker runs on the same host as BELY + and should use localhost or an internal hostname for API calls, while + bely_url remains the public URL for notification links. + + Returns: + API URL if configured, otherwise falls back to bely_url. + Returns None if neither is configured. + """ + return self.config.get("api_url") or self.bely_url + def __repr__(self) -> str: """Return string representation.""" return f"GlobalConfig({self.config})" @@ -117,7 +151,8 @@ def load_from_file(self, config_file: Path) -> None: global: shared_param: value another_param: value - bely_url: https://bely.example.com + bely_url: https://bely.example.com # public URL for notification links + api_url: http://localhost:8080 # optional; internal API URL, defaults to bely_url handlers: AdvancedLoggingHandler: From 6bfa86873dff271e26bbe56031ebee5c20452727 Mon Sep 17 00:00:00 2001 From: Dariusz Jarosz Date: Thu, 26 Mar 2026 13:42:50 -0500 Subject: [PATCH 2/3] make quality --- .../apprise_smart_notification/test/test_handler.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tools/developer_tools/bely-mqtt-message-broker/examples/handlers/apprise_smart_notification/test/test_handler.py b/tools/developer_tools/bely-mqtt-message-broker/examples/handlers/apprise_smart_notification/test/test_handler.py index 81192d844..4a3292152 100644 --- a/tools/developer_tools/bely-mqtt-message-broker/examples/handlers/apprise_smart_notification/test/test_handler.py +++ b/tools/developer_tools/bely-mqtt-message-broker/examples/handlers/apprise_smart_notification/test/test_handler.py @@ -895,9 +895,9 @@ async def test_notification_links_use_bely_url_not_api_url( ) # The internal api_url must never appear in user-facing notification content - assert "localhost:8080" not in body, ( - f"Internal api_url leaked into notification body:\n{body!r}" - ) + assert ( + "localhost:8080" not in body + ), f"Internal api_url leaked into notification body:\n{body!r}" class TestNotificationContent: From a1ec9fa9aad8f37044591f84fb112796317e8403 Mon Sep 17 00:00:00 2001 From: Dariusz Jarosz Date: Thu, 26 Mar 2026 13:47:47 -0500 Subject: [PATCH 3/3] Prepare patch release 2026.3.1 --- .../bely-mqtt-message-broker/conda-recipe/meta.yaml | 2 +- tools/developer_tools/bely-mqtt-message-broker/pyproject.toml | 2 +- .../bely-mqtt-message-broker/src/bely_mqtt/__init__.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tools/developer_tools/bely-mqtt-message-broker/conda-recipe/meta.yaml b/tools/developer_tools/bely-mqtt-message-broker/conda-recipe/meta.yaml index 5a75b9a20..da84ce1e1 100644 --- a/tools/developer_tools/bely-mqtt-message-broker/conda-recipe/meta.yaml +++ b/tools/developer_tools/bely-mqtt-message-broker/conda-recipe/meta.yaml @@ -1,5 +1,5 @@ {% set name = "bely-mqtt-message-broker" %} -{% set version = "2026.3.0" %} +{% set version = "2026.3.1" %} package: name: "{{ name|lower }}" diff --git a/tools/developer_tools/bely-mqtt-message-broker/pyproject.toml b/tools/developer_tools/bely-mqtt-message-broker/pyproject.toml index 010639af3..5d547fb31 100644 --- a/tools/developer_tools/bely-mqtt-message-broker/pyproject.toml +++ b/tools/developer_tools/bely-mqtt-message-broker/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "bely-mqtt-framework" -version = "2026.3.dev0" +version = "2026.3.1" description = "Pluggable Python framework for handling BELY MQTT events" readme = "README.md" requires-python = ">=3.10" diff --git a/tools/developer_tools/bely-mqtt-message-broker/src/bely_mqtt/__init__.py b/tools/developer_tools/bely-mqtt-message-broker/src/bely_mqtt/__init__.py index 8cd1fb683..ca35079c5 100644 --- a/tools/developer_tools/bely-mqtt-message-broker/src/bely_mqtt/__init__.py +++ b/tools/developer_tools/bely-mqtt-message-broker/src/bely_mqtt/__init__.py @@ -35,7 +35,7 @@ from bely_mqtt.mqtt_client import BelyMQTTClient from bely_mqtt.plugin import MQTTHandler, PluginManager -__version__ = "2026.3.dev0" +__version__ = "2026.3.1" __all__ = [ "BelyMQTTClient",