Files
pic/tests/test_setup_manager.py
2026-05-09 08:05:38 -04:00

302 lines
12 KiB
Python

#!/usr/bin/env python3
"""
Unit tests for SetupManager (api/setup_manager.py).
Config manager and auth manager are injected as MagicMock objects so no
filesystem access or Docker calls are needed.
"""
import os
import sys
from pathlib import Path
from unittest.mock import MagicMock, call, patch
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent / 'api'))
from setup_manager import SetupManager, AVAILABLE_SERVICES, AVAILABLE_TIMEZONES
# ── fixtures ──────────────────────────────────────────────────────────────────
@pytest.fixture
def mock_config_manager():
"""A MagicMock standing in for ConfigManager."""
mgr = MagicMock()
# Default: setup not yet complete
mgr.get_identity.return_value = {}
return mgr
@pytest.fixture
def mock_auth_manager():
"""A MagicMock standing in for AuthManager."""
mgr = MagicMock()
mgr.create_user.return_value = True
return mgr
@pytest.fixture
def setup_manager(mock_config_manager, mock_auth_manager):
"""SetupManager wired to both mocks."""
return SetupManager(mock_config_manager, mock_auth_manager)
# ── valid payload helper ───────────────────────────────────────────────────────
def _valid_payload(**overrides):
base = {
'cell_name': 'mycel',
'password': 'SecurePass1!',
'domain_mode': 'lan',
'timezone': 'UTC',
'services_enabled': ['wireguard'],
'ddns_provider': 'none',
}
base.update(overrides)
return base
# ── is_setup_complete ─────────────────────────────────────────────────────────
def test_is_setup_complete_missing_key_returns_false(setup_manager, mock_config_manager):
mock_config_manager.get_identity.return_value = {}
assert setup_manager.is_setup_complete() is False
def test_is_setup_complete_false_value_returns_false(setup_manager, mock_config_manager):
mock_config_manager.get_identity.return_value = {'setup_complete': False}
assert setup_manager.is_setup_complete() is False
def test_is_setup_complete_true_value_returns_true(setup_manager, mock_config_manager):
mock_config_manager.get_identity.return_value = {'setup_complete': True}
assert setup_manager.is_setup_complete() is True
# ── validate_cell_name ────────────────────────────────────────────────────────
@pytest.mark.parametrize('name', ['mycel', 'my-cel', 'a1', 'abc-123-xyz'])
def test_validate_cell_name_accepts_valid_names(setup_manager, name):
assert setup_manager.validate_cell_name(name) == []
def test_validate_cell_name_rejects_empty_string(setup_manager):
errors = setup_manager.validate_cell_name('')
assert errors
assert any('required' in e.lower() for e in errors)
def test_validate_cell_name_rejects_starts_with_digit(setup_manager):
errors = setup_manager.validate_cell_name('1abc')
assert errors
def test_validate_cell_name_rejects_starts_with_hyphen(setup_manager):
errors = setup_manager.validate_cell_name('-abc')
assert errors
def test_validate_cell_name_rejects_ends_with_hyphen(setup_manager):
errors = setup_manager.validate_cell_name('abc-')
assert errors
def test_validate_cell_name_rejects_uppercase(setup_manager):
errors = setup_manager.validate_cell_name('MyCell')
assert errors
def test_validate_cell_name_rejects_underscore(setup_manager):
errors = setup_manager.validate_cell_name('my_cell')
assert errors
def test_validate_cell_name_rejects_dot(setup_manager):
errors = setup_manager.validate_cell_name('my.cell')
assert errors
def test_validate_cell_name_rejects_too_short_single_char(setup_manager):
# Single character: regex requires at least 2 chars (start + 1-30 more)
errors = setup_manager.validate_cell_name('a')
assert errors
def test_validate_cell_name_rejects_too_long(setup_manager):
# 32 lowercase letters — one over the 31-char limit
errors = setup_manager.validate_cell_name('a' * 32)
assert errors
def test_validate_cell_name_accepts_maximum_length(setup_manager):
# 31 chars: 'a' + 30 more lowercase = exactly at limit
assert setup_manager.validate_cell_name('a' + 'b' * 30) == []
# ── validate_password ─────────────────────────────────────────────────────────
def test_validate_password_accepts_valid_password(setup_manager):
assert setup_manager.validate_password('SecurePass1!') == []
def test_validate_password_rejects_too_short(setup_manager):
errors = setup_manager.validate_password('Short1!')
assert errors
assert any('12' in e or 'least' in e.lower() for e in errors)
def test_validate_password_rejects_no_uppercase(setup_manager):
errors = setup_manager.validate_password('securepass1!')
assert errors
assert any('uppercase' in e.lower() for e in errors)
def test_validate_password_rejects_no_lowercase(setup_manager):
errors = setup_manager.validate_password('SECUREPASS1!')
assert errors
assert any('lowercase' in e.lower() for e in errors)
def test_validate_password_rejects_no_digit(setup_manager):
errors = setup_manager.validate_password('SecurePassword!')
assert errors
assert any('digit' in e.lower() for e in errors)
# ── complete_setup ────────────────────────────────────────────────────────────
def test_complete_setup_returns_error_when_cell_name_invalid(setup_manager):
result = setup_manager.complete_setup(_valid_payload(cell_name='1bad'))
assert result['success'] is False
assert result['errors']
def test_complete_setup_returns_error_when_password_invalid(setup_manager):
result = setup_manager.complete_setup(_valid_payload(password='weak'))
assert result['success'] is False
assert result['errors']
def test_complete_setup_returns_error_when_domain_mode_invalid(setup_manager):
result = setup_manager.complete_setup(_valid_payload(domain_mode='ftp'))
assert result['success'] is False
assert any('domain_mode' in e for e in result['errors'])
def test_complete_setup_calls_create_user_with_correct_args(
setup_manager, mock_auth_manager, mock_config_manager, tmp_path):
mock_config_manager.get_identity.return_value = {}
with patch.dict(os.environ, {'DATA_DIR': str(tmp_path)}):
result = setup_manager.complete_setup(_valid_payload())
mock_auth_manager.create_user.assert_called_once_with(
username='admin',
password='SecurePass1!',
role='admin',
)
def test_complete_setup_calls_set_identity_field_for_each_field(
setup_manager, mock_config_manager, mock_auth_manager, tmp_path):
mock_config_manager.get_identity.return_value = {}
with patch.dict(os.environ, {'DATA_DIR': str(tmp_path)}):
setup_manager.complete_setup(_valid_payload())
calls = mock_config_manager.set_identity_field.call_args_list
field_names = [c[0][0] for c in calls]
for expected in ('cell_name', 'domain_mode', 'timezone', 'services_enabled', 'ddns_provider'):
assert expected in field_names, f"set_identity_field not called for '{expected}'"
def test_complete_setup_marks_setup_complete_last(
setup_manager, mock_config_manager, mock_auth_manager, tmp_path):
"""setup_complete must be the final set_identity_field call."""
mock_config_manager.get_identity.return_value = {}
with patch.dict(os.environ, {'DATA_DIR': str(tmp_path)}):
setup_manager.complete_setup(_valid_payload())
calls = mock_config_manager.set_identity_field.call_args_list
last_call = calls[-1]
assert last_call == call('setup_complete', True)
def test_complete_setup_returns_success_redirect_on_valid_payload(
setup_manager, mock_config_manager, mock_auth_manager, tmp_path):
mock_config_manager.get_identity.return_value = {}
with patch.dict(os.environ, {'DATA_DIR': str(tmp_path)}):
result = setup_manager.complete_setup(_valid_payload())
assert result == {'success': True, 'redirect': '/login'}
def test_complete_setup_returns_error_when_already_complete(
setup_manager, mock_config_manager, tmp_path):
"""If setup is already done when the lock-protected re-check runs, return error."""
# complete_setup calls is_setup_complete() exactly once — inside the lock.
# Returning True there triggers the "already completed" guard.
mock_config_manager.get_identity.return_value = {'setup_complete': True}
with patch.dict(os.environ, {'DATA_DIR': str(tmp_path)}):
result = setup_manager.complete_setup(_valid_payload())
assert result['success'] is False
assert any('already' in e.lower() for e in result['errors'])
def test_complete_setup_does_not_persist_fields_when_already_complete(
setup_manager, mock_config_manager, mock_auth_manager, tmp_path):
"""No side-effects (no create_user, no set_identity_field) when already done."""
mock_config_manager.get_identity.return_value = {'setup_complete': True}
with patch.dict(os.environ, {'DATA_DIR': str(tmp_path)}):
setup_manager.complete_setup(_valid_payload())
mock_auth_manager.create_user.assert_not_called()
mock_config_manager.set_identity_field.assert_not_called()
def test_complete_setup_returns_error_when_create_user_fails(
setup_manager, mock_config_manager, mock_auth_manager, tmp_path):
mock_config_manager.get_identity.return_value = {}
mock_auth_manager.create_user.return_value = False
with patch.dict(os.environ, {'DATA_DIR': str(tmp_path)}):
result = setup_manager.complete_setup(_valid_payload())
assert result['success'] is False
assert any('admin' in e.lower() or 'user' in e.lower() for e in result['errors'])
# ── get_setup_status ──────────────────────────────────────────────────────────
def test_get_setup_status_returns_complete_key(setup_manager, mock_config_manager):
mock_config_manager.get_identity.return_value = {}
status = setup_manager.get_setup_status()
assert 'complete' in status
assert status['complete'] is False
def test_get_setup_status_complete_reflects_true_when_done(setup_manager, mock_config_manager):
mock_config_manager.get_identity.return_value = {'setup_complete': True}
status = setup_manager.get_setup_status()
assert status['complete'] is True
def test_get_setup_status_contains_available_services(setup_manager, mock_config_manager):
mock_config_manager.get_identity.return_value = {}
status = setup_manager.get_setup_status()
assert 'available_services' in status
assert isinstance(status['available_services'], list)
assert status['available_services'] == AVAILABLE_SERVICES
def test_get_setup_status_contains_available_timezones(setup_manager, mock_config_manager):
mock_config_manager.get_identity.return_value = {}
status = setup_manager.get_setup_status()
assert 'available_timezones' in status
assert isinstance(status['available_timezones'], list)
assert len(status['available_timezones']) > 0
def test_get_setup_status_timezones_includes_utc(setup_manager, mock_config_manager):
mock_config_manager.get_identity.return_value = {}
status = setup_manager.get_setup_status()
assert 'UTC' in status['available_timezones']
def test_get_setup_status_timezones_match_module_constant(setup_manager, mock_config_manager):
mock_config_manager.get_identity.return_value = {}
status = setup_manager.get_setup_status()
assert status['available_timezones'] == AVAILABLE_TIMEZONES