mirror of
https://github.com/github/spec-kit.git
synced 2026-07-03 12:28:06 +08:00
fix: resolve GitHub release asset API URL for private repo preset and workflow downloads (#2855)
* fix: resolve GitHub release asset API URL for private repo preset and workflow downloads - Add shared `resolve_github_release_asset_api_url` utility to `_github_http.py` for reuse across preset and workflow download paths - Apply the same private-repo fix from PR #2792 (extensions) to: - `PresetCatalog.download_pack` — ZIP downloads via catalog `download_url` - `preset add --from <url>` — ZIP downloads from a direct URL - `workflow add <url>` — workflow YAML downloads from a direct URL - `workflow add <id>` (catalog) — workflow YAML downloads via catalog `url` - For browser release URLs (`github.com/…/releases/download/…`), the asset is resolved via the GitHub REST API and downloaded with `Accept: application/octet-stream` - Direct REST API asset URLs (`api.github.com/…/releases/assets/<id>`) are downloaded directly with `Accept: application/octet-stream` - Auth is preserved end-to-end through the existing `open_url` infrastructure - Update `test_download_pack_sends_auth_header` and add `test_download_pack_accepts_direct_github_rest_asset_url` to cover both paths Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: URL-encode tag in release API URL to handle special characters Encode the tag as a path segment (using quote with safe='') when building the releases/tags/<tag> API URL. This prevents malformed URLs when tags contain reserved characters like '/' or '#'. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test: add CLI-level tests for preset add --from GitHub release URL resolution Adds regression tests covering: - resolve_github_release_asset_api_url unit tests (passthrough, resolution, network error, URL encoding of special chars in tags) - CLI-level 'preset add --from <github-release-url>' end-to-end flow - CLI-level 'preset add --from <api-asset-url>' direct passthrough Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * refactor: deduplicate release URL resolution; fix test issues - ExtensionCatalog._resolve_github_release_asset_api_url now delegates to the shared helper in _github_http.py (also gains URL-encoding fix) - Remove unused 'io' import from test_github_http.py - Remove duplicate 'provides' dict keys accidentally added to test_presets.py Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: align resolver timeout with download timeout; add workflow CLI tests - Pass timeout=30 to resolve_github_release_asset_api_url in both workflow add paths so worst-case latency matches the download timeout - Add CLI-level regression tests for 'workflow add <url>' covering browser URL resolution and direct API asset URL passthrough Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: remove unused urllib.request import; add catalog workflow test - Remove unused 'import urllib.request' in preset add --from path - Add CLI test for catalog-based 'workflow add <id>' with GitHub release URL resolution Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * style: remove unused MagicMock imports from tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Manfred Riem <mnriem@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -1,12 +1,15 @@
|
||||
"""Tests for GitHub-authenticated HTTP request helpers."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
@@ -76,4 +79,112 @@ class TestBuildGitHubRequest:
|
||||
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://")
|
||||
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]
|
||||
@@ -1528,17 +1528,33 @@ class TestPresetCatalog:
|
||||
zf.writestr("preset.yml", "id: test-pack\nname: Test\nversion: 1.0.0\n")
|
||||
zip_bytes = zip_buf.getvalue()
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.read.return_value = zip_bytes
|
||||
mock_response.__enter__ = lambda s: s
|
||||
mock_response.__exit__ = MagicMock(return_value=False)
|
||||
release_response = MagicMock()
|
||||
release_response.read.return_value = json.dumps(
|
||||
{
|
||||
"assets": [
|
||||
{
|
||||
"name": "test-pack.zip",
|
||||
"url": "https://api.github.com/repos/org/repo/releases/assets/1",
|
||||
}
|
||||
]
|
||||
}
|
||||
).encode()
|
||||
release_response.__enter__ = lambda s: s
|
||||
release_response.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
captured = {}
|
||||
asset_response = MagicMock()
|
||||
asset_response.read.return_value = zip_bytes
|
||||
asset_response.__enter__ = lambda s: s
|
||||
asset_response.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
captured = []
|
||||
mock_opener = MagicMock()
|
||||
|
||||
def fake_open(req, timeout=None):
|
||||
captured["req"] = req
|
||||
return mock_response
|
||||
captured.append(req)
|
||||
if req.full_url.endswith("/releases/tags/v1"):
|
||||
return release_response
|
||||
return asset_response
|
||||
|
||||
mock_opener.open.side_effect = fake_open
|
||||
|
||||
@@ -1554,7 +1570,56 @@ class TestPresetCatalog:
|
||||
patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
|
||||
catalog.download_pack("test-pack", target_dir=project_dir)
|
||||
|
||||
assert captured["req"].get_header("Authorization") == "Bearer ghp_testtoken"
|
||||
assert captured[0].full_url == "https://api.github.com/repos/org/repo/releases/tags/v1"
|
||||
assert captured[0].get_header("Authorization") == "Bearer ghp_testtoken"
|
||||
assert captured[1].full_url == "https://api.github.com/repos/org/repo/releases/assets/1"
|
||||
assert captured[1].get_header("Authorization") == "Bearer ghp_testtoken"
|
||||
assert captured[1].get_header("Accept") == "application/octet-stream"
|
||||
|
||||
def test_download_pack_accepts_direct_github_rest_asset_url(self, project_dir, monkeypatch):
|
||||
"""download_pack can use a GitHub REST release asset URL directly."""
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
|
||||
self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN")
|
||||
catalog = PresetCatalog(project_dir)
|
||||
|
||||
import io
|
||||
zip_buf = io.BytesIO()
|
||||
with zipfile.ZipFile(zip_buf, "w") as zf:
|
||||
zf.writestr("preset.yml", "id: test-pack\nname: Test\nversion: 1.0.0\n")
|
||||
zip_bytes = zip_buf.getvalue()
|
||||
|
||||
asset_response = MagicMock()
|
||||
asset_response.read.return_value = zip_bytes
|
||||
asset_response.__enter__ = lambda s: s
|
||||
asset_response.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
captured = []
|
||||
mock_opener = MagicMock()
|
||||
|
||||
def fake_open(req, timeout=None):
|
||||
captured.append(req)
|
||||
return asset_response
|
||||
|
||||
mock_opener.open.side_effect = fake_open
|
||||
|
||||
pack_info = {
|
||||
"id": "test-pack",
|
||||
"name": "Test Pack",
|
||||
"version": "1.0.0",
|
||||
"download_url": "https://api.github.com/repos/org/repo/releases/assets/1",
|
||||
"_install_allowed": True,
|
||||
}
|
||||
|
||||
with patch.object(catalog, "get_pack_info", return_value=pack_info), \
|
||||
patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
|
||||
catalog.download_pack("test-pack", target_dir=project_dir)
|
||||
|
||||
assert len(captured) == 1
|
||||
assert captured[0].full_url == "https://api.github.com/repos/org/repo/releases/assets/1"
|
||||
assert captured[0].get_header("Authorization") == "Bearer ghp_testtoken"
|
||||
assert captured[0].get_header("Accept") == "application/octet-stream"
|
||||
|
||||
|
||||
# ===== Integration Tests =====
|
||||
@@ -3831,6 +3896,119 @@ class TestBundledPresetLocator:
|
||||
assert "reinstall" in output, result.output
|
||||
|
||||
|
||||
class TestPresetAddFromUrlResolution:
|
||||
"""CLI-level tests for preset add --from <url> GitHub release resolution."""
|
||||
|
||||
def test_preset_add_from_github_release_url_resolves_and_downloads(self, project_dir):
|
||||
"""'preset add --from <github-release-url>' resolves to API asset URL."""
|
||||
from typer.testing import CliRunner
|
||||
from unittest.mock import patch
|
||||
from specify_cli import app
|
||||
|
||||
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 = __import__("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):
|
||||
captured_urls.append((url, extra_headers))
|
||||
if "releases/tags/" in url:
|
||||
return FakeResponse(json.dumps({
|
||||
"assets": [{"name": "preset.zip", "url": "https://api.github.com/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://github.com/org/repo/releases/download/v1.0/preset.zip",
|
||||
])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "My Preset" in result.output
|
||||
# First call should resolve the release tag
|
||||
assert any("releases/tags/v1.0" in url for url, _ in captured_urls)
|
||||
# Second call should download from the resolved asset URL with 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"}
|
||||
|
||||
def test_preset_add_from_direct_api_asset_url_passes_through(self, project_dir):
|
||||
"""'preset add --from <api-asset-url>' uses URL directly with octet-stream."""
|
||||
from typer.testing import CliRunner
|
||||
from unittest.mock import patch
|
||||
from specify_cli import app
|
||||
|
||||
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 = __import__("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):
|
||||
captured_urls.append((url, extra_headers))
|
||||
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://api.github.com/repos/org/repo/releases/assets/42",
|
||||
])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
# Should go directly to the asset URL with Accept header
|
||||
assert len(captured_urls) == 1
|
||||
assert captured_urls[0][0] == "https://api.github.com/repos/org/repo/releases/assets/42"
|
||||
assert captured_urls[0][1] == {"Accept": "application/octet-stream"}
|
||||
|
||||
|
||||
class TestWrapStrategy:
|
||||
"""Tests for strategy: wrap preset command substitution."""
|
||||
|
||||
|
||||
@@ -3681,3 +3681,185 @@ steps:
|
||||
)
|
||||
assert result.exit_code == 1
|
||||
assert "Invalid input format" in result.stdout
|
||||
|
||||
|
||||
class TestWorkflowAddUrlResolution:
|
||||
"""CLI-level tests for workflow add <url> GitHub release URL resolution."""
|
||||
|
||||
VALID_WORKFLOW_YAML = """
|
||||
schema_version: "1.0"
|
||||
workflow:
|
||||
id: "test-wf"
|
||||
name: "Test Workflow"
|
||||
version: "1.0.0"
|
||||
description: "A test workflow"
|
||||
steps:
|
||||
- id: step-one
|
||||
type: shell
|
||||
run: "echo hello"
|
||||
"""
|
||||
|
||||
def test_workflow_add_from_github_release_url_resolves_and_downloads(self, project_dir):
|
||||
"""'workflow add <github-release-url>' resolves to API asset URL."""
|
||||
from typer.testing import CliRunner
|
||||
from unittest.mock import patch
|
||||
from specify_cli import app
|
||||
|
||||
captured_urls = []
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, data, url=None):
|
||||
self._data = data
|
||||
self._url = url or "https://api.github.com/repos/org/repo/releases/assets/42"
|
||||
|
||||
def read(self):
|
||||
return self._data
|
||||
|
||||
def geturl(self):
|
||||
return self._url
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *a):
|
||||
return False
|
||||
|
||||
def fake_open_url(url, timeout=None, extra_headers=None):
|
||||
captured_urls.append((url, extra_headers, timeout))
|
||||
if "releases/tags/" in url:
|
||||
return FakeResponse(json.dumps({
|
||||
"assets": [{"name": "workflow.yml", "url": "https://api.github.com/repos/org/repo/releases/assets/42"}]
|
||||
}).encode())
|
||||
return FakeResponse(self.VALID_WORKFLOW_YAML.encode())
|
||||
|
||||
runner = CliRunner()
|
||||
with patch.object(Path, "cwd", return_value=project_dir), \
|
||||
patch("specify_cli.authentication.http.open_url", side_effect=fake_open_url):
|
||||
result = runner.invoke(app, [
|
||||
"workflow", "add",
|
||||
"https://github.com/org/repo/releases/download/v1.0/workflow.yml",
|
||||
])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "Test Workflow" in result.output
|
||||
# First call resolves the release tag with timeout=30
|
||||
tag_calls = [(url, h, t) for url, h, t in captured_urls if "releases/tags/" in url]
|
||||
assert len(tag_calls) == 1
|
||||
assert tag_calls[0][2] == 30 # timeout matches download timeout
|
||||
# Second call downloads from the resolved asset URL with octet-stream
|
||||
asset_calls = [(url, h, t) for url, h, t in captured_urls if "releases/assets/" in url]
|
||||
assert len(asset_calls) >= 1
|
||||
assert asset_calls[0][1] == {"Accept": "application/octet-stream"}
|
||||
|
||||
def test_workflow_add_from_direct_api_asset_url_passes_through(self, project_dir):
|
||||
"""'workflow add <api-asset-url>' uses URL directly with octet-stream."""
|
||||
from typer.testing import CliRunner
|
||||
from unittest.mock import patch
|
||||
from specify_cli import app
|
||||
|
||||
captured_urls = []
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, data, url=None):
|
||||
self._data = data
|
||||
self._url = url or "https://api.github.com/repos/org/repo/releases/assets/42"
|
||||
|
||||
def read(self):
|
||||
return self._data
|
||||
|
||||
def geturl(self):
|
||||
return self._url
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *a):
|
||||
return False
|
||||
|
||||
def fake_open_url(url, timeout=None, extra_headers=None):
|
||||
captured_urls.append((url, extra_headers))
|
||||
return FakeResponse(self.VALID_WORKFLOW_YAML.encode())
|
||||
|
||||
runner = CliRunner()
|
||||
with patch.object(Path, "cwd", return_value=project_dir), \
|
||||
patch("specify_cli.authentication.http.open_url", side_effect=fake_open_url):
|
||||
result = runner.invoke(app, [
|
||||
"workflow", "add",
|
||||
"https://api.github.com/repos/org/repo/releases/assets/42",
|
||||
])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
# Should go directly to the asset URL with Accept header
|
||||
assert len(captured_urls) == 1
|
||||
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_workflow_add_catalog_based_resolves_github_release_url(self, project_dir):
|
||||
"""'workflow add <id>' with catalog GitHub release URL resolves via API."""
|
||||
from typer.testing import CliRunner
|
||||
from unittest.mock import patch
|
||||
from specify_cli import app
|
||||
|
||||
captured_urls = []
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, data, url=None):
|
||||
self._data = data
|
||||
self._url = url or "https://api.github.com/repos/org/repo/releases/assets/55"
|
||||
|
||||
def read(self):
|
||||
return self._data
|
||||
|
||||
def geturl(self):
|
||||
return self._url
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *a):
|
||||
return False
|
||||
|
||||
def fake_open_url(url, timeout=None, extra_headers=None):
|
||||
captured_urls.append((url, extra_headers))
|
||||
if "releases/tags/" in url:
|
||||
return FakeResponse(json.dumps({
|
||||
"assets": [{"name": "workflow.yml", "url": "https://api.github.com/repos/org/repo/releases/assets/55"}]
|
||||
}).encode())
|
||||
# Use workflow YAML with id matching catalog key
|
||||
wf_yaml = """
|
||||
schema_version: "1.0"
|
||||
workflow:
|
||||
id: "my-wf"
|
||||
name: "My Workflow"
|
||||
version: "1.0.0"
|
||||
description: "A catalog workflow"
|
||||
steps:
|
||||
- id: step-one
|
||||
type: shell
|
||||
run: "echo hello"
|
||||
"""
|
||||
return FakeResponse(wf_yaml.encode())
|
||||
|
||||
fake_catalog_info = {
|
||||
"id": "my-wf",
|
||||
"name": "My Workflow",
|
||||
"version": "1.0.0",
|
||||
"url": "https://github.com/org/repo/releases/download/v2.0/workflow.yml",
|
||||
"_install_allowed": True,
|
||||
}
|
||||
|
||||
runner = CliRunner()
|
||||
with patch.object(Path, "cwd", return_value=project_dir), \
|
||||
patch("specify_cli.authentication.http.open_url", side_effect=fake_open_url), \
|
||||
patch("specify_cli.workflows.catalog.WorkflowCatalog.get_workflow_info", return_value=fake_catalog_info):
|
||||
result = runner.invoke(app, ["workflow", "add", "my-wf"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
# Should resolve via releases/tags API
|
||||
tag_calls = [url for url, _ in captured_urls if "releases/tags/" in url]
|
||||
assert len(tag_calls) == 1
|
||||
assert "releases/tags/v2.0" in tag_calls[0]
|
||||
# Should download from resolved asset URL with 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"}
|
||||
|
||||
Reference in New Issue
Block a user