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