Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{% set name = "bely-mqtt-message-broker" %}
{% set version = "2026.3.0" %}
{% set version = "2026.3.1" %}

package:
name: "{{ name|lower }}"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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})"
Expand Down Expand Up @@ -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:
Expand Down
Loading