use super::*;
use crate::store::record::{
Category, ConfidenceScore, FileRecord, GotchaRecord, Priority, QualityScore, Record,
RecordLifecycle, RecordSource, RecordVersion, StalenessScore,
};
use crate::store::{PolicyRecord, Store};
fn make_gotcha_record(key: &str, files: &[&str]) -> Record {
let gotcha = GotchaRecord {
rule: "test rule".into(),
reason: "test reason".into(),
severity: Priority::High,
affected_files: files.iter().map(|s| s.to_string()).collect(),
ref_url: None,
discovered_session: 1_000_000,
confirmed: true,
confirmed_content: Default::default(),
};
Record {
key: key.to_string(),
value: "test rule because test reason".into(),
payload: serde_json::to_value(&gotcha).ok(),
category: Category::Gotcha,
priority: Priority::High,
tags: vec![],
created_at: 1_000_000,
updated_at: 1_000_000,
ref_url: None,
staleness: StalenessScore::fresh(),
lifecycle: RecordLifecycle::Active,
version: RecordVersion {
device_id: uuid::Uuid::new_v4(),
logical_clock: 1,
wall_clock: 1_000_000,
},
quality: QualityScore::layer0_default(),
access_count: 0,
last_accessed: 0,
source: RecordSource::DeveloperManual,
confidence: ConfidenceScore::for_new_record(&RecordSource::DeveloperManual),
gap_analysis_score: 0.0,
}
}
fn make_file_record(path: &str) -> Record {
let file = FileRecord {
path: path.to_string(),
purpose: String::new(),
entry_points: vec![],
imports: vec![],
gotcha_keys: vec![],
decision_keys: vec![],
todos: vec![],
unsafe_count: 0,
unwrap_count: 0,
change_frequency: 0,
last_author: None,
is_hotspot: false,
token_cost_estimate: 0,
last_modified_session: 0,
content_hash: None,
line_count: 0,
blast_radius: None,
propagated_staleness: None,
};
Record {
key: format!("file:{path}"),
value: String::new(),
payload: serde_json::to_value(&file).ok(),
category: Category::File,
priority: Priority::Normal,
tags: vec![],
created_at: 1_000_000,
updated_at: 1_000_000,
ref_url: None,
staleness: StalenessScore::fresh(),
lifecycle: RecordLifecycle::Active,
version: RecordVersion {
device_id: uuid::Uuid::new_v4(),
logical_clock: 1,
wall_clock: 1_000_000,
},
quality: QualityScore::layer0_default(),
access_count: 0,
last_accessed: 0,
source: RecordSource::StaticAnalysis,
confidence: ConfidenceScore::for_new_record(&RecordSource::StaticAnalysis),
gap_analysis_score: 0.0,
}
}
fn file_gotcha_keys(record: &Record) -> Vec<String> {
record
.payload
.as_ref()
.and_then(|p| p.get("gotcha_keys"))
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default()
}
async fn make_test_graph(store: Store) -> Arc<tokio::sync::RwLock<Graph>> {
let graph = Graph::load(store).await.expect("failed to load test graph");
Arc::new(tokio::sync::RwLock::new(graph))
}
async fn dispatch_with_graph(
graph: &Arc<tokio::sync::RwLock<Graph>>,
cmd: &str,
args: serde_json::Value,
) -> SocketResponse {
let req = SocketRequest {
cmd: cmd.to_string(),
version: Some(PROTOCOL_VERSION),
args,
};
socket_dispatch(graph, Path::new("/tmp/mati-test"), &req).await
}
#[tokio::test]
async fn socket_gotcha_write_adds_keys_to_file_records() {
let dir = tempfile::TempDir::new().unwrap();
let store = Store::open(dir.path()).await.unwrap();
store
.put("file:src/a.rs", &make_file_record("src/a.rs"))
.await
.unwrap();
store
.put("file:src/b.rs", &make_file_record("src/b.rs"))
.await
.unwrap();
let graph = make_test_graph(store).await;
let record = make_gotcha_record("gotcha:socket-test", &["src/a.rs", "src/b.rs"]);
let resp = dispatch_with_graph(&graph, "gotcha_write", serde_json::json!({
"record": record, "new_files": ["src/a.rs", "src/b.rs"], "old_files": [], "is_new": true,
})).await;
assert!(resp.ok, "gotcha_write failed: {:?}", resp.error);
let g = graph.read().await;
let a = g.store().get("file:src/a.rs").await.unwrap().unwrap();
let b = g.store().get("file:src/b.rs").await.unwrap().unwrap();
assert!(file_gotcha_keys(&a).contains(&"gotcha:socket-test".into()));
assert!(file_gotcha_keys(&b).contains(&"gotcha:socket-test".into()));
}
#[tokio::test]
async fn socket_gotcha_write_edit_removes_key_from_old_file() {
let dir = tempfile::TempDir::new().unwrap();
let store = Store::open(dir.path()).await.unwrap();
store
.put("file:src/a.rs", &make_file_record("src/a.rs"))
.await
.unwrap();
store
.put("file:src/b.rs", &make_file_record("src/b.rs"))
.await
.unwrap();
let graph = make_test_graph(store).await;
let record = make_gotcha_record("gotcha:edit-socket", &["src/a.rs"]);
let resp = dispatch_with_graph(
&graph,
"gotcha_write",
serde_json::json!({
"record": record, "new_files": ["src/a.rs"], "old_files": [], "is_new": true,
}),
)
.await;
assert!(resp.ok);
let record2 = make_gotcha_record("gotcha:edit-socket", &["src/b.rs"]);
let resp2 = dispatch_with_graph(&graph, "gotcha_write", serde_json::json!({
"record": record2, "new_files": ["src/b.rs"], "old_files": ["src/a.rs"], "is_new": false,
})).await;
assert!(resp2.ok);
let g = graph.read().await;
let a = g.store().get("file:src/a.rs").await.unwrap().unwrap();
let b = g.store().get("file:src/b.rs").await.unwrap().unwrap();
assert!(!file_gotcha_keys(&a).contains(&"gotcha:edit-socket".into()));
assert!(file_gotcha_keys(&b).contains(&"gotcha:edit-socket".into()));
}
#[tokio::test]
async fn socket_gotcha_tombstone_removes_keys_from_file_records() {
let dir = tempfile::TempDir::new().unwrap();
let store = Store::open(dir.path()).await.unwrap();
store
.put("file:src/a.rs", &make_file_record("src/a.rs"))
.await
.unwrap();
store
.put("file:src/b.rs", &make_file_record("src/b.rs"))
.await
.unwrap();
let graph = make_test_graph(store).await;
let record = make_gotcha_record("gotcha:tomb-socket", &["src/a.rs", "src/b.rs"]);
let resp = dispatch_with_graph(&graph, "gotcha_write", serde_json::json!({
"record": record, "new_files": ["src/a.rs", "src/b.rs"], "old_files": [], "is_new": true,
})).await;
assert!(resp.ok);
let resp2 = dispatch_with_graph(
&graph,
"gotcha_tombstone",
serde_json::json!({
"key": "gotcha:tomb-socket", "affected_files": ["src/a.rs", "src/b.rs"],
}),
)
.await;
assert!(resp2.ok, "gotcha_tombstone failed: {:?}", resp2.error);
let g = graph.read().await;
let rec = g.store().get("gotcha:tomb-socket").await.unwrap().unwrap();
assert!(matches!(rec.lifecycle, RecordLifecycle::Tombstoned { .. }));
let a = g.store().get("file:src/a.rs").await.unwrap().unwrap();
let b = g.store().get("file:src/b.rs").await.unwrap().unwrap();
assert!(file_gotcha_keys(&a).is_empty());
assert!(file_gotcha_keys(&b).is_empty());
}
#[tokio::test]
async fn socket_gotcha_write_rejects_duplicate_key() {
let dir = tempfile::TempDir::new().unwrap();
let store = Store::open(dir.path()).await.unwrap();
let record1 = make_gotcha_record("gotcha:dup-socket", &["src/a.rs"]);
store.put("gotcha:dup-socket", &record1).await.unwrap();
let graph = make_test_graph(store).await;
let record2 = make_gotcha_record("gotcha:dup-socket", &["src/b.rs"]);
let resp = dispatch_with_graph(
&graph,
"gotcha_write",
serde_json::json!({
"record": record2, "new_files": ["src/b.rs"], "old_files": [], "is_new": true,
}),
)
.await;
assert!(!resp.ok, "duplicate key should be rejected");
assert!(resp
.error
.as_deref()
.unwrap_or("")
.contains("already exists"));
let g = graph.read().await;
let original = g.store().get("gotcha:dup-socket").await.unwrap().unwrap();
let payload = original.payload_as::<GotchaRecord>().unwrap();
assert_eq!(payload.affected_files, vec!["src/a.rs"]);
}
#[tokio::test]
async fn oversized_request_returns_frame_too_large_with_response() {
use super::super::protocol::MAX_FRAME_SIZE;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt};
let dir = tempfile::TempDir::new().unwrap();
let store = Store::open(dir.path()).await.unwrap();
let graph = make_test_graph(store).await;
let (client, server) = UnixStream::pair().unwrap();
let peer = super::super::metadata::PeerContext {
uid: 501,
pid: None,
};
let oversized = "x".repeat(MAX_FRAME_SIZE + 100);
let payload = format!("{oversized}\n");
let (client_read, client_write) = client.into_split();
let write_handle = tokio::spawn(async move {
let mut w = client_write;
w.write_all(payload.as_bytes()).await.unwrap();
w.shutdown().await.unwrap();
});
let handle_result = socket_handle_connection(
graph,
Arc::new(tokio::sync::RwLock::new(
crate::hooks::policy_match::PolicyMatcherSet::empty(),
)),
dir.path(),
server,
peer,
uuid::Uuid::nil(),
)
.await;
assert!(handle_result.is_ok());
write_handle.await.unwrap();
let mut reader = tokio::io::BufReader::new(client_read);
let mut line = String::new();
reader.read_line(&mut line).await.unwrap();
let resp: serde_json::Value = serde_json::from_str(line.trim()).unwrap();
assert_eq!(resp["status"], "err");
assert_eq!(resp["code"], "frame_too_large");
assert!(
resp["message"]
.as_str()
.unwrap()
.contains(&MAX_FRAME_SIZE.to_string()),
"error message should mention the size limit"
);
}
#[tokio::test]
async fn normal_sized_request_is_not_rejected_by_size_check() {
use super::super::protocol::MAX_FRAME_SIZE;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt};
let dir = tempfile::TempDir::new().unwrap();
let store = Store::open(dir.path()).await.unwrap();
let graph = make_test_graph(store).await;
let (client, server) = UnixStream::pair().unwrap();
let peer = super::super::metadata::PeerContext {
uid: 501,
pid: None,
};
let request = serde_json::json!({
"v": 2,
"id": uuid::Uuid::new_v4(),
"session": uuid::Uuid::nil(),
"cmd": { "type": "ping" }
});
let payload = format!("{}\n", serde_json::to_string(&request).unwrap());
assert!(
payload.len() < MAX_FRAME_SIZE,
"test payload should be small"
);
let (client_read, client_write) = client.into_split();
let write_handle = tokio::spawn(async move {
let mut w = client_write;
w.write_all(payload.as_bytes()).await.unwrap();
w.shutdown().await.unwrap();
});
let handle_result = socket_handle_connection(
graph,
Arc::new(tokio::sync::RwLock::new(
crate::hooks::policy_match::PolicyMatcherSet::empty(),
)),
dir.path(),
server,
peer,
uuid::Uuid::nil(),
)
.await;
assert!(handle_result.is_ok());
write_handle.await.unwrap();
let mut reader = tokio::io::BufReader::new(client_read);
let mut line = String::new();
reader.read_line(&mut line).await.unwrap();
let resp: serde_json::Value = serde_json::from_str(line.trim()).unwrap();
assert_eq!(resp["status"], "ok", "ping should succeed, got: {resp}");
}
#[tokio::test]
async fn policy_write_round_trips_over_socket_while_store_is_held() {
use tokio::io::{AsyncBufReadExt, AsyncWriteExt};
let dir = tempfile::TempDir::new().unwrap();
let store = Store::open(dir.path()).await.unwrap();
let graph = make_test_graph(store).await;
let (client, server) = UnixStream::pair().unwrap();
let request = serde_json::json!({
"v": 2,
"id": uuid::Uuid::new_v4(),
"session": uuid::Uuid::nil(),
"cmd": {
"type": "policy_write",
"op": "create",
"key": "policy:socket",
"policy": {
"name": "Socket policy",
"rule": "Consult first.",
"reason": "The schema changes because production is live.",
"scope": "repo",
"mode": "block",
"trigger": {"host_glob": "*prod*"},
"requires": {
"key": "schema:orders",
"via": ["mem_get"],
"freshness": {"ttl_secs": 900}
},
"stage": "enforce",
"severity": "high",
"created_by": "test"
}
}
});
let (client_read, mut client_write) = client.into_split();
client_write
.write_all(format!("{}\n", request).as_bytes())
.await
.unwrap();
client_write.shutdown().await.unwrap();
socket_handle_connection(
graph.clone(),
Arc::new(tokio::sync::RwLock::new(
crate::hooks::policy_match::PolicyMatcherSet::empty(),
)),
dir.path(),
server,
super::super::metadata::PeerContext {
uid: 501,
pid: None,
},
uuid::Uuid::nil(),
)
.await
.unwrap();
let mut reader = tokio::io::BufReader::new(client_read);
let mut line = String::new();
reader.read_line(&mut line).await.unwrap();
let response: serde_json::Value = serde_json::from_str(line.trim()).unwrap();
assert_eq!(response["status"], "ok");
let g = graph.read().await;
let record = g.store().get("policy:socket").await.unwrap().unwrap();
let policy = record.payload_as::<PolicyRecord>().unwrap();
assert_eq!(policy.name, "Socket policy");
}
async fn spawn_canned_responder(
sock_path: std::path::PathBuf,
responses: Vec<serde_json::Value>,
) -> tokio::task::JoinHandle<()> {
let listener = tokio::net::UnixListener::bind(&sock_path).expect("bind responder socket");
tokio::spawn(async move {
for resp in responses {
let (stream, _) = match listener.accept().await {
Ok(s) => s,
Err(_) => return,
};
let (reader, mut writer) = stream.into_split();
let mut buf_reader = tokio::io::BufReader::new(reader);
let mut line = String::new();
let _ = tokio::io::AsyncBufReadExt::read_line(&mut buf_reader, &mut line).await;
let mut bytes = serde_json::to_vec(&resp).unwrap();
bytes.push(b'\n');
let _ = tokio::io::AsyncWriteExt::write_all(&mut writer, &bytes).await;
let _ = tokio::io::AsyncWriteExt::shutdown(&mut writer).await;
}
})
}
#[tokio::test]
async fn mcp_call_after_daemon_restart_does_not_kill_transport() {
let dir = tempfile::TempDir::new().unwrap();
let root = dir.path().to_path_buf();
let sock_path = root.join("mati.sock");
let session_before = uuid::Uuid::new_v4();
let session_after = uuid::Uuid::new_v4();
let meta_before = super::super::metadata::DaemonMetadata {
pid: std::process::id(),
session: session_before,
owner: super::super::metadata::DaemonOwner::Daemon,
version: String::new(),
};
super::super::metadata::publish_metadata(&root, &meta_before).unwrap();
let responder_handle = spawn_canned_responder(
sock_path.clone(),
vec![
serde_json::json!({
"v": 2,
"id": uuid::Uuid::new_v4(),
"status": "err",
"code": "session_mismatch",
"message": "session mismatch: re-read daemon metadata and retry",
}),
serde_json::json!({
"v": 2,
"id": uuid::Uuid::new_v4(),
"status": "ok",
"data": "pong",
}),
],
)
.await;
let root_for_rotate = root.clone();
let rotate_handle = tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(20)).await;
let meta_after = super::super::metadata::DaemonMetadata {
pid: std::process::id(),
session: session_after,
owner: super::super::metadata::DaemonOwner::Daemon,
version: String::new(),
};
super::super::metadata::publish_metadata(&root_for_rotate, &meta_after).unwrap();
});
let result = tokio::time::timeout(
Duration::from_secs(5),
super::proxy_daemon_result(&root, "ping", serde_json::json!({})),
)
.await
.expect("proxy_daemon_result should resolve within 5s — retry path appears wedged");
rotate_handle.await.unwrap();
responder_handle.abort();
match result {
super::ProxyDaemonResult::Ok(v) => {
let ok = v.get("ok") == Some(&serde_json::Value::Bool(true));
let code = v.get("code").and_then(|c| c.as_str()).unwrap_or("");
assert!(
ok,
"second attempt should succeed after metadata rotation, \
but caller saw the first attempt's session_mismatch envelope: \
ok={ok} code={code:?} v={v}"
);
}
other => panic!(
"expected Ok(true) after auto-reconnect, got {other:?}; \
the daemon-restart retry path is not engaging"
),
}
}
#[tokio::test]
async fn mcp_call_session_mismatch_no_retry_target_returns_envelope() {
let dir = tempfile::TempDir::new().unwrap();
let root = dir.path().to_path_buf();
let sock_path = root.join("mati.sock");
let session = uuid::Uuid::new_v4();
let meta = super::super::metadata::DaemonMetadata {
pid: std::process::id(),
session,
owner: super::super::metadata::DaemonOwner::Daemon,
version: String::new(),
};
super::super::metadata::publish_metadata(&root, &meta).unwrap();
let responder_handle = spawn_canned_responder(
sock_path.clone(),
vec![
serde_json::json!({
"v": 2,
"id": uuid::Uuid::new_v4(),
"status": "err",
"code": "session_mismatch",
"message": "session mismatch (1)",
}),
serde_json::json!({
"v": 2,
"id": uuid::Uuid::new_v4(),
"status": "err",
"code": "session_mismatch",
"message": "session mismatch (2)",
}),
],
)
.await;
let result = tokio::time::timeout(
Duration::from_secs(5),
super::proxy_daemon_result(&root, "ping", serde_json::json!({})),
)
.await
.expect("proxy_daemon_result must resolve within 5s");
responder_handle.abort();
match result {
super::ProxyDaemonResult::Ok(v) => {
assert_eq!(v.get("ok"), Some(&serde_json::Value::Bool(false)));
assert_eq!(
v.get("code").and_then(|c| c.as_str()),
Some("session_mismatch")
);
}
other => panic!("expected structured Ok envelope, got {other:?}"),
}
}
#[tokio::test]
async fn proxy_daemon_result_handles_mem_get_translation_no_panic() {
let dir = tempfile::TempDir::new().unwrap();
let result = super::proxy_daemon_result(
dir.path(),
"mem_get",
serde_json::json!({ "key": "file:src/main.rs" }),
)
.await;
assert!(
matches!(result, super::ProxyDaemonResult::NotRunning),
"mem_get without daemon must return NotRunning, got {result:?}"
);
}
#[tokio::test]
async fn proxy_daemon_result_handles_mem_bootstrap_translation_no_panic() {
let dir = tempfile::TempDir::new().unwrap();
let result = super::proxy_daemon_result(
dir.path(),
"mem_bootstrap",
serde_json::json!({ "context_files": ["src/lib.rs"] }),
)
.await;
assert!(
matches!(result, super::ProxyDaemonResult::NotRunning),
"mem_bootstrap without daemon must return NotRunning, got {result:?}"
);
}
#[tokio::test]
async fn proxy_daemon_v2_typed_path_handles_mem_set_mutations_no_panic() {
let dir = tempfile::TempDir::new().unwrap();
let cmd = super::super::protocol::Command::GotchaConfirm(
super::super::protocol::GotchaConfirmInput {
key: "gotcha:test".into(),
via_elicitation: false,
},
);
let result = super::proxy_daemon_v2(dir.path(), cmd).await;
assert!(
matches!(result, super::ProxyDaemonResult::NotRunning),
"typed proxy_daemon_v2 must return NotRunning when daemon is absent, got {result:?}"
);
}