Files
superset2/tests/unit_tests/views/test_current_user_api.py
T

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"