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.
This commit is contained in:
splitsec2
2026-09-20 12:50:14 -04:00
committed by GitHub
parent acd59f7cbb
commit 127dd82615
4 changed files with 121 additions and 9 deletions
+34
View File
@@ -980,6 +980,40 @@ class TestAdminUserPasswordUpdate:
assert resp.status_code == 400
assert "Cannot set password for PROXY users" in resp.json["error"]
def test_update_password_not_applied_when_role_is_rejected(self, admin_client, user_db):
"""A rejected role leaves the stored password untouched."""
user = user_db.create_user(username="alice", role="user", password_hash="old_hash")
resp = admin_client.put(
f"/api/admin/users/{user['id']}",
json={"password": "newpass99", "role": "superuser"},
)
assert resp.status_code == 400
assert resp.json["error"] == "Role must be 'admin' or 'user'"
updated = user_db.get_user(user_id=user["id"])
assert updated["password_hash"] == "old_hash"
assert updated["role"] == "user"
def test_update_password_not_applied_when_settings_are_rejected(self, admin_client, user_db):
"""A rejected settings payload leaves the password and fields untouched."""
user = user_db.create_user(username="alice", role="user", password_hash="old_hash")
resp = admin_client.put(
f"/api/admin/users/{user['id']}",
json={
"password": "newpass99",
"role": "admin",
"settings": {"BOOK_LANGUAGE": ["klingon"]},
},
)
assert resp.status_code == 400
assert resp.json["error"] == "Invalid settings payload"
updated = user_db.get_user(user_id=user["id"])
assert updated["password_hash"] == "old_hash"
assert updated["role"] == "user"
# ---------------------------------------------------------------------------
# POST /api/admin/users/sync-cwa