Files
shelfmark/tests/config/test_oidc_settings.py
Michael Joshua Saul 2d2f54729f Add OIDC authentication and multi-user support (#606)
Closes #552

## Summary

Adds OIDC authentication and multi-user support to Shelfmark. Users can
now be managed individually with per-user download settings, while
maintaining full backwards compatibility with existing auth modes
(no-auth, builtin, proxy, CWA).

### Authentication
- **OIDC login** with PKCE, auto-discovery, group-based admin mapping
- **Password fallback** when OIDC is enabled (prevents admin lockout)
- **Auto-provisioning** of OIDC users (configurable on/off)
- **Email-based linking** of pre-created users to OIDC accounts
- **Lockout prevention** — requires a local admin before OIDC can be
enabled

### User Management
- **SQLite user database** (`users.db`) with admin CRUD API
- **Users management tab** in settings UI (admin-only)
- **Settings restricted to admins** in multi-user modes (builtin/OIDC) —
non-admin users cannot access settings
- Create, edit, and delete users with role assignment (admin/user)
- Password management for builtin auth users
- OIDC users shown with provider badge (password fields hidden)
- Per-user configurable settings:
  - **Download destination** — custom folder path per user
- **BookLore library & path** — dropdown select, each user's books go to
their own library
  - **Email recipients** — per-user email delivery targets
- **`{User}` template variable** — use in destination paths (e.g.,
`/books/{User}/`)
- Settings override model: per-user values override globals, empty/unset
falls back to global defaults

### Download Scoping
- **Per-user download visibility** — non-admins only see their own
downloads
- **Username display** in downloads sidebar (shows who requested each
download)
- **WebSocket room-based filtering** — admins see all, users see only
their own
- **Download progress scoping** — progress events routed to correct user
rooms

### BookLore Integration
- **Dynamic dropdown selects** for library/path (replaces text inputs)
- **Per-user library/path overrides** via user settings
- **Options cache refresh** after Test Connection

### Security
- SQL injection prevention (column whitelist on user updates)
- Generic OIDC error messages (no internal detail leakage)
- Admin self-deletion and last-local-admin deletion guards
- OIDC role overwrite fix (only updates role when admin_group is
configured)

## Migration

**No migration script needed.** The `users.db` is created automatically
on first startup. Existing builtin auth users are auto-migrated to the
database on their first login. All other auth modes (no-auth, proxy,
CWA) continue working unchanged.

## Test Plan

- [x] All 519 tests passing, 0 failures
- [ ] Test no-auth mode: settings accessible, downloads work without
login
- [ ] Test builtin auth: legacy credentials auto-migrate on login, new
users can be created
- [ ] Test OIDC auth: login flow, callback, auto-provisioning,
group-based admin
- [ ] Test CWA auth: unchanged behavior
- [ ] Test proxy auth: unchanged behavior
- [ ] Test per-user downloads: non-admin sees only own downloads
- [ ] Test BookLore dropdowns: library/path selection, per-user
overrides
- [ ] Test Docker build: no Dockerfile changes needed

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 17:44:27 +00:00

161 lines
5.4 KiB
Python

"""
Tests for OIDC settings fields in security configuration.
Tests that OIDC fields are registered correctly with proper
show_when conditions, defaults, and field types.
"""
from shelfmark.core.settings_registry import (
TextField,
PasswordField,
CheckboxField,
TagListField,
)
def _reload_security_module():
"""Reload security module to pick up patched values."""
import importlib
import shelfmark.config.security
importlib.reload(shelfmark.config.security)
return shelfmark.config.security.security_settings()
def _get_field(fields, key):
"""Find a field by key."""
return next((f for f in fields if f.key == key), None)
class TestOIDCAuthMethodOption:
"""Tests that OIDC appears as an auth method option."""
def test_oidc_option_available(self):
fields = _reload_security_module()
auth_field = _get_field(fields, "AUTH_METHOD")
option_values = [opt["value"] for opt in auth_field.options]
assert "oidc" in option_values
def test_oidc_option_label(self):
fields = _reload_security_module()
auth_field = _get_field(fields, "AUTH_METHOD")
oidc_option = next(o for o in auth_field.options if o["value"] == "oidc")
assert "OIDC" in oidc_option["label"]
class TestOIDCFieldsPresent:
"""Tests that all OIDC configuration fields are registered."""
def test_discovery_url_field_exists(self):
fields = _reload_security_module()
field = _get_field(fields, "OIDC_DISCOVERY_URL")
assert field is not None
assert isinstance(field, TextField)
def test_client_id_field_exists(self):
fields = _reload_security_module()
field = _get_field(fields, "OIDC_CLIENT_ID")
assert field is not None
assert isinstance(field, TextField)
def test_client_secret_field_exists(self):
fields = _reload_security_module()
field = _get_field(fields, "OIDC_CLIENT_SECRET")
assert field is not None
assert isinstance(field, PasswordField)
def test_scopes_field_exists(self):
fields = _reload_security_module()
field = _get_field(fields, "OIDC_SCOPES")
assert field is not None
assert isinstance(field, TagListField)
def test_scopes_default_includes_essentials(self):
fields = _reload_security_module()
field = _get_field(fields, "OIDC_SCOPES")
assert "openid" in field.default
assert "email" in field.default
assert "profile" in field.default
def test_use_admin_group_field_exists(self):
fields = _reload_security_module()
field = _get_field(fields, "OIDC_USE_ADMIN_GROUP")
assert field is not None
assert isinstance(field, CheckboxField)
assert field.default is True
def test_group_claim_field_exists(self):
fields = _reload_security_module()
field = _get_field(fields, "OIDC_GROUP_CLAIM")
assert field is not None
assert field.default == "groups"
def test_admin_group_field_exists(self):
fields = _reload_security_module()
field = _get_field(fields, "OIDC_ADMIN_GROUP")
assert field is not None
def test_auto_provision_field_exists(self):
fields = _reload_security_module()
field = _get_field(fields, "OIDC_AUTO_PROVISION")
assert field is not None
assert isinstance(field, CheckboxField)
assert field.default is True
def test_test_connection_button_exists(self):
fields = _reload_security_module()
field = _get_field(fields, "test_oidc")
assert field is not None
class TestOIDCFieldShowWhen:
"""Tests that OIDC fields are conditionally shown."""
def test_oidc_fields_show_when_oidc_selected(self):
fields = _reload_security_module()
oidc_keys = [
"OIDC_DISCOVERY_URL",
"OIDC_CLIENT_ID",
"OIDC_CLIENT_SECRET",
"OIDC_SCOPES",
"OIDC_USE_ADMIN_GROUP",
"OIDC_AUTO_PROVISION",
"OIDC_GROUP_CLAIM",
"OIDC_ADMIN_GROUP",
]
for key in oidc_keys:
field = _get_field(fields, key)
assert field is not None, f"Field {key} not found"
show_when = field.show_when
# show_when can be a dict or list of dicts
if isinstance(show_when, list):
conditions = show_when
else:
conditions = [show_when]
# At least one condition should reference AUTH_METHOD=oidc
has_oidc_condition = any(
c.get("field") == "AUTH_METHOD" and c.get("value") == "oidc"
for c in conditions
)
assert has_oidc_condition, f"Field {key} missing AUTH_METHOD=oidc show_when"
class TestOIDCFieldsEnvSupport:
"""Tests that OIDC fields are UI-only (no env var support)."""
def test_oidc_fields_not_env_supported(self):
fields = _reload_security_module()
oidc_keys = [
"OIDC_DISCOVERY_URL",
"OIDC_CLIENT_ID",
"OIDC_CLIENT_SECRET",
"OIDC_SCOPES",
"OIDC_USE_ADMIN_GROUP",
"OIDC_GROUP_CLAIM",
"OIDC_ADMIN_GROUP",
"OIDC_AUTO_PROVISION",
]
for key in oidc_keys:
field = _get_field(fields, key)
assert field is not None, f"Field {key} not found"
assert field.env_supported is False, f"Field {key} should not support env vars"