Files
superset2/tests/integration_tests/charts/version_restore_tests.py
2026-07-29 09:54:37 -07:00

457 lines
17 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 chart (Slice) version restore.
Covers POST /api/v1/chart/<uuid>/versions/<version_uuid>/restore: the
non-destructive revert applies the target snapshot, appends a new
version row, attributes the change to the restoring user, and returns
the documented 400/404 errors for malformed or unknown UUIDs.
"""
from __future__ import annotations
from typing import Any
import pytest
from sqlalchemy_continuum import version_class
from superset.extensions import db
from superset.models.slice import Slice
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(chart: Slice) -> list[Any]:
ver_cls = version_class(Slice)
return (
db.session.query(ver_cls)
.filter(ver_cls.id == chart.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 TestChartRestoreApi(SupersetTestCase):
"""T037 — POST /api/v1/chart/<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, chart_uuid: str, version_uuid: str) -> Any:
return self.client.post(
f"/api/v1/chart/{chart_uuid}/versions/{version_uuid}/restore"
)
def _list(self, chart_uuid: str) -> Any:
return self.client.get(f"/api/v1/chart/{chart_uuid}/versions/")
def test_restore_applies_scalar_field_from_target_version(self) -> None:
"""Restoring version 0 puts the slice_name back to its pre-edit value
and appends a new version entry."""
_persist_fixture_state()
chart: Slice = (
db.session.query(Slice).filter(Slice.slice_name == "Girls").first()
)
assert chart is not None
chart_uuid = str(chart.uuid)
original_name = chart.slice_name
# Produce two additional saves so version history is 0/1/2.
chart.slice_name = "Girls v1"
db.session.commit()
chart.slice_name = "Girls v2"
db.session.commit()
self.login(ADMIN_USERNAME)
rv_list = self._list(chart_uuid)
assert rv_list.status_code == 200
listing = _json.loads(rv_list.data.decode("utf-8"))
initial_count = listing["count"]
assert initial_count >= 3
target_uuid = listing["result"][0]["version_uuid"]
# Restore to the first version (the original "Girls" name).
rv = self._restore(chart_uuid, target_uuid)
assert rv.status_code == 200, rv.data
# Live state matches the restored snapshot.
db.session.expire_all()
chart = db.session.query(Slice).filter(Slice.uuid == chart.uuid).one()
assert chart.slice_name == original_name
# A new version row was recorded (non-destructive).
rv_list2 = self._list(chart_uuid)
body = _json.loads(rv_list2.data.decode("utf-8"))
assert body["count"] == initial_count + 1
# Cleanup
chart.slice_name = original_name
db.session.commit()
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()
chart: Slice = (
db.session.query(Slice).filter(Slice.slice_name == "Boys").first()
)
assert chart is not None
self.login(ADMIN_USERNAME)
rv = self._restore(str(chart.uuid), "00000000-0000-0000-0000-000000000099")
assert rv.status_code == 404
def test_restore_returns_400_for_invalid_entity_uuid(self) -> None:
self.login(ADMIN_USERNAME)
rv = self._restore("not-a-uuid", "00000000-0000-0000-0000-000000000001")
assert rv.status_code == 400
def test_restore_returns_400_for_invalid_version_uuid(self) -> None:
_persist_fixture_state()
chart: Slice = (
db.session.query(Slice).filter(Slice.slice_name == "Boys").first()
)
assert chart is not None
self.login(ADMIN_USERNAME)
rv = self._restore(str(chart.uuid), "not-a-uuid")
assert rv.status_code == 400
def test_get_version_returns_historical_snapshot(self) -> None:
"""GET /versions/<uuid>/ returns the chart's fields at that version
without modifying live state."""
_persist_fixture_state()
chart: Slice = (
db.session.query(Slice).filter(Slice.slice_name == "Girls").first()
)
assert chart is not None
chart_uuid = str(chart.uuid)
original_name = chart.slice_name
chart.slice_name = "Girls (v1)"
db.session.commit()
self.login(ADMIN_USERNAME)
listing = _json.loads(self._list(chart_uuid).data.decode("utf-8"))
assert listing["count"] >= 2
# The earliest entry should still hold the original slice_name.
first_version_uuid = listing["result"][0]["version_uuid"]
rv = self.client.get(
f"/api/v1/chart/{chart_uuid}/versions/{first_version_uuid}/"
)
assert rv.status_code == 200, rv.data
body = _json.loads(rv.data.decode("utf-8"))["result"]
assert body["slice_name"] == original_name
assert body["_version"]["version_uuid"] == first_version_uuid
assert body["_version"]["version_number"] == 0
# Live row unchanged.
db.session.expire_all()
live = db.session.query(Slice).filter(Slice.uuid == chart.uuid).one()
assert live.slice_name == "Girls (v1)"
# Cleanup
live.slice_name = original_name
db.session.commit()
def test_get_version_returns_404_for_unknown_entity(self) -> None:
self.login(ADMIN_USERNAME)
rv = self.client.get(
"/api/v1/chart/00000000-0000-0000-0000-000000000000"
"/versions/00000000-0000-0000-0000-000000000001/"
)
assert rv.status_code == 404
def test_get_version_returns_400_for_invalid_uuid(self) -> None:
self.login(ADMIN_USERNAME)
rv = self.client.get(
"/api/v1/chart/not-a-uuid/versions/00000000-0000-0000-0000-000000000001/"
)
assert rv.status_code == 400
def test_restore_stamps_changed_by_with_restoring_user(self) -> None:
"""After a restore, changed_by_fk on the live entity must point at
the restoring user (not at whoever authored the version being
restored). created_by_fk stays unchanged. The new version row
produced by the restore also carries the restoring user in its
changed_by metadata.
"""
from superset.daos.version import derive_version_uuid
_persist_fixture_state()
self.login(ADMIN_USERNAME)
admin_id = self.get_user(ADMIN_USERNAME).id
chart: Slice = (
db.session.query(Slice).filter(Slice.slice_name == "Girls").first()
)
assert chart is not None
chart_id = chart.id
chart_uuid = str(chart.uuid)
entity_uuid = chart.uuid
original_name = chart.slice_name
original_created_by = chart.created_by_fk
before_changed_on = chart.changed_on
# Produce a second version to restore to.
chart.slice_name = "Girls v1"
db.session.commit()
ver_cls = version_class(Slice)
first_tx = (
db.session.query(ver_cls.transaction_id)
.filter(ver_cls.id == chart_id)
.order_by(ver_cls.transaction_id.asc())
.limit(1)
.scalar()
)
assert first_tx is not None
target_uuid = str(derive_version_uuid(entity_uuid, first_tx))
rv = self.client.post(
f"/api/v1/chart/{chart_uuid}/versions/{target_uuid}/restore"
)
assert rv.status_code == 200, rv.data
db.session.expire_all()
chart = db.session.query(Slice).filter(Slice.id == chart_id).one()
# Live entity checks.
assert chart.slice_name == original_name
assert chart.created_by_fk == original_created_by
assert chart.changed_by_fk == admin_id, (
f"Expected changed_by_fk to be restoring user id={admin_id}, "
f"got {chart.changed_by_fk}"
)
if before_changed_on is not None and chart.changed_on is not None:
assert chart.changed_on >= before_changed_on
# The new version row produced by the restore must attribute the
# change to the restoring user.
rv_list = self.client.get(f"/api/v1/chart/{chart_uuid}/versions/")
assert rv_list.status_code == 200
body = _json.loads(rv_list.data.decode("utf-8"))
latest_entry = body["result"][-1]
assert latest_entry["changed_by"] is not None, (
"New version row should have a changed_by"
)
assert latest_entry["changed_by"]["id"] == admin_id
# Cleanup
chart.slice_name = original_name
db.session.commit()
def test_put_response_returns_old_and_new_version_numbers(self) -> None:
"""PUT /api/v1/chart/<id> response must include old_version and
new_version matching the list-versions ordering."""
_persist_fixture_state()
chart: Slice = (
db.session.query(Slice).filter(Slice.slice_name == "Girls").first()
)
assert chart is not None
chart_id = chart.id
original_name = chart.slice_name
ver_cls = version_class(Slice)
count_before = db.session.query(ver_cls).filter(ver_cls.id == chart_id).count()
expected_old = count_before - 1 if count_before > 0 else None
self.login(ADMIN_USERNAME)
rv = self.client.put(
f"/api/v1/chart/{chart_id}",
json={"slice_name": "put-response-version-test"},
)
assert rv.status_code == 200, rv.data
body = _json.loads(rv.data.decode("utf-8"))
assert body["id"] == chart_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
chart = db.session.query(Slice).filter(Slice.id == chart_id).one()
chart.slice_name = original_name
db.session.commit()
def test_restore_denies_non_editor_with_write_permission(self) -> None:
"""A user holding can_write on Chart but who is not an editor of
THIS chart gets 403 from the command's ``raise_for_editorship``
branch — the interesting case route-level ``@protect()`` cannot
catch."""
from superset.daos.version import derive_version_uuid
from tests.integration_tests.constants import ALPHA_USERNAME
_persist_fixture_state()
chart: Slice = (
db.session.query(Slice).filter(Slice.slice_name == "Girls").first()
)
assert chart is not None
# Ensure alpha is not an editor of the fixture chart.
alpha = self.get_user(ALPHA_USERNAME)
assert alpha not in chart.editors
ver_cls = version_class(Slice)
first_tx = (
db.session.query(ver_cls.transaction_id)
.filter(ver_cls.id == chart.id)
.order_by(ver_cls.transaction_id.asc())
.limit(1)
.scalar()
)
assert first_tx is not None
target_uuid = str(derive_version_uuid(chart.uuid, first_tx))
self.login(ALPHA_USERNAME)
rv = self._restore(str(chart.uuid), target_uuid)
assert rv.status_code == 403, rv.data
db.session.refresh(chart)
assert chart.slice_name == "Girls"
def test_restore_returns_404_when_capture_disabled(self) -> None:
"""With ENABLE_VERSIONING_CAPTURE off, the restore route is inert:
Continuum's write listeners are detached, so a revert would mutate
the live entity with no new version row — a destructive, untracked
write. The command refuses with 404 before touching anything."""
_persist_fixture_state()
chart: Slice = (
db.session.query(Slice).filter(Slice.slice_name == "Girls").first()
)
assert chart is not None
original_name = chart.slice_name
self.login(ADMIN_USERNAME)
listing = _json.loads(self._list(str(chart.uuid)).data.decode("utf-8"))
target_uuid = listing["result"][0]["version_uuid"]
self.app.config["ENABLE_VERSIONING_CAPTURE"] = False
try:
rv = self._restore(str(chart.uuid), target_uuid)
finally:
self.app.config["ENABLE_VERSIONING_CAPTURE"] = True
assert rv.status_code == 404, rv.data
db.session.expire_all()
live = db.session.query(Slice).filter(Slice.id == chart.id).one()
assert live.slice_name == original_name
def test_restore_returns_404_for_other_entitys_version_uuid(self) -> None:
"""A version_uuid belonging to a DIFFERENT chart must not resolve:
version identity is (entity_uuid, transaction), so entity A's
version_uuid presented under entity B's path is a 404."""
from superset.daos.version import derive_version_uuid
_persist_fixture_state()
girls: Slice = (
db.session.query(Slice).filter(Slice.slice_name == "Girls").first()
)
boys: Slice = db.session.query(Slice).filter(Slice.slice_name == "Boys").first()
assert girls is not None
assert boys is not None
ver_cls = version_class(Slice)
boys_tx = (
db.session.query(ver_cls.transaction_id)
.filter(ver_cls.id == boys.id)
.order_by(ver_cls.transaction_id.asc())
.limit(1)
.scalar()
)
assert boys_tx is not None
boys_version_uuid = str(derive_version_uuid(boys.uuid, boys_tx))
self.login(ADMIN_USERNAME)
rv = self._restore(str(girls.uuid), boys_version_uuid)
assert rv.status_code == 404, rv.data
def test_restore_stamps_action_kind_restore_on_transaction(self) -> None:
"""The restoring commit's version_transaction row must carry
``action_kind='restore'`` so the activity feed renders it as a
restore, not an ordinary save (contract in versioning/changes)."""
from sqlalchemy_continuum import versioning_manager
from superset.daos.version import derive_version_uuid
_persist_fixture_state()
chart: Slice = (
db.session.query(Slice).filter(Slice.slice_name == "Girls").first()
)
assert chart is not None
chart_id = chart.id
original_name = chart.slice_name
chart.slice_name = "Girls action-kind v1"
db.session.commit()
ver_cls = version_class(Slice)
first_tx = (
db.session.query(ver_cls.transaction_id)
.filter(ver_cls.id == chart_id)
.order_by(ver_cls.transaction_id.asc())
.limit(1)
.scalar()
)
target_uuid = str(derive_version_uuid(chart.uuid, first_tx))
self.login(ADMIN_USERNAME)
rv = self._restore(str(chart.uuid), target_uuid)
assert rv.status_code == 200, rv.data
latest_tx = (
db.session.query(ver_cls.transaction_id)
.filter(ver_cls.id == chart_id)
.order_by(ver_cls.transaction_id.desc())
.limit(1)
.scalar()
)
tx_tbl = versioning_manager.transaction_cls.__table__
action_kind = (
db.session.execute(tx_tbl.select().where(tx_tbl.c.id == latest_tx))
.mappings()
.one()["action_kind"]
)
assert action_kind == "restore"
# Cleanup
db.session.expire_all()
chart = db.session.query(Slice).filter(Slice.id == chart_id).one()
chart.slice_name = original_name
db.session.commit()