use super::evaluator::{McpPolicyEvaluator, PolicyDecision, PolicyDenialReason};
use super::rules::{build_effective_rules, conditions_match, make_eval_context};
use super::templates::{get_template, list_templates};
use super::types::{
PolicyAction, PolicyRateLimit, PolicyRule, PolicyTemplateName, RateLimitDimension,
RuleConditions, ToolCategory,
};
use crate::config::{McpPolicyConfig, OperatingMode};
use crate::storage::{self, rate_limits};
fn default_policy() -> McpPolicyConfig {
McpPolicyConfig::default()
}
fn enforcement_disabled() -> McpPolicyConfig {
McpPolicyConfig {
enforce_for_mutations: false,
..default_policy()
}
}
fn with_blocked(tools: Vec<&str>) -> McpPolicyConfig {
McpPolicyConfig {
blocked_tools: tools.into_iter().map(String::from).collect(),
..default_policy()
}
}
fn dry_run_policy() -> McpPolicyConfig {
McpPolicyConfig {
dry_run_mutations: true,
..default_policy()
}
}
fn no_approval_policy() -> McpPolicyConfig {
McpPolicyConfig {
require_approval_for: Vec::new(),
..default_policy()
}
}
#[tokio::test]
async fn enforcement_disabled_allows_all() {
let pool = storage::init_test_db().await.expect("init db");
rate_limits::init_mcp_rate_limit(&pool, 20)
.await
.expect("init rate limit");
let config = enforcement_disabled();
let decision =
McpPolicyEvaluator::evaluate(&pool, &config, &OperatingMode::Autopilot, "post_tweet")
.await
.expect("evaluate");
assert_eq!(decision, PolicyDecision::Allow);
}
#[tokio::test]
async fn blocked_tool_denied() {
let pool = storage::init_test_db().await.expect("init db");
rate_limits::init_mcp_rate_limit(&pool, 20)
.await
.expect("init rate limit");
let config = with_blocked(vec!["post_tweet"]);
let decision =
McpPolicyEvaluator::evaluate(&pool, &config, &OperatingMode::Autopilot, "post_tweet")
.await
.expect("evaluate");
assert!(matches!(
decision,
PolicyDecision::Deny {
reason: PolicyDenialReason::ToolBlocked,
..
}
));
}
#[tokio::test]
async fn dry_run_returns_dry_run() {
let pool = storage::init_test_db().await.expect("init db");
rate_limits::init_mcp_rate_limit(&pool, 20)
.await
.expect("init rate limit");
let config = dry_run_policy();
let decision =
McpPolicyEvaluator::evaluate(&pool, &config, &OperatingMode::Autopilot, "post_tweet")
.await
.expect("evaluate");
assert!(matches!(decision, PolicyDecision::DryRun { .. }));
}
#[tokio::test]
async fn rate_limit_exceeded_denies() {
let pool = storage::init_test_db().await.expect("init db");
rate_limits::init_mcp_rate_limit(&pool, 2)
.await
.expect("init rate limit");
rate_limits::increment_rate_limit(&pool, "mcp_mutation")
.await
.expect("inc");
rate_limits::increment_rate_limit(&pool, "mcp_mutation")
.await
.expect("inc");
let config = no_approval_policy();
let decision =
McpPolicyEvaluator::evaluate(&pool, &config, &OperatingMode::Autopilot, "post_tweet")
.await
.expect("evaluate");
assert!(matches!(
decision,
PolicyDecision::Deny {
reason: PolicyDenialReason::RateLimited,
..
}
));
}
#[tokio::test]
async fn approval_required_routes() {
let pool = storage::init_test_db().await.expect("init db");
rate_limits::init_mcp_rate_limit(&pool, 20)
.await
.expect("init rate limit");
let config = default_policy();
let decision =
McpPolicyEvaluator::evaluate(&pool, &config, &OperatingMode::Autopilot, "post_tweet")
.await
.expect("evaluate");
assert!(matches!(decision, PolicyDecision::RouteToApproval { .. }));
}
#[tokio::test]
async fn non_approval_tool_allowed() {
let pool = storage::init_test_db().await.expect("init db");
rate_limits::init_mcp_rate_limit(&pool, 20)
.await
.expect("init rate limit");
let config = default_policy();
let decision =
McpPolicyEvaluator::evaluate(&pool, &config, &OperatingMode::Autopilot, "unfollow_user")
.await
.expect("evaluate");
assert_eq!(decision, PolicyDecision::Allow);
}
#[tokio::test]
async fn composer_mode_forces_approval() {
let pool = storage::init_test_db().await.expect("init db");
rate_limits::init_mcp_rate_limit(&pool, 20)
.await
.expect("init rate limit");
let config = no_approval_policy();
let decision =
McpPolicyEvaluator::evaluate(&pool, &config, &OperatingMode::Composer, "unfollow_user")
.await
.expect("evaluate");
assert!(matches!(decision, PolicyDecision::RouteToApproval { .. }));
}
#[tokio::test]
async fn audit_record_logged() {
let pool = storage::init_test_db().await.expect("init db");
rate_limits::init_mcp_rate_limit(&pool, 20)
.await
.expect("init rate limit");
let config = default_policy();
let decision =
McpPolicyEvaluator::evaluate(&pool, &config, &OperatingMode::Autopilot, "post_tweet")
.await
.expect("evaluate");
McpPolicyEvaluator::log_decision(&pool, "post_tweet", &decision)
.await
.expect("log");
let rows: Vec<(String, String)> = sqlx::query_as(
"SELECT action_type, status FROM action_log WHERE action_type = 'mcp_policy'",
)
.fetch_all(&pool)
.await
.expect("query");
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].0, "mcp_policy");
assert_eq!(rows[0].1, "routed_to_approval");
}
#[tokio::test]
async fn record_mutation_increments_counter() {
let pool = storage::init_test_db().await.expect("init db");
rate_limits::init_mcp_rate_limit(&pool, 20)
.await
.expect("init rate limit");
McpPolicyEvaluator::record_mutation(&pool, "post_tweet", &[])
.await
.expect("record");
let limits = rate_limits::get_all_rate_limits(&pool)
.await
.expect("get limits");
let mcp = limits
.iter()
.find(|l| l.action_type == "mcp_mutation")
.expect("mcp_mutation row");
assert_eq!(mcp.request_count, 1);
}
#[tokio::test]
async fn blocked_takes_priority_over_dry_run() {
let pool = storage::init_test_db().await.expect("init db");
rate_limits::init_mcp_rate_limit(&pool, 20)
.await
.expect("init rate limit");
let config = McpPolicyConfig {
blocked_tools: vec!["post_tweet".to_string()],
dry_run_mutations: true,
..default_policy()
};
let decision =
McpPolicyEvaluator::evaluate(&pool, &config, &OperatingMode::Autopilot, "post_tweet")
.await
.expect("evaluate");
assert!(matches!(
decision,
PolicyDecision::Deny {
reason: PolicyDenialReason::ToolBlocked,
..
}
));
}
#[test]
fn tool_name_match_hit() {
let conditions = RuleConditions {
tools: vec!["post_tweet".into()],
..Default::default()
};
let ctx = make_eval_context("post_tweet", &OperatingMode::Autopilot);
assert!(conditions_match(&conditions, &ctx));
}
#[test]
fn tool_name_match_miss() {
let conditions = RuleConditions {
tools: vec!["like_tweet".into()],
..Default::default()
};
let ctx = make_eval_context("post_tweet", &OperatingMode::Autopilot);
assert!(!conditions_match(&conditions, &ctx));
}
#[test]
fn category_match_hit() {
let conditions = RuleConditions {
categories: vec![ToolCategory::Write],
..Default::default()
};
let ctx = make_eval_context("post_tweet", &OperatingMode::Autopilot);
assert!(conditions_match(&conditions, &ctx));
}
#[test]
fn category_match_miss() {
let conditions = RuleConditions {
categories: vec![ToolCategory::Engage],
..Default::default()
};
let ctx = make_eval_context("post_tweet", &OperatingMode::Autopilot);
assert!(!conditions_match(&conditions, &ctx));
}
#[test]
fn mode_match_hit() {
let conditions = RuleConditions {
modes: vec![OperatingMode::Autopilot],
..Default::default()
};
let ctx = make_eval_context("post_tweet", &OperatingMode::Autopilot);
assert!(conditions_match(&conditions, &ctx));
}
#[test]
fn mode_match_miss() {
let conditions = RuleConditions {
modes: vec![OperatingMode::Composer],
..Default::default()
};
let ctx = make_eval_context("post_tweet", &OperatingMode::Autopilot);
assert!(!conditions_match(&conditions, &ctx));
}
#[test]
fn empty_conditions_match_all() {
let conditions = RuleConditions::default();
let ctx = make_eval_context("any_tool", &OperatingMode::Autopilot);
assert!(conditions_match(&conditions, &ctx));
}
#[test]
fn multi_condition_and_logic() {
let conditions = RuleConditions {
categories: vec![ToolCategory::Write],
modes: vec![OperatingMode::Autopilot],
..Default::default()
};
let ctx = make_eval_context("post_tweet", &OperatingMode::Autopilot);
assert!(conditions_match(&conditions, &ctx));
let ctx = make_eval_context("post_tweet", &OperatingMode::Composer);
assert!(!conditions_match(&conditions, &ctx));
let ctx = make_eval_context("like_tweet", &OperatingMode::Autopilot);
assert!(!conditions_match(&conditions, &ctx));
}
#[test]
fn list_templates_returns_three() {
let templates = list_templates();
assert_eq!(templates.len(), 3);
}
#[test]
fn safe_default_template_has_expected_rules() {
let template = get_template(&PolicyTemplateName::SafeDefault);
assert!(template.rules.len() >= 3);
assert!(template
.rules
.iter()
.any(|r| r.id.contains("delete_approval")));
}
#[tokio::test]
async fn template_applied_produces_expected_decisions() {
let pool = storage::init_test_db().await.expect("init db");
rate_limits::init_mcp_rate_limit(&pool, 50)
.await
.expect("init rate limit");
let config = McpPolicyConfig {
template: Some(PolicyTemplateName::GrowthAggressive),
require_approval_for: Vec::new(),
..default_policy()
};
let decision =
McpPolicyEvaluator::evaluate(&pool, &config, &OperatingMode::Autopilot, "post_tweet")
.await
.expect("evaluate");
assert_eq!(decision, PolicyDecision::Allow);
let decision =
McpPolicyEvaluator::evaluate(&pool, &config, &OperatingMode::Autopilot, "delete_tweet")
.await
.expect("evaluate");
assert!(matches!(decision, PolicyDecision::RouteToApproval { .. }));
}
#[tokio::test]
async fn template_plus_user_rules_coexist() {
let pool = storage::init_test_db().await.expect("init db");
rate_limits::init_mcp_rate_limit(&pool, 50)
.await
.expect("init rate limit");
let config = McpPolicyConfig {
template: Some(PolicyTemplateName::GrowthAggressive),
rules: vec![PolicyRule {
id: "user:block_likes".into(),
priority: 99, label: "Block likes".into(),
enabled: true,
conditions: RuleConditions {
tools: vec!["like_tweet".into()],
..Default::default()
},
action: PolicyAction::Deny {
reason: "user blocked likes".into(),
},
}],
require_approval_for: Vec::new(),
..default_policy()
};
let decision =
McpPolicyEvaluator::evaluate(&pool, &config, &OperatingMode::Autopilot, "like_tweet")
.await
.expect("evaluate");
assert!(matches!(
decision,
PolicyDecision::Deny {
reason: PolicyDenialReason::UserRule,
..
}
));
let decision =
McpPolicyEvaluator::evaluate(&pool, &config, &OperatingMode::Autopilot, "post_tweet")
.await
.expect("evaluate");
assert_eq!(decision, PolicyDecision::Allow);
}
#[tokio::test]
async fn delete_tweet_always_routes_to_approval() {
let pool = storage::init_test_db().await.expect("init db");
rate_limits::init_mcp_rate_limit(&pool, 200)
.await
.expect("init rate limit");
let config = McpPolicyConfig {
template: Some(PolicyTemplateName::AgencyMode),
require_approval_for: Vec::new(),
..default_policy()
};
let decision =
McpPolicyEvaluator::evaluate(&pool, &config, &OperatingMode::Autopilot, "delete_tweet")
.await
.expect("evaluate");
assert!(matches!(decision, PolicyDecision::RouteToApproval { .. }));
}
#[tokio::test]
async fn composer_mode_always_routes_to_approval() {
let pool = storage::init_test_db().await.expect("init db");
rate_limits::init_mcp_rate_limit(&pool, 200)
.await
.expect("init rate limit");
let config = McpPolicyConfig {
template: Some(PolicyTemplateName::AgencyMode),
require_approval_for: Vec::new(),
..default_policy()
};
let decision =
McpPolicyEvaluator::evaluate(&pool, &config, &OperatingMode::Composer, "like_tweet")
.await
.expect("evaluate");
assert!(matches!(decision, PolicyDecision::RouteToApproval { .. }));
}
#[tokio::test]
async fn per_tool_rate_limit_enforced() {
let pool = storage::init_test_db().await.expect("init db");
rate_limits::init_mcp_rate_limit(&pool, 100)
.await
.expect("init rate limit");
let per_tool_limits = vec![PolicyRateLimit {
key: "mcp:tool:like_tweet:hourly".into(),
dimension: RateLimitDimension::Tool,
match_value: "like_tweet".into(),
max_count: 2,
period_seconds: 3600,
}];
rate_limits::init_policy_rate_limits(&pool, &per_tool_limits)
.await
.expect("init policy rate limits");
rate_limits::increment_rate_limit(&pool, "mcp:tool:like_tweet:hourly")
.await
.expect("inc");
rate_limits::increment_rate_limit(&pool, "mcp:tool:like_tweet:hourly")
.await
.expect("inc");
let config = McpPolicyConfig {
require_approval_for: Vec::new(),
rate_limits: per_tool_limits,
..default_policy()
};
let decision =
McpPolicyEvaluator::evaluate(&pool, &config, &OperatingMode::Autopilot, "like_tweet")
.await
.expect("evaluate");
assert!(matches!(
decision,
PolicyDecision::Deny {
reason: PolicyDenialReason::RateLimited,
..
}
));
let decision =
McpPolicyEvaluator::evaluate(&pool, &config, &OperatingMode::Autopilot, "post_tweet")
.await
.expect("evaluate");
assert_eq!(decision, PolicyDecision::Allow);
}
#[tokio::test]
async fn per_category_rate_limit_enforced() {
let pool = storage::init_test_db().await.expect("init db");
rate_limits::init_mcp_rate_limit(&pool, 100)
.await
.expect("init rate limit");
let per_cat_limits = vec![PolicyRateLimit {
key: "mcp:category:engage:daily".into(),
dimension: RateLimitDimension::Category,
match_value: "engage".into(),
max_count: 1,
period_seconds: 86400,
}];
rate_limits::init_policy_rate_limits(&pool, &per_cat_limits)
.await
.expect("init");
rate_limits::increment_rate_limit(&pool, "mcp:category:engage:daily")
.await
.expect("inc");
let config = McpPolicyConfig {
require_approval_for: Vec::new(),
rate_limits: per_cat_limits,
..default_policy()
};
let decision =
McpPolicyEvaluator::evaluate(&pool, &config, &OperatingMode::Autopilot, "like_tweet")
.await
.expect("evaluate");
assert!(matches!(
decision,
PolicyDecision::Deny {
reason: PolicyDenialReason::RateLimited,
..
}
));
}
#[tokio::test]
async fn global_limit_still_works_alongside_per_dimension() {
let pool = storage::init_test_db().await.expect("init db");
rate_limits::init_mcp_rate_limit(&pool, 1)
.await
.expect("init rate limit");
let per_tool_limits = vec![PolicyRateLimit {
key: "mcp:tool:post_tweet:hourly".into(),
dimension: RateLimitDimension::Tool,
match_value: "post_tweet".into(),
max_count: 100, period_seconds: 3600,
}];
rate_limits::init_policy_rate_limits(&pool, &per_tool_limits)
.await
.expect("init");
rate_limits::increment_rate_limit(&pool, "mcp_mutation")
.await
.expect("inc");
let config = McpPolicyConfig {
require_approval_for: Vec::new(),
rate_limits: per_tool_limits,
..default_policy()
};
let decision =
McpPolicyEvaluator::evaluate(&pool, &config, &OperatingMode::Autopilot, "post_tweet")
.await
.expect("evaluate");
assert!(matches!(
decision,
PolicyDecision::Deny {
reason: PolicyDenialReason::RateLimited,
..
}
));
}
#[test]
fn effective_rules_sorted_by_priority() {
let config = McpPolicyConfig {
rules: vec![
PolicyRule {
id: "user:z".into(),
priority: 250,
label: "Z".into(),
enabled: true,
conditions: RuleConditions::default(),
action: PolicyAction::Allow,
},
PolicyRule {
id: "user:a".into(),
priority: 200,
label: "A".into(),
enabled: true,
conditions: RuleConditions::default(),
action: PolicyAction::Allow,
},
],
..default_policy()
};
let rules = build_effective_rules(&config, &OperatingMode::Autopilot);
assert!(rules[0].priority <= rules[1].priority);
for i in 1..rules.len() {
assert!(rules[i - 1].priority <= rules[i].priority);
}
}
#[test]
fn v1_compat_rules_generated_when_no_v2_config() {
let config = McpPolicyConfig {
blocked_tools: vec!["bad_tool".into()],
require_approval_for: vec!["post_tweet".into()],
..default_policy()
};
let rules = build_effective_rules(&config, &OperatingMode::Autopilot);
assert!(rules.iter().any(|r| r.id.starts_with("v1:blocked:")));
assert!(rules.iter().any(|r| r.id.starts_with("v1:approval:")));
}
#[test]
fn v1_compat_rules_not_generated_with_template() {
let config = McpPolicyConfig {
template: Some(PolicyTemplateName::SafeDefault),
blocked_tools: vec!["bad_tool".into()],
..default_policy()
};
let rules = build_effective_rules(&config, &OperatingMode::Autopilot);
assert!(!rules.iter().any(|r| r.id.starts_with("v1:")));
}
#[test]
fn disabled_rules_excluded_from_matching() {
let config = McpPolicyConfig {
rules: vec![PolicyRule {
id: "user:disabled".into(),
priority: 200,
label: "Disabled".into(),
enabled: false,
conditions: RuleConditions::default(),
action: PolicyAction::Deny {
reason: "should not fire".into(),
},
}],
..default_policy()
};
let rules = build_effective_rules(&config, &OperatingMode::Autopilot);
let ctx = make_eval_context("post_tweet", &OperatingMode::Autopilot);
let matching = super::rules::find_matching_rule(&rules, &ctx);
if let Some(rule) = matching {
assert_ne!(rule.id, "user:disabled");
}
}
#[tokio::test]
async fn audit_record_includes_rule_metadata() {
let pool = storage::init_test_db().await.expect("init db");
rate_limits::init_mcp_rate_limit(&pool, 20)
.await
.expect("init rate limit");
let config = McpPolicyConfig {
template: Some(PolicyTemplateName::SafeDefault),
require_approval_for: Vec::new(),
..default_policy()
};
let decision =
McpPolicyEvaluator::evaluate(&pool, &config, &OperatingMode::Autopilot, "post_tweet")
.await
.expect("evaluate");
McpPolicyEvaluator::log_decision(&pool, "post_tweet", &decision)
.await
.expect("log");
let rows: Vec<(String, Option<String>)> = sqlx::query_as(
"SELECT action_type, metadata FROM action_log WHERE action_type = 'mcp_policy'",
)
.fetch_all(&pool)
.await
.expect("query");
assert_eq!(rows.len(), 1);
let metadata = rows[0].1.as_ref().expect("metadata should exist");
assert!(metadata.contains("category"));
assert!(metadata.contains("matched_rule_id"));
}
#[tokio::test]
async fn record_mutation_increments_per_dimension_counters() {
let pool = storage::init_test_db().await.expect("init db");
rate_limits::init_mcp_rate_limit(&pool, 100)
.await
.expect("init rate limit");
let per_tool_limits = vec![PolicyRateLimit {
key: "mcp:tool:like_tweet:hourly".into(),
dimension: RateLimitDimension::Tool,
match_value: "like_tweet".into(),
max_count: 10,
period_seconds: 3600,
}];
rate_limits::init_policy_rate_limits(&pool, &per_tool_limits)
.await
.expect("init");
McpPolicyEvaluator::record_mutation(&pool, "like_tweet", &per_tool_limits)
.await
.expect("record");
let limits = rate_limits::get_all_rate_limits(&pool)
.await
.expect("get limits");
let mcp = limits
.iter()
.find(|l| l.action_type == "mcp_mutation")
.expect("mcp_mutation row");
assert_eq!(mcp.request_count, 1);
let per_tool = limits
.iter()
.find(|l| l.action_type == "mcp:tool:like_tweet:hourly")
.expect("per-tool row");
assert_eq!(per_tool.request_count, 1);
}