#!/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