Skip to main content

atlassian_cli_api/
lib.rs

1pub mod error;
2pub mod pagination;
3pub mod ratelimit;
4pub mod retry;
5
6use backoff::backoff::Backoff;
7use error::{ApiError, Result};
8use ratelimit::RateLimiter;
9use reqwest::header::HeaderMap;
10use reqwest::{Client, Method, RequestBuilder, StatusCode};
11use retry::{retry_with_backoff, RetryConfig};
12use secrecy::{ExposeSecret, SecretString};
13use serde::de::DeserializeOwned;
14use serde::Serialize;
15use std::fmt;
16use std::time::Duration;
17use tracing::{debug, error, warn};
18use url::Url;
19
20#[derive(Clone)]
21pub enum AuthMethod {
22    Basic {
23        username: String,
24        token: SecretString,
25    },
26    Bearer {
27        token: SecretString,
28    },
29    GenieKey {
30        api_key: SecretString,
31    },
32}
33
34impl fmt::Debug for AuthMethod {
35    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
36        match self {
37            AuthMethod::Basic { username, .. } => f
38                .debug_struct("Basic")
39                .field("username", username)
40                .field("token", &"[REDACTED]")
41                .finish(),
42            AuthMethod::Bearer { .. } => f
43                .debug_struct("Bearer")
44                .field("token", &"[REDACTED]")
45                .finish(),
46            AuthMethod::GenieKey { .. } => f
47                .debug_struct("GenieKey")
48                .field("api_key", &"[REDACTED]")
49                .finish(),
50        }
51    }
52}
53
54/// Compare two URLs by full origin: scheme, host **and port**.
55///
56/// Port matters. Comparing scheme and host alone lets `https://site:8443/x`
57/// through on a `https://site` profile, and on a localhost profile it lets any
58/// other local port receive the profile's credentials.
59fn same_origin(a: &Url, b: &Url) -> bool {
60    a.scheme() == b.scheme()
61        && a.host() == b.host()
62        && a.port_or_known_default() == b.port_or_known_default()
63}
64
65/// Make a base URL end with `/`.
66///
67/// `Url::join` drops the base's last path segment unless the base ends with
68/// `/`. Idempotent.
69pub fn normalize_base_url(mut url: Url) -> Url {
70    if url.cannot_be_a_base() {
71        return url;
72    }
73
74    let path = url.path();
75    if !path.ends_with('/') {
76        url.set_path(&format!("{path}/"));
77    }
78    url
79}
80
81/// What a 401 says when the server offered no explanation of its own.
82const UNAUTHORIZED_FALLBACK: &str = "Invalid or expired credentials";
83
84/// Keep a quoted server message short enough to stay readable on one screen.
85const MAX_DETAIL_LEN: usize = 200;
86
87/// Build the error for a 401, keeping whatever reason the server gave.
88///
89/// Atlassian's gateway explains *why* it rejected the call, for example
90/// `{"code":401,"message":"Unauthorized; scope does not match"}`. Reporting
91/// every 401 as "Invalid or expired credentials" hid that, so a token missing
92/// one scope looked identical to an expired one and sent people re-issuing
93/// credentials that were fine all along.
94async fn unauthorized_error(response: reqwest::Response) -> ApiError {
95    let body = response.text().await.unwrap_or_default();
96    ApiError::AuthenticationFailed {
97        message: unauthorized_message(&body),
98    }
99}
100
101/// Combine the generic 401 wording with the server's own message, if any.
102fn unauthorized_message(body: &str) -> String {
103    match unauthorized_detail(body) {
104        Some(detail) => format!("{UNAUTHORIZED_FALLBACK} ({detail})"),
105        None => UNAUTHORIZED_FALLBACK.to_string(),
106    }
107}
108
109/// Pull the human-readable reason out of a 401 body.
110///
111/// Returns `None` when the body is empty or is an HTML error page, since a
112/// login page's markup tells the user nothing.
113fn unauthorized_detail(body: &str) -> Option<String> {
114    let trimmed = body.trim();
115    if trimmed.is_empty() || trimmed.starts_with('<') {
116        return None;
117    }
118
119    let detail = serde_json::from_str::<serde_json::Value>(trimmed)
120        .ok()
121        .and_then(|value| json_error_detail(&value))
122        .unwrap_or_else(|| trimmed.to_string());
123
124    let detail = detail.trim();
125    if detail.is_empty() {
126        return None;
127    }
128    Some(truncate_detail(&scrub_credentials(detail)))
129}
130
131/// Find the message field, across the several shapes Atlassian returns.
132fn json_error_detail(value: &serde_json::Value) -> Option<String> {
133    let direct = ["message", "error_description", "error"]
134        .iter()
135        .find_map(|key| value.get(*key).and_then(|v| v.as_str()))
136        .map(str::to_string);
137
138    direct
139        // `{"error": {"message": "..."}}`, which some Atlassian services and
140        // most proxies in front of them return. Without this the whole JSON
141        // document ends up quoted at the user as though it were prose.
142        .or_else(|| {
143            value
144                .get("error")
145                .and_then(|e| e.get("message").or_else(|| e.get("description")))
146                .and_then(|v| v.as_str())
147                .map(str::to_string)
148        })
149        // Jira classic instead returns `{"errorMessages": [...]}`.
150        .or_else(|| {
151            value
152                .get("errorMessages")
153                .and_then(|v| v.as_array())
154                .map(|messages| {
155                    messages
156                        .iter()
157                        .filter_map(|m| m.as_str())
158                        .collect::<Vec<_>>()
159                        .join("; ")
160                })
161        })
162        .map(|detail| detail.trim().to_string())
163        .filter(|detail| !detail.is_empty())
164}
165
166/// Replace credential-shaped substrings with a placeholder.
167///
168/// When a body has no field we recognise we quote it whole, and a gateway or
169/// proxy that echoes the request back would then put an `Authorization` value
170/// on the user's terminal, into their shell history and into any CI log
171/// scraping stderr. Tokens are `SecretString` everywhere else precisely so they
172/// cannot leak by accident; this closes the one path that bypasses that.
173///
174/// It looks for the two auth schemes this client sends and drops what follows,
175/// but only when that looks like a credential rather than a word: "Basic auth
176/// is not allowed" is a sentence a server really sends, and mangling it into
177/// "Basic <redacted> is not allowed" would destroy the message to protect
178/// nothing. See `is_credential_shaped`.
179fn scrub_credentials(detail: &str) -> String {
180    const SCHEMES: [&str; 2] = ["bearer ", "basic "];
181
182    // ASCII-lowercase, so byte offsets stay valid in the original. A full
183    // `to_lowercase` can change a string's length and desync the indices.
184    let haystack = detail.to_ascii_lowercase();
185
186    let mut out = String::with_capacity(detail.len());
187    let mut cursor = 0;
188
189    while cursor < detail.len() {
190        let found = SCHEMES
191            .iter()
192            .filter_map(|scheme| {
193                haystack[cursor..]
194                    .find(scheme)
195                    .map(|at| (cursor + at, *scheme))
196            })
197            .min_by_key(|(at, _)| *at);
198
199        let Some((at, scheme)) = found else {
200            out.push_str(&detail[cursor..]);
201            break;
202        };
203
204        let value_start = at + scheme.len();
205        out.push_str(&detail[cursor..value_start]);
206
207        let value_end = detail[value_start..]
208            .find(|c: char| c.is_whitespace() || matches!(c, '"' | '\'' | ',' | '}' | ']' | ')'))
209            .map(|offset| value_start + offset)
210            .unwrap_or(detail.len());
211
212        if is_credential_shaped(&detail[value_start..value_end]) {
213            out.push_str("<redacted>");
214            cursor = value_end;
215        } else {
216            // Not a credential. Leave it, but step past the scheme word so the
217            // scan cannot match it again and loop.
218            cursor = value_start;
219        }
220    }
221
222    out
223}
224
225/// Whether the token after an auth scheme is a credential rather than a word.
226///
227/// Credentials here are base64 or a JWT, so: only characters from that
228/// alphabet, and either long enough that no English word reaches it, or
229/// carrying one of the punctuation marks base64 and JWTs use and prose does
230/// not. "auth" and "authentication" fail both tests; `Zm9vOmJhcg==` and
231/// `eyJhbGci....sig` pass on punctuation alone, whatever their length.
232fn is_credential_shaped(token: &str) -> bool {
233    const MIN_OPAQUE_LEN: usize = 16;
234
235    if token.len() < 4 {
236        return false;
237    }
238    if !token
239        .chars()
240        .all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '/' | '=' | '.' | '_' | '-'))
241    {
242        return false;
243    }
244
245    token.len() >= MIN_OPAQUE_LEN || token.contains(['+', '/', '=', '.'])
246}
247
248/// Shorten on a char boundary, so multi-byte text cannot panic the slice.
249fn truncate_detail(detail: &str) -> String {
250    if detail.chars().count() <= MAX_DETAIL_LEN {
251        return detail.to_string();
252    }
253    let short: String = detail.chars().take(MAX_DETAIL_LEN).collect();
254    format!("{short}...")
255}
256
257/// The `Retry-After` delay in seconds, when the server sent one. The HTTP-date
258/// form is ignored; Atlassian sends seconds.
259fn retry_after(response: &reqwest::Response) -> Option<Duration> {
260    response
261        .headers()
262        .get(reqwest::header::RETRY_AFTER)?
263        .to_str()
264        .ok()?
265        .trim()
266        .parse::<u64>()
267        .ok()
268        .map(Duration::from_secs)
269}
270
271/// An arbitrary request for [`ApiClient::request_raw`].
272pub struct RawRequest<'a> {
273    pub method: Method,
274    /// Path (and optional query) relative to the client's base URL.
275    pub path: &'a str,
276    pub headers: HeaderMap,
277    pub body: Option<&'a [u8]>,
278    /// Overrides the client-wide 30s timeout for this request only.
279    pub timeout: Option<Duration>,
280}
281
282/// A response with no status-to-error mapping applied.
283#[derive(Debug, Clone)]
284pub struct RawResponse {
285    pub status: u16,
286    pub headers: Vec<(String, String)>,
287    pub body: Vec<u8>,
288}
289
290impl RawResponse {
291    pub fn is_success(&self) -> bool {
292        (200..300).contains(&self.status)
293    }
294
295    /// Case-insensitive header lookup. Returns the first match.
296    pub fn header(&self, name: &str) -> Option<&str> {
297        self.headers
298            .iter()
299            .find(|(key, _)| key.eq_ignore_ascii_case(name))
300            .map(|(_, value)| value.as_str())
301    }
302}
303
304#[derive(Clone)]
305pub struct ApiClient {
306    client: Client,
307    /// Same-origin-only redirect policy; used by `request_raw`.
308    raw_client: Client,
309    base_url: Url,
310    auth: Option<AuthMethod>,
311    retry_config: RetryConfig,
312    rate_limiter: RateLimiter,
313}
314
315impl ApiClient {
316    pub fn new(base_url: impl AsRef<str>) -> Result<Self> {
317        let url = Url::parse(base_url.as_ref()).map_err(ApiError::InvalidUrl)?;
318
319        // Enforce HTTPS for security (prevent accidental credential leaks over HTTP)
320        // Allow HTTP only for localhost/127.0.0.1 (for testing)
321        if url.scheme() != "https" {
322            let is_localhost = url
323                .host_str()
324                .map(|h| h == "localhost" || h == "127.0.0.1" || h.starts_with("127."))
325                .unwrap_or(false);
326
327            if !is_localhost {
328                return Err(ApiError::InvalidUrl(
329                    url::ParseError::InvalidDomainCharacter,
330                ));
331            }
332        }
333
334        let url = normalize_base_url(url);
335
336        let client = Client::builder()
337            .user_agent(format!("atlassian-cli/{}", env!("CARGO_PKG_VERSION")))
338            .timeout(Duration::from_secs(30))
339            .build()
340            .map_err(ApiError::RequestFailed)?;
341
342        // `request_raw` sends user-chosen methods, bodies and headers, so it gets
343        // a client that refuses to leave the profile's origin. The default
344        // policy would follow a `Location` anywhere: reqwest strips
345        // `Authorization` cross-host, but 307/308 replay the body and custom
346        // `-H` headers are not stripped. A stopped redirect is returned to the
347        // caller as the 3xx itself, which is the transparent answer for a
348        // passthrough. The normal `client` keeps following redirects, because
349        // attachment downloads depend on the cross-host hop to Atlassian's
350        // media host.
351        let origin = url.clone();
352        let raw_client = Client::builder()
353            .user_agent(format!("atlassian-cli/{}", env!("CARGO_PKG_VERSION")))
354            .timeout(Duration::from_secs(30))
355            .redirect(reqwest::redirect::Policy::custom(move |attempt| {
356                if attempt.previous().len() >= 10 {
357                    attempt.error("too many redirects")
358                } else if same_origin(attempt.url(), &origin) {
359                    attempt.follow()
360                } else {
361                    attempt.stop()
362                }
363            }))
364            .build()
365            .map_err(ApiError::RequestFailed)?;
366
367        Ok(Self {
368            client,
369            raw_client,
370            base_url: url,
371            auth: None,
372            retry_config: RetryConfig::default(),
373            rate_limiter: RateLimiter::new(),
374        })
375    }
376
377    /// Safely join a path to the base URL, ensuring the origin remains unchanged
378    /// to prevent SSRF attacks.
379    fn safe_join(&self, path: &str) -> Result<Url> {
380        let joined = self
381            .base_url
382            .join(path.strip_prefix('/').unwrap_or(path))
383            .map_err(ApiError::InvalidUrl)?;
384
385        if !same_origin(&joined, &self.base_url) {
386            return Err(ApiError::InvalidUrl(
387                url::ParseError::InvalidDomainCharacter,
388            ));
389        }
390
391        Ok(joined)
392    }
393
394    pub fn with_basic_auth(
395        mut self,
396        username: impl Into<String>,
397        token: impl Into<String>,
398    ) -> Self {
399        self.auth = Some(AuthMethod::Basic {
400            username: username.into(),
401            token: SecretString::from(token.into()),
402        });
403        self
404    }
405
406    pub fn with_bearer_token(mut self, token: impl Into<String>) -> Self {
407        self.auth = Some(AuthMethod::Bearer {
408            token: SecretString::from(token.into()),
409        });
410        self
411    }
412
413    pub fn with_genie_key(mut self, api_key: impl Into<String>) -> Self {
414        self.auth = Some(AuthMethod::GenieKey {
415            api_key: SecretString::from(api_key.into()),
416        });
417        self
418    }
419
420    pub fn with_retry_config(mut self, config: RetryConfig) -> Self {
421        self.retry_config = config;
422        self
423    }
424
425    pub fn base_url(&self) -> &str {
426        self.base_url.as_str()
427    }
428
429    /// Returns a reference to the underlying HTTP client for raw requests (e.g., multipart uploads).
430    pub fn http_client(&self) -> &Client {
431        &self.client
432    }
433
434    pub async fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
435        self.request(Method::GET, path, Option::<&()>::None).await
436    }
437
438    pub async fn post<T: DeserializeOwned, B: Serialize + ?Sized>(
439        &self,
440        path: &str,
441        body: &B,
442    ) -> Result<T> {
443        self.request(Method::POST, path, Some(body)).await
444    }
445
446    pub async fn put<T: DeserializeOwned, B: Serialize + ?Sized>(
447        &self,
448        path: &str,
449        body: &B,
450    ) -> Result<T> {
451        self.request(Method::PUT, path, Some(body)).await
452    }
453
454    pub async fn delete<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
455        self.request(Method::DELETE, path, Option::<&()>::None)
456            .await
457    }
458
459    pub async fn delete_with_body<T: DeserializeOwned, B: Serialize + ?Sized>(
460        &self,
461        path: &str,
462        body: &B,
463    ) -> Result<T> {
464        self.request(Method::DELETE, path, Some(body)).await
465    }
466
467    /// DELETE that expects 204 No Content (no response body).
468    pub async fn delete_no_content(&self, path: &str) -> Result<()> {
469        if let Some(wait_secs) = self.rate_limiter.check_limit().await {
470            warn!(wait_secs, "Rate limit reached, waiting");
471            tokio::time::sleep(Duration::from_secs(wait_secs)).await;
472        }
473
474        let joined = self.safe_join(path)?;
475
476        debug!(method = "DELETE", url = %joined, "Sending delete (no content) request");
477
478        retry_with_backoff(&self.retry_config, || async {
479            let mut req = self.client.request(Method::DELETE, joined.clone());
480            req = self.apply_auth(req);
481
482            let response = req.send().await.map_err(ApiError::RequestFailed)?;
483
484            self.rate_limiter.update_from_response(&response).await;
485
486            let status = response.status();
487
488            match status {
489                StatusCode::UNAUTHORIZED => Err(unauthorized_error(response).await),
490                StatusCode::FORBIDDEN => {
491                    let message = response
492                        .text()
493                        .await
494                        .unwrap_or_else(|_| "Access forbidden".to_string());
495                    Err(ApiError::Forbidden { message })
496                }
497                StatusCode::NOT_FOUND => {
498                    let resource = joined.path().to_string();
499                    Err(ApiError::NotFound { resource })
500                }
501                StatusCode::BAD_REQUEST => {
502                    let message = response
503                        .text()
504                        .await
505                        .unwrap_or_else(|_| "Bad request".to_string());
506                    Err(ApiError::BadRequest { message })
507                }
508                StatusCode::GONE => {
509                    let message = response
510                        .text()
511                        .await
512                        .unwrap_or_else(|_| "API endpoint has been removed".to_string());
513                    Err(ApiError::EndpointGone { message })
514                }
515                StatusCode::TOO_MANY_REQUESTS => {
516                    let retry_after = response
517                        .headers()
518                        .get("retry-after")
519                        .and_then(|v| v.to_str().ok())
520                        .and_then(|s| s.parse().ok())
521                        .unwrap_or(60);
522                    Err(ApiError::RateLimitExceeded { retry_after })
523                }
524                status if status.is_server_error() => {
525                    let message = response
526                        .text()
527                        .await
528                        .unwrap_or_else(|_| "Server error".to_string());
529                    Err(ApiError::ServerError {
530                        status: status.as_u16(),
531                        message,
532                    })
533                }
534                status if status.is_success() => Ok(()),
535                _ => {
536                    let message = response
537                        .text()
538                        .await
539                        .unwrap_or_else(|_| format!("Unexpected status: {}", status));
540                    Err(ApiError::ServerError {
541                        status: status.as_u16(),
542                        message,
543                    })
544                }
545            }
546        })
547        .await
548    }
549
550    /// Get plain text content from an endpoint.
551    /// Sets Accept: text/plain; charset=utf-8 header.
552    /// Includes retry logic and rate limiting.
553    pub async fn get_text(&self, path: &str) -> Result<String> {
554        if let Some(wait_secs) = self.rate_limiter.check_limit().await {
555            warn!(wait_secs, "Rate limit reached, waiting");
556            tokio::time::sleep(Duration::from_secs(wait_secs)).await;
557        }
558
559        let joined = self.safe_join(path)?;
560
561        debug!(method = "GET", url = %joined, "Sending text request");
562
563        let result = retry_with_backoff(&self.retry_config, || async {
564            let mut req = self.client.request(Method::GET, joined.clone());
565            req = self.apply_auth(req);
566            req = req.header("Accept", "text/plain, */*;q=0.1");
567
568            let response = req.send().await.map_err(ApiError::RequestFailed)?;
569
570            self.rate_limiter.update_from_response(&response).await;
571
572            let status = response.status();
573
574            match status {
575                StatusCode::UNAUTHORIZED => Err(unauthorized_error(response).await),
576                StatusCode::FORBIDDEN => {
577                    let message = response
578                        .text()
579                        .await
580                        .unwrap_or_else(|_| "Access forbidden".to_string());
581                    Err(ApiError::Forbidden { message })
582                }
583                StatusCode::NOT_FOUND => {
584                    let resource = joined.path().to_string();
585                    Err(ApiError::NotFound { resource })
586                }
587                StatusCode::BAD_REQUEST => {
588                    let message = response
589                        .text()
590                        .await
591                        .unwrap_or_else(|_| "Bad request".to_string());
592                    Err(ApiError::BadRequest { message })
593                }
594                StatusCode::NOT_ACCEPTABLE => {
595                    let message = response
596                        .text()
597                        .await
598                        .unwrap_or_else(|_| "Content not acceptable".to_string());
599                    Err(ApiError::ServerError {
600                        status: 406,
601                        message,
602                    })
603                }
604                StatusCode::GONE => {
605                    let message = response
606                        .text()
607                        .await
608                        .unwrap_or_else(|_| "API endpoint has been removed".to_string());
609                    Err(ApiError::EndpointGone { message })
610                }
611                StatusCode::TOO_MANY_REQUESTS => {
612                    let retry_after = response
613                        .headers()
614                        .get("retry-after")
615                        .and_then(|v| v.to_str().ok())
616                        .and_then(|s| s.parse().ok())
617                        .unwrap_or(60);
618                    Err(ApiError::RateLimitExceeded { retry_after })
619                }
620                status if status.is_server_error() => {
621                    let message = response
622                        .text()
623                        .await
624                        .unwrap_or_else(|_| "Server error".to_string());
625                    Err(ApiError::ServerError {
626                        status: status.as_u16(),
627                        message,
628                    })
629                }
630                status if status.is_success() => response.text().await.map_err(|e| {
631                    error!("Failed to read text response: {}", e);
632                    ApiError::InvalidResponse(e.to_string())
633                }),
634                _ => {
635                    let message = response
636                        .text()
637                        .await
638                        .unwrap_or_else(|_| format!("Unexpected status: {}", status));
639                    Err(ApiError::ServerError {
640                        status: status.as_u16(),
641                        message,
642                    })
643                }
644            }
645        })
646        .await?;
647
648        Ok(result)
649    }
650
651    /// Resolve `path` against the base URL, applying the same-origin (SSRF)
652    /// check used by every request. Public so callers can validate or preview a
653    /// path without sending anything.
654    pub fn resolve_url(&self, path: &str) -> Result<Url> {
655        self.safe_join(path)
656    }
657
658    /// Send an arbitrary request and return the status, headers and body bytes.
659    ///
660    /// Unlike [`ApiClient::request`], a non-2xx status is returned as
661    /// `Ok(RawResponse)` rather than mapped to an [`ApiError`], so callers can
662    /// surface the API's own error body. Only transport failures and URL
663    /// validation produce `Err`. Same-origin validation, auth and rate limiting
664    /// still apply.
665    ///
666    /// Retries on 429/5xx are limited to idempotent methods. `request` retries
667    /// POSTs, which can double-create; a raw passthrough must not inherit that.
668    pub async fn request_raw(&self, req: RawRequest<'_>) -> Result<RawResponse> {
669        if let Some(wait_secs) = self.rate_limiter.check_limit().await {
670            warn!(wait_secs, "Rate limit reached, waiting");
671            tokio::time::sleep(Duration::from_secs(wait_secs)).await;
672        }
673
674        let joined = self.safe_join(req.path)?;
675        debug!(method = %req.method, url = %joined, "Sending raw request");
676
677        let idempotent = matches!(
678            req.method,
679            Method::GET | Method::HEAD | Method::PUT | Method::DELETE | Method::OPTIONS
680        );
681        // retry_with_backoff cannot be used here: its closure must signal a
682        // retryable outcome as Err, which would discard the RawResponse we have
683        // to return on the final attempt.
684        let mut backoff = self.retry_config.backoff();
685        let mut attempts = 0usize;
686
687        loop {
688            attempts += 1;
689
690            let mut builder = self.raw_client.request(req.method.clone(), joined.clone());
691            builder = self.apply_auth(builder);
692            builder = builder.headers(req.headers.clone());
693            if let Some(body) = req.body {
694                builder = builder.body(body.to_vec());
695            }
696            if let Some(timeout) = req.timeout {
697                builder = builder.timeout(timeout);
698            }
699
700            let response = builder.send().await.map_err(ApiError::RequestFailed)?;
701            self.rate_limiter.update_from_response(&response).await;
702            let status = response.status();
703
704            let retryable = status == StatusCode::TOO_MANY_REQUESTS || status.is_server_error();
705            if idempotent && retryable && attempts < self.retry_config.max_retries {
706                if let Some(wait) = backoff.next_backoff() {
707                    // A 429 says how long to wait; obey it rather than racing
708                    // back in after a short exponential sleep.
709                    let wait = retry_after(&response).unwrap_or(wait);
710                    warn!(
711                        status = status.as_u16(),
712                        attempt = attempts,
713                        wait_ms = wait.as_millis(),
714                        "Raw request failed, retrying"
715                    );
716                    tokio::time::sleep(wait).await;
717                    continue;
718                }
719            }
720
721            let headers = response
722                .headers()
723                .iter()
724                .map(|(name, value)| {
725                    (
726                        name.as_str().to_string(),
727                        value.to_str().unwrap_or_default().to_string(),
728                    )
729                })
730                .collect();
731            let body = response
732                .bytes()
733                .await
734                .map_err(|err| ApiError::InvalidResponse(err.to_string()))?
735                .to_vec();
736
737            return Ok(RawResponse {
738                status: status.as_u16(),
739                headers,
740                body,
741            });
742        }
743    }
744
745    /// Get binary content from an endpoint.
746    /// Includes retry logic and rate limiting.
747    pub async fn get_bytes(&self, path: &str) -> Result<Vec<u8>> {
748        if let Some(wait_secs) = self.rate_limiter.check_limit().await {
749            warn!(wait_secs, "Rate limit reached, waiting");
750            tokio::time::sleep(Duration::from_secs(wait_secs)).await;
751        }
752
753        let joined = self.safe_join(path)?;
754
755        debug!(method = "GET", url = %joined, "Sending bytes request");
756
757        let result = retry_with_backoff(&self.retry_config, || async {
758            let mut req = self.client.request(Method::GET, joined.clone());
759            req = self.apply_auth(req);
760
761            let response = req.send().await.map_err(ApiError::RequestFailed)?;
762
763            self.rate_limiter.update_from_response(&response).await;
764
765            let status = response.status();
766
767            match status {
768                StatusCode::UNAUTHORIZED => Err(unauthorized_error(response).await),
769                StatusCode::FORBIDDEN => {
770                    let message = response
771                        .text()
772                        .await
773                        .unwrap_or_else(|_| "Access forbidden".to_string());
774                    Err(ApiError::Forbidden { message })
775                }
776                StatusCode::NOT_FOUND => {
777                    let resource = joined.path().to_string();
778                    Err(ApiError::NotFound { resource })
779                }
780                StatusCode::GONE => {
781                    let message = response
782                        .text()
783                        .await
784                        .unwrap_or_else(|_| "API endpoint has been removed".to_string());
785                    Err(ApiError::EndpointGone { message })
786                }
787                StatusCode::TOO_MANY_REQUESTS => {
788                    let retry_after = response
789                        .headers()
790                        .get("retry-after")
791                        .and_then(|v| v.to_str().ok())
792                        .and_then(|s| s.parse().ok())
793                        .unwrap_or(60);
794                    Err(ApiError::RateLimitExceeded { retry_after })
795                }
796                status if status.is_success() => {
797                    response.bytes().await.map(|b| b.to_vec()).map_err(|e| {
798                        error!("Failed to read bytes response: {}", e);
799                        ApiError::InvalidResponse(e.to_string())
800                    })
801                }
802                _ => {
803                    let message = response
804                        .text()
805                        .await
806                        .unwrap_or_else(|_| format!("Unexpected status: {}", status));
807                    Err(ApiError::ServerError {
808                        status: status.as_u16(),
809                        message,
810                    })
811                }
812            }
813        })
814        .await?;
815
816        Ok(result)
817    }
818
819    pub async fn request<T: DeserializeOwned, B: Serialize + ?Sized>(
820        &self,
821        method: Method,
822        path: &str,
823        body: Option<&B>,
824    ) -> Result<T> {
825        if let Some(wait_secs) = self.rate_limiter.check_limit().await {
826            warn!(wait_secs, "Rate limit reached, waiting");
827            tokio::time::sleep(Duration::from_secs(wait_secs)).await;
828        }
829
830        let joined = self.safe_join(path)?;
831
832        debug!(method = %method, url = %joined, "Sending request");
833
834        let result = retry_with_backoff(&self.retry_config, || async {
835            let mut req = self.client.request(method.clone(), joined.clone());
836            req = self.apply_auth(req);
837
838            if let Some(body) = body {
839                req = req.json(body);
840            }
841
842            let response = req.send().await.map_err(ApiError::RequestFailed)?;
843
844            self.rate_limiter.update_from_response(&response).await;
845
846            let status = response.status();
847
848            match status {
849                StatusCode::UNAUTHORIZED => Err(unauthorized_error(response).await),
850                StatusCode::FORBIDDEN => {
851                    let message = response
852                        .text()
853                        .await
854                        .unwrap_or_else(|_| "Access forbidden".to_string());
855                    Err(ApiError::Forbidden { message })
856                }
857                StatusCode::NOT_FOUND => {
858                    let resource = joined.path().to_string();
859                    Err(ApiError::NotFound { resource })
860                }
861                StatusCode::BAD_REQUEST => {
862                    let message = response
863                        .text()
864                        .await
865                        .unwrap_or_else(|_| "Bad request".to_string());
866                    Err(ApiError::BadRequest { message })
867                }
868                StatusCode::GONE => {
869                    let message = response
870                        .text()
871                        .await
872                        .unwrap_or_else(|_| "API endpoint has been removed".to_string());
873                    Err(ApiError::EndpointGone { message })
874                }
875                StatusCode::TOO_MANY_REQUESTS => {
876                    let retry_after = response
877                        .headers()
878                        .get("retry-after")
879                        .and_then(|v| v.to_str().ok())
880                        .and_then(|s| s.parse().ok())
881                        .unwrap_or(60);
882                    Err(ApiError::RateLimitExceeded { retry_after })
883                }
884                status if status.is_server_error() => {
885                    let message = response
886                        .text()
887                        .await
888                        .unwrap_or_else(|_| "Server error".to_string());
889                    Err(ApiError::ServerError {
890                        status: status.as_u16(),
891                        message,
892                    })
893                }
894                status if status.is_success() => {
895                    let bytes = response
896                        .bytes()
897                        .await
898                        .map_err(|e| ApiError::InvalidResponse(e.to_string()))?;
899                    // Successful responses with an empty (or whitespace-only) body,
900                    // e.g. HTTP 204 No Content from Jira update/transition/assign and
901                    // most DELETEs, are treated as JSON `null`. Callers that discard
902                    // the body (`let _: Value`) then succeed instead of failing to
903                    // parse an empty body as JSON.
904                    let slice: &[u8] = if bytes.iter().all(|b| b.is_ascii_whitespace()) {
905                        b"null"
906                    } else {
907                        &bytes
908                    };
909                    serde_json::from_slice::<T>(slice).map_err(|e| {
910                        error!("Failed to parse JSON response: {}", e);
911                        ApiError::InvalidResponse(e.to_string())
912                    })
913                }
914                _ => {
915                    let message = response
916                        .text()
917                        .await
918                        .unwrap_or_else(|_| format!("Unexpected status: {}", status));
919                    Err(ApiError::ServerError {
920                        status: status.as_u16(),
921                        message,
922                    })
923                }
924            }
925        })
926        .await?;
927
928        Ok(result)
929    }
930
931    pub fn apply_auth(&self, request: RequestBuilder) -> RequestBuilder {
932        match &self.auth {
933            Some(AuthMethod::Basic { username, token }) => {
934                request.basic_auth(username, Some(token.expose_secret()))
935            }
936            Some(AuthMethod::Bearer { token }) => request.bearer_auth(token.expose_secret()),
937            Some(AuthMethod::GenieKey { api_key }) => request.header(
938                "Authorization",
939                format!("GenieKey {}", api_key.expose_secret()),
940            ),
941            None => request,
942        }
943    }
944
945    pub fn rate_limiter(&self) -> &RateLimiter {
946        &self.rate_limiter
947    }
948}
949
950#[cfg(test)]
951mod tests {
952    use super::*;
953    use wiremock::matchers::{body_string, header, method, path};
954    use wiremock::{Mock, MockServer, ResponseTemplate};
955
956    #[tokio::test]
957    async fn test_403_returns_forbidden() {
958        let server = MockServer::start().await;
959        Mock::given(method("GET"))
960            .and(path("test"))
961            .respond_with(ResponseTemplate::new(403).set_body_string("You do not have access"))
962            .mount(&server)
963            .await;
964
965        let client = ApiClient::new(server.uri()).unwrap();
966        let result: error::Result<serde_json::Value> = client.get("/test").await;
967
968        match result {
969            Err(ApiError::Forbidden { message }) => {
970                assert!(message.contains("You do not have access"));
971            }
972            other => panic!("Expected Forbidden, got: {:?}", other),
973        }
974    }
975
976    #[tokio::test]
977    async fn test_401_returns_authentication_failed() {
978        let server = MockServer::start().await;
979        Mock::given(method("GET"))
980            .and(path("test"))
981            .respond_with(ResponseTemplate::new(401))
982            .mount(&server)
983            .await;
984
985        let client = ApiClient::new(server.uri()).unwrap();
986        let result: error::Result<serde_json::Value> = client.get("/test").await;
987
988        match result {
989            Err(ApiError::AuthenticationFailed { message }) => {
990                // No body, so the generic wording is all we can say.
991                assert_eq!(message, UNAUTHORIZED_FALLBACK);
992            }
993            other => panic!("Expected AuthenticationFailed, got: {:?}", other),
994        }
995    }
996
997    /// The scope-mismatch case that cost a full debugging session: the gateway
998    /// explained itself and the CLI threw the explanation away.
999    #[tokio::test]
1000    async fn test_401_surfaces_gateway_scope_message() {
1001        let server = MockServer::start().await;
1002        Mock::given(method("GET"))
1003            .and(path("test"))
1004            .respond_with(
1005                ResponseTemplate::new(401).set_body_string(
1006                    r#"{"code":401,"message":"Unauthorized; scope does not match"}"#,
1007                ),
1008            )
1009            .mount(&server)
1010            .await;
1011
1012        let client = ApiClient::new(server.uri()).unwrap();
1013        let result: error::Result<serde_json::Value> = client.get("/test").await;
1014
1015        match result {
1016            Err(ApiError::AuthenticationFailed { message }) => {
1017                assert!(
1018                    message.contains("scope does not match"),
1019                    "gateway reason was dropped: {message}"
1020                );
1021            }
1022            other => panic!("Expected AuthenticationFailed, got: {:?}", other),
1023        }
1024    }
1025
1026    #[test]
1027    fn unauthorized_message_falls_back_when_body_is_empty() {
1028        assert_eq!(unauthorized_message(""), UNAUTHORIZED_FALLBACK);
1029        assert_eq!(unauthorized_message("   "), UNAUTHORIZED_FALLBACK);
1030    }
1031
1032    #[test]
1033    fn unauthorized_message_keeps_gateway_reason() {
1034        let body = r#"{"code":401,"message":"Unauthorized; scope does not match"}"#;
1035        let message = unauthorized_message(body);
1036        assert!(message.starts_with(UNAUTHORIZED_FALLBACK));
1037        assert!(message.contains("Unauthorized; scope does not match"));
1038    }
1039
1040    #[test]
1041    fn unauthorized_message_reads_jira_error_messages() {
1042        let body = r#"{"errorMessages":["Client must be authenticated"],"errors":{}}"#;
1043        assert!(unauthorized_message(body).contains("Client must be authenticated"));
1044    }
1045
1046    #[test]
1047    fn unauthorized_message_reads_oauth_error_description() {
1048        let body = r#"{"error":"invalid_token","error_description":"The token expired"}"#;
1049        assert!(unauthorized_message(body).contains("The token expired"));
1050    }
1051
1052    #[test]
1053    fn unauthorized_message_reads_a_nested_error_object() {
1054        let body = r#"{"error":{"message":"Token does not have the required scope"}}"#;
1055        let message = unauthorized_message(body);
1056        assert!(message.contains("required scope"));
1057        // The whole document must not be quoted back as though it were prose.
1058        assert!(
1059            !message.contains("{\"error\""),
1060            "raw JSON leaked: {message}"
1061        );
1062    }
1063
1064    /// A body we cannot parse is quoted whole, so a proxy echoing the request
1065    /// back would otherwise print the token we just sent it.
1066    #[test]
1067    fn unauthorized_message_redacts_an_echoed_authorization_header() {
1068        let body =
1069            "rejected request: Authorization: Basic Zm9vOmJhcnNlY3JldA== to /rest/api/3/myself";
1070        let message = unauthorized_message(body);
1071        assert!(
1072            !message.contains("Zm9vOmJhcnNlY3JldA=="),
1073            "the credential survived: {message}"
1074        );
1075        assert!(message.contains("Basic <redacted>"));
1076        assert!(
1077            message.contains("/rest/api/3/myself"),
1078            "the useful part of the body was lost: {message}"
1079        );
1080    }
1081
1082    #[test]
1083    fn unauthorized_message_redacts_a_bearer_token_inside_json() {
1084        let body = r#"{"message":"bad header \"Bearer eyJhbGciOiJIUzI1NiJ9.payload.sig\""}"#;
1085        let message = unauthorized_message(body);
1086        assert!(!message.contains("eyJhbGciOiJIUzI1NiJ9"), "{message}");
1087        assert!(message.contains("Bearer <redacted>"));
1088    }
1089
1090    #[test]
1091    fn unauthorized_message_redacts_every_occurrence() {
1092        let body = "Bearer aGVsbG8gd29ybGQgdG9rZW4= and basic dXNlcjpwYXNzd29yZA==";
1093        let message = unauthorized_message(body);
1094        for secret in ["aGVsbG8gd29ybGQgdG9rZW4=", "dXNlcjpwYXNzd29yZA=="] {
1095            assert!(!message.contains(secret), "{secret} survived: {message}");
1096        }
1097        assert_eq!(message.matches("<redacted>").count(), 2);
1098    }
1099
1100    /// "Basic auth is not allowed" is a sentence servers really send. Redacting
1101    /// the word after the scheme would destroy the message to protect nothing.
1102    #[test]
1103    fn scrub_leaves_ordinary_prose_alone() {
1104        for prose in [
1105            "basic authentication is not permitted here",
1106            "Basic auth is not allowed",
1107            "use Bearer tokens instead",
1108            "no credentials at all",
1109        ] {
1110            assert_eq!(scrub_credentials(prose), prose, "prose was mangled");
1111        }
1112    }
1113
1114    #[test]
1115    fn credential_shape_separates_words_from_secrets() {
1116        for word in ["auth", "authentication", "tokens", "a", ""] {
1117            assert!(
1118                !is_credential_shaped(word),
1119                "{word} is a word, not a secret"
1120            );
1121        }
1122        for secret in [
1123            "Zm9vOmJhcg==",
1124            "eyJhbGciOiJIUzI1NiJ9.payload.sig",
1125            "abcdefghijklmnop",
1126            "ATATT3xFfGF0abc_def-123",
1127        ] {
1128            assert!(is_credential_shaped(secret), "{secret} should be redacted");
1129        }
1130    }
1131
1132    #[test]
1133    fn unauthorized_message_keeps_plain_text_body() {
1134        assert!(unauthorized_message("Basic auth is not allowed").contains("Basic auth"));
1135    }
1136
1137    #[test]
1138    fn unauthorized_message_ignores_html_login_page() {
1139        let body = "<!DOCTYPE html><html><body>Sign in</body></html>";
1140        assert_eq!(unauthorized_message(body), UNAUTHORIZED_FALLBACK);
1141    }
1142
1143    #[test]
1144    fn unauthorized_message_truncates_long_bodies() {
1145        let body = format!(r#"{{"message":"{}"}}"#, "x".repeat(500));
1146        let message = unauthorized_message(&body);
1147        assert!(message.contains("..."));
1148        assert!(message.len() < 300, "message was not truncated: {message}");
1149    }
1150
1151    /// Multi-byte text must not panic the truncation slice.
1152    #[test]
1153    fn unauthorized_message_truncates_on_char_boundary() {
1154        let body = format!(r#"{{"message":"{}"}}"#, "é".repeat(500));
1155        assert!(unauthorized_message(&body).contains("..."));
1156    }
1157
1158    #[tokio::test]
1159    async fn test_403_get_text_returns_forbidden() {
1160        let server = MockServer::start().await;
1161        Mock::given(method("GET"))
1162            .and(path("text-endpoint"))
1163            .respond_with(ResponseTemplate::new(403).set_body_string("Forbidden resource"))
1164            .mount(&server)
1165            .await;
1166
1167        let client = ApiClient::new(server.uri()).unwrap();
1168        let result = client.get_text("/text-endpoint").await;
1169
1170        match result {
1171            Err(ApiError::Forbidden { message }) => {
1172                assert!(message.contains("Forbidden resource"));
1173            }
1174            other => panic!("Expected Forbidden, got: {:?}", other),
1175        }
1176    }
1177
1178    #[tokio::test]
1179    async fn test_403_get_bytes_returns_forbidden() {
1180        let server = MockServer::start().await;
1181        Mock::given(method("GET"))
1182            .and(path("bytes-endpoint"))
1183            .respond_with(ResponseTemplate::new(403).set_body_string("Access denied"))
1184            .mount(&server)
1185            .await;
1186
1187        let client = ApiClient::new(server.uri()).unwrap();
1188        let result = client.get_bytes("/bytes-endpoint").await;
1189
1190        match result {
1191            Err(ApiError::Forbidden { message }) => {
1192                assert!(message.contains("Access denied"));
1193            }
1194            other => panic!("Expected Forbidden, got: {:?}", other),
1195        }
1196    }
1197
1198    // Regression for #45: a successful PUT/POST returning HTTP 204 No Content (empty
1199    // body) must not fail JSON parsing. Callers discard the body as `Value`.
1200    #[tokio::test]
1201    async fn test_204_no_content_put_succeeds() {
1202        let server = MockServer::start().await;
1203        Mock::given(method("PUT"))
1204            .and(path("issue/AEA-1"))
1205            .respond_with(ResponseTemplate::new(204))
1206            .mount(&server)
1207            .await;
1208
1209        let client = ApiClient::new(server.uri()).unwrap();
1210        let result: error::Result<serde_json::Value> = client
1211            .put("/issue/AEA-1", &serde_json::json!({"fields": {}}))
1212            .await;
1213
1214        match result {
1215            Ok(serde_json::Value::Null) => {}
1216            other => panic!("Expected Ok(Null) for 204, got: {:?}", other),
1217        }
1218    }
1219
1220    // A 200 with an empty/whitespace-only body is also treated as null.
1221    #[tokio::test]
1222    async fn test_200_empty_body_succeeds() {
1223        let server = MockServer::start().await;
1224        Mock::given(method("POST"))
1225            .and(path("transitions"))
1226            .respond_with(ResponseTemplate::new(200).set_body_string("  \n"))
1227            .mount(&server)
1228            .await;
1229
1230        let client = ApiClient::new(server.uri()).unwrap();
1231        let result: error::Result<serde_json::Value> =
1232            client.post("/transitions", &serde_json::json!({})).await;
1233
1234        match result {
1235            Ok(serde_json::Value::Null) => {}
1236            other => panic!("Expected Ok(Null) for empty 200, got: {:?}", other),
1237        }
1238    }
1239
1240    // A non-empty JSON body on success still parses normally.
1241    #[tokio::test]
1242    async fn test_200_json_body_still_parses() {
1243        let server = MockServer::start().await;
1244        Mock::given(method("GET"))
1245            .and(path("issue/AEA-1"))
1246            .respond_with(
1247                ResponseTemplate::new(200).set_body_json(serde_json::json!({"key": "AEA-1"})),
1248            )
1249            .mount(&server)
1250            .await;
1251
1252        let client = ApiClient::new(server.uri()).unwrap();
1253        let result: serde_json::Value = client.get("/issue/AEA-1").await.unwrap();
1254        assert_eq!(result["key"], "AEA-1");
1255    }
1256
1257    // -----------------------------------------------------------------------
1258    // request_raw
1259    // -----------------------------------------------------------------------
1260
1261    /// The point of the raw path: a non-2xx status is data, not an error, so the
1262    /// API's own error body survives instead of being replaced by ApiError.
1263    #[tokio::test]
1264    async fn test_request_raw_surfaces_non_2xx_without_erroring() {
1265        let server = MockServer::start().await;
1266        Mock::given(method("GET"))
1267            .and(path("/rest/api/3/issue/NOPE-1"))
1268            .respond_with(
1269                ResponseTemplate::new(404)
1270                    .set_body_json(serde_json::json!({"errorMessages": ["Issue does not exist"]})),
1271            )
1272            .mount(&server)
1273            .await;
1274
1275        let client = ApiClient::new(server.uri()).unwrap();
1276        let response = client
1277            .request_raw(RawRequest {
1278                method: Method::GET,
1279                path: "/rest/api/3/issue/NOPE-1",
1280                headers: HeaderMap::new(),
1281                body: None,
1282                timeout: None,
1283            })
1284            .await
1285            .unwrap();
1286
1287        assert_eq!(response.status, 404);
1288        assert!(!response.is_success());
1289        assert!(response
1290            .header("Content-Type")
1291            .unwrap()
1292            .contains("application/json"));
1293        assert!(String::from_utf8_lossy(&response.body).contains("Issue does not exist"));
1294    }
1295
1296    #[tokio::test]
1297    async fn test_request_raw_applies_headers_and_body() {
1298        let server = MockServer::start().await;
1299        Mock::given(method("POST"))
1300            .and(path("/rest/api/3/issue"))
1301            .and(header("X-Atlassian-Token", "no-check"))
1302            .and(body_string("{\"fields\":{}}"))
1303            .respond_with(
1304                ResponseTemplate::new(201).set_body_json(serde_json::json!({"key": "A-1"})),
1305            )
1306            .mount(&server)
1307            .await;
1308
1309        let mut headers = HeaderMap::new();
1310        headers.insert("X-Atlassian-Token", "no-check".parse().unwrap());
1311
1312        let client = ApiClient::new(server.uri()).unwrap();
1313        let response = client
1314            .request_raw(RawRequest {
1315                method: Method::POST,
1316                path: "/rest/api/3/issue",
1317                headers,
1318                body: Some(b"{\"fields\":{}}"),
1319                timeout: None,
1320            })
1321            .await
1322            .unwrap();
1323
1324        assert_eq!(response.status, 201);
1325    }
1326
1327    #[tokio::test]
1328    async fn test_request_raw_retries_5xx_for_get() {
1329        let server = MockServer::start().await;
1330        Mock::given(method("GET"))
1331            .and(path("/flaky"))
1332            .respond_with(ResponseTemplate::new(500))
1333            .expect(3)
1334            .mount(&server)
1335            .await;
1336
1337        let client = ApiClient::new(server.uri())
1338            .unwrap()
1339            .with_retry_config(RetryConfig {
1340                initial_interval: Duration::from_millis(1),
1341                ..RetryConfig::default()
1342            });
1343        let response = client
1344            .request_raw(RawRequest {
1345                method: Method::GET,
1346                path: "/flaky",
1347                headers: HeaderMap::new(),
1348                body: None,
1349                timeout: None,
1350            })
1351            .await
1352            .unwrap();
1353
1354        assert_eq!(response.status, 500);
1355    }
1356
1357    /// Replaying a POST can double-create. `request` does retry POSTs; the raw
1358    /// path deliberately does not inherit that.
1359    #[tokio::test]
1360    async fn test_request_raw_never_retries_post() {
1361        let server = MockServer::start().await;
1362        Mock::given(method("POST"))
1363            .and(path("/create"))
1364            .respond_with(ResponseTemplate::new(503))
1365            .expect(1)
1366            .mount(&server)
1367            .await;
1368
1369        let client = ApiClient::new(server.uri())
1370            .unwrap()
1371            .with_retry_config(RetryConfig {
1372                initial_interval: Duration::from_millis(1),
1373                ..RetryConfig::default()
1374            });
1375        let response = client
1376            .request_raw(RawRequest {
1377                method: Method::POST,
1378                path: "/create",
1379                headers: HeaderMap::new(),
1380                body: Some(b"{}"),
1381                timeout: None,
1382            })
1383            .await
1384            .unwrap();
1385
1386        assert_eq!(response.status, 503);
1387    }
1388
1389    #[tokio::test]
1390    async fn test_request_raw_rejects_cross_host_path() {
1391        let server = MockServer::start().await;
1392        Mock::given(method("GET"))
1393            .respond_with(ResponseTemplate::new(200))
1394            .expect(0)
1395            .mount(&server)
1396            .await;
1397
1398        let client = ApiClient::new(server.uri()).unwrap();
1399        let err = client
1400            .request_raw(RawRequest {
1401                method: Method::GET,
1402                path: "https://evil.example.com/steal",
1403                headers: HeaderMap::new(),
1404                body: None,
1405                timeout: None,
1406            })
1407            .await
1408            .unwrap_err();
1409
1410        assert!(matches!(err, ApiError::InvalidUrl(_)), "got {err:?}");
1411    }
1412
1413    #[test]
1414    fn test_resolve_url_enforces_same_origin() {
1415        let client = ApiClient::new("https://site.atlassian.net").unwrap();
1416
1417        assert_eq!(
1418            client.resolve_url("/rest/api/3/myself").unwrap().as_str(),
1419            "https://site.atlassian.net/rest/api/3/myself"
1420        );
1421        // Relative paths work with or without the leading slash.
1422        assert_eq!(
1423            client.resolve_url("rest/api/3/myself").unwrap().as_str(),
1424            "https://site.atlassian.net/rest/api/3/myself"
1425        );
1426        // Other hosts, scheme downgrades and userinfo tricks are all rejected.
1427        for bad in [
1428            "https://evil.example.com/x",
1429            "http://site.atlassian.net/x",
1430            "https://site.atlassian.net@evil.example.com/",
1431            "//evil.example.com/x",
1432        ] {
1433            let resolved = client.resolve_url(bad);
1434            match resolved {
1435                Err(_) => {}
1436                // A protocol-relative path is not treated as a host by `join`;
1437                // pin the behaviour so a future change cannot silently open it up.
1438                Ok(url) => assert_eq!(url.host_str(), Some("site.atlassian.net"), "{bad}"),
1439            }
1440        }
1441    }
1442
1443    /// Regression: a base URL carrying a path lost its last segment, so the
1444    /// API-gateway form used by scoped API tokens dropped the cloud id.
1445    #[test]
1446    fn test_resolve_url_keeps_the_base_path() {
1447        let client = ApiClient::new("https://api.atlassian.com/ex/jira/cloud-id").unwrap();
1448
1449        assert_eq!(
1450            client.base_url(),
1451            "https://api.atlassian.com/ex/jira/cloud-id/"
1452        );
1453        assert_eq!(
1454            client.resolve_url("/rest/api/3/myself").unwrap().as_str(),
1455            "https://api.atlassian.com/ex/jira/cloud-id/rest/api/3/myself"
1456        );
1457        assert_eq!(
1458            client.resolve_url("rest/api/3/myself").unwrap().as_str(),
1459            "https://api.atlassian.com/ex/jira/cloud-id/rest/api/3/myself"
1460        );
1461
1462        // A base written with the trailing slash resolves the same way.
1463        let client = ApiClient::new("https://api.atlassian.com/ex/jira/cloud-id/").unwrap();
1464
1465        assert_eq!(
1466            client.base_url(),
1467            "https://api.atlassian.com/ex/jira/cloud-id/"
1468        );
1469        assert_eq!(
1470            client.resolve_url("/rest/api/3/myself").unwrap().as_str(),
1471            "https://api.atlassian.com/ex/jira/cloud-id/rest/api/3/myself"
1472        );
1473        assert_eq!(
1474            client.resolve_url("rest/api/3/myself").unwrap().as_str(),
1475            "https://api.atlassian.com/ex/jira/cloud-id/rest/api/3/myself"
1476        );
1477    }
1478
1479    /// The same fix covers a self-hosted product behind a context path, which is
1480    /// the ordinary way Bamboo is deployed. Before this, `/rest/api/latest/plan`
1481    /// against `https://example.com/bamboo` resolved to `https://example.com/rest/...`
1482    /// and 404'd.
1483    #[test]
1484    fn test_resolve_url_keeps_a_context_path() {
1485        let client = ApiClient::new("https://example.com/bamboo").unwrap();
1486
1487        assert_eq!(
1488            client
1489                .resolve_url("/rest/api/latest/plan")
1490                .unwrap()
1491                .as_str(),
1492            "https://example.com/bamboo/rest/api/latest/plan"
1493        );
1494    }
1495
1496    /// Guard against the fix shifting any URL a working profile already resolves.
1497    /// Every shape below is byte-identical before and after normalisation; only
1498    /// the previously broken path-bearing bases move.
1499    #[test]
1500    fn test_normalisation_does_not_move_existing_product_urls() {
1501        for (base, path, expected) in [
1502            (
1503                "https://x.atlassian.net",
1504                "/rest/api/3/myself",
1505                "https://x.atlassian.net/rest/api/3/myself",
1506            ),
1507            (
1508                "https://x.atlassian.net",
1509                "/wiki/download/attachments/1/f.png?version=1",
1510                "https://x.atlassian.net/wiki/download/attachments/1/f.png?version=1",
1511            ),
1512            (
1513                "https://api.bitbucket.org",
1514                "/2.0/repositories/w/r",
1515                "https://api.bitbucket.org/2.0/repositories/w/r",
1516            ),
1517            // Opsgenie's base already carries a path and already ends in a
1518            // slash, and its request paths are relative, so it is untouched.
1519            (
1520                "https://api.opsgenie.com/v2/",
1521                "alerts/123",
1522                "https://api.opsgenie.com/v2/alerts/123",
1523            ),
1524        ] {
1525            let client = ApiClient::new(base).unwrap();
1526            assert_eq!(
1527                client.resolve_url(path).unwrap().as_str(),
1528                expected,
1529                "{base} + {path}"
1530            );
1531        }
1532    }
1533
1534    /// Regression: comparing scheme and host but not port let any other port on
1535    /// the same host receive the profile's credentials.
1536    #[tokio::test]
1537    async fn test_request_raw_rejects_a_different_port_on_the_same_host() {
1538        let victim = MockServer::start().await;
1539        Mock::given(method("GET"))
1540            .respond_with(ResponseTemplate::new(200).set_body_string("secrets"))
1541            .expect(0)
1542            .mount(&victim)
1543            .await;
1544
1545        let server = MockServer::start().await;
1546        let client = ApiClient::new(server.uri()).unwrap();
1547        let err = client
1548            .request_raw(RawRequest {
1549                method: Method::GET,
1550                path: &format!("{}/steal", victim.uri()),
1551                headers: HeaderMap::new(),
1552                body: None,
1553                timeout: None,
1554            })
1555            .await
1556            .unwrap_err();
1557
1558        assert!(matches!(err, ApiError::InvalidUrl(_)), "got {err:?}");
1559    }
1560
1561    /// `safe_join` only sees the first URL, so a same-origin endpoint could
1562    /// otherwise bounce a write-capable request anywhere. The raw client stops
1563    /// at the redirect and hands the 3xx back instead of following it.
1564    #[tokio::test]
1565    async fn test_request_raw_does_not_follow_a_cross_origin_redirect() {
1566        let evil = MockServer::start().await;
1567        Mock::given(method("POST"))
1568            .respond_with(ResponseTemplate::new(200).set_body_string("pwned"))
1569            .expect(0)
1570            .mount(&evil)
1571            .await;
1572
1573        let server = MockServer::start().await;
1574        Mock::given(method("POST"))
1575            .and(path("/rest/api/3/bounce"))
1576            .respond_with(
1577                ResponseTemplate::new(307)
1578                    .insert_header("location", format!("{}/steal", evil.uri()).as_str()),
1579            )
1580            .mount(&server)
1581            .await;
1582
1583        let client = ApiClient::new(server.uri())
1584            .unwrap()
1585            .with_basic_auth("dev@example.com", "token");
1586        let response = client
1587            .request_raw(RawRequest {
1588                method: Method::POST,
1589                path: "/rest/api/3/bounce",
1590                headers: HeaderMap::new(),
1591                body: Some(b"{}"),
1592                timeout: None,
1593            })
1594            .await
1595            .unwrap();
1596
1597        assert_eq!(response.status, 307);
1598        assert!(response.header("location").unwrap().contains("/steal"));
1599        assert_ne!(response.body, b"pwned".to_vec());
1600    }
1601
1602    /// Same-origin redirects are still followed, so ordinary endpoints work.
1603    #[tokio::test]
1604    async fn test_request_raw_follows_a_same_origin_redirect() {
1605        let server = MockServer::start().await;
1606        Mock::given(method("GET"))
1607            .and(path("/from"))
1608            .respond_with(ResponseTemplate::new(302).insert_header("location", "/to"))
1609            .mount(&server)
1610            .await;
1611        Mock::given(method("GET"))
1612            .and(path("/to"))
1613            .respond_with(ResponseTemplate::new(200).set_body_string("arrived"))
1614            .mount(&server)
1615            .await;
1616
1617        let client = ApiClient::new(server.uri()).unwrap();
1618        let response = client
1619            .request_raw(RawRequest {
1620                method: Method::GET,
1621                path: "/from",
1622                headers: HeaderMap::new(),
1623                body: None,
1624                timeout: None,
1625            })
1626            .await
1627            .unwrap();
1628
1629        assert_eq!(response.status, 200);
1630        assert_eq!(response.body, b"arrived".to_vec());
1631    }
1632
1633    /// Attachment downloads depend on the cross-host hop to the media host, so
1634    /// the ordinary client must keep following redirects.
1635    #[tokio::test]
1636    async fn test_get_bytes_still_follows_cross_host_redirects() {
1637        let media = MockServer::start().await;
1638        Mock::given(method("GET"))
1639            .and(path("/file/binary"))
1640            .respond_with(ResponseTemplate::new(200).set_body_bytes(b"BYTES".to_vec()))
1641            .mount(&media)
1642            .await;
1643
1644        let server = MockServer::start().await;
1645        Mock::given(method("GET"))
1646            .and(path("/content/1"))
1647            .respond_with(
1648                ResponseTemplate::new(302)
1649                    .insert_header("location", format!("{}/file/binary", media.uri()).as_str()),
1650            )
1651            .mount(&server)
1652            .await;
1653
1654        let client = ApiClient::new(server.uri()).unwrap();
1655        assert_eq!(client.get_bytes("/content/1").await.unwrap(), b"BYTES");
1656    }
1657
1658    #[test]
1659    fn test_same_origin_compares_scheme_host_and_port() {
1660        let base = Url::parse("https://site.atlassian.net").unwrap();
1661        assert!(same_origin(
1662            &Url::parse("https://site.atlassian.net/x").unwrap(),
1663            &base
1664        ));
1665        // 443 is the known default for https, so an explicit port still matches.
1666        assert!(same_origin(
1667            &Url::parse("https://site.atlassian.net:443/x").unwrap(),
1668            &base
1669        ));
1670        for other in [
1671            "https://site.atlassian.net:8443/x",
1672            "http://site.atlassian.net/x",
1673            "https://evil.example.com/x",
1674        ] {
1675            assert!(
1676                !same_origin(&Url::parse(other).unwrap(), &base),
1677                "{other} must not match"
1678            );
1679        }
1680    }
1681}