mirror of
https://github.com/apache/superset.git
synced 2026-08-04 04:52:32 +00:00
Co-authored-by: Mike Bridge <michael.bridge@ext.preset.io> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
419 lines
16 KiB
Python
419 lines
16 KiB
Python
# 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.
|
|
"""Integration tests for Dashboard version restore.
|
|
|
|
Covers POST /api/v1/dashboard/<uuid>/versions/<version_uuid>/restore:
|
|
the non-destructive revert applies the target snapshot (including
|
|
reattaching a chart removed after that snapshot) and returns the
|
|
documented 404s for unknown entity/version UUIDs.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
import pytest
|
|
import sqlalchemy as sa
|
|
from sqlalchemy_continuum import version_class
|
|
|
|
from superset.extensions import db
|
|
from superset.models.dashboard import Dashboard
|
|
from superset.utils import json as _json
|
|
from tests.integration_tests.base_tests import SupersetTestCase
|
|
from tests.integration_tests.constants import ADMIN_USERNAME
|
|
from tests.integration_tests.fixtures.birth_names_dashboard import ( # noqa: F401
|
|
load_birth_names_dashboard_with_slices,
|
|
load_birth_names_data,
|
|
)
|
|
|
|
|
|
def _get_version_rows(dashboard: Dashboard) -> list[Any]:
|
|
ver_cls = version_class(Dashboard)
|
|
return (
|
|
db.session.query(ver_cls)
|
|
.filter(ver_cls.id == dashboard.id)
|
|
.order_by(ver_cls.transaction_id.asc())
|
|
.all()
|
|
)
|
|
|
|
|
|
def _persist_fixture_state() -> None:
|
|
"""Force fixture's pending INSERTs to commit in their own transaction.
|
|
|
|
The birth_names fixture stages charts and the dashboard via session.add()
|
|
but does not commit. Without this, the test's first commit batches the
|
|
INSERTs and UPDATEs into the same Continuum transaction, causing the
|
|
existing version row to be updated in place instead of a new one being
|
|
created.
|
|
"""
|
|
db.session.commit()
|
|
|
|
|
|
class TestDashboardRestoreApi(SupersetTestCase):
|
|
"""T038 — POST /api/v1/dashboard/<uuid>/versions/<version_uuid>/restore."""
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _load_data(self, load_birth_names_dashboard_with_slices): # noqa: PT004, F811
|
|
pass
|
|
|
|
def _restore(self, dashboard_uuid: str, version_uuid: str) -> Any:
|
|
return self.client.post(
|
|
f"/api/v1/dashboard/{dashboard_uuid}/versions/{version_uuid}/restore"
|
|
)
|
|
|
|
def test_restore_applies_scalar_field(self) -> None:
|
|
"""Restore a dashboard title edit."""
|
|
from superset.daos.version import derive_version_uuid
|
|
|
|
_persist_fixture_state()
|
|
dashboard: Dashboard = (
|
|
db.session.query(Dashboard)
|
|
.filter(Dashboard.dashboard_title == "USA Births Names")
|
|
.first()
|
|
)
|
|
assert dashboard is not None
|
|
dashboard_uuid = str(dashboard.uuid)
|
|
original_title = dashboard.dashboard_title
|
|
dashboard_id = dashboard.id
|
|
entity_uuid = dashboard.uuid
|
|
|
|
# Make two more edits so we have a known non-trivial history to
|
|
# navigate: [initial, v1, v2].
|
|
dashboard.dashboard_title = "USA Births Names v1"
|
|
db.session.commit()
|
|
dashboard.dashboard_title = "USA Births Names v2"
|
|
db.session.commit()
|
|
|
|
ver_cls = version_class(Dashboard)
|
|
rows = (
|
|
db.session.query(
|
|
ver_cls.transaction_id,
|
|
ver_cls.operation_type,
|
|
ver_cls.dashboard_title,
|
|
ver_cls.end_transaction_id,
|
|
)
|
|
.filter(ver_cls.id == dashboard_id)
|
|
.order_by(ver_cls.transaction_id.asc())
|
|
.all()
|
|
)
|
|
# Find the version whose snapshot has the original title.
|
|
target_row = next(
|
|
(row for row in rows if row.dashboard_title == original_title),
|
|
None,
|
|
)
|
|
assert target_row is not None, (
|
|
f"Expected at least one version row with original title; rows={rows}"
|
|
)
|
|
target_uuid = str(derive_version_uuid(entity_uuid, target_row.transaction_id))
|
|
|
|
self.login(ADMIN_USERNAME)
|
|
rv = self._restore(dashboard_uuid, target_uuid)
|
|
assert rv.status_code == 200, rv.data
|
|
|
|
db.session.expire_all()
|
|
dashboard = (
|
|
db.session.query(Dashboard).filter(Dashboard.id == dashboard_id).one()
|
|
)
|
|
assert dashboard.dashboard_title == original_title, (
|
|
f"Restore did not revert title; rows={rows}"
|
|
)
|
|
|
|
# Cleanup
|
|
dashboard.dashboard_title = original_title
|
|
db.session.commit()
|
|
|
|
def test_restore_reattaches_chart_removed_after_snapshot(self) -> None:
|
|
"""After the target snapshot is captured, detaching a chart and saving
|
|
must be undone by restore — the chart comes back on dashboard_slices."""
|
|
from superset.daos.version import derive_version_uuid
|
|
|
|
_persist_fixture_state()
|
|
dashboard: Dashboard = (
|
|
db.session.query(Dashboard)
|
|
.filter(Dashboard.dashboard_title == "USA Births Names")
|
|
.first()
|
|
)
|
|
assert dashboard is not None
|
|
dashboard_uuid = str(dashboard.uuid)
|
|
dashboard_id = dashboard.id
|
|
entity_uuid = dashboard.uuid
|
|
|
|
original_slice_ids = sorted(s.id for s in dashboard.slices)
|
|
assert len(original_slice_ids) >= 2, (
|
|
f"fixture expected to attach >= 2 charts; got {original_slice_ids}"
|
|
)
|
|
slice_to_drop = dashboard.slices[0]
|
|
drop_id = slice_to_drop.id
|
|
|
|
# Touch the dashboard so a snapshot row is captured at a known tx.
|
|
dashboard.dashboard_title = "USA Births Names — snapshot point"
|
|
db.session.commit()
|
|
|
|
ver_cls = version_class(Dashboard)
|
|
target_tx = (
|
|
db.session.query(ver_cls.transaction_id)
|
|
.filter(ver_cls.id == dashboard_id)
|
|
.order_by(ver_cls.transaction_id.desc())
|
|
.limit(1)
|
|
.scalar()
|
|
)
|
|
assert target_tx is not None
|
|
target_uuid = str(derive_version_uuid(entity_uuid, target_tx))
|
|
|
|
# Detach the chart and commit — moves history forward.
|
|
dashboard.slices.remove(slice_to_drop)
|
|
db.session.commit()
|
|
|
|
db.session.expire_all()
|
|
dashboard = (
|
|
db.session.query(Dashboard).filter(Dashboard.id == dashboard_id).one()
|
|
)
|
|
live_ids = {s.id for s in dashboard.slices}
|
|
assert drop_id not in live_ids, "pre-restore: dropped chart should be detached"
|
|
|
|
self.login(ADMIN_USERNAME)
|
|
rv = self._restore(dashboard_uuid, target_uuid)
|
|
assert rv.status_code == 200, rv.data
|
|
|
|
db.session.expire_all()
|
|
dashboard = (
|
|
db.session.query(Dashboard).filter(Dashboard.id == dashboard_id).one()
|
|
)
|
|
restored_ids = sorted(s.id for s in dashboard.slices)
|
|
assert restored_ids == original_slice_ids, (
|
|
f"restore did not re-attach chart: expected {original_slice_ids}, "
|
|
f"got {restored_ids}"
|
|
)
|
|
|
|
def test_restore_preserves_live_chart_content(self) -> None:
|
|
"""Dashboard restore is membership-only: a member chart edited
|
|
AFTER the snapshot keeps its current content — charts are shared
|
|
entities with their own restore endpoint, so a dashboard restore
|
|
must never rewrite them to historical values."""
|
|
from superset.daos.version import derive_version_uuid
|
|
from superset.models.slice import Slice
|
|
|
|
_persist_fixture_state()
|
|
dashboard: Dashboard = (
|
|
db.session.query(Dashboard)
|
|
.filter(Dashboard.dashboard_title == "USA Births Names")
|
|
.first()
|
|
)
|
|
assert dashboard is not None
|
|
dashboard_id = dashboard.id
|
|
member = dashboard.slices[0]
|
|
member_id = member.id
|
|
original_chart_name = member.slice_name
|
|
original_title = dashboard.dashboard_title
|
|
|
|
# Snapshot point: chart still has its original name.
|
|
dashboard.dashboard_title = "USA Births Names — content snapshot"
|
|
db.session.commit()
|
|
|
|
ver_cls = version_class(Dashboard)
|
|
target_tx = (
|
|
db.session.query(ver_cls.transaction_id)
|
|
.filter(ver_cls.id == dashboard_id)
|
|
.order_by(ver_cls.transaction_id.desc())
|
|
.limit(1)
|
|
.scalar()
|
|
)
|
|
target_uuid = str(derive_version_uuid(dashboard.uuid, target_tx))
|
|
|
|
# Edit the member chart AFTER the snapshot.
|
|
member = db.session.query(Slice).filter(Slice.id == member_id).one()
|
|
member.slice_name = "edited after snapshot"
|
|
db.session.commit()
|
|
|
|
self.login(ADMIN_USERNAME)
|
|
rv = self._restore(str(dashboard.uuid), target_uuid)
|
|
assert rv.status_code == 200, rv.data
|
|
|
|
db.session.expire_all()
|
|
member = db.session.query(Slice).filter(Slice.id == member_id).one()
|
|
assert member.slice_name == "edited after snapshot", (
|
|
"dashboard restore must not rewrite live member chart content"
|
|
)
|
|
|
|
# Cleanup
|
|
member.slice_name = original_chart_name
|
|
dashboard = (
|
|
db.session.query(Dashboard).filter(Dashboard.id == dashboard_id).one()
|
|
)
|
|
dashboard.dashboard_title = original_title
|
|
db.session.commit()
|
|
|
|
def test_restore_skips_member_chart_that_no_longer_exists(self) -> None:
|
|
"""A snapshot member whose chart row has been hard-deleted stays
|
|
deleted: restore succeeds, reattaches the surviving members, does
|
|
NOT revive the deleted chart, and says so in the response."""
|
|
from superset.daos.version import derive_version_uuid
|
|
from superset.models.slice import Slice
|
|
|
|
_persist_fixture_state()
|
|
dashboard: Dashboard = (
|
|
db.session.query(Dashboard)
|
|
.filter(Dashboard.dashboard_title == "USA Births Names")
|
|
.first()
|
|
)
|
|
assert dashboard is not None
|
|
dashboard_id = dashboard.id
|
|
original_ids = sorted(s.id for s in dashboard.slices)
|
|
assert len(original_ids) >= 2
|
|
victim = dashboard.slices[0]
|
|
victim_id = victim.id
|
|
|
|
# Snapshot point: victim is a member.
|
|
dashboard.dashboard_title = "USA Births Names — skip snapshot"
|
|
db.session.commit()
|
|
|
|
ver_cls = version_class(Dashboard)
|
|
target_tx = (
|
|
db.session.query(ver_cls.transaction_id)
|
|
.filter(ver_cls.id == dashboard_id)
|
|
.order_by(ver_cls.transaction_id.desc())
|
|
.limit(1)
|
|
.scalar()
|
|
)
|
|
target_uuid = str(derive_version_uuid(dashboard.uuid, target_tx))
|
|
|
|
# Detach, then hard-delete the victim via raw SQL so no live row
|
|
# remains (bypasses the soft-delete listener deliberately — the
|
|
# scenario is a purged/legacy-deleted chart).
|
|
dashboard.slices.remove(victim)
|
|
db.session.commit()
|
|
for assoc in ("chart_editors", "chart_viewers"):
|
|
db.session.execute(
|
|
sa.text(f"DELETE FROM {assoc} WHERE chart_id = :sid"), # noqa: S608
|
|
{"sid": victim_id},
|
|
)
|
|
db.session.execute(
|
|
sa.text("DELETE FROM slices WHERE id = :sid"), {"sid": victim_id}
|
|
)
|
|
db.session.commit()
|
|
|
|
self.login(ADMIN_USERNAME)
|
|
rv = self._restore(str(dashboard.uuid), target_uuid)
|
|
assert rv.status_code == 200, rv.data
|
|
message = _json.loads(rv.data.decode("utf-8")).get("message", "")
|
|
assert "no longer exist" in message, message
|
|
|
|
db.session.expire_all()
|
|
dashboard = (
|
|
db.session.query(Dashboard).filter(Dashboard.id == dashboard_id).one()
|
|
)
|
|
restored_ids = sorted(s.id for s in dashboard.slices)
|
|
assert victim_id not in restored_ids, "deleted chart must stay deleted"
|
|
assert restored_ids == sorted(sid for sid in original_ids if sid != victim_id)
|
|
assert (
|
|
db.session.query(Slice).filter(Slice.id == victim_id).one_or_none() is None
|
|
), "restore must not revive a hard-deleted chart"
|
|
|
|
def test_restore_denies_non_editor_with_write_permission(self) -> None:
|
|
"""A user holding can_write on Dashboard but who is not an editor
|
|
of THIS dashboard gets 403 from the command's editorship check."""
|
|
from superset.daos.version import derive_version_uuid
|
|
from tests.integration_tests.constants import ALPHA_USERNAME
|
|
|
|
_persist_fixture_state()
|
|
dashboard: Dashboard = (
|
|
db.session.query(Dashboard)
|
|
.filter(Dashboard.dashboard_title == "USA Births Names")
|
|
.first()
|
|
)
|
|
assert dashboard is not None
|
|
alpha = self.get_user(ALPHA_USERNAME)
|
|
assert alpha not in dashboard.editors
|
|
|
|
ver_cls = version_class(Dashboard)
|
|
first_tx = (
|
|
db.session.query(ver_cls.transaction_id)
|
|
.filter(ver_cls.id == dashboard.id)
|
|
.order_by(ver_cls.transaction_id.asc())
|
|
.limit(1)
|
|
.scalar()
|
|
)
|
|
assert first_tx is not None
|
|
target_uuid = str(derive_version_uuid(dashboard.uuid, first_tx))
|
|
|
|
self.login(ALPHA_USERNAME)
|
|
rv = self._restore(str(dashboard.uuid), target_uuid)
|
|
assert rv.status_code == 403, rv.data
|
|
db.session.refresh(dashboard)
|
|
assert dashboard.dashboard_title == "USA Births Names"
|
|
|
|
def test_restore_returns_404_for_unknown_uuid(self) -> None:
|
|
self.login(ADMIN_USERNAME)
|
|
rv = self._restore(
|
|
"00000000-0000-0000-0000-000000000000",
|
|
"00000000-0000-0000-0000-000000000001",
|
|
)
|
|
assert rv.status_code == 404
|
|
|
|
def test_restore_returns_404_for_unknown_version_uuid(self) -> None:
|
|
_persist_fixture_state()
|
|
dashboard: Dashboard = (
|
|
db.session.query(Dashboard)
|
|
.filter(Dashboard.dashboard_title == "USA Births Names")
|
|
.first()
|
|
)
|
|
assert dashboard is not None
|
|
self.login(ADMIN_USERNAME)
|
|
rv = self._restore(str(dashboard.uuid), "00000000-0000-0000-0000-000000000099")
|
|
assert rv.status_code == 404
|
|
|
|
def test_put_response_returns_old_and_new_version_numbers(self) -> None:
|
|
"""PUT /api/v1/dashboard/<id> response must include old_version and
|
|
new_version matching the list-versions ordering."""
|
|
_persist_fixture_state()
|
|
dashboard: Dashboard = (
|
|
db.session.query(Dashboard)
|
|
.filter(Dashboard.dashboard_title == "USA Births Names")
|
|
.first()
|
|
)
|
|
assert dashboard is not None
|
|
dashboard_id = dashboard.id
|
|
original_title = dashboard.dashboard_title
|
|
|
|
ver_cls = version_class(Dashboard)
|
|
count_before = (
|
|
db.session.query(ver_cls).filter(ver_cls.id == dashboard_id).count()
|
|
)
|
|
expected_old = count_before - 1 if count_before > 0 else None
|
|
|
|
self.login(ADMIN_USERNAME)
|
|
rv = self.client.put(
|
|
f"/api/v1/dashboard/{dashboard_id}",
|
|
json={"dashboard_title": "put-response-version-test"},
|
|
)
|
|
assert rv.status_code == 200, rv.data
|
|
body = _json.loads(rv.data.decode("utf-8"))
|
|
assert body["id"] == dashboard_id
|
|
assert body["old_version"] == expected_old
|
|
assert body["new_version"] is not None
|
|
assert "old_transaction_id" in body
|
|
assert "new_transaction_id" in body
|
|
if body["old_transaction_id"] is not None:
|
|
assert body["new_transaction_id"] != body["old_transaction_id"]
|
|
|
|
# Cleanup
|
|
dashboard = (
|
|
db.session.query(Dashboard).filter(Dashboard.id == dashboard_id).one()
|
|
)
|
|
dashboard.dashboard_title = original_title
|
|
db.session.commit()
|