# SPDX-License-Identifier: Apache-2.0 """Tests for server alias support: /admin/api/server-info endpoint and ``server_aliases`` save/validate path in /admin/api/global-settings.""" import asyncio from contextlib import contextmanager from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch import pytest from fastapi import HTTPException import omlx.admin.routes as admin_routes import omlx.server # noqa: F401 — ensure server module is imported first (triggers set_admin_getters) from omlx.admin.routes import GlobalSettingsRequest from omlx.utils.network import ( detect_server_aliases, is_valid_alias, is_valid_bind_host, is_valid_hostname, is_valid_ip, ) # ============================================================================= # Helpers # ============================================================================= def _make_global_settings( server_aliases: list[str] | None = None, host: str = "127.0.0.1" ): """Build a MagicMock GlobalSettings with the fields the alias paths touch.""" gs = MagicMock() gs.server.host = host gs.server.port = 8000 gs.server.log_level = "info" gs.server.server_aliases = list(server_aliases or []) gs.server.preserve_mid_system_cache = True # Validation is invoked at the end of update_global_settings; return no errors. gs.validate.return_value = [] gs.save.return_value = None return gs @contextmanager def _patched_global_settings(gs): """Patch the module-level _get_global_settings getter without disturbing others.""" original = admin_routes._get_global_settings admin_routes._get_global_settings = lambda: gs try: yield finally: admin_routes._get_global_settings = original # ============================================================================= # Unit tests for omlx.utils.network # ============================================================================= class TestNetworkValidation: """Validation primitives used by the alias save path.""" def test_valid_ipv4(self): assert is_valid_ip("192.168.1.10") assert is_valid_ip("127.0.0.1") def test_valid_ipv6(self): assert is_valid_ip("::1") assert is_valid_ip("fe80::1") def test_rejects_unspecified_ipv4(self): """0.0.0.0 parses as a valid IP but is not routable as an alias.""" assert not is_valid_ip("0.0.0.0") def test_rejects_unspecified_ipv6(self): """:: is the IPv6 unspecified address — also not usable as an alias.""" assert not is_valid_ip("::") def test_rejects_garbage(self): assert not is_valid_ip("not-an-ip") assert not is_valid_ip("999.999.999.999") def test_valid_hostname(self): assert is_valid_hostname("example.local") assert is_valid_hostname("my-mac") assert is_valid_hostname("a.b.c.d") assert is_valid_hostname("web1.local") def test_rejects_invalid_hostname(self): assert not is_valid_hostname("") assert not is_valid_hostname("with space") assert not is_valid_hostname("-leading-dash") assert not is_valid_hostname("a" * 300) def test_rejects_all_numeric_last_label_in_dotted_names(self): # For dotted (multi-label) names: IANA never delegates numeric TLDs, # so an all-digit rightmost label signals an IP-shaped string. # Mirrors the approach used by the ``validators`` PyPI library. assert not is_valid_hostname("999.999.999.999") assert not is_valid_hostname("1.2.3.4") assert not is_valid_hostname("host.123") def test_accepts_single_label_without_letters(self): # Single-label names (no dots) are local hostnames — no TLD constraint. assert is_valid_hostname("192-168-1-1") assert is_valid_hostname("web1") def test_alias_accepts_either(self): assert is_valid_alias("localhost") assert is_valid_alias("192.168.1.10") assert is_valid_alias("foo.local") assert is_valid_alias("::1") def test_alias_rejects_unspecified(self): assert not is_valid_alias("0.0.0.0") assert not is_valid_alias("::") def test_alias_rejects_non_string(self): assert not is_valid_alias(None) # type: ignore[arg-type] assert not is_valid_alias(123) # type: ignore[arg-type] class TestIsValidBindHost: """is_valid_bind_host() accepts IPs (including unspecified) and hostnames, but must reject IP-shaped values that fail IP parsing.""" # ------------------------------------------------------------------ # Valid IPv4 — including unspecified/wildcard addresses that are # rejected by is_valid_alias() but are legitimate bind targets. # ------------------------------------------------------------------ def test_accepts_regular_ipv4(self): assert is_valid_bind_host("127.0.0.1") assert is_valid_bind_host("192.168.1.10") assert is_valid_bind_host("10.0.0.255") assert is_valid_bind_host("255.255.255.255") def test_accepts_wildcard_ipv4(self): assert is_valid_bind_host("0.0.0.0") # ------------------------------------------------------------------ # Valid IPv6 — the ip-shaped guard uses a digit+dot regex so it # never interferes with colon-containing IPv6 addresses. # ------------------------------------------------------------------ def test_accepts_wildcard_ipv6(self): assert is_valid_bind_host("::") def test_accepts_loopback_ipv6(self): assert is_valid_bind_host("::1") def test_accepts_link_local_ipv6(self): assert is_valid_bind_host("fe80::1") def test_accepts_full_ipv6(self): assert is_valid_bind_host("2001:db8::1") def test_accepts_ipv4_mapped_ipv6(self): # ::ffff:192.168.1.1 is valid IPv6 and contains dots, but the # colon means it reaches ipaddress.ip_address() first and parses fine. assert is_valid_bind_host("::ffff:192.168.1.1") # ------------------------------------------------------------------ # Valid hostnames # ------------------------------------------------------------------ def test_accepts_simple_hostname(self): assert is_valid_bind_host("localhost") assert is_valid_bind_host("my-host") def test_accepts_fqdn(self): assert is_valid_bind_host("my-host.local") assert is_valid_bind_host("example.com") def test_accepts_hostname_with_leading_digit_label(self): # Numeric-prefixed labels are valid hostnames (e.g. "web1.local") assert is_valid_bind_host("web1.local") def test_accepts_hostname_with_dashes_instead_of_dots(self): # "192-168-1-1" looks IP-like but uses dashes — valid hostname, # does not match the digit-dot regex. assert is_valid_bind_host("192-168-1-1") def test_accepts_all_letter_dotted_hostname(self): # Labels a.b.c.d contain letters so they don't match the ip-shaped guard. assert is_valid_bind_host("a.b.c.d") # ------------------------------------------------------------------ # Rejected: IP-shaped strings that fail IP parsing # The bug: ipaddress.ip_address() raises ValueError, and digit-only # dotted labels also match the hostname regex — so without the guard # they would be accepted silently. # ------------------------------------------------------------------ def test_rejects_ipv4_all_octets_out_of_range(self): assert not is_valid_bind_host("999.999.999.999") def test_rejects_ipv4_first_octet_out_of_range(self): assert not is_valid_bind_host("256.0.0.1") def test_rejects_ipv4_last_octet_out_of_range(self): assert not is_valid_bind_host("1.2.3.999") def test_rejects_ip_shaped_too_few_octets(self): # 3-part and 2-part dotted numeric strings look IP-shaped but are # not valid IPs and must not slip through as hostnames. assert not is_valid_bind_host("1.2.3") assert not is_valid_bind_host("1.2") def test_rejects_ip_shaped_too_many_octets(self): assert not is_valid_bind_host("1.2.3.4.5") # ------------------------------------------------------------------ # Rejected: malformed hostnames # ------------------------------------------------------------------ def test_rejects_leading_dash(self): assert not is_valid_bind_host("-bad-host") def test_rejects_hostname_with_space(self): assert not is_valid_bind_host("with space") def test_rejects_label_too_long(self): assert not is_valid_bind_host("a" * 64 + ".local") def test_rejects_value_too_long(self): assert not is_valid_bind_host("a." * 127 + "b") def test_rejects_invalid_ipv6_form(self): # Colon-containing but not a valid IP — falls through to hostname, # which rejects colons. assert not is_valid_bind_host(":invalid:") assert not is_valid_bind_host("[::1]") # ------------------------------------------------------------------ # Rejected: empty / wrong types # ------------------------------------------------------------------ def test_rejects_empty_string(self): assert not is_valid_bind_host("") def test_rejects_whitespace_only(self): assert not is_valid_bind_host(" ") def test_rejects_non_string(self): assert not is_valid_bind_host(None) # type: ignore[arg-type] assert not is_valid_bind_host(8080) # type: ignore[arg-type] # ------------------------------------------------------------------ # Whitespace stripping # ------------------------------------------------------------------ def test_strips_surrounding_whitespace(self): assert is_valid_bind_host(" 127.0.0.1 ") assert is_valid_bind_host(" localhost ") class TestDetectServerAliases: """Auto-detection should always return at least loopback when bound to localhost.""" def test_localhost_includes_loopback(self): aliases = detect_server_aliases(host="127.0.0.1") assert "localhost" in aliases assert "127.0.0.1" in aliases def test_no_unspecified_in_output(self): """Even when bound to 0.0.0.0, detection should not return 0.0.0.0 itself.""" aliases = detect_server_aliases(host="0.0.0.0") assert "0.0.0.0" not in aliases assert "::" not in aliases def test_returns_unique_values(self): aliases = detect_server_aliases() assert len(aliases) == len(set(aliases)) def test_comma_separated_host_includes_loopback(self): """Comma-separated bind hosts containing a loopback must not drop localhost aliases.""" aliases = detect_server_aliases(host="127.0.0.1, ::1") assert "localhost" in aliases assert "127.0.0.1" in aliases def test_comma_separated_wildcard_includes_loopback(self): aliases = detect_server_aliases(host="0.0.0.0, ::1") assert "localhost" in aliases assert "127.0.0.1" in aliases def test_comma_separated_non_loopback_skips_loopback(self): """If no part of the comma-separated host is a loopback/wildcard, no loopback aliases.""" aliases = detect_server_aliases(host="192.168.1.10, 10.0.0.1") assert "localhost" not in aliases # ============================================================================= # /admin/api/server-info endpoint # ============================================================================= class TestServerInfoEndpoint: """get_server_info: returns persisted aliases or falls back to detection.""" def test_returns_persisted_aliases(self): gs = _make_global_settings( server_aliases=["my-mac.local", "192.168.1.10", "localhost"], host="127.0.0.1", ) with _patched_global_settings(gs): result = asyncio.run(admin_routes.get_server_info(is_admin=True)) assert result["host"] == "127.0.0.1" assert result["port"] == 8000 assert result["aliases"] == ["my-mac.local", "192.168.1.10", "localhost"] def test_falls_back_to_detection_when_empty(self): """Empty persisted list → live auto-detection kicks in.""" gs = _make_global_settings(server_aliases=[], host="127.0.0.1") with _patched_global_settings(gs): result = asyncio.run(admin_routes.get_server_info(is_admin=True)) # Auto-detection always returns at least the loopback pair. assert "localhost" in result["aliases"] assert "127.0.0.1" in result["aliases"] def test_returns_503_when_settings_unavailable(self): with _patched_global_settings(None): with pytest.raises(HTTPException) as exc_info: asyncio.run(admin_routes.get_server_info(is_admin=True)) assert exc_info.value.status_code == 503 # ============================================================================= # /admin/api/global-settings save path for server_aliases # ============================================================================= class TestUpdateGlobalSettingsAliases: """update_global_settings: saving server_aliases with validation.""" def test_saves_valid_aliases(self): gs = _make_global_settings(server_aliases=[]) request = GlobalSettingsRequest(server_aliases=["custom.local", "10.0.0.5"]) with _patched_global_settings(gs): result = asyncio.run( admin_routes.update_global_settings(request=request, is_admin=True) ) assert result["success"] is True assert "server_aliases" in result["runtime_applied"] assert gs.server.server_aliases == ["custom.local", "10.0.0.5"] gs.save.assert_called_once() def test_strips_whitespace_and_dedupes(self): gs = _make_global_settings(server_aliases=[]) request = GlobalSettingsRequest( server_aliases=[" foo.local ", "foo.local", "10.0.0.5", " "], ) with _patched_global_settings(gs): asyncio.run( admin_routes.update_global_settings(request=request, is_admin=True) ) assert gs.server.server_aliases == ["foo.local", "10.0.0.5"] def test_rejects_invalid_alias_with_400(self): gs = _make_global_settings(server_aliases=[]) request = GlobalSettingsRequest( server_aliases=["valid.local", "not valid!!!"], ) with _patched_global_settings(gs): with pytest.raises(HTTPException) as exc_info: asyncio.run( admin_routes.update_global_settings(request=request, is_admin=True) ) assert exc_info.value.status_code == 400 assert "not valid!!!" in exc_info.value.detail gs.save.assert_not_called() def test_rejects_unspecified_address_with_400(self): """0.0.0.0 must be rejected — bind address, not a routable URL host.""" gs = _make_global_settings(server_aliases=[]) request = GlobalSettingsRequest(server_aliases=["0.0.0.0"]) with _patched_global_settings(gs): with pytest.raises(HTTPException) as exc_info: asyncio.run( admin_routes.update_global_settings(request=request, is_admin=True) ) assert exc_info.value.status_code == 400 assert "0.0.0.0" in exc_info.value.detail gs.save.assert_not_called() def test_rejects_ipv6_unspecified_with_400(self): gs = _make_global_settings(server_aliases=[]) request = GlobalSettingsRequest(server_aliases=["::"]) with _patched_global_settings(gs): with pytest.raises(HTTPException) as exc_info: asyncio.run( admin_routes.update_global_settings(request=request, is_admin=True) ) assert exc_info.value.status_code == 400 def test_accepts_ipv6_loopback(self): gs = _make_global_settings(server_aliases=[]) request = GlobalSettingsRequest(server_aliases=["::1"]) with _patched_global_settings(gs): asyncio.run( admin_routes.update_global_settings(request=request, is_admin=True) ) assert gs.server.server_aliases == ["::1"] def test_empty_list_clears_aliases(self): gs = _make_global_settings(server_aliases=["existing.local"]) request = GlobalSettingsRequest(server_aliases=[]) with _patched_global_settings(gs): asyncio.run( admin_routes.update_global_settings(request=request, is_admin=True) ) assert gs.server.server_aliases == [] class TestUpdateGlobalSettingsHotCache: """update_global_settings: validate hot cache size before runtime apply.""" def test_rejects_hot_cache_auto_with_400(self): gs = _make_global_settings() gs.cache.enabled = True gs.cache.hot_cache_max_size = "0" request = GlobalSettingsRequest(cache_enabled=False, hot_cache_max_size="auto") with _patched_global_settings(gs): with pytest.raises(HTTPException) as exc_info: asyncio.run( admin_routes.update_global_settings(request=request, is_admin=True) ) assert exc_info.value.status_code == 400 assert "hot_cache_max_size" in exc_info.value.detail assert "auto" in exc_info.value.detail assert gs.cache.enabled is True assert gs.cache.hot_cache_max_size == "0" gs.save.assert_not_called() class TestUpdateGlobalSettingsMidSystemCache: """update_global_settings: save the mid-system prefix-cache fallback toggle.""" def test_saves_disabled_mid_system_cache_fallback(self): gs = _make_global_settings() request = GlobalSettingsRequest(preserve_mid_system_cache=False) with _patched_global_settings(gs): result = asyncio.run( admin_routes.update_global_settings(request=request, is_admin=True) ) assert result["success"] is True assert "preserve_mid_system_cache" in result["runtime_applied"] assert gs.server.preserve_mid_system_cache is False gs.save.assert_called_once() class TestUpdateGlobalSettingsSampling: """update_global_settings: saving and hot-applying sampling defaults.""" @staticmethod def _make_sampling_settings(policy: int | None = None): return SimpleNamespace( max_context_window=32768, max_context_window_policy=policy, max_tokens=32768, temperature=1.0, top_p=0.95, top_k=0, repetition_penalty=1.0, ) def test_saves_and_hot_applies_context_window_policy(self): gs = MagicMock() gs.sampling = self._make_sampling_settings() gs.validate.return_value = [] gs.save.return_value = None server_state = SimpleNamespace(sampling=self._make_sampling_settings()) request = GlobalSettingsRequest(sampling_max_context_window_policy=128000) with ( _patched_global_settings(gs), patch.object( omlx.server, "_server_state", server_state, ), ): result = asyncio.run( admin_routes.update_global_settings(request=request, is_admin=True) ) assert result["success"] is True assert "sampling" in result["runtime_applied"] assert gs.sampling.max_context_window_policy == 128000 assert server_state.sampling.max_context_window_policy == 128000 gs.save.assert_called_once() def test_explicit_null_clears_context_window_policy(self): gs = MagicMock() gs.sampling = self._make_sampling_settings(policy=128000) gs.validate.return_value = [] gs.save.return_value = None server_state = SimpleNamespace( sampling=self._make_sampling_settings(policy=128000) ) request = GlobalSettingsRequest(sampling_max_context_window_policy=None) with ( _patched_global_settings(gs), patch.object( omlx.server, "_server_state", server_state, ), ): result = asyncio.run( admin_routes.update_global_settings(request=request, is_admin=True) ) assert "sampling_max_context_window_policy" in request.model_fields_set assert result["success"] is True assert "sampling" in result["runtime_applied"] assert gs.sampling.max_context_window_policy is None assert server_state.sampling.max_context_window_policy is None gs.save.assert_called_once() class TestUpdateGlobalSettingsEmbeddingBatchSize: """update_global_settings: saving and hot-applying embedding batch size.""" def _make_scheduler_settings(self): return SimpleNamespace( max_concurrent_requests=8, embedding_batch_size=32, chunked_prefill=False, ) def test_saves_and_hot_applies_embedding_batch_size(self): gs = MagicMock() gs.scheduler = self._make_scheduler_settings() gs.validate.return_value = [] gs.save.return_value = None pool = SimpleNamespace(apply_embedding_batch_size=AsyncMock()) server_state = SimpleNamespace(engine_pool=pool) request = GlobalSettingsRequest(embedding_batch_size=5) with ( _patched_global_settings(gs), patch.object( omlx.server, "_server_state", server_state, ), ): result = asyncio.run( admin_routes.update_global_settings(request=request, is_admin=True) ) assert result["success"] is True assert "embedding_batch_size" in result["runtime_applied"] assert gs.scheduler.embedding_batch_size == 5 pool.apply_embedding_batch_size.assert_awaited_once_with(5) gs.save.assert_called_once() def test_rejects_invalid_embedding_batch_size(self): gs = MagicMock() gs.scheduler = self._make_scheduler_settings() gs.validate.return_value = [] gs.save.return_value = None request = GlobalSettingsRequest(embedding_batch_size=0) with _patched_global_settings(gs): with pytest.raises(HTTPException) as exc_info: asyncio.run( admin_routes.update_global_settings(request=request, is_admin=True) ) assert exc_info.value.status_code == 400 assert "embedding_batch_size" in exc_info.value.detail gs.save.assert_not_called() def test_does_not_hot_apply_embedding_batch_size_when_validation_fails(self): gs = MagicMock() gs.scheduler = self._make_scheduler_settings() gs.validate.return_value = ["invalid unrelated setting"] gs.save.return_value = None pool = SimpleNamespace(apply_embedding_batch_size=AsyncMock()) server_state = SimpleNamespace(engine_pool=pool) request = GlobalSettingsRequest(embedding_batch_size=5) with ( _patched_global_settings(gs), patch.object( omlx.server, "_server_state", server_state, ), ): with pytest.raises(HTTPException) as exc_info: asyncio.run( admin_routes.update_global_settings(request=request, is_admin=True) ) assert exc_info.value.status_code == 400 pool.apply_embedding_batch_size.assert_not_awaited() assert gs.scheduler.embedding_batch_size == 32 gs.save.assert_not_called() def test_does_not_mutate_embedding_batch_size_when_api_key_is_invalid(self): gs = MagicMock() gs.scheduler = self._make_scheduler_settings() gs.validate.return_value = [] gs.save.return_value = None pool = SimpleNamespace(apply_embedding_batch_size=AsyncMock()) server_state = SimpleNamespace(engine_pool=pool) request = GlobalSettingsRequest(embedding_batch_size=5, api_key="abc") with ( _patched_global_settings(gs), patch.object( omlx.server, "_server_state", server_state, ), ): with pytest.raises(HTTPException) as exc_info: asyncio.run( admin_routes.update_global_settings(request=request, is_admin=True) ) assert exc_info.value.status_code == 400 pool.apply_embedding_batch_size.assert_not_awaited() assert gs.scheduler.embedding_batch_size == 32 gs.save.assert_not_called() def test_does_not_hot_apply_embedding_batch_size_when_save_fails(self): gs = MagicMock() gs.scheduler = self._make_scheduler_settings() gs.validate.return_value = [] gs.save.side_effect = OSError("disk full") pool = SimpleNamespace(apply_embedding_batch_size=AsyncMock()) server_state = SimpleNamespace(engine_pool=pool) request = GlobalSettingsRequest(embedding_batch_size=5) with ( _patched_global_settings(gs), patch.object( omlx.server, "_server_state", server_state, ), ): with pytest.raises(HTTPException) as exc_info: asyncio.run( admin_routes.update_global_settings(request=request, is_admin=True) ) assert exc_info.value.status_code == 500 pool.apply_embedding_batch_size.assert_not_awaited() assert gs.scheduler.embedding_batch_size == 32