Skip to main content

bitwarden_vault/cipher/cipher_client/
edit.rs

1use bitwarden_api_api::models::{CipherCollectionsRequestModel, CipherRequestModel};
2use bitwarden_collections::collection::CollectionId;
3use bitwarden_core::{
4    ApiError, MissingFieldError, NotAuthenticatedError, OrganizationId, UserId,
5    key_management::{KeySlotIds, SymmetricKeySlotId},
6    require,
7};
8use bitwarden_crypto::{
9    CompositeEncryptable, CryptoError, EncString, IdentifyKey, KeyStore, KeyStoreContext,
10    PrimitiveEncryptable,
11};
12use bitwarden_error::bitwarden_error;
13use bitwarden_state::repository::{Repository, RepositoryError};
14use chrono::{DateTime, Utc};
15use serde::{Deserialize, Serialize};
16use thiserror::Error;
17#[cfg(feature = "wasm")]
18use tsify::Tsify;
19#[cfg(feature = "wasm")]
20use wasm_bindgen::prelude::*;
21
22use super::CiphersClient;
23use crate::{
24    AttachmentView, Cipher, CipherId, CipherRepromptType, CipherType, CipherView, FieldView,
25    FolderId, ItemNotFoundError, PasswordHistoryView, VaultParseError,
26    cipher::cipher::{PartialCipher, StrictDecrypt},
27    cipher_view_type::CipherViewType,
28    password_history::MAX_PASSWORD_HISTORY_ENTRIES,
29};
30
31#[allow(missing_docs)]
32#[bitwarden_error(flat)]
33#[derive(Debug, Error)]
34pub enum EditCipherError {
35    #[error(transparent)]
36    ItemNotFound(#[from] ItemNotFoundError),
37    #[error(transparent)]
38    Crypto(#[from] CryptoError),
39    #[error(transparent)]
40    Api(#[from] ApiError),
41    #[error(transparent)]
42    VaultParse(#[from] VaultParseError),
43    #[error(transparent)]
44    MissingField(#[from] MissingFieldError),
45    #[error(transparent)]
46    NotAuthenticated(#[from] NotAuthenticatedError),
47    #[error(transparent)]
48    Repository(#[from] RepositoryError),
49    #[error(transparent)]
50    Uuid(#[from] uuid::Error),
51}
52
53impl<T> From<bitwarden_api_api::apis::Error<T>> for EditCipherError {
54    fn from(val: bitwarden_api_api::apis::Error<T>) -> Self {
55        Self::Api(val.into())
56    }
57}
58
59/// Request to edit a cipher.
60#[derive(Clone, Serialize, Deserialize, Debug)]
61#[serde(rename_all = "camelCase")]
62#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
63#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
64pub struct CipherEditRequest {
65    pub id: CipherId,
66
67    pub organization_id: Option<OrganizationId>,
68    pub folder_id: Option<FolderId>,
69    pub favorite: bool,
70    pub reprompt: CipherRepromptType,
71    pub name: String,
72    pub notes: Option<String>,
73    pub fields: Vec<FieldView>,
74    pub r#type: CipherViewType,
75    pub revision_date: DateTime<Utc>,
76    pub archived_date: Option<DateTime<Utc>>,
77    pub attachments: Vec<AttachmentView>,
78    pub key: Option<EncString>,
79}
80
81impl TryFrom<CipherView> for CipherEditRequest {
82    type Error = MissingFieldError;
83
84    fn try_from(value: CipherView) -> Result<Self, Self::Error> {
85        let type_data = match value.r#type {
86            CipherType::Login => value.login.map(CipherViewType::Login),
87            CipherType::SecureNote => value.secure_note.map(CipherViewType::SecureNote),
88            CipherType::Card => value.card.map(CipherViewType::Card),
89            CipherType::Identity => value.identity.map(CipherViewType::Identity),
90            CipherType::SshKey => value.ssh_key.map(CipherViewType::SshKey),
91            CipherType::BankAccount => value.bank_account.map(CipherViewType::BankAccount),
92        };
93        Ok(Self {
94            id: value.id.ok_or(MissingFieldError("id"))?,
95            organization_id: value.organization_id,
96            folder_id: value.folder_id,
97            favorite: value.favorite,
98            reprompt: value.reprompt,
99            key: value.key,
100            name: value.name,
101            notes: value.notes,
102            fields: value.fields.unwrap_or_default(),
103            r#type: require!(type_data),
104            attachments: value.attachments.unwrap_or_default(),
105            revision_date: value.revision_date,
106            archived_date: value.archived_date,
107        })
108    }
109}
110
111impl CipherEditRequest {
112    pub(super) fn generate_cipher_key(
113        &mut self,
114        ctx: &mut KeyStoreContext<KeySlotIds>,
115        wrapping_key: SymmetricKeySlotId,
116    ) -> Result<(), CryptoError> {
117        let old_key = Cipher::decrypt_cipher_key(ctx, wrapping_key, &self.key)?;
118
119        let new_key = ctx.generate_symmetric_key();
120
121        // Re-encrypt the internal fields with the new key
122        self.r#type
123            .as_login_view_mut()
124            .map(|l| l.reencrypt_fido2_credentials(ctx, old_key, new_key))
125            .transpose()?;
126        AttachmentView::reencrypt_keys(&mut self.attachments, ctx, old_key, new_key)?;
127        self.key = Some(ctx.wrap_symmetric_key(wrapping_key, new_key)?);
128        Ok(())
129    }
130}
131
132/// Used as an intermediary between the public-facing [CipherEditRequest], and the encrypted
133/// value. This allows us to calculate password history safely, without risking misuse.
134#[derive(Clone, Debug)]
135pub(super) struct CipherEditRequestInternal {
136    pub(super) edit_request: CipherEditRequest,
137    pub(super) password_history: Vec<PasswordHistoryView>,
138}
139
140impl CipherEditRequestInternal {
141    pub(super) fn new(edit_request: CipherEditRequest, orig_cipher: &CipherView) -> Self {
142        let mut internal_req = Self {
143            edit_request,
144            password_history: vec![],
145        };
146        internal_req.update_password_history(orig_cipher);
147
148        internal_req
149    }
150
151    fn update_password_history(&mut self, original_cipher: &CipherView) {
152        let changes = self
153            .detect_login_password_changes(original_cipher)
154            .into_iter()
155            .chain(self.detect_hidden_field_changes(original_cipher));
156        let history: Vec<_> = changes
157            .rev()
158            .chain(original_cipher.password_history.iter().flatten().cloned())
159            .take(MAX_PASSWORD_HISTORY_ENTRIES)
160            .collect();
161
162        self.password_history = history;
163    }
164
165    fn detect_login_password_changes(
166        &mut self,
167        original_cipher: &CipherView,
168    ) -> Vec<PasswordHistoryView> {
169        self.edit_request
170            .r#type
171            .as_login_view_mut()
172            .map_or(vec![], |login| {
173                login.detect_password_change(&original_cipher.login)
174            })
175    }
176
177    fn detect_hidden_field_changes(
178        &self,
179        original_cipher: &CipherView,
180    ) -> Vec<PasswordHistoryView> {
181        FieldView::detect_hidden_field_changes(
182            self.edit_request.fields.as_slice(),
183            original_cipher.fields.as_deref().unwrap_or(&[]),
184        )
185    }
186
187    fn generate_checksums(&mut self) {
188        if let Some(login) = &mut self.edit_request.r#type.as_login_view_mut() {
189            login.generate_checksums();
190        }
191    }
192}
193
194impl CompositeEncryptable<KeySlotIds, SymmetricKeySlotId, CipherRequestModel>
195    for CipherEditRequestInternal
196{
197    fn encrypt_composite(
198        &self,
199        ctx: &mut KeyStoreContext<KeySlotIds>,
200        key: SymmetricKeySlotId,
201    ) -> Result<CipherRequestModel, CryptoError> {
202        let mut cipher_data = (*self).clone();
203        cipher_data.generate_checksums();
204
205        let cipher_key = Cipher::decrypt_cipher_key(ctx, key, &self.edit_request.key)?;
206
207        let cipher_request = CipherRequestModel {
208            encrypted_for: None,
209            r#type: Some(cipher_data.edit_request.r#type.get_cipher_type().into()),
210            organization_id: cipher_data
211                .edit_request
212                .organization_id
213                .map(|id| id.to_string()),
214            folder_id: cipher_data.edit_request.folder_id.map(|id| id.to_string()),
215            favorite: Some(cipher_data.edit_request.favorite),
216            reprompt: Some(cipher_data.edit_request.reprompt.into()),
217            key: cipher_data.edit_request.key.map(|k| k.to_string()),
218            name: cipher_data
219                .edit_request
220                .name
221                .encrypt(ctx, cipher_key)?
222                .to_string(),
223            notes: cipher_data
224                .edit_request
225                .notes
226                .as_ref()
227                .map(|n| n.encrypt(ctx, cipher_key))
228                .transpose()?
229                .map(|n| n.to_string()),
230            fields: Some(
231                cipher_data
232                    .edit_request
233                    .fields
234                    .encrypt_composite(ctx, cipher_key)?
235                    .into_iter()
236                    .map(|f| f.into())
237                    .collect(),
238            ),
239            password_history: Some(
240                cipher_data
241                    .password_history
242                    .encrypt_composite(ctx, cipher_key)?
243                    .into_iter()
244                    .map(Into::into)
245                    .collect(),
246            ),
247            attachments: None,
248            attachments2: Some(
249                cipher_data
250                    .edit_request
251                    .attachments
252                    .encrypt_composite(ctx, cipher_key)?
253                    .into_iter()
254                    .map(|a| {
255                        Ok((
256                            a.id.clone().ok_or(CryptoError::MissingField("id"))?,
257                            a.into(),
258                        )) as Result<_, CryptoError>
259                    })
260                    .collect::<Result<_, _>>()?,
261            ),
262            login: cipher_data
263                .edit_request
264                .r#type
265                .as_login_view()
266                .map(|l| l.encrypt_composite(ctx, cipher_key))
267                .transpose()?
268                .map(|l| Box::new(l.into())),
269            card: cipher_data
270                .edit_request
271                .r#type
272                .as_card_view()
273                .map(|c| c.encrypt_composite(ctx, cipher_key))
274                .transpose()?
275                .map(|c| Box::new(c.into())),
276            identity: cipher_data
277                .edit_request
278                .r#type
279                .as_identity_view()
280                .map(|i| i.encrypt_composite(ctx, cipher_key))
281                .transpose()?
282                .map(|c| Box::new(c.into())),
283
284            secure_note: cipher_data
285                .edit_request
286                .r#type
287                .as_secure_note_view()
288                .map(|i| i.encrypt_composite(ctx, cipher_key))
289                .transpose()?
290                .map(|c| Box::new(c.into())),
291            ssh_key: cipher_data
292                .edit_request
293                .r#type
294                .as_ssh_key_view()
295                .map(|i| i.encrypt_composite(ctx, cipher_key))
296                .transpose()?
297                .map(|c| Box::new(c.into())),
298            bank_account: cipher_data
299                .edit_request
300                .r#type
301                .as_bank_account_view()
302                .map(|b| b.encrypt_composite(ctx, cipher_key))
303                .transpose()?
304                .map(|b| Box::new(b.into())),
305
306            last_known_revision_date: Some(
307                cipher_data
308                    .edit_request
309                    .revision_date
310                    .to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
311            ),
312            archived_date: cipher_data
313                .edit_request
314                .archived_date
315                .map(|d| d.to_rfc3339()),
316            data: None,
317        };
318
319        Ok(cipher_request)
320    }
321}
322
323impl IdentifyKey<SymmetricKeySlotId> for CipherEditRequest {
324    fn key_identifier(&self) -> SymmetricKeySlotId {
325        match self.organization_id {
326            Some(organization_id) => SymmetricKeySlotId::Organization(organization_id),
327            None => SymmetricKeySlotId::User,
328        }
329    }
330}
331
332impl IdentifyKey<SymmetricKeySlotId> for CipherEditRequestInternal {
333    fn key_identifier(&self) -> SymmetricKeySlotId {
334        self.edit_request.key_identifier()
335    }
336}
337
338async fn edit_cipher<R: Repository<Cipher> + ?Sized>(
339    key_store: &KeyStore<KeySlotIds>,
340    api_client: &bitwarden_api_api::apis::ApiClient,
341    repository: &R,
342    encrypted_for: UserId,
343    request: CipherEditRequest,
344    use_strict_decryption: bool,
345) -> Result<CipherView, EditCipherError> {
346    let cipher_id = request.id;
347
348    let original_cipher = repository.get(cipher_id).await?.ok_or(ItemNotFoundError)?;
349    let original_cipher_view: CipherView = if use_strict_decryption {
350        key_store.decrypt(&StrictDecrypt(original_cipher.clone()))?
351    } else {
352        key_store.decrypt(&original_cipher)?
353    };
354
355    let request = CipherEditRequestInternal::new(request, &original_cipher_view);
356
357    let mut cipher_request = key_store.encrypt(request)?;
358    cipher_request.encrypted_for = Some(encrypted_for.into());
359
360    let cipher: Cipher = api_client
361        .ciphers_api()
362        .put(cipher_id.into(), Some(cipher_request))
363        .await
364        .map_err(ApiError::from)?
365        .merge_with_cipher(Some(original_cipher))?;
366    debug_assert!(cipher.id.unwrap_or_default() == cipher_id);
367    repository.set(cipher_id, cipher.clone()).await?;
368
369    if use_strict_decryption {
370        Ok(key_store.decrypt(&StrictDecrypt(cipher))?)
371    } else {
372        Ok(key_store.decrypt(&cipher)?)
373    }
374}
375
376#[cfg_attr(feature = "wasm", wasm_bindgen)]
377impl CiphersClient {
378    /// Edit an existing [Cipher] and save it to the server.
379    pub async fn edit(
380        &self,
381        mut request: CipherEditRequest,
382    ) -> Result<CipherView, EditCipherError> {
383        let key_store = self.client.internal.get_key_store();
384        let config = self.client.internal.get_api_configurations();
385        let repository = self.get_repository()?;
386
387        let user_id = self
388            .client
389            .internal
390            .get_user_id()
391            .ok_or(NotAuthenticatedError)?;
392
393        // TODO: Once this flag is removed, the key generation logic should
394        // be moved closer to the actual encryption logic.
395        if request.key.is_none()
396            && self
397                .client
398                .internal
399                .get_flags()
400                .await
401                .enable_cipher_key_encryption
402        {
403            let key = request.key_identifier();
404            request.generate_cipher_key(&mut key_store.context(), key)?;
405        }
406
407        edit_cipher(
408            key_store,
409            &config.api_client,
410            repository.as_ref(),
411            user_id,
412            request,
413            self.is_strict_decrypt().await,
414        )
415        .await
416    }
417
418    /// Adds the cipher matched by [CipherId] to any number of collections on the server.
419    pub async fn update_collection(
420        &self,
421        cipher_id: CipherId,
422        collection_ids: Vec<CollectionId>,
423        is_admin: bool,
424    ) -> Result<CipherView, EditCipherError> {
425        let req = CipherCollectionsRequestModel {
426            collection_ids: collection_ids
427                .into_iter()
428                .map(|id| id.to_string())
429                .collect(),
430        };
431        let repository = self.get_repository()?;
432
433        let api_config = self.client.internal.get_api_configurations();
434        let api = api_config.api_client.ciphers_api();
435        let orig_cipher = repository.get(cipher_id).await?;
436        let cipher = if is_admin {
437            api.put_collections_admin(&cipher_id.to_string(), Some(req))
438                .await?
439                .merge_with_cipher(orig_cipher)?
440        } else {
441            let response: Cipher = api
442                .put_collections(cipher_id.into(), Some(req))
443                .await?
444                .merge_with_cipher(orig_cipher)?;
445            repository.set(cipher_id, response.clone()).await?;
446            response
447        };
448
449        Ok(self
450            .decrypt(cipher)
451            .await
452            .map_err(|_| CryptoError::KeyDecrypt)?)
453    }
454}
455
456#[cfg(test)]
457mod tests {
458    use bitwarden_api_api::{apis::ApiClient, models::CipherResponseModel};
459    use bitwarden_core::key_management::SymmetricKeySlotId;
460    use bitwarden_crypto::{KeyStore, PrimitiveEncryptable, SymmetricKeyAlgorithm};
461    use bitwarden_test::MemoryRepository;
462    use chrono::TimeZone;
463
464    use super::*;
465    use crate::{
466        Cipher, CipherId, CipherRepromptType, CipherType, FieldType, Login, LoginView,
467        PasswordHistoryView,
468    };
469
470    const TEST_CIPHER_ID: &str = "5faa9684-c793-4a2d-8a12-b33900187097";
471    const TEST_USER_ID: &str = "550e8400-e29b-41d4-a716-446655440000";
472
473    fn generate_test_cipher() -> CipherView {
474        CipherView {
475            id: Some(TEST_CIPHER_ID.parse().unwrap()),
476            organization_id: None,
477            folder_id: None,
478            collection_ids: vec![],
479            key: None,
480            name: "Test Login".to_string(),
481            notes: None,
482            r#type: CipherType::Login,
483            login: Some(LoginView {
484                username: Some("[email protected]".to_string()),
485                password: Some("password123".to_string()),
486                password_revision_date: None,
487                uris: None,
488                totp: None,
489                autofill_on_page_load: None,
490                fido2_credentials: None,
491            }),
492            identity: None,
493            card: None,
494            secure_note: None,
495            ssh_key: None,
496            bank_account: None,
497            favorite: false,
498            reprompt: CipherRepromptType::None,
499            organization_use_totp: true,
500            edit: true,
501            permissions: None,
502            view_password: true,
503            local_data: None,
504            attachments: None,
505            attachment_decryption_failures: None,
506            fields: None,
507            password_history: None,
508            creation_date: "2025-01-01T00:00:00Z".parse().unwrap(),
509            deleted_date: None,
510            revision_date: "2025-01-01T00:00:00Z".parse().unwrap(),
511            archived_date: None,
512        }
513    }
514
515    fn create_test_login_cipher(password: &str) -> CipherView {
516        let mut cipher_view = generate_test_cipher();
517        if let Some(ref mut login) = cipher_view.login {
518            login.password = Some(password.to_string());
519        }
520        cipher_view
521    }
522
523    async fn repository_add_cipher(
524        repository: &MemoryRepository<Cipher>,
525        store: &KeyStore<KeySlotIds>,
526        cipher_id: CipherId,
527        name: &str,
528    ) {
529        let cipher = {
530            let mut ctx = store.context();
531
532            Cipher {
533                id: Some(cipher_id),
534                organization_id: None,
535                folder_id: None,
536                collection_ids: vec![],
537                key: None,
538                name: name.encrypt(&mut ctx, SymmetricKeySlotId::User).unwrap(),
539                notes: None,
540                r#type: CipherType::Login,
541                login: Some(Login {
542                    username: Some("[email protected]")
543                        .map(|u| u.encrypt(&mut ctx, SymmetricKeySlotId::User))
544                        .transpose()
545                        .unwrap(),
546                    password: Some("password123")
547                        .map(|p| p.encrypt(&mut ctx, SymmetricKeySlotId::User))
548                        .transpose()
549                        .unwrap(),
550                    password_revision_date: None,
551                    uris: None,
552                    totp: None,
553                    autofill_on_page_load: None,
554                    fido2_credentials: None,
555                }),
556                identity: None,
557                card: None,
558                secure_note: None,
559                ssh_key: None,
560                bank_account: None,
561                favorite: false,
562                reprompt: CipherRepromptType::None,
563                organization_use_totp: true,
564                edit: true,
565                permissions: None,
566                view_password: true,
567                local_data: None,
568                attachments: None,
569                fields: None,
570                password_history: None,
571                creation_date: "2024-01-01T00:00:00Z".parse().unwrap(),
572                deleted_date: None,
573                revision_date: "2024-01-01T00:00:00Z".parse().unwrap(),
574                archived_date: None,
575                data: None,
576            }
577        };
578
579        repository.set(cipher_id, cipher).await.unwrap();
580    }
581
582    #[tokio::test]
583    async fn test_edit_cipher() {
584        let store: KeyStore<KeySlotIds> = KeyStore::default();
585        {
586            let mut ctx = store.context_mut();
587            let local_key_id = ctx.make_symmetric_key(SymmetricKeyAlgorithm::Aes256CbcHmac);
588            ctx.persist_symmetric_key(local_key_id, SymmetricKeySlotId::User)
589                .unwrap();
590        }
591
592        let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
593
594        let api_client = ApiClient::new_mocked(move |mock| {
595            mock.ciphers_api
596                .expect_put()
597                .returning(move |_id, body| {
598                    let body = body.unwrap();
599                    Ok(CipherResponseModel {
600                        object: Some("cipher".to_string()),
601                        id: Some(cipher_id.into()),
602                        name: Some(body.name),
603                        r#type: body.r#type,
604                        organization_id: body
605                            .organization_id
606                            .as_ref()
607                            .and_then(|id| uuid::Uuid::parse_str(id).ok()),
608                        folder_id: body
609                            .folder_id
610                            .as_ref()
611                            .and_then(|id| uuid::Uuid::parse_str(id).ok()),
612                        favorite: body.favorite,
613                        reprompt: body.reprompt,
614                        key: body.key,
615                        notes: body.notes,
616                        view_password: Some(true),
617                        edit: Some(true),
618                        organization_use_totp: Some(true),
619                        revision_date: Some("2025-01-01T00:00:00Z".to_string()),
620                        creation_date: Some("2025-01-01T00:00:00Z".to_string()),
621                        deleted_date: None,
622                        login: body.login,
623                        card: body.card,
624                        identity: body.identity,
625                        secure_note: body.secure_note,
626                        ssh_key: body.ssh_key,
627                        bank_account: body.bank_account,
628                        fields: body.fields,
629                        password_history: body.password_history,
630                        attachments: None,
631                        permissions: None,
632                        data: None,
633                        archived_date: None,
634                    })
635                })
636                .once();
637        });
638
639        let collection_id: CollectionId = "a4e13cc0-1234-5678-abcd-b181009709b8".parse().unwrap();
640
641        let repository = MemoryRepository::<Cipher>::default();
642        repository_add_cipher(&repository, &store, cipher_id, "old_name").await;
643        // Update the stored cipher to include a collection_id so we can verify it is preserved.
644        let mut stored = repository.get(cipher_id).await.unwrap().unwrap();
645        stored.collection_ids = vec![collection_id];
646        repository.set(cipher_id, stored).await.unwrap();
647
648        let cipher_view = generate_test_cipher();
649
650        let request = cipher_view.try_into().unwrap();
651
652        let result = edit_cipher(
653            &store,
654            &api_client,
655            &repository,
656            TEST_USER_ID.parse().unwrap(),
657            request,
658            false,
659        )
660        .await
661        .unwrap();
662
663        assert_eq!(result.id, Some(cipher_id));
664        assert_eq!(result.name, "Test Login");
665        // collection_ids must be preserved even though CipherResponseModel omits them.
666        assert_eq!(result.collection_ids, vec![collection_id]);
667    }
668
669    #[tokio::test]
670    async fn test_edit_cipher_does_not_exist() {
671        let store: KeyStore<KeySlotIds> = KeyStore::default();
672
673        let repository = MemoryRepository::<Cipher>::default();
674
675        let cipher_view = generate_test_cipher();
676        let api_client = ApiClient::new_mocked(|_| {});
677
678        let request = cipher_view.try_into().unwrap();
679
680        let result = edit_cipher(
681            &store,
682            &api_client,
683            &repository,
684            TEST_USER_ID.parse().unwrap(),
685            request,
686            false,
687        )
688        .await;
689
690        assert!(result.is_err());
691        assert!(matches!(
692            result.unwrap_err(),
693            EditCipherError::ItemNotFound(_)
694        ));
695    }
696
697    #[tokio::test]
698    async fn test_edit_cipher_http_error() {
699        let store: KeyStore<KeySlotIds> = KeyStore::default();
700        {
701            let mut ctx = store.context_mut();
702            let local_key_id = ctx.make_symmetric_key(SymmetricKeyAlgorithm::Aes256CbcHmac);
703            ctx.persist_symmetric_key(local_key_id, SymmetricKeySlotId::User)
704                .unwrap();
705        }
706
707        let cipher_id: CipherId = "5faa9684-c793-4a2d-8a12-b33900187097".parse().unwrap();
708
709        let api_client = ApiClient::new_mocked(move |mock| {
710            mock.ciphers_api.expect_put().returning(move |_id, _body| {
711                Err(bitwarden_api_api::apis::Error::Io(std::io::Error::other(
712                    "Simulated error",
713                )))
714            });
715        });
716
717        let repository = MemoryRepository::<Cipher>::default();
718        repository_add_cipher(&repository, &store, cipher_id, "old_name").await;
719        let cipher_view = generate_test_cipher();
720
721        let request = cipher_view.try_into().unwrap();
722
723        let result = edit_cipher(
724            &store,
725            &api_client,
726            &repository,
727            TEST_USER_ID.parse().unwrap(),
728            request,
729            false,
730        )
731        .await;
732
733        assert!(result.is_err());
734        assert!(matches!(result.unwrap_err(), EditCipherError::Api(_)));
735    }
736
737    #[test]
738    fn test_password_history_on_password_change() {
739        let original_cipher = create_test_login_cipher("old_password");
740        let edit_request =
741            CipherEditRequest::try_from(create_test_login_cipher("new_password")).unwrap();
742
743        let start = Utc::now();
744        let internal_req = CipherEditRequestInternal::new(edit_request, &original_cipher);
745        let history = internal_req.password_history;
746        let end = Utc::now();
747
748        assert_eq!(history.len(), 1);
749        assert!(
750            history[0].last_used_date >= start && history[0].last_used_date <= end,
751            "last_used_date was not set properly"
752        );
753        assert_eq!(history[0].password, "old_password");
754    }
755
756    #[test]
757    fn test_password_history_on_unchanged_password() {
758        let original_cipher = create_test_login_cipher("same_password");
759        let edit_request =
760            CipherEditRequest::try_from(create_test_login_cipher("same_password")).unwrap();
761
762        let internal_req = CipherEditRequestInternal::new(edit_request, &original_cipher);
763        let password_history = internal_req.password_history;
764
765        assert!(password_history.is_empty());
766    }
767
768    #[test]
769    fn test_password_history_is_preserved() {
770        let mut original_cipher = create_test_login_cipher("same_password");
771        original_cipher.password_history = Some(
772            (0..4)
773                .map(|i| PasswordHistoryView {
774                    password: format!("old_password_{}", i),
775                    last_used_date: Utc.with_ymd_and_hms(2025, i + 1, i + 1, i, i, i).unwrap(),
776                })
777                .collect(),
778        );
779
780        let edit_request =
781            CipherEditRequest::try_from(create_test_login_cipher("same_password")).unwrap();
782        let internal_req = CipherEditRequestInternal::new(edit_request, &original_cipher);
783        let history = internal_req.password_history;
784
785        assert_eq!(history[0].password, "old_password_0");
786
787        assert_eq!(
788            history[0].last_used_date,
789            Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap()
790        );
791        assert_eq!(history[1].password, "old_password_1");
792        assert_eq!(
793            history[1].last_used_date,
794            Utc.with_ymd_and_hms(2025, 2, 2, 1, 1, 1).unwrap()
795        );
796        assert_eq!(history[2].password, "old_password_2");
797        assert_eq!(
798            history[2].last_used_date,
799            Utc.with_ymd_and_hms(2025, 3, 3, 2, 2, 2).unwrap()
800        );
801        assert_eq!(history[3].password, "old_password_3");
802        assert_eq!(
803            history[3].last_used_date,
804            Utc.with_ymd_and_hms(2025, 4, 4, 3, 3, 3).unwrap()
805        );
806    }
807
808    #[test]
809    fn test_password_history_with_hidden_fields() {
810        let mut original_cipher = create_test_login_cipher("password");
811        original_cipher.fields = Some(vec![FieldView {
812            name: Some("Secret Key".to_string()),
813            value: Some("old_secret_value".to_string()),
814            r#type: FieldType::Hidden,
815            linked_id: None,
816        }]);
817
818        let mut new_cipher = create_test_login_cipher("password");
819        new_cipher.fields = Some(vec![FieldView {
820            name: Some("Secret Key".to_string()),
821            value: Some("new_secret_value".to_string()),
822            r#type: FieldType::Hidden,
823            linked_id: None,
824        }]);
825
826        let edit_request = CipherEditRequest::try_from(new_cipher).unwrap();
827
828        let internal_req = CipherEditRequestInternal::new(edit_request, &original_cipher);
829        let history = internal_req.password_history;
830
831        assert_eq!(history.len(), 1);
832        assert_eq!(history[0].password, "Secret Key: old_secret_value");
833    }
834
835    #[test]
836    fn test_password_history_length_limit() {
837        let mut original_cipher = create_test_login_cipher("password");
838        original_cipher.password_history = Some(
839            (0..10)
840                .map(|i| PasswordHistoryView {
841                    password: format!("old_password_{}", i),
842                    last_used_date: Utc::now(),
843                })
844                .collect(),
845        );
846
847        // Create edit request with new password (no existing history)
848        let edit_request =
849            CipherEditRequest::try_from(create_test_login_cipher("new_password")).unwrap();
850
851        let internal_req = CipherEditRequestInternal::new(edit_request, &original_cipher);
852        let history = internal_req.password_history;
853
854        assert_eq!(history.len(), MAX_PASSWORD_HISTORY_ENTRIES);
855        // Most recent change (original password) should be first
856        assert_eq!(history[0].password, "password");
857
858        assert_eq!(history[1].password, "old_password_0");
859        assert_eq!(history[2].password, "old_password_1");
860        assert_eq!(history[3].password, "old_password_2");
861        assert_eq!(history[4].password, "old_password_3");
862    }
863}