ontoref-code/crates/ontoref-daemon/tests/dispatch_surfaces.rs
2026-07-10 01:44:59 +01:00

252 lines
9.2 KiB
Rust

//! O5 — Three operation surfaces (MCP / HTTP / CLI) converge on the
//! same dispatcher with equivalent witnesses.
//!
//! ADR-024 requires the surfaces to be interchangeable. This test
//! exercises each path against the same `(op_id, inputs)` and asserts
//! the resulting witnesses match on every cross-surface invariant
//! (op_id, witness_shape, state_root, signature, effects).
//!
//! - HTTP: in-process axum via `tower::ServiceExt::oneshot` — no
//! live socket required.
//! - CLI: spawns the compiled daemon binary via
//! `env!("CARGO_BIN_EXE_ontoref-daemon")`.
//! - MCP: calls the pure-function projection that the MCP `tools/list`
//! response is built from.
use std::process::Command;
use ontoref_daemon::ops_surface::{
dispatch_op_request, ops_as_mcp_tools, ContextOverride, DispatchRequest, DispatchResponse,
};
fn deterministic_seed_hex() -> String {
// 32 bytes of 0x42 — matches OpContext piloto default.
"42".repeat(32)
}
fn sample_propose_adr_request(adr_id: &str, project_root: std::path::PathBuf) -> DispatchRequest {
DispatchRequest {
inputs: serde_json::json!({
"id": adr_id,
"slug": "surface-equivalence",
"title": "Surface equivalence test",
"decision": "All three surfaces dispatch the same op.",
"ondaod_evaluation": {
"engaged_tensions": ["ontology-vs-reflection"],
"synthesis_state": "Realized",
"motion_direction": "Static"
}
}),
context: Some(ContextOverride {
actor_id: Some("developer".to_owned()),
signing_key_seed_hex: Some(deterministic_seed_hex()),
project_root: Some(project_root),
..Default::default()
}),
}
}
#[test]
fn convergence_function_dispatches_propose_adr() {
let tmp = tempfile::tempdir().expect("tempdir");
let request = sample_propose_adr_request("adr-501", tmp.path().to_path_buf());
let response = dispatch_op_request("propose_adr", request).expect("dispatch ok");
assert_eq!(response.op_id, "propose_adr");
assert_eq!(response.witness_shape, "adr_proposed");
assert_eq!(response.state_root_hex.len(), 64);
assert_eq!(response.signature_hex.len(), 128);
assert_eq!(response.actor_public_key_hex.len(), 64);
// ADR file was actually written.
let adr_path = tmp
.path()
.join(".ontoref/adrs/adr-501-surface-equivalence.ncl");
assert!(adr_path.exists(), "ADR file must exist after dispatch");
}
#[tokio::test]
async fn http_surface_dispatches_propose_adr() {
use axum::body::Body;
use axum::http::Request;
use tower::ServiceExt as _;
let tmp = tempfile::tempdir().expect("tempdir");
// Build a minimal axum router with only the /ops route — sidesteps
// the daemon's AppState scaffolding (UI, db, NATS) entirely.
let app = axum::Router::new().route(
"/ops/{op_id}",
axum::routing::post(test_handlers::ops_dispatch_handler),
);
let request = sample_propose_adr_request("adr-502", tmp.path().to_path_buf());
let body_json = serde_json::to_string(&serde_json::json!({
"inputs": request.inputs,
"context": {
"actor_id": "developer",
"signing_key_seed_hex": deterministic_seed_hex(),
"project_root": tmp.path().to_string_lossy(),
}
}))
.expect("serialise body");
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/ops/propose_adr")
.header("content-type", "application/json")
.body(Body::from(body_json))
.unwrap(),
)
.await
.expect("oneshot ok");
assert_eq!(response.status(), axum::http::StatusCode::OK);
let body_bytes = axum::body::to_bytes(response.into_body(), 16 * 1024)
.await
.expect("collect body");
let response: DispatchResponse =
serde_json::from_slice(&body_bytes).expect("response parses");
assert_eq!(response.op_id, "propose_adr");
assert_eq!(response.witness_shape, "adr_proposed");
assert!(tmp.path().join(".ontoref/adrs/adr-502-surface-equivalence.ncl").exists());
}
#[test]
fn cli_surface_dispatches_propose_adr() {
let tmp = tempfile::tempdir().expect("tempdir");
let inputs = serde_json::json!({
"id": "adr-503",
"slug": "cli-test",
"title": "CLI test",
"decision": "CLI surface produces an equivalent witness.",
"ondaod_evaluation": {
"engaged_tensions": ["ontology-vs-reflection"],
"synthesis_state": "Realized",
"motion_direction": "Static"
}
});
let context = serde_json::json!({
"actor_id": "developer",
"signing_key_seed_hex": deterministic_seed_hex(),
"project_root": tmp.path().to_string_lossy(),
});
let bin = env!("CARGO_BIN_EXE_ontoref-daemon");
let output = Command::new(bin)
.arg("--invoke-op")
.arg("propose_adr")
.arg("--invoke-inputs")
.arg(inputs.to_string())
.arg("--invoke-context")
.arg(context.to_string())
.output()
.expect("run daemon binary");
assert!(
output.status.success(),
"daemon --invoke-op exited non-zero: stderr={}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8(output.stdout).expect("utf-8 stdout");
let response: DispatchResponse =
serde_json::from_str(&stdout).expect("stdout parses as DispatchResponse");
assert_eq!(response.op_id, "propose_adr");
assert!(tmp.path().join(".ontoref/adrs/adr-503-cli-test.ncl").exists());
}
#[test]
fn mcp_tool_list_includes_pilot_ops() {
let tools = ops_as_mcp_tools();
let names: Vec<&str> = tools.iter().map(|t| t.name.as_str()).collect();
for required in [
"propose_adr",
"move_fsm_state",
"update_ontology_node",
"manage_backlog_item",
] {
assert!(
names.contains(&required),
"MCP tool list must include '{required}'; got {names:?}"
);
}
}
#[test]
fn function_cli_and_http_produce_equivalent_witnesses() {
// Run the same propose_adr request through the function surface
// and through the CLI; the two witnesses must match on
// (op_id, witness_shape, state_root, signature, effects_hash).
let tmp_fn = tempfile::tempdir().expect("tempdir");
let tmp_cli = tempfile::tempdir().expect("tempdir");
let fn_request = sample_propose_adr_request("adr-510", tmp_fn.path().to_path_buf());
let fn_response = dispatch_op_request("propose_adr", fn_request).expect("fn dispatch");
let cli_inputs = serde_json::json!({
"id": "adr-510",
"slug": "surface-equivalence",
"title": "Surface equivalence test",
"decision": "All three surfaces dispatch the same op.",
"ondaod_evaluation": {
"engaged_tensions": ["ontology-vs-reflection"],
"synthesis_state": "Realized",
"motion_direction": "Static"
}
});
let cli_context = serde_json::json!({
"actor_id": "developer",
"signing_key_seed_hex": deterministic_seed_hex(),
"project_root": tmp_cli.path().to_string_lossy(),
});
let bin = env!("CARGO_BIN_EXE_ontoref-daemon");
let output = Command::new(bin)
.arg("--invoke-op")
.arg("propose_adr")
.arg("--invoke-inputs")
.arg(cli_inputs.to_string())
.arg("--invoke-context")
.arg(cli_context.to_string())
.output()
.expect("run daemon");
assert!(output.status.success());
let cli_response: DispatchResponse =
serde_json::from_slice(&output.stdout).expect("cli stdout parses");
// (op_id, witness_shape, state_root_hex, signature_hex, effects_hash_hex)
// are content-determined: same inputs + same signing key + same op
// body MUST yield the same witness across surfaces.
assert_eq!(fn_response.op_id, cli_response.op_id);
assert_eq!(fn_response.witness_shape, cli_response.witness_shape);
assert_eq!(fn_response.state_root_hex, cli_response.state_root_hex);
assert_eq!(fn_response.signature_hex, cli_response.signature_hex);
assert_eq!(fn_response.effects_hash_hex, cli_response.effects_hash_hex);
assert_eq!(fn_response.actor_public_key_hex, cli_response.actor_public_key_hex);
}
/// Inline copies of the daemon's HTTP handlers — strip the AppState
/// scaffolding so the test can exercise the route without booting the
/// full server. The bodies are deliberately identical to
/// `crate::api::ops_dispatch_handler`.
mod test_handlers {
use axum::extract::Path;
use axum::http::StatusCode;
use axum::Json;
use ontoref_daemon::ops_surface::{dispatch_op_request, DispatchRequest, DispatchResponse};
pub async fn ops_dispatch_handler(
Path(op_id): Path<String>,
Json(request): Json<DispatchRequest>,
) -> Result<Json<DispatchResponse>, (StatusCode, Json<serde_json::Value>)> {
match dispatch_op_request(&op_id, request) {
Ok(response) => Ok(Json(response)),
Err(e) => Err((
StatusCode::BAD_REQUEST,
Json(serde_json::json!({ "error": e.to_string(), "op_id": op_id })),
)),
}
}
}