koprogo_api/application/use_cases/
security_incident_use_cases.rs

1use crate::application::ports::security_incident_repository::{
2    SecurityIncidentFilters, SecurityIncidentRepository,
3};
4use crate::domain::entities::SecurityIncident;
5use std::sync::Arc;
6use uuid::Uuid;
7
8pub struct SecurityIncidentUseCases {
9    repository: Arc<dyn SecurityIncidentRepository>,
10}
11
12impl SecurityIncidentUseCases {
13    pub fn new(repository: Arc<dyn SecurityIncidentRepository>) -> Self {
14        Self { repository }
15    }
16
17    pub async fn create(
18        &self,
19        organization_id: Option<Uuid>,
20        reported_by: Uuid,
21        severity: String,
22        incident_type: String,
23        title: String,
24        description: String,
25        data_categories_affected: Vec<String>,
26        affected_subjects_count: Option<i32>,
27    ) -> Result<SecurityIncident, String> {
28        let incident = SecurityIncident::new(
29            organization_id,
30            reported_by,
31            severity,
32            incident_type,
33            title,
34            description,
35            data_categories_affected,
36            affected_subjects_count,
37        )?;
38        self.repository.create(&incident).await
39    }
40
41    pub async fn find_all(
42        &self,
43        organization_id: Option<Uuid>,
44        severity: Option<String>,
45        status: Option<String>,
46        page: i64,
47        per_page: i64,
48    ) -> Result<(Vec<SecurityIncident>, i64), String> {
49        let filters = SecurityIncidentFilters {
50            severity,
51            status,
52            page,
53            per_page,
54        };
55        self.repository.find_all(organization_id, filters).await
56    }
57
58    pub async fn find_by_id(
59        &self,
60        id: Uuid,
61        organization_id: Option<Uuid>,
62    ) -> Result<Option<SecurityIncident>, String> {
63        self.repository.find_by_id(id, organization_id).await
64    }
65
66    pub async fn report_to_apd(
67        &self,
68        id: Uuid,
69        organization_id: Option<Uuid>,
70        apd_reference_number: String,
71        investigation_notes: Option<String>,
72    ) -> Result<Option<SecurityIncident>, String> {
73        if apd_reference_number.is_empty() {
74            return Err("apd_reference_number is required".to_string());
75        }
76        // Check if already reported
77        match self.repository.find_by_id(id, organization_id).await? {
78            None => Ok(None),
79            Some(incident) if incident.notification_at.is_some() => {
80                Err("already_reported".to_string())
81            }
82            Some(_) => {
83                self.repository
84                    .report_to_apd(
85                        id,
86                        organization_id,
87                        apd_reference_number,
88                        investigation_notes,
89                    )
90                    .await
91            }
92        }
93    }
94
95    pub async fn find_overdue(
96        &self,
97        organization_id: Option<Uuid>,
98    ) -> Result<Vec<SecurityIncident>, String> {
99        self.repository.find_overdue(organization_id).await
100    }
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106    use crate::application::ports::security_incident_repository::SecurityIncidentFilters;
107    use async_trait::async_trait;
108
109    struct MockRepo {
110        should_fail: bool,
111    }
112
113    #[async_trait]
114    impl SecurityIncidentRepository for MockRepo {
115        async fn create(&self, incident: &SecurityIncident) -> Result<SecurityIncident, String> {
116            if self.should_fail {
117                return Err("db error".to_string());
118            }
119            Ok(incident.clone())
120        }
121
122        async fn find_by_id(
123            &self,
124            _id: Uuid,
125            _org: Option<Uuid>,
126        ) -> Result<Option<SecurityIncident>, String> {
127            Ok(None)
128        }
129
130        async fn find_all(
131            &self,
132            _org: Option<Uuid>,
133            _filters: SecurityIncidentFilters,
134        ) -> Result<(Vec<SecurityIncident>, i64), String> {
135            Ok((vec![], 0))
136        }
137
138        async fn report_to_apd(
139            &self,
140            _id: Uuid,
141            _org: Option<Uuid>,
142            _ref: String,
143            _notes: Option<String>,
144        ) -> Result<Option<SecurityIncident>, String> {
145            Ok(None)
146        }
147
148        async fn find_overdue(&self, _org: Option<Uuid>) -> Result<Vec<SecurityIncident>, String> {
149            Ok(vec![])
150        }
151    }
152
153    #[tokio::test]
154    async fn test_create_validates_domain() {
155        let uc = SecurityIncidentUseCases::new(Arc::new(MockRepo { should_fail: false }));
156        // Empty title rejected by domain entity
157        let result = uc
158            .create(
159                None,
160                Uuid::new_v4(),
161                "high".to_string(),
162                "breach".to_string(),
163                "".to_string(),
164                "desc".to_string(),
165                vec![],
166                None,
167            )
168            .await;
169        assert!(result.is_err());
170        assert!(result.unwrap_err().contains("title"));
171    }
172
173    #[tokio::test]
174    async fn test_create_invalid_severity_rejected() {
175        let uc = SecurityIncidentUseCases::new(Arc::new(MockRepo { should_fail: false }));
176        let result = uc
177            .create(
178                None,
179                Uuid::new_v4(),
180                "extreme".to_string(),
181                "breach".to_string(),
182                "title".to_string(),
183                "desc".to_string(),
184                vec![],
185                None,
186            )
187            .await;
188        assert!(result.is_err());
189    }
190
191    #[tokio::test]
192    async fn test_create_success() {
193        let uc = SecurityIncidentUseCases::new(Arc::new(MockRepo { should_fail: false }));
194        let result = uc
195            .create(
196                Some(Uuid::new_v4()),
197                Uuid::new_v4(),
198                "critical".to_string(),
199                "data_breach".to_string(),
200                "Production DB leaked".to_string(),
201                "Details here".to_string(),
202                vec!["email".to_string(), "address".to_string()],
203                Some(500),
204            )
205            .await;
206        assert!(result.is_ok());
207        let inc = result.unwrap();
208        assert_eq!(inc.status, "detected");
209        assert_eq!(inc.severity, "critical");
210    }
211
212    #[tokio::test]
213    async fn test_report_to_apd_empty_ref_rejected() {
214        let uc = SecurityIncidentUseCases::new(Arc::new(MockRepo { should_fail: false }));
215        let result = uc
216            .report_to_apd(Uuid::new_v4(), None, "".to_string(), None)
217            .await;
218        assert!(result.is_err());
219        assert!(result.unwrap_err().contains("required"));
220    }
221
222    #[tokio::test]
223    async fn test_find_all_empty() {
224        let uc = SecurityIncidentUseCases::new(Arc::new(MockRepo { should_fail: false }));
225        let (incidents, total) = uc.find_all(None, None, None, 1, 20).await.unwrap();
226        assert_eq!(total, 0);
227        assert!(incidents.is_empty());
228    }
229}