Skip to main content

ironflow_store/memory/
approval_delegation_store.rs

1//! In-memory [`ApprovalDelegationStore`] implementation.
2
3use 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        // `created_at` is stamped by the store, so the two rows need a real gap
258        // for the ordering assertion to mean anything.
259        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}