Write the completion time and backup directory to $XDG_STATE_HOME/simple_backup/last_backup after each successful backup (as the target user when run as root), for status bar reminders. --last-backup prints it.
138 lines
5.1 KiB
Python
138 lines
5.1 KiB
Python
"""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
|