"""Tests for multi-adapter serving — Part B of v0.22.0.""" from unittest.mock import MagicMock import pytest class TestAdapterValidation: """Test adapter name and path validation.""" def test_valid_adapter_name(self): from soup_cli.commands.serve import _validate_adapter_name assert _validate_adapter_name("my-adapter") is True assert _validate_adapter_name("chat") is True assert _validate_adapter_name("code-v2") is True assert _validate_adapter_name("model123") is True def test_invalid_adapter_name_path_separator(self): from soup_cli.commands.serve import _validate_adapter_name assert _validate_adapter_name("../evil") is False assert _validate_adapter_name("path/to") is False assert _validate_adapter_name("path\\to") is False def test_invalid_adapter_name_special_chars(self): from soup_cli.commands.serve import _validate_adapter_name assert _validate_adapter_name("") is False assert _validate_adapter_name("name with spaces") is False assert _validate_adapter_name("name\x00null") is False def test_adapter_path_traversal_protection(self, tmp_path): from soup_cli.commands.serve import _validate_adapter_path # Valid path under cwd adapter_dir = tmp_path / "adapters" / "chat" adapter_dir.mkdir(parents=True) assert _validate_adapter_path(str(adapter_dir), cwd=str(tmp_path)) is True # Path traversal attempt assert _validate_adapter_path("../../etc/passwd", cwd=str(tmp_path)) is False def test_adapter_path_must_exist(self, tmp_path): from soup_cli.commands.serve import _validate_adapter_path assert _validate_adapter_path( str(tmp_path / "nonexistent"), cwd=str(tmp_path) ) is False class TestParseAdapters: """Test --adapters flag parsing.""" def test_parse_single_adapter(self): from soup_cli.commands.serve import _parse_adapters result = _parse_adapters(["chat=./adapters/chat"]) assert result == {"chat": "./adapters/chat"} def test_parse_multiple_adapters(self): from soup_cli.commands.serve import _parse_adapters result = _parse_adapters([ "chat=./adapters/chat", "code=./adapters/code", "medical=./adapters/med", ]) assert len(result) == 3 assert result["chat"] == "./adapters/chat" assert result["code"] == "./adapters/code" assert result["medical"] == "./adapters/med" def test_parse_invalid_format(self): from soup_cli.commands.serve import _parse_adapters with pytest.raises(ValueError, match="key=path"): _parse_adapters(["invalid_no_equals"]) def test_parse_empty_list(self): from soup_cli.commands.serve import _parse_adapters result = _parse_adapters([]) assert result == {} def test_parse_none(self): from soup_cli.commands.serve import _parse_adapters result = _parse_adapters(None) assert result == {} class TestMultiAdapterApp: """Test multi-adapter FastAPI app.""" @pytest.fixture def multi_adapter_app(self): """Create a FastAPI app with multiple mock adapters.""" try: import fastapi # noqa: F401 except ImportError: pytest.skip("FastAPI not installed") from soup_cli.commands.serve import _create_app mock_model = MagicMock() mock_tokenizer = MagicMock() adapter_map = { "chat": "/fake/path/chat", "code": "/fake/path/code", } app = _create_app( model_obj=mock_model, tokenizer=mock_tokenizer, device="cpu", model_name="test-model", max_tokens_default=256, adapter_map=adapter_map, ) return app def test_adapters_endpoint_exists(self, multi_adapter_app): """GET /v1/adapters should exist.""" routes = [route.path for route in multi_adapter_app.routes] assert "/v1/adapters" in routes def test_adapters_endpoint_lists_adapters(self, multi_adapter_app): """GET /v1/adapters returns loaded adapters.""" try: from fastapi.testclient import TestClient except ImportError: pytest.skip("FastAPI not installed") client = TestClient(multi_adapter_app) response = client.get("/v1/adapters") assert response.status_code == 200 data = response.json() assert "adapters" in data names = [adapter["name"] for adapter in data["adapters"]] assert "chat" in names assert "code" in names # Security: paths should NOT be exposed for adapter in data["adapters"]: assert "path" not in adapter def test_no_adapters_returns_empty(self): """GET /v1/adapters with no adapters returns empty list.""" try: from fastapi.testclient import TestClient except ImportError: pytest.skip("FastAPI not installed") from soup_cli.commands.serve import _create_app mock_model = MagicMock() mock_tokenizer = MagicMock() app = _create_app( model_obj=mock_model, tokenizer=mock_tokenizer, device="cpu", model_name="test-model", max_tokens_default=256, ) client = TestClient(app) response = client.get("/v1/adapters") assert response.status_code == 200 data = response.json() assert data["adapters"] == [] def test_unknown_adapter_returns_404(self, multi_adapter_app): """Request with unknown adapter name returns 404.""" try: from fastapi.testclient import TestClient except ImportError: pytest.skip("FastAPI not installed") client = TestClient(multi_adapter_app) response = client.post( "/v1/chat/completions", json={ "model": "test-model", "messages": [{"role": "user", "content": "Hello"}], "adapter": "nonexistent", }, ) assert response.status_code == 404 def test_app_without_adapters_still_works(self): """App with no adapters should work normally.""" try: import fastapi # noqa: F401 except ImportError: pytest.skip("FastAPI not installed") from soup_cli.commands.serve import _create_app mock_model = MagicMock() mock_tokenizer = MagicMock() app = _create_app( model_obj=mock_model, tokenizer=mock_tokenizer, device="cpu", model_name="test-model", max_tokens_default=256, ) routes = [route.path for route in app.routes] assert "/v1/chat/completions" in routes assert "/v1/adapters" in routes class TestAdapterPathAbsolute: """Test adapter path validation with absolute paths.""" def test_adapter_path_absolute_outside_cwd(self, tmp_path): """Absolute path to real directory outside cwd should be rejected.""" import tempfile from soup_cli.commands.serve import _validate_adapter_path with tempfile.TemporaryDirectory() as external_dir: assert _validate_adapter_path(external_dir, cwd=str(tmp_path)) is False def test_adapters_endpoint_schema_strict(self): """Adapter objects should only contain 'name' key — no path leakage.""" try: from fastapi.testclient import TestClient except ImportError: import pytest pytest.skip("FastAPI not installed") from soup_cli.commands.serve import _create_app app = _create_app( model_obj=MagicMock(), tokenizer=MagicMock(), device="cpu", model_name="test-model", max_tokens_default=256, adapter_map={"chat": "/fake/path"}, ) client = TestClient(app) response = client.get("/v1/adapters") for adapter in response.json()["adapters"]: # name is always present; active (bool) may be present (v0.30.0). # The security invariant is: no path leaks. assert "name" in adapter assert "path" not in adapter assert set(adapter.keys()) <= {"name", "active"} class TestAdaptersBackendRejection: """Test --adapters rejected for non-transformers backends.""" def test_adapters_with_vllm_backend_rejected(self, tmp_path): """--adapters with --backend vllm should fail.""" from typer.testing import CliRunner from soup_cli.cli import app runner = CliRunner() model_dir = tmp_path / "model" model_dir.mkdir() adapter_dir = tmp_path / "adapter" adapter_dir.mkdir() result = runner.invoke(app, [ "serve", "--model", str(model_dir), "--backend", "vllm", "--adapters", f"chat={adapter_dir}", ]) assert result.exit_code != 0 assert "transformers" in result.output.lower() or "not" in result.output.lower() class TestMultiAdapterCLI: """Test multi-adapter CLI flags.""" def test_adapters_flag_invalid_format(self, tmp_path): """--adapters with invalid format shows error.""" from typer.testing import CliRunner from soup_cli.cli import app runner = CliRunner() model_dir = tmp_path / "model" model_dir.mkdir() result = runner.invoke(app, [ "serve", "--model", str(model_dir), "--adapters", "invalid_no_equals", ]) assert result.exit_code != 0 def test_adapters_flag_bad_name(self, tmp_path): """--adapters with invalid adapter name shows error.""" from typer.testing import CliRunner from soup_cli.cli import app runner = CliRunner() model_dir = tmp_path / "model" model_dir.mkdir() adapter_dir = tmp_path / "adapter" adapter_dir.mkdir() result = runner.invoke(app, [ "serve", "--model", str(model_dir), "--adapters", f"../evil={adapter_dir}", ]) assert result.exit_code != 0