P2: tests mutation found, three bugs fixed; plan P12 and P13

Mutation testing pauses until P12 (D-33, owner 2026-10-20), which reruns
the whole max-testing pass over everything since P1. Before the pause,
pass 1 on slices 3-6 and its triage gave tests in ledger-store,
ledger-package and ledger-mcp, and three real bugs, fixed:
- the command line took an admin answer longer than four MiB, or cut off
  before its newline, whenever the bytes that came still parsed;
- The CPA package put a twin's number on the last dot of the whole path,
  so in a folder with a dot the second file landed in another folder;
- a document or category named of dots alone ("..") could climb out of
  its folder when the CPA package is unpacked.
The plan gains P13, bookspec (D-34): a formal, sourced accounting
specification first (rules with symbolic forms and worked examples), then
its own formal validation and a conformance harness any engine can be
tested against, seeded by P3's regression tests. docs/testing.md records
slices 3-8 and what P12 must cover; plan/STATUS.md says where P2 stands.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-20 11:14:22 -06:00
1 parent 3cf359a0cd
commit d5c9522ad1
24 files changed
+2937 -20

No files matched your search

+105
View File
@@ -462,6 +462,13 @@ pub fn call_within(
if answer.is_empty() {
return Err(unknown("the books server stopped before answering".into()));
}
// INVARIANT 1: an answer is one line of at most MAX_LINE bytes; a
// longer one, or one cut off, is refused, even if what came parses.
if !answer.ends_with('\n') {
return Err(unknown(
"the books server's answer is too long, or cut off".into(),
));
}
serde_json::from_str(answer.trim_end())
.map_err(|e| unknown(format!("the books server's answer isn't understood ({e})")))
}
@@ -619,6 +626,8 @@ mod tests {
}
);
assert!(!shown.contains("wb_secret"), "{shown}");
// The text is there, and hidden: never shown as nothing.
assert!(shown.contains("text: TokenText(…)"), "{shown}");
let r: Envelope = serde_json::from_value(json!({"proto": 1, "op": "token_list"})).unwrap();
assert_eq!(r.request, Request::TokenList);
assert!(
@@ -648,4 +657,100 @@ mod tests {
assert!(!text.contains(&t.sha256));
assert!(text.contains("books:read"));
}
/// Why an admin call has no answer is what the command line shows.
#[test]
fn a_call_error_says_why() {
assert_eq!(
CallError::NotSent("the books server isn't running".into()).to_string(),
"the books server isn't running"
);
assert_eq!(
CallError::Unknown("no answer came".into()).to_string(),
"no answer came"
);
}
/// A fake books server on the socket `name` in `dir`: it takes one
/// request and answers `answer`, byte for byte.
#[cfg(unix)]
fn answering(
dir: &std::path::Path,
name: &str,
answer: Vec<u8>,
) -> (std::path::PathBuf, std::thread::JoinHandle<()>) {
let socket = dir.join(name);
let listener = std::os::unix::net::UnixListener::bind(&socket).unwrap();
let server = std::thread::spawn(move || {
use std::io::{BufRead, Write};
let (stream, _) = listener.accept().unwrap();
let mut request = String::new();
std::io::BufReader::new(&stream)
.read_line(&mut request)
.unwrap();
// A client that stops reading part way breaks the pipe: fine.
let _ = (&stream).write_all(&answer);
});
(socket, server)
}
/// A refusal padded with spaces to `len` bytes, then its newline.
fn padded(len: usize) -> Vec<u8> {
let mut answer = br#"{"refused":"no"}"#.to_vec();
answer.resize(len, b' ');
answer.push(b'\n');
answer
}
/// INVARIANT 1, the command line's side: an answer of 4 MiB, its
/// newline not counted, is taken whole (settings texts ride in them);
/// a byte more is refused, as is an answer cut off before its
/// newline, even when what came parses.
#[cfg(unix)]
#[test]
fn an_answer_is_one_line_of_at_most_four_mib() {
let four_mib = 4 * 1024 * 1024;
let dir = tempfile::tempdir().unwrap();
let (socket, server) = answering(dir.path(), "whole.sock", padded(four_mib));
assert_eq!(
call(&socket, Request::List),
Ok(Response::Refused("no".into()))
);
server.join().unwrap();
let too_long_or_cut = Err(CallError::Unknown(
"the books server's answer is too long, or cut off".into(),
));
let (socket, server) = answering(dir.path(), "long.sock", padded(four_mib + 1));
assert_eq!(call(&socket, Request::List), too_long_or_cut);
server.join().unwrap();
let (socket, server) = answering(dir.path(), "cut.sock", br#"{"refused":"no"}"#.into());
assert_eq!(call(&socket, Request::List), too_long_or_cut);
server.join().unwrap();
}
/// No server on the socket: the request never left, and the command
/// line says the server isn't running, whether there is no socket or
/// one a stopped server left behind.
#[cfg(unix)]
#[test]
fn no_server_means_nothing_was_sent() {
let dir = tempfile::tempdir().unwrap();
let not_running = Err(CallError::NotSent("the books server isn't running".into()));
assert_eq!(
call(&dir.path().join("admin.sock"), Request::List),
not_running
);
let stale = dir.path().join("stale.sock");
drop(std::os::unix::net::UnixListener::bind(&stale).unwrap());
assert_eq!(
call(
&stale,
Request::Create {
id: "acme".into(),
currency: "USD".into(),
}
),
not_running
);
}
}
+524
View File
@@ -2452,4 +2452,528 @@ mod tests {
assert!(peer_uid(&file).is_err());
assert!(!peer_is_us(&file));
}
/// A socket with no other end: the system answers for it (Linux: no
/// process, no user), and that answer names neither this process nor
/// this account; the client's check refuses it.
#[test]
fn a_socket_with_no_other_end_names_no_process_and_isnt_us() {
use std::os::fd::FromRawFd;
// SAFETY: socket(2) has no preconditions; the descriptor is checked
// and then owned by the stream alone.
let fd = unsafe { libc::socket(libc::AF_UNIX, libc::SOCK_STREAM | libc::SOCK_CLOEXEC, 0) };
assert!(fd >= 0);
// SAFETY: a fresh descriptor of a Unix stream socket, owned by none.
let lone = unsafe { std::os::unix::net::UnixStream::from_raw_fd(fd) };
assert_eq!(peer_pid(&lone), None);
assert!(!peer_is_us(&lone));
assert_eq!(
check_peer_std(&lone).unwrap_err(),
"the books server's socket belongs to another account: not used"
);
}
/// A session ends only after a day without a message (SESSION_IDLE),
/// never rmcp's few minutes: a call longer than that keeps its answer.
#[test]
fn a_session_ends_only_after_a_day_without_a_message() {
assert_eq!(
sessions().session_config.keep_alive,
Some(Duration::from_secs(86_400))
);
}
/// Settings moved in at adoption are dated in the time zone the file
/// sets (company.timezone), not by any other entry's text. The two
/// zones are 26 hours apart, so their dates always differ.
#[test]
fn settings_moved_in_are_dated_in_the_files_own_time_zone() {
let change = |key: &str, value: &str| ledger_core::settings::Change {
key: key.into(),
value: Some(serde_json::json!(value)),
};
let changes = [
change("company.name", "Etc/GMT+12"),
change("company.timezone", "Pacific/Kiritimati"),
];
let source = moved_in_at_adoption(&changes);
assert_eq!(
source.decided_on,
ledger_core::settings::today_in(Some("Pacific/Kiritimati"))
);
assert_ne!(
source.decided_on,
ledger_core::settings::today_in(Some("Etc/GMT+12"))
);
assert_eq!(source.decided_by, "the settings file");
}
/// A server's books in a fresh root under `home`, as `run` holds them,
/// without its sockets: each test drives the handlers itself.
fn books_in(home: &Path) -> Arc<Books> {
let (root, _) = Root::setup(home, Some(&home.join("root")), None).unwrap();
let books = Arc::new(Books {
root,
held: RwLock::new(BTreeMap::new()),
sessions: CancellationToken::new(),
closing: CancellationToken::new(),
in_flight: AtomicUsize::new(0),
finished: tokio::sync::Notify::new(),
last_arrival: std::sync::atomic::AtomicU64::new(0),
started: Instant::now(),
principals: RwLock::new(Err(String::new())),
session_tokens: Mutex::new(BTreeMap::new()),
backup_quiet: BACKUP_QUIET,
drill_first: DRILL_FIRST,
restoring: Mutex::new(()),
});
let loaded = books.load_principals();
*books.principals.write().unwrap() = loaded;
books
}
/// Make the books `id` through the admin request.
fn create(books: &Books, id: &str) {
match books.admin(Request::Create {
id: id.into(),
currency: "USD".into(),
}) {
Response::Created { problem: None, .. } => {}
other => panic!("{other:?}"),
}
}
/// A token for `book` named `name` with every scope (made, or made
/// again): its id and its text.
fn token(books: &Books, book: &str, name: &str) -> (String, String) {
match books.admin(Request::TokenCreate {
book: book.into(),
name: name.into(),
scopes: ["read", "write", "post", "admin"]
.map(String::from)
.to_vec(),
replace: true,
}) {
Response::TokenCreated { token, text } => (token.id, text.0),
other => panic!("{other:?}"),
}
}
/// The sessions bound, by id.
fn bound(books: &Books) -> Vec<String> {
books
.session_tokens
.lock()
.unwrap()
.keys()
.cloned()
.collect()
}
/// The status, the refusal code and the text of an answer.
async fn answer(r: axum::response::Response) -> (u16, Option<String>, String) {
let code = r
.headers()
.get(REFUSED_HEADER)
.map(|v| v.to_str().unwrap().to_string());
let status = r.status().as_u16();
let body = axum::body::to_bytes(r.into_body(), usize::MAX)
.await
.unwrap();
(status, code, String::from_utf8_lossy(&body).into_owned())
}
/// A PUT of `body` for upload `id` of the books `acme`.
fn put(id: &str, bearer: Option<&str>, body: axum::body::Body) -> axum::extract::Request {
let mut r = axum::http::Request::builder()
.method("PUT")
.uri(format!("http://localhost/b/acme/u/{id}"));
if let Some(b) = bearer {
r = r.header("authorization", format!("Bearer {b}"));
}
r.body(body).unwrap()
}
/// Send a PUT to the upload route as the router would.
async fn send_upload(
books: &Arc<Books>,
id: &str,
bearer: Option<&str>,
body: axum::body::Body,
) -> axum::response::Response {
upload(
axum::extract::State(books.clone()),
axum::extract::Path(("acme".to_string(), id.to_string())),
put(id, bearer, body),
)
.await
}
/// A request body that arrives as the test sends it, one chunk at a
/// time (the sender's `reserve` returns once the last chunk was taken).
fn fed() -> (tokio::sync::mpsc::Sender<Vec<u8>>, axum::body::Body) {
let (tx, rx) = tokio::sync::mpsc::channel::<Vec<u8>>(1);
let stream = futures_util::stream::unfold(rx, |mut rx| async move {
rx.recv()
.await
.map(|b| (Ok::<_, std::io::Error>(axum::body::Bytes::from(b)), rx))
});
(tx, axum::body::Body::from_stream(stream))
}
/// The upload's grant, for `token_id`, of `bytes`.
fn grant(books: &Books, token_id: &str, bytes: &[u8]) -> ledger_store::uploads::Grant {
let held = books.held();
let Some(Book::Open { uploads, .. }) = held.get("acme") else {
panic!("acme isn't open");
};
uploads
.grant(
token_id,
&ledger_store::journal::sha256_hex(bytes),
bytes.len() as u64,
"application/pdf",
"upload.pdf",
"document",
chrono::Utc::now().timestamp(),
)
.unwrap()
}
/// The last access record of `acme`: its action and its outcome.
fn last_access(books: &Books) -> (String, Outcome) {
let dir = books.root.book_dir(&BookId::parse("acme").unwrap());
let (records, _) = ledger_store::access::read(&dir).unwrap();
let last = records.last().unwrap();
(last.access.action.clone(), last.access.result.clone())
}
#[test]
fn a_book_that_didnt_open_says_why_and_one_open_says_nothing() {
let why = "its access log can't be used".to_string();
assert_eq!(Book::Failed(why.clone()).problem(), Some(why));
let home = tempfile::tempdir().unwrap();
let books = books_in(home.path());
create(&books, "acme");
assert_eq!(books.held()["acme"].problem(), None);
}
/// INVARIANT 6: once closing, every request is refused as closing,
/// even one for books that didn't open or don't exist (until then
/// told so), so a client knows the server is going, not that its
/// books are gone.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn once_closing_every_request_is_refused_as_closing() {
let home = tempfile::tempdir().unwrap();
let books = books_in(home.path());
create(&books, "acme");
books.hold(
&BookId::parse("broken").unwrap(),
Book::Failed("its access log can't be used".into()),
);
let asked = Asked::connect(&axum::http::Method::POST);
let headers = axum::http::HeaderMap::new();
let refused = |books: Arc<Books>, book: &'static str| {
let (asked, headers) = (&asked, &headers);
async move {
match door(&books, book, asked, headers).await {
Ok(_) => panic!("{book}: let through"),
Err(r) => answer(*r).await,
}
}
};
let (status, code, text) = refused(books.clone(), "broken").await;
assert_eq!((status, code.as_deref()), (503, Some("not-open")), "{text}");
let (status, code, text) = refused(books.clone(), "nobody").await;
assert_eq!(
(status, code.as_deref()),
(404, Some("no-such-books")),
"{text}"
);
books.closing.cancel();
for book in ["broken", "nobody"] {
let (status, code, text) = refused(books.clone(), book).await;
assert_eq!((status, code), (503, None), "{book}: {text}");
assert_eq!(text, "the books are closing: this request wasn't run");
}
let (status, code, text) = refused(books.clone(), "acme").await;
assert_eq!(
(status, code.as_deref()),
(503, Some(crate::access::CLOSING))
);
assert_eq!(text, "the books are closing: this request wasn't run");
}
/// INVARIANT 9: nothing comes in once the books stopped (SharedStore
/// INVARIANT 2): an upload is refused as stopped, and recorded so.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn an_upload_to_books_that_stopped_is_refused_as_stopped() {
let home = tempfile::tempdir().unwrap();
let books = books_in(home.path());
create(&books, "acme");
let (id, text) = token(&books, "acme", "agent");
let bytes = b"%PDF-1.4 stopped".to_vec();
let g = grant(&books, &id, &bytes);
match books.held().get("acme") {
Some(Book::Open { store, .. }) => store.stop(),
_ => panic!("acme isn't open"),
}
let r = send_upload(&books, &g.id, Some(&text), bytes.into()).await;
let (status, code, said) = answer(r).await;
assert_eq!((status, code.as_deref()), (503, Some("stopped")), "{said}");
assert!(said.contains("stopped taking requests"), "{said}");
assert_eq!(
last_access(&books),
("upload".to_string(), Outcome::Refused("stopped".into()))
);
}
/// INVARIANT 9: an upload is received from one request at a time;
/// a second sent while the first is still arriving is refused as in
/// use, and the first is kept.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn an_upload_still_arriving_is_refused_to_a_second_sender() {
let home = tempfile::tempdir().unwrap();
let books = books_in(home.path());
create(&books, "acme");
let (id, text) = token(&books, "acme", "agent");
let bytes: Vec<u8> = b"%PDF-1.4 "
.iter()
.copied()
.chain((0..2_000_000u32).map(|i| (i % 251) as u8))
.collect();
let g = grant(&books, &id, &bytes);
let (tx, body) = fed();
let first = tokio::spawn({
let (books, id, text) = (books.clone(), g.id.clone(), text.clone());
async move { send_upload(&books, &id, Some(&text), body).await }
});
tx.send(bytes[..1_000_000].to_vec()).await.unwrap();
// Arriving: its part is on the disk.
let part = books
.root
.book_dir(&BookId::parse("acme").unwrap())
.join(ledger_store::uploads::UPLOADS_DIR)
.join(format!("{}.part", g.id));
let deadline = Instant::now() + Duration::from_secs(30);
while !part.exists() {
assert!(Instant::now() < deadline, "the first upload never began");
tokio::time::sleep(Duration::from_millis(10)).await;
}
let second = send_upload(&books, &g.id, Some(&text), bytes.clone().into()).await;
let (status, code, said) = answer(second).await;
assert_eq!((status, code.as_deref()), (409, Some("in_use")), "{said}");
assert_eq!(
last_access(&books),
("upload".to_string(), Outcome::Refused("in_use".into()))
);
tx.send(bytes[1_000_000..].to_vec()).await.unwrap();
drop(tx);
let (status, _, said) = answer(first.await.unwrap()).await;
assert_eq!(status, 200, "{said}");
}
/// INVARIANT 9: a refused upload's bytes are read and dropped up to
/// the most an upload can be, so a client sending no more than that
/// is never cut off mid-send, and no further, so one that never stops
/// sending is still answered: refused at the door (no token) and past
/// it (no such upload) alike.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_refused_upload_is_read_up_to_the_most_an_upload_can_be() {
let home = tempfile::tempdir().unwrap();
let books = books_in(home.path());
create(&books, "acme");
let (_, text) = token(&books, "acme", "agent");
let max = ledger_store::uploads::MAX_UPLOAD as usize;
let mib = 1024 * 1024;
assert_eq!(max % mib, 0);
for (bearer, status, code) in [
(None, 401, crate::access::UNAUTHORIZED),
(Some(text.clone()), 404, "no_such_upload"),
] {
let (tx, body) = fed();
let answered = tokio::spawn({
let books = books.clone();
async move {
let id = "up_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
send_upload(&books, id, bearer.as_deref(), body).await
}
});
for _ in 0..max / mib {
tx.send(vec![b'x'; mib]).await.unwrap();
}
// Every byte up to the most taken, and a moment to act on it:
// still reading.
let more = tx.reserve().await.unwrap();
tokio::time::sleep(Duration::from_millis(500)).await;
assert!(
!answered.is_finished(),
"{status}: answered before the most an upload can be was read"
);
// One byte past it: answered, though the body never ends.
more.send(vec![b'x']);
let r = tokio::time::timeout(Duration::from_secs(60), answered)
.await
.unwrap_or_else(|_| panic!("{status}: never answered"))
.unwrap();
let (got, refused, said) = answer(r).await;
assert_eq!((got, refused.as_deref()), (status, Some(code)), "{said}");
drop(tx);
}
}
/// INVARIANT 8: a token revoked or replaced lets go of the sessions it
/// opened, and only those: not another book's token of its name, nor
/// its own book's token of another name.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_replaced_or_revoked_token_lets_go_of_its_own_sessions_alone() {
let home = tempfile::tempdir().unwrap();
let books = books_in(home.path());
create(&books, "acme");
create(&books, "beta");
// Oldest first: another book's token of the same name, then one of
// the same book with another name, then the one replaced.
let (beta, _) = token(&books, "beta", "agent");
let (other, _) = token(&books, "acme", "other");
let (agent, _) = token(&books, "acme", "agent");
{
let mut bound = books.session_tokens.lock().unwrap();
for (sid, owner) in [("s-beta", &beta), ("s-other", &other), ("s-agent", &agent)] {
bound.insert(sid.into(), owner.clone());
}
}
token(&books, "acme", "agent");
assert_eq!(bound(&books), ["s-beta", "s-other"]);
match books.admin(Request::TokenRevoke {
book: "beta".into(),
name: "agent".into(),
id: None,
}) {
Response::TokenRevoked(t) => assert_eq!(t.id, beta),
other => panic!("{other:?}"),
}
assert_eq!(bound(&books), ["s-other"]);
}
/// An MCP request to `acme` with `bearer`, on `session` if given.
fn mcp_request(
method: axum::http::Method,
bearer: &str,
session: Option<&str>,
body: serde_json::Value,
) -> axum::extract::Request {
let mut r = axum::http::Request::builder()
.method(method)
.uri("http://localhost/b/acme/mcp")
.header("host", "localhost")
.header("content-type", "application/json")
.header("accept", "application/json, text/event-stream")
.header("authorization", format!("Bearer {bearer}"));
if let Some(sid) = session {
r = r
.header(SESSION_HEADER, sid)
.header("mcp-protocol-version", "2025-06-18");
}
r.body(axum::body::Body::from(body.to_string())).unwrap()
}
async fn send_mcp(
books: &Arc<Books>,
request: axum::extract::Request,
) -> axum::response::Response {
mcp(
axum::extract::State(books.clone()),
axum::extract::Path("acme".to_string()),
request,
)
.await
}
fn initialize() -> serde_json::Value {
serde_json::json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {
"protocolVersion": "2025-06-18", "capabilities": {},
"clientInfo": {"name": "test", "version": "0"}}})
}
/// INVARIANT 8: a session stays bound to the token that opened it
/// through every request it serves, and is forgotten when it ends.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_session_stays_bound_to_its_token_until_it_ends() {
use axum::http::Method;
let home = tempfile::tempdir().unwrap();
let books = books_in(home.path());
create(&books, "acme");
let (agent_id, agent) = token(&books, "acme", "agent");
let (_, other) = token(&books, "acme", "other");
let r = send_mcp(
&books,
mcp_request(Method::POST, &agent, None, initialize()),
)
.await;
let sid = r.headers()[SESSION_HEADER].to_str().unwrap().to_string();
let (status, _, said) = answer(r).await;
assert_eq!(status, 200, "{said}");
let initialized =
serde_json::json!({"jsonrpc": "2.0", "method": "notifications/initialized"});
let list = serde_json::json!({"jsonrpc": "2.0", "id": 2, "method": "tools/list"});
for body in [initialized, list.clone()] {
let r = send_mcp(&books, mcp_request(Method::POST, &agent, Some(&sid), body)).await;
let (status, _, said) = answer(r).await;
assert!((200..300).contains(&status), "{status}: {said}");
assert_eq!(
books.session_tokens.lock().unwrap().get(&sid),
Some(&agent_id)
);
}
let r = send_mcp(&books, mcp_request(Method::POST, &other, Some(&sid), list)).await;
let (status, code, _) = answer(r).await;
assert_eq!((status, code.as_deref()), (403, Some("session_token")));
// Ended: forgotten.
let r = send_mcp(
&books,
mcp_request(Method::DELETE, &agent, Some(&sid), serde_json::json!({})),
)
.await;
let (status, _, said) = answer(r).await;
assert!((200..300).contains(&status), "{status}: {said}");
assert!(bound(&books).is_empty());
}
/// INVARIANT 8: past MAX_SESSIONS bindings only a request that opens
/// a session (an initialize) is refused; any other still reaches the
/// MCP service.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn past_the_most_sessions_only_opening_another_is_refused() {
use axum::http::Method;
let home = tempfile::tempdir().unwrap();
let books = books_in(home.path());
create(&books, "acme");
let (agent_id, agent) = token(&books, "acme", "agent");
{
let mut bound = books.session_tokens.lock().unwrap();
for n in 0..MAX_SESSIONS {
bound.insert(format!("s-{n}"), agent_id.clone());
}
}
let r = send_mcp(
&books,
mcp_request(Method::POST, &agent, None, initialize()),
)
.await;
let (status, code, said) = answer(r).await;
assert_eq!(
(status, code.as_deref()),
(503, Some("too_many_sessions")),
"{said}"
);
let list = serde_json::json!({"jsonrpc": "2.0", "id": 2, "method": "tools/list"});
let r = send_mcp(&books, mcp_request(Method::POST, &agent, None, list)).await;
let (status, code, said) = answer(r).await;
assert_ne!(
code.as_deref(),
Some("too_many_sessions"),
"{status}: {said}"
);
assert_eq!(bound(&books).len(), MAX_SESSIONS);
}
}
+455
View File
@@ -10274,4 +10274,459 @@ mod tests {
);
assert_eq!(digest_since("", d(2027, 1, 9)), "2027-01-01");
}
// Calls through the access door (access.rs), driven straight into the
// handler with a context the HTTP door would have left: its Caller,
// or none.
/// A peer to make request contexts with: a client and a server over
/// an in-memory pipe, kept alive while it is.
struct Session {
peer: rmcp::service::Peer<RoleServer>,
_server: rmcp::service::RunningService<RoleServer, BooksServer>,
_client: rmcp::service::RunningService<rmcp::service::RoleClient, ()>,
_books: TestDir,
}
async fn session(tag: &str) -> Session {
use rmcp::ServiceExt;
let books = unit_dir(&format!("{tag}-peer"));
let server = BooksServer::new(store(&books), ReceiptPolicy::default());
let (a, b) = tokio::io::duplex(1 << 16);
let served = tokio::spawn(async move { server.serve(a).await });
let client = ().serve(b).await.unwrap();
let server = served.await.unwrap().unwrap();
Session {
peer: server.peer().clone(),
_server: server,
_client: client,
_books: books,
}
}
/// A request's context: from a token with `scopes` (the door's
/// Caller), or with none.
fn context(
peer: &rmcp::service::Peer<RoleServer>,
scopes: Option<&[ledger_store::auth::Scope]>,
) -> RequestContext<RoleServer> {
let mut cx = RequestContext::new(rmcp::model::NumberOrString::Number(7), peer.clone());
if let Some(scopes) = scopes {
let token = ledger_store::auth::Token {
id: "tok_0123456789ab".into(),
book: "acme".into(),
name: "test".into(),
scopes: scopes.iter().copied().collect(),
created: "2026-10-20T00:00:00Z".into(),
sha256: String::new(),
};
let (mut parts, ()) = axum::http::Request::new(()).into_parts();
parts.extensions.insert(crate::access::Caller { token });
cx.extensions.insert(parts);
}
cx
}
const ALL: &[ledger_store::auth::Scope] = &[
ledger_store::auth::Scope::Read,
ledger_store::auth::Scope::Write,
ledger_store::auth::Scope::Post,
ledger_store::auth::Scope::Admin,
];
/// Books served from the data root: calls are checked against their
/// caller's token and recorded in the access log beside them.
fn logged(tag: &str) -> (BooksServer, TestDir) {
let books = unit_dir(tag);
let store = store(&books);
let log = ledger_store::access::AccessLog::open(&books.0).unwrap();
let server = BooksServer::new(store, ReceiptPolicy::default()).with_access(Arc::new(log));
(server, books)
}
/// Books whose access log can't be written: its file is a FIFO no one
/// reads, so every record fails (as on a full disk).
fn unwritable(tag: &str) -> (BooksServer, TestDir) {
let books = unit_dir(tag);
let store = store(&books);
let fifo = books.0.join(ledger_store::access::ACCESS_FILE);
let made = std::process::Command::new("mkfifo")
.arg(&fifo)
.status()
.unwrap();
assert!(made.success());
let path = fifo.clone();
let other_end = std::thread::spawn(move || {
// The log reads the file whole as it opens (empty: a writer
// that goes at once), then opens it to append (a reader that
// goes at once): nothing reads what is written after.
drop(std::fs::OpenOptions::new().write(true).open(&path).unwrap());
drop(std::fs::File::open(&path).unwrap());
});
let log = ledger_store::access::AccessLog::open(&books.0).unwrap();
other_end.join().unwrap();
let server = BooksServer::new(store, ReceiptPolicy::default()).with_access(Arc::new(log));
(server, books)
}
/// The access log's records: (subject, outcome, whether it carries
/// the arguments' digest).
fn records(books: &TestDir) -> Vec<(String, ledger_store::access::Outcome, bool)> {
ledger_store::access::read(&books.0)
.unwrap()
.0
.into_iter()
.map(|r| {
(
r.access.subject,
r.access.result,
r.access.args_digest.is_some(),
)
})
.collect()
}
fn refused(code: &str) -> ledger_store::access::Outcome {
ledger_store::access::Outcome::Refused(code.into())
}
fn tool_call(name: &str, args: serde_json::Value) -> rmcp::model::CallToolRequestParams {
rmcp::model::CallToolRequestParams::new(name.to_string())
.with_arguments(args.as_object().cloned().unwrap_or_default())
}
/// A tool's answer: Ok((is it a refusal, its text)), or the protocol
/// error.
fn answer(
out: Result<rmcp::model::CallToolResponse, rmcp::ErrorData>,
) -> Result<(bool, String), rmcp::ErrorData> {
match out? {
rmcp::model::CallToolResponse::Complete(r) => Ok((
r.is_error == Some(true),
r.content
.iter()
.find_map(|c| c.as_text().map(|t| t.text.clone()))
.unwrap_or_default(),
)),
other => panic!("not a complete answer: {other:?}"),
}
}
fn rt() -> tokio::runtime::Runtime {
tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.unwrap()
}
/// access.rs INVARIANTS 1-3: a call that reaches the books without a
/// Caller, or names a tool no table knows, is refused unrun (fail
/// closed), told why in words, and recorded so.
#[test]
fn a_call_without_a_caller_or_to_no_tool_is_refused_unrun_and_recorded() {
rt().block_on(async {
let s = session("door").await;
let (server, books) = logged("door");
let add = || {
tool_call(
"books.add_account",
serde_json::json!({"code": "1000", "name": "Cash", "account_type": "asset"}),
)
};
let (refusal, text) =
answer(server.call_tool(add(), context(&s.peer, None)).await).unwrap();
assert!(refusal, "{text}");
assert!(text.contains("without a token"), "{text}");
let unknown = tool_call("books.no_such_tool", serde_json::json!({}));
let (refusal, text) =
answer(server.call_tool(unknown, context(&s.peer, Some(ALL))).await).unwrap();
assert!(refusal, "{text}");
assert!(text.contains("there is no tool"), "{text}");
// Neither ran: the account isn't there.
let chart = tool_call("books.chart", serde_json::json!({}));
let (refusal, text) =
answer(server.call_tool(chart, context(&s.peer, Some(ALL))).await).unwrap();
assert!(!refusal, "{text}");
assert!(!text.contains("Cash"), "{text}");
assert_eq!(
records(&books),
vec![
("books.add_account".into(), refused("unauthorized"), true),
("books.no_such_tool".into(), refused("unknown_tool"), true),
(
"books.chart".into(),
ledger_store::access::Outcome::Ok,
true
),
]
);
});
}
/// SharedStore INVARIANT 3: once the books are closing a call or a
/// read isn't run, and says so with the not-run code (a JSON-RPC
/// server error, which the agents' bridge sends again once the books
/// are back); each is recorded as refused.
#[test]
fn a_call_while_the_books_close_is_refused_as_not_run() {
rt().block_on(async {
let s = session("closing").await;
let (server, books) = logged("closing");
server.store.close().await;
let chart = tool_call("books.chart", serde_json::json!({}));
let e = server
.call_tool(chart, context(&s.peer, Some(ALL)))
.await
.unwrap_err();
assert_eq!(e.code.0, NOT_RUN_CLOSING_CODE);
assert_eq!(e.message, NOT_RUN_CLOSING);
// JSON-RPC 2.0 5.1: -32000 to -32099 is for the server's own
// errors.
assert!((-32099..=-32000).contains(&NOT_RUN_CLOSING_CODE));
assert!((-32099..=-32000).contains(&NOT_RUN_STOPPED_CODE));
let read = ReadResourceRequestParams::new("books://chart");
let e = server
.read_resource(read, context(&s.peer, Some(ALL)))
.await
.unwrap_err();
assert_eq!(e.code.0, NOT_RUN_CLOSING_CODE);
assert_eq!(
records(&books),
vec![
("books.chart".into(), refused("closing"), true),
("books://chart".into(), refused("closing"), false),
]
);
});
}
/// Reading a resource is reading the books (access.rs): only a token
/// with books:read reads one, and every read is recorded with what
/// came of it.
#[test]
fn a_resource_is_read_only_with_books_read_and_each_read_is_recorded() {
use ledger_store::access::Outcome;
rt().block_on(async {
let s = session("resources").await;
let (server, books) = logged("resources");
let read = |uri: &str| ReadResourceRequestParams::new(uri);
assert!(
server
.read_resource(read("books://chart"), context(&s.peer, Some(ALL)))
.await
.is_ok()
);
let writer = [ledger_store::auth::Scope::Write];
let e = server
.read_resource(read("books://chart"), context(&s.peer, Some(&writer[..])))
.await
.unwrap_err();
assert_eq!(e.message, crate::access::INSUFFICIENT_SCOPE);
let e = server
.read_resource(read("books://chart"), context(&s.peer, None))
.await
.unwrap_err();
assert_eq!(e.message, crate::access::UNAUTHORIZED);
// One that ran and failed is an error, not a refusal.
assert!(
server
.read_resource(read("books://nope"), context(&s.peer, Some(ALL)))
.await
.is_err()
);
let outcomes: Vec<_> = records(&books).into_iter().map(|r| r.1).collect();
assert_eq!(
outcomes,
vec![
Outcome::Ok,
refused("insufficient_scope"),
refused("unauthorized"),
Outcome::Error,
]
);
});
}
/// SharedStore INVARIANTS 2-3 at the last moment: a call that passed
/// the checks, then found the books closing (or the server stopped)
/// as it went to run, isn't run either, and is answered and recorded
/// as one refused at the start would be. The settings are held so
/// the call waits between its checks and its run.
#[test]
fn a_call_the_books_close_or_stop_under_is_refused_as_not_run() {
let rt = rt();
for (how, stops) in [("closes", false), ("stops", true)] {
let (server, books) = logged(&format!("late-{how}"));
// The settings, held on a thread of their own until released.
let (locked, is_locked) = std::sync::mpsc::channel();
let (release, released) = std::sync::mpsc::channel::<()>();
let settings = server.settings.clone();
let holder = std::thread::spawn(move || {
let _held = settings.lock().unwrap();
locked.send(()).unwrap();
let _ = released.recv();
});
is_locked.recv().unwrap();
let answered = rt.block_on(async {
let s = session(&format!("late-{how}")).await;
let (started, began) = tokio::sync::oneshot::channel();
let calling = server.clone();
let cx = context(&s.peer, Some(ALL));
let call = tokio::spawn(async move {
let _ = started.send(());
let chart = tool_call("books.chart", serde_json::json!({}));
calling.call_tool(chart, cx).await
});
began.await.unwrap();
// Past its checks (no I/O between): waiting for the settings.
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
if stops {
server.store.stop();
} else {
server
.store
.closing
.store(true, std::sync::atomic::Ordering::SeqCst);
}
release.send(()).unwrap();
call.await.unwrap()
});
holder.join().unwrap();
if stops {
assert_eq!(answer(answered).unwrap(), (true, STOPPED.to_string()));
// Refused after its arguments were taken: their digest too.
assert_eq!(
records(&books),
vec![("books.chart".into(), refused("stopped"), true)]
);
} else {
let e = answered.unwrap_err();
assert_eq!(e.code.0, NOT_RUN_CLOSING_CODE, "{e:?}");
// Its arguments went with the request: no digest.
assert_eq!(
records(&books),
vec![("books.chart".into(), refused("closing"), false)]
);
}
}
}
/// access.rs INVARIANT 3: a record that can't be written stops the
/// books, and the answer says so; a call or read refused as not run
/// (closing) keeps its not-run code, so the bridge still sends it
/// again; anything else is an error in words.
#[test]
fn an_access_record_that_cant_be_written_stops_the_books_and_says_so() {
rt().block_on(async {
let s = session("unrecorded").await;
// A call refused as the books close.
let (server, _books) = unwritable("unrecorded-call");
server.store.close().await;
let chart = tool_call("books.chart", serde_json::json!({}));
let e = server
.call_tool(chart, context(&s.peer, Some(ALL)))
.await
.unwrap_err();
assert_eq!(e.code.0, NOT_RUN_CLOSING_CODE);
assert!(e.message.contains(crate::access::NOT_RECORDED), "{e:?}");
assert!(server.store.stopped());
// Any other answer refused unrun becomes the words alone.
let (server, _books) = unwritable("unrecorded-refusal");
let call = crate::access::Call::new(
server.access.as_ref().unwrap(),
&context(&s.peer, Some(ALL)),
"tool",
"books.chart",
);
let out = server
.refuse(
Some(&call),
crate::access::STOPPED,
Err(rmcp::ErrorData::internal_error("not run", None)),
)
.await;
assert_eq!(
answer(out).unwrap(),
(true, crate::access::NOT_RECORDED.to_string())
);
assert!(server.store.stopped());
// A read refused as the books close.
let (server, _books) = unwritable("unrecorded-closing-read");
server.store.close().await;
let e = server
.read_resource(
ReadResourceRequestParams::new("books://chart"),
context(&s.peer, Some(ALL)),
)
.await
.unwrap_err();
assert_eq!(e.code.0, NOT_RUN_CLOSING_CODE);
assert!(e.message.contains(crate::access::NOT_RECORDED), "{e:?}");
assert!(server.store.stopped());
// A read that ran and failed.
let (server, _books) = unwritable("unrecorded-read");
let e = server
.read_resource(
ReadResourceRequestParams::new("books://nope"),
context(&s.peer, Some(ALL)),
)
.await
.unwrap_err();
assert_eq!(e.code, rmcp::model::ErrorCode::INTERNAL_ERROR);
assert_eq!(e.message, crate::access::NOT_RECORDED);
assert!(server.store.stopped());
});
}
/// A request whose blocking work the runtime never ran (it was
/// shutting down) is answered as not run, in the words a stopped
/// server uses, and stops nothing: only a request that panicked
/// stops the books (run_blocking).
#[test]
fn a_request_the_runtime_never_ran_is_answered_not_run_and_stops_nothing() {
let dead = rt();
let handle = dead.handle().clone();
drop(dead);
rt().block_on(async {
let s = session("never-ran").await;
let (server, _books) = logged("never-ran");
let chart = tool_call("books.chart", serde_json::json!({}));
let mut call = std::pin::pin!(server.call_tool(chart, context(&s.peer, Some(ALL))));
// Its blocking work goes to the runtime that shut down.
let out = std::future::poll_fn(|cx| {
let _in = handle.enter();
call.as_mut().poll(cx)
})
.await;
assert_eq!(answer(out).unwrap(), (true, STOPPED.to_string()));
assert!(!server.store.stopped());
});
}
/// A tool list differs by caller (scopes.rs): a client on 2026-07-28
/// or later is told to cache it for this caller alone, and not to keep
/// it; an older one, which knows neither hint, is told neither.
#[test]
fn a_tool_list_is_cached_only_for_its_caller() {
rt().block_on(async {
let s = session("hints").await;
let books = unit_dir("hints");
let server = BooksServer::new(store(&books), ReceiptPolicy::default());
for (version, hinted) in [
(rmcp::model::ProtocolVersion::V_2026_07_28, true),
(rmcp::model::ProtocolVersion::V_2025_11_25, false),
] {
let mut cx = context(&s.peer, None);
cx.meta.set_protocol_version(version.clone());
let list = server.list_tools(None, cx).await.unwrap();
assert!(!list.tools.is_empty());
assert_eq!(list.ttl_ms, hinted.then_some(0), "{version:?}");
assert_eq!(
list.cache_scope,
hinted.then_some(rmcp::model::CacheScope::Private),
"{version:?}"
);
}
});
}
}
+15
View File
@@ -568,4 +568,19 @@ mod tests {
live.sync(&books, "USD");
assert!(live.is_journaled());
}
/// Books in the data root keep their own settings from the start
/// (INVARIANT 6): before they hold any, the settings in force say so
/// too, so no message sends anyone to a settings file; none is read.
#[test]
fn books_in_the_data_root_keep_their_own_settings_before_they_hold_any() {
let mut live = Live::own();
assert!(live.current().journaled);
assert!(live.is_journaled());
assert!(live.file_texts().is_none());
live.sync(&ledger_core::settings::Settings::default(), "USD");
assert!(live.current().journaled);
let s = live.status();
assert!(s.journaled && s.books_toml.is_none(), "{s:?}");
}
}
+22 -1
View File
@@ -347,7 +347,7 @@ impl BooksServer {
#[cfg(test)]
mod tests {
use super::plain_file_name;
use super::{CALLER, plain_file_name, token_flag};
/// A name kept before names were checked is handed over cleaned, never
/// refused (a W-9 sealed earlier, say).
@@ -364,6 +364,10 @@ mod tests {
let long = "x".repeat(400);
let cut = plain_file_name(&long);
assert!(cut.len() <= ledger_store::uploads::MAX_NAME_BYTES);
// Cut to fit, not dropped: as much of the name as the checks take,
// and a name that fits already kept whole.
assert_eq!(cut, "x".repeat(255));
assert_eq!(plain_file_name(&"y".repeat(255)), "y".repeat(255));
for n in [
"W9 \u{2714}\u{FE0F}.pdf",
"a/b\\c\n.pdf",
@@ -377,4 +381,21 @@ mod tests {
);
}
}
/// A command given to the agent names its token only when it isn't
/// the one the commands use by default ("agent"): the command runs as
/// the caller, never as another token.
#[test]
fn a_command_names_the_callers_token_unless_it_is_the_default() {
let flag_for = |caller: Option<(&str, &str)>| {
CALLER.with(|c| {
*c.borrow_mut() = caller.map(|(id, name)| (id.to_string(), name.to_string()))
});
token_flag()
};
assert_eq!(flag_for(None), "");
assert_eq!(flag_for(Some(("tok_1", "agent"))), "");
assert_eq!(flag_for(Some(("tok_2", "reader"))), " --token reader");
assert_eq!(flag_for(None), "");
}
}