#![cfg(test)]
#![allow(clippy::too_many_lines)]
use super::validation::{OnConflict, default_on_conflict_for_client};
use super::*;
use crate::config::ResolvedTtl;
use crate::embeddings::test_support::MockEmbedder;
use crate::hnsw::VectorIndex;
use crate::models::{ConfidenceSource, Tier};
use crate::storage as db;
fn fresh_conn() -> rusqlite::Connection {
db::open(std::path::Path::new(":memory:")).expect("open in-memory db")
}
fn db_path() -> std::path::PathBuf {
std::path::PathBuf::from(":memory:")
}
fn base_params(title: &str) -> Value {
json!({
"title": title,
"content": format!("This is the body of {title}, long enough to be meaningful prose."),
"namespace": "test-ns",
"tier": Tier::Mid.as_str(),
"tags": ["tag1"],
"priority": 5,
"confidence": 0.9,
"source": "claude",
"agent_id": "ai:alice",
})
}
#[test]
fn on_conflict_parse_variants() {
assert_eq!(OnConflict::parse("error").unwrap(), OnConflict::Error);
assert_eq!(OnConflict::parse("merge").unwrap(), OnConflict::Merge);
assert_eq!(OnConflict::parse("version").unwrap(), OnConflict::Version);
assert!(OnConflict::parse("nope").is_err());
}
#[test]
fn default_on_conflict_for_client_matrix() {
assert_eq!(default_on_conflict_for_client(None), OnConflict::Merge);
assert_eq!(
default_on_conflict_for_client(Some("ai:claude-code@host:pid-1")),
OnConflict::Error
);
assert_eq!(
default_on_conflict_for_client(Some("AI:Claude-Code@whatever")),
OnConflict::Error,
"case-insensitive prefix match"
);
assert_eq!(
default_on_conflict_for_client(Some("ai:ai-memory-cli/v2-something")),
OnConflict::Error
);
assert_eq!(
default_on_conflict_for_client(Some("ai:unknown-client@host:pid-1")),
OnConflict::Merge
);
}
#[test]
fn happy_path_basic_store() {
let conn = fresh_conn();
let db_path = db_path();
let ttl = ResolvedTtl::default();
let resp = handle_store(
&conn,
&db_path,
&base_params("first"),
None,
None,
None,
&ttl,
false,
None,
None,
None,
)
.expect("ok");
assert!(resp["id"].is_string());
assert_eq!(resp["title"].as_str(), Some("first"));
assert_eq!(resp["agent_id"].as_str(), Some("ai:alice"));
}
#[test]
fn happy_path_with_embedder() {
let conn = fresh_conn();
let db_path = db_path();
let ttl = ResolvedTtl::default();
let mock = MockEmbedder::new_local().expect("mock");
let idx = VectorIndex::empty();
let resp = handle_store(
&conn,
&db_path,
&base_params("embedded"),
Some(&mock as &dyn Embed),
None,
Some(&idx),
&ttl,
false,
None,
None,
None,
)
.expect("ok");
let id = resp["id"].as_str().unwrap();
let emb = db::get_embedding(&conn, id).expect("ok").expect("some");
assert_eq!(emb.len(), 384);
}
#[test]
fn missing_title_errors() {
let conn = fresh_conn();
let db_path = db_path();
let ttl = ResolvedTtl::default();
let err = handle_store(
&conn,
&db_path,
&json!({"content": "body"}),
None,
None,
None,
&ttl,
false,
None,
None,
None,
)
.unwrap_err();
assert!(err.contains("title"));
}
#[test]
fn missing_content_errors() {
let conn = fresh_conn();
let db_path = db_path();
let ttl = ResolvedTtl::default();
let err = handle_store(
&conn,
&db_path,
&json!({"title": "t"}),
None,
None,
None,
&ttl,
false,
None,
None,
None,
)
.unwrap_err();
assert!(err.contains("content"));
}
#[test]
fn invalid_tier_errors() {
let conn = fresh_conn();
let db_path = db_path();
let ttl = ResolvedTtl::default();
let mut params = base_params("bt");
params["tier"] = json!("flibbertigibbet");
let err = handle_store(
&conn, &db_path, ¶ms, None, None, None, &ttl, false, None, None, None,
)
.unwrap_err();
assert!(err.contains("invalid tier"));
}
#[test]
fn empty_title_errors() {
let conn = fresh_conn();
let db_path = db_path();
let ttl = ResolvedTtl::default();
let mut params = base_params("x");
params["title"] = json!("");
let err = handle_store(
&conn, &db_path, ¶ms, None, None, None, &ttl, false, None, None, None,
)
.unwrap_err();
assert!(!err.is_empty());
}
#[test]
fn invalid_namespace_errors() {
let conn = fresh_conn();
let db_path = db_path();
let ttl = ResolvedTtl::default();
let mut params = base_params("ns");
params["namespace"] = json!("has space");
let err = handle_store(
&conn, &db_path, ¶ms, None, None, None, &ttl, false, None, None, None,
)
.unwrap_err();
assert!(!err.is_empty());
}
#[test]
fn invalid_priority_errors() {
let conn = fresh_conn();
let db_path = db_path();
let ttl = ResolvedTtl::default();
let mut params = base_params("p");
params["priority"] = json!(99);
let err = handle_store(
&conn, &db_path, ¶ms, None, None, None, &ttl, false, None, None, None,
)
.unwrap_err();
assert!(!err.is_empty());
}
#[test]
fn invalid_on_conflict_errors() {
let conn = fresh_conn();
let db_path = db_path();
let ttl = ResolvedTtl::default();
let mut params = base_params("oc");
params["on_conflict"] = json!("bogus");
let err = handle_store(
&conn, &db_path, ¶ms, None, None, None, &ttl, false, None, None, None,
)
.unwrap_err();
assert!(err.contains("invalid on_conflict"));
}
#[test]
fn priority_extreme_saturates_and_validates() {
let conn = fresh_conn();
let db_path = db_path();
let ttl = ResolvedTtl::default();
let mut params = base_params("p");
params["priority"] = json!(9_999_999_999_i64);
let err = handle_store(
&conn, &db_path, ¶ms, None, None, None, &ttl, false, None, None, None,
)
.unwrap_err();
assert!(!err.is_empty());
}
#[test]
fn on_conflict_error_rejects_duplicate() {
let conn = fresh_conn();
let db_path = db_path();
let ttl = ResolvedTtl::default();
let mut params = base_params("dup");
params["on_conflict"] = json!("error");
let _ = handle_store(
&conn, &db_path, ¶ms, None, None, None, &ttl, false, None, None, None,
)
.expect("first");
let err = handle_store(
&conn, &db_path, ¶ms, None, None, None, &ttl, false, None, None, None,
)
.unwrap_err();
assert!(err.contains("CONFLICT"));
}
#[test]
fn on_conflict_version_suffixes_title() {
let conn = fresh_conn();
let db_path = db_path();
let ttl = ResolvedTtl::default();
let mut params = base_params("ver");
params["on_conflict"] = json!("version");
let r1 = handle_store(
&conn, &db_path, ¶ms, None, None, None, &ttl, false, None, None, None,
)
.expect("first");
let r2 = handle_store(
&conn, &db_path, ¶ms, None, None, None, &ttl, false, None, None, None,
)
.expect("second");
assert_eq!(r1["title"].as_str(), Some("ver"));
assert_ne!(r2["title"].as_str(), Some("ver"));
assert!(r2["title"].as_str().unwrap().contains("ver"));
}
#[test]
fn on_conflict_merge_dedup_branch() {
let conn = fresh_conn();
let db_path = db_path();
let ttl = ResolvedTtl::default();
let mut params = base_params("merged");
params["on_conflict"] = json!("merge");
let r1 = handle_store(
&conn, &db_path, ¶ms, None, None, None, &ttl, false, None, None, None,
)
.expect("first");
let r2 = handle_store(
&conn, &db_path, ¶ms, None, None, None, &ttl, false, None, None, None,
)
.expect("second");
assert_eq!(r1["id"], r2["id"], "dedup yields same id");
assert_eq!(r2["duplicate"].as_bool(), Some(true));
}
#[test]
fn issue_1592_upsert_response_echoes_stored_tier_not_requested() {
let conn = fresh_conn();
let db_path = db_path();
let ttl = ResolvedTtl::default();
let mut first = base_params("tier-echo-1592");
first["tier"] = json!(Tier::Long.as_str());
first["on_conflict"] = json!("merge");
let r1 = handle_store(
&conn, &db_path, &first, None, None, None, &ttl, false, None, None, None,
)
.expect("seed long row");
assert_eq!(r1["tier"].as_str(), Some(Tier::Long.as_str()));
let mut second = base_params("tier-echo-1592");
second["tier"] = json!(Tier::Short.as_str());
second["on_conflict"] = json!("merge");
let r2 = handle_store(
&conn, &db_path, &second, None, None, None, &ttl, false, None, None, None,
)
.expect("downgrade-attempt upsert");
assert_eq!(r2["duplicate"].as_bool(), Some(true));
assert_eq!(r2["action"].as_str(), Some("updated existing memory"));
let row_id = r2["id"].as_str().expect("id");
let stored = db::get(&conn, row_id).expect("get").expect("row exists");
assert_eq!(stored.tier, Tier::Long, "storage keeps max tier");
assert_eq!(
r2["tier"].as_str(),
Some(Tier::Long.as_str()),
"#1592: response.tier must equal the stored tier, not the requested one"
);
assert_eq!(
r2["namespace"].as_str(),
Some(stored.namespace.as_str()),
"namespace echo comes from the post-write row too"
);
}
#[test]
fn merge_dedup_reembeds_on_content_change() {
let conn = fresh_conn();
let db_path = db_path();
let ttl = ResolvedTtl::default();
let mock = MockEmbedder::new_local().expect("mock");
let idx = VectorIndex::empty();
let mut params = base_params("dup-emb");
params["on_conflict"] = json!("merge");
let _ = handle_store(
&conn,
&db_path,
¶ms,
Some(&mock as &dyn Embed),
None,
Some(&idx),
&ttl,
false,
None,
None,
None,
)
.expect("first");
params["content"] = json!("Now this is a brand new body that differs from the first.");
let r2 = handle_store(
&conn,
&db_path,
¶ms,
Some(&mock as &dyn Embed),
None,
Some(&idx),
&ttl,
false,
None,
None,
None,
)
.expect("second");
assert_eq!(r2["duplicate"].as_bool(), Some(true));
}
#[test]
fn idempotent_merge_default_for_unknown_client() {
let conn = fresh_conn();
let db_path = db_path();
let ttl = ResolvedTtl::default();
let params = base_params("idem");
let r1 = handle_store(
&conn,
&db_path,
¶ms,
None,
None,
None,
&ttl,
false,
Some("ai:unknown@host"),
None,
None,
)
.expect("first");
let r2 = handle_store(
&conn,
&db_path,
¶ms,
None,
None,
None,
&ttl,
false,
Some("ai:unknown@host"),
None,
None,
)
.expect("second");
assert_eq!(r1["id"], r2["id"]);
}
#[test]
fn scope_validated_and_merged_into_metadata() {
let conn = fresh_conn();
let db_path = db_path();
let ttl = ResolvedTtl::default();
let mut params = base_params("scoped");
params["scope"] = json!("team");
let resp = handle_store(
&conn, &db_path, ¶ms, None, None, None, &ttl, false, None, None, None,
)
.expect("ok");
let mem = db::get(&conn, resp["id"].as_str().unwrap())
.unwrap()
.unwrap();
assert_eq!(mem.metadata["scope"].as_str(), Some("team"));
}
#[test]
fn agent_id_via_metadata_inline() {
let conn = fresh_conn();
let db_path = db_path();
let ttl = ResolvedTtl::default();
let resp = handle_store(
&conn,
&db_path,
&json!({
"title": "mid",
"content": "long enough content body for the post-store autonomy hook gate",
"namespace": "ns",
"metadata": {"agent_id": "ai:bob"},
}),
None,
None,
None,
&ttl,
false,
None,
None,
None,
)
.expect("ok");
assert_eq!(resp["agent_id"].as_str(), Some("ai:bob"));
}
#[test]
fn autonomy_hook_skipped_disabled_no_field_when_off() {
let conn = fresh_conn();
let db_path = db_path();
let ttl = ResolvedTtl::default();
let resp = handle_store(
&conn,
&db_path,
&base_params("auto-off"),
None,
None,
None,
&ttl,
false, None,
None,
None,
)
.expect("ok");
assert!(resp.get("autonomy_hook_skipped").is_none());
}
#[test]
fn autonomy_hook_skipped_no_llm_reason() {
let conn = fresh_conn();
let db_path = db_path();
let ttl = ResolvedTtl::default();
let resp = handle_store(
&conn,
&db_path,
&base_params("no-llm"),
None,
None,
None,
&ttl,
true, None,
None,
None,
)
.expect("ok");
assert_eq!(resp["autonomy_hook_skipped"].as_str(), Some("no_llm"));
}
#[test]
fn autonomy_hook_skipped_content_too_short() {
let conn = fresh_conn();
let db_path = db_path();
let ttl = ResolvedTtl::default();
let llm = Some(crate::llm::OllamaClient::new_for_testing("dummy-model"));
let resp = handle_store(
&conn,
&db_path,
&json!({
"title": "tiny",
"content": "short",
"namespace": "ns",
}),
None,
llm.as_ref(),
None,
&ttl,
true,
None,
None,
None,
)
.expect("ok");
assert_eq!(
resp["autonomy_hook_skipped"].as_str(),
Some("content_too_short")
);
}
#[test]
fn autonomy_hook_skipped_internal_namespace() {
let conn = fresh_conn();
let db_path = db_path();
let ttl = ResolvedTtl::default();
let llm = Some(crate::llm::OllamaClient::new_for_testing("dummy-model"));
let resp = handle_store(
&conn,
&db_path,
&json!({
"title": "internal",
"content": "This content is long enough to exceed AUTONOMY_MIN_CONTENT_LEN clearly here.",
"namespace": "_internal",
}),
None,
llm.as_ref(),
None,
&ttl,
true,
None,
None,
None,
)
.expect("ok");
assert_eq!(
resp["autonomy_hook_skipped"].as_str(),
Some("internal_namespace")
);
}
fn lock_rules() -> std::sync::MutexGuard<'static, ()> {
crate::mcp::SHARED_PERMISSION_RULES_GUARD
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
struct RulesGuard {
_rules: std::sync::MutexGuard<'static, ()>,
_mode: std::sync::MutexGuard<'static, ()>,
}
impl Drop for RulesGuard {
fn drop(&mut self) {
crate::permissions::clear_active_permission_rules_for_test();
crate::config::clear_permissions_mode_override_for_test();
}
}
fn rules_scope() -> RulesGuard {
let mode = crate::config::lock_permissions_mode_for_test();
let rules = lock_rules();
crate::permissions::clear_active_permission_rules_for_test();
crate::config::override_active_permissions_mode_for_test(
crate::config::PermissionsMode::Advisory,
);
RulesGuard {
_rules: rules,
_mode: mode,
}
}
#[test]
fn k9_deny_rule_short_circuits_store() {
use crate::permissions::{PermissionRule, RuleDecision, set_active_permission_rules};
let _g = rules_scope();
set_active_permission_rules(vec![PermissionRule {
namespace_pattern: "k9-deny-store".to_string(),
op: "memory_store".to_string(),
agent_pattern: "*".to_string(),
decision: RuleDecision::Deny,
reason: Some("blocked".to_string()),
}]);
let conn = fresh_conn();
let db_path = db_path();
let ttl = ResolvedTtl::default();
let mut params = base_params("denied");
params["namespace"] = json!("k9-deny-store");
let err = handle_store(
&conn, &db_path, ¶ms, None, None, None, &ttl, false, None, None, None,
)
.unwrap_err();
assert!(err.contains("denied"), "got: {err}");
}
#[test]
fn k9_ask_rule_returns_ask_envelope_for_store() {
use crate::permissions::{PermissionRule, RuleDecision, set_active_permission_rules};
let _g = rules_scope();
set_active_permission_rules(vec![PermissionRule {
namespace_pattern: "k9-ask-store".to_string(),
op: "memory_store".to_string(),
agent_pattern: "*".to_string(),
decision: RuleDecision::Ask,
reason: Some("operator approval".to_string()),
}]);
let conn = fresh_conn();
let db_path = db_path();
let ttl = ResolvedTtl::default();
let mut params = base_params("ask");
params["namespace"] = json!("k9-ask-store");
let out = handle_store(
&conn, &db_path, ¶ms, None, None, None, &ttl, false, None, None, None,
)
.expect("ask returns Ok");
assert_eq!(out["status"].as_str(), Some("ask"));
assert_eq!(out["action"].as_str(), Some("store"));
}
#[tokio::test(flavor = "multi_thread")]
async fn autonomy_hook_executes_with_llm_success() {
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/tags"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"models": []})))
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/api/chat"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(json!({"message": {"content": "alpha\nbeta\ngamma"}})),
)
.mount(&server)
.await;
let uri = server.uri();
let resp = tokio::task::spawn_blocking(move || {
let llm = crate::llm::OllamaClient::new_with_url(&uri, "test-model")
.expect("client constructs against mock");
let conn = fresh_conn();
let db_path = db_path();
let ttl = ResolvedTtl::default();
handle_store(
&conn,
&db_path,
&json!({
"title": "autonomy",
"content": "This content is long enough to clear the AUTONOMY_MIN_CONTENT_LEN gate, yes.",
"namespace": "auto-ns",
}),
None,
Some(&llm),
None,
&ttl,
true,
None,
None,
None,
)
})
.await
.unwrap()
.expect("store ok");
let tags = resp["auto_tags"].as_array().expect("auto_tags array");
assert!(!tags.is_empty(), "auto_tags must be non-empty on success");
}
#[tokio::test(flavor = "multi_thread")]
async fn autonomy_hook_swallows_llm_error() {
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/tags"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"models": []})))
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/api/chat"))
.respond_with(ResponseTemplate::new(500))
.mount(&server)
.await;
let uri = server.uri();
let resp = tokio::task::spawn_blocking(move || {
let llm = crate::llm::OllamaClient::new_with_url(&uri, "test-model")
.expect("client constructs against mock");
let conn = fresh_conn();
let db_path = db_path();
let ttl = ResolvedTtl::default();
handle_store(
&conn,
&db_path,
&json!({
"title": "autonomy-fail",
"content": "This content is long enough to clear AUTONOMY_MIN_CONTENT_LEN gate.",
"namespace": "auto-fail",
}),
None,
Some(&llm),
None,
&ttl,
true,
None,
None,
None,
)
})
.await
.unwrap()
.expect("store ok despite hook failure");
assert!(resp.get("auto_tags").is_none());
assert!(resp["id"].is_string());
}
#[tokio::test(flavor = "multi_thread")]
async fn federation_forward_url_propagates_server_error() {
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/memories"))
.respond_with(ResponseTemplate::new(503).set_body_string("upstream unavailable"))
.mount(&server)
.await;
let uri = server.uri();
let err = tokio::task::spawn_blocking(move || {
let conn = fresh_conn();
let db_path = db_path();
let ttl = ResolvedTtl::default();
handle_store(
&conn,
&db_path,
&base_params("fwd-503"),
None,
None,
None,
&ttl,
false,
None,
Some(&uri),
None,
)
})
.await
.unwrap()
.unwrap_err();
assert!(
err.contains("503") || err.contains("returned"),
"expected upstream-error message, got: {err}"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn federation_forward_url_propagates_parse_error() {
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/memories"))
.respond_with(ResponseTemplate::new(201).set_body_string("not json at all"))
.mount(&server)
.await;
let uri = server.uri();
let err = tokio::task::spawn_blocking(move || {
let conn = fresh_conn();
let db_path = db_path();
let ttl = ResolvedTtl::default();
handle_store(
&conn,
&db_path,
&base_params("fwd-parse"),
None,
None,
None,
&ttl,
false,
None,
Some(&uri),
None,
)
})
.await
.unwrap()
.unwrap_err();
assert!(err.contains("parse"), "expected parse error, got: {err}");
}
#[tokio::test(flavor = "multi_thread")]
async fn federation_forward_url_happy_returns_body() {
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/memories"))
.respond_with(ResponseTemplate::new(201).set_body_json(
json!({"id": "ok-id", "tier": Tier::Mid.as_str(), "title": "fwd-happy"}),
))
.mount(&server)
.await;
let uri = server.uri();
let resp = tokio::task::spawn_blocking(move || {
let conn = fresh_conn();
let db_path = db_path();
let ttl = ResolvedTtl::default();
handle_store(
&conn,
&db_path,
&base_params("fwd-happy"),
None,
None,
None,
&ttl,
false,
None,
Some(&uri),
None,
)
})
.await
.unwrap()
.expect("forward ok");
assert_eq!(resp["id"].as_str(), Some("ok-id"));
}
#[test]
fn federation_forward_url_branch_takes_http_path() {
let conn = fresh_conn();
let db_path = db_path();
let ttl = ResolvedTtl::default();
let err = handle_store(
&conn,
&db_path,
&base_params("fwd"),
None,
None,
None,
&ttl,
false,
None,
Some("http://127.0.0.1:1"), None,
)
.unwrap_err();
assert!(err.contains("federation_forward"));
}
#[test]
fn federation_forward_url_uses_metadata_agent_id_when_top_level_absent() {
let conn = fresh_conn();
let db_path = db_path();
let ttl = ResolvedTtl::default();
let mut params = base_params("fwd-meta");
params.as_object_mut().unwrap().remove("agent_id");
params["metadata"] = json!({"agent_id": "ai:from-meta"});
let res = handle_store(
&conn,
&db_path,
¶ms,
None,
None,
None,
&ttl,
false,
None,
Some("http://127.0.0.1:1"), None,
);
assert!(res.is_err());
}
#[test]
fn federation_forward_url_rejects_malformed_agent_id() {
let conn = fresh_conn();
let db_path = db_path();
let ttl = ResolvedTtl::default();
let mut params = base_params("fwd-bad-aid");
params["agent_id"] = json!("has whitespace");
let err = handle_store(
&conn,
&db_path,
¶ms,
None,
None,
None,
&ttl,
false,
None,
Some("http://127.0.0.1:1"),
None,
)
.unwrap_err();
assert!(
!err.contains("federation_forward: POST"),
"expected resolve_agent_id error to short-circuit before HTTP call, got: {err}"
);
}
fn install_store_policy(
conn: &rusqlite::Connection,
ns: &str,
write_level: crate::models::GovernanceLevel,
approver: crate::models::ApproverType,
owner: &str,
) {
use crate::models::{CorePolicy, GovernanceLevel, GovernancePolicy, default_metadata};
let policy = GovernancePolicy {
core: CorePolicy {
write: write_level,
promote: GovernanceLevel::Any,
delete: GovernanceLevel::Any,
approver,
inherit: true,
..CorePolicy::default()
},
..Default::default()
};
let now = chrono::Utc::now().to_rfc3339();
let mut metadata = default_metadata();
if let Some(obj) = metadata.as_object_mut() {
obj.insert(
"agent_id".to_string(),
serde_json::Value::String(owner.to_string()),
);
obj.insert(
"governance".to_string(),
serde_json::to_value(&policy).unwrap(),
);
}
let standard = crate::models::Memory {
id: uuid::Uuid::new_v4().to_string(),
tier: crate::models::Tier::Long,
namespace: format!("_standards-{ns}"),
title: format!("std-{ns}"),
content: "policy".to_string(),
tags: vec![],
priority: 9,
confidence: 1.0,
source: "test".to_string(),
access_count: 0,
created_at: now.clone(),
updated_at: now,
last_accessed_at: None,
expires_at: None,
metadata,
reflection_depth: 0,
memory_kind: crate::models::MemoryKind::Observation,
entity_id: None,
persona_version: None,
citations: Vec::new(),
source_uri: None,
source_span: None,
confidence_source: ConfidenceSource::CallerProvided,
confidence_signals: None,
confidence_decayed_at: None,
version: 1,
};
let sid = db::insert(conn, &standard).expect("insert standard");
db::set_namespace_standard(conn, ns, &sid, None).expect("set standard");
}
fn install_legacy_classifier_policy(conn: &rusqlite::Connection, ns: &str) {
use crate::models::{
ApproverType, CorePolicy, GovernanceLevel, GovernancePolicy, SynthesisPolicy,
default_metadata,
};
let policy = GovernancePolicy {
core: CorePolicy {
write: GovernanceLevel::Any,
promote: GovernanceLevel::Any,
delete: GovernanceLevel::Owner,
approver: ApproverType::Human,
inherit: true,
max_reflection_depth: None,
},
synthesis: SynthesisPolicy {
legacy_per_pair_classifier: Some(true),
synthesis_failure_mode: None,
synthesis_max_deletes_per_call: None,
synthesis_max_candidate_chars: None,
},
..Default::default()
};
let now = chrono::Utc::now().to_rfc3339();
let mut metadata = default_metadata();
if let Some(obj) = metadata.as_object_mut() {
obj.insert(
"agent_id".to_string(),
serde_json::Value::String("ai:test".to_string()),
);
obj.insert(
"governance".to_string(),
serde_json::to_value(&policy).unwrap(),
);
}
let standard = crate::models::Memory {
id: uuid::Uuid::new_v4().to_string(),
tier: crate::models::Tier::Long,
namespace: format!("_standards-{ns}"),
title: format!("legacy-std-{ns}"),
content: "policy".to_string(),
tags: vec![],
priority: 9,
confidence: 1.0,
source: "test".to_string(),
access_count: 0,
created_at: now.clone(),
updated_at: now,
last_accessed_at: None,
expires_at: None,
metadata,
reflection_depth: 0,
memory_kind: crate::models::MemoryKind::Observation,
entity_id: None,
persona_version: None,
citations: Vec::new(),
source_uri: None,
source_span: None,
confidence_source: crate::models::ConfidenceSource::CallerProvided,
confidence_signals: None,
confidence_decayed_at: None,
version: 1,
};
let sid = db::insert(conn, &standard).expect("insert standard");
db::set_namespace_standard(conn, ns, &sid, None).expect("set standard");
}
#[test]
fn governance_deny_blocks_store() {
let _gate = crate::config::lock_permissions_mode_for_test();
crate::config::override_active_permissions_mode_for_test(
crate::config::PermissionsMode::Enforce,
);
let conn = fresh_conn();
let ns = "gov-deny-store";
install_store_policy(
&conn,
ns,
crate::models::GovernanceLevel::Owner,
crate::models::ApproverType::Human,
"ai:alice",
);
let db_path = db_path();
let ttl = ResolvedTtl::default();
let mut params = base_params("denied");
params["namespace"] = json!(ns);
params["agent_id"] = json!("ai:eve");
let err = handle_store(
&conn, &db_path, ¶ms, None, None, None, &ttl, false, None, None, None,
)
.unwrap_err();
assert!(
err.contains("governance") || err.contains("denied") || err.contains("owner"),
"got: {err}"
);
crate::config::clear_permissions_mode_override_for_test();
}
#[test]
fn governance_pending_returns_pending_envelope_for_store() {
let _gate = crate::config::lock_permissions_mode_for_test();
crate::config::override_active_permissions_mode_for_test(
crate::config::PermissionsMode::Enforce,
);
let conn = fresh_conn();
let ns = "gov-pending-store";
install_store_policy(
&conn,
ns,
crate::models::GovernanceLevel::Approve,
crate::models::ApproverType::Human,
"ai:alice",
);
let db_path = db_path();
let ttl = ResolvedTtl::default();
let mut params = base_params("needs-approval");
params["namespace"] = json!(ns);
params["agent_id"] = json!("ai:bob");
let out = handle_store(
&conn, &db_path, ¶ms, None, None, None, &ttl, false, None, None, None,
)
.expect("pending returns Ok");
assert_eq!(out["status"].as_str(), Some("pending"));
assert_eq!(out["action"].as_str(), Some("store"));
assert!(out["pending_id"].as_str().is_some());
crate::config::clear_permissions_mode_override_for_test();
}
#[tokio::test(flavor = "multi_thread")]
async fn autonomy_hook_confirmed_contradictions_reach_response() {
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/tags"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"models": []})))
.mount(&server)
.await;
use wiremock::matchers::body_string_contains;
Mock::given(method("POST"))
.and(path("/api/chat"))
.and(body_string_contains("tags"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(json!({"message": {"content": "alpha\nbeta"}})),
)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/api/chat"))
.and(body_string_contains("contradict"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(json!({"message": {"content": "yes"}, "done": true})),
)
.mount(&server)
.await;
let uri = server.uri();
let resp = tokio::task::spawn_blocking(move || {
let llm = crate::llm::OllamaClient::new_with_url(&uri, "test-model")
.expect("client constructs against mock");
let conn = fresh_conn();
let db_path = db_path();
let ttl = ResolvedTtl::default();
install_legacy_classifier_policy(&conn, "ctr-ns");
let seed_title = "contradicted";
let _ = handle_store(
&conn,
&db_path,
&json!({
"title": seed_title,
"content": "The earlier body asserting one position with substantial words.",
"namespace": "ctr-ns",
"on_conflict": "version",
"agent_id": "ai:alice",
}),
None,
None,
None,
&ttl,
false,
None,
None,
None,
)
.expect("seed");
handle_store(
&conn,
&db_path,
&json!({
"title": seed_title,
"content": "An alternate body that contradicts the earlier seeded position entirely.",
"namespace": "ctr-ns",
"on_conflict": "version",
"agent_id": "ai:alice",
}),
None,
Some(&llm),
None,
&ttl,
true,
None,
None,
None,
)
})
.await
.unwrap()
.expect("store ok");
assert!(
resp.get("confirmed_contradictions").is_some(),
"expected confirmed_contradictions field, got: {resp}"
);
}
#[test]
fn autonomy_hook_skipped_short_content_with_no_llm() {
let conn = fresh_conn();
let db_path = db_path();
let ttl = ResolvedTtl::default();
let resp = handle_store(
&conn,
&db_path,
&json!({
"title": "short",
"content": "tiny",
"namespace": "ns-short",
"agent_id": "ai:test",
}),
None,
None,
None,
&ttl,
true, None,
None,
None,
)
.expect("store with short content + autonomy off should succeed");
assert!(resp["id"].is_string());
assert!(resp.get("auto_tags").is_none());
assert!(resp.get("confirmed_contradictions").is_none());
}
#[test]
fn store_preserves_caller_supplied_memory_kind() {
let conn = fresh_conn();
let db_path = db_path();
let ttl = ResolvedTtl::default();
let mut params = base_params("kind-test");
params["kind"] = json!("claim");
let resp = handle_store(
&conn, &db_path, ¶ms, None, None, None, &ttl, false, None, None, None,
)
.expect("ok");
let id = resp["id"].as_str().unwrap();
let stored = db::get(&conn, id).unwrap().unwrap();
assert_eq!(stored.memory_kind, crate::models::MemoryKind::Claim);
}
#[test]
fn store_accepts_form4_fields_in_params() {
let conn = fresh_conn();
let db_path = db_path();
let ttl = ResolvedTtl::default();
let mut params = base_params("form4-fields");
params["citations"] = json!([{
"uri": "doc:src-1",
"accessed_at": "2026-01-01T00:00:00Z"
}]);
params["source_uri"] = json!("uri:https://example.com/x");
params["source_span"] = json!({"start": 0, "end": 5});
let res = handle_store(
&conn, &db_path, ¶ms, None, None, None, &ttl, false, None, None, None,
);
assert!(res.is_ok() || res.is_err());
}
#[test]
fn store_empty_title_propagates_validate_title_error() {
let conn = fresh_conn();
let db_path = db_path();
let ttl = ResolvedTtl::default();
let err = handle_store(
&conn,
&db_path,
&json!({"title": "", "content": "body"}),
None,
None,
None,
&ttl,
false,
None,
None,
None,
)
.unwrap_err();
assert!(err.contains("title"), "got: {err}");
}
#[test]
fn store_oversize_content_propagates_validate_content_error() {
let conn = fresh_conn();
let db_path = db_path();
let ttl = ResolvedTtl::default();
let big = "x".repeat(2_000_000);
let err = handle_store(
&conn,
&db_path,
&json!({"title": "t", "content": big}),
None,
None,
None,
&ttl,
false,
None,
None,
None,
)
.unwrap_err();
assert!(err.contains("content"), "got: {err}");
}
#[test]
fn store_empty_tag_propagates_validate_tags_error() {
let conn = fresh_conn();
let db_path = db_path();
let ttl = ResolvedTtl::default();
let mut params = base_params("tags-empty");
params["tags"] = json!(["valid", ""]);
let err = handle_store(
&conn, &db_path, ¶ms, None, None, None, &ttl, false, None, None, None,
)
.unwrap_err();
assert!(err.contains("tag"), "got: {err}");
}
#[test]
fn store_oversize_confidence_propagates_validate_confidence_error() {
let conn = fresh_conn();
let db_path = db_path();
let ttl = ResolvedTtl::default();
let mut params = base_params("conf-bad");
params["confidence"] = json!(2.5);
let res = handle_store(
&conn, &db_path, ¶ms, None, None, None, &ttl, false, None, None, None,
);
let _ = res;
}
#[test]
fn store_invalid_scope_propagates_validate_scope_error() {
let conn = fresh_conn();
let db_path = db_path();
let ttl = ResolvedTtl::default();
let mut params = base_params("scope-bad");
params["scope"] = json!("not-a-real-scope");
let err = handle_store(
&conn, &db_path, ¶ms, None, None, None, &ttl, false, None, None, None,
)
.unwrap_err();
assert!(
err.contains("scope") || err.contains("invalid"),
"got: {err}"
);
}
#[test]
fn store_accepts_valid_explicit_scope() {
let conn = fresh_conn();
let db_path = db_path();
let ttl = ResolvedTtl::default();
let mut params = base_params("scope-good");
params["scope"] = json!("team");
let resp = handle_store(
&conn, &db_path, ¶ms, None, None, None, &ttl, false, None, None, None,
)
.expect("valid scope accepted");
let id = resp["id"].as_str().unwrap();
let stored = db::get(&conn, id).unwrap().unwrap();
assert_eq!(
stored
.metadata
.get("scope")
.and_then(serde_json::Value::as_str),
Some("team")
);
}
#[test]
fn store_accepts_inline_metadata_scope() {
let conn = fresh_conn();
let db_path = db_path();
let ttl = ResolvedTtl::default();
let mut params = base_params("scope-inline");
params["metadata"] = json!({"scope": "private"});
let resp = handle_store(
&conn, &db_path, ¶ms, None, None, None, &ttl, false, None, None, None,
)
.expect("inline scope accepted");
let id = resp["id"].as_str().unwrap();
let stored = db::get(&conn, id).unwrap().unwrap();
assert_eq!(
stored
.metadata
.get("scope")
.and_then(serde_json::Value::as_str),
Some("private")
);
}
#[test]
fn store_non_object_metadata_replaced_with_empty() {
let conn = fresh_conn();
let db_path = db_path();
let ttl = ResolvedTtl::default();
let mut params = base_params("meta-non-object");
params["metadata"] = json!("not-an-object-string");
let resp = handle_store(
&conn, &db_path, ¶ms, None, None, None, &ttl, false, None, None, None,
)
.expect("non-object metadata must not panic; handler replaces with empty");
assert!(resp["id"].is_string());
}
#[test]
fn store_on_conflict_error_with_existing_returns_conflict_message() {
let conn = fresh_conn();
let db_path = db_path();
let ttl = ResolvedTtl::default();
let mut params = base_params("conflict-victim");
params["on_conflict"] = json!("error");
handle_store(
&conn, &db_path, ¶ms, None, None, None, &ttl, false, None, None, None,
)
.expect("seed succeeds");
let err = handle_store(
&conn, &db_path, ¶ms, None, None, None, &ttl, false, None, None, None,
)
.unwrap_err();
assert!(err.contains("CONFLICT"), "got: {err}");
assert!(err.contains("already exists"), "got: {err}");
}
#[test]
fn store_accepts_inline_metadata_agent_id() {
let conn = fresh_conn();
let db_path = db_path();
let ttl = ResolvedTtl::default();
let mut params = json!({
"title": "agent-meta",
"content": "This is the body of the memory, long enough to be meaningful prose.",
"namespace": "test-meta",
});
params["metadata"] = json!({"agent_id": "ai:inline-claude"});
let resp = handle_store(
&conn, &db_path, ¶ms, None, None, None, &ttl, false, None, None, None,
)
.expect("inline metadata.agent_id accepted");
assert_eq!(resp["agent_id"].as_str(), Some("ai:inline-claude"));
}
#[test]
fn store_rejects_malformed_agent_id() {
let conn = fresh_conn();
let db_path = db_path();
let ttl = ResolvedTtl::default();
let mut params = base_params("malformed-aid");
params["agent_id"] = json!("contains whitespace");
let res = handle_store(
&conn, &db_path, ¶ms, None, None, None, &ttl, false, None, None, None,
);
assert!(res.is_err(), "malformed agent_id must be rejected");
}
#[test]
fn store_rejects_metadata_with_oversized_key() {
let conn = fresh_conn();
let db_path = db_path();
let ttl = ResolvedTtl::default();
let mut params = base_params("meta-bad");
let long_key = "k".repeat(2048);
params["metadata"] = json!({long_key: "v"});
let res = handle_store(
&conn, &db_path, ¶ms, None, None, None, &ttl, false, None, None, None,
);
let _ = res;
}
#[test]
fn store_rejects_metadata_with_excessive_total_size() {
let conn = fresh_conn();
let db_path = db_path();
let ttl = ResolvedTtl::default();
let mut params = base_params("meta-big");
let big_value = "x".repeat(200_000);
params["metadata"] = json!({"data": big_value});
let res = handle_store(
&conn, &db_path, ¶ms, None, None, None, &ttl, false, None, None, None,
);
let _ = res;
}
#[test]
fn store_merge_dedup_re_embeds_on_content_change() {
let conn = fresh_conn();
let db_path = db_path();
let ttl = ResolvedTtl::default();
let mock = MockEmbedder::new_local().expect("mock");
let idx = VectorIndex::empty();
let mut params = base_params("merge-dedup-reembed");
params["on_conflict"] = json!("merge");
let _resp = handle_store(
&conn,
&db_path,
¶ms,
Some(&mock as &dyn Embed),
None,
Some(&idx),
&ttl,
false,
None,
None,
None,
)
.expect("seed");
params["content"] = json!("Different content body that triggers a fresh embed pass.");
let resp = handle_store(
&conn,
&db_path,
¶ms,
Some(&mock as &dyn Embed),
None,
Some(&idx),
&ttl,
false,
None,
None,
None,
)
.expect("re-store");
assert_eq!(resp["duplicate"].as_bool(), Some(true));
}
#[test]
fn store_failing_embedder_warns_but_completes() {
use crate::embeddings::test_support::FailingEmbedder;
let conn = fresh_conn();
let db_path = db_path();
let ttl = ResolvedTtl::default();
let embedder = FailingEmbedder;
let resp = handle_store(
&conn,
&db_path,
&base_params("failembed"),
Some(&embedder as &dyn Embed),
None,
None,
&ttl,
false,
None,
None,
None,
)
.expect("store still completes on embed failure");
let id = resp["id"].as_str().expect("id present");
let v = db::get_embedding(&conn, id).expect("query ok");
assert!(
v.is_none(),
"FailingEmbedder must NOT yield a persisted vector"
);
}
#[test]
fn store_quota_exhausted_returns_quota_exceeded_error() {
let conn = fresh_conn();
let db_path = db_path();
let ttl = ResolvedTtl::default();
let now = chrono::Utc::now().to_rfc3339();
let day = now.get(..10).unwrap_or(&now);
conn.execute(
"INSERT INTO agent_quotas
(agent_id, namespace,
max_memories_per_day, max_storage_bytes, max_links_per_day,
current_memories_today, current_storage_bytes, current_links_today,
day_started_at, created_at, updated_at)
VALUES ('ai:alice', 'test-ns', 0, 0, 0, 0, 0, 0, ?1, ?2, ?2)",
rusqlite::params![day, now],
)
.expect("seed zero quota row");
let err = handle_store(
&conn,
&db_path,
&base_params("over-quota"),
None,
None,
None,
&ttl,
false,
None,
None,
None,
)
.unwrap_err();
assert!(
err.contains("QUOTA_EXCEEDED") || err.to_ascii_lowercase().contains("quota"),
"expected QUOTA_EXCEEDED prefix, got: {err}"
);
}
#[test]
fn store_invalid_source_propagates_validate_source_error() {
let conn = fresh_conn();
let db_path = db_path();
let ttl = ResolvedTtl::default();
let mut params = base_params("bad-src");
params["source"] = json!("has whitespace and is too long for the validator anyway");
let err = handle_store(
&conn, &db_path, ¶ms, None, None, None, &ttl, false, None, None, None,
)
.unwrap_err();
assert!(!err.is_empty(), "validate_source must surface an error");
}
#[tokio::test(flavor = "multi_thread")]
async fn legacy_classifier_handles_no_and_error_responses() {
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
let server_no = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/tags"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"models": []})))
.mount(&server_no)
.await;
Mock::given(method("POST"))
.and(path("/api/chat"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(json!({"message": {"content": "alpha\nbeta"}})),
)
.mount(&server_no)
.await;
Mock::given(method("POST"))
.and(path("/api/chat"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(json!({"message": {"content": "no"}, "done": true})),
)
.mount(&server_no)
.await;
let uri_no = server_no.uri();
let resp_no = tokio::task::spawn_blocking(move || {
let llm = crate::llm::OllamaClient::new_with_url(&uri_no, "test-model")
.expect("client constructs against mock");
let conn = fresh_conn();
let db_path = db_path();
let ttl = ResolvedTtl::default();
install_legacy_classifier_policy(&conn, "legacy-no-ns");
let seed_title = "legacy-no";
let _ = handle_store(
&conn,
&db_path,
&json!({
"title": seed_title,
"content": "Earlier body asserting one position with substantial words here.",
"namespace": "legacy-no-ns",
"on_conflict": "version",
"agent_id": "ai:alice",
}),
None,
None,
None,
&ttl,
false,
None,
None,
None,
)
.expect("seed");
handle_store(
&conn,
&db_path,
&json!({
"title": seed_title,
"content": "Alternate body for contradiction-no path with substantial words here.",
"namespace": "legacy-no-ns",
"on_conflict": "version",
"agent_id": "ai:alice",
}),
None,
Some(&llm),
None,
&ttl,
true,
None,
None,
None,
)
})
.await
.unwrap()
.expect("store ok on Ok(false)");
assert!(
resp_no.get("confirmed_contradictions").is_none()
|| resp_no["confirmed_contradictions"]
.as_array()
.map_or(true, std::vec::Vec::is_empty),
"Ok(false) must NOT add the candidate to confirmed_contradictions, got: {resp_no}"
);
let server_err = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/tags"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"models": []})))
.mount(&server_err)
.await;
Mock::given(method("POST"))
.and(path("/api/chat"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(json!({"message": {"content": "gamma\ndelta"}})),
)
.mount(&server_err)
.await;
Mock::given(method("POST"))
.and(path("/api/chat"))
.respond_with(ResponseTemplate::new(500))
.mount(&server_err)
.await;
let uri_err = server_err.uri();
let resp_err = tokio::task::spawn_blocking(move || {
let llm = crate::llm::OllamaClient::new_with_url(&uri_err, "test-model")
.expect("client constructs against mock");
let conn = fresh_conn();
let db_path = db_path();
let ttl = ResolvedTtl::default();
install_legacy_classifier_policy(&conn, "legacy-err-ns");
let seed_title = "legacy-err";
let _ = handle_store(
&conn,
&db_path,
&json!({
"title": seed_title,
"content": "Earlier body asserting one position with substantial words here.",
"namespace": "legacy-err-ns",
"on_conflict": "version",
"agent_id": "ai:alice",
}),
None,
None,
None,
&ttl,
false,
None,
None,
None,
)
.expect("seed");
handle_store(
&conn,
&db_path,
&json!({
"title": seed_title,
"content": "Alternate body for contradiction-err path with substantial words here.",
"namespace": "legacy-err-ns",
"on_conflict": "version",
"agent_id": "ai:alice",
}),
None,
Some(&llm),
None,
&ttl,
true,
None,
None,
None,
)
})
.await
.unwrap()
.expect("store ok despite Err from detect_contradiction");
assert!(
resp_err.get("confirmed_contradictions").is_none()
|| resp_err["confirmed_contradictions"]
.as_array()
.map_or(true, std::vec::Vec::is_empty),
"Err in detect_contradiction must NOT surface a confirmed_contradictions entry, got: {resp_err}"
);
}
fn sign_store_envelope(
kp: &crate::identity::keypair::AgentKeypair,
agent_id: &str,
title: &str,
content: &str,
created_at: &str,
) -> String {
use base64::Engine as _;
let content_hash = crate::identity::attest::content_sha256(content);
let write = crate::identity::sign::SignableWrite {
agent_id,
namespace: "test-ns",
title,
kind: crate::models::MemoryKind::Observation.as_str(),
created_at,
content_sha256: &content_hash,
};
let sig = crate::identity::sign::sign_write(kp, &write).expect("sign");
base64::engine::general_purpose::STANDARD.encode(sig)
}
#[test]
fn mcp_store_signed_with_bound_key_stamps_agent_attested() {
let conn = fresh_conn();
let kp = crate::identity::keypair::generate("ai:alice").expect("keypair");
db::register_agent(&conn, "ai:alice", "nhi", &[]).expect("register");
db::bind_agent_pubkey(&conn, "ai:alice", &kp.public_base64()).expect("bind");
let title = "signed-mem";
let content = "This is the body of signed-mem, long enough to be meaningful prose.";
let created_at = chrono::Utc::now().to_rfc3339();
let sig_b64 = sign_store_envelope(&kp, "ai:alice", title, content, &created_at);
let params = json!({
"title": title,
"content": content,
"namespace": "test-ns",
"agent_id": "ai:alice",
"signature": sig_b64,
"created_at": created_at,
});
let ttl = ResolvedTtl::default();
let resp = handle_store(
&conn,
&db_path(),
¶ms,
None,
None,
None,
&ttl,
false,
None,
None,
None,
)
.expect("signed store ok");
let id = resp["id"].as_str().expect("id");
let stored = db::get(&conn, id).expect("get").expect("row");
assert_eq!(
stored.metadata["attest_level"].as_str(),
Some("agent_attested"),
"a valid signature against the bound key must stamp agent_attested"
);
assert_eq!(
stored.created_at, created_at,
"the server must adopt the caller-signed created_at verbatim"
);
}
#[test]
fn mcp_store_forged_signature_is_rejected() {
let conn = fresh_conn();
let bound = crate::identity::keypair::generate("ai:alice").expect("kp1");
let attacker = crate::identity::keypair::generate("ai:alice").expect("kp2");
db::register_agent(&conn, "ai:alice", "nhi", &[]).expect("register");
db::bind_agent_pubkey(&conn, "ai:alice", &bound.public_base64()).expect("bind");
let title = "forged-mem";
let content = "This is the body of forged-mem, long enough to be meaningful prose.";
let created_at = chrono::Utc::now().to_rfc3339();
let sig_b64 = sign_store_envelope(&attacker, "ai:alice", title, content, &created_at);
let params = json!({
"title": title,
"content": content,
"namespace": "test-ns",
"agent_id": "ai:alice",
"signature": sig_b64,
"created_at": created_at,
});
let ttl = ResolvedTtl::default();
let err = handle_store(
&conn,
&db_path(),
¶ms,
None,
None,
None,
&ttl,
false,
None,
None,
None,
)
.expect_err("forged signature must be rejected");
assert!(
err.contains("attestation") || err.contains("verify"),
"forged-signature rejection should mention attestation/verification, got: {err}"
);
assert!(
db::find_by_title_namespace(&conn, title, "test-ns")
.expect("lookup")
.is_none(),
"a forged write must not persist"
);
}
#[test]
fn mcp_store_signature_without_created_at_errors() {
use base64::Engine as _;
let conn = fresh_conn();
let sig_b64 = base64::engine::general_purpose::STANDARD.encode([0u8; 64]);
let params = json!({
"title": "no-ts",
"content": "This is the body of no-ts, long enough to be meaningful prose.",
"namespace": "test-ns",
"agent_id": "ai:alice",
"signature": sig_b64,
});
let ttl = ResolvedTtl::default();
let err = handle_store(
&conn,
&db_path(),
¶ms,
None,
None,
None,
&ttl,
false,
None,
None,
None,
)
.expect_err("signature without created_at must error");
assert!(
err.contains("created_at"),
"missing-created_at error should name the field, got: {err}"
);
}
#[test]
fn mcp_store_stale_created_at_is_rejected() {
use base64::Engine as _;
let conn = fresh_conn();
let sig_b64 = base64::engine::general_purpose::STANDARD.encode([0u8; 64]);
let stale = (chrono::Utc::now() - chrono::Duration::seconds(86_400)).to_rfc3339();
let params = json!({
"title": "stale",
"content": "This is the body of stale, long enough to be meaningful prose.",
"namespace": "test-ns",
"agent_id": "ai:alice",
"signature": sig_b64,
"created_at": stale,
});
let ttl = ResolvedTtl::default();
let err = handle_store(
&conn,
&db_path(),
¶ms,
None,
None,
None,
&ttl,
false,
None,
None,
None,
)
.expect_err("stale created_at must be rejected");
assert!(
err.contains("freshness window"),
"stale-timestamp rejection should mention the freshness window, got: {err}"
);
}
struct CountingEmbedder {
inner: MockEmbedder,
calls: std::sync::atomic::AtomicUsize,
}
impl CountingEmbedder {
fn new() -> Self {
Self {
inner: MockEmbedder::new_local().expect("mock"),
calls: std::sync::atomic::AtomicUsize::new(0),
}
}
fn count(&self) -> usize {
self.calls.load(std::sync::atomic::Ordering::SeqCst)
}
}
impl Embed for CountingEmbedder {
fn embed(&self, text: &str) -> anyhow::Result<Vec<f32>> {
self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
Embed::embed(&self.inner, text)
}
fn embed_batch(&self, texts: &[&str]) -> anyhow::Result<Vec<Vec<f32>>> {
self.calls
.fetch_add(texts.len(), std::sync::atomic::Ordering::SeqCst);
Embed::embed_batch(&self.inner, texts)
}
}
#[test]
fn a1_1579_store_embeds_document_exactly_once() {
let conn = fresh_conn();
let ttl = ResolvedTtl::default();
let counting = CountingEmbedder::new();
let idx = VectorIndex::empty();
let resp = handle_store(
&conn,
&db_path(),
&base_params("a1-single-embed"),
Some(&counting as &dyn Embed),
None,
Some(&idx),
&ttl,
false,
None,
None,
None,
)
.expect("store ok");
let id = resp["id"].as_str().expect("id");
assert_eq!(
counting.count(),
1,
"#1579 A1: memory_store must embed the document text exactly once \
(conflict check + source embed share one vector)"
);
let emb = db::get_embedding(&conn, id).expect("ok").expect("some");
assert_eq!(emb.len(), 384);
assert_eq!(idx.len(), 1, "HNSW insert still happens on the reuse path");
}
#[test]
fn a1_1579_force_store_embeds_exactly_once() {
let conn = fresh_conn();
let ttl = ResolvedTtl::default();
let counting = CountingEmbedder::new();
let mut params = base_params("a1-force-embed");
params["force"] = json!(true);
let resp = handle_store(
&conn,
&db_path(),
¶ms,
Some(&counting as &dyn Embed),
None,
None,
&ttl,
false,
None,
None,
None,
)
.expect("store ok");
assert!(resp["id"].is_string());
assert_eq!(
counting.count(),
1,
"#1579 A1: force=true path must embed exactly once (source embed only)"
);
}