diff --git a/cli/src/services/hooks/claude_mutation_scope/tests.rs b/cli/src/services/hooks/claude_mutation_scope/tests.rs index c2b407c9c..2e7e3dcbf 100644 --- a/cli/src/services/hooks/claude_mutation_scope/tests.rs +++ b/cli/src/services/hooks/claude_mutation_scope/tests.rs @@ -36,6 +36,56 @@ fn identity(session_id: &str, agent_id: Option<&str>, tool_use_id: &str) -> Atte } } +mod ingress_conformance { + use super::*; + use crate::services::hooks::mutation_scope_ingress_conformance::{ + self as conformance, mutation_scope_ingress_conformance_tests, IngressConformance, + }; + + struct ClaudeIngressConformance; + + impl IngressConformance for ClaudeIngressConformance { + const ADAPTER: &'static str = "Claude"; + const EVENT_NAME_FIELD: &'static str = HOOK_EVENT_NAME_FIELD; + const REQUIRED_START_FIELDS: &'static [&'static str] = &[ + SESSION_ID_FIELD, + CWD_FIELD, + TOOL_NAME_FIELD, + TOOL_USE_ID_FIELD, + ]; + const OPTIONAL_START_FIELDS: &'static [(&'static str, &'static str)] = &[ + (AGENT_ID_FIELD, "agent-1"), + (PROMPT_ID_FIELD, "prompt-1"), + (AGENT_TYPE_FIELD, "general-purpose"), + ]; + const UNSUPPORTED_EVENT_NAMES: &'static [&'static str] = + &["PostToolBatch", "PreCompact", "Notification"]; + + fn tracked_start() -> Map { + serde_json::from_str(&pre_tool_use_json(&[])) + .expect("the tracked PreToolUse fixture is a JSON object") + } + + fn parse(payload: &str) -> Result<()> { + parse_claude_hook_event(payload).map(|_| ()) + } + + fn run(payload: &str) -> Result { + run_claude_mutation_scope_from_payload(payload, None) + } + + fn run_with_seams( + payload: &str, + resolve_git_dir: conformance::GitDirResolver, + seam: conformance::IngressSeam, + ) -> Result { + run_claude_mutation_scope_from_payload_with(payload, None, resolve_git_dir, seam) + } + } + + mutation_scope_ingress_conformance_tests!(ClaudeIngressConformance); +} + #[test] fn pre_tool_use_parses_required_and_optional_fields() { let payload = pre_tool_use_json(&[ @@ -100,59 +150,6 @@ fn pre_tool_use_prompt_id_and_agent_type_are_optional() { assert_eq!(execution.agent_type, None); } -#[test] -fn missing_required_fields_are_rejected_without_fabricating_identity() { - for field in [ - SESSION_ID_FIELD, - CWD_FIELD, - TOOL_NAME_FIELD, - TOOL_USE_ID_FIELD, - ] { - let mut object: serde_json::Map = - serde_json::from_str(&pre_tool_use_json(&[])).unwrap(); - object.remove(field); - let payload = Value::Object(object).to_string(); - - let error = parse_claude_hook_event(&payload).unwrap_err(); - assert!( - error.to_string().contains(&format!("'{field}'")), - "expected missing-field error to name '{field}', got: {error}" - ); - } -} - -#[test] -fn wrong_type_required_field_is_rejected() { - let payload = pre_tool_use_json(&[(SESSION_ID_FIELD, Value::from(42))]); - - let error = parse_claude_hook_event(&payload).unwrap_err(); - assert!(error.to_string().contains("session_id")); -} - -#[test] -fn empty_string_required_field_is_rejected() { - let payload = pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String(String::new()))]); - - let error = parse_claude_hook_event(&payload).unwrap_err(); - assert!(error.to_string().contains("non-blank")); -} - -#[test] -fn wrong_type_optional_field_is_rejected() { - let payload = pre_tool_use_json(&[(PROMPT_ID_FIELD, Value::from(1))]); - - let error = parse_claude_hook_event(&payload).unwrap_err(); - assert!(error.to_string().contains("prompt_id")); -} - -#[test] -fn empty_optional_field_is_rejected() { - let payload = pre_tool_use_json(&[(AGENT_ID_FIELD, Value::String(String::new()))]); - - let error = parse_claude_hook_event(&payload).unwrap_err(); - assert!(error.to_string().contains("agent_id")); -} - #[test] fn null_optional_field_is_none() { let payload = pre_tool_use_json(&[(AGENT_ID_FIELD, Value::Null)]); @@ -164,38 +161,6 @@ fn null_optional_field_is_none() { assert_eq!(execution.identity.agent_id, None); } -#[test] -fn empty_payload_is_rejected() { - let error = parse_claude_hook_event("").unwrap_err(); - assert!(error.to_string().contains("empty payload")); - - let error = parse_claude_hook_event(" ").unwrap_err(); - assert!(error.to_string().contains("empty payload")); -} - -#[test] -fn malformed_json_is_rejected() { - let error = parse_claude_hook_event("{not json").unwrap_err(); - assert!(error.to_string().contains("valid JSON")); -} - -#[test] -fn non_object_json_is_rejected() { - let error = parse_claude_hook_event("[1, 2, 3]").unwrap_err(); - assert!(error.to_string().contains("JSON object")); -} - -#[test] -fn unsupported_hook_event_name_is_rejected() { - let payload = pre_tool_use_json(&[( - HOOK_EVENT_NAME_FIELD, - Value::String("PostToolBatch".to_string()), - )]); - - let error = parse_claude_hook_event(&payload).unwrap_err(); - assert!(error.to_string().contains("unsupported hook_event_name")); -} - #[test] fn post_tool_use_and_failure_and_permission_denied_share_tool_identity_shape() { for event_name in [ @@ -2061,12 +2026,6 @@ mod driver { remove_test_git_dir(&git_dir); } - - #[test] - fn malformed_payload_propagates_as_a_real_error_not_fail_open() { - let error = run_claude_mutation_scope_from_payload("not json", None).unwrap_err(); - assert!(error.to_string().contains("valid JSON")); - } } mod production_regressions { diff --git a/cli/src/services/hooks/codex_mutation_scope/tests.rs b/cli/src/services/hooks/codex_mutation_scope/tests.rs index 2418d4953..d0ab67bb3 100644 --- a/cli/src/services/hooks/codex_mutation_scope/tests.rs +++ b/cli/src/services/hooks/codex_mutation_scope/tests.rs @@ -85,104 +85,68 @@ fn pre_tool_use(payload: &str) -> CodexToolExecution { } } -#[test] -fn ac2_empty_payload_is_rejected() { - let error = parse_codex_hook_event(" ").unwrap_err().to_string(); - assert_eq!( - error, - "Invalid Codex hook event payload from STDIN: expected a JSON object, got an empty payload." - ); -} +mod ingress_conformance { + use super::*; + use crate::services::hooks::mutation_scope_ingress_conformance::{ + self as conformance, mutation_scope_ingress_conformance_tests, IngressConformance, + }; -#[test] -fn ac2_non_object_json_is_rejected() { - for payload in ["[]", "\"PreToolUse\"", "42", "null"] { - let error = parse_codex_hook_event(payload).unwrap_err().to_string(); - assert!( - error.contains("expected a JSON object"), - "payload {payload:?} produced {error:?}" - ); + struct CodexIngressConformance; + + fn unreachable_bash_policy(_root: &Path, command: &str) -> Result { + panic!("the Bash policy preflight must not run for rejected ingress (command: {command})"); } -} -#[test] -fn ac2_invalid_json_is_rejected() { - let error = parse_codex_hook_event("{not json").unwrap_err().to_string(); - assert!( - error.contains("Invalid Codex hook event payload from STDIN: expected valid JSON"), - "{error:?}" - ); -} + impl IngressConformance for CodexIngressConformance { + const ADAPTER: &'static str = "Codex"; + const EVENT_NAME_FIELD: &'static str = HOOK_EVENT_NAME_FIELD; + const REQUIRED_START_FIELDS: &'static [&'static str] = &[ + SESSION_ID_FIELD, + TURN_ID_FIELD, + CWD_FIELD, + TOOL_NAME_FIELD, + TOOL_USE_ID_FIELD, + ]; + const OPTIONAL_START_FIELDS: &'static [(&'static str, &'static str)] = &[ + (AGENT_ID_FIELD, "01a07c24-bb59-7ca0-80f7-99cf940a486e"), + (AGENT_TYPE_FIELD, "default"), + ]; + const UNSUPPORTED_EVENT_NAMES: &'static [&'static str] = &[ + "SessionStart", + "SubagentStart", + "UserPromptSubmit", + "PreCompact", + ]; -#[test] -fn ac2_unsupported_hook_event_name_is_rejected() { - for name in [ - "SessionStart", - "SubagentStart", - "UserPromptSubmit", - "PreCompact", - ] { - let payload = - pre_tool_use_json(&[(HOOK_EVENT_NAME_FIELD, Value::String(name.to_string()))]); - let error = parse_codex_hook_event(&payload).unwrap_err().to_string(); - assert!( - error.contains(&format!("unsupported hook_event_name '{name}'")), - "{error:?}" - ); - } -} + fn tracked_start() -> Map { + serde_json::from_str(&pre_tool_use_json(&[])) + .expect("the tracked PreToolUse fixture is a JSON object") + } -#[test] -fn ac2_missing_required_fields_are_rejected_without_fabricating_identity() { - for field in [ - SESSION_ID_FIELD, - TURN_ID_FIELD, - CWD_FIELD, - TOOL_NAME_FIELD, - TOOL_USE_ID_FIELD, - ] { - let mut object: Map = serde_json::from_str(&pre_tool_use_json(&[])).unwrap(); - object.remove(field); - let payload = Value::Object(object).to_string(); + fn parse(payload: &str) -> Result<()> { + parse_codex_hook_event(payload).map(|_| ()) + } - let error = parse_codex_hook_event(&payload).unwrap_err().to_string(); - assert!( - error.contains(&format!("'{field}'")), - "missing {field} produced {error:?}" - ); - } -} + fn run(payload: &str) -> Result { + run_codex_mutation_scope_from_payload(payload, None) + } -#[test] -fn ac2_blank_required_fields_are_rejected() { - for field in [SESSION_ID_FIELD, TURN_ID_FIELD, CWD_FIELD, TOOL_NAME_FIELD] { - let payload = pre_tool_use_json(&[(field, Value::String(" ".to_string()))]); - let error = parse_codex_hook_event(&payload).unwrap_err().to_string(); - assert!( - error.contains(&format!("field '{field}' must be a non-blank string")), - "blank {field} produced {error:?}" - ); + fn run_with_seams( + payload: &str, + resolve_git_dir: conformance::GitDirResolver, + seam: conformance::IngressSeam, + ) -> Result { + run_codex_mutation_scope_from_payload_with_bash_policy( + payload, + None, + resolve_git_dir, + seam, + &unreachable_bash_policy, + ) + } } -} -#[test] -fn ac2_wrong_typed_fields_are_rejected() { - let payload = pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::Bool(true))]); - let error = parse_codex_hook_event(&payload).unwrap_err().to_string(); - assert!( - error.contains("field 'tool_use_id' must be a string"), - "{error:?}" - ); -} - -#[test] -fn ac2_wrong_typed_optional_agent_id_is_rejected() { - let payload = pre_tool_use_json(&[(AGENT_ID_FIELD, Value::Bool(false))]); - let error = parse_codex_hook_event(&payload).unwrap_err().to_string(); - assert!( - error.contains("field 'agent_id' must be null, absent, or a non-blank string"), - "{error:?}" - ); + mutation_scope_ingress_conformance_tests!(CodexIngressConformance); } #[test] @@ -1786,24 +1750,6 @@ mod driver { remove_test_git_dir(&git_dir); } - #[test] - fn malformed_payload_propagates_as_a_real_error_not_fail_open() { - let error = run_codex_mutation_scope_from_payload("not json", None).unwrap_err(); - assert!(error.to_string().contains("valid JSON")); - } - - #[test] - fn unsupported_event_name_propagates_as_a_real_error() { - let payload = json!({ - HOOK_EVENT_NAME_FIELD: "UserPromptSubmit", - SESSION_ID_FIELD: "session-1", - CWD_FIELD: CWD, - }) - .to_string(); - let error = run_codex_mutation_scope_from_payload(&payload, None).unwrap_err(); - assert!(error.to_string().contains("unsupported hook_event_name")); - } - fn spawn_pre_tool_use( git_dir: &Path, tool_use_id: &'static str, diff --git a/cli/src/services/hooks/mod.rs b/cli/src/services/hooks/mod.rs index 7cc84229e..c7b036aeb 100644 --- a/cli/src/services/hooks/mod.rs +++ b/cli/src/services/hooks/mod.rs @@ -29,6 +29,8 @@ mod claude_transforms; mod commit_hooks; mod conversation_trace; mod diff_trace; +#[cfg(test)] +mod mutation_scope_ingress_conformance; mod mutation_scope_lock; #[cfg(test)] mod mutation_scope_state_conformance; diff --git a/cli/src/services/hooks/mutation_scope_ingress_conformance.rs b/cli/src/services/hooks/mutation_scope_ingress_conformance.rs new file mode 100644 index 000000000..efe4b5110 --- /dev/null +++ b/cli/src/services/hooks/mutation_scope_ingress_conformance.rs @@ -0,0 +1,390 @@ +use std::cell::Cell; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +use anyhow::{anyhow, Result}; +use serde_json::{json, Map, Value}; + +use crate::services::observability::traits::Logger; + +pub(crate) type GitDirResolver<'a> = &'a dyn Fn(&str) -> Result; + +pub(crate) type IngressSeam<'a> = &'a dyn Fn(&Path, &str, Option<&dyn Logger>) -> Result; + +static NEXT_CONFORMANCE_GIT_DIR_ID: AtomicU64 = AtomicU64::new(0); + +pub(crate) trait IngressConformance { + const ADAPTER: &'static str; + const EVENT_NAME_FIELD: &'static str; + const REQUIRED_START_FIELDS: &'static [&'static str]; + const OPTIONAL_START_FIELDS: &'static [(&'static str, &'static str)]; + const UNSUPPORTED_EVENT_NAMES: &'static [&'static str]; + + fn tracked_start() -> Map; + + fn parse(payload: &str) -> Result<()>; + + fn run(payload: &str) -> Result; + + fn run_with_seams( + payload: &str, + resolve_git_dir: GitDirResolver, + seam: IngressSeam, + ) -> Result; +} + +pub(crate) trait CheckoutResolutionConformance: IngressConformance { + const CWD_FIELD: &'static str; + const FAIL_CLOSED_MESSAGE: &'static str; +} + +struct Rejection { + case: String, + payload: String, + detail: String, +} + +fn rejection(case: impl Into, payload: impl Into, detail: &str) -> Rejection { + Rejection { + case: case.into(), + payload: payload.into(), + detail: detail.to_string(), + } +} + +fn start_with(field: &str, value: Value) -> String { + let mut object = A::tracked_start(); + object.insert(field.to_string(), value); + Value::Object(object).to_string() +} + +fn start_without(fields: &[&str]) -> String { + let mut object = A::tracked_start(); + for field in fields { + object.remove(*field); + } + Value::Object(object).to_string() +} + +fn required_fields() -> Vec<&'static str> { + let mut fields = vec![A::EVENT_NAME_FIELD]; + fields.extend_from_slice(A::REQUIRED_START_FIELDS); + fields +} + +fn malformed_json_rejections() -> Vec { + [ + ("", "got an empty payload"), + (" ", "got an empty payload"), + ("{", "expected valid JSON"), + ("{]", "expected valid JSON"), + ("{not json", "expected valid JSON"), + ] + .into_iter() + .map(|(payload, detail)| rejection(format!("malformed payload {payload:?}"), payload, detail)) + .collect() +} + +fn non_object_json_rejections() -> Vec { + ["[]", "\"string\"", "5", "true", "null"] + .into_iter() + .map(|payload| { + rejection( + format!("non-object payload {payload}"), + payload, + "expected a JSON object", + ) + }) + .collect() +} + +fn missing_required_rejections() -> Vec { + required_fields::() + .into_iter() + .map(|field| { + rejection( + format!("missing {field}"), + start_without::(&[field]), + &format!("missing required field '{field}'"), + ) + }) + .collect() +} + +fn blank_required_rejections() -> Vec { + let mut rejections = Vec::new(); + for field in required_fields::() { + for blank in ["", " "] { + rejections.push(rejection( + format!("blank {field} {blank:?}"), + start_with::(field, json!(blank)), + &format!("field '{field}' must be a non-blank string"), + )); + } + } + rejections +} + +fn wrong_typed_required_rejections() -> Vec { + let mut rejections = Vec::new(); + for field in required_fields::() { + for value in [json!(7), json!(false), json!([]), json!({}), Value::Null] { + rejections.push(rejection( + format!("wrong-typed {field} {value}"), + start_with::(field, value), + &format!("field '{field}' must be a string"), + )); + } + } + rejections +} + +fn unsupported_event_rejections() -> Vec { + A::UNSUPPORTED_EVENT_NAMES + .iter() + .map(|name| { + rejection( + format!("unsupported event {name:?}"), + start_with::(A::EVENT_NAME_FIELD, json!(name)), + &format!("unsupported {} '{name}'", A::EVENT_NAME_FIELD), + ) + }) + .collect() +} + +fn invalid_optional_rejections() -> Vec { + let mut rejections = Vec::new(); + for (field, _) in A::OPTIONAL_START_FIELDS { + for value in [ + json!(7), + json!(false), + json!([]), + json!({}), + json!(""), + json!(" "), + ] { + rejections.push(rejection( + format!("invalid optional {field} {value}"), + start_with::(field, value), + &format!("field '{field}' must be null, absent, or a non-blank string"), + )); + } + } + rejections +} + +fn assert_validation_error( + layer: &str, + rejection: &Rejection, + result: Result, +) { + let Err(error) = result else { + panic!( + "{} {layer} accepted {}: {}", + A::ADAPTER, + rejection.case, + rejection.payload + ); + }; + let error = error.to_string(); + let prefix = format!("Invalid {} hook event payload from STDIN: ", A::ADAPTER); + assert!( + error.starts_with(&prefix) && error.contains(&rejection.detail), + "{} {layer} rejected {} with an unexpected diagnostic: {error:?}", + A::ADAPTER, + rejection.case + ); +} + +fn assert_parser_rejects(rejections: &[Rejection]) { + assert!(!rejections.is_empty()); + for rejection in rejections { + assert_validation_error::("parser", rejection, A::parse(&rejection.payload)); + } +} + +fn unresolved_git_dir(contract: &str) -> PathBuf { + let id = NEXT_CONFORMANCE_GIT_DIR_ID.fetch_add(1, Ordering::Relaxed); + std::env::temp_dir().join(format!( + "sce-{}-mutation-scope-ingress-conformance-{contract}-{}-{id}", + A::ADAPTER, + std::process::id() + )) +} + +fn assert_fails_closed_before_dispatch(rejections: &[Rejection]) { + assert!(!rejections.is_empty()); + for rejection in rejections { + assert_validation_error::( + "production entrypoint", + rejection, + A::run(&rejection.payload), + ); + + let git_dir = unresolved_git_dir::("fails-closed"); + let resolver_calls = Cell::new(0_usize); + let seam_calls = Cell::new(0_usize); + let resolver = |_cwd: &str| { + resolver_calls.set(resolver_calls.get() + 1); + Ok(git_dir.clone()) + }; + let seam = |_root: &Path, _payload: &str, _logger: Option<&dyn Logger>| { + seam_calls.set(seam_calls.get() + 1); + Ok(String::new()) + }; + + let result = A::run_with_seams(&rejection.payload, &resolver, &seam); + let fabricated_state = git_dir.exists(); + let _ = std::fs::remove_dir_all(&git_dir); + + assert_validation_error::("seamed entrypoint", rejection, result); + assert_eq!( + resolver_calls.get(), + 0, + "{} resolved a checkout for {}", + A::ADAPTER, + rejection.case + ); + assert_eq!( + seam_calls.get(), + 0, + "{} invoked the mutation seam for {}", + A::ADAPTER, + rejection.case + ); + assert!( + !fabricated_state, + "{} fabricated mutation-scope state for {}", + A::ADAPTER, + rejection.case + ); + } +} + +pub(crate) fn malformed_json_is_rejected() { + assert_parser_rejects::(&malformed_json_rejections()); +} + +pub(crate) fn non_object_json_is_rejected() { + assert_parser_rejects::(&non_object_json_rejections()); +} + +pub(crate) fn missing_required_identity_is_rejected_without_fabrication() { + let rejections = missing_required_rejections::(); + assert_parser_rejects::(&rejections); + assert_fails_closed_before_dispatch::(&rejections); +} + +pub(crate) fn blank_required_identifiers_are_rejected() { + assert_parser_rejects::(&blank_required_rejections::()); +} + +pub(crate) fn wrong_typed_required_fields_are_rejected() { + assert_parser_rejects::(&wrong_typed_required_rejections::()); +} + +pub(crate) fn unsupported_event_name_is_rejected() { + assert_parser_rejects::(&unsupported_event_rejections::()); +} + +pub(crate) fn optional_fields_are_accepted_when_absent_null_or_string() { + assert!(!A::OPTIONAL_START_FIELDS.is_empty()); + let optional_fields: Vec<&str> = A::OPTIONAL_START_FIELDS + .iter() + .map(|(field, _)| *field) + .collect(); + + let absent = start_without::(&optional_fields); + if let Err(error) = A::parse(&absent) { + panic!( + "{} rejected a tracked start without optional fields: {error:#}", + A::ADAPTER + ); + } + + for (field, sample) in A::OPTIONAL_START_FIELDS { + for value in [Value::Null, json!(sample)] { + let payload = start_with::(field, value.clone()); + if let Err(error) = A::parse(&payload) { + panic!("{} rejected {field} = {value}: {error:#}", A::ADAPTER); + } + } + } +} + +pub(crate) fn wrong_typed_or_blank_optional_fields_are_rejected() { + assert_parser_rejects::(&invalid_optional_rejections::()); +} + +pub(crate) fn malformed_runtime_input_fails_closed_before_dispatch() { + let mut rejections = malformed_json_rejections(); + rejections.extend(non_object_json_rejections()); + rejections.extend(blank_required_rejections::()); + rejections.extend(wrong_typed_required_rejections::()); + rejections.extend(unsupported_event_rejections::()); + rejections.extend(invalid_optional_rejections::()); + assert_fails_closed_before_dispatch::(&rejections); +} + +pub(crate) fn tracked_start_fails_closed_when_its_checkout_cannot_be_resolved< + A: CheckoutResolutionConformance, +>() { + let unresolvable = start_with::( + A::CWD_FIELD, + json!(format!("/nonexistent/sce/{}/checkout", A::ADAPTER)), + ); + let error = A::run(&unresolvable) + .expect_err("a tracked start that cannot resolve its checkout must fail closed"); + assert!( + error.to_string().contains(A::FAIL_CLOSED_MESSAGE), + "{error:?}" + ); + + let resolver_calls = Cell::new(0_usize); + let seam_calls = Cell::new(0_usize); + let resolver = |_cwd: &str| { + resolver_calls.set(resolver_calls.get() + 1); + Err(anyhow!("checkout resolution failure injected by test")) + }; + let seam = |_root: &Path, _payload: &str, _logger: Option<&dyn Logger>| { + seam_calls.set(seam_calls.get() + 1); + Ok(String::new()) + }; + + let payload = Value::Object(A::tracked_start()).to_string(); + let error = A::run_with_seams(&payload, &resolver, &seam) + .expect_err("a failed checkout resolution must surface as a hard failure"); + assert!( + error.to_string().contains(A::FAIL_CLOSED_MESSAGE), + "{error:?}" + ); + assert_eq!(resolver_calls.get(), 1); + assert_eq!(seam_calls.get(), 0); +} + +macro_rules! mutation_scope_ingress_conformance_tests { + ($adapter:ty) => { + $crate::services::hooks::mutation_scope_ingress_conformance::mutation_scope_ingress_conformance_tests! { + $adapter => + malformed_json_is_rejected, + non_object_json_is_rejected, + missing_required_identity_is_rejected_without_fabrication, + blank_required_identifiers_are_rejected, + wrong_typed_required_fields_are_rejected, + unsupported_event_name_is_rejected, + optional_fields_are_accepted_when_absent_null_or_string, + wrong_typed_or_blank_optional_fields_are_rejected, + malformed_runtime_input_fails_closed_before_dispatch, + } + }; + ($adapter:ty => $($contract:ident),+ $(,)?) => { + $( + #[test] + fn $contract() { + $crate::services::hooks::mutation_scope_ingress_conformance::$contract::<$adapter>(); + } + )+ + }; +} + +pub(crate) use mutation_scope_ingress_conformance_tests; diff --git a/cli/src/services/hooks/opencode_mutation_scope/tests.rs b/cli/src/services/hooks/opencode_mutation_scope/tests.rs index 53f40a7a5..469f6394f 100644 --- a/cli/src/services/hooks/opencode_mutation_scope/tests.rs +++ b/cli/src/services/hooks/opencode_mutation_scope/tests.rs @@ -52,103 +52,64 @@ fn tool_execution(payload: &str) -> OpenCodeToolExecution { } } -#[test] -fn empty_payload_is_rejected() { - let error = parse_opencode_hook_event(" ").unwrap_err().to_string(); - assert_eq!( - error, - "Invalid OpenCode hook event payload from STDIN: expected a JSON object, got an empty payload." - ); -} - -#[test] -fn non_object_json_is_rejected() { - for payload in ["[]", "\"ToolExecuteBefore\"", "42", "null"] { - let error = parse_opencode_hook_event(payload).unwrap_err().to_string(); - assert!( - error.contains("expected a JSON object"), - "payload {payload:?} produced {error:?}" - ); - } -} +mod ingress_conformance { + use anyhow::Result; + + use super::super::lifecycle::run_opencode_mutation_scope_from_payload_with_seams; + use super::*; + use crate::services::hooks::mutation_scope_ingress_conformance::{ + self as conformance, mutation_scope_ingress_conformance_tests, + CheckoutResolutionConformance, IngressConformance, + }; -#[test] -fn invalid_json_is_rejected() { - let error = parse_opencode_hook_event("{not json") - .unwrap_err() - .to_string(); - assert!( - error.contains("Invalid OpenCode hook event payload from STDIN: expected valid JSON"), - "{error:?}" - ); -} + struct OpenCodeIngressConformance; + + impl IngressConformance for OpenCodeIngressConformance { + const ADAPTER: &'static str = "OpenCode"; + const EVENT_NAME_FIELD: &'static str = HOOK_EVENT_NAME_FIELD; + const REQUIRED_START_FIELDS: &'static [&'static str] = + &[SESSION_ID_FIELD, CALL_ID_FIELD, CWD_FIELD, TOOL_NAME_FIELD]; + const OPTIONAL_START_FIELDS: &'static [(&'static str, &'static str)] = + &[(MODEL_FIELD, "opencode/big-pickle")]; + const UNSUPPORTED_EVENT_NAMES: &'static [&'static str] = + &["PreToolUse", "ToolExecute", "chat.params"]; + + fn tracked_start() -> Map { + serde_json::from_str(&tool_event_json(HOOK_EVENT_TOOL_EXECUTE_BEFORE, &[])) + .expect("the tracked ToolExecuteBefore fixture is a JSON object") + } -#[test] -fn unsupported_hook_event_name_is_rejected() { - for name in ["PreToolUse", "ToolExecute", "chat.params", ""] { - let payload = tool_event_json(name, &[]); - let error = parse_opencode_hook_event(&payload).unwrap_err().to_string(); - assert!( - error.contains("hook_event_name"), - "name {name:?} produced {error:?}" - ); - } -} + fn parse(payload: &str) -> Result<()> { + parse_opencode_hook_event(payload).map(|_| ()) + } -#[test] -fn missing_required_fields_are_rejected_without_fabricating_identity() { - for field in [SESSION_ID_FIELD, CALL_ID_FIELD, CWD_FIELD, TOOL_NAME_FIELD] { - let mut object: Map = - serde_json::from_str(&tool_event_json(HOOK_EVENT_TOOL_EXECUTE_BEFORE, &[])).unwrap(); - object.remove(field); - let payload = Value::Object(object).to_string(); + fn run(payload: &str) -> Result { + run_opencode_mutation_scope_from_payload(payload, None) + } - let error = parse_opencode_hook_event(&payload).unwrap_err().to_string(); - assert!( - error.contains(&format!("'{field}'")), - "missing {field} produced {error:?}" - ); + fn run_with_seams( + payload: &str, + resolve_git_dir: conformance::GitDirResolver, + seam: conformance::IngressSeam, + ) -> Result { + run_opencode_mutation_scope_from_payload_with_seams( + payload, + None, + resolve_git_dir, + seam, + ) + } } -} -#[test] -fn blank_required_fields_are_rejected() { - for field in [SESSION_ID_FIELD, CALL_ID_FIELD, CWD_FIELD, TOOL_NAME_FIELD] { - let payload = tool_event_json( - HOOK_EVENT_TOOL_EXECUTE_BEFORE, - &[(field, Value::String(" ".to_string()))], - ); - let error = parse_opencode_hook_event(&payload).unwrap_err().to_string(); - assert!( - error.contains(&format!("field '{field}' must be a non-blank string")), - "blank {field} produced {error:?}" - ); + impl CheckoutResolutionConformance for OpenCodeIngressConformance { + const CWD_FIELD: &'static str = CWD_FIELD; + const FAIL_CLOSED_MESSAGE: &'static str = FAIL_CLOSED_MESSAGE; } -} - -#[test] -fn wrong_typed_fields_are_rejected() { - let payload = tool_event_json( - HOOK_EVENT_TOOL_EXECUTE_BEFORE, - &[(CALL_ID_FIELD, Value::Bool(true))], - ); - let error = parse_opencode_hook_event(&payload).unwrap_err().to_string(); - assert!( - error.contains("field 'call_id' must be a string"), - "{error:?}" - ); -} -#[test] -fn wrong_typed_optional_model_is_rejected() { - let payload = tool_event_json( - HOOK_EVENT_TOOL_EXECUTE_BEFORE, - &[(MODEL_FIELD, Value::Bool(false))], - ); - let error = parse_opencode_hook_event(&payload).unwrap_err().to_string(); - assert!( - error.contains("field 'model' must be null, absent, or a non-blank string"), - "{error:?}" + mutation_scope_ingress_conformance_tests!(OpenCodeIngressConformance); + mutation_scope_ingress_conformance_tests!( + OpenCodeIngressConformance => + tracked_start_fails_closed_when_its_checkout_cannot_be_resolved ); } @@ -483,23 +444,6 @@ fn provenance_is_built_from_a_parsed_start_event() { assert_eq!(provenance.model_id.as_deref(), Some("opencode/big-pickle")); } -#[test] -fn run_from_payload_fails_closed_when_a_tracked_start_cannot_resolve_its_checkout() { - let payload = tool_event_json( - HOOK_EVENT_TOOL_EXECUTE_BEFORE, - &[ - (TOOL_NAME_FIELD, Value::String("write".to_string())), - ( - CWD_FIELD, - Value::String("/nonexistent/sce/opencode/checkout".to_string()), - ), - ], - ); - let error = run_opencode_mutation_scope_from_payload(&payload, None) - .expect_err("a tracked Start that cannot resolve its checkout must fail closed"); - assert!(error.to_string().contains(FAIL_CLOSED_MESSAGE), "{error:?}"); -} - #[test] fn run_from_payload_is_neutral_for_untracked_and_delegation_events() { for tool_name in ["read", "task", "probe_mutate"] { @@ -514,14 +458,6 @@ fn run_from_payload_is_neutral_for_untracked_and_delegation_events() { } } -#[test] -fn run_from_payload_surfaces_malformed_input() { - let error = run_opencode_mutation_scope_from_payload("{bad", None) - .unwrap_err() - .to_string(); - assert!(error.contains("expected valid JSON"), "{error:?}"); -} - mod lifecycle_tests { use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; diff --git a/cli/src/services/hooks/pi_mutation_scope/tests.rs b/cli/src/services/hooks/pi_mutation_scope/tests.rs index a9ba41621..a307be4c3 100644 --- a/cli/src/services/hooks/pi_mutation_scope/tests.rs +++ b/cli/src/services/hooks/pi_mutation_scope/tests.rs @@ -42,108 +42,59 @@ fn tool_call(payload: &str) -> PiToolCall { } } -#[test] -fn empty_payload_is_rejected() { - let error = parse_pi_hook_event(" ").unwrap_err().to_string(); - assert_eq!( - error, - "Invalid Pi hook event payload from STDIN: expected a JSON object, got an empty payload." - ); -} +mod ingress_conformance { + use super::*; + use crate::services::hooks::mutation_scope_ingress_conformance::{ + self as conformance, mutation_scope_ingress_conformance_tests, + CheckoutResolutionConformance, IngressConformance, + }; -#[test] -fn non_object_json_is_rejected() { - for payload in ["[]", "\"ToolCall\"", "42", "null"] { - let error = parse_pi_hook_event(payload).unwrap_err().to_string(); - assert!( - error.contains("expected a JSON object"), - "payload {payload:?} produced {error:?}" - ); - } -} + struct PiIngressConformance; -#[test] -fn invalid_json_is_rejected() { - let error = parse_pi_hook_event("{not json").unwrap_err().to_string(); - assert!( - error.contains("Invalid Pi hook event payload from STDIN: expected valid JSON"), - "{error:?}" - ); -} + impl IngressConformance for PiIngressConformance { + const ADAPTER: &'static str = "Pi"; + const EVENT_NAME_FIELD: &'static str = HOOK_EVENT_NAME_FIELD; + const REQUIRED_START_FIELDS: &'static [&'static str] = &[ + SESSION_ID_FIELD, + TOOL_CALL_ID_FIELD, + CWD_FIELD, + TOOL_NAME_FIELD, + ]; + const OPTIONAL_START_FIELDS: &'static [(&'static str, &'static str)] = + &[(MODEL_FIELD, "openai-codex/gpt-5.5")]; + const UNSUPPORTED_EVENT_NAMES: &'static [&'static str] = + &["PreToolUse", "tool_call", "chat.params"]; -#[test] -fn unsupported_hook_event_name_is_rejected() { - for name in ["PreToolUse", "tool_call", "chat.params", ""] { - let payload = tool_event_json(name, &[]); - let error = parse_pi_hook_event(&payload).unwrap_err().to_string(); - assert!( - error.contains("hook_event_name"), - "name {name:?} produced {error:?}" - ); - } -} + fn tracked_start() -> Map { + serde_json::from_str(&tool_event_json(HOOK_EVENT_TOOL_CALL, &[])) + .expect("the tracked ToolCall fixture is a JSON object") + } -#[test] -fn missing_required_fields_are_rejected_without_fabricating_identity() { - for field in [ - SESSION_ID_FIELD, - TOOL_CALL_ID_FIELD, - CWD_FIELD, - TOOL_NAME_FIELD, - ] { - let mut object: Map = - serde_json::from_str(&tool_event_json(HOOK_EVENT_TOOL_CALL, &[])).unwrap(); - object.remove(field); - let payload = Value::Object(object).to_string(); + fn parse(payload: &str) -> Result<()> { + parse_pi_hook_event(payload).map(|_| ()) + } - let error = parse_pi_hook_event(&payload).unwrap_err().to_string(); - assert!( - error.contains(&format!("'{field}'")), - "missing {field} produced {error:?}" - ); - } -} + fn run(payload: &str) -> Result { + run_pi_mutation_scope_from_payload(payload, None) + } -#[test] -fn blank_required_fields_are_rejected() { - for field in [ - SESSION_ID_FIELD, - TOOL_CALL_ID_FIELD, - CWD_FIELD, - TOOL_NAME_FIELD, - ] { - let payload = tool_event_json( - HOOK_EVENT_TOOL_CALL, - &[(field, Value::String(" ".to_string()))], - ); - let error = parse_pi_hook_event(&payload).unwrap_err().to_string(); - assert!( - error.contains(&format!("field '{field}' must be a non-blank string")), - "blank {field} produced {error:?}" - ); + fn run_with_seams( + payload: &str, + resolve_git_dir: conformance::GitDirResolver, + seam: conformance::IngressSeam, + ) -> Result { + run_pi_mutation_scope_from_payload_with_seams(payload, None, resolve_git_dir, seam) + } } -} -#[test] -fn wrong_typed_fields_are_rejected() { - let payload = tool_event_json( - HOOK_EVENT_TOOL_CALL, - &[(TOOL_CALL_ID_FIELD, Value::Bool(true))], - ); - let error = parse_pi_hook_event(&payload).unwrap_err().to_string(); - assert!( - error.contains("field 'tool_call_id' must be a string"), - "{error:?}" - ); -} + impl CheckoutResolutionConformance for PiIngressConformance { + const CWD_FIELD: &'static str = CWD_FIELD; + const FAIL_CLOSED_MESSAGE: &'static str = FAIL_CLOSED_MESSAGE; + } -#[test] -fn wrong_typed_optional_model_is_rejected() { - let payload = tool_event_json(HOOK_EVENT_TOOL_CALL, &[(MODEL_FIELD, Value::Bool(false))]); - let error = parse_pi_hook_event(&payload).unwrap_err().to_string(); - assert!( - error.contains("field 'model' must be null, absent, or a non-blank string"), - "{error:?}" + mutation_scope_ingress_conformance_tests!(PiIngressConformance); + mutation_scope_ingress_conformance_tests!( + PiIngressConformance => tracked_start_fails_closed_when_its_checkout_cannot_be_resolved ); } @@ -281,20 +232,6 @@ fn provenance_without_model_evidence_is_null() { } } -#[test] -fn run_from_payload_fails_closed_when_a_tracked_start_cannot_resolve_its_checkout() { - let payload = tool_event_json( - HOOK_EVENT_TOOL_CALL, - &[( - CWD_FIELD, - Value::String("/nonexistent/sce/pi/checkout".to_string()), - )], - ); - let error = run_pi_mutation_scope_from_payload(&payload, None) - .expect_err("a tracked Start that cannot resolve its checkout must fail closed"); - assert!(error.to_string().contains(FAIL_CLOSED_MESSAGE), "{error:?}"); -} - #[test] fn run_from_payload_is_neutral_for_untracked_events() { for tool_name in ["read", "grep", "find", "ls", "probe_mutate"] { @@ -309,14 +246,6 @@ fn run_from_payload_is_neutral_for_untracked_events() { } } -#[test] -fn run_from_payload_surfaces_malformed_input() { - let error = run_pi_mutation_scope_from_payload("{bad", None) - .unwrap_err() - .to_string(); - assert!(error.contains("expected valid JSON"), "{error:?}"); -} - #[test] fn tool_execution_start_is_always_a_no_op_regardless_of_classification() { for tool_name in ["bash", "read"] {