Десктопное приложение на PySide6. Вся база — один JSON-документ, зашифрованный AES-256-GCM под паролем (ключ через scrypt), лежит в data/vault.fmdb и раз в час уезжает в этот репозиторий. Основное: - партии с дедлайном возврата себестоимости пекарне, FIFO-разнос продаж по партиям и прогресс покрытия к сроку; - типы выбытия: розница, другу по себестоимости, съел сам, подарок, списание — съеденное вычитается из прибыли, за него платить всё равно; - долги контрагентов с частичными оплатами; - номенклатура с историей изменения цен; - журнал изменений внутри базы: git хранит непрозрачные снимки, поэтому настоящая история ведётся здесь. Синхронизация коммитит только путь базы и схлопывает часовые пуши в один коммит на день через amend + force-with-lease. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
283 lines
9.6 KiB
Python
283 lines
9.6 KiB
Python
"""Тесты синхронизации против локального 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")
|