308 lines
10 KiB
Rust
308 lines
10 KiB
Rust
//! Tests for the registry, dispatch, the denial sentences, the result cap and the fake tools.
|
|
//! Do not edit.
|
|
|
|
mod support;
|
|
|
|
use loopd::tools::{
|
|
Dispatch, FakeTools, Pending, Registry, ToolPort, cap_result, denial_text, dispatch,
|
|
};
|
|
use proto::{CallId, DenyReason, SessionId, ToolRequest, ToolResponse};
|
|
|
|
fn req(tool: &str, arguments: &str) -> ToolRequest {
|
|
ToolRequest {
|
|
session: SessionId::new("s").unwrap(),
|
|
call: CallId(1),
|
|
tool: tool.to_string(),
|
|
arguments: arguments.to_string(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn the_core_schemas_are_the_fixed_tools_array() {
|
|
let names: Vec<String> = Registry::m2b()
|
|
.core_schemas()
|
|
.into_iter()
|
|
.map(|s| s.name)
|
|
.collect();
|
|
assert_eq!(names, ["clock", "find_tool", "call_tool"]);
|
|
let find = &Registry::m2b().core_schemas()[1];
|
|
assert_eq!(find.parameters["required"], serde_json::json!(["query"]));
|
|
let call = &Registry::m2b().core_schemas()[2];
|
|
assert_eq!(
|
|
call.parameters["required"],
|
|
serde_json::json!(["name", "arguments"])
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn find_matches_name_or_description_case_insensitively() {
|
|
let r = Registry::m2b();
|
|
let names = |q: &str| r.find(q).iter().map(|s| s.name.clone()).collect::<Vec<_>>();
|
|
assert_eq!(names("echo"), ["echo"]);
|
|
assert_eq!(names("ECHO"), ["echo"]);
|
|
assert_eq!(names("unchanged"), ["echo"], "matches the description too");
|
|
assert_eq!(names("time"), ["clock"]);
|
|
assert!(names("weather").is_empty());
|
|
assert!(
|
|
names("").is_empty(),
|
|
"an empty query matches nothing, not everything"
|
|
);
|
|
assert!(names(" ").is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn find_tool_is_answered_locally() {
|
|
let r = Registry::m2b();
|
|
match dispatch(&r, "find_tool", r#"{"query":"echo"}"#) {
|
|
Dispatch::Local(text) => {
|
|
assert!(text.starts_with("1 tool(s) match:\n"), "{text}");
|
|
assert!(text.contains("\"name\":\"echo\""), "{text}");
|
|
assert!(
|
|
text.contains("\"parameters\""),
|
|
"the schema is included: {text}"
|
|
);
|
|
assert!(text.ends_with("Call it with call_tool."), "{text}");
|
|
}
|
|
other => panic!("{other:?}"),
|
|
}
|
|
assert_eq!(
|
|
dispatch(&r, "find_tool", r#"{"query":"weather"}"#),
|
|
Dispatch::Local("No tool matches.".to_string())
|
|
);
|
|
assert!(
|
|
matches!(dispatch(&r, "find_tool", "not json"), Dispatch::Local(t) if t == "No tool matches.")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn call_tool_unwraps_a_known_non_core_tool_and_nothing_else() {
|
|
let r = Registry::m2b();
|
|
let args = r#"{"name":"echo","arguments":{"text": "box"}}"#;
|
|
match dispatch(&r, "call_tool", args) {
|
|
Dispatch::Port { tool, arguments } => {
|
|
assert_eq!(tool, "echo");
|
|
assert_eq!(
|
|
serde_json::from_str::<serde_json::Value>(&arguments).unwrap(),
|
|
serde_json::json!({"text": "box"})
|
|
);
|
|
}
|
|
other => panic!("{other:?}"),
|
|
}
|
|
let cases = [
|
|
(
|
|
r#"{"name":"weather","arguments":{}}"#,
|
|
"No tool named \"weather\"",
|
|
),
|
|
(r#"{"name":"clock","arguments":{}}"#, "core tool"),
|
|
(r#"{"arguments":{}}"#, "No tool named \"\""),
|
|
("not json", "needs a JSON object"),
|
|
];
|
|
for (args, want) in cases {
|
|
match dispatch(&r, "call_tool", args) {
|
|
Dispatch::Local(text) => assert!(text.contains(want), "{args}: {text}"),
|
|
other => panic!("{args}: {other:?}, must never reach the port"),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn any_other_tool_goes_to_the_port_as_it_is() {
|
|
let r = Registry::m2b();
|
|
// Even one the registry does not know: the port (brokerd) decides, not loopd.
|
|
let want = Dispatch::Port {
|
|
tool: "read_file".to_string(),
|
|
arguments: r#"{"path":"/x"}"#.to_string(),
|
|
};
|
|
assert_eq!(dispatch(&r, "read_file", r#"{"path":"/x"}"#), want);
|
|
let want = Dispatch::Port {
|
|
tool: "weather".to_string(),
|
|
arguments: "not json".to_string(),
|
|
};
|
|
assert_eq!(dispatch(&r, "weather", "not json"), want);
|
|
}
|
|
|
|
#[test]
|
|
fn the_clock_is_answered_locally_whatever_its_arguments() {
|
|
for registry in [Registry::m2b(), Registry::m3a(), Registry::new(vec![])] {
|
|
for arguments in ["{}", r#"{"zone":"UTC"}"#, "not json", ""] {
|
|
let before = proto::Timestamp::now();
|
|
match dispatch(®istry, "clock", arguments) {
|
|
Dispatch::Local(text) => {
|
|
let time = proto::Timestamp::parse(&text)
|
|
.unwrap_or_else(|e| panic!("an RFC 3339 time, got {text:?}: {e:?}"));
|
|
assert!(time >= before, "{text}");
|
|
assert!(text.ends_with('Z'), "UTC: {text}");
|
|
}
|
|
other => panic!("{arguments:?}: {other:?}, the clock must not reach the port"),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn the_m3a_registry_has_the_same_core_and_the_four_broker_tools() {
|
|
let r = Registry::m3a();
|
|
assert_eq!(
|
|
r.core_schemas(),
|
|
Registry::m2b().core_schemas(),
|
|
"the tools array is part of the baseline: it must not change"
|
|
);
|
|
let names = |q: &str| r.find(q).iter().map(|s| s.name.clone()).collect::<Vec<_>>();
|
|
assert_eq!(names("file"), ["read_file", "write_file"]);
|
|
assert_eq!(names("shell"), ["shell"]);
|
|
assert_eq!(names("fetch"), ["http_fetch"]);
|
|
assert_eq!(names("https"), ["http_fetch"]);
|
|
assert!(names("echo").is_empty(), "echo is a test tool only");
|
|
|
|
// The argument schemas are section 3's table: exactly these properties, all strings.
|
|
let table: [(&str, &[&str], &[&str]); 4] = [
|
|
("read_file", &["path"], &["path"]),
|
|
("write_file", &["content", "path"], &["path", "content"]),
|
|
("shell", &["command", "cwd"], &["command"]),
|
|
("http_fetch", &["url"], &["url"]),
|
|
];
|
|
for (name, properties, required) in table {
|
|
let entry = r.get(name).unwrap_or_else(|| panic!("{name} is missing"));
|
|
assert!(!entry.core, "{name} is found with find_tool, not declared");
|
|
let p = &entry.schema.parameters;
|
|
assert_eq!(p["type"], "object", "{name}");
|
|
let mut got: Vec<&str> = p["properties"]
|
|
.as_object()
|
|
.unwrap()
|
|
.keys()
|
|
.map(String::as_str)
|
|
.collect();
|
|
got.sort_unstable();
|
|
assert_eq!(got, properties, "{name}: properties");
|
|
for property in properties {
|
|
assert_eq!(
|
|
p["properties"][property]["type"], "string",
|
|
"{name}.{property}"
|
|
);
|
|
assert!(
|
|
p["properties"][property]["description"].is_string(),
|
|
"{name}.{property} needs a description"
|
|
);
|
|
}
|
|
assert_eq!(
|
|
p["required"],
|
|
serde_json::json!(required),
|
|
"{name}: required"
|
|
);
|
|
}
|
|
|
|
// call_tool lets the four through to the port and nothing else.
|
|
match dispatch(
|
|
&r,
|
|
"call_tool",
|
|
r#"{"name":"shell","arguments":{"command":"ls"}}"#,
|
|
) {
|
|
Dispatch::Port { tool, arguments } => {
|
|
assert_eq!(tool, "shell");
|
|
assert_eq!(arguments, r#"{"command":"ls"}"#);
|
|
}
|
|
other => panic!("{other:?}"),
|
|
}
|
|
assert!(matches!(
|
|
dispatch(&r, "call_tool", r#"{"name":"echo","arguments":{}}"#),
|
|
Dispatch::Local(t) if t.contains("No tool named \"echo\"")
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn every_deny_reason_has_its_sentence() {
|
|
let table = [
|
|
(DenyReason::NoGrant, "Denied: no grant allows this call."),
|
|
(
|
|
DenyReason::GrantExpired,
|
|
"Denied: the grant for this call has expired.",
|
|
),
|
|
(
|
|
DenyReason::TaintTooHigh,
|
|
"Denied: this session has seen data too sensitive for this call.",
|
|
),
|
|
(
|
|
DenyReason::DeniedByGrant,
|
|
"Denied: a grant forbids this call.",
|
|
),
|
|
(
|
|
DenyReason::ApprovalRefused,
|
|
"Denied: the owner refused this call.",
|
|
),
|
|
(
|
|
DenyReason::ApprovalExpired,
|
|
"Denied: the approval request expired without an answer.",
|
|
),
|
|
(
|
|
DenyReason::InvalidArguments,
|
|
"Denied: the arguments are not valid for this tool.",
|
|
),
|
|
(
|
|
DenyReason::GrantsInvalid,
|
|
"Denied: the grant files have an error; the owner has been told.",
|
|
),
|
|
(
|
|
DenyReason::AuditUnavailable,
|
|
"Denied: the audit log cannot be written; the owner has been told.",
|
|
),
|
|
(
|
|
DenyReason::StateUnreadable,
|
|
"Denied: this session's broker state is damaged; the owner has been told.",
|
|
),
|
|
];
|
|
for (reason, want) in table {
|
|
assert_eq!(denial_text(reason), want, "{reason:?}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn results_are_cut_on_a_character_boundary_and_marked() {
|
|
assert_eq!(cap_result("short", 100), ("short".to_string(), false));
|
|
assert_eq!(cap_result("exactly", 7), ("exactly".to_string(), false));
|
|
let (text, truncated) = cap_result("abcdefghij", 4);
|
|
assert_eq!(text, "abcd\n[truncated]");
|
|
assert!(truncated);
|
|
// 3 ASCII bytes, then 2-byte characters: byte 4 is inside a character.
|
|
let (text, truncated) = cap_result("abc\u{e9}\u{e9}\u{e9}", 4);
|
|
assert_eq!(text, "abc\n[truncated]");
|
|
assert!(truncated);
|
|
assert_eq!(cap_result("", 0), (String::new(), false));
|
|
assert_eq!(cap_result("x", 0), ("\n[truncated]".to_string(), true));
|
|
}
|
|
|
|
#[test]
|
|
fn fake_tools_answer_echo_deny_the_rest_and_record_calls() {
|
|
let fake = FakeTools::new();
|
|
let mut seen = 0;
|
|
let mut on_pending = |_: &Pending| seen += 1;
|
|
match fake.call(&req("echo", r#"{"text":"box"}"#), &mut on_pending) {
|
|
ToolResponse::Result {
|
|
content,
|
|
class,
|
|
untrusted,
|
|
truncated,
|
|
} => {
|
|
assert_eq!(content, "box");
|
|
assert_eq!(class, proto::DataClass::Public);
|
|
assert!(!untrusted && !truncated);
|
|
}
|
|
other => panic!("{other:?}"),
|
|
}
|
|
assert!(matches!(
|
|
fake.call(&req("echo", r#"{"tex":"box"}"#), &mut on_pending),
|
|
ToolResponse::Failed { .. }
|
|
));
|
|
for tool in ["weather", "read_file", "clock"] {
|
|
assert_eq!(
|
|
fake.call(&req(tool, "{}"), &mut on_pending),
|
|
ToolResponse::Denied {
|
|
reason: DenyReason::NoGrant
|
|
},
|
|
"{tool}: the clock is loopd's own now, not the port's"
|
|
);
|
|
}
|
|
assert_eq!(seen, 0, "the fake never asks for approval");
|
|
assert_eq!(fake.calls().len(), 5);
|
|
assert_eq!(fake.calls()[0].tool, "echo");
|
|
}
|