Files
shelfmark/tests/core/test_user_template_variable.py
T
Michael Joshua SaulandClaude Opus 4.6 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

85 lines
3.1 KiB
Python

"""Tests for {User} template variable in folder destination paths."""
from shelfmark.core.naming import KNOWN_TOKENS, parse_naming_template
class TestUserInKnownTokens:
"""User should be a recognized template token."""
def test_user_in_known_tokens(self):
assert "user" in KNOWN_TOKENS
def test_user_token_parsed(self):
result = parse_naming_template("{User}", {"User": "alice"})
assert result == "alice"
def test_user_token_case_insensitive(self):
result = parse_naming_template("{user}", {"User": "alice"})
assert result == "alice"
class TestUserTemplateSubstitution:
"""User variable should work in organize templates with path separators."""
def test_user_in_organize_template(self):
metadata = {"Author": "Author1", "Title": "Book1", "Year": "2024", "User": "alice"}
result = parse_naming_template("{User}/{Author}/{Title} ({Year})", metadata)
assert result == "alice/Author1/Book1 (2024)"
def test_user_empty_when_not_set(self):
metadata = {"Author": "Author1", "Title": "Book1", "User": None}
result = parse_naming_template("{User}/{Author}/{Title}", metadata)
# Empty user should be cleaned up, no leading slash
assert result == "Author1/Book1"
def test_user_with_prefix_suffix(self):
metadata = {"Author": "Author1", "Title": "Book1", "User": "bob"}
result = parse_naming_template("{User}/books/{Author}/{Title}", metadata)
assert result == "bob/books/Author1/Book1"
def test_user_sanitized(self):
metadata = {"User": "user:with*special", "Title": "Book1"}
result = parse_naming_template("{User}/{Title}", metadata)
# Special chars should be replaced with underscores
assert ":" not in result
assert "*" not in result
def test_user_missing_from_metadata(self):
metadata = {"Author": "Author1", "Title": "Book1"}
result = parse_naming_template("{User}/{Author}/{Title}", metadata)
assert result == "Author1/Book1"
class TestBuildMetadataWithUser:
"""build_metadata_dict should include User when task has user_id."""
def test_build_metadata_includes_user(self):
from shelfmark.core.models import DownloadTask
from shelfmark.download.postprocess.transfer import build_metadata_dict
task = DownloadTask(
task_id="test-1",
source="direct_download",
title="Book1",
author="Author1",
user_id=1,
username="alice",
)
metadata = build_metadata_dict(task)
assert metadata["User"] == "alice"
def test_build_metadata_user_none_when_no_user_id(self):
from shelfmark.core.models import DownloadTask
from shelfmark.download.postprocess.transfer import build_metadata_dict
task = DownloadTask(
task_id="test-2",
source="direct_download",
title="Book1",
author="Author1",
user_id=None,
username=None,
)
metadata = build_metadata_dict(task)
assert metadata.get("User") is None