1pub mod error;
2pub mod pagination;
3pub mod ratelimit;
4mod response;
5pub mod retry;
6
7use backoff::backoff::Backoff;
8use error::{ApiError, Result};
9use ratelimit::RateLimiter;
10use reqwest::header::HeaderMap;
11use reqwest::{Client, Method, RequestBuilder, StatusCode};
12use response::{error_for_status, log_error_body, log_response, redact_url};
13use retry::{retry_with_backoff, RetryConfig};
14use secrecy::{ExposeSecret, SecretString};
15use serde::de::DeserializeOwned;
16use serde::Serialize;
17use std::fmt;
18use std::time::{Duration, Instant};
19use tracing::{debug, error, warn};
20use url::Url;
21
22#[derive(Clone)]
23pub enum AuthMethod {
24 Basic {
25 username: String,
26 token: SecretString,
27 },
28 Bearer {
29 token: SecretString,
30 },
31 GenieKey {
32 api_key: SecretString,
33 },
34}
35
36impl fmt::Debug for AuthMethod {
37 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
38 match self {
39 AuthMethod::Basic { username, .. } => f
40 .debug_struct("Basic")
41 .field("username", username)
42 .field("token", &"[REDACTED]")
43 .finish(),
44 AuthMethod::Bearer { .. } => f
45 .debug_struct("Bearer")
46 .field("token", &"[REDACTED]")
47 .finish(),
48 AuthMethod::GenieKey { .. } => f
49 .debug_struct("GenieKey")
50 .field("api_key", &"[REDACTED]")
51 .finish(),
52 }
53 }
54}
55
56fn same_origin(a: &Url, b: &Url) -> bool {
62 a.scheme() == b.scheme()
63 && a.host() == b.host()
64 && a.port_or_known_default() == b.port_or_known_default()
65}
66
67pub fn normalize_base_url(mut url: Url) -> Url {
72 if url.cannot_be_a_base() {
73 return url;
74 }
75
76 let path = url.path();
77 if !path.ends_with('/') {
78 url.set_path(&format!("{path}/"));
79 }
80 url
81}
82
83const UNAUTHORIZED_FALLBACK: &str = "Invalid or expired credentials";
85
86const MAX_DETAIL_LEN: usize = 200;
88
89pub(crate) fn unauthorized_message(body: &str) -> String {
97 match unauthorized_detail(body) {
98 Some(detail) => format!("{UNAUTHORIZED_FALLBACK} ({detail})"),
99 None => UNAUTHORIZED_FALLBACK.to_string(),
100 }
101}
102
103fn unauthorized_detail(body: &str) -> Option<String> {
108 let trimmed = body.trim();
109 if trimmed.is_empty() || trimmed.starts_with('<') {
110 return None;
111 }
112
113 let detail = serde_json::from_str::<serde_json::Value>(trimmed)
114 .ok()
115 .and_then(|value| json_error_detail(&value))
116 .unwrap_or_else(|| trimmed.to_string());
117
118 let detail = detail.trim();
119 if detail.is_empty() {
120 return None;
121 }
122 Some(truncate_detail(&scrub_credentials(detail)))
123}
124
125fn json_error_detail(value: &serde_json::Value) -> Option<String> {
127 let direct = ["message", "error_description", "error"]
128 .iter()
129 .find_map(|key| value.get(*key).and_then(|v| v.as_str()))
130 .map(str::to_string);
131
132 direct
133 .or_else(|| {
137 value
138 .get("error")
139 .and_then(|e| e.get("message").or_else(|| e.get("description")))
140 .and_then(|v| v.as_str())
141 .map(str::to_string)
142 })
143 .or_else(|| {
145 value
146 .get("errorMessages")
147 .and_then(|v| v.as_array())
148 .map(|messages| {
149 messages
150 .iter()
151 .filter_map(|m| m.as_str())
152 .collect::<Vec<_>>()
153 .join("; ")
154 })
155 })
156 .map(|detail| detail.trim().to_string())
157 .filter(|detail| !detail.is_empty())
158}
159
160pub(crate) fn scrub_credentials(detail: &str) -> String {
174 const SCHEMES: [&str; 2] = ["bearer ", "basic "];
175
176 let haystack = detail.to_ascii_lowercase();
179
180 let mut out = String::with_capacity(detail.len());
181 let mut cursor = 0;
182
183 while cursor < detail.len() {
184 let found = SCHEMES
185 .iter()
186 .filter_map(|scheme| {
187 haystack[cursor..]
188 .find(scheme)
189 .map(|at| (cursor + at, *scheme))
190 })
191 .min_by_key(|(at, _)| *at);
192
193 let Some((at, scheme)) = found else {
194 out.push_str(&detail[cursor..]);
195 break;
196 };
197
198 let value_start = at + scheme.len();
199 out.push_str(&detail[cursor..value_start]);
200
201 let value_end = detail[value_start..]
202 .find(|c: char| c.is_whitespace() || matches!(c, '"' | '\'' | ',' | '}' | ']' | ')'))
203 .map(|offset| value_start + offset)
204 .unwrap_or(detail.len());
205
206 if is_credential_shaped(&detail[value_start..value_end]) {
207 out.push_str("<redacted>");
208 cursor = value_end;
209 } else {
210 cursor = value_start;
213 }
214 }
215
216 out
217}
218
219fn is_credential_shaped(token: &str) -> bool {
227 const MIN_OPAQUE_LEN: usize = 16;
228
229 if token.len() < 4 {
230 return false;
231 }
232 if !token
233 .chars()
234 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '/' | '=' | '.' | '_' | '-'))
235 {
236 return false;
237 }
238
239 token.len() >= MIN_OPAQUE_LEN || token.contains(['+', '/', '=', '.'])
240}
241
242fn truncate_detail(detail: &str) -> String {
244 if detail.chars().count() <= MAX_DETAIL_LEN {
245 return detail.to_string();
246 }
247 let short: String = detail.chars().take(MAX_DETAIL_LEN).collect();
248 format!("{short}...")
249}
250
251fn retry_after(response: &reqwest::Response) -> Option<Duration> {
254 response
255 .headers()
256 .get(reqwest::header::RETRY_AFTER)?
257 .to_str()
258 .ok()?
259 .trim()
260 .parse::<u64>()
261 .ok()
262 .map(Duration::from_secs)
263}
264
265pub struct RawRequest<'a> {
267 pub method: Method,
268 pub path: &'a str,
270 pub headers: HeaderMap,
271 pub body: Option<&'a [u8]>,
272 pub timeout: Option<Duration>,
274}
275
276#[derive(Debug, Clone)]
278pub struct RawResponse {
279 pub status: u16,
280 pub headers: Vec<(String, String)>,
281 pub body: Vec<u8>,
282}
283
284impl RawResponse {
285 pub fn is_success(&self) -> bool {
286 (200..300).contains(&self.status)
287 }
288
289 pub fn header(&self, name: &str) -> Option<&str> {
291 self.headers
292 .iter()
293 .find(|(key, _)| key.eq_ignore_ascii_case(name))
294 .map(|(_, value)| value.as_str())
295 }
296}
297
298#[derive(Clone)]
299pub struct ApiClient {
300 client: Client,
301 raw_client: Client,
303 base_url: Url,
304 auth: Option<AuthMethod>,
305 retry_config: RetryConfig,
306 rate_limiter: RateLimiter,
307}
308
309fn reject_restructuring_path(path: &str) -> Result<()> {
340 let restructured = |reason: &str| {
341 debug!(
342 path,
343 reason, "Refusing a path the URL parser would restructure"
344 );
345 ApiError::InvalidUrl(url::ParseError::InvalidDomainCharacter)
346 };
347
348 if path.contains('#') {
355 return Err(restructured("fragment marker truncates the path"));
356 }
357
358 let path_only = path.split('?').next().unwrap_or(path);
361
362 if path_only.chars().any(|c| c.is_control()) {
363 return Err(restructured("control character"));
364 }
365 if path_only.contains('\\') {
366 return Err(restructured("backslash is a path separator"));
367 }
368 if path_only.trim_matches(' ') != path_only {
373 return Err(restructured("leading or trailing space is stripped"));
374 }
375
376 for segment in path_only.split('/') {
377 if is_dot_segment(segment) {
378 return Err(restructured("dot component"));
379 }
380 }
381
382 Ok(())
383}
384
385fn is_dot_segment(segment: &str) -> bool {
391 let decoded = decode_once(segment);
392 decoded == "." || decoded == ".."
393}
394
395fn hex_value(byte: u8) -> Option<u8> {
397 match byte {
398 b'0'..=b'9' => Some(byte - b'0'),
399 b'a'..=b'f' => Some(byte - b'a' + 10),
400 b'A'..=b'F' => Some(byte - b'A' + 10),
401 _ => None,
402 }
403}
404
405fn decode_once(segment: &str) -> String {
407 let bytes = segment.as_bytes();
411 let mut out = Vec::with_capacity(bytes.len());
412 let mut i = 0;
413 while i < bytes.len() {
414 if bytes[i] == b'%' && i + 2 < bytes.len() {
415 if let (Some(hi), Some(lo)) = (hex_value(bytes[i + 1]), hex_value(bytes[i + 2])) {
416 out.push(hi * 16 + lo);
417 i += 3;
418 continue;
419 }
420 }
421 out.push(bytes[i]);
422 i += 1;
423 }
424 String::from_utf8_lossy(&out).into_owned()
425}
426
427impl ApiClient {
428 pub fn new(base_url: impl AsRef<str>) -> Result<Self> {
429 let url = Url::parse(base_url.as_ref()).map_err(ApiError::InvalidUrl)?;
430
431 if url.scheme() != "https" {
434 let is_localhost = url
435 .host_str()
436 .map(|h| h == "localhost" || h == "127.0.0.1" || h.starts_with("127."))
437 .unwrap_or(false);
438
439 if !is_localhost {
440 return Err(ApiError::InvalidUrl(
441 url::ParseError::InvalidDomainCharacter,
442 ));
443 }
444 }
445
446 let url = normalize_base_url(url);
447
448 let client = Client::builder()
449 .user_agent(format!("atlassian-cli/{}", env!("CARGO_PKG_VERSION")))
450 .timeout(Duration::from_secs(30))
451 .build()
452 .map_err(ApiError::RequestFailed)?;
453
454 let origin = url.clone();
464 let raw_client = Client::builder()
465 .user_agent(format!("atlassian-cli/{}", env!("CARGO_PKG_VERSION")))
466 .timeout(Duration::from_secs(30))
467 .redirect(reqwest::redirect::Policy::custom(move |attempt| {
468 if attempt.previous().len() >= 10 {
469 attempt.error("too many redirects")
470 } else if same_origin(attempt.url(), &origin) {
471 attempt.follow()
472 } else {
473 attempt.stop()
474 }
475 }))
476 .build()
477 .map_err(ApiError::RequestFailed)?;
478
479 Ok(Self {
480 client,
481 raw_client,
482 base_url: url,
483 auth: None,
484 retry_config: RetryConfig::default(),
485 rate_limiter: RateLimiter::new(),
486 })
487 }
488
489 fn safe_join(&self, path: &str) -> Result<Url> {
492 reject_restructuring_path(path)?;
493
494 let joined = self
495 .base_url
496 .join(path.strip_prefix('/').unwrap_or(path))
497 .map_err(ApiError::InvalidUrl)?;
498
499 if !same_origin(&joined, &self.base_url) {
500 return Err(ApiError::InvalidUrl(
501 url::ParseError::InvalidDomainCharacter,
502 ));
503 }
504
505 Ok(joined)
506 }
507
508 pub fn with_basic_auth(
509 mut self,
510 username: impl Into<String>,
511 token: impl Into<String>,
512 ) -> Self {
513 self.auth = Some(AuthMethod::Basic {
514 username: username.into(),
515 token: SecretString::from(token.into()),
516 });
517 self
518 }
519
520 pub fn with_bearer_token(mut self, token: impl Into<String>) -> Self {
521 self.auth = Some(AuthMethod::Bearer {
522 token: SecretString::from(token.into()),
523 });
524 self
525 }
526
527 pub fn with_genie_key(mut self, api_key: impl Into<String>) -> Self {
528 self.auth = Some(AuthMethod::GenieKey {
529 api_key: SecretString::from(api_key.into()),
530 });
531 self
532 }
533
534 pub fn with_retry_config(mut self, config: RetryConfig) -> Self {
535 self.retry_config = config;
536 self
537 }
538
539 pub fn base_url(&self) -> &str {
540 self.base_url.as_str()
541 }
542
543 pub fn http_client(&self) -> &Client {
545 &self.client
546 }
547
548 pub async fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
549 self.request(Method::GET, path, Option::<&()>::None).await
550 }
551
552 pub async fn post<T: DeserializeOwned, B: Serialize + ?Sized>(
553 &self,
554 path: &str,
555 body: &B,
556 ) -> Result<T> {
557 self.request(Method::POST, path, Some(body)).await
558 }
559
560 pub async fn put<T: DeserializeOwned, B: Serialize + ?Sized>(
561 &self,
562 path: &str,
563 body: &B,
564 ) -> Result<T> {
565 self.request(Method::PUT, path, Some(body)).await
566 }
567
568 pub async fn delete<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
569 self.request(Method::DELETE, path, Option::<&()>::None)
570 .await
571 }
572
573 pub async fn delete_with_body<T: DeserializeOwned, B: Serialize + ?Sized>(
574 &self,
575 path: &str,
576 body: &B,
577 ) -> Result<T> {
578 self.request(Method::DELETE, path, Some(body)).await
579 }
580
581 pub async fn delete_no_content(&self, path: &str) -> Result<()> {
583 if let Some(wait_secs) = self.rate_limiter.check_limit().await {
584 warn!(wait_secs, "Rate limit reached, waiting");
585 tokio::time::sleep(Duration::from_secs(wait_secs)).await;
586 }
587
588 let joined = self.safe_join(path)?;
589
590 debug!(method = "DELETE", url = %redact_url(&joined), "Sending delete (no content) request");
591
592 retry_with_backoff(&self.retry_config, || async {
593 let mut req = self.client.request(Method::DELETE, joined.clone());
594 req = self.apply_auth(req);
595
596 let started = Instant::now();
597 let response = req.send().await.map_err(ApiError::RequestFailed)?;
598
599 self.rate_limiter.update_from_response(&response).await;
600
601 let status = response.status();
602 log_response(&Method::DELETE, &joined, status, started.elapsed());
603 if !status.is_success() {
604 return Err(error_for_status(response, &joined).await);
605 }
606 Ok(())
607 })
608 .await
609 }
610
611 pub async fn response_header(&self, path: &str, header: &str) -> Result<Option<String>> {
624 if let Some(wait_secs) = self.rate_limiter.check_limit().await {
625 warn!(wait_secs, "Rate limit reached, waiting");
626 tokio::time::sleep(Duration::from_secs(wait_secs)).await;
627 }
628
629 let joined = self.safe_join(path)?;
630 debug!(method = "GET", url = %redact_url(&joined), header, "Reading response header");
631
632 let mut req = self.client.request(Method::GET, joined.clone());
633 req = self.apply_auth(req);
634 let started = Instant::now();
635 let response = req.send().await.map_err(ApiError::RequestFailed)?;
636
637 self.rate_limiter.update_from_response(&response).await;
638
639 let status = response.status();
640 log_response(&Method::GET, &joined, status, started.elapsed());
641 if status == StatusCode::UNAUTHORIZED || status == StatusCode::FORBIDDEN {
642 return Err(error_for_status(response, &joined).await);
643 }
644
645 Ok(response
646 .headers()
647 .get(header)
648 .and_then(|value| value.to_str().ok())
649 .map(str::to_string))
650 }
651
652 pub async fn get_text(&self, path: &str) -> Result<String> {
653 if let Some(wait_secs) = self.rate_limiter.check_limit().await {
654 warn!(wait_secs, "Rate limit reached, waiting");
655 tokio::time::sleep(Duration::from_secs(wait_secs)).await;
656 }
657
658 let joined = self.safe_join(path)?;
659
660 debug!(method = "GET", url = %redact_url(&joined), "Sending text request");
661
662 let result = retry_with_backoff(&self.retry_config, || async {
663 let mut req = self.client.request(Method::GET, joined.clone());
664 req = self.apply_auth(req);
665 req = req.header("Accept", "text/plain, */*;q=0.1");
666
667 let started = Instant::now();
668 let response = req.send().await.map_err(ApiError::RequestFailed)?;
669
670 self.rate_limiter.update_from_response(&response).await;
671
672 let status = response.status();
673 log_response(&Method::GET, &joined, status, started.elapsed());
674 if !status.is_success() {
675 return Err(error_for_status(response, &joined).await);
676 }
677 response.text().await.map_err(|e| {
678 error!("Failed to read text response: {}", e);
679 ApiError::InvalidResponse(e.to_string())
680 })
681 })
682 .await?;
683
684 Ok(result)
685 }
686
687 pub fn resolve_url(&self, path: &str) -> Result<Url> {
691 self.safe_join(path)
692 }
693
694 pub async fn request_raw(&self, req: RawRequest<'_>) -> Result<RawResponse> {
705 if let Some(wait_secs) = self.rate_limiter.check_limit().await {
706 warn!(wait_secs, "Rate limit reached, waiting");
707 tokio::time::sleep(Duration::from_secs(wait_secs)).await;
708 }
709
710 let joined = self.safe_join(req.path)?;
711 debug!(
713 method = %req.method,
714 url = %redact_url(&joined),
715 body_bytes = req.body.map(<[u8]>::len),
716 "Sending raw request"
717 );
718
719 let idempotent = matches!(
720 req.method,
721 Method::GET | Method::HEAD | Method::PUT | Method::DELETE | Method::OPTIONS
722 );
723 let mut backoff = self.retry_config.backoff();
727 let mut attempts = 0usize;
728
729 loop {
730 attempts += 1;
731
732 let mut builder = self.raw_client.request(req.method.clone(), joined.clone());
733 builder = self.apply_auth(builder);
734 builder = builder.headers(req.headers.clone());
735 if let Some(body) = req.body {
736 builder = builder.body(body.to_vec());
737 }
738 if let Some(timeout) = req.timeout {
739 builder = builder.timeout(timeout);
740 }
741
742 let started = Instant::now();
743 let response = builder.send().await.map_err(ApiError::RequestFailed)?;
744 self.rate_limiter.update_from_response(&response).await;
745 let status = response.status();
746 log_response(&req.method, &joined, status, started.elapsed());
747
748 let retryable = status == StatusCode::TOO_MANY_REQUESTS || status.is_server_error();
749 if idempotent && retryable && attempts < self.retry_config.max_retries {
750 if let Some(wait) = backoff.next_backoff() {
751 let wait = retry_after(&response).unwrap_or(wait);
754 warn!(
755 status = status.as_u16(),
756 attempt = attempts,
757 wait_ms = wait.as_millis(),
758 "Raw request failed, retrying"
759 );
760 tokio::time::sleep(wait).await;
761 continue;
762 }
763 }
764
765 let headers = response
766 .headers()
767 .iter()
768 .map(|(name, value)| {
769 (
770 name.as_str().to_string(),
771 value.to_str().unwrap_or_default().to_string(),
772 )
773 })
774 .collect();
775 let body = response
776 .bytes()
777 .await
778 .map_err(|err| ApiError::InvalidResponse(err.to_string()))?
779 .to_vec();
780 if !status.is_success() {
781 log_error_body(status, &joined, &String::from_utf8_lossy(&body));
782 }
783
784 return Ok(RawResponse {
785 status: status.as_u16(),
786 headers,
787 body,
788 });
789 }
790 }
791
792 pub async fn get_bytes(&self, path: &str) -> Result<Vec<u8>> {
795 if let Some(wait_secs) = self.rate_limiter.check_limit().await {
796 warn!(wait_secs, "Rate limit reached, waiting");
797 tokio::time::sleep(Duration::from_secs(wait_secs)).await;
798 }
799
800 let joined = self.safe_join(path)?;
801
802 debug!(method = "GET", url = %redact_url(&joined), "Sending bytes request");
803
804 let result = retry_with_backoff(&self.retry_config, || async {
805 let mut req = self.client.request(Method::GET, joined.clone());
806 req = self.apply_auth(req);
807
808 let started = Instant::now();
809 let response = req.send().await.map_err(ApiError::RequestFailed)?;
810
811 self.rate_limiter.update_from_response(&response).await;
812
813 let status = response.status();
814 log_response(&Method::GET, &joined, status, started.elapsed());
815 if !status.is_success() {
816 return Err(error_for_status(response, &joined).await);
817 }
818 response.bytes().await.map(|b| b.to_vec()).map_err(|e| {
819 error!("Failed to read bytes response: {}", e);
820 ApiError::InvalidResponse(e.to_string())
821 })
822 })
823 .await?;
824
825 Ok(result)
826 }
827
828 pub async fn request<T: DeserializeOwned, B: Serialize + ?Sized>(
829 &self,
830 method: Method,
831 path: &str,
832 body: Option<&B>,
833 ) -> Result<T> {
834 if let Some(wait_secs) = self.rate_limiter.check_limit().await {
835 warn!(wait_secs, "Rate limit reached, waiting");
836 tokio::time::sleep(Duration::from_secs(wait_secs)).await;
837 }
838
839 let joined = self.safe_join(path)?;
840
841 debug!(
844 method = %method,
845 url = %redact_url(&joined),
846 has_body = body.is_some(),
847 "Sending request"
848 );
849
850 let result = retry_with_backoff(&self.retry_config, || async {
851 let mut req = self.client.request(method.clone(), joined.clone());
852 req = self.apply_auth(req);
853
854 if let Some(body) = body {
855 req = req.json(body);
856 }
857
858 let started = Instant::now();
859 let response = req.send().await.map_err(ApiError::RequestFailed)?;
860
861 self.rate_limiter.update_from_response(&response).await;
862
863 let status = response.status();
864 log_response(&method, &joined, status, started.elapsed());
865 if !status.is_success() {
866 return Err(error_for_status(response, &joined).await);
867 }
868
869 let bytes = response
870 .bytes()
871 .await
872 .map_err(|e| ApiError::InvalidResponse(e.to_string()))?;
873 let slice: &[u8] = if bytes.iter().all(|b| b.is_ascii_whitespace()) {
879 b"null"
880 } else {
881 &bytes
882 };
883 serde_json::from_slice::<T>(slice).map_err(|e| {
884 error!("Failed to parse JSON response: {}", e);
885 ApiError::InvalidResponse(e.to_string())
886 })
887 })
888 .await?;
889
890 Ok(result)
891 }
892
893 pub fn apply_auth(&self, request: RequestBuilder) -> RequestBuilder {
894 match &self.auth {
895 Some(AuthMethod::Basic { username, token }) => {
896 request.basic_auth(username, Some(token.expose_secret()))
897 }
898 Some(AuthMethod::Bearer { token }) => request.bearer_auth(token.expose_secret()),
899 Some(AuthMethod::GenieKey { api_key }) => request.header(
900 "Authorization",
901 format!("GenieKey {}", api_key.expose_secret()),
902 ),
903 None => request,
904 }
905 }
906
907 pub fn rate_limiter(&self) -> &RateLimiter {
908 &self.rate_limiter
909 }
910}
911
912#[cfg(test)]
913mod tests {
914 use super::*;
915
916 #[test]
920 fn a_restructuring_path_is_refused_before_it_is_joined() {
921 for bad in [
922 "/2.0/repositories/w/r/hooks/..",
923 "/2.0/repositories/w/r/hooks/.",
924 "/2.0/repositories/w/r/hooks/..\\",
925 "/2.0/repositories/w/r/hooks/a\\..\\x",
926 "/2.0/repositories/w/r/hooks/.\t.",
927 "/2.0/repositories/w/r/hooks/.\n.",
928 "/rest/api/3/issue/../../admin",
929 "/2.0/repositories/w/r/hooks/%2e%2e",
933 "/2.0/repositories/w/r/hooks/%2E%2e",
934 "/2.0/repositories/w/r/hooks/.%2e",
935 "/2.0/repositories/w/r/hooks/%2e",
936 "/rest/api/3/issue/ ",
939 ] {
940 assert!(
941 reject_restructuring_path(bad).is_err(),
942 "{bad:?} must be refused"
943 );
944 }
945 }
946
947 #[test]
950 fn decode_once_does_not_panic_on_a_multibyte_char_after_a_percent() {
951 assert_eq!(decode_once("x-%2é"), "x-%2é");
952 assert_eq!(decode_once("%2é"), "%2é");
953 assert_eq!(decode_once("é%"), "é%");
954 assert_eq!(decode_once("%é2"), "%é2");
955 assert!(reject_restructuring_path("/rest/api/3/issue/x-%2é").is_ok());
957 }
958
959 #[test]
963 fn a_fragment_marker_is_refused_anywhere() {
964 assert!(reject_restructuring_path("/2.0/repositories/w/r#x/hooks/u").is_err());
965 assert!(reject_restructuring_path("/rest/api/3/issue/KEY-1#x").is_err());
966 assert!(reject_restructuring_path("/x?jql=a#b").is_err());
967 }
968
969 #[test]
973 fn only_edge_spaces_are_refused() {
974 assert!(reject_restructuring_path("/2.0/repositories/w/r/src/main/my file.txt").is_ok());
975 assert!(reject_restructuring_path("/rest/api/3/issue/ ").is_err());
976 assert!(reject_restructuring_path("/rest/api/3/issue/x ").is_err());
977 assert!(reject_restructuring_path(" /rest/api/3/issue/x").is_err());
978 assert!(reject_restructuring_path("/rest/api/3/search/jql?jql=a = b").is_ok());
980 }
981
982 #[test]
986 fn a_double_encoded_dot_is_a_real_segment() {
987 assert!(reject_restructuring_path("/2.0/repositories/w/r/hooks/%252e%252e").is_ok());
988 assert!(!is_dot_segment("%252e%252e"));
989 assert_eq!(decode_once("%252e%252e"), "%2e%2e");
990 }
991
992 #[test]
994 fn decode_once_leaves_invalid_escapes_alone() {
995 assert_eq!(decode_once("100%"), "100%");
996 assert_eq!(decode_once("a%zzb"), "a%zzb");
997 assert_eq!(decode_once("%7Babc%7D"), "{abc}");
998 }
999
1000 #[test]
1003 fn a_dot_in_the_query_is_not_a_path_component() {
1004 assert!(
1005 reject_restructuring_path("/rest/api/3/search/jql?jql=fixVersion%20in%20(1.0)").is_ok()
1006 );
1007 assert!(reject_restructuring_path("/x?range=a..b").is_ok());
1008 }
1009
1010 use wiremock::matchers::{body_string, header, method, path};
1011 use wiremock::{Mock, MockServer, ResponseTemplate};
1012
1013 #[tokio::test]
1014 async fn test_403_returns_forbidden() {
1015 let server = MockServer::start().await;
1016 Mock::given(method("GET"))
1017 .and(path("test"))
1018 .respond_with(ResponseTemplate::new(403).set_body_string("You do not have access"))
1019 .mount(&server)
1020 .await;
1021
1022 let client = ApiClient::new(server.uri()).unwrap();
1023 let result: error::Result<serde_json::Value> = client.get("/test").await;
1024
1025 match result {
1026 Err(ApiError::Forbidden { message }) => {
1027 assert!(message.contains("You do not have access"));
1028 }
1029 other => panic!("Expected Forbidden, got: {:?}", other),
1030 }
1031 }
1032
1033 #[tokio::test]
1034 async fn test_401_returns_authentication_failed() {
1035 let server = MockServer::start().await;
1036 Mock::given(method("GET"))
1037 .and(path("test"))
1038 .respond_with(ResponseTemplate::new(401))
1039 .mount(&server)
1040 .await;
1041
1042 let client = ApiClient::new(server.uri()).unwrap();
1043 let result: error::Result<serde_json::Value> = client.get("/test").await;
1044
1045 match result {
1046 Err(ApiError::AuthenticationFailed { message }) => {
1047 assert_eq!(message, UNAUTHORIZED_FALLBACK);
1049 }
1050 other => panic!("Expected AuthenticationFailed, got: {:?}", other),
1051 }
1052 }
1053
1054 #[tokio::test]
1057 async fn test_401_surfaces_gateway_scope_message() {
1058 let server = MockServer::start().await;
1059 Mock::given(method("GET"))
1060 .and(path("test"))
1061 .respond_with(
1062 ResponseTemplate::new(401).set_body_string(
1063 r#"{"code":401,"message":"Unauthorized; scope does not match"}"#,
1064 ),
1065 )
1066 .mount(&server)
1067 .await;
1068
1069 let client = ApiClient::new(server.uri()).unwrap();
1070 let result: error::Result<serde_json::Value> = client.get("/test").await;
1071
1072 match result {
1073 Err(ApiError::AuthenticationFailed { message }) => {
1074 assert!(
1075 message.contains("scope does not match"),
1076 "gateway reason was dropped: {message}"
1077 );
1078 }
1079 other => panic!("Expected AuthenticationFailed, got: {:?}", other),
1080 }
1081 }
1082
1083 #[test]
1084 fn unauthorized_message_falls_back_when_body_is_empty() {
1085 assert_eq!(unauthorized_message(""), UNAUTHORIZED_FALLBACK);
1086 assert_eq!(unauthorized_message(" "), UNAUTHORIZED_FALLBACK);
1087 }
1088
1089 #[test]
1090 fn unauthorized_message_keeps_gateway_reason() {
1091 let body = r#"{"code":401,"message":"Unauthorized; scope does not match"}"#;
1092 let message = unauthorized_message(body);
1093 assert!(message.starts_with(UNAUTHORIZED_FALLBACK));
1094 assert!(message.contains("Unauthorized; scope does not match"));
1095 }
1096
1097 #[test]
1098 fn unauthorized_message_reads_jira_error_messages() {
1099 let body = r#"{"errorMessages":["Client must be authenticated"],"errors":{}}"#;
1100 assert!(unauthorized_message(body).contains("Client must be authenticated"));
1101 }
1102
1103 #[test]
1104 fn unauthorized_message_reads_oauth_error_description() {
1105 let body = r#"{"error":"invalid_token","error_description":"The token expired"}"#;
1106 assert!(unauthorized_message(body).contains("The token expired"));
1107 }
1108
1109 #[test]
1110 fn unauthorized_message_reads_a_nested_error_object() {
1111 let body = r#"{"error":{"message":"Token does not have the required scope"}}"#;
1112 let message = unauthorized_message(body);
1113 assert!(message.contains("required scope"));
1114 assert!(
1116 !message.contains("{\"error\""),
1117 "raw JSON leaked: {message}"
1118 );
1119 }
1120
1121 #[test]
1124 fn unauthorized_message_redacts_an_echoed_authorization_header() {
1125 let body =
1126 "rejected request: Authorization: Basic Zm9vOmJhcnNlY3JldA== to /rest/api/3/myself";
1127 let message = unauthorized_message(body);
1128 assert!(
1129 !message.contains("Zm9vOmJhcnNlY3JldA=="),
1130 "the credential survived: {message}"
1131 );
1132 assert!(message.contains("Basic <redacted>"));
1133 assert!(
1134 message.contains("/rest/api/3/myself"),
1135 "the useful part of the body was lost: {message}"
1136 );
1137 }
1138
1139 #[test]
1140 fn unauthorized_message_redacts_a_bearer_token_inside_json() {
1141 let body = r#"{"message":"bad header \"Bearer eyJhbGciOiJIUzI1NiJ9.payload.sig\""}"#;
1142 let message = unauthorized_message(body);
1143 assert!(!message.contains("eyJhbGciOiJIUzI1NiJ9"), "{message}");
1144 assert!(message.contains("Bearer <redacted>"));
1145 }
1146
1147 #[test]
1148 fn unauthorized_message_redacts_every_occurrence() {
1149 let body = "Bearer aGVsbG8gd29ybGQgdG9rZW4= and basic dXNlcjpwYXNzd29yZA==";
1150 let message = unauthorized_message(body);
1151 for secret in ["aGVsbG8gd29ybGQgdG9rZW4=", "dXNlcjpwYXNzd29yZA=="] {
1152 assert!(!message.contains(secret), "{secret} survived: {message}");
1153 }
1154 assert_eq!(message.matches("<redacted>").count(), 2);
1155 }
1156
1157 #[test]
1160 fn scrub_leaves_ordinary_prose_alone() {
1161 for prose in [
1162 "basic authentication is not permitted here",
1163 "Basic auth is not allowed",
1164 "use Bearer tokens instead",
1165 "no credentials at all",
1166 ] {
1167 assert_eq!(scrub_credentials(prose), prose, "prose was mangled");
1168 }
1169 }
1170
1171 #[test]
1172 fn credential_shape_separates_words_from_secrets() {
1173 for word in ["auth", "authentication", "tokens", "a", ""] {
1174 assert!(
1175 !is_credential_shaped(word),
1176 "{word} is a word, not a secret"
1177 );
1178 }
1179 for secret in [
1180 "Zm9vOmJhcg==",
1181 "eyJhbGciOiJIUzI1NiJ9.payload.sig",
1182 "abcdefghijklmnop",
1183 "ATATT3xFfGF0abc_def-123",
1184 ] {
1185 assert!(is_credential_shaped(secret), "{secret} should be redacted");
1186 }
1187 }
1188
1189 #[test]
1190 fn unauthorized_message_keeps_plain_text_body() {
1191 assert!(unauthorized_message("Basic auth is not allowed").contains("Basic auth"));
1192 }
1193
1194 #[test]
1195 fn unauthorized_message_ignores_html_login_page() {
1196 let body = "<!DOCTYPE html><html><body>Sign in</body></html>";
1197 assert_eq!(unauthorized_message(body), UNAUTHORIZED_FALLBACK);
1198 }
1199
1200 #[test]
1201 fn unauthorized_message_truncates_long_bodies() {
1202 let body = format!(r#"{{"message":"{}"}}"#, "x".repeat(500));
1203 let message = unauthorized_message(&body);
1204 assert!(message.contains("..."));
1205 assert!(message.len() < 300, "message was not truncated: {message}");
1206 }
1207
1208 #[test]
1210 fn unauthorized_message_truncates_on_char_boundary() {
1211 let body = format!(r#"{{"message":"{}"}}"#, "é".repeat(500));
1212 assert!(unauthorized_message(&body).contains("..."));
1213 }
1214
1215 #[tokio::test]
1216 async fn test_403_get_text_returns_forbidden() {
1217 let server = MockServer::start().await;
1218 Mock::given(method("GET"))
1219 .and(path("text-endpoint"))
1220 .respond_with(ResponseTemplate::new(403).set_body_string("Forbidden resource"))
1221 .mount(&server)
1222 .await;
1223
1224 let client = ApiClient::new(server.uri()).unwrap();
1225 let result = client.get_text("/text-endpoint").await;
1226
1227 match result {
1228 Err(ApiError::Forbidden { message }) => {
1229 assert!(message.contains("Forbidden resource"));
1230 }
1231 other => panic!("Expected Forbidden, got: {:?}", other),
1232 }
1233 }
1234
1235 #[tokio::test]
1236 async fn test_403_get_bytes_returns_forbidden() {
1237 let server = MockServer::start().await;
1238 Mock::given(method("GET"))
1239 .and(path("bytes-endpoint"))
1240 .respond_with(ResponseTemplate::new(403).set_body_string("Access denied"))
1241 .mount(&server)
1242 .await;
1243
1244 let client = ApiClient::new(server.uri()).unwrap();
1245 let result = client.get_bytes("/bytes-endpoint").await;
1246
1247 match result {
1248 Err(ApiError::Forbidden { message }) => {
1249 assert!(message.contains("Access denied"));
1250 }
1251 other => panic!("Expected Forbidden, got: {:?}", other),
1252 }
1253 }
1254
1255 #[tokio::test]
1258 async fn test_204_no_content_put_succeeds() {
1259 let server = MockServer::start().await;
1260 Mock::given(method("PUT"))
1261 .and(path("issue/AEA-1"))
1262 .respond_with(ResponseTemplate::new(204))
1263 .mount(&server)
1264 .await;
1265
1266 let client = ApiClient::new(server.uri()).unwrap();
1267 let result: error::Result<serde_json::Value> = client
1268 .put("/issue/AEA-1", &serde_json::json!({"fields": {}}))
1269 .await;
1270
1271 match result {
1272 Ok(serde_json::Value::Null) => {}
1273 other => panic!("Expected Ok(Null) for 204, got: {:?}", other),
1274 }
1275 }
1276
1277 #[tokio::test]
1279 async fn test_200_empty_body_succeeds() {
1280 let server = MockServer::start().await;
1281 Mock::given(method("POST"))
1282 .and(path("transitions"))
1283 .respond_with(ResponseTemplate::new(200).set_body_string(" \n"))
1284 .mount(&server)
1285 .await;
1286
1287 let client = ApiClient::new(server.uri()).unwrap();
1288 let result: error::Result<serde_json::Value> =
1289 client.post("/transitions", &serde_json::json!({})).await;
1290
1291 match result {
1292 Ok(serde_json::Value::Null) => {}
1293 other => panic!("Expected Ok(Null) for empty 200, got: {:?}", other),
1294 }
1295 }
1296
1297 #[tokio::test]
1299 async fn test_200_json_body_still_parses() {
1300 let server = MockServer::start().await;
1301 Mock::given(method("GET"))
1302 .and(path("issue/AEA-1"))
1303 .respond_with(
1304 ResponseTemplate::new(200).set_body_json(serde_json::json!({"key": "AEA-1"})),
1305 )
1306 .mount(&server)
1307 .await;
1308
1309 let client = ApiClient::new(server.uri()).unwrap();
1310 let result: serde_json::Value = client.get("/issue/AEA-1").await.unwrap();
1311 assert_eq!(result["key"], "AEA-1");
1312 }
1313
1314 #[tokio::test]
1321 async fn test_request_raw_surfaces_non_2xx_without_erroring() {
1322 let server = MockServer::start().await;
1323 Mock::given(method("GET"))
1324 .and(path("/rest/api/3/issue/NOPE-1"))
1325 .respond_with(
1326 ResponseTemplate::new(404)
1327 .set_body_json(serde_json::json!({"errorMessages": ["Issue does not exist"]})),
1328 )
1329 .mount(&server)
1330 .await;
1331
1332 let client = ApiClient::new(server.uri()).unwrap();
1333 let response = client
1334 .request_raw(RawRequest {
1335 method: Method::GET,
1336 path: "/rest/api/3/issue/NOPE-1",
1337 headers: HeaderMap::new(),
1338 body: None,
1339 timeout: None,
1340 })
1341 .await
1342 .unwrap();
1343
1344 assert_eq!(response.status, 404);
1345 assert!(!response.is_success());
1346 assert!(response
1347 .header("Content-Type")
1348 .unwrap()
1349 .contains("application/json"));
1350 assert!(String::from_utf8_lossy(&response.body).contains("Issue does not exist"));
1351 }
1352
1353 #[tokio::test]
1354 async fn test_request_raw_applies_headers_and_body() {
1355 let server = MockServer::start().await;
1356 Mock::given(method("POST"))
1357 .and(path("/rest/api/3/issue"))
1358 .and(header("X-Atlassian-Token", "no-check"))
1359 .and(body_string("{\"fields\":{}}"))
1360 .respond_with(
1361 ResponseTemplate::new(201).set_body_json(serde_json::json!({"key": "A-1"})),
1362 )
1363 .mount(&server)
1364 .await;
1365
1366 let mut headers = HeaderMap::new();
1367 headers.insert("X-Atlassian-Token", "no-check".parse().unwrap());
1368
1369 let client = ApiClient::new(server.uri()).unwrap();
1370 let response = client
1371 .request_raw(RawRequest {
1372 method: Method::POST,
1373 path: "/rest/api/3/issue",
1374 headers,
1375 body: Some(b"{\"fields\":{}}"),
1376 timeout: None,
1377 })
1378 .await
1379 .unwrap();
1380
1381 assert_eq!(response.status, 201);
1382 }
1383
1384 #[tokio::test]
1385 async fn test_request_raw_retries_5xx_for_get() {
1386 let server = MockServer::start().await;
1387 Mock::given(method("GET"))
1388 .and(path("/flaky"))
1389 .respond_with(ResponseTemplate::new(500))
1390 .expect(3)
1391 .mount(&server)
1392 .await;
1393
1394 let client = ApiClient::new(server.uri())
1395 .unwrap()
1396 .with_retry_config(RetryConfig {
1397 initial_interval: Duration::from_millis(1),
1398 ..RetryConfig::default()
1399 });
1400 let response = client
1401 .request_raw(RawRequest {
1402 method: Method::GET,
1403 path: "/flaky",
1404 headers: HeaderMap::new(),
1405 body: None,
1406 timeout: None,
1407 })
1408 .await
1409 .unwrap();
1410
1411 assert_eq!(response.status, 500);
1412 }
1413
1414 #[tokio::test]
1417 async fn test_request_raw_never_retries_post() {
1418 let server = MockServer::start().await;
1419 Mock::given(method("POST"))
1420 .and(path("/create"))
1421 .respond_with(ResponseTemplate::new(503))
1422 .expect(1)
1423 .mount(&server)
1424 .await;
1425
1426 let client = ApiClient::new(server.uri())
1427 .unwrap()
1428 .with_retry_config(RetryConfig {
1429 initial_interval: Duration::from_millis(1),
1430 ..RetryConfig::default()
1431 });
1432 let response = client
1433 .request_raw(RawRequest {
1434 method: Method::POST,
1435 path: "/create",
1436 headers: HeaderMap::new(),
1437 body: Some(b"{}"),
1438 timeout: None,
1439 })
1440 .await
1441 .unwrap();
1442
1443 assert_eq!(response.status, 503);
1444 }
1445
1446 #[tokio::test]
1447 async fn test_request_raw_rejects_cross_host_path() {
1448 let server = MockServer::start().await;
1449 Mock::given(method("GET"))
1450 .respond_with(ResponseTemplate::new(200))
1451 .expect(0)
1452 .mount(&server)
1453 .await;
1454
1455 let client = ApiClient::new(server.uri()).unwrap();
1456 let err = client
1457 .request_raw(RawRequest {
1458 method: Method::GET,
1459 path: "https://evil.example.com/steal",
1460 headers: HeaderMap::new(),
1461 body: None,
1462 timeout: None,
1463 })
1464 .await
1465 .unwrap_err();
1466
1467 assert!(matches!(err, ApiError::InvalidUrl(_)), "got {err:?}");
1468 }
1469
1470 #[test]
1471 fn test_resolve_url_enforces_same_origin() {
1472 let client = ApiClient::new("https://site.atlassian.net").unwrap();
1473
1474 assert_eq!(
1475 client.resolve_url("/rest/api/3/myself").unwrap().as_str(),
1476 "https://site.atlassian.net/rest/api/3/myself"
1477 );
1478 assert_eq!(
1480 client.resolve_url("rest/api/3/myself").unwrap().as_str(),
1481 "https://site.atlassian.net/rest/api/3/myself"
1482 );
1483 for bad in [
1485 "https://evil.example.com/x",
1486 "http://site.atlassian.net/x",
1487 "https://site.atlassian.net@evil.example.com/",
1488 "//evil.example.com/x",
1489 ] {
1490 let resolved = client.resolve_url(bad);
1491 match resolved {
1492 Err(_) => {}
1493 Ok(url) => assert_eq!(url.host_str(), Some("site.atlassian.net"), "{bad}"),
1496 }
1497 }
1498 }
1499
1500 #[test]
1503 fn test_resolve_url_keeps_the_base_path() {
1504 let client = ApiClient::new("https://api.atlassian.com/ex/jira/cloud-id").unwrap();
1505
1506 assert_eq!(
1507 client.base_url(),
1508 "https://api.atlassian.com/ex/jira/cloud-id/"
1509 );
1510 assert_eq!(
1511 client.resolve_url("/rest/api/3/myself").unwrap().as_str(),
1512 "https://api.atlassian.com/ex/jira/cloud-id/rest/api/3/myself"
1513 );
1514 assert_eq!(
1515 client.resolve_url("rest/api/3/myself").unwrap().as_str(),
1516 "https://api.atlassian.com/ex/jira/cloud-id/rest/api/3/myself"
1517 );
1518
1519 let client = ApiClient::new("https://api.atlassian.com/ex/jira/cloud-id/").unwrap();
1521
1522 assert_eq!(
1523 client.base_url(),
1524 "https://api.atlassian.com/ex/jira/cloud-id/"
1525 );
1526 assert_eq!(
1527 client.resolve_url("/rest/api/3/myself").unwrap().as_str(),
1528 "https://api.atlassian.com/ex/jira/cloud-id/rest/api/3/myself"
1529 );
1530 assert_eq!(
1531 client.resolve_url("rest/api/3/myself").unwrap().as_str(),
1532 "https://api.atlassian.com/ex/jira/cloud-id/rest/api/3/myself"
1533 );
1534 }
1535
1536 #[test]
1541 fn test_resolve_url_keeps_a_context_path() {
1542 let client = ApiClient::new("https://example.com/bamboo").unwrap();
1543
1544 assert_eq!(
1545 client
1546 .resolve_url("/rest/api/latest/plan")
1547 .unwrap()
1548 .as_str(),
1549 "https://example.com/bamboo/rest/api/latest/plan"
1550 );
1551 }
1552
1553 #[test]
1557 fn test_normalisation_does_not_move_existing_product_urls() {
1558 for (base, path, expected) in [
1559 (
1560 "https://x.atlassian.net",
1561 "/rest/api/3/myself",
1562 "https://x.atlassian.net/rest/api/3/myself",
1563 ),
1564 (
1565 "https://x.atlassian.net",
1566 "/wiki/download/attachments/1/f.png?version=1",
1567 "https://x.atlassian.net/wiki/download/attachments/1/f.png?version=1",
1568 ),
1569 (
1570 "https://api.bitbucket.org",
1571 "/2.0/repositories/w/r",
1572 "https://api.bitbucket.org/2.0/repositories/w/r",
1573 ),
1574 (
1577 "https://api.opsgenie.com/v2/",
1578 "alerts/123",
1579 "https://api.opsgenie.com/v2/alerts/123",
1580 ),
1581 ] {
1582 let client = ApiClient::new(base).unwrap();
1583 assert_eq!(
1584 client.resolve_url(path).unwrap().as_str(),
1585 expected,
1586 "{base} + {path}"
1587 );
1588 }
1589 }
1590
1591 #[tokio::test]
1594 async fn test_request_raw_rejects_a_different_port_on_the_same_host() {
1595 let victim = MockServer::start().await;
1596 Mock::given(method("GET"))
1597 .respond_with(ResponseTemplate::new(200).set_body_string("secrets"))
1598 .expect(0)
1599 .mount(&victim)
1600 .await;
1601
1602 let server = MockServer::start().await;
1603 let client = ApiClient::new(server.uri()).unwrap();
1604 let err = client
1605 .request_raw(RawRequest {
1606 method: Method::GET,
1607 path: &format!("{}/steal", victim.uri()),
1608 headers: HeaderMap::new(),
1609 body: None,
1610 timeout: None,
1611 })
1612 .await
1613 .unwrap_err();
1614
1615 assert!(matches!(err, ApiError::InvalidUrl(_)), "got {err:?}");
1616 }
1617
1618 #[tokio::test]
1622 async fn test_request_raw_does_not_follow_a_cross_origin_redirect() {
1623 let evil = MockServer::start().await;
1624 Mock::given(method("POST"))
1625 .respond_with(ResponseTemplate::new(200).set_body_string("pwned"))
1626 .expect(0)
1627 .mount(&evil)
1628 .await;
1629
1630 let server = MockServer::start().await;
1631 Mock::given(method("POST"))
1632 .and(path("/rest/api/3/bounce"))
1633 .respond_with(
1634 ResponseTemplate::new(307)
1635 .insert_header("location", format!("{}/steal", evil.uri()).as_str()),
1636 )
1637 .mount(&server)
1638 .await;
1639
1640 let client = ApiClient::new(server.uri())
1641 .unwrap()
1642 .with_basic_auth("dev@example.com", "token");
1643 let response = client
1644 .request_raw(RawRequest {
1645 method: Method::POST,
1646 path: "/rest/api/3/bounce",
1647 headers: HeaderMap::new(),
1648 body: Some(b"{}"),
1649 timeout: None,
1650 })
1651 .await
1652 .unwrap();
1653
1654 assert_eq!(response.status, 307);
1655 assert!(response.header("location").unwrap().contains("/steal"));
1656 assert_ne!(response.body, b"pwned".to_vec());
1657 }
1658
1659 #[tokio::test]
1661 async fn test_request_raw_follows_a_same_origin_redirect() {
1662 let server = MockServer::start().await;
1663 Mock::given(method("GET"))
1664 .and(path("/from"))
1665 .respond_with(ResponseTemplate::new(302).insert_header("location", "/to"))
1666 .mount(&server)
1667 .await;
1668 Mock::given(method("GET"))
1669 .and(path("/to"))
1670 .respond_with(ResponseTemplate::new(200).set_body_string("arrived"))
1671 .mount(&server)
1672 .await;
1673
1674 let client = ApiClient::new(server.uri()).unwrap();
1675 let response = client
1676 .request_raw(RawRequest {
1677 method: Method::GET,
1678 path: "/from",
1679 headers: HeaderMap::new(),
1680 body: None,
1681 timeout: None,
1682 })
1683 .await
1684 .unwrap();
1685
1686 assert_eq!(response.status, 200);
1687 assert_eq!(response.body, b"arrived".to_vec());
1688 }
1689
1690 #[tokio::test]
1693 async fn test_get_bytes_still_follows_cross_host_redirects() {
1694 let media = MockServer::start().await;
1695 Mock::given(method("GET"))
1696 .and(path("/file/binary"))
1697 .respond_with(ResponseTemplate::new(200).set_body_bytes(b"BYTES".to_vec()))
1698 .mount(&media)
1699 .await;
1700
1701 let server = MockServer::start().await;
1702 Mock::given(method("GET"))
1703 .and(path("/content/1"))
1704 .respond_with(
1705 ResponseTemplate::new(302)
1706 .insert_header("location", format!("{}/file/binary", media.uri()).as_str()),
1707 )
1708 .mount(&server)
1709 .await;
1710
1711 let client = ApiClient::new(server.uri()).unwrap();
1712 assert_eq!(client.get_bytes("/content/1").await.unwrap(), b"BYTES");
1713 }
1714
1715 #[test]
1716 fn test_same_origin_compares_scheme_host_and_port() {
1717 let base = Url::parse("https://site.atlassian.net").unwrap();
1718 assert!(same_origin(
1719 &Url::parse("https://site.atlassian.net/x").unwrap(),
1720 &base
1721 ));
1722 assert!(same_origin(
1724 &Url::parse("https://site.atlassian.net:443/x").unwrap(),
1725 &base
1726 ));
1727 for other in [
1728 "https://site.atlassian.net:8443/x",
1729 "http://site.atlassian.net/x",
1730 "https://evil.example.com/x",
1731 ] {
1732 assert!(
1733 !same_origin(&Url::parse(other).unwrap(), &base),
1734 "{other} must not match"
1735 );
1736 }
1737 }
1738}