|
13 | 13 | IntegrationDescriptor, |
14 | 14 | IntegrationDescriptorError, |
15 | 15 | IntegrationValidationError, |
| 16 | + _catalog_shape_error, |
16 | 17 | ) |
17 | 18 |
|
18 | 19 |
|
| 20 | +class TestCatalogShapeValidator: |
| 21 | + """The shared shape validator used by BOTH the fresh-fetch and cache-read |
| 22 | + paths, so a poisoned/older cache can't bypass the format contract the fresh |
| 23 | + fetch enforces (dict + 'schema_version' + dict 'integrations').""" |
| 24 | + |
| 25 | + def test_valid_payload_returns_none(self): |
| 26 | + assert _catalog_shape_error({"schema_version": "1.0", "integrations": {}}) is None |
| 27 | + |
| 28 | + def test_missing_schema_version_is_rejected(self): |
| 29 | + # The exact bypass the two paths used to disagree on: a dict with a dict |
| 30 | + # 'integrations' but no 'schema_version'. |
| 31 | + assert _catalog_shape_error({"integrations": {}}) is not None |
| 32 | + |
| 33 | + def test_missing_integrations_is_rejected(self): |
| 34 | + assert _catalog_shape_error({"schema_version": "1.0"}) is not None |
| 35 | + |
| 36 | + def test_non_dict_integrations_is_rejected(self): |
| 37 | + assert _catalog_shape_error({"schema_version": "1.0", "integrations": []}) is not None |
| 38 | + |
| 39 | + @pytest.mark.parametrize("payload", [[], "x", 5, None]) |
| 40 | + def test_non_dict_payload_is_rejected(self, payload): |
| 41 | + assert _catalog_shape_error(payload) is not None |
| 42 | + |
| 43 | + |
19 | 44 | # --------------------------------------------------------------------------- |
20 | 45 | # IntegrationCatalogEntry |
21 | 46 | # --------------------------------------------------------------------------- |
@@ -293,47 +318,6 @@ def test_poisoned_cache_shape_is_dropped_and_refetched(self, tmp_path, monkeypat |
293 | 318 | results = cat.search() |
294 | 319 | assert "acme-coder" in [r["id"] for r in results] |
295 | 320 |
|
296 | | - def test_cache_missing_schema_version_is_dropped_and_refetched(self, tmp_path, monkeypatch): |
297 | | - """A cache that has a dict 'integrations' but no 'schema_version' must be |
298 | | - dropped and refetched — the cache path enforces the SAME contract as a |
299 | | - fresh fetch (which rejects a missing schema_version). Otherwise an |
300 | | - older/poisoned payload like {"integrations": {}} bypasses the format |
301 | | - contract and hides real catalog entries.""" |
302 | | - monkeypatch.setenv("HOME", str(tmp_path)) |
303 | | - monkeypatch.setenv("USERPROFILE", str(tmp_path)) |
304 | | - monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False) |
305 | | - (tmp_path / ".specify").mkdir() |
306 | | - cat = IntegrationCatalog(tmp_path) |
307 | | - |
308 | | - catalog = { |
309 | | - "schema_version": "1.0", |
310 | | - "updated_at": "2026-01-01T00:00:00Z", |
311 | | - "integrations": { |
312 | | - "acme-coder": { |
313 | | - "id": "acme-coder", "name": "Acme Coder", "version": "2.0.0", |
314 | | - "description": "Community integration", "author": "acme-org", |
315 | | - "tags": ["cli"], |
316 | | - }, |
317 | | - }, |
318 | | - } |
319 | | - self._patch_urlopen(monkeypatch, catalog) |
320 | | - cat.search() # populate the cache legitimately |
321 | | - |
322 | | - cache_dir = tmp_path / ".specify" / "integrations" / ".cache" |
323 | | - data_files = [ |
324 | | - f for f in cache_dir.glob("catalog-*.json") |
325 | | - if not f.name.endswith("-metadata.json") |
326 | | - ] |
327 | | - assert data_files, "cache was not populated" |
328 | | - # Well-shaped except for the missing schema_version key. |
329 | | - data_files[0].write_text( |
330 | | - json.dumps({"integrations": {}}), |
331 | | - encoding="utf-8", |
332 | | - ) |
333 | | - |
334 | | - results = cat.search() |
335 | | - assert "acme-coder" in [r["id"] for r in results] |
336 | | - |
337 | 321 | def test_search_by_tag(self, tmp_path, monkeypatch): |
338 | 322 | monkeypatch.setenv("HOME", str(tmp_path)) |
339 | 323 | monkeypatch.setenv("USERPROFILE", str(tmp_path)) |
|
0 commit comments