diff --git a/tests/conftest.py b/tests/conftest.py index 64d3123..947f455 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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 diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..a275fcc --- /dev/null +++ b/tests/test_cli.py @@ -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