main
py 253 lines 8.16 KB
Raw
1 import json
2 import shutil
3 import zipfile
4 from pathlib import Path
5
6 import pytest
7 from dotenv import dotenv_values
8
9 from helpers import dotenv
10 from helpers.backup import BackupService
11
12
13 class UploadedBackup:
14 def __init__(self, path: Path):
15 self.path = path
16
17 def save(self, target: str) -> None:
18 shutil.copyfile(self.path, target)
19
20
21 @pytest.mark.asyncio
22 async def test_default_backup_patterns_exclude_time_travel_history(tmp_path):
23 root = tmp_path / "a0"
24 usr = root / "usr"
25 time_travel = usr / ".time_travel" / "workspaces" / "demo" / "repo.git"
26 time_travel.mkdir(parents=True)
27 (usr / "settings.json").write_text('{"ok": true}\n', encoding="utf-8")
28 (time_travel / "objects.pack").write_text("history\n", encoding="utf-8")
29
30 service = BackupService()
31 service.agent_zero_root = str(root)
32 service.base_paths = {str(root): str(root)}
33 metadata = service.get_default_backup_metadata()
34
35 files = await service.test_patterns(metadata, max_files=None)
36 paths = {item["real_path"] for item in files}
37
38 assert str(usr / "settings.json") in paths
39 assert str(time_travel / "objects.pack") not in paths
40 assert f"{root}/usr/.time_travel/**" in metadata["exclude_patterns"]
41
42
43 @pytest.mark.parametrize(
44 ("backup_credentials", "expected_credentials"),
45 [
46 (
47 "AUTH_LOGIN=backup\nAUTH_PASSWORD=backup-password\n",
48 {"AUTH_LOGIN": "backup", "AUTH_PASSWORD": "backup-password"},
49 ),
50 (
51 "AUTH_LOGIN=\nAUTH_PASSWORD=\n",
52 {"AUTH_LOGIN": "", "AUTH_PASSWORD": ""},
53 ),
54 ("", {}),
55 ],
56 ids=("credentials", "blank-credentials", "missing-credentials"),
57 )
58 @pytest.mark.asyncio
59 async def test_restore_preserves_destination_origin_and_restores_backup_credentials(
60 tmp_path, monkeypatch, backup_credentials, expected_credentials
61 ):
62 old_root = "/old-a0"
63 destination_root = tmp_path / "a0"
64 destination_env = destination_root / "usr" / ".env"
65 destination_env.parent.mkdir(parents=True)
66 destination_env.write_text(
67 "AUTH_LOGIN=current\n"
68 "AUTH_PASSWORD=current-password\n"
69 "ALLOWED_ORIGINS=http://current.example\n",
70 encoding="utf-8",
71 )
72 monkeypatch.setenv("AUTH_LOGIN", "current")
73 monkeypatch.setenv("AUTH_PASSWORD", "current-password")
74 monkeypatch.setenv("ALLOWED_ORIGINS", "http://current.example")
75 monkeypatch.setattr(dotenv, "get_dotenv_file_path", lambda: str(destination_env))
76 monkeypatch.setattr(
77 dotenv,
78 "load_dotenv",
79 lambda: pytest.fail("restore must not reload unrelated environment values"),
80 )
81
82 zip_path = tmp_path / "backup.zip"
83 metadata = {
84 "environment_info": {"agent_zero_root": old_root},
85 "include_patterns": [f"{old_root}/usr/**"],
86 "exclude_patterns": [],
87 "include_hidden": True,
88 }
89 with zipfile.ZipFile(zip_path, "w") as archive:
90 archive.writestr("metadata.json", json.dumps(metadata))
91 archive.writestr(
92 "old-a0/usr/.env",
93 f"{backup_credentials}ALLOWED_ORIGINS=http://backup.example\n"
94 "PORTABLE_SETTING=restored\n",
95 )
96
97 service = BackupService()
98 service.agent_zero_root = str(destination_root)
99 result = await service.restore_backup(UploadedBackup(zip_path))
100
101 assert dotenv_values(destination_env) == {
102 **expected_credentials,
103 "ALLOWED_ORIGINS": "http://current.example",
104 "PORTABLE_SETTING": "restored",
105 }
106 assert len(result["restored_files"]) == 1
107 assert result["errors"] == []
108
109
110 @pytest.mark.asyncio
111 async def test_pattern_scan_can_run_without_file_limit(tmp_path):
112 root = tmp_path / "a0"
113 usr = root / "usr"
114 usr.mkdir(parents=True)
115 for index in range(3):
116 (usr / f"file-{index}.txt").write_text(f"{index}\n", encoding="utf-8")
117
118 service = BackupService()
119 service.agent_zero_root = str(root)
120 service.base_paths = {str(root): str(root)}
121 metadata = {
122 "include_patterns": [f"{root}/usr/**"],
123 "exclude_patterns": [],
124 "include_hidden": True,
125 }
126
127 capped_files = await service.test_patterns(metadata, max_files=2)
128 all_files = await service.test_patterns(metadata, max_files=None)
129
130 assert len(capped_files) == 2
131 assert len(all_files) == 3
132
133
134 @pytest.mark.asyncio
135 async def test_create_backup_uses_unlimited_pattern_scan(tmp_path, monkeypatch):
136 source_file = tmp_path / "source.txt"
137 source_file.write_text("payload\n", encoding="utf-8")
138 captured = {}
139
140 service = BackupService()
141
142 async def fake_test_patterns(metadata, max_files=1000):
143 captured["max_files"] = max_files
144 return [
145 {
146 "path": f"{service.agent_zero_root.rstrip('/')}/usr/file-{index}.txt",
147 "real_path": str(source_file),
148 "size": source_file.stat().st_size,
149 "modified": "2026-06-26T00:00:00+00:00",
150 "type": "file",
151 }
152 for index in range(3)
153 ]
154
155 async def fake_info():
156 return {}
157
158 async def fake_author():
159 return "test"
160
161 monkeypatch.setattr(service, "test_patterns", fake_test_patterns)
162 monkeypatch.setattr(service, "_get_system_info", fake_info)
163 monkeypatch.setattr(service, "_get_environment_info", fake_info)
164 monkeypatch.setattr(service, "_get_backup_author", fake_author)
165
166 zip_path = await service.create_backup(
167 include_patterns=[f"{service.agent_zero_root}/usr/**"],
168 exclude_patterns=[],
169 include_hidden=True,
170 backup_name="large-backup",
171 )
172
173 assert captured["max_files"] is None
174 with zipfile.ZipFile(zip_path) as archive:
175 metadata = json.loads(archive.read("metadata.json").decode("utf-8"))
176 assert metadata["total_files"] == 3
177 assert (
178 f"{service.agent_zero_root.rstrip('/').lstrip('/')}/usr/file-2.txt"
179 in archive.namelist()
180 )
181
182
183 @pytest.mark.asyncio
184 async def test_restore_can_reach_files_after_50000_archive_entries(tmp_path):
185 old_root = "/old-a0"
186 archive_root = old_root.lstrip("/")
187 file_count = 50_001
188 last_index = file_count - 1
189 zip_path = tmp_path / "large-backup.zip"
190
191 metadata = {
192 "environment_info": {"agent_zero_root": old_root},
193 "include_patterns": [f"{old_root}/usr/large/**"],
194 "exclude_patterns": [],
195 "include_hidden": True,
196 }
197 with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_STORED) as archive:
198 archive.writestr("metadata.json", json.dumps(metadata))
199 for index in range(file_count):
200 payload = "tail payload\n" if index == last_index else ""
201 archive.writestr(
202 f"{archive_root}/usr/large/file-{index:05d}.txt",
203 payload,
204 )
205
206 service = BackupService()
207 service.agent_zero_root = str(tmp_path / "restored-a0")
208
209 result = await service.restore_backup(
210 backup_file=UploadedBackup(zip_path),
211 restore_include_patterns=[
212 f"{old_root}/usr/large/file-{last_index:05d}.txt"
213 ],
214 restore_exclude_patterns=[],
215 overwrite_policy="overwrite",
216 )
217
218 restored_path = (
219 Path(service.agent_zero_root)
220 / "usr"
221 / "large"
222 / f"file-{last_index:05d}.txt"
223 )
224 assert len(result["restored_files"]) == 1
225 assert len(result["skipped_files"]) == file_count - 1
226 assert result["errors"] == []
227 assert restored_path.read_text(encoding="utf-8") == "tail payload\n"
228
229
230 @pytest.mark.asyncio
231 async def test_restore_clean_before_restore_uses_unlimited_pattern_scan(monkeypatch):
232 service = BackupService()
233 captured = {}
234
235 async def fake_test_patterns(metadata, max_files=1000):
236 captured["max_files"] = max_files
237 return []
238
239 monkeypatch.setattr(service, "test_patterns", fake_test_patterns)
240
241 result = await service._find_files_to_clean_with_user_metadata(
242 user_metadata={
243 "include_patterns": [f"{service.agent_zero_root}/usr/**"],
244 "exclude_patterns": [],
245 "include_hidden": True,
246 },
247 original_metadata={
248 "environment_info": {"agent_zero_root": service.agent_zero_root}
249 },
250 )
251
252 assert result == []
253 assert captured["max_files"] is None