bitwarden_auth/send_access/
client.rs

1use bitwarden_core::Client;
2#[cfg(feature = "wasm")]
3use wasm_bindgen::prelude::*;
4
5use crate::send_access::{
6    SendAccessTokenError, SendAccessTokenRequest, SendAccessTokenResponse,
7    access_token_response::UnexpectedIdentityError,
8    api::{
9        SendAccessTokenApiErrorResponse, SendAccessTokenApiSuccessResponse,
10        SendAccessTokenRequestPayload,
11    },
12};
13
14/// The `SendAccessClient` is used to interact with the Bitwarden API to get send access tokens.
15#[derive(Clone)]
16#[cfg_attr(feature = "wasm", wasm_bindgen)]
17pub struct SendAccessClient {
18    pub(crate) client: Client,
19}
20
21impl SendAccessClient {
22    pub(crate) fn new(client: Client) -> Self {
23        Self { client }
24    }
25}
26
27#[cfg_attr(feature = "wasm", wasm_bindgen)]
28impl SendAccessClient {
29    /// Requests a new send access token.
30    pub async fn request_send_access_token(
31        &self,
32        request: SendAccessTokenRequest,
33    ) -> Result<SendAccessTokenResponse, SendAccessTokenError> {
34        // Convert the request to the appropriate format for sending.
35        let payload: SendAccessTokenRequestPayload = request.into();
36
37        // When building other identity token requests, we used to send credentials: "include" on
38        // non-web clients or if the env had a base URL. See client's
39        // apiService.getCredentials() for example. However, it doesn't seem necessary for
40        // this request, so we are not including it here. If needed, we can revisit this and
41        // add it back in.
42
43        let configurations = self.client.internal.get_api_configurations().await;
44
45        // save off url in variable for re-use
46        let url = format!(
47            "{}/connect/token",
48            &configurations.identity_config.base_path
49        );
50
51        let request: reqwest::RequestBuilder = configurations
52            .identity_config
53            .client
54            .post(&url)
55            .header(reqwest::header::ACCEPT, "application/json")
56            .header(reqwest::header::CACHE_CONTROL, "no-store")
57            .form(&payload);
58
59        // Because of the ? operator, any errors from sending the request are automatically
60        // wrapped in SendAccessTokenError::Unexpected as an UnexpectedIdentityError::Reqwest
61        // variant and returned.
62        // note: we had to manually built a trait to map reqwest::Error to SendAccessTokenError.
63        let response: reqwest::Response = request.send().await?;
64
65        let response_status = response.status();
66
67        // handle success and error responses
68        // If the response is 2xx, we can deserialize it into SendAccessToken
69        if response_status.is_success() {
70            let send_access_token: SendAccessTokenApiSuccessResponse = response.json().await?;
71            return Ok(send_access_token.into());
72        }
73
74        let err_response = match response.json::<SendAccessTokenApiErrorResponse>().await {
75            // If the response is a 400 with a specific error type, we can deserialize it into
76            // SendAccessTokenApiErrorResponse and then convert it into
77            // SendAccessTokenError::Expected later on.
78            Ok(err) => err,
79            Err(_) => {
80                // This handles any 4xx that aren't specifically handled above
81                // as well as any other non-2xx responses (5xx, etc).
82
83                let error_string = format!(
84                    "Received response status {} against {}",
85                    response_status, url
86                );
87
88                return Err(SendAccessTokenError::Unexpected(UnexpectedIdentityError(
89                    error_string,
90                )));
91            }
92        };
93
94        Err(SendAccessTokenError::Expected(err_response))
95    }
96}
97
98#[cfg(test)]
99mod tests {
100    use bitwarden_core::{Client as CoreClient, ClientSettings, DeviceType};
101    use bitwarden_test::start_api_mock;
102    use wiremock::{
103        Mock, MockServer, ResponseTemplate,
104        matchers::{self, body_string_contains},
105    };
106
107    use crate::{
108        AuthClientExt,
109        api::enums::{GrantType, Scope},
110        send_access::{
111            SendAccessClient, SendAccessCredentials, SendAccessTokenError, SendAccessTokenRequest,
112            SendAccessTokenResponse, SendEmailCredentials, SendEmailOtpCredentials,
113            SendPasswordCredentials, UnexpectedIdentityError,
114            api::{
115                SendAccessTokenApiErrorResponse, SendAccessTokenInvalidGrantError,
116                SendAccessTokenInvalidRequestError,
117            },
118        },
119    };
120
121    fn make_send_client(mock_server: &MockServer) -> SendAccessClient {
122        let settings = ClientSettings {
123            identity_url: format!("http://{}/identity", mock_server.address()),
124            api_url: format!("http://{}/api", mock_server.address()),
125            user_agent: "Bitwarden Rust-SDK [TEST]".into(),
126            device_type: DeviceType::SDK,
127            bitwarden_client_version: None,
128        };
129        let core_client = CoreClient::new(Some(settings));
130        core_client.auth_new().send_access()
131    }
132
133    mod request_send_access_token_success_tests {
134
135        use super::*;
136
137        #[tokio::test]
138        async fn request_send_access_token_anon_send_success() {
139            let scope_value = serde_json::to_value(Scope::ApiSendAccess).unwrap();
140            let scope_str = scope_value.as_str().unwrap();
141
142            let grant_type_value = serde_json::to_value(GrantType::SendAccess).unwrap();
143            let grant_type_str = grant_type_value.as_str().unwrap();
144
145            // Create a mock success response
146            let raw_success = serde_json::json!({
147                "access_token": "token",
148                "token_type": "bearer",
149                "expires_in":   3600,
150                "scope": scope_str
151            });
152
153            // Construct the real Request type
154            let req = SendAccessTokenRequest {
155                send_id: "test_send_id".into(),
156                send_access_credentials: None, // No credentials for this test
157            };
158
159            let mock = Mock::given(matchers::method("POST"))
160                .and(matchers::path("identity/connect/token"))
161                // expect the headers we set in the client
162                .and(matchers::header(
163                    reqwest::header::CONTENT_TYPE.as_str(),
164                    "application/x-www-form-urlencoded",
165                ))
166                .and(matchers::header(
167                    reqwest::header::ACCEPT.as_str(),
168                    "application/json",
169                ))
170                .and(matchers::header(
171                    reqwest::header::CACHE_CONTROL.as_str(),
172                    "no-store",
173                ))
174                // expect the body to contain the fields we set in our payload object
175                .and(body_string_contains("client_id=send"))
176                .and(body_string_contains(format!(
177                    "grant_type={}",
178                    grant_type_str
179                )))
180                .and(body_string_contains(format!("scope={}", scope_str)))
181                .and(body_string_contains(format!("send_id={}", req.send_id)))
182                // respond with the mock success response
183                .respond_with(ResponseTemplate::new(200).set_body_json(raw_success));
184
185            // Spin up a server and register mock with it
186            let (mock_server, _api_config) = start_api_mock(vec![mock]).await;
187
188            // Create a send access client
189            let send_access_client = make_send_client(&mock_server);
190
191            let token: SendAccessTokenResponse = send_access_client
192                .request_send_access_token(req)
193                .await
194                .unwrap();
195
196            assert_eq!(token.token, "token");
197            assert!(token.expires_at > 0);
198        }
199
200        #[tokio::test]
201        async fn request_send_access_token_password_protected_send_success() {
202            let scope_value = serde_json::to_value(Scope::ApiSendAccess).unwrap();
203            let scope_str = scope_value.as_str().unwrap();
204
205            let grant_type_value = serde_json::to_value(GrantType::SendAccess).unwrap();
206            let grant_type_str = grant_type_value.as_str().unwrap();
207
208            // Create a mock success response
209            let raw_success = serde_json::json!({
210                "access_token": "token",
211                "token_type": "bearer",
212                "expires_in":   3600,
213                "scope": scope_str
214            });
215
216            let password_hash_b64 = "valid-hash";
217
218            let password_credentials = SendPasswordCredentials {
219                password_hash_b64: password_hash_b64.into(),
220            };
221
222            let req = SendAccessTokenRequest {
223                send_id: "valid-send-id".into(),
224                send_access_credentials: Some(SendAccessCredentials::Password(
225                    password_credentials,
226                )),
227            };
228
229            let mock = Mock::given(matchers::method("POST"))
230                .and(matchers::path("identity/connect/token"))
231                // expect the headers we set in the client
232                .and(matchers::header(
233                    reqwest::header::CONTENT_TYPE.as_str(),
234                    "application/x-www-form-urlencoded",
235                ))
236                .and(matchers::header(
237                    reqwest::header::ACCEPT.as_str(),
238                    "application/json",
239                ))
240                .and(matchers::header(
241                    reqwest::header::CACHE_CONTROL.as_str(),
242                    "no-store",
243                ))
244                // expect the body to contain the fields we set in our payload object
245                .and(body_string_contains("client_id=send"))
246                .and(body_string_contains(format!(
247                    "grant_type={}",
248                    grant_type_str
249                )))
250                .and(body_string_contains(format!("scope={}", scope_str)))
251                .and(body_string_contains(format!("send_id={}", req.send_id)))
252                .and(body_string_contains(format!(
253                    "password_hash_b64={}",
254                    password_hash_b64
255                )))
256                // respond with the mock success response
257                .respond_with(ResponseTemplate::new(200).set_body_json(raw_success));
258
259            // Spin up a server and register mock with it
260            let (mock_server, _api_config) = start_api_mock(vec![mock]).await;
261
262            // Create a send access client
263            let send_access_client = make_send_client(&mock_server);
264
265            let token: SendAccessTokenResponse = send_access_client
266                .request_send_access_token(req)
267                .await
268                .unwrap();
269
270            assert_eq!(token.token, "token");
271            assert!(token.expires_at > 0);
272        }
273
274        #[tokio::test]
275        async fn request_send_access_token_email_otp_protected_send_success() {
276            let scope_value = serde_json::to_value(Scope::ApiSendAccess).unwrap();
277            let scope_str = scope_value.as_str().unwrap();
278
279            let grant_type_value = serde_json::to_value(GrantType::SendAccess).unwrap();
280            let grant_type_str = grant_type_value.as_str().unwrap();
281
282            // Create a mock success response
283            let raw_success = serde_json::json!({
284                "access_token": "token",
285                "token_type": "bearer",
286                "expires_in":   3600,
287                "scope": scope_str
288            });
289
290            let email = "[email protected]";
291            let otp: &str = "valid_otp";
292
293            let email_otp_credentials = SendEmailOtpCredentials {
294                email: email.into(),
295                otp: otp.into(),
296            };
297
298            let req = SendAccessTokenRequest {
299                send_id: "valid-send-id".into(),
300                send_access_credentials: Some(SendAccessCredentials::EmailOtp(
301                    email_otp_credentials,
302                )),
303            };
304
305            let mock = Mock::given(matchers::method("POST"))
306                .and(matchers::path("identity/connect/token"))
307                // expect the headers we set in the client
308                .and(matchers::header(
309                    reqwest::header::CONTENT_TYPE.as_str(),
310                    "application/x-www-form-urlencoded",
311                ))
312                .and(matchers::header(
313                    reqwest::header::ACCEPT.as_str(),
314                    "application/json",
315                ))
316                .and(matchers::header(
317                    reqwest::header::CACHE_CONTROL.as_str(),
318                    "no-store",
319                ))
320                // expect the body to contain the fields we set in our payload object
321                .and(body_string_contains("client_id=send"))
322                .and(body_string_contains(format!(
323                    "grant_type={}",
324                    grant_type_str
325                )))
326                .and(body_string_contains(format!("scope={}", scope_str)))
327                .and(body_string_contains(format!("send_id={}", req.send_id)))
328                .and(body_string_contains("email=valid%40email.com"))
329                .and(body_string_contains(format!("otp={}", otp)))
330                // respond with the mock success response
331                .respond_with(ResponseTemplate::new(200).set_body_json(raw_success));
332
333            // Spin up a server and register mock with it
334            let (mock_server, _api_config) = start_api_mock(vec![mock]).await;
335
336            // Create a send access client
337            let send_access_client = make_send_client(&mock_server);
338
339            let token: SendAccessTokenResponse = send_access_client
340                .request_send_access_token(req)
341                .await
342                .unwrap();
343
344            assert_eq!(token.token, "token");
345            assert!(token.expires_at > 0);
346        }
347    }
348
349    mod request_send_access_token_invalid_request_tests {
350        use super::*;
351
352        #[tokio::test]
353        async fn request_send_access_token_invalid_request_send_id_required_error() {
354            // Create a mock error response
355            let error_description = "send_id is required.".into();
356            let raw_error = serde_json::json!({
357                "error": "invalid_request",
358                "error_description": error_description,
359                "send_access_error_type": "send_id_required"
360            });
361
362            // Register the mock for the request
363            let mock = Mock::given(matchers::method("POST"))
364                .and(matchers::path("identity/connect/token"))
365                .respond_with(ResponseTemplate::new(400).set_body_json(raw_error));
366
367            // Spin up a server and register mock with it
368            let (mock_server, _api_config) = start_api_mock(vec![mock]).await;
369
370            // Create a send access client
371            let send_access_client = make_send_client(&mock_server);
372
373            // Construct the request without a send_id to trigger an error
374            let req = SendAccessTokenRequest {
375                send_id: "".into(),
376                send_access_credentials: None, // No credentials for this test
377            };
378
379            let result = send_access_client.request_send_access_token(req).await;
380
381            assert!(result.is_err());
382
383            let err = result.unwrap_err();
384            match err {
385                SendAccessTokenError::Expected(api_err) => {
386                    assert_eq!(
387                        api_err,
388                        SendAccessTokenApiErrorResponse::InvalidRequest {
389                            send_access_error_type: Some(
390                                SendAccessTokenInvalidRequestError::SendIdRequired
391                            ),
392                            error_description: Some(error_description),
393                        }
394                    );
395                }
396                other => panic!("expected Response variant, got {:?}", other),
397            }
398        }
399
400        #[tokio::test]
401        async fn request_send_access_token_invalid_request_password_hash_required_error() {
402            // Create a mock error response
403            let error_description = "password_hash_b64 is required.".into();
404            let raw_error = serde_json::json!({
405                "error": "invalid_request",
406                "error_description": error_description,
407                "send_access_error_type": "password_hash_b64_required"
408            });
409
410            // Register the mock for the request
411            let mock = Mock::given(matchers::method("POST"))
412                .and(matchers::path("identity/connect/token"))
413                .respond_with(ResponseTemplate::new(400).set_body_json(raw_error));
414
415            // Spin up a server and register mock with it
416            let (mock_server, _api_config) = start_api_mock(vec![mock]).await;
417
418            // Create a send access client
419            let send_access_client = make_send_client(&mock_server);
420
421            // Construct the request with a send_id but no credentials to trigger the error
422            let req = SendAccessTokenRequest {
423                send_id: "test_send_id".into(),
424                send_access_credentials: None, // No credentials for this test
425            };
426
427            let result = send_access_client.request_send_access_token(req).await;
428
429            assert!(result.is_err());
430
431            let err = result.unwrap_err();
432            match err {
433                SendAccessTokenError::Expected(api_err) => {
434                    assert_eq!(
435                        api_err,
436                        SendAccessTokenApiErrorResponse::InvalidRequest {
437                            send_access_error_type: Some(
438                                SendAccessTokenInvalidRequestError::PasswordHashB64Required
439                            ),
440                            error_description: Some(error_description),
441                        }
442                    );
443                }
444                other => panic!("expected Response variant, got {:?}", other),
445            }
446        }
447
448        #[tokio::test]
449        async fn request_send_access_token_invalid_request_email_required_error() {
450            // Create a mock error response
451            let error_description = "email is required.".into();
452            let raw_error = serde_json::json!({
453                "error": "invalid_request",
454                "error_description": error_description,
455                "send_access_error_type": "email_required"
456            });
457
458            // Register the mock for the request
459            let mock = Mock::given(matchers::method("POST"))
460                .and(matchers::path("identity/connect/token"))
461                .respond_with(ResponseTemplate::new(400).set_body_json(raw_error));
462
463            // Spin up a server and register mock with it
464            let (mock_server, _api_config) = start_api_mock(vec![mock]).await;
465
466            // Create a send access client
467            let send_access_client = make_send_client(&mock_server);
468
469            // Construct the request with a send_id but no credentials to trigger the error
470            let req = SendAccessTokenRequest {
471                send_id: "test_send_id".into(),
472                send_access_credentials: None, // No credentials for this test
473            };
474
475            let result = send_access_client.request_send_access_token(req).await;
476
477            assert!(result.is_err());
478
479            let err = result.unwrap_err();
480            match err {
481                SendAccessTokenError::Expected(api_err) => {
482                    assert_eq!(
483                        api_err,
484                        SendAccessTokenApiErrorResponse::InvalidRequest {
485                            send_access_error_type: Some(
486                                SendAccessTokenInvalidRequestError::EmailRequired
487                            ),
488                            error_description: Some(error_description),
489                        }
490                    );
491                }
492                other => panic!("expected Response variant, got {:?}", other),
493            }
494        }
495
496        #[tokio::test]
497        async fn request_send_access_token_invalid_request_email_otp_required_error() {
498            // Create a mock error response
499            let error_description =
500                "email and otp are required. An OTP has been sent to the email address provided."
501                    .into();
502            let raw_error = serde_json::json!({
503                "error": "invalid_request",
504                "error_description": error_description,
505                "send_access_error_type": "email_and_otp_required_otp_sent"
506            });
507
508            // Create the mock for the request
509            let mock = Mock::given(matchers::method("POST"))
510                .and(matchers::path("identity/connect/token"))
511                .respond_with(ResponseTemplate::new(400).set_body_json(raw_error));
512
513            // Spin up a server and register mock with it
514            let (mock_server, _api_config) = start_api_mock(vec![mock]).await;
515
516            // Create a send access client
517            let send_access_client = make_send_client(&mock_server);
518
519            // Construct the request with a send_id and email credential
520            let email_credentials = SendEmailCredentials {
521                email: "[email protected]".into(),
522            };
523
524            let req = SendAccessTokenRequest {
525                send_id: "test_send_id".into(),
526                send_access_credentials: Some(SendAccessCredentials::Email(email_credentials)),
527            };
528
529            let result = send_access_client.request_send_access_token(req).await;
530
531            assert!(result.is_err());
532
533            let err = result.unwrap_err();
534            match err {
535                SendAccessTokenError::Expected(api_err) => {
536                    assert_eq!(
537                        api_err,
538                        SendAccessTokenApiErrorResponse::InvalidRequest {
539                            send_access_error_type: Some(
540                                SendAccessTokenInvalidRequestError::EmailAndOtpRequiredOtpSent
541                            ),
542                            error_description: Some(error_description),
543                        }
544                    );
545                }
546                other => panic!("expected Response variant, got {:?}", other),
547            }
548        }
549    }
550
551    mod request_send_access_token_invalid_grant_tests {
552
553        use super::*;
554
555        #[tokio::test]
556        async fn request_send_access_token_invalid_grant_invalid_send_id_error() {
557            // Create a mock error response
558            let error_description = "send_id is invalid.".into();
559            let raw_error = serde_json::json!({
560                "error": "invalid_grant",
561                "error_description": error_description,
562                "send_access_error_type": "send_id_invalid"
563            });
564
565            // Create the mock for the request
566            let mock = Mock::given(matchers::method("POST"))
567                .and(matchers::path("identity/connect/token"))
568                .respond_with(ResponseTemplate::new(400).set_body_json(raw_error));
569
570            // Spin up a server and register mock with it
571            let (mock_server, _api_config) = start_api_mock(vec![mock]).await;
572
573            // Create a send access client
574            let send_access_client = make_send_client(&mock_server);
575
576            // Construct the request without a send_id to trigger an error
577            let req = SendAccessTokenRequest {
578                send_id: "invalid-send-id".into(),
579                send_access_credentials: None, // No credentials for this test
580            };
581
582            let result = send_access_client.request_send_access_token(req).await;
583
584            assert!(result.is_err());
585
586            let err = result.unwrap_err();
587            match err {
588                SendAccessTokenError::Expected(api_err) => {
589                    // Now assert the inner enum:
590                    assert_eq!(
591                        api_err,
592                        SendAccessTokenApiErrorResponse::InvalidGrant {
593                            send_access_error_type: Some(
594                                SendAccessTokenInvalidGrantError::SendIdInvalid
595                            ),
596                            error_description: Some(error_description),
597                        }
598                    );
599                }
600                other => panic!("expected Response variant, got {:?}", other),
601            }
602        }
603
604        #[tokio::test]
605        async fn request_send_access_token_invalid_grant_invalid_password_hash_error() {
606            // Create a mock error response
607            let error_description = "password_hash_b64 is invalid.".into();
608            let raw_error = serde_json::json!({
609                "error": "invalid_grant",
610                "error_description": error_description,
611                "send_access_error_type": "password_hash_b64_invalid"
612            });
613
614            // Create the mock for the request
615            let mock = Mock::given(matchers::method("POST"))
616                .and(matchers::path("identity/connect/token"))
617                .respond_with(ResponseTemplate::new(400).set_body_json(raw_error));
618
619            // Spin up a server and register mock with it
620            let (mock_server, _api_config) = start_api_mock(vec![mock]).await;
621
622            // Create a send access client
623            let send_access_client = make_send_client(&mock_server);
624
625            // Construct the request
626            let password_credentials = SendPasswordCredentials {
627                password_hash_b64: "invalid-hash".into(),
628            };
629
630            let req = SendAccessTokenRequest {
631                send_id: "valid-send-id".into(),
632                send_access_credentials: Some(SendAccessCredentials::Password(
633                    password_credentials,
634                )),
635            };
636
637            let result = send_access_client.request_send_access_token(req).await;
638
639            assert!(result.is_err());
640
641            let err = result.unwrap_err();
642            match err {
643                SendAccessTokenError::Expected(api_err) => {
644                    // Now assert the inner enum:
645                    assert_eq!(
646                        api_err,
647                        SendAccessTokenApiErrorResponse::InvalidGrant {
648                            send_access_error_type: Some(
649                                SendAccessTokenInvalidGrantError::PasswordHashB64Invalid
650                            ),
651                            error_description: Some(error_description),
652                        }
653                    );
654                }
655                other => panic!("expected Response variant, got {:?}", other),
656            }
657        }
658
659        #[tokio::test]
660        async fn request_send_access_token_invalid_grant_invalid_email_error() {
661            // Create a mock error response
662            let error_description = "email is invalid.".into();
663            let raw_error = serde_json::json!({
664                "error": "invalid_grant",
665                "error_description": error_description,
666                "send_access_error_type": "email_invalid"
667            });
668
669            // Register the mock for the request
670            let mock = Mock::given(matchers::method("POST"))
671                .and(matchers::path("identity/connect/token"))
672                .respond_with(ResponseTemplate::new(400).set_body_json(raw_error));
673
674            // Spin up a server and register mock with it
675            let (mock_server, _api_config) = start_api_mock(vec![mock]).await;
676
677            // Create a send access client
678            let send_access_client = make_send_client(&mock_server);
679
680            // Construct the request
681            let email_credentials = SendEmailCredentials {
682                email: "invalid-email".into(),
683            };
684            let req = SendAccessTokenRequest {
685                send_id: "valid-send-id".into(),
686                send_access_credentials: Some(SendAccessCredentials::Email(email_credentials)),
687            };
688
689            let result = send_access_client.request_send_access_token(req).await;
690
691            assert!(result.is_err());
692
693            let err = result.unwrap_err();
694            match err {
695                SendAccessTokenError::Expected(api_err) => {
696                    // Now assert the inner enum:
697                    assert_eq!(
698                        api_err,
699                        SendAccessTokenApiErrorResponse::InvalidGrant {
700                            send_access_error_type: Some(
701                                SendAccessTokenInvalidGrantError::EmailInvalid
702                            ),
703                            error_description: Some(error_description),
704                        }
705                    );
706                }
707                other => panic!("expected Response variant, got {:?}", other),
708            }
709        }
710
711        #[tokio::test]
712        async fn request_send_access_token_invalid_grant_invalid_otp_error() {
713            // Create a mock error response
714            let error_description = "otp is invalid.".into();
715            let raw_error = serde_json::json!({
716                "error": "invalid_grant",
717                "error_description": error_description,
718                "send_access_error_type": "otp_invalid"
719            });
720
721            // Create the mock for the request
722            let mock = Mock::given(matchers::method("POST"))
723                .and(matchers::path("identity/connect/token"))
724                .respond_with(ResponseTemplate::new(400).set_body_json(raw_error));
725
726            // Spin up a server and register mock with it
727            let (mock_server, _api_config) = start_api_mock(vec![mock]).await;
728
729            // Create a send access client
730            let send_access_client = make_send_client(&mock_server);
731
732            // Construct the request
733            let email_otp_credentials = SendEmailOtpCredentials {
734                email: "[email protected]".into(),
735                otp: "valid_otp".into(),
736            };
737            let req = SendAccessTokenRequest {
738                send_id: "valid-send-id".into(),
739                send_access_credentials: Some(SendAccessCredentials::EmailOtp(
740                    email_otp_credentials,
741                )),
742            };
743
744            let result = send_access_client.request_send_access_token(req).await;
745
746            assert!(result.is_err());
747
748            let err = result.unwrap_err();
749            match err {
750                SendAccessTokenError::Expected(api_err) => {
751                    // Now assert the inner enum:
752                    assert_eq!(
753                        api_err,
754                        SendAccessTokenApiErrorResponse::InvalidGrant {
755                            send_access_error_type: Some(
756                                SendAccessTokenInvalidGrantError::OtpInvalid
757                            ),
758                            error_description: Some(error_description),
759                        }
760                    );
761                }
762                other => panic!("expected Response variant, got {:?}", other),
763            }
764        }
765    }
766
767    mod request_send_access_token_unexpected_error_tests {
768
769        use super::*;
770
771        async fn run_case(status_code: u16, reason: &str) {
772            let mock = Mock::given(matchers::method("POST"))
773                .and(matchers::path("identity/connect/token"))
774                .respond_with(ResponseTemplate::new(status_code));
775
776            let (mock_server, _api_config) = start_api_mock(vec![mock]).await;
777            let send_access_client = make_send_client(&mock_server);
778
779            let req = SendAccessTokenRequest {
780                send_id: "test_send_id".into(),
781                send_access_credentials: None,
782            };
783
784            let result = send_access_client.request_send_access_token(req).await;
785
786            assert!(result.is_err());
787
788            let err = result.expect_err(&format!(
789                "expected Err for status {} {} against http://{}/identity/connect/token",
790                status_code,
791                reason,
792                mock_server.address()
793            ));
794
795            match err {
796                SendAccessTokenError::Unexpected(api_err) => {
797                    let expected = UnexpectedIdentityError(format!(
798                        "Received response status {} {} against http://{}/identity/connect/token",
799                        status_code,
800                        reason,
801                        mock_server.address()
802                    ));
803                    assert_eq!(api_err, expected, "mismatch for status {}", status_code);
804                }
805                other => panic!("expected Unexpected variant, got {:?}", other),
806            }
807        }
808
809        #[tokio::test]
810        async fn request_send_access_token_unexpected_statuses() {
811            let cases = [
812                // 4xx (client errors) — excluding 400 Bad Request as we handle those as expected
813                // errors.
814                (401, "Unauthorized"),
815                (402, "Payment Required"),
816                (403, "Forbidden"),
817                (404, "Not Found"),
818                (405, "Method Not Allowed"),
819                (406, "Not Acceptable"),
820                (407, "Proxy Authentication Required"),
821                (408, "Request Timeout"),
822                (409, "Conflict"),
823                (410, "Gone"),
824                (411, "Length Required"),
825                (412, "Precondition Failed"),
826                (413, "Payload Too Large"),
827                (414, "URI Too Long"),
828                (415, "Unsupported Media Type"),
829                (416, "Range Not Satisfiable"),
830                (417, "Expectation Failed"),
831                (421, "Misdirected Request"),
832                (422, "Unprocessable Entity"),
833                (423, "Locked"),
834                (424, "Failed Dependency"),
835                (425, "Too Early"),
836                (426, "Upgrade Required"),
837                (428, "Precondition Required"),
838                (429, "Too Many Requests"),
839                (431, "Request Header Fields Too Large"),
840                (451, "Unavailable For Legal Reasons"),
841                // 5xx (server errors)
842                (500, "Internal Server Error"),
843                (501, "Not Implemented"),
844                (502, "Bad Gateway"),
845                (503, "Service Unavailable"),
846                (504, "Gateway Timeout"),
847                (505, "HTTP Version Not Supported"),
848                (506, "Variant Also Negotiates"),
849                (507, "Insufficient Storage"),
850                (508, "Loop Detected"),
851                (510, "Not Extended"),
852                (511, "Network Authentication Required"),
853            ];
854
855            for (code, reason) in cases {
856                run_case(code, reason).await;
857            }
858        }
859    }
860}