diff --git a/api/libs/helper.py b/api/libs/helper.py index 752342bfad6..0992553442a 100644 --- a/api/libs/helper.py +++ b/api/libs/helper.py @@ -289,7 +289,11 @@ UUIDStr = Annotated[str, AfterValidator(_strict_uuid)] def alphanumeric(value: str): # check if the value is alphanumeric and underlined - if re.match(r"^[a-zA-Z0-9_]+$", value): + # Use re.fullmatch instead of re.match to reject trailing newlines. + # In Python, '$' matches at end-of-string OR just before a trailing newline, + # so re.match accepts "tool_name\n". re.fullmatch requires the entire + # string to match. Regression for #39666 (sibling of #39234 / #39548). + if re.fullmatch(r"^[a-zA-Z0-9_]+$", value): return value raise ValueError(f"{value} is not a valid alphanumeric value") diff --git a/api/tests/unit_tests/libs/test_helper.py b/api/tests/unit_tests/libs/test_helper.py index dbc2cd6cba8..d5f78e67abc 100644 --- a/api/tests/unit_tests/libs/test_helper.py +++ b/api/tests/unit_tests/libs/test_helper.py @@ -2,7 +2,7 @@ from datetime import datetime import pytest -from libs.helper import OptionalTimestampField, email, escape_like_pattern, extract_tenant_id +from libs.helper import OptionalTimestampField, alphanumeric, email, escape_like_pattern, extract_tenant_id from models.account import Account from models.model import EndUser @@ -153,3 +153,47 @@ class TestEmailValidator: def test_invalid_email_rejected(self): with pytest.raises(ValueError, match="not a valid email"): email("not-an-email") + + +class TestAlphanumericValidator: + """Tests for the alphanumeric() validator — regression for #39666.""" + + def test_valid_alphanumeric_accepted(self): + assert alphanumeric("tool_name") == "tool_name" + assert alphanumeric("Tool123") == "Tool123" + assert alphanumeric("_underscore_start") == "_underscore_start" + assert alphanumeric("a") == "a" + + def test_trailing_newline_rejected(self): + # re.match with $ accepts a trailing \n in Python; re.fullmatch does not. + # This was the pre-fix behaviour: alphanumeric("tool\n") returned "tool\n". + with pytest.raises(ValueError, match="not a valid alphanumeric value"): + alphanumeric("tool_name\n") + + def test_trailing_carriage_return_rejected(self): + with pytest.raises(ValueError, match="not a valid alphanumeric value"): + alphanumeric("tool_name\r") + + def test_trailing_crlf_rejected(self): + with pytest.raises(ValueError, match="not a valid alphanumeric value"): + alphanumeric("tool_name\r\n") + + def test_leading_newline_rejected(self): + with pytest.raises(ValueError, match="not a valid alphanumeric value"): + alphanumeric("\ntool_name") + + def test_embedded_whitespace_rejected(self): + with pytest.raises(ValueError, match="not a valid alphanumeric value"): + alphanumeric("tool name") + + def test_empty_string_rejected(self): + with pytest.raises(ValueError, match="not a valid alphanumeric value"): + alphanumeric("") + + def test_special_characters_rejected(self): + with pytest.raises(ValueError, match="not a valid alphanumeric value"): + alphanumeric("tool-name") + with pytest.raises(ValueError, match="not a valid alphanumeric value"): + alphanumeric("tool.name") + with pytest.raises(ValueError, match="not a valid alphanumeric value"): + alphanumeric("tool/name")