Files
shelfmark/tests/config/test_security.py
Tag Howard 0d7a12ca7c Feature: Reverse proxy authentication (#455)
- Changes the auth settings to support more than two auth types
- Added a proxy auth type with settings for user and optionally group
headers
- Added a global middleware `proxy_auth_middleware` to handle proxy auth
(it does nothing if any other auth mode is set)
- Added support for proxy auth to `get_auth_mode`, `login_required`,
`api_login/out`, and `api_auth_check`
- Added a backend check to make protect the API for settings when admin
is required

---------

Co-authored-by: Joshua Tag Howard <git@jthoward.dev>
Co-authored-by: Alex <alex.bilbie1@gmail.com>
2026-01-15 13:27:50 +00:00

367 lines
16 KiB
Python

"""
Tests for security configuration and migration.
Tests the security settings registration, migration from old settings,
and proxy authentication configuration.
"""
import json
import os
import tempfile
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
@pytest.fixture
def temp_config_dir():
"""Create a temporary config directory for tests."""
with tempfile.TemporaryDirectory() as tmpdir:
config_dir = Path(tmpdir)
security_dir = config_dir / "security"
security_dir.mkdir(parents=True, exist_ok=True)
yield security_dir
@pytest.fixture
def mock_logger():
"""Mock logger to capture log messages."""
return MagicMock()
class TestSecurityMigration:
"""Tests for migrating legacy security settings."""
def test_migrate_use_cwa_auth_true(self, temp_config_dir, mock_logger):
"""Test migrating USE_CWA_AUTH=True to AUTH_METHOD='cwa'."""
# Create legacy config
config_file = temp_config_dir / "config.json"
legacy_config = {
"USE_CWA_AUTH": True,
"BUILTIN_USERNAME": "admin",
"BUILTIN_PASSWORD_HASH": "hashed_password"
}
config_file.write_text(json.dumps(legacy_config, indent=2))
# Mock load_config_file to return our test config, and the paths
with patch('shelfmark.config.security.load_config_file', return_value=legacy_config.copy()):
with patch('shelfmark.core.settings_registry._get_config_file_path', return_value=str(config_file)):
with patch('shelfmark.core.settings_registry._ensure_config_dir'):
with patch('shelfmark.config.security.logger', mock_logger):
from shelfmark.config.security import _migrate_security_settings
_migrate_security_settings()
# Verify migration - read the actual file
migrated_config = json.loads(config_file.read_text())
assert migrated_config["AUTH_METHOD"] == "cwa"
assert "USE_CWA_AUTH" not in migrated_config
def test_migrate_use_cwa_auth_false_with_credentials(self, temp_config_dir, mock_logger):
"""Test migrating USE_CWA_AUTH=False with credentials to AUTH_METHOD='builtin'."""
config_file = temp_config_dir / "config.json"
legacy_config = {
"USE_CWA_AUTH": False,
"BUILTIN_USERNAME": "admin",
"BUILTIN_PASSWORD_HASH": "hashed_password"
}
config_file.write_text(json.dumps(legacy_config, indent=2))
with patch('shelfmark.config.security.load_config_file', return_value=legacy_config.copy()):
with patch('shelfmark.core.settings_registry._get_config_file_path', return_value=str(config_file)):
with patch('shelfmark.core.settings_registry._ensure_config_dir'):
with patch('shelfmark.config.security.logger', mock_logger):
from shelfmark.config.security import _migrate_security_settings
_migrate_security_settings()
migrated_config = json.loads(config_file.read_text())
assert migrated_config["AUTH_METHOD"] == "builtin"
assert "USE_CWA_AUTH" not in migrated_config
def test_migrate_use_cwa_auth_false_without_credentials(self, temp_config_dir, mock_logger):
"""Test migrating USE_CWA_AUTH=False without credentials to AUTH_METHOD='none'."""
config_file = temp_config_dir / "config.json"
legacy_config = {
"USE_CWA_AUTH": False
}
config_file.write_text(json.dumps(legacy_config, indent=2))
with patch('shelfmark.config.security.load_config_file', return_value=legacy_config.copy()):
with patch('shelfmark.core.settings_registry._get_config_file_path', return_value=str(config_file)):
with patch('shelfmark.core.settings_registry._ensure_config_dir'):
with patch('shelfmark.config.security.logger', mock_logger):
from shelfmark.config.security import _migrate_security_settings
_migrate_security_settings()
migrated_config = json.loads(config_file.read_text())
assert migrated_config["AUTH_METHOD"] == "none"
assert "USE_CWA_AUTH" not in migrated_config
def test_migrate_restrict_settings_to_admin(self, temp_config_dir, mock_logger):
"""Test migrating RESTRICT_SETTINGS_TO_ADMIN to CWA_RESTRICT_SETTINGS_TO_ADMIN."""
config_file = temp_config_dir / "config.json"
legacy_config = {
"AUTH_METHOD": "cwa",
"RESTRICT_SETTINGS_TO_ADMIN": True
}
config_file.write_text(json.dumps(legacy_config, indent=2))
with patch('shelfmark.config.security.load_config_file', return_value=legacy_config.copy()):
with patch('shelfmark.core.settings_registry._get_config_file_path', return_value=str(config_file)):
with patch('shelfmark.core.settings_registry._ensure_config_dir'):
with patch('shelfmark.config.security.logger', mock_logger):
from shelfmark.config.security import _migrate_security_settings
_migrate_security_settings()
migrated_config = json.loads(config_file.read_text())
assert migrated_config["CWA_RESTRICT_SETTINGS_TO_ADMIN"] is True
assert "RESTRICT_SETTINGS_TO_ADMIN" not in migrated_config
def test_migrate_preserves_existing_auth_method(self, temp_config_dir, mock_logger):
"""Test that existing AUTH_METHOD is not overwritten during migration."""
config_file = temp_config_dir / "config.json"
legacy_config = {
"USE_CWA_AUTH": True,
"AUTH_METHOD": "proxy" # Already has new format
}
config_file.write_text(json.dumps(legacy_config, indent=2))
with patch('shelfmark.config.security.load_config_file', return_value=legacy_config.copy()):
with patch('shelfmark.core.settings_registry._get_config_file_path', return_value=str(config_file)):
with patch('shelfmark.core.settings_registry._ensure_config_dir'):
with patch('shelfmark.config.security.logger', mock_logger):
from shelfmark.config.security import _migrate_security_settings
_migrate_security_settings()
migrated_config = json.loads(config_file.read_text())
assert migrated_config["AUTH_METHOD"] == "proxy" # Should not change
assert "USE_CWA_AUTH" not in migrated_config
def test_migrate_handles_missing_config_file(self, temp_config_dir, mock_logger):
"""Test that migration handles missing config file gracefully."""
with patch('shelfmark.config.security.load_config_file', side_effect=FileNotFoundError()):
with patch('shelfmark.config.security.logger', mock_logger):
from shelfmark.config.security import _migrate_security_settings
_migrate_security_settings()
mock_logger.debug.assert_any_call("No existing security config file found - nothing to migrate")
def test_migrate_no_changes_needed(self, temp_config_dir, mock_logger):
"""Test migration when no changes are needed."""
config_file = temp_config_dir / "config.json"
modern_config = {
"AUTH_METHOD": "builtin",
"BUILTIN_USERNAME": "admin",
"BUILTIN_PASSWORD_HASH": "hashed_password"
}
config_file.write_text(json.dumps(modern_config, indent=2))
with patch('shelfmark.config.security.load_config_file', return_value=modern_config.copy()):
with patch('shelfmark.core.settings_registry._get_config_file_path', return_value=str(config_file)):
with patch('shelfmark.core.settings_registry._ensure_config_dir'):
with patch('shelfmark.config.security.logger', mock_logger):
from shelfmark.config.security import _migrate_security_settings
_migrate_security_settings()
# Config should remain unchanged
final_config = json.loads(config_file.read_text())
# File won't have been rewritten, so it should be the original
assert final_config == modern_config
mock_logger.debug.assert_any_call("No security settings migration needed")
class TestSecuritySettings:
"""Tests for security settings registration."""
def test_security_settings_without_cwa(self):
"""Test that CWA option is not available when DB is not mounted."""
# Patch CWA_DB_PATH where it's imported in the function
with patch('shelfmark.config.env.CWA_DB_PATH', None):
# Need to reload the module to pick up the patch
import importlib
import shelfmark.config.security
importlib.reload(shelfmark.config.security)
from shelfmark.config.security import security_settings
fields = security_settings()
# Find the AUTH_METHOD field
auth_method_field = next((f for f in fields if f.key == "AUTH_METHOD"), None)
assert auth_method_field is not None
# CWA should not be in options
option_values = [opt["value"] for opt in auth_method_field.options]
assert "none" in option_values
assert "builtin" in option_values
assert "proxy" in option_values
assert "cwa" not in option_values
def test_security_settings_with_cwa(self):
"""Test that CWA option is available when DB is mounted."""
# Create a mock path that exists
mock_path = MagicMock()
mock_path.exists.return_value = True
with patch('shelfmark.config.env.CWA_DB_PATH', mock_path):
import importlib
import shelfmark.config.security
importlib.reload(shelfmark.config.security)
from shelfmark.config.security import security_settings
fields = security_settings()
# Find the AUTH_METHOD field
auth_method_field = next((f for f in fields if f.key == "AUTH_METHOD"), None)
assert auth_method_field is not None
# CWA should be in options
option_values = [opt["value"] for opt in auth_method_field.options]
assert "cwa" in option_values
def test_proxy_auth_fields_present(self):
"""Test that proxy auth configuration fields are present."""
from shelfmark.config.security import security_settings
fields = security_settings()
field_keys = [f.key for f in fields]
# Verify proxy auth fields exist
assert "PROXY_AUTH_USER_HEADER" in field_keys
assert "PROXY_AUTH_LOGOUT_URL" in field_keys
assert "PROXY_AUTH_RESTRICT_SETTINGS_TO_ADMIN" in field_keys
assert "PROXY_AUTH_ADMIN_GROUP_HEADER" in field_keys
assert "PROXY_AUTH_ADMIN_GROUP_NAME" in field_keys
def test_cwa_restrict_settings_field_present(self):
"""Test that CWA restrict settings field is present."""
from shelfmark.config.security import security_settings
fields = security_settings()
field_keys = [f.key for f in fields]
assert "CWA_RESTRICT_SETTINGS_TO_ADMIN" in field_keys
class TestPasswordValidation:
"""Tests for password validation in the on_save handler."""
def test_on_save_validates_password_match(self):
"""Test that passwords must match."""
from shelfmark.config.security import _on_save_security
values = {
"AUTH_METHOD": "builtin",
"BUILTIN_USERNAME": "admin",
"BUILTIN_PASSWORD": "password123",
"BUILTIN_PASSWORD_CONFIRM": "different_password"
}
result = _on_save_security(values)
assert result["error"] is True
assert "do not match" in result["message"]
def test_on_save_validates_password_length(self):
"""Test that password must be at least 4 characters."""
from shelfmark.config.security import _on_save_security
values = {
"AUTH_METHOD": "builtin",
"BUILTIN_USERNAME": "admin",
"BUILTIN_PASSWORD": "abc",
"BUILTIN_PASSWORD_CONFIRM": "abc"
}
result = _on_save_security(values)
assert result["error"] is True
assert "at least 4 characters" in result["message"]
def test_on_save_requires_username_with_password(self):
"""Test that username is required when password is set."""
from shelfmark.config.security import _on_save_security
values = {
"AUTH_METHOD": "builtin",
"BUILTIN_PASSWORD": "password123",
"BUILTIN_PASSWORD_CONFIRM": "password123"
}
result = _on_save_security(values)
assert result["error"] is True
assert "Username cannot be empty" in result["message"]
def test_on_save_hashes_password(self):
"""Test that password is properly hashed."""
from shelfmark.config.security import _on_save_security
values = {
"AUTH_METHOD": "builtin",
"BUILTIN_USERNAME": "admin",
"BUILTIN_PASSWORD": "password123",
"BUILTIN_PASSWORD_CONFIRM": "password123"
}
result = _on_save_security(values)
assert result["error"] is False
assert "BUILTIN_PASSWORD_HASH" in result["values"]
assert "BUILTIN_PASSWORD" not in result["values"]
assert "BUILTIN_PASSWORD_CONFIRM" not in result["values"]
# Hash should be different from raw password
assert result["values"]["BUILTIN_PASSWORD_HASH"] != "password123"
def test_on_save_preserves_existing_hash_when_no_password(self):
"""Test that existing password hash is preserved when password fields are empty."""
from shelfmark.config.security import _on_save_security
with patch('shelfmark.config.security.load_config_file') as mock_load:
mock_load.return_value = {
"BUILTIN_PASSWORD_HASH": "existing_hash"
}
values = {
"AUTH_METHOD": "builtin",
"BUILTIN_USERNAME": "admin"
}
result = _on_save_security(values)
assert result["error"] is False
assert result["values"]["BUILTIN_PASSWORD_HASH"] == "existing_hash"
class TestClearCredentials:
"""Tests for clearing built-in credentials."""
def test_clear_credentials_removes_username_and_hash(self, temp_config_dir):
"""Test that clearing credentials removes username and password hash."""
config_file = temp_config_dir / "config.json"
config = {
"AUTH_METHOD": "builtin",
"BUILTIN_USERNAME": "admin",
"BUILTIN_PASSWORD_HASH": "hashed_password"
}
config_file.write_text(json.dumps(config, indent=2))
with patch('shelfmark.core.settings_registry._get_config_file_path', return_value=str(config_file)):
with patch('shelfmark.core.settings_registry._ensure_config_dir'):
with patch('shelfmark.config.security.load_config_file', return_value=config.copy()):
from shelfmark.config.security import _clear_builtin_credentials
result = _clear_builtin_credentials()
assert result["success"] is True
cleared_config = json.loads(config_file.read_text())
assert "BUILTIN_USERNAME" not in cleared_config
assert "BUILTIN_PASSWORD_HASH" not in cleared_config
def test_clear_credentials_handles_errors(self):
"""Test that clearing credentials handles errors gracefully."""
with patch('shelfmark.config.security.load_config_file', side_effect=Exception("Test error")):
from shelfmark.config.security import _clear_builtin_credentials
result = _clear_builtin_credentials()
assert result["success"] is False
assert "Test error" in result["message"]