#![allow(clippy::unwrap_used, clippy::panic)]
use std::{collections::HashMap, sync::Arc};
use fraiseql_core::{
error::FraiseQLError,
security::{BeforeMutationGate, BeforeMutationOutcome, BeforeMutationRequest},
};
use fraiseql_functions::{
BeforeMutationResult, BeforeMutationTrigger, FunctionObserver, TriggerRegistry,
};
use super::{BeforeMutationBudget, FunctionChainGate, map_chain_outcome, run_within_budget};
use crate::subsystems::BeforeMutationHooks;
fn hooks_for(mutations: &[&str]) -> Arc<BeforeMutationHooks> {
let mut registry = TriggerRegistry::new();
for mutation in mutations {
registry.before_mutation_triggers.push(BeforeMutationTrigger {
function_name: format!("guard_{mutation}"),
mutation_name: (*mutation).to_string(),
});
}
Arc::new(BeforeMutationHooks::new(
registry,
HashMap::new(),
Arc::new(FunctionObserver::new()),
))
}
#[test]
fn proceed_carries_the_chains_arguments_to_the_write() {
let threaded = serde_json::json!({ "input": { "name": "REWRITTEN" } });
let mapped = map_chain_outcome("createUser", Ok(BeforeMutationResult::Proceed(threaded)))
.expect("Proceed must not refuse");
match mapped {
BeforeMutationOutcome::ProceedWith { arguments } => {
assert_eq!(arguments["input"]["name"], "REWRITTEN");
},
other => panic!("expected ProceedWith, got {other:?}"),
}
}
#[test]
fn proceed_with_null_is_carried_through_rather_than_dropped() {
let mapped =
map_chain_outcome("createUser", Ok(BeforeMutationResult::Proceed(serde_json::Value::Null)))
.expect("Proceed must not refuse");
match mapped {
BeforeMutationOutcome::ProceedWith { arguments } => {
assert_eq!(
arguments,
serde_json::Value::Null,
"a null rewrite must reach the write's required-argument check, not vanish"
);
},
other => panic!("expected ProceedWith, got {other:?}"),
}
}
#[test]
fn abort_carries_the_rules_own_message() {
let mapped = map_chain_outcome(
"pay",
Ok(BeforeMutationResult::Abort("needs two approvals".to_string())),
)
.expect("an abort is a decision, not a gate failure");
match mapped {
BeforeMutationOutcome::Abort { reason } => assert_eq!(reason, "needs two approvals"),
other => panic!("expected Abort, got {other:?}"),
}
}
#[test]
fn a_chain_failure_refuses_the_write() {
let err = map_chain_outcome(
"pay",
Err(FraiseQLError::Validation {
message: "before:mutation function 'guard_pay' not found in module registry"
.to_string(),
path: None,
}),
)
.expect_err("a chain failure must refuse the write");
assert!(
matches!(&err, FraiseQLError::Internal { message, .. }
if message == "before:mutation hook execution failed"),
"the chain's own text is withheld from the client: {err:?}"
);
}
#[tokio::test]
async fn a_mutation_with_no_trigger_proceeds_untouched() {
let gate = FunctionChainGate::new(hooks_for(&["guarded"]));
let arguments = serde_json::json!({ "id": 1 });
let request = BeforeMutationRequest::new(None, "harmless", "harmless", &arguments);
let mapped = gate.before_mutation(&request).await.expect("no trigger cannot fail");
assert!(
matches!(mapped, BeforeMutationOutcome::Proceed),
"an unhooked mutation must proceed with the arguments the engine resolved"
);
}
#[tokio::test]
async fn a_declared_trigger_is_found_under_an_alias_and_fails_closed() {
let gate = FunctionChainGate::new(hooks_for(&["guarded"]));
let arguments = serde_json::json!({ "id": 1 });
let request = BeforeMutationRequest::new(None, "guarded", "aliasedAs", &arguments);
let err = gate
.before_mutation(&request)
.await
.expect_err("a declared trigger with no module must refuse the write");
assert!(
matches!(&err, FraiseQLError::Internal { message, .. }
if message == "before:mutation hook execution failed"),
"lookup must use `mutation`, not `response_key`: {err:?}"
);
}
#[tokio::test]
async fn the_alias_is_never_the_lookup_key() {
let gate = FunctionChainGate::new(hooks_for(&["guarded"]));
let arguments = serde_json::json!({ "id": 1 });
let request = BeforeMutationRequest::new(None, "harmless", "guarded", &arguments);
let mapped = gate.before_mutation(&request).await.expect("no trigger for `harmless`");
assert!(
matches!(mapped, BeforeMutationOutcome::Proceed),
"an alias spelled like a guarded mutation must not run that mutation's chain"
);
}
#[test]
fn the_default_budget_is_the_documented_five_hundred_milliseconds() {
assert_eq!(BeforeMutationBudget::default().duration().as_millis(), 500);
assert!(BeforeMutationBudget::default().is_enforced());
}
#[test]
fn the_env_var_overrides_the_default() {
let budget = BeforeMutationBudget::from_getter(|key| {
(key == BeforeMutationBudget::ENV).then(|| "1200".to_string())
});
assert_eq!(budget.duration().as_millis(), 1200);
}
#[test]
fn an_unparseable_override_leaves_the_default_in_place() {
assert_eq!(BeforeMutationBudget::from_getter(|_| None), BeforeMutationBudget::default());
assert_eq!(
BeforeMutationBudget::from_getter(|_| Some("soon".to_string())),
BeforeMutationBudget::default()
);
}
#[test]
fn zero_disables_the_ceiling() {
assert!(!BeforeMutationBudget::from_millis(0).is_enforced());
}
#[tokio::test(start_paused = true)]
async fn a_chain_inside_its_budget_decides() {
let outcome = run_within_budget("pay", BeforeMutationBudget::from_millis(500), async {
tokio::time::sleep(std::time::Duration::from_millis(499)).await;
Ok(BeforeMutationResult::Abort("needs two approvals".to_string()))
})
.await
.expect("a chain inside its budget must return its own decision");
assert!(
matches!(outcome, BeforeMutationResult::Abort(reason) if reason == "needs two approvals")
);
}
#[tokio::test(start_paused = true)]
async fn a_chain_over_its_budget_refuses_the_write() {
let error = run_within_budget("pay", BeforeMutationBudget::from_millis(500), async {
tokio::time::sleep(std::time::Duration::from_millis(501)).await;
Ok(BeforeMutationResult::Proceed(serde_json::json!({ "input": {} })))
})
.await
.expect_err("a chain that ran out of time approved nothing");
match &error {
FraiseQLError::Timeout { timeout_ms, query } => {
assert_eq!(*timeout_ms, 500);
assert_eq!(query.as_deref(), Some("before:mutation:pay"));
},
other => panic!("expected the budget's own diagnosis, got {other:?}"),
}
}
#[test]
fn the_overrun_diagnosis_is_not_flattened_into_execution_failed() {
let error = map_chain_outcome(
"pay",
Err(FraiseQLError::Timeout {
timeout_ms: 500,
query: Some("before:mutation:pay".to_string()),
}),
)
.expect_err("an overrun refuses the write");
assert!(
matches!(&error, FraiseQLError::Timeout { timeout_ms, .. } if *timeout_ms == 500),
"the budget's diagnosis must reach the operator: {error:?}"
);
}
#[tokio::test(start_paused = true)]
async fn a_disabled_ceiling_does_not_refuse() {
let outcome = run_within_budget("pay", BeforeMutationBudget::from_millis(0), async {
tokio::time::sleep(std::time::Duration::from_secs(30)).await;
Ok(BeforeMutationResult::Abort("late but heard".to_string()))
})
.await
.expect("a disabled ceiling must not refuse");
assert!(matches!(outcome, BeforeMutationResult::Abort(reason) if reason == "late but heard"));
}
#[tokio::test(start_paused = true)]
async fn the_gate_applies_its_budget() {
let gate = FunctionChainGate::new(hooks_for(&["guarded"]))
.with_budget(BeforeMutationBudget::from_millis(1));
let arguments = serde_json::json!({ "id": 1 });
let request = BeforeMutationRequest::new(None, "guarded", "guarded", &arguments);
let error = gate.before_mutation(&request).await.expect_err("the chain cannot run");
assert!(
matches!(&error, FraiseQLError::Timeout { .. } | FraiseQLError::Internal { .. }),
"the gate must refuse: {error:?}"
);
}