Skip to main content

bitwarden_organization_invite_link/
invite_link_admin_client.rs

1use std::sync::Arc;
2
3use bitwarden_api_api::models::{
4    CreateOrganizationInviteLinkRequestModel, RefreshOrganizationInviteLinkRequestModel,
5    UpdateInviteSupportConfirmRequestModel, UpdateOrganizationInviteLinkRequestModel,
6};
7use bitwarden_core::{
8    ApiError, FromClient, OrganizationId,
9    client::ApiConfigurations,
10    key_management::{KeySlotIds, SymmetricKeySlotId},
11    require,
12};
13use bitwarden_crypto::{EncString, KeyStore};
14use bitwarden_organization_crypto::invite::{Invite, InviteSecret};
15use http::StatusCode;
16
17use crate::{InviteLinkError, OrganizationInviteLink, OrganizationInviteLinkView};
18
19/// Client for organization invite link administrative (organization-key) operations: creating,
20/// refreshing, updating, deleting, and inspecting invite links, and recovering the invite secret.
21#[bitwarden_ffi::wasm_object]
22#[derive(FromClient)]
23pub struct InviteLinkAdminClient {
24    pub(crate) key_store: KeyStore<KeySlotIds>,
25    pub(crate) api_configurations: Arc<ApiConfigurations>,
26}
27
28// The `create`/`refresh` methods delegate to the deprecated `create_invite_link`/
29// `refresh_invite_link`, and the `wasm_bindgen`-generated shims for those deprecated exports call
30// them; both would otherwise emit deprecation warnings from code we cannot annotate individually.
31#[allow(deprecated)]
32#[bitwarden_ffi::wasm_export]
33impl InviteLinkAdminClient {
34    /// Get an existing invite link.
35    pub async fn get(
36        &self,
37        organization_id: OrganizationId,
38    ) -> Result<Option<OrganizationInviteLinkView>, InviteLinkError> {
39        let response = match self
40            .api_configurations
41            .api_client
42            .organization_invite_links_api()
43            .get(organization_id.into())
44            .await
45        {
46            Ok(response) => response,
47            Err(ApiError::Response(rc)) if rc.status == StatusCode::NOT_FOUND => return Ok(None),
48            Err(e) => return Err(e.into()),
49        };
50
51        let mut ctx = self.key_store.context();
52        OrganizationInviteLink::try_from(response)
53            .and_then(|link| link.to_view(&mut ctx))
54            .map(Some)
55    }
56
57    /// Delete an existing invite link.
58    pub async fn delete(&self, organization_id: OrganizationId) -> Result<(), InviteLinkError> {
59        self.api_configurations
60            .api_client
61            .organization_invite_links_api()
62            .delete(organization_id.into())
63            .await?;
64        Ok(())
65    }
66
67    /// Creates a new organization invite link.
68    pub async fn create(
69        &self,
70        organization_id: OrganizationId,
71        allowed_domains: Vec<String>,
72        supports_confirmation: bool,
73    ) -> Result<OrganizationInviteLinkView, InviteLinkError> {
74        if allowed_domains.is_empty() {
75            return Err(InviteLinkError::NoAllowedDomains);
76        }
77
78        let link = self
79            .create_invite_link(organization_id, allowed_domains, supports_confirmation)
80            .await?;
81
82        let mut ctx = self.key_store.context();
83        link.to_view(&mut ctx)
84    }
85
86    /// Refreshes an existing invite link.
87    pub async fn refresh(
88        &self,
89        organization_id: OrganizationId,
90        supports_confirmation: bool,
91    ) -> Result<OrganizationInviteLinkView, InviteLinkError> {
92        let link = self
93            .refresh_invite_link(organization_id, supports_confirmation)
94            .await?;
95
96        let mut ctx = self.key_store.context();
97        link.to_view(&mut ctx)
98    }
99
100    /// Updates the allowed domains for an existing organization invite link.
101    pub async fn update_allowed_domains(
102        &self,
103        organization_id: OrganizationId,
104        allowed_domains: Vec<String>,
105    ) -> Result<OrganizationInviteLinkView, InviteLinkError> {
106        if allowed_domains.is_empty() {
107            return Err(InviteLinkError::NoAllowedDomains);
108        }
109
110        let response = self
111            .api_configurations
112            .api_client
113            .organization_invite_links_api()
114            .update(
115                organization_id.into(),
116                Some(UpdateOrganizationInviteLinkRequestModel { allowed_domains }),
117            )
118            .await?;
119
120        let mut ctx = self.key_store.context();
121        OrganizationInviteLink::try_from(response)?.to_view(&mut ctx)
122    }
123
124    /// Updates whether an existing invite link supports confirmation.
125    ///
126    /// If supports_confirmation is true, users gain immediate access to the organization.
127    /// If supports_confirmation is false, admins need to confirm all new users.
128    ///
129    /// Does not break the URL of the existing invite link.
130    pub async fn update_confirmation(
131        &self,
132        organization_id: OrganizationId,
133        supports_confirmation: bool,
134    ) -> Result<OrganizationInviteLinkView, InviteLinkError> {
135        // Update the existing Invite blob so we don't break the existing URL. We fetch this from
136        // the server so that the client doesn't have to handle the Invite blob directly to
137        // pass it back in - it stays fully internal to the SDK.
138        let existing_link = self
139            .api_configurations
140            .api_client
141            .organization_invite_links_api()
142            .get(organization_id.into())
143            .await?;
144        let invite: Invite = require!(existing_link.invite).parse()?;
145
146        let result = self
147            .set_invite_confirmation(organization_id, invite, supports_confirmation)
148            .await?;
149        let mut ctx = self.key_store.context();
150        result.to_view(&mut ctx)
151    }
152
153    /// Updates whether an existing invite link supports confirmation, re-sealing the given invite
154    /// accordingly and persisting it to the server.
155    ///
156    /// Enabling confirmation re-seals the organization key under the invite's existing invite key;
157    /// disabling it strips that envelope. Either way the invite key, code, and secret are left
158    /// untouched, so links already handed out stay valid. Use
159    /// [`InviteLinkAdminClient::refresh`] instead when the code and secret must be rotated.
160    ///
161    /// # Security
162    /// Only the re-sealed invite is posted to the server; the invite secret is never sent.
163    #[deprecated(
164        note = "Use `update_confirmation`, which returns an `OrganizationInviteLinkView`, instead"
165    )]
166    pub async fn set_invite_confirmation(
167        &self,
168        organization_id: OrganizationId,
169        invite: Invite,
170        supports_confirmation: bool,
171    ) -> Result<OrganizationInviteLink, InviteLinkError> {
172        let mut invite = invite;
173
174        // Confine the (non-Send) key store context to a synchronous scope; the re-sealed invite it
175        // produces is consumed by the request posted after the `.await` below.
176        {
177            let mut ctx = self.key_store.context();
178            let org_key = SymmetricKeySlotId::Organization(organization_id);
179            if supports_confirmation {
180                invite.enable_confirmation(org_key, &mut ctx)?;
181            } else {
182                invite.disable_confirmation();
183            }
184        }
185
186        let response = self
187            .api_configurations
188            .api_client
189            .organization_invite_links_api()
190            .update_invite_support_confirm(
191                organization_id.into(),
192                Some(UpdateInviteSupportConfirmRequestModel {
193                    invite: String::from(&invite),
194                    supports_confirmation: invite.supports_confirmation(),
195                }),
196            )
197            .await?;
198
199        OrganizationInviteLink::try_from(response)
200    }
201
202    /// Creates a new organization invite and posts it to the server, returning the full
203    /// [`OrganizationInviteLink`] persisted by the server.
204    ///
205    /// # Security
206    /// Only the sealed invite is posted to the server; the invite secret is never sent. Use
207    /// [`InviteLinkAdminClient::get_invite_secret`] to recover the secret needed to reconstruct the
208    /// invite link.
209    #[deprecated(note = "Use `create`, which returns an `OrganizationInviteLinkView`, instead")]
210    pub async fn create_invite_link(
211        &self,
212        organization_id: OrganizationId,
213        allowed_domains: Vec<String>,
214        supports_confirmation: bool,
215    ) -> Result<OrganizationInviteLink, InviteLinkError> {
216        let invite = self
217            .make_invite(organization_id, supports_confirmation)
218            .await?;
219
220        let response = self
221            .api_configurations
222            .api_client
223            .organization_invite_links_api()
224            .create(
225                organization_id.into(),
226                Some(CreateOrganizationInviteLinkRequestModel {
227                    allowed_domains,
228                    invite: String::from(&invite),
229                    supports_confirmation: invite.supports_confirmation(),
230                }),
231            )
232            .await?;
233
234        OrganizationInviteLink::try_from(response)
235    }
236
237    /// Refresh an existing invite link.
238    /// This generates a new code and secret.
239    #[deprecated(note = "Use `refresh`, which returns an `OrganizationInviteLinkView`, instead")]
240    pub async fn refresh_invite_link(
241        &self,
242        organization_id: OrganizationId,
243        supports_confirmation: bool,
244    ) -> Result<OrganizationInviteLink, InviteLinkError> {
245        let invite = self
246            .make_invite(organization_id, supports_confirmation)
247            .await?;
248
249        let response = self
250            .api_configurations
251            .api_client
252            .organization_invite_links_api()
253            .refresh(
254                organization_id.into(),
255                Some(RefreshOrganizationInviteLinkRequestModel {
256                    invite: String::from(&invite),
257                    supports_confirmation: invite.supports_confirmation(),
258                }),
259            )
260            .await?;
261
262        OrganizationInviteLink::try_from(response)
263    }
264
265    /// Using the organization key, recovers the [`InviteSecret`] from the invite carried in the
266    /// given [`OrganizationInviteLink`] so an admin can reconstruct the invite link.
267    #[deprecated(
268        note = "Use `create` or `refresh`, which returns an `OrganizationInviteLinkView`, instead"
269    )]
270    #[cfg_attr(feature = "wasm", wasm_bindgen(unchecked_return_type = "InviteSecret"))]
271    pub fn get_invite_secret(
272        &self,
273        organization_id: OrganizationId,
274        invite: Invite,
275    ) -> Result<InviteSecret, InviteLinkError> {
276        let mut ctx = self.key_store.context();
277        let org_key = SymmetricKeySlotId::Organization(organization_id);
278        let invite_key = invite.unseal_invite_key_with_organization_key(org_key, &mut ctx)?;
279        let invite_secret = invite.get_invite_secret(invite_key, &mut ctx)?;
280        Ok(invite_secret)
281    }
282
283    /// Helper function to make a new Invite to be included in a request model.
284    async fn make_invite(
285        &self,
286        organization_id: OrganizationId,
287        supports_confirmation: bool,
288    ) -> Result<Invite, InviteLinkError> {
289        let wrapped_private_key_response = self
290            .api_configurations
291            .api_client
292            .organizations_api()
293            .get_private_key(organization_id.into())
294            .await?;
295
296        let wrapped_private_key: EncString =
297            require!(wrapped_private_key_response.private_key).parse()?;
298
299        let mut ctx = self.key_store.context();
300        let org_key = SymmetricKeySlotId::Organization(organization_id);
301        let (_, mut invite) =
302            Invite::make_for_private_key(org_key, &wrapped_private_key, &mut ctx)?;
303
304        // Invites support confirmation by default; disable if not applicable
305        if !supports_confirmation {
306            invite.disable_confirmation();
307        }
308
309        Ok(invite)
310    }
311}
312
313#[cfg(test)]
314#[allow(deprecated)]
315mod tests {
316    use bitwarden_api_api::{
317        apis::{ApiClient, ResponseContent},
318        models::OrganizationInviteLinkResponseModel,
319    };
320    use bitwarden_core::{
321        Client, client::ApiConfigurations, key_management::create_test_crypto_with_user_and_org_key,
322    };
323    use bitwarden_crypto::{
324        PublicKeyEncryptionAlgorithm, SymmetricCryptoKey, SymmetricKeyAlgorithm,
325    };
326    use bitwarden_encoding::B64;
327
328    use super::*;
329    use crate::InviteLinkClientExt as _;
330
331    fn make_client(org_id: OrganizationId, api_client: ApiClient) -> InviteLinkAdminClient {
332        let user_key = SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac);
333        let org_key = SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac);
334        let key_store = create_test_crypto_with_user_and_org_key(user_key, org_id, org_key);
335        InviteLinkAdminClient {
336            key_store,
337            api_configurations: Arc::new(ApiConfigurations::from_api_client(api_client)),
338        }
339    }
340
341    /// Wraps a fresh private key under the client's organization key and returns the serialized
342    /// [`EncString`], matching what the server's `get_private_key` endpoint would return.
343    fn wrapped_org_private_key(client: &InviteLinkAdminClient, org_id: OrganizationId) -> String {
344        let mut ctx = client.key_store.context();
345        let org_key = SymmetricKeySlotId::Organization(org_id);
346        let private_key = ctx.make_private_key(PublicKeyEncryptionAlgorithm::RsaOaepSha1);
347        ctx.wrap_private_key(org_key, private_key)
348            .unwrap()
349            .to_string()
350    }
351
352    /// Builds the response model an invite-links `create`/`refresh` endpoint would return, echoing
353    /// the posted invite back so it can be parsed into an [`OrganizationInviteLink`].
354    fn echo_link_response(
355        org_id: uuid::Uuid,
356        allowed_domains: Vec<String>,
357        invite: String,
358        supports_confirmation: bool,
359    ) -> OrganizationInviteLinkResponseModel {
360        OrganizationInviteLinkResponseModel {
361            object: None,
362            id: Some(uuid::Uuid::new_v4()),
363            code: Some(uuid::Uuid::new_v4()),
364            organization_id: Some(org_id),
365            allowed_domains: Some(allowed_domains),
366            invite: Some(invite),
367            supports_confirmation: Some(supports_confirmation),
368            creation_date: Some("2024-01-01T00:00:00Z".to_string()),
369        }
370    }
371
372    /// Builds an invite + its secret and the organization public key it binds, all consistent with
373    /// the client's org key.
374    fn build_invite(
375        client: &InviteLinkAdminClient,
376        org_id: OrganizationId,
377    ) -> (InviteSecret, Invite, B64) {
378        let mut ctx = client.key_store.context();
379        let org_key = SymmetricKeySlotId::Organization(org_id);
380        let private_key = ctx.make_private_key(PublicKeyEncryptionAlgorithm::RsaOaepSha1);
381        let org_public_key = B64::from(
382            ctx.get_public_key(private_key)
383                .unwrap()
384                .to_der()
385                .unwrap()
386                .as_ref(),
387        );
388        let wrapped = ctx.wrap_private_key(org_key, private_key).unwrap();
389        let (secret, invite) = Invite::make_for_private_key(org_key, &wrapped, &mut ctx).unwrap();
390        (secret, invite, org_public_key)
391    }
392
393    /// Regenerates the invite-link fixtures used by the WASM integration tests in
394    /// `crates/bitwarden-wasm-internal/integration-tests/tests/org-fixtures.ts`. All five values
395    /// belong together — the invites bind the thumbprint of the public key of the private key they
396    /// wrap — so they must always be copied over as a set.
397    #[tokio::test]
398    #[ignore = "Manual test to generate integration-test fixtures"]
399    async fn generate_integration_test_fixtures() {
400        let org_id: OrganizationId = "1bc9ac1e-f5aa-45f2-94bf-b181009709b8".parse().unwrap();
401        let core = Client::init_test_account(
402            bitwarden_core::client::test_accounts::test_bitwarden_com_account(),
403        )
404        .await;
405        let client = core.invite_link().admin();
406
407        let mut ctx = client.key_store.context();
408        let org_key = SymmetricKeySlotId::Organization(org_id);
409        let private_key = ctx.make_private_key(PublicKeyEncryptionAlgorithm::RsaOaepSha1);
410        let public_key = B64::from(
411            ctx.get_public_key(private_key)
412                .unwrap()
413                .to_der()
414                .unwrap()
415                .as_ref(),
416        );
417        let wrapped = ctx.wrap_private_key(org_key, private_key).unwrap();
418        let (secret, invite) = Invite::make_for_private_key(org_key, &wrapped, &mut ctx).unwrap();
419
420        // The same invite with the organization-key envelope stripped, which drives the acceptance
421        // (rather than self-confirmation) branch. It shares the invite secret and the bound
422        // public-key thumbprint, so one secret and one public key serve both invites.
423        let mut no_confirmation = invite.clone();
424        no_confirmation.disable_confirmation();
425        assert!(invite.supports_confirmation() && !no_confirmation.supports_confirmation());
426
427        println!("TEST_ORG_WRAPPED_PRIVATE_KEY = {}", wrapped.to_string());
428        println!("TEST_ORG_PUBLIC_KEY = {public_key}");
429        println!("TEST_INVITE = {}", String::from(&invite));
430        println!(
431            "TEST_INVITE_NO_CONFIRMATION = {}",
432            String::from(&no_confirmation)
433        );
434        println!("TEST_INVITE_SECRET = {}", String::from(&secret));
435    }
436
437    #[tokio::test]
438    async fn create_posts_and_returns_link_without_confirmation() {
439        let org_id = OrganizationId::new_v4();
440        let wrapped = Arc::new(std::sync::Mutex::new(None::<String>));
441        let for_mock = wrapped.clone();
442        let client = make_client(
443            org_id,
444            ApiClient::new_mocked(move |mock| {
445                mock.organizations_api
446                    .expect_get_private_key()
447                    .returning(move |_org| {
448                        Ok(
449                            bitwarden_api_api::models::OrganizationPrivateKeyResponseModel {
450                                object: None,
451                                private_key: for_mock.lock().unwrap().clone(),
452                            },
453                        )
454                    })
455                    .once();
456                mock.organization_invite_links_api
457                    .expect_create()
458                    .returning(|org, model| {
459                        let model = model.unwrap();
460                        Ok(echo_link_response(
461                            org,
462                            model.allowed_domains,
463                            model.invite,
464                            model.supports_confirmation,
465                        ))
466                    })
467                    .once();
468            }),
469        );
470        *wrapped.lock().unwrap() = Some(wrapped_org_private_key(&client, org_id));
471
472        let link = client
473            .create(org_id, vec!["example.com".to_string()], false)
474            .await
475            .unwrap();
476
477        assert_eq!(link.allowed_domains, vec!["example.com".to_string()]);
478        assert!(!link.supports_confirmation);
479    }
480
481    #[tokio::test]
482    async fn create_posts_and_returns_link_with_confirmation() {
483        let org_id = OrganizationId::new_v4();
484        let wrapped = Arc::new(std::sync::Mutex::new(None::<String>));
485        let for_mock = wrapped.clone();
486        let client = make_client(
487            org_id,
488            ApiClient::new_mocked(move |mock| {
489                mock.organizations_api
490                    .expect_get_private_key()
491                    .returning(move |_org| {
492                        Ok(
493                            bitwarden_api_api::models::OrganizationPrivateKeyResponseModel {
494                                object: None,
495                                private_key: for_mock.lock().unwrap().clone(),
496                            },
497                        )
498                    })
499                    .once();
500                mock.organization_invite_links_api
501                    .expect_create()
502                    .returning(|org, model| {
503                        let model = model.unwrap();
504                        Ok(echo_link_response(
505                            org,
506                            model.allowed_domains,
507                            model.invite,
508                            model.supports_confirmation,
509                        ))
510                    })
511                    .once();
512            }),
513        );
514        *wrapped.lock().unwrap() = Some(wrapped_org_private_key(&client, org_id));
515
516        let link = client
517            .create(org_id, vec!["example.com".to_string()], true)
518            .await
519            .unwrap();
520
521        assert_eq!(link.allowed_domains, vec!["example.com".to_string()]);
522        assert!(link.supports_confirmation);
523    }
524
525    #[tokio::test]
526    async fn create_builds_url_fragment_from_org_code_and_secret() {
527        let org_id = OrganizationId::new_v4();
528        let code = uuid::Uuid::new_v4();
529        let wrapped = Arc::new(std::sync::Mutex::new(None::<String>));
530        let for_mock = wrapped.clone();
531        let client = make_client(
532            org_id,
533            ApiClient::new_mocked(move |mock| {
534                mock.organizations_api
535                    .expect_get_private_key()
536                    .returning(move |_org| {
537                        Ok(
538                            bitwarden_api_api::models::OrganizationPrivateKeyResponseModel {
539                                object: None,
540                                private_key: for_mock.lock().unwrap().clone(),
541                            },
542                        )
543                    })
544                    .once();
545                mock.organization_invite_links_api
546                    .expect_create()
547                    .returning(move |org, model| {
548                        let model = model.unwrap();
549                        // Pin the code so the fragment's middle segment is deterministic.
550                        let mut response = echo_link_response(
551                            org,
552                            model.allowed_domains,
553                            model.invite,
554                            model.supports_confirmation,
555                        );
556                        response.code = Some(code);
557                        Ok(response)
558                    })
559                    .once();
560            }),
561        );
562        *wrapped.lock().unwrap() = Some(wrapped_org_private_key(&client, org_id));
563
564        let link = client
565            .create(org_id, vec!["example.com".to_string()], false)
566            .await
567            .unwrap();
568
569        // Fragment shape: #/join/{org}/{code}?key={secret}. The org id and server-issued code are
570        // deterministic; the trailing secret must be a real, parseable `InviteSecret`.
571        // The leading hash is security critical to avoid the key being sent to the server.
572        let prefix = format!("#/join/{org_id}/{code}?key=");
573        let key = link
574            .url_fragment
575            .strip_prefix(&prefix)
576            .unwrap_or_else(|| panic!("unexpected fragment: {}", link.url_fragment));
577        assert!(key.parse::<InviteSecret>().is_ok());
578    }
579
580    #[tokio::test]
581    async fn create_two_calls_produce_different_invites() {
582        let org_id = OrganizationId::new_v4();
583        let wrapped = Arc::new(std::sync::Mutex::new(None::<String>));
584        let for_mock = wrapped.clone();
585        let client = make_client(
586            org_id,
587            ApiClient::new_mocked(move |mock| {
588                mock.organizations_api
589                    .expect_get_private_key()
590                    .returning(move |_org| {
591                        Ok(
592                            bitwarden_api_api::models::OrganizationPrivateKeyResponseModel {
593                                object: None,
594                                private_key: for_mock.lock().unwrap().clone(),
595                            },
596                        )
597                    })
598                    .times(2);
599                mock.organization_invite_links_api
600                    .expect_create()
601                    .returning(|org, model| {
602                        let model = model.unwrap();
603                        Ok(echo_link_response(
604                            org,
605                            model.allowed_domains,
606                            model.invite,
607                            model.supports_confirmation,
608                        ))
609                    })
610                    .times(2);
611            }),
612        );
613        *wrapped.lock().unwrap() = Some(wrapped_org_private_key(&client, org_id));
614
615        let link1 = client
616            .create(org_id, vec!["example.com".to_string()], false)
617            .await
618            .unwrap();
619        let link2 = client
620            .create(org_id, vec!["example.com".to_string()], false)
621            .await
622            .unwrap();
623
624        assert_ne!(&link1.url_fragment, &link2.url_fragment);
625    }
626
627    #[tokio::test]
628    async fn create_with_unknown_organization_id_fails() {
629        let org_id = OrganizationId::new_v4();
630        let other_org_id = OrganizationId::new_v4();
631        let wrapped = Arc::new(std::sync::Mutex::new(None::<String>));
632        let for_mock = wrapped.clone();
633        let client = make_client(
634            org_id,
635            ApiClient::new_mocked(move |mock| {
636                mock.organizations_api
637                    .expect_get_private_key()
638                    .returning(move |_org| {
639                        Ok(
640                            bitwarden_api_api::models::OrganizationPrivateKeyResponseModel {
641                                object: None,
642                                private_key: for_mock.lock().unwrap().clone(),
643                            },
644                        )
645                    })
646                    .once();
647            }),
648        );
649        // The wrapped key is bound to the client's own org key; unwrapping it under a different
650        // organization's key slot (which is absent from the store) must fail.
651        *wrapped.lock().unwrap() = Some(wrapped_org_private_key(&client, org_id));
652
653        let result = client
654            .create(other_org_id, vec![String::from("example.com")], false)
655            .await;
656
657        assert!(matches!(result, Err(InviteLinkError::Invite(_))));
658    }
659
660    #[tokio::test]
661    async fn create_surfaces_api_errors() {
662        let org_id = OrganizationId::new_v4();
663        let client = make_client(
664            org_id,
665            ApiClient::new_mocked(|mock| {
666                mock.organizations_api
667                    .expect_get_private_key()
668                    .returning(|_org| Err(std::io::Error::other("boom").into()));
669            }),
670        );
671
672        let result = client
673            .create(org_id, vec![String::from("example.com")], false)
674            .await;
675
676        assert!(matches!(result, Err(InviteLinkError::Api(_))));
677    }
678
679    #[tokio::test]
680    async fn set_invite_confirmation_enables_confirmation_on_an_existing_invite() {
681        let org_id = OrganizationId::new_v4();
682        // Captures the invite posted to the server so it can be checked independently of the
683        // echoed response.
684        let posted = Arc::new(std::sync::Mutex::new(None::<String>));
685        let for_mock = posted.clone();
686        let client = make_client(
687            org_id,
688            ApiClient::new_mocked(move |mock| {
689                mock.organization_invite_links_api
690                    .expect_update_invite_support_confirm()
691                    .returning(move |org, model| {
692                        let model = model.unwrap();
693                        *for_mock.lock().unwrap() = Some(model.invite.clone());
694                        Ok(echo_link_response(
695                            org,
696                            vec![],
697                            model.invite,
698                            model.supports_confirmation,
699                        ))
700                    })
701                    .once();
702            }),
703        );
704
705        // Start from an invite with confirmation stripped; the invite key is still sealed under the
706        // organization key, so confirmation can be re-enabled from it.
707        let (_secret, mut invite, _org_public_key) = build_invite(&client, org_id);
708        invite.disable_confirmation();
709        assert!(!invite.supports_confirmation());
710
711        let link = client
712            .set_invite_confirmation(org_id, invite, true)
713            .await
714            .unwrap();
715
716        assert!(link.supports_confirmation);
717        assert!(link.invite.supports_confirmation());
718        let posted: Invite = posted.lock().unwrap().clone().unwrap().parse().unwrap();
719        assert!(posted.supports_confirmation());
720    }
721
722    #[tokio::test]
723    async fn set_invite_confirmation_disables_confirmation_on_an_existing_invite() {
724        let org_id = OrganizationId::new_v4();
725        let client = make_client(
726            org_id,
727            ApiClient::new_mocked(|mock| {
728                mock.organization_invite_links_api
729                    .expect_update_invite_support_confirm()
730                    .returning(|org, model| {
731                        let model = model.unwrap();
732                        Ok(echo_link_response(
733                            org,
734                            vec![],
735                            model.invite,
736                            model.supports_confirmation,
737                        ))
738                    })
739                    .once();
740            }),
741        );
742
743        let (_secret, invite, _org_public_key) = build_invite(&client, org_id);
744        assert!(invite.supports_confirmation());
745
746        let link = client
747            .set_invite_confirmation(org_id, invite, false)
748            .await
749            .unwrap();
750
751        assert!(!link.supports_confirmation);
752        assert!(!link.invite.supports_confirmation());
753    }
754
755    #[tokio::test]
756    async fn set_invite_confirmation_preserves_the_invite_secret() {
757        let org_id = OrganizationId::new_v4();
758        let client = make_client(
759            org_id,
760            ApiClient::new_mocked(|mock| {
761                mock.organization_invite_links_api
762                    .expect_update_invite_support_confirm()
763                    .returning(|org, model| {
764                        let model = model.unwrap();
765                        Ok(echo_link_response(
766                            org,
767                            vec![],
768                            model.invite,
769                            model.supports_confirmation,
770                        ))
771                    })
772                    .once();
773            }),
774        );
775
776        // Toggling confirmation must not rotate the invite key, so already-distributed links (which
777        // carry the secret) keep working.
778        let (secret, invite, _org_public_key) = build_invite(&client, org_id);
779        let link = client
780            .set_invite_confirmation(org_id, invite, false)
781            .await
782            .unwrap();
783
784        let recovered = client.get_invite_secret(org_id, link.invite).unwrap();
785        assert_eq!(String::from(&recovered), String::from(&secret));
786    }
787
788    #[tokio::test]
789    async fn set_invite_confirmation_with_unknown_organization_id_fails() {
790        let org_id = OrganizationId::new_v4();
791        let other_org_id = OrganizationId::new_v4();
792        let client = make_client(org_id, ApiClient::new_mocked(|_| {}));
793
794        // The invite key is sealed to the client's own org key; re-sealing under a different
795        // organization's key slot (which is absent from the store) must fail before any request.
796        let (_secret, mut invite, _org_public_key) = build_invite(&client, org_id);
797        invite.disable_confirmation();
798
799        let result = client
800            .set_invite_confirmation(other_org_id, invite, true)
801            .await;
802
803        assert!(matches!(result, Err(InviteLinkError::Invite(_))));
804    }
805
806    #[tokio::test]
807    async fn set_invite_confirmation_surfaces_api_errors() {
808        let org_id = OrganizationId::new_v4();
809        let client = make_client(
810            org_id,
811            ApiClient::new_mocked(|mock| {
812                mock.organization_invite_links_api
813                    .expect_update_invite_support_confirm()
814                    .returning(|_org, _model| Err(std::io::Error::other("boom").into()));
815            }),
816        );
817
818        let (_secret, invite, _org_public_key) = build_invite(&client, org_id);
819        let result = client.set_invite_confirmation(org_id, invite, false).await;
820
821        assert!(matches!(result, Err(InviteLinkError::Api(_))));
822    }
823
824    #[tokio::test]
825    async fn get_returns_none_on_404() {
826        let org_id = OrganizationId::new_v4();
827        let client = make_client(
828            org_id,
829            ApiClient::new_mocked(|mock| {
830                mock.organization_invite_links_api
831                    .expect_get()
832                    .returning(|_org| {
833                        Err(ApiError::Response(ResponseContent {
834                            status: StatusCode::NOT_FOUND,
835                            message: "not found".to_string(),
836                        }))
837                    })
838                    .once();
839            }),
840        );
841
842        let result = client.get(org_id).await.unwrap();
843
844        assert!(result.is_none());
845    }
846
847    #[tokio::test]
848    async fn get_surfaces_non_404_api_errors() {
849        let org_id = OrganizationId::new_v4();
850        let client = make_client(
851            org_id,
852            ApiClient::new_mocked(|mock| {
853                mock.organization_invite_links_api
854                    .expect_get()
855                    .returning(|_org| {
856                        Err(ApiError::Response(ResponseContent {
857                            status: StatusCode::INTERNAL_SERVER_ERROR,
858                            message: "boom".to_string(),
859                        }))
860                    })
861                    .once();
862            }),
863        );
864
865        let result = client.get(org_id).await;
866
867        assert!(matches!(result, Err(InviteLinkError::Api(_))));
868    }
869}