Skip to main content

bitwarden_send/
edit.rs

1use bitwarden_core::{
2    ApiError, MissingFieldError,
3    key_management::{KeySlotIds, SymmetricKeySlotId},
4};
5use bitwarden_crypto::{
6    CompositeEncryptable, CryptoError, IdentifyKey, KeyStore, KeyStoreContext, OctetStreamBytes,
7    PrimitiveEncryptable,
8};
9use bitwarden_encoding::B64Url;
10use bitwarden_error::bitwarden_error;
11use bitwarden_state::repository::{Repository, RepositoryError};
12use chrono::{DateTime, Utc};
13use serde::{Deserialize, Serialize};
14use thiserror::Error;
15use uuid::Uuid;
16#[cfg(feature = "wasm")]
17use wasm_bindgen::prelude::*;
18
19use crate::{
20    EmptyEmailListError, Send, SendAuthType, SendId, SendView, SendViewType,
21    error::{ItemNotFoundError, SendParseError},
22    send_client::SendClient,
23};
24
25#[allow(missing_docs)]
26#[bitwarden_error(flat)]
27#[derive(Debug, Error)]
28pub enum EditSendError {
29    #[error(transparent)]
30    ItemNotFound(#[from] ItemNotFoundError),
31    #[error(transparent)]
32    Crypto(#[from] CryptoError),
33    #[error(transparent)]
34    Api(#[from] ApiError),
35    #[error(transparent)]
36    EmptyEmailList(#[from] EmptyEmailListError),
37    #[error(transparent)]
38    MissingField(#[from] MissingFieldError),
39    #[error(transparent)]
40    Repository(#[from] RepositoryError),
41    #[error(transparent)]
42    Uuid(#[from] uuid::Error),
43    #[error(transparent)]
44    SendParse(#[from] SendParseError),
45    #[error("Server returned Send with ID {returned:?} but expected {expected}")]
46    IdMismatch {
47        expected: Uuid,
48        returned: Option<Uuid>,
49    },
50}
51
52/// Controls how `bw send edit` updates the auth on an existing Send.
53#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
54#[serde(tag = "type", rename_all = "camelCase")]
55#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
56#[bitwarden_ffi::wasm_record]
57pub enum AuthEdit {
58    /// Keep the existing auth on the Send.
59    Preserve,
60    /// Replace the existing auth. Pass `SendAuthType::None` to strip auth entirely.
61    Set {
62        /// The new auth configuration to apply.
63        auth: SendAuthType,
64    },
65}
66
67/// Request model for editing an existing Send.
68#[derive(Serialize, Deserialize, Debug)]
69#[serde(rename_all = "camelCase")]
70#[bitwarden_ffi::wasm_record]
71pub struct SendEditRequest {
72    /// The name of the Send.
73    pub name: String,
74    /// Optional notes visible to the sender.
75    pub notes: Option<String>,
76
77    /// The type and content of the Send.
78    pub view_type: SendViewType,
79
80    /// Maximum number of times the Send can be accessed.
81    pub max_access_count: Option<u32>,
82    /// Whether the Send is disabled and cannot be accessed.
83    pub disabled: bool,
84    /// Whether to hide the sender's email from recipients.
85    pub hide_email: bool,
86
87    /// Date and time when the Send will be permanently deleted.
88    pub deletion_date: DateTime<Utc>,
89    /// Optional date and time when the Send expires and can no longer be accessed.
90    pub expiration_date: Option<DateTime<Utc>>,
91
92    /// Authentication for accessing this Send. Use `AuthEdit::Preserve` to keep the
93    /// existing auth on partial edits.
94    pub auth: AuthEdit,
95}
96
97/// Internal helper carrying the send key and resolved auth needed for encryption.
98#[derive(Debug)]
99struct SendEditRequestWithKey {
100    request: SendEditRequest,
101    send_key: String,
102    resolved_auth: ResolvedAuth,
103}
104
105/// Resolved auth for an encrypted edit request, built at the `edit_send` boundary.
106#[derive(Debug)]
107enum ResolvedAuth {
108    /// Write this `SendAuthType` verbatim. Already `validate()`-d.
109    Overwrite(SendAuthType),
110    /// Forward the existing `authType` only. The server retains the stored password
111    /// hash and email list when `password`/`emails` are omitted from the request.
112    Preserve(crate::AuthType),
113}
114
115impl
116    CompositeEncryptable<
117        KeySlotIds,
118        SymmetricKeySlotId,
119        bitwarden_api_api::models::SendRequestModel,
120    > for SendEditRequestWithKey
121{
122    fn encrypt_composite(
123        &self,
124        ctx: &mut KeyStoreContext<KeySlotIds>,
125        key: SymmetricKeySlotId,
126    ) -> Result<bitwarden_api_api::models::SendRequestModel, CryptoError> {
127        // Decode the send key from the existing send
128        let k = B64Url::try_from(self.send_key.as_str())
129            .map_err(|_| CryptoError::InvalidKey)?
130            .as_bytes()
131            .to_vec();
132
133        let send_key = Send::derive_shareable_key(ctx, &k)?;
134
135        let (send_type, file, text, data) = self
136            .request
137            .view_type
138            .clone()
139            .encrypt_composite(ctx, send_key)?;
140
141        let (auth_type, password, emails) = match &self.resolved_auth {
142            ResolvedAuth::Overwrite(auth) => {
143                let (password, emails) = auth.auth_data(&k);
144                (auth.auth_type(), password, emails)
145            }
146            ResolvedAuth::Preserve(auth_type) => (*auth_type, None, None),
147        };
148
149        Ok(bitwarden_api_api::models::SendRequestModel {
150            r#type: Some(send_type),
151            auth_type: Some(auth_type.into()),
152            file_length: None,
153            name: Some(self.request.name.encrypt(ctx, send_key)?.to_string()),
154            notes: self
155                .request
156                .notes
157                .as_ref()
158                .map(|n| n.encrypt(ctx, send_key))
159                .transpose()?
160                .map(|e| e.to_string()),
161            // Encrypt the send key itself with the user key
162            key: OctetStreamBytes::from(k).encrypt(ctx, key)?.to_string(),
163            max_access_count: self.request.max_access_count.map(|c| c as i32),
164            expiration_date: self.request.expiration_date.map(|d| d.to_rfc3339()),
165            deletion_date: self.request.deletion_date.to_rfc3339(),
166            file,
167            text,
168            data,
169            password,
170            emails,
171            disabled: self.request.disabled,
172            hide_email: Some(self.request.hide_email),
173        })
174    }
175}
176
177impl IdentifyKey<SymmetricKeySlotId> for SendEditRequestWithKey {
178    fn key_identifier(&self) -> SymmetricKeySlotId {
179        SymmetricKeySlotId::User
180    }
181}
182
183async fn edit_send<R: Repository<Send> + ?Sized>(
184    key_store: &KeyStore<KeySlotIds>,
185    api_client: &bitwarden_api_api::apis::ApiClient,
186    repository: &R,
187    send_id: SendId,
188    request: SendEditRequest,
189) -> Result<SendView, EditSendError> {
190    let id = send_id.to_string();
191
192    let existing_send = repository.get(send_id).await?.ok_or(ItemNotFoundError)?;
193
194    let resolved_auth = match &request.auth {
195        AuthEdit::Set { auth } => {
196            auth.validate()?;
197            ResolvedAuth::Overwrite(auth.clone())
198        }
199        AuthEdit::Preserve => ResolvedAuth::Preserve(existing_send.auth_type),
200    };
201
202    // Decrypt to get the key - we only need the key field
203    let existing_send_view: SendView = key_store.decrypt(&existing_send)?;
204    let send_key = existing_send_view.key.ok_or(MissingFieldError("key"))?;
205
206    // Create the wrapper with the key from the existing send
207    let request_with_key = SendEditRequestWithKey {
208        request,
209        send_key,
210        resolved_auth,
211    };
212
213    let send_request = key_store.encrypt(request_with_key)?;
214
215    let resp = api_client.sends_api().put(&id, Some(send_request)).await?;
216
217    let send: Send = resp.try_into()?;
218
219    // Verify the server returned the correct send ID
220    if send.id != Some(send_id) {
221        return Err(EditSendError::IdMismatch {
222            expected: send_id.into(),
223            returned: send.id.map(Into::into),
224        });
225    }
226
227    repository.set(send_id, send.clone()).await?;
228
229    Ok(key_store.decrypt(&send)?)
230}
231
232#[bitwarden_ffi::wasm_export]
233impl SendClient {
234    /// Edit the [Send] and save it to the server.
235    pub async fn edit(
236        &self,
237        send_id: SendId,
238        request: SendEditRequest,
239    ) -> Result<SendView, EditSendError> {
240        let key_store = self.client.internal.get_key_store();
241        let config = self.client.internal.get_api_configurations();
242        let repository = self.get_repository()?;
243
244        edit_send(
245            key_store,
246            &config.api_client,
247            repository.as_ref(),
248            send_id,
249            request,
250        )
251        .await
252    }
253}
254
255#[cfg(test)]
256mod tests {
257    use bitwarden_api_api::{apis::ApiClient, models::SendResponseModel};
258    use bitwarden_core::key_management::SymmetricKeySlotId;
259    use bitwarden_crypto::SymmetricKeyAlgorithm;
260    use bitwarden_test::MemoryRepository;
261    use chrono::{DateTime, Utc};
262    use uuid::uuid;
263
264    use super::*;
265    use crate::{AuthType, SendTextView, SendType, SendViewType};
266
267    // Pins the wire shape of `AuthEdit`. Both `AuthEdit` and `SendAuthType` are
268    // internally tagged with `#[serde(tag = "type")]`; the `Set { auth: ... }` struct
269    // variant keeps those tags in separate scopes. A tuple variant `Set(SendAuthType)`
270    // would flatten the inner tag into the outer object and produce a duplicate `"type"`
271    // key — this test catches that if anyone refactors back.
272    #[test]
273    fn auth_edit_round_trips_through_json_without_duplicate_type_keys() {
274        let cases = [
275            (AuthEdit::Preserve, serde_json::json!({"type": "preserve"})),
276            (
277                AuthEdit::Set {
278                    auth: SendAuthType::None,
279                },
280                serde_json::json!({"type": "set", "auth": {"type": "none"}}),
281            ),
282            (
283                AuthEdit::Set {
284                    auth: SendAuthType::Password {
285                        password: "hunter2".to_string(),
286                    },
287                },
288                serde_json::json!({
289                    "type": "set",
290                    "auth": {"type": "password", "password": "hunter2"}
291                }),
292            ),
293            (
294                AuthEdit::Set {
295                    auth: SendAuthType::Emails {
296                        emails: vec!["[email protected]".to_string(), "[email protected]".to_string()],
297                    },
298                },
299                serde_json::json!({
300                    "type": "set",
301                    "auth": {"type": "emails", "emails": ["[email protected]", "[email protected]"]}
302                }),
303            ),
304        ];
305        for (value, expected_json) in cases {
306            let serialized = serde_json::to_value(&value).expect("serialize");
307            assert_eq!(
308                serialized, expected_json,
309                "wire shape mismatch for {value:?}"
310            );
311            let deserialized: AuthEdit = serde_json::from_value(serialized).expect("round-trip");
312            assert_eq!(deserialized, value, "round-trip mismatch for {value:?}");
313        }
314    }
315
316    #[tokio::test]
317    async fn test_edit_send() {
318        let store: KeyStore<KeySlotIds> = KeyStore::default();
319        {
320            let mut ctx = store.context_mut();
321            let local_key_id = ctx.make_symmetric_key(SymmetricKeyAlgorithm::Aes256CbcHmac);
322            ctx.persist_symmetric_key(local_key_id, SymmetricKeySlotId::User)
323                .unwrap();
324        }
325
326        let send_id = uuid!("25afb11c-9c95-4db5-8bac-c21cb204a3f1");
327
328        // Pre-populate the repository with an existing send by encrypting a SendView
329        let repository = MemoryRepository::<Send>::default();
330        let existing_send_view = SendView {
331            id: None, // No ID initially to allow key generation
332            access_id: None,
333            name: "original".to_string(),
334            notes: Some("original notes".to_string()),
335            key: None, // Generates a new key when first encrypted
336            new_password: None,
337            has_password: false,
338            r#type: SendType::Text,
339            file: None,
340            text: Some(SendTextView {
341                text: Some("original text".to_string()),
342                hidden: false,
343            }),
344            data: None,
345            max_access_count: None,
346            access_count: 0,
347            disabled: false,
348            hide_email: false,
349            revision_date: "2025-01-01T00:00:00Z".parse().unwrap(),
350            deletion_date: "2025-01-10T00:00:00Z".parse().unwrap(),
351            expiration_date: None,
352            emails: Vec::new(),
353            auth_type: AuthType::None,
354        };
355        let mut existing_send = store.encrypt(existing_send_view).unwrap();
356        existing_send.id = Some(crate::send::SendId::new(send_id)); // Set the ID after encryption
357        repository
358            .set(SendId::new(send_id), existing_send)
359            .await
360            .unwrap();
361
362        let api_client = ApiClient::new_mocked(move |mock| {
363            mock.sends_api
364                .expect_put()
365                .returning(move |_id, model| {
366                    let model = model.unwrap();
367                    Ok(SendResponseModel {
368                        id: Some(send_id),
369                        name: model.name,
370                        revision_date: Some("2025-01-02T00:00:00Z".to_string()),
371                        object: Some("send".to_string()),
372                        access_id: None,
373                        r#type: model.r#type,
374                        auth_type: model.auth_type,
375                        notes: model.notes,
376                        file: model.file,
377                        text: model.text,
378                        data: model.data,
379                        key: Some(model.key),
380                        max_access_count: model.max_access_count,
381                        access_count: Some(0),
382                        password: model.password,
383                        emails: model.emails,
384                        disabled: Some(model.disabled),
385                        expiration_date: model.expiration_date,
386                        deletion_date: Some(model.deletion_date),
387                        hide_email: model.hide_email,
388                    })
389                })
390                .once();
391        });
392
393        let result = edit_send(
394            &store,
395            &api_client,
396            &repository,
397            SendId::new(send_id),
398            SendEditRequest {
399                name: "updated".to_string(),
400                notes: Some("updated notes".to_string()),
401                view_type: SendViewType::Text(SendTextView {
402                    text: Some("updated text".to_string()),
403                    hidden: false,
404                }),
405                max_access_count: None,
406                disabled: false,
407                hide_email: false,
408                deletion_date: "2025-01-10T00:00:00Z".parse().unwrap(),
409                expiration_date: None,
410                auth: AuthEdit::Set {
411                    auth: SendAuthType::None,
412                },
413            },
414        )
415        .await
416        .unwrap();
417
418        // Verify the result
419        assert_eq!(result.id, Some(crate::send::SendId::new(send_id)));
420        assert_eq!(result.name, "updated");
421        assert_eq!(result.notes, Some("updated notes".to_string()));
422        assert!(result.key.is_some(), "Expected a key");
423        assert_eq!(
424            result.revision_date,
425            "2025-01-02T00:00:00Z".parse::<DateTime<Utc>>().unwrap()
426        );
427
428        // Confirm the send was updated in the repository
429        let stored = repository.get(SendId::new(send_id)).await.unwrap().unwrap();
430        assert_eq!(
431            store
432                .decrypt::<SymmetricKeySlotId, Send, SendView>(&stored)
433                .unwrap()
434                .name,
435            "updated"
436        );
437    }
438
439    #[tokio::test]
440    async fn test_edit_send_not_found() {
441        let store: KeyStore<KeySlotIds> = KeyStore::default();
442        {
443            let mut ctx = store.context_mut();
444            let local_key_id = ctx.make_symmetric_key(SymmetricKeyAlgorithm::Aes256CbcHmac);
445            ctx.persist_symmetric_key(local_key_id, SymmetricKeySlotId::User)
446                .unwrap();
447        }
448
449        let send_id = uuid!("25afb11c-9c95-4db5-8bac-c21cb204a3f1");
450        let repository = MemoryRepository::<Send>::default();
451        let api_client = ApiClient::new_mocked(move |_mock| {});
452
453        let result = edit_send(
454            &store,
455            &api_client,
456            &repository,
457            SendId::new(send_id),
458            SendEditRequest {
459                name: "test".to_string(),
460                notes: None,
461                view_type: SendViewType::Text(SendTextView {
462                    text: Some("test".to_string()),
463                    hidden: false,
464                }),
465                max_access_count: None,
466                disabled: false,
467                hide_email: false,
468                deletion_date: "2025-01-10T00:00:00Z".parse().unwrap(),
469                expiration_date: None,
470                auth: AuthEdit::Set {
471                    auth: SendAuthType::None,
472                },
473            },
474        )
475        .await;
476
477        assert!(result.is_err());
478        assert!(matches!(
479            result.unwrap_err(),
480            EditSendError::ItemNotFound(_)
481        ));
482    }
483
484    #[tokio::test]
485    async fn test_edit_send_http_error() {
486        let store: KeyStore<KeySlotIds> = KeyStore::default();
487        {
488            let mut ctx = store.context_mut();
489            let local_key_id = ctx.make_symmetric_key(SymmetricKeyAlgorithm::Aes256CbcHmac);
490            ctx.persist_symmetric_key(local_key_id, SymmetricKeySlotId::User)
491                .unwrap();
492        }
493
494        let send_id = uuid!("25afb11c-9c95-4db5-8bac-c21cb204a3f1");
495
496        // Pre-populate the repository with an existing send by encrypting a SendView
497        let repository = MemoryRepository::<Send>::default();
498        let existing_send_view = SendView {
499            id: None, // No ID initially to allow key generation
500            access_id: None,
501            name: "original".to_string(),
502            notes: Some("original notes".to_string()),
503            key: None, // Generates a new key when first encrypted
504            new_password: None,
505            has_password: false,
506            r#type: SendType::Text,
507            file: None,
508            text: Some(SendTextView {
509                text: Some("original text".to_string()),
510                hidden: false,
511            }),
512            data: None,
513            max_access_count: None,
514            access_count: 0,
515            disabled: false,
516            hide_email: false,
517            revision_date: "2025-01-01T00:00:00Z".parse().unwrap(),
518            deletion_date: "2025-01-10T00:00:00Z".parse().unwrap(),
519            expiration_date: None,
520            emails: Vec::new(),
521            auth_type: AuthType::None,
522        };
523        let mut existing_send = store.encrypt(existing_send_view).unwrap();
524        existing_send.id = Some(crate::send::SendId::new(send_id)); // Set the ID after encryption
525        repository
526            .set(SendId::new(send_id), existing_send)
527            .await
528            .unwrap();
529
530        let api_client = ApiClient::new_mocked(move |mock| {
531            mock.sends_api
532                .expect_put()
533                .returning(move |_id, _model| Err(std::io::Error::other("Simulated error").into()));
534        });
535
536        let result = edit_send(
537            &store,
538            &api_client,
539            &repository,
540            SendId::new(send_id),
541            SendEditRequest {
542                name: "test".to_string(),
543                notes: None,
544                view_type: SendViewType::Text(SendTextView {
545                    text: Some("test".to_string()),
546                    hidden: false,
547                }),
548                max_access_count: None,
549                disabled: false,
550                hide_email: false,
551                deletion_date: "2025-01-10T00:00:00Z".parse().unwrap(),
552                expiration_date: None,
553                auth: AuthEdit::Set {
554                    auth: SendAuthType::None,
555                },
556            },
557        )
558        .await;
559
560        assert!(result.is_err());
561        assert!(matches!(result.unwrap_err(), EditSendError::Api(_)));
562    }
563
564    // Builds a fixture with the given `password_hash`, `emails`, and `auth_type` patched
565    // onto the encrypted `Send` row. Goes around `encrypt(SendView)` because `SendView`
566    // doesn't expose the wire-format password/emails fields that preserve-mode tests need
567    // to assert against.
568    async fn make_fixture_with_existing_auth(
569        send_id: uuid::Uuid,
570        password_hash: Option<String>,
571        emails: Option<String>,
572        auth_type: AuthType,
573    ) -> (KeyStore<KeySlotIds>, MemoryRepository<Send>) {
574        let store: KeyStore<KeySlotIds> = KeyStore::default();
575        {
576            let mut ctx = store.context_mut();
577            let local_key_id = ctx.make_symmetric_key(SymmetricKeyAlgorithm::Aes256CbcHmac);
578            ctx.persist_symmetric_key(local_key_id, SymmetricKeySlotId::User)
579                .unwrap();
580        }
581
582        // Encrypt a baseline view to get realistic name/text/key ciphertext, then patch the
583        // wire-format auth fields onto the encrypted row.
584        let baseline = SendView {
585            id: None,
586            access_id: None,
587            name: "original".to_string(),
588            notes: None,
589            key: None,
590            new_password: None,
591            has_password: false,
592            r#type: SendType::Text,
593            file: None,
594            text: Some(SendTextView {
595                text: Some("secret".to_string()),
596                hidden: false,
597            }),
598            data: None,
599            max_access_count: None,
600            access_count: 0,
601            disabled: false,
602            hide_email: false,
603            revision_date: "2025-01-01T00:00:00Z".parse().unwrap(),
604            deletion_date: "2025-01-10T00:00:00Z".parse().unwrap(),
605            expiration_date: None,
606            emails: Vec::new(),
607            auth_type: AuthType::None,
608        };
609        let mut existing_send = store.encrypt(baseline).unwrap();
610        existing_send.id = Some(crate::send::SendId::new(send_id));
611        existing_send.password = password_hash;
612        existing_send.emails = emails;
613        existing_send.auth_type = auth_type;
614
615        let repository = MemoryRepository::<Send>::default();
616        repository
617            .set(SendId::new(send_id), existing_send)
618            .await
619            .unwrap();
620
621        (store, repository)
622    }
623
624    // Drives `edit_send` and captures the `SendRequestModel` sent to the server.
625    async fn capture_edit_put_model(
626        store: &KeyStore<KeySlotIds>,
627        repository: &MemoryRepository<Send>,
628        send_id: uuid::Uuid,
629        request: SendEditRequest,
630    ) -> bitwarden_api_api::models::SendRequestModel {
631        let captured: std::sync::Arc<
632            std::sync::Mutex<Option<bitwarden_api_api::models::SendRequestModel>>,
633        > = std::sync::Arc::new(std::sync::Mutex::new(None));
634        let sink = captured.clone();
635
636        let api_client = ApiClient::new_mocked(move |mock| {
637            let sink = sink.clone();
638            mock.sends_api
639                .expect_put()
640                .returning(move |_id, model| {
641                    let model = model.unwrap();
642                    *sink.lock().unwrap() = Some(model.clone());
643                    Ok(SendResponseModel {
644                        id: Some(send_id),
645                        name: model.name.clone(),
646                        revision_date: Some("2025-01-02T00:00:00Z".to_string()),
647                        object: Some("send".to_string()),
648                        access_id: None,
649                        r#type: model.r#type,
650                        auth_type: model.auth_type,
651                        notes: model.notes.clone(),
652                        file: model.file.clone(),
653                        text: model.text.clone(),
654                        data: model.data.clone(),
655                        key: Some(model.key.clone()),
656                        max_access_count: model.max_access_count,
657                        access_count: Some(0),
658                        password: model.password.clone(),
659                        emails: model.emails.clone(),
660                        disabled: Some(model.disabled),
661                        expiration_date: model.expiration_date.clone(),
662                        deletion_date: Some(model.deletion_date.clone()),
663                        hide_email: model.hide_email,
664                    })
665                })
666                .once();
667        });
668
669        edit_send(
670            store,
671            &api_client,
672            repository,
673            SendId::new(send_id),
674            request,
675        )
676        .await
677        .unwrap();
678
679        captured.lock().unwrap().take().expect("PUT was not called")
680    }
681
682    // Regression test for the auth-strip bug: `AuthEdit::Preserve` must forward only
683    // the existing `authType` so the server retains the stored password hash.
684    #[tokio::test]
685    async fn test_edit_preserves_existing_password_when_auth_is_none() {
686        let send_id = uuid!("25afb11c-9c95-4db5-8bac-c21cb204a3f1");
687        let existing_hash = "abc123hashstub==".to_string();
688        let (store, repository) =
689            make_fixture_with_existing_auth(send_id, Some(existing_hash), None, AuthType::Password)
690                .await;
691
692        let model = capture_edit_put_model(
693            &store,
694            &repository,
695            send_id,
696            SendEditRequest {
697                name: "updated".to_string(),
698                notes: None,
699                view_type: SendViewType::Text(SendTextView {
700                    text: Some("secret".to_string()),
701                    hidden: false,
702                }),
703                max_access_count: None,
704                disabled: false,
705                hide_email: false,
706                deletion_date: "2025-01-10T00:00:00Z".parse().unwrap(),
707                expiration_date: None,
708                auth: AuthEdit::Preserve,
709            },
710        )
711        .await;
712
713        assert_eq!(
714            model.password, None,
715            "preserve mode must omit the password hash so the server retains the stored value",
716        );
717        assert_eq!(model.emails, None);
718        assert_eq!(
719            model.auth_type,
720            Some(bitwarden_api_api::models::AuthType::Password),
721            "preserve mode must forward the existing authType so the server doesn't clear auth",
722        );
723    }
724
725    #[tokio::test]
726    async fn test_edit_preserves_existing_emails_when_auth_is_none() {
727        let send_id = uuid!("25afb11c-9c95-4db5-8bac-c21cb204a3f1");
728        let existing_emails = "[email protected],[email protected]".to_string();
729        let (store, repository) =
730            make_fixture_with_existing_auth(send_id, None, Some(existing_emails), AuthType::Email)
731                .await;
732
733        let model = capture_edit_put_model(
734            &store,
735            &repository,
736            send_id,
737            SendEditRequest {
738                name: "updated".to_string(),
739                notes: None,
740                view_type: SendViewType::Text(SendTextView {
741                    text: Some("secret".to_string()),
742                    hidden: false,
743                }),
744                max_access_count: None,
745                disabled: false,
746                hide_email: false,
747                deletion_date: "2025-01-10T00:00:00Z".parse().unwrap(),
748                expiration_date: None,
749                auth: AuthEdit::Preserve,
750            },
751        )
752        .await;
753
754        assert_eq!(model.password, None);
755        assert_eq!(
756            model.emails, None,
757            "preserve mode must omit the email list so the server retains the stored value",
758        );
759        assert_eq!(
760            model.auth_type,
761            Some(bitwarden_api_api::models::AuthType::Email),
762            "preserve mode must forward the existing authType so the server doesn't clear auth",
763        );
764    }
765
766    #[tokio::test]
767    async fn test_edit_preserves_no_auth_when_auth_is_none() {
768        let send_id = uuid!("25afb11c-9c95-4db5-8bac-c21cb204a3f1");
769        let (store, repository) =
770            make_fixture_with_existing_auth(send_id, None, None, AuthType::None).await;
771
772        let model = capture_edit_put_model(
773            &store,
774            &repository,
775            send_id,
776            SendEditRequest {
777                name: "updated".to_string(),
778                notes: None,
779                view_type: SendViewType::Text(SendTextView {
780                    text: Some("secret".to_string()),
781                    hidden: false,
782                }),
783                max_access_count: None,
784                disabled: false,
785                hide_email: false,
786                deletion_date: "2025-01-10T00:00:00Z".parse().unwrap(),
787                expiration_date: None,
788                auth: AuthEdit::Preserve,
789            },
790        )
791        .await;
792
793        assert_eq!(model.password, None);
794        assert_eq!(model.emails, None);
795        assert_eq!(
796            model.auth_type,
797            Some(bitwarden_api_api::models::AuthType::None),
798        );
799    }
800
801    // `AuthEdit::Set { auth: SendAuthType::None }` is the escape hatch for deliberately
802    // stripping auth — distinct from `Preserve`, which leaves existing auth in place.
803    #[tokio::test]
804    async fn test_edit_explicit_auth_none_overrides_existing_password() {
805        let send_id = uuid!("25afb11c-9c95-4db5-8bac-c21cb204a3f1");
806        let (store, repository) = make_fixture_with_existing_auth(
807            send_id,
808            Some("existing-hash".to_string()),
809            None,
810            AuthType::Password,
811        )
812        .await;
813
814        let model = capture_edit_put_model(
815            &store,
816            &repository,
817            send_id,
818            SendEditRequest {
819                name: "updated".to_string(),
820                notes: None,
821                view_type: SendViewType::Text(SendTextView {
822                    text: Some("secret".to_string()),
823                    hidden: false,
824                }),
825                max_access_count: None,
826                disabled: false,
827                hide_email: false,
828                deletion_date: "2025-01-10T00:00:00Z".parse().unwrap(),
829                expiration_date: None,
830                auth: AuthEdit::Set {
831                    auth: SendAuthType::None,
832                },
833            },
834        )
835        .await;
836
837        assert_eq!(model.password, None);
838        assert_eq!(model.emails, None);
839        assert_eq!(
840            model.auth_type,
841            Some(bitwarden_api_api::models::AuthType::None),
842        );
843    }
844}