mod common;
use common::{expect_tool_error, Server, DEFAULT_TIMEOUT};
#[test]
fn read_spans_project_scope_and_shared() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("multi.redb");
let project = topodb::ScopeId::new().to_string();
let read_list = format!("{project},shared");
let mut server = Server::spawn(
&db_path,
&[
"--scope",
project.as_str(),
"--read-scopes",
read_list.as_str(),
],
);
server.initialize(DEFAULT_TIMEOUT);
server.call_tool_ok(
"create_memory",
serde_json::json!({ "content": "zzqqx project fact" }),
DEFAULT_TIMEOUT,
);
server.call_tool_ok(
"create_memory",
serde_json::json!({ "content": "zzqqx shared lesson", "scope": "shared" }),
DEFAULT_TIMEOUT,
);
let res = server.call_tool_ok(
"search_memories",
serde_json::json!({ "query": "zzqqx", "k": 10 }),
DEFAULT_TIMEOUT,
);
let hits = res["hits"].as_array().expect("hits should be an array");
assert_eq!(
hits.len(),
2,
"a {{project, shared}} read set must see BOTH memories, got: {hits:?}"
);
}
#[test]
fn unscoped_write_lands_in_the_write_scope_not_merely_a_read_scope() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("write_scope_leak.redb");
let project = topodb::ScopeId::new().to_string();
let read_list = format!("{project},shared");
let mut server = Server::spawn(
&db_path,
&[
"--scope",
project.as_str(),
"--read-scopes",
read_list.as_str(),
],
);
server.initialize(DEFAULT_TIMEOUT);
server.call_tool_ok(
"create_memory",
serde_json::json!({ "content": "leakcheck9000 unscoped write" }),
DEFAULT_TIMEOUT,
);
let in_project = server.call_tool_ok(
"search_memories",
serde_json::json!({ "query": "leakcheck9000", "k": 10, "scopes": [project.as_str()] }),
DEFAULT_TIMEOUT,
);
assert_eq!(
in_project["hits"].as_array().unwrap().len(),
1,
"an unscoped write must land in the configured WRITE scope \
(--scope): {in_project:#?}"
);
let in_shared = server.call_tool_ok(
"search_memories",
serde_json::json!({ "query": "leakcheck9000", "k": 10, "scopes": ["shared"] }),
DEFAULT_TIMEOUT,
);
assert_eq!(
in_shared["hits"].as_array().unwrap().len(),
0,
"an unscoped write must NOT drift into `shared` just because \
`shared` is part of --read-scopes (this is the cross-project leak \
direction that matters): {in_shared:#?}"
);
}
#[test]
fn per_call_scopes_param_overrides_the_default_and_beats_scope() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("percall.redb");
let project = topodb::ScopeId::new().to_string();
let mut server = Server::spawn(&db_path, &["--scope", project.as_str()]);
server.initialize(DEFAULT_TIMEOUT);
server.call_tool_ok(
"create_memory",
serde_json::json!({ "content": "wwvvu project fact" }),
DEFAULT_TIMEOUT,
);
server.call_tool_ok(
"create_memory",
serde_json::json!({ "content": "wwvvu shared lesson", "scope": "shared" }),
DEFAULT_TIMEOUT,
);
let res = server.call_tool_ok(
"search_memories",
serde_json::json!({ "query": "wwvvu", "k": 10 }),
DEFAULT_TIMEOUT,
);
assert_eq!(res["hits"].as_array().unwrap().len(), 1);
let res = server.call_tool_ok(
"search_memories",
serde_json::json!({
"query": "wwvvu",
"k": 10,
"scope": "shared",
"scopes": [project.as_str(), "shared"]
}),
DEFAULT_TIMEOUT,
);
assert_eq!(
res["hits"].as_array().unwrap().len(),
2,
"`scopes` must take precedence over `scope`"
);
}
#[test]
fn a_shared_edge_is_traversable_from_a_multi_scope_reader() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("edge.redb");
let project = topodb::ScopeId::new().to_string();
let read_list = format!("{project},shared");
let mut server = Server::spawn(
&db_path,
&[
"--scope",
project.as_str(),
"--read-scopes",
read_list.as_str(),
],
);
server.initialize(DEFAULT_TIMEOUT);
let a = server.call_tool_ok(
"create_entity",
serde_json::json!({ "name": "shared-a", "scope": "shared" }),
DEFAULT_TIMEOUT,
);
let b = server.call_tool_ok(
"create_entity",
serde_json::json!({ "name": "shared-b", "scope": "shared" }),
DEFAULT_TIMEOUT,
);
let a_id = a["id"].as_str().unwrap().to_string();
let b_id = b["id"].as_str().unwrap().to_string();
server.call_tool_ok(
"link",
serde_json::json!({
"from_id": a_id, "to_id": b_id, "edge_type": "about", "scope": "shared"
}),
DEFAULT_TIMEOUT,
);
let res = server.call_tool_ok(
"traverse",
serde_json::json!({ "seed_id": a_id, "max_hops": 1, "scopes": ["shared"] }),
DEFAULT_TIMEOUT,
);
let body = res["subgraph"].to_string();
assert!(
body.contains(&b_id),
"a `shared`-scoped edge must be traversable by a reader of `shared`; \
got subgraph: {body}"
);
}
#[test]
fn create_memory_rejects_unknown_scopes_field_instead_of_silently_ignoring_it() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("deny_unknown.redb");
let mut server = Server::spawn(&db_path, &[]);
server.initialize(DEFAULT_TIMEOUT);
let resp = server.call_tool(
"create_memory",
serde_json::json!({ "content": "probeqqq fact", "scopes": ["shared"] }),
DEFAULT_TIMEOUT,
);
expect_tool_error(&resp);
let message = tool_error_message(&resp);
assert!(
message.contains("scopes"),
"the rejection should name the unknown `scopes` field so a caller can \
tell `scopes` isn't a write-tool param (write tools take one `scope`, \
not a `scopes` set), got: {resp:#?}"
);
let found = server.call_tool_ok(
"search_memories",
serde_json::json!({ "query": "probeqqq" }),
DEFAULT_TIMEOUT,
);
assert_eq!(
found["hits"].as_array().unwrap().len(),
0,
"create_memory with an unknown `scopes` field must not silently create \
a project-scoped memory: {found:#?}"
);
}
fn tool_error_message(resp: &serde_json::Value) -> String {
if let Some(msg) = resp
.get("error")
.and_then(|e| e.get("message"))
.and_then(|m| m.as_str())
{
return msg.to_string();
}
resp.get("result")
.and_then(|r| r.get("content"))
.and_then(|c| c.as_array())
.and_then(|arr| arr.first())
.and_then(|first| first.get("text"))
.and_then(|t| t.as_str())
.unwrap_or_default()
.to_string()
}
#[test]
fn empty_scopes_is_rejected_by_every_read_tool() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("empty_scopes.redb");
let project = topodb::ScopeId::new().to_string();
let mut server = Server::spawn(&db_path, &["--scope", project.as_str()]);
server.initialize(DEFAULT_TIMEOUT);
let node_id = topodb::NodeId::new().to_string();
let cases: [(&str, serde_json::Value); 6] = [
(
"get_node",
serde_json::json!({ "id": node_id, "scopes": [] }),
),
(
"find_by_prop",
serde_json::json!({
"label": "Entity", "prop": "name", "value": "ada", "scopes": []
}),
),
(
"search_memories",
serde_json::json!({ "query": "ada", "scopes": [] }),
),
(
"traverse",
serde_json::json!({ "seed_id": node_id, "scopes": [] }),
),
(
"access_stats",
serde_json::json!({ "id": node_id, "scopes": [] }),
),
(
"search_vectors",
serde_json::json!({ "model": "test", "vector": [1.0], "scopes": [] }),
),
];
for (tool, args) in cases {
let resp = server.call_tool(tool, args, DEFAULT_TIMEOUT);
expect_tool_error(&resp);
let message = tool_error_message(&resp);
assert!(
message.contains("scopes") && message.contains("empty"),
"{tool}'s `scopes: []` error should mention `scopes` being empty \
(so a future error-message change can't silently turn rejection \
into acceptance unnoticed), got: {resp:#?}"
);
}
}
#[test]
fn all_six_read_tools_honour_scopes_default_vs_override() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("all_six.redb");
let project = topodb::ScopeId::new().to_string();
let mut server = Server::spawn(&db_path, &["--scope", project.as_str()]);
server.initialize(DEFAULT_TIMEOUT);
let entity = server.call_tool_ok(
"create_entity",
serde_json::json!({ "name": "zzzscope-entity", "scope": "shared" }),
DEFAULT_TIMEOUT,
);
let entity_id = entity["id"].as_str().unwrap().to_string();
let other = server.call_tool_ok(
"create_entity",
serde_json::json!({ "name": "zzzscope-entity-2", "scope": "shared" }),
DEFAULT_TIMEOUT,
);
let other_id = other["id"].as_str().unwrap().to_string();
server.call_tool_ok(
"link",
serde_json::json!({
"from_id": entity_id, "to_id": other_id, "edge_type": "about", "scope": "shared"
}),
DEFAULT_TIMEOUT,
);
let memory = server.call_tool_ok(
"create_memory",
serde_json::json!({ "content": "zzzscope memory content", "scope": "shared" }),
DEFAULT_TIMEOUT,
);
let memory_id = memory["id"].as_str().unwrap().to_string();
server.call_tool_ok(
"set_embedding",
serde_json::json!({ "id": memory_id, "model": "zzzscope-model", "vector": [1.0, 0.0] }),
DEFAULT_TIMEOUT,
);
let default = server.call_tool_ok(
"get_node",
serde_json::json!({ "id": entity_id }),
DEFAULT_TIMEOUT,
);
assert_eq!(
default["found"], false,
"get_node's default (project-only) read set must not see a `shared` node: {default:#?}"
);
let overridden = server.call_tool_ok(
"get_node",
serde_json::json!({ "id": entity_id, "scopes": ["shared"] }),
DEFAULT_TIMEOUT,
);
assert_eq!(
overridden["found"], true,
"get_node with scopes: [\"shared\"] must see the node: {overridden:#?}"
);
let default = server.call_tool_ok(
"access_stats",
serde_json::json!({ "id": entity_id }),
DEFAULT_TIMEOUT,
);
assert_eq!(
default["found"], false,
"access_stats's default (project-only) read set must not see a `shared` node: {default:#?}"
);
let overridden = server.call_tool_ok(
"access_stats",
serde_json::json!({ "id": entity_id, "scopes": ["shared"] }),
DEFAULT_TIMEOUT,
);
assert_eq!(
overridden["found"], true,
"access_stats with scopes: [\"shared\"] must see the node: {overridden:#?}"
);
let default = server.call_tool_ok(
"find_by_prop",
serde_json::json!({ "label": "Entity", "prop": "name", "value": "zzzscope-entity" }),
DEFAULT_TIMEOUT,
);
assert_eq!(
default["nodes"].as_array().unwrap().len(),
0,
"find_by_prop's default (project-only) read set must find 0 nodes in \
`shared`: {default:#?}"
);
let overridden = server.call_tool_ok(
"find_by_prop",
serde_json::json!({
"label": "Entity", "prop": "name", "value": "zzzscope-entity", "scopes": ["shared"]
}),
DEFAULT_TIMEOUT,
);
assert_eq!(
overridden["nodes"].as_array().unwrap().len(),
1,
"find_by_prop with scopes: [\"shared\"] must find the node: {overridden:#?}"
);
let default = server.call_tool_ok(
"search_memories",
serde_json::json!({ "query": "zzzscope", "k": 10 }),
DEFAULT_TIMEOUT,
);
assert_eq!(
default["hits"].as_array().unwrap().len(),
0,
"search_memories's default (project-only) read set must find 0 hits \
in `shared`: {default:#?}"
);
let overridden = server.call_tool_ok(
"search_memories",
serde_json::json!({ "query": "zzzscope", "k": 10, "scopes": ["shared"] }),
DEFAULT_TIMEOUT,
);
assert_eq!(
overridden["hits"].as_array().unwrap().len(),
1,
"search_memories with scopes: [\"shared\"] must find the memory: {overridden:#?}"
);
let default = server.call_tool_ok(
"traverse",
serde_json::json!({ "seed_id": entity_id, "max_hops": 1 }),
DEFAULT_TIMEOUT,
);
let default_body = default["subgraph"].to_string();
assert!(
!default_body.contains(&other_id),
"traverse's default (project-only) read set must not reach the \
`shared` edge: {default_body}"
);
let overridden = server.call_tool_ok(
"traverse",
serde_json::json!({ "seed_id": entity_id, "max_hops": 1, "scopes": ["shared"] }),
DEFAULT_TIMEOUT,
);
let overridden_body = overridden["subgraph"].to_string();
assert!(
overridden_body.contains(&other_id),
"traverse with scopes: [\"shared\"] must reach the `shared` edge: {overridden_body}"
);
let default = server.call_tool_ok(
"search_vectors",
serde_json::json!({ "model": "zzzscope-model", "vector": [1.0, 0.0], "k": 5 }),
DEFAULT_TIMEOUT,
);
assert_eq!(
default["hits"].as_array().unwrap().len(),
0,
"search_vectors's default (project-only) read set must find 0 hits \
in `shared`: {default:#?}"
);
let overridden = server.call_tool_ok(
"search_vectors",
serde_json::json!({
"model": "zzzscope-model", "vector": [1.0, 0.0], "k": 5, "scopes": ["shared"]
}),
DEFAULT_TIMEOUT,
);
assert_eq!(
overridden["hits"].as_array().unwrap().len(),
1,
"search_vectors with scopes: [\"shared\"] must find the embedding: {overridden:#?}"
);
}
#[test]
fn get_changes_is_gated_unless_explicitly_allowed() {
use common::expect_tool_error;
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("gate.redb");
let mut server = Server::spawn(&db_path, &["--scope", "shared"]);
server.initialize(DEFAULT_TIMEOUT);
let resp = server.call_tool(
"get_changes",
serde_json::json!({ "since_seq": 0 }),
DEFAULT_TIMEOUT,
);
expect_tool_error(&resp);
let body = resp.to_string();
assert!(
body.contains("--allow-unscoped-changes"),
"the error must name the flag that enables it; got: {body}"
);
drop(server);
let mut server = Server::spawn(&db_path, &["--scope", "shared", "--allow-unscoped-changes"]);
server.initialize(DEFAULT_TIMEOUT);
let res = server.call_tool_ok(
"get_changes",
serde_json::json!({ "since_seq": 0 }),
DEFAULT_TIMEOUT,
);
assert!(res.get("ops").is_some());
}
#[test]
fn db_info_reports_both_the_default_write_scope_and_the_default_read_scope_set() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("dbinfo_scopes.redb");
let project = topodb::ScopeId::new().to_string();
let read_list = format!("{project},shared");
let mut server = Server::spawn(
&db_path,
&[
"--scope",
project.as_str(),
"--read-scopes",
read_list.as_str(),
],
);
server.initialize(DEFAULT_TIMEOUT);
let info = server.call_tool_ok("db_info", serde_json::json!({}), DEFAULT_TIMEOUT);
assert_eq!(
info["default_scope"], project,
"db_info's default_scope should be the configured --scope: {info:#?}"
);
let read_scopes = info["default_read_scopes"]
.as_array()
.expect("db_info should report default_read_scopes as an array");
let read_scopes: Vec<&str> = read_scopes.iter().map(|v| v.as_str().unwrap()).collect();
assert_eq!(
read_scopes,
vec![project.as_str(), "shared"],
"db_info's default_read_scopes should list BOTH --read-scopes entries, \
not just the default write scope: {info:#?}"
);
}