1use std::sync::Arc;
2
3use bitwarden_api_api::models::{
4 PamTargetSystemMethod as ApiTargetSystemMethod, PamTargetSystemResponseModel,
5 RegisterTargetSystemRequestModel, UpdateTargetSystemRequestModel,
6};
7use bitwarden_core::{
8 FromClient, OrganizationId,
9 client::ApiConfigurations,
10 key_management::{KeySlotIds, SymmetricKeySlotId},
11 require,
12};
13use bitwarden_crypto::{
14 CryptoError, Decryptable, EncString, IdentifyKey, KeyStore, KeyStoreContext, safe::DataEnvelope,
15};
16use bitwarden_error::bitwarden_error;
17use bitwarden_pam_sealed::{
18 Sealed, SealedPasswordPolicy, SealedTargetSystemKind, SealedTargetSystemMethod,
19 TargetSystemSealedV1, seal_target_system, unseal_target_system,
20};
21use chrono::{DateTime, Utc};
22use serde::{Deserialize, Serialize};
23use thiserror::Error;
24use uuid::Uuid;
25
26use super::{
27 error::RotationError,
28 models::{PasswordPolicy, TargetSystemKind, TargetSystemMethod, TargetSystemStatus},
29 validate::{RotationValidationError, validate_name},
30};
31use crate::TargetSystemId;
32
33#[derive(Debug, Clone, PartialEq)]
35pub(crate) struct TargetSystem {
36 id: TargetSystemId,
37 organization_id: OrganizationId,
38 name: String,
39 method: TargetSystemMethod,
40 status: TargetSystemStatus,
41 sealed_data: Option<String>,
42 wrapped_key: Option<String>,
43 creation_date: DateTime<Utc>,
44 revision_date: DateTime<Utc>,
45}
46
47impl TargetSystem {
48 pub(super) fn from_response(
51 organization_id: OrganizationId,
52 response: PamTargetSystemResponseModel,
53 ) -> Result<Self, RotationError> {
54 let (sealed_data, wrapped_key) = if response.organization_id == Some(organization_id.into())
55 {
56 (response.sealed_data, response.wrapped_key)
57 } else {
58 tracing::debug!("PAM target system organization_id mismatch");
59 (None, None)
60 };
61
62 Ok(Self {
63 id: TargetSystemId::new(require!(response.id)),
64 organization_id,
65 name: require!(response.name),
66 method: require!(response.method).into(),
67 status: require!(response.status).into(),
68 sealed_data,
69 wrapped_key,
70 creation_date: require!(response.creation_date).parse()?,
71 revision_date: require!(response.revision_date).parse()?,
72 })
73 }
74
75 fn unseal(
78 &self,
79 key: SymmetricKeySlotId,
80 ctx: &mut KeyStoreContext<KeySlotIds>,
81 ) -> Option<TargetSystemSealedFields> {
82 if self.status == TargetSystemStatus::PendingSeal {
83 return None;
85 }
86 self.try_unseal(key, ctx)
87 .inspect_err(|reason| tracing::debug!(reason, "PAM target system sealed blob rejected"))
88 .ok()
89 }
90
91 fn try_unseal(
92 &self,
93 key: SymmetricKeySlotId,
94 ctx: &mut KeyStoreContext<KeySlotIds>,
95 ) -> Result<TargetSystemSealedFields, &'static str> {
96 let sealed_data = self.sealed_data.as_deref().ok_or("missing blob")?;
97 let wrapped_key = self.wrapped_key.as_deref().ok_or("missing wrapped key")?;
98 let envelope: DataEnvelope = sealed_data.parse().map_err(|_| "malformed sealed data")?;
99 let wrapped_key = EncString::from(wrapped_key);
101
102 let unsealed = unseal_target_system::<KeySlotIds>(&envelope, &wrapped_key, &key, ctx)
103 .map_err(|_| "unseal failed")?;
104
105 if unsealed.target_system_id != Uuid::from(self.id) {
106 return Err("target_system_id mismatch");
107 }
108 if TargetSystemMethod::from(unsealed.method) != self.method {
109 return Err("method mismatch");
110 }
111
112 Ok(TargetSystemSealedFields {
113 kind: unsealed.kind.map(Into::into),
114 password_policy: unsealed.password_policy.into(),
115 supports_session_termination: unsealed.supports_session_termination,
116 })
117 }
118}
119
120impl IdentifyKey<SymmetricKeySlotId> for TargetSystem {
121 fn key_identifier(&self) -> SymmetricKeySlotId {
122 SymmetricKeySlotId::Organization(self.organization_id)
123 }
124}
125
126impl Decryptable<KeySlotIds, SymmetricKeySlotId, TargetSystemView> for TargetSystem {
127 fn decrypt(
129 &self,
130 ctx: &mut KeyStoreContext<KeySlotIds>,
131 key: SymmetricKeySlotId,
132 ) -> Result<TargetSystemView, CryptoError> {
133 Ok(TargetSystemView {
134 id: self.id,
135 organization_id: self.organization_id,
136 name: self.name.clone(),
137 method: self.method,
138 status: self.status,
139 sealed: self.unseal(key, ctx),
140 creation_date: self.creation_date,
141 revision_date: self.revision_date,
142 })
143 }
144}
145
146#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
148#[bitwarden_ffi::wasm_record]
149#[serde(rename_all = "camelCase")]
150pub struct TargetSystemView {
151 pub id: TargetSystemId,
153 pub organization_id: OrganizationId,
155 pub name: String,
157 pub method: TargetSystemMethod,
159 pub status: TargetSystemStatus,
161 pub sealed: Option<TargetSystemSealedFields>,
164 pub creation_date: DateTime<Utc>,
166 pub revision_date: DateTime<Utc>,
168}
169
170#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
172#[bitwarden_ffi::wasm_record]
173#[serde(rename_all = "camelCase")]
174pub struct TargetSystemSealedFields {
175 pub kind: Option<TargetSystemKind>,
178 pub password_policy: PasswordPolicy,
180 pub supports_session_termination: Option<bool>,
183}
184
185#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
190#[bitwarden_ffi::wasm_record]
191#[serde(tag = "method", rename_all = "snake_case")]
192pub enum TargetSystemCreateRequest {
193 #[serde(rename_all = "camelCase")]
195 Automatic {
196 name: String,
198 kind: TargetSystemKind,
200 password_policy: PasswordPolicy,
202 supports_session_termination: bool,
204 },
205 #[serde(rename_all = "camelCase")]
207 Manual {
208 name: String,
210 password_policy: PasswordPolicy,
213 },
214}
215
216impl TargetSystemCreateRequest {
217 pub fn name(&self) -> &str {
219 match self {
220 Self::Automatic { name, .. } | Self::Manual { name, .. } => name,
221 }
222 }
223
224 pub fn password_policy(&self) -> &PasswordPolicy {
226 match self {
227 Self::Automatic {
228 password_policy, ..
229 }
230 | Self::Manual {
231 password_policy, ..
232 } => password_policy,
233 }
234 }
235
236 pub fn method(&self) -> TargetSystemMethod {
238 match self {
239 Self::Automatic { .. } => TargetSystemMethod::Automatic,
240 Self::Manual { .. } => TargetSystemMethod::Manual,
241 }
242 }
243
244 pub fn kind(&self) -> Option<TargetSystemKind> {
246 match self {
247 Self::Automatic { kind, .. } => Some(*kind),
248 Self::Manual { .. } => None,
249 }
250 }
251
252 pub fn supports_session_termination(&self) -> Option<bool> {
255 match self {
256 Self::Automatic {
257 supports_session_termination,
258 ..
259 } => Some(*supports_session_termination),
260 Self::Manual { .. } => None,
261 }
262 }
263}
264
265struct PreparedTargetSystem {
269 name: String,
270 method: SealedTargetSystemMethod,
271 kind: Option<SealedTargetSystemKind>,
272 password_policy: SealedPasswordPolicy,
273 supports_session_termination: Option<bool>,
274}
275
276impl TryFrom<&TargetSystemCreateRequest> for PreparedTargetSystem {
277 type Error = RotationError;
278
279 fn try_from(request: &TargetSystemCreateRequest) -> Result<Self, Self::Error> {
280 validate_name(request.name())?;
281 request
282 .password_policy()
283 .validate()
284 .map_err(RotationValidationError::from)?;
285 let kind = request
286 .kind()
287 .map(SealedTargetSystemKind::try_from)
288 .transpose()?;
289 let method = match request {
290 TargetSystemCreateRequest::Automatic { .. } => SealedTargetSystemMethod::Automatic,
291 TargetSystemCreateRequest::Manual { .. } => SealedTargetSystemMethod::Manual,
292 };
293
294 Ok(Self {
295 name: request.name().to_string(),
296 method,
297 kind,
298 password_policy: request.password_policy().clone().into(),
299 supports_session_termination: request.supports_session_termination(),
300 })
301 }
302}
303
304impl PreparedTargetSystem {
305 fn seal(
308 &self,
309 id: TargetSystemId,
310 key: SymmetricKeySlotId,
311 ctx: &mut KeyStoreContext<KeySlotIds>,
312 ) -> Result<Sealed, RotationError> {
313 if !ctx.has_symmetric_key(key) {
314 return Err(RotationError::MissingOrganizationKey);
315 }
316
317 let data = TargetSystemSealedV1 {
318 target_system_id: id.into(),
319 method: self.method,
320 kind: self.kind,
321 password_policy: self.password_policy.clone(),
322 supports_session_termination: self.supports_session_termination,
323 };
324 Ok(seal_target_system::<KeySlotIds>(data, &key, ctx)?)
325 }
326}
327
328impl From<&PreparedTargetSystem> for RegisterTargetSystemRequestModel {
329 fn from(prepared: &PreparedTargetSystem) -> Self {
330 Self {
331 name: prepared.name.clone(),
332 method: match prepared.method {
333 SealedTargetSystemMethod::Automatic => ApiTargetSystemMethod::Automatic,
334 SealedTargetSystemMethod::Manual => ApiTargetSystemMethod::Manual,
335 },
336 }
337 }
338}
339
340#[bitwarden_error(full)]
345#[derive(Debug, Error)]
346#[error("{message}")]
347pub struct TargetSystemCreateError {
348 pub id: Option<TargetSystemId>,
350 pub message: String,
352}
353
354#[bitwarden_ffi::wasm_object]
356#[derive(FromClient)]
357pub struct TargetSystemsClient {
358 pub(crate) key_store: KeyStore<KeySlotIds>,
359 pub(crate) api_configurations: Arc<ApiConfigurations>,
360}
361
362#[bitwarden_ffi::wasm_export]
363impl TargetSystemsClient {
364 pub async fn list(
366 &self,
367 organization_id: OrganizationId,
368 ) -> Result<Vec<TargetSystemView>, RotationError> {
369 let response = self
370 .api_configurations
371 .api_client
372 .pam_access_connector_rotation_target_systems_api()
373 .get_all(organization_id.into())
374 .await?;
375
376 let targets = response
377 .data
378 .unwrap_or_default()
379 .into_iter()
380 .map(|item| TargetSystem::from_response(organization_id, item))
381 .collect::<Result<Vec<_>, _>>()?;
382 Ok(self.key_store.decrypt_list(&targets)?)
383 }
384
385 pub async fn create(
390 &self,
391 organization_id: OrganizationId,
392 request: TargetSystemCreateRequest,
393 ) -> Result<TargetSystemView, TargetSystemCreateError> {
394 let nothing_created = |error: RotationError| TargetSystemCreateError {
395 id: None,
396 message: error.to_string(),
397 };
398
399 let prepared = PreparedTargetSystem::try_from(&request).map_err(nothing_created)?;
400 let (id, phase_one) = self
401 .register(organization_id, &prepared)
402 .await
403 .map_err(nothing_created)?;
404
405 self.seal_and_finish(organization_id, id, &phase_one, &prepared, &request)
406 .await
407 .map_err(|error| TargetSystemCreateError {
408 id: Some(id),
409 message: error.to_string(),
410 })
411 }
412
413 async fn register(
416 &self,
417 organization_id: OrganizationId,
418 prepared: &PreparedTargetSystem,
419 ) -> Result<(TargetSystemId, PamTargetSystemResponseModel), RotationError> {
420 self.require_organization_key(organization_id)?;
421
422 let response = self
423 .api_configurations
424 .api_client
425 .pam_access_connector_rotation_target_systems_api()
426 .post(organization_id.into(), prepared.into())
427 .await?;
428
429 let id = TargetSystemId::new(require!(response.id));
430 Ok((id, response))
431 }
432
433 fn require_organization_key(
436 &self,
437 organization_id: OrganizationId,
438 ) -> Result<(), RotationError> {
439 let organization_key = SymmetricKeySlotId::Organization(organization_id);
440 if !self.key_store.context().has_symmetric_key(organization_key) {
441 return Err(RotationError::MissingOrganizationKey);
442 }
443
444 Ok(())
445 }
446
447 async fn seal_and_finish(
450 &self,
451 organization_id: OrganizationId,
452 id: TargetSystemId,
453 phase_one: &PamTargetSystemResponseModel,
454 prepared: &PreparedTargetSystem,
455 request: &TargetSystemCreateRequest,
456 ) -> Result<TargetSystemView, RotationError> {
457 self.put_sealed(organization_id, id, prepared).await?;
458
459 let creation_date: DateTime<Utc> = require!(phase_one.creation_date.clone()).parse()?;
460
461 Ok(TargetSystemView {
462 id,
463 organization_id,
464 name: request.name().to_string(),
465 method: request.method(),
466 status: TargetSystemStatus::Active,
467 sealed: Some(TargetSystemSealedFields {
468 kind: request.kind(),
469 password_policy: request.password_policy().clone(),
470 supports_session_termination: request.supports_session_termination(),
471 }),
472 creation_date,
473 revision_date: creation_date,
474 })
475 }
476
477 pub async fn update(
483 &self,
484 organization_id: OrganizationId,
485 id: TargetSystemId,
486 request: TargetSystemCreateRequest,
487 ) -> Result<(), RotationError> {
488 let prepared = PreparedTargetSystem::try_from(&request)?;
489
490 let existing = self.existing(organization_id, id).await?;
491
492 if request.method() != existing.method {
493 return Err(RotationValidationError::TargetSystemMethodMismatch.into());
494 }
495
496 if let Some(existing_sealed) = &existing.sealed
497 && request.kind() != existing_sealed.kind
498 {
499 return Err(RotationValidationError::TargetSystemKindMismatch.into());
500 }
501
502 self.put_sealed(organization_id, id, &prepared).await
503 }
504
505 async fn existing(
507 &self,
508 organization_id: OrganizationId,
509 id: TargetSystemId,
510 ) -> Result<TargetSystemView, RotationError> {
511 let response = self
512 .api_configurations
513 .api_client
514 .pam_access_connector_rotation_target_systems_api()
515 .get_all(organization_id.into())
516 .await?;
517
518 let item = response
519 .data
520 .unwrap_or_default()
521 .into_iter()
522 .find(|item| item.id == Some(id.into()))
523 .ok_or(RotationError::TargetSystemNotFound)?;
524
525 let target = TargetSystem::from_response(organization_id, item)?;
526 Ok(self.key_store.decrypt(&target)?)
527 }
528
529 async fn put_sealed(
531 &self,
532 organization_id: OrganizationId,
533 id: TargetSystemId,
534 prepared: &PreparedTargetSystem,
535 ) -> Result<(), RotationError> {
536 let sealed = {
537 let mut ctx = self.key_store.context_mut();
538 prepared.seal(
539 id,
540 SymmetricKeySlotId::Organization(organization_id),
541 &mut ctx,
542 )?
543 };
544
545 self.api_configurations
546 .api_client
547 .pam_access_connector_rotation_target_systems_api()
548 .put(
549 organization_id.into(),
550 id.into(),
551 UpdateTargetSystemRequestModel {
552 name: prepared.name.clone(),
553 sealed_data: sealed.envelope.to_string(),
554 wrapped_key: sealed.wrapped_key.to_string(),
555 },
556 )
557 .await?;
558
559 Ok(())
560 }
561
562 pub async fn enable(
564 &self,
565 organization_id: OrganizationId,
566 id: TargetSystemId,
567 ) -> Result<(), RotationError> {
568 self.api_configurations
569 .api_client
570 .pam_access_connector_rotation_target_systems_api()
571 .enable(organization_id.into(), id.into())
572 .await?;
573
574 Ok(())
575 }
576
577 pub async fn disable(
580 &self,
581 organization_id: OrganizationId,
582 id: TargetSystemId,
583 ) -> Result<(), RotationError> {
584 self.api_configurations
585 .api_client
586 .pam_access_connector_rotation_target_systems_api()
587 .disable(organization_id.into(), id.into())
588 .await?;
589
590 Ok(())
591 }
592
593 pub async fn delete(
597 &self,
598 organization_id: OrganizationId,
599 id: TargetSystemId,
600 ) -> Result<(), RotationError> {
601 self.api_configurations
602 .api_client
603 .pam_access_connector_rotation_target_systems_api()
604 .delete(organization_id.into(), id.into())
605 .await?;
606
607 Ok(())
608 }
609}
610
611#[cfg(test)]
612mod tests {
613 use bitwarden_api_api::{
614 apis::ApiClient,
615 models::{
616 PamTargetSystemResponseModelListResponseModel,
617 PamTargetSystemStatus as ApiTargetSystemStatus,
618 },
619 };
620 use bitwarden_core::key_management::create_test_crypto_with_user_and_org_key;
621 use bitwarden_crypto::{
622 EncString, KeyStoreContext, SymmetricCryptoKey, SymmetricKeyAlgorithm, safe::DataEnvelope,
623 };
624 use bitwarden_pam_sealed::{
625 Sealed, SealedPasswordPolicy, TargetSystemSealedV1, unseal_target_system,
626 };
627 use uuid::{Uuid, uuid};
628
629 use super::*;
630 use crate::rotation::validate::RotationValidationError;
631
632 fn organization_id() -> OrganizationId {
633 OrganizationId::new(uuid!("11111111-1111-1111-1111-111111111111"))
634 }
635
636 fn target_system_id() -> TargetSystemId {
637 TargetSystemId::new(uuid!("22222222-2222-2222-2222-222222222222"))
638 }
639
640 fn organization_key() -> SymmetricCryptoKey {
641 SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac)
642 }
643
644 fn client(api_client: ApiClient) -> TargetSystemsClient {
645 client_with_org_key(api_client, organization_key())
646 }
647
648 fn client_with_org_key(
649 api_client: ApiClient,
650 organization_key: SymmetricCryptoKey,
651 ) -> TargetSystemsClient {
652 TargetSystemsClient {
653 key_store: create_test_crypto_with_user_and_org_key(
654 SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac),
655 organization_id(),
656 organization_key,
657 ),
658 api_configurations: Arc::new(ApiConfigurations::from_api_client(api_client)),
659 }
660 }
661
662 fn policy() -> PasswordPolicy {
663 PasswordPolicy {
664 min_length: 14,
665 max_length: 20,
666 include_uppercase: true,
667 include_lowercase: true,
668 include_digits: true,
669 include_symbols: false,
670 }
671 }
672
673 fn automatic_create_request() -> TargetSystemCreateRequest {
674 TargetSystemCreateRequest::Automatic {
675 name: "Prod SQL".to_string(),
676 kind: TargetSystemKind::CustomScript,
677 password_policy: policy(),
678 supports_session_termination: true,
679 }
680 }
681
682 fn manual_create_request() -> TargetSystemCreateRequest {
683 TargetSystemCreateRequest::Manual {
684 name: "Legacy mainframe".to_string(),
685 password_policy: policy(),
686 }
687 }
688
689 fn seal_with(organization_key: &SymmetricCryptoKey, data: TargetSystemSealedV1) -> Sealed {
691 let store = create_test_crypto_with_user_and_org_key(
692 SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac),
693 organization_id(),
694 organization_key.clone(),
695 );
696 let mut ctx = store.context_mut();
697 bitwarden_pam_sealed::seal_target_system::<KeySlotIds>(
698 data,
699 &SymmetricKeySlotId::Organization(organization_id()),
700 &mut ctx,
701 )
702 .expect("sealing succeeds")
703 }
704
705 fn sealed_policy() -> SealedPasswordPolicy {
706 SealedPasswordPolicy {
707 min_length: 14,
708 max_length: 20,
709 include_uppercase: true,
710 include_lowercase: true,
711 include_digits: true,
712 include_symbols: false,
713 }
714 }
715
716 fn sample_sealed_data() -> TargetSystemSealedV1 {
717 TargetSystemSealedV1 {
718 target_system_id: target_system_id().into(),
719 method: SealedTargetSystemMethod::Automatic,
720 kind: Some(bitwarden_pam_sealed::SealedTargetSystemKind::CustomScript),
721 password_policy: sealed_policy(),
722 supports_session_termination: Some(true),
723 }
724 }
725
726 fn target_view(
727 organization_id: OrganizationId,
728 response: PamTargetSystemResponseModel,
729 ctx: &mut KeyStoreContext<KeySlotIds>,
730 ) -> Result<TargetSystemView, RotationError> {
731 let target = TargetSystem::from_response(organization_id, response)?;
732 let key = target.key_identifier();
733 Ok(target.decrypt(ctx, key)?)
734 }
735
736 fn sample_response(organization_key: &SymmetricCryptoKey) -> PamTargetSystemResponseModel {
739 let payload = seal_with(organization_key, sample_sealed_data());
740 PamTargetSystemResponseModel {
741 id: Some(target_system_id().into()),
742 organization_id: Some(organization_id().into()),
743 name: Some("Prod SQL".to_string()),
744 method: Some(ApiTargetSystemMethod::Automatic),
745 status: Some(ApiTargetSystemStatus::Active),
746 sealed_data: Some(payload.envelope.to_string()),
747 wrapped_key: Some(payload.wrapped_key.to_string()),
748 creation_date: Some("2026-01-01T00:00:00Z".to_string()),
749 revision_date: Some("2026-01-15T00:00:00Z".to_string()),
750 ..Default::default()
751 }
752 }
753
754 #[test]
755 fn a_full_payload_maps_every_field() {
756 let organization_key = organization_key();
757 let store = create_test_crypto_with_user_and_org_key(
758 SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac),
759 organization_id(),
760 organization_key.clone(),
761 );
762 let mut ctx = store.context_mut();
763
764 let target = target_view(
765 organization_id(),
766 sample_response(&organization_key),
767 &mut ctx,
768 )
769 .expect("the payload is valid");
770
771 assert_eq!(target.id, target_system_id());
772 assert_eq!(target.organization_id, organization_id());
773 assert_eq!(target.name, "Prod SQL");
774 assert_eq!(target.method, TargetSystemMethod::Automatic);
775 assert_eq!(target.status, TargetSystemStatus::Active);
776 let sealed = target.sealed.expect("the blob is sealed");
777 assert_eq!(sealed.kind, Some(TargetSystemKind::CustomScript));
778 assert_eq!(sealed.password_policy, policy());
779 assert_eq!(sealed.supports_session_termination, Some(true));
780 assert_eq!(
781 target.creation_date,
782 "2026-01-01T00:00:00Z".parse::<DateTime<Utc>>().unwrap()
783 );
784 assert_eq!(
785 target.revision_date,
786 "2026-01-15T00:00:00Z".parse::<DateTime<Utc>>().unwrap()
787 );
788 }
789
790 #[test]
791 fn a_pending_seal_target_has_no_sealed_fields() {
792 let organization_key = organization_key();
793 let store = create_test_crypto_with_user_and_org_key(
794 SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac),
795 organization_id(),
796 organization_key.clone(),
797 );
798 let mut ctx = store.context_mut();
799
800 let response = PamTargetSystemResponseModel {
801 status: Some(ApiTargetSystemStatus::PendingSeal),
802 sealed_data: None,
803 ..sample_response(&organization_key)
804 };
805
806 let target =
807 target_view(organization_id(), response, &mut ctx).expect("the payload is valid");
808
809 assert_eq!(target.status, TargetSystemStatus::PendingSeal);
810 assert_eq!(target.sealed, None);
811 }
812
813 #[test]
815 fn invalid_blobs_degrade_to_no_sealed_fields() {
816 let organization_key = organization_key();
817 let other_target_blob = seal_with(
818 &organization_key,
819 TargetSystemSealedV1 {
820 target_system_id: uuid!("99999999-9999-9999-9999-999999999999"),
821 ..sample_sealed_data()
822 },
823 );
824 let wrong_method_blob = seal_with(
825 &organization_key,
826 TargetSystemSealedV1 {
827 method: SealedTargetSystemMethod::Manual,
828 ..sample_sealed_data()
829 },
830 );
831
832 let sample = sample_response(&organization_key);
833 for (sealed_data, wrapped_key) in [
834 (
835 Some("not a sealed blob".to_string()),
836 sample.wrapped_key.clone(),
837 ),
838 (
839 Some(other_target_blob.envelope.to_string()),
840 Some(other_target_blob.wrapped_key.to_string()),
841 ),
842 (
843 Some(wrong_method_blob.envelope.to_string()),
844 Some(wrong_method_blob.wrapped_key.to_string()),
845 ),
846 (None, sample.wrapped_key.clone()),
847 (sample.sealed_data.clone(), None),
848 ] {
849 let store = create_test_crypto_with_user_and_org_key(
850 SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac),
851 organization_id(),
852 organization_key.clone(),
853 );
854 let mut ctx: KeyStoreContext<'_, KeySlotIds> = store.context_mut();
855
856 let response = PamTargetSystemResponseModel {
857 sealed_data,
858 wrapped_key,
859 ..sample_response(&organization_key)
860 };
861
862 let target =
863 target_view(organization_id(), response, &mut ctx).expect("the payload is valid");
864
865 assert_eq!(target.sealed, None, "for status {:?}", target.status);
866 }
867 }
868
869 #[test]
871 fn a_wrapped_key_from_a_different_seal_degrades_to_no_sealed_fields() {
872 let organization_key = organization_key();
873 let store = create_test_crypto_with_user_and_org_key(
874 SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac),
875 organization_id(),
876 organization_key.clone(),
877 );
878 let mut ctx = store.context_mut();
879
880 let other = seal_with(&organization_key, sample_sealed_data());
881 let response = PamTargetSystemResponseModel {
882 wrapped_key: Some(other.wrapped_key.to_string()),
883 ..sample_response(&organization_key)
884 };
885
886 let target =
887 target_view(organization_id(), response, &mut ctx).expect("the payload is valid");
888
889 assert_eq!(target.sealed, None);
890 }
891
892 #[test]
894 fn a_blob_from_a_different_organization_is_not_trusted() {
895 let org_a = organization_id();
896 let org_b = OrganizationId::new(uuid!("55555555-5555-5555-5555-555555555555"));
897 let org_a_key = organization_key();
898 let org_b_key = SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac);
899
900 let org_b_blob = seal_with(&org_b_key, sample_sealed_data());
902
903 let store = KeyStore::<KeySlotIds>::default();
904 {
905 let mut ctx = store.context_mut();
906 #[allow(deprecated)]
907 ctx.set_symmetric_key(SymmetricKeySlotId::Organization(org_a), org_a_key)
908 .expect("mutable context");
909 #[allow(deprecated)]
910 ctx.set_symmetric_key(SymmetricKeySlotId::Organization(org_b), org_b_key)
911 .expect("mutable context");
912 }
913
914 let response = PamTargetSystemResponseModel {
915 organization_id: Some(org_b.into()),
916 sealed_data: Some(org_b_blob.envelope.to_string()),
917 wrapped_key: Some(org_b_blob.wrapped_key.to_string()),
918 ..sample_response(&organization_key())
919 };
920
921 let mut ctx = store.context_mut();
922 let target = target_view(org_a, response, &mut ctx)
923 .expect("the payload is still structurally valid");
924
925 assert_eq!(target.organization_id, org_a);
926 assert_eq!(target.sealed, None);
927 }
928
929 #[test]
930 fn a_missing_required_field_is_reported_rather_than_defaulted() {
931 let organization_key = organization_key();
932 let store = create_test_crypto_with_user_and_org_key(
933 SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac),
934 organization_id(),
935 organization_key.clone(),
936 );
937 let mut ctx = store.context_mut();
938
939 for response in [
940 PamTargetSystemResponseModel {
941 id: None,
942 ..sample_response(&organization_key)
943 },
944 PamTargetSystemResponseModel {
947 name: None,
948 ..sample_response(&organization_key)
949 },
950 PamTargetSystemResponseModel {
951 method: None,
952 ..sample_response(&organization_key)
953 },
954 PamTargetSystemResponseModel {
955 status: None,
956 ..sample_response(&organization_key)
957 },
958 PamTargetSystemResponseModel {
959 creation_date: None,
960 ..sample_response(&organization_key)
961 },
962 PamTargetSystemResponseModel {
963 revision_date: None,
964 ..sample_response(&organization_key)
965 },
966 ] {
967 assert!(matches!(
968 target_view(organization_id(), response, &mut ctx),
969 Err(RotationError::MissingField(_))
970 ));
971 }
972 }
973
974 #[test]
975 fn an_unparseable_date_is_reported() {
976 let organization_key = organization_key();
977 let store = create_test_crypto_with_user_and_org_key(
978 SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac),
979 organization_id(),
980 organization_key.clone(),
981 );
982 let mut ctx = store.context_mut();
983
984 for response in [
985 PamTargetSystemResponseModel {
986 creation_date: Some("not a date".to_string()),
987 ..sample_response(&organization_key)
988 },
989 PamTargetSystemResponseModel {
990 revision_date: Some("not a date".to_string()),
991 ..sample_response(&organization_key)
992 },
993 ] {
994 assert!(matches!(
995 target_view(organization_id(), response, &mut ctx),
996 Err(RotationError::Chrono(_))
997 ));
998 }
999 }
1000
1001 #[test]
1002 fn unrecognized_enum_values_degrade_to_unknown() {
1003 let organization_key = organization_key();
1004 let store = create_test_crypto_with_user_and_org_key(
1005 SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac),
1006 organization_id(),
1007 organization_key.clone(),
1008 );
1009 let mut ctx = store.context_mut();
1010
1011 let response = PamTargetSystemResponseModel {
1012 method: Some(ApiTargetSystemMethod::__Unknown(9)),
1013 status: Some(ApiTargetSystemStatus::__Unknown(9)),
1014 ..sample_response(&organization_key)
1015 };
1016
1017 let target =
1018 target_view(organization_id(), response, &mut ctx).expect("unknown values still map");
1019
1020 assert_eq!(target.method, TargetSystemMethod::Unknown);
1021 assert_eq!(target.status, TargetSystemStatus::Unknown);
1022 assert_eq!(target.sealed, None);
1024 }
1025
1026 #[test]
1027 fn a_create_request_serializes_with_the_servers_method_discriminant() {
1028 let automatic = serde_json::to_value(automatic_create_request()).expect("it serializes");
1029 assert_eq!(automatic["method"], "automatic");
1030 assert_eq!(automatic["kind"], "custom_script");
1031 assert_eq!(automatic["supportsSessionTermination"], true);
1032 assert_eq!(automatic["passwordPolicy"]["minLength"], 14);
1033
1034 let manual = serde_json::to_value(manual_create_request()).expect("it serializes");
1035 assert_eq!(manual["method"], "manual");
1036 assert_eq!(manual["name"], "Legacy mainframe");
1037 assert!(manual.get("kind").is_none());
1038 }
1039
1040 #[tokio::test]
1041 async fn list_maps_the_organizations_target_systems() {
1042 let organization_key = organization_key();
1043 let api_client = {
1044 let organization_key = organization_key.clone();
1045 ApiClient::new_mocked(move |mock| {
1046 mock.pam_access_connector_rotation_target_systems_api
1047 .expect_get_all()
1048 .withf(|org_id| *org_id == Uuid::from(organization_id()))
1049 .returning(move |_org_id| {
1050 Ok(PamTargetSystemResponseModelListResponseModel {
1051 data: Some(vec![sample_response(&organization_key)]),
1052 ..Default::default()
1053 })
1054 })
1055 .once();
1056 })
1057 };
1058
1059 let targets = client_with_org_key(api_client, organization_key)
1060 .list(organization_id())
1061 .await
1062 .expect("the list succeeds");
1063
1064 assert_eq!(targets.len(), 1);
1065 assert_eq!(targets[0].id, target_system_id());
1066 assert!(targets[0].sealed.is_some());
1067 }
1068
1069 #[tokio::test]
1070 async fn an_organization_with_no_target_systems_lists_empty() {
1071 let api_client = ApiClient::new_mocked(move |mock| {
1072 mock.pam_access_connector_rotation_target_systems_api
1073 .expect_get_all()
1074 .returning(move |_org_id| {
1075 Ok(PamTargetSystemResponseModelListResponseModel::default())
1076 })
1077 .once();
1078 });
1079
1080 let targets = client(api_client)
1081 .list(organization_id())
1082 .await
1083 .expect("an absent list is not an error");
1084
1085 assert!(targets.is_empty());
1086 }
1087
1088 #[tokio::test]
1089 async fn list_returns_the_rest_when_one_item_has_an_invalid_blob() {
1090 let organization_key = organization_key();
1091 let api_client = {
1092 let organization_key = organization_key.clone();
1093 ApiClient::new_mocked(move |mock| {
1094 mock.pam_access_connector_rotation_target_systems_api
1095 .expect_get_all()
1096 .returning(move |_org_id| {
1097 let bad = PamTargetSystemResponseModel {
1098 id: Some(uuid!("33333333-3333-3333-3333-333333333333")),
1099 sealed_data: Some("not a sealed blob".to_string()),
1100 ..sample_response(&organization_key)
1101 };
1102 Ok(PamTargetSystemResponseModelListResponseModel {
1103 data: Some(vec![sample_response(&organization_key), bad]),
1104 ..Default::default()
1105 })
1106 })
1107 .once();
1108 })
1109 };
1110
1111 let targets = client_with_org_key(api_client, organization_key)
1112 .list(organization_id())
1113 .await
1114 .expect("one invalid item does not fail the list");
1115
1116 assert_eq!(targets.len(), 2);
1117 assert!(targets[0].sealed.is_some());
1118 assert_eq!(targets[1].sealed, None);
1119 }
1120
1121 #[tokio::test]
1122 async fn list_fails_if_a_required_field_is_missing() {
1123 let organization_key = organization_key();
1124 let api_client = {
1125 let organization_key = organization_key.clone();
1126 ApiClient::new_mocked(move |mock| {
1127 mock.pam_access_connector_rotation_target_systems_api
1128 .expect_get_all()
1129 .returning(move |_org_id| {
1130 Ok(PamTargetSystemResponseModelListResponseModel {
1131 data: Some(vec![
1132 sample_response(&organization_key),
1133 PamTargetSystemResponseModel {
1134 id: None,
1135 ..sample_response(&organization_key)
1136 },
1137 ]),
1138 ..Default::default()
1139 })
1140 })
1141 .once();
1142 })
1143 };
1144
1145 let result = client_with_org_key(api_client, organization_key)
1146 .list(organization_id())
1147 .await;
1148
1149 assert!(matches!(result, Err(RotationError::MissingField(_))));
1150 }
1151
1152 #[tokio::test]
1153 async fn list_surfaces_an_api_error() {
1154 let api_client = ApiClient::new_mocked(move |mock| {
1155 mock.pam_access_connector_rotation_target_systems_api
1156 .expect_get_all()
1157 .returning(move |_org_id| {
1158 Err(bitwarden_api_api::apis::Error::Response(
1159 bitwarden_api_api::apis::ResponseContent {
1160 status: reqwest::StatusCode::INTERNAL_SERVER_ERROR,
1161 message: String::new(),
1162 },
1163 ))
1164 })
1165 .once();
1166 });
1167
1168 let result = client(api_client).list(organization_id()).await;
1169
1170 assert!(matches!(result, Err(RotationError::Api(_))));
1171 }
1172
1173 #[tokio::test]
1174 async fn create_seals_the_put_body_with_the_phase_one_id() {
1175 let organization_key = organization_key();
1176 let api_client = {
1177 let organization_key = organization_key.clone();
1178 ApiClient::new_mocked(move |mock| {
1179 mock.pam_access_connector_rotation_target_systems_api
1180 .expect_post()
1181 .withf(|org_id, request| {
1182 *org_id == Uuid::from(organization_id())
1183 && request.name == "Prod SQL"
1184 && request.method == ApiTargetSystemMethod::Automatic
1185 })
1186 .returning(move |_org_id, _request| {
1187 Ok(PamTargetSystemResponseModel {
1188 id: Some(target_system_id().into()),
1189 organization_id: Some(organization_id().into()),
1190 name: Some("Prod SQL".to_string()),
1191 method: Some(ApiTargetSystemMethod::Automatic),
1192 status: Some(ApiTargetSystemStatus::PendingSeal),
1193 sealed_data: None,
1194 creation_date: Some("2026-01-01T00:00:00Z".to_string()),
1195 revision_date: Some("2026-01-01T00:00:00Z".to_string()),
1196 ..Default::default()
1197 })
1198 })
1199 .once();
1200
1201 let organization_key = organization_key.clone();
1202 mock.pam_access_connector_rotation_target_systems_api
1203 .expect_put()
1204 .withf(move |org_id, id, request| {
1205 if *org_id != Uuid::from(organization_id())
1206 || *id != Uuid::from(target_system_id())
1207 || request.name != "Prod SQL"
1208 {
1209 return false;
1210 }
1211
1212 let store = create_test_crypto_with_user_and_org_key(
1213 SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac),
1214 organization_id(),
1215 organization_key.clone(),
1216 );
1217 let mut ctx = store.context_mut();
1218 let unsealed = unseal_target_system::<KeySlotIds>(
1219 &request
1220 .sealed_data
1221 .parse::<DataEnvelope>()
1222 .expect("sealedData is well-formed"),
1223 &request
1224 .wrapped_key
1225 .parse::<EncString>()
1226 .expect("wrappedKey is well-formed"),
1227 &SymmetricKeySlotId::Organization(organization_id()),
1228 &mut ctx,
1229 )
1230 .expect("the blob unseals under the organization key");
1231
1232 unsealed.target_system_id == Uuid::from(target_system_id())
1233 && unsealed.method == SealedTargetSystemMethod::Automatic
1234 && unsealed.kind
1235 == Some(bitwarden_pam_sealed::SealedTargetSystemKind::CustomScript)
1236 && unsealed.supports_session_termination == Some(true)
1237 && unsealed.password_policy == sealed_policy()
1238 })
1239 .returning(move |_org_id, _id, _request| Ok(()))
1240 .once();
1241 })
1242 };
1243
1244 let target = client_with_org_key(api_client, organization_key)
1245 .create(organization_id(), automatic_create_request())
1246 .await
1247 .expect("creation succeeds");
1248
1249 assert_eq!(target.id, target_system_id());
1250 assert_eq!(target.status, TargetSystemStatus::Active);
1251 assert_eq!(
1252 target.sealed.and_then(|sealed| sealed.kind),
1253 Some(TargetSystemKind::CustomScript)
1254 );
1255 }
1256
1257 #[tokio::test]
1258 async fn create_reports_the_id_when_phase_two_fails() {
1259 let api_client = ApiClient::new_mocked(move |mock| {
1260 mock.pam_access_connector_rotation_target_systems_api
1261 .expect_post()
1262 .returning(move |_org_id, _request| {
1263 Ok(PamTargetSystemResponseModel {
1264 id: Some(target_system_id().into()),
1265 organization_id: Some(organization_id().into()),
1266 name: Some("Prod SQL".to_string()),
1267 method: Some(ApiTargetSystemMethod::Automatic),
1268 status: Some(ApiTargetSystemStatus::PendingSeal),
1269 sealed_data: None,
1270 creation_date: Some("2026-01-01T00:00:00Z".to_string()),
1271 revision_date: Some("2026-01-01T00:00:00Z".to_string()),
1272 ..Default::default()
1273 })
1274 })
1275 .once();
1276
1277 mock.pam_access_connector_rotation_target_systems_api
1278 .expect_put()
1279 .returning(move |_org_id, _id, _request| {
1280 Err(bitwarden_api_api::apis::Error::Response(
1281 bitwarden_api_api::apis::ResponseContent {
1282 status: reqwest::StatusCode::INTERNAL_SERVER_ERROR,
1283 message: String::new(),
1284 },
1285 ))
1286 })
1287 .once();
1288 });
1289
1290 let result = client(api_client)
1291 .create(organization_id(), automatic_create_request())
1292 .await;
1293
1294 let error = result.expect_err("phase 2 failed");
1295 assert_eq!(error.id, Some(target_system_id()));
1296 }
1297
1298 #[tokio::test]
1299 async fn create_refuses_without_the_organization_key_before_calling_the_server() {
1300 let key_store = KeyStore::<KeySlotIds>::default();
1301 let client = TargetSystemsClient {
1302 key_store,
1303 api_configurations: Arc::new(ApiConfigurations::from_api_client(
1304 ApiClient::new_mocked(|_| {}),
1305 )),
1306 };
1307
1308 let error = client
1309 .create(organization_id(), automatic_create_request())
1310 .await
1311 .expect_err("the organization key is missing");
1312
1313 assert_eq!(error.id, None);
1314 assert_eq!(
1315 error.message,
1316 RotationError::MissingOrganizationKey.to_string()
1317 );
1318 }
1319
1320 #[tokio::test]
1323 async fn create_rejects_an_invalid_request_before_calling_the_server() {
1324 let unsatisfiable = PasswordPolicy {
1325 include_uppercase: false,
1326 include_lowercase: false,
1327 include_digits: false,
1328 include_symbols: false,
1329 ..policy()
1330 };
1331
1332 for (request, expected) in [
1333 (
1334 TargetSystemCreateRequest::Automatic {
1335 name: " ".to_string(),
1336 kind: TargetSystemKind::CustomScript,
1337 password_policy: policy(),
1338 supports_session_termination: true,
1339 },
1340 RotationValidationError::InvalidName,
1341 ),
1342 (
1343 TargetSystemCreateRequest::Manual {
1344 name: String::new(),
1345 password_policy: policy(),
1346 },
1347 RotationValidationError::InvalidName,
1348 ),
1349 (
1350 TargetSystemCreateRequest::Automatic {
1351 name: "Prod SQL".to_string(),
1352 kind: TargetSystemKind::CustomScript,
1353 password_policy: unsatisfiable.clone(),
1354 supports_session_termination: true,
1355 },
1356 RotationValidationError::NoCharacterClasses,
1357 ),
1358 ] {
1359 let result = client(ApiClient::new_mocked(|_| {}))
1360 .create(organization_id(), request)
1361 .await;
1362
1363 let error = result.expect_err("the request is invalid");
1364 assert_eq!(error.id, None);
1365 assert_eq!(error.message, expected.to_string());
1366 }
1367 }
1368
1369 #[tokio::test]
1370 async fn create_refuses_an_unrecognized_kind_before_calling_the_server() {
1371 let result = client(ApiClient::new_mocked(|_| {}))
1372 .create(
1373 organization_id(),
1374 TargetSystemCreateRequest::Automatic {
1375 name: "Prod SQL".to_string(),
1376 kind: TargetSystemKind::Unknown,
1377 password_policy: policy(),
1378 supports_session_termination: true,
1379 },
1380 )
1381 .await;
1382
1383 let error = result.expect_err("an unrecognized kind is refused");
1384 assert_eq!(error.id, None);
1385 assert_eq!(
1386 error.message,
1387 RotationError::UnrecognizedVariant.to_string()
1388 );
1389 }
1390
1391 #[tokio::test]
1392 async fn update_seals_and_sends_the_request_to_the_requested_target_system() {
1393 let organization_key = organization_key();
1394 let api_client = {
1395 let organization_key = organization_key.clone();
1396 ApiClient::new_mocked(move |mock| {
1397 let existing_key = organization_key.clone();
1398 mock.pam_access_connector_rotation_target_systems_api
1399 .expect_get_all()
1400 .returning(move |_org_id| {
1401 Ok(PamTargetSystemResponseModelListResponseModel {
1402 data: Some(vec![sample_response(&existing_key)]),
1403 ..Default::default()
1404 })
1405 })
1406 .once();
1407
1408 mock.pam_access_connector_rotation_target_systems_api
1409 .expect_put()
1410 .withf(move |org_id, id, request| {
1411 if *org_id != Uuid::from(organization_id())
1412 || *id != Uuid::from(target_system_id())
1413 || request.name != "Prod SQL"
1414 {
1415 return false;
1416 }
1417
1418 let store = create_test_crypto_with_user_and_org_key(
1419 SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac),
1420 organization_id(),
1421 organization_key.clone(),
1422 );
1423 let mut ctx = store.context_mut();
1424 let unsealed = unseal_target_system::<KeySlotIds>(
1425 &request
1426 .sealed_data
1427 .parse::<DataEnvelope>()
1428 .expect("sealedData is well-formed"),
1429 &request
1430 .wrapped_key
1431 .parse::<EncString>()
1432 .expect("wrappedKey is well-formed"),
1433 &SymmetricKeySlotId::Organization(organization_id()),
1434 &mut ctx,
1435 )
1436 .expect("the blob unseals under the organization key");
1437
1438 unsealed.target_system_id == Uuid::from(target_system_id())
1439 })
1440 .returning(move |_org_id, _id, _request| Ok(()))
1441 .once();
1442 })
1443 };
1444
1445 client_with_org_key(api_client, organization_key)
1446 .update(
1447 organization_id(),
1448 target_system_id(),
1449 automatic_create_request(),
1450 )
1451 .await
1452 .expect("the update succeeds");
1453 }
1454
1455 #[tokio::test]
1456 async fn update_rejects_an_invalid_request_before_calling_the_server() {
1457 let result = client(ApiClient::new_mocked(|_| {}))
1458 .update(
1459 organization_id(),
1460 target_system_id(),
1461 TargetSystemCreateRequest::Automatic {
1462 name: " ".to_string(),
1463 kind: TargetSystemKind::CustomScript,
1464 password_policy: policy(),
1465 supports_session_termination: true,
1466 },
1467 )
1468 .await;
1469
1470 assert!(matches!(
1471 result,
1472 Err(RotationError::Validation(
1473 RotationValidationError::InvalidName
1474 ))
1475 ));
1476 }
1477
1478 #[tokio::test]
1480 async fn update_rejects_a_method_different_from_the_existing_target() {
1481 let organization_key = organization_key();
1482 let api_client = {
1483 let organization_key = organization_key.clone();
1484 ApiClient::new_mocked(move |mock| {
1485 mock.pam_access_connector_rotation_target_systems_api
1486 .expect_get_all()
1487 .returning(move |_org_id| {
1488 Ok(PamTargetSystemResponseModelListResponseModel {
1489 data: Some(vec![sample_response(&organization_key)]),
1491 ..Default::default()
1492 })
1493 })
1494 .once();
1495 })
1496 };
1497
1498 let result = client_with_org_key(api_client, organization_key)
1499 .update(
1500 organization_id(),
1501 target_system_id(),
1502 manual_create_request(),
1504 )
1505 .await;
1506
1507 assert!(matches!(
1508 result,
1509 Err(RotationError::Validation(
1510 RotationValidationError::TargetSystemMethodMismatch
1511 ))
1512 ));
1513 }
1514
1515 #[tokio::test]
1517 async fn update_rejects_a_kind_different_from_the_existing_sealed_target() {
1518 let organization_key = organization_key();
1519 let api_client = {
1520 let organization_key = organization_key.clone();
1521 ApiClient::new_mocked(move |mock| {
1522 mock.pam_access_connector_rotation_target_systems_api
1523 .expect_get_all()
1524 .returning(move |_org_id| {
1525 Ok(PamTargetSystemResponseModelListResponseModel {
1526 data: Some(vec![sample_response(&organization_key)]),
1529 ..Default::default()
1530 })
1531 })
1532 .once();
1533 })
1534 };
1535
1536 let result = client_with_org_key(api_client, organization_key)
1537 .update(
1538 organization_id(),
1539 target_system_id(),
1540 TargetSystemCreateRequest::Automatic {
1541 name: "Prod SQL".to_string(),
1542 kind: TargetSystemKind::Entra,
1544 password_policy: policy(),
1545 supports_session_termination: true,
1546 },
1547 )
1548 .await;
1549
1550 assert!(matches!(
1551 result,
1552 Err(RotationError::Validation(
1553 RotationValidationError::TargetSystemKindMismatch
1554 ))
1555 ));
1556 }
1557
1558 #[tokio::test]
1559 async fn update_fails_when_the_target_system_does_not_exist() {
1560 let api_client = ApiClient::new_mocked(move |mock| {
1561 mock.pam_access_connector_rotation_target_systems_api
1562 .expect_get_all()
1563 .returning(move |_org_id| {
1564 Ok(PamTargetSystemResponseModelListResponseModel::default())
1565 })
1566 .once();
1567 });
1568
1569 let result = client(api_client)
1570 .update(
1571 organization_id(),
1572 target_system_id(),
1573 automatic_create_request(),
1574 )
1575 .await;
1576
1577 assert!(matches!(result, Err(RotationError::TargetSystemNotFound)));
1578 }
1579
1580 #[tokio::test]
1581 async fn enable_targets_the_requested_target_system() {
1582 let api_client = ApiClient::new_mocked(move |mock| {
1583 mock.pam_access_connector_rotation_target_systems_api
1584 .expect_enable()
1585 .withf(|org_id, id| {
1586 *org_id == Uuid::from(organization_id())
1587 && *id == Uuid::from(target_system_id())
1588 })
1589 .returning(move |_org_id, _id| Ok(()))
1590 .once();
1591 });
1592
1593 client(api_client)
1594 .enable(organization_id(), target_system_id())
1595 .await
1596 .expect("enabling succeeds");
1597 }
1598
1599 #[tokio::test]
1600 async fn disable_targets_the_requested_target_system() {
1601 let api_client = ApiClient::new_mocked(move |mock| {
1602 mock.pam_access_connector_rotation_target_systems_api
1603 .expect_disable()
1604 .withf(|org_id, id| {
1605 *org_id == Uuid::from(organization_id())
1606 && *id == Uuid::from(target_system_id())
1607 })
1608 .returning(move |_org_id, _id| Ok(()))
1609 .once();
1610 });
1611
1612 client(api_client)
1613 .disable(organization_id(), target_system_id())
1614 .await
1615 .expect("disabling succeeds");
1616 }
1617
1618 #[tokio::test]
1619 async fn disable_surfaces_an_api_error() {
1620 let api_client = ApiClient::new_mocked(move |mock| {
1621 mock.pam_access_connector_rotation_target_systems_api
1622 .expect_disable()
1623 .returning(move |_org_id, _id| {
1624 Err(bitwarden_api_api::apis::Error::Response(
1625 bitwarden_api_api::apis::ResponseContent {
1626 status: reqwest::StatusCode::CONFLICT,
1627 message: String::new(),
1628 },
1629 ))
1630 })
1631 .once();
1632 });
1633
1634 let result = client(api_client)
1635 .disable(organization_id(), target_system_id())
1636 .await;
1637
1638 assert!(matches!(result, Err(RotationError::Api(_))));
1639 }
1640
1641 #[tokio::test]
1642 async fn delete_targets_the_requested_target_system() {
1643 let api_client = ApiClient::new_mocked(move |mock| {
1644 mock.pam_access_connector_rotation_target_systems_api
1645 .expect_delete()
1646 .withf(|org_id, id| {
1647 *org_id == Uuid::from(organization_id())
1648 && *id == Uuid::from(target_system_id())
1649 })
1650 .returning(move |_org_id, _id| Ok(()))
1651 .once();
1652 });
1653
1654 client(api_client)
1655 .delete(organization_id(), target_system_id())
1656 .await
1657 .expect("deleting succeeds");
1658 }
1659
1660 #[tokio::test]
1663 async fn delete_surfaces_the_refusal_when_a_config_still_names_the_target() {
1664 let api_client = ApiClient::new_mocked(move |mock| {
1665 mock.pam_access_connector_rotation_target_systems_api
1666 .expect_delete()
1667 .returning(move |_org_id, _id| {
1668 Err(bitwarden_api_api::apis::Error::Response(
1669 bitwarden_api_api::apis::ResponseContent {
1670 status: reqwest::StatusCode::CONFLICT,
1671 message: String::new(),
1672 },
1673 ))
1674 })
1675 .once();
1676 });
1677
1678 let result = client(api_client)
1679 .delete(organization_id(), target_system_id())
1680 .await;
1681
1682 assert!(matches!(result, Err(RotationError::Api(_))));
1683 }
1684}