Files
superset2/tests/integration_tests/themes/api_tests.py

524 lines
18 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.
# isort:skip_file
"""Unit tests for Superset"""
import pytest
import prison
import uuid
import yaml
from datetime import datetime
from freezegun import freeze_time
from io import BytesIO
from sqlalchemy.sql import func
from typing import Any
from zipfile import ZipFile
import tests.integration_tests.test_app # noqa: F401
from superset import db
from superset.models.core import Theme
from superset.utils.database import get_example_database # noqa: F401
from superset.utils import json
from tests.integration_tests.base_tests import SupersetTestCase
from tests.integration_tests.constants import ADMIN_USERNAME
THEMES_FIXTURE_COUNT = 3
class TestThemeApi(SupersetTestCase):
def insert_theme(
self,
theme_name: str,
json_data: str,
created_by_username: str = "admin",
) -> Theme:
admin = self.get_user(created_by_username)
theme = Theme(
theme_name=theme_name,
json_data=json_data,
created_by=admin,
changed_by=admin,
)
db.session.add(theme)
db.session.commit()
return theme
@pytest.fixture
def create_themes(self):
with self.create_app().app_context():
themes = []
for cx in range(THEMES_FIXTURE_COUNT):
themes.append(
self.insert_theme(
theme_name=f"theme_name{cx}",
json_data=f'{{"color": "theme{cx}"}}',
)
)
yield themes
# rollback changes
for theme in themes:
db.session.delete(theme)
db.session.commit()
@pytest.mark.usefixtures("create_themes")
def test_get_list_theme(self):
"""
Theme API: Test get list themes
"""
themes = db.session.query(Theme).all()
self.login(ADMIN_USERNAME)
uri = "api/v1/theme/"
rv = self.get_assert_metric(uri, "get_list")
assert rv.status_code == 200
data = json.loads(rv.data.decode("utf-8"))
assert data["count"] == len(themes)
expected_columns = [
"changed_by",
"changed_by_name",
"changed_on_delta_humanized",
"created_by",
"created_on",
"id",
"is_system",
"is_system_default",
"is_system_dark",
"json_data",
"theme_name",
"uuid",
]
result_columns = list(data["result"][0].keys())
assert set(expected_columns) == set(result_columns)
@pytest.mark.usefixtures("create_themes")
def test_get_list_sort_theme(self):
"""
Theme API: Test get list and sort themes
"""
themes = db.session.query(Theme).order_by(Theme.theme_name.asc()).all()
self.login(ADMIN_USERNAME)
query_string = {"order_column": "theme_name", "order_direction": "asc"}
uri = f"api/v1/theme/?q={prison.dumps(query_string)}"
rv = self.get_assert_metric(uri, "get_list")
assert rv.status_code == 200
data = json.loads(rv.data.decode("utf-8"))
assert data["count"] == len(themes)
for i, theme in enumerate(themes):
assert theme.theme_name == data["result"][i]["theme_name"]
@pytest.mark.usefixtures("create_themes")
def test_get_list_custom_filter_theme(self):
"""
Theme API: Test get list and custom filter
"""
self.login(ADMIN_USERNAME)
# Test filtering by JSON data
all_themes = (
db.session.query(Theme).filter(Theme.json_data.ilike("%theme1%")).all()
)
query_string = {
"filters": [
{
"col": "theme_name",
"opr": "theme_all_text",
"value": "theme1",
}
],
}
uri = f"api/v1/theme/?q={prison.dumps(query_string)}"
rv = self.get_assert_metric(uri, "get_list")
assert rv.status_code == 200
data = json.loads(rv.data.decode("utf-8"))
assert data["count"] == len(all_themes)
# Test filtering by theme name
all_themes = (
db.session.query(Theme)
.filter(Theme.theme_name.ilike("%theme_name2%"))
.all()
)
query_string = {
"filters": [
{
"col": "theme_name",
"opr": "theme_all_text",
"value": "theme_name2",
}
],
}
uri = f"api/v1/theme/?q={prison.dumps(query_string)}"
rv = self.get_assert_metric(uri, "get_list")
assert rv.status_code == 200
data = json.loads(rv.data.decode("utf-8"))
assert data["count"] == len(all_themes)
def test_info_theme(self):
"""
Theme API: Test info
"""
self.login(ADMIN_USERNAME)
uri = "api/v1/theme/_info"
rv = self.get_assert_metric(uri, "info")
assert rv.status_code == 200
def test_info_security_theme(self):
"""
Theme API: Test info security
"""
self.login(ADMIN_USERNAME)
params = {"keys": ["permissions"]}
uri = f"api/v1/theme/_info?q={prison.dumps(params)}"
rv = self.get_assert_metric(uri, "info")
data = json.loads(rv.data.decode("utf-8"))
assert rv.status_code == 200
assert "can_read" in data["permissions"]
assert "can_write" in data["permissions"]
assert len(data["permissions"]) == 3
@pytest.mark.usefixtures("create_themes")
def test_get_theme(self):
"""
Theme API: Test get theme
"""
with freeze_time(datetime.now()):
theme = (
db.session.query(Theme)
.filter(Theme.theme_name == "theme_name1")
.one_or_none()
)
self.login(ADMIN_USERNAME)
uri = f"api/v1/theme/{theme.id}"
rv = self.get_assert_metric(uri, "get")
assert rv.status_code == 200
expected_result = {
"id": theme.id,
"theme_name": "theme_name1",
"json_data": '{"color": "theme1"}',
"is_system": False,
"is_system_default": False,
"is_system_dark": False,
"uuid": str(theme.uuid),
"changed_by": {
"first_name": theme.created_by.first_name,
"id": theme.created_by.id,
"last_name": theme.created_by.last_name,
},
"changed_on_delta_humanized": "now",
"created_by": {
"first_name": theme.created_by.first_name,
"id": theme.created_by.id,
"last_name": theme.created_by.last_name,
},
}
data = json.loads(rv.data.decode("utf-8"))
for key, value in data["result"].items():
assert value == expected_result[key]
@pytest.mark.usefixtures("create_themes")
def test_get_theme_not_found(self):
"""
Theme API: Test get theme not found
"""
max_id = db.session.query(func.max(Theme.id)).scalar()
self.login(ADMIN_USERNAME)
uri = f"api/v1/theme/{max_id + 1}"
rv = self.get_assert_metric(uri, "get")
assert rv.status_code == 404
def test_create_theme(self):
"""
Theme API: Test create
"""
post_data = {
"theme_name": "test_theme",
"json_data": '{"primary": "#007bff", "secondary": "#6c757d"}',
}
self.login(ADMIN_USERNAME)
uri = "api/v1/theme/"
rv = self.post_assert_metric(uri, post_data, "post")
data = json.loads(rv.data.decode("utf-8"))
assert rv.status_code == 201
theme_id = data.get("id")
model = db.session.query(Theme).get(theme_id)
for key in post_data:
assert getattr(model, key) == data["result"][key]
# Rollback changes
db.session.delete(model)
db.session.commit()
@pytest.mark.usefixtures("create_themes")
def test_update_theme(self):
"""
Theme API: Test update
"""
theme = (
db.session.query(Theme).filter(Theme.theme_name == "theme_name1").all()[0]
)
put_data = {
"theme_name": "updated_theme_name",
"json_data": '{"primary": "#28a745", "secondary": "#ffc107"}',
}
self.login(ADMIN_USERNAME)
uri = f"api/v1/theme/{theme.id}"
rv = self.put_assert_metric(uri, put_data, "put")
assert rv.status_code == 200
model = db.session.query(Theme).get(theme.id)
assert model.theme_name == "updated_theme_name"
assert model.json_data == '{"primary": "#28a745", "secondary": "#ffc107"}'
@pytest.mark.usefixtures("create_themes")
def test_update_theme_not_found(self):
"""
Theme API: Test update not found
"""
max_id = db.session.query(func.max(Theme.id)).scalar()
self.login(ADMIN_USERNAME)
put_data = {
"theme_name": "updated_theme_name",
"json_data": '{"primary": "#28a745"}',
}
uri = f"api/v1/theme/{max_id + 1}"
rv = self.put_assert_metric(uri, put_data, "put")
assert rv.status_code == 404
@pytest.mark.usefixtures("create_themes")
@pytest.mark.skip(
"DELETE endpoint not properly registered due to route method exclusion"
)
def test_delete_theme(self):
"""
Theme API: Test delete
"""
theme = (
db.session.query(Theme)
.filter(Theme.theme_name == "theme_name1")
.one_or_none()
)
self.login(ADMIN_USERNAME)
uri = f"api/v1/theme/{theme.id}"
rv = self.delete_assert_metric(uri, "delete")
assert rv.status_code == 200
model = db.session.query(Theme).get(theme.id)
assert model is None
@pytest.mark.usefixtures("create_themes")
@pytest.mark.skip(
"DELETE endpoint not properly registered due to route method exclusion"
)
def test_delete_theme_not_found(self):
"""
Theme API: Test delete not found
"""
max_id = db.session.query(func.max(Theme.id)).scalar()
self.login(ADMIN_USERNAME)
uri = f"api/v1/theme/{max_id + 1}"
rv = self.delete_assert_metric(uri, "delete")
assert rv.status_code == 404
@pytest.mark.usefixtures("create_themes")
def test_delete_bulk_themes(self):
"""
Theme API: Test delete bulk
"""
# Only delete non-system themes to avoid 403 errors
themes = db.session.query(Theme).filter(~Theme.is_system).all()
theme_ids = [theme.id for theme in themes]
self.login(ADMIN_USERNAME)
uri = f"api/v1/theme/?q={prison.dumps(theme_ids)}"
rv = self.delete_assert_metric(uri, "bulk_delete")
assert rv.status_code == 200
response = json.loads(rv.data.decode("utf-8"))
expected_response = {"message": f"Deleted {len(theme_ids)} themes"}
assert response == expected_response
# Verify only non-system themes are deleted, system themes remain
remaining_themes = db.session.query(Theme).filter(~Theme.is_system).all()
assert remaining_themes == []
@pytest.mark.usefixtures("create_themes")
def test_delete_one_bulk_themes(self):
"""
Theme API: Test delete one in bulk
"""
# Only delete non-system themes to avoid 403 errors
theme = db.session.query(Theme).filter(~Theme.is_system).first()
theme_ids = [theme.id]
self.login(ADMIN_USERNAME)
uri = f"api/v1/theme/?q={prison.dumps(theme_ids)}"
rv = self.delete_assert_metric(uri, "bulk_delete")
assert rv.status_code == 200
response = json.loads(rv.data.decode("utf-8"))
expected_response = {"message": f"Deleted {len(theme_ids)} theme"}
assert response == expected_response
theme_ = db.session.query(Theme).get(theme_ids[0])
assert theme_ is None
def test_delete_bulk_theme_bad_request(self):
"""
Theme API: Test delete bulk bad request
"""
theme_ids = [1, "a"]
self.login(ADMIN_USERNAME)
uri = f"api/v1/theme/?q={prison.dumps(theme_ids)}"
rv = self.delete_assert_metric(uri, "bulk_delete")
assert rv.status_code == 400
@pytest.mark.usefixtures("create_themes")
def test_delete_bulk_theme_not_found(self):
"""
Theme API: Test delete bulk not found
"""
max_id = db.session.query(func.max(Theme.id)).scalar()
theme_ids = [max_id + 1, max_id + 2]
self.login(ADMIN_USERNAME)
uri = f"api/v1/theme/?q={prison.dumps(theme_ids)}"
rv = self.delete_assert_metric(uri, "bulk_delete")
assert rv.status_code == 404
def create_theme_import_zip(self, theme_config: dict[str, Any]) -> BytesIO:
"""Helper method to create a theme import ZIP file"""
buf = BytesIO()
with ZipFile(buf, "w") as bundle:
# Use a root folder like the export does
root = "theme_import"
# Add metadata.yaml
metadata = {
"version": "1.0.0",
"type": "Theme",
"timestamp": datetime.now().isoformat(),
}
with bundle.open(f"{root}/metadata.yaml", "w") as fp:
fp.write(yaml.safe_dump(metadata).encode())
# Add theme YAML file
theme_yaml = yaml.safe_dump(theme_config)
with bundle.open(
f"{root}/themes/{theme_config['theme_name']}.yaml", "w"
) as fp:
fp.write(theme_yaml.encode())
buf.seek(0)
return buf
def test_import_theme(self):
"""
Theme API: Test import theme
"""
theme_config = {
"theme_name": "imported_theme",
"uuid": str(uuid.uuid4()),
"version": "1.0.0",
"json_data": {"colors": {"primary": "#007bff"}},
}
self.login(ADMIN_USERNAME)
uri = "api/v1/theme/import/"
buf = self.create_theme_import_zip(theme_config)
form_data = {
"formData": (buf, "theme_export.zip"),
}
rv = self.client.post(uri, data=form_data, content_type="multipart/form-data")
response = json.loads(rv.data.decode("utf-8"))
assert rv.status_code == 200
assert response == {"message": "Theme imported successfully"}
theme = db.session.query(Theme).filter_by(uuid=theme_config["uuid"]).one()
assert theme.theme_name == "imported_theme"
# Cleanup
db.session.delete(theme)
db.session.commit()
def test_import_theme_overwrite(self):
"""
Theme API: Test import existing theme without and with overwrite
"""
theme_config = {
"theme_name": "overwrite_theme",
"uuid": str(uuid.uuid4()),
"version": "1.0.0",
"json_data": {"colors": {"primary": "#007bff"}},
}
self.login(ADMIN_USERNAME)
uri = "api/v1/theme/import/"
# First import
buf = self.create_theme_import_zip(theme_config)
form_data = {
"formData": (buf, "theme_export.zip"),
}
rv = self.client.post(uri, data=form_data, content_type="multipart/form-data")
response = json.loads(rv.data.decode("utf-8"))
assert rv.status_code == 200
assert response == {"message": "Theme imported successfully"}
# Import again without overwrite flag - should fail with structured error
buf = self.create_theme_import_zip(theme_config)
form_data = {
"formData": (buf, "theme_export.zip"),
}
rv = self.client.post(uri, data=form_data, content_type="multipart/form-data")
response = json.loads(rv.data.decode("utf-8"))
assert rv.status_code == 422
assert len(response["errors"]) == 1
error = response["errors"][0]
assert error["message"].startswith("Error importing theme")
assert error["error_type"] == "GENERIC_COMMAND_ERROR"
assert error["level"] == "warning"
assert f"themes/{theme_config['theme_name']}.yaml" in str(error["extra"])
assert "Theme already exists and `overwrite=true` was not passed" in str(
error["extra"]
)
# Import with overwrite flag - should succeed
buf = self.create_theme_import_zip(theme_config)
form_data = {
"formData": (buf, "theme_export.zip"),
"overwrite": "true",
}
rv = self.client.post(uri, data=form_data, content_type="multipart/form-data")
response = json.loads(rv.data.decode("utf-8"))
assert rv.status_code == 200
assert response == {"message": "Theme imported successfully"}
# Cleanup
theme = db.session.query(Theme).filter_by(uuid=theme_config["uuid"]).one()
db.session.delete(theme)
db.session.commit()