1use std::sync::Arc;
2
3use bitwarden_api_api::models::{AccessDecisionRequestModel, AccessRequestRevokeRequestModel};
4use bitwarden_core::{FromClient, client::ApiConfigurations};
5
6use super::{
7 error::ApprovalError,
8 models::{AccessDecisionRequest, AccessRequestRevokeRequest},
9};
10use crate::{AccessRequestId, access_requests::AccessRequestView};
11
12#[bitwarden_ffi::wasm_object]
15#[derive(FromClient)]
16pub struct ApprovalsClient {
17 pub(crate) api_configurations: Arc<ApiConfigurations>,
18}
19
20#[bitwarden_ffi::wasm_export]
21impl ApprovalsClient {
22 pub async fn list_inbox(&self) -> Result<Vec<AccessRequestView>, ApprovalError> {
25 let response = self
26 .api_configurations
27 .api_client
28 .access_requests_api()
29 .get_inbox()
30 .await?;
31
32 response
33 .data
34 .unwrap_or_default()
35 .into_iter()
36 .map(AccessRequestView::try_from)
37 .collect::<Result<Vec<_>, _>>()
38 .map_err(Into::into)
39 }
40
41 pub async fn list_history(&self) -> Result<Vec<AccessRequestView>, ApprovalError> {
43 let response = self
44 .api_configurations
45 .api_client
46 .access_requests_api()
47 .get_history()
48 .await?;
49
50 response
51 .data
52 .unwrap_or_default()
53 .into_iter()
54 .map(AccessRequestView::try_from)
55 .collect::<Result<Vec<_>, _>>()
56 .map_err(Into::into)
57 }
58
59 pub async fn decide(
64 &self,
65 id: AccessRequestId,
66 request: AccessDecisionRequest,
67 ) -> Result<AccessRequestView, ApprovalError> {
68 let model = AccessDecisionRequestModel::try_from(request)?;
69
70 let response = self
71 .api_configurations
72 .api_client
73 .access_requests_api()
74 .decide(id.into(), model)
75 .await?;
76
77 Ok(AccessRequestView::try_from(response)?)
78 }
79
80 pub async fn revoke(
83 &self,
84 id: AccessRequestId,
85 request: AccessRequestRevokeRequest,
86 ) -> Result<(), ApprovalError> {
87 self.api_configurations
88 .api_client
89 .access_requests_api()
90 .revoke(
91 id.into(),
92 Some(AccessRequestRevokeRequestModel::from(request)),
93 )
94 .await?;
95
96 Ok(())
97 }
98}
99
100#[cfg(test)]
101mod tests {
102 use bitwarden_api_api::{
103 apis::ApiClient,
104 models::{
105 AccessDecisionVerdict as ApiAccessDecisionVerdict, AccessRequestDetailsResponseModel,
106 AccessRequestDetailsResponseModelListResponseModel,
107 AccessRequestStatus as ApiAccessRequestStatus,
108 },
109 };
110 use uuid::uuid;
111
112 use super::*;
113 use crate::{AccessDecisionVerdict, AccessRequestStatus};
114
115 fn request_id() -> AccessRequestId {
116 AccessRequestId::new(uuid!("44444444-4444-4444-4444-444444444444"))
117 }
118
119 fn client(api_client: ApiClient) -> ApprovalsClient {
120 ApprovalsClient {
121 api_configurations: Arc::new(ApiConfigurations::from_api_client(api_client)),
122 }
123 }
124
125 fn sample_request() -> AccessRequestDetailsResponseModel {
126 AccessRequestDetailsResponseModel {
127 id: Some(request_id().into()),
128 cipher_id: Some(uuid!("55555555-5555-5555-5555-555555555555")),
129 collection_id: Some(uuid!("66666666-6666-6666-6666-666666666666")),
130 requester_id: Some(uuid!("88888888-8888-8888-8888-888888888888")),
131 status: Some(ApiAccessRequestStatus::Pending),
132 lease_not_before: Some("2025-01-01T00:00:00Z".to_string()),
133 lease_not_after: Some("2025-01-01T01:00:00Z".to_string()),
134 submitted_at: Some("2025-01-01T00:00:00Z".to_string()),
135 ..Default::default()
136 }
137 }
138
139 #[tokio::test]
140 async fn list_inbox_returns_views() {
141 let api_client = ApiClient::new_mocked(move |mock| {
142 mock.access_requests_api
143 .expect_get_inbox()
144 .returning(move || {
145 let mut list = AccessRequestDetailsResponseModelListResponseModel::new();
146 list.data = Some(vec![sample_request()]);
147 Ok(list)
148 })
149 .once();
150 });
151
152 let result = client(api_client).list_inbox().await.unwrap();
153
154 assert_eq!(result.len(), 1);
155 assert_eq!(result[0].id, request_id());
156 assert_eq!(result[0].status, AccessRequestStatus::Pending);
157 }
158
159 #[tokio::test]
160 async fn list_inbox_returns_empty_vec_when_server_sends_no_data() {
161 let api_client = ApiClient::new_mocked(move |mock| {
162 mock.access_requests_api
163 .expect_get_inbox()
164 .returning(move || Ok(AccessRequestDetailsResponseModelListResponseModel::new()))
165 .once();
166 });
167
168 let result = client(api_client).list_inbox().await.unwrap();
169
170 assert_eq!(result, Vec::new());
171 }
172
173 #[tokio::test]
174 async fn list_history_returns_views() {
175 let api_client = ApiClient::new_mocked(move |mock| {
176 mock.access_requests_api
177 .expect_get_history()
178 .returning(move || {
179 let mut list = AccessRequestDetailsResponseModelListResponseModel::new();
180 list.data = Some(vec![sample_request()]);
181 Ok(list)
182 })
183 .once();
184 });
185
186 let result = client(api_client).list_history().await.unwrap();
187
188 assert_eq!(result.len(), 1);
189 assert_eq!(result[0].id, request_id());
190 }
191
192 #[tokio::test]
193 async fn decide_returns_updated_view_and_sends_approve_verdict() {
194 let api_client = ApiClient::new_mocked(move |mock| {
195 mock.access_requests_api
196 .expect_decide()
197 .returning(move |_id, model| {
198 assert_eq!(model.verdict, ApiAccessDecisionVerdict::Approve);
199 assert_eq!(model.comment.as_deref(), Some("Looks fine"));
200
201 Ok(AccessRequestDetailsResponseModel {
202 status: Some(ApiAccessRequestStatus::Approved),
203 resolved_at: Some("2025-01-01T00:30:00Z".to_string()),
204 ..sample_request()
205 })
206 })
207 .once();
208 });
209
210 let request = AccessDecisionRequest {
211 verdict: AccessDecisionVerdict::Approve,
212 comment: Some("Looks fine".to_string()),
213 };
214
215 let result = client(api_client)
216 .decide(request_id(), request)
217 .await
218 .unwrap();
219
220 assert_eq!(result.id, request_id());
221 assert_eq!(result.status, AccessRequestStatus::Approved);
222 }
223
224 #[tokio::test]
225 async fn decide_sends_deny_verdict() {
226 let api_client = ApiClient::new_mocked(move |mock| {
227 mock.access_requests_api
228 .expect_decide()
229 .returning(move |_id, model| {
230 assert_eq!(model.verdict, ApiAccessDecisionVerdict::Deny);
231 assert_eq!(model.comment.as_deref(), Some("Not authorized"));
232
233 Ok(AccessRequestDetailsResponseModel {
234 status: Some(ApiAccessRequestStatus::Denied),
235 resolved_at: Some("2025-01-01T00:30:00Z".to_string()),
236 ..sample_request()
237 })
238 })
239 .once();
240 });
241
242 let request = AccessDecisionRequest {
243 verdict: AccessDecisionVerdict::Deny,
244 comment: Some("Not authorized".to_string()),
245 };
246
247 let result = client(api_client)
248 .decide(request_id(), request)
249 .await
250 .unwrap();
251
252 assert_eq!(result.status, AccessRequestStatus::Denied);
253 }
254
255 #[tokio::test]
256 async fn decide_rejects_unknown_verdict_without_calling_the_api() {
257 let api_client = ApiClient::new_mocked(|mock| {
258 mock.access_requests_api.expect_decide().never();
259 });
260
261 let request = AccessDecisionRequest {
262 verdict: AccessDecisionVerdict::Unknown,
263 comment: None,
264 };
265
266 let result = client(api_client).decide(request_id(), request).await;
267
268 assert!(matches!(result, Err(ApprovalError::UnsubmittableVerdict)));
269 }
270
271 #[tokio::test]
272 async fn decide_surfaces_api_error() {
273 let api_client = ApiClient::new_mocked(move |mock| {
274 mock.access_requests_api
275 .expect_decide()
276 .returning(move |_id, _model| {
277 Err(bitwarden_api_api::apis::Error::Response(
278 bitwarden_api_api::apis::ResponseContent {
279 status: reqwest::StatusCode::CONFLICT,
280 message: "Already decided".to_string(),
281 },
282 ))
283 })
284 .once();
285 });
286
287 let request = AccessDecisionRequest {
288 verdict: AccessDecisionVerdict::Approve,
289 comment: None,
290 };
291
292 let result = client(api_client).decide(request_id(), request).await;
293
294 assert!(matches!(result, Err(ApprovalError::Api(_))));
295 }
296
297 #[tokio::test]
298 async fn revoke_sends_the_reason() {
299 let api_client = ApiClient::new_mocked(move |mock| {
300 mock.access_requests_api
301 .expect_revoke()
302 .returning(move |id, body| {
303 assert_eq!(id, uuid::Uuid::from(request_id()));
304 assert_eq!(
305 body.and_then(|b| b.reason).as_deref(),
306 Some("Approved by mistake")
307 );
308 Ok(())
309 })
310 .once();
311 });
312
313 let request = AccessRequestRevokeRequest {
314 reason: "Approved by mistake".to_string(),
315 };
316
317 let result = client(api_client).revoke(request_id(), request).await;
318
319 assert!(result.is_ok());
320 }
321
322 #[tokio::test]
323 async fn revoke_surfaces_api_error() {
324 let api_client = ApiClient::new_mocked(move |mock| {
325 mock.access_requests_api
326 .expect_revoke()
327 .returning(move |_id, _body| {
328 Err(bitwarden_api_api::apis::Error::Response(
329 bitwarden_api_api::apis::ResponseContent {
330 status: reqwest::StatusCode::CONFLICT,
331 message: "This request has already been resolved.".to_string(),
332 },
333 ))
334 })
335 .once();
336 });
337
338 let request = AccessRequestRevokeRequest {
339 reason: "Approved by mistake".to_string(),
340 };
341
342 let result = client(api_client).revoke(request_id(), request).await;
343
344 assert!(matches!(result, Err(ApprovalError::Api(_))));
345 }
346}