bitwarden_pam/approvals/
client.rs1use std::sync::Arc;
2
3use bitwarden_api_api::models::AccessDecisionRequestModel;
4use bitwarden_core::{FromClient, client::ApiConfigurations};
5#[cfg(feature = "wasm")]
6use wasm_bindgen::prelude::wasm_bindgen;
7
8use super::{error::ApprovalError, models::AccessDecisionRequest};
9use crate::{AccessRequestId, access_requests::AccessRequestView};
10
11#[cfg_attr(feature = "wasm", wasm_bindgen)]
14#[derive(FromClient)]
15pub struct ApprovalsClient {
16 pub(crate) api_configurations: Arc<ApiConfigurations>,
17}
18
19#[cfg_attr(feature = "wasm", wasm_bindgen)]
20impl ApprovalsClient {
21 pub async fn list_inbox(&self) -> Result<Vec<AccessRequestView>, ApprovalError> {
24 let response = self
25 .api_configurations
26 .api_client
27 .access_requests_api()
28 .get_inbox()
29 .await?;
30
31 response
32 .data
33 .unwrap_or_default()
34 .into_iter()
35 .map(AccessRequestView::try_from)
36 .collect::<Result<Vec<_>, _>>()
37 .map_err(Into::into)
38 }
39
40 pub async fn list_history(&self) -> Result<Vec<AccessRequestView>, ApprovalError> {
42 let response = self
43 .api_configurations
44 .api_client
45 .access_requests_api()
46 .get_history()
47 .await?;
48
49 response
50 .data
51 .unwrap_or_default()
52 .into_iter()
53 .map(AccessRequestView::try_from)
54 .collect::<Result<Vec<_>, _>>()
55 .map_err(Into::into)
56 }
57
58 pub async fn decide(
63 &self,
64 id: AccessRequestId,
65 request: AccessDecisionRequest,
66 ) -> Result<AccessRequestView, ApprovalError> {
67 let model = AccessDecisionRequestModel::try_from(request)?;
68
69 let response = self
70 .api_configurations
71 .api_client
72 .access_requests_api()
73 .decide(id.into(), model)
74 .await?;
75
76 Ok(AccessRequestView::try_from(response)?)
77 }
78}
79
80#[cfg(test)]
81mod tests {
82 use bitwarden_api_api::{
83 apis::ApiClient,
84 models::{
85 AccessDecisionVerdict as ApiAccessDecisionVerdict, AccessRequestDetailsResponseModel,
86 AccessRequestDetailsResponseModelListResponseModel,
87 AccessRequestStatus as ApiAccessRequestStatus,
88 },
89 };
90 use uuid::uuid;
91
92 use super::*;
93 use crate::{AccessDecisionVerdict, AccessRequestStatus};
94
95 fn request_id() -> AccessRequestId {
96 AccessRequestId::new(uuid!("44444444-4444-4444-4444-444444444444"))
97 }
98
99 fn client(api_client: ApiClient) -> ApprovalsClient {
100 ApprovalsClient {
101 api_configurations: Arc::new(ApiConfigurations::from_api_client(api_client)),
102 }
103 }
104
105 fn sample_request() -> AccessRequestDetailsResponseModel {
106 AccessRequestDetailsResponseModel {
107 id: Some(request_id().into()),
108 cipher_id: Some(uuid!("55555555-5555-5555-5555-555555555555")),
109 collection_id: Some(uuid!("66666666-6666-6666-6666-666666666666")),
110 requester_id: Some(uuid!("88888888-8888-8888-8888-888888888888")),
111 status: Some(ApiAccessRequestStatus::Pending),
112 lease_not_before: Some("2025-01-01T00:00:00Z".to_string()),
113 lease_not_after: Some("2025-01-01T01:00:00Z".to_string()),
114 submitted_at: Some("2025-01-01T00:00:00Z".to_string()),
115 ..Default::default()
116 }
117 }
118
119 #[tokio::test]
120 async fn list_inbox_returns_views() {
121 let api_client = ApiClient::new_mocked(move |mock| {
122 mock.access_requests_api
123 .expect_get_inbox()
124 .returning(move || {
125 let mut list = AccessRequestDetailsResponseModelListResponseModel::new();
126 list.data = Some(vec![sample_request()]);
127 Ok(list)
128 })
129 .once();
130 });
131
132 let result = client(api_client).list_inbox().await.unwrap();
133
134 assert_eq!(result.len(), 1);
135 assert_eq!(result[0].id, request_id());
136 assert_eq!(result[0].status, AccessRequestStatus::Pending);
137 }
138
139 #[tokio::test]
140 async fn list_inbox_returns_empty_vec_when_server_sends_no_data() {
141 let api_client = ApiClient::new_mocked(move |mock| {
142 mock.access_requests_api
143 .expect_get_inbox()
144 .returning(move || Ok(AccessRequestDetailsResponseModelListResponseModel::new()))
145 .once();
146 });
147
148 let result = client(api_client).list_inbox().await.unwrap();
149
150 assert_eq!(result, Vec::new());
151 }
152
153 #[tokio::test]
154 async fn list_history_returns_views() {
155 let api_client = ApiClient::new_mocked(move |mock| {
156 mock.access_requests_api
157 .expect_get_history()
158 .returning(move || {
159 let mut list = AccessRequestDetailsResponseModelListResponseModel::new();
160 list.data = Some(vec![sample_request()]);
161 Ok(list)
162 })
163 .once();
164 });
165
166 let result = client(api_client).list_history().await.unwrap();
167
168 assert_eq!(result.len(), 1);
169 assert_eq!(result[0].id, request_id());
170 }
171
172 #[tokio::test]
173 async fn decide_returns_updated_view_and_sends_approve_verdict() {
174 let api_client = ApiClient::new_mocked(move |mock| {
175 mock.access_requests_api
176 .expect_decide()
177 .returning(move |_id, model| {
178 assert_eq!(model.verdict, ApiAccessDecisionVerdict::Approve);
179 assert_eq!(model.comment.as_deref(), Some("Looks fine"));
180
181 Ok(AccessRequestDetailsResponseModel {
182 status: Some(ApiAccessRequestStatus::Approved),
183 resolved_at: Some("2025-01-01T00:30:00Z".to_string()),
184 ..sample_request()
185 })
186 })
187 .once();
188 });
189
190 let request = AccessDecisionRequest {
191 verdict: AccessDecisionVerdict::Approve,
192 comment: Some("Looks fine".to_string()),
193 };
194
195 let result = client(api_client)
196 .decide(request_id(), request)
197 .await
198 .unwrap();
199
200 assert_eq!(result.id, request_id());
201 assert_eq!(result.status, AccessRequestStatus::Approved);
202 }
203
204 #[tokio::test]
205 async fn decide_sends_deny_verdict() {
206 let api_client = ApiClient::new_mocked(move |mock| {
207 mock.access_requests_api
208 .expect_decide()
209 .returning(move |_id, model| {
210 assert_eq!(model.verdict, ApiAccessDecisionVerdict::Deny);
211 assert_eq!(model.comment.as_deref(), Some("Not authorized"));
212
213 Ok(AccessRequestDetailsResponseModel {
214 status: Some(ApiAccessRequestStatus::Denied),
215 resolved_at: Some("2025-01-01T00:30:00Z".to_string()),
216 ..sample_request()
217 })
218 })
219 .once();
220 });
221
222 let request = AccessDecisionRequest {
223 verdict: AccessDecisionVerdict::Deny,
224 comment: Some("Not authorized".to_string()),
225 };
226
227 let result = client(api_client)
228 .decide(request_id(), request)
229 .await
230 .unwrap();
231
232 assert_eq!(result.status, AccessRequestStatus::Denied);
233 }
234
235 #[tokio::test]
236 async fn decide_rejects_unknown_verdict_without_calling_the_api() {
237 let api_client = ApiClient::new_mocked(|mock| {
238 mock.access_requests_api.expect_decide().never();
239 });
240
241 let request = AccessDecisionRequest {
242 verdict: AccessDecisionVerdict::Unknown,
243 comment: None,
244 };
245
246 let result = client(api_client).decide(request_id(), request).await;
247
248 assert!(matches!(result, Err(ApprovalError::UnsubmittableVerdict)));
249 }
250
251 #[tokio::test]
252 async fn decide_surfaces_api_error() {
253 let api_client = ApiClient::new_mocked(move |mock| {
254 mock.access_requests_api
255 .expect_decide()
256 .returning(move |_id, _model| {
257 Err(bitwarden_api_api::apis::Error::Response(
258 bitwarden_api_api::apis::ResponseContent {
259 status: reqwest::StatusCode::CONFLICT,
260 message: "Already decided".to_string(),
261 },
262 ))
263 })
264 .once();
265 });
266
267 let request = AccessDecisionRequest {
268 verdict: AccessDecisionVerdict::Approve,
269 comment: None,
270 };
271
272 let result = client(api_client).decide(request_id(), request).await;
273
274 assert!(matches!(result, Err(ApprovalError::Api(_))));
275 }
276}