diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 00000000..dded6e4c --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,45 @@ +name: Tests + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + test: + runs-on: ubuntu-24.04 + + env: + PYTHONPATH: ${{ github.workspace }}:${{ github.workspace }}/web + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.10' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install pytest + pip install -r tests/requirements.txt + pip install -r web/requirements.txt + + - name: Run tests + run: | + pytest tests/ -v --tb=short + + - name: Run tests with coverage + run: | + pytest tests/ --cov=. --cov-report=term --cov-report=xml + + - name: Upload coverage reports + uses: codecov/codecov-action@v4 + if: success() + with: + file: ./coverage.xml + fail_ci_if_error: false diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 00000000..6f94355f --- /dev/null +++ b/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +testpaths = tests +asyncio_mode = auto diff --git a/tests/requirements.txt b/tests/requirements.txt new file mode 100644 index 00000000..32d72cde --- /dev/null +++ b/tests/requirements.txt @@ -0,0 +1,5 @@ + +pytest-asyncio==0.21.1 +pytest-cov==4.1.0 +httpx==0.25.2 +pytest-mock==3.12.0 diff --git a/tests/web/conftest.py b/tests/web/conftest.py new file mode 100644 index 00000000..d7aaaeb0 --- /dev/null +++ b/tests/web/conftest.py @@ -0,0 +1,315 @@ +""" +Pytest configuration and shared fixtures for end-to-end tests. + +This module provides fixtures for setting up test environment including: +- Test client with FastAPI app +- Mock git repository +- Predefined remotes.json to avoid version fetching +- Remote reload authentication tokens +""" +import os +import json +import tempfile +import shutil +from typing import Generator +from unittest.mock import Mock + +import pytest +from fastapi.testclient import TestClient + + +# Sample remotes.json for testing - no version fetching needed +TEST_REMOTES_JSON = [ + { + "name": "test-remote-1", + "url": "https://github.com/test/ardupilot.git", + "vehicles": [ + { + "name": "Copter", + "releases": [ + { + "release_type": "latest", + "version_number": "4.6.0", + "commit_reference": "refs/heads/master" + }, + { + "release_type": "stable", + "version_number": "4.3.0", + "commit_reference": "refs/tags/Copter-4.3.0" + } + ] + }, + { + "name": "Plane", + "releases": [ + { + "release_type": "latest", + "version_number": "4.5.0", + "commit_reference": "refs/heads/master" + } + ] + } + ] + }, + { + "name": "test-remote-2", + "url": "https://github.com/another/ardupilot.git", + "vehicles": [ + { + "name": "Rover", + "releases": [ + { + "release_type": "Custom", + "version_number": "Custom", + "commit_reference": "refs/tags/Rover-4.2.0" + } + ] + } + ] + } +] + + +@pytest.fixture(scope="session") +def test_base_dir() -> Generator[str, None, None]: + """ + Create a temporary base directory structure for testing. + + Yields: + str: Path to the temporary base directory + """ + temp_dir = tempfile.mkdtemp(prefix="custombuild_test_") + + # Create required subdirectories + subdirs = ["artifacts", "configs", "workdir", "secrets", "ardupilot"] + for subdir in subdirs: + os.makedirs(os.path.join(temp_dir, subdir), exist_ok=True) + + # Create remotes.json with test data + remotes_json_path = os.path.join(temp_dir, "configs", "remotes.json") + with open(remotes_json_path, "w") as f: + json.dump(TEST_REMOTES_JSON, f, indent=2) + + # Create remote reload token file + token_file_path = os.path.join(temp_dir, "secrets", "reload_token") + with open(token_file_path, "w") as f: + f.write("test-remote-reload-token-12345") + + yield temp_dir + + # Cleanup + shutil.rmtree(temp_dir, ignore_errors=True) + + +@pytest.fixture(scope="session") +def test_remote_reload_token() -> str: + """ + Return the test remote reload token. + + Returns: + str: Test remote reload authentication token + """ + return "test-remote-reload-token-12345" + + +@pytest.fixture +def mock_git_repo(): + """ + Create a mock GitRepo object. + + Returns: + Mock: Mock GitRepo instance + """ + mock_repo = Mock() + mock_repo.path = "/tmp/test/ardupilot" + mock_repo.get_current_commit_hash.return_value = "abc123def456" + mock_repo.checkout_commit.return_value = True + mock_repo.get_tags.return_value = ["Copter-4.3.0", "Copter-4.4.0"] + return mock_repo + + +@pytest.fixture +def mock_versions_fetcher(test_base_dir): + """ + Create a mock VersionsFetcher that doesn't actually fetch versions. + + This allows tests to run without starting background threads or + making actual git operations. + + Args: + test_base_dir: Test base directory fixture + + Returns: + Mock: Mock VersionsFetcher instance + """ + from metadata_manager.versions_fetcher import RemoteInfo + + mock_fetcher = Mock() + + # Mock the reload_remotes_json method + mock_fetcher.reload_remotes_json = Mock(return_value=None) + + # Mock get_all_remotes_info to return test remotes + test_remotes = [ + RemoteInfo(name="test-remote-1", url="https://github.com/test/ardupilot.git"), + RemoteInfo(name="test-remote-2", url="https://github.com/another/ardupilot.git") + ] + mock_fetcher.get_all_remotes_info = Mock(return_value=test_remotes) + + # Mock start/stop methods (no-op for tests) + mock_fetcher.start = Mock() + mock_fetcher.stop = Mock() + + return mock_fetcher + + +@pytest.fixture +def mock_build_manager(): + """ + Create a mock BuildManager for testing. + + Returns: + Mock: Mock BuildManager instance + """ + mock_manager = Mock() + mock_manager.submit_build = Mock(return_value="test-build-id-123") + mock_manager.get_build_progress = Mock(return_value={ + "build_id": "test-build-id-123", + "status": "queued", + "progress": 0 + }) + return mock_manager + + +@pytest.fixture +def mock_vehicles_manager(): + """ + Create a mock VehiclesManager for testing. + + Returns: + Mock: Mock VehiclesManager instance + """ + mock_manager = Mock() + mock_manager.get_vehicle_names = Mock(return_value=["Copter", "Plane", "Rover"]) + return mock_manager + + +@pytest.fixture +def mock_cleaner_and_updater(): + """ + Create mock instances for BuildArtifactsCleaner and BuildProgressUpdater. + + Returns: + tuple: (mock_cleaner, mock_updater) + """ + mock_cleaner = Mock() + mock_cleaner.start = Mock() + mock_cleaner.stop = Mock() + + mock_updater = Mock() + mock_updater.start = Mock() + mock_updater.stop = Mock() + + return mock_cleaner, mock_updater + + +@pytest.fixture +def app_with_mocked_dependencies( + test_base_dir, + mock_git_repo, + mock_versions_fetcher, + mock_build_manager, + mock_vehicles_manager, + mock_cleaner_and_updater +): + """ + Create a FastAPI app instance with mocked dependencies. + + This fixture sets up the application without requiring actual: + - Git repository cloning + - Version fetching background tasks + - Redis connection + - Build artifacts + + Args: + test_base_dir: Test base directory + mock_git_repo: Mock git repository + mock_versions_fetcher: Mock versions fetcher + mock_build_manager: Mock build manager + mock_vehicles_manager: Mock vehicles manager + mock_cleaner_and_updater: Mock cleaner and updater + + Yields: + FastAPI: Configured FastAPI application instance + """ + from contextlib import asynccontextmanager + from fastapi import FastAPI + from api.v1 import router as v1_router + + mock_cleaner, mock_updater = mock_cleaner_and_updater + + # Set environment variables for test configuration + os.environ["CBS_BASEDIR"] = test_base_dir + os.environ["CBS_REDIS_HOST"] = "localhost" + os.environ["CBS_REDIS_PORT"] = "6379" + os.environ["CBS_ENABLE_INBUILT_BUILDER"] = "0" # Disable builder for tests + + @asynccontextmanager + async def test_lifespan(app: FastAPI): + """Test lifespan that doesn't start background tasks.""" + # Setup: Attach mocked dependencies to app state + app.state.repo = mock_git_repo + app.state.versions_fetcher = mock_versions_fetcher + app.state.vehicles_manager = mock_vehicles_manager + app.state.build_manager = mock_build_manager + + # Create mock AP source metadata fetcher + mock_ap_src_fetcher = Mock() + app.state.ap_src_metadata_fetcher = mock_ap_src_fetcher + + # Don't start background tasks in test mode + # versions_fetcher.start() - SKIPPED + # cleaner.start() - SKIPPED + # progress_updater.start() - SKIPPED + + yield + + # Shutdown - also skipped for tests + # No cleanup needed since we didn't start anything + + # Create app with test lifespan + app = FastAPI(title="CustomBuild Test API", lifespan=test_lifespan) + + # Include routers + app.include_router(v1_router, prefix="/api") + + return app + + +@pytest.fixture +def client(app_with_mocked_dependencies) -> Generator[TestClient, None, None]: + """ + Create a TestClient for making requests to the app. + + Args: + app_with_mocked_dependencies: FastAPI app with mocked dependencies + + Yields: + TestClient: Test client for making API requests + """ + with TestClient(app_with_mocked_dependencies) as test_client: + yield test_client + + +@pytest.fixture +def auth_headers(test_remote_reload_token) -> dict: + """ + Create authorization headers with bearer token. + + Args: + test_remote_reload_token: Test remote reload token + + Returns: + dict: Headers with Authorization bearer token + """ + return {"Authorization": f"Bearer {test_remote_reload_token}"} diff --git a/tests/web/test_admin_api.py b/tests/web/test_admin_api.py new file mode 100644 index 00000000..e3ec9a81 --- /dev/null +++ b/tests/web/test_admin_api.py @@ -0,0 +1,88 @@ +""" +End-to-end tests for the Admin API endpoints. +""" +from fastapi import status + + +class TestAdminRefreshRemotesEndpoint: + """Test suite for the /admin/refresh_remotes endpoint.""" + + def test_refresh_remotes_success(self, client, auth_headers, test_base_dir): + """Test successful refresh of remotes with valid auth and verifies against remotes.json.""" + import os + import json + + remotes_file = os.path.join(test_base_dir, "configs", "remotes.json") + assert os.path.exists(remotes_file) + + with open(remotes_file, "r") as f: + initial_remotes = json.load(f) + + response = client.post( + "/api/v1/admin/refresh_remotes", + headers=auth_headers + ) + + assert response.status_code == status.HTTP_200_OK + assert "application/json" in response.headers["content-type"] + + data = response.json() + + assert len(data["remotes"]) == len(initial_remotes) + expected_names = [r["name"] for r in initial_remotes] + for name in expected_names: + assert name in data["remotes"] + + def test_refresh_remotes_no_auth(self, client): + """Test refresh without authentication - should fail.""" + response = client.post("/api/v1/admin/refresh_remotes") + + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + def test_refresh_remotes_invalid_token(self, client): + """Test refresh with invalid token - should fail.""" + response = client.post( + "/api/v1/admin/refresh_remotes", + headers={"Authorization": "Bearer invalid-token-xyz"} + ) + + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + data = response.json() + assert "detail" in data + assert "Invalid authentication token" in data["detail"] + + def test_refresh_remotes_malformed_auth_header(self, client): + """Test refresh with malformed authorization header.""" + response = client.post( + "/api/v1/admin/refresh_remotes", + headers={"Authorization": "test-remote-reload-token-12345"} + ) + + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + def test_refresh_remotes_empty_token(self, client): + """Test refresh with empty token.""" + response = client.post( + "/api/v1/admin/refresh_remotes", + headers={"Authorization": "Bearer "} + ) + + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + def test_refresh_remotes_method_not_allowed(self, client, auth_headers): + """Test that only POST method is allowed.""" + disallowed_methods = [ + ("GET", client.get), + ("PUT", client.put), + ("PATCH", client.patch), + ("DELETE", client.delete), + ] + + for method_name, method_func in disallowed_methods: + response = method_func( + "/api/v1/admin/refresh_remotes", + headers=auth_headers + ) + assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED, \ + f"{method_name} should return 405" diff --git a/tests/web/test_admin_service.py b/tests/web/test_admin_service.py new file mode 100644 index 00000000..1fbb865e --- /dev/null +++ b/tests/web/test_admin_service.py @@ -0,0 +1,57 @@ +""" +End-to-end tests for the Admin Service. +""" +import pytest +from unittest.mock import Mock + +from web.services.admin import AdminService + + +class TestAdminService: + """Test suite for AdminService business logic.""" + @pytest.mark.asyncio + async def test_verify_token_success(self): + """Test successful token verification.""" + admin_service = AdminService(remote_reload_token="valid-token") + result = await admin_service.verify_remote_reload_token("valid-token") + assert result is True + + @pytest.mark.asyncio + async def test_verify_token_failure(self): + """Test token verification with incorrect token.""" + admin_service = AdminService(remote_reload_token="valid-token") + result = await admin_service.verify_remote_reload_token("invalid-token") + assert result is False + + @pytest.mark.asyncio + async def test_refresh_remotes_success(self, mock_versions_fetcher): + """Test successful refresh of remote metadata.""" + admin_service = AdminService( + remote_reload_token="some-token", + versions_fetcher=mock_versions_fetcher + ) + remotes = await admin_service.refresh_remotes() + + assert len(remotes) == 2 + assert "test-remote-1" in remotes + assert "test-remote-2" in remotes + + mock_versions_fetcher.reload_remotes_json.assert_called_once() + mock_versions_fetcher.get_all_remotes_info.assert_called_once() + + @pytest.mark.asyncio + async def test_refresh_remotes_empty_result(self): + """Test refresh when no remotes are configured.""" + mock_fetcher = Mock() + mock_fetcher.reload_remotes_json = Mock() + mock_fetcher.get_all_remotes_info = Mock(return_value=[]) + + admin_service = AdminService( + remote_reload_token="some-token", + versions_fetcher=mock_fetcher + ) + + remotes = await admin_service.refresh_remotes() + + assert len(remotes) == 0 + mock_fetcher.reload_remotes_json.assert_called_once() diff --git a/tests/web/test_config.py b/tests/web/test_config.py new file mode 100644 index 00000000..3456546f --- /dev/null +++ b/tests/web/test_config.py @@ -0,0 +1,117 @@ +""" +Tests for the configuration module. +""" +import os +from unittest.mock import patch + +from web.core.config import Settings + + +class TestSettings: + """Test suite for Settings class.""" + + def test_default_settings(self): + """Test that default settings are initialized correctly.""" + with patch.dict(os.environ, {}, clear=True): + settings = Settings() + + assert settings.app_name == "CustomBuild API" + assert settings.app_version == "1.0.0" + assert settings.debug is False + assert settings.redis_host == "localhost" + assert settings.redis_port == "6379" + assert settings.log_level == "INFO" + assert settings.ap_git_url == "https://github.com/ardupilot/ardupilot.git" + assert settings.enable_inbuilt_builder is True + + def test_env_var_overrides(self): + """Test that environment variables override default settings.""" + env_overrides = { + "CBS_BASEDIR": "/custom/base/path", + "CBS_REDIS_HOST": "redis.example.com", + "CBS_REDIS_PORT": "6380", + "CBS_LOG_LEVEL": "DEBUG", + "CBS_ENABLE_INBUILT_BUILDER": "0" + } + + with patch.dict(os.environ, env_overrides, clear=True): + settings = Settings() + + assert settings.base_dir == "/custom/base/path" + assert settings.redis_host == "redis.example.com" + assert settings.redis_port == "6380" + assert settings.log_level == "DEBUG" + assert settings.enable_inbuilt_builder is False + + +class TestRemoteReloadToken: + """Test suite for remote_reload_token property.""" + + def test_token_from_file(self, tmp_path): + """Test that token is read from file when it exists.""" + secrets_dir = tmp_path / 'secrets' + secrets_dir.mkdir() + token_file = secrets_dir / 'reload_token' + + expected_token = "test-token-from-file" + token_file.write_text(f" {expected_token} \n") # Test whitespace stripping + + with patch.dict(os.environ, {"CBS_BASEDIR": str(tmp_path)}, clear=True): + settings = Settings() + assert settings.remote_reload_token == expected_token + + def test_token_file_takes_precedence_over_env(self, tmp_path): + """Test that token from file takes precedence over environment variable.""" + secrets_dir = tmp_path / 'secrets' + secrets_dir.mkdir() + token_file = secrets_dir / 'reload_token' + + file_token = "token-from-file" + env_token = "token-from-env" + + token_file.write_text(file_token) + + with patch.dict(os.environ, { + "CBS_BASEDIR": str(tmp_path), + "CBS_REMOTES_RELOAD_TOKEN": env_token + }, clear=True): + settings = Settings() + assert settings.remote_reload_token == file_token + + def test_token_from_env_when_file_not_found(self, tmp_path): + """Test that token falls back to environment variable when file doesn't exist.""" + expected_token = "test-token-from-env" + + with patch.dict(os.environ, { + "CBS_BASEDIR": str(tmp_path), + "CBS_REMOTES_RELOAD_TOKEN": expected_token + }, clear=True): + settings = Settings() + assert settings.remote_reload_token == expected_token + + def test_token_from_env_on_file_read_error(self, tmp_path): + """Test that token falls back to env var when file cannot be read.""" + env_token = "env-fallback-token" + + with patch.dict(os.environ, { + "CBS_BASEDIR": str(tmp_path), + "CBS_REMOTES_RELOAD_TOKEN": env_token + }, clear=True): + with patch("builtins.open", side_effect=PermissionError("No access")): + settings = Settings() + assert settings.remote_reload_token == env_token + + def test_token_none_when_not_configured(self, tmp_path): + """Test that token is None when neither file nor env var is set.""" + with patch.dict(os.environ, {"CBS_BASEDIR": str(tmp_path)}, clear=True): + settings = Settings() + assert settings.remote_reload_token is None + + def test_token_none_when_env_is_empty_string(self, tmp_path): + """Test that token is None when env var is empty string.""" + with patch.dict(os.environ, { + "CBS_BASEDIR": str(tmp_path), + "CBS_REMOTES_RELOAD_TOKEN": "" + }, clear=True): + settings = Settings() + assert settings.remote_reload_token is None diff --git a/web/api/v1/admin.py b/web/api/v1/admin.py index 5ce7e897..19b28b99 100644 --- a/web/api/v1/admin.py +++ b/web/api/v1/admin.py @@ -6,15 +6,15 @@ router = APIRouter(prefix="/admin", tags=["admin"]) -security = HTTPBearer() +security = HTTPBearer(auto_error=False) -async def verify_admin_token( +async def verify_remote_reload_token( credentials: HTTPAuthorizationCredentials = Depends(security), admin_service: AdminService = Depends(get_admin_service) ) -> None: """ - Verify the bearer token for admin authentication. + Verify the bearer token for remote reload authentication. Args: credentials: HTTP authorization credentials from request header @@ -24,9 +24,15 @@ async def verify_admin_token( 401: Invalid or missing token 500: Server configuration error (token not configured) """ + if credentials is None or credentials.scheme.lower() != "bearer": + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Missing or invalid authentication token" + ) + token = credentials.credentials try: - if not await admin_service.verify_token(token): + if not await admin_service.verify_remote_reload_token(token): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid authentication token" @@ -52,7 +58,7 @@ async def verify_admin_token( } ) async def refresh_remotes( - _: None = Depends(verify_admin_token), + _: None = Depends(verify_remote_reload_token), admin_service: AdminService = Depends(get_admin_service) ): """ diff --git a/web/core/config.py b/web/core/config.py index f322b0ef..3b3f31ab 100644 --- a/web/core/config.py +++ b/web/core/config.py @@ -2,8 +2,11 @@ Application configuration and settings. """ import os +import logging from pathlib import Path -from functools import lru_cache +from typing import Optional + +logger = logging.getLogger(__name__) class Settings: @@ -62,24 +65,42 @@ def remotes_json_path(self) -> str: """Path to remotes.json configuration.""" return os.path.join(self.base_dir, 'configs', 'remotes.json') - @property - def admin_token_file_path(self) -> str: - """Path to admin token secret file.""" - return os.path.join(self.base_dir, 'secrets', 'reload_token') - @property def enable_inbuilt_builder(self) -> bool: """Whether to enable the inbuilt builder.""" return os.getenv('CBS_ENABLE_INBUILT_BUILDER', '1') == '1' @property - def admin_token_env(self) -> str: - """Token required to reload remotes.json via API.""" - env = os.getenv('CBS_REMOTES_RELOAD_TOKEN', '') - return env if env != '' else None + def remote_reload_token(self) -> Optional[str]: + """ + Get remote reload token from file or environment variable. + + Tries to read token from file first, falls back to environment variable. + + Returns: + The authorization token if found, None otherwise + """ + token_file_path = os.path.join(self.base_dir, 'secrets', 'reload_token') + + try: + # Try to read the secret token from the file + with open(token_file_path, 'r') as file: + token = file.read().strip() + return token + except (FileNotFoundError, PermissionError): + # If the file does not exist or no permission, check environment + env_token = os.getenv('CBS_REMOTES_RELOAD_TOKEN', '') + return env_token if env_token != '' else None + except Exception as e: + logger.error( + f"Unexpected error reading token file at {token_file_path}: {e}. " + "Checking environment for token." + ) + # For any other error, fall back to environment variable + env_token = os.getenv('CBS_REMOTES_RELOAD_TOKEN', None) + return env_token if env_token != '' else None -@lru_cache() def get_settings() -> Settings: - """Get cached settings instance.""" + """Get settings instance.""" return Settings() diff --git a/web/services/admin.py b/web/services/admin.py index 83aa79f1..3c238fb0 100644 --- a/web/services/admin.py +++ b/web/services/admin.py @@ -2,11 +2,10 @@ Admin service for handling administrative operations. """ import logging -from typing import Optional, List +from typing import List from fastapi import Request -from core.config import get_settings logger = logging.getLogger(__name__) @@ -14,66 +13,31 @@ class AdminService: """Service for managing administrative operations.""" - def __init__(self, versions_fetcher=None): + def __init__(self, remote_reload_token: str, versions_fetcher=None): """ Initialize the admin service. Args: + remote_reload_token: Remote reload authentication token versions_fetcher: VersionsFetcher instance for managing remotes """ + self.remote_reload_token = remote_reload_token self.versions_fetcher = versions_fetcher - self.settings = get_settings() - def get_auth_token(self) -> Optional[str]: + async def verify_remote_reload_token(self, token: str) -> bool: """ - Retrieve the authorization token from file or environment. - - Returns: - The authorization token if found, None otherwise - """ - try: - # Try to read the secret token from the file - token_file_path = self.settings.admin_token_file_path - with open(token_file_path, 'r') as file: - token = file.read().strip() - return token - except (FileNotFoundError, PermissionError) as e: - logger.error( - f"Couldn't open token file at " - f"{self.settings.admin_token_file_path}: {e}. " - "Checking environment for token." - ) - # If the file does not exist or no permission, check environment - return self.settings.admin_token_env - except Exception as e: - logger.error( - f"Unexpected error reading token file at " - f"{self.settings.admin_token_file_path}: {e}. " - "Checking environment for token." - ) - # For any other error, fall back to environment variable - return self.settings.admin_token_env - - async def verify_token(self, token: str) -> bool: - """ - Verify that the provided token matches the expected admin token. + Verify that the provided token matches the expected remote reload token. Args: token: The token to verify Returns: True if token is valid, False otherwise - - Raises: - RuntimeError: If admin token is not configured on server """ - expected_token = self.get_auth_token() + if not token: + return False - if expected_token is None: - logger.error("No admin token configured") - raise RuntimeError("Admin token not configured on server") - - return token == expected_token + return token == self.remote_reload_token async def refresh_remotes(self) -> List[str]: """ @@ -111,5 +75,19 @@ def get_admin_service(request: Request) -> AdminService: Returns: AdminService instance initialized with app state dependencies + + Raises: + RuntimeError: If remote reload token is not configured """ - return AdminService(versions_fetcher=request.app.state.versions_fetcher) + from core.config import get_settings + + settings = get_settings() + remote_reload_token = settings.remote_reload_token + + if remote_reload_token is None: + raise RuntimeError("Remote reload token not configured on server") + + return AdminService( + remote_reload_token=remote_reload_token, + versions_fetcher=request.app.state.versions_fetcher + )