1use bitwarden_api_api::models::RotationClaimResponseModel;
10use bitwarden_core::{
11 MissingFieldError, OrganizationId,
12 key_management::{KeySlotIds, SymmetricKeySlotId},
13 require,
14};
15use bitwarden_crypto::{EncString, KeyStore, safe::DataEnvelope};
16use bitwarden_generators::PasswordGeneratorRequest;
17use bitwarden_pam_sealed::{
18 GENERATOR_MAX_LENGTH, GENERATOR_MIN_LENGTH, PasswordPolicyError, RotationConfigSealedV1,
19 SealedPasswordPolicy, SealedTargetSystemKind, SealedTargetSystemMethod, TargetSystemSealedV1,
20 unseal_rotation_config, unseal_target_system,
21};
22use chrono::{DateTime, Utc};
23use thiserror::Error;
24use uuid::Uuid;
25
26#[derive(Debug, Clone)]
29pub struct WorkSnapshot {
30 pub attempt_id: Uuid,
32 pub job_id: Uuid,
34 pub target_system_id: Uuid,
36 pub target_system_name: String,
38 pub cipher_id: Uuid,
40 pub target_system_sealed_data: Option<String>,
42 pub target_system_wrapped_key: Option<String>,
44 pub rotation_config_sealed_data: Option<String>,
46 pub rotation_config_wrapped_key: Option<String>,
48 pub execute_by: DateTime<Utc>,
50}
51
52#[derive(Debug, Error)]
54pub enum WorkSnapshotError {
55 #[error(transparent)]
57 MissingField(#[from] MissingFieldError),
58 #[error("invalid executeBy timestamp")]
60 InvalidExecuteBy,
61}
62
63impl TryFrom<RotationClaimResponseModel> for WorkSnapshot {
64 type Error = WorkSnapshotError;
65
66 fn try_from(model: RotationClaimResponseModel) -> Result<Self, Self::Error> {
67 let attempt_id = require!(model.attempt_id);
68 let job_id = require!(model.job_id);
69 let target_system_id = require!(model.target_system_id);
70 let target_system_name = require!(model.target_system_name);
71 let cipher_id = require!(model.cipher_id);
72
73 let execute_by = require!(model.execute_by)
74 .parse::<DateTime<Utc>>()
75 .map_err(|_| WorkSnapshotError::InvalidExecuteBy)?;
76
77 Ok(Self {
79 attempt_id,
80 job_id,
81 target_system_id,
82 target_system_name,
83 cipher_id,
84 target_system_sealed_data: model.target_system_sealed_data,
85 target_system_wrapped_key: model.target_system_wrapped_key,
86 rotation_config_sealed_data: model.rotation_config_sealed_data,
87 rotation_config_wrapped_key: model.rotation_config_wrapped_key,
88 execute_by,
89 })
90 }
91}
92
93#[derive(Debug, Clone, Copy, PartialEq, Eq)]
96pub enum SealRejection {
97 MissingTargetBlob,
99 MissingConfigBlob,
101 TargetBlobInvalid,
103 ConfigBlobInvalid,
105 TargetSystemIdMismatch,
107 CipherIdMismatch,
109 TargetMethodNotAutomatic,
111 TargetKindMissing,
113 PolicyRejected,
115}
116
117impl SealRejection {
118 pub fn as_str(self) -> &'static str {
120 match self {
121 Self::MissingTargetBlob => "missing_target_blob",
122 Self::MissingConfigBlob => "missing_config_blob",
123 Self::TargetBlobInvalid => "target_blob_invalid",
124 Self::ConfigBlobInvalid => "config_blob_invalid",
125 Self::TargetSystemIdMismatch => "target_system_id_mismatch",
126 Self::CipherIdMismatch => "cipher_id_mismatch",
127 Self::TargetMethodNotAutomatic => "target_method_not_automatic",
128 Self::TargetKindMissing => "target_kind_missing",
129 Self::PolicyRejected => "policy_rejected",
130 }
131 }
132}
133
134#[derive(Debug)]
137pub struct VerifiedJob {
138 attempt_id: Uuid,
139 job_id: Uuid,
140 target_system_id: Uuid,
141 target_system_name: String,
142 cipher_id: Uuid,
143 kind: SealedTargetSystemKind,
144 password_policy: SealedPasswordPolicy,
145 account_identity: String,
146 terminate_sessions: bool,
147 execute_by: DateTime<Utc>,
148}
149
150impl VerifiedJob {
151 pub fn attempt_id(&self) -> Uuid {
153 self.attempt_id
154 }
155
156 pub fn job_id(&self) -> Uuid {
158 self.job_id
159 }
160
161 pub fn target_system_id(&self) -> Uuid {
163 self.target_system_id
164 }
165
166 pub fn target_system_name(&self) -> &str {
168 &self.target_system_name
169 }
170
171 pub fn cipher_id(&self) -> Uuid {
173 self.cipher_id
174 }
175
176 pub fn kind(&self) -> SealedTargetSystemKind {
178 self.kind
179 }
180
181 pub fn password_policy(&self) -> &SealedPasswordPolicy {
183 &self.password_policy
184 }
185
186 pub fn account_identity(&self) -> &str {
188 &self.account_identity
189 }
190
191 pub fn terminate_sessions(&self) -> bool {
193 self.terminate_sessions
194 }
195
196 pub fn execute_by(&self) -> DateTime<Utc> {
198 self.execute_by
199 }
200
201 pub fn generator_request(&self) -> Result<PasswordGeneratorRequest, PasswordPolicyError> {
207 let policy = &self.password_policy;
208 policy.validate()?;
209
210 let length = policy
211 .max_length
212 .clamp(GENERATOR_MIN_LENGTH, GENERATOR_MAX_LENGTH);
213
214 #[allow(clippy::cast_possible_truncation)]
216 let length_u8 = length as u8;
217
218 Ok(PasswordGeneratorRequest {
219 lowercase: policy.include_lowercase,
220 uppercase: policy.include_uppercase,
221 numbers: policy.include_digits,
222 special: policy.include_symbols,
223 length: length_u8,
224 avoid_ambiguous: false,
225 min_lowercase: None,
226 min_uppercase: None,
227 min_number: None,
228 min_special: None,
229 custom_required_chars: None,
230 custom_allowed_chars: None,
231 max_consecutive: None,
232 })
233 }
234}
235
236impl WorkSnapshot {
237 pub fn verify(
240 self,
241 key_store: &KeyStore<KeySlotIds>,
242 organization_id: OrganizationId,
243 ) -> Result<VerifiedJob, SealRejection> {
244 let (target_envelope, target_key) = self
246 .target_system_sealed_data
247 .as_deref()
248 .zip(self.target_system_wrapped_key.as_deref())
249 .ok_or(SealRejection::MissingTargetBlob)?;
250 let (config_envelope, config_key) = self
251 .rotation_config_sealed_data
252 .as_deref()
253 .zip(self.rotation_config_wrapped_key.as_deref())
254 .ok_or(SealRejection::MissingConfigBlob)?;
255
256 let target_envelope: DataEnvelope = target_envelope
257 .parse()
258 .map_err(|_| SealRejection::TargetBlobInvalid)?;
259 let config_envelope: DataEnvelope = config_envelope
260 .parse()
261 .map_err(|_| SealRejection::ConfigBlobInvalid)?;
262
263 let mut ctx = key_store.context_mut();
264 let org_slot = SymmetricKeySlotId::Organization(organization_id);
265
266 let target: TargetSystemSealedV1 = unseal_target_system::<KeySlotIds>(
267 &target_envelope,
268 &EncString::from(target_key),
269 &org_slot,
270 &mut ctx,
271 )
272 .map_err(|_| SealRejection::TargetBlobInvalid)?;
273
274 let config: RotationConfigSealedV1 = unseal_rotation_config::<KeySlotIds>(
275 &config_envelope,
276 &EncString::from(config_key),
277 &org_slot,
278 &mut ctx,
279 )
280 .map_err(|_| SealRejection::ConfigBlobInvalid)?;
281
282 if self.target_system_id != target.target_system_id
283 || self.target_system_id != config.target_system_id
284 {
285 return Err(SealRejection::TargetSystemIdMismatch);
286 }
287
288 if self.cipher_id != config.cipher_id {
289 return Err(SealRejection::CipherIdMismatch);
290 }
291
292 if target.method != SealedTargetSystemMethod::Automatic {
294 return Err(SealRejection::TargetMethodNotAutomatic);
295 }
296
297 let kind = target.kind.ok_or(SealRejection::TargetKindMissing)?;
299
300 target
301 .password_policy
302 .validate()
303 .map_err(|_| SealRejection::PolicyRejected)?;
304
305 let terminate_sessions =
307 config.terminate_sessions && target.supports_session_termination == Some(true);
308
309 Ok(VerifiedJob {
310 attempt_id: self.attempt_id,
311 job_id: self.job_id,
312 target_system_id: self.target_system_id,
313 target_system_name: self.target_system_name,
314 cipher_id: self.cipher_id,
315 kind,
316 password_policy: target.password_policy,
317 account_identity: config.account_identity,
318 terminate_sessions,
319 execute_by: self.execute_by,
320 })
321 }
322}
323
324#[cfg(test)]
326mod test_support {
327 use bitwarden_crypto::{SymmetricCryptoKey, SymmetricKeyAlgorithm};
328 use bitwarden_pam_sealed::{Sealed, seal_rotation_config, seal_target_system};
329
330 use super::*;
331
332 pub(super) type SealedStrings = (String, String);
334
335 fn org_id() -> OrganizationId {
336 "3f1a5c4e-7b2d-4f8a-9c6e-1d2b3a4c5d6e"
337 .parse()
338 .expect("valid uuid")
339 }
340
341 fn org_slot() -> SymmetricKeySlotId {
342 SymmetricKeySlotId::Organization(org_id())
343 }
344
345 fn strings(sealed: Sealed) -> SealedStrings {
347 (sealed.envelope.to_string(), sealed.wrapped_key.to_string())
348 }
349
350 pub(super) fn all_classes(min_length: u32, max_length: u32) -> SealedPasswordPolicy {
351 SealedPasswordPolicy {
352 min_length,
353 max_length,
354 include_uppercase: true,
355 include_lowercase: true,
356 include_digits: true,
357 include_symbols: true,
358 }
359 }
360
361 pub(super) fn no_classes() -> SealedPasswordPolicy {
362 SealedPasswordPolicy {
363 include_uppercase: false,
364 include_lowercase: false,
365 include_digits: false,
366 include_symbols: false,
367 ..all_classes(20, 20)
368 }
369 }
370
371 pub(super) struct Fixture {
373 store: KeyStore<KeySlotIds>,
374 pub(super) target_system_id: Uuid,
375 pub(super) cipher_id: Uuid,
376 }
377
378 impl Fixture {
379 pub(super) fn new() -> Self {
380 let store = KeyStore::default();
381 #[allow(deprecated)]
382 store
383 .context_mut()
384 .set_symmetric_key(
385 org_slot(),
386 SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac),
387 )
388 .expect("set_symmetric_key");
389 Self {
390 store,
391 target_system_id: Uuid::new_v4(),
392 cipher_id: Uuid::new_v4(),
393 }
394 }
395
396 pub(super) fn target(&self) -> TargetSystemSealedV1 {
397 TargetSystemSealedV1 {
398 target_system_id: self.target_system_id,
399 method: SealedTargetSystemMethod::Automatic,
400 kind: Some(SealedTargetSystemKind::CustomScript),
401 password_policy: all_classes(20, 20),
402 supports_session_termination: Some(true),
403 }
404 }
405
406 pub(super) fn config(&self) -> RotationConfigSealedV1 {
407 RotationConfigSealedV1 {
408 target_system_id: self.target_system_id,
409 cipher_id: self.cipher_id,
410 account_identity: "[email protected]".to_owned(),
411 terminate_sessions: true,
412 }
413 }
414
415 pub(super) fn seal_target(&self, target: TargetSystemSealedV1) -> SealedStrings {
416 let mut ctx = self.store.context_mut();
417 strings(seal_target_system::<KeySlotIds>(target, &org_slot(), &mut ctx).expect("seal"))
418 }
419
420 pub(super) fn seal_config(&self, config: RotationConfigSealedV1) -> SealedStrings {
421 let mut ctx = self.store.context_mut();
422 strings(
423 seal_rotation_config::<KeySlotIds>(config, &org_slot(), &mut ctx).expect("seal"),
424 )
425 }
426
427 pub(super) fn snapshot(
429 &self,
430 target_blob: Option<SealedStrings>,
431 config_blob: Option<SealedStrings>,
432 ) -> WorkSnapshot {
433 let (target_data, target_key) = target_blob.unzip();
434 let (config_data, config_key) = config_blob.unzip();
435 WorkSnapshot {
436 attempt_id: Uuid::new_v4(),
437 job_id: Uuid::new_v4(),
438 target_system_id: self.target_system_id,
439 target_system_name: "test-system".to_owned(),
440 cipher_id: self.cipher_id,
441 target_system_sealed_data: target_data,
442 target_system_wrapped_key: target_key,
443 rotation_config_sealed_data: config_data,
444 rotation_config_wrapped_key: config_key,
445 execute_by: Utc::now() + chrono::Duration::minutes(5),
446 }
447 }
448
449 pub(super) fn sealed_snapshot(
451 &self,
452 target: TargetSystemSealedV1,
453 config: RotationConfigSealedV1,
454 ) -> WorkSnapshot {
455 self.snapshot(
456 Some(self.seal_target(target)),
457 Some(self.seal_config(config)),
458 )
459 }
460
461 pub(super) fn valid_snapshot(&self) -> WorkSnapshot {
463 self.sealed_snapshot(self.target(), self.config())
464 }
465
466 pub(super) fn verify(&self, snapshot: WorkSnapshot) -> Result<VerifiedJob, SealRejection> {
467 snapshot.verify(&self.store, org_id())
468 }
469
470 pub(super) fn reject(&self, snapshot: WorkSnapshot) -> SealRejection {
471 self.verify(snapshot).expect_err("claim should be rejected")
472 }
473 }
474}
475
476#[cfg(test)]
477mod tests {
478 use super::{test_support::*, *};
479
480 #[test]
481 fn happy_path_produces_verified_job() {
482 let f = Fixture::new();
483 let snapshot = f.valid_snapshot();
484 let (attempt_id, job_id) = (snapshot.attempt_id, snapshot.job_id);
485
486 let job = f.verify(snapshot).expect("well-formed claim should verify");
487
488 assert_eq!(job.attempt_id(), attempt_id);
489 assert_eq!(job.job_id(), job_id);
490 assert_eq!(job.target_system_id(), f.target_system_id);
491 assert_eq!(job.cipher_id(), f.cipher_id);
492 assert_eq!(job.kind(), SealedTargetSystemKind::CustomScript);
493 assert_eq!(job.account_identity(), "[email protected]");
494 assert!(job.terminate_sessions());
495 }
496
497 #[test]
498 fn missing_target_blob_is_rejected() {
499 let f = Fixture::new();
500 let snapshot = f.snapshot(None, Some(f.seal_config(f.config())));
501 assert_eq!(f.reject(snapshot), SealRejection::MissingTargetBlob);
502 }
503
504 #[test]
505 fn missing_config_blob_is_rejected() {
506 let f = Fixture::new();
507 let snapshot = f.snapshot(Some(f.seal_target(f.target())), None);
508 assert_eq!(f.reject(snapshot), SealRejection::MissingConfigBlob);
509 }
510
511 #[test]
512 fn target_sealed_data_without_wrapped_key_is_rejected() {
513 let f = Fixture::new();
514 let mut snapshot = f.valid_snapshot();
515 snapshot.target_system_wrapped_key = None;
516 assert_eq!(f.reject(snapshot), SealRejection::MissingTargetBlob);
517 }
518
519 #[test]
520 fn config_sealed_data_without_wrapped_key_is_rejected() {
521 let f = Fixture::new();
522 let mut snapshot = f.valid_snapshot();
523 snapshot.rotation_config_wrapped_key = None;
524 assert_eq!(f.reject(snapshot), SealRejection::MissingConfigBlob);
525 }
526
527 #[test]
528 fn unparseable_wrapped_key_is_rejected() {
529 let f = Fixture::new();
530 let mut snapshot = f.valid_snapshot();
531 snapshot.target_system_wrapped_key = Some("not a wrapped key".to_owned());
532 assert_eq!(f.reject(snapshot), SealRejection::TargetBlobInvalid);
533 }
534
535 #[test]
536 fn wrapped_key_from_a_different_seal_is_rejected() {
537 let f = Fixture::new();
539 let (_, other_key) = f.seal_target(f.target());
540 let mut snapshot = f.valid_snapshot();
541 snapshot.target_system_wrapped_key = Some(other_key);
542 assert_eq!(f.reject(snapshot), SealRejection::TargetBlobInvalid);
543 }
544
545 #[test]
546 fn garbage_target_blob_is_rejected() {
547 let f = Fixture::new();
548 let mut snapshot = f.valid_snapshot();
549 snapshot.target_system_sealed_data = Some("not a sealed blob".to_owned());
550 assert_eq!(f.reject(snapshot), SealRejection::TargetBlobInvalid);
551 }
552
553 #[test]
554 fn target_blob_sealed_under_a_different_org_key_is_rejected() {
555 let f = Fixture::new();
556 let other_key = Fixture::new();
557 let snapshot = f.snapshot(
558 Some(other_key.seal_target(f.target())),
559 Some(f.seal_config(f.config())),
560 );
561 assert_eq!(f.reject(snapshot), SealRejection::TargetBlobInvalid);
562 }
563
564 #[test]
565 fn config_blob_from_a_different_config_is_rejected() {
566 let f = Fixture::new();
568 let other_config = RotationConfigSealedV1 {
569 cipher_id: Uuid::new_v4(),
570 ..f.config()
571 };
572 let snapshot = f.sealed_snapshot(f.target(), other_config);
573 assert_eq!(f.reject(snapshot), SealRejection::CipherIdMismatch);
574 }
575
576 #[test]
577 fn target_blob_from_a_different_target_is_rejected() {
578 let f = Fixture::new();
579 let other_target = TargetSystemSealedV1 {
580 target_system_id: Uuid::new_v4(),
581 ..f.target()
582 };
583 let snapshot = f.sealed_snapshot(other_target, f.config());
584 assert_eq!(f.reject(snapshot), SealRejection::TargetSystemIdMismatch);
585 }
586
587 #[test]
588 fn claim_target_id_differs_from_sealed_blobs_is_rejected() {
589 let f = Fixture::new();
590 let mut snapshot = f.valid_snapshot();
591 snapshot.target_system_id = Uuid::new_v4();
592 assert_eq!(f.reject(snapshot), SealRejection::TargetSystemIdMismatch);
593 }
594
595 #[test]
596 fn claim_cipher_id_differs_from_config_blob_is_rejected() {
597 let f = Fixture::new();
598 let mut snapshot = f.valid_snapshot();
599 snapshot.cipher_id = Uuid::new_v4();
600 assert_eq!(f.reject(snapshot), SealRejection::CipherIdMismatch);
601 }
602
603 #[test]
604 fn manual_target_method_is_rejected() {
605 let f = Fixture::new();
606 let manual_target = TargetSystemSealedV1 {
607 method: SealedTargetSystemMethod::Manual,
608 kind: None,
609 supports_session_termination: None,
610 ..f.target()
611 };
612 let snapshot = f.sealed_snapshot(manual_target, f.config());
613 assert_eq!(f.reject(snapshot), SealRejection::TargetMethodNotAutomatic);
614 }
615
616 #[test]
617 fn automatic_target_with_missing_kind_is_rejected() {
618 let f = Fixture::new();
619 let malformed_target = TargetSystemSealedV1 {
620 kind: None,
621 ..f.target()
622 };
623 let snapshot = f.sealed_snapshot(malformed_target, f.config());
624 assert_eq!(f.reject(snapshot), SealRejection::TargetKindMissing);
625 }
626
627 #[test]
628 fn policy_with_no_character_classes_is_rejected() {
629 let f = Fixture::new();
630 let invalid_target = TargetSystemSealedV1 {
631 password_policy: no_classes(),
632 ..f.target()
633 };
634 let snapshot = f.sealed_snapshot(invalid_target, f.config());
635 assert_eq!(f.reject(snapshot), SealRejection::PolicyRejected);
636 }
637
638 #[test]
639 fn terminate_requested_but_unsupported_is_anded_to_false_not_rejected() {
640 let f = Fixture::new();
641 let target = TargetSystemSealedV1 {
642 supports_session_termination: Some(false),
643 ..f.target()
644 };
645 let config = RotationConfigSealedV1 {
646 terminate_sessions: true,
647 ..f.config()
648 };
649
650 let job = f
651 .verify(f.sealed_snapshot(target, config))
652 .expect("an unsupported termination request must not be a rejection");
653 assert!(!job.terminate_sessions());
654 }
655}
656
657#[cfg(test)]
658mod generator_request_tests {
659 use bitwarden_core::Client;
660 use bitwarden_generators::GeneratorClientsExt as _;
661
662 use super::{test_support::*, *};
663
664 fn to_generator_request(
665 policy: &SealedPasswordPolicy,
666 ) -> Result<PasswordGeneratorRequest, PasswordPolicyError> {
667 VerifiedJob {
668 attempt_id: Uuid::new_v4(),
669 job_id: Uuid::new_v4(),
670 target_system_id: Uuid::new_v4(),
671 target_system_name: "test-system".to_owned(),
672 cipher_id: Uuid::new_v4(),
673 kind: SealedTargetSystemKind::CustomScript,
674 password_policy: policy.clone(),
675 account_identity: "[email protected]".to_owned(),
676 terminate_sessions: false,
677 execute_by: Utc::now(),
678 }
679 .generator_request()
680 }
681
682 fn password(req: PasswordGeneratorRequest) -> String {
683 Client::new(None)
684 .generator()
685 .password(req)
686 .expect("generate password")
687 }
688
689 #[test]
690 fn length_is_taken_from_max_length() {
691 for (min, max) in [
692 (5, 20),
693 (16, 16),
694 (5, GENERATOR_MAX_LENGTH),
695 (GENERATOR_MIN_LENGTH, GENERATOR_MIN_LENGTH),
696 ] {
697 let req = to_generator_request(&all_classes(min, max)).unwrap();
698 assert_eq!(u32::from(req.length), max, "min={min} max={max}");
699 }
700 }
701
702 #[test]
703 fn out_of_range_lengths_are_rejected() {
704 for (min, max) in [
705 (30, 20),
706 (5, GENERATOR_MAX_LENGTH + 1),
707 (GENERATOR_MIN_LENGTH - 1, 20),
708 ] {
709 assert_eq!(
710 to_generator_request(&all_classes(min, max)).unwrap_err(),
711 PasswordPolicyError::InvalidLengthBounds,
712 "min={min} max={max}"
713 );
714 }
715 }
716
717 #[test]
718 fn no_character_classes_is_rejected() {
719 let err = to_generator_request(&no_classes()).unwrap_err();
720 assert_eq!(err, PasswordPolicyError::NoCharacterClasses);
721 }
722
723 #[test]
724 fn single_class_enabled_is_sufficient() {
725 for policy in [
726 SealedPasswordPolicy {
727 include_uppercase: true,
728 ..no_classes()
729 },
730 SealedPasswordPolicy {
731 include_lowercase: true,
732 ..no_classes()
733 },
734 SealedPasswordPolicy {
735 include_digits: true,
736 ..no_classes()
737 },
738 SealedPasswordPolicy {
739 include_symbols: true,
740 ..no_classes()
741 },
742 ] {
743 assert!(to_generator_request(&policy).is_ok(), "{policy:?}");
744 }
745 }
746
747 #[test]
748 fn include_flags_map_correctly() {
749 let policy = SealedPasswordPolicy {
750 include_lowercase: false,
751 include_symbols: false,
752 ..all_classes(30, 30)
753 };
754 let req = to_generator_request(&policy).unwrap();
755 assert!(req.uppercase);
756 assert!(!req.lowercase);
757 assert!(req.numbers);
758 assert!(!req.special);
759 assert!(req.min_uppercase.is_none());
760 assert!(req.min_lowercase.is_none());
761 assert!(req.min_number.is_none());
762 assert!(req.min_special.is_none());
763 assert!(!req.avoid_ambiguous);
764 assert!(req.custom_required_chars.is_none());
765 assert!(req.custom_allowed_chars.is_none());
766 assert!(req.max_consecutive.is_none());
767 }
768
769 #[test]
770 fn generated_password_uses_only_enabled_charset() {
771 let policy = SealedPasswordPolicy {
772 include_digits: true,
773 ..no_classes()
774 };
775 let pwd = password(to_generator_request(&policy).unwrap());
776 assert!(
777 pwd.chars().all(|c| c.is_ascii_digit()),
778 "expected only digits in {pwd:?}"
779 );
780 }
781
782 #[test]
783 fn generated_password_at_maximum_length() {
784 let req = to_generator_request(&all_classes(5, GENERATOR_MAX_LENGTH)).unwrap();
785 let pwd = password(req);
786 assert_eq!(pwd.len() as u32, GENERATOR_MAX_LENGTH);
787 }
788}
789
790#[cfg(test)]
791mod parse_tests {
792 use super::*;
793
794 fn claim() -> RotationClaimResponseModel {
795 RotationClaimResponseModel {
796 attempt_id: Some(Uuid::new_v4()),
797 job_id: Some(Uuid::new_v4()),
798 source: None,
799 target_system_id: Some(Uuid::new_v4()),
800 target_system_name: Some("test-system".to_owned()),
801 target_system_sealed_data: Some("opaque-target-blob".to_owned()),
802 target_system_wrapped_key: Some("opaque-target-key".to_owned()),
803 cipher_id: Some(Uuid::new_v4()),
804 rotation_config_sealed_data: Some("opaque-config-blob".to_owned()),
805 rotation_config_wrapped_key: Some("opaque-config-key".to_owned()),
806 execute_by: Some(Utc::now().to_rfc3339()),
807 }
808 }
809
810 #[test]
811 fn converts_correctly() {
812 let snap = WorkSnapshot::try_from(claim()).expect("parse");
813 assert_eq!(
814 snap.target_system_sealed_data.as_deref(),
815 Some("opaque-target-blob")
816 );
817 assert_eq!(
818 snap.target_system_wrapped_key.as_deref(),
819 Some("opaque-target-key")
820 );
821 assert_eq!(
822 snap.rotation_config_sealed_data.as_deref(),
823 Some("opaque-config-blob")
824 );
825 assert_eq!(
826 snap.rotation_config_wrapped_key.as_deref(),
827 Some("opaque-config-key")
828 );
829 }
830
831 #[test]
832 fn missing_sealed_blobs_still_parses() {
833 let model = RotationClaimResponseModel {
834 target_system_sealed_data: None,
835 target_system_wrapped_key: None,
836 rotation_config_sealed_data: None,
837 rotation_config_wrapped_key: None,
838 ..claim()
839 };
840
841 let snap = WorkSnapshot::try_from(model).expect("parse");
842 assert!(snap.target_system_sealed_data.is_none());
843 assert!(snap.target_system_wrapped_key.is_none());
844 assert!(snap.rotation_config_sealed_data.is_none());
845 assert!(snap.rotation_config_wrapped_key.is_none());
846 }
847
848 #[test]
849 fn missing_field_is_an_error() {
850 let err = WorkSnapshot::try_from(RotationClaimResponseModel::new()).expect_err("fails");
851 assert!(matches!(err, WorkSnapshotError::MissingField(_)));
852 }
853
854 #[test]
855 fn bad_execute_by_is_an_error() {
856 let model = RotationClaimResponseModel {
857 execute_by: Some("NOT-A-DATE".to_owned()),
858 ..claim()
859 };
860
861 let err = WorkSnapshot::try_from(model).expect_err("fails");
862 assert!(matches!(err, WorkSnapshotError::InvalidExecuteBy));
863 }
864}