Skip to main content

omne_cli/
github.rs

1//! GitHub Releases API client.
2//!
3//! `GithubClient` fetches release metadata and streams asset downloads
4//! via sync ureq + rustls with platform certificate verification. Supports
5//! `GITHUB_TOKEN` / `GH_TOKEN` auth with pinned precedence (GITHUB_TOKEN wins).
6//!
7//! The client is configured to strip the `Authorization` header on any
8//! cross-host redirect (e.g. `api.github.com` → `objects.githubusercontent.com`)
9//! while preserving it on same-host redirects.
10
11// All public types and methods in this module are consumed by command
12// handlers landing in Units 8b and 9. Suppress dead-code warnings until
13// those call sites exist.
14#![allow(dead_code)]
15
16use std::io::Read;
17use std::time::{Duration, SystemTime};
18
19use serde::Deserialize;
20use thiserror::Error;
21use ureq::{http, Agent, Body};
22
23/// Errors returned by GitHub API operations.
24///
25/// Each variant is self-contained — no references to ureq internals or
26/// request headers leak through `Display`. The `Http` variant is sanitized:
27/// it carries only `{ method, url, status }`, never headers or body content.
28#[derive(Debug, Error)]
29pub enum Error {
30    /// HTTP 403 with `x-ratelimit-remaining: 0`.
31    /// `reset_at` is parsed from the `x-ratelimit-reset` epoch-seconds header.
32    #[error("{}", format_rate_limit_message(reset_at))]
33    RateLimited { reset_at: Option<SystemTime> },
34
35    /// HTTP 403 without rate-limit headers — likely a permissions issue.
36    #[error("GitHub API returned 403 Forbidden — check your GITHUB_TOKEN permissions")]
37    AuthFailed,
38
39    /// No releases found for the given org/repo (empty list or 404).
40    #[error("no releases found for {org}/{repo}")]
41    NoReleaseFound { org: String, repo: String },
42
43    /// The release exists but has no `.tar.gz` asset.
44    #[error("no .tar.gz asset found for {org}/{repo} tag {tag}")]
45    NoTarballAsset {
46        org: String,
47        repo: String,
48        tag: String,
49    },
50
51    /// Sanitized HTTP error — carries only method, URL, and status code.
52    /// Never includes request headers, request body, or response body.
53    #[error("HTTP error: {method} {url} → {}", status.map_or("connection failed".to_string(), |s| format!("{s}")))]
54    Http {
55        method: String,
56        url: String,
57        status: Option<u16>,
58    },
59}
60
61fn format_rate_limit_message(reset_at: &Option<SystemTime>) -> String {
62    match reset_at {
63        Some(t) => {
64            let now = SystemTime::now();
65            let remaining = t.duration_since(now).unwrap_or(Duration::ZERO);
66            let mins = remaining.as_secs() / 60;
67            format!(
68                "GitHub API rate limit exceeded; resets in {mins} minute(s). \
69                 Set GITHUB_TOKEN to raise the limit to 5000/hour"
70            )
71        }
72        None => "GitHub API rate limit exceeded. \
73                 Set GITHUB_TOKEN to raise the limit to 5000/hour"
74            .to_string(),
75    }
76}
77
78/// A release entry from the GitHub Releases API.
79#[derive(Debug, Deserialize)]
80struct Release {
81    tag_name: String,
82    draft: bool,
83    assets: Vec<Asset>,
84}
85
86/// A single asset within a release.
87#[derive(Debug, Deserialize)]
88struct Asset {
89    name: String,
90    browser_download_url: String,
91    size: u64,
92}
93
94/// Sync GitHub Releases API client.
95///
96/// Constructed with an explicit base URL (for test injection via mockito)
97/// and an optional bearer token. Does **not** read environment variables
98/// in the constructor — use `from_env()` for that.
99pub struct GithubClient {
100    agent: Agent,
101    base_url: String,
102    user_agent: String,
103    token: Option<String>,
104}
105
106impl GithubClient {
107    /// Create a client with an explicit token (or `None` for unauthenticated).
108    ///
109    /// Tests use this constructor directly to avoid touching process-global
110    /// env state.
111    pub fn new(base_url: &str, user_agent: &str, token: Option<String>) -> Self {
112        use ureq::config::RedirectAuthHeaders;
113        use ureq::tls::{RootCerts, TlsConfig};
114
115        let agent = Agent::config_builder()
116            .tls_config(
117                TlsConfig::builder()
118                    .root_certs(RootCerts::PlatformVerifier)
119                    .build(),
120            )
121            .redirect_auth_headers(RedirectAuthHeaders::SameHost)
122            .http_status_as_error(false)
123            .build()
124            .new_agent();
125
126        Self {
127            agent,
128            base_url: base_url.trim_end_matches('/').to_string(),
129            user_agent: user_agent.to_string(),
130            token,
131        }
132    }
133
134    /// Create a client reading auth from the environment.
135    ///
136    /// Precedence: `GITHUB_TOKEN` wins when both are set, matching `gh` CLI's
137    /// default non-enterprise behavior.
138    pub fn from_env(base_url: &str, user_agent: &str) -> Self {
139        let token = std::env::var("GITHUB_TOKEN")
140            .ok()
141            .or_else(|| std::env::var("GH_TOKEN").ok());
142        Self::new(base_url, user_agent, token)
143    }
144
145    /// Fetch the latest (most recently published, non-draft) release tag.
146    ///
147    /// Uses the list endpoint (`/repos/{org}/{repo}/releases`) rather than
148    /// `/releases/latest` so that pre-release tags are visible.
149    pub fn latest_release_tag(&self, org: &str, repo: &str) -> Result<String, Error> {
150        let url = format!("{}/repos/{}/{}/releases", self.base_url, org, repo);
151        let mut response = self.get(&url)?;
152        let status = response.status().as_u16();
153
154        if status == 403 {
155            return Err(classify_forbidden(&response));
156        }
157        if status == 404 || status == 410 {
158            return Err(Error::NoReleaseFound {
159                org: org.to_string(),
160                repo: repo.to_string(),
161            });
162        }
163        if status == 429 {
164            return Err(Error::RateLimited {
165                reset_at: parse_reset_header(&response),
166            });
167        }
168        if !is_success(status) {
169            return Err(Error::Http {
170                method: "GET".to_string(),
171                url,
172                status: Some(status),
173            });
174        }
175
176        let releases: Vec<Release> = response.body_mut().read_json().map_err(|_| Error::Http {
177            method: "GET".to_string(),
178            url: url.clone(),
179            status: None,
180        })?;
181
182        releases
183            .into_iter()
184            .find(|r| !r.draft)
185            .map(|r| r.tag_name)
186            .ok_or_else(|| Error::NoReleaseFound {
187                org: org.to_string(),
188                repo: repo.to_string(),
189            })
190    }
191
192    /// Stream the first `.tar.gz` asset for a given release tag.
193    ///
194    /// Returns `(content_length, reader)`. The reader is the raw HTTP body;
195    /// the caller wraps it in a progress bar and hands it to `tarball::extract_safe`.
196    pub fn release_asset_body(
197        &self,
198        org: &str,
199        repo: &str,
200        tag: &str,
201    ) -> Result<(u64, Box<dyn Read + Send>), Error> {
202        let url = format!("{}/repos/{}/{}/releases", self.base_url, org, repo);
203        let mut response = self.get(&url)?;
204        let status = response.status().as_u16();
205
206        if status == 403 {
207            return Err(classify_forbidden(&response));
208        }
209        if status == 404 || status == 410 {
210            return Err(Error::NoReleaseFound {
211                org: org.to_string(),
212                repo: repo.to_string(),
213            });
214        }
215        if status == 429 {
216            return Err(Error::RateLimited {
217                reset_at: parse_reset_header(&response),
218            });
219        }
220        if !is_success(status) {
221            return Err(Error::Http {
222                method: "GET".to_string(),
223                url,
224                status: Some(status),
225            });
226        }
227
228        let releases: Vec<Release> = response.body_mut().read_json().map_err(|_| Error::Http {
229            method: "GET".to_string(),
230            url: url.clone(),
231            status: None,
232        })?;
233
234        let release = releases
235            .into_iter()
236            .find(|r| r.tag_name == tag)
237            .ok_or_else(|| Error::NoReleaseFound {
238                org: org.to_string(),
239                repo: repo.to_string(),
240            })?;
241
242        let asset = release
243            .assets
244            .into_iter()
245            .find(|a| a.name.ends_with(".tar.gz"))
246            .ok_or_else(|| Error::NoTarballAsset {
247                org: org.to_string(),
248                repo: repo.to_string(),
249                tag: tag.to_string(),
250            })?;
251
252        let content_length = asset.size;
253
254        // Download the asset — follows redirects (api.github.com → objects.githubusercontent.com).
255        // The SameHost redirect policy strips Authorization on the cross-host hop.
256        let asset_response = self.get(&asset.browser_download_url)?;
257        let asset_status = asset_response.status().as_u16();
258
259        if !is_success(asset_status) {
260            return Err(Error::Http {
261                method: "GET".to_string(),
262                url: asset.browser_download_url,
263                status: Some(asset_status),
264            });
265        }
266
267        let reader = asset_response.into_body().into_reader();
268        Ok((content_length, Box::new(reader)))
269    }
270
271    /// Build and send a GET request with standard GitHub API headers.
272    fn get(&self, url: &str) -> Result<http::Response<Body>, Error> {
273        let mut req = self
274            .agent
275            .get(url)
276            .header("Accept", "application/vnd.github+json")
277            .header("User-Agent", &self.user_agent)
278            .header("X-GitHub-Api-Version", "2022-11-28");
279
280        if let Some(ref token) = self.token {
281            req = req.header("Authorization", &format!("Bearer {token}"));
282        }
283
284        req.call().map_err(|e| sanitize_ureq_error("GET", url, e))
285    }
286}
287
288/// Classify a 403 response as rate-limit or auth failure.
289fn classify_forbidden(response: &http::Response<Body>) -> Error {
290    let remaining = response
291        .headers()
292        .get("x-ratelimit-remaining")
293        .and_then(|v| v.to_str().ok());
294    match remaining {
295        Some("0") => Error::RateLimited {
296            reset_at: parse_reset_header(response),
297        },
298        _ => Error::AuthFailed,
299    }
300}
301
302/// Parse the `x-ratelimit-reset` header into a `SystemTime`.
303fn parse_reset_header(response: &http::Response<Body>) -> Option<SystemTime> {
304    response
305        .headers()
306        .get("x-ratelimit-reset")
307        .and_then(|v| v.to_str().ok())
308        .and_then(|v| v.parse::<u64>().ok())
309        .map(|secs| SystemTime::UNIX_EPOCH + Duration::from_secs(secs))
310}
311
312/// Strip ureq error internals down to method + URL + optional status code.
313/// Never leaks request headers, request body, or response body.
314fn sanitize_ureq_error(method: &str, url: &str, _err: ureq::Error) -> Error {
315    Error::Http {
316        method: method.to_string(),
317        url: url.to_string(),
318        status: None,
319    }
320}
321
322fn is_success(status: u16) -> bool {
323    (200..300).contains(&status)
324}
325
326#[cfg(test)]
327mod tests {
328    use super::*;
329
330    // ── Error Display tests ──────────────────────────────────────────
331
332    #[test]
333    fn token_not_leaked_in_http_error_display() {
334        let sentinel = "sentinel_value_please_do_not_leak";
335        let err = Error::Http {
336            method: "GET".to_string(),
337            url: "https://api.github.com/repos/org/repo/releases".to_string(),
338            status: None,
339        };
340        let display = format!("{err}");
341        assert!(
342            !display.contains(sentinel),
343            "Error Display must never contain the token value"
344        );
345    }
346
347    #[test]
348    fn rate_limited_error_mentions_github_token() {
349        let err = Error::RateLimited { reset_at: None };
350        let display = format!("{err}");
351        assert!(
352            display.contains("GITHUB_TOKEN"),
353            "Rate limit error should suggest GITHUB_TOKEN"
354        );
355    }
356
357    #[test]
358    fn rate_limited_with_reset_time_formats_minutes() {
359        let reset_at = SystemTime::now() + Duration::from_secs(300);
360        let err = Error::RateLimited {
361            reset_at: Some(reset_at),
362        };
363        let display = format!("{err}");
364        assert!(
365            display.contains("minute"),
366            "Should mention minutes: {display}"
367        );
368        assert!(
369            display.contains("GITHUB_TOKEN"),
370            "Should suggest GITHUB_TOKEN"
371        );
372    }
373
374    #[test]
375    fn auth_failed_error_mentions_permissions() {
376        let err = Error::AuthFailed;
377        let display = format!("{err}");
378        assert!(display.contains("403"), "Should mention 403 status");
379    }
380
381    #[test]
382    fn no_release_found_names_org_and_repo() {
383        let err = Error::NoReleaseFound {
384            org: "myorg".to_string(),
385            repo: "myrepo".to_string(),
386        };
387        let display = format!("{err}");
388        assert!(display.contains("myorg/myrepo"));
389    }
390
391    #[test]
392    fn no_tarball_asset_names_tag() {
393        let err = Error::NoTarballAsset {
394            org: "o".to_string(),
395            repo: "r".to_string(),
396            tag: "v1.0.0".to_string(),
397        };
398        let display = format!("{err}");
399        assert!(display.contains("v1.0.0"));
400    }
401
402    // ── Mockito-based client tests ───────────────────────────────────
403
404    /// Standard fake releases JSON for two releases (one published, one draft).
405    fn fake_releases_json(server_url: &str) -> String {
406        serde_json::json!([
407            {
408                "tag_name": "v0.2.0",
409                "draft": false,
410                "assets": [{
411                    "name": "omne-v0.2.0-x86_64-unknown-linux-gnu.tar.gz",
412                    "browser_download_url": format!("{server_url}/download/v0.2.0/omne.tar.gz"),
413                    "size": 1024
414                }]
415            },
416            {
417                "tag_name": "v0.1.0",
418                "draft": false,
419                "assets": [{
420                    "name": "omne-v0.1.0-x86_64-unknown-linux-gnu.tar.gz",
421                    "browser_download_url": format!("{server_url}/download/v0.1.0/omne.tar.gz"),
422                    "size": 512
423                }]
424            }
425        ])
426        .to_string()
427    }
428
429    // ── latest_release_tag ───────────────────────────────────────────
430
431    #[test]
432    fn latest_release_tag_returns_first_non_draft() {
433        let mut server = mockito::Server::new();
434        let body = fake_releases_json(&server.url());
435
436        let _m = server
437            .mock("GET", "/repos/omne-org/omne/releases")
438            .with_status(200)
439            .with_header("content-type", "application/json")
440            .with_body(&body)
441            .create();
442
443        let client = GithubClient::new(&server.url(), "test/1.0", None);
444        let tag = client.latest_release_tag("omne-org", "omne").unwrap();
445        assert_eq!(tag, "v0.2.0");
446    }
447
448    #[test]
449    fn latest_release_tag_skips_drafts() {
450        let mut server = mockito::Server::new();
451        let body = serde_json::json!([
452            { "tag_name": "v0.3.0-draft", "draft": true, "assets": [] },
453            { "tag_name": "v0.2.0", "draft": false, "assets": [] }
454        ])
455        .to_string();
456
457        let _m = server
458            .mock("GET", "/repos/org/repo/releases")
459            .with_status(200)
460            .with_header("content-type", "application/json")
461            .with_body(&body)
462            .create();
463
464        let client = GithubClient::new(&server.url(), "test/1.0", None);
465        let tag = client.latest_release_tag("org", "repo").unwrap();
466        assert_eq!(tag, "v0.2.0");
467    }
468
469    #[test]
470    fn latest_release_tag_empty_list_returns_no_release_found() {
471        let mut server = mockito::Server::new();
472        let _m = server
473            .mock("GET", "/repos/org/repo/releases")
474            .with_status(200)
475            .with_header("content-type", "application/json")
476            .with_body("[]")
477            .create();
478
479        let client = GithubClient::new(&server.url(), "test/1.0", None);
480        let err = client.latest_release_tag("org", "repo").unwrap_err();
481        assert!(
482            matches!(err, Error::NoReleaseFound { ref org, ref repo } if org == "org" && repo == "repo"),
483            "Expected NoReleaseFound, got: {err:?}"
484        );
485    }
486
487    #[test]
488    fn latest_release_tag_404_returns_no_release_found() {
489        let mut server = mockito::Server::new();
490        let _m = server
491            .mock("GET", "/repos/org/missing/releases")
492            .with_status(404)
493            .with_body("{\"message\": \"Not Found\"}")
494            .create();
495
496        let client = GithubClient::new(&server.url(), "test/1.0", None);
497        let err = client.latest_release_tag("org", "missing").unwrap_err();
498        assert!(matches!(err, Error::NoReleaseFound { .. }));
499    }
500
501    // ── Rate limiting ────────────────────────────────────────────────
502
503    #[test]
504    fn rate_limit_403_with_headers_returns_rate_limited() {
505        let mut server = mockito::Server::new();
506        let reset_epoch = SystemTime::now()
507            .duration_since(SystemTime::UNIX_EPOCH)
508            .unwrap()
509            .as_secs()
510            + 600;
511
512        let _m = server
513            .mock("GET", "/repos/org/repo/releases")
514            .with_status(403)
515            .with_header("x-ratelimit-remaining", "0")
516            .with_header("x-ratelimit-reset", &reset_epoch.to_string())
517            .with_body("{\"message\": \"API rate limit exceeded\"}")
518            .create();
519
520        let client = GithubClient::new(&server.url(), "test/1.0", None);
521        let err = client.latest_release_tag("org", "repo").unwrap_err();
522        match err {
523            Error::RateLimited { reset_at } => {
524                assert!(reset_at.is_some(), "Should parse reset_at from header");
525                let display = format!("{err}");
526                assert!(display.contains("GITHUB_TOKEN"));
527                assert!(display.contains("minute"));
528            }
529            other => panic!("Expected RateLimited, got: {other:?}"),
530        }
531    }
532
533    #[test]
534    fn auth_failure_403_without_rate_limit_headers() {
535        let mut server = mockito::Server::new();
536        let _m = server
537            .mock("GET", "/repos/org/repo/releases")
538            .with_status(403)
539            .with_body("{\"message\": \"Bad credentials\"}")
540            .create();
541
542        let client = GithubClient::new(&server.url(), "test/1.0", Some("bad-token".into()));
543        let err = client.latest_release_tag("org", "repo").unwrap_err();
544        assert!(
545            matches!(err, Error::AuthFailed),
546            "Expected AuthFailed, got: {err:?}"
547        );
548    }
549
550    #[test]
551    fn rate_limit_429_returns_rate_limited() {
552        let mut server = mockito::Server::new();
553        let _m = server
554            .mock("GET", "/repos/org/repo/releases")
555            .with_status(429)
556            .with_header("retry-after", "60")
557            .with_body("{\"message\": \"Too Many Requests\"}")
558            .create();
559
560        let client = GithubClient::new(&server.url(), "test/1.0", None);
561        let err = client.latest_release_tag("org", "repo").unwrap_err();
562        assert!(
563            matches!(err, Error::RateLimited { .. }),
564            "Expected RateLimited, got: {err:?}"
565        );
566    }
567
568    // ── Token handling ───────────────────────────────────────────────
569
570    #[test]
571    fn client_with_token_sends_authorization_header() {
572        let mut server = mockito::Server::new();
573        let body = fake_releases_json(&server.url());
574
575        let _m = server
576            .mock("GET", "/repos/org/repo/releases")
577            .match_header("Authorization", "Bearer test-token-123")
578            .with_status(200)
579            .with_header("content-type", "application/json")
580            .with_body(&body)
581            .expect(1)
582            .create();
583
584        let client = GithubClient::new(&server.url(), "test/1.0", Some("test-token-123".into()));
585        let tag = client.latest_release_tag("org", "repo").unwrap();
586        assert_eq!(tag, "v0.2.0");
587        // mockito's expect(1) asserts the mock was hit exactly once with the
588        // matched Authorization header.
589    }
590
591    #[test]
592    fn client_without_token_sends_no_authorization_header() {
593        let mut server = mockito::Server::new();
594        let body = fake_releases_json(&server.url());
595
596        let _m = server
597            .mock("GET", "/repos/org/repo/releases")
598            .match_header("Authorization", mockito::Matcher::Missing)
599            .with_status(200)
600            .with_header("content-type", "application/json")
601            .with_body(&body)
602            .expect(1)
603            .create();
604
605        let client = GithubClient::new(&server.url(), "test/1.0", None);
606        let tag = client.latest_release_tag("org", "repo").unwrap();
607        assert_eq!(tag, "v0.2.0");
608    }
609
610    // ── Token sanitization ───────────────────────────────────────────
611
612    #[test]
613    fn token_not_leaked_on_network_error() {
614        // Point at a port that nothing is listening on.
615        let client = GithubClient::new(
616            "http://127.0.0.1:1",
617            "test/1.0",
618            Some("sentinel_value_please_do_not_leak".into()),
619        );
620        let err = client.latest_release_tag("org", "repo").unwrap_err();
621        let display = format!("{err}");
622        assert!(
623            !display.contains("sentinel_value_please_do_not_leak"),
624            "Error must not leak token: {display}"
625        );
626    }
627
628    // ── release_asset_body ───────────────────────────────────────────
629
630    #[test]
631    fn release_asset_body_returns_reader_and_size() {
632        let mut server = mockito::Server::new();
633        let tarball_bytes = b"fake-tarball-content";
634        let body = serde_json::json!([{
635            "tag_name": "v1.0.0",
636            "draft": false,
637            "assets": [{
638                "name": "omne-v1.0.0.tar.gz",
639                "browser_download_url": format!("{}/download/v1.0.0/omne.tar.gz", server.url()),
640                "size": tarball_bytes.len()
641            }]
642        }])
643        .to_string();
644
645        let _m_releases = server
646            .mock("GET", "/repos/org/repo/releases")
647            .with_status(200)
648            .with_header("content-type", "application/json")
649            .with_body(&body)
650            .create();
651
652        let _m_download = server
653            .mock("GET", "/download/v1.0.0/omne.tar.gz")
654            .with_status(200)
655            .with_body(tarball_bytes)
656            .create();
657
658        let client = GithubClient::new(&server.url(), "test/1.0", None);
659        let (size, mut reader) = client.release_asset_body("org", "repo", "v1.0.0").unwrap();
660
661        assert_eq!(size, tarball_bytes.len() as u64);
662
663        let mut buf = Vec::new();
664        reader.read_to_end(&mut buf).unwrap();
665        assert_eq!(buf, tarball_bytes);
666    }
667
668    #[test]
669    fn release_asset_body_no_tarball_asset() {
670        let mut server = mockito::Server::new();
671        let body = serde_json::json!([{
672            "tag_name": "v1.0.0",
673            "draft": false,
674            "assets": [{
675                "name": "omne-v1.0.0.zip",
676                "browser_download_url": format!("{}/download/omne.zip", server.url()),
677                "size": 100
678            }]
679        }])
680        .to_string();
681
682        let _m = server
683            .mock("GET", "/repos/org/repo/releases")
684            .with_status(200)
685            .with_header("content-type", "application/json")
686            .with_body(&body)
687            .create();
688
689        let client = GithubClient::new(&server.url(), "test/1.0", None);
690        let result = client.release_asset_body("org", "repo", "v1.0.0");
691        let err = result.err().expect("expected error");
692        assert!(
693            matches!(err, Error::NoTarballAsset { ref tag, .. } if tag == "v1.0.0"),
694            "Expected NoTarballAsset, got: {err:?}"
695        );
696    }
697
698    #[test]
699    fn release_asset_body_tag_not_found() {
700        let mut server = mockito::Server::new();
701        let body = serde_json::json!([{
702            "tag_name": "v2.0.0",
703            "draft": false,
704            "assets": []
705        }])
706        .to_string();
707
708        let _m = server
709            .mock("GET", "/repos/org/repo/releases")
710            .with_status(200)
711            .with_header("content-type", "application/json")
712            .with_body(&body)
713            .create();
714
715        let client = GithubClient::new(&server.url(), "test/1.0", None);
716        let result = client.release_asset_body("org", "repo", "v1.0.0");
717        let err = result.err().expect("expected error");
718        assert!(
719            matches!(err, Error::NoReleaseFound { .. }),
720            "Expected NoReleaseFound, got: {err:?}"
721        );
722    }
723
724    #[test]
725    fn release_asset_body_404_returns_no_release_found() {
726        let mut server = mockito::Server::new();
727
728        let _m = server
729            .mock("GET", "/repos/org/repo/releases")
730            .with_status(404)
731            .create();
732
733        let client = GithubClient::new(&server.url(), "test/1.0", None);
734        let result = client.release_asset_body("org", "repo", "v1.0.0");
735        let err = result.err().expect("expected error");
736        assert!(
737            matches!(err, Error::NoReleaseFound { .. }),
738            "Expected NoReleaseFound on 404, got: {err:?}"
739        );
740    }
741
742    #[test]
743    fn release_asset_body_429_returns_rate_limited() {
744        let mut server = mockito::Server::new();
745
746        let _m = server
747            .mock("GET", "/repos/org/repo/releases")
748            .with_status(429)
749            .with_header("retry-after", "60")
750            .create();
751
752        let client = GithubClient::new(&server.url(), "test/1.0", None);
753        let result = client.release_asset_body("org", "repo", "v1.0.0");
754        let err = result.err().expect("expected error");
755        assert!(
756            matches!(err, Error::RateLimited { .. }),
757            "Expected RateLimited on 429, got: {err:?}"
758        );
759    }
760
761    // ── GitHub API headers ───────────────────────────────────────────
762
763    #[test]
764    fn client_sends_required_github_api_headers() {
765        let mut server = mockito::Server::new();
766        let body = fake_releases_json(&server.url());
767
768        let _m = server
769            .mock("GET", "/repos/org/repo/releases")
770            .match_header("Accept", "application/vnd.github+json")
771            .match_header("X-GitHub-Api-Version", "2022-11-28")
772            .match_header("User-Agent", "omne-cli/test")
773            .with_status(200)
774            .with_header("content-type", "application/json")
775            .with_body(&body)
776            .expect(1)
777            .create();
778
779        let client = GithubClient::new(&server.url(), "omne-cli/test", None);
780        client.latest_release_tag("org", "repo").unwrap();
781    }
782
783    // ── from_env constructor ─────────────────────────────────────────
784
785    #[test]
786    fn from_env_prefers_github_token_over_gh_token() {
787        // This test validates the from_env logic by testing the constructor
788        // with explicit tokens (avoiding process-global env mutation).
789        // The precedence logic is: GITHUB_TOKEN wins when both are set.
790        //
791        // We verify this structurally: from_env reads GITHUB_TOKEN first
792        // via ok().or_else(GH_TOKEN). We test the actual header sent
793        // using the explicit new() constructor.
794        let mut server = mockito::Server::new();
795        let body = fake_releases_json(&server.url());
796
797        // Simulate the precedence: GITHUB_TOKEN="X" wins over GH_TOKEN="Y"
798        let _m = server
799            .mock("GET", "/repos/org/repo/releases")
800            .match_header("Authorization", "Bearer X")
801            .with_status(200)
802            .with_header("content-type", "application/json")
803            .with_body(&body)
804            .expect(1)
805            .create();
806
807        // This mirrors what from_env does when GITHUB_TOKEN=X and GH_TOKEN=Y
808        let client = GithubClient::new(&server.url(), "test/1.0", Some("X".into()));
809        client.latest_release_tag("org", "repo").unwrap();
810    }
811
812    // ── Live smoke test (ignored by default) ─────────────────────────
813
814    #[test]
815    #[ignore = "hits real api.github.com — run manually with `cargo test -- --ignored`"]
816    fn live_smoke_test_latest_release() {
817        let client = GithubClient::from_env("https://api.github.com", "omne-cli/test");
818        let tag = client.latest_release_tag("omne-org", "omne").unwrap();
819        assert!(
820            tag.starts_with('v'),
821            "Expected tag starting with 'v', got: {tag}"
822        );
823    }
824}