Files
shelfmark/tests/core/test_oidc_routes.py
T
Alex 5bed0b20f4 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.
2026-02-12 14:38:28 +00:00

260 lines
9.6 KiB
Python

"""Tests for OIDC Flask route handlers using Authlib transport."""
import os
import tempfile
from unittest.mock import Mock, patch
import pytest
from flask import Flask, redirect
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
MOCK_OIDC_CONFIG = {
"AUTH_METHOD": "oidc",
"OIDC_DISCOVERY_URL": "https://auth.example.com/.well-known/openid-configuration",
"OIDC_CLIENT_ID": "shelfmark",
"OIDC_CLIENT_SECRET": "secret123",
"OIDC_SCOPES": ["openid", "email", "profile", "groups"],
"OIDC_GROUP_CLAIM": "groups",
"OIDC_ADMIN_GROUP": "shelfmark-admins",
"OIDC_AUTO_PROVISION": True,
"OIDC_USE_ADMIN_GROUP": True,
}
@pytest.fixture
def app(user_db):
from shelfmark.core.oidc_routes import register_oidc_routes
test_app = Flask(__name__)
test_app.config["SECRET_KEY"] = "test-secret"
test_app.config["TESTING"] = True
register_oidc_routes(test_app, user_db)
return test_app
@pytest.fixture
def client(app):
return app.test_client()
class TestOIDCClientRegistration:
@patch("shelfmark.core.oidc_routes.load_config_file", return_value=MOCK_OIDC_CONFIG)
@patch("shelfmark.core.oidc_routes.oauth.create_client")
@patch("shelfmark.core.oidc_routes.oauth.register")
def test_registers_client_with_pkce_and_expected_scopes(
self, mock_register, mock_create_client, _mock_config
):
from shelfmark.core.oidc_routes import _get_oidc_client
fake_client = Mock()
mock_create_client.return_value = fake_client
client_obj, config = _get_oidc_client()
assert client_obj is fake_client
assert config["OIDC_CLIENT_ID"] == "shelfmark"
kwargs = mock_register.call_args.kwargs
assert kwargs["name"] == "shelfmark_idp"
assert kwargs["server_metadata_url"] == MOCK_OIDC_CONFIG["OIDC_DISCOVERY_URL"]
assert kwargs["overwrite"] is True
assert kwargs["client_kwargs"]["code_challenge_method"] == "S256"
scope_str = kwargs["client_kwargs"]["scope"]
assert "openid" in scope_str
assert "email" in scope_str
assert "profile" in scope_str
assert "groups" in scope_str
@patch("shelfmark.core.oidc_routes.load_config_file")
@patch("shelfmark.core.oidc_routes.oauth.create_client")
@patch("shelfmark.core.oidc_routes.oauth.register")
def test_does_not_append_group_claim_when_admin_group_auth_disabled(
self, mock_register, mock_create_client, mock_config
):
from shelfmark.core.oidc_routes import _get_oidc_client
config = {
**MOCK_OIDC_CONFIG,
"OIDC_SCOPES": ["openid", "email", "profile"],
"OIDC_USE_ADMIN_GROUP": False,
"OIDC_GROUP_CLAIM": "groups",
}
mock_config.return_value = config
mock_create_client.return_value = Mock()
_get_oidc_client()
scope_str = mock_register.call_args.kwargs["client_kwargs"]["scope"]
assert "groups" not in scope_str
class TestOIDCLoginEndpoint:
@patch("shelfmark.core.oidc_routes._get_oidc_client")
def test_login_redirects_to_provider(self, mock_get_client, client):
fake_client = Mock()
fake_client.authorize_redirect.return_value = redirect("https://auth.example.com/authorize")
mock_get_client.return_value = (fake_client, MOCK_OIDC_CONFIG)
resp = client.get("/api/auth/oidc/login")
assert resp.status_code == 302
assert resp.headers["Location"].startswith("https://auth.example.com/authorize")
fake_client.authorize_redirect.assert_called_once()
redirect_uri = fake_client.authorize_redirect.call_args.args[0]
assert redirect_uri.endswith("/api/auth/oidc/callback")
@patch("shelfmark.core.oidc_routes._get_oidc_client", side_effect=ValueError("OIDC not configured"))
def test_login_returns_500_when_not_configured(self, _mock_get_client, client):
resp = client.get("/api/auth/oidc/login")
assert resp.status_code == 500
assert resp.get_json()["error"] == "OIDC not configured"
class TestOIDCCallbackEndpoint:
@patch("shelfmark.core.oidc_routes._get_oidc_client")
def test_callback_creates_session(self, mock_get_client, client):
fake_client = Mock()
fake_client.authorize_access_token.return_value = {
"userinfo": {
"sub": "user-123",
"email": "john@example.com",
"name": "John Doe",
"preferred_username": "john",
"groups": ["users"],
}
}
mock_get_client.return_value = (fake_client, MOCK_OIDC_CONFIG)
resp = client.get("/api/auth/oidc/callback?code=abc123&state=test-state")
assert resp.status_code == 302
with client.session_transaction() as sess:
assert sess["user_id"] == "john"
assert sess["db_user_id"] is not None
@patch("shelfmark.core.oidc_routes._get_oidc_client")
def test_callback_sets_admin_from_groups(self, mock_get_client, client):
fake_client = Mock()
fake_client.authorize_access_token.return_value = {
"userinfo": {
"sub": "admin-123",
"email": "admin@example.com",
"preferred_username": "admin",
"groups": ["users", "shelfmark-admins"],
}
}
mock_get_client.return_value = (fake_client, MOCK_OIDC_CONFIG)
client.get("/api/auth/oidc/callback?code=abc123&state=test-state")
with client.session_transaction() as sess:
assert sess["is_admin"] is True
@patch("shelfmark.core.oidc_routes._get_oidc_client")
def test_callback_falls_back_to_userinfo_endpoint(self, mock_get_client, client):
fake_client = Mock()
fake_client.authorize_access_token.return_value = {}
fake_client.userinfo.return_value = {
"sub": "fallback-123",
"email": "fallback@example.com",
"preferred_username": "fallback",
"groups": [],
}
mock_get_client.return_value = (fake_client, MOCK_OIDC_CONFIG)
resp = client.get("/api/auth/oidc/callback?code=abc123&state=test-state")
assert resp.status_code == 302
@patch("shelfmark.core.oidc_routes._get_oidc_client")
def test_callback_returns_400_when_claims_missing(self, mock_get_client, client):
fake_client = Mock()
fake_client.authorize_access_token.return_value = {}
fake_client.userinfo.side_effect = RuntimeError("userinfo failed")
mock_get_client.return_value = (fake_client, MOCK_OIDC_CONFIG)
resp = client.get("/api/auth/oidc/callback?code=abc123&state=test-state")
assert resp.status_code == 400
assert "missing user claims" in resp.get_json()["error"]
@patch("shelfmark.core.oidc_routes._get_oidc_client")
def test_callback_rejects_when_auto_provision_disabled(self, mock_get_client, client):
config = {**MOCK_OIDC_CONFIG, "OIDC_AUTO_PROVISION": False}
fake_client = Mock()
fake_client.authorize_access_token.return_value = {
"userinfo": {
"sub": "unknown-user",
"email": "unknown@example.com",
"preferred_username": "unknown",
"groups": [],
}
}
mock_get_client.return_value = (fake_client, config)
resp = client.get("/api/auth/oidc/callback?code=abc123&state=test-state")
assert resp.status_code == 403
@patch("shelfmark.core.oidc_routes._get_oidc_client")
def test_callback_allows_pre_created_user_by_verified_email_when_no_provision(
self, mock_get_client, client, user_db
):
config = {**MOCK_OIDC_CONFIG, "OIDC_AUTO_PROVISION": False}
user_db.create_user(username="alice", email="alice@example.com", password_hash="hash")
fake_client = Mock()
fake_client.authorize_access_token.return_value = {
"userinfo": {
"sub": "oidc-alice-sub",
"email": "alice@example.com",
"email_verified": True,
"preferred_username": "alice_oidc",
"groups": [],
}
}
mock_get_client.return_value = (fake_client, config)
resp = client.get("/api/auth/oidc/callback?code=abc123&state=test-state")
assert resp.status_code == 302
with client.session_transaction() as sess:
assert sess["user_id"] == "alice"
assert sess.get("db_user_id") is not None
@patch("shelfmark.core.oidc_routes._get_oidc_client")
def test_callback_does_not_link_unverified_email_when_no_provision(
self, mock_get_client, client, user_db
):
config = {**MOCK_OIDC_CONFIG, "OIDC_AUTO_PROVISION": False}
user = user_db.create_user(username="bob", email="bob@example.com", password_hash="hash")
fake_client = Mock()
fake_client.authorize_access_token.return_value = {
"userinfo": {
"sub": "oidc-bob-sub",
"email": "bob@example.com",
"email_verified": False,
"preferred_username": "bob_oidc",
"groups": [],
}
}
mock_get_client.return_value = (fake_client, config)
resp = client.get("/api/auth/oidc/callback?code=abc123&state=test-state")
assert resp.status_code == 403
updated_user = user_db.get_user(user_id=user["id"])
assert updated_user["oidc_subject"] is None