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, data) = 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            data,
171            password,
172            emails,
173            disabled: self.request.disabled,
174            hide_email: Some(self.request.hide_email),
175        })
176    }
177}
178
179impl IdentifyKey<SymmetricKeySlotId> for SendEditRequestWithKey {
180    fn key_identifier(&self) -> SymmetricKeySlotId {
181        SymmetricKeySlotId::User
182    }
183}
184
185async fn edit_send<R: Repository<Send> + ?Sized>(
186    key_store: &KeyStore<KeySlotIds>,
187    api_client: &bitwarden_api_api::apis::ApiClient,
188    repository: &R,
189    send_id: SendId,
190    request: SendEditRequest,
191) -> Result<SendView, EditSendError> {
192    let id = send_id.to_string();
193
194    let existing_send = repository.get(send_id).await?.ok_or(ItemNotFoundError)?;
195
196    let resolved_auth = match &request.auth {
197        AuthEdit::Set { auth } => {
198            auth.validate()?;
199            ResolvedAuth::Overwrite(auth.clone())
200        }
201        AuthEdit::Preserve => ResolvedAuth::Preserve(existing_send.auth_type),
202    };
203
204    // Decrypt to get the key - we only need the key field
205    let existing_send_view: SendView = key_store.decrypt(&existing_send)?;
206    let send_key = existing_send_view.key.ok_or(MissingFieldError("key"))?;
207
208    // Create the wrapper with the key from the existing send
209    let request_with_key = SendEditRequestWithKey {
210        request,
211        send_key,
212        resolved_auth,
213    };
214
215    let send_request = key_store.encrypt(request_with_key)?;
216
217    let resp = api_client.sends_api().put(&id, Some(send_request)).await?;
218
219    let send: Send = resp.try_into()?;
220
221    // Verify the server returned the correct send ID
222    if send.id != Some(send_id) {
223        return Err(EditSendError::IdMismatch {
224            expected: send_id.into(),
225            returned: send.id.map(Into::into),
226        });
227    }
228
229    repository.set(send_id, send.clone()).await?;
230
231    Ok(key_store.decrypt(&send)?)
232}
233
234#[cfg_attr(feature = "wasm", wasm_bindgen)]
235impl SendClient {
236    /// Edit the [Send] and save it to the server.
237    pub async fn edit(
238        &self,
239        send_id: SendId,
240        request: SendEditRequest,
241    ) -> Result<SendView, EditSendError> {
242        let key_store = self.client.internal.get_key_store();
243        let config = self.client.internal.get_api_configurations();
244        let repository = self.get_repository()?;
245
246        edit_send(
247            key_store,
248            &config.api_client,
249            repository.as_ref(),
250            send_id,
251            request,
252        )
253        .await
254    }
255}
256
257#[cfg(test)]
258mod tests {
259    use bitwarden_api_api::{apis::ApiClient, models::SendResponseModel};
260    use bitwarden_core::key_management::SymmetricKeySlotId;
261    use bitwarden_crypto::SymmetricKeyAlgorithm;
262    use bitwarden_test::MemoryRepository;
263    use chrono::{DateTime, Utc};
264    use uuid::uuid;
265
266    use super::*;
267    use crate::{AuthType, SendTextView, SendType, SendViewType};
268
269    // Pins the wire shape of `AuthEdit`. Both `AuthEdit` and `SendAuthType` are
270    // internally tagged with `#[serde(tag = "type")]`; the `Set { auth: ... }` struct
271    // variant keeps those tags in separate scopes. A tuple variant `Set(SendAuthType)`
272    // would flatten the inner tag into the outer object and produce a duplicate `"type"`
273    // key — this test catches that if anyone refactors back.
274    #[test]
275    fn auth_edit_round_trips_through_json_without_duplicate_type_keys() {
276        let cases = [
277            (AuthEdit::Preserve, serde_json::json!({"type": "preserve"})),
278            (
279                AuthEdit::Set {
280                    auth: SendAuthType::None,
281                },
282                serde_json::json!({"type": "set", "auth": {"type": "none"}}),
283            ),
284            (
285                AuthEdit::Set {
286                    auth: SendAuthType::Password {
287                        password: "hunter2".to_string(),
288                    },
289                },
290                serde_json::json!({
291                    "type": "set",
292                    "auth": {"type": "password", "password": "hunter2"}
293                }),
294            ),
295            (
296                AuthEdit::Set {
297                    auth: SendAuthType::Emails {
298                        emails: vec!["[email protected]".to_string(), "[email protected]".to_string()],
299                    },
300                },
301                serde_json::json!({
302                    "type": "set",
303                    "auth": {"type": "emails", "emails": ["[email protected]", "[email protected]"]}
304                }),
305            ),
306        ];
307        for (value, expected_json) in cases {
308            let serialized = serde_json::to_value(&value).expect("serialize");
309            assert_eq!(
310                serialized, expected_json,
311                "wire shape mismatch for {value:?}"
312            );
313            let deserialized: AuthEdit = serde_json::from_value(serialized).expect("round-trip");
314            assert_eq!(deserialized, value, "round-trip mismatch for {value:?}");
315        }
316    }
317
318    #[tokio::test]
319    async fn test_edit_send() {
320        let store: KeyStore<KeySlotIds> = KeyStore::default();
321        {
322            let mut ctx = store.context_mut();
323            let local_key_id = ctx.make_symmetric_key(SymmetricKeyAlgorithm::Aes256CbcHmac);
324            ctx.persist_symmetric_key(local_key_id, SymmetricKeySlotId::User)
325                .unwrap();
326        }
327
328        let send_id = uuid!("25afb11c-9c95-4db5-8bac-c21cb204a3f1");
329
330        // Pre-populate the repository with an existing send by encrypting a SendView
331        let repository = MemoryRepository::<Send>::default();
332        let existing_send_view = SendView {
333            id: None, // No ID initially to allow key generation
334            access_id: None,
335            name: "original".to_string(),
336            notes: Some("original notes".to_string()),
337            key: None, // Generates a new key when first encrypted
338            new_password: None,
339            has_password: false,
340            r#type: SendType::Text,
341            file: None,
342            text: Some(SendTextView {
343                text: Some("original text".to_string()),
344                hidden: false,
345            }),
346            data: None,
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            data: None,
515            max_access_count: None,
516            access_count: 0,
517            disabled: false,
518            hide_email: false,
519            revision_date: "2025-01-01T00:00:00Z".parse().unwrap(),
520            deletion_date: "2025-01-10T00:00:00Z".parse().unwrap(),
521            expiration_date: None,
522            emails: Vec::new(),
523            auth_type: AuthType::None,
524        };
525        let mut existing_send = store.encrypt(existing_send_view).unwrap();
526        existing_send.id = Some(crate::send::SendId::new(send_id)); // Set the ID after encryption
527        repository
528            .set(SendId::new(send_id), existing_send)
529            .await
530            .unwrap();
531
532        let api_client = ApiClient::new_mocked(move |mock| {
533            mock.sends_api
534                .expect_put()
535                .returning(move |_id, _model| Err(std::io::Error::other("Simulated error").into()));
536        });
537
538        let result = edit_send(
539            &store,
540            &api_client,
541            &repository,
542            SendId::new(send_id),
543            SendEditRequest {
544                name: "test".to_string(),
545                notes: None,
546                view_type: SendViewType::Text(SendTextView {
547                    text: Some("test".to_string()),
548                    hidden: false,
549                }),
550                max_access_count: None,
551                disabled: false,
552                hide_email: false,
553                deletion_date: "2025-01-10T00:00:00Z".parse().unwrap(),
554                expiration_date: None,
555                auth: AuthEdit::Set {
556                    auth: SendAuthType::None,
557                },
558            },
559        )
560        .await;
561
562        assert!(result.is_err());
563        assert!(matches!(result.unwrap_err(), EditSendError::Api(_)));
564    }
565
566    // Builds a fixture with the given `password_hash`, `emails`, and `auth_type` patched
567    // onto the encrypted `Send` row. Goes around `encrypt(SendView)` because `SendView`
568    // doesn't expose the wire-format password/emails fields that preserve-mode tests need
569    // to assert against.
570    async fn make_fixture_with_existing_auth(
571        send_id: uuid::Uuid,
572        password_hash: Option<String>,
573        emails: Option<String>,
574        auth_type: AuthType,
575    ) -> (KeyStore<KeySlotIds>, MemoryRepository<Send>) {
576        let store: KeyStore<KeySlotIds> = KeyStore::default();
577        {
578            let mut ctx = store.context_mut();
579            let local_key_id = ctx.make_symmetric_key(SymmetricKeyAlgorithm::Aes256CbcHmac);
580            ctx.persist_symmetric_key(local_key_id, SymmetricKeySlotId::User)
581                .unwrap();
582        }
583
584        // Encrypt a baseline view to get realistic name/text/key ciphertext, then patch the
585        // wire-format auth fields onto the encrypted row.
586        let baseline = SendView {
587            id: None,
588            access_id: None,
589            name: "original".to_string(),
590            notes: None,
591            key: None,
592            new_password: None,
593            has_password: false,
594            r#type: SendType::Text,
595            file: None,
596            text: Some(SendTextView {
597                text: Some("secret".to_string()),
598                hidden: false,
599            }),
600            data: None,
601            max_access_count: None,
602            access_count: 0,
603            disabled: false,
604            hide_email: false,
605            revision_date: "2025-01-01T00:00:00Z".parse().unwrap(),
606            deletion_date: "2025-01-10T00:00:00Z".parse().unwrap(),
607            expiration_date: None,
608            emails: Vec::new(),
609            auth_type: AuthType::None,
610        };
611        let mut existing_send = store.encrypt(baseline).unwrap();
612        existing_send.id = Some(crate::send::SendId::new(send_id));
613        existing_send.password = password_hash;
614        existing_send.emails = emails;
615        existing_send.auth_type = auth_type;
616
617        let repository = MemoryRepository::<Send>::default();
618        repository
619            .set(SendId::new(send_id), existing_send)
620            .await
621            .unwrap();
622
623        (store, repository)
624    }
625
626    // Drives `edit_send` and captures the `SendRequestModel` sent to the server.
627    async fn capture_edit_put_model(
628        store: &KeyStore<KeySlotIds>,
629        repository: &MemoryRepository<Send>,
630        send_id: uuid::Uuid,
631        request: SendEditRequest,
632    ) -> bitwarden_api_api::models::SendRequestModel {
633        let captured: std::sync::Arc<
634            std::sync::Mutex<Option<bitwarden_api_api::models::SendRequestModel>>,
635        > = std::sync::Arc::new(std::sync::Mutex::new(None));
636        let sink = captured.clone();
637
638        let api_client = ApiClient::new_mocked(move |mock| {
639            let sink = sink.clone();
640            mock.sends_api
641                .expect_put()
642                .returning(move |_id, model| {
643                    let model = model.unwrap();
644                    *sink.lock().unwrap() = Some(model.clone());
645                    Ok(SendResponseModel {
646                        id: Some(send_id),
647                        name: model.name.clone(),
648                        revision_date: Some("2025-01-02T00:00:00Z".to_string()),
649                        object: Some("send".to_string()),
650                        access_id: None,
651                        r#type: model.r#type,
652                        auth_type: model.auth_type,
653                        notes: model.notes.clone(),
654                        file: model.file.clone(),
655                        text: model.text.clone(),
656                        data: model.data.clone(),
657                        key: Some(model.key.clone()),
658                        max_access_count: model.max_access_count,
659                        access_count: Some(0),
660                        password: model.password.clone(),
661                        emails: model.emails.clone(),
662                        disabled: Some(model.disabled),
663                        expiration_date: model.expiration_date.clone(),
664                        deletion_date: Some(model.deletion_date.clone()),
665                        hide_email: model.hide_email,
666                    })
667                })
668                .once();
669        });
670
671        edit_send(
672            store,
673            &api_client,
674            repository,
675            SendId::new(send_id),
676            request,
677        )
678        .await
679        .unwrap();
680
681        captured.lock().unwrap().take().expect("PUT was not called")
682    }
683
684    // Regression test for the auth-strip bug: `AuthEdit::Preserve` must forward only
685    // the existing `authType` so the server retains the stored password hash.
686    #[tokio::test]
687    async fn test_edit_preserves_existing_password_when_auth_is_none() {
688        let send_id = uuid!("25afb11c-9c95-4db5-8bac-c21cb204a3f1");
689        let existing_hash = "abc123hashstub==".to_string();
690        let (store, repository) =
691            make_fixture_with_existing_auth(send_id, Some(existing_hash), None, AuthType::Password)
692                .await;
693
694        let model = capture_edit_put_model(
695            &store,
696            &repository,
697            send_id,
698            SendEditRequest {
699                name: "updated".to_string(),
700                notes: None,
701                view_type: SendViewType::Text(SendTextView {
702                    text: Some("secret".to_string()),
703                    hidden: false,
704                }),
705                max_access_count: None,
706                disabled: false,
707                hide_email: false,
708                deletion_date: "2025-01-10T00:00:00Z".parse().unwrap(),
709                expiration_date: None,
710                auth: AuthEdit::Preserve,
711            },
712        )
713        .await;
714
715        assert_eq!(
716            model.password, None,
717            "preserve mode must omit the password hash so the server retains the stored value",
718        );
719        assert_eq!(model.emails, None);
720        assert_eq!(
721            model.auth_type,
722            Some(bitwarden_api_api::models::AuthType::Password),
723            "preserve mode must forward the existing authType so the server doesn't clear auth",
724        );
725    }
726
727    #[tokio::test]
728    async fn test_edit_preserves_existing_emails_when_auth_is_none() {
729        let send_id = uuid!("25afb11c-9c95-4db5-8bac-c21cb204a3f1");
730        let existing_emails = "[email protected],[email protected]".to_string();
731        let (store, repository) =
732            make_fixture_with_existing_auth(send_id, None, Some(existing_emails), AuthType::Email)
733                .await;
734
735        let model = capture_edit_put_model(
736            &store,
737            &repository,
738            send_id,
739            SendEditRequest {
740                name: "updated".to_string(),
741                notes: None,
742                view_type: SendViewType::Text(SendTextView {
743                    text: Some("secret".to_string()),
744                    hidden: false,
745                }),
746                max_access_count: None,
747                disabled: false,
748                hide_email: false,
749                deletion_date: "2025-01-10T00:00:00Z".parse().unwrap(),
750                expiration_date: None,
751                auth: AuthEdit::Preserve,
752            },
753        )
754        .await;
755
756        assert_eq!(model.password, None);
757        assert_eq!(
758            model.emails, None,
759            "preserve mode must omit the email list so the server retains the stored value",
760        );
761        assert_eq!(
762            model.auth_type,
763            Some(bitwarden_api_api::models::AuthType::Email),
764            "preserve mode must forward the existing authType so the server doesn't clear auth",
765        );
766    }
767
768    #[tokio::test]
769    async fn test_edit_preserves_no_auth_when_auth_is_none() {
770        let send_id = uuid!("25afb11c-9c95-4db5-8bac-c21cb204a3f1");
771        let (store, repository) =
772            make_fixture_with_existing_auth(send_id, None, None, AuthType::None).await;
773
774        let model = capture_edit_put_model(
775            &store,
776            &repository,
777            send_id,
778            SendEditRequest {
779                name: "updated".to_string(),
780                notes: None,
781                view_type: SendViewType::Text(SendTextView {
782                    text: Some("secret".to_string()),
783                    hidden: false,
784                }),
785                max_access_count: None,
786                disabled: false,
787                hide_email: false,
788                deletion_date: "2025-01-10T00:00:00Z".parse().unwrap(),
789                expiration_date: None,
790                auth: AuthEdit::Preserve,
791            },
792        )
793        .await;
794
795        assert_eq!(model.password, None);
796        assert_eq!(model.emails, None);
797        assert_eq!(
798            model.auth_type,
799            Some(bitwarden_api_api::models::AuthType::None),
800        );
801    }
802
803    // `AuthEdit::Set { auth: SendAuthType::None }` is the escape hatch for deliberately
804    // stripping auth — distinct from `Preserve`, which leaves existing auth in place.
805    #[tokio::test]
806    async fn test_edit_explicit_auth_none_overrides_existing_password() {
807        let send_id = uuid!("25afb11c-9c95-4db5-8bac-c21cb204a3f1");
808        let (store, repository) = make_fixture_with_existing_auth(
809            send_id,
810            Some("existing-hash".to_string()),
811            None,
812            AuthType::Password,
813        )
814        .await;
815
816        let model = capture_edit_put_model(
817            &store,
818            &repository,
819            send_id,
820            SendEditRequest {
821                name: "updated".to_string(),
822                notes: None,
823                view_type: SendViewType::Text(SendTextView {
824                    text: Some("secret".to_string()),
825                    hidden: false,
826                }),
827                max_access_count: None,
828                disabled: false,
829                hide_email: false,
830                deletion_date: "2025-01-10T00:00:00Z".parse().unwrap(),
831                expiration_date: None,
832                auth: AuthEdit::Set {
833                    auth: SendAuthType::None,
834                },
835            },
836        )
837        .await;
838
839        assert_eq!(model.password, None);
840        assert_eq!(model.emails, None);
841        assert_eq!(
842            model.auth_type,
843            Some(bitwarden_api_api::models::AuthType::None),
844        );
845    }
846}