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#[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#[allow(deprecated)]
34#[cfg_attr(feature = "wasm", wasm_bindgen)]
35impl InviteLinkAdminClient {
36 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 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 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 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 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 pub async fn update_confirmation(
133 &self,
134 organization_id: OrganizationId,
135 supports_confirmation: bool,
136 ) -> Result<OrganizationInviteLinkView, InviteLinkError> {
137 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 #[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 {
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 #[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 #[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 #[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 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 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 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 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 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 #[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 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 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 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 *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 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 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 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 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}