"""Тесты синхронизации против локального bare-репозитория — без сети.""" import subprocess import pytest from app import gitsync from app.gitsync import Diverged, GitSync, auth_url, commit_message, scrub from app.models import GitSettings pytestmark = pytest.mark.skipif( gitsync.git_executable() is None, reason="git не установлен" ) REL = "data/vault.fmdb" def git(cwd, *args): proc = subprocess.run( ["git", *args], cwd=cwd, capture_output=True, text=True, encoding="utf-8" ) assert proc.returncode == 0, proc.stderr return proc.stdout.strip() @pytest.fixture def remote(tmp_path): path = tmp_path / "remote.git" path.mkdir() git(path, "init", "--bare", "-b", "main") return path @pytest.fixture def sync(tmp_path, remote): work = tmp_path / "work" (work / "data").mkdir(parents=True) (work / "data" / "vault.fmdb").write_bytes(b"encrypted-v1") settings = GitSettings(remote_url=remote.as_posix(), branch="main") return GitSync(work, REL, settings) def write_vault(sync, content: bytes): (sync.repo_dir / REL).write_bytes(content) def remote_log(remote): return git(remote, "log", "--format=%H %s").splitlines() def remote_vault(remote): proc = subprocess.run( ["git", "show", f"main:{REL}"], cwd=remote, capture_output=True ) assert proc.returncode == 0, proc.stderr return proc.stdout # --- базовый цикл --------------------------------------------------------- def test_first_sync_creates_repo_and_pushes(sync, remote): result = sync.sync() assert result.committed and result.pushed assert remote_vault(remote) == b"encrypted-v1" assert len(remote_log(remote)) == 1 def test_sync_without_changes_does_nothing(sync, remote): sync.sync() result = sync.sync() assert result.committed is False assert result.pushed is False assert len(remote_log(remote)) == 1 def test_commit_touches_only_the_vault(sync, remote): """Незакоммиченные правки исходников не должны уезжать вместе с данными.""" (sync.repo_dir / "app.py").write_text("print('работа в процессе')", encoding="utf-8") (sync.repo_dir / "notes.txt").write_text("черновик", encoding="utf-8") sync.sync() files = git(remote, "show", "--name-only", "--format=", "main").split() assert files == [REL] def test_commit_message_leaks_no_business_numbers(sync, remote): sync.sync() subject = remote_log(remote)[0].split(" ", 1)[1] assert subject == commit_message() assert subject.startswith("vault ") # --- схлопывание коммитов по дням ----------------------------------------- def test_same_day_syncs_collapse_into_one_commit(sync, remote): """Ради этого всё и затевалось: не 24 копии файла в сутки, а одна.""" sync.sync() for i in range(2, 5): write_vault(sync, f"encrypted-v{i}".encode()) result = sync.sync() assert result.amended is True assert len(remote_log(remote)) == 1 assert remote_vault(remote) == b"encrypted-v4" def test_squash_can_be_turned_off(sync, remote): sync.settings.daily_squash = False sync.sync() write_vault(sync, b"encrypted-v2") result = sync.sync() assert result.amended is False assert len(remote_log(remote)) == 2 def test_no_amend_when_head_is_not_on_the_server(sync): """Переписывать можно только ту вершину, которая точно наша и уже запушена.""" sync.ensure_repo() sync._run(["add", "--", REL]) sync._run(["commit", "-m", commit_message(), "--", REL]) assert sync._can_amend(remote=None) is False assert sync._can_amend(remote="0" * 40) is False def test_no_amend_over_a_foreign_commit(sync): """Чужой коммит не наш формат — трогать его нельзя.""" sync.ensure_repo() (sync.repo_dir / "readme.md").write_text("привет", encoding="utf-8") sync._run(["add", "-A"]) sync._run(["commit", "-m", "правки руками"]) assert sync._can_amend(remote=sync.head_sha()) is False def test_no_amend_over_a_commit_touching_other_files(sync): """Amend утащил бы чужие изменения из той же вершины.""" sync.ensure_repo() (sync.repo_dir / "readme.md").write_text("привет", encoding="utf-8") sync._run(["add", "-A"]) sync._run(["commit", "-m", commit_message()]) assert sync._can_amend(remote=sync.head_sha()) is False def test_yesterdays_commit_is_not_amended(sync, remote): sync.sync() sync._run(["commit", "--amend", "-m", "vault 2020-01-01"]) assert sync._can_amend(remote=sync.head_sha()) is False # --- защита от затирания чужой работы ------------------------------------- def test_stale_lease_is_refused_and_remote_survives(sync, remote, tmp_path): """Ключевая проверка безопасности схлопывания. Между нашим fetch и push другая машина успела запушить своё. Мы уже переписали локальную вершину — force-with-lease обязан это поймать. """ sync.sync() stale = sync.head_sha() other = tmp_path / "other" git(tmp_path, "clone", remote.as_posix(), "other") (other / "data" / "vault.fmdb").write_bytes(b"encrypted-from-other-machine") git(other, "-c", "user.email=o@o", "-c", "user.name=o", "commit", "-am", "vault 2030-01-01") git(other, "push", "origin", "main") theirs = git(other, "rev-parse", "HEAD") write_vault(sync, b"encrypted-ours") committed, amended, lease = sync._commit(remote=stale) assert (committed, amended, lease) == (True, True, stale) with pytest.raises(Diverged): sync._push(amended=True, lease=lease) # Чужой коммит на месте, наш форс его не снёс. assert git(remote, "rev-parse", "main") == theirs assert remote_vault(remote) == b"encrypted-from-other-machine" def test_remote_ahead_and_clean_fast_forwards(sync, remote, tmp_path): sync.sync() other = tmp_path / "other" git(tmp_path, "clone", remote.as_posix(), "other") (other / "data" / "vault.fmdb").write_bytes(b"encrypted-from-other-machine") git(other, "-c", "user.email=o@o", "-c", "user.name=o", "commit", "-am", "vault 2030-01-01") git(other, "push", "origin", "main") result = sync.sync() assert result.pulled is True assert (sync.repo_dir / REL).read_bytes() == b"encrypted-from-other-machine" def test_edits_on_both_machines_raise_and_keep_both_copies(sync, remote, tmp_path): """Ничего не сливаем и ничего не теряем — решение за пользователем.""" sync.sync() other = tmp_path / "other" git(tmp_path, "clone", remote.as_posix(), "other") (other / "data" / "vault.fmdb").write_bytes(b"encrypted-from-other-machine") git(other, "-c", "user.email=o@o", "-c", "user.name=o", "commit", "-am", "vault 2030-01-01") git(other, "push", "origin", "main") write_vault(sync, b"encrypted-ours") copy = sync.repo_dir / "data" / "vault.remote.fmdb" with pytest.raises(Diverged): sync.sync(remote_copy_path=copy) assert (sync.repo_dir / REL).read_bytes() == b"encrypted-ours" assert copy.read_bytes() == b"encrypted-from-other-machine" # --- обращение с токеном -------------------------------------------------- def test_auth_url_injects_token(): url = auth_url("https://gt.ser.gay/kizya/food-market.git", "glpat-secret") assert url == "https://oauth2:glpat-secret@gt.ser.gay/kizya/food-market.git" def test_auth_url_replaces_existing_credentials(): url = auth_url("https://old:creds@gt.ser.gay/kizya/food-market.git", "glpat-new") assert url == "https://oauth2:glpat-new@gt.ser.gay/kizya/food-market.git" def test_auth_url_escapes_special_characters(): url = auth_url("https://gt.ser.gay/x.git", "a/b@c:d") assert "a%2Fb%40c%3Ad" in url assert url.count("@") == 1 def test_auth_url_left_alone_for_local_paths(): assert auth_url("/srv/repo.git", "glpat-secret") == "/srv/repo.git" def test_token_is_not_written_into_git_config(sync): sync.settings.token = "glpat-secret" sync.ensure_repo() config = (sync.repo_dir / ".git" / "config").read_text(encoding="utf-8") assert "glpat-secret" not in config def test_scrub_hides_token_in_messages(): assert scrub("fatal: https://oauth2:glpat-x@host", "glpat-x") == "fatal: https://oauth2:***@host" def test_errors_do_not_leak_the_token(sync): sync.settings.remote_url = "https://127.0.0.1:1/nope.git" sync.settings.token = "glpat-supersecret" with pytest.raises(gitsync.GitError) as exc: sync.sync() assert "glpat-supersecret" not in str(exc.value) # --- прочее --------------------------------------------------------------- def test_sync_without_remote_is_a_clear_error(sync): sync.settings.remote_url = "" with pytest.raises(gitsync.GitError, match="адрес репозитория"): sync.sync() def test_ensure_repo_is_idempotent(sync): sync.ensure_repo() sync.ensure_repo() assert sync.is_repo() assert git(sync.repo_dir, "remote", "get-url", "origin") def test_ensure_repo_updates_a_changed_remote(sync, tmp_path): sync.ensure_repo() sync.settings.remote_url = (tmp_path / "elsewhere.git").as_posix() sync.ensure_repo() assert git(sync.repo_dir, "remote", "get-url", "origin").endswith("elsewhere.git")