1use crate::hooks::types::Hook;
4use crate::manifest::{Hooks, Project};
5use crate::{Error, Result};
6use chrono::{DateTime, Utc};
7use fs4::tokio::AsyncFileExt;
8use serde::{Deserialize, Serialize};
9use sha2::{Digest, Sha256};
10use std::collections::{BTreeMap, HashMap};
11use std::path::{Component, Path, PathBuf};
12use tokio::fs;
13use tokio::fs::OpenOptions;
14use tokio::io::{AsyncReadExt, AsyncWriteExt};
15use tracing::{debug, info, warn};
16
17#[derive(Debug, Clone)]
19pub struct ApprovalManager {
20 approval_file: PathBuf,
21 approvals: HashMap<String, ApprovalRecord>,
22}
23
24impl ApprovalManager {
25 pub fn new(approval_file: PathBuf) -> Self {
27 Self {
28 approval_file,
29 approvals: HashMap::new(),
30 }
31 }
32
33 pub fn default_approval_file() -> Result<PathBuf> {
42 if let Ok(approval_file) = std::env::var("CUENV_APPROVAL_FILE")
44 && !approval_file.is_empty()
45 {
46 return Ok(PathBuf::from(approval_file));
47 }
48
49 crate::paths::approvals_file()
50 }
51
52 pub fn with_default_file() -> Result<Self> {
54 Ok(Self::new(Self::default_approval_file()?))
55 }
56
57 pub fn get_approval(&self, directory: &str) -> Option<&ApprovalRecord> {
59 let path = PathBuf::from(directory);
60 let dir_key = compute_directory_key(&path);
61 self.approvals.get(&dir_key)
62 }
63
64 pub async fn load_approvals(&mut self) -> Result<()> {
66 if !self.approval_file.exists() {
67 debug!("No approval file found at {}", self.approval_file.display());
68 return Ok(());
69 }
70
71 let mut file = OpenOptions::new()
73 .read(true)
74 .open(&self.approval_file)
75 .await
76 .map_err(|e| Error::Io {
77 source: e,
78 path: Some(self.approval_file.clone().into_boxed_path()),
79 operation: "open".to_string(),
80 })?;
81
82 file.lock_shared().map_err(|e| {
84 Error::configuration(format!(
85 "Failed to acquire shared lock on approval file: {}",
86 e
87 ))
88 })?;
89
90 let mut contents = String::new();
91 file.read_to_string(&mut contents)
92 .await
93 .map_err(|e| Error::Io {
94 source: e,
95 path: Some(self.approval_file.clone().into_boxed_path()),
96 operation: "read_to_string".to_string(),
97 })?;
98
99 drop(file);
101
102 self.approvals = serde_json::from_str(&contents)
103 .map_err(|e| Error::configuration(format!("Failed to parse approval file: {e}")))?;
104
105 info!("Loaded {} approvals from file", self.approvals.len());
106 Ok(())
107 }
108
109 pub async fn save_approvals(&self) -> Result<()> {
111 let canonical_path = validate_and_canonicalize_path(&self.approval_file)?;
113
114 if let Some(parent) = canonical_path.parent()
116 && !parent.exists()
117 {
118 let parent_path = validate_directory_path(parent)?;
120 fs::create_dir_all(&parent_path)
121 .await
122 .map_err(|e| Error::Io {
123 source: e,
124 path: Some(parent_path.into()),
125 operation: "create_dir_all".to_string(),
126 })?;
127 }
128
129 let contents = serde_json::to_string_pretty(&self.approvals)
130 .map_err(|e| Error::configuration(format!("Failed to serialize approvals: {e}")))?;
131
132 let temp_path = canonical_path.with_extension("tmp");
134
135 let mut file = OpenOptions::new()
137 .write(true)
138 .create(true)
139 .truncate(true)
140 .open(&temp_path)
141 .await
142 .map_err(|e| Error::Io {
143 source: e,
144 path: Some(temp_path.clone().into_boxed_path()),
145 operation: "open".to_string(),
146 })?;
147
148 file.lock_exclusive().map_err(|e| {
150 Error::configuration(format!(
151 "Failed to acquire exclusive lock on temp file: {}",
152 e
153 ))
154 })?;
155
156 file.write_all(contents.as_bytes())
157 .await
158 .map_err(|e| Error::Io {
159 source: e,
160 path: Some(temp_path.clone().into_boxed_path()),
161 operation: "write_all".to_string(),
162 })?;
163
164 file.sync_all().await.map_err(|e| Error::Io {
165 source: e,
166 path: Some(temp_path.clone().into_boxed_path()),
167 operation: "sync_all".to_string(),
168 })?;
169
170 drop(file);
172
173 fs::rename(&temp_path, &canonical_path)
175 .await
176 .map_err(|e| Error::Io {
177 source: e,
178 path: Some(canonical_path.clone().into_boxed_path()),
179 operation: "rename".to_string(),
180 })?;
181
182 debug!("Saved {} approvals to file", self.approvals.len());
183 Ok(())
184 }
185
186 pub fn is_approved(&self, directory_path: &Path, config_hash: &str) -> Result<bool> {
188 let dir_key = compute_directory_key(directory_path);
189
190 if let Some(approval) = self.approvals.get(&dir_key)
191 && approval.config_hash == config_hash
192 {
193 if let Some(expires_at) = approval.expires_at
195 && Utc::now() > expires_at
196 {
197 warn!("Approval for {} has expired", directory_path.display());
198 return Ok(false);
199 }
200 return Ok(true);
201 }
202
203 Ok(false)
204 }
205
206 pub async fn approve_config(
208 &mut self,
209 directory_path: &Path,
210 config_hash: String,
211 note: Option<String>,
212 ) -> Result<()> {
213 let dir_key = compute_directory_key(directory_path);
214 let approval = ApprovalRecord {
215 directory_path: directory_path.to_path_buf(),
216 config_hash,
217 approved_at: Utc::now(),
218 expires_at: None, note,
220 };
221
222 self.approvals.insert(dir_key, approval);
223 self.save_approvals().await?;
224
225 info!(
226 "Approved configuration for directory: {}",
227 directory_path.display()
228 );
229 Ok(())
230 }
231
232 pub async fn revoke_approval(&mut self, directory_path: &Path) -> Result<bool> {
234 let dir_key = compute_directory_key(directory_path);
235
236 if self.approvals.remove(&dir_key).is_some() {
237 self.save_approvals().await?;
238 info!(
239 "Revoked approval for directory: {}",
240 directory_path.display()
241 );
242 Ok(true)
243 } else {
244 Ok(false)
245 }
246 }
247
248 pub fn list_approved(&self) -> Vec<&ApprovalRecord> {
250 self.approvals.values().collect()
251 }
252
253 pub async fn cleanup_expired(&mut self) -> Result<usize> {
255 let now = Utc::now();
256 let initial_count = self.approvals.len();
257
258 self.approvals.retain(|_, approval| {
259 if let Some(expires_at) = approval.expires_at {
260 expires_at > now
261 } else {
262 true }
264 });
265
266 let removed_count = initial_count - self.approvals.len();
267 if removed_count > 0 {
268 self.save_approvals().await?;
269 info!("Cleaned up {} expired approvals", removed_count);
270 }
271
272 Ok(removed_count)
273 }
274}
275
276#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
278pub struct ApprovalRecord {
279 pub directory_path: PathBuf,
281 pub config_hash: String,
283 pub approved_at: DateTime<Utc>,
285 pub expires_at: Option<DateTime<Utc>>,
287 pub note: Option<String>,
289}
290
291#[derive(Debug, Clone, PartialEq, Eq)]
293pub enum ApprovalStatus {
294 Approved,
296 RequiresApproval { current_hash: String },
298 NotApproved { current_hash: String },
300}
301
302#[derive(Debug, Serialize)]
303struct ApprovalHashInput {
304 #[serde(skip_serializing_if = "Option::is_none")]
305 hooks: Option<HooksForHash>,
306}
307
308#[derive(Debug, Serialize)]
309struct HooksForHash {
310 #[serde(skip_serializing_if = "Option::is_none", rename = "onEnter")]
311 on_enter: Option<BTreeMap<String, Hook>>,
312 #[serde(skip_serializing_if = "Option::is_none", rename = "onExit")]
313 on_exit: Option<BTreeMap<String, Hook>>,
314}
315
316impl HooksForHash {
317 fn from_hooks(hooks: &Hooks) -> Self {
318 Self {
319 on_enter: hooks.on_enter.as_ref().map(sorted_hooks_map),
320 on_exit: hooks.on_exit.as_ref().map(sorted_hooks_map),
321 }
322 }
323}
324
325fn sorted_hooks_map(map: &HashMap<String, Hook>) -> BTreeMap<String, Hook> {
326 map.iter()
327 .map(|(name, hook)| (name.clone(), hook.clone()))
328 .collect()
329}
330
331pub fn check_approval_status(
333 manager: &ApprovalManager,
334 directory_path: &Path,
335 config: &Project,
336) -> Result<ApprovalStatus> {
337 let current_hash = compute_approval_hash(config);
338
339 if manager.is_approved(directory_path, ¤t_hash)? {
340 Ok(ApprovalStatus::Approved)
341 } else {
342 let dir_key = compute_directory_key(directory_path);
344 if manager.approvals.contains_key(&dir_key) {
345 Ok(ApprovalStatus::RequiresApproval { current_hash })
346 } else {
347 Ok(ApprovalStatus::NotApproved { current_hash })
348 }
349 }
350}
351
352pub fn compute_approval_hash(config: &Project) -> String {
356 let mut hasher = Sha256::new();
357
358 let hooks_only = ApprovalHashInput {
360 hooks: config.hooks.as_ref().map(HooksForHash::from_hooks),
361 };
362 let canonical = serde_json::to_string(&hooks_only).unwrap_or_default();
363 hasher.update(canonical.as_bytes());
364
365 format!("{:x}", hasher.finalize())[..16].to_string()
366}
367
368pub fn compute_directory_key(path: &Path) -> String {
370 let canonical_path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
373
374 let mut hasher = Sha256::new();
375 hasher.update(canonical_path.to_string_lossy().as_bytes());
376 format!("{:x}", hasher.finalize())[..16].to_string()
377}
378
379fn validate_and_canonicalize_path(path: &Path) -> Result<PathBuf> {
381 for component in path.components() {
383 match component {
384 Component::Normal(_) | Component::RootDir | Component::CurDir => {}
385 Component::ParentDir => {
386 }
389 Component::Prefix(_) => {
390 }
392 }
393 }
394
395 if path.exists() {
397 std::fs::canonicalize(path)
398 .map_err(|e| Error::configuration(format!("Failed to canonicalize path: {}", e)))
399 } else {
400 if let Some(parent) = path.parent() {
402 if parent.exists() {
403 let canonical_parent = std::fs::canonicalize(parent).map_err(|e| {
404 Error::configuration(format!("Failed to canonicalize parent path: {}", e))
405 })?;
406 if let Some(file_name) = path.file_name() {
407 Ok(canonical_parent.join(file_name))
408 } else {
409 Err(Error::configuration("Invalid file path"))
410 }
411 } else {
412 validate_path_structure(path)?;
414 Ok(path.to_path_buf())
415 }
416 } else {
417 validate_path_structure(path)?;
418 Ok(path.to_path_buf())
419 }
420 }
421}
422
423fn validate_directory_path(path: &Path) -> Result<PathBuf> {
425 validate_path_structure(path)?;
427
428 Ok(path.to_path_buf())
430}
431
432fn validate_path_structure(path: &Path) -> Result<()> {
434 let path_str = path.to_string_lossy();
435
436 if path_str.contains('\0') {
438 return Err(Error::configuration("Path contains null bytes"));
439 }
440
441 let suspicious_patterns = [
443 "../../../", "..\\..\\..\\", "%2e%2e", "..;/", ];
448
449 for pattern in &suspicious_patterns {
450 if path_str.contains(pattern) {
451 return Err(Error::configuration(format!(
452 "Path contains suspicious pattern: {}",
453 pattern
454 )));
455 }
456 }
457
458 Ok(())
459}
460
461#[derive(Debug, Clone)]
463pub struct ConfigSummary {
464 pub has_hooks: bool,
465 pub hook_count: usize,
466 pub has_env_vars: bool,
467 pub env_var_count: usize,
468 pub has_tasks: bool,
469 pub task_count: usize,
470}
471
472impl ConfigSummary {
473 pub fn from_project(config: &Project) -> Self {
475 let mut summary = Self {
476 has_hooks: false,
477 hook_count: 0,
478 has_env_vars: false,
479 env_var_count: 0,
480 has_tasks: false,
481 task_count: 0,
482 };
483
484 if let Some(hooks) = &config.hooks {
485 let on_enter_count = hooks.on_enter.as_ref().map_or(0, |map| map.len());
486 let on_exit_count = hooks.on_exit.as_ref().map_or(0, |map| map.len());
487 summary.hook_count = on_enter_count + on_exit_count;
488 summary.has_hooks = summary.hook_count > 0;
489 }
490
491 if let Some(env) = &config.env {
492 summary.env_var_count = env.base.len();
493 if env.environment.is_some() {
494 summary.env_var_count += 1;
495 }
496 summary.has_env_vars = summary.env_var_count > 0;
497 }
498
499 summary.task_count = config.tasks.len();
500 summary.has_tasks = summary.task_count > 0;
501
502 summary
503 }
504
505 pub fn description(&self) -> String {
507 let mut parts = Vec::new();
508
509 if self.has_hooks {
510 if self.hook_count == 1 {
511 parts.push("1 hook".to_string());
512 } else {
513 parts.push(format!("{} hooks", self.hook_count));
514 }
515 }
516
517 if self.has_env_vars {
518 if self.env_var_count == 1 {
519 parts.push("1 environment variable".to_string());
520 } else {
521 parts.push(format!("{} environment variables", self.env_var_count));
522 }
523 }
524
525 if self.has_tasks {
526 if self.task_count == 1 {
527 parts.push("1 task".to_string());
528 } else {
529 parts.push(format!("{} tasks", self.task_count));
530 }
531 }
532
533 if parts.is_empty() {
534 "empty configuration".to_string()
535 } else {
536 parts.join(", ")
537 }
538 }
539}
540
541#[cfg(test)]
542mod tests {
543 use super::*;
544 use crate::environment::{Env, EnvValue};
545 use crate::manifest::{Hooks, Project};
546 use crate::tasks::{Task, TaskDefinition};
547 use std::collections::HashMap;
548 use tempfile::TempDir;
549
550 fn base_project() -> Project {
551 Project {
552 name: "test".to_string(),
553 ..Default::default()
554 }
555 }
556
557 fn make_hook(command: &str, args: &[&str]) -> Hook {
558 Hook {
559 order: 100,
560 propagate: false,
561 command: command.to_string(),
562 args: args.iter().map(|arg| arg.to_string()).collect(),
563 dir: None,
564 inputs: vec![],
565 source: None,
566 }
567 }
568
569 fn make_task(command: &str) -> TaskDefinition {
570 TaskDefinition::Single(Box::new(Task {
571 command: command.to_string(),
572 ..Default::default()
573 }))
574 }
575
576 #[tokio::test]
577 async fn test_approval_manager_operations() {
578 let temp_dir = TempDir::new().unwrap();
579 let approval_file = temp_dir.path().join("approvals.json");
580 let mut manager = ApprovalManager::new(approval_file);
581
582 let directory = Path::new("/test/directory");
583 let config_hash = "test_hash_123".to_string();
584
585 assert!(!manager.is_approved(directory, &config_hash).unwrap());
587
588 manager
590 .approve_config(
591 directory,
592 config_hash.clone(),
593 Some("Test approval".to_string()),
594 )
595 .await
596 .unwrap();
597
598 assert!(manager.is_approved(directory, &config_hash).unwrap());
600
601 assert!(!manager.is_approved(directory, "different_hash").unwrap());
603
604 let mut manager2 = ApprovalManager::new(manager.approval_file.clone());
606 manager2.load_approvals().await.unwrap();
607 assert!(manager2.is_approved(directory, &config_hash).unwrap());
608
609 let revoked = manager2.revoke_approval(directory).await.unwrap();
611 assert!(revoked);
612 assert!(!manager2.is_approved(directory, &config_hash).unwrap());
613 }
614
615 #[test]
616 fn test_approval_hash_only_includes_hooks() {
617 let mut hooks_map = HashMap::new();
619 hooks_map.insert("setup".to_string(), make_hook("echo", &["hello"]));
620 let hooks = Hooks {
621 on_enter: Some(hooks_map),
622 on_exit: None,
623 };
624
625 let mut config1 = base_project();
626 config1.env = Some(Env {
627 base: HashMap::from([("TEST".to_string(), EnvValue::String("value1".to_string()))]),
628 environment: None,
629 });
630 config1.hooks = Some(hooks.clone());
631
632 let mut config2 = base_project();
633 config2.env = Some(Env {
634 base: HashMap::from([
635 ("TEST".to_string(), EnvValue::String("value2".to_string())),
636 ("NEW_VAR".to_string(), EnvValue::String("new".to_string())),
637 ]),
638 environment: None,
639 });
640 config2.hooks = Some(hooks);
641
642 let hash1 = compute_approval_hash(&config1);
643 let hash2 = compute_approval_hash(&config2);
644 assert_eq!(hash1, hash2, "Env changes should not affect approval hash");
645
646 let mut hooks_map = HashMap::new();
648 hooks_map.insert("setup".to_string(), make_hook("echo", &["world"]));
649
650 let mut config3 = base_project();
651 config3.env = config1.env.clone();
652 config3.hooks = Some(Hooks {
653 on_enter: Some(hooks_map),
654 on_exit: None,
655 });
656
657 let hash3 = compute_approval_hash(&config3);
658 assert_ne!(hash1, hash3, "Hook changes should affect approval hash");
659 }
660
661 #[test]
662 fn test_approval_hash_ignores_tasks() {
663 let mut hooks_map = HashMap::new();
664 hooks_map.insert("setup".to_string(), make_hook("echo", &[]));
665
666 let mut config1 = base_project();
667 config1.hooks = Some(Hooks {
668 on_enter: Some(hooks_map.clone()),
669 on_exit: None,
670 });
671 config1.tasks.insert("build".to_string(), make_task("npm"));
672
673 let mut config2 = base_project();
674 config2.hooks = Some(Hooks {
675 on_enter: Some(hooks_map),
676 on_exit: None,
677 });
678
679 let hash1 = compute_approval_hash(&config1);
680 let hash2 = compute_approval_hash(&config2);
681 assert_eq!(hash1, hash2, "Task changes should not affect approval hash");
682 }
683
684 #[test]
685 fn test_approval_hash_no_hooks() {
686 let mut config1 = base_project();
688 config1.env = Some(Env {
689 base: HashMap::from([("TEST".to_string(), EnvValue::String("value".to_string()))]),
690 environment: None,
691 });
692
693 let mut config2 = base_project();
694 config2.env = Some(Env {
695 base: HashMap::from([(
696 "OTHER".to_string(),
697 EnvValue::String("different".to_string()),
698 )]),
699 environment: None,
700 });
701 config2.tasks.insert("test".to_string(), make_task("echo"));
702
703 let hash1 = compute_approval_hash(&config1);
704 let hash2 = compute_approval_hash(&config2);
705 assert_eq!(hash1, hash2, "Configs without hooks should have same hash");
706 }
707
708 #[test]
709 fn test_config_summary() {
710 let mut on_enter = HashMap::new();
711 on_enter.insert("npm".to_string(), make_hook("npm", &["install"]));
712 on_enter.insert(
713 "docker".to_string(),
714 make_hook("docker-compose", &["up", "-d"]),
715 );
716
717 let mut on_exit = HashMap::new();
718 on_exit.insert("docker".to_string(), make_hook("docker-compose", &["down"]));
719
720 let mut config = base_project();
721 config.env = Some(Env {
722 base: HashMap::from([
723 (
724 "NODE_ENV".to_string(),
725 EnvValue::String("development".to_string()),
726 ),
727 (
728 "API_URL".to_string(),
729 EnvValue::String("http://localhost:3000".to_string()),
730 ),
731 ]),
732 environment: None,
733 });
734 config.hooks = Some(Hooks {
735 on_enter: Some(on_enter),
736 on_exit: Some(on_exit),
737 });
738 config.tasks.insert("build".to_string(), make_task("npm"));
739 config.tasks.insert("test".to_string(), make_task("npm"));
740
741 let summary = ConfigSummary::from_project(&config);
742 assert!(summary.has_hooks);
743 assert_eq!(summary.hook_count, 3);
744 assert!(summary.has_env_vars);
745 assert_eq!(summary.env_var_count, 2);
746 assert!(summary.has_tasks);
747 assert_eq!(summary.task_count, 2);
748
749 let description = summary.description();
750 assert!(description.contains("3 hooks"));
751 assert!(description.contains("2 environment variables"));
752 assert!(description.contains("2 tasks"));
753 }
754
755 #[test]
756 fn test_approval_status() {
757 let mut manager = ApprovalManager::new(PathBuf::from("/tmp/test"));
758 let directory = Path::new("/test/dir");
759 let mut config = base_project();
760 config.env = Some(Env {
761 base: HashMap::from([("TEST".to_string(), EnvValue::String("value".to_string()))]),
762 environment: None,
763 });
764
765 let status = check_approval_status(&manager, directory, &config).unwrap();
766 assert!(matches!(status, ApprovalStatus::NotApproved { .. }));
767
768 let different_hash = "different_hash".to_string();
770 manager.approvals.insert(
771 compute_directory_key(directory),
772 ApprovalRecord {
773 directory_path: directory.to_path_buf(),
774 config_hash: different_hash,
775 approved_at: Utc::now(),
776 expires_at: None,
777 note: None,
778 },
779 );
780
781 let status = check_approval_status(&manager, directory, &config).unwrap();
782 assert!(matches!(status, ApprovalStatus::RequiresApproval { .. }));
783
784 let correct_hash = compute_approval_hash(&config);
786 manager.approvals.insert(
787 compute_directory_key(directory),
788 ApprovalRecord {
789 directory_path: directory.to_path_buf(),
790 config_hash: correct_hash,
791 approved_at: Utc::now(),
792 expires_at: None,
793 note: None,
794 },
795 );
796
797 let status = check_approval_status(&manager, directory, &config).unwrap();
798 assert!(matches!(status, ApprovalStatus::Approved));
799 }
800
801 #[test]
802 fn test_path_validation() {
803 assert!(validate_path_structure(Path::new("/home/user/test")).is_ok());
805 assert!(validate_path_structure(Path::new("./relative/path")).is_ok());
806 assert!(validate_path_structure(Path::new("file.txt")).is_ok());
807
808 let path_with_null = PathBuf::from("/test\0/path");
810 assert!(validate_path_structure(&path_with_null).is_err());
811
812 assert!(validate_path_structure(Path::new("../../../etc/passwd")).is_err());
814 assert!(validate_path_structure(Path::new("..\\..\\..\\windows\\system32")).is_err());
815
816 assert!(validate_path_structure(Path::new("/test/%2e%2e/passwd")).is_err());
818
819 assert!(validate_path_structure(Path::new("..;/etc/passwd")).is_err());
821 }
822
823 #[test]
824 fn test_validate_and_canonicalize_path() {
825 let temp_dir = TempDir::new().unwrap();
826 let test_file = temp_dir.path().join("test.txt");
827 std::fs::write(&test_file, "test").unwrap();
828
829 let result = validate_and_canonicalize_path(&test_file).unwrap();
831 assert!(result.is_absolute());
832 assert!(result.exists());
833
834 let new_file = temp_dir.path().join("new_file.txt");
836 let result = validate_and_canonicalize_path(&new_file).unwrap();
837 assert!(result.ends_with("new_file.txt"));
838
839 let nested_new = temp_dir.path().join("subdir/newfile.txt");
841 let result = validate_and_canonicalize_path(&nested_new);
842 assert!(result.is_ok()); }
844
845 #[tokio::test]
846 async fn test_approval_file_corruption_recovery() {
847 let temp_dir = TempDir::new().unwrap();
848 let approval_file = temp_dir.path().join("approvals.json");
849
850 std::fs::write(&approval_file, "{invalid json}").unwrap();
852
853 let mut manager = ApprovalManager::new(approval_file.clone());
854
855 let result = manager.load_approvals().await;
857 assert!(
858 result.is_err(),
859 "Expected error when loading corrupted JSON"
860 );
861
862 assert_eq!(manager.approvals.len(), 0);
864
865 let directory = Path::new("/test/dir");
867 manager
868 .approve_config(directory, "test_hash".to_string(), None)
869 .await
870 .unwrap();
871
872 let mut manager2 = ApprovalManager::new(approval_file);
874 manager2.load_approvals().await.unwrap();
875 assert_eq!(manager2.approvals.len(), 1);
876 }
877
878 #[tokio::test]
879 async fn test_concurrent_approval_access() {
880 let temp_dir = TempDir::new().unwrap();
881 let approval_file = temp_dir.path().join("approvals.json");
882
883 let mut manager1 = ApprovalManager::new(approval_file.clone());
885 let mut manager2 = ApprovalManager::new(approval_file.clone());
886
887 manager1
889 .approve_config(
890 Path::new("/test/dir1"),
891 "hash1".to_string(),
892 Some("Manager 1".to_string()),
893 )
894 .await
895 .unwrap();
896
897 manager2
899 .approve_config(
900 Path::new("/test/dir2"),
901 "hash2".to_string(),
902 Some("Manager 2".to_string()),
903 )
904 .await
905 .unwrap();
906
907 let mut manager3 = ApprovalManager::new(approval_file);
909 manager3.load_approvals().await.unwrap();
910
911 assert!(!manager3.approvals.is_empty());
914 }
915
916 #[tokio::test]
917 async fn test_approval_expiration() {
918 let temp_dir = TempDir::new().unwrap();
919 let approval_file = temp_dir.path().join("approvals.json");
920 let mut manager = ApprovalManager::new(approval_file);
921
922 let directory = Path::new("/test/expire");
923 let config_hash = "expire_hash".to_string();
924
925 let expired_approval = ApprovalRecord {
927 directory_path: directory.to_path_buf(),
928 config_hash: config_hash.clone(),
929 approved_at: Utc::now() - chrono::Duration::hours(2),
930 expires_at: Some(Utc::now() - chrono::Duration::hours(1)),
931 note: Some("Expired approval".to_string()),
932 };
933
934 manager
935 .approvals
936 .insert(compute_directory_key(directory), expired_approval);
937
938 assert!(!manager.is_approved(directory, &config_hash).unwrap());
940
941 let removed = manager.cleanup_expired().await.unwrap();
943 assert_eq!(removed, 1);
944 assert_eq!(manager.approvals.len(), 0);
945 }
946}