Skip to main content

devboy_linear/
liveness.rs

1//! Linear liveness probe.
2
3use async_trait::async_trait;
4use devboy_core::{LivenessProbe, LivenessResult, liveness::LivenessStatus};
5use secrecy::SecretString;
6
7use crate::client::LinearClient;
8
9#[async_trait]
10impl LivenessProbe for LinearClient {
11    async fn test(&self, token: &SecretString) -> devboy_core::Result<LivenessResult> {
12        match self.viewer_with_token(token).await {
13            Ok(viewer) => Ok(LivenessResult::live(format!(
14                "{} ({})",
15                viewer.name,
16                viewer.email.unwrap_or_else(|| "no email".to_string())
17            ))),
18            Err(devboy_core::Error::Unauthorized(_)) => {
19                Ok(LivenessResult::revoked("Linear API rejected the token"))
20            }
21            Err(devboy_core::Error::RateLimited { retry_after }) => Ok(LivenessResult::throttled(
22                format!("retry_after={}", retry_after.unwrap_or(0)),
23            )),
24            Err(err) => Ok(LivenessResult {
25                status: LivenessStatus::Error,
26                detail: Some(err.to_string()),
27                expires_at: None,
28            }),
29        }
30    }
31
32    fn provider_name(&self) -> &str {
33        "linear"
34    }
35}
36
37#[cfg(test)]
38mod tests {
39    use super::*;
40    use httpmock::Method::POST;
41    use httpmock::MockServer;
42
43    #[tokio::test]
44    async fn probe_returns_live_for_valid_token() {
45        let server = MockServer::start();
46        let mock = server.mock(|when, then| {
47            when.method(POST).path("/graphql");
48            then.status(200)
49                .header("content-type", "application/json")
50                .body(r#"{"data":{"viewer":{"id":"u1","name":"Alice","displayName":"alice","email":"alice@example.com"}}}"#);
51        });
52
53        let client = LinearClient::with_base_url(
54            format!("{}/graphql", server.base_url()),
55            "team-1",
56            SecretString::from("lin_api_live".to_owned()),
57        );
58
59        let result = client
60            .test(&SecretString::from("lin_api_live".to_owned()))
61            .await
62            .unwrap();
63        assert_eq!(result.status, LivenessStatus::Live);
64        mock.assert();
65    }
66
67    #[tokio::test]
68    async fn probe_returns_revoked_for_unauthorized_token() {
69        let server = MockServer::start();
70        let mock = server.mock(|when, then| {
71            when.method(POST).path("/graphql");
72            then.status(401).header("content-type", "application/json");
73        });
74
75        let client = LinearClient::with_base_url(
76            format!("{}/graphql", server.base_url()),
77            "team-1",
78            SecretString::from("lin_api_revoked".to_owned()),
79        );
80
81        let result = client
82            .test(&SecretString::from("lin_api_revoked".to_owned()))
83            .await
84            .unwrap();
85        assert_eq!(result.status, LivenessStatus::Revoked);
86        assert_eq!(client.provider_name(), "linear");
87        mock.assert();
88    }
89
90    #[tokio::test]
91    async fn probe_returns_throttled_and_error_states() {
92        let rate_limited = MockServer::start();
93        let rate_limited_mock = rate_limited.mock(|when, then| {
94            when.method(POST).path("/graphql");
95            then.status(429).header("content-type", "application/json");
96        });
97        let throttled_client = LinearClient::with_base_url(
98            format!("{}/graphql", rate_limited.base_url()),
99            "team-1",
100            SecretString::from("lin_api_throttled".to_owned()),
101        );
102
103        let throttled = throttled_client
104            .test(&SecretString::from("lin_api_throttled".to_owned()))
105            .await
106            .unwrap();
107        assert_eq!(throttled.status, LivenessStatus::Throttled);
108        assert_eq!(throttled.detail.as_deref(), Some("retry_after=0"));
109        rate_limited_mock.assert();
110
111        let error_server = MockServer::start();
112        let error_mock = error_server.mock(|when, then| {
113            when.method(POST).path("/graphql");
114            then.status(500)
115                .header("content-type", "application/json")
116                .body("boom");
117        });
118        let error_client = LinearClient::with_base_url(
119            format!("{}/graphql", error_server.base_url()),
120            "team-1",
121            SecretString::from("lin_api_error".to_owned()),
122        );
123
124        let error = error_client
125            .test(&SecretString::from("lin_api_error".to_owned()))
126            .await
127            .unwrap();
128        assert_eq!(error.status, LivenessStatus::Error);
129        assert!(error.detail.as_deref().unwrap().contains("boom"));
130        error_mock.assert();
131    }
132}