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#[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 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 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 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 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 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 pub async fn update_confirmation(
171 &self,
172 organization_id: OrganizationId,
173 supports_confirmation: bool,
174 ) -> Result<OrganizationInviteLinkView, InviteLinkError> {
175 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 {
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 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 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 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 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 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 #[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 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 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 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 *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 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 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 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 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 let client = make_client(
739 org_id,
740 ApiClient::new_mocked(move |mock| expect_get_serving(mock, for_get)),
741 );
742
743 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}