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 partial_data: None,
148 id: TEST_CIPHER_ID.parse().ok(),
149 name: Some(TEST_CIPHER_NAME.parse().unwrap()),
150 r#type: CipherType::Login,
151 attachments: Some(vec![Attachment {
152 id: Some(TEST_ATTACHMENT_ID.to_string()),
153 url: Some(TEST_FALLBACK_URL.to_string()),
154 file_name: Some(TEST_FILE_NAME.parse().unwrap()),
155 key: None,
156 size: Some("65".to_string()),
157 size_name: Some("65 Bytes".to_string()),
158 }]),
159 organization_id: None,
160 folder_id: None,
161 collection_ids: vec![],
162 key: None,
163 notes: None,
164 login: None,
165 identity: None,
166 card: None,
167 secure_note: None,
168 ssh_key: None,
169 bank_account: None,
170 drivers_license: None,
171 passport: None,
172 favorite: false,
173 reprompt: CipherRepromptType::None,
174 organization_use_totp: true,
175 edit: true,
176 permissions: None,
177 view_password: true,
178 local_data: None,
179 fields: None,
180 password_history: None,
181 creation_date: "2024-05-31T11:20:58.4566667Z".parse().unwrap(),
182 deleted_date: None,
183 revision_date: "2024-05-31T11:20:58.4566667Z".parse().unwrap(),
184 archived_date: None,
185 data: None,
186 }
187 }
188
189 fn not_found_response() -> bitwarden_api_api::ApiError {
190 bitwarden_api_api::ApiError::Response(bitwarden_api_api::ResponseContent {
191 status: StatusCode::NOT_FOUND,
192 message: String::new(),
193 })
194 }
195
196 #[tokio::test]
197 async fn returns_url_from_api_response() {
198 let api_client = ApiClient::new_mocked(|mock| {
199 mock.ciphers_api
200 .expect_get_attachment_data()
201 .returning(|id, attachment_id| {
202 assert_eq!(&id.to_string(), TEST_CIPHER_ID);
203 assert_eq!(attachment_id, TEST_ATTACHMENT_ID);
204 Ok(AttachmentResponseModel {
205 id: Some(TEST_ATTACHMENT_ID.to_string()),
206 url: Some(TEST_API_URL.to_string()),
207 ..Default::default()
208 })
209 });
210 });
211
212 let client = client_with_api_and_repo(api_client, MemoryRepository::<Cipher>::default());
213 let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
214
215 let url = client
216 .get_attachment_download_url(cipher_id, TEST_ATTACHMENT_ID.to_string(), None)
217 .await
218 .unwrap();
219
220 assert_eq!(url, TEST_API_URL);
221 }
222
223 #[tokio::test]
224 async fn returns_missing_field_when_response_has_no_url() {
225 let api_client = ApiClient::new_mocked(|mock| {
226 mock.ciphers_api
227 .expect_get_attachment_data()
228 .returning(|_id, _attachment_id| {
229 Ok(AttachmentResponseModel {
230 id: Some(TEST_ATTACHMENT_ID.to_string()),
231 url: None,
232 ..Default::default()
233 })
234 });
235 });
236
237 let client = client_with_api_and_repo(api_client, MemoryRepository::<Cipher>::default());
238 let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
239
240 let err = client
241 .get_attachment_download_url(cipher_id, TEST_ATTACHMENT_ID.to_string(), None)
242 .await
243 .unwrap_err();
244
245 assert!(matches!(
246 err,
247 CipherGetAttachmentDownloadUrlError::MissingField(_)
248 ));
249 }
250
251 #[tokio::test]
252 async fn falls_back_to_repository_url_on_404() {
253 let api_client = ApiClient::new_mocked(|mock| {
254 mock.ciphers_api
255 .expect_get_attachment_data()
256 .returning(|_id, _attachment_id| Err(not_found_response()));
257 });
258
259 let repository = MemoryRepository::<Cipher>::default();
260 let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
261 repository.set(cipher_id, test_cipher()).await.unwrap();
262
263 let client = client_with_api_and_repo(api_client, repository);
264
265 let url = client
266 .get_attachment_download_url(cipher_id, TEST_ATTACHMENT_ID.to_string(), None)
267 .await
268 .unwrap();
269
270 assert_eq!(url, TEST_FALLBACK_URL);
271 }
272
273 #[tokio::test]
274 async fn returns_not_found_on_404_when_cipher_missing_from_repository() {
275 let api_client = ApiClient::new_mocked(|mock| {
276 mock.ciphers_api
277 .expect_get_attachment_data()
278 .returning(|_id, _attachment_id| Err(not_found_response()));
279 });
280
281 let client = client_with_api_and_repo(api_client, MemoryRepository::<Cipher>::default());
282 let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
283
284 let err = client
285 .get_attachment_download_url(cipher_id, TEST_ATTACHMENT_ID.to_string(), None)
286 .await
287 .unwrap_err();
288
289 assert!(matches!(err, CipherGetAttachmentDownloadUrlError::NotFound));
290 }
291
292 #[tokio::test]
293 async fn returns_not_found_on_404_when_attachment_has_no_stored_url() {
294 let api_client = ApiClient::new_mocked(|mock| {
295 mock.ciphers_api
296 .expect_get_attachment_data()
297 .returning(|_id, _attachment_id| Err(not_found_response()));
298 });
299
300 let repository = MemoryRepository::<Cipher>::default();
301 let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
302 let mut cipher = test_cipher();
303 if let Some(attachments) = cipher.attachments.as_mut() {
304 for attachment in attachments {
305 attachment.url = None;
306 }
307 }
308 repository.set(cipher_id, cipher).await.unwrap();
309
310 let client = client_with_api_and_repo(api_client, repository);
311
312 let err = client
313 .get_attachment_download_url(cipher_id, TEST_ATTACHMENT_ID.to_string(), None)
314 .await
315 .unwrap_err();
316
317 assert!(matches!(err, CipherGetAttachmentDownloadUrlError::NotFound));
318 }
319
320 #[tokio::test]
321 async fn propagates_non_404_api_errors() {
322 let api_client = ApiClient::new_mocked(|mock| {
323 mock.ciphers_api
324 .expect_get_attachment_data()
325 .returning(|_id, _attachment_id| {
326 Err(bitwarden_api_api::ApiError::Response(
327 bitwarden_api_api::ResponseContent {
328 status: StatusCode::INTERNAL_SERVER_ERROR,
329 message: "bitwarden".to_string(),
330 },
331 ))
332 });
333 });
334
335 let client = client_with_api_and_repo(api_client, MemoryRepository::<Cipher>::default());
336 let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
337
338 let err = client
339 .get_attachment_download_url(cipher_id, TEST_ATTACHMENT_ID.to_string(), None)
340 .await
341 .unwrap_err();
342
343 assert!(matches!(err, CipherGetAttachmentDownloadUrlError::Api(_)));
344 }
345
346 #[tokio::test]
347 async fn emergency_access_returns_url_from_api_response() {
348 let api_client = ApiClient::new_mocked(|mock| {
349 mock.emergency_access_api
350 .expect_get_attachment_data()
351 .returning(|ea_id, cipher_id, attachment_id| {
352 assert_eq!(&ea_id.to_string(), TEST_EMERGENCY_ACCESS_ID);
353 assert_eq!(&cipher_id.to_string(), TEST_CIPHER_ID);
354 assert_eq!(attachment_id, TEST_ATTACHMENT_ID);
355 Ok(AttachmentResponseModel {
356 id: Some(TEST_ATTACHMENT_ID.to_string()),
357 url: Some(TEST_API_URL.to_string()),
358 ..Default::default()
359 })
360 });
361 });
362
363 let client = client_with_api_and_repo(api_client, MemoryRepository::<Cipher>::default());
364 let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
365
366 let url = client
367 .get_attachment_download_url(
368 cipher_id,
369 TEST_ATTACHMENT_ID.to_string(),
370 Some(TEST_EMERGENCY_ACCESS_ID.to_string()),
371 )
372 .await
373 .unwrap();
374
375 assert_eq!(url, TEST_API_URL);
376 }
377
378 #[tokio::test]
379 async fn emergency_access_returns_missing_field_when_response_has_no_url() {
380 let api_client = ApiClient::new_mocked(|mock| {
381 mock.emergency_access_api
382 .expect_get_attachment_data()
383 .returning(|_ea_id, _cipher_id, _attachment_id| {
384 Ok(AttachmentResponseModel {
385 id: Some(TEST_ATTACHMENT_ID.to_string()),
386 url: None,
387 ..Default::default()
388 })
389 });
390 });
391
392 let client = client_with_api_and_repo(api_client, MemoryRepository::<Cipher>::default());
393 let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
394
395 let err = client
396 .get_attachment_download_url(
397 cipher_id,
398 TEST_ATTACHMENT_ID.to_string(),
399 Some(TEST_EMERGENCY_ACCESS_ID.to_string()),
400 )
401 .await
402 .unwrap_err();
403
404 assert!(matches!(
405 err,
406 CipherGetAttachmentDownloadUrlError::MissingField(_)
407 ));
408 }
409
410 #[tokio::test]
411 async fn emergency_access_returns_not_found_on_404() {
412 let api_client = ApiClient::new_mocked(|mock| {
413 mock.emergency_access_api
414 .expect_get_attachment_data()
415 .returning(|_ea_id, _cipher_id, _attachment_id| Err(not_found_response()));
416 });
417
418 let client = client_with_api_and_repo(api_client, MemoryRepository::<Cipher>::default());
419 let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
420
421 let err = client
422 .get_attachment_download_url(
423 cipher_id,
424 TEST_ATTACHMENT_ID.to_string(),
425 Some(TEST_EMERGENCY_ACCESS_ID.to_string()),
426 )
427 .await
428 .unwrap_err();
429
430 assert!(matches!(err, CipherGetAttachmentDownloadUrlError::NotFound));
431 }
432
433 #[tokio::test]
434 async fn emergency_access_propagates_non_404_api_errors() {
435 let api_client = ApiClient::new_mocked(|mock| {
436 mock.emergency_access_api
437 .expect_get_attachment_data()
438 .returning(|_ea_id, _cipher_id, _attachment_id| {
439 Err(bitwarden_api_api::ApiError::Response(
440 bitwarden_api_api::ResponseContent {
441 status: StatusCode::INTERNAL_SERVER_ERROR,
442 message: "bitwarden".to_string(),
443 },
444 ))
445 });
446 });
447
448 let client = client_with_api_and_repo(api_client, MemoryRepository::<Cipher>::default());
449 let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
450
451 let err = client
452 .get_attachment_download_url(
453 cipher_id,
454 TEST_ATTACHMENT_ID.to_string(),
455 Some(TEST_EMERGENCY_ACCESS_ID.to_string()),
456 )
457 .await
458 .unwrap_err();
459
460 assert!(matches!(err, CipherGetAttachmentDownloadUrlError::Api(_)));
461 }
462
463 #[tokio::test]
464 async fn returns_invalid_emergency_access_id_when_parse_fails() {
465 let api_client = ApiClient::new_mocked(|_mock| {});
466 let client = client_with_api_and_repo(api_client, MemoryRepository::<Cipher>::default());
467 let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
468
469 let err = client
470 .get_attachment_download_url(
471 cipher_id,
472 TEST_ATTACHMENT_ID.to_string(),
473 Some("not-a-uuid".to_string()),
474 )
475 .await
476 .unwrap_err();
477
478 assert!(matches!(
479 err,
480 CipherGetAttachmentDownloadUrlError::InvalidEmergencyAccessId
481 ));
482 }
483}