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
54fn 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
65pub 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
81const UNAUTHORIZED_FALLBACK: &str = "Invalid or expired credentials";
83
84const MAX_DETAIL_LEN: usize = 200;
86
87async 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
101fn 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
109fn 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
131fn 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 .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 .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
166fn scrub_credentials(detail: &str) -> String {
180 const SCHEMES: [&str; 2] = ["bearer ", "basic "];
181
182 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 cursor = value_start;
219 }
220 }
221
222 out
223}
224
225fn 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
248fn 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
257fn 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
271pub struct RawRequest<'a> {
273 pub method: Method,
274 pub path: &'a str,
276 pub headers: HeaderMap,
277 pub body: Option<&'a [u8]>,
278 pub timeout: Option<Duration>,
280}
281
282#[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 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 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 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 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 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 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 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 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 pub fn resolve_url(&self, path: &str) -> Result<Url> {
655 self.safe_join(path)
656 }
657
658 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 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 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 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 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 assert_eq!(message, UNAUTHORIZED_FALLBACK);
992 }
993 other => panic!("Expected AuthenticationFailed, got: {:?}", other),
994 }
995 }
996
997 #[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 assert!(
1059 !message.contains("{\"error\""),
1060 "raw JSON leaked: {message}"
1061 );
1062 }
1063
1064 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 assert_eq!(
1423 client.resolve_url("rest/api/3/myself").unwrap().as_str(),
1424 "https://site.atlassian.net/rest/api/3/myself"
1425 );
1426 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 Ok(url) => assert_eq!(url.host_str(), Some("site.atlassian.net"), "{bad}"),
1439 }
1440 }
1441 }
1442
1443 #[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 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 #[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 #[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 (
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 #[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 #[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 #[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 #[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 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}