P2 slice 8: the viewer's menu, setup, no confinement (D-32)

No Landlock, no seccomp, no helper: isolation is "same_user
(advisory)". The lease socket keeps two intents (close, format-upgrade
consent). The viewer holds the books open while it runs and gains a
Books menu: New books, Back up now, Restore (preview, then confirm),
Close, Connect Claude Code / Codex. New commands `wanderbooks books
back-up` and `wanderbooks connect claude|codex`. The scaffold
(`new`/`init` and its templates) is gone; install.sh needs no sudo.

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 019d8d76a3
commit 3cf359a0cd
28 files changed
+1976 -1931

No files matched your search

-458
View File
@@ -1,458 +0,0 @@
//! Integration tests for `wanderbooks new`: one command lands a complete
//! books dir with the MCP server registered for it, the TOML and JSON
//! parse, and re-running is refused without `--force` (which never
//! touches the company profile).
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
fn bin() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_wanderbooks"))
}
/// Stand-ins for the installed binaries, so tests never run cargo install.
fn fake_bins(tmp: &Path) -> PathBuf {
let dir = tmp.join("bin");
fs::create_dir_all(&dir).unwrap();
for b in ["ledger-mcp", "ledger-gui"] {
fs::write(dir.join(b), "#!/bin/sh\n").unwrap();
}
dir
}
fn run(tmp: &Path, args: &[&str]) -> (bool, String) {
let path = format!(
"{}:{}",
fake_bins(tmp).display(),
std::env::var("PATH").unwrap_or_default()
);
let out = Command::new(bin())
.args(args)
.arg("--no-start")
.env("PATH", path)
.output()
.expect("run wanderbooks");
let mut s = String::from_utf8_lossy(&out.stdout).into_owned();
s.push_str(&String::from_utf8_lossy(&out.stderr));
(out.status.success(), s)
}
fn new(tmp: &Path, books: &Path, extra: &[&str]) -> (bool, String) {
let mut args = vec!["new", books.to_str().unwrap()];
args.extend(extra);
run(tmp, &args)
}
#[test]
fn scaffold_lands_complete() {
let tmp = tempfile::tempdir().unwrap();
let books = tmp.path().join("acme-books");
let (ok, out) = new(tmp.path(), &books, &[]);
assert!(ok, "new should succeed: {out}");
for f in [
"justfile",
"AGENTS.md",
"CLAUDE.md",
"wanderbooks.toml",
".gitignore",
".mcp.json",
".claude/settings.json",
".codex/hooks.json",
] {
let p = books.join(f);
assert!(p.exists(), "{f} exists");
assert!(!fs::read_to_string(&p).unwrap().is_empty(), "{f} non-empty");
}
assert!(books.join("books-data").is_dir(), "books-data dir exists");
assert!(books.join(".git").is_dir(), "fresh dir gets git init");
let log = Command::new("git")
.args(["log", "--oneline"])
.current_dir(&books)
.output()
.unwrap();
assert!(
String::from_utf8_lossy(&log.stdout).contains("init"),
"scaffold committed"
);
}
#[test]
fn the_server_is_registered_for_this_dir() {
let tmp = tempfile::tempdir().unwrap();
let books = tmp.path().join("b");
assert!(new(tmp.path(), &books, &[]).0);
let books = books.canonicalize().unwrap();
let mcp: serde_json::Value =
serde_json::from_str(&fs::read_to_string(books.join(".mcp.json")).unwrap()).unwrap();
let server = &mcp["mcpServers"]["wanderbooks"];
assert!(server["command"].as_str().unwrap().ends_with("ledger-mcp"));
assert_eq!(
server["args"],
serde_json::json!(["--data-dir", books.join("books-data").to_string_lossy()])
);
let settings: serde_json::Value =
serde_json::from_str(&fs::read_to_string(books.join(".claude/settings.json")).unwrap())
.unwrap();
assert_eq!(
settings["enabledMcpjsonServers"],
serde_json::json!(["wanderbooks"])
);
// Every session starts by checking the viewer against open_at_start.
let hook = settings["hooks"]["SessionStart"][0]["hooks"][0]["command"]
.as_str()
.expect("SessionStart hook command");
assert!(hook.ends_with("just session-start"), "{hook}");
let codex: serde_json::Value =
serde_json::from_str(&fs::read_to_string(books.join(".codex/hooks.json")).unwrap())
.unwrap();
let hook = codex["hooks"]["SessionStart"][0]["hooks"][0]["command"]
.as_str()
.expect("Codex SessionStart hook command");
assert!(hook.ends_with("just session-start"), "{hook}");
// The justfile opens this dir's books in the GUI.
let just = fs::read_to_string(books.join("justfile")).unwrap();
assert!(just.contains("ledger-gui\" --data-dir \"{{justfile_directory()}}/books-data\""));
for recipe in [
"agent:",
"gui:",
"gui-fg:",
"gui-status:",
"session-start:",
"backup:",
"update:",
] {
assert!(just.contains(recipe), "justfile has {recipe}");
}
assert_eq!(
fs::read_to_string(books.join("CLAUDE.md")).unwrap().trim(),
"@AGENTS.md"
);
let toml: toml::Value =
toml::from_str(&fs::read_to_string(books.join("wanderbooks.toml")).unwrap()).unwrap();
assert_eq!(toml["books"]["data_dir"].as_str(), Some("books-data"));
}
#[test]
fn agents_md_carries_the_contract() {
let tmp = tempfile::tempdir().unwrap();
let books = tmp.path().join("b");
assert!(new(tmp.path(), &books, &[]).0);
let md = fs::read_to_string(books.join("AGENTS.md")).unwrap();
for phrase in [
"preview",
"Posted is forever",
"reversing entries",
"receipt",
"onboard",
"chart_template",
"to the cent",
"just gui",
"just gui-status",
"open_at_start",
] {
assert!(md.contains(phrase), "AGENTS.md mentions {phrase:?}");
}
}
/// The agent reaches the books only through the MCP tools. The scaffolded
/// AGENTS.md once said "the journal is the evidence — read it", and an
/// agent `cat`ed the journal file instead of asking the books.
#[test]
fn agents_md_reaches_the_books_only_through_tools() {
let tmp = tempfile::tempdir().unwrap();
let books = tmp.path().join("b");
assert!(new(tmp.path(), &books, &[]).0);
let md = fs::read_to_string(books.join("AGENTS.md")).unwrap();
for phrase in [
"only through the",
"wanderbooks MCP tools",
"Never open, list or parse",
"books.get_entry",
"books.search_entries",
"general_ledger",
"can't answer, say so.",
] {
assert!(md.contains(phrase), "AGENTS.md says {phrase:?}");
}
// Nothing points the agent at the books' files.
for path in ["books-data", "journal.jsonl", "wanderbooks.db", "read it"] {
assert!(!md.contains(path), "AGENTS.md never mentions {path:?}");
}
}
#[test]
fn rerun_refused_without_force_and_never_clobbers_the_profile() {
let tmp = tempfile::tempdir().unwrap();
let books = tmp.path().join("b");
assert!(new(tmp.path(), &books, &[]).0);
let (ok, out) = new(tmp.path(), &books, &[]);
assert!(!ok, "second new without --force fails");
assert!(out.contains("--force"), "error points at --force: {out}");
fs::write(
books.join("wanderbooks.toml"),
"[company]\nname = \"Acme LLC\"\n",
)
.unwrap();
let (ok, _) = new(tmp.path(), &books, &["--force"]);
assert!(ok, "second new with --force succeeds");
assert!(
fs::read_to_string(books.join("wanderbooks.toml"))
.unwrap()
.contains("Acme LLC"),
"--force rewrites the scaffold, never the company profile"
);
}
#[test]
fn init_is_an_alias_and_respects_an_existing_repo() {
let tmp = tempfile::tempdir().unwrap();
let books = tmp.path().join("b");
fs::create_dir_all(&books).unwrap();
assert!(
Command::new("git")
.args(["init", "-q"])
.current_dir(&books)
.status()
.unwrap()
.success()
);
let (ok, out) = run(tmp.path(), &["init", books.to_str().unwrap()]);
assert!(ok, "{out}");
assert!(books.join("wanderbooks.toml").exists());
}
#[test]
fn codex_gets_its_own_named_server() {
let tmp = tempfile::tempdir().unwrap();
let bin = fake_bins(tmp.path());
let log = tmp.path().join("codex-args");
// A stand-in codex that records how it was called.
fs::write(
bin.join("codex"),
format!("#!/bin/sh\necho \"$@\" > {}\n", log.display()),
)
.unwrap();
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(bin.join("codex"), fs::Permissions::from_mode(0o755)).unwrap();
let books = tmp.path().join("Acme Books");
let (ok, out) = new(tmp.path(), &books, &["--codex"]);
assert!(ok, "{out}");
let args = fs::read_to_string(&log).unwrap();
let books = books.canonicalize().unwrap();
assert!(
args.starts_with("mcp add wanderbooks-acme-books -- "),
"{args}"
);
assert!(
args.trim_end().ends_with(&format!(
"--data-dir {}",
books.join("books-data").display()
)),
"{args}"
);
let just = fs::read_to_string(books.join("justfile")).unwrap();
assert!(just.contains("codex mcp add \"wanderbooks-acme-books\""));
assert!(out.contains("just codex"), "{out}");
}
/// The `wanderbooks.toml` a fresh `new` writes.
fn scaffolded_toml(tmp: &Path) -> (PathBuf, String) {
let books = tmp.join("b");
assert!(new(tmp, &books, &[]).0);
let path = books.join("wanderbooks.toml");
let text = fs::read_to_string(&path).unwrap();
(path, text)
}
/// The example a commented line holds, if it is one: `# key = value`, or
/// `# [section]` / `# [[section]]`. Prose comments are not examples.
fn example(line: &str) -> Option<&str> {
let rest = line.strip_prefix("# ")?;
let is_key = |k: &str| !k.is_empty() && k.bytes().all(|b| b.is_ascii_lowercase() || b == b'_');
let header = rest
.trim_start_matches('[')
.trim_end_matches(']')
.split('.')
.all(is_key);
let assignment = rest
.split_once(" = ")
.is_some_and(|(k, v)| is_key(k) && !v.trim().is_empty());
((rest.starts_with('[') && rest.ends_with(']') && header) || assignment).then_some(rest)
}
/// The template with the examples `pick` chooses uncommented.
fn uncomment(text: &str, pick: impl Fn(&str) -> bool) -> String {
text.lines()
.map(|l| match example(l) {
Some(ex) if pick(ex) => ex,
_ => l,
})
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn the_scaffolded_toml_loads_with_the_defaults() {
let tmp = tempfile::tempdir().unwrap();
let (path, text) = scaffolded_toml(tmp.path());
let s = ledger_config::load(&path).expect("scaffolded wanderbooks.toml loads");
let c = &s.company;
assert_eq!(c.name, "Unnamed company", "name is left for onboarding");
assert_eq!(c.fiscal_year_start, (1, 1));
assert_eq!(c.basis, "Accrual");
assert!(c.members.is_empty());
assert_eq!(c.tax_form, None);
assert_eq!(c.close_into, None);
assert!(c.close_sweep.is_empty());
assert_eq!(
c.profile,
[
("base_currency".to_string(), "USD".to_string()),
("fiscal_year_start".to_string(), "01-01".to_string()),
]
);
assert_eq!(s.receipt_threshold.as_deref(), Some("25.00"));
assert!(s.cash_accounts.is_empty());
assert!(s.financing_accounts.is_empty());
assert!(s.investing_accounts.is_empty());
let toml: toml::Value = toml::from_str(&text).unwrap();
assert_eq!(toml["books"]["data_dir"].as_str(), Some("books-data"));
// The justfile's session-start reads this line with sed, from column 0.
assert!(
text.lines().any(|l| l == "open_at_start = \"ask\""),
"{text}"
);
assert!(text.contains("SSN"), "the template warns off storing SSNs");
}
#[test]
fn every_example_in_the_toml_is_valid() {
let tmp = tempfile::tempdir().unwrap();
let (_, text) = scaffolded_toml(tmp.path());
let examples = text.lines().filter_map(example).count();
assert!(examples >= 20, "only {examples} examples found");
// close_into is for a single owner and refused with members, so it
// gets a pass of its own; together the two passes cover every example.
let single_owner = |ex: &str| ex.starts_with("close_into ");
let all = ledger_config::parse(&uncomment(&text, |ex| !single_owner(ex)))
.unwrap_or_else(|e| panic!("examples (without close_into) parse: {e}"));
let c = &all.company;
assert_eq!(c.name, "Acme Widgets LLC");
assert_eq!(c.tax_form, Some(ledger_core::TaxForm::Form1065));
assert_eq!(c.members.len(), 2);
assert_eq!(c.members[1].distribution_account.as_deref(), Some("3101"));
assert_eq!(c.close_sweep, ["3100"]);
assert!(c.profile.iter().any(|(k, _)| k == "ein"));
assert_eq!(all.cash_accounts, ["1000", "1010"]);
assert_eq!(all.financing_accounts, ["2100"]);
assert_eq!(all.investing_accounts, ["1400"]);
let single = ledger_config::parse(&uncomment(&text, single_owner))
.unwrap_or_else(|e| panic!("close_into example parses: {e}"));
assert_eq!(single.company.close_into.as_deref(), Some("3000"));
assert!(
ledger_config::parse(&uncomment(&text, |_| true)).is_err(),
"close_into with members is refused, as the template says"
);
}
/// The serde field names of `struct name` in ledger-config's source.
fn fields(src: &str, name: &str) -> Vec<String> {
let start = src
.find(&format!("struct {name} {{"))
.unwrap_or_else(|| panic!("ledger-config has struct {name}"));
let body = &src[start..];
let body = &body[..body.find("\n}").expect("struct ends")];
let found: Vec<String> = body
.lines()
.skip(1)
.map(str::trim)
.filter(|l| !l.starts_with("//") && !l.starts_with("#["))
.filter_map(|l| l.split_once(':'))
.map(|(k, _)| k.trim_start_matches("pub ").to_string())
.collect();
assert!(!found.is_empty(), "struct {name} has fields");
found
}
#[test]
fn the_toml_shows_every_setting_the_config_reads() {
let tmp = tempfile::tempdir().unwrap();
let (_, text) = scaffolded_toml(tmp.path());
// (section, key) for every assignment, examples included.
let mut section = String::new();
let mut present = Vec::new();
for line in uncomment(&text, |_| true).lines() {
if line.starts_with('[') {
section = line.trim_matches(['[', ']']).to_string();
} else if let Some((k, _)) = line.split_once(" = ") {
present.push((section.clone(), k.to_string()));
}
}
let has = |s: &str, k: &str| present.iter().any(|(ps, pk)| ps == s && pk == k);
// Read from the config crate's serde structs, so a new setting there
// fails here until the template shows it.
let src = include_str!("../../ledger-config/src/lib.rs");
assert_eq!(fields(src, "BooksToml"), ["company", "books", "vault"]);
for (strukt, sect) in [
("CompanyToml", "company"),
("MemberToml", "company.members"),
("BooksSection", "books"),
("VaultSection", "vault"),
] {
for key in fields(src, strukt) {
if strukt == "CompanyToml" && key == "members" {
assert!(text.contains("# [[company.members]]"));
continue;
}
assert!(has(sect, &key), "[{sect}] {key} is in the template");
}
}
// Read elsewhere: the justfile's session-start (main.rs), and the two
// the scaffold has always written live.
for (sect, key) in [
("viewer", "open_at_start"),
("company", "base_currency"),
("books", "data_dir"),
] {
assert!(has(sect, key), "[{sect}] {key} is in the template");
}
}
/// Moving the scaffolded settings file, and every example in it, into
/// books: each imports, reads as the file does (the import refuses
/// otherwise), and exporting it back and importing the export changes
/// nothing (plan/served.md P1: toml_round_trip_zero_events).
#[test]
fn the_toml_and_its_examples_import_and_round_trip() {
use ledger_core::settings::{Settings, Source};
let tmp = tempfile::tempdir().unwrap();
let (_, text) = scaffolded_toml(tmp.path());
let single_owner = |ex: &str| ex.starts_with("close_into ");
let source = Source {
decided_by: "onboarding".into(),
decided_on: chrono_date(),
citation: None,
note: None,
};
for file in [
text.clone(),
uncomment(&text, |ex| !single_owner(ex)),
uncomment(&text, single_owner),
] {
let import = ledger_config::codec::import(&file, None, &Settings::default(), "USD")
.unwrap_or_else(|e| panic!("imports: {e}\n{file}"));
assert!(import.unknown.is_empty(), "{:?}", import.unknown);
let mut books = Settings::default();
let changes = books.check_changes(&import.changes, &source).unwrap();
books.apply(&changes, &source, 1).unwrap();
let export = ledger_config::codec::export(&books, "USD");
let again = ledger_config::codec::import(&export, None, &books, "USD")
.unwrap_or_else(|e| panic!("the export imports: {e}\n{export}"));
assert!(again.changes.is_empty(), "{:?}\n{export}", again.changes);
}
}
fn chrono_date() -> chrono::NaiveDate {
chrono::NaiveDate::from_ymd_opt(2026, 10, 17).unwrap()
}
+14
View File
@@ -150,6 +150,20 @@ fn a_mode_refuses_what_it_does_not_know() {
assert_eq!(out.status.code(), Some(2), "{out:?}");
}
#[test]
fn the_books_folder_scaffold_is_gone_and_says_so() {
for gone in ["new", "init"] {
let out = wanderbooks().args([gone, "acme"]).output().unwrap();
assert_eq!(out.status.code(), Some(2), "{gone}: {out:?}");
let err = String::from_utf8_lossy(&out.stderr);
assert!(
err.contains(&format!("`wanderbooks {gone}` is gone")),
"{err}"
);
assert!(err.contains("wanderbooks books create ID"), "{err}");
}
}
#[test]
fn a_mode_comes_first() {
for mode in ["gui", "dev-stdio", "server"] {
+247 -13
View File
@@ -170,7 +170,7 @@ impl Drop for Home {
let deadline = Instant::now() + Duration::from_secs(10);
loop {
let mut found = false;
for mode in ["serve", "mcp", "helper", "host", "server"] {
for mode in ["serve", "mcp", "host", "server"] {
for pid in self.pids(mode) {
found = true;
// SAFETY: a process of this test's own root.
@@ -922,19 +922,15 @@ fn the_lease_socket_takes_only_the_owners_intents() {
),
(
"{\"proto\":1,\"intent\":{\"back_up_now\":{\"book\":\"acme\"}}}\n",
"books.back_up_now",
"not understood",
),
(
"{\"proto\":1,\"intent\":{\"restore\":{\"source\":\"/x\",\"id\":\"acme\",\"replace\":true}}}\n",
"wanderbooks books restore",
"{\"proto\":1,\"intent\":{\"restore\":{\"source\":\"/x\",\"id\":\"acme\"}}}\n",
"not understood",
),
(
"{\"proto\":1,\"intent\":{\"new_books\":{\"id\":\"beta\",\"currency\":\"USD\"}}}\n",
"new books beta made",
),
(
"{\"proto\":1,\"intent\":{\"new_books\":{\"id\":\"beta\",\"currency\":\"USD\"}}}\n",
"already books called",
"not understood",
),
] {
let answer = home.lease_line(line);
@@ -946,7 +942,7 @@ fn the_lease_socket_takes_only_the_owners_intents() {
}
let out = home.books(&["list"]);
assert!(
String::from_utf8_lossy(&out.stdout).contains("beta open"),
!String::from_utf8_lossy(&out.stdout).contains("beta"),
"{out:?}"
);
}
@@ -1920,9 +1916,8 @@ fn the_books_command_takes_its_flags_and_asks_before_adopting() {
/// A program that starts the bridge with its stderr closed (some launchers
/// do) still gets a working bridge, its descriptor 2 on /dev/null: Rust's
/// runtime opens it before main, so the helper's socket can't take its
/// number and be sent the error messages (asked in slice 3's mutation
/// testing: the helper's descriptor 3 then is never its own pair's end).
/// runtime opens it before main, so no socket the bridge opens can take
/// its number and be sent the error messages.
#[test]
fn a_bridge_started_without_stderr_keeps_its_descriptors_apart() {
use std::os::unix::process::CommandExt;
@@ -2464,3 +2459,242 @@ fn a_backup_is_restored_from_the_command_line() {
assert_eq!(start["head"]["seq"].as_u64(), Some(head), "{start}");
fresh.end();
}
/// A stand-in for an agent's own command line (`claude`, `codex`): it
/// notes each call's arguments, a line each.
fn fake_agent(home: &Home, name: &str) -> (PathBuf, PathBuf) {
use std::os::unix::fs::PermissionsExt;
let bin = home.dir.path().join("agent-bin");
std::fs::create_dir_all(&bin).unwrap();
let log = home.dir.path().join(format!("{name}.log"));
let script = bin.join(name);
std::fs::write(
&script,
format!("#!/bin/sh\nprintf '%s\\n' \"$*\" >> '{}'\n", log.display()),
)
.unwrap();
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
(bin, log)
}
#[test]
fn an_agent_is_connected_and_reaches_the_books_by_what_it_was_given() {
let home = Home::new("connect");
let out = home.books(&["create", "beta"]);
assert!(out.status.success(), "{out:?}");
let (bin, log) = fake_agent(&home, "claude");
let connect = |agent: &str, path: &Path| {
home.cmd("connect")
.args([agent, "--book", "beta"])
.env("PATH", format!("{}:/usr/bin:/bin", path.display()))
.stdin(Stdio::null())
.output()
.unwrap()
};
let out = connect("claude", &bin);
assert!(out.status.success(), "{out:?}");
let said = String::from_utf8_lossy(&out.stdout);
assert!(said.contains("Made the books beta' agent token"), "{said}");
assert!(
said.contains("Claude Code reaches the books beta"),
"{said}"
);
let calls = std::fs::read_to_string(&log).unwrap();
let lines: Vec<&str> = calls.lines().collect();
assert_eq!(lines[0], "mcp remove --scope user wanderbooks-beta");
let add = lines[1];
assert!(
add.starts_with("mcp add --scope user wanderbooks-beta -- "),
"{add}"
);
assert!(add.contains(" mcp --book beta --root "), "{add}");
assert!(!said.contains("wb_"), "a token's text was shown");
// The command it was given reaches the books (with the token kept).
let given: Vec<&str> = add.split(" -- ").nth(1).unwrap().split(' ').collect();
let mut c = Command::new(given[0]);
c.args(&given[1..])
.env_clear()
.env("PATH", "/usr/bin:/bin")
.env("HOME", home.dir.path());
let mut agent = Agent::from(c);
agent.initialize();
let start = agent.call("books.start", json!({})).unwrap();
assert!(start["head"]["seq"].as_u64().is_some(), "{start}");
agent.end();
// Again: the token kept is used, not made anew.
let out = connect("claude", &bin);
assert!(out.status.success(), "{out:?}");
assert!(
!String::from_utf8_lossy(&out.stdout).contains("Made the books"),
"{out:?}"
);
// Codex: its own list, no scope.
let (bin, log) = fake_agent(&home, "codex");
let out = connect("codex", &bin);
assert!(out.status.success(), "{out:?}");
let calls = std::fs::read_to_string(&log).unwrap();
assert!(
calls
.lines()
.nth(1)
.unwrap()
.starts_with("mcp add wanderbooks-beta -- "),
"{calls}"
);
// An agent that isn't installed: said. (Never where a real one could
// be found and run: skipped on a machine with one installed system-wide.)
if ["/usr/local/bin/claude", "/opt/homebrew/bin/claude"]
.iter()
.any(|p| Path::new(p).exists())
{
return;
}
let empty = home.dir.path().join("empty-bin");
std::fs::create_dir_all(&empty).unwrap();
let out = home
.cmd("connect")
.args(["claude", "--book", "beta"])
.env("PATH", empty.display().to_string())
.stdin(Stdio::null())
.output()
.unwrap();
assert_eq!(out.status.code(), Some(1));
assert!(
String::from_utf8_lossy(&out.stderr).contains("isn't installed"),
"{out:?}"
);
}
#[test]
fn books_are_backed_up_now_from_the_command_line() {
let home = Home::new("back-up");
home.create("acme");
// None set up: said, and failing.
let out = home.books(&["back-up", "acme"]);
assert_eq!(out.status.code(), Some(1), "{out:?}");
assert!(
String::from_utf8_lossy(&out.stderr).contains("No backups are set up"),
"{out:?}"
);
let disk = home.dir.path().join("disk");
std::fs::create_dir(&disk).unwrap();
let mut a = Agent::start(&home, "acme");
a.initialize();
a.call(
"books.set_setting",
json!({"changes": [{"key": "backup.folder", "value": disk.display().to_string()}],
"decided_by": "Test Owner"}),
)
.unwrap();
let out = home.books(&["back-up", "acme"]);
assert!(out.status.success(), "{out:?}");
assert!(
String::from_utf8_lossy(&out.stdout).contains("Backed up to the backup folder"),
"{out:?}"
);
assert!(disk.join("acme.git").join("HEAD").is_file());
// A folder gone since: said, and --new-disk begins it again.
std::fs::rename(disk.join("acme.git"), home.dir.path().join("away.git")).unwrap();
let out = home.books(&["back-up", "acme"]);
assert_eq!(out.status.code(), Some(1), "{out:?}");
assert!(
String::from_utf8_lossy(&out.stderr).contains("holds no backup"),
"{out:?}"
);
let out = home.books(&["back-up", "acme", "--new-disk"]);
assert!(out.status.success(), "{out:?}");
assert!(disk.join("acme.git").join("HEAD").is_file());
a.end();
}
/// The viewer's owner (ledger-cli's `ViewerOwner`, over these ledger-host
/// functions): its hold is a lease of kind app; new books are made by the
/// server; "Close the books" ends every lease, the viewer's hold with it.
/// (A `serve` holds the books first: run in this test's process, a hold
/// that had to start the host would start this test's own program.)
#[test]
fn the_viewers_hold_new_books_and_close_the_books() {
use ledger_host::commands::{Common, close_books, hold_for_viewer, new_books};
let home = Home::new("viewer-owner");
home.create("acme");
let serving = serve(&home);
let common = Common {
root: Some(home.root()),
knobs: ledger_host::knobs::Knobs::parse(KNOBS).unwrap(),
};
let hold = hold_for_viewer(&common, Some("acme".into()));
assert!(
wait_for(20, || home
.lease_line("{\"proto\":1,\"leases\":null}\n")
.contains("\"app\"")),
"the viewer's lease never showed"
);
assert_eq!(
new_books(&common, "beta", "USD").unwrap(),
"Made new books \"beta\" (USD)."
);
assert!(
new_books(&common, "beta", "USD")
.unwrap_err()
.contains("already books called")
);
let out = home.books(&["list"]);
assert!(
String::from_utf8_lossy(&out.stdout).contains("beta open"),
"{out:?}"
);
let said = close_books(&common).unwrap();
assert!(said.starts_with("The books are closing"), "{said}");
// The viewer's hold lets go, for good (closed by the owner).
let (tx, rx) = mpsc::channel();
std::thread::spawn(move || {
let _ = tx.send(hold.join().is_ok());
});
assert_eq!(rx.recv_timeout(Duration::from_secs(30)), Ok(true));
serving.expect("closed", 30);
assert!(home.wait_closed(30));
// Closed: said so.
assert_eq!(close_books(&common).unwrap(), "The books are closed.");
}
#[test]
fn connect_makes_anew_a_token_the_keyring_lost_and_says_when_an_agent_refuses() {
use std::os::unix::fs::PermissionsExt;
let home = Home::new("connect-lost");
home.create("acme");
// The server keeps the books' agent token; this computer's keyring
// lost it.
std::fs::remove_dir_all(home.run_dir().join("test-keyring")).unwrap();
let (bin, _log) = fake_agent(&home, "claude");
let connect = |path: &Path| {
home.cmd("connect")
.args(["claude", "--book", "acme"])
.env("PATH", format!("{}:/usr/bin:/bin", path.display()))
.stdin(Stdio::null())
.output()
.unwrap()
};
let out = connect(&bin);
assert!(out.status.success(), "{out:?}");
assert!(
String::from_utf8_lossy(&out.stdout).contains("Made the books acme' agent token"),
"{out:?}"
);
// The agent's own command refuses: said, with what it said.
let refusing = home.dir.path().join("refusing-bin");
std::fs::create_dir_all(&refusing).unwrap();
let script = refusing.join("claude");
std::fs::write(
&script,
"#!/bin/sh\n[ \"$2\" = add ] && { echo 'no room at the inn' >&2; exit 1; }\nexit 0\n",
)
.unwrap();
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
let out = connect(&refusing);
assert_eq!(out.status.code(), Some(1), "{out:?}");
let err = String::from_utf8_lossy(&out.stderr);
assert!(
err.contains("Claude Code didn't add the books: no room at the inn"),
"{err}"
);
}