chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 11:57:40 +08:00
commit 923a61929d
462 changed files with 139124 additions and 0 deletions
+116
View File
@@ -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)
+260
View File
@@ -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")
+54
View File
@@ -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 == []
+193
View File
@@ -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
+217
View File
@@ -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
+190
View File
@@ -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) == []
+41
View File
@@ -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)
+81
View File
@@ -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)
+32
View File
@@ -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)
+68
View File
@@ -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("")) == ""