chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
"""Unit tests for catalog-fetch adapters (auth + redirect safety)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from specify_cli.bundler import BundlerError
|
||||
from specify_cli.bundler.models.catalog import CatalogSource, InstallPolicy
|
||||
from specify_cli.bundler.services import adapters
|
||||
|
||||
|
||||
def _source(url: str) -> CatalogSource:
|
||||
return CatalogSource(
|
||||
id="team",
|
||||
url=url,
|
||||
priority=10,
|
||||
install_policy=InstallPolicy.INSTALL_ALLOWED,
|
||||
)
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
def __init__(self, body: bytes, final_url: str) -> None:
|
||||
self._body = body
|
||||
self._final_url = final_url
|
||||
|
||||
def __enter__(self) -> "_FakeResponse":
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc) -> bool:
|
||||
return False
|
||||
|
||||
def geturl(self) -> str:
|
||||
return self._final_url
|
||||
|
||||
def read(self) -> bytes:
|
||||
return self._body
|
||||
|
||||
|
||||
def test_http_fetch_uses_shared_client_and_rejects_redirect_downgrade(monkeypatch):
|
||||
captured: dict = {}
|
||||
|
||||
def fake_open_url(url, timeout=10, extra_headers=None, redirect_validator=None):
|
||||
captured["url"] = url
|
||||
captured["validator"] = redirect_validator
|
||||
return _FakeResponse(b'{"schema_version": "1.0"}', url)
|
||||
|
||||
monkeypatch.setattr("specify_cli.authentication.http.open_url", fake_open_url)
|
||||
|
||||
fetcher = adapters.make_catalog_fetcher(allow_network=True)
|
||||
result = fetcher(_source("https://example.com/c.json"))
|
||||
assert result == {"schema_version": "1.0"}
|
||||
assert captured["url"] == "https://example.com/c.json"
|
||||
|
||||
# The validator handed to open_url must reject an HTTP downgrade redirect.
|
||||
validator = captured["validator"]
|
||||
assert validator is not None
|
||||
with pytest.raises(BundlerError, match="must use HTTPS"):
|
||||
validator("https://example.com/c.json", "http://evil.example/c.json")
|
||||
# And a same-scheme HTTPS redirect is allowed (no raise).
|
||||
validator("https://example.com/c.json", "https://cdn.example/c.json")
|
||||
|
||||
|
||||
def test_http_fetch_rejects_non_https_final_url(monkeypatch):
|
||||
def fake_open_url(url, timeout=10, extra_headers=None, redirect_validator=None):
|
||||
# Simulate a response whose final URL silently downgraded to HTTP.
|
||||
return _FakeResponse(b"{}", "http://evil.example/c.json")
|
||||
|
||||
monkeypatch.setattr("specify_cli.authentication.http.open_url", fake_open_url)
|
||||
|
||||
fetcher = adapters.make_catalog_fetcher(allow_network=True)
|
||||
with pytest.raises(BundlerError, match="must use HTTPS"):
|
||||
fetcher(_source("https://example.com/c.json"))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"url",
|
||||
[
|
||||
"https://:8080", # port only, no host
|
||||
"https://:0",
|
||||
"https://user@", # userinfo only, no host
|
||||
"https://user:pw@",
|
||||
"https://:8080/catalog.json",
|
||||
],
|
||||
)
|
||||
def test_validate_remote_url_rejects_host_less_urls(url):
|
||||
"""A URL with a truthy netloc but no host (``https://:8080``,
|
||||
``https://user@``) must be rejected.
|
||||
|
||||
``urlparse`` gives these a non-empty ``netloc`` but ``hostname is None``,
|
||||
so a ``netloc`` check would wrongly accept them. This mirrors the fix in
|
||||
``specify_cli.catalogs`` (#3210), which the docstring says this validator
|
||||
mirrors."""
|
||||
with pytest.raises(BundlerError, match="valid URL with a host"):
|
||||
adapters._validate_remote_url("team", url)
|
||||
|
||||
|
||||
def test_validate_remote_url_accepts_normal_https_url():
|
||||
# Sanity: a real host with a port still passes.
|
||||
adapters._validate_remote_url("team", "https://example.com:8080/c.json")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"url",
|
||||
[
|
||||
"https://[::1", # unclosed IPv6 bracket
|
||||
"https://[not-an-ip]/c.json",
|
||||
],
|
||||
)
|
||||
def test_validate_remote_url_rejects_malformed_url_cleanly(url):
|
||||
"""A malformed URL must raise BundlerError, not a raw ValueError.
|
||||
|
||||
``urlparse``/``hostname`` raise ``ValueError`` on a malformed authority
|
||||
(e.g. an unclosed IPv6 bracket). The validator's contract is to raise
|
||||
BundlerError for any bad URL, so the raw ValueError must not escape to the
|
||||
caller. Bundler sibling of #3369."""
|
||||
with pytest.raises(BundlerError):
|
||||
adapters._validate_remote_url("team", url)
|
||||
@@ -0,0 +1,260 @@
|
||||
"""Unit tests for project catalog-config id derivation and url canonicalization."""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from specify_cli.bundler import BundlerError
|
||||
from specify_cli.bundler.commands_impl import catalog_config as cc
|
||||
|
||||
|
||||
def test_derive_id_incorporates_path_stem_for_same_host():
|
||||
# Two catalogs on the same host must not collide on the derived id.
|
||||
a = cc._derive_id("https://example.com/team-a.json")
|
||||
b = cc._derive_id("https://example.com/team-b.json")
|
||||
assert a == "example-com-team-a"
|
||||
assert b == "example-com-team-b"
|
||||
assert a != b
|
||||
|
||||
|
||||
def test_derive_id_distinguishes_tlds():
|
||||
# Different TLDs sharing a second-level label must not collide.
|
||||
com = cc._derive_id("https://example.com/team-a.json")
|
||||
net = cc._derive_id("https://example.net/team-a.json")
|
||||
assert com == "example-com-team-a"
|
||||
assert net == "example-net-team-a"
|
||||
assert com != net
|
||||
|
||||
|
||||
def test_derive_id_falls_back_to_host_when_no_path():
|
||||
assert cc._derive_id("https://example.com/") == "example-com"
|
||||
|
||||
|
||||
def test_derive_id_for_local_path_uses_stem():
|
||||
assert cc._derive_id("./catalogs/my-catalog.json") == "my-catalog"
|
||||
|
||||
|
||||
def test_canonicalize_makes_relative_local_path_absolute(tmp_path: Path, monkeypatch):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
(tmp_path / "local.json").write_text("{}", encoding="utf-8")
|
||||
|
||||
result = cc._canonicalize_url("local.json")
|
||||
|
||||
assert Path(result).is_absolute()
|
||||
assert Path(result) == (tmp_path / "local.json").resolve()
|
||||
|
||||
|
||||
def test_canonicalize_leaves_remote_urls_untouched():
|
||||
for url in (
|
||||
"https://example.com/c.json",
|
||||
"http://localhost:8080/c.json",
|
||||
"file:///tmp/c.json",
|
||||
"builtin://default",
|
||||
):
|
||||
assert cc._canonicalize_url(url) == url
|
||||
|
||||
|
||||
def test_add_source_persists_absolute_local_path(tmp_path: Path, monkeypatch):
|
||||
project = tmp_path / "proj"
|
||||
(project / ".specify").mkdir(parents=True)
|
||||
catalog = project / "sub" / "cat.json"
|
||||
catalog.parent.mkdir()
|
||||
catalog.write_text("{}", encoding="utf-8")
|
||||
|
||||
monkeypatch.chdir(project)
|
||||
source = cc.add_source(project, "sub/cat.json", policy="install-allowed", priority=50)
|
||||
|
||||
assert Path(source.url).is_absolute()
|
||||
assert Path(source.url) == catalog.resolve()
|
||||
|
||||
|
||||
def test_remove_source_accepts_relative_local_path(tmp_path: Path, monkeypatch):
|
||||
"""add_source stores a local path as an absolute url, so remove_source must
|
||||
accept the same relative path the caller added; otherwise `remove ./cat.json`
|
||||
cannot undo `add ./cat.json`."""
|
||||
project = tmp_path / "proj"
|
||||
(project / ".specify").mkdir(parents=True)
|
||||
catalog = project / "sub" / "cat.json"
|
||||
catalog.parent.mkdir()
|
||||
catalog.write_text("{}", encoding="utf-8")
|
||||
monkeypatch.chdir(project)
|
||||
|
||||
cc.add_source(project, "sub/cat.json", policy="install-allowed", priority=50)
|
||||
# Removing with the same relative path must succeed (stored absolute).
|
||||
removed = cc.remove_source(project, "sub/cat.json")
|
||||
assert removed == "sub/cat.json"
|
||||
# And it is actually gone now.
|
||||
with pytest.raises(BundlerError, match="No project-scoped catalog source"):
|
||||
cc.remove_source(project, "sub/cat.json")
|
||||
|
||||
|
||||
def test_remove_by_id_does_not_also_delete_canonical_url_match(tmp_path: Path, monkeypatch):
|
||||
"""`remove <id>` must remove only the exact-id source, not also a different
|
||||
source whose url happens to equal the id's canonicalized path. (_canonicalize_url
|
||||
treats a bare id as a local path, so the canonical match is only a fallback when
|
||||
there is no exact id/url match.)"""
|
||||
project = tmp_path / "proj"
|
||||
(project / ".specify").mkdir(parents=True)
|
||||
monkeypatch.chdir(project)
|
||||
# Source A: id "local", a remote url.
|
||||
cc.add_source(
|
||||
project, "https://example.com/a.json", source_id="local",
|
||||
policy="install-allowed", priority=10,
|
||||
)
|
||||
# Source B: a local path that canonicalizes to <cwd>/local, with a distinct id.
|
||||
cc.add_source(project, "local", source_id="bsource", policy="install-allowed", priority=20)
|
||||
|
||||
removed = cc.remove_source(project, "local")
|
||||
assert removed == "local"
|
||||
ids = {c["id"] for c in cc._read(project)}
|
||||
assert "local" not in ids # the exact-id source was removed
|
||||
assert "bsource" in ids # the canonical-url source survives (not collateral)
|
||||
|
||||
|
||||
def test_add_source_refuses_symlinked_specify_escape(tmp_path: Path):
|
||||
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"):
|
||||
cc.add_source(project, "https://example.com/c.json", policy="install-allowed", priority=50)
|
||||
|
||||
|
||||
def test_read_rejects_non_list_catalogs(tmp_path: Path):
|
||||
project = tmp_path / "proj"
|
||||
(project / ".specify").mkdir(parents=True)
|
||||
cc._config_path(project).write_text(
|
||||
"schema_version: '1.0'\ncatalogs: not-a-list\n", encoding="utf-8"
|
||||
)
|
||||
|
||||
with pytest.raises(BundlerError, match="'catalogs' must be a list"):
|
||||
cc._read(project)
|
||||
|
||||
|
||||
def test_read_rejects_non_mapping_catalog_entry(tmp_path: Path):
|
||||
project = tmp_path / "proj"
|
||||
(project / ".specify").mkdir(parents=True)
|
||||
cc._config_path(project).write_text(
|
||||
"schema_version: '1.0'\ncatalogs:\n - just-a-string\n", encoding="utf-8"
|
||||
)
|
||||
|
||||
with pytest.raises(BundlerError, match="each catalog entry must be a mapping"):
|
||||
cc._read(project)
|
||||
|
||||
|
||||
def test_read_rejects_non_mapping_top_level(tmp_path: Path):
|
||||
project = tmp_path / "proj"
|
||||
(project / ".specify").mkdir(parents=True)
|
||||
cc._config_path(project).write_text("- a\n- b\n", encoding="utf-8")
|
||||
|
||||
with pytest.raises(BundlerError, match="expected a mapping at the top level"):
|
||||
cc._read(project)
|
||||
|
||||
|
||||
def test_read_rejects_unknown_schema_version(tmp_path: Path):
|
||||
project = tmp_path / "proj"
|
||||
(project / ".specify").mkdir(parents=True)
|
||||
cc._config_path(project).write_text(
|
||||
"schema_version: '2.0'\ncatalogs: []\n", encoding="utf-8"
|
||||
)
|
||||
|
||||
with pytest.raises(BundlerError, match="Unsupported catalog config schema version"):
|
||||
cc._read(project)
|
||||
|
||||
|
||||
def test_read_accepts_forward_compatible_minor_schema(tmp_path: Path):
|
||||
project = tmp_path / "proj"
|
||||
(project / ".specify").mkdir(parents=True)
|
||||
cc._config_path(project).write_text(
|
||||
"schema_version: '1.5'\ncatalogs: []\n", encoding="utf-8"
|
||||
)
|
||||
assert cc._read(project) == []
|
||||
|
||||
|
||||
def test_read_tolerates_missing_schema_version(tmp_path: Path):
|
||||
project = tmp_path / "proj"
|
||||
(project / ".specify").mkdir(parents=True)
|
||||
cc._config_path(project).write_text("catalogs: []\n", encoding="utf-8")
|
||||
assert cc._read(project) == []
|
||||
|
||||
|
||||
def test_read_returns_empty_for_missing_or_empty_config(tmp_path: Path):
|
||||
project = tmp_path / "proj"
|
||||
(project / ".specify").mkdir(parents=True)
|
||||
assert cc._read(project) == []
|
||||
|
||||
cc._config_path(project).write_text("schema_version: '1.0'\n", encoding="utf-8")
|
||||
assert cc._read(project) == []
|
||||
|
||||
|
||||
def test_slug_lowercases_for_deterministic_ids():
|
||||
# Mixed-case local filenames must derive the same id regardless of case so
|
||||
# the case-sensitive duplicate check cannot admit logical duplicates.
|
||||
assert cc._slug("Team-A") == "team-a"
|
||||
assert cc._derive_id("./catalogs/Team-A.json") == "team-a"
|
||||
assert cc._derive_id("https://Example.com/Team-A.json") == "example-com-team-a"
|
||||
|
||||
|
||||
def test_derive_id_handles_ipv6_literal():
|
||||
# An IPv6 host must not be truncated at the first colon.
|
||||
derived = cc._derive_id("https://[2001:db8::1]/catalog.json")
|
||||
assert derived == "2001-db8--1-catalog"
|
||||
|
||||
|
||||
def test_derive_id_ignores_credentials_and_port():
|
||||
assert cc._derive_id("https://user:pw@example.com:8443/c.json") == "example-com-c"
|
||||
|
||||
|
||||
def test_add_source_rejects_unsupported_scheme(tmp_path: Path):
|
||||
project = tmp_path / "proj"
|
||||
(project / ".specify").mkdir(parents=True)
|
||||
with pytest.raises(BundlerError, match="Unsupported catalog url scheme"):
|
||||
cc.add_source(project, "ssh://host/catalog.json", policy="install-allowed", priority=50)
|
||||
|
||||
|
||||
def test_add_source_allows_local_path_with_colon(tmp_path: Path, monkeypatch):
|
||||
project = tmp_path / "proj"
|
||||
(project / ".specify").mkdir(parents=True)
|
||||
monkeypatch.chdir(project)
|
||||
# A relative path containing ':' but no '://' is still a local path.
|
||||
source = cc.add_source(project, "weird:name.json", policy="install-allowed", priority=50)
|
||||
assert source.url.endswith("weird:name.json") or "weird" in source.url
|
||||
|
||||
|
||||
def test_add_source_rejects_plain_http_for_non_localhost(tmp_path: Path):
|
||||
project = tmp_path / "proj"
|
||||
(project / ".specify").mkdir(parents=True)
|
||||
with pytest.raises(BundlerError, match="HTTPS"):
|
||||
cc.add_source(project, "http://example.com/catalog.json", policy="install-allowed", priority=50)
|
||||
|
||||
|
||||
def test_add_source_allows_http_for_localhost(tmp_path: Path):
|
||||
project = tmp_path / "proj"
|
||||
(project / ".specify").mkdir(parents=True)
|
||||
source = cc.add_source(project, "http://localhost:8080/c.json", policy="install-allowed", priority=50)
|
||||
assert source.url == "http://localhost:8080/c.json"
|
||||
|
||||
|
||||
def test_add_source_rejects_host_less_remote_urls(tmp_path: Path):
|
||||
project = tmp_path / "proj"
|
||||
(project / ".specify").mkdir(parents=True)
|
||||
for url in ("https://:8080", "https://user@"):
|
||||
with pytest.raises(BundlerError, match="host"):
|
||||
cc.add_source(project, url, policy="install-allowed", priority=50)
|
||||
|
||||
|
||||
def test_add_source_wraps_invalid_ipv6_as_bundler_error(tmp_path: Path):
|
||||
project = tmp_path / "proj"
|
||||
(project / ".specify").mkdir(parents=True)
|
||||
with pytest.raises(BundlerError, match="Invalid catalog url"):
|
||||
cc.add_source(project, "https://[::1/c.json", policy="install-allowed", priority=50)
|
||||
|
||||
|
||||
def test_remove_source_does_not_crash_on_invalid_ipv6(tmp_path: Path):
|
||||
project = tmp_path / "proj"
|
||||
(project / ".specify").mkdir(parents=True)
|
||||
with pytest.raises(BundlerError, match="No project-scoped catalog source"):
|
||||
cc.remove_source(project, "https://[::1/c.json")
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Unit tests for conflict detection (T034): integration clash and overlap precedence."""
|
||||
from __future__ import annotations
|
||||
|
||||
from specify_cli.bundler.models.manifest import BundleManifest, ComponentRef
|
||||
from specify_cli.bundler.models.records import InstalledBundleRecord
|
||||
from specify_cli.bundler.services.conflict import detect_conflicts
|
||||
from tests.bundler_helpers import valid_manifest_dict
|
||||
|
||||
|
||||
def _manifest(**overrides) -> BundleManifest:
|
||||
return BundleManifest.from_dict(valid_manifest_dict(**overrides))
|
||||
|
||||
|
||||
def test_integration_clash_is_blocking():
|
||||
manifest = _manifest(integration={"id": "claude"})
|
||||
report = detect_conflicts(manifest, active_integration="copilot", installed=[])
|
||||
assert report.has_blocking_conflict is True
|
||||
assert "claude" in report.integration_clash
|
||||
assert "copilot" in report.integration_clash
|
||||
|
||||
|
||||
def test_matching_integration_no_clash():
|
||||
manifest = _manifest(integration={"id": "copilot"})
|
||||
report = detect_conflicts(manifest, active_integration="copilot", installed=[])
|
||||
assert report.has_blocking_conflict is False
|
||||
|
||||
|
||||
def test_agnostic_bundle_never_clashes():
|
||||
manifest = _manifest() # no integration
|
||||
report = detect_conflicts(manifest, active_integration="copilot", installed=[])
|
||||
assert report.has_blocking_conflict is False
|
||||
|
||||
|
||||
def test_overlap_with_other_bundle_is_reported():
|
||||
manifest = _manifest()
|
||||
other = InstalledBundleRecord.create(
|
||||
bundle_id="other",
|
||||
version="1.0.0",
|
||||
components=[ComponentRef(kind="presets", id="preset-a")],
|
||||
)
|
||||
report = detect_conflicts(manifest, active_integration="copilot", installed=[other])
|
||||
assert any("preset-a" in o and "other" in o for o in report.overlaps)
|
||||
assert report.has_blocking_conflict is False
|
||||
|
||||
|
||||
def test_same_bundle_reinstall_is_not_overlap():
|
||||
manifest = _manifest()
|
||||
same = InstalledBundleRecord.create(
|
||||
bundle_id="demo-bundle",
|
||||
version="1.2.0",
|
||||
components=[ComponentRef(kind="presets", id="preset-a")],
|
||||
)
|
||||
report = detect_conflicts(manifest, active_integration="copilot", installed=[same])
|
||||
assert report.overlaps == []
|
||||
@@ -0,0 +1,193 @@
|
||||
"""Unit tests for the artifact packager (T023): contents, versioning, determinism."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from specify_cli.bundler import BundlerError
|
||||
from specify_cli.bundler.services.packager import build_bundle
|
||||
from tests.bundler_helpers import valid_manifest_dict
|
||||
|
||||
|
||||
def _make_bundle(directory: Path, *, extra_files: dict | None = None) -> Path:
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
(directory / "bundle.yml").write_text(
|
||||
yaml.safe_dump(valid_manifest_dict()), encoding="utf-8"
|
||||
)
|
||||
(directory / "README.md").write_text("# Demo bundle", encoding="utf-8")
|
||||
for rel, content in (extra_files or {}).items():
|
||||
target = directory / rel
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(content, encoding="utf-8")
|
||||
return directory
|
||||
|
||||
|
||||
def test_artifact_named_by_id_and_version(tmp_path: Path):
|
||||
bundle = _make_bundle(tmp_path / "b")
|
||||
result = build_bundle(bundle, output_dir=tmp_path / "out")
|
||||
assert result.artifact_path.name == "demo-bundle-1.2.0.zip"
|
||||
|
||||
|
||||
def test_artifact_contains_manifest_and_assets(tmp_path: Path):
|
||||
bundle = _make_bundle(tmp_path / "b", extra_files={"assets/logo.txt": "logo"})
|
||||
result = build_bundle(bundle, output_dir=tmp_path / "out")
|
||||
with zipfile.ZipFile(result.artifact_path) as archive:
|
||||
names = set(archive.namelist())
|
||||
assert "bundle.yml" in names
|
||||
assert "README.md" in names
|
||||
assert "assets/logo.txt" in names
|
||||
|
||||
|
||||
def test_build_refuses_invalid_manifest(tmp_path: Path):
|
||||
bundle = tmp_path / "b"
|
||||
bundle.mkdir()
|
||||
data = valid_manifest_dict()
|
||||
del data["bundle"]["license"]
|
||||
(bundle / "bundle.yml").write_text(yaml.safe_dump(data), encoding="utf-8")
|
||||
(bundle / "README.md").write_text("# x", encoding="utf-8")
|
||||
with pytest.raises(BundlerError, match="validate"):
|
||||
build_bundle(bundle, output_dir=tmp_path / "out")
|
||||
|
||||
|
||||
def test_build_missing_manifest_errors(tmp_path: Path):
|
||||
with pytest.raises(BundlerError, match="No bundle.yml"):
|
||||
build_bundle(tmp_path, output_dir=tmp_path / "out")
|
||||
|
||||
|
||||
def test_build_is_deterministic(tmp_path: Path):
|
||||
bundle = _make_bundle(tmp_path / "b", extra_files={"a.txt": "a", "z.txt": "z"})
|
||||
first = build_bundle(bundle, output_dir=tmp_path / "out1")
|
||||
second = build_bundle(bundle, output_dir=tmp_path / "out2")
|
||||
with zipfile.ZipFile(first.artifact_path) as a, zipfile.ZipFile(second.artifact_path) as b:
|
||||
# Same files, same order (sorted).
|
||||
assert a.namelist() == b.namelist()
|
||||
# Fixed timestamps + permissions make each member byte-identical.
|
||||
for left, right in zip(a.infolist(), b.infolist()):
|
||||
assert left.date_time == right.date_time
|
||||
assert left.external_attr == right.external_attr
|
||||
# The whole artifact is byte-for-byte reproducible.
|
||||
assert first.artifact_path.read_bytes() == second.artifact_path.read_bytes()
|
||||
|
||||
|
||||
def test_output_dir_inside_bundle_excludes_prior_artifacts(tmp_path: Path):
|
||||
bundle = _make_bundle(tmp_path / "b", extra_files={"a.txt": "a"})
|
||||
out_dir = bundle / "dist"
|
||||
# Build twice into a dir nested in the bundle; the second build must not
|
||||
# re-package the first artifact, so contents stay identical and bounded.
|
||||
first = build_bundle(bundle, output_dir=out_dir)
|
||||
second = build_bundle(bundle, output_dir=out_dir)
|
||||
with zipfile.ZipFile(second.artifact_path) as archive:
|
||||
names = archive.namelist()
|
||||
assert not any(name.startswith("dist/") for name in names)
|
||||
assert not any(name.endswith(".zip") for name in names)
|
||||
assert first.file_count == second.file_count
|
||||
|
||||
|
||||
def test_prior_version_artifact_not_repackaged(tmp_path: Path):
|
||||
# An older artifact sitting next to bundle.yml must not be packaged.
|
||||
bundle = _make_bundle(tmp_path / "b", extra_files={"a.txt": "a"})
|
||||
(bundle / "demo-bundle-0.9.0.zip").write_bytes(b"PK\x03\x04 old artifact")
|
||||
result = build_bundle(bundle, output_dir=bundle)
|
||||
with zipfile.ZipFile(result.artifact_path) as archive:
|
||||
names = archive.namelist()
|
||||
assert not any(name.endswith(".zip") for name in names)
|
||||
assert "demo-bundle-0.9.0.zip" not in names
|
||||
|
||||
|
||||
def test_symlinked_directory_is_not_followed(tmp_path: Path):
|
||||
outside = tmp_path / "outside"
|
||||
outside.mkdir()
|
||||
(outside / "secret.txt").write_text("secret", encoding="utf-8")
|
||||
bundle = _make_bundle(tmp_path / "b", extra_files={"a.txt": "a"})
|
||||
link = bundle / "linkdir"
|
||||
try:
|
||||
link.symlink_to(outside, target_is_directory=True)
|
||||
except (OSError, NotImplementedError):
|
||||
pytest.skip("symlinks not supported on this platform")
|
||||
# Build must succeed (no ensure_within failure) and must not pull in the
|
||||
# out-of-tree file behind the symlinked directory.
|
||||
result = build_bundle(bundle, output_dir=tmp_path / "out")
|
||||
with zipfile.ZipFile(result.artifact_path) as archive:
|
||||
names = archive.namelist()
|
||||
assert "linkdir/secret.txt" not in names
|
||||
assert not any("secret" in name for name in names)
|
||||
|
||||
|
||||
def test_unsafe_bundle_id_is_rejected_before_build(tmp_path: Path):
|
||||
data = valid_manifest_dict()
|
||||
data["bundle"]["id"] = "../evil"
|
||||
bundle = tmp_path / "b"
|
||||
bundle.mkdir(parents=True)
|
||||
(bundle / "bundle.yml").write_text(yaml.safe_dump(data), encoding="utf-8")
|
||||
(bundle / "README.md").write_text("# x", encoding="utf-8")
|
||||
with pytest.raises(BundlerError):
|
||||
build_bundle(bundle, output_dir=tmp_path / "out")
|
||||
# The traversal target must not have been written outside out_dir.
|
||||
assert not (tmp_path / "evil-1.2.0.zip").exists()
|
||||
|
||||
|
||||
def test_build_refuses_missing_readme(tmp_path: Path):
|
||||
bundle = tmp_path / "b"
|
||||
bundle.mkdir()
|
||||
(bundle / "bundle.yml").write_text(
|
||||
yaml.safe_dump(valid_manifest_dict()), encoding="utf-8"
|
||||
)
|
||||
with pytest.raises(BundlerError, match="README.md"):
|
||||
build_bundle(bundle, output_dir=tmp_path / "out")
|
||||
|
||||
|
||||
def test_asset_zip_starting_with_bundle_id_is_packaged(tmp_path: Path):
|
||||
# A non-artifact asset whose name merely starts with the bundle id (but is
|
||||
# not a semver-named build artifact) must still be included.
|
||||
bundle = _make_bundle(tmp_path / "b", extra_files={"demo-bundle-assets.zip": "data"})
|
||||
result = build_bundle(bundle, output_dir=tmp_path / "out")
|
||||
with zipfile.ZipFile(result.artifact_path) as archive:
|
||||
names = set(archive.namelist())
|
||||
assert "demo-bundle-assets.zip" in names
|
||||
|
||||
|
||||
def test_prior_semver_artifact_is_excluded(tmp_path: Path):
|
||||
bundle = _make_bundle(tmp_path / "b", extra_files={"demo-bundle-0.9.0.zip": "old"})
|
||||
result = build_bundle(bundle, output_dir=bundle)
|
||||
with zipfile.ZipFile(result.artifact_path) as archive:
|
||||
names = set(archive.namelist())
|
||||
assert "demo-bundle-0.9.0.zip" not in names
|
||||
|
||||
|
||||
def test_prior_artifact_with_prerelease_and_build_is_excluded(tmp_path: Path):
|
||||
# A semver artifact carrying both prerelease and build metadata must still
|
||||
# be recognized as a prior build artifact and excluded.
|
||||
bundle = _make_bundle(
|
||||
tmp_path / "b", extra_files={"demo-bundle-1.0.0-rc1+build5.zip": "old"}
|
||||
)
|
||||
result = build_bundle(bundle, output_dir=bundle)
|
||||
with zipfile.ZipFile(result.artifact_path) as archive:
|
||||
names = set(archive.namelist())
|
||||
assert "demo-bundle-1.0.0-rc1+build5.zip" not in names
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
os.name == "nt",
|
||||
reason="Windows filesystems do not carry Unix execute bits, so chmod(0o755) "
|
||||
"is a no-op and there is no executability to preserve.",
|
||||
)
|
||||
def test_executable_bit_preserved_in_artifact(tmp_path: Path):
|
||||
bundle = _make_bundle(tmp_path / "bundle")
|
||||
script = bundle / "scripts" / "hook.sh"
|
||||
script.parent.mkdir(parents=True, exist_ok=True)
|
||||
script.write_text("#!/bin/sh\necho hi\n", encoding="utf-8")
|
||||
script.chmod(0o755)
|
||||
|
||||
result = build_bundle(bundle, output_dir=tmp_path / "out")
|
||||
with zipfile.ZipFile(result.artifact_path) as archive:
|
||||
modes = {
|
||||
info.filename: (info.external_attr >> 16) & 0o777
|
||||
for info in archive.infolist()
|
||||
}
|
||||
# Executable source -> 0755; plain text files -> 0644.
|
||||
assert modes["scripts/hook.sh"] == 0o755
|
||||
assert modes["README.md"] == 0o644
|
||||
@@ -0,0 +1,217 @@
|
||||
"""Unit tests for the primitive-dispatch bridge (T044).
|
||||
|
||||
Covers routing, offline gating, and the network-aware ``DefaultPrimitiveInstaller``
|
||||
seam — without touching real catalogs or the network (Constitution Principle II,
|
||||
offline-first).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from specify_cli.bundler import BundlerError
|
||||
from specify_cli.bundler.models.manifest import ComponentRef
|
||||
from specify_cli.bundler.services.adapters import DefaultPrimitiveInstaller
|
||||
from specify_cli.bundler.services.primitives import (
|
||||
_ExtensionKindManager,
|
||||
_PresetKindManager,
|
||||
_StepKindManager,
|
||||
_WorkflowKindManager,
|
||||
primitive_manager,
|
||||
)
|
||||
|
||||
|
||||
def _component(kind: str, cid: str = "x") -> ComponentRef:
|
||||
return ComponentRef(kind=kind, id=cid)
|
||||
|
||||
|
||||
def test_primitive_manager_routes_each_kind(tmp_path: Path):
|
||||
assert isinstance(primitive_manager("presets", tmp_path), _PresetKindManager)
|
||||
assert isinstance(primitive_manager("extensions", tmp_path), _ExtensionKindManager)
|
||||
assert isinstance(primitive_manager("workflows", tmp_path), _WorkflowKindManager)
|
||||
assert isinstance(primitive_manager("steps", tmp_path), _StepKindManager)
|
||||
|
||||
|
||||
def test_primitive_manager_rejects_unknown_kind(tmp_path: Path):
|
||||
with pytest.raises(BundlerError, match="Unknown component kind"):
|
||||
primitive_manager("bogus", tmp_path)
|
||||
|
||||
|
||||
def test_offline_preset_not_bundled_refuses(tmp_path: Path):
|
||||
manager = primitive_manager("presets", tmp_path, allow_network=False)
|
||||
with pytest.raises(BundlerError, match="network access is disabled"):
|
||||
manager.install(_component("presets", "definitely-not-bundled"))
|
||||
|
||||
|
||||
def test_offline_extension_not_bundled_refuses(tmp_path: Path):
|
||||
manager = primitive_manager("extensions", tmp_path, allow_network=False)
|
||||
with pytest.raises(BundlerError, match="network access is disabled"):
|
||||
manager.install(_component("extensions", "definitely-not-bundled"))
|
||||
|
||||
|
||||
def test_offline_workflow_refuses_without_network(tmp_path: Path):
|
||||
manager = primitive_manager("workflows", tmp_path, allow_network=False)
|
||||
with pytest.raises(BundlerError, match="network access is disabled"):
|
||||
manager.install(_component("workflows"))
|
||||
|
||||
|
||||
def test_offline_step_refuses_without_network(tmp_path: Path):
|
||||
manager = primitive_manager("steps", tmp_path, allow_network=False)
|
||||
with pytest.raises(BundlerError, match="network access is disabled"):
|
||||
manager.install(_component("steps"))
|
||||
|
||||
|
||||
def test_default_installer_threads_allow_network(tmp_path: Path):
|
||||
installer = DefaultPrimitiveInstaller(allow_network=False)
|
||||
with pytest.raises(BundlerError, match="network access is disabled"):
|
||||
installer.install(tmp_path, _component("workflows"))
|
||||
|
||||
|
||||
def test_offline_workflow_allows_bundled(tmp_path: Path, monkeypatch):
|
||||
# A workflow that ships with Spec Kit must install even with --offline.
|
||||
import specify_cli
|
||||
import specify_cli._assets as assets
|
||||
|
||||
monkeypatch.setattr(
|
||||
assets, "_locate_bundled_workflow", lambda wid: tmp_path / "wf"
|
||||
)
|
||||
calls: list[str] = []
|
||||
monkeypatch.setattr(specify_cli, "workflow_add", lambda wid: calls.append(wid))
|
||||
|
||||
manager = primitive_manager("workflows", tmp_path, allow_network=False)
|
||||
manager.install(_component("workflows", "bundled-wf"))
|
||||
|
||||
assert calls == ["bundled-wf"]
|
||||
|
||||
|
||||
def test_assert_pinned_version_matches_passes():
|
||||
from specify_cli.bundler.services.primitives import _assert_pinned_version
|
||||
|
||||
# Equal (including v-prefix/normalization) is accepted; no version pins are no-ops.
|
||||
_assert_pinned_version("Preset", "p", "2.0.0", "2.0.0")
|
||||
_assert_pinned_version("Preset", "p", "2.0.0", "v2.0.0")
|
||||
_assert_pinned_version("Preset", "p", None, "9.9.9")
|
||||
_assert_pinned_version("Preset", "p", "2.0.0", None)
|
||||
|
||||
|
||||
def test_assert_pinned_version_mismatch_raises():
|
||||
from specify_cli.bundler.services.primitives import _assert_pinned_version
|
||||
|
||||
with pytest.raises(BundlerError, match="pinned to version 2.0.0"):
|
||||
_assert_pinned_version("Preset", "preset-a", "2.0.0", "3.1.0")
|
||||
|
||||
|
||||
def test_workflow_version_mismatch_refuses(tmp_path: Path, monkeypatch):
|
||||
from specify_cli.workflows.catalog import WorkflowCatalog
|
||||
|
||||
monkeypatch.setattr(
|
||||
WorkflowCatalog, "get_workflow_info", lambda self, wid: {"version": "9.9.9"}
|
||||
)
|
||||
manager = primitive_manager("workflows", tmp_path, allow_network=True)
|
||||
component = ComponentRef(kind="workflows", id="wf-a", version="0.3.0")
|
||||
with pytest.raises(BundlerError, match="pinned to version 0.3.0"):
|
||||
manager.install(component)
|
||||
|
||||
|
||||
def test_preset_install_preserves_explicit_zero_priority(tmp_path: Path, monkeypatch):
|
||||
import specify_cli._assets as assets
|
||||
|
||||
calls = {}
|
||||
|
||||
class _FakeManager:
|
||||
def install_from_directory(self, directory, speckit_version, priority):
|
||||
calls["priority"] = priority
|
||||
|
||||
monkeypatch.setattr(assets, "_locate_bundled_preset", lambda cid: tmp_path)
|
||||
|
||||
manager = primitive_manager("presets", tmp_path, allow_network=False)
|
||||
manager._manager = _FakeManager()
|
||||
manager.install(ComponentRef(kind="presets", id="p", priority=0))
|
||||
|
||||
# An explicit priority of 0 must be passed through, not replaced by default.
|
||||
assert calls["priority"] == 0
|
||||
|
||||
|
||||
def _write_manifest(path: Path, root_key: str, version: str) -> Path:
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
(path / f"{root_key}.yml").write_text(
|
||||
f"{root_key}:\n id: x\n version: {version}\n", encoding="utf-8"
|
||||
)
|
||||
return path
|
||||
|
||||
|
||||
def test_bundled_extension_pin_mismatch_refuses(tmp_path: Path, monkeypatch):
|
||||
"""A bundled extension whose version != the manifest pin must be refused
|
||||
(the bundled path previously skipped the pin the catalog path enforces)."""
|
||||
import specify_cli._assets as assets
|
||||
from specify_cli.extensions import ExtensionManager
|
||||
|
||||
bundled = _write_manifest(tmp_path / "ext", "extension", "1.0.0")
|
||||
monkeypatch.setattr(assets, "_locate_bundled_extension", lambda cid: bundled)
|
||||
called: list = []
|
||||
monkeypatch.setattr(
|
||||
ExtensionManager, "install_from_directory",
|
||||
lambda self, *a, **k: called.append(a),
|
||||
)
|
||||
|
||||
manager = primitive_manager("extensions", tmp_path, allow_network=False)
|
||||
with pytest.raises(BundlerError, match="pinned to version 2.0.0"):
|
||||
manager.install(ComponentRef(kind="extensions", id="my-ext", version="2.0.0"))
|
||||
assert called == [] # install must not proceed
|
||||
|
||||
|
||||
def test_bundled_extension_pin_match_installs(tmp_path: Path, monkeypatch):
|
||||
import specify_cli._assets as assets
|
||||
from specify_cli.extensions import ExtensionManager
|
||||
|
||||
bundled = _write_manifest(tmp_path / "ext", "extension", "1.0.0")
|
||||
monkeypatch.setattr(assets, "_locate_bundled_extension", lambda cid: bundled)
|
||||
called: list = []
|
||||
monkeypatch.setattr(
|
||||
ExtensionManager, "install_from_directory",
|
||||
lambda self, *a, **k: called.append(a),
|
||||
)
|
||||
|
||||
manager = primitive_manager("extensions", tmp_path, allow_network=False)
|
||||
# matching pin, and unpinned, both install cleanly
|
||||
manager.install(ComponentRef(kind="extensions", id="my-ext", version="1.0.0"))
|
||||
manager.install(ComponentRef(kind="extensions", id="my-ext", version=None))
|
||||
assert len(called) == 2
|
||||
|
||||
|
||||
def test_bundled_preset_pin_mismatch_refuses(tmp_path: Path, monkeypatch):
|
||||
import specify_cli._assets as assets
|
||||
from specify_cli.presets import PresetManager
|
||||
|
||||
bundled = _write_manifest(tmp_path / "preset", "preset", "1.0.0")
|
||||
monkeypatch.setattr(assets, "_locate_bundled_preset", lambda cid: bundled)
|
||||
called: list = []
|
||||
monkeypatch.setattr(
|
||||
PresetManager, "install_from_directory",
|
||||
lambda self, *a, **k: called.append(a),
|
||||
)
|
||||
|
||||
manager = primitive_manager("presets", tmp_path, allow_network=False)
|
||||
with pytest.raises(BundlerError, match="pinned to version 2.0.0"):
|
||||
manager.install(ComponentRef(kind="presets", id="my-preset", version="2.0.0"))
|
||||
assert called == []
|
||||
|
||||
|
||||
def test_bundled_preset_pin_match_installs(tmp_path: Path, monkeypatch):
|
||||
import specify_cli._assets as assets
|
||||
from specify_cli.presets import PresetManager
|
||||
|
||||
bundled = _write_manifest(tmp_path / "preset", "preset", "1.0.0")
|
||||
monkeypatch.setattr(assets, "_locate_bundled_preset", lambda cid: bundled)
|
||||
called: list = []
|
||||
monkeypatch.setattr(
|
||||
PresetManager, "install_from_directory",
|
||||
lambda self, *a, **k: called.append(a),
|
||||
)
|
||||
|
||||
manager = primitive_manager("presets", tmp_path, allow_network=False)
|
||||
# matching pin, and unpinned, both proceed to install
|
||||
manager.install(ComponentRef(kind="presets", id="my-preset", version="1.0.0"))
|
||||
manager.install(ComponentRef(kind="presets", id="my-preset", version=None))
|
||||
assert len(called) == 2
|
||||
@@ -0,0 +1,190 @@
|
||||
"""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) == []
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Unit tests for the bundle reference checker (T047 / FR-005 / SC-007).
|
||||
|
||||
Resolution is offline-first: bundled and installed components resolve without a
|
||||
network; unknown ids fail online and downgrade to warnings offline.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from specify_cli.bundler.models.manifest import ComponentRef
|
||||
from specify_cli.bundler.services.references import make_reference_checker
|
||||
from tests.bundler_helpers import make_project
|
||||
|
||||
|
||||
def _ref(kind: str, id_: str) -> ComponentRef:
|
||||
return ComponentRef(kind=kind, id=id_, version="1.0.0")
|
||||
|
||||
|
||||
def test_bundled_extension_resolves(tmp_path: Path):
|
||||
root = make_project(tmp_path)
|
||||
warnings: list[str] = []
|
||||
check = make_reference_checker(root, allow_network=True, warnings=warnings)
|
||||
assert check(_ref("extensions", "agent-context")) is None
|
||||
assert warnings == []
|
||||
|
||||
|
||||
def test_unknown_reference_errors_online(tmp_path: Path):
|
||||
root = make_project(tmp_path)
|
||||
warnings: list[str] = []
|
||||
check = make_reference_checker(root, allow_network=True, warnings=warnings)
|
||||
problem = check(_ref("presets", "does-not-exist"))
|
||||
assert problem is not None
|
||||
assert "does-not-exist" in problem
|
||||
|
||||
|
||||
def test_unknown_reference_warns_offline(tmp_path: Path):
|
||||
root = make_project(tmp_path)
|
||||
warnings: list[str] = []
|
||||
check = make_reference_checker(root, allow_network=False, warnings=warnings)
|
||||
assert check(_ref("presets", "does-not-exist")) is None
|
||||
assert any("does-not-exist" in w for w in warnings)
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Unit tests for the resolver: version gate and integration compatibility."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from specify_cli.bundler import BundlerError
|
||||
from specify_cli.bundler.models.manifest import BundleManifest
|
||||
from specify_cli.bundler.services.resolver import resolve_install_plan
|
||||
from tests.bundler_helpers import valid_manifest_dict
|
||||
|
||||
|
||||
def _manifest(**overrides) -> BundleManifest:
|
||||
return BundleManifest.from_dict(valid_manifest_dict(**overrides))
|
||||
|
||||
|
||||
def test_plan_expands_all_components():
|
||||
plan = resolve_install_plan(
|
||||
_manifest(), speckit_version="0.11.2", active_integration="copilot"
|
||||
)
|
||||
assert plan.component_count == 4
|
||||
assert plan.bundle_id == "demo-bundle"
|
||||
|
||||
|
||||
def test_version_gate_refuses_incompatible():
|
||||
manifest = _manifest(requires={"speckit_version": ">=99.0.0"})
|
||||
with pytest.raises(BundlerError, match="requires Spec Kit"):
|
||||
resolve_install_plan(
|
||||
manifest, speckit_version="0.11.2", active_integration="copilot"
|
||||
)
|
||||
|
||||
|
||||
def test_integration_clash_halts():
|
||||
manifest = _manifest(integration={"id": "claude"})
|
||||
with pytest.raises(BundlerError, match="active integration"):
|
||||
resolve_install_plan(
|
||||
manifest, speckit_version="0.11.2", active_integration="copilot"
|
||||
)
|
||||
|
||||
|
||||
def test_agnostic_inherits_active_integration():
|
||||
plan = resolve_install_plan(
|
||||
_manifest(), speckit_version="0.11.2", active_integration="copilot"
|
||||
)
|
||||
assert plan.effective_integration == "copilot"
|
||||
|
||||
|
||||
def test_matching_integration_is_allowed():
|
||||
manifest = _manifest(integration={"id": "copilot"})
|
||||
plan = resolve_install_plan(
|
||||
manifest, speckit_version="0.11.2", active_integration="copilot"
|
||||
)
|
||||
assert plan.effective_integration == "copilot"
|
||||
|
||||
|
||||
def test_pinned_integration_with_indeterminate_active_fails():
|
||||
# FR-019 guard: a bundle that pins an integration must not silently adopt it
|
||||
# when the project's active integration cannot be determined.
|
||||
manifest = _manifest(integration={"id": "claude"})
|
||||
with pytest.raises(BundlerError, match="could not be determined"):
|
||||
resolve_install_plan(
|
||||
manifest, speckit_version="0.11.2", active_integration=None
|
||||
)
|
||||
|
||||
|
||||
def test_pinned_integration_with_indeterminate_active_allows_explicit_override():
|
||||
manifest = _manifest(integration={"id": "claude"})
|
||||
plan = resolve_install_plan(
|
||||
manifest,
|
||||
speckit_version="0.11.2",
|
||||
active_integration="claude",
|
||||
integration_explicit=True,
|
||||
)
|
||||
assert plan.effective_integration == "claude"
|
||||
|
||||
|
||||
def test_tool_requirements_become_warnings():
|
||||
manifest = _manifest(requires={"speckit_version": ">=0.1.0", "tools": ["docker"]})
|
||||
plan = resolve_install_plan(
|
||||
manifest, speckit_version="0.11.2", active_integration="copilot"
|
||||
)
|
||||
assert any("docker" in w for w in plan.warnings)
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Unit tests for the bundle manifest validator service."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from specify_cli.bundler.models.manifest import BundleManifest
|
||||
from specify_cli.bundler.services import validator as validator_mod
|
||||
from specify_cli.bundler.services.validator import validate_manifest
|
||||
from tests.bundler_helpers import valid_manifest_dict
|
||||
|
||||
|
||||
def _manifest(**overrides) -> BundleManifest:
|
||||
return BundleManifest.from_dict(valid_manifest_dict(**overrides))
|
||||
|
||||
|
||||
def test_invalid_speckit_constraint_reported_as_error():
|
||||
manifest = _manifest(requires={"speckit_version": ">>bad"})
|
||||
report = validate_manifest(manifest)
|
||||
assert not report.ok
|
||||
assert any("speckit_version" in e for e in report.errors)
|
||||
|
||||
|
||||
def test_non_bundler_error_not_swallowed(monkeypatch):
|
||||
# A programming error inside constraint parsing must propagate, not be
|
||||
# masked behind an "invalid constraint" validation message.
|
||||
def boom(_value):
|
||||
raise RuntimeError("unexpected bug")
|
||||
|
||||
monkeypatch.setattr(validator_mod, "parse_constraint", boom)
|
||||
manifest = _manifest(requires={"speckit_version": ">=1.0.0"})
|
||||
with pytest.raises(RuntimeError, match="unexpected bug"):
|
||||
validate_manifest(manifest)
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Unit tests for version parsing and constraint satisfaction (FR-016 gate)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from specify_cli.bundler import BundlerError
|
||||
from specify_cli.bundler.lib.versioning import is_semver, satisfies
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value,expected", [
|
||||
("1.0.0", True),
|
||||
("0.11.2", True),
|
||||
("1.2.3-rc1", True),
|
||||
("1.2.3-alpha1", True),
|
||||
("1.2.3-beta2", True),
|
||||
("v1.2.3", True),
|
||||
("not-a-version", False),
|
||||
("", False),
|
||||
# packaging.version.Version accepts these partial versions; SemVer must not.
|
||||
("1", False),
|
||||
("1.0", False),
|
||||
("1.2.3.4", False),
|
||||
])
|
||||
def test_is_semver(value, expected):
|
||||
assert is_semver(value) is expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("installed,constraint,ok", [
|
||||
("0.11.2", ">=0.1.0", True),
|
||||
("0.11.2", ">=1.0.0", False),
|
||||
("1.0.0", ">=1.0.0,<2.0.0", True),
|
||||
("2.0.0", ">=1.0.0,<2.0.0", False),
|
||||
("1.5.0", "", True), # empty constraint is permissive
|
||||
# Prerelease spellings normalize consistently for constraint checks.
|
||||
("1.2.3-rc1", ">=1.2.0", True),
|
||||
("1.2.3-alpha1", ">=2.0.0", False),
|
||||
])
|
||||
def test_satisfies(installed, constraint, ok):
|
||||
assert satisfies(installed, constraint) is ok
|
||||
|
||||
|
||||
def test_invalid_constraint_raises():
|
||||
with pytest.raises(BundlerError):
|
||||
satisfies("1.0.0", ">>bad")
|
||||
|
||||
|
||||
def test_uppercase_v_prefix_tolerated():
|
||||
# Mirrors specify_cli._version tag normalization (V -> v).
|
||||
assert is_semver("V1.2.3") is True
|
||||
assert satisfies("V1.2.3", ">=1.2.0") is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("installed,constraint,ok", [
|
||||
# Prerelease spellings are now normalized inside constraints too, so a
|
||||
# constraint like ">=1.2.3-rc1" parses (previously raised InvalidSpecifier).
|
||||
("1.2.3-rc2", ">=1.2.3-rc1", True),
|
||||
("1.2.2", ">=1.2.3-rc1", False),
|
||||
("1.5.0", ">=1.2.3-rc1,<2.0.0", True),
|
||||
("1.2.3-beta.1", ">=1.2.3-alpha1", True),
|
||||
])
|
||||
def test_satisfies_prerelease_in_constraint(installed, constraint, ok):
|
||||
assert satisfies(installed, constraint) is ok
|
||||
|
||||
|
||||
def test_parse_constraint_empty_is_permissive():
|
||||
from specify_cli.bundler.lib.versioning import parse_constraint
|
||||
|
||||
assert str(parse_constraint("")) == ""
|
||||
Reference in New Issue
Block a user