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) -> dict:
28
+ return orig_load(path)
29
+
30
+ def patched_save(registry: dict, path: Path = reg_path) -> None:
31
+ return orig_save(registry, path)
32
+
33
+ with patch.object(registry_mod, "load_registry", patched_load), \
34
+ patch.object(registry_mod, "save_registry", patched_save):
35
+ return registry_mod.main(argv)
36
+
37
+
38
+class TestPathSafety:
39
+ def test_rejects_http_url(self) -> None:
40
+ safe, reason = registry_mod._is_safe_local_path("https://evil.com/img.png")
41
+ assert not safe
42
+ assert "URL" in reason
43
+
44
+ def test_rejects_protocol_relative_url(self) -> None:
45
+ safe, reason = registry_mod._is_safe_local_path("//evil.com/img.png")
46
+ assert not safe
47
+ assert "URL" in reason
48
+
49
+ def test_rejects_absolute_path(self) -> None:
50
+ safe, reason = registry_mod._is_safe_local_path("/etc/passwd")
51
+ assert not safe
52
+ assert "absolute" in reason
53
+
54
+ def test_rejects_path_traversal(self) -> None:
55
+ safe, reason = registry_mod._is_safe_local_path("assets/../../../etc/passwd")
56
+ assert not safe
57
+ assert "traversal" in reason
58
+
59
+ def test_accepts_relative_path(self) -> None:
60
+ safe, reason = registry_mod._is_safe_local_path("assets/covers/2026-W24.webp")
61
+ assert safe
62
+ assert reason == ""
63
+
64
+
65
+class TestAddCommand:
66
+ def test_rejects_url_filename(self, tmp_path: Path) -> None:
67
+ rc = _run_with_registry(tmp_path, [], [
68
+ "add",
69
+ "--filename", "https://example.com/image.png",
70
+ "--license", "CC0",
71
+ "--added-by", "test",
72
+ ])
73
+ assert rc == 1
74
+
75
+ def test_rejects_traversal_filename(self, tmp_path: Path) -> None:
76
+ rc = _run_with_registry(tmp_path, [], [
77
+ "add",
78
+ "--filename", "assets/../../etc/shadow",
79
+ "--license", "CC0",
80
+ "--added-by", "test",
81
+ ])
82
+ assert rc == 1
83
+
84
+ def test_adds_valid_image(self, tmp_path: Path) -> None:
85
+ # Create a relative-path image file
86
+ img_rel = "assets/covers/test.webp"
87
+ img_abs = tmp_path / img_rel
88
+ img_abs.parent.mkdir(parents=True)
89
+ img_abs.write_bytes(b"fake image")
90
+ # Run from tmp_path so relative path resolves
91
+ old_cwd = os.getcwd()
92
+ os.chdir(tmp_path)
93
+ try:
94
+ rc = _run_with_registry(tmp_path, [], [
95
+ "add",
96
+ "--filename", img_rel,
97
+ "--license", "CC0",
98
+ "--added-by", "test",
99
+ "--source-url", "https://example.com/source",
100
+ "--attribution", "Test Author",
101
+ ])
102
+ finally:
103
+ os.chdir(old_cwd)
104
+ assert rc == 0
105
+ reg_path = tmp_path / "image-registry.json"
106
+ data = json.loads(reg_path.read_text())
107
+ assert len(data["images"]) == 1
108
+ assert data["images"][0]["license"] == "CC0"
109
+ assert data["images"][0]["source_url"] == "https://example.com/source"
110
+
111
+ def test_rejects_duplicate(self, tmp_path: Path) -> None:
112
+ rc = _run_with_registry(
113
+ tmp_path,
114
+ [{"filename": "assets/x.webp", "license": "CC0", "added_by": "op"}],
115
+ ["add", "--filename", "assets/x.webp", "--license", "CC0", "--added-by", "test"],
116
+ )
117
+ assert rc == 1
118
+
119
+
120
+class TestValidateCommand:
121
+ def test_valid_registry_passes(self, tmp_path: Path) -> None:
122
+ rc = _run_with_registry(tmp_path, [
123
+ {"filename": "assets/covers/img.webp", "license": "CC0", "added_by": "op"},
124
+ ], ["validate"])
125
+ assert rc == 0
126
+
127
+ def test_detects_url_filename(self, tmp_path: Path) -> None:
128
+ rc = _run_with_registry(tmp_path, [
129
+ {"filename": "https://evil.com/x.png", "license": "CC0", "added_by": "op"},
130
+ ], ["validate"])
131
+ assert rc == 1
132
+
133
+ def test_detects_absolute_path(self, tmp_path: Path) -> None:
134
+ rc = _run_with_registry(tmp_path, [
135
+ {"filename": "/etc/passwd", "license": "CC0", "added_by": "op"},
136
+ ], ["validate"])
137
+ assert rc == 1
138
+
139
+ def test_detects_traversal_path(self, tmp_path: Path) -> None:
140
+ rc = _run_with_registry(tmp_path, [
141
+ {"filename": "assets/../../../etc/shadow", "license": "CC0", "added_by": "op"},
142
+ ], ["validate"])
143
+ assert rc == 1
144
+
145
+ def test_detects_missing_license(self, tmp_path: Path) -> None:
146
+ rc = _run_with_registry(tmp_path, [
147
+ {"filename": "assets/x.webp", "added_by": "op"},
148
+ ], ["validate"])
149
+ assert rc == 1
150
+
151
+
152
+class TestRegistryLoading:
153
+ def test_load_registry_rejects_invalid_json(self, tmp_path: Path) -> None:
154
+ reg_path = tmp_path / "image-registry.json"
155
+ reg_path.write_text("{not-json", encoding="utf-8")
156
+
157
+ try:
158
+ registry_mod.load_registry(reg_path)
159
+ except registry_mod.RegistryError as exc:
160
+ assert "Invalid image registry JSON" in str(exc)
161
+ else:
162
+ raise AssertionError("Expected RegistryError for invalid JSON")
163
+
164
+ def test_load_registry_rejects_non_list_images_shape(self, tmp_path: Path) -> None:
165
+ reg_path = tmp_path / "image-registry.json"
166
+ reg_path.write_text(json.dumps({"images": {}}), encoding="utf-8")
167
+
168
+ try:
169
+ registry_mod.load_registry(reg_path)
170
+ except registry_mod.RegistryError as exc:
171
+ assert "expected an object with an 'images' list" in str(exc)
172
+ else:
173
+ raise AssertionError("Expected RegistryError for invalid registry shape")
174
+
175
+ def test_main_reports_registry_load_errors_cleanly(self, tmp_path: Path, capsys) -> None:
176
+ reg_path = tmp_path / "image-registry.json"
177
+ reg_path.write_text("{not-json", encoding="utf-8")
178
+ orig_load = registry_mod.load_registry
179
+
180
+ def patched_load(path: Path = reg_path) -> dict:
181
+ return orig_load(path)
182
+
183
+ with patch.object(registry_mod, "load_registry", patched_load):
184
+ rc = registry_mod.main(["list"])
185
+
186
+ captured = capsys.readouterr()
187
+ assert rc == 1
188
+ assert "ERROR: Invalid image registry JSON" in captured.err
189
+
190
+
191
+class TestListCommand:
192
+ def test_lists_registered_images(self, tmp_path: Path, capsys) -> None:
193
+ rc = _run_with_registry(tmp_path, [
194
+ {"filename": "assets/covers/img.webp", "license": "CC0", "added_by": "op"},
195
+ ], ["list"])
196
+ captured = capsys.readouterr()
197
+ assert rc == 0
198
+ assert "assets/covers/img.webp" in captured.out
199
+ assert "[CC0]" in captured.out
200
+ assert "by op" in captured.out