From 76e0a649768d8072cfbf2ad91dc8703156a7c2b1 Mon Sep 17 00:00:00 2001 From: guenhter Date: Thu, 12 Feb 2026 18:28:02 +0100 Subject: [PATCH 1/4] feat: support switching module files --- frontend_omni/Dockerfile | 14 ++++++++++---- frontend_omni/resources/Caddyfile | 4 ++-- frontend_omni/resources/entrypoint.sh | 19 +++++++++++++++++++ .../compose-files/compose-onmi-full.yaml | 4 ++-- 4 files changed, 33 insertions(+), 8 deletions(-) create mode 100755 frontend_omni/resources/entrypoint.sh diff --git a/frontend_omni/Dockerfile b/frontend_omni/Dockerfile index 05bc020..0468cf0 100644 --- a/frontend_omni/Dockerfile +++ b/frontend_omni/Dockerfile @@ -3,7 +3,6 @@ ################# FROM node:24-slim AS build -# Set working directory WORKDIR /app # Copy package files @@ -21,19 +20,26 @@ COPY . . # Build the app RUN pnpm build +# Ensure modules.json is not baked into the image +RUN rm -f /app/dist/modules.json + ################# ## Final stage ## ################# FROM caddy:alpine +WORKDIR /app + # Copy built app from build stage -COPY --from=build /app/dist /usr/share/caddy/html +COPY --from=build /app/dist /app -# Copy Caddyfile +# Copy other resources COPY resources/Caddyfile /etc/caddy/Caddyfile +COPY resources/entrypoint.sh /entrypoint.sh EXPOSE 80 EXPOSE 443 -# Start Caddy +# Create modules.json at runtime, then start Caddy +ENTRYPOINT ["/entrypoint.sh"] CMD ["caddy", "run", "--config", "/etc/caddy/Caddyfile"] diff --git a/frontend_omni/resources/Caddyfile b/frontend_omni/resources/Caddyfile index 764a6c5..0dfb8e0 100644 --- a/frontend_omni/resources/Caddyfile +++ b/frontend_omni/resources/Caddyfile @@ -4,8 +4,8 @@ localhost { } handle { - root * /usr/share/caddy/html + root * /app try_files {path} {path}/ /index.html file_server - } + } } diff --git a/frontend_omni/resources/entrypoint.sh b/frontend_omni/resources/entrypoint.sh new file mode 100755 index 0000000..42f2024 --- /dev/null +++ b/frontend_omni/resources/entrypoint.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env sh +set -e + +modules_dir="/app" +modules_file="$modules_dir/modules_${MODULES_FILE}.json" +fallback_modules_file="$modules_dir/modules_with_backend.json" + +if [ ! -f "$modules_file" ]; then + modules_file="$fallback_modules_file" +fi + +if [ ! -f "$modules_file" ]; then + echo "Module definition file not found: $modules_file" + exit 1 +fi + +cp -f "$modules_file" "$modules_dir/modules.json" + +exec "$@" diff --git a/resources/compose-files/compose-onmi-full.yaml b/resources/compose-files/compose-onmi-full.yaml index f478072..8a13fa3 100644 --- a/resources/compose-files/compose-onmi-full.yaml +++ b/resources/compose-files/compose-onmi-full.yaml @@ -1,10 +1,10 @@ --- services: backend: - image: gcr.io//modAI-systems/modai-chat-backend:latest + image: ghcr.io/modai-systems/modai-chat-backend:latest frontend: - image: gcr.io//modAI-systems/modai-chat-frontend-omni:latest + image: ghcr.io/modai-systems/modai-chat-frontend:latest environment: - API_BACKEND_URL=http://backend:8080 ports: From 12f010166c9c1a826a34bb67ede5607ec39b96d2 Mon Sep 17 00:00:00 2001 From: guenhter Date: Thu, 12 Feb 2026 18:44:45 +0100 Subject: [PATCH 2/4] chore: run container as non root --- backend/Dockerfile | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/backend/Dockerfile b/backend/Dockerfile index 40ed95a..dc82fd3 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -18,11 +18,16 @@ RUN uv sync --no-dev ################# FROM python:3.13-alpine +# Create non-root user +RUN addgroup -S appgroup && adduser -S appuser -G appgroup + # Copy the app from build stage -COPY --from=build /app /app +COPY --from=build --chown=appuser:appgroup /app /app WORKDIR /app +USER appuser + EXPOSE 8080 CMD ["/app/.venv/bin/uvicorn", "modai.main:app", "--host", "0.0.0.0", "--port", "8080"] From cb9ad1df4ea5a8741bc8a5ae9dced5ef3850984c Mon Sep 17 00:00:00 2001 From: guenhter Date: Thu, 12 Feb 2026 20:32:36 +0100 Subject: [PATCH 3/4] fix: secure endpoints --- backend/config.yaml | 3 + .../src/modai/modules/chat/web_chat_router.py | 7 ++ .../modules/model_provider/central_router.py | 24 +++++-- .../modai/modules/model_provider/module.py | 34 +++++++--- .../modules/model_provider/openai_provider.py | 48 ++++++++++---- .../test_central_model_provider_router.py | 63 ++++++++++++++---- backend/tests/test_chat.py | 57 +++++++++++++++- backend/tests/test_health.py | 7 ++ backend/tests/test_model_provider.py | 65 ++++++++++++++++++- 9 files changed, 266 insertions(+), 42 deletions(-) diff --git a/backend/config.yaml b/backend/config.yaml index df8cc6d..722149d 100644 --- a/backend/config.yaml +++ b/backend/config.yaml @@ -8,6 +8,7 @@ modules: openai: chat_openai module_dependencies: chat_openai: chat_openai + session: "session" chat_openai: class: modai.modules.chat.openai_llm_chat.OpenAILLMChatModule module_dependencies: @@ -21,10 +22,12 @@ modules: class: modai.modules.model_provider.openai_provider.OpenAIProviderModule module_dependencies: llm_provider_store: "model_provider_store" + session: "session" central_model_provider_router: class: modai.modules.model_provider.central_router.CentralModelProviderRouter module_dependencies: openai_provider: "openai_model_provider" + session: "session" user_store: class: modai.modules.user_store.sql_model_user_store.SQLAlchemyUserStore config: diff --git a/backend/src/modai/modules/chat/web_chat_router.py b/backend/src/modai/modules/chat/web_chat_router.py index b1d6611..c391429 100644 --- a/backend/src/modai/modules/chat/web_chat_router.py +++ b/backend/src/modai/modules/chat/web_chat_router.py @@ -3,6 +3,7 @@ from typing import Any, Dict, cast from modai.module import ModuleDependencies from .module import ChatLLMModule, ChatWebModule as ChatWebModuleBase +from modai.modules.session.module import SessionModule import openai from openai.types.responses import ResponseCreateParams @@ -15,6 +16,10 @@ class ChatWebModule(ChatWebModuleBase): def __init__(self, dependencies: ModuleDependencies, config: dict[str, Any]): super().__init__(dependencies, config) # Router is already set up in base class with response_model=None + self.session_module: SessionModule = dependencies.modules.get("session") + if not self.session_module: + raise ValueError("ChatWebModule requires a 'session' module dependency") + clients_config = config.get("clients", {}) self.clients: Dict[str, ChatLLMModule] = {} for prefix, module_name in clients_config.items(): @@ -34,6 +39,8 @@ async def responses_endpoint( """ Routes the chat request to the appropriate LLM module based on the model prefix. """ + self.session_module.validate_session_for_http(request) + model = body_json.get("model", "") if not model: diff --git a/backend/src/modai/modules/model_provider/central_router.py b/backend/src/modai/modules/model_provider/central_router.py index c0d0874..6e8da9a 100644 --- a/backend/src/modai/modules/model_provider/central_router.py +++ b/backend/src/modai/modules/model_provider/central_router.py @@ -4,7 +4,7 @@ """ from typing import Any, List, Optional -from fastapi import APIRouter, Query +from fastapi import APIRouter, Query, Request from pydantic import BaseModel from modai.module import ModaiModule, ModuleDependencies @@ -13,6 +13,7 @@ ModelProviderModule, Model, ) +from modai.modules.session.module import SessionModule class ModelsListResponse(BaseModel): @@ -40,6 +41,12 @@ def __init__(self, dependencies: ModuleDependencies, config: dict[str, Any]): super().__init__(dependencies, config) self.router = APIRouter() + self.session_module: SessionModule = dependencies.modules.get("session") + if not self.session_module: + raise ValueError( + "CentralModelProviderRouter requires a 'session' module dependency" + ) + # Add the central route for getting all providers self.router.add_api_route( "/api/v1/models/providers", @@ -56,6 +63,7 @@ def __init__(self, dependencies: ModuleDependencies, config: dict[str, Any]): async def get_all_providers( self, + request: Request, limit: Optional[int] = Query( None, ge=1, le=1000, description="Maximum number of providers to return" ), @@ -73,6 +81,8 @@ async def get_all_providers( Returns: ModelProvidersAllResponse with providers list and pagination info """ + self.session_module.validate_session_for_http(request) + all_providers = [] # Get all provider modules from dependencies @@ -87,7 +97,7 @@ async def get_all_providers( # Call the get_providers method on each provider module # But we need to modify it to not apply pagination per module providers_response = await provider_module.get_providers( - limit=None, offset=None + request, limit=None, offset=None ) all_providers.extend(providers_response.providers) except Exception as e: @@ -111,7 +121,7 @@ async def get_all_providers( offset=offset, ) - async def get_all_models(self) -> ModelsListResponse: + async def get_all_models(self, request: Request) -> ModelsListResponse: """ Get all models from all providers across all provider types. Returns in OpenAI-compatible format. @@ -119,6 +129,8 @@ async def get_all_models(self) -> ModelsListResponse: Returns: ModelsListResponse with all available models """ + self.session_module.validate_session_for_http(request) + all_models = [] # Get all provider modules from dependencies @@ -131,13 +143,15 @@ async def get_all_models(self) -> ModelsListResponse: try: # Get all providers for this module type providers_response = await provider_module.get_providers( - limit=None, offset=None + request, limit=None, offset=None ) # For each provider, get its models for provider in providers_response.providers: try: - models_response = await provider_module.get_models(provider.id) + models_response = await provider_module.get_models( + request, provider.id + ) # Add models with prefixed IDs to avoid conflicts for model_data in models_response.data: diff --git a/backend/src/modai/modules/model_provider/module.py b/backend/src/modai/modules/model_provider/module.py index 2e31bf8..cd5b86e 100644 --- a/backend/src/modai/modules/model_provider/module.py +++ b/backend/src/modai/modules/model_provider/module.py @@ -5,7 +5,7 @@ from abc import ABC, abstractmethod from typing import Any, List, Optional -from fastapi import APIRouter, Query +from fastapi import APIRouter, Query, Request from pydantic import BaseModel from modai.module import ModaiModule, ModuleDependencies @@ -116,6 +116,7 @@ def __init__( @abstractmethod async def get_providers( self, + request: Request, limit: Optional[int] = Query( None, ge=1, le=1000, description="Maximum number of providers to return" ), @@ -127,6 +128,7 @@ async def get_providers( Get all model providers with optional pagination. Args: + request: FastAPI request object limit: Maximum number of providers to return offset: Number of providers to skip @@ -134,91 +136,107 @@ async def get_providers( ModelProvidersListResponse: List of providers with pagination info Raises: + HTTPException: 401 if not authenticated HTTPException: 500 if retrieval fails """ pass @abstractmethod - async def get_provider(self, provider_id: str) -> ModelProviderResponse: + async def get_provider( + self, request: Request, provider_id: str + ) -> ModelProviderResponse: """ Get a specific model provider by ID. Args: + request: FastAPI request object provider_id: Unique identifier for the provider Returns: ModelProviderResponse: Provider data Raises: + HTTPException: 401 if not authenticated HTTPException: 404 if provider not found, 500 if retrieval fails """ pass @abstractmethod async def create_provider( - self, request: ModelProviderCreateRequest + self, request: Request, provider_data: ModelProviderCreateRequest ) -> ModelProviderResponse: """ Create a new model provider. Args: - request: Provider data + request: FastAPI request object + provider_data: Provider data Returns: ModelProviderResponse: Created provider data Raises: + HTTPException: 401 if not authenticated HTTPException: 400 for validation errors, 409 for conflicts, 500 for other failures """ pass @abstractmethod async def update_provider( - self, provider_id: str, request: ModelProviderCreateRequest + self, + request: Request, + provider_id: str, + provider_data: ModelProviderCreateRequest, ) -> ModelProviderResponse: """ Update an existing model provider. Args: + request: FastAPI request object provider_id: The ID of the provider to update - request: Provider data + provider_data: Provider data Returns: ModelProviderResponse: Updated provider data Raises: + HTTPException: 401 if not authenticated HTTPException: 400 for validation errors, 404 if provider not found, 409 for conflicts, 500 for other failures """ pass @abstractmethod - async def get_models(self, provider_id: str) -> ModelResponse: + async def get_models(self, request: Request, provider_id: str) -> ModelResponse: """ Get available models from a specific provider. Args: + request: FastAPI request object provider_id: Unique identifier for the provider Returns: ModelResponse: Models data from the provider Raises: + HTTPException: 401 if not authenticated HTTPException: 404 if provider not found, 500 if retrieval fails """ pass @abstractmethod - async def delete_provider(self, provider_id: str) -> None: + async def delete_provider(self, request: Request, provider_id: str) -> None: """ Delete a model provider. Args: + request: FastAPI request object provider_id: ID of the provider to delete Returns: None (204 No Content) Raises: + HTTPException: 401 if not authenticated HTTPException: 500 if deletion fails """ pass diff --git a/backend/src/modai/modules/model_provider/openai_provider.py b/backend/src/modai/modules/model_provider/openai_provider.py index 1697e0e..76de859 100644 --- a/backend/src/modai/modules/model_provider/openai_provider.py +++ b/backend/src/modai/modules/model_provider/openai_provider.py @@ -4,7 +4,7 @@ """ from typing import Any, Optional -from fastapi import HTTPException, Query +from fastapi import HTTPException, Query, Request from openai import OpenAI from modai.module import ModuleDependencies @@ -16,6 +16,7 @@ ModelResponse, ) from modai.modules.model_provider_store.module import ModelProviderStore, ModelProvider +from modai.modules.session.module import SessionModule OPENAI_PROVIDER_TYPE = "openai" @@ -39,8 +40,16 @@ def __init__(self, dependencies: ModuleDependencies, config: dict[str, Any]): f"DefaultModelProviderModule requires '{provider_store_name}' module dependency" ) + # Get session module for authentication + self.session_module: SessionModule = dependencies.modules.get("session") + if not self.session_module: + raise ValueError( + "OpenAIProviderModule requires a 'session' module dependency" + ) + async def get_providers( self, + request: Request, limit: Optional[int] = Query( None, ge=1, le=1000, description="Maximum number of providers to return" ), @@ -49,6 +58,7 @@ async def get_providers( ), ) -> ModelProvidersListResponse: """Get all LLM providers with optional pagination""" + self.session_module.validate_session_for_http(request) providers = await self.provider_store.get_providers(limit=limit, offset=offset) # Convert to response models @@ -61,8 +71,11 @@ async def get_providers( offset=offset, ) - async def get_provider(self, provider_id: str) -> ModelProviderResponse: + async def get_provider( + self, request: Request, provider_id: str + ) -> ModelProviderResponse: """Get a specific LLM provider by ID""" + self.session_module.validate_session_for_http(request) provider = await self.provider_store.get_provider(provider_id) if not provider: raise HTTPException( @@ -73,17 +86,20 @@ async def get_provider(self, provider_id: str) -> ModelProviderResponse: return self._create_provider_response(provider) async def create_provider( - self, request: ModelProviderCreateRequest + self, request: Request, provider_data: ModelProviderCreateRequest ) -> ModelProviderResponse: """Create a new LLM provider""" + self.session_module.validate_session_for_http(request) try: # Merge api_key into properties for storage - properties = (request.properties or {}).copy() - properties["api_key"] = request.api_key + properties = (provider_data.properties or {}).copy() + properties["api_key"] = provider_data.api_key # Create new provider provider = await self.provider_store.add_provider( - name=request.name, url=request.base_url, properties=properties + name=provider_data.name, + url=provider_data.base_url, + properties=properties, ) return self._create_provider_response(provider) @@ -95,19 +111,23 @@ async def create_provider( raise async def update_provider( - self, provider_id: str, request: ModelProviderCreateRequest + self, + request: Request, + provider_id: str, + provider_data: ModelProviderCreateRequest, ) -> ModelProviderResponse: """Update an existing LLM provider""" + self.session_module.validate_session_for_http(request) try: # Merge api_key into properties for storage - properties = (request.properties or {}).copy() - properties["api_key"] = request.api_key + properties = (provider_data.properties or {}).copy() + properties["api_key"] = provider_data.api_key # Update existing provider provider = await self.provider_store.update_provider( provider_id=provider_id, - name=request.name, - url=request.base_url, + name=provider_data.name, + url=provider_data.base_url, properties=properties, ) if not provider: @@ -124,8 +144,9 @@ async def update_provider( # Re-raise HTTPExceptions as-is raise - async def get_models(self, provider_id: str) -> ModelResponse: + async def get_models(self, request: Request, provider_id: str) -> ModelResponse: """Get available models from a specific provider""" + self.session_module.validate_session_for_http(request) # Check if provider exists provider = await self.provider_store.get_provider(provider_id) if not provider: @@ -169,8 +190,9 @@ async def get_models(self, provider_id: str) -> ModelResponse: detail=f"Failed to fetch models from provider '{provider_id}': {str(e)}", ) - async def delete_provider(self, provider_id: str) -> None: + async def delete_provider(self, request: Request, provider_id: str) -> None: """Delete an LLM provider""" + self.session_module.validate_session_for_http(request) await self.provider_store.delete_provider(provider_id) # Return 204 No Content for successful deletion (idempotent) return None diff --git a/backend/tests/test_central_model_provider_router.py b/backend/tests/test_central_model_provider_router.py index ef640af..ad36d68 100644 --- a/backend/tests/test_central_model_provider_router.py +++ b/backend/tests/test_central_model_provider_router.py @@ -5,6 +5,7 @@ import sys import os import pytest +from unittest.mock import MagicMock from fastapi.testclient import TestClient from fastapi import FastAPI @@ -17,6 +18,7 @@ ModelProvidersListResponse, ModelResponse, ) +from modai.modules.session.module import SessionModule, Session from modai.module import ModuleDependencies @@ -31,7 +33,7 @@ def __init__(self, provider_type: str, providers_data: list, models_data: dict): self.providers_data = providers_data self.models_data = models_data - async def get_providers(self, limit=None, offset=None): + async def get_providers(self, request=None, limit=None, offset=None): return ModelProvidersListResponse( providers=self.providers_data, total=len(self.providers_data), @@ -39,24 +41,26 @@ async def get_providers(self, limit=None, offset=None): offset=offset, ) - async def get_provider(self, provider_id: str): + async def get_provider(self, request=None, provider_id: str = ""): for provider in self.providers_data: if provider.id == provider_id: return provider raise Exception("Provider not found") - async def create_provider(self, request): + async def create_provider(self, request=None, provider_data=None): raise NotImplementedError() - async def update_provider(self, provider_id: str, request): + async def update_provider( + self, request=None, provider_id: str = "", provider_data=None + ): raise NotImplementedError() - async def get_models(self, provider_id: str): + async def get_models(self, request=None, provider_id: str = ""): if provider_id in self.models_data: return self.models_data[provider_id] raise Exception("Models not found") - async def delete_provider(self, provider_id: str): + async def delete_provider(self, request=None, provider_id: str = ""): raise NotImplementedError() @@ -129,12 +133,22 @@ def mock_provider_modules(self): return [openai_module, ollama_module] @pytest.fixture - def central_router(self, mock_provider_modules): + def mock_session_module(self): + """Create a mock session module that always validates successfully.""" + session_module = MagicMock(spec=SessionModule) + session_module.validate_session_for_http.return_value = Session( + user_id="test-user", additional={} + ) + return session_module + + @pytest.fixture + def central_router(self, mock_provider_modules, mock_session_module): """Create central router instance""" dependencies = ModuleDependencies( { "openai_provider": mock_provider_modules[0], "ollama_provider": mock_provider_modules[1], + "session": mock_session_module, } ) config = {} @@ -205,9 +219,9 @@ def test_get_all_providers_with_pagination(self, test_client): assert data["limit"] == 1 assert data["offset"] == 0 - def test_get_all_providers_empty(self): + def test_get_all_providers_empty(self, mock_session_module): """Test GET /models/providers with no provider modules""" - dependencies = ModuleDependencies({}) + dependencies = ModuleDependencies({"session": mock_session_module}) config = {} router = CentralModelProviderRouter(dependencies, config) @@ -223,9 +237,9 @@ def test_get_all_providers_empty(self): assert data["providers"] == [] assert data["total"] == 0 - def test_get_all_models_empty(self): + def test_get_all_models_empty(self, mock_session_module): """Test GET /models with no provider modules""" - dependencies = ModuleDependencies({}) + dependencies = ModuleDependencies({"session": mock_session_module}) config = {} router = CentralModelProviderRouter(dependencies, config) @@ -240,3 +254,30 @@ def test_get_all_models_empty(self): assert data["object"] == "list" assert data["data"] == [] + + def test_all_endpoints_reject_unauthenticated_requests(self): + """All central model provider endpoints must return 401 without a valid session.""" + from fastapi import HTTPException + + rejecting_session = MagicMock(spec=SessionModule) + rejecting_session.validate_session_for_http.side_effect = HTTPException( + status_code=401, detail="Missing, invalid or expired session" + ) + + dependencies = ModuleDependencies({"session": rejecting_session}) + router = CentralModelProviderRouter(dependencies, config={}) + + app = FastAPI() + app.include_router(router.router) + client = TestClient(app) + + endpoints = [ + ("GET", "/api/v1/models/providers"), + ("GET", "/api/v1/models"), + ] + + for method, path in endpoints: + response = client.request(method, path) + assert response.status_code == 401, ( + f"{method} {path} returned {response.status_code}, expected 401" + ) diff --git a/backend/tests/test_chat.py b/backend/tests/test_chat.py index d065c75..6f7c13a 100644 --- a/backend/tests/test_chat.py +++ b/backend/tests/test_chat.py @@ -5,13 +5,15 @@ import pytest import pytest_asyncio from openai import AsyncOpenAI -from unittest.mock import Mock, AsyncMock +from unittest.mock import Mock, MagicMock, AsyncMock +from fastapi.testclient import TestClient sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) from modai.module import ModuleDependencies from modai.modules.chat.openai_llm_chat import OpenAILLMChatModule from modai.modules.chat.web_chat_router import ChatWebModule from modai.modules.chat.module import ChatLLMModule +from modai.modules.session.module import SessionModule, Session import openai @@ -262,6 +264,15 @@ async def test_chat_responses_api_streaming(openai_client: AsyncOpenAI, request) assert "Hello" in full_content +def _create_chat_mock_session_module(): + """Create a mock session module that validates successfully.""" + session_module = MagicMock(spec=SessionModule) + session_module.validate_session_for_http.return_value = Session( + user_id="test-user", additional={} + ) + return session_module + + @pytest.mark.asyncio async def test_chat_web_module_routing(): """Test ChatWebModule routing to dummy LLM module.""" @@ -273,9 +284,12 @@ async def test_chat_web_module_routing(): config={}, ) - # Mock dependencies + session_module = _create_chat_mock_session_module() + + # Mock dependencies - include session in modules dict mock_dependencies = Mock(spec=ModuleDependencies) mock_dependencies.get_module.return_value = dummy_module + mock_dependencies.modules = {"session": session_module} # Create ChatWebModule web_module = ChatWebModule( @@ -312,9 +326,12 @@ async def test_chat_web_module_routing_streaming(): config={}, ) - # Mock dependencies + session_module = _create_chat_mock_session_module() + + # Mock dependencies - include session in modules dict mock_dependencies = Mock(spec=ModuleDependencies) mock_dependencies.get_module.return_value = dummy_module + mock_dependencies.modules = {"session": session_module} # Create ChatWebModule web_module = ChatWebModule( @@ -435,3 +452,37 @@ async def test_openai_llm_provider_not_found(): with pytest.raises(ValueError, match="Provider 'nonexistent' not found"): await llm_module.generate_response(request, body_json) + + +def test_responses_endpoint_rejects_unauthenticated_request(): + """The POST /responses endpoint must return 401 without a valid session.""" + from fastapi import FastAPI, HTTPException + + dummy_module = DummyLLMModule( + dependencies=ModuleDependencies(), + config={}, + ) + + rejecting_session = MagicMock(spec=SessionModule) + rejecting_session.validate_session_for_http.side_effect = HTTPException( + status_code=401, detail="Missing, invalid or expired session" + ) + + mock_dependencies = Mock(spec=ModuleDependencies) + mock_dependencies.get_module.return_value = dummy_module + mock_dependencies.modules = {"session": rejecting_session} + + web_module = ChatWebModule( + dependencies=mock_dependencies, + config={"clients": {"dummy": "dummy_module"}}, + ) + + app = FastAPI() + app.include_router(web_module.router) + client = TestClient(app) + + response = client.post( + "/api/v1/responses", + json={"model": "dummy/test_model", "input": "hello"}, + ) + assert response.status_code == 401 diff --git a/backend/tests/test_health.py b/backend/tests/test_health.py index 21f3a5b..5fd4056 100644 --- a/backend/tests/test_health.py +++ b/backend/tests/test_health.py @@ -22,3 +22,10 @@ def test_health_endpoint_returns_healthy_status(client): response = client.get("/api/v1/health") assert response.status_code == 200 assert response.json() == {"status": "healthy"} + + +def test_health_endpoint_requires_no_authentication(client): + """Health endpoint must be accessible without any session or credentials.""" + response = client.get("/api/v1/health") + assert response.status_code == 200 + assert response.json()["status"] == "healthy" diff --git a/backend/tests/test_model_provider.py b/backend/tests/test_model_provider.py index 095790f..f416d76 100644 --- a/backend/tests/test_model_provider.py +++ b/backend/tests/test_model_provider.py @@ -15,7 +15,9 @@ from modai.modules.model_provider.openai_provider import OpenAIProviderModule from modai.modules.model_provider_store.module import ModelProvider, ModelProviderStore +from modai.modules.session.module import SessionModule, Session from modai.module import ModuleDependencies +from unittest.mock import MagicMock from datetime import datetime working_dir = Path.cwd() @@ -57,12 +59,25 @@ def mock_provider_store(self) -> ModelProviderStore: return mock_store + @pytest.fixture + def mock_session_module(self) -> SessionModule: + """Create a mock session module that always validates successfully.""" + session_module = MagicMock(spec=SessionModule) + session_module.validate_session_for_http.return_value = Session( + user_id="test-user", additional={} + ) + return session_module + @pytest.fixture def web_module( - self, mock_provider_store: ModelProviderStore + self, + mock_provider_store: ModelProviderStore, + mock_session_module: SessionModule, ) -> OpenAIProviderModule: """Create web module instance""" - dependencies = ModuleDependencies({"llm_provider_store": mock_provider_store}) + dependencies = ModuleDependencies( + {"llm_provider_store": mock_provider_store, "session": mock_session_module} + ) config = {"llm_provider_store_module": "llm_provider_store"} return OpenAIProviderModule(dependencies, config) @@ -420,3 +435,49 @@ def test_get_models_provider_not_found( assert response.status_code == 404 data = response.json() assert "not found" in data["detail"].lower() + + def test_all_endpoints_reject_unauthenticated_requests( + self, mock_provider_store: ModelProviderStore + ) -> None: + """All model provider endpoints must return 401 without a valid session.""" + from fastapi import HTTPException + + # Create a session module that always rejects + rejecting_session = MagicMock(spec=SessionModule) + rejecting_session.validate_session_for_http.side_effect = HTTPException( + status_code=401, detail="Missing, invalid or expired session" + ) + + dependencies = ModuleDependencies( + {"llm_provider_store": mock_provider_store, "session": rejecting_session} + ) + module = OpenAIProviderModule( + dependencies, {"llm_provider_store_module": "llm_provider_store"} + ) + + app = FastAPI() + app.include_router(module.router) + client = TestClient(app) + + provider_body = { + "name": "Test", + "base_url": "https://api.test.com", + "api_key": "key", + } + + endpoints = [ + ("GET", "/api/v1/models/providers/openai"), + ("POST", "/api/v1/models/providers/openai", provider_body), + ("GET", "/api/v1/models/providers/openai/some-id"), + ("PUT", "/api/v1/models/providers/openai/some-id", provider_body), + ("DELETE", "/api/v1/models/providers/openai/some-id"), + ("GET", "/api/v1/models/providers/openai/some-id/models"), + ] + + for entry in endpoints: + method, path = entry[0], entry[1] + json_body = entry[2] if len(entry) > 2 else None + response = client.request(method, path, json=json_body) + assert response.status_code == 401, ( + f"{method} {path} returned {response.status_code}, expected 401" + ) From e4ee4f41195a79244651b992802e823495720bd3 Mon Sep 17 00:00:00 2001 From: guenhter Date: Fri, 13 Feb 2026 13:47:47 +0100 Subject: [PATCH 4/4] chroe: correct dependencies --- frontend_omni/public/modules_with_backend.json | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/frontend_omni/public/modules_with_backend.json b/frontend_omni/public/modules_with_backend.json index 72456ff..eb11380 100644 --- a/frontend_omni/public/modules_with_backend.json +++ b/frontend_omni/public/modules_with_backend.json @@ -62,7 +62,8 @@ { "id": "llm-context", "type": "GlobalContextProvider", - "path": "@/modules/llm-picker/LLMContextProvider" + "path": "@/modules/llm-picker/LLMContextProvider", + "dependencies": ["flag:sessionActive"] }, { "id": "llm-chat-top-pane", @@ -72,17 +73,20 @@ { "id": "llm-provider-service", "type": "GlobalContextProvider", - "path": "@/modules/llm-provider-service/LLMRestProviderService" + "path": "@/modules/llm-provider-service/LLMRestProviderService", + "dependencies": ["flag:sessionActive"] }, { "id": "chat-side-panels-provider", "type": "GlobalContextProvider", - "path": "@/modules/chat-layout/ChatSidePanelProvider" + "path": "@/modules/chat-layout/ChatSidePanelProvider", + "dependencies": ["flag:sessionActive"] }, { "id": "global-settings-sidebar-footer", "type": "SidebarFooterItem", - "path": "@/modules/settings/SidebarFooterItemGlobal" + "path": "@/modules/settings/SidebarFooterItemGlobal", + "dependencies": ["flag:sessionActive"] }, { "id": "global-settings-router", @@ -106,7 +110,8 @@ { "id": "openai-chat-service", "type": "openai", - "path": "@/modules/chat-service/WithBackendOpenAIService" + "path": "@/modules/chat-service/WithBackendOpenAIService", + "dependencies": ["flag:sessionActive"] } ] }