#![allow(clippy::too_many_lines)]
use super::*;
use crate::db;
use crate::models::Memory;
use axum::Json;
use axum::http::StatusCode;
use axum::response::IntoResponse;
use chrono::Utc;
use serde_json::json;
use super::http::maybe_auto_tag;
use crate::config::ResolvedTtl;
use crate::embeddings::Embedder;
use crate::models::Tier;
use crate::profile::Family;
use chrono::Duration;
use std::sync::Arc;
use tokio::sync::{Mutex, RwLock};
use uuid::Uuid;
static SECURITY_BYPASS_INIT: std::sync::Once = std::sync::Once::new();
fn install_security_bypass_for_legacy_tests() {
SECURITY_BYPASS_INIT.call_once(|| {
unsafe {
std::env::set_var(
crate::federation::peer_attestation::TRUST_BODY_AGENT_ID_ENV,
"1",
);
std::env::set_var(
crate::federation::peer_attestation::SYNC_TRUST_PEER_ENV,
"1",
);
}
crate::handlers::admin_role::mark_request_authn_configured(true);
});
}
fn test_state() -> Db {
install_security_bypass_for_legacy_tests();
let conn = db::open(std::path::Path::new(":memory:")).unwrap();
let path = std::path::PathBuf::from(":memory:");
Arc::new(Mutex::new((conn, path, ResolvedTtl::default(), true)))
}
pub(super) static APPROVE_HMAC_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
pub(super) fn sign_approve_body(
secret: &str,
method: &str,
pending_id: &str,
body: &[u8],
) -> (String, String) {
let ts = chrono::Utc::now().timestamp().to_string();
let body_str = std::str::from_utf8(body).unwrap_or("");
let canonical = format!("{ts}.{method}.{pending_id}.{body_str}");
let key_hash = crate::subscriptions::sha256_hex(secret);
let sig = crate::subscriptions::hmac_sha256_hex(&key_hash, &canonical);
(ts, format!("sha256={sig}"))
}
#[tokio::test]
async fn health_returns_ok() {
let state = test_state();
let lock = state.lock().await;
let ok = db::health_check(&lock.0).unwrap_or(false);
assert!(ok);
}
#[tokio::test]
async fn store_and_retrieve_via_state() {
let state = test_state();
let lock = state.lock().await;
let now = Utc::now();
let mem = Memory {
id: Uuid::new_v4().to_string(),
tier: Tier::Long,
namespace: "test".into(),
title: "Handler test".into(),
content: "Testing handlers.".into(),
tags: vec!["test".into()],
priority: 7,
confidence: 1.0,
source: "test".into(),
access_count: 0,
created_at: now.to_rfc3339(),
updated_at: now.to_rfc3339(),
last_accessed_at: None,
expires_at: None,
metadata: serde_json::json!({}),
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 id = db::insert(&lock.0, &mem).unwrap();
let got = db::get(&lock.0, &id).unwrap().unwrap();
assert_eq!(got.title, "Handler test");
}
#[tokio::test]
async fn recall_via_state() {
let state = test_state();
let lock = state.lock().await;
let now = Utc::now();
let mem = Memory {
id: Uuid::new_v4().to_string(),
tier: Tier::Long,
namespace: "test".into(),
title: "Recall handler test".into(),
content: "Content for recall.".into(),
tags: vec![],
priority: 8,
confidence: 1.0,
source: "test".into(),
access_count: 0,
created_at: now.to_rfc3339(),
updated_at: now.to_rfc3339(),
last_accessed_at: None,
expires_at: None,
metadata: serde_json::json!({}),
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,
};
db::insert(&lock.0, &mem).unwrap();
let (results, _outcome) = db::recall(
&lock.0,
"recall handler",
Some("test"),
10,
None,
None,
None,
crate::models::SHORT_TTL_EXTEND_SECS,
crate::models::MID_TTL_EXTEND_SECS,
None,
None,
false,
None,
)
.unwrap();
assert!(!results.is_empty());
assert!(results[0].1 > 0.0); }
#[tokio::test]
async fn stats_via_state() {
let state = test_state();
let lock = state.lock().await;
let path = std::path::Path::new(":memory:");
let s = db::stats(&lock.0, path).unwrap();
assert_eq!(s.total, 0);
}
#[tokio::test]
async fn bulk_size_limit() {
assert_eq!(MAX_BULK_SIZE, 1000);
}
#[tokio::test]
async fn list_empty_namespace() {
let state = test_state();
let lock = state.lock().await;
let results = db::list(
&lock.0,
Some("nonexistent"),
None,
10,
0,
None,
None,
None,
None,
None,
)
.unwrap();
assert!(results.is_empty());
}
#[tokio::test]
async fn create_and_update_with_metadata() {
let state = test_state();
let lock = state.lock().await;
let now = Utc::now();
let mem = Memory {
id: Uuid::new_v4().to_string(),
tier: Tier::Long,
namespace: "test".into(),
title: "HTTP metadata test".into(),
content: "Testing metadata through handler layer.".into(),
tags: vec![],
priority: 5,
confidence: 1.0,
source: "api".into(),
access_count: 0,
created_at: now.to_rfc3339(),
updated_at: now.to_rfc3339(),
last_accessed_at: None,
expires_at: None,
metadata: serde_json::json!({"http_test": true, "version": 1}),
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 id = db::insert(&lock.0, &mem).unwrap();
let got = db::get(&lock.0, &id).unwrap().unwrap();
assert_eq!(got.metadata["http_test"], true);
assert_eq!(got.metadata["version"], 1);
let new_meta = serde_json::json!({"http_test": true, "version": 2, "updated_by": "handler"});
let (found, _) = db::update(
&lock.0,
&id,
None,
None,
None,
None,
None,
None,
None,
None,
Some(&new_meta),
)
.unwrap();
assert!(found);
let got = db::get(&lock.0, &id).unwrap().unwrap();
assert_eq!(got.metadata["version"], 2);
assert_eq!(got.metadata["updated_by"], "handler");
}
use axum::{Router, body::Body, routing::get as axum_get, routing::post as axum_post};
use tower::ServiceExt as _;
fn test_app_state(db: Db) -> AppState {
AppState {
db,
embedder: Arc::new(None),
vector_index: Arc::new(Mutex::new(None)),
federation: Arc::new(None),
tier_config: Arc::new(crate::config::FeatureTier::Keyword.config()),
scoring: Arc::new(crate::config::ResolvedScoring::default()),
profile: Arc::new(crate::profile::Profile::core()),
mcp_config: Arc::new(None),
active_keypair: Arc::new(None),
family_embeddings: Arc::new(RwLock::new(Some(Vec::new()))),
storage_backend: StorageBackend::Sqlite,
#[cfg(feature = "sal")]
store: test_sqlite_store_handle(),
llm: Arc::new(None),
auto_tag_model: Arc::new(None),
llm_call_timeout: std::time::Duration::from_secs(
crate::config::DEFAULT_LLM_CALL_TIMEOUT_SECS,
),
replay_cache: Arc::new(crate::identity::replay::ReplayCache::new()),
verify_require_nonce: false,
federation_nonce_cache: Arc::new(crate::identity::replay::FederationNonceCache::new()),
autonomous_hooks: false,
recall_scope: Arc::new(None),
deferred_audit_queue: Arc::new(None),
admin_agent_ids: Arc::new(vec!["*".to_string()]),
rule_cache: std::sync::Arc::new(crate::governance::rule_cache::RuleCache::new()),
resolved_models: std::sync::Arc::new(crate::config::ResolvedModels::default()),
runtime: crate::runtime_context::RuntimeContext::global_arc(),
max_page_size: crate::handlers::MAX_BULK_SIZE,
}
}
#[cfg(test)]
fn test_app_state_with_admin(db: Db, agent_id: &str) -> AppState {
let mut state = test_app_state(db);
state.admin_agent_ids = Arc::new(vec![agent_id.to_string()]);
state
}
#[cfg(feature = "sal")]
fn test_sqlite_store_handle() -> Arc<dyn crate::store::MemoryStore> {
let tmp = tempfile::NamedTempFile::new().expect("tempfile for test SqliteStore");
let path = tmp.path().to_path_buf();
std::mem::forget(tmp);
Arc::new(
crate::store::sqlite::SqliteStore::open(&path)
.expect("open SqliteStore for test_app_state"),
)
}
#[tokio::test]
async fn http_create_memory_uses_appstate_and_persists() {
let state = test_state();
let app = Router::new()
.route("/api/v1/memories", axum_post(create_memory))
.with_state(test_app_state(state.clone()));
let body = serde_json::json!({
"tier": Tier::Long.as_str(),
"namespace": "http-embed-test",
"title": "Semantic-ready via HTTP",
"content": "HTTP-authored memories must now participate in semantic recall.",
"tags": ["issue-219"],
"priority": 7,
"confidence": 1.0,
"source": "api",
"metadata": {}
});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/memories")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.header("x-agent-id", "alice")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
let lock = state.lock().await;
let rows = db::list(
&lock.0,
Some("http-embed-test"),
None,
10,
0,
None,
None,
None,
None,
None,
)
.unwrap();
assert!(!rows.is_empty(), "HTTP-authored memory must be persisted");
assert_eq!(rows[0].title, "Semantic-ready via HTTP");
}
#[tokio::test]
async fn http_create_memory_succeeds_when_llm_is_absent_l5() {
let state = test_state();
let app = Router::new()
.route("/api/v1/memories", axum_post(create_memory))
.with_state(test_app_state(state.clone()));
let body = serde_json::json!({
"tier": Tier::Long.as_str(),
"namespace": "l5-no-llm",
"title": "L5 soft-hook absence",
"content": "Auto-tag must remain a soft hook when no LLM is wired; \
the store must still succeed and the operator's tags \
must round-trip unchanged through the response.",
"tags": ["op-tag-a", "op-tag-b"],
"priority": 5,
"confidence": 1.0,
"source": "api",
"metadata": {}
});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/memories")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.header("x-agent-id", "alice")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::CREATED,
"L5: store must succeed even when no LLM client is wired"
);
let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.unwrap();
let payload: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert!(
payload.get("auto_tags").is_none(),
"L5: auto_tags must be absent in the response when no LLM ran (got {payload})"
);
let lock = state.lock().await;
let rows = db::list(
&lock.0,
Some("l5-no-llm"),
None,
10,
0,
None,
None,
None,
None,
None,
)
.unwrap();
assert_eq!(rows.len(), 1, "L5: row must be persisted");
assert_eq!(
rows[0].tags,
vec!["op-tag-a".to_string(), "op-tag-b".to_string()],
"L5: operator tags must round-trip unchanged when LLM hook was a no-op"
);
}
#[tokio::test]
async fn maybe_auto_tag_gate_matrix_l5() {
let state = test_state();
let app = test_app_state(state);
let r = maybe_auto_tag(
&app,
"t",
"x".repeat(200).as_str(),
&["op".to_string()],
"ns",
)
.await;
assert!(
r.is_empty(),
"L5: operator-supplied tags must skip auto_tag"
);
let r = maybe_auto_tag(&app, "t", "short", &[], "ns").await;
assert!(r.is_empty(), "L5: short content must skip auto_tag");
let r = maybe_auto_tag(&app, "t", &"x".repeat(200), &[], "_internal").await;
assert!(r.is_empty(), "L5: internal namespace must skip auto_tag");
let r = maybe_auto_tag(&app, "t", &"x".repeat(200), &[], "ns").await;
assert!(
r.is_empty(),
"L5: tier with no llm_model must skip auto_tag (got {r:?})"
);
}
#[tokio::test]
async fn http_update_memory_uses_appstate() {
let state = test_state();
let now = Utc::now();
let id = {
let lock = state.lock().await;
let mem = Memory {
id: Uuid::new_v4().to_string(),
tier: Tier::Long,
namespace: "http-embed-test".into(),
title: "Before update".into(),
content: "Original content.".into(),
tags: vec![],
priority: 5,
confidence: 1.0,
source: "test".into(),
access_count: 0,
created_at: now.to_rfc3339(),
updated_at: now.to_rfc3339(),
last_accessed_at: None,
expires_at: None,
metadata: serde_json::json!({}),
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,
};
db::insert(&lock.0, &mem).unwrap()
};
let app = Router::new()
.route("/api/v1/memories/{id}", axum::routing::put(update_memory))
.with_state(test_app_state(state.clone()));
let patch = serde_json::json!({"content": "Updated content for semantic refresh."});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri(format!("/api/v1/memories/{id}"))
.method("PUT")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&patch).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn http_sync_push_applies_and_advances_clock() {
let state = test_state();
let app = Router::new()
.route("/api/v1/sync/push", axum_post(sync_push))
.with_state(test_app_state(state.clone()));
let now = Utc::now().to_rfc3339();
let body = serde_json::json!({
"sender_agent_id": "peer-alice",
"sender_clock": {"entries": {}},
"memories": [{
"id": Uuid::new_v4().to_string(),
"tier": Tier::Long.as_str(),
"namespace": "sync-smoke",
"title": "From peer",
"content": "Pushed via HTTP sync endpoint.",
"tags": [],
"priority": 5,
"confidence": 1.0,
"source": "api",
"access_count": 0,
"created_at": now,
"updated_at": now,
"last_accessed_at": null,
"expires_at": null,
"metadata": {"agent_id": "peer-alice"}
}],
"dry_run": false
});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/sync/push")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.header("x-agent-id", "local-receiver")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let lock = state.lock().await;
let rows = db::list(
&lock.0,
Some("sync-smoke"),
None,
10,
0,
None,
None,
None,
None,
None,
)
.unwrap();
assert_eq!(rows.len(), 1);
let clock = db::sync_state_load(&lock.0, "local-receiver").unwrap();
assert!(
clock.latest_from("peer-alice").is_some(),
"push must record sender in sync_state; got: {:?}",
clock.entries
);
}
#[tokio::test]
async fn http_sync_push_links_over_cap_rejected_1556() {
let state = test_state();
let app_state = test_app_state(state.clone());
let cap = app_state.max_page_size;
let app = Router::new()
.route("/api/v1/sync/push", axum_post(sync_push))
.with_state(app_state);
let link_created_at = Utc::now().to_rfc3339();
let over_cap: Vec<serde_json::Value> = (0..=cap)
.map(|i| {
serde_json::json!({
"source_id": format!("s{i}"),
"target_id": format!("t{i}"),
"relation": "related_to",
"created_at": link_created_at,
})
})
.collect();
let body = serde_json::json!({
"sender_agent_id": "peer-alice",
"sender_clock": {"entries": {}},
"memories": [],
"links": over_cap,
"dry_run": false
});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/sync/push")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.header("x-agent-id", "local-receiver")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert!(
v["error"]
.as_str()
.unwrap_or_default()
.contains("links per request"),
"expected links-cap rejection; got: {v}"
);
}
#[tokio::test]
async fn http_sync_push_applies_archives() {
let state = test_state();
let id = {
let lock = state.lock().await;
let now = Utc::now().to_rfc3339();
let mem = Memory {
id: Uuid::new_v4().to_string(),
tier: Tier::Long,
namespace: "s29".into(),
title: "Archive M1".into(),
content: "body".into(),
tags: vec![],
priority: 5,
confidence: 1.0,
source: "api".into(),
access_count: 0,
created_at: now.clone(),
updated_at: now,
last_accessed_at: None,
expires_at: None,
metadata: serde_json::json!({}),
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,
};
db::insert(&lock.0, &mem).unwrap()
};
let app = Router::new()
.route("/api/v1/sync/push", axum_post(sync_push))
.with_state(test_app_state(state.clone()));
let body = serde_json::json!({
"sender_agent_id": "peer-a",
"sender_clock": {"entries": {}},
"memories": [],
"archives": [id, "missing-on-peer"],
"dry_run": false
});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/sync/push")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["archived"], 1, "live row must be archived");
assert_eq!(v["noop"], 1, "missing id must no-op");
let lock = state.lock().await;
assert!(db::get(&lock.0, &id).unwrap().is_none());
let archived = db::list_archived(&lock.0, None, 10, 0).unwrap();
assert_eq!(archived.len(), 1);
assert_eq!(archived[0]["id"], id);
assert_eq!(archived[0]["archive_reason"], "sync_push");
}
#[tokio::test]
async fn http_archive_by_ids_happy_path() {
let state = test_state();
let live_id = {
let lock = state.lock().await;
let now = Utc::now().to_rfc3339();
let mem = Memory {
id: Uuid::new_v4().to_string(),
tier: Tier::Long,
namespace: "s29".into(),
title: "Live for archive".into(),
content: "will be archived".into(),
tags: vec![],
priority: 5,
confidence: 1.0,
source: "api".into(),
access_count: 0,
created_at: now.clone(),
updated_at: now,
last_accessed_at: None,
expires_at: None,
metadata: serde_json::json!({}),
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,
};
db::insert(&lock.0, &mem).unwrap()
};
let app = Router::new()
.route("/api/v1/archive", axum_post(archive_by_ids))
.with_state(test_app_state(state.clone()));
let body = serde_json::json!({
"ids": [live_id, "does-not-exist"],
"reason": "scenario_s29"
});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/archive")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["count"], 1);
assert_eq!(v["archived"].as_array().unwrap().len(), 1);
assert_eq!(v["missing"].as_array().unwrap().len(), 1);
assert_eq!(v["reason"], "scenario_s29");
let lock = state.lock().await;
assert!(db::get(&lock.0, &live_id).unwrap().is_none());
let archived = db::list_archived(&lock.0, None, 10, 0).unwrap();
assert_eq!(archived.len(), 1);
assert_eq!(archived[0]["id"], live_id);
assert_eq!(archived[0]["archive_reason"], "scenario_s29");
}
#[tokio::test]
async fn http_archive_by_ids_default_reason() {
let state = test_state();
let live_id = {
let lock = state.lock().await;
let now = Utc::now().to_rfc3339();
let mem = Memory {
id: Uuid::new_v4().to_string(),
tier: Tier::Long,
namespace: "s29-default".into(),
title: "Default reason".into(),
content: "c".into(),
tags: vec![],
priority: 5,
confidence: 1.0,
source: "api".into(),
access_count: 0,
created_at: now.clone(),
updated_at: now,
last_accessed_at: None,
expires_at: None,
metadata: serde_json::json!({}),
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,
};
db::insert(&lock.0, &mem).unwrap()
};
let app = Router::new()
.route("/api/v1/archive", axum_post(archive_by_ids))
.with_state(test_app_state(state.clone()));
let body = serde_json::json!({"ids": [live_id]});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/archive")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["reason"], "archive");
let lock = state.lock().await;
let archived = db::list_archived(&lock.0, None, 10, 0).unwrap();
assert_eq!(archived[0]["archive_reason"], "archive");
}
#[tokio::test]
async fn http_bulk_create_uses_appstate_and_persists() {
let state = test_state();
let app = Router::new()
.route("/api/v1/memories/bulk", axum_post(bulk_create))
.with_state(test_app_state(state.clone()));
let bodies: Vec<serde_json::Value> = (0..5)
.map(|i| {
serde_json::json!({
"tier": Tier::Long.as_str(),
"namespace": "bulk-appstate",
"title": format!("bulk-{i}"),
"content": format!("body-{i}"),
"tags": [],
"priority": 5,
"confidence": 1.0,
"source": "api",
"metadata": {}
})
})
.collect();
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/memories/bulk")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&bodies).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["created"], 5);
assert!(v["errors"].as_array().unwrap().is_empty());
let lock = state.lock().await;
let rows = db::list(
&lock.0,
Some("bulk-appstate"),
None,
100,
0,
None,
None,
None,
None,
None,
)
.unwrap();
assert_eq!(rows.len(), 5, "bulk rows must persist via AppState");
}
#[tokio::test]
async fn http_bulk_create_fans_out_with_federation() {
use std::sync::atomic::{AtomicUsize, Ordering};
use tokio::net::TcpListener;
let state = test_state();
let count = Arc::new(AtomicUsize::new(0));
let count_for_peer = count.clone();
#[derive(Clone)]
struct MockState {
count: Arc<AtomicUsize>,
}
async fn mock_sync_push(
axum::extract::State(s): axum::extract::State<MockState>,
Json(_body): Json<serde_json::Value>,
) -> (StatusCode, Json<serde_json::Value>) {
s.count.fetch_add(1, Ordering::Relaxed);
(
StatusCode::OK,
Json(json!({"applied":1,"noop":0,"skipped":0})),
)
}
let peer_app = Router::new()
.route("/api/v1/sync/push", axum_post(mock_sync_push))
.with_state(MockState {
count: count_for_peer,
});
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
axum::serve(listener, peer_app).await.ok();
});
let peer_url = format!("http://{addr}");
let fed = crate::federation::FederationConfig::build(
2, &[peer_url],
std::time::Duration::from_secs(2),
None,
None,
None,
"ai:bulk-test".to_string(),
None,
)
.unwrap()
.expect("federation must be built");
let app_state = AppState {
db: state.clone(),
embedder: Arc::new(None),
vector_index: Arc::new(Mutex::new(None)),
federation: Arc::new(Some(fed)),
tier_config: Arc::new(crate::config::FeatureTier::Keyword.config()),
scoring: Arc::new(crate::config::ResolvedScoring::default()),
profile: Arc::new(crate::profile::Profile::core()),
mcp_config: Arc::new(None),
active_keypair: Arc::new(None),
family_embeddings: Arc::new(RwLock::new(Some(Vec::new()))),
storage_backend: StorageBackend::Sqlite,
#[cfg(feature = "sal")]
store: test_sqlite_store_handle(),
llm: Arc::new(None),
auto_tag_model: Arc::new(None),
llm_call_timeout: std::time::Duration::from_secs(
crate::config::DEFAULT_LLM_CALL_TIMEOUT_SECS,
),
replay_cache: std::sync::Arc::new(crate::identity::replay::ReplayCache::default()),
verify_require_nonce: false,
federation_nonce_cache: std::sync::Arc::new(
crate::identity::replay::FederationNonceCache::default(),
),
autonomous_hooks: false,
recall_scope: Arc::new(None),
deferred_audit_queue: Arc::new(None),
admin_agent_ids: Arc::new(Vec::new()),
rule_cache: std::sync::Arc::new(crate::governance::rule_cache::RuleCache::new()),
resolved_models: std::sync::Arc::new(crate::config::ResolvedModels::default()),
runtime: crate::runtime_context::RuntimeContext::global_arc(),
max_page_size: crate::handlers::MAX_BULK_SIZE,
};
let router = Router::new()
.route("/api/v1/memories/bulk", axum_post(bulk_create))
.with_state(app_state);
let n = 4;
let bodies: Vec<serde_json::Value> = (0..n)
.map(|i| {
serde_json::json!({
"tier": Tier::Long.as_str(),
"namespace": "bulk-fanout",
"title": format!("bulk-fanout-{i}"),
"content": "c",
"tags": [],
"priority": 5,
"confidence": 1.0,
"source": "api",
"metadata": {}
})
})
.collect();
let resp = router
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/memories/bulk")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&bodies).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["created"], n);
let expected = n + 1;
for _ in 0..20 {
if count.load(Ordering::Relaxed) >= expected {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
assert_eq!(
count.load(Ordering::Relaxed),
expected,
"mock peer must receive one sync_push POST per bulk row plus one terminal catchup batch"
);
}
#[tokio::test]
async fn http_sync_push_rejects_oversized_batch_redteam_242() {
let state = test_state();
let app = Router::new()
.route("/api/v1/sync/push", axum_post(sync_push))
.with_state(test_app_state(state));
let now = Utc::now().to_rfc3339();
let mems: Vec<serde_json::Value> = (0..=MAX_BULK_SIZE)
.map(|i| {
serde_json::json!({
"id": Uuid::new_v4().to_string(),
"tier": Tier::Long.as_str(),
"namespace": "oversize",
"title": format!("m{i}"),
"content": "x",
"tags": [],
"priority": 5,
"confidence": 1.0,
"source": "api",
"access_count": 0,
"created_at": now,
"updated_at": now,
"last_accessed_at": null,
"expires_at": null,
"metadata": {}
})
})
.collect();
let body = serde_json::json!({
"sender_agent_id": "peer-flood",
"sender_clock": {"entries": {}},
"memories": mems,
"dry_run": false,
});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/sync/push")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn http_bulk_create_honors_operator_resolved_max_page_size() {
const SMALL_CAP: usize = 3;
let mut state = test_app_state(test_state());
state.max_page_size = SMALL_CAP;
let app = Router::new()
.route("/api/v1/memories/bulk", axum_post(bulk_create))
.with_state(state);
let bodies: Vec<serde_json::Value> = (0..=SMALL_CAP)
.map(|i| {
serde_json::json!({
"title": format!("m{i}"),
"content": "x",
"namespace": "cap-test",
})
})
.collect();
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/memories/bulk")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&bodies).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::BAD_REQUEST,
"bulk_create must reject a batch above the operator-resolved max_page_size"
);
let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert!(
v["error"]
.as_str()
.unwrap_or_default()
.contains(&SMALL_CAP.to_string()),
"error message must echo the configured cap, got {v:?}"
);
}
#[tokio::test]
async fn http_sync_push_dry_run_applies_nothing() {
let state = test_state();
let app = Router::new()
.route("/api/v1/sync/push", axum_post(sync_push))
.with_state(test_app_state(state.clone()));
let now = Utc::now().to_rfc3339();
let body = serde_json::json!({
"sender_agent_id": "peer-bob",
"sender_clock": {"entries": {}},
"memories": [{
"id": Uuid::new_v4().to_string(),
"tier": Tier::Long.as_str(),
"namespace": "sync-dryrun",
"title": "Must not land",
"content": "Preview only.",
"tags": [],
"priority": 5,
"confidence": 1.0,
"source": "api",
"access_count": 0,
"created_at": now,
"updated_at": now,
"last_accessed_at": null,
"expires_at": null,
"metadata": {}
}],
"dry_run": true
});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/sync/push")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let lock = state.lock().await;
let rows = db::list(
&lock.0,
Some("sync-dryrun"),
None,
10,
0,
None,
None,
None,
None,
None,
)
.unwrap();
assert!(rows.is_empty(), "dry_run must not write rows");
}
#[tokio::test]
async fn http_contradictions_surfaces_same_topic_candidates_and_synth_link() {
let state = test_state();
let now = Utc::now().to_rfc3339();
{
let lock = state.lock().await;
let topic = "sky-color-test";
for (title, agent, content) in [
("sky-color-test-alice", "ai:alice", "sky-color-test is blue"),
("sky-color-test-bob", "ai:bob", "sky-color-test is red"),
] {
let mem = Memory {
id: Uuid::new_v4().to_string(),
tier: Tier::Mid,
namespace: "contradictions-test".into(),
title: title.into(),
content: content.into(),
tags: vec![],
priority: 5,
confidence: 1.0,
source: "api".into(),
access_count: 0,
created_at: now.clone(),
updated_at: now.clone(),
last_accessed_at: None,
expires_at: None,
metadata: serde_json::json!({
"agent_id": agent,
"topic": topic,
}),
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,
};
db::insert(&lock.0, &mem).unwrap();
}
}
let app = Router::new()
.route("/api/v1/contradictions", axum_get(detect_contradictions))
.with_state(test_app_state(state));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/contradictions?topic=sky-color-test&namespace=contradictions-test")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
let memories = v["memories"].as_array().unwrap();
assert_eq!(memories.len(), 2, "both candidates should be returned");
let links = v["links"].as_array().unwrap();
let synth_contradict = links.iter().find(|l| {
l["relation"].as_str() == Some("contradicts") && l["synthesized"].as_bool() == Some(true)
});
assert!(
synth_contradict.is_some(),
"expected a synthesized contradicts link between alice and bob"
);
}
#[tokio::test]
async fn http_contradictions_requires_topic_or_namespace() {
let state = test_state();
let app = Router::new()
.route("/api/v1/contradictions", axum_get(detect_contradictions))
.with_state(test_app_state(state));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/contradictions")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn http_sync_push_applies_deletions() {
let state = test_state();
let now = Utc::now().to_rfc3339();
let seeded_id = {
let lock = state.lock().await;
let mem = Memory {
id: Uuid::new_v4().to_string(),
tier: Tier::Mid,
namespace: "delete-fanout".into(),
title: "to-be-deleted".into(),
content: "body".into(),
tags: vec![],
priority: 5,
confidence: 1.0,
source: "api".into(),
access_count: 0,
created_at: now.clone(),
updated_at: now.clone(),
last_accessed_at: None,
expires_at: None,
metadata: serde_json::json!({"agent_id": "ai:seeder"}),
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,
};
db::insert(&lock.0, &mem).unwrap()
};
let app = Router::new()
.route("/api/v1/sync/push", axum_post(sync_push))
.with_state(test_app_state(state.clone()));
let body = serde_json::json!({
"sender_agent_id": "peer-alice",
"sender_clock": {"entries": {}},
"memories": [],
"deletions": [seeded_id.clone()],
"dry_run": false
});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/sync/push")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.header("x-agent-id", "local-receiver")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["deleted"], 1);
let lock = state.lock().await;
let gone = db::get(&lock.0, &seeded_id).unwrap();
assert!(
gone.is_none(),
"row should have been tombstoned by sync_push"
);
}
#[tokio::test]
async fn http_sync_push_applies_incoming_links() {
let state = test_state();
let now = Utc::now().to_rfc3339();
let (m1, m2) = {
let lock = state.lock().await;
let m1 = Memory {
id: Uuid::new_v4().to_string(),
tier: Tier::Mid,
namespace: "link-fanout".into(),
title: "source".into(),
content: "a".into(),
tags: vec![],
priority: 5,
confidence: 1.0,
source: "api".into(),
access_count: 0,
created_at: now.clone(),
updated_at: now.clone(),
last_accessed_at: None,
expires_at: None,
metadata: serde_json::json!({"agent_id": "ai:seeder"}),
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 m1_id = db::insert(&lock.0, &m1).unwrap();
let m2 = Memory {
id: Uuid::new_v4().to_string(),
tier: Tier::Mid,
namespace: "link-fanout".into(),
title: "target".into(),
content: "b".into(),
tags: vec![],
priority: 5,
confidence: 1.0,
source: "api".into(),
access_count: 0,
created_at: now.clone(),
updated_at: now.clone(),
last_accessed_at: None,
expires_at: None,
metadata: serde_json::json!({"agent_id": "ai:seeder"}),
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 m2_id = db::insert(&lock.0, &m2).unwrap();
(m1_id, m2_id)
};
let app = Router::new()
.route("/api/v1/sync/push", axum_post(sync_push))
.with_state(test_app_state(state.clone()));
let body = serde_json::json!({
"sender_agent_id": "peer-alice",
"sender_clock": {"entries": {}},
"memories": [],
"links": [{
"source_id": m1,
"target_id": m2,
"relation": "related_to",
"created_at": now,
}],
"dry_run": false
});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/sync/push")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.header("x-agent-id", "local-receiver")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["links_applied"], 1);
let lock = state.lock().await;
let links = db::get_links(&lock.0, &m1).unwrap();
assert_eq!(links.len(), 1);
assert_eq!(links[0].target_id, m2);
assert_eq!(
links[0].relation,
crate::models::MemoryLinkRelation::RelatedTo
);
}
#[tokio::test]
async fn http_sync_push_refuses_reflection_cycle_from_peer() {
use crate::config::{
PermissionsMode, lock_permissions_mode_for_test, override_active_permissions_mode_for_test,
};
let _gate = lock_permissions_mode_for_test();
override_active_permissions_mode_for_test(PermissionsMode::Off);
let state = test_state();
let now = Utc::now().to_rfc3339();
let (a_id, b_id) = {
let lock = state.lock().await;
let a = Memory {
id: Uuid::new_v4().to_string(),
tier: Tier::Long,
namespace: "a3-fed-cycle".into(),
title: "a".into(),
content: "a".into(),
tags: vec![],
priority: 5,
confidence: 1.0,
source: "api".into(),
access_count: 0,
created_at: now.clone(),
updated_at: now.clone(),
last_accessed_at: None,
expires_at: None,
metadata: serde_json::json!({}),
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 a_id = db::insert(&lock.0, &a).unwrap();
let b = Memory {
id: Uuid::new_v4().to_string(),
tier: Tier::Long,
namespace: "a3-fed-cycle".into(),
title: "b".into(),
content: "b".into(),
tags: vec![],
priority: 5,
confidence: 1.0,
source: "api".into(),
access_count: 0,
created_at: now.clone(),
updated_at: now.clone(),
last_accessed_at: None,
expires_at: None,
metadata: serde_json::json!({}),
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 b_id = db::insert(&lock.0, &b).unwrap();
db::create_link(&lock.0, &a_id, &b_id, "reflects_on").unwrap();
(a_id, b_id)
};
let app = Router::new()
.route("/api/v1/sync/push", axum_post(sync_push))
.with_state(test_app_state(state.clone()));
let body = serde_json::json!({
"sender_agent_id": "peer-alice",
"sender_clock": {"entries": {}},
"memories": [],
"links": [{
"source_id": b_id,
"target_id": a_id,
"relation": "reflects_on",
"created_at": now,
}],
"dry_run": false
});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/sync/push")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.header("x-agent-id", "local-receiver")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let lock = state.lock().await;
let links_from_b = db::get_links(&lock.0, &b_id).unwrap();
let landed = links_from_b.iter().any(|l| {
l.source_id == b_id
&& l.target_id == a_id
&& l.relation == crate::models::MemoryLinkRelation::ReflectsOn
});
assert!(
!landed,
"cycle-closing reflects_on must NOT land via sync_push"
);
}
#[tokio::test]
async fn http_sync_push_governance_bypass_on_peer_attested() {
use crate::config::{
PermissionsMode, lock_permissions_mode_for_test, override_active_permissions_mode_for_test,
};
let _gate = lock_permissions_mode_for_test();
override_active_permissions_mode_for_test(PermissionsMode::Off);
let state = test_state();
let now = Utc::now().to_rfc3339();
let (s_id, t_id) = {
let lock = state.lock().await;
let s = Memory {
id: Uuid::new_v4().to_string(),
tier: Tier::Long,
namespace: "a3-fed-bypass".into(),
title: "src".into(),
content: "src".into(),
tags: vec![],
priority: 5,
confidence: 1.0,
source: "api".into(),
access_count: 0,
created_at: now.clone(),
updated_at: now.clone(),
last_accessed_at: None,
expires_at: None,
metadata: serde_json::json!({}),
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 s_id = db::insert(&lock.0, &s).unwrap();
let t = Memory {
id: Uuid::new_v4().to_string(),
tier: Tier::Long,
namespace: "a3-fed-bypass".into(),
title: "tgt".into(),
content: "tgt".into(),
tags: vec![],
priority: 5,
confidence: 1.0,
source: "api".into(),
access_count: 0,
created_at: now.clone(),
updated_at: now.clone(),
last_accessed_at: None,
expires_at: None,
metadata: serde_json::json!({}),
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 t_id = db::insert(&lock.0, &t).unwrap();
(s_id, t_id)
};
let app = Router::new()
.route("/api/v1/sync/push", axum_post(sync_push))
.with_state(test_app_state(state.clone()));
let body = serde_json::json!({
"sender_agent_id": "peer-alice",
"sender_clock": {"entries": {}},
"memories": [],
"links": [{
"source_id": s_id,
"target_id": t_id,
"relation": "related_to",
"created_at": now,
}],
"dry_run": false
});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/sync/push")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.header("x-agent-id", "local-receiver")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["links_applied"], 1);
}
#[tokio::test]
async fn http_sync_since_streams_new_memories_only() {
let state = test_state();
let old_ts = "2020-01-01T00:00:00+00:00";
let new_ts = Utc::now().to_rfc3339();
{
let lock = state.lock().await;
for (title, ts) in [("old-mem", old_ts), ("new-mem", new_ts.as_str())] {
let mem = Memory {
id: Uuid::new_v4().to_string(),
tier: Tier::Long,
namespace: "since-test".into(),
title: title.into(),
content: "body".into(),
tags: vec![],
priority: 5,
confidence: 1.0,
source: "api".into(),
access_count: 0,
created_at: ts.to_string(),
updated_at: ts.to_string(),
last_accessed_at: None,
expires_at: None,
metadata: serde_json::json!({"scope": "shared"}),
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,
};
db::insert(&lock.0, &mem).unwrap();
}
}
let app = Router::new()
.route("/api/v1/sync/since", axum_get(sync_since))
.with_state(test_app_state(state));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/sync/since?since=2020-06-01T00:00:00%2B00:00")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
let titles: Vec<String> = v["memories"]
.as_array()
.unwrap()
.iter()
.filter_map(|m| m["title"].as_str().map(str::to_string))
.collect();
assert_eq!(titles, vec!["new-mem".to_string()]);
}
#[tokio::test]
async fn http_sync_since_includes_s39_diagnostic_fields() {
let state = test_state();
let mid_ts = "2024-06-01T00:00:00+00:00";
let newer_ts = "2025-06-01T00:00:00+00:00";
let newest_ts = "2026-01-01T00:00:00+00:00";
{
let lock = state.lock().await;
for (title, ts) in [("mid", mid_ts), ("newer", newer_ts), ("newest", newest_ts)] {
let mem = Memory {
id: Uuid::new_v4().to_string(),
tier: Tier::Long,
namespace: "s39-diag".into(),
title: title.into(),
content: "c".into(),
tags: vec![],
priority: 5,
confidence: 1.0,
source: "api".into(),
access_count: 0,
created_at: ts.to_string(),
updated_at: ts.to_string(),
last_accessed_at: None,
expires_at: None,
metadata: serde_json::json!({"scope": "shared"}),
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,
};
db::insert(&lock.0, &mem).unwrap();
}
}
let app = Router::new()
.route("/api/v1/sync/since", axum_get(sync_since))
.with_state(test_app_state(state.clone()));
let since = "2024-01-01T00:00:00%2B00:00";
let resp = app
.oneshot(
axum::http::Request::builder()
.uri(format!("/api/v1/sync/since?since={since}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["count"], 3);
assert_eq!(v["updated_since"], "2024-01-01T00:00:00+00:00");
assert_eq!(v["earliest_updated_at"], mid_ts);
assert_eq!(v["latest_updated_at"], newest_ts);
let empty_app = Router::new()
.route("/api/v1/sync/since", axum_get(sync_since))
.with_state(test_app_state(state));
let resp = empty_app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/sync/since?since=2099-01-01T00:00:00%2B00:00")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["count"], 0);
assert!(v["earliest_updated_at"].is_null());
assert!(v["latest_updated_at"].is_null());
assert_eq!(v["updated_since"], "2099-01-01T00:00:00+00:00");
}
#[tokio::test]
async fn sync_since_rejects_garbage_timestamp_with_400() {
let state = test_state();
let app = Router::new()
.route("/api/v1/sync/since", axum_get(sync_since))
.with_state(test_app_state(state));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/sync/since?since=not-a-date")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert!(v["error"].as_str().unwrap().contains("RFC 3339"));
}
#[tokio::test]
async fn sync_state_observe_is_monotonic() {
let conn = db::open(std::path::Path::new(":memory:")).unwrap();
let older = "2020-01-01T00:00:00+00:00";
let newer = "2026-04-17T00:00:00+00:00";
db::sync_state_observe(&conn, "local", "peer-a", newer).unwrap();
db::sync_state_observe(&conn, "local", "peer-a", older).unwrap();
let clock = db::sync_state_load(&conn, "local").unwrap();
assert_eq!(clock.latest_from("peer-a"), Some(newer));
}
async fn dummy_handler() -> impl IntoResponse {
(StatusCode::OK, "ok")
}
fn auth_app(api_key: Option<&str>) -> Router {
let auth_state = ApiKeyState {
key: api_key.map(String::from),
mtls_enforced: false,
};
Router::new()
.route("/api/v1/health", axum_get(dummy_handler))
.route("/api/v1/memories", axum_get(dummy_handler))
.layer(axum::middleware::from_fn_with_state(
auth_state,
api_key_auth,
))
}
#[tokio::test]
async fn api_key_no_key_configured_allows_all() {
let app = auth_app(None);
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/memories")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn api_key_valid_header_allows() {
let app = auth_app(Some("secret123"));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/memories")
.header("x-api-key", "secret123")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn api_key_invalid_header_rejected() {
let app = auth_app(Some("secret123"));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/memories")
.header("x-api-key", "wrong")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn api_key_missing_header_rejected() {
let app = auth_app(Some("secret123"));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/memories")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn api_key_valid_query_param_allows() {
let app = auth_app(Some("secret123"));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/memories?api_key=secret123")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn api_key_health_exempt() {
let app = auth_app(Some("secret123"));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/health")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn create_memory_rejects_invalid_json() {
let state = test_state();
let app = Router::new()
.route("/api/v1/memories", axum_post(create_memory))
.with_state(test_app_state(state));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/memories")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(b"not valid json".to_vec()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn create_memory_rejects_missing_required_fields() {
let state = test_state();
let app = Router::new()
.route("/api/v1/memories", axum_post(create_memory))
.with_state(test_app_state(state));
let body = serde_json::json!({
"tier": Tier::Long.as_str(),
"namespace": "test",
"content": "body text",
"tags": [],
"priority": 5,
"confidence": 1.0,
"source": "api",
"metadata": {}
});
let resp = app
.clone()
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/memories")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let payload: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert!(
payload.get("error").and_then(|v| v.as_str()).is_some(),
"F9: response must include sanitized `error` field"
);
let fields = payload
.get("fields")
.and_then(|v| v.as_array())
.expect("F9: response must include `fields` array");
assert!(
fields.iter().any(|v| v.as_str() == Some("title")),
"F9: `fields` must name the missing required field (`title`)"
);
}
#[tokio::test]
async fn create_memory_rejects_empty_title() {
let state = test_state();
let app = Router::new()
.route("/api/v1/memories", axum_post(create_memory))
.with_state(test_app_state(state));
let body = serde_json::json!({
"tier": Tier::Long.as_str(),
"namespace": "test",
"title": "",
"content": "body text",
"tags": [],
"priority": 5,
"confidence": 1.0,
"source": "api",
"metadata": {}
});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/memories")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert!(v["error"].as_str().unwrap().contains("title"));
}
#[tokio::test]
async fn create_memory_rejects_oversized_content() {
let state = test_state();
let app = Router::new()
.route("/api/v1/memories", axum_post(create_memory))
.with_state(test_app_state(state));
let oversized = "x".repeat(65537);
let body = serde_json::json!({
"tier": Tier::Long.as_str(),
"namespace": "test",
"title": "Test",
"content": oversized,
"tags": [],
"priority": 5,
"confidence": 1.0,
"source": "api",
"metadata": {}
});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/memories")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert!(v["error"].as_str().unwrap().contains("exceeds max size"));
}
#[tokio::test]
async fn create_memory_rejects_invalid_tier() {
let state = test_state();
let app = Router::new()
.route("/api/v1/memories", axum_post(create_memory))
.with_state(test_app_state(state));
let body_str = r#"{"tier":"invalid_tier","namespace":"test","title":"Test","content":"body","tags":[],"priority":5,"confidence":1.0,"source":"api","metadata":{}}"#;
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/memories")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(body_str.as_bytes().to_vec()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn create_memory_rejects_invalid_priority() {
let state = test_state();
let app = Router::new()
.route("/api/v1/memories", axum_post(create_memory))
.with_state(test_app_state(state));
let body = serde_json::json!({
"tier": Tier::Long.as_str(),
"namespace": "test",
"title": "Test",
"content": "body",
"tags": [],
"priority": 0, "confidence": 1.0,
"source": "api",
"metadata": {}
});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/memories")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn create_memory_rejects_invalid_confidence() {
let state = test_state();
let app = Router::new()
.route("/api/v1/memories", axum_post(create_memory))
.with_state(test_app_state(state));
let body = serde_json::json!({
"tier": Tier::Long.as_str(),
"namespace": "test",
"title": "Test",
"content": "body",
"tags": [],
"priority": 5,
"confidence": 1.5, "source": "api",
"metadata": {}
});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/memories")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn create_memory_rejects_invalid_source() {
let state = test_state();
let app = Router::new()
.route("/api/v1/memories", axum_post(create_memory))
.with_state(test_app_state(state));
let body = serde_json::json!({
"tier": Tier::Long.as_str(),
"namespace": "test",
"title": "Test",
"content": "body",
"tags": [],
"priority": 5,
"confidence": 1.0,
"source": "invalid_source",
"metadata": {}
});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/memories")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn update_memory_rejects_invalid_id() {
let state = test_state();
let app = Router::new()
.route("/api/v1/memories/{id}", axum::routing::put(update_memory))
.with_state(test_app_state(state));
let body = serde_json::json!({"content": "new content"});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/memories/@@@@@@@@@@@@") .method("PUT")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert!(resp.status() == StatusCode::BAD_REQUEST || resp.status() == StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn update_memory_rejects_oversized_content() {
let state = test_state();
let now = Utc::now();
let id = {
let lock = state.lock().await;
let mem = Memory {
id: Uuid::new_v4().to_string(),
tier: Tier::Long,
namespace: "test".into(),
title: "To Update".into(),
content: "Original".into(),
tags: vec![],
priority: 5,
confidence: 1.0,
source: "test".into(),
access_count: 0,
created_at: now.to_rfc3339(),
updated_at: now.to_rfc3339(),
last_accessed_at: None,
expires_at: None,
metadata: serde_json::json!({}),
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,
};
db::insert(&lock.0, &mem).unwrap()
};
let app = Router::new()
.route("/api/v1/memories/{id}", axum::routing::put(update_memory))
.with_state(test_app_state(state));
let oversized = "x".repeat(65537);
let body = serde_json::json!({"content": oversized});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri(format!("/api/v1/memories/{id}"))
.method("PUT")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn update_memory_rejects_invalid_confidence() {
let state = test_state();
let now = Utc::now();
let id = {
let lock = state.lock().await;
let mem = Memory {
id: Uuid::new_v4().to_string(),
tier: Tier::Long,
namespace: "test".into(),
title: "To Update".into(),
content: "Original".into(),
tags: vec![],
priority: 5,
confidence: 1.0,
source: "test".into(),
access_count: 0,
created_at: now.to_rfc3339(),
updated_at: now.to_rfc3339(),
last_accessed_at: None,
expires_at: None,
metadata: serde_json::json!({}),
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,
};
db::insert(&lock.0, &mem).unwrap()
};
let app = Router::new()
.route("/api/v1/memories/{id}", axum::routing::put(update_memory))
.with_state(test_app_state(state));
let body = serde_json::json!({"confidence": -0.5});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri(format!("/api/v1/memories/{id}"))
.method("PUT")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn link_rejects_self_link() {
let state = test_state();
let app = Router::new()
.route("/api/v1/links", axum_post(create_link))
.with_state(test_app_state(state));
let same_id = Uuid::new_v4().to_string();
let body = serde_json::json!({
"source_id": same_id,
"target_id": same_id,
"relation": "related_to"
});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/links")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert!(
v["error"]
.as_str()
.unwrap()
.contains("cannot link a memory to itself")
);
}
#[tokio::test]
async fn link_rejects_unknown_relation() {
let state = test_state();
let src = insert_test_memory(&state, "ns-link-relation", "src").await;
let tgt = insert_test_memory(&state, "ns-link-relation", "tgt").await;
let app = Router::new()
.route("/api/v1/links", axum_post(create_link))
.with_state(test_app_state(state));
let body = serde_json::json!({
"source_id": src,
"target_id": tgt,
"relation": "invalid_relation"
});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/links")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::BAD_REQUEST,
"off-closed-set relation must surface as a structured 400 (#706)"
);
let body_bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap();
assert_eq!(v["error"], "invalid_relation");
assert_eq!(v["got"], "invalid_relation");
assert!(v["allowed"].is_array());
}
#[tokio::test]
async fn link_rejects_malformed_relation() {
let state = test_state();
let src = insert_test_memory(&state, "ns-link-malformed", "src").await;
let tgt = insert_test_memory(&state, "ns-link-malformed", "tgt").await;
let app_state = test_app_state(state);
for bad in ["BAD", "bad relation", "bad-relation", "bad/relation"] {
let app = Router::new()
.route("/api/v1/links", axum_post(create_link))
.with_state(app_state.clone());
let body = serde_json::json!({
"source_id": src,
"target_id": tgt,
"relation": bad,
});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/links")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::BAD_REQUEST,
"relation `{bad}` should be rejected by validate_relation",
);
let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert!(
v["error"].as_str().unwrap().contains("relation"),
"error body for `{bad}` should mention `relation`; got: {v}",
);
}
}
#[tokio::test]
async fn recall_post_rejects_empty_context() {
let state = test_state();
let app = Router::new()
.route("/api/v1/memories/recall", axum_post(recall_memories_post))
.with_state(test_app_state(state));
let body = serde_json::json!({
"context": "",
"limit": 10
});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/memories/recall")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn recall_post_zero_budget_tokens_returns_empty() {
let state = test_state();
let app = Router::new()
.route("/api/v1/memories/recall", axum_post(recall_memories_post))
.with_state(test_app_state(state));
let body = serde_json::json!({
"context": "search term",
"limit": 10,
"budget_tokens": 0
});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/memories/recall")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["count"], 0, "budget_tokens=0 returns zero memories");
assert_eq!(v["budget_tokens"], 0);
assert_eq!(v["meta"]["budget_overflow"], false);
}
#[tokio::test]
async fn recall_get_rejects_empty_context() {
let state = test_state();
let app = Router::new()
.route(
"/api/v1/memories/recall",
axum::routing::get(recall_memories_get),
)
.with_state(test_app_state(state));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/memories/recall?context=")
.method("GET")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn register_agent_rejects_invalid_agent_id() {
let state = test_state();
let app = Router::new()
.route("/api/v1/agents", axum_post(register_agent))
.with_state(test_app_state(state));
let body = serde_json::json!({
"agent_id": "x".repeat(129), "agent_type": "human",
"capabilities": []
});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/agents")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn register_agent_rejects_invalid_agent_type() {
let state = test_state();
let app = Router::new()
.route("/api/v1/agents", axum_post(register_agent))
.with_state(test_app_state(state));
let body = serde_json::json!({
"agent_id": "test-agent",
"agent_type": "invalid_type",
"capabilities": []
});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/agents")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn subscribe_rejects_private_ip() {
let state = test_state();
let app = Router::new()
.route("/api/v1/subscriptions", axum_post(subscribe))
.with_state(test_app_state(state));
let body = serde_json::json!({
"url": "http://10.0.0.1/webhook",
"events": "*",
"secret": "test-sub-secret",
});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/subscriptions")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.header("x-agent-id", "alice")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
let error_msg = v["error"].as_str().unwrap();
assert!(
error_msg.contains("private")
|| error_msg.contains("link-local")
|| error_msg.contains("https")
|| error_msg.contains("non-loopback")
);
}
#[tokio::test]
async fn subscribe_rejects_file_url() {
let state = test_state();
let app = Router::new()
.route("/api/v1/subscriptions", axum_post(subscribe))
.with_state(test_app_state(state));
let body = serde_json::json!({
"url": "file:///etc/passwd",
"events": "*",
"secret": "test-sub-secret",
});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/subscriptions")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.header("x-agent-id", "alice")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn subscribe_accepts_localhost_loopback() {
let state = test_state();
let app = Router::new()
.route("/api/v1/subscriptions", axum_post(subscribe))
.with_state(test_app_state(state));
let body = serde_json::json!({
"url": "http://localhost/webhook",
"events": "*",
"secret": "test-sub-secret",
});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/subscriptions")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.header("x-agent-id", "alice")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert!(resp.status() == StatusCode::CREATED || resp.status() == StatusCode::OK);
}
#[tokio::test]
async fn notify_rejects_missing_payload() {
let state = test_state();
let app = Router::new()
.route("/api/v1/notify", axum_post(notify))
.with_state(test_app_state(state));
let body = serde_json::json!({
"target_agent_id": "bob",
"title": "A message"
});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/notify")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.header("x-agent-id", "alice")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert!(
v["error"].as_str().unwrap().contains("payload")
|| v["error"].as_str().unwrap().contains("content")
);
}
#[tokio::test]
async fn create_memory_handles_missing_content_type() {
let state = test_state();
let app = Router::new()
.route("/api/v1/memories", axum_post(create_memory))
.with_state(test_app_state(state));
let body = serde_json::json!({
"tier": Tier::Long.as_str(),
"namespace": "test",
"title": "Test",
"content": "body",
"tags": [],
"priority": 5,
"confidence": 1.0,
"source": "api",
"metadata": {}
});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/memories")
.method("POST")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert!(resp.status() != StatusCode::CREATED);
}
#[tokio::test]
async fn list_memories_handles_limit_zero() {
let state = test_state();
let app = Router::new()
.route("/api/v1/memories", axum::routing::get(list_memories))
.with_state(test_app_state(state));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/memories?limit=0")
.method("GET")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn list_memories_clamps_oversized_limit() {
let state = test_state();
let app = Router::new()
.route("/api/v1/memories", axum::routing::get(list_memories))
.with_state(test_app_state(state));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/memories?limit=10000") .method("GET")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn search_memories_handles_negative_limit() {
let state = test_state();
let app = Router::new()
.route(
"/api/v1/memories/search",
axum::routing::get(search_memories),
)
.with_state(test_app_state(state));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/memories/search?query=test&limit=-1")
.method("GET")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert!(resp.status() == StatusCode::OK || resp.status() == StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn api_key_missing_when_required_rejects() {
let app = auth_app(Some("secret123"));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/memories")
.method("GET")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn api_key_wrong_value_rejects() {
let app = auth_app(Some("secret123"));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/memories")
.method("GET")
.header("x-api-key", "wrong_secret")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
async fn insert_test_memory(state: &Db, namespace: &str, title: &str) -> String {
let lock = state.lock().await;
let now = Utc::now().to_rfc3339();
let mem = Memory {
id: Uuid::new_v4().to_string(),
tier: Tier::Long,
namespace: namespace.into(),
title: title.into(),
content: format!("content for {title}"),
tags: vec![],
priority: 5,
confidence: 1.0,
source: "test".into(),
access_count: 0,
created_at: now.clone(),
updated_at: now,
last_accessed_at: None,
expires_at: None,
metadata: serde_json::json!({"scope": "collective"}),
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,
};
db::insert(&lock.0, &mem).unwrap()
}
#[tokio::test]
async fn http_list_archive_rejects_limit_zero() {
let state = test_state();
let app = Router::new()
.route("/api/v1/archive", axum::routing::get(list_archive))
.with_state(test_app_state(state.clone()));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/archive?limit=0")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert!(v["error"].as_str().unwrap().contains("limit"));
}
#[tokio::test]
async fn http_list_archive_clamps_oversized_limit() {
let state = test_state();
let app = Router::new()
.route("/api/v1/archive", axum::routing::get(list_archive))
.with_state(test_app_state(state.clone()));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/archive?limit=99999")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn http_list_archive_filters_by_namespace() {
let state = test_state();
let id = insert_test_memory(&state, "arch-ns-a", "to-archive").await;
{
let lock = state.lock().await;
db::archive_memory(&lock.0, &id, Some("test")).unwrap();
}
let app = Router::new()
.route("/api/v1/archive", axum::routing::get(list_archive))
.with_state(test_app_state(state.clone()));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/archive?namespace=arch-ns-a&limit=10")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["count"], 1);
}
#[tokio::test]
async fn http_restore_archive_404_for_unknown_id() {
let state = test_state();
let app = Router::new()
.route("/api/v1/archive/{id}/restore", axum_post(restore_archive))
.with_state(test_app_state(state));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/archive/00000000-0000-0000-0000-000000000000/restore")
.method("POST")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn http_restore_archive_rejects_empty_id() {
let state = test_state();
let app = Router::new()
.route("/api/v1/archive/{id}/restore", axum_post(restore_archive))
.with_state(test_app_state(state));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/archive/%01/restore")
.method("POST")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn http_restore_archive_double_restore_returns_404() {
let state = test_state();
let id = insert_test_memory(&state, "restore-twice", "row").await;
{
let lock = state.lock().await;
db::archive_memory(&lock.0, &id, Some("test")).unwrap();
}
let app = Router::new()
.route("/api/v1/archive/{id}/restore", axum_post(restore_archive))
.with_state(test_app_state(state.clone()));
let resp = app
.clone()
.oneshot(
axum::http::Request::builder()
.uri(format!("/api/v1/archive/{id}/restore"))
.method("POST")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let resp = app
.oneshot(
axum::http::Request::builder()
.uri(format!("/api/v1/archive/{id}/restore"))
.method("POST")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn http_purge_archive_zero_days_purges_all() {
let state = test_state();
let id = insert_test_memory(&state, "purge-zero", "x").await;
{
let lock = state.lock().await;
db::archive_memory(&lock.0, &id, Some("test")).unwrap();
}
let app = Router::new()
.route("/api/v1/archive/purge", axum_post(purge_archive))
.with_state(test_app_state(state.clone()));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/archive/purge?older_than_days=0")
.method("POST")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert!(v["purged"].as_u64().is_some());
}
#[tokio::test]
async fn http_purge_archive_negative_days_returns_500() {
let state = test_state();
let app = Router::new()
.route("/api/v1/archive/purge", axum_post(purge_archive))
.with_state(test_app_state(state));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/archive/purge?older_than_days=-1")
.method("POST")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
}
#[tokio::test]
async fn http_purge_archive_no_days_purges_unconditional() {
let state = test_state();
let id = insert_test_memory(&state, "purge-all", "x").await;
{
let lock = state.lock().await;
db::archive_memory(&lock.0, &id, Some("test")).unwrap();
}
let app = Router::new()
.route("/api/v1/archive/purge", axum_post(purge_archive))
.with_state(test_app_state_with_admin(state.clone(), "ops:admin"));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/archive/purge")
.method("POST")
.header("x-agent-id", "ops:admin")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["purged"], 1);
}
#[tokio::test]
async fn http_archive_stats_reports_per_namespace_counts() {
let state = test_state();
let id_a = insert_test_memory(&state, "stats-a", "a").await;
let id_b = insert_test_memory(&state, "stats-b", "b").await;
{
let lock = state.lock().await;
db::archive_memory(&lock.0, &id_a, Some("t")).unwrap();
db::archive_memory(&lock.0, &id_b, Some("t")).unwrap();
}
let app = Router::new()
.route("/api/v1/archive/stats", axum::routing::get(archive_stats))
.with_state(test_app_state(state.clone()));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/archive/stats")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["archived_total"], 2);
assert_eq!(v["by_namespace"].as_array().unwrap().len(), 2);
}
#[tokio::test]
async fn http_archive_by_ids_rejects_oversized_batch() {
let state = test_state();
let app = Router::new()
.route("/api/v1/archive", axum_post(archive_by_ids))
.with_state(test_app_state(state));
let big_ids: Vec<String> = (0..=MAX_BULK_SIZE)
.map(|_| Uuid::new_v4().to_string())
.collect();
let body = serde_json::json!({"ids": big_ids});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/archive")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert!(v["error"].as_str().unwrap().contains("archive limited"));
}
#[tokio::test]
async fn http_archive_by_ids_rejects_invalid_id_in_batch() {
let state = test_state();
let app = Router::new()
.route("/api/v1/archive", axum_post(archive_by_ids))
.with_state(test_app_state(state));
let body = serde_json::json!({"ids": [" "]});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/archive")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert!(v["error"].as_str().unwrap().contains("invalid id"));
}
#[tokio::test]
async fn http_archive_by_ids_all_missing() {
let state = test_state();
let app = Router::new()
.route("/api/v1/archive", axum_post(archive_by_ids))
.with_state(test_app_state(state));
let ids: Vec<String> = (0..3).map(|_| Uuid::new_v4().to_string()).collect();
let body = serde_json::json!({"ids": ids});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/archive")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["count"], 0);
assert_eq!(v["archived"].as_array().unwrap().len(), 0);
assert_eq!(v["missing"].as_array().unwrap().len(), 3);
}
#[tokio::test]
async fn http_bulk_create_oversized_batch_rejected() {
let state = test_state();
let app = Router::new()
.route("/api/v1/memories/bulk", axum_post(bulk_create))
.with_state(test_app_state(state));
let bodies: Vec<serde_json::Value> = (0..=MAX_BULK_SIZE)
.map(|i| {
serde_json::json!({
"tier": Tier::Long.as_str(),
"namespace": "bulk-overflow",
"title": format!("t-{i}"),
"content": "c",
"tags": [],
"priority": 5,
"confidence": 1.0,
"source": "api",
"metadata": {}
})
})
.collect();
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/memories/bulk")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&bodies).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn http_bulk_create_partial_success_collects_errors() {
let state = test_state();
let app = Router::new()
.route("/api/v1/memories/bulk", axum_post(bulk_create))
.with_state(test_app_state(state.clone()));
let bodies = serde_json::json!([
{
"tier": Tier::Long.as_str(),
"namespace": "bulk-mixed",
"title": "good row",
"content": "ok",
"tags": [],
"priority": 5,
"confidence": 1.0,
"source": "api",
"metadata": {}
},
{
"tier": Tier::Long.as_str(),
"namespace": "bulk-mixed",
"title": "",
"content": "bad: empty title",
"tags": [],
"priority": 5,
"confidence": 1.0,
"source": "api",
"metadata": {}
}
]);
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/memories/bulk")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&bodies).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["created"], 1);
assert_eq!(v["errors"].as_array().unwrap().len(), 1);
let lock = state.lock().await;
let rows = db::list(
&lock.0,
Some("bulk-mixed"),
None,
10,
0,
None,
None,
None,
None,
None,
)
.unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].title, "good row");
}
#[tokio::test]
async fn http_bulk_create_empty_body_succeeds_with_zero_created() {
let state = test_state();
let app = Router::new()
.route("/api/v1/memories/bulk", axum_post(bulk_create))
.with_state(test_app_state(state));
let bodies: Vec<serde_json::Value> = vec![];
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/memories/bulk")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&bodies).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["created"], 0);
assert!(v["errors"].as_array().unwrap().is_empty());
}
#[tokio::test]
async fn http_list_pending_empty_returns_zero_count() {
let state = test_state();
let app = Router::new()
.route("/api/v1/pending", axum::routing::get(list_pending))
.with_state(test_app_state(state.clone()));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/pending")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["count"], 0);
}
#[tokio::test]
async fn http_list_pending_with_status_filter() {
let state = test_state();
let app = Router::new()
.route("/api/v1/pending", axum::routing::get(list_pending))
.with_state(test_app_state(state.clone()));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/pending?status=approved&limit=5")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn http_approve_pending_unknown_id_returns_404_1620() {
let _g = APPROVE_HMAC_TEST_LOCK
.lock()
.unwrap_or_else(|p| p.into_inner());
crate::config::set_active_hooks_hmac_secret(Some("a1-test-secret".to_string()));
let state = test_state();
let app = Router::new()
.route("/api/v1/pending/{id}/approve", axum_post(approve_pending))
.with_state(test_app_state(state));
let unknown = Uuid::new_v4().to_string();
let (ts, sig) = sign_approve_body("a1-test-secret", "POST", &unknown, b"");
let resp = app
.oneshot(
axum::http::Request::builder()
.uri(format!("/api/v1/pending/{unknown}/approve"))
.method("POST")
.header("x-agent-id", "alice")
.header("x-ai-memory-timestamp", &ts)
.header("x-ai-memory-signature", &sig)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
crate::config::set_active_hooks_hmac_secret(None);
assert_eq!(
resp.status(),
StatusCode::NOT_FOUND,
"#1620: unknown pending id must be 404 on the sqlite branch"
);
}
#[tokio::test]
async fn http_approve_pending_rejects_invalid_agent_id() {
let _g = APPROVE_HMAC_TEST_LOCK
.lock()
.unwrap_or_else(|p| p.into_inner());
crate::config::set_active_hooks_hmac_secret(Some("a1-test-secret".to_string()));
let state = test_state();
let app = Router::new()
.route("/api/v1/pending/{id}/approve", axum_post(approve_pending))
.with_state(test_app_state(state));
let id = Uuid::new_v4().to_string();
let (ts, sig) = sign_approve_body("a1-test-secret", "POST", &id, b"");
let resp = app
.oneshot(
axum::http::Request::builder()
.uri(format!("/api/v1/pending/{id}/approve"))
.method("POST")
.header("x-agent-id", "bad agent")
.header("x-ai-memory-timestamp", &ts)
.header("x-ai-memory-signature", &sig)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
crate::config::set_active_hooks_hmac_secret(None);
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn http_reject_pending_unknown_id_returns_404() {
let _g = APPROVE_HMAC_TEST_LOCK
.lock()
.unwrap_or_else(|p| p.into_inner());
crate::config::set_active_hooks_hmac_secret(Some("a1-test-secret".to_string()));
let state = test_state();
let app = Router::new()
.route("/api/v1/pending/{id}/reject", axum_post(reject_pending))
.with_state(test_app_state(state));
let unknown = Uuid::new_v4().to_string();
let (ts, sig) = sign_approve_body("a1-test-secret", "POST", &unknown, b"");
let resp = app
.oneshot(
axum::http::Request::builder()
.uri(format!("/api/v1/pending/{unknown}/reject"))
.method("POST")
.header("x-agent-id", "alice")
.header("x-ai-memory-timestamp", &ts)
.header("x-ai-memory-signature", &sig)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
crate::config::set_active_hooks_hmac_secret(None);
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn http_reject_pending_rejects_invalid_agent_id() {
let _g = APPROVE_HMAC_TEST_LOCK
.lock()
.unwrap_or_else(|p| p.into_inner());
crate::config::set_active_hooks_hmac_secret(Some("a1-test-secret".to_string()));
let state = test_state();
let app = Router::new()
.route("/api/v1/pending/{id}/reject", axum_post(reject_pending))
.with_state(test_app_state(state));
let id = Uuid::new_v4().to_string();
let (ts, sig) = sign_approve_body("a1-test-secret", "POST", &id, b"");
let resp = app
.oneshot(
axum::http::Request::builder()
.uri(format!("/api/v1/pending/{id}/reject"))
.method("POST")
.header("x-agent-id", "bad agent")
.header("x-ai-memory-timestamp", &ts)
.header("x-ai-memory-signature", &sig)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
crate::config::set_active_hooks_hmac_secret(None);
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn http_search_rejects_blank_query() {
let state = test_state();
let app = Router::new()
.route(
"/api/v1/memories/search",
axum::routing::get(search_memories),
)
.with_state(test_app_state(state));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/memories/search?q=%20%20%20") .body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn http_search_long_query_succeeds() {
let state = test_state();
let app = Router::new()
.route(
"/api/v1/memories/search",
axum::routing::get(search_memories),
)
.with_state(test_app_state(state));
let q = "a".repeat(2_000);
let resp = app
.oneshot(
axum::http::Request::builder()
.uri(format!("/api/v1/memories/search?q={q}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert!(
resp.status() == StatusCode::OK
|| resp.status() == StatusCode::BAD_REQUEST
|| resp.status() == StatusCode::INTERNAL_SERVER_ERROR,
"unexpected status {}",
resp.status()
);
}
#[tokio::test]
async fn http_search_normal_query_returns_results_array() {
let state = test_state();
let app = Router::new()
.route(
"/api/v1/memories/search",
axum::routing::get(search_memories),
)
.with_state(test_app_state(state));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/memories/search?q=hello")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert!(v["results"].is_array());
assert_eq!(v["query"], "hello");
}
#[tokio::test]
async fn http_search_invalid_agent_id_filter_rejected() {
let state = test_state();
let app = Router::new()
.route(
"/api/v1/memories/search",
axum::routing::get(search_memories),
)
.with_state(test_app_state(state));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/memories/search?q=test&agent_id=bad%20agent")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn http_recall_get_rejects_blank_context() {
let state = test_state();
let app = Router::new()
.route(
"/api/v1/memories/recall",
axum::routing::get(recall_memories_get),
)
.with_state(test_app_state(state));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/memories/recall?context=%20")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn http_recall_get_zero_budget_tokens_returns_empty() {
let state = test_state();
let app = Router::new()
.route(
"/api/v1/memories/recall",
axum::routing::get(recall_memories_get),
)
.with_state(test_app_state(state));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/memories/recall?context=hi&budget_tokens=0")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["count"], 0);
assert_eq!(v["budget_tokens"], 0);
assert_eq!(v["meta"]["budget_overflow"], false);
}
#[tokio::test]
async fn http_recall_post_rejects_blank_context() {
let state = test_state();
let app = Router::new()
.route("/api/v1/memories/recall", axum_post(recall_memories_post))
.with_state(test_app_state(state));
let body = serde_json::json!({"context": " "});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/memories/recall")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn http_recall_post_keyword_mode_returns_mode_field() {
let state = test_state();
let _id = insert_test_memory(&state, "recall-mode", "the title").await;
let app = Router::new()
.route("/api/v1/memories/recall", axum_post(recall_memories_post))
.with_state(test_app_state(state));
let body = serde_json::json!({"context": "title", "namespace": "recall-mode"});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/memories/recall")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["mode"], "keyword");
}
#[tokio::test]
async fn http_sync_since_empty_db_returns_zero_count() {
let state = test_state();
let app = Router::new()
.route("/api/v1/sync/since", axum::routing::get(sync_since))
.with_state(test_app_state(state));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/sync/since?since=2000-01-01T00:00:00Z&limit=10")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["count"], 0);
assert!(v["earliest_updated_at"].is_null());
assert!(v["latest_updated_at"].is_null());
}
#[tokio::test]
async fn http_sync_since_clamps_oversized_limit() {
let state = test_state();
let app = Router::new()
.route("/api/v1/sync/since", axum::routing::get(sync_since))
.with_state(test_app_state(state));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/sync/since?limit=999999")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert!(v["limit"].as_u64().unwrap() <= 10_000);
}
#[tokio::test]
async fn http_sync_since_empty_since_string_treated_as_full_snapshot() {
let state = test_state();
let _id = insert_test_memory(&state, "sync-empty", "row").await;
let app = Router::new()
.route("/api/v1/sync/since", axum::routing::get(sync_since))
.with_state(test_app_state(state));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/sync/since?since=")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn http_sync_since_records_peer_via_observe() {
let state = test_state();
let _id = insert_test_memory(&state, "sync-peer", "row").await;
let app = Router::new()
.route("/api/v1/sync/since", axum::routing::get(sync_since))
.with_state(test_app_state(state.clone()));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/sync/since?peer=peer-x")
.header("x-agent-id", "alice")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn http_capabilities_returns_features() {
let state = test_state();
let app = Router::new()
.route("/api/v1/capabilities", axum::routing::get(get_capabilities))
.with_state(test_app_state(state));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/capabilities")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["features"]["embedder_loaded"], false);
}
#[tokio::test]
async fn http_session_start_rejects_invalid_agent_id() {
let state = test_state();
let app = Router::new()
.route("/api/v1/session/start", axum_post(session_start))
.with_state(state);
let body = serde_json::json!({"agent_id": "bad agent id with spaces"});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/session/start")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn http_session_start_stamps_session_id() {
let state = test_state();
let app = Router::new()
.route("/api/v1/session/start", axum_post(session_start))
.with_state(state);
let body = serde_json::json!({});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/session/start")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert!(v["session_id"].as_str().is_some());
}
#[tokio::test]
async fn http_get_taxonomy_rejects_invalid_prefix() {
let state = test_state();
let app = Router::new()
.route("/api/v1/taxonomy", axum::routing::get(get_taxonomy))
.with_state(test_app_state(state.clone()));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/taxonomy?prefix=bad%20prefix")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn http_get_taxonomy_clamps_depth_and_limit() {
let state = test_state();
let app = Router::new()
.route("/api/v1/taxonomy", axum::routing::get(get_taxonomy))
.with_state(test_app_state(state.clone()));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/taxonomy?depth=1000&limit=999999")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn http_list_subscriptions_empty_returns_zero() {
let state = test_state();
let app = Router::new()
.route(
"/api/v1/subscriptions",
axum::routing::get(list_subscriptions),
)
.with_state(test_app_state(state.clone()));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/subscriptions")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["count"], 0);
assert!(v["subscriptions"].as_array().unwrap().is_empty());
}
#[tokio::test]
async fn http_list_subscriptions_filters_by_agent_id() {
let state = test_state();
let app = Router::new()
.route(
"/api/v1/subscriptions",
axum::routing::get(list_subscriptions),
)
.with_state(test_app_state(state.clone()));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/subscriptions?agent_id=alice")
.header("x-agent-id", "alice")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn http_get_inbox_with_x_agent_id_header() {
let state = test_state();
let app = Router::new()
.route("/api/v1/inbox", axum::routing::get(get_inbox))
.with_state(test_app_state(state));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/inbox?unread_only=true&limit=20")
.header("x-agent-id", "alice")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn http_check_duplicate_rejects_invalid_title() {
let state = test_state();
let app = Router::new()
.route("/api/v1/check_duplicate", axum_post(check_duplicate))
.with_state(test_app_state(state));
let body = serde_json::json!({"title": "", "content": "non-empty"});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/check_duplicate")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn http_check_duplicate_rejects_invalid_content() {
let state = test_state();
let app = Router::new()
.route("/api/v1/check_duplicate", axum_post(check_duplicate))
.with_state(test_app_state(state));
let body = serde_json::json!({"title": "ok", "content": ""});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/check_duplicate")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn http_check_duplicate_rejects_invalid_namespace() {
let state = test_state();
let app = Router::new()
.route("/api/v1/check_duplicate", axum_post(check_duplicate))
.with_state(test_app_state(state));
let body = serde_json::json!({
"title": "ok",
"content": "ok content",
"namespace": "BAD NAMESPACE WITH SPACES",
});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/check_duplicate")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn http_check_duplicate_503_when_no_embedder() {
let state = test_state();
let app = Router::new()
.route("/api/v1/check_duplicate", axum_post(check_duplicate))
.with_state(test_app_state(state));
let body = serde_json::json!({"title": "anchor", "content": "some long enough content"});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/check_duplicate")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
}
#[tokio::test]
async fn http_entity_register_creates_then_idempotent_returns_200() {
let state = test_state();
let app = Router::new()
.route("/api/v1/entities", axum_post(entity_register))
.with_state(test_app_state(state.clone()));
let body = serde_json::json!({
"canonical_name": "Acme Corp",
"namespace": "kg-test",
"aliases": ["acme", "Acme"],
"metadata": {"region": "us"},
});
let resp = app
.clone()
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/entities")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.header("x-agent-id", "alice")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
let resp2 = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/entities")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp2.status(), StatusCode::OK);
}
#[tokio::test]
async fn http_entity_register_rejects_invalid_canonical_name() {
let state = test_state();
let app = Router::new()
.route("/api/v1/entities", axum_post(entity_register))
.with_state(test_app_state(state.clone()));
let body = serde_json::json!({
"canonical_name": "",
"namespace": "kg-test",
});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/entities")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn http_entity_register_rejects_invalid_namespace() {
let state = test_state();
let app = Router::new()
.route("/api/v1/entities", axum_post(entity_register))
.with_state(test_app_state(state.clone()));
let body = serde_json::json!({
"canonical_name": "Acme",
"namespace": "BAD NS!",
});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/entities")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn http_entity_register_rejects_invalid_agent_id_header() {
let state = test_state();
let app = Router::new()
.route("/api/v1/entities", axum_post(entity_register))
.with_state(test_app_state(state.clone()));
let body = serde_json::json!({
"canonical_name": "Acme",
"namespace": "kg-test",
});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/entities")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.header("x-agent-id", "BAD AGENT!")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn http_entity_register_collision_with_non_entity_returns_409() {
let state = test_state();
let now = Utc::now().to_rfc3339();
{
let lock = state.lock().await;
let mem = Memory {
id: Uuid::new_v4().to_string(),
tier: Tier::Long,
namespace: "collide-ns".into(),
title: "Acme Squat".into(),
content: "this is a regular memory".into(),
tags: vec![],
priority: 5,
confidence: 1.0,
source: "test".into(),
access_count: 0,
created_at: now.clone(),
updated_at: now,
last_accessed_at: None,
expires_at: None,
metadata: serde_json::json!({}),
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,
};
db::insert(&lock.0, &mem).unwrap();
}
let app = Router::new()
.route("/api/v1/entities", axum_post(entity_register))
.with_state(test_app_state(state.clone()));
let body = serde_json::json!({
"canonical_name": "Acme Squat",
"namespace": "collide-ns",
});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/entities")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::CONFLICT);
}
#[tokio::test]
async fn http_entity_get_by_alias_blank_alias_rejected() {
let state = test_state();
let app = Router::new()
.route(
"/api/v1/entities/by_alias",
axum::routing::get(entity_get_by_alias),
)
.with_state(test_app_state(state.clone()));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/entities/by_alias?alias=%20%20")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn http_entity_get_by_alias_invalid_namespace_rejected() {
let state = test_state();
let app = Router::new()
.route(
"/api/v1/entities/by_alias",
axum::routing::get(entity_get_by_alias),
)
.with_state(test_app_state(state.clone()));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/entities/by_alias?alias=acme&namespace=BAD%20NS!")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn http_entity_get_by_alias_returns_found_false_when_unknown() {
let state = test_state();
let app = Router::new()
.route(
"/api/v1/entities/by_alias",
axum::routing::get(entity_get_by_alias),
)
.with_state(test_app_state(state.clone()));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/entities/by_alias?alias=nonexistent")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["found"], serde_json::json!(false));
}
#[tokio::test]
async fn http_entity_get_by_alias_returns_found_true_after_register() {
let state = test_state();
{
let lock = state.lock().await;
db::entity_register(
&lock.0,
"Acme Corp",
"kg-lookup",
&["acme".to_string(), "ACME".to_string()],
&serde_json::json!({}),
Some("alice"),
)
.unwrap();
}
let app = Router::new()
.route(
"/api/v1/entities/by_alias",
axum::routing::get(entity_get_by_alias),
)
.with_state(test_app_state(state.clone()));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/entities/by_alias?alias=acme&namespace=kg-lookup")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["found"], serde_json::json!(true));
assert_eq!(v["canonical_name"], serde_json::json!("Acme Corp"));
}
#[tokio::test]
async fn http_kg_timeline_rejects_invalid_source_id() {
let state = test_state();
let app = Router::new()
.route("/api/v1/kg/timeline", axum::routing::get(kg_timeline))
.with_state(test_app_state(state.clone()));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/kg/timeline?source_id=")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn http_kg_timeline_rejects_invalid_since() {
let state = test_state();
let app = Router::new()
.route("/api/v1/kg/timeline", axum::routing::get(kg_timeline))
.with_state(test_app_state(state.clone()));
let id = Uuid::new_v4().to_string();
let uri = format!("/api/v1/kg/timeline?source_id={id}&since=NOT-A-TIMESTAMP");
let resp = app
.oneshot(
axum::http::Request::builder()
.uri(&uri)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn http_kg_timeline_rejects_invalid_until() {
let state = test_state();
let app = Router::new()
.route("/api/v1/kg/timeline", axum::routing::get(kg_timeline))
.with_state(test_app_state(state.clone()));
let id = Uuid::new_v4().to_string();
let uri = format!("/api/v1/kg/timeline?source_id={id}&until=garbage");
let resp = app
.oneshot(
axum::http::Request::builder()
.uri(&uri)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn http_kg_timeline_returns_empty_for_unlinked_source() {
let state = test_state();
let id = {
let lock = state.lock().await;
let now = Utc::now().to_rfc3339();
let mem = Memory {
id: Uuid::new_v4().to_string(),
tier: Tier::Long,
namespace: "kg-tl".into(),
title: "anchor".into(),
content: "anchor body".into(),
tags: vec![],
priority: 5,
confidence: 1.0,
source: "test".into(),
access_count: 0,
created_at: now.clone(),
updated_at: now,
last_accessed_at: None,
expires_at: None,
metadata: serde_json::json!({}),
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,
};
db::insert(&lock.0, &mem).unwrap()
};
let app = Router::new()
.route("/api/v1/kg/timeline", axum::routing::get(kg_timeline))
.with_state(test_app_state(state.clone()));
let uri = format!("/api/v1/kg/timeline?source_id={id}");
let resp = app
.oneshot(
axum::http::Request::builder()
.uri(&uri)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["count"], serde_json::json!(0));
assert!(v["events"].is_array());
}
#[tokio::test]
async fn http_kg_invalidate_rejects_invalid_link() {
let state = test_state();
let app = Router::new()
.route("/api/v1/kg/invalidate", axum_post(kg_invalidate))
.with_state(test_app_state(state.clone()));
let body = serde_json::json!({
"source_id": "11111111-1111-4111-8111-111111111111",
"target_id": "11111111-1111-4111-8111-111111111111",
"relation": "related_to",
});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/kg/invalidate")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn http_kg_invalidate_rejects_invalid_valid_until() {
let state = test_state();
let app = Router::new()
.route("/api/v1/kg/invalidate", axum_post(kg_invalidate))
.with_state(test_app_state(state.clone()));
let body = serde_json::json!({
"source_id": "11111111-1111-4111-8111-111111111111",
"target_id": "22222222-2222-4222-8222-222222222222",
"relation": "related_to",
"valid_until": "garbage",
});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/kg/invalidate")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn http_kg_invalidate_404_when_link_missing() {
let state = test_state();
let app = Router::new()
.route("/api/v1/kg/invalidate", axum_post(kg_invalidate))
.with_state(test_app_state(state.clone()));
let body = serde_json::json!({
"source_id": "11111111-1111-4111-8111-111111111111",
"target_id": "22222222-2222-4222-8222-222222222222",
"relation": "related_to",
});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/kg/invalidate")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn http_kg_invalidate_marks_link_as_invalidated() {
let state = test_state();
let (a_id, b_id) = {
let lock = state.lock().await;
let now = Utc::now().to_rfc3339();
let mk = |title: &str| Memory {
id: Uuid::new_v4().to_string(),
tier: Tier::Long,
namespace: "kg-inv".into(),
title: title.into(),
content: format!("{title} body"),
tags: vec![],
priority: 5,
confidence: 1.0,
source: "test".into(),
access_count: 0,
created_at: now.clone(),
updated_at: now.clone(),
last_accessed_at: None,
expires_at: None,
metadata: serde_json::json!({}),
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 a = db::insert(&lock.0, &mk("source-a")).unwrap();
let b = db::insert(&lock.0, &mk("target-b")).unwrap();
db::create_link(&lock.0, &a, &b, "related_to").unwrap();
(a, b)
};
let app = Router::new()
.route("/api/v1/kg/invalidate", axum_post(kg_invalidate))
.with_state(test_app_state(state.clone()));
let body = serde_json::json!({
"source_id": a_id,
"target_id": b_id,
"relation": "related_to",
});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/kg/invalidate")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["found"], serde_json::json!(true));
}
#[tokio::test]
async fn http_kg_query_rejects_invalid_source_id() {
let state = test_state();
let app = Router::new()
.route("/api/v1/kg/query", axum_post(kg_query))
.with_state(test_app_state(state.clone()));
let body = serde_json::json!({"source_id": ""});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/kg/query")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn http_kg_query_rejects_invalid_valid_at() {
let state = test_state();
let app = Router::new()
.route("/api/v1/kg/query", axum_post(kg_query))
.with_state(test_app_state(state.clone()));
let body = serde_json::json!({
"source_id": "11111111-1111-4111-8111-111111111111",
"valid_at": "not-a-timestamp",
});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/kg/query")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn http_kg_query_rejects_invalid_allowed_agent() {
let state = test_state();
let app = Router::new()
.route("/api/v1/kg/query", axum_post(kg_query))
.with_state(test_app_state(state.clone()));
let body = serde_json::json!({
"source_id": "11111111-1111-4111-8111-111111111111",
"allowed_agents": ["BAD AGENT!"],
});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/kg/query")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn http_kg_query_returns_422_for_oversized_max_depth() {
let state = test_state();
let app = Router::new()
.route("/api/v1/kg/query", axum_post(kg_query))
.with_state(test_app_state(state.clone()));
let body = serde_json::json!({
"source_id": "11111111-1111-4111-8111-111111111111",
"max_depth": 999_usize,
});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/kg/query")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
}
#[tokio::test]
async fn http_kg_query_returns_422_for_zero_max_depth() {
let state = test_state();
let app = Router::new()
.route("/api/v1/kg/query", axum_post(kg_query))
.with_state(test_app_state(state.clone()));
let body = serde_json::json!({
"source_id": "11111111-1111-4111-8111-111111111111",
"max_depth": 0_usize,
});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/kg/query")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
}
#[tokio::test]
async fn http_kg_query_returns_empty_for_unlinked_source() {
let state = test_state();
let id = {
let lock = state.lock().await;
let now = Utc::now().to_rfc3339();
let mem = Memory {
id: Uuid::new_v4().to_string(),
tier: Tier::Long,
namespace: "kg-q".into(),
title: "anchor".into(),
content: "anchor body".into(),
tags: vec![],
priority: 5,
confidence: 1.0,
source: "test".into(),
access_count: 0,
created_at: now.clone(),
updated_at: now,
last_accessed_at: None,
expires_at: None,
metadata: serde_json::json!({}),
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,
};
db::insert(&lock.0, &mem).unwrap()
};
let app = Router::new()
.route("/api/v1/kg/query", axum_post(kg_query))
.with_state(test_app_state(state.clone()));
let body = serde_json::json!({
"source_id": id,
"max_depth": 1_usize,
});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/kg/query")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["count"], serde_json::json!(0));
assert_eq!(v["max_depth"], serde_json::json!(1));
}
#[tokio::test]
async fn http_kg_query_short_circuits_empty_allowed_agents() {
let state = test_state();
let app = Router::new()
.route("/api/v1/kg/query", axum_post(kg_query))
.with_state(test_app_state(state.clone()));
let body = serde_json::json!({
"source_id": "11111111-1111-4111-8111-111111111111",
"allowed_agents": [],
});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/kg/query")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["count"], serde_json::json!(0));
}
#[tokio::test]
async fn http_delete_link_rejects_self_link() {
let state = test_state();
let app = Router::new()
.route("/api/v1/links", axum::routing::delete(delete_link))
.with_state(test_app_state(state));
let body = serde_json::json!({
"source_id": "11111111-1111-4111-8111-111111111111",
"target_id": "11111111-1111-4111-8111-111111111111",
"relation": "related_to",
});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/links")
.method("DELETE")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn http_delete_link_returns_deleted_false_when_missing() {
let state = test_state();
let app = Router::new()
.route("/api/v1/links", axum::routing::delete(delete_link))
.with_state(test_app_state(state));
let body = serde_json::json!({
"source_id": "11111111-1111-4111-8111-111111111111",
"target_id": "22222222-2222-4222-8222-222222222222",
"relation": "related_to",
});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/links")
.method("DELETE")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["deleted"], serde_json::json!(false));
}
#[tokio::test]
async fn http_get_links_for_unknown_id_returns_empty_array() {
let state = test_state();
let app = Router::new()
.route("/api/v1/memories/{id}/links", axum::routing::get(get_links))
.with_state(test_app_state(state));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/memories/nonexistent-id/links")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert!(v["links"].is_array());
assert_eq!(v["links"].as_array().unwrap().len(), 0);
}
#[tokio::test]
async fn http_get_links_returns_empty_array_for_unlinked_id() {
let state = test_state();
let id = {
let lock = state.lock().await;
let now = Utc::now().to_rfc3339();
let mem = Memory {
id: Uuid::new_v4().to_string(),
tier: Tier::Long,
namespace: "links-test".into(),
title: "anchor".into(),
content: "no links yet".into(),
tags: vec![],
priority: 5,
confidence: 1.0,
source: "test".into(),
access_count: 0,
created_at: now.clone(),
updated_at: now,
last_accessed_at: None,
expires_at: None,
metadata: serde_json::json!({}),
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,
};
db::insert(&lock.0, &mem).unwrap()
};
let app = Router::new()
.route("/api/v1/memories/{id}/links", axum::routing::get(get_links))
.with_state(test_app_state(state));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri(format!("/api/v1/memories/{id}/links"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert!(v["links"].is_array());
assert_eq!(v["links"].as_array().unwrap().len(), 0);
}
#[tokio::test]
async fn http_list_namespaces_returns_empty_for_fresh_db() {
let state = test_state();
let app = Router::new()
.route("/api/v1/namespaces", axum::routing::get(list_namespaces))
.with_state(test_app_state(state.clone()));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/namespaces")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert!(v["namespaces"].is_array());
}
#[tokio::test]
async fn http_forget_memories_with_namespace_filter_returns_count() {
let state = test_state();
{
let lock = state.lock().await;
let now = Utc::now().to_rfc3339();
for i in 0..3 {
let mem = Memory {
id: Uuid::new_v4().to_string(),
tier: Tier::Long,
namespace: "forget-target".into(),
title: format!("row-{i}"),
content: format!("content {i}"),
tags: vec![],
priority: 5,
confidence: 1.0,
source: "test".into(),
access_count: 0,
created_at: now.clone(),
updated_at: now.clone(),
last_accessed_at: None,
expires_at: None,
metadata: serde_json::json!({}),
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,
};
db::insert(&lock.0, &mem).unwrap();
}
}
let app = Router::new()
.route("/api/v1/forget", axum_post(forget_memories))
.with_state(test_app_state(state));
let body = serde_json::json!({"namespace": "forget-target"});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/forget")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert!(v["deleted"].as_u64().is_some());
}
#[tokio::test]
async fn http_archive_stats_empty_db_returns_zero() {
let state = test_state();
let app = Router::new()
.route("/api/v1/archive/stats", axum::routing::get(archive_stats))
.with_state(test_app_state(state.clone()));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/archive/stats")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn http_purge_archive_returns_zero_for_empty_archive() {
let state = test_state();
let app = Router::new()
.route("/api/v1/archive/purge", axum_post(purge_archive))
.with_state(test_app_state(state));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/archive/purge")
.method("POST")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["purged"], serde_json::json!(0));
}
#[tokio::test]
async fn http_run_gc_returns_zero_for_clean_db() {
let state = test_state();
let app = Router::new()
.route("/api/v1/gc", axum_post(run_gc))
.with_state(test_app_state(state));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/gc")
.method("POST")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn http_export_memories_empty_returns_zero_count() {
let state = test_state();
let app = Router::new()
.route("/api/v1/export", axum::routing::get(export_memories))
.with_state(test_app_state_with_admin(state, "ops:admin"));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/export")
.header("x-agent-id", "ops:admin")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["count"], serde_json::json!(0));
}
#[tokio::test]
async fn http_import_memories_oversized_batch_rejected() {
let state = test_state();
let app = Router::new()
.route("/api/v1/import", axum_post(import_memories))
.with_state(test_app_state(state));
let many: Vec<serde_json::Value> = (0..=MAX_BULK_SIZE)
.map(|i| {
serde_json::json!({
"id": format!("11111111-1111-4111-8111-{:012}", i),
"tier": Tier::Long.as_str(),
"namespace": "imp",
"title": format!("t-{i}"),
"content": "x",
"tags": [],
"priority": 5,
"confidence": 1.0,
"source": "import",
"access_count": 0,
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:00:00Z",
"last_accessed_at": null,
"expires_at": null,
"metadata": {},
})
})
.collect();
let body = serde_json::json!({"memories": many});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/import")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn http_import_memories_skips_invalid_rows() {
let state = test_state();
let app = Router::new()
.route("/api/v1/import", axum_post(import_memories))
.with_state(test_app_state_with_admin(state, "ops:admin"));
let valid = serde_json::json!({
"id": Uuid::new_v4().to_string(),
"tier": Tier::Long.as_str(),
"namespace": "imp",
"title": "ok-row",
"content": "valid content",
"tags": [],
"priority": 5,
"confidence": 1.0,
"source": "import",
"access_count": 0,
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:00:00Z",
"last_accessed_at": null,
"expires_at": null,
"metadata": {},
});
let invalid = serde_json::json!({
"id": Uuid::new_v4().to_string(),
"tier": Tier::Long.as_str(),
"namespace": "imp",
"title": "",
"content": "x",
"tags": [],
"priority": 5,
"confidence": 1.0,
"source": "import",
"access_count": 0,
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:00:00Z",
"last_accessed_at": null,
"expires_at": null,
"metadata": {},
});
let body = serde_json::json!({"memories": [valid, invalid]});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/import")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.header("x-agent-id", "ops:admin")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["imported"], serde_json::json!(1));
assert!(v["errors"].as_array().unwrap().len() >= 1);
}
#[tokio::test]
async fn http_get_stats_empty_db() {
let state = test_state();
let app = Router::new()
.route("/api/v1/stats", axum::routing::get(get_stats))
.with_state(test_app_state(state.clone()));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/stats")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn http_sync_push_namespace_meta_clears_garbage_skipped() {
let state = test_state();
let app = Router::new()
.route("/api/v1/sync/push", axum_post(sync_push))
.with_state(test_app_state(state));
let body = serde_json::json!({
"sender_agent_id": "peer-x",
"memories": [],
"namespace_meta_clears": ["BAD NAMESPACE!"],
});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/sync/push")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn http_sync_push_pending_decision_invalid_id_skipped() {
let state = test_state();
let app = Router::new()
.route("/api/v1/sync/push", axum_post(sync_push))
.with_state(test_app_state(state));
let body = serde_json::json!({
"sender_agent_id": "peer-x",
"memories": [],
"pending_decisions": [
{"id": "BAD ID!", "approved": true, "decider": "alice"}
],
});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/sync/push")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn http_sync_push_namespace_meta_invalid_skipped() {
let state = test_state();
let app = Router::new()
.route("/api/v1/sync/push", axum_post(sync_push))
.with_state(test_app_state(state));
let body = serde_json::json!({
"sender_agent_id": "peer-x",
"memories": [],
"namespace_meta": [
{"namespace": "BAD NS!", "standard_id": "11111111-1111-4111-8111-111111111111", "parent_namespace": null}
],
});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/sync/push")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn http_sync_push_dry_run_namespace_meta_no_apply() {
let state = test_state();
let app = Router::new()
.route("/api/v1/sync/push", axum_post(sync_push))
.with_state(test_app_state(state.clone()));
let body = serde_json::json!({
"sender_agent_id": "peer-x",
"memories": [],
"dry_run": true,
"namespace_meta_clears": ["preview-ns"],
"pending_decisions": [
{"id": "11111111-1111-4111-8111-111111111111", "approved": true, "decider": "alice"}
],
});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/sync/push")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn http_list_archive_empty_returns_empty_array() {
let state = test_state();
let app = Router::new()
.route("/api/v1/archive", axum::routing::get(list_archive))
.with_state(test_app_state(state.clone()));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/archive")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["count"], 0);
assert_eq!(v["archived"].as_array().unwrap().len(), 0);
}
#[tokio::test]
async fn http_list_archive_with_items_returns_them() {
let state = test_state();
let id_a = insert_test_memory(&state, "h8a-list-items", "row-a").await;
let id_b = insert_test_memory(&state, "h8a-list-items", "row-b").await;
{
let lock = state.lock().await;
db::archive_memory(&lock.0, &id_a, Some("test")).unwrap();
db::archive_memory(&lock.0, &id_b, Some("test")).unwrap();
}
let app = Router::new()
.route("/api/v1/archive", axum::routing::get(list_archive))
.with_state(test_app_state(state.clone()));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/archive?limit=10")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["count"], 2);
}
#[tokio::test]
async fn http_list_archive_pagination_offset_skips() {
let state = test_state();
let id1 = insert_test_memory(&state, "h8a-page", "row-1").await;
let id2 = insert_test_memory(&state, "h8a-page", "row-2").await;
let id3 = insert_test_memory(&state, "h8a-page", "row-3").await;
{
let lock = state.lock().await;
db::archive_memory(&lock.0, &id1, Some("p")).unwrap();
db::archive_memory(&lock.0, &id2, Some("p")).unwrap();
db::archive_memory(&lock.0, &id3, Some("p")).unwrap();
}
let app = Router::new()
.route("/api/v1/archive", axum::routing::get(list_archive))
.with_state(test_app_state(state.clone()));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/archive?limit=1&offset=1")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["count"], 1);
}
#[tokio::test]
async fn http_list_archive_namespace_filter_excludes_others() {
let state = test_state();
let id_a = insert_test_memory(&state, "h8a-ns-a", "row-a").await;
let id_b = insert_test_memory(&state, "h8a-ns-b", "row-b").await;
{
let lock = state.lock().await;
db::archive_memory(&lock.0, &id_a, Some("t")).unwrap();
db::archive_memory(&lock.0, &id_b, Some("t")).unwrap();
}
let app = Router::new()
.route("/api/v1/archive", axum::routing::get(list_archive))
.with_state(test_app_state(state.clone()));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/archive?namespace=h8a-ns-a&limit=10")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["count"], 1);
let entries = v["archived"].as_array().unwrap();
assert_eq!(entries[0]["namespace"], "h8a-ns-a");
}
#[tokio::test]
async fn http_list_archive_namespace_filter_unknown_returns_empty() {
let state = test_state();
let id_a = insert_test_memory(&state, "h8a-ns-known", "row-a").await;
{
let lock = state.lock().await;
db::archive_memory(&lock.0, &id_a, Some("t")).unwrap();
}
let app = Router::new()
.route("/api/v1/archive", axum::routing::get(list_archive))
.with_state(test_app_state(state.clone()));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/archive?namespace=h8a-no-such-ns")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["count"], 0);
}
#[tokio::test]
async fn http_archive_by_ids_single_id_success() {
let state = test_state();
let id = insert_test_memory(&state, "h8a-aby-single", "row").await;
let app = Router::new()
.route("/api/v1/archive", axum_post(archive_by_ids))
.with_state(test_app_state(state.clone()));
let body = serde_json::json!({"ids": [id], "reason": "h8a-single"});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/archive")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["count"], 1);
assert_eq!(v["missing"].as_array().unwrap().len(), 0);
assert_eq!(v["reason"], "h8a-single");
}
#[tokio::test]
async fn http_archive_by_ids_bulk_success() {
let state = test_state();
let id1 = insert_test_memory(&state, "h8a-bulk", "row-1").await;
let id2 = insert_test_memory(&state, "h8a-bulk", "row-2").await;
let id3 = insert_test_memory(&state, "h8a-bulk", "row-3").await;
let app = Router::new()
.route("/api/v1/archive", axum_post(archive_by_ids))
.with_state(test_app_state(state.clone()));
let body = serde_json::json!({"ids": [id1, id2, id3]});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/archive")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["count"], 3);
assert_eq!(v["missing"].as_array().unwrap().len(), 0);
}
#[tokio::test]
async fn http_archive_by_ids_empty_array_returns_ok_zero_count() {
let state = test_state();
let app = Router::new()
.route("/api/v1/archive", axum_post(archive_by_ids))
.with_state(test_app_state(state.clone()));
let body = serde_json::json!({"ids": []});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/archive")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["count"], 0);
assert_eq!(v["archived"].as_array().unwrap().len(), 0);
assert_eq!(v["missing"].as_array().unwrap().len(), 0);
}
#[tokio::test]
async fn http_archive_by_ids_missing_ids_field_returns_400() {
let state = test_state();
let app = Router::new()
.route("/api/v1/archive", axum_post(archive_by_ids))
.with_state(test_app_state(state));
let body = serde_json::json!({"reason": "no-ids-field"});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/archive")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert!(resp.status().is_client_error());
}
#[tokio::test]
async fn http_archive_by_ids_malformed_json_returns_400() {
let state = test_state();
let app = Router::new()
.route("/api/v1/archive", axum_post(archive_by_ids))
.with_state(test_app_state(state));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/archive")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from("not-valid-json{{"))
.unwrap(),
)
.await
.unwrap();
assert!(resp.status().is_client_error());
}
#[tokio::test]
async fn http_purge_archive_older_than_keeps_recent() {
let state = test_state();
let id = insert_test_memory(&state, "h8a-purge-recent", "row").await;
{
let lock = state.lock().await;
db::archive_memory(&lock.0, &id, Some("recent")).unwrap();
}
let app = Router::new()
.route("/api/v1/archive", axum::routing::delete(purge_archive))
.with_state(test_app_state(state.clone()));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/archive?older_than_days=365")
.method("DELETE")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["purged"], 0);
let lock = state.lock().await;
let rows = db::list_archived(&lock.0, None, 10, 0).unwrap();
assert_eq!(rows.len(), 1);
}
#[tokio::test]
async fn http_purge_archive_unfiltered_purges_everything() {
let state = test_state();
for i in 0..3 {
let id = insert_test_memory(&state, "h8a-purge-all", &format!("row-{i}")).await;
let lock = state.lock().await;
db::archive_memory(&lock.0, &id, Some("all")).unwrap();
}
let app = Router::new()
.route("/api/v1/archive", axum::routing::delete(purge_archive))
.with_state(test_app_state_with_admin(state.clone(), "ops:admin"));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/archive")
.method("DELETE")
.header("x-agent-id", "ops:admin")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["purged"], 3);
let lock = state.lock().await;
let rows = db::list_archived(&lock.0, None, 10, 0).unwrap();
assert!(rows.is_empty());
}
#[tokio::test]
async fn http_purge_archive_zero_days_purges_all_archived() {
let state = test_state();
let id = insert_test_memory(&state, "h8a-purge-zero", "row").await;
{
let lock = state.lock().await;
db::archive_memory(&lock.0, &id, Some("zero")).unwrap();
}
let app = Router::new()
.route("/api/v1/archive", axum::routing::delete(purge_archive))
.with_state(test_app_state_with_admin(state.clone(), "ops:admin"));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/archive?older_than_days=0")
.method("DELETE")
.header("x-agent-id", "ops:admin")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert!(v["purged"].as_u64().unwrap() >= 1);
}
#[tokio::test]
async fn http_purge_archive_response_shape_has_purged_key() {
let state = test_state();
let app = Router::new()
.route("/api/v1/archive", axum::routing::delete(purge_archive))
.with_state(test_app_state(state));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/archive")
.method("DELETE")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert!(v.is_object());
assert!(v["purged"].is_number());
}
#[tokio::test]
async fn http_restore_archive_happy_path_and_listed_in_active() {
let state = test_state();
let id = insert_test_memory(&state, "h8a-restore-ok", "row").await;
{
let lock = state.lock().await;
db::archive_memory(&lock.0, &id, Some("h8a")).unwrap();
}
let app = Router::new()
.route("/api/v1/archive/{id}/restore", axum_post(restore_archive))
.with_state(test_app_state(state.clone()));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri(format!("/api/v1/archive/{id}/restore"))
.method("POST")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["restored"], true);
assert_eq!(v["id"], id);
let lock = state.lock().await;
let got = db::get(&lock.0, &id).unwrap();
assert!(got.is_some());
let archived = db::list_archived(&lock.0, None, 10, 0).unwrap();
assert!(archived.is_empty());
}
#[tokio::test]
async fn http_restore_archive_then_list_archive_excludes_restored() {
let state = test_state();
let id = insert_test_memory(&state, "h8a-restore-list", "row").await;
{
let lock = state.lock().await;
db::archive_memory(&lock.0, &id, Some("h8a")).unwrap();
let rows = db::list_archived(&lock.0, None, 10, 0).unwrap();
assert_eq!(rows.len(), 1);
}
let restore_app = Router::new()
.route("/api/v1/archive/{id}/restore", axum_post(restore_archive))
.with_state(test_app_state(state.clone()));
let resp = restore_app
.oneshot(
axum::http::Request::builder()
.uri(format!("/api/v1/archive/{id}/restore"))
.method("POST")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let list_app = Router::new()
.route("/api/v1/archive", axum::routing::get(list_archive))
.with_state(test_app_state(state.clone()));
let resp = list_app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/archive")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["count"], 0);
}
#[tokio::test]
async fn http_restore_archive_preserves_namespace_and_title() {
let state = test_state();
let id = insert_test_memory(&state, "h8a-rest-meta", "preserve-me").await;
{
let lock = state.lock().await;
db::archive_memory(&lock.0, &id, Some("test")).unwrap();
}
let app = Router::new()
.route("/api/v1/archive/{id}/restore", axum_post(restore_archive))
.with_state(test_app_state(state.clone()));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri(format!("/api/v1/archive/{id}/restore"))
.method("POST")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let lock = state.lock().await;
let got = db::get(&lock.0, &id).unwrap().unwrap();
assert_eq!(got.namespace, "h8a-rest-meta");
assert_eq!(got.title, "preserve-me");
}
#[tokio::test]
async fn http_restore_archive_after_purge_returns_404() {
let state = test_state();
let id = insert_test_memory(&state, "h8a-rest-purged", "row").await;
{
let lock = state.lock().await;
db::archive_memory(&lock.0, &id, Some("test")).unwrap();
db::purge_archive(&lock.0, None).unwrap();
}
let app = Router::new()
.route("/api/v1/archive/{id}/restore", axum_post(restore_archive))
.with_state(test_app_state(state));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri(format!("/api/v1/archive/{id}/restore"))
.method("POST")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn http_restore_archive_oversized_id_returns_400() {
let state = test_state();
let app = Router::new()
.route("/api/v1/archive/{id}/restore", axum_post(restore_archive))
.with_state(test_app_state(state));
let huge = "a".repeat(200);
let resp = app
.oneshot(
axum::http::Request::builder()
.uri(format!("/api/v1/archive/{huge}/restore"))
.method("POST")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn http_archive_stats_with_data_reports_total_and_breakdown() {
let state = test_state();
let id_a1 = insert_test_memory(&state, "h8a-stats-a", "row-1").await;
let id_a2 = insert_test_memory(&state, "h8a-stats-a", "row-2").await;
let id_b1 = insert_test_memory(&state, "h8a-stats-b", "row-3").await;
{
let lock = state.lock().await;
db::archive_memory(&lock.0, &id_a1, Some("t")).unwrap();
db::archive_memory(&lock.0, &id_a2, Some("t")).unwrap();
db::archive_memory(&lock.0, &id_b1, Some("t")).unwrap();
}
let app = Router::new()
.route("/api/v1/archive/stats", axum::routing::get(archive_stats))
.with_state(test_app_state(state.clone()));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/archive/stats")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["archived_total"], 3);
let by_ns = v["by_namespace"].as_array().unwrap();
assert_eq!(by_ns.len(), 2);
assert_eq!(by_ns[0]["count"], 2);
assert_eq!(by_ns[0]["namespace"], "h8a-stats-a");
}
#[tokio::test]
async fn http_archive_stats_empty_returns_total_zero_empty_breakdown() {
let state = test_state();
let app = Router::new()
.route("/api/v1/archive/stats", axum::routing::get(archive_stats))
.with_state(test_app_state(state.clone()));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/archive/stats")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["archived_total"], 0);
assert!(v["by_namespace"].as_array().unwrap().is_empty());
}
#[tokio::test]
async fn http_archive_stats_unaffected_by_active_rows() {
let state = test_state();
for i in 0..5 {
insert_test_memory(&state, "h8a-stats-active", &format!("row-{i}")).await;
}
let app = Router::new()
.route("/api/v1/archive/stats", axum::routing::get(archive_stats))
.with_state(test_app_state(state.clone()));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/archive/stats")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["archived_total"], 0);
}
#[tokio::test]
async fn http_forget_memories_no_filter_returns_400() {
let state = test_state();
let app = Router::new()
.route("/api/v1/forget", axum_post(forget_memories))
.with_state(test_app_state(state));
let body = serde_json::json!({});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/forget")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn http_forget_memories_pattern_only_deletes_matches() {
let state = test_state();
{
let lock = state.lock().await;
let now = Utc::now().to_rfc3339();
for (i, content) in ["delete-me alpha", "keep-this beta", "delete-me gamma"]
.iter()
.enumerate()
{
let mem = Memory {
id: Uuid::new_v4().to_string(),
tier: Tier::Long,
namespace: "h8a-forget-pat".into(),
title: format!("row-{i}"),
content: (*content).into(),
tags: vec![],
priority: 5,
confidence: 1.0,
source: "test".into(),
access_count: 0,
created_at: now.clone(),
updated_at: now.clone(),
last_accessed_at: None,
expires_at: None,
metadata: serde_json::json!({}),
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,
};
db::insert(&lock.0, &mem).unwrap();
}
}
let app = Router::new()
.route("/api/v1/forget", axum_post(forget_memories))
.with_state(test_app_state(state));
let body = serde_json::json!({"pattern": "delete-me"});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/forget")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["deleted"], 2);
}
#[tokio::test]
async fn http_forget_memories_by_tier_only_targets_tier() {
let state = test_state();
{
let lock = state.lock().await;
let now = Utc::now().to_rfc3339();
for (i, tier) in [Tier::Short, Tier::Short, Tier::Long].iter().enumerate() {
let mem = Memory {
id: Uuid::new_v4().to_string(),
tier: tier.clone(),
namespace: "h8a-forget-tier".into(),
title: format!("row-{i}"),
content: format!("content {i}"),
tags: vec![],
priority: 5,
confidence: 1.0,
source: "test".into(),
access_count: 0,
created_at: now.clone(),
updated_at: now.clone(),
last_accessed_at: None,
expires_at: None,
metadata: serde_json::json!({}),
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,
};
db::insert(&lock.0, &mem).unwrap();
}
}
let app = Router::new()
.route("/api/v1/forget", axum_post(forget_memories))
.with_state(test_app_state(state));
let body = serde_json::json!({"tier": Tier::Short.as_str()});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/forget")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["deleted"], 2);
}
#[tokio::test]
async fn http_forget_memories_combined_filters_intersect() {
let state = test_state();
{
let lock = state.lock().await;
let now = Utc::now().to_rfc3339();
for (ns, content) in [
("h8a-forget-and", "purge alpha"),
("h8a-forget-and", "purge beta"),
("h8a-forget-and", "keep gamma"),
("h8a-forget-other", "purge delta"),
] {
let mem = Memory {
id: Uuid::new_v4().to_string(),
tier: Tier::Long,
namespace: ns.into(),
title: format!("row-{content}"),
content: content.into(),
tags: vec![],
priority: 5,
confidence: 1.0,
source: "test".into(),
access_count: 0,
created_at: now.clone(),
updated_at: now.clone(),
last_accessed_at: None,
expires_at: None,
metadata: serde_json::json!({}),
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,
};
db::insert(&lock.0, &mem).unwrap();
}
}
let app = Router::new()
.route("/api/v1/forget", axum_post(forget_memories))
.with_state(test_app_state(state));
let body = serde_json::json!({
"namespace": "h8a-forget-and",
"pattern": "purge"
});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/forget")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["deleted"], 2);
}
#[tokio::test]
async fn http_forget_memories_malformed_json_returns_400() {
let state = test_state();
let app = Router::new()
.route("/api/v1/forget", axum_post(forget_memories))
.with_state(test_app_state(state));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/forget")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from("{not-json"))
.unwrap(),
)
.await
.unwrap();
assert!(resp.status().is_client_error());
}
#[tokio::test]
async fn http_forget_memories_no_match_returns_zero_deleted() {
let state = test_state();
for i in 0..3 {
insert_test_memory(&state, "h8a-forget-keep", &format!("k-{i}")).await;
}
let app = Router::new()
.route("/api/v1/forget", axum_post(forget_memories))
.with_state(test_app_state(state.clone()));
let body = serde_json::json!({"namespace": "h8a-forget-empty"});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/forget")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["deleted"], 0);
let lock = state.lock().await;
let rows = db::list(
&lock.0,
Some("h8a-forget-keep"),
None,
10,
0,
None,
None,
None,
None,
None,
)
.unwrap();
assert_eq!(rows.len(), 3);
}
#[tokio::test]
async fn h8b_subscribe_https_url_returns_created() {
let state = test_state();
let app = Router::new()
.route("/api/v1/subscriptions", axum_post(subscribe))
.with_state(test_app_state(state));
let body = serde_json::json!({
"url": "https://example.com/webhook",
"events": "*",
"secret": "h8b-test-secret",
});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/subscriptions")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.header("x-agent-id", "alice")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert!(v["id"].as_str().is_some(), "id must be returned");
assert_eq!(v["url"], "https://example.com/webhook");
assert_eq!(v["created_by"], "alice");
}
#[tokio::test]
async fn h8b_subscribe_missing_url_and_namespace_rejected() {
let state = test_state();
let app = Router::new()
.route("/api/v1/subscriptions", axum_post(subscribe))
.with_state(test_app_state(state));
let body = serde_json::json!({"events": "*", "secret": "h8b-test-secret"});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/subscriptions")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.header("x-agent-id", "alice")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert!(v["error"].as_str().unwrap().contains("url or namespace"),);
}
#[tokio::test]
async fn h8b_subscribe_invalid_url_rejected() {
let state = test_state();
let app = Router::new()
.route("/api/v1/subscriptions", axum_post(subscribe))
.with_state(test_app_state(state));
let body = serde_json::json!({
"url": "not-a-url",
"events": "*",
"secret": "h8b-test-secret",
});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/subscriptions")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.header("x-agent-id", "alice")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn h8b_subscribe_rejects_link_local_metadata_ip() {
let state = test_state();
let app = Router::new()
.route("/api/v1/subscriptions", axum_post(subscribe))
.with_state(test_app_state(state));
let body = serde_json::json!({
"url": "https://169.254.169.254/latest/meta-data/",
"events": "*",
"secret": "h8b-test-secret",
});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/subscriptions")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.header("x-agent-id", "alice")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
let err = v["error"].as_str().unwrap();
assert!(
err.contains("private") || err.contains("link-local") || err.contains("non-loopback"),
"expected SSRF rejection, got: {err}",
);
}
#[tokio::test]
async fn h8b_subscribe_namespace_shape_synthesizes_url() {
let state = test_state();
let app = Router::new()
.route("/api/v1/subscriptions", axum_post(subscribe))
.with_state(test_app_state(state));
let body = serde_json::json!({
"agent_id": "alice",
"namespace": "team/research",
"secret": "h8b-test-secret",
});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/subscriptions")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.header("x-agent-id", "alice")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["agent_id"], "alice");
assert_eq!(v["namespace"], "team/research");
assert!(
v["url"]
.as_str()
.unwrap()
.starts_with("http://localhost/_ns/"),
"expected synthetic URL, got {}",
v["url"],
);
}
#[tokio::test]
async fn h8b_subscribe_event_filter_round_trips() {
let state = test_state();
let app = Router::new()
.route("/api/v1/subscriptions", axum_post(subscribe))
.with_state(test_app_state(state));
let body = serde_json::json!({
"url": "https://example.com/hook",
"events": "memory.created",
"namespace_filter": "global",
"secret": "h8b-test-secret",
});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/subscriptions")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.header("x-agent-id", "alice")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["events"], "memory.created");
assert_eq!(v["namespace_filter"], "global");
}
#[tokio::test]
async fn h8b_subscribe_persists_hmac_secret() {
let state = test_state();
let app = Router::new()
.route("/api/v1/subscriptions", axum_post(subscribe))
.with_state(test_app_state(state.clone()));
let body = serde_json::json!({
"url": "https://example.com/signed-hook",
"events": "*",
"secret": "topsecret-hmac-key",
});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/subscriptions")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.header("x-agent-id", "alice")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert!(v.get("secret").is_none(), "secret leaked into response");
let lock = state.lock().await;
let subs = crate::subscriptions::list(&lock.0, None).unwrap();
assert_eq!(subs.len(), 1);
assert_eq!(subs[0].url, "https://example.com/signed-hook");
}
#[tokio::test]
async fn h8b_unsubscribe_by_id_happy_path() {
let state = test_state();
let id = {
let lock = state.lock().await;
crate::subscriptions::insert(
&lock.0,
&crate::subscriptions::NewSubscription {
url: "https://example.com/h",
events: "*",
secret: None,
namespace_filter: None,
agent_filter: None,
created_by: Some("alice"),
event_types: None,
},
)
.unwrap()
};
let app = Router::new()
.route("/api/v1/subscriptions", axum::routing::delete(unsubscribe))
.with_state(test_app_state(state.clone()));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri(format!("/api/v1/subscriptions?id={id}"))
.method("DELETE")
.header("x-agent-id", "alice")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["removed"], true);
let lock = state.lock().await;
assert!(
crate::subscriptions::list(&lock.0, None)
.unwrap()
.is_empty()
);
}
#[tokio::test]
async fn h8b_unsubscribe_nonexistent_id_returns_removed_false() {
let state = test_state();
let app = Router::new()
.route("/api/v1/subscriptions", axum::routing::delete(unsubscribe))
.with_state(test_app_state(state));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/subscriptions?id=does-not-exist")
.method("DELETE")
.header("x-agent-id", "alice")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["removed"], false);
}
#[tokio::test]
async fn h8b_unsubscribe_by_agent_and_namespace() {
let state = test_state();
{
let lock = state.lock().await;
crate::subscriptions::insert(
&lock.0,
&crate::subscriptions::NewSubscription {
url: "http://localhost/_ns/alice/demo",
events: "*",
secret: None,
namespace_filter: Some("demo"),
agent_filter: Some("alice"),
created_by: Some("alice"),
event_types: None,
},
)
.unwrap();
}
let app = Router::new()
.route("/api/v1/subscriptions", axum::routing::delete(unsubscribe))
.with_state(test_app_state(state.clone()));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/subscriptions?namespace=demo")
.method("DELETE")
.header("x-agent-id", "alice")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["removed"], true);
}
#[tokio::test]
async fn h8b_unsubscribe_missing_id_and_namespace_rejected() {
let state = test_state();
let app = Router::new()
.route("/api/v1/subscriptions", axum::routing::delete(unsubscribe))
.with_state(test_app_state(state));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/subscriptions")
.method("DELETE")
.header("x-agent-id", "alice")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert!(
v["error"]
.as_str()
.unwrap()
.contains("id or (agent_id, namespace)"),
);
}
#[tokio::test]
async fn h8b_list_subscriptions_returns_seeded_rows() {
let state = test_state();
{
let lock = state.lock().await;
crate::subscriptions::insert(
&lock.0,
&crate::subscriptions::NewSubscription {
url: "https://example.com/a",
events: "*",
secret: None,
namespace_filter: Some("ns1"),
agent_filter: Some("alice"),
created_by: Some("alice"),
event_types: None,
},
)
.unwrap();
crate::subscriptions::insert(
&lock.0,
&crate::subscriptions::NewSubscription {
url: "https://example.com/b",
events: "memory.updated",
secret: None,
namespace_filter: Some("ns2"),
agent_filter: Some("alice"),
created_by: Some("alice"),
event_types: None,
},
)
.unwrap();
}
let app = Router::new()
.route(
"/api/v1/subscriptions",
axum::routing::get(list_subscriptions),
)
.with_state(test_app_state(state.clone()));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/subscriptions")
.header("x-agent-id", "alice")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["count"], 2);
let subs = v["subscriptions"].as_array().unwrap();
assert_eq!(subs.len(), 2);
for s in subs {
assert!(s["namespace"].is_string());
assert!(s["namespace_filter"].is_string());
assert!(s["id"].is_string());
}
}
#[tokio::test]
async fn h8b_list_subscriptions_agent_id_filter_excludes_others() {
let state = test_state();
{
let lock = state.lock().await;
crate::subscriptions::insert(
&lock.0,
&crate::subscriptions::NewSubscription {
url: "https://example.com/a",
events: "*",
secret: None,
namespace_filter: Some("ns1"),
agent_filter: Some("alice"),
created_by: Some("alice"),
event_types: None,
},
)
.unwrap();
crate::subscriptions::insert(
&lock.0,
&crate::subscriptions::NewSubscription {
url: "https://example.com/b",
events: "*",
secret: None,
namespace_filter: Some("ns2"),
agent_filter: Some("bob"),
created_by: Some("bob"),
event_types: None,
},
)
.unwrap();
}
let app = Router::new()
.route(
"/api/v1/subscriptions",
axum::routing::get(list_subscriptions),
)
.with_state(test_app_state(state.clone()));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/subscriptions?agent_id=alice")
.header("x-agent-id", "alice")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["count"], 1);
assert_eq!(v["subscriptions"][0]["namespace"], "ns1");
}
#[tokio::test]
async fn h8b_notify_happy_path_creates_message() {
let state = test_state();
let app = Router::new()
.route("/api/v1/notify", axum_post(notify))
.with_state(test_app_state(state.clone()));
let body = serde_json::json!({
"target_agent_id": "bob",
"title": "Hi bob",
"payload": "hello there",
});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/notify")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.header("x-agent-id", "alice")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["to"], "bob");
assert!(v["id"].as_str().is_some());
assert!(v["delivered_at"].as_str().is_some());
let lock = state.lock().await;
let rows = db::list(
&lock.0,
Some("_messages/bob"),
None,
10,
0,
None,
None,
None,
None,
None,
)
.unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].title, "Hi bob");
}
#[tokio::test]
async fn h8b_notify_missing_target_agent_id_rejected() {
let state = test_state();
let app = Router::new()
.route("/api/v1/notify", axum_post(notify))
.with_state(test_app_state(state));
let body = serde_json::json!({
"title": "stray",
"payload": "no target",
});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/notify")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.header("x-agent-id", "alice")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert!(
resp.status() == StatusCode::UNPROCESSABLE_ENTITY
|| resp.status() == StatusCode::BAD_REQUEST,
"expected 4xx for missing target_agent_id, got {}",
resp.status(),
);
}
#[tokio::test]
async fn h8b_notify_invalid_target_agent_id_rejected() {
let state = test_state();
let app = Router::new()
.route("/api/v1/notify", axum_post(notify))
.with_state(test_app_state(state));
let body = serde_json::json!({
"target_agent_id": "bob with spaces",
"title": "Hi",
"payload": "hello",
});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/notify")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.header("x-agent-id", "alice")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn h8b_notify_oversized_payload_rejected() {
let state = test_state();
let app = Router::new()
.route("/api/v1/notify", axum_post(notify))
.with_state(test_app_state(state));
let big = "a".repeat(65_537);
let body = serde_json::json!({
"target_agent_id": "bob",
"title": "huge",
"payload": big,
});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/notify")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.header("x-agent-id", "alice")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(
v["error"].as_str().unwrap(),
"invalid request",
"expected sanitized envelope, got {:?}",
v["error"],
);
}
#[tokio::test]
async fn h8b_notify_accepts_content_alias_for_payload() {
let state = test_state();
let app = Router::new()
.route("/api/v1/notify", axum_post(notify))
.with_state(test_app_state(state));
let body = serde_json::json!({
"target_agent_id": "bob",
"title": "alias",
"content": "via the content field",
});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/notify")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.header("x-agent-id", "alice")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
}
#[tokio::test]
async fn h8b_get_inbox_empty_returns_zero() {
let state = test_state();
let app = Router::new()
.route("/api/v1/inbox", axum::routing::get(get_inbox))
.with_state(test_app_state(state));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/inbox?agent_id=alice")
.header("x-agent-id", "alice")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["count"], 0);
assert_eq!(v["messages"].as_array().unwrap().len(), 0);
}
#[tokio::test]
async fn h8b_get_inbox_returns_pending_after_notify() {
let state = test_state();
let notify_app = Router::new()
.route("/api/v1/notify", axum_post(notify))
.with_state(test_app_state(state.clone()));
let notify_body = serde_json::json!({
"target_agent_id": "bob",
"title": "ping",
"payload": "wake up",
});
let resp = notify_app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/notify")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.header("x-agent-id", "alice")
.body(Body::from(serde_json::to_vec(¬ify_body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
let inbox_app = Router::new()
.route("/api/v1/inbox", axum::routing::get(get_inbox))
.with_state(test_app_state(state));
let resp = inbox_app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/inbox?agent_id=bob")
.header("x-agent-id", "bob")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["count"], 1);
let msg = &v["messages"][0];
assert_eq!(msg["title"], "ping");
let from = msg["from"].as_str().unwrap();
assert!(
from == "alice" || from.starts_with("ai:alice@"),
"unexpected sender: {from}",
);
assert_eq!(msg["read"], false);
}
#[tokio::test]
async fn h8b_get_inbox_unread_only_filter_excludes_read() {
let state = test_state();
{
let lock = state.lock().await;
let now = Utc::now().to_rfc3339();
let unread = Memory {
id: Uuid::new_v4().to_string(),
tier: Tier::Mid,
namespace: "_messages/alice".into(),
title: "unread".into(),
content: "u".into(),
tags: vec!["_message".into()],
priority: 5,
confidence: 1.0,
source: "notify".into(),
access_count: 0,
created_at: now.clone(),
updated_at: now.clone(),
last_accessed_at: None,
expires_at: None,
metadata: serde_json::json!({"agent_id": "bob"}),
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 read = Memory {
id: Uuid::new_v4().to_string(),
tier: Tier::Mid,
namespace: "_messages/alice".into(),
title: "read".into(),
content: "r".into(),
tags: vec!["_message".into()],
priority: 5,
confidence: 1.0,
source: "notify".into(),
access_count: 5,
created_at: now.clone(),
updated_at: now,
last_accessed_at: None,
expires_at: None,
metadata: serde_json::json!({"agent_id": "bob"}),
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,
};
db::insert(&lock.0, &unread).unwrap();
db::insert(&lock.0, &read).unwrap();
}
let app = Router::new()
.route("/api/v1/inbox", axum::routing::get(get_inbox))
.with_state(test_app_state(state));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/inbox?agent_id=alice&unread_only=true")
.header("x-agent-id", "alice")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["count"], 1);
assert_eq!(v["messages"][0]["title"], "unread");
assert_eq!(v["unread_only"], true);
}
#[tokio::test]
async fn h8b_get_inbox_limit_clamps_returned_count() {
let state = test_state();
{
let lock = state.lock().await;
let now = Utc::now().to_rfc3339();
for i in 0..3 {
let mem = Memory {
id: Uuid::new_v4().to_string(),
tier: Tier::Mid,
namespace: "_messages/alice".into(),
title: format!("msg-{i}"),
content: "c".into(),
tags: vec!["_message".into()],
priority: 5,
confidence: 1.0,
source: "notify".into(),
access_count: 0,
created_at: now.clone(),
updated_at: now.clone(),
last_accessed_at: None,
expires_at: None,
metadata: serde_json::json!({"agent_id": "carol"}),
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,
};
db::insert(&lock.0, &mem).unwrap();
}
}
let app = Router::new()
.route("/api/v1/inbox", axum::routing::get(get_inbox))
.with_state(test_app_state(state));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/inbox?agent_id=alice&limit=2")
.header("x-agent-id", "alice")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["count"], 2);
}
#[tokio::test]
async fn h8b_get_inbox_invalid_agent_id_rejected() {
let state = test_state();
let app = Router::new()
.route("/api/v1/inbox", axum::routing::get(get_inbox))
.with_state(test_app_state(state));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/inbox?agent_id=bad%20agent")
.header("x-agent-id", "bad agent")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn h8b_session_start_with_valid_agent_id_echoes() {
let state = test_state();
let app = Router::new()
.route("/api/v1/session/start", axum_post(session_start))
.with_state(state);
let body = serde_json::json!({"agent_id": "alice"});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/session/start")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert!(v["session_id"].as_str().is_some());
assert_eq!(v["agent_id"], "alice");
}
#[tokio::test]
async fn h8b_session_start_namespace_filter() {
let state = test_state();
{
let lock = state.lock().await;
let now = Utc::now().to_rfc3339();
for (ns, title) in [("target-ns", "in-scope"), ("other-ns", "out")] {
let mem = Memory {
id: Uuid::new_v4().to_string(),
tier: Tier::Long,
namespace: ns.into(),
title: title.into(),
content: "body".into(),
tags: vec![],
priority: 5,
confidence: 1.0,
source: "api".into(),
access_count: 0,
created_at: now.clone(),
updated_at: now.clone(),
last_accessed_at: None,
expires_at: None,
metadata: serde_json::json!({"agent_id": "alice"}),
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,
};
db::insert(&lock.0, &mem).unwrap();
}
}
let app = Router::new()
.route("/api/v1/session/start", axum_post(session_start))
.with_state(state);
let body = serde_json::json!({"namespace": "target-ns", "limit": 5, "agent_id": "alice"});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/session/start")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
let mems = v["memories"].as_array().unwrap();
assert_eq!(mems.len(), 1);
assert_eq!(mems[0]["title"], "in-scope");
}
#[tokio::test]
async fn h8b_session_start_returns_session_id_without_agent() {
let state = test_state();
let app = Router::new()
.route("/api/v1/session/start", axum_post(session_start))
.with_state(state);
let body = serde_json::json!({});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/session/start")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
let sid = v["session_id"].as_str().unwrap();
assert_eq!(sid.len(), 36);
assert!(v.get("agent_id").is_none() || v["agent_id"].is_null());
assert_eq!(v["mode"], "session_start");
}
#[tokio::test]
async fn h8b_session_start_preloads_recent_context() {
let state = test_state();
{
let lock = state.lock().await;
let now = Utc::now().to_rfc3339();
let mem = Memory {
id: Uuid::new_v4().to_string(),
tier: Tier::Long,
namespace: "global".into(),
title: "preload-me".into(),
content: "context".into(),
tags: vec![],
priority: 5,
confidence: 1.0,
source: "api".into(),
access_count: 0,
created_at: now.clone(),
updated_at: now,
last_accessed_at: None,
expires_at: None,
metadata: serde_json::json!({"agent_id": "alice"}),
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,
};
db::insert(&lock.0, &mem).unwrap();
}
let app = Router::new()
.route("/api/v1/session/start", axum_post(session_start))
.with_state(state);
let body = serde_json::json!({"limit": 50, "agent_id": "alice"});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/session/start")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
let mems = v["memories"].as_array().unwrap();
assert!(
mems.iter().any(|m| m["title"] == "preload-me"),
"session_start must preload recent memories",
);
}
#[tokio::test]
async fn http_list_agents_empty_returns_zero_count() {
let state = test_state();
let app = Router::new()
.route("/api/v1/agents", axum_get(list_agents))
.with_state(test_app_state(state.clone()));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/agents")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["count"], 0);
assert_eq!(v["agents"].as_array().unwrap().len(), 0);
}
#[tokio::test]
async fn http_list_agents_returns_registered_rows() {
let state = test_state();
{
let lock = state.lock().await;
db::register_agent(&lock.0, "alice", "human", &["read".into(), "write".into()]).unwrap();
db::register_agent(&lock.0, "bob", "ai:claude-opus-4.7", &["recall".into()]).unwrap();
}
let app = Router::new()
.route("/api/v1/agents", axum_get(list_agents))
.with_state(test_app_state(state.clone()));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/agents")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["count"], 2);
let agents = v["agents"].as_array().unwrap();
let ids: Vec<&str> = agents
.iter()
.filter_map(|a| a["agent_id"].as_str())
.collect();
assert!(ids.contains(&"alice"));
assert!(ids.contains(&"bob"));
}
#[tokio::test]
async fn http_list_agents_includes_types_and_capabilities() {
let state = test_state();
{
let lock = state.lock().await;
db::register_agent(
&lock.0,
"alpha",
"ai:claude-opus-4.7",
&["read".into(), "store".into(), "recall".into()],
)
.unwrap();
}
let app = Router::new()
.route("/api/v1/agents", axum_get(list_agents))
.with_state(test_app_state(state.clone()));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/agents")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
let agents = v["agents"].as_array().unwrap();
assert_eq!(agents.len(), 1);
let a = &agents[0];
assert_eq!(a["agent_id"], "alpha");
assert_eq!(a["agent_type"], "ai:claude-opus-4.7");
let caps = a["capabilities"].as_array().unwrap();
assert_eq!(caps.len(), 3);
let cap_strs: Vec<&str> = caps.iter().filter_map(|c| c.as_str()).collect();
assert!(cap_strs.contains(&"read"));
assert!(cap_strs.contains(&"store"));
assert!(cap_strs.contains(&"recall"));
}
#[tokio::test]
async fn http_register_agent_happy_path_returns_created() {
let state = test_state();
let app = Router::new()
.route("/api/v1/agents", axum_post(register_agent))
.with_state(test_app_state(state.clone()));
let body = serde_json::json!({
"agent_id": "alice",
"agent_type": "human",
"capabilities": ["read", "write"]
});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/agents")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["registered"], true);
assert_eq!(v["agent_id"], "alice");
assert_eq!(v["agent_type"], "human");
let lock = state.lock().await;
let agents = db::list_agents(&lock.0).unwrap();
assert_eq!(agents.len(), 1);
assert_eq!(agents[0].agent_id, "alice");
}
#[tokio::test]
async fn http_register_agent_missing_agent_type_400() {
let state = test_state();
let app = Router::new()
.route("/api/v1/agents", axum_post(register_agent))
.with_state(test_app_state(state));
let body = serde_json::json!({
"agent_id": "alice"
});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/agents")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert!(
resp.status().is_client_error(),
"expected 4xx for missing agent_type, got {}",
resp.status()
);
}
#[tokio::test]
async fn http_register_agent_invalid_agent_id_with_space_400() {
let state = test_state();
let app = Router::new()
.route("/api/v1/agents", axum_post(register_agent))
.with_state(test_app_state(state));
let body = serde_json::json!({
"agent_id": "bad agent",
"agent_type": "human",
"capabilities": []
});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/agents")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn http_register_agent_duplicate_register_idempotent_preserves_registered_at() {
let state = test_state();
let app = Router::new()
.route("/api/v1/agents", axum_post(register_agent))
.with_state(test_app_state(state.clone()));
let body = serde_json::json!({
"agent_id": "twice",
"agent_type": "human",
"capabilities": ["read"]
});
let r1 = app
.clone()
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/agents")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(r1.status(), StatusCode::CREATED);
let r2 = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/agents")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(r2.status(), StatusCode::CREATED);
let lock = state.lock().await;
let agents = db::list_agents(&lock.0).unwrap();
let twice: Vec<_> = agents.iter().filter(|a| a.agent_id == "twice").collect();
assert_eq!(
twice.len(),
1,
"duplicate register must collapse to one row"
);
}
#[tokio::test]
async fn http_register_agent_capabilities_array_preserved() {
let state = test_state();
let app = Router::new()
.route("/api/v1/agents", axum_post(register_agent))
.with_state(test_app_state(state.clone()));
let body = serde_json::json!({
"agent_id": "capper",
"agent_type": "ai:claude-opus-4.7",
"capabilities": ["search", "store", "recall", "consolidate"]
});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/agents")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
let echoed = v["capabilities"].as_array().unwrap();
assert_eq!(echoed.len(), 4);
let lock = state.lock().await;
let agents = db::list_agents(&lock.0).unwrap();
let me = agents.iter().find(|a| a.agent_id == "capper").unwrap();
assert_eq!(me.capabilities.len(), 4);
assert!(me.capabilities.contains(&"search".to_string()));
assert!(me.capabilities.contains(&"store".to_string()));
assert!(me.capabilities.contains(&"recall".to_string()));
assert!(me.capabilities.contains(&"consolidate".to_string()));
}
#[tokio::test]
async fn http_list_pending_with_pending_actions_returns_them() {
use crate::models::GovernedAction;
let state = test_state();
{
let lock = state.lock().await;
db::queue_pending_action(
&lock.0,
GovernedAction::Store,
"ns-a",
None,
"alice",
&serde_json::json!({"title": "first", "content": "c1"}),
)
.unwrap();
db::queue_pending_action(
&lock.0,
GovernedAction::Store,
"ns-b",
None,
"bob",
&serde_json::json!({"title": "second", "content": "c2"}),
)
.unwrap();
}
let app = Router::new()
.route("/api/v1/pending", axum_get(list_pending))
.with_state(test_app_state_with_admin(state.clone(), "ops:admin"));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/pending")
.header("x-agent-id", "ops:admin")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["count"], 2);
assert_eq!(v["pending"].as_array().unwrap().len(), 2);
}
#[tokio::test]
async fn http_list_pending_filters_by_status_pending() {
use crate::models::GovernedAction;
let state = test_state();
let kept_id = {
let lock = state.lock().await;
let id = db::queue_pending_action(
&lock.0,
GovernedAction::Store,
"ns-keep",
None,
"alice",
&serde_json::json!({"title": "stay", "content": "x"}),
)
.unwrap();
let other = db::queue_pending_action(
&lock.0,
GovernedAction::Store,
"ns-reject",
None,
"alice",
&serde_json::json!({"title": "out", "content": "x"}),
)
.unwrap();
db::decide_pending_action(&lock.0, &other, false, "alice").unwrap();
id
};
let app = Router::new()
.route("/api/v1/pending", axum_get(list_pending))
.with_state(test_app_state_with_admin(state.clone(), "ops:admin"));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/pending?status=pending")
.header("x-agent-id", "ops:admin")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
let items = v["pending"].as_array().unwrap();
assert_eq!(items.len(), 1);
assert_eq!(items[0]["id"], kept_id);
assert_eq!(items[0]["status"], "pending");
}
#[tokio::test]
async fn http_list_pending_filters_by_status_rejected() {
use crate::models::GovernedAction;
let state = test_state();
{
let lock = state.lock().await;
let id = db::queue_pending_action(
&lock.0,
GovernedAction::Store,
"ns-r",
None,
"alice",
&serde_json::json!({"title": "rejected", "content": "x"}),
)
.unwrap();
db::decide_pending_action(&lock.0, &id, false, "alice").unwrap();
db::queue_pending_action(
&lock.0,
GovernedAction::Store,
"ns-p",
None,
"alice",
&serde_json::json!({"title": "pending", "content": "x"}),
)
.unwrap();
}
let app = Router::new()
.route("/api/v1/pending", axum_get(list_pending))
.with_state(test_app_state_with_admin(state.clone(), "ops:admin"));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/pending?status=rejected&limit=10")
.header("x-agent-id", "ops:admin")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
let items = v["pending"].as_array().unwrap();
assert_eq!(items.len(), 1);
assert_eq!(items[0]["status"], "rejected");
}
#[tokio::test]
async fn http_list_pending_limit_clamped_to_1000() {
let state = test_state();
let app = Router::new()
.route("/api/v1/pending", axum_get(list_pending))
.with_state(test_app_state(state.clone()));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/pending?limit=99999")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn http_approve_pending_happy_path_executes_store() {
let _g = APPROVE_HMAC_TEST_LOCK
.lock()
.unwrap_or_else(|p| p.into_inner());
crate::config::set_active_hooks_hmac_secret(Some("a1-test-secret".to_string()));
use crate::models::GovernedAction;
let state = test_state();
let now_rfc = Utc::now().to_rfc3339();
let pending_id = {
let lock = state.lock().await;
db::queue_pending_action(
&lock.0,
GovernedAction::Store,
"approve-ns",
None,
"alice",
&serde_json::json!({
"id": Uuid::new_v4().to_string(),
"tier": Tier::Long.as_str(),
"namespace": "approve-ns",
"title": "approved-store",
"content": "executed via approval",
"tags": [],
"priority": 5,
"confidence": 1.0,
"source": "api",
"access_count": 0,
"created_at": now_rfc,
"updated_at": now_rfc,
"metadata": {}
}),
)
.unwrap()
};
let app = Router::new()
.route("/api/v1/pending/{id}/approve", axum_post(approve_pending))
.with_state(test_app_state(state.clone()));
let (ts, sig) = sign_approve_body("a1-test-secret", "POST", &pending_id, b"");
let resp = app
.oneshot(
axum::http::Request::builder()
.uri(format!("/api/v1/pending/{pending_id}/approve"))
.method("POST")
.header("x-agent-id", "approver-alice")
.header("x-ai-memory-timestamp", &ts)
.header("x-ai-memory-signature", &sig)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let status = resp.status();
let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
.await
.unwrap();
crate::config::set_active_hooks_hmac_secret(None);
assert_eq!(status, StatusCode::OK);
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["approved"], true);
assert_eq!(v["executed"], true);
assert_eq!(v["decided_by"], "approver-alice");
let lock = state.lock().await;
let pa = db::get_pending_action(&lock.0, &pending_id)
.unwrap()
.unwrap();
assert_eq!(pa.status, "approved");
assert_eq!(pa.decided_by.as_deref(), Some("approver-alice"));
}
#[tokio::test]
async fn http_approve_pending_invalid_id_format_400() {
let _g = APPROVE_HMAC_TEST_LOCK
.lock()
.unwrap_or_else(|p| p.into_inner());
crate::config::set_active_hooks_hmac_secret(Some("a1-test-secret".to_string()));
let state = test_state();
let app = Router::new()
.route("/api/v1/pending/{id}/approve", axum_post(approve_pending))
.with_state(test_app_state(state));
let (ts, sig) = sign_approve_body("a1-test-secret", "POST", "bad\x01id", b"");
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/pending/bad%01id/approve")
.method("POST")
.header("x-agent-id", "alice")
.header("x-ai-memory-timestamp", &ts)
.header("x-ai-memory-signature", &sig)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
crate::config::set_active_hooks_hmac_secret(None);
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn http_approve_pending_already_approved_is_rejected() {
let _g = APPROVE_HMAC_TEST_LOCK
.lock()
.unwrap_or_else(|p| p.into_inner());
crate::config::set_active_hooks_hmac_secret(Some("a1-test-secret".to_string()));
use crate::models::GovernedAction;
let state = test_state();
let pid = {
let lock = state.lock().await;
let id = db::queue_pending_action(
&lock.0,
GovernedAction::Store,
"double-approve",
None,
"alice",
&serde_json::json!({
"tier": Tier::Long.as_str(),
"namespace": "double-approve",
"title": "store",
"content": "x",
"tags": [], "priority": 5, "confidence": 1.0,
"source": "api", "metadata": {}
}),
)
.unwrap();
db::decide_pending_action(&lock.0, &id, true, "alice").unwrap();
id
};
let app = Router::new()
.route("/api/v1/pending/{id}/approve", axum_post(approve_pending))
.with_state(test_app_state(state));
let (ts, sig) = sign_approve_body("a1-test-secret", "POST", &pid, b"");
let resp = app
.oneshot(
axum::http::Request::builder()
.uri(format!("/api/v1/pending/{pid}/approve"))
.method("POST")
.header("x-agent-id", "alice")
.header("x-ai-memory-timestamp", &ts)
.header("x-ai-memory-signature", &sig)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let status = resp.status();
let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
.await
.unwrap();
crate::config::set_active_hooks_hmac_secret(None);
assert_eq!(status, StatusCode::FORBIDDEN);
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
let err = v["error"].as_str().unwrap_or("");
assert!(
err.contains("already decided") || err.contains("rejected"),
"expected already-decided message, got {err}"
);
}
#[tokio::test]
async fn http_approve_pending_executor_records_decided_by() {
let _g = APPROVE_HMAC_TEST_LOCK
.lock()
.unwrap_or_else(|p| p.into_inner());
crate::config::set_active_hooks_hmac_secret(Some("a1-test-secret".to_string()));
use crate::models::GovernedAction;
let state = test_state();
let now_rfc = Utc::now().to_rfc3339();
let pid = {
let lock = state.lock().await;
db::queue_pending_action(
&lock.0,
GovernedAction::Store,
"executor-ns",
None,
"requester-bob",
&serde_json::json!({
"id": Uuid::new_v4().to_string(),
"tier": Tier::Long.as_str(),
"namespace": "executor-ns",
"title": "e",
"content": "y",
"tags": [], "priority": 5, "confidence": 1.0,
"source": "api",
"access_count": 0,
"created_at": now_rfc,
"updated_at": now_rfc,
"metadata": {}
}),
)
.unwrap()
};
let app = Router::new()
.route("/api/v1/pending/{id}/approve", axum_post(approve_pending))
.with_state(test_app_state(state.clone()));
let (ts, sig) = sign_approve_body("a1-test-secret", "POST", &pid, b"");
let resp = app
.oneshot(
axum::http::Request::builder()
.uri(format!("/api/v1/pending/{pid}/approve"))
.method("POST")
.header("x-agent-id", "executor-claude")
.header("x-ai-memory-timestamp", &ts)
.header("x-ai-memory-signature", &sig)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
crate::config::set_active_hooks_hmac_secret(None);
assert_eq!(resp.status(), StatusCode::OK);
let lock = state.lock().await;
let pa = db::get_pending_action(&lock.0, &pid).unwrap().unwrap();
assert_eq!(pa.requested_by, "requester-bob");
assert_eq!(pa.decided_by.as_deref(), Some("executor-claude"));
assert_eq!(pa.status, "approved");
}
#[tokio::test]
async fn http_approve_pending_returns_memory_id_for_store_payload() {
use crate::models::GovernedAction;
let state = test_state();
let now_rfc = Utc::now().to_rfc3339();
let pid = {
let lock = state.lock().await;
db::queue_pending_action(
&lock.0,
GovernedAction::Store,
"executed-write",
None,
"alice",
&serde_json::json!({
"id": Uuid::new_v4().to_string(),
"tier": Tier::Long.as_str(),
"namespace": "executed-write",
"title": "executed-mem",
"content": "this exists after approval",
"tags": [], "priority": 5, "confidence": 1.0,
"source": "api",
"access_count": 0,
"created_at": now_rfc,
"updated_at": now_rfc,
"metadata": {}
}),
)
.unwrap()
};
let app = Router::new()
.route("/api/v1/pending/{id}/approve", axum_post(approve_pending))
.with_state(test_app_state(state.clone()));
let _g = APPROVE_HMAC_TEST_LOCK
.lock()
.unwrap_or_else(|p| p.into_inner());
crate::config::set_active_hooks_hmac_secret(Some("a1-test-secret".to_string()));
let (ts, sig) = sign_approve_body("a1-test-secret", "POST", &pid, b"");
let resp = app
.oneshot(
axum::http::Request::builder()
.uri(format!("/api/v1/pending/{pid}/approve"))
.method("POST")
.header("x-agent-id", "alice")
.header("x-ai-memory-timestamp", &ts)
.header("x-ai-memory-signature", &sig)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let status = resp.status();
let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
.await
.unwrap();
crate::config::set_active_hooks_hmac_secret(None);
assert_eq!(status, StatusCode::OK);
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
let mem_id = v["memory_id"].as_str().expect("memory_id present");
let lock = state.lock().await;
let mem = db::get(&lock.0, mem_id).unwrap().expect("memory exists");
assert_eq!(mem.title, "executed-mem");
assert_eq!(mem.namespace, "executed-write");
}
#[tokio::test]
async fn http_reject_pending_happy_path_marks_rejected_no_execution() {
let _g = APPROVE_HMAC_TEST_LOCK
.lock()
.unwrap_or_else(|p| p.into_inner());
crate::config::set_active_hooks_hmac_secret(Some("a1-test-secret".to_string()));
use crate::models::GovernedAction;
let state = test_state();
let pid = {
let lock = state.lock().await;
db::queue_pending_action(
&lock.0,
GovernedAction::Store,
"reject-ns",
None,
"alice",
&serde_json::json!({
"tier": Tier::Long.as_str(),
"namespace": "reject-ns",
"title": "blocked",
"content": "must not be created",
"tags": [], "priority": 5, "confidence": 1.0,
"source": "api", "metadata": {}
}),
)
.unwrap()
};
let app = Router::new()
.route("/api/v1/pending/{id}/reject", axum_post(reject_pending))
.with_state(test_app_state(state.clone()));
let (ts, sig) = sign_approve_body("a1-test-secret", "POST", &pid, b"");
let resp = app
.oneshot(
axum::http::Request::builder()
.uri(format!("/api/v1/pending/{pid}/reject"))
.method("POST")
.header("x-agent-id", "rejector-alice")
.header("x-ai-memory-timestamp", &ts)
.header("x-ai-memory-signature", &sig)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let status = resp.status();
let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
.await
.unwrap();
crate::config::set_active_hooks_hmac_secret(None);
assert_eq!(status, StatusCode::OK);
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["rejected"], true);
assert_eq!(v["decided_by"], "rejector-alice");
let lock = state.lock().await;
let pa = db::get_pending_action(&lock.0, &pid).unwrap().unwrap();
assert_eq!(pa.status, "rejected");
let rows = db::list(
&lock.0,
Some("reject-ns"),
None,
10,
0,
None,
None,
None,
None,
None,
)
.unwrap();
assert!(
rows.is_empty(),
"rejection must not execute the queued payload"
);
}
#[tokio::test]
async fn http_reject_pending_already_rejected_returns_404() {
let _g = APPROVE_HMAC_TEST_LOCK
.lock()
.unwrap_or_else(|p| p.into_inner());
crate::config::set_active_hooks_hmac_secret(Some("a1-test-secret".to_string()));
use crate::models::GovernedAction;
let state = test_state();
let pid = {
let lock = state.lock().await;
let id = db::queue_pending_action(
&lock.0,
GovernedAction::Store,
"double-reject",
None,
"alice",
&serde_json::json!({
"tier": Tier::Long.as_str(),
"namespace": "double-reject",
"title": "x",
"content": "x",
"tags": [], "priority": 5, "confidence": 1.0,
"source": "api", "metadata": {}
}),
)
.unwrap();
db::decide_pending_action(&lock.0, &id, false, "alice").unwrap();
id
};
let app = Router::new()
.route("/api/v1/pending/{id}/reject", axum_post(reject_pending))
.with_state(test_app_state(state));
let (ts, sig) = sign_approve_body("a1-test-secret", "POST", &pid, b"");
let resp = app
.oneshot(
axum::http::Request::builder()
.uri(format!("/api/v1/pending/{pid}/reject"))
.method("POST")
.header("x-agent-id", "alice")
.header("x-ai-memory-timestamp", &ts)
.header("x-ai-memory-signature", &sig)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
crate::config::set_active_hooks_hmac_secret(None);
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn http_reject_pending_invalid_id_format_400() {
let _g = APPROVE_HMAC_TEST_LOCK
.lock()
.unwrap_or_else(|p| p.into_inner());
crate::config::set_active_hooks_hmac_secret(Some("a1-test-secret".to_string()));
let state = test_state();
let app = Router::new()
.route("/api/v1/pending/{id}/reject", axum_post(reject_pending))
.with_state(test_app_state(state));
let (ts, sig) = sign_approve_body("a1-test-secret", "POST", "bad\x01id", b"");
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/pending/bad%01id/reject")
.method("POST")
.header("x-agent-id", "alice")
.header("x-ai-memory-timestamp", &ts)
.header("x-ai-memory-signature", &sig)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
crate::config::set_active_hooks_hmac_secret(None);
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn http_consolidate_two_into_one_happy_path() {
let state = test_state();
let now = Utc::now().to_rfc3339();
let (id_a, id_b) = {
let lock = state.lock().await;
let mk = |title: &str| Memory {
id: Uuid::new_v4().to_string(),
tier: Tier::Long,
namespace: "merge-ns".into(),
title: title.into(),
content: format!("body for {title}"),
tags: vec![],
priority: 5,
confidence: 1.0,
source: "test".into(),
access_count: 0,
created_at: now.clone(),
updated_at: now.clone(),
last_accessed_at: None,
expires_at: None,
metadata: serde_json::json!({"agent_id": "alice"}),
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 a = db::insert(&lock.0, &mk("draft-a")).unwrap();
let b = db::insert(&lock.0, &mk("draft-b")).unwrap();
(a, b)
};
let app = Router::new()
.route("/api/v1/consolidate", axum_post(consolidate_memories))
.with_state(test_app_state(state.clone()));
let body = serde_json::json!({
"ids": [id_a, id_b],
"title": "merged-result",
"summary": "a merge of two drafts",
"namespace": "merge-ns",
"tier": Tier::Long.as_str()
});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/consolidate")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.header("x-agent-id", "consolidator")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["consolidated"], 2);
let new_id = v["id"].as_str().unwrap();
let lock = state.lock().await;
let merged = db::get(&lock.0, new_id).unwrap().unwrap();
assert_eq!(merged.title, "merged-result");
assert_eq!(merged.namespace, "merge-ns");
assert!(db::get(&lock.0, &id_a).unwrap().is_none());
assert!(db::get(&lock.0, &id_b).unwrap().is_none());
}
#[tokio::test]
async fn http_consolidate_fans_out_to_peer_1552() {
use std::sync::atomic::Ordering;
let state = test_state();
let now = Utc::now().to_rfc3339();
let (id_a, id_b) = {
let lock = state.lock().await;
let mk = |title: &str| Memory {
id: Uuid::new_v4().to_string(),
tier: Tier::Long,
namespace: "merge-fed-ns".into(),
title: title.into(),
content: format!("body for {title}"),
tags: vec![],
priority: 5,
confidence: 1.0,
source: "test".into(),
access_count: 0,
created_at: now.clone(),
updated_at: now.clone(),
last_accessed_at: None,
expires_at: None,
metadata: serde_json::json!({"agent_id": "alice"}),
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 a = db::insert(&lock.0, &mk("fed-draft-a")).unwrap();
let b = db::insert(&lock.0, &mk("fed-draft-b")).unwrap();
(a, b)
};
let (peer_url, count) = h8d_spawn_mock_peer(H8dPeerBehaviour::Ack).await;
let app_state = h8d_app_state_with_fed(state.clone(), vec![peer_url], 2, 1500);
let app = Router::new()
.route("/api/v1/consolidate", axum_post(consolidate_memories))
.with_state(app_state);
let body = serde_json::json!({
"ids": [id_a, id_b],
"title": "fed-merged-result",
"summary": "a merge of two drafts that must federate",
"namespace": "merge-fed-ns",
"tier": Tier::Long.as_str()
});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/consolidate")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.header("x-agent-id", "consolidator")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
assert!(
count.load(Ordering::Relaxed) >= 1,
"consolidate must broadcast the merged memory to the federation quorum"
);
}
#[tokio::test]
async fn http_reflect_fans_out_to_peer_1552() {
use std::sync::atomic::Ordering;
let state = test_state();
let now = Utc::now().to_rfc3339();
let base_id = {
let lock = state.lock().await;
let base = Memory {
id: Uuid::new_v4().to_string(),
tier: Tier::Long,
namespace: "reflect-fed-ns".into(),
title: "reflect-base".into(),
content: "base observation to reflect upon".into(),
tags: vec![],
priority: 5,
confidence: 1.0,
source: "test".into(),
access_count: 0,
created_at: now.clone(),
updated_at: now.clone(),
last_accessed_at: None,
expires_at: None,
metadata: serde_json::json!({"agent_id": "alice"}),
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,
};
db::insert(&lock.0, &base).unwrap()
};
let (peer_url, count) = h8d_spawn_mock_peer(H8dPeerBehaviour::Ack).await;
let app_state = h8d_app_state_with_fed(state.clone(), vec![peer_url], 2, 1500);
let app = Router::new()
.route("/api/v1/memory_reflect", axum_post(handle_reflect_http))
.with_state(app_state);
let body = serde_json::json!({
"source_ids": [base_id],
"title": "reflection-1",
"content": "a reflection on the base observation",
"namespace": "reflect-fed-ns",
"agent_id": "alice",
});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/memory_reflect")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.header("x-agent-id", "alice")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
assert!(
count.load(Ordering::Relaxed) >= 1,
"reflect must broadcast the reflection to the federation quorum"
);
}
#[tokio::test]
async fn http_consolidate_single_id_400() {
let state = test_state();
let app = Router::new()
.route("/api/v1/consolidate", axum_post(consolidate_memories))
.with_state(test_app_state(state));
let body = serde_json::json!({
"ids": [Uuid::new_v4().to_string()],
"title": "lone-merge",
"summary": "only one source",
"namespace": "merge-ns"
});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/consolidate")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn http_consolidate_invalid_namespace_400() {
let state = test_state();
let app = Router::new()
.route("/api/v1/consolidate", axum_post(consolidate_memories))
.with_state(test_app_state(state));
let body = serde_json::json!({
"ids": [Uuid::new_v4().to_string(), Uuid::new_v4().to_string()],
"title": "merge",
"summary": "x",
"namespace": "bad ns"
});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/consolidate")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn http_consolidate_invalid_agent_id_400() {
let state = test_state();
let id_a = Uuid::new_v4().to_string();
let id_b = Uuid::new_v4().to_string();
let app = Router::new()
.route("/api/v1/consolidate", axum_post(consolidate_memories))
.with_state(test_app_state(state));
let body = serde_json::json!({
"ids": [id_a, id_b],
"title": "merge",
"summary": "x",
"namespace": "merge-ns"
});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/consolidate")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.header("x-agent-id", "bad agent id")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn http_consolidate_max_id_count_cap_exceeded_400() {
let state = test_state();
let ids: Vec<String> = (0..101).map(|_| Uuid::new_v4().to_string()).collect();
let app = Router::new()
.route("/api/v1/consolidate", axum_post(consolidate_memories))
.with_state(test_app_state(state));
let body = serde_json::json!({
"ids": ids,
"title": "too-many",
"summary": "x",
"namespace": "merge-ns"
});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/consolidate")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn http_consolidate_missing_source_500() {
let state = test_state();
let id_a = Uuid::new_v4().to_string();
let id_b = Uuid::new_v4().to_string();
let app = Router::new()
.route("/api/v1/consolidate", axum_post(consolidate_memories))
.with_state(test_app_state(state));
let body = serde_json::json!({
"ids": [id_a, id_b],
"title": "merge",
"summary": "x",
"namespace": "merge-ns"
});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/consolidate")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
}
#[tokio::test]
async fn http_contradictions_empty_no_pairs() {
let state = test_state();
let app = Router::new()
.route("/api/v1/contradictions", axum_get(detect_contradictions))
.with_state(test_app_state(state));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/contradictions?namespace=empty-ns")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["memories"].as_array().unwrap().len(), 0);
assert_eq!(v["links"].as_array().unwrap().len(), 0);
}
#[tokio::test]
async fn http_contradictions_synthesizes_links_for_same_title() {
let state = test_state();
let now = Utc::now().to_rfc3339();
{
let lock = state.lock().await;
let mk = |title: &str, content: &str| Memory {
id: Uuid::new_v4().to_string(),
tier: Tier::Long,
namespace: "contradict-ns".into(),
title: title.into(),
content: content.into(),
tags: vec![],
priority: 5,
confidence: 1.0,
source: "api".into(),
access_count: 0,
created_at: now.clone(),
updated_at: now.clone(),
last_accessed_at: None,
expires_at: None,
metadata: serde_json::json!({"topic": "earth-shape"}),
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,
};
db::insert(&lock.0, &mk("alice-says", "earth is round")).unwrap();
db::insert(&lock.0, &mk("bob-says", "earth is flat")).unwrap();
}
let app = Router::new()
.route("/api/v1/contradictions", axum_get(detect_contradictions))
.with_state(test_app_state(state));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/contradictions?namespace=contradict-ns&topic=earth-shape")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
let memories = v["memories"].as_array().unwrap();
assert_eq!(memories.len(), 2);
let links = v["links"].as_array().unwrap();
assert!(links.iter().any(|l| {
l["relation"].as_str() == Some("contradicts") && l["synthesized"].as_bool() == Some(true)
}));
}
#[tokio::test]
async fn http_contradictions_namespace_filter_isolates_results() {
let state = test_state();
let now = Utc::now().to_rfc3339();
{
let lock = state.lock().await;
let mk = |ns: &str, content: &str| Memory {
id: Uuid::new_v4().to_string(),
tier: Tier::Long,
namespace: ns.into(),
title: "shared-topic".into(),
content: content.into(),
tags: vec![],
priority: 5,
confidence: 1.0,
source: "api".into(),
access_count: 0,
created_at: now.clone(),
updated_at: now.clone(),
last_accessed_at: None,
expires_at: None,
metadata: serde_json::json!({}),
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,
};
db::insert(&lock.0, &mk("ns-iso-a", "first opinion")).unwrap();
db::insert(&lock.0, &mk("ns-iso-b", "different opinion")).unwrap();
}
let app = Router::new()
.route("/api/v1/contradictions", axum_get(detect_contradictions))
.with_state(test_app_state(state));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/contradictions?namespace=ns-iso-a")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
let memories = v["memories"].as_array().unwrap();
assert_eq!(memories.len(), 1, "ns filter must isolate results");
assert_eq!(memories[0]["namespace"], "ns-iso-a");
}
#[tokio::test]
async fn http_contradictions_invalid_namespace_400() {
let state = test_state();
let app = Router::new()
.route("/api/v1/contradictions", axum_get(detect_contradictions))
.with_state(test_app_state(state));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/contradictions?namespace=bad%20ns")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn http_capabilities_returns_expected_shape() {
let state = test_state();
let app = Router::new()
.route("/api/v1/capabilities", axum_get(get_capabilities))
.with_state(test_app_state(state));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/capabilities")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert!(v.get("tier").is_some(), "missing `tier`");
assert!(v.get("version").is_some(), "missing `version`");
assert!(v.get("features").is_some(), "missing `features`");
assert!(v.get("models").is_some(), "missing `models`");
assert_eq!(v["features"]["keyword_search"], true);
assert_eq!(v["features"]["semantic_search"], false);
assert_eq!(v["features"]["query_expansion"], false);
}
#[tokio::test]
async fn http_capabilities_v2_schema_includes_all_blocks() {
let _gate = crate::config::lock_permissions_mode_for_test();
crate::config::clear_permissions_mode_override_for_test();
let state = test_state();
let app = Router::new()
.route("/api/v1/capabilities", axum_get(get_capabilities))
.with_state(test_app_state(state));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/capabilities")
.header("accept-capabilities", "v2")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["schema_version"], "2");
assert!(v["permissions"].is_object());
assert_eq!(v["permissions"]["mode"], "advisory");
assert!(v["permissions"]["active_rules"].is_number());
assert!(v["permissions"].get("rule_summary").is_none());
assert_eq!(v["permissions"]["inheritance"], "enforced");
assert!(v["hooks"].is_object());
assert!(v["hooks"]["registered_count"].is_number());
assert!(v["hooks"].get("by_event").is_none());
assert!(v["compaction"].is_object());
assert_eq!(v["compaction"]["planned"], true);
assert_eq!(v["compaction"]["enabled"], false);
assert_eq!(v["compaction"]["version"], "v0.8+");
assert!(v["approval"].is_object());
assert!(v["approval"]["pending_requests"].is_number());
assert!(v["approval"].get("subscribers").is_none());
assert!(v["approval"].get("default_timeout_seconds").is_none());
assert!(v["transcripts"].is_object());
assert_eq!(v["transcripts"]["planned"], false);
assert_eq!(v["transcripts"]["enabled"], false);
assert_eq!(v["features"]["recall_mode_active"], "disabled");
assert_eq!(v["features"]["reranker_active"], "off");
assert_eq!(v["features"]["memory_reflection"]["planned"], false);
assert_eq!(v["features"]["memory_reflection"]["enabled"], true);
}
#[tokio::test]
async fn http_capabilities_version_matches_pkg_version() {
let state = test_state();
let app = Router::new()
.route("/api/v1/capabilities", axum_get(get_capabilities))
.with_state(test_app_state(state));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/capabilities")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["version"], env!("CARGO_PKG_VERSION"));
assert_eq!(v["tier"], "keyword");
}
async fn h8d_spawn_mock_peer(
behaviour: H8dPeerBehaviour,
) -> (String, std::sync::Arc<std::sync::atomic::AtomicUsize>) {
use std::sync::atomic::{AtomicUsize, Ordering};
use tokio::net::TcpListener;
let count = Arc::new(AtomicUsize::new(0));
let count_for_peer = count.clone();
#[derive(Clone)]
struct PeerState {
count: Arc<AtomicUsize>,
behaviour: H8dPeerBehaviour,
}
async fn handler(
axum::extract::State(s): axum::extract::State<PeerState>,
Json(_body): Json<serde_json::Value>,
) -> (StatusCode, Json<serde_json::Value>) {
s.count.fetch_add(1, Ordering::Relaxed);
match s.behaviour {
H8dPeerBehaviour::Ack => (
StatusCode::OK,
Json(json!({"applied": 1, "noop": 0, "skipped": 0})),
),
H8dPeerBehaviour::Fail500 => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "stub failure"})),
),
H8dPeerBehaviour::Fail503 => (
StatusCode::SERVICE_UNAVAILABLE,
Json(json!({"error": "stub unavailable"})),
),
H8dPeerBehaviour::Fail400 => (
StatusCode::BAD_REQUEST,
Json(json!({"error": "stub bad request"})),
),
H8dPeerBehaviour::Hang => {
tokio::time::sleep(std::time::Duration::from_secs(10)).await;
(StatusCode::OK, Json(json!({"applied": 1})))
}
}
}
let app = Router::new()
.route("/api/v1/sync/push", axum_post(handler))
.with_state(PeerState {
count: count_for_peer,
behaviour,
});
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
axum::serve(listener, app).await.ok();
});
(format!("http://{addr}"), count)
}
#[derive(Clone, Copy)]
enum H8dPeerBehaviour {
Ack,
Fail500,
Fail503,
Fail400,
Hang,
}
fn h8d_app_state_with_fed(db: Db, peer_urls: Vec<String>, w: usize, timeout_ms: u64) -> AppState {
let fed = crate::federation::FederationConfig::build(
w,
&peer_urls,
std::time::Duration::from_millis(timeout_ms),
None,
None,
None,
"ai:h8d-test".to_string(),
None,
)
.unwrap()
.expect("federation must be built");
AppState {
db,
embedder: Arc::new(None),
vector_index: Arc::new(Mutex::new(None)),
federation: Arc::new(Some(fed)),
tier_config: Arc::new(crate::config::FeatureTier::Keyword.config()),
scoring: Arc::new(crate::config::ResolvedScoring::default()),
profile: Arc::new(crate::profile::Profile::core()),
mcp_config: Arc::new(None),
active_keypair: Arc::new(None),
family_embeddings: Arc::new(RwLock::new(Some(Vec::new()))),
storage_backend: StorageBackend::Sqlite,
#[cfg(feature = "sal")]
store: test_sqlite_store_handle(),
llm: Arc::new(None),
auto_tag_model: Arc::new(None),
llm_call_timeout: std::time::Duration::from_secs(
crate::config::DEFAULT_LLM_CALL_TIMEOUT_SECS,
),
replay_cache: Arc::new(crate::identity::replay::ReplayCache::new()),
verify_require_nonce: false,
federation_nonce_cache: Arc::new(crate::identity::replay::FederationNonceCache::new()),
autonomous_hooks: false,
recall_scope: Arc::new(None),
deferred_audit_queue: Arc::new(None),
admin_agent_ids: Arc::new(Vec::new()),
rule_cache: std::sync::Arc::new(crate::governance::rule_cache::RuleCache::new()),
resolved_models: std::sync::Arc::new(crate::config::ResolvedModels::default()),
runtime: crate::runtime_context::RuntimeContext::global_arc(),
max_page_size: crate::handlers::MAX_BULK_SIZE,
}
}
#[tokio::test]
async fn http_get_namespace_standard_qs_returns_standard_for_existing_ns() {
let state = test_state();
let app_state = test_app_state(state.clone());
let set_router = Router::new()
.route(
"/api/v1/namespaces/{ns}/standard",
axum_post(set_namespace_standard),
)
.with_state(app_state);
let resp = set_router
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/namespaces/qs-existing/standard")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&json!({})).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
let get_router = Router::new()
.route(
"/api/v1/namespaces",
axum::routing::get(get_namespace_standard_qs),
)
.with_state(test_app_state(state.clone()));
let resp = get_router
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/namespaces?namespace=qs-existing")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["namespace"], "qs-existing");
assert!(v["standard_id"].is_string(), "standard_id must be set");
}
#[tokio::test]
async fn http_get_namespace_standard_qs_returns_null_for_missing_ns_record() {
let state = test_state();
let app = Router::new()
.route(
"/api/v1/namespaces",
axum::routing::get(get_namespace_standard_qs),
)
.with_state(test_app_state(state.clone()));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/namespaces?namespace=qs-never-set")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["namespace"], "qs-never-set");
assert!(
v["standard_id"].is_null(),
"standard_id must be null for an unset namespace"
);
}
#[tokio::test]
async fn http_get_namespace_standard_qs_falls_through_to_list_on_missing_param() {
let state = test_state();
let app = Router::new()
.route(
"/api/v1/namespaces",
axum::routing::get(get_namespace_standard_qs),
)
.with_state(test_app_state(state.clone()));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/namespaces")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert!(
v["namespaces"].is_array(),
"fallthrough must produce the list shape, got {v:?}"
);
}
#[tokio::test]
async fn http_get_namespace_standard_qs_inherit_flag_returns_chain() {
let state = test_state();
let app = Router::new()
.route(
"/api/v1/namespaces",
axum::routing::get(get_namespace_standard_qs),
)
.with_state(test_app_state(state.clone()));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/namespaces?namespace=child&inherit=true")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert!(v["chain"].is_array(), "inherit must surface the chain");
assert!(v["standards"].is_array());
}
#[tokio::test]
async fn http_get_namespace_standard_qs_invalid_namespace_returns_400() {
let state = test_state();
let app = Router::new()
.route(
"/api/v1/namespaces",
axum::routing::get(get_namespace_standard_qs),
)
.with_state(test_app_state(state.clone()));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/namespaces?namespace=bad%20ns")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn http_set_namespace_standard_qs_happy_path_creates_placeholder() {
let state = test_state();
let app = Router::new()
.route("/api/v1/namespaces", axum_post(set_namespace_standard_qs))
.with_state(test_app_state(state.clone()));
let body = json!({"namespace": "qs-set-happy"});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/namespaces")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["namespace"], "qs-set-happy");
assert_eq!(v["set"], true);
assert!(v["standard_id"].is_string());
}
#[tokio::test]
async fn http_set_namespace_standard_qs_missing_namespace_returns_400() {
let state = test_state();
let app = Router::new()
.route("/api/v1/namespaces", axum_post(set_namespace_standard_qs))
.with_state(test_app_state(state));
let body = json!({"governance": {"approver": "human"}});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/namespaces")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert!(
v["error"].as_str().unwrap_or("").contains("namespace"),
"error must mention the missing namespace, got {v:?}"
);
}
#[tokio::test]
async fn http_set_namespace_standard_qs_invalid_governance_returns_400() {
let state = test_state();
let mem_id = {
let lock = state.lock().await;
let now = Utc::now().to_rfc3339();
let mem = Memory {
id: Uuid::new_v4().to_string(),
tier: Tier::Long,
namespace: "qs-set-bad-policy".into(),
title: "anchor".into(),
content: "anchor".into(),
tags: vec![],
priority: 5,
confidence: 1.0,
source: "test".into(),
access_count: 0,
created_at: now.clone(),
updated_at: now,
last_accessed_at: None,
expires_at: None,
metadata: serde_json::json!({}),
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,
};
db::insert(&lock.0, &mem).unwrap()
};
let app = Router::new()
.route("/api/v1/namespaces", axum_post(set_namespace_standard_qs))
.with_state(test_app_state(state));
let body = json!({
"namespace": "qs-set-bad-policy",
"id": mem_id,
"governance": {
"approver": {"consensus": 0},
"write": "approve",
"promote": "log",
"delete": "log"
}
});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/namespaces")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn http_set_namespace_standard_qs_nested_standard_payload_works() {
let state = test_state();
let app = Router::new()
.route("/api/v1/namespaces", axum_post(set_namespace_standard_qs))
.with_state(test_app_state(state));
let body = json!({"standard": {"namespace": "qs-nested-ns"}});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/namespaces")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["namespace"], "qs-nested-ns");
}
#[tokio::test]
async fn http_clear_namespace_standard_qs_happy_path_after_set() {
let state = test_state();
let app_state = test_app_state(state.clone());
let set_router = Router::new()
.route("/api/v1/namespaces", axum_post(set_namespace_standard_qs))
.with_state(app_state.clone());
let _ = set_router
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/namespaces")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(
serde_json::to_vec(&json!({"namespace": "qs-clear-happy"})).unwrap(),
))
.unwrap(),
)
.await
.unwrap();
let clear_router = Router::new()
.route(
"/api/v1/namespaces",
axum::routing::delete(clear_namespace_standard_qs),
)
.with_state(app_state);
let resp = clear_router
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/namespaces?namespace=qs-clear-happy")
.method("DELETE")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["namespace"], "qs-clear-happy");
}
#[tokio::test]
async fn http_clear_namespace_standard_qs_idempotent_on_unset() {
let state = test_state();
let app = Router::new()
.route(
"/api/v1/namespaces",
axum::routing::delete(clear_namespace_standard_qs),
)
.with_state(test_app_state(state));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/namespaces?namespace=qs-clear-noop")
.method("DELETE")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn http_clear_namespace_standard_qs_missing_namespace_returns_400() {
let state = test_state();
let app = Router::new()
.route(
"/api/v1/namespaces",
axum::routing::delete(clear_namespace_standard_qs),
)
.with_state(test_app_state(state));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/namespaces")
.method("DELETE")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert!(
v["error"].as_str().unwrap_or("").contains("namespace"),
"error must mention namespace, got {v:?}"
);
}
#[tokio::test]
async fn http_set_qs_fanout_503_when_all_peers_down() {
let state = test_state();
let (peer_url, _count) = h8d_spawn_mock_peer(H8dPeerBehaviour::Fail500).await;
let app_state = h8d_app_state_with_fed(state, vec![peer_url], 2, 1500);
let app = Router::new()
.route("/api/v1/namespaces", axum_post(set_namespace_standard_qs))
.with_state(app_state);
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/namespaces")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(
serde_json::to_vec(&json!({"namespace": "qs-fed-down"})).unwrap(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
}
#[tokio::test]
async fn http_set_qs_fanout_503_payload_shape_includes_quorum_fields() {
let state = test_state();
let (peer_url, _count) = h8d_spawn_mock_peer(H8dPeerBehaviour::Fail500).await;
let app_state = h8d_app_state_with_fed(state, vec![peer_url], 2, 1500);
let app = Router::new()
.route("/api/v1/namespaces", axum_post(set_namespace_standard_qs))
.with_state(app_state);
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/namespaces")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(
serde_json::to_vec(&json!({"namespace": "qs-503-shape"})).unwrap(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["error"], "quorum_not_met");
assert!(v["got"].as_u64().is_some(), "got must be a number");
assert!(v["needed"].as_u64().is_some(), "needed must be a number");
assert!(v["reason"].is_string(), "reason must be a string");
assert_eq!(v["needed"].as_u64().unwrap(), 2);
}
#[tokio::test]
async fn http_set_qs_fanout_503_includes_retry_after_header() {
let state = test_state();
let (peer_url, _count) = h8d_spawn_mock_peer(H8dPeerBehaviour::Fail500).await;
let app_state = h8d_app_state_with_fed(state, vec![peer_url], 2, 1500);
let app = Router::new()
.route("/api/v1/namespaces", axum_post(set_namespace_standard_qs))
.with_state(app_state);
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/namespaces")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(
serde_json::to_vec(&json!({"namespace": "qs-503-retry-after"})).unwrap(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
let retry = resp
.headers()
.get("retry-after")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
assert_eq!(retry, "2", "503 must include Retry-After: 2");
}
#[tokio::test]
async fn http_set_qs_fanout_quorum_met_with_one_peer_down() {
let state = test_state();
let (peer_up, _) = h8d_spawn_mock_peer(H8dPeerBehaviour::Ack).await;
let (peer_down, _) = h8d_spawn_mock_peer(H8dPeerBehaviour::Fail500).await;
let app_state = h8d_app_state_with_fed(state, vec![peer_up, peer_down], 2, 1500);
let app = Router::new()
.route("/api/v1/namespaces", axum_post(set_namespace_standard_qs))
.with_state(app_state);
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/namespaces")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(
serde_json::to_vec(&json!({"namespace": "qs-quorum-met"})).unwrap(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
}
#[tokio::test]
async fn http_set_qs_fanout_quorum_not_met_strict_n_equals_w() {
let state = test_state();
let (peer_url, _) = h8d_spawn_mock_peer(H8dPeerBehaviour::Fail500).await;
let app_state = h8d_app_state_with_fed(state, vec![peer_url], 2, 1500);
let app = Router::new()
.route("/api/v1/namespaces", axum_post(set_namespace_standard_qs))
.with_state(app_state);
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/namespaces")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(
serde_json::to_vec(&json!({"namespace": "qs-strict-quorum"})).unwrap(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["needed"].as_u64().unwrap(), 2);
assert!(v["got"].as_u64().unwrap() < v["needed"].as_u64().unwrap());
}
#[tokio::test]
async fn http_set_qs_fanout_quorum_w_equals_one_any_success_writes_succeed() {
let state = test_state();
let (peer_url, _) = h8d_spawn_mock_peer(H8dPeerBehaviour::Fail500).await;
let app_state = h8d_app_state_with_fed(state, vec![peer_url], 1, 1500);
let app = Router::new()
.route("/api/v1/namespaces", axum_post(set_namespace_standard_qs))
.with_state(app_state);
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/namespaces")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(
serde_json::to_vec(&json!({"namespace": "qs-w1-any"})).unwrap(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
}
#[tokio::test]
async fn http_set_qs_fanout_503_when_peer_hangs_past_deadline() {
let state = test_state();
let (peer_url, _) = h8d_spawn_mock_peer(H8dPeerBehaviour::Hang).await;
let app_state = h8d_app_state_with_fed(state, vec![peer_url], 2, 200);
let app = Router::new()
.route("/api/v1/namespaces", axum_post(set_namespace_standard_qs))
.with_state(app_state);
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/namespaces")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(
serde_json::to_vec(&json!({"namespace": "qs-hang"})).unwrap(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
let reason = v["reason"].as_str().unwrap_or("");
assert!(
reason == "timeout" || reason == "unreachable",
"expected timeout/unreachable, got {reason:?}"
);
}
#[tokio::test]
async fn http_set_qs_fanout_503_when_peer_returns_503() {
let state = test_state();
let (peer_url, _) = h8d_spawn_mock_peer(H8dPeerBehaviour::Fail503).await;
let app_state = h8d_app_state_with_fed(state, vec![peer_url], 2, 1500);
let app = Router::new()
.route("/api/v1/namespaces", axum_post(set_namespace_standard_qs))
.with_state(app_state);
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/namespaces")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(
serde_json::to_vec(&json!({"namespace": "qs-peer-503"})).unwrap(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["error"], "quorum_not_met");
}
#[tokio::test]
async fn http_set_qs_fanout_503_when_peer_returns_4xx() {
let state = test_state();
let (peer_url, _) = h8d_spawn_mock_peer(H8dPeerBehaviour::Fail400).await;
let app_state = h8d_app_state_with_fed(state, vec![peer_url], 2, 1500);
let app = Router::new()
.route("/api/v1/namespaces", axum_post(set_namespace_standard_qs))
.with_state(app_state);
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/namespaces")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(
serde_json::to_vec(&json!({"namespace": "qs-peer-400"})).unwrap(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
}
#[tokio::test]
async fn http_set_qs_fanout_503_partition_minority_fails() {
let state = test_state();
let (up, _) = h8d_spawn_mock_peer(H8dPeerBehaviour::Ack).await;
let (down1, _) = h8d_spawn_mock_peer(H8dPeerBehaviour::Fail500).await;
let (down2, _) = h8d_spawn_mock_peer(H8dPeerBehaviour::Fail500).await;
let app_state = h8d_app_state_with_fed(state, vec![up, down1, down2], 3, 1500);
let app = Router::new()
.route("/api/v1/namespaces", axum_post(set_namespace_standard_qs))
.with_state(app_state);
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/namespaces")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(
serde_json::to_vec(&json!({"namespace": "qs-minority"})).unwrap(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["needed"].as_u64().unwrap(), 3);
assert!(v["got"].as_u64().unwrap() < 3);
}
#[tokio::test]
async fn http_set_qs_fanout_majority_tolerates_minority_partition() {
let state = test_state();
let (up1, _) = h8d_spawn_mock_peer(H8dPeerBehaviour::Ack).await;
let (up2, _) = h8d_spawn_mock_peer(H8dPeerBehaviour::Ack).await;
let (down, _) = h8d_spawn_mock_peer(H8dPeerBehaviour::Fail500).await;
let app_state = h8d_app_state_with_fed(state, vec![up1, up2, down], 3, 1500);
let app = Router::new()
.route("/api/v1/namespaces", axum_post(set_namespace_standard_qs))
.with_state(app_state);
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/namespaces")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(
serde_json::to_vec(&json!({"namespace": "qs-majority"})).unwrap(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
}
#[tokio::test]
async fn http_clear_qs_fanout_503_when_peer_down() {
let state = test_state();
let local_app_state = test_app_state(state.clone());
let set_router = Router::new()
.route("/api/v1/namespaces", axum_post(set_namespace_standard_qs))
.with_state(local_app_state);
let _ = set_router
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/namespaces")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(
serde_json::to_vec(&json!({"namespace": "qs-clear-fed"})).unwrap(),
))
.unwrap(),
)
.await
.unwrap();
let (peer_url, _) = h8d_spawn_mock_peer(H8dPeerBehaviour::Fail500).await;
let app_state = h8d_app_state_with_fed(state, vec![peer_url], 2, 1500);
let app = Router::new()
.route(
"/api/v1/namespaces",
axum::routing::delete(clear_namespace_standard_qs),
)
.with_state(app_state);
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/namespaces?namespace=qs-clear-fed")
.method("DELETE")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
let retry = resp
.headers()
.get("retry-after")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
assert_eq!(retry, "2", "clear 503 must include Retry-After: 2");
let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["error"], "quorum_not_met");
}
#[tokio::test]
async fn http_set_qs_fanout_no_federation_returns_201_without_peers() {
let state = test_state();
let app = Router::new()
.route("/api/v1/namespaces", axum_post(set_namespace_standard_qs))
.with_state(test_app_state(state));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/namespaces")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(
serde_json::to_vec(&json!({"namespace": "qs-no-fed"})).unwrap(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
}
#[tokio::test]
async fn http_set_qs_fanout_peer_called_at_least_once_on_quorum_failure() {
use std::sync::atomic::Ordering;
let state = test_state();
let (peer_url, count) = h8d_spawn_mock_peer(H8dPeerBehaviour::Fail500).await;
let app_state = h8d_app_state_with_fed(state, vec![peer_url], 2, 1500);
let app = Router::new()
.route("/api/v1/namespaces", axum_post(set_namespace_standard_qs))
.with_state(app_state);
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/namespaces")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(
serde_json::to_vec(&json!({"namespace": "qs-fanout-attempt"})).unwrap(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
for _ in 0..50 {
if count.load(Ordering::Relaxed) >= 1 {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
}
assert!(
count.load(Ordering::Relaxed) >= 1,
"leader must have attempted the fanout POST at least once"
);
}
#[tokio::test]
async fn http_set_qs_fanout_peer_receives_post_on_happy_path() {
use std::sync::atomic::Ordering;
let state = test_state();
let (peer_url, count) = h8d_spawn_mock_peer(H8dPeerBehaviour::Ack).await;
let app_state = h8d_app_state_with_fed(state, vec![peer_url], 2, 1500);
let app = Router::new()
.route("/api/v1/namespaces", axum_post(set_namespace_standard_qs))
.with_state(app_state);
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/namespaces")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(
serde_json::to_vec(&json!({"namespace": "qs-fanout-happy"})).unwrap(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
for _ in 0..50 {
if count.load(Ordering::Relaxed) >= 1 {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
}
assert!(count.load(Ordering::Relaxed) >= 1);
}
#[test]
fn percent_decode_lossy_passes_through_plain_ascii() {
let s = percent_decode_lossy("hello-world_123");
assert_eq!(s, "hello-world_123");
}
#[test]
fn percent_decode_lossy_decodes_basic_escape() {
let s = percent_decode_lossy("a%20b");
assert_eq!(s, "a b");
}
#[test]
fn percent_decode_lossy_decodes_plus_and_ampersand() {
let s = percent_decode_lossy("a%2Bb%26c");
assert_eq!(s, "a+b&c");
}
#[test]
fn percent_decode_lossy_handles_invalid_hex_passthrough() {
let s = percent_decode_lossy("a%ZZb");
assert_eq!(s, "a%ZZb");
}
#[test]
fn percent_decode_lossy_handles_truncated_escape() {
let s = percent_decode_lossy("a%2");
assert_eq!(s, "a%2");
let s2 = percent_decode_lossy("%");
assert_eq!(s2, "%");
}
#[test]
fn percent_decode_lossy_decodes_full_byte_range() {
let s = percent_decode_lossy("%41%42%43");
assert_eq!(s, "ABC");
}
#[test]
fn percent_decode_lossy_empty_input_returns_empty() {
let s = percent_decode_lossy("");
assert_eq!(s, "");
}
#[test]
fn constant_time_eq_returns_true_for_equal_bytes() {
assert!(constant_time_eq(b"hello", b"hello"));
assert!(constant_time_eq(b"", b""));
}
#[test]
fn constant_time_eq_returns_false_for_different_bytes() {
assert!(!constant_time_eq(b"hello", b"world"));
}
#[test]
fn constant_time_eq_returns_false_for_different_lengths() {
assert!(!constant_time_eq(b"a", b"ab"));
assert!(!constant_time_eq(b"abc", b""));
}
#[test]
fn constant_time_eq_compares_high_bytes_correctly() {
let a = [0x80u8, 0x81, 0x82, 0xFF];
let b = [0x80u8, 0x81, 0x82, 0xFF];
assert!(constant_time_eq(&a, &b));
let c = [0x80u8, 0x81, 0x82, 0xFE];
assert!(!constant_time_eq(&a, &c));
}
#[tokio::test]
async fn api_key_query_param_with_percent_encoded_chars_matches() {
let app = auth_app(Some("a+b"));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/memories?api_key=a%2Bb")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn api_key_query_param_wrong_value_rejected() {
let app = auth_app(Some("secret"));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/memories?api_key=wrong")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn api_key_query_param_with_other_pairs_still_matches() {
let app = auth_app(Some("secret"));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/memories?other=val&api_key=secret&trailing=x")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn api_key_header_with_invalid_utf8_falls_through() {
let app = auth_app(Some("secret"));
let bytes = [0x80u8, 0x81u8];
let req = axum::http::Request::builder()
.uri("/api/v1/memories")
.header(
"x-api-key",
axum::http::HeaderValue::from_bytes(&bytes).unwrap(),
)
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn http_health_route_returns_200_with_status_ok() {
let state = test_state();
let app = Router::new()
.route("/api/v1/health", axum_get(health))
.with_state(test_app_state(state));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/health")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["status"], "ok");
assert_eq!(v["service"], "ai-memory");
assert_eq!(v["embedder_ready"], false);
assert_eq!(v["federation_enabled"], false);
}
#[tokio::test]
async fn http_prometheus_metrics_returns_text_body() {
let state = test_state();
let app = Router::new()
.route("/api/v1/metrics", axum_get(prometheus_metrics))
.with_state(state);
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/metrics")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.unwrap();
assert!(!bytes.is_empty());
}
#[tokio::test]
async fn http_list_namespaces_returns_seeded_namespaces() {
let state = test_state();
let _ = insert_test_memory(&state, "ns-foo", "t1").await;
let _ = insert_test_memory(&state, "ns-bar", "t2").await;
let app = Router::new()
.route("/api/v1/namespaces", axum_get(list_namespaces))
.with_state(test_app_state(state.clone()));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/namespaces")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
let ns = v["namespaces"].as_array().expect("namespaces array");
assert!(!ns.is_empty());
}
#[tokio::test]
async fn http_get_taxonomy_no_prefix_returns_tree() {
let state = test_state();
let _ = insert_test_memory(&state, "tax/a", "t1").await;
let _ = insert_test_memory(&state, "tax/b", "t2").await;
let app = Router::new()
.route("/api/v1/taxonomy", axum_get(get_taxonomy))
.with_state(test_app_state(state.clone()));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/taxonomy")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert!(v["tree"].is_array() || v["tree"].is_object());
}
#[tokio::test]
async fn http_get_taxonomy_invalid_prefix_returns_400() {
let state = test_state();
let app = Router::new()
.route("/api/v1/taxonomy", axum_get(get_taxonomy))
.with_state(test_app_state(state.clone()));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/taxonomy?prefix=foo%2F%2Fbar")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn http_get_taxonomy_with_depth_and_limit() {
let state = test_state();
let _ = insert_test_memory(&state, "tax2/a/b", "t").await;
let app = Router::new()
.route("/api/v1/taxonomy", axum_get(get_taxonomy))
.with_state(test_app_state(state.clone()));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/taxonomy?prefix=tax2&depth=4&limit=100")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn http_get_memory_invalid_id_returns_400() {
let state = test_state();
let app = Router::new()
.route("/api/v1/memories/{id}", axum_get(get_memory))
.with_state(test_app_state(state));
let big = "a".repeat(200);
let resp = app
.oneshot(
axum::http::Request::builder()
.uri(format!("/api/v1/memories/{big}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn http_get_memory_unknown_id_returns_404() {
let state = test_state();
let app = Router::new()
.route("/api/v1/memories/{id}", axum_get(get_memory))
.with_state(test_app_state(state));
let id = "deadbeefdeadbeefdeadbeefdeadbeef";
let resp = app
.oneshot(
axum::http::Request::builder()
.uri(format!("/api/v1/memories/{id}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn http_get_memory_after_insert_returns_payload() {
let state = test_state();
let id = insert_test_memory(&state, "ns-get", "t-get").await;
let app = Router::new()
.route("/api/v1/memories/{id}", axum_get(get_memory))
.with_state(test_app_state(state));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri(format!("/api/v1/memories/{id}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["memory"]["id"], id);
assert!(v["links"].is_array());
}
#[tokio::test]
async fn http_delete_memory_invalid_id_returns_400() {
let state = test_state();
let app = Router::new()
.route(
"/api/v1/memories/{id}",
axum::routing::delete(delete_memory),
)
.with_state(test_app_state(state));
let big = "b".repeat(200);
let resp = app
.oneshot(
axum::http::Request::builder()
.uri(format!("/api/v1/memories/{big}"))
.method("DELETE")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn http_delete_memory_unknown_id_returns_404() {
let state = test_state();
let app = Router::new()
.route(
"/api/v1/memories/{id}",
axum::routing::delete(delete_memory),
)
.with_state(test_app_state(state));
let id = "cafebabecafebabecafebabecafebabe";
let resp = app
.oneshot(
axum::http::Request::builder()
.uri(format!("/api/v1/memories/{id}"))
.method("DELETE")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn http_delete_memory_happy_path_returns_deleted_true() {
let state = test_state();
let id = insert_test_memory(&state, "ns-del", "t-del").await;
let app = Router::new()
.route(
"/api/v1/memories/{id}",
axum::routing::delete(delete_memory),
)
.with_state(test_app_state(state));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri(format!("/api/v1/memories/{id}"))
.method("DELETE")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["deleted"], true);
}
#[tokio::test]
async fn http_delete_memory_invalid_x_agent_id_returns_400() {
let state = test_state();
let id = insert_test_memory(&state, "ns-del-bad", "t").await;
let app = Router::new()
.route(
"/api/v1/memories/{id}",
axum::routing::delete(delete_memory),
)
.with_state(test_app_state(state));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri(format!("/api/v1/memories/{id}"))
.method("DELETE")
.header("x-agent-id", "bad agent id")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn http_promote_memory_invalid_id_returns_400() {
let state = test_state();
let app = Router::new()
.route("/api/v1/memories/{id}/promote", axum_post(promote_memory))
.with_state(test_app_state(state));
let big = "p".repeat(200);
let resp = app
.oneshot(
axum::http::Request::builder()
.uri(format!("/api/v1/memories/{big}/promote"))
.method("POST")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn http_promote_memory_unknown_id_returns_404() {
let state = test_state();
let app = Router::new()
.route("/api/v1/memories/{id}/promote", axum_post(promote_memory))
.with_state(test_app_state(state));
let id = "facefacefacefacefacefacefaceface";
let resp = app
.oneshot(
axum::http::Request::builder()
.uri(format!("/api/v1/memories/{id}/promote"))
.method("POST")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn http_promote_target_tier_mid_stops_at_mid_keeps_expiry_1623() {
let state = test_state();
let id = {
let lock = state.lock().await;
let now = Utc::now();
let mem = Memory {
id: Uuid::new_v4().to_string(),
tier: Tier::Short,
namespace: "ns-promote-1623".into(),
title: "to-promote-1623".into(),
content: "content".into(),
tags: vec![],
priority: 5,
confidence: 1.0,
source: "test".into(),
access_count: 0,
created_at: now.to_rfc3339(),
updated_at: now.to_rfc3339(),
last_accessed_at: None,
expires_at: Some((now + Duration::seconds(crate::SECS_PER_HOUR)).to_rfc3339()),
metadata: serde_json::json!({}),
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,
};
db::insert(&lock.0, &mem).unwrap()
};
let app = Router::new()
.route("/api/v1/memories/{id}/promote", axum_post(promote_memory))
.with_state(test_app_state(state.clone()));
let resp = app
.clone()
.oneshot(
axum::http::Request::builder()
.uri(format!("/api/v1/memories/{id}/promote"))
.method("POST")
.header("content-type", "application/json")
.body(Body::from(r#"{"target_tier":"mid"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
{
let lock = state.lock().await;
let got = db::get(&lock.0, &id).unwrap().unwrap();
assert_eq!(got.tier, Tier::Mid, "#1623: must stop at mid");
assert!(
got.expires_at.is_some(),
"#1623: mid landing must keep the live TTL"
);
}
let resp2 = app
.oneshot(
axum::http::Request::builder()
.uri(format!("/api/v1/memories/{id}/promote"))
.method("POST")
.header("content-type", "application/json")
.body(Body::from(r#"{"target_tier":"short"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp2.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn http_promote_memory_happy_path_clears_expires_at() {
let state = test_state();
let id = {
let lock = state.lock().await;
let now = Utc::now();
let mem = Memory {
id: Uuid::new_v4().to_string(),
tier: Tier::Short,
namespace: "ns-promote".into(),
title: "to-promote".into(),
content: "content".into(),
tags: vec![],
priority: 5,
confidence: 1.0,
source: "test".into(),
access_count: 0,
created_at: now.to_rfc3339(),
updated_at: now.to_rfc3339(),
last_accessed_at: None,
expires_at: Some((now + Duration::seconds(crate::SECS_PER_HOUR)).to_rfc3339()),
metadata: serde_json::json!({}),
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,
};
db::insert(&lock.0, &mem).unwrap()
};
let app = Router::new()
.route("/api/v1/memories/{id}/promote", axum_post(promote_memory))
.with_state(test_app_state(state.clone()));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri(format!("/api/v1/memories/{id}/promote"))
.method("POST")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let lock = state.lock().await;
let m = db::get(&lock.0, &id).unwrap().unwrap();
assert_eq!(m.tier, Tier::Long);
assert!(m.expires_at.is_none());
}
#[tokio::test]
async fn http_update_memory_unknown_id_returns_404() {
let state = test_state();
let app = Router::new()
.route("/api/v1/memories/{id}", axum::routing::put(update_memory))
.with_state(test_app_state(state));
let id = "1234567812345678123456781234567a";
let body = serde_json::json!({"title": "new title"});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri(format!("/api/v1/memories/{id}"))
.method("PUT")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn http_update_memory_happy_path_returns_updated_payload() {
let state = test_state();
let id = insert_test_memory(&state, "ns-upd", "old title").await;
let app = Router::new()
.route("/api/v1/memories/{id}", axum::routing::put(update_memory))
.with_state(test_app_state(state.clone()));
let body = serde_json::json!({"title": "new title", "content": "new content"});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri(format!("/api/v1/memories/{id}"))
.method("PUT")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let lock = state.lock().await;
let m = db::get(&lock.0, &id).unwrap().unwrap();
assert_eq!(m.title, "new title");
assert_eq!(m.content, "new content");
}
#[tokio::test]
async fn http_create_link_happy_path_returns_201() {
let state = test_state();
let src = insert_test_memory(&state, "ns-link", "src").await;
let tgt = insert_test_memory(&state, "ns-link", "tgt").await;
let app = Router::new()
.route("/api/v1/links", axum_post(create_link))
.with_state(test_app_state(state));
let body = serde_json::json!({
"source_id": src,
"target_id": tgt,
"relation": "related_to",
});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/links")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["linked"], true);
}
#[tokio::test]
async fn http_create_link_refuses_cycle() {
use crate::config::{
PermissionsMode, lock_permissions_mode_for_test, override_active_permissions_mode_for_test,
};
let _gate = lock_permissions_mode_for_test();
override_active_permissions_mode_for_test(PermissionsMode::Off);
let state = test_state();
let a = insert_test_memory(&state, "a3-http-cycle", "a").await;
let b = insert_test_memory(&state, "a3-http-cycle", "b").await;
{
let lock = state.lock().await;
db::create_link(&lock.0, &a, &b, "reflects_on").unwrap();
}
let app = Router::new()
.route("/api/v1/links", axum_post(create_link))
.with_state(test_app_state(state));
let body = serde_json::json!({
"source_id": b,
"target_id": a,
"relation": "reflects_on",
});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/links")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::CONFLICT);
let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
let err = v["error"].as_str().unwrap_or_default();
assert!(
err.starts_with(db::LINK_CYCLE_ERR_PREFIX),
"expected cycle prefix, got: {err}"
);
}
#[tokio::test]
async fn http_create_link_respects_governance() {
use crate::config::{
PermissionsMode, lock_permissions_mode_for_test, override_active_permissions_mode_for_test,
};
use crate::permissions::{
PermissionRule, RuleDecision, clear_active_permission_rules_for_test,
set_active_permission_rules,
};
let _gate = lock_permissions_mode_for_test();
override_active_permissions_mode_for_test(PermissionsMode::Enforce);
clear_active_permission_rules_for_test();
set_active_permission_rules(vec![PermissionRule {
namespace_pattern: "a3-http-gov/**".to_string(),
op: "memory_link".to_string(),
agent_pattern: "*".to_string(),
decision: RuleDecision::Deny,
reason: Some("test: A3 http governance deny".to_string()),
}]);
let state = test_state();
let src = insert_test_memory(&state, "a3-http-gov/zone", "src").await;
let tgt = insert_test_memory(&state, "a3-http-gov/zone", "tgt").await;
let app = Router::new()
.route("/api/v1/links", axum_post(create_link))
.with_state(test_app_state(state));
let body = serde_json::json!({
"source_id": src,
"target_id": tgt,
"relation": "related_to",
});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/links")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
let err = v["error"].as_str().unwrap_or_default();
assert!(
err.starts_with(db::LINK_PERMISSION_DENIED_ERR_PREFIX),
"expected permission-denied prefix, got: {err}"
);
clear_active_permission_rules_for_test();
override_active_permissions_mode_for_test(PermissionsMode::Advisory);
}
#[tokio::test]
async fn http_create_link_invalid_link_returns_400() {
let state = test_state();
let app = Router::new()
.route("/api/v1/links", axum_post(create_link))
.with_state(test_app_state(state));
let body = serde_json::json!({
"source_id": "abc",
"target_id": "abc",
"relation": "related_to",
});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/links")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn http_get_links_invalid_id_returns_400() {
let state = test_state();
let app = Router::new()
.route("/api/v1/memories/{id}/links", axum_get(get_links))
.with_state(test_app_state(state));
let big = "x".repeat(200);
let resp = app
.oneshot(
axum::http::Request::builder()
.uri(format!("/api/v1/memories/{big}/links"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn http_get_links_after_create_returns_link() {
let state = test_state();
let src = insert_test_memory(&state, "ns-getlinks", "src").await;
let tgt = insert_test_memory(&state, "ns-getlinks", "tgt").await;
{
let lock = state.lock().await;
db::create_link(&lock.0, &src, &tgt, "related_to").unwrap();
}
let app = Router::new()
.route("/api/v1/memories/{id}/links", axum_get(get_links))
.with_state(test_app_state(state));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri(format!("/api/v1/memories/{src}/links"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
let links = v["links"].as_array().expect("links array");
assert!(!links.is_empty());
}
#[tokio::test]
async fn http_delete_link_after_create_returns_deleted_true() {
let state = test_state();
let src = insert_test_memory(&state, "ns-dellink", "src").await;
let tgt = insert_test_memory(&state, "ns-dellink", "tgt").await;
{
let lock = state.lock().await;
db::create_link(&lock.0, &src, &tgt, "related_to").unwrap();
}
let app = Router::new()
.route("/api/v1/links", axum::routing::delete(delete_link))
.with_state(test_app_state(state));
let body = serde_json::json!({
"source_id": src,
"target_id": tgt,
"relation": "related_to",
});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/links")
.method("DELETE")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["deleted"], true);
}
#[tokio::test]
async fn http_get_stats_with_data_returns_total() {
let state = test_state();
let _ = insert_test_memory(&state, "ns-stats", "t1").await;
let _ = insert_test_memory(&state, "ns-stats", "t2").await;
let app = Router::new()
.route("/api/v1/stats", axum_get(get_stats))
.with_state(test_app_state(state.clone()));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/stats")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["total"], 2);
}
#[tokio::test]
async fn http_export_memories_with_data_returns_count() {
let state = test_state();
let _ = insert_test_memory(&state, "ns-export", "t1").await;
let _ = insert_test_memory(&state, "ns-export", "t2").await;
let app = Router::new()
.route("/api/v1/export", axum_get(export_memories))
.with_state(test_app_state_with_admin(state, "ops:admin"));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/export")
.header("x-agent-id", "ops:admin")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), 256 * 1024)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["count"], 2);
assert!(v["exported_at"].is_string());
}
#[tokio::test]
async fn http_import_memories_inserts_valid_rows() {
let state = test_state();
let app = Router::new()
.route("/api/v1/import", axum_post(import_memories))
.with_state(test_app_state_with_admin(state, "ops:admin"));
let now = Utc::now().to_rfc3339();
let mem = serde_json::json!({
"id": Uuid::new_v4().to_string(),
"tier": Tier::Long.as_str(),
"namespace": "imported",
"title": "imported-row",
"content": "imported content",
"tags": [],
"priority": 5,
"confidence": 1.0,
"source": "import",
"access_count": 0,
"created_at": now,
"updated_at": now,
"last_accessed_at": null,
"expires_at": null,
"metadata": {},
});
let body = serde_json::json!({"memories": [mem]});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/import")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.header("x-agent-id", "ops:admin")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["imported"], 1);
}
#[tokio::test]
async fn http_recall_get_invalid_as_agent_returns_400() {
let state = test_state();
let app = Router::new()
.route("/api/v1/recall", axum_get(recall_memories_get))
.with_state(test_app_state(state));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/recall?context=hello&as_agent=bad%20agent")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn http_recall_post_invalid_as_agent_returns_400() {
let state = test_state();
let app = Router::new()
.route("/api/v1/recall", axum_post(recall_memories_post))
.with_state(test_app_state(state));
let body = serde_json::json!({"context": "x", "as_agent": "bad agent"});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/recall")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn http_recall_post_zero_budget_tokens_returns_200() {
let state = test_state();
let app = Router::new()
.route("/api/v1/recall", axum_post(recall_memories_post))
.with_state(test_app_state(state));
let body = serde_json::json!({"context": "x", "budget_tokens": 0});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/recall")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn http_search_invalid_as_agent_returns_400() {
let state = test_state();
let app = Router::new()
.route("/api/v1/search", axum_get(search_memories))
.with_state(test_app_state(state));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/search?q=hello&as_agent=bad%20agent")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn http_forget_memories_with_nothing_to_match_returns_zero() {
let state = test_state();
let app = Router::new()
.route("/api/v1/forget", axum_post(forget_memories))
.with_state(test_app_state(state));
let body = serde_json::json!({"namespace": "no-such-ns"});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/forget")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["deleted"], 0);
}
#[tokio::test]
async fn http_run_gc_after_insert_returns_zero_when_nothing_expired() {
let state = test_state();
let _ = insert_test_memory(&state, "gc-ns", "title").await;
let app = Router::new()
.route("/api/v1/gc", axum_post(run_gc))
.with_state(test_app_state(state));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/gc")
.method("POST")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["expired_deleted"], 0);
}
#[tokio::test]
async fn http_list_pending_default_limit_returns_count_zero_for_empty() {
let state = test_state();
let app = Router::new()
.route("/api/v1/pending", axum_get(list_pending))
.with_state(test_app_state(state.clone()));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/pending")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["count"], 0);
}
#[tokio::test]
async fn http_restore_archive_invalid_id_returns_400() {
let state = test_state();
let app = Router::new()
.route("/api/v1/archive/{id}/restore", axum_post(restore_archive))
.with_state(test_app_state(state));
let big = "r".repeat(200);
let resp = app
.oneshot(
axum::http::Request::builder()
.uri(format!("/api/v1/archive/{big}/restore"))
.method("POST")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn http_restore_archive_unknown_id_returns_404() {
let state = test_state();
let app = Router::new()
.route("/api/v1/archive/{id}/restore", axum_post(restore_archive))
.with_state(test_app_state(state));
let id = "0123456701234567012345670123456a";
let resp = app
.oneshot(
axum::http::Request::builder()
.uri(format!("/api/v1/archive/{id}/restore"))
.method("POST")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn http_restore_archive_happy_path_returns_restored_true() {
let state = test_state();
let id = insert_test_memory(&state, "ns-restore", "row").await;
{
let lock = state.lock().await;
db::archive_memory(&lock.0, &id, Some("test")).unwrap();
}
let app = Router::new()
.route("/api/v1/archive/{id}/restore", axum_post(restore_archive))
.with_state(test_app_state(state));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri(format!("/api/v1/archive/{id}/restore"))
.method("POST")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["restored"], true);
}
#[tokio::test]
async fn http_entity_get_by_alias_with_namespace_filter_returns_found_false() {
let state = test_state();
let app = Router::new()
.route("/api/v1/entities/by_alias", axum_get(entity_get_by_alias))
.with_state(test_app_state(state.clone()));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/entities/by_alias?alias=Acme&namespace=corp")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["found"], false);
}
#[tokio::test]
async fn http_kg_timeline_with_valid_since_and_until_succeeds() {
let state = test_state();
let id = insert_test_memory(&state, "kg-tl", "src").await;
let app = Router::new()
.route("/api/v1/kg/timeline", axum_get(kg_timeline))
.with_state(test_app_state(state.clone()));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri(format!(
"/api/v1/kg/timeline?source_id={id}&since=2020-01-01T00:00:00Z&until=2030-01-01T00:00:00Z&limit=100"
))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn http_session_start_with_namespace_returns_session_id() {
let state = test_state();
let _ = insert_test_memory(&state, "session-ns", "row").await;
let app = Router::new()
.route("/api/v1/session/start", axum_post(session_start))
.with_state(state);
let body = serde_json::json!({"namespace": "session-ns", "limit": 5, "agent_id": "ai:tester"});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/session/start")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert!(v["session_id"].is_string());
assert_eq!(v["agent_id"], "ai:tester");
}
#[tokio::test]
async fn http_notify_missing_payload_and_content_returns_400() {
let state = test_state();
let app = Router::new()
.route("/api/v1/notify", axum_post(notify))
.with_state(test_app_state(state));
let body = serde_json::json!({
"target_agent_id": "ai:bob",
"title": "ping",
});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/notify")
.method("POST")
.header("x-agent-id", "ai:alice")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn http_notify_with_payload_field_returns_201() {
let state = test_state();
{
let lock = state.lock().await;
db::register_agent(&lock.0, "ai:alice", "ai:human", &[]).unwrap();
db::register_agent(&lock.0, "ai:bob", "ai:human", &[]).unwrap();
}
let app = Router::new()
.route("/api/v1/notify", axum_post(notify))
.with_state(test_app_state(state));
let body = serde_json::json!({
"target_agent_id": "ai:bob",
"title": "ping",
"payload": "hi bob",
});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/notify")
.method("POST")
.header("x-agent-id", "ai:alice")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
}
#[tokio::test]
async fn http_subscribe_missing_url_and_namespace_returns_400() {
let state = test_state();
let app = Router::new()
.route("/api/v1/subscribe", axum_post(subscribe))
.with_state(test_app_state(state));
let body = serde_json::json!({"agent_id": "ai:alice"});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/subscribe")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.header("x-agent-id", "ai:alice")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn http_subscribe_with_namespace_synthesizes_loopback_url_and_returns_201() {
let state = test_state();
let app = Router::new()
.route("/api/v1/subscribe", axum_post(subscribe))
.with_state(test_app_state(state));
let body = serde_json::json!({"agent_id": "ai:alice", "namespace": "team/alice", "secret": "ns-test-secret"});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/subscribe")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.header("x-agent-id", "ai:alice")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["namespace"], "team/alice");
assert_eq!(v["agent_id"], "ai:alice");
}
#[tokio::test]
async fn http_unsubscribe_missing_id_and_namespace_returns_400() {
let state = test_state();
let app = Router::new()
.route("/api/v1/subscribe", axum::routing::delete(unsubscribe))
.with_state(test_app_state(state));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/subscribe")
.method("DELETE")
.header("x-agent-id", "ai:alice")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn http_unsubscribe_by_agent_namespace_after_subscribe_returns_removed() {
let state = test_state();
let sub_app = Router::new()
.route("/api/v1/subscribe", axum_post(subscribe))
.with_state(test_app_state(state.clone()));
let body = serde_json::json!({"agent_id": "ai:alice", "namespace": "team/alice", "secret": "ns-test-secret"});
let resp = sub_app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/subscribe")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.header("x-agent-id", "ai:alice")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
let app = Router::new()
.route("/api/v1/subscribe", axum::routing::delete(unsubscribe))
.with_state(test_app_state(state));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/subscribe?agent_id=ai:alice&namespace=team/alice")
.method("DELETE")
.header("x-agent-id", "ai:alice")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["removed"], true);
}
#[tokio::test]
async fn http_list_subscriptions_returns_subscription_rows() {
let state = test_state();
let sub_app = Router::new()
.route("/api/v1/subscribe", axum_post(subscribe))
.with_state(test_app_state(state.clone()));
let body = serde_json::json!({"agent_id": "ai:carol", "namespace": "team/carol", "secret": "ns-test-secret"});
let resp = sub_app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/subscribe")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.header("x-agent-id", "ai:carol")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
let app = Router::new()
.route("/api/v1/subscriptions", axum_get(list_subscriptions))
.with_state(test_app_state(state.clone()));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/subscriptions")
.header("x-agent-id", "ai:carol")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert!(v["count"].as_u64().unwrap() >= 1);
}
#[tokio::test]
async fn http_kg_query_after_create_link_returns_node() {
let state = test_state();
let src = insert_test_memory(&state, "kg-q", "src").await;
let tgt = insert_test_memory(&state, "kg-q", "tgt").await;
{
let lock = state.lock().await;
db::create_link(&lock.0, &src, &tgt, "related_to").unwrap();
}
let app = Router::new()
.route("/api/v1/kg/query", axum_post(kg_query))
.with_state(test_app_state(state.clone()));
let body = serde_json::json!({"source_id": src, "max_depth": 1, "limit": 10});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/kg/query")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["source_id"], src);
let mems = v["memories"].as_array().expect("memories array");
assert!(!mems.is_empty());
}
#[tokio::test]
async fn http_kg_invalidate_round_trip_marks_link() {
let state = test_state();
let src = insert_test_memory(&state, "kg-inv", "src").await;
let tgt = insert_test_memory(&state, "kg-inv", "tgt").await;
{
let lock = state.lock().await;
db::create_link(&lock.0, &src, &tgt, "related_to").unwrap();
}
let app = Router::new()
.route("/api/v1/kg/invalidate", axum_post(kg_invalidate))
.with_state(test_app_state(state.clone()));
let body = serde_json::json!({
"source_id": src,
"target_id": tgt,
"relation": "related_to",
"valid_until": "2030-01-01T00:00:00Z",
});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/kg/invalidate")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["found"], true);
}
#[tokio::test]
async fn http_list_archive_returns_archived_rows() {
let state = test_state();
let id = insert_test_memory(&state, "ns-archive", "row").await;
{
let lock = state.lock().await;
db::archive_memory(&lock.0, &id, Some("test")).unwrap();
}
let app = Router::new()
.route("/api/v1/archive", axum_get(list_archive))
.with_state(test_app_state(state.clone()));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/archive?namespace=ns-archive&limit=10&offset=0")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert!(v["count"].as_u64().unwrap() >= 1);
}
#[tokio::test]
async fn http_archive_by_ids_with_explicit_reason_records_it() {
let state = test_state();
let id = insert_test_memory(&state, "ns-arch", "row").await;
let app = Router::new()
.route("/api/v1/archive", axum_post(archive_by_ids))
.with_state(test_app_state(state));
let body = serde_json::json!({"ids": [id], "reason": "user requested"});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/archive")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["reason"], "user requested");
assert_eq!(v["count"], 1);
}
fn over_max_string_vec(n: usize) -> Vec<String> {
(0..n).map(|i| format!("id-{i:040}")).collect()
}
#[tokio::test]
async fn http_sync_push_oversize_deletions_returns_400() {
let state = test_state();
let app = Router::new()
.route("/api/v1/sync/push", axum_post(sync_push))
.with_state(test_app_state(state));
let body = serde_json::json!({
"sender_agent_id": "ai:peer",
"memories": [],
"deletions": over_max_string_vec(MAX_BULK_SIZE + 1),
});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/sync/push")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert!(
v["error"]
.as_str()
.unwrap()
.contains("deletions per request"),
"{v:?}"
);
}
#[tokio::test]
async fn http_sync_push_oversize_archives_returns_400() {
let state = test_state();
let app = Router::new()
.route("/api/v1/sync/push", axum_post(sync_push))
.with_state(test_app_state(state));
let body = serde_json::json!({
"sender_agent_id": "ai:peer",
"memories": [],
"archives": over_max_string_vec(MAX_BULK_SIZE + 1),
});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/sync/push")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert!(v["error"].as_str().unwrap().contains("archives"));
}
#[tokio::test]
async fn http_sync_push_oversize_restores_returns_400() {
let state = test_state();
let app = Router::new()
.route("/api/v1/sync/push", axum_post(sync_push))
.with_state(test_app_state(state));
let body = serde_json::json!({
"sender_agent_id": "ai:peer",
"memories": [],
"restores": over_max_string_vec(MAX_BULK_SIZE + 1),
});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/sync/push")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert!(v["error"].as_str().unwrap().contains("restores"));
}
#[tokio::test]
async fn http_sync_push_oversize_namespace_meta_clears_returns_400() {
let state = test_state();
let app = Router::new()
.route("/api/v1/sync/push", axum_post(sync_push))
.with_state(test_app_state(state));
let body = serde_json::json!({
"sender_agent_id": "ai:peer",
"memories": [],
"namespace_meta_clears": over_max_string_vec(MAX_BULK_SIZE + 1),
});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/sync/push")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert!(
v["error"]
.as_str()
.unwrap()
.contains("namespace_meta_clears")
);
}
#[tokio::test]
async fn http_sync_push_invalid_sender_agent_id_returns_400() {
let state = test_state();
let app = Router::new()
.route("/api/v1/sync/push", axum_post(sync_push))
.with_state(test_app_state(state));
let body = serde_json::json!({
"sender_agent_id": "bad agent id",
"memories": [],
});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/sync/push")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert!(v["error"].as_str().unwrap().contains("sender_agent_id"));
}
#[tokio::test]
async fn http_sync_push_invalid_x_agent_id_header_returns_400() {
let state = test_state();
let app = Router::new()
.route("/api/v1/sync/push", axum_post(sync_push))
.with_state(test_app_state(state));
let body = serde_json::json!({
"sender_agent_id": "ai:peer",
"memories": [],
});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/sync/push")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.header("x-agent-id", "bad agent id")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn http_sync_push_pending_invalid_id_skipped() {
let state = test_state();
let app = Router::new()
.route("/api/v1/sync/push", axum_post(sync_push))
.with_state(test_app_state(state));
let bad_id = "x".repeat(200); let body = serde_json::json!({
"sender_agent_id": "ai:peer",
"memories": [],
"pendings": [{
"id": bad_id,
"action_type": "store",
"memory_id": null,
"namespace": "ns",
"payload": {},
"requested_by": "ai:peer",
"requested_at": "2024-01-01T00:00:00Z",
"status": "pending",
"approvals": [],
}],
});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/sync/push")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["skipped"], 1);
assert_eq!(v["pendings_applied"], 0);
}
#[tokio::test]
async fn http_sync_push_links_invalid_id_skipped() {
let state = test_state();
let app = Router::new()
.route("/api/v1/sync/push", axum_post(sync_push))
.with_state(test_app_state(state));
let body = serde_json::json!({
"sender_agent_id": "ai:peer",
"memories": [],
"links": [{
"source_id": "abc",
"target_id": "abc",
"relation": "related_to",
"created_at": "2024-01-01T00:00:00Z",
}],
});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/sync/push")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["skipped"], 1);
assert_eq!(v["links_applied"], 0);
}
#[tokio::test]
async fn http_sync_push_dry_run_links_no_apply() {
let state = test_state();
let src = insert_test_memory(&state, "dryrun-links", "src").await;
let tgt = insert_test_memory(&state, "dryrun-links", "tgt").await;
let app = Router::new()
.route("/api/v1/sync/push", axum_post(sync_push))
.with_state(test_app_state(state));
let body = serde_json::json!({
"sender_agent_id": "ai:peer",
"memories": [],
"links": [{
"source_id": src,
"target_id": tgt,
"relation": "related_to",
"created_at": "2024-01-01T00:00:00Z",
}],
"dry_run": true,
});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/sync/push")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["links_applied"], 0);
assert_eq!(v["dry_run"], true);
}
#[tokio::test]
async fn http_consolidate_invalid_title_returns_400() {
let state = test_state();
let id1 = insert_test_memory(&state, "ns-cons", "a").await;
let id2 = insert_test_memory(&state, "ns-cons", "b").await;
let app = Router::new()
.route("/api/v1/consolidate", axum_post(consolidate_memories))
.with_state(test_app_state(state));
let body = serde_json::json!({
"ids": [id1, id2],
"title": "",
"summary": "Summary text",
"namespace": "ns-cons",
});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/consolidate")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.header("x-agent-id", "ai:tester")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn http_bulk_create_zero_body_returns_zero_created() {
let state = test_state();
let app = Router::new()
.route("/api/v1/memories/bulk", axum_post(bulk_create))
.with_state(test_app_state(state));
let body: Vec<serde_json::Value> = Vec::new();
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/memories/bulk")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["created"], 0);
}
#[tokio::test]
async fn http_entity_register_with_x_agent_id_header_succeeds() {
let state = test_state();
let app = Router::new()
.route("/api/v1/entities", axum_post(entity_register))
.with_state(test_app_state(state.clone()));
let body = serde_json::json!({
"canonical_name": "Acme Inc",
"namespace": "corp",
"aliases": ["acme", "ACME"],
});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/entities")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.header("x-agent-id", "ai:tester")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["created"], true);
assert_eq!(v["canonical_name"], "Acme Inc");
}
#[tokio::test]
async fn http_get_inbox_without_caller_uses_anonymous_default() {
let state = test_state();
let app = Router::new()
.route("/api/v1/inbox", axum_get(get_inbox))
.with_state(test_app_state(state));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/inbox")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn http_approve_pending_with_bad_header_agent_id_returns_400() {
let _g = APPROVE_HMAC_TEST_LOCK
.lock()
.unwrap_or_else(|p| p.into_inner());
crate::config::set_active_hooks_hmac_secret(Some("a1-test-secret".to_string()));
let state = test_state();
let app = Router::new()
.route("/api/v1/pending/{id}/approve", axum_post(approve_pending))
.with_state(test_app_state(state));
let id = "abcdef0123456789abcdef0123456789";
let (ts, sig) = sign_approve_body("a1-test-secret", "POST", &id, b"");
let resp = app
.oneshot(
axum::http::Request::builder()
.uri(format!("/api/v1/pending/{id}/approve"))
.method("POST")
.header("x-agent-id", "bad agent id")
.header("x-ai-memory-timestamp", &ts)
.header("x-ai-memory-signature", &sig)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
crate::config::set_active_hooks_hmac_secret(None);
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn http_reject_pending_with_bad_header_agent_id_returns_400() {
let _g = APPROVE_HMAC_TEST_LOCK
.lock()
.unwrap_or_else(|p| p.into_inner());
crate::config::set_active_hooks_hmac_secret(Some("a1-test-secret".to_string()));
let state = test_state();
let app = Router::new()
.route("/api/v1/pending/{id}/reject", axum_post(reject_pending))
.with_state(test_app_state(state));
let id = "abcdef0123456789abcdef0123456789";
let (ts, sig) = sign_approve_body("a1-test-secret", "POST", &id, b"");
let resp = app
.oneshot(
axum::http::Request::builder()
.uri(format!("/api/v1/pending/{id}/reject"))
.method("POST")
.header("x-agent-id", "bad agent id")
.header("x-ai-memory-timestamp", &ts)
.header("x-ai-memory-signature", &sig)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
crate::config::set_active_hooks_hmac_secret(None);
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn http_create_memory_invalid_x_agent_id_header_returns_400() {
let state = test_state();
let app = Router::new()
.route("/api/v1/memories", axum_post(create_memory))
.with_state(test_app_state(state));
let body = serde_json::json!({
"tier": Tier::Long.as_str(),
"namespace": "test",
"title": "t",
"content": "c",
"tags": [],
"priority": 5,
"confidence": 1.0,
"source": "api",
"metadata": {}
});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/memories")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.header("x-agent-id", "bad agent id")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert!(v["error"].as_str().unwrap().contains("agent_id"));
}
#[tokio::test]
async fn l11_create_memory_rejects_metadata_agent_id_mismatch_post_907() {
let state = test_state();
let app = Router::new()
.route("/api/v1/memories", axum_post(create_memory))
.with_state(test_app_state(state.clone()));
let body = serde_json::json!({
"tier": Tier::Long.as_str(),
"namespace": "l11-agentid",
"title": "L11 agent_id from metadata",
"content": "Caller stamped agent_id only inside metadata.",
"tags": [],
"priority": 5,
"confidence": 1.0,
"source": "api",
"metadata": {"agent_id": "ai:alice@plan-c"}
});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/memories")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.header("x-agent-id", "ai:bob@plan-c")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::FORBIDDEN,
"post-#907: metadata.agent_id disagreeing with X-Agent-Id must 403"
);
}
#[tokio::test]
async fn http_create_memory_invalid_scope_returns_400() {
let state = test_state();
let app = Router::new()
.route("/api/v1/memories", axum_post(create_memory))
.with_state(test_app_state(state));
let body = serde_json::json!({
"tier": Tier::Long.as_str(),
"namespace": "test",
"title": "t",
"content": "c",
"tags": [],
"priority": 5,
"confidence": 1.0,
"source": "api",
"metadata": {},
"scope": "not-a-valid-scope-token"
});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/memories")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn http_list_memories_invalid_agent_id_filter_returns_400() {
let state = test_state();
let app = Router::new()
.route("/api/v1/memories", axum_get(list_memories))
.with_state(test_app_state(state));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/memories?agent_id=bad%20id")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn http_check_duplicate_blank_namespace_treated_as_none() {
let state = test_state();
let app = Router::new()
.route("/api/v1/check_duplicate", axum_post(check_duplicate))
.with_state(test_app_state(state));
let body = serde_json::json!({"title": "t", "content": "c", "namespace": " "});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/check_duplicate")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
}
#[tokio::test]
async fn http_archive_by_ids_with_no_reason_defaults_to_archive() {
let state = test_state();
let id = insert_test_memory(&state, "ns-arch-default", "row").await;
let app = Router::new()
.route("/api/v1/archive", axum_post(archive_by_ids))
.with_state(test_app_state(state));
let body = serde_json::json!({"ids": [id]});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/archive")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["reason"], "archive");
}
fn pin_governance_enforce_for_test() -> std::sync::MutexGuard<'static, ()> {
let guard = crate::config::lock_permissions_mode_for_test();
crate::config::override_active_permissions_mode_for_test(
crate::config::PermissionsMode::Enforce,
);
guard
}
async fn seed_governance_policy(state: &Db, ns: &str, policy: serde_json::Value) {
let lock = state.lock().await;
let now = Utc::now().to_rfc3339();
let standard = Memory {
id: Uuid::new_v4().to_string(),
tier: Tier::Long,
namespace: ns.into(),
title: format!("_standard:{ns}"),
content: format!("standard for {ns}"),
tags: vec!["_namespace_standard".to_string()],
priority: 5,
confidence: 1.0,
source: "test".into(),
access_count: 0,
created_at: now.clone(),
updated_at: now,
last_accessed_at: None,
expires_at: None,
metadata: serde_json::json!({
"agent_id": "ai:owner",
"governance": policy,
}),
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 standard_id = db::insert(&lock.0, &standard).unwrap();
db::set_namespace_standard(&lock.0, ns, &standard_id, None).unwrap();
}
#[tokio::test]
async fn http_create_memory_governance_pending_returns_202() {
let _gate = pin_governance_enforce_for_test();
let state = test_state();
seed_governance_policy(
&state,
"gov-create",
serde_json::json!({
"write": "approve",
"delete": "owner",
"promote": "any",
"approver": "human",
}),
)
.await;
let app = Router::new()
.route("/api/v1/memories", axum_post(create_memory))
.with_state(test_app_state(state));
let body = serde_json::json!({
"tier": Tier::Long.as_str(),
"namespace": "gov-create",
"title": "queued",
"content": "should be queued, not stored",
"tags": [],
"priority": 5,
"confidence": 1.0,
"source": "api",
"metadata": {},
});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/memories")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.header("x-agent-id", "ai:caller")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::ACCEPTED);
let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["status"], "pending");
assert_eq!(v["action"], "store");
assert!(v["pending_id"].is_string());
}
#[tokio::test]
async fn http_create_memory_governance_deny_returns_403() {
let _gate = pin_governance_enforce_for_test();
let state = test_state();
seed_governance_policy(
&state,
"gov-deny",
serde_json::json!({"write": "registered", "approver": "human"}),
)
.await;
let app = Router::new()
.route("/api/v1/memories", axum_post(create_memory))
.with_state(test_app_state(state));
let body = serde_json::json!({
"tier": Tier::Long.as_str(),
"namespace": "gov-deny",
"title": "rejected",
"content": "rejected content",
"tags": [],
"priority": 5,
"confidence": 1.0,
"source": "api",
"metadata": {},
});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/memories")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.header("x-agent-id", "ai:unregistered")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert!(v["error"].as_str().unwrap().contains("governance"));
}
#[tokio::test]
async fn http_delete_memory_governance_pending_returns_202() {
let _gate = pin_governance_enforce_for_test();
let state = test_state();
seed_governance_policy(
&state,
"gov-delete",
serde_json::json!({
"write": "any",
"delete": "approve",
"promote": "any",
"approver": "human",
}),
)
.await;
let id = insert_test_memory(&state, "gov-delete", "to-delete").await;
let app = Router::new()
.route(
"/api/v1/memories/{id}",
axum::routing::delete(delete_memory),
)
.with_state(test_app_state(state));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri(format!("/api/v1/memories/{id}"))
.method("DELETE")
.header("x-agent-id", "ai:caller")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::ACCEPTED);
let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["status"], "pending");
assert_eq!(v["action"], "delete");
assert_eq!(v["memory_id"], id);
}
#[tokio::test]
async fn http_delete_memory_governance_deny_returns_403() {
let _gate = pin_governance_enforce_for_test();
let state = test_state();
seed_governance_policy(
&state,
"gov-delete-deny",
serde_json::json!({"write": "any", "delete": "owner", "approver": "human"}),
)
.await;
let id = insert_test_memory(&state, "gov-delete-deny", "row").await;
let app = Router::new()
.route(
"/api/v1/memories/{id}",
axum::routing::delete(delete_memory),
)
.with_state(test_app_state(state));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri(format!("/api/v1/memories/{id}"))
.method("DELETE")
.header("x-agent-id", "ai:other")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn http_promote_memory_governance_pending_returns_202() {
let _gate = pin_governance_enforce_for_test();
let state = test_state();
seed_governance_policy(
&state,
"gov-promote",
serde_json::json!({
"write": "any",
"delete": "any",
"promote": "approve",
"approver": "human",
}),
)
.await;
let id = insert_test_memory(&state, "gov-promote", "to-promote").await;
let app = Router::new()
.route("/api/v1/memories/{id}/promote", axum_post(promote_memory))
.with_state(test_app_state(state));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri(format!("/api/v1/memories/{id}/promote"))
.method("POST")
.header("x-agent-id", "ai:caller")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::ACCEPTED);
let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["status"], "pending");
assert_eq!(v["action"], "promote");
assert_eq!(v["memory_id"], id);
}
#[tokio::test]
async fn http_create_memory_with_top_level_scope_succeeds() {
let state = test_state();
let app = Router::new()
.route("/api/v1/memories", axum_post(create_memory))
.with_state(test_app_state(state));
let body = serde_json::json!({
"tier": Tier::Long.as_str(),
"namespace": "scoped",
"title": "with scope",
"content": "scoped content",
"tags": [],
"priority": 5,
"confidence": 1.0,
"source": "api",
"metadata": {},
"scope": "private"
});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/memories")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
}
#[tokio::test]
async fn http_create_memory_clamps_extreme_priority_to_range() {
let state = test_state();
let app = Router::new()
.route("/api/v1/memories", axum_post(create_memory))
.with_state(test_app_state(state.clone()));
let body = serde_json::json!({
"tier": Tier::Long.as_str(),
"namespace": "clamp",
"title": "clamp",
"content": "c",
"tags": [],
"priority": 10,
"confidence": 1.0,
"source": "api",
"metadata": {},
});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/memories")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
let lock = state.lock().await;
let rows = db::list(
&lock.0,
Some("clamp"),
None,
10,
0,
None,
None,
None,
None,
None,
)
.unwrap();
assert_eq!(rows[0].priority, 10);
}
#[tokio::test]
async fn http_update_memory_with_oversized_title_returns_400() {
let state = test_state();
let id = insert_test_memory(&state, "ns-bigtitle", "old").await;
let app = Router::new()
.route("/api/v1/memories/{id}", axum::routing::put(update_memory))
.with_state(test_app_state(state));
let big_title = "T".repeat(10_000);
let body = serde_json::json!({"title": big_title});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri(format!("/api/v1/memories/{id}"))
.method("PUT")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn http_purge_archive_no_query_returns_purged_zero_for_empty_archive() {
let state = test_state();
let app = Router::new()
.route("/api/v1/archive", axum::routing::delete(purge_archive))
.with_state(test_app_state(state));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/archive")
.method("DELETE")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["purged"], 0);
}
#[tokio::test]
async fn http_contradictions_topic_only_returns_ok_empty() {
let state = test_state();
let app = Router::new()
.route("/api/v1/contradictions", axum_get(detect_contradictions))
.with_state(test_app_state(state));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/contradictions?topic=missing-topic")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn http_entity_register_aliases_with_blanks_filtered() {
let state = test_state();
let app = Router::new()
.route("/api/v1/entities", axum_post(entity_register))
.with_state(test_app_state(state.clone()));
let body = serde_json::json!({
"canonical_name": "Globex",
"namespace": "corp2",
"aliases": ["", "globex", " ", "GLOBEX"],
});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/entities")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
}
#[tokio::test]
async fn http_subscribe_with_explicit_url_succeeds() {
let state = test_state();
let app = Router::new()
.route("/api/v1/subscribe", axum_post(subscribe))
.with_state(test_app_state(state));
let body = serde_json::json!({
"agent_id": "ai:webhook-user",
"url": "http://localhost:9999/webhook",
"events": "store",
"secret": "shhh",
"namespace_filter": "team",
});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/subscribe")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.header("x-agent-id", "ai:webhook-user")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["url"], "http://localhost:9999/webhook");
assert_eq!(v["events"], "store");
}
#[tokio::test]
async fn http_unsubscribe_by_unknown_id_returns_ok_unchanged() {
let state = test_state();
let app = Router::new()
.route("/api/v1/subscribe", axum::routing::delete(unsubscribe))
.with_state(test_app_state(state));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/subscribe?id=does-not-exist")
.method("DELETE")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert!(
resp.status() == StatusCode::OK || resp.status() == StatusCode::BAD_REQUEST,
"got {}",
resp.status()
);
}
#[test]
fn b3_family_descriptors_cover_all_eight_families_in_order() {
let descriptors = family_descriptors();
assert_eq!(
descriptors.len(),
Family::all().len(),
"family_descriptors must cover every Family variant"
);
for (i, family) in Family::all().iter().enumerate() {
assert_eq!(
descriptors[i].0, *family,
"family_descriptors[{i}] should be {family:?} to match Family::all()"
);
assert!(
!descriptors[i].1.trim().is_empty(),
"descriptor for {family:?} must be non-empty",
);
}
}
#[test]
fn b3_precompute_with_no_embedder_returns_empty() {
let cache = AppState::precompute_family_embeddings(None);
assert!(
cache.is_empty(),
"no-embedder boot must produce an empty cache",
);
}
#[test]
fn b3_precompute_with_local_embedder_populates_eight_descriptors() {
let Ok(embedder) = Embedder::new_local() else {
eprintln!(
"b3_precompute_with_local_embedder_populates_eight_descriptors: \
Embedder::new_local() failed (likely sandboxed network); skipping",
);
return;
};
let cache =
AppState::precompute_family_embeddings(Some(&embedder as &dyn crate::embeddings::Embed));
assert_eq!(
cache.len(),
8,
"embedder-available boot must produce 8 family-descriptor embeddings",
);
let dim = embedder.dim();
for (family, vec) in &cache {
assert_eq!(
vec.len(),
dim,
"descriptor vector for {family:?} must match embedder dim",
);
}
}
#[test]
fn b3_best_family_match_returns_none_when_cache_empty() {
let state = test_state();
let app = test_app_state(state); assert!(app.best_family_match("store a memory").is_none());
}
#[tokio::test]
async fn http_auto_tag_route_returns_503_when_no_llm_l6() {
let state = test_state();
let app = Router::new()
.route("/api/v1/auto_tag", axum_post(auto_tag_handler))
.with_state(test_app_state(state));
let body = serde_json::json!({"title": "OKR review", "content": "Quarterly OKR review notes"});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/auto_tag")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
let bytes = axum::body::to_bytes(resp.into_body(), 4096).await.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["error"], "LLM not configured");
}
#[tokio::test]
async fn http_expand_query_route_returns_503_when_no_llm_l6() {
let state = test_state();
let app = Router::new()
.route("/api/v1/expand_query", axum_post(expand_query_handler))
.with_state(test_app_state(state));
let body = serde_json::json!({"query": "team velocity"});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/expand_query")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
let bytes = axum::body::to_bytes(resp.into_body(), 4096).await.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["error"], "LLM not configured");
}
#[tokio::test]
async fn http_consolidate_accepts_use_llm_without_summary_l7() {
let state = test_state();
let now = Utc::now().to_rfc3339();
let (id_a, id_b) = {
let lock = state.lock().await;
let mk = |title: &str, content: &str| Memory {
id: Uuid::new_v4().to_string(),
tier: Tier::Mid,
namespace: "l7-no-summary".into(),
title: title.into(),
content: content.into(),
tags: vec![],
priority: 5,
confidence: 1.0,
source: "test".into(),
access_count: 0,
created_at: now.clone(),
updated_at: now.clone(),
last_accessed_at: None,
expires_at: None,
metadata: serde_json::json!({"agent_id": "alice"}),
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 a = db::insert(&lock.0, &mk("aom101-0", "first")).unwrap();
let b = db::insert(&lock.0, &mk("aom101-1", "second")).unwrap();
(a, b)
};
let app = Router::new()
.route("/api/v1/consolidate", axum_post(consolidate_memories))
.with_state(test_app_state(state.clone()));
let body = serde_json::json!({
"ids": [id_a, id_b],
"title": "AOM-101 lifecycle",
"namespace": "l7-no-summary",
"use_llm": true,
});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/consolidate")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.header("x-agent-id", "ai:alice")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_ne!(
resp.status(),
StatusCode::UNPROCESSABLE_ENTITY,
"L7 regression: consolidate 422'd on absent summary"
);
assert_eq!(resp.status(), StatusCode::CREATED);
let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
let summary = v["summary"].as_str().expect("summary in response");
assert!(
summary.len() >= 20,
"L7 fallback summary too short: {summary:?}"
);
}
#[tokio::test]
async fn http_consolidate_response_carries_summary_on_every_key_s51_reads() {
let state = test_state();
let now = Utc::now().to_rfc3339();
let (id_a, id_b) = {
let lock = state.lock().await;
let mk = |title: &str, content: &str| Memory {
id: Uuid::new_v4().to_string(),
tier: Tier::Mid,
namespace: "l7-followup".into(),
title: title.into(),
content: content.into(),
tags: vec![],
priority: 5,
confidence: 1.0,
source: "test".into(),
access_count: 0,
created_at: now.clone(),
updated_at: now.clone(),
last_accessed_at: None,
expires_at: None,
metadata: serde_json::json!({"agent_id": "alice"}),
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 a = db::insert(
&lock.0,
&mk(
"aom101-0",
"Engineering filed JIRA AOM-101 to harden the sync_push retry path.",
),
)
.unwrap();
let b = db::insert(
&lock.0,
&mk(
"aom101-1",
"AOM-101 follow-up: added exponential backoff + jitter in retry loop.",
),
)
.unwrap();
(a, b)
};
let app = Router::new()
.route("/api/v1/consolidate", axum_post(consolidate_memories))
.with_state(test_app_state(state.clone()));
let body = serde_json::json!({
"ids": [id_a, id_b],
"title": "AOM-101 lifecycle",
"namespace": "l7-followup",
"use_llm": true,
});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/consolidate")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.header("x-agent-id", "ai:alice")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
let summary_field = v["summary"].as_str().expect("summary in response");
assert!(
summary_field.len() >= 20,
"L7-followup: summary field too short: {summary_field:?}"
);
let content_field = v["content"].as_str().expect("content in response");
assert_eq!(content_field, summary_field);
let memory_obj = v["memory"]
.as_object()
.expect("response must include a memory object so S51's ternary takes the truthy branch");
let memory_content = memory_obj["content"]
.as_str()
.expect("memory.content must be a string");
assert_eq!(memory_content, summary_field);
assert!(memory_obj.contains_key("id"));
assert!(memory_obj.contains_key("title"));
let cbody = &v;
let memory_is_dict = cbody
.get("memory")
.is_some_and(serde_json::Value::is_object);
let s51_summary: String = if memory_is_dict {
let a = cbody
.get("summary")
.and_then(serde_json::Value::as_str)
.unwrap_or("");
let b = cbody
.get("content")
.and_then(serde_json::Value::as_str)
.unwrap_or("");
let c = cbody
.get("memory")
.and_then(|m| m.get("content"))
.and_then(serde_json::Value::as_str)
.unwrap_or("");
if !a.is_empty() {
a.to_string()
} else if !b.is_empty() {
b.to_string()
} else {
c.to_string()
}
} else {
String::new()
};
assert!(
s51_summary.len() >= 20,
"S51 reader sees summary_len={} on the daemon wire shape; \
expected >= 20 chars",
s51_summary.len(),
);
}
#[tokio::test]
async fn http_tools_list_returns_200_with_tools_array_l9() {
let state = test_state();
let app = Router::new()
.route("/api/v1/tools/list", axum_get(tools_list))
.with_state(test_app_state(state));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/tools/list")
.method("GET")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
let tools = v["tools"].as_array().expect("tools array");
assert!(
!tools.is_empty(),
"tools/list must enumerate at least one tool"
);
let names: Vec<&str> = tools.iter().filter_map(|t| t["name"].as_str()).collect();
assert!(
names.iter().any(|n| *n == "memory_capabilities"),
"always-on `memory_capabilities` must appear in tools/list"
);
}
#[tokio::test]
async fn http_load_family_returns_200_on_known_family_l10() {
let state = test_state();
let app = Router::new()
.route("/api/v1/memory_load_family", axum_post(load_family_handler))
.with_state(test_app_state(state));
let body = serde_json::json!({"family": "core", "k": 5});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/memory_load_family")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["family"], "core");
assert!(v["memories"].is_array(), "memories must be an array");
assert_eq!(v["k"], 5);
}
#[tokio::test]
async fn http_load_family_rejects_unknown_family_l10() {
let state = test_state();
let app = Router::new()
.route("/api/v1/memory_load_family", axum_post(load_family_handler))
.with_state(test_app_state(state));
let body = serde_json::json!({"family": "totally-bogus"});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/memory_load_family")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[test]
fn h5_verify_link_body_deserialises_verification_nonce() {
let body: VerifyLinkBody = serde_json::from_value(serde_json::json!({
"source_id": "src",
"target_id": "tgt",
"verification_nonce": "f47ac10b-58cc-4372-a567-0e02b2c3d479"
}))
.unwrap();
assert_eq!(
body.verification_nonce.as_deref(),
Some("f47ac10b-58cc-4372-a567-0e02b2c3d479"),
"H5 wire shape: nonce must round-trip from JSON to struct"
);
let body: VerifyLinkBody = serde_json::from_value(serde_json::json!({
"source_id": "src",
"target_id": "tgt"
}))
.unwrap();
assert!(
body.verification_nonce.is_none(),
"H5 back-compat: missing nonce must deserialise to None"
);
}
#[tokio::test]
async fn h5_verify_link_strict_mode_rejects_missing_nonce_with_400() {
let state = test_state();
let mut app_state = test_app_state(state);
app_state.verify_require_nonce = true;
let app = Router::new()
.route("/api/v1/links/verify", axum_post(verify_link_handler))
.with_state(app_state);
let body = serde_json::json!({
"source_id": "src-id",
"target_id": "tgt-id"
});
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/links/verify")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::BAD_REQUEST,
"strict-mode missing nonce must produce 400"
);
let bytes = http_body_util::BodyExt::collect(resp.into_body())
.await
.unwrap()
.to_bytes();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
let err = v["error"].as_str().unwrap_or("");
assert!(
err.contains("verification_nonce is required"),
"H5 strict-mode 400 must name the missing field; got: {err}"
);
}
#[test]
fn h5_replay_cache_dedups_identical_tuple() {
use crate::identity::replay::{ReplayCache, ReplayDecision};
let cache = ReplayCache::new();
let link_id = "src|tgt|related_to";
let nonce = "f47ac10b-58cc-4372-a567-0e02b2c3d479";
assert_eq!(
cache.record_and_check(link_id, b"", nonce),
ReplayDecision::Fresh,
"first verify must be fresh"
);
assert_eq!(
cache.record_and_check(link_id, b"", nonce),
ReplayDecision::Replay,
"repeat verify with same nonce must trigger replay"
);
}
#[test]
fn to_value_or_500_serialises_typed_struct() {
let mem = Memory {
id: "m1".into(),
tier: Tier::Long,
namespace: "ns".into(),
title: "t".into(),
content: "c".into(),
tags: Vec::new(),
priority: 5,
confidence: 1.0,
source: "test".into(),
access_count: 0,
created_at: Utc::now().to_rfc3339(),
updated_at: Utc::now().to_rfc3339(),
last_accessed_at: None,
expires_at: None,
metadata: serde_json::Map::new().into(),
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 value = super::to_value_or_500("test.happy", &mem).expect("Memory must serialise to JSON");
assert_eq!(value["id"], "m1");
assert_eq!(value["title"], "t");
}
#[tokio::test]
async fn to_value_or_500_returns_500_on_encode_failure() {
struct AlwaysErrors;
impl serde::Serialize for AlwaysErrors {
fn serialize<S: serde::Serializer>(&self, _serializer: S) -> Result<S::Ok, S::Error> {
Err(serde::ser::Error::custom(
"synthetic encode failure for #869 regression",
))
}
}
let err_resp = super::to_value_or_500("test.bad", &AlwaysErrors)
.expect_err("synthetic Serialize impl must fail serde_json encode");
assert_eq!(err_resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
let bytes = axum::body::to_bytes(err_resp.into_body(), 1024 * 1024)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
let err = v["error"].as_str().unwrap_or_default();
assert!(
err.contains("internal server error"),
"500 body must carry the sanitised error envelope, got: {err}"
);
}
#[tokio::test]
async fn quorum_not_met_response_wire_compat() {
use crate::federation::QuorumNotMetPayload;
use crate::replication::{QuorumError, QuorumFailureReason};
let err = QuorumError::QuorumNotMet {
got: 1,
needed: 2,
reason: QuorumFailureReason::Unreachable,
};
let payload = QuorumNotMetPayload::from_err(&err);
let resp = super::quorum_not_met_response(&payload);
assert_eq!(
resp.status(),
StatusCode::SERVICE_UNAVAILABLE,
"503 status preserved"
);
assert_eq!(
resp.headers()
.get("Retry-After")
.and_then(|v| v.to_str().ok()),
Some("2"),
"Retry-After header preserved"
);
let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["error"], "quorum_not_met");
assert_eq!(v["got"], 1);
assert_eq!(v["needed"], 2);
assert_eq!(v["reason"], "unreachable");
}
#[tokio::test]
async fn quorum_not_met_response_timeout_branch() {
use crate::federation::QuorumNotMetPayload;
use crate::replication::{QuorumError, QuorumFailureReason};
let err = QuorumError::QuorumNotMet {
got: 0,
needed: 3,
reason: QuorumFailureReason::Timeout,
};
let payload = QuorumNotMetPayload::from_err(&err);
let resp = super::quorum_not_met_response(&payload);
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["reason"], "timeout");
assert_eq!(v["got"], 0);
assert_eq!(v["needed"], 3);
assert!(
v.is_object(),
"response body must be a typed object, got {v}"
);
}
fn capture_turn_test_router(state: Db) -> Router {
Router::new()
.route("/api/v1/capture_turn", axum_post(capture_turn))
.with_state(test_app_state(state))
}
async fn post_capture_turn(
app: &Router,
agent_id: &str,
body: &serde_json::Value,
) -> (StatusCode, serde_json::Value) {
let resp = app
.clone()
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/capture_turn")
.method("POST")
.header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
.header("x-agent-id", agent_id)
.body(Body::from(serde_json::to_vec(body).unwrap()))
.unwrap(),
)
.await
.unwrap();
let status = resp.status();
let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.unwrap();
let payload: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
(status, payload)
}
#[tokio::test]
async fn http_capture_turn_creates_then_dedups_1416() {
let state = test_state();
let app = capture_turn_test_router(state);
let body = json!({
"host_session_id": "http-sess-1416",
"host_turn_index": 0,
"role": "user",
"content": "operator directive captured via HTTP L4"
});
let (status, payload) = post_capture_turn(&app, "alice", &body).await;
assert_eq!(
status,
StatusCode::CREATED,
"fresh capture must be 201, got {payload}"
);
assert_eq!(payload["dedup_hit"].as_bool(), Some(false));
assert_eq!(payload["layer"].as_str(), Some("L4"));
assert_eq!(payload["attest_level"].as_str(), Some("self_signed"));
let first_id = payload["memory_id"]
.as_str()
.expect("memory_id present")
.to_string();
assert!(!first_id.is_empty(), "memory_id must be non-empty");
let (status2, payload2) = post_capture_turn(&app, "alice", &body).await;
assert_eq!(
status2,
StatusCode::OK,
"idempotent replay must be 200, got {payload2}"
);
assert_eq!(payload2["dedup_hit"].as_bool(), Some(true));
assert_eq!(payload2["memory_id"].as_str(), Some(first_id.as_str()));
assert!(
payload2.get("attest_level").is_none(),
"replay envelope omits attest_level, got {payload2}"
);
}
#[tokio::test]
async fn http_capture_turn_rejects_missing_required_field_1416() {
let state = test_state();
let app = capture_turn_test_router(state);
let (status, _payload) = post_capture_turn(
&app,
"alice",
&json!({ "host_session_id": "x", "host_turn_index": 0, "role": "user" }),
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn http_capture_turn_rejects_agent_id_mismatch_1413() {
let state = test_state();
let app = capture_turn_test_router(state);
let (status, _payload) = post_capture_turn(
&app,
"alice",
&json!({
"host_session_id": "sess-mismatch",
"host_turn_index": 0,
"role": "user",
"content": "forged",
"metadata": { "agent_id": "mallory" }
}),
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
}
async fn seed_b4_corpus(state: &Db, n: usize) {
let lock = state.lock().await;
let now = Utc::now();
for i in 0..n {
let mem = Memory {
id: Uuid::new_v4().to_string(),
tier: Tier::Long,
namespace: "b4-test".into(),
title: format!("b4 corpus row {i}"),
content: format!(
"gzip toon corpus row {i} with enough repeated filler text to make \
compression worthwhile — filler filler filler filler filler filler"
),
tags: vec!["b4".into()],
priority: 5,
confidence: 1.0,
source: "test".into(),
access_count: 0,
created_at: now.to_rfc3339(),
updated_at: now.to_rfc3339(),
last_accessed_at: None,
expires_at: None,
metadata: serde_json::json!({"scope": "collective", "agent_id": "b4-tester"}),
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,
};
db::insert(&lock.0, &mem).unwrap();
}
}
fn full_router(state: Db) -> axum::Router {
crate::build_router(
ApiKeyState {
key: None,
mtls_enforced: false,
},
test_app_state(state),
)
}
#[tokio::test]
async fn issue_1579_b4_gzip_round_trip_honors_accept_encoding() {
let state = test_state();
seed_b4_corpus(&state, 20).await;
let app = full_router(state);
let resp = app
.clone()
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/recall?context=gzip%20toon%20corpus&namespace=b4-test&limit=20")
.method("GET")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
assert!(
resp.headers()
.get(axum::http::header::CONTENT_ENCODING)
.is_none(),
"no Accept-Encoding → identity response"
);
let identity_body = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&identity_body).unwrap();
assert!(v["count"].as_u64().unwrap() > 0, "fixture must recall rows");
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/recall?context=gzip%20toon%20corpus&namespace=b4-test&limit=20")
.method("GET")
.header(axum::http::header::ACCEPT_ENCODING, "gzip")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(
resp.headers()
.get(axum::http::header::CONTENT_ENCODING)
.and_then(|v| v.to_str().ok()),
Some("gzip"),
"Accept-Encoding: gzip must produce a gzip-coded response"
);
let gzip_body = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
.await
.unwrap();
assert!(
gzip_body.len() >= 2 && gzip_body[0] == 0x1f && gzip_body[1] == 0x8b,
"gzip body must start with the gzip magic bytes"
);
assert!(
gzip_body.len() < identity_body.len(),
"gzip body ({}) must be smaller than identity body ({})",
gzip_body.len(),
identity_body.len()
);
eprintln!(
"issue_1579_b4 measured recall response: identity={}B gzip={}B ({:.1}x)",
identity_body.len(),
gzip_body.len(),
identity_body.len() as f64 / gzip_body.len() as f64
);
}
#[tokio::test]
async fn issue_1579_b4_sse_stream_is_not_gzip_compressed() {
let state = test_state();
let app = full_router(state);
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/approvals/stream")
.method("GET")
.header(axum::http::header::ACCEPT_ENCODING, "gzip")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(
resp.headers()
.get(axum::http::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.map(|s| s.split(';').next().unwrap_or(s).trim().to_string()),
Some("text/event-stream".to_string()),
"fixture must actually hit the SSE surface"
);
assert!(
resp.headers()
.get(axum::http::header::CONTENT_ENCODING)
.is_none(),
"SSE stream must never be gzip-coded"
);
}
#[tokio::test]
async fn issue_1579_b4_recall_format_toon_compact() {
let state = test_state();
seed_b4_corpus(&state, 10).await;
let app = Router::new()
.route("/api/v1/recall", axum_get(recall_memories_get))
.with_state(test_app_state(state));
let json_resp = app
.clone()
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/recall?context=gzip%20toon%20corpus&namespace=b4-test&limit=10")
.method("GET")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(json_resp.status(), StatusCode::OK);
let json_body = axum::body::to_bytes(json_resp.into_body(), crate::TEST_BODY_READ_CAP)
.await
.unwrap();
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/recall?context=gzip%20toon%20corpus&namespace=b4-test&limit=10&format=toon_compact")
.method("GET")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
assert!(
resp.headers()
.get(axum::http::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or_default()
.starts_with("text/plain"),
"TOON responses are text/plain"
);
let toon_body = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
.await
.unwrap();
let toon = String::from_utf8(toon_body.to_vec()).unwrap();
assert!(
toon.contains("memories["),
"TOON header line expected: {toon}"
);
assert!(toon.contains("b4 corpus row"), "TOON rows expected: {toon}");
assert!(
toon_body.len() < json_body.len(),
"toon_compact ({}) must be smaller than the JSON envelope ({})",
toon_body.len(),
json_body.len()
);
eprintln!(
"issue_1579_b4 measured recall response: json={}B toon_compact={}B ({:.0}% smaller)",
json_body.len(),
toon_body.len(),
(1.0 - toon_body.len() as f64 / json_body.len() as f64) * 100.0
);
}
#[tokio::test]
async fn issue_1579_b4_recall_format_toon_and_invalid() {
let state = test_state();
seed_b4_corpus(&state, 3).await;
let app = Router::new()
.route("/api/v1/recall", axum_get(recall_memories_get))
.with_state(test_app_state(state));
let resp = app
.clone()
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/recall?context=gzip%20toon%20corpus&namespace=b4-test&format=toon")
.method("GET")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
.await
.unwrap();
let toon = String::from_utf8(body.to_vec()).unwrap();
assert!(
toon.contains("created_at"),
"non-compact TOON expected: {toon}"
);
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/recall?context=gzip%20toon%20corpus&namespace=b4-test&format=yaml")
.method("GET")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let body = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(
v["error"].as_str().unwrap(),
crate::toon::invalid_format_msg("yaml"),
"400 must carry the SSOT invalid-format message"
);
}
#[tokio::test]
async fn issue_1579_b4_search_format_negotiation() {
let state = test_state();
seed_b4_corpus(&state, 5).await;
let app = Router::new()
.route("/api/v1/search", axum_get(search_memories))
.with_state(test_app_state(state));
let resp = app
.clone()
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/search?q=gzip&namespace=b4-test")
.method("GET")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(v["count"].as_u64().unwrap() > 0, "fixture must match rows");
let resp = app
.clone()
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/search?q=gzip&namespace=b4-test&format=toon_compact")
.method("GET")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
.await
.unwrap();
let toon = String::from_utf8(body.to_vec()).unwrap();
assert!(
toon.contains("memories["),
"search TOON normalizes results: {toon}"
);
assert!(toon.contains("b4 corpus row"));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/search?q=gzip&namespace=b4-test&format=xml")
.method("GET")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let body = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(
v["error"].as_str().unwrap(),
crate::toon::invalid_format_msg("xml")
);
}