mirror of
https://github.com/github/spec-kit.git
synced 2026-07-03 12:28:06 +08:00
feat: Config-driven opt-in authentication registry with multi-platform support (#2393)
* Initial plan * feat: add authentication provider registry (GitHub + Azure DevOps) Agent-Logs-Url: https://github.com/github/spec-kit/sessions/da7ecfd0-e1c9-48dc-b692-27be0879e976 Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> * feat: add try-each-provider HTTP helper and wire all catalog fetches through auth registry - Add authentication/http.py with open_url() that tries each configured provider in registry order, falling through on 401/403 to the next, and finally to unauthenticated - Add build_request() for one-shot request construction - Add configured_providers() to registry __init__ - Remove api_base_url() from AuthProvider ABC (unused) - Remove hosts attribute from providers (no host matching) - Replace _github_http.py usage in ExtensionCatalog and PresetCatalog - Wire IntegrationCatalog and WorkflowCatalog through open_url (were unauthenticated) - Wire _fetch_latest_release_tag() through open_url - Wire all inline --from-url downloads through open_url - Fix unused stub variable flagged by code-quality bot - 49 auth tests (positive + negative), 1805 total tests passing * fix: address review — fix stale docstrings, restore Accept header, add extra_headers to open_url - Fix _open_url() docstrings in extensions.py and presets.py that incorrectly claimed redirect stripping behavior - Add extra_headers parameter to open_url() so callers can pass additional headers (e.g. Accept) that persist across retries - Restore Accept: application/vnd.github+json header in _fetch_latest_release_tag() via extra_headers * feat: config-driven opt-in auth via ~/.specify/auth.json Security-first redesign: no credentials are sent unless the user explicitly creates ~/.specify/auth.json mapping hosts to providers. - Add authentication/config.py: loads and validates auth.json with host-to-provider mappings, supports token/token_env/azure-ad/azure-cli - Refactor AuthProvider ABC: auth_headers(token, scheme) + resolve_token(entry) - Refactor GitHubAuth: bearer scheme only, token from config entry - Refactor AzureDevOpsAuth: 4 schemes (basic-pat, bearer, azure-cli, azure-ad) with dynamic token acquisition for azure-cli and azure-ad - Rewrite authentication/http.py: host matching, redirect stripping, provider fallthrough on 401/403, unauthenticated fallback - Add docs/reference/authentication.md with full reference and template - 1823 tests passing (67 auth-specific) * fix: address review — unused imports, host normalization, provider+scheme validation, security hardening - Remove unused imports (os, field, Any) in config.py - Normalize hosts during load (strip + lowercase) - Validate token/token_env are non-empty strings during load - Validate provider+scheme compatibility during load - Fix extra_headers order: auth headers applied last, cannot be overridden - Remove unused 'tried' variable in http.py - Warn (once) on malformed auth.json instead of silent fallback - URL-encode OAuth2 client credentials body in azure_devops.py - Update 403 message to mention auth.json configuration - Fix registry leak in test_register_duplicate (try/finally) - Fix import style consistency in test_authentication.py - Add azure-cli and azure-ad token acquisition tests (mock subprocess/urlopen) - Add autouse fixture to isolate upgrade tests from real auth.json - 1829 tests passing * fix: reject unknown providers, validate azure-ad fields, strip Authorization from extra_headers - Reject unknown provider keys during auth.json load with clear error message - Validate azure-ad tenant_id/client_id/client_secret_env as non-empty strings - Strip Authorization from extra_headers in both build_request and open_url to prevent accidental or intentional bypass of provider-configured auth - Add tests for unknown provider and incompatible scheme validation - 1831 tests passing * fix: extract shared auth test helpers, global config isolation, align docstring - Move _inject_github_config / make_github_auth_entry to tests/auth_helpers.py to eliminate duplication across test_extensions, test_presets, test_upgrade - Move auth config isolation fixture to global conftest.py (autouse) so ALL tests are isolated from ~/.specify/auth.json, not just test_upgrade - Align load_auth_config docstring with actual behavior: ValueError may be caught by higher-level HTTP helpers that warn and continue unauthenticated - 1831 tests passing * fix: preserve auth header across multi-hop redirect chains - Read Authorization from both headers and unredirected_hdrs in _StripAuthOnRedirect to survive multi-hop chains within allowed hosts - Add test_multi_hop_redirect_within_hosts_preserves_auth - 1832 tests passing * fix: use resolved config path in warning/error messages and patch build_opener in no-network test Agent-Logs-Url: https://github.com/github/spec-kit/sessions/86df9557-54f1-4fe4-a25f-9501cb2356cf Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> * fix: assert full resolved config path in rate-limit output test Agent-Logs-Url: https://github.com/github/spec-kit/sessions/86df9557-54f1-4fe4-a25f-9501cb2356cf Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> * fix: close HTTPError on 401/403, remove _VALID_AUTH_SCHEMES, catch TimeoutExpired, skip POSIX test on Windows, remove unused import Agent-Logs-Url: https://github.com/github/spec-kit/sessions/a1e29737-dd6e-4287-96c1-509e0c96fb21 Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> * fix: use stable ~/.specify/auth.json in rate-limit message, skip POSIX permission check on Windows Agent-Logs-Url: https://github.com/github/spec-kit/sessions/4636bcdb-87ae-45d6-9545-a40e4effd617 Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> * fix: validate host patterns, cache auth config per-process Agent-Logs-Url: https://github.com/github/spec-kit/sessions/889b58a7-7f8c-47e2-8056-931ebcc671cc Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> * fix: clarify _is_valid_host_pattern docstring, clean up test sentinel type Agent-Logs-Url: https://github.com/github/spec-kit/sessions/889b58a7-7f8c-47e2-8056-931ebcc671cc Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> * fix: improve _is_valid_host_pattern docstring and test observability Agent-Logs-Url: https://github.com/github/spec-kit/sessions/889b58a7-7f8c-47e2-8056-931ebcc671cc Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
21
tests/auth_helpers.py
Normal file
21
tests/auth_helpers.py
Normal file
@@ -0,0 +1,21 @@
|
||||
"""Shared test helpers for authentication config injection."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from specify_cli.authentication.config import AuthConfigEntry
|
||||
|
||||
|
||||
def make_github_auth_entry(token_env: str = "GH_TOKEN") -> AuthConfigEntry:
|
||||
"""Build a GitHub ``AuthConfigEntry`` for testing."""
|
||||
return AuthConfigEntry(
|
||||
hosts=("github.com", "api.github.com", "raw.githubusercontent.com", "codeload.github.com"),
|
||||
provider="github",
|
||||
auth="bearer",
|
||||
token_env=token_env,
|
||||
)
|
||||
|
||||
|
||||
def inject_github_config(monkeypatch, token_env: str = "GH_TOKEN") -> None:
|
||||
"""Inject a GitHub auth.json config entry into the auth HTTP module."""
|
||||
from specify_cli.authentication import http as _auth_http
|
||||
monkeypatch.setattr(_auth_http, "_config_override", [make_github_auth_entry(token_env)])
|
||||
@@ -66,3 +66,18 @@ requires_bash = pytest.mark.skipif(
|
||||
def strip_ansi(text: str) -> str:
|
||||
"""Remove ANSI escape codes from Rich-formatted CLI output."""
|
||||
return _ANSI_ESCAPE_RE.sub("", text)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth config isolation — prevents tests from reading ~/.specify/auth.json
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_auth_config(monkeypatch):
|
||||
"""Ensure no test reads the real ~/.specify/auth.json."""
|
||||
from specify_cli.authentication import http as _auth_http
|
||||
monkeypatch.setattr(_auth_http, "_config_override", [])
|
||||
# Also clear the per-process cache so tests that unset _config_override
|
||||
# won't see a previously cached real-file result.
|
||||
monkeypatch.setattr(_auth_http, "_config_cache", None)
|
||||
|
||||
@@ -166,12 +166,12 @@ class TestCatalogFetch:
|
||||
"""Tests that use a local HTTP server stub via monkeypatch."""
|
||||
|
||||
def _patch_urlopen(self, monkeypatch, catalog_data):
|
||||
"""Patch urllib.request.urlopen to return *catalog_data*."""
|
||||
"""Patch authentication.http.urllib.request.urlopen to return *catalog_data*."""
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, data, url=""):
|
||||
self._data = json.dumps(data).encode()
|
||||
self._url = url
|
||||
self._url = url if isinstance(url, str) else url.full_url
|
||||
|
||||
def read(self):
|
||||
return self._data
|
||||
@@ -185,11 +185,12 @@ class TestCatalogFetch:
|
||||
def __exit__(self, *a):
|
||||
pass
|
||||
|
||||
def fake_urlopen(url, timeout=10):
|
||||
def fake_urlopen(req, timeout=10):
|
||||
url = req if isinstance(req, str) else req.full_url
|
||||
return FakeResponse(catalog_data, url)
|
||||
|
||||
import urllib.request
|
||||
monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen)
|
||||
import specify_cli.authentication.http as _auth_http
|
||||
monkeypatch.setattr(_auth_http.urllib.request, "urlopen", fake_urlopen)
|
||||
|
||||
def test_fetch_and_search_all(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
@@ -486,12 +487,12 @@ class TestIntegrationListCatalog:
|
||||
},
|
||||
}
|
||||
|
||||
import urllib.request
|
||||
import specify_cli.authentication.http as _auth_http
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, data, url=""):
|
||||
self._data = json.dumps(data).encode()
|
||||
self._url = url
|
||||
self._url = url if isinstance(url, str) else url.full_url
|
||||
def read(self):
|
||||
return self._data
|
||||
def geturl(self):
|
||||
@@ -501,7 +502,8 @@ class TestIntegrationListCatalog:
|
||||
def __exit__(self, *a):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(urllib.request, "urlopen", lambda url, timeout=10: FakeResponse(catalog, url))
|
||||
monkeypatch.setattr(_auth_http.urllib.request, "urlopen",
|
||||
lambda req, timeout=10: FakeResponse(catalog, req if isinstance(req, str) else req.full_url))
|
||||
|
||||
old = os.getcwd()
|
||||
try:
|
||||
|
||||
860
tests/test_authentication.py
Normal file
860
tests/test_authentication.py
Normal file
@@ -0,0 +1,860 @@
|
||||
"""Tests for the authentication provider registry and config-driven HTTP helpers.
|
||||
|
||||
Covers:
|
||||
- Config loading (auth.json parsing, validation, permission warning)
|
||||
- Registry mechanics (_register, get_provider, duplicate/empty-key guards)
|
||||
- GitHubAuth — bearer headers
|
||||
- AzureDevOpsAuth — basic-pat, bearer, azure-cli, azure-ad headers
|
||||
- Host matching (find_entries_for_url)
|
||||
- open_url — config-driven auth with fallthrough and redirect stripping
|
||||
- build_request — single-shot request construction
|
||||
- _fetch_latest_release_tag() delegation
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from specify_cli.authentication import AUTH_REGISTRY, _register, get_provider
|
||||
from specify_cli.authentication.azure_devops import AzureDevOpsAuth
|
||||
from specify_cli.authentication.base import AuthProvider
|
||||
from specify_cli.authentication.config import (
|
||||
AuthConfigEntry,
|
||||
find_entries_for_url,
|
||||
load_auth_config,
|
||||
)
|
||||
from specify_cli.authentication.github import GitHubAuth
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _github_entry(token_env: str = "GH_TOKEN", token: str | None = None) -> AuthConfigEntry:
|
||||
"""Build a standard GitHub config entry."""
|
||||
return AuthConfigEntry(
|
||||
hosts=("github.com", "api.github.com", "raw.githubusercontent.com", "codeload.github.com"),
|
||||
provider="github",
|
||||
auth="bearer",
|
||||
token=token,
|
||||
token_env=token_env if token is None else None,
|
||||
)
|
||||
|
||||
|
||||
def _ado_basic_entry(token_env: str = "AZURE_DEVOPS_PAT") -> AuthConfigEntry:
|
||||
"""Build an ADO basic-pat config entry."""
|
||||
return AuthConfigEntry(
|
||||
hosts=("dev.azure.com",),
|
||||
provider="azure-devops",
|
||||
auth="basic-pat",
|
||||
token_env=token_env,
|
||||
)
|
||||
|
||||
|
||||
class _StubProvider(AuthProvider):
|
||||
"""Minimal concrete provider for registry mechanics tests."""
|
||||
|
||||
key = "stub-provider"
|
||||
supported_auth_schemes = ("bearer",)
|
||||
|
||||
def auth_headers(self, token: str, auth_scheme: str) -> dict[str, str]:
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config loading
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestLoadAuthConfig:
|
||||
def test_missing_file_returns_empty(self, tmp_path):
|
||||
assert load_auth_config(tmp_path / "nonexistent.json") == []
|
||||
|
||||
def test_valid_github_config(self, tmp_path):
|
||||
cfg = tmp_path / "auth.json"
|
||||
cfg.write_text(json.dumps({
|
||||
"providers": [{
|
||||
"hosts": ["github.com"],
|
||||
"provider": "github",
|
||||
"auth": "bearer",
|
||||
"token_env": "GH_TOKEN",
|
||||
}]
|
||||
}))
|
||||
entries = load_auth_config(cfg)
|
||||
assert len(entries) == 1
|
||||
assert entries[0].provider == "github"
|
||||
assert entries[0].auth == "bearer"
|
||||
assert entries[0].token_env == "GH_TOKEN"
|
||||
|
||||
def test_valid_ado_config(self, tmp_path):
|
||||
cfg = tmp_path / "auth.json"
|
||||
cfg.write_text(json.dumps({
|
||||
"providers": [{
|
||||
"hosts": ["dev.azure.com"],
|
||||
"provider": "azure-devops",
|
||||
"auth": "basic-pat",
|
||||
"token_env": "AZURE_DEVOPS_PAT",
|
||||
}]
|
||||
}))
|
||||
entries = load_auth_config(cfg)
|
||||
assert len(entries) == 1
|
||||
assert entries[0].provider == "azure-devops"
|
||||
assert entries[0].auth == "basic-pat"
|
||||
|
||||
def test_inline_token(self, tmp_path):
|
||||
cfg = tmp_path / "auth.json"
|
||||
cfg.write_text(json.dumps({
|
||||
"providers": [{
|
||||
"hosts": ["github.com"],
|
||||
"provider": "github",
|
||||
"auth": "bearer",
|
||||
"token": "ghp_inline_token",
|
||||
}]
|
||||
}))
|
||||
entries = load_auth_config(cfg)
|
||||
assert entries[0].token == "ghp_inline_token"
|
||||
|
||||
def test_azure_ad_config(self, tmp_path):
|
||||
cfg = tmp_path / "auth.json"
|
||||
cfg.write_text(json.dumps({
|
||||
"providers": [{
|
||||
"hosts": ["dev.azure.com"],
|
||||
"provider": "azure-devops",
|
||||
"auth": "azure-ad",
|
||||
"tenant_id": "tid",
|
||||
"client_id": "cid",
|
||||
"client_secret_env": "SECRET",
|
||||
}]
|
||||
}))
|
||||
entries = load_auth_config(cfg)
|
||||
assert entries[0].auth == "azure-ad"
|
||||
assert entries[0].tenant_id == "tid"
|
||||
|
||||
def test_azure_cli_config(self, tmp_path):
|
||||
cfg = tmp_path / "auth.json"
|
||||
cfg.write_text(json.dumps({
|
||||
"providers": [{
|
||||
"hosts": ["dev.azure.com"],
|
||||
"provider": "azure-devops",
|
||||
"auth": "azure-cli",
|
||||
}]
|
||||
}))
|
||||
entries = load_auth_config(cfg)
|
||||
assert entries[0].auth == "azure-cli"
|
||||
|
||||
def test_multiple_entries(self, tmp_path):
|
||||
cfg = tmp_path / "auth.json"
|
||||
cfg.write_text(json.dumps({
|
||||
"providers": [
|
||||
{"hosts": ["github.com"], "provider": "github", "auth": "bearer", "token_env": "GH_TOKEN"},
|
||||
{"hosts": ["dev.azure.com"], "provider": "azure-devops", "auth": "basic-pat", "token_env": "ADO_PAT"},
|
||||
]
|
||||
}))
|
||||
entries = load_auth_config(cfg)
|
||||
assert len(entries) == 2
|
||||
|
||||
# -- Negative: validation errors --
|
||||
|
||||
def test_invalid_json_raises(self, tmp_path):
|
||||
cfg = tmp_path / "auth.json"
|
||||
cfg.write_text("not json")
|
||||
with pytest.raises(json.JSONDecodeError):
|
||||
load_auth_config(cfg)
|
||||
|
||||
def test_not_object_raises(self, tmp_path):
|
||||
cfg = tmp_path / "auth.json"
|
||||
cfg.write_text("[]")
|
||||
with pytest.raises(ValueError, match="JSON object"):
|
||||
load_auth_config(cfg)
|
||||
|
||||
def test_missing_providers_raises(self, tmp_path):
|
||||
cfg = tmp_path / "auth.json"
|
||||
cfg.write_text(json.dumps({"foo": "bar"}))
|
||||
with pytest.raises(ValueError, match="providers"):
|
||||
load_auth_config(cfg)
|
||||
|
||||
def test_empty_hosts_raises(self, tmp_path):
|
||||
cfg = tmp_path / "auth.json"
|
||||
cfg.write_text(json.dumps({
|
||||
"providers": [{"hosts": [], "provider": "github", "auth": "bearer", "token_env": "X"}]
|
||||
}))
|
||||
with pytest.raises(ValueError, match="non-empty"):
|
||||
load_auth_config(cfg)
|
||||
|
||||
def test_missing_provider_key_raises(self, tmp_path):
|
||||
cfg = tmp_path / "auth.json"
|
||||
cfg.write_text(json.dumps({
|
||||
"providers": [{"hosts": ["github.com"], "auth": "bearer", "token_env": "X"}]
|
||||
}))
|
||||
with pytest.raises(ValueError, match="provider"):
|
||||
load_auth_config(cfg)
|
||||
|
||||
def test_unsupported_auth_scheme_raises(self, tmp_path):
|
||||
cfg = tmp_path / "auth.json"
|
||||
cfg.write_text(json.dumps({
|
||||
"providers": [{"hosts": ["github.com"], "provider": "github", "auth": "ntlm", "token_env": "X"}]
|
||||
}))
|
||||
with pytest.raises(ValueError, match="does not support"):
|
||||
load_auth_config(cfg)
|
||||
|
||||
def test_bearer_without_token_raises(self, tmp_path):
|
||||
cfg = tmp_path / "auth.json"
|
||||
cfg.write_text(json.dumps({
|
||||
"providers": [{"hosts": ["github.com"], "provider": "github", "auth": "bearer"}]
|
||||
}))
|
||||
with pytest.raises(ValueError, match="token"):
|
||||
load_auth_config(cfg)
|
||||
|
||||
def test_azure_ad_missing_fields_raises(self, tmp_path):
|
||||
cfg = tmp_path / "auth.json"
|
||||
cfg.write_text(json.dumps({
|
||||
"providers": [{
|
||||
"hosts": ["dev.azure.com"],
|
||||
"provider": "azure-devops",
|
||||
"auth": "azure-ad",
|
||||
"tenant_id": "tid",
|
||||
}]
|
||||
}))
|
||||
with pytest.raises(ValueError, match="azure-ad"):
|
||||
load_auth_config(cfg)
|
||||
|
||||
def test_unknown_provider_raises(self, tmp_path):
|
||||
cfg = tmp_path / "auth.json"
|
||||
cfg.write_text(json.dumps({
|
||||
"providers": [{"hosts": ["example.com"], "provider": "gitlab", "auth": "bearer", "token_env": "X"}]
|
||||
}))
|
||||
with pytest.raises(ValueError, match="unknown provider"):
|
||||
load_auth_config(cfg)
|
||||
|
||||
def test_incompatible_provider_scheme_raises(self, tmp_path):
|
||||
cfg = tmp_path / "auth.json"
|
||||
cfg.write_text(json.dumps({
|
||||
"providers": [{
|
||||
"hosts": ["github.com"],
|
||||
"provider": "github",
|
||||
"auth": "basic-pat",
|
||||
"token_env": "X",
|
||||
}]
|
||||
}))
|
||||
with pytest.raises(ValueError, match="does not support"):
|
||||
load_auth_config(cfg)
|
||||
|
||||
def test_dangerous_wildcard_host_raises(self, tmp_path):
|
||||
cfg = tmp_path / "auth.json"
|
||||
cfg.write_text(json.dumps({
|
||||
"providers": [{"hosts": ["*github.com"], "provider": "github", "auth": "bearer", "token_env": "X"}]
|
||||
}))
|
||||
with pytest.raises(ValueError, match="invalid host pattern"):
|
||||
load_auth_config(cfg)
|
||||
|
||||
def test_multi_wildcard_host_raises(self, tmp_path):
|
||||
cfg = tmp_path / "auth.json"
|
||||
cfg.write_text(json.dumps({
|
||||
"providers": [{"hosts": ["*.*.example.com"], "provider": "github", "auth": "bearer", "token_env": "X"}]
|
||||
}))
|
||||
with pytest.raises(ValueError, match="invalid host pattern"):
|
||||
load_auth_config(cfg)
|
||||
|
||||
def test_valid_star_dot_host_accepted(self, tmp_path):
|
||||
cfg = tmp_path / "auth.json"
|
||||
cfg.write_text(json.dumps({
|
||||
"providers": [{"hosts": ["*.visualstudio.com"], "provider": "azure-devops", "auth": "basic-pat", "token_env": "X"}]
|
||||
}))
|
||||
entries = load_auth_config(cfg)
|
||||
assert entries[0].hosts == ("*.visualstudio.com",)
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="POSIX permission bits not supported on Windows")
|
||||
def test_world_readable_warns(self, tmp_path):
|
||||
import stat
|
||||
|
||||
cfg = tmp_path / "auth.json"
|
||||
cfg.write_text(json.dumps({
|
||||
"providers": [{"hosts": ["github.com"], "provider": "github", "auth": "bearer", "token_env": "GH_TOKEN"}]
|
||||
}))
|
||||
cfg.chmod(stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IROTH)
|
||||
with pytest.warns(UserWarning, match="readable by group"):
|
||||
load_auth_config(cfg)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Host matching
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFindEntriesForUrl:
|
||||
def test_exact_match(self):
|
||||
entry = _github_entry()
|
||||
result = find_entries_for_url("https://github.com/org/repo", [entry])
|
||||
assert result == [entry]
|
||||
|
||||
def test_wildcard_match(self):
|
||||
entry = AuthConfigEntry(
|
||||
hosts=("*.visualstudio.com",),
|
||||
provider="azure-devops",
|
||||
auth="basic-pat",
|
||||
token_env="ADO_PAT",
|
||||
)
|
||||
result = find_entries_for_url("https://myorg.visualstudio.com/project", [entry])
|
||||
assert result == [entry]
|
||||
|
||||
def test_no_match_returns_empty(self):
|
||||
entry = _github_entry()
|
||||
result = find_entries_for_url("https://evil.example.com/file", [entry])
|
||||
assert result == []
|
||||
|
||||
def test_no_match_for_lookalike_host(self):
|
||||
entry = _github_entry()
|
||||
result = find_entries_for_url("https://github.com.evil.com/file", [entry])
|
||||
assert result == []
|
||||
|
||||
def test_empty_url_returns_empty(self):
|
||||
assert find_entries_for_url("", [_github_entry()]) == []
|
||||
|
||||
def test_empty_entries_returns_empty(self):
|
||||
assert find_entries_for_url("https://github.com/org/repo", []) == []
|
||||
|
||||
def test_multiple_matches_returned(self):
|
||||
e1 = _github_entry(token_env="GH_TOKEN")
|
||||
e2 = _github_entry(token_env="GITHUB_TOKEN")
|
||||
result = find_entries_for_url("https://github.com/org/repo", [e1, e2])
|
||||
assert len(result) == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Registry mechanics
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAuthRegistry:
|
||||
def test_github_registered(self):
|
||||
assert "github" in AUTH_REGISTRY
|
||||
|
||||
def test_azure_devops_registered(self):
|
||||
assert "azure-devops" in AUTH_REGISTRY
|
||||
|
||||
def test_get_provider_returns_github(self):
|
||||
assert isinstance(get_provider("github"), GitHubAuth)
|
||||
|
||||
def test_get_provider_returns_azure_devops(self):
|
||||
assert isinstance(get_provider("azure-devops"), AzureDevOpsAuth)
|
||||
|
||||
def test_get_provider_unknown_returns_none(self):
|
||||
assert get_provider("does-not-exist") is None
|
||||
|
||||
def test_register_duplicate_raises_key_error(self):
|
||||
class _UniqueStub(_StubProvider):
|
||||
key = "__test_duplicate__"
|
||||
|
||||
try:
|
||||
_register(_UniqueStub())
|
||||
with pytest.raises(KeyError, match="already registered"):
|
||||
_register(_UniqueStub())
|
||||
finally:
|
||||
AUTH_REGISTRY.pop("__test_duplicate__", None)
|
||||
|
||||
def test_register_empty_key_raises_value_error(self):
|
||||
class _EmptyKey(_StubProvider):
|
||||
key = ""
|
||||
|
||||
with pytest.raises(ValueError, match="empty key"):
|
||||
_register(_EmptyKey())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GitHubAuth
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGitHubAuth:
|
||||
def test_bearer_headers(self):
|
||||
assert GitHubAuth().auth_headers("my-token", "bearer") == {"Authorization": "Bearer my-token"}
|
||||
|
||||
def test_unsupported_scheme_raises(self):
|
||||
with pytest.raises(ValueError, match="basic-pat"):
|
||||
GitHubAuth().auth_headers("tok", "basic-pat")
|
||||
|
||||
def test_resolve_token_from_env(self, monkeypatch):
|
||||
monkeypatch.setenv("GH_TOKEN", "env-token")
|
||||
assert GitHubAuth().resolve_token(_github_entry()) == "env-token"
|
||||
|
||||
def test_resolve_token_inline(self):
|
||||
assert GitHubAuth().resolve_token(_github_entry(token="inline-tok")) == "inline-tok"
|
||||
|
||||
def test_resolve_token_strips_whitespace(self, monkeypatch):
|
||||
monkeypatch.setenv("GH_TOKEN", " my-token ")
|
||||
assert GitHubAuth().resolve_token(_github_entry()) == "my-token"
|
||||
|
||||
def test_resolve_token_empty_env_returns_none(self, monkeypatch):
|
||||
monkeypatch.setenv("GH_TOKEN", " ")
|
||||
assert GitHubAuth().resolve_token(_github_entry()) is None
|
||||
|
||||
def test_resolve_token_missing_env_returns_none(self, monkeypatch):
|
||||
monkeypatch.delenv("GH_TOKEN", raising=False)
|
||||
assert GitHubAuth().resolve_token(_github_entry()) is None
|
||||
|
||||
def test_key(self):
|
||||
assert GitHubAuth.key == "github"
|
||||
|
||||
def test_supported_schemes(self):
|
||||
assert GitHubAuth.supported_auth_schemes == ("bearer",)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AzureDevOpsAuth
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAzureDevOpsAuth:
|
||||
def test_basic_pat_headers(self):
|
||||
headers = AzureDevOpsAuth().auth_headers("my-pat", "basic-pat")
|
||||
encoded = base64.b64encode(b":my-pat").decode("ascii")
|
||||
assert headers == {"Authorization": f"Basic {encoded}"}
|
||||
|
||||
def test_basic_pat_format(self):
|
||||
header = AzureDevOpsAuth().auth_headers("test-pat", "basic-pat")["Authorization"]
|
||||
raw = base64.b64decode(header[len("Basic "):]).decode("ascii")
|
||||
assert raw == ":test-pat"
|
||||
|
||||
def test_bearer_headers(self):
|
||||
assert AzureDevOpsAuth().auth_headers("tok", "bearer") == {"Authorization": "Bearer tok"}
|
||||
|
||||
def test_azure_cli_headers(self):
|
||||
assert AzureDevOpsAuth().auth_headers("tok", "azure-cli") == {"Authorization": "Bearer tok"}
|
||||
|
||||
def test_azure_ad_headers(self):
|
||||
assert AzureDevOpsAuth().auth_headers("tok", "azure-ad") == {"Authorization": "Bearer tok"}
|
||||
|
||||
def test_unsupported_scheme_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
AzureDevOpsAuth().auth_headers("tok", "ntlm")
|
||||
|
||||
def test_resolve_token_basic_pat(self, monkeypatch):
|
||||
monkeypatch.setenv("AZURE_DEVOPS_PAT", "my-pat")
|
||||
assert AzureDevOpsAuth().resolve_token(_ado_basic_entry()) == "my-pat"
|
||||
|
||||
def test_resolve_token_strips_whitespace(self, monkeypatch):
|
||||
monkeypatch.setenv("AZURE_DEVOPS_PAT", " my-pat ")
|
||||
assert AzureDevOpsAuth().resolve_token(_ado_basic_entry()) == "my-pat"
|
||||
|
||||
def test_resolve_token_missing_returns_none(self, monkeypatch):
|
||||
monkeypatch.delenv("AZURE_DEVOPS_PAT", raising=False)
|
||||
assert AzureDevOpsAuth().resolve_token(_ado_basic_entry()) is None
|
||||
|
||||
def test_key(self):
|
||||
assert AzureDevOpsAuth.key == "azure-devops"
|
||||
|
||||
def test_supported_schemes(self):
|
||||
schemes = AzureDevOpsAuth.supported_auth_schemes
|
||||
assert "basic-pat" in schemes
|
||||
assert "bearer" in schemes
|
||||
assert "azure-cli" in schemes
|
||||
assert "azure-ad" in schemes
|
||||
|
||||
def test_resolve_token_azure_cli_success(self):
|
||||
"""azure-cli acquires token via az CLI."""
|
||||
from unittest.mock import patch, MagicMock
|
||||
entry = AuthConfigEntry(
|
||||
hosts=("dev.azure.com",), provider="azure-devops", auth="azure-cli",
|
||||
)
|
||||
result = MagicMock()
|
||||
result.returncode = 0
|
||||
result.stdout = '{"accessToken": "cli-acquired-token"}'
|
||||
with patch("specify_cli.authentication.azure_devops.subprocess.run", return_value=result):
|
||||
assert AzureDevOpsAuth().resolve_token(entry) == "cli-acquired-token"
|
||||
|
||||
def test_resolve_token_azure_cli_failure_returns_none(self):
|
||||
"""azure-cli returns None when az CLI fails."""
|
||||
from unittest.mock import patch, MagicMock
|
||||
entry = AuthConfigEntry(
|
||||
hosts=("dev.azure.com",), provider="azure-devops", auth="azure-cli",
|
||||
)
|
||||
result = MagicMock()
|
||||
result.returncode = 1
|
||||
result.stdout = ""
|
||||
with patch("specify_cli.authentication.azure_devops.subprocess.run", return_value=result):
|
||||
assert AzureDevOpsAuth().resolve_token(entry) is None
|
||||
|
||||
def test_resolve_token_azure_cli_not_installed_returns_none(self):
|
||||
"""azure-cli returns None when az is not installed."""
|
||||
from unittest.mock import patch
|
||||
entry = AuthConfigEntry(
|
||||
hosts=("dev.azure.com",), provider="azure-devops", auth="azure-cli",
|
||||
)
|
||||
with patch("specify_cli.authentication.azure_devops.subprocess.run", side_effect=OSError("not found")):
|
||||
assert AzureDevOpsAuth().resolve_token(entry) is None
|
||||
|
||||
def test_resolve_token_azure_ad_success(self, monkeypatch):
|
||||
"""azure-ad acquires token via OAuth2 client credentials."""
|
||||
from unittest.mock import patch, MagicMock
|
||||
monkeypatch.setenv("MY_SECRET", "secret-value")
|
||||
entry = AuthConfigEntry(
|
||||
hosts=("dev.azure.com",), provider="azure-devops", auth="azure-ad",
|
||||
tenant_id="tid", client_id="cid", client_secret_env="MY_SECRET",
|
||||
)
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.read.return_value = b'{"access_token": "ad-acquired-token"}'
|
||||
mock_resp.__enter__ = lambda s: s
|
||||
mock_resp.__exit__ = MagicMock(return_value=False)
|
||||
with patch("urllib.request.urlopen", return_value=mock_resp):
|
||||
assert AzureDevOpsAuth().resolve_token(entry) == "ad-acquired-token"
|
||||
|
||||
def test_resolve_token_azure_ad_missing_secret_returns_none(self, monkeypatch):
|
||||
"""azure-ad returns None when client secret env var is missing."""
|
||||
monkeypatch.delenv("MY_SECRET", raising=False)
|
||||
entry = AuthConfigEntry(
|
||||
hosts=("dev.azure.com",), provider="azure-devops", auth="azure-ad",
|
||||
tenant_id="tid", client_id="cid", client_secret_env="MY_SECRET",
|
||||
)
|
||||
assert AzureDevOpsAuth().resolve_token(entry) is None
|
||||
|
||||
def test_resolve_token_azure_ad_network_error_returns_none(self, monkeypatch):
|
||||
"""azure-ad returns None on network errors."""
|
||||
import urllib.error
|
||||
from unittest.mock import patch
|
||||
monkeypatch.setenv("MY_SECRET", "secret-value")
|
||||
entry = AuthConfigEntry(
|
||||
hosts=("dev.azure.com",), provider="azure-devops", auth="azure-ad",
|
||||
tenant_id="tid", client_id="cid", client_secret_env="MY_SECRET",
|
||||
)
|
||||
with patch("urllib.request.urlopen",
|
||||
side_effect=urllib.error.URLError("connection refused")):
|
||||
assert AzureDevOpsAuth().resolve_token(entry) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# open_url / build_request — positive tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAuthenticatedHttp:
|
||||
def _set_config(self, monkeypatch, entries):
|
||||
from specify_cli.authentication import http as _mod
|
||||
monkeypatch.setattr(_mod, "_config_override", entries)
|
||||
|
||||
def test_build_request_attaches_auth_for_matching_host(self, monkeypatch):
|
||||
from specify_cli.authentication.http import build_request
|
||||
monkeypatch.setenv("GH_TOKEN", "my-token")
|
||||
self._set_config(monkeypatch, [_github_entry()])
|
||||
req = build_request("https://github.com/org/repo")
|
||||
assert req.get_header("Authorization") == "Bearer my-token"
|
||||
|
||||
def test_build_request_no_auth_for_non_matching_host(self, monkeypatch):
|
||||
from specify_cli.authentication.http import build_request
|
||||
monkeypatch.setenv("GH_TOKEN", "my-token")
|
||||
self._set_config(monkeypatch, [_github_entry()])
|
||||
req = build_request("https://evil.example.com/file")
|
||||
assert "Authorization" not in req.headers
|
||||
|
||||
def test_build_request_no_auth_when_no_config(self, monkeypatch):
|
||||
from specify_cli.authentication.http import build_request
|
||||
self._set_config(monkeypatch, [])
|
||||
req = build_request("https://github.com/org/repo")
|
||||
assert "Authorization" not in req.headers
|
||||
|
||||
def test_build_request_extra_headers(self, monkeypatch):
|
||||
from specify_cli.authentication.http import build_request
|
||||
monkeypatch.setenv("GH_TOKEN", "my-token")
|
||||
self._set_config(monkeypatch, [_github_entry()])
|
||||
req = build_request("https://github.com/api", extra_headers={"Accept": "application/json"})
|
||||
assert req.get_header("Accept") == "application/json"
|
||||
assert req.get_header("Authorization") == "Bearer my-token"
|
||||
|
||||
def test_open_url_attaches_auth_for_matching_host(self, monkeypatch):
|
||||
from unittest.mock import MagicMock, patch
|
||||
from specify_cli.authentication.http import open_url
|
||||
monkeypatch.setenv("GH_TOKEN", "my-token")
|
||||
self._set_config(monkeypatch, [_github_entry()])
|
||||
captured = {}
|
||||
mock_opener = MagicMock()
|
||||
def fake_open(req, timeout=None):
|
||||
captured["req"] = req
|
||||
resp = MagicMock(); resp.__enter__ = lambda s: s; resp.__exit__ = MagicMock(return_value=False)
|
||||
return resp
|
||||
mock_opener.open.side_effect = fake_open
|
||||
with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
|
||||
open_url("https://github.com/org/repo/catalog.json")
|
||||
assert captured["req"].get_header("Authorization") == "Bearer my-token"
|
||||
|
||||
def test_open_url_no_auth_for_non_matching_host(self, monkeypatch):
|
||||
from unittest.mock import MagicMock, patch
|
||||
from specify_cli.authentication.http import open_url
|
||||
monkeypatch.setenv("GH_TOKEN", "my-token")
|
||||
self._set_config(monkeypatch, [_github_entry()])
|
||||
captured = {}
|
||||
def fake_urlopen(req, timeout=None):
|
||||
captured["req"] = req
|
||||
resp = MagicMock(); resp.__enter__ = lambda s: s; resp.__exit__ = MagicMock(return_value=False)
|
||||
return resp
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=fake_urlopen):
|
||||
open_url("https://example.com/file.json")
|
||||
assert captured["req"].get_header("Authorization") is None
|
||||
|
||||
def test_open_url_no_auth_when_no_config(self, monkeypatch):
|
||||
from unittest.mock import MagicMock, patch
|
||||
from specify_cli.authentication.http import open_url
|
||||
self._set_config(monkeypatch, [])
|
||||
captured = {}
|
||||
def fake_urlopen(req, timeout=None):
|
||||
captured["req"] = req
|
||||
resp = MagicMock(); resp.__enter__ = lambda s: s; resp.__exit__ = MagicMock(return_value=False)
|
||||
return resp
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=fake_urlopen):
|
||||
open_url("https://github.com/org/repo")
|
||||
assert captured["req"].get_header("Authorization") is None
|
||||
|
||||
def test_open_url_falls_through_on_401(self, monkeypatch):
|
||||
import urllib.error
|
||||
from unittest.mock import MagicMock, patch
|
||||
from specify_cli.authentication.http import open_url
|
||||
monkeypatch.setenv("GH_TOKEN", "bad-token")
|
||||
self._set_config(monkeypatch, [_github_entry()])
|
||||
call_count = 0
|
||||
def fake_side_effect(req, timeout=None):
|
||||
nonlocal call_count; call_count += 1
|
||||
if call_count == 1:
|
||||
raise urllib.error.HTTPError("url", 401, "Unauthorized", {}, None)
|
||||
resp = MagicMock(); resp.__enter__ = lambda s: s; resp.__exit__ = MagicMock(return_value=False)
|
||||
return resp
|
||||
mock_opener = MagicMock(); mock_opener.open.side_effect = fake_side_effect
|
||||
with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener), \
|
||||
patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=fake_side_effect):
|
||||
open_url("https://github.com/org/repo")
|
||||
assert call_count == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# open_url — negative tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAuthenticatedHttpNegative:
|
||||
def _set_config(self, monkeypatch, entries):
|
||||
from specify_cli.authentication import http as _mod
|
||||
monkeypatch.setattr(_mod, "_config_override", entries)
|
||||
|
||||
def test_500_raises_immediately(self, monkeypatch):
|
||||
import urllib.error
|
||||
from unittest.mock import MagicMock, patch
|
||||
from specify_cli.authentication.http import open_url
|
||||
monkeypatch.setenv("GH_TOKEN", "tok")
|
||||
self._set_config(monkeypatch, [_github_entry()])
|
||||
mock_opener = MagicMock()
|
||||
mock_opener.open.side_effect = urllib.error.HTTPError("url", 500, "ISE", {}, None)
|
||||
with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
|
||||
with pytest.raises(urllib.error.HTTPError, match="500"):
|
||||
open_url("https://github.com/org/repo")
|
||||
|
||||
def test_404_raises_immediately(self, monkeypatch):
|
||||
import urllib.error
|
||||
from unittest.mock import MagicMock, patch
|
||||
from specify_cli.authentication.http import open_url
|
||||
monkeypatch.setenv("GH_TOKEN", "tok")
|
||||
self._set_config(monkeypatch, [_github_entry()])
|
||||
mock_opener = MagicMock()
|
||||
mock_opener.open.side_effect = urllib.error.HTTPError("url", 404, "Not Found", {}, None)
|
||||
with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
|
||||
with pytest.raises(urllib.error.HTTPError, match="404"):
|
||||
open_url("https://github.com/org/repo")
|
||||
|
||||
def test_urlerror_propagates(self, monkeypatch):
|
||||
import urllib.error
|
||||
from unittest.mock import patch
|
||||
from specify_cli.authentication.http import open_url
|
||||
self._set_config(monkeypatch, [])
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen",
|
||||
side_effect=urllib.error.URLError("refused")):
|
||||
with pytest.raises(urllib.error.URLError):
|
||||
open_url("https://example.com/file")
|
||||
|
||||
def test_timeout_propagates(self, monkeypatch):
|
||||
import socket
|
||||
from unittest.mock import patch
|
||||
from specify_cli.authentication.http import open_url
|
||||
self._set_config(monkeypatch, [])
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen",
|
||||
side_effect=socket.timeout("timed out")):
|
||||
with pytest.raises(socket.timeout):
|
||||
open_url("https://example.com/file")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _load_config caching
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestLoadConfigCaching:
|
||||
def test_config_cached_after_first_load(self, monkeypatch):
|
||||
"""_load_config() should call load_auth_config only once per process."""
|
||||
from unittest.mock import patch
|
||||
from specify_cli.authentication import http as _mod
|
||||
from specify_cli.authentication.config import AuthConfigEntry
|
||||
# Allow the real load path (no override)
|
||||
monkeypatch.setattr(_mod, "_config_override", None)
|
||||
monkeypatch.setattr(_mod, "_config_cache", None)
|
||||
|
||||
entry = _github_entry()
|
||||
call_count = 0
|
||||
|
||||
def fake_load(path=None):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
return [entry]
|
||||
|
||||
with patch.object(_mod, "load_auth_config", side_effect=fake_load):
|
||||
_mod._load_config()
|
||||
_mod._load_config()
|
||||
_mod._load_config()
|
||||
|
||||
assert call_count == 1
|
||||
|
||||
def test_cache_bypassed_by_override(self, monkeypatch):
|
||||
"""When _config_override is set, the cache is ignored entirely."""
|
||||
from specify_cli.authentication import http as _mod
|
||||
sentinel = [_github_entry()]
|
||||
monkeypatch.setattr(_mod, "_config_override", sentinel)
|
||||
monkeypatch.setattr(_mod, "_config_cache", None)
|
||||
|
||||
result = _mod._load_config()
|
||||
assert result is sentinel
|
||||
# Cache must not have been populated when override is active
|
||||
assert _mod._config_cache is None
|
||||
|
||||
def test_failed_load_warns_once_and_caches_empty(self, monkeypatch):
|
||||
"""A bad auth.json emits exactly one warning and subsequent calls use cache."""
|
||||
from unittest.mock import patch
|
||||
from specify_cli.authentication import http as _mod
|
||||
import warnings as _warnings
|
||||
monkeypatch.setattr(_mod, "_config_override", None)
|
||||
monkeypatch.setattr(_mod, "_config_cache", None)
|
||||
|
||||
call_count = 0
|
||||
|
||||
def fail_load(path=None):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
raise ValueError("bad config")
|
||||
|
||||
with patch.object(_mod, "load_auth_config", side_effect=fail_load):
|
||||
with _warnings.catch_warnings(record=True) as w:
|
||||
_warnings.simplefilter("always")
|
||||
result1 = _mod._load_config()
|
||||
result2 = _mod._load_config()
|
||||
result3 = _mod._load_config()
|
||||
|
||||
user_warnings = [x for x in w if issubclass(x.category, UserWarning)]
|
||||
assert len(user_warnings) == 1, "Expected exactly one warning"
|
||||
# Loader called only once — subsequent calls used cache
|
||||
assert call_count == 1
|
||||
# All calls returned the cached empty list
|
||||
assert result1 == result2 == result3 == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Redirect stripping
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRedirectStripping:
|
||||
def test_redirect_within_hosts_preserves_auth(self):
|
||||
from specify_cli.authentication.http import _StripAuthOnRedirect
|
||||
from urllib.request import Request
|
||||
import io
|
||||
handler = _StripAuthOnRedirect(("github.com", "codeload.github.com"))
|
||||
req = Request("https://github.com/org/repo", headers={"Authorization": "Bearer tok"})
|
||||
new_req = handler.redirect_request(req, io.BytesIO(b""), 302, "Found", {},
|
||||
"https://codeload.github.com/org/repo/zip")
|
||||
assert new_req is not None
|
||||
auth = new_req.get_header("Authorization") or new_req.unredirected_hdrs.get("Authorization")
|
||||
assert auth == "Bearer tok"
|
||||
|
||||
def test_redirect_outside_hosts_strips_auth(self):
|
||||
from specify_cli.authentication.http import _StripAuthOnRedirect
|
||||
from urllib.request import Request
|
||||
import io
|
||||
handler = _StripAuthOnRedirect(("github.com",))
|
||||
req = Request("https://github.com/org/repo", headers={"Authorization": "Bearer tok"})
|
||||
new_req = handler.redirect_request(req, io.BytesIO(b""), 302, "Found", {},
|
||||
"https://objects.githubusercontent.com/asset")
|
||||
assert new_req is not None
|
||||
assert new_req.headers.get("Authorization") is None
|
||||
assert new_req.unredirected_hdrs.get("Authorization") is None
|
||||
|
||||
def test_multi_hop_redirect_within_hosts_preserves_auth(self):
|
||||
"""Auth survives a multi-hop redirect chain within allowed hosts."""
|
||||
from specify_cli.authentication.http import _StripAuthOnRedirect
|
||||
from urllib.request import Request
|
||||
import io
|
||||
hosts = ("github.com", "codeload.github.com", "objects-origin.githubusercontent.com")
|
||||
handler = _StripAuthOnRedirect(hosts)
|
||||
|
||||
# First hop: github.com → codeload.github.com
|
||||
req1 = Request("https://github.com/org/repo", headers={"Authorization": "Bearer tok"})
|
||||
req2 = handler.redirect_request(req1, io.BytesIO(b""), 302, "Found", {},
|
||||
"https://codeload.github.com/org/repo/zip")
|
||||
assert req2 is not None
|
||||
auth2 = req2.get_header("Authorization") or req2.unredirected_hdrs.get("Authorization")
|
||||
assert auth2 == "Bearer tok"
|
||||
|
||||
# Second hop: codeload.github.com → objects-origin.githubusercontent.com
|
||||
req3 = handler.redirect_request(req2, io.BytesIO(b""), 302, "Found", {},
|
||||
"https://objects-origin.githubusercontent.com/asset")
|
||||
assert req3 is not None
|
||||
auth3 = req3.get_header("Authorization") or req3.unredirected_hdrs.get("Authorization")
|
||||
assert auth3 == "Bearer tok"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _fetch_latest_release_tag delegation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFetchLatestReleaseTagDelegation:
|
||||
def _set_config(self, monkeypatch, entries):
|
||||
from specify_cli.authentication import http as _mod
|
||||
monkeypatch.setattr(_mod, "_config_override", entries)
|
||||
|
||||
def _capture_request(self):
|
||||
import json as _json
|
||||
from unittest.mock import MagicMock
|
||||
captured: dict = {}
|
||||
def side_effect(req, timeout=None):
|
||||
captured["request"] = req
|
||||
body = _json.dumps({"tag_name": "v9.9.9"}).encode()
|
||||
resp = MagicMock(); resp.read.return_value = body
|
||||
cm = MagicMock(); cm.__enter__.return_value = resp; cm.__exit__.return_value = False
|
||||
return cm
|
||||
return captured, side_effect
|
||||
|
||||
def test_gh_token_forwarded_when_configured(self, monkeypatch):
|
||||
from unittest.mock import MagicMock, patch
|
||||
from specify_cli import _fetch_latest_release_tag
|
||||
monkeypatch.setenv("GH_TOKEN", "forwarded-sentinel")
|
||||
self._set_config(monkeypatch, [_github_entry()])
|
||||
captured, side_effect = self._capture_request()
|
||||
mock_opener = MagicMock(); mock_opener.open.side_effect = side_effect
|
||||
with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
|
||||
_fetch_latest_release_tag()
|
||||
assert captured["request"].get_header("Authorization") == "Bearer forwarded-sentinel"
|
||||
|
||||
def test_no_config_means_no_auth(self, monkeypatch):
|
||||
from unittest.mock import patch
|
||||
from specify_cli import _fetch_latest_release_tag
|
||||
self._set_config(monkeypatch, [])
|
||||
captured, side_effect = self._capture_request()
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect):
|
||||
_fetch_latest_release_tag()
|
||||
assert captured["request"].get_header("Authorization") is None
|
||||
|
||||
def test_accept_header_present(self, monkeypatch):
|
||||
from unittest.mock import patch
|
||||
from specify_cli import _fetch_latest_release_tag
|
||||
self._set_config(monkeypatch, [])
|
||||
captured, side_effect = self._capture_request()
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect):
|
||||
_fetch_latest_release_tag()
|
||||
assert captured["request"].get_header("Accept") == "application/vnd.github+json"
|
||||
@@ -2453,6 +2453,10 @@ class TestExtensionCatalog:
|
||||
(project_dir / ".specify").mkdir()
|
||||
return ExtensionCatalog(project_dir)
|
||||
|
||||
def _inject_github_config(self, monkeypatch, token_env="GH_TOKEN"):
|
||||
from tests.auth_helpers import inject_github_config
|
||||
inject_github_config(monkeypatch, token_env)
|
||||
|
||||
def test_make_request_no_token_no_auth_header(self, temp_dir, monkeypatch):
|
||||
"""Without a token, requests carry no Authorization header."""
|
||||
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
||||
@@ -2473,6 +2477,7 @@ class TestExtensionCatalog:
|
||||
"""When GITHUB_TOKEN is whitespace-only, GH_TOKEN is used as fallback."""
|
||||
monkeypatch.setenv("GITHUB_TOKEN", " ")
|
||||
monkeypatch.setenv("GH_TOKEN", "ghp_fallback")
|
||||
self._inject_github_config(monkeypatch, token_env="GH_TOKEN")
|
||||
catalog = self._make_catalog(temp_dir)
|
||||
req = catalog._make_request("https://raw.githubusercontent.com/org/repo/main/catalog.json")
|
||||
assert req.get_header("Authorization") == "Bearer ghp_fallback"
|
||||
@@ -2481,6 +2486,7 @@ class TestExtensionCatalog:
|
||||
"""GITHUB_TOKEN is attached for raw.githubusercontent.com URLs."""
|
||||
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
|
||||
monkeypatch.delenv("GH_TOKEN", raising=False)
|
||||
self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN")
|
||||
catalog = self._make_catalog(temp_dir)
|
||||
req = catalog._make_request("https://raw.githubusercontent.com/org/repo/main/catalog.json")
|
||||
assert req.get_header("Authorization") == "Bearer ghp_testtoken"
|
||||
@@ -2489,49 +2495,40 @@ class TestExtensionCatalog:
|
||||
"""GH_TOKEN is used when GITHUB_TOKEN is absent."""
|
||||
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
||||
monkeypatch.setenv("GH_TOKEN", "ghp_ghtoken")
|
||||
self._inject_github_config(monkeypatch, token_env="GH_TOKEN")
|
||||
catalog = self._make_catalog(temp_dir)
|
||||
req = catalog._make_request("https://github.com/org/repo/releases/download/v1/ext.zip")
|
||||
assert req.get_header("Authorization") == "Bearer ghp_ghtoken"
|
||||
|
||||
def test_make_request_github_token_takes_precedence_over_gh_token(self, temp_dir, monkeypatch):
|
||||
"""GITHUB_TOKEN takes precedence over GH_TOKEN when both are set."""
|
||||
monkeypatch.setenv("GITHUB_TOKEN", "ghp_primary")
|
||||
monkeypatch.setenv("GH_TOKEN", "ghp_secondary")
|
||||
def test_make_request_gh_token_takes_precedence_over_github_token(self, temp_dir, monkeypatch):
|
||||
"""When auth.json uses GH_TOKEN, that token is used regardless of GITHUB_TOKEN."""
|
||||
monkeypatch.setenv("GITHUB_TOKEN", "ghp_secondary")
|
||||
monkeypatch.setenv("GH_TOKEN", "ghp_primary")
|
||||
self._inject_github_config(monkeypatch, token_env="GH_TOKEN")
|
||||
catalog = self._make_catalog(temp_dir)
|
||||
req = catalog._make_request("https://api.github.com/repos/org/repo")
|
||||
assert req.get_header("Authorization") == "Bearer ghp_primary"
|
||||
|
||||
def test_make_request_token_not_added_for_non_github_url(self, temp_dir, monkeypatch):
|
||||
"""Auth header is never attached to non-GitHub URLs to prevent credential leakage."""
|
||||
def test_make_request_no_auth_for_non_matching_host(self, temp_dir, monkeypatch):
|
||||
"""Auth is NOT attached to hosts not listed in auth.json."""
|
||||
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
|
||||
self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN")
|
||||
catalog = self._make_catalog(temp_dir)
|
||||
req = catalog._make_request("https://internal.example.com/catalog.json")
|
||||
assert "Authorization" not in req.headers
|
||||
|
||||
def test_make_request_token_not_added_for_github_lookalike_host(self, temp_dir, monkeypatch):
|
||||
"""Auth header is not attached to hosts that include github.com as a suffix."""
|
||||
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
|
||||
def test_make_request_no_auth_when_no_config(self, temp_dir, monkeypatch):
|
||||
"""No auth header when no auth.json config exists."""
|
||||
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
||||
monkeypatch.delenv("GH_TOKEN", raising=False)
|
||||
catalog = self._make_catalog(temp_dir)
|
||||
req = catalog._make_request("https://github.com.evil.com/org/repo/releases/download/v1/ext.zip")
|
||||
assert "Authorization" not in req.headers
|
||||
|
||||
def test_make_request_token_not_added_for_github_in_path(self, temp_dir, monkeypatch):
|
||||
"""Auth header is not attached when github.com appears only in the URL path."""
|
||||
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
|
||||
catalog = self._make_catalog(temp_dir)
|
||||
req = catalog._make_request("https://evil.example.com/github.com/org/repo/releases/download/v1/ext.zip")
|
||||
assert "Authorization" not in req.headers
|
||||
|
||||
def test_make_request_token_not_added_for_github_in_query(self, temp_dir, monkeypatch):
|
||||
"""Auth header is not attached when github.com appears only in the query string."""
|
||||
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
|
||||
catalog = self._make_catalog(temp_dir)
|
||||
req = catalog._make_request("https://evil.example.com/download?source=https://github.com/org/repo/v1/ext.zip")
|
||||
req = catalog._make_request("https://github.com/org/repo/releases/download/v1/ext.zip")
|
||||
assert "Authorization" not in req.headers
|
||||
|
||||
def test_make_request_token_added_for_api_github_com(self, temp_dir, monkeypatch):
|
||||
"""GITHUB_TOKEN is attached for api.github.com URLs."""
|
||||
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
|
||||
self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN")
|
||||
catalog = self._make_catalog(temp_dir)
|
||||
req = catalog._make_request("https://api.github.com/repos/org/repo/releases/assets/1")
|
||||
assert req.get_header("Authorization") == "Bearer ghp_testtoken"
|
||||
@@ -2539,49 +2536,17 @@ class TestExtensionCatalog:
|
||||
def test_make_request_token_added_for_codeload_github_com(self, temp_dir, monkeypatch):
|
||||
"""GITHUB_TOKEN is attached for codeload.github.com URLs (GitHub archive redirects)."""
|
||||
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
|
||||
self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN")
|
||||
catalog = self._make_catalog(temp_dir)
|
||||
req = catalog._make_request("https://codeload.github.com/org/repo/zip/refs/tags/v1.0.0")
|
||||
assert req.get_header("Authorization") == "Bearer ghp_testtoken"
|
||||
|
||||
def test_redirect_preserves_auth_for_github_to_codeload(self):
|
||||
"""Auth header is preserved when GitHub redirects to codeload.github.com."""
|
||||
from specify_cli._github_http import _StripAuthOnRedirect
|
||||
from urllib.request import Request
|
||||
import io
|
||||
|
||||
handler = _StripAuthOnRedirect()
|
||||
original_url = "https://github.com/org/repo/archive/refs/tags/v1.zip"
|
||||
redirect_url = "https://codeload.github.com/org/repo/zip/refs/tags/v1"
|
||||
req = Request(original_url, headers={"Authorization": "Bearer ghp_test"})
|
||||
fp = io.BytesIO(b"")
|
||||
new_req = handler.redirect_request(req, fp, 302, "Found", {}, redirect_url)
|
||||
assert new_req is not None
|
||||
auth = new_req.get_header("Authorization") or new_req.unredirected_hdrs.get("Authorization")
|
||||
assert auth == "Bearer ghp_test"
|
||||
|
||||
def test_redirect_strips_auth_for_github_to_external(self):
|
||||
"""Auth header is stripped when GitHub redirects to a non-GitHub host."""
|
||||
from specify_cli._github_http import _StripAuthOnRedirect
|
||||
from urllib.request import Request
|
||||
import io
|
||||
|
||||
handler = _StripAuthOnRedirect()
|
||||
original_url = "https://github.com/org/repo/releases/download/v1/asset.zip"
|
||||
redirect_url = "https://objects.githubusercontent.com/github-production-release-asset/12345"
|
||||
req = Request(original_url, headers={"Authorization": "Bearer ghp_test"})
|
||||
fp = io.BytesIO(b"")
|
||||
new_req = handler.redirect_request(req, fp, 302, "Found", {}, redirect_url)
|
||||
assert new_req is not None
|
||||
auth_header = new_req.headers.get("Authorization")
|
||||
auth_unredirected = new_req.unredirected_hdrs.get("Authorization")
|
||||
assert auth_header is None
|
||||
assert auth_unredirected is None
|
||||
|
||||
def test_fetch_single_catalog_sends_auth_header(self, temp_dir, monkeypatch):
|
||||
"""_fetch_single_catalog passes Authorization header via opener for GitHub URLs."""
|
||||
"""_fetch_single_catalog passes Authorization header when a provider is configured."""
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
|
||||
self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN")
|
||||
catalog = self._make_catalog(temp_dir)
|
||||
|
||||
catalog_data = {"schema_version": "1.0", "extensions": {}}
|
||||
@@ -2589,6 +2554,7 @@ class TestExtensionCatalog:
|
||||
mock_response.read.return_value = json.dumps(catalog_data).encode()
|
||||
mock_response.__enter__ = lambda s: s
|
||||
mock_response.__exit__ = MagicMock(return_value=False)
|
||||
mock_response.geturl.return_value = "https://raw.githubusercontent.com/org/repo/main/catalog.json"
|
||||
|
||||
captured = {}
|
||||
mock_opener = MagicMock()
|
||||
@@ -2606,17 +2572,18 @@ class TestExtensionCatalog:
|
||||
install_allowed=True,
|
||||
)
|
||||
|
||||
with patch("urllib.request.build_opener", return_value=mock_opener):
|
||||
with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
|
||||
catalog._fetch_single_catalog(entry, force_refresh=True)
|
||||
|
||||
assert captured["req"].get_header("Authorization") == "Bearer ghp_testtoken"
|
||||
|
||||
def test_download_extension_sends_auth_header(self, temp_dir, monkeypatch):
|
||||
"""download_extension passes Authorization header via opener for GitHub URLs."""
|
||||
"""download_extension passes Authorization header when a provider is configured."""
|
||||
from unittest.mock import patch, MagicMock
|
||||
import zipfile, io
|
||||
|
||||
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
|
||||
self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN")
|
||||
catalog = self._make_catalog(temp_dir)
|
||||
|
||||
# Build a minimal valid ZIP in memory
|
||||
@@ -2631,7 +2598,6 @@ class TestExtensionCatalog:
|
||||
mock_response.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
captured = {}
|
||||
|
||||
mock_opener = MagicMock()
|
||||
|
||||
def fake_open(req, timeout=None):
|
||||
@@ -2648,7 +2614,7 @@ class TestExtensionCatalog:
|
||||
}
|
||||
|
||||
with patch.object(catalog, "get_extension_info", return_value=ext_info), \
|
||||
patch("urllib.request.build_opener", return_value=mock_opener):
|
||||
patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
|
||||
catalog.download_extension("test-ext", target_dir=temp_dir)
|
||||
|
||||
assert captured["req"].get_header("Authorization") == "Bearer ghp_testtoken"
|
||||
|
||||
@@ -1224,6 +1224,10 @@ class TestExtensionPriorityResolution:
|
||||
class TestPresetCatalog:
|
||||
"""Test template catalog functionality."""
|
||||
|
||||
def _inject_github_config(self, monkeypatch, token_env="GH_TOKEN"):
|
||||
from tests.auth_helpers import inject_github_config
|
||||
inject_github_config(monkeypatch, token_env)
|
||||
|
||||
def test_default_catalog_url(self, project_dir):
|
||||
"""Test default catalog URL."""
|
||||
catalog = PresetCatalog(project_dir)
|
||||
@@ -1418,6 +1422,7 @@ class TestPresetCatalog:
|
||||
"""When GITHUB_TOKEN is whitespace-only, GH_TOKEN is used as fallback."""
|
||||
monkeypatch.setenv("GITHUB_TOKEN", " ")
|
||||
monkeypatch.setenv("GH_TOKEN", "ghp_fallback")
|
||||
self._inject_github_config(monkeypatch, token_env="GH_TOKEN")
|
||||
catalog = PresetCatalog(project_dir)
|
||||
req = catalog._make_request("https://raw.githubusercontent.com/org/repo/main/catalog.json")
|
||||
assert req.get_header("Authorization") == "Bearer ghp_fallback"
|
||||
@@ -1426,6 +1431,7 @@ class TestPresetCatalog:
|
||||
"""GITHUB_TOKEN is attached for raw.githubusercontent.com URLs."""
|
||||
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
|
||||
monkeypatch.delenv("GH_TOKEN", raising=False)
|
||||
self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN")
|
||||
catalog = PresetCatalog(project_dir)
|
||||
req = catalog._make_request("https://raw.githubusercontent.com/org/repo/main/catalog.json")
|
||||
assert req.get_header("Authorization") == "Bearer ghp_testtoken"
|
||||
@@ -1434,58 +1440,50 @@ class TestPresetCatalog:
|
||||
"""GH_TOKEN is used when GITHUB_TOKEN is absent."""
|
||||
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
||||
monkeypatch.setenv("GH_TOKEN", "ghp_ghtoken")
|
||||
self._inject_github_config(monkeypatch, token_env="GH_TOKEN")
|
||||
catalog = PresetCatalog(project_dir)
|
||||
req = catalog._make_request("https://github.com/org/repo/releases/download/v1/pack.zip")
|
||||
assert req.get_header("Authorization") == "Bearer ghp_ghtoken"
|
||||
|
||||
def test_make_request_github_token_takes_precedence(self, project_dir, monkeypatch):
|
||||
"""GITHUB_TOKEN takes precedence over GH_TOKEN when both are set."""
|
||||
monkeypatch.setenv("GITHUB_TOKEN", "ghp_primary")
|
||||
monkeypatch.setenv("GH_TOKEN", "ghp_secondary")
|
||||
def test_make_request_gh_token_takes_precedence(self, project_dir, monkeypatch):
|
||||
"""When auth.json uses GH_TOKEN, that token is used regardless of GITHUB_TOKEN."""
|
||||
monkeypatch.setenv("GITHUB_TOKEN", "ghp_secondary")
|
||||
monkeypatch.setenv("GH_TOKEN", "ghp_primary")
|
||||
self._inject_github_config(monkeypatch, token_env="GH_TOKEN")
|
||||
catalog = PresetCatalog(project_dir)
|
||||
req = catalog._make_request("https://api.github.com/repos/org/repo")
|
||||
assert req.get_header("Authorization") == "Bearer ghp_primary"
|
||||
|
||||
def test_make_request_token_added_for_codeload_github_com(self, project_dir, monkeypatch):
|
||||
"""GITHUB_TOKEN is attached for codeload.github.com URLs (GitHub archive redirects)."""
|
||||
"""GITHUB_TOKEN is attached for codeload.github.com URLs."""
|
||||
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
|
||||
self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN")
|
||||
catalog = PresetCatalog(project_dir)
|
||||
req = catalog._make_request("https://codeload.github.com/org/repo/zip/refs/tags/v1.0.0")
|
||||
assert req.get_header("Authorization") == "Bearer ghp_testtoken"
|
||||
|
||||
def test_make_request_token_not_added_for_non_github_url(self, project_dir, monkeypatch):
|
||||
"""Auth header is never attached to non-GitHub URLs to prevent credential leakage."""
|
||||
def test_make_request_no_auth_for_non_matching_host(self, project_dir, monkeypatch):
|
||||
"""Auth is NOT attached to hosts not listed in auth.json."""
|
||||
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
|
||||
self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN")
|
||||
catalog = PresetCatalog(project_dir)
|
||||
req = catalog._make_request("https://internal.example.com/catalog.json")
|
||||
assert "Authorization" not in req.headers
|
||||
|
||||
def test_make_request_token_not_added_for_github_lookalike_host(self, project_dir, monkeypatch):
|
||||
"""Auth header is not attached to hosts that include github.com as a suffix."""
|
||||
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
|
||||
def test_make_request_no_auth_when_no_config(self, project_dir, monkeypatch):
|
||||
"""No auth header when no auth.json config exists."""
|
||||
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
||||
monkeypatch.delenv("GH_TOKEN", raising=False)
|
||||
catalog = PresetCatalog(project_dir)
|
||||
req = catalog._make_request("https://github.com.evil.com/org/repo/releases/download/v1/pack.zip")
|
||||
assert "Authorization" not in req.headers
|
||||
|
||||
def test_make_request_token_not_added_for_github_in_path(self, project_dir, monkeypatch):
|
||||
"""Auth header is not attached when github.com appears only in the URL path."""
|
||||
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
|
||||
catalog = PresetCatalog(project_dir)
|
||||
req = catalog._make_request("https://evil.example.com/github.com/org/repo/releases/download/v1/pack.zip")
|
||||
assert "Authorization" not in req.headers
|
||||
|
||||
def test_make_request_token_not_added_for_github_in_query(self, project_dir, monkeypatch):
|
||||
"""Auth header is not attached when github.com appears only in the query string."""
|
||||
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
|
||||
catalog = PresetCatalog(project_dir)
|
||||
req = catalog._make_request("https://evil.example.com/download?source=https://github.com/org/repo/v1/pack.zip")
|
||||
req = catalog._make_request("https://github.com/org/repo/releases/download/v1/pack.zip")
|
||||
assert "Authorization" not in req.headers
|
||||
|
||||
def test_fetch_single_catalog_sends_auth_header(self, project_dir, monkeypatch):
|
||||
"""_fetch_single_catalog passes Authorization header via opener for GitHub URLs."""
|
||||
"""_fetch_single_catalog passes Authorization header when configured."""
|
||||
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)
|
||||
|
||||
catalog_data = {"schema_version": "1.0", "presets": {}}
|
||||
@@ -1493,6 +1491,7 @@ class TestPresetCatalog:
|
||||
mock_response.read.return_value = json.dumps(catalog_data).encode()
|
||||
mock_response.__enter__ = lambda s: s
|
||||
mock_response.__exit__ = MagicMock(return_value=False)
|
||||
mock_response.geturl.return_value = "https://raw.githubusercontent.com/org/repo/main/presets/catalog.json"
|
||||
|
||||
captured = {}
|
||||
mock_opener = MagicMock()
|
||||
@@ -1510,16 +1509,17 @@ class TestPresetCatalog:
|
||||
install_allowed=True,
|
||||
)
|
||||
|
||||
with patch("urllib.request.build_opener", return_value=mock_opener):
|
||||
with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
|
||||
catalog._fetch_single_catalog(entry, force_refresh=True)
|
||||
|
||||
assert captured["req"].get_header("Authorization") == "Bearer ghp_testtoken"
|
||||
|
||||
def test_download_pack_sends_auth_header(self, project_dir, monkeypatch):
|
||||
"""download_pack passes Authorization header via opener for GitHub URLs."""
|
||||
"""download_pack passes Authorization header when configured."""
|
||||
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
|
||||
@@ -1551,7 +1551,7 @@ class TestPresetCatalog:
|
||||
}
|
||||
|
||||
with patch.object(catalog, "get_pack_info", return_value=pack_info), \
|
||||
patch("urllib.request.build_opener", return_value=mock_opener):
|
||||
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"
|
||||
|
||||
@@ -23,7 +23,6 @@ from specify_cli import (
|
||||
_normalize_tag,
|
||||
app,
|
||||
)
|
||||
|
||||
from tests.conftest import strip_ansi
|
||||
|
||||
runner = CliRunner()
|
||||
@@ -31,6 +30,10 @@ runner = CliRunner()
|
||||
SENTINEL_GH_TOKEN = "SENTINEL-GH-TOKEN-VALUE"
|
||||
SENTINEL_GITHUB_TOKEN = "SENTINEL-GITHUB-TOKEN-VALUE"
|
||||
|
||||
_RATE_LIMITED_REASON = (
|
||||
"rate limited (configure ~/.specify/auth.json with a GitHub token)"
|
||||
)
|
||||
|
||||
|
||||
def _mock_urlopen_response(payload: dict) -> MagicMock:
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
@@ -66,11 +69,20 @@ class TestSelfUpgradeStub:
|
||||
]
|
||||
|
||||
def test_stub_makes_no_network_call(self):
|
||||
# If the stub ever starts calling urllib, this patch's side_effect
|
||||
# would fire and the assertion below would fail.
|
||||
with patch(
|
||||
"specify_cli.urllib.request.urlopen",
|
||||
side_effect=AssertionError("stub must not hit the network"),
|
||||
# The stub must not hit the network via either urllib path:
|
||||
# unauthenticated requests use urlopen() directly; authenticated ones
|
||||
# go through build_opener(...).open(). Both are patched so that any
|
||||
# accidental network call raises immediately.
|
||||
network_error = AssertionError("stub must not hit the network")
|
||||
with (
|
||||
patch(
|
||||
"specify_cli.authentication.http.urllib.request.urlopen",
|
||||
side_effect=network_error,
|
||||
),
|
||||
patch(
|
||||
"specify_cli.authentication.http.urllib.request.build_opener",
|
||||
side_effect=network_error,
|
||||
),
|
||||
):
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
assert result.exit_code == 0
|
||||
@@ -138,7 +150,7 @@ class TestNormalizeTag:
|
||||
class TestUserStory1:
|
||||
def test_newer_available_prints_update_and_install_command(self):
|
||||
with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch(
|
||||
"specify_cli.urllib.request.urlopen",
|
||||
"specify_cli.authentication.http.urllib.request.urlopen",
|
||||
return_value=_mock_urlopen_response({"tag_name": "v0.9.0"}),
|
||||
):
|
||||
result = runner.invoke(app, ["self", "check"])
|
||||
@@ -151,7 +163,7 @@ class TestUserStory1:
|
||||
|
||||
def test_up_to_date_prints_current_only(self):
|
||||
with patch("specify_cli._get_installed_version", return_value="0.9.0"), patch(
|
||||
"specify_cli.urllib.request.urlopen",
|
||||
"specify_cli.authentication.http.urllib.request.urlopen",
|
||||
return_value=_mock_urlopen_response({"tag_name": "v0.9.0"}),
|
||||
):
|
||||
result = runner.invoke(app, ["self", "check"])
|
||||
@@ -163,7 +175,7 @@ class TestUserStory1:
|
||||
|
||||
def test_dev_build_ahead_of_release_is_up_to_date(self):
|
||||
with patch("specify_cli._get_installed_version", return_value="0.7.5.dev0"), patch(
|
||||
"specify_cli.urllib.request.urlopen",
|
||||
"specify_cli.authentication.http.urllib.request.urlopen",
|
||||
return_value=_mock_urlopen_response({"tag_name": "v0.7.4"}),
|
||||
):
|
||||
result = runner.invoke(app, ["self", "check"])
|
||||
@@ -174,7 +186,7 @@ class TestUserStory1:
|
||||
|
||||
def test_unknown_installed_still_prints_latest_and_reinstall(self):
|
||||
with patch("specify_cli._get_installed_version", return_value="unknown"), patch(
|
||||
"specify_cli.urllib.request.urlopen",
|
||||
"specify_cli.authentication.http.urllib.request.urlopen",
|
||||
return_value=_mock_urlopen_response({"tag_name": "v0.7.4"}),
|
||||
):
|
||||
result = runner.invoke(app, ["self", "check"])
|
||||
@@ -186,7 +198,7 @@ class TestUserStory1:
|
||||
|
||||
def test_unparseable_tag_routes_to_indeterminate(self):
|
||||
with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch(
|
||||
"specify_cli.urllib.request.urlopen",
|
||||
"specify_cli.authentication.http.urllib.request.urlopen",
|
||||
return_value=_mock_urlopen_response({"tag_name": "not-a-version"}),
|
||||
):
|
||||
result = runner.invoke(app, ["self", "check"])
|
||||
@@ -200,7 +212,7 @@ class TestUserStory1:
|
||||
class TestFailureCategorization:
|
||||
def test_urlerror_maps_to_offline(self):
|
||||
with patch(
|
||||
"specify_cli.urllib.request.urlopen",
|
||||
"specify_cli.authentication.http.urllib.request.urlopen",
|
||||
side_effect=urllib.error.URLError("no route to host"),
|
||||
):
|
||||
tag, reason = _fetch_latest_release_tag()
|
||||
@@ -209,7 +221,7 @@ class TestFailureCategorization:
|
||||
|
||||
def test_timeout_maps_to_offline(self):
|
||||
with patch(
|
||||
"specify_cli.urllib.request.urlopen",
|
||||
"specify_cli.authentication.http.urllib.request.urlopen",
|
||||
side_effect=TimeoutError(),
|
||||
):
|
||||
tag, reason = _fetch_latest_release_tag()
|
||||
@@ -218,17 +230,17 @@ class TestFailureCategorization:
|
||||
|
||||
def test_403_maps_to_rate_limited(self):
|
||||
with patch(
|
||||
"specify_cli.urllib.request.urlopen",
|
||||
"specify_cli.authentication.http.urllib.request.urlopen",
|
||||
side_effect=_http_error(403, "rate limited"),
|
||||
):
|
||||
tag, reason = _fetch_latest_release_tag()
|
||||
assert tag is None
|
||||
assert reason == "rate limited (try setting GH_TOKEN or GITHUB_TOKEN)"
|
||||
assert reason == _RATE_LIMITED_REASON
|
||||
|
||||
@pytest.mark.parametrize("code", [404, 500, 502])
|
||||
def test_other_http_uses_code_string(self, code):
|
||||
with patch(
|
||||
"specify_cli.urllib.request.urlopen",
|
||||
"specify_cli.authentication.http.urllib.request.urlopen",
|
||||
side_effect=_http_error(code, "oops"),
|
||||
):
|
||||
tag, reason = _fetch_latest_release_tag()
|
||||
@@ -238,7 +250,7 @@ class TestFailureCategorization:
|
||||
def test_generic_exception_propagates(self):
|
||||
# Per research D-006, no catch-all exists; RuntimeError MUST bubble.
|
||||
with patch(
|
||||
"specify_cli.urllib.request.urlopen",
|
||||
"specify_cli.authentication.http.urllib.request.urlopen",
|
||||
side_effect=RuntimeError("boom"),
|
||||
):
|
||||
with pytest.raises(RuntimeError):
|
||||
@@ -247,7 +259,7 @@ class TestFailureCategorization:
|
||||
|
||||
_FAILURE_CASES = [
|
||||
("offline or timeout", urllib.error.URLError("down")),
|
||||
("rate limited (try setting GH_TOKEN or GITHUB_TOKEN)", _http_error(403)),
|
||||
(_RATE_LIMITED_REASON, _http_error(403)),
|
||||
("HTTP 500", _http_error(500)),
|
||||
]
|
||||
|
||||
@@ -258,22 +270,21 @@ class TestUserStory2:
|
||||
self, expected_reason, side_effect
|
||||
):
|
||||
with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch(
|
||||
"specify_cli.urllib.request.urlopen", side_effect=side_effect
|
||||
"specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect
|
||||
):
|
||||
result = runner.invoke(app, ["self", "check"])
|
||||
output = strip_ansi(result.output)
|
||||
assert "Installed: 0.7.4" in output
|
||||
if expected_reason == "rate limited (try setting GH_TOKEN or GITHUB_TOKEN)":
|
||||
if expected_reason == _RATE_LIMITED_REASON:
|
||||
assert "Could not check latest release: rate limited" in output
|
||||
assert "GH_TOKEN" in output
|
||||
assert "GITHUB_TOKEN" in output
|
||||
assert "~/.specify/auth.json" in output
|
||||
else:
|
||||
assert f"Could not check latest release: {expected_reason}" in output
|
||||
|
||||
@pytest.mark.parametrize("_expected_reason, side_effect", _FAILURE_CASES)
|
||||
def test_failure_exits_zero(self, _expected_reason, side_effect):
|
||||
with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch(
|
||||
"specify_cli.urllib.request.urlopen", side_effect=side_effect
|
||||
"specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect
|
||||
):
|
||||
result = runner.invoke(app, ["self", "check"])
|
||||
assert result.exit_code == 0
|
||||
@@ -283,7 +294,7 @@ class TestUserStory2:
|
||||
self, _expected_reason, side_effect
|
||||
):
|
||||
with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch(
|
||||
"specify_cli.urllib.request.urlopen", side_effect=side_effect
|
||||
"specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect
|
||||
):
|
||||
result = runner.invoke(app, ["self", "check"])
|
||||
combined = (result.output or "") + (result.stderr or "")
|
||||
@@ -302,12 +313,20 @@ def _capture_request_via_urlopen():
|
||||
return captured, _side_effect
|
||||
|
||||
|
||||
def _inject_github_config(monkeypatch, token_env="GH_TOKEN"):
|
||||
from tests.auth_helpers import inject_github_config
|
||||
inject_github_config(monkeypatch, token_env)
|
||||
|
||||
|
||||
class TestUserStory3:
|
||||
def test_gh_token_attached_as_bearer_header(self, monkeypatch):
|
||||
monkeypatch.setenv("GH_TOKEN", SENTINEL_GH_TOKEN)
|
||||
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
||||
_inject_github_config(monkeypatch, token_env="GH_TOKEN")
|
||||
captured, side_effect = _capture_request_via_urlopen()
|
||||
with patch("specify_cli.urllib.request.urlopen", side_effect=side_effect):
|
||||
mock_opener = MagicMock()
|
||||
mock_opener.open.side_effect = side_effect
|
||||
with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
|
||||
_fetch_latest_release_tag()
|
||||
req = captured["request"]
|
||||
assert req.get_header("Authorization") == f"Bearer {SENTINEL_GH_TOKEN}"
|
||||
@@ -315,8 +334,11 @@ class TestUserStory3:
|
||||
def test_github_token_used_when_gh_token_unset(self, monkeypatch):
|
||||
monkeypatch.delenv("GH_TOKEN", raising=False)
|
||||
monkeypatch.setenv("GITHUB_TOKEN", SENTINEL_GITHUB_TOKEN)
|
||||
_inject_github_config(monkeypatch, token_env="GITHUB_TOKEN")
|
||||
captured, side_effect = _capture_request_via_urlopen()
|
||||
with patch("specify_cli.urllib.request.urlopen", side_effect=side_effect):
|
||||
mock_opener = MagicMock()
|
||||
mock_opener.open.side_effect = side_effect
|
||||
with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
|
||||
_fetch_latest_release_tag()
|
||||
req = captured["request"]
|
||||
assert req.get_header("Authorization") == f"Bearer {SENTINEL_GITHUB_TOKEN}"
|
||||
@@ -325,7 +347,7 @@ class TestUserStory3:
|
||||
monkeypatch.delenv("GH_TOKEN", raising=False)
|
||||
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
||||
captured, side_effect = _capture_request_via_urlopen()
|
||||
with patch("specify_cli.urllib.request.urlopen", side_effect=side_effect):
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect):
|
||||
_fetch_latest_release_tag()
|
||||
req = captured["request"]
|
||||
assert req.get_header("Authorization") is None
|
||||
@@ -333,8 +355,9 @@ class TestUserStory3:
|
||||
def test_empty_string_gh_token_treated_as_unset(self, monkeypatch):
|
||||
monkeypatch.setenv("GH_TOKEN", "")
|
||||
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
||||
_inject_github_config(monkeypatch, token_env="GH_TOKEN")
|
||||
captured, side_effect = _capture_request_via_urlopen()
|
||||
with patch("specify_cli.urllib.request.urlopen", side_effect=side_effect):
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect):
|
||||
_fetch_latest_release_tag()
|
||||
req = captured["request"]
|
||||
assert req.get_header("Authorization") is None
|
||||
@@ -342,8 +365,9 @@ class TestUserStory3:
|
||||
def test_whitespace_only_gh_token_treated_as_unset(self, monkeypatch):
|
||||
monkeypatch.setenv("GH_TOKEN", " ")
|
||||
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
||||
_inject_github_config(monkeypatch, token_env="GH_TOKEN")
|
||||
captured, side_effect = _capture_request_via_urlopen()
|
||||
with patch("specify_cli.urllib.request.urlopen", side_effect=side_effect):
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect):
|
||||
_fetch_latest_release_tag()
|
||||
req = captured["request"]
|
||||
assert req.get_header("Authorization") is None
|
||||
@@ -351,8 +375,11 @@ class TestUserStory3:
|
||||
def test_whitespace_only_gh_token_falls_back_to_github_token(self, monkeypatch):
|
||||
monkeypatch.setenv("GH_TOKEN", " ")
|
||||
monkeypatch.setenv("GITHUB_TOKEN", SENTINEL_GITHUB_TOKEN)
|
||||
_inject_github_config(monkeypatch, token_env="GITHUB_TOKEN")
|
||||
captured, side_effect = _capture_request_via_urlopen()
|
||||
with patch("specify_cli.urllib.request.urlopen", side_effect=side_effect):
|
||||
mock_opener = MagicMock()
|
||||
mock_opener.open.side_effect = side_effect
|
||||
with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
|
||||
_fetch_latest_release_tag()
|
||||
req = captured["request"]
|
||||
assert req.get_header("Authorization") == f"Bearer {SENTINEL_GITHUB_TOKEN}"
|
||||
@@ -364,7 +391,7 @@ class TestUserStory3:
|
||||
monkeypatch.setenv("GH_TOKEN", SENTINEL_GH_TOKEN)
|
||||
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
||||
with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch(
|
||||
"specify_cli.urllib.request.urlopen", side_effect=side_effect
|
||||
"specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect
|
||||
):
|
||||
result = runner.invoke(app, ["self", "check"])
|
||||
combined = strip_ansi((result.output or "") + (result.stderr or ""))
|
||||
@@ -377,7 +404,7 @@ class TestUserStory3:
|
||||
monkeypatch.delenv("GH_TOKEN", raising=False)
|
||||
monkeypatch.setenv("GITHUB_TOKEN", SENTINEL_GITHUB_TOKEN)
|
||||
with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch(
|
||||
"specify_cli.urllib.request.urlopen", side_effect=side_effect
|
||||
"specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect
|
||||
):
|
||||
result = runner.invoke(app, ["self", "check"])
|
||||
combined = strip_ansi((result.output or "") + (result.stderr or ""))
|
||||
|
||||
Reference in New Issue
Block a user