1use crate::error::WalletKitError;
2use crate::http_request::Request;
3use reqwest::Method;
4use serde::{Deserialize, Serialize};
5
6#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, uniffi::Record)]
10pub struct ManageRecoveryBindingRequest {
11 pub sub: String,
13 #[serde(rename = "leafIndex")]
15 pub leaf_index: u64,
16 pub signature: String,
18 pub nonce: String,
20 #[serde(rename = "recoveryAgent")]
22 pub recovery_agent: String,
23}
24
25#[derive(Deserialize)]
26struct ChallengeResponse {
27 challenge: String,
28}
29
30#[derive(Deserialize)]
32struct RecoveryBindingErrorResponse {
33 error: String,
34}
35
36#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, uniffi::Record)]
37pub struct RecoveryBindingResponse {
38 #[serde(rename = "recoveryAgent")]
39 pub recovery_agent: Option<String>,
40 #[serde(rename = "pendingRecoveryAgent")]
41 pub pending_recovery_agent: Option<String>,
42 #[serde(rename = "executeAfter")]
43 pub execute_after: Option<String>,
44}
45
46pub struct PopBackendClient {
55 request: Request,
56 base_url: String,
57}
58
59impl PopBackendClient {
60 #[must_use]
62 pub fn new(base_url: String, user_agent: String) -> Self {
63 let request = Request::new(user_agent);
64 Self { request, base_url }
65 }
66}
67
68impl PopBackendClient {
69 pub async fn bind_recovery_agent(
81 &self,
82 request: ManageRecoveryBindingRequest,
83 security_token: String,
84 challenge: String,
85 ) -> Result<(), WalletKitError> {
86 let url: String = format!("{}/api/v1/recovery-binding", self.base_url);
87 let response = self
88 .request
89 .post(&url)
90 .json(&request)
91 .header("X-Auth-Signature", security_token)
92 .header("X-Auth-Challenge", challenge)
93 .send()
94 .await?;
95
96 let response_status = response.status();
97 match response_status {
98 reqwest::StatusCode::CREATED | reqwest::StatusCode::OK => Ok(()),
99 reqwest::StatusCode::NOT_FOUND => Err(eligibility_error(response).await),
100 reqwest::StatusCode::PRECONDITION_FAILED => {
101 Err(WalletKitError::NotEligibleForRecovery)
102 }
103 _ => {
104 let error_message = response
105 .text()
106 .await
107 .unwrap_or_else(|e| format!("Unknown error: {e:?}"));
108 Err(WalletKitError::NetworkError {
109 url,
110 error: error_message,
111 status: Some(response_status.as_u16()),
112 })
113 }
114 }
115 }
116
117 pub async fn unbind_recovery_agent(
124 &self,
125 request: ManageRecoveryBindingRequest,
126 security_token: String,
127 challenge: String,
128 ) -> Result<(), WalletKitError> {
129 let url: String = format!("{}/api/v1/recovery-binding", self.base_url);
130 let response = self
131 .request
132 .req(Method::DELETE, url.as_str())
133 .json(&request)
134 .header("X-Auth-Signature", security_token)
135 .header("X-Auth-Challenge", challenge)
136 .send()
137 .await?;
138 let response_status = response.status();
139 match response_status {
140 reqwest::StatusCode::OK => Ok(()),
141 reqwest::StatusCode::NOT_FOUND => {
142 Err(WalletKitError::RecoveryBindingDoesNotExist)
143 }
144 _ => {
145 let error_message = response
146 .text()
147 .await
148 .unwrap_or_else(|e| format!("Unknown error: {e:?}"));
149 Err(WalletKitError::NetworkError {
150 url,
151 error: error_message,
152 status: Some(response_status.as_u16()),
153 })
154 }
155 }
156 }
157
158 pub async fn get_challenge(&self) -> Result<String, WalletKitError> {
168 let url = format!("{}/api/v1/challenge", self.base_url);
169 let response = self.request.get(url.as_str()).send().await?;
170
171 let response_status = response.status();
172 if !response_status.is_success() {
173 let error_message = response
174 .text()
175 .await
176 .unwrap_or_else(|e| format!("Unknown error: {e:?}"));
177 return Err(WalletKitError::NetworkError {
178 url,
179 status: Some(response_status.as_u16()),
180 error: error_message,
181 });
182 }
183
184 let challenge_response: ChallengeResponse =
185 response
186 .json()
187 .await
188 .map_err(|e| WalletKitError::SerializationError {
189 error: format!("Failed to parse challenge response: {e}"),
190 })?;
191
192 Ok(challenge_response.challenge)
193 }
194
195 pub async fn get_recovery_binding(
203 &self,
204 leaf_index: u64,
205 ) -> Result<RecoveryBindingResponse, WalletKitError> {
206 let url = format!(
207 "{}/api/v1/recovery-binding?leafIndex={leaf_index}",
208 self.base_url
209 );
210 let response = self.request.get(url.as_str()).send().await?;
211
212 let status = response.status();
213 if status.is_success() {
214 let recovery_binding: RecoveryBindingResponse = response.json().await?;
215 return Ok(recovery_binding);
216 }
217 if status == reqwest::StatusCode::NOT_FOUND {
218 return Err(WalletKitError::RecoveryBindingDoesNotExist);
219 }
220 let error_message = response
221 .text()
222 .await
223 .unwrap_or_else(|e| format!("Unknown error: {e:?}"));
224 Err(WalletKitError::NetworkError {
225 url,
226 error: error_message,
227 status: Some(status.as_u16()),
228 })
229 }
230}
231
232async fn eligibility_error(response: reqwest::Response) -> WalletKitError {
236 match response
237 .json::<RecoveryBindingErrorResponse>()
238 .await
239 .map(|body| body.error)
240 .as_deref()
241 {
242 Ok("IdentityNotFound") => WalletKitError::IdentityNotFound,
243 Ok("NoSuccessfulCaptureFound") => WalletKitError::NoSuccessfulCaptureFound,
244 _ => WalletKitError::DebugReportNotFound,
245 }
246}
247
248#[cfg(test)]
249mod tests {
250
251 use super::*;
252 use test_case::test_case;
253
254 #[tokio::test]
255 async fn test_register_recovery_agent_success() {
256 let mut server = mockito::Server::new_async().await;
257 let url = server.url();
258
259 let recovery_agent = "0x1234567890abcdef".to_string();
260 let request = ManageRecoveryBindingRequest {
261 sub: "test-sub-123".to_string(),
262 leaf_index: 42,
263 signature: "0x1234567890abcdef".to_string(),
264 nonce: "0x1234567890abcdef1".to_string(),
265 recovery_agent: recovery_agent.clone(),
266 };
267
268 let mock = server
269 .mock("POST", "/api/v1/recovery-binding")
270 .match_body(mockito::Matcher::Json(serde_json::json!({
271 "sub": "test-sub-123",
272 "leafIndex": 42,
273 "signature": "0x1234567890abcdef",
274 "nonce": "0x1234567890abcdef1",
275 "recoveryAgent": "0x1234567890abcdef",
276 })))
277 .match_header("X-Auth-Signature", "security_token")
278 .match_header("X-Auth-Challenge", "challenge")
279 .with_status(201)
280 .with_body("{}")
281 .create_async()
282 .await;
283
284 let pop_api_client =
285 PopBackendClient::new(url.clone(), "test-user-agent".to_string());
286
287 let result = pop_api_client
288 .bind_recovery_agent(
289 request,
290 "security_token".to_string(),
291 "challenge".to_string(),
292 )
293 .await;
294
295 assert!(result.is_ok(), "Expected success but got error: {result:?}");
296 mock.assert_async().await;
297 drop(server);
298 }
299
300 #[test_case(r#"{"error":"IdentityNotFound"}"#, "identity_not_found")]
301 #[test_case(
302 r#"{"error":"NoSuccessfulCaptureFound"}"#,
303 "no_successful_capture_found"
304 )]
305 #[test_case(r#"{"error":"DebugReportNotFound"}"#, "debug_report_not_found")]
306 #[test_case(r#"{"error":"SomethingNewFromTheBackend"}"#, "debug_report_not_found")]
307 #[test_case("not json at all", "debug_report_not_found")]
308 #[tokio::test]
309 async fn test_register_recovery_agent_404_maps_to_eligibility_error(
310 response_body: &str,
311 expected_error: &str,
312 ) {
313 let mut server = mockito::Server::new_async().await;
314 let url = server.url();
315
316 let request = ManageRecoveryBindingRequest {
317 sub: "test-sub-123".to_string(),
318 leaf_index: 42,
319 signature: "0x1234567890abcdef".to_string(),
320 nonce: "0x1234567890abcdef1".to_string(),
321 recovery_agent: "0x1234567890abcdef".to_string(),
322 };
323
324 let mock = server
325 .mock("POST", "/api/v1/recovery-binding")
326 .match_body(mockito::Matcher::Json(serde_json::json!({
327 "sub": "test-sub-123",
328 "leafIndex": 42,
329 "signature": "0x1234567890abcdef",
330 "nonce": "0x1234567890abcdef1",
331 "recoveryAgent": "0x1234567890abcdef",
332 })))
333 .match_header("X-Auth-Signature", "security_token")
334 .match_header("X-Auth-Challenge", "challenge")
335 .with_status(404)
336 .with_body(response_body)
337 .create_async()
338 .await;
339
340 let pop_api_client =
341 PopBackendClient::new(url.clone(), "test-user-agent".to_string());
342
343 let result = pop_api_client
344 .bind_recovery_agent(
345 request,
346 "security_token".to_string(),
347 "challenge".to_string(),
348 )
349 .await;
350
351 let err = result.expect_err("Expected error but got success");
352 assert_eq!(err.to_string(), expected_error);
353 mock.assert_async().await;
354 drop(server);
355 }
356
357 #[tokio::test]
358 async fn test_unregister_recovery_agent_success() {
359 let mut server = mockito::Server::new_async().await;
360 let url = server.url();
361
362 let request = ManageRecoveryBindingRequest {
363 sub: "test-sub-123".to_string(),
364 leaf_index: 42,
365 signature: "0x1234567890abcdef".to_string(),
366 nonce: "0x1234567890abcdef1".to_string(),
367 recovery_agent: "0x0000000000000000000000000000000000000000".to_string(),
368 };
369
370 let mock = server
371 .mock("DELETE", "/api/v1/recovery-binding")
372 .match_body(mockito::Matcher::Json(serde_json::json!({
373 "sub": "test-sub-123",
374 "leafIndex": 42,
375 "signature": "0x1234567890abcdef",
376 "nonce": "0x1234567890abcdef1",
377 "recoveryAgent": "0x0000000000000000000000000000000000000000",
378 })))
379 .match_header("X-Auth-Signature", "security_token")
380 .match_header("X-Auth-Challenge", "challenge")
381 .with_status(200)
382 .with_body("{}")
383 .create_async()
384 .await;
385
386 let pop_api_client =
387 PopBackendClient::new(url.clone(), "test-user-agent".to_string());
388
389 let result = pop_api_client
390 .unbind_recovery_agent(
391 request,
392 "security_token".to_string(),
393 "challenge".to_string(),
394 )
395 .await;
396
397 assert!(result.is_ok(), "Expected success but got error: {result:?}");
398 mock.assert_async().await;
399 drop(server);
400 }
401
402 #[tokio::test]
403 async fn test_unregister_recovery_agent_not_found() {
404 let mut server = mockito::Server::new_async().await;
405 let url = server.url();
406
407 let recovery_agent = "0x0000000000000000000000000000000000000000".to_string();
408 let request = ManageRecoveryBindingRequest {
409 sub: "test-sub-123".to_string(),
410 leaf_index: 42,
411 signature: "0x1234567890abcdef".to_string(),
412 nonce: "0x1234567890abcdef1".to_string(),
413 recovery_agent: recovery_agent.clone(),
414 };
415
416 let mock = server
417 .mock("DELETE", "/api/v1/recovery-binding")
418 .match_body(mockito::Matcher::Json(serde_json::json!({
419 "sub": "test-sub-123",
420 "leafIndex": 42,
421 "signature": "0x1234567890abcdef",
422 "nonce": "0x1234567890abcdef1",
423 "recoveryAgent": "0x0000000000000000000000000000000000000000",
424 })))
425 .match_header("X-Auth-Signature", "security_token")
426 .match_header("X-Auth-Challenge", "challenge")
427 .with_status(404)
428 .with_body("Recovery agent not found")
429 .create_async()
430 .await;
431
432 let pop_api_client =
433 PopBackendClient::new(url.clone(), "test-user-agent".to_string());
434
435 let result = pop_api_client
436 .unbind_recovery_agent(
437 request,
438 "security_token".to_string(),
439 "challenge".to_string(),
440 )
441 .await;
442
443 assert!(result.is_err(), "Expected error but got success");
444 let err = result.unwrap_err();
445 assert!(
446 matches!(err, WalletKitError::RecoveryBindingDoesNotExist),
447 "Expected RecoveryBindingDoesNotExist error, got: {err:?}"
448 );
449 mock.assert_async().await;
450 drop(server);
451 }
452
453 #[tokio::test]
454 async fn test_get_recovery_binding_success() {
455 let mut server = mockito::Server::new_async().await;
456 let url = server.url();
457
458 let mock = server
459 .mock("GET", "/api/v1/recovery-binding?leafIndex=42")
460 .with_status(200)
461 .with_body("{\"recoveryAgent\": \"0x1234567890abcdef\"}")
462 .create_async()
463 .await;
464
465 let pop_api_client =
466 PopBackendClient::new(url.clone(), "test-user-agent".to_string());
467
468 let result = pop_api_client.get_recovery_binding(42).await;
469 assert!(result.is_ok(), "Expected success but got error: {result:?}");
470 mock.assert_async().await;
471 assert_eq!(
472 result.unwrap().recovery_agent,
473 Some("0x1234567890abcdef".to_string())
474 );
475 drop(server);
476 }
477
478 #[tokio::test]
479 async fn test_get_recovery_binding_not_found() {
480 let mut server = mockito::Server::new_async().await;
481 let url = server.url();
482
483 let mock = server
484 .mock("GET", "/api/v1/recovery-binding?leafIndex=42")
485 .with_status(404)
486 .with_body("Recovery agent not found")
487 .create_async()
488 .await;
489
490 let pop_api_client =
491 PopBackendClient::new(url.clone(), "test-user-agent".to_string());
492
493 let result = pop_api_client.get_recovery_binding(42).await;
494 assert!(result.is_err(), "Expected error but got success");
495 let err = result.unwrap_err();
496 assert!(
497 matches!(err, WalletKitError::RecoveryBindingDoesNotExist),
498 "Expected RecoveryBindingDoesNotExist error, got: {err:?}"
499 );
500 mock.assert_async().await;
501 drop(server);
502 }
503
504 #[tokio::test]
505 async fn test_get_recovery_binding_no_pending_recovery_agent() {
506 let mut server = mockito::Server::new_async().await;
507 let url = server.url();
508
509 let mock = server
510 .mock("GET", "/api/v1/recovery-binding?leafIndex=42")
511 .with_status(200)
512 .with_body("{\"recoveryAgent\": \"0x1234567890abcdef\"}")
513 .create_async()
514 .await;
515
516 let pop_api_client =
517 PopBackendClient::new(url.clone(), "test-user-agent".to_string());
518
519 let result = pop_api_client.get_recovery_binding(42).await;
520 assert!(result.is_ok(), "Expected success but got error: {result:?}");
521 mock.assert_async().await;
522 let recovery_binding = result.unwrap();
523 assert_eq!(recovery_binding.pending_recovery_agent, None);
524 assert_eq!(recovery_binding.execute_after, None);
525 assert_eq!(
526 recovery_binding.recovery_agent,
527 Some("0x1234567890abcdef".to_string())
528 );
529 drop(server);
530 }
531
532 #[tokio::test]
533 async fn test_get_recovery_binding_with_pending_recovery_agent() {
534 let mut server = mockito::Server::new_async().await;
535 let url = server.url();
536
537 let mock = server
538 .mock("GET", "/api/v1/recovery-binding?leafIndex=42")
539 .with_status(200)
540 .with_body("{\"recoveryAgent\": \"0x0000000000000000000000000000000000000001\", \"pendingRecoveryAgent\": \"0x0000000000000000000000000000000000000000\", \"executeAfter\": \"0x01\"}")
541 .create_async()
542 .await;
543
544 let pop_api_client =
545 PopBackendClient::new(url.clone(), "test-user-agent".to_string());
546
547 let result = pop_api_client.get_recovery_binding(42).await;
548 assert!(result.is_ok(), "Expected success but got error: {result:?}");
549 mock.assert_async().await;
550 let recovery_binding = result.unwrap();
551 assert_eq!(
552 recovery_binding.pending_recovery_agent,
553 Some("0x0000000000000000000000000000000000000000".to_string())
554 );
555 assert_eq!(recovery_binding.execute_after, Some("0x01".to_string()));
556 assert_eq!(
557 recovery_binding.recovery_agent,
558 Some("0x0000000000000000000000000000000000000001".to_string())
559 );
560 drop(server);
561 }
562}