Skip to main content

walletkit_core/issuers/
pop_backend_client.rs

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/// Request payload for registering or unregistering a recovery binding.
9///
10/// Serialized as JSON with `leafIndex` (camelCase) to match the `PoP` backend API.
11#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, uniffi::Record)]
12pub struct ManageRecoveryBindingRequest {
13    /// Hex-encoded subject identifier of the recovery binding.
14    pub sub: String,
15    /// The authenticator's leaf index in the World ID Merkle tree.
16    #[serde(rename = "leafIndex")]
17    pub leaf_index: u64,
18    /// The signature of the recovery agent update.
19    pub signature: String,
20    /// The nonce of the recovery agent update.
21    pub nonce: String,
22    /// The checksummed hex address of the recovery agent (e.g. `"0x1234…"`).
23    #[serde(rename = "recoveryAgent")]
24    pub recovery_agent: String,
25}
26
27#[derive(Deserialize)]
28struct ChallengeResponse {
29    challenge: String,
30}
31
32/// Error body returned by the recovery binding endpoints, carrying the sentinel error name.
33#[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
48/// Low-level HTTP client for the Proof-of-Personhood (`PoP`) backend API.
49///
50/// Handles the REST calls for recovery binding management and challenge retrieval.
51/// All requests that mutate state require a security token (signature) and a
52/// challenge, passed via `X-Auth-Signature` and `X-Auth-Challenge` headers.
53///
54/// This client is not meant to be used directly — prefer [`super::RecoveryBindingManager`],
55/// which orchestrates the full challenge-response signing flow.
56pub struct PopBackendClient {
57    request: Request,
58    base_url: String,
59}
60
61impl PopBackendClient {
62    /// Creates a new client targeting the given base URL.
63    #[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    /// Registers a new recovery binding via `POST /api/v1/recovery-binding`.
72    ///
73    /// # Errors
74    ///
75    /// * [`WalletKitError::IdentityNotFound`], [`WalletKitError::NoSuccessfulCaptureFound`],
76    ///   [`WalletKitError::DebugReportNotFound`]: HTTP 404 Not Found, split by the `error`
77    ///   field of the response body. An unrecognized body maps to `DebugReportNotFound`.
78    /// * [`WalletKitError::NotEligibleForRecovery`] — HTTP 412 Precondition Failed.
79    /// * [`WalletKitError::NetworkError`] — request or response-body read failure, or another
80    ///   non-success status. Rejections retain the response body and HTTP status.
81    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    /// Removes a recovery binding via `DELETE /api/v1/recovery-binding`.
113    ///
114    /// # Errors
115    ///
116    /// * [`WalletKitError::AccountDoesNotExist`] — HTTP 404 (no binding found).
117    /// * [`WalletKitError::NetworkError`] — request or response-body read failure, or another
118    ///   non-success status.
119    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    /// Fetches a one-time challenge string via `GET /api/v1/challenge`.
147    ///
148    /// The returned challenge is used as input to the commitment signature
149    /// required by the register/unregister endpoints.
150    ///
151    /// # Errors
152    ///
153    /// * [`WalletKitError::NetworkError`] — non-success HTTP status, request failure,
154    ///   response-body read failure, or JSON decode failure.
155    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    /// Fetches a recovery binding via `GET /api/v1/recovery-binding`.
173    ///
174    /// # Errors
175    ///
176    /// * [`WalletKitError::NetworkError`] — non-success HTTP status, request failure,
177    ///   response-body read failure, or JSON decode failure.
178    /// * [`WalletKitError::RecoveryBindingDoesNotExist`] — HTTP 404 (no binding found).
179    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
212/// Maps a 404 from `POST /api/v1/recovery-binding` to the eligibility failure named in the body.
213///
214/// An unrecognized body keeps the historical [`WalletKitError::DebugReportNotFound`] mapping.
215async 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        // Serde messages can echo successful authentication values.
298        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}