367 lines
12 KiB
Python
367 lines
12 KiB
Python
"""Tests for FileSyncManager — mtime tracking, deletion detection, transactional rollback."""
|
|
|
|
import io
|
|
import os
|
|
import tarfile
|
|
import time
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from tools.environments.file_sync import FileSyncManager, _FORCE_SYNC_ENV, iter_sync_files
|
|
|
|
|
|
@pytest.fixture
|
|
def tmp_files(tmp_path):
|
|
"""Create a few temp files to use as sync sources."""
|
|
files = {}
|
|
for name in ("cred_a.json", "cred_b.json", "skill_main.py"):
|
|
p = tmp_path / name
|
|
p.write_text(f"content of {name}")
|
|
files[name] = str(p)
|
|
return files
|
|
|
|
|
|
def _make_get_files(tmp_files, remote_base="/root/.hermes"):
|
|
"""Return a get_files_fn that maps local files to remote paths."""
|
|
mapping = [(hp, f"{remote_base}/{name}") for name, hp in tmp_files.items()]
|
|
|
|
def get_files():
|
|
return [(hp, rp) for hp, rp in mapping if Path(hp).exists()]
|
|
|
|
return get_files
|
|
|
|
|
|
def _make_manager(tmp_files, remote_base="/root/.hermes", upload=None, delete=None):
|
|
"""Create a FileSyncManager with test callbacks."""
|
|
return FileSyncManager(
|
|
get_files_fn=_make_get_files(tmp_files, remote_base),
|
|
upload_fn=upload or MagicMock(),
|
|
delete_fn=delete or MagicMock(),
|
|
)
|
|
|
|
|
|
class TestMtimeSkip:
|
|
def test_unchanged_files_not_re_uploaded(self, tmp_files):
|
|
upload = MagicMock()
|
|
mgr = _make_manager(tmp_files, upload=upload)
|
|
|
|
mgr.sync(force=True)
|
|
assert upload.call_count == 3
|
|
|
|
upload.reset_mock()
|
|
mgr.sync(force=True)
|
|
assert upload.call_count == 0, "unchanged files should not be re-uploaded"
|
|
|
|
def test_changed_file_re_uploaded(self, tmp_files):
|
|
upload = MagicMock()
|
|
mgr = _make_manager(tmp_files, upload=upload)
|
|
|
|
mgr.sync(force=True)
|
|
upload.reset_mock()
|
|
|
|
# Touch one file
|
|
time.sleep(0.05)
|
|
Path(tmp_files["cred_a.json"]).write_text("updated content")
|
|
|
|
mgr.sync(force=True)
|
|
assert upload.call_count == 1
|
|
assert tmp_files["cred_a.json"] in upload.call_args[0][0]
|
|
|
|
def test_new_file_detected(self, tmp_files, tmp_path):
|
|
upload = MagicMock()
|
|
mgr = FileSyncManager(
|
|
get_files_fn=_make_get_files(tmp_files),
|
|
upload_fn=upload,
|
|
delete_fn=MagicMock(),
|
|
)
|
|
|
|
mgr.sync(force=True)
|
|
assert upload.call_count == 3
|
|
|
|
# Add a new file
|
|
new_file = tmp_path / "new_skill.py"
|
|
new_file.write_text("new content")
|
|
tmp_files["new_skill.py"] = str(new_file)
|
|
# Recreate manager with updated file list
|
|
mgr._get_files_fn = _make_get_files(tmp_files)
|
|
|
|
upload.reset_mock()
|
|
mgr.sync(force=True)
|
|
assert upload.call_count == 1
|
|
|
|
|
|
class TestDeletion:
|
|
def test_removed_file_triggers_delete(self, tmp_files):
|
|
upload = MagicMock()
|
|
delete = MagicMock()
|
|
mgr = _make_manager(tmp_files, upload=upload, delete=delete)
|
|
|
|
mgr.sync(force=True)
|
|
delete.assert_not_called()
|
|
|
|
# Remove a file locally
|
|
os.unlink(tmp_files["cred_b.json"])
|
|
del tmp_files["cred_b.json"]
|
|
mgr._get_files_fn = _make_get_files(tmp_files)
|
|
|
|
mgr.sync(force=True)
|
|
delete.assert_called_once()
|
|
deleted_paths = delete.call_args[0][0]
|
|
assert any("cred_b.json" in p for p in deleted_paths)
|
|
|
|
def test_no_delete_when_no_removals(self, tmp_files):
|
|
delete = MagicMock()
|
|
mgr = _make_manager(tmp_files, delete=delete)
|
|
|
|
mgr.sync(force=True)
|
|
mgr.sync(force=True)
|
|
delete.assert_not_called()
|
|
|
|
|
|
class TestTransactionalRollback:
|
|
def test_upload_failure_rolls_back(self, tmp_files):
|
|
call_count = 0
|
|
|
|
def failing_upload(host_path, remote_path):
|
|
nonlocal call_count
|
|
call_count += 1
|
|
if call_count == 2:
|
|
raise RuntimeError("upload failed")
|
|
|
|
mgr = _make_manager(tmp_files, upload=failing_upload)
|
|
|
|
# First sync fails (swallowed, logged, state rolled back)
|
|
mgr.sync(force=True)
|
|
|
|
# State should be empty (rolled back) — next sync retries all files
|
|
good_upload = MagicMock()
|
|
mgr._upload_fn = good_upload
|
|
mgr.sync(force=True)
|
|
assert good_upload.call_count == 3, "all files should be retried after rollback"
|
|
|
|
def test_delete_failure_rolls_back(self, tmp_files):
|
|
upload = MagicMock()
|
|
mgr = _make_manager(tmp_files, upload=upload)
|
|
|
|
# Initial sync
|
|
mgr.sync(force=True)
|
|
|
|
# Remove a file
|
|
os.unlink(tmp_files["skill_main.py"])
|
|
del tmp_files["skill_main.py"]
|
|
mgr._get_files_fn = _make_get_files(tmp_files)
|
|
|
|
# Delete fails (swallowed, state rolled back)
|
|
mgr._delete_fn = MagicMock(side_effect=RuntimeError("delete failed"))
|
|
mgr.sync(force=True)
|
|
|
|
# Next sync should retry the delete
|
|
good_delete = MagicMock()
|
|
mgr._delete_fn = good_delete
|
|
upload.reset_mock()
|
|
mgr.sync(force=True)
|
|
good_delete.assert_called_once()
|
|
|
|
|
|
class TestRateLimiting:
|
|
def test_sync_skipped_within_interval(self, tmp_files):
|
|
upload = MagicMock()
|
|
mgr = FileSyncManager(
|
|
get_files_fn=_make_get_files(tmp_files),
|
|
upload_fn=upload,
|
|
delete_fn=MagicMock(),
|
|
sync_interval=10.0,
|
|
)
|
|
|
|
mgr.sync(force=True)
|
|
assert upload.call_count == 3
|
|
|
|
upload.reset_mock()
|
|
# Without force, should skip due to rate limit
|
|
mgr.sync()
|
|
assert upload.call_count == 0
|
|
|
|
def test_force_bypasses_rate_limit(self, tmp_files, tmp_path):
|
|
upload = MagicMock()
|
|
mgr = FileSyncManager(
|
|
get_files_fn=_make_get_files(tmp_files),
|
|
upload_fn=upload,
|
|
delete_fn=MagicMock(),
|
|
sync_interval=10.0,
|
|
)
|
|
|
|
mgr.sync(force=True)
|
|
upload.reset_mock()
|
|
|
|
# Add a new file and force sync
|
|
new_file = tmp_path / "forced.txt"
|
|
new_file.write_text("forced")
|
|
tmp_files["forced.txt"] = str(new_file)
|
|
mgr._get_files_fn = _make_get_files(tmp_files)
|
|
|
|
mgr.sync(force=True)
|
|
assert upload.call_count == 1
|
|
|
|
def test_env_var_forces_sync(self, tmp_files, tmp_path):
|
|
upload = MagicMock()
|
|
mgr = FileSyncManager(
|
|
get_files_fn=_make_get_files(tmp_files),
|
|
upload_fn=upload,
|
|
delete_fn=MagicMock(),
|
|
sync_interval=10.0,
|
|
)
|
|
|
|
mgr.sync(force=True)
|
|
upload.reset_mock()
|
|
|
|
new_file = tmp_path / "env_forced.txt"
|
|
new_file.write_text("env forced")
|
|
tmp_files["env_forced.txt"] = str(new_file)
|
|
mgr._get_files_fn = _make_get_files(tmp_files)
|
|
|
|
with patch.dict(os.environ, {_FORCE_SYNC_ENV: "1"}):
|
|
mgr.sync()
|
|
assert upload.call_count == 1
|
|
|
|
|
|
class TestEdgeCases:
|
|
def test_empty_file_list(self):
|
|
upload = MagicMock()
|
|
delete = MagicMock()
|
|
mgr = FileSyncManager(
|
|
get_files_fn=lambda: [],
|
|
upload_fn=upload,
|
|
delete_fn=delete,
|
|
)
|
|
|
|
mgr.sync(force=True)
|
|
upload.assert_not_called()
|
|
delete.assert_not_called()
|
|
|
|
def test_file_disappears_between_list_and_upload(self, tmp_path):
|
|
"""File listed by get_files but deleted before _file_mtime_key reads it."""
|
|
f = tmp_path / "ephemeral.txt"
|
|
f.write_text("here now")
|
|
|
|
upload = MagicMock()
|
|
mgr = FileSyncManager(
|
|
get_files_fn=lambda: [(str(f), "/root/.hermes/ephemeral.txt")],
|
|
upload_fn=upload,
|
|
delete_fn=MagicMock(),
|
|
)
|
|
|
|
# Delete the file before sync can stat it
|
|
os.unlink(str(f))
|
|
|
|
mgr.sync(force=True)
|
|
upload.assert_not_called() # _file_mtime_key returns None, skipped
|
|
|
|
|
|
class TestSyncBackSecurity:
|
|
def test_sync_back_does_not_overwrite_uploaded_credential_files(self, tmp_path, monkeypatch):
|
|
credential = tmp_path / "token.json"
|
|
credential.write_text("host-token", encoding="utf-8")
|
|
skill = tmp_path / "skill.py"
|
|
skill.write_text("host-skill", encoding="utf-8")
|
|
|
|
monkeypatch.setattr(
|
|
"tools.credential_files.get_credential_file_mounts",
|
|
lambda: [
|
|
{
|
|
"host_path": str(credential),
|
|
"container_path": "/root/.hermes/credentials/token.json",
|
|
}
|
|
],
|
|
)
|
|
monkeypatch.setattr(
|
|
"tools.credential_files.iter_skills_files",
|
|
lambda container_base="/root/.hermes": [
|
|
{
|
|
"host_path": str(skill),
|
|
"container_path": f"{container_base}/skills/skill.py",
|
|
}
|
|
],
|
|
)
|
|
monkeypatch.setattr(
|
|
"tools.credential_files.iter_cache_files",
|
|
lambda container_base="/root/.hermes": [],
|
|
)
|
|
|
|
def bulk_download(dest: Path) -> None:
|
|
with tarfile.open(dest, "w") as tar:
|
|
for name, data in {
|
|
"root/.hermes/credentials/token.json": b"remote-token",
|
|
"root/.hermes/skills/skill.py": b"remote-skill",
|
|
}.items():
|
|
info = tarfile.TarInfo(name)
|
|
info.size = len(data)
|
|
tar.addfile(info, io.BytesIO(data))
|
|
|
|
mgr = FileSyncManager(
|
|
get_files_fn=lambda: iter_sync_files("/root/.hermes"),
|
|
upload_fn=MagicMock(),
|
|
delete_fn=MagicMock(),
|
|
bulk_download_fn=bulk_download,
|
|
)
|
|
|
|
mgr.sync(force=True)
|
|
mgr.sync_back(hermes_home=tmp_path)
|
|
|
|
assert credential.read_text(encoding="utf-8") == "host-token"
|
|
assert skill.read_text(encoding="utf-8") == "remote-skill"
|
|
|
|
|
|
class TestBulkUpload:
|
|
"""Tests for the optional bulk_upload_fn callback."""
|
|
|
|
def test_bulk_upload_used_when_provided(self, tmp_files):
|
|
"""When bulk_upload_fn is set, it's called instead of per-file upload_fn."""
|
|
upload = MagicMock()
|
|
bulk_upload = MagicMock()
|
|
mgr = FileSyncManager(
|
|
get_files_fn=_make_get_files(tmp_files),
|
|
upload_fn=upload,
|
|
delete_fn=MagicMock(),
|
|
bulk_upload_fn=bulk_upload,
|
|
)
|
|
|
|
mgr.sync(force=True)
|
|
upload.assert_not_called()
|
|
bulk_upload.assert_called_once()
|
|
# All 3 files passed as a list of (host, remote) tuples
|
|
files_arg = bulk_upload.call_args[0][0]
|
|
assert len(files_arg) == 3
|
|
|
|
def test_fallback_to_upload_fn_when_no_bulk(self, tmp_files):
|
|
"""Without bulk_upload_fn, per-file upload_fn is used (backwards compat)."""
|
|
upload = MagicMock()
|
|
mgr = FileSyncManager(
|
|
get_files_fn=_make_get_files(tmp_files),
|
|
upload_fn=upload,
|
|
delete_fn=MagicMock(),
|
|
bulk_upload_fn=None,
|
|
)
|
|
|
|
mgr.sync(force=True)
|
|
assert upload.call_count == 3
|
|
|
|
def test_bulk_upload_rollback_on_failure(self, tmp_files):
|
|
"""Bulk upload failure rolls back synced state so next sync retries."""
|
|
bulk_upload = MagicMock(side_effect=RuntimeError("upload failed"))
|
|
mgr = FileSyncManager(
|
|
get_files_fn=_make_get_files(tmp_files),
|
|
upload_fn=MagicMock(),
|
|
delete_fn=MagicMock(),
|
|
bulk_upload_fn=bulk_upload,
|
|
)
|
|
|
|
mgr.sync(force=True) # fails, should rollback
|
|
|
|
# State rolled back: next sync should retry all files
|
|
bulk_upload.side_effect = None
|
|
bulk_upload.reset_mock()
|
|
mgr.sync(force=True)
|
|
bulk_upload.assert_called_once()
|
|
assert len(bulk_upload.call_args[0][0]) == 3
|