Phase 1: first-run setup wizard, bash installer, Docker profiles
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,301 @@
|
||||
#!/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
|
||||
Reference in New Issue
Block a user