1use std::cmp::Reverse;
4
5use chrono::Utc;
6use uuid::Uuid;
7
8use crate::approval_delegation_store::ApprovalDelegationStore;
9use crate::entities::{ApprovalDelegation, DelegationFilter, NewApprovalDelegation, Page};
10use crate::error::StoreError;
11use crate::memory::InMemoryStore;
12use crate::store::StoreFuture;
13
14impl ApprovalDelegationStore for InMemoryStore {
15 fn create_delegation(&self, req: NewApprovalDelegation) -> StoreFuture<'_, ApprovalDelegation> {
16 Box::pin(async move {
17 let delegation = ApprovalDelegation {
18 id: Uuid::now_v7(),
19 from_user_id: req.from_user_id,
20 to_user_id: req.to_user_id,
21 valid_from: req.valid_from,
22 valid_until: req.valid_until,
23 workflow_filter: req.workflow_filter,
24 created_at: Utc::now(),
25 };
26 let mut state = self.state.write().await;
27 state
28 .approval_delegations
29 .insert(delegation.id, delegation.clone());
30 Ok(delegation)
31 })
32 }
33
34 fn find_delegation_by_id(&self, id: Uuid) -> StoreFuture<'_, Option<ApprovalDelegation>> {
35 Box::pin(async move {
36 let state = self.state.read().await;
37 Ok(state.approval_delegations.get(&id).cloned())
38 })
39 }
40
41 fn list_active_delegations(
42 &self,
43 filter: DelegationFilter,
44 page: u32,
45 per_page: u32,
46 ) -> StoreFuture<'_, Page<ApprovalDelegation>> {
47 Box::pin(async move {
48 let now = Utc::now();
49 let state = self.state.read().await;
50 let mut items: Vec<_> = state
51 .approval_delegations
52 .values()
53 .filter(|d| d.is_active_at(now))
54 .filter(|d| filter.from_user_id.is_none_or(|id| d.from_user_id == id))
55 .filter(|d| filter.to_user_id.is_none_or(|id| d.to_user_id == id))
56 .filter(|d| {
57 filter
58 .involving_user_id
59 .is_none_or(|id| d.from_user_id == id || d.to_user_id == id)
60 })
61 .cloned()
62 .collect();
63 items.sort_by_key(|d| Reverse(d.created_at));
64
65 let total = items.len() as u64;
66 let offset = (page.saturating_sub(1) as usize) * (per_page as usize);
67 let items = items
68 .into_iter()
69 .skip(offset)
70 .take(per_page as usize)
71 .collect();
72
73 Ok(Page {
74 items,
75 total,
76 page,
77 per_page,
78 })
79 })
80 }
81
82 fn find_active_delegation(
83 &self,
84 from_user_id: Uuid,
85 to_user_id: Uuid,
86 workflow_name: &str,
87 ) -> StoreFuture<'_, Option<ApprovalDelegation>> {
88 let workflow_name = workflow_name.to_string();
89 Box::pin(async move {
90 let now = Utc::now();
91 let state = self.state.read().await;
92 Ok(state
93 .approval_delegations
94 .values()
95 .filter(|d| d.from_user_id == from_user_id && d.to_user_id == to_user_id)
96 .filter(|d| d.is_active_at(now) && d.matches_workflow(&workflow_name))
97 .max_by_key(|d| d.created_at)
98 .cloned())
99 })
100 }
101
102 fn delete_delegation(&self, id: Uuid) -> StoreFuture<'_, ()> {
103 Box::pin(async move {
104 let mut state = self.state.write().await;
105 state
106 .approval_delegations
107 .remove(&id)
108 .ok_or(StoreError::DelegationNotFound(id))?;
109 Ok(())
110 })
111 }
112}
113
114#[cfg(test)]
115mod tests {
116 use std::time::Duration;
117
118 use chrono::TimeDelta;
119 use tokio::time::sleep;
120
121 use super::*;
122
123 fn new_delegation(from: Uuid, to: Uuid) -> NewApprovalDelegation {
124 let now = Utc::now();
125 NewApprovalDelegation {
126 from_user_id: from,
127 to_user_id: to,
128 valid_from: now - TimeDelta::hours(1),
129 valid_until: now + TimeDelta::hours(1),
130 workflow_filter: None,
131 }
132 }
133
134 #[tokio::test]
135 async fn create_and_find() {
136 let store = InMemoryStore::new();
137 let (alice, bob) = (Uuid::now_v7(), Uuid::now_v7());
138
139 let created = store
140 .create_delegation(NewApprovalDelegation {
141 workflow_filter: Some("deploy-*".to_string()),
142 ..new_delegation(alice, bob)
143 })
144 .await
145 .expect("create");
146
147 assert_eq!(created.from_user_id, alice);
148 assert_eq!(created.to_user_id, bob);
149 assert_eq!(created.workflow_filter.as_deref(), Some("deploy-*"));
150
151 let found = store
152 .find_delegation_by_id(created.id)
153 .await
154 .expect("find")
155 .expect("some");
156 assert_eq!(found.id, created.id);
157 }
158
159 #[tokio::test]
160 async fn list_skips_expired_and_future_rows() {
161 let store = InMemoryStore::new();
162 let (alice, bob) = (Uuid::now_v7(), Uuid::now_v7());
163 let now = Utc::now();
164
165 let active = store
166 .create_delegation(new_delegation(alice, bob))
167 .await
168 .expect("create active");
169 store
170 .create_delegation(NewApprovalDelegation {
171 valid_from: now - TimeDelta::days(2),
172 valid_until: now - TimeDelta::days(1),
173 ..new_delegation(alice, bob)
174 })
175 .await
176 .expect("create expired");
177 store
178 .create_delegation(NewApprovalDelegation {
179 valid_from: now + TimeDelta::days(1),
180 valid_until: now + TimeDelta::days(2),
181 ..new_delegation(alice, bob)
182 })
183 .await
184 .expect("create future");
185
186 let listed = store
187 .list_active_delegations(DelegationFilter::default(), 1, 100)
188 .await
189 .expect("list")
190 .items;
191 assert_eq!(listed.len(), 1);
192 assert_eq!(listed[0].id, active.id);
193 }
194
195 #[tokio::test]
196 async fn list_filters_by_delegate_and_by_delegator() {
197 let store = InMemoryStore::new();
198 let (alice, bob, carol) = (Uuid::now_v7(), Uuid::now_v7(), Uuid::now_v7());
199
200 let to_bob = store
201 .create_delegation(new_delegation(alice, bob))
202 .await
203 .expect("create");
204 let to_carol = store
205 .create_delegation(new_delegation(alice, carol))
206 .await
207 .expect("create");
208 let from_carol = store
209 .create_delegation(new_delegation(carol, bob))
210 .await
211 .expect("create");
212
213 let received_by_bob = store
214 .list_active_delegations(
215 DelegationFilter {
216 to_user_id: Some(bob),
217 ..DelegationFilter::default()
218 },
219 1,
220 100,
221 )
222 .await
223 .expect("list")
224 .items;
225 let ids: Vec<_> = received_by_bob.iter().map(|d| d.id).collect();
226 assert_eq!(received_by_bob.len(), 2);
227 assert!(ids.contains(&to_bob.id));
228 assert!(ids.contains(&from_carol.id));
229
230 let granted_by_alice = store
231 .list_active_delegations(
232 DelegationFilter {
233 from_user_id: Some(alice),
234 ..DelegationFilter::default()
235 },
236 1,
237 100,
238 )
239 .await
240 .expect("list")
241 .items;
242 let ids: Vec<_> = granted_by_alice.iter().map(|d| d.id).collect();
243 assert_eq!(granted_by_alice.len(), 2);
244 assert!(ids.contains(&to_bob.id));
245 assert!(ids.contains(&to_carol.id));
246 }
247
248 #[tokio::test]
249 async fn list_returns_the_newest_delegation_first() {
250 let store = InMemoryStore::new();
251 let (alice, bob, carol) = (Uuid::now_v7(), Uuid::now_v7(), Uuid::now_v7());
252
253 let first = store
254 .create_delegation(new_delegation(alice, bob))
255 .await
256 .expect("create");
257 sleep(Duration::from_millis(5)).await;
260 let second = store
261 .create_delegation(new_delegation(alice, carol))
262 .await
263 .expect("create");
264
265 let listed = store
266 .list_active_delegations(DelegationFilter::default(), 1, 100)
267 .await
268 .expect("list")
269 .items;
270 assert_eq!(listed[0].id, second.id);
271 assert_eq!(listed[1].id, first.id);
272 }
273
274 #[tokio::test]
275 async fn list_filters_by_involved_user_on_either_side() {
276 let store = InMemoryStore::new();
277 let (alice, bob, carol) = (Uuid::now_v7(), Uuid::now_v7(), Uuid::now_v7());
278
279 let granted = store
280 .create_delegation(new_delegation(alice, bob))
281 .await
282 .expect("create");
283 let received = store
284 .create_delegation(new_delegation(carol, alice))
285 .await
286 .expect("create");
287 store
288 .create_delegation(new_delegation(bob, carol))
289 .await
290 .expect("create");
291
292 let page = store
293 .list_active_delegations(
294 DelegationFilter {
295 involving_user_id: Some(alice),
296 ..DelegationFilter::default()
297 },
298 1,
299 100,
300 )
301 .await
302 .expect("list");
303 let ids: Vec<_> = page.items.iter().map(|d| d.id).collect();
304 assert_eq!(page.total, 2);
305 assert!(ids.contains(&granted.id));
306 assert!(ids.contains(&received.id));
307 }
308
309 #[tokio::test]
310 async fn list_paginates_and_reports_the_total() {
311 let store = InMemoryStore::new();
312 let alice = Uuid::now_v7();
313 for _ in 0..5 {
314 store
315 .create_delegation(new_delegation(alice, Uuid::now_v7()))
316 .await
317 .expect("create");
318 }
319
320 let first = store
321 .list_active_delegations(DelegationFilter::default(), 1, 2)
322 .await
323 .expect("list");
324 assert_eq!(first.items.len(), 2);
325 assert_eq!(first.total, 5);
326 assert_eq!(first.page, 1);
327 assert_eq!(first.per_page, 2);
328
329 let last = store
330 .list_active_delegations(DelegationFilter::default(), 3, 2)
331 .await
332 .expect("list");
333 assert_eq!(last.items.len(), 1);
334
335 let past_the_end = store
336 .list_active_delegations(DelegationFilter::default(), 4, 2)
337 .await
338 .expect("list");
339 assert!(past_the_end.items.is_empty());
340 }
341
342 #[tokio::test]
343 async fn find_active_delegation_matches_pair_and_workflow() {
344 let store = InMemoryStore::new();
345 let (alice, bob, carol) = (Uuid::now_v7(), Uuid::now_v7(), Uuid::now_v7());
346
347 let deploy = store
348 .create_delegation(NewApprovalDelegation {
349 workflow_filter: Some("deploy-*".to_string()),
350 ..new_delegation(alice, bob)
351 })
352 .await
353 .expect("create");
354
355 let found = store
356 .find_active_delegation(alice, bob, "deploy-api")
357 .await
358 .expect("find");
359 assert_eq!(found.map(|d| d.id), Some(deploy.id));
360
361 let other_workflow = store
362 .find_active_delegation(alice, bob, "billing")
363 .await
364 .expect("find");
365 assert!(other_workflow.is_none());
366
367 let reversed = store
368 .find_active_delegation(bob, alice, "deploy-api")
369 .await
370 .expect("find");
371 assert!(reversed.is_none());
372
373 let other_delegate = store
374 .find_active_delegation(alice, carol, "deploy-api")
375 .await
376 .expect("find");
377 assert!(other_delegate.is_none());
378 }
379
380 #[tokio::test]
381 async fn find_active_delegation_ignores_expired_rows() {
382 let store = InMemoryStore::new();
383 let (alice, bob) = (Uuid::now_v7(), Uuid::now_v7());
384 let now = Utc::now();
385
386 store
387 .create_delegation(NewApprovalDelegation {
388 valid_from: now - TimeDelta::days(2),
389 valid_until: now - TimeDelta::days(1),
390 ..new_delegation(alice, bob)
391 })
392 .await
393 .expect("create expired");
394
395 let found = store
396 .find_active_delegation(alice, bob, "deploy")
397 .await
398 .expect("find");
399 assert!(found.is_none());
400 }
401
402 #[tokio::test]
403 async fn delete_removes_the_row() {
404 let store = InMemoryStore::new();
405 let created = store
406 .create_delegation(new_delegation(Uuid::now_v7(), Uuid::now_v7()))
407 .await
408 .expect("create");
409
410 store.delete_delegation(created.id).await.expect("delete");
411
412 let found = store
413 .find_delegation_by_id(created.id)
414 .await
415 .expect("find delegation");
416 assert!(found.is_none());
417 }
418
419 #[tokio::test]
420 async fn delete_unknown_id_is_not_found() {
421 let store = InMemoryStore::new();
422 let err = store.delete_delegation(Uuid::now_v7()).await.unwrap_err();
423 assert!(matches!(err, StoreError::DelegationNotFound(_)));
424 }
425}