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
588}
589
590struct Session {
593 ctx: HashMap<String, String>,
595 expires_at: Option<Instant>,
596}
597
598impl Session {
599 fn valid(&self) -> bool {
600 match self.expires_at {
601 Some(exp) => Instant::now() < exp,
602 None => true,
603 }
604 }
605}
606
607pub struct FlowProvider {
609 http: reqwest::Client,
610 config: FlowConfig,
611 state: Mutex<Option<Session>>,
612}
613
614impl std::fmt::Debug for FlowProvider {
615 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
616 f.debug_struct("FlowProvider")
617 .field("steps", &self.config.steps.len())
618 .field("apply", &self.config.apply.len())
619 .finish()
620 }
621}
622
623impl FlowProvider {
624 pub fn from_config(config: &Value) -> Result<Self, FaucetError> {
626 let config: FlowConfig = serde_json::from_value(config.clone())
627 .map_err(|e| FaucetError::Config(format!("flow auth: invalid config: {e}")))?;
628 config.validate()?;
629 Ok(Self {
630 http: auth_http_client(),
631 config,
632 state: Mutex::new(None),
633 })
634 }
635
636 fn clock_ctx() -> HashMap<String, String> {
638 let now = SystemTime::now()
639 .duration_since(UNIX_EPOCH)
640 .map(|d| d.as_secs())
641 .unwrap_or(0);
642 let nanos = SystemTime::now()
643 .duration_since(UNIX_EPOCH)
644 .map(|d| d.subsec_nanos())
645 .unwrap_or(0);
646 let mut ctx = HashMap::new();
647 ctx.insert("ts".to_owned(), now.to_string());
648 ctx.insert("nonce".to_owned(), format!("{now}{nanos}"));
649 ctx
650 }
651
652 async fn run_login(&self) -> Result<HashMap<String, String>, FaucetError> {
654 let mut ctx: HashMap<String, String> = HashMap::new();
655 for (i, step) in self.config.steps.iter().enumerate() {
656 let req = &step.request;
657 let method = reqwest::Method::from_bytes(req.method.to_uppercase().as_bytes())
658 .map_err(|_| {
659 FaucetError::Config(format!(
660 "flow auth: step {i} has an invalid HTTP method '{}'",
661 req.method
662 ))
663 })?;
664 let url = render(&req.url, &ctx);
665 let mut builder = self.http.request(method, &url);
666 for (k, v) in &req.headers {
667 builder = builder.header(k.as_str(), render(v, &ctx));
668 }
669 if !req.query.is_empty() {
670 let q: Vec<(String, String)> = req
671 .query
672 .iter()
673 .map(|(k, v)| (k.clone(), render(v, &ctx)))
674 .collect();
675 builder = builder.query(&q);
676 }
677 if let Some(form) = &req.form {
678 let f: Vec<(String, String)> = form
679 .iter()
680 .map(|(k, v)| (k.clone(), render(v, &ctx)))
681 .collect();
682 builder = builder.form(&f);
683 } else if let Some(json) = &req.json {
684 let rendered = render(&serde_json::to_string(json).unwrap_or_default(), &ctx);
685 let body: Value = serde_json::from_str(&rendered).unwrap_or_else(|_| json.clone());
686 builder = builder.json(&body);
687 }
688 if let Some(sign) = &req.sign {
691 let mut sign_ctx = ctx.clone();
692 sign_ctx.extend(Self::clock_ctx());
693 let (name, value) = sign_header(sign, &sign_ctx);
694 builder = builder.header(name.as_str(), value);
695 }
696 let resp = builder.send().await.map_err(|e| {
697 FaucetError::Auth(format!("flow auth: step {i} request failed: {e}"))
698 })?;
699 let status = resp.status();
700 if !status.is_success() {
701 return Err(FaucetError::Auth(format!(
702 "flow auth: step {i} returned HTTP {}",
703 status.as_u16()
704 )));
705 }
706 let headers = resp.headers().clone();
709 let needs_json = step.capture.values().any(|c| c.kind() == CaptureFrom::Json);
710 let needs_xml = step.capture.values().any(|c| c.kind() == CaptureFrom::Xml);
711 let text: Option<String> = if needs_json || needs_xml {
712 Some(resp.text().await.map_err(|e| {
713 FaucetError::Auth(format!(
714 "flow auth: step {i} failed to read response body: {e}"
715 ))
716 })?)
717 } else {
718 None
719 };
720 let json_body: Option<Value> = if needs_json {
721 Some(
722 serde_json::from_str(text.as_deref().unwrap_or("")).map_err(|e| {
723 FaucetError::Auth(format!(
724 "flow auth: step {i} response was not valid JSON: {e}"
725 ))
726 })?,
727 )
728 } else {
729 None
730 };
731 for (name, cap) in &step.capture {
732 let extracted = match cap {
733 CaptureSpec::Json(path) => json_body
734 .as_ref()
735 .and_then(|b| jsonpath_first(b, path))
736 .map(|v| value_to_string(&v)),
737 CaptureSpec::Source(s) => match s.from {
738 CaptureFrom::Json => json_body
739 .as_ref()
740 .and_then(|b| jsonpath_first(b, s.path.as_deref().unwrap_or("")))
741 .map(|v| value_to_string(&v)),
742 CaptureFrom::Xml => text
743 .as_deref()
744 .and_then(|t| xml_dot_path(t, s.path.as_deref().unwrap_or(""))),
745 CaptureFrom::Header => {
746 header_value(&headers, s.name.as_deref().unwrap_or(""))
747 }
748 CaptureFrom::SetCookie => {
749 set_cookie_value(&headers, s.name.as_deref().unwrap_or(""))
750 }
751 },
752 };
753 match extracted {
754 Some(v) => {
755 ctx.insert(name.clone(), v);
756 }
757 None => {
758 return Err(FaucetError::Auth(format!(
759 "flow auth: step {i} capture '{name}' matched nothing"
760 )));
761 }
762 }
763 }
764 }
765 Ok(ctx)
766 }
767
768 async fn ensure_ctx(&self) -> Result<HashMap<String, String>, FaucetError> {
771 let mut guard = self.state.lock().await;
772 if let Some(s) = guard.as_ref()
773 && s.valid()
774 {
775 return Ok(s.ctx.clone());
776 }
777 let ctx = self.run_login().await?;
778 let expires_at = self
779 .config
780 .ttl_secs
781 .map(|s| Instant::now() + Duration::from_secs(s));
782 *guard = Some(Session {
783 ctx: ctx.clone(),
784 expires_at,
785 });
786 Ok(ctx)
787 }
788
789 fn request_auth_from_ctx(&self, captured: &HashMap<String, String>) -> RequestAuth {
790 let mut ctx = captured.clone();
791 ctx.extend(Self::clock_ctx());
792 build_request_auth(&self.config.apply, &self.config.base_url_from, &ctx)
793 }
794}
795
796#[async_trait]
797impl AuthProvider for FlowProvider {
798 async fn credential(&self) -> Result<Credential, FaucetError> {
799 let ctx = self.ensure_ctx().await?;
800 let auth = self.request_auth_from_ctx(&ctx);
801 for p in &auth.placements {
805 if let CredentialPlacement::Header { name, value } = p {
806 return Ok(Credential::Header {
807 name: name.clone(),
808 value: value.clone(),
809 });
810 }
811 }
812 Err(FaucetError::Auth(
813 "flow auth: no header credential to apply via credential(); this flow places its \
814 credential in a query/cookie/body, which requires a connector that consumes \
815 request_auth() (e.g. the REST source)"
816 .to_owned(),
817 ))
818 }
819
820 async fn invalidate(&self, _stale: &Credential) -> Result<Credential, FaucetError> {
821 *self.state.lock().await = None;
823 self.credential().await
824 }
825
826 async fn request_auth(
827 &self,
828 _method: &str,
829 _url: &str,
830 _query: &std::collections::BTreeMap<String, String>,
831 ) -> Result<RequestAuth, FaucetError> {
832 let ctx = self.ensure_ctx().await?;
833 Ok(self.request_auth_from_ctx(&ctx))
834 }
835
836 fn reauth_statuses(&self) -> &[u16] {
837 &self.config.reauth_on
838 }
839
840 fn provider_name(&self) -> &'static str {
841 "flow"
842 }
843}
844
845#[cfg(test)]
846mod tests {
847 use super::*;
848 use serde_json::json;
849
850 fn ctx(pairs: &[(&str, &str)]) -> HashMap<String, String> {
851 pairs
852 .iter()
853 .map(|(k, v)| (k.to_string(), v.to_string()))
854 .collect()
855 }
856
857 #[test]
858 fn render_substitutes_known_and_leaves_unknown() {
859 let c = ctx(&[("token", "abc"), ("region", "eu")]);
860 assert_eq!(render("Bearer ${token}", &c), "Bearer abc");
861 assert_eq!(render("https://${region}.api", &c), "https://eu.api");
862 assert_eq!(render("${missing}", &c), "${missing}");
863 assert_eq!(render("no tokens", &c), "no tokens");
864 assert_eq!(render("${token}${region}", &c), "abceu");
865 }
866
867 #[test]
868 fn render_handles_unterminated_and_utf8() {
869 let c = ctx(&[("x", "✓")]);
870 assert_eq!(render("prefix ${x} ünïcode", &c), "prefix ✓ ünïcode");
871 assert_eq!(render("dangling ${x", &c), "dangling ${x");
872 }
873
874 #[test]
875 fn value_to_string_covers_kinds() {
876 assert_eq!(value_to_string(&json!("s")), "s");
877 assert_eq!(value_to_string(&json!(7)), "7");
878 assert_eq!(value_to_string(&json!(true)), "true");
879 assert_eq!(value_to_string(&json!(null)), "");
880 assert_eq!(value_to_string(&json!([1, 2])), "[1,2]");
881 }
882
883 #[test]
884 fn jsonpath_first_extracts_scalar() {
885 let body = json!({"data": {"BhRestToken": "tok", "restUrl": "https://host/x"}});
886 assert_eq!(
887 jsonpath_first(&body, "$.data.BhRestToken"),
888 Some(json!("tok"))
889 );
890 assert_eq!(
891 jsonpath_first(&body, "$.data.restUrl"),
892 Some(json!("https://host/x"))
893 );
894 assert_eq!(jsonpath_first(&body, "$.data.missing"), None);
895 }
896
897 #[test]
898 fn hmac_hex_and_base64_are_deterministic() {
899 let hex = hmac_sign("key", "message", SigEncoding::Hex);
900 assert_eq!(
902 hex,
903 "6e9ef29b75fffc5b7abae527d58fdadb2fe42e7219011976917343065f58ed4a"
904 );
905 let b64 = hmac_sign("key", "message", SigEncoding::Base64);
906 assert_eq!(b64, "bp7ym3X//Ft6uuUn1Y/a2y/kLnIZARl2kXNDBl9Y7Uo=");
907 }
908
909 #[test]
910 fn build_request_auth_places_header_query_cookie_body() {
911 let apply: Vec<ApplySpec> = serde_json::from_value(json!([
912 { "into": "header", "name": "X-Tok", "value": "${tok}" },
913 { "into": "query", "name": "access_token", "value": "${tok}" },
914 { "into": "cookie", "name": "sid", "value": "${sid}" },
915 { "into": "body", "name": "auth", "value": "${tok}" }
916 ]))
917 .unwrap();
918 let c = ctx(&[("tok", "T"), ("sid", "S")]);
919 let ra = build_request_auth(&apply, &None, &c);
920 assert_eq!(ra.placements.len(), 4);
921 assert!(
922 matches!(&ra.placements[0], CredentialPlacement::Header { name, value } if name=="X-Tok" && value=="T")
923 );
924 assert!(
925 matches!(&ra.placements[1], CredentialPlacement::Query { name, value } if name=="access_token" && value=="T")
926 );
927 assert!(
928 matches!(&ra.placements[2], CredentialPlacement::Cookie { name, value } if name=="sid" && value=="S")
929 );
930 assert!(
931 matches!(&ra.placements[3], CredentialPlacement::BodyField { name, value } if name=="auth" && value=="T")
932 );
933 assert!(ra.base_url.is_none());
934 }
935
936 #[test]
937 fn build_request_auth_signs_and_sets_base_url() {
938 let apply: Vec<ApplySpec> = serde_json::from_value(json!([
939 { "sign": { "alg": "hmac_sha256", "key": "secret", "template": "${client}:${ts}",
940 "encoding": "hex", "into": { "header": "Authorization", "format": "SS ${sig}" } } }
941 ]))
942 .unwrap();
943 let mut c = ctx(&[
944 ("client", "abc"),
945 ("ts", "100"),
946 ("base_url", "https://eu.host"),
947 ]);
948 c.insert("base_url".to_owned(), "https://eu.host".to_owned());
949 let ra = build_request_auth(&apply, &Some("${base_url}".to_owned()), &c);
950 assert_eq!(ra.placements.len(), 1);
951 let expected = format!("SS {}", hmac_sign("secret", "abc:100", SigEncoding::Hex));
952 assert!(
953 matches!(&ra.placements[0], CredentialPlacement::Header { name, value } if name=="Authorization" && *value==expected)
954 );
955 assert_eq!(ra.base_url.as_deref(), Some("https://eu.host"));
956 }
957
958 #[test]
959 fn base_url_from_empty_render_is_ignored() {
960 let ra = build_request_auth(&[], &Some("${missing_and_stripped}".to_owned()), &ctx(&[]));
961 assert!(ra.base_url.is_some());
964 let ra2 = build_request_auth(&[], &Some("${e}".to_owned()), &ctx(&[("e", "")]));
965 assert!(ra2.base_url.is_none());
966 }
967
968 #[test]
969 fn config_validate_requires_steps_or_apply() {
970 let empty: FlowConfig = serde_json::from_value(json!({})).unwrap();
971 assert!(empty.validate().is_err());
972 }
973
974 #[test]
975 fn config_rejects_form_and_json_together() {
976 let cfg: FlowConfig = serde_json::from_value(json!({
977 "steps": [ { "request": { "url": "https://x", "form": {"a":"b"}, "json": {"c":"d"} } } ]
978 }))
979 .unwrap();
980 assert!(cfg.validate().is_err());
981 }
982
983 #[test]
984 fn config_rejects_empty_place_name_and_sign_header() {
985 let bad_place: FlowConfig = serde_json::from_value(json!({
986 "apply": [ { "into": "query", "name": "", "value": "${x}" } ]
987 }))
988 .unwrap();
989 assert!(bad_place.validate().is_err());
990
991 let bad_sign: FlowConfig = serde_json::from_value(json!({
992 "apply": [ { "sign": { "alg": "hmac_sha256", "key": "k", "template": "${ts}",
993 "into": { "header": "" } } } ]
994 }))
995 .unwrap();
996 assert!(bad_sign.validate().is_err());
997 }
998
999 #[test]
1000 fn from_config_rejects_unknown_field() {
1001 assert!(FlowProvider::from_config(&json!({ "bogus": 1 })).is_err());
1002 }
1003
1004 #[test]
1005 fn build_provider_dispatches_flow() {
1006 let p = crate::build_provider(&json!({
1007 "type": "flow",
1008 "config": { "apply": [ { "into": "header", "name": "X", "value": "v" } ] }
1009 }))
1010 .unwrap();
1011 assert_eq!(p.provider_name(), "flow");
1012 }
1013
1014 #[test]
1015 fn debug_redacts_and_summarizes() {
1016 let p = FlowProvider::from_config(&json!({
1017 "apply": [ { "into": "header", "name": "X", "value": "${t}" } ]
1018 }))
1019 .unwrap();
1020 let s = format!("{p:?}");
1021 assert!(s.contains("FlowProvider"));
1022 assert!(s.contains("apply"));
1023 }
1024
1025 use std::collections::BTreeMap;
1026 use std::sync::Arc;
1027 use std::sync::atomic::{AtomicUsize, Ordering};
1028 use wiremock::matchers::{body_json, header, method, path};
1029 use wiremock::{Mock, MockServer, Respond, ResponseTemplate};
1030
1031 #[tokio::test]
1032 async fn login_chain_captures_placements_and_base_url() {
1033 let server = MockServer::start().await;
1034 Mock::given(method("POST"))
1035 .and(path("/token"))
1036 .respond_with(ResponseTemplate::new(200).set_body_json(json!({"access_token": "AT"})))
1037 .mount(&server)
1038 .await;
1039 Mock::given(method("GET"))
1040 .and(path("/login"))
1041 .respond_with(
1042 ResponseTemplate::new(200).set_body_json(
1043 json!({"BhRestToken": "BRT", "restUrl": "https://data.example"}),
1044 ),
1045 )
1046 .mount(&server)
1047 .await;
1048
1049 let cfg = json!({
1050 "steps": [
1051 { "request": { "method": "POST", "url": format!("{}/token", server.uri()),
1052 "form": {"grant_type": "refresh_token"} },
1053 "capture": { "access_token": "$.access_token" } },
1054 { "request": { "method": "GET", "url": format!("{}/login", server.uri()),
1055 "query": {"access_token": "${access_token}"} },
1056 "capture": { "bh_rest_token": "$.BhRestToken", "base_url": "$.restUrl" } }
1057 ],
1058 "apply": [ { "into": "query", "name": "BhRestToken", "value": "${bh_rest_token}" } ],
1059 "base_url_from": "${base_url}",
1060 "reauth_on": [401]
1061 });
1062 let p = FlowProvider::from_config(&cfg).unwrap();
1063 let ra = p
1064 .request_auth("GET", "https://data.example/x", &BTreeMap::new())
1065 .await
1066 .unwrap();
1067 assert_eq!(ra.base_url.as_deref(), Some("https://data.example"));
1068 assert!(
1069 matches!(&ra.placements[0], CredentialPlacement::Query { name, value } if name == "BhRestToken" && value == "BRT")
1070 );
1071 assert_eq!(p.reauth_statuses(), &[401]);
1072 assert!(p.credential().await.is_err());
1074 }
1075
1076 struct CountingLogin(Arc<AtomicUsize>);
1077 impl Respond for CountingLogin {
1078 fn respond(&self, _: &wiremock::Request) -> ResponseTemplate {
1079 let n = self.0.fetch_add(1, Ordering::SeqCst) + 1;
1080 ResponseTemplate::new(200).set_body_json(json!({ "sid": format!("S{n}") }))
1081 }
1082 }
1083
1084 #[tokio::test]
1085 async fn header_credential_caches_then_reloads_on_invalidate() {
1086 let server = MockServer::start().await;
1087 let hits = Arc::new(AtomicUsize::new(0));
1088 Mock::given(method("POST"))
1089 .and(path("/login"))
1090 .respond_with(CountingLogin(hits.clone()))
1091 .mount(&server)
1092 .await;
1093 let cfg = json!({
1094 "steps": [ { "request": { "method": "POST", "url": format!("{}/login", server.uri()) },
1095 "capture": { "sid": "$.sid" } } ],
1096 "apply": [ { "into": "header", "name": "X-Session", "value": "${sid}" } ]
1097 });
1098 let p = FlowProvider::from_config(&cfg).unwrap();
1099
1100 let c1 = p.credential().await.unwrap();
1101 assert!(
1102 matches!(&c1, Credential::Header { name, value } if name == "X-Session" && value == "S1")
1103 );
1104 let _ = p.credential().await.unwrap();
1106 assert_eq!(hits.load(Ordering::SeqCst), 1);
1107 let c2 = p.invalidate(&c1).await.unwrap();
1109 assert!(matches!(&c2, Credential::Header { value, .. } if value == "S2"));
1110 assert_eq!(hits.load(Ordering::SeqCst), 2);
1111 }
1112
1113 #[tokio::test]
1114 async fn login_capture_miss_is_an_error() {
1115 let server = MockServer::start().await;
1116 Mock::given(method("GET"))
1117 .and(path("/x"))
1118 .respond_with(ResponseTemplate::new(200).set_body_json(json!({"other": 1})))
1119 .mount(&server)
1120 .await;
1121 let cfg = json!({
1122 "steps": [ { "request": { "url": format!("{}/x", server.uri()) },
1123 "capture": { "tok": "$.access_token" } } ],
1124 "apply": [ { "into": "header", "name": "X", "value": "${tok}" } ]
1125 });
1126 let p = FlowProvider::from_config(&cfg).unwrap();
1127 assert!(p.credential().await.is_err());
1128 }
1129
1130 #[tokio::test]
1131 async fn login_non_success_is_an_error() {
1132 let server = MockServer::start().await;
1133 Mock::given(method("GET"))
1134 .and(path("/x"))
1135 .respond_with(ResponseTemplate::new(500))
1136 .mount(&server)
1137 .await;
1138 let cfg = json!({
1139 "steps": [ { "request": { "url": format!("{}/x", server.uri()) } } ],
1140 "apply": [ { "into": "header", "name": "X", "value": "static" } ]
1141 });
1142 let p = FlowProvider::from_config(&cfg).unwrap();
1143 assert!(
1144 p.request_auth("GET", "https://x", &BTreeMap::new())
1145 .await
1146 .is_err()
1147 );
1148 }
1149
1150 #[test]
1151 fn config_rejects_empty_step_url() {
1152 let cfg: FlowConfig = serde_json::from_value(json!({
1153 "steps": [ { "request": { "url": " " } } ]
1154 }))
1155 .unwrap();
1156 assert!(cfg.validate().is_err());
1157 }
1158
1159 #[tokio::test]
1160 async fn session_ttl_caches_within_window() {
1161 let server = MockServer::start().await;
1162 let hits = Arc::new(AtomicUsize::new(0));
1163 Mock::given(method("POST"))
1164 .and(path("/login"))
1165 .respond_with(CountingLogin(hits.clone()))
1166 .mount(&server)
1167 .await;
1168 let cfg = json!({
1169 "steps": [ { "request": { "method": "POST", "url": format!("{}/login", server.uri()) },
1170 "capture": { "sid": "$.sid" } } ],
1171 "apply": [ { "into": "header", "name": "X", "value": "${sid}" } ],
1172 "ttl_secs": 3600
1173 });
1174 let p = FlowProvider::from_config(&cfg).unwrap();
1175 let _ = p.credential().await.unwrap();
1176 let _ = p.credential().await.unwrap();
1178 assert_eq!(hits.load(Ordering::SeqCst), 1);
1179 }
1180
1181 #[tokio::test]
1182 async fn login_sends_headers_and_json_body() {
1183 let server = MockServer::start().await;
1184 Mock::given(method("POST"))
1185 .and(path("/login"))
1186 .and(header("x-tenant", "acme"))
1187 .and(body_json(json!({"scope": "read"})))
1188 .respond_with(ResponseTemplate::new(200).set_body_json(json!({"tok": "T"})))
1189 .mount(&server)
1190 .await;
1191 let cfg = json!({
1192 "steps": [ { "request": { "method": "POST", "url": format!("{}/login", server.uri()),
1193 "headers": {"X-Tenant": "acme"}, "json": {"scope": "read"} },
1194 "capture": { "t": "$.tok" } } ],
1195 "apply": [ { "into": "header", "name": "Authorization", "value": "Bearer ${t}" } ]
1196 });
1197 let p = FlowProvider::from_config(&cfg).unwrap();
1198 assert_eq!(p.provider_name(), "flow");
1199 let c = p.credential().await.unwrap();
1200 assert!(
1201 matches!(&c, Credential::Header { name, value } if name == "Authorization" && value == "Bearer T")
1202 );
1203 }
1204
1205 #[tokio::test]
1206 async fn login_invalid_method_errors() {
1207 let cfg = json!({
1208 "steps": [ { "request": { "method": "BAD METHOD", "url": "https://x/login" } } ],
1209 "apply": [ { "into": "header", "name": "X", "value": "static" } ]
1210 });
1211 let p = FlowProvider::from_config(&cfg).unwrap();
1212 assert!(
1213 p.request_auth("GET", "https://x", &BTreeMap::new())
1214 .await
1215 .is_err()
1216 );
1217 }
1218
1219 #[tokio::test]
1220 async fn login_send_failure_errors() {
1221 let cfg = json!({
1223 "steps": [ { "request": { "url": "http://127.0.0.1:1/x" }, "capture": { "t": "$.t" } } ],
1224 "apply": [ { "into": "header", "name": "X", "value": "${t}" } ]
1225 });
1226 let p = FlowProvider::from_config(&cfg).unwrap();
1227 assert!(
1228 p.request_auth("GET", "https://x", &BTreeMap::new())
1229 .await
1230 .is_err()
1231 );
1232 }
1233
1234 #[tokio::test]
1235 async fn login_non_json_response_errors() {
1236 let server = MockServer::start().await;
1237 Mock::given(method("GET"))
1238 .and(path("/x"))
1239 .respond_with(ResponseTemplate::new(200).set_body_string("not json"))
1240 .mount(&server)
1241 .await;
1242 let cfg = json!({
1243 "steps": [ { "request": { "url": format!("{}/x", server.uri()) }, "capture": { "t": "$.t" } } ],
1244 "apply": [ { "into": "header", "name": "X", "value": "${t}" } ]
1245 });
1246 let p = FlowProvider::from_config(&cfg).unwrap();
1247 assert!(
1248 p.request_auth("GET", "https://x", &BTreeMap::new())
1249 .await
1250 .is_err()
1251 );
1252 }
1253
1254 #[tokio::test]
1255 async fn login_step_with_empty_capture_succeeds() {
1256 let server = MockServer::start().await;
1259 Mock::given(method("GET"))
1260 .and(path("/ping"))
1261 .respond_with(ResponseTemplate::new(200).set_body_string("ok"))
1262 .mount(&server)
1263 .await;
1264 Mock::given(method("GET"))
1265 .and(path("/token"))
1266 .respond_with(ResponseTemplate::new(200).set_body_json(json!({"t": "T"})))
1267 .mount(&server)
1268 .await;
1269 let cfg = json!({
1270 "steps": [
1271 { "request": { "url": format!("{}/ping", server.uri()) } },
1272 { "request": { "url": format!("{}/token", server.uri()) }, "capture": { "t": "$.t" } }
1273 ],
1274 "apply": [ { "into": "header", "name": "X", "value": "${t}" } ]
1275 });
1276 let p = FlowProvider::from_config(&cfg).unwrap();
1277 let c = p.credential().await.unwrap();
1278 assert!(matches!(&c, Credential::Header { value, .. } if value == "T"));
1279 }
1280
1281 #[tokio::test]
1282 async fn no_steps_apply_only_flow_works() {
1283 let cfg = json!({
1286 "apply": [ { "sign": { "alg": "hmac_sha256", "key": "k", "template": "msg",
1287 "into": { "header": "Authorization", "format": "HMAC ${sig}" } } } ]
1288 });
1289 let p = FlowProvider::from_config(&cfg).unwrap();
1290 let c = p.credential().await.unwrap();
1291 let expected = format!("HMAC {}", hmac_sign("k", "msg", SigEncoding::Hex));
1292 assert!(
1293 matches!(&c, Credential::Header { name, value } if name == "Authorization" && *value == expected)
1294 );
1295 }
1296
1297 #[test]
1300 fn sign_header_computes_name_and_value_with_clock() {
1301 let mut c = ctx(&[("client", "abc")]);
1302 c.insert("ts".to_owned(), "999".to_owned());
1303 let sign: SignSpec = serde_json::from_value(json!({
1304 "alg": "hmac_sha256", "key": "k", "template": "${client}:${ts}",
1305 "encoding": "hex", "into": { "header": "Authorization", "format": "SS ${sig}" }
1306 }))
1307 .unwrap();
1308 let (name, value) = sign_header(&sign, &c);
1309 assert_eq!(name, "Authorization");
1310 assert_eq!(
1311 value,
1312 format!("SS {}", hmac_sign("k", "abc:999", SigEncoding::Hex))
1313 );
1314 }
1315
1316 #[tokio::test]
1317 async fn signed_login_step_sends_computed_signature_header() {
1318 let server = MockServer::start().await;
1319 let key = "secret_key";
1320 let expected = format!(
1322 "SS access:{}",
1323 hmac_sign(key, "client:secret", SigEncoding::Base64)
1324 );
1325 Mock::given(method("POST"))
1326 .and(path("/auth/login"))
1327 .and(header("authorization", expected.as_str()))
1328 .respond_with(
1329 ResponseTemplate::new(200).set_body_json(json!({"session": {"token": "SESS"}})),
1330 )
1331 .mount(&server)
1332 .await;
1333 let cfg = json!({
1334 "steps": [ {
1335 "request": {
1336 "method": "POST",
1337 "url": format!("{}/auth/login", server.uri()),
1338 "json": {"clientId": "client"},
1339 "sign": { "alg": "hmac_sha256", "key": key, "template": "client:secret",
1340 "encoding": "base64",
1341 "into": { "header": "Authorization", "format": "SS access:${sig}" } }
1342 },
1343 "capture": { "session": "$.session.token" }
1344 } ],
1345 "apply": [ { "into": "header", "name": "Session", "value": "${session}" } ]
1346 });
1347 let p = FlowProvider::from_config(&cfg).unwrap();
1348 let c = p.credential().await.unwrap();
1351 assert!(
1352 matches!(&c, Credential::Header { name, value } if name == "Session" && value == "SESS")
1353 );
1354 }
1355
1356 #[tokio::test]
1357 async fn skyslope_style_signed_login_then_signed_data_requests() {
1358 let server = MockServer::start().await;
1359 let key = "base64secret";
1360 let login_sig = format!(
1361 "SS access:{}",
1362 hmac_sign(key, "cid:csec", SigEncoding::Base64)
1363 );
1364 Mock::given(method("POST"))
1365 .and(path("/auth/login"))
1366 .and(header("authorization", login_sig.as_str()))
1367 .respond_with(
1368 ResponseTemplate::new(200)
1369 .set_body_json(json!({"session": {"token": "SESSION-TOK"}})),
1370 )
1371 .mount(&server)
1372 .await;
1373 let signer = json!({ "alg": "hmac_sha256", "key": key, "template": "cid:csec",
1374 "encoding": "base64",
1375 "into": { "header": "Authorization", "format": "SS access:${sig}" } });
1376 let cfg = json!({
1377 "steps": [ {
1378 "request": { "method": "POST", "url": format!("{}/auth/login", server.uri()),
1379 "json": {"clientId": "cid"}, "sign": signer },
1380 "capture": { "session": "$.session.token" }
1381 } ],
1382 "apply": [
1383 { "sign": signer },
1384 { "into": "header", "name": "Session", "value": "${session}" }
1385 ]
1386 });
1387 let p = FlowProvider::from_config(&cfg).unwrap();
1388 let ra = p
1389 .request_auth("GET", "https://data", &BTreeMap::new())
1390 .await
1391 .unwrap();
1392 let has_auth = ra.placements.iter().any(|pl| {
1393 matches!(pl, CredentialPlacement::Header { name, value } if name == "Authorization" && *value == login_sig)
1394 });
1395 let has_sess = ra.placements.iter().any(|pl| {
1396 matches!(pl, CredentialPlacement::Header { name, value } if name == "Session" && value == "SESSION-TOK")
1397 });
1398 assert!(has_auth, "data request carries the signer header");
1399 assert!(has_sess, "data request carries the captured Session header");
1400 }
1401
1402 #[test]
1403 fn config_rejects_empty_step_sign_header() {
1404 let cfg: FlowConfig = serde_json::from_value(json!({
1405 "steps": [ { "request": { "url": "https://x",
1406 "sign": { "alg": "hmac_sha256", "key": "k", "template": "${ts}",
1407 "into": { "header": "" } } } } ]
1408 }))
1409 .unwrap();
1410 assert!(cfg.validate().is_err());
1411 }
1412
1413 #[test]
1416 fn xml_dot_path_extracts_nested_text() {
1417 let xml = "<operation><result><data><api><sessionid>ABC123</sessionid></api></data></result></operation>";
1418 assert_eq!(
1419 xml_dot_path(xml, "operation.result.data.api.sessionid").as_deref(),
1420 Some("ABC123")
1421 );
1422 assert_eq!(xml_dot_path(xml, "operation.result.missing"), None);
1423 }
1424
1425 #[test]
1426 fn xml_dot_path_ignores_declaration_attrs_and_namespaces() {
1427 let xml = r#"<?xml version="1.0"?><ns:root xmlns:ns="urn:x"><ns:child id="1"> hi </ns:child></ns:root>"#;
1428 assert_eq!(xml_dot_path(xml, "root.child").as_deref(), Some("hi"));
1429 assert_eq!(xml_dot_path(xml, "ns:root.ns:child").as_deref(), Some("hi"));
1431 }
1432
1433 #[test]
1434 fn xml_dot_path_handles_cdata_and_entities() {
1435 let xml = "<r><a><![CDATA[a&b]]></a><b>x & y</b></r>";
1436 assert_eq!(xml_dot_path(xml, "r.a").as_deref(), Some("a&b"));
1437 assert_eq!(xml_dot_path(xml, "r.b").as_deref(), Some("x & y"));
1438 }
1439
1440 #[test]
1441 fn set_cookie_and_header_helpers_select_by_name() {
1442 let mut h = reqwest::header::HeaderMap::new();
1443 h.append("set-cookie", "a=1; path=/".parse().unwrap());
1444 h.append(
1445 "set-cookie",
1446 "ASP.NET_SessionId=SID; path=/; HttpOnly".parse().unwrap(),
1447 );
1448 h.insert("location", "https://x/next".parse().unwrap());
1449 assert_eq!(
1450 set_cookie_value(&h, "ASP.NET_SessionId").as_deref(),
1451 Some("SID")
1452 );
1453 assert_eq!(set_cookie_value(&h, "missing"), None);
1454 assert_eq!(
1455 header_value(&h, "Location").as_deref(),
1456 Some("https://x/next")
1457 );
1458 assert_eq!(header_value(&h, "absent"), None);
1459 }
1460
1461 #[test]
1462 fn capture_backcompat_string_form_parses_as_json() {
1463 let step: FlowStep = serde_json::from_value(json!({
1464 "request": { "url": "https://x" },
1465 "capture": { "tok": "$.access_token" }
1466 }))
1467 .unwrap();
1468 assert_eq!(step.capture["tok"].kind(), CaptureFrom::Json);
1469 assert!(matches!(&step.capture["tok"], CaptureSpec::Json(p) if p == "$.access_token"));
1470 }
1471
1472 #[test]
1473 fn capture_struct_forms_parse_kinds() {
1474 let step: FlowStep = serde_json::from_value(json!({
1475 "request": { "url": "https://x" },
1476 "capture": {
1477 "h": { "from": "header", "name": "Location" },
1478 "c": { "from": "set_cookie", "name": "sid" },
1479 "x": { "from": "xml", "path": "a.b" },
1480 "j": { "from": "json", "path": "$.tok" }
1481 }
1482 }))
1483 .unwrap();
1484 assert_eq!(step.capture["h"].kind(), CaptureFrom::Header);
1485 assert_eq!(step.capture["c"].kind(), CaptureFrom::SetCookie);
1486 assert_eq!(step.capture["x"].kind(), CaptureFrom::Xml);
1487 assert_eq!(step.capture["j"].kind(), CaptureFrom::Json);
1488 }
1489
1490 #[test]
1491 fn config_rejects_capture_missing_selector() {
1492 let cfg: FlowConfig = serde_json::from_value(json!({
1493 "steps": [ { "request": { "url": "https://x" }, "capture": { "h": { "from": "header" } } } ]
1494 }))
1495 .unwrap();
1496 assert!(cfg.validate().is_err());
1497
1498 let cfg2: FlowConfig = serde_json::from_value(json!({
1499 "steps": [ { "request": { "url": "https://x" }, "capture": { "x": { "from": "xml" } } } ]
1500 }))
1501 .unwrap();
1502 assert!(cfg2.validate().is_err());
1503 }
1504
1505 #[tokio::test]
1506 async fn capture_from_header_works() {
1507 let server = MockServer::start().await;
1508 Mock::given(method("GET"))
1509 .and(path("/login"))
1510 .respond_with(
1511 ResponseTemplate::new(200)
1512 .insert_header("Location", "https://redirect.example/next"),
1513 )
1514 .mount(&server)
1515 .await;
1516 let cfg = json!({
1517 "steps": [ { "request": { "url": format!("{}/login", server.uri()) },
1518 "capture": { "loc": { "from": "header", "name": "Location" } } } ],
1519 "apply": [ { "into": "header", "name": "X-Loc", "value": "${loc}" } ]
1520 });
1521 let p = FlowProvider::from_config(&cfg).unwrap();
1522 let c = p.credential().await.unwrap();
1523 assert!(
1524 matches!(&c, Credential::Header { name, value } if name == "X-Loc" && value == "https://redirect.example/next")
1525 );
1526 }
1527
1528 #[tokio::test]
1529 async fn capture_from_set_cookie_applies_as_cookie() {
1530 let server = MockServer::start().await;
1532 Mock::given(method("POST"))
1533 .and(path("/entity/auth/login"))
1534 .respond_with(
1535 ResponseTemplate::new(204)
1536 .append_header("Set-Cookie", "other=nope; path=/")
1537 .append_header("Set-Cookie", "ASP.NET_SessionId=SID123; path=/; HttpOnly"),
1538 )
1539 .mount(&server)
1540 .await;
1541 let cfg = json!({
1542 "steps": [ { "request": { "method": "POST",
1543 "url": format!("{}/entity/auth/login", server.uri()),
1544 "json": {"name": "u", "password": "p"} },
1545 "capture": { "session_cookie": { "from": "set_cookie", "name": "ASP.NET_SessionId" } } } ],
1546 "apply": [ { "into": "cookie", "name": "ASP.NET_SessionId", "value": "${session_cookie}" } ]
1547 });
1548 let p = FlowProvider::from_config(&cfg).unwrap();
1549 let ra = p
1550 .request_auth("GET", "https://data", &BTreeMap::new())
1551 .await
1552 .unwrap();
1553 assert!(
1554 matches!(&ra.placements[0], CredentialPlacement::Cookie { name, value } if name == "ASP.NET_SessionId" && value == "SID123")
1555 );
1556 }
1557
1558 #[tokio::test]
1559 async fn capture_from_xml_body_works() {
1560 let server = MockServer::start().await;
1562 let xml = r#"<?xml version="1.0" encoding="UTF-8"?><response><operation><result><data><api><sessionid>XYZ-SESSION</sessionid></api></data></result></operation></response>"#;
1563 Mock::given(method("POST"))
1564 .and(path("/xml/xmlgw.phtml"))
1565 .respond_with(ResponseTemplate::new(200).set_body_string(xml))
1566 .mount(&server)
1567 .await;
1568 let cfg = json!({
1569 "steps": [ { "request": { "method": "POST",
1570 "url": format!("{}/xml/xmlgw.phtml", server.uri()) },
1571 "capture": { "sess_id": { "from": "xml",
1572 "path": "response.operation.result.data.api.sessionid" } } } ],
1573 "apply": [ { "into": "header", "name": "X-Session", "value": "${sess_id}" } ]
1574 });
1575 let p = FlowProvider::from_config(&cfg).unwrap();
1576 let c = p.credential().await.unwrap();
1577 assert!(matches!(&c, Credential::Header { value, .. } if value == "XYZ-SESSION"));
1578 }
1579
1580 #[tokio::test]
1581 async fn capture_from_xml_miss_is_an_error() {
1582 let server = MockServer::start().await;
1583 Mock::given(method("POST"))
1584 .and(path("/x"))
1585 .respond_with(ResponseTemplate::new(200).set_body_string("<r><a>1</a></r>"))
1586 .mount(&server)
1587 .await;
1588 let cfg = json!({
1589 "steps": [ { "request": { "method": "POST", "url": format!("{}/x", server.uri()) },
1590 "capture": { "s": { "from": "xml", "path": "r.missing" } } } ],
1591 "apply": [ { "into": "header", "name": "X", "value": "${s}" } ]
1592 });
1593 let p = FlowProvider::from_config(&cfg).unwrap();
1594 assert!(p.credential().await.is_err());
1595 }
1596}