fix(export): dashboard export must not leak env-local chartIds (#32972) (#40588)

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Evan Rusackas
2026-06-22 03:50:29 -07:00
committed by GitHub
parent 7128760d32
commit b8aeecfc44
2 changed files with 714 additions and 1 deletions

View File

@@ -19,6 +19,7 @@
import logging
import random
import string
import uuid as uuid_module
from typing import Any, Optional, Callable
from collections.abc import Iterator
@@ -105,6 +106,189 @@ def append_charts(position: dict[str, Any], charts: set[Slice]) -> dict[str, Any
return position
# Bound the derived id to the largest positive signed 32-bit integer
# (2**31 - 1) so it stays within the range a database auto-increment primary
# key would occupy and never collides with the sign bit. The concrete value is
# irrelevant — only its stability across environments matters.
_STABLE_CHART_ID_MODULO = 2_147_483_647
def stable_chart_id(chart_uuid: str) -> int:
"""Derive a deterministic, environment-independent integer from a chart UUID.
The dashboard export format historically embedded ``meta.chartId`` — the
source environment's auto-increment primary key — inside every ``CHART-*``
position node (issue #32972). That integer differs between environments, so
re-exporting an imported dashboard produced a different bundle for the same
logical content. Deriving the id from the (stable) UUID instead makes the
export reproducible while still giving the importer an integer it can use to
rewire the legacy, integer-keyed metadata references back to local IDs.
"""
return (uuid_module.UUID(chart_uuid).int % _STABLE_CHART_ID_MODULO) + 1
def _stabilize_chart_ids(payload: dict[str, Any]) -> None:
"""Replace env-local integer chart IDs in ``payload`` with UUID-derived ones.
Rewrites ``meta.chartId`` in every ``CHART`` position node to a value derived
from ``meta.uuid`` and remaps the legacy, integer-keyed metadata references
(filter scopes, default filters, expanded slices, native filter scopes, and
cross-filter/chart configuration) accordingly so the bundle stays internally
consistent and the import-side id remap resolves against the same IDs.
Mappings are applied defensively — a reference whose source id is unknown is
dropped rather than raising, and a node with a malformed ``meta.uuid`` is
skipped — so a partially corrupt position never aborts the export. See
``stable_chart_id`` and issue #32972 for the motivation.
"""
position = payload.get("position")
if not isinstance(position, dict):
return
# Collect each chart node with its stable UUID-derived id. Nodes are
# processed in UUID order so that, on the (astronomically unlikely) event
# that two UUIDs reduce to the same integer, the deterministic collision
# resolution below assigns the same ids regardless of dict iteration order
# or environment.
chart_nodes: list[tuple[str, Any, dict[str, Any]]] = []
for node in position.values():
if (
isinstance(node, dict)
and node.get("type") == "CHART"
and isinstance(node.get("meta"), dict)
):
meta = node["meta"]
chart_uuid = meta.get("uuid")
if chart_uuid is None:
continue
try:
uuid_module.UUID(str(chart_uuid))
except ValueError:
# A malformed ``meta.uuid`` (corrupt position_json) must not
# abort the whole export — skip stabilizing this single node
# and leave its existing chartId untouched.
logger.warning(
"Skipping chart id stabilization for invalid uuid %r",
chart_uuid,
)
continue
chart_nodes.append((str(chart_uuid), meta.get("chartId"), meta))
# Map each chart's env-local integer id -> stable UUID-derived id. Since
# ``stable_chart_id`` reduces a UUID into a finite integer space, two
# distinct charts could in principle collide; if so, probe forward
# deterministically so every chart keeps a distinct id and the metadata
# remaps below never silently overwrite one another.
id_map: dict[int, int] = {}
assigned: set[int] = set()
for chart_uuid, old_id, meta in sorted(chart_nodes, key=lambda item: item[0]):
new_id = stable_chart_id(chart_uuid)
while new_id in assigned:
new_id = (new_id % _STABLE_CHART_ID_MODULO) + 1
assigned.add(new_id)
if isinstance(old_id, int):
id_map[old_id] = new_id
meta["chartId"] = new_id
if not id_map:
return
metadata = payload.get("metadata")
if not isinstance(metadata, dict):
return
def remap_id(old_id: Any) -> Optional[int]:
"""Map a single legacy chart id to its stabilized id, or ``None``.
Returns ``None`` when the id is unknown or not coercible to ``int`` so
callers can drop unresolved references rather than raising.
"""
try:
return id_map.get(int(old_id))
except (TypeError, ValueError):
return None
def remap_ids(old_ids: Any) -> list[int]:
"""Remap a collection of legacy chart ids, dropping unresolved entries."""
return [
new_id for old_id in old_ids if (new_id := remap_id(old_id)) is not None
]
if isinstance(metadata.get("timed_refresh_immune_slices"), list):
metadata["timed_refresh_immune_slices"] = remap_ids(
metadata["timed_refresh_immune_slices"]
)
if isinstance(metadata.get("filter_scopes"), dict):
metadata["filter_scopes"] = {
str(new_key): columns
for old_key, columns in metadata["filter_scopes"].items()
if (new_key := remap_id(old_key)) is not None
}
for columns in metadata["filter_scopes"].values():
if not isinstance(columns, dict):
continue
for attributes in columns.values():
if isinstance(attributes, dict) and isinstance(
attributes.get("immune"), list
):
attributes["immune"] = remap_ids(attributes["immune"])
if isinstance(metadata.get("expanded_slices"), dict):
metadata["expanded_slices"] = {
str(new_key): value
for old_key, value in metadata["expanded_slices"].items()
if (new_key := remap_id(old_key)) is not None
}
if metadata.get("default_filters"):
try:
default_filters = json.loads(metadata["default_filters"])
except (TypeError, json.JSONDecodeError):
default_filters = None
if isinstance(default_filters, dict):
metadata["default_filters"] = json.dumps(
{
str(new_key): value
for old_key, value in default_filters.items()
if (new_key := remap_id(old_key)) is not None
}
)
def remap_scope(container: Any) -> None:
"""Remap ``scope.excluded`` and ``chartsInScope`` of a filter container.
Shared by native filters and (global) cross-filter configuration, which
both denormalize the charts a filter applies to into these two lists.
"""
if not isinstance(container, dict):
return
scope = container.get("scope")
if isinstance(scope, dict) and isinstance(scope.get("excluded"), list):
scope["excluded"] = remap_ids(scope["excluded"])
if isinstance(container.get("chartsInScope"), list):
container["chartsInScope"] = remap_ids(container["chartsInScope"])
if isinstance(metadata.get("native_filter_configuration"), list):
for native_filter in metadata["native_filter_configuration"]:
remap_scope(native_filter)
if isinstance(metadata.get("global_chart_configuration"), dict):
remap_scope(metadata["global_chart_configuration"])
if isinstance(metadata.get("chart_configuration"), dict):
new_chart_configuration: dict[str, Any] = {}
for old_key, chart_config in metadata["chart_configuration"].items():
new_key = remap_id(old_key)
if new_key is None:
continue
if isinstance(chart_config, dict):
if isinstance(chart_config.get("id"), int):
chart_config["id"] = new_key
remap_scope(chart_config.get("crossFilters"))
new_chart_configuration[str(new_key)] = chart_config
metadata["chart_configuration"] = new_chart_configuration
class ExportDashboardsCommand(ExportModelsCommand):
dao = DashboardDAO
not_found = DashboardNotFoundError
@@ -182,6 +366,11 @@ class ExportDashboardsCommand(ExportModelsCommand):
if orphan_charts:
payload["position"] = append_charts(payload["position"], orphan_charts)
# Strip env-local integer chart IDs in favor of UUID-derived ones so the
# export is reproducible across environments (issue #32972). Must run
# after orphan charts are appended so their nodes are stabilized too.
_stabilize_chart_ids(payload)
# Add theme UUID for proper cross-system imports
payload["theme_uuid"] = str(model.theme.uuid) if model.theme else None

View File

@@ -256,7 +256,488 @@ def test_file_content_omits_roles_field_when_dashboard_has_no_roles():
assert "roles" not in result
def test_file_content_missing_dataset_preserves_dataset_id():
def test_position_json_chart_id_is_stable_across_environments() -> None:
"""
Regression for #32972: dashboard export must produce stable output that does
not vary with env-local integer chartIds.
The export format includes a ``meta.chartId`` field inside each ``CHART-*``
position entry. Historically that integer was the database auto-increment
primary key from the source environment. When a bundle is imported into a
different environment the importer (``update_id_refs``) rewrites those IDs to
the destination-env primary keys, so a second export from the destination
would serialize the new env-local integers — the same logical chart produced
different ``chartId`` values in each environment.
The exporter now derives ``meta.chartId`` from the (stable) ``meta.uuid``
instead, so this test asserts that two exports of the same logical dashboard
agree on ``chartId`` even when the underlying chart has a different integer
primary key in each environment.
"""
from superset.commands.dashboard.export import ExportDashboardsCommand
chart_uuid = "812bc377-ac09-475a-8d34-a63f7f087bd7"
# ------------------------------------------------------------------ #
# Source environment: chart has auto-increment id = 392 #
# ------------------------------------------------------------------ #
source_position = {
"DASHBOARD_VERSION_KEY": "v2",
"ROOT_ID": {"children": ["GRID_ID"], "id": "ROOT_ID", "type": "ROOT"},
"GRID_ID": {
"children": ["CHART-srcAAA"],
"id": "GRID_ID",
"parents": ["ROOT_ID"],
"type": "GRID",
},
"HEADER_ID": {
"id": "HEADER_ID",
"meta": {"text": "My Dashboard"},
"type": "HEADER",
},
"CHART-srcAAA": {
"children": [],
"id": "CHART-srcAAA",
"meta": {
"chartId": 392, # source-env integer primary key
"height": 20,
"sliceName": "My Wonderful Chart",
"uuid": chart_uuid,
"width": 4,
},
"parents": ["ROOT_ID", "GRID_ID"],
"type": "CHART",
},
}
src_dashboard = MagicMock()
src_dashboard.dashboard_title = "My Dashboard"
src_dashboard.theme = None
src_dashboard.slices = []
src_dashboard.tags = []
src_dashboard.roles = []
src_dashboard.export_to_dict.return_value = {
"position_json": json.dumps(source_position),
"json_metadata": json.dumps({"native_filter_configuration": []}),
}
with patch(
"superset.commands.dashboard.export.feature_flag_manager.is_feature_enabled",
return_value=False,
):
first_export_content = ExportDashboardsCommand._file_content(src_dashboard)
first_export = yaml.safe_load(first_export_content)
first_chart_positions = [
node
for node in first_export["position"].values()
if isinstance(node, dict) and node.get("type") == "CHART"
]
assert first_chart_positions, "Export must contain at least one CHART position node"
first_chart_meta = first_chart_positions[0]["meta"]
# uuid must be present — it is the stable cross-environment identifier
assert first_chart_meta.get("uuid") == chart_uuid, (
"meta.uuid must be present in exported position_json; "
"it is the stable identifier that survives re-import."
)
# ------------------------------------------------------------------ #
# Destination environment: same chart receives a different id = 1001 #
# (This is what happens after import — update_id_refs rewrites the #
# chartId field inside position_json to the destination-env integer.) #
# ------------------------------------------------------------------ #
dest_position = {
k: (
{
**v,
"meta": {
**v["meta"],
"chartId": 1001, # destination-env integer primary key
},
}
if isinstance(v, dict)
and v.get("type") == "CHART"
and v.get("meta", {}).get("uuid") == chart_uuid
else v
)
for k, v in source_position.items()
}
dest_dashboard = MagicMock()
dest_dashboard.dashboard_title = "My Dashboard"
dest_dashboard.theme = None
dest_dashboard.slices = []
dest_dashboard.tags = []
dest_dashboard.roles = []
dest_dashboard.export_to_dict.return_value = {
"position_json": json.dumps(dest_position),
"json_metadata": json.dumps({"native_filter_configuration": []}),
}
with patch(
"superset.commands.dashboard.export.feature_flag_manager.is_feature_enabled",
return_value=False,
):
second_export_content = ExportDashboardsCommand._file_content(dest_dashboard)
second_export = yaml.safe_load(second_export_content)
second_chart_positions = [
node
for node in second_export["position"].values()
if isinstance(node, dict) and node.get("type") == "CHART"
]
assert second_chart_positions, "Second export must contain at least one CHART node"
second_chart_meta = second_chart_positions[0]["meta"]
# uuid must survive the re-import round-trip unchanged
assert second_chart_meta.get("uuid") == chart_uuid, (
"meta.uuid must be stable across export → import → re-export; "
"UUIDs are the cross-environment identity, not integer IDs."
)
# ------------------------------------------------------------------ #
# THE INVARIANT: chartId must be stable across environments. #
# #
# The source-env export and the destination-env export (whose #
# position_json carries a different env-local integer after import) #
# must agree on chartId, because it is derived from the stable #
# meta.uuid rather than the env-local primary key. #
# ------------------------------------------------------------------ #
first_chart_id = first_chart_meta.get("chartId")
second_chart_id = second_chart_meta.get("chartId")
assert first_chart_id == second_chart_id, (
f"meta.chartId must be stable across environments, but the source-env "
f"export produced chartId={first_chart_id!r} while the destination-env "
f"export (after re-import with remapped IDs) produced "
f"chartId={second_chart_id!r}. "
"Env-local integer IDs must not leak into the export format (issue "
"#32972); chartId is derived from meta.uuid so the export stays "
"environment-independent."
)
def _export_with_chart(
chart_uuid: str,
chart_id: int,
json_metadata: dict[str, Any],
) -> dict[str, Any]:
"""Export a single-chart dashboard and return the parsed YAML payload."""
from superset.commands.dashboard.export import ExportDashboardsCommand
position = {
"DASHBOARD_VERSION_KEY": "v2",
"ROOT_ID": {"children": ["GRID_ID"], "id": "ROOT_ID", "type": "ROOT"},
"GRID_ID": {
"children": ["CHART-aaa"],
"id": "GRID_ID",
"parents": ["ROOT_ID"],
"type": "GRID",
},
"CHART-aaa": {
"children": [],
"id": "CHART-aaa",
"meta": {
"chartId": chart_id,
"height": 20,
"sliceName": "Chart",
"uuid": chart_uuid,
"width": 4,
},
"parents": ["ROOT_ID", "GRID_ID"],
"type": "CHART",
},
}
dashboard = MagicMock()
dashboard.dashboard_title = "Test Dashboard"
dashboard.theme = None
dashboard.slices = []
dashboard.tags = []
dashboard.roles = []
dashboard.export_to_dict.return_value = {
"position_json": json.dumps(position),
"json_metadata": json.dumps(json_metadata),
}
with patch(
"superset.commands.dashboard.export.feature_flag_manager.is_feature_enabled",
return_value=False,
):
content = ExportDashboardsCommand._file_content(dashboard)
return yaml.safe_load(content)
def test_orphan_chart_gets_uuid_derived_chart_id() -> None:
"""
A chart in ``model.slices`` that is NOT referenced in ``position_json`` is
appended via ``append_charts`` (which writes the env-local ``chart.id``).
``_stabilize_chart_ids`` runs afterwards, so the appended node must end up
with a UUID-derived ``chartId`` rather than the env-local integer.
"""
from superset.commands.dashboard.export import (
ExportDashboardsCommand,
stable_chart_id,
)
chart_uuid = "812bc377-ac09-475a-8d34-a63f7f087bd7"
env_local_id = 392
orphan = MagicMock()
orphan.id = env_local_id
orphan.uuid = chart_uuid
orphan.slice_name = "Orphan Chart"
# position_json references no charts, so ``orphan`` is appended as an orphan.
mock_dashboard = _make_mock_dashboard({"native_filter_configuration": []})
mock_dashboard.slices = [orphan]
with patch(
"superset.commands.dashboard.export.feature_flag_manager.is_feature_enabled",
return_value=False,
):
content = ExportDashboardsCommand._file_content(mock_dashboard)
result = yaml.safe_load(content)
chart_nodes = [
node
for node in result["position"].values()
if isinstance(node, dict) and node.get("type") == "CHART"
]
assert chart_nodes, "Orphan chart must be appended to the position tree"
chart_meta = chart_nodes[0]["meta"]
assert chart_meta["uuid"] == chart_uuid
# The env-local id must have been stabilized away, not serialized verbatim.
assert chart_meta["chartId"] == stable_chart_id(chart_uuid)
assert chart_meta["chartId"] != env_local_id
def test_stabilize_chart_ids_skips_invalid_uuid() -> None:
"""A malformed meta.uuid must not abort the whole dashboard export."""
result = _export_with_chart(
"not-a-valid-uuid",
392,
{"native_filter_configuration": []},
)
chart_nodes = [
node
for node in result["position"].values()
if isinstance(node, dict) and node.get("type") == "CHART"
]
# Export still succeeds; the unstabilizable node keeps its original chartId.
assert chart_nodes
assert chart_nodes[0]["meta"]["chartId"] == 392
def test_stabilize_chart_ids_remaps_native_filter_scope() -> None:
"""Native filter scope.excluded / chartsInScope must track the stabilized id."""
from superset.commands.dashboard.export import stable_chart_id
chart_uuid = "812bc377-ac09-475a-8d34-a63f7f087bd7"
new_id = stable_chart_id(chart_uuid)
result = _export_with_chart(
chart_uuid,
392,
{
"native_filter_configuration": [
{
"id": "NATIVE_FILTER-1",
"scope": {"rootPath": ["ROOT_ID"], "excluded": [392]},
"chartsInScope": [392],
}
]
},
)
native_filter = result["metadata"]["native_filter_configuration"][0]
assert native_filter["scope"]["excluded"] == [new_id]
assert native_filter["chartsInScope"] == [new_id]
def test_stabilize_chart_ids_remaps_cross_filter_configuration() -> None:
"""global_chart_configuration and chart_configuration must be remapped."""
from superset.commands.dashboard.export import stable_chart_id
chart_uuid = "812bc377-ac09-475a-8d34-a63f7f087bd7"
new_id = stable_chart_id(chart_uuid)
result = _export_with_chart(
chart_uuid,
392,
{
"native_filter_configuration": [],
"global_chart_configuration": {
"scope": {"rootPath": ["ROOT_ID"], "excluded": [392]},
"chartsInScope": [392],
},
"chart_configuration": {
"392": {
"id": 392,
"crossFilters": {
"scope": {"rootPath": ["ROOT_ID"], "excluded": [392]},
"chartsInScope": [392],
},
}
},
},
)
metadata = result["metadata"]
assert metadata["global_chart_configuration"]["scope"]["excluded"] == [new_id]
assert metadata["global_chart_configuration"]["chartsInScope"] == [new_id]
# chart_configuration is re-keyed and its inner id / scopes are remapped.
assert str(new_id) in metadata["chart_configuration"]
chart_config = metadata["chart_configuration"][str(new_id)]
assert chart_config["id"] == new_id
assert chart_config["crossFilters"]["scope"]["excluded"] == [new_id]
assert chart_config["crossFilters"]["chartsInScope"] == [new_id]
def test_stable_chart_id_is_deterministic_and_in_range() -> None:
"""
stable_chart_id must derive a stable, environment-independent integer from a
chart UUID. The same UUID always yields the same id, and the id stays within
the positive signed 32-bit range so it can stand in for a database
auto-increment primary key without colliding with the sign bit.
"""
from superset.commands.dashboard.export import (
_STABLE_CHART_ID_MODULO,
stable_chart_id,
)
chart_uuid = "812bc377-ac09-475a-8d34-a63f7f087bd7"
# Deterministic: repeated derivations of the same UUID agree.
assert stable_chart_id(chart_uuid) == stable_chart_id(chart_uuid)
# Distinct UUIDs map to distinct ids (no accidental collapse).
other_uuid = "00000000-0000-4000-8000-000000000001"
assert stable_chart_id(chart_uuid) != stable_chart_id(other_uuid)
# Bounded to [1, _STABLE_CHART_ID_MODULO] for any UUID, including one whose
# high bits are all set (boundary case). Static inputs keep the test
# deterministic.
high_bits_uuid = "ffffffff-ffff-4fff-bfff-ffffffffffff"
for candidate in (chart_uuid, other_uuid, high_bits_uuid):
derived = stable_chart_id(candidate)
assert 1 <= derived <= _STABLE_CHART_ID_MODULO
def test_stabilize_chart_ids_remaps_default_filters() -> None:
"""
default_filters is a JSON *string* keyed by env-local chart id. The exporter
must parse it, remap the top-level keys to the stabilized ids, and re-emit it
as a JSON string so the bundle never leaks the source-env integer.
"""
from superset.commands.dashboard.export import stable_chart_id
chart_uuid = "812bc377-ac09-475a-8d34-a63f7f087bd7"
new_id = stable_chart_id(chart_uuid)
result = _export_with_chart(
chart_uuid,
392,
{
"native_filter_configuration": [],
"default_filters": json.dumps({"392": {"__time_range": "No filter"}}),
},
)
# default_filters round-trips as a JSON string keyed by the stabilized id.
default_filters = json.loads(result["metadata"]["default_filters"])
assert str(new_id) in default_filters
assert "392" not in default_filters
assert default_filters[str(new_id)] == {"__time_range": "No filter"}
def test_stabilize_chart_ids_remaps_timed_refresh_immune_slices() -> None:
"""
timed_refresh_immune_slices is a flat list of env-local chart ids. Each entry
must be remapped to the stabilized id so the immune list keeps pointing at the
same logical charts after a cross-environment round-trip.
"""
from superset.commands.dashboard.export import stable_chart_id
chart_uuid = "812bc377-ac09-475a-8d34-a63f7f087bd7"
new_id = stable_chart_id(chart_uuid)
result = _export_with_chart(
chart_uuid,
392,
{
"native_filter_configuration": [],
"timed_refresh_immune_slices": [392],
},
)
assert result["metadata"]["timed_refresh_immune_slices"] == [new_id]
def test_stabilize_chart_ids_remaps_filter_scopes_keys_and_immune() -> None:
"""
filter_scopes is a dict keyed by env-local chart id, whose values hold nested
per-column ``immune`` lists of chart ids. The exporter must remap BOTH the
top-level keys AND the nested immune arrays to the stabilized ids.
"""
from superset.commands.dashboard.export import stable_chart_id
chart_uuid = "812bc377-ac09-475a-8d34-a63f7f087bd7"
new_id = stable_chart_id(chart_uuid)
result = _export_with_chart(
chart_uuid,
392,
{
"native_filter_configuration": [],
"filter_scopes": {
"392": {
"region": {
"scope": ["ROOT_ID"],
"immune": [392],
}
}
},
},
)
filter_scopes = result["metadata"]["filter_scopes"]
# Top-level key remapped to the stabilized id (and the old key is gone).
assert str(new_id) in filter_scopes
assert "392" not in filter_scopes
# Nested immune array remapped too.
assert filter_scopes[str(new_id)]["region"]["immune"] == [new_id]
def test_stabilize_chart_ids_remaps_expanded_slices() -> None:
"""
expanded_slices is a dict keyed by env-local chart id. The exporter must
re-key it to the stabilized ids while preserving the values.
"""
from superset.commands.dashboard.export import stable_chart_id
chart_uuid = "812bc377-ac09-475a-8d34-a63f7f087bd7"
new_id = stable_chart_id(chart_uuid)
result = _export_with_chart(
chart_uuid,
392,
{
"native_filter_configuration": [],
"expanded_slices": {"392": True},
},
)
expanded_slices = result["metadata"]["expanded_slices"]
assert str(new_id) in expanded_slices
assert "392" not in expanded_slices
assert expanded_slices[str(new_id)] is True
def test_file_content_missing_dataset_preserves_dataset_id() -> None:
"""
When DatasetDAO.find_by_id returns None for a display control target,
datasetId is preserved (dual-write: it was never popped) and no
@@ -292,3 +773,46 @@ def test_file_content_missing_dataset_preserves_dataset_id():
target = result["metadata"]["chart_customization_config"][0]["targets"][0]
assert target["datasetId"] == 9999
assert "datasetUuid" not in target
def test_stabilize_chart_ids_resolves_id_collisions() -> None:
"""
Two charts whose UUIDs reduce to the same derived id must still get distinct
stabilized ids, so the chart-keyed metadata remaps (filter_scopes,
chart_configuration, …) never silently overwrite one another. The forced
collision is simulated by stubbing ``stable_chart_id`` to a constant.
"""
from superset.commands.dashboard.export import _stabilize_chart_ids
uuid_a = "00000000-0000-4000-8000-00000000000a"
uuid_b = "00000000-0000-4000-8000-00000000000b"
payload: dict[str, Any] = {
"position": {
"CHART-a": {
"type": "CHART",
"meta": {"chartId": 1, "uuid": uuid_a},
},
"CHART-b": {
"type": "CHART",
"meta": {"chartId": 2, "uuid": uuid_b},
},
},
"metadata": {
"expanded_slices": {"1": True, "2": False},
},
}
with patch(
"superset.commands.dashboard.export.stable_chart_id",
return_value=100,
):
_stabilize_chart_ids(payload)
id_a = payload["position"]["CHART-a"]["meta"]["chartId"]
id_b = payload["position"]["CHART-b"]["meta"]["chartId"]
# Distinct ids despite the collision; one keeps the derived value, the other
# is deterministically probed forward.
assert id_a != id_b
assert {id_a, id_b} == {100, 101}
# Neither expanded_slices entry was dropped by a key clash.
assert set(payload["metadata"]["expanded_slices"]) == {str(id_a), str(id_b)}