diff --git a/superset/commands/dashboard/update.py b/superset/commands/dashboard/update.py index 56d15befb3f9..dd81d96deeb1 100644 --- a/superset/commands/dashboard/update.py +++ b/superset/commands/dashboard/update.py @@ -66,6 +66,10 @@ def run(self) -> Model: if (tags := self._properties.pop("tags", None)) is not None: update_tags(ObjectType.dashboard, self._model.id, self._model.tags, tags) + # Re-serialize position_json to escape 4-byte Unicode characters + if position_json := self._properties.get("position_json"): + self._properties["position_json"] = json.dumps(json.loads(position_json)) + dashboard = DashboardDAO.update(self._model, self._properties) if self._properties.get("json_metadata"): DashboardDAO.set_dash_metadata( diff --git a/tests/integration_tests/dashboards/test_update_emoji.py b/tests/integration_tests/dashboards/test_update_emoji.py new file mode 100644 index 000000000000..28abf0e70ed5 --- /dev/null +++ b/tests/integration_tests/dashboards/test_update_emoji.py @@ -0,0 +1,111 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +"""Tests that emoji characters in position_json are persisted correctly via PUT.""" + +from superset import db +from superset.models.dashboard import Dashboard +from superset.utils import json +from tests.integration_tests.base_tests import SupersetTestCase +from tests.integration_tests.constants import ADMIN_USERNAME + +# position_json payload containing a 4-byte emoji in a MARKDOWN component, +# matching the real-world payload that triggered the truncation bug +POSITION_JSON_WITH_EMOJI = json.dumps( + { + "DASHBOARD_VERSION_KEY": "v2", + "ROOT_ID": {"type": "ROOT", "id": "ROOT_ID", "children": ["GRID_ID"]}, + "GRID_ID": { + "type": "GRID", + "id": "GRID_ID", + "children": ["ROW-test"], + "parents": ["ROOT_ID"], + }, + "ROW-test": { + "type": "ROW", + "id": "ROW-test", + "children": ["MARKDOWN-test"], + "parents": ["ROOT_ID", "GRID_ID"], + "meta": {"background": "BACKGROUND_TRANSPARENT"}, + }, + "MARKDOWN-test": { + "type": "MARKDOWN", + "id": "MARKDOWN-test", + "children": [], + "parents": ["ROOT_ID", "GRID_ID", "ROW-test"], + "meta": {"code": "📈 See Tab\n\ntest\ntest2", "height": 50, "width": 4}, + }, + }, +) + + +class TestDashboardUpdateEmoji(SupersetTestCase): + """Verify that emoji in position_json survive a dashboard PUT round-trip.""" + + def setUp(self) -> None: + super().setUp() + admin = self.get_user("admin") + self.dashboard = self.insert_dashboard( + "emoji test dashboard", "emoji-test-slug", [admin.id] + ) + + def tearDown(self) -> None: + db.session.delete(self.dashboard) + db.session.commit() + super().tearDown() + + def test_position_json_emoji_survives_put(self) -> None: + """Emoji in position_json must be stored and retrievable after PUT.""" + self.login(ADMIN_USERNAME) + uri = f"api/v1/dashboard/{self.dashboard.id}" + + rv = self.client.put( + uri, + json={"position_json": POSITION_JSON_WITH_EMOJI}, + ) + + assert rv.status_code == 200, rv.json + + db.session.expire(self.dashboard) + saved = db.session.get(Dashboard, self.dashboard.id) + assert saved is not None + + parsed = json.loads(saved.position_json) + code = parsed["MARKDOWN-test"]["meta"]["code"] + + # The emoji and the text after it must not be truncated + assert code == "📈 See Tab\n\ntest\ntest2" + + def test_position_json_emoji_is_ascii_safe_in_db(self) -> None: + """position_json stored in DB must be ASCII-safe (emoji escaped as \\uXXXX) + so it is safe for MySQL utf8 (3-byte) charset columns.""" + self.login(ADMIN_USERNAME) + uri = f"api/v1/dashboard/{self.dashboard.id}" + + rv = self.client.put( + uri, + json={"position_json": POSITION_JSON_WITH_EMOJI}, + ) + + assert rv.status_code == 200, rv.json + + db.session.expire(self.dashboard) + saved = db.session.get(Dashboard, self.dashboard.id) + assert saved is not None + + # Raw 4-byte emoji must not appear in the stored string + assert "📈" not in saved.position_json + assert all(ord(c) < 128 for c in saved.position_json)