Skip to main content

bitwarden_send/
create_file_send.rs

1use bitwarden_api_base::AuthRequired;
2use bitwarden_core::{ApiError, MissingFieldError, key_management::SymmetricKeySlotId, require};
3use bitwarden_crypto::{CryptoError, EncString, OctetStreamBytes, PrimitiveEncryptable};
4use bitwarden_error::bitwarden_error;
5use bitwarden_state::repository::RepositoryError;
6use serde::{Deserialize, Serialize};
7use serde_repr::{Deserialize_repr, Serialize_repr};
8use thiserror::Error;
9#[cfg(feature = "wasm")]
10use tsify::Tsify;
11#[cfg(feature = "wasm")]
12use wasm_bindgen::prelude::*;
13
14use crate::{
15    EmptyEmailListError, Send, SendAddRequest, SendId, SendParseError, SendView,
16    send_client::SendClient,
17};
18
19/// Where the client should upload the encrypted file bytes after [`SendClient::create_file_send`].
20#[derive(Clone, Copy, Serialize_repr, Deserialize_repr, Debug, PartialEq)]
21#[repr(u8)]
22#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
23#[cfg_attr(feature = "wasm", wasm_bindgen)]
24pub enum FileUploadType {
25    /// Upload directly to the Bitwarden server via `POST /sends/{id}/file/{file_id}`.
26    Direct = 0,
27    /// Upload to an Azure Blob Storage pre-signed URL.
28    Azure = 1,
29}
30
31impl TryFrom<bitwarden_api_api::models::FileUploadType> for FileUploadType {
32    type Error = MissingFieldError;
33
34    fn try_from(t: bitwarden_api_api::models::FileUploadType) -> Result<Self, Self::Error> {
35        Ok(match t {
36            bitwarden_api_api::models::FileUploadType::Direct => FileUploadType::Direct,
37            bitwarden_api_api::models::FileUploadType::Azure => FileUploadType::Azure,
38            bitwarden_api_api::models::FileUploadType::__Unknown(_) => {
39                return Err(MissingFieldError("file_upload_type"));
40            }
41        })
42    }
43}
44
45/// View returned after creating a file send.
46///
47/// Contains the created send and information needed to upload the file data.
48#[derive(Serialize, Deserialize, Debug)]
49#[serde(rename_all = "camelCase")]
50#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi))]
51pub struct CreateFileSendResponse {
52    /// The created send.
53    pub send: SendView,
54    /// The upload URL for the file data.
55    pub url: String,
56    /// Which upload backend the client should target with the encrypted bytes.
57    pub file_upload_type: FileUploadType,
58    /// The file ID assigned by the server.
59    pub file_id: String,
60    /// The encrypted file name string (e.g. `"2.ABCD..."`).
61    pub encrypted_file_name: String,
62    /// The encrypted file bytes, ready to hand to [`SendClient::upload_send_file`]. These were
63    /// encrypted under the same send key recorded on the created send, and their length was used
64    /// to populate `file_length` on the create request (matching the legacy client, which sends
65    /// the encrypted buffer length so the server can enforce storage quotas up-front).
66    pub encrypted_file_buffer: Vec<u8>,
67}
68
69#[allow(missing_docs)]
70#[bitwarden_error(flat)]
71#[derive(Debug, Error)]
72pub enum CreateFileSendError {
73    /// An API or network error occurred.
74    #[error(transparent)]
75    Api(#[from] ApiError),
76    /// A cryptographic error occurred.
77    #[error(transparent)]
78    Crypto(#[from] CryptoError),
79    /// An email list validation error occurred.
80    #[error(transparent)]
81    EmptyEmailList(#[from] EmptyEmailListError),
82    /// A required field was missing from the API response.
83    #[error(transparent)]
84    MissingField(#[from] MissingFieldError),
85    /// A repository error occurred.
86    #[error(transparent)]
87    Repository(#[from] RepositoryError),
88    /// A send parse error occurred.
89    #[error(transparent)]
90    SendParse(#[from] SendParseError),
91}
92
93#[allow(missing_docs)]
94#[bitwarden_error(flat)]
95#[derive(Debug, Error)]
96pub enum UploadSendFileError {
97    /// An API or network error occurred.
98    #[error(transparent)]
99    Api(#[from] ApiError),
100    /// A reqwest error occurred when building the multipart form.
101    #[error(transparent)]
102    Reqwest(reqwest::Error),
103    /// The Azure blob upload URL could not be renewed after it expired.
104    #[error(transparent)]
105    RenewFileUploadUrl(#[from] RenewFileUploadUrlError),
106}
107
108#[allow(missing_docs)]
109#[bitwarden_error(flat)]
110#[derive(Debug, Error)]
111pub enum RenewFileUploadUrlError {
112    /// An API or network error occurred.
113    #[error(transparent)]
114    Api(#[from] ApiError),
115    /// A required field was missing from the API response.
116    #[error(transparent)]
117    MissingField(#[from] MissingFieldError),
118}
119
120#[cfg_attr(feature = "wasm", wasm_bindgen)]
121impl SendClient {
122    /// Create a new file [Send] and save it to the server.
123    ///
124    /// `file_buffer` is the *plaintext* file content. It is encrypted internally under the send key
125    /// derived for this send, and its ciphertext length is sent to the server as `file_length` so
126    /// the server can enforce storage quotas before the upload (matching the legacy client's
127    /// encrypt-then-create ordering).
128    ///
129    /// Returns the created send, the encrypted file bytes, and the metadata needed to upload them.
130    /// After calling this, hand [`CreateFileSendResponse::encrypted_file_buffer`] and the upload
131    /// metadata to [`SendClient::upload_send_file`].
132    pub async fn create_file_send(
133        &self,
134        request: SendAddRequest,
135        file_buffer: Vec<u8>,
136    ) -> Result<CreateFileSendResponse, CreateFileSendError> {
137        request.auth.validate()?;
138
139        let key_store = self.client.internal.get_key_store();
140        let config = self.client.internal.get_api_configurations();
141        let repository = self.get_repository()?;
142
143        let mut send_request = key_store.encrypt(request)?;
144
145        // Encrypt the file buffer under the send key that `send_request` just wrapped (its `key`
146        // field), so the ciphertext decrypts under the key the server records — and so we can send
147        // the true ciphertext length as `file_length`. Legacy: `new SendRequest(sendData,
148        // encBuffer.byteLength)`.
149        let encrypted_file_buffer =
150            encrypt_file_buffer_with_send_key(key_store, &send_request.key, &file_buffer)?;
151        send_request.file_length = Some(encrypted_file_buffer.len() as i64);
152
153        let resp = config
154            .api_client
155            .sends_api()
156            .post_file(Some(send_request))
157            .await?;
158
159        let url = require!(resp.url);
160        let file_upload_type: FileUploadType = require!(resp.file_upload_type).try_into()?;
161        let send_response = *require!(resp.send_response);
162
163        let send: Send = send_response.try_into()?;
164
165        let file_id = send
166            .file
167            .as_ref()
168            .and_then(|f| f.id.clone())
169            .ok_or(MissingFieldError("file.id"))?;
170
171        let encrypted_file_name = send
172            .file
173            .as_ref()
174            .map(|f| f.file_name.to_string())
175            .ok_or(MissingFieldError("file.file_name"))?;
176
177        let send_view = key_store.decrypt(&send)?;
178
179        let send_id = require!(send.id);
180        repository.set(send_id, send.clone()).await?;
181
182        Ok(CreateFileSendResponse {
183            send: send_view,
184            url,
185            file_upload_type,
186            file_id,
187            encrypted_file_name,
188            encrypted_file_buffer,
189        })
190    }
191
192    /// Upload the encrypted file data for a file [Send].
193    ///
194    /// `data` must be the encrypted file content (encrypted with the send key).
195    /// `encrypted_file_name` is the encrypted file name string from [`CreateFileSendResponse`].
196    ///
197    /// `file_upload_type` and `upload_url` come from [`CreateFileSendResponse`] and select the
198    /// upload backend:
199    /// - [`FileUploadType::Direct`] uploads directly to the Bitwarden server via a multipart POST.
200    ///   The `upload_url` is ignored in this case (the direct endpoint is derived from the send and
201    ///   file IDs).
202    /// - [`FileUploadType::Azure`] `PUT`s the ciphertext to the pre-signed Azure Blob Storage
203    ///   `upload_url`. If that URL has expired, it is renewed once via
204    ///   [`SendClient::renew_file_upload_url`] and the upload is retried.
205    pub async fn upload_send_file(
206        &self,
207        send_id: SendId,
208        file_id: String,
209        encrypted_file_name: String,
210        file_upload_type: FileUploadType,
211        upload_url: String,
212        data: Vec<u8>,
213    ) -> Result<(), UploadSendFileError> {
214        match file_upload_type {
215            FileUploadType::Direct => {
216                self.upload_send_file_direct(send_id, file_id, encrypted_file_name, data)
217                    .await
218            }
219            FileUploadType::Azure => {
220                self.upload_send_file_azure(send_id, file_id, upload_url, data)
221                    .await
222            }
223        }
224    }
225
226    /// Direct upload: multipart `POST` of the encrypted bytes to the Bitwarden server.
227    async fn upload_send_file_direct(
228        &self,
229        send_id: SendId,
230        file_id: String,
231        encrypted_file_name: String,
232        data: Vec<u8>,
233    ) -> Result<(), UploadSendFileError> {
234        let config = self.client.internal.get_api_configurations();
235
236        let url = format!(
237            "{}/sends/{}/file/{}",
238            config.api_config.base_path,
239            bitwarden_api_base::urlencode(send_id.to_string()),
240            bitwarden_api_base::urlencode(&file_id),
241        );
242
243        let part = reqwest::multipart::Part::bytes(data)
244            .file_name(encrypted_file_name)
245            .mime_str("application/octet-stream")
246            .map_err(UploadSendFileError::Reqwest)?;
247
248        let form = reqwest::multipart::Form::new().part("data", part);
249
250        let req_builder = config
251            .api_config
252            .client
253            .post(url)
254            .with_extension(AuthRequired::Bearer)
255            .multipart(form);
256
257        bitwarden_api_base::process_with_empty_response(req_builder).await?;
258
259        Ok(())
260    }
261
262    /// Azure upload: `PUT` the encrypted bytes to the pre-signed blob `upload_url`, retrying once
263    /// with a freshly renewed URL if the first attempt fails (the pre-signed URL is short-lived).
264    async fn upload_send_file_azure(
265        &self,
266        send_id: SendId,
267        file_id: String,
268        upload_url: String,
269        data: Vec<u8>,
270    ) -> Result<(), UploadSendFileError> {
271        match self.put_azure_blob(&upload_url, data.clone()).await {
272            Ok(()) => Ok(()),
273            // The pre-signed URL is short-lived; a failure is most likely an expired SAS token.
274            // Renew it once and retry before surfacing the error.
275            Err(_) => {
276                let renewed = self.renew_file_upload_url(send_id, file_id).await?;
277                self.put_azure_blob(&renewed, data).await
278            }
279        }
280    }
281
282    /// Single-blob `PUT` to an Azure Blob Storage pre-signed URL.
283    ///
284    /// Mirrors the TS client's `azureUploadBlob` single-shot path (see
285    /// `apps`/`libs/common/.../azure-file-upload.service.ts`): the `x-ms-blob-type: BlockBlob`
286    /// header is required by Azure for a whole-blob PUT, and `x-ms-version` is taken from the SAS
287    /// URL's `sv` query parameter when present. Send files are always well under the 256 MiB
288    /// single-blob limit, so the block-staging path is intentionally not reproduced.
289    async fn put_azure_blob(&self, url: &str, data: Vec<u8>) -> Result<(), UploadSendFileError> {
290        let config = self.client.internal.get_api_configurations();
291
292        let mut req = config
293            .api_config
294            .client
295            .put(url)
296            .header("x-ms-blob-type", "BlockBlob")
297            .header("content-type", "application/octet-stream");
298
299        // Azure requires the storage-service version for the SAS token; the SAS URL carries it in
300        // the `sv` query parameter. Forward it so the PUT is validated against the same version the
301        // server signed for.
302        if let Some(version) = reqwest::Url::parse(url).ok().and_then(|u| {
303            u.query_pairs()
304                .find(|(k, _)| k == "sv")
305                .map(|(_, v)| v.into_owned())
306        }) {
307            req = req.header("x-ms-version", version);
308        }
309
310        let req = req.body(data);
311
312        bitwarden_api_base::process_with_empty_response(req).await?;
313
314        Ok(())
315    }
316
317    /// Renew the upload URL for a file [Send].
318    ///
319    /// Returns a fresh upload URL if the previous one has expired.
320    pub async fn renew_file_upload_url(
321        &self,
322        send_id: SendId,
323        file_id: String,
324    ) -> Result<String, RenewFileUploadUrlError> {
325        let config = self.client.internal.get_api_configurations();
326
327        let resp = config
328            .api_client
329            .sends_api()
330            .renew_file_upload(&send_id.to_string(), &file_id)
331            .await?;
332
333        Ok(require!(resp.url))
334    }
335}
336
337/// Encrypt `buffer` under the send key wrapped in `wrapped_send_key` (the `key` field of a
338/// [`bitwarden_api_api::models::SendRequestModel`], which is the send key encrypted under the user
339/// key). Mirrors [`SendClient::encrypt_buffer`], but sources the key from the just-built create
340/// request instead of a round-tripped [`Send`], so the create request can carry the true ciphertext
341/// length before the send exists on the server.
342fn encrypt_file_buffer_with_send_key(
343    key_store: &bitwarden_crypto::KeyStore<bitwarden_core::key_management::KeySlotIds>,
344    wrapped_send_key: &str,
345    buffer: &[u8],
346) -> Result<Vec<u8>, CryptoError> {
347    let wrapped_send_key: EncString = wrapped_send_key.parse()?;
348    let mut ctx = key_store.context();
349    let send_key = Send::get_key(&mut ctx, &wrapped_send_key, SymmetricKeySlotId::User)?;
350    let encrypted = OctetStreamBytes::from(buffer).encrypt(&mut ctx, send_key)?;
351    encrypted.to_buffer()
352}
353
354#[cfg(test)]
355mod tests {
356    use std::sync::Arc;
357
358    use bitwarden_api_api::models::{
359        FileUploadType, SendFileModel, SendFileUploadDataResponseModel, SendResponseModel,
360    };
361    use bitwarden_core::{
362        Client, ClientSettings, DeviceType,
363        key_management::{KeySlotIds, SymmetricKeySlotId},
364    };
365    use bitwarden_crypto::{KeyStore, SymmetricKeyAlgorithm};
366    use bitwarden_state::repository::Repository;
367    use bitwarden_test::{MemoryRepository, start_api_mock};
368    use uuid::uuid;
369    use wiremock::{
370        Mock, MockServer, ResponseTemplate,
371        matchers::{method, path, path_regex},
372    };
373
374    use super::*;
375    use crate::{AuthType, Send, SendAuthType, SendClientExt, SendId, SendType, SendViewType};
376
377    const SEND_ID: &str = "25afb11c-9c95-4db5-8bac-c21cb204a3f1";
378    const FILE_ID: &str = "file-id-abc";
379
380    /// Builds a [`Client`] whose API and identity URLs point at the supplied wiremock server,
381    /// then registers a [`MemoryRepository`] for [`Send`] and installs a fresh symmetric key
382    /// in the user slot so that encrypt/decrypt round-trips succeed.
383    fn make_test_client(server: &MockServer) -> (Client, Arc<MemoryRepository<Send>>) {
384        let settings = ClientSettings {
385            identity_url: server.uri(),
386            api_url: server.uri(),
387            user_agent: "Bitwarden Test".into(),
388            device_type: DeviceType::SDK,
389            device_identifier: None,
390            bitwarden_client_version: None,
391            bitwarden_package_type: None,
392        };
393        let client = Client::new(Some(settings));
394
395        // Seed the user key slot so SendAddRequest::encrypt_composite can wrap the send key.
396        {
397            let key_store: &KeyStore<KeySlotIds> = client.internal.get_key_store();
398            let mut ctx = key_store.context_mut();
399            let local_key_id = ctx.make_symmetric_key(SymmetricKeyAlgorithm::Aes256CbcHmac);
400            ctx.persist_symmetric_key(local_key_id, SymmetricKeySlotId::User)
401                .unwrap();
402        }
403
404        let repository = Arc::new(MemoryRepository::<Send>::default());
405        client
406            .platform()
407            .state()
408            .register_client_managed(repository.clone());
409
410        (client, repository)
411    }
412
413    fn sample_request() -> SendAddRequest {
414        SendAddRequest {
415            name: "test-file-send".to_string(),
416            notes: None,
417            // File create requests carry a `File` view type with `size: None` (the CLI leaves it
418            // unset; the server derives the real size from the uploaded blob). The encrypted-buffer
419            // length is threaded separately as `file_length` inside `create_file_send`.
420            view_type: SendViewType::File(crate::SendFileView {
421                id: None,
422                file_name: "secret.txt".to_string(),
423                size: None,
424                size_name: None,
425            }),
426            max_access_count: None,
427            disabled: false,
428            hide_email: false,
429            deletion_date: "2025-01-10T00:00:00Z".parse().unwrap(),
430            expiration_date: None,
431            auth: SendAuthType::None,
432        }
433    }
434
435    /// Build a [`SendResponseModel`] that echoes the encrypted `name`/`key` from the request,
436    /// adds a server-assigned file ID, and is a valid input for `Send::try_from`.
437    fn echo_file_send_response(
438        request: bitwarden_api_api::models::SendRequestModel,
439    ) -> SendResponseModel {
440        let encrypted_name = request.name.clone();
441        SendResponseModel {
442            id: Some(uuid!("25afb11c-9c95-4db5-8bac-c21cb204a3f1")),
443            name: request.name,
444            revision_date: Some("2025-01-01T00:00:00Z".to_string()),
445            object: Some("send".to_string()),
446            access_id: None,
447            r#type: Some(bitwarden_api_api::models::SendType::File),
448            auth_type: Some(bitwarden_api_api::models::AuthType::None),
449            notes: request.notes,
450            file: Some(Box::new(SendFileModel {
451                id: Some(FILE_ID.to_string()),
452                // Re-use the encrypted name as a stand-in for an encrypted file_name; the
453                // SDK only requires that this be a parseable EncString.
454                file_name: encrypted_name,
455                size: Some("123".to_string()),
456                size_name: Some("123 B".to_string()),
457            })),
458            text: None,
459            data: None,
460            key: Some(request.key),
461            max_access_count: request.max_access_count,
462            access_count: Some(0),
463            password: request.password,
464            emails: request.emails,
465            disabled: Some(request.disabled),
466            expiration_date: request.expiration_date,
467            deletion_date: Some(request.deletion_date),
468            hide_email: request.hide_email,
469        }
470    }
471
472    // ===== create_file_send =====
473
474    #[tokio::test]
475    async fn test_create_file_send() {
476        let upload_url = "https://upload.example.com/abc";
477        // Capture the create request body so we can assert on `file_length` and `file.size`.
478        let captured: Arc<std::sync::Mutex<Option<bitwarden_api_api::models::SendRequestModel>>> =
479            Arc::new(std::sync::Mutex::new(None));
480        let captured_mock = captured.clone();
481        let mock = Mock::given(method("POST"))
482            .and(path("/sends/file/v2"))
483            .respond_with(move |req: &wiremock::Request| {
484                let body: bitwarden_api_api::models::SendRequestModel =
485                    serde_json::from_slice(&req.body).expect("request body should be valid JSON");
486                *captured_mock.lock().unwrap() = Some(body.clone());
487                let send_response = echo_file_send_response(body);
488                let response = SendFileUploadDataResponseModel {
489                    object: Some("send-fileUpload".to_string()),
490                    url: Some(upload_url.to_string()),
491                    file_upload_type: Some(FileUploadType::Azure),
492                    send_response: Some(Box::new(send_response)),
493                };
494                ResponseTemplate::new(200).set_body_json(&response)
495            });
496
497        let (server, _config) = start_api_mock(vec![mock]).await;
498        let (client, repository) = make_test_client(&server);
499
500        let plaintext = b"the quick brown fox".to_vec();
501        let result = client
502            .sends()
503            .create_file_send(sample_request(), plaintext)
504            .await
505            .unwrap();
506
507        assert_eq!(result.url, upload_url);
508        assert_eq!(result.file_upload_type, crate::FileUploadType::Azure);
509        assert_eq!(result.file_id, FILE_ID);
510        assert!(!result.encrypted_file_name.is_empty());
511        assert_eq!(result.send.id, Some(SendId::new(SEND_ID.parse().unwrap())));
512        assert_eq!(result.send.name, "test-file-send");
513        assert_eq!(result.send.r#type, SendType::File);
514        assert_eq!(result.send.auth_type, AuthType::None);
515
516        // The create request must carry the *encrypted* buffer length as `file_length`, and must
517        // NOT set `file.size` (the server derives that from the uploaded blob). This mirrors the
518        // legacy client: `new SendRequest(sendData, encBuffer.byteLength)` with no `file.size`.
519        let request_body = captured.lock().unwrap().clone().expect("request captured");
520        assert_eq!(
521            request_body.file_length,
522            Some(result.encrypted_file_buffer.len() as i64),
523            "file_length must equal the encrypted buffer length"
524        );
525        assert!(
526            request_body.file.and_then(|f| f.size).is_none(),
527            "file.size must not be set on the create request"
528        );
529
530        // The returned encrypted buffer must be non-empty and longer than the plaintext (the
531        // EncString framing adds an IV + MAC), confirming the bytes were actually encrypted.
532        assert!(result.encrypted_file_buffer.len() > b"the quick brown fox".len());
533
534        // The created send should have been persisted to the repository.
535        let stored: Option<Send> = repository
536            .get(SendId::new(SEND_ID.parse().unwrap()))
537            .await
538            .unwrap();
539        assert!(stored.is_some(), "send should be stored in the repository");
540    }
541
542    #[tokio::test]
543    async fn test_create_file_send_errors_when_file_upload_type_missing() {
544        let mock = Mock::given(method("POST"))
545            .and(path("/sends/file/v2"))
546            .respond_with(move |req: &wiremock::Request| {
547                let body: bitwarden_api_api::models::SendRequestModel =
548                    serde_json::from_slice(&req.body).unwrap();
549                let send_response = echo_file_send_response(body);
550                let response = SendFileUploadDataResponseModel {
551                    object: Some("send-fileUpload".to_string()),
552                    url: Some("https://upload.example.com/abc".to_string()),
553                    // Omit file_upload_type — the SDK must not silently default to a
554                    // backend that could route bytes to the wrong place.
555                    file_upload_type: None,
556                    send_response: Some(Box::new(send_response)),
557                };
558                ResponseTemplate::new(200).set_body_json(&response)
559            });
560
561        let (server, _config) = start_api_mock(vec![mock]).await;
562        let (client, _repository) = make_test_client(&server);
563
564        let err = client
565            .sends()
566            .create_file_send(sample_request(), b"file-bytes".to_vec())
567            .await
568            .unwrap_err();
569
570        assert!(matches!(err, CreateFileSendError::MissingField(_)));
571    }
572
573    #[tokio::test]
574    async fn test_create_file_send_http_error() {
575        let mock = Mock::given(method("POST"))
576            .and(path("/sends/file/v2"))
577            .respond_with(ResponseTemplate::new(500));
578
579        let (server, _config) = start_api_mock(vec![mock]).await;
580        let (client, _repository) = make_test_client(&server);
581
582        let err = client
583            .sends()
584            .create_file_send(sample_request(), b"file-bytes".to_vec())
585            .await
586            .unwrap_err();
587
588        assert!(matches!(err, CreateFileSendError::Api(_)));
589    }
590
591    #[tokio::test]
592    async fn test_create_file_send_empty_email_list_validation() {
593        // No HTTP mock needed: validation should fail before the request is sent.
594        let server = MockServer::start().await;
595        let (client, _repository) = make_test_client(&server);
596
597        let mut request = sample_request();
598        request.auth = SendAuthType::Emails { emails: vec![] };
599
600        let err = client
601            .sends()
602            .create_file_send(request, b"file-bytes".to_vec())
603            .await
604            .unwrap_err();
605
606        assert!(matches!(err, CreateFileSendError::EmptyEmailList(_)));
607        assert!(
608            server.received_requests().await.unwrap().is_empty(),
609            "validation failure should short-circuit before any HTTP call"
610        );
611    }
612
613    // ===== upload_send_file =====
614
615    #[tokio::test]
616    async fn test_upload_send_file() {
617        let send_id = SendId::new(SEND_ID.parse().unwrap());
618        let file_id = FILE_ID.to_string();
619        let encrypted_file_name = "2.encrypted-name|abc|def".to_string();
620        let data = b"encrypted-file-bytes".to_vec();
621
622        let mock = Mock::given(method("POST"))
623            .and(path(format!("/sends/{}/file/{}", SEND_ID, FILE_ID)))
624            .respond_with(ResponseTemplate::new(200));
625
626        let (server, _config) = start_api_mock(vec![mock]).await;
627        let (client, _repository) = make_test_client(&server);
628
629        client
630            .sends()
631            .upload_send_file(
632                send_id,
633                file_id,
634                encrypted_file_name,
635                crate::FileUploadType::Direct,
636                // Direct uploads ignore the upload URL; it is derived from the send/file IDs.
637                "https://ignored.example.com".to_string(),
638                data.clone(),
639            )
640            .await
641            .unwrap();
642
643        // Verify the multipart request looked right.
644        let requests = server.received_requests().await.unwrap();
645        assert_eq!(requests.len(), 1);
646        let req = &requests[0];
647        let content_type = req
648            .headers
649            .get("content-type")
650            .map(|v| v.to_str().unwrap())
651            .unwrap_or_default();
652        assert!(
653            content_type.starts_with("multipart/form-data"),
654            "expected multipart/form-data, got {content_type}"
655        );
656        // The encrypted bytes should appear verbatim in the multipart body.
657        let body = &req.body;
658        assert!(
659            body.windows(data.len()).any(|w| w == data.as_slice()),
660            "request body should contain the encrypted file bytes"
661        );
662        // The form part name and file_name should be embedded in the multipart headers.
663        let body_str = String::from_utf8_lossy(body);
664        assert!(
665            body_str.contains("name=\"data\""),
666            "multipart should include the 'data' part name"
667        );
668        assert!(
669            body_str.contains("filename=\"2.encrypted-name|abc|def\""),
670            "multipart should include the encrypted file name as the part's filename"
671        );
672    }
673
674    #[tokio::test]
675    async fn test_upload_send_file_http_error() {
676        let send_id = SendId::new(SEND_ID.parse().unwrap());
677        let file_id = FILE_ID.to_string();
678
679        let mock = Mock::given(method("POST"))
680            .and(path(format!("/sends/{}/file/{}", SEND_ID, FILE_ID)))
681            .respond_with(ResponseTemplate::new(500));
682
683        let (server, _config) = start_api_mock(vec![mock]).await;
684        let (client, _repository) = make_test_client(&server);
685
686        let err = client
687            .sends()
688            .upload_send_file(
689                send_id,
690                file_id,
691                "encrypted-name".to_string(),
692                crate::FileUploadType::Direct,
693                "https://ignored.example.com".to_string(),
694                b"data".to_vec(),
695            )
696            .await
697            .unwrap_err();
698
699        assert!(matches!(err, UploadSendFileError::Api(_)));
700    }
701
702    /// Azure happy path: the encrypted bytes are `PUT` to the pre-signed blob URL (pointed at the
703    /// mock server) with the `x-ms-blob-type: BlockBlob` header and the `sv`-derived
704    /// `x-ms-version` header. Azure returns 201 Created on success.
705    #[tokio::test]
706    async fn test_upload_send_file_azure() {
707        let send_id = SendId::new(SEND_ID.parse().unwrap());
708        let file_id = FILE_ID.to_string();
709        let data = b"encrypted-file-bytes".to_vec();
710
711        // A pre-signed blob path with a `sv` (storage version) query param, as Azure SAS URLs
712        // carry. `{server}` is substituted below once the mock server URI is known.
713        let blob_path = "/container/blob";
714
715        let mock = Mock::given(method("PUT"))
716            .and(path(blob_path))
717            .respond_with(move |req: &wiremock::Request| {
718                // The blob-type header is mandatory for a whole-blob PUT.
719                assert_eq!(
720                    req.headers
721                        .get("x-ms-blob-type")
722                        .map(|v| v.to_str().unwrap()),
723                    Some("BlockBlob")
724                );
725                // The `sv` query param must be forwarded as `x-ms-version`.
726                assert_eq!(
727                    req.headers.get("x-ms-version").map(|v| v.to_str().unwrap()),
728                    Some("2024-01-01")
729                );
730                // The encrypted bytes are the raw request body (no multipart wrapping).
731                assert_eq!(req.body, b"encrypted-file-bytes");
732                ResponseTemplate::new(201)
733            });
734
735        let (server, _config) = start_api_mock(vec![mock]).await;
736        let (client, _repository) = make_test_client(&server);
737
738        let upload_url = format!("{}{}?sv=2024-01-01&sig=abc", server.uri(), blob_path);
739
740        client
741            .sends()
742            .upload_send_file(
743                send_id,
744                file_id,
745                "encrypted-name".to_string(),
746                crate::FileUploadType::Azure,
747                upload_url,
748                data,
749            )
750            .await
751            .unwrap();
752    }
753
754    /// Azure retry path: the first `PUT` to the (expired) pre-signed URL fails, the SDK renews the
755    /// URL via `GET /sends/{id}/file/{fileId}`, and retries the `PUT` against the renewed URL,
756    /// which succeeds.
757    #[tokio::test]
758    async fn test_upload_send_file_azure_renews_and_retries_on_failure() {
759        let send_id = SendId::new(SEND_ID.parse().unwrap());
760        let file_id = FILE_ID.to_string();
761        let data = b"encrypted-file-bytes".to_vec();
762
763        // Start a bare server so we know its URI before building the renewal response (which must
764        // point the renewed blob URL back at this same server).
765        let (server, _config) = start_api_mock(vec![]).await;
766
767        // First PUT (the "expired" URL) returns 403; the renewed PUT returns 201.
768        Mock::given(method("PUT"))
769            .and(path("/container/expired"))
770            .respond_with(ResponseTemplate::new(403))
771            .expect(1)
772            .mount(&server)
773            .await;
774
775        Mock::given(method("PUT"))
776            .and(path("/container/renewed"))
777            .respond_with(ResponseTemplate::new(201))
778            .expect(1)
779            .mount(&server)
780            .await;
781
782        // Renewal call: GET /sends/{id}/file/{fileId} returns the fresh blob URL.
783        let renewed_url = format!("{}/container/renewed?sv=2024-01-01&sig=fresh", server.uri());
784        Mock::given(method("GET"))
785            .and(path_regex(r"^/sends/[a-f0-9-]+/file/[^/]+$"))
786            .respond_with(move |_req: &wiremock::Request| {
787                let response = SendFileUploadDataResponseModel {
788                    object: Some("send-fileUpload".to_string()),
789                    url: Some(renewed_url.clone()),
790                    file_upload_type: Some(FileUploadType::Azure),
791                    send_response: None,
792                };
793                ResponseTemplate::new(200).set_body_json(&response)
794            })
795            .expect(1)
796            .mount(&server)
797            .await;
798
799        let (client, _repository) = make_test_client(&server);
800
801        let expired_url = format!("{}/container/expired?sv=2024-01-01&sig=old", server.uri());
802
803        client
804            .sends()
805            .upload_send_file(
806                send_id,
807                file_id,
808                "encrypted-name".to_string(),
809                crate::FileUploadType::Azure,
810                expired_url,
811                data,
812            )
813            .await
814            .unwrap();
815    }
816
817    // ===== renew_file_upload_url =====
818
819    #[tokio::test]
820    async fn test_renew_file_upload_url() {
821        let send_id = SendId::new(SEND_ID.parse().unwrap());
822        let file_id = FILE_ID.to_string();
823        let new_url = "https://upload.example.com/renewed";
824
825        // The generated client issues a GET against /sends/{id}/file/{fileId}.
826        let mock = Mock::given(method("GET"))
827            .and(path_regex(r"^/sends/[a-f0-9-]+/file/[^/]+$"))
828            .respond_with(move |req: &wiremock::Request| {
829                // Sanity-check the path arguments were threaded through correctly.
830                assert!(
831                    req.url
832                        .path()
833                        .ends_with(&format!("/sends/{}/file/{}", SEND_ID, FILE_ID))
834                );
835                let response = SendFileUploadDataResponseModel {
836                    object: Some("send-fileUpload".to_string()),
837                    url: Some(new_url.to_string()),
838                    file_upload_type: Some(FileUploadType::Azure),
839                    send_response: None,
840                };
841                ResponseTemplate::new(200).set_body_json(&response)
842            });
843
844        let (server, _config) = start_api_mock(vec![mock]).await;
845        let (client, _repository) = make_test_client(&server);
846
847        let url = client
848            .sends()
849            .renew_file_upload_url(send_id, file_id)
850            .await
851            .unwrap();
852
853        assert_eq!(url, new_url);
854    }
855
856    #[tokio::test]
857    async fn test_renew_file_upload_url_missing_url() {
858        let send_id = SendId::new(SEND_ID.parse().unwrap());
859        let file_id = FILE_ID.to_string();
860
861        // Server returns a 200 but omits the url field; this exercises the require!(resp.url)
862        // branch which maps to MissingField.
863        let mock = Mock::given(method("GET"))
864            .and(path_regex(r"^/sends/[a-f0-9-]+/file/[^/]+$"))
865            .respond_with(ResponseTemplate::new(200).set_body_json(
866                &SendFileUploadDataResponseModel {
867                    object: Some("send-fileUpload".to_string()),
868                    url: None,
869                    file_upload_type: None,
870                    send_response: None,
871                },
872            ));
873
874        let (server, _config) = start_api_mock(vec![mock]).await;
875        let (client, _repository) = make_test_client(&server);
876
877        let err = client
878            .sends()
879            .renew_file_upload_url(send_id, file_id)
880            .await
881            .unwrap_err();
882
883        assert!(matches!(err, RenewFileUploadUrlError::MissingField(_)));
884    }
885
886    #[tokio::test]
887    async fn test_renew_file_upload_url_http_error() {
888        let send_id = SendId::new(SEND_ID.parse().unwrap());
889        let file_id = FILE_ID.to_string();
890
891        let mock = Mock::given(method("GET"))
892            .and(path_regex(r"^/sends/[a-f0-9-]+/file/[^/]+$"))
893            .respond_with(ResponseTemplate::new(500));
894
895        let (server, _config) = start_api_mock(vec![mock]).await;
896        let (client, _repository) = make_test_client(&server);
897
898        let err = client
899            .sends()
900            .renew_file_upload_url(send_id, file_id)
901            .await
902            .unwrap_err();
903
904        assert!(matches!(err, RenewFileUploadUrlError::Api(_)));
905    }
906}