mirror of
https://github.com/apache/superset.git
synced 2026-07-08 15:55:33 +00:00
224 lines
7.9 KiB
Python
224 lines
7.9 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 collections.abc import Iterator
|
|
from typing import Optional
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
from flask import Flask
|
|
|
|
from superset.security.password_change import (
|
|
_get_user_attribute,
|
|
_is_exempt_endpoint,
|
|
password_change_required,
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"endpoint,expected",
|
|
[
|
|
(None, True), # static file serving etc.
|
|
("ResetMyPasswordView.this_form_get", True),
|
|
("AuthDBView.login", True),
|
|
("AuthDBView.logout", True),
|
|
("appbuilder.static", True),
|
|
("UserInfoEditView.this_form_post", True),
|
|
("AuthOAuthView.login", True),
|
|
("SomeBlueprint.static", True),
|
|
("health", True),
|
|
("SupersetIndexView.index", False),
|
|
("Superset.dashboard", False),
|
|
# Substring over-matching must NOT exempt these (they merely share a
|
|
# substring with an exempt token).
|
|
("AuthorView.list", False),
|
|
("HealthDashboardView.show", False),
|
|
("StaticAssetReportView.list", False),
|
|
("UserInfoFancyView.show", False),
|
|
],
|
|
)
|
|
def test_is_exempt_endpoint(endpoint: Optional[str], expected: bool) -> None:
|
|
"""Exempt-endpoint matching is exact per view class, never substring."""
|
|
# The password-reset / auth / static endpoints must stay reachable to avoid
|
|
# a redirect loop while a change is pending.
|
|
assert _is_exempt_endpoint(endpoint) is expected
|
|
|
|
|
|
def test_password_change_required() -> None:
|
|
"""The flag on the user's attribute row drives the required-change check."""
|
|
user = MagicMock()
|
|
user.id = 5
|
|
|
|
with patch(
|
|
"superset.security.password_change._get_user_attribute"
|
|
) as mock_get_attr:
|
|
mock_get_attr.return_value = MagicMock(password_must_change=True)
|
|
assert password_change_required(user) is True
|
|
|
|
mock_get_attr.return_value = MagicMock(password_must_change=False)
|
|
assert password_change_required(user) is False
|
|
|
|
mock_get_attr.return_value = None
|
|
assert password_change_required(user) is False
|
|
|
|
|
|
def test_password_change_required_no_user_id() -> None:
|
|
"""A user without an id (e.g. anonymous) never requires a change."""
|
|
user = MagicMock()
|
|
user.id = None
|
|
assert password_change_required(user) is False
|
|
|
|
|
|
def test_get_user_attribute_deterministic_with_duplicates() -> None:
|
|
"""Duplicate attribute rows must yield a deterministic row, not a 500."""
|
|
# Databases migrated from before the ``user_attribute.user_id`` unique
|
|
# constraint could contain duplicate rows. The query must not raise (which
|
|
# ``.one_or_none()`` would have done via ``MultipleResultsFound``); it must
|
|
# fetch a single row deterministically via ``order_by(id).first()``.
|
|
query = MagicMock()
|
|
db = MagicMock()
|
|
db.session.query.return_value = query
|
|
query.filter.return_value = query
|
|
query.order_by.return_value = query
|
|
sentinel = MagicMock(name="first_row")
|
|
query.first.return_value = sentinel
|
|
|
|
with (
|
|
patch("superset.extensions.db", db),
|
|
patch("superset.models.user_attributes.UserAttribute") as user_attribute,
|
|
):
|
|
result = _get_user_attribute(5)
|
|
|
|
# Deterministic ordering on the primary key, then ``.first()`` — never
|
|
# ``.one_or_none()``, which could 500 on duplicate rows.
|
|
query.order_by.assert_called_once_with(user_attribute.id)
|
|
query.first.assert_called_once_with()
|
|
assert not query.one_or_none.called
|
|
assert result is sentinel
|
|
|
|
|
|
@pytest.fixture
|
|
def enforcement_app() -> Flask:
|
|
"""A minimal Flask app with the enforcement hook registered and a flagged
|
|
user, used to exercise the before-request redirect behavior end to end."""
|
|
from flask import g
|
|
|
|
from superset.security.password_change import (
|
|
register_password_change_enforcement,
|
|
)
|
|
|
|
app = Flask(__name__)
|
|
app.config["ENABLE_FORCE_PASSWORD_CHANGE"] = True
|
|
app.secret_key = "test" # noqa: S105
|
|
|
|
user = MagicMock()
|
|
user.id = 5
|
|
user.is_anonymous = False
|
|
|
|
@app.before_request
|
|
def _set_user() -> None: # pylint: disable=unused-variable
|
|
g.user = user
|
|
|
|
# A non-exempt route that, if redirected to, would re-trigger enforcement.
|
|
@app.route("/")
|
|
def index() -> str: # pylint: disable=unused-variable
|
|
return "index"
|
|
|
|
register_password_change_enforcement(app)
|
|
return app
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _no_babel_flash() -> Iterator[None]:
|
|
"""The minimal test app has no babel/flash messaging set up; stub them so
|
|
the enforcement hook's translation + flash calls don't blow up. These are
|
|
incidental to the redirect-target logic under test."""
|
|
with (
|
|
patch("superset.security.password_change.flash"),
|
|
patch("superset.security.password_change.__", side_effect=lambda s: s),
|
|
):
|
|
yield
|
|
|
|
|
|
def test_enforcement_redirects_to_reset_view(enforcement_app: Flask) -> None:
|
|
# Happy path: the reset endpoint resolves, so flagged users are redirected
|
|
# there (an exempt route) — no loop.
|
|
with (
|
|
patch(
|
|
"superset.security.password_change.password_change_required",
|
|
return_value=True,
|
|
),
|
|
patch(
|
|
"superset.security.password_change.url_for",
|
|
return_value="/resetmypassword/form",
|
|
),
|
|
):
|
|
resp = enforcement_app.test_client().get("/")
|
|
assert resp.status_code == 302
|
|
assert resp.headers["Location"].endswith("/resetmypassword/form")
|
|
|
|
|
|
def test_enforcement_falls_back_to_exempt_logout_not_index(
|
|
enforcement_app: Flask,
|
|
) -> None:
|
|
# If the reset endpoint can't be resolved, the fallback must be an exempt
|
|
# route (logout) — never "/" / the index, which would loop. We make the
|
|
# reset endpoint fail and the logout endpoint resolve.
|
|
def fake_url_for(endpoint: str, *args, **kwargs) -> str:
|
|
if endpoint == "ResetMyPasswordView.this_form_get":
|
|
raise RuntimeError("no such endpoint")
|
|
if endpoint == "AuthDBView.logout":
|
|
return "/logout"
|
|
raise AssertionError(f"unexpected endpoint {endpoint}")
|
|
|
|
with (
|
|
patch(
|
|
"superset.security.password_change.password_change_required",
|
|
return_value=True,
|
|
),
|
|
patch(
|
|
"superset.security.password_change.url_for",
|
|
side_effect=fake_url_for,
|
|
),
|
|
):
|
|
resp = enforcement_app.test_client().get("/")
|
|
assert resp.status_code == 302
|
|
location = resp.headers["Location"]
|
|
assert location.endswith("/logout")
|
|
# Crucially, the fallback is NOT a redirect back to the non-exempt index.
|
|
assert not location.endswith("/")
|
|
|
|
|
|
def test_enforcement_no_resolvable_target_returns_error_not_loop(
|
|
enforcement_app: Flask,
|
|
) -> None:
|
|
# If NO exempt target can be resolved, we must return an error response
|
|
# rather than redirect, so the flagged user can never get stuck in a loop.
|
|
with (
|
|
patch(
|
|
"superset.security.password_change.password_change_required",
|
|
return_value=True,
|
|
),
|
|
patch(
|
|
"superset.security.password_change.url_for",
|
|
side_effect=RuntimeError("no endpoints"),
|
|
),
|
|
):
|
|
resp = enforcement_app.test_client().get("/")
|
|
assert resp.status_code == 503
|
|
assert "Location" not in resp.headers
|