//! `QueueServiceImpl` — wires `QueueRepo` underneath the //! `picloud_shared::QueueService` trait scripts see via the Rhai bridge. //! //! Mirrors the other stateful services: script-as-gate authz //! (`AppQueueEnqueue`, skipped when `cx.principal` is `None`), with the //! backend doing the actual write. No `ServiceEventEmitter` here — //! enqueue is observable via `queue:receive` triggers (commit 6). use std::sync::Arc; use async_trait::async_trait; use chrono::Utc; use picloud_shared::{EnqueueOpts, QueueError, QueueMessageId, QueueService, SdkCallCx}; use crate::authz::{self, AuthzRepo, Capability}; use crate::queue_repo::{NewQueueMessage, QueueRepo}; /// Production impl: authz gate → repo. Trivial wrapper. pub struct QueueServiceImpl { repo: Arc, authz: Arc, } impl QueueServiceImpl { #[must_use] pub fn new(repo: Arc, authz: Arc) -> Self { Self { repo, authz } } } #[async_trait] impl QueueService for QueueServiceImpl { async fn enqueue( &self, cx: &SdkCallCx, queue_name: &str, payload: serde_json::Value, opts: EnqueueOpts, ) -> Result { if queue_name.is_empty() { return Err(QueueError::EmptyName); } let max_attempts = opts.max_attempts.unwrap_or(3); if !(1..=20).contains(&max_attempts) { return Err(QueueError::InvalidOpts( "queue::enqueue: max_attempts must be in [1, 20]".into(), )); } if let Some(delay) = opts.delay_ms { // 24h cap on delay; matches what a cron tick would do anyway. if !(0..=86_400_000).contains(&delay) { return Err(QueueError::InvalidOpts( "queue::enqueue: delay_ms must be in [0, 86_400_000]".into(), )); } } // Script-as-gate authz: anonymous public-HTTP scripts skip the // check (cx.principal is None); authenticated callers must hold // AppQueueEnqueue. if let Some(principal) = cx.principal.as_ref() { match authz::require( &*self.authz, principal, Capability::AppQueueEnqueue(cx.app_id), ) .await { Ok(()) => {} Err(authz::AuthzDenied::Denied) => return Err(QueueError::Forbidden), Err(authz::AuthzDenied::Repo(e)) => return Err(QueueError::Backend(e.to_string())), } } let deliver_after = opts .delay_ms .and_then(chrono::Duration::try_milliseconds) .map(|d| Utc::now() + d); // Forensic only — never used for authz at consume-time (the // queue:receive trigger runs as the registering principal per // design notes §4). let enqueued_by_principal = cx.principal.as_ref().map(|p| p.user_id); self.repo .enqueue(NewQueueMessage { app_id: cx.app_id, queue_name: queue_name.to_string(), payload, deliver_after, max_attempts, enqueued_by_principal, }) .await .map_err(|e| QueueError::Backend(e.to_string())) } async fn depth(&self, cx: &SdkCallCx, queue_name: &str) -> Result { if queue_name.is_empty() { return Err(QueueError::EmptyName); } self.repo .depth(cx.app_id, queue_name) .await .map_err(|e| QueueError::Backend(e.to_string())) } async fn depth_pending(&self, cx: &SdkCallCx, queue_name: &str) -> Result { if queue_name.is_empty() { return Err(QueueError::EmptyName); } self.repo .depth_pending(cx.app_id, queue_name) .await .map_err(|e| QueueError::Backend(e.to_string())) } } #[cfg(test)] mod tests { use super::*; use crate::authz::AuthzError; use picloud_shared::{AppId, AppRole, ExecutionId, RequestId, ScriptId}; struct AlwaysAllowAuthz; #[async_trait] impl AuthzRepo for AlwaysAllowAuthz { async fn membership( &self, _user_id: picloud_shared::UserId, _app_id: AppId, ) -> Result, AuthzError> { Ok(Some(AppRole::Editor)) } } struct CapturingRepo { last: tokio::sync::Mutex>, } #[async_trait] impl QueueRepo for CapturingRepo { async fn enqueue( &self, msg: NewQueueMessage, ) -> Result { let id = QueueMessageId::new(); *self.last.lock().await = Some(msg); Ok(id) } async fn claim( &self, _app_id: AppId, _queue_name: &str, ) -> Result, crate::queue_repo::QueueRepoError> { Ok(None) } async fn ack( &self, _message_id: QueueMessageId, _claim_token: uuid::Uuid, ) -> Result { Ok(true) } async fn nack( &self, _message_id: QueueMessageId, _claim_token: uuid::Uuid, _retry_delay: chrono::Duration, ) -> Result { Ok(true) } async fn reclaim_visibility_timeouts( &self, ) -> Result { Ok(0) } async fn depth( &self, _app_id: AppId, _queue_name: &str, ) -> Result { Ok(42) } async fn depth_pending( &self, _app_id: AppId, _queue_name: &str, ) -> Result { Ok(7) } async fn list_for_app( &self, _app_id: AppId, ) -> Result, crate::queue_repo::QueueRepoError> { Ok(vec![]) } async fn dead_letter( &self, _message_id: QueueMessageId, _claim_token: uuid::Uuid, _app_id: AppId, _queue_name: &str, _trigger_id: Option, _script_id: Option, _attempt: u32, _first_attempt_at: chrono::DateTime, _last_error: &str, ) -> Result { Ok(picloud_shared::DeadLetterId::new()) } } fn anon_cx() -> SdkCallCx { let exec = ExecutionId::new(); SdkCallCx { app_id: AppId::new(), script_id: ScriptId::new(), principal: None, execution_id: exec, request_id: RequestId::new(), trigger_depth: 0, root_execution_id: exec, is_dead_letter_handler: false, event: None, } } #[tokio::test] async fn empty_queue_name_rejects() { let repo = Arc::new(CapturingRepo { last: tokio::sync::Mutex::new(None), }); let svc = QueueServiceImpl::new(repo.clone(), Arc::new(AlwaysAllowAuthz)); let cx = anon_cx(); let err = svc .enqueue(&cx, "", serde_json::Value::Null, EnqueueOpts::default()) .await .unwrap_err(); assert!(matches!(err, QueueError::EmptyName)); } #[tokio::test] async fn invalid_max_attempts_rejects() { let repo = Arc::new(CapturingRepo { last: tokio::sync::Mutex::new(None), }); let svc = QueueServiceImpl::new(repo, Arc::new(AlwaysAllowAuthz)); let cx = anon_cx(); let err = svc .enqueue( &cx, "x", serde_json::Value::Null, EnqueueOpts { delay_ms: None, max_attempts: Some(0), }, ) .await .unwrap_err(); assert!(matches!(err, QueueError::InvalidOpts(_))); let err = svc .enqueue( &cx, "x", serde_json::Value::Null, EnqueueOpts { delay_ms: None, max_attempts: Some(21), }, ) .await .unwrap_err(); assert!(matches!(err, QueueError::InvalidOpts(_))); } #[tokio::test] async fn invalid_delay_rejects() { let repo = Arc::new(CapturingRepo { last: tokio::sync::Mutex::new(None), }); let svc = QueueServiceImpl::new(repo, Arc::new(AlwaysAllowAuthz)); let cx = anon_cx(); let err = svc .enqueue( &cx, "x", serde_json::Value::Null, EnqueueOpts { delay_ms: Some(-1), max_attempts: None, }, ) .await .unwrap_err(); assert!(matches!(err, QueueError::InvalidOpts(_))); } #[tokio::test] async fn anon_principal_skips_authz_and_writes() { let repo = Arc::new(CapturingRepo { last: tokio::sync::Mutex::new(None), }); let svc = QueueServiceImpl::new(repo.clone(), Arc::new(AlwaysAllowAuthz)); let cx = anon_cx(); let payload = serde_json::json!({ "x": 1 }); svc.enqueue(&cx, "jobs", payload.clone(), EnqueueOpts::default()) .await .unwrap(); let captured = repo.last.lock().await.clone().unwrap(); assert_eq!(captured.queue_name, "jobs"); assert_eq!(captured.payload, payload); assert_eq!(captured.max_attempts, 3); assert!(captured.deliver_after.is_none()); } struct DenyAllAuthz; #[async_trait] impl AuthzRepo for DenyAllAuthz { async fn membership( &self, _user_id: picloud_shared::UserId, _app_id: AppId, ) -> Result, AuthzError> { Ok(None) } } #[tokio::test] async fn authed_principal_denied_returns_forbidden_variant() { use picloud_shared::{InstanceRole, Principal, UserId}; let repo = Arc::new(CapturingRepo { last: tokio::sync::Mutex::new(None), }); let svc = QueueServiceImpl::new(repo, Arc::new(DenyAllAuthz)); let exec = ExecutionId::new(); let cx = SdkCallCx { app_id: AppId::new(), script_id: ScriptId::new(), principal: Some(Principal { user_id: UserId::new(), instance_role: InstanceRole::Member, scopes: None, app_binding: None, }), execution_id: exec, request_id: RequestId::new(), trigger_depth: 0, root_execution_id: exec, is_dead_letter_handler: false, event: None, }; let err = svc .enqueue(&cx, "x", serde_json::Value::Null, EnqueueOpts::default()) .await .unwrap_err(); assert!(matches!(err, QueueError::Forbidden)); } #[tokio::test] async fn depth_passes_through() { let repo = Arc::new(CapturingRepo { last: tokio::sync::Mutex::new(None), }); let svc = QueueServiceImpl::new(repo, Arc::new(AlwaysAllowAuthz)); let cx = anon_cx(); assert_eq!(svc.depth(&cx, "any").await.unwrap(), 42); assert_eq!(svc.depth_pending(&cx, "any").await.unwrap(), 7); } }