|
12 | 12 | get_binary_path, |
13 | 13 | download_binary, |
14 | 14 | run_core, |
| 15 | + _fetch_expected_checksum, |
| 16 | + _verify_checksum, |
15 | 17 | CORE_VERSION, |
16 | 18 | GITHUB_REPO, |
17 | 19 | ) |
@@ -145,15 +147,17 @@ def test_returns_existing_binary(self, mock_get_path): |
145 | 147 | result = download_binary("1.0.0") |
146 | 148 | assert result == mock_path |
147 | 149 |
|
| 150 | + @patch('capiscio.manager._fetch_expected_checksum', return_value=(None, "fetch_failed")) |
148 | 151 | @patch('capiscio.manager.get_platform_info', return_value=('linux', 'amd64')) |
149 | 152 | @patch('capiscio.manager.get_binary_path') |
150 | 153 | @patch('capiscio.manager.requests.get') |
151 | 154 | @patch('capiscio.manager.console') |
152 | | - def test_downloads_binary_on_missing(self, mock_console, mock_requests, mock_get_path, mock_platform): |
| 155 | + def test_downloads_binary_on_missing(self, mock_console, mock_requests, mock_get_path, mock_platform, mock_fetch_checksum): |
153 | 156 | """Test that binary is downloaded when missing.""" |
154 | 157 | mock_path = MagicMock(spec=Path) |
155 | 158 | mock_path.exists.return_value = False |
156 | 159 | mock_path.parent = MagicMock() |
| 160 | + mock_path.name = "capiscio-linux-amd64" |
157 | 161 | mock_get_path.return_value = mock_path |
158 | 162 |
|
159 | 163 | # Mock the response |
@@ -194,6 +198,163 @@ def test_cleans_up_on_download_error(self, mock_console, mock_requests, mock_get |
194 | 198 | mock_path.unlink.assert_called_once() |
195 | 199 |
|
196 | 200 |
|
| 201 | +class TestFetchExpectedChecksum: |
| 202 | + """Tests for _fetch_expected_checksum function.""" |
| 203 | + |
| 204 | + @patch('capiscio.manager.requests.get') |
| 205 | + def test_returns_checksum_on_match(self, mock_get): |
| 206 | + """Test successful checksum lookup.""" |
| 207 | + mock_resp = MagicMock() |
| 208 | + mock_resp.text = "abc123 capiscio-linux-amd64\ndef456 capiscio-darwin-arm64\n" |
| 209 | + mock_resp.raise_for_status = MagicMock() |
| 210 | + mock_resp.__enter__ = MagicMock(return_value=mock_resp) |
| 211 | + mock_resp.__exit__ = MagicMock(return_value=False) |
| 212 | + mock_get.return_value = mock_resp |
| 213 | + |
| 214 | + checksum, status = _fetch_expected_checksum("1.0.0", "capiscio-linux-amd64") |
| 215 | + assert checksum == "abc123" |
| 216 | + assert status == "ok" |
| 217 | + |
| 218 | + @patch('capiscio.manager.requests.get') |
| 219 | + def test_returns_entry_missing_when_not_found(self, mock_get): |
| 220 | + """Test that missing entry returns entry_missing status.""" |
| 221 | + mock_resp = MagicMock() |
| 222 | + mock_resp.text = "abc123 capiscio-linux-amd64\n" |
| 223 | + mock_resp.raise_for_status = MagicMock() |
| 224 | + mock_resp.__enter__ = MagicMock(return_value=mock_resp) |
| 225 | + mock_resp.__exit__ = MagicMock(return_value=False) |
| 226 | + mock_get.return_value = mock_resp |
| 227 | + |
| 228 | + checksum, status = _fetch_expected_checksum("1.0.0", "capiscio-darwin-arm64") |
| 229 | + assert checksum is None |
| 230 | + assert status == "entry_missing" |
| 231 | + |
| 232 | + @patch('capiscio.manager.requests.get') |
| 233 | + def test_returns_fetch_failed_on_network_error(self, mock_get): |
| 234 | + """Test that network errors return fetch_failed status.""" |
| 235 | + import requests.exceptions |
| 236 | + mock_get.side_effect = requests.exceptions.ConnectionError("timeout") |
| 237 | + |
| 238 | + checksum, status = _fetch_expected_checksum("1.0.0", "capiscio-linux-amd64") |
| 239 | + assert checksum is None |
| 240 | + assert status == "fetch_failed" |
| 241 | + |
| 242 | + |
| 243 | +class TestChecksumVerificationIntegration: |
| 244 | + """Tests for checksum verification during download.""" |
| 245 | + |
| 246 | + def _make_download_mocks(self): |
| 247 | + """Helper: set up common mocks for download_binary tests.""" |
| 248 | + mock_path = MagicMock(spec=Path) |
| 249 | + mock_path.exists.return_value = False |
| 250 | + mock_path.parent = MagicMock() |
| 251 | + mock_path.name = "capiscio-linux-amd64" |
| 252 | + return mock_path |
| 253 | + |
| 254 | + @patch('capiscio.manager._fetch_expected_checksum', return_value=("abc123", "ok")) |
| 255 | + @patch('capiscio.manager._verify_checksum', return_value=True) |
| 256 | + @patch('capiscio.manager.get_platform_info', return_value=('linux', 'amd64')) |
| 257 | + @patch('capiscio.manager.get_binary_path') |
| 258 | + @patch('capiscio.manager.requests.get') |
| 259 | + @patch('capiscio.manager.console') |
| 260 | + def test_checksum_verified_match(self, mock_console, mock_requests, mock_get_path, |
| 261 | + mock_platform, mock_verify, mock_fetch): |
| 262 | + """Test that download succeeds when checksum matches.""" |
| 263 | + mock_path = self._make_download_mocks() |
| 264 | + mock_get_path.return_value = mock_path |
| 265 | + |
| 266 | + mock_response = MagicMock() |
| 267 | + mock_response.headers = {'content-length': '1024'} |
| 268 | + mock_response.iter_content.return_value = [b'x' * 1024] |
| 269 | + mock_response.__enter__ = MagicMock(return_value=mock_response) |
| 270 | + mock_response.__exit__ = MagicMock(return_value=False) |
| 271 | + mock_requests.return_value = mock_response |
| 272 | + |
| 273 | + with patch('builtins.open', mock_open()): |
| 274 | + with patch.object(os, 'stat') as mock_stat: |
| 275 | + with patch.object(os, 'chmod'): |
| 276 | + mock_stat.return_value = MagicMock(st_mode=0o644) |
| 277 | + result = download_binary("1.0.0") |
| 278 | + |
| 279 | + assert result == mock_path |
| 280 | + mock_verify.assert_called_once_with(mock_path, "abc123") |
| 281 | + |
| 282 | + @patch('capiscio.manager._fetch_expected_checksum', return_value=("abc123", "ok")) |
| 283 | + @patch('capiscio.manager._verify_checksum', return_value=False) |
| 284 | + @patch('capiscio.manager.get_platform_info', return_value=('linux', 'amd64')) |
| 285 | + @patch('capiscio.manager.get_binary_path') |
| 286 | + @patch('capiscio.manager.requests.get') |
| 287 | + @patch('capiscio.manager.console') |
| 288 | + def test_checksum_mismatch_cleans_up(self, mock_console, mock_requests, mock_get_path, |
| 289 | + mock_platform, mock_verify, mock_fetch): |
| 290 | + """Test that a checksum mismatch deletes the binary and raises.""" |
| 291 | + mock_path = self._make_download_mocks() |
| 292 | + mock_get_path.return_value = mock_path |
| 293 | + |
| 294 | + mock_response = MagicMock() |
| 295 | + mock_response.headers = {'content-length': '1024'} |
| 296 | + mock_response.iter_content.return_value = [b'x' * 1024] |
| 297 | + mock_response.__enter__ = MagicMock(return_value=mock_response) |
| 298 | + mock_response.__exit__ = MagicMock(return_value=False) |
| 299 | + mock_requests.return_value = mock_response |
| 300 | + |
| 301 | + with patch('builtins.open', mock_open()): |
| 302 | + with pytest.raises(RuntimeError, match="integrity check failed"): |
| 303 | + download_binary("1.0.0") |
| 304 | + |
| 305 | + mock_path.unlink.assert_called() |
| 306 | + |
| 307 | + @patch.dict(os.environ, {"CAPISCIO_REQUIRE_CHECKSUM": "true"}) |
| 308 | + @patch('capiscio.manager._fetch_expected_checksum', return_value=(None, "fetch_failed")) |
| 309 | + @patch('capiscio.manager.get_platform_info', return_value=('linux', 'amd64')) |
| 310 | + @patch('capiscio.manager.get_binary_path') |
| 311 | + @patch('capiscio.manager.requests.get') |
| 312 | + @patch('capiscio.manager.console') |
| 313 | + def test_require_checksum_fails_on_fetch_failed(self, mock_console, mock_requests, |
| 314 | + mock_get_path, mock_platform, mock_fetch): |
| 315 | + """Test fail-closed when CAPISCIO_REQUIRE_CHECKSUM=true and fetch fails.""" |
| 316 | + mock_path = self._make_download_mocks() |
| 317 | + mock_get_path.return_value = mock_path |
| 318 | + |
| 319 | + mock_response = MagicMock() |
| 320 | + mock_response.headers = {'content-length': '1024'} |
| 321 | + mock_response.iter_content.return_value = [b'x' * 1024] |
| 322 | + mock_response.__enter__ = MagicMock(return_value=mock_response) |
| 323 | + mock_response.__exit__ = MagicMock(return_value=False) |
| 324 | + mock_requests.return_value = mock_response |
| 325 | + |
| 326 | + with patch('builtins.open', mock_open()): |
| 327 | + with pytest.raises(RuntimeError, match="could not be fetched"): |
| 328 | + download_binary("1.0.0") |
| 329 | + |
| 330 | + mock_path.unlink.assert_called() |
| 331 | + |
| 332 | + @patch.dict(os.environ, {"CAPISCIO_REQUIRE_CHECKSUM": "true"}) |
| 333 | + @patch('capiscio.manager._fetch_expected_checksum', return_value=(None, "entry_missing")) |
| 334 | + @patch('capiscio.manager.get_platform_info', return_value=('linux', 'amd64')) |
| 335 | + @patch('capiscio.manager.get_binary_path') |
| 336 | + @patch('capiscio.manager.requests.get') |
| 337 | + @patch('capiscio.manager.console') |
| 338 | + def test_require_checksum_fails_on_entry_missing(self, mock_console, mock_requests, |
| 339 | + mock_get_path, mock_platform, mock_fetch): |
| 340 | + """Test fail-closed when CAPISCIO_REQUIRE_CHECKSUM=true and entry is missing.""" |
| 341 | + mock_path = self._make_download_mocks() |
| 342 | + mock_get_path.return_value = mock_path |
| 343 | + |
| 344 | + mock_response = MagicMock() |
| 345 | + mock_response.headers = {'content-length': '1024'} |
| 346 | + mock_response.iter_content.return_value = [b'x' * 1024] |
| 347 | + mock_response.__enter__ = MagicMock(return_value=mock_response) |
| 348 | + mock_response.__exit__ = MagicMock(return_value=False) |
| 349 | + mock_requests.return_value = mock_response |
| 350 | + |
| 351 | + with patch('builtins.open', mock_open()): |
| 352 | + with pytest.raises(RuntimeError, match="no checksum entry found"): |
| 353 | + download_binary("1.0.0") |
| 354 | + |
| 355 | + mock_path.unlink.assert_called() |
| 356 | + |
| 357 | + |
197 | 358 | class TestRunCore: |
198 | 359 | """Tests for run_core function.""" |
199 | 360 |
|
|
0 commit comments