1use super::challenge::{PaymentChallenge, PaymentCredential, Receipt};
13use super::types::{base64url_decode, base64url_encode, Base64UrlJson, IntentName, MethodName};
14use crate::error::{MppError, Result};
15use std::collections::HashMap;
16
17const MAX_TOKEN_LEN: usize = 16 * 1024;
19
20macro_rules! require_param {
22 ($params:expr, $key:literal) => {
23 $params.get($key).ok_or_else(|| {
24 MppError::invalid_challenge_reason(format!("Missing '{}' field", $key))
25 })?
26 };
27}
28
29fn strip_payment_scheme(header: &str) -> Option<&str> {
32 let header = header.trim_start();
33 let scheme_len = PAYMENT_SCHEME.len();
34
35 if header.len() >= scheme_len
36 && header
37 .get(..scheme_len)
38 .is_some_and(|s| s.eq_ignore_ascii_case(PAYMENT_SCHEME))
39 {
40 header.get(scheme_len..)
41 } else {
42 None
43 }
44}
45
46pub fn extract_payment_scheme(header: &str) -> Option<&str> {
69 header.split(',').map(|s| s.trim()).find(|s| {
70 s.len() >= 8
71 && s.get(..8)
72 .is_some_and(|prefix| prefix.eq_ignore_ascii_case("payment "))
73 })
74}
75
76fn escape_quoted_value(s: &str) -> Result<String> {
79 if s.contains('\r') || s.contains('\n') {
80 return Err(MppError::invalid_challenge_reason(
81 "Header value contains invalid CRLF characters",
82 ));
83 }
84 Ok(s.replace('\\', "\\\\").replace('"', "\\\""))
85}
86
87pub const WWW_AUTHENTICATE_HEADER: &str = "www-authenticate";
89
90pub const AUTHORIZATION_HEADER: &str = "authorization";
92
93pub const PAYMENT_RECEIPT_HEADER: &str = "payment-receipt";
95
96pub const PAYMENT_SCHEME: &str = "Payment";
98
99fn parse_auth_params(params_str: &str) -> Result<HashMap<String, String>> {
106 let mut params = HashMap::new();
107 let chars: Vec<char> = params_str.chars().collect();
108 let mut i = 0;
109
110 while i < chars.len() {
111 while i < chars.len() && (chars[i].is_whitespace() || chars[i] == ',') {
112 i += 1;
113 }
114 if i >= chars.len() {
115 break;
116 }
117
118 let key_start = i;
119 while i < chars.len() && chars[i] != '=' && !chars[i].is_whitespace() {
120 i += 1;
121 }
122 if i >= chars.len() || chars[i] != '=' {
123 while i < chars.len() && !chars[i].is_whitespace() && chars[i] != ',' {
124 i += 1;
125 }
126 continue;
127 }
128
129 let key: String = chars[key_start..i].iter().collect();
130 i += 1;
131
132 if i >= chars.len() {
133 break;
134 }
135
136 let value = if chars[i] == '"' {
137 i += 1;
138 let mut value = String::new();
139 while i < chars.len() && chars[i] != '"' {
140 if chars[i] == '\\' && i + 1 < chars.len() {
141 i += 1;
142 value.push(chars[i]);
143 } else {
144 value.push(chars[i]);
145 }
146 i += 1;
147 }
148 if i < chars.len() {
149 i += 1;
150 }
151 value
152 } else {
153 let value_start = i;
154 while i < chars.len() && !chars[i].is_whitespace() && chars[i] != ',' {
155 i += 1;
156 }
157 chars[value_start..i].iter().collect()
158 };
159
160 if params.contains_key(&key) {
161 return Err(MppError::invalid_challenge_reason(format!(
162 "Duplicate parameter: {}",
163 key
164 )));
165 }
166 params.insert(key, value);
167 }
168
169 Ok(params)
170}
171
172fn is_iso8601_timestamp(s: &str) -> bool {
174 time::OffsetDateTime::parse(s, &time::format_description::well_known::Rfc3339).is_ok()
175}
176
177fn is_valid_digest_format(d: &str) -> bool {
181 d.starts_with("sha-256=")
182}
183
184pub fn parse_www_authenticate(header: &str) -> Result<PaymentChallenge> {
200 let rest = strip_payment_scheme(header).ok_or_else(|| {
201 MppError::invalid_challenge_reason("Expected 'Payment' scheme".to_string())
202 })?;
203
204 let params_str = rest
205 .strip_prefix(' ')
206 .or_else(|| rest.strip_prefix('\t'))
207 .ok_or_else(|| {
208 MppError::invalid_challenge_reason("Expected space after 'Payment' scheme".to_string())
209 })?
210 .trim_start();
211 let params = parse_auth_params(params_str)?;
212
213 let id = require_param!(params, "id").clone();
214 if id.is_empty() {
215 return Err(MppError::invalid_challenge_reason(
216 "Empty 'id' parameter".to_string(),
217 ));
218 }
219 let realm = require_param!(params, "realm").clone();
220 let method_raw = require_param!(params, "method").clone();
221 if method_raw.is_empty() || !method_raw.chars().all(|c| c.is_ascii_lowercase()) {
222 return Err(MppError::invalid_challenge_reason(format!(
223 "Invalid method: \"{}\". Must match method-name ABNF.",
224 method_raw
225 )));
226 }
227 let method = MethodName::new(method_raw);
228 let intent = IntentName::new(require_param!(params, "intent"));
229 let request_b64 = require_param!(params, "request").clone();
230
231 let request_bytes = base64url_decode(&request_b64)?;
232 let _ = serde_json::from_slice::<serde_json::Value>(&request_bytes).map_err(|e| {
234 MppError::invalid_challenge_reason(format!("Invalid JSON in request field: {}", e))
235 })?;
236 let request = Base64UrlJson::from_raw(request_b64);
237
238 let digest = params.get("digest").cloned();
239 if let Some(ref d) = digest {
240 if !is_valid_digest_format(d) {
241 return Err(MppError::invalid_challenge_reason("Invalid digest format"));
242 }
243 }
244
245 Ok(PaymentChallenge {
246 id,
247 realm,
248 method,
249 intent,
250 request,
251 expires: params.get("expires").cloned(),
252 description: params.get("description").cloned(),
253 digest,
254 opaque: params.get("opaque").map(Base64UrlJson::from_raw),
255 })
256}
257
258pub fn parse_www_authenticate_all<'a>(
277 headers: impl IntoIterator<Item = &'a str>,
278) -> Vec<Result<PaymentChallenge>> {
279 headers
280 .into_iter()
281 .filter(|h| {
282 h.trim_start()
283 .get(..8)
284 .is_some_and(|s| s.eq_ignore_ascii_case("payment "))
285 })
286 .map(parse_www_authenticate)
287 .collect()
288}
289
290pub fn format_www_authenticate(challenge: &PaymentChallenge) -> Result<String> {
315 let mut parts = vec![
317 format!("id=\"{}\"", escape_quoted_value(&challenge.id)?),
318 format!("realm=\"{}\"", escape_quoted_value(&challenge.realm)?),
319 format!(
320 "method=\"{}\"",
321 escape_quoted_value(challenge.method.as_str())?
322 ),
323 format!(
324 "intent=\"{}\"",
325 escape_quoted_value(challenge.intent.as_str())?
326 ),
327 format!(
328 "request=\"{}\"",
329 escape_quoted_value(challenge.request.raw())?
330 ),
331 ];
332
333 if let Some(ref expires) = challenge.expires {
334 parts.push(format!("expires=\"{}\"", escape_quoted_value(expires)?));
335 }
336
337 if let Some(ref description) = challenge.description {
338 parts.push(format!(
339 "description=\"{}\"",
340 escape_quoted_value(description)?
341 ));
342 }
343
344 if let Some(ref digest) = challenge.digest {
345 parts.push(format!("digest=\"{}\"", escape_quoted_value(digest)?));
346 }
347
348 if let Some(ref opaque) = challenge.opaque {
349 parts.push(format!("opaque=\"{}\"", escape_quoted_value(opaque.raw())?));
350 }
351
352 Ok(format!("Payment {}", parts.join(", ")))
353}
354
355pub fn format_www_authenticate_many(challenges: &[PaymentChallenge]) -> Result<Vec<String>> {
380 challenges.iter().map(format_www_authenticate).collect()
381}
382
383pub fn parse_authorization(header: &str) -> Result<PaymentCredential> {
387 let payment_part = extract_payment_scheme(header).ok_or_else(|| {
388 MppError::invalid_challenge_reason("Expected 'Payment' scheme".to_string())
389 })?;
390
391 let token = payment_part.get(8..).unwrap_or("").trim();
393
394 if token.len() > MAX_TOKEN_LEN {
396 return Err(MppError::invalid_challenge_reason(format!(
397 "Token exceeds maximum length of {} bytes",
398 MAX_TOKEN_LEN
399 )));
400 }
401
402 let decoded = base64url_decode(token)?;
403 let credential: PaymentCredential = serde_json::from_slice(&decoded).map_err(|e| {
404 MppError::invalid_challenge_reason(format!("Invalid credential JSON: {}", e))
405 })?;
406
407 if let Some(ref d) = credential.challenge.digest {
408 if !is_valid_digest_format(d) {
409 return Err(MppError::invalid_challenge_reason("Invalid digest format"));
410 }
411 }
412
413 Ok(credential)
414}
415
416pub fn format_authorization(credential: &PaymentCredential) -> Result<String> {
420 let json = serde_json::to_string(credential)?;
421 let encoded = base64url_encode(json.as_bytes());
422 Ok(format!("Payment {}", encoded))
423}
424
425pub fn parse_receipt(header: &str) -> Result<Receipt> {
429 let token = header.trim();
430
431 if token.len() > MAX_TOKEN_LEN {
433 return Err(MppError::invalid_challenge_reason(format!(
434 "Receipt exceeds maximum length of {} bytes",
435 MAX_TOKEN_LEN
436 )));
437 }
438
439 let decoded = base64url_decode(token)?;
440 let receipt: Receipt = serde_json::from_slice(&decoded)
441 .map_err(|e| MppError::invalid_challenge_reason(format!("Invalid receipt JSON: {}", e)))?;
442
443 if !is_iso8601_timestamp(&receipt.timestamp) {
444 return Err(MppError::invalid_challenge_reason(
445 "Invalid timestamp format: expected ISO 8601".to_string(),
446 ));
447 }
448
449 Ok(receipt)
450}
451
452pub fn format_receipt(receipt: &Receipt) -> Result<String> {
456 let json = serde_json::to_string(receipt)?;
457 Ok(base64url_encode(json.as_bytes()))
458}
459
460#[cfg(test)]
461mod tests {
462 use super::*;
463 use crate::protocol::core::types::{PayloadType, ReceiptStatus};
464 use crate::protocol::core::PaymentPayload;
465
466 fn test_challenge() -> PaymentChallenge {
467 PaymentChallenge {
468 id: "abc123".to_string(),
469 realm: "api".to_string(),
470 method: "tempo".into(),
471 intent: "charge".into(),
472 request: Base64UrlJson::from_value(&serde_json::json!({
473 "amount": "10000",
474 "currency": "0x123"
475 }))
476 .unwrap(),
477 expires: Some("2024-01-01T00:00:00Z".to_string()),
478 description: None,
479 digest: None,
480 opaque: None,
481 }
482 }
483
484 #[test]
485 fn test_parse_www_authenticate() {
486 let challenge = test_challenge();
487 let header = format_www_authenticate(&challenge).unwrap();
488 let parsed = parse_www_authenticate(&header).unwrap();
489
490 assert_eq!(parsed.id, "abc123");
491 assert_eq!(parsed.realm, "api");
492 assert_eq!(parsed.method.as_str(), "tempo");
493 assert_eq!(parsed.intent.as_str(), "charge");
494 assert_eq!(parsed.expires, Some("2024-01-01T00:00:00Z".to_string()));
495
496 let request: serde_json::Value = parsed.request.decode_value().unwrap();
498 assert_eq!(request["amount"], "10000");
499 }
500
501 #[test]
502 fn test_parse_www_authenticate_case_insensitive() {
503 let header =
504 r#"payment id="test", realm="api", method="tempo", intent="charge", request="e30""#;
505 let parsed = parse_www_authenticate(header).unwrap();
506 assert_eq!(parsed.id, "test");
507
508 let header2 =
509 r#"PAYMENT id="test2", realm="api", method="tempo", intent="charge", request="e30""#;
510 let parsed2 = parse_www_authenticate(header2).unwrap();
511 assert_eq!(parsed2.id, "test2");
512 }
513
514 #[test]
515 fn test_parse_www_authenticate_leading_whitespace() {
516 let header =
517 r#" Payment id="test", realm="api", method="tempo", intent="charge", request="e30""#;
518 let parsed = parse_www_authenticate(header).unwrap();
519 assert_eq!(parsed.id, "test");
520 }
521
522 #[test]
523 fn test_parse_www_authenticate_with_description() {
524 let mut challenge = test_challenge();
525 challenge.description = Some("Pay \"here\" now".to_string());
526 let header = format_www_authenticate(&challenge).unwrap();
527
528 assert!(header.contains("description=\"Pay \\\"here\\\" now\""));
529
530 let parsed = parse_www_authenticate(&header).unwrap();
531 assert_eq!(parsed.description, Some("Pay \"here\" now".to_string()));
532 }
533
534 #[test]
535 fn test_parse_www_authenticate_all() {
536 let headers = vec![
537 "Bearer token",
538 r#"Payment id="a", realm="api", method="tempo", intent="charge", request="e30""#,
539 "Basic xyz",
540 r#"Payment id="b", realm="api", method="base", intent="charge", request="e30""#,
541 ];
542
543 let results = parse_www_authenticate_all(headers);
544 assert_eq!(results.len(), 2);
545
546 let first = results[0].as_ref().unwrap();
547 assert_eq!(first.id, "a");
548
549 let second = results[1].as_ref().unwrap();
550 assert_eq!(second.id, "b");
551 }
552
553 #[test]
554 fn test_format_www_authenticate_many() {
555 let c1 = test_challenge();
556 let mut c2 = test_challenge();
557 c2.id = "def456".to_string();
558 c2.method = "base".into();
559
560 let headers = format_www_authenticate_many(&[c1, c2]).unwrap();
561 assert_eq!(headers.len(), 2);
562 assert!(headers[0].contains("abc123"));
563 assert!(headers[1].contains("def456"));
564 }
565
566 #[test]
567 fn test_parse_authorization() {
568 let challenge = test_challenge();
569 let credential = PaymentCredential::with_source(
570 challenge.to_echo(),
571 "did:pkh:eip155:42431:0x123",
572 PaymentPayload::transaction("0xabc"),
573 );
574
575 let header = format_authorization(&credential).unwrap();
576 let parsed = parse_authorization(&header).unwrap();
577
578 assert_eq!(parsed.challenge.id, "abc123");
579 assert_eq!(
580 parsed.source,
581 Some("did:pkh:eip155:42431:0x123".to_string())
582 );
583 let charge_payload: PaymentPayload = parsed.charge_payload().unwrap();
584 assert_eq!(charge_payload.signed_tx(), Some("0xabc"));
585 assert_eq!(charge_payload.payload_type(), PayloadType::Transaction);
586 }
587
588 #[test]
589 fn test_parse_receipt() {
590 let receipt = Receipt {
591 status: ReceiptStatus::Success,
592 method: "tempo".into(),
593 timestamp: "2024-01-01T00:00:00Z".to_string(),
594 reference: "0xabc123".to_string(),
595 };
596
597 let header = format_receipt(&receipt).unwrap();
598 let parsed = parse_receipt(&header).unwrap();
599
600 assert_eq!(parsed.status, ReceiptStatus::Success);
601 assert_eq!(parsed.method.as_str(), "tempo");
602 assert_eq!(parsed.reference, "0xabc123");
603 }
604
605 #[test]
606 fn test_parse_invalid_scheme() {
607 let result = parse_www_authenticate("Basic realm=\"test\"");
608 assert!(result.is_err());
609 }
610
611 #[test]
612 fn test_parse_missing_required_field() {
613 let result = parse_www_authenticate("Payment id=\"abc\", realm=\"api\"");
614 assert!(result.is_err());
615 }
616
617 #[test]
618 fn test_parse_authorization_missing_payment_scheme() {
619 let result = parse_authorization("Bearer abc123");
620 assert!(result.is_err());
621 }
622
623 #[test]
624 fn test_parse_authorization_invalid_base64url() {
625 let result = parse_authorization("Payment !");
626 assert!(result.is_err());
627 }
628
629 #[test]
630 fn test_parse_authorization_invalid_json() {
631 let token = base64url_encode(b"not valid json");
632 let result = parse_authorization(&format!("Payment {}", token));
633 assert!(result.is_err());
634 }
635
636 #[test]
637 fn test_parse_authorization_missing_challenge_fields() {
638 let json = r#"{"challenge":{"id":"abc"},"payload":{}}"#;
639 let token = base64url_encode(json.as_bytes());
640 let result = parse_authorization(&format!("Payment {}", token));
641 assert!(result.is_err());
642 }
643
644 #[test]
645 fn test_credential_roundtrip_with_optional_fields() {
646 let mut challenge = test_challenge();
647 challenge.expires = Some("2025-06-01T00:00:00Z".to_string());
648 challenge.digest = Some("sha-256=abc123".to_string());
649
650 let credential = PaymentCredential::with_source(
651 challenge.to_echo(),
652 "did:pkh:eip155:42431:0x123",
653 PaymentPayload::transaction("0xabc"),
654 );
655
656 let header = format_authorization(&credential).unwrap();
657 let parsed = parse_authorization(&header).unwrap();
658
659 assert_eq!(
660 parsed.challenge.expires,
661 Some("2025-06-01T00:00:00Z".to_string())
662 );
663 assert_eq!(parsed.challenge.digest, Some("sha-256=abc123".to_string()));
664 }
665
666 #[test]
667 fn test_credential_roundtrip_without_source() {
668 let challenge = test_challenge();
669 let credential =
670 PaymentCredential::new(challenge.to_echo(), PaymentPayload::transaction("0xabc"));
671
672 let header = format_authorization(&credential).unwrap();
673 let parsed = parse_authorization(&header).unwrap();
674
675 assert!(parsed.source.is_none());
676 }
677
678 #[test]
679 fn test_parse_receipt_invalid_status() {
680 let json = r#"{"status":"failed","method":"tempo","timestamp":"2024-01-01T00:00:00Z","reference":"0xabc"}"#;
681 let token = base64url_encode(json.as_bytes());
682 let result = parse_receipt(&token);
683 assert!(result.is_err());
684 }
685
686 #[test]
687 fn test_parse_authorization_invalid_digest_format() {
688 let mut challenge = test_challenge();
689 challenge.digest = Some("invalid-digest-format".to_string());
690
691 let credential = PaymentCredential::with_source(
692 challenge.to_echo(),
693 "did:pkh:eip155:42431:0x123",
694 PaymentPayload::transaction("0xabc"),
695 );
696
697 let json = serde_json::to_string(&credential).unwrap();
699 let token = base64url_encode(json.as_bytes());
700 let result = parse_authorization(&format!("Payment {}", token));
701 assert!(result.is_err());
702 }
703
704 #[test]
705 fn test_parse_authorization_rejects_non_sha256_digest() {
706 let mut challenge = test_challenge();
707 challenge.digest = Some("sha-512=abc123".to_string());
708
709 let credential = PaymentCredential::with_source(
710 challenge.to_echo(),
711 "did:pkh:eip155:42431:0x123",
712 PaymentPayload::transaction("0xabc"),
713 );
714
715 let json = serde_json::to_string(&credential).unwrap();
716 let token = base64url_encode(json.as_bytes());
717 let result = parse_authorization(&format!("Payment {}", token));
718 assert!(result.is_err());
719 }
720
721 #[test]
722 fn test_parse_www_authenticate_invalid_digest_format() {
723 let header = r#"Payment id="abc", realm="api", method="tempo", intent="charge", request="e30", digest="invalid-digest-format""#;
724 let result = parse_www_authenticate(header);
725 assert!(result.is_err());
726 }
727
728 #[test]
729 fn test_parse_www_authenticate_rejects_non_sha256_digest() {
730 let header = r#"Payment id="abc", realm="api", method="tempo", intent="charge", request="e30", digest="sha-512=abc""#;
731 let result = parse_www_authenticate(header);
732 assert!(result.is_err());
733 }
734
735 #[test]
736 fn test_parse_www_authenticate_invalid_request_json() {
737 let header = r#"Payment id="abc", realm="api", method="tempo", intent="charge", request="bm90IGpzb24""#;
739 let result = parse_www_authenticate(header);
740 assert!(result.is_err());
741 }
742
743 #[test]
744 fn test_roundtrip_preserves_request() {
745 let original_request = serde_json::json!({
746 "amount": "5000",
747 "currency": "0xabc",
748 "nested": {"key": "value"}
749 });
750 let mut challenge = test_challenge();
751 challenge.request = Base64UrlJson::from_value(&original_request).unwrap();
752
753 let header = format_www_authenticate(&challenge).unwrap();
754 let parsed = parse_www_authenticate(&header).unwrap();
755
756 assert_eq!(parsed.request.raw(), challenge.request.raw());
758
759 let decoded: serde_json::Value = parsed.request.decode_value().unwrap();
761 assert_eq!(decoded, original_request);
762 }
763
764 #[test]
765 fn test_extract_payment_scheme_single() {
766 let header = "Payment eyJhYmMi";
767 let result = extract_payment_scheme(header);
768 assert!(result.is_some());
769 assert!(result.unwrap().starts_with("Payment "));
770 }
771
772 #[test]
773 fn test_extract_payment_scheme_mixed() {
774 let header = "Bearer token123, Payment eyJhYmMi";
775 let result = extract_payment_scheme(header);
776 assert!(result.is_some());
777 assert_eq!(result.unwrap(), "Payment eyJhYmMi");
778 }
779
780 #[test]
781 fn test_extract_payment_scheme_not_found() {
782 assert!(extract_payment_scheme("Bearer token123").is_none());
783 assert!(extract_payment_scheme("Basic abc123").is_none());
784 }
785
786 #[test]
787 fn test_extract_payment_scheme_case_insensitive() {
788 let header = "Bearer xxx, payment eyJhYmMi";
789 let result = extract_payment_scheme(header);
790 assert!(result.is_some());
791 }
792
793 #[test]
794 fn test_parse_authorization_mixed_schemes() {
795 let challenge = test_challenge();
796 let credential = PaymentCredential::with_source(
797 challenge.to_echo(),
798 "did:pkh:eip155:42431:0x123",
799 PaymentPayload::transaction("0xabc"),
800 );
801 let formatted = format_authorization(&credential).unwrap();
802
803 let mixed = format!("Bearer some-token, {}", formatted);
805 let parsed = parse_authorization(&mixed).unwrap();
806 assert_eq!(parsed.challenge.id, "abc123");
807 }
808
809 #[test]
810 fn test_parse_www_authenticate_rejects_duplicate_params() {
811 let header = r#"Payment id="a", realm="api", method="tempo", intent="charge", request="e30", id="b""#;
812 let err = parse_www_authenticate(header).unwrap_err();
813 assert!(err.to_string().contains("Duplicate parameter"));
814 }
815
816 #[test]
817 fn test_parse_www_authenticate_rejects_empty_id() {
818 let header =
819 r#"Payment id="", realm="api", method="tempo", intent="charge", request="e30""#;
820 let err = parse_www_authenticate(header).unwrap_err();
821 assert!(err.to_string().contains("Empty 'id'"));
822 }
823
824 #[test]
825 fn test_parse_www_authenticate_rejects_invalid_method_name_dash() {
826 let header =
827 r#"Payment id="abc", realm="api", method="tempo-v2", intent="charge", request="e30""#;
828 let err = parse_www_authenticate(header).unwrap_err();
829 assert!(err.to_string().contains("Invalid method"));
830 }
831
832 #[test]
833 fn test_parse_www_authenticate_rejects_invalid_method_name_digit_prefix() {
834 let header =
835 r#"Payment id="abc", realm="api", method="1tempo", intent="charge", request="e30""#;
836 let err = parse_www_authenticate(header).unwrap_err();
837 assert!(err.to_string().contains("Invalid method"));
838 }
839
840 #[test]
841 fn test_parse_www_authenticate_rejects_mixed_case_method_name() {
842 let header =
843 r#"Payment id="abc", realm="api", method="Tempo", intent="charge", request="e30""#;
844 let err = parse_www_authenticate(header).unwrap_err();
845 assert!(err.to_string().contains("Invalid method"));
846 }
847
848 #[test]
849 fn test_parse_www_authenticate_accepts_standard_base64_request() {
850 use base64::engine::general_purpose::STANDARD;
855 use base64::Engine as _;
856
857 let payload = r#"{"amount":"94","currency":"0x20c000000000000000000000b9537d11c60e8b50","methodDetails":{"chainId":4217},"recipient":"0x8A739f3A6f40194C0128904bC387e63d9C0577A4"}"#;
858 let request_b64 = STANDARD.encode(payload.as_bytes());
859 assert!(request_b64.ends_with('='));
861
862 let header = format!(
863 r#"Payment id="test-123", realm="mpp-hosting", method="tempo", intent="charge", request="{request_b64}", description="VPS provisioning", expires="2026-03-24T21:20:34Z""#,
864 );
865 let challenge = parse_www_authenticate(&header).unwrap();
866 assert_eq!(challenge.id, "test-123");
867 assert_eq!(challenge.method.to_string(), "tempo");
868 assert_eq!(challenge.intent.to_string(), "charge");
869
870 let decoded: serde_json::Value = challenge.request.decode().unwrap();
871 assert_eq!(decoded["amount"], "94");
872 }
873
874 #[test]
875 fn test_parse_receipt_rejects_non_iso8601_timestamp() {
876 let wire = "eyJtZXRob2QiOiJ0ZW1wbyIsInJlZmVyZW5jZSI6IjB4YWJjIiwic3RhdHVzIjoic3VjY2VzcyIsInRpbWVzdGFtcCI6IkphbiAyOSAyMDI2IDEyOjAwIn0";
879 let err = parse_receipt(wire).unwrap_err();
880 assert!(err.to_string().contains("timestamp"));
881 }
882}