Skip to main content

devboy_github/
liveness.rs

1//! GitHub `LivenessProbe` impl per [ADR-021] §6.
2//!
3//! Hits `GET /user` with the supplied token. The endpoint
4//! authenticates the caller and returns the public profile, so a
5//! 200 means the token is live and the response carries enough
6//! detail (`login`) for `doctor` to render
7//! "live as alice@github.com".
8//!
9//! Token expiry is reported through the
10//! `github-authentication-token-expiration` response header
11//! (lowercased per RFC 9110 / hyper conventions). When present
12//! it goes into [`LivenessResult::expires_at`] verbatim so the
13//! P9.3 expiry-tracking pass can write it back into the
14//! global index.
15//!
16//! Error mapping:
17//!
18//! | Status | Mapping                            |
19//! |--------|------------------------------------|
20//! | 200    | `Live` (login captured)            |
21//! | 401    | `Revoked`                          |
22//! | 403    | `Revoked` (insufficient-permission case still means the token can't probe) |
23//! | 429    | `Throttled` (`Retry-After` if any)|
24//! | other  | `Error` (status + body)            |
25//!
26//! [ADR-021]: https://github.com/meteora-pro/devboy-tools/blob/main/docs/architecture/adr/ADR-021-external-secret-sources.md
27
28use async_trait::async_trait;
29use devboy_core::{Error, LivenessProbe, LivenessResult, Result, liveness::LivenessStatus};
30use secrecy::{ExposeSecret, SecretString};
31
32use crate::client::GitHubClient;
33
34const TOKEN_EXPIRATION_HEADER: &str = "github-authentication-token-expiration";
35
36#[async_trait]
37impl LivenessProbe for GitHubClient {
38    fn provider_name(&self) -> &str {
39        "github"
40    }
41
42    async fn test(&self, token: &SecretString) -> Result<LivenessResult> {
43        let url = format!("{}/user", self.base_url());
44        let resp = self
45            .http_client()
46            .get(&url)
47            .header("Accept", "application/vnd.github+json")
48            .header("X-GitHub-Api-Version", "2022-11-28")
49            .header("Authorization", format!("Bearer {}", token.expose_secret()))
50            .send()
51            .await
52            .map_err(|e| Error::Http(format!("github liveness GET /user: {e}")))?;
53
54        let expires_at = resp
55            .headers()
56            .get(TOKEN_EXPIRATION_HEADER)
57            .and_then(|v| v.to_str().ok())
58            .map(str::trim)
59            .filter(|s| !s.is_empty())
60            .map(str::to_owned);
61
62        let status = resp.status();
63        match status.as_u16() {
64            200 => {
65                #[derive(serde::Deserialize)]
66                struct User {
67                    login: Option<String>,
68                }
69                let body: User = resp.json().await.unwrap_or(User { login: None });
70                let detail = body.login.unwrap_or_else(|| "github user".to_owned());
71                Ok(LivenessResult {
72                    status: LivenessStatus::Live,
73                    detail: Some(detail),
74                    expires_at,
75                })
76            }
77            401 => Ok(LivenessResult::revoked(
78                "github rejected the token (401 Unauthorized)",
79            )),
80            403 => Ok(LivenessResult::revoked(
81                "github refused the token (403 Forbidden) — revoked or insufficient scope",
82            )),
83            429 => {
84                let retry = resp
85                    .headers()
86                    .get("retry-after")
87                    .and_then(|v| v.to_str().ok())
88                    .map(str::to_owned)
89                    .unwrap_or_else(|| "unknown".to_owned());
90                Ok(LivenessResult::throttled(format!(
91                    "github rate limit exceeded; retry-after: {retry}"
92                )))
93            }
94            other => {
95                let body = resp.text().await.unwrap_or_default();
96                Ok(LivenessResult::error(format!(
97                    "github GET /user returned {other}: {}",
98                    body.trim()
99                )))
100            }
101        }
102    }
103}
104
105// =============================================================================
106// Tests
107// =============================================================================
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112    use httpmock::prelude::*;
113
114    fn client_against(base_url: &str) -> GitHubClient {
115        GitHubClient::with_base_url(
116            base_url,
117            "owner",
118            "repo",
119            SecretString::from("ignored-self-token".to_owned()),
120        )
121    }
122
123    #[tokio::test]
124    async fn live_token_returns_login_and_optional_expiry() {
125        let server = MockServer::start_async().await;
126        let _m = server
127            .mock_async(|when, then| {
128                when.method(GET)
129                    .path("/user")
130                    .header("Authorization", "Bearer ghp_fixture");
131                then.status(200)
132                    .header(
133                        "github-authentication-token-expiration",
134                        "2026-08-01 00:00:00 UTC",
135                    )
136                    .json_body(serde_json::json!({"login": "octocat"}));
137            })
138            .await;
139
140        let client = client_against(&server.base_url());
141        let r = client
142            .test(&SecretString::from("ghp_fixture".to_owned()))
143            .await
144            .unwrap();
145        assert_eq!(r.status, LivenessStatus::Live);
146        assert_eq!(r.detail.as_deref(), Some("octocat"));
147        assert_eq!(
148            r.expires_at.as_deref(),
149            Some("2026-08-01 00:00:00 UTC"),
150            "expiry header must be plumbed through to result"
151        );
152    }
153
154    #[tokio::test]
155    async fn revoked_on_401() {
156        let server = MockServer::start_async().await;
157        let _m = server
158            .mock_async(|when, then| {
159                when.method(GET).path("/user");
160                then.status(401)
161                    .json_body(serde_json::json!({"message": "Bad credentials"}));
162            })
163            .await;
164        let client = client_against(&server.base_url());
165        let r = client
166            .test(&SecretString::from("bad".to_owned()))
167            .await
168            .unwrap();
169        assert_eq!(r.status, LivenessStatus::Revoked);
170    }
171
172    #[tokio::test]
173    async fn revoked_on_403() {
174        let server = MockServer::start_async().await;
175        let _m = server
176            .mock_async(|when, then| {
177                when.method(GET).path("/user");
178                then.status(403);
179            })
180            .await;
181        let client = client_against(&server.base_url());
182        let r = client
183            .test(&SecretString::from("revoked".to_owned()))
184            .await
185            .unwrap();
186        assert_eq!(r.status, LivenessStatus::Revoked);
187    }
188
189    #[tokio::test]
190    async fn throttled_on_429_carries_retry_after() {
191        let server = MockServer::start_async().await;
192        let _m = server
193            .mock_async(|when, then| {
194                when.method(GET).path("/user");
195                then.status(429).header("Retry-After", "60");
196            })
197            .await;
198        let client = client_against(&server.base_url());
199        let r = client
200            .test(&SecretString::from("over-limit".to_owned()))
201            .await
202            .unwrap();
203        assert_eq!(r.status, LivenessStatus::Throttled);
204        assert!(r.detail.unwrap().contains("60"));
205    }
206
207    #[tokio::test]
208    async fn unexpected_status_classified_as_error() {
209        let server = MockServer::start_async().await;
210        let _m = server
211            .mock_async(|when, then| {
212                when.method(GET).path("/user");
213                then.status(503).body("upstream down");
214            })
215            .await;
216        let client = client_against(&server.base_url());
217        let r = client
218            .test(&SecretString::from("any".to_owned()))
219            .await
220            .unwrap();
221        assert_eq!(r.status, LivenessStatus::Error);
222    }
223
224    #[test]
225    fn provider_name_is_github() {
226        let client = GitHubClient::new("owner", "repo", SecretString::from("any".to_owned()));
227        assert_eq!(client.provider_name(), "github");
228    }
229}