bitwarden_auth/login/login_via_password/
login_via_password_impl.rs

1use bitwarden_core::key_management::MasterPasswordAuthenticationData;
2#[cfg(feature = "wasm")]
3use wasm_bindgen::prelude::*;
4
5use crate::login::{
6    LoginClient,
7    api::{request::LoginApiRequest, send_login_request},
8    login_via_password::{PasswordLoginApiRequest, PasswordLoginError, PasswordLoginRequest},
9    models::LoginResponse,
10};
11
12#[cfg_attr(feature = "wasm", wasm_bindgen)]
13#[cfg_attr(feature = "uniffi", uniffi::export)]
14impl LoginClient {
15    /// Authenticates a user via email and master password.
16    ///
17    /// Derives the master password hash using KDF settings from prelogin, then sends
18    /// the authentication request to obtain access tokens and vault keys.
19    pub async fn login_via_password(
20        &self,
21        request: PasswordLoginRequest,
22    ) -> Result<LoginResponse, PasswordLoginError> {
23        let master_password_authentication = MasterPasswordAuthenticationData::derive(
24            &request.password,
25            &request.prelogin_response.kdf,
26            &request.prelogin_response.salt,
27        )?;
28
29        let api_request: LoginApiRequest<PasswordLoginApiRequest> =
30            (request, master_password_authentication).into();
31
32        let api_configs = self.client.internal.get_api_configurations().await;
33
34        let response = send_login_request(&api_configs.identity_config, &api_request).await;
35
36        response.map_err(Into::into)
37    }
38}
39
40#[cfg(test)]
41mod tests {
42    use bitwarden_core::{ClientSettings, DeviceType};
43    use bitwarden_crypto::Kdf;
44    use bitwarden_test::start_api_mock;
45    use wiremock::{Mock, ResponseTemplate, matchers};
46
47    use super::*;
48    use crate::login::{
49        login_via_password::{PasswordLoginRequest, PasswordPreloginResponse},
50        models::{LoginDeviceRequest, LoginRequest, LoginResponse},
51    };
52
53    const TEST_EMAIL: &str = "[email protected]";
54    const TEST_PASSWORD: &str = "test-password-123";
55    const TEST_SALT: &str = "test-salt-value";
56    const TEST_CLIENT_ID: &str = "connector";
57    const TEST_DEVICE_IDENTIFIER: &str = "test-device-id";
58    const TEST_DEVICE_NAME: &str = "Test Device";
59
60    #[derive(Debug, Clone, Copy)]
61    enum TestKdfType {
62        Pbkdf2,
63        Argon2id,
64    }
65
66    // Mock success response constants (using real-world valid encrypted data format)
67    const TEST_ACCESS_TOKEN: &str = "test_access_token";
68    const TEST_TOKEN_TYPE: &str = "Bearer";
69    const TEST_EXPIRES_IN: u64 = 3600;
70    const TEST_SCOPE: &str = "api offline_access";
71    const TEST_REFRESH_TOKEN: &str = "test_refresh_token";
72    const TEST_PRIVATE_KEY: &str = "2.SVgjObXyZZKLDVxM3y197w==|tUHZ+bo2o7Y9NyAPPqWOhhuaDiiYT26R2vPI0ILqg8W1vtjq+kzsGHPRZhA1nOXAcJ/ACe77YGFicueH+tryWZHgF1whGZxXza8JPYVtd4k8vO2NE7j8MUZ0FHHq7O+mUiVql0+mC1Af9gM5xp8W022aWgobyu4IZQi6l5hmJZ76NvzUbxDRFadzd8/sxFh+g3I4lEl5kQfzIi3IT0PmX3h75I/8jyGzgWxuUpLiko8hNkIwcjLXesCE641hH8oCtTtwzowZfuRUTO6O/WSR5fHMR2nR2IKf+YvK3SvlywvFTbOAzi7GLNd6NPOZ5ohJrJWtThUZ+65N3CFIczhjj/KvtR5NYVlXlCKWGRLjMsG5Aj8MPCAtAGH8AT6qRoDyh7jXF8SjMo/7BpFay9Xp+kd8M79LEFyUVMybShJ/1Es1qDNCZlnYP8iy1uQe1osLIzSk4IcH2uAD91jvWAOaJGw+HuAOjhqBlP2I7hI8jST5pJAeAzZeY1mnfryYB92wdDVPWKHp+nFcDl34w9lwQRAxken+yxCaepJCRyTXYzpzDNW7Si47PKndchSof9j27MBXTjoOgcsCN2s/V6mNomNybwfN/8J5ts8BNatTnCfiDhV/zrHP9N7wjRXjYoVTLTHXBJqehnLXCNFjnWWmbUTz0fMIRC5q4iNRnSmGMuuCGZfCvlhaIaSVbw35K7ksjTvakJQ8npZU+ULq0Z49jw10GULUbXrP0h/VG+ScKGsRG3E1AOYtd2ff2oe8ht03IpopQWKKk8vqofhDKG++E+SYd/VgMo2O9tuOKilrKCoOBW17/FIftCpWqdGmbG3OBnKiXNOeelqd51i0n9G2ddYhgt+a++8J3UfmrNTX5483+g2usJeJBkKfIbB87FaCxBRSBdvy+bPIPqm6dEWLhk5m3GGkPCndpZywef+tpV7NkC6J8cUDQS0ah1w7r9DG5kNdoSWHbvwhuPR8Ytk8uPdAHI2vOcO/4E6CCPGlsGbXq6egZ39XypO7QJ4+NWTzGDiNGSVOB4Mrxe23++GYRqaMS3bGX0cLKXvCuR1sjYYiM8kechXcmIBGKavs3JrZcT7qEJ8bEpnFQcV+F0iW1bvRTCclVM8XSTbeX6SktHs6fO3vrV+bfkVJsWUAbqR/2di0B9Ye97kJign/03oKUUpg8ksapMfr+IE4CVdHeEC4Xq/y5I+R5TRP/EXiIu2mDIgx7nITj0oTysl070t0OC8QLFrpUkZxjx7ELq76NjMc0IIgumWsivRyBeqz6r3lIA25b6H/3+9xrpjZFb/K/M/NMXFdenjflhYaQLzzsO9Cz7EAorYTf6bV0+g43GyUOC6w0D8R7rerfsVSnwIENlEwpd4s5TC+rWjNPG1r1w91E+It1UbuvBDBTMIZw4BRrCd5/2G0nQyNnNWxn5WLkg3xRCmPYqcVFygagJLh6baYGLb1SVmRu8NF2QMggRsYDkckql6gseq5gGGCfcaFLtAHgfdlfV4jnSZ0tuYpjsLRYhUD/oFGlM56sxnMe/EX6DdDnoGFlAxkRNeHuiY6tdlNhbOAyRjJwQL1Vnweip5vvrHpbEsR6z71E05dwEDnK+2Gz7gVq2x4BIzkLm3MwlOmZFsbLewHr6vB5mm+rgM=|YfKU1iB2Yn/pqeBDbE2IXnpVIlGUR0Sjv9twpnNklHU=";
73    const TEST_PUBLIC_KEY: &str = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqRwZGmKLN34tUq+lLT50JoXJaEJh2E13g8IMFYd5xaywJxA63rnQ5rDa6HFrjjyhg0kbhY60Igv7tpeR7Hq6VTU2CnsRmT47+3ZKm2Y8w/h8Dk0X/a8QcxMbvJZP+2wQ0/6lIbfxRYm7cCi8KZz03mz79lUBJxioy8N+46rMwlj9HQCb8tle5gyEYtF+XtWeAP3JpVvRs3unNvlgThCETnusAIruIJzNX8e+0z7HkzNyFQ3/jY+MyZZUTz3X+r3werc8r94W/4EgoLdjg4651KBQbJuMiknlRzpN+gipClDyjgILxiswtGjuCr80Dyk+jhpDmYhytRcpinnjqkLlzwIDAQAB";
74    const TEST_ENCRYPTED_USER_KEY: &str = "2.EvwbalCwa3ba6j/eEtGOLA==|Nd+7WgEZpd3fsGmpDHOknPhS9e8SVeXpmeJQDTLI3Ki9S7BB/L+k0TxzRnUtcMx646d4Nfco5mz7Q1mMrGO/PGtf4FNleyCR9LMIzHneiRI=|B9bEzJ4LLh0Vz2zexhBwZBQSmXWsPdRKL+haJG/KB6c=";
75    const TEST_KDF_TYPE: i32 = 0;
76    const TEST_KDF_ITERATIONS: i32 = 600000;
77    const TEST_PUSH_TOKEN: &str = "test_push_token";
78
79    fn make_login_client(mock_server: &wiremock::MockServer) -> LoginClient {
80        let settings = ClientSettings {
81            identity_url: format!("http://{}/identity", mock_server.address()),
82            api_url: format!("http://{}/api", mock_server.address()),
83            user_agent: "Bitwarden Rust-SDK [TEST]".into(),
84            device_type: DeviceType::SDK,
85            device_identifier: None,
86            bitwarden_client_version: None,
87            bitwarden_package_type: None,
88        };
89        LoginClient::new(settings)
90    }
91
92    fn make_password_login_request(kdf_type: TestKdfType) -> PasswordLoginRequest {
93        let kdf = match kdf_type {
94            TestKdfType::Pbkdf2 => Kdf::default_pbkdf2(),
95            TestKdfType::Argon2id => Kdf::default_argon2(),
96        };
97
98        PasswordLoginRequest {
99            login_request: LoginRequest {
100                client_id: TEST_CLIENT_ID.to_string(),
101                device: LoginDeviceRequest {
102                    device_type: DeviceType::SDK,
103                    device_identifier: TEST_DEVICE_IDENTIFIER.to_string(),
104                    device_name: TEST_DEVICE_NAME.to_string(),
105                    device_push_token: Some(TEST_PUSH_TOKEN.to_string()),
106                },
107            },
108            email: TEST_EMAIL.to_string(),
109            password: TEST_PASSWORD.to_string(),
110            prelogin_response: PasswordPreloginResponse {
111                kdf,
112                salt: TEST_SALT.to_string(),
113            },
114        }
115    }
116
117    fn add_standard_login_headers(mock_builder: wiremock::MockBuilder) -> wiremock::MockBuilder {
118        mock_builder
119            .and(matchers::header(
120                reqwest::header::CONTENT_TYPE.as_str(),
121                "application/x-www-form-urlencoded",
122            ))
123            .and(matchers::header(
124                reqwest::header::ACCEPT.as_str(),
125                "application/json",
126            ))
127            .and(matchers::header(
128                reqwest::header::CACHE_CONTROL.as_str(),
129                "no-store",
130            ))
131            .and(matchers::header(
132                reqwest::header::PRAGMA.as_str(),
133                "no-cache",
134            ))
135    }
136
137    fn make_mock_success_response() -> serde_json::Value {
138        serde_json::json!({
139            "access_token": TEST_ACCESS_TOKEN,
140            "expires_in": TEST_EXPIRES_IN,
141            "token_type": TEST_TOKEN_TYPE,
142            "refresh_token": TEST_REFRESH_TOKEN,
143            "scope": TEST_SCOPE,
144            "PrivateKey": TEST_PRIVATE_KEY,
145            "AccountKeys": {
146                "publicKeyEncryptionKeyPair": {
147                    "wrappedPrivateKey": TEST_PRIVATE_KEY,
148                    "publicKey": TEST_PUBLIC_KEY,
149                    "Object": "publicKeyEncryptionKeyPair"
150                },
151                "Object": "privateKeys"
152            },
153            "Key": TEST_ENCRYPTED_USER_KEY,
154            "MasterPasswordPolicy": {
155                "Object": "masterPasswordPolicy"
156            },
157            "ForcePasswordReset": false,
158            "Kdf": TEST_KDF_TYPE,
159            "KdfIterations": TEST_KDF_ITERATIONS,
160            "KdfMemory": null,
161            "KdfParallelism": null,
162            "UserDecryptionOptions": {
163                "HasMasterPassword": true,
164                "MasterPasswordUnlock": {
165                    "Kdf": {
166                        "KdfType": TEST_KDF_TYPE,
167                        "Iterations": TEST_KDF_ITERATIONS
168                    },
169                    "MasterKeyEncryptedUserKey": TEST_ENCRYPTED_USER_KEY,
170                    "Salt": TEST_EMAIL
171                },
172                "Object": "userDecryptionOptions"
173            }
174        })
175    }
176
177    fn assert_login_success_response(login_response: &LoginResponse) {
178        match login_response {
179            LoginResponse::Authenticated(success_response) => {
180                assert_eq!(success_response.access_token, TEST_ACCESS_TOKEN);
181                assert_eq!(success_response.token_type, TEST_TOKEN_TYPE);
182                assert_eq!(success_response.expires_in, TEST_EXPIRES_IN);
183                assert_eq!(success_response.scope, TEST_SCOPE);
184                assert_eq!(
185                    success_response.refresh_token,
186                    Some(TEST_REFRESH_TOKEN.to_string())
187                );
188                assert_eq!(
189                    success_response.user_key_wrapped_user_private_key,
190                    Some(TEST_PRIVATE_KEY.to_string())
191                );
192                assert_eq!(success_response.two_factor_token, None);
193                assert_eq!(success_response.force_password_reset, Some(false));
194                assert_eq!(success_response.api_use_key_connector, None);
195
196                // Verify user decryption options
197                let decryption_options = &success_response.user_decryption_options;
198                assert!(decryption_options.master_password_unlock.is_some());
199                let mp_unlock = decryption_options.master_password_unlock.as_ref().unwrap();
200                assert_eq!(
201                    mp_unlock.master_key_wrapped_user_key.to_string(),
202                    TEST_ENCRYPTED_USER_KEY
203                );
204                assert_eq!(mp_unlock.salt, TEST_EMAIL);
205
206                // Verify master password policy is present
207                assert!(success_response.master_password_policy.is_some());
208            }
209        }
210    }
211
212    #[tokio::test]
213    async fn test_login_via_password_success() {
214        let kdf_types = [TestKdfType::Pbkdf2, TestKdfType::Argon2id];
215
216        for kdf_type in kdf_types {
217            let raw_success = make_mock_success_response();
218
219            let mock = add_standard_login_headers(
220                Mock::given(matchers::method("POST")).and(matchers::path("identity/connect/token")),
221            )
222            .respond_with(ResponseTemplate::new(200).set_body_json(raw_success));
223
224            let (mock_server, _api_config) = start_api_mock(vec![mock]).await;
225            let login_client = make_login_client(&mock_server);
226
227            let request = make_password_login_request(kdf_type);
228            let result = login_client.login_via_password(request).await;
229
230            assert!(result.is_ok(), "Failed for KDF type: {kdf_type:?}");
231            let login_response = result.unwrap();
232            assert_login_success_response(&login_response);
233        }
234    }
235
236    #[tokio::test]
237    async fn test_login_via_password_invalid_credentials() {
238        let error_response = serde_json::json!({
239            "error": "invalid_grant",
240            "error_description": "invalid_username_or_password"
241        });
242
243        let mock = Mock::given(matchers::method("POST"))
244            .and(matchers::path("identity/connect/token"))
245            .respond_with(ResponseTemplate::new(400).set_body_json(error_response));
246
247        let (mock_server, _api_config) = start_api_mock(vec![mock]).await;
248        let login_client = make_login_client(&mock_server);
249
250        let request = make_password_login_request(TestKdfType::Pbkdf2);
251        let result = login_client.login_via_password(request).await;
252
253        assert!(result.is_err());
254        let error = result.unwrap_err();
255
256        assert!(matches!(
257            error,
258            PasswordLoginError::InvalidUsernameOrPassword
259        ));
260    }
261
262    #[tokio::test]
263    async fn test_login_via_password_invalid_request() {
264        let error_response = serde_json::json!({
265            "error": "invalid_request",
266            "error_description": "Missing required parameter"
267        });
268
269        let mock = Mock::given(matchers::method("POST"))
270            .and(matchers::path("identity/connect/token"))
271            .respond_with(ResponseTemplate::new(400).set_body_json(error_response));
272
273        let (mock_server, _api_config) = start_api_mock(vec![mock]).await;
274        let login_client = make_login_client(&mock_server);
275
276        let request = make_password_login_request(TestKdfType::Pbkdf2);
277        let result = login_client.login_via_password(request).await;
278
279        assert!(result.is_err());
280        let error = result.unwrap_err();
281
282        match error {
283            PasswordLoginError::Unknown(msg) => {
284                assert!(msg.contains("Invalid request"));
285                assert!(msg.contains("Missing required parameter"));
286            }
287            _ => panic!("Expected Unknown error variant"),
288        }
289    }
290
291    #[tokio::test]
292    async fn test_login_via_password_invalid_client() {
293        let error_response = serde_json::json!({
294            "error": "invalid_client",
295            "error_description": "Client authentication failed"
296        });
297
298        let mock = Mock::given(matchers::method("POST"))
299            .and(matchers::path("identity/connect/token"))
300            .respond_with(ResponseTemplate::new(401).set_body_json(error_response));
301
302        let (mock_server, _api_config) = start_api_mock(vec![mock]).await;
303        let login_client = make_login_client(&mock_server);
304
305        let request = make_password_login_request(TestKdfType::Pbkdf2);
306        let result = login_client.login_via_password(request).await;
307
308        assert!(result.is_err());
309        let error = result.unwrap_err();
310
311        match error {
312            PasswordLoginError::Unknown(msg) => {
313                assert!(msg.contains("Invalid client"));
314                assert!(msg.contains("Client authentication failed"));
315            }
316            _ => panic!("Expected Unknown error variant"),
317        }
318    }
319
320    #[tokio::test]
321    async fn test_login_via_password_unexpected_error() {
322        let error_response = serde_json::json!({
323            "unexpected_field": "unexpected_value"
324        });
325
326        let mock = Mock::given(matchers::method("POST"))
327            .and(matchers::path("identity/connect/token"))
328            .respond_with(ResponseTemplate::new(500).set_body_json(error_response));
329
330        let (mock_server, _api_config) = start_api_mock(vec![mock]).await;
331        let login_client = make_login_client(&mock_server);
332
333        let request = make_password_login_request(TestKdfType::Pbkdf2);
334        let result = login_client.login_via_password(request).await;
335
336        assert!(result.is_err());
337        let error = result.unwrap_err();
338
339        match error {
340            PasswordLoginError::Unknown(msg) => {
341                assert!(msg.contains("Unexpected error"));
342            }
343            _ => panic!("Expected Unknown error variant"),
344        }
345    }
346
347    #[tokio::test]
348    async fn test_login_via_password_invalid_kdf_configuration() {
349        // No mock server needed - error occurs during KDF derivation before API call
350        let (mock_server, _api_config) = start_api_mock(vec![]).await;
351        let login_client = make_login_client(&mock_server);
352
353        // Create a request with PBKDF2 iterations below the minimum (5000)
354        // This will cause derive() to fail with InsufficientKdfParameters
355        let request = PasswordLoginRequest {
356            login_request: LoginRequest {
357                client_id: TEST_CLIENT_ID.to_string(),
358                device: LoginDeviceRequest {
359                    device_type: DeviceType::SDK,
360                    device_identifier: TEST_DEVICE_IDENTIFIER.to_string(),
361                    device_name: TEST_DEVICE_NAME.to_string(),
362                    device_push_token: Some(TEST_PUSH_TOKEN.to_string()),
363                },
364            },
365            email: TEST_EMAIL.to_string(),
366            password: TEST_PASSWORD.to_string(),
367            prelogin_response: PasswordPreloginResponse {
368                kdf: Kdf::PBKDF2 {
369                    iterations: std::num::NonZeroU32::new(100).unwrap(), // Below minimum of 5000
370                },
371                salt: TEST_SALT.to_string(),
372            },
373        };
374
375        let result = login_client.login_via_password(request).await;
376
377        assert!(result.is_err());
378        let error = result.unwrap_err();
379
380        // Verify it's the PasswordAuthenticationDataDerivation error variant
381        assert!(
382            matches!(
383                error,
384                PasswordLoginError::PasswordAuthenticationDataDerivation(_)
385            ),
386            "Expected PasswordAuthenticationDataDerivation error, got: {:?}",
387            error
388        );
389    }
390
391    // ==================== Network Error Tests ====================
392
393    #[tokio::test]
394    async fn test_login_via_password_connection_refused() {
395        // Use an invalid port that will refuse connections
396        let settings = ClientSettings {
397            identity_url: "http://127.0.0.1:1".to_string(), // Port 1 will be refused
398            api_url: "http://127.0.0.1:1".to_string(),
399            user_agent: "Bitwarden Rust-SDK [TEST]".into(),
400            device_type: DeviceType::SDK,
401            device_identifier: None,
402            bitwarden_client_version: None,
403            bitwarden_package_type: None,
404        };
405        let login_client = LoginClient::new(settings);
406
407        let request = make_password_login_request(TestKdfType::Pbkdf2);
408        let result = login_client.login_via_password(request).await;
409
410        // Should fail with Unknown error due to connection refused
411        assert!(result.is_err());
412        let error = result.unwrap_err();
413        assert!(
414            matches!(error, PasswordLoginError::Unknown(_)),
415            "Expected Unknown error for connection refused, got: {:?}",
416            error
417        );
418    }
419
420    #[tokio::test]
421    async fn test_login_via_password_dns_failure() {
422        // Use a domain that doesn't exist
423        let settings = ClientSettings {
424            identity_url: "http://this-domain-definitely-does-not-exist-12345.invalid".to_string(),
425            api_url: "http://this-domain-definitely-does-not-exist-12345.invalid".to_string(),
426            user_agent: "Bitwarden Rust-SDK [TEST]".into(),
427            device_type: DeviceType::SDK,
428            device_identifier: None,
429            bitwarden_client_version: None,
430            bitwarden_package_type: None,
431        };
432        let login_client = LoginClient::new(settings);
433
434        let request = make_password_login_request(TestKdfType::Pbkdf2);
435        let result = login_client.login_via_password(request).await;
436
437        // Should fail with Unknown error due to DNS failure
438        assert!(result.is_err());
439        let error = result.unwrap_err();
440        assert!(
441            matches!(error, PasswordLoginError::Unknown(_)),
442            "Expected Unknown error for DNS failure, got: {:?}",
443            error
444        );
445    }
446
447    // ==================== Malformed Response Tests ====================
448
449    #[tokio::test]
450    async fn test_login_via_password_empty_response_body() {
451        // Server returns 200 but with empty body
452        let mock = Mock::given(matchers::method("POST"))
453            .and(matchers::path("identity/connect/token"))
454            .respond_with(ResponseTemplate::new(200).set_body_string(""));
455
456        let (mock_server, _api_config) = start_api_mock(vec![mock]).await;
457        let login_client = make_login_client(&mock_server);
458
459        let request = make_password_login_request(TestKdfType::Pbkdf2);
460        let result = login_client.login_via_password(request).await;
461
462        // Should fail with Unknown error due to empty body
463        assert!(result.is_err());
464        let error = result.unwrap_err();
465        assert!(
466            matches!(error, PasswordLoginError::Unknown(_)),
467            "Expected Unknown error for empty response, got: {:?}",
468            error
469        );
470    }
471
472    #[tokio::test]
473    async fn test_login_via_password_malformed_json() {
474        // Server returns 200 but with invalid JSON
475        let mock = Mock::given(matchers::method("POST"))
476            .and(matchers::path("identity/connect/token"))
477            .respond_with(ResponseTemplate::new(200).set_body_string("{invalid json"));
478
479        let (mock_server, _api_config) = start_api_mock(vec![mock]).await;
480        let login_client = make_login_client(&mock_server);
481
482        let request = make_password_login_request(TestKdfType::Pbkdf2);
483        let result = login_client.login_via_password(request).await;
484
485        // Should fail with Unknown error due to malformed JSON
486        assert!(result.is_err());
487        let error = result.unwrap_err();
488        assert!(
489            matches!(error, PasswordLoginError::Unknown(_)),
490            "Expected Unknown error for malformed JSON, got: {:?}",
491            error
492        );
493    }
494
495    #[tokio::test]
496    async fn test_login_via_password_incomplete_success_response() {
497        // Server returns 200 with valid JSON but missing required fields
498        let incomplete_response = serde_json::json!({
499            "access_token": TEST_ACCESS_TOKEN,
500            // Missing expires_in, token_type, and other required fields
501        });
502
503        let mock = Mock::given(matchers::method("POST"))
504            .and(matchers::path("identity/connect/token"))
505            .respond_with(ResponseTemplate::new(200).set_body_json(incomplete_response));
506
507        let (mock_server, _api_config) = start_api_mock(vec![mock]).await;
508        let login_client = make_login_client(&mock_server);
509
510        let request = make_password_login_request(TestKdfType::Pbkdf2);
511        let result = login_client.login_via_password(request).await;
512
513        // Should fail with Unknown error due to missing required fields
514        assert!(result.is_err());
515        let error = result.unwrap_err();
516        assert!(
517            matches!(error, PasswordLoginError::Unknown(_)),
518            "Expected Unknown error for incomplete response, got: {:?}",
519            error
520        );
521    }
522
523    #[tokio::test]
524    async fn test_login_via_password_wrong_content_type() {
525        // Server returns HTML instead of JSON
526        let mock = Mock::given(matchers::method("POST"))
527            .and(matchers::path("identity/connect/token"))
528            .respond_with(
529                ResponseTemplate::new(200)
530                    .set_body_string("<html><body>Error</body></html>")
531                    .insert_header("content-type", "text/html"),
532            );
533
534        let (mock_server, _api_config) = start_api_mock(vec![mock]).await;
535        let login_client = make_login_client(&mock_server);
536
537        let request = make_password_login_request(TestKdfType::Pbkdf2);
538        let result = login_client.login_via_password(request).await;
539
540        // Should fail with Unknown error due to wrong content type
541        assert!(result.is_err());
542        let error = result.unwrap_err();
543        assert!(
544            matches!(error, PasswordLoginError::Unknown(_)),
545            "Expected Unknown error for wrong content type, got: {:?}",
546            error
547        );
548    }
549
550    #[tokio::test]
551    async fn test_login_via_password_unexpected_status_code() {
552        // Server returns 418 I'm a teapot (unexpected status code)
553        let mock = Mock::given(matchers::method("POST"))
554            .and(matchers::path("identity/connect/token"))
555            .respond_with(ResponseTemplate::new(418).set_body_string("I'm a teapot"));
556
557        let (mock_server, _api_config) = start_api_mock(vec![mock]).await;
558        let login_client = make_login_client(&mock_server);
559
560        let request = make_password_login_request(TestKdfType::Pbkdf2);
561        let result = login_client.login_via_password(request).await;
562
563        // Should fail with Unknown error due to unexpected status code
564        assert!(result.is_err());
565        let error = result.unwrap_err();
566        assert!(
567            matches!(error, PasswordLoginError::Unknown(_)),
568            "Expected Unknown error for unexpected status code, got: {:?}",
569            error
570        );
571    }
572}