191 lines
6.5 KiB
Python
191 lines
6.5 KiB
Python
"""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) == []
|