aminghadersohi commented on code in PR #45575:
URL: https://github.com/apache/superset/pull/45575#discussion_r4344467286


##########
tests/unit_tests/mcp_service/test_sanitization_warning_inputs.py:
##########
@@ -524,3 +579,603 @@ def validate(request: BaseModel) -> str:
     )
     assert f"request.{field}: Contains disallowed content" in message
     assert "private-input" not in message
+
+
[email protected]
[email protected](
+    ("tool", "payload", "field", "limit"),
+    [
+        ("generate_dashboard", {"chart_ids": [1]}, "dashboard_title", 500),
+        ("update_dashboard", {"identifier": 1}, "dashboard_title", 500),
+        ("duplicate_dashboard", {"dashboard_id": 1}, "dashboard_title", 500),
+        (
+            "generate_chart",
+            {
+                "dataset_id": 1,
+                "config": {"chart_type": "table", "columns": [{"name": "a"}]},
+            },
+            "chart_name",
+            250,
+        ),
+        ("update_chart", {"identifier": 1}, "chart_name", 250),
+    ],
+)
[email protected]("alias", [None, "title", "name"])
[email protected]("length", [None, 600])
+async def test_title_length_wire_reason(
+    tool: str,
+    payload: dict[str, Any],
+    field: str,
+    limit: int,
+    alias: str | None,
+    length: int | None,
+) -> None:
+    """Length errors retain their reason through warning detection and MCP."""
+    rejected = "x" * (length or limit + 1)
+    async with Client(make_title_server()) as client:
+        result = await client.call_tool_mcp(
+            tool, {"request": {**payload, alias or field: rejected}}
+        )
+    assert result.isError is True
+    assert result.content == [
+        TextContent(
+            type="text",
+            text=f"Error: Validation error in {tool}: request.{field}: "
+            "String exceeds the allowed maximum length",
+        )
+    ]
+
+
[email protected]
[email protected]("field", ["dashboard_title", "title", "name"])
[email protected]("rejected", ["", " \t\n"])
+async def test_duplicate_dashboard_empty_title_wire_reason(
+    field: str,
+    rejected: str,
+) -> None:
+    """Blank required titles have a static, actionable reason on the wire."""
+    async with Client(make_title_server()) as client:
+        result = await client.call_tool_mcp(
+            "duplicate_dashboard", {"request": {"dashboard_id": 1, field: 
rejected}}
+        )
+    assert result.isError is True
+    assert result.content == [
+        TextContent(
+            type="text",
+            text="Error: Validation error in duplicate_dashboard: "
+            "request.dashboard_title: Text must not be empty",
+        )
+    ]
+
+
+_REQUIRED_NAME_CASES: list[tuple[str, dict[str, Any], str, list[str | None]]] 
= [
+    (
+        "create_virtual_dataset",
+        {"database_id": 1, "sql": "select 1"},
+        "dataset_name",
+        ["", " \t\n"],
+    ),
+    ("create_theme", {"json_data": {}}, "theme_name", ["", " \t\n"]),
+    (
+        "create_dataset_metric",
+        {"dataset_id": 1, "expression": "count(*)"},
+        "metric_name",
+        ["", " \t\n"],
+    ),
+    (
+        "update_dataset_metric",
+        {"dataset_id": 1, "metric": 1},
+        "metric_name",
+        ["", " \t\n", None],
+    ),
+    ("update_dataset", {"dataset_id": 1}, "table_name", ["", " \t\n", None]),
+    (
+        "save_sql_query",
+        {"database_id": 1, "sql": "select 1"},
+        "label",
+        ["", " \t\n"],
+    ),
+]
+
+
[email protected]
[email protected](
+    ("tool", "payload", "field", "rejected"),
+    [
+        (tool, payload, field, rejected)
+        for tool, payload, field, blanks in _REQUIRED_NAME_CASES
+        for rejected in blanks
+    ],
+)
+async def test_required_name_empty_wire_reason(
+    tool: str, payload: dict[str, Any], field: str, rejected: str | None
+) -> None:
+    """Blank object names report a safe reason at the declared field."""
+    async with Client(make_name_server()) as client:
+        result = await client.call_tool_mcp(
+            tool, {"request": {**payload, field: rejected}}
+        )
+    assert result.isError is True
+    assert result.content == [
+        TextContent(
+            type="text",
+            text=f"Error: Validation error in {tool}: request.{field}: "
+            "Text must not be empty",
+        )
+    ]
+
+
[email protected](
+    "model,payload",
+    [
+        (
+            GenerateChartRequest,
+            {
+                "dataset_id": 1,
+                "config": {"chart_type": "table", "columns": [{"name": "a"}]},
+            },
+        ),
+        (UpdateChartRequest, {"identifier": 1}),
+    ],
+)
[email protected]("length", [250, 251, 252, 253, 254, 255])
+def test_chart_name_model_column_boundary(
+    model: type[BaseModel], payload: dict[str, Any], length: int
+) -> None:
+    """Chart names must fit the persisted Slice name column."""
+    from superset.models.slice import Slice
+
+    limit = Slice.__table__.c.slice_name.type.length
+    assert limit == 250
+    if length <= limit:
+        assert (
+            model.model_validate({**payload, "chart_name": "x" * 
length}).chart_name
+            == "x" * length
+        )
+    else:
+        with pytest.raises(ValidationError) as exc:
+            model.model_validate({**payload, "chart_name": "x" * length})
+        assert exc.value.errors()[0]["type"] == "string_too_long"
+        assert exc.value.errors()[0]["loc"] == ("chart_name",)
+
+
[email protected](
+    "model,payload",
+    [
+        (
+            GenerateChartRequest,
+            {
+                "dataset_id": 1,
+                "config": {"chart_type": "table", "columns": [{"name": "a"}]},
+            },
+        ),
+        (UpdateChartRequest, {"identifier": 1}),
+    ],
+)
[email protected]("alias", ["chart_name", "name", "title", 
"slice_name"])
[email protected]("length", [250, 251])
+def test_padded_chart_name_boundary(
+    model: type[BaseModel], payload: dict[str, Any], alias: str, length: int
+) -> None:
+    """Validate the trimmed chart name against the model column boundary."""
+    value = "  " + "x" * length + "  "
+    if length == 250:
+        assert (
+            model.model_validate({**payload, alias: value}).chart_name == "x" 
* length
+        )
+    else:
+        with pytest.raises(ValidationError) as exc:
+            model.model_validate({**payload, alias: value})
+        assert exc.value.errors()[0]["type"] == "string_too_long"
+
+
[email protected]("length", [250, 251, 310])
+def test_theme_name_model_column_boundary(length: int) -> None:
+    """Theme creation must respect its persisted name column."""
+    from superset.models.core import Theme
+
+    assert Theme.__table__.c.theme_name.type.length == 250
+    if length == 250:
+        assert (
+            CreateThemeRequest(theme_name="x" * length, 
json_data={}).theme_name
+            == "x" * length
+        )
+    else:
+        with pytest.raises(ValidationError) as exc:
+            CreateThemeRequest(theme_name="x" * length, json_data={})
+        assert exc.value.errors()[0]["type"] == "string_too_long"
+
+
[email protected]
[email protected]("alias", ["dashboard_title", "title", "name"])
[email protected]("value", ["", " \t\n"])
+async def test_update_dashboard_blank_title_wire_reason(alias: str, value: 
str) -> None:
+    """Reject blank renames rather than reporting a title that was not 
saved."""
+    async with Client(make_title_server()) as client:
+        result = await client.call_tool_mcp(
+            "update_dashboard", {"request": {"identifier": 1, alias: value}}
+        )
+    assert result.isError is True
+    assert "request.dashboard_title: Text must not be empty" in 
result.content[0].text
+
+
[email protected]("alias", ["chart_name", "name", "title", 
"slice_name"])
[email protected]("script_only", [True, False])
+def test_update_chart_sanitization_matches_generate(
+    alias: str, script_only: bool
+) -> None:
+    """Update rejects stripped-only names and warns on partially stripped 
names."""
+    if script_only:
+        with pytest.raises(ValidationError) as exc:
+            UpdateChartRequest.model_validate(
+                {"identifier": 1, alias: "<script>alert(1)</script>"}
+            )
+        assert exc.value.errors()[0]["type"] == "unsafe_text"
+        assert exc.value.errors()[0]["loc"] == ("chart_name",)
+        return
+    request = UpdateChartRequest.model_validate(
+        {
+            "identifier": 1,
+            alias: "Weekly KPIs <img src=x onerror=alert(1)>",
+            "sanitization_warnings": ["untrusted"],
+        }
+    )
+    assert request.chart_name == "Weekly KPIs"
+    assert len(request.sanitization_warnings) == 1
+    assert "untrusted" not in request.sanitization_warnings
+    assert "sanitization_warnings" not in json.dumps(
+        UpdateChartRequest.model_json_schema()
+    )
+
+
+def test_duplicate_dataset_error_is_readable() -> None:
+    """Duplicate errors use a plain string and preserve display-name 
characters."""
+    from superset.commands.dataset.exceptions import 
DatasetExistsValidationError
+    from superset.sql.parse import Table
+
+    name = "Sales “Q3” & Growth"
+    error = DatasetExistsValidationError(
+        Table(name, schema="my schema", catalog="my catalog")
+    )
+    assert error.normalized_messages() == {
+        "table": [f"Dataset my catalog.my schema.{name} already exists"]
+    }
+    assert type(error.messages[0]) is str
+
+
[email protected](
+    "kind,limit", [("dataset", 250), ("saved_query", 256), ("tab", 256)]
+)
[email protected]("extra", [0, 1])
+def test_padded_sibling_name_boundary(kind: str, limit: int, extra: int) -> 
None:
+    """Persisted names that trim must check the length after trimming."""
+    from superset.mcp_service.sql_lab.schemas import OpenSqlLabRequest
+
+    cases: dict[str, tuple[type[BaseModel], dict[str, Any], str]] = {
+        "dataset": (
+            CreateVirtualDatasetRequest,
+            {"database_id": 1, "sql": "select 1"},
+            "dataset_name",
+        ),
+        "saved_query": (
+            SaveSqlQueryRequest,
+            {"database_id": 1, "sql": "select 1"},
+            "label",
+        ),
+        "tab": (OpenSqlLabRequest, {"database_id": 1}, "title"),
+    }
+    model, payload, field = cases[kind]
+    value = "x" * (limit + extra)
+    if not extra:
+        assert (
+            getattr(model.model_validate({**payload, field: f"  {value}  "}), 
field)
+            == value
+        )
+    else:
+        with pytest.raises(ValidationError) as exc:
+            model.model_validate({**payload, field: f"  {value}  "})
+        assert exc.value.errors()[0]["type"] == "string_too_long"
+
+
[email protected](
+    "model,payload",
+    [
+        (GenerateDashboardRequest, {"chart_ids": [1]}),
+        (UpdateDashboardRequest, {"identifier": 1}),
+        (DuplicateDashboardRequest, {"dashboard_id": 1}),
+    ],
+)
[email protected]("alias", ["dashboard_title", "title", "name"])
[email protected]("length", [500, 501])
+def test_padded_dashboard_title_boundary(
+    model: type[BaseModel], payload: dict[str, Any], alias: str, length: int
+) -> None:
+    """Dashboard aliases check the trimmed title against the column cap."""
+    if length == 500:
+        assert (
+            model.model_validate(
+                {**payload, alias: "  " + "x" * length + "  "}
+            ).dashboard_title
+            == "x" * length
+        )
+    else:
+        with pytest.raises(ValidationError) as exc:
+            model.model_validate({**payload, alias: "  " + "x" * length + "  
"})
+        assert exc.value.errors()[0]["type"] == "string_too_long"
+
+
+def test_padded_dashboard_slug_boundary() -> None:
+    """Update slug normalization precedes its persisted column limit."""
+    request = UpdateDashboardRequest(identifier=1, slug="  " + "x" * 255 + "  
")
+    assert request.slug == "x" * 255
+    with pytest.raises(ValidationError):
+        UpdateDashboardRequest(identifier=1, slug="  " + "x" * 256 + "  ")
+
+
+_TITLE_SANITIZATION_CASES = [
+    (
+        GenerateChartRequest,
+        {
+            "dataset_id": 1,
+            "config": {"chart_type": "table", "columns": [{"name": "a"}]},
+        },
+        "chart_name",
+    ),
+    (UpdateChartRequest, {"identifier": 1}, "chart_name"),
+    (GenerateDashboardRequest, {"chart_ids": [1]}, "dashboard_title"),
+    (UpdateDashboardRequest, {"identifier": 1}, "dashboard_title"),
+    (DuplicateDashboardRequest, {"dashboard_id": 1}, "dashboard_title"),
+]
+
+
[email protected]("model,payload,field", _TITLE_SANITIZATION_CASES)
[email protected](
+    "value",
+    ["Users aged <30", "Revenue <$1M", "Love <3 charts", "Users aged <30 & 
“VIP” 📈"],
+)
+def test_literal_less_than_title_round_trip(
+    model: type[BaseModel], payload: dict[str, Any], field: str, value: str
+) -> None:
+    """Non-tag less-than signs survive validation and JSON readback 
unchanged."""
+    request = model.model_validate({**payload, field: value})
+    assert getattr(request, field) == value
+    assert request.sanitization_warnings == []
+    assert json.loads(request.model_dump_json())[field] == value
+
+
[email protected]("model,payload,field", _TITLE_SANITIZATION_CASES)
[email protected](
+    "value", ["Weekly <img src=x>KPIs", "Q3 <b>revenue</b> &amp; costs"]
+)
+def test_title_sanitized_once(
+    model: type[BaseModel], payload: dict[str, Any], field: str, value: str
+) -> None:
+    """Persist the single sanitizer result, never a second-pass empty 
fallback."""
+    from unittest.mock import patch
+
+    from superset.mcp_service.utils.sanitization import sanitize_user_input
+
+    expected = sanitize_user_input(value, "Title", max_length=500, 
allow_empty=True)
+    assert expected
+    with (
+        patch(
+            "superset.mcp_service.utils.sanitization.sanitize_user_input",
+            wraps=sanitize_user_input,
+        ) as sanitize,
+        patch("superset.mcp_service.chart.schemas.sanitize_user_input", 
sanitize),
+        patch("superset.mcp_service.dashboard.schemas.sanitize_user_input", 
sanitize),
+    ):
+        request = model.model_validate({**payload, field: value})
+    calls = [
+        c
+        for c in sanitize.call_args_list
+        if c.args[1] in ("Chart name", "Dashboard title")
+    ]
+    assert len(calls) == 1
+    assert getattr(request, field) == expected
+    assert len(request.sanitization_warnings) == 1
+
+
[email protected]("model,payload,field", _TITLE_SANITIZATION_CASES)
[email protected](
+    "value", ["&#x3c;&#x3c;</b>x", "&amp;lt;</script>click= <b>📈"]
+)
+def test_reconstructed_tag_title_rejected(
+    model: type[BaseModel], payload: dict[str, Any], field: str, value: str
+) -> None:
+    """Encoded tag text is rejected instead of stored or auto-named."""
+    with pytest.raises(ValidationError) as exc:
+        model.model_validate({**payload, field: value})
+    assert exc.value.errors()[0]["loc"] == (field,)
+    assert exc.value.errors()[0]["type"] == "unsafe_text"
+
+
[email protected]("model,payload,field", _TITLE_SANITIZATION_CASES)
+def test_empty_sanitized_title_never_uses_fallback(
+    model: type[BaseModel], payload: dict[str, Any], field: str
+) -> None:
+    """Encoded script-only names retain the existing disallowed-content 
reason."""
+    with pytest.raises(ValidationError) as exc:
+        model.model_validate({**payload, field: 
"&amp;lt;script>alert(1)</script>"})
+    assert exc.value.errors()[0]["loc"] == (field,)
+    assert exc.value.errors()[0]["type"] == "unsafe_text"
+
+
[email protected](
+    "model,payload,field",
+    [
+        (ManageDashboardCertificationRequest, {"identifier": 1}, 
"certified_by"),
+        (
+            ManageDashboardCertificationRequest,
+            {"identifier": 1},
+            "certification_details",
+        ),
+        (BaseChartConfig, {}, "temporal_column"),
+        (ColumnRef, {}, "name"),
+        (ColumnRef, {"name": "amount"}, "label"),
+        (FilterConfig, {"op": "=", "value": "safe"}, "column"),
+        (FilterConfig, {"column": "region", "op": "="}, "value"),
+        (GanttSortByConfig, {}, "column"),
+        (
+            SunburstChartConfig,
+            {
+                "hierarchy": [{"name": "region"}],
+                "metric": {"name": "amount", "aggregate": "SUM"},
+            },
+            "time_range",
+        ),
+        (
+            InteractivePivotChartConfig,
+            {
+                "rows": [{"name": "region"}],
+                "metrics": [{"name": "amount", "aggregate": "SUM"}],
+            },
+            "comparison_period",
+        ),
+        (
+            BulletChartConfig,
+            {"metric": {"name": "amount", "aggregate": "SUM"}},
+            "time_range",
+        ),
+        (
+            BulletChartConfig,
+            {"metric": {"name": "amount", "aggregate": "SUM"}},
+            "range_labels",
+        ),
+        (
+            BulletChartConfig,
+            {"metric": {"name": "amount", "aggregate": "SUM"}},
+            "marker_labels",
+        ),
+        (
+            BulletChartConfig,
+            {"metric": {"name": "amount", "aggregate": "SUM"}},
+            "marker_line_labels",
+        ),
+    ],
+)
[email protected](
+    "value", ["Users aged <30", "&#x3c;&#x3c;</b>x", "&amp;lt;</script>click= 
<b>📈"]
+)
+def test_sibling_fields_use_single_sanitizer_result(
+    model: type[BaseModel], payload: dict[str, Any], field: str, value: str
+) -> None:
+    """Shared-sanitizer siblings preserve comparisons and never re-sanitize 
output."""
+    from superset.mcp_service.utils.sanitization import sanitize_user_input
+
+    if model is InteractivePivotChartConfig:
+        payload = {**payload, "comparison_type": "values"}
+    sql_identifier = field in ("column", "temporal_column", "name")
+    expected: str | int | float | None
+    try:
+        if model is FilterConfig and field == "value":
+            from superset.mcp_service.utils.sanitization import 
sanitize_filter_value
+
+            expected = sanitize_filter_value(value)
+        else:
+            expected = sanitize_user_input(
+                value,
+                "Value",
+                max_length=500,
+                check_sql_keywords=sql_identifier,
+                allow_empty=True,
+            )
+    except ValueError:

Review Comment:
   Fixed in 9b87c3b399ca023b232bd3f356b9273ce9d4ca24. I split accepted and 
rejected inputs rather than deriving expectations from the sanitizer.
   
   Regression test: 
`tests/unit_tests/mcp_service/test_sanitization_warning_inputs.py::test_sibling_fields_preserve_literal_comparison`
 requires successful validation and the exact literal `Users aged <30` across 
all 14 sibling fields, including identifiers. With only the literal-`<` 
restoration removed, all 14 cases fail; restored, all 14 pass. The old test 
still passed all 42 cases under that same mutation, reproducing the coverage 
gap. `test_sibling_fields_reject_reconstructed_tag` separately pins all 28 
rejections to `unsafe_text` and the exact canonical field location. The 
combined restored run passes all 42 cases.
   
   Siblings checked:
   - tests/unit_tests/mcp_service/test_sanitization_warning_inputs.py:1004 — 
fixed in 9b87c3b399ca023b232bd3f356b9273ce9d4ca24
   - superset/mcp_service/chart/schemas.py:879 — not affected: production code 
unchanged; temporal_column literal acceptance and typed rejection are pinned by 
the split tests.
   - superset/mcp_service/chart/schemas.py:1003 — not affected: ColumnRef name 
and label both covered by the split tests.
   - superset/mcp_service/chart/schemas.py:1132 — not affected: filter column 
and value both covered, including the separate filter sanitizer.
   - superset/mcp_service/chart/schemas.py:1211 — not affected: Gantt sort 
column covered by the split tests.
   - superset/mcp_service/chart/schemas.py:2761 — not affected: Sunburst 
time_range covered by the split tests.
   - superset/mcp_service/chart/schemas.py:3155 — not affected: 
interactive-pivot comparison_period covered by the split tests.
   - superset/mcp_service/chart/schemas.py:4628 — not affected: Bullet 
time_range and all three label-list fields covered by the split tests.
   - superset/mcp_service/dashboard/schemas.py:1393 — not affected: both 
certification fields covered by the split tests.
   - tests/unit_tests/mcp_service/test_sanitization_warning_inputs.py:958 — not 
affected: test_title_sanitized_once asserts invocation count without catching 
rejection; literal titles have separate exact-output tests.
   - tests/unit_tests/mcp_service/chart/tool/test_generate_chart.py:1206 — not 
affected: revalidation test asserts one invocation and storage writeback 
without catching rejection; separate storage round-trip tests pin literal 
titles.
   - tests/unit_tests/mcp_service/test_logging_filters.py:67 — not affected: 
ValueError catch constructs logging exception metadata, not a validation 
expectation.
   
   Search: `rg -n 'sanitize_user_input\(|sanitize_filter_value\(' 
superset/mcp_service` and `rg -n 'expected = sanitize|except ValueError:' 
tests/unit_tests/mcp_service`.
   



##########
docs/admin_docs/configuration/mcp-server.mdx:
##########
@@ -2424,3 +2424,20 @@ Context names are returned verbatim per the Tool Result 
Value Contract.
 "Did you mean" column candidates also preserve dataset spelling, including
 quotes and ampersands, with a 200-character bound per candidate. Use
 `get_dataset_info` for the complete schema.
+
+### Title validation errors
+
+Rejected MCP titles report a field-specific reason without echoing the input.
+Titles exceeding 500 characters for dashboards or 250 characters for charts
+report "String exceeds the allowed maximum length". `duplicate_dashboard`
+requires a non-blank title and reports "Text must not be empty" for blank or
+whitespace-only input. A title containing only removed HTML/script content
+(such as `<script>alert(1)</script>`) reports "Contains disallowed content"
+(`unsafe_text`), not an empty-text error. Optional titles retain their existing

Review Comment:
   Fixed in 9b87c3b399ca023b232bd3f356b9273ce9d4ca24. Rejecting blank renames 
is intentional: the PR description explicitly calls for it, and the update tool 
writes every non-null title. I limited the unchanged empty-value behavior to 
creation and documented blank/whitespace-only dashboard rename rejection plus 
omission preserving the stored title. No runtime behavior changed in this 
commit.
   
   Regression test: 
`tests/unit_tests/mcp_service/test_sanitization_warning_inputs.py::test_update_dashboard_blank_title_wire_reason`
 covers empty/whitespace input through all three aliases. Against the base 
dashboard schema, all 6 cases fail; restored to the PR schema, all 6 pass. A 
documentation contract assertion also fails on the preceding paragraph and 
passes on this wording. The scoped MCP suite passes 1,070 tests.
   
   Siblings checked:
   - docs/admin_docs/configuration/mcp-server.mdx:2436 — fixed in 
9b87c3b399ca023b232bd3f356b9273ce9d4ca24
   - docs/developer_docs/extensions/mcp.md:610 — not affected: already 
documents explicit blank dashboard rename rejection and omission preserving the 
existing value.
   - superset/mcp_service/dashboard/schemas.py:1072 — not affected: intentional 
blank rename rejection remains unchanged and is covered by the six wire cases.
   - superset/mcp_service/dashboard/tool/update_dashboard.py:186 — not 
affected: writes only non-null titles; omitted title remains unchanged.
   - superset/mcp_service/dashboard/schemas.py:907 — not affected: creation 
retains its existing optional-title empty-value behavior.
   - superset/mcp_service/dashboard/schemas.py:1553 — not affected: duplication 
still requires a nonblank title, already documented and covered by 
test_duplicate_dashboard_empty_title_wire_reason.
   - superset/mcp_service/chart/schemas.py:6393 — not affected: optional 
chart-name normalization remains unchanged; this correction describes dashboard 
renames only.
   
   Search: `rg -n 'Optional titles|empty-value behavior|blank.*rename|Text must 
not be empty' docs/admin_docs/configuration/mcp-server.mdx 
docs/developer_docs/extensions/mcp.md` and `rg -n 'dashboard_title' 
superset/mcp_service/dashboard/tool/update_dashboard.py`.
   



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to