"""Tests for the last backup state file and --last-backup""" import os import pwd from collections.abc import Callable from datetime import datetime, timedelta from pathlib import Path import pytest from simple_backup import simple_backup as sb DESTINATION = '/backups/simple_backup/2026-10-25_10-00-00' @pytest.fixture(autouse=True) def as_user(monkeypatch: pytest.MonkeyPatch) -> None: """Run as a non-root user (the tests may run as root in a chroot)""" monkeypatch.setattr(os, 'geteuid', lambda: 1000) monkeypatch.setenv('USER', 'user') @pytest.fixture def state(tmp_path: Path) -> Path: return tmp_path / 'state' / 'simple_backup' / 'last_backup' def write_state(state: Path, text: str) -> None: state.parent.mkdir(parents=True) state.write_text(text) def test_write(state: Path) -> None: sb._write_state(None, DESTINATION) time, destination = state.read_text().splitlines() assert abs(datetime.now().astimezone() - datetime.fromisoformat(time)) < timedelta(minutes=1) assert destination == DESTINATION # No temporary file left behind assert os.listdir(state.parent) == ['last_backup'] def test_overwrite(state: Path) -> None: write_state(state, '2026-10-01T10:00:00+02:00\n/old\n') sb._write_state(None, DESTINATION) assert state.read_text().splitlines()[1] == DESTINATION def test_relative_xdg_state_home_ignored(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: monkeypatch.setenv('XDG_STATE_HOME', 'relative') monkeypatch.setenv('HOME', str(tmp_path / 'home')) sb._write_state(None, DESTINATION) assert (tmp_path / 'home' / '.local' / 'state' / 'simple_backup' / 'last_backup').exists() def test_write_error(monkeypatch: pytest.MonkeyPatch, tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None: # A file where the state directory should be (tmp_path / 'not a dir').write_text('') monkeypatch.setenv('XDG_STATE_HOME', str(tmp_path / 'not a dir')) sb._write_state(None, DESTINATION) assert 'Cannot save the time of the last backup' in caplog.text def test_root_writes_as_user(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: calls: list[tuple[str, int]] = [] egid = os.getegid() owner = pwd.struct_passwd(('user', 'x', 1000, 1001, '', str(tmp_path / 'home'), '/bin/sh')) monkeypatch.setattr(os, 'geteuid', lambda: 0) monkeypatch.setattr(pwd, 'getpwnam', lambda name: owner) monkeypatch.setattr(os, 'seteuid', lambda uid: calls.append(('euid', uid))) monkeypatch.setattr(os, 'setegid', lambda gid: calls.append(('egid', gid))) sb._write_state('user', DESTINATION) # Root's XDG_STATE_HOME is not used assert (tmp_path / 'home' / '.local' / 'state' / 'simple_backup' / 'last_backup').exists() assert calls == [('egid', 1001), ('euid', 1000), ('euid', 0), ('egid', egid)] class TestStateOwner: def test_non_root(self) -> None: assert sb._state_owner('user') is None @pytest.mark.parametrize('name, expected', [('user', 'user'), ('root', None), ('unknown', None), (None, None)]) def test_root(self, monkeypatch: pytest.MonkeyPatch, name: str | None, expected: str | None) -> None: users = {'user': pwd.struct_passwd(('user', 'x', 1000, 1000, '', '/home/user', '/bin/sh')), 'root': pwd.struct_passwd(('root', 'x', 0, 0, '', '/root', '/bin/sh'))} def getpwnam(name: str) -> pwd.struct_passwd: if name not in users: raise KeyError(name) return users[name] monkeypatch.setattr(os, 'geteuid', lambda: 0) monkeypatch.setattr(pwd, 'getpwnam', getpwnam) owner = sb._state_owner(name) assert (owner.pw_name if owner is not None else None) == expected class TestShow: @pytest.mark.parametrize('days, age', [(0, 'today'), (1, 'yesterday'), (3, '3 days ago')]) def test_age(self, run_cli: Callable[..., int], state: Path, capsys: pytest.CaptureFixture[str], days: int, age: str) -> None: completed = (datetime.now() - timedelta(days=days)).replace(hour=12, minute=30).astimezone() write_state(state, f'{completed.isoformat(timespec="seconds")}\n{DESTINATION}\n') assert run_cli('--last-backup') == 0 assert capsys.readouterr().out == \ f'Last backup: {completed:%Y-%m-%d} 12:30 ({age})\nDirectory: {DESTINATION}\n' def test_after_write(self, run_cli: Callable[..., int], capsys: pytest.CaptureFixture[str]) -> None: sb._write_state(None, DESTINATION) assert run_cli('--last-backup') == 0 assert '(today)' in capsys.readouterr().out def test_no_backup(self, run_cli: Callable[..., int], capsys: pytest.CaptureFixture[str]) -> None: assert run_cli('--last-backup') == 1 assert capsys.readouterr().out == 'No backup recorded yet\n' @pytest.mark.parametrize('text', ['', '2026-10-25T10:00:00+02:00\n', 'yesterday\n/backups\n']) def test_bad_file(self, run_cli: Callable[..., int], state: Path, capsys: pytest.CaptureFixture[str], text: str) -> None: write_state(state, text) assert run_cli('--last-backup') == 1 assert 'Cannot read' in capsys.readouterr().err