use std::collections::HashMap; use std::sync::{Arc, Mutex}; use async_trait::async_trait; use domain::ports::{Clock, IdGenerator, LocalPath, ManagedTempStore}; use domain::{ ManagedTempAllocation, ManagedTempError, ManagedTempGcReport, ManagedTempHandle, ManagedTempKind, ManagedTempLease, ManagedTempState, ProjectId, }; use uuid::Uuid; use application::{ AllocateManagedTemp, AllocateManagedTempInput, AppError, ResolveManagedTemp, ResolveManagedTempInput, MANAGED_TEMP_DEFAULT_TTL_MS, MANAGED_TEMP_PURPOSE_MAX_CHARS, }; struct FixedClock; impl Clock for FixedClock { fn now_millis(&self) -> i64 { 10_000 } } struct FixedIds; impl IdGenerator for FixedIds { fn new_uuid(&self) -> Uuid { Uuid::from_u128(0x1234) } } #[derive(Default)] struct FakeManagedTempStore { allocations: Mutex>, leases: Mutex>, } #[async_trait] impl ManagedTempStore for FakeManagedTempStore { async fn allocate( &self, allocation: ManagedTempAllocation, ) -> Result { let lease = ManagedTempLease { handle: allocation.handle.clone(), kind: allocation.kind, owner_project_id: allocation.owner_project_id, purpose: allocation.purpose.clone(), path: LocalPath::new(format!("/managed/{}", allocation.handle.as_str())), state: ManagedTempState::Active, created_at_ms: allocation.created_at_ms, last_access_at_ms: allocation.created_at_ms, expires_at_ms: allocation.expires_at_ms, released_at_ms: None, }; self.allocations.lock().unwrap().push(allocation); self.leases .lock() .unwrap() .insert(lease.handle.clone(), lease.clone()); Ok(lease) } async fn resolve( &self, handle: &ManagedTempHandle, _now_ms: u64, ) -> Result { self.leases .lock() .unwrap() .get(handle) .cloned() .ok_or(ManagedTempError::NotFound) } async fn release( &self, handle: &ManagedTempHandle, now_ms: u64, ) -> Result { let mut leases = self.leases.lock().unwrap(); let lease = leases.get_mut(handle).ok_or(ManagedTempError::NotFound)?; lease.state = ManagedTempState::Released; lease.released_at_ms = Some(now_ms); Ok(lease.clone()) } async fn gc(&self, _now_ms: u64) -> Result { Ok(ManagedTempGcReport { deleted: 0 }) } } #[tokio::test] async fn allocate_uses_generated_opaque_handle_and_default_ttl() { let store = Arc::new(FakeManagedTempStore::default()); let use_case = AllocateManagedTemp::new( Arc::clone(&store) as Arc, Arc::new(FixedIds), Arc::new(FixedClock), ); let owner = ProjectId::from_uuid(Uuid::from_u128(7)); let output = use_case .execute(AllocateManagedTempInput { owner_project_id: Some(owner), kind: ManagedTempKind::Directory, purpose: " shared scratch ".to_owned(), ttl_ms: None, }) .await .unwrap(); assert_eq!( output.lease.handle.as_str(), "00000000-0000-0000-0000-000000001234" ); assert_eq!( output.lease.path.as_str(), "/managed/00000000-0000-0000-0000-000000001234" ); let allocations = store.allocations.lock().unwrap(); assert_eq!(allocations.len(), 1); assert_eq!(allocations[0].owner_project_id, Some(owner)); assert_eq!(allocations[0].purpose, "shared scratch"); assert_eq!(allocations[0].created_at_ms, 10_000); assert_eq!( allocations[0].expires_at_ms, Some(10_000 + MANAGED_TEMP_DEFAULT_TTL_MS) ); } #[tokio::test] async fn allocate_rejects_empty_or_zero_ttl_and_bounds_purpose_metadata() { let store = Arc::new(FakeManagedTempStore::default()); let use_case = AllocateManagedTemp::new( Arc::clone(&store) as Arc, Arc::new(FixedIds), Arc::new(FixedClock), ); let empty = use_case .execute(AllocateManagedTempInput { owner_project_id: None, kind: ManagedTempKind::File, purpose: " ".to_owned(), ttl_ms: Some(1), }) .await .unwrap_err(); assert!(matches!(empty, AppError::Invalid(_))); let zero_ttl = use_case .execute(AllocateManagedTempInput { owner_project_id: None, kind: ManagedTempKind::File, purpose: "scratch".to_owned(), ttl_ms: Some(0), }) .await .unwrap_err(); assert!(matches!(zero_ttl, AppError::Invalid(_))); let overflow_ttl = use_case .execute(AllocateManagedTempInput { owner_project_id: None, kind: ManagedTempKind::File, purpose: "scratch".to_owned(), ttl_ms: Some(u64::MAX), }) .await .unwrap_err(); assert!(matches!(overflow_ttl, AppError::Invalid(_))); use_case .execute(AllocateManagedTempInput { owner_project_id: None, kind: ManagedTempKind::File, purpose: "x".repeat(MANAGED_TEMP_PURPOSE_MAX_CHARS + 10), ttl_ms: Some(5), }) .await .unwrap(); let allocations = store.allocations.lock().unwrap(); assert_eq!( allocations[0].purpose.chars().count(), MANAGED_TEMP_PURPOSE_MAX_CHARS ); } #[tokio::test] async fn resolve_rejects_path_like_handle_before_store_call() { let store = Arc::new(FakeManagedTempStore::default()); let use_case = ResolveManagedTemp::new( Arc::clone(&store) as Arc, Arc::new(FixedClock), ); let err = use_case .execute(ResolveManagedTempInput { handle: "../escape".to_owned(), }) .await .unwrap_err(); assert!(matches!(err, AppError::Invalid(_))); }