mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-04-20 05:51:21 -04:00
- Update file movement to prefer copy - Improved mirror config overwriting on app updates - Request / user DB hardening
424 lines
18 KiB
Python
424 lines
18 KiB
Python
"""
|
|
Tests for security configuration and migration.
|
|
|
|
Tests the security settings registration, migration from old settings,
|
|
and current on-save guard behavior.
|
|
"""
|
|
|
|
import json
|
|
import tempfile
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from shelfmark.core.user_db import UserDB
|
|
|
|
|
|
@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_syncs_legacy_admin(self, temp_config_dir, mock_logger, monkeypatch):
|
|
"""USE_CWA_AUTH=True migrates to cwa and keeps legacy creds synced to users DB."""
|
|
config_root = temp_config_dir.parent
|
|
monkeypatch.setenv("CONFIG_DIR", str(config_root))
|
|
|
|
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))
|
|
|
|
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 = json.loads(config_file.read_text())
|
|
assert migrated["AUTH_METHOD"] == "cwa"
|
|
assert "USE_CWA_AUTH" not in migrated
|
|
assert migrated["BUILTIN_USERNAME"] == "admin"
|
|
assert migrated["BUILTIN_PASSWORD_HASH"] == "hashed_password"
|
|
|
|
user_db = UserDB(str(config_root / "users.db"))
|
|
user_db.initialize()
|
|
user = user_db.get_user(username="admin")
|
|
assert user is not None
|
|
assert user["role"] == "admin"
|
|
assert user["auth_source"] == "builtin"
|
|
assert user["password_hash"] == "hashed_password"
|
|
|
|
def test_migrate_use_cwa_auth_false_with_credentials(self, temp_config_dir, mock_logger, monkeypatch):
|
|
"""USE_CWA_AUTH=False with creds migrates to builtin and syncs users DB."""
|
|
config_root = temp_config_dir.parent
|
|
monkeypatch.setenv("CONFIG_DIR", str(config_root))
|
|
|
|
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 = json.loads(config_file.read_text())
|
|
assert migrated["AUTH_METHOD"] == "builtin"
|
|
assert "USE_CWA_AUTH" not in migrated
|
|
assert migrated["BUILTIN_USERNAME"] == "admin"
|
|
assert migrated["BUILTIN_PASSWORD_HASH"] == "hashed_password"
|
|
|
|
user_db = UserDB(str(config_root / "users.db"))
|
|
user_db.initialize()
|
|
user = user_db.get_user(username="admin")
|
|
assert user is not None
|
|
assert user["role"] == "admin"
|
|
|
|
def test_migrate_use_cwa_auth_false_without_credentials(self, temp_config_dir, mock_logger):
|
|
"""USE_CWA_AUTH=False without creds migrates to 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 = json.loads(config_file.read_text())
|
|
assert migrated["AUTH_METHOD"] == "none"
|
|
assert "USE_CWA_AUTH" not in migrated
|
|
|
|
def test_migrate_restrict_settings_to_admin(self, temp_config_dir, mock_logger):
|
|
"""Legacy settings restriction should migrate to users tab global toggle."""
|
|
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))
|
|
|
|
def _load_config(tab_name: str):
|
|
if tab_name == "security":
|
|
return legacy_config.copy()
|
|
if tab_name == "users":
|
|
return {}
|
|
return {}
|
|
|
|
with patch("shelfmark.config.security.load_config_file", side_effect=_load_config):
|
|
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.core.settings_registry.save_config_file") as mock_save_config:
|
|
with patch("shelfmark.config.security.logger", mock_logger):
|
|
from shelfmark.config.security import _migrate_security_settings
|
|
|
|
_migrate_security_settings()
|
|
|
|
migrated = json.loads(config_file.read_text())
|
|
assert "RESTRICT_SETTINGS_TO_ADMIN" not in migrated
|
|
mock_save_config.assert_called_with("users", {"RESTRICT_SETTINGS_TO_ADMIN": True})
|
|
|
|
def test_migrate_proxy_restriction_to_users_global(self, temp_config_dir, mock_logger):
|
|
"""Proxy-specific restriction should migrate to users.RESTRICT_SETTINGS_TO_ADMIN."""
|
|
config_file = temp_config_dir / "config.json"
|
|
legacy_config = {
|
|
"AUTH_METHOD": "proxy",
|
|
"PROXY_AUTH_RESTRICT_SETTINGS_TO_ADMIN": False,
|
|
}
|
|
config_file.write_text(json.dumps(legacy_config, indent=2))
|
|
|
|
def _load_config(tab_name: str):
|
|
if tab_name == "security":
|
|
return legacy_config.copy()
|
|
if tab_name == "users":
|
|
return {}
|
|
return {}
|
|
|
|
with patch("shelfmark.config.security.load_config_file", side_effect=_load_config):
|
|
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.core.settings_registry.save_config_file") as mock_save_config:
|
|
with patch("shelfmark.config.security.logger", mock_logger):
|
|
from shelfmark.config.security import _migrate_security_settings
|
|
|
|
_migrate_security_settings()
|
|
|
|
migrated = json.loads(config_file.read_text())
|
|
assert "PROXY_AUTH_RESTRICT_SETTINGS_TO_ADMIN" not in migrated
|
|
mock_save_config.assert_called_with("users", {"RESTRICT_SETTINGS_TO_ADMIN": False})
|
|
|
|
def test_migrate_preserves_existing_auth_method(self, temp_config_dir, mock_logger):
|
|
"""Existing AUTH_METHOD should not be overwritten."""
|
|
config_file = temp_config_dir / "config.json"
|
|
legacy_config = {
|
|
"USE_CWA_AUTH": True,
|
|
"AUTH_METHOD": "proxy",
|
|
}
|
|
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 = json.loads(config_file.read_text())
|
|
assert migrated["AUTH_METHOD"] == "proxy"
|
|
assert "USE_CWA_AUTH" not in migrated
|
|
|
|
def test_migrate_backfills_auth_method_from_legacy_builtin_credentials(
|
|
self, temp_config_dir, mock_logger, monkeypatch
|
|
):
|
|
"""Configs with BUILTIN creds but no AUTH_METHOD should be backfilled to builtin."""
|
|
config_root = temp_config_dir.parent
|
|
monkeypatch.setenv("CONFIG_DIR", str(config_root))
|
|
|
|
config_file = temp_config_dir / "config.json"
|
|
legacy_config = {
|
|
"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 = json.loads(config_file.read_text())
|
|
assert migrated["AUTH_METHOD"] == "builtin"
|
|
assert migrated["BUILTIN_USERNAME"] == "admin"
|
|
assert migrated["BUILTIN_PASSWORD_HASH"] == "hashed_password"
|
|
|
|
user_db = UserDB(str(config_root / "users.db"))
|
|
user_db.initialize()
|
|
user = user_db.get_user(username="admin")
|
|
assert user is not None
|
|
assert user["role"] == "admin"
|
|
|
|
def test_migrate_handles_missing_config_file(self, mock_logger):
|
|
"""Missing config file should be handled 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):
|
|
"""No-op migration should not rewrite config."""
|
|
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()
|
|
|
|
final_config = json.loads(config_file.read_text())
|
|
assert final_config == modern_config
|
|
|
|
|
|
class TestSecuritySettings:
|
|
"""Tests for security settings registration."""
|
|
|
|
def test_security_settings_without_cwa_shows_warning_but_keeps_option(self):
|
|
"""CWA remains selectable but warns when the DB is unavailable."""
|
|
with patch("shelfmark.config.env.CWA_DB_PATH", None):
|
|
import importlib
|
|
import shelfmark.config.security
|
|
|
|
importlib.reload(shelfmark.config.security)
|
|
from shelfmark.config.security import security_settings
|
|
|
|
fields = security_settings()
|
|
auth_method_field = next((f for f in fields if f.key == "AUTH_METHOD"), None)
|
|
assert auth_method_field is not None
|
|
|
|
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" in option_values
|
|
|
|
cwa_warning_field = next((f for f in fields if f.key == "cwa_db_missing"), None)
|
|
assert cwa_warning_field is not None
|
|
|
|
def test_security_settings_with_cwa(self):
|
|
"""CWA option should be shown when DB is mounted."""
|
|
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()
|
|
auth_method_field = next((f for f in fields if f.key == "AUTH_METHOD"), None)
|
|
assert auth_method_field is not None
|
|
|
|
option_values = [opt["value"] for opt in auth_method_field.options]
|
|
assert "cwa" in option_values
|
|
|
|
def test_builtin_credential_fields_hidden(self):
|
|
"""Builtin username/password fields should be removed from settings UI."""
|
|
from shelfmark.config.security import security_settings
|
|
|
|
fields = security_settings()
|
|
field_keys = [f.key for f in fields]
|
|
|
|
assert "BUILTIN_USERNAME" not in field_keys
|
|
assert "BUILTIN_PASSWORD" not in field_keys
|
|
assert "BUILTIN_PASSWORD_CONFIRM" not in field_keys
|
|
|
|
def test_builtin_notice_field_removed(self):
|
|
"""Builtin guidance should be handled by the action button only."""
|
|
from shelfmark.config.security import security_settings
|
|
|
|
fields = security_settings()
|
|
notice = next((f for f in fields if f.key == "builtin_auth_notice"), None)
|
|
assert notice is None
|
|
|
|
def test_builtin_admin_requirement_hint_present(self):
|
|
"""Builtin mode should show local-admin requirement warning."""
|
|
from shelfmark.config.security import security_settings
|
|
|
|
fields = security_settings()
|
|
hint = next((f for f in fields if f.key == "builtin_admin_requirement"), None)
|
|
assert hint is not None
|
|
assert hint.component == "oidc_admin_hint"
|
|
assert hint.show_when == {"field": "AUTH_METHOD", "value": "builtin"}
|
|
assert "inactive" in hint.label.lower()
|
|
assert "local admin" in hint.label.lower()
|
|
|
|
def test_builtin_option_label_is_local(self):
|
|
"""Builtin auth option should be labeled Local."""
|
|
from shelfmark.config.security import security_settings
|
|
|
|
fields = security_settings()
|
|
auth_field = next((f for f in fields if f.key == "AUTH_METHOD"), None)
|
|
builtin_option = next((opt for opt in auth_field.options if opt["value"] == "builtin"), None)
|
|
assert builtin_option is not None
|
|
assert builtin_option["label"] == "Local"
|
|
|
|
def test_builtin_users_navigation_action_present(self):
|
|
"""Builtin mode should include an action button to open Users tab."""
|
|
from shelfmark.config.security import security_settings
|
|
|
|
fields = security_settings()
|
|
action = next((f for f in fields if f.key == "open_users_tab"), None)
|
|
assert action is not None
|
|
assert action.label == "Go to Users"
|
|
assert action.show_when == {"field": "AUTH_METHOD", "value": ["builtin", "oidc"]}
|
|
|
|
|
|
class TestSecurityOnSave:
|
|
"""Tests for current security on-save guard behavior."""
|
|
|
|
def test_on_save_passthrough_for_non_oidc(self, tmp_path, monkeypatch):
|
|
from shelfmark.config.security import _on_save_security
|
|
|
|
monkeypatch.setenv("CONFIG_DIR", str(tmp_path))
|
|
values = {"AUTH_METHOD": "builtin", "PROXY_AUTH_USER_HEADER": "X-Auth-User"}
|
|
|
|
result = _on_save_security(values.copy())
|
|
|
|
assert result["error"] is False
|
|
assert result["values"] == values
|
|
|
|
def test_on_save_blocks_oidc_without_local_admin(self, tmp_path, monkeypatch):
|
|
from shelfmark.config.security import _on_save_security
|
|
|
|
monkeypatch.setenv("CONFIG_DIR", str(tmp_path))
|
|
UserDB(str(tmp_path / "users.db")).initialize()
|
|
|
|
result = _on_save_security({"AUTH_METHOD": "oidc"})
|
|
|
|
assert result["error"] is True
|
|
assert "local admin" in result["message"].lower()
|
|
|
|
def test_on_save_allows_oidc_with_local_password_admin(self, tmp_path, monkeypatch):
|
|
from shelfmark.config.security import _on_save_security
|
|
|
|
monkeypatch.setenv("CONFIG_DIR", str(tmp_path))
|
|
user_db = UserDB(str(tmp_path / "users.db"))
|
|
user_db.initialize()
|
|
user_db.create_user(username="admin", password_hash="hash", role="admin")
|
|
|
|
result = _on_save_security({"AUTH_METHOD": "oidc"})
|
|
|
|
assert result["error"] is False
|
|
|
|
def test_on_save_normalizes_oidc_discovery_url(self, tmp_path, monkeypatch):
|
|
from shelfmark.config.security import _on_save_security
|
|
|
|
monkeypatch.setenv("CONFIG_DIR", str(tmp_path))
|
|
values = {"OIDC_DISCOVERY_URL": " 'auth.example.com/.well-known/openid-configuration/' "}
|
|
|
|
result = _on_save_security(values)
|
|
|
|
assert result["error"] is False
|
|
assert (
|
|
result["values"]["OIDC_DISCOVERY_URL"]
|
|
== "https://auth.example.com/.well-known/openid-configuration"
|
|
)
|
|
|
|
def test_on_save_normalizes_proxy_logout_url(self, tmp_path, monkeypatch):
|
|
from shelfmark.config.security import _on_save_security
|
|
|
|
monkeypatch.setenv("CONFIG_DIR", str(tmp_path))
|
|
values = {"PROXY_AUTH_LOGOUT_URL": "auth.example.com/logout"}
|
|
|
|
result = _on_save_security(values)
|
|
|
|
assert result["error"] is False
|
|
assert result["values"]["PROXY_AUTH_LOGOUT_URL"] == "https://auth.example.com/logout"
|