1use crate::auth_http_client;
32use async_trait::async_trait;
33use base64::Engine;
34use faucet_core::{AuthProvider, Credential, CredentialPlacement, FaucetError, RequestAuth};
35use hmac::{Hmac, Mac};
36use jsonpath_rust::JsonPath;
37use serde::Deserialize;
38use serde_json::Value;
39use sha2::Sha256;
40use std::collections::HashMap;
41use std::time::{SystemTime, UNIX_EPOCH};
42use tokio::sync::Mutex;
43use tokio::time::{Duration, Instant};
44
45type HmacSha256 = Hmac<Sha256>;
46
47#[derive(Debug, Clone, Deserialize)]
51#[serde(deny_unknown_fields)]
52struct FlowRequest {
53 #[serde(default = "default_method")]
54 method: String,
55 url: String,
56 #[serde(default)]
57 headers: HashMap<String, String>,
58 #[serde(default)]
59 query: HashMap<String, String>,
60 #[serde(default)]
62 form: Option<HashMap<String, String>>,
63 #[serde(default)]
65 json: Option<Value>,
66 #[serde(default)]
73 sign: Option<SignSpec>,
74}
75
76fn default_method() -> String {
77 "GET".to_owned()
78}
79
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
84#[serde(rename_all = "snake_case")]
85enum CaptureFrom {
86 #[default]
88 Json,
89 Xml,
92 Header,
94 SetCookie,
96}
97
98#[derive(Debug, Clone, Deserialize)]
100#[serde(deny_unknown_fields)]
101struct CaptureSource {
102 from: CaptureFrom,
103 #[serde(default)]
105 name: Option<String>,
106 #[serde(default)]
108 path: Option<String>,
109}
110
111#[derive(Debug, Clone, Deserialize)]
115#[serde(untagged)]
116enum CaptureSpec {
117 Json(String),
119 Source(CaptureSource),
121}
122
123impl CaptureSpec {
124 fn kind(&self) -> CaptureFrom {
125 match self {
126 CaptureSpec::Json(_) => CaptureFrom::Json,
127 CaptureSpec::Source(s) => s.from,
128 }
129 }
130
131 fn validate(&self) -> Result<(), &'static str> {
133 match self {
134 CaptureSpec::Json(p) if p.trim().is_empty() => Err("empty JSONPath"),
135 CaptureSpec::Json(_) => Ok(()),
136 CaptureSpec::Source(s) => match s.from {
137 CaptureFrom::Json | CaptureFrom::Xml => match s.path.as_deref().map(str::trim) {
138 Some(p) if !p.is_empty() => Ok(()),
139 _ => Err("`from: json|xml` requires a non-empty `path`"),
140 },
141 CaptureFrom::Header | CaptureFrom::SetCookie => {
142 match s.name.as_deref().map(str::trim) {
143 Some(n) if !n.is_empty() => Ok(()),
144 _ => Err("`from: header|set_cookie` requires a non-empty `name`"),
145 }
146 }
147 },
148 }
149 }
150}
151
152#[derive(Debug, Clone, Deserialize)]
154#[serde(deny_unknown_fields)]
155struct FlowStep {
156 request: FlowRequest,
157 #[serde(default)]
160 capture: HashMap<String, CaptureSpec>,
161}
162
163#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
165#[serde(rename_all = "snake_case")]
166enum PlaceTarget {
167 Header,
168 Query,
169 Cookie,
170 Body,
171}
172
173#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
175#[serde(rename_all = "snake_case")]
176enum SignAlg {
177 HmacSha256,
178}
179
180#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
182#[serde(rename_all = "snake_case")]
183enum SigEncoding {
184 #[default]
185 Hex,
186 Base64,
187}
188
189#[derive(Debug, Clone, Deserialize)]
191#[serde(deny_unknown_fields)]
192struct SignInto {
193 header: String,
194 #[serde(default = "default_sig_format")]
196 format: String,
197}
198
199fn default_sig_format() -> String {
200 "${sig}".to_owned()
201}
202
203#[derive(Debug, Clone, Deserialize)]
205#[serde(deny_unknown_fields)]
206struct SignSpec {
207 alg: SignAlg,
208 key: String,
210 template: String,
213 #[serde(default)]
214 encoding: SigEncoding,
215 into: SignInto,
216}
217
218#[derive(Debug, Clone, Deserialize)]
220#[serde(untagged, deny_unknown_fields)]
221enum ApplySpec {
222 Place {
224 into: PlaceTarget,
225 name: String,
226 value: String,
228 },
229 Sign { sign: SignSpec },
231}
232
233#[derive(Debug, Clone, Deserialize)]
235#[serde(deny_unknown_fields)]
236struct FlowConfig {
237 #[serde(default)]
240 steps: Vec<FlowStep>,
241 #[serde(default)]
243 apply: Vec<ApplySpec>,
244 #[serde(default)]
247 base_url_from: Option<String>,
248 #[serde(default)]
250 ttl_secs: Option<u64>,
251 #[serde(default)]
254 reauth_on: Vec<u16>,
255 }
261
262impl FlowConfig {
263 fn validate(&self) -> Result<(), FaucetError> {
264 if self.steps.is_empty() && self.apply.is_empty() {
265 return Err(FaucetError::Config(
266 "flow auth: at least one of `steps` or `apply` is required".to_owned(),
267 ));
268 }
269 for (i, step) in self.steps.iter().enumerate() {
270 if step.request.url.trim().is_empty() {
271 return Err(FaucetError::Config(format!(
272 "flow auth: step {i} has an empty `url`"
273 )));
274 }
275 if step.request.form.is_some() && step.request.json.is_some() {
276 return Err(FaucetError::Config(format!(
277 "flow auth: step {i} sets both `form` and `json`; pick one"
278 )));
279 }
280 if let Some(sign) = &step.request.sign
281 && sign.into.header.trim().is_empty()
282 {
283 return Err(FaucetError::Config(format!(
284 "flow auth: step {i} sign.into.header must not be empty"
285 )));
286 }
287 for (name, cap) in &step.capture {
288 if let Err(msg) = cap.validate() {
289 return Err(FaucetError::Config(format!(
290 "flow auth: step {i} capture '{name}': {msg}"
291 )));
292 }
293 }
294 }
295 for (i, a) in self.apply.iter().enumerate() {
296 match a {
297 ApplySpec::Place { name, .. } if name.trim().is_empty() => {
298 return Err(FaucetError::Config(format!(
299 "flow auth: apply[{i}] has an empty `name`"
300 )));
301 }
302 ApplySpec::Sign { sign } if sign.into.header.trim().is_empty() => {
303 return Err(FaucetError::Config(format!(
304 "flow auth: apply[{i}].sign.into.header must not be empty"
305 )));
306 }
307 _ => {}
308 }
309 }
310 Ok(())
311 }
312}
313
314fn render(template: &str, ctx: &HashMap<String, String>) -> String {
319 let mut out = String::with_capacity(template.len());
320 let mut rest = template;
321 while let Some(start) = rest.find("${") {
322 out.push_str(&rest[..start]);
323 let after = &rest[start + 2..];
324 if let Some(end) = after.find('}') {
325 let key = &after[..end];
326 match ctx.get(key) {
327 Some(v) => out.push_str(v),
328 None => {
329 out.push_str("${");
330 out.push_str(key);
331 out.push('}');
332 }
333 }
334 rest = &after[end + 1..];
335 } else {
336 out.push_str(&rest[start..]);
337 rest = "";
338 }
339 }
340 out.push_str(rest);
341 out
342}
343
344fn value_to_string(v: &Value) -> String {
346 match v {
347 Value::String(s) => s.clone(),
348 Value::Bool(b) => b.to_string(),
349 Value::Number(n) => n.to_string(),
350 Value::Null => String::new(),
351 other => other.to_string(),
352 }
353}
354
355fn jsonpath_first(body: &Value, path: &str) -> Option<Value> {
356 let results = body.query(path).ok()?;
357 results.first().map(|v| (*v).clone())
358}
359
360fn to_hex(bytes: &[u8]) -> String {
361 let mut s = String::with_capacity(bytes.len() * 2);
362 for b in bytes {
363 s.push_str(&format!("{b:02x}"));
364 }
365 s
366}
367
368fn hmac_sign(key: &str, message: &str, encoding: SigEncoding) -> String {
369 let mut mac =
370 HmacSha256::new_from_slice(key.as_bytes()).expect("HMAC accepts a key of any length");
371 mac.update(message.as_bytes());
372 let bytes = mac.finalize().into_bytes();
373 match encoding {
374 SigEncoding::Hex => to_hex(&bytes),
375 SigEncoding::Base64 => base64::engine::general_purpose::STANDARD.encode(bytes),
376 }
377}
378
379fn sign_header(sign: &SignSpec, ctx: &HashMap<String, String>) -> (String, String) {
383 let base = render(&sign.template, ctx);
384 let sig = match sign.alg {
385 SignAlg::HmacSha256 => hmac_sign(&sign.key, &base, sign.encoding),
386 };
387 let mut sig_ctx = ctx.clone();
388 sig_ctx.insert("sig".to_owned(), sig);
389 let value = render(&sign.into.format, &sig_ctx);
390 (sign.into.header.clone(), value)
391}
392
393fn header_value(headers: &reqwest::header::HeaderMap, name: &str) -> Option<String> {
395 headers
396 .get(name)
397 .and_then(|v| v.to_str().ok())
398 .map(|s| s.to_owned())
399}
400
401fn set_cookie_value(headers: &reqwest::header::HeaderMap, name: &str) -> Option<String> {
403 for v in headers.get_all("set-cookie") {
404 let Ok(s) = v.to_str() else { continue };
405 let pair = s.split(';').next().unwrap_or("");
408 if let Some((k, val)) = pair.split_once('=')
409 && k.trim() == name
410 {
411 return Some(val.trim().to_owned());
412 }
413 }
414 None
415}
416
417#[derive(Debug)]
426struct XmlNode {
427 tag: String,
428 text: String,
429 children: Vec<XmlNode>,
430}
431
432enum XmlToken {
433 Start(String),
434 End,
435 SelfClose(String),
436 Text(String),
437}
438
439fn xml_local_name(raw: &str) -> String {
441 let name = raw.split_whitespace().next().unwrap_or("");
442 match name.split_once(':') {
443 Some((_, local)) => local.to_owned(),
444 None => name.to_owned(),
445 }
446}
447
448fn xml_unescape(s: &str) -> String {
449 s.replace("<", "<")
450 .replace(">", ">")
451 .replace(""", "\"")
452 .replace("'", "'")
453 .replace("&", "&")
454}
455
456fn xml_tokenize(input: &str) -> Vec<XmlToken> {
457 let mut out = Vec::new();
458 let mut rest = input;
459 while let Some(lt) = rest.find('<') {
460 let text = &rest[..lt];
461 if !text.trim().is_empty() {
462 out.push(XmlToken::Text(xml_unescape(text)));
463 }
464 let after = &rest[lt..];
465 if let Some(cdata) = after.strip_prefix("<![CDATA[") {
467 if let Some(end) = cdata.find("]]>") {
468 let content = &cdata[..end];
469 if !content.trim().is_empty() {
470 out.push(XmlToken::Text(content.to_owned()));
471 }
472 rest = &cdata[end + 3..];
473 continue;
474 }
475 break;
476 }
477 let Some(gt_rel) = after.find('>') else { break };
478 let inner = &after[1..gt_rel]; rest = &after[gt_rel + 1..];
480 if inner.starts_with('?') || inner.starts_with('!') {
481 continue;
483 }
484 if let Some(close) = inner.strip_prefix('/') {
485 let _ = close;
486 out.push(XmlToken::End);
487 } else if let Some(sc) = inner.strip_suffix('/') {
488 out.push(XmlToken::SelfClose(xml_local_name(sc)));
489 } else {
490 out.push(XmlToken::Start(xml_local_name(inner)));
491 }
492 }
493 out
494}
495
496fn xml_build_tree(tokens: Vec<XmlToken>) -> XmlNode {
497 let mut stack: Vec<XmlNode> = vec![XmlNode {
498 tag: String::new(),
499 text: String::new(),
500 children: Vec::new(),
501 }];
502 for tok in tokens {
503 match tok {
504 XmlToken::Start(tag) => stack.push(XmlNode {
505 tag,
506 text: String::new(),
507 children: Vec::new(),
508 }),
509 XmlToken::SelfClose(tag) => {
510 if let Some(parent) = stack.last_mut() {
511 parent.children.push(XmlNode {
512 tag,
513 text: String::new(),
514 children: Vec::new(),
515 });
516 }
517 }
518 XmlToken::Text(t) => {
519 if let Some(node) = stack.last_mut() {
520 node.text.push_str(&t);
521 }
522 }
523 XmlToken::End => {
524 if stack.len() > 1 {
525 let node = stack.pop().unwrap();
526 stack.last_mut().unwrap().children.push(node);
527 }
528 }
529 }
530 }
531 while stack.len() > 1 {
533 let node = stack.pop().unwrap();
534 stack.last_mut().unwrap().children.push(node);
535 }
536 stack.pop().unwrap()
537}
538
539fn xml_dot_path(xml: &str, path: &str) -> Option<String> {
542 let root = xml_build_tree(xml_tokenize(xml));
543 let mut current = &root;
544 for seg in path.split('.') {
545 let seg = xml_local_name(seg.trim());
546 if seg.is_empty() {
547 continue;
548 }
549 current = current.children.iter().find(|c| c.tag == seg)?;
550 }
551 Some(current.text.trim().to_owned())
552}
553
554fn build_request_auth(
557 apply: &[ApplySpec],
558 base_url_from: &Option<String>,
559 ctx: &HashMap<String, String>,
560) -> RequestAuth {
561 let mut out = RequestAuth::new();
562 for a in apply {
563 match a {
564 ApplySpec::Place { into, name, value } => {
565 let value = render(value, ctx);
566 let name = name.clone();
567 let placement = match into {
568 PlaceTarget::Header => CredentialPlacement::Header { name, value },
569 PlaceTarget::Query => CredentialPlacement::Query { name, value },
570 PlaceTarget::Cookie => CredentialPlacement::Cookie { name, value },
571 PlaceTarget::Body => CredentialPlacement::BodyField { name, value },
572 };
573 out = out.with_placement(placement);
574 }
575 ApplySpec::Sign { sign } => {
576 let (name, value) = sign_header(sign, ctx);
577 out = out.with_placement(CredentialPlacement::Header { name, value });
578 }
579 }
580 }
581 if let Some(tmpl) = base_url_from {
582 let rendered = render(tmpl, ctx);
583 if !rendered.is_empty() {
584 out = out.with_base_url(rendered);
585 }
586 }
587 out = out.with_captured(ctx.iter().map(|(k, v)| (k.clone(), v.clone())).collect());
591 out
592}
593
594struct Session {
597 ctx: HashMap<String, String>,
599 expires_at: Option<Instant>,
600}
601
602impl Session {
603 fn valid(&self) -> bool {
604 match self.expires_at {
605 Some(exp) => Instant::now() < exp,
606 None => true,
607 }
608 }
609}
610
611pub struct FlowProvider {
613 http: reqwest::Client,
614 config: FlowConfig,
615 state: Mutex<Option<Session>>,
616}
617
618impl std::fmt::Debug for FlowProvider {
619 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
620 f.debug_struct("FlowProvider")
621 .field("steps", &self.config.steps.len())
622 .field("apply", &self.config.apply.len())
623 .finish()
624 }
625}
626
627impl FlowProvider {
628 pub fn from_config(config: &Value) -> Result<Self, FaucetError> {
630 let config: FlowConfig = serde_json::from_value(config.clone())
631 .map_err(|e| FaucetError::Config(format!("flow auth: invalid config: {e}")))?;
632 config.validate()?;
633 Ok(Self {
634 http: auth_http_client(),
635 config,
636 state: Mutex::new(None),
637 })
638 }
639
640 fn clock_ctx() -> HashMap<String, String> {
642 let now = SystemTime::now()
643 .duration_since(UNIX_EPOCH)
644 .map(|d| d.as_secs())
645 .unwrap_or(0);
646 let nanos = SystemTime::now()
647 .duration_since(UNIX_EPOCH)
648 .map(|d| d.subsec_nanos())
649 .unwrap_or(0);
650 let mut ctx = HashMap::new();
651 ctx.insert("ts".to_owned(), now.to_string());
652 ctx.insert("nonce".to_owned(), format!("{now}{nanos}"));
653 ctx
654 }
655
656 async fn run_login(&self) -> Result<HashMap<String, String>, FaucetError> {
658 let mut ctx: HashMap<String, String> = HashMap::new();
659 for (i, step) in self.config.steps.iter().enumerate() {
660 let req = &step.request;
661 let method = reqwest::Method::from_bytes(req.method.to_uppercase().as_bytes())
662 .map_err(|_| {
663 FaucetError::Config(format!(
664 "flow auth: step {i} has an invalid HTTP method '{}'",
665 req.method
666 ))
667 })?;
668 let url = render(&req.url, &ctx);
669 let mut builder = self.http.request(method, &url);
670 for (k, v) in &req.headers {
671 builder = builder.header(k.as_str(), render(v, &ctx));
672 }
673 if !req.query.is_empty() {
674 let q: Vec<(String, String)> = req
675 .query
676 .iter()
677 .map(|(k, v)| (k.clone(), render(v, &ctx)))
678 .collect();
679 builder = builder.query(&q);
680 }
681 if let Some(form) = &req.form {
682 let f: Vec<(String, String)> = form
683 .iter()
684 .map(|(k, v)| (k.clone(), render(v, &ctx)))
685 .collect();
686 builder = builder.form(&f);
687 } else if let Some(json) = &req.json {
688 let rendered = render(&serde_json::to_string(json).unwrap_or_default(), &ctx);
689 let body: Value = serde_json::from_str(&rendered).unwrap_or_else(|_| json.clone());
690 builder = builder.json(&body);
691 }
692 if let Some(sign) = &req.sign {
695 let mut sign_ctx = ctx.clone();
696 sign_ctx.extend(Self::clock_ctx());
697 let (name, value) = sign_header(sign, &sign_ctx);
698 builder = builder.header(name.as_str(), value);
699 }
700 let resp = builder.send().await.map_err(|e| {
701 FaucetError::Auth(format!("flow auth: step {i} request failed: {e}"))
702 })?;
703 let status = resp.status();
704 if !status.is_success() {
705 return Err(FaucetError::Auth(format!(
706 "flow auth: step {i} returned HTTP {}",
707 status.as_u16()
708 )));
709 }
710 let headers = resp.headers().clone();
713 let needs_json = step.capture.values().any(|c| c.kind() == CaptureFrom::Json);
714 let needs_xml = step.capture.values().any(|c| c.kind() == CaptureFrom::Xml);
715 let text: Option<String> = if needs_json || needs_xml {
716 Some(resp.text().await.map_err(|e| {
717 FaucetError::Auth(format!(
718 "flow auth: step {i} failed to read response body: {e}"
719 ))
720 })?)
721 } else {
722 None
723 };
724 let json_body: Option<Value> = if needs_json {
725 Some(
726 serde_json::from_str(text.as_deref().unwrap_or("")).map_err(|e| {
727 FaucetError::Auth(format!(
728 "flow auth: step {i} response was not valid JSON: {e}"
729 ))
730 })?,
731 )
732 } else {
733 None
734 };
735 for (name, cap) in &step.capture {
736 let extracted = match cap {
737 CaptureSpec::Json(path) => json_body
738 .as_ref()
739 .and_then(|b| jsonpath_first(b, path))
740 .map(|v| value_to_string(&v)),
741 CaptureSpec::Source(s) => match s.from {
742 CaptureFrom::Json => json_body
743 .as_ref()
744 .and_then(|b| jsonpath_first(b, s.path.as_deref().unwrap_or("")))
745 .map(|v| value_to_string(&v)),
746 CaptureFrom::Xml => text
747 .as_deref()
748 .and_then(|t| xml_dot_path(t, s.path.as_deref().unwrap_or(""))),
749 CaptureFrom::Header => {
750 header_value(&headers, s.name.as_deref().unwrap_or(""))
751 }
752 CaptureFrom::SetCookie => {
753 set_cookie_value(&headers, s.name.as_deref().unwrap_or(""))
754 }
755 },
756 };
757 match extracted {
758 Some(v) => {
759 ctx.insert(name.clone(), v);
760 }
761 None => {
762 return Err(FaucetError::Auth(format!(
763 "flow auth: step {i} capture '{name}' matched nothing"
764 )));
765 }
766 }
767 }
768 }
769 Ok(ctx)
770 }
771
772 async fn ensure_ctx(&self) -> Result<HashMap<String, String>, FaucetError> {
775 let mut guard = self.state.lock().await;
776 if let Some(s) = guard.as_ref()
777 && s.valid()
778 {
779 return Ok(s.ctx.clone());
780 }
781 let ctx = self.run_login().await?;
782 let expires_at = self
783 .config
784 .ttl_secs
785 .map(|s| Instant::now() + Duration::from_secs(s));
786 *guard = Some(Session {
787 ctx: ctx.clone(),
788 expires_at,
789 });
790 Ok(ctx)
791 }
792
793 fn request_auth_from_ctx(&self, captured: &HashMap<String, String>) -> RequestAuth {
794 let mut ctx = captured.clone();
795 ctx.extend(Self::clock_ctx());
796 build_request_auth(&self.config.apply, &self.config.base_url_from, &ctx)
797 }
798}
799
800#[async_trait]
801impl AuthProvider for FlowProvider {
802 async fn credential(&self) -> Result<Credential, FaucetError> {
803 let ctx = self.ensure_ctx().await?;
804 let auth = self.request_auth_from_ctx(&ctx);
805 for p in &auth.placements {
809 if let CredentialPlacement::Header { name, value } = p {
810 return Ok(Credential::Header {
811 name: name.clone(),
812 value: value.clone(),
813 });
814 }
815 }
816 Err(FaucetError::Auth(
817 "flow auth: no header credential to apply via credential(); this flow places its \
818 credential in a query/cookie/body, which requires a connector that consumes \
819 request_auth() (e.g. the REST source)"
820 .to_owned(),
821 ))
822 }
823
824 async fn invalidate(&self, _stale: &Credential) -> Result<Credential, FaucetError> {
825 *self.state.lock().await = None;
827 self.credential().await
828 }
829
830 async fn request_auth(
831 &self,
832 _method: &str,
833 _url: &str,
834 _query: &std::collections::BTreeMap<String, String>,
835 ) -> Result<RequestAuth, FaucetError> {
836 let ctx = self.ensure_ctx().await?;
837 Ok(self.request_auth_from_ctx(&ctx))
838 }
839
840 fn reauth_statuses(&self) -> &[u16] {
841 &self.config.reauth_on
842 }
843
844 fn provider_name(&self) -> &'static str {
845 "flow"
846 }
847}
848
849#[cfg(test)]
850mod tests {
851 use super::*;
852 use serde_json::json;
853
854 fn ctx(pairs: &[(&str, &str)]) -> HashMap<String, String> {
855 pairs
856 .iter()
857 .map(|(k, v)| (k.to_string(), v.to_string()))
858 .collect()
859 }
860
861 #[test]
862 fn render_substitutes_known_and_leaves_unknown() {
863 let c = ctx(&[("token", "abc"), ("region", "eu")]);
864 assert_eq!(render("Bearer ${token}", &c), "Bearer abc");
865 assert_eq!(render("https://${region}.api", &c), "https://eu.api");
866 assert_eq!(render("${missing}", &c), "${missing}");
867 assert_eq!(render("no tokens", &c), "no tokens");
868 assert_eq!(render("${token}${region}", &c), "abceu");
869 }
870
871 #[test]
872 fn render_handles_unterminated_and_utf8() {
873 let c = ctx(&[("x", "✓")]);
874 assert_eq!(render("prefix ${x} ünïcode", &c), "prefix ✓ ünïcode");
875 assert_eq!(render("dangling ${x", &c), "dangling ${x");
876 }
877
878 #[test]
879 fn value_to_string_covers_kinds() {
880 assert_eq!(value_to_string(&json!("s")), "s");
881 assert_eq!(value_to_string(&json!(7)), "7");
882 assert_eq!(value_to_string(&json!(true)), "true");
883 assert_eq!(value_to_string(&json!(null)), "");
884 assert_eq!(value_to_string(&json!([1, 2])), "[1,2]");
885 }
886
887 #[test]
888 fn jsonpath_first_extracts_scalar() {
889 let body = json!({"data": {"BhRestToken": "tok", "restUrl": "https://host/x"}});
890 assert_eq!(
891 jsonpath_first(&body, "$.data.BhRestToken"),
892 Some(json!("tok"))
893 );
894 assert_eq!(
895 jsonpath_first(&body, "$.data.restUrl"),
896 Some(json!("https://host/x"))
897 );
898 assert_eq!(jsonpath_first(&body, "$.data.missing"), None);
899 }
900
901 #[test]
902 fn hmac_hex_and_base64_are_deterministic() {
903 let hex = hmac_sign("key", "message", SigEncoding::Hex);
904 assert_eq!(
906 hex,
907 "6e9ef29b75fffc5b7abae527d58fdadb2fe42e7219011976917343065f58ed4a"
908 );
909 let b64 = hmac_sign("key", "message", SigEncoding::Base64);
910 assert_eq!(b64, "bp7ym3X//Ft6uuUn1Y/a2y/kLnIZARl2kXNDBl9Y7Uo=");
911 }
912
913 #[test]
914 fn build_request_auth_places_header_query_cookie_body() {
915 let apply: Vec<ApplySpec> = serde_json::from_value(json!([
916 { "into": "header", "name": "X-Tok", "value": "${tok}" },
917 { "into": "query", "name": "access_token", "value": "${tok}" },
918 { "into": "cookie", "name": "sid", "value": "${sid}" },
919 { "into": "body", "name": "auth", "value": "${tok}" }
920 ]))
921 .unwrap();
922 let c = ctx(&[("tok", "T"), ("sid", "S")]);
923 let ra = build_request_auth(&apply, &None, &c);
924 assert_eq!(ra.placements.len(), 4);
925 assert!(
926 matches!(&ra.placements[0], CredentialPlacement::Header { name, value } if name=="X-Tok" && value=="T")
927 );
928 assert!(
929 matches!(&ra.placements[1], CredentialPlacement::Query { name, value } if name=="access_token" && value=="T")
930 );
931 assert!(
932 matches!(&ra.placements[2], CredentialPlacement::Cookie { name, value } if name=="sid" && value=="S")
933 );
934 assert!(
935 matches!(&ra.placements[3], CredentialPlacement::BodyField { name, value } if name=="auth" && value=="T")
936 );
937 assert!(ra.base_url.is_none());
938 assert_eq!(ra.captured.get("tok").map(String::as_str), Some("T"));
941 assert_eq!(ra.captured.get("sid").map(String::as_str), Some("S"));
942 }
943
944 #[test]
945 fn build_request_auth_signs_and_sets_base_url() {
946 let apply: Vec<ApplySpec> = serde_json::from_value(json!([
947 { "sign": { "alg": "hmac_sha256", "key": "secret", "template": "${client}:${ts}",
948 "encoding": "hex", "into": { "header": "Authorization", "format": "SS ${sig}" } } }
949 ]))
950 .unwrap();
951 let mut c = ctx(&[
952 ("client", "abc"),
953 ("ts", "100"),
954 ("base_url", "https://eu.host"),
955 ]);
956 c.insert("base_url".to_owned(), "https://eu.host".to_owned());
957 let ra = build_request_auth(&apply, &Some("${base_url}".to_owned()), &c);
958 assert_eq!(ra.placements.len(), 1);
959 let expected = format!("SS {}", hmac_sign("secret", "abc:100", SigEncoding::Hex));
960 assert!(
961 matches!(&ra.placements[0], CredentialPlacement::Header { name, value } if name=="Authorization" && *value==expected)
962 );
963 assert_eq!(ra.base_url.as_deref(), Some("https://eu.host"));
964 }
965
966 #[test]
967 fn base_url_from_empty_render_is_ignored() {
968 let ra = build_request_auth(&[], &Some("${missing_and_stripped}".to_owned()), &ctx(&[]));
969 assert!(ra.base_url.is_some());
972 let ra2 = build_request_auth(&[], &Some("${e}".to_owned()), &ctx(&[("e", "")]));
973 assert!(ra2.base_url.is_none());
974 }
975
976 #[test]
977 fn config_validate_requires_steps_or_apply() {
978 let empty: FlowConfig = serde_json::from_value(json!({})).unwrap();
979 assert!(empty.validate().is_err());
980 }
981
982 #[test]
983 fn config_rejects_form_and_json_together() {
984 let cfg: FlowConfig = serde_json::from_value(json!({
985 "steps": [ { "request": { "url": "https://x", "form": {"a":"b"}, "json": {"c":"d"} } } ]
986 }))
987 .unwrap();
988 assert!(cfg.validate().is_err());
989 }
990
991 #[test]
992 fn config_rejects_empty_place_name_and_sign_header() {
993 let bad_place: FlowConfig = serde_json::from_value(json!({
994 "apply": [ { "into": "query", "name": "", "value": "${x}" } ]
995 }))
996 .unwrap();
997 assert!(bad_place.validate().is_err());
998
999 let bad_sign: FlowConfig = serde_json::from_value(json!({
1000 "apply": [ { "sign": { "alg": "hmac_sha256", "key": "k", "template": "${ts}",
1001 "into": { "header": "" } } } ]
1002 }))
1003 .unwrap();
1004 assert!(bad_sign.validate().is_err());
1005 }
1006
1007 #[test]
1008 fn from_config_rejects_unknown_field() {
1009 assert!(FlowProvider::from_config(&json!({ "bogus": 1 })).is_err());
1010 }
1011
1012 #[test]
1013 fn build_provider_dispatches_flow() {
1014 let p = crate::build_provider(&json!({
1015 "type": "flow",
1016 "config": { "apply": [ { "into": "header", "name": "X", "value": "v" } ] }
1017 }))
1018 .unwrap();
1019 assert_eq!(p.provider_name(), "flow");
1020 }
1021
1022 #[test]
1023 fn debug_redacts_and_summarizes() {
1024 let p = FlowProvider::from_config(&json!({
1025 "apply": [ { "into": "header", "name": "X", "value": "${t}" } ]
1026 }))
1027 .unwrap();
1028 let s = format!("{p:?}");
1029 assert!(s.contains("FlowProvider"));
1030 assert!(s.contains("apply"));
1031 }
1032
1033 use std::collections::BTreeMap;
1034 use std::sync::Arc;
1035 use std::sync::atomic::{AtomicUsize, Ordering};
1036 use wiremock::matchers::{body_json, header, method, path};
1037 use wiremock::{Mock, MockServer, Respond, ResponseTemplate};
1038
1039 #[tokio::test]
1040 async fn login_chain_captures_placements_and_base_url() {
1041 let server = MockServer::start().await;
1042 Mock::given(method("POST"))
1043 .and(path("/token"))
1044 .respond_with(ResponseTemplate::new(200).set_body_json(json!({"access_token": "AT"})))
1045 .mount(&server)
1046 .await;
1047 Mock::given(method("GET"))
1048 .and(path("/login"))
1049 .respond_with(
1050 ResponseTemplate::new(200).set_body_json(
1051 json!({"BhRestToken": "BRT", "restUrl": "https://data.example"}),
1052 ),
1053 )
1054 .mount(&server)
1055 .await;
1056
1057 let cfg = json!({
1058 "steps": [
1059 { "request": { "method": "POST", "url": format!("{}/token", server.uri()),
1060 "form": {"grant_type": "refresh_token"} },
1061 "capture": { "access_token": "$.access_token" } },
1062 { "request": { "method": "GET", "url": format!("{}/login", server.uri()),
1063 "query": {"access_token": "${access_token}"} },
1064 "capture": { "bh_rest_token": "$.BhRestToken", "base_url": "$.restUrl" } }
1065 ],
1066 "apply": [ { "into": "query", "name": "BhRestToken", "value": "${bh_rest_token}" } ],
1067 "base_url_from": "${base_url}",
1068 "reauth_on": [401]
1069 });
1070 let p = FlowProvider::from_config(&cfg).unwrap();
1071 let ra = p
1072 .request_auth("GET", "https://data.example/x", &BTreeMap::new())
1073 .await
1074 .unwrap();
1075 assert_eq!(ra.base_url.as_deref(), Some("https://data.example"));
1076 assert!(
1077 matches!(&ra.placements[0], CredentialPlacement::Query { name, value } if name == "BhRestToken" && value == "BRT")
1078 );
1079 assert_eq!(p.reauth_statuses(), &[401]);
1080 assert!(p.credential().await.is_err());
1082 }
1083
1084 struct CountingLogin(Arc<AtomicUsize>);
1085 impl Respond for CountingLogin {
1086 fn respond(&self, _: &wiremock::Request) -> ResponseTemplate {
1087 let n = self.0.fetch_add(1, Ordering::SeqCst) + 1;
1088 ResponseTemplate::new(200).set_body_json(json!({ "sid": format!("S{n}") }))
1089 }
1090 }
1091
1092 #[tokio::test]
1093 async fn header_credential_caches_then_reloads_on_invalidate() {
1094 let server = MockServer::start().await;
1095 let hits = Arc::new(AtomicUsize::new(0));
1096 Mock::given(method("POST"))
1097 .and(path("/login"))
1098 .respond_with(CountingLogin(hits.clone()))
1099 .mount(&server)
1100 .await;
1101 let cfg = json!({
1102 "steps": [ { "request": { "method": "POST", "url": format!("{}/login", server.uri()) },
1103 "capture": { "sid": "$.sid" } } ],
1104 "apply": [ { "into": "header", "name": "X-Session", "value": "${sid}" } ]
1105 });
1106 let p = FlowProvider::from_config(&cfg).unwrap();
1107
1108 let c1 = p.credential().await.unwrap();
1109 assert!(
1110 matches!(&c1, Credential::Header { name, value } if name == "X-Session" && value == "S1")
1111 );
1112 let _ = p.credential().await.unwrap();
1114 assert_eq!(hits.load(Ordering::SeqCst), 1);
1115 let c2 = p.invalidate(&c1).await.unwrap();
1117 assert!(matches!(&c2, Credential::Header { value, .. } if value == "S2"));
1118 assert_eq!(hits.load(Ordering::SeqCst), 2);
1119 }
1120
1121 #[tokio::test]
1122 async fn login_capture_miss_is_an_error() {
1123 let server = MockServer::start().await;
1124 Mock::given(method("GET"))
1125 .and(path("/x"))
1126 .respond_with(ResponseTemplate::new(200).set_body_json(json!({"other": 1})))
1127 .mount(&server)
1128 .await;
1129 let cfg = json!({
1130 "steps": [ { "request": { "url": format!("{}/x", server.uri()) },
1131 "capture": { "tok": "$.access_token" } } ],
1132 "apply": [ { "into": "header", "name": "X", "value": "${tok}" } ]
1133 });
1134 let p = FlowProvider::from_config(&cfg).unwrap();
1135 assert!(p.credential().await.is_err());
1136 }
1137
1138 #[tokio::test]
1139 async fn login_non_success_is_an_error() {
1140 let server = MockServer::start().await;
1141 Mock::given(method("GET"))
1142 .and(path("/x"))
1143 .respond_with(ResponseTemplate::new(500))
1144 .mount(&server)
1145 .await;
1146 let cfg = json!({
1147 "steps": [ { "request": { "url": format!("{}/x", server.uri()) } } ],
1148 "apply": [ { "into": "header", "name": "X", "value": "static" } ]
1149 });
1150 let p = FlowProvider::from_config(&cfg).unwrap();
1151 assert!(
1152 p.request_auth("GET", "https://x", &BTreeMap::new())
1153 .await
1154 .is_err()
1155 );
1156 }
1157
1158 #[test]
1159 fn config_rejects_empty_step_url() {
1160 let cfg: FlowConfig = serde_json::from_value(json!({
1161 "steps": [ { "request": { "url": " " } } ]
1162 }))
1163 .unwrap();
1164 assert!(cfg.validate().is_err());
1165 }
1166
1167 #[tokio::test]
1168 async fn session_ttl_caches_within_window() {
1169 let server = MockServer::start().await;
1170 let hits = Arc::new(AtomicUsize::new(0));
1171 Mock::given(method("POST"))
1172 .and(path("/login"))
1173 .respond_with(CountingLogin(hits.clone()))
1174 .mount(&server)
1175 .await;
1176 let cfg = json!({
1177 "steps": [ { "request": { "method": "POST", "url": format!("{}/login", server.uri()) },
1178 "capture": { "sid": "$.sid" } } ],
1179 "apply": [ { "into": "header", "name": "X", "value": "${sid}" } ],
1180 "ttl_secs": 3600
1181 });
1182 let p = FlowProvider::from_config(&cfg).unwrap();
1183 let _ = p.credential().await.unwrap();
1184 let _ = p.credential().await.unwrap();
1186 assert_eq!(hits.load(Ordering::SeqCst), 1);
1187 }
1188
1189 #[tokio::test]
1190 async fn login_sends_headers_and_json_body() {
1191 let server = MockServer::start().await;
1192 Mock::given(method("POST"))
1193 .and(path("/login"))
1194 .and(header("x-tenant", "acme"))
1195 .and(body_json(json!({"scope": "read"})))
1196 .respond_with(ResponseTemplate::new(200).set_body_json(json!({"tok": "T"})))
1197 .mount(&server)
1198 .await;
1199 let cfg = json!({
1200 "steps": [ { "request": { "method": "POST", "url": format!("{}/login", server.uri()),
1201 "headers": {"X-Tenant": "acme"}, "json": {"scope": "read"} },
1202 "capture": { "t": "$.tok" } } ],
1203 "apply": [ { "into": "header", "name": "Authorization", "value": "Bearer ${t}" } ]
1204 });
1205 let p = FlowProvider::from_config(&cfg).unwrap();
1206 assert_eq!(p.provider_name(), "flow");
1207 let c = p.credential().await.unwrap();
1208 assert!(
1209 matches!(&c, Credential::Header { name, value } if name == "Authorization" && value == "Bearer T")
1210 );
1211 }
1212
1213 #[tokio::test]
1214 async fn login_invalid_method_errors() {
1215 let cfg = json!({
1216 "steps": [ { "request": { "method": "BAD METHOD", "url": "https://x/login" } } ],
1217 "apply": [ { "into": "header", "name": "X", "value": "static" } ]
1218 });
1219 let p = FlowProvider::from_config(&cfg).unwrap();
1220 assert!(
1221 p.request_auth("GET", "https://x", &BTreeMap::new())
1222 .await
1223 .is_err()
1224 );
1225 }
1226
1227 #[tokio::test]
1228 async fn login_send_failure_errors() {
1229 let cfg = json!({
1231 "steps": [ { "request": { "url": "http://127.0.0.1:1/x" }, "capture": { "t": "$.t" } } ],
1232 "apply": [ { "into": "header", "name": "X", "value": "${t}" } ]
1233 });
1234 let p = FlowProvider::from_config(&cfg).unwrap();
1235 assert!(
1236 p.request_auth("GET", "https://x", &BTreeMap::new())
1237 .await
1238 .is_err()
1239 );
1240 }
1241
1242 #[tokio::test]
1243 async fn login_non_json_response_errors() {
1244 let server = MockServer::start().await;
1245 Mock::given(method("GET"))
1246 .and(path("/x"))
1247 .respond_with(ResponseTemplate::new(200).set_body_string("not json"))
1248 .mount(&server)
1249 .await;
1250 let cfg = json!({
1251 "steps": [ { "request": { "url": format!("{}/x", server.uri()) }, "capture": { "t": "$.t" } } ],
1252 "apply": [ { "into": "header", "name": "X", "value": "${t}" } ]
1253 });
1254 let p = FlowProvider::from_config(&cfg).unwrap();
1255 assert!(
1256 p.request_auth("GET", "https://x", &BTreeMap::new())
1257 .await
1258 .is_err()
1259 );
1260 }
1261
1262 #[tokio::test]
1263 async fn login_step_with_empty_capture_succeeds() {
1264 let server = MockServer::start().await;
1267 Mock::given(method("GET"))
1268 .and(path("/ping"))
1269 .respond_with(ResponseTemplate::new(200).set_body_string("ok"))
1270 .mount(&server)
1271 .await;
1272 Mock::given(method("GET"))
1273 .and(path("/token"))
1274 .respond_with(ResponseTemplate::new(200).set_body_json(json!({"t": "T"})))
1275 .mount(&server)
1276 .await;
1277 let cfg = json!({
1278 "steps": [
1279 { "request": { "url": format!("{}/ping", server.uri()) } },
1280 { "request": { "url": format!("{}/token", server.uri()) }, "capture": { "t": "$.t" } }
1281 ],
1282 "apply": [ { "into": "header", "name": "X", "value": "${t}" } ]
1283 });
1284 let p = FlowProvider::from_config(&cfg).unwrap();
1285 let c = p.credential().await.unwrap();
1286 assert!(matches!(&c, Credential::Header { value, .. } if value == "T"));
1287 }
1288
1289 #[tokio::test]
1290 async fn no_steps_apply_only_flow_works() {
1291 let cfg = json!({
1294 "apply": [ { "sign": { "alg": "hmac_sha256", "key": "k", "template": "msg",
1295 "into": { "header": "Authorization", "format": "HMAC ${sig}" } } } ]
1296 });
1297 let p = FlowProvider::from_config(&cfg).unwrap();
1298 let c = p.credential().await.unwrap();
1299 let expected = format!("HMAC {}", hmac_sign("k", "msg", SigEncoding::Hex));
1300 assert!(
1301 matches!(&c, Credential::Header { name, value } if name == "Authorization" && *value == expected)
1302 );
1303 }
1304
1305 #[test]
1308 fn sign_header_computes_name_and_value_with_clock() {
1309 let mut c = ctx(&[("client", "abc")]);
1310 c.insert("ts".to_owned(), "999".to_owned());
1311 let sign: SignSpec = serde_json::from_value(json!({
1312 "alg": "hmac_sha256", "key": "k", "template": "${client}:${ts}",
1313 "encoding": "hex", "into": { "header": "Authorization", "format": "SS ${sig}" }
1314 }))
1315 .unwrap();
1316 let (name, value) = sign_header(&sign, &c);
1317 assert_eq!(name, "Authorization");
1318 assert_eq!(
1319 value,
1320 format!("SS {}", hmac_sign("k", "abc:999", SigEncoding::Hex))
1321 );
1322 }
1323
1324 #[tokio::test]
1325 async fn signed_login_step_sends_computed_signature_header() {
1326 let server = MockServer::start().await;
1327 let key = "secret_key";
1328 let expected = format!(
1330 "SS access:{}",
1331 hmac_sign(key, "client:secret", SigEncoding::Base64)
1332 );
1333 Mock::given(method("POST"))
1334 .and(path("/auth/login"))
1335 .and(header("authorization", expected.as_str()))
1336 .respond_with(
1337 ResponseTemplate::new(200).set_body_json(json!({"session": {"token": "SESS"}})),
1338 )
1339 .mount(&server)
1340 .await;
1341 let cfg = json!({
1342 "steps": [ {
1343 "request": {
1344 "method": "POST",
1345 "url": format!("{}/auth/login", server.uri()),
1346 "json": {"clientId": "client"},
1347 "sign": { "alg": "hmac_sha256", "key": key, "template": "client:secret",
1348 "encoding": "base64",
1349 "into": { "header": "Authorization", "format": "SS access:${sig}" } }
1350 },
1351 "capture": { "session": "$.session.token" }
1352 } ],
1353 "apply": [ { "into": "header", "name": "Session", "value": "${session}" } ]
1354 });
1355 let p = FlowProvider::from_config(&cfg).unwrap();
1356 let c = p.credential().await.unwrap();
1359 assert!(
1360 matches!(&c, Credential::Header { name, value } if name == "Session" && value == "SESS")
1361 );
1362 }
1363
1364 #[tokio::test]
1365 async fn skyslope_style_signed_login_then_signed_data_requests() {
1366 let server = MockServer::start().await;
1367 let key = "base64secret";
1368 let login_sig = format!(
1369 "SS access:{}",
1370 hmac_sign(key, "cid:csec", SigEncoding::Base64)
1371 );
1372 Mock::given(method("POST"))
1373 .and(path("/auth/login"))
1374 .and(header("authorization", login_sig.as_str()))
1375 .respond_with(
1376 ResponseTemplate::new(200)
1377 .set_body_json(json!({"session": {"token": "SESSION-TOK"}})),
1378 )
1379 .mount(&server)
1380 .await;
1381 let signer = json!({ "alg": "hmac_sha256", "key": key, "template": "cid:csec",
1382 "encoding": "base64",
1383 "into": { "header": "Authorization", "format": "SS access:${sig}" } });
1384 let cfg = json!({
1385 "steps": [ {
1386 "request": { "method": "POST", "url": format!("{}/auth/login", server.uri()),
1387 "json": {"clientId": "cid"}, "sign": signer },
1388 "capture": { "session": "$.session.token" }
1389 } ],
1390 "apply": [
1391 { "sign": signer },
1392 { "into": "header", "name": "Session", "value": "${session}" }
1393 ]
1394 });
1395 let p = FlowProvider::from_config(&cfg).unwrap();
1396 let ra = p
1397 .request_auth("GET", "https://data", &BTreeMap::new())
1398 .await
1399 .unwrap();
1400 let has_auth = ra.placements.iter().any(|pl| {
1401 matches!(pl, CredentialPlacement::Header { name, value } if name == "Authorization" && *value == login_sig)
1402 });
1403 let has_sess = ra.placements.iter().any(|pl| {
1404 matches!(pl, CredentialPlacement::Header { name, value } if name == "Session" && value == "SESSION-TOK")
1405 });
1406 assert!(has_auth, "data request carries the signer header");
1407 assert!(has_sess, "data request carries the captured Session header");
1408 }
1409
1410 #[test]
1411 fn config_rejects_empty_step_sign_header() {
1412 let cfg: FlowConfig = serde_json::from_value(json!({
1413 "steps": [ { "request": { "url": "https://x",
1414 "sign": { "alg": "hmac_sha256", "key": "k", "template": "${ts}",
1415 "into": { "header": "" } } } } ]
1416 }))
1417 .unwrap();
1418 assert!(cfg.validate().is_err());
1419 }
1420
1421 #[test]
1424 fn xml_dot_path_extracts_nested_text() {
1425 let xml = "<operation><result><data><api><sessionid>ABC123</sessionid></api></data></result></operation>";
1426 assert_eq!(
1427 xml_dot_path(xml, "operation.result.data.api.sessionid").as_deref(),
1428 Some("ABC123")
1429 );
1430 assert_eq!(xml_dot_path(xml, "operation.result.missing"), None);
1431 }
1432
1433 #[test]
1434 fn xml_dot_path_ignores_declaration_attrs_and_namespaces() {
1435 let xml = r#"<?xml version="1.0"?><ns:root xmlns:ns="urn:x"><ns:child id="1"> hi </ns:child></ns:root>"#;
1436 assert_eq!(xml_dot_path(xml, "root.child").as_deref(), Some("hi"));
1437 assert_eq!(xml_dot_path(xml, "ns:root.ns:child").as_deref(), Some("hi"));
1439 }
1440
1441 #[test]
1442 fn xml_dot_path_handles_cdata_and_entities() {
1443 let xml = "<r><a><![CDATA[a&b]]></a><b>x & y</b></r>";
1444 assert_eq!(xml_dot_path(xml, "r.a").as_deref(), Some("a&b"));
1445 assert_eq!(xml_dot_path(xml, "r.b").as_deref(), Some("x & y"));
1446 }
1447
1448 #[test]
1449 fn set_cookie_and_header_helpers_select_by_name() {
1450 let mut h = reqwest::header::HeaderMap::new();
1451 h.append("set-cookie", "a=1; path=/".parse().unwrap());
1452 h.append(
1453 "set-cookie",
1454 "ASP.NET_SessionId=SID; path=/; HttpOnly".parse().unwrap(),
1455 );
1456 h.insert("location", "https://x/next".parse().unwrap());
1457 assert_eq!(
1458 set_cookie_value(&h, "ASP.NET_SessionId").as_deref(),
1459 Some("SID")
1460 );
1461 assert_eq!(set_cookie_value(&h, "missing"), None);
1462 assert_eq!(
1463 header_value(&h, "Location").as_deref(),
1464 Some("https://x/next")
1465 );
1466 assert_eq!(header_value(&h, "absent"), None);
1467 }
1468
1469 #[test]
1470 fn capture_backcompat_string_form_parses_as_json() {
1471 let step: FlowStep = serde_json::from_value(json!({
1472 "request": { "url": "https://x" },
1473 "capture": { "tok": "$.access_token" }
1474 }))
1475 .unwrap();
1476 assert_eq!(step.capture["tok"].kind(), CaptureFrom::Json);
1477 assert!(matches!(&step.capture["tok"], CaptureSpec::Json(p) if p == "$.access_token"));
1478 }
1479
1480 #[test]
1481 fn capture_struct_forms_parse_kinds() {
1482 let step: FlowStep = serde_json::from_value(json!({
1483 "request": { "url": "https://x" },
1484 "capture": {
1485 "h": { "from": "header", "name": "Location" },
1486 "c": { "from": "set_cookie", "name": "sid" },
1487 "x": { "from": "xml", "path": "a.b" },
1488 "j": { "from": "json", "path": "$.tok" }
1489 }
1490 }))
1491 .unwrap();
1492 assert_eq!(step.capture["h"].kind(), CaptureFrom::Header);
1493 assert_eq!(step.capture["c"].kind(), CaptureFrom::SetCookie);
1494 assert_eq!(step.capture["x"].kind(), CaptureFrom::Xml);
1495 assert_eq!(step.capture["j"].kind(), CaptureFrom::Json);
1496 }
1497
1498 #[test]
1499 fn config_rejects_capture_missing_selector() {
1500 let cfg: FlowConfig = serde_json::from_value(json!({
1501 "steps": [ { "request": { "url": "https://x" }, "capture": { "h": { "from": "header" } } } ]
1502 }))
1503 .unwrap();
1504 assert!(cfg.validate().is_err());
1505
1506 let cfg2: FlowConfig = serde_json::from_value(json!({
1507 "steps": [ { "request": { "url": "https://x" }, "capture": { "x": { "from": "xml" } } } ]
1508 }))
1509 .unwrap();
1510 assert!(cfg2.validate().is_err());
1511 }
1512
1513 #[tokio::test]
1514 async fn capture_from_header_works() {
1515 let server = MockServer::start().await;
1516 Mock::given(method("GET"))
1517 .and(path("/login"))
1518 .respond_with(
1519 ResponseTemplate::new(200)
1520 .insert_header("Location", "https://redirect.example/next"),
1521 )
1522 .mount(&server)
1523 .await;
1524 let cfg = json!({
1525 "steps": [ { "request": { "url": format!("{}/login", server.uri()) },
1526 "capture": { "loc": { "from": "header", "name": "Location" } } } ],
1527 "apply": [ { "into": "header", "name": "X-Loc", "value": "${loc}" } ]
1528 });
1529 let p = FlowProvider::from_config(&cfg).unwrap();
1530 let c = p.credential().await.unwrap();
1531 assert!(
1532 matches!(&c, Credential::Header { name, value } if name == "X-Loc" && value == "https://redirect.example/next")
1533 );
1534 }
1535
1536 #[tokio::test]
1537 async fn capture_from_set_cookie_applies_as_cookie() {
1538 let server = MockServer::start().await;
1540 Mock::given(method("POST"))
1541 .and(path("/entity/auth/login"))
1542 .respond_with(
1543 ResponseTemplate::new(204)
1544 .append_header("Set-Cookie", "other=nope; path=/")
1545 .append_header("Set-Cookie", "ASP.NET_SessionId=SID123; path=/; HttpOnly"),
1546 )
1547 .mount(&server)
1548 .await;
1549 let cfg = json!({
1550 "steps": [ { "request": { "method": "POST",
1551 "url": format!("{}/entity/auth/login", server.uri()),
1552 "json": {"name": "u", "password": "p"} },
1553 "capture": { "session_cookie": { "from": "set_cookie", "name": "ASP.NET_SessionId" } } } ],
1554 "apply": [ { "into": "cookie", "name": "ASP.NET_SessionId", "value": "${session_cookie}" } ]
1555 });
1556 let p = FlowProvider::from_config(&cfg).unwrap();
1557 let ra = p
1558 .request_auth("GET", "https://data", &BTreeMap::new())
1559 .await
1560 .unwrap();
1561 assert!(
1562 matches!(&ra.placements[0], CredentialPlacement::Cookie { name, value } if name == "ASP.NET_SessionId" && value == "SID123")
1563 );
1564 }
1565
1566 #[tokio::test]
1567 async fn capture_from_xml_body_works() {
1568 let server = MockServer::start().await;
1570 let xml = r#"<?xml version="1.0" encoding="UTF-8"?><response><operation><result><data><api><sessionid>XYZ-SESSION</sessionid></api></data></result></operation></response>"#;
1571 Mock::given(method("POST"))
1572 .and(path("/xml/xmlgw.phtml"))
1573 .respond_with(ResponseTemplate::new(200).set_body_string(xml))
1574 .mount(&server)
1575 .await;
1576 let cfg = json!({
1577 "steps": [ { "request": { "method": "POST",
1578 "url": format!("{}/xml/xmlgw.phtml", server.uri()) },
1579 "capture": { "sess_id": { "from": "xml",
1580 "path": "response.operation.result.data.api.sessionid" } } } ],
1581 "apply": [ { "into": "header", "name": "X-Session", "value": "${sess_id}" } ]
1582 });
1583 let p = FlowProvider::from_config(&cfg).unwrap();
1584 let c = p.credential().await.unwrap();
1585 assert!(matches!(&c, Credential::Header { value, .. } if value == "XYZ-SESSION"));
1586 }
1587
1588 #[tokio::test]
1589 async fn capture_from_xml_miss_is_an_error() {
1590 let server = MockServer::start().await;
1591 Mock::given(method("POST"))
1592 .and(path("/x"))
1593 .respond_with(ResponseTemplate::new(200).set_body_string("<r><a>1</a></r>"))
1594 .mount(&server)
1595 .await;
1596 let cfg = json!({
1597 "steps": [ { "request": { "method": "POST", "url": format!("{}/x", server.uri()) },
1598 "capture": { "s": { "from": "xml", "path": "r.missing" } } } ],
1599 "apply": [ { "into": "header", "name": "X", "value": "${s}" } ]
1600 });
1601 let p = FlowProvider::from_config(&cfg).unwrap();
1602 assert!(p.credential().await.is_err());
1603 }
1604}