"""Tests for bot-disable enforcement. Verifies that when disable_bots=True: - POST /api/fanout with type=bot returns 403 - Health endpoint includes bots_disabled=True """ from unittest.mock import AsyncMock, MagicMock, patch import pytest from fastapi import HTTPException from app.config import Settings from app.repository.fanout import FanoutConfigRepository from app.routers.fanout import FanoutConfigCreate, create_fanout_config from app.routers.health import build_health_data class TestDisableBotsConfig: """Test the disable_bots configuration field.""" def test_disable_bots_defaults_to_false(self): s = Settings(serial_port="", tcp_host="", ble_address="") assert s.disable_bots is False def test_disable_bots_can_be_set_true(self): s = Settings(serial_port="", tcp_host="", ble_address="", disable_bots=True) assert s.disable_bots is True class TestDisableBotsFanoutEndpoint: """Test that bot creation via fanout router is rejected when bots are disabled.""" @pytest.mark.asyncio async def test_bot_create_returns_403_when_disabled(self, test_db): """POST /api/fanout with type=bot returns 403.""" with patch( "app.routers.fanout.fanout_manager.get_bots_disabled_source", return_value="env" ): with pytest.raises(HTTPException) as exc_info: await create_fanout_config( FanoutConfigCreate( type="bot", name="Test Bot", config={"code": "def bot(**k): pass"}, enabled=False, ) ) assert exc_info.value.status_code == 403 assert "disabled" in exc_info.value.detail.lower() @pytest.mark.asyncio async def test_mqtt_create_allowed_when_bots_disabled(self, test_db): """Non-bot fanout configs can still be created when bots are disabled.""" with patch( "app.routers.fanout.fanout_manager.get_bots_disabled_source", return_value="env" ): # Create as disabled so fanout_manager.reload_config is not called result = await create_fanout_config( FanoutConfigCreate( type="mqtt_private", name="Test MQTT", config={"broker_host": "localhost", "broker_port": 1883}, enabled=False, ) ) assert result["type"] == "mqtt_private" @pytest.mark.asyncio async def test_bot_create_returns_403_when_disabled_until_restart(self, test_db): with patch( "app.routers.fanout.fanout_manager.get_bots_disabled_source", return_value="until_restart", ): with pytest.raises(HTTPException) as exc_info: await create_fanout_config( FanoutConfigCreate( type="bot", name="Test Bot", config={"code": "def bot(**k): pass"}, enabled=False, ) ) assert exc_info.value.status_code == 403 assert "until the server restarts" in exc_info.value.detail @pytest.mark.asyncio async def test_disable_bots_until_restart_endpoint(self, test_db): from app.routers.fanout import disable_bots_until_restart await FanoutConfigRepository.create( config_type="bot", name="Test Bot", config={"code": "def bot(**k): pass"}, scope={"messages": "all", "raw_packets": "none"}, enabled=True, ) with ( patch( "app.routers.fanout.fanout_manager.disable_bots_until_restart", new=AsyncMock(return_value="until_restart"), ) as mock_disable, patch("app.websocket.broadcast_health") as mock_broadcast_health, patch("app.services.radio_runtime.radio_runtime") as mock_radio_runtime, ): mock_radio_runtime.is_connected = True mock_radio_runtime.connection_info = "TCP: 1.2.3.4:4000" result = await disable_bots_until_restart() mock_disable.assert_awaited_once() mock_broadcast_health.assert_called_once_with(True, "TCP: 1.2.3.4:4000") assert result == { "status": "ok", "bots_disabled": True, "bots_disabled_source": "until_restart", } class TestDisableBotsHealthEndpoint: """Test that bots_disabled is exposed in health data.""" @pytest.mark.asyncio async def test_health_includes_bots_disabled_true(self, test_db): with patch( "app.routers.health.settings", MagicMock(disable_bots=True, basic_auth_enabled=False, database_path="x"), ): with patch("app.routers.health.os.path.getsize", return_value=0): data = await build_health_data(True, "TCP: 1.2.3.4:4000") assert data["bots_disabled"] is True @pytest.mark.asyncio async def test_health_includes_bots_disabled_false(self, test_db): with patch( "app.routers.health.settings", MagicMock(disable_bots=False, basic_auth_enabled=False, database_path="x"), ): with patch("app.routers.health.os.path.getsize", return_value=0): data = await build_health_data(True, "TCP: 1.2.3.4:4000") assert data["bots_disabled"] is False @pytest.mark.asyncio async def test_health_includes_basic_auth_enabled(self, test_db): with patch( "app.routers.health.settings", MagicMock(disable_bots=False, basic_auth_enabled=True, database_path="x"), ): with patch("app.routers.health.os.path.getsize", return_value=0): data = await build_health_data(True, "TCP: 1.2.3.4:4000") assert data["basic_auth_enabled"] is True @pytest.mark.asyncio async def test_health_includes_runtime_bot_disable_source(self, test_db): with ( patch( "app.routers.health.settings", MagicMock(disable_bots=False, basic_auth_enabled=False, database_path="x"), ), patch("app.routers.health.os.path.getsize", return_value=0), patch("app.fanout.manager.fanout_manager") as mock_fm, ): mock_fm.get_statuses.return_value = {} mock_fm.get_bots_disabled_source.return_value = "until_restart" data = await build_health_data(True, "TCP: 1.2.3.4:4000") assert data["bots_disabled"] is True assert data["bots_disabled_source"] == "until_restart"