- Workspace 1.1.4 → 1.1.5; SDK 1.5 → 1.6; dashboard 0.10.0 → 0.11.0. - CHANGELOG v1.1.5 entry; CLAUDE.md runtime-config table gains PICLOUD_FILES_ROOT + PICLOUD_FILES_MAX_FILE_SIZE_BYTES. - schema_snapshot test: drop #[ignore] + #[sqlx::test]; run against DATABASE_URL when set, skip cleanly when absent. Re-blessed golden picks up files / files_trigger_details / pubsub_trigger_details, the two widened CHECKs, and the pubsub partial index. - First CI workflow (.github/workflows/ci.yml): postgres:15 service + fmt + clippy + cargo test --workspace; separate dashboard check job. - Add files/pubsub admin-trigger reject-coverage tests (module + cross-app + bad-pattern), mirroring the v1.1.3 regression set. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1997 lines
70 KiB
Rust
1997 lines
70 KiB
Rust
//! `/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, FilesEventOp, 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, CreateFilesTrigger,
|
|
CreateKvTrigger, CreatePubsubTrigger, Trigger, TriggerDispatchMode, TriggerRepo,
|
|
TriggerRepoError,
|
|
};
|
|
|
|
#[derive(Clone)]
|
|
pub struct TriggersState {
|
|
pub triggers: Arc<dyn TriggerRepo>,
|
|
pub apps: Arc<dyn AppRepository>,
|
|
pub authz: Arc<dyn AuthzRepo>,
|
|
/// 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<dyn ScriptRepository>,
|
|
/// 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/files", post(create_files_trigger))
|
|
.route(
|
|
"/apps/{app_id}/triggers/pubsub",
|
|
post(create_pubsub_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<KvEventOp>,
|
|
#[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<u32>,
|
|
#[serde(default)]
|
|
pub retry_backoff: Option<BackoffShape>,
|
|
#[serde(default)]
|
|
pub retry_base_ms: Option<u32>,
|
|
}
|
|
|
|
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<DocsEventOp>,
|
|
#[serde(default = "default_dispatch")]
|
|
pub dispatch_mode: TriggerDispatchMode,
|
|
#[serde(default)]
|
|
pub retry_max_attempts: Option<u32>,
|
|
#[serde(default)]
|
|
pub retry_backoff: Option<BackoffShape>,
|
|
#[serde(default)]
|
|
pub retry_base_ms: Option<u32>,
|
|
}
|
|
|
|
/// 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<u32>,
|
|
#[serde(default)]
|
|
pub retry_backoff: Option<BackoffShape>,
|
|
#[serde(default)]
|
|
pub retry_base_ms: Option<u32>,
|
|
}
|
|
|
|
fn default_timezone() -> String {
|
|
"UTC".to_string()
|
|
}
|
|
|
|
/// v1.1.5 files trigger. Mirrors `CreateKvTriggerRequest`; `ops` uses
|
|
/// `FilesEventOp` (`create` / `update` / `delete`).
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct CreateFilesTriggerRequest {
|
|
pub script_id: ScriptId,
|
|
pub collection_glob: String,
|
|
#[serde(default)]
|
|
pub ops: Vec<FilesEventOp>,
|
|
#[serde(default = "default_dispatch")]
|
|
pub dispatch_mode: TriggerDispatchMode,
|
|
#[serde(default)]
|
|
pub retry_max_attempts: Option<u32>,
|
|
#[serde(default)]
|
|
pub retry_backoff: Option<BackoffShape>,
|
|
#[serde(default)]
|
|
pub retry_base_ms: Option<u32>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct CreateDeadLetterTriggerRequest {
|
|
pub script_id: ScriptId,
|
|
#[serde(default)]
|
|
pub source_filter: Option<String>,
|
|
#[serde(default)]
|
|
pub trigger_id_filter: Option<TriggerId>,
|
|
#[serde(default)]
|
|
pub script_id_filter: Option<ScriptId>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct TriggerListResponse {
|
|
pub triggers: Vec<Trigger>,
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// Handlers
|
|
// ----------------------------------------------------------------------------
|
|
|
|
async fn list_triggers(
|
|
State(s): State<TriggersState>,
|
|
Extension(principal): Extension<Principal>,
|
|
Path(app_id): Path<AppId>,
|
|
) -> Result<Json<TriggerListResponse>, 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<TriggersState>,
|
|
Extension(principal): Extension<Principal>,
|
|
Path(app_id): Path<AppId>,
|
|
Json(input): Json<CreateKvTriggerRequest>,
|
|
) -> Result<(StatusCode, Json<Trigger>), 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<TriggersState>,
|
|
Extension(principal): Extension<Principal>,
|
|
Path(app_id): Path<AppId>,
|
|
Json(input): Json<CreateDocsTriggerRequest>,
|
|
) -> Result<(StatusCode, Json<Trigger>), 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<TriggersState>,
|
|
Extension(principal): Extension<Principal>,
|
|
Path(app_id): Path<AppId>,
|
|
Json(input): Json<CreateCronTriggerRequest>,
|
|
) -> Result<(StatusCode, Json<Trigger>), 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)))
|
|
}
|
|
|
|
/// v1.1.5 pubsub trigger. `topic_pattern` is validated to be exact /
|
|
/// `<prefix>.*` / `*`.
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct CreatePubsubTriggerRequest {
|
|
pub script_id: ScriptId,
|
|
pub topic_pattern: String,
|
|
#[serde(default = "default_dispatch")]
|
|
pub dispatch_mode: TriggerDispatchMode,
|
|
#[serde(default)]
|
|
pub retry_max_attempts: Option<u32>,
|
|
#[serde(default)]
|
|
pub retry_backoff: Option<BackoffShape>,
|
|
#[serde(default)]
|
|
pub retry_base_ms: Option<u32>,
|
|
}
|
|
|
|
async fn create_pubsub_trigger(
|
|
State(s): State<TriggersState>,
|
|
Extension(principal): Extension<Principal>,
|
|
Path(app_id): Path<AppId>,
|
|
Json(input): Json<CreatePubsubTriggerRequest>,
|
|
) -> Result<(StatusCode, Json<Trigger>), TriggersApiError> {
|
|
ensure_app_exists(&*s.apps, app_id).await?;
|
|
require(
|
|
s.authz.as_ref(),
|
|
&principal,
|
|
Capability::AppManageTriggers(app_id),
|
|
)
|
|
.await?;
|
|
|
|
// Validate the topic pattern before touching the script repo so a
|
|
// bad pattern fails fast with a clear 422.
|
|
picloud_shared::validate_topic_pattern(&input.topic_pattern)
|
|
.map_err(TriggersApiError::Invalid)?;
|
|
validate_trigger_target(&*s.scripts, app_id, input.script_id).await?;
|
|
|
|
let req = CreatePubsubTrigger {
|
|
script_id: input.script_id,
|
|
topic_pattern: input.topic_pattern,
|
|
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_pubsub_trigger(app_id, req).await?;
|
|
Ok((StatusCode::CREATED, Json(created)))
|
|
}
|
|
|
|
async fn create_files_trigger(
|
|
State(s): State<TriggersState>,
|
|
Extension(principal): Extension<Principal>,
|
|
Path(app_id): Path<AppId>,
|
|
Json(input): Json<CreateFilesTriggerRequest>,
|
|
) -> Result<(StatusCode, Json<Trigger>), 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 = CreateFilesTrigger {
|
|
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_files_trigger(app_id, req).await?;
|
|
Ok((StatusCode::CREATED, Json(created)))
|
|
}
|
|
|
|
async fn create_dl_trigger(
|
|
State(s): State<TriggersState>,
|
|
Extension(principal): Extension<Principal>,
|
|
Path(app_id): Path<AppId>,
|
|
Json(input): Json<CreateDeadLetterTriggerRequest>,
|
|
) -> Result<(StatusCode, Json<Trigger>), 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<TriggersState>,
|
|
Extension(principal): Extension<Principal>,
|
|
Path((app_id, trigger_id)): Path<(AppId, TriggerId)>,
|
|
) -> Result<StatusCode, TriggersApiError> {
|
|
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<AuthzDenied> for TriggersApiError {
|
|
fn from(d: AuthzDenied) -> Self {
|
|
match d {
|
|
AuthzDenied::Denied => Self::Forbidden,
|
|
AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<AuthzError> for TriggersApiError {
|
|
fn from(e: AuthzError) -> Self {
|
|
Self::AuthzRepo(e.to_string())
|
|
}
|
|
}
|
|
|
|
impl From<TriggerRepoError> 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, CreateFilesTrigger, CreatePubsubTrigger, DeadLetterTriggerMatch,
|
|
DocsTriggerMatch, FilesTriggerMatch, KvTriggerMatch, Trigger, TriggerDetails, TriggerRepo,
|
|
TriggerRepoError,
|
|
};
|
|
use async_trait::async_trait;
|
|
use chrono::Utc;
|
|
use picloud_shared::{
|
|
AdminUserId, App, AppRole, DocsEventOp, FilesEventOp, KvEventOp, ScriptId, TriggerId,
|
|
UserId,
|
|
};
|
|
use std::collections::HashMap;
|
|
use tokio::sync::Mutex;
|
|
|
|
#[derive(Default)]
|
|
struct InMemoryTriggerRepo {
|
|
inner: Mutex<HashMap<TriggerId, Trigger>>,
|
|
}
|
|
|
|
#[async_trait]
|
|
impl TriggerRepo for InMemoryTriggerRepo {
|
|
async fn create_kv_trigger(
|
|
&self,
|
|
app_id: AppId,
|
|
req: CreateKvTrigger,
|
|
) -> Result<Trigger, TriggerRepoError> {
|
|
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<Trigger, TriggerRepoError> {
|
|
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<Trigger, TriggerRepoError> {
|
|
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<Trigger, TriggerRepoError> {
|
|
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 create_files_trigger(
|
|
&self,
|
|
app_id: AppId,
|
|
req: CreateFilesTrigger,
|
|
) -> Result<Trigger, TriggerRepoError> {
|
|
let now = Utc::now();
|
|
let id = TriggerId::new();
|
|
let trigger = Trigger {
|
|
id,
|
|
app_id,
|
|
script_id: req.script_id,
|
|
kind: crate::trigger_repo::TriggerKind::Files,
|
|
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::Files {
|
|
collection_glob: req.collection_glob,
|
|
ops: req.ops,
|
|
},
|
|
};
|
|
self.inner.lock().await.insert(id, trigger.clone());
|
|
Ok(trigger)
|
|
}
|
|
async fn create_pubsub_trigger(
|
|
&self,
|
|
app_id: AppId,
|
|
req: CreatePubsubTrigger,
|
|
) -> Result<Trigger, TriggerRepoError> {
|
|
let now = Utc::now();
|
|
let id = TriggerId::new();
|
|
let trigger = Trigger {
|
|
id,
|
|
app_id,
|
|
script_id: req.script_id,
|
|
kind: crate::trigger_repo::TriggerKind::Pubsub,
|
|
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::Pubsub {
|
|
topic_pattern: req.topic_pattern,
|
|
},
|
|
};
|
|
self.inner.lock().await.insert(id, trigger.clone());
|
|
Ok(trigger)
|
|
}
|
|
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Trigger>, TriggerRepoError> {
|
|
Ok(self
|
|
.inner
|
|
.lock()
|
|
.await
|
|
.values()
|
|
.filter(|t| t.app_id == app_id)
|
|
.cloned()
|
|
.collect())
|
|
}
|
|
async fn get(&self, id: TriggerId) -> Result<Option<Trigger>, TriggerRepoError> {
|
|
Ok(self.inner.lock().await.get(&id).cloned())
|
|
}
|
|
async fn delete(&self, id: TriggerId) -> Result<bool, TriggerRepoError> {
|
|
Ok(self.inner.lock().await.remove(&id).is_some())
|
|
}
|
|
async fn list_matching_kv(
|
|
&self,
|
|
_app_id: AppId,
|
|
_collection: &str,
|
|
_op: KvEventOp,
|
|
) -> Result<Vec<KvTriggerMatch>, TriggerRepoError> {
|
|
Ok(vec![])
|
|
}
|
|
async fn list_matching_docs(
|
|
&self,
|
|
_app_id: AppId,
|
|
_collection: &str,
|
|
_op: DocsEventOp,
|
|
) -> Result<Vec<DocsTriggerMatch>, TriggerRepoError> {
|
|
Ok(vec![])
|
|
}
|
|
async fn list_matching_files(
|
|
&self,
|
|
_app_id: AppId,
|
|
_collection: &str,
|
|
_op: FilesEventOp,
|
|
) -> Result<Vec<FilesTriggerMatch>, TriggerRepoError> {
|
|
Ok(vec![])
|
|
}
|
|
async fn list_matching_dead_letter(
|
|
&self,
|
|
_app_id: AppId,
|
|
_source: &str,
|
|
_trigger_id: Option<TriggerId>,
|
|
_script_id: Option<ScriptId>,
|
|
) -> Result<Vec<DeadLetterTriggerMatch>, TriggerRepoError> {
|
|
Ok(vec![])
|
|
}
|
|
}
|
|
|
|
struct InMemoryAppRepo {
|
|
existing: Mutex<HashMap<AppId, App>>,
|
|
}
|
|
|
|
impl InMemoryAppRepo {
|
|
fn with(app_id: AppId) -> Arc<Self> {
|
|
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<App, crate::repo::ScriptRepositoryError> {
|
|
unimplemented!()
|
|
}
|
|
async fn create_with_takeover(
|
|
&self,
|
|
_slug: &str,
|
|
_name: &str,
|
|
_description: Option<&str>,
|
|
) -> Result<App, crate::repo::ScriptRepositoryError> {
|
|
unimplemented!()
|
|
}
|
|
async fn slug_in_history(
|
|
&self,
|
|
_slug: &str,
|
|
) -> Result<Option<App>, crate::repo::ScriptRepositoryError> {
|
|
unimplemented!()
|
|
}
|
|
async fn list(&self) -> Result<Vec<App>, crate::repo::ScriptRepositoryError> {
|
|
unimplemented!()
|
|
}
|
|
async fn list_for_user(
|
|
&self,
|
|
_user_id: AdminUserId,
|
|
) -> Result<Vec<App>, crate::repo::ScriptRepositoryError> {
|
|
unimplemented!()
|
|
}
|
|
async fn get_by_id(
|
|
&self,
|
|
id: AppId,
|
|
) -> Result<Option<App>, crate::repo::ScriptRepositoryError> {
|
|
Ok(self.existing.lock().await.get(&id).cloned())
|
|
}
|
|
async fn get_by_slug(
|
|
&self,
|
|
_slug: &str,
|
|
) -> Result<Option<App>, crate::repo::ScriptRepositoryError> {
|
|
unimplemented!()
|
|
}
|
|
async fn get_by_slug_or_history(
|
|
&self,
|
|
_slug: &str,
|
|
) -> Result<Option<AppLookup>, crate::repo::ScriptRepositoryError> {
|
|
unimplemented!()
|
|
}
|
|
async fn update(
|
|
&self,
|
|
_id: AppId,
|
|
_name: Option<&str>,
|
|
_description: Option<Option<&str>>,
|
|
) -> Result<App, crate::repo::ScriptRepositoryError> {
|
|
unimplemented!()
|
|
}
|
|
async fn rename_slug(
|
|
&self,
|
|
_id: AppId,
|
|
_new_slug: &str,
|
|
_take_over_history: bool,
|
|
) -> Result<App, crate::repo::ScriptRepositoryError> {
|
|
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<i64, crate::repo::ScriptRepositoryError> {
|
|
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<HashMap<ScriptId, picloud_shared::Script>>,
|
|
}
|
|
|
|
impl InMemoryScriptRepo {
|
|
fn empty() -> Arc<Self> {
|
|
Arc::new(Self {
|
|
existing: Mutex::new(HashMap::new()),
|
|
})
|
|
}
|
|
fn with_endpoint(app_id: AppId, script_id: ScriptId) -> Arc<Self> {
|
|
Self::with(app_id, script_id, ScriptKind::Endpoint)
|
|
}
|
|
fn with_module(app_id: AppId, script_id: ScriptId) -> Arc<Self> {
|
|
Self::with(app_id, script_id, ScriptKind::Module)
|
|
}
|
|
fn with(app_id: AppId, script_id: ScriptId, kind: ScriptKind) -> Arc<Self> {
|
|
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<Option<picloud_shared::Script>, crate::repo::ScriptRepositoryError> {
|
|
Ok(self.existing.lock().await.get(&id).cloned())
|
|
}
|
|
async fn list(
|
|
&self,
|
|
) -> Result<Vec<picloud_shared::Script>, crate::repo::ScriptRepositoryError> {
|
|
Ok(self.existing.lock().await.values().cloned().collect())
|
|
}
|
|
async fn list_for_app(
|
|
&self,
|
|
_app_id: AppId,
|
|
) -> Result<Vec<picloud_shared::Script>, crate::repo::ScriptRepositoryError> {
|
|
unimplemented!()
|
|
}
|
|
async fn list_for_user(
|
|
&self,
|
|
_user_id: AdminUserId,
|
|
) -> Result<Vec<picloud_shared::Script>, crate::repo::ScriptRepositoryError> {
|
|
unimplemented!()
|
|
}
|
|
async fn create(
|
|
&self,
|
|
_input: crate::repo::NewScript,
|
|
) -> Result<picloud_shared::Script, crate::repo::ScriptRepositoryError> {
|
|
unimplemented!()
|
|
}
|
|
async fn update(
|
|
&self,
|
|
_id: ScriptId,
|
|
_patch: crate::repo::ScriptPatch,
|
|
) -> Result<picloud_shared::Script, crate::repo::ScriptRepositoryError> {
|
|
unimplemented!()
|
|
}
|
|
async fn delete(&self, _id: ScriptId) -> Result<(), crate::repo::ScriptRepositoryError> {
|
|
unimplemented!()
|
|
}
|
|
async fn count_routes_for_script(
|
|
&self,
|
|
_script_id: ScriptId,
|
|
) -> Result<i64, crate::repo::ScriptRepositoryError> {
|
|
Ok(0)
|
|
}
|
|
async fn count_triggers_for_script(
|
|
&self,
|
|
_script_id: ScriptId,
|
|
) -> Result<i64, crate::repo::ScriptRepositoryError> {
|
|
Ok(0)
|
|
}
|
|
async fn list_imports(
|
|
&self,
|
|
_script_id: ScriptId,
|
|
) -> Result<Vec<picloud_shared::Script>, crate::repo::ScriptRepositoryError> {
|
|
Ok(vec![])
|
|
}
|
|
}
|
|
|
|
struct AlwaysAllowAuthzRepo;
|
|
#[async_trait]
|
|
impl AuthzRepo for AlwaysAllowAuthzRepo {
|
|
async fn membership(
|
|
&self,
|
|
_user_id: UserId,
|
|
_app_id: AppId,
|
|
) -> Result<Option<AppRole>, AuthzError> {
|
|
Ok(Some(AppRole::AppAdmin))
|
|
}
|
|
}
|
|
|
|
struct AlwaysDenyAuthzRepo;
|
|
#[async_trait]
|
|
impl AuthzRepo for AlwaysDenyAuthzRepo {
|
|
async fn membership(
|
|
&self,
|
|
_user_id: UserId,
|
|
_app_id: AppId,
|
|
) -> Result<Option<AppRole>, 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<dyn AuthzRepo>, 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<dyn AuthzRepo>,
|
|
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);
|
|
}
|
|
|
|
// ----------------------------------------------------------------
|
|
// v1.1.5: files + pubsub trigger create (Layout-E reject coverage).
|
|
// ----------------------------------------------------------------
|
|
|
|
fn files_req(script_id: ScriptId, glob: &str) -> CreateFilesTriggerRequest {
|
|
CreateFilesTriggerRequest {
|
|
script_id,
|
|
collection_glob: glob.into(),
|
|
ops: vec![FilesEventOp::Create],
|
|
dispatch_mode: TriggerDispatchMode::Async,
|
|
retry_max_attempts: None,
|
|
retry_backoff: None,
|
|
retry_base_ms: None,
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn files_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_files_trigger(
|
|
State(state),
|
|
Extension(member_principal()),
|
|
Path(app_id),
|
|
Json(files_req(script_id, "avatars")),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(status, StatusCode::CREATED);
|
|
assert!(matches!(
|
|
trigger.kind,
|
|
crate::trigger_repo::TriggerKind::Files
|
|
));
|
|
match trigger.details {
|
|
TriggerDetails::Files {
|
|
collection_glob,
|
|
ops,
|
|
} => {
|
|
assert_eq!(collection_glob, "avatars");
|
|
assert_eq!(ops, vec![FilesEventOp::Create]);
|
|
}
|
|
other => panic!("expected Files details, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn files_trigger_empty_glob_rejected() {
|
|
let app_id = AppId::new();
|
|
let state = state_with(Arc::new(AlwaysAllowAuthzRepo), app_id);
|
|
let res = create_files_trigger(
|
|
State(state),
|
|
Extension(member_principal()),
|
|
Path(app_id),
|
|
Json(files_req(ScriptId::new(), " ")),
|
|
)
|
|
.await;
|
|
assert!(matches!(
|
|
res.expect_err("empty glob"),
|
|
TriggersApiError::Invalid(_)
|
|
));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn files_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_files_trigger(
|
|
State(state),
|
|
Extension(member_principal()),
|
|
Path(app_id),
|
|
Json(files_req(script_id, "avatars")),
|
|
)
|
|
.await;
|
|
let msg = match res.expect_err("module rejected") {
|
|
TriggersApiError::Invalid(m) => m,
|
|
other => panic!("expected Invalid, got {other:?}"),
|
|
};
|
|
assert!(msg.to_lowercase().contains("module"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn files_trigger_rejects_cross_app_script() {
|
|
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_files_trigger(
|
|
State(state),
|
|
Extension(member_principal()),
|
|
Path(app_a),
|
|
Json(files_req(script_id, "avatars")),
|
|
)
|
|
.await;
|
|
let msg = match res.expect_err("cross-app rejected") {
|
|
TriggersApiError::Invalid(m) => m,
|
|
other => panic!("expected Invalid, got {other:?}"),
|
|
};
|
|
assert!(msg.to_lowercase().contains("does not belong"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn files_trigger_member_without_role_is_forbidden() {
|
|
let app_id = AppId::new();
|
|
let state = state_with(Arc::new(AlwaysDenyAuthzRepo), app_id);
|
|
let res = create_files_trigger(
|
|
State(state),
|
|
Extension(member_principal()),
|
|
Path(app_id),
|
|
Json(files_req(ScriptId::new(), "avatars")),
|
|
)
|
|
.await;
|
|
assert!(matches!(
|
|
res.expect_err("forbidden"),
|
|
TriggersApiError::Forbidden
|
|
));
|
|
}
|
|
|
|
fn pubsub_req(script_id: ScriptId, pattern: &str) -> CreatePubsubTriggerRequest {
|
|
CreatePubsubTriggerRequest {
|
|
script_id,
|
|
topic_pattern: pattern.into(),
|
|
dispatch_mode: TriggerDispatchMode::Async,
|
|
retry_max_attempts: None,
|
|
retry_backoff: None,
|
|
retry_base_ms: None,
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn pubsub_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_pubsub_trigger(
|
|
State(state),
|
|
Extension(member_principal()),
|
|
Path(app_id),
|
|
Json(pubsub_req(script_id, "user.*")),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(status, StatusCode::CREATED);
|
|
match trigger.details {
|
|
TriggerDetails::Pubsub { topic_pattern } => assert_eq!(topic_pattern, "user.*"),
|
|
other => panic!("expected Pubsub details, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn pubsub_trigger_rejects_bad_pattern() {
|
|
let app_id = AppId::new();
|
|
let script_id = ScriptId::new();
|
|
let state = state_with_endpoint(Arc::new(AlwaysAllowAuthzRepo), app_id, script_id);
|
|
for bad in ["*.created", "a.*.b", "**"] {
|
|
let res = create_pubsub_trigger(
|
|
State(state.clone()),
|
|
Extension(member_principal()),
|
|
Path(app_id),
|
|
Json(pubsub_req(script_id, bad)),
|
|
)
|
|
.await;
|
|
let msg = match res.expect_err("bad pattern") {
|
|
TriggersApiError::Invalid(m) => m,
|
|
other => panic!("expected Invalid, got {other:?}"),
|
|
};
|
|
assert!(
|
|
msg.contains("unsupported pubsub topic pattern"),
|
|
"got {msg} for {bad}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn pubsub_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_pubsub_trigger(
|
|
State(state),
|
|
Extension(member_principal()),
|
|
Path(app_id),
|
|
Json(pubsub_req(script_id, "user.*")),
|
|
)
|
|
.await;
|
|
let msg = match res.expect_err("module rejected") {
|
|
TriggersApiError::Invalid(m) => m,
|
|
other => panic!("expected Invalid, got {other:?}"),
|
|
};
|
|
assert!(msg.to_lowercase().contains("module"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn pubsub_trigger_rejects_cross_app_script() {
|
|
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_pubsub_trigger(
|
|
State(state),
|
|
Extension(member_principal()),
|
|
Path(app_a),
|
|
Json(pubsub_req(script_id, "user.*")),
|
|
)
|
|
.await;
|
|
let msg = match res.expect_err("cross-app rejected") {
|
|
TriggersApiError::Invalid(m) => m,
|
|
other => panic!("expected Invalid, got {other:?}"),
|
|
};
|
|
assert!(msg.to_lowercase().contains("does not belong"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn pubsub_trigger_member_without_role_is_forbidden() {
|
|
let app_id = AppId::new();
|
|
let state = state_with(Arc::new(AlwaysDenyAuthzRepo), app_id);
|
|
let res = create_pubsub_trigger(
|
|
State(state),
|
|
Extension(member_principal()),
|
|
Path(app_id),
|
|
Json(pubsub_req(ScriptId::new(), "user.*")),
|
|
)
|
|
.await;
|
|
assert!(matches!(
|
|
res.expect_err("forbidden"),
|
|
TriggersApiError::Forbidden
|
|
));
|
|
}
|
|
}
|