mirror of
https://github.com/apache/superset.git
synced 2026-09-01 21:11:28 +00:00
2285 lines
71 KiB
Python
2285 lines
71 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.
|
|
|
|
# pylint: disable=invalid-name, unused-argument, redefined-outer-name
|
|
|
|
import json # noqa: TID251
|
|
from types import SimpleNamespace
|
|
from typing import Any
|
|
|
|
import pytest
|
|
from flask_appbuilder.security.sqla.models import Role, User
|
|
from pytest_mock import MockerFixture
|
|
|
|
from superset.common.query_object import QueryObject
|
|
from superset.connectors.sqla.models import Database, SqlaTable
|
|
from superset.exceptions import SupersetSecurityException
|
|
from superset.extensions import appbuilder
|
|
from superset.models.slice import Slice
|
|
from superset.security.manager import (
|
|
query_context_modified,
|
|
SupersetSecurityManager,
|
|
)
|
|
from superset.sql.parse import Table
|
|
from superset.superset_typing import AdhocColumn, AdhocMetric
|
|
from superset.utils.core import DatasourceName, override_user
|
|
|
|
|
|
def test_security_manager(app_context: None) -> None:
|
|
"""
|
|
Test that the security manager can be built.
|
|
"""
|
|
sm = SupersetSecurityManager(appbuilder)
|
|
assert sm
|
|
|
|
|
|
@pytest.fixture
|
|
def stored_metrics() -> list[AdhocMetric]:
|
|
"""
|
|
Return a list of metrics.
|
|
"""
|
|
return [
|
|
{
|
|
"column": None,
|
|
"expressionType": "SQL",
|
|
"hasCustomLabel": False,
|
|
"label": "COUNT(*) + 1",
|
|
"sqlExpression": "COUNT(*) + 1",
|
|
},
|
|
]
|
|
|
|
|
|
@pytest.fixture
|
|
def stored_columns() -> list[AdhocColumn]:
|
|
"""
|
|
Return a list of columns.
|
|
"""
|
|
return [
|
|
{
|
|
"label": "My column",
|
|
"sqlExpression": "UPPER(name)",
|
|
},
|
|
]
|
|
|
|
|
|
def test_raise_for_access_guest_user_ok(
|
|
mocker: MockerFixture,
|
|
app_context: None,
|
|
stored_metrics: list[AdhocMetric],
|
|
stored_columns: list[AdhocColumn],
|
|
) -> None:
|
|
"""
|
|
Test that guest user can submit an unmodified chart payload.
|
|
"""
|
|
sm = SupersetSecurityManager(appbuilder)
|
|
mocker.patch.object(sm, "is_guest_user", return_value=True)
|
|
mocker.patch.object(sm, "can_access", return_value=True)
|
|
|
|
query_context = mocker.MagicMock()
|
|
query_context.slice_.id = 42
|
|
query_context.slice_.query_context = None
|
|
query_context.slice_.params_dict = {
|
|
"metrics": stored_metrics,
|
|
"columns": stored_columns,
|
|
}
|
|
|
|
query_context.form_data = {
|
|
"slice_id": 42,
|
|
"metrics": stored_metrics,
|
|
"columns": stored_columns,
|
|
}
|
|
query_context.queries = [QueryObject(metrics=stored_metrics)] # type: ignore
|
|
sm.raise_for_access(query_context=query_context)
|
|
|
|
|
|
def test_raise_for_access_guest_user_ok_subset(
|
|
mocker: MockerFixture,
|
|
app_context: None,
|
|
stored_metrics: list[AdhocMetric],
|
|
stored_columns: list[AdhocColumn],
|
|
) -> None:
|
|
"""
|
|
Test that guest user can submit a request of a subset of the metrics/columns.
|
|
"""
|
|
sm = SupersetSecurityManager(appbuilder)
|
|
mocker.patch.object(sm, "is_guest_user", return_value=True)
|
|
mocker.patch.object(sm, "can_access", return_value=True)
|
|
|
|
query_context = mocker.MagicMock()
|
|
query_context.slice_.id = 42
|
|
query_context.slice_.query_context = None
|
|
query_context.slice_.params_dict = {
|
|
"metrics": stored_metrics,
|
|
"columns": stored_columns,
|
|
}
|
|
|
|
query_context.form_data = {
|
|
"slice_id": 42,
|
|
"metrics": [],
|
|
"columns": [],
|
|
}
|
|
query_context.queries = [QueryObject(metrics=stored_metrics)] # type: ignore
|
|
sm.raise_for_access(query_context=query_context)
|
|
|
|
|
|
def test_raise_for_access_guest_user_tampered_id(
|
|
mocker: MockerFixture,
|
|
app_context: None,
|
|
stored_metrics: list[AdhocMetric],
|
|
) -> None:
|
|
"""
|
|
Test that guest user cannot modify the chart ID.
|
|
"""
|
|
sm = SupersetSecurityManager(appbuilder)
|
|
mocker.patch.object(sm, "is_guest_user", return_value=True)
|
|
mocker.patch.object(sm, "can_access", return_value=True)
|
|
|
|
query_context = mocker.MagicMock()
|
|
query_context.slice_.id = 42
|
|
query_context.slice_.query_context = None
|
|
query_context.slice_.params_dict = {
|
|
"metrics": stored_metrics,
|
|
}
|
|
|
|
query_context.form_data = {
|
|
"slice_id": 43,
|
|
"metrics": stored_metrics,
|
|
}
|
|
query_context.queries = [QueryObject(metrics=stored_metrics)] # type: ignore
|
|
with pytest.raises(SupersetSecurityException):
|
|
sm.raise_for_access(query_context=query_context)
|
|
|
|
|
|
def test_raise_for_access_guest_user_tampered_form_data_metrics(
|
|
mocker: MockerFixture,
|
|
app_context: None,
|
|
stored_metrics: list[AdhocMetric],
|
|
) -> None:
|
|
"""
|
|
Test that guest user cannot modify metrics in the form data.
|
|
"""
|
|
sm = SupersetSecurityManager(appbuilder)
|
|
mocker.patch.object(sm, "is_guest_user", return_value=True)
|
|
mocker.patch.object(sm, "can_access", return_value=True)
|
|
|
|
query_context = mocker.MagicMock()
|
|
query_context.slice_.id = 42
|
|
query_context.slice_.query_context = None
|
|
query_context.slice_.params_dict = {
|
|
"metrics": stored_metrics,
|
|
}
|
|
|
|
tampered_metrics = [
|
|
{
|
|
"column": None,
|
|
"expressionType": "SQL",
|
|
"hasCustomLabel": False,
|
|
"label": "COUNT(*) + 2",
|
|
"sqlExpression": "COUNT(*) + 2",
|
|
}
|
|
]
|
|
|
|
query_context.form_data = {
|
|
"slice_id": 42,
|
|
"metrics": tampered_metrics,
|
|
}
|
|
with pytest.raises(SupersetSecurityException):
|
|
sm.raise_for_access(query_context=query_context)
|
|
|
|
|
|
def test_raise_for_access_guest_user_tampered_form_data_columns(
|
|
mocker: MockerFixture,
|
|
app_context: None,
|
|
stored_columns: list[AdhocColumn],
|
|
) -> None:
|
|
"""
|
|
Test that guest user cannot modify columns in the form data.
|
|
"""
|
|
sm = SupersetSecurityManager(appbuilder)
|
|
mocker.patch.object(sm, "is_guest_user", return_value=True)
|
|
mocker.patch.object(sm, "can_access", return_value=True)
|
|
|
|
query_context = mocker.MagicMock()
|
|
query_context.slice_.id = 42
|
|
query_context.slice_.query_context = None
|
|
query_context.slice_.params_dict = {
|
|
"columns": stored_columns,
|
|
}
|
|
|
|
tampered_columns = [
|
|
{
|
|
"label": "My column",
|
|
"sqlExpression": "list_secret()",
|
|
"expressionType": "SQL",
|
|
},
|
|
]
|
|
|
|
query_context.form_data = {
|
|
"slice_id": 42,
|
|
"columns": tampered_columns,
|
|
}
|
|
with pytest.raises(SupersetSecurityException):
|
|
sm.raise_for_access(query_context=query_context)
|
|
|
|
|
|
def test_raise_for_access_guest_user_tampered_form_data_groupby(
|
|
mocker: MockerFixture,
|
|
app_context: None,
|
|
stored_columns: list[AdhocColumn],
|
|
) -> None:
|
|
"""
|
|
Test that guest user cannot modify groupby in the form data.
|
|
"""
|
|
sm = SupersetSecurityManager(appbuilder)
|
|
mocker.patch.object(sm, "is_guest_user", return_value=True)
|
|
mocker.patch.object(sm, "can_access", return_value=True)
|
|
|
|
query_context = mocker.MagicMock()
|
|
query_context.slice_.id = 42
|
|
query_context.slice_.query_context = None
|
|
query_context.slice_.params_dict = {
|
|
"groupby": stored_columns,
|
|
}
|
|
|
|
tampered_columns = [
|
|
{
|
|
"label": "My column",
|
|
"sqlExpression": "list_secret()",
|
|
"expressionType": "SQL",
|
|
},
|
|
]
|
|
|
|
query_context.form_data = {
|
|
"slice_id": 42,
|
|
"columns": tampered_columns,
|
|
}
|
|
with pytest.raises(SupersetSecurityException):
|
|
sm.raise_for_access(query_context=query_context)
|
|
|
|
|
|
def test_raise_for_access_guest_user_tampered_queries_metrics(
|
|
mocker: MockerFixture,
|
|
app_context: None,
|
|
stored_metrics: list[AdhocMetric],
|
|
) -> None:
|
|
"""
|
|
Test that guest user cannot modify metrics in the queries.
|
|
"""
|
|
sm = SupersetSecurityManager(appbuilder)
|
|
mocker.patch.object(sm, "is_guest_user", return_value=True)
|
|
mocker.patch.object(sm, "can_access", return_value=True)
|
|
|
|
query_context = mocker.MagicMock()
|
|
query_context.slice_.id = 42
|
|
query_context.slice_.query_context = None
|
|
query_context.slice_.params_dict = {
|
|
"metrics": stored_metrics,
|
|
}
|
|
|
|
tampered_metrics = [
|
|
{
|
|
"column": None,
|
|
"expressionType": "SQL",
|
|
"hasCustomLabel": False,
|
|
"label": "COUNT(*) + 2",
|
|
"sqlExpression": "COUNT(*) + 2",
|
|
}
|
|
]
|
|
|
|
query_context.form_data = {
|
|
"slice_id": 42,
|
|
"metrics": stored_metrics,
|
|
}
|
|
query_context.queries = [QueryObject(metrics=tampered_metrics)] # type: ignore
|
|
with pytest.raises(SupersetSecurityException):
|
|
sm.raise_for_access(query_context=query_context)
|
|
|
|
|
|
def test_raise_for_access_guest_user_tampered_queries_columns(
|
|
mocker: MockerFixture,
|
|
app_context: None,
|
|
stored_columns: list[AdhocColumn],
|
|
) -> None:
|
|
"""
|
|
Test that guest user cannot modify columns in the queries.
|
|
"""
|
|
sm = SupersetSecurityManager(appbuilder)
|
|
mocker.patch.object(sm, "is_guest_user", return_value=True)
|
|
mocker.patch.object(sm, "can_access", return_value=True)
|
|
|
|
query_context = mocker.MagicMock()
|
|
query_context.slice_.id = 42
|
|
query_context.slice_.query_context = None
|
|
query_context.slice_.params_dict = {
|
|
"columns": stored_columns,
|
|
}
|
|
|
|
tampered_columns = [
|
|
{
|
|
"label": "My column",
|
|
"sqlExpression": "list_secret()",
|
|
"expressionType": "SQL",
|
|
}
|
|
]
|
|
|
|
query_context.form_data = {
|
|
"slice_id": 42,
|
|
"columns": stored_columns,
|
|
}
|
|
query_context.queries = [QueryObject(metrics=tampered_columns)] # type: ignore
|
|
with pytest.raises(SupersetSecurityException):
|
|
sm.raise_for_access(query_context=query_context)
|
|
|
|
|
|
def test_raise_for_access_query_default_schema(
|
|
mocker: MockerFixture,
|
|
app_context: None,
|
|
) -> None:
|
|
"""
|
|
Test that the DB default schema is used in non-qualified table names.
|
|
|
|
For example, in Postgres, for the following query:
|
|
|
|
> SELECT * FROM foo;
|
|
|
|
We should check that the user has access to the `public` schema, regardless of the
|
|
schema set in the query.
|
|
"""
|
|
sm = SupersetSecurityManager(appbuilder)
|
|
mocker.patch.object(sm, "can_access_database", return_value=False)
|
|
mocker.patch.object(sm, "get_schema_perm", return_value="[PostgreSQL].[public]")
|
|
mocker.patch.object(sm, "is_guest_user", return_value=False)
|
|
SqlaTable = mocker.patch("superset.connectors.sqla.models.SqlaTable") # noqa: N806
|
|
SqlaTable.query_datasources_by_name.return_value = []
|
|
|
|
database = mocker.MagicMock()
|
|
database.get_default_catalog.return_value = None
|
|
database.get_default_schema_for_query.return_value = "public"
|
|
query = mocker.MagicMock()
|
|
query.catalog = None
|
|
query.database = database
|
|
query.sql = "SELECT * FROM ab_user"
|
|
|
|
# user has access to `public` schema
|
|
mocker.patch.object(sm, "can_access", return_value=True)
|
|
assert (
|
|
sm.raise_for_access( # type: ignore
|
|
database=None,
|
|
datasource=None,
|
|
query=query,
|
|
query_context=None,
|
|
table=None,
|
|
viz=None,
|
|
)
|
|
is None
|
|
)
|
|
sm.can_access.assert_called_with("schema_access", "[PostgreSQL].[public]") # type: ignore
|
|
|
|
# user has only access to `secret` schema
|
|
mocker.patch.object(sm, "can_access", return_value=False)
|
|
with pytest.raises(SupersetSecurityException) as excinfo:
|
|
sm.raise_for_access(
|
|
database=None,
|
|
datasource=None,
|
|
query=query,
|
|
query_context=None,
|
|
table=None,
|
|
viz=None,
|
|
)
|
|
assert (
|
|
str(excinfo.value)
|
|
== """You need access to the following tables: `public.ab_user`,
|
|
`all_database_access` or `all_datasource_access` permission"""
|
|
)
|
|
|
|
|
|
def test_raise_for_access_jinja_sql(mocker: MockerFixture, app_context: None) -> None:
|
|
"""
|
|
Test that Jinja gets rendered to SQL.
|
|
"""
|
|
sm = SupersetSecurityManager(appbuilder)
|
|
mocker.patch.object(sm, "can_access_database", return_value=False)
|
|
mocker.patch.object(sm, "get_schema_perm", return_value="[PostgreSQL].[public]")
|
|
mocker.patch.object(sm, "can_access", return_value=False)
|
|
mocker.patch.object(sm, "is_guest_user", return_value=False)
|
|
get_table_access_error_object = mocker.patch.object(
|
|
sm, "get_table_access_error_object"
|
|
)
|
|
SqlaTable = mocker.patch("superset.connectors.sqla.models.SqlaTable") # noqa: N806
|
|
SqlaTable.query_datasources_by_name.return_value = []
|
|
|
|
database = mocker.MagicMock()
|
|
database.get_default_catalog.return_value = None
|
|
database.get_default_schema_for_query.return_value = "public"
|
|
query = mocker.MagicMock()
|
|
query.catalog = None
|
|
query.database = database
|
|
query.sql = "SELECT * FROM {% if True %}ab_user{% endif %} WHERE 1=1"
|
|
|
|
with pytest.raises(SupersetSecurityException):
|
|
sm.raise_for_access(
|
|
database=None,
|
|
datasource=None,
|
|
query=query,
|
|
query_context=None,
|
|
table=None,
|
|
viz=None,
|
|
)
|
|
|
|
get_table_access_error_object.assert_called_with({Table("ab_user", "public", None)})
|
|
|
|
|
|
def test_raise_for_access_chart_for_datasource_permission(
|
|
mocker: MockerFixture,
|
|
app_context: None,
|
|
) -> None:
|
|
"""
|
|
Test that the security manager can raise an exception for chart access,
|
|
when the user does not have access to the chart datasource
|
|
"""
|
|
sm = SupersetSecurityManager(appbuilder)
|
|
session = sm.session
|
|
|
|
engine = session.get_bind()
|
|
Slice.metadata.create_all(engine) # pylint: disable=no-member
|
|
|
|
alpha = User(
|
|
first_name="Alice",
|
|
last_name="Doe",
|
|
email="adoe@example.org",
|
|
username="admin",
|
|
roles=[Role(name="Alpha")],
|
|
)
|
|
|
|
dataset = SqlaTable(
|
|
table_name="test_table",
|
|
metrics=[],
|
|
main_dttm_col=None,
|
|
database=Database(database_name="my_database", sqlalchemy_uri="sqlite://"),
|
|
)
|
|
session.add(dataset)
|
|
session.flush()
|
|
|
|
slice = Slice(
|
|
id=1,
|
|
datasource_id=dataset.id,
|
|
datasource_type="table",
|
|
datasource_name="tmp_perm_table",
|
|
slice_name="slice_name",
|
|
)
|
|
session.add(slice)
|
|
session.flush()
|
|
|
|
mocker.patch.object(sm, "can_access_datasource", return_value=False)
|
|
with override_user(alpha):
|
|
with pytest.raises(SupersetSecurityException) as excinfo:
|
|
sm.raise_for_access(
|
|
chart=slice,
|
|
)
|
|
assert str(excinfo.value) == "You don't have access to this chart."
|
|
|
|
mocker.patch.object(sm, "can_access_datasource", return_value=True)
|
|
with override_user(alpha):
|
|
sm.raise_for_access(
|
|
chart=slice,
|
|
)
|
|
|
|
|
|
def test_raise_for_access_chart_on_admin(
|
|
app_context: None,
|
|
) -> None:
|
|
"""
|
|
Test that the security manager can raise an exception for chart access,
|
|
when the user does not have access to the chart datasource
|
|
"""
|
|
from flask_appbuilder.security.sqla.models import Role, User
|
|
|
|
from superset.models.slice import Slice
|
|
from superset.utils.core import override_user
|
|
|
|
sm = SupersetSecurityManager(appbuilder)
|
|
session = sm.session
|
|
|
|
engine = session.get_bind()
|
|
Slice.metadata.create_all(engine) # pylint: disable=no-member
|
|
|
|
admin = User(
|
|
first_name="Alice",
|
|
last_name="Doe",
|
|
email="adoe@example.org",
|
|
username="admin",
|
|
roles=[Role(name="Admin")],
|
|
)
|
|
|
|
slice = Slice(
|
|
id=1,
|
|
datasource_id=1,
|
|
datasource_type="table",
|
|
datasource_name="tmp_perm_table",
|
|
slice_name="slice_name",
|
|
)
|
|
session.add(slice)
|
|
session.flush()
|
|
|
|
with override_user(admin):
|
|
sm.raise_for_access(
|
|
chart=slice,
|
|
)
|
|
|
|
|
|
def test_raise_for_access_chart_owner(
|
|
app_context: None,
|
|
) -> None:
|
|
"""
|
|
Test that the security manager can raise an exception for chart access,
|
|
when the user does not have access to the chart datasource
|
|
"""
|
|
sm = SupersetSecurityManager(appbuilder)
|
|
session = sm.session
|
|
|
|
engine = session.get_bind()
|
|
Slice.metadata.create_all(engine) # pylint: disable=no-member
|
|
|
|
# Check if Alpha role already exists
|
|
alpha_role = session.query(Role).filter_by(name="Alpha").first()
|
|
if not alpha_role:
|
|
alpha_role = Role(name="Alpha")
|
|
session.add(alpha_role)
|
|
session.commit()
|
|
|
|
# Check if user already exists
|
|
alpha = session.query(User).filter_by(username="test_chart_owner_user").first()
|
|
if not alpha:
|
|
alpha = User(
|
|
first_name="Alice",
|
|
last_name="Doe",
|
|
email="adoe@example.org",
|
|
username="test_chart_owner_user",
|
|
roles=[alpha_role],
|
|
)
|
|
session.add(alpha)
|
|
session.commit()
|
|
else:
|
|
# Ensure the user has the Alpha role
|
|
if alpha_role not in alpha.roles:
|
|
alpha.roles.append(alpha_role)
|
|
session.commit()
|
|
|
|
slice = Slice(
|
|
id=1,
|
|
datasource_id=1,
|
|
datasource_type="table",
|
|
datasource_name="tmp_perm_table",
|
|
slice_name="slice_name",
|
|
owners=[alpha],
|
|
)
|
|
session.add(slice)
|
|
|
|
with override_user(alpha):
|
|
sm.raise_for_access(
|
|
chart=slice,
|
|
)
|
|
|
|
|
|
def test_query_context_modified(
|
|
mocker: MockerFixture,
|
|
stored_metrics: list[AdhocMetric],
|
|
) -> None:
|
|
"""
|
|
Test the `query_context_modified` function.
|
|
|
|
The function is used to ensure guest users are not modifying the request payload on
|
|
embedded dashboard, preventing users from modifying it to access metrics different
|
|
from the ones stored in dashboard charts.
|
|
"""
|
|
query_context = mocker.MagicMock()
|
|
query_context.slice_.id = 42
|
|
query_context.slice_.query_context = None
|
|
query_context.slice_.params_dict = {
|
|
"metrics": stored_metrics,
|
|
}
|
|
|
|
query_context.form_data = {
|
|
"slice_id": 42,
|
|
"metrics": stored_metrics,
|
|
}
|
|
query_context.queries = [QueryObject(metrics=stored_metrics)] # type: ignore
|
|
assert not query_context_modified(query_context)
|
|
|
|
|
|
def test_query_context_modified_tampered(
|
|
mocker: MockerFixture,
|
|
stored_metrics: list[AdhocMetric],
|
|
) -> None:
|
|
"""
|
|
Test the `query_context_modified` function when the request is tampered with.
|
|
|
|
The function is used to ensure guest users are not modifying the request payload on
|
|
embedded dashboard, preventing users from modifying it to access metrics different
|
|
from the ones stored in dashboard charts.
|
|
"""
|
|
query_context = mocker.MagicMock()
|
|
query_context.slice_.id = 42
|
|
query_context.slice_.query_context = None
|
|
query_context.slice_.params_dict = {
|
|
"metrics": stored_metrics,
|
|
}
|
|
|
|
tampered_metrics = [
|
|
{
|
|
"column": None,
|
|
"expressionType": "SQL",
|
|
"hasCustomLabel": False,
|
|
"label": "COUNT(*) + 2",
|
|
"sqlExpression": "COUNT(*) + 2",
|
|
}
|
|
]
|
|
|
|
query_context.form_data = {
|
|
"slice_id": 42,
|
|
"metrics": tampered_metrics,
|
|
}
|
|
query_context.queries = [QueryObject(metrics=tampered_metrics)] # type: ignore
|
|
assert query_context_modified(query_context)
|
|
|
|
|
|
def test_query_context_modified_native_filter(mocker: MockerFixture) -> None:
|
|
"""
|
|
Test the `query_context_modified` function with a native filter request.
|
|
|
|
A native filter request has no chart (slice) associated with it.
|
|
"""
|
|
query_context = mocker.MagicMock()
|
|
query_context.slice_ = None
|
|
|
|
assert not query_context_modified(query_context)
|
|
|
|
|
|
def test_query_context_modified_mixed_chart(mocker: MockerFixture) -> None:
|
|
"""
|
|
Test the `query_context_modified` function for a mixed chart request.
|
|
|
|
The metrics in the mixed chart are a nested dictionary (due to `columns`), and need
|
|
to be serialized to JSON with the keys sorted in order to compare the request
|
|
metrics with the chart metrics.
|
|
"""
|
|
stored_metrics = [
|
|
{
|
|
"optionName": "metric_vgops097wej_g8uff99zhk7",
|
|
"label": "AVG(num)",
|
|
"expressionType": "SIMPLE",
|
|
"column": {"column_name": "num", "type": "BIGINT(20)"},
|
|
"aggregate": "AVG",
|
|
}
|
|
]
|
|
# different order (remember, dicts have order!)
|
|
requested_metrics = [
|
|
{
|
|
"aggregate": "AVG",
|
|
"column": {"column_name": "num", "type": "BIGINT(20)"},
|
|
"expressionType": "SIMPLE",
|
|
"label": "AVG(num)",
|
|
"optionName": "metric_vgops097wej_g8uff99zhk7",
|
|
}
|
|
]
|
|
|
|
query_context = mocker.MagicMock()
|
|
query_context.slice_.id = 42
|
|
query_context.slice_.query_context = None
|
|
query_context.slice_.params_dict = {
|
|
"metrics": stored_metrics,
|
|
}
|
|
|
|
query_context.form_data = {
|
|
"slice_id": 42,
|
|
"metrics": requested_metrics,
|
|
}
|
|
query_context.queries = [QueryObject(metrics=requested_metrics)] # type: ignore
|
|
assert not query_context_modified(query_context)
|
|
|
|
|
|
def test_query_context_modified_sankey_tampered(mocker: MockerFixture) -> None:
|
|
"""
|
|
Test the `query_context_modified` function for a sankey chart request.
|
|
"""
|
|
query_context = mocker.MagicMock()
|
|
query_context.queries = [
|
|
QueryObject(
|
|
apply_fetch_values_predicate=False,
|
|
columns=["bot_id", "channel_id"],
|
|
extras={"having": "", "where": ""},
|
|
filter=[
|
|
{
|
|
"col": "bot_profile__updated",
|
|
"op": "TEMPORAL_RANGE",
|
|
"val": "No filter",
|
|
}
|
|
],
|
|
from_dttm=None,
|
|
granularity=None,
|
|
inner_from_dttm=None,
|
|
inner_to_dttm=None,
|
|
is_rowcount=False,
|
|
is_timeseries=False,
|
|
metrics=["count"],
|
|
order_desc=True,
|
|
orderby=[],
|
|
row_limit=10000,
|
|
row_offset=0,
|
|
series_columns=[],
|
|
series_limit=0,
|
|
series_limit_metric=None,
|
|
time_shift=None,
|
|
to_dttm=None,
|
|
),
|
|
]
|
|
query_context.form_data = {
|
|
"datasource": "12__table",
|
|
"viz_type": "sankey_v2",
|
|
"slice_id": 97,
|
|
"url_params": {},
|
|
"source": "bot_id",
|
|
"target": "channel_id",
|
|
"metric": "count",
|
|
"adhoc_filters": [
|
|
{
|
|
"clause": "WHERE",
|
|
"comparator": "No filter",
|
|
"expressionType": "SIMPLE",
|
|
"operator": "TEMPORAL_RANGE",
|
|
"subject": "bot_profile__updated",
|
|
}
|
|
],
|
|
"row_limit": 10000,
|
|
"color_scheme": "supersetColors",
|
|
"dashboards": [11],
|
|
"extra_form_data": {},
|
|
"label_colors": {},
|
|
"shared_label_colors": [],
|
|
"map_label_colors": {},
|
|
"extra_filters": [],
|
|
"dashboardId": 11,
|
|
"force": False,
|
|
"result_format": "json",
|
|
"result_type": "full",
|
|
}
|
|
query_context.slice_.id = 97
|
|
query_context.slice_.params_dict = {
|
|
"datasource": "12__table",
|
|
"viz_type": "sankey_v2",
|
|
"slice_id": 97,
|
|
"source": "bot_id",
|
|
"target": "channel_id",
|
|
"metric": "count",
|
|
"adhoc_filters": [
|
|
{
|
|
"clause": "WHERE",
|
|
"comparator": "No filter",
|
|
"expressionType": "SIMPLE",
|
|
"operator": "TEMPORAL_RANGE",
|
|
"subject": "bot_profile__updated",
|
|
}
|
|
],
|
|
"row_limit": 10000,
|
|
"color_scheme": "supersetColors",
|
|
"extra_form_data": {},
|
|
"dashboards": [11],
|
|
}
|
|
query_context.slice_.query_context = json.dumps(
|
|
{
|
|
"datasource": {"id": 12, "type": "table"},
|
|
"force": False,
|
|
"queries": [
|
|
{
|
|
"filters": [
|
|
{
|
|
"col": "bot_profile__updated",
|
|
"op": "TEMPORAL_RANGE",
|
|
"val": "No filter",
|
|
}
|
|
],
|
|
"extras": {"having": "", "where": ""},
|
|
"applied_time_extras": {},
|
|
"columns": [],
|
|
"metrics": ["count"],
|
|
"annotation_layers": [],
|
|
"row_limit": 10000,
|
|
"series_limit": 0,
|
|
"order_desc": True,
|
|
"url_params": {},
|
|
"custom_params": {},
|
|
"custom_form_data": {},
|
|
"groupby": ["bot_id", "channel_id"],
|
|
}
|
|
],
|
|
"form_data": {
|
|
"datasource": "12__table",
|
|
"viz_type": "sankey_v2",
|
|
"slice_id": 97,
|
|
"source": "bot_id",
|
|
"target": "channel_id",
|
|
"metric": "count",
|
|
"adhoc_filters": [
|
|
{
|
|
"clause": "WHERE",
|
|
"comparator": "No filter",
|
|
"expressionType": "SIMPLE",
|
|
"operator": "TEMPORAL_RANGE",
|
|
"subject": "bot_profile__updated",
|
|
}
|
|
],
|
|
"row_limit": 10000,
|
|
"color_scheme": "supersetColors",
|
|
"extra_form_data": {},
|
|
"dashboards": [11],
|
|
"force": False,
|
|
"result_format": "json",
|
|
"result_type": "full",
|
|
},
|
|
"result_format": "json",
|
|
"result_type": "full",
|
|
}
|
|
)
|
|
assert not query_context_modified(query_context)
|
|
|
|
|
|
def test_query_context_modified_orderby(mocker: MockerFixture) -> None:
|
|
"""
|
|
Test the `query_context_modified` function when the ORDER BY is modified.
|
|
"""
|
|
tampered_groupby: AdhocMetric = {
|
|
"aggregate": "",
|
|
"column": None,
|
|
"expressionType": "SQL",
|
|
"hasCustomLabel": False,
|
|
"label": "random()",
|
|
"sqlExpression": "random()",
|
|
}
|
|
|
|
query_context = mocker.MagicMock()
|
|
query_context.queries = [
|
|
QueryObject(
|
|
apply_fetch_values_predicate=False,
|
|
columns=["gender"],
|
|
extras={"having": "", "where": ""},
|
|
filter=[{"col": "ds", "op": "TEMPORAL_RANGE", "val": "No filter"}],
|
|
from_dttm=None,
|
|
granularity=None,
|
|
inner_from_dttm=None,
|
|
inner_to_dttm=None,
|
|
is_rowcount=False,
|
|
is_timeseries=False,
|
|
metrics=["count"],
|
|
order_desc=True,
|
|
orderby=[(tampered_groupby, False)],
|
|
row_limit=1000,
|
|
row_offset=0,
|
|
series_columns=[],
|
|
series_limit=0,
|
|
series_limit_metric=tampered_groupby,
|
|
time_shift=None,
|
|
to_dttm=None,
|
|
),
|
|
]
|
|
query_context.form_data = {
|
|
"datasource": "2__table",
|
|
"viz_type": "table",
|
|
"slice_id": 101,
|
|
"url_params": {
|
|
"datasource_id": "2",
|
|
"datasource_type": "table",
|
|
"save_action": "saveas",
|
|
"slice_id": "101",
|
|
},
|
|
"query_mode": "aggregate",
|
|
"groupby": ["gender"],
|
|
"time_grain_sqla": "P1D",
|
|
"temporal_columns_lookup": {"ds": True},
|
|
"metrics": ["count"],
|
|
"all_columns": [],
|
|
"percent_metrics": [],
|
|
"adhoc_filters": [
|
|
{
|
|
"clause": "WHERE",
|
|
"comparator": "No filter",
|
|
"expressionType": "SIMPLE",
|
|
"operator": "TEMPORAL_RANGE",
|
|
"subject": "ds",
|
|
}
|
|
],
|
|
"timeseries_limit_metric": {
|
|
"aggregate": None,
|
|
"column": None,
|
|
"datasourceWarning": False,
|
|
"expressionType": "SQL",
|
|
"hasCustomLabel": False,
|
|
"label": "random()",
|
|
"optionName": "metric_3kwbghgzkv9_wz84h9j1p5d",
|
|
"sqlExpression": "random()",
|
|
},
|
|
"order_by_cols": [],
|
|
"row_limit": 1000,
|
|
"server_page_length": 10,
|
|
"order_desc": True,
|
|
"table_timestamp_format": "smart_date",
|
|
"allow_render_html": True,
|
|
"show_cell_bars": True,
|
|
"color_pn": True,
|
|
"comparison_color_scheme": "Green",
|
|
"comparison_type": "values",
|
|
"extra_form_data": {},
|
|
"force": False,
|
|
"result_format": "json",
|
|
"result_type": "full",
|
|
}
|
|
query_context.slice_.id = 101
|
|
query_context.slice_.params_dict = {
|
|
"datasource": "2__table",
|
|
"viz_type": "table",
|
|
"query_mode": "aggregate",
|
|
"groupby": ["gender"],
|
|
"time_grain_sqla": "P1D",
|
|
"temporal_columns_lookup": {"ds": True},
|
|
"metrics": ["count"],
|
|
"all_columns": [],
|
|
"percent_metrics": [],
|
|
"adhoc_filters": [
|
|
{
|
|
"clause": "WHERE",
|
|
"subject": "ds",
|
|
"operator": "TEMPORAL_RANGE",
|
|
"comparator": "No filter",
|
|
"expressionType": "SIMPLE",
|
|
}
|
|
],
|
|
"order_by_cols": [],
|
|
"row_limit": 1000,
|
|
"server_page_length": 10,
|
|
"order_desc": True,
|
|
"table_timestamp_format": "smart_date",
|
|
"allow_render_html": True,
|
|
"show_cell_bars": True,
|
|
"color_pn": True,
|
|
"comparison_color_scheme": "Green",
|
|
"comparison_type": "values",
|
|
"extra_form_data": {},
|
|
"dashboards": [],
|
|
}
|
|
query_context.slice_.query_context = json.dumps(
|
|
{
|
|
"datasource": {"id": 2, "type": "table"},
|
|
"force": False,
|
|
"queries": [
|
|
{
|
|
"filters": [
|
|
{"col": "ds", "op": "TEMPORAL_RANGE", "val": "No filter"}
|
|
],
|
|
"extras": {"having": "", "where": ""},
|
|
"applied_time_extras": {},
|
|
"columns": ["gender"],
|
|
"metrics": ["count"],
|
|
"orderby": [],
|
|
"annotation_layers": [],
|
|
"row_limit": 1000,
|
|
"series_limit": 0,
|
|
"order_desc": True,
|
|
"url_params": {},
|
|
"custom_params": {},
|
|
"custom_form_data": {},
|
|
"post_processing": [],
|
|
"time_offsets": [],
|
|
}
|
|
],
|
|
"form_data": {
|
|
"datasource": "2__table",
|
|
"viz_type": "table",
|
|
"query_mode": "aggregate",
|
|
"groupby": ["gender"],
|
|
"time_grain_sqla": "P1D",
|
|
"temporal_columns_lookup": {"ds": True},
|
|
"metrics": ["count"],
|
|
"all_columns": [],
|
|
"percent_metrics": [],
|
|
"adhoc_filters": [
|
|
{
|
|
"clause": "WHERE",
|
|
"subject": "ds",
|
|
"operator": "TEMPORAL_RANGE",
|
|
"comparator": "No filter",
|
|
"expressionType": "SIMPLE",
|
|
}
|
|
],
|
|
"order_by_cols": [],
|
|
"row_limit": 1000,
|
|
"server_page_length": 10,
|
|
"order_desc": True,
|
|
"table_timestamp_format": "smart_date",
|
|
"allow_render_html": True,
|
|
"show_cell_bars": True,
|
|
"color_pn": True,
|
|
"comparison_color_scheme": "Green",
|
|
"comparison_type": "values",
|
|
"extra_form_data": {},
|
|
"dashboards": [],
|
|
"force": False,
|
|
"result_format": "json",
|
|
"result_type": "full",
|
|
},
|
|
"result_format": "json",
|
|
"result_type": "full",
|
|
}
|
|
)
|
|
assert query_context_modified(query_context)
|
|
|
|
|
|
def build_guest_query_context(
|
|
mocker: MockerFixture,
|
|
params: dict[str, Any],
|
|
form_data: dict[str, Any],
|
|
queries: list[QueryObject] | None = None,
|
|
) -> Any:
|
|
"""
|
|
A guest request for the chart described by `params`.
|
|
|
|
`form_data` holds only the keys under test; the slice id is filled in so the request
|
|
is for the stored chart rather than a different one.
|
|
"""
|
|
query_context = mocker.MagicMock()
|
|
query_context.slice_.id = 42
|
|
query_context.slice_.query_context = None
|
|
query_context.slice_.params_dict = params
|
|
query_context.form_data = {"slice_id": 42, **form_data}
|
|
query_context.queries = queries if queries is not None else []
|
|
return query_context
|
|
|
|
|
|
def test_query_context_modified_gantt_tooltip_metrics(
|
|
mocker: MockerFixture,
|
|
) -> None:
|
|
"""
|
|
Test that a Gantt chart's `tooltip_metrics` are accepted as stored metrics.
|
|
|
|
Gantt's `buildQuery` sends `tooltip_metrics` as the query's `metrics`.
|
|
"""
|
|
query_context = build_guest_query_context(
|
|
mocker,
|
|
{
|
|
"viz_type": "gantt_chart",
|
|
"start_time": "start",
|
|
"end_time": "end",
|
|
"y_axis": "task",
|
|
"tooltip_metrics": ["count"],
|
|
},
|
|
{"metrics": ["count"], "columns": ["start", "end", "task"]},
|
|
)
|
|
assert not query_context_modified(query_context)
|
|
|
|
|
|
def test_query_context_modified_mixed_timeseries_query_b(
|
|
mocker: MockerFixture,
|
|
) -> None:
|
|
"""
|
|
Test that a mixed timeseries chart's Query B controls are accepted.
|
|
|
|
`retainFormDataSuffix` strips the `_b` suffix, so `groupby_b` and
|
|
`timeseries_limit_metric_b` become Query B's columns and sort metric.
|
|
"""
|
|
query_context = build_guest_query_context(
|
|
mocker,
|
|
{
|
|
"viz_type": "mixed_timeseries",
|
|
"metrics": ["count"],
|
|
"groupby": ["gender"],
|
|
"metrics_b": ["sum__num"],
|
|
"groupby_b": ["state"],
|
|
"timeseries_limit_metric_b": "sum__num",
|
|
},
|
|
{},
|
|
[
|
|
QueryObject(metrics=["count"], columns=["gender"]),
|
|
QueryObject(
|
|
metrics=["sum__num"],
|
|
columns=["state"],
|
|
orderby=[("sum__num", False)],
|
|
),
|
|
],
|
|
)
|
|
assert not query_context_modified(query_context)
|
|
|
|
|
|
def test_query_context_modified_graph_categories(mocker: MockerFixture) -> None:
|
|
"""
|
|
Test that a graph chart's category controls are accepted as stored columns.
|
|
|
|
Graph aliases `source_category` and `target_category` to `columns`.
|
|
"""
|
|
query_context = build_guest_query_context(
|
|
mocker,
|
|
{
|
|
"viz_type": "graph_chart",
|
|
"source": "src",
|
|
"target": "tgt",
|
|
"source_category": "src_cat",
|
|
"target_category": "tgt_cat",
|
|
"metric": "count",
|
|
},
|
|
{"columns": ["src", "tgt", "src_cat", "tgt_cat"], "metrics": ["count"]},
|
|
)
|
|
assert not query_context_modified(query_context)
|
|
|
|
|
|
def test_query_context_modified_deckgl_spatial(mocker: MockerFixture) -> None:
|
|
"""
|
|
Test that a deck.gl chart's nested spatial configuration is decomposed.
|
|
|
|
`getSpatialColumns()` turns the config into the columns the query selects, so the
|
|
stored config has to be decomposed the same way.
|
|
"""
|
|
query_context = build_guest_query_context(
|
|
mocker,
|
|
{
|
|
"viz_type": "deck_screengrid",
|
|
"spatial": {"type": "latlong", "lonCol": "LON", "latCol": "LAT"},
|
|
"size": "count",
|
|
"js_columns": ["extra"],
|
|
},
|
|
{"columns": ["LON", "LAT", "extra"], "metrics": ["count"]},
|
|
)
|
|
assert not query_context_modified(query_context)
|
|
|
|
|
|
def test_query_context_modified_deckgl_arc_spatial(mocker: MockerFixture) -> None:
|
|
"""
|
|
Test that an Arc chart's start and end spatial configurations are decomposed.
|
|
"""
|
|
query_context = build_guest_query_context(
|
|
mocker,
|
|
{
|
|
"viz_type": "deck_arc",
|
|
"start_spatial": {"type": "delimited", "lonlatCol": "from_latlon"},
|
|
"end_spatial": {"type": "geohash", "geohashCol": "to_geohash"},
|
|
"dimension": "carrier",
|
|
},
|
|
{"columns": ["from_latlon", "to_geohash", "carrier"]},
|
|
)
|
|
assert not query_context_modified(query_context)
|
|
|
|
|
|
def test_query_context_modified_deckgl_spatial_tampered(
|
|
mocker: MockerFixture,
|
|
) -> None:
|
|
"""
|
|
Test that a column absent from a spatial configuration is still rejected.
|
|
"""
|
|
query_context = build_guest_query_context(
|
|
mocker,
|
|
{
|
|
"viz_type": "deck_screengrid",
|
|
"spatial": {"type": "latlong", "lonCol": "LON", "latCol": "LAT"},
|
|
},
|
|
{"columns": ["LON", "LAT", "salary"]},
|
|
)
|
|
assert query_context_modified(query_context)
|
|
|
|
|
|
def test_query_context_modified_deckgl_tooltip_contents(
|
|
mocker: MockerFixture,
|
|
) -> None:
|
|
"""
|
|
Test that deck.gl tooltip-config objects are reduced to the columns they wrap.
|
|
|
|
`extractTooltipColumns()` adds `item_type: "column"` entries to the query's columns.
|
|
"""
|
|
query_context = build_guest_query_context(
|
|
mocker,
|
|
{
|
|
"viz_type": "deck_path",
|
|
"line_column": "path",
|
|
"tooltip_contents": [
|
|
{"item_type": "column", "column_name": "name"},
|
|
{"item_type": "metric", "metric_name": "count"},
|
|
"plain_column",
|
|
],
|
|
},
|
|
{"columns": ["path", "name", "plain_column"]},
|
|
)
|
|
assert not query_context_modified(query_context)
|
|
|
|
|
|
def test_query_context_modified_deckgl_tooltip_metric_not_a_column(
|
|
mocker: MockerFixture,
|
|
) -> None:
|
|
"""
|
|
Test that a tooltip metric entry does not become a permitted column.
|
|
|
|
Metric entries are read from data already fetched and never reach the query's
|
|
columns, so requesting one as a column is a modification.
|
|
"""
|
|
query_context = build_guest_query_context(
|
|
mocker,
|
|
{
|
|
"viz_type": "deck_path",
|
|
"line_column": "path",
|
|
"tooltip_contents": [{"item_type": "metric", "metric_name": "count"}],
|
|
},
|
|
{"columns": ["path", "count"]},
|
|
)
|
|
assert query_context_modified(query_context)
|
|
|
|
|
|
def test_query_context_modified_point_radius_fixed_metric(
|
|
mocker: MockerFixture,
|
|
) -> None:
|
|
"""
|
|
Test that a metric-typed `point_radius_fixed` is accepted as a stored metric.
|
|
"""
|
|
query_context = build_guest_query_context(
|
|
mocker,
|
|
{
|
|
"viz_type": "deck_scatter",
|
|
"spatial": {"type": "latlong", "lonCol": "LON", "latCol": "LAT"},
|
|
"point_radius_fixed": {"type": "metric", "value": "count"},
|
|
},
|
|
{
|
|
"columns": ["LON", "LAT"],
|
|
"metrics": ["count"],
|
|
"orderby": [["count", False]],
|
|
},
|
|
)
|
|
assert not query_context_modified(query_context)
|
|
|
|
|
|
def test_query_context_modified_point_radius_fixed_legacy(
|
|
mocker: MockerFixture,
|
|
) -> None:
|
|
"""
|
|
Test that the legacy bare-string form of `point_radius_fixed` is accepted.
|
|
"""
|
|
query_context = build_guest_query_context(
|
|
mocker,
|
|
{
|
|
"viz_type": "deck_scatter",
|
|
"spatial": {"type": "latlong", "lonCol": "LON", "latCol": "LAT"},
|
|
"point_radius_fixed": "count",
|
|
},
|
|
{"columns": ["LON", "LAT"], "metrics": ["count"]},
|
|
)
|
|
assert not query_context_modified(query_context)
|
|
|
|
|
|
def test_query_context_modified_point_radius_fixed_value_not_a_metric(
|
|
mocker: MockerFixture,
|
|
) -> None:
|
|
"""
|
|
Test that a fixed-typed `point_radius_fixed` grants no metric.
|
|
|
|
A `fix` value is a radius in pixels, never queried, so a metric of the same name
|
|
still has to be stored elsewhere on the chart.
|
|
"""
|
|
query_context = build_guest_query_context(
|
|
mocker,
|
|
{
|
|
"viz_type": "deck_scatter",
|
|
"spatial": {"type": "latlong", "lonCol": "LON", "latCol": "LAT"},
|
|
"point_radius_fixed": {"type": "fix", "value": "count"},
|
|
},
|
|
{"columns": ["LON", "LAT"], "metrics": ["count"]},
|
|
)
|
|
assert query_context_modified(query_context)
|
|
|
|
|
|
def test_query_context_modified_cartodiagram(mocker: MockerFixture) -> None:
|
|
"""
|
|
Test that a Cartodiagram's nested chart definition is descended into.
|
|
|
|
`selected_chart` holds a JSON-encoded chart whose own `params` is a JSON string; its
|
|
metrics and group-bys are what the delegated `buildQuery` actually queries, with the
|
|
geometry column prepended to the group-bys.
|
|
"""
|
|
query_context = build_guest_query_context(
|
|
mocker,
|
|
{
|
|
"viz_type": "cartodiagram",
|
|
"geom_column": "geom",
|
|
"selected_chart": json.dumps(
|
|
{
|
|
"viz_type": "pie",
|
|
"params": json.dumps(
|
|
{"metric": "count", "groupby": ["gender"]},
|
|
),
|
|
}
|
|
),
|
|
},
|
|
{"groupby": ["geom", "gender"], "metrics": ["count"]},
|
|
)
|
|
assert not query_context_modified(query_context)
|
|
|
|
|
|
def test_query_context_modified_cartodiagram_tampered(
|
|
mocker: MockerFixture,
|
|
) -> None:
|
|
"""
|
|
Test that a column absent from a Cartodiagram's nested chart is still rejected.
|
|
"""
|
|
query_context = build_guest_query_context(
|
|
mocker,
|
|
{
|
|
"viz_type": "cartodiagram",
|
|
"geom_column": "geom",
|
|
"selected_chart": json.dumps(
|
|
{
|
|
"viz_type": "pie",
|
|
"params": json.dumps(
|
|
{"metric": "count", "groupby": ["gender"]},
|
|
),
|
|
}
|
|
),
|
|
},
|
|
{"groupby": ["geom", "salary"], "metrics": ["count"]},
|
|
)
|
|
assert query_context_modified(query_context)
|
|
|
|
|
|
def test_query_context_modified_malformed_selected_chart(
|
|
mocker: MockerFixture,
|
|
) -> None:
|
|
"""
|
|
Test that an unparseable `selected_chart` neither crashes nor grants access.
|
|
"""
|
|
for selected_chart in ["not json", json.dumps({"params": "not json"}), None, 42]:
|
|
query_context = build_guest_query_context(
|
|
mocker,
|
|
{
|
|
"viz_type": "cartodiagram",
|
|
"geom_column": "geom",
|
|
"selected_chart": selected_chart,
|
|
},
|
|
{"groupby": ["geom"]},
|
|
)
|
|
assert not query_context_modified(query_context)
|
|
|
|
tampered = build_guest_query_context(
|
|
mocker,
|
|
{
|
|
"viz_type": "cartodiagram",
|
|
"geom_column": "geom",
|
|
"selected_chart": selected_chart,
|
|
},
|
|
{"groupby": ["geom", "salary"]},
|
|
)
|
|
assert query_context_modified(tampered)
|
|
|
|
|
|
def test_get_catalog_perm() -> None:
|
|
"""
|
|
Test the `get_catalog_perm` method.
|
|
"""
|
|
sm = SupersetSecurityManager(appbuilder)
|
|
|
|
assert sm.get_catalog_perm("my_db", None) is None
|
|
assert sm.get_catalog_perm("my_db", "my_catalog") == "[my_db].[my_catalog]"
|
|
|
|
|
|
def test_get_schema_perm() -> None:
|
|
"""
|
|
Test the `get_schema_perm` method.
|
|
"""
|
|
sm = SupersetSecurityManager(appbuilder)
|
|
|
|
assert sm.get_schema_perm("my_db", None, "my_schema") == "[my_db].[my_schema]"
|
|
assert (
|
|
sm.get_schema_perm("my_db", "my_catalog", "my_schema")
|
|
== "[my_db].[my_catalog].[my_schema]"
|
|
)
|
|
assert sm.get_schema_perm("my_db", None, None) is None
|
|
assert sm.get_schema_perm("my_db", "my_catalog", None) is None
|
|
|
|
|
|
def test_raise_for_access_catalog(
|
|
mocker: MockerFixture,
|
|
app_context: None,
|
|
) -> None:
|
|
"""
|
|
Test catalog-level permissions.
|
|
"""
|
|
sm = SupersetSecurityManager(appbuilder)
|
|
mocker.patch.object(sm, "can_access_database", return_value=False)
|
|
mocker.patch.object(
|
|
sm,
|
|
"get_catalog_perm",
|
|
return_value="[PostgreSQL].[db1]",
|
|
)
|
|
mocker.patch.object(sm, "is_guest_user", return_value=False)
|
|
SqlaTable = mocker.patch("superset.connectors.sqla.models.SqlaTable") # noqa: N806
|
|
SqlaTable.query_datasources_by_name.return_value = []
|
|
|
|
database = mocker.MagicMock()
|
|
database.get_default_catalog.return_value = "db1"
|
|
database.get_default_schema_for_query.return_value = "public"
|
|
query = mocker.MagicMock()
|
|
query.catalog = "db1"
|
|
query.database = database
|
|
query.sql = "SELECT * FROM ab_user"
|
|
|
|
can_access = mocker.patch.object(sm, "can_access", return_value=True)
|
|
sm.raise_for_access(query=query)
|
|
can_access.assert_called_with("catalog_access", "[PostgreSQL].[db1]")
|
|
|
|
mocker.patch.object(sm, "can_access", return_value=False)
|
|
with pytest.raises(SupersetSecurityException) as excinfo:
|
|
sm.raise_for_access(query=query)
|
|
assert (
|
|
str(excinfo.value)
|
|
== """You need access to the following tables: `db1.public.ab_user`,
|
|
`all_database_access` or `all_datasource_access` permission"""
|
|
)
|
|
|
|
query.sql = "SELECT * FROM db2.public.ab_user"
|
|
with pytest.raises(SupersetSecurityException) as excinfo:
|
|
sm.raise_for_access(query=query)
|
|
assert (
|
|
str(excinfo.value)
|
|
== """You need access to the following tables: `db2.public.ab_user`,
|
|
`all_database_access` or `all_datasource_access` permission"""
|
|
)
|
|
|
|
|
|
def test_get_datasources_accessible_by_user_schema_access(
|
|
mocker: MockerFixture,
|
|
app_context: None,
|
|
) -> None:
|
|
"""
|
|
Test that `get_datasources_accessible_by_user` works with schema permissions.
|
|
"""
|
|
sm = SupersetSecurityManager(appbuilder)
|
|
mocker.patch.object(sm, "can_access_database", return_value=False)
|
|
|
|
database = mocker.MagicMock()
|
|
database.database_name = "db1"
|
|
database.get_default_catalog.return_value = "catalog2"
|
|
|
|
# False for catalog_access, True for schema_access
|
|
can_access = mocker.patch.object(sm, "can_access", side_effect=[False, True])
|
|
|
|
datasource_names = [
|
|
DatasourceName("table1", "schema1", "catalog2"),
|
|
DatasourceName("table2", "schema1", "catalog2"),
|
|
]
|
|
|
|
assert sm.get_datasources_accessible_by_user(
|
|
database,
|
|
datasource_names,
|
|
catalog=None,
|
|
schema="schema1",
|
|
) == [
|
|
DatasourceName("table1", "schema1", "catalog2"),
|
|
DatasourceName("table2", "schema1", "catalog2"),
|
|
]
|
|
|
|
# Even though we passed `catalog=None,` the schema check uses the default catalog
|
|
# when building the schema permission, since the DB supports catalog.
|
|
can_access.assert_has_calls(
|
|
[
|
|
mocker.call("catalog_access", "[db1].[catalog2]"),
|
|
mocker.call("schema_access", "[db1].[catalog2].[schema1]"),
|
|
]
|
|
)
|
|
|
|
|
|
def test_get_catalogs_accessible_by_user_schema_access(
|
|
mocker: MockerFixture,
|
|
app_context: None,
|
|
) -> None:
|
|
"""
|
|
Test that `get_catalogs_accessible_by_user` works with schema permissions.
|
|
"""
|
|
sm = SupersetSecurityManager(appbuilder)
|
|
mocker.patch.object(sm, "can_access_database", return_value=False)
|
|
mocker.patch.object(
|
|
sm,
|
|
"user_view_menu_names",
|
|
side_effect=[
|
|
set(), # catalog_access
|
|
{"[db1].[catalog2].[schema1]"}, # schema_access
|
|
set(), # datasource_access
|
|
],
|
|
)
|
|
|
|
database = mocker.MagicMock()
|
|
database.database_name = "db1"
|
|
database.get_default_catalog.return_value = "catalog2"
|
|
|
|
catalogs = {"catalog1", "catalog2"}
|
|
|
|
assert sm.get_catalogs_accessible_by_user(database, catalogs) == {"catalog2"}
|
|
|
|
|
|
def test_get_rls_filters_uses_table_id_directly(
|
|
mocker: MockerFixture,
|
|
app_context: None,
|
|
) -> None:
|
|
"""
|
|
Test that get_rls_filters() uses table.id directly instead of table.data["id"].
|
|
|
|
Accessing table.data triggers the full data property chain including select_star,
|
|
which requires a live database engine connection. When the DB is unreachable, this
|
|
causes the entire dashboard GET endpoint to fail with a 500 error.
|
|
|
|
This test ensures we use the direct .id attribute and never access .data,
|
|
preventing regressions that would break dashboard loading when DBs are unavailable.
|
|
"""
|
|
sm = SupersetSecurityManager(appbuilder)
|
|
|
|
# Create a mock table where .data raises an exception if accessed
|
|
table = mocker.MagicMock()
|
|
table.id = 42
|
|
type(table).data = mocker.PropertyMock(
|
|
side_effect=Exception(
|
|
"table.data should not be accessed - use table.id directly"
|
|
)
|
|
)
|
|
|
|
# Mock user context
|
|
mock_user = mocker.MagicMock()
|
|
mock_user.roles = [mocker.MagicMock(id=1)]
|
|
mocker.patch("superset.security.manager.g", user=mock_user)
|
|
mocker.patch.object(sm, "get_user_roles", return_value=mock_user.roles)
|
|
|
|
# Call get_rls_filters - if it accesses table.data, the PropertyMock will raise
|
|
# If it uses table.id directly (correct behavior), it will complete successfully
|
|
result = sm.get_rls_filters(table)
|
|
assert isinstance(result, list)
|
|
|
|
|
|
def test_get_rls_filters_returns_cached_result(
|
|
mocker: MockerFixture,
|
|
app_context: None,
|
|
) -> None:
|
|
"""
|
|
Test that get_rls_filters() returns cached results on subsequent calls
|
|
for the same user and table, avoiding redundant DB queries.
|
|
"""
|
|
sm = SupersetSecurityManager(appbuilder)
|
|
|
|
mock_user = mocker.MagicMock()
|
|
mock_user.id = 1
|
|
mock_user.username = "admin"
|
|
mock_user.roles = [mocker.MagicMock(id=1)]
|
|
mock_g = SimpleNamespace(user=mock_user)
|
|
mocker.patch("superset.security.manager.g", new=mock_g)
|
|
mocker.patch("superset.security.manager.get_username", return_value="admin")
|
|
mocker.patch.object(sm, "get_user_roles", return_value=mock_user.roles)
|
|
|
|
table = mocker.MagicMock()
|
|
table.id = 42
|
|
|
|
# First call populates the cache
|
|
result1 = sm.get_rls_filters(table)
|
|
|
|
# Verify cache was populated keyed by (username, table_id)
|
|
assert ("admin", 42) in mock_g._rls_filter_cache
|
|
|
|
# Replace session query with something that would fail if called
|
|
mocker.patch.object(
|
|
sm.session,
|
|
"query",
|
|
side_effect=AssertionError("DB should not be queried on cache hit"),
|
|
)
|
|
|
|
# Second call should return cached result without querying DB
|
|
result2 = sm.get_rls_filters(table)
|
|
assert result1 == result2
|
|
|
|
|
|
def test_prefetch_rls_filters_populates_cache(
|
|
mocker: MockerFixture,
|
|
app_context: None,
|
|
) -> None:
|
|
"""
|
|
Test that prefetch_rls_filters() populates the cache for all provided
|
|
table_ids, including empty results for tables with no matching filters.
|
|
"""
|
|
sm = SupersetSecurityManager(appbuilder)
|
|
|
|
mock_user = mocker.MagicMock()
|
|
mock_user.id = 1
|
|
mock_user.username = "admin"
|
|
mock_user.roles = [mocker.MagicMock(id=10)]
|
|
mock_g = SimpleNamespace(user=mock_user)
|
|
mocker.patch("superset.security.manager.g", new=mock_g)
|
|
mocker.patch("superset.security.manager.get_username", return_value="admin")
|
|
mocker.patch.object(sm, "get_user_roles", return_value=mock_user.roles)
|
|
|
|
# Mock the batch query to return filters for table 1 but not table 2
|
|
mock_query = mocker.MagicMock()
|
|
mock_query.join.return_value = mock_query
|
|
mock_query.filter.return_value = mock_query
|
|
mock_query.all.return_value = [
|
|
(1, 100, "group_a", "id > 0"), # table_id=1
|
|
(1, 101, None, "active = 1"), # table_id=1
|
|
]
|
|
mocker.patch.object(sm.session, "query", return_value=mock_query)
|
|
|
|
sm.prefetch_rls_filters([1, 2])
|
|
|
|
# Table 1 should have 2 filters with named attribute access
|
|
cached = mock_g._rls_filter_cache[("admin", 1)]
|
|
assert len(cached) == 2
|
|
assert cached[0].id == 100
|
|
assert cached[0].group_key == "group_a"
|
|
assert cached[0].clause == "id > 0"
|
|
assert cached[1].id == 101
|
|
assert cached[1].group_key is None
|
|
assert cached[1].clause == "active = 1"
|
|
# Table 2 should have empty list
|
|
assert mock_g._rls_filter_cache[("admin", 2)] == []
|
|
|
|
|
|
def test_prefetch_rls_filters_skips_cached_ids(
|
|
mocker: MockerFixture,
|
|
app_context: None,
|
|
) -> None:
|
|
"""
|
|
Test that prefetch_rls_filters() skips table_ids already in cache
|
|
and returns early when all ids are cached.
|
|
"""
|
|
sm = SupersetSecurityManager(appbuilder)
|
|
|
|
mock_user = mocker.MagicMock()
|
|
mock_user.id = 1
|
|
mock_user.username = "admin"
|
|
mock_user.roles = [mocker.MagicMock(id=10)]
|
|
mock_g = SimpleNamespace(
|
|
user=mock_user,
|
|
_rls_filter_cache={("admin", 1): [(100, "group_a", "id > 0")]},
|
|
)
|
|
mocker.patch("superset.security.manager.g", new=mock_g)
|
|
mocker.patch("superset.security.manager.get_username", return_value="admin")
|
|
mocker.patch.object(sm, "get_user_roles", return_value=mock_user.roles)
|
|
|
|
# If it queries the DB, this will fail
|
|
mocker.patch.object(
|
|
sm.session,
|
|
"query",
|
|
side_effect=AssertionError("DB should not be queried for cached ids"),
|
|
)
|
|
|
|
# All ids already cached -> should return immediately
|
|
sm.prefetch_rls_filters([1])
|
|
|
|
|
|
def test_prefetch_rls_filters_no_user(
|
|
mocker: MockerFixture,
|
|
app_context: None,
|
|
) -> None:
|
|
"""
|
|
Test that prefetch_rls_filters() returns early when no user is present.
|
|
"""
|
|
sm = SupersetSecurityManager(appbuilder)
|
|
mocker.patch("superset.security.manager.g", new=SimpleNamespace())
|
|
|
|
# Should not attempt any DB queries
|
|
mocker.patch.object(
|
|
sm.session,
|
|
"query",
|
|
side_effect=AssertionError("DB should not be queried without a user"),
|
|
)
|
|
sm.prefetch_rls_filters([1, 2])
|
|
|
|
|
|
def test_get_rls_filters_cache_works_for_guest_user(
|
|
mocker: MockerFixture,
|
|
app_context: None,
|
|
) -> None:
|
|
"""
|
|
Test that get_rls_filters() caches results for guest users
|
|
using the same (username, table_id) cache key as regular users.
|
|
"""
|
|
sm = SupersetSecurityManager(appbuilder)
|
|
|
|
mock_guest = mocker.MagicMock()
|
|
mock_guest.username = "guest_user"
|
|
mock_guest.roles = [mocker.MagicMock(id=99)]
|
|
|
|
mock_g = SimpleNamespace(user=mock_guest)
|
|
mocker.patch("superset.security.manager.g", new=mock_g)
|
|
mocker.patch("superset.security.manager.get_username", return_value="guest_user")
|
|
mocker.patch.object(sm, "get_user_roles", return_value=mock_guest.roles)
|
|
|
|
table = mocker.MagicMock()
|
|
table.id = 42
|
|
|
|
# First call runs the query
|
|
result1 = sm.get_rls_filters(table)
|
|
|
|
# Verify cache was populated with (username, table_id) key
|
|
assert ("guest_user", 42) in mock_g._rls_filter_cache
|
|
|
|
# Replace session query to detect if it's called again
|
|
mocker.patch.object(
|
|
sm.session,
|
|
"query",
|
|
side_effect=AssertionError("DB should not be queried on cache hit"),
|
|
)
|
|
|
|
# Second call should use cache
|
|
result2 = sm.get_rls_filters(table)
|
|
assert result1 == result2
|
|
|
|
|
|
def test_prefetch_rls_filters_works_for_guest_user(
|
|
mocker: MockerFixture,
|
|
app_context: None,
|
|
) -> None:
|
|
"""
|
|
Test that prefetch_rls_filters() works for guest users using the
|
|
same (username, table_id) cache key as regular users.
|
|
"""
|
|
sm = SupersetSecurityManager(appbuilder)
|
|
|
|
mock_guest = mocker.MagicMock()
|
|
mock_guest.username = "guest_user"
|
|
mock_guest.roles = [mocker.MagicMock(id=99)]
|
|
|
|
mock_g = SimpleNamespace(user=mock_guest)
|
|
mocker.patch("superset.security.manager.g", new=mock_g)
|
|
mocker.patch("superset.security.manager.get_username", return_value="guest_user")
|
|
mocker.patch.object(sm, "get_user_roles", return_value=mock_guest.roles)
|
|
|
|
# Mock the batch query returning no filters
|
|
mock_query = mocker.MagicMock()
|
|
mock_query.join.return_value = mock_query
|
|
mock_query.filter.return_value = mock_query
|
|
mock_query.all.return_value = []
|
|
mocker.patch.object(sm.session, "query", return_value=mock_query)
|
|
|
|
sm.prefetch_rls_filters([10, 20])
|
|
|
|
# Cache should be populated with (username, table_id) keys and empty lists
|
|
assert mock_g._rls_filter_cache[("guest_user", 10)] == []
|
|
assert mock_g._rls_filter_cache[("guest_user", 20)] == []
|
|
|
|
|
|
def test_validate_child_in_parent_multilayer_valid(
|
|
app_context: None, mocker: MockerFixture
|
|
) -> None:
|
|
"""Test validation succeeds for valid multi-layer child"""
|
|
sm = SupersetSecurityManager(appbuilder)
|
|
|
|
parent_slice = mocker.MagicMock(spec=Slice)
|
|
parent_slice.params = json.dumps(
|
|
{"viz_type": "deck_multi", "deck_slices": [1, 2, 3]}
|
|
)
|
|
|
|
# Child 2 is in parent's deck_slices
|
|
assert sm._validate_child_in_parent_multilayer(
|
|
child_slice_id=2, parent_slice=parent_slice
|
|
)
|
|
|
|
|
|
def test_validate_child_in_parent_multilayer_invalid_child(
|
|
app_context: None, mocker: MockerFixture
|
|
) -> None:
|
|
"""Test validation fails for child not in parent config"""
|
|
sm = SupersetSecurityManager(appbuilder)
|
|
|
|
parent_slice = mocker.MagicMock(spec=Slice)
|
|
parent_slice.params = json.dumps(
|
|
{"viz_type": "deck_multi", "deck_slices": [1, 2, 3]}
|
|
)
|
|
|
|
# Child 5 is NOT in parent's deck_slices
|
|
assert not sm._validate_child_in_parent_multilayer(
|
|
child_slice_id=5, parent_slice=parent_slice
|
|
)
|
|
|
|
|
|
def test_validate_child_in_parent_multilayer_wrong_viz_type(
|
|
app_context: None, mocker: MockerFixture
|
|
) -> None:
|
|
"""Test validation fails for non-multilayer charts"""
|
|
sm = SupersetSecurityManager(appbuilder)
|
|
|
|
parent_slice = mocker.MagicMock(spec=Slice)
|
|
parent_slice.params = json.dumps(
|
|
{
|
|
"viz_type": "line", # Not deck_multi
|
|
"deck_slices": [1, 2, 3],
|
|
}
|
|
)
|
|
|
|
assert not sm._validate_child_in_parent_multilayer(
|
|
child_slice_id=2, parent_slice=parent_slice
|
|
)
|
|
|
|
|
|
def test_validate_child_in_parent_multilayer_empty_deck_slices(
|
|
app_context: None, mocker: MockerFixture
|
|
) -> None:
|
|
"""Test validation fails when deck_slices is empty"""
|
|
sm = SupersetSecurityManager(appbuilder)
|
|
|
|
parent_slice = mocker.MagicMock(spec=Slice)
|
|
parent_slice.params = json.dumps({"viz_type": "deck_multi", "deck_slices": []})
|
|
|
|
assert not sm._validate_child_in_parent_multilayer(
|
|
child_slice_id=1, parent_slice=parent_slice
|
|
)
|
|
|
|
|
|
def test_validate_child_in_parent_multilayer_no_deck_slices(
|
|
app_context: None, mocker: MockerFixture
|
|
) -> None:
|
|
"""Test validation fails when deck_slices is missing"""
|
|
sm = SupersetSecurityManager(appbuilder)
|
|
|
|
parent_slice = mocker.MagicMock(spec=Slice)
|
|
parent_slice.params = json.dumps(
|
|
{
|
|
"viz_type": "deck_multi"
|
|
# No deck_slices key
|
|
}
|
|
)
|
|
|
|
assert not sm._validate_child_in_parent_multilayer(
|
|
child_slice_id=1, parent_slice=parent_slice
|
|
)
|
|
|
|
|
|
def test_validate_child_in_parent_multilayer_malformed_json(
|
|
app_context: None, mocker: MockerFixture
|
|
) -> None:
|
|
"""Test validation fails gracefully with malformed JSON"""
|
|
sm = SupersetSecurityManager(appbuilder)
|
|
|
|
parent_slice = mocker.MagicMock(spec=Slice)
|
|
parent_slice.params = "not valid json {{"
|
|
|
|
assert not sm._validate_child_in_parent_multilayer(
|
|
child_slice_id=1, parent_slice=parent_slice
|
|
)
|
|
|
|
|
|
def test_validate_child_in_parent_multilayer_null_params(
|
|
app_context: None, mocker: MockerFixture
|
|
) -> None:
|
|
"""Test validation fails gracefully with null params"""
|
|
sm = SupersetSecurityManager(appbuilder)
|
|
|
|
parent_slice = mocker.MagicMock(spec=Slice)
|
|
parent_slice.params = None
|
|
|
|
assert not sm._validate_child_in_parent_multilayer(
|
|
child_slice_id=1, parent_slice=parent_slice
|
|
)
|
|
|
|
|
|
def test_query_context_modified_stored_groupby(mocker: MockerFixture) -> None:
|
|
"""
|
|
Test the `query_context_modified` function for a chart storing only `groupby`.
|
|
|
|
A request always sends its columns under `columns`, while a chart with a group-by
|
|
stores them under `groupby`, so the two control names must compare as equivalent.
|
|
"""
|
|
query_context = mocker.MagicMock()
|
|
query_context.slice_.id = 42
|
|
query_context.slice_.query_context = None
|
|
query_context.slice_.params_dict = {
|
|
"metrics": ["count"],
|
|
"groupby": ["deal_size"],
|
|
}
|
|
|
|
query_context.form_data = {
|
|
"slice_id": 42,
|
|
"metrics": ["count"],
|
|
"columns": ["deal_size"],
|
|
}
|
|
query_context.queries = [QueryObject(metrics=["count"], columns=["deal_size"])]
|
|
assert not query_context_modified(query_context)
|
|
|
|
|
|
def test_query_context_modified_stored_metric(mocker: MockerFixture) -> None:
|
|
"""
|
|
Test the `query_context_modified` function for a big number chart.
|
|
|
|
Big number stores its single metric under `metric`, but requests it as `metrics`.
|
|
"""
|
|
query_context = mocker.MagicMock()
|
|
query_context.slice_.id = 42
|
|
query_context.slice_.query_context = None
|
|
query_context.slice_.params_dict = {"metric": "count"}
|
|
|
|
query_context.form_data = {
|
|
"slice_id": 42,
|
|
"metric": "count",
|
|
"metrics": ["count"],
|
|
}
|
|
query_context.queries = [QueryObject(metrics=["count"])]
|
|
assert not query_context_modified(query_context)
|
|
|
|
|
|
def test_query_context_modified_stored_all_columns(mocker: MockerFixture) -> None:
|
|
"""
|
|
Test the `query_context_modified` function for a table chart in raw mode.
|
|
|
|
A raw mode table stores its columns under `all_columns`.
|
|
"""
|
|
query_context = mocker.MagicMock()
|
|
query_context.slice_.id = 42
|
|
query_context.slice_.query_context = None
|
|
query_context.slice_.params_dict = {
|
|
"query_mode": "raw",
|
|
"all_columns": ["product_line", "status"],
|
|
}
|
|
|
|
query_context.form_data = {
|
|
"slice_id": 42,
|
|
"query_mode": "raw",
|
|
"all_columns": ["product_line", "status"],
|
|
"columns": ["product_line", "status"],
|
|
}
|
|
query_context.queries = [QueryObject(columns=["product_line", "status"])]
|
|
assert not query_context_modified(query_context)
|
|
|
|
|
|
def test_query_context_modified_base_axis(mocker: MockerFixture) -> None:
|
|
"""
|
|
Test the `query_context_modified` function for a time series chart.
|
|
|
|
The frontend replaces the chart's saved `x_axis` with a synthesized `BASE_AXIS`
|
|
column reference, which must compare equal to the stored physical column name.
|
|
"""
|
|
base_axis: dict[str, Any] = {
|
|
"columnType": "BASE_AXIS",
|
|
"expressionType": "SQL",
|
|
"isColumnReference": True,
|
|
"label": "order_date",
|
|
"sqlExpression": "order_date",
|
|
"timeGrain": "P1M",
|
|
}
|
|
|
|
query_context = mocker.MagicMock()
|
|
query_context.slice_.id = 42
|
|
query_context.slice_.query_context = None
|
|
query_context.slice_.params_dict = {
|
|
"metrics": ["count"],
|
|
"groupby": ["deal_size"],
|
|
"x_axis": "order_date",
|
|
}
|
|
|
|
query_context.form_data = {
|
|
"slice_id": 42,
|
|
"metrics": ["count"],
|
|
"x_axis": "order_date",
|
|
"columns": [base_axis, "deal_size"],
|
|
}
|
|
query_context.queries = [
|
|
QueryObject(metrics=["count"], columns=[base_axis, "deal_size"]) # type: ignore
|
|
]
|
|
assert not query_context_modified(query_context)
|
|
|
|
|
|
def test_query_context_modified_base_axis_tampered(mocker: MockerFixture) -> None:
|
|
"""
|
|
Test the `query_context_modified` function when a `BASE_AXIS` is forged.
|
|
|
|
Tagging a column the chart does not read as `BASE_AXIS` must not grant access to it.
|
|
"""
|
|
forged_axis: dict[str, Any] = {
|
|
"columnType": "BASE_AXIS",
|
|
"expressionType": "SQL",
|
|
"isColumnReference": True,
|
|
"label": "credit_limit",
|
|
"sqlExpression": "credit_limit",
|
|
"timeGrain": "P1M",
|
|
}
|
|
|
|
query_context = mocker.MagicMock()
|
|
query_context.slice_.id = 42
|
|
query_context.slice_.query_context = None
|
|
query_context.slice_.params_dict = {
|
|
"metrics": ["count"],
|
|
"groupby": ["deal_size"],
|
|
"x_axis": "order_date",
|
|
}
|
|
|
|
query_context.form_data = {
|
|
"slice_id": 42,
|
|
"metrics": ["count"],
|
|
"x_axis": "order_date",
|
|
"columns": [forged_axis, "deal_size"],
|
|
}
|
|
query_context.queries = [
|
|
QueryObject(metrics=["count"], columns=[forged_axis, "deal_size"]) # type: ignore
|
|
]
|
|
assert query_context_modified(query_context)
|
|
|
|
|
|
def test_query_context_modified_base_axis_metric_tampered(
|
|
mocker: MockerFixture,
|
|
) -> None:
|
|
"""
|
|
Test the `query_context_modified` function when `BASE_AXIS` markers are smuggled
|
|
onto a metric, to disguise free-form SQL as a column reference.
|
|
"""
|
|
smuggled_metric: dict[str, Any] = {
|
|
"columnType": "BASE_AXIS",
|
|
"expressionType": "SQL",
|
|
"isColumnReference": True,
|
|
"label": "MAX(credit_limit)",
|
|
"sqlExpression": "MAX(credit_limit)",
|
|
}
|
|
|
|
query_context = mocker.MagicMock()
|
|
query_context.slice_.id = 42
|
|
query_context.slice_.query_context = None
|
|
query_context.slice_.params_dict = {
|
|
"metrics": ["count"],
|
|
"x_axis": "order_date",
|
|
}
|
|
|
|
query_context.form_data = {
|
|
"slice_id": 42,
|
|
"metrics": [smuggled_metric],
|
|
"x_axis": "order_date",
|
|
}
|
|
query_context.queries = [QueryObject(metrics=[smuggled_metric])] # type: ignore
|
|
assert query_context_modified(query_context)
|
|
|
|
|
|
def test_query_context_modified_orderby_own_metric(mocker: MockerFixture) -> None:
|
|
"""
|
|
Test the `query_context_modified` function when sorting by the chart's own metric.
|
|
|
|
Charts legitimately order by a metric or column they already read, neither of which
|
|
is stored under `orderby`.
|
|
"""
|
|
query_context = mocker.MagicMock()
|
|
query_context.slice_.id = 42
|
|
query_context.slice_.query_context = None
|
|
query_context.slice_.params_dict = {
|
|
"metrics": ["count"],
|
|
"groupby": ["deal_size"],
|
|
}
|
|
|
|
query_context.form_data = {
|
|
"slice_id": 42,
|
|
"metrics": ["count"],
|
|
"columns": ["deal_size"],
|
|
"orderby": [["count", False]],
|
|
}
|
|
query_context.queries = [
|
|
QueryObject(
|
|
metrics=["count"],
|
|
columns=["deal_size"],
|
|
orderby=[("count", False)],
|
|
)
|
|
]
|
|
assert not query_context_modified(query_context)
|
|
|
|
|
|
def test_query_context_modified_orderby_unrelated_column(
|
|
mocker: MockerFixture,
|
|
) -> None:
|
|
"""
|
|
Test the `query_context_modified` function when ordering by an unrelated column.
|
|
"""
|
|
query_context = mocker.MagicMock()
|
|
query_context.slice_.id = 42
|
|
query_context.slice_.query_context = None
|
|
query_context.slice_.params_dict = {
|
|
"metrics": ["count"],
|
|
"groupby": ["deal_size"],
|
|
}
|
|
|
|
query_context.form_data = {
|
|
"slice_id": 42,
|
|
"metrics": ["count"],
|
|
"columns": ["deal_size"],
|
|
"orderby": [["credit_limit", False]],
|
|
}
|
|
query_context.queries = [
|
|
QueryObject(
|
|
metrics=["count"],
|
|
columns=["deal_size"],
|
|
orderby=[("credit_limit", False)],
|
|
)
|
|
]
|
|
assert query_context_modified(query_context)
|
|
|
|
|
|
def test_query_context_modified_scalar_groupby(mocker: MockerFixture) -> None:
|
|
"""
|
|
Test the `query_context_modified` function for a scalar-valued `groupby`.
|
|
|
|
Heatmap stores its group-by as a bare string rather than a list; it must not be
|
|
compared character by character.
|
|
"""
|
|
query_context = mocker.MagicMock()
|
|
query_context.slice_.id = 42
|
|
query_context.slice_.query_context = None
|
|
query_context.slice_.params_dict = {
|
|
"metric": "count",
|
|
"x_axis": "product_line",
|
|
"groupby": "deal_size",
|
|
}
|
|
|
|
query_context.form_data = {
|
|
"slice_id": 42,
|
|
"metric": "count",
|
|
"x_axis": "product_line",
|
|
"groupby": "deal_size",
|
|
"metrics": ["count"],
|
|
}
|
|
query_context.queries = [
|
|
QueryObject(metrics=["count"], columns=["product_line", "deal_size"])
|
|
]
|
|
assert not query_context_modified(query_context)
|
|
|
|
|
|
def test_query_context_modified_column_reference(mocker: MockerFixture) -> None:
|
|
"""
|
|
Test the `query_context_modified` function for an adhoc column reference.
|
|
|
|
Box plot sends its saved temporal column as an adhoc column whose `sqlExpression` is
|
|
its own label, which stands for the physical column stored in `params`.
|
|
"""
|
|
column_reference: dict[str, Any] = {
|
|
"expressionType": "SQL",
|
|
"label": "order_date",
|
|
"sqlExpression": "order_date",
|
|
}
|
|
|
|
query_context = mocker.MagicMock()
|
|
query_context.slice_.id = 42
|
|
query_context.slice_.query_context = None
|
|
query_context.slice_.params_dict = {
|
|
"metrics": ["count"],
|
|
"columns": ["order_date"],
|
|
"groupby": ["product_line"],
|
|
}
|
|
|
|
query_context.form_data = {
|
|
"slice_id": 42,
|
|
"metrics": ["count"],
|
|
"columns": ["order_date"],
|
|
"groupby": ["product_line"],
|
|
}
|
|
query_context.queries = [
|
|
QueryObject(
|
|
metrics=["count"],
|
|
columns=[column_reference, "product_line"], # type: ignore
|
|
)
|
|
]
|
|
assert not query_context_modified(query_context)
|
|
|
|
|
|
def test_query_context_modified_column_reference_tampered(
|
|
mocker: MockerFixture,
|
|
) -> None:
|
|
"""
|
|
Test the `query_context_modified` function when an adhoc column wraps free-form SQL.
|
|
|
|
A column reference only collapses to the name it points at, so SQL disguised as a
|
|
label must still be rejected.
|
|
"""
|
|
disguised: dict[str, Any] = {
|
|
"expressionType": "SQL",
|
|
"label": "UPPER(secret)",
|
|
"sqlExpression": "UPPER(secret)",
|
|
}
|
|
|
|
query_context = mocker.MagicMock()
|
|
query_context.slice_.id = 42
|
|
query_context.slice_.query_context = None
|
|
query_context.slice_.params_dict = {
|
|
"metrics": ["count"],
|
|
"columns": ["order_date"],
|
|
}
|
|
|
|
query_context.form_data = {
|
|
"slice_id": 42,
|
|
"metrics": ["count"],
|
|
"columns": ["order_date"],
|
|
}
|
|
query_context.queries = [
|
|
QueryObject(metrics=["count"], columns=[disguised]) # type: ignore # type: ignore
|
|
]
|
|
assert query_context_modified(query_context)
|
|
|
|
|
|
def test_query_context_modified_pivot_table_groupby(mocker: MockerFixture) -> None:
|
|
"""
|
|
Test the `query_context_modified` function for a pivot table.
|
|
|
|
Pivot table splits its group-bys across `groupbyColumns` and `groupbyRows`.
|
|
"""
|
|
query_context = mocker.MagicMock()
|
|
query_context.slice_.id = 42
|
|
query_context.slice_.query_context = None
|
|
query_context.slice_.params_dict = {
|
|
"metrics": ["count"],
|
|
"groupbyColumns": ["product_line"],
|
|
"groupbyRows": ["country", "city"],
|
|
}
|
|
|
|
query_context.form_data = {
|
|
"slice_id": 42,
|
|
"metrics": ["count"],
|
|
"groupbyColumns": ["product_line"],
|
|
"groupbyRows": ["country", "city"],
|
|
}
|
|
query_context.queries = [
|
|
QueryObject(
|
|
metrics=["count"],
|
|
columns=["product_line", "country", "city"],
|
|
)
|
|
]
|
|
assert not query_context_modified(query_context)
|
|
|
|
|
|
def test_query_context_modified_order_by_cols(mocker: MockerFixture) -> None:
|
|
"""
|
|
Test the `query_context_modified` function for `order_by_cols`.
|
|
|
|
Entries are JSON-encoded `[column, is_ascending]` pairs, and the column they sort on
|
|
is read by the query.
|
|
"""
|
|
query_context = mocker.MagicMock()
|
|
query_context.slice_.id = 42
|
|
query_context.slice_.query_context = None
|
|
query_context.slice_.params_dict = {
|
|
"start_time": "start_time",
|
|
"end_time": "end_time",
|
|
"series": "priority",
|
|
"order_by_cols": ['["status",false]'],
|
|
}
|
|
|
|
query_context.form_data = {
|
|
"slice_id": 42,
|
|
"start_time": "start_time",
|
|
"end_time": "end_time",
|
|
"series": "priority",
|
|
"order_by_cols": ['["status",false]'],
|
|
}
|
|
query_context.queries = [
|
|
QueryObject(
|
|
columns=["start_time", "end_time", "priority", "status"],
|
|
)
|
|
]
|
|
assert not query_context_modified(query_context)
|