devboy_linear/
liveness.rs1use 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}