1use std::path::Path;
10
11use crate::error::SendraError;
12use crate::http::client::HttpClient;
13use crate::oauth::OAuthTokenCache;
14use crate::request::auth::{ApiKeyLocation, Auth};
15use crate::request::multipart::{encode_multipart, read_body_file};
16use crate::request::Request;
17
18impl Request {
19 pub fn resolve_query(&self) -> Result<Request, SendraError> {
44 let mut resolved = self.clone();
45 if self.query.is_empty() {
46 return Ok(resolved);
47 }
48
49 let mut url =
50 reqwest::Url::parse(&self.url).map_err(|source| SendraError::InvalidRequest {
51 reason: format!("url `{}` is not valid: {source}", self.url),
52 })?;
53
54 let overridden: std::collections::HashSet<&str> =
58 self.query.iter().map(|(name, _)| name.as_str()).collect();
59 let kept: Vec<(String, String)> = url
60 .query_pairs()
61 .filter(|(name, _)| !overridden.contains(name.as_ref()))
62 .map(|(name, value)| (name.into_owned(), value.into_owned()))
63 .collect();
64
65 let mut pairs = url.query_pairs_mut();
66 pairs.clear();
67 for (name, value) in &kept {
68 pairs.append_pair(name, value);
69 }
70 for (name, value) in &self.query {
71 pairs.append_pair(name, value);
72 }
73 drop(pairs);
74
75 resolved.url = url.to_string();
76 resolved.query = Vec::new();
77 Ok(resolved)
78 }
79
80 pub fn resolve_body(&self, base_dir: &Path) -> Result<Request, SendraError> {
114 let mut resolved = self.clone();
115
116 if let Some(value) = &self.json {
117 let body = serde_json::to_string(value).expect("a serde_json::Value always serializes");
118 resolved.body = Some(body);
119 crate::config::insert_if_absent(
120 &mut resolved.headers,
121 "Content-Type",
122 "application/json",
123 );
124 } else if let Some(path) = &self.body_file {
125 resolved.body = Some(read_body_file(base_dir, path)?);
126 } else if !self.form.is_empty() {
127 let body = serde_urlencoded::to_string(&self.form)
128 .expect("a Vec<(String, String)> always encodes as x-www-form-urlencoded pairs");
129 resolved.body = Some(body);
130 crate::config::insert_if_absent(
131 &mut resolved.headers,
132 "Content-Type",
133 "application/x-www-form-urlencoded",
134 );
135 } else if !self.multipart.is_empty() {
136 let (body, content_type) = encode_multipart(&self.multipart, base_dir)?;
137 resolved.body = Some(body);
138 crate::config::insert_if_absent(&mut resolved.headers, "Content-Type", &content_type);
139 }
140
141 resolved.json = None;
142 resolved.body_file = None;
143 resolved.form = Vec::new();
144 resolved.multipart = Vec::new();
145
146 Ok(resolved)
147 }
148
149 pub fn resolve_auth(&self) -> Result<Request, SendraError> {
173 let mut resolved = self.clone();
174
175 if let Some(auth) = &self.auth {
176 match (&auth.bearer, &auth.basic, &auth.api_key, &auth.oauth) {
177 (Some(token), None, None, None) => {
178 resolved
179 .headers
180 .push(("Authorization".to_string(), format!("Bearer {token}")));
181 }
182 (None, Some(basic), None, None) => {
183 let credentials = format!("{}:{}", basic.user, basic.pass);
184 let encoded = base64::Engine::encode(
185 &base64::engine::general_purpose::STANDARD,
186 credentials,
187 );
188 resolved
189 .headers
190 .push(("Authorization".to_string(), format!("Basic {encoded}")));
191 }
192 (None, None, Some(api_key), None) => match api_key.r#in {
193 ApiKeyLocation::Header => {
194 resolved
195 .headers
196 .push((api_key.name.clone(), api_key.value.clone()));
197 }
198 ApiKeyLocation::Query => {
199 resolved
200 .query
201 .push((api_key.name.clone(), api_key.value.clone()));
202 }
203 },
204 (None, None, None, Some(_)) => {
205 return Err(SendraError::InvalidRequest {
213 reason: "auth.oauth must be resolved via Request::resolve_oauth before \
214 resolve_auth"
215 .to_string(),
216 });
217 }
218 _ => unreachable!(
220 "Request::validate enforces exactly one of bearer/basic/api_key/oauth"
221 ),
222 };
223 }
224 resolved.auth = None;
225
226 Ok(resolved)
227 }
228
229 pub async fn resolve_oauth(
243 &self,
244 client: &HttpClient,
245 cache: &OAuthTokenCache,
246 ) -> Result<Request, SendraError> {
247 let Some(oauth) = self.auth.as_ref().and_then(|auth| auth.oauth.as_ref()) else {
248 return Ok(self.clone());
249 };
250
251 let access_token = crate::oauth::acquire_token(oauth, client, cache).await?;
252
253 let mut resolved = self.clone();
254 resolved.auth = Some(Auth {
255 bearer: Some(access_token),
256 basic: None,
257 api_key: None,
258 oauth: None,
259 });
260 Ok(resolved)
261 }
262}
263
264#[cfg(test)]
265mod tests {
266 use super::*;
267 use crate::request::auth::OAuthGrantType;
268 use crate::SendraError;
269
270 fn request_with(field_and_value: &str) -> Request {
274 Request::from_yaml_str(&format!(
275 "method: POST\nurl: https://example.com\n{field_and_value}\n"
276 ))
277 .expect("the test request should parse")
278 }
279
280 #[test]
281 fn a_json_body_is_serialized_and_gets_the_default_content_type() {
282 let request = request_with("json:\n name: ada\n roles: [admin, user]\n");
283 let resolved = request
284 .resolve_body(Path::new("."))
285 .expect("no file to read");
286
287 let sent: serde_json::Value =
288 serde_json::from_str(resolved.body.as_deref().expect("a body was produced"))
289 .expect("the body is valid json");
290 assert_eq!(
291 sent,
292 serde_json::json!({"name": "ada", "roles": ["admin", "user"]})
293 );
294 assert_eq!(resolved.header("Content-Type"), Some("application/json"));
295 assert!(resolved.json.is_none());
299 }
300
301 #[test]
302 fn a_json_bodys_explicit_content_type_is_not_clobbered() {
303 let request = request_with(
304 "headers:\n Content-Type: application/vnd.example+json\njson:\n ok: true\n",
305 );
306 let resolved = request
307 .resolve_body(Path::new("."))
308 .expect("no file to read");
309
310 assert_eq!(
311 resolved.header("Content-Type"),
312 Some("application/vnd.example+json"),
313 "an explicit content-type header must win over the automatic one"
314 );
315 }
316
317 #[test]
318 fn body_file_reads_relative_to_the_request_files_directory_not_the_cwd() {
319 let dir = tempfile::tempdir().unwrap();
320 std::fs::write(dir.path().join("payload.json"), r#"{"id":1}"#).unwrap();
321
322 let request = request_with("body_file: ./payload.json\n");
323 let resolved = request
324 .resolve_body(dir.path())
325 .expect("the file is beside the (hypothetical) request file");
326
327 assert_eq!(resolved.body.as_deref(), Some(r#"{"id":1}"#));
328 assert!(resolved.header("Content-Type").is_none());
332
333 let elsewhere = tempfile::tempdir().unwrap();
338 assert!(matches!(
339 request.resolve_body(elsewhere.path()),
340 Err(SendraError::BodyFileIo { .. })
341 ));
342 }
343
344 #[test]
345 fn a_non_utf8_body_file_is_a_typed_error_not_a_silent_corruption() {
346 let dir = tempfile::tempdir().unwrap();
347 std::fs::write(dir.path().join("payload.bin"), [0xff, 0xfe, 0x00, 0xff]).unwrap();
348
349 let request = request_with("body_file: ./payload.bin\n");
350 assert!(matches!(
351 request.resolve_body(dir.path()),
352 Err(SendraError::BodyFileIo { .. })
353 ));
354 }
355
356 #[test]
357 fn a_form_body_is_url_encoded_and_gets_the_default_content_type() {
358 let request = request_with("form:\n username: ada lovelace\n remember_me: \"true\"\n");
359 let resolved = request
360 .resolve_body(Path::new("."))
361 .expect("no file to read");
362
363 assert_eq!(
364 resolved.body.as_deref(),
365 Some("username=ada+lovelace&remember_me=true")
366 );
367 assert_eq!(
368 resolved.header("Content-Type"),
369 Some("application/x-www-form-urlencoded")
370 );
371 assert!(resolved.form.is_empty());
372 }
373
374 #[test]
375 fn a_multipart_body_encodes_a_text_part_and_a_file_part() {
376 let dir = tempfile::tempdir().unwrap();
377 std::fs::write(dir.path().join("cat.txt"), "meow").unwrap();
378
379 let request = request_with(
380 "multipart:\n \
381 - name: description\n value: a photo of my cat\n \
382 - name: photo\n path: ./cat.txt\n",
383 );
384 let resolved = request
385 .resolve_body(dir.path())
386 .expect("the file part reads fine");
387
388 let content_type = resolved
389 .header("Content-Type")
390 .expect("multipart sets its own content-type")
391 .to_string();
392 assert!(
393 content_type.starts_with("multipart/form-data; boundary="),
394 "got {content_type}"
395 );
396 let boundary = content_type
397 .strip_prefix("multipart/form-data; boundary=")
398 .unwrap();
399
400 let body = resolved.body.expect("a body was produced");
401 assert!(body.contains(&format!("--{boundary}\r\n")));
402 assert!(body.contains(
403 "Content-Disposition: form-data; name=\"description\"\r\n\r\na photo of my cat"
404 ));
405 assert!(body.contains(
406 "Content-Disposition: form-data; name=\"photo\"; filename=\"cat.txt\"\r\n\r\nmeow"
407 ));
408 assert!(body.trim_end().ends_with(&format!("--{boundary}--")));
409 assert!(resolved.multipart.is_empty());
410 }
411
412 #[test]
413 fn a_multipart_part_with_both_value_and_path_is_rejected_at_parse_time() {
414 let err = Request::from_yaml_str(
415 "method: POST\nurl: https://example.com\n\
416 multipart:\n - name: photo\n value: x\n path: ./cat.jpg\n",
417 )
418 .expect_err("a part cannot be both text and a file");
419 assert!(
420 matches!(&err, SendraError::InvalidRequest { reason } if reason.contains("both `value` and `path`")),
421 "got {err:?}"
422 );
423 }
424
425 #[test]
426 fn a_multipart_part_with_neither_value_nor_path_is_rejected_at_parse_time() {
427 let err = Request::from_yaml_str(
428 "method: POST\nurl: https://example.com\nmultipart:\n - name: photo\n",
429 )
430 .expect_err("a part needs exactly one of value/path");
431 assert!(
432 matches!(&err, SendraError::InvalidRequest { reason } if reason.contains("neither `value` nor `path`")),
433 "got {err:?}"
434 );
435 }
436
437 #[test]
438 fn a_request_naming_two_body_fields_is_rejected_at_parse_time() {
439 let err = Request::from_yaml_str(
440 "method: POST\nurl: https://example.com\nbody: '{}'\njson:\n a: 1\n",
441 )
442 .expect_err("body and json together must be rejected");
443 assert!(
444 matches!(&err, SendraError::InvalidRequest { reason } if reason.contains("body") && reason.contains("json")),
445 "got {err:?}"
446 );
447 }
448
449 #[test]
450 fn two_body_fields_inside_a_collection_are_rejected_with_the_requests_context() {
451 let yaml = "\
452requests:
453 - name: Broken
454 method: POST
455 url: https://example.com
456 form:
457 a: '1'
458 body_file: ./x.json
459";
460 let err = crate::Document::from_yaml_str(yaml).expect_err("must be rejected");
461 match err {
462 SendraError::InvalidCollection { reason } => {
463 assert!(reason.contains("Broken"), "got {reason}");
464 assert!(reason.contains("form"), "got {reason}");
465 assert!(reason.contains("body_file"), "got {reason}");
466 }
467 other => panic!("expected InvalidCollection, got {other:?}"),
468 }
469 }
470
471 #[test]
472 fn a_plain_body_still_parses_and_resolves_unchanged() {
473 let request = request_with("body: '{\"name\": \"ada\"}'\n");
476 let resolved = request
477 .resolve_body(Path::new("."))
478 .expect("nothing to read");
479 assert_eq!(resolved.body.as_deref(), Some(r#"{"name": "ada"}"#));
480 assert!(resolved.header("Content-Type").is_none());
481 }
482
483 #[test]
484 fn a_request_with_no_body_field_at_all_resolves_to_no_body() {
485 let request = request_with("");
486 let resolved = request
487 .resolve_body(Path::new("."))
488 .expect("nothing to resolve");
489 assert!(resolved.body.is_none());
490 }
491
492 fn get_with(field_and_value: &str) -> Request {
495 Request::from_yaml_str(&format!(
496 "method: GET\nurl: https://example.com/search\n{field_and_value}\n"
497 ))
498 .expect("the test request should parse")
499 }
500
501 #[test]
502 fn a_request_with_no_query_field_leaves_the_url_untouched() {
503 let request = get_with("");
505 let resolved = request.resolve_query().expect("nothing to resolve");
506 assert_eq!(resolved.url, "https://example.com/search");
507 assert!(resolved.query.is_empty());
508 }
509
510 #[test]
511 fn a_query_map_merges_onto_a_url_with_no_existing_query_string() {
512 let request = get_with("query:\n a: '1'\n b: '2'\n");
513 let resolved = request.resolve_query().expect("resolves");
514 assert_eq!(resolved.url, "https://example.com/search?a=1&b=2");
515 assert!(resolved.query.is_empty(), "cleared after resolution");
516 }
517
518 #[test]
519 fn a_query_map_is_appended_onto_a_url_that_already_has_a_query_string() {
520 let request = Request::from_yaml_str(
521 "method: GET\nurl: https://example.com/search?existing=1\nquery:\n new: '2'\n",
522 )
523 .unwrap();
524 let resolved = request.resolve_query().expect("resolves");
525 assert_eq!(resolved.url, "https://example.com/search?existing=1&new=2");
526 }
527
528 #[test]
529 fn a_key_in_both_the_url_and_the_query_map_is_decided_by_the_query_map() {
530 let request = Request::from_yaml_str(
533 "method: GET\nurl: https://example.com/search?a=from-url&b=kept\nquery:\n a: from-query\n",
534 )
535 .unwrap();
536 let resolved = request.resolve_query().expect("resolves");
537 let url = reqwest::Url::parse(&resolved.url).unwrap();
538 let pairs: Vec<(String, String)> = url
539 .query_pairs()
540 .map(|(k, v)| (k.into_owned(), v.into_owned()))
541 .collect();
542 assert_eq!(
543 pairs,
544 vec![
545 ("b".to_string(), "kept".to_string()),
546 ("a".to_string(), "from-query".to_string()),
547 ],
548 "got {pairs:?}"
549 );
550 }
551
552 #[test]
553 fn special_characters_are_percent_encoded_not_concatenated() {
554 let request = get_with("query:\n q: 'coffee & tea, café'\n");
555 let resolved = request.resolve_query().expect("resolves");
556
557 let url = reqwest::Url::parse(&resolved.url).unwrap();
561 let (_, value) = url
562 .query_pairs()
563 .find(|(name, _)| name == "q")
564 .expect("q was sent");
565 assert_eq!(value, "coffee & tea, café");
566 assert!(!resolved.url.contains(' '));
569 assert!(resolved.url.is_ascii());
570 }
571
572 #[test]
573 fn a_repeated_query_key_is_written_as_a_list() {
574 let request = get_with("query:\n tag:\n - hot\n - iced\n");
575 let resolved = request.resolve_query().expect("resolves");
576 let url = reqwest::Url::parse(&resolved.url).unwrap();
577 let tags: Vec<String> = url
578 .query_pairs()
579 .filter(|(name, _)| name == "tag")
580 .map(|(_, value)| value.into_owned())
581 .collect();
582 assert_eq!(tags, vec!["hot".to_string(), "iced".to_string()]);
583 }
584
585 #[test]
586 fn an_unquoted_number_query_value_is_coerced_to_its_string_form() {
587 let request = get_with("query:\n limit: 10\n");
588 let resolved = request.resolve_query().expect("resolves");
589 assert_eq!(resolved.url, "https://example.com/search?limit=10");
590 }
591
592 #[test]
593 fn environment_substitution_reaches_query_values_and_list_entries() {
594 let request =
595 get_with("query:\n tenant: '{{tenant}}'\n tag:\n - '{{tenant}}'\n - iced\n");
596 let environment = crate::Environment::from_yaml_str("tenant: acme\n").unwrap();
597 let substituted = environment.apply(&request).expect("tenant is set");
598 assert_eq!(
599 substituted.query,
600 vec![
601 ("tenant".to_string(), "acme".to_string()),
602 ("tag".to_string(), "acme".to_string()),
603 ("tag".to_string(), "iced".to_string()),
604 ]
605 );
606 }
607
608 #[test]
611 fn auth_bearer_resolves_to_a_bearer_authorization_header() {
612 let request = request_with("auth:\n bearer: my-token\n");
613 let resolved = request.resolve_auth().expect("resolves");
614 assert_eq!(resolved.header("Authorization"), Some("Bearer my-token"));
615 assert!(resolved.auth.is_none());
616 }
617
618 #[test]
619 fn auth_basic_resolves_to_a_base64_encoded_authorization_header() {
620 let request = request_with("auth:\n basic:\n user: ada\n pass: s3cr3t\n");
621 let resolved = request.resolve_auth().expect("resolves");
622 assert_eq!(
624 resolved.header("Authorization"),
625 Some("Basic YWRhOnMzY3IzdA==")
626 );
627 assert!(resolved.auth.is_none());
628 }
629
630 #[test]
631 fn a_request_with_no_auth_field_resolves_to_no_authorization_header() {
632 let request = request_with("");
633 let resolved = request.resolve_auth().expect("nothing to resolve");
634 assert!(resolved.header("Authorization").is_none());
635 }
636
637 #[test]
638 fn auth_naming_both_bearer_and_basic_is_rejected_at_parse_time() {
639 let err = Request::from_yaml_str(
640 "method: GET\nurl: https://example.com\nauth:\n bearer: x\n basic:\n user: a\n pass: b\n",
641 )
642 .expect_err("bearer and basic together must be rejected");
643 assert!(
644 matches!(&err, SendraError::InvalidRequest { reason } if reason.contains("bearer") && reason.contains("basic")),
645 "got {err:?}"
646 );
647 }
648
649 #[test]
650 fn auth_naming_neither_bearer_nor_basic_is_rejected_at_parse_time() {
651 let err = Request::from_yaml_str("method: GET\nurl: https://example.com\nauth: {}\n")
652 .expect_err("an empty auth block must be rejected");
653 assert!(
654 matches!(&err, SendraError::InvalidRequest { reason } if reason.contains("bearer") && reason.contains("basic")),
655 "got {err:?}"
656 );
657 }
658
659 #[test]
660 fn auth_alongside_an_explicit_authorization_header_is_rejected_at_parse_time() {
661 let err = Request::from_yaml_str(
662 "method: GET\nurl: https://example.com\nheaders:\n Authorization: Bearer hand-written\nauth:\n bearer: x\n",
663 )
664 .expect_err("auth and an explicit Authorization header together must be rejected");
665 assert!(
666 matches!(&err, SendraError::InvalidRequest { reason } if reason.contains("Authorization")),
667 "got {err:?}"
668 );
669 }
670
671 #[test]
672 fn the_authorization_collision_check_is_case_insensitive() {
673 let err = Request::from_yaml_str(
674 "method: GET\nurl: https://example.com\nheaders:\n authorization: Bearer hand-written\nauth:\n bearer: x\n",
675 )
676 .expect_err("a differently-cased Authorization header must still collide");
677 assert!(matches!(&err, SendraError::InvalidRequest { .. }));
678 }
679
680 #[test]
681 fn environment_substitution_reaches_bearer_and_basic_values() {
682 let request =
683 request_with("auth:\n basic:\n user: '{{username}}'\n pass: '{{password}}'\n");
684 let environment =
685 crate::Environment::from_yaml_str("username: ada\npassword: s3cr3t\n").unwrap();
686 let substituted = environment.apply(&request).expect("both are set");
687 let auth = substituted.auth.expect("auth survives substitution");
688 let basic = auth.basic.expect("basic survives substitution");
689 assert_eq!(basic.user, "ada");
690 assert_eq!(basic.pass, "s3cr3t");
691 }
692
693 #[test]
696 fn auth_api_key_header_resolves_to_the_named_header() {
697 let request = request_with(
698 "auth:\n api_key:\n in: header\n name: X-API-Key\n value: s3cr3t\n",
699 );
700 let resolved = request.resolve_auth().expect("resolves");
701 assert_eq!(resolved.header("X-API-Key"), Some("s3cr3t"));
702 assert!(resolved.auth.is_none());
703 }
704
705 #[test]
706 fn auth_api_key_query_merges_through_resolve_query_not_a_parallel_path() {
707 let request =
708 get_with("auth:\n api_key:\n in: query\n name: api_key\n value: s3cr3t\n");
709 let resolved = request
710 .resolve_auth()
711 .and_then(|request| request.resolve_query())
712 .expect("resolves");
713 assert_eq!(resolved.url, "https://example.com/search?api_key=s3cr3t");
714 assert!(resolved.auth.is_none());
715 assert!(resolved.query.is_empty());
716 }
717
718 #[test]
719 fn auth_api_key_query_still_wins_over_an_existing_url_query_key_of_the_same_name() {
720 let request = Request::from_yaml_str(
723 "method: GET\nurl: https://example.com/search?api_key=stale\nauth:\n api_key:\n in: query\n name: api_key\n value: fresh\n",
724 )
725 .unwrap();
726 let resolved = request
727 .resolve_auth()
728 .and_then(|request| request.resolve_query())
729 .expect("resolves");
730 assert_eq!(resolved.url, "https://example.com/search?api_key=fresh");
731 }
732
733 #[test]
734 fn a_request_with_no_auth_field_resolves_to_no_api_key_header_or_query_param() {
735 let request = get_with("");
736 let resolved = request
737 .resolve_auth()
738 .and_then(|request| request.resolve_query())
739 .expect("nothing to resolve");
740 assert_eq!(resolved.url, "https://example.com/search");
741 }
742
743 #[test]
744 fn auth_naming_bearer_and_api_key_is_rejected_at_parse_time() {
745 let err = Request::from_yaml_str(
746 "method: GET\nurl: https://example.com\nauth:\n bearer: x\n api_key:\n in: header\n name: X-API-Key\n value: y\n",
747 )
748 .expect_err("bearer and api_key together must be rejected");
749 assert!(
750 matches!(&err, SendraError::InvalidRequest { reason } if reason.contains("bearer") && reason.contains("api_key")),
751 "got {err:?}"
752 );
753 }
754
755 #[test]
756 fn auth_api_key_header_colliding_with_an_explicit_header_is_rejected_at_parse_time() {
757 let err = Request::from_yaml_str(
758 "method: GET\nurl: https://example.com\nheaders:\n X-API-Key: hand-written\nauth:\n api_key:\n in: header\n name: X-API-Key\n value: y\n",
759 )
760 .expect_err("api_key and an explicit header of the same name together must be rejected");
761 assert!(
762 matches!(&err, SendraError::InvalidRequest { reason } if reason.contains("X-API-Key")),
763 "got {err:?}"
764 );
765 }
766
767 #[test]
768 fn the_api_key_header_collision_check_is_case_insensitive() {
769 let err = Request::from_yaml_str(
770 "method: GET\nurl: https://example.com\nheaders:\n x-api-key: hand-written\nauth:\n api_key:\n in: header\n name: X-API-Key\n value: y\n",
771 )
772 .expect_err("a differently-cased header name must still collide");
773 assert!(matches!(&err, SendraError::InvalidRequest { .. }));
774 }
775
776 #[test]
777 fn auth_api_key_query_colliding_with_an_explicit_query_entry_is_rejected_at_parse_time() {
778 let err = Request::from_yaml_str(
779 "method: GET\nurl: https://example.com\nquery:\n api_key: hand-written\nauth:\n api_key:\n in: query\n name: api_key\n value: y\n",
780 )
781 .expect_err("api_key and an explicit query entry of the same name together must be rejected");
782 assert!(
783 matches!(&err, SendraError::InvalidRequest { reason } if reason.contains("api_key")),
784 "got {err:?}"
785 );
786 }
787
788 #[test]
789 fn environment_substitution_reaches_api_key_name_and_value() {
790 let request = request_with(
791 "auth:\n api_key:\n in: header\n name: '{{header_name}}'\n value: '{{token}}'\n",
792 );
793 let environment =
794 crate::Environment::from_yaml_str("header_name: X-API-Key\ntoken: s3cr3t\n").unwrap();
795 let substituted = environment.apply(&request).expect("both are set");
796 let auth = substituted.auth.expect("auth survives substitution");
797 let api_key = auth.api_key.expect("api_key survives substitution");
798 assert_eq!(api_key.name, "X-API-Key");
799 assert_eq!(api_key.value, "s3cr3t");
800 }
801
802 fn client() -> HttpClient {
805 crate::http::client::build_client(&crate::Config::default()).expect("a client builds")
806 }
807
808 fn token_server(body: &'static str) -> std::net::SocketAddr {
809 crate::test_support::start_route_server(vec![(
810 "/token",
811 format!(
812 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{body}",
813 body.len()
814 )
815 .into_bytes(),
816 )])
817 }
818
819 #[tokio::test]
820 async fn a_request_with_no_oauth_auth_is_unchanged_by_resolve_oauth() {
821 let request = request_with("auth:\n bearer: unrelated\n");
822 let client = client();
823 let cache = OAuthTokenCache::new();
824 let resolved = request
825 .resolve_oauth(&client, &cache)
826 .await
827 .expect("nothing to acquire");
828 assert_eq!(resolved, request);
829
830 let no_auth = request_with("");
831 let resolved = no_auth
832 .resolve_oauth(&client, &cache)
833 .await
834 .expect("nothing to acquire");
835 assert_eq!(resolved, no_auth);
836 }
837
838 #[tokio::test]
839 async fn auth_oauth_resolves_via_resolve_oauth_then_resolve_auth_to_a_bearer_header() {
840 let addr = token_server(r#"{"access_token": "acquired-token"}"#);
841 let request = request_with(&format!(
842 "auth:\n oauth:\n grant_type: client_credentials\n token_url: http://{addr}/token\n client_id: id\n client_secret: secret\n"
843 ));
844 let client = client();
845 let cache = OAuthTokenCache::new();
846
847 let resolved = request
848 .resolve_oauth(&client, &cache)
849 .await
850 .expect("the mock token endpoint answers");
851 assert_eq!(
852 resolved
853 .auth
854 .as_ref()
855 .and_then(|auth| auth.bearer.as_deref()),
856 Some("acquired-token"),
857 "resolve_oauth must collapse auth.oauth into auth.bearer"
858 );
859
860 let resolved = resolved.resolve_auth().expect("resolves");
861 assert_eq!(
862 resolved.header("Authorization"),
863 Some("Bearer acquired-token")
864 );
865 assert!(resolved.auth.is_none());
866 }
867
868 #[test]
869 fn calling_resolve_auth_directly_on_unresolved_oauth_is_a_typed_error_not_a_panic() {
870 let request = request_with(
871 "auth:\n oauth:\n grant_type: client_credentials\n token_url: http://example.com/token\n client_id: id\n client_secret: secret\n",
872 );
873 let err = request
874 .resolve_auth()
875 .expect_err("oauth must be resolved via resolve_oauth first");
876 assert!(matches!(err, SendraError::InvalidRequest { .. }));
877 }
878
879 #[test]
880 fn oauth_password_grant_missing_username_is_rejected_at_parse_time() {
881 let err = Request::from_yaml_str(
882 "method: GET\nurl: https://example.com\nauth:\n oauth:\n grant_type: password\n token_url: https://example.com/token\n client_id: id\n client_secret: secret\n password: pw\n",
883 )
884 .expect_err("password grant needs username too");
885 assert!(
886 matches!(&err, SendraError::InvalidRequest { reason } if reason.contains("username") && reason.contains("password")),
887 "got {err:?}"
888 );
889 }
890
891 #[test]
892 fn oauth_client_credentials_needs_neither_username_nor_password() {
893 let request = Request::from_yaml_str(
894 "method: GET\nurl: https://example.com\nauth:\n oauth:\n grant_type: client_credentials\n token_url: https://example.com/token\n client_id: id\n client_secret: secret\n",
895 )
896 .expect("client_credentials needs no username/password");
897 let oauth = request
898 .auth
899 .expect("auth survives parse")
900 .oauth
901 .expect("oauth is set");
902 assert_eq!(oauth.grant_type, OAuthGrantType::ClientCredentials);
903 }
904
905 #[test]
906 fn auth_naming_oauth_and_bearer_together_is_rejected_at_parse_time() {
907 let err = Request::from_yaml_str(
908 "method: GET\nurl: https://example.com\nauth:\n bearer: x\n oauth:\n grant_type: client_credentials\n token_url: https://example.com/token\n client_id: id\n client_secret: secret\n",
909 )
910 .expect_err("bearer and oauth together must be rejected");
911 assert!(
912 matches!(&err, SendraError::InvalidRequest { reason } if reason.contains("bearer") && reason.contains("oauth")),
913 "got {err:?}"
914 );
915 }
916
917 #[test]
918 fn auth_oauth_alongside_an_explicit_authorization_header_is_rejected_at_parse_time() {
919 let err = Request::from_yaml_str(
920 "method: GET\nurl: https://example.com\nheaders:\n Authorization: Bearer hand-written\nauth:\n oauth:\n grant_type: client_credentials\n token_url: https://example.com/token\n client_id: id\n client_secret: secret\n",
921 )
922 .expect_err("auth.oauth and an explicit Authorization header together must be rejected");
923 assert!(
924 matches!(&err, SendraError::InvalidRequest { reason } if reason.contains("Authorization"))
925 );
926 }
927
928 #[test]
929 fn environment_substitution_reaches_oauth_fields() {
930 let request = request_with(
931 "auth:\n oauth:\n grant_type: password\n token_url: '{{token_url}}'\n client_id: '{{client_id}}'\n client_secret: '{{client_secret}}'\n username: '{{username}}'\n password: '{{password}}'\n scope: '{{scope}}'\n",
932 );
933 let environment = crate::Environment::from_yaml_str(
934 "token_url: https://auth.example.com/token\nclient_id: id-value\nclient_secret: secret-value\nusername: ada\npassword: s3cr3t\nscope: read write\n",
935 )
936 .unwrap();
937 let substituted = environment.apply(&request).expect("every variable is set");
938 let oauth = substituted
939 .auth
940 .expect("auth survives substitution")
941 .oauth
942 .expect("oauth survives substitution");
943 assert_eq!(oauth.token_url, "https://auth.example.com/token");
944 assert_eq!(oauth.client_id, "id-value");
945 assert_eq!(oauth.client_secret.as_deref(), Some("secret-value"));
946 assert_eq!(oauth.username.as_deref(), Some("ada"));
947 assert_eq!(oauth.password.as_deref(), Some("s3cr3t"));
948 assert_eq!(oauth.scope.as_deref(), Some("read write"));
949 }
950}