2d842abe5b
Unit Tests / test (push) Successful in 15m39s
Reverts 8d1ef39. The installer must collect cell name, domain mode, and
provider tokens before 'make install' so that DDNS registration,
availability checks, and Caddy TLS can be configured at first boot.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
380 lines
15 KiB
Python
380 lines
15 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
|
|
mock_auth_manager.update_password.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 'password' 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
|
|
|
|
|
|
def test_get_setup_status_preconfigured_empty_when_identity_blank(setup_manager, mock_config_manager):
|
|
mock_config_manager.get_identity.return_value = {}
|
|
status = setup_manager.get_setup_status()
|
|
assert status['preconfigured'] == {}
|
|
|
|
|
|
def test_get_setup_status_preconfigured_returns_installer_values(setup_manager, mock_config_manager):
|
|
mock_config_manager.get_identity.return_value = {
|
|
'cell_name': 'myhome',
|
|
'domain_mode': 'pic_ngo',
|
|
'domain_name': 'myhome.pic.ngo',
|
|
}
|
|
status = setup_manager.get_setup_status()
|
|
pre = status['preconfigured']
|
|
assert pre['cell_name'] == 'myhome'
|
|
assert pre['domain_mode'] == 'pic_ngo'
|
|
assert pre['domain_name'] == 'myhome.pic.ngo'
|
|
assert 'cloudflare_api_token' not in pre
|
|
|
|
|
|
# ── _build_ddns_config ────────────────────────────────────────────────────────
|
|
|
|
from setup_manager import _build_ddns_config
|
|
|
|
|
|
def test_build_ddns_config_pic_ngo():
|
|
cfg = _build_ddns_config('pic_ngo')
|
|
assert cfg['provider'] == 'pic_ngo'
|
|
assert cfg['enabled'] is True
|
|
|
|
|
|
def test_build_ddns_config_cloudflare_includes_token():
|
|
cfg = _build_ddns_config('cloudflare', cloudflare_api_token='tok123')
|
|
assert cfg['provider'] == 'cloudflare'
|
|
assert cfg['api_token'] == 'tok123'
|
|
|
|
|
|
def test_build_ddns_config_duckdns_includes_token_and_subdomain():
|
|
cfg = _build_ddns_config('duckdns', duckdns_token='duck', duckdns_subdomain='myhome')
|
|
assert cfg['provider'] == 'duckdns'
|
|
assert cfg['token'] == 'duck'
|
|
assert cfg['subdomain'] == 'myhome'
|
|
|
|
|
|
def test_build_ddns_config_lan_disabled():
|
|
cfg = _build_ddns_config('lan')
|
|
assert cfg['provider'] == 'none'
|
|
assert cfg['enabled'] is False
|
|
|
|
|
|
# ── ddns config written on complete_setup ─────────────────────────────────────
|
|
|
|
def test_complete_setup_writes_ddns_config_section(
|
|
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(domain_mode='lan'))
|
|
mock_config_manager.set_ddns_config.assert_called_once()
|
|
ddns_arg = mock_config_manager.set_ddns_config.call_args[0][0]
|
|
assert ddns_arg['provider'] == 'none'
|
|
|
|
|
|
def test_complete_setup_writes_cloudflare_ddns_config(
|
|
setup_manager, mock_config_manager, mock_auth_manager, tmp_path):
|
|
mock_config_manager.get_identity.return_value = {}
|
|
payload = _valid_payload(
|
|
domain_mode='cloudflare',
|
|
domain_name='home.example.com',
|
|
cloudflare_api_token='cf-token-xyz',
|
|
)
|
|
with patch.dict(os.environ, {'DATA_DIR': str(tmp_path)}):
|
|
setup_manager.complete_setup(payload)
|
|
ddns_arg = mock_config_manager.set_ddns_config.call_args[0][0]
|
|
assert ddns_arg['provider'] == 'cloudflare'
|
|
assert ddns_arg['api_token'] == 'cf-token-xyz'
|