Plan M4b: approvals over Mattermost in 15 tasks

Skeletons for new files only; existing files change by exact edits, so no task overwrites an
earlier one's work. Each task's tests were run against a reference built in order from master,
with the gate at each step and mutations checked; each skeleton compiles and fails its tests, and
every todo!() comment was read beside its signature. The reference is kept off this machine.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-24 03:34:21 -07:00
co-authored by Claude Opus 5.5
parent b1f732a42a
commit 2d415fc1be
64 changed files with 7701 additions and 2 deletions
@@ -0,0 +1,105 @@
//! Approvals in Mattermost (M4b spec, sections 6 and 7): the text of the posts, and the
//! approvals this `gatewayd` has posted and the commands that answer them.
pub mod text;
use std::collections::HashMap;
use std::sync::mpsc::Sender;
use std::sync::{Mutex, MutexGuard};
use proto::ApprovalReply;
use crate::sessions::ApprovalCommand;
pub const NOT_APPROVER: &str = "only an approver can answer approvals";
pub const USAGE: &str = "use !approve <id> or !deny <id> <reason>";
/// One approval posted here and not yet answered.
#[derive(Debug)]
pub struct Posted {
pub approval: u64,
/// The session whose turn waits for it.
pub session: String,
/// The thread it is answered in.
pub channel: String,
pub root: String,
/// The last post's `create_at`: only a command after it counts.
pub shown_at: i64,
/// False when the arguments were too long to show: only `bxctl` answers it.
pub answerable: bool,
/// Where an accepted answer goes: the thread of its `gateway.sock` connection.
pub answer: Sender<ApprovalReply>,
}
/// What a command came to.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Judged {
/// The answer went to `brokerd`'s connection; the approval post will show the outcome.
Sent,
/// Refused: this line goes in the thread.
Reply(String),
}
/// The approvals posted here, by id.
#[derive(Debug, Default)]
pub struct Registry {
posted: Mutex<HashMap<u64, Posted>>,
}
impl Registry {
pub fn new() -> Registry {
Registry::default()
}
fn lock(&self) -> MutexGuard<'_, HashMap<u64, Posted>> {
self.posted.lock().unwrap_or_else(|p| p.into_inner())
}
pub fn add(&self, posted: Posted) {
// `self.lock().insert(posted.approval, posted);`
todo!()
}
/// Take it out: it ended, or its connection is done with it.
pub fn remove(&self, id: u64) -> Option<Posted> {
// `self.lock().remove(&id)`
todo!()
}
pub fn holds(&self, id: u64) -> bool {
// `self.lock().contains_key(&id)`
todo!()
}
/// The sessions with an approval waiting here: their turns are waiting, not working.
pub fn waiting_sessions(&self) -> Vec<String> {
// The `session` of every entry: `self.lock().values().map(|p|
// p.session.clone()).collect()`.
todo!()
}
/// Judge a command from `user`, posted as `post` at `at` in the thread `root`, in the order of
/// the spec's section 7. An accepted answer is taken out and sent; nothing else changes.
pub fn judge(
&self,
command: &ApprovalCommand,
user: &str,
root: &str,
post: &str,
at: i64,
approvers: &[String],
) -> Judged {
// In this order; each refusal is `Judged::Reply(<text>)` and changes nothing:
// 1. `user` not in `approvers`: NOT_APPROVER.
// 2. The command: Approve(id) is (id, approved true, reason None); Deny(id, reason) is (id,
// false, reason.clone()); Malformed: USAGE.
// 3. `let mut posted = self.lock();` Not in `posted`: "approval {id} is not pending here".
// 4. `!entry.answerable`: "approval {id} can only be answered with bxctl approvals".
// 5. `entry.root != root`: "approval {id} is answered in its own thread".
// 6. `at <= entry.shown_at`: "approval {id} was answered before it was shown in full".
// 7. Take it out (`posted.remove(&id)`) and send `ApprovalReply { approval: id, approved,
// by: user, post, reason }` on its `answer`: Ok -> `Judged::Sent`; Err (the connection
// is gone) -> "approval {id} is not pending here".
todo!()
}
}
@@ -0,0 +1,4 @@
//! Approvals in Mattermost (M4b spec, sections 6 and 7): the text of the posts, and the
//! approvals this `gatewayd` has posted and the commands that answer them.
pub mod text;
@@ -0,0 +1,157 @@
//! The text of an approval's posts, and of the line added when it ends (M4b spec, section 6).
//! Nothing here is shortened: arguments too long to show whole are not shown at all.
use proto::escape::escape_json_text;
use proto::{DenyReason, PendingApproval, Timestamp, sha256};
/// Arguments are split into parts of at most this many characters.
pub const PART_CHARS: usize = 15_000;
/// At most this many parts; more are answerable only in `bxctl`.
pub const MAX_PARTS: usize = 4;
/// The longest a part's post may be, leaving room for the line added when it ends.
pub const MAX_PART_POST: usize = 15_500;
/// An approval's posts, in order. Only an answer after the last counts; when `answerable` is
/// false, no command answers it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Shown {
pub posts: Vec<String>,
pub answerable: bool,
}
/// How an approval ended, for the line added to its last post.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Ended {
Runs { by: String },
Denied { by: String, reason: DenyReason },
Refused { by: String, reason: Option<String> },
NoLonger,
Restarted,
}
/// `text` as inline code, with a backtick run longer than any inside it.
pub fn code(text: &str) -> String {
// `let ticks = "`".repeat(longest_run(text) + 1);`, and a pad of one space on both sides when
// `text` starts or ends with a backtick (CommonMark needs it), else none:
// `format!("{ticks}{pad}{text}{pad}{ticks}")`.
todo!()
}
/// `text` as a fenced block, with a fence longer than any backtick run inside it (at least 3).
pub fn fence(text: &str) -> String {
// `let ticks = "`".repeat((longest_run(text) + 1).max(3));` then
// `format!("{ticks}\n{text}\n{ticks}")`.
todo!()
}
fn longest_run(text: &str) -> usize {
// The longest run of consecutive backticks in `text`: walk `text.chars()`, counting up on a
// backtick and back to 0 on anything else, keeping the most seen.
todo!()
}
/// `2026-09-24 14:47 UTC`.
fn minute(at: Timestamp) -> String {
// `let rfc = at.to_rfc3339();` ("2026-09-24T14:47:10.000Z"); then `format!("{} {} UTC",
// rfc.get(..10).unwrap_or_default(), rfc.get(11..16).unwrap_or_default())`.
todo!()
}
/// A serde name (`private`, `no_grant`), or the debug form if there is none.
fn name<T: serde::Serialize + std::fmt::Debug>(value: &T) -> String {
// `match serde_json::to_value(value)`: `Ok(serde_json::Value::String(s))` -> s; anything else
// -> `format!("{value:?}")`.
todo!()
}
fn reply_line(id: u64) -> String {
// `format!("Reply in this thread with `!approve {id}` or `!deny {id} <reason>`.")`.
todo!()
}
/// The posts that show `info`.
pub fn render(info: &PendingApproval) -> Shown {
let id = info.approval;
let args = escape_json_text(&info.arguments);
let header = format!(
"**Approval {id}** · tool {} · grant {} · taint {} · expires {}",
code(&escape_json_text(&info.tool)),
code(&escape_json_text(&info.grant)),
name(&info.taint),
minute(info.expires),
);
let chars: Vec<char> = args.chars().collect();
let parts: Vec<String> = if chars.is_empty() {
vec![String::new()]
} else {
chars
.chunks(PART_CHARS)
.map(|c| c.iter().collect())
.collect()
};
let count = parts.len();
let posts: Vec<String> = if count == 1 {
vec![format!("{header}\n{}\n{}", fence(&args), reply_line(id))]
} else {
let hash = sha256(args.as_bytes())
.map(|h| h.to_hex())
.unwrap_or_default();
parts
.iter()
.enumerate()
.map(|(i, part)| {
let n = i + 1;
let top = if n == 1 {
format!("{header}\nPart 1 of {count} of the arguments:")
} else {
format!("**Approval {id}**, part {n} of {count} of the arguments:")
};
let mut post = format!("{top}\n{}", fence(part));
if n == count {
post.push_str(&format!(
"\nApproval {id}: {count} parts, {} characters, sha256 {hash} (of the arguments as shown).\n{}",
chars.len(),
reply_line(id)
));
}
post
})
.collect()
};
if count > MAX_PARTS || posts.iter().any(|p| p.chars().count() > MAX_PART_POST) {
let hash = sha256(args.as_bytes())
.map(|h| h.to_hex())
.unwrap_or_default();
let post = format!(
"{header}\nThe arguments are {} characters (sha256 {hash}), too long to show here; answer it with `bxctl approvals`.",
chars.len()
);
return Shown {
posts: vec![post],
answerable: false,
};
}
Shown {
posts,
answerable: true,
}
}
/// The line added to the last post when the approval ends. `by` is `@username`, or the id.
pub fn ended_line(ended: &Ended) -> String {
// A `match ended`, one line each, exactly (`by` as given):
// 1. Runs: "✅ Approved by {by}: the call runs."
// 2. Denied: "Approved by {by}, but denied on the re-check: {name(reason)}."
// 3. Refused with Some(reason): "❌ Refused by {by}: {code(&escape_json_text(reason))}."
// 4. Refused with None: "❌ Refused by {by}: no reason given."
// 5. NoLonger: "No longer pending (answered elsewhere, expired, or its turn ended)."
// 6. Restarted: "No longer answerable here: gatewayd restarted. If it is still pending it is
// posted again."
todo!()
}
/// The last post's text with the ending line added.
pub fn with_ending(last: &str, ended: &Ended) -> String {
// `format!("{last}\n\n{}", ended_line(ended))`.
todo!()
}
@@ -0,0 +1,224 @@
//! `gateway.sock`: the listener, and one thread per `brokerd` connection (M4b spec, sections 4
//! and 6). A connection thread speaks the protocol only; everything that touches Mattermost or the
//! state file is asked of the serve loop, which alone owns them.
use std::os::unix::fs::{DirBuilderExt, FileTypeExt, PermissionsExt};
use std::os::unix::net::{UnixListener, UnixStream};
use std::path::Path;
use std::sync::Arc;
use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender, TryRecvError};
use std::time::Duration;
use proto::{
ApprovalPosted, ApprovalReply, Envelope, ErrorCode, FrameError, GatewayOutcome, Message,
PROTOCOL_VERSION, PendingApproval, WireError, read_frame, write_frame,
};
/// A log line printer, shared by the threads.
pub type Log = Arc<dyn Fn(&str) + Send + Sync>;
/// What a connection thread asks of the serve loop.
#[derive(Debug)]
pub enum ToLoop {
/// Post this approval; an accepted answer comes back on `answer`, and the post ids (or why
/// it could not be posted) on `shown`.
Show {
info: PendingApproval,
answer: Sender<ApprovalReply>,
shown: Sender<Result<Vec<String>, String>>,
},
/// It ended: edit its last post, and forget it.
Ended { approval: u64, ending: Ending },
}
/// How an approval ended, as far as this `gatewayd` knows.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Ending {
/// Answered here, by `by`, and `brokerd` said what came of it.
Answered {
by: String,
reason: Option<String>,
outcome: GatewayOutcome,
},
/// `brokerd` closed first, or said nothing after the answer.
NoLonger,
}
/// How long to wait: for the first frame, for the loop to post, for `brokerd`'s outcome; and how
/// often to look for an answer meanwhile.
#[derive(Debug, Clone, Copy)]
pub struct Timing {
pub first_frame: Duration,
pub posting: Duration,
pub outcome: Duration,
pub tick: Duration,
}
impl Default for Timing {
fn default() -> Self {
Timing {
first_frame: Duration::from_secs(10),
posting: Duration::from_secs(120),
outcome: Duration::from_secs(30),
tick: Duration::from_millis(100),
}
}
}
/// Listen on `socket`: its directory made 0700, a stale socket removed, the socket made 0600.
pub fn listen(socket: &Path) -> Result<UnixListener, String> {
// 1. The directory: `socket.parent()`, which must not be empty, else "cannot prepare <socket>:
// a socket needs a directory".
// 2. `std::fs::DirBuilder::new().recursive(true).mode(0o700).create(dir)`, then
// `set_permissions(dir, 0o700)`; an error is "cannot prepare <dir>: <e>".
// 3. `std::fs::symlink_metadata(socket)`: a socket (`file_type().is_socket()`) is removed
// (stale); anything else there is "cannot listen on <socket>: something else is there";
// nothing there is fine.
// 4. `UnixListener::bind(socket)`, then `set_permissions(socket, 0o600)`; an error is "cannot
// listen on <socket>: <e>".
// 5. Ok(listener). Paths are shown with `.display()`.
todo!()
}
/// Accept connections on a thread of their own, each served on its own thread.
pub fn serve(
listener: UnixListener,
to_loop: Sender<ToLoop>,
timing: Timing,
log: Log,
) -> std::io::Result<()> {
// Spawn (with `std::thread::Builder`, `?`) a thread that loops over `listener.incoming()`,
// skipping errors. For each stream: clone `to_loop` and `log`, and spawn a thread running
// `connection(stream, &to_loop, timing, &log)`; if that spawn fails, log "gatewayd: cannot
// start a thread for an approval: <e>". Then Ok(()).
todo!()
}
fn frame(id: u64, last: bool, msg: Message) -> Envelope {
Envelope {
v: PROTOCOL_VERSION,
id,
r#final: last,
msg,
}
}
/// Every frame `brokerd` sends, read whole on a thread of its own; shutting the stream down ends it.
fn frames(stream: &UnixStream) -> std::io::Result<Receiver<Result<Envelope, FrameError>>> {
// As in `brokerd`'s courier: `let mut reader = stream.try_clone()?;`, a channel, and a thread
// (`std::thread::Builder`, `?`) looping `let frame = read_frame(&mut reader); let last =
// frame.is_err(); if tx.send(frame).is_err() || last { return; }`. Then `Ok(rx)`.
todo!()
}
/// One `brokerd` connection, frames 1 to 4 of the spec's section 4.
pub fn connection(mut stream: UnixStream, to_loop: &Sender<ToLoop>, timing: Timing, log: &Log) {
// 1. The request, within `first_frame`.
let _ = stream.set_read_timeout(Some(timing.first_frame));
let info = match read_frame(&mut stream) {
Ok(Envelope {
id: 1,
r#final: true,
msg: Message::ApprovalRequest(req),
..
}) => req.approval,
_ => return,
};
let _ = stream.set_read_timeout(None);
let id = info.approval;
// 2. The loop posts it.
let (answer_tx, answer_rx) = mpsc::channel();
let (shown_tx, shown_rx) = mpsc::channel();
if to_loop
.send(ToLoop::Show {
info,
answer: answer_tx,
shown: shown_tx,
})
.is_err()
{
return;
}
let posts = match shown_rx.recv_timeout(timing.posting) {
Ok(Ok(posts)) => posts,
Ok(Err(why)) => {
let e = WireError {
code: ErrorCode::Internal,
detail: why,
};
let _ = write_frame(&mut stream, &frame(1, true, Message::Error(e)));
return;
}
Err(_) => {
let e = WireError {
code: ErrorCode::Internal,
detail: "gatewayd did not post it in time".to_string(),
};
let _ = write_frame(&mut stream, &frame(1, true, Message::Error(e)));
return;
}
};
let rx = match frames(&stream) {
Ok(rx) => rx,
Err(_) => return end(to_loop, id, Ending::NoLonger),
};
if write_frame(
&mut stream,
&frame(1, false, Message::ApprovalPosted(ApprovalPosted { posts })),
)
.is_err()
{
return end(to_loop, id, Ending::NoLonger);
}
// 3. An answer from an approver, or brokerd closing first.
let mut answers = Some(answer_rx);
let reply = loop {
if let Some(answers_rx) = &answers {
match answers_rx.recv_timeout(timing.tick) {
Ok(reply) => break reply,
Err(RecvTimeoutError::Timeout) => {}
Err(RecvTimeoutError::Disconnected) => answers = None,
}
} else {
std::thread::sleep(timing.tick);
}
match rx.try_recv() {
Err(TryRecvError::Empty) => {}
// brokerd closed (the approval ended elsewhere), or sent what it may not.
_ => {
let _ = stream.shutdown(std::net::Shutdown::Both);
return end(to_loop, id, Ending::NoLonger);
}
}
};
let (by, reason) = (reply.by.clone(), reply.reason.clone());
if write_frame(&mut stream, &frame(1, true, Message::ApprovalReply(reply))).is_err() {
return end(to_loop, id, Ending::NoLonger);
}
// 4. What came of it.
let ending = match rx.recv_timeout(timing.outcome) {
Ok(Ok(Envelope {
id: 2,
r#final: true,
msg: Message::ApprovalOutcome(o),
..
})) => Ending::Answered {
by,
reason,
outcome: o.outcome,
},
_ => {
(log)(&format!(
"gatewayd: approval {id}: brokerd said nothing after the answer"
));
Ending::NoLonger
}
};
let _ = stream.shutdown(std::net::Shutdown::Both);
end(to_loop, id, ending);
}
fn end(to_loop: &Sender<ToLoop>, approval: u64, ending: Ending) {
// `let _ = to_loop.send(ToLoop::Ended { approval, ending });`
todo!()
}
@@ -0,0 +1,143 @@
//! The serve loop's part in approvals (M4b spec, sections 6 and 8): posting what a `gateway.sock`
//! connection brings, marking it when it ends, and marking what a restart left behind. The loop
//! alone touches Mattermost and the state file; connection threads only ask.
use std::sync::mpsc::Sender;
use proto::{ApprovalReply, GatewayOutcome, PendingApproval};
use crate::approvals::Posted;
use crate::approvals::text::{self, Ended};
use crate::gateway::{Ending, ToLoop};
use crate::serve::{Gateway, Stop};
use crate::state::ApprovalPosts;
/// An approval's last post, the one that is edited when it ends.
#[derive(Debug)]
pub(super) struct Open {
pub(super) last_post: String,
pub(super) last_text: String,
}
impl Gateway {
/// Everything the connection threads asked since the last look, never blocking.
pub(super) fn gateway_requests(&mut self) -> Result<(), Stop> {
while let Ok(asked) = self.gateway_rx.try_recv() {
match asked {
ToLoop::Show {
info,
answer,
shown,
} => {
let result = self.show(&info, answer)?;
let _ = shown.send(result);
}
ToLoop::Ended { approval, ending } => self.ended(approval, ending)?,
}
}
Ok(())
}
/// Where an approval goes: its session's turn in flight, or the first approver's direct
/// messages as a new thread (an empty root).
fn place(&self, info: &PendingApproval) -> Result<(String, String), String> {
// 1. `self.state.turn_of(info.session.as_str())`: if Some(turn), `Ok((turn.channel,
// turn.root))`.
// 2. Otherwise the first approver's direct messages, as a new thread:
// `self.config.first_approver()` (None is Err("no approver is configured")), then
// `self.client.direct_channel(&self.me.id, first)` (an Err(e) is Err(format!("no direct
// channel with the first approver: {e}"))). `Ok((channel, String::new()))`.
todo!()
}
/// Post an approval's parts in order, record them, and register it for answers. An `Err`
/// inside is why it could not be posted; the outer one stops `gatewayd` (the state file).
fn show(
&mut self,
info: &PendingApproval,
answer: Sender<ApprovalReply>,
) -> Result<Result<Vec<String>, String>, Stop> {
let id = info.approval;
let (channel, mut root) = match self.place(info) {
Ok(place) => place,
Err(why) => return Ok(Err(why)),
};
let shown = text::render(info);
let mut posts = Vec::new();
let mut shown_at = 0;
for body in &shown.posts {
match self.client.create_post(&channel, &root, body) {
Ok(post) => {
if root.is_empty() {
root = post.id.clone();
}
shown_at = post.create_at;
posts.push(post.id);
}
Err(e) => {
// Parts already posted must not look answerable.
if let (Some(last), Some(text)) =
(posts.last(), shown.posts.get(posts.len().wrapping_sub(1)))
{
self.mark(id, last, &text::with_ending(text, &Ended::NoLonger));
}
return Ok(Err(format!("cannot post approval {id}: {e}")));
}
}
}
self.state.add_approval(ApprovalPosts {
approval: id,
channel: channel.clone(),
posts: posts.clone(),
})?;
self.registry.add(Posted {
approval: id,
session: info.session.as_str().to_string(),
channel,
root,
shown_at,
answerable: shown.answerable,
answer,
});
if let (Some(last_post), Some(last_text)) = (posts.last(), shown.posts.last()) {
self.open.insert(
id,
Open {
last_post: last_post.clone(),
last_text: last_text.clone(),
},
);
}
Ok(Ok(posts))
}
/// An approval ended: forget it, and add how it ended to its last post.
fn ended(&mut self, id: u64, ending: Ending) -> Result<(), Stop> {
// 1. `self.registry.remove(id);` and `self.state.remove_approval(id)?;`.
// 2. `let Some(open) = self.open.remove(&id) else { return Ok(()); };`
// 3. The `Ended`: Ending::NoLonger -> Ended::NoLonger. Ending::Answered { by, reason,
// outcome } -> `by` becomes "@<username>" from `self.client.username(&by)`, or stays the
// id if that fails; then outcome Runs -> Ended::Runs { by }, Denied { reason } ->
// Ended::Denied { by, reason }, Refused -> Ended::Refused { by, reason }, Gone ->
// Ended::NoLonger.
// 4. `self.mark(id, &open.last_post, &text::with_ending(&open.last_text, &ended));` and
// Ok(()).
todo!()
}
/// At the first connection after a start: the approval posts the last run left are no longer
/// answerable. Their last post is replaced by its title and the restart line.
pub(super) fn mark_left_behind(&mut self) -> Result<(), Stop> {
// For each `left` in `self.state.take_approvals()?`: if it has a last post,
// `self.mark(left.approval, last, &body)` with `body = format!("**Approval {}**\n\n{}",
// left.approval, text::ended_line(&Ended::Restarted))`. Then Ok(()).
todo!()
}
/// Edit a post; a failure is logged, not retried.
fn mark(&self, id: u64, post: &str, body: &str) {
// `if let Err(e) = self.client.patch_post(post, body)`: log "gatewayd: cannot mark approval
// {id} as ended: {e}" with `(self.log)(&line)`.
todo!()
}
}
@@ -0,0 +1,166 @@
//! Which commands answer an approval, in the order of the M4b spec, section 7; everything else is
//! answered with its reason and changes nothing. Do not edit.
use std::sync::mpsc::{self, Receiver};
use gatewayd::approvals::{Judged, NOT_APPROVER, Posted, Registry, USAGE};
use gatewayd::sessions::ApprovalCommand::{self, Approve, Deny, Malformed};
use proto::ApprovalReply;
const KYLE: &str = "k0000000000000000000000000";
const HERMES: &str = "h0000000000000000000000000";
const DM: &str = "d0000000000000000000000000";
const ROOT: &str = "r0000000000000000000000000";
const ELSEWHERE: &str = "e0000000000000000000000000";
const CMD: &str = "c0000000000000000000000001";
fn approvers() -> Vec<String> {
vec![KYLE.to_string()]
}
/// A registry with approval 42 posted in ROOT, shown at 100.
fn posted(answerable: bool) -> (Registry, Receiver<ApprovalReply>) {
let registry = Registry::new();
let (tx, rx) = mpsc::channel();
registry.add(Posted {
approval: 42,
session: format!("mm-{ROOT}"),
channel: DM.to_string(),
root: ROOT.to_string(),
shown_at: 100,
answerable,
answer: tx,
});
(registry, rx)
}
fn judge(r: &Registry, command: &ApprovalCommand, user: &str, root: &str, at: i64) -> Judged {
r.judge(command, user, root, CMD, at, &approvers())
}
#[test]
fn an_approver_in_the_thread_after_the_post_answers_it() {
let (r, rx) = posted(true);
assert_eq!(judge(&r, &Approve(42), KYLE, ROOT, 101), Judged::Sent);
assert_eq!(
rx.try_recv().unwrap(),
ApprovalReply {
approval: 42,
approved: true,
by: KYLE.to_string(),
post: CMD.to_string(),
reason: None
}
);
assert!(!r.holds(42), "answered once");
assert_eq!(
judge(&r, &Approve(42), KYLE, ROOT, 102),
Judged::Reply("approval 42 is not pending here".to_string())
);
assert!(rx.try_recv().is_err(), "a second answer goes nowhere");
}
#[test]
fn a_refusal_carries_its_reason() {
let (r, rx) = posted(true);
assert_eq!(
judge(
&r,
&Deny(42, Some("not on a Friday".to_string())),
KYLE,
ROOT,
101
),
Judged::Sent
);
let got = rx.try_recv().unwrap();
assert_eq!(
(got.approved, got.reason.as_deref()),
(false, Some("not on a Friday"))
);
let (r, rx) = posted(true);
assert_eq!(judge(&r, &Deny(42, None), KYLE, ROOT, 101), Judged::Sent);
assert_eq!(rx.try_recv().unwrap().reason, None);
}
#[test]
fn everything_else_is_refused_with_its_reason_in_order() {
let cases: &[(&ApprovalCommand, &str, &str, i64, &str)] = &[
(&Approve(42), HERMES, ROOT, 101, NOT_APPROVER),
(&Malformed, HERMES, ROOT, 101, NOT_APPROVER),
(&Malformed, KYLE, ROOT, 101, USAGE),
(
&Approve(7),
KYLE,
ROOT,
101,
"approval 7 is not pending here",
),
(
&Approve(42),
KYLE,
ELSEWHERE,
101,
"approval 42 is answered in its own thread",
),
(
&Approve(42),
KYLE,
ROOT,
100,
"approval 42 was answered before it was shown in full",
),
(
&Approve(42),
KYLE,
ROOT,
50,
"approval 42 was answered before it was shown in full",
),
(
&Approve(42),
KYLE,
ELSEWHERE,
50,
"approval 42 is answered in its own thread",
),
];
for (command, user, root, at, want) in cases {
let (r, rx) = posted(true);
assert_eq!(
judge(&r, command, user, root, *at),
Judged::Reply(want.to_string()),
"{command:?} {user} {root} {at}"
);
assert!(r.holds(42), "still pending after {command:?}");
assert!(rx.try_recv().is_err(), "nothing sent for {command:?}");
}
}
#[test]
fn too_long_to_show_is_answered_only_in_bxctl() {
let (r, rx) = posted(false);
assert_eq!(
judge(&r, &Approve(42), KYLE, ROOT, 101),
Judged::Reply("approval 42 can only be answered with bxctl approvals".to_string())
);
assert!(r.holds(42) && rx.try_recv().is_err());
}
#[test]
fn an_answer_whose_connection_is_gone_is_not_pending() {
let (r, rx) = posted(true);
drop(rx);
assert_eq!(
judge(&r, &Approve(42), KYLE, ROOT, 101),
Judged::Reply("approval 42 is not pending here".to_string())
);
}
#[test]
fn the_waiting_sessions_and_removal() {
let (r, _rx) = posted(true);
assert_eq!(r.waiting_sessions(), [format!("mm-{ROOT}")]);
assert_eq!(r.remove(42).map(|p| p.approval), Some(42));
assert!(r.waiting_sessions().is_empty() && !r.holds(42) && r.remove(42).is_none());
}
@@ -0,0 +1,197 @@
//! The text of an approval's posts and of its ending line (M4b spec, section 6). Do not edit.
use gatewayd::approvals::text::{
Ended, MAX_PART_POST, MAX_PARTS, PART_CHARS, code, ended_line, fence, render, with_ending,
};
use proto::{CallId, DataClass, DenyReason, PendingApproval, SessionId, Timestamp, sha256};
/// The escape for one code point, built here so this file holds no such character itself.
fn esc(code: u32) -> String {
format!("{}u{code:04x}", char::from(92))
}
fn info(arguments: &str) -> PendingApproval {
PendingApproval {
approval: 42,
session: SessionId::new("mm-r0000000000000000000000001").unwrap(),
call: CallId(3),
tool: "shell".to_string(),
arguments: arguments.to_string(),
grant: "ops-shell".to_string(),
taint: DataClass::Private,
created: Timestamp::parse("2026-09-24T14:32:10.000Z").unwrap(),
expires: Timestamp::parse("2026-09-24T14:47:10.000Z").unwrap(),
}
}
#[test]
fn one_part_exactly() {
let shown = render(&info(
r#"{"command":"ls -la /srv/backups","timeout_ms":10000}"#,
));
assert!(shown.answerable);
assert_eq!(
shown.posts,
[
"**Approval 42** · tool `shell` · grant `ops-shell` · taint private · expires 2026-09-24 14:47 UTC\n```\n{\"command\":\"ls -la /srv/backups\",\"timeout_ms\":10000}\n```\nReply in this thread with `!approve 42` or `!deny 42 <reason>`."
]
);
}
#[test]
fn fences_and_code_outrun_backticks() {
assert_eq!(fence("a"), "```\na\n```");
assert_eq!(fence("a ``` b ```` c"), "`````\na ``` b ```` c\n`````");
assert_eq!(code("ops"), "`ops`");
assert_eq!(code("a`b"), "``a`b``");
assert_eq!(code("`x"), "`` `x ``");
let shown = render(&info(r#"{"command":"echo ```` done"}"#));
assert!(
shown.posts[0].contains("\n`````\n{\"command\":\"echo ```` done\"}\n`````\n"),
"{}",
shown.posts[0]
);
}
#[test]
fn what_must_not_reach_the_reader_raw_is_escaped() {
let raw = "a\u{7}b\u{202e}c\u{200b}d\u{2028}e\u{feff}f\nnewline";
let shown = render(&info(&format!("{{\"command\":\"{raw}\"}}")));
let post = &shown.posts[0];
for bad in ['\u{7}', '\u{202e}', '\u{200b}', '\u{2028}', '\u{feff}'] {
assert!(!post.contains(bad), "{bad:?} reached the post");
}
let want = format!(
"a{}b{}c{}d{}e{}f{}newline",
esc(0x7),
esc(0x202e),
esc(0x200b),
esc(0x2028),
esc(0xfeff),
esc(0xa)
);
assert!(post.contains(&want), "{post}");
let mut odd = info("{}");
odd.grant = "g`rant\u{202e}".to_string();
assert!(
render(&odd).posts[0].contains(&format!("grant ``g`rant{}``", esc(0x202e))),
"{}",
render(&odd).posts[0]
);
}
#[test]
fn long_arguments_come_in_parts_with_a_hash() {
let args = format!("{{\"content\":\"{}\"}}", "é".repeat(PART_CHARS * 2));
let shown = render(&info(&args));
assert!(shown.answerable);
assert_eq!(shown.posts.len(), 3);
let total = args.chars().count();
assert!(shown.posts[0].starts_with("**Approval 42** · tool `shell` · grant `ops-shell` · taint private · expires 2026-09-24 14:47 UTC\nPart 1 of 3 of the arguments:\n```\n{\"content\":\""));
assert!(shown.posts[1].starts_with("**Approval 42**, part 2 of 3 of the arguments:\n```\n"));
let hash = sha256(args.as_bytes()).unwrap().to_hex();
let last = &shown.posts[2];
assert!(last.ends_with(&format!("\nApproval 42: 3 parts, {total} characters, sha256 {hash} (of the arguments as shown).\nReply in this thread with `!approve 42` or `!deny 42 <reason>`.")), "{last}");
assert!(
!shown.posts[0].contains("Reply in this thread")
&& !shown.posts[1].contains("Reply in this thread")
);
let shown_again: String = shown
.posts
.iter()
.map(|p| {
let body = p.split_once("\n```\n").unwrap().1;
body.split_once("\n```").unwrap().0.to_string()
})
.collect();
assert_eq!(
shown_again, args,
"the parts, joined, are the arguments: nothing lost"
);
assert!(
shown
.posts
.iter()
.all(|p| p.chars().count() <= MAX_PART_POST)
);
}
#[test]
fn too_long_to_show_is_answerable_only_in_bxctl() {
let args = "x".repeat(PART_CHARS * MAX_PARTS + 1);
let shown = render(&info(&args));
assert!(!shown.answerable);
let hash = sha256(args.as_bytes()).unwrap().to_hex();
assert_eq!(
shown.posts,
[format!(
"**Approval 42** · tool `shell` · grant `ops-shell` · taint private · expires 2026-09-24 14:47 UTC\nThe arguments are {} characters (sha256 {hash}), too long to show here; answer it with `bxctl approvals`.",
args.len()
)]
);
let exactly = "x".repeat(PART_CHARS * MAX_PARTS);
assert!(
render(&info(&exactly)).answerable,
"four full parts still show"
);
let ticks = format!("{}{}", "`".repeat(PART_CHARS - 10), "x".repeat(20));
assert!(
!render(&info(&ticks)).answerable,
"a fence that would overflow the post is not shown"
);
}
#[test]
fn ending_lines() {
let kyle = "@kyle".to_string();
assert_eq!(
ended_line(&Ended::Runs { by: kyle.clone() }),
"✅ Approved by @kyle: the call runs."
);
assert_eq!(
ended_line(&Ended::Denied {
by: kyle.clone(),
reason: DenyReason::GrantExpired
}),
"Approved by @kyle, but denied on the re-check: grant_expired."
);
assert_eq!(
ended_line(&Ended::Refused {
by: kyle.clone(),
reason: None
}),
"❌ Refused by @kyle: no reason given."
);
assert_eq!(
ended_line(&Ended::Refused {
by: kyle.clone(),
reason: Some("not @channel `now`".to_string())
}),
"❌ Refused by @kyle: `` not @channel `now` ``."
);
assert_eq!(
ended_line(&Ended::NoLonger),
"No longer pending (answered elsewhere, expired, or its turn ended)."
);
assert_eq!(
ended_line(&Ended::Restarted),
"No longer answerable here: gatewayd restarted. If it is still pending it is posted again."
);
assert_eq!(
with_ending("post", &Ended::NoLonger),
"post\n\nNo longer pending (answered elsewhere, expired, or its turn ended)."
);
let longest = render(&info(&"y".repeat(PART_CHARS))).posts.remove(0);
let ended = with_ending(
&longest,
&Ended::Refused {
by: format!("@{}", "u".repeat(64)),
reason: Some("r".repeat(500)),
},
);
assert!(
ended.chars().count() <= 16_383,
"an ended post still fits: {}",
ended.chars().count()
);
}
@@ -0,0 +1,68 @@
//! `[approvals]` in `gatewayd.toml`: who may answer, the first approver, and `gateway.sock`
//! (M4b spec, section 3). Do not edit.
use std::path::PathBuf;
use gatewayd::config::Config;
const KYLE: &str = "k0000000000000000000000000";
const HERMES: &str = "h0000000000000000000000000";
const OTHER: &str = "o0000000000000000000000000";
fn text(approvals: &str) -> String {
format!(
"[mattermost]\nurl = \"https://chat.example\"\n[secrets.mattermost_token]\nenv = \"T\"\n[allow]\nusers = [\"{KYLE}\", \"{HERMES}\"]\n[paths]\nhome = \"/srv/bx\"\n{approvals}"
)
}
fn config(approvals: &str) -> Config {
let c = Config::parse(&text(approvals)).unwrap();
assert_eq!(c.problem(), None);
c
}
#[test]
fn without_the_section_everyone_allowed_may_answer() {
let c = config("");
assert_eq!(c.approvers(), [KYLE, HERMES]);
assert_eq!(c.first_approver(), Some(KYLE));
assert_eq!(
c.gateway_socket(),
PathBuf::from("/srv/bx/run/gateway/gateway.sock")
);
assert_eq!(
config("[approvals]\nusers = []\n").approvers(),
[KYLE, HERMES]
);
}
#[test]
fn a_list_narrows_who_may_answer() {
let c = config(&format!(
"[approvals]\nusers = [\"{HERMES}\"]\nsocket = \"/run/gw.sock\"\n"
));
assert_eq!(c.approvers(), [HERMES]);
assert_eq!(c.first_approver(), Some(HERMES));
assert_eq!(c.gateway_socket(), PathBuf::from("/run/gw.sock"));
}
#[test]
fn an_approver_must_be_heard() {
let c = Config::parse(&text(&format!(
"[approvals]\nusers = [\"{KYLE}\", \"{OTHER}\"]\n"
)))
.unwrap();
let why = c.problem().expect("refused");
assert_eq!(
why,
format!(
"[approvals] \"{OTHER}\" is not in [allow] users: an approver who is not heard could never answer"
)
);
}
#[test]
fn unknown_keys_are_errors() {
assert!(Config::parse(&text("[approvals]\napprovers = []\n")).is_err());
assert!(Config::parse(&text("[approvals]\nusers = []\nttl = 5\n")).is_err());
}
@@ -0,0 +1,313 @@
//! A turn on `loop.sock` and its answer in the thread, against a fake `loopd`: the answer, long
//! answers, approvals, errors, an unknown session, and a loop that is not there (M4a spec, section
//! 8; M4b spec, section 7, for the notice). Do not edit.
#[path = "support/fake_loop.rs"]
mod fake_loop;
#[path = "support/tmp.rs"]
mod tmp;
use std::sync::Mutex;
use std::time::Duration;
use fake_loop::{Reply, done, error, event, serve_loop};
use gatewayd::deliver::{EMPTY_ANSWER, LOOP_DOWN, MAX_POST, Notice, Poster, deliver, split_answer};
use gatewayd::mm::MmError;
use gatewayd::sessions::{Batch, Thread};
use proto::{DataClass, ErrorCode, SessionId, Timestamp, TurnEvent};
use tmp::TempDir;
const DM: &str = "d0000000000000000000000000";
const ROOT: &str = "r0000000000000000000000000";
#[derive(Default)]
struct Record {
posts: Mutex<Vec<(String, String, String)>>,
fail: bool,
}
impl Poster for Record {
fn post(&self, channel: &str, root: &str, text: &str) -> Result<(), MmError> {
if self.fail {
return Err(MmError::Status(500, "down".to_string()));
}
self.posts
.lock()
.unwrap()
.push((channel.to_string(), root.to_string(), text.to_string()));
Ok(())
}
}
impl Record {
fn texts(&self) -> Vec<String> {
let posts = self.posts.lock().unwrap();
assert!(
posts.iter().all(|(c, r, _)| c == DM && r == ROOT),
"every post in the thread"
);
posts.iter().map(|(_, _, t)| t.clone()).collect()
}
}
fn batch(resume: bool, text: &str) -> Batch {
Batch {
session: SessionId::new(&format!("mm-{ROOT}")).unwrap(),
thread: Thread {
channel: DM.to_string(),
root: ROOT.to_string(),
},
resume,
text: text.to_string(),
}
}
fn run(dir: &TempDir, poster: &Record, batch: &Batch) -> Vec<String> {
let log = Mutex::new(Vec::new());
let never = |_: u64| false;
let notice = Notice {
posted: &never,
wait: Duration::ZERO,
};
deliver(
poster,
&dir.path().join("loop.sock"),
batch,
&|line| log.lock().unwrap().push(line.to_string()),
&notice,
);
log.into_inner().unwrap()
}
#[test]
fn the_answer_is_posted_and_nothing_else() {
let dir = TempDir::new("deliver-answer");
let turns = serve_loop(&dir.path().join("loop.sock"), |_, _| {
vec![
event(TurnEvent::Progress {
total: 10,
cache: 0,
processed: 10,
}),
event(TurnEvent::Reasoning {
text: "private thoughts".to_string(),
}),
event(TurnEvent::ToolCallStarted {
name: "read_file".to_string(),
}),
event(TurnEvent::ToolResult {
name: "read_file".to_string(),
class: DataClass::Private,
truncated: false,
}),
event(TurnEvent::Content {
text: "The ans".to_string(),
}),
done("The answer."),
]
});
let poster = Record::default();
let log = run(&dir, &poster, &batch(true, "one\n\ntwo"));
assert_eq!(poster.texts(), ["The answer."]);
assert!(log.is_empty(), "{log:?}");
let turn = turns.recv_timeout(Duration::from_secs(5)).unwrap();
assert_eq!(
(turn.session.as_str(), turn.content.as_str(), turn.resume),
(format!("mm-{ROOT}").as_str(), "one\n\ntwo", true)
);
}
#[test]
fn an_approval_is_announced_once_before_the_answer() {
let dir = TempDir::new("deliver-approval");
let expires = Timestamp::from_unix_millis(1_758_650_000_000).unwrap();
let _turns = serve_loop(&dir.path().join("loop.sock"), move |_, _| {
vec![
event(TurnEvent::ApprovalPending {
approval: 42,
tool: "shell".to_string(),
expires,
}),
done("done"),
]
});
let poster = Record::default();
run(&dir, &poster, &batch(false, "go"));
assert_eq!(
poster.texts(),
[
"waiting for approval 42: brokerd has not sent it here; answer it with bxctl approvals",
"done"
]
);
}
#[test]
fn errors_are_posted_with_their_code() {
let dir = TempDir::new("deliver-error");
let _turns = serve_loop(&dir.path().join("loop.sock"), |_, _| {
vec![error(
ErrorCode::Inference,
"the model server failed\nsee docs/runbook.md#loopd-selftest-failed",
)]
});
let poster = Record::default();
run(&dir, &poster, &batch(false, "go"));
assert_eq!(
poster.texts(),
["Error: inference: the model server failed\nsee docs/runbook.md#loopd-selftest-failed"]
);
}
#[test]
fn a_reply_in_a_thread_loopd_does_not_know_creates_the_session() {
let dir = TempDir::new("deliver-unknown");
let turns = serve_loop(&dir.path().join("loop.sock"), |n, _| {
if n == 0 {
vec![error(ErrorCode::NoSuchSession, "no such session")]
} else {
vec![done("hello")]
}
});
let poster = Record::default();
run(&dir, &poster, &batch(true, "hi"));
assert_eq!(poster.texts(), ["hello"]);
let first = turns.recv_timeout(Duration::from_secs(5)).unwrap();
let second = turns.recv_timeout(Duration::from_secs(5)).unwrap();
assert_eq!(
(first.resume, second.resume, second.content.as_str()),
(true, false, "hi")
);
}
#[test]
fn a_new_session_is_not_retried() {
let dir = TempDir::new("deliver-noretry");
let turns = serve_loop(&dir.path().join("loop.sock"), |_, _| {
vec![error(ErrorCode::NoSuchSession, "odd")]
});
let poster = Record::default();
run(&dir, &poster, &batch(false, "hi"));
assert_eq!(poster.texts(), ["Error: no_such_session: odd"]);
assert!(turns.recv_timeout(Duration::from_secs(5)).is_ok());
assert!(
turns.recv_timeout(Duration::from_millis(200)).is_err(),
"one turn only"
);
}
#[test]
fn a_loop_that_is_not_there_or_goes_away() {
let dir = TempDir::new("deliver-down");
let poster = Record::default();
let log = run(&dir, &poster, &batch(false, "hi"));
assert_eq!(poster.texts(), [LOOP_DOWN]);
assert_eq!(log.len(), 1, "{log:?}");
assert!(
log[0].starts_with(&format!("gatewayd: mm-{ROOT}: cannot connect to ")),
"{log:?}"
);
for (n, replies) in [
vec![
event(TurnEvent::Content {
text: "x".to_string(),
}),
Reply::Close,
],
vec![Reply::Bytes(b"\x00\x00\x00\x05{bad}".to_vec())],
vec![
Reply::Frame(proto::Envelope {
v: 1,
id: 2,
r#final: true,
msg: proto::Message::Ok(proto::Empty {}),
}),
done("late"),
],
]
.into_iter()
.enumerate()
{
let dir = TempDir::new(&format!("deliver-early-{n}"));
let replies = Mutex::new(Some(replies));
let _turns = serve_loop(&dir.path().join("loop.sock"), move |_, _| {
replies.lock().unwrap().take().unwrap_or_default()
});
let poster = Record::default();
let log = run(&dir, &poster, &batch(false, "hi"));
assert_eq!(poster.texts(), [LOOP_DOWN], "case {n}");
assert_eq!(log.len(), 1, "case {n}: {log:?}");
}
}
#[test]
fn a_post_that_fails_is_logged() {
let dir = TempDir::new("deliver-postfail");
let _turns = serve_loop(&dir.path().join("loop.sock"), |_, _| vec![done("lost")]);
let poster = Record {
fail: true,
..Record::default()
};
let log = run(&dir, &poster, &batch(false, "hi"));
assert_eq!(
log,
[format!(
"gatewayd: cannot post in {DM} (thread {ROOT}): status 500: \"down\""
)]
);
}
#[test]
fn long_answers_are_split_at_newlines() {
let short = "a".repeat(MAX_POST);
assert_eq!(split_answer(&short), [short.as_str()]);
let over = "a".repeat(MAX_POST + 1);
assert_eq!(split_answer(&over), ["a".repeat(MAX_POST), "a".to_string()]);
let lines = format!(
"{}\n{}\n{}",
"a".repeat(10_000),
"b".repeat(5_000),
"c".repeat(2_000)
);
assert_eq!(
split_answer(&lines),
[
format!("{}\n{}", "a".repeat(10_000), "b".repeat(5_000)),
"c".repeat(2_000)
]
);
let wide = "é".repeat(MAX_POST + 5);
let parts = split_answer(&wide);
assert_eq!(
parts.iter().map(|p| p.chars().count()).collect::<Vec<_>>(),
[MAX_POST, 5],
"characters, not bytes"
);
let leading = format!("\n{}", "x".repeat(MAX_POST + 1));
let parts = split_answer(&leading);
assert!(
parts
.iter()
.all(|p| !p.is_empty() && p.chars().count() <= MAX_POST),
"{:?}",
parts.iter().map(|p| p.len()).collect::<Vec<_>>()
);
assert_eq!(parts.concat(), leading, "a hard cut drops nothing");
assert_eq!(split_answer(""), [EMPTY_ANSWER]);
assert_eq!(split_answer(" \n "), [EMPTY_ANSWER]);
}
#[test]
fn a_long_answer_is_posted_in_order() {
let dir = TempDir::new("deliver-long");
let answer = format!("{}\n{}", "a".repeat(MAX_POST - 1), "b".repeat(MAX_POST));
let sent = answer.clone();
let _turns = serve_loop(&dir.path().join("loop.sock"), move |_, _| vec![done(&sent)]);
let poster = Record::default();
run(&dir, &poster, &batch(false, "hi"));
assert_eq!(
poster.texts(),
["a".repeat(MAX_POST - 1), "b".repeat(MAX_POST)]
);
}
@@ -0,0 +1,95 @@
//! The notice for an approval that `brokerd` did not bring: posted only when it has not arrived
//! within the wait (M4b spec, section 7). Do not edit.
#[path = "support/fake_loop.rs"]
mod fake_loop;
#[path = "support/tmp.rs"]
mod tmp;
use std::sync::Mutex;
use std::time::{Duration, Instant};
use fake_loop::{done, event, serve_loop};
use gatewayd::deliver::{Notice, Poster, deliver};
use gatewayd::mm::MmError;
use gatewayd::sessions::{Batch, Thread};
use proto::{SessionId, Timestamp, TurnEvent};
use tmp::TempDir;
const DM: &str = "d0000000000000000000000000";
const ROOT: &str = "r0000000000000000000000000";
const NOTICE: &str =
"waiting for approval 42: brokerd has not sent it here; answer it with bxctl approvals";
#[derive(Default)]
struct Record(Mutex<Vec<String>>);
impl Poster for Record {
fn post(&self, _: &str, _: &str, text: &str) -> Result<(), MmError> {
self.0.lock().unwrap().push(text.to_string());
Ok(())
}
}
/// A turn whose loop says approval 42 is pending, then answers; what was posted, and how long it took.
fn turn(tag: &str, posted: &dyn Fn(u64) -> bool, wait: Duration) -> (Vec<String>, Duration) {
let dir = TempDir::new(tag);
let socket = dir.path().join("loop.sock");
let expires = Timestamp::parse("2026-09-24T08:20:00.000Z").unwrap();
let _turns = serve_loop(&socket, move |_, _| {
vec![
event(TurnEvent::ApprovalPending {
approval: 42,
tool: "read_file".to_string(),
expires,
}),
done("done"),
]
});
let batch = Batch {
session: SessionId::new(&format!("mm-{ROOT}")).unwrap(),
thread: Thread {
channel: DM.to_string(),
root: ROOT.to_string(),
},
resume: false,
text: "go".to_string(),
};
let poster = Record::default();
let started = Instant::now();
deliver(&poster, &socket, &batch, &|_| {}, &Notice { posted, wait });
let took = started.elapsed();
(poster.0.into_inner().unwrap(), took)
}
#[test]
fn brought_already_no_notice_and_no_wait() {
let (posts, took) = turn("notice-there", &|id| id == 42, Duration::from_secs(5));
assert_eq!(posts, ["done"]);
assert!(took < Duration::from_secs(1), "{took:?}");
}
#[test]
fn brought_during_the_wait_no_notice() {
let started = Instant::now();
let later = move |id: u64| id == 42 && started.elapsed() > Duration::from_millis(300);
let (posts, took) = turn("notice-later", &later, Duration::from_secs(5));
assert_eq!(posts, ["done"]);
assert!(took < Duration::from_secs(2), "{took:?}");
}
#[test]
fn never_brought_the_notice_after_the_wait() {
let (posts, took) = turn("notice-never", &|_| false, Duration::from_millis(400));
assert_eq!(posts, [NOTICE, "done"]);
assert!(
took >= Duration::from_millis(400) && took < Duration::from_secs(3),
"{took:?}"
);
}
#[test]
fn another_approval_brought_does_not_count() {
let (posts, _) = turn("notice-other", &|id| id == 7, Duration::from_millis(200));
assert_eq!(posts, [NOTICE, "done"]);
}
@@ -0,0 +1,288 @@
//! `gateway.sock` from `gatewayd`'s side: the socket's permissions, and one connection's frames,
//! with the test playing both the serve loop and `brokerd` (M4b spec, sections 4 and 6). Do not
//! edit.
#[path = "support/tmp.rs"]
mod tmp;
use std::os::unix::fs::PermissionsExt;
use std::os::unix::net::UnixStream;
use std::path::Path;
use std::sync::mpsc::{self, Receiver};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use gatewayd::gateway::{Ending, Timing, ToLoop, listen, serve};
use proto::{
ApprovalOutcome, ApprovalReply, ApprovalRequest, CallId, DataClass, Envelope, ErrorCode,
GatewayOutcome, Message, PROTOCOL_VERSION, PendingApproval, SessionId, Timestamp, read_frame,
write_frame,
};
use tmp::TempDir;
const KYLE: &str = "k0000000000000000000000000";
const POST: &str = "p0000000000000000000000001";
const CMD: &str = "c0000000000000000000000001";
const WAIT: Duration = Duration::from_secs(5);
fn info(approval: u64) -> PendingApproval {
PendingApproval {
approval,
session: SessionId::new("mm-r0000000000000000000000001").unwrap(),
call: CallId(3),
tool: "shell".to_string(),
arguments: "{}".to_string(),
grant: "g".to_string(),
taint: DataClass::Private,
created: Timestamp::parse("2026-09-24T08:05:00.000Z").unwrap(),
expires: Timestamp::parse("2026-09-24T08:20:00.000Z").unwrap(),
}
}
fn frame(id: u64, last: bool, msg: Message) -> Envelope {
Envelope {
v: PROTOCOL_VERSION,
id,
r#final: last,
msg,
}
}
fn timing() -> Timing {
Timing {
first_frame: Duration::from_millis(500),
posting: WAIT,
outcome: Duration::from_millis(500),
tick: Duration::from_millis(20),
}
}
/// A listening gatewayd side: the socket and what it asks of the loop.
fn start(
dir: &TempDir,
) -> (
std::path::PathBuf,
Receiver<ToLoop>,
Arc<Mutex<Vec<String>>>,
) {
let socket = dir.path().join("run/gateway/gateway.sock");
let listener = listen(&socket).unwrap();
let (tx, rx) = mpsc::channel();
let lines = Arc::new(Mutex::new(Vec::new()));
let sink = Arc::clone(&lines);
serve(
listener,
tx,
timing(),
Arc::new(move |l: &str| sink.lock().unwrap().push(l.to_string())),
)
.unwrap();
(socket, rx, lines)
}
/// brokerd: connect and send the request.
fn request(socket: &Path, approval: u64) -> UnixStream {
let mut s = UnixStream::connect(socket).unwrap();
s.set_read_timeout(Some(WAIT)).unwrap();
write_frame(
&mut s,
&frame(
1,
true,
Message::ApprovalRequest(ApprovalRequest {
approval: info(approval),
}),
),
)
.unwrap();
s
}
fn reply(approval: u64) -> ApprovalReply {
ApprovalReply {
approval,
approved: true,
by: KYLE.to_string(),
post: CMD.to_string(),
reason: None,
}
}
#[test]
fn the_socket_is_private_and_a_stale_one_is_replaced() {
let dir = TempDir::new("gw-perms");
let socket = dir.path().join("run/gateway/gateway.sock");
drop(listen(&socket).unwrap());
let listener = listen(&socket).expect("a stale socket is replaced");
let mode = |p: &Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777;
assert_eq!(
(mode(&socket), mode(socket.parent().unwrap())),
(0o600, 0o700)
);
drop(listener);
let file = dir.path().join("run/gateway/not-a-socket");
std::fs::write(&file, "x").unwrap();
let why = listen(&file).unwrap_err();
assert!(
why.starts_with(&format!("cannot listen on {}", file.display())),
"{why}"
);
assert!(
dir.path().join("run/gateway/not-a-socket").exists(),
"a file that is not a socket is left alone"
);
}
#[test]
fn posted_answered_and_ended() {
let dir = TempDir::new("gw-answer");
let (socket, to_loop, _) = start(&dir);
let mut brokerd = request(&socket, 42);
let (answer, shown) = match to_loop.recv_timeout(WAIT).unwrap() {
ToLoop::Show {
info: got,
answer,
shown,
} => {
assert_eq!(got, info(42));
(answer, shown)
}
other => panic!("{other:?}"),
};
shown.send(Ok(vec![POST.to_string()])).unwrap();
let posted = read_frame(&mut brokerd).unwrap();
assert_eq!((posted.id, posted.r#final), (1, false));
assert!(
matches!(posted.msg, Message::ApprovalPosted(ref p) if p.posts == [POST]),
"{posted:?}"
);
answer.send(reply(42)).unwrap();
let answered = read_frame(&mut brokerd).unwrap();
assert_eq!(answered, frame(1, true, Message::ApprovalReply(reply(42))));
write_frame(
&mut brokerd,
&frame(
2,
true,
Message::ApprovalOutcome(ApprovalOutcome {
outcome: GatewayOutcome::Runs {},
}),
),
)
.unwrap();
match to_loop.recv_timeout(WAIT).unwrap() {
ToLoop::Ended { approval, ending } => {
assert_eq!(approval, 42);
assert_eq!(
ending,
Ending::Answered {
by: KYLE.to_string(),
reason: None,
outcome: GatewayOutcome::Runs {}
}
);
}
other => panic!("{other:?}"),
}
}
#[test]
fn brokerd_closing_first_ends_it_as_no_longer_pending() {
let dir = TempDir::new("gw-closed");
let (socket, to_loop, _) = start(&dir);
let mut brokerd = request(&socket, 7);
let ToLoop::Show { shown, answer, .. } = to_loop.recv_timeout(WAIT).unwrap() else {
panic!()
};
shown.send(Ok(vec![POST.to_string()])).unwrap();
read_frame(&mut brokerd).unwrap();
drop(brokerd);
match to_loop.recv_timeout(WAIT).unwrap() {
ToLoop::Ended { approval, ending } => assert_eq!((approval, ending), (7, Ending::NoLonger)),
other => panic!("{other:?}"),
}
assert!(
answer.send(reply(7)).is_err(),
"nobody listens for an answer any more"
);
}
#[test]
fn an_approval_that_cannot_be_posted_is_an_error_to_brokerd() {
let dir = TempDir::new("gw-cannot");
let (socket, to_loop, _) = start(&dir);
let mut brokerd = request(&socket, 8);
let ToLoop::Show { shown, .. } = to_loop.recv_timeout(WAIT).unwrap() else {
panic!()
};
shown.send(Err("Mattermost is down".to_string())).unwrap();
let got = read_frame(&mut brokerd).unwrap();
assert_eq!((got.id, got.r#final), (1, true));
match got.msg {
Message::Error(e) => assert_eq!(
(e.code, e.detail.as_str()),
(ErrorCode::Internal, "Mattermost is down")
),
other => panic!("{other:?}"),
}
assert!(
to_loop.recv_timeout(Duration::from_millis(300)).is_err(),
"nothing more for the loop"
);
}
#[test]
fn no_outcome_after_the_answer_is_no_longer_pending_and_logged() {
let dir = TempDir::new("gw-silent");
let (socket, to_loop, lines) = start(&dir);
let mut brokerd = request(&socket, 9);
let ToLoop::Show { shown, answer, .. } = to_loop.recv_timeout(WAIT).unwrap() else {
panic!()
};
shown.send(Ok(vec![POST.to_string()])).unwrap();
read_frame(&mut brokerd).unwrap();
answer.send(reply(9)).unwrap();
read_frame(&mut brokerd).unwrap();
match to_loop.recv_timeout(WAIT).unwrap() {
ToLoop::Ended { approval, ending } => assert_eq!((approval, ending), (9, Ending::NoLonger)),
other => panic!("{other:?}"),
}
assert_eq!(
*lines.lock().unwrap(),
["gatewayd: approval 9: brokerd said nothing after the answer"]
);
}
#[test]
fn anything_but_a_request_first_is_dropped() {
let dir = TempDir::new("gw-bad");
let (socket, to_loop, _) = start(&dir);
for first in [
frame(1, true, Message::ApprovalReply(reply(1))),
frame(
2,
true,
Message::ApprovalRequest(ApprovalRequest { approval: info(1) }),
),
frame(
1,
false,
Message::ApprovalRequest(ApprovalRequest { approval: info(1) }),
),
] {
let mut s = UnixStream::connect(&socket).unwrap();
s.set_read_timeout(Some(WAIT)).unwrap();
write_frame(&mut s, &first).unwrap();
assert!(
read_frame(&mut s).is_err(),
"closed without an answer: {first:?}"
);
}
let silent = UnixStream::connect(&socket).unwrap();
std::thread::sleep(Duration::from_millis(700));
drop(silent);
assert!(
to_loop.recv_timeout(Duration::from_millis(300)).is_err(),
"nothing reached the loop"
);
}
@@ -0,0 +1,129 @@
//! Editing a post and looking up a username, against a scripted server (M4b spec, sections 6 and
//! 7). Do not edit.
#[path = "support/http_server.rs"]
mod http_server;
#[path = "support/tls_server.rs"]
mod tls_server;
use std::net::SocketAddr;
use std::time::Duration;
use gatewayd::config::ServerUrl;
use gatewayd::mm::MmError;
use gatewayd::mm::rest::Client;
use gatewayd::net::Connector;
use gatewayd::secrets::Secret;
use http_server::{reply, serve_http};
use serde_json::json;
const BOT: &str = "b0000000000000000000000000";
const KYLE: &str = "k0000000000000000000000000";
const DM: &str = "d0000000000000000000000000";
const POST: &str = "p0000000000000000000000001";
const WAIT: Duration = Duration::from_secs(5);
fn client(addr: SocketAddr) -> Client {
let url = ServerUrl {
tls: false,
host: "localhost".to_string(),
port: addr.port(),
};
Client::new(
Connector::new(url, None).unwrap(),
Secret::new("TOKEN".to_string()),
WAIT,
)
}
#[test]
fn a_post_is_patched_with_its_new_text() {
let (addr, rx) = serve_http(None, |_, r| {
let text = r.json()["message"].as_str().unwrap().to_string();
let post = json!({"id": POST, "user_id": BOT, "channel_id": DM, "root_id": "", "message": text, "create_at": 5, "is_pinned": false});
reply(200, "", &post.to_string())
});
client(addr)
.patch_post(
POST,
"**Approval 42**\n✅ Approved by @kyle: the call runs.",
)
.unwrap();
let r = rx.recv_timeout(WAIT).unwrap();
assert_eq!(
(r.method.as_str(), r.path.as_str()),
("PUT", format!("/api/v4/posts/{POST}/patch").as_str())
);
assert!(
r.head.contains("Authorization: Bearer TOKEN\r\n"),
"{}",
r.head
);
assert_eq!(
r.json(),
json!({"message": "**Approval 42**\n✅ Approved by @kyle: the call runs."})
);
}
#[test]
fn a_post_that_is_not_an_id_is_never_sent() {
let (addr, rx) = serve_http(None, |_, _| reply(200, "", "{}"));
for bad in ["../users/me", "", "P0000000000000000000000001"] {
assert!(
matches!(client(addr).patch_post(bad, "x"), Err(MmError::Json(_))),
"{bad:?}"
);
assert!(
matches!(client(addr).username(bad), Err(MmError::Json(_))),
"{bad:?}"
);
}
std::thread::sleep(Duration::from_millis(100));
assert!(rx.try_recv().is_err(), "nothing was sent");
}
#[test]
fn a_refused_edit_is_an_error() {
let (addr, _rx) = serve_http(None, |_, _| {
reply(
403,
"",
r#"{"id":"api.post.update_post.permissions_details.app_error"}"#,
)
});
assert!(matches!(
client(addr).patch_post(POST, "x"),
Err(MmError::Auth(403))
));
}
#[test]
fn the_username_of_a_user() {
let (addr, rx) = serve_http(None, |_, _| {
reply(
200,
"",
&json!({"id": KYLE, "username": "kyle", "first_name": "K", "roles": "system_user"})
.to_string(),
)
});
assert_eq!(client(addr).username(KYLE).unwrap(), "kyle");
let r = rx.recv_timeout(WAIT).unwrap();
assert_eq!(
(r.method.as_str(), r.path.as_str()),
("GET", format!("/api/v4/users/{KYLE}").as_str())
);
for bad in [
json!({"id": KYLE}),
json!({"id": KYLE, "username": ""}),
json!({"id": KYLE, "username": "Kyle\n@channel"}),
json!({"id": KYLE, "username": "x".repeat(65)}),
] {
let body = bad.to_string();
let (addr, _rx) = serve_http(None, move |_, _| reply(200, "", &body));
assert!(
matches!(client(addr).username(KYLE), Err(MmError::Json(_))),
"{bad}"
);
}
}
@@ -0,0 +1,214 @@
//! `gatewayd` end to end, against a fake Mattermost and a fake `loopd`: who is answered, where,
//! and how (M4a spec, sections 7 and 8). Do not edit.
#[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::sync::{Mutex, mpsc};
use std::time::Duration;
use fake_loop::{done, serve_loop};
use fake_mm::{BOT, BOT_NAME, DM, EVE, EVE_DM, FakeMm, KYLE, id, post};
use gateway::{OTHER, SHARED, WAIT, config, loop_dir, read_state, start, up};
use gatewayd::serve::Stop;
use gatewayd::sessions::BUSY;
use tmp::TempDir;
#[test]
fn a_direct_message_is_answered_in_its_thread_while_typing() {
let home = TempDir::new("serve-dm");
let (mm, running, turns, mut ws) = up(&home, Duration::from_millis(600));
let p1 = id('p', 1);
ws.posted(&post(&p1, KYLE, DM, "", "hello there", 5), "D");
let turn = turns.recv_timeout(WAIT).unwrap();
assert_eq!(
(turn.session.as_str(), turn.content.as_str(), turn.resume),
(format!("mm-{p1}").as_str(), "hello there", false)
);
let typing = ws.typing_within(Duration::from_millis(500));
assert!(typing.len() >= 2, "{typing:?}");
assert!(
typing.iter().all(|(c, p)| c == DM && *p == p1),
"{typing:?}"
);
let posts = mm.wait_posts(1, WAIT);
assert_eq!(
posts,
[(
DM.to_string(),
p1.clone(),
"answer to hello there".to_string()
)]
);
// Typing sent just before the answer may still be on its way; after that, it stops.
ws.typing_within(Duration::from_millis(300));
assert!(
ws.typing_within(Duration::from_millis(400)).is_empty(),
"typing stops after the answer"
);
let log = running.log();
assert!(
log.iter()
.any(|l| l == &format!("gatewayd: connected to {} as {BOT_NAME}", mm.url())),
"{log:?}"
);
assert!(matches!(running.finish(), Stop::Asked));
}
#[test]
fn anyone_else_gets_nothing_at_all() {
let home = TempDir::new("serve-stranger");
let (mm, running, turns, mut ws) = up(&home, Duration::ZERO);
let p1 = id('p', 1);
ws.posted(&post(&p1, EVE, EVE_DM, "", "secret words", 5), "D");
let mut own = post(&id('p', 2), BOT, DM, "", "my own post", 6);
own["user_id"] = serde_json::json!(BOT);
ws.posted(&own, "D");
let log = running.wait_log("not allowed");
assert!(turns.recv_timeout(Duration::from_millis(300)).is_err());
assert!(ws.typing_within(Duration::from_millis(200)).is_empty());
assert!(mm.posts().is_empty());
assert!(
log.contains(&format!(
"gatewayd: ignored post {p1} from {EVE}: not allowed"
)),
"{log:?}"
);
assert!(!log.iter().any(|l| l.contains("secret words")), "{log:?}");
}
#[test]
fn messages_during_a_turn_go_together_in_the_next() {
let home = TempDir::new("serve-burst");
loop_dir(&home);
let (release_tx, release_rx) = mpsc::channel::<()>();
let release = Mutex::new(release_rx);
let turns = serve_loop(&home.path().join("run/loop/loop.sock"), move |n, turn| {
if n == 0 {
let _ = release.lock().unwrap().recv_timeout(WAIT);
}
vec![done(&format!("answer to {}", turn.content))]
});
let mm = FakeMm::start();
let _running = start(config(&home, &mm.url(), ""));
let mut ws = mm.next_ws(WAIT);
let p1 = id('p', 1);
ws.posted(&post(&p1, KYLE, DM, "", "one", 5), "D");
assert_eq!(turns.recv_timeout(WAIT).unwrap().content, "one");
let saved = read_state(&home);
assert_eq!(
saved["in_flight"],
serde_json::json!([{"session": format!("mm-{p1}"), "channel": DM, "root": p1}])
);
ws.posted(&post(&id('p', 2), KYLE, DM, &p1, "two", 6), "D");
ws.posted(&post(&id('p', 3), KYLE, DM, &p1, "three", 7), "D");
std::thread::sleep(Duration::from_millis(200));
release_tx.send(()).unwrap();
let second = turns.recv_timeout(WAIT).unwrap();
assert_eq!(
(
second.session.as_str(),
second.content.as_str(),
second.resume
),
(format!("mm-{p1}").as_str(), "two\n\nthree", true)
);
let posts = mm.wait_posts(2, WAIT);
let texts: Vec<&str> = posts.iter().map(|(_, _, t)| t.as_str()).collect();
assert_eq!(texts, ["answer to one", "answer to two\n\nthree"]);
}
#[test]
fn a_full_queue_says_busy() {
let home = TempDir::new("serve-busy");
loop_dir(&home);
let (release_tx, release_rx) = mpsc::channel::<()>();
let release = Mutex::new(release_rx);
let _turns = serve_loop(&home.path().join("run/loop/loop.sock"), move |_, _| {
let _ = release.lock().unwrap().recv_timeout(WAIT);
vec![done("ok")]
});
let mm = FakeMm::start();
let _running = start(config(&home, &mm.url(), "queue = 1"));
let mut ws = mm.next_ws(WAIT);
let p1 = id('p', 1);
for (n, text) in ["run", "waits", "too many"].iter().enumerate() {
let n = u32::try_from(n).unwrap();
let root = if n == 0 { String::new() } else { p1.clone() };
ws.posted(
&post(&id('p', n + 1), KYLE, DM, &root, text, i64::from(n) + 5),
"D",
);
}
let posts = mm.wait_posts(1, WAIT);
assert_eq!(posts, [(DM.to_string(), p1, BUSY.to_string())]);
release_tx.send(()).unwrap();
release_tx.send(()).unwrap();
}
#[test]
fn channels_are_answered_only_when_named_or_in_our_thread() {
let home = TempDir::new("serve-channel");
let (mm, _running, turns, mut ws) = up(&home, Duration::ZERO);
let p1 = id('p', 1);
ws.posted(
&post(&id('p', 9), KYLE, SHARED, "", "hello everyone", 4),
"O",
);
ws.posted(
&post(
&id('p', 8),
KYLE,
OTHER,
"",
"@boxmaker-straylight elsewhere",
4,
),
"O",
);
ws.posted(
&post(&p1, KYLE, SHARED, "", "@boxmaker-straylight start", 5),
"O",
);
assert_eq!(turns.recv_timeout(WAIT).unwrap().content, "start");
mm.wait_posts(1, WAIT);
ws.posted(
&post(&id('p', 2), KYLE, SHARED, &p1, "@hermes your turn", 6),
"O",
);
ws.posted(&post(&id('p', 3), KYLE, SHARED, &p1, "and more", 7), "O");
let next = turns.recv_timeout(WAIT).unwrap();
assert_eq!((next.content.as_str(), next.resume), ("and more", true));
let posts = mm.wait_posts(2, WAIT);
assert!(
posts.iter().all(|(c, r, _)| c == SHARED && *r == p1),
"{posts:?}"
);
assert!(turns.recv_timeout(Duration::from_millis(200)).is_err());
let saved = read_state(&home);
assert!(saved["channels"].get(OTHER).is_none(), "{saved}");
assert_eq!(saved["threads"], serde_json::json!([p1]));
}
#[test]
fn commands_are_answered_without_a_turn() {
let home = TempDir::new("serve-command");
let (mm, _running, turns, mut ws) = up(&home, Duration::ZERO);
let p1 = id('p', 1);
ws.posted(&post(&p1, KYLE, DM, "", "!approve 3", 5), "D");
assert_eq!(
mm.wait_posts(1, WAIT),
[(
DM.to_string(),
p1,
"approval 3 is not pending here".to_string()
)]
);
assert!(turns.recv_timeout(Duration::from_millis(200)).is_err());
}
@@ -0,0 +1,210 @@
//! `gatewayd` end to end, against a fake Mattermost and a fake `loopd`: who is answered, where,
//! and how (M4a spec, sections 7 and 8). Do not edit.
#[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::sync::{Mutex, mpsc};
use std::time::Duration;
use fake_loop::{done, serve_loop};
use fake_mm::{BOT, BOT_NAME, DM, EVE, EVE_DM, FakeMm, KYLE, id, post};
use gateway::{OTHER, SHARED, WAIT, config, loop_dir, read_state, start, up};
use gatewayd::serve::Stop;
use gatewayd::sessions::{BUSY, M4B_COMMAND};
use tmp::TempDir;
#[test]
fn a_direct_message_is_answered_in_its_thread_while_typing() {
let home = TempDir::new("serve-dm");
let (mm, running, turns, mut ws) = up(&home, Duration::from_millis(600));
let p1 = id('p', 1);
ws.posted(&post(&p1, KYLE, DM, "", "hello there", 5), "D");
let turn = turns.recv_timeout(WAIT).unwrap();
assert_eq!(
(turn.session.as_str(), turn.content.as_str(), turn.resume),
(format!("mm-{p1}").as_str(), "hello there", false)
);
let typing = ws.typing_within(Duration::from_millis(500));
assert!(typing.len() >= 2, "{typing:?}");
assert!(
typing.iter().all(|(c, p)| c == DM && *p == p1),
"{typing:?}"
);
let posts = mm.wait_posts(1, WAIT);
assert_eq!(
posts,
[(
DM.to_string(),
p1.clone(),
"answer to hello there".to_string()
)]
);
// Typing sent just before the answer may still be on its way; after that, it stops.
ws.typing_within(Duration::from_millis(300));
assert!(
ws.typing_within(Duration::from_millis(400)).is_empty(),
"typing stops after the answer"
);
let log = running.log();
assert!(
log.iter()
.any(|l| l == &format!("gatewayd: connected to {} as {BOT_NAME}", mm.url())),
"{log:?}"
);
assert!(matches!(running.finish(), Stop::Asked));
}
#[test]
fn anyone_else_gets_nothing_at_all() {
let home = TempDir::new("serve-stranger");
let (mm, running, turns, mut ws) = up(&home, Duration::ZERO);
let p1 = id('p', 1);
ws.posted(&post(&p1, EVE, EVE_DM, "", "secret words", 5), "D");
let mut own = post(&id('p', 2), BOT, DM, "", "my own post", 6);
own["user_id"] = serde_json::json!(BOT);
ws.posted(&own, "D");
let log = running.wait_log("not allowed");
assert!(turns.recv_timeout(Duration::from_millis(300)).is_err());
assert!(ws.typing_within(Duration::from_millis(200)).is_empty());
assert!(mm.posts().is_empty());
assert!(
log.contains(&format!(
"gatewayd: ignored post {p1} from {EVE}: not allowed"
)),
"{log:?}"
);
assert!(!log.iter().any(|l| l.contains("secret words")), "{log:?}");
}
#[test]
fn messages_during_a_turn_go_together_in_the_next() {
let home = TempDir::new("serve-burst");
loop_dir(&home);
let (release_tx, release_rx) = mpsc::channel::<()>();
let release = Mutex::new(release_rx);
let turns = serve_loop(&home.path().join("run/loop/loop.sock"), move |n, turn| {
if n == 0 {
let _ = release.lock().unwrap().recv_timeout(WAIT);
}
vec![done(&format!("answer to {}", turn.content))]
});
let mm = FakeMm::start();
let _running = start(config(&home, &mm.url(), ""));
let mut ws = mm.next_ws(WAIT);
let p1 = id('p', 1);
ws.posted(&post(&p1, KYLE, DM, "", "one", 5), "D");
assert_eq!(turns.recv_timeout(WAIT).unwrap().content, "one");
let saved = read_state(&home);
assert_eq!(
saved["in_flight"],
serde_json::json!([{"session": format!("mm-{p1}"), "channel": DM, "root": p1}])
);
ws.posted(&post(&id('p', 2), KYLE, DM, &p1, "two", 6), "D");
ws.posted(&post(&id('p', 3), KYLE, DM, &p1, "three", 7), "D");
std::thread::sleep(Duration::from_millis(200));
release_tx.send(()).unwrap();
let second = turns.recv_timeout(WAIT).unwrap();
assert_eq!(
(
second.session.as_str(),
second.content.as_str(),
second.resume
),
(format!("mm-{p1}").as_str(), "two\n\nthree", true)
);
let posts = mm.wait_posts(2, WAIT);
let texts: Vec<&str> = posts.iter().map(|(_, _, t)| t.as_str()).collect();
assert_eq!(texts, ["answer to one", "answer to two\n\nthree"]);
}
#[test]
fn a_full_queue_says_busy() {
let home = TempDir::new("serve-busy");
loop_dir(&home);
let (release_tx, release_rx) = mpsc::channel::<()>();
let release = Mutex::new(release_rx);
let _turns = serve_loop(&home.path().join("run/loop/loop.sock"), move |_, _| {
let _ = release.lock().unwrap().recv_timeout(WAIT);
vec![done("ok")]
});
let mm = FakeMm::start();
let _running = start(config(&home, &mm.url(), "queue = 1"));
let mut ws = mm.next_ws(WAIT);
let p1 = id('p', 1);
for (n, text) in ["run", "waits", "too many"].iter().enumerate() {
let n = u32::try_from(n).unwrap();
let root = if n == 0 { String::new() } else { p1.clone() };
ws.posted(
&post(&id('p', n + 1), KYLE, DM, &root, text, i64::from(n) + 5),
"D",
);
}
let posts = mm.wait_posts(1, WAIT);
assert_eq!(posts, [(DM.to_string(), p1, BUSY.to_string())]);
release_tx.send(()).unwrap();
release_tx.send(()).unwrap();
}
#[test]
fn channels_are_answered_only_when_named_or_in_our_thread() {
let home = TempDir::new("serve-channel");
let (mm, _running, turns, mut ws) = up(&home, Duration::ZERO);
let p1 = id('p', 1);
ws.posted(
&post(&id('p', 9), KYLE, SHARED, "", "hello everyone", 4),
"O",
);
ws.posted(
&post(
&id('p', 8),
KYLE,
OTHER,
"",
"@boxmaker-straylight elsewhere",
4,
),
"O",
);
ws.posted(
&post(&p1, KYLE, SHARED, "", "@boxmaker-straylight start", 5),
"O",
);
assert_eq!(turns.recv_timeout(WAIT).unwrap().content, "start");
mm.wait_posts(1, WAIT);
ws.posted(
&post(&id('p', 2), KYLE, SHARED, &p1, "@hermes your turn", 6),
"O",
);
ws.posted(&post(&id('p', 3), KYLE, SHARED, &p1, "and more", 7), "O");
let next = turns.recv_timeout(WAIT).unwrap();
assert_eq!((next.content.as_str(), next.resume), ("and more", true));
let posts = mm.wait_posts(2, WAIT);
assert!(
posts.iter().all(|(c, r, _)| c == SHARED && *r == p1),
"{posts:?}"
);
assert!(turns.recv_timeout(Duration::from_millis(200)).is_err());
let saved = read_state(&home);
assert!(saved["channels"].get(OTHER).is_none(), "{saved}");
assert_eq!(saved["threads"], serde_json::json!([p1]));
}
#[test]
fn commands_are_answered_without_a_turn() {
let home = TempDir::new("serve-command");
let (mm, _running, turns, mut ws) = up(&home, Duration::ZERO);
let p1 = id('p', 1);
ws.posted(&post(&p1, KYLE, DM, "", "!approve 3", 5), "D");
assert_eq!(
mm.wait_posts(1, WAIT),
[(DM.to_string(), p1, M4B_COMMAND.to_string())]
);
assert!(turns.recv_timeout(Duration::from_millis(200)).is_err());
}
@@ -0,0 +1,248 @@
//! 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();
}
@@ -0,0 +1,199 @@
//! The serve loop posts what `gateway.sock` brings, where it belongs, and marks it when it ends
//! or when a restart left it behind (M4b spec, sections 6 and 8). 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;
use brokerd_client::{ask, pending};
use fake_loop::{done, serve_loop};
use fake_mm::{DM, FakeMm, KYLE, id, post};
use gateway::{WAIT, config, loop_dir, read_state, start};
use tmp::TempDir;
fn socket(home: &TempDir) -> PathBuf {
home.path().join("run/gateway/gateway.sock")
}
/// A turn that waits until the returned sender says so.
fn held_loop(home: &TempDir) -> mpsc::Sender<()> {
loop_dir(home);
let (release_tx, release_rx) = mpsc::channel::<()>();
let release = Mutex::new(release_rx);
let _turns = serve_loop(&home.path().join("run/loop/loop.sock"), move |_, _| {
let _ = release
.lock()
.unwrap()
.recv_timeout(Duration::from_secs(20));
vec![done("done")]
});
release_tx
}
#[test]
fn an_approval_goes_into_the_asking_thread_and_is_marked_when_it_ends() {
let home = TempDir::new("sa-thread");
let release = held_loop(&home);
let mm = FakeMm::start();
let _running = start(config(&home, &mm.url(), ""));
let mut ws = mm.next_ws(WAIT);
let p1 = id('p', 1);
ws.posted(&post(&p1, KYLE, DM, "", "read my notes", 5), "D");
let until = std::time::Instant::now() + WAIT;
let path = home.path().join("gateway/state.json");
let in_flight = || -> bool {
let Ok(text) = std::fs::read_to_string(&path) else {
return false;
};
let v: serde_json::Value = serde_json::from_str(&text).unwrap();
!v["in_flight"].as_array().unwrap().is_empty()
};
while !in_flight() {
assert!(std::time::Instant::now() < until, "the turn never started");
std::thread::sleep(Duration::from_millis(20));
}
let asking = ask(
&socket(&home),
pending(42, &format!("mm-{p1}"), r#"{"path":"/n/a"}"#),
)
.unwrap();
let posts = mm.posts();
assert_eq!(posts.len(), 1, "{posts:?}");
let (channel, root, text) = &posts[0];
assert_eq!(
(channel.as_str(), root.as_str()),
(DM, p1.as_str()),
"in the asking thread"
);
assert!(text.starts_with("**Approval 42** · tool `read_file` · grant `notes` · taint private · expires 2026-09-24 08:20 UTC\n```\n{\"path\":\"/n/a\"}\n```\n"), "{text}");
assert_eq!(asking.posts, [FakeMm::post_id(0)]);
assert_eq!(
read_state(&home)["approvals"],
serde_json::json!([{"approval": 42, "channel": DM, "posts": [FakeMm::post_id(0)]}])
);
asking.close();
let edits = mm.wait_edits(1, WAIT);
assert_eq!(edits.len(), 1, "{edits:?}");
assert_eq!(edits[0].0, FakeMm::post_id(0));
assert_eq!(
edits[0].1,
format!("{text}\n\nNo longer pending (answered elsewhere, expired, or its turn ended).")
);
let until = std::time::Instant::now() + WAIT;
while !read_state(&home)["approvals"]
.as_array()
.unwrap()
.is_empty()
{
assert!(std::time::Instant::now() < until, "still recorded");
std::thread::sleep(Duration::from_millis(20));
}
release.send(()).unwrap();
}
#[test]
fn without_a_turn_in_flight_it_goes_to_the_first_approvers_direct_messages_in_parts() {
let home = TempDir::new("sa-dm");
loop_dir(&home);
let mm = FakeMm::start();
let _running = start(config(&home, &mm.url(), ""));
let _ws = mm.next_ws(WAIT);
let long = format!("{{\"content\":\"{}\"}}", "x".repeat(20_000));
let asking = ask(&socket(&home), pending(7, "chat-1789700000-1", &long)).unwrap();
let posts = mm.posts();
assert_eq!(posts.len(), 2, "two parts");
assert_eq!(
(posts[0].0.as_str(), posts[0].1.as_str()),
(DM, ""),
"a new thread in the direct channel"
);
assert_eq!(
(posts[1].0.as_str(), posts[1].1.clone()),
(DM, FakeMm::post_id(0)),
"the next part in its thread"
);
assert!(
posts[1]
.2
.contains("Reply in this thread with `!approve 7`")
);
assert_eq!(asking.posts, [FakeMm::post_id(0), FakeMm::post_id(1)]);
asking.close();
let edits = mm.wait_edits(1, WAIT);
assert_eq!(
edits[0].0,
FakeMm::post_id(1),
"only the last part is edited"
);
}
#[test]
fn an_approval_that_cannot_be_posted_is_refused_to_brokerd() {
let home = TempDir::new("sa-refused");
loop_dir(&home);
let mm = FakeMm::start();
let _running = start(config(&home, &mm.url(), ""));
let _ws = mm.next_ws(WAIT);
mm.refuse_posts(true);
let why = ask(&socket(&home), pending(8, "chat-1789700000-1", "{}"))
.err()
.expect("refused");
assert!(
why.starts_with("cannot post approval 8: status 400"),
"{why}"
);
assert!(
read_state(&home)["approvals"]
.as_array()
.unwrap()
.is_empty()
);
}
#[test]
fn a_restart_marks_the_posts_it_left_behind() {
let home = TempDir::new("sa-restart");
let (a, b) = (id('q', 1), id('q', 2));
let state = serde_json::json!({
"channels": {}, "recent": [], "threads": [], "in_flight": [],
"approvals": [{"approval": 5, "channel": DM, "posts": [a, b]}]
});
home.write("gateway/state.json", &state.to_string());
loop_dir(&home);
let mm = FakeMm::start();
let _running = start(config(&home, &mm.url(), ""));
let _ws = mm.next_ws(WAIT);
let edits = mm.wait_edits(1, WAIT);
assert_eq!(edits, [(b, "**Approval 5**\n\nNo longer answerable here: gatewayd restarted. If it is still pending it is posted again.".to_string())]);
assert!(
read_state(&home)["approvals"]
.as_array()
.unwrap()
.is_empty()
);
}
#[test]
fn the_socket_is_there_while_gatewayd_runs() {
let home = TempDir::new("sa-socket");
loop_dir(&home);
let mm = FakeMm::start();
let running = start(config(&home, &mm.url(), ""));
let _ws = mm.next_ws(WAIT);
use std::os::unix::fs::{FileTypeExt, PermissionsExt};
let meta = std::fs::metadata(socket(&home)).unwrap();
assert!(meta.file_type().is_socket());
assert_eq!(meta.permissions().mode() & 0o777, 0o600);
drop(running);
}
@@ -0,0 +1,314 @@
//! Which posts become turns, in which session, and how messages wait for a running turn (M4a spec,
//! section 7, including its table of examples). Do not edit.
use gatewayd::mm::Post;
use gatewayd::sessions::{
ApprovalCommand, BUSY, Batch, Ignored, Message, Pushed, Queues, Route, Router, Thread,
UNKNOWN_COMMAND, named,
};
use proto::SessionId;
const BOT: &str = "b0000000000000000000000000";
const KYLE: &str = "k0000000000000000000000000";
const EVE: &str = "e0000000000000000000000000";
const DM: &str = "d0000000000000000000000000";
const SHARED: &str = "c0000000000000000000000000";
const OTHER: &str = "o0000000000000000000000000";
const ROOT: &str = "r0000000000000000000000000";
const POST: &str = "p0000000000000000000000000";
fn router() -> Router {
Router::new(
BOT,
"boxmaker-straylight",
&[KYLE.to_string()],
&[SHARED.to_string()],
)
}
fn post(channel: &str, root: &str, message: &str) -> Post {
Post {
id: POST.to_string(),
user_id: KYLE.to_string(),
channel_id: channel.to_string(),
root_id: root.to_string(),
message: message.to_string(),
create_at: 5,
delete_at: 0,
kind: String::new(),
}
}
fn known(root: &str) -> bool {
root == ROOT
}
fn unknown(_: &str) -> bool {
false
}
fn queued(route: Route) -> Message {
match route {
Route::Queue(m) => m,
other => panic!("not queued: {other:?}"),
}
}
#[test]
fn the_examples_in_the_spec() {
let r = router();
let cases = [
("", "@boxmaker-straylight summarise the audit log", true),
(ROOT, "and the older files?", true),
(ROOT, "@hermes what do you think?", false),
("", "@boxmaker-straylight @hermes compare notes", true),
("", "@boxmaker-straylightx hello", false),
("", "@channel standup in five", false),
];
for (root, message, yes) in cases {
let got = r.route(&post(SHARED, root, message), "O", &known);
assert_eq!(matches!(got, Route::Queue(_)), yes, "{message}: {got:?}");
if !yes {
assert_eq!(got, Route::Ignore(Ignored::NotForUs), "{message}");
}
}
}
#[test]
fn naming() {
assert_eq!(named("@Boxmaker-Straylight, look"), ["boxmaker-straylight"]);
assert_eq!(named("ask @boxmaker-straylight."), ["boxmaker-straylight"]);
assert_eq!(named("@a.b_c-d... and @e"), ["a.b_c-d", "e"]);
assert_eq!(named("@ alone, @@x, trailing @"), ["x"]);
assert_eq!(named("ünïcødé @ʙob @bob"), ["bob"]);
assert!(named("no names here").is_empty());
let r = router();
for message in [
"hi @BOXMAKER-STRAYLIGHT",
"@boxmaker-straylight.",
"(@boxmaker-straylight)",
] {
assert!(
matches!(
r.route(&post(SHARED, "", message), "P", &unknown),
Route::Queue(_)
),
"{message}"
);
}
}
#[test]
fn replies_in_our_thread_that_name_everyone_are_still_ours() {
let r = router();
for message in ["@here any news?", "@all done", "thanks @channel"] {
assert!(
matches!(
r.route(&post(SHARED, ROOT, message), "O", &known),
Route::Queue(_)
),
"{message}"
);
}
let got = r.route(
&post(
SHARED,
"q0000000000000000000000000",
"a reply in someone else's thread",
),
"O",
&known,
);
assert_eq!(got, Route::Ignore(Ignored::NotForUs));
}
#[test]
fn who_and_where() {
let r = router();
let mut own = post(DM, "", "hello");
own.user_id = BOT.to_string();
assert_eq!(r.route(&own, "D", &unknown), Route::Ignore(Ignored::Own));
let mut system = post(DM, "", "joined");
system.kind = "system_join_channel".to_string();
assert_eq!(
r.route(&system, "D", &unknown),
Route::Ignore(Ignored::System)
);
let mut stranger = post(DM, "", "@boxmaker-straylight hello");
stranger.user_id = EVE.to_string();
assert_eq!(
r.route(&stranger, "D", &unknown),
Route::Ignore(Ignored::NotAllowed)
);
let mut stranger_cmd = post(DM, "", "!approve 1");
stranger_cmd.user_id = EVE.to_string();
assert_eq!(
r.route(&stranger_cmd, "D", &unknown),
Route::Ignore(Ignored::NotAllowed)
);
let naming = "@boxmaker-straylight hello";
assert_eq!(
r.route(&post(OTHER, "", naming), "O", &unknown),
Route::Ignore(Ignored::NotForUs)
);
assert_eq!(
r.route(&post(SHARED, "", naming), "X", &unknown),
Route::Ignore(Ignored::NotForUs)
);
assert!(matches!(
r.route(&post(SHARED, "", naming), "G", &unknown),
Route::Queue(_)
));
assert!(matches!(
r.route(&post(DM, "", "no name needed"), "D", &unknown),
Route::Queue(_)
));
}
#[test]
fn sessions_and_threads() {
let r = router();
let top = queued(r.route(&post(DM, "", "hello"), "D", &unknown));
assert_eq!(top.session.as_str(), format!("mm-{POST}"));
assert_eq!(
top.thread,
Thread {
channel: DM.to_string(),
root: POST.to_string()
}
);
assert!(!top.resume && !top.joins_thread);
assert_eq!(top.text, "hello");
let reply = queued(r.route(&post(DM, ROOT, "more"), "D", &unknown));
assert_eq!(reply.session.as_str(), format!("mm-{ROOT}"));
assert_eq!(reply.thread.root, ROOT);
assert!(reply.resume);
let channel = queued(r.route(&post(SHARED, "", "@boxmaker-straylight hi"), "O", &unknown));
assert!(channel.joins_thread && !channel.resume);
assert_eq!(channel.text, "hi", "the leading mention is stripped (M4b)");
}
#[test]
fn commands() {
let r = router();
let thread = Thread {
channel: DM.to_string(),
root: ROOT.to_string(),
};
for (message, command) in [
("!approve 42", ApprovalCommand::Approve(42)),
(
"!deny 42 not now",
ApprovalCommand::Deny(42, Some("not now".to_string())),
),
("!deny", ApprovalCommand::Malformed),
(
"@boxmaker-straylight !approve 7",
ApprovalCommand::Approve(7),
),
] {
assert_eq!(
r.route(&post(DM, ROOT, message), "D", &unknown),
Route::Approval {
thread: thread.clone(),
command,
user: KYLE.to_string(),
post: POST.to_string(),
at: 5,
},
"{message}"
);
}
for message in ["!approved", "!help", "!"] {
assert_eq!(
r.route(&post(DM, ROOT, message), "D", &unknown),
Route::Reply {
thread: thread.clone(),
text: UNKNOWN_COMMAND.to_string(),
},
"{message}"
);
}
assert_eq!(
queued(r.route(&post(DM, ROOT, "!!approve is a word"), "D", &unknown)).text,
"!approve is a word"
);
assert_eq!(
queued(r.route(&post(DM, ROOT, "!!"), "D", &unknown)).text,
"!"
);
assert_eq!(
queued(r.route(&post(DM, ROOT, " !help"), "D", &unknown)).text,
" !help"
);
}
fn message(root: &str, resume: bool, text: &str) -> Message {
Message {
session: SessionId::new(&format!("mm-{root}")).unwrap(),
thread: Thread {
channel: DM.to_string(),
root: root.to_string(),
},
resume,
text: text.to_string(),
joins_thread: false,
}
}
#[test]
fn messages_wait_for_the_running_turn_and_go_together() {
let mut q = Queues::new(3);
let first = message(ROOT, false, "one");
let Pushed::Start(batch) = q.push(first.clone()) else {
panic!("not started")
};
assert_eq!(
batch,
Batch {
session: first.session.clone(),
thread: first.thread.clone(),
resume: false,
text: "one".to_string()
}
);
assert_eq!(q.push(message(ROOT, true, "two")), Pushed::Waiting);
assert_eq!(q.push(message(ROOT, true, "three\nlines")), Pushed::Waiting);
let other = message(POST, false, "elsewhere");
assert!(
matches!(q.push(other.clone()), Pushed::Start(_)),
"another session starts at once"
);
assert_eq!(q.running(), 2);
let mut roots: Vec<String> = q.threads().into_iter().map(|t| t.root).collect();
roots.sort();
assert_eq!(roots, [POST, ROOT]);
let next = q.finish(&first.session).unwrap();
assert_eq!(
(next.text.as_str(), next.resume),
("two\n\nthree\nlines", true)
);
assert_eq!(q.finish(&first.session), None);
assert_eq!(q.finish(&other.session), None);
assert_eq!(q.running(), 0);
assert!(
matches!(q.push(message(ROOT, true, "later")), Pushed::Start(_)),
"idle again"
);
}
#[test]
fn a_full_queue_drops_the_message() {
let mut q = Queues::new(2);
let m = message(ROOT, false, "run");
assert!(matches!(q.push(m.clone()), Pushed::Start(_)));
assert_eq!(q.push(message(ROOT, true, "a")), Pushed::Waiting);
assert_eq!(q.push(message(ROOT, true, "b")), Pushed::Waiting);
assert_eq!(
q.push(message(ROOT, true, "c")),
Pushed::Full(m.thread.clone())
);
assert_eq!(q.finish(&m.session).unwrap().text, "a\n\nb");
assert!(!BUSY.is_empty());
assert_eq!(q.finish(&SessionId::new("mm-never").unwrap()), None);
}
@@ -0,0 +1,142 @@
//! A leading mention of this bot is stripped before anything reads a message, and approval
//! commands are recognised exactly (M4b spec, section 7). Do not edit.
use gatewayd::mm::Post;
use gatewayd::sessions::{
ApprovalCommand, MAX_REASON, Route, Router, approval_command, strip_mention,
};
const ME: &str = "boxmaker0";
const BOT: &str = "b0000000000000000000000000";
const KYLE: &str = "k0000000000000000000000000";
const SHARED: &str = "c0000000000000000000000000";
const ROOT: &str = "r0000000000000000000000000";
#[test]
fn a_leading_mention_is_stripped() {
assert_eq!(
strip_mention("@boxmaker0 summarise the log", ME),
"summarise the log"
);
assert_eq!(strip_mention("@BoxMaker0 !approve 42", ME), "!approve 42");
assert_eq!(
strip_mention("@boxmaker0 @hermes compare notes", ME),
"@hermes compare notes"
);
assert_eq!(strip_mention("@boxmaker0, look", ME), ", look");
assert_eq!(
strip_mention("@boxmaker0\nsecond line", ME),
"\nsecond line",
"only spaces go with it"
);
}
#[test]
fn anything_else_is_kept() {
for message in [
"@boxmaker0",
"@boxmaker0 ",
"@boxmaker0x hello",
"@hermes @boxmaker0 hello",
"hello @boxmaker0",
" @boxmaker0 hello",
"@ boxmaker0 hello",
"@@boxmaker0 hello",
"",
] {
assert_eq!(strip_mention(message, ME), message, "{message:?}");
}
assert_eq!(
strip_mention("@boxmaker0. hi", ME),
". hi",
"the trailing dot is not part of the name"
);
}
#[test]
fn approval_commands_exactly() {
use ApprovalCommand::{Approve, Deny, Malformed};
let cases: &[(&str, Option<ApprovalCommand>)] = &[
("!approve 42", Some(Approve(42))),
("!approve 0", Some(Approve(0))),
("!approve 18446744073709551615", Some(Approve(u64::MAX))),
("!deny 42", Some(Deny(42, None))),
(
"!deny 42 not on a Friday",
Some(Deny(42, Some("not on a Friday".to_string()))),
),
(
"!deny 42 two spaces",
Some(Deny(42, Some(" two spaces".to_string()))),
),
("!approve", Some(Malformed)),
("!approve 42", Some(Malformed)),
("!approve 42 ", Some(Malformed)),
("!approve 42 now", Some(Malformed)),
("!approve +42", Some(Malformed)),
("!approve 4x2", Some(Malformed)),
("!approve 18446744073709551616", Some(Malformed)),
("!deny", Some(Malformed)),
("!deny 42 ", Some(Malformed)),
("!deny x reason", Some(Malformed)),
("!approved 42", None),
("!denied 42", None),
("!help", None),
("approve 42", None),
("!Approve 42", None),
];
for (text, want) in cases {
assert_eq!(&approval_command(text), want, "{text:?}");
}
let long = format!("!deny 42 {}", "é".repeat(MAX_REASON));
assert!(
matches!(approval_command(&long), Some(Deny(42, Some(_)))),
"characters, not bytes"
);
let over = format!("!deny 42 {}", "x".repeat(MAX_REASON + 1));
assert_eq!(approval_command(&over), Some(Malformed));
}
fn post(message: &str, root: &str) -> Post {
Post {
id: "p0000000000000000000000001".to_string(),
user_id: KYLE.to_string(),
channel_id: SHARED.to_string(),
root_id: root.to_string(),
message: message.to_string(),
create_at: 9,
delete_at: 0,
kind: String::new(),
}
}
#[test]
fn in_a_shared_thread_the_mention_picks_the_bot() {
let me = Router::new(BOT, ME, &[KYLE.to_string()], &[SHARED.to_string()]);
let ours = |root: &str| root == ROOT;
let addressed = me.route(&post("@boxmaker0 !approve 42", ROOT), "O", &ours);
assert!(
matches!(
addressed,
Route::Approval {
command: ApprovalCommand::Approve(42),
..
}
),
"{addressed:?}"
);
let other = me.route(&post("@boxmaker1 !approve 42", ROOT), "O", &ours);
assert!(
matches!(other, Route::Ignore(_)),
"addressed to another bot: {other:?}"
);
let bare = me.route(&post("!approve 42", ROOT), "O", &ours);
assert!(
matches!(bare, Route::Approval { .. }),
"a bare command in our thread is ours too: {bare:?}"
);
match me.route(&post("@boxmaker0 hello there", ""), "O", &ours) {
Route::Queue(m) => assert_eq!(m.text, "hello there"),
other => panic!("{other:?}"),
}
}
@@ -0,0 +1,95 @@
//! The approval posts in the state file, for marking them at a restart; an M4a state file still
//! loads; and the turn of a session (M4b spec, sections 6 and 8). Do not edit.
#[path = "support/tmp.rs"]
mod tmp;
use gatewayd::state::{ApprovalPosts, InFlight, State, StateError};
use tmp::TempDir;
const DM: &str = "d0000000000000000000000000";
const ROOT: &str = "r0000000000000000000000000";
fn id(n: u32) -> String {
format!("p{n:025}")
}
fn shown(approval: u64, posts: &[u32]) -> ApprovalPosts {
ApprovalPosts {
approval,
channel: DM.to_string(),
posts: posts.iter().map(|n| id(*n)).collect(),
}
}
#[test]
fn approval_posts_survive_a_restart_and_are_taken_once() {
let dir = TempDir::new("state-approvals");
let path = dir.path().join("gateway/state.json");
let mut s = State::load(&path).unwrap();
s.add_approval(shown(42, &[1, 2])).unwrap();
s.add_approval(shown(43, &[3])).unwrap();
s.add_approval(shown(42, &[4])).unwrap();
s.remove_approval(43).unwrap();
s.remove_approval(99).unwrap();
let mut again = State::load(&path).unwrap();
assert_eq!(
again.take_approvals().unwrap(),
[shown(42, &[4])],
"the later posting of 42 replaced the first"
);
assert!(
State::load(&path)
.unwrap()
.take_approvals()
.unwrap()
.is_empty(),
"taking is saved"
);
}
#[test]
fn an_m4a_state_file_loads() {
let dir = TempDir::new("state-m4a");
let text = format!(r#"{{"channels":{{"{DM}":5}},"recent":[],"threads":[],"in_flight":[]}}"#);
let path = dir.write("state.json", &text);
let mut s = State::load(&path).unwrap();
assert_eq!(s.since(DM), Some(5));
assert!(s.take_approvals().unwrap().is_empty());
}
#[test]
fn bad_approval_posts_are_refused() {
let dir = TempDir::new("state-approvals-bad");
for (n, approvals) in [
r#"[{"approval":1,"channel":"../x","posts":["p0000000000000000000000001"]}]"#,
r#"[{"approval":1,"channel":"d0000000000000000000000000","posts":[]}]"#,
r#"[{"approval":1,"channel":"d0000000000000000000000000","posts":["short"]}]"#,
r#"[{"approval":1,"channel":"d0000000000000000000000000","posts":["p0000000000000000000000001"],"extra":1}]"#,
r#"[{"approval":-1,"channel":"d0000000000000000000000000","posts":["p0000000000000000000000001"]}]"#,
]
.iter()
.enumerate()
{
let text = format!(r#"{{"channels":{{}},"recent":[],"threads":[],"in_flight":[],"approvals":{approvals}}}"#);
let path = dir.write(&format!("s{n}.json"), &text);
assert!(matches!(State::load(&path), Err(StateError::Read(..))), "{approvals}");
}
}
#[test]
fn the_turn_of_a_session() {
let dir = TempDir::new("state-turn-of");
let mut s = State::load(&dir.path().join("state.json")).unwrap();
let turn = InFlight {
session: format!("mm-{ROOT}"),
channel: DM.to_string(),
root: ROOT.to_string(),
};
assert_eq!(s.turn_of(&turn.session), None);
s.start_turn(turn.clone()).unwrap();
assert_eq!(s.turn_of(&turn.session), Some(turn.clone()));
assert_eq!(s.turn_of("mm-other"), None);
s.end_turn(&turn.session).unwrap();
assert_eq!(s.turn_of(&turn.session), None);
}
@@ -0,0 +1,97 @@
//! `brokerd`'s side of one `gateway.sock` connection, for tests: ask, read what comes back,
//! answer with an outcome, or close. Do not edit.
#![allow(dead_code)] // each test file uses a different part of this module
use std::os::unix::net::UnixStream;
use std::path::Path;
use std::time::Duration;
use proto::{
ApprovalOutcome, ApprovalReply, ApprovalRequest, CallId, DataClass, Envelope, GatewayOutcome,
Message, PROTOCOL_VERSION, PendingApproval, SessionId, Timestamp, read_frame, write_frame,
};
pub struct Asking {
stream: UnixStream,
pub posts: Vec<String>,
}
/// An approval of `session`, with these arguments.
pub fn pending(approval: u64, session: &str, arguments: &str) -> PendingApproval {
PendingApproval {
approval,
session: SessionId::new(session).unwrap(),
call: CallId(3),
tool: "read_file".to_string(),
arguments: arguments.to_string(),
grant: "notes".to_string(),
taint: DataClass::Private,
created: Timestamp::parse("2026-09-24T08:05:00.000Z").unwrap(),
expires: Timestamp::parse("2026-09-24T08:20:00.000Z").unwrap(),
}
}
fn frame(id: u64, last: bool, msg: Message) -> Envelope {
Envelope {
v: PROTOCOL_VERSION,
id,
r#final: last,
msg,
}
}
/// Connect and ask; `Ok` with the posts, or `Err` with gatewayd's error detail.
pub fn ask(socket: &Path, info: PendingApproval) -> Result<Asking, String> {
let mut stream = UnixStream::connect(socket).map_err(|e| e.to_string())?;
stream
.set_read_timeout(Some(Duration::from_secs(10)))
.unwrap();
write_frame(
&mut stream,
&frame(
1,
true,
Message::ApprovalRequest(ApprovalRequest { approval: info }),
),
)
.unwrap();
match read_frame(&mut stream).map_err(|e| e.to_string())?.msg {
Message::ApprovalPosted(p) => Ok(Asking {
stream,
posts: p.posts,
}),
Message::Error(e) => Err(e.detail),
other => panic!("{other:?}"),
}
}
impl Asking {
/// The answer, within `wait`, or `None`.
pub fn answer(&mut self, wait: Duration) -> Option<ApprovalReply> {
self.stream.set_read_timeout(Some(wait)).unwrap();
match read_frame(&mut self.stream) {
Ok(Envelope {
msg: Message::ApprovalReply(r),
..
}) => Some(r),
_ => None,
}
}
pub fn outcome(&mut self, outcome: GatewayOutcome) {
write_frame(
&mut self.stream,
&frame(
2,
true,
Message::ApprovalOutcome(ApprovalOutcome { outcome }),
),
)
.unwrap();
}
pub fn close(self) {
let _ = self.stream.shutdown(std::net::Shutdown::Both);
}
}
@@ -0,0 +1,381 @@
//! A fake Mattermost on 127.0.0.1, plain TCP: the four REST calls `gatewayd` makes, and the
//! WebSocket, whose events the test sends and whose requests it reads. Do not edit.
#![allow(dead_code)] // each test file uses a different part of this module
use std::collections::HashMap;
use std::io::{Read, Write};
use std::net::{Shutdown, SocketAddr, TcpListener, TcpStream};
use std::sync::{Arc, Mutex, mpsc};
use std::time::{Duration, Instant};
use gatewayd::ws::handshake::accept_for;
use serde_json::{Value, json};
pub const BOT: &str = "b0000000000000000000000000";
pub const BOT_NAME: &str = "boxmaker-straylight";
pub const KYLE: &str = "k0000000000000000000000000";
pub const EVE: &str = "e0000000000000000000000000";
/// The direct channel between the bot and Kyle, and between the bot and anyone else.
pub const DM: &str = "d0000000000000000000000000";
pub const EVE_DM: &str = "f0000000000000000000000000";
#[derive(Default)]
struct Inner {
/// Status for `users/me`: 200 unless a test sets another.
me_status: u16,
/// The body for `channels/<id>/posts?since=`, by channel.
since: HashMap<String, Value>,
/// Every post made: channel, root, message.
posts: Vec<(String, String, String)>,
/// Every REST call: method and path.
calls: Vec<(String, String)>,
/// Every edit: the post and its new text.
edits: Vec<(String, String)>,
/// When set, `POST /posts` is refused with 400.
refuse_posts: bool,
}
pub struct FakeMm {
pub addr: SocketAddr,
inner: Arc<Mutex<Inner>>,
sockets: Mutex<mpsc::Receiver<WsPeer>>,
}
/// One WebSocket connection from `gatewayd`.
pub struct WsPeer {
writer: TcpStream,
/// The text of every text frame `gatewayd` sends.
pub texts: mpsc::Receiver<String>,
}
/// A post as Mattermost sends it.
pub fn post(
id: &str,
user: &str,
channel: &str,
root: &str,
message: &str,
create_at: i64,
) -> Value {
json!({
"id": id, "create_at": create_at, "update_at": create_at, "delete_at": 0, "user_id": user,
"channel_id": channel, "root_id": root, "message": message, "type": "", "props": {}
})
}
pub fn id(prefix: char, n: u32) -> String {
format!("{prefix}{n:025}")
}
fn frame(opcode: u8, payload: &[u8]) -> Vec<u8> {
let mut out = vec![0x80 | opcode];
match payload.len() {
n if n < 126 => out.push(n as u8),
n => {
out.push(126);
out.extend_from_slice(&(n as u16).to_be_bytes());
}
}
out.extend_from_slice(payload);
out
}
impl WsPeer {
pub fn event(&mut self, value: &Value) {
let _ = self
.writer
.write_all(&frame(0x1, value.to_string().as_bytes()));
}
pub fn posted(&mut self, post: &Value, channel_type: &str) {
let data = json!({"post": post.to_string(), "channel_type": channel_type, "team_id": ""});
self.event(&json!({"event": "posted", "data": data, "broadcast": {}, "seq": 1}));
}
/// End the connection without a close frame.
pub fn drop_connection(self) {
let _ = self.writer.shutdown(Shutdown::Both);
}
/// The `user_typing` requests received within `wait`, as (channel, parent).
pub fn typing_within(&self, wait: Duration) -> Vec<(String, String)> {
let until = Instant::now() + wait;
let mut got = Vec::new();
while let Ok(text) = self
.texts
.recv_timeout(until.saturating_duration_since(Instant::now()))
{
let v: Value = serde_json::from_str(&text).unwrap();
if v["action"] == "user_typing" {
let data = &v["data"];
got.push((
data["channel_id"].as_str().unwrap().to_string(),
data["parent_id"].as_str().unwrap().to_string(),
));
}
}
got
}
}
fn read_head(stream: &mut TcpStream) -> Option<String> {
let mut head = Vec::new();
let mut byte = [0u8; 1];
while !head.ends_with(b"\r\n\r\n") {
if stream.read(&mut byte).ok()? == 0 {
return None;
}
head.push(byte[0]);
}
String::from_utf8(head).ok()
}
/// Unmask the client's frames and send each text on `tx`, until the connection ends.
fn read_frames(mut stream: TcpStream, tx: mpsc::Sender<String>) {
let mut exact = |n: usize| -> Option<Vec<u8>> {
let mut buf = vec![0u8; n];
stream.read_exact(&mut buf).ok().map(|()| buf)
};
loop {
let Some(head) = exact(2) else { return };
let len = match head[1] & 0x7F {
126 => u16::from_be_bytes(exact(2).unwrap().try_into().unwrap()) as usize,
127 => return,
n => n as usize,
};
let Some(mask) = exact(4) else { return };
let Some(raw) = exact(len) else { return };
let payload: Vec<u8> = raw
.iter()
.zip(mask.iter().cycle())
.map(|(b, m)| b ^ m)
.collect();
if head[0] & 0x0F == 0x1 {
let _ = tx.send(String::from_utf8(payload).unwrap());
}
}
}
impl FakeMm {
pub fn start() -> FakeMm {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let inner = Arc::new(Mutex::new(Inner {
me_status: 200,
..Inner::default()
}));
let (ws_tx, ws_rx) = mpsc::channel();
let shared = Arc::clone(&inner);
std::thread::spawn(move || {
for stream in listener.incoming() {
let Ok(stream) = stream else { continue };
let (inner, ws_tx) = (Arc::clone(&shared), ws_tx.clone());
std::thread::spawn(move || connection(stream, &inner, &ws_tx));
}
});
FakeMm {
addr,
inner,
sockets: Mutex::new(ws_rx),
}
}
pub fn url(&self) -> String {
format!("http://127.0.0.1:{}", self.addr.port())
}
pub fn refuse_token(&self) {
self.inner.lock().unwrap().me_status = 401;
}
pub fn set_since(&self, channel: &str, posts: &[Value]) {
let order: Vec<Value> = posts.iter().map(|p| p["id"].clone()).collect();
let map: serde_json::Map<String, Value> = posts
.iter()
.map(|p| (p["id"].as_str().unwrap().to_string(), p.clone()))
.collect();
self.inner
.lock()
.unwrap()
.since
.insert(channel.to_string(), json!({"order": order, "posts": map}));
}
/// The next WebSocket `gatewayd` opens, after its hello.
pub fn next_ws(&self, wait: Duration) -> WsPeer {
self.sockets
.lock()
.unwrap()
.recv_timeout(wait)
.expect("no WebSocket connection")
}
pub fn posts(&self) -> Vec<(String, String, String)> {
self.inner.lock().unwrap().posts.clone()
}
/// Wait until at least `n` posts were made, for at most `wait`.
pub fn wait_posts(&self, n: usize, wait: Duration) -> Vec<(String, String, String)> {
let until = Instant::now() + wait;
while self.posts().len() < n && Instant::now() < until {
std::thread::sleep(Duration::from_millis(10));
}
self.posts()
}
pub fn calls(&self) -> Vec<(String, String)> {
self.inner.lock().unwrap().calls.clone()
}
/// The id of the n-th post made (from 0).
pub fn post_id(n: usize) -> String {
id('x', u32::try_from(n).unwrap())
}
pub fn edits(&self) -> Vec<(String, String)> {
self.inner.lock().unwrap().edits.clone()
}
/// Wait until at least `n` edits were made, for at most `wait`.
pub fn wait_edits(&self, n: usize, wait: Duration) -> Vec<(String, String)> {
let until = Instant::now() + wait;
while self.edits().len() < n && Instant::now() < until {
std::thread::sleep(Duration::from_millis(10));
}
self.edits()
}
pub fn refuse_posts(&self, refuse: bool) {
self.inner.lock().unwrap().refuse_posts = refuse;
}
}
fn connection(mut stream: TcpStream, inner: &Mutex<Inner>, ws_tx: &mpsc::Sender<WsPeer>) {
let Some(head) = read_head(&mut stream) else {
return;
};
let mut words = head.split_whitespace();
let (method, path) = (
words.next().unwrap_or("").to_string(),
words.next().unwrap_or("").to_string(),
);
if path == "/api/v4/websocket" {
let key = head
.lines()
.find_map(|l| l.strip_prefix("Sec-WebSocket-Key: "))
.unwrap_or("")
.trim()
.to_string();
let reply = format!(
"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: {}\r\n\r\n",
accept_for(&key)
);
let _ = stream.write_all(reply.as_bytes());
let _ = stream.write_all(&frame(
0x1,
br#"{"event":"hello","data":{},"broadcast":{},"seq":0}"#,
));
let (tx, texts) = mpsc::channel();
let reader = stream.try_clone().unwrap();
std::thread::spawn(move || read_frames(reader, tx));
let _ = ws_tx.send(WsPeer {
writer: stream,
texts,
});
return;
}
let length = head
.lines()
.find_map(|l| {
l.to_ascii_lowercase()
.strip_prefix("content-length:")
.map(|v| v.trim().parse::<usize>().unwrap_or(0))
})
.unwrap_or(0);
let mut body = vec![0u8; length];
let _ = stream.read_exact(&mut body);
let (status, answer) = rest(inner, &method, &path, &body);
let text = answer.to_string();
let reply = format!(
"HTTP/1.1 {status} X\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{text}",
text.len()
);
let _ = stream.write_all(reply.as_bytes());
}
fn rest(inner: &Mutex<Inner>, method: &str, path: &str, body: &[u8]) -> (u16, Value) {
let mut inner = inner.lock().unwrap();
inner.calls.push((method.to_string(), path.to_string()));
match (method, path) {
("GET", "/api/v4/users/me") if inner.me_status == 200 => {
(200, json!({"id": BOT, "username": BOT_NAME}))
}
("GET", "/api/v4/users/me") => (
inner.me_status,
json!({"id": "api.context.session_expired.app_error"}),
),
("POST", "/api/v4/channels/direct") => {
let users: Vec<String> = serde_json::from_slice(body).unwrap();
let channel = if users.iter().any(|u| u == KYLE) {
DM
} else {
EVE_DM
};
(201, json!({"id": channel, "type": "D"}))
}
("POST", "/api/v4/posts") if inner.refuse_posts => {
(400, json!({"id": "api.post.create_post.refused"}))
}
("POST", "/api/v4/posts") => {
let p: Value = serde_json::from_slice(body).unwrap();
let n = u32::try_from(inner.posts.len()).unwrap();
let (channel, root, message) = (
p["channel_id"].as_str().unwrap(),
p["root_id"].as_str().unwrap(),
p["message"].as_str().unwrap(),
);
inner
.posts
.push((channel.to_string(), root.to_string(), message.to_string()));
(201, post(&id('x', n), BOT, channel, root, message, 1))
}
("GET", p) if p.contains("/posts?since=") => {
let channel = p
.trim_start_matches("/api/v4/channels/")
.split('/')
.next()
.unwrap_or("");
(
200,
inner
.since
.get(channel)
.cloned()
.unwrap_or(json!({"order": [], "posts": {}})),
)
}
("PUT", p) if p.starts_with("/api/v4/posts/") && p.ends_with("/patch") => {
let post_id = p
.trim_start_matches("/api/v4/posts/")
.trim_end_matches("/patch")
.to_string();
let message = serde_json::from_slice::<Value>(body).unwrap()["message"]
.as_str()
.unwrap()
.to_string();
inner.edits.push((post_id.clone(), message.clone()));
(200, post(&post_id, BOT, DM, "", &message, 1))
}
("GET", p) if p.starts_with("/api/v4/users/") => {
let user = p.trim_start_matches("/api/v4/users/");
let name = if user == KYLE {
"kyle".to_string()
} else {
format!("user-{}", &user[..4])
};
(200, json!({"id": user, "username": name}))
}
_ => (404, json!({"message": "not found"})),
}
}
@@ -0,0 +1,142 @@
//! Running `gatewayd`'s serve loop in a test, against the fake Mattermost and the fake `loopd`.
//! Do not edit.
#![allow(dead_code)] // each test file uses a different part of this module
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, mpsc};
use std::thread::JoinHandle;
use std::time::Duration;
use gatewayd::config::Config;
use gatewayd::secrets::Secret;
use gatewayd::serve::{Stop, Tuning, run};
use proto::Turn;
use crate::fake_loop::{done, serve_loop};
use crate::fake_mm::{FakeMm, KYLE, WsPeer};
use crate::tmp::TempDir;
pub const SHARED: &str = "c0000000000000000000000000";
pub const OTHER: &str = "o0000000000000000000000000";
pub const WAIT: Duration = Duration::from_secs(5);
pub struct Running {
pub stop: Arc<AtomicBool>,
pub log: Arc<Mutex<Vec<String>>>,
pub handle: Option<JoinHandle<Stop>>,
}
impl Running {
pub fn log(&self) -> Vec<String> {
self.log.lock().unwrap().clone()
}
pub fn wait_log(&self, part: &str) -> Vec<String> {
let until = std::time::Instant::now() + WAIT;
while !self.log().iter().any(|l| l.contains(part)) {
assert!(
std::time::Instant::now() < until,
"no log line with {part:?}: {:?}",
self.log()
);
std::thread::sleep(Duration::from_millis(10));
}
self.log()
}
/// The `Stop` `run` returns by itself within 5 s; after that it is stopped, and the test fails.
pub fn join_within(mut self) -> Stop {
let handle = self.handle.take().unwrap();
let until = std::time::Instant::now() + WAIT;
while !handle.is_finished() && std::time::Instant::now() < until {
std::thread::sleep(Duration::from_millis(10));
}
self.stop.store(true, Ordering::SeqCst);
let stop = handle.join().unwrap();
assert!(!matches!(stop, Stop::Asked), "run did not stop by itself");
stop
}
pub fn finish(mut self) -> Stop {
self.stop.store(true, Ordering::SeqCst);
self.handle.take().unwrap().join().unwrap()
}
}
impl Drop for Running {
fn drop(&mut self) {
self.stop.store(true, Ordering::SeqCst);
}
}
pub fn config(home: &TempDir, url: &str, extra: &str) -> Config {
let text = format!(
r#"
[mattermost]
url = "{url}"
[secrets.mattermost_token]
env = "NOT_READ_BY_RUN"
[allow]
users = ["{KYLE}"]
channels = ["{SHARED}"]
[paths]
home = "{}"
[limits]
typing_every_ms = 100
{extra}
"#,
home.path().display()
);
Config::parse(&text).unwrap()
}
pub fn start(config: Config) -> Running {
let stop = Arc::new(AtomicBool::new(false));
let log = Arc::new(Mutex::new(Vec::new()));
let tuning = Tuning {
backoff: vec![Duration::from_millis(50)],
poll: Duration::from_millis(20),
rest_timeout: WAIT,
notice: Duration::from_millis(300),
};
let (s, l) = (Arc::clone(&stop), Arc::clone(&log));
let handle = std::thread::spawn(move || {
let sink: gatewayd::serve::Log =
Arc::new(move |line: &str| l.lock().unwrap().push(line.to_string()));
run(config, Secret::new("TOKEN".to_string()), tuning, sink, &s)
});
Running {
stop,
log,
handle: Some(handle),
}
}
/// A fake loop that answers every turn with "answer to <content>", after `delay`.
pub fn answering(home: &TempDir, delay: Duration) -> mpsc::Receiver<Turn> {
serve_loop(&home.path().join("run/loop/loop.sock"), move |_, turn| {
std::thread::sleep(delay);
vec![done(&format!("answer to {}", turn.content))]
})
}
pub fn read_state(home: &TempDir) -> serde_json::Value {
let text = std::fs::read_to_string(home.path().join("gateway/state.json")).unwrap();
serde_json::from_str(&text).unwrap()
}
pub fn loop_dir(home: &TempDir) {
std::fs::create_dir_all(home.path().join("run/loop")).unwrap();
}
/// Start with a fake Mattermost and a fake loop; the first WebSocket is returned.
pub fn up(home: &TempDir, delay: Duration) -> (FakeMm, Running, mpsc::Receiver<Turn>, WsPeer) {
loop_dir(home);
let turns = answering(home, delay);
let mm = FakeMm::start();
let running = start(config(home, &mm.url(), ""));
let ws = mm.next_ws(WAIT);
running.wait_log("gatewayd: connected to ");
(mm, running, turns, ws)
}