diff --git a/app/fanout/manager.py b/app/fanout/manager.py index b393a89..d58abbf 100644 --- a/app/fanout/manager.py +++ b/app/fanout/manager.py @@ -82,6 +82,21 @@ class FanoutManager: def __init__(self) -> None: self._modules: dict[str, tuple[FanoutModule, dict]] = {} # id -> (module, scope) self._restart_locks: dict[str, asyncio.Lock] = {} + self._bots_disabled_until_restart = False + + def get_bots_disabled_source(self) -> str | None: + """Return why bot modules are unavailable, if at all.""" + from app.config import settings as server_settings + + if server_settings.disable_bots: + return "env" + if self._bots_disabled_until_restart: + return "until_restart" + return None + + def bots_disabled_effective(self) -> bool: + """Return True when bot modules should be treated as unavailable.""" + return self.get_bots_disabled_source() is not None async def load_from_db(self) -> None: """Read enabled fanout_configs and instantiate modules.""" @@ -99,13 +114,14 @@ class FanoutManager: config_blob = cfg["config"] scope = cfg["scope"] - # Skip bot modules when bots are disabled server-wide - if config_type == "bot": - from app.config import settings as server_settings - - if server_settings.disable_bots: - logger.info("Skipping bot module %s (bots disabled by server config)", config_id) - return + # Skip bot modules when bots are disabled server-wide or until restart. + if config_type == "bot" and self.bots_disabled_effective(): + logger.info( + "Skipping bot module %s (bots disabled: %s)", + config_id, + self.get_bots_disabled_source(), + ) + return cls = _MODULE_TYPES.get(config_type) if cls is None: @@ -240,6 +256,26 @@ class FanoutManager: } return result + async def disable_bots_until_restart(self) -> str: + """Stop active bot modules and prevent them from starting again until restart.""" + source = self.get_bots_disabled_source() + if source == "env": + return source + + self._bots_disabled_until_restart = True + + from app.repository.fanout import _configs_cache + + bot_ids = [ + config_id + for config_id in list(self._modules) + if _configs_cache.get(config_id, {}).get("type") == "bot" + ] + for config_id in bot_ids: + await self.remove_config(config_id) + + return "until_restart" + # Module-level singleton fanout_manager = FanoutManager() diff --git a/app/routers/fanout.py b/app/routers/fanout.py index 36c7d40..d5c8cb7 100644 --- a/app/routers/fanout.py +++ b/app/routers/fanout.py @@ -9,8 +9,8 @@ import string from fastapi import APIRouter, HTTPException from pydantic import BaseModel, Field -from app.config import settings as server_settings from app.fanout.bot_exec import _analyze_bot_signature +from app.fanout.manager import fanout_manager from app.repository.fanout import FanoutConfigRepository logger = logging.getLogger(__name__) @@ -325,6 +325,15 @@ def _enforce_scope(config_type: str, scope: dict) -> dict: return {"messages": messages, "raw_packets": raw_packets} +def _bot_system_disabled_detail() -> str | None: + source = fanout_manager.get_bots_disabled_source() + if source == "env": + return "Bot system disabled by server configuration (MESHCORE_DISABLE_BOTS)" + if source == "until_restart": + return "Bot system disabled until the server restarts" + return None + + @router.get("") async def list_fanout_configs() -> list[dict]: """List all fanout configs.""" @@ -340,8 +349,10 @@ async def create_fanout_config(body: FanoutConfigCreate) -> dict: detail=f"Invalid type '{body.type}'. Must be one of: {', '.join(sorted(_VALID_TYPES))}", ) - if body.type == "bot" and server_settings.disable_bots: - raise HTTPException(status_code=403, detail="Bot system disabled by server configuration") + if body.type == "bot": + disabled_detail = _bot_system_disabled_detail() + if disabled_detail: + raise HTTPException(status_code=403, detail=disabled_detail) normalized_config = _validate_and_normalize_config(body.type, body.config) scope = _enforce_scope(body.type, body.scope) @@ -356,8 +367,6 @@ async def create_fanout_config(body: FanoutConfigCreate) -> dict: # Start the module if enabled if cfg["enabled"]: - from app.fanout.manager import fanout_manager - await fanout_manager.reload_config(cfg["id"]) logger.info("Created fanout config %s (type=%s, name=%s)", cfg["id"], body.type, body.name) @@ -371,8 +380,10 @@ async def update_fanout_config(config_id: str, body: FanoutConfigUpdate) -> dict if existing is None: raise HTTPException(status_code=404, detail="Fanout config not found") - if existing["type"] == "bot" and server_settings.disable_bots: - raise HTTPException(status_code=403, detail="Bot system disabled by server configuration") + if existing["type"] == "bot": + disabled_detail = _bot_system_disabled_detail() + if disabled_detail: + raise HTTPException(status_code=403, detail=disabled_detail) kwargs = {} if body.name is not None: @@ -390,8 +401,6 @@ async def update_fanout_config(config_id: str, body: FanoutConfigUpdate) -> dict raise HTTPException(status_code=404, detail="Fanout config not found") # Reload the module to pick up changes - from app.fanout.manager import fanout_manager - await fanout_manager.reload_config(config_id) logger.info("Updated fanout config %s", config_id) @@ -406,10 +415,24 @@ async def delete_fanout_config(config_id: str) -> dict: raise HTTPException(status_code=404, detail="Fanout config not found") # Stop the module first - from app.fanout.manager import fanout_manager - await fanout_manager.remove_config(config_id) await FanoutConfigRepository.delete(config_id) logger.info("Deleted fanout config %s", config_id) return {"deleted": True} + + +@router.post("/bots/disable-until-restart") +async def disable_bots_until_restart() -> dict: + """Stop active bot modules and prevent them from running again until restart.""" + source = await fanout_manager.disable_bots_until_restart() + + from app.services.radio_runtime import radio_runtime as radio_manager + from app.websocket import broadcast_health + + broadcast_health(radio_manager.is_connected, radio_manager.connection_info) + return { + "status": "ok", + "bots_disabled": True, + "bots_disabled_source": source, + } diff --git a/app/routers/health.py b/app/routers/health.py index 2ceb39e..7b62564 100644 --- a/app/routers/health.py +++ b/app/routers/health.py @@ -1,5 +1,5 @@ import os -from typing import Any +from typing import Any, Literal from fastapi import APIRouter from pydantic import BaseModel @@ -37,6 +37,8 @@ class HealthResponse(BaseModel): oldest_undecrypted_timestamp: int | None fanout_statuses: dict[str, dict[str, str]] = {} bots_disabled: bool = False + bots_disabled_source: Literal["env", "until_restart"] | None = None + basic_auth_enabled: bool = False def _clean_optional_str(value: object) -> str | None: @@ -46,6 +48,11 @@ def _clean_optional_str(value: object) -> str | None: return cleaned or None +def _read_optional_bool_setting(name: str) -> bool: + value = getattr(settings, name, False) + return value if isinstance(value, bool) else False + + async def build_health_data(radio_connected: bool, connection_info: str | None) -> dict: """Build the health status payload used by REST endpoint and WebSocket broadcasts.""" app_build_info = get_app_build_info() @@ -64,10 +71,14 @@ async def build_health_data(radio_connected: bool, connection_info: str | None) # Fanout module statuses fanout_statuses: dict[str, Any] = {} + bots_disabled_source = "env" if _read_optional_bool_setting("disable_bots") else None try: from app.fanout.manager import fanout_manager fanout_statuses = fanout_manager.get_statuses() + manager_bots_disabled_source = fanout_manager.get_bots_disabled_source() + if manager_bots_disabled_source is not None: + bots_disabled_source = manager_bots_disabled_source except Exception: pass @@ -118,7 +129,9 @@ async def build_health_data(radio_connected: bool, connection_info: str | None) "database_size_mb": db_size_mb, "oldest_undecrypted_timestamp": oldest_ts, "fanout_statuses": fanout_statuses, - "bots_disabled": settings.disable_bots, + "bots_disabled": bots_disabled_source is not None, + "bots_disabled_source": bots_disabled_source, + "basic_auth_enabled": _read_optional_bool_setting("basic_auth_enabled"), } diff --git a/docker-compose.yaml b/docker-compose.yaml index ee79598..4ed71c0 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -18,15 +18,18 @@ services: environment: MESHCORE_DATABASE_PATH: data/meshcore.db # Radio connection -- optional if you map just a single serial device above, as the app will autodetect - # Serial (USB) # MESHCORE_SERIAL_PORT: /dev/ttyUSB0 # MESHCORE_SERIAL_BAUDRATE: 115200 - # TCP # MESHCORE_TCP_HOST: 192.168.1.100 # MESHCORE_TCP_PORT: 4000 + # Security + # MESHCORE_DISABLE_BOTS: "true" + # MESHCORE_BASIC_AUTH_USERNAME: changeme + # MESHCORE_BASIC_AUTH_PASSWORD: changeme + # Logging # MESHCORE_LOG_LEVEL: INFO restart: unless-stopped diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 0c70c54..ab7644b 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -343,6 +343,14 @@ export const api = { fetchJson<{ deleted: boolean }>(`/fanout/${id}`, { method: 'DELETE', }), + disableBotsUntilRestart: () => + fetchJson<{ + status: string; + bots_disabled: boolean; + bots_disabled_source: 'env' | 'until_restart'; + }>('/fanout/bots/disable-until-restart', { + method: 'POST', + }), // Statistics getStatistics: () => fetchJson('/statistics'), diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx index 1da675c..9b50faf 100644 --- a/frontend/src/components/AppShell.tsx +++ b/frontend/src/components/AppShell.tsx @@ -6,6 +6,7 @@ import { ConversationPane } from './ConversationPane'; import { NewMessageModal } from './NewMessageModal'; import { ContactInfoPane } from './ContactInfoPane'; import { ChannelInfoPane } from './ChannelInfoPane'; +import { SecurityWarningModal } from './SecurityWarningModal'; import { Toaster } from './ui/sonner'; import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './ui/sheet'; import { @@ -289,6 +290,7 @@ export function AppShell({ }} /> + diff --git a/frontend/src/components/SecurityWarningModal.tsx b/frontend/src/components/SecurityWarningModal.tsx new file mode 100644 index 0000000..201eb97 --- /dev/null +++ b/frontend/src/components/SecurityWarningModal.tsx @@ -0,0 +1,181 @@ +import { useEffect, useState } from 'react'; +import { AlertTriangle } from 'lucide-react'; + +import { api } from '../api'; +import type { HealthStatus } from '../types'; +import { Button } from './ui/button'; +import { Checkbox } from './ui/checkbox'; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from './ui/dialog'; +import { toast } from './ui/sonner'; + +const STORAGE_KEY = 'meshcore_security_warning_acknowledged'; + +function readAcknowledgedState(): boolean { + if (typeof window === 'undefined') { + return false; + } + try { + return window.localStorage.getItem(STORAGE_KEY) === 'true'; + } catch { + return false; + } +} + +function writeAcknowledgedState(): void { + if (typeof window === 'undefined') { + return; + } + try { + window.localStorage.setItem(STORAGE_KEY, 'true'); + } catch { + // Best effort only; the warning will continue to show if localStorage is unavailable. + } +} + +interface SecurityWarningModalProps { + health: HealthStatus | null; +} + +export function SecurityWarningModal({ health }: SecurityWarningModalProps) { + const [acknowledged, setAcknowledged] = useState(readAcknowledgedState); + const [confirmedRisk, setConfirmedRisk] = useState(false); + const [disablingBots, setDisablingBots] = useState(false); + const [botsDisabledLocally, setBotsDisabledLocally] = useState(false); + + const shouldWarn = + health !== null && + health.bots_disabled !== true && + health.basic_auth_enabled !== true && + !botsDisabledLocally && + !acknowledged; + + useEffect(() => { + if (!shouldWarn) { + setConfirmedRisk(false); + } + }, [shouldWarn]); + + useEffect(() => { + if (health?.bots_disabled !== true) { + setBotsDisabledLocally(false); + } + }, [health?.bots_disabled, health?.bots_disabled_source]); + + if (!shouldWarn) { + return null; + } + + return ( + + event.preventDefault()} + onInteractOutside={(event) => event.preventDefault()} + > + +
+
+
+ + Unprotected bot execution is enabled + +
+
+ +
+ +
+ + Bots are enabled, and app-wide Basic Auth is not configured. + +

+ Without one of those protections, or another access-control layer in front of + RemoteTerm, anyone on your local network who can reach this app can run Python code on + the computer hosting this instance. +

+

+ This is only safe on protected or isolated networks with appropriate access control. If + your network is untrusted or later compromised, this setup may expose the host system to + arbitrary code execution. +

+

+ To reduce that risk, run the server with environment variables to either disable bots + with{' '} + + MESHCORE_DISABLE_BOTS=true + {' '} + or enable the built-in login with{' '} + + MESHCORE_BASIC_AUTH_USERNAME + {' '} + /{' '} + + MESHCORE_BASIC_AUTH_PASSWORD + + . Another external auth or access-control system is also acceptable. +

+

+ If you just want a temporary safety measure while you learn the system, you can use the + button below to disable bots until the server restarts. That is only a temporary guard; + permanent protection through Basic Auth or env-based bot disablement is still + encouraged. +

+
+ +
+ +
+ +
+ + + +
+
+
+ ); +} diff --git a/frontend/src/components/settings/SettingsFanoutSection.tsx b/frontend/src/components/settings/SettingsFanoutSection.tsx index 6e0abc1..a68a4a7 100644 --- a/frontend/src/components/settings/SettingsFanoutSection.tsx +++ b/frontend/src/components/settings/SettingsFanoutSection.tsx @@ -1817,8 +1817,9 @@ export function SettingsFanoutSection({ {health?.bots_disabled && (
- Bot system is disabled by server configuration (MESHCORE_DISABLE_BOTS). Bot integrations - cannot be created or modified. + {health.bots_disabled_source === 'until_restart' + ? 'Bot system is disabled until the server restarts. Bot integrations cannot run, be created, or be modified right now.' + : 'Bot system is disabled by server configuration (MESHCORE_DISABLE_BOTS). Bot integrations cannot run, be created, or be modified.'}
)} diff --git a/frontend/src/components/ui/dialog.tsx b/frontend/src/components/ui/dialog.tsx index 802b213..575df20 100644 --- a/frontend/src/components/ui/dialog.tsx +++ b/frontend/src/components/ui/dialog.tsx @@ -29,10 +29,16 @@ const DialogOverlay = React.forwardRef< )); DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; +interface DialogContentProps extends React.ComponentPropsWithoutRef< + typeof DialogPrimitive.Content +> { + hideCloseButton?: boolean; +} + const DialogContent = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( + DialogContentProps +>(({ className, children, hideCloseButton = false, ...props }, ref) => ( {children} - - - Close - + {!hideCloseButton && ( + + + Close + + )} )); diff --git a/frontend/src/test/fanoutSection.test.tsx b/frontend/src/test/fanoutSection.test.tsx index defd708..6a54d5f 100644 --- a/frontend/src/test/fanoutSection.test.tsx +++ b/frontend/src/test/fanoutSection.test.tsx @@ -114,6 +114,15 @@ describe('SettingsFanoutSection', () => { }); }); + it('shows restart-scoped bots disabled messaging when disabled until restart', async () => { + renderSection({ + health: { ...baseHealth, bots_disabled: true, bots_disabled_source: 'until_restart' }, + }); + await waitFor(() => { + expect(screen.getByText(/disabled until the server restarts/i)).toBeInTheDocument(); + }); + }); + it('hides bot option from add integration menu when bots_disabled', async () => { renderSection({ health: { ...baseHealth, bots_disabled: true } }); await waitFor(() => { diff --git a/frontend/src/test/securityWarningModal.test.tsx b/frontend/src/test/securityWarningModal.test.tsx new file mode 100644 index 0000000..287555a --- /dev/null +++ b/frontend/src/test/securityWarningModal.test.tsx @@ -0,0 +1,119 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + disableBotsUntilRestart: vi.fn(), + toast: { + success: vi.fn(), + error: vi.fn(), + }, +})); + +vi.mock('../api', () => ({ + api: { + disableBotsUntilRestart: mocks.disableBotsUntilRestart, + }, +})); + +vi.mock('../components/ui/sonner', () => ({ + toast: mocks.toast, +})); + +import { SecurityWarningModal } from '../components/SecurityWarningModal'; +import type { HealthStatus } from '../types'; + +const baseHealth: HealthStatus = { + status: 'degraded', + radio_connected: false, + radio_initializing: false, + connection_info: null, + database_size_mb: 1.2, + oldest_undecrypted_timestamp: null, + fanout_statuses: {}, + bots_disabled: false, + basic_auth_enabled: false, +}; + +describe('SecurityWarningModal', () => { + beforeEach(() => { + vi.clearAllMocks(); + window.localStorage.clear(); + mocks.disableBotsUntilRestart.mockResolvedValue({ + status: 'ok', + bots_disabled: true, + bots_disabled_source: 'until_restart', + }); + }); + + it('shows the warning when bots are enabled and basic auth is off', () => { + render(); + + expect(screen.getByText('Unprotected bot execution is enabled')).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Do Not Warn Me On This Device Again' }) + ).toBeDisabled(); + }); + + it('does not show when bots are disabled', () => { + render(); + + expect(screen.queryByText('Unprotected bot execution is enabled')).not.toBeInTheDocument(); + }); + + it('does not show when basic auth is enabled', () => { + render(); + + expect(screen.queryByText('Unprotected bot execution is enabled')).not.toBeInTheDocument(); + }); + + it('persists dismissal only after the checkbox is acknowledged', async () => { + const user = userEvent.setup(); + + render(); + + const dismissButton = screen.getByRole('button', { + name: 'Do Not Warn Me On This Device Again', + }); + await user.click(screen.getByLabelText('Acknowledge bot security risk')); + expect(dismissButton).toBeEnabled(); + + await user.click(dismissButton); + + expect(window.localStorage.getItem('meshcore_security_warning_acknowledged')).toBe('true'); + expect(screen.queryByText('Unprotected bot execution is enabled')).not.toBeInTheDocument(); + }); + + it('disables bots until restart from the warning modal', async () => { + const user = userEvent.setup(); + + render(); + + await user.click(screen.getByRole('button', { name: 'Disable Bots Until Server Restart' })); + + expect(mocks.disableBotsUntilRestart).toHaveBeenCalledTimes(1); + expect(mocks.toast.success).toHaveBeenCalledWith('Bots disabled until restart'); + expect(screen.queryByText('Unprotected bot execution is enabled')).not.toBeInTheDocument(); + }); + + it('shows the warning again after temporary bot disable disappears on a later health update', async () => { + const user = userEvent.setup(); + const { rerender } = render(); + + await user.click(screen.getByRole('button', { name: 'Disable Bots Until Server Restart' })); + expect(screen.queryByText('Unprotected bot execution is enabled')).not.toBeInTheDocument(); + + rerender( + + ); + expect(screen.queryByText('Unprotected bot execution is enabled')).not.toBeInTheDocument(); + + rerender(); + + await waitFor(() => { + expect(screen.getByText('Unprotected bot execution is enabled')).toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 062170d..95ea3b4 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -78,6 +78,8 @@ export interface HealthStatus { oldest_undecrypted_timestamp: number | null; fanout_statuses: Record; bots_disabled: boolean; + bots_disabled_source?: 'env' | 'until_restart' | null; + basic_auth_enabled?: boolean; } export interface FanoutConfig { diff --git a/tests/e2e/helpers/api.ts b/tests/e2e/helpers/api.ts index ca0f998..36d25f7 100644 --- a/tests/e2e/helpers/api.ts +++ b/tests/e2e/helpers/api.ts @@ -23,6 +23,9 @@ export interface HealthStatus { radio_connected: boolean; radio_initializing: boolean; connection_info: string | null; + bots_disabled?: boolean; + bots_disabled_source?: 'env' | 'until_restart' | null; + basic_auth_enabled?: boolean; } export function getHealth(): Promise { diff --git a/tests/test_disable_bots.py b/tests/test_disable_bots.py index 6f0d0ff..f607ba1 100644 --- a/tests/test_disable_bots.py +++ b/tests/test_disable_bots.py @@ -1,16 +1,17 @@ -"""Tests for the --disable-bots (MESHCORE_DISABLE_BOTS) startup flag. +"""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 MagicMock, patch +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 @@ -33,7 +34,9 @@ class TestDisableBotsFanoutEndpoint: @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.server_settings", MagicMock(disable_bots=True)): + 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( @@ -50,7 +53,9 @@ class TestDisableBotsFanoutEndpoint: @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.server_settings", MagicMock(disable_bots=True)): + 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( @@ -62,13 +67,68 @@ class TestDisableBotsFanoutEndpoint: ) 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, database_path="x")): + 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") @@ -76,8 +136,39 @@ class TestDisableBotsHealthEndpoint: @pytest.mark.asyncio async def test_health_includes_bots_disabled_false(self, test_db): - with patch("app.routers.health.settings", MagicMock(disable_bots=False, database_path="x")): + 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" diff --git a/tests/test_fanout_hitlist.py b/tests/test_fanout_hitlist.py index e7eec4d..51023b4 100644 --- a/tests/test_fanout_hitlist.py +++ b/tests/test_fanout_hitlist.py @@ -593,7 +593,9 @@ class TestDisableBotsPatchGuard: ) # Now try to update with bots disabled - with patch("app.routers.fanout.server_settings", MagicMock(disable_bots=True)): + with patch( + "app.routers.fanout.fanout_manager.get_bots_disabled_source", return_value="env" + ): with pytest.raises(HTTPException) as exc_info: await update_fanout_config( cfg["id"], @@ -617,7 +619,9 @@ class TestDisableBotsPatchGuard: enabled=False, ) - with patch("app.routers.fanout.server_settings", MagicMock(disable_bots=True)): + with patch( + "app.routers.fanout.fanout_manager.get_bots_disabled_source", return_value="env" + ): with patch("app.fanout.manager.fanout_manager.reload_config", new_callable=AsyncMock): result = await update_fanout_config( cfg["id"],