mirror of
https://github.com/github/spec-kit.git
synced 2026-07-03 12:28:06 +08:00
fix(extensions,presets,workflows): resolve private GHES release assets via /api/v3 (#3157)
* feat(auth): add github_provider_hosts() to enumerate GHES hosts from auth.json Assisted-by: Claude Code (model: claude-sonnet-4-6, autonomous) * fix(extensions): resolve GHES release assets via /api/v3 Generalizes resolve_github_release_asset_api_url to GitHub Enterprise Server hosts (gated by auth.json github hosts), fixing private GHES extension/preset downloads. github/spec-kit#3147 Assisted-by: Claude Code (model: claude-sonnet-4-6, autonomous) * fix(extensions,presets): pass auth.json github hosts into release resolver Assisted-by: Claude Code (model: claude-sonnet-4-6, autonomous) * docs(auth): document GHES private catalog + release-asset auth Assisted-by: Claude Code (model: claude-sonnet-4-6, autonomous) * fix(presets,workflows): pass auth.json github hosts into remaining release resolvers Wires preset add --from and workflow add through github_provider_hosts() so private GHES release assets resolve via /api/v3 there too. github/spec-kit#3147 Assisted-by: Claude Code (model: claude-sonnet-4-6, autonomous) * test(presets): use module-level io.BytesIO in GHES preset test Addresses Copilot review on PR #3157: drop unnecessary __import__("io") in test_preset_add_from_ghes_release_url_resolves_via_api_v3 since io is already imported at module level. * fix(github-http): pass through GHES asset API URLs by path shape Addresses Copilot review on PR #3157. A direct GHES /api/v3 release asset URL was only returned as already-resolved when its host was in the allowlist; otherwise the resolver returned None and the caller downloaded the same URL without 'Accept: application/octet-stream', fetching JSON metadata instead of the binary. Gate the passthrough on path shape alone, mirroring the github.com case. This is safe: passthrough returns the input URL unchanged and the caller fetches it either way, so no new request to an arbitrary host is induced; the token stays independently gated by auth.json in open_url. The allowlist remains the anti-SSRF gate on the tag-lookup resolving path. Add test_passthrough_for_unlisted_ghes_api_asset_url.
This commit is contained in:
@@ -17,9 +17,11 @@ import tempfile
|
||||
import shutil
|
||||
import warnings
|
||||
import zipfile
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import yaml
|
||||
|
||||
@@ -4752,6 +4754,69 @@ class TestPresetAddFromUrlResolution:
|
||||
assert captured_urls[0][0] == "https://api.github.com/repos/org/repo/releases/assets/42"
|
||||
assert captured_urls[0][1] == {"Accept": "application/octet-stream"}
|
||||
|
||||
def test_preset_add_from_ghes_release_url_resolves_via_api_v3(self, project_dir, monkeypatch):
|
||||
"""'preset add --from <ghes-release-url>' resolves via GHES /api/v3 endpoint."""
|
||||
from typer.testing import CliRunner
|
||||
from unittest.mock import patch
|
||||
from specify_cli import app
|
||||
from specify_cli.authentication import http as _auth_http
|
||||
from specify_cli.authentication.config import AuthConfigEntry
|
||||
|
||||
monkeypatch.setattr(_auth_http, "_config_override", [
|
||||
AuthConfigEntry(hosts=("ghes.example",), provider="github", auth="bearer", token="t"),
|
||||
])
|
||||
|
||||
manifest_content = yaml.dump({
|
||||
"schema_version": "1.0",
|
||||
"preset": {"id": "my-preset", "name": "My Preset", "version": "1.0.0", "description": "Test preset", "author": "Test", "license": "MIT"},
|
||||
"requires": {"speckit_version": ">=0.1.0"},
|
||||
"provides": {"templates": [{"type": "template", "name": "t", "file": "templates/t.md", "description": "t"}]},
|
||||
})
|
||||
zip_buf = io.BytesIO()
|
||||
with zipfile.ZipFile(zip_buf, "w") as zf:
|
||||
zf.writestr("preset.yml", manifest_content)
|
||||
zip_bytes = zip_buf.getvalue()
|
||||
|
||||
captured_urls = []
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, data):
|
||||
self._data = data
|
||||
|
||||
def read(self):
|
||||
return self._data
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *a):
|
||||
return False
|
||||
|
||||
def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None):
|
||||
captured_urls.append((url, extra_headers))
|
||||
if "releases/tags/" in url:
|
||||
return FakeResponse(json.dumps({
|
||||
"assets": [{"name": "preset.zip", "url": "https://ghes.example/api/v3/repos/org/repo/releases/assets/42"}]
|
||||
}).encode())
|
||||
return FakeResponse(zip_bytes)
|
||||
|
||||
runner = CliRunner()
|
||||
with patch.object(Path, "cwd", return_value=project_dir), \
|
||||
patch("specify_cli.get_speckit_version", return_value="1.0.0"), \
|
||||
patch("specify_cli.authentication.http.open_url", side_effect=fake_open_url):
|
||||
result = runner.invoke(app, [
|
||||
"preset", "add",
|
||||
"--from", "https://ghes.example/org/repo/releases/download/v1.0/preset.zip",
|
||||
])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
# The tag-lookup call must use the GHES /api/v3 endpoint
|
||||
assert any("ghes.example/api/v3/repos/org/repo/releases/tags/v1.0" in url for url, _ in captured_urls)
|
||||
# The asset download call must carry Accept: application/octet-stream
|
||||
asset_calls = [(url, h) for url, h in captured_urls if "releases/assets/" in url]
|
||||
assert len(asset_calls) >= 1
|
||||
assert asset_calls[0][1] == {"Accept": "application/octet-stream"}
|
||||
|
||||
|
||||
class TestWrapStrategy:
|
||||
"""Tests for strategy: wrap preset command substitution."""
|
||||
@@ -6021,3 +6086,36 @@ def _create_pack(temp_dir, valid_pack_data, pack_id, content,
|
||||
(subdir / f"{template_name}.md").write_text(content)
|
||||
|
||||
return pack_dir
|
||||
|
||||
|
||||
def test_preset_wrapper_resolves_ghes_asset_when_host_configured(tmp_path, monkeypatch):
|
||||
"""End-to-end wiring for presets: auth.json github host → GHES asset resolution."""
|
||||
from specify_cli.authentication import http as _auth_http
|
||||
from specify_cli.authentication.config import AuthConfigEntry
|
||||
from specify_cli.presets import PresetCatalog
|
||||
|
||||
monkeypatch.setattr(_auth_http, "_config_override", [
|
||||
AuthConfigEntry(hosts=("ghes.example",), provider="github",
|
||||
auth="bearer", token="t"),
|
||||
])
|
||||
catalog = PresetCatalog(tmp_path)
|
||||
|
||||
captured = []
|
||||
|
||||
@contextmanager
|
||||
def fake_open(url, timeout=None, extra_headers=None):
|
||||
captured.append(url)
|
||||
resp = MagicMock()
|
||||
resp.read.return_value = json.dumps({
|
||||
"assets": [{"name": "pack.zip",
|
||||
"url": "https://ghes.example/api/v3/repos/o/r/releases/assets/9"}]
|
||||
}).encode()
|
||||
yield resp
|
||||
|
||||
monkeypatch.setattr(catalog, "_open_url", fake_open)
|
||||
|
||||
resolved = catalog._resolve_github_release_asset_api_url(
|
||||
"https://ghes.example/o/r/releases/download/v2/pack.zip"
|
||||
)
|
||||
assert resolved == "https://ghes.example/api/v3/repos/o/r/releases/assets/9"
|
||||
assert captured == ["https://ghes.example/api/v3/repos/o/r/releases/tags/v2"]
|
||||
|
||||
Reference in New Issue
Block a user