mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-10-03 20:55:46 +01:00
`PUT /api/users/me` and `PUT /api/admin/users/<id>` write the new password hash, and then the profile fields, before the rest of the payload is checked. When the request is rejected further down as an invalid role, an admin-only setting, an invalid settings value, the route answers 400 with those writes already committed, so the caller sees an error while the password has in fact changed. Both routes now validate the whole payload before touching the database, and the password hash is folded into the same `update_user` call as the other fields so the field write is a single transaction. Error messages, status codes and the order they are reported in are unchanged. ## Verification - New tests in `tests/core/test_self_user_routes.py` and `tests/core/test_admin_users_api.py` assert that a rejected update leaves the password, profile fields and role as they were, plus a positive case that a valid payload still applies all three. They fail on current main and pass here. - Full suite (3150), ruff, ruff format, basedpyright, vulture green.
309 lines
11 KiB
Python
309 lines
11 KiB
Python
"""Tests for self-service account edit context and update endpoints."""
|
|
|
|
import os
|
|
import tempfile
|
|
from typing import Any
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
from flask import Flask
|
|
|
|
from shelfmark.core.self_user_routes import register_self_user_routes
|
|
from shelfmark.core.user_db import UserDB
|
|
|
|
|
|
@pytest.fixture
|
|
def db_path():
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
yield os.path.join(tmpdir, "shelfmark.db")
|
|
|
|
|
|
@pytest.fixture
|
|
def user_db(db_path):
|
|
db = UserDB(db_path)
|
|
db.initialize()
|
|
return db
|
|
|
|
|
|
@pytest.fixture
|
|
def app(user_db):
|
|
test_app = Flask(__name__)
|
|
test_app.config["SECRET_KEY"] = "test-secret"
|
|
test_app.config["TESTING"] = True
|
|
|
|
register_self_user_routes(test_app, user_db)
|
|
return test_app
|
|
|
|
|
|
def _authed_client_for_user(app: Flask, user: dict) -> Any:
|
|
client = app.test_client()
|
|
with client.session_transaction() as sess:
|
|
sess["user_id"] = user["username"]
|
|
sess["db_user_id"] = user["id"]
|
|
sess["is_admin"] = False
|
|
return client
|
|
|
|
|
|
def _visible_sections_config_get(
|
|
visible_sections: object,
|
|
):
|
|
def _get(key: str, default: object = None, user_id: int | None = None) -> object:
|
|
del user_id
|
|
if key == "VISIBLE_SELF_SETTINGS_SECTIONS":
|
|
return visible_sections
|
|
return default
|
|
|
|
return _get
|
|
|
|
|
|
def test_users_me_edit_context_respects_visible_sections(app, user_db, monkeypatch):
|
|
user = user_db.create_user(username="alice")
|
|
user_db.set_user_settings(user["id"], {"DESTINATION": "/books/alice"})
|
|
client = _authed_client_for_user(app, user)
|
|
monkeypatch.delenv("INGEST_DIR", raising=False)
|
|
|
|
with patch("shelfmark.core.self_user_routes.load_active_auth_mode", return_value="builtin"):
|
|
with patch(
|
|
"shelfmark.core.self_user_routes.app_config.get",
|
|
side_effect=_visible_sections_config_get(["delivery"]),
|
|
):
|
|
resp = client.get("/api/users/me/edit-context")
|
|
|
|
assert resp.status_code == 200
|
|
assert resp.json["visibleUserSettingsSections"] == ["delivery"]
|
|
assert resp.json["deliveryPreferences"]["tab"] == "downloads"
|
|
assert resp.json["deliveryPreferences"]["effective"]["DESTINATION"]["value"] == "/books/alice"
|
|
assert resp.json["deliveryPreferences"]["effective"]["DESTINATION"]["source"] == "user_override"
|
|
assert "DESTINATION" in resp.json["userOverridableKeys"]
|
|
assert resp.json["notificationPreferences"] is None
|
|
|
|
|
|
def test_users_me_edit_context_includes_search_preferences_when_visible(app, user_db):
|
|
user = user_db.create_user(username="alice")
|
|
user_db.set_user_settings(
|
|
user["id"],
|
|
{
|
|
"SEARCH_MODE": "universal",
|
|
"METADATA_PROVIDER": "openlibrary",
|
|
"BOOK_LANGUAGE": ["de", "en"],
|
|
},
|
|
)
|
|
client = _authed_client_for_user(app, user)
|
|
|
|
with patch("shelfmark.core.self_user_routes.load_active_auth_mode", return_value="builtin"):
|
|
with patch(
|
|
"shelfmark.core.self_user_routes.app_config.get",
|
|
side_effect=_visible_sections_config_get(["delivery", "search"]),
|
|
):
|
|
resp = client.get("/api/users/me/edit-context")
|
|
|
|
assert resp.status_code == 200
|
|
assert resp.json["visibleUserSettingsSections"] == ["delivery", "search"]
|
|
assert resp.json["deliveryPreferences"]["tab"] == "downloads"
|
|
assert resp.json["searchPreferences"]["tab"] == "search_mode"
|
|
assert resp.json["searchPreferences"]["effective"]["SEARCH_MODE"]["value"] == "universal"
|
|
assert resp.json["searchPreferences"]["effective"]["SEARCH_MODE"]["source"] == "user_override"
|
|
assert resp.json["searchPreferences"]["effective"]["BOOK_LANGUAGE"]["value"] == ["de", "en"]
|
|
assert resp.json["searchPreferences"]["effective"]["BOOK_LANGUAGE"]["source"] == (
|
|
"user_override"
|
|
)
|
|
assert "SEARCH_MODE" in resp.json["userOverridableKeys"]
|
|
assert "METADATA_PROVIDER" in resp.json["userOverridableKeys"]
|
|
assert "BOOK_LANGUAGE" in resp.json["userOverridableKeys"]
|
|
assert resp.json["notificationPreferences"] is None
|
|
assert resp.json["userOverridableKeys"] == sorted(resp.json["userOverridableKeys"])
|
|
|
|
|
|
def test_users_me_edit_context_falls_back_to_default_sections_for_invalid_config(app, user_db):
|
|
user = user_db.create_user(username="alice")
|
|
client = _authed_client_for_user(app, user)
|
|
|
|
with patch("shelfmark.core.self_user_routes.load_active_auth_mode", return_value="builtin"):
|
|
with patch(
|
|
"shelfmark.core.self_user_routes.app_config.get",
|
|
side_effect=_visible_sections_config_get("bogus"),
|
|
):
|
|
resp = client.get("/api/users/me/edit-context")
|
|
|
|
assert resp.status_code == 200
|
|
assert resp.json["visibleUserSettingsSections"] == ["delivery", "search", "notifications"]
|
|
assert resp.json["deliveryPreferences"] is not None
|
|
assert resp.json["searchPreferences"] is not None
|
|
assert resp.json["notificationPreferences"] is not None
|
|
|
|
|
|
def test_users_me_update_rejects_hidden_section_settings(app, user_db):
|
|
user = user_db.create_user(username="alice")
|
|
client = _authed_client_for_user(app, user)
|
|
|
|
with patch("shelfmark.core.self_user_routes.load_active_auth_mode", return_value="builtin"):
|
|
with patch(
|
|
"shelfmark.core.self_user_routes.app_config.get",
|
|
side_effect=_visible_sections_config_get(["delivery"]),
|
|
):
|
|
resp = client.put(
|
|
"/api/users/me",
|
|
json={
|
|
"settings": {
|
|
"USER_NOTIFICATION_ROUTES": [
|
|
{"event": "all", "url": "ntfys://ntfy.sh/alice"}
|
|
],
|
|
}
|
|
},
|
|
)
|
|
|
|
assert resp.status_code == 400
|
|
assert resp.json["error"] == "Some settings are admin-only"
|
|
assert "Setting not user-overridable: USER_NOTIFICATION_ROUTES" in resp.json["details"]
|
|
|
|
|
|
def test_users_me_update_accepts_visible_section_settings(app, user_db):
|
|
user = user_db.create_user(username="alice")
|
|
client = _authed_client_for_user(app, user)
|
|
|
|
with patch("shelfmark.core.self_user_routes.load_active_auth_mode", return_value="builtin"):
|
|
with patch(
|
|
"shelfmark.core.self_user_routes.app_config.get",
|
|
side_effect=_visible_sections_config_get(["delivery"]),
|
|
):
|
|
resp = client.put(
|
|
"/api/users/me",
|
|
json={"settings": {"DESTINATION": "/books/alice"}},
|
|
)
|
|
|
|
assert resp.status_code == 200
|
|
assert user_db.get_user_settings(user["id"]).get("DESTINATION") == "/books/alice"
|
|
assert resp.json["settings"]["DESTINATION"] == "/books/alice"
|
|
|
|
|
|
def test_users_me_update_accepts_book_language_when_search_section_visible(app, user_db):
|
|
user = user_db.create_user(username="alice")
|
|
client = _authed_client_for_user(app, user)
|
|
|
|
with patch("shelfmark.core.self_user_routes.load_active_auth_mode", return_value="builtin"):
|
|
with patch(
|
|
"shelfmark.core.self_user_routes.app_config.get",
|
|
side_effect=_visible_sections_config_get(["search"]),
|
|
):
|
|
resp = client.put(
|
|
"/api/users/me",
|
|
json={"settings": {"BOOK_LANGUAGE": ["German", "en"]}},
|
|
)
|
|
|
|
assert resp.status_code == 200
|
|
assert user_db.get_user_settings(user["id"])["BOOK_LANGUAGE"] == ["de", "en"]
|
|
|
|
|
|
def test_users_me_update_rejects_book_language_when_search_section_hidden(app, user_db):
|
|
user = user_db.create_user(username="alice")
|
|
client = _authed_client_for_user(app, user)
|
|
|
|
with patch("shelfmark.core.self_user_routes.load_active_auth_mode", return_value="builtin"):
|
|
with patch(
|
|
"shelfmark.core.self_user_routes.app_config.get",
|
|
side_effect=_visible_sections_config_get(["delivery"]),
|
|
):
|
|
resp = client.put(
|
|
"/api/users/me",
|
|
json={"settings": {"BOOK_LANGUAGE": ["de"]}},
|
|
)
|
|
|
|
assert resp.status_code == 400
|
|
assert resp.json["error"] == "Some settings are admin-only"
|
|
assert user_db.get_user_settings(user["id"]) == {}
|
|
|
|
|
|
def test_users_me_update_rejects_non_object_settings_payload(app, user_db):
|
|
user = user_db.create_user(username="alice")
|
|
client = _authed_client_for_user(app, user)
|
|
|
|
with patch("shelfmark.core.self_user_routes.load_active_auth_mode", return_value="builtin"):
|
|
with patch(
|
|
"shelfmark.core.self_user_routes.app_config.get",
|
|
side_effect=_visible_sections_config_get(["delivery"]),
|
|
):
|
|
resp = client.put("/api/users/me", json={"settings": ["DESTINATION"]})
|
|
|
|
assert resp.status_code == 400
|
|
assert resp.json["error"] == "Settings must be an object"
|
|
|
|
|
|
def test_users_me_update_rejects_oidc_email_change(app, user_db):
|
|
user = user_db.create_user(
|
|
username="oidc-user",
|
|
oidc_subject="oidc-sub-123",
|
|
auth_source="oidc",
|
|
)
|
|
client = _authed_client_for_user(app, user)
|
|
|
|
with patch("shelfmark.core.self_user_routes.load_active_auth_mode", return_value="builtin"):
|
|
resp = client.put("/api/users/me", json={"email": "new@example.com"})
|
|
|
|
assert resp.status_code == 400
|
|
assert resp.json["error"] == "Cannot change email for OIDC users"
|
|
assert user_db.get_user(user_id=user["id"])["email"] is None
|
|
|
|
|
|
def test_users_me_update_keeps_password_and_profile_when_settings_rejected(app, user_db):
|
|
user = user_db.create_user(
|
|
username="alice",
|
|
display_name="Alice Old",
|
|
password_hash="old_hash",
|
|
)
|
|
client = _authed_client_for_user(app, user)
|
|
|
|
with patch("shelfmark.core.self_user_routes.load_active_auth_mode", return_value="builtin"):
|
|
with patch(
|
|
"shelfmark.core.self_user_routes.app_config.get",
|
|
side_effect=_visible_sections_config_get(["delivery"]),
|
|
):
|
|
resp = client.put(
|
|
"/api/users/me",
|
|
json={
|
|
"password": "newpass99",
|
|
"display_name": "Alice New",
|
|
"settings": {
|
|
"USER_NOTIFICATION_ROUTES": [
|
|
{"event": "all", "url": "ntfys://ntfy.sh/alice"}
|
|
],
|
|
},
|
|
},
|
|
)
|
|
|
|
assert resp.status_code == 400
|
|
assert resp.json["error"] == "Some settings are admin-only"
|
|
|
|
stored = user_db.get_user(user_id=user["id"])
|
|
assert stored["password_hash"] == "old_hash"
|
|
assert stored["display_name"] == "Alice Old"
|
|
|
|
|
|
def test_users_me_update_applies_password_profile_and_settings_together(app, user_db):
|
|
user = user_db.create_user(
|
|
username="alice",
|
|
display_name="Alice Old",
|
|
password_hash="old_hash",
|
|
)
|
|
client = _authed_client_for_user(app, user)
|
|
|
|
with patch("shelfmark.core.self_user_routes.load_active_auth_mode", return_value="builtin"):
|
|
with patch(
|
|
"shelfmark.core.self_user_routes.app_config.get",
|
|
side_effect=_visible_sections_config_get(["delivery"]),
|
|
):
|
|
resp = client.put(
|
|
"/api/users/me",
|
|
json={
|
|
"password": "newpass99",
|
|
"display_name": "Alice New",
|
|
"settings": {"DESTINATION": "/books/alice"},
|
|
},
|
|
)
|
|
|
|
assert resp.status_code == 200
|
|
|
|
stored = user_db.get_user(user_id=user["id"])
|
|
assert stored["password_hash"] != "old_hash"
|
|
assert stored["display_name"] == "Alice New"
|
|
assert user_db.get_user_settings(user["id"])["DESTINATION"] == "/books/alice"
|