1use crate::error::WalletKitError;
2use crate::http_request::Request;
3use reqwest::Method;
4use serde::{Deserialize, Serialize};
5use std::error::Error as _;
6use std::fmt::Write as _;
7
8#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, uniffi::Record)]
12pub struct ManageRecoveryBindingRequest {
13 pub sub: String,
15 #[serde(rename = "leafIndex")]
17 pub leaf_index: u64,
18 pub signature: String,
20 pub nonce: String,
22 #[serde(rename = "recoveryAgent")]
24 pub recovery_agent: String,
25}
26
27#[derive(Deserialize)]
28struct ChallengeResponse {
29 challenge: String,
30}
31
32#[derive(Deserialize)]
34struct RecoveryBindingErrorResponse {
35 error: String,
36}
37
38#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, uniffi::Record)]
39pub struct RecoveryBindingResponse {
40 #[serde(rename = "recoveryAgent")]
41 pub recovery_agent: Option<String>,
42 #[serde(rename = "pendingRecoveryAgent")]
43 pub pending_recovery_agent: Option<String>,
44 #[serde(rename = "executeAfter")]
45 pub execute_after: Option<String>,
46}
47
48pub struct PopBackendClient {
57 request: Request,
58 base_url: String,
59}
60
61impl PopBackendClient {
62 #[must_use]
64 pub fn new(base_url: String, user_agent: String) -> Self {
65 let request = Request::new(user_agent);
66 Self { request, base_url }
67 }
68}
69
70impl PopBackendClient {
71 pub async fn bind_recovery_agent(
82 &self,
83 request: ManageRecoveryBindingRequest,
84 security_token: String,
85 challenge: String,
86 ) -> Result<(), WalletKitError> {
87 let url: String = format!("{}/api/v1/recovery-binding", self.base_url);
88 let endpoint = self.diagnostic_endpoint("/api/v1/recovery-binding");
89 let response = self
90 .request
91 .post(&url)
92 .json(&request)
93 .header("X-Auth-Signature", security_token)
94 .header("X-Auth-Challenge", challenge)
95 .send()
96 .await
97 .map_err(|error| request_error("POST", &endpoint, error))?;
98
99 let response_status = response.status();
100 match response_status {
101 reqwest::StatusCode::CREATED | reqwest::StatusCode::OK => Ok(()),
102 reqwest::StatusCode::NOT_FOUND => {
103 Err(eligibility_error(&endpoint, response).await)
104 }
105 reqwest::StatusCode::PRECONDITION_FAILED => {
106 Err(WalletKitError::NotEligibleForRecovery)
107 }
108 _ => Err(response_error("POST", &endpoint, response).await),
109 }
110 }
111
112 pub async fn unbind_recovery_agent(
120 &self,
121 request: ManageRecoveryBindingRequest,
122 security_token: String,
123 challenge: String,
124 ) -> Result<(), WalletKitError> {
125 let url: String = format!("{}/api/v1/recovery-binding", self.base_url);
126 let endpoint = self.diagnostic_endpoint("/api/v1/recovery-binding");
127 let response = self
128 .request
129 .req(Method::DELETE, url.as_str())
130 .json(&request)
131 .header("X-Auth-Signature", security_token)
132 .header("X-Auth-Challenge", challenge)
133 .send()
134 .await
135 .map_err(|error| request_error("DELETE", &endpoint, error))?;
136 let response_status = response.status();
137 match response_status {
138 reqwest::StatusCode::OK => Ok(()),
139 reqwest::StatusCode::NOT_FOUND => {
140 Err(WalletKitError::RecoveryBindingDoesNotExist)
141 }
142 _ => Err(response_error("DELETE", &endpoint, response).await),
143 }
144 }
145
146 pub async fn get_challenge(&self) -> Result<String, WalletKitError> {
156 let url = format!("{}/api/v1/challenge", self.base_url);
157 let endpoint = self.diagnostic_endpoint("/api/v1/challenge");
158 let response = self
159 .request
160 .get(url.as_str())
161 .send()
162 .await
163 .map_err(|error| request_error("GET", &endpoint, error))?;
164 if !response.status().is_success() {
165 return Err(response_error("GET", &endpoint, response).await);
166 }
167 let challenge_response: ChallengeResponse =
168 json_response("GET", &endpoint, response).await?;
169 Ok(challenge_response.challenge)
170 }
171
172 pub async fn get_recovery_binding(
180 &self,
181 leaf_index: u64,
182 ) -> Result<RecoveryBindingResponse, WalletKitError> {
183 let url = format!(
184 "{}/api/v1/recovery-binding?leafIndex={leaf_index}",
185 self.base_url
186 );
187 let endpoint = self.diagnostic_endpoint("/api/v1/recovery-binding");
188 let response = self
189 .request
190 .get(url.as_str())
191 .send()
192 .await
193 .map_err(|error| request_error("GET", &endpoint, error))?;
194 let status = response.status();
195 if status.is_success() {
196 return json_response("GET", &endpoint, response).await;
197 }
198 if status == reqwest::StatusCode::NOT_FOUND {
199 return Err(WalletKitError::RecoveryBindingDoesNotExist);
200 }
201 Err(response_error("GET", &endpoint, response).await)
202 }
203
204 fn diagnostic_endpoint(&self, route: &str) -> String {
205 reqwest::Url::parse(&format!("{}{route}", self.base_url)).map_or_else(
206 |_| route.to_string(),
207 |url| format!("{}{}", url.origin().ascii_serialization(), url.path()),
208 )
209 }
210}
211
212async fn eligibility_error(
216 endpoint: &str,
217 response: reqwest::Response,
218) -> WalletKitError {
219 let status = response.status().as_u16();
220 let context = response_context(&response);
221 let body = match response.bytes().await {
222 Ok(body) => body,
223 Err(error) => {
224 return WalletKitError::NetworkError {
225 url: endpoint.to_string(),
226 status: Some(status),
227 error: format!(
228 "POST failed to read error body; {context}: {}",
229 request_cause(error)
230 ),
231 }
232 }
233 };
234 match serde_json::from_slice::<RecoveryBindingErrorResponse>(&body)
235 .map(|body| body.error)
236 .as_deref()
237 {
238 Ok("IdentityNotFound") => WalletKitError::IdentityNotFound,
239 Ok("NoSuccessfulCaptureFound") => WalletKitError::NoSuccessfulCaptureFound,
240 _ => WalletKitError::DebugReportNotFound,
241 }
242}
243
244fn request_error(
245 method: &str,
246 endpoint: &str,
247 error: reqwest::Error,
248) -> WalletKitError {
249 WalletKitError::NetworkError {
250 url: endpoint.to_string(),
251 status: error.status().map(|status| status.as_u16()),
252 error: format!("{method} request failed: {}", request_cause(error)),
253 }
254}
255
256fn request_cause(error: reqwest::Error) -> String {
257 let error = error.without_url();
258 let mut message = error.to_string();
259 let mut source = error.source();
260 while let Some(cause) = source {
261 message.push_str(": ");
262 message.push_str(&cause.to_string());
263 source = cause.source();
264 }
265 message
266}
267
268async fn response_error(
269 method: &str,
270 endpoint: &str,
271 response: reqwest::Response,
272) -> WalletKitError {
273 let status = response.status().as_u16();
274 let context = response_context(&response);
275 let message = match response.text().await {
276 Ok(body) => format!("{method} response rejected; {context}; body={body:?}"),
277 Err(error) => format!(
278 "{method} failed to read error body; {context}: {}",
279 request_cause(error)
280 ),
281 };
282 WalletKitError::NetworkError {
283 url: endpoint.to_string(),
284 status: Some(status),
285 error: message,
286 }
287}
288
289async fn json_response<T: serde::de::DeserializeOwned>(
290 method: &str,
291 endpoint: &str,
292 response: reqwest::Response,
293) -> Result<T, WalletKitError> {
294 let status = response.status().as_u16();
295 let context = response_context(&response);
296 response.json().await.map_err(|error| {
297 let mut json_detail = None;
299 let mut source = error.source();
300 while let Some(cause) = source {
301 if let Some(json) = cause.downcast_ref::<serde_json::Error>() {
302 json_detail = Some(format!(
303 "JSON {:?} at line {} column {}",
304 json.classify(),
305 json.line(),
306 json.column()
307 ));
308 break;
309 }
310 source = cause.source();
311 }
312 let (failure, detail) = json_detail.map_or_else(
313 || ("failed to read response body", request_cause(error)),
314 |detail| ("failed to decode JSON response", detail),
315 );
316 WalletKitError::NetworkError {
317 url: endpoint.to_string(),
318 status: Some(status),
319 error: format!("{method} {failure}; {context}: {detail}"),
320 }
321 })
322}
323
324fn response_context(response: &reqwest::Response) -> String {
325 let mut context = format!("status={}", response.status());
326 for name in [
327 "content-type",
328 "x-request-id",
329 "x-amzn-requestid",
330 "x-amzn-trace-id",
331 "traceparent",
332 "cf-ray",
333 ] {
334 if let Some(value) = response.headers().get(name) {
335 let value = value.to_str().unwrap_or("<non-UTF8>");
336 write!(context, "; {name}={value:?}")
337 .expect("writing to a String cannot fail");
338 }
339 }
340 context
341}
342
343#[cfg(test)]
344mod tests {
345
346 use super::*;
347 use test_case::test_case;
348
349 #[tokio::test]
350 async fn test_register_recovery_agent_success() {
351 let mut server = mockito::Server::new_async().await;
352 let url = server.url();
353
354 let recovery_agent = "0x1234567890abcdef".to_string();
355 let request = ManageRecoveryBindingRequest {
356 sub: "test-sub-123".to_string(),
357 leaf_index: 42,
358 signature: "0x1234567890abcdef".to_string(),
359 nonce: "0x1234567890abcdef1".to_string(),
360 recovery_agent: recovery_agent.clone(),
361 };
362
363 let mock = server
364 .mock("POST", "/api/v1/recovery-binding")
365 .match_body(mockito::Matcher::Json(serde_json::json!({
366 "sub": "test-sub-123",
367 "leafIndex": 42,
368 "signature": "0x1234567890abcdef",
369 "nonce": "0x1234567890abcdef1",
370 "recoveryAgent": "0x1234567890abcdef",
371 })))
372 .match_header("X-Auth-Signature", "security_token")
373 .match_header("X-Auth-Challenge", "challenge")
374 .with_status(201)
375 .with_body("{}")
376 .create_async()
377 .await;
378
379 let pop_api_client =
380 PopBackendClient::new(url.clone(), "test-user-agent".to_string());
381
382 let result = pop_api_client
383 .bind_recovery_agent(
384 request,
385 "security_token".to_string(),
386 "challenge".to_string(),
387 )
388 .await;
389
390 assert!(result.is_ok(), "Expected success but got error: {result:?}");
391 mock.assert_async().await;
392 drop(server);
393 }
394
395 #[test_case(r#"{"error":"IdentityNotFound"}"#, "identity_not_found")]
396 #[test_case(
397 r#"{"error":"NoSuccessfulCaptureFound"}"#,
398 "no_successful_capture_found"
399 )]
400 #[test_case(r#"{"error":"DebugReportNotFound"}"#, "debug_report_not_found")]
401 #[test_case(r#"{"error":"SomethingNewFromTheBackend"}"#, "debug_report_not_found")]
402 #[test_case("not json at all", "debug_report_not_found")]
403 #[tokio::test]
404 async fn test_register_recovery_agent_404_maps_to_eligibility_error(
405 response_body: &str,
406 expected_error: &str,
407 ) {
408 let mut server = mockito::Server::new_async().await;
409 let url = server.url();
410
411 let request = ManageRecoveryBindingRequest {
412 sub: "test-sub-123".to_string(),
413 leaf_index: 42,
414 signature: "0x1234567890abcdef".to_string(),
415 nonce: "0x1234567890abcdef1".to_string(),
416 recovery_agent: "0x1234567890abcdef".to_string(),
417 };
418
419 let mock = server
420 .mock("POST", "/api/v1/recovery-binding")
421 .match_body(mockito::Matcher::Json(serde_json::json!({
422 "sub": "test-sub-123",
423 "leafIndex": 42,
424 "signature": "0x1234567890abcdef",
425 "nonce": "0x1234567890abcdef1",
426 "recoveryAgent": "0x1234567890abcdef",
427 })))
428 .match_header("X-Auth-Signature", "security_token")
429 .match_header("X-Auth-Challenge", "challenge")
430 .with_status(404)
431 .with_body(response_body)
432 .create_async()
433 .await;
434
435 let pop_api_client =
436 PopBackendClient::new(url.clone(), "test-user-agent".to_string());
437
438 let result = pop_api_client
439 .bind_recovery_agent(
440 request,
441 "security_token".to_string(),
442 "challenge".to_string(),
443 )
444 .await;
445
446 let err = result.expect_err("Expected error but got success");
447 assert_eq!(err.to_string(), expected_error);
448 mock.assert_async().await;
449 drop(server);
450 }
451
452 #[tokio::test]
453 async fn test_unregister_recovery_agent_success() {
454 let mut server = mockito::Server::new_async().await;
455 let url = server.url();
456
457 let request = ManageRecoveryBindingRequest {
458 sub: "test-sub-123".to_string(),
459 leaf_index: 42,
460 signature: "0x1234567890abcdef".to_string(),
461 nonce: "0x1234567890abcdef1".to_string(),
462 recovery_agent: "0x0000000000000000000000000000000000000000".to_string(),
463 };
464
465 let mock = server
466 .mock("DELETE", "/api/v1/recovery-binding")
467 .match_body(mockito::Matcher::Json(serde_json::json!({
468 "sub": "test-sub-123",
469 "leafIndex": 42,
470 "signature": "0x1234567890abcdef",
471 "nonce": "0x1234567890abcdef1",
472 "recoveryAgent": "0x0000000000000000000000000000000000000000",
473 })))
474 .match_header("X-Auth-Signature", "security_token")
475 .match_header("X-Auth-Challenge", "challenge")
476 .with_status(200)
477 .with_body("{}")
478 .create_async()
479 .await;
480
481 let pop_api_client =
482 PopBackendClient::new(url.clone(), "test-user-agent".to_string());
483
484 let result = pop_api_client
485 .unbind_recovery_agent(
486 request,
487 "security_token".to_string(),
488 "challenge".to_string(),
489 )
490 .await;
491
492 assert!(result.is_ok(), "Expected success but got error: {result:?}");
493 mock.assert_async().await;
494 drop(server);
495 }
496
497 #[tokio::test]
498 async fn test_unregister_recovery_agent_not_found() {
499 let mut server = mockito::Server::new_async().await;
500 let url = server.url();
501
502 let recovery_agent = "0x0000000000000000000000000000000000000000".to_string();
503 let request = ManageRecoveryBindingRequest {
504 sub: "test-sub-123".to_string(),
505 leaf_index: 42,
506 signature: "0x1234567890abcdef".to_string(),
507 nonce: "0x1234567890abcdef1".to_string(),
508 recovery_agent: recovery_agent.clone(),
509 };
510
511 let mock = server
512 .mock("DELETE", "/api/v1/recovery-binding")
513 .match_body(mockito::Matcher::Json(serde_json::json!({
514 "sub": "test-sub-123",
515 "leafIndex": 42,
516 "signature": "0x1234567890abcdef",
517 "nonce": "0x1234567890abcdef1",
518 "recoveryAgent": "0x0000000000000000000000000000000000000000",
519 })))
520 .match_header("X-Auth-Signature", "security_token")
521 .match_header("X-Auth-Challenge", "challenge")
522 .with_status(404)
523 .with_body("Recovery agent not found")
524 .create_async()
525 .await;
526
527 let pop_api_client =
528 PopBackendClient::new(url.clone(), "test-user-agent".to_string());
529
530 let result = pop_api_client
531 .unbind_recovery_agent(
532 request,
533 "security_token".to_string(),
534 "challenge".to_string(),
535 )
536 .await;
537
538 assert!(result.is_err(), "Expected error but got success");
539 let err = result.unwrap_err();
540 assert!(
541 matches!(err, WalletKitError::RecoveryBindingDoesNotExist),
542 "Expected RecoveryBindingDoesNotExist error, got: {err:?}"
543 );
544 mock.assert_async().await;
545 drop(server);
546 }
547
548 #[tokio::test]
549 async fn test_get_recovery_binding_success() {
550 let mut server = mockito::Server::new_async().await;
551 let url = server.url();
552
553 let mock = server
554 .mock("GET", "/api/v1/recovery-binding?leafIndex=42")
555 .with_status(200)
556 .with_body("{\"recoveryAgent\": \"0x1234567890abcdef\"}")
557 .create_async()
558 .await;
559
560 let pop_api_client =
561 PopBackendClient::new(url.clone(), "test-user-agent".to_string());
562
563 let result = pop_api_client.get_recovery_binding(42).await;
564 assert!(result.is_ok(), "Expected success but got error: {result:?}");
565 mock.assert_async().await;
566 assert_eq!(
567 result.unwrap().recovery_agent,
568 Some("0x1234567890abcdef".to_string())
569 );
570 drop(server);
571 }
572
573 #[tokio::test]
574 async fn test_get_recovery_binding_not_found() {
575 let mut server = mockito::Server::new_async().await;
576 let url = server.url();
577
578 let mock = server
579 .mock("GET", "/api/v1/recovery-binding?leafIndex=42")
580 .with_status(404)
581 .with_body("Recovery agent not found")
582 .create_async()
583 .await;
584
585 let pop_api_client =
586 PopBackendClient::new(url.clone(), "test-user-agent".to_string());
587
588 let result = pop_api_client.get_recovery_binding(42).await;
589 assert!(result.is_err(), "Expected error but got success");
590 let err = result.unwrap_err();
591 assert!(
592 matches!(err, WalletKitError::RecoveryBindingDoesNotExist),
593 "Expected RecoveryBindingDoesNotExist error, got: {err:?}"
594 );
595 mock.assert_async().await;
596 drop(server);
597 }
598
599 #[tokio::test]
600 async fn test_get_recovery_binding_no_pending_recovery_agent() {
601 let mut server = mockito::Server::new_async().await;
602 let url = server.url();
603
604 let mock = server
605 .mock("GET", "/api/v1/recovery-binding?leafIndex=42")
606 .with_status(200)
607 .with_body("{\"recoveryAgent\": \"0x1234567890abcdef\"}")
608 .create_async()
609 .await;
610
611 let pop_api_client =
612 PopBackendClient::new(url.clone(), "test-user-agent".to_string());
613
614 let result = pop_api_client.get_recovery_binding(42).await;
615 assert!(result.is_ok(), "Expected success but got error: {result:?}");
616 mock.assert_async().await;
617 let recovery_binding = result.unwrap();
618 assert_eq!(recovery_binding.pending_recovery_agent, None);
619 assert_eq!(recovery_binding.execute_after, None);
620 assert_eq!(
621 recovery_binding.recovery_agent,
622 Some("0x1234567890abcdef".to_string())
623 );
624 drop(server);
625 }
626
627 #[tokio::test]
628 async fn test_get_recovery_binding_with_pending_recovery_agent() {
629 let mut server = mockito::Server::new_async().await;
630 let url = server.url();
631
632 let mock = server
633 .mock("GET", "/api/v1/recovery-binding?leafIndex=42")
634 .with_status(200)
635 .with_body("{\"recoveryAgent\": \"0x0000000000000000000000000000000000000001\", \"pendingRecoveryAgent\": \"0x0000000000000000000000000000000000000000\", \"executeAfter\": \"0x01\"}")
636 .create_async()
637 .await;
638
639 let pop_api_client =
640 PopBackendClient::new(url.clone(), "test-user-agent".to_string());
641
642 let result = pop_api_client.get_recovery_binding(42).await;
643 assert!(result.is_ok(), "Expected success but got error: {result:?}");
644 mock.assert_async().await;
645 let recovery_binding = result.unwrap();
646 assert_eq!(
647 recovery_binding.pending_recovery_agent,
648 Some("0x0000000000000000000000000000000000000000".to_string())
649 );
650 assert_eq!(recovery_binding.execute_after, Some("0x01".to_string()));
651 assert_eq!(
652 recovery_binding.recovery_agent,
653 Some("0x0000000000000000000000000000000000000001".to_string())
654 );
655 drop(server);
656 }
657 #[tokio::test]
658 async fn recovery_transport_error_keeps_operation_and_source_cause() {
659 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
660 let address = listener.local_addr().unwrap();
661 drop(listener);
662 let client =
663 PopBackendClient::new(format!("https://{address}"), "test-agent".into());
664 let error = tokio::time::timeout(
665 std::time::Duration::from_secs(10),
666 client.get_recovery_binding(987_654_321),
667 )
668 .await
669 .unwrap()
670 .unwrap_err();
671 let displayed = error.to_string();
672 assert!(displayed.contains("GET"), "missing operation: {displayed}");
673 assert!(
674 displayed.contains("/api/v1/recovery-binding"),
675 "missing endpoint: {displayed}"
676 );
677 assert!(
678 displayed.to_lowercase().contains("refused"),
679 "missing source cause: {displayed}"
680 );
681 assert!(!displayed.contains("987654321"));
682 assert!(!format!("{error:?}").contains("987654321"));
683 }
684 fn recovery_request() -> ManageRecoveryBindingRequest {
685 ManageRecoveryBindingRequest {
686 sub: "PRIVATE_SUB".into(),
687 leaf_index: 987_654_321,
688 signature: "PRIVATE_SIGNATURE".into(),
689 nonce: "PRIVATE_NONCE".into(),
690 recovery_agent: "PRIVATE_RECOVERY_AGENT".into(),
691 }
692 }
693
694 async fn call_recovery_endpoint(
695 client: &PopBackendClient,
696 method: &str,
697 route: &str,
698 ) -> Result<(), WalletKitError> {
699 match (method, route) {
700 ("POST", _) => {
701 client
702 .bind_recovery_agent(
703 recovery_request(),
704 "PRIVATE_AUTH_SIGNATURE".into(),
705 "PRIVATE_AUTH_CHALLENGE".into(),
706 )
707 .await
708 }
709 ("DELETE", _) => {
710 client
711 .unbind_recovery_agent(
712 recovery_request(),
713 "PRIVATE_AUTH_SIGNATURE".into(),
714 "PRIVATE_AUTH_CHALLENGE".into(),
715 )
716 .await
717 }
718 ("GET", "/api/v1/challenge") => client.get_challenge().await.map(|_| ()),
719 ("GET", _) => client.get_recovery_binding(987_654_321).await.map(|_| ()),
720 _ => panic!("unsupported fixture endpoint"),
721 }
722 }
723
724 #[test_case("POST", "/api/v1/recovery-binding")]
725 #[test_case("DELETE", "/api/v1/recovery-binding")]
726 #[test_case("GET", "/api/v1/recovery-binding")]
727 #[test_case("GET", "/api/v1/challenge")]
728 #[tokio::test]
729 async fn rejection_displays_method_status_body_and_response_metadata(
730 method: &str,
731 route: &str,
732 ) {
733 let mut server = mockito::Server::new_async().await;
734 let uri = if method == "GET" && route == "/api/v1/recovery-binding" {
735 format!("{route}?leafIndex=987654321")
736 } else {
737 route.to_string()
738 };
739 let mock = server
740 .mock(method, uri.as_str())
741 .with_status(503)
742 .with_header("content-type", "application/json")
743 .with_header("x-request-id", "request-abc123")
744 .with_header("cf-ray", "ray-123-FRA")
745 .with_body(r#"{"error":"upstream maintenance"}"#)
746 .create_async()
747 .await;
748 let client = PopBackendClient::new(server.url(), "test-agent".into());
749 let error = call_recovery_endpoint(&client, method, route)
750 .await
751 .unwrap_err();
752 let displayed = error.to_string();
753 for expected in [
754 method,
755 route,
756 "503",
757 "upstream maintenance",
758 "application/json",
759 "request-abc123",
760 "ray-123-FRA",
761 ] {
762 assert!(
763 displayed.contains(expected),
764 "missing {expected}: {displayed}"
765 );
766 }
767 for private in [
768 "PRIVATE_",
769 "987654321",
770 "X-Auth-Signature",
771 "X-Auth-Challenge",
772 ] {
773 assert!(
774 !displayed.contains(private),
775 "request data leaked: {displayed}"
776 );
777 }
778 assert!(matches!(
779 error,
780 WalletKitError::NetworkError {
781 status: Some(503),
782 ..
783 }
784 ));
785 mock.assert_async().await;
786 drop(server);
787 }
788
789 #[test_case("/api/v1/challenge", "{", "Eof")]
790 #[test_case("/api/v1/recovery-binding", "{", "Eof")]
791 #[test_case("/api/v1/challenge", r#""PRIVATE_SUCCESS_CHALLENGE""#, "Data")]
792 #[test_case("/api/v1/recovery-binding", r#""PRIVATE_SUCCESS_BINDING""#, "Data")]
793 #[test_case("/api/v1/challenge", r#"{"challenge":123456789101112}"#, "Data")]
794 #[test_case(
795 "/api/v1/recovery-binding",
796 r#"{"recoveryAgent":123456789101112}"#,
797 "Data"
798 )]
799 #[tokio::test]
800 async fn decode_failure_displays_context_without_successful_auth_data(
801 route: &str,
802 body: &str,
803 category: &str,
804 ) {
805 let mut server = mockito::Server::new_async().await;
806 let uri = if route == "/api/v1/recovery-binding" {
807 format!("{route}?leafIndex=987654321")
808 } else {
809 route.to_string()
810 };
811 let mock = server
812 .mock("GET", uri.as_str())
813 .with_status(200)
814 .with_header("content-type", "application/json")
815 .with_header("x-request-id", "decode-request-123")
816 .with_body(body)
817 .create_async()
818 .await;
819 let client = PopBackendClient::new(server.url(), "test-agent".into());
820 let error = call_recovery_endpoint(&client, "GET", route)
821 .await
822 .unwrap_err();
823 let displayed = error.to_string();
824 for expected in [
825 "GET",
826 route,
827 "200",
828 "decode JSON",
829 category,
830 "line",
831 "column",
832 "application/json",
833 "decode-request-123",
834 ] {
835 assert!(
836 displayed.contains(expected),
837 "missing {expected}: {displayed}"
838 );
839 }
840 for private in ["PRIVATE_", "987654321", "123456789101112"] {
841 assert!(
842 !displayed.contains(private),
843 "successful response data leaked: {displayed}"
844 );
845 assert!(!format!("{error:?}").contains(private));
846 }
847 assert!(matches!(
848 error,
849 WalletKitError::NetworkError {
850 status: Some(200),
851 ..
852 }
853 ));
854 mock.assert_async().await;
855 drop(server);
856 }
857
858 #[test_case(503)]
859 #[test_case(404)]
860 #[tokio::test]
861 async fn failed_error_body_read_retains_context(status: usize) {
862 let mut server = mockito::Server::new_async().await;
863 let mock = server
864 .mock("POST", "/api/v1/recovery-binding")
865 .with_status(status)
866 .with_header("x-request-id", "truncated-request-123")
867 .with_chunked_body(|writer| {
868 writer.write_all(b"incomplete")?;
869 Err(std::io::Error::new(
870 std::io::ErrorKind::ConnectionReset,
871 "fixture interrupted body",
872 ))
873 })
874 .create_async()
875 .await;
876 let client = PopBackendClient::new(server.url(), "test-agent".into());
877 let error = call_recovery_endpoint(&client, "POST", "/api/v1/recovery-binding")
878 .await
879 .unwrap_err();
880 let displayed = error.to_string();
881 for expected in [
882 "POST",
883 "/api/v1/recovery-binding",
884 "failed to read error body",
885 "truncated-request-123",
886 ] {
887 assert!(
888 displayed.contains(expected),
889 "missing {expected}: {displayed}"
890 );
891 }
892 match error {
893 WalletKitError::NetworkError {
894 status: Some(observed),
895 ..
896 } => assert_eq!(usize::from(observed), status),
897 other => panic!("missing status on body-read failure: {other:?}"),
898 }
899 mock.assert_async().await;
900 drop(server);
901 }
902
903 #[tokio::test]
904 async fn bind_precondition_failure_keeps_semantic_error() {
905 let mut server = mockito::Server::new_async().await;
906 let mock = server
907 .mock("POST", "/api/v1/recovery-binding")
908 .with_status(412)
909 .create_async()
910 .await;
911 let client = PopBackendClient::new(server.url(), "test-agent".into());
912 let error = call_recovery_endpoint(&client, "POST", "/api/v1/recovery-binding")
913 .await
914 .unwrap_err();
915 assert!(matches!(error, WalletKitError::NotEligibleForRecovery));
916 mock.assert_async().await;
917 drop(server);
918 }
919 #[test]
920 fn diagnostic_endpoint_preserves_base_path_without_private_url_parts() {
921 let client = PopBackendClient::new(
922 "https://private-user:private-password@stage.invalid/proxy".into(),
923 "test-agent".into(),
924 );
925 assert_eq!(
926 client.diagnostic_endpoint("/api/v1/recovery-binding"),
927 "https://stage.invalid/proxy/api/v1/recovery-binding"
928 );
929 let client = PopBackendClient::new("https://private-user:private-password@stage.invalid/proxy?PRIVATE_QUERY#PRIVATE_FRAGMENT".into(), "test-agent".into());
930 assert_eq!(
931 client.diagnostic_endpoint("/api/v1/challenge"),
932 "https://stage.invalid/proxy"
933 );
934 }
935
936 #[tokio::test]
937 async fn rejection_reports_the_actual_prefixed_endpoint() {
938 let mut server = mockito::Server::new_async().await;
939 let mock = server
940 .mock("GET", "/proxy/api/v1/challenge")
941 .with_status(503)
942 .with_body("upstream unavailable")
943 .create_async()
944 .await;
945 let client = PopBackendClient::new(
946 format!("{}/proxy", server.url()),
947 "test-agent".into(),
948 );
949 let error = client.get_challenge().await.unwrap_err();
950 assert!(error.to_string().contains("/proxy/api/v1/challenge"));
951 mock.assert_async().await;
952 drop(server);
953 }
954 #[tokio::test]
955 async fn failed_success_body_read_retains_the_transport_cause() {
956 let mut server = mockito::Server::new_async().await;
957 let mock = server
958 .mock("GET", "/api/v1/challenge")
959 .with_status(200)
960 .with_header("x-request-id", "success-read-request")
961 .with_chunked_body(|writer| {
962 writer.write_all(b"{\"challenge\":")?;
963 Err(std::io::Error::new(
964 std::io::ErrorKind::ConnectionReset,
965 "fixture interrupted body",
966 ))
967 })
968 .create_async()
969 .await;
970 let client = PopBackendClient::new(server.url(), "test-agent".into());
971 let error = client.get_challenge().await.unwrap_err();
972 let displayed = error.to_string();
973 assert!(
974 displayed.contains("connection"),
975 "transport cause was discarded: {displayed}"
976 );
977 assert!(displayed.contains("failed to read response body"));
978 assert!(displayed.contains("200"));
979 assert!(displayed.contains("success-read-request"));
980 mock.assert_async().await;
981 drop(server);
982 }
983}