Skip to content

Commit d468c02

Browse files
committed
test(extensions): cover list --available catalog query and add --from path traversal
Add regression coverage for the two behaviors wired up in the preceding fix: - list --available/--all: queries the catalog, filters installed IDs, marks discovery-only entries, reports an empty catalog, and exits 1 on catalog failure. - add --from <url>: a label containing path separators is sanitized so the download cannot escape the downloads cache dir. Both suites were verified red against the pre-fix behavior and green after.
1 parent 814bc90 commit d468c02

2 files changed

Lines changed: 204 additions & 0 deletions

File tree

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
"""Security test for `specify extension add <label> --from <url>`.
2+
3+
The raw extension label is interpolated into the downloaded ZIP filename. A
4+
label containing path separators (e.g. "../../etc/evil") must not let the
5+
download escape the downloads cache directory. The handler sanitizes the
6+
label before building the filename; this test asserts the resulting path
7+
stays inside the downloads dir.
8+
"""
9+
10+
import contextlib
11+
12+
import pytest
13+
from typer.testing import CliRunner
14+
15+
from specify_cli import app
16+
from specify_cli.extensions import ExtensionManager
17+
18+
runner = CliRunner()
19+
20+
21+
@pytest.fixture
22+
def project_dir(tmp_path):
23+
proj_dir = tmp_path / "project"
24+
proj_dir.mkdir()
25+
(proj_dir / ".specify").mkdir()
26+
(proj_dir / ".specify" / "config.toml").write_text("ai = 'claude'")
27+
return proj_dir
28+
29+
30+
def test_add_from_url_sanitizes_traversal_label(project_dir, monkeypatch):
31+
monkeypatch.chdir(project_dir)
32+
33+
captured = {}
34+
35+
@contextlib.contextmanager
36+
def fake_open_url(url, timeout=60):
37+
class _Resp:
38+
def read(self):
39+
return b"not-a-real-zip"
40+
yield _Resp()
41+
42+
monkeypatch.setattr("specify_cli.authentication.http.open_url", fake_open_url)
43+
44+
def fake_install_from_zip(self, zip_path, *args, **kwargs):
45+
captured["zip_path"] = zip_path
46+
# Stop the flow before real install/registration runs.
47+
raise RuntimeError("stop after capture")
48+
49+
monkeypatch.setattr(ExtensionManager, "install_from_zip", fake_install_from_zip)
50+
51+
malicious = "../../../etc/evil"
52+
runner.invoke(
53+
app,
54+
["extension", "add", malicious, "--from", "https://example.com/payload.zip"],
55+
obj={"project_root": project_dir},
56+
input="y\n", # confirm the "Untrusted Source" prompt for --from URLs
57+
)
58+
59+
zip_path = captured.get("zip_path")
60+
assert zip_path is not None, "install_from_zip was never reached"
61+
62+
download_dir = (project_dir / ".specify" / "extensions" / ".cache" / "downloads").resolve()
63+
resolved = zip_path.resolve()
64+
65+
# The download must stay inside the downloads cache dir...
66+
assert resolved.parent == download_dir
67+
# ...and the filename must not carry path separators from the raw label.
68+
assert "/" not in zip_path.name
69+
assert ".." not in zip_path.name
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
"""Behavior tests for `specify extension list --available/--all`.
2+
3+
These flags were documented from the original extension system (#1551) as
4+
"Show available extensions from catalog", but the implementation was a static
5+
hint that never queried the catalog. This suite covers the wired-up behavior:
6+
the catalog is queried, already-installed IDs are filtered out, and a clear
7+
error is surfaced when the catalog is unavailable.
8+
"""
9+
10+
import pytest
11+
from typer.testing import CliRunner
12+
13+
from specify_cli import app
14+
from specify_cli.extensions import ExtensionManager, ExtensionCatalog, ExtensionError
15+
16+
runner = CliRunner()
17+
18+
19+
@pytest.fixture
20+
def project_dir(tmp_path):
21+
"""Create a minimal spec-kit project directory."""
22+
proj_dir = tmp_path / "project"
23+
proj_dir.mkdir()
24+
(proj_dir / ".specify").mkdir()
25+
(proj_dir / ".specify" / "config.toml").write_text("ai = 'claude'")
26+
return proj_dir
27+
28+
29+
def _catalog_entry(ext_id, name, version="1.0.0", verified=False, install_allowed=True, catalog_name="default"):
30+
return {
31+
"id": ext_id,
32+
"name": name,
33+
"version": version,
34+
"description": f"{name} description",
35+
"verified": verified,
36+
"_install_allowed": install_allowed,
37+
"_catalog_name": catalog_name,
38+
}
39+
40+
41+
def test_list_available_queries_catalog_and_filters_installed(project_dir, monkeypatch):
42+
"""--available must query the catalog and drop already-installed IDs."""
43+
monkeypatch.chdir(project_dir)
44+
45+
monkeypatch.setattr(ExtensionManager, "list_installed", lambda self: [{"id": "already-installed"}])
46+
monkeypatch.setattr(ExtensionCatalog, "search", lambda self: [
47+
_catalog_entry("already-installed", "Already Installed"),
48+
_catalog_entry("fresh-ext", "Fresh Ext", verified=True),
49+
])
50+
51+
result = runner.invoke(app, ["extension", "list", "--available"], obj={"project_root": project_dir})
52+
53+
assert result.exit_code == 0
54+
assert "Available Extensions:" in result.output
55+
# Uninstalled catalog extension is shown...
56+
assert "fresh-ext" in result.output
57+
assert "✓ Verified" in result.output
58+
assert "specify extension add fresh-ext" in result.output
59+
# ...and the installed one is filtered out.
60+
assert "already-installed" not in result.output
61+
62+
63+
def test_list_available_marks_discovery_only_entries(project_dir, monkeypatch):
64+
"""Entries whose catalog disallows install render a discovery-only note."""
65+
monkeypatch.chdir(project_dir)
66+
67+
monkeypatch.setattr(ExtensionManager, "list_installed", lambda self: [])
68+
monkeypatch.setattr(ExtensionCatalog, "search", lambda self: [
69+
_catalog_entry("locked-ext", "Locked Ext", install_allowed=False, catalog_name="curated"),
70+
])
71+
72+
result = runner.invoke(app, ["extension", "list", "--available"], obj={"project_root": project_dir})
73+
74+
assert result.exit_code == 0
75+
assert "Discovery only" in result.output
76+
assert "curated" in result.output
77+
assert "specify extension add locked-ext" not in result.output
78+
79+
80+
def test_list_available_empty_catalog_message(project_dir, monkeypatch):
81+
"""An empty (post-filter) catalog reports no additional extensions."""
82+
monkeypatch.chdir(project_dir)
83+
84+
monkeypatch.setattr(ExtensionManager, "list_installed", lambda self: [])
85+
monkeypatch.setattr(ExtensionCatalog, "search", lambda self: [])
86+
87+
result = runner.invoke(app, ["extension", "list", "--available"], obj={"project_root": project_dir})
88+
89+
assert result.exit_code == 0
90+
assert "Available Extensions:" in result.output
91+
assert "No additional extensions available" in result.output
92+
93+
94+
def test_list_available_catalog_error_exits(project_dir, monkeypatch):
95+
"""A catalog failure surfaces a clear error and exits non-zero."""
96+
monkeypatch.chdir(project_dir)
97+
98+
def _boom(self):
99+
raise ExtensionError("catalog unreachable")
100+
101+
monkeypatch.setattr(ExtensionManager, "list_installed", lambda self: [])
102+
monkeypatch.setattr(ExtensionCatalog, "search", _boom)
103+
104+
result = runner.invoke(app, ["extension", "list", "--available"], obj={"project_root": project_dir})
105+
106+
assert result.exit_code == 1
107+
assert "Could not query extension catalog" in result.output
108+
assert "catalog unreachable" in result.output
109+
110+
111+
def test_list_all_shows_installed_and_available(project_dir, monkeypatch):
112+
"""--all lists installed extensions and available catalog extensions."""
113+
monkeypatch.chdir(project_dir)
114+
115+
monkeypatch.setattr(ExtensionManager, "list_installed", lambda self: [{
116+
"id": "my-ext",
117+
"name": "My Ext",
118+
"version": "2.0.0",
119+
"description": "installed one",
120+
"command_count": 1,
121+
"hook_count": 0,
122+
"priority": 10,
123+
"enabled": True,
124+
}])
125+
monkeypatch.setattr(ExtensionCatalog, "search", lambda self: [
126+
_catalog_entry("other-ext", "Other Ext"),
127+
])
128+
129+
result = runner.invoke(app, ["extension", "list", "--all"], obj={"project_root": project_dir})
130+
131+
assert result.exit_code == 0
132+
assert "Installed Extensions:" in result.output
133+
assert "My Ext" in result.output
134+
assert "Available Extensions:" in result.output
135+
assert "other-ext" in result.output

0 commit comments

Comments
 (0)