M5b plan: task 02, the secret store moves to bxcore

The task moves gatewayd's secret store to bxcore::secrets behind the
Cargo feature `secrets` (spec, section 5). The moved module is given
whole; SecretSpec and SecretSource move with it, so gatewayd's config
keeps its [secrets.<name>] table with the per-secret type from bxcore.
load and read_checked take the role's name first, so the three
sentences that named gatewayd literally now name the role; gatewayd
passes "gatewayd" and its bytes are unchanged (its tests/main.rs pins
them and is not touched). A given loopd test parses the three
manifests (loopd's, the workspace entry, bxcore's features) and checks
the cfg line in bxcore's lib.rs; the task also checks `cargo tree -p
loopd -e features` and builds loopd on its own.

Checked: the reference, replayed from the task text applied
mechanically to the start tree (master plus task 01), is byte for byte
the reference; every before block found exactly once there. Step 2's
failures recorded from that tree: ten test targets with E0432 on
`bxcore::secrets`, two of four no_secrets tests. make gate on titan's
toolchain: gate: ok, 963 tests (956 + 3 + 4), clippy clean, /tmp
unchanged across the run. 23 one-line mutations of the reference, all
caught; removing the zeroize wrapper is not observable and not counted. Given
files pass fmt and the line limit; no chat-template tags.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-26 02:56:26 -07:00
co-authored by Claude Opus 5.5
parent fa5bcae503
commit 0f1de0d6d4
10 changed files with 2176 additions and 0 deletions
@@ -0,0 +1,247 @@
//! `gatewayd.toml` (M4a spec, section 3). Do not edit.
#[path = "support/tmp.rs"]
mod tmp;
use std::path::PathBuf;
use bxcore::secrets::SecretSource;
use gatewayd::config::{Config, ConfigError, ServerUrl, parse_url, valid_id};
use tmp::TempDir;
const OWNER: &str = "abcdefghijklmnopqrstuvwxyz";
const CHANNEL: &str = "0123456789abcdefghijklmnop";
fn minimal() -> String {
format!(
"[mattermost]\nurl = \"https://straylight.scylla-hammerhead.ts.net\"\n\
[secrets.mattermost_token]\ncredential = \"mattermost-token\"\n\
[allow]\nusers = [\"{OWNER}\"]\n"
)
}
fn load(text: &str) -> Result<Config, ConfigError> {
let dir = TempDir::new("cfg");
let path = dir.write("gatewayd.toml", text);
Config::load(&path)
}
fn invalid(text: &str) -> String {
match load(text) {
Err(ConfigError::Invalid(_, why)) => why,
other => panic!("expected Invalid for {text:?}, got {other:?}"),
}
}
#[test]
fn a_minimal_config_gets_every_default() {
let c = load(&minimal()).unwrap();
assert_eq!(
c.server().unwrap(),
ServerUrl {
tls: true,
host: "straylight.scylla-hammerhead.ts.net".to_string(),
port: 443
}
);
assert_eq!(c.mattermost.ca_file, None);
assert_eq!(
c.token_source().unwrap(),
SecretSource::Credential("mattermost-token".to_string())
);
assert_eq!(c.allow.users, vec![OWNER.to_string()]);
assert!(c.allow.channels.is_empty());
assert_eq!((c.limits.queue, c.limits.typing_every_ms), (20, 3_000));
assert_eq!(
(c.limits.ping_every_ms, c.limits.dead_after_ms),
(30_000, 60_000)
);
let home = std::env::var_os("BOXMAKER_HOME")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("/var/lib/boxmaker"));
assert_eq!(c.loop_socket(), home.join("run/loop/loop.sock"));
assert_eq!(c.state_path(), home.join("gateway/state.json"));
}
#[test]
fn every_value_can_be_set() {
let text = format!(
"[mattermost]\nurl = \"http://127.0.0.1:8065\"\nca_file = \"/etc/boxmaker/ca.pem\"\n\
[secrets.mattermost_token]\nfile = \"/home/k/.config/boxmaker/token\"\n\
[allow]\nusers = [\"{OWNER}\"]\nchannels = [\"{CHANNEL}\"]\n\
[loop]\nsocket = \"/run/l.sock\"\n[paths]\nhome = \"/h\"\n\
[limits]\nqueue = 5\ntyping_every_ms = 1\nping_every_ms = 2\ndead_after_ms = 3\n"
);
let c = load(&text).unwrap();
assert_eq!(
c.server().unwrap(),
ServerUrl {
tls: false,
host: "127.0.0.1".to_string(),
port: 8065
}
);
assert_eq!(
c.mattermost.ca_file,
Some(PathBuf::from("/etc/boxmaker/ca.pem"))
);
assert_eq!(
c.token_source().unwrap(),
SecretSource::File(PathBuf::from("/home/k/.config/boxmaker/token"))
);
assert_eq!(c.allow.channels, vec![CHANNEL.to_string()]);
assert_eq!(c.loop_socket(), PathBuf::from("/run/l.sock"));
assert_eq!(c.state_path(), PathBuf::from("/h/gateway/state.json"));
assert_eq!(
(
c.limits.queue,
c.limits.typing_every_ms,
c.limits.ping_every_ms,
c.limits.dead_after_ms
),
(5, 1, 2, 3)
);
}
#[test]
fn an_env_secret() {
let text = minimal().replace(
"credential = \"mattermost-token\"",
"env = \"BOXMAKER_MM_TOKEN\"",
);
assert_eq!(
load(&text).unwrap().token_source().unwrap(),
SecretSource::Env("BOXMAKER_MM_TOKEN".to_string())
);
}
#[test]
fn urls() {
for (url, tls, host, port) in [
("https://a.example", true, "a.example", 443),
("https://a.example:8443", true, "a.example", 8443),
("http://localhost", false, "localhost", 80),
("http://127.0.0.1:8065", false, "127.0.0.1", 8065),
] {
assert_eq!(
parse_url(url),
Ok(ServerUrl {
tls,
host: host.to_string(),
port
}),
"{url}"
);
}
for url in [
"",
"a.example",
"ftp://a.example",
"https://",
"https://a.example/",
"https://a.example/api",
"https://A.example",
"https://a.example:0",
"https://a.example:65536",
"https://a.example:0443",
"https://a.example:",
"https://user@a.example",
"https://a.example?x",
"https://.a.example",
"https://a.example.",
"https://[::1]:443",
"https:// a.example",
] {
assert!(parse_url(url).is_err(), "{url:?} must be refused");
}
}
#[test]
fn ids() {
assert!(valid_id(OWNER));
assert!(valid_id(CHANNEL));
for id in [
"",
"abc",
"abcdefghijklmnopqrstuvwxyZ",
"abcdefghijklmnopqrstuvwxy-",
"abcdefghijklmnopqrstuvwxyza",
] {
assert!(!valid_id(id), "{id:?}");
}
}
#[test]
fn bad_values_are_named() {
let token = "credential = \"mattermost-token\"";
let cases: Vec<(String, &str)> = vec![
(
minimal().replace(
"https://straylight.scylla-hammerhead.ts.net",
"https://x.example/path",
),
"url",
),
(
minimal()
.replace(
"[secrets.mattermost_token]",
"[mattermost2]\n[secrets.other]",
)
.replace("[mattermost2]\n", ""),
"mattermost_token",
),
(
minimal().replace(token, "credential = \"a b\""),
"credential",
),
(minimal().replace(token, "env = \"lower\""), "env"),
(
minimal().replace(token, "file = \"relative/token\""),
"absolute",
),
(
minimal().replace(token, "credential = \"x\"\nenv = \"Y\""),
"exactly one",
),
(minimal().replace(token, ""), "exactly one"),
(
minimal().replace(&format!("[\"{OWNER}\"]"), "[]"),
"users is empty",
),
(minimal().replace(OWNER, "tooshort"), "not a Mattermost id"),
(
format!("{}channels = [\"NOTANID\"]\n", minimal()),
"not a Mattermost id",
),
(
minimal().replace("[mattermost]\n", "[mattermost]\nca_file = \"ca.pem\"\n"),
"ca_file",
),
(format!("{}[limits]\nqueue = 0\n", minimal()), "queue"),
(
format!("{}[limits]\ndead_after_ms = 0\n", minimal()),
"dead_after_ms",
),
];
for (text, word) in cases {
let why = invalid(&text);
assert!(why.contains(word), "{word}: {why}");
}
}
#[test]
fn unknown_keys_and_missing_tables_are_parse_errors() {
for text in [
format!("{}[allow2]\n", minimal()),
minimal().replace("[mattermost]\n", "[mattermost]\nproxy = \"x\"\n"),
minimal().replace(
"credential = \"mattermost-token\"",
"credential = \"t\"\nkeyring = \"x\"",
),
minimal().replace(&format!("[allow]\nusers = [\"{OWNER}\"]\n"), ""),
format!("{}[limits]\nqueue = -1\n", minimal()),
] {
assert!(matches!(load(&text), Err(ConfigError::Parse(..))), "{text}");
}
}
@@ -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 bxcore::secrets::Secret;
use gatewayd::config::ServerUrl;
use gatewayd::mm::MmError;
use gatewayd::mm::rest::Client;
use gatewayd::net::Connector;
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,242 @@
//! Mattermost's REST calls against a scripted server: the requests on the wire, and what a 401,
//! 403, 429, 5xx or 404 does (M4a spec, sections 5 and 9). 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::sync::mpsc::Receiver;
use std::time::{Duration, Instant};
use bxcore::secrets::Secret;
use gatewayd::config::ServerUrl;
use gatewayd::mm::MmError;
use gatewayd::mm::rest::Client;
use gatewayd::net::Connector;
use http_server::{Request, reply, serve_http};
use serde_json::json;
use tls_server::{fixture, server_config};
const BOT: &str = "b0000000000000000000000000";
const KYLE: &str = "k0000000000000000000000000";
const DM: &str = "d0000000000000000000000000";
const POST: &str = "p0000000000000000000000001";
fn client(addr: SocketAddr, tls: bool) -> Client {
let url = ServerUrl {
tls,
host: "localhost".to_string(),
port: addr.port(),
};
let ca = tls.then(|| fixture("test-ca.pem"));
let connector = Connector::new(url, ca.as_deref()).unwrap();
Client::new(
connector,
Secret::new("TOKEN".to_string()),
Duration::from_secs(5),
)
}
fn me_body() -> String {
json!({"id": BOT, "username": "boxmaker-straylight", "roles": "system_user", "is_bot": true})
.to_string()
}
fn requests(rx: &Receiver<Request>) -> Vec<Request> {
rx.try_iter().collect()
}
#[test]
fn me_plain_and_over_tls() {
for tls in [false, true] {
let config = tls.then(|| server_config("server"));
let (addr, rx) = serve_http(config, |_, _| reply(200, "", &me_body()));
let me = client(addr, tls).me().unwrap();
assert_eq!(
(me.id.as_str(), me.username.as_str()),
(BOT, "boxmaker-straylight")
);
let r = rx.recv_timeout(Duration::from_secs(5)).unwrap();
assert_eq!(
(r.method.as_str(), r.path.as_str()),
("GET", "/api/v4/users/me")
);
assert!(
r.head.contains("Authorization: Bearer TOKEN\r\n"),
"{}",
r.head
);
assert!(
r.head
.contains(&format!("Host: localhost:{}\r\n", addr.port())),
"{}",
r.head
);
}
}
#[test]
fn create_post_sends_the_thread_and_reads_the_post_back() {
let (addr, rx) = serve_http(None, |_, r| {
let mut p = r.json();
p["id"] = json!(POST);
p["user_id"] = json!(BOT);
p["create_at"] = json!(5);
reply(201, "", &p.to_string())
});
let post = client(addr, false)
.create_post(DM, KYLE, "an answer\nin two lines")
.unwrap();
assert_eq!((post.id.as_str(), post.root_id.as_str()), (POST, KYLE));
let r = rx.recv_timeout(Duration::from_secs(5)).unwrap();
assert_eq!(
(r.method.as_str(), r.path.as_str()),
("POST", "/api/v4/posts")
);
assert!(
r.head.contains("Content-Type: application/json\r\n"),
"{}",
r.head
);
assert_eq!(
r.json(),
json!({"channel_id": DM, "root_id": KYLE, "message": "an answer\nin two lines"})
);
}
#[test]
fn posts_since_and_the_direct_channel() {
let (addr, rx) = serve_http(None, |_, r| {
if r.path.contains("/posts?since=") {
reply(200, "", r#"{"order":[],"posts":{}}"#)
} else {
reply(201, "", &json!({"id": DM, "type": "D"}).to_string())
}
});
let c = client(addr, false);
assert!(
c.posts_since(DM, 1_758_650_000_000)
.unwrap()
.posts
.is_empty()
);
assert_eq!(c.direct_channel(BOT, KYLE).unwrap(), DM);
let rs = requests(&rx);
assert_eq!(
rs[0].path,
format!("/api/v4/channels/{DM}/posts?since=1758650000000")
);
assert_eq!(
(rs[1].method.as_str(), rs[1].path.as_str()),
("POST", "/api/v4/channels/direct")
);
assert_eq!(rs[1].json(), json!([BOT, KYLE]));
}
#[test]
fn a_channel_that_is_not_an_id_is_never_sent() {
let (addr, rx) = serve_http(None, |_, _| reply(200, "", r#"{"order":[],"posts":{}}"#));
let err = client(addr, false)
.posts_since("../users/me?x=", 0)
.unwrap_err();
assert!(matches!(err, MmError::Json(_)), "{err}");
std::thread::sleep(Duration::from_millis(100));
assert!(requests(&rx).is_empty());
}
#[test]
fn a_refused_token_stops_at_once() {
for status in [401, 403] {
let (addr, rx) = serve_http(None, move |_, _| {
reply(
status,
"",
r#"{"id":"api.context.session_expired.app_error"}"#,
)
});
let err = client(addr, false).me().unwrap_err();
assert!(matches!(err, MmError::Auth(s) if s == status), "{err}");
std::thread::sleep(Duration::from_millis(100));
assert_eq!(requests(&rx).len(), 1, "no retry on {status}");
}
}
#[test]
fn a_server_error_is_tried_twice_more() {
let (addr, rx) = serve_http(None, |n, _| {
if n < 2 {
reply(503, "", "{}")
} else {
reply(200, "", &me_body())
}
});
assert!(client(addr, false).me().is_ok());
assert_eq!(requests(&rx).len(), 3);
let (addr, rx) = serve_http(None, |_, _| reply(502, "", "{}"));
let err = client(addr, false).me().unwrap_err();
assert!(matches!(err, MmError::Status(502, _)), "{err}");
std::thread::sleep(Duration::from_millis(100));
assert_eq!(requests(&rx).len(), 3);
}
#[test]
fn a_rate_limit_is_waited_out() {
let (addr, rx) = serve_http(None, |n, _| {
if n == 0 {
reply(429, "X-Ratelimit-Reset: 1\r\n", "{}")
} else {
reply(200, "", &me_body())
}
});
let started = Instant::now();
assert!(client(addr, false).me().is_ok());
assert!(
started.elapsed() >= Duration::from_millis(900),
"{:?}",
started.elapsed()
);
assert_eq!(requests(&rx).len(), 2);
let (addr, _rx) = serve_http(None, |_, _| reply(429, "X-Ratelimit-Reset: 1\r\n", "{}"));
assert!(matches!(
client(addr, false).me(),
Err(MmError::RateLimited(_))
));
}
#[test]
fn other_statuses_keep_the_start_of_the_body() {
let long = format!("{{\"message\":\"{}\"}}", "x".repeat(1000));
let (addr, _rx) = serve_http(None, move |_, _| reply(404, "", &long));
let Err(MmError::Status(404, body)) = client(addr, false).me() else {
panic!("not a 404")
};
assert_eq!(body.chars().count(), 200);
}
#[test]
fn answers_that_are_not_what_we_expect() {
for body in [
"not json",
r#"{"id":"short","username":"x"}"#,
r#"{"id":"b0000000000000000000000000","username":""}"#,
] {
let (addr, _rx) = serve_http(None, move |_, _| reply(200, "", body));
assert!(
matches!(client(addr, false).me(), Err(MmError::Json(_))),
"{body}"
);
}
}
#[test]
fn nobody_listening_is_a_network_error() {
let port = std::net::TcpListener::bind("127.0.0.1:0")
.unwrap()
.local_addr()
.unwrap();
assert!(matches!(client(port, false).me(), Err(MmError::Net(_))));
}
@@ -0,0 +1,153 @@
//! 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 bxcore::Turn;
use bxcore::secrets::Secret;
use gatewayd::config::Config;
use gatewayd::serve::{Stop, Tuning, run};
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);
// Wait for `run` to finish before the test's home is removed, or it can recreate its
// socket directory there afterwards (tip T44). The wait has a limit (T27).
if let Some(handle) = self.handle.take() {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
while !handle.is_finished() && std::time::Instant::now() < deadline {
std::thread::sleep(std::time::Duration::from_millis(10));
}
if handle.is_finished() {
let _ = handle.join();
}
}
}
}
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)
}