1use std::sync::Arc;
2use std::sync::atomic::{AtomicBool, Ordering};
3use std::time::{Duration, Instant};
4
5use futures::future::BoxFuture;
6use futures::{Stream, TryStreamExt as _};
7use reqwest::header::{ACCEPT, AUTHORIZATION, HeaderValue};
8use reqwest::{Method, StatusCode};
9use secrecy::{ExposeSecret as _, SecretString};
10use serde::de::DeserializeOwned;
11use serde_json::Value;
12use url::Url;
13
14use crate::api::error::{api_error, unexpected};
15use crate::api::projection::Projection;
16use crate::error::{Result, YtError};
17use crate::http::{is_transient, retry_delay};
18use crate::instance::InstanceUrl;
19
20const TRANSFER_TIMEOUT: Duration = Duration::from_secs(60 * 60);
22pub(crate) const LIST_PAGE: usize = 500;
23
24pub trait TokenSource: Send + Sync {
26 fn bearer(&self) -> BoxFuture<'_, Result<SecretString>>;
27 fn refresh_after_unauthorized(&self) -> BoxFuture<'_, Result<bool>>;
29 fn label(&self) -> &str;
31}
32
33pub struct Client {
34 http: reqwest::Client,
35 instance: InstanceUrl,
36 api: Url,
37 auth: Arc<dyn TokenSource>,
38 retries: u32,
39 written: AtomicBool,
40}
41
42struct Response {
43 status: StatusCode,
44 body: String,
45}
46
47#[derive(Clone, Copy)]
48struct Page {
49 skip: usize,
50 size: usize,
51 done: bool,
52}
53
54impl Client {
55 pub fn new(
56 http: reqwest::Client,
57 instance: InstanceUrl,
58 auth: Arc<dyn TokenSource>,
59 retries: u32,
60 ) -> Self {
61 let api = instance.api();
62 Self {
63 http,
64 instance,
65 api,
66 auth,
67 retries,
68 written: AtomicBool::new(false),
69 }
70 }
71
72 pub fn has_written(&self) -> bool {
74 self.written.load(Ordering::SeqCst)
75 }
76
77 pub fn instance(&self) -> &InstanceUrl {
78 &self.instance
79 }
80
81 pub async fn get<T: Projection>(&self, path: &str, query: &[(&str, &str)]) -> Result<T> {
83 let url = self.url(path, query, Some(T::FIELDS))?;
84 let response = self.execute(Method::GET, url.clone(), None, true).await?;
85 let body = self.success(&Method::GET, &url, response)?;
86 decode(&url, &body)
87 }
88
89 pub async fn post<T: Projection>(&self, path: &str, body: &Value) -> Result<T> {
91 let url = self.url(path, &[], Some(T::FIELDS))?;
92 let response = self
93 .execute(Method::POST, url.clone(), Some(body), false)
94 .await?;
95 let text = self.success(&Method::POST, &url, response)?;
96 decode(&url, &text)
97 }
98
99 pub async fn post_query<T: Projection>(&self, path: &str, body: &Value) -> Result<T> {
101 let url = self.url(path, &[], Some(T::FIELDS))?;
102 let response = self
103 .execute(Method::POST, url.clone(), Some(body), true)
104 .await?;
105 let text = self.success(&Method::POST, &url, response)?;
106 decode(&url, &text)
107 }
108
109 pub async fn post_unit(&self, path: &str, body: &Value) -> Result<()> {
111 let url = self.url(path, &[], None)?;
112 let response = self
113 .execute(Method::POST, url.clone(), Some(body), false)
114 .await?;
115 self.success(&Method::POST, &url, response).map(drop)
116 }
117
118 pub async fn delete(&self, path: &str) -> Result<()> {
119 let url = self.url(path, &[], None)?;
120 let response = self
121 .execute(Method::DELETE, url.clone(), None, false)
122 .await?;
123 self.success(&Method::DELETE, &url, response).map(drop)
124 }
125
126 pub async fn upload<T: Projection>(
128 &self,
129 path: &str,
130 files: &[(String, Vec<u8>)],
131 ) -> Result<T> {
132 let url = self.url(path, &[], Some(T::FIELDS))?;
133 let response = self
134 .send_once(Method::POST, url.clone(), |request| {
135 let mut form = reqwest::multipart::Form::new();
136 for (name, data) in files {
137 form = form.part(
138 "file",
139 reqwest::multipart::Part::bytes(data.clone()).file_name(name.clone()),
140 );
141 }
142 request.multipart(form)
143 })
144 .await?;
145 let status = response.status();
146 let body = response.text().await.map_err(network)?;
147 let text = self.success(&Method::POST, &url, Response { status, body })?;
148 decode(&url, &text)
149 }
150
151 pub async fn download(&self, url: &str) -> Result<reqwest::Response> {
153 let url = self.instance.join(url);
154 let response = self
155 .send_once(Method::GET, url.clone(), |request| request)
156 .await?;
157 let status = response.status();
158 if status.is_success() {
159 return Ok(response);
160 }
161 let body = response.text().await.unwrap_or_default();
162 Err(self.failure(&Method::GET, &url, status, &body))
163 }
164
165 async fn send_once(
167 &self,
168 method: Method,
169 url: Url,
170 build: impl Fn(reqwest::RequestBuilder) -> reqwest::RequestBuilder,
171 ) -> Result<reqwest::Response> {
172 self.check_origin(&url)?;
173 let mut refreshed = false;
174 loop {
175 let request = self
176 .http
177 .request(method.clone(), url.clone())
178 .header(AUTHORIZATION, self.authorization().await?);
179 let response = match build(request).timeout(TRANSFER_TIMEOUT).send().await {
180 Ok(response) => response,
181 Err(error) => {
182 if method != Method::GET && !error.is_connect() {
183 self.written.store(true, Ordering::SeqCst);
184 }
185 return Err(network(error));
186 }
187 };
188 if response.status() == StatusCode::UNAUTHORIZED && !refreshed {
189 refreshed = true;
190 if self.auth.refresh_after_unauthorized().await? {
191 continue;
192 }
193 }
194 if method != Method::GET && response.status() != StatusCode::UNAUTHORIZED {
195 self.written.store(true, Ordering::SeqCst);
196 }
197 return Ok(response);
198 }
199 }
200
201 fn check_origin(&self, url: &Url) -> Result<()> {
202 if url.origin() == self.api.origin() {
203 return Ok(());
204 }
205 Err(YtError::Usage {
206 message: format!(
207 "refusing to send the credential to {}: it is not {}",
208 url.origin().ascii_serialization(),
209 self.instance
210 ),
211 }
212 .into())
213 }
214
215 async fn authorization(&self) -> Result<HeaderValue> {
216 let token = self.auth.bearer().await?;
217 let mut value = HeaderValue::from_str(&format!("Bearer {}", token.expose_secret()))
218 .map_err(|_| YtError::Usage {
219 message: "the credential contains characters that cannot be sent".into(),
220 })?;
221 value.set_sensitive(true);
222 Ok(value)
223 }
224
225 pub async fn raw(
227 &self,
228 method: Method,
229 path_and_query: &str,
230 body: Option<&Value>,
231 ) -> Result<Option<String>> {
232 let relative = path_and_query.trim_start_matches('/');
233 let relative = relative.strip_prefix("api/").unwrap_or(relative);
234 let url = self.api.join(relative).map_err(|error| YtError::Usage {
235 message: format!("invalid API path '{path_and_query}': {error}"),
236 })?;
237 let idempotent = method == Method::GET;
238 let response = self
239 .execute(method.clone(), url.clone(), body, idempotent)
240 .await?;
241 let text = self.success(&method, &url, response)?;
242 if text.trim().is_empty() {
243 return Ok(None);
244 }
245 if serde_json::from_str::<serde::de::IgnoredAny>(&text).is_err() {
246 return Err(YtError::Api {
247 method: method.to_string(),
248 path: url.path().to_string(),
249 status: 200,
250 message: unexpected(StatusCode::OK, &text),
251 help: None,
252 }
253 .into());
254 }
255 Ok(Some(text))
256 }
257
258 pub async fn get_all<T>(&self, path: &str) -> Result<Vec<T>>
260 where
261 T: Projection + Send + 'static,
262 {
263 self.paginate(path.to_string(), Vec::new(), LIST_PAGE)
264 .try_collect()
265 .await
266 }
267
268 pub fn paginate<T>(
270 &self,
271 path: String,
272 query: Vec<(String, String)>,
273 page_size: usize,
274 ) -> impl Stream<Item = Result<T>> + Send + '_
275 where
276 T: Projection + Send + 'static,
277 {
278 let start = Page {
279 skip: 0,
280 size: page_size.max(1),
281 done: false,
282 };
283 futures::stream::try_unfold(start, move |page| {
284 let path = path.clone();
285 let query = query.clone();
286 async move {
287 self.next_page::<T>(&path, &query, page).await.map(|next| {
288 next.map(|(items, page)| {
289 (
290 futures::stream::iter(items.into_iter().map(Ok::<T, miette::Report>)),
291 page,
292 )
293 })
294 })
295 }
296 })
297 .try_flatten()
298 }
299
300 async fn next_page<T: Projection>(
301 &self,
302 path: &str,
303 query: &[(String, String)],
304 mut page: Page,
305 ) -> Result<Option<(Vec<T>, Page)>> {
306 if page.done {
307 return Ok(None);
308 }
309 loop {
310 let skip = page.skip.to_string();
311 let top = page.size.to_string();
312 let mut pairs: Vec<(&str, &str)> = query
313 .iter()
314 .map(|(key, value)| (key.as_str(), value.as_str()))
315 .collect();
316 pairs.push(("$skip", &skip));
317 pairs.push(("$top", &top));
318 let url = self.url(path, &pairs, Some(T::FIELDS))?;
319 let response = self.execute(Method::GET, url.clone(), None, true).await?;
320 if response.status == StatusCode::BAD_REQUEST
321 && let Some(limit) = export_limit(&response.body).filter(|limit| *limit < page.size)
322 {
323 page.size = limit;
324 continue;
325 }
326 let body = self.success(&Method::GET, &url, response)?;
327 let items: Vec<T> = decode(&url, &body)?;
328 page.skip += items.len();
329 page.done = items.len() < page.size;
330 return Ok(Some((items, page)));
331 }
332 }
333
334 fn url(&self, path: &str, query: &[(&str, &str)], fields: Option<&str>) -> Result<Url> {
335 let mut url = self.api.join(path).map_err(|error| YtError::Usage {
336 message: format!("invalid API path '{path}': {error}"),
337 })?;
338 if !query.is_empty() || fields.is_some() {
339 let mut pairs = url.query_pairs_mut();
340 for (key, value) in query {
341 pairs.append_pair(key, value);
342 }
343 if let Some(fields) = fields {
344 pairs.append_pair("fields", fields);
345 }
346 }
347 Ok(url)
348 }
349
350 async fn execute(
351 &self,
352 method: Method,
353 url: Url,
354 body: Option<&Value>,
355 idempotent: bool,
356 ) -> Result<Response> {
357 self.check_origin(&url)?;
358 let mut attempt = 0;
359 let mut refreshed = false;
360 loop {
361 let authorization = self.authorization().await?;
362 let mut request = self
363 .http
364 .request(method.clone(), url.clone())
365 .header(AUTHORIZATION, authorization)
366 .header(ACCEPT, "application/json");
367 if let Some(body) = body {
368 request = request.json(body);
369 }
370 let started = Instant::now();
371 match request.send().await {
372 Ok(response) => {
373 let status = response.status();
374 log::debug!(
375 "{method} {} -> {} in {} ms",
376 url.path(),
377 status.as_u16(),
378 started.elapsed().as_millis()
379 );
380 log::trace!("query: {}", url.query().unwrap_or(""));
381 if status == StatusCode::UNAUTHORIZED && !refreshed {
382 refreshed = true;
383 if self.auth.refresh_after_unauthorized().await? {
384 continue;
385 }
386 }
387 if !idempotent && status != StatusCode::UNAUTHORIZED {
388 self.written.store(true, Ordering::SeqCst);
389 }
390 if idempotent && is_transient(status) && attempt < self.retries {
391 tokio::time::sleep(retry_delay(attempt, Some(response.headers()))).await;
392 attempt += 1;
393 continue;
394 }
395 let body = response.text().await.map_err(network)?;
396 return Ok(Response { status, body });
397 }
398 Err(error)
399 if idempotent
400 && attempt < self.retries
401 && (error.is_connect() || error.is_timeout()) =>
402 {
403 log::debug!("{method} {} failed, retrying: {error}", url.path());
404 tokio::time::sleep(retry_delay(attempt, None)).await;
405 attempt += 1;
406 }
407 Err(error) => {
408 if !idempotent && !error.is_connect() {
409 self.written.store(true, Ordering::SeqCst);
410 }
411 return Err(network(error));
412 }
413 }
414 }
415 }
416
417 fn success(&self, method: &Method, url: &Url, response: Response) -> Result<String> {
418 if response.status.is_success() {
419 return Ok(response.body);
420 }
421 Err(self.failure(method, url, response.status, &response.body))
422 }
423
424 fn failure(
425 &self,
426 method: &Method,
427 url: &Url,
428 status: StatusCode,
429 body: &str,
430 ) -> miette::Report {
431 if status == StatusCode::UNAUTHORIZED {
432 return YtError::CredentialRejected {
433 profile: self.auth.label().to_string(),
434 }
435 .into();
436 }
437 api_error(method, url.path(), status, body)
438 }
439}
440
441fn decode<T: DeserializeOwned>(url: &Url, body: &str) -> Result<T> {
442 let mut deserializer = serde_json::Deserializer::from_str(body);
443 serde_path_to_error::deserialize(&mut deserializer).map_err(|error| {
444 let message = if body.trim_start().starts_with('<') {
445 unexpected(StatusCode::OK, body)
446 } else {
447 format!(
448 "could not read the response at {}: {}",
449 error.path(),
450 error.inner()
451 )
452 };
453 YtError::Api {
454 method: "GET".into(),
455 path: url.path().to_string(),
456 status: 200,
457 message,
458 help: None,
459 }
460 .into()
461 })
462}
463
464fn export_limit(body: &str) -> Option<usize> {
465 let rest = body.split("greater than limit").nth(1)?;
466 rest.trim_start()
467 .chars()
468 .take_while(char::is_ascii_digit)
469 .collect::<String>()
470 .parse()
471 .ok()
472}
473
474fn network(error: reqwest::Error) -> miette::Report {
475 YtError::Network(crate::redact::redact(&error.to_string())).into()
476}
477
478#[cfg(test)]
479mod tests {
480 use std::sync::atomic::{AtomicUsize, Ordering};
481 use std::time::Duration;
482
483 use serde::Deserialize;
484 use serde_json::json;
485 use wiremock::matchers::{body_json, header, method, path, query_param};
486 use wiremock::{Mock, MockServer, ResponseTemplate};
487
488 use super::*;
489 use crate::http::HttpConfig;
490
491 const TOKEN: &str = "perm-dGVzdA==.Y2xpZW50.secret123456";
492
493 struct Fixed {
494 refreshes: AtomicUsize,
495 }
496
497 impl TokenSource for Fixed {
498 fn bearer(&self) -> BoxFuture<'_, Result<SecretString>> {
499 Box::pin(async { Ok(SecretString::from(TOKEN.to_string())) })
500 }
501 fn refresh_after_unauthorized(&self) -> BoxFuture<'_, Result<bool>> {
502 self.refreshes.fetch_add(1, Ordering::SeqCst);
503 Box::pin(async { Ok(false) })
504 }
505 fn label(&self) -> &str {
506 "acme"
507 }
508 }
509
510 #[derive(Debug, Deserialize, PartialEq)]
511 #[serde(rename_all = "camelCase")]
512 struct Item {
513 id_readable: String,
514 }
515
516 impl Projection for Item {
517 const FIELDS: &'static str = "idReadable";
518 }
519
520 fn client_for(server: &MockServer, auth: Arc<Fixed>) -> Client {
521 let http = crate::http::client(&HttpConfig {
522 timeout: Duration::from_secs(5),
523 retries: 3,
524 ca_file: None,
525 })
526 .unwrap();
527 Client::new(http, InstanceUrl::parse(&server.uri()).unwrap(), auth, 3)
528 }
529
530 fn fixed() -> Arc<Fixed> {
531 Arc::new(Fixed {
532 refreshes: AtomicUsize::new(0),
533 })
534 }
535
536 fn item(id: &str) -> serde_json::Value {
537 json!({"idReadable": id, "$type": "Issue"})
538 }
539
540 #[tokio::test]
541 async fn get_sends_fields_and_the_token() {
542 let server = MockServer::start().await;
543 Mock::given(method("GET"))
544 .and(path("/api/issues/WEB-1"))
545 .and(query_param("fields", "idReadable"))
546 .and(header("authorization", format!("Bearer {TOKEN}").as_str()))
547 .respond_with(ResponseTemplate::new(200).set_body_json(item("WEB-1")))
548 .expect(1)
549 .mount(&server)
550 .await;
551 let found: Item = client_for(&server, fixed())
552 .get("issues/WEB-1", &[])
553 .await
554 .unwrap();
555 assert_eq!(
556 found,
557 Item {
558 id_readable: "WEB-1".into()
559 }
560 );
561 }
562
563 #[tokio::test]
564 async fn unauthorized_asks_for_a_refresh_once_and_never_shows_the_token() {
565 let server = MockServer::start().await;
566 Mock::given(method("GET"))
567 .respond_with(ResponseTemplate::new(401).set_body_json(
568 json!({"error": "Unauthorized", "error_description": "Invalid token"}),
569 ))
570 .mount(&server)
571 .await;
572 let auth = fixed();
573 let error = client_for(&server, auth.clone())
574 .get::<Item>("issues/WEB-1", &[])
575 .await
576 .unwrap_err();
577 assert_eq!(crate::error::exit_code(&error), 4);
578 assert_eq!(auth.refreshes.load(Ordering::SeqCst), 1);
579 assert!(!format!("{error:?}").contains(TOKEN));
580 }
581
582 #[tokio::test]
583 async fn a_rejected_download_asks_to_sign_in_again() {
584 let server = MockServer::start().await;
585 Mock::given(method("GET"))
586 .respond_with(ResponseTemplate::new(401))
587 .mount(&server)
588 .await;
589 let auth = fixed();
590 let error = client_for(&server, auth.clone())
591 .download("/api/files/8-1?sign=x")
592 .await
593 .unwrap_err();
594 assert_eq!(crate::error::exit_code(&error), 4);
595 assert_eq!(auth.refreshes.load(Ordering::SeqCst), 1);
596 }
597
598 #[tokio::test]
599 async fn reads_are_retried_on_503() {
600 let server = MockServer::start().await;
601 Mock::given(method("GET"))
602 .respond_with(ResponseTemplate::new(503).insert_header("retry-after", "0"))
603 .up_to_n_times(1)
604 .mount(&server)
605 .await;
606 Mock::given(method("GET"))
607 .respond_with(ResponseTemplate::new(200).set_body_json(item("WEB-1")))
608 .mount(&server)
609 .await;
610 let found: Item = client_for(&server, fixed())
611 .get("issues/WEB-1", &[])
612 .await
613 .unwrap();
614 assert_eq!(found.id_readable, "WEB-1");
615 }
616
617 #[tokio::test]
618 async fn writes_are_never_retried() {
619 let server = MockServer::start().await;
620 Mock::given(method("POST"))
621 .respond_with(ResponseTemplate::new(503).insert_header("retry-after", "0"))
622 .expect(1)
623 .mount(&server)
624 .await;
625 let result = client_for(&server, fixed())
626 .raw(Method::POST, "commands", Some(&json!({"query": "x"})))
627 .await;
628 assert!(result.is_err());
629 }
630
631 #[tokio::test]
632 async fn html_error_pages_are_reported_as_such() {
633 let server = MockServer::start().await;
634 Mock::given(method("GET"))
635 .respond_with(
636 ResponseTemplate::new(502)
637 .set_body_raw("<html><body>Bad gateway</body></html>", "text/html"),
638 )
639 .mount(&server)
640 .await;
641 let auth = fixed();
642 let http = crate::http::client(&HttpConfig {
643 timeout: Duration::from_secs(5),
644 retries: 0,
645 ca_file: None,
646 })
647 .unwrap();
648 let client = Client::new(http, InstanceUrl::parse(&server.uri()).unwrap(), auth, 0);
649 let error = client.get::<Item>("issues/WEB-1", &[]).await.unwrap_err();
650 assert!(
651 error
652 .to_string()
653 .contains("unexpected HTML response (HTTP 502"),
654 "{error}"
655 );
656 }
657
658 #[tokio::test]
659 async fn html_with_status_200_is_not_a_decode_panic() {
660 let server = MockServer::start().await;
661 Mock::given(method("GET"))
662 .respond_with(
663 ResponseTemplate::new(200).set_body_raw("<html>Sign in</html>", "text/html"),
664 )
665 .mount(&server)
666 .await;
667 let error = client_for(&server, fixed())
668 .get::<Item>("issues/WEB-1", &[])
669 .await
670 .unwrap_err();
671 assert!(
672 error.to_string().contains("unexpected HTML response"),
673 "{error}"
674 );
675 }
676
677 #[tokio::test]
678 async fn youtrack_errors_are_shown_verbatim() {
679 let server = MockServer::start().await;
680 Mock::given(method("GET"))
681 .respond_with(ResponseTemplate::new(400).set_body_json(json!({
682 "error": "invalid_query",
683 "error_description": "Can't parse search query",
684 "error_children": [{"error": "The value \"NOPE\" isn't used for the project field."}]
685 })))
686 .mount(&server)
687 .await;
688 let error = client_for(&server, fixed())
689 .get::<Item>("issues", &[])
690 .await
691 .unwrap_err();
692 let text = error.to_string();
693 assert!(
694 text.contains("Can't parse search query")
695 && text.contains("isn't used for the project field"),
696 "{text}"
697 );
698 assert_eq!(crate::error::exit_code(&error), 1);
699 }
700
701 #[tokio::test]
702 async fn decode_errors_name_the_json_path() {
703 let server = MockServer::start().await;
704 Mock::given(method("GET"))
705 .respond_with(ResponseTemplate::new(200).set_body_json(json!({"idReadable": 5})))
706 .mount(&server)
707 .await;
708 let error = client_for(&server, fixed())
709 .get::<Item>("issues/WEB-1", &[])
710 .await
711 .unwrap_err();
712 assert!(error.to_string().contains("idReadable"), "{error}");
713 }
714
715 #[tokio::test]
716 async fn paginate_walks_pages_until_a_short_one() {
717 let server = MockServer::start().await;
718 Mock::given(method("GET"))
719 .and(path("/api/issues"))
720 .and(query_param("$skip", "0"))
721 .and(query_param("$top", "2"))
722 .respond_with(
723 ResponseTemplate::new(200).set_body_json(json!([item("WEB-1"), item("WEB-2")])),
724 )
725 .expect(1)
726 .mount(&server)
727 .await;
728 Mock::given(method("GET"))
729 .and(path("/api/issues"))
730 .and(query_param("$skip", "2"))
731 .respond_with(ResponseTemplate::new(200).set_body_json(json!([item("WEB-3")])))
732 .expect(1)
733 .mount(&server)
734 .await;
735 let client = client_for(&server, fixed());
736 let items: Vec<Item> = client
737 .paginate("issues".into(), vec![], 2)
738 .try_collect()
739 .await
740 .unwrap();
741 assert_eq!(items.len(), 3);
742 }
743
744 #[tokio::test]
745 async fn paginate_shrinks_to_the_export_limit() {
746 let server = MockServer::start().await;
747 Mock::given(method("GET"))
748 .and(query_param("$top", "100"))
749 .respond_with(ResponseTemplate::new(400).set_body_json(json!({
750 "error": "Bad Request",
751 "error_description": "Requested size 100 is greater than limit 50"
752 })))
753 .mount(&server)
754 .await;
755 Mock::given(method("GET"))
756 .and(query_param("$top", "50"))
757 .respond_with(ResponseTemplate::new(200).set_body_json(json!([item("WEB-1")])))
758 .mount(&server)
759 .await;
760 let client = client_for(&server, fixed());
761 let items: Vec<Item> = client
762 .paginate("issues".into(), vec![], 100)
763 .try_collect()
764 .await
765 .unwrap();
766 assert_eq!(items.len(), 1);
767 }
768
769 #[tokio::test]
770 async fn raw_accepts_paths_with_or_without_the_api_prefix() {
771 let server = MockServer::start().await;
772 Mock::given(method("GET"))
773 .and(path("/api/admin/projects"))
774 .and(query_param("$top", "1"))
775 .respond_with(ResponseTemplate::new(200).set_body_json(json!([])))
776 .expect(2)
777 .mount(&server)
778 .await;
779 let client = client_for(&server, fixed());
780 assert_eq!(
781 client
782 .raw(Method::GET, "/api/admin/projects?$top=1", None)
783 .await
784 .unwrap()
785 .as_deref(),
786 Some("[]")
787 );
788 assert_eq!(
789 client
790 .raw(Method::GET, "admin/projects?$top=1", None)
791 .await
792 .unwrap()
793 .as_deref(),
794 Some("[]")
795 );
796 }
797
798 #[tokio::test]
799 async fn the_token_never_leaves_the_instance() {
800 let instance = MockServer::start().await;
801 let other = MockServer::start().await;
802 Mock::given(method("GET"))
803 .respond_with(ResponseTemplate::new(200).set_body_json(json!({})))
804 .mount(&other)
805 .await;
806 let client = client_for(&instance, fixed());
807 let other_host = other.address().to_string();
808 for path in [
809 format!("{}/steal", other.uri()),
810 format!("\\\\{other_host}/steal"),
811 ] {
812 let error = client.raw(Method::GET, &path, None).await.unwrap_err();
813 assert_eq!(crate::error::exit_code(&error), 2, "{path}: {error}");
814 }
815 assert!(other.received_requests().await.unwrap().is_empty());
816 }
817
818 #[tokio::test]
819 async fn only_changes_the_server_accepted_count_as_writes() {
820 let server = MockServer::start().await;
821 Mock::given(method("GET"))
822 .respond_with(ResponseTemplate::new(200).set_body_json(json!({})))
823 .mount(&server)
824 .await;
825 Mock::given(method("POST"))
826 .and(path("/api/rejected"))
827 .respond_with(ResponseTemplate::new(401))
828 .mount(&server)
829 .await;
830 Mock::given(method("POST"))
831 .and(path("/api/accepted"))
832 .respond_with(ResponseTemplate::new(200).set_body_json(json!({})))
833 .mount(&server)
834 .await;
835 let client = client_for(&server, fixed());
836 client.raw(Method::GET, "issues", None).await.unwrap();
837 assert!(!client.has_written());
838 client
839 .raw(Method::POST, "rejected", Some(&json!({})))
840 .await
841 .unwrap_err();
842 assert!(!client.has_written());
843 client
844 .raw(Method::POST, "accepted", Some(&json!({})))
845 .await
846 .unwrap();
847 assert!(client.has_written());
848 }
849
850 #[tokio::test]
851 async fn post_sends_the_body_and_decodes_the_answer() {
852 let server = MockServer::start().await;
853 Mock::given(method("POST"))
854 .and(path("/api/issues"))
855 .and(query_param("fields", "idReadable"))
856 .and(body_json(json!({"summary": "x"})))
857 .respond_with(ResponseTemplate::new(200).set_body_json(item("WEB-9")))
858 .expect(1)
859 .mount(&server)
860 .await;
861 let client = client_for(&server, fixed());
862 let created: Item = client
863 .post("issues", &json!({"summary": "x"}))
864 .await
865 .unwrap();
866 assert_eq!(created.id_readable, "WEB-9");
867 assert!(client.has_written());
868 }
869
870 #[tokio::test]
871 async fn deletes_and_unit_posts_are_never_retried() {
872 let server = MockServer::start().await;
873 Mock::given(method("DELETE"))
874 .respond_with(ResponseTemplate::new(503))
875 .expect(1)
876 .mount(&server)
877 .await;
878 Mock::given(method("POST"))
879 .respond_with(ResponseTemplate::new(503))
880 .expect(1)
881 .mount(&server)
882 .await;
883 let client = client_for(&server, fixed());
884 assert!(client.delete("issues/WEB-1").await.is_err());
885 assert!(client.post_unit("issues/WEB-1", &json!({})).await.is_err());
886 }
887
888 #[tokio::test]
889 async fn a_read_only_post_is_not_a_write() {
890 let server = MockServer::start().await;
891 Mock::given(method("POST"))
892 .and(path("/api/commands/assist"))
893 .respond_with(ResponseTemplate::new(200).set_body_json(item("WEB-1")))
894 .mount(&server)
895 .await;
896 let client = client_for(&server, fixed());
897 let _: Item = client
898 .post_query("commands/assist", &json!({}))
899 .await
900 .unwrap();
901 assert!(!client.has_written());
902 }
903}