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