815 lines
29 KiB
Rust
815 lines
29 KiB
Rust
//! Parameter validation module
|
|
//!
|
|
//! Validates trigger and action parameters against their declared schemas.
|
|
//! Schemas use the flat StackStorm-style format:
|
|
//! { "param_name": { "type": "string", "required": true, "secret": true, ... }, ... }
|
|
//!
|
|
//! Before validation, flat schemas are converted to standard JSON Schema so we
|
|
//! can reuse the `jsonschema` crate. Template-aware: values containing `{{ }}`
|
|
//! template expressions are replaced with schema-appropriate placeholders before
|
|
//! validation, so template expressions pass type checks while literal values are
|
|
//! still validated normally.
|
|
|
|
use attune_common::models::{action::Action, trigger::Trigger};
|
|
use jsonschema::Validator;
|
|
use serde_json::Value;
|
|
|
|
use crate::middleware::ApiError;
|
|
|
|
/// Convert a flat StackStorm-style parameter schema into a standard JSON Schema
|
|
/// object suitable for `jsonschema::Validator`.
|
|
///
|
|
/// Input (flat):
|
|
/// ```json
|
|
/// { "url": { "type": "string", "required": true }, "timeout": { "type": "integer", "default": 30 } }
|
|
/// ```
|
|
///
|
|
/// Output (JSON Schema):
|
|
/// ```json
|
|
/// { "type": "object", "properties": { "url": { "type": "string" }, "timeout": { "type": "integer", "default": 30 } }, "required": ["url"] }
|
|
/// ```
|
|
fn flat_to_json_schema(flat: &Value) -> Value {
|
|
let Some(map) = flat.as_object() else {
|
|
// Not an object — return a permissive schema
|
|
return serde_json::json!({});
|
|
};
|
|
|
|
// If it already looks like a JSON Schema (has "type": "object" + "properties"),
|
|
// pass it through unchanged for backward tolerance.
|
|
if map.get("type").and_then(|v| v.as_str()) == Some("object") && map.contains_key("properties")
|
|
{
|
|
return flat.clone();
|
|
}
|
|
|
|
let mut properties = serde_json::Map::new();
|
|
let mut required: Vec<Value> = Vec::new();
|
|
|
|
for (key, prop_def) in map {
|
|
let Some(prop_obj) = prop_def.as_object() else {
|
|
// Skip non-object entries (shouldn't happen in valid schemas)
|
|
continue;
|
|
};
|
|
|
|
// Clone the property definition, stripping `required` and `secret`
|
|
// (they are not valid JSON Schema keywords).
|
|
let mut clean = prop_obj.clone();
|
|
let is_required = clean
|
|
.remove("required")
|
|
.and_then(|v| v.as_bool())
|
|
.unwrap_or(false);
|
|
clean.remove("secret");
|
|
// `position` is also an Attune extension, not JSON Schema
|
|
clean.remove("position");
|
|
|
|
if is_required {
|
|
required.push(Value::String(key.clone()));
|
|
}
|
|
|
|
properties.insert(key.clone(), Value::Object(clean));
|
|
}
|
|
|
|
let mut schema = serde_json::Map::new();
|
|
schema.insert("type".to_string(), Value::String("object".to_string()));
|
|
schema.insert("properties".to_string(), Value::Object(properties));
|
|
if !required.is_empty() {
|
|
schema.insert("required".to_string(), Value::Array(required));
|
|
}
|
|
|
|
Value::Object(schema)
|
|
}
|
|
|
|
/// Check if a JSON value is (or contains) a template expression.
|
|
fn is_template_expression(value: &Value) -> bool {
|
|
match value {
|
|
Value::String(s) => s.contains("{{") && s.contains("}}"),
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
/// Given a JSON Schema property definition, produce a placeholder value that
|
|
/// satisfies the schema's type constraint. This is used to replace template
|
|
/// expressions so that JSON Schema validation passes for the remaining
|
|
/// (non-template) parts of the parameters.
|
|
fn placeholder_for_schema(property_schema: &Value) -> Value {
|
|
// Handle anyOf / oneOf by picking the first variant
|
|
if let Some(any_of) = property_schema.get("anyOf").and_then(|v| v.as_array()) {
|
|
if let Some(first) = any_of.first() {
|
|
return placeholder_for_schema(first);
|
|
}
|
|
}
|
|
if let Some(one_of) = property_schema.get("oneOf").and_then(|v| v.as_array()) {
|
|
if let Some(first) = one_of.first() {
|
|
return placeholder_for_schema(first);
|
|
}
|
|
}
|
|
|
|
let type_value = property_schema.get("type").and_then(|t| t.as_str());
|
|
|
|
match type_value {
|
|
Some("integer") => {
|
|
// Use minimum if set, else default if set, else 0
|
|
if let Some(default) = property_schema.get("default") {
|
|
return default.clone();
|
|
}
|
|
if let Some(min) = property_schema.get("minimum").and_then(|v| v.as_i64()) {
|
|
return Value::Number(min.into());
|
|
}
|
|
Value::Number(0.into())
|
|
}
|
|
Some("number") => {
|
|
if let Some(default) = property_schema.get("default") {
|
|
return default.clone();
|
|
}
|
|
if let Some(min) = property_schema.get("minimum").and_then(|v| v.as_f64()) {
|
|
return serde_json::Number::from_f64(min)
|
|
.map(Value::Number)
|
|
.unwrap_or(Value::Number(0.into()));
|
|
}
|
|
serde_json::Number::from_f64(0.0)
|
|
.map(Value::Number)
|
|
.unwrap_or(Value::Number(0.into()))
|
|
}
|
|
Some("boolean") => {
|
|
if let Some(default) = property_schema.get("default") {
|
|
return default.clone();
|
|
}
|
|
Value::Bool(true)
|
|
}
|
|
Some("array") => {
|
|
if let Some(default) = property_schema.get("default") {
|
|
return default.clone();
|
|
}
|
|
Value::Array(vec![])
|
|
}
|
|
Some("object") => {
|
|
if let Some(default) = property_schema.get("default") {
|
|
return default.clone();
|
|
}
|
|
Value::Object(serde_json::Map::new())
|
|
}
|
|
Some("string") | None => {
|
|
// For enum fields, use the first valid value so enum validation passes
|
|
if let Some(enum_values) = property_schema.get("enum").and_then(|v| v.as_array()) {
|
|
if let Some(first) = enum_values.first() {
|
|
return first.clone();
|
|
}
|
|
}
|
|
if let Some(default) = property_schema.get("default") {
|
|
return default.clone();
|
|
}
|
|
Value::String("__template_placeholder__".to_string())
|
|
}
|
|
Some(_) => Value::Null,
|
|
}
|
|
}
|
|
|
|
/// Walk a parameters object and replace any template expression values with
|
|
/// schema-appropriate placeholders. Only replaces leaf values that match
|
|
/// `{{ ... }}`; non-template values are left untouched for normal validation.
|
|
///
|
|
/// `schema` must be a standard JSON Schema object (with `properties`, `type`, etc).
|
|
/// Call `flat_to_json_schema` first if starting from flat format.
|
|
fn replace_templates_with_placeholders(params: &Value, schema: &Value) -> Value {
|
|
match params {
|
|
Value::Object(map) => {
|
|
let properties = schema.get("properties").and_then(|p| p.as_object());
|
|
|
|
let mut result = serde_json::Map::new();
|
|
for (key, value) in map {
|
|
let prop_schema = properties.and_then(|p| p.get(key));
|
|
|
|
if is_template_expression(value) {
|
|
// Replace with a type-appropriate placeholder
|
|
if let Some(ps) = prop_schema {
|
|
result.insert(key.clone(), placeholder_for_schema(ps));
|
|
} else {
|
|
// No schema for this property — keep as string placeholder
|
|
result.insert(
|
|
key.clone(),
|
|
Value::String("__template_placeholder__".to_string()),
|
|
);
|
|
}
|
|
} else if value.is_object() {
|
|
// Recurse into nested objects
|
|
let empty_schema = Value::Object(serde_json::Map::new());
|
|
let nested_schema = prop_schema.unwrap_or(&empty_schema);
|
|
result.insert(
|
|
key.clone(),
|
|
replace_templates_with_placeholders(value, nested_schema),
|
|
);
|
|
} else if value.is_array() {
|
|
// Recurse into arrays — check each element
|
|
if let Some(arr) = value.as_array() {
|
|
let empty_items_schema = Value::Object(serde_json::Map::new());
|
|
let item_schema = prop_schema
|
|
.and_then(|ps| ps.get("items"))
|
|
.unwrap_or(&empty_items_schema);
|
|
let new_arr: Vec<Value> = arr
|
|
.iter()
|
|
.map(|item| {
|
|
if is_template_expression(item) {
|
|
placeholder_for_schema(item_schema)
|
|
} else if item.is_object() || item.is_array() {
|
|
replace_templates_with_placeholders(item, item_schema)
|
|
} else {
|
|
item.clone()
|
|
}
|
|
})
|
|
.collect();
|
|
result.insert(key.clone(), Value::Array(new_arr));
|
|
} else {
|
|
result.insert(key.clone(), value.clone());
|
|
}
|
|
} else {
|
|
result.insert(key.clone(), value.clone());
|
|
}
|
|
}
|
|
Value::Object(result)
|
|
}
|
|
other => other.clone(),
|
|
}
|
|
}
|
|
|
|
/// Validate trigger parameters against the trigger's parameter schema.
|
|
/// Template expressions (`{{ ... }}`) are accepted for any field type.
|
|
///
|
|
/// The schema is expected in flat StackStorm format and is converted to
|
|
/// JSON Schema internally for validation.
|
|
pub fn validate_trigger_params(trigger: &Trigger, params: &Value) -> Result<(), ApiError> {
|
|
// If no schema is defined, accept any parameters
|
|
let Some(flat_schema) = &trigger.param_schema else {
|
|
return Ok(());
|
|
};
|
|
|
|
// Convert flat format to JSON Schema for validation
|
|
let schema = flat_to_json_schema(flat_schema);
|
|
|
|
// Replace template expressions with schema-appropriate placeholders
|
|
let sanitized = replace_templates_with_placeholders(params, &schema);
|
|
|
|
// Compile the JSON schema
|
|
let compiled_schema = Validator::new(&schema).map_err(|e| {
|
|
ApiError::InternalServerError(format!(
|
|
"Invalid parameter schema for trigger '{}': {}",
|
|
trigger.r#ref, e
|
|
))
|
|
})?;
|
|
|
|
// Validate the sanitized parameters
|
|
let errors: Vec<String> = compiled_schema
|
|
.iter_errors(&sanitized)
|
|
.map(|e| {
|
|
let path = e.instance_path().to_string();
|
|
if path.is_empty() {
|
|
e.to_string()
|
|
} else {
|
|
format!("{} at {}", e, path)
|
|
}
|
|
})
|
|
.collect();
|
|
|
|
if !errors.is_empty() {
|
|
return Err(ApiError::ValidationError(format!(
|
|
"Invalid parameters for trigger '{}': {}",
|
|
trigger.r#ref,
|
|
errors.join(", ")
|
|
)));
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Validate action parameters against the action's parameter schema.
|
|
/// Template expressions (`{{ ... }}`) are accepted for any field type.
|
|
///
|
|
/// The schema is expected in flat StackStorm format and is converted to
|
|
/// JSON Schema internally for validation.
|
|
pub fn validate_action_params(action: &Action, params: &Value) -> Result<(), ApiError> {
|
|
// If no schema is defined, accept any parameters
|
|
let Some(flat_schema) = &action.param_schema else {
|
|
return Ok(());
|
|
};
|
|
|
|
// Convert flat format to JSON Schema for validation
|
|
let schema = flat_to_json_schema(flat_schema);
|
|
|
|
// Replace template expressions with schema-appropriate placeholders
|
|
let sanitized = replace_templates_with_placeholders(params, &schema);
|
|
|
|
// Compile the JSON schema
|
|
let compiled_schema = Validator::new(&schema).map_err(|e| {
|
|
ApiError::InternalServerError(format!(
|
|
"Invalid parameter schema for action '{}': {}",
|
|
action.r#ref, e
|
|
))
|
|
})?;
|
|
|
|
// Validate the sanitized parameters
|
|
let errors: Vec<String> = compiled_schema
|
|
.iter_errors(&sanitized)
|
|
.map(|e| {
|
|
let path = e.instance_path().to_string();
|
|
if path.is_empty() {
|
|
e.to_string()
|
|
} else {
|
|
format!("{} at {}", e, path)
|
|
}
|
|
})
|
|
.collect();
|
|
|
|
if !errors.is_empty() {
|
|
return Err(ApiError::ValidationError(format!(
|
|
"Invalid parameters for action '{}': {}",
|
|
action.r#ref,
|
|
errors.join(", ")
|
|
)));
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use serde_json::json;
|
|
|
|
// ── Helper builders ──────────────────────────────────────────────
|
|
|
|
fn make_trigger(schema: Option<Value>) -> Trigger {
|
|
Trigger {
|
|
id: 1,
|
|
r#ref: "test.trigger".to_string(),
|
|
pack: Some(1),
|
|
pack_ref: Some("test".to_string()),
|
|
label: "Test Trigger".to_string(),
|
|
description: None,
|
|
enabled: true,
|
|
param_schema: schema,
|
|
out_schema: None,
|
|
webhook_enabled: false,
|
|
webhook_key: None,
|
|
webhook_config: None,
|
|
is_adhoc: false,
|
|
created: chrono::Utc::now(),
|
|
updated: chrono::Utc::now(),
|
|
}
|
|
}
|
|
|
|
fn make_action(schema: Option<Value>) -> Action {
|
|
Action {
|
|
id: 1,
|
|
r#ref: "test.action".to_string(),
|
|
pack: 1,
|
|
pack_ref: "test".to_string(),
|
|
label: "Test Action".to_string(),
|
|
description: Some("Test action".to_string()),
|
|
entrypoint: "test.sh".to_string(),
|
|
runtime: Some(1),
|
|
runtime_version_constraint: None,
|
|
param_schema: schema,
|
|
out_schema: None,
|
|
workflow_def: None,
|
|
is_adhoc: false,
|
|
parameter_delivery: attune_common::models::ParameterDelivery::default(),
|
|
parameter_format: attune_common::models::ParameterFormat::default(),
|
|
output_format: attune_common::models::OutputFormat::default(),
|
|
created: chrono::Utc::now(),
|
|
updated: chrono::Utc::now(),
|
|
}
|
|
}
|
|
|
|
// ── No schema ────────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn test_validate_trigger_params_with_no_schema() {
|
|
let trigger = make_trigger(None);
|
|
let params = json!({ "any": "value" });
|
|
assert!(validate_trigger_params(&trigger, ¶ms).is_ok());
|
|
}
|
|
|
|
// ── Basic trigger validation (no templates) ──────────────────────
|
|
|
|
// ── flat_to_json_schema unit tests ───────────────────────────────
|
|
|
|
#[test]
|
|
fn test_flat_to_json_schema_basic() {
|
|
let flat = json!({
|
|
"url": { "type": "string", "required": true },
|
|
"timeout": { "type": "integer", "default": 30 }
|
|
});
|
|
let result = flat_to_json_schema(&flat);
|
|
assert_eq!(result["type"], "object");
|
|
assert_eq!(result["properties"]["url"]["type"], "string");
|
|
// `required` should be stripped from individual properties
|
|
assert!(result["properties"]["url"].get("required").is_none());
|
|
assert_eq!(result["properties"]["timeout"]["default"], 30);
|
|
// Top-level required array should contain "url"
|
|
let req = result["required"].as_array().unwrap();
|
|
assert!(req.contains(&json!("url")));
|
|
assert!(!req.contains(&json!("timeout")));
|
|
}
|
|
|
|
#[test]
|
|
fn test_flat_to_json_schema_strips_secret_and_position() {
|
|
let flat = json!({
|
|
"token": { "type": "string", "secret": true, "position": 0, "required": true }
|
|
});
|
|
let result = flat_to_json_schema(&flat);
|
|
let token = &result["properties"]["token"];
|
|
assert!(token.get("secret").is_none());
|
|
assert!(token.get("position").is_none());
|
|
assert!(token.get("required").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_flat_to_json_schema_empty() {
|
|
let flat = json!({});
|
|
let result = flat_to_json_schema(&flat);
|
|
assert_eq!(result["type"], "object");
|
|
assert!(result.get("required").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_flat_to_json_schema_passthrough_json_schema() {
|
|
// If already JSON Schema format, pass through unchanged
|
|
let js = json!({
|
|
"type": "object",
|
|
"properties": { "x": { "type": "string" } },
|
|
"required": ["x"]
|
|
});
|
|
let result = flat_to_json_schema(&js);
|
|
assert_eq!(result, js);
|
|
}
|
|
|
|
// ── Basic trigger validation (flat format) ──────────────────────
|
|
|
|
#[test]
|
|
fn test_validate_trigger_params_with_valid_params() {
|
|
let schema = json!({
|
|
"unit": { "type": "string", "enum": ["seconds", "minutes", "hours"], "required": true },
|
|
"delta": { "type": "integer", "minimum": 1, "required": true }
|
|
});
|
|
|
|
let trigger = make_trigger(Some(schema));
|
|
let params = json!({ "unit": "seconds", "delta": 10 });
|
|
assert!(validate_trigger_params(&trigger, ¶ms).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_trigger_params_with_invalid_params() {
|
|
let schema = json!({
|
|
"unit": { "type": "string", "enum": ["seconds", "minutes", "hours"], "required": true },
|
|
"delta": { "type": "integer", "minimum": 1, "required": true }
|
|
});
|
|
|
|
let trigger = make_trigger(Some(schema));
|
|
|
|
// Missing required field 'delta'
|
|
let params = json!({ "unit": "seconds" });
|
|
assert!(validate_trigger_params(&trigger, ¶ms).is_err());
|
|
|
|
// Invalid enum value for 'unit'
|
|
let params = json!({ "unit": "days", "delta": 10 });
|
|
assert!(validate_trigger_params(&trigger, ¶ms).is_err());
|
|
|
|
// Invalid type for 'delta'
|
|
let params = json!({ "unit": "seconds", "delta": "10" });
|
|
assert!(validate_trigger_params(&trigger, ¶ms).is_err());
|
|
}
|
|
|
|
// ── Basic action validation (flat format) ───────────────────────
|
|
|
|
#[test]
|
|
fn test_validate_action_params_with_valid_params() {
|
|
let schema = json!({
|
|
"message": { "type": "string", "required": true }
|
|
});
|
|
|
|
let action = make_action(Some(schema));
|
|
let params = json!({ "message": "Hello, world!" });
|
|
assert!(validate_action_params(&action, ¶ms).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_action_params_with_empty_params_but_required_fields() {
|
|
let schema = json!({
|
|
"message": { "type": "string", "required": true }
|
|
});
|
|
|
|
let action = make_action(Some(schema));
|
|
let params = json!({});
|
|
assert!(validate_action_params(&action, ¶ms).is_err());
|
|
}
|
|
|
|
// ── Template-aware validation (flat format) ──────────────────────
|
|
|
|
#[test]
|
|
fn test_template_in_integer_field_passes() {
|
|
let schema = json!({
|
|
"counter": { "type": "integer", "required": true }
|
|
});
|
|
|
|
let action = make_action(Some(schema));
|
|
let params = json!({ "counter": "{{ event.payload.counter }}" });
|
|
assert!(validate_action_params(&action, ¶ms).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_template_in_boolean_field_passes() {
|
|
let schema = json!({
|
|
"verbose": { "type": "boolean", "required": true }
|
|
});
|
|
|
|
let action = make_action(Some(schema));
|
|
let params = json!({ "verbose": "{{ event.payload.debug }}" });
|
|
assert!(validate_action_params(&action, ¶ms).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_template_in_number_field_passes() {
|
|
let schema = json!({
|
|
"threshold": { "type": "number", "minimum": 0.0, "required": true }
|
|
});
|
|
|
|
let action = make_action(Some(schema));
|
|
let params = json!({ "threshold": "{{ event.payload.threshold }}" });
|
|
assert!(validate_action_params(&action, ¶ms).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_template_in_enum_field_passes() {
|
|
let schema = json!({
|
|
"level": { "type": "string", "enum": ["info", "warn", "error"], "required": true }
|
|
});
|
|
|
|
let action = make_action(Some(schema));
|
|
let params = json!({ "level": "{{ event.payload.severity }}" });
|
|
assert!(validate_action_params(&action, ¶ms).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_template_in_array_field_passes() {
|
|
let schema = json!({
|
|
"recipients": { "type": "array", "items": { "type": "string" }, "required": true }
|
|
});
|
|
|
|
let action = make_action(Some(schema));
|
|
let params = json!({ "recipients": "{{ event.payload.emails }}" });
|
|
assert!(validate_action_params(&action, ¶ms).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_template_in_object_field_passes() {
|
|
let schema = json!({
|
|
"metadata": { "type": "object", "required": true }
|
|
});
|
|
|
|
let action = make_action(Some(schema));
|
|
let params = json!({ "metadata": "{{ event.payload.meta }}" });
|
|
assert!(validate_action_params(&action, ¶ms).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_mixed_template_and_literal_values() {
|
|
let schema = json!({
|
|
"message": { "type": "string", "required": true },
|
|
"count": { "type": "integer", "required": true },
|
|
"verbose": { "type": "boolean", "required": true }
|
|
});
|
|
|
|
let action = make_action(Some(schema));
|
|
|
|
// Mix of literal and template values
|
|
let params = json!({
|
|
"message": "Hello",
|
|
"count": "{{ event.payload.count }}",
|
|
"verbose": true
|
|
});
|
|
assert!(validate_action_params(&action, ¶ms).is_ok());
|
|
}
|
|
|
|
// ── Secret fields are ignored during validation ──────────────────
|
|
|
|
#[test]
|
|
fn test_secret_field_validated_normally() {
|
|
let schema = json!({
|
|
"api_key": { "type": "string", "required": true, "secret": true },
|
|
"endpoint": { "type": "string" }
|
|
});
|
|
|
|
let action = make_action(Some(schema));
|
|
|
|
// Valid: secret field provided
|
|
let params = json!({ "api_key": "sk-1234", "endpoint": "https://api.example.com" });
|
|
assert!(validate_action_params(&action, ¶ms).is_ok());
|
|
|
|
// Invalid: secret field missing but required
|
|
let params = json!({ "endpoint": "https://api.example.com" });
|
|
assert!(validate_action_params(&action, ¶ms).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_literal_values_still_validated() {
|
|
let schema = json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"message": { "type": "string" },
|
|
"count": { "type": "integer" }
|
|
},
|
|
"required": ["message", "count"]
|
|
});
|
|
|
|
let action = make_action(Some(schema));
|
|
|
|
// Template for message is fine, but literal "not_a_number" for integer is not
|
|
let params = json!({
|
|
"message": "{{ event.payload.msg }}",
|
|
"count": "not_a_number"
|
|
});
|
|
assert!(validate_action_params(&action, ¶ms).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_required_field_still_enforced_with_templates() {
|
|
let schema = json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"message": { "type": "string" },
|
|
"count": { "type": "integer" }
|
|
},
|
|
"required": ["message", "count"]
|
|
});
|
|
|
|
let action = make_action(Some(schema));
|
|
|
|
// Only message provided (even as template), count is missing
|
|
let params = json!({ "message": "{{ event.payload.msg }}" });
|
|
assert!(validate_action_params(&action, ¶ms).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_pack_config_template_passes() {
|
|
let schema = json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"api_key": { "type": "string" },
|
|
"timeout": { "type": "integer" }
|
|
},
|
|
"required": ["api_key", "timeout"]
|
|
});
|
|
|
|
let action = make_action(Some(schema));
|
|
let params = json!({
|
|
"api_key": "{{ pack.config.api_key }}",
|
|
"timeout": "{{ pack.config.default_timeout }}"
|
|
});
|
|
assert!(validate_action_params(&action, ¶ms).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_system_template_passes() {
|
|
let schema = json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"timestamp": { "type": "string" },
|
|
"rule_id": { "type": "integer" }
|
|
},
|
|
"required": ["timestamp", "rule_id"]
|
|
});
|
|
|
|
let action = make_action(Some(schema));
|
|
let params = json!({
|
|
"timestamp": "{{ system.timestamp }}",
|
|
"rule_id": "{{ system.rule.id }}"
|
|
});
|
|
assert!(validate_action_params(&action, ¶ms).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_trigger_params_template_aware() {
|
|
let schema = json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"unit": { "type": "string", "enum": ["seconds", "minutes", "hours"] },
|
|
"delta": { "type": "integer", "minimum": 1 }
|
|
},
|
|
"required": ["unit", "delta"]
|
|
});
|
|
|
|
let trigger = make_trigger(Some(schema));
|
|
|
|
// Both fields as templates
|
|
let params = json!({
|
|
"unit": "{{ pack.config.timer_unit }}",
|
|
"delta": "{{ pack.config.timer_delta }}"
|
|
});
|
|
assert!(validate_trigger_params(&trigger, ¶ms).is_ok());
|
|
}
|
|
|
|
// ── Placeholder generation ───────────────────────────────────────
|
|
|
|
#[test]
|
|
fn test_is_template_expression() {
|
|
assert!(is_template_expression(&json!("{{ event.payload.x }}")));
|
|
assert!(is_template_expression(&json!("{{ pack.config.key }}")));
|
|
assert!(is_template_expression(&json!(
|
|
"prefix {{ system.ts }} suffix"
|
|
)));
|
|
assert!(!is_template_expression(&json!("no braces here")));
|
|
assert!(!is_template_expression(&json!(42)));
|
|
assert!(!is_template_expression(&json!(true)));
|
|
assert!(!is_template_expression(&json!("{ single braces }")));
|
|
}
|
|
|
|
#[test]
|
|
fn test_placeholder_for_schema_types() {
|
|
assert_eq!(
|
|
placeholder_for_schema(&json!({"type": "integer"})),
|
|
json!(0)
|
|
);
|
|
assert_eq!(
|
|
placeholder_for_schema(&json!({"type": "number"})),
|
|
json!(0.0)
|
|
);
|
|
assert_eq!(
|
|
placeholder_for_schema(&json!({"type": "boolean"})),
|
|
json!(true)
|
|
);
|
|
assert_eq!(placeholder_for_schema(&json!({"type": "array"})), json!([]));
|
|
assert_eq!(
|
|
placeholder_for_schema(&json!({"type": "object"})),
|
|
json!({})
|
|
);
|
|
assert_eq!(
|
|
placeholder_for_schema(&json!({"type": "string"})),
|
|
json!("__template_placeholder__")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_placeholder_respects_enum() {
|
|
let schema = json!({"type": "string", "enum": ["a", "b", "c"]});
|
|
assert_eq!(placeholder_for_schema(&schema), json!("a"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_placeholder_respects_default() {
|
|
let schema = json!({"type": "integer", "default": 42});
|
|
assert_eq!(placeholder_for_schema(&schema), json!(42));
|
|
}
|
|
|
|
#[test]
|
|
fn test_placeholder_respects_minimum() {
|
|
let schema = json!({"type": "integer", "minimum": 5});
|
|
assert_eq!(placeholder_for_schema(&schema), json!(5));
|
|
}
|
|
|
|
#[test]
|
|
fn test_nested_object_template_replacement() {
|
|
let schema = json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"outer": {
|
|
"type": "object",
|
|
"properties": {
|
|
"inner_count": { "type": "integer" }
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
let params = json!({
|
|
"outer": {
|
|
"inner_count": "{{ event.payload.count }}"
|
|
}
|
|
});
|
|
|
|
let sanitized = replace_templates_with_placeholders(¶ms, &schema);
|
|
// The inner template should be replaced with an integer placeholder
|
|
assert!(sanitized["outer"]["inner_count"].is_number());
|
|
}
|
|
|
|
#[test]
|
|
fn test_array_element_template_replacement() {
|
|
let schema = json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"tags": {
|
|
"type": "array",
|
|
"items": { "type": "string" }
|
|
}
|
|
}
|
|
});
|
|
|
|
let params = json!({
|
|
"tags": ["literal", "{{ event.payload.tag }}"]
|
|
});
|
|
|
|
let sanitized = replace_templates_with_placeholders(¶ms, &schema);
|
|
let tags = sanitized["tags"].as_array().unwrap();
|
|
assert_eq!(tags[0], "literal");
|
|
assert!(tags[1].is_string());
|
|
assert_ne!(tags[1], "{{ event.payload.tag }}");
|
|
}
|
|
}
|