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:
@@ -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()),
|
||||
¬ice,
|
||||
);
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user