249 lines
8.1 KiB
Rust
249 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 text posted in the thread after the approval, within five seconds.
|
|
fn replies(&self, after: usize) -> Vec<String> {
|
|
let posts = self.mm.wait_posts(after + 1, WAIT);
|
|
posts[after..].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();
|
|
}
|