nobody_else_and_nothing_else_answers_it read five replies after waiting only for the first, so it failed whenever the other four were not posted yet. It failed once in a full `make gate` run, when many test binaries ran at once. The helper now waits for as many posts as the test reads. The M4b plan's copy is left as it was handed over. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
252 lines
8.1 KiB
Rust
252 lines
8.1 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 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 proto::{GatewayOutcome, Timestamp, Turn, TurnEvent};
|
|
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 {
|
|
_home: TempDir,
|
|
mm: FakeMm,
|
|
_running: Running,
|
|
ws: WsPeer,
|
|
go: mpsc::Sender<()>,
|
|
socket: PathBuf,
|
|
root: String,
|
|
}
|
|
|
|
/// 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();
|
|
}
|