Skip to main content

bitwarden_pam/access_requests/
models.rs

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/// The lifecycle state of an access request.
26///
27/// Activation does not change the status; it shows in
28/// [`produced_lease_id`](AccessRequestView::produced_lease_id) and
29/// [`produced_lease_status`](AccessRequestView::produced_lease_status).
30#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
31#[bitwarden_ffi::wasm_record]
32#[serde(rename_all = "snake_case")]
33pub enum AccessRequestStatus {
34    /// Awaiting an approver's decision.
35    Pending,
36    /// Approved; the requester may activate it to mint a lease.
37    Approved,
38    /// Denied by an approver, or automatically; terminal.
39    Denied,
40    /// Withdrawn by the requester before activation; terminal.
41    Canceled,
42    /// The window lapsed unanswered or unactivated; [`decisions`](AccessRequestView::decisions)
43    /// tells which. Terminal.
44    Expired,
45    /// A status value this SDK version does not recognize. Kept as a distinct variant so listing
46    /// requests never fails on a newer server's status.
47    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/// An approver's verdict on an access request decision.
64#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
65#[bitwarden_ffi::wasm_record]
66#[serde(rename_all = "snake_case")]
67pub enum AccessDecisionVerdict {
68    /// The request was denied.
69    Deny,
70    /// The request was approved.
71    Approve,
72    /// A verdict value this SDK version does not recognize. Kept as a distinct variant so reading
73    /// a request's decision log never fails on a newer server's verdict.
74    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/// A single decision recorded on an access request's decision log.
88#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
89#[bitwarden_ffi::wasm_record]
90#[serde(rename_all = "camelCase")]
91pub struct AccessRequestDecisionView {
92    /// Who made the decision.
93    pub decider: AccessDecider,
94    /// The decision's verdict.
95    pub verdict: AccessDecisionVerdict,
96    /// The optional note recorded with the decision.
97    pub comment: Option<String>,
98    /// When the decision was recorded (UTC).
99    pub decided_at: DateTime<Utc>,
100}
101
102/// Who made a decision on an access request.
103#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
104#[bitwarden_ffi::wasm_record]
105#[serde(rename_all = "camelCase")]
106pub enum AccessDecider {
107    /// Decided automatically by the governing access rule.
108    Automatic,
109    /// The decision was made by a human approver, whose identity is denormalized by the server.
110    Human(AccessApprover),
111}
112
113/// The identity of a human approver, denormalized by the server.
114#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
115#[bitwarden_ffi::wasm_record]
116#[serde(rename_all = "camelCase")]
117pub struct AccessApprover {
118    /// The approver's user id; `None` when the server omitted it.
119    pub id: Option<UserId>,
120    /// The approver's display name; `None` when the user could not be resolved.
121    pub name: Option<String>,
122    /// The approver's email; `None` when the user could not be resolved.
123    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/// An access request, as returned by the server.
152#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
153#[bitwarden_ffi::wasm_record]
154#[serde(rename_all = "camelCase")]
155pub struct AccessRequestView {
156    /// The request's unique identifier.
157    pub id: AccessRequestId,
158    /// The cipher access was requested for.
159    pub cipher_id: CipherId,
160    /// The collection the cipher belongs to, through which the request is governed.
161    pub collection_id: CollectionId,
162    /// The organization that owns the cipher. None when the server omits it.
163    pub organization_id: Option<OrganizationId>,
164    /// The member who opened the request.
165    pub requester_id: UserId,
166    /// The access rule pinned to the request at submit, if any.
167    pub rule_id: Option<AccessRuleId>,
168    /// The request's lifecycle state.
169    pub status: AccessRequestStatus,
170    /// The start of the activation window resolved at submit (UTC), the earliest the request may
171    /// be activated.
172    pub lease_not_before: DateTime<Utc>,
173    /// The end of the activation window resolved at submit (UTC).
174    pub lease_not_after: DateTime<Utc>,
175    /// The optional justification the requester supplied when opening the request.
176    pub reason: Option<String>,
177    /// When the request was opened (UTC).
178    pub submitted_at: DateTime<Utc>,
179    /// When the request was approved, denied, or cancelled (UTC); None while pending or expired.
180    pub resolved_at: Option<DateTime<Utc>>,
181    /// The request's decision log, oldest first.
182    pub decisions: Vec<AccessRequestDecisionView>,
183    /// The lease this request produced on activation. None until activation.
184    pub produced_lease_id: Option<AccessLeaseId>,
185    /// The status of the produced lease at the time this view was fetched. None until activation.
186    pub produced_lease_status: Option<AccessLeaseStatus>,
187    /// The produced lease's current end (UTC); None until activation. Unlike
188    /// [`lease_not_after`](Self::lease_not_after), this moves when the lease is extended.
189    pub produced_lease_not_after: Option<DateTime<Utc>>,
190    /// The parent lease, if this is an extension request.
191    pub extension_of_lease_id: Option<AccessLeaseId>,
192    /// The requester's display name, denormalized by the server. None only when the user could
193    /// not be resolved.
194    pub requester_name: Option<String>,
195    /// The requester's email, denormalized by the server. None only when the user could not be
196    /// resolved.
197    pub requester_email: Option<String>,
198    /// True while approved but not yet activated into a lease.
199    pub awaiting_activation: bool,
200    /// The approver's or holder's decision, if any. None for automatic decisions.
201    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/// The approval path an access request takes.
253#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
254#[bitwarden_ffi::wasm_record]
255#[serde(rename_all = "snake_case")]
256pub enum AccessApprovalMode {
257    /// Approved on submit; the requester picks a duration.
258    Automatic,
259    /// Needs an approver; the requester picks a window and gives a reason.
260    Human,
261    /// An approval mode value this SDK version does not recognize.
262    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/// The resolved approval outcome for a cipher, read without submitting a request.
276#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
277#[bitwarden_ffi::wasm_record]
278#[serde(rename_all = "camelCase")]
279pub struct AccessPreCheckView {
280    /// The cipher this pre-check was resolved for.
281    pub cipher_id: CipherId,
282    /// The approval path a request for this cipher would take.
283    pub approval_mode: AccessApprovalMode,
284    /// True when the caller already holds an active lease, so no request is needed.
285    pub has_active_lease: bool,
286    /// Duration to pre-select, in seconds; never above
287    /// [`max_duration_seconds`](Self::max_duration_seconds).
288    pub default_duration_seconds: u32,
289    /// Longest duration or window span, in seconds, that submit will accept for this cipher.
290    pub max_duration_seconds: u32,
291    /// False while another member holds this cipher's single active lease. A hint; the server
292    /// re-checks at start.
293    pub can_start_lease: bool,
294    /// When the lease holding the slot ends; None while
295    /// [`can_start_lease`](Self::can_start_lease) is true.
296    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        // Absent and non-positive both mean "no cap", resolving to the ceiling as the server's
304        // `EffectiveMax` does.
305        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            // Re-clamped here and server-side: the two bounds arrive as independent fields,
315            // so a default above the cap would pre-fill a value submit refuses.
316            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            // Fails open: reading absence as false would block every gated cipher.
321            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
327/// Reads an optional wire-side duration, mapping absent, zero, and negative alike onto `None` so
328/// the caller applies its fallback.
329fn positive_u32(value: Option<i32>) -> Option<u32> {
330    value.filter(|v| *v > 0).map(|v| v as u32)
331}
332
333/// A just-submitted access request, as its requester sees it; a lighter sibling of
334/// [`AccessRequestView`].
335#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
336#[bitwarden_ffi::wasm_record]
337#[serde(rename_all = "camelCase")]
338pub struct AccessRequestSummaryView {
339    /// The request's unique identifier.
340    pub id: AccessRequestId,
341    /// The cipher access was requested for.
342    pub cipher_id: CipherId,
343    /// The collection the cipher belongs to, through which the request is governed.
344    pub collection_id: CollectionId,
345    /// The organization that owns the cipher. None when the server omits it.
346    pub organization_id: Option<OrganizationId>,
347    /// The request's lifecycle state.
348    pub status: AccessRequestStatus,
349    /// The start of the activation window resolved at submit (UTC).
350    pub lease_not_before: DateTime<Utc>,
351    /// The end of the activation window resolved at submit (UTC).
352    pub lease_not_after: DateTime<Utc>,
353    /// The optional justification the requester supplied when opening the request.
354    pub reason: Option<String>,
355    /// When the request was opened (UTC).
356    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/// The result of submitting a cipher-lease request.
378///
379/// No lease is minted at submit on either path; the requester
380/// [`activate`](crate::AccessRequestsClient::activate)s the request to start the lease.
381#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
382#[bitwarden_ffi::wasm_record]
383#[serde(rename_all = "camelCase")]
384pub struct AccessRequestResultView {
385    /// [`Automatic`](AccessApprovalMode::Automatic) when [`request`](Self::request) was approved
386    /// on submit and is ready to activate, [`Human`](AccessApprovalMode::Human) when it is
387    /// pending an approver.
388    pub approval_mode: AccessApprovalMode,
389    /// The request that was just submitted.
390    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/// The single badge to show for a gated cipher: an active lease outranks an approved request,
405/// which outranks a pending one, which outranks the rule refusing a new request. Has no `expired`
406/// state, since the access-state response is a snapshot.
407#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
408#[bitwarden_ffi::wasm_record]
409#[serde(rename_all = "camelCase")]
410pub enum AccessBadgeState {
411    /// The caller holds an active lease; the cipher is unlocked until it expires.
412    Active {
413        /// The active lease's access window close time (UTC).
414        #[serde(rename = "expiresAt")]
415        expires_at: DateTime<Utc>,
416    },
417    /// The caller's request was approved and is ready to be activated into a lease.
418    Ready,
419    /// The caller has a request awaiting a decision.
420    Pending,
421    /// Nothing is held or pending, and the rule's conditions refuse a new request right now, for
422    /// example outside its IP allowlist.
423    Unavailable,
424    /// No active lease, approved request, or pending request; the item is gated and resting.
425    Privileged,
426}
427
428/// A single-snapshot read of the caller's access state for one cipher, powering the cipher-view
429/// banner and the vault-row badge.
430#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
431#[bitwarden_ffi::wasm_record]
432#[serde(rename_all = "camelCase")]
433pub struct CipherAccessStateView {
434    /// The cipher this state was resolved for.
435    pub cipher_id: CipherId,
436    /// The caller's active lease over this cipher, if any.
437    pub active_lease: Option<AccessLeaseView>,
438    /// The caller's request awaiting a decision on this cipher, if any.
439    pub pending_request: Option<AccessRequestView>,
440    /// The caller's approved-but-not-yet-activated request on this cipher, if any. Lapsed
441    /// approvals are never surfaced here.
442    pub approved_request: Option<AccessRequestView>,
443    /// The single badge to show for this cipher; see [`AccessBadgeState`] for precedence.
444    pub badge_state: AccessBadgeState,
445    /// Whether the active lease can still be extended.
446    pub extensions_allowed: bool,
447    /// The longest a single extension of the active lease may run, in seconds; None without an
448    /// active lease or when its rule does not allow extensions.
449    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            // Absent reads as requestable, so an older server keeps the resting badge.
479            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/// Request to lease a cipher.
497///
498/// Supply [`duration_seconds`](Self::duration_seconds) for the automatic path, or
499/// [`start`](Self::start)/[`end`](Self::end) + [`reason`](Self::reason) for the human path.
500#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)]
501#[bitwarden_ffi::wasm_record]
502#[serde(rename_all = "camelCase")]
503pub struct AccessRequestCreateRequest {
504    /// How long the automatic path's lease should run, in seconds. None on the human path.
505    pub duration_seconds: Option<NonZeroU32>,
506    /// The start of the requested window (UTC). Required on the human path.
507    pub start: Option<DateTime<Utc>>,
508    /// The end of the requested window (UTC). Required on the human path.
509    pub end: Option<DateTime<Utc>>,
510    /// The justification recorded with the request. Required on the human path.
511    pub reason: Option<String>,
512}
513
514impl TryFrom<AccessRequestCreateRequest> for AccessRequestCreateRequestModel {
515    type Error = AccessRequestWindowError;
516
517    /// Validates the request's activation window on the way to the wire model, so no path to the
518    /// server skips it.
519    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        // A rule left at a 1h default but capped at 15m; pre-filling the default
808        // would hand the requester a duration submit refuses.
809        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            // A multi-day cap under the ceiling must survive unclamped.
820            (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        // Zero means "no cap" and a negative only comes from a malformed response; neither should
832        // collapse the picker.
833        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        // An active lease outranks the (also-populated) approved and pending requests.
932        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    /// Far future because `validate` rejects an already-ended window; only the rendering is
1096    /// under test here.
1097    #[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    /// Activation does not change the status, so only the minted lease separates "still to start"
1160    /// from "already running".
1161    #[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}