1use async_trait::async_trait;
17use devboy_core::{Error, LivenessProbe, LivenessResult, Result};
18use secrecy::{ExposeSecret, SecretString};
19use serde::Deserialize;
20
21use crate::client::{ConfluenceAuth, ConfluenceClient, ConfluenceFlavor};
22
23#[derive(Debug, Deserialize)]
24struct ConfluenceCurrentUser {
25 #[serde(default, rename = "username")]
26 username: Option<String>,
27 #[serde(default, rename = "displayName")]
28 display_name: Option<String>,
29 #[serde(default, rename = "accountId")]
30 account_id: Option<String>,
31 #[serde(default, rename = "email")]
32 email: Option<String>,
33}
34
35#[derive(Debug, Deserialize)]
36struct AtlassianAccessibleResource {
37 id: String,
38 #[serde(default)]
39 url: Option<String>,
40}
41
42#[async_trait]
43impl LivenessProbe for ConfluenceClient {
44 fn provider_name(&self) -> &str {
45 "confluence"
46 }
47
48 async fn test(&self, token: &SecretString) -> Result<LivenessResult> {
49 let http = reqwest::Client::new();
50 match self.flavor() {
51 ConfluenceFlavor::SelfHosted => {
52 probe_current_user(
53 &http,
54 &format!("{}/rest/api/user/current", self.base_url()),
55 self.auth(),
56 token,
57 "confluence",
58 )
59 .await
60 }
61 ConfluenceFlavor::Cloud => match self.auth() {
62 ConfluenceAuth::Basic { .. } => {
63 probe_current_user(
64 &http,
65 &format!("{}/wiki/rest/api/user/current", self.base_url()),
66 self.auth(),
67 token,
68 "confluence cloud",
69 )
70 .await
71 }
72 ConfluenceAuth::BearerToken(_) | ConfluenceAuth::None => {
73 probe_cloud_bearer(&http, self, token).await
74 }
75 },
76 }
77 }
78}
79
80async fn probe_current_user(
81 http: &reqwest::Client,
82 url: &str,
83 auth: &ConfluenceAuth,
84 token: &SecretString,
85 provider_label: &str,
86) -> Result<LivenessResult> {
87 let mut request = http
88 .get(url)
89 .header(reqwest::header::ACCEPT, "application/json");
90 request = apply_auth(request, auth, token);
91
92 let resp = request
93 .send()
94 .await
95 .map_err(|e| Error::Http(format!("{provider_label} liveness GET {url}: {e}")))?;
96
97 let status = resp.status();
98 match status.as_u16() {
99 200 => {
100 let body: ConfluenceCurrentUser = resp.json().await.map_err(|e| {
101 Error::InvalidData(format!(
102 "{provider_label} user/current returned non-JSON body: {e}"
103 ))
104 })?;
105 let detail = body
106 .display_name
107 .or(body.username)
108 .or(body.account_id)
109 .or(body.email)
110 .unwrap_or_else(|| provider_label.to_owned());
111 Ok(LivenessResult::live(detail))
112 }
113 401 => Ok(LivenessResult::revoked(format!(
114 "{provider_label} rejected the token (401 Unauthorized)"
115 ))),
116 403 => Ok(LivenessResult::revoked(format!(
117 "{provider_label} refused the token (403 Forbidden)"
118 ))),
119 429 => {
120 let retry = resp
121 .headers()
122 .get("retry-after")
123 .and_then(|v| v.to_str().ok())
124 .map(str::to_owned)
125 .unwrap_or_else(|| "unknown".to_owned());
126 Ok(LivenessResult::throttled(format!(
127 "{provider_label} rate limit exceeded; retry-after: {retry}"
128 )))
129 }
130 other => {
131 let body = resp.text().await.unwrap_or_default();
132 Ok(LivenessResult::error(format!(
133 "{provider_label} GET {url} returned {other}: {}",
134 body.trim()
135 )))
136 }
137 }
138}
139
140async fn probe_cloud_bearer(
141 http: &reqwest::Client,
142 client: &ConfluenceClient,
143 token: &SecretString,
144) -> Result<LivenessResult> {
145 let accessible_resources_url = format!(
146 "{}/oauth/token/accessible-resources",
147 client
148 .cloud_api_base_url()
149 .unwrap_or_else(|| "https://api.atlassian.com".to_string())
150 );
151 let resp = http
152 .get(&accessible_resources_url)
153 .header(reqwest::header::ACCEPT, "application/json")
154 .bearer_auth(token.expose_secret())
155 .send()
156 .await
157 .map_err(|e| {
158 Error::Http(format!(
159 "confluence cloud liveness GET accessible-resources: {e}"
160 ))
161 })?;
162
163 match resp.status().as_u16() {
164 200 => {
165 let resources: Vec<AtlassianAccessibleResource> = resp.json().await.map_err(|e| {
166 Error::InvalidData(format!(
167 "confluence cloud accessible-resources returned non-JSON body: {e}"
168 ))
169 })?;
170 let wanted_origin = url_origin(client.instance_url())
171 .or_else(|| url_origin(client.base_url()))
172 .ok_or_else(|| {
173 Error::InvalidData(format!(
174 "cannot determine origin from Confluence base URL '{}'",
175 client.base_url()
176 ))
177 })?;
178
179 let matched = resources
180 .into_iter()
181 .find(|resource| {
182 resource
183 .url
184 .as_deref()
185 .and_then(url_origin)
186 .map(|origin| origin == wanted_origin)
187 .unwrap_or(false)
188 })
189 .ok_or_else(|| {
190 Error::NotFound(format!(
191 "no Atlassian accessible resource matched Confluence base URL '{}'",
192 client.instance_url()
193 ))
194 })?;
195
196 let cloud_api_base = client
197 .cloud_api_base_url()
198 .unwrap_or_else(|| "https://api.atlassian.com".to_string());
199 let probe_url = format!(
200 "{cloud_api_base}/ex/confluence/{}/wiki/api/v2/space?limit=1",
201 matched.id
202 );
203 let probe = http
204 .get(&probe_url)
205 .header(reqwest::header::ACCEPT, "application/json")
206 .bearer_auth(token.expose_secret())
207 .send()
208 .await
209 .map_err(|e| {
210 Error::Http(format!(
211 "confluence cloud liveness GET /wiki/api/v2/space: {e}"
212 ))
213 })?;
214
215 match probe.status().as_u16() {
216 200 => Ok(LivenessResult::live(format!(
217 "{} ({})",
218 matched.url.as_deref().unwrap_or(client.instance_url()),
219 matched.id
220 ))),
221 401 => Ok(LivenessResult::revoked(
222 "confluence cloud rejected the bearer token (401 Unauthorized)",
223 )),
224 403 => Ok(LivenessResult::revoked(
225 "confluence cloud refused the bearer token (403 Forbidden)",
226 )),
227 429 => {
228 let retry = probe
229 .headers()
230 .get("retry-after")
231 .and_then(|v| v.to_str().ok())
232 .map(str::to_owned)
233 .unwrap_or_else(|| "unknown".to_owned());
234 Ok(LivenessResult::throttled(format!(
235 "confluence cloud rate limit exceeded; retry-after: {retry}"
236 )))
237 }
238 other => {
239 let body = probe.text().await.unwrap_or_default();
240 Ok(LivenessResult::error(format!(
241 "confluence cloud GET {probe_url} returned {other}: {}",
242 body.trim()
243 )))
244 }
245 }
246 }
247 401 => Ok(LivenessResult::revoked(
248 "confluence cloud rejected the bearer token (401 Unauthorized)",
249 )),
250 403 => Ok(LivenessResult::revoked(
251 "confluence cloud refused the bearer token (403 Forbidden)",
252 )),
253 429 => {
254 let retry = resp
255 .headers()
256 .get("retry-after")
257 .and_then(|v| v.to_str().ok())
258 .map(str::to_owned)
259 .unwrap_or_else(|| "unknown".to_owned());
260 Ok(LivenessResult::throttled(format!(
261 "confluence cloud rate limit exceeded; retry-after: {retry}"
262 )))
263 }
264 other => {
265 let body = resp.text().await.unwrap_or_default();
266 Ok(LivenessResult::error(format!(
267 "confluence cloud GET {accessible_resources_url} returned {other}: {}",
268 body.trim()
269 )))
270 }
271 }
272}
273
274fn apply_auth(
275 request: reqwest::RequestBuilder,
276 auth: &ConfluenceAuth,
277 token: &SecretString,
278) -> reqwest::RequestBuilder {
279 match auth {
280 ConfluenceAuth::None => request,
281 ConfluenceAuth::BearerToken(_) => request.bearer_auth(token.expose_secret()),
282 ConfluenceAuth::Basic { username, .. } => {
283 request.basic_auth(username.as_str(), Some(token.expose_secret()))
284 }
285 }
286}
287
288fn url_origin(url: &str) -> Option<String> {
289 let (scheme, rest) = url.split_once("://")?;
290 let host = rest.split('/').next()?;
291 if host.is_empty() {
292 return None;
293 }
294 Some(format!("{}://{}", scheme.to_ascii_lowercase(), host))
295}
296
297#[cfg(test)]
298mod tests {
299 use super::*;
300 use devboy_core::liveness::LivenessStatus;
301 use httpmock::prelude::*;
302
303 #[tokio::test]
304 async fn self_hosted_live_token_returns_user_detail() {
305 let server = MockServer::start_async().await;
306 let _m = server
307 .mock_async(|when, then| {
308 when.method(GET)
309 .path("/rest/api/user/current")
310 .header("Authorization", "Bearer secret-token");
311 then.status(200)
312 .json_body(serde_json::json!({"displayName": "Confluence Admin"}));
313 })
314 .await;
315
316 let client = ConfluenceClient::new(server.base_url(), ConfluenceAuth::bearer("ignored"));
317 let r = client
318 .test(&SecretString::from("secret-token".to_owned()))
319 .await
320 .unwrap();
321 assert_eq!(r.status, LivenessStatus::Live);
322 assert_eq!(r.detail.as_deref(), Some("Confluence Admin"));
323 }
324
325 #[tokio::test]
326 async fn cloud_basic_live_token_uses_wiki_rest_api() {
327 let server = MockServer::start_async().await;
328 let _m = server
329 .mock_async(|when, then| {
330 when.method(GET).path("/wiki/rest/api/user/current").header(
331 "Authorization",
332 "Basic ZGV2QGV4YW1wbGUuY29tOnNlY3JldC10b2tlbg==",
333 );
334 then.status(200)
335 .json_body(serde_json::json!({"accountId": "acct-123"}));
336 })
337 .await;
338
339 let client = ConfluenceClient::new(
340 server.base_url(),
341 ConfluenceAuth::basic("dev@example.com", "ignored"),
342 )
343 .with_flavor(ConfluenceFlavor::Cloud);
344 let r = client
345 .test(&SecretString::from("secret-token".to_owned()))
346 .await
347 .unwrap();
348 assert_eq!(r.status, LivenessStatus::Live);
349 assert_eq!(r.detail.as_deref(), Some("acct-123"));
350 }
351
352 #[tokio::test]
353 async fn cloud_bearer_live_token_discovers_resource_and_probes_space_api() {
354 let server = MockServer::start_async().await;
355 let _resources = server
356 .mock_async(|when, then| {
357 when.method(GET)
358 .path("/oauth/token/accessible-resources")
359 .header("Authorization", "Bearer secret-token");
360 then.status(200).json_body(serde_json::json!([
361 {"id": "cloud-123", "url": "https://team.atlassian.net"}
362 ]));
363 })
364 .await;
365 let _spaces = server
366 .mock_async(|when, then| {
367 when.method(GET)
368 .path("/ex/confluence/cloud-123/wiki/api/v2/space")
369 .query_param("limit", "1")
370 .header("Authorization", "Bearer secret-token");
371 then.status(200)
372 .json_body(serde_json::json!({"results": []}));
373 })
374 .await;
375
376 let client = ConfluenceClient::new(
377 "https://team.atlassian.net",
378 ConfluenceAuth::bearer("ignored-self-token"),
379 )
380 .with_flavor(ConfluenceFlavor::Cloud)
381 .with_cloud_api_base_url(server.base_url());
382 let r = client
383 .test(&SecretString::from("secret-token".to_owned()))
384 .await
385 .unwrap();
386 assert_eq!(r.status, LivenessStatus::Live);
387 let detail = r.detail.unwrap();
388 assert!(detail.contains("team.atlassian.net"));
389 assert!(detail.contains("cloud-123"));
390 }
391
392 #[tokio::test]
393 async fn revoked_on_401() {
394 let server = MockServer::start_async().await;
395 let _m = server
396 .mock_async(|when, then| {
397 when.method(GET).path("/rest/api/user/current");
398 then.status(401);
399 })
400 .await;
401 let client = ConfluenceClient::new(server.base_url(), ConfluenceAuth::bearer("ignored"));
402 let r = client
403 .test(&SecretString::from("bad".to_owned()))
404 .await
405 .unwrap();
406 assert_eq!(r.status, LivenessStatus::Revoked);
407 }
408
409 #[tokio::test]
410 async fn throttled_on_429_carries_retry_after() {
411 let server = MockServer::start_async().await;
412 let _m = server
413 .mock_async(|when, then| {
414 when.method(GET).path("/rest/api/user/current");
415 then.status(429).header("Retry-After", "60");
416 })
417 .await;
418 let client = ConfluenceClient::new(server.base_url(), ConfluenceAuth::bearer("ignored"));
419 let r = client
420 .test(&SecretString::from("over-limit".to_owned()))
421 .await
422 .unwrap();
423 assert_eq!(r.status, LivenessStatus::Throttled);
424 assert!(r.detail.unwrap().contains("60"));
425 }
426
427 #[test]
428 fn provider_name_is_confluence() {
429 let client =
430 ConfluenceClient::new("https://example.atlassian.net", ConfluenceAuth::bearer("x"));
431 assert_eq!(client.provider_name(), "confluence");
432 }
433}