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, Utc};
14use serde::{Deserialize, Serialize};
15#[cfg(feature = "wasm")]
16use tsify::Tsify;
17
18use crate::{
19 AccessLeaseId, AccessLeaseStatus, AccessRequestId, AccessRuleId, error::LeasingError,
20 leases::AccessLeaseView,
21};
22
23#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
31#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
32#[serde(rename_all = "snake_case")]
33pub enum AccessRequestStatus {
34 Pending,
36 Approved,
38 Denied,
40 Canceled,
42 Expired,
44 Unknown,
47}
48
49impl From<ApiAccessRequestStatus> for AccessRequestStatus {
50 fn from(status: ApiAccessRequestStatus) -> Self {
51 match status {
52 ApiAccessRequestStatus::Pending => Self::Pending,
53 ApiAccessRequestStatus::Approved => Self::Approved,
54 ApiAccessRequestStatus::Denied => Self::Denied,
55 ApiAccessRequestStatus::Cancelled => Self::Canceled,
56 ApiAccessRequestStatus::Expired => Self::Expired,
57 ApiAccessRequestStatus::__Unknown(_) => Self::Unknown,
58 }
59 }
60}
61
62#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
64#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
65#[serde(rename_all = "snake_case")]
66pub enum AccessDecisionVerdict {
67 Deny,
69 Approve,
71 Unknown,
74}
75
76impl From<ApiAccessDecisionVerdict> for AccessDecisionVerdict {
77 fn from(verdict: ApiAccessDecisionVerdict) -> Self {
78 match verdict {
79 ApiAccessDecisionVerdict::Deny => Self::Deny,
80 ApiAccessDecisionVerdict::Approve => Self::Approve,
81 ApiAccessDecisionVerdict::__Unknown(_) => Self::Unknown,
82 }
83 }
84}
85
86#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
93#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
94#[serde(rename_all = "camelCase")]
95pub struct AccessRequestDecisionView {
96 pub decider: AccessDecider,
98 pub verdict: AccessDecisionVerdict,
100 pub comment: Option<String>,
102 pub decided_at: DateTime<Utc>,
104}
105
106#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
108#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
109#[serde(rename_all = "camelCase")]
110pub enum AccessDecider {
111 Automatic,
114 Human(AccessApprover),
116}
117
118#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
120#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
121#[serde(rename_all = "camelCase")]
122pub struct AccessApprover {
123 pub id: Option<UserId>,
125 pub name: Option<String>,
127 pub email: Option<String>,
129}
130
131impl TryFrom<AccessRequestDecisionResponseModel> for AccessRequestDecisionView {
132 type Error = LeasingError;
133
134 fn try_from(response: AccessRequestDecisionResponseModel) -> Result<Self, Self::Error> {
135 let decider = match require!(response.decider_kind) {
136 ApiAccessDeciderKind::Automatic => AccessDecider::Automatic,
137 ApiAccessDeciderKind::Human => AccessDecider::Human(AccessApprover {
138 id: response.id.map(UserId::new),
139 name: response.name,
140 email: response.email,
141 }),
142 ApiAccessDeciderKind::__Unknown(_) => {
143 return Err(LeasingError::UnrecognizedDeciderKind);
144 }
145 };
146
147 Ok(Self {
148 decider,
149 verdict: AccessDecisionVerdict::from(require!(response.verdict)),
150 comment: response.comment,
151 decided_at: require!(response.decided_at).parse()?,
152 })
153 }
154}
155
156#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
162#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
163#[serde(rename_all = "camelCase")]
164pub struct AccessRequestView {
165 pub id: AccessRequestId,
167 pub cipher_id: CipherId,
169 pub collection_id: CollectionId,
171 pub organization_id: Option<OrganizationId>,
173 pub requester_id: UserId,
175 pub rule_id: Option<AccessRuleId>,
178 pub status: AccessRequestStatus,
180 pub lease_not_before: DateTime<Utc>,
183 pub lease_not_after: DateTime<Utc>,
185 pub reason: Option<String>,
187 pub submitted_at: DateTime<Utc>,
189 pub resolved_at: Option<DateTime<Utc>>,
191 pub decisions: Vec<AccessRequestDecisionView>,
193 pub produced_lease_id: Option<AccessLeaseId>,
195 pub produced_lease_status: Option<AccessLeaseStatus>,
197 pub extension_of_lease_id: Option<AccessLeaseId>,
199 pub requester_name: Option<String>,
202 pub requester_email: Option<String>,
205}
206
207impl TryFrom<AccessRequestDetailsResponseModel> for AccessRequestView {
208 type Error = LeasingError;
209
210 fn try_from(response: AccessRequestDetailsResponseModel) -> Result<Self, Self::Error> {
211 Ok(Self {
212 id: AccessRequestId::new(require!(response.id)),
213 cipher_id: CipherId::new(require!(response.cipher_id)),
214 collection_id: CollectionId::new(require!(response.collection_id)),
215 organization_id: response.organization_id.map(OrganizationId::new),
216 requester_id: UserId::new(require!(response.requester_id)),
217 rule_id: response.rule_id.map(AccessRuleId::new),
218 status: AccessRequestStatus::from(require!(response.status)),
219 lease_not_before: require!(response.lease_not_before).parse()?,
220 lease_not_after: require!(response.lease_not_after).parse()?,
221 reason: response.reason,
222 submitted_at: require!(response.submitted_at).parse()?,
223 resolved_at: response.resolved_at.map(|d| d.parse()).transpose()?,
224 decisions: response
225 .decisions
226 .unwrap_or_default()
227 .into_iter()
228 .map(AccessRequestDecisionView::try_from)
229 .collect::<Result<Vec<_>, _>>()?,
230 produced_lease_id: response.produced_lease_id.map(AccessLeaseId::new),
231 produced_lease_status: response.produced_lease_status.map(AccessLeaseStatus::from),
232 extension_of_lease_id: response.extension_of_lease_id.map(AccessLeaseId::new),
233 requester_name: response.requester_name,
234 requester_email: response.requester_email,
235 })
236 }
237}
238
239#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
243#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
244#[serde(rename_all = "snake_case")]
245pub enum AccessApprovalMode {
246 Automatic,
249 Human,
252 Unknown,
254}
255
256impl From<ApiAccessApprovalMode> for AccessApprovalMode {
257 fn from(mode: ApiAccessApprovalMode) -> Self {
258 match mode {
259 ApiAccessApprovalMode::Automatic => Self::Automatic,
260 ApiAccessApprovalMode::Human => Self::Human,
261 ApiAccessApprovalMode::__Unknown(_) => Self::Unknown,
262 }
263 }
264}
265
266#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
268#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
269#[serde(rename_all = "camelCase")]
270pub struct AccessPreCheckView {
271 pub cipher_id: CipherId,
273 pub approval_mode: AccessApprovalMode,
275 pub has_active_lease: bool,
278}
279
280impl TryFrom<AccessPreCheckResponseModel> for AccessPreCheckView {
281 type Error = LeasingError;
282
283 fn try_from(response: AccessPreCheckResponseModel) -> Result<Self, Self::Error> {
284 Ok(Self {
285 cipher_id: CipherId::new(require!(response.cipher_id)),
286 approval_mode: AccessApprovalMode::from(require!(response.approval_mode)),
287 has_active_lease: require!(response.has_active_lease),
288 })
289 }
290}
291
292#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
298#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
299#[serde(rename_all = "camelCase")]
300pub struct AccessRequestSummaryView {
301 pub id: AccessRequestId,
303 pub cipher_id: CipherId,
305 pub collection_id: CollectionId,
307 pub organization_id: Option<OrganizationId>,
309 pub status: AccessRequestStatus,
311 pub lease_not_before: DateTime<Utc>,
313 pub lease_not_after: DateTime<Utc>,
315 pub reason: Option<String>,
317 pub submitted_at: DateTime<Utc>,
319}
320
321impl TryFrom<AccessRequestDetailsResponseModel> for AccessRequestSummaryView {
322 type Error = LeasingError;
323
324 fn try_from(response: AccessRequestDetailsResponseModel) -> Result<Self, Self::Error> {
325 Ok(Self {
326 id: AccessRequestId::new(require!(response.id)),
327 cipher_id: CipherId::new(require!(response.cipher_id)),
328 collection_id: CollectionId::new(require!(response.collection_id)),
329 organization_id: response.organization_id.map(OrganizationId::new),
330 status: AccessRequestStatus::from(require!(response.status)),
331 lease_not_before: require!(response.lease_not_before).parse()?,
332 lease_not_after: require!(response.lease_not_after).parse()?,
333 reason: response.reason,
334 submitted_at: require!(response.submitted_at).parse()?,
335 })
336 }
337}
338
339#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
344#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
345#[serde(rename_all = "camelCase")]
346pub struct AccessRequestResultView {
347 pub approval_mode: AccessApprovalMode,
351 pub request: AccessRequestSummaryView,
353}
354
355impl TryFrom<AccessRequestResultResponseModel> for AccessRequestResultView {
356 type Error = LeasingError;
357
358 fn try_from(response: AccessRequestResultResponseModel) -> Result<Self, Self::Error> {
359 Ok(Self {
360 approval_mode: AccessApprovalMode::from(require!(response.approval_mode)),
361 request: AccessRequestSummaryView::try_from(*require!(response.request))?,
362 })
363 }
364}
365
366#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
374#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
375#[serde(rename_all = "camelCase")]
376pub struct CipherAccessStateView {
377 pub cipher_id: CipherId,
379 pub active_lease: Option<AccessLeaseView>,
381 pub pending_request: Option<AccessRequestView>,
383 pub approved_request: Option<AccessRequestView>,
386 pub extensions_allowed: bool,
388 pub max_extension_duration_seconds: Option<i32>,
391}
392
393impl TryFrom<CipherAccessStateResponseModel> for CipherAccessStateView {
394 type Error = LeasingError;
395
396 fn try_from(response: CipherAccessStateResponseModel) -> Result<Self, Self::Error> {
397 Ok(Self {
398 cipher_id: CipherId::new(require!(response.cipher_id)),
399 active_lease: response
400 .active_lease
401 .map(|lease| AccessLeaseView::try_from(*lease))
402 .transpose()?,
403 pending_request: response
404 .pending_request
405 .map(|request| AccessRequestView::try_from(*request))
406 .transpose()?,
407 approved_request: response
408 .approved_request
409 .map(|request| AccessRequestView::try_from(*request))
410 .transpose()?,
411 extensions_allowed: require!(response.extensions_allowed),
412 max_extension_duration_seconds: response.max_extension_duration_seconds,
413 })
414 }
415}
416
417#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)]
424#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
425#[serde(rename_all = "camelCase")]
426pub struct AccessRequestCreateRequest {
427 pub duration_seconds: Option<NonZeroU32>,
429 pub start: Option<DateTime<Utc>>,
431 pub end: Option<DateTime<Utc>>,
433 pub reason: Option<String>,
435}
436
437impl From<AccessRequestCreateRequest> for AccessRequestCreateRequestModel {
438 fn from(request: AccessRequestCreateRequest) -> Self {
439 Self {
440 duration_seconds: request.duration_seconds.map(|d| d.get() as i32),
441 start: request.start.map(|d| d.to_rfc3339()),
442 end: request.end.map(|d| d.to_rfc3339()),
443 reason: request.reason,
444 }
445 }
446}
447
448#[cfg(test)]
449mod tests {
450 use bitwarden_api_api::models::{
451 AccessDeciderKind, AccessLeaseResponseModel, AccessLeaseStatus as ApiAccessLeaseStatus,
452 };
453 use uuid::{Uuid, uuid};
454
455 use super::*;
456
457 fn human_decision() -> AccessRequestDecisionResponseModel {
458 AccessRequestDecisionResponseModel {
459 decider_kind: Some(AccessDeciderKind::Human),
460 id: Some(Uuid::new_v4()),
461 name: Some("Ana Approver".to_string()),
462 email: Some("[email protected]".to_string()),
463 comment: Some("Looks fine".to_string()),
464 verdict: Some(ApiAccessDecisionVerdict::Approve),
465 decided_at: Some("2025-01-01T00:30:00Z".to_string()),
466 }
467 }
468
469 fn automatic_decision() -> AccessRequestDecisionResponseModel {
470 AccessRequestDecisionResponseModel {
471 decider_kind: Some(AccessDeciderKind::Automatic),
472 id: None,
473 name: None,
474 email: None,
475 comment: None,
476 verdict: Some(ApiAccessDecisionVerdict::Approve),
477 decided_at: Some("2025-01-01T00:00:05Z".to_string()),
478 }
479 }
480
481 fn full_response() -> AccessRequestDetailsResponseModel {
482 AccessRequestDetailsResponseModel {
483 id: Some(Uuid::new_v4()),
484 cipher_id: Some(Uuid::new_v4()),
485 collection_id: Some(Uuid::new_v4()),
486 organization_id: Some(Uuid::new_v4()),
487 requester_id: Some(Uuid::new_v4()),
488 rule_id: Some(Uuid::new_v4()),
489 status: Some(ApiAccessRequestStatus::Approved),
490 lease_not_before: Some("2025-01-01T00:00:00Z".to_string()),
491 lease_not_after: Some("2025-01-01T01:00:00Z".to_string()),
492 reason: Some("Need to fix an incident".to_string()),
493 submitted_at: Some("2025-01-01T00:00:00Z".to_string()),
494 resolved_at: Some("2025-01-01T00:30:00Z".to_string()),
495 decisions: Some(vec![automatic_decision(), human_decision()]),
496 produced_lease_id: Some(Uuid::new_v4()),
497 produced_lease_status: Some(ApiAccessLeaseStatus::Active),
498 extension_of_lease_id: Some(Uuid::new_v4()),
499 requester_name: Some("Rea Quester".to_string()),
500 requester_email: Some("[email protected]".to_string()),
501 ..Default::default()
502 }
503 }
504
505 #[test]
506 fn full_response_converts_decisions_and_lease_linkage() {
507 let response = full_response();
508 let expected_produced_lease_id = response.produced_lease_id.unwrap();
509 let expected_extension_of_lease_id = response.extension_of_lease_id.unwrap();
510
511 let view = AccessRequestView::try_from(response).unwrap();
512
513 assert_eq!(view.decisions.len(), 2);
514 assert_eq!(
515 view.produced_lease_id,
516 Some(AccessLeaseId::new(expected_produced_lease_id))
517 );
518 assert_eq!(view.produced_lease_status, Some(AccessLeaseStatus::Active));
519 assert_eq!(
520 view.extension_of_lease_id,
521 Some(AccessLeaseId::new(expected_extension_of_lease_id))
522 );
523 assert_eq!(view.requester_name, Some("Rea Quester".to_string()));
524 assert_eq!(view.requester_email, Some("[email protected]".to_string()));
525
526 assert!(matches!(
527 view.decisions[0].decider,
528 AccessDecider::Automatic
529 ));
530
531 let decision = &view.decisions[1];
532 let AccessDecider::Human(approver) = &decision.decider else {
533 panic!("expected a human decision, got {:?}", decision.decider);
534 };
535 assert_eq!(approver.name.as_deref(), Some("Ana Approver"));
536 assert_eq!(approver.email.as_deref(), Some("[email protected]"));
537 assert_eq!(decision.comment.as_deref(), Some("Looks fine"));
538 assert_eq!(decision.verdict, AccessDecisionVerdict::Approve);
539 }
540
541 #[test]
542 fn automatic_decision_has_no_approver_identity() {
543 let view = AccessRequestDecisionView::try_from(automatic_decision()).unwrap();
544
545 assert!(matches!(view.decider, AccessDecider::Automatic));
546 }
547
548 #[test]
549 fn missing_decisions_becomes_empty_vec() {
550 let response = AccessRequestDetailsResponseModel {
551 decisions: None,
552 ..full_response()
553 };
554
555 let view = AccessRequestView::try_from(response).unwrap();
556
557 assert_eq!(view.decisions, Vec::new());
558 }
559
560 #[test]
561 fn unknown_decider_kind_is_rejected() {
562 let response = AccessRequestDecisionResponseModel {
563 decider_kind: Some(AccessDeciderKind::__Unknown(99)),
564 ..human_decision()
565 };
566
567 assert!(AccessRequestDecisionView::try_from(response).is_err());
568 }
569
570 #[test]
571 fn unknown_verdict_maps_to_unknown() {
572 let response = AccessRequestDecisionResponseModel {
573 verdict: Some(ApiAccessDecisionVerdict::__Unknown(99)),
574 ..human_decision()
575 };
576
577 let view = AccessRequestDecisionView::try_from(response).unwrap();
578
579 assert_eq!(view.verdict, AccessDecisionVerdict::Unknown);
580 }
581
582 #[test]
583 fn human_decision_serializes_with_nested_approver() {
584 let view = AccessRequestDecisionView::try_from(human_decision()).unwrap();
585
586 let json = serde_json::to_value(&view).unwrap();
587
588 assert_eq!(json["decider"]["human"]["name"], "Ana Approver");
589 assert_eq!(json["decider"]["human"]["email"], "[email protected]");
590 assert_eq!(json["verdict"], "approve");
591 assert!(json.get("decidedAt").is_some());
592 }
593
594 #[test]
595 fn automatic_decision_serializes_without_approver() {
596 let view = AccessRequestDecisionView::try_from(automatic_decision()).unwrap();
597
598 let json = serde_json::to_value(&view).unwrap();
599
600 assert_eq!(json["decider"], "automatic");
601 assert_eq!(json["verdict"], "approve");
602 }
603
604 fn request_id() -> AccessRequestId {
605 AccessRequestId::new(uuid!("44444444-4444-4444-4444-444444444444"))
606 }
607
608 fn cipher_id() -> uuid::Uuid {
609 uuid!("55555555-5555-5555-5555-555555555555")
610 }
611
612 #[test]
613 fn pre_check_view_converts() {
614 let response = AccessPreCheckResponseModel {
615 cipher_id: Some(cipher_id()),
616 approval_mode: Some(ApiAccessApprovalMode::Automatic),
617 has_active_lease: Some(true),
618 ..Default::default()
619 };
620
621 let view = AccessPreCheckView::try_from(response).unwrap();
622
623 assert_eq!(view.cipher_id, CipherId::new(cipher_id()));
624 assert_eq!(view.approval_mode, AccessApprovalMode::Automatic);
625 assert!(view.has_active_lease);
626 }
627
628 #[test]
629 fn pre_check_view_maps_unknown_approval_mode() {
630 let response = AccessPreCheckResponseModel {
631 approval_mode: Some(ApiAccessApprovalMode::__Unknown(99)),
632 ..AccessPreCheckResponseModel {
633 cipher_id: Some(cipher_id()),
634 has_active_lease: Some(false),
635 ..Default::default()
636 }
637 };
638
639 let view = AccessPreCheckView::try_from(response).unwrap();
640
641 assert_eq!(view.approval_mode, AccessApprovalMode::Unknown);
642 }
643
644 fn sample_created_request() -> AccessRequestDetailsResponseModel {
645 AccessRequestDetailsResponseModel {
646 id: Some(request_id().into()),
647 cipher_id: Some(cipher_id()),
648 collection_id: Some(uuid!("66666666-6666-6666-6666-666666666666")),
649 organization_id: Some(uuid!("77777777-7777-7777-7777-777777777777")),
650 status: Some(ApiAccessRequestStatus::Pending),
651 lease_not_before: Some("2025-01-01T00:00:00Z".to_string()),
652 lease_not_after: Some("2025-01-01T01:00:00Z".to_string()),
653 reason: Some("Need to fix an incident".to_string()),
654 submitted_at: Some("2025-01-01T00:00:00Z".to_string()),
655 ..Default::default()
656 }
657 }
658
659 #[test]
660 fn access_request_summary_view_converts() {
661 let view = AccessRequestSummaryView::try_from(sample_created_request()).unwrap();
662
663 assert_eq!(view.id, request_id());
664 assert_eq!(view.cipher_id, CipherId::new(cipher_id()));
665 assert_eq!(view.status, AccessRequestStatus::Pending);
666 assert_eq!(view.reason, Some("Need to fix an incident".to_string()));
667 }
668
669 #[test]
670 fn access_request_result_view_converts() {
671 let response = AccessRequestResultResponseModel {
672 approval_mode: Some(ApiAccessApprovalMode::Human),
673 request: Some(Box::new(sample_created_request())),
674 ..Default::default()
675 };
676
677 let view = AccessRequestResultView::try_from(response).unwrap();
678
679 assert_eq!(view.approval_mode, AccessApprovalMode::Human);
680 assert_eq!(view.request.id, request_id());
681 }
682
683 #[test]
684 fn cipher_access_state_view_converts_when_nothing_active() {
685 let response = CipherAccessStateResponseModel {
686 cipher_id: Some(cipher_id()),
687 active_lease: None,
688 pending_request: None,
689 approved_request: None,
690 extensions_allowed: Some(false),
691 max_extension_duration_seconds: None,
692 ..Default::default()
693 };
694
695 let view = CipherAccessStateView::try_from(response).unwrap();
696
697 assert_eq!(view.cipher_id, CipherId::new(cipher_id()));
698 assert_eq!(view.active_lease, None);
699 assert_eq!(view.pending_request, None);
700 assert_eq!(view.approved_request, None);
701 assert!(!view.extensions_allowed);
702 assert_eq!(view.max_extension_duration_seconds, None);
703 }
704
705 #[test]
706 fn cipher_access_state_view_converts_when_all_branches_populated() {
707 let response = CipherAccessStateResponseModel {
708 cipher_id: Some(cipher_id()),
709 active_lease: Some(Box::new(AccessLeaseResponseModel {
710 id: Some(uuid!("33333333-3333-3333-3333-333333333333")),
711 request_id: Some(request_id().into()),
712 cipher_id: Some(cipher_id()),
713 collection_id: Some(uuid!("66666666-6666-6666-6666-666666666666")),
714 requester_id: Some(uuid!("88888888-8888-8888-8888-888888888888")),
715 status: Some(ApiAccessLeaseStatus::Active),
716 not_before: Some("2025-01-01T00:00:00Z".to_string()),
717 not_after: Some("2025-01-01T01:00:00Z".to_string()),
718 ..Default::default()
719 })),
720 pending_request: Some(Box::new(full_response())),
721 approved_request: Some(Box::new(full_response())),
722 extensions_allowed: Some(true),
723 max_extension_duration_seconds: Some(3600),
724 ..Default::default()
725 };
726
727 let view = CipherAccessStateView::try_from(response).unwrap();
728
729 assert!(view.active_lease.is_some());
730 assert!(view.pending_request.is_some());
731 assert!(view.approved_request.is_some());
732 assert!(view.extensions_allowed);
733 assert_eq!(view.max_extension_duration_seconds, Some(3600));
734 }
735
736 #[test]
737 fn access_request_create_request_converts_to_model() {
738 let request = AccessRequestCreateRequest {
739 duration_seconds: NonZeroU32::new(3600),
740 start: Some("2025-01-01T00:00:00Z".parse().unwrap()),
741 end: Some("2025-01-01T01:00:00Z".parse().unwrap()),
742 reason: Some("Need access".to_string()),
743 };
744
745 let model = AccessRequestCreateRequestModel::from(request);
746
747 assert_eq!(model.duration_seconds, Some(3600));
748 assert_eq!(model.start, Some("2025-01-01T00:00:00+00:00".to_string()));
749 assert_eq!(model.end, Some("2025-01-01T01:00:00+00:00".to_string()));
750 assert_eq!(model.reason, Some("Need access".to_string()));
751 }
752}