Skip to main content

bitwarden_vault/cipher/cipher_client/
delete_attachment.rs

1use bitwarden_core::{ApiError, MissingFieldError};
2use bitwarden_error::bitwarden_error;
3use bitwarden_state::repository::{Repository, RepositoryError};
4use thiserror::Error;
5#[cfg(feature = "wasm")]
6use wasm_bindgen::prelude::wasm_bindgen;
7
8use crate::{Cipher, CipherId, CiphersClient, VaultParseError, cipher::cipher::PartialCipher};
9
10#[allow(missing_docs)]
11#[bitwarden_error(flat)]
12#[derive(Debug, Error)]
13pub enum CipherDeleteAttachmentError {
14    #[error(transparent)]
15    Api(#[from] ApiError),
16    #[error(transparent)]
17    Repository(#[from] RepositoryError),
18    #[error(transparent)]
19    MissingField(#[from] MissingFieldError),
20    #[error(transparent)]
21    VaultParse(#[from] VaultParseError),
22}
23
24impl<T> From<bitwarden_api_api::apis::Error<T>> for CipherDeleteAttachmentError {
25    fn from(value: bitwarden_api_api::apis::Error<T>) -> Self {
26        Self::Api(value.into())
27    }
28}
29
30/// Deletes an attachment from a cipher, and updates the local repository with the new cipher data
31/// returned from the API.
32pub async fn delete_attachment<R: Repository<Cipher> + ?Sized>(
33    cipher_id: CipherId,
34    attachment_id: &str,
35    api_client: &bitwarden_api_api::apis::ApiClient,
36    repository: &R,
37) -> Result<Cipher, CipherDeleteAttachmentError> {
38    let api = api_client.ciphers_api();
39
40    let response = api
41        .delete_attachment(cipher_id.into(), attachment_id)
42        .await?;
43
44    let existing_cipher = repository.get(cipher_id).await?;
45    let cipher_response = response
46        .cipher
47        .map(|c| *c)
48        .ok_or(MissingFieldError("cipher"))?;
49    let cipher = cipher_response.merge_with_cipher(existing_cipher)?;
50
51    repository.set(cipher_id, cipher.clone()).await?;
52
53    Ok(cipher)
54}
55
56#[allow(deprecated)]
57#[cfg_attr(feature = "wasm", wasm_bindgen)]
58impl CiphersClient {
59    /// Deletes an attachment from a cipher, and updates the local repository with the new cipher
60    /// data returned from the API.
61    pub async fn delete_attachment(
62        &self,
63        cipher_id: CipherId,
64        attachment_id: String,
65    ) -> Result<Cipher, CipherDeleteAttachmentError> {
66        let configs = self.client.internal.get_api_configurations();
67        delete_attachment(
68            cipher_id,
69            &attachment_id,
70            &configs.api_client,
71            &*self.get_repository()?,
72        )
73        .await
74    }
75}
76
77#[cfg(test)]
78mod tests {
79    use bitwarden_api_api::{
80        apis::ApiClient,
81        models::{CipherMiniResponseModel, DeleteAttachmentResponseModel},
82    };
83    use bitwarden_state::repository::Repository;
84    use bitwarden_test::MemoryRepository;
85
86    use super::*;
87    use crate::Attachment;
88
89    const TEST_CIPHER_ID: &str = "5faa9684-c793-4a2d-8a12-b33900187097";
90    const TEST_ATTACHMENT_ID: &str = "uf7bkexzag04d3cw04jsbqqkbpbwhxs0";
91
92    fn generate_test_cipher() -> Cipher {
93        Cipher {
94            id: TEST_CIPHER_ID.parse().ok(),
95            name: "2.pMS6/icTQABtulw52pq2lg==|XXbxKxDTh+mWiN1HjH2N1w==|Q6PkuT+KX/axrgN9ubD5Ajk2YNwxQkgs3WJM0S0wtG8=".parse().unwrap(),
96            r#type: crate::CipherType::Login,
97            attachments: Some(vec![Attachment {
98                id: Some(TEST_ATTACHMENT_ID.to_string()),
99                url: Some("http://localhost:4000/attachments/test".to_string()),
100                file_name: Some("2.mV50WiLq6duhwGbhM1TO0A==|dTufWNH8YTPP0EMlNLIpFA==|QHp+7OM8xHtEmCfc9QPXJ0Ro2BeakzvLgxJZ7NdLuDc=".parse().unwrap()),
101                key: None,
102                size: Some("65".to_string()),
103                size_name: Some("65 Bytes".to_string()),
104            }]),
105            notes: Default::default(),
106            organization_id: Default::default(),
107            folder_id: Default::default(),
108            favorite: Default::default(),
109            reprompt: Default::default(),
110            fields: Default::default(),
111            collection_ids: Default::default(),
112            key: Default::default(),
113            login: Default::default(),
114            identity: Default::default(),
115            card: Default::default(),
116            secure_note: Default::default(),
117            ssh_key: Default::default(),
118            bank_account: Default::default(),
119            drivers_license: Default::default(),
120            passport: Default::default(),
121            organization_use_totp: Default::default(),
122            edit: Default::default(),
123            permissions: Default::default(),
124            view_password: Default::default(),
125            local_data: Default::default(),
126            password_history: Default::default(),
127            creation_date: Default::default(),
128            deleted_date: Default::default(),
129            revision_date: Default::default(),
130            archived_date: Default::default(),
131            data: Default::default(),
132        }
133    }
134
135    #[tokio::test]
136    async fn test_delete_attachment() {
137        let cipher = generate_test_cipher();
138        let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
139
140        let api_client = ApiClient::new_mocked(move |mock| {
141            mock.ciphers_api
142                .expect_delete_attachment()
143                .returning(move |id, attachment_id| {
144                    assert_eq!(&id.to_string(), TEST_CIPHER_ID);
145                    assert_eq!(attachment_id, TEST_ATTACHMENT_ID);
146                    Ok(DeleteAttachmentResponseModel {
147                        object: None,
148                        cipher: Some(Box::new(CipherMiniResponseModel {
149                            id: Some(TEST_CIPHER_ID.try_into().unwrap()),
150                            name: Some("2.pMS6/icTQABtulw52pq2lg==|XXbxKxDTh+mWiN1HjH2N1w==|Q6PkuT+KX/axrgN9ubD5Ajk2YNwxQkgs3WJM0S0wtG8=".to_string()),
151                            r#type: Some(bitwarden_api_api::models::CipherType::Login),
152                            creation_date: Some("2024-05-31T11:20:58.4566667Z".to_string()),
153                            revision_date: Some("2024-05-31T11:20:58.4566667Z".to_string()),
154                            attachments: None,
155                            ..Default::default()
156                        })),
157                    })
158                });
159        });
160
161        let repository = MemoryRepository::<Cipher>::default();
162        repository.set(cipher_id, cipher).await.unwrap();
163
164        let result = delete_attachment(cipher_id, TEST_ATTACHMENT_ID, &api_client, &repository)
165            .await
166            .unwrap();
167
168        // The returned cipher should have no attachments (API response had none)
169        assert!(result.attachments.is_none());
170
171        // Verify the repository was updated
172        let repo_cipher = repository.get(cipher_id).await.unwrap().unwrap();
173        assert!(repo_cipher.attachments.is_none());
174    }
175
176    #[tokio::test]
177    async fn test_delete_attachment_missing_cipher_in_response() {
178        let cipher = generate_test_cipher();
179        let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
180
181        let api_client = ApiClient::new_mocked(move |mock| {
182            mock.ciphers_api
183                .expect_delete_attachment()
184                .returning(move |_id, _attachment_id| {
185                    Ok(DeleteAttachmentResponseModel {
186                        object: None,
187                        cipher: None,
188                    })
189                });
190        });
191
192        let repository = MemoryRepository::<Cipher>::default();
193        repository.set(cipher_id, cipher).await.unwrap();
194
195        let result =
196            delete_attachment(cipher_id, TEST_ATTACHMENT_ID, &api_client, &repository).await;
197
198        assert!(
199            result.is_err(),
200            "Should fail when API response has no cipher"
201        );
202    }
203}