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