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#[cfg(feature = "wasm")]
16use tsify::Tsify;
17
18use super::error::RotationError;
19use crate::{AccessConnectorId, RotationAttemptId, RotationConfigId, RotationJobId};
20
21#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
24#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
25#[serde(rename_all = "snake_case")]
26pub enum AccessConnectorStatus {
27 Enabled,
29 Disabled,
32 Unknown,
35}
36
37impl From<ApiAccessConnectorStatus> for AccessConnectorStatus {
38 fn from(status: ApiAccessConnectorStatus) -> Self {
39 match status {
40 ApiAccessConnectorStatus::Enabled => Self::Enabled,
41 ApiAccessConnectorStatus::Disabled => Self::Disabled,
42 ApiAccessConnectorStatus::__Unknown(_) => Self::Unknown,
43 }
44 }
45}
46
47#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
49#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
50#[serde(rename_all = "snake_case")]
51pub enum TargetSystemMethod {
52 Automatic,
54 Manual,
56 Unknown,
58}
59
60impl From<ApiTargetSystemMethod> for TargetSystemMethod {
61 fn from(method: ApiTargetSystemMethod) -> Self {
62 match method {
63 ApiTargetSystemMethod::Automatic => Self::Automatic,
64 ApiTargetSystemMethod::Manual => Self::Manual,
65 ApiTargetSystemMethod::__Unknown(_) => Self::Unknown,
66 }
67 }
68}
69
70impl TryFrom<TargetSystemMethod> for ApiTargetSystemMethod {
71 type Error = RotationError;
72
73 fn try_from(method: TargetSystemMethod) -> Result<Self, Self::Error> {
74 match method {
75 TargetSystemMethod::Automatic => Ok(Self::Automatic),
76 TargetSystemMethod::Manual => Ok(Self::Manual),
77 TargetSystemMethod::Unknown => Err(RotationError::UnrecognizedVariant),
79 }
80 }
81}
82
83#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
87#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
88#[serde(rename_all = "snake_case")]
89pub enum TargetSystemKind {
90 Entra,
92 Mssql,
94 CustomScript,
96 Unknown,
98}
99
100impl From<SealedTargetSystemMethod> for TargetSystemMethod {
101 fn from(method: SealedTargetSystemMethod) -> Self {
102 match method {
103 SealedTargetSystemMethod::Automatic => Self::Automatic,
104 SealedTargetSystemMethod::Manual => Self::Manual,
105 }
106 }
107}
108
109impl From<SealedTargetSystemKind> for TargetSystemKind {
110 fn from(kind: SealedTargetSystemKind) -> Self {
111 match kind {
112 SealedTargetSystemKind::Entra => Self::Entra,
113 SealedTargetSystemKind::Mssql => Self::Mssql,
114 SealedTargetSystemKind::CustomScript => Self::CustomScript,
115 }
116 }
117}
118
119impl TryFrom<TargetSystemKind> for SealedTargetSystemKind {
120 type Error = RotationError;
121
122 fn try_from(kind: TargetSystemKind) -> Result<Self, Self::Error> {
123 match kind {
124 TargetSystemKind::Entra => Ok(Self::Entra),
125 TargetSystemKind::Mssql => Ok(Self::Mssql),
126 TargetSystemKind::CustomScript => Ok(Self::CustomScript),
127 TargetSystemKind::Unknown => Err(RotationError::UnrecognizedVariant),
128 }
129 }
130}
131
132#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
134#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
135#[serde(rename_all = "snake_case")]
136pub enum TargetSystemStatus {
137 Active,
139 Disabled,
141 PendingSeal,
149 Unknown,
151}
152
153impl From<ApiTargetSystemStatus> for TargetSystemStatus {
154 fn from(status: ApiTargetSystemStatus) -> Self {
155 match status {
156 ApiTargetSystemStatus::Active => Self::Active,
157 ApiTargetSystemStatus::Disabled => Self::Disabled,
158 ApiTargetSystemStatus::PendingSeal => Self::PendingSeal,
159 ApiTargetSystemStatus::__Unknown(_) => Self::Unknown,
160 }
161 }
162}
163
164#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
166#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
167#[serde(rename_all = "snake_case")]
168pub enum RotationSource {
169 Scheduled,
171 OnDemand,
173 AccessEnd,
175 Unknown,
177}
178
179impl From<ApiRotationSource> for RotationSource {
180 fn from(source: ApiRotationSource) -> Self {
181 match source {
182 ApiRotationSource::Scheduled => Self::Scheduled,
183 ApiRotationSource::OnDemand => Self::OnDemand,
184 ApiRotationSource::AccessEnd => Self::AccessEnd,
185 ApiRotationSource::__Unknown(_) => Self::Unknown,
186 }
187 }
188}
189
190#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
192#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
193#[serde(rename_all = "snake_case")]
194pub enum RotationJobStatus {
195 Pending,
197 Claimed,
199 Succeeded,
201 Failed,
203 TimedOut,
205 Unknown,
207}
208
209impl From<ApiRotationJobStatus> for RotationJobStatus {
210 fn from(status: ApiRotationJobStatus) -> Self {
211 match status {
212 ApiRotationJobStatus::Pending => Self::Pending,
213 ApiRotationJobStatus::Claimed => Self::Claimed,
214 ApiRotationJobStatus::Succeeded => Self::Succeeded,
215 ApiRotationJobStatus::Failed => Self::Failed,
216 ApiRotationJobStatus::TimedOut => Self::TimedOut,
217 ApiRotationJobStatus::__Unknown(_) => Self::Unknown,
218 }
219 }
220}
221
222#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
224#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
225#[serde(rename_all = "snake_case")]
226pub enum RotationAttemptStatus {
227 Executing,
229 Rotated,
231 Errored,
233 Abandoned,
236 Unknown,
238}
239
240impl From<ApiRotationAttemptStatus> for RotationAttemptStatus {
241 fn from(status: ApiRotationAttemptStatus) -> Self {
242 match status {
243 ApiRotationAttemptStatus::Executing => Self::Executing,
244 ApiRotationAttemptStatus::Rotated => Self::Rotated,
245 ApiRotationAttemptStatus::Errored => Self::Errored,
246 ApiRotationAttemptStatus::Abandoned => Self::Abandoned,
247 ApiRotationAttemptStatus::__Unknown(_) => Self::Unknown,
248 }
249 }
250}
251
252#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
254#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
255#[serde(rename_all = "snake_case")]
256pub enum RotationSyncState {
257 TargetUnchanged,
259 TargetUpdated,
261 Indeterminate,
264 Unknown,
266}
267
268impl From<ApiRotationSyncState> for RotationSyncState {
269 fn from(state: ApiRotationSyncState) -> Self {
270 match state {
271 ApiRotationSyncState::TargetUnchanged => Self::TargetUnchanged,
272 ApiRotationSyncState::TargetUpdated => Self::TargetUpdated,
273 ApiRotationSyncState::Indeterminate => Self::Indeterminate,
274 ApiRotationSyncState::__Unknown(_) => Self::Unknown,
275 }
276 }
277}
278
279impl TryFrom<RotationSyncState> for ApiRotationSyncState {
280 type Error = RotationError;
281
282 fn try_from(state: RotationSyncState) -> Result<Self, Self::Error> {
283 match state {
284 RotationSyncState::TargetUnchanged => Ok(Self::TargetUnchanged),
285 RotationSyncState::TargetUpdated => Ok(Self::TargetUpdated),
286 RotationSyncState::Indeterminate => Ok(Self::Indeterminate),
287 RotationSyncState::Unknown => Err(RotationError::UnrecognizedVariant),
288 }
289 }
290}
291
292#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
294#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
295#[serde(rename_all = "snake_case")]
296pub enum SessionTerminationOutcome {
297 NotRequested,
299 Terminated,
301 TermFailed,
303 Unknown,
305}
306
307impl From<ApiSessionTerminationOutcome> for SessionTerminationOutcome {
308 fn from(outcome: ApiSessionTerminationOutcome) -> Self {
309 match outcome {
310 ApiSessionTerminationOutcome::NotRequested => Self::NotRequested,
311 ApiSessionTerminationOutcome::Terminated => Self::Terminated,
312 ApiSessionTerminationOutcome::TermFailed => Self::TermFailed,
313 ApiSessionTerminationOutcome::__Unknown(_) => Self::Unknown,
314 }
315 }
316}
317
318impl TryFrom<SessionTerminationOutcome> for ApiSessionTerminationOutcome {
319 type Error = RotationError;
320
321 fn try_from(outcome: SessionTerminationOutcome) -> Result<Self, Self::Error> {
322 match outcome {
323 SessionTerminationOutcome::NotRequested => Ok(Self::NotRequested),
324 SessionTerminationOutcome::Terminated => Ok(Self::Terminated),
325 SessionTerminationOutcome::TermFailed => Ok(Self::TermFailed),
326 SessionTerminationOutcome::Unknown => Err(RotationError::UnrecognizedVariant),
327 }
328 }
329}
330
331#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
334#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
335#[serde(rename_all = "snake_case")]
336pub enum RotationFailureCode {
337 NoActiveSession,
339 CredentialsUnresolved,
341 InvalidPolicy,
343 UnsupportedKind,
345 TargetRejected,
348 TargetUnreachable,
350 VerificationFailed,
352 ScriptFailed,
354 ScriptTimeout,
356 CipherWriteRejected,
358 CipherEncryptFailed,
360 SealedDataRejected,
362 Internal,
364 Unknown,
366}
367
368impl RotationFailureCode {
369 pub fn as_token(self) -> Result<&'static str, RotationError> {
372 match self {
373 Self::NoActiveSession => Ok("no_active_session"),
374 Self::CredentialsUnresolved => Ok("credentials_unresolved"),
375 Self::InvalidPolicy => Ok("invalid_policy"),
376 Self::UnsupportedKind => Ok("unsupported_kind"),
377 Self::TargetRejected => Ok("target_rejected"),
378 Self::TargetUnreachable => Ok("target_unreachable"),
379 Self::VerificationFailed => Ok("verification_failed"),
380 Self::ScriptFailed => Ok("script_failed"),
381 Self::ScriptTimeout => Ok("script_timeout"),
382 Self::CipherWriteRejected => Ok("cipher_write_rejected"),
383 Self::CipherEncryptFailed => Ok("cipher_encrypt_failed"),
384 Self::SealedDataRejected => Ok("sealed_data_rejected"),
385 Self::Internal => Ok("internal"),
386 Self::Unknown => Err(RotationError::UnrecognizedVariant),
387 }
388 }
389
390 pub fn from_token(token: &str) -> Self {
393 match token {
394 "no_active_session" => Self::NoActiveSession,
395 "credentials_unresolved" => Self::CredentialsUnresolved,
396 "invalid_policy" => Self::InvalidPolicy,
397 "unsupported_kind" => Self::UnsupportedKind,
398 "target_rejected" => Self::TargetRejected,
399 "target_unreachable" => Self::TargetUnreachable,
400 "verification_failed" => Self::VerificationFailed,
401 "script_failed" => Self::ScriptFailed,
402 "script_timeout" => Self::ScriptTimeout,
403 "cipher_write_rejected" => Self::CipherWriteRejected,
404 "cipher_encrypt_failed" => Self::CipherEncryptFailed,
405 "sealed_data_rejected" => Self::SealedDataRejected,
406 "internal" => Self::Internal,
407 _ => Self::Unknown,
408 }
409 }
410}
411
412#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
416#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
417#[serde(rename_all = "camelCase")]
418pub struct PasswordPolicy {
419 pub min_length: u32,
421 pub max_length: u32,
423 pub include_uppercase: bool,
425 pub include_lowercase: bool,
427 pub include_digits: bool,
429 pub include_symbols: bool,
431}
432
433impl PasswordPolicy {
434 pub(crate) fn validate(&self) -> Result<(), PasswordPolicyError> {
438 SealedPasswordPolicy::from(self.clone()).validate()
439 }
440}
441
442impl From<SealedPasswordPolicy> for PasswordPolicy {
443 fn from(policy: SealedPasswordPolicy) -> Self {
444 Self {
445 min_length: policy.min_length,
446 max_length: policy.max_length,
447 include_uppercase: policy.include_uppercase,
448 include_lowercase: policy.include_lowercase,
449 include_digits: policy.include_digits,
450 include_symbols: policy.include_symbols,
451 }
452 }
453}
454
455impl From<PasswordPolicy> for SealedPasswordPolicy {
456 fn from(policy: PasswordPolicy) -> Self {
457 Self {
458 min_length: policy.min_length,
459 max_length: policy.max_length,
460 include_uppercase: policy.include_uppercase,
461 include_lowercase: policy.include_lowercase,
462 include_digits: policy.include_digits,
463 include_symbols: policy.include_symbols,
464 }
465 }
466}
467
468#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
470#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
471#[serde(rename_all = "camelCase")]
472pub struct RotationAttempt {
473 pub id: RotationAttemptId,
475 pub job_id: RotationJobId,
477 pub claimed_by_access_connector_id: Option<AccessConnectorId>,
479 pub status: RotationAttemptStatus,
481 pub failure_reason: Option<String>,
484 pub failure_code: Option<RotationFailureCode>,
488 pub cipher_updated: bool,
490 pub sync_state: Option<RotationSyncState>,
492 pub session_termination: Option<SessionTerminationOutcome>,
494 pub started_at: DateTime<Utc>,
496 pub ended_at: Option<DateTime<Utc>>,
498}
499
500impl TryFrom<PamRotationAttemptResponseModel> for RotationAttempt {
501 type Error = RotationError;
502
503 fn try_from(response: PamRotationAttemptResponseModel) -> Result<Self, Self::Error> {
504 Ok(Self {
505 id: RotationAttemptId::new(require!(response.id)),
506 job_id: RotationJobId::new(require!(response.job_id)),
507 claimed_by_access_connector_id: response
508 .claimed_by_access_connector_id
509 .map(AccessConnectorId::new),
510 status: require!(response.status).into(),
511 failure_code: response
512 .failure_reason
513 .as_deref()
514 .map(failure_code_from_reason),
515 failure_reason: response.failure_reason,
516 cipher_updated: response.cipher_updated.unwrap_or(false),
517 sync_state: response.sync_state.map(Into::into),
518 session_termination: response.session_termination.map(Into::into),
519 started_at: require!(response.creation_date).parse()?,
520 ended_at: response
521 .resolved_date
522 .map(|date| date.parse())
523 .transpose()?,
524 })
525 }
526}
527
528fn failure_code_from_reason(reason: &str) -> RotationFailureCode {
530 let token = reason.split_once(": ").map_or(reason, |(code, _)| code);
531 RotationFailureCode::from_token(token)
532}
533
534#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
537#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
538#[serde(rename_all = "camelCase")]
539pub struct RotationJob {
540 pub id: RotationJobId,
542 pub rotation_config_id: RotationConfigId,
544 pub source: RotationSource,
546 pub status: RotationJobStatus,
548 pub claimed_by_access_connector_id: Option<AccessConnectorId>,
550 pub claimed_at: Option<DateTime<Utc>>,
552 pub created_at: DateTime<Utc>,
554 pub next_claimable_at: Option<DateTime<Utc>>,
556 pub expires_at: Option<DateTime<Utc>>,
558 pub attempts: Vec<RotationAttempt>,
560}
561
562impl TryFrom<PamRotationJobResponseModel> for RotationJob {
563 type Error = RotationError;
564
565 fn try_from(response: PamRotationJobResponseModel) -> Result<Self, Self::Error> {
566 Ok(Self {
567 id: RotationJobId::new(require!(response.id)),
568 rotation_config_id: RotationConfigId::new(require!(response.rotation_config_id)),
569 source: require!(response.source).into(),
570 status: require!(response.status).into(),
571 claimed_by_access_connector_id: response
572 .claimed_by_access_connector_id
573 .map(AccessConnectorId::new),
574 claimed_at: response.claimed_at.map(|date| date.parse()).transpose()?,
575 created_at: require!(response.creation_date).parse()?,
576 next_claimable_at: response
577 .next_claimable_at
578 .map(|date| date.parse())
579 .transpose()?,
580 expires_at: response.expires_at.map(|date| date.parse()).transpose()?,
581 attempts: response
582 .attempts
583 .unwrap_or_default()
584 .into_iter()
585 .map(RotationAttempt::try_from)
586 .collect::<Result<Vec<_>, _>>()?,
587 })
588 }
589}
590
591#[cfg(test)]
592mod tests {
593 use super::*;
594
595 const KNOWN_CODES: [(RotationFailureCode, &str); 13] = [
596 (RotationFailureCode::NoActiveSession, "no_active_session"),
597 (
598 RotationFailureCode::CredentialsUnresolved,
599 "credentials_unresolved",
600 ),
601 (RotationFailureCode::InvalidPolicy, "invalid_policy"),
602 (RotationFailureCode::UnsupportedKind, "unsupported_kind"),
603 (RotationFailureCode::TargetRejected, "target_rejected"),
604 (RotationFailureCode::TargetUnreachable, "target_unreachable"),
605 (
606 RotationFailureCode::VerificationFailed,
607 "verification_failed",
608 ),
609 (RotationFailureCode::ScriptFailed, "script_failed"),
610 (RotationFailureCode::ScriptTimeout, "script_timeout"),
611 (
612 RotationFailureCode::CipherWriteRejected,
613 "cipher_write_rejected",
614 ),
615 (
616 RotationFailureCode::CipherEncryptFailed,
617 "cipher_encrypt_failed",
618 ),
619 (
620 RotationFailureCode::SealedDataRejected,
621 "sealed_data_rejected",
622 ),
623 (RotationFailureCode::Internal, "internal"),
624 ];
625
626 #[test]
627 fn sync_state_maps_to_api_values() {
628 let pairs = [
629 (RotationSyncState::TargetUnchanged, 0),
630 (RotationSyncState::TargetUpdated, 1),
631 (RotationSyncState::Indeterminate, 2),
632 ];
633 for (state, expected) in pairs {
634 let api = ApiRotationSyncState::try_from(state).unwrap();
635 assert_eq!(api.as_i64(), expected, "{state:?}");
636 }
637 }
638
639 #[test]
640 fn sync_state_unknown_is_refused() {
641 assert!(matches!(
642 ApiRotationSyncState::try_from(RotationSyncState::Unknown),
643 Err(RotationError::UnrecognizedVariant)
644 ));
645 }
646
647 #[test]
648 fn session_termination_maps_to_api_values() {
649 let pairs = [
650 (SessionTerminationOutcome::NotRequested, 0),
651 (SessionTerminationOutcome::Terminated, 1),
652 (SessionTerminationOutcome::TermFailed, 2),
653 ];
654 for (outcome, expected) in pairs {
655 let api = ApiSessionTerminationOutcome::try_from(outcome).unwrap();
656 assert_eq!(api.as_i64(), expected, "{outcome:?}");
657 }
658 }
659
660 #[test]
661 fn session_termination_unknown_is_refused() {
662 assert!(matches!(
663 ApiSessionTerminationOutcome::try_from(SessionTerminationOutcome::Unknown),
664 Err(RotationError::UnrecognizedVariant)
665 ));
666 }
667
668 #[test]
669 fn failure_code_tokens_round_trip() {
670 for (code, token) in KNOWN_CODES {
671 assert_eq!(code.as_token().unwrap(), token, "{code:?}");
672 assert_eq!(RotationFailureCode::from_token(token), code);
673 assert_eq!(
674 serde_json::to_string(&code).unwrap(),
675 format!("\"{token}\""),
676 "{code:?}"
677 );
678 }
679 }
680
681 #[test]
682 fn failure_code_tokens_are_bounded_snake_case() {
683 for (code, _) in KNOWN_CODES {
684 let token = code.as_token().unwrap();
685 assert!(token.len() <= 100, "{code:?}");
686 assert!(
687 token.chars().all(|c| c.is_ascii_lowercase() || c == '_'),
688 "{code:?}"
689 );
690 }
691 }
692
693 #[test]
694 fn failure_code_unknown_is_refused() {
695 assert!(matches!(
696 RotationFailureCode::Unknown.as_token(),
697 Err(RotationError::UnrecognizedVariant)
698 ));
699 }
700
701 #[test]
702 fn failure_code_unrecognized_token_is_unknown() {
703 for token in ["", "from_the_future", "unknown", "Internal"] {
704 assert_eq!(
705 RotationFailureCode::from_token(token),
706 RotationFailureCode::Unknown,
707 "{token:?}"
708 );
709 }
710 }
711
712 fn attempt_response(failure_reason: Option<&str>) -> PamRotationAttemptResponseModel {
713 PamRotationAttemptResponseModel {
714 id: Some(uuid::Uuid::new_v4()),
715 job_id: Some(uuid::Uuid::new_v4()),
716 status: Some(ApiRotationAttemptStatus::Errored),
717 failure_reason: failure_reason.map(str::to_owned),
718 creation_date: Some("2026-01-01T00:00:00Z".to_owned()),
719 ..Default::default()
720 }
721 }
722
723 #[test]
724 fn attempt_failure_code_from_code_alone() {
725 let attempt = RotationAttempt::try_from(attempt_response(Some("script_timeout"))).unwrap();
726 assert_eq!(
727 attempt.failure_code,
728 Some(RotationFailureCode::ScriptTimeout)
729 );
730 assert_eq!(attempt.failure_reason.as_deref(), Some("script_timeout"));
731 }
732
733 #[test]
734 fn attempt_failure_code_from_code_and_detail() {
735 let attempt = RotationAttempt::try_from(attempt_response(Some(
736 "target_rejected: HTTP 403: forbidden",
737 )))
738 .unwrap();
739 assert_eq!(
740 attempt.failure_code,
741 Some(RotationFailureCode::TargetRejected)
742 );
743 assert_eq!(
744 attempt.failure_reason.as_deref(),
745 Some("target_rejected: HTTP 403: forbidden")
746 );
747 }
748
749 #[test]
750 fn attempt_failure_code_unrecognized() {
751 let attempt =
752 RotationAttempt::try_from(attempt_response(Some("from_the_future: detail"))).unwrap();
753 assert_eq!(attempt.failure_code, Some(RotationFailureCode::Unknown));
754 }
755
756 #[test]
757 fn attempt_failure_code_none_without_reason() {
758 let attempt = RotationAttempt::try_from(attempt_response(None)).unwrap();
759 assert_eq!(attempt.failure_code, None);
760 }
761}