mirror of
https://github.com/apache/superset.git
synced 2026-09-01 21:11:28 +00:00
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> Co-authored-by: Joe Li <joe@preset.io>
161 lines
6.3 KiB
Python
161 lines
6.3 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.
|
|
"""Unit tests for the ``CurrentUserRestApi`` self-service update flow.
|
|
|
|
Covers the ``password`` handling in ``PUT /api/v1/me/``: whether the caller
|
|
must prove knowledge of the existing password, and whether the value that
|
|
ends up persisted is the hash computed in ``pre_update`` or the raw value
|
|
from the request payload.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
from flask_appbuilder.security.sqla.models import User
|
|
from marshmallow import ValidationError
|
|
from werkzeug.security import check_password_hash, generate_password_hash
|
|
|
|
from superset import db
|
|
from superset.daos.user import UserDAO
|
|
from superset.views.users.api import CurrentUserRestApi
|
|
from superset.views.users.schemas import CurrentUserPutSchema
|
|
from tests.unit_tests.fixtures.common import admin_user, after_each # noqa: F401
|
|
|
|
|
|
def _run_update_me(user: User, data: dict[str, Any]) -> None:
|
|
"""Reproduce the body of ``CurrentUserRestApi.update_me`` for ``data``.
|
|
|
|
Exercises the same two calls the endpoint makes -- ``pre_update`` followed
|
|
by ``UserDAO.update`` with the schema-loaded payload as ``attributes`` --
|
|
without going through HTTP/auth plumbing, since neither call depends on it.
|
|
"""
|
|
api = CurrentUserRestApi()
|
|
with patch("superset.views.users.api.g") as mock_g:
|
|
mock_g.user = user
|
|
api.pre_update(user, data)
|
|
UserDAO.update(item=user, attributes=data)
|
|
|
|
|
|
def test_current_user_put_schema_has_current_password_field() -> None:
|
|
"""The payload schema for ``PUT /api/v1/me/`` carries a field for proving
|
|
knowledge of the existing password, required whenever ``password`` is
|
|
supplied.
|
|
"""
|
|
schema = CurrentUserPutSchema()
|
|
|
|
assert "password" in schema.fields
|
|
assert "current_password" in schema.fields
|
|
|
|
with pytest.raises(ValidationError):
|
|
schema.load({"password": "BrandNewPassw0rd!"})
|
|
|
|
# Present alongside "password", it loads fine (the schema only checks
|
|
# that it was *supplied*; whether it's actually correct is verified
|
|
# against the database in ``CurrentUserRestApi.pre_update``).
|
|
loaded = schema.load(
|
|
{"password": "BrandNewPassw0rd!", "current_password": "OldPassw0rd!"}
|
|
)
|
|
assert loaded["current_password"] == "OldPassw0rd!" # noqa: S105
|
|
|
|
|
|
def test_update_me_rejects_password_change_without_correct_current_password(
|
|
admin_user: User, # noqa: F811
|
|
after_each: None, # noqa: F811
|
|
) -> None:
|
|
"""A caller can no longer change the password by supplying only the new
|
|
value: an account with an existing password must prove knowledge of it
|
|
via ``current_password`` -- omitting the field, or getting it wrong, both
|
|
reject the change and leave the stored password untouched. Supplying the
|
|
correct current password lets the change through.
|
|
"""
|
|
original_hash = generate_password_hash("OldPassw0rd!")
|
|
admin_user.password = original_hash
|
|
|
|
with pytest.raises(ValidationError):
|
|
_run_update_me(admin_user, {"password": "BrandNewPassw0rd!"})
|
|
assert admin_user.password == original_hash
|
|
|
|
with pytest.raises(ValidationError):
|
|
_run_update_me(
|
|
admin_user,
|
|
{
|
|
"password": "BrandNewPassw0rd!",
|
|
"current_password": "WrongPassw0rd!",
|
|
},
|
|
)
|
|
assert admin_user.password == original_hash
|
|
|
|
_run_update_me(
|
|
admin_user,
|
|
{"password": "BrandNewPassw0rd!", "current_password": "OldPassw0rd!"},
|
|
)
|
|
db.session.flush()
|
|
|
|
assert admin_user.password != original_hash
|
|
assert check_password_hash(admin_user.password, "BrandNewPassw0rd!")
|
|
|
|
|
|
def test_update_me_password_change_persists_a_hash_not_plaintext(
|
|
admin_user: User, # noqa: F811
|
|
after_each: None, # noqa: F811
|
|
) -> None:
|
|
"""``pre_update`` computes ``generate_password_hash(data["password"])`` and
|
|
assigns it to the user, and ``UserDAO.update`` is then called with the
|
|
*same* ``data`` dict as ``attributes``. ``BaseDAO.update`` blindly
|
|
``setattr``s every key in ``attributes``, so ``pre_update`` must remove
|
|
the plaintext ``password`` key (and any ``current_password``) from that
|
|
dict once it's done with them, or the plaintext would overwrite the hash
|
|
that was just computed and reach the database instead of it.
|
|
|
|
``admin_user`` starts with no password set, so this exercises the
|
|
first-password-set path, which requires no proof of a prior password.
|
|
"""
|
|
new_password = "BrandNewPassw0rd!" # noqa: S105
|
|
|
|
_run_update_me(admin_user, {"password": new_password})
|
|
db.session.flush()
|
|
|
|
stored_password = admin_user.password
|
|
|
|
# The stored value should be a password hash that verifies against the
|
|
# new password -- not the plaintext value itself.
|
|
assert stored_password != new_password
|
|
assert check_password_hash(stored_password, new_password)
|
|
|
|
|
|
def test_update_me_falsy_password_does_not_blank_stored_hash(
|
|
admin_user: User, # noqa: F811
|
|
after_each: None, # noqa: F811
|
|
) -> None:
|
|
"""A falsy ``password`` (e.g. an empty string, which the schema's
|
|
complexity validator lets through when password complexity validation is
|
|
disabled) skips the hashing branch entirely -- ``pre_update`` must still
|
|
drop the key from ``data`` so it never reaches ``UserDAO.update``'s
|
|
``setattr`` loop and blanks the account's stored hash.
|
|
"""
|
|
original_hash = generate_password_hash("OldPassw0rd!")
|
|
admin_user.password = original_hash
|
|
|
|
_run_update_me(admin_user, {"password": "", "first_name": "Foo"})
|
|
db.session.flush()
|
|
|
|
assert admin_user.password == original_hash
|
|
assert admin_user.first_name == "Foo"
|