//! Approvals answered from Mattermost end to end: `!approve` and `!deny` from an approver in the //! approval's thread reach `brokerd`, the post shows what came of it, typing pauses while the turn //! waits, and everything else is answered with its reason; the notice comes only when `brokerd` //! never brought the approval (M4b spec, section 7). Do not edit. #[path = "support/brokerd_client.rs"] mod brokerd_client; #[path = "support/fake_loop.rs"] mod fake_loop; #[path = "support/fake_mm.rs"] mod fake_mm; #[path = "support/gateway.rs"] mod gateway; #[path = "support/tmp.rs"] mod tmp; use std::path::PathBuf; use std::sync::{Mutex, mpsc}; use std::time::{Duration, Instant}; use brokerd_client::{Asking, ask, pending}; use bxcore::{GatewayOutcome, Timestamp, Turn, TurnEvent}; use fake_loop::{done, event, serve_loop}; use fake_mm::{DM, EVE, FakeMm, KYLE, WsPeer, id, post}; use gateway::{Running, WAIT, loop_dir, start}; use gatewayd::approvals::NOT_APPROVER; use gatewayd::config::Config; use tmp::TempDir; /// KYLE and EVE may talk; only KYLE approves. fn config(home: &TempDir, url: &str) -> Config { let text = format!( "[mattermost]\nurl = \"{url}\"\n[secrets.mattermost_token]\nenv = \"NOT_READ_BY_RUN\"\n[allow]\nusers = [\"{KYLE}\", \"{EVE}\"]\n[approvals]\nusers = [\"{KYLE}\"]\n[paths]\nhome = \"{}\"\n[limits]\ntyping_every_ms = 100\n", home.path().display() ); Config::parse(&text).unwrap() } /// A loop whose first turn waits for `go`, then sends `before` and answers "done". fn held(home: &TempDir, before: Vec) -> (mpsc::Receiver, mpsc::Sender<()>) { loop_dir(home); let (go_tx, go_rx) = mpsc::channel::<()>(); let go = Mutex::new(go_rx); let before = Mutex::new(Some(before)); let turns = serve_loop(&home.path().join("run/loop/loop.sock"), move |_, _| { let _ = go.lock().unwrap().recv_timeout(Duration::from_secs(20)); let mut replies: Vec<_> = before .lock() .unwrap() .take() .unwrap_or_default() .into_iter() .map(event) .collect(); replies.push(done("done")); replies }); (turns, go_tx) } struct Stage { mm: FakeMm, _running: Running, ws: WsPeer, go: mpsc::Sender<()>, socket: PathBuf, root: String, // Declared last so it is dropped last: fields drop in order, and the home must outlive // everything that may still write into it (tip T44). _home: TempDir, } /// gatewayd up, a direct message from KYLE whose turn is held, and the approval's socket. fn stage(tag: &str, before: Vec) -> Stage { let home = TempDir::new(tag); let (turns, go) = held(&home, before); let mm = FakeMm::start(); let running = start(config(&home, &mm.url())); let mut ws = mm.next_ws(WAIT); running.wait_log("gatewayd: connected to "); let root = id('p', 1); ws.posted(&post(&root, KYLE, DM, "", "read my notes", 5), "D"); turns.recv_timeout(WAIT).expect("the turn started"); let socket = home.path().join("run/gateway/gateway.sock"); Stage { _home: home, mm, _running: running, ws, go, socket, root, } } impl Stage { fn ask(&self, approval: u64) -> Asking { ask( &self.socket, pending(approval, &format!("mm-{}", self.root), r#"{"path":"/n/a"}"#), ) .unwrap() } fn command(&mut self, n: u32, user: &str, root: &str, text: &str, at: i64) { self.ws .posted(&post(&id('c', n), user, DM, root, text, at), "D"); } /// The first `count` posts after the first `after`, waiting up to five seconds for all of them. fn replies(&self, after: usize, count: usize) -> Vec { let posts = self.mm.wait_posts(after + count, WAIT); posts[after..after + count] .iter() .map(|(_, _, t)| t.clone()) .collect() } } fn wait_until(what: &str, mut done: impl FnMut() -> bool) { let until = Instant::now() + WAIT; while !done() { assert!(Instant::now() < until, "never happened: {what}"); std::thread::sleep(Duration::from_millis(20)); } } #[test] fn approved_by_the_approver_the_post_says_it_runs_and_typing_pauses_meanwhile() { let mut s = stage("ans-approve", vec![]); s.ws.typing_within(Duration::from_millis(300)); let mut asking = s.ask(42); s.ws.typing_within(Duration::from_millis(200)); assert!( s.ws.typing_within(Duration::from_millis(400)).is_empty(), "no typing while it waits for approval" ); let root = s.root.clone(); s.command(1, KYLE, &root, "!approve 42", 5); let reply = asking.answer(WAIT).expect("the answer reaches brokerd"); assert_eq!( ( reply.approval, reply.approved, reply.by.as_str(), reply.post.clone(), reply.reason.clone() ), (42, true, KYLE, id('c', 1), None) ); asking.outcome(GatewayOutcome::Runs {}); let edits = s.mm.wait_edits(1, WAIT); assert_eq!(edits[0].0, FakeMm::post_id(0)); assert!( edits[0] .1 .ends_with("\n\n✅ Approved by @kyle: the call runs."), "{}", edits[0].1 ); assert_eq!(s.mm.posts().len(), 1, "nothing else posted in the thread"); wait_until("typing again", || { !s.ws.typing_within(Duration::from_millis(300)).is_empty() }); s.go.send(()).unwrap(); } #[test] fn refused_with_a_reason_the_post_says_so() { let mut s = stage("ans-deny", vec![]); let mut asking = s.ask(42); let root = s.root.clone(); s.command(1, KYLE, &root, "@boxmaker-straylight !deny 42 not now", 5); let reply = asking.answer(WAIT).expect("the answer reaches brokerd"); assert_eq!( (reply.approved, reply.reason.as_deref()), (false, Some("not now")) ); asking.outcome(GatewayOutcome::Refused {}); let edits = s.mm.wait_edits(1, WAIT); assert!( edits[0].1.ends_with("\n\n❌ Refused by @kyle: `not now`."), "{}", edits[0].1 ); s.go.send(()).unwrap(); } #[test] fn nobody_else_and_nothing_else_answers_it() { let mut s = stage("ans-refused", vec![]); let mut asking = s.ask(42); let root = s.root.clone(); let elsewhere = id('p', 9); s.command(1, EVE, &root, "!approve 42", 5); s.command(2, KYLE, &elsewhere, "!approve 42", 6); s.command(3, KYLE, &root, "!approve 42", 1); s.command(4, KYLE, &root, "!approve 7", 7); s.command(5, KYLE, &root, "!approve 42 please", 8); assert_eq!( s.replies(1, 5), [ NOT_APPROVER.to_string(), "approval 42 is answered in its own thread".to_string(), "approval 42 was answered before it was shown in full".to_string(), "approval 7 is not pending here".to_string(), "use !approve or !deny ".to_string(), ] ); assert_eq!( asking.answer(Duration::from_millis(300)), None, "nothing reached brokerd" ); s.command(6, KYLE, &root, "!approve 42", 9); assert!( asking.answer(WAIT).is_some(), "the approver, in the thread, after the post" ); asking.outcome(GatewayOutcome::Runs {}); s.go.send(()).unwrap(); } #[test] fn the_notice_only_when_brokerd_never_brought_it() { let expires = Timestamp::parse("2026-09-24T08:20:00.000Z").unwrap(); let pending_event = TurnEvent::ApprovalPending { approval: 42, tool: "read_file".to_string(), expires, }; let s = stage("ans-notice", vec![pending_event.clone()]); s.go.send(()).unwrap(); let posts = s.mm.wait_posts(2, WAIT); let texts: Vec<&str> = posts.iter().map(|(_, _, t)| t.as_str()).collect(); assert_eq!( texts, [ "waiting for approval 42: brokerd has not sent it here; answer it with bxctl approvals", "done" ] ); let s = stage("ans-no-notice", vec![pending_event]); let asking = s.ask(42); s.go.send(()).unwrap(); let posts = s.mm.wait_posts(2, WAIT); assert!(posts[0].2.starts_with("**Approval 42**"), "{posts:?}"); assert_eq!(posts[1].2, "done"); std::thread::sleep(Duration::from_millis(500)); assert_eq!(s.mm.posts().len(), 2, "no notice: {:?}", s.mm.posts()); asking.close(); }