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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user