Skip to main content

bitwarden_vault/cipher/attachment_client/admin/
download_url.rs

1use bitwarden_core::{ApiError, MissingFieldError};
2use bitwarden_error::bitwarden_error;
3use reqwest::StatusCode;
4use thiserror::Error;
5#[cfg(feature = "wasm")]
6use wasm_bindgen::prelude::wasm_bindgen;
7
8use crate::{AttachmentAdminClient, CipherId};
9
10#[allow(missing_docs)]
11#[bitwarden_error(flat)]
12#[derive(Debug, Error)]
13pub enum CipherAdminGetAttachmentDownloadUrlError {
14    #[error(transparent)]
15    Api(#[from] ApiError),
16    #[error(transparent)]
17    MissingField(#[from] MissingFieldError),
18    #[error("Attachment not found")]
19    NotFound,
20}
21
22#[cfg_attr(feature = "wasm", wasm_bindgen)]
23impl AttachmentAdminClient {
24    /// Fetches the download URL for an attachment from the admin API. The admin client has
25    /// no local repository to fall back to on 404, so a server-side 404 is surfaced as
26    /// [`CipherAdminGetAttachmentDownloadUrlError::NotFound`] for the caller to handle.
27    pub async fn get_attachment_download_url(
28        &self,
29        cipher_id: CipherId,
30        attachment_id: String,
31    ) -> Result<String, CipherAdminGetAttachmentDownloadUrlError> {
32        let response = self
33            .api_configurations
34            .api_client
35            .ciphers_api()
36            .get_attachment_data_admin(cipher_id.into(), &attachment_id)
37            .await
38            .map_err(|e| match e {
39                bitwarden_api_api::ApiError::Response(content)
40                    if content.status == StatusCode::NOT_FOUND =>
41                {
42                    CipherAdminGetAttachmentDownloadUrlError::NotFound
43                }
44                other => other.into(),
45            })?;
46
47        response.url.ok_or_else(|| MissingFieldError("url").into())
48    }
49}
50
51#[cfg(test)]
52mod tests {
53    use std::sync::Arc;
54
55    use bitwarden_api_api::{apis::ApiClient, models::AttachmentResponseModel};
56    use bitwarden_core::client::ApiConfigurations;
57    use reqwest::StatusCode;
58
59    use super::*;
60
61    const TEST_CIPHER_ID: &str = "5faa9684-c793-4a2d-8a12-b33900187097";
62    const TEST_ATTACHMENT_ID: &str = "uf7bkexzag04d3cw04jsbqqkbpbwhxs0";
63    const TEST_API_URL: &str = "http://localhost:4000/attachments/test/api";
64
65    fn client_with_api(api_client: ApiClient) -> AttachmentAdminClient {
66        AttachmentAdminClient {
67            api_configurations: Arc::new(ApiConfigurations::from_api_client(api_client)),
68        }
69    }
70
71    #[tokio::test]
72    async fn returns_url_from_api_response() {
73        let api_client = ApiClient::new_mocked(|mock| {
74            mock.ciphers_api
75                .expect_get_attachment_data_admin()
76                .returning(|id, attachment_id| {
77                    assert_eq!(&id.to_string(), TEST_CIPHER_ID);
78                    assert_eq!(attachment_id, TEST_ATTACHMENT_ID);
79                    Ok(AttachmentResponseModel {
80                        id: Some(TEST_ATTACHMENT_ID.to_string()),
81                        url: Some(TEST_API_URL.to_string()),
82                        ..Default::default()
83                    })
84                });
85        });
86
87        let client = client_with_api(api_client);
88        let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
89        let url = client
90            .get_attachment_download_url(cipher_id, TEST_ATTACHMENT_ID.to_string())
91            .await
92            .unwrap();
93
94        assert_eq!(url, TEST_API_URL);
95    }
96
97    #[tokio::test]
98    async fn returns_missing_field_when_response_has_no_url() {
99        let api_client = ApiClient::new_mocked(|mock| {
100            mock.ciphers_api
101                .expect_get_attachment_data_admin()
102                .returning(|_id, _attachment_id| {
103                    Ok(AttachmentResponseModel {
104                        id: Some(TEST_ATTACHMENT_ID.to_string()),
105                        url: None,
106                        ..Default::default()
107                    })
108                });
109        });
110
111        let client = client_with_api(api_client);
112        let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
113        let err = client
114            .get_attachment_download_url(cipher_id, TEST_ATTACHMENT_ID.to_string())
115            .await
116            .unwrap_err();
117
118        assert!(matches!(
119            err,
120            CipherAdminGetAttachmentDownloadUrlError::MissingField(_)
121        ));
122    }
123
124    #[tokio::test]
125    async fn returns_not_found_on_404() {
126        let api_client = ApiClient::new_mocked(|mock| {
127            mock.ciphers_api
128                .expect_get_attachment_data_admin()
129                .returning(|_id, _attachment_id| {
130                    Err(bitwarden_api_api::ApiError::Response(
131                        bitwarden_api_api::ResponseContent {
132                            status: StatusCode::NOT_FOUND,
133                            message: String::new(),
134                        },
135                    ))
136                });
137        });
138
139        let client = client_with_api(api_client);
140        let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
141        let err = client
142            .get_attachment_download_url(cipher_id, TEST_ATTACHMENT_ID.to_string())
143            .await
144            .unwrap_err();
145
146        assert!(matches!(
147            err,
148            CipherAdminGetAttachmentDownloadUrlError::NotFound
149        ));
150    }
151
152    #[tokio::test]
153    async fn propagates_non_404_api_errors() {
154        let api_client = ApiClient::new_mocked(|mock| {
155            mock.ciphers_api
156                .expect_get_attachment_data_admin()
157                .returning(|_id, _attachment_id| {
158                    Err(bitwarden_api_api::ApiError::Response(
159                        bitwarden_api_api::ResponseContent {
160                            status: StatusCode::INTERNAL_SERVER_ERROR,
161                            message: "bitwarden".to_string(),
162                        },
163                    ))
164                });
165        });
166
167        let client = client_with_api(api_client);
168        let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
169        let err = client
170            .get_attachment_download_url(cipher_id, TEST_ATTACHMENT_ID.to_string())
171            .await
172            .unwrap_err();
173
174        assert!(matches!(
175            err,
176            CipherAdminGetAttachmentDownloadUrlError::Api(_)
177        ));
178    }
179}