Add tests for command line handling and user detection

Test how command line and config values are combined, the exit codes for an incomplete SSH setup and a bad config, and the user detection under sudo/doas (simulated by patching geteuid and the environment).
This commit is contained in:
Fuxino
2026-10-14 08:10:02 +02:00
parent 41a45dad41
commit dbb0aadc84
2 changed files with 333 additions and 0 deletions
+15
View File
@@ -1,5 +1,8 @@
"""Shared fixtures for the simple_backup tests"""
import sys
from typing import Callable
import pytest
from simple_backup import simple_backup as sb
@@ -13,3 +16,15 @@ def no_side_effects(monkeypatch: pytest.MonkeyPatch) -> None:
sb.logger.removeHandler(sb.j_handler)
monkeypatch.setattr(sb, '_notify', lambda text: None)
@pytest.fixture
def run_cli(monkeypatch: pytest.MonkeyPatch) -> Callable[..., int]:
"""Run simple_backup() with the given command line arguments"""
def run(*argv: str) -> int:
monkeypatch.setattr(sys, 'argv', ['simple_backup', *argv])
return sb.simple_backup()
return run
+318
View File
@@ -0,0 +1,318 @@
"""Tests for combining command line and config values, and for detecting the user"""
import os
import pwd
from pathlib import Path
from typing import Any, Callable, Optional
import pytest
from simple_backup import simple_backup as sb
USER = pwd.getpwuid(os.getuid()).pw_name
HOME = pwd.getpwuid(os.getuid()).pw_dir
UID = os.getuid()
DEFAULT_OPTIONS = '-r -v --ignore-missing-args --mkpath -a -h -H -X -s'
RunCli = Callable[..., int]
class FakeBackup:
"""Records the values simple_backup() passes to Backup"""
instances: list['FakeBackup'] = []
check_code = 0
def __init__(self, inputs: Any, output: Any, exclude: Any, keep: Any, options: str,
ssh_host: Optional[str] = None, ssh_user: Optional[str] = None,
ssh_keyfile: Optional[str] = None, remote_sudo: bool = False,
remove_before: bool = False, verbose: bool = False) -> None:
self.inputs = inputs
self.output = output
self.exclude = exclude
self.keep = keep
self.options = options
self.ssh_host = ssh_host
self.ssh_user = ssh_user
self.ssh_keyfile = ssh_keyfile
self.remote_sudo = remote_sudo
self.remove_before = remove_before
self.verbose = verbose
self.homedir: Optional[str] = None
self.ran = False
self.closed = False
FakeBackup.instances.append(self)
def check_params(self, homedir: str = '') -> int:
self.homedir = homedir
return FakeBackup.check_code
def run(self) -> int:
self.ran = True
return 0
def close(self) -> None:
self.closed = True
@pytest.fixture(autouse=True)
def fake_backup(monkeypatch: pytest.MonkeyPatch) -> None:
FakeBackup.instances = []
FakeBackup.check_code = 0
monkeypatch.setattr(sb, 'Backup', FakeBackup)
@pytest.fixture
def as_user(monkeypatch: pytest.MonkeyPatch) -> None:
"""Run as the current (non-root) user"""
monkeypatch.setattr(os, 'geteuid', lambda: UID if UID != 0 else 1000)
monkeypatch.setenv('USER', USER)
monkeypatch.setenv('HOME', HOME)
@pytest.fixture
def as_root(monkeypatch: pytest.MonkeyPatch) -> None:
"""Run as root, with no sudo/doas variables set"""
monkeypatch.setattr(os, 'geteuid', lambda: 0)
for var in ['SUDO_USER', 'SUDO_UID', 'DOAS_USER']:
monkeypatch.delenv(var, raising=False)
@pytest.fixture
def config_calls(monkeypatch: pytest.MonkeyPatch) -> list[tuple[str, Optional[str]]]:
"""Record the config file path and user instead of reading the file"""
calls: list[tuple[str, Optional[str]]] = []
read_config = sb._read_config
def record(config_file: str, user: Optional[str] = None) -> dict[str, Any]:
calls.append((config_file, user))
return read_config('/nonexistent/simple_backup.conf', user)
monkeypatch.setattr(sb, '_read_config', record)
return calls
@pytest.fixture
def config(tmp_path: Path) -> Callable[[str], str]:
"""Write a config file with existing inputs, followed by the given text"""
(tmp_path / 'data').mkdir()
def write(text: str = '') -> str:
config_file = tmp_path / 'simple_backup.conf'
config_file.write_text(f'[backup]\ninputs={tmp_path}/data\n{text}\n')
return str(config_file)
return write
def last_backup() -> FakeBackup:
assert len(FakeBackup.instances) == 1
return FakeBackup.instances[0]
@pytest.mark.usefixtures('as_user')
class TestValues:
def test_config_values(self, run_cli: RunCli, config: Callable[[str], str], tmp_path: Path) -> None:
config_file = config("""backup_dir=/backups
exclude=*.bak
keep=3
[server]
ssh_host=server
ssh_user=user
ssh_keyfile=/keys/id
remote_sudo=true
numeric_ids=true
""")
assert run_cli('-c', config_file) == 0
b = last_backup()
assert b.inputs == [f'{tmp_path}/data']
assert b.output == '/backups'
assert b.exclude == ['*.bak']
assert b.keep == 3
assert (b.ssh_host, b.ssh_user, b.ssh_keyfile) == ('server', 'user', '/keys/id')
assert b.remote_sudo is True
assert b.options == f'{DEFAULT_OPTIONS} --numeric-ids'
assert b.ran and b.closed
def test_cli_overrides_config(self, run_cli: RunCli, config: Callable[[str], str]) -> None:
config_file = config("""backup_dir=/backups
exclude=*.bak
keep=3
[server]
ssh_host=server
ssh_user=user
ssh_keyfile=/keys/id
""")
assert run_cli('-c', config_file, '-i', '/cli/in', '-o', '/cli/out', '-e', '*.tmp', '-k', '0',
'--ssh-host', 'cli-host', '--ssh-user', 'cli-user', '--keyfile', '/cli/key') == 0
b = last_backup()
assert b.inputs == ['/cli/in']
assert b.output == '/cli/out'
assert b.exclude == ['*.tmp']
assert b.keep == 0
assert (b.ssh_host, b.ssh_user, b.ssh_keyfile) == ('cli-host', 'cli-user', '/cli/key')
def test_ssh_host_and_user_from_cli_and_config(self, run_cli: RunCli, config: Callable[[str], str]) -> None:
config_file = config('backup_dir=/backups\n[server]\nssh_user=user')
assert run_cli('-c', config_file, '--ssh-host', 'server') == 0
assert (last_backup().ssh_host, last_backup().ssh_user) == ('server', 'user')
@pytest.mark.parametrize('server, argv', [
('ssh_host=server', []),
('ssh_user=user', []),
('', ['--ssh-host', 'server']),
('', ['--ssh-user', 'user']),
])
def test_only_one_of_ssh_host_and_user(self, run_cli: RunCli, config: Callable[[str], str],
caplog: pytest.LogCaptureFixture, server: str,
argv: list[str]) -> None:
config_file = config(f'backup_dir=/backups\n[server]\n{server}')
assert run_cli('-c', config_file, *argv) == 6
assert FakeBackup.instances == []
assert 'Both SSH host and SSH user are needed' in caplog.text
def test_bad_config(self, run_cli: RunCli, config: Callable[[str], str],
caplog: pytest.LogCaptureFixture) -> None:
config_file = config('backup_dir=/backups\nkeep=abc')
assert run_cli('-c', config_file) == 6
assert FakeBackup.instances == []
assert 'Bad configuration file' in caplog.text
def test_local_output_expanded(self, run_cli: RunCli, config: Callable[[str], str]) -> None:
assert run_cli('-c', config('backup_dir=~/backups')) == 0
assert last_backup().output == f'{HOME}/backups'
def test_remote_output_not_expanded(self, run_cli: RunCli, config: Callable[[str], str]) -> None:
config_file = config('backup_dir=~/backups\n[server]\nssh_host=server\nssh_user=user')
assert run_cli('-c', config_file) == 0
# Expanded on the server
assert last_backup().output == '~/backups'
@pytest.mark.parametrize('argv, options', [
([], DEFAULT_OPTIONS),
(['--rsync-options', 'a', 'l'], '-r -v --ignore-missing-args --mkpath -a -l'),
(['-s', '-z', '--numeric-ids'], f'{DEFAULT_OPTIONS} -c -z --numeric-ids'),
])
def test_rsync_options(self, run_cli: RunCli, config: Callable[[str], str], argv: list[str],
options: str) -> None:
assert run_cli('-c', config('backup_dir=/backups'), *argv) == 0
assert last_backup().options == options
def test_flags(self, run_cli: RunCli, config: Callable[[str], str]) -> None:
assert run_cli('-c', config('backup_dir=/backups'), '--remote-sudo', '--remove-before-backup', '-v') == 0
b = last_backup()
assert b.remote_sudo and b.remove_before and b.verbose
def test_check_params_failure(self, run_cli: RunCli, config: Callable[[str], str]) -> None:
FakeBackup.check_code = 2
assert run_cli('-c', config('backup_dir=/backups')) == 2
b = last_backup()
assert not b.ran
assert b.closed
class TestUser:
@pytest.mark.usefixtures('as_user')
def test_current_user(self, run_cli: RunCli, config_calls: list[tuple[str, Optional[str]]]) -> None:
assert run_cli() == 0
assert config_calls == [(f'{HOME}/.config/simple_backup/simple_backup.conf', USER)]
assert last_backup().homedir == HOME
@pytest.mark.usefixtures('as_root')
def test_explicit_user(self, run_cli: RunCli, config_calls: list[tuple[str, Optional[str]]]) -> None:
assert run_cli('-u', USER, '-c', '~/other.conf') == 0
assert config_calls == [(f'{HOME}/other.conf', USER)]
assert last_backup().homedir == HOME
@pytest.mark.usefixtures('as_root')
@pytest.mark.parametrize('var', ['SUDO_USER', 'DOAS_USER'])
def test_sudo_or_doas_user(self, run_cli: RunCli, config_calls: list[tuple[str, Optional[str]]],
monkeypatch: pytest.MonkeyPatch, var: str) -> None:
monkeypatch.setenv(var, USER)
assert run_cli() == 0
assert config_calls == [(f'{HOME}/.config/simple_backup/simple_backup.conf', USER)]
assert last_backup().homedir == HOME
@pytest.mark.usefixtures('as_root')
def test_root_without_user(self, run_cli: RunCli, config_calls: list[tuple[str, Optional[str]]],
caplog: pytest.LogCaptureFixture) -> None:
assert run_cli() == 0
# '~' is left to expanduser, i.e. $HOME
assert config_calls[0][1] is None
assert last_backup().homedir == ''
assert 'Failed to detect user' in caplog.text
class TestDetectUser:
@pytest.mark.usefixtures('as_root')
def test_root_prefers_sudo_user(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv('SUDO_USER', 'alice')
monkeypatch.setenv('DOAS_USER', 'bob')
assert sb._detect_user() == 'alice'
@pytest.mark.usefixtures('as_root')
def test_root_doas_user(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv('DOAS_USER', 'bob')
assert sb._detect_user() == 'bob'
@pytest.mark.usefixtures('as_root')
def test_root_no_user(self) -> None:
assert sb._detect_user() is None
@pytest.mark.usefixtures('as_user')
def test_non_root(self) -> None:
assert sb._detect_user() == USER
class TestTargetUid:
@pytest.mark.usefixtures('as_user')
def test_non_root(self) -> None:
assert sb._target_uid() == os.geteuid()
@pytest.mark.usefixtures('as_root')
def test_sudo_uid(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv('SUDO_UID', '1234')
assert sb._target_uid() == 1234
@pytest.mark.usefixtures('as_root')
def test_doas_user(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv('DOAS_USER', USER)
assert sb._target_uid() == UID
@pytest.mark.usefixtures('as_root')
def test_unknown_doas_user(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv('DOAS_USER', 'no-such-user-simple-backup')
assert sb._target_uid() is None
@pytest.mark.usefixtures('as_root')
def test_no_user(self) -> None:
assert sb._target_uid() is None