Files
muxplex/muxplex/tests/test_settings_sync_poll.py
T
Brian Krabach d541612843 fix: read federation key live on each request and check PUT sync response
Bug 1 (auth.py): AuthMiddleware.dispatch() was using self.federation_key
(set once at startup) for Bearer token validation. If the key was generated
or rotated via POST /api/federation/generate-key after startup, the old
(often empty) value caused all federation auth to silently return 401.

Fix: import load_federation_key from muxplex.settings and call it fresh on
every non-exempt, non-cookie request. Also adds a warning log when a Bearer
token is received but no key is configured on this server.

Bug 2 (main.py): _sync_settings_with_remotes() discarded the PUT response,
silently swallowing 401/500 errors from the remote sync endpoint.

Fix: capture the PUT response as put_resp. Handle 409 (Conflict = remote is
newer) with a debug log; raise_for_status() for any other non-2xx so errors
propagate to the outer except and are logged as warnings.

Tests:
- test_dispatch_calls_load_federation_key_live: pattern test confirming
  load_federation_key() is called inside dispatch()
- test_dispatch_does_not_use_stale_self_federation_key_for_bearer: pattern
  test confirming self.federation_key is gone from the live bearer check
- test_sync_put_response_calls_raise_for_status: pattern test confirming
  raise_for_status() is called on the PUT response in the sync function
- Updated existing bearer tests to monkeypatch load_federation_key so they
  are isolated from any real key file on disk
2026-04-08 22:39:03 -07:00

273 lines
9.3 KiB
Python

"""
Tests for settings sync logic in the poll cycle (main.py).
Covers:
- SETTINGS_SYNC_INTERVAL constant exists
- _settings_sync_counter module-level variable exists
- _sync_settings_with_remotes function exists and is callable
- Sync adopts newer remote settings (GET, remote_ts > local_ts)
- Sync pushes local settings to older remote (PUT, local_ts > remote_ts)
- Sync skips when timestamps are equal
- Sync gracefully handles 404 from older muxplex instances
- Sync gracefully handles connection errors
- Sync skips remotes with no URL
"""
import asyncio
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
import muxplex.main as main_mod
import muxplex.settings as settings_mod
# ---------------------------------------------------------------------------
# Autouse fixture: redirect settings to tmp_path
# ---------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def redirect_settings(tmp_path, monkeypatch):
"""Redirect SETTINGS_PATH to a temporary file for all tests."""
fake_path = tmp_path / "settings.json"
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", fake_path)
return fake_path
# ---------------------------------------------------------------------------
# Pattern / existence tests
# ---------------------------------------------------------------------------
def test_settings_sync_interval_constant_exists():
"""SETTINGS_SYNC_INTERVAL constant must exist in main module."""
assert hasattr(main_mod, "SETTINGS_SYNC_INTERVAL")
def test_settings_sync_interval_is_15():
"""SETTINGS_SYNC_INTERVAL must equal 15 (15 * 2s = ~30s)."""
assert main_mod.SETTINGS_SYNC_INTERVAL == 15
def test_settings_sync_counter_exists():
"""_settings_sync_counter module-level variable must exist in main module."""
assert hasattr(main_mod, "_settings_sync_counter")
def test_sync_settings_with_remotes_is_callable():
"""_sync_settings_with_remotes must be an async callable in main module."""
assert hasattr(main_mod, "_sync_settings_with_remotes")
assert asyncio.iscoroutinefunction(main_mod._sync_settings_with_remotes)
# ---------------------------------------------------------------------------
# Behaviour tests: sync logic with mocked HTTP
#
# Patch at the main_mod level (not settings_mod) because the functions are
# imported into muxplex.main's namespace via "from muxplex.settings import ...".
# ---------------------------------------------------------------------------
async def test_sync_adopts_newer_remote_settings():
"""When remote timestamp is newer, apply_synced_settings is called with remote data."""
local_ts = 100.0
remote_ts = 200.0
remote_settings = {"fontSize": 18, "sort_order": "alpha"}
get_resp = MagicMock()
get_resp.status_code = 200
get_resp.json.return_value = {
"settings": remote_settings,
"settings_updated_at": remote_ts,
}
get_resp.raise_for_status = MagicMock()
http_client = MagicMock()
http_client.get = AsyncMock(return_value=get_resp)
http_client.put = AsyncMock()
remote_config = {
"remote_instances": [{"url": "http://remote1:8088", "key": "testkey"}]
}
with (
patch.object(main_mod, "get_syncable_settings") as mock_get_sync,
patch.object(main_mod, "apply_synced_settings") as mock_apply,
):
mock_get_sync.return_value = {
"fontSize": 14,
"sort_order": "manual",
"settings_updated_at": local_ts,
}
await main_mod._sync_settings_with_remotes(remote_config, http_client)
mock_apply.assert_called_once_with(remote_settings, remote_ts)
http_client.put.assert_not_called()
async def test_sync_pushes_local_to_older_remote():
"""When local timestamp is newer, PUT is called to push local settings to remote."""
local_ts = 200.0
remote_ts = 100.0
local_syncable = {
"fontSize": 16,
"sort_order": "alpha",
"settings_updated_at": local_ts,
}
get_resp = MagicMock()
get_resp.status_code = 200
get_resp.json.return_value = {
"settings": {"fontSize": 14},
"settings_updated_at": remote_ts,
}
get_resp.raise_for_status = MagicMock()
put_resp = MagicMock()
put_resp.status_code = 200
http_client = MagicMock()
http_client.get = AsyncMock(return_value=get_resp)
http_client.put = AsyncMock(return_value=put_resp)
remote_config = {
"remote_instances": [{"url": "http://remote1:8088", "key": "testkey"}]
}
with (
patch.object(main_mod, "get_syncable_settings") as mock_get_sync,
patch.object(main_mod, "apply_synced_settings") as mock_apply,
):
mock_get_sync.return_value = local_syncable
await main_mod._sync_settings_with_remotes(remote_config, http_client)
mock_apply.assert_not_called()
http_client.put.assert_called_once()
call_kwargs = http_client.put.call_args
assert "/api/settings/sync" in call_kwargs[0][0]
payload = call_kwargs[1]["json"]
assert payload["settings_updated_at"] == local_ts
assert "fontSize" in payload["settings"]
async def test_sync_skips_equal_timestamps():
"""When timestamps are equal, neither apply_synced_settings nor PUT is called."""
equal_ts = 100.0
get_resp = MagicMock()
get_resp.status_code = 200
get_resp.json.return_value = {
"settings": {"fontSize": 14},
"settings_updated_at": equal_ts,
}
get_resp.raise_for_status = MagicMock()
http_client = MagicMock()
http_client.get = AsyncMock(return_value=get_resp)
http_client.put = AsyncMock()
remote_config = {
"remote_instances": [{"url": "http://remote1:8088", "key": "testkey"}]
}
with (
patch.object(main_mod, "get_syncable_settings") as mock_get_sync,
patch.object(main_mod, "apply_synced_settings") as mock_apply,
):
mock_get_sync.return_value = {"fontSize": 14, "settings_updated_at": equal_ts}
await main_mod._sync_settings_with_remotes(remote_config, http_client)
mock_apply.assert_not_called()
http_client.put.assert_not_called()
async def test_sync_handles_404_gracefully():
"""A 404 from the remote (older muxplex, no sync endpoint) is silently skipped."""
get_resp = MagicMock()
get_resp.status_code = 404
http_client = MagicMock()
http_client.get = AsyncMock(return_value=get_resp)
http_client.put = AsyncMock()
remote_config = {
"remote_instances": [{"url": "http://remote1:8088", "key": "testkey"}]
}
with (
patch.object(main_mod, "get_syncable_settings") as mock_get_sync,
patch.object(main_mod, "apply_synced_settings") as mock_apply,
):
mock_get_sync.return_value = {"fontSize": 14, "settings_updated_at": 100.0}
# Must not raise
await main_mod._sync_settings_with_remotes(remote_config, http_client)
mock_apply.assert_not_called()
http_client.put.assert_not_called()
async def test_sync_handles_connection_error_gracefully():
"""Connection errors during sync are caught and logged, not re-raised."""
http_client = MagicMock()
http_client.get = AsyncMock(side_effect=ConnectionError("timeout"))
http_client.put = AsyncMock()
remote_config = {
"remote_instances": [{"url": "http://remote1:8088", "key": "testkey"}]
}
with (
patch.object(main_mod, "get_syncable_settings") as mock_get_sync,
patch.object(main_mod, "apply_synced_settings") as mock_apply,
):
mock_get_sync.return_value = {"fontSize": 14, "settings_updated_at": 100.0}
# Must not raise even with connection error
await main_mod._sync_settings_with_remotes(remote_config, http_client)
mock_apply.assert_not_called()
async def test_sync_skips_remote_with_no_url():
"""Remotes without a URL are silently skipped (no HTTP call made)."""
http_client = MagicMock()
http_client.get = AsyncMock()
http_client.put = AsyncMock()
remote_config = {"remote_instances": [{"url": "", "key": "somekey"}]}
with patch.object(main_mod, "get_syncable_settings") as mock_get_sync:
mock_get_sync.return_value = {"fontSize": 14, "settings_updated_at": 100.0}
await main_mod._sync_settings_with_remotes(remote_config, http_client)
http_client.get.assert_not_called()
http_client.put.assert_not_called()
# ---------------------------------------------------------------------------
# Pattern test: PUT response is checked via raise_for_status()
# ---------------------------------------------------------------------------
def test_sync_put_response_calls_raise_for_status():
"""_sync_settings_with_remotes must capture the PUT response and call raise_for_status() on it."""
import inspect
source = inspect.getsource(main_mod._sync_settings_with_remotes)
# The PUT response must be assigned to a variable (not fire-and-forget)
assert "put_resp" in source, (
"_sync_settings_with_remotes must capture the PUT response in put_resp "
"so errors (401, 500) are not silently swallowed"
)
# raise_for_status() must be called on the captured response
assert "raise_for_status" in source, (
"_sync_settings_with_remotes must call raise_for_status() on the PUT response "
"so non-2xx errors propagate to the outer exception handler"
)