Files
boxmaker/crates/gatewayd/tests/serve_answers.rs
T
kyleandClaude Opus 5.5 2b3ae080b4 proto becomes bxcore, the one shared crate (M5b task 00)
Done the way the owner asked and the spike mapped: the crate and the
manifests renamed, then what the build reported fixed. Layers: 138
"proto unresolved" errors across 169 files; cargo fmt reordering
imports; six fixture paths built as strings in brokerd's and bxctl's
tests, which the compiler cannot see, and comments naming
crates/proto/; the crate-dependency check and its self-test. One more
the spike did not reach: a `\nproto =` key inside a printf format in
the self-test, which a word-boundary replacement skips because `n` and
`p` are both word characters.

make gate: gate: ok, 944 tests; no new entry in /tmp.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-26 01:48:38 -07:00

254 lines
8.3 KiB
Rust

//! 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<TurnEvent>) -> (mpsc::Receiver<Turn>, 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<TurnEvent>) -> 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<String> {
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 <id> or !deny <id> <reason>".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();
}