mirror of
https://github.com/apache/superset.git
synced 2026-09-01 04:51:23 +00:00
331 lines
12 KiB
Python
331 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.
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from typing import Any, cast
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
import sshtunnel
|
|
from flask import Flask, Response
|
|
from flask_babel import Babel
|
|
from sqlalchemy.exc import IntegrityError, OperationalError, ProgrammingError
|
|
|
|
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
|
|
from superset.exceptions import QueryObjectValidationError, SupersetException
|
|
from superset.superset_typing import FlaskResponse
|
|
from superset.utils import json
|
|
from superset.utils.error_sanitization import (
|
|
GENERIC_ACCESS_MESSAGE,
|
|
GENERIC_ERROR_MESSAGE,
|
|
)
|
|
from superset.views.error_handling import (
|
|
handle_api_exception,
|
|
json_error_response,
|
|
set_app_error_handlers,
|
|
)
|
|
|
|
|
|
class TestHandleApiExceptionSSHTunnelError:
|
|
def test_returns_400_with_connection_host_down_error_and_no_error_log(
|
|
self, app, caplog: pytest.LogCaptureFixture
|
|
):
|
|
@handle_api_exception
|
|
def view(self: object) -> FlaskResponse:
|
|
raise sshtunnel.BaseSSHTunnelForwarderError(
|
|
"Could not establish session to SSH gateway"
|
|
)
|
|
|
|
with app.test_request_context():
|
|
with caplog.at_level(logging.WARNING):
|
|
response = cast(Response, view(self=object()))
|
|
|
|
assert response.status_code == 400
|
|
payload = json.loads(response.data)
|
|
assert (
|
|
payload["errors"][0]["error_type"]
|
|
== SupersetErrorType.CONNECTION_HOST_DOWN_ERROR.value
|
|
)
|
|
assert not any(record.levelno >= logging.ERROR for record in caplog.records)
|
|
assert any(
|
|
record.levelno == logging.WARNING
|
|
and "BaseSSHTunnelForwarderError" in record.message
|
|
for record in caplog.records
|
|
)
|
|
|
|
|
|
class TestHandleApiExceptionDatabaseErrors:
|
|
"""
|
|
`OperationalError` is a `DatabaseError` subclass, so it must be matched by
|
|
its own clause ahead of the 422 handler for other `DatabaseError`s.
|
|
"""
|
|
|
|
def _run(self, app, ex: Exception, *, guest: bool = False) -> Response:
|
|
@handle_api_exception
|
|
def view(self: object) -> FlaskResponse:
|
|
raise ex
|
|
|
|
with (
|
|
app.test_request_context(),
|
|
patch(
|
|
"superset.security.SupersetSecurityManager.is_guest_user",
|
|
return_value=guest,
|
|
),
|
|
):
|
|
return cast(Response, view(self=object()))
|
|
|
|
def test_operational_error_returns_500(self, app):
|
|
response = self._run(
|
|
app, OperationalError("SELECT 1", {}, Exception("connection closed"))
|
|
)
|
|
|
|
assert response.status_code == 500
|
|
|
|
def test_non_connection_database_error_still_returns_422(self, app):
|
|
response = self._run(
|
|
app,
|
|
ProgrammingError("SELECT 1", {}, Exception("relation does not exist")),
|
|
)
|
|
|
|
assert response.status_code == 422
|
|
|
|
def test_integrity_error_still_returns_422(self, app):
|
|
response = self._run(
|
|
app, IntegrityError("INSERT", {}, Exception("duplicate key"))
|
|
)
|
|
|
|
assert response.status_code == 422
|
|
|
|
def test_operational_error_message_is_redacted_for_guest_users(self, app):
|
|
"""
|
|
The 500 clause hands the raw driver message to `json_error_response`,
|
|
which routes a bare string through `sanitize_error_message`. A driver
|
|
message quotes the host, port and user of the connection it failed on,
|
|
so an embedded viewer must receive the generic text instead.
|
|
"""
|
|
leaky = (
|
|
"could not connect to server: Connection refused\n\tIs the server "
|
|
'running on host "analytics-prod.internal" (10.0.4.17) and accepting '
|
|
"TCP/IP connections on port 5432?"
|
|
)
|
|
|
|
response = self._run(
|
|
app, OperationalError("SELECT 1", {}, Exception(leaky)), guest=True
|
|
)
|
|
|
|
assert response.status_code == 500
|
|
payload = json.loads(response.data)
|
|
assert payload["error"] == str(GENERIC_ERROR_MESSAGE)
|
|
for secret in ("analytics-prod.internal", "10.0.4.17", "5432"):
|
|
assert secret not in response.get_data(as_text=True)
|
|
|
|
def test_operational_error_message_is_kept_for_regular_users(self, app):
|
|
"""The redaction above is guest-only; an operator still needs the detail."""
|
|
response = self._run(
|
|
app, OperationalError("SELECT 1", {}, Exception("Connection refused"))
|
|
)
|
|
|
|
assert response.status_code == 500
|
|
assert "Connection refused" in json.loads(response.data)["error"]
|
|
|
|
|
|
class TestShowUnexpectedException:
|
|
def _build_app_with_handlers(self) -> Flask:
|
|
# A fresh, minimal Flask app per test: `set_app_error_handlers` can
|
|
# only register handlers before the app has served its first
|
|
# request, so it can't share the module-scoped `app` fixture across
|
|
# tests in this class.
|
|
test_app = Flask(__name__)
|
|
test_app.config["DEBUG"] = False
|
|
Babel(test_app)
|
|
set_app_error_handlers(test_app)
|
|
|
|
@test_app.route("/ssh-tunnel-error")
|
|
def ssh_tunnel_error_view() -> FlaskResponse:
|
|
raise sshtunnel.BaseSSHTunnelForwarderError(
|
|
"Could not establish session to SSH gateway"
|
|
)
|
|
|
|
@test_app.route("/generic-error")
|
|
def generic_error_view() -> FlaskResponse:
|
|
raise ValueError("boom")
|
|
|
|
return test_app
|
|
|
|
def test_ssh_tunnel_error_returns_structured_400(
|
|
self, caplog: pytest.LogCaptureFixture
|
|
):
|
|
client = self._build_app_with_handlers().test_client()
|
|
|
|
with caplog.at_level(logging.WARNING):
|
|
response = client.get("/ssh-tunnel-error")
|
|
|
|
assert response.status_code == 400
|
|
payload = json.loads(response.data)
|
|
assert (
|
|
payload["errors"][0]["error_type"]
|
|
== SupersetErrorType.CONNECTION_HOST_DOWN_ERROR.value
|
|
)
|
|
assert not any(record.levelno >= logging.ERROR for record in caplog.records)
|
|
|
|
def test_generic_exception_still_returns_original_500_shape(
|
|
self, caplog: pytest.LogCaptureFixture
|
|
):
|
|
client = self._build_app_with_handlers().test_client()
|
|
|
|
with caplog.at_level(logging.WARNING):
|
|
response = client.get("/generic-error")
|
|
|
|
assert response.status_code == 500
|
|
payload = json.loads(response.data)
|
|
assert (
|
|
payload["errors"][0]["error_type"]
|
|
== SupersetErrorType.GENERIC_BACKEND_ERROR.value
|
|
)
|
|
assert any(record.levelno >= logging.ERROR for record in caplog.records)
|
|
|
|
|
|
class TestShowSupersetException:
|
|
def _build_app_with_handlers(self) -> Flask:
|
|
# A fresh, minimal Flask app per test: `set_app_error_handlers` can
|
|
# only register handlers before the app has served its first
|
|
# request, so it can't share the module-scoped `app` fixture across
|
|
# tests in this class.
|
|
test_app = Flask(__name__)
|
|
test_app.config["DEBUG"] = False
|
|
Babel(test_app)
|
|
set_app_error_handlers(test_app)
|
|
|
|
@test_app.route("/query-validation-error")
|
|
def query_validation_error_view() -> FlaskResponse:
|
|
raise QueryObjectValidationError("list object has no element 0")
|
|
|
|
@test_app.route("/generic-superset-exception")
|
|
def generic_superset_exception_view() -> FlaskResponse:
|
|
raise SupersetException("boom")
|
|
|
|
return test_app
|
|
|
|
def test_4xx_superset_exception_returns_its_status_and_logs_at_warning(
|
|
self, caplog: pytest.LogCaptureFixture
|
|
):
|
|
client = self._build_app_with_handlers().test_client()
|
|
|
|
with caplog.at_level(logging.WARNING):
|
|
response = client.get("/query-validation-error")
|
|
|
|
assert response.status_code == 400
|
|
payload = json.loads(response.data)
|
|
assert payload["errors"][0]["message"] == "list object has no element 0"
|
|
assert not any(record.levelno >= logging.ERROR for record in caplog.records)
|
|
assert any(
|
|
record.levelno == logging.WARNING
|
|
and record.message == "list object has no element 0"
|
|
for record in caplog.records
|
|
)
|
|
|
|
def test_5xx_superset_exception_still_returns_500_and_logs_at_error(
|
|
self, caplog: pytest.LogCaptureFixture
|
|
):
|
|
client = self._build_app_with_handlers().test_client()
|
|
|
|
with caplog.at_level(logging.WARNING):
|
|
response = client.get("/generic-superset-exception")
|
|
|
|
assert response.status_code == 500
|
|
payload = json.loads(response.data)
|
|
assert payload["errors"][0]["message"] == "boom"
|
|
assert any(record.levelno >= logging.ERROR for record in caplog.records)
|
|
|
|
def test_html_accept_serves_branded_error_page_not_raw_json(self):
|
|
client = self._build_app_with_handlers().test_client()
|
|
|
|
with patch(
|
|
"superset.views.error_handling.send_file",
|
|
return_value=Response("<html>500</html>", mimetype="text/html"),
|
|
) as mock_send_file:
|
|
response = client.get(
|
|
"/generic-superset-exception", headers={"Accept": "text/html"}
|
|
)
|
|
|
|
assert response.status_code == 500
|
|
assert response.content_type.startswith("text/html")
|
|
mock_send_file.assert_called_once()
|
|
|
|
|
|
class TestGuestErrorSanitization:
|
|
def _response_payload(
|
|
self,
|
|
error_details: str | list[SupersetError],
|
|
status: int,
|
|
app: Flask,
|
|
) -> dict[str, Any]:
|
|
with (
|
|
app.test_request_context(),
|
|
patch(
|
|
"superset.security.SupersetSecurityManager.is_guest_user",
|
|
return_value=True,
|
|
),
|
|
):
|
|
response = cast(Response, json_error_response(error_details, status=status))
|
|
return json.loads(response.data)
|
|
|
|
def test_db_error_is_replaced_for_guest_users(self, app):
|
|
payload = self._response_payload(
|
|
[
|
|
SupersetError(
|
|
message="Table mydb.myschema.mytable was not found",
|
|
error_type=SupersetErrorType.TABLE_DOES_NOT_EXIST_ERROR,
|
|
level=ErrorLevel.ERROR,
|
|
extra={"engine_name": "BigQuery"},
|
|
)
|
|
],
|
|
500,
|
|
app,
|
|
)
|
|
|
|
assert payload["errors"][0]["message"] == str(GENERIC_ERROR_MESSAGE)
|
|
assert (
|
|
payload["errors"][0]["error_type"]
|
|
== SupersetErrorType.GENERIC_BACKEND_ERROR.value
|
|
)
|
|
assert "engine_name" not in payload["errors"][0]["extra"]
|
|
|
|
def test_bare_string_error_is_replaced_for_guest_users(self, app):
|
|
payload = self._response_payload(
|
|
"relation mydb.mytable does not exist", 422, app
|
|
)
|
|
|
|
assert payload["error"] == str(GENERIC_ERROR_MESSAGE)
|
|
|
|
def test_access_denial_reads_as_a_denial_for_guest_users(self, app: Flask) -> None:
|
|
payload = self._response_payload("Forbidden", 403, app)
|
|
|
|
assert payload["error"] == str(GENERIC_ACCESS_MESSAGE)
|
|
|
|
def test_not_found_is_replaced_for_guest_users(self, app: Flask) -> None:
|
|
"""
|
|
A 404 message is no safer than any other: `warm_up_cache` reports a
|
|
missing table as "Table %(table)s wasn't found in the database %(db)s".
|
|
"""
|
|
payload = self._response_payload(
|
|
"Table my_table wasn't found in the database my_db", 404, app
|
|
)
|
|
|
|
assert payload["error"] == str(GENERIC_ERROR_MESSAGE)
|