mirror of
https://github.com/github/spec-kit.git
synced 2026-07-03 20:36:23 +08:00
* 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.
305 lines
12 KiB
Python
305 lines
12 KiB
Python
"""Tests for GitHub-authenticated HTTP request helpers."""
|
|
|
|
import json
|
|
import os
|
|
from contextlib import contextmanager
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from specify_cli._github_http import (
|
|
build_github_request,
|
|
resolve_github_release_asset_api_url,
|
|
)
|
|
|
|
|
|
class TestBuildGitHubRequest:
|
|
"""Tests for build_github_request() URL validation and auth handling."""
|
|
|
|
# --- URL Validation Tests ---
|
|
|
|
def test_empty_url_raises_value_error(self):
|
|
"""build_github_request() must reject an empty string URL."""
|
|
with pytest.raises(ValueError, match="url must not be empty"):
|
|
build_github_request("")
|
|
|
|
def test_whitespace_url_raises_value_error(self):
|
|
"""build_github_request() must reject a whitespace-only URL."""
|
|
with pytest.raises(ValueError, match="url must not be empty"):
|
|
build_github_request(" ")
|
|
|
|
def test_non_http_url_raises_value_error(self):
|
|
"""build_github_request() must reject URLs without http/https scheme."""
|
|
with pytest.raises(ValueError, match="url must start with http"):
|
|
build_github_request("not-a-url")
|
|
|
|
def test_ftp_url_raises_value_error(self):
|
|
"""build_github_request() must reject ftp:// URLs."""
|
|
with pytest.raises(ValueError, match="url must start with http"):
|
|
build_github_request("ftp://github.com/file.zip")
|
|
|
|
# --- Valid URL Tests ---
|
|
|
|
def test_valid_https_url_returns_request(self):
|
|
"""build_github_request() must return a Request for a valid https URL."""
|
|
req = build_github_request("https://github.com/github/spec-kit")
|
|
assert req.full_url == "https://github.com/github/spec-kit"
|
|
|
|
def test_valid_http_url_returns_request(self):
|
|
"""build_github_request() must accept http:// URLs."""
|
|
req = build_github_request("http://example.com/file")
|
|
assert req.full_url == "http://example.com/file"
|
|
|
|
# --- Auth Header Tests ---
|
|
|
|
def test_github_token_added_for_github_host(self):
|
|
"""Authorization header is set when GITHUB_TOKEN is present."""
|
|
with patch.dict(os.environ, {"GITHUB_TOKEN": "test-token", "GH_TOKEN": ""}):
|
|
req = build_github_request("https://github.com/github/spec-kit")
|
|
assert req.get_header("Authorization") == "Bearer test-token"
|
|
|
|
def test_gh_token_used_as_fallback(self):
|
|
"""GH_TOKEN is used when GITHUB_TOKEN is absent."""
|
|
with patch.dict(os.environ, {"GITHUB_TOKEN": "", "GH_TOKEN": "fallback-token"}):
|
|
req = build_github_request("https://github.com/github/spec-kit")
|
|
assert req.get_header("Authorization") == "Bearer fallback-token"
|
|
|
|
def test_no_auth_header_for_non_github_host(self):
|
|
"""Authorization header must NOT be set for non-GitHub URLs."""
|
|
with patch.dict(os.environ, {"GITHUB_TOKEN": "test-token"}):
|
|
req = build_github_request("https://example.com/file")
|
|
assert req.get_header("Authorization") is None
|
|
|
|
def test_no_auth_header_when_no_token(self):
|
|
"""No Authorization header when no token is set in environment."""
|
|
with patch.dict(os.environ, {}, clear=True):
|
|
req = build_github_request("https://github.com/github/spec-kit")
|
|
assert req.get_header("Authorization") is None
|
|
|
|
def test_missing_hostname_raises_value_error(self):
|
|
"""build_github_request() must reject URLs with valid scheme but no hostname."""
|
|
with pytest.raises(ValueError, match="url must include a hostname"):
|
|
build_github_request("http://")
|
|
|
|
|
|
class TestResolveGitHubReleaseAssetApiUrl:
|
|
"""Tests for resolve_github_release_asset_api_url()."""
|
|
|
|
def _make_open_url_fn(self, release_json):
|
|
"""Create a fake open_url_fn that returns release JSON."""
|
|
@contextmanager
|
|
def fake_open(url, timeout=None, extra_headers=None):
|
|
resp = MagicMock()
|
|
resp.read.return_value = json.dumps(release_json).encode()
|
|
yield resp
|
|
return fake_open
|
|
|
|
def test_returns_none_for_non_github_url(self):
|
|
"""Non-GitHub URLs should return None."""
|
|
result = resolve_github_release_asset_api_url(
|
|
"https://example.com/file.zip", lambda *a, **kw: None
|
|
)
|
|
assert result is None
|
|
|
|
def test_returns_none_for_non_release_github_url(self):
|
|
"""GitHub URLs that aren't release downloads return None."""
|
|
result = resolve_github_release_asset_api_url(
|
|
"https://github.com/org/repo/archive/refs/tags/v1.zip",
|
|
lambda *a, **kw: None,
|
|
)
|
|
assert result is None
|
|
|
|
def test_passthrough_for_existing_api_asset_url(self):
|
|
"""Already-resolved REST API asset URLs are returned as-is."""
|
|
url = "https://api.github.com/repos/org/repo/releases/assets/12345"
|
|
result = resolve_github_release_asset_api_url(url, lambda *a, **kw: None)
|
|
assert result == url
|
|
|
|
def test_resolves_browser_url_to_api_url(self):
|
|
"""Browser release URL resolves to REST API asset URL."""
|
|
release_json = {
|
|
"assets": [
|
|
{"name": "pack.zip", "url": "https://api.github.com/repos/org/repo/releases/assets/99"}
|
|
]
|
|
}
|
|
result = resolve_github_release_asset_api_url(
|
|
"https://github.com/org/repo/releases/download/v1.0/pack.zip",
|
|
self._make_open_url_fn(release_json),
|
|
)
|
|
assert result == "https://api.github.com/repos/org/repo/releases/assets/99"
|
|
|
|
def test_returns_none_when_asset_not_found(self):
|
|
"""Returns None when the release exists but asset name doesn't match."""
|
|
release_json = {"assets": [{"name": "other.zip", "url": "https://api.github.com/repos/org/repo/releases/assets/1"}]}
|
|
result = resolve_github_release_asset_api_url(
|
|
"https://github.com/org/repo/releases/download/v1/missing.zip",
|
|
self._make_open_url_fn(release_json),
|
|
)
|
|
assert result is None
|
|
|
|
def test_returns_none_on_network_error(self):
|
|
"""Returns None when the API request fails."""
|
|
import urllib.error
|
|
|
|
@contextmanager
|
|
def failing_open(url, timeout=None, extra_headers=None):
|
|
raise urllib.error.URLError("network error")
|
|
yield # noqa: unreachable
|
|
|
|
result = resolve_github_release_asset_api_url(
|
|
"https://github.com/org/repo/releases/download/v1/pack.zip",
|
|
failing_open,
|
|
)
|
|
assert result is None
|
|
|
|
def test_tag_with_special_characters_is_url_encoded(self):
|
|
"""Tags with reserved characters (e.g. '/') are encoded in the API URL."""
|
|
captured_urls = []
|
|
|
|
@contextmanager
|
|
def capturing_open(url, timeout=None, extra_headers=None):
|
|
captured_urls.append(url)
|
|
resp = MagicMock()
|
|
resp.read.return_value = json.dumps({"assets": []}).encode()
|
|
yield resp
|
|
|
|
resolve_github_release_asset_api_url(
|
|
"https://github.com/org/repo/releases/download/feature%2Fv1/pack.zip",
|
|
capturing_open,
|
|
)
|
|
# The tag "feature/v1" (decoded from %2F) must be re-encoded as "feature%2Fv1"
|
|
assert len(captured_urls) == 1
|
|
assert "releases/tags/feature%2Fv1" in captured_urls[0]
|
|
|
|
def test_tag_with_hash_is_url_encoded(self):
|
|
"""Tags with '#' character are properly encoded."""
|
|
captured_urls = []
|
|
|
|
@contextmanager
|
|
def capturing_open(url, timeout=None, extra_headers=None):
|
|
captured_urls.append(url)
|
|
resp = MagicMock()
|
|
resp.read.return_value = json.dumps({"assets": []}).encode()
|
|
yield resp
|
|
|
|
resolve_github_release_asset_api_url(
|
|
"https://github.com/org/repo/releases/download/v1%23beta/pack.zip",
|
|
capturing_open,
|
|
)
|
|
assert len(captured_urls) == 1
|
|
assert "releases/tags/v1%23beta" in captured_urls[0]
|
|
|
|
# --- GHES (GitHub Enterprise Server) ---
|
|
|
|
def test_resolves_ghes_browser_url_to_api_url(self):
|
|
"""A GHES browser release URL resolves to the /api/v3 asset URL."""
|
|
release_json = {
|
|
"assets": [
|
|
{"name": "ext.zip",
|
|
"url": "https://ghes.example/api/v3/repos/o/r/releases/assets/7"}
|
|
]
|
|
}
|
|
result = resolve_github_release_asset_api_url(
|
|
"https://ghes.example/o/r/releases/download/v1/ext.zip",
|
|
self._make_open_url_fn(release_json),
|
|
github_hosts=("ghes.example",),
|
|
)
|
|
assert result == "https://ghes.example/api/v3/repos/o/r/releases/assets/7"
|
|
|
|
def test_passthrough_for_existing_ghes_api_asset_url(self):
|
|
"""An already-resolved GHES /api/v3 asset URL is returned as-is."""
|
|
url = "https://ghes.example/api/v3/repos/o/r/releases/assets/7"
|
|
result = resolve_github_release_asset_api_url(
|
|
url, lambda *a, **kw: None, github_hosts=("ghes.example",)
|
|
)
|
|
assert result == url
|
|
|
|
def test_returns_none_for_ghes_host_not_in_allowlist(self):
|
|
"""Unlisted hosts get no GHES treatment and trigger no API call (anti-SSRF)."""
|
|
called = []
|
|
|
|
@contextmanager
|
|
def recording_open(url, timeout=None, extra_headers=None):
|
|
called.append(url)
|
|
resp = MagicMock()
|
|
resp.read.return_value = b"{}"
|
|
yield resp
|
|
|
|
result = resolve_github_release_asset_api_url(
|
|
"https://ghes.example/o/r/releases/download/v1/ext.zip",
|
|
recording_open,
|
|
github_hosts=("other.example",),
|
|
)
|
|
assert result is None
|
|
assert called == []
|
|
|
|
def test_passthrough_for_unlisted_ghes_api_asset_url(self):
|
|
"""A direct GHES /api/v3 asset URL passes through even when the host is
|
|
not allowlisted: passthrough issues no API request, and the download
|
|
helper gates the token independently, so octet-stream resolution must
|
|
not be withheld."""
|
|
called = []
|
|
|
|
@contextmanager
|
|
def recording_open(url, timeout=None, extra_headers=None):
|
|
called.append(url)
|
|
resp = MagicMock()
|
|
resp.read.return_value = b"{}"
|
|
yield resp
|
|
|
|
url = "https://ghes.example/api/v3/repos/o/r/releases/assets/7"
|
|
result = resolve_github_release_asset_api_url(
|
|
url, recording_open, github_hosts=("other.example",)
|
|
)
|
|
assert result == url
|
|
assert called == []
|
|
|
|
def test_ghes_api_base_preserves_scheme_and_port(self):
|
|
"""The GHES API base mirrors the URL scheme and keeps a non-standard port."""
|
|
captured = []
|
|
|
|
@contextmanager
|
|
def capturing_open(url, timeout=None, extra_headers=None):
|
|
captured.append(url)
|
|
resp = MagicMock()
|
|
resp.read.return_value = json.dumps({"assets": []}).encode()
|
|
yield resp
|
|
|
|
resolve_github_release_asset_api_url(
|
|
"http://localhost:8000/o/r/releases/download/v1/ext.zip",
|
|
capturing_open,
|
|
github_hosts=("localhost",),
|
|
)
|
|
assert captured == ["http://localhost:8000/api/v3/repos/o/r/releases/tags/v1"]
|
|
|
|
def test_ghes_wildcard_does_not_match_bare_host(self):
|
|
"""A '*.suffix' pattern does not match the bare host (must list it explicitly)."""
|
|
result = resolve_github_release_asset_api_url(
|
|
"https://ghes.example/o/r/releases/download/v1/ext.zip",
|
|
lambda *a, **kw: None,
|
|
github_hosts=("*.ghes.example",),
|
|
)
|
|
assert result is None
|
|
|
|
def test_public_github_url_unaffected_by_github_hosts(self):
|
|
"""Public github.com still resolves via api.github.com even with github_hosts set."""
|
|
captured = []
|
|
|
|
@contextmanager
|
|
def capturing_open(url, timeout=None, extra_headers=None):
|
|
captured.append(url)
|
|
resp = MagicMock()
|
|
resp.read.return_value = json.dumps({
|
|
"assets": [{"name": "pack.zip",
|
|
"url": "https://api.github.com/repos/org/repo/releases/assets/99"}]
|
|
}).encode()
|
|
yield resp
|
|
|
|
result = resolve_github_release_asset_api_url(
|
|
"https://github.com/org/repo/releases/download/v1.0/pack.zip",
|
|
capturing_open,
|
|
github_hosts=("ghes.example",),
|
|
)
|
|
assert result == "https://api.github.com/repos/org/repo/releases/assets/99"
|
|
assert captured == ["https://api.github.com/repos/org/repo/releases/tags/v1.0"]
|