252 lines
9.2 KiB
Rust
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 })),
|
|
)),
|
|
}
|
|
}
|
|
}
|