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