"""Validation rules for script parameters (type coercion, regex pattern).""" from __future__ import annotations import pytest from fastapi import HTTPException from media_server.config import ScriptParameterConfig from media_server.routes.scripts import _validate_params def _defs(**kwargs) -> dict[str, ScriptParameterConfig]: return {name: ScriptParameterConfig(**spec) for name, spec in kwargs.items()} def test_unknown_param_rejected(): with pytest.raises(HTTPException) as ei: _validate_params({"x": "1"}, _defs()) assert ei.value.status_code == 400 assert "Unknown" in ei.value.detail def test_missing_required_rejected(): defs = _defs(name={"type": "string", "required": True}) with pytest.raises(HTTPException, match="missing"): _validate_params({}, defs) def test_integer_coercion_and_bounds(): defs = _defs(volume={"type": "integer", "min": 0, "max": 100}) out = _validate_params({"volume": "42"}, defs) assert out == {"SCRIPT_PARAM_VOLUME": "42"} with pytest.raises(HTTPException, match="<="): _validate_params({"volume": 200}, defs) with pytest.raises(HTTPException, match=">="): _validate_params({"volume": -1}, defs) with pytest.raises(HTTPException, match="integer"): _validate_params({"volume": "not-a-number"}, defs) def test_boolean_coercion(): defs = _defs(flag={"type": "boolean"}) assert _validate_params({"flag": "true"}, defs) == {"SCRIPT_PARAM_FLAG": "True"} assert _validate_params({"flag": "no"}, defs) == {"SCRIPT_PARAM_FLAG": "False"} with pytest.raises(HTTPException, match="boolean"): _validate_params({"flag": "maybe"}, defs) def test_select_rejects_non_option(): defs = _defs(mode={"type": "select", "options": ["a", "b", "c"]}) assert _validate_params({"mode": "a"}, defs) == {"SCRIPT_PARAM_MODE": "a"} with pytest.raises(HTTPException, match="must be one of"): _validate_params({"mode": "z"}, defs) def test_pattern_enforced_on_string(): """Regex pattern is the defence against shell metachars in shell=true scripts.""" defs = _defs(host={"type": "string", "pattern": r"^[a-z0-9.\-]+$"}) assert _validate_params({"host": "example.com"}, defs) == {"SCRIPT_PARAM_HOST": "example.com"} with pytest.raises(HTTPException, match="pattern"): _validate_params({"host": "evil & calc.exe"}, defs) with pytest.raises(HTTPException, match="pattern"): _validate_params({"host": "$(rm -rf /)"}, defs) def test_pattern_can_disallow_empty(): defs = _defs(host={"type": "string", "pattern": r"^[a-z]+$"}) with pytest.raises(HTTPException, match="pattern"): _validate_params({"host": ""}, defs) def test_invalid_pattern_in_config_fails_closed(): defs = _defs(host={"type": "string", "pattern": r"["}) # unmatched bracket with pytest.raises(HTTPException) as ei: _validate_params({"host": "x"}, defs) assert ei.value.status_code == 500