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/pds_client): address code review feedback

- Remove all AC/TASK ticket references from source code (replace with system-level descriptions)
- Fix DNS error silencing in resolve_handle: preserve DNS transport errors if both DNS and HTTP fail
- Fix XRPC functions including error body text for better debugging
- Fix discover_auth_server error classification: use InvalidResponse for HTTP errors (not PdsUnreachable)
- Fix try_resolve_http to distinguish 4xx (not found) from 5xx (server error): return NetworkError for 5xx
- Add test for sign_plc_operation error path
- Fix discover_pds to reuse client timeout instead of creating new Client
- Extract CLIENT_ID and REDIRECT_URI as module-level constants
- Configure 30-second default timeout on PdsClient's reqwest::Client
- Add serialization tests for NetworkError, InvalidResponse, and OauthFailed error variants
- Rename OAuthFailed variant to OauthFailed for correct screaming_snake_case serialization
- Update CLAUDE.md: change urlencoding from 'local dep' to 'workspace dep'
- All 37 pds_client tests pass

authored by

Malpercio and committed by
Tangled
da743b60 ef235692

+192 -96
+1 -1
apps/identity-wallet/CLAUDE.md
··· 105 105 - Rust backend -> plc.directory (via `reqwest` HTTP at runtime; used by `PdsClient::discover_pds` to fetch DID documents) 106 106 - Rust backend -> arbitrary PDS endpoints (via `reqwest` HTTP at runtime; used by `PdsClient` for OAuth discovery, PAR, token exchange, and XRPC identity methods) 107 107 - Rust backend -> `hickory-resolver` (workspace dep: DNS TXT resolution for ATProto handle verification in `pds_client::try_resolve_dns`) 108 - - Rust backend -> `urlencoding` (local dep: URL-encoding for OAuth authorize URL construction in `PdsClient::build_pds_authorize_url`) 108 + - Rust backend -> `urlencoding` (workspace dep: URL-encoding for OAuth authorize URL construction in `PdsClient::build_pds_authorize_url`) 109 109 - Rust backend -> iOS Keychain (via `security-framework` crate with `OSX_10_12` feature for SE access control APIs) 110 110 - Rust backend -> Secure Enclave hardware (real iOS device only; via `security-framework` `SecKey`/`GenerateKeyOptions`/`Token::SecureEnclave`) 111 111 - `src-tauri/gen/` -> NOT tracked in git; generated per-developer by `cargo tauri ios init` (gitignored)
+191 -95
apps/identity-wallet/src-tauri/src/pds_client.rs
··· 10 10 use reqwest::Client; 11 11 use serde::{Deserialize, Serialize}; 12 12 13 + /// OAuth client ID for the identity wallet application 14 + const CLIENT_ID: &str = "dev.malpercio.identitywallet"; 15 + 16 + /// OAuth redirect URI for the identity wallet application 17 + const REDIRECT_URI: &str = "dev.malpercio.identitywallet:/oauth/callback"; 18 + 13 19 /// Error type for PDS client operations. 14 20 /// 15 21 /// Serializes to frontend with `#[serde(tag = "code", rename_all = "SCREAMING_SNAKE_CASE")]`, ··· 44 50 45 51 /// PAR or token exchange failed. 46 52 #[error("oauth failed: {message}")] 47 - OAuthFailed { message: String }, 53 + OauthFailed { message: String }, 48 54 } 49 55 50 56 /// PLC directory DID document response. ··· 146 152 /// Construct a new PdsClient with the default plc.directory URL. 147 153 pub fn new() -> Self { 148 154 Self { 149 - client: Client::new(), 155 + client: Client::builder() 156 + .timeout(Duration::from_secs(30)) 157 + .build() 158 + .unwrap_or_else(|_| Client::new()), 150 159 plc_directory_url: "https://plc.directory".to_string(), 151 160 } 152 161 } ··· 157 166 #[cfg(test)] 158 167 pub fn new_for_test(plc_directory_url: String) -> Self { 159 168 Self { 160 - client: Client::new(), 169 + client: Client::builder() 170 + .timeout(Duration::from_secs(30)) 171 + .build() 172 + .unwrap_or_else(|_| Client::new()), 161 173 plc_directory_url, 162 174 } 163 175 } 164 176 165 177 /// Resolve a handle to a DID via DNS TXT lookup with HTTP fallback. 166 178 /// 167 - /// Verifies: 168 - /// - AC3.1: DNS TXT lookup for `_atproto.{handle}` returns a DID 169 - /// - AC3.2: HTTP fallback to `/.well-known/atproto-did` works 170 - /// - AC3.3: Returns `HANDLE_NOT_FOUND` when neither method succeeds 179 + /// Attempts DNS TXT lookup for `_atproto.{handle}` first, then falls back to HTTP 180 + /// `/.well-known/atproto-did` if DNS fails or returns no records. 181 + /// Returns `HANDLE_NOT_FOUND` only when both methods fail. 171 182 pub async fn resolve_handle(&self, handle: &str) -> Result<String, PdsClientError> { 172 183 // Try DNS TXT lookup first 173 - match try_resolve_dns(handle).await { 184 + let dns_error = match try_resolve_dns(handle).await { 174 185 Ok(Some(did)) => return Ok(did), 175 - Ok(None) => {} // Fall through to HTTP 176 - Err(_e) => { 177 - // DNS transport error, but we'll try HTTP as fallback 178 - // Return this error only if HTTP also fails 179 - } 180 - } 186 + Ok(None) => None, 187 + Err(e) => Some(e), 188 + }; 181 189 182 190 // Try HTTP well-known lookup 183 191 let http_url = format!("https://{}/.well-known/atproto-did", handle); 184 192 match try_resolve_http(&self.client, &http_url).await { 185 193 Ok(Some(did)) => return Ok(did), 186 - Ok(None) => {} // Both failed 194 + Ok(None) => { 195 + // Both DNS and HTTP failed; if DNS had a transport error, surface it 196 + if let Some(dns_err) = dns_error { 197 + return Err(dns_err); 198 + } 199 + } 187 200 Err(e) => return Err(e), 188 201 } 189 202 190 - // Neither DNS nor HTTP succeeded 203 + // Neither DNS nor HTTP succeeded (both returned "not found", no transport errors) 191 204 Err(PdsClientError::HandleNotFound) 192 205 } 193 206 194 207 /// Fetch the DID document from plc.directory and extract the PDS endpoint. 195 208 /// 196 - /// Verifies: 197 - /// - AC3.4: PDS endpoint extracted from DID document 198 - /// - AC3.7: Returns `DID_NOT_FOUND` on 404 199 - /// - AC3.8: Returns `PDS_UNREACHABLE` when PDS endpoint is down 209 + /// Fetches the DID document from plc.directory, extracts the atproto_pds service 210 + /// endpoint, and verifies it is reachable via a HEAD request. 211 + /// Returns `DID_NOT_FOUND` on 404, `PDS_UNREACHABLE` if the endpoint is down. 200 212 pub async fn discover_pds( 201 213 &self, 202 214 did: &str, ··· 243 255 let pds_endpoint = &pds_service.endpoint; 244 256 245 257 // Verify PDS reachability with a HEAD request (5-second timeout) 246 - let timeout_client = Client::builder() 247 - .timeout(Duration::from_secs(5)) 248 - .build() 249 - .map_err(|e| PdsClientError::PdsUnreachable { 250 - reason: format!("failed to create HTTP client: {}", e), 251 - })?; 252 - 253 - timeout_client 258 + self.client 254 259 .head(pds_endpoint) 260 + .timeout(Duration::from_secs(5)) 255 261 .send() 256 262 .await 257 263 .map_err(|e| PdsClientError::PdsUnreachable { ··· 263 269 264 270 /// Fetch OAuth authorization server metadata from the PDS. 265 271 /// 266 - /// Verifies: 267 - /// - AC3.5: Auth server metadata is fetched and parsed correctly 268 - /// - Validates that `response_types_supported` includes "code" 269 - /// - Validates that `code_challenge_methods_supported` includes "S256" 272 + /// Fetches `/.well-known/oauth-authorization-server` and validates that 273 + /// `response_types_supported` includes "code" and `code_challenge_methods_supported` 274 + /// includes "S256". 270 275 pub async fn discover_auth_server( 271 276 &self, 272 277 pds_url: &str, ··· 278 283 .get(&url) 279 284 .send() 280 285 .await 281 - .map_err(|e| PdsClientError::PdsUnreachable { 282 - reason: format!("failed to fetch OAuth metadata: {}", e), 286 + .map_err(|e| PdsClientError::NetworkError { 287 + message: format!("failed to fetch OAuth metadata: {}", e), 283 288 })?; 284 289 285 290 if !response.status().is_success() { 286 - return Err(PdsClientError::PdsUnreachable { 287 - reason: format!( 291 + return Err(PdsClientError::InvalidResponse { 292 + message: format!( 288 293 "OAuth metadata fetch returned {} from {}", 289 294 response.status(), 290 295 pds_url ··· 325 330 326 331 /// Perform a Pushed Authorization Request to an arbitrary PDS. 327 332 /// 328 - /// Verifies: AC3.6 (PAR sends correct request with PKCE, DPoP, and optional login_hint) 333 + /// Sends a PAR request with PKCE challenge, DPoP proof, and optional login_hint. 329 334 pub async fn pds_par( 330 335 &self, 331 336 metadata: &AuthServerMetadata, ··· 345 350 ("code_challenge_method", "S256".to_string()), 346 351 ("code_challenge", pkce_challenge.to_string()), 347 352 ("state", state_param.to_string()), 348 - ("client_id", "dev.malpercio.identitywallet".to_string()), 349 - ( 350 - "redirect_uri", 351 - "dev.malpercio.identitywallet:/oauth/callback".to_string(), 352 - ), 353 + ("client_id", CLIENT_ID.to_string()), 354 + ("redirect_uri", REDIRECT_URI.to_string()), 353 355 ("scope", "atproto transition:generic".to_string()), 354 356 ("dpop_jkt", dpop_jkt.to_string()), 355 357 ]; ··· 365 367 .form(&form_data) 366 368 .send() 367 369 .await 368 - .map_err(|e| PdsClientError::OAuthFailed { 370 + .map_err(|e| PdsClientError::OauthFailed { 369 371 message: format!("PAR request failed: {}", e), 370 372 })?; 371 373 372 374 let status = response.status(); 373 375 if !status.is_success() { 374 376 let error_body = response.text().await.unwrap_or_default(); 375 - return Err(PdsClientError::OAuthFailed { 377 + return Err(PdsClientError::OauthFailed { 376 378 message: format!("PAR returned {}: {}", status, error_body), 377 379 }); 378 380 } ··· 381 383 response 382 384 .json::<PdsParResponse>() 383 385 .await 384 - .map_err(|e| PdsClientError::OAuthFailed { 386 + .map_err(|e| PdsClientError::OauthFailed { 385 387 message: format!("failed to parse PAR response: {}", e), 386 388 })?; 387 389 ··· 389 391 } 390 392 391 393 /// Exchange authorization code for tokens at an arbitrary PDS. 392 - /// 393 - /// Verifies: AC3.6 (token exchange sends correct request with code, verifier, and DPoP) 394 394 /// 395 395 /// Returns the raw response so the caller can handle nonce retry logic. 396 396 /// Only transport-level failures are mapped to PdsClientError; HTTP error statuses ··· 407 407 let form_data = vec![ 408 408 ("grant_type", "authorization_code"), 409 409 ("code", code), 410 - ( 411 - "redirect_uri", 412 - "dev.malpercio.identitywallet:/oauth/callback", 413 - ), 410 + ("redirect_uri", REDIRECT_URI), 414 411 ("code_verifier", pkce_verifier), 415 - ("client_id", "dev.malpercio.identitywallet"), 412 + ("client_id", CLIENT_ID), 416 413 ]; 417 414 418 415 self.client ··· 421 418 .form(&form_data) 422 419 .send() 423 420 .await 424 - .map_err(|e| PdsClientError::OAuthFailed { 421 + .map_err(|e| PdsClientError::OauthFailed { 425 422 message: format!("token exchange request failed: {}", e), 426 423 }) 427 424 } ··· 435 432 login_hint: Option<&str>, 436 433 ) -> String { 437 434 let mut url = format!( 438 - "{}?client_id=dev.malpercio.identitywallet&request_uri={}", 435 + "{}?client_id={}&request_uri={}", 439 436 metadata.authorization_endpoint, 437 + CLIENT_ID, 440 438 urlencoding::encode(request_uri) 441 439 ); 442 440 ··· 505 503 } 506 504 507 505 /// HTTP well-known fetch. `GET {url}` and return trimmed body on 2xx, 508 - /// `Ok(None)` on non-2xx. The caller constructs the full URL. 506 + /// `Ok(None)` on 4xx (handle not found), `Err(NetworkError)` on transport or 5xx. 507 + /// The caller constructs the full URL. 509 508 async fn try_resolve_http( 510 509 client: &reqwest::Client, 511 510 url: &str, ··· 519 518 message: format!("failed to read response body: {}", e), 520 519 }), 521 520 } 522 - } else { 523 - // Non-2xx status, return None to allow fallback 521 + } else if response.status().is_client_error() { 522 + // 4xx = handle not found at this endpoint 524 523 Ok(None) 524 + } else { 525 + // 5xx = temporary server error 526 + Err(PdsClientError::NetworkError { 527 + message: format!("server error from {}: {}", url, response.status()), 528 + }) 525 529 } 526 530 } 527 531 Err(e) => { ··· 556 560 if resp.status().is_success() { 557 561 Ok(()) 558 562 } else { 563 + let status = resp.status(); 564 + let body = resp.text().await.unwrap_or_default(); 559 565 Err(PdsClientError::NetworkError { 560 - message: format!("request_plc_operation_signature returned {}", resp.status()), 566 + message: format!("request_plc_operation_signature returned {}: {}", status, body), 561 567 }) 562 568 } 563 569 } ··· 575 581 })?; 576 582 577 583 if !resp.status().is_success() { 584 + let status = resp.status(); 585 + let body = resp.text().await.unwrap_or_default(); 578 586 return Err(PdsClientError::NetworkError { 579 - message: format!("sign_plc_operation returned {}", resp.status()), 587 + message: format!("sign_plc_operation returned {}: {}", status, body), 580 588 }); 581 589 } 582 590 ··· 599 607 })?; 600 608 601 609 if !resp.status().is_success() { 610 + let status = resp.status(); 611 + let body = resp.text().await.unwrap_or_default(); 602 612 return Err(PdsClientError::NetworkError { 603 - message: format!("get_recommended_did_credentials returned {}", resp.status()), 613 + message: format!("get_recommended_did_credentials returned {}: {}", status, body), 604 614 }); 605 615 } 606 616 ··· 643 653 } 644 654 645 655 // ============================================================================ 646 - // TASK 4 & 5: discover_pds and discover_auth_server tests 656 + // discover_pds and discover_auth_server tests 647 657 // ============================================================================ 648 658 649 - /// AC3.4: PDS endpoint extracted from DID doc 659 + /// PDS endpoint is extracted from DID document 650 660 #[tokio::test] 651 661 async fn test_discover_pds_extracts_endpoint() { 652 662 let mock_server = MockServer::start(); ··· 690 700 assert_eq!(doc.rotation_keys.len(), 2); 691 701 } 692 702 693 - /// AC3.7: DID_NOT_FOUND when plc.directory returns 404 703 + /// DID_NOT_FOUND error when plc.directory returns 404 694 704 #[tokio::test] 695 705 async fn test_discover_pds_did_not_found() { 696 706 let mock_server = MockServer::start(); ··· 712 722 } 713 723 } 714 724 715 - /// AC3.8: PDS_UNREACHABLE when PDS endpoint is down 725 + /// PDS_UNREACHABLE error when PDS endpoint is down 716 726 #[tokio::test] 717 727 async fn test_discover_pds_pds_unreachable() { 718 728 let mock_server = MockServer::start(); ··· 749 759 } 750 760 } 751 761 752 - /// AC3.8: InvalidResponse when atproto_pds service is missing 762 + /// InvalidResponse error when atproto_pds service is missing 753 763 #[tokio::test] 754 764 async fn test_discover_pds_missing_service() { 755 765 let mock_server = MockServer::start(); ··· 781 791 } 782 792 } 783 793 784 - /// AC3.5: Auth server metadata is fetched and validated 794 + /// Auth server metadata is fetched and validated 785 795 #[tokio::test] 786 796 async fn test_discover_auth_server_success() { 787 797 let mock_server = MockServer::start(); ··· 902 912 } 903 913 } 904 914 905 - /// discover_auth_server returns PdsUnreachable on HTTP error 915 + /// discover_auth_server returns InvalidResponse on HTTP error 906 916 #[tokio::test] 907 917 async fn test_discover_auth_server_pds_unreachable() { 908 918 let mock_server = MockServer::start(); ··· 918 928 919 929 assert!(result.is_err()); 920 930 match result.unwrap_err() { 921 - PdsClientError::PdsUnreachable { .. } => { 922 - // Expected 931 + PdsClientError::InvalidResponse { .. } => { 932 + // Expected: HTTP errors are InvalidResponse, not PdsUnreachable 923 933 } 924 - e => panic!("Expected PdsUnreachable, got: {:?}", e), 934 + e => panic!("Expected InvalidResponse, got: {:?}", e), 925 935 } 926 936 } 927 937 928 938 // ============================================================================ 929 - // TASK 2 & 3: resolve_handle tests 939 + // resolve_handle tests 930 940 // ============================================================================ 931 941 932 - /// AC3.3: HANDLE_NOT_FOUND is returned correctly (error type test) 942 + /// HANDLE_NOT_FOUND error is returned correctly 933 943 #[test] 934 944 fn test_pds_client_error_handle_not_found() { 935 945 let error = PdsClientError::HandleNotFound; 936 946 assert_eq!(format!("{}", error), "handle not found"); 937 947 } 938 948 939 - /// AC3.1: DNS TXT resolution (integration test, ignored for CI) 949 + /// DNS TXT resolution (integration test, ignored for CI) 940 950 /// 941 951 /// This requires real DNS access and tests against a known public handle. 942 952 /// Run manually with `cargo test -- --ignored --nocapture` if DNS is available. ··· 960 970 } 961 971 962 972 // ============================================================================ 963 - // TASK 2 & 3: resolve_handle tests with httpmock 973 + // HTTP fallback resolution tests 964 974 // ============================================================================ 965 975 966 - /// AC3.2: HTTP fallback resolves handle to DID 976 + /// HTTP fallback resolves handle to DID 967 977 #[tokio::test] 968 978 async fn test_try_resolve_http_success() { 969 979 let mock_server = MockServer::start(); ··· 983 993 assert_eq!(result.unwrap(), Some("did:plc:test123".to_string())); 984 994 } 985 995 986 - /// AC3.2: HTTP fallback with whitespace trimming 996 + /// HTTP fallback handles response body with whitespace 987 997 #[tokio::test] 988 998 async fn test_try_resolve_http_with_whitespace() { 989 999 let mock_server = MockServer::start(); ··· 1003 1013 assert_eq!(result.unwrap(), Some("did:plc:test123".to_string())); 1004 1014 } 1005 1015 1006 - /// AC3.3: HTTP fallback returns Ok(None) on 404 1016 + /// HTTP fallback returns Ok(None) on 404 client error 1007 1017 #[tokio::test] 1008 1018 async fn test_try_resolve_http_not_found() { 1009 1019 let mock_server = MockServer::start(); ··· 1023 1033 assert_eq!(result.unwrap(), None); 1024 1034 } 1025 1035 1026 - /// AC3.3: HTTP fallback returns Ok(None) on 500 1036 + /// HTTP fallback returns NetworkError on 500 server error 1027 1037 #[tokio::test] 1028 1038 async fn test_try_resolve_http_server_error() { 1029 1039 let mock_server = MockServer::start(); ··· 1039 1049 let url = format!("{}/.well-known/atproto-did", mock_server.base_url()); 1040 1050 let result = try_resolve_http(&client, &url).await; 1041 1051 1042 - assert!(result.is_ok()); 1043 - assert_eq!(result.unwrap(), None); 1052 + assert!(result.is_err()); 1053 + match result.unwrap_err() { 1054 + PdsClientError::NetworkError { .. } => { 1055 + // Expected: 5xx is a server error, not a missing handle 1056 + } 1057 + e => panic!("Expected NetworkError on 5xx, got: {:?}", e), 1058 + } 1044 1059 } 1045 1060 1046 1061 // ============================================================================ 1047 - // TASK 6 & 7: PAR and token exchange tests 1062 + // PAR and token exchange tests 1048 1063 // ============================================================================ 1049 1064 1050 - /// AC3.6: PAR sends correct request with PKCE, DPoP, and optional login_hint 1065 + /// PAR sends correct request with PKCE, DPoP, and optional login_hint 1051 1066 #[tokio::test] 1052 1067 async fn test_pds_par_sends_correct_request() { 1053 1068 let mock_server = MockServer::start(); ··· 1099 1114 assert_eq!(mock_par.hits(), 1); 1100 1115 } 1101 1116 1102 - /// AC3.6: PAR without login_hint 1117 + /// PAR without login_hint 1103 1118 #[tokio::test] 1104 1119 async fn test_pds_par_without_login_hint() { 1105 1120 let mock_server = MockServer::start(); ··· 1135 1150 assert!(result.is_ok()); 1136 1151 } 1137 1152 1138 - /// AC3.6: PAR failure returns OAuthFailed 1153 + /// PAR failure returns OauthFailed 1139 1154 #[tokio::test] 1140 1155 async fn test_pds_par_failure() { 1141 1156 let mock_server = MockServer::start(); ··· 1170 1185 1171 1186 assert!(result.is_err()); 1172 1187 match result.unwrap_err() { 1173 - PdsClientError::OAuthFailed { .. } => { 1188 + PdsClientError::OauthFailed { .. } => { 1174 1189 // Expected 1175 1190 } 1176 - e => panic!("Expected OAuthFailed, got: {:?}", e), 1191 + e => panic!("Expected OauthFailed, got: {:?}", e), 1177 1192 } 1178 1193 } 1179 1194 1180 - /// AC3.6: Token exchange sends correct request 1195 + /// Token exchange sends correct request 1181 1196 #[tokio::test] 1182 1197 async fn test_pds_token_exchange_sends_correct_request() { 1183 1198 let mock_server = MockServer::start(); ··· 1217 1232 assert_eq!(response.status().as_u16(), 200); 1218 1233 } 1219 1234 1220 - /// AC3.6: Token exchange returns raw response on non-2xx 1235 + /// Token exchange returns raw response on non-2xx 1221 1236 #[tokio::test] 1222 1237 async fn test_pds_token_exchange_returns_raw_response_on_error() { 1223 1238 let mock_server = MockServer::start(); ··· 1253 1268 assert_eq!(response.status().as_u16(), 400); 1254 1269 } 1255 1270 1256 - /// AC3.6: Token exchange to unreachable endpoint returns OAuthFailed 1271 + /// Token exchange to unreachable endpoint returns OauthFailed 1257 1272 #[tokio::test] 1258 1273 async fn test_pds_token_exchange_unreachable_endpoint() { 1259 1274 let metadata = AuthServerMetadata { ··· 1275 1290 1276 1291 assert!(result.is_err()); 1277 1292 match result.unwrap_err() { 1278 - PdsClientError::OAuthFailed { .. } => { 1293 + PdsClientError::OauthFailed { .. } => { 1279 1294 // Expected 1280 1295 } 1281 - e => panic!("Expected OAuthFailed, got: {:?}", e), 1296 + e => panic!("Expected OauthFailed, got: {:?}", e), 1282 1297 } 1283 1298 } 1284 1299 ··· 1564 1579 assert!(creds.also_known_as.is_some()); 1565 1580 } 1566 1581 1567 - /// AC3.3 resolve_handle orchestration: handle fails both DNS and HTTP 1582 + /// resolve_handle returns HandleNotFound when both DNS and HTTP fail 1568 1583 /// This test uses a nonexistent .test TLD which DNS will reject, then attempts HTTP 1569 1584 /// which will fail due to inability to connect. Both failures result in HandleNotFound. 1570 1585 #[tokio::test] ··· 1589 1604 } 1590 1605 } 1591 1606 1592 - /// AC3.3 error serialization: HandleNotFound serializes with code "HANDLE_NOT_FOUND" 1607 + /// HandleNotFound error serializes with code "HANDLE_NOT_FOUND" 1593 1608 #[test] 1594 1609 fn test_pds_client_error_handle_not_found_serialization() { 1595 1610 let error = PdsClientError::HandleNotFound; ··· 1597 1612 assert!(json.contains("\"code\":\"HANDLE_NOT_FOUND\"")); 1598 1613 } 1599 1614 1600 - /// AC3.7 error serialization: DidNotFound serializes with code "DID_NOT_FOUND" 1615 + /// DidNotFound error serializes with code "DID_NOT_FOUND" 1601 1616 #[test] 1602 1617 fn test_pds_client_error_did_not_found_serialization() { 1603 1618 let error = PdsClientError::DidNotFound; ··· 1605 1620 assert!(json.contains("\"code\":\"DID_NOT_FOUND\"")); 1606 1621 } 1607 1622 1608 - /// AC3.8 error serialization: PdsUnreachable serializes with code "PDS_UNREACHABLE" 1623 + /// PdsUnreachable error serializes with code "PDS_UNREACHABLE" 1609 1624 /// and does NOT include "reason" (because it's #[serde(skip)]) 1610 1625 #[test] 1611 1626 fn test_pds_client_error_pds_unreachable_serialization() { ··· 1653 1668 ); 1654 1669 1655 1670 let result = get_recommended_did_credentials(&oauth_client).await; 1671 + assert!(result.is_err()); 1672 + match result.unwrap_err() { 1673 + PdsClientError::NetworkError { .. } => { 1674 + // Expected 1675 + } 1676 + e => panic!("Expected NetworkError, got: {:?}", e), 1677 + } 1678 + } 1679 + 1680 + /// NetworkError error serializes with code "NETWORK_ERROR" 1681 + #[test] 1682 + fn test_pds_client_error_network_error_serialization() { 1683 + let error = PdsClientError::NetworkError { 1684 + message: "connection refused".to_string(), 1685 + }; 1686 + let json = serde_json::to_string(&error).expect("serialization failed"); 1687 + assert!(json.contains("\"code\":\"NETWORK_ERROR\"")); 1688 + } 1689 + 1690 + /// InvalidResponse error serializes with code "INVALID_RESPONSE" 1691 + #[test] 1692 + fn test_pds_client_error_invalid_response_serialization() { 1693 + let error = PdsClientError::InvalidResponse { 1694 + message: "missing required field".to_string(), 1695 + }; 1696 + let json = serde_json::to_string(&error).expect("serialization failed"); 1697 + assert!(json.contains("\"code\":\"INVALID_RESPONSE\"")); 1698 + } 1699 + 1700 + /// OauthFailed error serializes with code "OAUTH_FAILED" 1701 + #[test] 1702 + fn test_pds_client_error_oauth_failed_serialization() { 1703 + let error = PdsClientError::OauthFailed { 1704 + message: "invalid_grant".to_string(), 1705 + }; 1706 + let json = serde_json::to_string(&error).expect("serialization failed"); 1707 + assert!(json.contains("\"code\":\"OAUTH_FAILED\"")); 1708 + } 1709 + 1710 + /// sign_plc_operation returns NetworkError on HTTP error 1711 + #[tokio::test] 1712 + async fn test_sign_plc_operation_error() { 1713 + use std::sync::{Arc, Mutex}; 1714 + 1715 + let mock_server = MockServer::start(); 1716 + 1717 + mock_server.mock(|when, then| { 1718 + when.method(httpmock::Method::POST) 1719 + .path("/xrpc/com.atproto.identity.signPlcOperation"); 1720 + then.status(400).json_body(serde_json::json!({ 1721 + "error": "invalid_token" 1722 + })); 1723 + }); 1724 + 1725 + let session = Arc::new(Mutex::new(crate::oauth::OAuthSession { 1726 + access_token: "test_access_token".to_string(), 1727 + refresh_token: "test_refresh_token".to_string(), 1728 + expires_at: std::time::SystemTime::now() 1729 + .duration_since(std::time::UNIX_EPOCH) 1730 + .unwrap() 1731 + .as_secs() 1732 + + 3600, 1733 + dpop_nonce: None, 1734 + })); 1735 + 1736 + let keypair = crate::oauth::DPoPKeypair::get_or_create().expect("keypair must exist"); 1737 + let oauth_client = crate::oauth_client::OAuthClient::new_for_test( 1738 + keypair, 1739 + session, 1740 + mock_server.base_url(), 1741 + ); 1742 + 1743 + let request = SignPlcOperationRequest { 1744 + token: "test_email_token".to_string(), 1745 + rotation_keys: None, 1746 + also_known_as: None, 1747 + verification_methods: None, 1748 + services: None, 1749 + }; 1750 + 1751 + let result = sign_plc_operation(&oauth_client, &request).await; 1656 1752 assert!(result.is_err()); 1657 1753 match result.unwrap_err() { 1658 1754 PdsClientError::NetworkError { .. } => {