mirror of
https://github.com/apache/superset.git
synced 2026-07-11 17:25:31 +00:00
Co-authored-by: Mike Bridge <michael.bridge@ext.preset.io> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
304 lines
12 KiB
Python
304 lines
12 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.
|
|
"""Endpoint tests for the entity version-history REST API.
|
|
|
|
Exercises the wired surface (the ``capture_disabled_tests`` prove the
|
|
kill-switch; these prove the read endpoints and the PUT version fields):
|
|
|
|
* ``GET /api/v1/{chart,dashboard,dataset}/<uuid>/versions/`` — list
|
|
* ``GET /api/v1/{chart,dashboard,dataset}/<uuid>/versions/<version_uuid>/`` — single
|
|
* the six ``old_*``/``new_*`` version fields on the entity ``PUT`` responses
|
|
|
|
The suite runs with ``ENABLE_VERSIONING_CAPTURE=True`` (see
|
|
``tests/integration_tests/superset_test_config.py``); ``conftest.py``'s
|
|
autouse fixture clears the version tables around each test.
|
|
"""
|
|
|
|
from uuid import uuid4
|
|
|
|
import pytest
|
|
|
|
from superset import db
|
|
from superset.connectors.sqla.models import SqlaTable
|
|
from superset.models.dashboard import Dashboard
|
|
from superset.models.slice import Slice
|
|
from superset.utils import json
|
|
from tests.integration_tests.base_tests import SupersetTestCase
|
|
from tests.integration_tests.constants import ADMIN_USERNAME, GAMMA_USERNAME
|
|
from tests.integration_tests.fixtures.birth_names_dashboard import (
|
|
load_birth_names_dashboard_with_slices, # noqa: F401
|
|
load_birth_names_data, # noqa: F401
|
|
)
|
|
|
|
# A syntactically valid UUID that matches no entity / version.
|
|
MISSING_UUID = str(uuid4())
|
|
|
|
|
|
@pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
|
|
class TestChartVersionsApi(SupersetTestCase):
|
|
"""The chart ``/versions/`` endpoints, covered end to end; the dashboard
|
|
and dataset variants share the same helper layer, so only their wiring +
|
|
entity-specific paths are re-checked in the classes below."""
|
|
|
|
def _girls_chart(self) -> Slice:
|
|
db.session.commit()
|
|
chart = db.session.query(Slice).filter(Slice.slice_name == "Girls").one()
|
|
return chart
|
|
|
|
def test_list_versions_happy_path_and_etag_and_ordering(self) -> None:
|
|
"""A chart with history returns an ordered, well-shaped list + an ETag."""
|
|
chart = self._girls_chart()
|
|
chart_uuid, chart_id = str(chart.uuid), chart.id
|
|
self.login(ADMIN_USERNAME)
|
|
try:
|
|
# Two edits → at least a baseline (op 0) plus update rows.
|
|
assert (
|
|
self.client.put(
|
|
f"/api/v1/chart/{chart_id}", json={"slice_name": "Girls v2"}
|
|
).status_code
|
|
== 200
|
|
)
|
|
assert (
|
|
self.client.put(
|
|
f"/api/v1/chart/{chart_id}", json={"slice_name": "Girls v3"}
|
|
).status_code
|
|
== 200
|
|
)
|
|
|
|
rv = self.get_assert_metric(
|
|
f"/api/v1/chart/{chart_uuid}/versions/", "list_versions"
|
|
)
|
|
assert rv.status_code == 200, rv.data
|
|
assert rv.headers.get("ETag"), "list response must carry an ETag"
|
|
|
|
body = json.loads(rv.data.decode("utf-8"))
|
|
assert set(body) >= {"result", "count"}
|
|
assert body["count"] == len(body["result"]) >= 2
|
|
for item in body["result"]:
|
|
assert set(item) >= {
|
|
"version_uuid",
|
|
"version_number",
|
|
"transaction_id",
|
|
"operation_type",
|
|
"issued_at",
|
|
"changes",
|
|
}
|
|
for change in item["changes"]:
|
|
# The verb column we surface explicitly on the read side.
|
|
assert "operation" in change
|
|
assert "kind" in change
|
|
|
|
# version_number is 0-based, oldest first; the baseline sits at 0.
|
|
numbers = [i["version_number"] for i in body["result"]]
|
|
assert numbers == sorted(numbers)
|
|
assert numbers[0] == 0
|
|
assert body["result"][0]["operation_type"] == "baseline"
|
|
finally:
|
|
self.client.put(f"/api/v1/chart/{chart_id}", json={"slice_name": "Girls"})
|
|
|
|
def test_get_single_version_happy_path(self) -> None:
|
|
"""A single version resolves to a snapshot carrying a ``_version`` block."""
|
|
chart = self._girls_chart()
|
|
chart_uuid, chart_id = str(chart.uuid), chart.id
|
|
self.login(ADMIN_USERNAME)
|
|
try:
|
|
self.client.put(
|
|
f"/api/v1/chart/{chart_id}", json={"slice_name": "Girls edited"}
|
|
)
|
|
listing = json.loads(
|
|
self.client.get(f"/api/v1/chart/{chart_uuid}/versions/").data
|
|
)
|
|
version_uuid = listing["result"][0]["version_uuid"]
|
|
|
|
rv = self.get_assert_metric(
|
|
f"/api/v1/chart/{chart_uuid}/versions/{version_uuid}/", "get_version"
|
|
)
|
|
assert rv.status_code == 200, rv.data
|
|
assert rv.headers.get("ETag")
|
|
snapshot = json.loads(rv.data.decode("utf-8"))["result"]
|
|
assert "_version" in snapshot
|
|
assert set(snapshot["_version"]) >= {
|
|
"version_uuid",
|
|
"version_number",
|
|
"operation_type",
|
|
}
|
|
finally:
|
|
self.client.put(f"/api/v1/chart/{chart_id}", json={"slice_name": "Girls"})
|
|
|
|
def test_list_versions_400_on_invalid_entity_uuid(self) -> None:
|
|
self.login(ADMIN_USERNAME)
|
|
rv = self.client.get("/api/v1/chart/not-a-uuid/versions/")
|
|
assert rv.status_code == 400, rv.data
|
|
|
|
def test_list_versions_404_on_unknown_entity(self) -> None:
|
|
self.login(ADMIN_USERNAME)
|
|
rv = self.client.get(f"/api/v1/chart/{MISSING_UUID}/versions/")
|
|
assert rv.status_code == 404, rv.data
|
|
|
|
def test_get_version_400_on_invalid_version_uuid(self) -> None:
|
|
chart_uuid = str(self._girls_chart().uuid)
|
|
self.login(ADMIN_USERNAME)
|
|
rv = self.client.get(f"/api/v1/chart/{chart_uuid}/versions/not-a-uuid/")
|
|
assert rv.status_code == 400, rv.data
|
|
|
|
def test_get_version_404_on_unknown_version(self) -> None:
|
|
chart_uuid = str(self._girls_chart().uuid)
|
|
self.login(ADMIN_USERNAME)
|
|
rv = self.client.get(f"/api/v1/chart/{chart_uuid}/versions/{MISSING_UUID}/")
|
|
assert rv.status_code == 404, rv.data
|
|
|
|
def test_list_versions_denies_unauthorized_user(self) -> None:
|
|
"""The per-object access gate (``raise_for_access(chart=...)``) must
|
|
refuse a user without access — as a 403, or 404 if the object isn't
|
|
even visible to them."""
|
|
chart_uuid = str(self._girls_chart().uuid)
|
|
self.login(GAMMA_USERNAME)
|
|
rv = self.client.get(f"/api/v1/chart/{chart_uuid}/versions/")
|
|
assert rv.status_code in (403, 404), rv.data
|
|
|
|
def test_put_non_numeric_pk_does_not_500_with_capture_on(self) -> None:
|
|
"""The PUT route is ``/<pk>`` (a string); with capture on, a
|
|
non-numeric pk must not raise a raw SQL type-cast error from the
|
|
pre-update version lookup — it should resolve to a clean 4xx."""
|
|
self.login(ADMIN_USERNAME)
|
|
rv = self.client.put("/api/v1/chart/not-a-number", json={"slice_name": "x"})
|
|
assert rv.status_code < 500, rv.data
|
|
|
|
def test_put_returns_version_fields_when_capture_on(self) -> None:
|
|
"""A capture-on chart PUT surfaces the six version fields, populated."""
|
|
chart_id = self._girls_chart().id
|
|
self.login(ADMIN_USERNAME)
|
|
try:
|
|
rv = self.client.put(
|
|
f"/api/v1/chart/{chart_id}", json={"slice_name": "Girls versioned"}
|
|
)
|
|
assert rv.status_code == 200, rv.data
|
|
body = json.loads(rv.data.decode("utf-8"))
|
|
assert set(body) >= {
|
|
"old_version",
|
|
"new_version",
|
|
"old_transaction_id",
|
|
"new_transaction_id",
|
|
"old_version_uuid",
|
|
"new_version_uuid",
|
|
}
|
|
# Capture is on, so the post-save fields are populated.
|
|
assert body["new_version"] is not None
|
|
assert body["new_version_uuid"] is not None
|
|
finally:
|
|
self.client.put(f"/api/v1/chart/{chart_id}", json={"slice_name": "Girls"})
|
|
|
|
|
|
@pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
|
|
class TestDashboardVersionsApi(SupersetTestCase):
|
|
def _dashboard(self) -> Dashboard:
|
|
db.session.commit()
|
|
return self.get_dash_by_slug("births")
|
|
|
|
def test_list_versions_happy_path(self) -> None:
|
|
dash = self._dashboard()
|
|
dash_uuid, dash_id = str(dash.uuid), dash.id
|
|
self.login(ADMIN_USERNAME)
|
|
original = dash.dashboard_title
|
|
try:
|
|
assert (
|
|
self.client.put(
|
|
f"/api/v1/dashboard/{dash_id}",
|
|
json={"dashboard_title": "Births versioned"},
|
|
).status_code
|
|
== 200
|
|
)
|
|
rv = self.get_assert_metric(
|
|
f"/api/v1/dashboard/{dash_uuid}/versions/", "list_versions"
|
|
)
|
|
assert rv.status_code == 200, rv.data
|
|
assert rv.headers.get("ETag")
|
|
body = json.loads(rv.data.decode("utf-8"))
|
|
assert body["count"] == len(body["result"]) >= 1
|
|
assert body["result"][0]["version_number"] == 0
|
|
finally:
|
|
self.client.put(
|
|
f"/api/v1/dashboard/{dash_id}",
|
|
json={"dashboard_title": original},
|
|
)
|
|
|
|
def test_list_versions_404_on_unknown_dashboard(self) -> None:
|
|
self.login(ADMIN_USERNAME)
|
|
rv = self.client.get(f"/api/v1/dashboard/{MISSING_UUID}/versions/")
|
|
assert rv.status_code == 404, rv.data
|
|
|
|
|
|
@pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
|
|
class TestDatasetVersionsApi(SupersetTestCase):
|
|
def _dataset(self) -> SqlaTable:
|
|
db.session.commit()
|
|
return (
|
|
db.session.query(SqlaTable)
|
|
.filter(SqlaTable.table_name == "birth_names")
|
|
.one()
|
|
)
|
|
|
|
def test_list_versions_happy_path(self) -> None:
|
|
dataset = self._dataset()
|
|
ds_uuid, ds_id = str(dataset.uuid), dataset.id
|
|
self.login(ADMIN_USERNAME)
|
|
original = dataset.description
|
|
try:
|
|
assert (
|
|
self.client.put(
|
|
f"/api/v1/dataset/{ds_id}",
|
|
json={"description": "versioned description"},
|
|
).status_code
|
|
== 200
|
|
)
|
|
rv = self.get_assert_metric(
|
|
f"/api/v1/dataset/{ds_uuid}/versions/", "list_versions"
|
|
)
|
|
assert rv.status_code == 200, rv.data
|
|
assert rv.headers.get("ETag")
|
|
body = json.loads(rv.data.decode("utf-8"))
|
|
assert body["count"] == len(body["result"]) >= 1
|
|
finally:
|
|
self.client.put(
|
|
f"/api/v1/dataset/{ds_id}",
|
|
json={"description": original},
|
|
)
|
|
|
|
def test_put_override_columns_returns_version_fields(self) -> None:
|
|
"""The ``override_columns`` save is two commits / two Continuum
|
|
transactions (update + refresh); the PUT still returns populated
|
|
version fields and the response is a clean 200."""
|
|
dataset = self._dataset()
|
|
ds_id = dataset.id
|
|
self.login(ADMIN_USERNAME)
|
|
# Round-trip the dataset's own columns back through an override save.
|
|
existing = json.loads(self.client.get(f"/api/v1/dataset/{ds_id}").data)[
|
|
"result"
|
|
]
|
|
columns = [
|
|
{"column_name": c["column_name"], "type": c.get("type")}
|
|
for c in existing["columns"]
|
|
]
|
|
rv = self.client.put(
|
|
f"/api/v1/dataset/{ds_id}?override_columns=true",
|
|
json={"columns": columns},
|
|
)
|
|
assert rv.status_code == 200, rv.data
|
|
body = json.loads(rv.data.decode("utf-8"))
|
|
assert set(body) >= {"old_version", "new_version", "new_version_uuid"}
|
|
assert body["new_version_uuid"] is not None
|