Skip to main content

bitwarden_vault/cipher/attachment_client/
delete.rs

1use bitwarden_core::{ApiError, MissingFieldError};
2use bitwarden_error::bitwarden_error;
3use bitwarden_state::repository::{RepositoryError, RepositoryOption};
4use thiserror::Error;
5#[cfg(feature = "wasm")]
6use wasm_bindgen::prelude::wasm_bindgen;
7
8use crate::{AttachmentsClient, Cipher, CipherId, 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#[cfg_attr(feature = "wasm", wasm_bindgen)]
31impl AttachmentsClient {
32    /// Deletes an attachment from a cipher, and updates the local repository with the new
33    /// cipher data returned from the API.
34    pub async fn delete_attachment(
35        &self,
36        cipher_id: CipherId,
37        attachment_id: String,
38    ) -> Result<Cipher, CipherDeleteAttachmentError> {
39        let repository = self.repository.require()?;
40
41        let response = self
42            .api_configurations
43            .api_client
44            .ciphers_api()
45            .delete_attachment(cipher_id.into(), &attachment_id)
46            .await?;
47
48        let existing_cipher = repository.get(cipher_id).await?;
49        let cipher_response = response
50            .cipher
51            .map(|c| *c)
52            .ok_or(MissingFieldError("cipher"))?;
53        let cipher = cipher_response.merge_with_cipher(existing_cipher)?;
54
55        repository.set(cipher_id, cipher.clone()).await?;
56
57        Ok(cipher)
58    }
59}
60
61#[cfg(test)]
62mod tests {
63    use std::sync::Arc;
64
65    use bitwarden_api_api::{
66        apis::ApiClient,
67        models::{CipherMiniResponseModel, DeleteAttachmentResponseModel},
68    };
69    use bitwarden_core::{client::ApiConfigurations, key_management::KeySlotIds};
70    use bitwarden_crypto::KeyStore;
71    use bitwarden_state::repository::Repository;
72    use bitwarden_test::MemoryRepository;
73
74    use super::*;
75    use crate::{Attachment, CipherRepromptType, CipherType};
76
77    const TEST_CIPHER_ID: &str = "5faa9684-c793-4a2d-8a12-b33900187097";
78    const TEST_ATTACHMENT_ID: &str = "uf7bkexzag04d3cw04jsbqqkbpbwhxs0";
79    const TEST_CIPHER_NAME: &str = "2.pMS6/icTQABtulw52pq2lg==|XXbxKxDTh+mWiN1HjH2N1w==|Q6PkuT+KX/axrgN9ubD5Ajk2YNwxQkgs3WJM0S0wtG8=";
80    const TEST_FILE_NAME: &str = "2.mV50WiLq6duhwGbhM1TO0A==|dTufWNH8YTPP0EMlNLIpFA==|QHp+7OM8xHtEmCfc9QPXJ0Ro2BeakzvLgxJZ7NdLuDc=";
81
82    fn client_with_api_and_repo(
83        api_client: ApiClient,
84        repository: MemoryRepository<Cipher>,
85    ) -> AttachmentsClient {
86        AttachmentsClient {
87            key_store: KeyStore::<KeySlotIds>::default(),
88            api_configurations: Arc::new(ApiConfigurations::from_api_client(api_client)),
89            repository: Some(Arc::new(repository)),
90            http_client: reqwest::Client::new(),
91        }
92    }
93
94    fn test_cipher() -> Cipher {
95        Cipher {
96            id: TEST_CIPHER_ID.parse().ok(),
97            name: Some(TEST_CIPHER_NAME.parse().unwrap()),
98            r#type: CipherType::Login,
99            attachments: Some(vec![Attachment {
100                id: Some(TEST_ATTACHMENT_ID.to_string()),
101                url: Some("http://localhost:4000/attachments/test".to_string()),
102                file_name: Some(TEST_FILE_NAME.parse().unwrap()),
103                key: None,
104                size: Some("65".to_string()),
105                size_name: Some("65 Bytes".to_string()),
106            }]),
107            organization_id: None,
108            folder_id: None,
109            collection_ids: vec![],
110            key: None,
111            notes: None,
112            login: None,
113            identity: None,
114            card: None,
115            secure_note: None,
116            ssh_key: None,
117            bank_account: None,
118            drivers_license: None,
119            passport: None,
120            favorite: false,
121            reprompt: CipherRepromptType::None,
122            organization_use_totp: true,
123            edit: true,
124            permissions: None,
125            view_password: true,
126            local_data: None,
127            fields: None,
128            password_history: None,
129            creation_date: "2024-05-31T11:20:58.4566667Z".parse().unwrap(),
130            deleted_date: None,
131            revision_date: "2024-05-31T11:20:58.4566667Z".parse().unwrap(),
132            archived_date: None,
133            data: None,
134        }
135    }
136
137    #[tokio::test]
138    async fn returns_updated_cipher_on_success() {
139        let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
140        let api_client = ApiClient::new_mocked(|mock| {
141            mock.ciphers_api
142                .expect_delete_attachment()
143                .returning(|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(TEST_CIPHER_NAME.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, test_cipher()).await.unwrap();
163        let client = client_with_api_and_repo(api_client, repository);
164
165        let result = client
166            .delete_attachment(cipher_id, TEST_ATTACHMENT_ID.to_string())
167            .await
168            .unwrap();
169
170        assert!(result.attachments.is_none());
171
172        let repo_cipher = client
173            .repository
174            .as_ref()
175            .unwrap()
176            .get(cipher_id)
177            .await
178            .unwrap()
179            .unwrap();
180        assert!(repo_cipher.attachments.is_none());
181    }
182
183    #[tokio::test]
184    async fn errors_when_response_has_no_cipher() {
185        let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
186        let api_client = ApiClient::new_mocked(|mock| {
187            mock.ciphers_api
188                .expect_delete_attachment()
189                .returning(|_id, _attachment_id| {
190                    Ok(DeleteAttachmentResponseModel {
191                        object: None,
192                        cipher: None,
193                    })
194                });
195        });
196
197        let repository = MemoryRepository::<Cipher>::default();
198        repository.set(cipher_id, test_cipher()).await.unwrap();
199        let client = client_with_api_and_repo(api_client, repository);
200
201        let result = client
202            .delete_attachment(cipher_id, TEST_ATTACHMENT_ID.to_string())
203            .await;
204
205        assert!(result.is_err());
206    }
207}