120 lines
6.8 KiB
Rust
120 lines
6.8 KiB
Rust
//! The safety flags: read-only sessions, `--write` and `--allow-delete`.
|
|
|
|
mod common;
|
|
|
|
use common::{ask, call_tool, is_error, registry, seeded_vault};
|
|
use notescraft_engine::Session;
|
|
use notescraft_mcp::{Server, ServerOptions};
|
|
use serde_json::{Value, json};
|
|
|
|
/// The code of the error of a tool call.
|
|
fn code_of(server: &mut Server, name: &str, arguments: &Value) -> Option<String> {
|
|
let reply = ask(server, &json!({ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": name, "arguments": arguments } }).to_string());
|
|
reply
|
|
.get("result")
|
|
.and_then(|result| result.get("structuredContent"))
|
|
.and_then(|value| value.get("error"))
|
|
.and_then(|error| error.get("code"))
|
|
.and_then(Value::as_str)
|
|
.map(str::to_owned)
|
|
}
|
|
|
|
/// The message of the error of a tool call.
|
|
fn message_of(server: &mut Server, name: &str, arguments: &Value) -> String {
|
|
let reply = ask(server, &json!({ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": name, "arguments": arguments } }).to_string());
|
|
reply
|
|
.get("result")
|
|
.and_then(|result| result.get("structuredContent"))
|
|
.and_then(|value| value.get("error"))
|
|
.and_then(|error| error.get("message"))
|
|
.and_then(Value::as_str)
|
|
.unwrap_or_default()
|
|
.to_owned()
|
|
}
|
|
|
|
#[test]
|
|
fn a_read_only_session_refuses_every_write_with_a_readable_message() {
|
|
let (vault, mut server) = common::read_only_server("safety-read-only");
|
|
let root = vault.path().to_path_buf();
|
|
for (name, arguments) in [
|
|
("create_note", json!({ "path": "New.md" })),
|
|
("write_note", json!({ "path": "Alpha.md", "content": "x" })),
|
|
("append_note", json!({ "path": "Alpha.md", "content": "x" })),
|
|
("patch_frontmatter", json!({ "path": "Alpha.md", "set": { "a": "b" } })),
|
|
("rename_note", json!({ "from": "Alpha.md", "to": "Beta2.md" })),
|
|
("daily_note", json!({ "date": "2026-02-03" })),
|
|
("note_write", json!({ "path": "Alpha.md", "content": "x" })),
|
|
("run_command", json!({ "id": "note.write", "params": { "path": "Alpha.md", "content": "x" } })),
|
|
] {
|
|
assert!(is_error(&mut server, name, &arguments), "{name} must be refused");
|
|
let message = message_of(&mut server, name, &arguments);
|
|
assert!(message.contains("--write"), "{name} must say how to enable writes: {message}");
|
|
}
|
|
assert!(!root.join("New.md").exists(), "nothing was written");
|
|
assert_eq!(std::fs::read_to_string(root.join("Alpha.md")).ok().as_deref(), Some("---\ntags: [work]\n---\n# Alpha\n\nlinks [[Beta]] and #work/project\n"));
|
|
let read = call_tool(&mut server, "read_note", &json!({ "path": "Alpha.md" }));
|
|
assert!(read.get("content").and_then(Value::as_str).is_some_and(|text| text.contains("Alpha")), "reads still work");
|
|
}
|
|
|
|
#[test]
|
|
fn a_writable_session_without_the_flag_refuses_the_writes() {
|
|
let vault = seeded_vault("safety-write-flag");
|
|
let session = Session::open(vault.path()).expect("open for writing");
|
|
let mut server = Server::new(session, registry(), ServerOptions { write: false, allow_delete: true, via_control: false });
|
|
assert_eq!(code_of(&mut server, "write_note", &json!({ "path": "Alpha.md", "content": "x" })).as_deref(), Some("read_only"));
|
|
assert_eq!(code_of(&mut server, "note_write", &json!({ "path": "Alpha.md", "content": "x" })).as_deref(), Some("read_only"));
|
|
assert_eq!(
|
|
std::fs::read_to_string(vault.path().join("Alpha.md")).ok().as_deref(),
|
|
Some("---\ntags: [work]\n---\n# Alpha\n\nlinks [[Beta]] and #work/project\n"),
|
|
"the session is writable, the flag is what refuses"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn deleting_needs_its_own_flag() {
|
|
let vault = seeded_vault("safety-delete");
|
|
let session = Session::open(vault.path()).expect("open for writing");
|
|
let mut server = Server::new(session, registry(), ServerOptions { write: true, allow_delete: false, via_control: false });
|
|
assert_eq!(code_of(&mut server, "delete_note", &json!({ "path": "Beta.md" })).as_deref(), Some("delete_disabled"));
|
|
assert_eq!(code_of(&mut server, "note_delete", &json!({ "path": "Beta.md" })).as_deref(), Some("delete_disabled"));
|
|
assert!(message_of(&mut server, "delete_note", &json!({ "path": "Beta.md" })).contains("--allow-delete"), "the message says which flag is missing");
|
|
assert!(vault.path().join("Beta.md").exists(), "the note is still there");
|
|
assert_eq!(code_of(&mut server, "run_command", &json!({ "id": "note.delete", "params": { "path": "Beta.md" } })).as_deref(), Some("delete_disabled"));
|
|
let read = call_tool(&mut server, "read_note", &json!({ "path": "Beta.md" }));
|
|
assert!(read.get("content").is_some(), "reads keep working while deletes are off: {read}");
|
|
}
|
|
|
|
#[test]
|
|
fn the_delete_flag_lets_the_trash_happen() {
|
|
let vault = seeded_vault("safety-delete-ok");
|
|
let session = Session::open(vault.path()).expect("open for writing");
|
|
let mut server = Server::new(session, registry(), ServerOptions { write: true, allow_delete: true, via_control: false });
|
|
let deleted = call_tool(&mut server, "delete_note", &json!({ "path": "Beta.md" }));
|
|
assert_eq!(deleted.get("deleted").and_then(Value::as_bool), Some(true), "{deleted}");
|
|
assert!(!vault.path().join("Beta.md").exists(), "the note went to the trash");
|
|
assert_eq!(code_of(&mut server, "read_note", &json!({ "path": "Beta.md" })).as_deref(), Some("not_found"), "and left the index");
|
|
}
|
|
|
|
#[test]
|
|
fn run_command_cannot_route_around_the_gate() {
|
|
let vault = seeded_vault("safety-run-command");
|
|
let session = Session::open(vault.path()).expect("open for writing");
|
|
let mut server = Server::new(session, registry(), ServerOptions { write: false, allow_delete: false, via_control: false });
|
|
for id in ["note.write", "note.create", "note.delete", "note.rename", "buffer.save", "theme.set", "ui.palette.open", "plugin.demo.whatever"] {
|
|
let arguments = json!({ "id": id });
|
|
assert!(is_error(&mut server, "run_command", &arguments), "{id} must be refused");
|
|
}
|
|
let read = call_tool(&mut server, "run_command", &json!({ "id": "note.read", "params": { "path": "Alpha.md" } }));
|
|
assert!(read.get("content").and_then(Value::as_str).is_some_and(|text| text.contains("Alpha")), "{read}");
|
|
}
|
|
|
|
#[test]
|
|
fn a_writable_session_with_the_flag_writes() {
|
|
let vault = seeded_vault("safety-write-ok");
|
|
let session = Session::open(vault.path()).expect("open for writing");
|
|
let mut server = Server::new(session, registry(), ServerOptions { write: true, allow_delete: false, via_control: false });
|
|
let written = call_tool(&mut server, "write_note", &json!({ "path": "Alpha.md", "content": "changed\n" }));
|
|
assert!(written.get("changed").and_then(Value::as_bool).is_some(), "{written}");
|
|
assert_eq!(std::fs::read_to_string(vault.path().join("Alpha.md")).ok().as_deref(), Some("changed\n"));
|
|
}
|