main
py 249 lines 8.05 KB
Raw
1 """Tests for scripts/manage_image_registry.py."""
2
3 from __future__ import annotations
4
5 import json
6 import os
7 from pathlib import Path
8 from unittest.mock import patch
9
10 import scripts.manage_image_registry as registry_mod
11
12
13 def _tmp_registry(tmp_path: Path, images: list | None = None) -> Path:
14 """Create a temporary registry file."""
15 reg_path = tmp_path / "image-registry.json"
16 reg_path.write_text(json.dumps({"images": images or []}, indent=2), encoding="utf-8")
17 return reg_path
18
19
20 def _run_with_registry(tmp_path: Path, images: list | None, argv: list[str]) -> int:
21 """Run the CLI with a patched REGISTRY_PATH via monkeypatching load/save defaults."""
22 reg_path = _tmp_registry(tmp_path, images)
23 # Patch load_registry and save_registry to use our temp path
24 orig_load = registry_mod.load_registry
25 orig_save = registry_mod.save_registry
26
27 def patched_load(path: Path = reg_path, **kwargs) -> dict:
28 return orig_load(path, **kwargs)
29
30 def patched_save(registry: dict, path: Path = reg_path) -> None:
31 return orig_save(registry, path)
32
33 with (
34 patch.object(registry_mod, "load_registry", patched_load),
35 patch.object(registry_mod, "save_registry", patched_save),
36 ):
37 return registry_mod.main(argv)
38
39
40 class TestPathSafety:
41 def test_rejects_http_url(self) -> None:
42 safe, reason = registry_mod._is_safe_local_path("https://evil.com/img.png")
43 assert not safe
44 assert "URL" in reason
45
46 def test_rejects_protocol_relative_url(self) -> None:
47 safe, reason = registry_mod._is_safe_local_path("//evil.com/img.png")
48 assert not safe
49 assert "URL" in reason
50
51 def test_rejects_absolute_path(self) -> None:
52 safe, reason = registry_mod._is_safe_local_path("/etc/passwd")
53 assert not safe
54 assert "absolute" in reason
55
56 def test_rejects_path_traversal(self) -> None:
57 safe, reason = registry_mod._is_safe_local_path("assets/../../../etc/passwd")
58 assert not safe
59 assert "traversal" in reason
60
61 def test_accepts_relative_path(self) -> None:
62 safe, reason = registry_mod._is_safe_local_path("assets/covers/2026-W24.webp")
63 assert safe
64 assert reason == ""
65
66
67 class TestAddCommand:
68 def test_rejects_url_filename(self, tmp_path: Path) -> None:
69 rc = _run_with_registry(
70 tmp_path,
71 [],
72 [
73 "add",
74 "--filename",
75 "https://example.com/image.png",
76 "--license",
77 "CC0",
78 "--added-by",
79 "test",
80 ],
81 )
82 assert rc == 1
83
84 def test_rejects_traversal_filename(self, tmp_path: Path) -> None:
85 rc = _run_with_registry(
86 tmp_path,
87 [],
88 [
89 "add",
90 "--filename",
91 "assets/../../etc/shadow",
92 "--license",
93 "CC0",
94 "--added-by",
95 "test",
96 ],
97 )
98 assert rc == 1
99
100 def test_adds_valid_image(self, tmp_path: Path) -> None:
101 # Create a relative-path image file
102 img_rel = "assets/covers/test.webp"
103 img_abs = tmp_path / img_rel
104 img_abs.parent.mkdir(parents=True)
105 img_abs.write_bytes(b"fake image")
106 # Run from tmp_path so relative path resolves
107 old_cwd = os.getcwd()
108 os.chdir(tmp_path)
109 try:
110 rc = _run_with_registry(
111 tmp_path,
112 [],
113 [
114 "add",
115 "--filename",
116 img_rel,
117 "--license",
118 "CC0",
119 "--added-by",
120 "test",
121 "--source-url",
122 "https://example.com/source",
123 "--attribution",
124 "Test Author",
125 ],
126 )
127 finally:
128 os.chdir(old_cwd)
129 assert rc == 0
130 reg_path = tmp_path / "image-registry.json"
131 data = json.loads(reg_path.read_text())
132 assert len(data["images"]) == 1
133 assert data["images"][0]["license"] == "CC0"
134 assert data["images"][0]["source_url"] == "https://example.com/source"
135
136 def test_rejects_duplicate(self, tmp_path: Path) -> None:
137 rc = _run_with_registry(
138 tmp_path,
139 [{"filename": "assets/x.webp", "license": "CC0", "added_by": "op"}],
140 ["add", "--filename", "assets/x.webp", "--license", "CC0", "--added-by", "test"],
141 )
142 assert rc == 1
143
144
145 class TestValidateCommand:
146 def test_valid_registry_passes(self, tmp_path: Path) -> None:
147 rc = _run_with_registry(
148 tmp_path,
149 [
150 {"filename": "assets/covers/img.webp", "license": "CC0", "added_by": "op"},
151 ],
152 ["validate"],
153 )
154 assert rc == 0
155
156 def test_detects_url_filename(self, tmp_path: Path) -> None:
157 rc = _run_with_registry(
158 tmp_path,
159 [
160 {"filename": "https://evil.com/x.png", "license": "CC0", "added_by": "op"},
161 ],
162 ["validate"],
163 )
164 assert rc == 1
165
166 def test_detects_absolute_path(self, tmp_path: Path) -> None:
167 rc = _run_with_registry(
168 tmp_path,
169 [
170 {"filename": "/etc/passwd", "license": "CC0", "added_by": "op"},
171 ],
172 ["validate"],
173 )
174 assert rc == 1
175
176 def test_detects_traversal_path(self, tmp_path: Path) -> None:
177 rc = _run_with_registry(
178 tmp_path,
179 [
180 {"filename": "assets/../../../etc/shadow", "license": "CC0", "added_by": "op"},
181 ],
182 ["validate"],
183 )
184 assert rc == 1
185
186 def test_detects_missing_license(self, tmp_path: Path) -> None:
187 rc = _run_with_registry(
188 tmp_path,
189 [
190 {"filename": "assets/x.webp", "added_by": "op"},
191 ],
192 ["validate"],
193 )
194 assert rc == 1
195
196
197 class TestRegistryLoading:
198 def test_load_registry_rejects_invalid_json(self, tmp_path: Path) -> None:
199 reg_path = tmp_path / "image-registry.json"
200 reg_path.write_text("{not-json", encoding="utf-8")
201
202 try:
203 registry_mod.load_registry(reg_path)
204 except registry_mod.RegistryError as exc:
205 assert "Invalid image registry JSON" in str(exc)
206 else:
207 raise AssertionError("Expected RegistryError for invalid JSON")
208
209 def test_load_registry_rejects_non_list_images_shape(self, tmp_path: Path) -> None:
210 reg_path = tmp_path / "image-registry.json"
211 reg_path.write_text(json.dumps({"images": {}}), encoding="utf-8")
212
213 try:
214 registry_mod.load_registry(reg_path)
215 except registry_mod.RegistryError as exc:
216 assert "expected an object with an 'images' list" in str(exc)
217 else:
218 raise AssertionError("Expected RegistryError for invalid registry shape")
219
220 def test_main_reports_registry_load_errors_cleanly(self, tmp_path: Path, capsys) -> None:
221 reg_path = tmp_path / "image-registry.json"
222 reg_path.write_text("{not-json", encoding="utf-8")
223 orig_load = registry_mod.load_registry
224
225 def patched_load(path: Path = reg_path) -> dict:
226 return orig_load(path)
227
228 with patch.object(registry_mod, "load_registry", patched_load):
229 rc = registry_mod.main(["list"])
230
231 captured = capsys.readouterr()
232 assert rc == 1
233 assert "ERROR: Invalid image registry JSON" in captured.err
234
235
236 class TestListCommand:
237 def test_lists_registered_images(self, tmp_path: Path, capsys) -> None:
238 rc = _run_with_registry(
239 tmp_path,
240 [
241 {"filename": "assets/covers/img.webp", "license": "CC0", "added_by": "op"},
242 ],
243 ["list"],
244 )
245 captured = capsys.readouterr()
246 assert rc == 0
247 assert "assets/covers/img.webp" in captured.out
248 assert "[CC0]" in captured.out
249 assert "by op" in captured.out