Files
wanderbooks/crates/ledger-gui/tests/read_only.rs
T
swaitsandClaude Opus 5.5 a833229d87 tests: every test dir is removed when its test ends, pass or panic
Many test helpers removed their temp dir only before the test, or only
on pass, so runs left them behind: 117,000 filled /tmp (RAM here) and
stopped a mutation run. Each now holds a guard that removes the dir on
drop, unwind included; servers started on a test dir hold its guard
until they stop. A full cargo test --workspace leaves 0 dirs (859
before). just sweep-test-tmp stays for runs killed mid-test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-17 20:47:44 -06:00

225 lines
7.7 KiB
Rust

//! The viewer is read-only: it never writes into the books.
//!
//! ```text
//! GOAL: Stephen, Kat, and the CPA can open the books in the viewer
//! without any chance of changing them.
//! SOURCE: DECISION (AGENTS.md: "read-only and live ... It never writes;
//! changes go through you").
//! INVARIANTS:
//! 1. With every file and folder of the books made read-only on disk,
//! the viewer still loads, follows live, and renders every page
//! (receipts included), and reports the books whole.
//! 2. Afterwards every file holds the same bytes, size, and
//! modification time, and no file has appeared or gone; on writable
//! books too (no SQLite -wal or -shm created beside the database).
//! ```
use egui_kittest::Harness;
use ledger_gui::data::{self, Loader, Update};
use ledger_gui::nav::{ReportKind, Route};
use ledger_gui::{App, Source};
use std::collections::BTreeMap;
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
type Tree = BTreeMap<PathBuf, (Vec<u8>, u64, SystemTime)>;
fn tree(root: &Path) -> Tree {
fn walk(root: &Path, d: &Path, out: &mut Tree) {
for e in std::fs::read_dir(d).unwrap() {
let p = e.unwrap().path();
let m = std::fs::symlink_metadata(&p).unwrap();
if m.is_dir() {
walk(root, &p, out);
} else {
out.insert(
p.strip_prefix(root).unwrap().to_path_buf(),
(std::fs::read(&p).unwrap(), m.len(), m.modified().unwrap()),
);
}
}
}
let mut out = BTreeMap::new();
walk(root, root, &mut out);
out
}
/// Every file 0444 and folder 0555 (`read_only`), or back to 0644/0755.
fn set_read_only(root: &Path, read_only: bool) {
fn walk(p: &Path, ro: bool) {
let m = std::fs::symlink_metadata(p).unwrap();
if m.is_dir() {
if !ro {
std::fs::set_permissions(p, std::fs::Permissions::from_mode(0o755)).unwrap();
}
for e in std::fs::read_dir(p).unwrap() {
walk(&e.unwrap().path(), ro);
}
if ro {
std::fs::set_permissions(p, std::fs::Permissions::from_mode(0o555)).unwrap();
}
} else {
let mode = if ro { 0o444 } else { 0o644 };
std::fs::set_permissions(p, std::fs::Permissions::from_mode(mode)).unwrap();
}
}
walk(root, read_only);
}
/// Makes the books writable again and removes them when dropped, so a
/// run that passes or fails never leaves a folder behind, let alone one no
/// one can delete.
struct Writable(PathBuf);
impl Drop for Writable {
fn drop(&mut self) {
if self.0.exists() {
set_read_only(&self.0, false);
let _ = std::fs::remove_dir_all(&self.0);
}
}
}
/// Folders left read-only by a killed run (no destructor ran): made
/// writable and removed once their process is gone.
fn sweep_leftovers() {
let Ok(entries) = std::fs::read_dir(std::env::temp_dir()) else {
return;
};
for e in entries.flatten() {
let name = e.file_name().to_string_lossy().to_string();
let Some(pid) = name.strip_prefix("ledger-gui-ro-") else {
continue;
};
if Path::new("/proc").join(pid).exists() {
continue; // still running (or the pid was reused): leave it
}
set_read_only(&e.path(), false);
let _ = std::fs::remove_dir_all(e.path());
}
}
#[test]
fn the_viewer_never_writes_into_the_books() {
sweep_leftovers();
let dir = std::env::temp_dir().join(format!("ledger-gui-ro-{}", std::process::id()));
if dir.exists() {
set_read_only(&dir, false);
std::fs::remove_dir_all(&dir).unwrap();
}
let _restore = Writable(dir.clone());
let today = chrono::NaiveDate::from_ymd_opt(2026, 10, 12).unwrap();
ledger_gui::demo::build(&dir, today).unwrap();
let source = Source {
data_dir: dir.join("books-data"),
books_toml: Some(dir.join("wanderbooks.toml")),
today: Some(today),
};
set_read_only(&dir, true);
let before = tree(&dir);
// Load once; the books are whole (the database matches the journal).
let books = data::load_once(&source).expect("loads from read-only books");
assert!(
matches!(books.integrity.disk, ledger_store::DiskCheck::Matches),
"read-only books reported as {:?}",
books.integrity.disk
);
assert!(books.integrity.file_failures.is_empty());
let entry = books.entries.last().map(|e| e.id).unwrap();
let cash = books.cash_accounts[0];
let doc = books.attachments.last().map(|a| a.id).unwrap();
let event = books.seq();
let asset = books
.ledger
.registered_assets()
.next()
.map(|a| a.id)
.unwrap();
let party = books.ledger.parties().next().map(|p| p.id).unwrap();
drop(books);
// Follow live for a moment.
let loader = Loader::spawn(source.clone(), egui::Context::default());
let deadline = std::time::Instant::now() + Duration::from_secs(20);
let mut live = false;
while std::time::Instant::now() < deadline {
match loader.wait(Duration::from_millis(200)) {
Some(Update::Books(_)) => {
live = true;
break;
}
Some(Update::Error(e)) => panic!("live loader on read-only books: {e}"),
_ => {}
}
}
assert!(live, "the live loader never delivered the books");
// Render every page.
let routes = [
Route::Overview,
Route::Transactions,
Route::Entry(entry),
Route::Cash,
Route::Profit,
Route::Reports(ReportKind::ProfitAndLoss),
Route::Reports(ReportKind::BalanceSheet),
Route::Reports(ReportKind::CashFlow),
Route::Reports(ReportKind::TrialBalance),
Route::Reports(ReportKind::GeneralLedger),
Route::Reports(ReportKind::TaxSummary),
Route::Reports(ReportKind::Vendor1099),
Route::Reports(ReportKind::ArAging),
Route::Reports(ReportKind::ApAging),
Route::Reports(ReportKind::MembersEquity),
Route::Reports(ReportKind::FixedAssetRegister),
Route::Accounts,
Route::Account(cash),
Route::Assets,
Route::Asset(asset),
Route::Parties,
Route::Party(party),
Route::Document(doc),
Route::JournalEvent(event),
Route::Documents,
Route::Company,
Route::Journal,
];
for route in routes {
let books = data::load_once(&source).unwrap();
let mut harness = Harness::builder()
.with_size(egui::vec2(1280.0, 800.0))
.build_eframe(|cc| App::with_books(&cc.egui_ctx, books, false, route.clone()));
harness.run_steps(3);
let ctx = harness.ctx.clone();
harness.state_mut().settle(&ctx);
harness.run_steps(2);
}
let after = tree(&dir);
set_read_only(&dir, false);
let changed: Vec<_> = before
.keys()
.chain(after.keys())
.filter(|k| before.get(*k) != after.get(*k))
.collect();
assert!(changed.is_empty(), "the viewer changed {changed:?}");
// On writable books too: loading and checking creates nothing (no
// SQLite -wal or -shm beside the database).
let before = tree(&dir);
let books = data::load_once(&source).unwrap();
assert!(matches!(
books.integrity.disk,
ledger_store::DiskCheck::Matches
));
drop(books);
let after = tree(&dir);
assert_eq!(
before.keys().collect::<Vec<_>>(),
after.keys().collect::<Vec<_>>(),
"the viewer created or removed files in writable books"
);
assert!(before == after, "the viewer changed writable books");
}