//! `/api/v1/admin/apps/{id}/triggers/*` — trigger CRUD admin endpoints. //! //! Per design notes §2, two kinds ship in v1.1.1: `kv` (with //! collection_glob + ops) and `dead_letter` (with optional source / //! trigger_id / script_id filters). Separate endpoints per kind keep //! validation clean. //! //! Every endpoint is guarded by `Capability::AppManageTriggers(app_id)` //! evaluated after the resource lookup so the capability binds to the //! resource's actual `app_id` (mirrors `apps_api`). use std::sync::Arc; use axum::extract::{Path, State}; use axum::http::StatusCode; use axum::response::{IntoResponse, Json, Response}; use axum::routing::{delete, get, post}; use axum::{Extension, Router}; use picloud_shared::{AppId, DocsEventOp, KvEventOp, Principal, ScriptId, ScriptKind, TriggerId}; use serde::{Deserialize, Serialize}; use serde_json::json; use crate::app_repo::AppRepository; use crate::authz::{require, AuthzDenied, AuthzError, AuthzRepo, Capability}; use crate::repo::{ScriptRepository, ScriptRepositoryError}; use crate::trigger_config::{BackoffShape, TriggerConfig}; use crate::trigger_repo::{ CreateCronTrigger, CreateDeadLetterTrigger, CreateDocsTrigger, CreateKvTrigger, Trigger, TriggerDispatchMode, TriggerRepo, TriggerRepoError, }; #[derive(Clone)] pub struct TriggersState { pub triggers: Arc, pub apps: Arc, pub authz: Arc, /// v1.1.3: trigger creation must verify the target script (1) exists, /// (2) belongs to this app, and (3) is `kind = endpoint` — modules /// cannot be invoked. The script-load lives in the handler, so the /// state needs a repo handle. pub scripts: Arc, /// Defaults applied to created triggers when the request omits /// retry settings. Kept on the state struct so tests can swap /// in a stricter / looser config without env tinkering. pub config: TriggerConfig, } pub fn triggers_router(state: TriggersState) -> Router { Router::new() .route( "/apps/{app_id}/triggers", get(list_triggers).delete(noop_405), ) .route("/apps/{app_id}/triggers/kv", post(create_kv_trigger)) .route("/apps/{app_id}/triggers/docs", post(create_docs_trigger)) .route("/apps/{app_id}/triggers/cron", post(create_cron_trigger)) .route( "/apps/{app_id}/triggers/dead_letter", post(create_dl_trigger), ) .route( "/apps/{app_id}/triggers/{trigger_id}", delete(delete_trigger), ) .with_state(state) } async fn noop_405() -> StatusCode { StatusCode::METHOD_NOT_ALLOWED } // ---------------------------------------------------------------------------- // DTOs // ---------------------------------------------------------------------------- #[derive(Debug, Deserialize)] pub struct CreateKvTriggerRequest { pub script_id: ScriptId, pub collection_glob: String, /// Subset of `{insert, update, delete}`. Empty array means "any /// op" (the trigger fires on every mutation in matching /// collections). #[serde(default)] pub ops: Vec, #[serde(default = "default_dispatch")] pub dispatch_mode: TriggerDispatchMode, /// Overrides for the platform retry defaults. Omitted fields fall /// back to `TriggerConfig` (env-overridable) at write time. #[serde(default)] pub retry_max_attempts: Option, #[serde(default)] pub retry_backoff: Option, #[serde(default)] pub retry_base_ms: Option, } const fn default_dispatch() -> TriggerDispatchMode { TriggerDispatchMode::Async } /// v1.1.2. Same shape as `CreateKvTriggerRequest`; `ops` uses /// `DocsEventOp` (`create` / `update` / `delete`) instead of /// `KvEventOp` (`insert` / `update` / `delete`). #[derive(Debug, Deserialize)] pub struct CreateDocsTriggerRequest { pub script_id: ScriptId, pub collection_glob: String, #[serde(default)] pub ops: Vec, #[serde(default = "default_dispatch")] pub dispatch_mode: TriggerDispatchMode, #[serde(default)] pub retry_max_attempts: Option, #[serde(default)] pub retry_backoff: Option, #[serde(default)] pub retry_base_ms: Option, } /// v1.1.4 cron trigger. `schedule` is a 6-field cron expression (with /// seconds); `timezone` is an IANA name (defaults to UTC if omitted). #[derive(Debug, Deserialize)] pub struct CreateCronTriggerRequest { pub script_id: ScriptId, pub schedule: String, #[serde(default = "default_timezone")] pub timezone: String, #[serde(default = "default_dispatch")] pub dispatch_mode: TriggerDispatchMode, #[serde(default)] pub retry_max_attempts: Option, #[serde(default)] pub retry_backoff: Option, #[serde(default)] pub retry_base_ms: Option, } fn default_timezone() -> String { "UTC".to_string() } #[derive(Debug, Deserialize)] pub struct CreateDeadLetterTriggerRequest { pub script_id: ScriptId, #[serde(default)] pub source_filter: Option, #[serde(default)] pub trigger_id_filter: Option, #[serde(default)] pub script_id_filter: Option, } #[derive(Debug, Serialize)] pub struct TriggerListResponse { pub triggers: Vec, } // ---------------------------------------------------------------------------- // Handlers // ---------------------------------------------------------------------------- async fn list_triggers( State(s): State, Extension(principal): Extension, Path(app_id): Path, ) -> Result, TriggersApiError> { ensure_app_exists(&*s.apps, app_id).await?; require( s.authz.as_ref(), &principal, Capability::AppManageTriggers(app_id), ) .await?; let triggers = s.triggers.list_for_app(app_id).await?; Ok(Json(TriggerListResponse { triggers })) } /// v1.1.3: shared check used by every trigger-create handler. Returns /// `Ok(())` when the target script exists, lives in the same app, and /// is `kind = endpoint`. Wrong app surfaces as 422 (not 404) so we /// don't leak whether a script id exists in some other app. async fn validate_trigger_target( scripts: &dyn ScriptRepository, app_id: AppId, script_id: ScriptId, ) -> Result<(), TriggersApiError> { let script = scripts .get(script_id) .await .map_err(map_script_repo_err)? .ok_or_else(|| { TriggersApiError::Invalid(format!("script {script_id} not found in this app")) })?; if script.app_id != app_id { return Err(TriggersApiError::Invalid(format!( "script {script_id} does not belong to this app" ))); } if script.kind == ScriptKind::Module { return Err(TriggersApiError::Invalid(format!( "script {script_id} has kind=module; modules cannot be trigger targets — \ switch the script to kind=endpoint or attach this trigger to a different script" ))); } Ok(()) } fn map_script_repo_err(e: ScriptRepositoryError) -> TriggersApiError { // Surface as Invalid so the wire shape (422 with `error` field) // stays consistent with the other trigger-validation failures. // The underlying DB error is still logged through the manager's // tracing instrumentation. TriggersApiError::Invalid(format!("script lookup failed: {e}")) } async fn create_kv_trigger( State(s): State, Extension(principal): Extension, Path(app_id): Path, Json(input): Json, ) -> Result<(StatusCode, Json), TriggersApiError> { ensure_app_exists(&*s.apps, app_id).await?; require( s.authz.as_ref(), &principal, Capability::AppManageTriggers(app_id), ) .await?; if input.collection_glob.trim().is_empty() { return Err(TriggersApiError::Invalid( "collection_glob must not be empty".into(), )); } validate_trigger_target(&*s.scripts, app_id, input.script_id).await?; let req = CreateKvTrigger { script_id: input.script_id, collection_glob: input.collection_glob, ops: input.ops, dispatch_mode: input.dispatch_mode, retry_max_attempts: input .retry_max_attempts .unwrap_or(s.config.retry_max_attempts), retry_backoff: input.retry_backoff.unwrap_or(s.config.retry_backoff), retry_base_ms: input.retry_base_ms.unwrap_or(s.config.retry_base_ms), registered_by_principal: principal.user_id, }; let created = s.triggers.create_kv_trigger(app_id, req).await?; Ok((StatusCode::CREATED, Json(created))) } async fn create_docs_trigger( State(s): State, Extension(principal): Extension, Path(app_id): Path, Json(input): Json, ) -> Result<(StatusCode, Json), TriggersApiError> { ensure_app_exists(&*s.apps, app_id).await?; require( s.authz.as_ref(), &principal, Capability::AppManageTriggers(app_id), ) .await?; if input.collection_glob.trim().is_empty() { return Err(TriggersApiError::Invalid( "collection_glob must not be empty".into(), )); } validate_trigger_target(&*s.scripts, app_id, input.script_id).await?; let req = CreateDocsTrigger { script_id: input.script_id, collection_glob: input.collection_glob, ops: input.ops, dispatch_mode: input.dispatch_mode, retry_max_attempts: input .retry_max_attempts .unwrap_or(s.config.retry_max_attempts), retry_backoff: input.retry_backoff.unwrap_or(s.config.retry_backoff), retry_base_ms: input.retry_base_ms.unwrap_or(s.config.retry_base_ms), registered_by_principal: principal.user_id, }; let created = s.triggers.create_docs_trigger(app_id, req).await?; Ok((StatusCode::CREATED, Json(created))) } async fn create_cron_trigger( State(s): State, Extension(principal): Extension, Path(app_id): Path, Json(input): Json, ) -> Result<(StatusCode, Json), TriggersApiError> { ensure_app_exists(&*s.apps, app_id).await?; require( s.authz.as_ref(), &principal, Capability::AppManageTriggers(app_id), ) .await?; // Validate the schedule + timezone before touching the script repo // so a bad expression fails fast with a clear 422. crate::cron_scheduler::validate_schedule(&input.schedule) .map_err(|e| TriggersApiError::Invalid(format!("invalid cron schedule: {e}")))?; crate::cron_scheduler::validate_timezone(&input.timezone) .map_err(|e| TriggersApiError::Invalid(format!("invalid timezone: {e}")))?; // v1.1.3 check: target script exists, lives in this app, is an // endpoint (not a module). validate_trigger_target(&*s.scripts, app_id, input.script_id).await?; let req = CreateCronTrigger { script_id: input.script_id, schedule: input.schedule, timezone: input.timezone, dispatch_mode: input.dispatch_mode, retry_max_attempts: input .retry_max_attempts .unwrap_or(s.config.retry_max_attempts), retry_backoff: input.retry_backoff.unwrap_or(s.config.retry_backoff), retry_base_ms: input.retry_base_ms.unwrap_or(s.config.retry_base_ms), registered_by_principal: principal.user_id, }; let created = s.triggers.create_cron_trigger(app_id, req).await?; Ok((StatusCode::CREATED, Json(created))) } async fn create_dl_trigger( State(s): State, Extension(principal): Extension, Path(app_id): Path, Json(input): Json, ) -> Result<(StatusCode, Json), TriggersApiError> { ensure_app_exists(&*s.apps, app_id).await?; require( s.authz.as_ref(), &principal, Capability::AppManageTriggers(app_id), ) .await?; validate_trigger_target(&*s.scripts, app_id, input.script_id).await?; let req = CreateDeadLetterTrigger { script_id: input.script_id, source_filter: input.source_filter, trigger_id_filter: input.trigger_id_filter, script_id_filter: input.script_id_filter, registered_by_principal: principal.user_id, }; let created = s.triggers.create_dead_letter_trigger(app_id, req).await?; Ok((StatusCode::CREATED, Json(created))) } async fn delete_trigger( State(s): State, Extension(principal): Extension, Path((app_id, trigger_id)): Path<(AppId, TriggerId)>, ) -> Result { ensure_app_exists(&*s.apps, app_id).await?; // Load the trigger so we can confirm it belongs to the right // app; this prevents a caller from deleting a trigger by id alone // when their capability is bound to a different app. let trigger = s .triggers .get(trigger_id) .await? .ok_or(TriggersApiError::NotFound(trigger_id))?; if trigger.app_id != app_id { return Err(TriggersApiError::NotFound(trigger_id)); } require( s.authz.as_ref(), &principal, Capability::AppManageTriggers(app_id), ) .await?; if !s.triggers.delete(trigger_id).await? { return Err(TriggersApiError::NotFound(trigger_id)); } Ok(StatusCode::NO_CONTENT) } async fn ensure_app_exists( apps: &dyn AppRepository, app_id: AppId, ) -> Result<(), TriggersApiError> { apps.get_by_id(app_id) .await .map_err(|e| TriggersApiError::Backend(e.to_string()))? .ok_or_else(|| TriggersApiError::AppNotFound(app_id.to_string()))?; Ok(()) } // ---------------------------------------------------------------------------- // Errors // ---------------------------------------------------------------------------- #[derive(Debug, thiserror::Error)] pub enum TriggersApiError { #[error("app not found: {0}")] AppNotFound(String), #[error("trigger not found: {0}")] NotFound(TriggerId), #[error("invalid trigger: {0}")] Invalid(String), #[error("forbidden")] Forbidden, #[error("authorization repo error: {0}")] AuthzRepo(String), #[error("trigger backend: {0}")] Backend(String), } impl From for TriggersApiError { fn from(d: AuthzDenied) -> Self { match d { AuthzDenied::Denied => Self::Forbidden, AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()), } } } impl From for TriggersApiError { fn from(e: AuthzError) -> Self { Self::AuthzRepo(e.to_string()) } } impl From for TriggersApiError { fn from(e: TriggerRepoError) -> Self { match e { TriggerRepoError::NotFound(id) => Self::NotFound(id), TriggerRepoError::Invalid(s) => Self::Invalid(s), TriggerRepoError::Db(e) => Self::Backend(e.to_string()), } } } impl IntoResponse for TriggersApiError { fn into_response(self) -> Response { let (status, body) = match &self { Self::AppNotFound(_) | Self::NotFound(_) => { (StatusCode::NOT_FOUND, json!({ "error": self.to_string() })) } Self::Invalid(_) => ( StatusCode::UNPROCESSABLE_ENTITY, json!({ "error": self.to_string() }), ), Self::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })), Self::AuthzRepo(e) => { tracing::error!(error = %e, "triggers authz repo error"); ( StatusCode::INTERNAL_SERVER_ERROR, json!({ "error": "internal error" }), ) } Self::Backend(e) => { tracing::error!(error = %e, "triggers api backend error"); ( StatusCode::INTERNAL_SERVER_ERROR, json!({ "error": "internal error" }), ) } }; (status, Json(body)).into_response() } } #[cfg(test)] mod tests { //! In-memory tests for the trigger admin path. The Axum routing //! / extractor surface is exercised by integration tests (which //! need a real Postgres for the trigger repo); these tests cover //! the handlers' invariant logic — capability enforcement, app //! validation, default fallback for retry settings. use super::*; use crate::app_repo::{AppLookup, AppRepository}; use crate::trigger_repo::{ CreateCronTrigger, DeadLetterTriggerMatch, DocsTriggerMatch, KvTriggerMatch, Trigger, TriggerDetails, TriggerRepo, TriggerRepoError, }; use async_trait::async_trait; use chrono::Utc; use picloud_shared::{ AdminUserId, App, AppRole, DocsEventOp, KvEventOp, ScriptId, TriggerId, UserId, }; use std::collections::HashMap; use tokio::sync::Mutex; #[derive(Default)] struct InMemoryTriggerRepo { inner: Mutex>, } #[async_trait] impl TriggerRepo for InMemoryTriggerRepo { async fn create_kv_trigger( &self, app_id: AppId, req: CreateKvTrigger, ) -> Result { let now = Utc::now(); let id = TriggerId::new(); let trigger = Trigger { id, app_id, script_id: req.script_id, kind: crate::trigger_repo::TriggerKind::Kv, enabled: true, dispatch_mode: req.dispatch_mode, retry_max_attempts: req.retry_max_attempts, retry_backoff: req.retry_backoff, retry_base_ms: req.retry_base_ms, registered_by_principal: req.registered_by_principal, created_at: now, updated_at: now, details: TriggerDetails::Kv { collection_glob: req.collection_glob, ops: req.ops, }, }; self.inner.lock().await.insert(id, trigger.clone()); Ok(trigger) } async fn create_docs_trigger( &self, app_id: AppId, req: CreateDocsTrigger, ) -> Result { let now = Utc::now(); let id = TriggerId::new(); let trigger = Trigger { id, app_id, script_id: req.script_id, kind: crate::trigger_repo::TriggerKind::Docs, enabled: true, dispatch_mode: req.dispatch_mode, retry_max_attempts: req.retry_max_attempts, retry_backoff: req.retry_backoff, retry_base_ms: req.retry_base_ms, registered_by_principal: req.registered_by_principal, created_at: now, updated_at: now, details: TriggerDetails::Docs { collection_glob: req.collection_glob, ops: req.ops, }, }; self.inner.lock().await.insert(id, trigger.clone()); Ok(trigger) } async fn create_dead_letter_trigger( &self, app_id: AppId, req: CreateDeadLetterTrigger, ) -> Result { let now = Utc::now(); let id = TriggerId::new(); let trigger = Trigger { id, app_id, script_id: req.script_id, kind: crate::trigger_repo::TriggerKind::DeadLetter, enabled: true, dispatch_mode: TriggerDispatchMode::Async, retry_max_attempts: 1, retry_backoff: BackoffShape::Constant, retry_base_ms: 0, registered_by_principal: req.registered_by_principal, created_at: now, updated_at: now, details: TriggerDetails::DeadLetter { source_filter: req.source_filter, trigger_id_filter: req.trigger_id_filter, script_id_filter: req.script_id_filter, }, }; self.inner.lock().await.insert(id, trigger.clone()); Ok(trigger) } async fn create_cron_trigger( &self, app_id: AppId, req: CreateCronTrigger, ) -> Result { let now = Utc::now(); let id = TriggerId::new(); let trigger = Trigger { id, app_id, script_id: req.script_id, kind: crate::trigger_repo::TriggerKind::Cron, enabled: true, dispatch_mode: req.dispatch_mode, retry_max_attempts: req.retry_max_attempts, retry_backoff: req.retry_backoff, retry_base_ms: req.retry_base_ms, registered_by_principal: req.registered_by_principal, created_at: now, updated_at: now, details: TriggerDetails::Cron { schedule: req.schedule, timezone: req.timezone, last_fired_at: None, }, }; self.inner.lock().await.insert(id, trigger.clone()); Ok(trigger) } async fn list_for_app(&self, app_id: AppId) -> Result, TriggerRepoError> { Ok(self .inner .lock() .await .values() .filter(|t| t.app_id == app_id) .cloned() .collect()) } async fn get(&self, id: TriggerId) -> Result, TriggerRepoError> { Ok(self.inner.lock().await.get(&id).cloned()) } async fn delete(&self, id: TriggerId) -> Result { Ok(self.inner.lock().await.remove(&id).is_some()) } async fn list_matching_kv( &self, _app_id: AppId, _collection: &str, _op: KvEventOp, ) -> Result, TriggerRepoError> { Ok(vec![]) } async fn list_matching_docs( &self, _app_id: AppId, _collection: &str, _op: DocsEventOp, ) -> Result, TriggerRepoError> { Ok(vec![]) } async fn list_matching_dead_letter( &self, _app_id: AppId, _source: &str, _trigger_id: Option, _script_id: Option, ) -> Result, TriggerRepoError> { Ok(vec![]) } } struct InMemoryAppRepo { existing: Mutex>, } impl InMemoryAppRepo { fn with(app_id: AppId) -> Arc { let now = Utc::now(); let mut existing = HashMap::new(); existing.insert( app_id, App { id: app_id, slug: "test".into(), name: "test".into(), description: None, created_at: now, updated_at: now, }, ); Arc::new(Self { existing: Mutex::new(existing), }) } } #[async_trait] impl AppRepository for InMemoryAppRepo { async fn create( &self, _slug: &str, _name: &str, _description: Option<&str>, ) -> Result { unimplemented!() } async fn create_with_takeover( &self, _slug: &str, _name: &str, _description: Option<&str>, ) -> Result { unimplemented!() } async fn slug_in_history( &self, _slug: &str, ) -> Result, crate::repo::ScriptRepositoryError> { unimplemented!() } async fn list(&self) -> Result, crate::repo::ScriptRepositoryError> { unimplemented!() } async fn list_for_user( &self, _user_id: AdminUserId, ) -> Result, crate::repo::ScriptRepositoryError> { unimplemented!() } async fn get_by_id( &self, id: AppId, ) -> Result, crate::repo::ScriptRepositoryError> { Ok(self.existing.lock().await.get(&id).cloned()) } async fn get_by_slug( &self, _slug: &str, ) -> Result, crate::repo::ScriptRepositoryError> { unimplemented!() } async fn get_by_slug_or_history( &self, _slug: &str, ) -> Result, crate::repo::ScriptRepositoryError> { unimplemented!() } async fn update( &self, _id: AppId, _name: Option<&str>, _description: Option>, ) -> Result { unimplemented!() } async fn rename_slug( &self, _id: AppId, _new_slug: &str, _take_over_history: bool, ) -> Result { unimplemented!() } async fn delete(&self, _id: AppId) -> Result<(), crate::repo::ScriptRepositoryError> { unimplemented!() } async fn delete_cascade( &self, _id: AppId, ) -> Result<(), crate::repo::ScriptRepositoryError> { unimplemented!() } async fn count_scripts_in_app( &self, _id: AppId, ) -> Result { unimplemented!() } } /// Minimal `ScriptRepository` impl backing the trigger-create /// handler's `validate_trigger_target` check. Tests insert one or /// more scripts via [`InMemoryScriptRepo::with_endpoint`] / /// [`with_module`] and pass it into `TriggersState`. struct InMemoryScriptRepo { existing: Mutex>, } impl InMemoryScriptRepo { fn empty() -> Arc { Arc::new(Self { existing: Mutex::new(HashMap::new()), }) } fn with_endpoint(app_id: AppId, script_id: ScriptId) -> Arc { Self::with(app_id, script_id, ScriptKind::Endpoint) } fn with_module(app_id: AppId, script_id: ScriptId) -> Arc { Self::with(app_id, script_id, ScriptKind::Module) } fn with(app_id: AppId, script_id: ScriptId, kind: ScriptKind) -> Arc { let now = Utc::now(); let mut existing = HashMap::new(); existing.insert( script_id, picloud_shared::Script { id: script_id, app_id, name: format!( "{}_{}", match kind { ScriptKind::Endpoint => "endpoint", ScriptKind::Module => "module", }, script_id ), description: None, version: 1, source: String::new(), kind, timeout_seconds: 30, sandbox: picloud_shared::ScriptSandbox::default(), memory_limit_mb: 256, created_at: now, updated_at: now, }, ); Arc::new(Self { existing: Mutex::new(existing), }) } } #[async_trait] impl ScriptRepository for InMemoryScriptRepo { async fn get( &self, id: ScriptId, ) -> Result, crate::repo::ScriptRepositoryError> { Ok(self.existing.lock().await.get(&id).cloned()) } async fn list( &self, ) -> Result, crate::repo::ScriptRepositoryError> { Ok(self.existing.lock().await.values().cloned().collect()) } async fn list_for_app( &self, _app_id: AppId, ) -> Result, crate::repo::ScriptRepositoryError> { unimplemented!() } async fn list_for_user( &self, _user_id: AdminUserId, ) -> Result, crate::repo::ScriptRepositoryError> { unimplemented!() } async fn create( &self, _input: crate::repo::NewScript, ) -> Result { unimplemented!() } async fn update( &self, _id: ScriptId, _patch: crate::repo::ScriptPatch, ) -> Result { unimplemented!() } async fn delete(&self, _id: ScriptId) -> Result<(), crate::repo::ScriptRepositoryError> { unimplemented!() } async fn count_routes_for_script( &self, _script_id: ScriptId, ) -> Result { Ok(0) } async fn count_triggers_for_script( &self, _script_id: ScriptId, ) -> Result { Ok(0) } async fn list_imports( &self, _script_id: ScriptId, ) -> Result, crate::repo::ScriptRepositoryError> { Ok(vec![]) } } struct AlwaysAllowAuthzRepo; #[async_trait] impl AuthzRepo for AlwaysAllowAuthzRepo { async fn membership( &self, _user_id: UserId, _app_id: AppId, ) -> Result, AuthzError> { Ok(Some(AppRole::AppAdmin)) } } struct AlwaysDenyAuthzRepo; #[async_trait] impl AuthzRepo for AlwaysDenyAuthzRepo { async fn membership( &self, _user_id: UserId, _app_id: AppId, ) -> Result, AuthzError> { Ok(None) } } fn member_principal() -> Principal { Principal { user_id: AdminUserId::new(), instance_role: picloud_shared::InstanceRole::Member, scopes: None, app_binding: None, } } fn state_with(authz: Arc, app_id: AppId) -> TriggersState { TriggersState { triggers: Arc::new(InMemoryTriggerRepo::default()), apps: InMemoryAppRepo::with(app_id), authz, scripts: InMemoryScriptRepo::empty(), config: TriggerConfig::conservative(), } } /// Like [`state_with`] but pre-populates the script repo with a /// single endpoint script (so the v1.1.3 `validate_trigger_target` /// check passes and tests can exercise downstream behavior). fn state_with_endpoint( authz: Arc, app_id: AppId, script_id: ScriptId, ) -> TriggersState { TriggersState { triggers: Arc::new(InMemoryTriggerRepo::default()), apps: InMemoryAppRepo::with(app_id), authz, scripts: InMemoryScriptRepo::with_endpoint(app_id, script_id), config: TriggerConfig::conservative(), } } #[tokio::test] async fn unknown_app_returns_404() { let state = state_with(Arc::new(AlwaysAllowAuthzRepo), AppId::new()); let res = create_kv_trigger( State(state), Extension(member_principal()), Path(AppId::new()), // a different (non-existent) app Json(CreateKvTriggerRequest { script_id: ScriptId::new(), collection_glob: "*".into(), ops: vec![], dispatch_mode: TriggerDispatchMode::Async, retry_max_attempts: None, retry_backoff: None, retry_base_ms: None, }), ) .await; let err = res.expect_err("missing app should error"); assert!(matches!(err, TriggersApiError::AppNotFound(_))); } #[tokio::test] async fn member_without_role_is_forbidden() { let app_id = AppId::new(); let state = state_with(Arc::new(AlwaysDenyAuthzRepo), app_id); let res = create_kv_trigger( State(state), Extension(member_principal()), Path(app_id), Json(CreateKvTriggerRequest { script_id: ScriptId::new(), collection_glob: "*".into(), ops: vec![], dispatch_mode: TriggerDispatchMode::Async, retry_max_attempts: None, retry_backoff: None, retry_base_ms: None, }), ) .await; let err = res.expect_err("member without role should be forbidden"); assert!(matches!(err, TriggersApiError::Forbidden)); } #[tokio::test] async fn kv_trigger_uses_env_defaults_when_omitted() { let app_id = AppId::new(); let script_id = ScriptId::new(); let mut state = state_with_endpoint(Arc::new(AlwaysAllowAuthzRepo), app_id, script_id); // Tweak the config so we can detect that defaults were used. state.config.retry_max_attempts = 7; state.config.retry_base_ms = 12_345; let (status, Json(trigger)) = create_kv_trigger( State(state), Extension(member_principal()), Path(app_id), Json(CreateKvTriggerRequest { script_id, collection_glob: "widgets".into(), ops: vec![KvEventOp::Insert], dispatch_mode: TriggerDispatchMode::Async, retry_max_attempts: None, retry_backoff: None, retry_base_ms: None, }), ) .await .unwrap(); assert_eq!(status, StatusCode::CREATED); assert_eq!(trigger.retry_max_attempts, 7); assert_eq!(trigger.retry_base_ms, 12_345); } #[tokio::test] async fn empty_collection_glob_rejected() { let app_id = AppId::new(); let state = state_with(Arc::new(AlwaysAllowAuthzRepo), app_id); let res = create_kv_trigger( State(state), Extension(member_principal()), Path(app_id), Json(CreateKvTriggerRequest { script_id: ScriptId::new(), collection_glob: " ".into(), ops: vec![], dispatch_mode: TriggerDispatchMode::Async, retry_max_attempts: None, retry_backoff: None, retry_base_ms: None, }), ) .await; let err = res.expect_err("empty glob should reject"); assert!(matches!(err, TriggersApiError::Invalid(_))); } #[tokio::test] async fn docs_trigger_create_succeeds() { let app_id = AppId::new(); let script_id = ScriptId::new(); let state = state_with_endpoint(Arc::new(AlwaysAllowAuthzRepo), app_id, script_id); let (status, Json(trigger)) = create_docs_trigger( State(state), Extension(member_principal()), Path(app_id), Json(CreateDocsTriggerRequest { script_id, collection_glob: "users".into(), ops: vec![DocsEventOp::Create, DocsEventOp::Update], dispatch_mode: TriggerDispatchMode::Async, retry_max_attempts: None, retry_backoff: None, retry_base_ms: None, }), ) .await .unwrap(); assert_eq!(status, StatusCode::CREATED); assert!(matches!( trigger.kind, crate::trigger_repo::TriggerKind::Docs )); match trigger.details { TriggerDetails::Docs { collection_glob, ops, } => { assert_eq!(collection_glob, "users"); assert_eq!(ops, vec![DocsEventOp::Create, DocsEventOp::Update]); } other => panic!("expected Docs details, got {other:?}"), } } #[tokio::test] async fn docs_trigger_empty_glob_rejected() { let app_id = AppId::new(); let state = state_with(Arc::new(AlwaysAllowAuthzRepo), app_id); let res = create_docs_trigger( State(state), Extension(member_principal()), Path(app_id), Json(CreateDocsTriggerRequest { script_id: ScriptId::new(), collection_glob: " ".into(), ops: vec![], dispatch_mode: TriggerDispatchMode::Async, retry_max_attempts: None, retry_backoff: None, retry_base_ms: None, }), ) .await; let err = res.expect_err("empty docs glob should reject"); assert!(matches!(err, TriggersApiError::Invalid(_))); } #[tokio::test] async fn docs_trigger_member_without_role_is_forbidden() { let app_id = AppId::new(); let state = state_with(Arc::new(AlwaysDenyAuthzRepo), app_id); let res = create_docs_trigger( State(state), Extension(member_principal()), Path(app_id), Json(CreateDocsTriggerRequest { script_id: ScriptId::new(), collection_glob: "users".into(), ops: vec![], dispatch_mode: TriggerDispatchMode::Async, retry_max_attempts: None, retry_backoff: None, retry_base_ms: None, }), ) .await; let err = res.expect_err("member without role should be forbidden"); assert!(matches!(err, TriggersApiError::Forbidden)); } #[tokio::test] async fn delete_rejects_cross_app_trigger_id() { let app_a = AppId::new(); let app_b = AppId::new(); let state = state_with(Arc::new(AlwaysAllowAuthzRepo), app_a); // Inject the app_b row into the in-memory apps repo too so // the path-existence check succeeds against app_a. // Insert a trigger that belongs to app_a. let trigger = state .triggers .create_kv_trigger( app_a, CreateKvTrigger { script_id: ScriptId::new(), collection_glob: "*".into(), ops: vec![], dispatch_mode: TriggerDispatchMode::Async, retry_max_attempts: 3, retry_backoff: BackoffShape::Exponential, retry_base_ms: 1000, registered_by_principal: AdminUserId::new(), }, ) .await .unwrap(); let _ = app_b; // Attempt to delete via app_b's path — should 404. // First, give the in-memory app repo a record for app_b. // (Otherwise we'd 404 on app-existence before reaching the // cross-app check.) let state = TriggersState { apps: { let now = Utc::now(); let mut existing = HashMap::new(); existing.insert( app_a, App { id: app_a, slug: "a".into(), name: "a".into(), description: None, created_at: now, updated_at: now, }, ); existing.insert( app_b, App { id: app_b, slug: "b".into(), name: "b".into(), description: None, created_at: now, updated_at: now, }, ); Arc::new(InMemoryAppRepo { existing: Mutex::new(existing), }) }, ..state }; let res = delete_trigger( State(state), Extension(member_principal()), Path((app_b, trigger.id)), ) .await; let err = res.expect_err("cross-app delete should 404"); assert!(matches!(err, TriggersApiError::NotFound(_))); } // ---------------------------------------------------------------- // v1.1.3: kind + cross-app target validation on trigger create. // ---------------------------------------------------------------- #[tokio::test] async fn kv_trigger_rejects_module_target() { let app_id = AppId::new(); let script_id = ScriptId::new(); let state = TriggersState { triggers: Arc::new(InMemoryTriggerRepo::default()), apps: InMemoryAppRepo::with(app_id), authz: Arc::new(AlwaysAllowAuthzRepo), scripts: InMemoryScriptRepo::with_module(app_id, script_id), config: TriggerConfig::conservative(), }; let res = create_kv_trigger( State(state), Extension(member_principal()), Path(app_id), Json(CreateKvTriggerRequest { script_id, collection_glob: "widgets".into(), ops: vec![KvEventOp::Insert], dispatch_mode: TriggerDispatchMode::Async, retry_max_attempts: None, retry_backoff: None, retry_base_ms: None, }), ) .await; let err = res.expect_err("module script should be rejected as trigger target"); let msg = match err { TriggersApiError::Invalid(m) => m, other => panic!("expected Invalid, got {other:?}"), }; assert!( msg.to_lowercase().contains("module"), "expected error to mention 'module', got {msg}" ); } #[tokio::test] async fn docs_trigger_rejects_module_target() { let app_id = AppId::new(); let script_id = ScriptId::new(); let state = TriggersState { triggers: Arc::new(InMemoryTriggerRepo::default()), apps: InMemoryAppRepo::with(app_id), authz: Arc::new(AlwaysAllowAuthzRepo), scripts: InMemoryScriptRepo::with_module(app_id, script_id), config: TriggerConfig::conservative(), }; let res = create_docs_trigger( State(state), Extension(member_principal()), Path(app_id), Json(CreateDocsTriggerRequest { script_id, collection_glob: "users".into(), ops: vec![DocsEventOp::Create], dispatch_mode: TriggerDispatchMode::Async, retry_max_attempts: None, retry_backoff: None, retry_base_ms: None, }), ) .await; let err = res.expect_err("module script should be rejected as docs-trigger target"); let msg = match err { TriggersApiError::Invalid(m) => m, other => panic!("expected Invalid, got {other:?}"), }; assert!(msg.to_lowercase().contains("module")); } #[tokio::test] async fn dl_trigger_rejects_module_target() { let app_id = AppId::new(); let script_id = ScriptId::new(); let state = TriggersState { triggers: Arc::new(InMemoryTriggerRepo::default()), apps: InMemoryAppRepo::with(app_id), authz: Arc::new(AlwaysAllowAuthzRepo), scripts: InMemoryScriptRepo::with_module(app_id, script_id), config: TriggerConfig::conservative(), }; let res = create_dl_trigger( State(state), Extension(member_principal()), Path(app_id), Json(CreateDeadLetterTriggerRequest { script_id, source_filter: None, trigger_id_filter: None, script_id_filter: None, }), ) .await; let err = res.expect_err("module script should be rejected as dead-letter target"); let msg = match err { TriggersApiError::Invalid(m) => m, other => panic!("expected Invalid, got {other:?}"), }; assert!(msg.to_lowercase().contains("module")); } #[tokio::test] async fn kv_trigger_rejects_missing_script() { let app_id = AppId::new(); // Empty script repo — the requested script_id doesn't exist. let state = state_with(Arc::new(AlwaysAllowAuthzRepo), app_id); let res = create_kv_trigger( State(state), Extension(member_principal()), Path(app_id), Json(CreateKvTriggerRequest { script_id: ScriptId::new(), collection_glob: "widgets".into(), ops: vec![], dispatch_mode: TriggerDispatchMode::Async, retry_max_attempts: None, retry_backoff: None, retry_base_ms: None, }), ) .await; let err = res.expect_err("missing script should reject"); let msg = match err { TriggersApiError::Invalid(m) => m, other => panic!("expected Invalid, got {other:?}"), }; assert!(msg.to_lowercase().contains("not found")); } #[tokio::test] async fn kv_trigger_rejects_cross_app_script() { // Latent v1.1.1/v1.1.2 isolation gap closed by v1.1.3: a // member of app A could previously target a script in app B. let app_a = AppId::new(); let app_b = AppId::new(); let script_id = ScriptId::new(); // Pre-populate the script repo with the script living in app B, // but the trigger request targets app A. let scripts = InMemoryScriptRepo::with_endpoint(app_b, script_id); let state = TriggersState { triggers: Arc::new(InMemoryTriggerRepo::default()), apps: InMemoryAppRepo::with(app_a), authz: Arc::new(AlwaysAllowAuthzRepo), scripts, config: TriggerConfig::conservative(), }; let res = create_kv_trigger( State(state), Extension(member_principal()), Path(app_a), Json(CreateKvTriggerRequest { script_id, collection_glob: "widgets".into(), ops: vec![], dispatch_mode: TriggerDispatchMode::Async, retry_max_attempts: None, retry_backoff: None, retry_base_ms: None, }), ) .await; let err = res.expect_err("cross-app trigger target should reject"); let msg = match err { TriggersApiError::Invalid(m) => m, other => panic!("expected Invalid, got {other:?}"), }; assert!( msg.to_lowercase().contains("does not belong"), "expected cross-app rejection message, got {msg}" ); } // ---------------------------------------------------------------- // v1.1.4: cron trigger create. // ---------------------------------------------------------------- fn cron_req(script_id: ScriptId, schedule: &str, timezone: &str) -> CreateCronTriggerRequest { CreateCronTriggerRequest { script_id, schedule: schedule.into(), timezone: timezone.into(), dispatch_mode: TriggerDispatchMode::Async, retry_max_attempts: None, retry_backoff: None, retry_base_ms: None, } } #[tokio::test] async fn cron_trigger_create_succeeds() { let app_id = AppId::new(); let script_id = ScriptId::new(); let state = state_with_endpoint(Arc::new(AlwaysAllowAuthzRepo), app_id, script_id); let (status, Json(trigger)) = create_cron_trigger( State(state), Extension(member_principal()), Path(app_id), Json(cron_req( script_id, "0 0 9 * * MON-FRI", "America/Los_Angeles", )), ) .await .unwrap(); assert_eq!(status, StatusCode::CREATED); assert!(matches!( trigger.kind, crate::trigger_repo::TriggerKind::Cron )); match trigger.details { TriggerDetails::Cron { schedule, timezone, last_fired_at, } => { assert_eq!(schedule, "0 0 9 * * MON-FRI"); assert_eq!(timezone, "America/Los_Angeles"); assert!(last_fired_at.is_none()); } other => panic!("expected Cron details, got {other:?}"), } } #[tokio::test] async fn cron_trigger_rejects_invalid_schedule() { let app_id = AppId::new(); let script_id = ScriptId::new(); let state = state_with_endpoint(Arc::new(AlwaysAllowAuthzRepo), app_id, script_id); let res = create_cron_trigger( State(state), Extension(member_principal()), Path(app_id), // 5-field expression — not the 6-field format we accept. Json(cron_req(script_id, "* * * * *", "UTC")), ) .await; let err = res.expect_err("invalid schedule should reject"); let msg = match err { TriggersApiError::Invalid(m) => m, other => panic!("expected Invalid, got {other:?}"), }; assert!(msg.to_lowercase().contains("schedule"), "got {msg}"); } #[tokio::test] async fn cron_trigger_rejects_unknown_timezone() { let app_id = AppId::new(); let script_id = ScriptId::new(); let state = state_with_endpoint(Arc::new(AlwaysAllowAuthzRepo), app_id, script_id); let res = create_cron_trigger( State(state), Extension(member_principal()), Path(app_id), Json(cron_req(script_id, "0 * * * * *", "Mars/Phobos")), ) .await; let err = res.expect_err("unknown timezone should reject"); let msg = match err { TriggersApiError::Invalid(m) => m, other => panic!("expected Invalid, got {other:?}"), }; assert!(msg.to_lowercase().contains("timezone"), "got {msg}"); } #[tokio::test] async fn cron_trigger_rejects_module_target() { let app_id = AppId::new(); let script_id = ScriptId::new(); let state = TriggersState { triggers: Arc::new(InMemoryTriggerRepo::default()), apps: InMemoryAppRepo::with(app_id), authz: Arc::new(AlwaysAllowAuthzRepo), scripts: InMemoryScriptRepo::with_module(app_id, script_id), config: TriggerConfig::conservative(), }; let res = create_cron_trigger( State(state), Extension(member_principal()), Path(app_id), Json(cron_req(script_id, "0 * * * * *", "UTC")), ) .await; let err = res.expect_err("module script should be rejected as cron target"); let msg = match err { TriggersApiError::Invalid(m) => m, other => panic!("expected Invalid, got {other:?}"), }; assert!(msg.to_lowercase().contains("module"), "got {msg}"); } #[tokio::test] async fn cron_trigger_rejects_cross_app_script() { // v1.1.3 isolation gap regression: app A cannot target app B's // script via a cron trigger. let app_a = AppId::new(); let app_b = AppId::new(); let script_id = ScriptId::new(); let state = TriggersState { triggers: Arc::new(InMemoryTriggerRepo::default()), apps: InMemoryAppRepo::with(app_a), authz: Arc::new(AlwaysAllowAuthzRepo), scripts: InMemoryScriptRepo::with_endpoint(app_b, script_id), config: TriggerConfig::conservative(), }; let res = create_cron_trigger( State(state), Extension(member_principal()), Path(app_a), Json(cron_req(script_id, "0 * * * * *", "UTC")), ) .await; let err = res.expect_err("cross-app cron target should reject"); let msg = match err { TriggersApiError::Invalid(m) => m, other => panic!("expected Invalid, got {other:?}"), }; assert!(msg.to_lowercase().contains("does not belong"), "got {msg}"); } #[tokio::test] async fn cron_trigger_member_without_role_is_forbidden() { let app_id = AppId::new(); let state = state_with(Arc::new(AlwaysDenyAuthzRepo), app_id); let res = create_cron_trigger( State(state), Extension(member_principal()), Path(app_id), Json(cron_req(ScriptId::new(), "0 * * * * *", "UTC")), ) .await; let err = res.expect_err("member without role should be forbidden"); assert!(matches!(err, TriggersApiError::Forbidden)); } #[tokio::test] async fn kv_trigger_accepts_endpoint_target() { let app_id = AppId::new(); let script_id = ScriptId::new(); let state = state_with_endpoint(Arc::new(AlwaysAllowAuthzRepo), app_id, script_id); let (status, _) = create_kv_trigger( State(state), Extension(member_principal()), Path(app_id), Json(CreateKvTriggerRequest { script_id, collection_glob: "widgets".into(), ops: vec![KvEventOp::Insert], dispatch_mode: TriggerDispatchMode::Async, retry_max_attempts: None, retry_backoff: None, retry_base_ms: None, }), ) .await .expect("endpoint target should succeed"); assert_eq!(status, StatusCode::CREATED); } }