Files
superset2/tests/unit_tests/security/test_password_change.py
2026-06-11 22:23:10 -07:00

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