1use std::sync::Arc;
2
3use bitwarden_api_api::models::{
4 AssignAccessConnectorTargetRequestModel, PamAccessConnectorDetailResponseModel,
5 PamAccessConnectorResponseModel, RegisterAccessConnectorRequestModel,
6};
7use bitwarden_core::{
8 FromClient, OrganizationId, client::ApiConfigurations, key_management::KeySlotIds, require,
9};
10use bitwarden_crypto::KeyStore;
11use chrono::{DateTime, Utc};
12use serde::{Deserialize, Serialize};
13
14use super::{
15 error::RotationError,
16 models::{AccessConnectorStatus, RotationJob},
17 validate::validate_name,
18};
19use crate::{AccessConnectorId, TargetSystemId};
20
21#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
24#[bitwarden_ffi::wasm_record]
25#[serde(rename_all = "camelCase")]
26pub struct AccessConnector {
27 pub id: AccessConnectorId,
29 pub organization_id: OrganizationId,
31 pub name: String,
34 pub status: AccessConnectorStatus,
36 pub is_connected: bool,
39 pub last_heartbeat_at: Option<DateTime<Utc>>,
41 pub assigned_target_system_ids: Vec<TargetSystemId>,
43 pub creation_date: DateTime<Utc>,
45 pub revision_date: DateTime<Utc>,
47}
48
49impl TryFrom<PamAccessConnectorResponseModel> for AccessConnector {
50 type Error = RotationError;
51
52 fn try_from(response: PamAccessConnectorResponseModel) -> Result<Self, Self::Error> {
53 Ok(Self {
54 id: AccessConnectorId::new(require!(response.id)),
55 organization_id: OrganizationId::new(require!(response.organization_id)),
56 name: require!(response.name),
57 status: require!(response.status).into(),
58 is_connected: response.is_connected.unwrap_or(false),
59 last_heartbeat_at: response
60 .last_heartbeat_at
61 .map(|date| date.parse())
62 .transpose()?,
63 assigned_target_system_ids: response
64 .assigned_target_system_ids
65 .unwrap_or_default()
66 .into_iter()
67 .map(TargetSystemId::new)
68 .collect(),
69 creation_date: require!(response.creation_date).parse()?,
70 revision_date: require!(response.revision_date).parse()?,
71 })
72 }
73}
74
75#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
77#[bitwarden_ffi::wasm_record]
78#[serde(rename_all = "camelCase")]
79pub struct AccessConnectorDetail {
80 pub connector: AccessConnector,
82 pub jobs: Vec<RotationJob>,
85}
86
87impl TryFrom<PamAccessConnectorDetailResponseModel> for AccessConnectorDetail {
88 type Error = RotationError;
89
90 fn try_from(response: PamAccessConnectorDetailResponseModel) -> Result<Self, Self::Error> {
91 let jobs = response
92 .jobs
93 .clone()
94 .unwrap_or_default()
95 .into_iter()
96 .map(RotationJob::try_from)
97 .collect::<Result<Vec<_>, _>>()?;
98
99 let connector = AccessConnector::try_from(PamAccessConnectorResponseModel {
101 object: response.object,
102 id: response.id,
103 organization_id: response.organization_id,
104 name: response.name,
105 status: response.status,
106 is_connected: response.is_connected,
107 last_heartbeat_at: response.last_heartbeat_at,
108 assigned_target_system_ids: response.assigned_target_system_ids,
109 creation_date: response.creation_date,
110 revision_date: response.revision_date,
111 })?;
112
113 Ok(Self { connector, jobs })
114 }
115}
116
117#[derive(Serialize, Deserialize, Debug, Clone)]
119#[bitwarden_ffi::wasm_record]
120#[serde(rename_all = "camelCase")]
121pub struct AccessConnectorRegistrationResponse {
122 pub id: AccessConnectorId,
124 pub organization_id: OrganizationId,
126 pub name: String,
128 pub status: AccessConnectorStatus,
131 pub creation_date: DateTime<Utc>,
133 pub token: String,
138}
139
140#[bitwarden_ffi::wasm_object]
142#[derive(FromClient)]
143pub struct AccessConnectorsClient {
144 pub(crate) key_store: KeyStore<KeySlotIds>,
145 pub(crate) api_configurations: Arc<ApiConfigurations>,
146}
147
148#[bitwarden_ffi::wasm_export]
149impl AccessConnectorsClient {
150 pub async fn list(
152 &self,
153 organization_id: OrganizationId,
154 ) -> Result<Vec<AccessConnector>, RotationError> {
155 let response = self
156 .api_configurations
157 .api_client
158 .pam_access_connectors_api()
159 .get_all(organization_id.into())
160 .await?;
161
162 response
163 .data
164 .unwrap_or_default()
165 .into_iter()
166 .map(AccessConnector::try_from)
167 .collect()
168 }
169
170 pub async fn get(
172 &self,
173 organization_id: OrganizationId,
174 id: AccessConnectorId,
175 ) -> Result<AccessConnectorDetail, RotationError> {
176 let response = self
177 .api_configurations
178 .api_client
179 .pam_access_connectors_api()
180 .get(organization_id.into(), id.into())
181 .await?;
182
183 AccessConnectorDetail::try_from(response)
184 }
185
186 pub async fn register(
189 &self,
190 organization_id: OrganizationId,
191 name: String,
192 ) -> Result<AccessConnectorRegistrationResponse, RotationError> {
193 validate_name(&name)?;
194
195 let secrets = self.registration_secrets(organization_id)?;
198
199 let response = self
200 .api_configurations
201 .api_client
202 .pam_access_connectors_api()
203 .post(
204 organization_id.into(),
205 RegisterAccessConnectorRequestModel {
206 name: name.clone(),
207 encrypted_payload: secrets.encrypted_payload.to_string(),
208 key: secrets.key.to_string(),
209 },
210 )
211 .await?;
212
213 let api_key_id = require!(response.api_key_id);
214 let client_secret = require!(response.client_secret);
215
216 Ok(AccessConnectorRegistrationResponse {
217 id: AccessConnectorId::new(require!(response.id)),
218 organization_id: OrganizationId::new(require!(response.organization_id)),
219 name: response.name.unwrap_or(name),
220 status: require!(response.status).into(),
221 creation_date: require!(response.creation_date).parse()?,
222 token: secrets.into_token(api_key_id, &client_secret),
223 })
224 }
225
226 pub async fn enable(
228 &self,
229 organization_id: OrganizationId,
230 id: AccessConnectorId,
231 ) -> Result<(), RotationError> {
232 self.api_configurations
233 .api_client
234 .pam_access_connectors_api()
235 .enable(organization_id.into(), id.into())
236 .await?;
237
238 Ok(())
239 }
240
241 pub async fn disable(
245 &self,
246 organization_id: OrganizationId,
247 id: AccessConnectorId,
248 ) -> Result<(), RotationError> {
249 self.api_configurations
250 .api_client
251 .pam_access_connectors_api()
252 .disable(organization_id.into(), id.into())
253 .await?;
254
255 Ok(())
256 }
257
258 pub async fn delete(
261 &self,
262 organization_id: OrganizationId,
263 id: AccessConnectorId,
264 ) -> Result<(), RotationError> {
265 self.api_configurations
266 .api_client
267 .pam_access_connectors_api()
268 .delete(organization_id.into(), id.into())
269 .await?;
270
271 Ok(())
272 }
273
274 pub async fn assign_target(
276 &self,
277 organization_id: OrganizationId,
278 id: AccessConnectorId,
279 target_system_id: TargetSystemId,
280 ) -> Result<(), RotationError> {
281 self.api_configurations
282 .api_client
283 .pam_access_connectors_api()
284 .assign_target(
285 organization_id.into(),
286 id.into(),
287 AssignAccessConnectorTargetRequestModel {
288 target_system_id: target_system_id.into(),
289 },
290 )
291 .await?;
292
293 Ok(())
294 }
295
296 pub async fn unassign_target(
298 &self,
299 organization_id: OrganizationId,
300 id: AccessConnectorId,
301 target_system_id: TargetSystemId,
302 ) -> Result<(), RotationError> {
303 self.api_configurations
304 .api_client
305 .pam_access_connectors_api()
306 .unassign_target(organization_id.into(), id.into(), target_system_id.into())
307 .await?;
308
309 Ok(())
310 }
311}
312
313#[cfg(test)]
314mod tests {
315 use std::sync::Mutex;
316
317 use bitwarden_access_token::{AccessToken, AccessTokenKind};
318 use bitwarden_api_api::{
319 apis::ApiClient,
320 models::{
321 PamAccessConnectorResponseModelListResponseModel,
322 PamAccessConnectorStatus as ApiAccessConnectorStatus, PamRotationJobResponseModel,
323 PamRotationJobStatus as ApiRotationJobStatus, PamRotationSource as ApiRotationSource,
324 RegisterAccessConnectorResponseModel,
325 },
326 };
327 use bitwarden_core::key_management::{
328 SymmetricKeySlotId, create_test_crypto_with_user_and_org_key,
329 create_test_crypto_with_user_key,
330 };
331 use bitwarden_crypto::{KeyStore, SymmetricCryptoKey, SymmetricKeyAlgorithm};
332 use bitwarden_sensitive_value::ExposeSensitive as _;
333 use uuid::{Uuid, uuid};
334
335 use super::*;
336 use crate::rotation::{
337 models::{RotationJobStatus, RotationSource},
338 validate::RotationValidationError,
339 };
340
341 fn organization_id() -> OrganizationId {
342 OrganizationId::new(uuid!("11111111-1111-1111-1111-111111111111"))
343 }
344
345 fn connector_id() -> AccessConnectorId {
346 AccessConnectorId::new(uuid!("22222222-2222-2222-2222-222222222222"))
347 }
348
349 fn target_system_id() -> TargetSystemId {
350 TargetSystemId::new(uuid!("33333333-3333-3333-3333-333333333333"))
351 }
352
353 fn job_id() -> Uuid {
354 uuid!("44444444-4444-4444-4444-444444444444")
355 }
356
357 fn api_key_id() -> Uuid {
358 uuid!("55555555-5555-5555-5555-555555555555")
359 }
360
361 fn client(api_client: ApiClient) -> AccessConnectorsClient {
362 client_with_org_key(
363 api_client,
364 SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac),
365 )
366 }
367
368 fn client_with_org_key(
369 api_client: ApiClient,
370 organization_key: SymmetricCryptoKey,
371 ) -> AccessConnectorsClient {
372 AccessConnectorsClient {
373 key_store: create_test_crypto_with_user_and_org_key(
374 SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac),
375 organization_id(),
376 organization_key,
377 ),
378 api_configurations: Arc::new(ApiConfigurations::from_api_client(api_client)),
379 }
380 }
381
382 fn sample_response() -> PamAccessConnectorResponseModel {
385 PamAccessConnectorResponseModel {
386 id: Some(connector_id().into()),
387 organization_id: Some(organization_id().into()),
388 name: Some("Prod connector".to_string()),
389 status: Some(ApiAccessConnectorStatus::Enabled),
390 is_connected: Some(true),
391 last_heartbeat_at: Some("2026-02-01T12:30:00Z".to_string()),
392 assigned_target_system_ids: Some(vec![target_system_id().into()]),
393 creation_date: Some("2026-01-01T00:00:00Z".to_string()),
394 revision_date: Some("2026-01-15T00:00:00Z".to_string()),
395 ..Default::default()
396 }
397 }
398
399 fn sample_detail_response(
402 jobs: Option<Vec<PamRotationJobResponseModel>>,
403 ) -> PamAccessConnectorDetailResponseModel {
404 let connector = sample_response();
405 PamAccessConnectorDetailResponseModel {
406 object: connector.object,
407 id: connector.id,
408 organization_id: connector.organization_id,
409 name: connector.name,
410 status: connector.status,
411 is_connected: connector.is_connected,
412 last_heartbeat_at: connector.last_heartbeat_at,
413 assigned_target_system_ids: connector.assigned_target_system_ids,
414 creation_date: connector.creation_date,
415 revision_date: connector.revision_date,
416 jobs,
417 }
418 }
419
420 fn sample_job(id: Uuid) -> PamRotationJobResponseModel {
421 PamRotationJobResponseModel {
422 id: Some(id),
423 rotation_config_id: Some(uuid!("66666666-6666-6666-6666-666666666666")),
424 source: Some(ApiRotationSource::Scheduled),
425 status: Some(ApiRotationJobStatus::Succeeded),
426 creation_date: Some("2026-01-10T00:00:00Z".to_string()),
427 ..Default::default()
428 }
429 }
430
431 fn registration_response() -> RegisterAccessConnectorResponseModel {
432 RegisterAccessConnectorResponseModel {
433 id: Some(connector_id().into()),
434 organization_id: Some(organization_id().into()),
435 name: Some("Prod connector".to_string()),
436 status: Some(ApiAccessConnectorStatus::Enabled),
437 creation_date: Some("2026-01-01T00:00:00Z".to_string()),
438 api_key_id: Some(api_key_id()),
439 client_secret: Some("client-secret-value".to_string()),
440 ..Default::default()
441 }
442 }
443
444 #[test]
445 fn a_full_payload_maps_every_field() {
446 let connector = AccessConnector::try_from(sample_response()).expect("the payload is valid");
447
448 assert_eq!(connector.id, connector_id());
449 assert_eq!(connector.organization_id, organization_id());
450 assert_eq!(connector.name, "Prod connector");
451 assert_eq!(connector.status, AccessConnectorStatus::Enabled);
452 assert!(connector.is_connected);
453 assert_eq!(
454 connector.last_heartbeat_at,
455 Some("2026-02-01T12:30:00Z".parse().expect("a valid timestamp"))
456 );
457 assert_eq!(connector.assigned_target_system_ids, [target_system_id()]);
458 assert_eq!(
459 connector.creation_date,
460 "2026-01-01T00:00:00Z".parse::<DateTime<Utc>>().unwrap()
461 );
462 assert_eq!(
463 connector.revision_date,
464 "2026-01-15T00:00:00Z".parse::<DateTime<Utc>>().unwrap()
465 );
466 }
467
468 #[test]
471 fn a_connector_that_has_never_connected_maps_to_disconnected_with_no_heartbeat() {
472 let response = PamAccessConnectorResponseModel {
473 is_connected: None,
474 last_heartbeat_at: None,
475 assigned_target_system_ids: None,
476 ..sample_response()
477 };
478
479 let connector = AccessConnector::try_from(response).expect("the payload is valid");
480
481 assert!(!connector.is_connected);
482 assert_eq!(connector.last_heartbeat_at, None);
483 assert!(connector.assigned_target_system_ids.is_empty());
484 }
485
486 #[test]
487 fn a_missing_required_field_is_reported_rather_than_defaulted() {
488 for response in [
489 PamAccessConnectorResponseModel {
490 id: None,
491 ..sample_response()
492 },
493 PamAccessConnectorResponseModel {
494 organization_id: None,
495 ..sample_response()
496 },
497 PamAccessConnectorResponseModel {
498 name: None,
499 ..sample_response()
500 },
501 PamAccessConnectorResponseModel {
502 status: None,
503 ..sample_response()
504 },
505 PamAccessConnectorResponseModel {
506 creation_date: None,
507 ..sample_response()
508 },
509 PamAccessConnectorResponseModel {
510 revision_date: None,
511 ..sample_response()
512 },
513 ] {
514 assert!(matches!(
515 AccessConnector::try_from(response.clone()),
516 Err(RotationError::MissingField(_))
517 ));
518 }
519 }
520
521 #[test]
522 fn an_unparseable_date_is_reported() {
523 for response in [
524 PamAccessConnectorResponseModel {
525 creation_date: Some("not a date".to_string()),
526 ..sample_response()
527 },
528 PamAccessConnectorResponseModel {
529 revision_date: Some("not a date".to_string()),
530 ..sample_response()
531 },
532 PamAccessConnectorResponseModel {
533 last_heartbeat_at: Some("not a date".to_string()),
534 ..sample_response()
535 },
536 ] {
537 assert!(matches!(
538 AccessConnector::try_from(response),
539 Err(RotationError::Chrono(_))
540 ));
541 }
542 }
543
544 #[test]
546 fn an_unrecognized_status_degrades_to_unknown() {
547 let response = PamAccessConnectorResponseModel {
548 status: Some(ApiAccessConnectorStatus::__Unknown(99)),
549 ..sample_response()
550 };
551
552 let connector = AccessConnector::try_from(response).expect("an unknown status still maps");
553
554 assert_eq!(connector.status, AccessConnectorStatus::Unknown);
555 }
556
557 #[test]
560 fn the_detail_payload_yields_the_same_connector_as_the_list_payload() {
561 let detail =
562 AccessConnectorDetail::try_from(sample_detail_response(Some(vec![sample_job(
563 job_id(),
564 )])))
565 .expect("the payload is valid");
566
567 assert_eq!(
568 detail.connector,
569 AccessConnector::try_from(sample_response()).expect("the payload is valid")
570 );
571 }
572
573 #[test]
574 fn detail_jobs_keep_the_order_the_server_sent() {
575 let second = uuid!("77777777-7777-7777-7777-777777777777");
576 let detail = AccessConnectorDetail::try_from(sample_detail_response(Some(vec![
577 sample_job(job_id()),
578 sample_job(second),
579 ])))
580 .expect("the payload is valid");
581
582 let ids: Vec<Uuid> = detail.jobs.iter().map(|job| job.id.into()).collect();
583 assert_eq!(ids, [job_id(), second]);
584 assert_eq!(detail.jobs[0].source, RotationSource::Scheduled);
585 assert_eq!(detail.jobs[0].status, RotationJobStatus::Succeeded);
586 }
587
588 #[test]
590 fn a_detail_payload_without_jobs_has_no_jobs() {
591 let detail =
592 AccessConnectorDetail::try_from(sample_detail_response(None)).expect("no jobs is fine");
593
594 assert!(detail.jobs.is_empty());
595 }
596
597 #[test]
598 fn a_malformed_job_fails_the_whole_detail() {
599 let malformed = PamRotationJobResponseModel {
600 id: None,
601 ..sample_job(job_id())
602 };
603
604 let result = AccessConnectorDetail::try_from(sample_detail_response(Some(vec![malformed])));
605
606 assert!(matches!(result, Err(RotationError::MissingField(_))));
607 }
608
609 #[tokio::test]
610 async fn list_maps_the_organizations_connectors() {
611 let api_client = ApiClient::new_mocked(move |mock| {
612 mock.pam_access_connectors_api
613 .expect_get_all()
614 .withf(|org_id| *org_id == Uuid::from(organization_id()))
615 .returning(move |_org_id| {
616 Ok(PamAccessConnectorResponseModelListResponseModel {
617 data: Some(vec![sample_response()]),
618 ..Default::default()
619 })
620 })
621 .once();
622 });
623
624 let connectors = client(api_client)
625 .list(organization_id())
626 .await
627 .expect("the list succeeds");
628
629 assert_eq!(connectors.len(), 1);
630 assert_eq!(connectors[0].id, connector_id());
631 }
632
633 #[tokio::test]
634 async fn an_organization_with_no_connectors_lists_empty() {
635 let api_client = ApiClient::new_mocked(move |mock| {
636 mock.pam_access_connectors_api
637 .expect_get_all()
638 .returning(move |_org_id| {
639 Ok(PamAccessConnectorResponseModelListResponseModel::default())
640 })
641 .once();
642 });
643
644 let connectors = client(api_client)
645 .list(organization_id())
646 .await
647 .expect("an absent list is not an error");
648
649 assert!(connectors.is_empty());
650 }
651
652 #[tokio::test]
653 async fn list_fails_if_any_connector_is_malformed() {
654 let api_client = ApiClient::new_mocked(move |mock| {
655 mock.pam_access_connectors_api
656 .expect_get_all()
657 .returning(move |_org_id| {
658 Ok(PamAccessConnectorResponseModelListResponseModel {
659 data: Some(vec![
660 sample_response(),
661 PamAccessConnectorResponseModel {
662 id: None,
663 ..sample_response()
664 },
665 ]),
666 ..Default::default()
667 })
668 })
669 .once();
670 });
671
672 let result = client(api_client).list(organization_id()).await;
673
674 assert!(matches!(result, Err(RotationError::MissingField(_))));
675 }
676
677 #[tokio::test]
678 async fn list_surfaces_an_api_error() {
679 let api_client = ApiClient::new_mocked(move |mock| {
680 mock.pam_access_connectors_api
681 .expect_get_all()
682 .returning(move |_org_id| {
683 Err(bitwarden_api_api::apis::Error::Response(
684 bitwarden_api_api::apis::ResponseContent {
685 status: reqwest::StatusCode::INTERNAL_SERVER_ERROR,
686 message: String::new(),
687 },
688 ))
689 })
690 .once();
691 });
692
693 let result = client(api_client).list(organization_id()).await;
694
695 assert!(matches!(result, Err(RotationError::Api(_))));
696 }
697
698 #[tokio::test]
699 async fn get_reads_the_requested_connector_with_its_jobs() {
700 let api_client = ApiClient::new_mocked(move |mock| {
701 mock.pam_access_connectors_api
702 .expect_get()
703 .withf(|org_id, id| {
704 *org_id == Uuid::from(organization_id()) && *id == Uuid::from(connector_id())
705 })
706 .returning(move |_org_id, _id| {
707 Ok(sample_detail_response(Some(vec![sample_job(job_id())])))
708 })
709 .once();
710 });
711
712 let detail = client(api_client)
713 .get(organization_id(), connector_id())
714 .await
715 .expect("the read succeeds");
716
717 assert_eq!(detail.connector.id, connector_id());
718 assert_eq!(detail.jobs.len(), 1);
719 }
720
721 #[tokio::test]
722 async fn get_surfaces_an_api_error() {
723 let api_client = ApiClient::new_mocked(move |mock| {
724 mock.pam_access_connectors_api
725 .expect_get()
726 .returning(move |_org_id, _id| {
727 Err(bitwarden_api_api::apis::Error::Response(
728 bitwarden_api_api::apis::ResponseContent {
729 status: reqwest::StatusCode::NOT_FOUND,
730 message: String::new(),
731 },
732 ))
733 })
734 .once();
735 });
736
737 let result = client(api_client)
738 .get(organization_id(), connector_id())
739 .await;
740
741 assert!(matches!(result, Err(RotationError::Api(_))));
742 }
743
744 #[tokio::test]
748 async fn register_returns_a_token_that_recovers_the_organization_key() {
749 let organization_key = SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac);
750 let sent_payload = Arc::new(Mutex::new(None));
751
752 let captured = sent_payload.clone();
753 let api_client = ApiClient::new_mocked(move |mock| {
754 mock.pam_access_connectors_api
755 .expect_post()
756 .withf(|org_id, request| {
757 *org_id == Uuid::from(organization_id()) && request.name == "Prod connector"
758 })
759 .returning(move |_org_id, request| {
760 *captured.lock().expect("the lock is not poisoned") =
761 Some(request.encrypted_payload);
762 Ok(registration_response())
763 })
764 .once();
765 });
766
767 let registered = client_with_org_key(api_client, organization_key.clone())
768 .register(organization_id(), "Prod connector".to_string())
769 .await
770 .expect("registration succeeds");
771
772 assert_eq!(registered.id, connector_id());
773 assert_eq!(registered.organization_id, organization_id());
774 assert_eq!(registered.name, "Prod connector");
775 assert_eq!(registered.status, AccessConnectorStatus::Enabled);
776
777 let token = AccessToken::parse(®istered.token, AccessTokenKind::AccessConnector)
779 .expect("the token parses");
780 assert_eq!(token.api_key_id(), api_key_id());
781 assert_eq!(token.client_secret().expose(), "client-secret-value");
782
783 let payload = sent_payload
784 .lock()
785 .expect("the lock is not poisoned")
786 .clone()
787 .expect("the request carried an encrypted payload");
788
789 let connector_store: KeyStore<KeySlotIds> = KeyStore::default();
790 let organization_key_slot = SymmetricKeySlotId::Organization(organization_id());
791 token
792 .open_payload(
793 &mut connector_store.context_mut(),
794 &payload,
795 organization_key_slot,
796 )
797 .expect("the key derived from the token decrypts the payload");
798
799 let ctx = connector_store.context();
800 #[allow(deprecated)]
801 let recovered_b64 = ctx
802 .dangerous_get_symmetric_key(organization_key_slot)
803 .expect("the slot was just populated")
804 .to_base64();
805 assert_eq!(
806 recovered_b64.to_string(),
807 organization_key.to_base64().to_string(),
808 "the connector must recover the organization key verbatim"
809 );
810 }
811
812 #[tokio::test]
815 async fn register_falls_back_to_the_requested_name_when_the_server_omits_it() {
816 let api_client = ApiClient::new_mocked(move |mock| {
817 mock.pam_access_connectors_api
818 .expect_post()
819 .returning(move |_org_id, _request| {
820 Ok(RegisterAccessConnectorResponseModel {
821 name: None,
822 ..registration_response()
823 })
824 })
825 .once();
826 });
827
828 let registered = client(api_client)
829 .register(organization_id(), "Prod connector".to_string())
830 .await
831 .expect("registration succeeds");
832
833 assert_eq!(registered.name, "Prod connector");
834 }
835
836 #[tokio::test]
839 async fn register_rejects_an_invalid_name_before_calling_the_server() {
840 for name in ["", " ", &"a".repeat(201)] {
841 let result = client(ApiClient::new_mocked(|_| {}))
842 .register(organization_id(), name.to_string())
843 .await;
844
845 assert!(
846 matches!(
847 result,
848 Err(RotationError::Validation(
849 RotationValidationError::InvalidName
850 ))
851 ),
852 "for {name:?}"
853 );
854 }
855 }
856
857 #[tokio::test]
860 async fn register_without_the_organization_key_never_reaches_the_server() {
861 let client = AccessConnectorsClient {
862 key_store: create_test_crypto_with_user_key(SymmetricCryptoKey::make(
864 SymmetricKeyAlgorithm::Aes256CbcHmac,
865 )),
866 api_configurations: Arc::new(ApiConfigurations::from_api_client(
867 ApiClient::new_mocked(|_| {}),
868 )),
869 };
870
871 let result = client
872 .register(organization_id(), "Prod connector".to_string())
873 .await;
874
875 assert!(matches!(result, Err(RotationError::MissingOrganizationKey)));
876 }
877
878 #[tokio::test]
881 async fn register_fails_when_the_response_cannot_complete_the_token() {
882 for response in [
883 RegisterAccessConnectorResponseModel {
884 api_key_id: None,
885 ..registration_response()
886 },
887 RegisterAccessConnectorResponseModel {
888 client_secret: None,
889 ..registration_response()
890 },
891 ] {
892 let api_client = ApiClient::new_mocked(move |mock| {
893 mock.pam_access_connectors_api
894 .expect_post()
895 .returning(move |_org_id, _request| Ok(response.clone()))
896 .once();
897 });
898
899 let result = client(api_client)
900 .register(organization_id(), "Prod connector".to_string())
901 .await;
902
903 assert!(matches!(result, Err(RotationError::MissingField(_))));
904 }
905 }
906
907 #[tokio::test]
908 async fn enable_targets_the_requested_connector() {
909 let api_client = ApiClient::new_mocked(move |mock| {
910 mock.pam_access_connectors_api
911 .expect_enable()
912 .withf(|org_id, id| {
913 *org_id == Uuid::from(organization_id()) && *id == Uuid::from(connector_id())
914 })
915 .returning(move |_org_id, _id| Ok(()))
916 .once();
917 });
918
919 client(api_client)
920 .enable(organization_id(), connector_id())
921 .await
922 .expect("enabling succeeds");
923 }
924
925 #[tokio::test]
926 async fn disable_targets_the_requested_connector() {
927 let api_client = ApiClient::new_mocked(move |mock| {
928 mock.pam_access_connectors_api
929 .expect_disable()
930 .withf(|org_id, id| {
931 *org_id == Uuid::from(organization_id()) && *id == Uuid::from(connector_id())
932 })
933 .returning(move |_org_id, _id| Ok(()))
934 .once();
935 });
936
937 client(api_client)
938 .disable(organization_id(), connector_id())
939 .await
940 .expect("disabling succeeds");
941 }
942
943 #[tokio::test]
944 async fn delete_targets_the_requested_connector() {
945 let api_client = ApiClient::new_mocked(move |mock| {
946 mock.pam_access_connectors_api
947 .expect_delete()
948 .withf(|org_id, id| {
949 *org_id == Uuid::from(organization_id()) && *id == Uuid::from(connector_id())
950 })
951 .returning(move |_org_id, _id| Ok(()))
952 .once();
953 });
954
955 client(api_client)
956 .delete(organization_id(), connector_id())
957 .await
958 .expect("deleting succeeds");
959 }
960
961 #[tokio::test]
962 async fn delete_surfaces_an_api_error() {
963 let api_client = ApiClient::new_mocked(move |mock| {
964 mock.pam_access_connectors_api
965 .expect_delete()
966 .returning(move |_org_id, _id| {
967 Err(bitwarden_api_api::apis::Error::Response(
968 bitwarden_api_api::apis::ResponseContent {
969 status: reqwest::StatusCode::NOT_FOUND,
970 message: String::new(),
971 },
972 ))
973 })
974 .once();
975 });
976
977 let result = client(api_client)
978 .delete(organization_id(), connector_id())
979 .await;
980
981 assert!(matches!(result, Err(RotationError::Api(_))));
982 }
983
984 #[tokio::test]
985 async fn assign_target_sends_the_target_system_in_the_body() {
986 let api_client = ApiClient::new_mocked(move |mock| {
987 mock.pam_access_connectors_api
988 .expect_assign_target()
989 .withf(|org_id, id, request| {
990 *org_id == Uuid::from(organization_id())
991 && *id == Uuid::from(connector_id())
992 && request.target_system_id == Uuid::from(target_system_id())
993 })
994 .returning(move |_org_id, _id, _request| Ok(()))
995 .once();
996 });
997
998 client(api_client)
999 .assign_target(organization_id(), connector_id(), target_system_id())
1000 .await
1001 .expect("assigning succeeds");
1002 }
1003
1004 #[tokio::test]
1007 async fn unassign_target_sends_the_identifiers_in_the_right_order() {
1008 let api_client = ApiClient::new_mocked(move |mock| {
1009 mock.pam_access_connectors_api
1010 .expect_unassign_target()
1011 .withf(|org_id, id, target_system_id_arg| {
1012 *org_id == Uuid::from(organization_id())
1013 && *id == Uuid::from(connector_id())
1014 && *target_system_id_arg == Uuid::from(target_system_id())
1015 })
1016 .returning(move |_org_id, _id, _target_system_id| Ok(()))
1017 .once();
1018 });
1019
1020 client(api_client)
1021 .unassign_target(organization_id(), connector_id(), target_system_id())
1022 .await
1023 .expect("unassigning succeeds");
1024 }
1025}