ironflow_engine/config/
approval.rs1use std::time::Duration;
4
5use ironflow_store::entities::Assignee;
6use serde::{Deserialize, Serialize};
7
8use super::{Approvers, EscalationPolicy};
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct ApprovalConfig {
43 message: String,
44 #[serde(default, skip_serializing_if = "Option::is_none")]
45 timeout_seconds: Option<u64>,
46 #[serde(default, skip_serializing_if = "Option::is_none")]
47 deadline_secs: Option<u64>,
48 #[serde(default, skip_serializing_if = "Option::is_none")]
49 on_timeout: Option<EscalationPolicy>,
50 #[serde(default, skip_serializing_if = "Option::is_none")]
51 assignee: Option<Assignee>,
52 #[serde(default, skip_serializing_if = "Option::is_none")]
53 approvers: Option<Approvers>,
54}
55
56impl ApprovalConfig {
57 pub fn new(message: &str) -> Self {
68 Self {
69 message: message.to_string(),
70 timeout_seconds: None,
71 deadline_secs: None,
72 on_timeout: None,
73 assignee: None,
74 approvers: None,
75 }
76 }
77
78 pub fn with_timeout_seconds(mut self, seconds: u64) -> Self {
100 self.timeout_seconds = Some(seconds);
101 self
102 }
103
104 pub fn with_deadline(self, deadline: Duration) -> Self {
123 self.with_deadline_secs(deadline.as_secs())
124 }
125
126 pub fn with_deadline_secs(mut self, secs: u64) -> Self {
142 assert!(secs > 0, "approval deadline must be greater than zero");
143 self.deadline_secs = Some(secs);
144 self
145 }
146
147 pub fn on_timeout(mut self, policy: EscalationPolicy) -> Self {
160 self.on_timeout = Some(policy);
161 self
162 }
163
164 pub fn assigned_to(mut self, assignee: Assignee) -> Self {
180 assert!(
181 !assignee.name().trim().is_empty(),
182 "approval assignee must not be empty"
183 );
184 self.assignee = Some(assignee);
185 self
186 }
187
188 pub fn requiring(mut self, approvers: Approvers) -> Self {
204 self.approvers = Some(approvers);
205 self
206 }
207
208 pub fn approvers(&self) -> Option<&Approvers> {
218 self.approvers.as_ref()
219 }
220
221 pub fn message(&self) -> &str {
223 &self.message
224 }
225
226 pub fn timeout_seconds(&self) -> Option<u64> {
228 self.timeout_seconds
229 }
230
231 pub fn deadline(&self) -> Option<Duration> {
243 self.deadline_secs.map(Duration::from_secs)
244 }
245
246 pub fn deadline_secs(&self) -> Option<u64> {
248 self.deadline_secs
249 }
250
251 pub fn on_timeout_policy(&self) -> Option<&EscalationPolicy> {
253 self.on_timeout.as_ref()
254 }
255
256 pub fn assignee(&self) -> Option<&Assignee> {
258 self.assignee.as_ref()
259 }
260
261 pub fn effective_deadline_secs(&self) -> Option<u64> {
282 self.deadline_secs.or(self.timeout_seconds)
283 }
284
285 pub fn effective_policy(&self) -> EscalationPolicy {
298 self.on_timeout
299 .clone()
300 .unwrap_or(EscalationPolicy::AutoReject)
301 }
302}
303
304#[cfg(test)]
305mod tests {
306 use serde_json::{from_str, from_value, json, to_string};
307
308 use super::*;
309 use crate::config::NotificationTarget;
310
311 #[test]
312 fn new_sets_message() {
313 let config = ApprovalConfig::new("Deploy?");
314 assert_eq!(config.message(), "Deploy?");
315 assert!(config.timeout_seconds().is_none());
316 assert!(config.deadline_secs().is_none());
317 assert!(config.on_timeout_policy().is_none());
318 assert!(config.assignee().is_none());
319 }
320
321 #[test]
322 fn with_timeout() {
323 let config = ApprovalConfig::new("Approve?").with_timeout_seconds(7200);
324 assert_eq!(config.timeout_seconds(), Some(7200));
325 }
326
327 #[test]
328 fn with_deadline_stores_whole_seconds() {
329 let config = ApprovalConfig::new("Approve?").with_deadline(Duration::from_millis(90_500));
330 assert_eq!(config.deadline_secs(), Some(90));
331 assert_eq!(config.deadline(), Some(Duration::from_secs(90)));
332 }
333
334 #[test]
335 fn on_timeout_stores_the_policy() {
336 let config = ApprovalConfig::new("Approve?").on_timeout(EscalationPolicy::AutoApprove);
337 assert_eq!(
338 config.on_timeout_policy(),
339 Some(&EscalationPolicy::AutoApprove)
340 );
341 }
342
343 #[test]
344 fn assigned_to_stores_the_assignee() {
345 let config =
346 ApprovalConfig::new("Approve?").assigned_to(Assignee::group("release-managers"));
347 assert_eq!(
348 config.assignee(),
349 Some(&Assignee::group("release-managers"))
350 );
351 }
352
353 #[test]
354 fn effective_deadline_prefers_the_explicit_deadline() {
355 let config = ApprovalConfig::new("Approve?")
356 .with_timeout_seconds(7200)
357 .with_deadline_secs(60);
358 assert_eq!(config.effective_deadline_secs(), Some(60));
359 }
360
361 #[test]
362 fn effective_deadline_falls_back_to_the_legacy_timeout() {
363 let config = ApprovalConfig::new("Approve?").with_timeout_seconds(7200);
364 assert_eq!(config.effective_deadline_secs(), Some(7200));
365 }
366
367 #[test]
368 fn effective_deadline_is_none_without_any_timer() {
369 assert_eq!(
370 ApprovalConfig::new("Approve?").effective_deadline_secs(),
371 None
372 );
373 }
374
375 #[test]
376 fn effective_policy_defaults_to_auto_reject() {
377 let config = ApprovalConfig::new("Approve?").with_deadline_secs(60);
378 assert_eq!(config.effective_policy(), EscalationPolicy::AutoReject);
379 }
380
381 #[test]
382 fn effective_policy_returns_the_configured_policy() {
383 let policy = EscalationPolicy::Chain(vec![
384 EscalationPolicy::Notify(vec![NotificationTarget::Webhook {
385 url: "https://example.com/sla".to_string(),
386 }]),
387 EscalationPolicy::AutoReject,
388 ]);
389 let config = ApprovalConfig::new("Approve?")
390 .with_deadline_secs(60)
391 .on_timeout(policy.clone());
392 assert_eq!(config.effective_policy(), policy);
393 }
394
395 #[test]
396 #[should_panic(expected = "approval deadline must be greater than zero")]
397 fn with_deadline_secs_rejects_zero() {
398 let _ = ApprovalConfig::new("Approve?").with_deadline_secs(0);
399 }
400
401 #[test]
402 #[should_panic(expected = "approval assignee must not be empty")]
403 fn assigned_to_rejects_blank() {
404 let _ = ApprovalConfig::new("Approve?").assigned_to(Assignee::group(" "));
405 }
406
407 #[test]
408 fn serde_roundtrip() {
409 let config = ApprovalConfig::new("Deploy to prod?")
410 .with_timeout_seconds(3600)
411 .with_deadline_secs(1800)
412 .on_timeout(EscalationPolicy::Escalate(Assignee::group("sre-oncall")))
413 .assigned_to(Assignee::group("release-managers"));
414
415 let json = serde_json::to_string(&config).expect("serialize");
416 let back: ApprovalConfig = serde_json::from_str(&json).expect("deserialize");
417
418 assert_eq!(back.message(), config.message());
419 assert_eq!(back.timeout_seconds(), config.timeout_seconds());
420 assert_eq!(back.deadline_secs(), config.deadline_secs());
421 assert_eq!(back.on_timeout_policy(), config.on_timeout_policy());
422 assert_eq!(back.assignee(), config.assignee());
423 }
424
425 #[test]
426 fn serde_minimal() {
427 let config = ApprovalConfig::new("Approve?");
428 let json = serde_json::to_string(&config).expect("serialize");
429 assert!(!json.contains("timeout_seconds"));
430 assert!(!json.contains("deadline_secs"));
431 assert!(!json.contains("on_timeout"));
432 assert!(!json.contains("assignee"));
433 assert!(!json.contains("approvers"));
434 }
435
436 #[test]
437 fn requiring_stores_the_approvers() {
438 let approvers = Approvers::at_least(2)
439 .from_groups(["finance"])
440 .because("amount > 10k");
441 let config = ApprovalConfig::new("Release the payment?").requiring(approvers.clone());
442 assert_eq!(config.approvers(), Some(&approvers));
443 }
444
445 #[test]
446 fn requiring_twice_keeps_the_last_approvers() {
447 let config = ApprovalConfig::new("Approve?")
448 .requiring(Approvers::at_least(3))
449 .requiring(Approvers::any());
450 assert_eq!(config.approvers(), Some(&Approvers::any()));
451 }
452
453 #[test]
454 fn serde_roundtrip_with_approvers() {
455 let config = ApprovalConfig::new("Release the payment?").requiring(
456 Approvers::at_least(3)
457 .from_groups(["finance", "board"])
458 .because("amount > 100k"),
459 );
460 let json = to_string(&config).expect("serialize");
461 let back: ApprovalConfig = from_str(&json).expect("deserialize");
462
463 assert_eq!(back.approvers(), config.approvers());
464 assert_eq!(to_string(&back).expect("serialize"), json);
465 }
466
467 #[test]
468 fn serde_accepts_a_config_written_before_approvers_existed() {
469 let raw = r#"{"message":"Approve?","assignee":"user:alice"}"#;
470 let config: ApprovalConfig = from_str(raw).expect("deserialize");
471
472 assert!(config.approvers().is_none());
473 assert_eq!(config.assignee(), Some(&Assignee::user("alice")));
474 }
475
476 #[test]
477 fn serde_ignores_the_rules_of_a_config_written_by_approval_rules() {
478 let config: ApprovalConfig = from_value(json!({
480 "message": "Approve?",
481 "rules": [{"condition": "payload.amount > 10000", "required_approvers": 2}],
482 }))
483 .expect("deserialize");
484
485 assert_eq!(config.message(), "Approve?");
486 assert!(config.approvers().is_none());
487 }
488
489 #[test]
490 fn serde_rejects_zero_approvers() {
491 let result = from_value::<ApprovalConfig>(json!({
492 "message": "Approve?",
493 "approvers": {"required_approvers": 0},
494 }));
495 assert!(result.is_err());
496 }
497
498 #[test]
499 fn serde_accepts_a_config_written_before_escalation_existed() {
500 let config: ApprovalConfig =
501 serde_json::from_str(r#"{"message":"Approve?","timeout_seconds":60}"#)
502 .expect("deserialize");
503
504 assert_eq!(config.effective_deadline_secs(), Some(60));
505 assert_eq!(config.effective_policy(), EscalationPolicy::AutoReject);
506 }
507}