1use std::num::NonZeroU32;
2
3use bitwarden_api_api::models::{
4 AccessApprovalMode as ApiAccessApprovalMode, AccessDeciderKind as ApiAccessDeciderKind,
5 AccessDecisionVerdict as ApiAccessDecisionVerdict, AccessPreCheckResponseModel,
6 AccessRequestCreateRequestModel, AccessRequestDecisionResponseModel,
7 AccessRequestDetailsResponseModel, AccessRequestResultResponseModel,
8 AccessRequestStatus as ApiAccessRequestStatus, CipherAccessStateResponseModel,
9};
10use bitwarden_collections::collection::CollectionId;
11use bitwarden_core::{OrganizationId, UserId, require};
12use bitwarden_vault::CipherId;
13use chrono::{DateTime, SecondsFormat, Utc};
14use serde::{Deserialize, Serialize};
15
16use super::validate::{
17 AccessRequestWindowError, DEFAULT_REQUEST_ACCESS_DURATION_SECONDS,
18 MAX_REQUEST_ACCESS_WINDOW_SECONDS,
19};
20use crate::{
21 AccessLeaseId, AccessLeaseStatus, AccessRequestId, AccessRuleId, error::PamDecodeError,
22 leases::AccessLeaseView,
23};
24
25#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
31#[bitwarden_ffi::wasm_record]
32#[serde(rename_all = "snake_case")]
33pub enum AccessRequestStatus {
34 Pending,
36 Approved,
38 Denied,
40 Canceled,
42 Expired,
45 Unknown,
48}
49
50impl From<ApiAccessRequestStatus> for AccessRequestStatus {
51 fn from(status: ApiAccessRequestStatus) -> Self {
52 match status {
53 ApiAccessRequestStatus::Pending => Self::Pending,
54 ApiAccessRequestStatus::Approved => Self::Approved,
55 ApiAccessRequestStatus::Denied => Self::Denied,
56 ApiAccessRequestStatus::Cancelled => Self::Canceled,
57 ApiAccessRequestStatus::Expired => Self::Expired,
58 ApiAccessRequestStatus::__Unknown(_) => Self::Unknown,
59 }
60 }
61}
62
63#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
65#[bitwarden_ffi::wasm_record]
66#[serde(rename_all = "snake_case")]
67pub enum AccessDecisionVerdict {
68 Deny,
70 Approve,
72 Unknown,
75}
76
77impl From<ApiAccessDecisionVerdict> for AccessDecisionVerdict {
78 fn from(verdict: ApiAccessDecisionVerdict) -> Self {
79 match verdict {
80 ApiAccessDecisionVerdict::Deny => Self::Deny,
81 ApiAccessDecisionVerdict::Approve => Self::Approve,
82 ApiAccessDecisionVerdict::__Unknown(_) => Self::Unknown,
83 }
84 }
85}
86
87#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
89#[bitwarden_ffi::wasm_record]
90#[serde(rename_all = "camelCase")]
91pub struct AccessRequestDecisionView {
92 pub decider: AccessDecider,
94 pub verdict: AccessDecisionVerdict,
96 pub comment: Option<String>,
98 pub decided_at: DateTime<Utc>,
100}
101
102#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
104#[bitwarden_ffi::wasm_record]
105#[serde(rename_all = "camelCase")]
106pub enum AccessDecider {
107 Automatic,
109 Human(AccessApprover),
111}
112
113#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
115#[bitwarden_ffi::wasm_record]
116#[serde(rename_all = "camelCase")]
117pub struct AccessApprover {
118 pub id: Option<UserId>,
120 pub name: Option<String>,
122 pub email: Option<String>,
124}
125
126impl TryFrom<AccessRequestDecisionResponseModel> for AccessRequestDecisionView {
127 type Error = PamDecodeError;
128
129 fn try_from(response: AccessRequestDecisionResponseModel) -> Result<Self, Self::Error> {
130 let decider = match require!(response.decider_kind) {
131 ApiAccessDeciderKind::Automatic => AccessDecider::Automatic,
132 ApiAccessDeciderKind::Human => AccessDecider::Human(AccessApprover {
133 id: response.id.map(UserId::new),
134 name: response.name,
135 email: response.email,
136 }),
137 ApiAccessDeciderKind::__Unknown(_) => {
138 return Err(PamDecodeError::UnrecognizedDeciderKind);
139 }
140 };
141
142 Ok(Self {
143 decider,
144 verdict: AccessDecisionVerdict::from(require!(response.verdict)),
145 comment: response.comment,
146 decided_at: require!(response.decided_at).parse()?,
147 })
148 }
149}
150
151#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
153#[bitwarden_ffi::wasm_record]
154#[serde(rename_all = "camelCase")]
155pub struct AccessRequestView {
156 pub id: AccessRequestId,
158 pub cipher_id: CipherId,
160 pub collection_id: CollectionId,
162 pub organization_id: Option<OrganizationId>,
164 pub requester_id: UserId,
166 pub rule_id: Option<AccessRuleId>,
168 pub status: AccessRequestStatus,
170 pub lease_not_before: DateTime<Utc>,
173 pub lease_not_after: DateTime<Utc>,
175 pub reason: Option<String>,
177 pub submitted_at: DateTime<Utc>,
179 pub resolved_at: Option<DateTime<Utc>>,
181 pub decisions: Vec<AccessRequestDecisionView>,
183 pub produced_lease_id: Option<AccessLeaseId>,
185 pub produced_lease_status: Option<AccessLeaseStatus>,
187 pub produced_lease_not_after: Option<DateTime<Utc>>,
190 pub extension_of_lease_id: Option<AccessLeaseId>,
192 pub requester_name: Option<String>,
195 pub requester_email: Option<String>,
198 pub awaiting_activation: bool,
200 pub human_decision: Option<AccessRequestDecisionView>,
202}
203
204impl TryFrom<AccessRequestDetailsResponseModel> for AccessRequestView {
205 type Error = PamDecodeError;
206
207 fn try_from(response: AccessRequestDetailsResponseModel) -> Result<Self, Self::Error> {
208 let status = AccessRequestStatus::from(require!(response.status));
209 let produced_lease_id = response.produced_lease_id.map(AccessLeaseId::new);
210 let decisions = response
211 .decisions
212 .unwrap_or_default()
213 .into_iter()
214 .map(AccessRequestDecisionView::try_from)
215 .collect::<Result<Vec<_>, _>>()?;
216
217 let human_decision = decisions
218 .iter()
219 .find(|decision| !matches!(decision.decider, AccessDecider::Automatic))
220 .cloned();
221
222 Ok(Self {
223 id: AccessRequestId::new(require!(response.id)),
224 cipher_id: CipherId::new(require!(response.cipher_id)),
225 collection_id: CollectionId::new(require!(response.collection_id)),
226 organization_id: response.organization_id.map(OrganizationId::new),
227 requester_id: UserId::new(require!(response.requester_id)),
228 rule_id: response.rule_id.map(AccessRuleId::new),
229 status,
230 lease_not_before: require!(response.lease_not_before).parse()?,
231 lease_not_after: require!(response.lease_not_after).parse()?,
232 reason: response.reason,
233 submitted_at: require!(response.submitted_at).parse()?,
234 resolved_at: response.resolved_at.map(|d| d.parse()).transpose()?,
235 awaiting_activation: status == AccessRequestStatus::Approved
236 && produced_lease_id.is_none(),
237 human_decision,
238 decisions,
239 produced_lease_id,
240 produced_lease_status: response.produced_lease_status.map(AccessLeaseStatus::from),
241 produced_lease_not_after: response
242 .produced_lease_not_after
243 .map(|d| d.parse())
244 .transpose()?,
245 extension_of_lease_id: response.extension_of_lease_id.map(AccessLeaseId::new),
246 requester_name: response.requester_name,
247 requester_email: response.requester_email,
248 })
249 }
250}
251
252#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
254#[bitwarden_ffi::wasm_record]
255#[serde(rename_all = "snake_case")]
256pub enum AccessApprovalMode {
257 Automatic,
259 Human,
261 Unknown,
263}
264
265impl From<ApiAccessApprovalMode> for AccessApprovalMode {
266 fn from(mode: ApiAccessApprovalMode) -> Self {
267 match mode {
268 ApiAccessApprovalMode::Automatic => Self::Automatic,
269 ApiAccessApprovalMode::Human => Self::Human,
270 ApiAccessApprovalMode::__Unknown(_) => Self::Unknown,
271 }
272 }
273}
274
275#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
277#[bitwarden_ffi::wasm_record]
278#[serde(rename_all = "camelCase")]
279pub struct AccessPreCheckView {
280 pub cipher_id: CipherId,
282 pub approval_mode: AccessApprovalMode,
284 pub has_active_lease: bool,
286 pub default_duration_seconds: u32,
289 pub max_duration_seconds: u32,
291 pub can_start_lease: bool,
294 pub slot_frees_at: Option<DateTime<Utc>>,
297}
298
299impl TryFrom<AccessPreCheckResponseModel> for AccessPreCheckView {
300 type Error = PamDecodeError;
301
302 fn try_from(response: AccessPreCheckResponseModel) -> Result<Self, Self::Error> {
303 let max_duration_seconds = positive_u32(response.max_duration_seconds)
306 .map_or(MAX_REQUEST_ACCESS_WINDOW_SECONDS, |max| {
307 max.min(MAX_REQUEST_ACCESS_WINDOW_SECONDS)
308 });
309
310 Ok(Self {
311 cipher_id: CipherId::new(require!(response.cipher_id)),
312 approval_mode: AccessApprovalMode::from(require!(response.approval_mode)),
313 has_active_lease: require!(response.has_active_lease),
314 default_duration_seconds: positive_u32(response.default_duration_seconds)
317 .unwrap_or(DEFAULT_REQUEST_ACCESS_DURATION_SECONDS)
318 .min(max_duration_seconds),
319 max_duration_seconds,
320 can_start_lease: response.can_start_lease.unwrap_or(true),
322 slot_frees_at: response.slot_frees_at.map(|d| d.parse()).transpose()?,
323 })
324 }
325}
326
327fn positive_u32(value: Option<i32>) -> Option<u32> {
330 value.filter(|v| *v > 0).map(|v| v as u32)
331}
332
333#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
336#[bitwarden_ffi::wasm_record]
337#[serde(rename_all = "camelCase")]
338pub struct AccessRequestSummaryView {
339 pub id: AccessRequestId,
341 pub cipher_id: CipherId,
343 pub collection_id: CollectionId,
345 pub organization_id: Option<OrganizationId>,
347 pub status: AccessRequestStatus,
349 pub lease_not_before: DateTime<Utc>,
351 pub lease_not_after: DateTime<Utc>,
353 pub reason: Option<String>,
355 pub submitted_at: DateTime<Utc>,
357}
358
359impl TryFrom<AccessRequestDetailsResponseModel> for AccessRequestSummaryView {
360 type Error = PamDecodeError;
361
362 fn try_from(response: AccessRequestDetailsResponseModel) -> Result<Self, Self::Error> {
363 Ok(Self {
364 id: AccessRequestId::new(require!(response.id)),
365 cipher_id: CipherId::new(require!(response.cipher_id)),
366 collection_id: CollectionId::new(require!(response.collection_id)),
367 organization_id: response.organization_id.map(OrganizationId::new),
368 status: AccessRequestStatus::from(require!(response.status)),
369 lease_not_before: require!(response.lease_not_before).parse()?,
370 lease_not_after: require!(response.lease_not_after).parse()?,
371 reason: response.reason,
372 submitted_at: require!(response.submitted_at).parse()?,
373 })
374 }
375}
376
377#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
382#[bitwarden_ffi::wasm_record]
383#[serde(rename_all = "camelCase")]
384pub struct AccessRequestResultView {
385 pub approval_mode: AccessApprovalMode,
389 pub request: AccessRequestSummaryView,
391}
392
393impl TryFrom<AccessRequestResultResponseModel> for AccessRequestResultView {
394 type Error = PamDecodeError;
395
396 fn try_from(response: AccessRequestResultResponseModel) -> Result<Self, Self::Error> {
397 Ok(Self {
398 approval_mode: AccessApprovalMode::from(require!(response.approval_mode)),
399 request: AccessRequestSummaryView::try_from(*require!(response.request))?,
400 })
401 }
402}
403
404#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
408#[bitwarden_ffi::wasm_record]
409#[serde(rename_all = "camelCase")]
410pub enum AccessBadgeState {
411 Active {
413 #[serde(rename = "expiresAt")]
415 expires_at: DateTime<Utc>,
416 },
417 Ready,
419 Pending,
421 Unavailable,
424 Privileged,
426}
427
428#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
431#[bitwarden_ffi::wasm_record]
432#[serde(rename_all = "camelCase")]
433pub struct CipherAccessStateView {
434 pub cipher_id: CipherId,
436 pub active_lease: Option<AccessLeaseView>,
438 pub pending_request: Option<AccessRequestView>,
440 pub approved_request: Option<AccessRequestView>,
443 pub badge_state: AccessBadgeState,
445 pub extensions_allowed: bool,
447 pub max_extension_duration_seconds: Option<i32>,
450}
451
452impl TryFrom<CipherAccessStateResponseModel> for CipherAccessStateView {
453 type Error = PamDecodeError;
454
455 fn try_from(response: CipherAccessStateResponseModel) -> Result<Self, Self::Error> {
456 let active_lease = response
457 .active_lease
458 .map(|lease| AccessLeaseView::try_from(*lease))
459 .transpose()?;
460 let pending_request = response
461 .pending_request
462 .map(|request| AccessRequestView::try_from(*request))
463 .transpose()?;
464 let approved_request = response
465 .approved_request
466 .map(|request| AccessRequestView::try_from(*request))
467 .transpose()?;
468
469 let badge_state = if let Some(lease) = &active_lease {
470 AccessBadgeState::Active {
471 expires_at: lease.not_after,
472 }
473 } else if approved_request.is_some() {
474 AccessBadgeState::Ready
475 } else if pending_request.is_some() {
476 AccessBadgeState::Pending
477 } else if response.can_request == Some(false) {
478 AccessBadgeState::Unavailable
480 } else {
481 AccessBadgeState::Privileged
482 };
483
484 Ok(Self {
485 cipher_id: CipherId::new(require!(response.cipher_id)),
486 active_lease,
487 pending_request,
488 approved_request,
489 badge_state,
490 extensions_allowed: require!(response.extensions_allowed),
491 max_extension_duration_seconds: response.max_extension_duration_seconds,
492 })
493 }
494}
495
496#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)]
501#[bitwarden_ffi::wasm_record]
502#[serde(rename_all = "camelCase")]
503pub struct AccessRequestCreateRequest {
504 pub duration_seconds: Option<NonZeroU32>,
506 pub start: Option<DateTime<Utc>>,
508 pub end: Option<DateTime<Utc>>,
510 pub reason: Option<String>,
512}
513
514impl TryFrom<AccessRequestCreateRequest> for AccessRequestCreateRequestModel {
515 type Error = AccessRequestWindowError;
516
517 fn try_from(request: AccessRequestCreateRequest) -> Result<Self, Self::Error> {
520 request.validate()?;
521
522 Ok(Self {
523 duration_seconds: request.duration_seconds.map(|d| d.get() as i32),
524 start: request
525 .start
526 .map(|d| d.to_rfc3339_opts(SecondsFormat::Millis, true)),
527 end: request
528 .end
529 .map(|d| d.to_rfc3339_opts(SecondsFormat::Millis, true)),
530 reason: request.reason,
531 })
532 }
533}
534
535#[cfg(test)]
536mod tests {
537 use bitwarden_api_api::models::{
538 AccessDeciderKind, AccessLeaseResponseModel, AccessLeaseStatus as ApiAccessLeaseStatus,
539 };
540 use uuid::{Uuid, uuid};
541
542 use super::*;
543
544 fn human_decision() -> AccessRequestDecisionResponseModel {
545 AccessRequestDecisionResponseModel {
546 decider_kind: Some(AccessDeciderKind::Human),
547 id: Some(Uuid::new_v4()),
548 name: Some("Ana Approver".to_string()),
549 email: Some("[email protected]".to_string()),
550 comment: Some("Looks fine".to_string()),
551 verdict: Some(ApiAccessDecisionVerdict::Approve),
552 decided_at: Some("2025-01-01T00:30:00Z".to_string()),
553 }
554 }
555
556 fn automatic_decision() -> AccessRequestDecisionResponseModel {
557 AccessRequestDecisionResponseModel {
558 decider_kind: Some(AccessDeciderKind::Automatic),
559 id: None,
560 name: None,
561 email: None,
562 comment: None,
563 verdict: Some(ApiAccessDecisionVerdict::Approve),
564 decided_at: Some("2025-01-01T00:00:05Z".to_string()),
565 }
566 }
567
568 fn full_response() -> AccessRequestDetailsResponseModel {
569 AccessRequestDetailsResponseModel {
570 id: Some(Uuid::new_v4()),
571 cipher_id: Some(Uuid::new_v4()),
572 collection_id: Some(Uuid::new_v4()),
573 organization_id: Some(Uuid::new_v4()),
574 requester_id: Some(Uuid::new_v4()),
575 rule_id: Some(Uuid::new_v4()),
576 status: Some(ApiAccessRequestStatus::Approved),
577 lease_not_before: Some("2025-01-01T00:00:00Z".to_string()),
578 lease_not_after: Some("2025-01-01T01:00:00Z".to_string()),
579 reason: Some("Need to fix an incident".to_string()),
580 submitted_at: Some("2025-01-01T00:00:00Z".to_string()),
581 resolved_at: Some("2025-01-01T00:30:00Z".to_string()),
582 decisions: Some(vec![automatic_decision(), human_decision()]),
583 produced_lease_id: Some(Uuid::new_v4()),
584 produced_lease_status: Some(ApiAccessLeaseStatus::Active),
585 extension_of_lease_id: Some(Uuid::new_v4()),
586 requester_name: Some("Rea Quester".to_string()),
587 requester_email: Some("[email protected]".to_string()),
588 ..Default::default()
589 }
590 }
591
592 #[test]
593 fn full_response_converts_decisions_and_lease_linkage() {
594 let response = full_response();
595 let expected_produced_lease_id = response.produced_lease_id.unwrap();
596 let expected_extension_of_lease_id = response.extension_of_lease_id.unwrap();
597
598 let view = AccessRequestView::try_from(response).unwrap();
599
600 assert_eq!(view.decisions.len(), 2);
601 assert_eq!(
602 view.produced_lease_id,
603 Some(AccessLeaseId::new(expected_produced_lease_id))
604 );
605 assert_eq!(view.produced_lease_status, Some(AccessLeaseStatus::Active));
606 assert_eq!(
607 view.extension_of_lease_id,
608 Some(AccessLeaseId::new(expected_extension_of_lease_id))
609 );
610 assert_eq!(view.requester_name, Some("Rea Quester".to_string()));
611 assert_eq!(view.requester_email, Some("[email protected]".to_string()));
612
613 assert!(matches!(
614 view.decisions[0].decider,
615 AccessDecider::Automatic
616 ));
617
618 let decision = &view.decisions[1];
619 let AccessDecider::Human(approver) = &decision.decider else {
620 panic!("expected a human decision, got {:?}", decision.decider);
621 };
622 assert_eq!(approver.name.as_deref(), Some("Ana Approver"));
623 assert_eq!(approver.email.as_deref(), Some("[email protected]"));
624 assert_eq!(decision.comment.as_deref(), Some("Looks fine"));
625 assert_eq!(decision.verdict, AccessDecisionVerdict::Approve);
626 }
627
628 #[test]
629 fn automatic_decision_has_no_approver_identity() {
630 let view = AccessRequestDecisionView::try_from(automatic_decision()).unwrap();
631
632 assert!(matches!(view.decider, AccessDecider::Automatic));
633 }
634
635 #[test]
636 fn missing_decisions_becomes_empty_vec() {
637 let response = AccessRequestDetailsResponseModel {
638 decisions: None,
639 ..full_response()
640 };
641
642 let view = AccessRequestView::try_from(response).unwrap();
643
644 assert_eq!(view.decisions, Vec::new());
645 }
646
647 #[test]
648 fn unknown_decider_kind_is_rejected() {
649 let response = AccessRequestDecisionResponseModel {
650 decider_kind: Some(AccessDeciderKind::__Unknown(99)),
651 ..human_decision()
652 };
653
654 assert!(AccessRequestDecisionView::try_from(response).is_err());
655 }
656
657 #[test]
658 fn unknown_verdict_maps_to_unknown() {
659 let response = AccessRequestDecisionResponseModel {
660 verdict: Some(ApiAccessDecisionVerdict::__Unknown(99)),
661 ..human_decision()
662 };
663
664 let view = AccessRequestDecisionView::try_from(response).unwrap();
665
666 assert_eq!(view.verdict, AccessDecisionVerdict::Unknown);
667 }
668
669 #[test]
670 fn human_decision_serializes_with_nested_approver() {
671 let view = AccessRequestDecisionView::try_from(human_decision()).unwrap();
672
673 let json = serde_json::to_value(&view).unwrap();
674
675 assert_eq!(json["decider"]["human"]["name"], "Ana Approver");
676 assert_eq!(json["decider"]["human"]["email"], "[email protected]");
677 assert_eq!(json["verdict"], "approve");
678 assert!(json.get("decidedAt").is_some());
679 }
680
681 #[test]
682 fn automatic_decision_serializes_without_approver() {
683 let view = AccessRequestDecisionView::try_from(automatic_decision()).unwrap();
684
685 let json = serde_json::to_value(&view).unwrap();
686
687 assert_eq!(json["decider"], "automatic");
688 assert_eq!(json["verdict"], "approve");
689 }
690
691 fn request_id() -> AccessRequestId {
692 AccessRequestId::new(uuid!("44444444-4444-4444-4444-444444444444"))
693 }
694
695 fn cipher_id() -> uuid::Uuid {
696 uuid!("55555555-5555-5555-5555-555555555555")
697 }
698
699 #[test]
700 fn pre_check_view_converts() {
701 let response = AccessPreCheckResponseModel {
702 cipher_id: Some(cipher_id()),
703 approval_mode: Some(ApiAccessApprovalMode::Automatic),
704 has_active_lease: Some(true),
705 ..Default::default()
706 };
707
708 let view = AccessPreCheckView::try_from(response).unwrap();
709
710 assert_eq!(view.cipher_id, CipherId::new(cipher_id()));
711 assert_eq!(view.approval_mode, AccessApprovalMode::Automatic);
712 assert!(view.has_active_lease);
713 }
714
715 #[test]
716 fn pre_check_view_maps_unknown_approval_mode() {
717 let response = AccessPreCheckResponseModel {
718 approval_mode: Some(ApiAccessApprovalMode::__Unknown(99)),
719 ..AccessPreCheckResponseModel {
720 cipher_id: Some(cipher_id()),
721 has_active_lease: Some(false),
722 ..Default::default()
723 }
724 };
725
726 let view = AccessPreCheckView::try_from(response).unwrap();
727
728 assert_eq!(view.approval_mode, AccessApprovalMode::Unknown);
729 }
730
731 #[test]
732 fn pre_check_view_defaults_can_start_lease_to_true_when_absent() {
733 let response = pre_check_response(None, None);
734
735 let view = AccessPreCheckView::try_from(response).unwrap();
736
737 assert!(view.can_start_lease);
738 assert_eq!(view.slot_frees_at, None);
739 }
740
741 #[test]
742 fn pre_check_view_maps_a_taken_slot_and_its_free_time() {
743 let response = AccessPreCheckResponseModel {
744 can_start_lease: Some(false),
745 slot_frees_at: Some("2026-08-31T10:52:00Z".to_string()),
746 ..pre_check_response(None, None)
747 };
748
749 let view = AccessPreCheckView::try_from(response).unwrap();
750
751 assert!(!view.can_start_lease);
752 assert_eq!(
753 view.slot_frees_at,
754 Some("2026-08-31T10:52:00Z".parse::<DateTime<Utc>>().unwrap())
755 );
756 }
757
758 #[test]
759 fn pre_check_view_maps_a_taken_slot_without_a_free_time() {
760 let response = AccessPreCheckResponseModel {
761 can_start_lease: Some(false),
762 slot_frees_at: None,
763 ..pre_check_response(None, None)
764 };
765
766 let view = AccessPreCheckView::try_from(response).unwrap();
767
768 assert!(!view.can_start_lease);
769 assert_eq!(view.slot_frees_at, None);
770 }
771
772 fn pre_check_response(
773 default_duration_seconds: Option<i32>,
774 max_duration_seconds: Option<i32>,
775 ) -> AccessPreCheckResponseModel {
776 AccessPreCheckResponseModel {
777 cipher_id: Some(cipher_id()),
778 approval_mode: Some(ApiAccessApprovalMode::Automatic),
779 has_active_lease: Some(false),
780 default_duration_seconds,
781 max_duration_seconds,
782 ..Default::default()
783 }
784 }
785
786 #[test]
787 fn pre_check_view_carries_the_rules_duration_bounds() {
788 let view = AccessPreCheckView::try_from(pre_check_response(Some(900), Some(1800))).unwrap();
789
790 assert_eq!(view.default_duration_seconds, 900);
791 assert_eq!(view.max_duration_seconds, 1800);
792 }
793
794 #[test]
795 fn pre_check_view_falls_back_when_bounds_are_absent() {
796 let view = AccessPreCheckView::try_from(pre_check_response(None, None)).unwrap();
797
798 assert_eq!(
799 view.default_duration_seconds,
800 DEFAULT_REQUEST_ACCESS_DURATION_SECONDS
801 );
802 assert_eq!(view.max_duration_seconds, MAX_REQUEST_ACCESS_WINDOW_SECONDS);
803 }
804
805 #[test]
806 fn pre_check_view_clamps_default_to_max() {
807 let view = AccessPreCheckView::try_from(pre_check_response(Some(3600), Some(900))).unwrap();
810
811 assert_eq!(view.default_duration_seconds, 900);
812 assert_eq!(view.max_duration_seconds, 900);
813 }
814
815 #[test]
816 fn pre_check_view_publishes_the_rule_cap_narrowed_only_by_the_global_ceiling() {
817 let ceiling = i32::try_from(MAX_REQUEST_ACCESS_WINDOW_SECONDS).unwrap();
818 for (sent, expected) in [
819 (7 * 86_400, 7 * 86_400),
821 (ceiling + 1, MAX_REQUEST_ACCESS_WINDOW_SECONDS),
822 ] {
823 let view = AccessPreCheckView::try_from(pre_check_response(None, Some(sent))).unwrap();
824
825 assert_eq!(view.max_duration_seconds, expected, "cap {sent}");
826 }
827 }
828
829 #[test]
830 fn pre_check_view_treats_non_positive_bounds_as_absent() {
831 let view = AccessPreCheckView::try_from(pre_check_response(Some(0), Some(-1))).unwrap();
834
835 assert_eq!(
836 view.default_duration_seconds,
837 DEFAULT_REQUEST_ACCESS_DURATION_SECONDS
838 );
839 assert_eq!(view.max_duration_seconds, MAX_REQUEST_ACCESS_WINDOW_SECONDS);
840 }
841
842 fn sample_created_request() -> AccessRequestDetailsResponseModel {
843 AccessRequestDetailsResponseModel {
844 id: Some(request_id().into()),
845 cipher_id: Some(cipher_id()),
846 collection_id: Some(uuid!("66666666-6666-6666-6666-666666666666")),
847 organization_id: Some(uuid!("77777777-7777-7777-7777-777777777777")),
848 status: Some(ApiAccessRequestStatus::Pending),
849 lease_not_before: Some("2025-01-01T00:00:00Z".to_string()),
850 lease_not_after: Some("2025-01-01T01:00:00Z".to_string()),
851 reason: Some("Need to fix an incident".to_string()),
852 submitted_at: Some("2025-01-01T00:00:00Z".to_string()),
853 ..Default::default()
854 }
855 }
856
857 #[test]
858 fn access_request_summary_view_converts() {
859 let view = AccessRequestSummaryView::try_from(sample_created_request()).unwrap();
860
861 assert_eq!(view.id, request_id());
862 assert_eq!(view.cipher_id, CipherId::new(cipher_id()));
863 assert_eq!(view.status, AccessRequestStatus::Pending);
864 assert_eq!(view.reason, Some("Need to fix an incident".to_string()));
865 }
866
867 #[test]
868 fn access_request_result_view_converts() {
869 let response = AccessRequestResultResponseModel {
870 approval_mode: Some(ApiAccessApprovalMode::Human),
871 request: Some(Box::new(sample_created_request())),
872 ..Default::default()
873 };
874
875 let view = AccessRequestResultView::try_from(response).unwrap();
876
877 assert_eq!(view.approval_mode, AccessApprovalMode::Human);
878 assert_eq!(view.request.id, request_id());
879 }
880
881 #[test]
882 fn cipher_access_state_view_converts_when_nothing_active() {
883 let response = CipherAccessStateResponseModel {
884 cipher_id: Some(cipher_id()),
885 active_lease: None,
886 pending_request: None,
887 approved_request: None,
888 extensions_allowed: Some(false),
889 max_extension_duration_seconds: None,
890 ..Default::default()
891 };
892
893 let view = CipherAccessStateView::try_from(response).unwrap();
894
895 assert_eq!(view.cipher_id, CipherId::new(cipher_id()));
896 assert_eq!(view.active_lease, None);
897 assert_eq!(view.pending_request, None);
898 assert_eq!(view.approved_request, None);
899 assert_eq!(view.badge_state, AccessBadgeState::Privileged);
900 assert!(!view.extensions_allowed);
901 assert_eq!(view.max_extension_duration_seconds, None);
902 }
903
904 #[test]
905 fn cipher_access_state_view_converts_when_all_branches_populated() {
906 let response = CipherAccessStateResponseModel {
907 cipher_id: Some(cipher_id()),
908 active_lease: Some(Box::new(AccessLeaseResponseModel {
909 id: Some(uuid!("33333333-3333-3333-3333-333333333333")),
910 request_id: Some(request_id().into()),
911 cipher_id: Some(cipher_id()),
912 collection_id: Some(uuid!("66666666-6666-6666-6666-666666666666")),
913 requester_id: Some(uuid!("88888888-8888-8888-8888-888888888888")),
914 status: Some(ApiAccessLeaseStatus::Active),
915 not_before: Some("2025-01-01T00:00:00Z".to_string()),
916 not_after: Some("2025-01-01T01:00:00Z".to_string()),
917 ..Default::default()
918 })),
919 pending_request: Some(Box::new(full_response())),
920 approved_request: Some(Box::new(full_response())),
921 extensions_allowed: Some(true),
922 max_extension_duration_seconds: Some(3600),
923 ..Default::default()
924 };
925
926 let view = CipherAccessStateView::try_from(response).unwrap();
927
928 assert!(view.active_lease.is_some());
929 assert!(view.pending_request.is_some());
930 assert!(view.approved_request.is_some());
931 assert_eq!(
933 view.badge_state,
934 AccessBadgeState::Active {
935 expires_at: "2025-01-01T01:00:00Z".parse().unwrap()
936 }
937 );
938 assert!(view.extensions_allowed);
939 assert_eq!(view.max_extension_duration_seconds, Some(3600));
940 }
941
942 fn cipher_access_state_response_with(
943 active_lease: Option<Box<AccessLeaseResponseModel>>,
944 pending_request: Option<Box<AccessRequestDetailsResponseModel>>,
945 approved_request: Option<Box<AccessRequestDetailsResponseModel>>,
946 ) -> CipherAccessStateResponseModel {
947 CipherAccessStateResponseModel {
948 cipher_id: Some(cipher_id()),
949 active_lease,
950 pending_request,
951 approved_request,
952 extensions_allowed: Some(false),
953 max_extension_duration_seconds: None,
954 ..Default::default()
955 }
956 }
957
958 fn sample_active_lease() -> Box<AccessLeaseResponseModel> {
959 Box::new(AccessLeaseResponseModel {
960 id: Some(uuid!("33333333-3333-3333-3333-333333333333")),
961 request_id: Some(request_id().into()),
962 cipher_id: Some(cipher_id()),
963 collection_id: Some(uuid!("66666666-6666-6666-6666-666666666666")),
964 requester_id: Some(uuid!("88888888-8888-8888-8888-888888888888")),
965 status: Some(ApiAccessLeaseStatus::Active),
966 not_before: Some("2025-01-01T00:00:00Z".to_string()),
967 not_after: Some("2025-01-01T01:00:00Z".to_string()),
968 ..Default::default()
969 })
970 }
971
972 #[test]
973 fn badge_state_is_active_when_only_an_active_lease_is_present() {
974 let response = cipher_access_state_response_with(Some(sample_active_lease()), None, None);
975
976 let view = CipherAccessStateView::try_from(response).unwrap();
977
978 assert_eq!(
979 view.badge_state,
980 AccessBadgeState::Active {
981 expires_at: "2025-01-01T01:00:00Z".parse().unwrap()
982 }
983 );
984 }
985
986 #[test]
987 fn badge_state_is_ready_when_approved_request_is_present_without_a_lease() {
988 let response =
989 cipher_access_state_response_with(None, None, Some(Box::new(full_response())));
990
991 let view = CipherAccessStateView::try_from(response).unwrap();
992
993 assert_eq!(view.badge_state, AccessBadgeState::Ready);
994 }
995
996 #[test]
997 fn badge_state_prefers_ready_over_pending() {
998 let response = cipher_access_state_response_with(
999 None,
1000 Some(Box::new(full_response())),
1001 Some(Box::new(full_response())),
1002 );
1003
1004 let view = CipherAccessStateView::try_from(response).unwrap();
1005
1006 assert_eq!(view.badge_state, AccessBadgeState::Ready);
1007 }
1008
1009 #[test]
1010 fn badge_state_is_pending_when_only_a_pending_request_is_present() {
1011 let response =
1012 cipher_access_state_response_with(None, Some(Box::new(full_response())), None);
1013
1014 let view = CipherAccessStateView::try_from(response).unwrap();
1015
1016 assert_eq!(view.badge_state, AccessBadgeState::Pending);
1017 }
1018
1019 #[test]
1020 fn badge_state_is_privileged_when_nothing_is_present() {
1021 let response = cipher_access_state_response_with(None, None, None);
1022
1023 let view = CipherAccessStateView::try_from(response).unwrap();
1024
1025 assert_eq!(view.badge_state, AccessBadgeState::Privileged);
1026 }
1027
1028 #[test]
1029 fn badge_state_is_unavailable_when_the_rule_refuses_a_request() {
1030 let response = CipherAccessStateResponseModel {
1031 can_request: Some(false),
1032 ..cipher_access_state_response_with(None, None, None)
1033 };
1034
1035 let view = CipherAccessStateView::try_from(response).unwrap();
1036
1037 assert_eq!(view.badge_state, AccessBadgeState::Unavailable);
1038 }
1039
1040 #[test]
1041 fn badge_state_prefers_pending_over_unavailable() {
1042 let response = CipherAccessStateResponseModel {
1043 can_request: Some(false),
1044 ..cipher_access_state_response_with(None, Some(Box::new(full_response())), None)
1045 };
1046
1047 let view = CipherAccessStateView::try_from(response).unwrap();
1048
1049 assert_eq!(view.badge_state, AccessBadgeState::Pending);
1050 }
1051
1052 #[test]
1053 fn badge_state_is_privileged_when_the_rule_allows_a_request() {
1054 let response = CipherAccessStateResponseModel {
1055 can_request: Some(true),
1056 ..cipher_access_state_response_with(None, None, None)
1057 };
1058
1059 let view = CipherAccessStateView::try_from(response).unwrap();
1060
1061 assert_eq!(view.badge_state, AccessBadgeState::Privileged);
1062 }
1063
1064 #[test]
1065 fn active_badge_state_serializes_with_camel_case_expires_at() {
1066 let state = AccessBadgeState::Active {
1067 expires_at: "2025-01-01T01:00:00Z".parse().unwrap(),
1068 };
1069
1070 let json = serde_json::to_value(&state).unwrap();
1071
1072 assert_eq!(json["active"]["expiresAt"], "2025-01-01T01:00:00Z");
1073 }
1074
1075 #[test]
1076 fn unit_badge_states_serialize_as_bare_strings() {
1077 assert_eq!(
1078 serde_json::to_value(AccessBadgeState::Ready).unwrap(),
1079 serde_json::json!("ready")
1080 );
1081 assert_eq!(
1082 serde_json::to_value(AccessBadgeState::Pending).unwrap(),
1083 serde_json::json!("pending")
1084 );
1085 assert_eq!(
1086 serde_json::to_value(AccessBadgeState::Unavailable).unwrap(),
1087 serde_json::json!("unavailable")
1088 );
1089 assert_eq!(
1090 serde_json::to_value(AccessBadgeState::Privileged).unwrap(),
1091 serde_json::json!("privileged")
1092 );
1093 }
1094
1095 #[test]
1098 fn access_request_create_request_converts_to_model() {
1099 let request = AccessRequestCreateRequest {
1100 duration_seconds: NonZeroU32::new(3600),
1101 start: Some("2099-01-01T00:00:00Z".parse().unwrap()),
1102 end: Some("2099-01-01T01:00:00Z".parse().unwrap()),
1103 reason: Some("Need access".to_string()),
1104 };
1105
1106 let model = AccessRequestCreateRequestModel::try_from(request).unwrap();
1107
1108 assert_eq!(model.duration_seconds, Some(3600));
1109 assert_eq!(model.start, Some("2099-01-01T00:00:00.000Z".to_string()));
1110 assert_eq!(model.end, Some("2099-01-01T01:00:00.000Z".to_string()));
1111 assert_eq!(model.reason, Some("Need access".to_string()));
1112 }
1113
1114 #[test]
1115 fn access_request_create_request_window_is_serialized_as_utc_with_a_z_designator() {
1116 let request = AccessRequestCreateRequest {
1117 start: Some("2099-06-15T13:30:00.250Z".parse().unwrap()),
1118 end: Some("2099-06-15T14:30:00Z".parse().unwrap()),
1119 ..Default::default()
1120 };
1121
1122 let model = AccessRequestCreateRequestModel::try_from(request).unwrap();
1123
1124 assert_eq!(model.start, Some("2099-06-15T13:30:00.250Z".to_string()));
1125 assert_eq!(model.end, Some("2099-06-15T14:30:00.000Z".to_string()));
1126 assert!(model.start.unwrap().ends_with('Z'));
1127 assert!(model.end.unwrap().ends_with('Z'));
1128 }
1129
1130 #[test]
1131 fn access_request_create_request_conversion_enforces_validation() {
1132 let request = AccessRequestCreateRequest {
1133 start: Some("2025-01-01T01:00:00Z".parse().unwrap()),
1134 end: Some("2025-01-01T00:00:00Z".parse().unwrap()),
1135 ..Default::default()
1136 };
1137
1138 let result = AccessRequestCreateRequestModel::try_from(request);
1139
1140 assert_eq!(
1141 result.unwrap_err(),
1142 AccessRequestWindowError::EndBeforeStart
1143 );
1144 }
1145
1146 #[test]
1147 fn awaiting_activation_is_true_for_an_approved_request_with_no_lease_yet() {
1148 let response = AccessRequestDetailsResponseModel {
1149 status: Some(ApiAccessRequestStatus::Approved),
1150 produced_lease_id: None,
1151 ..full_response()
1152 };
1153
1154 let view = AccessRequestView::try_from(response).unwrap();
1155
1156 assert!(view.awaiting_activation);
1157 }
1158
1159 #[test]
1162 fn awaiting_activation_is_false_once_the_request_has_minted_a_lease() {
1163 let response = AccessRequestDetailsResponseModel {
1164 status: Some(ApiAccessRequestStatus::Approved),
1165 produced_lease_id: Some(Uuid::new_v4()),
1166 ..full_response()
1167 };
1168
1169 let view = AccessRequestView::try_from(response).unwrap();
1170
1171 assert!(!view.awaiting_activation);
1172 assert_eq!(view.status, AccessRequestStatus::Approved);
1173 }
1174
1175 #[test]
1176 fn awaiting_activation_is_false_for_a_request_still_pending() {
1177 let response = AccessRequestDetailsResponseModel {
1178 status: Some(ApiAccessRequestStatus::Pending),
1179 produced_lease_id: None,
1180 ..full_response()
1181 };
1182
1183 let view = AccessRequestView::try_from(response).unwrap();
1184
1185 assert!(!view.awaiting_activation);
1186 }
1187
1188 #[test]
1189 fn human_decision_skips_the_automatic_one() {
1190 let response = AccessRequestDetailsResponseModel {
1191 decisions: Some(vec![automatic_decision(), human_decision()]),
1192 ..full_response()
1193 };
1194
1195 let view = AccessRequestView::try_from(response).unwrap();
1196
1197 let decision = view.human_decision.expect("a human decided this request");
1198 assert!(matches!(decision.decider, AccessDecider::Human(_)));
1199 assert_eq!(decision.comment.as_deref(), Some("Looks fine"));
1200 }
1201
1202 #[test]
1203 fn human_decision_is_none_when_only_an_access_rule_decided() {
1204 let response = AccessRequestDetailsResponseModel {
1205 decisions: Some(vec![automatic_decision()]),
1206 ..full_response()
1207 };
1208
1209 let view = AccessRequestView::try_from(response).unwrap();
1210
1211 assert!(view.human_decision.is_none());
1212 }
1213
1214 #[test]
1215 fn human_decision_is_none_while_the_request_is_undecided() {
1216 let response = AccessRequestDetailsResponseModel {
1217 decisions: Some(vec![]),
1218 ..full_response()
1219 };
1220
1221 let view = AccessRequestView::try_from(response).unwrap();
1222
1223 assert!(view.human_decision.is_none());
1224 }
1225}