1use bitwarden_core::{ApiError, MissingFieldError};
2use bitwarden_error::bitwarden_error;
3use bitwarden_state::repository::{RepositoryError, RepositoryOption};
4use reqwest::StatusCode;
5use thiserror::Error;
6#[cfg(feature = "wasm")]
7use wasm_bindgen::prelude::wasm_bindgen;
8
9use crate::{AttachmentsClient, CipherId};
10
11#[allow(missing_docs)]
12#[bitwarden_error(flat)]
13#[derive(Debug, Error)]
14pub enum CipherGetAttachmentDownloadUrlError {
15 #[error(transparent)]
16 Api(#[from] ApiError),
17 #[error(transparent)]
18 Repository(#[from] RepositoryError),
19 #[error(transparent)]
20 MissingField(#[from] MissingFieldError),
21 #[error("Cipher or attachment not found")]
22 NotFound,
23 #[error("Invalid emergency access ID")]
24 InvalidEmergencyAccessId,
25}
26
27#[cfg_attr(feature = "wasm", wasm_bindgen)]
28impl AttachmentsClient {
29 pub async fn get_attachment_download_url(
34 &self,
35 cipher_id: CipherId,
36 attachment_id: String,
37 emergency_access_id: Option<String>,
38 ) -> Result<String, CipherGetAttachmentDownloadUrlError> {
39 if let Some(emergency_access_id) = emergency_access_id {
40 return self
41 .get_emergency_access_attachment_download_url(
42 &emergency_access_id,
43 cipher_id,
44 &attachment_id,
45 )
46 .await;
47 }
48
49 match self
50 .api_configurations
51 .api_client
52 .ciphers_api()
53 .get_attachment_data(cipher_id.into(), &attachment_id)
54 .await
55 {
56 Ok(response) => response.url.ok_or_else(|| MissingFieldError("url").into()),
57 Err(bitwarden_api_api::ApiError::Response(content))
58 if content.status == StatusCode::NOT_FOUND =>
59 {
60 let repository = self.repository.require()?;
61 let cipher = repository
62 .get(cipher_id)
63 .await?
64 .ok_or(CipherGetAttachmentDownloadUrlError::NotFound)?;
65
66 cipher
67 .attachments
68 .and_then(|attachments| {
69 attachments
70 .into_iter()
71 .find(|a| a.id.as_deref() == Some(&attachment_id))
72 })
73 .and_then(|attachment| attachment.url)
74 .ok_or(CipherGetAttachmentDownloadUrlError::NotFound)
75 }
76 Err(e) => Err(e.into()),
77 }
78 }
79}
80
81impl AttachmentsClient {
82 async fn get_emergency_access_attachment_download_url(
84 &self,
85 emergency_access_id: &str,
86 cipher_id: CipherId,
87 attachment_id: &str,
88 ) -> Result<String, CipherGetAttachmentDownloadUrlError> {
89 let emergency_access_id = emergency_access_id
90 .parse::<uuid::Uuid>()
91 .map_err(|_| CipherGetAttachmentDownloadUrlError::InvalidEmergencyAccessId)?;
92
93 let response = self
94 .api_configurations
95 .api_client
96 .emergency_access_api()
97 .get_attachment_data(emergency_access_id, cipher_id.into(), attachment_id)
98 .await
99 .map_err(|e| match e {
100 bitwarden_api_api::ApiError::Response(content)
101 if content.status == StatusCode::NOT_FOUND =>
102 {
103 CipherGetAttachmentDownloadUrlError::NotFound
104 }
105 other => other.into(),
106 })?;
107
108 response.url.ok_or_else(|| MissingFieldError("url").into())
109 }
110}
111
112#[cfg(test)]
113mod tests {
114 use std::sync::Arc;
115
116 use bitwarden_api_api::{apis::ApiClient, models::AttachmentResponseModel};
117 use bitwarden_core::{client::ApiConfigurations, key_management::KeySlotIds};
118 use bitwarden_crypto::KeyStore;
119 use bitwarden_state::repository::Repository;
120 use bitwarden_test::MemoryRepository;
121
122 use super::*;
123 use crate::{Attachment, Cipher, CipherRepromptType, CipherType};
124
125 const TEST_CIPHER_ID: &str = "5faa9684-c793-4a2d-8a12-b33900187097";
126 const TEST_ATTACHMENT_ID: &str = "uf7bkexzag04d3cw04jsbqqkbpbwhxs0";
127 const TEST_CIPHER_NAME: &str = "2.pMS6/icTQABtulw52pq2lg==|XXbxKxDTh+mWiN1HjH2N1w==|Q6PkuT+KX/axrgN9ubD5Ajk2YNwxQkgs3WJM0S0wtG8=";
128 const TEST_FILE_NAME: &str = "2.mV50WiLq6duhwGbhM1TO0A==|dTufWNH8YTPP0EMlNLIpFA==|QHp+7OM8xHtEmCfc9QPXJ0Ro2BeakzvLgxJZ7NdLuDc=";
129 const TEST_API_URL: &str = "http://localhost:4000/attachments/test/api";
130 const TEST_FALLBACK_URL: &str = "http://localhost:4000/attachments/test/fallback";
131 const TEST_EMERGENCY_ACCESS_ID: &str = "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d";
132
133 fn client_with_api_and_repo(
134 api_client: ApiClient,
135 repository: MemoryRepository<Cipher>,
136 ) -> AttachmentsClient {
137 AttachmentsClient {
138 key_store: KeyStore::<KeySlotIds>::default(),
139 api_configurations: Arc::new(ApiConfigurations::from_api_client(api_client)),
140 repository: Some(Arc::new(repository)),
141 http_client: reqwest::Client::new(),
142 }
143 }
144
145 fn test_cipher() -> Cipher {
146 Cipher {
147 id: TEST_CIPHER_ID.parse().ok(),
148 name: Some(TEST_CIPHER_NAME.parse().unwrap()),
149 r#type: CipherType::Login,
150 attachments: Some(vec![Attachment {
151 id: Some(TEST_ATTACHMENT_ID.to_string()),
152 url: Some(TEST_FALLBACK_URL.to_string()),
153 file_name: Some(TEST_FILE_NAME.parse().unwrap()),
154 key: None,
155 size: Some("65".to_string()),
156 size_name: Some("65 Bytes".to_string()),
157 }]),
158 organization_id: None,
159 folder_id: None,
160 collection_ids: vec![],
161 key: None,
162 notes: None,
163 login: None,
164 identity: None,
165 card: None,
166 secure_note: None,
167 ssh_key: None,
168 bank_account: None,
169 drivers_license: None,
170 passport: None,
171 favorite: false,
172 reprompt: CipherRepromptType::None,
173 organization_use_totp: true,
174 edit: true,
175 permissions: None,
176 view_password: true,
177 local_data: None,
178 fields: None,
179 password_history: None,
180 creation_date: "2024-05-31T11:20:58.4566667Z".parse().unwrap(),
181 deleted_date: None,
182 revision_date: "2024-05-31T11:20:58.4566667Z".parse().unwrap(),
183 archived_date: None,
184 data: None,
185 }
186 }
187
188 fn not_found_response() -> bitwarden_api_api::ApiError {
189 bitwarden_api_api::ApiError::Response(bitwarden_api_api::ResponseContent {
190 status: StatusCode::NOT_FOUND,
191 message: String::new(),
192 })
193 }
194
195 #[tokio::test]
196 async fn returns_url_from_api_response() {
197 let api_client = ApiClient::new_mocked(|mock| {
198 mock.ciphers_api
199 .expect_get_attachment_data()
200 .returning(|id, attachment_id| {
201 assert_eq!(&id.to_string(), TEST_CIPHER_ID);
202 assert_eq!(attachment_id, TEST_ATTACHMENT_ID);
203 Ok(AttachmentResponseModel {
204 id: Some(TEST_ATTACHMENT_ID.to_string()),
205 url: Some(TEST_API_URL.to_string()),
206 ..Default::default()
207 })
208 });
209 });
210
211 let client = client_with_api_and_repo(api_client, MemoryRepository::<Cipher>::default());
212 let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
213
214 let url = client
215 .get_attachment_download_url(cipher_id, TEST_ATTACHMENT_ID.to_string(), None)
216 .await
217 .unwrap();
218
219 assert_eq!(url, TEST_API_URL);
220 }
221
222 #[tokio::test]
223 async fn returns_missing_field_when_response_has_no_url() {
224 let api_client = ApiClient::new_mocked(|mock| {
225 mock.ciphers_api
226 .expect_get_attachment_data()
227 .returning(|_id, _attachment_id| {
228 Ok(AttachmentResponseModel {
229 id: Some(TEST_ATTACHMENT_ID.to_string()),
230 url: None,
231 ..Default::default()
232 })
233 });
234 });
235
236 let client = client_with_api_and_repo(api_client, MemoryRepository::<Cipher>::default());
237 let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
238
239 let err = client
240 .get_attachment_download_url(cipher_id, TEST_ATTACHMENT_ID.to_string(), None)
241 .await
242 .unwrap_err();
243
244 assert!(matches!(
245 err,
246 CipherGetAttachmentDownloadUrlError::MissingField(_)
247 ));
248 }
249
250 #[tokio::test]
251 async fn falls_back_to_repository_url_on_404() {
252 let api_client = ApiClient::new_mocked(|mock| {
253 mock.ciphers_api
254 .expect_get_attachment_data()
255 .returning(|_id, _attachment_id| Err(not_found_response()));
256 });
257
258 let repository = MemoryRepository::<Cipher>::default();
259 let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
260 repository.set(cipher_id, test_cipher()).await.unwrap();
261
262 let client = client_with_api_and_repo(api_client, repository);
263
264 let url = client
265 .get_attachment_download_url(cipher_id, TEST_ATTACHMENT_ID.to_string(), None)
266 .await
267 .unwrap();
268
269 assert_eq!(url, TEST_FALLBACK_URL);
270 }
271
272 #[tokio::test]
273 async fn returns_not_found_on_404_when_cipher_missing_from_repository() {
274 let api_client = ApiClient::new_mocked(|mock| {
275 mock.ciphers_api
276 .expect_get_attachment_data()
277 .returning(|_id, _attachment_id| Err(not_found_response()));
278 });
279
280 let client = client_with_api_and_repo(api_client, MemoryRepository::<Cipher>::default());
281 let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
282
283 let err = client
284 .get_attachment_download_url(cipher_id, TEST_ATTACHMENT_ID.to_string(), None)
285 .await
286 .unwrap_err();
287
288 assert!(matches!(err, CipherGetAttachmentDownloadUrlError::NotFound));
289 }
290
291 #[tokio::test]
292 async fn returns_not_found_on_404_when_attachment_has_no_stored_url() {
293 let api_client = ApiClient::new_mocked(|mock| {
294 mock.ciphers_api
295 .expect_get_attachment_data()
296 .returning(|_id, _attachment_id| Err(not_found_response()));
297 });
298
299 let repository = MemoryRepository::<Cipher>::default();
300 let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
301 let mut cipher = test_cipher();
302 if let Some(attachments) = cipher.attachments.as_mut() {
303 for attachment in attachments {
304 attachment.url = None;
305 }
306 }
307 repository.set(cipher_id, cipher).await.unwrap();
308
309 let client = client_with_api_and_repo(api_client, repository);
310
311 let err = client
312 .get_attachment_download_url(cipher_id, TEST_ATTACHMENT_ID.to_string(), None)
313 .await
314 .unwrap_err();
315
316 assert!(matches!(err, CipherGetAttachmentDownloadUrlError::NotFound));
317 }
318
319 #[tokio::test]
320 async fn propagates_non_404_api_errors() {
321 let api_client = ApiClient::new_mocked(|mock| {
322 mock.ciphers_api
323 .expect_get_attachment_data()
324 .returning(|_id, _attachment_id| {
325 Err(bitwarden_api_api::ApiError::Response(
326 bitwarden_api_api::ResponseContent {
327 status: StatusCode::INTERNAL_SERVER_ERROR,
328 message: "bitwarden".to_string(),
329 },
330 ))
331 });
332 });
333
334 let client = client_with_api_and_repo(api_client, MemoryRepository::<Cipher>::default());
335 let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
336
337 let err = client
338 .get_attachment_download_url(cipher_id, TEST_ATTACHMENT_ID.to_string(), None)
339 .await
340 .unwrap_err();
341
342 assert!(matches!(err, CipherGetAttachmentDownloadUrlError::Api(_)));
343 }
344
345 #[tokio::test]
346 async fn emergency_access_returns_url_from_api_response() {
347 let api_client = ApiClient::new_mocked(|mock| {
348 mock.emergency_access_api
349 .expect_get_attachment_data()
350 .returning(|ea_id, cipher_id, attachment_id| {
351 assert_eq!(&ea_id.to_string(), TEST_EMERGENCY_ACCESS_ID);
352 assert_eq!(&cipher_id.to_string(), TEST_CIPHER_ID);
353 assert_eq!(attachment_id, TEST_ATTACHMENT_ID);
354 Ok(AttachmentResponseModel {
355 id: Some(TEST_ATTACHMENT_ID.to_string()),
356 url: Some(TEST_API_URL.to_string()),
357 ..Default::default()
358 })
359 });
360 });
361
362 let client = client_with_api_and_repo(api_client, MemoryRepository::<Cipher>::default());
363 let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
364
365 let url = client
366 .get_attachment_download_url(
367 cipher_id,
368 TEST_ATTACHMENT_ID.to_string(),
369 Some(TEST_EMERGENCY_ACCESS_ID.to_string()),
370 )
371 .await
372 .unwrap();
373
374 assert_eq!(url, TEST_API_URL);
375 }
376
377 #[tokio::test]
378 async fn emergency_access_returns_missing_field_when_response_has_no_url() {
379 let api_client = ApiClient::new_mocked(|mock| {
380 mock.emergency_access_api
381 .expect_get_attachment_data()
382 .returning(|_ea_id, _cipher_id, _attachment_id| {
383 Ok(AttachmentResponseModel {
384 id: Some(TEST_ATTACHMENT_ID.to_string()),
385 url: None,
386 ..Default::default()
387 })
388 });
389 });
390
391 let client = client_with_api_and_repo(api_client, MemoryRepository::<Cipher>::default());
392 let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
393
394 let err = client
395 .get_attachment_download_url(
396 cipher_id,
397 TEST_ATTACHMENT_ID.to_string(),
398 Some(TEST_EMERGENCY_ACCESS_ID.to_string()),
399 )
400 .await
401 .unwrap_err();
402
403 assert!(matches!(
404 err,
405 CipherGetAttachmentDownloadUrlError::MissingField(_)
406 ));
407 }
408
409 #[tokio::test]
410 async fn emergency_access_returns_not_found_on_404() {
411 let api_client = ApiClient::new_mocked(|mock| {
412 mock.emergency_access_api
413 .expect_get_attachment_data()
414 .returning(|_ea_id, _cipher_id, _attachment_id| Err(not_found_response()));
415 });
416
417 let client = client_with_api_and_repo(api_client, MemoryRepository::<Cipher>::default());
418 let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
419
420 let err = client
421 .get_attachment_download_url(
422 cipher_id,
423 TEST_ATTACHMENT_ID.to_string(),
424 Some(TEST_EMERGENCY_ACCESS_ID.to_string()),
425 )
426 .await
427 .unwrap_err();
428
429 assert!(matches!(err, CipherGetAttachmentDownloadUrlError::NotFound));
430 }
431
432 #[tokio::test]
433 async fn emergency_access_propagates_non_404_api_errors() {
434 let api_client = ApiClient::new_mocked(|mock| {
435 mock.emergency_access_api
436 .expect_get_attachment_data()
437 .returning(|_ea_id, _cipher_id, _attachment_id| {
438 Err(bitwarden_api_api::ApiError::Response(
439 bitwarden_api_api::ResponseContent {
440 status: StatusCode::INTERNAL_SERVER_ERROR,
441 message: "bitwarden".to_string(),
442 },
443 ))
444 });
445 });
446
447 let client = client_with_api_and_repo(api_client, MemoryRepository::<Cipher>::default());
448 let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
449
450 let err = client
451 .get_attachment_download_url(
452 cipher_id,
453 TEST_ATTACHMENT_ID.to_string(),
454 Some(TEST_EMERGENCY_ACCESS_ID.to_string()),
455 )
456 .await
457 .unwrap_err();
458
459 assert!(matches!(err, CipherGetAttachmentDownloadUrlError::Api(_)));
460 }
461
462 #[tokio::test]
463 async fn returns_invalid_emergency_access_id_when_parse_fails() {
464 let api_client = ApiClient::new_mocked(|_mock| {});
465 let client = client_with_api_and_repo(api_client, MemoryRepository::<Cipher>::default());
466 let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
467
468 let err = client
469 .get_attachment_download_url(
470 cipher_id,
471 TEST_ATTACHMENT_ID.to_string(),
472 Some("not-a-uuid".to_string()),
473 )
474 .await
475 .unwrap_err();
476
477 assert!(matches!(
478 err,
479 CipherGetAttachmentDownloadUrlError::InvalidEmergencyAccessId
480 ));
481 }
482}