Files
shelfmark/tests/core/test_self_user_routes.py
T
splitsec2 127dd82615 fix(users): apply user updates only after the payload validates (#1360)
`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.
2026-09-20 12:50:14 -04:00

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"