"""Тесты синхронизации против локального 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 = "vault.fmdb" SETUP = {".gitattributes", ".gitignore"} 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 / "data" work.mkdir(parents=True) (work / REL).write_bytes(b"encrypted-v1") settings = GitSettings(remote_url=remote.as_posix(), branch="main") return GitSync(work, REL, settings) @pytest.fixture def settled(sync, remote): """Репозиторий после двух синхронизаций. Первый коммит несёт служебные файлы, поэтому переписывать его нельзя. Дальнейшие коммиты — чистая база, и вот они уже схлопываются. """ sync.sync() write_vault(sync, b"encrypted-v2") sync.sync() return sync 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_files(remote): return set(git(remote, "ls-tree", "-r", "--name-only", "main").split()) 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(settled, remote): result = settled.sync() assert result.committed is False assert result.pushed is False assert len(remote_log(remote)) == 2 def test_data_repo_holds_nothing_but_the_vault(sync, remote): """Ради этого база и вынесена в свой репозиторий. Что бы ни оказалось в папке рядом — в репозиторий данных уезжает только сама база и пара служебных файлов. """ (sync.repo_dir / "main.py").write_text("код программы", encoding="utf-8") (sync.repo_dir / "notes.txt").write_text("черновик", encoding="utf-8") (sync.repo_dir / "app").mkdir() (sync.repo_dir / "app" / "ledger.py").write_text("код", encoding="utf-8") sync.sync() assert remote_files(remote) == {REL} | SETUP def test_setup_files_protect_the_binary(sync, remote): """Без пометки binary git подставил бы CRLF и база перестала бы читаться.""" sync.sync() attributes = git(remote, "show", "main:.gitattributes") assert "vault.fmdb binary" in attributes 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 seed_remote(tmp_path, remote, files: dict[str, bytes]) -> str: """Положить в удалённый репозиторий начальную историю.""" seed = tmp_path / "seed" seed.mkdir() git(seed, "init", "-b", "main") for name, content in files.items(): (seed / name).write_bytes(content) git(seed, "add", "-A") git(seed, "-c", "user.email=s@s", "-c", "user.name=s", "commit", "-m", "начало") git(seed, "push", remote.as_posix(), "main") return git(seed, "rev-parse", "HEAD") def test_first_sync_builds_on_existing_remote_history(sync, remote, tmp_path): """Репозиторий заводят с README — своя параллельная история не годится.""" seeded = seed_remote(tmp_path, remote, {"README.md": b"# food-records\n"}) result = sync.sync() assert result.pushed is True assert remote_vault(remote) == b"encrypted-v1" assert "README.md" in remote_files(remote) # История линейная: наш коммит лёг поверх, а не рядом. log = remote_log(remote) assert len(log) == 2 assert log[-1].startswith(seeded) def test_existing_remote_vault_is_never_silently_replaced(sync, remote, tmp_path): """На сервере уже есть база, локально завели новую — обе настоящие.""" seed_remote(tmp_path, remote, {REL: b"encrypted-on-server"}) copy = sync.repo_dir / "vault.remote.fmdb" with pytest.raises(Diverged): sync.sync(remote_copy_path=copy) assert (sync.repo_dir / REL).read_bytes() == b"encrypted-v1" assert copy.read_bytes() == b"encrypted-on-server" assert remote_vault(remote) == b"encrypted-on-server" # --- схлопывание коммитов по дням ----------------------------------------- def test_same_day_syncs_collapse_into_one_commit(settled, remote): """Ради этого всё и затевалось: не 24 копии файла в сутки, а одна.""" before = len(remote_log(remote)) for i in range(3, 6): write_vault(settled, f"encrypted-v{i}".encode()) assert settled.sync().amended is True assert len(remote_log(remote)) == before assert remote_vault(remote) == b"encrypted-v5" def test_the_setup_commit_is_never_rewritten(sync, remote): """Первая вершина несёт не только базу, поэтому amend по ней запрещён.""" sync.sync() write_vault(sync, b"encrypted-v2") result = sync.sync() assert result.amended is False assert len(remote_log(remote)) == 2 def test_squash_can_be_turned_off(settled, remote): settled.settings.daily_squash = False before = len(remote_log(remote)) write_vault(settled, b"encrypted-v9") assert settled.sync().amended is False assert len(remote_log(remote)) == before + 1 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(settled): settled._run(["commit", "--amend", "-m", "vault 2020-01-01"]) assert settled._can_amend(remote=settled.head_sha()) is False # --- защита от затирания чужой работы ------------------------------------- def clone_and_push(tmp_path, remote, content: bytes) -> str: """Смоделировать вторую машину, которая запушила своё.""" other = tmp_path / "other" git(tmp_path, "clone", remote.as_posix(), "other") (other / REL).write_bytes(content) git(other, "-c", "user.email=o@o", "-c", "user.name=o", "commit", "-am", "vault 2030-01-01") git(other, "push", "origin", "main") return git(other, "rev-parse", "HEAD") def test_stale_lease_is_refused_and_remote_survives(settled, remote, tmp_path): """Ключевая проверка безопасности схлопывания. Между нашим fetch и push другая машина успела запушить своё. Мы уже переписали локальную вершину — force-with-lease обязан это поймать. """ stale = settled.head_sha() theirs = clone_and_push(tmp_path, remote, b"encrypted-from-other-machine") write_vault(settled, b"encrypted-ours") committed, amended, lease = settled._commit(remote=stale) assert (committed, amended, lease) == (True, True, stale) with pytest.raises(Diverged): settled._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(settled, remote, tmp_path): clone_and_push(tmp_path, remote, b"encrypted-from-other-machine") result = settled.sync() assert result.pulled is True assert (settled.repo_dir / REL).read_bytes() == b"encrypted-from-other-machine" def test_edits_on_both_machines_raise_and_keep_both_copies(settled, remote, tmp_path): """Ничего не сливаем и ничего не теряем — решение за пользователем.""" clone_and_push(tmp_path, remote, b"encrypted-from-other-machine") write_vault(settled, b"encrypted-ours") copy = settled.repo_dir / "vault.remote.fmdb" with pytest.raises(Diverged): settled.sync(remote_copy_path=copy) assert (settled.repo_dir / REL).read_bytes() == b"encrypted-ours" assert copy.read_bytes() == b"encrypted-from-other-machine" def test_force_push_overwrites_only_when_asked(settled, remote, tmp_path): clone_and_push(tmp_path, remote, b"encrypted-from-other-machine") write_vault(settled, b"encrypted-ours") settled.force_push() assert remote_vault(remote) == b"encrypted-ours" def test_reset_to_remote_drops_local_changes(settled, remote, tmp_path): clone_and_push(tmp_path, remote, b"encrypted-from-other-machine") write_vault(settled, b"encrypted-ours") settled.reset_to_remote() assert (settled.repo_dir / REL).read_bytes() == b"encrypted-from-other-machine" # --- обращение с токеном -------------------------------------------------- def test_auth_url_injects_token(): url = auth_url("https://gt.ser.gay/kizya/food-records.git", "glpat-secret") assert url == "https://oauth2:glpat-secret@gt.ser.gay/kizya/food-records.git" def test_auth_url_replaces_existing_credentials(): url = auth_url("https://old:creds@gt.ser.gay/kizya/food-records.git", "glpat-new") assert url == "https://oauth2:glpat-new@gt.ser.gay/kizya/food-records.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") def test_default_remote_points_at_the_data_repo(): """Настройки по умолчанию должны вести в репозиторий данных, а не в код.""" assert "food-records" in GitSettings().remote_url