M4b review, and follow-up tasks 16 to 18

The review of tasks 01 to 15 goes in the implementer log, with the
device check against the owner's Mattermost. Three follow-ups:

- 16: the refusal without a runner says why.
- 17: the texts that say who asked.
- 18: approvals from non-approvers in direct or group messages go to
  an approver.

Task 14's given serve_approvals.rs becomes serve_approvals.rs-task14,
since task 18 hands over a new version. Tip T31 is added.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-24 11:35:24 -07:00
co-authored by Claude Opus 5.5
parent d2e814161f
commit f6dd7f1741
12 changed files with 1202 additions and 19 deletions
@@ -0,0 +1,178 @@
//! The runner seam: what `run` puts in the `RunSpec` for each tool, and what it answers. Do not
//! edit.
#[path = "support/build.rs"]
mod build;
#[path = "support/runtime.rs"]
mod runtime;
use brokerd::args::{ToolArgs, ToolName};
use brokerd::policy::{Decision, Outcome, SessionState, decide};
use brokerd::runner::{Mount, REFUSING, Refusing, RunError, RunOutput, run};
use build::{grant, now, read, request, set};
use proto::{DataClass, Mode, ToolRequest, ToolResponse};
use runtime::Recording;
fn allowed(grants: Vec<build::Build>, request: ToolRequest) -> Decision {
match decide(request, &set(grants), SessionState::default(), now()) {
Outcome::Allowed(decision) => decision,
other => panic!("the test's call is not allowed: {other:?}"),
}
}
fn mount(path: &str, writable: bool) -> Mount {
Mount {
path: path.to_string(),
writable,
}
}
#[test]
fn read_file_mounts_the_matched_path_read_only_and_has_no_network() {
let d = allowed(
vec![grant("notes", "read_file", Mode::Auto).paths(&["/h/notes", "/h/notes/deep"])],
read("/h/notes/deep/a.md"),
);
let rt = Recording::answering("text");
run(d, rt.as_ref());
let seen = rt.seen();
assert_eq!(seen.len(), 1);
assert_eq!(seen[0].tool, ToolName::ReadFile);
assert_eq!(
seen[0].arguments,
ToolArgs::ReadFile {
path: "/h/notes/deep/a.md".to_string()
}
);
// The longest path that holds the argument, and only that one.
assert_eq!(seen[0].mounts, [mount("/h/notes/deep", false)]);
assert_eq!(seen[0].egress, None);
}
#[test]
fn write_file_mounts_the_matched_path_writable() {
// A grant path equal to the argument does not count, so `/s/out` is written through `/s`.
let d = allowed(
vec![grant("s", "write_file", Mode::Auto).paths(&["/s", "/s/out"])],
request("write_file", r#"{"path":"/s/out","content":"x"}"#),
);
let rt = Recording::answering("");
run(d, rt.as_ref());
let seen = rt.seen();
assert_eq!(seen[0].tool, ToolName::WriteFile);
assert_eq!(seen[0].mounts, [mount("/s", true)]);
assert_eq!(seen[0].egress, None);
}
#[test]
fn shell_mounts_every_path_of_the_grant_writable() {
let d = allowed(
vec![grant("sh", "shell", Mode::Auto).paths(&["/a", "/b/c"])],
request("shell", r#"{"command":"ls","cwd":"/b/c/d"}"#),
);
let rt = Recording::answering("");
run(d, rt.as_ref());
let seen = rt.seen();
assert_eq!(seen[0].tool, ToolName::Shell);
assert_eq!(seen[0].mounts, [mount("/a", true), mount("/b/c", true)]);
assert_eq!(seen[0].egress, None);
}
#[test]
fn shell_without_paths_mounts_nothing() {
let d = allowed(
vec![grant("sh", "shell", Mode::Auto)],
request("shell", r#"{"command":"date"}"#),
);
let rt = Recording::answering("");
run(d, rt.as_ref());
let seen = rt.seen();
assert_eq!(seen[0].mounts, []);
assert_eq!(seen[0].egress, None);
}
#[test]
fn http_fetch_mounts_nothing_and_may_reach_the_grants_hosts_only() {
let d = allowed(
vec![grant("web", "http_fetch", Mode::Auto).hosts(&["example.com", "*.example.org"])],
request("http_fetch", r#"{"url":"https://www.example.org/x"}"#),
);
let rt = Recording::answering("");
run(d, rt.as_ref());
let seen = rt.seen();
assert_eq!(seen[0].tool, ToolName::HttpFetch);
assert_eq!(seen[0].mounts, []);
assert_eq!(
seen[0].egress,
Some(vec!["example.com".to_string(), "*.example.org".to_string()])
);
}
#[test]
fn a_result_carries_the_label_combined_over_every_matching_grant() {
// `b-keys` has the longer path and wins the mount; the label is the highest class of both
// grants, and untrusted because `a-home` says so.
let d = allowed(
vec![
grant("a-home", "read_file", Mode::Auto)
.paths(&["/home/kyle"])
.class(DataClass::Private),
grant("b-keys", "read_file", Mode::Auto)
.paths(&["/home/kyle/keys"])
.class(DataClass::Secret)
.trusted(),
],
read("/home/kyle/keys/id"),
);
let rt = Recording::with(Ok(RunOutput {
content: "key".to_string(),
truncated: true,
}));
let answer = run(d, rt.as_ref());
assert_eq!(
answer,
ToolResponse::Result {
content: "key".to_string(),
class: DataClass::Secret,
untrusted: true,
truncated: true,
}
);
assert_eq!(rt.seen()[0].mounts, [mount("/home/kyle/keys", false)]);
}
#[test]
fn a_run_error_is_a_failure_with_the_runtimes_sentence() {
for error in [
RunError::Failed("the tool timed out".to_string()),
RunError::Unavailable("the container could not start".to_string()),
] {
let d = allowed(
vec![grant("n", "read_file", Mode::Auto).paths(&["/n"])],
read("/n/a"),
);
let text = match &error {
RunError::Failed(t) | RunError::Unavailable(t) => t.clone(),
};
let rt = Recording::with(Err(error));
assert_eq!(run(d, rt.as_ref()), ToolResponse::Failed { message: text });
}
}
#[test]
fn the_production_runtime_refuses_every_call() {
assert_eq!(
REFUSING,
"no tool runner is configured: brokerd.toml has no [runner] section"
);
let d = allowed(
vec![grant("n", "read_file", Mode::Auto).paths(&["/n"])],
read("/n/a"),
);
assert_eq!(
run(d, &Refusing),
ToolResponse::Failed {
message: REFUSING.to_string()
}
);
}
@@ -0,0 +1,86 @@
//! The posts that tell an approver who asked for a redirected approval and what, and the line left
//! in the asking thread (M4b spec, section 6, after the review). Do not edit.
use gatewayd::approvals::text::{
Asked, PROMPT_SHOWN, asked_post, sent_to_approver, unfollowed_post,
};
/// 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 asked(prompt: &str, channel_type: &str) -> Asked {
Asked {
by: "@alex".to_string(),
channel_type: channel_type.to_string(),
prompt: prompt.to_string(),
}
}
#[test]
fn who_where_and_what_they_asked() {
assert_eq!(
asked_post(
3,
&asked("Read the file /srv/notes/q3.txt and summarise it.", "D")
),
"**Approval 3** was asked by @alex in a direct message with the bot. Their message:\n```\nRead the file /srv/notes/q3.txt and summarise it.\n```"
);
let group = asked_post(4, &asked("first\n\nsecond", "G"));
assert_eq!(
group,
"**Approval 4** was asked by @alex in a group message with the bot. Their message:\n```\nfirst\n\nsecond\n```",
"a burst keeps its lines"
);
}
#[test]
fn the_message_is_escaped_and_fenced() {
let hostile = format!(
"see ``` and @channel{}{}",
char::from_u32(0x202e).unwrap(),
char::from(7)
);
let post = asked_post(5, &asked(&hostile, "D"));
assert!(
post.contains(&format!(
"\n````\nsee ``` and @channel{}{}\n````",
esc(0x202e),
esc(7)
)),
"{post}"
);
assert!(!post.contains(char::from_u32(0x202e).unwrap()) && !post.contains(char::from(7)));
}
#[test]
fn a_long_message_is_cut_and_says_how_much() {
let long = "é".repeat(PROMPT_SHOWN + 25);
let post = asked_post(6, &asked(&long, "D"));
assert!(
post.ends_with(&format!(
"\n```\n{}\n```\n(… 25 more characters)",
"é".repeat(PROMPT_SHOWN)
)),
"{}",
&post[post.len() - 80..]
);
let exact = asked_post(6, &asked(&"x".repeat(PROMPT_SHOWN), "D"));
assert!(
!exact.contains("more characters"),
"nothing cut, nothing said"
);
}
#[test]
fn an_unfollowed_session_and_the_note() {
assert_eq!(
unfollowed_post(9, "chat-1789700000-1"),
"**Approval 9** was asked in session `chat-1789700000-1`, which gatewayd does not follow now (a restart, or a session from outside Mattermost)."
);
assert_eq!(
sent_to_approver(3),
"waiting for approval 3: it was sent to an approver"
);
}
@@ -112,28 +112,41 @@ fn without_a_turn_in_flight_it_goes_to_the_first_approvers_direct_messages_in_pa
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.len(), 3, "a first post, then 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]
posts[0]
.2
.starts_with("**Approval 7** was asked in session `chat-1789700000-1`"),
"{}",
posts[0].2
);
for (n, part) in posts[1..].iter().enumerate() {
assert_eq!(
(part.0.as_str(), part.1.clone()),
(DM, FakeMm::post_id(0)),
"part {n} in its thread"
);
}
assert!(posts[1].2.starts_with("**Approval 7**"), "{}", posts[1].2);
assert!(
posts[2]
.2
.contains("Reply in this thread with `!approve 7`")
);
assert_eq!(asking.posts, [FakeMm::post_id(0), FakeMm::post_id(1)]);
assert_eq!(
asking.posts,
[FakeMm::post_id(0), FakeMm::post_id(1), FakeMm::post_id(2)]
);
asking.close();
let edits = mm.wait_edits(1, WAIT);
assert_eq!(
edits[0].0,
FakeMm::post_id(1),
FakeMm::post_id(2),
"only the last part is edited"
);
}
@@ -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,198 @@
//! An approval asked for by someone who may talk but not approve, in a direct or group message,
//! goes to the first approver's direct messages: first who asked and what, then the approval, and
//! the asking thread is told where it went. In a channel it stays in its thread (M4b spec,
//! section 6). 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::sync::{Mutex, mpsc};
use std::time::Duration;
use brokerd_client::{Asking, ask, pending};
use fake_loop::{done, serve_loop};
use fake_mm::{DM, EVE, EVE_DM, FakeMm, KYLE, WsPeer, id, post};
use gateway::{Running, WAIT, loop_dir, start};
use gatewayd::config::Config;
use proto::{GatewayOutcome, Turn};
use tmp::TempDir;
const GROUP: &str = "g0000000000000000000000000";
const CHANNEL: &str = "o0000000000000000000000000";
/// KYLE and EVE may talk, in direct messages, GROUP and CHANNEL; 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}\"]\nchannels = [\"{GROUP}\", \"{CHANNEL}\"]\n[approvals]\nusers = [\"{KYLE}\"]\n[paths]\nhome = \"{}\"\n[limits]\ntyping_every_ms = 100\n",
home.path().display()
);
Config::parse(&text).unwrap()
}
/// A loop whose turns wait for `go`, then answer "done".
fn held(home: &TempDir) -> (mpsc::Receiver<Turn>, mpsc::Sender<()>) {
loop_dir(home);
let (go_tx, go_rx) = mpsc::channel::<()>();
let go = Mutex::new(go_rx);
let turns = serve_loop(&home.path().join("run/loop/loop.sock"), move |_, _| {
let _ = go.lock().unwrap().recv_timeout(Duration::from_secs(20));
vec![done("done")]
});
(turns, go_tx)
}
struct Stage {
_home: TempDir,
mm: FakeMm,
_running: Running,
ws: WsPeer,
go: mpsc::Sender<()>,
asking: Asking,
root: String,
}
/// gatewayd up, EVE's message `text` in `channel` (of `channel_type`), its turn held, and
/// approval 42 asked for its session.
fn stage(tag: &str, channel: &str, channel_type: &str, text: &str) -> Stage {
let home = TempDir::new(tag);
let (turns, go) = held(&home);
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, EVE, channel, "", text, 5), channel_type);
turns.recv_timeout(WAIT).expect("the turn started");
let socket = home.path().join("run/gateway/gateway.sock");
let session = format!("mm-{root}");
let asking = ask(&socket, pending(42, &session, r#"{"path":"/n/a"}"#)).unwrap();
Stage {
_home: home,
mm,
_running: running,
ws,
go,
asking,
root,
}
}
/// EVE's posts in `channel`, redirected: who asked, the approval, and the note, in that order.
fn redirected(s: &Stage, channel: &str, place: &str) {
let posts = s.mm.wait_posts(3, WAIT);
assert_eq!(posts.len(), 3, "{posts:?}");
assert_eq!(
(posts[0].0.as_str(), posts[0].1.as_str()),
(DM, ""),
"a new thread in the approver's direct messages"
);
assert!(
posts[0].2.starts_with(&format!(
"**Approval 42** was asked by @user-e000 in {place} with the bot. Their message:\n"
)),
"{}",
posts[0].2
);
assert_eq!(
(posts[1].0.as_str(), posts[1].1.clone()),
(DM, FakeMm::post_id(0)),
"the approval in that thread"
);
assert!(posts[1].2.starts_with("**Approval 42**"), "{}", posts[1].2);
assert!(
posts[1]
.2
.contains("Reply in this thread with `!approve 42`")
);
assert_eq!(
posts[2],
(
channel.to_string(),
s.root.clone(),
"waiting for approval 42: it was sent to an approver".to_string()
),
"the note, in the asking thread"
);
assert_eq!(s.asking.posts, [FakeMm::post_id(0), FakeMm::post_id(1)]);
}
#[test]
fn a_direct_message_from_a_non_approver_goes_to_the_approver_who_answers_it_there() {
let mut s = stage("rd-dm", EVE_DM, "D", "read my notes");
redirected(&s, EVE_DM, "a direct message");
assert!(
s.mm.posts()[0].2.contains("\n```\nread my notes\n```"),
"the prompt, fenced: {}",
s.mm.posts()[0].2
);
let thread = FakeMm::post_id(0);
s.ws.posted(&post(&id('c', 1), KYLE, DM, &thread, "!approve 42", 6), "D");
let reply = s.asking.answer(WAIT).expect("the answer reaches brokerd");
assert_eq!(
(reply.approval, reply.approved, reply.by.as_str()),
(42, true, KYLE)
);
s.asking.outcome(GatewayOutcome::Runs {});
let edits = s.mm.wait_edits(1, WAIT);
assert_eq!(
edits[0].0,
FakeMm::post_id(1),
"the approval's post is marked"
);
let approval_text = s.mm.posts()[1].2.clone();
assert!(
edits[0].1.starts_with(&approval_text) && edits[0].1.len() > approval_text.len(),
"its own text, with the ending: {}",
edits[0].1
);
s.go.send(()).unwrap();
}
#[test]
fn a_long_prompt_is_cut() {
let long = "y".repeat(2_500);
let s = stage("rd-long", EVE_DM, "D", &long);
redirected(&s, EVE_DM, "a direct message");
let first = &s.mm.posts()[0].2;
assert!(first.contains(&"y".repeat(2_000)), "{first}");
assert!(!first.contains(&"y".repeat(2_001)), "{first}");
assert!(first.ends_with("\n(… 500 more characters)"), "{first}");
s.go.send(()).unwrap();
}
#[test]
fn a_group_message_goes_to_the_approver_with_the_mention_stripped() {
let s = stage("rd-group", GROUP, "G", "@boxmaker-straylight read my notes");
redirected(&s, GROUP, "a group message");
let first = &s.mm.posts()[0].2;
assert!(first.contains("\n```\nread my notes\n```"), "{first}");
s.go.send(()).unwrap();
}
#[test]
fn in_a_channel_it_stays_in_the_thread() {
let s = stage(
"rd-channel",
CHANNEL,
"O",
"@boxmaker-straylight read my notes",
);
s.mm.wait_posts(1, WAIT);
std::thread::sleep(Duration::from_millis(300));
let posts = s.mm.posts();
assert_eq!(posts.len(), 1, "{posts:?}");
assert_eq!(
(posts[0].0.as_str(), posts[0].1.as_str()),
(CHANNEL, s.root.as_str())
);
assert!(posts[0].2.starts_with("**Approval 42**"), "{}", posts[0].2);
s.go.send(()).unwrap();
}