An easy-to-host PDS on the ATProtocol, iPhone and MacOS. Maintain control of your keys and data, always.
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix(identity-wallet): address Phase 2 code review feedback

- C1: Add 3 missing tests for request_claim_verification (AC4.2)
* Test 1: Success path - mock returns 200, verifies XRPC call succeeds
* Test 3: Unauthorized - no OAuth client in claim state (already existed, renamed)
* Test 4: Network error - PDS returns 500, verifies NetworkError is returned

- I1: Fix claim_state Mutex lock held during network round-trip
* Wrapped OAuthClient in Arc in ClaimState for safe cloning out of lock
* Updated request_claim_verification to extract and clone Arc before network call
* Updated start_pds_auth to wrap OAuthClient in Arc when storing
* Updated test cases to use Arc-wrapped OAuthClient

- I2: Update FCIS pattern comment at top of claim.rs
* Added start_pds_auth and request_claim_verification to Imperative Shell list
* Updated to show all three Tauri commands in the file

- M1: Add descriptive error for nonce retry failure
* Added else branch in pds_exchange_code_with_retry to handle non-200 retry response
* Extracts status and response body for detailed error message
* Format: "token exchange retry returned {status}: {body}"

authored by

Malpercio and committed by
Tangled
0be06c23 81e4190e

+150 -9
+150 -9
apps/identity-wallet/src-tauri/src/claim.rs
··· 1 - // pattern: Mixed (Functional Core types + Imperative Shell command) 1 + // pattern: Mixed (Functional Core types + Imperative Shell commands) 2 2 // 3 3 // Functional Core: IdentityInfo, VerifiedClaimOp, OpDiff, ServiceChange, ClaimResult, 4 4 // ClaimState, ResolveError, ClaimError (types and errors) 5 5 // Imperative Shell: resolve_identity (command: resolves handle/DID, fetches DID doc from 6 6 // plc.directory, checks IdentityStore, stores state, returns IdentityInfo) 7 + // start_pds_auth (command: performs OAuth PKCE+DPoP flow against PDS, 8 + // stores OAuthClient in claim_state) 9 + // request_claim_verification (command: calls requestPlcOperationSignature XRPC 10 + // endpoint on old PDS to trigger email verification) 7 11 8 12 use serde::Serialize; 9 13 use tauri::Emitter; ··· 97 101 /// The DID document fetched from plc.directory (discovered by `resolve_identity`) 98 102 pub did_doc: PlcDidDocument, 99 103 /// OAuth client for the PDS (set after `start_pds_auth` succeeds) 100 - pub pds_oauth_client: Option<OAuthClient>, 104 + /// Wrapped in Arc to allow cloning out of the Mutex without holding the lock 105 + /// across the network call in `request_claim_verification`. 106 + pub pds_oauth_client: Option<std::sync::Arc<OAuthClient>>, 101 107 /// Verified signed operation (set after `sign_and_verify_claim` succeeds) 102 108 pub verified_signed_op: Option<String>, 103 109 } ··· 411 417 412 418 let mut claim_state = state.claim_state.lock().await; 413 419 if let Some(ref mut claim) = claim_state.as_mut() { 414 - claim.pds_oauth_client = Some(oauth_client); 420 + claim.pds_oauth_client = Some(std::sync::Arc::new(oauth_client)); 415 421 } 416 422 drop(claim_state); 417 423 ··· 505 511 message: format!("retry token response parsing failed: {}", e), 506 512 })?; 507 513 return Ok((token, retry_nonce)); 514 + } else { 515 + // Retry response was non-200, extract status and body for error message 516 + let status = retry_resp.status(); 517 + let body = retry_resp 518 + .text() 519 + .await 520 + .unwrap_or_else(|_| "(unable to read response body)".to_string()); 521 + return Err(ClaimError::NetworkError { 522 + message: format!("token exchange retry returned {}: {}", status, body), 523 + }); 508 524 } 509 525 } 510 526 } ··· 530 546 state: tauri::State<'_, crate::oauth::AppState>, 531 547 _did: String, 532 548 ) -> Result<(), ClaimError> { 533 - let claim_state = state.claim_state.lock().await; 534 - let Some(claim) = claim_state.as_ref() else { 535 - drop(claim_state); 549 + // Acquire lock, extract Arc<OAuthClient>, and release lock before making network call 550 + let oauth_client = { 551 + let claim_state = state.claim_state.lock().await; 552 + let Some(claim) = claim_state.as_ref() else { 553 + return Err(ClaimError::Unauthorized); 554 + }; 555 + claim.pds_oauth_client.clone() 556 + }; // claim_state lock released here 557 + 558 + let Some(oauth_client) = oauth_client else { 536 559 return Err(ClaimError::Unauthorized); 537 560 }; 538 561 539 - request_claim_verification_impl(claim).await 562 + crate::pds_client::request_plc_operation_signature(&oauth_client) 563 + .await 564 + .map_err(|e| ClaimError::NetworkError { 565 + message: format!("request_plc_operation_signature failed: {}", e), 566 + }) 540 567 } 541 568 542 569 /// Testable core logic for `request_claim_verification`. ··· 916 943 917 944 // ── request_claim_verification tests (AC4.2) ────────────────────────────── 918 945 919 - /// Test: request_claim_verification_impl returns Unauthorized when pds_oauth_client is None 920 - /// Verifies AC4.2: request_claim_verification validates OAuth client is present 946 + /// Test 1: Success — calls XRPC endpoint with 200 response 947 + /// Verifies AC4.2: request_claim_verification calls requestPlcOperationSignature on the old PDS 948 + #[tokio::test] 949 + async fn test_request_claim_verification_success() { 950 + use httpmock::MockServer; 951 + use std::sync::{Arc, Mutex}; 952 + 953 + let mock_server = MockServer::start(); 954 + 955 + mock_server.mock(|when, then| { 956 + when.method(httpmock::Method::POST) 957 + .path("/xrpc/com.atproto.identity.requestPlcOperationSignature") 958 + .header_exists("Authorization") 959 + .header_exists("DPoP"); 960 + then.status(200).json_body(serde_json::json!({})); 961 + }); 962 + 963 + // Create a test session and OAuthClient 964 + let session = Arc::new(Mutex::new(crate::oauth::OAuthSession { 965 + access_token: "test_access_token".to_string(), 966 + refresh_token: "test_refresh_token".to_string(), 967 + expires_at: std::time::SystemTime::now() 968 + .duration_since(std::time::UNIX_EPOCH) 969 + .unwrap() 970 + .as_secs() 971 + + 3600, 972 + dpop_nonce: None, 973 + })); 974 + 975 + let keypair = crate::oauth::DPoPKeypair::get_or_create().expect("keypair must exist"); 976 + let oauth_client = crate::oauth_client::OAuthClient::new_for_test( 977 + keypair, 978 + session, 979 + mock_server.base_url(), 980 + ); 981 + 982 + let claim_state = ClaimState { 983 + did: "did:plc:test".to_string(), 984 + pds_url: mock_server.base_url(), 985 + did_doc: PlcDidDocument { 986 + did: "did:plc:test".to_string(), 987 + also_known_as: vec!["at://test.example.com".to_string()], 988 + rotation_keys: vec!["did:key:zQ3test".to_string()], 989 + verification_methods: serde_json::json!({}), 990 + services: std::collections::HashMap::new(), 991 + }, 992 + pds_oauth_client: Some(std::sync::Arc::new(oauth_client)), 993 + verified_signed_op: None, 994 + }; 995 + 996 + let result = request_claim_verification_impl(&claim_state).await; 997 + assert!( 998 + result.is_ok(), 999 + "should successfully call requestPlcOperationSignature when PDS returns 200" 1000 + ); 1001 + } 1002 + 1003 + /// Test 3 (renamed): Unauthorized — no OAuth client 1004 + /// Verifies AC4.2: request_claim_verification returns Unauthorized when pds_oauth_client is None 921 1005 #[tokio::test] 922 1006 async fn test_request_claim_verification_unauthorized_no_oauth_client() { 923 1007 let claim_state = ClaimState { ··· 938 1022 assert!( 939 1023 matches!(result, Err(ClaimError::Unauthorized)), 940 1024 "should return Unauthorized when pds_oauth_client is None" 1025 + ); 1026 + } 1027 + 1028 + /// Test 4: Network error — PDS returns 500 1029 + /// Verifies AC4.2: request_claim_verification returns NetworkError on PDS failure 1030 + #[tokio::test] 1031 + async fn test_request_claim_verification_pds_returns_500() { 1032 + use httpmock::MockServer; 1033 + use std::sync::{Arc, Mutex}; 1034 + 1035 + let mock_server = MockServer::start(); 1036 + 1037 + mock_server.mock(|when, then| { 1038 + when.method(httpmock::Method::POST) 1039 + .path("/xrpc/com.atproto.identity.requestPlcOperationSignature"); 1040 + then.status(500).json_body(serde_json::json!({ 1041 + "error": "Internal Server Error" 1042 + })); 1043 + }); 1044 + 1045 + // Create a test session and OAuthClient 1046 + let session = Arc::new(Mutex::new(crate::oauth::OAuthSession { 1047 + access_token: "test_access_token".to_string(), 1048 + refresh_token: "test_refresh_token".to_string(), 1049 + expires_at: std::time::SystemTime::now() 1050 + .duration_since(std::time::UNIX_EPOCH) 1051 + .unwrap() 1052 + .as_secs() 1053 + + 3600, 1054 + dpop_nonce: None, 1055 + })); 1056 + 1057 + let keypair = crate::oauth::DPoPKeypair::get_or_create().expect("keypair must exist"); 1058 + let oauth_client = crate::oauth_client::OAuthClient::new_for_test( 1059 + keypair, 1060 + session, 1061 + mock_server.base_url(), 1062 + ); 1063 + 1064 + let claim_state = ClaimState { 1065 + did: "did:plc:test".to_string(), 1066 + pds_url: mock_server.base_url(), 1067 + did_doc: PlcDidDocument { 1068 + did: "did:plc:test".to_string(), 1069 + also_known_as: vec!["at://test.example.com".to_string()], 1070 + rotation_keys: vec!["did:key:zQ3test".to_string()], 1071 + verification_methods: serde_json::json!({}), 1072 + services: std::collections::HashMap::new(), 1073 + }, 1074 + pds_oauth_client: Some(std::sync::Arc::new(oauth_client)), 1075 + verified_signed_op: None, 1076 + }; 1077 + 1078 + let result = request_claim_verification_impl(&claim_state).await; 1079 + assert!( 1080 + matches!(result, Err(ClaimError::NetworkError { .. })), 1081 + "should return NetworkError when PDS returns 500" 941 1082 ); 942 1083 } 943 1084 }