mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-10-06 07:54:38 +01:00
Patch: Multi-user and OIDC polish (#612)
- Moved backend OIDC functionality to external library Authlib to help maintainability - Separated User settings UI into individual components, allowing for standard settings UI decorator components to be used. - Added full support for reverse proxy and CWA users alongside local and OIDC - Added mapping and syncing functionality for OIDC, CWA and reverse proxy users - Added per-user settings into the app-wide config system. Each config can be declared as user-overrideable, and app-wide functionality can now receive user-specific options via standard config calls. - Added per-user audiobook destination config - Updated login modal UI for simplified login, plus custom labels for OIDC login - Added user visibility in header dropdown - Unified "restrict settings to admin" to use app-wide user roles.
This commit is contained in:
@@ -7,6 +7,7 @@ request contexts. They do not require the full application stack.
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import sqlite3
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Tuple
|
||||
from unittest.mock import Mock, patch
|
||||
@@ -42,13 +43,18 @@ class TestGetAuthMode:
|
||||
def test_get_auth_mode_builtin(self, main_module):
|
||||
with patch(
|
||||
"shelfmark.core.settings_registry.load_config_file",
|
||||
return_value={
|
||||
"AUTH_METHOD": "builtin",
|
||||
"BUILTIN_USERNAME": "admin",
|
||||
"BUILTIN_PASSWORD_HASH": "hashed_password",
|
||||
},
|
||||
return_value={"AUTH_METHOD": "builtin"},
|
||||
):
|
||||
assert main_module.get_auth_mode() == "builtin"
|
||||
with patch.object(main_module, "has_local_password_admin", return_value=True):
|
||||
assert main_module.get_auth_mode() == "builtin"
|
||||
|
||||
def test_get_auth_mode_builtin_without_local_admin_falls_back_to_none(self, main_module):
|
||||
with patch(
|
||||
"shelfmark.core.settings_registry.load_config_file",
|
||||
return_value={"AUTH_METHOD": "builtin"},
|
||||
):
|
||||
with patch.object(main_module, "has_local_password_admin", return_value=False):
|
||||
assert main_module.get_auth_mode() == "none"
|
||||
|
||||
def test_get_auth_mode_proxy(self, main_module):
|
||||
with patch(
|
||||
@@ -102,6 +108,7 @@ class TestAuthCheckEndpoint:
|
||||
with patch("shelfmark.core.settings_registry.load_config_file", return_value={}):
|
||||
with main_module.app.test_request_context("/api/auth/check"):
|
||||
main_module.session["user_id"] = "admin"
|
||||
main_module.session["is_admin"] = True
|
||||
resp = _as_response(main_module.api_auth_check())
|
||||
data = resp.get_json()
|
||||
|
||||
@@ -118,7 +125,6 @@ class TestAuthCheckEndpoint:
|
||||
"shelfmark.core.settings_registry.load_config_file",
|
||||
return_value={
|
||||
"PROXY_AUTH_USER_HEADER": "X-Auth-User",
|
||||
"PROXY_AUTH_RESTRICT_SETTINGS_TO_ADMIN": True,
|
||||
"PROXY_AUTH_LOGOUT_URL": "https://auth.example.com/logout",
|
||||
},
|
||||
):
|
||||
@@ -166,15 +172,16 @@ class TestLoginEndpoint:
|
||||
assert data.get("success") is True
|
||||
|
||||
def test_login_builtin_success(self, main_module):
|
||||
mock_user_db = Mock()
|
||||
mock_user_db.get_user.return_value = {
|
||||
"id": 1,
|
||||
"username": "admin",
|
||||
"password_hash": "hash",
|
||||
"role": "admin",
|
||||
}
|
||||
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
|
||||
with patch.object(main_module, "is_account_locked", return_value=False):
|
||||
with patch(
|
||||
"shelfmark.core.settings_registry.load_config_file",
|
||||
return_value={
|
||||
"BUILTIN_USERNAME": "admin",
|
||||
"BUILTIN_PASSWORD_HASH": "hash",
|
||||
},
|
||||
):
|
||||
with patch.object(main_module, "user_db", mock_user_db):
|
||||
with patch.object(main_module, "check_password_hash", return_value=True):
|
||||
with main_module.app.test_request_context(
|
||||
"/api/auth/login",
|
||||
@@ -188,6 +195,94 @@ class TestLoginEndpoint:
|
||||
assert resp.status_code == 200
|
||||
assert data.get("success") is True
|
||||
|
||||
def test_login_cwa_provisions_db_user(self, main_module, tmp_path):
|
||||
cwa_db_path = tmp_path / "app.db"
|
||||
username = "cwa_test_user"
|
||||
|
||||
conn = sqlite3.connect(cwa_db_path)
|
||||
conn.execute(
|
||||
"CREATE TABLE user (name TEXT PRIMARY KEY, password TEXT, role INTEGER, email TEXT)"
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO user (name, password, role, email) VALUES (?, ?, ?, ?)",
|
||||
(username, "hashed_password", 1, "cwa@example.com"),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
with patch.object(main_module, "get_auth_mode", return_value="cwa"):
|
||||
with patch.object(main_module, "is_account_locked", return_value=False):
|
||||
with patch.object(main_module, "CWA_DB_PATH", cwa_db_path):
|
||||
with patch.object(main_module, "check_password_hash", return_value=True):
|
||||
with main_module.app.test_request_context(
|
||||
"/api/auth/login",
|
||||
method="POST",
|
||||
json={"username": username, "password": "correct", "remember_me": False},
|
||||
):
|
||||
resp = _as_response(main_module.api_login())
|
||||
data = resp.get_json()
|
||||
assert main_module.session.get("user_id") == username
|
||||
assert main_module.session.get("is_admin") is True
|
||||
assert main_module.session.get("db_user_id") is not None
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert data.get("success") is True
|
||||
db_user = main_module.user_db.get_user(username=username)
|
||||
assert db_user["email"] == "cwa@example.com"
|
||||
assert db_user["role"] == "admin"
|
||||
assert db_user["auth_source"] == "cwa"
|
||||
|
||||
def test_login_cwa_avoids_overwriting_local_username_collision(self, main_module, tmp_path):
|
||||
cwa_db_path = tmp_path / "app.db"
|
||||
username = "collision_admin"
|
||||
external_email = "collision.cwa@example.com"
|
||||
|
||||
local_user = main_module.user_db.create_user(
|
||||
username=username,
|
||||
email="collision.local@example.com",
|
||||
role="admin",
|
||||
auth_source="builtin",
|
||||
)
|
||||
|
||||
conn = sqlite3.connect(cwa_db_path)
|
||||
conn.execute(
|
||||
"CREATE TABLE user (name TEXT PRIMARY KEY, password TEXT, role INTEGER, email TEXT)"
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO user (name, password, role, email) VALUES (?, ?, ?, ?)",
|
||||
(username, "hashed_password", 1, external_email),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
with patch.object(main_module, "get_auth_mode", return_value="cwa"):
|
||||
with patch.object(main_module, "is_account_locked", return_value=False):
|
||||
with patch.object(main_module, "CWA_DB_PATH", cwa_db_path):
|
||||
with patch.object(main_module, "check_password_hash", return_value=True):
|
||||
with main_module.app.test_request_context(
|
||||
"/api/auth/login",
|
||||
method="POST",
|
||||
json={"username": username, "password": "correct", "remember_me": False},
|
||||
):
|
||||
resp = _as_response(main_module.api_login())
|
||||
data = resp.get_json()
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert data.get("success") is True
|
||||
assert main_module.session.get("user_id") == username
|
||||
assert main_module.session.get("db_user_id") is not None
|
||||
|
||||
local_after = main_module.user_db.get_user(user_id=local_user["id"])
|
||||
assert local_after is not None
|
||||
assert local_after["auth_source"] == "builtin"
|
||||
assert local_after["email"] == "collision.local@example.com"
|
||||
|
||||
provisioned_cwa_user = next(
|
||||
user for user in main_module.user_db.list_users()
|
||||
if user.get("auth_source") == "cwa" and user.get("email") == external_email
|
||||
)
|
||||
assert provisioned_cwa_user["username"].startswith(f"{username}__cwa")
|
||||
|
||||
|
||||
class TestLogoutEndpoint:
|
||||
def test_logout_proxy_returns_logout_url(self, main_module):
|
||||
|
||||
Reference in New Issue
Block a user