# 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 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 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("500", 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)