feat: add comprehensive unit and integration testing infrastructure
- Backend: pytest with unit tests (auth, rbac, config) and integration tests (auth flow, docker, files) - Frontend: vitest with unit tests (api client) and component tests (login) - CI: Gitea Actions workflow for automated testing with coverage reports - Documentation: TESTING.md guide with setup, usage, and best practices - Coverage goals: 80%+ backend, 70%+ frontend
This commit is contained in:
@@ -0,0 +1,316 @@
|
||||
"""
|
||||
Unit tests for rbac.py - Role-based access control.
|
||||
"""
|
||||
import pytest
|
||||
import json
|
||||
import os
|
||||
from rbac import (
|
||||
load_rbac,
|
||||
save_rbac,
|
||||
update_rbac,
|
||||
resolve_role,
|
||||
get_pages,
|
||||
get_sidebar_links,
|
||||
get_sidebar_order,
|
||||
DEFAULT_RBAC,
|
||||
ROLE_GROUP_MAP,
|
||||
)
|
||||
|
||||
|
||||
class TestLoadRBAC:
|
||||
"""Test loading RBAC configuration."""
|
||||
|
||||
def test_load_rbac_from_file(self, mock_config, temp_rbac_file):
|
||||
"""Test loading RBAC from existing file."""
|
||||
rbac = load_rbac()
|
||||
|
||||
assert "role_defaults" in rbac
|
||||
assert "user_overrides" in rbac
|
||||
assert "admin" in rbac["role_defaults"]
|
||||
assert "member" in rbac["role_defaults"]
|
||||
assert "viewer" in rbac["role_defaults"]
|
||||
|
||||
def test_load_rbac_missing_file(self, mock_config, temp_volume_root):
|
||||
"""Test loading RBAC when file doesn't exist returns defaults."""
|
||||
# Don't create rbac file
|
||||
rbac = load_rbac()
|
||||
|
||||
assert rbac == DEFAULT_RBAC
|
||||
|
||||
def test_load_rbac_corrupted_file(self, mock_config, temp_volume_root):
|
||||
"""Test loading RBAC with corrupted file returns defaults."""
|
||||
rbac_file = os.path.join(temp_volume_root, "docker/nas-dashboard/rbac.json")
|
||||
os.makedirs(os.path.dirname(rbac_file), exist_ok=True)
|
||||
|
||||
# Write invalid JSON
|
||||
with open(rbac_file, "w") as f:
|
||||
f.write("invalid json {{{")
|
||||
|
||||
rbac = load_rbac()
|
||||
assert rbac == DEFAULT_RBAC
|
||||
|
||||
|
||||
class TestSaveRBAC:
|
||||
"""Test saving RBAC configuration."""
|
||||
|
||||
def test_save_rbac(self, mock_config, temp_volume_root):
|
||||
"""Test saving RBAC configuration."""
|
||||
rbac_file = os.path.join(temp_volume_root, "docker/nas-dashboard/rbac.json")
|
||||
test_data = {
|
||||
"role_defaults": {"admin": {"pages": "*"}},
|
||||
"user_overrides": {"testuser": {"pages": ["dashboard"]}}
|
||||
}
|
||||
|
||||
save_rbac(test_data)
|
||||
|
||||
assert os.path.exists(rbac_file)
|
||||
with open(rbac_file) as f:
|
||||
loaded = json.load(f)
|
||||
assert loaded == test_data
|
||||
|
||||
def test_save_rbac_creates_directory(self, mock_config, temp_volume_root):
|
||||
"""Test that save_rbac creates directory if it doesn't exist."""
|
||||
rbac_file = os.path.join(temp_volume_root, "docker/nas-dashboard/rbac.json")
|
||||
|
||||
# Remove directory
|
||||
import shutil
|
||||
if os.path.exists(os.path.dirname(rbac_file)):
|
||||
shutil.rmtree(os.path.dirname(rbac_file))
|
||||
|
||||
test_data = {"role_defaults": {}, "user_overrides": {}}
|
||||
save_rbac(test_data)
|
||||
|
||||
assert os.path.exists(rbac_file)
|
||||
|
||||
|
||||
class TestUpdateRBAC:
|
||||
"""Test atomic RBAC updates."""
|
||||
|
||||
def test_update_rbac_with_mutator(self, mock_config, temp_rbac_file):
|
||||
"""Test updating RBAC with mutator function."""
|
||||
def add_user_override(data):
|
||||
data["user_overrides"]["newuser"] = {"pages": ["dashboard", "docker"]}
|
||||
return "success"
|
||||
|
||||
result = update_rbac(add_user_override)
|
||||
|
||||
assert result == "success"
|
||||
|
||||
# Verify changes persisted
|
||||
rbac = load_rbac()
|
||||
assert "newuser" in rbac["user_overrides"]
|
||||
assert rbac["user_overrides"]["newuser"]["pages"] == ["dashboard", "docker"]
|
||||
|
||||
def test_update_rbac_thread_safe(self, mock_config, temp_rbac_file):
|
||||
"""Test that update_rbac is thread-safe."""
|
||||
import threading
|
||||
|
||||
results = []
|
||||
|
||||
def increment_counter(data):
|
||||
current = data.get("counter", 0)
|
||||
data["counter"] = current + 1
|
||||
return data["counter"]
|
||||
|
||||
# Run multiple updates concurrently
|
||||
threads = []
|
||||
for _ in range(10):
|
||||
t = threading.Thread(target=lambda: results.append(update_rbac(increment_counter)))
|
||||
threads.append(t)
|
||||
t.start()
|
||||
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
# Final counter should be 10
|
||||
rbac = load_rbac()
|
||||
assert rbac.get("counter") == 10
|
||||
|
||||
|
||||
class TestResolveRole:
|
||||
"""Test role resolution from groups."""
|
||||
|
||||
def test_resolve_role_admin(self):
|
||||
"""Test resolving admin role."""
|
||||
groups = ["dashboard_admin", "other_group"]
|
||||
role = resolve_role(groups)
|
||||
assert role == "admin"
|
||||
|
||||
def test_resolve_role_member(self):
|
||||
"""Test resolving member role."""
|
||||
groups = ["dashboard_member"]
|
||||
role = resolve_role(groups)
|
||||
assert role == "member"
|
||||
|
||||
def test_resolve_role_viewer(self):
|
||||
"""Test resolving viewer role."""
|
||||
groups = ["dashboard_viewer", "unrelated_group"]
|
||||
role = resolve_role(groups)
|
||||
assert role == "viewer"
|
||||
|
||||
def test_resolve_role_no_match(self):
|
||||
"""Test resolving role with no matching groups."""
|
||||
groups = ["other_group", "another_group"]
|
||||
role = resolve_role(groups)
|
||||
assert role is None
|
||||
|
||||
def test_resolve_role_empty_groups(self):
|
||||
"""Test resolving role with empty groups list."""
|
||||
role = resolve_role([])
|
||||
assert role is None
|
||||
|
||||
def test_resolve_role_priority(self):
|
||||
"""Test that first matching group is used."""
|
||||
groups = ["dashboard_admin", "dashboard_member"]
|
||||
role = resolve_role(groups)
|
||||
assert role == "admin"
|
||||
|
||||
|
||||
class TestGetPages:
|
||||
"""Test page access resolution."""
|
||||
|
||||
def test_get_pages_admin_default(self, mock_config, temp_rbac_file):
|
||||
"""Test getting pages for admin with default settings."""
|
||||
pages = get_pages("testuser", "admin")
|
||||
assert pages == "*"
|
||||
|
||||
def test_get_pages_member_default(self, mock_config, temp_rbac_file):
|
||||
"""Test getting pages for member with default settings."""
|
||||
pages = get_pages("testuser", "member")
|
||||
assert isinstance(pages, list)
|
||||
assert "dashboard" in pages
|
||||
assert "docker" in pages
|
||||
|
||||
def test_get_pages_viewer_default(self, mock_config, temp_rbac_file):
|
||||
"""Test getting pages for viewer with default settings."""
|
||||
pages = get_pages("testuser", "viewer")
|
||||
assert pages == ["dashboard"]
|
||||
|
||||
def test_get_pages_with_user_override(self, mock_config, temp_rbac_file):
|
||||
"""Test getting pages with user-specific override."""
|
||||
# Add user override
|
||||
def add_override(data):
|
||||
data["user_overrides"]["customuser"] = {"pages": ["dashboard", "files"]}
|
||||
|
||||
update_rbac(add_override)
|
||||
|
||||
pages = get_pages("customuser", "admin")
|
||||
assert pages == ["dashboard", "files"]
|
||||
|
||||
def test_get_pages_unknown_role(self, mock_config, temp_rbac_file):
|
||||
"""Test getting pages for unknown role returns empty list."""
|
||||
pages = get_pages("testuser", "unknown_role")
|
||||
assert pages == []
|
||||
|
||||
|
||||
class TestGetSidebarLinks:
|
||||
"""Test sidebar link access resolution."""
|
||||
|
||||
def test_get_sidebar_links_admin_default(self, mock_config, temp_rbac_file):
|
||||
"""Test getting sidebar links for admin."""
|
||||
links = get_sidebar_links("testuser", "admin")
|
||||
assert links == "*"
|
||||
|
||||
def test_get_sidebar_links_member_default(self, mock_config, temp_rbac_file):
|
||||
"""Test getting sidebar links for member."""
|
||||
links = get_sidebar_links("testuser", "member")
|
||||
assert links == "*"
|
||||
|
||||
def test_get_sidebar_links_viewer_default(self, mock_config, temp_rbac_file):
|
||||
"""Test getting sidebar links for viewer."""
|
||||
links = get_sidebar_links("testuser", "viewer")
|
||||
assert links == ["dashboard"]
|
||||
|
||||
def test_get_sidebar_links_with_user_override(self, mock_config, temp_rbac_file):
|
||||
"""Test getting sidebar links with user override."""
|
||||
def add_override(data):
|
||||
data["user_overrides"]["customuser"] = {
|
||||
"pages": "*",
|
||||
"sidebar_links": ["dashboard", "docker", "files"]
|
||||
}
|
||||
|
||||
update_rbac(add_override)
|
||||
|
||||
links = get_sidebar_links("customuser", "admin")
|
||||
assert links == ["dashboard", "docker", "files"]
|
||||
|
||||
def test_get_sidebar_links_no_override(self, mock_config, temp_rbac_file):
|
||||
"""Test that user without sidebar_links override gets role default."""
|
||||
def add_override(data):
|
||||
data["user_overrides"]["customuser"] = {"pages": ["dashboard"]}
|
||||
# No sidebar_links specified
|
||||
|
||||
update_rbac(add_override)
|
||||
|
||||
links = get_sidebar_links("customuser", "admin")
|
||||
assert links == "*" # Should fall back to admin default
|
||||
|
||||
|
||||
class TestGetSidebarOrder:
|
||||
"""Test sidebar order retrieval."""
|
||||
|
||||
def test_get_sidebar_order_no_override(self, mock_config, temp_rbac_file):
|
||||
"""Test getting sidebar order when user has no override."""
|
||||
order = get_sidebar_order("testuser")
|
||||
assert order is None
|
||||
|
||||
def test_get_sidebar_order_with_override(self, mock_config, temp_rbac_file):
|
||||
"""Test getting sidebar order with user override."""
|
||||
custom_order = {
|
||||
"Main": ["dashboard", "docker"],
|
||||
"Media": ["navidrome", "immich"]
|
||||
}
|
||||
|
||||
def add_override(data):
|
||||
data["user_overrides"]["customuser"] = {"sidebar_order": custom_order}
|
||||
|
||||
update_rbac(add_override)
|
||||
|
||||
order = get_sidebar_order("customuser")
|
||||
assert order == custom_order
|
||||
|
||||
|
||||
class TestDefaultRBAC:
|
||||
"""Test default RBAC configuration."""
|
||||
|
||||
def test_default_rbac_structure(self):
|
||||
"""Test that DEFAULT_RBAC has correct structure."""
|
||||
assert "role_defaults" in DEFAULT_RBAC
|
||||
assert "user_overrides" in DEFAULT_RBAC
|
||||
|
||||
assert "admin" in DEFAULT_RBAC["role_defaults"]
|
||||
assert "member" in DEFAULT_RBAC["role_defaults"]
|
||||
assert "viewer" in DEFAULT_RBAC["role_defaults"]
|
||||
|
||||
def test_default_rbac_admin_permissions(self):
|
||||
"""Test admin has full permissions by default."""
|
||||
admin = DEFAULT_RBAC["role_defaults"]["admin"]
|
||||
assert admin["pages"] == "*"
|
||||
assert admin["sidebar_links"] == "*"
|
||||
|
||||
def test_default_rbac_member_permissions(self):
|
||||
"""Test member has limited permissions."""
|
||||
member = DEFAULT_RBAC["role_defaults"]["member"]
|
||||
assert isinstance(member["pages"], list)
|
||||
assert "dashboard" in member["pages"]
|
||||
assert member["sidebar_links"] == "*"
|
||||
|
||||
def test_default_rbac_viewer_permissions(self):
|
||||
"""Test viewer has minimal permissions."""
|
||||
viewer = DEFAULT_RBAC["role_defaults"]["viewer"]
|
||||
assert viewer["pages"] == ["dashboard"]
|
||||
assert viewer["sidebar_links"] == ["dashboard"]
|
||||
|
||||
|
||||
class TestRoleGroupMap:
|
||||
"""Test role group mapping."""
|
||||
|
||||
def test_role_group_map_completeness(self):
|
||||
"""Test that ROLE_GROUP_MAP has all expected mappings."""
|
||||
assert "dashboard_admin" in ROLE_GROUP_MAP
|
||||
assert "dashboard_member" in ROLE_GROUP_MAP
|
||||
assert "dashboard_viewer" in ROLE_GROUP_MAP
|
||||
|
||||
assert ROLE_GROUP_MAP["dashboard_admin"] == "admin"
|
||||
assert ROLE_GROUP_MAP["dashboard_member"] == "member"
|
||||
assert ROLE_GROUP_MAP["dashboard_viewer"] == "viewer"
|
||||
Reference in New Issue
Block a user