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