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;
15#[cfg(feature = "wasm")]
16use tsify::Tsify;
17use uuid::Uuid;
18#[cfg(feature = "wasm")]
19use wasm_bindgen::prelude::*;
20
21use crate::{
22    EmptyEmailListError, Send, SendAuthType, SendId, SendView, SendViewType,
23    error::{ItemNotFoundError, SendParseError},
24    send_client::SendClient,
25};
26
27#[allow(missing_docs)]
28#[bitwarden_error(flat)]
29#[derive(Debug, Error)]
30pub enum EditSendError {
31    #[error(transparent)]
32    ItemNotFound(#[from] ItemNotFoundError),
33    #[error(transparent)]
34    Crypto(#[from] CryptoError),
35    #[error(transparent)]
36    Api(#[from] ApiError),
37    #[error(transparent)]
38    EmptyEmailList(#[from] EmptyEmailListError),
39    #[error(transparent)]
40    MissingField(#[from] MissingFieldError),
41    #[error(transparent)]
42    Repository(#[from] RepositoryError),
43    #[error(transparent)]
44    Uuid(#[from] uuid::Error),
45    #[error(transparent)]
46    SendParse(#[from] SendParseError),
47    #[error("Server returned Send with ID {returned:?} but expected {expected}")]
48    IdMismatch {
49        expected: Uuid,
50        returned: Option<Uuid>,
51    },
52}
53
54/// Controls how `bw send edit` updates the auth on an existing Send.
55#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
56#[serde(tag = "type", rename_all = "camelCase")]
57#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
58#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
59pub enum AuthEdit {
60    /// Keep the existing auth on the Send.
61    Preserve,
62    /// Replace the existing auth. Pass `SendAuthType::None` to strip auth entirely.
63    Set {
64        /// The new auth configuration to apply.
65        auth: SendAuthType,
66    },
67}
68
69/// Request model for editing an existing Send.
70#[derive(Serialize, Deserialize, Debug)]
71#[serde(rename_all = "camelCase")]
72#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
73pub struct SendEditRequest {
74    /// The name of the Send.
75    pub name: String,
76    /// Optional notes visible to the sender.
77    pub notes: Option<String>,
78
79    /// The type and content of the Send.
80    pub view_type: SendViewType,
81
82    /// Maximum number of times the Send can be accessed.
83    pub max_access_count: Option<u32>,
84    /// Whether the Send is disabled and cannot be accessed.
85    pub disabled: bool,
86    /// Whether to hide the sender's email from recipients.
87    pub hide_email: bool,
88
89    /// Date and time when the Send will be permanently deleted.
90    pub deletion_date: DateTime<Utc>,
91    /// Optional date and time when the Send expires and can no longer be accessed.
92    pub expiration_date: Option<DateTime<Utc>>,
93
94    /// Authentication for accessing this Send. Use `AuthEdit::Preserve` to keep the
95    /// existing auth on partial edits.
96    pub auth: AuthEdit,
97}
98
99/// Internal helper carrying the send key and resolved auth needed for encryption.
100#[derive(Debug)]
101struct SendEditRequestWithKey {
102    request: SendEditRequest,
103    send_key: String,
104    resolved_auth: ResolvedAuth,
105}
106
107/// Resolved auth for an encrypted edit request, built at the `edit_send` boundary.
108#[derive(Debug)]
109enum ResolvedAuth {
110    /// Write this `SendAuthType` verbatim. Already `validate()`-d.
111    Overwrite(SendAuthType),
112    /// Forward the existing `authType` only. The server retains the stored password
113    /// hash and email list when `password`/`emails` are omitted from the request.
114    Preserve(crate::AuthType),
115}
116
117impl
118    CompositeEncryptable<
119        KeySlotIds,
120        SymmetricKeySlotId,
121        bitwarden_api_api::models::SendRequestModel,
122    > for SendEditRequestWithKey
123{
124    fn encrypt_composite(
125        &self,
126        ctx: &mut KeyStoreContext<KeySlotIds>,
127        key: SymmetricKeySlotId,
128    ) -> Result<bitwarden_api_api::models::SendRequestModel, CryptoError> {
129        // Decode the send key from the existing send
130        let k = B64Url::try_from(self.send_key.as_str())
131            .map_err(|_| CryptoError::InvalidKey)?
132            .as_bytes()
133            .to_vec();
134
135        let send_key = Send::derive_shareable_key(ctx, &k)?;
136
137        let (send_type, file, text) = self
138            .request
139            .view_type
140            .clone()
141            .encrypt_composite(ctx, send_key)?;
142
143        let (auth_type, password, emails) = match &self.resolved_auth {
144            ResolvedAuth::Overwrite(auth) => {
145                let (password, emails) = auth.auth_data(&k);
146                (auth.auth_type(), password, emails)
147            }
148            ResolvedAuth::Preserve(auth_type) => (*auth_type, None, None),
149        };
150
151        Ok(bitwarden_api_api::models::SendRequestModel {
152            r#type: Some(send_type),
153            auth_type: Some(auth_type.into()),
154            file_length: None,
155            name: Some(self.request.name.encrypt(ctx, send_key)?.to_string()),
156            notes: self
157                .request
158                .notes
159                .as_ref()
160                .map(|n| n.encrypt(ctx, send_key))
161                .transpose()?
162                .map(|e| e.to_string()),
163            // Encrypt the send key itself with the user key
164            key: OctetStreamBytes::from(k).encrypt(ctx, key)?.to_string(),
165            max_access_count: self.request.max_access_count.map(|c| c as i32),
166            expiration_date: self.request.expiration_date.map(|d| d.to_rfc3339()),
167            deletion_date: self.request.deletion_date.to_rfc3339(),
168            file,
169            text,
170            // TODO: Implement logic for item-based Sends
171            data: None,
172            password,
173            emails,
174            disabled: self.request.disabled,
175            hide_email: Some(self.request.hide_email),
176        })
177    }
178}
179
180impl IdentifyKey<SymmetricKeySlotId> for SendEditRequestWithKey {
181    fn key_identifier(&self) -> SymmetricKeySlotId {
182        SymmetricKeySlotId::User
183    }
184}
185
186async fn edit_send<R: Repository<Send> + ?Sized>(
187    key_store: &KeyStore<KeySlotIds>,
188    api_client: &bitwarden_api_api::apis::ApiClient,
189    repository: &R,
190    send_id: SendId,
191    request: SendEditRequest,
192) -> Result<SendView, EditSendError> {
193    let id = send_id.to_string();
194
195    let existing_send = repository.get(send_id).await?.ok_or(ItemNotFoundError)?;
196
197    let resolved_auth = match &request.auth {
198        AuthEdit::Set { auth } => {
199            auth.validate()?;
200            ResolvedAuth::Overwrite(auth.clone())
201        }
202        AuthEdit::Preserve => ResolvedAuth::Preserve(existing_send.auth_type),
203    };
204
205    // Decrypt to get the key - we only need the key field
206    let existing_send_view: SendView = key_store.decrypt(&existing_send)?;
207    let send_key = existing_send_view.key.ok_or(MissingFieldError("key"))?;
208
209    // Create the wrapper with the key from the existing send
210    let request_with_key = SendEditRequestWithKey {
211        request,
212        send_key,
213        resolved_auth,
214    };
215
216    let send_request = key_store.encrypt(request_with_key)?;
217
218    let resp = api_client.sends_api().put(&id, Some(send_request)).await?;
219
220    let send: Send = resp.try_into()?;
221
222    // Verify the server returned the correct send ID
223    if send.id != Some(send_id) {
224        return Err(EditSendError::IdMismatch {
225            expected: send_id.into(),
226            returned: send.id.map(Into::into),
227        });
228    }
229
230    repository.set(send_id, send.clone()).await?;
231
232    Ok(key_store.decrypt(&send)?)
233}
234
235#[cfg_attr(feature = "wasm", wasm_bindgen)]
236impl SendClient {
237    /// Edit the [Send] and save it to the server.
238    pub async fn edit(
239        &self,
240        send_id: SendId,
241        request: SendEditRequest,
242    ) -> Result<SendView, EditSendError> {
243        let key_store = self.client.internal.get_key_store();
244        let config = self.client.internal.get_api_configurations();
245        let repository = self.get_repository()?;
246
247        edit_send(
248            key_store,
249            &config.api_client,
250            repository.as_ref(),
251            send_id,
252            request,
253        )
254        .await
255    }
256}
257
258#[cfg(test)]
259mod tests {
260    use bitwarden_api_api::{apis::ApiClient, models::SendResponseModel};
261    use bitwarden_core::key_management::SymmetricKeySlotId;
262    use bitwarden_crypto::SymmetricKeyAlgorithm;
263    use bitwarden_test::MemoryRepository;
264    use chrono::{DateTime, Utc};
265    use uuid::uuid;
266
267    use super::*;
268    use crate::{AuthType, SendTextView, SendType, SendViewType};
269
270    // Pins the wire shape of `AuthEdit`. Both `AuthEdit` and `SendAuthType` are
271    // internally tagged with `#[serde(tag = "type")]`; the `Set { auth: ... }` struct
272    // variant keeps those tags in separate scopes. A tuple variant `Set(SendAuthType)`
273    // would flatten the inner tag into the outer object and produce a duplicate `"type"`
274    // key — this test catches that if anyone refactors back.
275    #[test]
276    fn auth_edit_round_trips_through_json_without_duplicate_type_keys() {
277        let cases = [
278            (AuthEdit::Preserve, serde_json::json!({"type": "preserve"})),
279            (
280                AuthEdit::Set {
281                    auth: SendAuthType::None,
282                },
283                serde_json::json!({"type": "set", "auth": {"type": "none"}}),
284            ),
285            (
286                AuthEdit::Set {
287                    auth: SendAuthType::Password {
288                        password: "hunter2".to_string(),
289                    },
290                },
291                serde_json::json!({
292                    "type": "set",
293                    "auth": {"type": "password", "password": "hunter2"}
294                }),
295            ),
296            (
297                AuthEdit::Set {
298                    auth: SendAuthType::Emails {
299                        emails: vec!["[email protected]".to_string(), "[email protected]".to_string()],
300                    },
301                },
302                serde_json::json!({
303                    "type": "set",
304                    "auth": {"type": "emails", "emails": ["[email protected]", "[email protected]"]}
305                }),
306            ),
307        ];
308        for (value, expected_json) in cases {
309            let serialized = serde_json::to_value(&value).expect("serialize");
310            assert_eq!(
311                serialized, expected_json,
312                "wire shape mismatch for {value:?}"
313            );
314            let deserialized: AuthEdit = serde_json::from_value(serialized).expect("round-trip");
315            assert_eq!(deserialized, value, "round-trip mismatch for {value:?}");
316        }
317    }
318
319    #[tokio::test]
320    async fn test_edit_send() {
321        let store: KeyStore<KeySlotIds> = KeyStore::default();
322        {
323            let mut ctx = store.context_mut();
324            let local_key_id = ctx.make_symmetric_key(SymmetricKeyAlgorithm::Aes256CbcHmac);
325            ctx.persist_symmetric_key(local_key_id, SymmetricKeySlotId::User)
326                .unwrap();
327        }
328
329        let send_id = uuid!("25afb11c-9c95-4db5-8bac-c21cb204a3f1");
330
331        // Pre-populate the repository with an existing send by encrypting a SendView
332        let repository = MemoryRepository::<Send>::default();
333        let existing_send_view = SendView {
334            id: None, // No ID initially to allow key generation
335            access_id: None,
336            name: "original".to_string(),
337            notes: Some("original notes".to_string()),
338            key: None, // Generates a new key when first encrypted
339            new_password: None,
340            has_password: false,
341            r#type: SendType::Text,
342            file: None,
343            text: Some(SendTextView {
344                text: Some("original text".to_string()),
345                hidden: false,
346            }),
347            max_access_count: None,
348            access_count: 0,
349            disabled: false,
350            hide_email: false,
351            revision_date: "2025-01-01T00:00:00Z".parse().unwrap(),
352            deletion_date: "2025-01-10T00:00:00Z".parse().unwrap(),
353            expiration_date: None,
354            emails: Vec::new(),
355            auth_type: AuthType::None,
356        };
357        let mut existing_send = store.encrypt(existing_send_view).unwrap();
358        existing_send.id = Some(crate::send::SendId::new(send_id)); // Set the ID after encryption
359        repository
360            .set(SendId::new(send_id), existing_send)
361            .await
362            .unwrap();
363
364        let api_client = ApiClient::new_mocked(move |mock| {
365            mock.sends_api
366                .expect_put()
367                .returning(move |_id, model| {
368                    let model = model.unwrap();
369                    Ok(SendResponseModel {
370                        id: Some(send_id),
371                        name: model.name,
372                        revision_date: Some("2025-01-02T00:00:00Z".to_string()),
373                        object: Some("send".to_string()),
374                        access_id: None,
375                        r#type: model.r#type,
376                        auth_type: model.auth_type,
377                        notes: model.notes,
378                        file: model.file,
379                        text: model.text,
380                        data: model.data,
381                        key: Some(model.key),
382                        max_access_count: model.max_access_count,
383                        access_count: Some(0),
384                        password: model.password,
385                        emails: model.emails,
386                        disabled: Some(model.disabled),
387                        expiration_date: model.expiration_date,
388                        deletion_date: Some(model.deletion_date),
389                        hide_email: model.hide_email,
390                    })
391                })
392                .once();
393        });
394
395        let result = edit_send(
396            &store,
397            &api_client,
398            &repository,
399            SendId::new(send_id),
400            SendEditRequest {
401                name: "updated".to_string(),
402                notes: Some("updated notes".to_string()),
403                view_type: SendViewType::Text(SendTextView {
404                    text: Some("updated text".to_string()),
405                    hidden: false,
406                }),
407                max_access_count: None,
408                disabled: false,
409                hide_email: false,
410                deletion_date: "2025-01-10T00:00:00Z".parse().unwrap(),
411                expiration_date: None,
412                auth: AuthEdit::Set {
413                    auth: SendAuthType::None,
414                },
415            },
416        )
417        .await
418        .unwrap();
419
420        // Verify the result
421        assert_eq!(result.id, Some(crate::send::SendId::new(send_id)));
422        assert_eq!(result.name, "updated");
423        assert_eq!(result.notes, Some("updated notes".to_string()));
424        assert!(result.key.is_some(), "Expected a key");
425        assert_eq!(
426            result.revision_date,
427            "2025-01-02T00:00:00Z".parse::<DateTime<Utc>>().unwrap()
428        );
429
430        // Confirm the send was updated in the repository
431        let stored = repository.get(SendId::new(send_id)).await.unwrap().unwrap();
432        assert_eq!(
433            store
434                .decrypt::<SymmetricKeySlotId, Send, SendView>(&stored)
435                .unwrap()
436                .name,
437            "updated"
438        );
439    }
440
441    #[tokio::test]
442    async fn test_edit_send_not_found() {
443        let store: KeyStore<KeySlotIds> = KeyStore::default();
444        {
445            let mut ctx = store.context_mut();
446            let local_key_id = ctx.make_symmetric_key(SymmetricKeyAlgorithm::Aes256CbcHmac);
447            ctx.persist_symmetric_key(local_key_id, SymmetricKeySlotId::User)
448                .unwrap();
449        }
450
451        let send_id = uuid!("25afb11c-9c95-4db5-8bac-c21cb204a3f1");
452        let repository = MemoryRepository::<Send>::default();
453        let api_client = ApiClient::new_mocked(move |_mock| {});
454
455        let result = edit_send(
456            &store,
457            &api_client,
458            &repository,
459            SendId::new(send_id),
460            SendEditRequest {
461                name: "test".to_string(),
462                notes: None,
463                view_type: SendViewType::Text(SendTextView {
464                    text: Some("test".to_string()),
465                    hidden: false,
466                }),
467                max_access_count: None,
468                disabled: false,
469                hide_email: false,
470                deletion_date: "2025-01-10T00:00:00Z".parse().unwrap(),
471                expiration_date: None,
472                auth: AuthEdit::Set {
473                    auth: SendAuthType::None,
474                },
475            },
476        )
477        .await;
478
479        assert!(result.is_err());
480        assert!(matches!(
481            result.unwrap_err(),
482            EditSendError::ItemNotFound(_)
483        ));
484    }
485
486    #[tokio::test]
487    async fn test_edit_send_http_error() {
488        let store: KeyStore<KeySlotIds> = KeyStore::default();
489        {
490            let mut ctx = store.context_mut();
491            let local_key_id = ctx.make_symmetric_key(SymmetricKeyAlgorithm::Aes256CbcHmac);
492            ctx.persist_symmetric_key(local_key_id, SymmetricKeySlotId::User)
493                .unwrap();
494        }
495
496        let send_id = uuid!("25afb11c-9c95-4db5-8bac-c21cb204a3f1");
497
498        // Pre-populate the repository with an existing send by encrypting a SendView
499        let repository = MemoryRepository::<Send>::default();
500        let existing_send_view = SendView {
501            id: None, // No ID initially to allow key generation
502            access_id: None,
503            name: "original".to_string(),
504            notes: Some("original notes".to_string()),
505            key: None, // Generates a new key when first encrypted
506            new_password: None,
507            has_password: false,
508            r#type: SendType::Text,
509            file: None,
510            text: Some(SendTextView {
511                text: Some("original text".to_string()),
512                hidden: false,
513            }),
514            max_access_count: None,
515            access_count: 0,
516            disabled: false,
517            hide_email: false,
518            revision_date: "2025-01-01T00:00:00Z".parse().unwrap(),
519            deletion_date: "2025-01-10T00:00:00Z".parse().unwrap(),
520            expiration_date: None,
521            emails: Vec::new(),
522            auth_type: AuthType::None,
523        };
524        let mut existing_send = store.encrypt(existing_send_view).unwrap();
525        existing_send.id = Some(crate::send::SendId::new(send_id)); // Set the ID after encryption
526        repository
527            .set(SendId::new(send_id), existing_send)
528            .await
529            .unwrap();
530
531        let api_client = ApiClient::new_mocked(move |mock| {
532            mock.sends_api
533                .expect_put()
534                .returning(move |_id, _model| Err(std::io::Error::other("Simulated error").into()));
535        });
536
537        let result = edit_send(
538            &store,
539            &api_client,
540            &repository,
541            SendId::new(send_id),
542            SendEditRequest {
543                name: "test".to_string(),
544                notes: None,
545                view_type: SendViewType::Text(SendTextView {
546                    text: Some("test".to_string()),
547                    hidden: false,
548                }),
549                max_access_count: None,
550                disabled: false,
551                hide_email: false,
552                deletion_date: "2025-01-10T00:00:00Z".parse().unwrap(),
553                expiration_date: None,
554                auth: AuthEdit::Set {
555                    auth: SendAuthType::None,
556                },
557            },
558        )
559        .await;
560
561        assert!(result.is_err());
562        assert!(matches!(result.unwrap_err(), EditSendError::Api(_)));
563    }
564
565    // Builds a fixture with the given `password_hash`, `emails`, and `auth_type` patched
566    // onto the encrypted `Send` row. Goes around `encrypt(SendView)` because `SendView`
567    // doesn't expose the wire-format password/emails fields that preserve-mode tests need
568    // to assert against.
569    async fn make_fixture_with_existing_auth(
570        send_id: uuid::Uuid,
571        password_hash: Option<String>,
572        emails: Option<String>,
573        auth_type: AuthType,
574    ) -> (KeyStore<KeySlotIds>, MemoryRepository<Send>) {
575        let store: KeyStore<KeySlotIds> = KeyStore::default();
576        {
577            let mut ctx = store.context_mut();
578            let local_key_id = ctx.make_symmetric_key(SymmetricKeyAlgorithm::Aes256CbcHmac);
579            ctx.persist_symmetric_key(local_key_id, SymmetricKeySlotId::User)
580                .unwrap();
581        }
582
583        // Encrypt a baseline view to get realistic name/text/key ciphertext, then patch the
584        // wire-format auth fields onto the encrypted row.
585        let baseline = SendView {
586            id: None,
587            access_id: None,
588            name: "original".to_string(),
589            notes: None,
590            key: None,
591            new_password: None,
592            has_password: false,
593            r#type: SendType::Text,
594            file: None,
595            text: Some(SendTextView {
596                text: Some("secret".to_string()),
597                hidden: false,
598            }),
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}