"""Unit tests for installed-bundle records and collateral-protection logic.""" from __future__ import annotations import json from pathlib import Path import pytest from specify_cli.bundler import BundlerError from specify_cli.bundler.models.manifest import ComponentRef from specify_cli.bundler.models.records import ( InstalledBundleRecord, components_still_needed, load_records, records_path, remove_record, save_records, upsert_record, ) def _record(bundle_id: str, comps) -> InstalledBundleRecord: return InstalledBundleRecord.create( bundle_id=bundle_id, version="1.0.0", components=[ComponentRef(kind=k, id=i) for k, i in comps], ) def test_save_and_load_roundtrip(tmp_path: Path): (tmp_path / ".specify").mkdir() rec = _record("a", [("presets", "p1"), ("steps", "s1")]) save_records(tmp_path, [rec]) loaded = load_records(tmp_path) assert len(loaded) == 1 assert loaded[0].bundle_id == "a" assert {(c.kind, c.id) for c in loaded[0].contributed_components} == { ("presets", "p1"), ("steps", "s1"), } def test_load_missing_file_returns_empty(tmp_path: Path): (tmp_path / ".specify").mkdir() assert load_records(tmp_path) == [] def test_corrupt_priority_raises_actionable_error(tmp_path: Path): (tmp_path / ".specify").mkdir() rec = _record("a", [("presets", "p1")]) save_records(tmp_path, [rec]) path = records_path(tmp_path) data = json.loads(path.read_text(encoding="utf-8")) data["bundles"][0]["contributed_components"][0]["priority"] = "high" path.write_text(json.dumps(data), encoding="utf-8") with pytest.raises(BundlerError, match="priority must be an integer"): load_records(tmp_path) def test_upsert_replaces_same_id(): rec1 = _record("a", [("presets", "p1")]) rec2 = _record("a", [("presets", "p2")]) result = upsert_record([rec1], rec2) assert len(result) == 1 assert result[0].contributed_components[0].id == "p2" def test_remove_record_drops_target(): recs = [_record("a", [("presets", "p1")]), _record("b", [("steps", "s1")])] result = remove_record(recs, "a") assert [r.bundle_id for r in result] == ["b"] def test_components_still_needed_excludes_target(): recs = [ _record("a", [("presets", "shared"), ("steps", "only-a")]), _record("b", [("presets", "shared")]), ] needed = components_still_needed(recs, exclude_bundle_id="a") assert ("presets", "shared") in needed assert ("steps", "only-a") not in needed def test_save_records_refuses_symlinked_specify_escape(tmp_path: Path): # Defense-in-depth: a symlinked .specify pointing outside the project must # not let records be written outside project_root. project = tmp_path / "proj" project.mkdir() outside = tmp_path / "outside" outside.mkdir() (project / ".specify").symlink_to(outside, target_is_directory=True) with pytest.raises(BundlerError, match="escapes the allowed root"): save_records(project, [_record("a", [("presets", "p1")])]) def test_load_records_rejects_non_list_bundles(tmp_path: Path): (tmp_path / ".specify").mkdir() path = records_path(tmp_path) path.write_text(json.dumps({"schema_version": "1.0", "bundles": "oops"}), encoding="utf-8") with pytest.raises(BundlerError, match="'bundles' must be a list"): load_records(tmp_path) def test_load_records_rejects_non_list_contributed_components(tmp_path: Path): (tmp_path / ".specify").mkdir() path = records_path(tmp_path) payload = { "schema_version": "1.0", "bundles": [ {"bundle_id": "a", "version": "1.0.0", "contributed_components": "oops"} ], } path.write_text(json.dumps(payload), encoding="utf-8") with pytest.raises(BundlerError, match="'contributed_components' must be a list"): load_records(tmp_path) def test_load_records_rejects_unknown_component_kind(tmp_path: Path): (tmp_path / ".specify").mkdir() path = records_path(tmp_path) payload = { "schema_version": "1.0", "bundles": [ { "bundle_id": "a", "version": "1.0.0", "contributed_components": [{"kind": "bogus", "id": "x"}], } ], } path.write_text(json.dumps(payload), encoding="utf-8") with pytest.raises(BundlerError, match="must be one of"): load_records(tmp_path) def test_load_records_rejects_component_missing_id(tmp_path: Path): (tmp_path / ".specify").mkdir() path = records_path(tmp_path) payload = { "schema_version": "1.0", "bundles": [ { "bundle_id": "a", "version": "1.0.0", "contributed_components": [{"kind": "presets", "id": ""}], } ], } path.write_text(json.dumps(payload), encoding="utf-8") with pytest.raises(BundlerError, match="missing its 'id'"): load_records(tmp_path) def test_load_records_rejects_missing_schema_version(tmp_path: Path): (tmp_path / ".specify").mkdir() records_path(tmp_path).write_text(json.dumps({"bundles": []}), encoding="utf-8") with pytest.raises(BundlerError, match="missing 'schema_version'"): load_records(tmp_path) def test_load_records_rejects_unknown_schema_version(tmp_path: Path): (tmp_path / ".specify").mkdir() payload = {"schema_version": "2.0", "bundles": []} records_path(tmp_path).write_text(json.dumps(payload), encoding="utf-8") with pytest.raises(BundlerError, match="Unsupported records schema version"): load_records(tmp_path) def test_load_records_rejects_record_missing_bundle_id(tmp_path: Path): (tmp_path / ".specify").mkdir() payload = {"schema_version": "1.0", "bundles": [{"version": "1.0.0"}]} records_path(tmp_path).write_text(json.dumps(payload), encoding="utf-8") with pytest.raises(BundlerError, match="missing its 'bundle_id'"): load_records(tmp_path) def test_load_records_rejects_record_missing_version(tmp_path: Path): (tmp_path / ".specify").mkdir() payload = {"schema_version": "1.0", "bundles": [{"bundle_id": "a"}]} records_path(tmp_path).write_text(json.dumps(payload), encoding="utf-8") with pytest.raises(BundlerError, match="missing its 'version'"): load_records(tmp_path) def test_load_records_accepts_forward_compatible_minor_schema(tmp_path: Path): (tmp_path / ".specify").mkdir() payload = {"schema_version": "1.5", "bundles": []} records_path(tmp_path).write_text(json.dumps(payload), encoding="utf-8") assert load_records(tmp_path) == []