Files
shelfmark/tests/core/test_auth_api.py
T
splitsec2andCaliBrain 72b5d04939 perf: cut repeated work on the status and activity paths (#1545)
Two small cuts to work that repeats on every poll or request.

The delivery-state sync behind /api/status and the activity snapshot
listed every fulfilled request (SELECT * plus a JSON parse of book_data
and release_data per row) before it looked at the queue. That history is
never purged, so an idle instance paid for it on every poll. It now
returns straight away when no queue bucket holds a task, since nothing
could map to a request then. Results and emitted updates are unchanged.
The activity snapshot gains less from this, because its status merges
download history rows, so it is usually not empty.

In proxy auth mode the before-request hook looked the user up by name
and by session id on every API call, cover images included, each on a
new SQLite connection. UserDB.get_user_cached answers repeat lookups
from memory for up to five seconds, bounded at 256 entries. create_user,
update_user and delete_user drop the cache as soon as they commit, and a
lookup that overlaps a write is not stored, so a role change or deletion
made in the app applies on the next request. Changes made outside that
UserDB instance (another process, or the separate instances some helpers
open) show within the five seconds. Only the middleware uses the cached
path; every other get_user caller is unchanged.

Each change has a test that failed before it: one counts list_requests
calls for an empty queue, the others count get_user calls across
repeated lookups and middleware runs, check expiry with a patched clock,
and check that writes and caller mutations do not leave a stale row
behind. The full suite passes (4432 passed, 48 skipped, e2e excluded),
as do ruff and basedpyright on the changed files.

I also looked at throttling the full status_update broadcast when only
the progress message changes during torrent and usenet polling, and left
it out. Those handlers poll every 2 seconds and
DOWNLOAD_PROGRESS_UPDATE_INTERVAL defaults to 1 second, so a throttle on
that interval would skip nothing at the default setting. A longer window
would slow the speed and ETA text in the UI, and without a trailing
flush it would hide the last message of a quick burst, such as "Waiting
for bot response" arriving just after "Requesting file from bot", for
the whole wait that follows.

---------

Co-authored-by: CaliBrain <calibrain@l4n.xyz>
2026-10-22 20:27:07 -04:00

350 lines
14 KiB
Python

"""Focused auth API regression tests for lockout handling."""
from __future__ import annotations
import importlib
from datetime import datetime
from unittest.mock import patch
import pytest
from werkzeug.security import generate_password_hash
@pytest.fixture(scope="module")
def main_module():
"""Import `shelfmark.main` with background startup disabled."""
with patch("shelfmark.download.orchestrator.start"):
import shelfmark.main as main
importlib.reload(main)
return main
@pytest.fixture
def client(main_module):
main_module.failed_login_attempts.clear()
try:
yield main_module.app.test_client()
finally:
main_module.failed_login_attempts.clear()
@pytest.fixture
def temp_user_db(tmp_path):
from shelfmark.core.user_db import UserDB
db = UserDB(str(tmp_path / "users.db"))
db.initialize()
return db
class TestLoginSemantics:
def test_login_rejects_missing_payload(self, main_module, client):
response = client.post("/api/auth/login")
assert response.status_code == 400
assert response.get_json()["error"] == "No data provided"
def test_login_in_none_mode_sets_session_without_db_user(self, main_module, client):
with patch.object(main_module, "get_auth_mode", return_value="none"):
response = client.post(
"/api/auth/login",
json={"username": "guest", "password": "ignored", "remember_me": True},
)
assert response.status_code == 200
assert response.get_json() == {"success": True}
with client.session_transaction() as sess:
assert sess["user_id"] == "guest"
assert "db_user_id" not in sess
assert sess.permanent is True
def test_login_builtin_success_sets_session_and_admin_flag(
self, main_module, client, temp_user_db, monkeypatch
):
monkeypatch.setattr(main_module, "user_db", temp_user_db)
user = temp_user_db.create_user(
username="alice",
password_hash=generate_password_hash("secret"),
display_name="Alice Example",
role="admin",
)
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
response = client.post(
"/api/auth/login",
json={"username": "alice", "password": "secret", "remember_me": False},
)
assert response.status_code == 200
assert response.get_json() == {"success": True}
with client.session_transaction() as sess:
assert sess["user_id"] == "alice"
assert sess["db_user_id"] == user["id"]
assert sess["is_admin"] is True
assert sess.permanent is False
assert "alice" not in main_module.failed_login_attempts
def test_login_builtin_rejects_wrong_password_and_tracks_failure(
self, main_module, client, temp_user_db, monkeypatch
):
monkeypatch.setattr(main_module, "user_db", temp_user_db)
temp_user_db.create_user(
username="alice",
password_hash=generate_password_hash("secret"),
role="user",
)
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
response = client.post(
"/api/auth/login",
json={"username": "alice", "password": "wrong", "remember_me": False},
)
assert response.status_code == 401
assert response.get_json()["error"] == "Invalid username or password."
assert main_module.failed_login_attempts["alice"]["count"] == 1
def test_login_rejects_proxy_mode(self, main_module, client):
with patch.object(main_module, "get_auth_mode", return_value="proxy"):
response = client.post(
"/api/auth/login",
json={"username": "alice", "password": "secret", "remember_me": False},
)
assert response.status_code == 401
assert response.get_json()["error"] == "Proxy authentication is enabled"
def test_login_rejects_oidc_when_local_auth_is_hidden(self, main_module, client):
with patch.object(main_module, "get_auth_mode", return_value="oidc"):
with patch.object(main_module, "HIDE_LOCAL_AUTH", True):
response = client.post(
"/api/auth/login",
json={"username": "alice", "password": "secret", "remember_me": False},
)
assert response.status_code == 403
assert response.get_json()["error"] == "Local authentication is disabled"
@pytest.mark.parametrize("auth_mode", ["builtin", "oidc"])
def test_login_rejects_password_auth_when_local_auth_is_disabled(
self, main_module, client, auth_mode
):
with patch.object(main_module, "get_auth_mode", return_value=auth_mode):
with patch.object(main_module, "DISABLE_LOCAL_AUTH", True):
response = client.post(
"/api/auth/login",
json={"username": "alice", "password": "wrong", "remember_me": False},
)
assert response.status_code == 403
assert response.get_json()["error"] == "Local authentication is disabled"
assert main_module.failed_login_attempts == {}
def test_auth_check_none_mode_reports_full_access(self, main_module, client):
with patch.object(main_module, "get_auth_mode", return_value="none"):
response = client.get("/api/auth/check")
assert response.status_code == 200
assert response.get_json() == {
"authenticated": True,
"auth_required": False,
"auth_mode": "none",
"is_admin": True,
}
@pytest.mark.parametrize("auth_mode", ["builtin", "oidc"])
def test_auth_check_hides_local_auth_when_disabled(self, main_module, client, auth_mode):
with patch.object(main_module, "get_auth_mode", return_value=auth_mode):
with patch.object(main_module, "DISABLE_LOCAL_AUTH", True):
response = client.get("/api/auth/check")
assert response.status_code == 200
body = response.get_json()
assert body["auth_mode"] == auth_mode
assert body["auth_required"] is True
assert body["authenticated"] is False
assert body["hide_local_auth"] is True
def test_auth_check_includes_display_name_for_authenticated_user(
self, main_module, client, temp_user_db, monkeypatch
):
monkeypatch.setattr(main_module, "user_db", temp_user_db)
user = temp_user_db.create_user(
username="alice",
password_hash=generate_password_hash("secret"),
display_name="Alice Example",
role="admin",
)
with client.session_transaction() as sess:
sess["user_id"] = "alice"
sess["db_user_id"] = user["id"]
sess["is_admin"] = True
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
response = client.get("/api/auth/check")
assert response.status_code == 200
body = response.get_json()
assert body["authenticated"] is True
assert body["auth_required"] is True
assert body["auth_mode"] == "builtin"
assert body["is_admin"] is True
assert body["username"] == "alice"
assert body["display_name"] == "Alice Example"
def test_logout_proxy_includes_logout_url_and_clears_session(self, main_module, client):
with client.session_transaction() as sess:
sess["user_id"] = "alice"
sess["db_user_id"] = 1
sess["is_admin"] = True
with patch.object(main_module, "get_auth_mode", return_value="proxy"):
with patch.object(
main_module.app_config,
"get",
side_effect=lambda key, default=None, user_id=None: {
"PROXY_AUTH_LOGOUT_URL": "https://auth.example.com/logout",
}.get(key, default),
):
response = client.post("/api/auth/logout")
assert response.status_code == 200
assert response.get_json() == {
"success": True,
"logout_url": "https://auth.example.com/logout",
}
with client.session_transaction() as sess:
assert "user_id" not in sess
assert "db_user_id" not in sess
assert "is_admin" not in sess
class TestProxyProvisioningRole:
def _check(self, main_module, username, settings=None):
values = {"PROXY_AUTH_USER_HEADER": "X-Auth-User", **(settings or {})}
fresh_client = main_module.app.test_client()
with (
patch.object(main_module, "get_auth_mode", return_value="proxy"),
patch.object(
main_module.app_config,
"get",
side_effect=lambda key, default=None, user_id=None: values.get(key, default),
),
):
response = fresh_client.get("/api/auth/check", headers={"X-Auth-User": username})
assert response.status_code == 200
return response.get_json()
def test_first_proxy_user_is_admin_and_later_users_are_not(
self, main_module, temp_user_db, monkeypatch
):
monkeypatch.setattr(main_module, "user_db", temp_user_db)
first = self._check(main_module, "alice")
second = self._check(main_module, "bob")
assert first["is_admin"] is True
assert second["is_admin"] is False
assert temp_user_db.get_user(username="alice")["role"] == "admin"
assert temp_user_db.get_user(username="bob")["role"] == "user"
def test_default_role_admin_restores_admin_for_everyone(
self, main_module, temp_user_db, monkeypatch
):
monkeypatch.setattr(main_module, "user_db", temp_user_db)
settings = {"PROXY_AUTH_DEFAULT_ROLE": "admin"}
self._check(main_module, "alice", settings)
second = self._check(main_module, "bob", settings)
assert second["is_admin"] is True
assert temp_user_db.get_user(username="bob")["role"] == "admin"
def test_existing_admin_from_another_auth_source_counts_as_the_first_admin(
self, main_module, temp_user_db, monkeypatch
):
monkeypatch.setattr(main_module, "user_db", temp_user_db)
temp_user_db.create_user(username="local_admin", role="admin", auth_source="builtin")
first_proxy_user = self._check(main_module, "alice")
assert first_proxy_user["is_admin"] is False
assert temp_user_db.get_user(username="alice")["role"] == "user"
def test_known_user_keeps_their_role_whatever_the_default(
self, main_module, temp_user_db, monkeypatch
):
monkeypatch.setattr(main_module, "user_db", temp_user_db)
temp_user_db.create_user(username="ops", role="admin", auth_source="proxy")
temp_user_db.create_user(username="bob", role="user", auth_source="proxy")
bob = self._check(main_module, "bob", {"PROXY_AUTH_DEFAULT_ROLE": "admin"})
assert bob["is_admin"] is False
assert temp_user_db.get_user(username="bob")["role"] == "user"
def test_repeat_requests_reuse_the_user_lookups_until_the_user_changes(
self, main_module, temp_user_db, monkeypatch
):
monkeypatch.setattr(main_module, "user_db", temp_user_db)
temp_user_db.create_user(username="ops", role="admin", auth_source="proxy")
bob = temp_user_db.create_user(username="bob", role="user", auth_source="proxy")
calls = []
real_get_user = temp_user_db.get_user
def counting_get_user(**kwargs):
calls.append(kwargs)
return real_get_user(**kwargs)
monkeypatch.setattr(temp_user_db, "get_user", counting_get_user)
def run_middleware():
with (
patch.object(main_module, "get_auth_mode", return_value="proxy"),
main_module.app.test_request_context(
"/api/covers/abc", headers={"X-Auth-User": "bob"}
),
):
main_module.session["user_id"] = "bob"
main_module.session["db_user_id"] = bob["id"]
assert main_module.proxy_auth_middleware() is None
return main_module.session["is_admin"]
assert run_middleware() is False
lookups_per_request = len(calls)
assert lookups_per_request == 2
assert run_middleware() is False
assert len(calls) == lookups_per_request
temp_user_db.update_user(bob["id"], role="admin")
assert run_middleware() is True
class TestLoginLockoutRepair:
def test_is_account_locked_repairs_missing_timestamp(self, main_module):
main_module.failed_login_attempts.clear()
main_module.failed_login_attempts["locked-user"] = {"count": main_module.MAX_LOGIN_ATTEMPTS}
assert main_module.is_account_locked("locked-user") is True
assert isinstance(
main_module.failed_login_attempts["locked-user"].get("lockout_until"), datetime
)
def test_login_keeps_account_locked_when_timestamp_is_missing(self, main_module, client):
main_module.failed_login_attempts["locked-user"] = {"count": main_module.MAX_LOGIN_ATTEMPTS}
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
response = client.post(
"/api/auth/login",
json={"username": "locked-user", "password": "secret", "remember_me": False},
)
assert response.status_code == 429
assert "Account temporarily locked" in response.get_json()["error"]
assert isinstance(
main_module.failed_login_attempts["locked-user"].get("lockout_until"), datetime
)