1use bitwarden_api_api::models::{
2 PamAccessConnectorStatus as ApiAccessConnectorStatus, PamRotationAttemptResponseModel,
3 PamRotationAttemptStatus as ApiRotationAttemptStatus, PamRotationJobResponseModel,
4 PamRotationJobStatus as ApiRotationJobStatus, PamRotationSource as ApiRotationSource,
5 PamRotationSyncState as ApiRotationSyncState,
6 PamSessionTerminationOutcome as ApiSessionTerminationOutcome,
7 PamTargetSystemMethod as ApiTargetSystemMethod, PamTargetSystemStatus as ApiTargetSystemStatus,
8};
9use bitwarden_core::require;
10use bitwarden_pam_sealed::{
11 PasswordPolicyError, SealedPasswordPolicy, SealedTargetSystemKind, SealedTargetSystemMethod,
12};
13use chrono::{DateTime, Utc};
14use serde::{Deserialize, Serialize};
15
16use super::error::RotationError;
17use crate::{AccessConnectorId, RotationAttemptId, RotationConfigId, RotationJobId};
18
19#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
22#[bitwarden_ffi::wasm_record]
23#[serde(rename_all = "snake_case")]
24pub enum AccessConnectorStatus {
25 Enabled,
27 Disabled,
30 Unknown,
33}
34
35impl From<ApiAccessConnectorStatus> for AccessConnectorStatus {
36 fn from(status: ApiAccessConnectorStatus) -> Self {
37 match status {
38 ApiAccessConnectorStatus::Enabled => Self::Enabled,
39 ApiAccessConnectorStatus::Disabled => Self::Disabled,
40 ApiAccessConnectorStatus::__Unknown(_) => Self::Unknown,
41 }
42 }
43}
44
45#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
47#[bitwarden_ffi::wasm_record]
48#[serde(rename_all = "snake_case")]
49pub enum TargetSystemMethod {
50 Automatic,
52 Manual,
54 Unknown,
56}
57
58impl From<ApiTargetSystemMethod> for TargetSystemMethod {
59 fn from(method: ApiTargetSystemMethod) -> Self {
60 match method {
61 ApiTargetSystemMethod::Automatic => Self::Automatic,
62 ApiTargetSystemMethod::Manual => Self::Manual,
63 ApiTargetSystemMethod::__Unknown(_) => Self::Unknown,
64 }
65 }
66}
67
68impl TryFrom<TargetSystemMethod> for ApiTargetSystemMethod {
69 type Error = RotationError;
70
71 fn try_from(method: TargetSystemMethod) -> Result<Self, Self::Error> {
72 match method {
73 TargetSystemMethod::Automatic => Ok(Self::Automatic),
74 TargetSystemMethod::Manual => Ok(Self::Manual),
75 TargetSystemMethod::Unknown => Err(RotationError::UnrecognizedVariant),
77 }
78 }
79}
80
81#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
85#[bitwarden_ffi::wasm_record]
86#[serde(rename_all = "snake_case")]
87pub enum TargetSystemKind {
88 Entra,
90 CustomScript,
92 ActiveDirectory,
95 Unknown,
97}
98
99impl From<SealedTargetSystemMethod> for TargetSystemMethod {
100 fn from(method: SealedTargetSystemMethod) -> Self {
101 match method {
102 SealedTargetSystemMethod::Automatic => Self::Automatic,
103 SealedTargetSystemMethod::Manual => Self::Manual,
104 }
105 }
106}
107
108impl From<SealedTargetSystemKind> for TargetSystemKind {
109 fn from(kind: SealedTargetSystemKind) -> Self {
110 match kind {
111 SealedTargetSystemKind::Entra => Self::Entra,
112 SealedTargetSystemKind::CustomScript => Self::CustomScript,
113 SealedTargetSystemKind::ActiveDirectory => Self::ActiveDirectory,
114 }
115 }
116}
117
118impl TryFrom<TargetSystemKind> for SealedTargetSystemKind {
119 type Error = RotationError;
120
121 fn try_from(kind: TargetSystemKind) -> Result<Self, Self::Error> {
122 match kind {
123 TargetSystemKind::Entra => Ok(Self::Entra),
124 TargetSystemKind::CustomScript => Ok(Self::CustomScript),
125 TargetSystemKind::ActiveDirectory => Ok(Self::ActiveDirectory),
126 TargetSystemKind::Unknown => Err(RotationError::UnrecognizedVariant),
127 }
128 }
129}
130
131#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
133#[bitwarden_ffi::wasm_record]
134#[serde(rename_all = "snake_case")]
135pub enum TargetSystemStatus {
136 Active,
138 Disabled,
140 PendingSeal,
148 Unknown,
150}
151
152impl From<ApiTargetSystemStatus> for TargetSystemStatus {
153 fn from(status: ApiTargetSystemStatus) -> Self {
154 match status {
155 ApiTargetSystemStatus::Active => Self::Active,
156 ApiTargetSystemStatus::Disabled => Self::Disabled,
157 ApiTargetSystemStatus::PendingSeal => Self::PendingSeal,
158 ApiTargetSystemStatus::__Unknown(_) => Self::Unknown,
159 }
160 }
161}
162
163#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
165#[bitwarden_ffi::wasm_record]
166#[serde(rename_all = "snake_case")]
167pub enum RotationSource {
168 Scheduled,
170 OnDemand,
172 AccessEnd,
174 Unknown,
176}
177
178impl From<ApiRotationSource> for RotationSource {
179 fn from(source: ApiRotationSource) -> Self {
180 match source {
181 ApiRotationSource::Scheduled => Self::Scheduled,
182 ApiRotationSource::OnDemand => Self::OnDemand,
183 ApiRotationSource::AccessEnd => Self::AccessEnd,
184 ApiRotationSource::__Unknown(_) => Self::Unknown,
185 }
186 }
187}
188
189#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
191#[bitwarden_ffi::wasm_record]
192#[serde(rename_all = "snake_case")]
193pub enum RotationJobStatus {
194 Pending,
196 Claimed,
198 Succeeded,
200 Failed,
202 TimedOut,
204 Unknown,
206}
207
208impl From<ApiRotationJobStatus> for RotationJobStatus {
209 fn from(status: ApiRotationJobStatus) -> Self {
210 match status {
211 ApiRotationJobStatus::Pending => Self::Pending,
212 ApiRotationJobStatus::Claimed => Self::Claimed,
213 ApiRotationJobStatus::Succeeded => Self::Succeeded,
214 ApiRotationJobStatus::Failed => Self::Failed,
215 ApiRotationJobStatus::TimedOut => Self::TimedOut,
216 ApiRotationJobStatus::__Unknown(_) => Self::Unknown,
217 }
218 }
219}
220
221#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
223#[bitwarden_ffi::wasm_record]
224#[serde(rename_all = "snake_case")]
225pub enum RotationAttemptStatus {
226 Executing,
228 Rotated,
230 Errored,
232 Abandoned,
235 Unknown,
237}
238
239impl From<ApiRotationAttemptStatus> for RotationAttemptStatus {
240 fn from(status: ApiRotationAttemptStatus) -> Self {
241 match status {
242 ApiRotationAttemptStatus::Executing => Self::Executing,
243 ApiRotationAttemptStatus::Rotated => Self::Rotated,
244 ApiRotationAttemptStatus::Errored => Self::Errored,
245 ApiRotationAttemptStatus::Abandoned => Self::Abandoned,
246 ApiRotationAttemptStatus::__Unknown(_) => Self::Unknown,
247 }
248 }
249}
250
251#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
253#[bitwarden_ffi::wasm_record]
254#[serde(rename_all = "snake_case")]
255pub enum RotationSyncState {
256 TargetUnchanged,
258 TargetUpdated,
260 Indeterminate,
263 Unknown,
265}
266
267impl From<ApiRotationSyncState> for RotationSyncState {
268 fn from(state: ApiRotationSyncState) -> Self {
269 match state {
270 ApiRotationSyncState::TargetUnchanged => Self::TargetUnchanged,
271 ApiRotationSyncState::TargetUpdated => Self::TargetUpdated,
272 ApiRotationSyncState::Indeterminate => Self::Indeterminate,
273 ApiRotationSyncState::__Unknown(_) => Self::Unknown,
274 }
275 }
276}
277
278impl TryFrom<RotationSyncState> for ApiRotationSyncState {
279 type Error = RotationError;
280
281 fn try_from(state: RotationSyncState) -> Result<Self, Self::Error> {
282 match state {
283 RotationSyncState::TargetUnchanged => Ok(Self::TargetUnchanged),
284 RotationSyncState::TargetUpdated => Ok(Self::TargetUpdated),
285 RotationSyncState::Indeterminate => Ok(Self::Indeterminate),
286 RotationSyncState::Unknown => Err(RotationError::UnrecognizedVariant),
287 }
288 }
289}
290
291#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
293#[bitwarden_ffi::wasm_record]
294#[serde(rename_all = "snake_case")]
295pub enum SessionTerminationOutcome {
296 NotRequested,
298 Terminated,
300 TermFailed,
302 Unknown,
304}
305
306impl From<ApiSessionTerminationOutcome> for SessionTerminationOutcome {
307 fn from(outcome: ApiSessionTerminationOutcome) -> Self {
308 match outcome {
309 ApiSessionTerminationOutcome::NotRequested => Self::NotRequested,
310 ApiSessionTerminationOutcome::Terminated => Self::Terminated,
311 ApiSessionTerminationOutcome::TermFailed => Self::TermFailed,
312 ApiSessionTerminationOutcome::__Unknown(_) => Self::Unknown,
313 }
314 }
315}
316
317impl TryFrom<SessionTerminationOutcome> for ApiSessionTerminationOutcome {
318 type Error = RotationError;
319
320 fn try_from(outcome: SessionTerminationOutcome) -> Result<Self, Self::Error> {
321 match outcome {
322 SessionTerminationOutcome::NotRequested => Ok(Self::NotRequested),
323 SessionTerminationOutcome::Terminated => Ok(Self::Terminated),
324 SessionTerminationOutcome::TermFailed => Ok(Self::TermFailed),
325 SessionTerminationOutcome::Unknown => Err(RotationError::UnrecognizedVariant),
326 }
327 }
328}
329
330#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
333#[bitwarden_ffi::wasm_record]
334#[serde(rename_all = "snake_case")]
335pub enum RotationFailureCode {
336 NoActiveSession,
338 CredentialsUnresolved,
340 InvalidPolicy,
342 UnsupportedKind,
344 TargetRejected,
347 TargetUnreachable,
349 VerificationFailed,
351 ScriptFailed,
353 ScriptTimeout,
355 CipherWriteRejected,
357 CipherEncryptFailed,
359 SealedDataRejected,
361 Internal,
363 Unknown,
365}
366
367impl RotationFailureCode {
368 pub fn as_token(self) -> Result<&'static str, RotationError> {
371 match self {
372 Self::NoActiveSession => Ok("no_active_session"),
373 Self::CredentialsUnresolved => Ok("credentials_unresolved"),
374 Self::InvalidPolicy => Ok("invalid_policy"),
375 Self::UnsupportedKind => Ok("unsupported_kind"),
376 Self::TargetRejected => Ok("target_rejected"),
377 Self::TargetUnreachable => Ok("target_unreachable"),
378 Self::VerificationFailed => Ok("verification_failed"),
379 Self::ScriptFailed => Ok("script_failed"),
380 Self::ScriptTimeout => Ok("script_timeout"),
381 Self::CipherWriteRejected => Ok("cipher_write_rejected"),
382 Self::CipherEncryptFailed => Ok("cipher_encrypt_failed"),
383 Self::SealedDataRejected => Ok("sealed_data_rejected"),
384 Self::Internal => Ok("internal"),
385 Self::Unknown => Err(RotationError::UnrecognizedVariant),
386 }
387 }
388
389 pub fn from_token(token: &str) -> Self {
392 match token {
393 "no_active_session" => Self::NoActiveSession,
394 "credentials_unresolved" => Self::CredentialsUnresolved,
395 "invalid_policy" => Self::InvalidPolicy,
396 "unsupported_kind" => Self::UnsupportedKind,
397 "target_rejected" => Self::TargetRejected,
398 "target_unreachable" => Self::TargetUnreachable,
399 "verification_failed" => Self::VerificationFailed,
400 "script_failed" => Self::ScriptFailed,
401 "script_timeout" => Self::ScriptTimeout,
402 "cipher_write_rejected" => Self::CipherWriteRejected,
403 "cipher_encrypt_failed" => Self::CipherEncryptFailed,
404 "sealed_data_rejected" => Self::SealedDataRejected,
405 "internal" => Self::Internal,
406 _ => Self::Unknown,
407 }
408 }
409}
410
411#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
415#[bitwarden_ffi::wasm_record]
416#[serde(rename_all = "camelCase")]
417pub struct PasswordPolicy {
418 pub min_length: u32,
420 pub max_length: u32,
422 pub include_uppercase: bool,
424 pub include_lowercase: bool,
426 pub include_digits: bool,
428 pub include_symbols: bool,
430}
431
432impl PasswordPolicy {
433 pub(crate) fn validate(&self) -> Result<(), PasswordPolicyError> {
437 SealedPasswordPolicy::from(self.clone()).validate()
438 }
439}
440
441impl From<SealedPasswordPolicy> for PasswordPolicy {
442 fn from(policy: SealedPasswordPolicy) -> Self {
443 Self {
444 min_length: policy.min_length,
445 max_length: policy.max_length,
446 include_uppercase: policy.include_uppercase,
447 include_lowercase: policy.include_lowercase,
448 include_digits: policy.include_digits,
449 include_symbols: policy.include_symbols,
450 }
451 }
452}
453
454impl From<PasswordPolicy> for SealedPasswordPolicy {
455 fn from(policy: PasswordPolicy) -> Self {
456 Self {
457 min_length: policy.min_length,
458 max_length: policy.max_length,
459 include_uppercase: policy.include_uppercase,
460 include_lowercase: policy.include_lowercase,
461 include_digits: policy.include_digits,
462 include_symbols: policy.include_symbols,
463 }
464 }
465}
466
467#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
469#[bitwarden_ffi::wasm_record]
470#[serde(rename_all = "camelCase")]
471pub struct RotationAttempt {
472 pub id: RotationAttemptId,
474 pub job_id: RotationJobId,
476 pub claimed_by_access_connector_id: Option<AccessConnectorId>,
478 pub status: RotationAttemptStatus,
480 pub failure_reason: Option<String>,
483 pub failure_code: Option<RotationFailureCode>,
487 pub cipher_updated: bool,
489 pub sync_state: Option<RotationSyncState>,
491 pub session_termination: Option<SessionTerminationOutcome>,
493 pub started_at: DateTime<Utc>,
495 pub ended_at: Option<DateTime<Utc>>,
497}
498
499impl TryFrom<PamRotationAttemptResponseModel> for RotationAttempt {
500 type Error = RotationError;
501
502 fn try_from(response: PamRotationAttemptResponseModel) -> Result<Self, Self::Error> {
503 Ok(Self {
504 id: RotationAttemptId::new(require!(response.id)),
505 job_id: RotationJobId::new(require!(response.job_id)),
506 claimed_by_access_connector_id: response
507 .claimed_by_access_connector_id
508 .map(AccessConnectorId::new),
509 status: require!(response.status).into(),
510 failure_code: response
511 .failure_reason
512 .as_deref()
513 .map(failure_code_from_reason),
514 failure_reason: response.failure_reason,
515 cipher_updated: response.cipher_updated.unwrap_or(false),
516 sync_state: response.sync_state.map(Into::into),
517 session_termination: response.session_termination.map(Into::into),
518 started_at: require!(response.creation_date).parse()?,
519 ended_at: response
520 .resolved_date
521 .map(|date| date.parse())
522 .transpose()?,
523 })
524 }
525}
526
527fn failure_code_from_reason(reason: &str) -> RotationFailureCode {
529 let token = reason.split_once(": ").map_or(reason, |(code, _)| code);
530 RotationFailureCode::from_token(token)
531}
532
533#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
536#[bitwarden_ffi::wasm_record]
537#[serde(rename_all = "camelCase")]
538pub struct RotationJob {
539 pub id: RotationJobId,
541 pub rotation_config_id: RotationConfigId,
543 pub source: RotationSource,
545 pub status: RotationJobStatus,
547 pub claimed_by_access_connector_id: Option<AccessConnectorId>,
549 pub claimed_at: Option<DateTime<Utc>>,
551 pub created_at: DateTime<Utc>,
553 pub next_claimable_at: Option<DateTime<Utc>>,
555 pub expires_at: Option<DateTime<Utc>>,
557 pub attempts: Vec<RotationAttempt>,
559}
560
561impl TryFrom<PamRotationJobResponseModel> for RotationJob {
562 type Error = RotationError;
563
564 fn try_from(response: PamRotationJobResponseModel) -> Result<Self, Self::Error> {
565 Ok(Self {
566 id: RotationJobId::new(require!(response.id)),
567 rotation_config_id: RotationConfigId::new(require!(response.rotation_config_id)),
568 source: require!(response.source).into(),
569 status: require!(response.status).into(),
570 claimed_by_access_connector_id: response
571 .claimed_by_access_connector_id
572 .map(AccessConnectorId::new),
573 claimed_at: response.claimed_at.map(|date| date.parse()).transpose()?,
574 created_at: require!(response.creation_date).parse()?,
575 next_claimable_at: response
576 .next_claimable_at
577 .map(|date| date.parse())
578 .transpose()?,
579 expires_at: response.expires_at.map(|date| date.parse()).transpose()?,
580 attempts: response
581 .attempts
582 .unwrap_or_default()
583 .into_iter()
584 .map(RotationAttempt::try_from)
585 .collect::<Result<Vec<_>, _>>()?,
586 })
587 }
588}
589
590#[cfg(test)]
591mod tests {
592 use super::*;
593
594 const KNOWN_CODES: [(RotationFailureCode, &str); 13] = [
595 (RotationFailureCode::NoActiveSession, "no_active_session"),
596 (
597 RotationFailureCode::CredentialsUnresolved,
598 "credentials_unresolved",
599 ),
600 (RotationFailureCode::InvalidPolicy, "invalid_policy"),
601 (RotationFailureCode::UnsupportedKind, "unsupported_kind"),
602 (RotationFailureCode::TargetRejected, "target_rejected"),
603 (RotationFailureCode::TargetUnreachable, "target_unreachable"),
604 (
605 RotationFailureCode::VerificationFailed,
606 "verification_failed",
607 ),
608 (RotationFailureCode::ScriptFailed, "script_failed"),
609 (RotationFailureCode::ScriptTimeout, "script_timeout"),
610 (
611 RotationFailureCode::CipherWriteRejected,
612 "cipher_write_rejected",
613 ),
614 (
615 RotationFailureCode::CipherEncryptFailed,
616 "cipher_encrypt_failed",
617 ),
618 (
619 RotationFailureCode::SealedDataRejected,
620 "sealed_data_rejected",
621 ),
622 (RotationFailureCode::Internal, "internal"),
623 ];
624
625 #[test]
626 fn sync_state_maps_to_api_values() {
627 let pairs = [
628 (RotationSyncState::TargetUnchanged, 0),
629 (RotationSyncState::TargetUpdated, 1),
630 (RotationSyncState::Indeterminate, 2),
631 ];
632 for (state, expected) in pairs {
633 let api = ApiRotationSyncState::try_from(state).unwrap();
634 assert_eq!(api.as_i64(), expected, "{state:?}");
635 }
636 }
637
638 #[test]
639 fn sync_state_unknown_is_refused() {
640 assert!(matches!(
641 ApiRotationSyncState::try_from(RotationSyncState::Unknown),
642 Err(RotationError::UnrecognizedVariant)
643 ));
644 }
645
646 #[test]
647 fn session_termination_maps_to_api_values() {
648 let pairs = [
649 (SessionTerminationOutcome::NotRequested, 0),
650 (SessionTerminationOutcome::Terminated, 1),
651 (SessionTerminationOutcome::TermFailed, 2),
652 ];
653 for (outcome, expected) in pairs {
654 let api = ApiSessionTerminationOutcome::try_from(outcome).unwrap();
655 assert_eq!(api.as_i64(), expected, "{outcome:?}");
656 }
657 }
658
659 #[test]
660 fn session_termination_unknown_is_refused() {
661 assert!(matches!(
662 ApiSessionTerminationOutcome::try_from(SessionTerminationOutcome::Unknown),
663 Err(RotationError::UnrecognizedVariant)
664 ));
665 }
666
667 #[test]
668 fn failure_code_tokens_round_trip() {
669 for (code, token) in KNOWN_CODES {
670 assert_eq!(code.as_token().unwrap(), token, "{code:?}");
671 assert_eq!(RotationFailureCode::from_token(token), code);
672 assert_eq!(
673 serde_json::to_string(&code).unwrap(),
674 format!("\"{token}\""),
675 "{code:?}"
676 );
677 }
678 }
679
680 #[test]
681 fn failure_code_tokens_are_bounded_snake_case() {
682 for (code, _) in KNOWN_CODES {
683 let token = code.as_token().unwrap();
684 assert!(token.len() <= 100, "{code:?}");
685 assert!(
686 token.chars().all(|c| c.is_ascii_lowercase() || c == '_'),
687 "{code:?}"
688 );
689 }
690 }
691
692 #[test]
693 fn failure_code_unknown_is_refused() {
694 assert!(matches!(
695 RotationFailureCode::Unknown.as_token(),
696 Err(RotationError::UnrecognizedVariant)
697 ));
698 }
699
700 #[test]
701 fn failure_code_unrecognized_token_is_unknown() {
702 for token in ["", "from_the_future", "unknown", "Internal"] {
703 assert_eq!(
704 RotationFailureCode::from_token(token),
705 RotationFailureCode::Unknown,
706 "{token:?}"
707 );
708 }
709 }
710
711 fn attempt_response(failure_reason: Option<&str>) -> PamRotationAttemptResponseModel {
712 PamRotationAttemptResponseModel {
713 id: Some(uuid::Uuid::new_v4()),
714 job_id: Some(uuid::Uuid::new_v4()),
715 status: Some(ApiRotationAttemptStatus::Errored),
716 failure_reason: failure_reason.map(str::to_owned),
717 creation_date: Some("2026-01-01T00:00:00Z".to_owned()),
718 ..Default::default()
719 }
720 }
721
722 #[test]
723 fn attempt_failure_code_from_code_alone() {
724 let attempt = RotationAttempt::try_from(attempt_response(Some("script_timeout"))).unwrap();
725 assert_eq!(
726 attempt.failure_code,
727 Some(RotationFailureCode::ScriptTimeout)
728 );
729 assert_eq!(attempt.failure_reason.as_deref(), Some("script_timeout"));
730 }
731
732 #[test]
733 fn attempt_failure_code_from_code_and_detail() {
734 let attempt = RotationAttempt::try_from(attempt_response(Some(
735 "target_rejected: HTTP 403: forbidden",
736 )))
737 .unwrap();
738 assert_eq!(
739 attempt.failure_code,
740 Some(RotationFailureCode::TargetRejected)
741 );
742 assert_eq!(
743 attempt.failure_reason.as_deref(),
744 Some("target_rejected: HTTP 403: forbidden")
745 );
746 }
747
748 #[test]
749 fn attempt_failure_code_unrecognized() {
750 let attempt =
751 RotationAttempt::try_from(attempt_response(Some("from_the_future: detail"))).unwrap();
752 assert_eq!(attempt.failure_code, Some(RotationFailureCode::Unknown));
753 }
754
755 #[test]
756 fn attempt_failure_code_none_without_reason() {
757 let attempt = RotationAttempt::try_from(attempt_response(None)).unwrap();
758 assert_eq!(attempt.failure_code, None);
759 }
760
761 #[test]
762 fn every_named_kind_round_trips_through_the_sealed_kind() {
763 for kind in [
764 TargetSystemKind::Entra,
765 TargetSystemKind::CustomScript,
766 TargetSystemKind::ActiveDirectory,
767 ] {
768 let sealed = SealedTargetSystemKind::try_from(kind).unwrap();
769 assert_eq!(TargetSystemKind::from(sealed), kind);
770 }
771 }
772
773 #[test]
774 fn unknown_kind_cannot_be_sealed() {
775 assert!(matches!(
776 SealedTargetSystemKind::try_from(TargetSystemKind::Unknown),
777 Err(RotationError::UnrecognizedVariant)
778 ));
779 }
780
781 #[test]
782 fn kinds_serialize_to_snake_case_names() {
783 for (kind, name) in [
784 (TargetSystemKind::Entra, "entra"),
785 (TargetSystemKind::CustomScript, "custom_script"),
786 (TargetSystemKind::ActiveDirectory, "active_directory"),
787 (TargetSystemKind::Unknown, "unknown"),
788 ] {
789 assert_eq!(serde_json::to_value(kind).unwrap(), name);
790 assert_eq!(
791 serde_json::from_value::<TargetSystemKind>(name.into()).unwrap(),
792 kind
793 );
794 }
795 }
796}