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