use super::super::router;
use super::test_state;
use crate::AppState;
use axum::body::{to_bytes, Body};
use axum::http::{Request, StatusCode};
use serde_json::{json, Value};
use tower::util::ServiceExt;
use trusty_common::memory_core::palace::PalaceId;
#[tokio::test]
async fn hook_fired_activity_emit_smoke() {
let state = test_state();
let app = router().with_state(state.clone());
let payload = serde_json::json!({
"palace_id": "alpha",
"palace_name": "alpha",
"hook_type": "UserPromptSubmit",
"injection_kind": "prompt-context",
"injection_length": 256,
"trigger_prompt_excerpt": "test prompt",
"duration_ms": 12,
});
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/v1/activity/hook")
.header("content-type", "application/json")
.body(Body::from(payload.to_string()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
state.flush_activity_writes().await;
let app = router().with_state(state);
let resp = app
.oneshot(
Request::builder()
.uri("/api/v1/activity?source=hook&limit=10")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
let v: Value = serde_json::from_slice(&bytes).unwrap();
let entries = v["entries"].as_array().expect("entries array");
assert!(
!entries.is_empty(),
"expected at least one hook activity row, got {entries:?}"
);
let first = &entries[0];
assert_eq!(first["source"], "hook");
assert_eq!(first["event_type"], "hook_fired");
assert_eq!(first["palace_id"], "alpha");
let body = &first["payload"];
assert_eq!(body["hook_type"], "UserPromptSubmit");
assert_eq!(body["injection_kind"], "prompt-context");
}
#[tokio::test]
async fn drawer_creator_attribution_http_default() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path().to_path_buf();
let state = AppState::new(root);
let palace = trusty_common::memory_core::Palace {
id: PalaceId::new("cred-default"),
name: "cred-default".to_string(),
description: None,
created_at: chrono::Utc::now(),
data_dir: state.data_root.join("cred-default"),
};
state
.registry
.create_palace(&state.data_root, palace)
.expect("create palace");
let app = router().with_state(state.clone());
let body = serde_json::json!({
"content": "hello world from anonymous client",
"tags": ["user-tag"],
});
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/v1/palaces/cred-default/drawers")
.header("content-type", "application/json")
.body(Body::from(body.to_string()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let app = router().with_state(state);
let resp = app
.oneshot(
Request::builder()
.uri("/api/v1/palaces/cred-default/drawers?limit=10")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = to_bytes(resp.into_body(), 8192).await.unwrap();
let v: Value = serde_json::from_slice(&bytes).unwrap();
let drawers = v.as_array().expect("drawers array");
assert_eq!(drawers.len(), 1, "expected one drawer, got {drawers:?}");
let tags: Vec<&str> = drawers[0]["tags"]
.as_array()
.expect("tags array")
.iter()
.filter_map(|t| t.as_str())
.collect();
assert!(
tags.contains(&"user-tag"),
"user-supplied tag must survive; got {tags:?}"
);
assert!(
tags.contains(&"creator:client=unknown-http-client"),
"expected default client tag; got {tags:?}"
);
assert!(
tags.contains(&"creator:source=http"),
"expected http source tag; got {tags:?}"
);
assert!(
tags.iter().any(|t| t.starts_with("creator:version=")),
"expected creator:version tag; got {tags:?}"
);
}
#[tokio::test]
async fn drawer_creator_attribution_http_header() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path().to_path_buf();
let state = AppState::new(root);
let palace = trusty_common::memory_core::Palace {
id: PalaceId::new("cred-header"),
name: "cred-header".to_string(),
description: None,
created_at: chrono::Utc::now(),
data_dir: state.data_root.join("cred-header"),
};
state
.registry
.create_palace(&state.data_root, palace)
.expect("create palace");
let app = router().with_state(state.clone());
let body = serde_json::json!({
"content": "this is enough content to pass the signal/noise filter applied by remember",
"tags": [],
});
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/v1/palaces/cred-header/drawers")
.header("content-type", "application/json")
.header("x-trusty-client-name", "qa-curl")
.header("x-trusty-client-cwd", "/tmp/qa")
.body(Body::from(body.to_string()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let app = router().with_state(state);
let resp = app
.oneshot(
Request::builder()
.uri("/api/v1/palaces/cred-header/drawers?limit=10")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let bytes = to_bytes(resp.into_body(), 8192).await.unwrap();
let v: Value = serde_json::from_slice(&bytes).unwrap();
let tags: Vec<&str> = v[0]["tags"]
.as_array()
.expect("tags")
.iter()
.filter_map(|t| t.as_str())
.collect();
assert!(
tags.contains(&"creator:client=qa-curl"),
"expected custom client tag; got {tags:?}"
);
assert!(
tags.contains(&"creator:cwd=/tmp/qa"),
"expected cwd tag from header; got {tags:?}"
);
}
#[tokio::test]
async fn drawer_creator_attribution_http_workstream_header() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path().to_path_buf();
let state = AppState::new(root);
let palace = trusty_common::memory_core::Palace {
id: PalaceId::new("cred-ws-header"),
name: "cred-ws-header".to_string(),
description: None,
created_at: chrono::Utc::now(),
data_dir: state.data_root.join("cred-ws-header"),
};
state
.registry
.create_palace(&state.data_root, palace)
.expect("create palace");
let app = router().with_state(state.clone());
let body = serde_json::json!({
"content": "this is enough content to pass the signal/noise filter applied by remember",
"tags": [],
});
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/v1/palaces/cred-ws-header/drawers")
.header("content-type", "application/json")
.header("x-trusty-client-workstream", "explicit-ws")
.header("x-trusty-client-cwd", "/tmp/no-worktrees-here")
.body(Body::from(body.to_string()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let app = router().with_state(state);
let resp = app
.oneshot(
Request::builder()
.uri("/api/v1/palaces/cred-ws-header/drawers?limit=10")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let bytes = to_bytes(resp.into_body(), 8192).await.unwrap();
let v: Value = serde_json::from_slice(&bytes).unwrap();
let tags: Vec<&str> = v[0]["tags"]
.as_array()
.expect("tags")
.iter()
.filter_map(|t| t.as_str())
.collect();
assert!(
tags.contains(&"creator:workstream=explicit-ws"),
"expected explicit workstream header tag; got {tags:?}"
);
assert!(
tags.contains(&"ws:explicit-ws"),
"expected bare ws: tag; got {tags:?}"
);
}
#[tokio::test]
async fn drawer_creator_attribution_mcp_default() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path().to_path_buf();
std::mem::forget(tmp);
let state = AppState::new(root);
state.set_ready();
let palace = trusty_common::memory_core::Palace {
id: PalaceId::new("cred-mcp"),
name: "cred-mcp".to_string(),
description: None,
created_at: chrono::Utc::now(),
data_dir: state.data_root.join("cred-mcp"),
};
state
.registry
.create_palace(&state.data_root, palace)
.expect("create palace");
let _ = crate::tools::dispatch_tool(
&state,
"memory_remember",
json!({
"palace": "cred-mcp",
"text": "remember a sentence with enough tokens to pass filters please",
"room": "General",
"tags": ["from-test"],
}),
)
.await
.expect("memory_remember dispatch");
let app = router().with_state(state);
let resp = app
.oneshot(
Request::builder()
.uri("/api/v1/palaces/cred-mcp/drawers?limit=10")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let bytes = to_bytes(resp.into_body(), 8192).await.unwrap();
let v: Value = serde_json::from_slice(&bytes).unwrap();
let drawers = v.as_array().expect("drawers array");
assert!(!drawers.is_empty(), "expected at least one drawer");
let tags: Vec<&str> = drawers[0]["tags"]
.as_array()
.expect("tags array")
.iter()
.filter_map(|t| t.as_str())
.collect();
assert!(
tags.contains(&"creator:client=trusty-memory-mcp"),
"expected MCP client tag; got {tags:?}"
);
assert!(
tags.contains(&"creator:source=mcp"),
"expected MCP source tag; got {tags:?}"
);
}
#[tokio::test]
async fn mcp_writes_carry_distinct_ws_tags_per_caller_over_rpc() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path().to_path_buf();
let state = AppState::new(root);
state.set_ready();
let palace = trusty_common::memory_core::Palace {
id: PalaceId::new("cred-multi-caller"),
name: "cred-multi-caller".to_string(),
description: None,
created_at: chrono::Utc::now(),
data_dir: state.data_root.join("cred-multi-caller"),
};
state
.registry
.create_palace(&state.data_root, palace)
.expect("create palace");
let rpc_call = |workstream: &str, marker: &str| {
serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "memory_remember",
"arguments": {
"palace": "cred-multi-caller",
"text": format!(
"distinct-caller regression content marker {marker} with enough tokens"
),
"room": "General",
"workstream": workstream,
"force": true,
}
}
})
};
for (workstream, marker) in [("ws-alpha", "alpha"), ("ws-beta", "beta")] {
let app = router().with_state(state.clone());
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/rpc")
.header("content-type", "application/json")
.body(Body::from(rpc_call(workstream, marker).to_string()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = to_bytes(resp.into_body(), 8192).await.unwrap();
let v: Value = serde_json::from_slice(&bytes).unwrap();
assert!(
v.get("error").is_none(),
"tools/call memory_remember for {workstream} must not error; got {v:?}"
);
let inner_text = v["result"]["content"][0]["text"]
.as_str()
.unwrap_or_default();
let inner: Value = serde_json::from_str(inner_text).unwrap_or_default();
assert_eq!(
inner["status"], "stored",
"expected a stored (not skipped) envelope for {workstream}; got {inner:?}"
);
}
let app = router().with_state(state);
let resp = app
.oneshot(
Request::builder()
.uri("/api/v1/palaces/cred-multi-caller/drawers?limit=10")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let bytes = to_bytes(resp.into_body(), 8192).await.unwrap();
let v: Value = serde_json::from_slice(&bytes).unwrap();
let drawers = v.as_array().expect("drawers array");
assert_eq!(drawers.len(), 2, "expected two drawers, got {drawers:?}");
for drawer in drawers {
let content = drawer["content"].as_str().unwrap_or_default();
let tags: Vec<&str> = drawer["tags"]
.as_array()
.expect("tags array")
.iter()
.filter_map(|t| t.as_str())
.collect();
if content.contains("marker alpha") {
assert!(
tags.contains(&"ws:ws-alpha") && tags.contains(&"creator:workstream=ws-alpha"),
"alpha drawer must carry its own ws tags; got {tags:?}"
);
assert!(
!tags.contains(&"ws:ws-beta") && !tags.contains(&"creator:workstream=ws-beta"),
"alpha drawer must NOT leak the beta caller's ws tags (daemon-vs-caller \
mis-attribution); got {tags:?}"
);
} else if content.contains("marker beta") {
assert!(
tags.contains(&"ws:ws-beta") && tags.contains(&"creator:workstream=ws-beta"),
"beta drawer must carry its own ws tags; got {tags:?}"
);
assert!(
!tags.contains(&"ws:ws-alpha") && !tags.contains(&"creator:workstream=ws-alpha"),
"beta drawer must NOT leak the alpha caller's ws tags (daemon-vs-caller \
mis-attribution); got {tags:?}"
);
} else {
panic!("drawer content did not match either marker: {content:?}");
}
}
}
#[tokio::test]
async fn hook_emit_failure_isolated() {
let _guard = crate::commands::env_test_lock().lock().await;
let tmp = tempfile::tempdir().expect("tempdir");
unsafe {
std::env::set_var(trusty_common::DATA_DIR_OVERRIDE_ENV, tmp.path());
}
let res =
crate::commands::prompt_context::handle_prompt_context_with_payload(String::new()).await;
unsafe {
std::env::remove_var(trusty_common::DATA_DIR_OVERRIDE_ENV);
}
assert!(
res.is_ok(),
"hook must complete even when daemon emit fails; got {res:?}"
);
}