# SPDX-License-Identifier: Apache-2.0 """Tests for admin authentication and chat page API key injection.""" import asyncio import time from pathlib import Path from unittest.mock import MagicMock, patch import pytest from fastapi import HTTPException import omlx.server # noqa: F401 — ensure server module is imported first import omlx.admin.auth as admin_auth import omlx.admin.routes as admin_routes def _mock_global_settings(api_key=None): """Create a mock GlobalSettings with the given API key.""" mock = MagicMock() mock.auth.api_key = api_key mock.auth.skip_api_key_verification = False return mock def _patch_getter(mock_settings): """Replace the module-level _get_global_settings with a lambda returning mock.""" original = admin_routes._get_global_settings admin_routes._get_global_settings = lambda: mock_settings return original def _restore_getter(original): """Restore the original _get_global_settings.""" admin_routes._get_global_settings = original class TestAutoLogin: """Tests for GET /admin/auto-login endpoint.""" def test_auto_login_success_redirects_to_dashboard(self): """Valid API key should redirect to the specified path with session cookie.""" mock_settings = _mock_global_settings(api_key="test-key") original = _patch_getter(mock_settings) try: result = asyncio.run( admin_routes.auto_login(key="test-key", redirect="/admin/dashboard") ) assert result.status_code == 302 assert result.headers["location"] == "/admin/dashboard" # Check that session cookie is set cookie_header = result.headers.get("set-cookie", "") assert "omlx_admin_session" in cookie_header finally: _restore_getter(original) def test_auto_login_success_redirects_to_chat(self): """Valid API key should redirect to chat page.""" mock_settings = _mock_global_settings(api_key="test-key") original = _patch_getter(mock_settings) try: result = asyncio.run( admin_routes.auto_login(key="test-key", redirect="/admin/chat") ) assert result.status_code == 302 assert result.headers["location"] == "/admin/chat" finally: _restore_getter(original) def test_auto_login_invalid_key_redirects_to_login(self): """Invalid API key should redirect to login page without session cookie.""" mock_settings = _mock_global_settings(api_key="correct-key") original = _patch_getter(mock_settings) try: result = asyncio.run( admin_routes.auto_login(key="wrong-key", redirect="/admin/dashboard") ) assert result.status_code == 302 assert result.headers["location"] == "/admin" cookie_header = result.headers.get("set-cookie", "") assert "omlx_admin_session" not in cookie_header finally: _restore_getter(original) def test_auto_login_empty_key_redirects_to_login(self): """Empty API key should redirect to login page.""" mock_settings = _mock_global_settings(api_key="test-key") original = _patch_getter(mock_settings) try: result = asyncio.run( admin_routes.auto_login(key="", redirect="/admin/dashboard") ) assert result.status_code == 302 assert result.headers["location"] == "/admin" finally: _restore_getter(original) def test_auto_login_no_server_key_redirects_to_login(self): """No server API key configured should redirect to login page.""" mock_settings = _mock_global_settings(api_key=None) original = _patch_getter(mock_settings) try: result = asyncio.run( admin_routes.auto_login(key="any-key", redirect="/admin/dashboard") ) assert result.status_code == 302 assert result.headers["location"] == "/admin" finally: _restore_getter(original) def test_auto_login_invalid_redirect_returns_400(self): """Redirect path not starting with /admin should return 400.""" mock_settings = _mock_global_settings(api_key="test-key") original = _patch_getter(mock_settings) try: with pytest.raises(HTTPException) as exc_info: asyncio.run( admin_routes.auto_login( key="test-key", redirect="https://evil.com" ) ) assert exc_info.value.status_code == 400 assert "Invalid redirect path" in exc_info.value.detail finally: _restore_getter(original) def test_auto_login_redirect_to_admin_root(self): """Redirect to /admin (exact match) should be allowed.""" mock_settings = _mock_global_settings(api_key="test-key") original = _patch_getter(mock_settings) try: result = asyncio.run( admin_routes.auto_login(key="test-key", redirect="/admin") ) assert result.status_code == 302 assert result.headers["location"] == "/admin" finally: _restore_getter(original) class TestLoginPage: """Tests for GET /admin login page TemplateResponse signature.""" def test_login_page_uses_new_template_signature(self): """login_page should pass request as first arg to TemplateResponse.""" mock_settings = _mock_global_settings(api_key="test-key") original = _patch_getter(mock_settings) try: mock_request = MagicMock() with patch("omlx.admin.auth.verify_session", return_value=False): with patch.object(admin_routes, "templates") as mock_templates: mock_templates.TemplateResponse.return_value = MagicMock() asyncio.run(admin_routes.login_page(request=mock_request)) mock_templates.TemplateResponse.assert_called_once_with( mock_request, "login.html", {"api_key_configured": True} ) finally: _restore_getter(original) class TestDashboardPage: """Tests for GET /admin/dashboard TemplateResponse signature.""" def test_dashboard_page_uses_new_template_signature(self): """dashboard_page should pass request as first arg to TemplateResponse.""" mock_request = MagicMock() with patch.object(admin_routes, "templates") as mock_templates: mock_templates.TemplateResponse.return_value = MagicMock() asyncio.run( admin_routes.dashboard_page(request=mock_request, is_admin=True) ) mock_templates.TemplateResponse.assert_called_once_with( mock_request, "dashboard.html", {} ) class TestChatPageApiKeyInjection: """Tests for GET /admin/chat API key template injection.""" def test_chat_page_passes_api_key_in_context(self): """Chat page should include API key in template context.""" mock_settings = _mock_global_settings(api_key="test-chat-key") original = _patch_getter(mock_settings) try: mock_request = MagicMock() with patch.object(admin_routes, "templates") as mock_templates: mock_templates.TemplateResponse.return_value = MagicMock() asyncio.run( admin_routes.chat_page(request=mock_request, is_admin=True) ) mock_templates.TemplateResponse.assert_called_once_with( mock_request, "chat.html", {"api_key": "test-chat-key"}, ) finally: _restore_getter(original) def test_chat_page_passes_empty_when_no_key(self): """Chat page should pass empty string when no API key is configured.""" mock_settings = _mock_global_settings(api_key=None) original = _patch_getter(mock_settings) try: mock_request = MagicMock() with patch.object(admin_routes, "templates") as mock_templates: mock_templates.TemplateResponse.return_value = MagicMock() asyncio.run( admin_routes.chat_page(request=mock_request, is_admin=True) ) call_args = mock_templates.TemplateResponse.call_args context = call_args[0][2] assert context["api_key"] == "" finally: _restore_getter(original) def test_chat_page_passes_empty_when_no_settings(self): """Chat page should pass empty string when global settings is None.""" original = admin_routes._get_global_settings admin_routes._get_global_settings = lambda: None try: mock_request = MagicMock() with patch.object(admin_routes, "templates") as mock_templates: mock_templates.TemplateResponse.return_value = MagicMock() asyncio.run( admin_routes.chat_page(request=mock_request, is_admin=True) ) call_args = mock_templates.TemplateResponse.call_args context = call_args[0][2] assert context["api_key"] == "" finally: admin_routes._get_global_settings = original class TestSkipAdminAuth: """Tests for skipping admin auth when skip_api_key_verification is enabled.""" def _mock_gs(self, skip=True, host="127.0.0.1"): mock = MagicMock() mock.auth.skip_api_key_verification = skip mock.server.host = host return mock def test_require_admin_skipped_on_localhost(self): """require_admin should pass when skip_api_key_verification=True.""" gs = self._mock_gs(skip=True, host="127.0.0.1") original = admin_auth._get_global_settings admin_auth._get_global_settings = lambda: gs try: mock_request = MagicMock() mock_request.cookies.get.return_value = None # No session cookie result = asyncio.run(admin_auth.require_admin(mock_request)) assert result is True finally: admin_auth._get_global_settings = original def test_require_admin_skipped_on_any_host(self): """require_admin should skip auth when skip_api_key_verification=True regardless of host.""" gs = self._mock_gs(skip=True, host="0.0.0.0") original = admin_auth._get_global_settings admin_auth._get_global_settings = lambda: gs try: mock_request = MagicMock() mock_request.cookies.get.return_value = None result = asyncio.run(admin_auth.require_admin(mock_request)) assert result is True finally: admin_auth._get_global_settings = original def test_require_admin_not_skipped_when_disabled(self): """require_admin should still require auth when skip_api_key_verification=False.""" gs = self._mock_gs(skip=False, host="127.0.0.1") original = admin_auth._get_global_settings admin_auth._get_global_settings = lambda: gs try: mock_request = MagicMock() mock_request.cookies.get.return_value = None mock_request.headers.get.return_value = "application/json" with pytest.raises(HTTPException) as exc_info: asyncio.run(admin_auth.require_admin(mock_request)) assert exc_info.value.status_code == 401 finally: admin_auth._get_global_settings = original def test_login_page_redirects_when_skip_enabled(self): """Login page should redirect to dashboard when skip is enabled on localhost.""" gs = MagicMock() gs.auth.skip_api_key_verification = True gs.auth.api_key = "test-key" gs.server.host = "127.0.0.1" original = _patch_getter(gs) try: mock_request = MagicMock() with patch("omlx.admin.auth.verify_session", return_value=False): result = asyncio.run(admin_routes.login_page(request=mock_request)) assert result.status_code == 302 assert result.headers["location"] == "/admin/dashboard" finally: _restore_getter(original) class TestInitAuth: """Tests for init_auth() persistent secret key initialization.""" def test_init_auth_sets_serializer(self): """init_auth should update the serializer with the provided key.""" original_serializer = admin_auth._serializer try: admin_auth.init_auth("test-persistent-secret-key") # Create a token with the new serializer token = admin_auth.create_session_token() assert admin_auth.verify_session_token(token) is True finally: admin_auth._serializer = original_serializer def test_init_auth_env_var_takes_priority(self): """OMLX_SECRET_KEY env var should take priority over provided key.""" original_serializer = admin_auth._serializer original_secret = admin_auth.SECRET_KEY try: with patch.dict("os.environ", {"OMLX_SECRET_KEY": "env-secret-key"}): admin_auth.init_auth("settings-secret-key") assert admin_auth.SECRET_KEY == "env-secret-key" finally: admin_auth._serializer = original_serializer admin_auth.SECRET_KEY = original_secret def test_init_auth_uses_provided_key_when_no_env(self): """Should use provided key when no OMLX_SECRET_KEY env var.""" original_serializer = admin_auth._serializer original_secret = admin_auth.SECRET_KEY try: with patch.dict("os.environ", {}, clear=True): # Remove OMLX_SECRET_KEY if it exists import os os.environ.pop("OMLX_SECRET_KEY", None) admin_auth.init_auth("my-persistent-key") assert admin_auth.SECRET_KEY == "my-persistent-key" finally: admin_auth._serializer = original_serializer admin_auth.SECRET_KEY = original_secret def test_tokens_survive_reinit_with_same_key(self): """Tokens created before re-init should still be valid with same key.""" original_serializer = admin_auth._serializer original_secret = admin_auth.SECRET_KEY try: key = "persistent-key-for-test" admin_auth.init_auth(key) token = admin_auth.create_session_token() # Re-initialize with same key (simulates server restart) admin_auth.init_auth(key) assert admin_auth.verify_session_token(token) is True finally: admin_auth._serializer = original_serializer admin_auth.SECRET_KEY = original_secret def test_tokens_invalid_after_reinit_with_different_key(self): """Tokens should be invalid after re-init with a different key.""" original_serializer = admin_auth._serializer original_secret = admin_auth.SECRET_KEY try: admin_auth.init_auth("key-one") token = admin_auth.create_session_token() admin_auth.init_auth("key-two") assert admin_auth.verify_session_token(token) is False finally: admin_auth._serializer = original_serializer admin_auth.SECRET_KEY = original_secret class TestRememberMe: """Tests for remember me session token functionality.""" def test_create_token_default_no_remember(self): """Default token should not have remember flag.""" token = admin_auth.create_session_token() # Verify it works with default max_age assert admin_auth.verify_session_token(token) is True def test_create_token_with_remember(self): """Token with remember=True should be valid.""" token = admin_auth.create_session_token(remember=True) assert admin_auth.verify_session_token(token) is True def test_remember_token_has_extended_max_age(self): """Remember token should use 30-day max_age for verification.""" token = admin_auth.create_session_token(remember=True) # Manually load the payload to check the remember flag data = admin_auth._serializer.loads(token, max_age=None) assert data["remember"] is True assert data["admin"] is True def test_non_remember_token_payload(self): """Non-remember token should have remember=False in payload.""" token = admin_auth.create_session_token(remember=False) data = admin_auth._serializer.loads(token, max_age=None) assert data["remember"] is False assert data["admin"] is True def test_remember_me_max_age_constant(self): """REMEMBER_ME_MAX_AGE should be 30 days.""" assert admin_auth.REMEMBER_ME_MAX_AGE == 2592000 # 30 * 24 * 60 * 60 def test_session_max_age_constant(self): """SESSION_MAX_AGE should be 24 hours.""" assert admin_auth.SESSION_MAX_AGE == 86400 # 24 * 60 * 60 # ============================================================================= # Update Check # ============================================================================= def _make_async_return(value): """Create a coroutine function that returns the given value.""" async def _coro(*args, **kwargs): return value return _coro class _FakeResponse: """Minimal stand-in for requests.Response.""" def __init__(self, status_code, json_data): self.status_code = status_code self._json = json_data def json(self): return self._json class TestCheckUpdate: """Tests for update-check version filtering.""" def setup_method(self): admin_routes._update_cache = {} admin_routes._update_cache_time = {} admin_routes._UPDATE_PREFS_PATH = Path( "/tmp/omlx-test-missing-update-prefs.json" ) @pytest.mark.asyncio async def test_prerelease_not_shown(self): """Dev/pre-release GitHub releases should not trigger update notification.""" fake_resp = _FakeResponse( 200, [{ "tag_name": "v99.0.0.dev1", "html_url": "https://github.com/jundot/omlx/releases/tag/v99.0.0.dev1", }], ) with patch("omlx.admin.routes.asyncio") as mock_asyncio: mock_asyncio.to_thread = _make_async_return(fake_resp) result = await admin_routes.check_update(is_admin=True) assert result["update_available"] is False assert result["latest_version"] is None @pytest.mark.asyncio async def test_stable_version_shown(self): """Stable GitHub releases should trigger update notification.""" fake_resp = _FakeResponse( 200, [{ "tag_name": "v99.0.0", "html_url": "https://github.com/jundot/omlx/releases/tag/v99.0.0", }], ) with patch("omlx.admin.routes.asyncio") as mock_asyncio: mock_asyncio.to_thread = _make_async_return(fake_resp) result = await admin_routes.check_update(is_admin=True) assert result["update_available"] is True assert result["latest_version"] == "99.0.0" @pytest.mark.asyncio async def test_rc_not_shown(self): """RC releases should not trigger update notification.""" fake_resp = _FakeResponse( 200, [{ "tag_name": "v99.0.0rc1", "html_url": "https://github.com/jundot/omlx/releases/tag/v99.0.0rc1", }], ) with patch("omlx.admin.routes.asyncio") as mock_asyncio: mock_asyncio.to_thread = _make_async_return(fake_resp) result = await admin_routes.check_update(is_admin=True) assert result["update_available"] is False assert result["latest_version"] is None