P2 slice 4: access
Agent tokens (wb_ and 64 hex digits), one book and one name each, kept only as SHA-256 in a chain log and in the OS keyring; made, listed and revoked only through the admin socket (`wanderbooks token ...`). The door checks them, scopes cover every tool, every call is in the access log (fail closed), and the owner passphrase is an Argon2id hash. Setup's terminal test waits for its prompts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
1 parent
a298d154b6
commit
d1f3ad891a
28 files changed
+5504
-116
No files matched your search
@@ -22,6 +22,8 @@ type Client = RunningService<RoleClient, ClientConfig>;
|
||||
/// A home with a data root made by `wanderbooks setup`.
|
||||
struct Home {
|
||||
dir: tempfile::TempDir,
|
||||
/// A token for each book, made on first use (all scopes).
|
||||
tokens: std::sync::Mutex<std::collections::HashMap<String, String>>,
|
||||
}
|
||||
|
||||
impl Home {
|
||||
@@ -37,7 +39,32 @@ impl Home {
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(out.status.success(), "{out:?}");
|
||||
Self { dir }
|
||||
Self {
|
||||
dir,
|
||||
tokens: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The token tests use for `book`, made through the admin socket the
|
||||
/// first time.
|
||||
fn token(&self, book: &str) -> String {
|
||||
let mut tokens = self.tokens.lock().unwrap();
|
||||
if let Some(t) = tokens.get(book) {
|
||||
return t.clone();
|
||||
}
|
||||
let text = match self.admin(Request::TokenCreate {
|
||||
book: book.into(),
|
||||
name: "test".into(),
|
||||
scopes: ["read", "write", "post", "admin"]
|
||||
.map(String::from)
|
||||
.to_vec(),
|
||||
replace: true,
|
||||
}) {
|
||||
Response::TokenCreated { text, .. } => text.0,
|
||||
other => panic!("{other:?}"),
|
||||
};
|
||||
tokens.insert(book.to_string(), text.clone());
|
||||
text
|
||||
}
|
||||
|
||||
fn root(&self) -> PathBuf {
|
||||
@@ -130,22 +157,45 @@ impl Drop for Server {
|
||||
|
||||
/// An HTTP client for the server's MCP socket.
|
||||
fn mcp_http(home: &Home) -> reqwest::Client {
|
||||
mcp_http_builder(home).build().unwrap()
|
||||
}
|
||||
|
||||
fn mcp_http_builder(home: &Home) -> reqwest::ClientBuilder {
|
||||
// The short path is good in this process only, while it lives: kept.
|
||||
let short = ledger_store::root::SocketPath::new(&home.run(), "mcp.sock").unwrap();
|
||||
let path = short.path().to_path_buf();
|
||||
std::mem::forget(short);
|
||||
reqwest::Client::builder()
|
||||
.unix_socket(path)
|
||||
reqwest::Client::builder().unix_socket(path)
|
||||
}
|
||||
|
||||
/// An HTTP client for the server's MCP socket that sends `book`'s token
|
||||
/// on every request.
|
||||
fn mcp_http_for(home: &Home, book: &str) -> reqwest::Client {
|
||||
let mut headers = reqwest::header::HeaderMap::new();
|
||||
headers.insert(
|
||||
reqwest::header::AUTHORIZATION,
|
||||
format!("Bearer {}", home.token(book)).parse().unwrap(),
|
||||
);
|
||||
mcp_http_builder(home)
|
||||
.default_headers(headers)
|
||||
.build()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn connect(home: &Home, book: &str) -> Result<Client, String> {
|
||||
let token = home.token(book);
|
||||
connect_with(home, book, Some(token)).await
|
||||
}
|
||||
|
||||
/// [`connect`], with this token (or none).
|
||||
async fn connect_with(home: &Home, book: &str, token: Option<String>) -> Result<Client, String> {
|
||||
let http = mcp_http(home);
|
||||
let transport = StreamableHttpClientTransport::with_client(
|
||||
http,
|
||||
StreamableHttpClientTransportConfig::with_uri(format!("http://localhost/b/{book}/mcp")),
|
||||
);
|
||||
let mut config =
|
||||
StreamableHttpClientTransportConfig::with_uri(format!("http://localhost/b/{book}/mcp"));
|
||||
if let Some(token) = token {
|
||||
config = config.auth_header(token);
|
||||
}
|
||||
let transport = StreamableHttpClientTransport::with_client(http, config);
|
||||
ClientConfig::default()
|
||||
.serve(transport)
|
||||
.await
|
||||
@@ -266,7 +316,9 @@ async fn every_book_is_served_on_its_own_route() {
|
||||
home.assert_hidden(&start.to_string());
|
||||
assert!(start.to_string().contains("EUR"), "{start}");
|
||||
// A route for no books says so, and names no folder.
|
||||
let err = connect(&home, "nobody").await.unwrap_err();
|
||||
let err = connect_with(&home, "nobody", Some(home.token("acme")))
|
||||
.await
|
||||
.unwrap_err();
|
||||
home.assert_hidden(&err);
|
||||
drop((acme, beta));
|
||||
assert!(server.close().success());
|
||||
@@ -714,7 +766,7 @@ async fn a_call_whose_client_hung_up_finishes_before_the_close() {
|
||||
let home = Home::new("hangup");
|
||||
let mut server = Server::start(&home);
|
||||
home.create("acme", "USD");
|
||||
let http = mcp_http(&home);
|
||||
let http = mcp_http_for(&home, "acme");
|
||||
let session = raw_session(&http, "acme").await;
|
||||
// A large document: its filing takes long enough to close mid-way.
|
||||
let mut bytes = b"%PDF-1.4\n".to_vec();
|
||||
@@ -1063,3 +1115,655 @@ fn adoption_checks_the_vault_key_and_the_settings_file() {
|
||||
assert!(book.join("vault/tin-1.bin").is_file());
|
||||
assert!(server.close().success());
|
||||
}
|
||||
|
||||
// P2 slice 4: tokens, scopes and the access log (local.rs INVARIANT 8,
|
||||
// ledger-mcp access.rs, scopes.rs; ledger-store auth.rs, access.rs).
|
||||
|
||||
impl Home {
|
||||
/// A token for `book` named `name` with `scopes`, made (or remade).
|
||||
fn token_with(&self, book: &str, name: &str, scopes: &[&str]) -> String {
|
||||
match self.admin(Request::TokenCreate {
|
||||
book: book.into(),
|
||||
name: name.into(),
|
||||
scopes: scopes.iter().map(|s| s.to_string()).collect(),
|
||||
replace: true,
|
||||
}) {
|
||||
Response::TokenCreated { text, .. } => text.0,
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn book_dir(&self, book: &str) -> PathBuf {
|
||||
self.root().join("books").join(book)
|
||||
}
|
||||
}
|
||||
|
||||
/// A raw initialize on `book`'s route with `bearer` (if any): its status,
|
||||
/// its refusal header, its session id and its body.
|
||||
async fn raw_initialize(
|
||||
home: &Home,
|
||||
book: &str,
|
||||
bearer: Option<&str>,
|
||||
) -> (u16, Option<String>, Option<String>, String) {
|
||||
let mut r = mcp_http(home)
|
||||
.post(format!("http://localhost/b/{book}/mcp"))
|
||||
.header("content-type", "application/json")
|
||||
.header("accept", "application/json, text/event-stream")
|
||||
.body(
|
||||
json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {
|
||||
"protocolVersion": "2025-06-18", "capabilities": {},
|
||||
"clientInfo": {"name": "raw", "version": "0"}}})
|
||||
.to_string(),
|
||||
);
|
||||
if let Some(b) = bearer {
|
||||
r = r.bearer_auth(b);
|
||||
}
|
||||
let r = r.send().await.unwrap();
|
||||
let header = |name: &str| {
|
||||
r.headers()
|
||||
.get(name)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(str::to_string)
|
||||
};
|
||||
let refused = header("x-wanderbooks-refused");
|
||||
let session = header("mcp-session-id");
|
||||
let status = r.status().as_u16();
|
||||
if status == 401 {
|
||||
assert_eq!(
|
||||
header("www-authenticate").as_deref(),
|
||||
Some("Bearer realm=\"wanderbooks\"")
|
||||
);
|
||||
}
|
||||
(status, refused, session, r.text().await.unwrap())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn a_token_opens_only_its_own_books_until_revoked() {
|
||||
let home = Home::new("tokens");
|
||||
let server = Server::start(&home);
|
||||
home.create("acme", "USD");
|
||||
home.create("beta", "USD");
|
||||
// None, or one that isn't a token: 401, recorded, nothing named.
|
||||
for bearer in [None, Some("wb_nonsense"), Some("")] {
|
||||
let (status, refused, session, body) = raw_initialize(&home, "acme", bearer).await;
|
||||
assert_eq!(status, 401, "{bearer:?}: {body}");
|
||||
assert_eq!(refused.as_deref(), Some("unauthorized"));
|
||||
assert!(session.is_none());
|
||||
assert!(body.contains("token"), "{body}");
|
||||
home.assert_hidden(&body);
|
||||
}
|
||||
// Another book's token: 403.
|
||||
let beta = home.token("beta");
|
||||
let (status, refused, _, body) = raw_initialize(&home, "acme", Some(&beta)).await;
|
||||
assert_eq!(status, 403, "{body}");
|
||||
assert_eq!(refused.as_deref(), Some("wrong_books"));
|
||||
assert!(body.contains("beta"), "{body}");
|
||||
// Its own: in.
|
||||
let acme = connect(&home, "acme").await.unwrap();
|
||||
chart(&acme).await;
|
||||
// Revoked: the very next call is refused, on the same session.
|
||||
match home.admin(Request::TokenRevoke {
|
||||
book: "acme".into(),
|
||||
name: "test".into(),
|
||||
id: None,
|
||||
}) {
|
||||
Response::TokenRevoked(t) => assert_eq!(t.name, "test"),
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
assert!(call(&acme, "books.chart", json!({})).await.is_err());
|
||||
let (status, ..) = raw_initialize(&home, "acme", Some(&home.token("acme"))).await;
|
||||
assert_eq!(status, 401, "the remembered token was revoked");
|
||||
// The tokens listed carry no text.
|
||||
match home.admin(Request::TokenList) {
|
||||
Response::Tokens(tokens) => {
|
||||
assert_eq!(tokens.len(), 1);
|
||||
assert_eq!(tokens[0].book, "beta");
|
||||
let text = serde_json::to_string(&tokens).unwrap();
|
||||
assert!(!text.contains(&beta[3..]));
|
||||
}
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
// Every refusal at the door is in acme's access log.
|
||||
let (records, _) = ledger_store::access::read(&home.book_dir("acme")).unwrap();
|
||||
let refused: Vec<&str> = records
|
||||
.iter()
|
||||
.filter_map(|r| match &r.access.result {
|
||||
ledger_store::access::Outcome::Refused(code) => Some(code.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
refused,
|
||||
[
|
||||
"unauthorized",
|
||||
"unauthorized",
|
||||
"unauthorized",
|
||||
"wrong_books",
|
||||
"unauthorized",
|
||||
"unauthorized"
|
||||
],
|
||||
"{records:?}"
|
||||
);
|
||||
drop(acme);
|
||||
assert!(server.close().success());
|
||||
}
|
||||
|
||||
/// The tool names a client is offered.
|
||||
async fn offered(c: &Client) -> Vec<String> {
|
||||
c.list_all_tools()
|
||||
.await
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|t| t.name.to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn a_token_does_only_what_its_scopes_allow() {
|
||||
let home = Home::new("scopes");
|
||||
let server = Server::start(&home);
|
||||
home.create("acme", "USD");
|
||||
let full = connect(&home, "acme").await.unwrap();
|
||||
chart(&full).await;
|
||||
let entry = call(
|
||||
&full,
|
||||
"books.draft_entry",
|
||||
json!({"date": "2026-03-01", "description": "lunch"}),
|
||||
)
|
||||
.await
|
||||
.unwrap()["entry_id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
for (account, side) in [("6000", "debit"), ("1000", "credit")] {
|
||||
call(
|
||||
&full,
|
||||
"books.add_line",
|
||||
json!({"entry_id": entry, "account": account, "side": side, "amount": "5.00"}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
let head = || ledger_store::Snapshot::load(&home.book_dir("acme")).unwrap();
|
||||
let before = head().head_hash().to_string();
|
||||
// Read only.
|
||||
let read = home.token_with("acme", "reader", &["read"]);
|
||||
let reader = connect_with(&home, "acme", Some(read)).await.unwrap();
|
||||
let tools = offered(&reader).await;
|
||||
assert!(tools.contains(&"books.chart".to_string()));
|
||||
for hidden in ["books.add_line", "books.post_entry", "books.access_log"] {
|
||||
assert!(!tools.contains(&hidden.to_string()), "{hidden} offered");
|
||||
}
|
||||
assert!(call(&reader, "books.chart", json!({})).await.is_ok());
|
||||
let err = call(
|
||||
&reader,
|
||||
"books.add_account",
|
||||
json!({"code": "1100", "name": "Bank", "account_type": "asset"}),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.contains("needs books:write"), "{err}");
|
||||
assert!(err.contains("reader"), "{err}");
|
||||
// Write: a preview, never the post.
|
||||
let write = home.token_with("acme", "writer", &["read", "write"]);
|
||||
let writer = connect_with(&home, "acme", Some(write)).await.unwrap();
|
||||
let tools = offered(&writer).await;
|
||||
assert!(
|
||||
tools.contains(&"books.post_entry".to_string()),
|
||||
"it may preview"
|
||||
);
|
||||
assert!(!tools.contains(&"books.create_invoice".to_string()));
|
||||
let preview = call(&writer, "books.post_entry", json!({"entry_id": entry})).await;
|
||||
if let Err(e) = &preview {
|
||||
assert!(
|
||||
!e.contains("needs books"),
|
||||
"a preview is refused by scope: {e}"
|
||||
);
|
||||
}
|
||||
let err = call(
|
||||
&writer,
|
||||
"books.post_entry",
|
||||
json!({"entry_id": entry, "confirmed": true}),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.contains("needs books:post"), "{err}");
|
||||
assert_eq!(head().head_hash(), before, "nothing posted");
|
||||
// No admin: no access log.
|
||||
let poster = home.token_with("acme", "poster", &["read", "write", "post"]);
|
||||
let poster = connect_with(&home, "acme", Some(poster)).await.unwrap();
|
||||
let err = call(&poster, "books.access_log", json!({}))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.contains("needs books:admin"), "{err}");
|
||||
// A token without books:read reads no resource.
|
||||
let blind = home.token_with("acme", "blind", &["post"]);
|
||||
let blind = connect_with(&home, "acme", Some(blind)).await.unwrap();
|
||||
assert!(
|
||||
blind
|
||||
.read_resource(rmcp::model::ReadResourceRequestParams::new(
|
||||
"wanderbooks://handbook"
|
||||
))
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(offered(&blind).await.iter().all(|t| t != "books.chart"));
|
||||
drop((full, reader, writer, poster, blind));
|
||||
assert!(server.close().success());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn every_call_is_recorded_once_refusals_included() {
|
||||
let home = Home::new("accesslog");
|
||||
let server = Server::start(&home);
|
||||
home.create("acme", "USD");
|
||||
let admin_text = home.token("acme");
|
||||
let full = connect(&home, "acme").await.unwrap();
|
||||
chart(&full).await; // 3 ok
|
||||
let id = post_one(&full, 7).await; // draft, 2 lines, post: ok or an error
|
||||
let _ = id;
|
||||
// A call that errs (bad arguments) and one refused by scope.
|
||||
assert!(
|
||||
call(&full, "books.get_entry", json!({"entry_id": 999_999}))
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
let reader = home.token_with("acme", "reader", &["read"]);
|
||||
let reader = connect_with(&home, "acme", Some(reader)).await.unwrap();
|
||||
let marker = "a-distinctive-argument-1234";
|
||||
assert!(
|
||||
call(
|
||||
&reader,
|
||||
"books.add_account",
|
||||
json!({"code": "1100", "name": marker, "account_type": "asset"})
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
// A request with no token at all.
|
||||
assert_eq!(raw_initialize(&home, "acme", None).await.0, 401);
|
||||
// Read through the tool (admin): newest first, all of them.
|
||||
let log = call(&full, "books.access_log", json!({"limit": 500}))
|
||||
.await
|
||||
.unwrap();
|
||||
let records = log["records"].as_array().unwrap();
|
||||
assert_eq!(log["total"].as_u64().unwrap() as usize, records.len());
|
||||
assert_eq!(log["more"], false);
|
||||
assert_eq!(log["head"]["seq"].as_u64().unwrap() as usize, records.len());
|
||||
let seqs: Vec<u64> = records.iter().map(|r| r["seq"].as_u64().unwrap()).collect();
|
||||
let mut sorted = seqs.clone();
|
||||
sorted.sort_unstable_by(|a, b| b.cmp(a));
|
||||
assert_eq!(seqs, sorted, "newest first");
|
||||
let tools: Vec<&Value> = records.iter().filter(|r| r["action"] == "tool").collect();
|
||||
// Every tool call made so far, one record each: 3 accounts, the
|
||||
// posting (draft, two lines, post: up to four), the failed lookup,
|
||||
// the refusal.
|
||||
let by = |subject: &str| tools.iter().filter(|r| r["subject"] == subject).count();
|
||||
assert_eq!(by("books.add_account"), 4);
|
||||
assert_eq!(by("books.get_entry"), 1);
|
||||
assert_eq!(by("books.draft_entry"), 1);
|
||||
assert_eq!(by("books.add_line"), 2);
|
||||
assert_eq!(by("books.post_entry"), 1);
|
||||
assert_eq!(by("books.access_log"), 0, "recorded once answered");
|
||||
let refused: Vec<&&Value> = tools
|
||||
.iter()
|
||||
.filter(|r| r["result"]["refused"] == "insufficient_scope")
|
||||
.collect();
|
||||
assert_eq!(refused.len(), 1);
|
||||
assert_eq!(refused[0]["principal"]["label"], "reader");
|
||||
assert!(
|
||||
refused[0]["args_digest"]
|
||||
.as_str()
|
||||
.is_some_and(|d| d.len() == 64)
|
||||
);
|
||||
let errored = tools.iter().filter(|r| r["result"] == "error").count();
|
||||
assert!(errored >= 1);
|
||||
// Writes carry the head after them; reads don't.
|
||||
let added = tools
|
||||
.iter()
|
||||
.find(|r| r["subject"] == "books.add_account" && r["result"] == "ok")
|
||||
.unwrap();
|
||||
assert!(added["head_after"]["seq"].as_u64().unwrap() >= 1);
|
||||
assert!(refused[0].get("head_after").is_none());
|
||||
// Each write's head is its own: oldest first, the heads climb, and
|
||||
// the last is the journal's head now; a call that wrote nothing has
|
||||
// none (review of slice 4, round 2).
|
||||
let mut heads: Vec<(u64, String)> = Vec::new();
|
||||
for r in records.iter().rev() {
|
||||
let wrote = r.get("head_after").is_some();
|
||||
let reads = ["books.get_entry", "books.access_log", "books.chart"]
|
||||
.contains(&r["subject"].as_str().unwrap_or_default());
|
||||
if reads || r["action"] != "tool" || r["result"] != "ok" {
|
||||
assert!(!wrote, "{r}");
|
||||
}
|
||||
if wrote {
|
||||
heads.push((
|
||||
r["head_after"]["seq"].as_u64().unwrap(),
|
||||
r["head_after"]["hash"].as_str().unwrap().to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
assert!(heads.windows(2).all(|w| w[0].0 < w[1].0), "{heads:?}");
|
||||
let snap = ledger_store::Snapshot::load(&home.book_dir("acme")).unwrap();
|
||||
assert_eq!(heads.last().unwrap().1, snap.head_hash());
|
||||
assert_eq!(added["client"]["name"], "rmcp");
|
||||
let door: Vec<&Value> = records
|
||||
.iter()
|
||||
.filter(|r| r["action"] == "connect")
|
||||
.collect();
|
||||
assert_eq!(door.len(), 1);
|
||||
assert_eq!(door[0]["result"]["refused"], "unauthorized");
|
||||
assert_eq!(door[0]["principal"]["kind"], "unknown");
|
||||
// The access_log call itself is the next record.
|
||||
let next = call(&full, "books.access_log", json!({"limit": 1}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(next["records"][0]["subject"], "books.access_log");
|
||||
assert_eq!(next["more"], true);
|
||||
// Filters and paging.
|
||||
let mine = call(&full, "books.access_log", json!({"token": "reader"}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
mine["records"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.all(|r| r["principal"]["label"] == "reader")
|
||||
);
|
||||
let only_refused = call(&full, "books.access_log", json!({"outcome": "refused"}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(only_refused["records"].as_array().unwrap().len(), 2);
|
||||
let top = seqs[0];
|
||||
let older = call(
|
||||
&full,
|
||||
"books.access_log",
|
||||
json!({"before": top, "limit": 2}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
older["records"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.all(|r| r["seq"].as_u64().unwrap() < top)
|
||||
);
|
||||
for bad in [
|
||||
json!({"limit": 0}),
|
||||
json!({"limit": 501}),
|
||||
json!({"outcome": "maybe"}),
|
||||
] {
|
||||
assert!(call(&full, "books.access_log", bad).await.is_err());
|
||||
}
|
||||
// On disk: a chain that verifies, holding no token and no argument.
|
||||
let (on_disk, head) = ledger_store::access::read(&home.book_dir("acme")).unwrap();
|
||||
assert_eq!(head.seq as usize, on_disk.len());
|
||||
let text = std::fs::read_to_string(home.book_dir("acme").join("access.jsonl")).unwrap();
|
||||
assert!(
|
||||
!text.contains(&admin_text[3..]),
|
||||
"a token's text was logged"
|
||||
);
|
||||
assert!(!text.contains(marker), "an argument was logged");
|
||||
assert_eq!(mode(&home.book_dir("acme").join("access.jsonl")), 0o600);
|
||||
drop((full, reader));
|
||||
assert!(server.close().success());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn a_session_is_used_only_with_the_token_that_opened_it() {
|
||||
let home = Home::new("sessions");
|
||||
let server = Server::start(&home);
|
||||
home.create("acme", "USD");
|
||||
let first = home.token("acme");
|
||||
let second = home.token_with("acme", "other", &["read", "write", "post", "admin"]);
|
||||
let (status, _, session, _) = raw_initialize(&home, "acme", Some(&first)).await;
|
||||
assert_eq!(status, 200);
|
||||
let session = session.unwrap();
|
||||
let list = |bearer: String| {
|
||||
let session = session.clone();
|
||||
let http = mcp_http(&home);
|
||||
async move {
|
||||
http.post("http://localhost/b/acme/mcp")
|
||||
.header("content-type", "application/json")
|
||||
.header("accept", "application/json, text/event-stream")
|
||||
.header("mcp-session-id", session)
|
||||
.header("mcp-protocol-version", "2025-06-18")
|
||||
.bearer_auth(bearer)
|
||||
.body(json!({"jsonrpc": "2.0", "id": 2, "method": "tools/list"}).to_string())
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
};
|
||||
let r = list(second).await;
|
||||
assert_eq!(r.status().as_u16(), 403);
|
||||
assert_eq!(r.headers()["x-wanderbooks-refused"], "session_token");
|
||||
let r = list(first).await;
|
||||
assert_eq!(r.status().as_u16(), 200, "{:?}", r.text().await);
|
||||
assert!(server.close().success());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_damaged_access_log_keeps_its_books_closed() {
|
||||
let home = Home::new("damagedlog");
|
||||
let server = Server::start(&home);
|
||||
home.create("acme", "USD");
|
||||
home.create("beta", "USD");
|
||||
assert!(server.close().success());
|
||||
std::fs::write(home.book_dir("acme").join("access.jsonl"), "not a record\n").unwrap();
|
||||
let server = Server::start(&home);
|
||||
match home.admin(Request::List) {
|
||||
Response::Books(books) => {
|
||||
let acme = books.iter().find(|b| b.id == "acme").unwrap();
|
||||
assert!(!acme.open);
|
||||
let why = acme.problem.as_deref().unwrap();
|
||||
assert!(why.contains("access log"), "{why}");
|
||||
home.assert_hidden(why);
|
||||
assert!(books.iter().find(|b| b.id == "beta").unwrap().open);
|
||||
}
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
assert!(server.close().success());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn damaged_token_records_refuse_every_agent_but_not_the_list() {
|
||||
let home = Home::new("damagedauth");
|
||||
let server = Server::start(&home);
|
||||
home.create("acme", "USD");
|
||||
let token = home.token("acme");
|
||||
assert!(server.close().success());
|
||||
let path = home.root().join("auth/principals.jsonl");
|
||||
let text = std::fs::read_to_string(&path).unwrap();
|
||||
std::fs::write(&path, text.replace("books:admin", "books:read")).unwrap();
|
||||
let server = Server::start(&home);
|
||||
assert!(matches!(home.admin(Request::List), Response::Books(_)));
|
||||
let (status, _, _, body) = raw_initialize(&home, "acme", Some(&token)).await;
|
||||
assert_eq!(status, 401);
|
||||
assert!(body.contains("record of agent tokens"), "{body}");
|
||||
home.assert_hidden(&body);
|
||||
match home.admin(Request::TokenList) {
|
||||
Response::Refused(why) => assert!(why.contains("record of agent tokens"), "{why}"),
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
assert!(server.close().success());
|
||||
}
|
||||
|
||||
/// Review of slice 4, finding 1: the access log gives no TIN away, not
|
||||
/// in its text and not through its digest of the arguments (one with the
|
||||
/// TIN masked, so no guess at it can be checked).
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn the_access_log_gives_no_tin_away() {
|
||||
let home = Home::new("tincanary");
|
||||
let server = Server::start(&home);
|
||||
home.create("acme", "USD");
|
||||
let full = connect(&home, "acme").await.unwrap();
|
||||
call(
|
||||
&full,
|
||||
"books.add_party",
|
||||
json!({"name": "Pat Example", "roles": ["contractor"]}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let tin = "123-45-6789";
|
||||
let args = json!({"party": "Pat Example", "tin": tin, "tin_type": "ssn"});
|
||||
// Sealed or not (the vault may refuse in a fresh root), the call is
|
||||
// recorded; and once more by a token that may not, refused.
|
||||
let _ = call(&full, "books.set_tin", args.clone()).await;
|
||||
let reader = home.token_with("acme", "reader", &["read"]);
|
||||
let reader = connect_with(&home, "acme", Some(reader)).await.unwrap();
|
||||
assert!(call(&reader, "books.set_tin", args.clone()).await.is_err());
|
||||
let text = std::fs::read_to_string(home.book_dir("acme").join("access.jsonl")).unwrap();
|
||||
assert!(!text.contains(tin) && !text.contains("123456789"), "{text}");
|
||||
let other = json!({"party": "Pat Example", "tin": "987-81-4321", "tin_type": "ssn"});
|
||||
let want = ledger_store::access::digest(other.as_object().map(|_| &other).unwrap());
|
||||
let (records, _) = ledger_store::access::read(&home.book_dir("acme")).unwrap();
|
||||
let set: Vec<_> = records
|
||||
.iter()
|
||||
.filter(|r| r.access.subject == "books.set_tin")
|
||||
.collect();
|
||||
assert_eq!(set.len(), 2, "{records:?}");
|
||||
for r in set {
|
||||
assert_eq!(
|
||||
r.access.args_digest.as_deref(),
|
||||
Some(want.as_str()),
|
||||
"{r:?}"
|
||||
);
|
||||
}
|
||||
drop((full, reader));
|
||||
assert!(server.close().success());
|
||||
}
|
||||
|
||||
/// Review of slice 4, finding 6: a session id the server never opened is
|
||||
/// bound to nothing (no flood of made-up ids binds or unbinds anything),
|
||||
/// and an ended session is forgotten.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn only_sessions_the_server_opened_are_bound() {
|
||||
let home = Home::new("sessbind");
|
||||
let server = Server::start(&home);
|
||||
home.create("acme", "USD");
|
||||
let first = home.token("acme");
|
||||
let second = home.token_with("acme", "other", &["read", "write", "post", "admin"]);
|
||||
let (_, _, session, _) = raw_initialize(&home, "acme", Some(&first)).await;
|
||||
let session = session.unwrap();
|
||||
let request = |bearer: String, sid: String, method: reqwest::Method| {
|
||||
let http = mcp_http(&home);
|
||||
async move {
|
||||
http.request(method, "http://localhost/b/acme/mcp")
|
||||
.header("content-type", "application/json")
|
||||
.header("accept", "application/json, text/event-stream")
|
||||
.header("mcp-session-id", sid)
|
||||
.header("mcp-protocol-version", "2025-06-18")
|
||||
.bearer_auth(bearer)
|
||||
.body(json!({"jsonrpc": "2.0", "id": 2, "method": "tools/list"}).to_string())
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.status()
|
||||
.as_u16()
|
||||
}
|
||||
};
|
||||
// Made-up ids: not this server's sessions, so not found, and bound to
|
||||
// nothing.
|
||||
for n in 0..50 {
|
||||
let made_up = format!("not-a-session-{n}");
|
||||
assert_eq!(
|
||||
request(second.clone(), made_up, reqwest::Method::POST).await,
|
||||
404
|
||||
);
|
||||
}
|
||||
// The real one is still bound to the token that opened it.
|
||||
assert_eq!(
|
||||
request(second.clone(), session.clone(), reqwest::Method::POST).await,
|
||||
403
|
||||
);
|
||||
assert_eq!(
|
||||
request(first.clone(), session.clone(), reqwest::Method::POST).await,
|
||||
200
|
||||
);
|
||||
// Ended: forgotten (the next use of its id is simply not found).
|
||||
let ended = request(first.clone(), session.clone(), reqwest::Method::DELETE).await;
|
||||
assert!((200..300).contains(&ended), "{ended}");
|
||||
assert_eq!(
|
||||
request(second.clone(), session, reqwest::Method::POST).await,
|
||||
404
|
||||
);
|
||||
// "bearer" in any case.
|
||||
let (status, ..) = raw_initialize(&home, "acme", None).await;
|
||||
assert_eq!(status, 401);
|
||||
let r = mcp_http(&home)
|
||||
.post("http://localhost/b/acme/mcp")
|
||||
.header("content-type", "application/json")
|
||||
.header("accept", "application/json, text/event-stream")
|
||||
.header("authorization", format!("bEaReR {first}"))
|
||||
.body(
|
||||
json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {
|
||||
"protocolVersion": "2025-06-18", "capabilities": {},
|
||||
"clientInfo": {"name": "raw", "version": "0"}}})
|
||||
.to_string(),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(r.status().as_u16(), 200);
|
||||
assert!(server.close().success());
|
||||
}
|
||||
|
||||
/// Review of slice 4, findings 2 to 4: no token below books:admin gets a
|
||||
/// full TIN out, and none below books:post changes the vault or the
|
||||
/// approval controls.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn the_vault_and_the_approval_controls_need_their_scopes() {
|
||||
let home = Home::new("vaultscope");
|
||||
let server = Server::start(&home);
|
||||
home.create("acme", "USD");
|
||||
let writer = home.token_with("acme", "writer", &["read", "write", "post"]);
|
||||
let w = connect_with(&home, "acme", Some(writer)).await.unwrap();
|
||||
for (tool, args) in [
|
||||
(
|
||||
"books.vendor_1099",
|
||||
json!({"year": 2026, "include_tins": true}),
|
||||
),
|
||||
("books.export_cpa_package", json!({"as_of": "2026-12-31"})),
|
||||
("books.get_w9", json!({"party": "x"})),
|
||||
] {
|
||||
let err = call(&w, tool, args).await.unwrap_err();
|
||||
assert!(err.contains("needs books:admin"), "{tool}: {err}");
|
||||
}
|
||||
let plain = home.token_with("acme", "plain", &["read", "write"]);
|
||||
let p = connect_with(&home, "acme", Some(plain)).await.unwrap();
|
||||
for (tool, args) in [
|
||||
(
|
||||
"books.set_tin",
|
||||
json!({"party": "x", "tin": "123-45-6789", "tin_type": "ssn"}),
|
||||
),
|
||||
(
|
||||
"books.retire_convention",
|
||||
json!({"id": "x", "decided_by": "Pat"}),
|
||||
),
|
||||
(
|
||||
"books.approve_entry",
|
||||
json!({"entry_id": 1, "approver": "Pat"}),
|
||||
),
|
||||
(
|
||||
"books.set_convention",
|
||||
json!({"id": "x", "kind": "approval_rule", "title": "x", "body_md": "x",
|
||||
"decided_by": "Pat", "structured": {"approver": "Pat"}}),
|
||||
),
|
||||
] {
|
||||
let err = call(&p, tool, args).await.unwrap_err();
|
||||
assert!(err.contains("needs books:post"), "{tool}: {err}");
|
||||
}
|
||||
// Another kind of convention is a write.
|
||||
let note = call(
|
||||
&p,
|
||||
"books.set_convention",
|
||||
json!({"id": "a-note", "kind": "note", "title": "A note", "body_md": "Hello.",
|
||||
"decided_by": "Pat"}),
|
||||
)
|
||||
.await;
|
||||
assert!(note.is_ok(), "{note:?}");
|
||||
drop((w, p));
|
||||
assert!(server.close().success());
|
||||
}
|
||||
@@ -17,8 +17,10 @@ use std::process::{Child, Command, Stdio};
|
||||
use std::sync::mpsc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Short timings for tests (the defaults are the plan's).
|
||||
const KNOBS: &str = "close_after=2500,backoff=50,exe_poll=100,init_wait=5000,queue_wait=30000";
|
||||
/// Short timings for tests (the defaults are the plan's), and agents'
|
||||
/// tokens kept in files in the test root, not this computer's keyring.
|
||||
const KNOBS: &str =
|
||||
"close_after=2500,backoff=50,exe_poll=100,init_wait=5000,queue_wait=30000,file_keyring=1";
|
||||
|
||||
/// A home with a data root, and the program the tests run (possibly a
|
||||
/// copy, for restarts).
|
||||
@@ -91,9 +93,17 @@ impl Home {
|
||||
c.output().unwrap()
|
||||
}
|
||||
|
||||
/// New books, and the agents' token the bridges use for them.
|
||||
fn create(&self, id: &str) {
|
||||
let out = self.books(&["create", id]);
|
||||
assert!(out.status.success(), "{out:?}");
|
||||
let out = self
|
||||
.cmd("token")
|
||||
.args(["create", "--book", id])
|
||||
.stdin(Stdio::null())
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(out.status.success(), "{out:?}");
|
||||
}
|
||||
|
||||
/// The pids running `mode` for this root.
|
||||
@@ -569,7 +579,7 @@ fn the_bridge_answers_initialize_itself_during_a_slow_start() {
|
||||
"--book",
|
||||
"acme",
|
||||
"--test-knobs",
|
||||
"close_after=1500,backoff=50,server_delay=3000,init_wait=300,queue_wait=30000",
|
||||
"close_after=1500,backoff=50,server_delay=3000,init_wait=300,queue_wait=30000,file_keyring=1",
|
||||
]);
|
||||
let mut a = Agent::from(c);
|
||||
let started = Instant::now();
|
||||
@@ -638,7 +648,7 @@ fn the_bridge_gives_up_on_books_that_never_open() {
|
||||
"--book",
|
||||
"acme",
|
||||
"--test-knobs",
|
||||
"close_after=800,backoff=50,server_delay=20000,init_wait=200,queue_wait=1000",
|
||||
"close_after=800,backoff=50,server_delay=20000,init_wait=200,queue_wait=1000,file_keyring=1",
|
||||
]);
|
||||
let mut a = Agent::from(c);
|
||||
a.initialize();
|
||||
@@ -1247,7 +1257,7 @@ fn a_call_cancelled_at_once_is_never_made() {
|
||||
"--book",
|
||||
"acme",
|
||||
"--test-knobs",
|
||||
"close_after=1500,backoff=50,server_delay=1500,init_wait=200,queue_wait=30000",
|
||||
"close_after=1500,backoff=50,server_delay=1500,init_wait=200,queue_wait=30000,file_keyring=1",
|
||||
]);
|
||||
let mut a = Agent::from(c);
|
||||
a.initialize();
|
||||
@@ -1317,7 +1327,7 @@ fn a_signalled_bridge_goes_at_once() {
|
||||
"--book",
|
||||
"acme",
|
||||
"--test-knobs",
|
||||
"close_after=1500,backoff=50,server_delay=20000,init_wait=200,queue_wait=30000",
|
||||
"close_after=1500,backoff=50,server_delay=20000,init_wait=200,queue_wait=30000,file_keyring=1",
|
||||
]);
|
||||
let mut a = Agent::from(c);
|
||||
a.initialize();
|
||||
@@ -1755,7 +1765,7 @@ fn books_made_mid_session_and_bad_requests() {
|
||||
"--book",
|
||||
"acme",
|
||||
"--test-knobs",
|
||||
"close_after=2500,backoff=50,server_delay=2000,init_wait=200,queue_wait=30000",
|
||||
"close_after=2500,backoff=50,server_delay=2000,init_wait=200,queue_wait=30000,file_keyring=1",
|
||||
]);
|
||||
let mut d = Agent::from(c);
|
||||
d.initialize();
|
||||
@@ -1939,3 +1949,104 @@ fn a_bridge_started_without_stderr_keeps_its_descriptors_apart() {
|
||||
assert_eq!(fd2, Path::new("/dev/null"));
|
||||
assert!(a.end().is_some_and(|s| s.success()));
|
||||
}
|
||||
|
||||
/// P2 slice 4: `wanderbooks token` keeps the token where the bridge finds
|
||||
/// it (commands.rs INVARIANT 4, bridge.rs INVARIANT 11): made, used,
|
||||
/// limited by its scopes, listed without its text, revoked at once.
|
||||
#[test]
|
||||
fn the_token_commands_and_the_bridge() {
|
||||
let home = Home::new("tokencmd");
|
||||
let out = home.books(&["create", "acme"]);
|
||||
assert!(out.status.success(), "{out:?}");
|
||||
let token = |args: &[&str]| {
|
||||
let out = home
|
||||
.cmd("token")
|
||||
.args(args)
|
||||
.stdin(Stdio::null())
|
||||
.output()
|
||||
.unwrap();
|
||||
(
|
||||
out.status.code(),
|
||||
String::from_utf8_lossy(&out.stdout).to_string(),
|
||||
String::from_utf8_lossy(&out.stderr).to_string(),
|
||||
)
|
||||
};
|
||||
// A session begun before any token: told how to make one.
|
||||
let mut a = Agent::start(&home, "acme");
|
||||
a.initialize();
|
||||
let err = a.call("books.start", json!({})).unwrap_err();
|
||||
assert!(err.contains("wanderbooks token create"), "{err}");
|
||||
// A read-only token, made: the same session uses it from now on.
|
||||
let (code, made, _) = token(&["create", "--book", "acme", "--scopes", "read"]);
|
||||
assert_eq!(code, Some(0), "{made}");
|
||||
assert!(made.contains("kept in this computer's keyring"), "{made}");
|
||||
assert!(
|
||||
made.contains("books:read") && !made.contains("wb_"),
|
||||
"{made}"
|
||||
);
|
||||
assert!(!made.contains(&home.root().display().to_string()));
|
||||
assert!(a.call("books.start", json!({})).is_ok());
|
||||
let err = a
|
||||
.call(
|
||||
"books.add_account",
|
||||
json!({"code": "1000", "name": "Cash", "account_type": "asset"}),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(err.contains("needs books:write"), "{err}");
|
||||
// A name taken; replaced on request, and the running session takes
|
||||
// the new token at its next call (the old one refused at the door, the
|
||||
// keyring read again: review of slice 4).
|
||||
let (code, _, err) = token(&["create", "--book", "acme"]);
|
||||
assert_eq!(code, Some(1));
|
||||
assert!(err.contains("already have a token"), "{err}");
|
||||
let (code, _, _) = token(&["create", "--book", "acme", "--replace"]);
|
||||
assert_eq!(code, Some(0));
|
||||
a.call(
|
||||
"books.add_account",
|
||||
json!({"code": "1000", "name": "Cash", "account_type": "asset"}),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(a.end().is_some());
|
||||
let mut b = Agent::start(&home, "acme");
|
||||
b.initialize();
|
||||
for (code, name, ty) in [
|
||||
("3000", "Capital", "equity"),
|
||||
("6000", "Supplies", "expense"),
|
||||
] {
|
||||
b.call(
|
||||
"books.add_account",
|
||||
json!({"code": code, "name": name, "account_type": ty}),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
// Listed without its text; printed only by `print`.
|
||||
let (_, listed, _) = token(&["list"]);
|
||||
assert!(
|
||||
listed.contains("acme agent books:read,books:write,books:post,books:admin"),
|
||||
"{listed}"
|
||||
);
|
||||
assert!(!listed.contains("wb_"));
|
||||
let (code, printed, _) = token(&["print", "--book", "acme"]);
|
||||
assert_eq!(code, Some(0));
|
||||
assert!(
|
||||
printed.trim().starts_with("wb_") && printed.trim().len() == 67,
|
||||
"{printed}"
|
||||
);
|
||||
// Revoked: the next call is refused, and the keyring forgets it.
|
||||
let (code, said, _) = token(&["revoke", "--book", "acme"]);
|
||||
assert_eq!(code, Some(0), "{said}");
|
||||
assert!(b.call("books.chart", json!({})).is_err());
|
||||
let (code, _, err) = token(&["print", "--book", "acme"]);
|
||||
assert_eq!(code, Some(1));
|
||||
assert!(err.contains("keeps no token"), "{err}");
|
||||
let (_, listed, _) = token(&["list"]);
|
||||
assert!(listed.contains("No tokens yet"), "{listed}");
|
||||
// Asked of books there aren't, or with a scope there isn't.
|
||||
let (code, _, err) = token(&["create", "--book", "nobody"]);
|
||||
assert_eq!(code, Some(1));
|
||||
assert!(err.contains("no books called"), "{err}");
|
||||
let (code, _, err) = token(&["create", "--book", "acme", "--scopes", "read,everything"]);
|
||||
assert_eq!(code, Some(2));
|
||||
assert!(err.contains("isn't a scope"), "{err}");
|
||||
assert!(b.end().is_some());
|
||||
}
|
||||
@@ -152,3 +152,156 @@ fn another_home_ignores_this_accounts_data_home() {
|
||||
"XDG_DATA_HOME was used"
|
||||
);
|
||||
}
|
||||
|
||||
/// Run `args` at a terminal (a pseudo-terminal, through util-linux
|
||||
/// `script`), typing `lines` one at a time: its exit code and what the
|
||||
/// terminal showed.
|
||||
fn at_terminal(args: &[String], lines: &[&str]) -> (i32, String) {
|
||||
use std::io::Write;
|
||||
let command = std::iter::once(env!("CARGO_BIN_EXE_wanderbooks").to_string())
|
||||
.chain(args.iter().cloned())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let mut child = Command::new("script")
|
||||
.args(["-qefc", &command, "/dev/null"])
|
||||
.env_remove("XDG_DATA_HOME")
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.expect("util-linux script");
|
||||
let mut stdin = child.stdin.take().unwrap();
|
||||
// What it shows, as it shows it: each line is typed only once it has
|
||||
// asked for it (a prompt ending ": "), never before it stops echoing.
|
||||
let shown_so_far = std::sync::Arc::new(std::sync::Mutex::new(Vec::<u8>::new()));
|
||||
let mut stdout = child.stdout.take().unwrap();
|
||||
let reader = {
|
||||
let shown = shown_so_far.clone();
|
||||
std::thread::spawn(move || {
|
||||
use std::io::Read;
|
||||
let mut buf = [0u8; 4096];
|
||||
while let Ok(n) = stdout.read(&mut buf) {
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
shown.lock().unwrap().extend_from_slice(&buf[..n]);
|
||||
}
|
||||
})
|
||||
};
|
||||
let prompts = |shown: &[u8]| {
|
||||
String::from_utf8_lossy(shown)
|
||||
.split(['\r', '\n'])
|
||||
.filter(|l| l.ends_with(": ") || l.ends_with(':'))
|
||||
.count()
|
||||
};
|
||||
for (i, line) in lines.iter().enumerate() {
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60);
|
||||
while prompts(&shown_so_far.lock().unwrap()) <= i {
|
||||
if child.try_wait().unwrap().is_some() || std::time::Instant::now() > deadline {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||
}
|
||||
// It turns its echo off just after asking.
|
||||
std::thread::sleep(std::time::Duration::from_millis(150));
|
||||
let _ = stdin.write_all(format!("{line}\n").as_bytes());
|
||||
}
|
||||
// Kept open until it ends: script stops when its input does.
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60);
|
||||
while child.try_wait().unwrap().is_none() {
|
||||
assert!(std::time::Instant::now() < deadline, "it never ended");
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
}
|
||||
drop(stdin);
|
||||
let out = child.wait_with_output().unwrap();
|
||||
let _ = reader.join();
|
||||
let shown = format!(
|
||||
"{}{}",
|
||||
String::from_utf8_lossy(&shown_so_far.lock().unwrap()),
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
(out.status.code().unwrap_or(-1), shown)
|
||||
}
|
||||
|
||||
const FIRST: &str = "correct horse battery";
|
||||
const SECOND: &str = "a different staple phrase";
|
||||
|
||||
#[test]
|
||||
fn setup_at_a_terminal_sets_the_owner_passphrase() {
|
||||
let home = tempfile::Builder::new()
|
||||
.prefix("wb-pass-")
|
||||
.tempdir()
|
||||
.unwrap();
|
||||
let args = vec![
|
||||
"setup".to_string(),
|
||||
"--home".into(),
|
||||
home.path().display().to_string(),
|
||||
];
|
||||
let (code, shown) = at_terminal(&args, &[FIRST, FIRST]);
|
||||
assert_eq!(code, 0, "{shown}");
|
||||
assert!(shown.contains("The owner passphrase is set."), "{shown}");
|
||||
assert!(!shown.contains(FIRST), "the passphrase was shown: {shown}");
|
||||
assert!(!shown.contains(home.path().to_str().unwrap()), "{shown}");
|
||||
let auth = home.path().join(".local/share/wanderbooks/auth");
|
||||
assert!(ledger_store::passphrase::verify(&auth, FIRST).unwrap());
|
||||
assert_eq!(mode(&auth), 0o700);
|
||||
assert_eq!(mode(&auth.join("owner.json")), 0o600);
|
||||
// Run again, it asks for nothing more.
|
||||
let (code, shown) = at_terminal(&args, &[]);
|
||||
assert_eq!(code, 0, "{shown}");
|
||||
assert!(!shown.contains("passphrase"), "{shown}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_passphrase_is_typed_by_a_person_and_changed_only_with_the_one_in_force() {
|
||||
let home = tempfile::Builder::new()
|
||||
.prefix("wb-pass-")
|
||||
.tempdir()
|
||||
.unwrap();
|
||||
// Set up without a terminal: the folder, and a note.
|
||||
let out = wanderbooks()
|
||||
.args(["setup", "--home"])
|
||||
.arg(home.path())
|
||||
.env_remove("XDG_DATA_HOME")
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(out.status.success(), "{out:?}");
|
||||
assert!(String::from_utf8_lossy(&out.stdout).contains("wanderbooks passphrase"));
|
||||
let root = home.path().join(".local/share/wanderbooks");
|
||||
let auth = root.join("auth");
|
||||
assert!(!ledger_store::passphrase::is_set(&auth).unwrap());
|
||||
let args = vec![
|
||||
"passphrase".to_string(),
|
||||
"--root".into(),
|
||||
root.display().to_string(),
|
||||
];
|
||||
// No terminal (a script, an agent): refused, nothing set.
|
||||
let out = wanderbooks()
|
||||
.args(&args)
|
||||
.stdin(std::process::Stdio::null())
|
||||
.output()
|
||||
.unwrap();
|
||||
assert_eq!(out.status.code(), Some(1), "{out:?}");
|
||||
assert!(String::from_utf8_lossy(&out.stderr).contains("typed by a person"));
|
||||
assert!(!ledger_store::passphrase::is_set(&auth).unwrap());
|
||||
// Too short, or the two not matching: nothing set.
|
||||
for lines in [["short", "short"], [FIRST, SECOND]] {
|
||||
let (code, shown) = at_terminal(&args, &lines);
|
||||
assert_eq!(code, 1, "{shown}");
|
||||
assert!(!ledger_store::passphrase::is_set(&auth).unwrap(), "{shown}");
|
||||
}
|
||||
let (code, shown) = at_terminal(&args, &[FIRST, FIRST]);
|
||||
assert_eq!(code, 0, "{shown}");
|
||||
// A change needs the one in force.
|
||||
let (code, shown) = at_terminal(&args, &["not the passphrase at all"]);
|
||||
assert_eq!(code, 1, "{shown}");
|
||||
assert!(shown.contains("isn't the owner passphrase"), "{shown}");
|
||||
assert!(ledger_store::passphrase::verify(&auth, FIRST).unwrap());
|
||||
let (code, shown) = at_terminal(&args, &[FIRST, SECOND, SECOND]);
|
||||
assert_eq!(code, 0, "{shown}");
|
||||
assert!(!shown.contains(FIRST) && !shown.contains(SECOND), "{shown}");
|
||||
assert!(ledger_store::passphrase::verify(&auth, SECOND).unwrap());
|
||||
assert!(!ledger_store::passphrase::verify(&auth, FIRST).unwrap());
|
||||
let kept = std::fs::read_to_string(auth.join("owner.json")).unwrap();
|
||||
assert!(!kept.contains("staple"), "the text itself was kept");
|
||||
}
|
||||
Reference in new issue
Block a user