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